Content Management that can withstand the plundering hoards

Uploading and publishing content from CI

Pipelines use the same authenticated CMS API as the editor. Get an OAuth2 access token from the identity provider with the client credentials grant, then send Authorization: Bearer <access_token> on every CMS request. Viking validates the token; the identity provider issues it. No interactive login, session cookie or CSRF bootstrap is needed for verified Bearer requests. Browser sessions still require CSRF protection.

Register a dedicated confidential service client at the provider with client credentials enabled. Configure its access tokens to include:

Tokens must be signed JWTs verifiable through the configured JWKS, or opaque tokens validated by the configured introspectionURL. See authentication. Use the provider's token endpoint and audience/scope settings; these differ by provider. The CMS does not register the pipeline client or grant it roles automatically. Roles grant CMS-wide access, not access restricted to one URI branch.

Prepare the job inputs

Run these examples in Bash, with curl supporting --fail-with-body, jq, trusted HTTPS CA certificates and network access from the runner to both IdP and CMS. Shell variables persist only within the same shell/job context. Configure these masked/protected CI settings as appropriate to your runner:

Variable Value to supply
CMS_URL External CMS origin with scheme and no trailing slash, such as https://cms.example.net; not the public site origin
OAUTH_TOKEN_URL Actual IdP token endpoint URL from provider configuration/discovery, not merely its issuer URL
CI_CLIENT_ID Dedicated confidential service client's registered ID
CI_CLIENT_SECRET That service client's secret; never a committed example value
OAUTH_SCOPE Optional provider-specific scopes for the requested access token

The audience is configured at the IdP (or through its documented token-request parameters); this generic request does not assume a provider-specific audience parameter. ACCESS_TOKEN is obtained below and ASSET/ASSET_URL are set by the upload response later. Do not print these credentials or enable shell tracing.

Example docs/start.md, docs/example.html and docs/diagram.png are input files from your content repository/job working directory, not fixtures shipped by Viking's docs. Create those files or replace the local paths with real inputs. The public URI /docs/start is independent of the local filename.

Store the service client's secret in masked CI variables. Use HTTPS. Obtain a token:

set -euo pipefail
# Configure CMS_URL, OAUTH_TOKEN_URL, CI_CLIENT_ID and CI_CLIENT_SECRET as CI variables.
# OAUTH_SCOPE is optional and provider-specific. Configure the API audience at the IdP.
ACCESS_TOKEN=$(curl --fail-with-body --silent --show-error \
  --user "$CI_CLIENT_ID:$CI_CLIENT_SECRET" \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode "scope=${OAUTH_SCOPE:-}" \
  "$OAUTH_TOKEN_URL" | jq -er '.access_token')

Request a fresh token for each job, or obtain another if it expires during a long job. A rejected/missing token returns 401; missing roles return 403. If an Origin or Referer header is sent, the existing same-origin check applies.

Run the folder scripts

The repository includes three independent Bash scripts. Install curl with --fail-with-body support; uploading also needs Bash 4+, jq and find with -print0. Export these variables so the scripts inherit them (access_token is lowercase):

export access_token="$ACCESS_TOKEN"
export VIKING_URL="$CMS_URL"
export SOURCE_FILES='/path/to/content'
export PUBLISH_POINT='/docs'

# Run from the Viking repository root; stop if any step fails.
scripts/delete-pages.sh &&
  scripts/upload-files.sh &&
  scripts/publish-site.sh

VIKING_URL is the CMS base URL. PUBLISH_POINT is a URI path, not a full URL. Deletion includes the branch root; explicitly setting / deletes every draft page. It preserves uploaded media and existing releases. Deletion needs no source directory; publishing needs only the token and CMS URL.

Upload scans the source directory recursively for lowercase .md, .jpg and .png extensions. SVG files are reported and skipped; all other unsupported files and symbolic links are ignored. Relative directories and filename stems become page URIs: guide/start.md becomes /docs/guide/start and images/photo.jpg becomes /docs/images/photo. README.md becomes /docs/README; index.md becomes /docs/index. Neither is renamed or mapped to the branch root. Titles use the filename stem. Directory names and stems must satisfy Viking's URI segment rules; invalid paths and collisions such as photo.md plus photo.png fail before any upload.

Markdown uploads replace pages by URI and preserve source bytes. JPG/PNG files are uploaded as assets and receive HTML pages displaying them below the publish point, hidden from the menu. Their image bytes use generated /api/assets/<id>/content URLs. Existing pages at these URIs are replaced. Relative page links and image references in Markdown are not rewritten; use the destination page URIs and returned image URLs in your content. Repeating an image upload creates a new asset; previous media remains stored.

All scripts stop on HTTP errors. Draft changes made before a failed upload remain saved. The publish script activates the entire site, including draft edits outside PUBLISH_POINT. Serialize jobs that edit or publish the same site.

Upload a page by URI

PUT /api/pages/content?path=/docs/start&title=Getting%20started&menu=false accepts a raw UTF-8 body with Content-Type: text/markdown or text/html (optionally charset=utf-8), up to 8 MiB. path and title are required; menu is optional and accepts only true or false. Unknown or duplicate query parameters are rejected. URI paths follow the existing page rules: each segment starts with an ASCII letter or digit and uses ASCII letters, digits, dots, underscores or hyphens; trailing slashes are normalized. Extensions such as /docs/overview.md are allowed. See page URL rules for length limits and reserved paths.

New pages return 201 and replacements return 200, with the full page JSON. Uploading again at the same URI preserves its page ID and can explicitly switch between HTML and Markdown. Menu and layout settings are preserved when omitted; new pages appear in the menu by default. An explicit menu value supplies a root-level menu entry at position zero. Markdown's leading !! menu, !! menu-position and !! menu-parent directives can supply more detailed menu metadata and take precedence over the query's menu setting. For full HTML menu metadata or CSS, use the existing JSON page API instead.

curl --fail-with-body --silent --show-error \
  -X PUT -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: text/markdown; charset=utf-8' \
  --data-binary @docs/start.md \
  "$CMS_URL/api/pages/content?path=/docs/start&title=Getting%20started&menu=false"

curl --fail-with-body --silent --show-error \
  -X PUT -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: text/html; charset=utf-8' \
  --data-binary @docs/example.html \
  "$CMS_URL/api/pages/content?path=/docs/example&title=Example&menu=false"

HTML is stored as authoring content and sanitized for preview/publication. Scripts and unsafe markup are removed. Markdown remains source, with raw HTML disabled. These page uploads use a separate route from binary assets.

Upload images

Use the existing multipart POST /api/assets endpoint with field file:

ASSET=$(curl --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -F 'file=@docs/diagram.png' "$CMS_URL/api/assets")
ASSET_URL=$(jq -er '"/api/assets/" + .id + "/content"' <<<"$ASSET")

Use ASSET_URL in Markdown (![Diagram](/api/assets/<id>/content)) or an HTML image's src. The same URL serves the image on the published site. Raster image formats follow the asset allowlist; SVG and executable assets remain unsupported. Asset uploads create new IDs, so keep the returned ID if you want to reuse an upload. Relative image references in Markdown/HTML are not automatically imported or rewritten.

For arbitrary binaries and files, use POST /api/assets?raw=true and associate the returned ID with a format: "raw" page. See raw objects and plugins API for the JSON fields, related images, repeated block templates and portable package API.

Remove a URI branch

DELETE /api/pages/branch?path=/docs deletes the /docs draft and every draft whose URI starts with /docs/. It preserves /docs-old and pages whose menu parent is /docs but whose URI is elsewhere. The branch root need not exist. It returns 200 {"count": 3, "deleted": [...]} with deleted page summaries. Repeating a deletion returns an empty list. A missing/empty path is rejected; an explicit path=/ removes all draft pages.

curl --fail-with-body --silent --show-error \
  -X DELETE -H "Authorization: Bearer $ACCESS_TOKEN" \
  "$CMS_URL/api/pages/branch?path=/docs"

Deletion is journaled and completes before the next page read, mutation or publication snapshot, including after restart. It preserves media and existing published releases. Uploading a group of pages is a sequence of draft edits, not an atomic batch: serialize jobs that modify/publish the same site. An upload failure leaves successful draft edits in place, and the public site continues serving its previous release until an explicit publish.

Publish

Once all uploads succeed, publish the complete current draft project:

curl --fail-with-body --silent --show-error \
  -X POST -H "Authorization: Bearer $ACCESS_TOKEN" "$CMS_URL/api/publish"

This is synchronous: 200 returns the release ID after activation. A publish includes every draft page, settings and assets, including edits outside the pipeline's URI branch. GET /api/publish/status reports the current release. Menu visibility affects navigation only; hidden pages still publish and appear in URI-based Index blocks. Repeated publish requests create separate releases.

Add an Index block

In the visual page editor, add Site → Index. The editor displays a placeholder; Preview shows the current generated list. In uploaded HTML, use:

<ul class="vc-index" data-viking-index=""></ul>

In Markdown, put {{index}} in a paragraph of its own, with blank lines around it. Occurrences in prose, inline code and fenced code remain literal.

An Index lists only direct URI children, regardless of their menu metadata. For /docs, /docs/start is included and /docs/start/details is excluded. Items are sorted alphabetically by title, ignoring case, with URI as a stable tiebreaker. Each li contains a linked title followed by a p containing the first nonempty paragraph's plain text. Headings, shared headers/navigation/ footers and other Index blocks do not supply summaries. Pages without a paragraph have only their title link; an empty title falls back to the URI. An Index with no children renders an empty ul.

Index content is generated from the same project snapshot as the release and also resolves in unsaved previews. It never changes authoring source or expands recursively. After uploading or deleting children, publish again to update the public list.