camox 0.47.0 → 0.48.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.
Files changed (75) hide show
  1. package/dist/core/createApp.d.ts +19 -6
  2. package/dist/core/createApp.js +7 -2
  3. package/dist/core/createBlock.js +54 -43
  4. package/dist/core/createCollection.d.ts +25 -7
  5. package/dist/core/createCollection.js +28 -9
  6. package/dist/core/createLayout.d.ts +98 -71
  7. package/dist/core/createLayout.js +10 -16
  8. package/dist/core/derivedRoutes.js +1 -1
  9. package/dist/core/editing/BlockEditingRuntime.d.ts +3 -2
  10. package/dist/core/editing/CompleteBlockEditingRuntime.js +2 -1
  11. package/dist/core/editing/createEditableBlock.d.ts +59 -19
  12. package/dist/core/editing/createEditableBlock.js +162 -151
  13. package/dist/core/editing/useLatestRecord.js +7 -7
  14. package/dist/core/layoutContext.d.ts +54 -0
  15. package/dist/core/layoutContext.js +66 -0
  16. package/dist/core/lib/contentType.d.ts +91 -4
  17. package/dist/core/lib/contentType.js +84 -7
  18. package/dist/core/lib/recordScope.js +19 -17
  19. package/dist/core/lib/reference.d.ts +6 -2
  20. package/dist/core/recordItem.js +55 -2
  21. package/dist/core/requestContext.d.ts +18 -0
  22. package/dist/core/structuredData.d.ts +126 -0
  23. package/dist/core/structuredData.js +40 -0
  24. package/dist/features/canvas/canvasCamera.js +1 -1
  25. package/dist/features/canvas/sidebar/CanvasSidebarFrame.js +1 -1
  26. package/dist/features/content/ContentCollection.js +13 -7
  27. package/dist/features/content/collection-form.js +6 -2
  28. package/dist/features/content/components/MissingFromLocale.js +1 -1
  29. package/dist/features/og/pageImage.js +89 -0
  30. package/dist/features/page/CuratedPageContent.js +45 -32
  31. package/dist/features/page/DerivedPageContent.js +49 -22
  32. package/dist/features/page/SharedLayoutContent.js +26 -22
  33. package/dist/features/preview/CamoxPreview.js +40 -26
  34. package/dist/features/preview/components/AddBlockSheet.js +1 -0
  35. package/dist/features/preview/components/BlockActionsPopover.js +8 -6
  36. package/dist/features/preview/components/PageInfo.js +244 -264
  37. package/dist/features/preview/components/PageTree.js +63 -53
  38. package/dist/features/preview/components/RecordCard.js +50 -34
  39. package/dist/features/preview/components/RecordCombobox.js +12 -5
  40. package/dist/features/preview/components/ReferenceFieldEditor.js +4 -2
  41. package/dist/features/preview/components/ReferenceListFieldEditor.js +24 -19
  42. package/dist/features/preview/components/RepeatableItemsList.js +62 -46
  43. package/dist/features/preview/components/SelectionEditor.js +3 -2
  44. package/dist/features/preview/components/useFieldWriter.js +2 -4
  45. package/dist/features/preview/components/useRecordView.js +2 -2
  46. package/dist/features/preview/entityName.js +11 -0
  47. package/dist/features/preview/pageRecord.js +17 -18
  48. package/dist/features/routes/pageRuntime.d.ts +15 -1
  49. package/dist/features/routes/pageRuntime.js +35 -38
  50. package/dist/features/runtime/RuntimeChrome.js +1 -1
  51. package/dist/features/runtime/breadcrumbs.js +68 -0
  52. package/dist/features/runtime/livePages.js +3 -2
  53. package/dist/features/runtime/pageGraph.js +92 -0
  54. package/dist/features/runtime/projectLogo.js +110 -0
  55. package/dist/features/runtime/runtime.d.ts +11 -6
  56. package/dist/features/runtime/runtime.js +278 -55
  57. package/dist/features/runtime/runtimeRoutes.d.ts +1 -0
  58. package/dist/features/runtime/runtimeRoutes.js +1 -0
  59. package/dist/features/runtime/structuredData.js +213 -0
  60. package/dist/features/studio/components/ChatSidebar.js +5 -5
  61. package/dist/features/studio/components/ProjectMenu.js +127 -100
  62. package/dist/features/studio/components/SocialProfilesDialog.js +342 -0
  63. package/dist/features/studio/components/ThreadMessages.js +2 -2
  64. package/dist/features/vite/blockBoilerplate.js +3 -2
  65. package/dist/features/vite/layoutGeneration.js +3 -2
  66. package/dist/features/vite/vite.js +1 -0
  67. package/dist/lib/queries.d.ts +4 -0
  68. package/dist/lib/queries.js +1 -0
  69. package/dist/lib/studio-reads.js +3 -1
  70. package/dist/package.js +1 -1
  71. package/dist/studio.css +1 -1
  72. package/package.json +5 -4
  73. package/skills/camox/references/block-definitions.md +105 -25
  74. package/skills/camox/references/layout-definitions.md +53 -17
  75. package/skills/camox/references/routed-layouts.md +17 -14
@@ -18,8 +18,9 @@ const myBlock = createBlock({
18
18
  content: (field) => ({
19
19
  title: field.string({ default: "Welcome" }),
20
20
  }),
21
- component: MyBlockComponent,
22
- toMarkdown: (c) => [`# ${c.title}`],
21
+ toReact: MyBlockComponent,
22
+ toTitle: ({ content }) => content.title,
23
+ toMarkdown: ({ content }) => [`# ${content.title}`],
23
24
  });
24
25
 
25
26
  function MyBlockComponent() {
@@ -37,17 +38,19 @@ For DOM listeners, scrolling, scripts, widgets, or JavaScript-driven animation,
37
38
 
38
39
  ## The `createBlock` options
39
40
 
40
- | Option | Required | Description |
41
- | ------------- | -------- | -------------------------------------------------------------------------------------------- |
42
- | `id` | yes | Unique kebab-case identifier matching the filename without extension. |
43
- | `title` | yes | CMS display name. |
44
- | `description` | yes | Agent guidance on placement, expected content, and tone. |
45
- | `content` | yes | `(field) => ({ ... })`: field names mapped to `field.*` builders for editable content. |
46
- | `settings` | no | `(setting) => ({ ... })`: settings-panel options; only `setting.enum` and `setting.boolean`. |
47
- | `layoutOnly` | no | If true, restricts placement to layouts and hides the type from the add-block picker. |
48
- | `synced` | no | Defaults to false. Shares content/settings across placements; see below. |
49
- | `component` | yes | Named React function component. |
50
- | `toMarkdown` | yes | `(c, s) => [...]` markdown builder; see below. |
41
+ | Option | Required | Description |
42
+ | ------------------ | -------- | ---------------------------------------------------------------------------------------------- |
43
+ | `id` | yes | Unique kebab-case identifier matching the filename without extension. |
44
+ | `title` | yes | CMS display name. |
45
+ | `description` | yes | Agent guidance on placement, expected content, and tone. |
46
+ | `content` | yes | `(field) => ({ ... })`: field names mapped to `field.*` builders for editable content. |
47
+ | `settings` | no | `(setting) => ({ ... })`: settings-panel options; only `setting.enum` and `setting.boolean`. |
48
+ | `layoutOnly` | no | If true, restricts placement to layouts and hides the type from the add-block picker. |
49
+ | `synced` | no | Defaults to false. Shares content/settings across placements; see below. |
50
+ | `toReact` | yes | Named React function component. Receives `{ content, settings }` as props. |
51
+ | `toTitle` | yes | `({ content, settings }) => ...` one-line title; see below. |
52
+ | `toMarkdown` | yes | `({ content, settings }) => [...]` markdown builder; see below. |
53
+ | `toStructuredData` | no | `({ content, settings }) => node \| [...]`: the things the block shows, as JSON-LD; see below. |
51
54
 
52
55
  ## Synced blocks (`synced`, optional)
53
56
 
@@ -59,14 +62,30 @@ For shared navbar/footer data across layouts, use `layoutOnly: true, synced: tru
59
62
  - Position, layout slot, and deletion remain per placement; deleting one placement leaves the others.
60
63
  - Enabling syncing on an existing type applies the oldest placement's data to all placements. Disabling it leaves independently editable copies.
61
64
 
65
+ ## Titles (`toTitle`)
66
+
67
+ Required on blocks and repeaters. Names each block and repeatable item in the studio and to agents until it has a Summary, which always wins once it exists. Receives `{ content, settings }` like `toMarkdown` and returns one line: a field, a template literal, or a fixed string for blocks with no telling field.
68
+
69
+ ```tsx
70
+ toTitle: ({ content }) => content.title;
71
+ toTitle: ({ content }) => `${content.number} ${content.label}`;
72
+ toTitle: () => "Navigation";
73
+ ```
74
+
75
+ Titles read the default locale's content as plain text: a Link reads as its text. References aren't resolved in titles, so name a block by its own fields.
76
+
62
77
  ## Markdown Representation (`toMarkdown`)
63
78
 
64
- Required on blocks and repeaters. Receives typed proxies `c` (content) and `s` (settings); returns entries joined with `\n\n` for AI summaries and SEO. Lines whose referenced fields are all empty, or whose condition is false, are omitted.
79
+ Required on blocks and repeaters. Receives `{ content, settings }`, typed proxies of the block's content and settings; returns entries joined with `\n\n` for AI summaries and SEO. Lines whose referenced fields are all empty, or whose condition is false, are omitted.
65
80
 
66
- Use bare `c.field` for single-field lines and template literals for markdown or combined fields:
81
+ Use bare `content.field` for single-field lines and template literals for markdown or combined fields:
67
82
 
68
83
  ```tsx
69
- toMarkdown: (c) => [`> ${c.quote}`, `— ${c.author}, ${c.company}`, c.photo];
84
+ toMarkdown: ({ content }) => [
85
+ `> ${content.quote}`,
86
+ `— ${content.author}, ${content.company}`,
87
+ content.photo,
88
+ ];
70
89
  ```
71
90
 
72
91
  Field resolution:
@@ -87,16 +106,16 @@ Field resolution:
87
106
  Boolean settings wrap one line or an array; Enum settings match a variant:
88
107
 
89
108
  ```tsx
90
- toMarkdown: (c, s) => [
91
- `# ${c.title}`,
92
- s.showCta(c.cta),
93
- s.showDetails([c.subtitle, c.description]),
94
- s.variant("banner", `**${c.headline}** — ${c.subtext}`),
95
- s.variant("inline", `${c.headline}: ${c.cta}`),
109
+ toMarkdown: ({ content, settings }) => [
110
+ `# ${content.title}`,
111
+ settings.showCta(content.cta),
112
+ settings.showDetails([content.subtitle, content.description]),
113
+ settings.variant("banner", `**${content.headline}** — ${content.subtext}`),
114
+ settings.variant("inline", `${content.headline}: ${content.cta}`),
96
115
  ];
97
116
  ```
98
117
 
99
- Only Boolean/Enum settings support these conditionals. A repeater's `s` refers to the item's settings, not the parent block's.
118
+ Only Boolean/Enum settings support these conditionals. A repeater's `settings` refers to the item's settings, not the parent block's.
100
119
 
101
120
  ## Content Field Types
102
121
 
@@ -203,7 +222,7 @@ field.artifact({ size: "fill", data: ["headline", "variant"] }); // shares sibli
203
222
 
204
223
  ### field.repeater
205
224
 
206
- Structured items with their own content, optional settings, and required `toMarkdown`. `minItems` must be at least 1; repeaters cannot be empty. Nest repeaters in an item's `content` when needed.
225
+ Structured items with their own content, optional settings, and required `toTitle` and `toMarkdown`. `minItems` must be at least 1; repeaters cannot be empty. Nest repeaters in an item's `content` when needed.
207
226
 
208
227
  ```tsx
209
228
  field.repeater({
@@ -217,12 +236,73 @@ field.repeater({
217
236
  minItems: 1,
218
237
  maxItems: 10,
219
238
  title: "Features",
220
- toMarkdown: (c, s) => [`### ${c.name}`, s.highlighted(c.description)],
239
+ toTitle: ({ content }) => content.name,
240
+ toMarkdown: ({ content, settings }) => [
241
+ `### ${content.name}`,
242
+ settings.highlighted(content.description),
243
+ ],
221
244
  });
222
245
  ```
223
246
 
224
247
  Reference the repeater field in the parent block's `toMarkdown` to include its rendered items.
225
248
 
249
+ ## Structured Data (`toStructuredData`, optional)
250
+
251
+ On blocks and repeaters. Describes the things a block shows (a video, a product, questions, reviews) as schema.org nodes in the page's JSON-LD graph. Returns one node or a list. Camox links each node to the page (`isPartOf` for creative works, `subjectOf` for anything else) and gives each placement its own nodes. Only the layout says what the page is, so a block never returns a kind of web page, and Camox alone emits the site's `Organization` and `WebSite`. `@type` and properties are typed: invented types or properties, or a node missing what search engines require (a `Product`'s offers, review or rating), fail to compile.
252
+
253
+ ```tsx
254
+ toStructuredData: ({ content }) => ({
255
+ "@type": "VideoObject",
256
+ name: content.title,
257
+ embedUrl: content.video,
258
+ thumbnailUrl: content.thumbnail,
259
+ uploadDate: "2026-01-15",
260
+ });
261
+ ```
262
+
263
+ Field tokens resolve as follows; template literals read their fields as plain text:
264
+
265
+ | Type | Value |
266
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------- |
267
+ | String | Plain text |
268
+ | Image | An `ImageObject` with its absolute URL, width, height and alt text |
269
+ | File | Its absolute URL |
270
+ | Link | The page's absolute URL in the page's locale, or the external href |
271
+ | Embed | Its URL |
272
+ | Repeater | Its items' nodes, from the repeater's own `toStructuredData` |
273
+ | Reference | A link to the record's node, from its collection's `toStructuredData`, or the record's title when the collection has none |
274
+ | Reference list | A list of such links |
275
+
276
+ Icon and artifact fields have no token. A property whose field is empty is left out. A reference's fields are tokens too (`content.author.name`). The place referencing a record decides its role, such as a testimonial's `author: content.author`; Camox adds the record's node to the graph once, however many blocks link it.
277
+
278
+ Place a repeater's token where its items' nodes go, such as an FAQ's questions:
279
+
280
+ ```tsx
281
+ items: field.repeater({
282
+ content: (field) => ({ question: field.string({ default: "Why?" }), answer: field.string({ default: "Because" }) }),
283
+ minItems: 1,
284
+ maxItems: 20,
285
+ toTitle: ({ content }) => content.question,
286
+ toMarkdown: ({ content }) => [`Q: ${content.question}`, `A: ${content.answer}`],
287
+ toStructuredData: ({ content }) => ({
288
+ "@type": "Question",
289
+ name: content.question,
290
+ acceptedAnswer: { "@type": "Answer", text: content.answer },
291
+ }),
292
+ }),
293
+ // On the block:
294
+ toStructuredData: ({ content }) => content.items,
295
+ ```
296
+
297
+ Settings show a part of the template only while they hold: a Boolean setting takes the part, an Enum setting the option it must equal, then the part. The part is left out otherwise, and a list it shows inside a list adds its entries:
298
+
299
+ ```tsx
300
+ toStructuredData: ({ content, settings }) => [
301
+ { "@type": "Product", name: content.name, description: settings.variant("detailed", content.description), offers: { "@type": "Offer", price: 49, priceCurrency: "EUR" } },
302
+ settings.showFaq(content.questions),
303
+ ],
304
+ ```
305
+
226
306
  ## Rendering in the Component
227
307
 
228
308
  Field renderers receive `(props, data)`:
@@ -25,8 +25,9 @@ const myLayout = createLayout({
25
25
  after: [footerBlock],
26
26
  initial: [() => import("../blocks/hero")],
27
27
  },
28
- component: MyLayoutComponent,
29
- buildMetaTitle: ({ pageMetaTitle, projectName }) => `${pageMetaTitle} | ${projectName}`,
28
+ toReact: MyLayoutComponent,
29
+ toTitle: ({ page, project }) => `${page.title} | ${project.name}`,
30
+ toDescription: ({ page }) => page.description,
30
31
  });
31
32
 
32
33
  function MyLayoutComponent({ children }: { children: React.ReactNode }) {
@@ -44,16 +45,28 @@ export { myLayout as layout };
44
45
 
45
46
  ## The `createLayout` options
46
47
 
47
- | Option | Required | Description |
48
- | ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
49
- | `id` | yes | Unique kebab-case identifier matching the filename without extension. |
50
- | `title` | yes | CMS display name. |
51
- | `description` | yes | Guidance for users and agents on which pages suit this layout. |
52
- | `blocks` | yes | `before`, `after`, and optional `initial` arrays; see layout slot rules below. |
53
- | `component` | yes | Named function component receiving `{ children }`. |
54
- | `buildMetaTitle` | yes | `({ pageMetaTitle, projectName, pageFullPath }) => string` for the browser title. |
55
- | `buildMetaDescription` | no | `({ pageMetaDescription, projectName, pageFullPath }) => string \| undefined`. Without it, the editor's description is used. |
56
- | `buildOgImage` | no | `({ title, description, projectName }) => JSX` for a sharing image. |
48
+ | Option | Required | Description |
49
+ | ------------------ | -------- | ------------------------------------------------------------------------------------------------------ |
50
+ | `id` | yes | Unique kebab-case identifier matching the filename without extension. |
51
+ | `title` | yes | CMS display name. |
52
+ | `description` | yes | Guidance for users and agents on which pages suit this layout. |
53
+ | `blocks` | yes | `before`, `after`, and optional `initial` arrays; see layout slot rules below. |
54
+ | `toReact` | yes | Named function component receiving the context and `{ children }` as props. |
55
+ | `toTitle` | yes | `(context) => string`: the head title, `og:title`, and the Markdown front matter's `title`. |
56
+ | `toDescription` | yes | `(context) => string \| undefined`: the head description and `og:description`. May return `undefined`. |
57
+ | `toImage` | no | `(context) => JSX`: the sharing image; see below. |
58
+ | `toStructuredData` | no | `(context) => node \| undefined`: what the page is, in its JSON-LD; see below. |
59
+
60
+ ## The `to*` options and their context
61
+
62
+ Every `to*` option receives the same context object, and none receives another one's output: Camox joins the outputs itself.
63
+
64
+ - `page.title`: the page's undecorated title. On curated pages, the editor's or generated meta title.
65
+ - `page.description`: on curated pages, the editor's or generated description. Absent otherwise.
66
+ - `page.path`: the page's path, with its locale's prefix. `page.locale`: the locale's tag, such as `de`.
67
+ - `project.name`: the project's name.
68
+
69
+ Singleton and derived layouts also receive `data` and `params`, and record pages `content`; see [Routed layouts](routed-layouts.md#metadata-and-markdown).
57
70
 
58
71
  ## Layout Slots — `before`, `after`, and `initial`
59
72
 
@@ -70,12 +83,12 @@ export { myLayout as layout };
70
83
  - At most one layout per app may define `initial`; multiple definitions cause a startup error.
71
84
  - Without `initial`, Camox seeds an empty homepage using the first curated layout. Apps with only singleton/derived layouts do not seed a curated homepage.
72
85
 
73
- ## OG Image — `buildOgImage` (optional)
86
+ ## OG Image — `toImage` (optional)
74
87
 
75
88
  Returns JSX rendered as a 1200×630 image. Use **inline styles, not Tailwind**, and `display: "flex"` for layout: this runs in an image-generation engine, not a browser.
76
89
 
77
90
  ```tsx
78
- buildOgImage: ({ title, description, projectName }) => (
91
+ toImage: ({ page, project }) => (
79
92
  <div
80
93
  style={{
81
94
  display: "flex",
@@ -88,9 +101,32 @@ buildOgImage: ({ title, description, projectName }) => (
88
101
  fontFamily: "sans-serif",
89
102
  }}
90
103
  >
91
- {projectName && <div style={{ fontSize: 24 }}>{projectName}</div>}
92
- <div style={{ fontSize: 64 }}>{title}</div>
93
- {description && <div style={{ fontSize: 28 }}>{description}</div>}
104
+ {project.name && <div style={{ fontSize: 24 }}>{project.name}</div>}
105
+ <div style={{ fontSize: 64 }}>{page.title}</div>
106
+ {page.description && <div style={{ fontSize: 28 }}>{page.description}</div>}
94
107
  </div>
95
108
  ),
96
109
  ```
110
+
111
+ The site serves the image at `/og?path=<page path>` for real pages only, rendered from the page as it is published. An editor's custom image replaces it, and a layout without one gets Camox's default image showing the page title and project name.
112
+
113
+ ## Structured Data — `toStructuredData` (optional)
114
+
115
+ Every page's head carries a JSON-LD graph that Camox builds itself: the project's `Organization` and `WebSite`, and a `WebPage` named and described by `toTitle` and `toDescription`, with its URL, language, image and publish dates. `toStructuredData` only says what the page is. Return one of:
116
+
117
+ - **A kind of web page**, which the page's node becomes: `AboutPage`, `ContactPage`, `CollectionPage`, `FAQPage`, `ProfilePage`, `ItemPage`, `QAPage`, `SearchResultsPage`, `CheckoutPage`, `MedicalWebPage` or `RealEstateListing`.
118
+ - **A main entity**, emitted as its own node and linked as the page's `mainEntity`: an Article type (`BlogPosting`, `NewsArticle`, `TechArticle`, …), `Product`, `Event`, `Person`, `Place`, `Review`, `Question`, `HowTo`, `Recipe`, `Course`, `JobPosting`, `SoftwareApplication`, `VideoObject`, `ImageObject`, `Book`, `Service`, …. Camox fills what you leave out: the name (an Article's `headline`), description, URL and image, plus the dates, publisher and language of creative works. Values you set win. An Article makes `og:type` `article`.
119
+
120
+ ```tsx
121
+ toStructuredData: () => ({ "@type": "AboutPage" }),
122
+ // A derived layout: the post is the page's main entity.
123
+ toStructuredData: ({ data }) => ({ "@type": "BlogPosting", wordCount: data.wordCount }),
124
+ toStructuredData: ({ data }) => ({
125
+ "@type": "Product",
126
+ offers: { price: data.price, priceCurrency: "EUR" },
127
+ }),
128
+ ```
129
+
130
+ - Only the listed types type-check, each with only its schema.org properties. There's no way out to the rest of schema.org.
131
+ - Properties search engines require and Camox can't fill are required, such as a `Product`'s `offers`, `review` or `aggregateRating`, an `Event`'s `startDate` and `location`, and a `JobPosting`'s `datePosted` and `hiringOrganization`.
132
+ - Layouts can't return `Organization` or `WebSite`: Camox builds the website's own from the project.
@@ -29,10 +29,11 @@ export const layout = createLayout("pokedex")({
29
29
  after: [footerBlock],
30
30
  },
31
31
  loader: async () => ({ kind: "data", data: await loadPokemon() }),
32
- // After the loader: these callbacks' `data` is inferred from it.
33
- buildMetaTitle: ({ pageMetaTitle }) => pageMetaTitle,
32
+ // After the loader: these options' `data` is inferred from it.
33
+ toTitle: ({ page }) => page.title,
34
+ toDescription: ({ data }) => `${data.length} Pokémon to browse.`,
34
35
  toMarkdown: ({ data }) => [`${data.length} Pokémon`, data.map((p) => `- ${p.name}`).join("\n")],
35
- component: PokedexPage,
36
+ toReact: PokedexPage,
36
37
  });
37
38
 
38
39
  function PokedexPage() {
@@ -105,10 +106,10 @@ export const Layout = createLayout("articles.$slug")({
105
106
  description: "One article, found by its slug.",
106
107
  blocks: { before: [navbarBlock], after: [footerBlock] },
107
108
  ...articles.pages({ by: "slug", param: "slug" }),
108
- buildMetaTitle: ({ item, projectName }) => `${item.title} | ${projectName}`,
109
- buildMetaDescription: ({ item }) => item.excerpt || undefined,
109
+ toTitle: ({ page, project }) => `${page.title} | ${project.name}`,
110
+ toDescription: ({ content }) => content.excerpt || undefined,
110
111
  toMarkdown: ({ item }) => [`# ${item.title}`, `By ${item.author.name}`, item.body],
111
- component: ArticlePage,
112
+ toReact: ArticlePage,
112
113
  });
113
114
 
114
115
  function ArticlePage() {
@@ -133,11 +134,13 @@ function ArticlePage() {
133
134
  - Several records sharing a value in the same source is a server error naming the collection, field, value and records. Camox does not enforce uniqueness: keep the field unique.
134
135
  - The loader returns a `collection-item` envelope, `{ kind: "collection-item", collectionId, data: { id, content } }`, typed as `LayoutCollectionItemResult` from `camox/createLayout`. `layout.useData()` returns the unwrapped record, `{ id, content }`. Camox rejects an envelope naming an unregistered collection or a malformed record id.
135
136
  - `routes` lists one entry per record that can have a page, in the request's source: the sitemap lists published records only, even when an editor asks, while the Canvas previews a signed-in editor's first record, draft-only records included. Each entry is `{ params, target: { kind: "collection-item", collectionId, id } }`; `target` names the record independently of its current value. Records whose value is empty or contains `/` are left out. Several records sharing a value fail discovery, so `/sitemap.xml` responds 500 naming the layout and the records. An empty collection returns `{ data: [] }`, and the Canvas asks for a path.
136
- - `layout.Item` renders the page's record with the same typed scope as a block's `Reference`: `Field`, `Image`, `File`, `Embed`, `ImageList`, `FileList`, `Reference` and `ReferenceList`, plus `id` and `label`. Field names are checked against the collection. The record is the first hop: `Reference` and `ReferenceList` render the records it links, which link nothing further. Prefer it to `useData()`, whose `content` holds stored values (String fields may be rich-text state).
137
+ - `layout.Item` renders the page's record with the same typed scope as a block's `Reference`: `Field`, `Image`, `File`, `Embed`, `ImageList`, `FileList`, `Reference` and `ReferenceList`, plus `id`, `title` and `description` (from the collection's `toTitle` and `toDescription`). Field names are checked against the collection. The record is the first hop: `Reference` and `ReferenceList` render the records it links, which link nothing further. Prefer it to `useData()`, whose `content` holds stored values (String fields may be rich-text state).
137
138
  - `layout.Item` exists only when every return of the loader is a record of one collection. On a loader that may return external data or records of several collections, using it is a type error. At runtime it renders nothing when the result isn't a record.
138
139
  - In Studio, editors edit record page fields in place, like a referenced record's: purple overlays, selection, and the record's fields in the sidebar. Edits change the shared record everywhere it appears.
139
140
  - `toMarkdown` also receives `item`, the same Markdown builder as a reference list's `toMarkdown`: interpolate fields (`item.title`, `item.author.name`, `item.cover`) and Camox fills them with the live record, rich text included. `data` stays the raw record. Markdown always shows live content, whoever asks.
140
- - `buildMetaTitle` and `buildMetaDescription` also receive `item`: the record's String fields as plain text.
141
+ - `toTitle`, `toDescription`, `toImage`, `toReact` and `toStructuredData` also receive `content`, the record's String fields as plain text and its references as links to their records' Structured Data nodes. Their `page.title` is the record's title.
142
+ - A collection's `toStructuredData` describes each of its records as one node, from tokens of its fields: `toStructuredData: ({ content }) => ({ "@type": "BlogPosting", author: content.author, image: content.cover })`. Camox fills its name (an Article's `headline`) and description from the record's title and description, and gives it one `@id` per record and locale, which every page referencing the record links. Collections may describe records as an `Organization` (a customer, a partner), never as the `WebSite`.
143
+ - When the layout's `toStructuredData` declares nothing, the record's node is the page's main entity, dated by the record's publish times. Otherwise link the record's references from the layout, such as `toStructuredData: ({ content }) => ({ "@type": "BlogPosting", author: content.author })`. A reference to a collection without `toStructuredData` reads as the record's title. Records the page's content references that no node links are listed as the page's `mentions`.
141
144
  - To wrap the helper, pass the whole loader input through, `context` included, and return the envelope unchanged. Type it as the helper's loader (`ReturnType<typeof articlePages.loader>`) to keep `layout.Item` and `item` typed:
142
145
 
143
146
  ```tsx
@@ -162,22 +165,22 @@ The playground's `/pokedex`, `/pokemon/pikachu`, and `/pokemon-types/electric` a
162
165
 
163
166
  ## Metadata and Markdown
164
167
 
165
- Singleton and derived pages get the same head as curated pages: title, description, `og:*` tags, the `/og` image, and the favicon. They have no editor metadata, so the callbacks receive the loader's unwrapped `data` and the route `params` instead:
168
+ Singleton and derived pages get the same head as curated pages: title, description, `og:*` tags, the `/og` image, and the favicon. They have no editor metadata, so the `to*` options also receive the loader's unwrapped `data` and the route `params`:
166
169
 
167
170
  ```tsx
168
171
  export const Layout = createLayout("pokemon.$name")({
169
172
  kind: "derived",
170
173
  // Other options omitted.
171
174
  loader: async ({ params }) => ({ kind: "data", data: await loadPokemon(params.name) }),
172
- buildMetaTitle: ({ data, pageMetaTitle }) => `${data.name} – ${pageMetaTitle}`,
173
- buildMetaDescription: ({ data }) => `${data.name} is a ${data.types.join("/")} type Pokémon.`,
175
+ toTitle: ({ data, page }) => `${data.name} – ${page.title}`,
176
+ toDescription: ({ data }) => `${data.name} is a ${data.types.join("/")} type Pokémon.`,
174
177
  toMarkdown: ({ data, params }) => [`# ${data.name}`, `Types: ${data.types.join(", ")}`],
175
178
  });
176
179
  ```
177
180
 
178
- - **Put `loader` before `buildMetaTitle`, `buildMetaDescription`, and `toMarkdown`.** TypeScript infers their `data` from the loader only in that order. Otherwise the loader is inferred as `never` and fails to type-check.
179
- - `buildMetaTitle` receives `pageMetaTitle` (the layout's `title` on these pages), `projectName`, `pageFullPath`, `data`, and `params`. The `/og` image shows its result.
180
- - `buildMetaDescription` is optional; without it these pages have no description.
181
+ - **Put `loader` before `toTitle`, `toDescription`, `toImage`, `toReact`, and `toMarkdown`.** TypeScript infers their `data` from the loader only in that order. Otherwise the loader is inferred as `never` and fails to type-check.
182
+ - `toTitle`, `toDescription`, `toImage` and `toReact` receive the same context as on curated pages (see [Layout Definitions](layout-definitions.md#the-to-options-and-their-context)), plus `data` and `params`. Here `page.title` is the layout's `title` (the record's title on record pages) and `page.description` is absent.
183
+ - `toDescription` may return `undefined`: these pages then have no description.
181
184
  - `toMarkdown` returns the page's Markdown Representation as a list of strings. Empty strings are dropped and the rest joined with blank lines. A request with `Accept: text/markdown` gets YAML front matter (the `title` and `description` the head shows, and the page's `url`), then the live before-block Markdown, then this output, then the live after-block Markdown. Curated pages' Markdown opens with the same front matter. Without `toMarkdown`, the page answers HTML only. `throw notFound()` in the loader answers 404. Curated layouts cannot declare `toMarkdown`: their Markdown comes from their blocks.
182
185
  - The playground's Pokémon, Pokémon type, and Pokédex layouts declare these. Try `curl -H "Accept: text/markdown" http://localhost:3000/pokemon/pikachu`.
183
186