@typeroll/mcp-server 0.44.73 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,297 +1,69 @@
1
1
  ---
2
2
  name: tr-migrate-astro
3
- description: Use when the user wants to migrate an Astro site — particularly an Astro Content Collections-backed site — to Typeroll. Walks `src/content/<collection>/*.md(x)`, lifts frontmatter into Typeroll collection schemas, converts markdown bodies into richtext fields, batch-imports items, then maps `src/pages/*` into Typeroll pages/partials. Triggers on "migrate an Astro site", "import from src/content", "convert content collections", or when the user names a local Astro repo as the source.
3
+ description: Migrate an existing Astro site, including Astro Content Collections, to native Typeroll Pages and Content types while preserving routes, design and media.
4
4
  ---
5
5
 
6
- # Migrate an Astro site to Typeroll
7
-
8
- > **The buffer model (draft writes).** Every content write in this recipe
9
- > (pages, blocks, partials, collection items) lands in an unsaved per-doc
10
- > DRAFT — deploys and plain previews only see SAVED content. For recipe-style
11
- > build work, pass `save: true` on write calls (the work is pre-approved by
12
- > the task itself), or run `commit_working_copy` per doc before any
13
- > `trigger_deploy`. Preview your drafts with `include_working_copy: true`.
14
-
15
-
16
- Astro's [Content Collections](https://docs.astro.build/en/guides/content-collections/) and Typeroll's `Collection` + `CollectionItem` map one-to-one. A collection in Astro is a directory of frontmatter-bearing files under `src/content/<name>/`; in Typeroll it's a schema + items doc set under `organizations/{org}/sites/{site}/collections/{name}/`. The Astro schema (`zod.object(...)` in `src/content/config.ts`) is your field list. The frontmatter values are field values. The markdown bodies are richtext fields.
17
-
18
- This skill walks the migration from a checked-out Astro repo on the user's machine to a target Typeroll site. You run it locally — the source repo is on disk, the MCP just receives the final shape.
19
-
20
- ## Preconditions
21
-
22
- - The Astro repo is checked out locally and `npm install`d (so we can read `src/content/config.ts` to extract the schemas).
23
- - `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY` pointing at the target site (or org-scoped key + a `site_id` argument per call).
24
- - Target Typeroll site exists. **Empty starter site is best.** If non-empty, treat existing pages and collections as off-limits unless the user explicitly says otherwise.
25
- - The Astro design / theme is **not** being migrated — Typeroll has its own design layer. We migrate content; the user re-skins on the Typeroll side.
26
-
27
- ## Recipe
28
-
29
- ### 1. Map the source
30
-
31
- From the Astro repo root:
32
-
33
- ```bash
34
- ls src/content/ # which collections exist?
35
- cat src/content/config.ts # collection schemas (zod)
36
- ls src/pages/ # standalone pages
37
- ls public/ # static assets and images
38
- ```
39
-
40
- Build a working manifest:
41
-
42
- | Astro source | Typeroll target |
43
- |---|---|
44
- | `src/content/blog/*.md` | Collection `blog` with items |
45
- | `src/content/projects/*.mdx` | Collection `projects` with items |
46
- | `src/pages/about.astro` | Page with slug `about` |
47
- | `src/pages/services/[slug].astro` | If dynamic from a collection → that collection's `route_template`. If genuinely per-page → individual Typeroll pages. |
48
- | `public/og/*.png`, `public/images/*` | Upload to Typeroll media via `upload_media_from_url` (after staging them on a temporary public URL) or via local upload if the MCP supports it. |
49
- | `src/layouts/*.astro` | Header / footer / shared chunks → Typeroll partials. The rest of the layout is the site design, owned by the target site. |
50
- | `src/components/*.astro` | Either become partials (if reused across pages) or get inlined into the page that uses them. |
51
-
52
- ### 2. Learn the target's design (don't skip)
53
-
54
- ```
55
- get_site
56
- read_site_settings # colours, fonts, voice
57
- list_partials
58
- read_partial partial_id="header"
59
- list_pages limit=5
60
- ```
61
-
62
- Same rule as in `tr-migrate-wp`: you're moving content into the *target's* visual language, not preserving the source's. Note the existing fonts, colour vars, header structure.
63
-
64
- ### 3. Translate one collection schema
65
-
66
- Pick the most representative collection first (usually `blog`). Read its zod schema:
67
-
68
- ```ts
69
- // src/content/config.ts
70
- const blog = defineCollection({
71
- type: 'content',
72
- schema: z.object({
73
- title: z.string(),
74
- description: z.string(),
75
- pubDate: z.coerce.date(),
76
- updatedDate: z.coerce.date().optional(),
77
- heroImage: z.string().optional(),
78
- author: z.string().default('Editorial'),
79
- tags: z.array(z.string()).default([]),
80
- draft: z.boolean().default(false),
81
- }),
82
- });
83
- ```
84
-
85
- Map zod types to Typeroll field types:
86
-
87
- | Astro zod | Typeroll field type |
88
- |---|---|
89
- | `z.string()` | `text` (or `textarea` if it's a description/excerpt — judge by typical length) |
90
- | `z.string().long()` / a description field | `textarea` |
91
- | `z.coerce.date()` / `z.date()` | `date` |
92
- | `z.number()` | `number` |
93
- | `z.boolean()` | `boolean` |
94
- | `z.string()` with image path / `image()` helper | `image` |
95
- | `z.array(z.string())` | `text` (comma-joined) or `tags` if you have a tags field type |
96
- | `z.enum([...])` | `text` with a comment about the allowed values; the model writes the listing logic |
97
- | `z.object({...})` (nested) | Flatten into prefixed fields, or pre-render into a `*_html` field (see `tr-collection-template`) |
98
- | Markdown body | `body: richtext` (the markdown content of the file, converted to HTML — see §4) |
99
-
100
- Create the collection with the design template baked in (this is the new pattern — read `tr-blog` if you haven't yet):
101
-
102
- ```
103
- create_collection {
104
- "name": "blog",
105
- "label_singular": "Article",
106
- "label_plural": "Articles",
107
- "slug_field": "slug",
108
- "sort_field": "date",
109
- "sort_dir": "desc",
110
- "route_template": "/blog/{slug}",
111
- "fields": [
112
- {"name":"title", "type":"text", "required":true},
113
- {"name":"slug", "type":"text", "required":true},
114
- {"name":"description", "type":"textarea"},
115
- {"name":"date", "type":"date", "required":true},
116
- {"name":"updated_date","type":"date"},
117
- {"name":"hero_image", "type":"image"},
118
- {"name":"author", "type":"text"},
119
- {"name":"tags", "type":"text"},
120
- {"name":"body", "type":"richtext"}
121
- ],
122
- "item_template_html": "<article class=\"post\">...</article>"
123
- }
124
- ```
125
-
126
- Field-name rules: ASCII, lowercase, `[a-z][a-z0-9_-]*`. The Astro source `pubDate` → Typeroll `date`. `updatedDate` → `updated_date` (snake_case is fine; camelCase isn't).
127
-
128
- ### 4. Convert markdown bodies → richtext
129
-
130
- Astro stores the markdown body as the file content after the `---` frontmatter fence. Typeroll's `body: richtext` wants HTML. Pick one of:
131
-
132
- **Option A — use Astro's own markdown renderer (preferred when the repo already builds):**
133
-
134
- ```js
135
- import { unified } from 'unified';
136
- import remarkParse from 'remark-parse';
137
- import remarkRehype from 'remark-rehype';
138
- import rehypeStringify from 'rehype-stringify';
139
-
140
- const md2html = async (md) => {
141
- const file = await unified()
142
- .use(remarkParse)
143
- .use(remarkRehype, { allowDangerousHtml: true })
144
- .use(rehypeStringify, { allowDangerousHtml: true })
145
- .process(md);
146
- return String(file);
147
- };
148
- ```
149
-
150
- Run this on the body of every collection file. Image references inside the markdown (`![alt](./hero.png)`) need their `src` rewritten to the uploaded Typeroll CDN URLs after step 6.
151
-
152
- **Option B — convert ad-hoc:** `marked`, `markdown-it`, or any other parser. Just stay consistent across files so the HTML output looks uniform.
153
-
154
- Typeroll's sanitiser will strip `<script>` and event handlers from the output regardless. If the markdown carried embedded raw HTML you want to keep (iframes for video, etc.), check the sanitiser config in `packages/site-template/src/lib/sanitize.ts` for the whitelist.
155
-
156
- ### 5. Resolve and upload images
157
-
158
- For every image referenced by an item (`heroImage`, images inside the markdown body):
159
-
160
- 1. Stage the local file at a temporary public URL (or use a local-upload MCP tool if one is configured).
161
- 2. `upload_media_from_url url=<staged-url> alt=<from frontmatter or filename>` — record the returned CDN URL.
162
- 3. Substitute the CDN URL into both the `hero_image` field value AND the markdown-converted HTML body (regex replace `src=` references).
163
-
164
- Keep a local map (`./astro-migration-state.json`):
165
-
166
- ```json
167
- {
168
- "media": {
169
- "./hero.png": "https://cdn.typeroll.com/<orgId>/<siteId>/abc123.png"
170
- }
171
- }
172
- ```
173
-
174
- So a partial run is resumable and you don't re-upload the same image twice.
175
-
176
- ### 6. Batch-import items
177
-
178
- For each file in `src/content/<collection>/`:
179
-
180
- ```js
181
- import fs from 'node:fs';
182
- import path from 'node:path';
183
- import matter from 'gray-matter';
184
-
185
- const files = fs.readdirSync('src/content/blog').filter(f => /\.mdx?$/.test(f));
186
- for (const file of files) {
187
- const raw = fs.readFileSync(`src/content/blog/${file}`, 'utf8');
188
- const { data, content } = matter(raw);
189
- const slug = file.replace(/\.mdx?$/, '');
190
- const body = await md2html(content);
191
-
192
- // create_collection_item via the MCP
193
- await mcp.callTool('create_collection_item', {
194
- collection: 'blog',
195
- status: data.draft ? 'draft' : 'published',
196
- fields: {
197
- title: data.title,
198
- slug,
199
- description: data.description,
200
- date: data.pubDate ? new Date(data.pubDate).toISOString().slice(0, 10) : null,
201
- updated_date: data.updatedDate ? new Date(data.updatedDate).toISOString().slice(0, 10) : null,
202
- hero_image: mediaMap[data.heroImage] || data.heroImage,
203
- author: data.author,
204
- tags: (data.tags || []).join(', '),
205
- body,
206
- },
207
- });
208
- }
209
- ```
210
-
211
- Rate-limit awareness: spawn at most 5 parallel `create_collection_item` calls; the API caps at ~60 writes/minute and responds with 429 + `Retry-After` if exceeded.
212
-
213
- Set `status: 'draft'` (or honour Astro's `draft: true` frontmatter) for items the user should review before publishing. Only published items get static pages.
214
-
215
- ### 7. Build the listing page
216
-
217
- ```
218
- create_page title="Blog" slug="blog" status="published" content_mode="html"
219
- html_content="<section><h1>Blog</h1>
220
- <!-- typeroll:listing:blog -->
221
- <!-- /typeroll:listing:blog -->
222
- </section>"
223
-
224
- regenerate_collection_listing
225
- collection="blog"
226
- page_id="blog"
227
- item_template="<article class=\"blog-card\"><a href=\"{{url}}\"><h2>{{title}}</h2><p>{{description}}</p><time>{{date}}</time></a></article>"
228
- wrap_open="<div class=\"blog-grid\">"
229
- wrap_close="</div>"
230
- ```
231
-
232
- See `tr-blog` for full styling and post-import update flow.
233
-
234
- ### 8. Translate standalone pages
235
-
236
- For each non-dynamic `.astro` page under `src/pages/`:
237
-
238
- ```
239
- read_partial partial_id="header" # learn target's nav style
240
- # Re-skin the page content using target site's CSS variables and partials.
241
- create_page title="About" slug="about" status="draft" content_mode="html"
242
- html_content="..."
243
- ```
244
-
245
- Default to **draft** — the user reviews each page before publishing.
246
-
247
- For dynamic Astro pages (`[slug].astro` that consume a collection), you're already done: the matching Typeroll collection's `route_template` produces the same URLs at build time.
248
-
249
- ### 9. Add redirects for URL changes
250
-
251
- If Astro's slugs differed from what you derived for Typeroll (e.g. Astro had `/posts/my-article` but you want `/blog/my-article`), bulk-add redirects:
252
-
253
- ```
254
- add_redirect from="/posts/my-article" to="/blog/my-article" status=301
255
- ```
256
-
257
- Or build a redirect map from the `astro-migration-state.json` and apply it in one pass.
258
-
259
- ### 10. Deploy
260
-
261
- ```
262
- trigger_deploy
263
- get_deploy_status job_id=<id>
264
- ```
265
-
266
- Browse the resulting site, compare against the Astro source, surface anything that drifted.
267
-
268
- ## Astro-specific gotchas
269
-
270
- - **`.mdx` files with custom components.** Components inside MDX (`<MyCustom prop="..." />`) will not render in Typeroll because the component definitions don't migrate. Two options: (1) replace each component with its rendered HTML output (run the Astro build, scrape the rendered HTML, use that as `body`); (2) if the component is a reusable visual element used across many items, factor it into a Typeroll partial and replace MDX usages with `<x-include name="..." />` calls. See `tr-page-template` for the partial-include pattern.
271
- - **`src/content/config.ts` typed `image()` helper.** Astro resolves `image()` fields at build time to optimised assets. After migration the Typeroll `hero_image` field carries the original source URL — re-upload via step 5 to get it onto the Typeroll CDN.
272
- - **`getCollection` filters at runtime.** If `src/pages/blog/index.astro` does `getCollection('blog', ({ data }) => !data.draft)`, Typeroll handles this via `status` on the item. Don't translate the filter — set `status: 'draft'` on items where `data.draft === true`.
273
- - **Per-tag pages (`/blog/tag/[tag].astro`).** Typeroll doesn't auto-generate these. Either (a) drop tag pages and use a client-side filter in the blog listing JS, (b) generate them by enumerating unique tags and calling `create_page` per tag with a server-side-pre-filtered listing. (a) is preferable for ≤dozens of tags.
274
- - **`rehype-pretty-code` / shiki / fenced code with syntax highlighting.** The HTML output of these contains inline styles that the Typeroll sanitiser preserves. The colours match the Astro site's theme at import time; redoing the target design later means re-running the highlighter against the same markdown source. Keep a copy of the raw markdown if you might.
275
- - **`og:image` per page from `astro-og-image` style plugins.** Typeroll has its own SEO surface (`seo_title`, `seo_description`, `seo_og_image`). Set them explicitly on each page during step 8.
276
- - **i18n via `src/content/<lang>/<collection>/`.** Typeroll's site-level `default_language` + per-page `language` field cover this (capabilities: `supports_language_per_page: true`). Map each language directory to per-item `language: 'sv-SE' | 'en-US' | …` instead of separate collections.
277
-
278
- ## Mixing imported + new content
279
-
280
- Half-migrate, leave drafts, let the user inspect, iterate. You can:
281
-
282
- - Import only collections, skip standalone pages, let the user rebuild those from scratch.
283
- - Import everything as `status: 'draft'`, treat publishing as a per-item human review pass.
284
- - Mix sources: pull article bodies from Astro markdown, use Claude to generate fresh excerpts/SEO metadata before writing the item.
285
-
286
- Keep the local `astro-migration-state.json` honest — it's the only way to make a partial run resumable when the API rate-limits or a markdown parser trips on an edge case file.
287
-
288
- ## Storage prerequisite
289
-
290
- Before reading or importing source content, call `get_import_readiness` (Core
291
- 0.1.95 / MCP 0.44.68 or later). If `ready` is false, stop the import and show the
292
- returned message and Publishing settings link. The organization must connect
293
- and verify its own storage first. Do not fall back to draft storage, hotlink
294
- source images, or use ordinary page-write tools to bypass the import gate.
295
- Use `upload_media_from_url` for referenced source images; the customer’s
296
- Cloudflare transfer Worker copies and verifies them directly in R2, independently
297
- of the GitHub/Cloudflare build-provider choice.
6
+ # Migrate an Astro site
7
+
8
+ Astro Content Collections are source data. In Typeroll, each source record
9
+ becomes a Page, and each source schema becomes a Content type. Static Astro
10
+ pages use that same Page model. Shared Astro layouts become Page templates,
11
+ partials or reusable block types; no separate item storage is created.
12
+
13
+ ## Inventory before writing
14
+
15
+ Read the source repository's routing, content loaders, schema, frontmatter,
16
+ layouts, components, styling and public assets. New Astro projects can define
17
+ loaders in `src/content.config.ts`; older projects may use
18
+ `src/content/config.ts` and files under `src/content/`. Follow what the actual
19
+ project uses, including generated route logic and trailing-slash policy.
20
+
21
+ Discover the target with `get_site`, `get_site_capabilities`, `list_pages`,
22
+ `list_content_types`, `list_page_templates` and `list_block_types`. Existing target
23
+ content remains out of scope unless the user authorized replacing it. Create a
24
+ site version for the migration and pass it consistently on all versioned calls.
25
+ Call `get_migration_readiness` before import; connect verified customer storage
26
+ before transferring any source content or media.
27
+
28
+ Record a URL inventory from the existing site and source routing. Use explicit
29
+ Page paths to preserve nested routes or frontmatter-based permalinks. Do not
30
+ assume that a filename alone determines the production URL.
31
+
32
+ ## Map the source
33
+
34
+ - Common frontmatter such as title, slug, author, publication date and SEO maps
35
+ to top-level Page metadata.
36
+ - Other schema fields become the Content type's custom fields and Page `fields`.
37
+ Preserve typed objects and arrays; use `page_ref`/`page_ref_list` for relations.
38
+ - Markdown becomes native Page body blocks. First render Markdown to HTML using
39
+ the source project's parser, then review block conversion. MDX components
40
+ require explicit equivalents; never drop them as if they were plain Markdown.
41
+ - A shared layout becomes a reusable Page template with `template_content_slot`.
42
+ Assign it as the type's default. Header/footer and reusable fragments can be
43
+ partials. Use native blocks before choosing a rare reviewed HTML/embed exception.
44
+ - Browser scripts, integrations and server-rendered endpoints need separate
45
+ review. A static Page cannot inherit a private server runtime by copying its
46
+ source text. Configure the supported Forms/Extension runtime explicitly.
47
+
48
+ Create templates with `create_page_template`, types with `create_content_type`,
49
+ then one `create_page` per source record. Use returned Page IDs, not source IDs
50
+ or slugs, for later updates and previews. For linked content, create the Pages
51
+ first and resolve their reference fields in a second pass.
52
+
53
+ Upload assets to connected storage and rewrite source-relative image references
54
+ to returned media URLs. Use direct upload URLs for local bytes and the customer
55
+ transfer flow for remote media; do not relay large base64 payloads through the
56
+ model. Preserve alt text, captions, dimensions, links and image variants.
57
+
58
+ ## Listing and verification
59
+
60
+ An archive is a Page with `core/page_list` filtered by `content_type`. Configure
61
+ sorting and pagination from the block schema. It reads saved Pages at build
62
+ time; do not generate a second listing representation or call a regeneration tool.
63
+
64
+ Check representative long/short bodies, code, tables, images, captions, embedded
65
+ components and related links at desktop and mobile widths. Compare the source
66
+ and Typeroll preview, then a fresh authorized static build. Verify every inventory
67
+ URL and redirect, canonical/hreflang, sitemap inclusion, noindex state and assets.
68
+ Save working copies before publication. Keep the source host and DNS in place
69
+ until the candidate passes checks and the user authorizes its cutover.
@@ -6,7 +6,7 @@ description: Use when migrating SEVERAL sites at once — a WordPress multisite
6
6
  # Migrate a multisite / multi-domain family to Typeroll
7
7
 
8
8
  > **The buffer model (draft writes).** Every content write in this recipe
9
- > (pages, blocks, partials, collection items) lands in an unsaved per-doc
9
+ > (pages, blocks and partials) lands in an unsaved per-doc
10
10
  > DRAFT — deploys and plain previews only see SAVED content. For recipe-style
11
11
  > build work, pass `save: true` on write calls (the work is pre-approved by
12
12
  > the task itself), or run `commit_working_copy` per doc before any
@@ -6,7 +6,7 @@ description: Use when the user asks to migrate a WordPress site to Typeroll, men
6
6
  # Migrate from WordPress to Typeroll
7
7
 
8
8
  > **The buffer model (draft writes).** Every content write in this recipe
9
- > (pages, blocks, partials, collection items) lands in an unsaved per-doc
9
+ > (pages, blocks and partials) lands in an unsaved per-doc
10
10
  > DRAFT — deploys and plain previews only see SAVED content. For recipe-style
11
11
  > build work, pass `save: true` on write calls (the work is pre-approved by
12
12
  > the task itself), or run `commit_working_copy` per doc before any
@@ -27,7 +27,7 @@ get_migration_readiness source_url="https://oldsite.com"
27
27
  ```
28
28
 
29
29
  When the migration includes reusable page/item layouts, include each proposed
30
- block composition and its collection fields in this same call. A
30
+ block composition and its content type fields in this same call. A
31
31
  `waiting_for_native_support` result means leave that template intact and do
32
32
  not replace the gap with generic custom blocks, raw HTML, or corrective site
33
33
  CSS. Independent content and SEO work may continue. Rerun the review after the
@@ -6,7 +6,7 @@ description: Use when the user wants to create a new Typeroll site from scratch,
6
6
  # Bootstrap a new Typeroll site
7
7
 
8
8
  > **The buffer model (draft writes).** Every content write in this recipe
9
- > (pages, blocks, partials, collection items) lands in an unsaved per-doc
9
+ > (pages, blocks and partials) lands in an unsaved per-doc
10
10
  > DRAFT — deploys and plain previews only see SAVED content. For recipe-style
11
11
  > build work, pass `save: true` on write calls (the work is pre-approved by
12
12
  > the task itself), or run `commit_working_copy` per doc before any
@@ -55,11 +55,11 @@ Common requirements that are easy to misclassify:
55
55
  |---|---|
56
56
  | Full-bleed hero flush below the header | `core/section` + `core/hero`; block-mode sections already own the full width and have no page-shell padding |
57
57
  | Responsive icon/card grid | `core/grid` + `core/icon_box`, or `core/feature_grid`; set responsive fields with `set_block_responsive` |
58
- | Custom cards backed by a collection | `core/repeater` / `core/collection_list` with a site-authored `item_compatible` block type as `item_block` |
59
- | Grouped collection listing | `core/repeater` with `group_by`; array-valued fields place an item in every matching group |
58
+ | Custom cards backed by Pages | `core/repeater` / `core/page_list` with a site-authored `item_compatible` block type as `item_block` |
59
+ | Grouped Page listing | `core/repeater` with `group_by`; array-valued fields place an item in every matching group |
60
60
  | Breadcrumbs in a page template | `template/page_breadcrumbs`; page and item routes supply a server-rendered trail |
61
- | Generated heading index | `core/table_of_contents`; choose heading levels and set `source_field` for collection content |
62
- | Previous/next collection item links | `template/item_navigation`; defaults to collection order and can bind explicit neighbor fields |
61
+ | Generated heading index | `core/table_of_contents`; choose heading levels and set `source_field` for Page content |
62
+ | Previous/next Page links | `template/page_navigation`; defaults to content type sort order and can bind explicit neighbor fields |
63
63
  | Download CTA that disappears without a file | `template/show_if` around a context-bound `core/button`; a dedicated download block is only editor convenience |
64
64
  | Sticky/custom header and multi-column footer | Block-mode header/footer partials plus layout blocks, or one reusable custom block type |
65
65
  | Cookie notice | `settings.cookie_consent`, not a page block |
@@ -199,14 +199,14 @@ truth):
199
199
  (run `generate_image_variants` after upload) — the in-portal preview
200
200
  shows a plain `<img>`, the deployed site gets the upgrade. Use the
201
201
  `radius` field for rounded corners.
202
- - **Repeaters/listings:** `core/collection_list`, `gallery`,
202
+ - **Repeaters/listings:** `core/page_list`, `gallery`,
203
203
  `feature_grid` etc. — alias blocks over `core/repeater`. Use these for
204
- collection-driven content instead of hand-writing listing markup. The base
204
+ Pages of a content type instead of hand-writing listing markup. The base
205
205
  repeater also supports `group_by`, group ordering/headings, multi-valued
206
206
  membership, filters, custom `item_block`, and `item_overrides`.
207
207
  - **Long-form navigation:** `core/table_of_contents` builds an anchor list
208
- from page headings. Collection block templates use
209
- `template/item_navigation` for deterministic previous/next links.
208
+ from page headings. Page templates use
209
+ `template/page_navigation` for deterministic previous/next links.
210
210
  - **Forms:** `create_form`, then place `core/form` with its `form_id`.
211
211
  HTML-mode pages use `<x-form id="…" />`; both paths render the same signed
212
212
  shell and runtime. See `tr-forms`.