Skip to content

Publication: localized posts with a public list

This example publishes posts in several languages, serves a public list per locale, and accepts reader suggestions. It is fully declarative: no handler code is needed. Read it if you run a blog, a news section or any authored, translated content.

Problem

Editors draft posts in one or more locales, review them in Admin, and publish each language version independently. Every language version of a post shares one stable identity (its slug) so the site can link translations together and Admin can show translation completeness. Visitors read a per-locale list of published posts, newest first, over REST and public MCP. Readers may suggest topics; those suggestions are live records staff read in Admin, not content that is published.

Manifest

yaml
apiVersion: cms.mantle.aotter.net/v1
kind: Schema
metadata:
  name: posts
spec:
  title: Posts
  description: Stable post identities shared by every language version.
  localized: false
  lifecycle: publishing
  uniqueIndexes:
    - [slug]
  schema:
    type: object
    required: [slug]
    properties:
      slug: { type: string, pattern: "^[a-z0-9-]+$" }
---
apiVersion: cms.mantle.aotter.net/v1
kind: Schema
metadata:
  name: post-translations
spec:
  title: Post translations
  description: Localized titles and bodies for each post.
  localized: true
  translates:
    parent: posts
    on: slug
  lifecycle: publishing
  uniqueIndexes:
    - [slug, locale]
  indexes:
    - [locale, publishedAt]
  schema:
    type: object
    required: [slug, locale, title, publishedAt]
    properties:
      slug: { type: string, pattern: "^[a-z0-9-]+$" }
      locale: { type: string }
      title: { type: string }
      excerpt: { type: string }
      body: { type: string, x-mcp-hint: markdown }
      publishedAt: { type: number, x-mcp-hint: timestamp-ms }
---
apiVersion: cms.mantle.aotter.net/v1
kind: View
metadata:
  name: published-posts
spec:
  title: Published posts
  surface: public
  from: post-translations
  params:
    type: object
    additionalProperties: false
    required: [locale]
    properties:
      locale: { type: string }
  fields: [id, slug, locale, title, excerpt, body, publishedAt, updatedAt]
  filter:
    and:
      - eq: { field: status, value: published }
      - eq: { field: locale, value: { $param: locale } }
      - gte: { field: publishedAt, value: 0 }
  orderBy:
    - { field: publishedAt, direction: desc }
  limit: 50
---
apiVersion: cms.mantle.aotter.net/v1
kind: Schema
metadata:
  name: post-suggestions
spec:
  title: Post suggestions
  description: Reader suggestions for future posts.
  lifecycle: operational
  schema:
    type: object
    required: [title, email]
    properties:
      title: { type: string }
      email: { type: string, format: email }
      note: { type: string }
      createdAt: { type: integer, x-mcp-hint: timestamp-ms, x-mantle-bind: now }
---
apiVersion: cms.mantle.aotter.net/v1
kind: Procedure
metadata:
  name: submit-post-suggestion
spec:
  input:
    type: object
    additionalProperties: false
    required: [title, email]
    properties:
      title: { type: string }
      email: { type: string, format: email }
      note: { type: string }
  output: { type: object }
  handler: { kind: builtin, op: create, schema: post-suggestions }
---
apiVersion: cms.mantle.aotter.net/v1
kind: Trigger
metadata:
  name: submit-post-suggestion-http
spec:
  source: { kind: http, method: POST, path: /api/post-suggestions }
  target: { procedure: submit-post-suggestion }

How the pieces fit:

  • posts is the non-localized parent. It holds only what every language shares; here that is the slug.
  • post-translations is the localized child. translates: { parent: posts, on: slug } requires localized: true, at least one content field besides slug and locale, and a parent that exists and is not itself localized. Admin renders the child as locale tabs inside the parent's editor.
  • published-posts takes a required locale parameter. { $param: locale } must name a property in params.properties that is also in params.required. The gte publishedAt 0 clause keeps the filter aligned with the declared [locale, publishedAt] index; confirm the plan with mantle-harness indexes.
  • Site locales must be configured. A localized Schema with zero siteDefaults.locales fails at boot with SCHEMA_LOCALIZED_REQUIRES_SITE_LOCALES, and a write whose locale is not in the site list fails with INPUT_VALIDATION_FAILED.

See Lifecycle and locales and the Schema reference.

Worker and handlers

There are none. Every Procedure here is builtin, and Views need no Trigger. The Worker is the minimal one:

ts
import { createMantleWorker } from "@aotter/mantle/cloudflare";
import { plan } from "../.mantle/generated/mantle.js";

export default createMantleWorker({ plan });

Rendering

Public HTML pages are optional and separate from the data contract. If you want /en/posts/<slug> rendered by the Worker, register a template for the translations collection and pass it to createMantleWorker({ templates, publicPathResolver }). A trimmed entry template:

ts
import { TemplateRegistry } from "@aotter/mantle/web";
import MarkdownIt from "markdown-it";

const markdown = new MarkdownIt({ html: false, linkify: true });

export function createTemplates(): TemplateRegistry {
  const templates = new TemplateRegistry();
  templates.registerEntryTemplate("post-translations", ({ entry, site, seo }) => {
    const title = typeof entry.data.title === "string" ? entry.data.title : site.title;
    const body = typeof entry.data.body === "string" ? entry.data.body : "";
    return `<html lang="${entry.locale ?? "en"}"><head><title>${escapeHtml(title)}</title></head>
<body><main><h1>${escapeHtml(title)}</h1><div>${markdown.render(body)}</div></main></body></html>`;
  });
  return templates;
}

The template receives { entry, site, seo, mediaAssets } and returns a complete HTML string. Escape every field you interpolate; body is rendered from Markdown because the Schema marks it x-mcp-hint: markdown. Paths, Markdown mirrors, llms.txt, sitemap and cache headers are covered in Public web, SEO and cache.

The single-Schema alternative

The simpler alternative is one localized posts Schema with uniqueIndexes: [[slug, locale]] and no parent. Each locale row is an independent record; nothing is shared except the slug convention. Choose that shape when translations do not need shared fields or Admin translation grouping. Choose the parent/child shape above when several locale rows are versions of one entity and editors need to see which languages are missing. Both shapes serve the same published-posts View contract.

Try it

Public list for one locale:

sh
curl -sS 'http://localhost:8787/api/views/published-posts?locale=en&show=10'
json
{
  "ok": true,
  "data": {
    "rows": [
      {
        "id": "tr_01j...",
        "slug": "hello-world",
        "locale": "en",
        "title": "Hello, world",
        "excerpt": "First post.",
        "body": "# Hello\n\nFirst post.",
        "publishedAt": 1788879363492,
        "updatedAt": 1788879400000
      }
    ],
    "page": 1,
    "show": 10,
    "hasMore": false
  }
}

Omitting locale returns HTTP 400 INPUT_VALIDATION_FAILED. show is capped at the View's limit (50); hasMore is rows.length === show. A View may declare cache.sharedMaxAge to cache eligible anonymous REST responses under the Worker's deployment-scoped tag; publishing content changes purge that tag.

Submit a suggestion:

sh
curl -sS -X POST http://localhost:8787/api/post-suggestions \
  -H 'content-type: application/json' \
  -d '{"title":"Write about indexes","email":"reader@example.test"}'

The response is { ok: true, data: <EntryRow> } with collection: "post-suggestions" and status: "published".

MCP tools:

SurfaceToolOrigin
/mcpquery_view_published_posts (arguments locale, page, show)public View
/mcp/staffcreate_draft_posts, update_draft_postspublishing Schema posts
/mcp/staffcreate_draft_post_translations, update_draft_post_translationspublishing Schema post-translations
/mcp/staffcreate_record_post_suggestions, update_record_post_suggestionsoperational Schema post-suggestions
/mcp/staffrequest_publish, unpublish_entry, archive_entry, delete_entrygeneric lifecycle tools

There is no submit_post_suggestion tool because no MCP Trigger targets that Procedure. See MCP and agents.

What this deliberately leaves out

  • Comments. Reader comments would be another operational Schema with its own moderation Procedures.
  • Search. searchableFields powers Admin and Staff MCP substring search only; public full-text search is application code.
  • Scheduling. publishedAt is a display timestamp set by editors. Nothing publishes a draft at that time; publishing remains an explicit request_publish or Admin action.

Related: Intake form hardens the suggestion box with a bot check.

Source