Contents
You have images and documents on your website that change every so often — a current price list, this quarter's promo banner, the latest catalogue PDF. This guide sets up each of those as a content slot: one blog article that holds the file, and a component that pulls it onto any page. After setup, changing what visitors see is a two-minute job in the admin console. No page editing, no developer.
How it works
A blog article is really just a container with a title, some text, images and files attached to it. Nothing says it has to look like a blog post. Here we use one article as a permanent, named holder for a single asset.

The component stores only two things: a blog name and an article name. It fetches whatever that article holds at the moment the page is viewed.
Because the link is by name rather than by a copy of the file, the page never needs to be touched again. Swap the image on the article and every page carrying the component shows the new one. Use the same article on five pages and all five update at once.
Setting up a slot
Once per slot · steps run in order
Do this once for each piece of content you want to be able to swap. The examples use a blog named b1 with an article named test1.
1. Create the blog
/blogs/
Open that address in your admin console and choose Add new blog. You are asked for three things:
- Blog title — shown to people, e.g. “Website assets”.
- Blog name — the short identifier used in web addresses, e.g.
b1. This is what the component matches on. - Website — which website the blog belongs to.
One blog can hold every slot on the site, so in most cases you create a single blog here and add articles to it from then on.
2. Create the article
/blogs/b1/
Open the blog and choose Create new article. You are asked only for an Article title. Give it a name that describes the slot rather than the current content — “Current price list”, not “Price list March”.
CHECK THE ARTICLE'S WEB NAME
Kademi generates the article's web name from the title you type, so “Test 1” becomes test1. Open the new article and look at the Blog article name field on the General tab to see exactly what it came out as — that is the value you will select in the component's settings.
3. Attach the image or file
/blogs/b1/test1/
The article page has a row of tabs. Two of them matter here:
- Images — Add and crop image for a single image, or Bulk import images for several.
- Files — Add file for a PDF, spreadsheet or any other download.
An article can hold both at once, which is how a slot can offer a cover image and a downloadable document together.
While an article is published, the buttons for adding images and files are replaced by a notice. This is normal, and it is why the update routine below starts by unpublishing. A brand new article has not been published yet, so you can add files right away.
4. Publish the article
On the General tab, choose Publish. Publishing is what makes the article itself readable on the website, and it locks the attached files against accidental change.
5. Add the component to a page
Edit the page where the content should appear and open the component picker. Find Blog Article Asset, listed under the Blogs App category, and drag it into place.
Open its settings and choose your Blog and Article from the two dropdowns — the article list narrows to the blog you picked, so there are no names to type. Then set Show to an image, a file link, or both. Save the page.
PREREQUISITE.
The component comes with the Blogs app. If it is missing from the picker, the Blogs app is not enabled on that website — enable it in the website's app settings and reload the editor.
Updating the content
The routine · repeat as often as you like
This is the part your team does from now on. It happens entirely on the article page, and the website picks the change up immediately.
|
Unpublish
General tab → Unpublish. This unlocks the Images and Files tabs. |
Swap the asset
Delete the old image or file, add the new one. Keep the article's title and name as they are. |
Publish
General tab → Publish. The slot reappears on your pages, and is locked again until next time. |
↺ every change to this slot, forever — no page is ever edited again
The pages carrying the component need no attention at all. They read the article fresh each time somebody views them, so the new image or document is live the moment you publish.
The slot is empty while you work
A slot only shows published content, so between step 1 and step 3 the image or file disappears from every page using it. Nothing else on those pages is affected. Keep the gap short, or make the swap outside busy hours if the slot sits somewhere prominent.
The upside is that nothing reaches visitors until you choose. You can attach a new file, check it over, and release it by pressing Publish.
Publishing accepts a future date. Set one and the slot stays empty until that moment, then fills on its own — useful for a price list that should appear at the start of a quarter without anyone being at a keyboar
What keeps a slot working
A slot is only as stable as the name it is found by. Three habits are worth passing on to whoever maintains the content.
Never change the article's web name
The Blog article name field on the General tab is the address the component looks for. Change it and every page using the slot falls back to its empty state until you re-point the component. The Article title beside it is free to change as often as you like — it is only a label.
Don't delete the article to replace it
Deleting and recreating an article gives you a new web name, which breaks the link. Always edit the existing article in place, exactly as in the routine above.
The Groups tab on an article restricts who the article is for, and a slot honours it. Set a group filter and only people in those groups see the image or file; everyone else sees nothing in its place, and visitors who are not logged in never see it.
This makes a slot usable for content meant for one audience — a distributor price list, say — on a page that everyone can reach. Leave the filter empty and the slot is visible to all, including visitors who are not logged in
Component settings
Every setting on Blog Article Asset. Only the first three are normally needed.
| Setting | What it does | Leave blank to… |
|---|---|---|
| Blog | The blog holding your article. Lists every blog on the account. | — required |
| Article | The article to read from. Narrowed to the blog chosen above. | — required |
| Show | Whether to render the image, the file link, or both. | defaults to the image |
| Asset name | Picks one specific image or file by name, when the article holds several. | use the first image, and list every file |
| Image max width | Caps the image width in pixels. | fill the space available |
| File link text | Label for the download link, e.g. “Download the 2026 catalogue”. | show the file's own name |
Asset name is the setting worth knowing about. If a slot holds a cover image plus three regional price lists, give each file a predictable name on the article and place three components, each naming one file. Attaching a replacement under the same name updates just that one.
Troubleshooting
- The page shows “Choose a blog and an article in this component's settings.”
- The component could not find the article. Either its settings are incomplete, or the article's web name has changed. Open the component's settings and re-select the blog and article.
- Nothing appears at all, not even that message
- The article was found but is being withheld, which has three possible causes, in the order worth checking: the article is unpublished, or mid-swap; its Groups filter excludes the person viewing (test while logged out as well as logged in); or it simply holds nothing matching what Show is set to — check the Images and Files tabs.
- The old image is still showing
- Confirm the old image was deleted from the Images tab and not merely joined by the new one — with Asset name blank the component shows the first image on the article. Then reload with a cache-clearing refresh.
- I can't add an image or file to the article
- The article is published. Unpublish it on the General tab, make the change, then publish again.
- “Blog Article Asset” isn't in the component picker
- The Blogs app is not enabled on that website, or the site is running a version of it from before this component shipped.
The component, in full
Slots are one use of a general mechanism: a custom component is just a few files in a library, following a naming convention, plus one line to register it. Everything below is the working source of the component this guide describes — a reasonable starting point for building one that carries your own logic.
How the files are found
You never write file paths. You register a component id, and the platform derives every path from it. For the id blogArticleAsset registered under blogs/components:
| File | Role |
|---|---|
blogArticleAssetComponent.html |
Server-side render template |
blogArticleAssetSettings.html |
The settings panel shown in the editor |
blogArticleAssetComponent.js |
Editor-side wiring for that panel |
blogArticleAsset.png |
Thumbnail in the component picker |
All four live together in the library, under common/theme/apps/blogs/components/. Registration is a single line in the library's APP-INF/app.js — the last argument is the category the component is filed under in the picker:
controllerMappings.addComponent(
"blogs/components", // where the files live
"blogArticleAsset", // component id, and the basis of every filename
"html", // renders on website pages
"Shows an image and/or file link from one specific blog article",
"Blogs App" // picker category
);
Three mechanics worth knowing
These are the parts that are hard to infer from the code alone.
data- attribute on the component in the page. Before rendering, the platform strips data- and converts the rest from hyphens to camel case. So data-blog-name reaches the template as $blogName, and data-max-width as $maxWidth. Nothing declares this mapping — it is purely the naming.$services available, so a component can look things up for itself. This one calls $services.blogServices.findBlogByName() and then $services.blogManager.findBlogArticle(). That is the hook for your own logic: any service the platform exposes is reachable from these two files.$page, the page being viewed. When the editor refreshes a preview, it does not. Most blog components branch on #if($page) and show a placeholder in the editor. This component never reads $page at all — it finds its article by name — which is why its preview shows the real asset. Worth copying if your component's content does not depend on which page it sits on.Source
blogArticleAssetComponent.html
What renders on the page. Reads the settings, finds the article, checks the viewer may see it, writes the markup. Two things to copy: the false sentinel before each lookup, because Velocity's #set silently does nothing when the right-hand side is null — pre-setting false is what makes “not found” detectable; and the single canViewArticle call, which keeps the publish and audience rules in one place rather than spread through the template.
#**
* Renders an image and/or file link from ONE specific blog article, located by
* blog name + article name from the component's own settings.
*
* Settings are supplied as data attributes on the component div. The platform
* strips the "data-" prefix and converts the rest from lower-hyphen to
* lowerCamel, so data-blog-name arrives here as $blogName:
*
* data-blog-name blog web name (slug), eg "b1"
* data-article-name article web name (slug), eg "test1"
* data-render-as image | file | both (default: image)
* data-asset-name optional image/file name. Blank = first image / all files
* data-max-width optional max width in px applied to the image
* data-link-text optional label to use instead of the file name
*
* Unlike the other blog components this one never reads $page, so it resolves
* the same article on any page of the website AND inside the page editor.
*#
#set($renderAs = $formatter.ifNull($renderAs, "image"))
#set($bs = $services.blogServices)
#set($bm = $services.blogManager)
#set($blog = false)
#if($formatter.isNotEmpty($blogName))
#set($blog = $bs.findBlogByName($blogName))
#end
#set($article = false)
#if($blog && $formatter.isNotEmpty($articleName))
## findBlogArticle filters out soft-deleted articles; $blog.article() does not
#set($article = $bm.findBlogArticle($blog, $articleName))
#end
#if($article)
## canViewArticle applies BOTH the article's publish state and its audience
## (group) filter, so unpublished or targeted content is never leaked onto a
## page. Render nothing at all when it fails - no placeholder, since that
## would tell a visitor the content exists.
#if($bm.canViewArticle($article))
<div class="blog-article-asset">
#if($renderAs == "image" || $renderAs == "both")
#renderAssetImage($article)
#end
#if($renderAs == "file" || $renderAs == "both")
#renderAssetFiles($article)
#end
</div>
#end
#else
<div class="blog-article-asset blog-article-asset-empty text-muted">
<i class="fa fa-fw fa-picture-o"></i> Choose a blog and an article in this component's settings.
</div>
#end
#macro(renderAssetImage $art)
#set($img = false)
#if($formatter.isNotEmpty($assetName))
#set($img = $art.image($assetName))
#else
#set($img = $formatter.safeGet($art.images, 0))
#end
#if($img)
<img class="blog-article-asset-image img-responsive"
src="/_hashes/files/${img.imageHash}"
alt="$formatter.htmlEncode($art.title)"
#if($formatter.isNotEmpty($maxWidth)) style="max-width: ${maxWidth}px;" #end />
#end
#end
#macro(renderAssetFiles $art)
#set($files = $formatter.newArrayList())
#if($formatter.isNotEmpty($assetName))
#set($oneFile = $art.file($assetName))
#if($oneFile)
$formatter.call($files.add($oneFile))
#end
#else
#set($files = $art.files)
#end
#foreach($f in $files)
<a class="btn btn-default blog-article-asset-file" target="_blank"
href="/_hashes/files/${f.fileHash}"
download="$formatter.htmlEncode($f.name)">
<i class="fa fa-fw fa-file-o"></i>
#if($formatter.isNotEmpty($linkText))$formatter.htmlEncode($linkText)#else$formatter.htmlEncode($f.name)#end
</a>
#end
#end
The settings panel. A plain form. The component's JS reads and writes these controls by class name — nothing here submits on its own. It is rendered server-side, so it can query for data the editor needs to pick from.
#**
* Settings form for the blogArticleAsset component.
*
* Every blog and every article is rendered up-front; the component's JS filters
* the article list down to the selected blog, so picking an article needs no
* extra request and the editor never has to type a slug by hand.
*#
#parse("/theme/apps/admin/common-macros.html")
#set($bm = $services.blogManager)
<form class="form-horizontal">
<div class="form-group">
<label class="col-sm-12">Blog</label>
<div class="col-sm-12">
<select class="form-control select-blog">
<option value="">- Select a blog -</option>
#foreach($blog in $bm.blogs)
<option value="$formatter.htmlEncode($blog.name)">$formatter.htmlEncode($blog.title) ($formatter.htmlEncode($blog.name))</option>
#end
</select>
</div>
</div>
<div class="form-group">
<label class="col-sm-12">Article</label>
<div class="col-sm-12">
<select class="form-control select-article">
<option value="">- Select an article -</option>
#foreach($blog in $bm.blogs)
#foreach($article in $blog.articles)
#if(!$article.deleted)
<option value="$formatter.htmlEncode($article.name)"
data-blog="$formatter.htmlEncode($blog.name)">$formatter.htmlEncode($article.title) ($formatter.htmlEncode($article.name))</option>
#end
#end
#end
</select>
#subtext("Only articles in the selected blog are listed.")
</div>
</div>
<hr />
<div class="form-group">
<label class="col-sm-12">Show</label>
<div class="col-sm-12">
<select class="form-control select-render-as">
<option value="image">Image only</option>
<option value="file">File link only</option>
<option value="both">Image and file link</option>
</select>
</div>
</div>
<div class="form-group">
<label class="col-sm-12">Asset name</label>
<div class="col-sm-12">
<input type="text" class="form-control asset-name" />
#subtext("Optional. Name of a specific image or file on the article. Leave blank to use the first image / list all files.")
</div>
</div>
<div class="form-group image-setting">
<label class="col-sm-12">Image max width (px)</label>
<div class="col-sm-12">
<input type="number" class="form-control max-width" min="1" />
#subtext("Optional. Leave blank for full width.")
</div>
</div>
<div class="form-group file-setting">
<label class="col-sm-12">File link text</label>
<div class="col-sm-12">
<input type="text" class="form-control link-text" />
#subtext("Optional. Leave blank to show the file's own name.")
</div>
</div>
</form>
blogArticleAssetComponent.js (JavaScript)
Wiring the panel to the page. Registers against $.keditor.components. Every control writes a data- attribute onto the component and asks KEditor to re-render it, which is what makes the editor preview update as settings change.
(function ($) {
var KEditor = $.keditor;
var flog = KEditor.log;
var COMPONENT_ID = 'blogArticleAsset';
KEditor.components[COMPONENT_ID] = {
settingEnabled: true,
settingTitle: 'Blog Article Asset',
initSettingForm: function (form, keditor) {
flog('initSettingForm "' + COMPONENT_ID + '" component', form, keditor);
var self = this;
return $.ajax({
url: '_components/' + COMPONENT_ID + '?settings',
type: 'get',
dataType: 'html',
success: function (resp) {
form.html(resp);
form.find('.select-blog').on('change', function () {
// Changing blog invalidates the current article
form.find('.select-article').val('');
self.filterArticles(form, this.value);
self.setAttr(keditor, 'data-blog-name', this.value);
self.setAttr(keditor, 'data-article-name', '');
self.refresh(keditor);
});
form.find('.select-article').on('change', function () {
self.setAttr(keditor, 'data-article-name', this.value);
self.refresh(keditor);
});
form.find('.select-render-as').on('change', function () {
self.setAttr(keditor, 'data-render-as', this.value);
self.toggleGroups(form, this.value);
self.refresh(keditor);
});
form.find('.asset-name').on('change', function () {
self.setAttr(keditor, 'data-asset-name', this.value);
self.refresh(keditor);
});
form.find('.max-width').on('change', function () {
self.setAttr(keditor, 'data-max-width', this.value);
self.refresh(keditor);
});
form.find('.link-text').on('change', function () {
self.setAttr(keditor, 'data-link-text', this.value);
self.refresh(keditor);
});
}
});
},
showSettingForm: function (form, component, keditor) {
flog('showSettingForm "' + COMPONENT_ID + '" component', form, component, keditor);
var atts = keditor.getDataAttributes(component, null, false);
var blogName = atts['data-blog-name'] || '';
var renderAs = atts['data-render-as'] || 'image';
form.find('.select-blog').val(blogName);
this.filterArticles(form, blogName);
form.find('.select-article').val(atts['data-article-name'] || '');
form.find('.select-render-as').val(renderAs);
form.find('.asset-name').val(atts['data-asset-name'] || '');
form.find('.max-width').val(atts['data-max-width'] || '');
form.find('.link-text').val(atts['data-link-text'] || '');
this.toggleGroups(form, renderAs);
},
// Every article of every blog is rendered into the select; show only
// those belonging to the selected blog.
filterArticles: function (form, blogName) {
form.find('.select-article option[data-blog]').each(function () {
var opt = $(this);
opt.prop('hidden', opt.attr('data-blog') !== blogName);
});
},
toggleGroups: function (form, renderAs) {
form.find('.image-setting').toggleClass('hidden', renderAs === 'file');
form.find('.file-setting').toggleClass('hidden', renderAs === 'image');
},
setAttr: function (keditor, name, value) {
var component = keditor.getSettingComponent();
if (value === '' || value === null || typeof value === 'undefined') {
component.removeAttr(name);
} else {
component.attr(name, value);
}
},
refresh: function (keditor) {
var component = keditor.getSettingComponent();
keditor.initDynamicContent(component.find('[data-dynamic-href]'));
}
};
})(jQuery);
Component: blogArticleAsset · shipped with the Kademi Blogs app. Example addresses in this guide use blog “b1” and article “test1”; substitute your own.