Content Management that can withstand the plundering hoards

Raw objects, repeated blocks and package reference

For browser tasks start with downloads and packages. This reference defines object fields, repeater markup and package authoring.

Viking can store downloadable files with descriptive content, repeat a block layout for matching content, and install portable page-type, renderer, block and theme packages. These packages form the installation contract for a future Viking Forge. This release supports local ZIP upload and export; it does not include a hosted forge, catalogue, remote download client, signatures or automatic updates.

Downloadable objects

Create a page and choose Raw downloadable object, then select the file. The object editor provides a Markdown description, a replacement-file upload and related-image uploads. Save the draft and publish as usual.

The object's URI (for example /downloads/linux) identifies its descriptive page. The file itself stays unchanged at /api/assets/<id>/content. Raw uploads accept any file type, up to 50 MiB, and are served as application/octet-stream attachments with their original filename, including on the public frontend. They never become executable website content. Descriptions and related images render on the object's page and can supply repeater fields. Replacing a file creates a new immutable asset; existing releases retain their original bytes. Remove an object's references before removing its file or related images from media storage.

The API uses the existing OAuth and CSRF rules. For automation, upload a file with POST /api/assets?raw=true, multipart field file, then use the returned asset ID in POST /api/pages:

{
  "title": "Linux application",
  "path": "/downloads/linux",
  "contentVersion": 1,
  "format": "raw",
  "source": "The **Linux** build. Requires a 64-bit system.",
  "object": {
    "assetId": "returned-raw-asset-id",
    "name": "application-linux.bin",
    "images": ["uploaded-image-asset-id"]
  }
}

images is optional and holds up to 100 image IDs from normal image uploads. The main asset must have been uploaded with raw=true. Update the object with PUT /api/pages/<page-id> and the same fields. URI paths use Viking's existing page path rules; the original download filename can contain an extension.

For a named raster image, set object.publishImage: true, use an asset from a normal POST /api/assets upload and leave source, layout and related images empty. Both the object name and URL extension must match the validated image type. Publication writes the original bytes directly at the page path, without a trailing slash or HTML wrapper. Indexes, navigation and repeaters use that named URL; repeater image and download bindings also use it. The object's menu and disabled settings still apply. See the named-image API example.

Repeat a group of blocks

Add Site → Repeat files to a visual page. Its children form the template: add, rearrange and style them in GrapesJS. Select the repeater to configure its URI path, content kind and whether to include all descendants. An empty URI uses the current page's path. The default includes direct children of all kinds. Menu visibility and navigation ancestry do not affect matching.

URI ancestors do not need stored pages. A homepage repeater with an empty URI and all descendants enabled includes /poetry/lights-out without a /poetry page. The editor stores the recursive setting as the strings true or false; the renderer also accepts a present empty/bare recursive attribute as enabled for compatibility with older GrapesJS saves. An absent attribute defaults to direct children.

The image field prefers the featured image, falling back to a raw object's first related image. featured-image and featured-image-alt select only the cover and its alt text. Bind data-viking-alt="featured-image-alt" on an image, or use <figure data-viking-featured-image=""></figure> inside the card template. Missing image bindings produce no img element. Theme layouts use the featured-image slot; see content metadata.

Preview and publication expand the template once per matching page or raw object. Matches sort by title ignoring case, then by URI. The current page and the selected branch root are excluded; an empty match set produces no cards. Authoring source retains the template. Nested repeaters are rejected, and each page's expansion is limited to 10,000 nodes. Repeated IDs become shared classes; ID selectors in the page stylesheet are rewritten so GrapesJS styling survives. Use the generated field traits to change a text, link or image binding.

The equivalent HTML is:

<section data-viking-repeat="/downloads"
         data-viking-kind="raw"
         data-viking-recursive="false"
         data-viking-summary-unit="paragraphs"
         data-viking-summary-count="2">
  <article class="download-card">
    <h2><a data-viking-href="url" data-viking-field="title">Title</a></h2>
    <img data-viking-src="image" alt="">
    <p data-viking-field="summary">Description</p>
    <a data-viking-href="download">Download file</a>
  </article>
</section>

data-viking-kind accepts all, pages or raw. data-viking-recursive accepts true or false. data-viking-summary-unit accepts paragraphs (default) or words. data-viking-summary-count accepts a positive whole number (default 1). Absent or empty summary attributes use the defaults, preserving older templates. The limits apply to summary bindings in repeaters and all file gallery layouts. Summaries take nonempty paragraphs in document order, excluding headers, navigation and footers, and join their text with spaces. Word limits count whitespace-separated words across those paragraphs without splitting a word. Shorter content is returned in full; content without paragraphs has no summary.

Field Value
title Title, falling back to URI
url Descriptive page URL
path Canonical URI path
summary Leading paragraphs or words as plain text, according to the repeater's summary settings
download Raw object's file URL; empty for other pages
image Raw object's first related image URL; empty if absent
name Raw object's filename; empty for other pages

data-viking-field replaces an element's text. data-viking-href fills its href, and data-viking-src fills its src. Values are escaped as text or attributes and pass through the normal publication sanitizer. Missing URL values remove the URL attribute. Index and repeater templates do not supply summaries for other cards.

Gallery blocks retain the ordinary repeater attributes and card field bindings. Set data-viking-gallery on the repeater's section or div to carousel, coverflow, ring or masonry. Use one direct child element as the card template; controls belong to the runtime and must not be included in the template. The four separate example plugins provide starting templates, styles and build scripts.

Attribute Behaviour
data-viking-visible Carousel's maximum visible cards or masonry's maximum columns; 1–12, default 3
data-viking-interval Autoplay interval in milliseconds; 0 disables it, otherwise 1000–60000; default 4000
data-viking-card-width Maximum 3D card width, 160–640 pixels; default 280
data-viking-peek Cover flow's visible neighbours per side, 1–5; default 2
aria-label Accessible name identifying the gallery

Publication expands the repeater before sanitizing the output. Only the four known presentation names and numeric settings survive as gallery attributes. The fixed CMS runtime then enhances the generated cards; it never executes code from a ZIP or template. Ordinary pages contain no gallery script. Gallery pages embed the runtime once regardless of the number of galleries. They are static, self-contained releases and require no CMS requests for navigation/rotation.

The 2D carousel moves existing card nodes between the list ends, cover flow recycles edge positions behind the current card, and the ring rotates through unbounded angles. All maintain circular previous/next selection without making duplicate card links. Off-screen/non-current 3D cards are inert for keyboard and assistive navigation. Visible carousel cards remain interactive.

Preview fetches the runtime from GET /api/content/gallery-runtime using the parent's authenticated session and embeds those exact bytes in the opaque preview. The SPA CSP contains its SHA-256 hash; it permits neither arbitrary inline scripts nor same-origin access by that frame. Card templates and renderer output still pass through the normal script/event/URL sanitizer.

Install and distribute packages

Open Plugins and packages, select a ZIP and wait for installation. Renderer packages become choices in the new-page format selector. Block packs add categories to the visual editor's block panel. Theme packages appear in Themes, where you can preview and select them for the site. Publish to update the public website. Package names and IDs do not depend on their ZIP filename.

The API supports:

Operation Endpoint
List installed page-type/renderer/block packages GET /api/plugins
Install a plugin or existing theme ZIP POST /api/plugins/install (raw ZIP body)
Export a page-type/renderer/block package GET /api/plugins/<id>/bundle
Remove a page-type/renderer/block package DELETE /api/plugins/<id>
List/export/remove themes Existing /api/themes endpoints

Installation refuses an existing ID with 409. Version 1 has no in-place upgrade operation. A page-type or renderer package used by a page cannot be removed. Inserted blocks are ordinary editable page content and remain after removing their package. Publication snapshots retain installed renderer bytes, and installed packages contribute to the draft revision hash. Packages are stored on private CMS storage; executable modules are never copied into public releases.

Plugin ZIPs contain a root plugin.json and, for renderers only, a root renderer.wasm. Unknown entries, traversal paths, symlinks, duplicate names, unknown manifest fields and unsupported schemas are rejected. The compressed ZIP, expanded contents and stored JSON representation each have a 32 MiB limit. Themes retain their existing 6 MiB bundle limit. IDs start with a lowercase letter and use lowercase letters, digits, _ or -, up to 64 characters. Versions use major.minor.patch.

Block packages

A minimal plugin.json:

{
  "schemaVersion": 1,
  "id": "notice-blocks",
  "name": "Notice blocks",
  "version": "1.0.0",
  "kind": "blocks",
  "blocks": [
    {
      "id": "notice",
      "label": "Notice",
      "html": "<article class=\"notice\"><h2>Notice</h2><p>Edit me.</p></article>",
      "css": ".notice{padding:1rem;border:1px solid #999}"
    }
  ]
}

Block IDs must be unique within the package. Packages support 1–100 blocks, with HTML templates and optional CSS. Scripts and browser plugin code are not supported. CSS is sanitized at installation, HTML is sanitized before entering GrapesJS, and published content uses the normal HTML/CSS sanitizer. Optional package metadata is author, license and description.

Build the supplied Callouts example from repository root (Python 3):

examples/plugins/callouts/build.sh

Upload dist/examples/callouts-1.0.0.viking-plugin.zip through Plugins and packages. Every example's build.sh accepts an optional output directory; the example guide documents all compilers, shared Docker verification and generic-registry output.

Renderer packages

Renderer packages use the WASI Preview 1 command ABI: _start reads source bytes from standard input and writes an HTML fragment to standard output. A nonzero exit, trap or timeout fails preview/publication. Diagnostics belong on standard error. The CMS supplies no mounted filesystem, inherited environment, network or process access. When an authenticated editor context is available, the only environment variable is VIKING_EDITOR, a JSON object containing issuer, subject and name. It identifies the current editor/publisher, not the page creator. Background rendering may omit it. Never derive persistent authorship from the identity of whoever happens to publish; creator fields retain the identity from creation/conversion. Execution has a 3-second deadline, a 256 MiB linear memory limit and an 8 MiB limit for each output stream. Output HTML is sanitized by the same pipeline as built-in page formats.

The manifest has kind: "renderer" and no blocks. Bundle the compiler's WASI command as renderer.wasm. Source pages store format: "plugin:<package-id>" and their original source, with no HTML/CSS. The editor accepts pasted source or a source-file upload. RST or RTF renderers can implement this ABI; Viking does not bundle an RST or RTF converter.

The example renderers demonstrate different tasks and implementation languages:

Package Language Input and result
source-text Go Escaped source text inside a preformatted block
checklist-c C - [ ] / - [x] lines become inert task checkboxes; invalid lines fail
csv-table-rust Rust CSV header/data rows with quoted fields become an escaped HTML table

From repository root, with the compilers described in the build guide:

examples/plugins/source-text/build.sh
examples/plugins/checklist-c/build.sh
examples/plugins/csv-table-rust/build.sh

Use scripts/build-examples.sh instead to run the pinned-version toolchains in Docker, verify actual CMS installation/rendering and export all plugin/theme ZIPs. Install a renderer ZIP, choose its page format, paste/upload source, preview and publish. A script packages only plugin.json and renderer.wasm; compiler sources and test fixtures are not installed. Browser-targeted Wasm and native Go shared libraries do not implement this WASI command ABI.

Page-type packages

A page type is independent of format: it adds metadata and declarative editing behaviour to HTML/Markdown content or defines a metadata-only navigation object. The Blog manifest is a complete working example. Build it with examples/plugins/blog/build.sh. A page-type ZIP contains only plugin.json, with kind: "page-type", a pageType definition and optional ordinary blocks. It has no WASI module or browser script. Blocks use pageType.blockCategory as their editor section and are available in visual pages and theme templates.

The definition specifies version: 1, supported formats (html/markdown, or only none for metadata-only types), defaultFormat, defaultPath, convertLabel, blockCategory, fields and bindings. Field IDs and binding names follow package-ID rules. At most 32 fields, 64 bindings and 100 blocks are supported. Duplicate/unknown declarations and unsupported versions are rejected.

Field type Value and behaviour
creator Read-only {issuer, subject, name} derived from the authenticated editor on creation/conversion; later saves preserve it and ignore caller-supplied authorship.
datetime Editable RFC3339 timestamp with explicit timezone; defaults to current UTC when first added.
text Editable text, at most 8192 bytes.
url Editable absolute HTTP/HTTPS URL, at most 8192 bytes; no credentials, whitespace or control characters. Validation never fetches the URL.

Each field declares id, label, type and optional required. Bindings declare name, field and part: name for creators, date or time for timestamps, and no part for text or URLs. Creators expose only their display name in rendered bindings. The generic registry supplies form controls, conversion actions, repeater filters and validation from the installed definitions; no Blog ID is special-cased in CMS code.

Metadata-only types use formats: ["none"], defaultFormat: "none" and navigationUrlField naming a required url field. Their objects use the existing page API with format: "none", pageType: "plugin:<id>", typeVersion: 1 and fields. They retain a unique internal path, title, disabled state and menu settings. Source, HTML, CSS, page layout, featured images and raw assets are rejected. The destination replaces the internal URL in navigation and is never marked as the current page. No page, redirect, sitemap entry, index entry or repeater card is generated. Direct object previews return empty HTML/CSS; preview an ordinary page with a navigation theme to review the menu. The External link package is the complete working example.

Typed pages retain the existing source fields and add:

{
  "pageType": "plugin:blog",
  "typeVersion": 1,
  "format": "markdown",
  "source": "By [author] on [date] at [time].",
  "path": "/journal/2026-11-03/12-13-14/my-post",
  "pathTemplate": "/journal/[date]/[time]/my-post",
  "fields": {
    "author": {"issuer": "https://identity.example", "subject": "editor-id", "name": "Editor"},
    "published-at": "2026-11-03T12:13:14Z"
  }
}

Create/update requests can put the template directly in path. Responses retain pathTemplate alongside a resolved canonical path, so page lists and menus have usable URLs. If pathTemplate is supplied, it is authoritative; send an empty string to switch to a literal URL. Only date/time bindings may appear as URI tokens, and a tokenized URI requires a literal page-name suffix. Resolved paths pass ordinary path/reserved-name validation and collision checks. Publication re-evaluates templates from snapshot properties and rejects duplicate output URLs; it never substitutes the publication wall clock. This does not schedule posts.

Use <span data-viking-type-field="author">[author]</span> for a field block. Text tokens such as [author] also work in Markdown/HTML text outside code and pre; substitutions cannot change attributes or introduce markup. Fields inside repeaters use each entry's type values. Use data-viking-kind="plugin:blog" for Blog-only selection. The same registry supplies every gallery's type choices. Builtin pages still includes Blog posts and other non-raw pages. Repeater fields such as title, url and summary retain their existing meanings.

Core Go modules can read editorcontext.FromContext(ctx) for non-secret current editor attribution; auth populates it alongside auth.IdentityFrom(ctx). Core editor modules can use editor.vikingContext.getIdentity(); /api/me remains the authenticated browser identity endpoint. WASI renderers can read VIKING_EDITOR as described above. Tokens, session IDs, email and roles are not exposed through the shared extension context or stored creator fields.

Version 1 supports metadata decoration and generated controls. It rejects unknown content versions rather than guessing migrations; in-place package upgrades and content migrations remain separate work. Snapshot/export include the full installed definition and blocks, while public releases contain only the rendered output.

Theme images

Existing .viking-theme.zip bundles include templates, CSS, image/font bytes and preview fixtures in one file. In Themes → Shared stylesheet and bundled assets, upload an image to add it to the current template and bundle. References use viking-asset:<filename> and resolve to content-addressed URLs when published. Previews inline those bytes. PNG, JPEG, GIF, WebP and AVIF images and WOFF/WOFF2 fonts are supported, up to 2 MiB per asset. Themes must bundle their own media rather than reference a site's asset IDs.