@glw907/cairn-cms 0.68.0 → 0.76.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 (177) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/ambient.d.ts +2 -0
  3. package/dist/components/CairnAdmin.svelte.d.ts +2 -7
  4. package/dist/components/ComponentForm.svelte +44 -27
  5. package/dist/components/ComponentInsertDialog.svelte +5 -5
  6. package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
  7. package/dist/components/EditPage.svelte +29 -107
  8. package/dist/components/EditPage.svelte.d.ts +2 -7
  9. package/dist/components/EntryPicker.svelte +117 -0
  10. package/dist/components/EntryPicker.svelte.d.ts +35 -0
  11. package/dist/components/FieldInput.svelte +218 -0
  12. package/dist/components/FieldInput.svelte.d.ts +51 -0
  13. package/dist/components/IconPicker.svelte +2 -2
  14. package/dist/components/IconPicker.svelte.d.ts +2 -0
  15. package/dist/components/LinkPicker.svelte +8 -75
  16. package/dist/components/LinkPicker.svelte.d.ts +4 -5
  17. package/dist/components/MediaHeroField.svelte +8 -5
  18. package/dist/components/MediaHeroField.svelte.d.ts +4 -0
  19. package/dist/components/ObjectGroupField.svelte +54 -0
  20. package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
  21. package/dist/components/ReferenceField.svelte +94 -0
  22. package/dist/components/ReferenceField.svelte.d.ts +27 -0
  23. package/dist/components/RepeatableField.svelte +221 -0
  24. package/dist/components/RepeatableField.svelte.d.ts +53 -0
  25. package/dist/components/cairn-admin.css +4 -0
  26. package/dist/components/preview-doc.js +5 -1
  27. package/dist/components/tidy-validate.js +1 -1
  28. package/dist/content/adapter.js +18 -0
  29. package/dist/content/advisories.d.ts +2 -2
  30. package/dist/content/advisories.js +3 -5
  31. package/dist/content/compose.d.ts +7 -6
  32. package/dist/content/compose.js +26 -20
  33. package/dist/content/concepts.d.ts +21 -15
  34. package/dist/content/concepts.js +55 -32
  35. package/dist/content/field-rules.js +3 -4
  36. package/dist/content/fields.d.ts +49 -1
  37. package/dist/content/fields.js +11 -0
  38. package/dist/content/fieldset.d.ts +31 -10
  39. package/dist/content/fieldset.js +262 -109
  40. package/dist/content/frontmatter-region.d.ts +38 -0
  41. package/dist/content/frontmatter-region.js +75 -0
  42. package/dist/content/frontmatter.d.ts +35 -2
  43. package/dist/content/frontmatter.js +232 -11
  44. package/dist/content/manifest.d.ts +34 -0
  45. package/dist/content/manifest.js +80 -4
  46. package/dist/content/media-refs.d.ts +2 -2
  47. package/dist/content/media-rewrite.js +1 -69
  48. package/dist/content/reference-index.d.ts +56 -0
  49. package/dist/content/reference-index.js +95 -0
  50. package/dist/content/references.d.ts +40 -0
  51. package/dist/content/references.js +0 -0
  52. package/dist/content/standard-schema.d.ts +30 -0
  53. package/dist/content/standard-schema.js +4 -0
  54. package/dist/content/types.d.ts +127 -178
  55. package/dist/delivery/data.d.ts +2 -2
  56. package/dist/delivery/data.js +1 -1
  57. package/dist/delivery/public-routes.d.ts +2 -5
  58. package/dist/delivery/public-routes.js +15 -1
  59. package/dist/delivery/site-descriptors.d.ts +5 -1
  60. package/dist/delivery/site-descriptors.js +8 -3
  61. package/dist/delivery/site-indexes.d.ts +2 -2
  62. package/dist/delivery/site-resolver.d.ts +25 -0
  63. package/dist/delivery/site-resolver.js +49 -0
  64. package/dist/doctor/checks-local.js +6 -11
  65. package/dist/github/backend.d.ts +83 -0
  66. package/dist/github/backend.js +76 -0
  67. package/dist/github/credentials.d.ts +11 -5
  68. package/dist/github/credentials.js +3 -3
  69. package/dist/github/repo.d.ts +8 -19
  70. package/dist/github/repo.js +69 -80
  71. package/dist/github/types.d.ts +1 -1
  72. package/dist/github/types.js +4 -4
  73. package/dist/index.d.ts +16 -12
  74. package/dist/index.js +7 -8
  75. package/dist/islands/index.d.ts +12 -0
  76. package/dist/islands/index.js +83 -0
  77. package/dist/islands/types.d.ts +7 -0
  78. package/dist/islands/types.js +1 -0
  79. package/dist/media/rewrite-plan.d.ts +2 -3
  80. package/dist/media/rewrite-plan.js +2 -3
  81. package/dist/media/usage.d.ts +2 -2
  82. package/dist/media/usage.js +3 -5
  83. package/dist/nav/site-config.d.ts +0 -6
  84. package/dist/nav/site-config.js +6 -4
  85. package/dist/render/component-grammar.js +11 -11
  86. package/dist/render/component-reference.js +5 -3
  87. package/dist/render/component-validate.d.ts +4 -1
  88. package/dist/render/component-validate.js +10 -35
  89. package/dist/render/pipeline.d.ts +0 -6
  90. package/dist/render/pipeline.js +1 -1
  91. package/dist/render/registry.d.ts +34 -34
  92. package/dist/render/registry.js +26 -5
  93. package/dist/render/rehype-dispatch.d.ts +4 -4
  94. package/dist/render/rehype-dispatch.js +36 -11
  95. package/dist/render/remark-directives.js +4 -5
  96. package/dist/render/sanitize-schema.js +1 -1
  97. package/dist/sveltekit/cairn-admin.d.ts +5 -5
  98. package/dist/sveltekit/cairn-admin.js +3 -4
  99. package/dist/sveltekit/content-routes.d.ts +10 -8
  100. package/dist/sveltekit/content-routes.js +269 -181
  101. package/dist/sveltekit/health.d.ts +7 -3
  102. package/dist/sveltekit/health.js +9 -3
  103. package/dist/sveltekit/index.d.ts +1 -1
  104. package/dist/sveltekit/nav-routes.d.ts +6 -5
  105. package/dist/sveltekit/nav-routes.js +22 -20
  106. package/dist/sveltekit/types.d.ts +2 -0
  107. package/dist/vite/index.d.ts +3 -3
  108. package/dist/vite/index.js +17 -8
  109. package/package.json +5 -1
  110. package/src/lib/ambient.ts +7 -0
  111. package/src/lib/components/CairnAdmin.svelte +2 -6
  112. package/src/lib/components/ComponentForm.svelte +48 -27
  113. package/src/lib/components/ComponentInsertDialog.svelte +9 -8
  114. package/src/lib/components/EditPage.svelte +43 -119
  115. package/src/lib/components/EntryPicker.svelte +154 -0
  116. package/src/lib/components/FieldInput.svelte +262 -0
  117. package/src/lib/components/IconPicker.svelte +4 -2
  118. package/src/lib/components/LinkPicker.svelte +10 -81
  119. package/src/lib/components/MediaHeroField.svelte +12 -5
  120. package/src/lib/components/ObjectGroupField.svelte +97 -0
  121. package/src/lib/components/ReferenceField.svelte +126 -0
  122. package/src/lib/components/RepeatableField.svelte +310 -0
  123. package/src/lib/components/preview-doc.ts +5 -1
  124. package/src/lib/components/tidy-validate.ts +1 -1
  125. package/src/lib/content/adapter.ts +21 -0
  126. package/src/lib/content/advisories.ts +4 -7
  127. package/src/lib/content/compose.ts +30 -23
  128. package/src/lib/content/concepts.ts +68 -40
  129. package/src/lib/content/field-rules.ts +3 -4
  130. package/src/lib/content/fields.ts +52 -1
  131. package/src/lib/content/fieldset.ts +291 -128
  132. package/src/lib/content/frontmatter-region.ts +90 -0
  133. package/src/lib/content/frontmatter.ts +231 -15
  134. package/src/lib/content/manifest.ts +101 -4
  135. package/src/lib/content/media-refs.ts +2 -2
  136. package/src/lib/content/media-rewrite.ts +7 -80
  137. package/src/lib/content/reference-index.ts +159 -0
  138. package/src/lib/content/references.ts +0 -0
  139. package/src/lib/content/standard-schema.ts +25 -0
  140. package/src/lib/content/types.ts +128 -195
  141. package/src/lib/delivery/data.ts +2 -2
  142. package/src/lib/delivery/public-routes.ts +17 -3
  143. package/src/lib/delivery/site-descriptors.ts +8 -3
  144. package/src/lib/delivery/site-indexes.ts +2 -2
  145. package/src/lib/delivery/site-resolver.ts +64 -0
  146. package/src/lib/doctor/checks-local.ts +6 -14
  147. package/src/lib/github/backend.ts +161 -0
  148. package/src/lib/github/credentials.ts +10 -7
  149. package/src/lib/github/repo.ts +79 -83
  150. package/src/lib/github/types.ts +5 -5
  151. package/src/lib/index.ts +38 -23
  152. package/src/lib/islands/index.ts +84 -0
  153. package/src/lib/islands/types.ts +11 -0
  154. package/src/lib/media/rewrite-plan.ts +4 -6
  155. package/src/lib/media/usage.ts +4 -7
  156. package/src/lib/nav/site-config.ts +8 -9
  157. package/src/lib/render/component-grammar.ts +10 -10
  158. package/src/lib/render/component-reference.ts +4 -3
  159. package/src/lib/render/component-validate.ts +10 -35
  160. package/src/lib/render/pipeline.ts +1 -7
  161. package/src/lib/render/registry.ts +58 -39
  162. package/src/lib/render/rehype-dispatch.ts +45 -10
  163. package/src/lib/render/remark-directives.ts +4 -5
  164. package/src/lib/render/sanitize-schema.ts +1 -1
  165. package/src/lib/sveltekit/cairn-admin.ts +8 -9
  166. package/src/lib/sveltekit/content-routes.ts +330 -221
  167. package/src/lib/sveltekit/health.ts +13 -6
  168. package/src/lib/sveltekit/index.ts +2 -2
  169. package/src/lib/sveltekit/nav-routes.ts +33 -29
  170. package/src/lib/sveltekit/types.ts +5 -1
  171. package/src/lib/vite/index.ts +20 -11
  172. package/dist/content/schema.d.ts +0 -87
  173. package/dist/content/schema.js +0 -85
  174. package/dist/content/validate.d.ts +0 -17
  175. package/dist/content/validate.js +0 -93
  176. package/src/lib/content/schema.ts +0 -163
  177. package/src/lib/content/validate.ts +0 -90
@@ -19,10 +19,8 @@
19
19
  // is therefore "found in N entries" / "no references found", never a bare "unused": absence of a row
20
20
  // means no reference was found, not a proof that none exists.
21
21
  import type { ConceptDescriptor } from '../content/types.js';
22
- import type { RepoRef } from '../github/types.js';
22
+ import type { Backend } from '../github/backend.js';
23
23
  import type { Manifest } from '../content/manifest.js';
24
- import { listBranches } from '../github/branches.js';
25
- import { readRaw } from '../github/repo.js';
26
24
  import { PENDING_PREFIX, parsePendingBranch } from '../content/pending.js';
27
25
  import { findConcept } from '../content/concepts.js';
28
26
  import { isValidId, filenameFromId } from '../content/ids.js';
@@ -84,8 +82,7 @@ function push(index: UsageIndex, hash: string, entry: UsageEntry): void {
84
82
  * (the load path lists once for the media-union) rather than listing them a second time.
85
83
  */
86
84
  export async function buildUsageIndex(
87
- repo: RepoRef,
88
- token: string,
85
+ backend: Backend,
89
86
  concepts: ConceptDescriptor[],
90
87
  manifest: Manifest,
91
88
  opts: BuildUsageOptions = {},
@@ -108,7 +105,7 @@ export async function buildUsageIndex(
108
105
 
109
106
  // The branch arm: read each open cairn/* branch's one edited file. The path is derivable from the
110
107
  // branch name, so no tree-listing is needed. The branch list is reused when the caller passes it.
111
- const names = opts.branches ?? (await listBranches(repo, PENDING_PREFIX, token));
108
+ const names = opts.branches ?? (await backend.listBranches(PENDING_PREFIX));
112
109
  // Read the branches in parallel rather than one at a time, so the latency floor is one round trip
113
110
  // instead of N. workerd self-throttles to 6 simultaneous outbound connections, so this batch and
114
111
  // the load path's media-union batch each stay under the limit; do NOT merge the two into one
@@ -125,7 +122,7 @@ export async function buildUsageIndex(
125
122
 
126
123
  const path = `${concept.dir}/${filenameFromId(ref.id)}`;
127
124
  try {
128
- const raw = await readRaw({ ...repo, branch: name }, path, token);
125
+ const raw = await backend.readFile(path, name);
129
126
  if (raw === null) return []; // The file is absent on the branch: nothing to extract.
130
127
  const { frontmatter, body } = parseMarkdown(raw);
131
128
  const fmTitle = frontmatter.title;
@@ -3,7 +3,6 @@
3
3
  // commits the file back through the GitHub-App pipeline. This module is pure: parse, validate, and
4
4
  // rewrite only. The engine returns data; each site renders the tree with its own markup.
5
5
  import { parse as parseYaml, parseDocument } from 'yaml';
6
- import type { ConceptUrlPolicy } from '../content/types.js';
7
6
 
8
7
  /** One navigation node. An omitted or empty `url` is a label-only grouping header; no `children` is a leaf. */
9
8
  export interface NavNode {
@@ -75,12 +74,9 @@ export interface SiteConfig {
75
74
  siteName: string;
76
75
  description?: string;
77
76
  author?: string;
78
- url?: string;
79
77
  locale?: string;
80
78
  /** Named navigation menus, each a NavNode[] (normalized by extractMenu). */
81
79
  menus?: Record<string, unknown>;
82
- /** Per-concept URL policy: the permalink pattern and date-prefix granularity, keyed by concept id. */
83
- content?: Record<string, ConceptUrlPolicy>;
84
80
  /**
85
81
  * The editor spellcheck settings. The dialect is declared once per site (spec 1.2), so a British
86
82
  * site loads the British word list and "colour" reads as correct. Today only US English ships, so an
@@ -285,6 +281,14 @@ export function parseSiteConfig(raw: string): SiteConfig {
285
281
  if (typeof siteName !== 'string' || !siteName.trim()) {
286
282
  throw new SiteConfigError('Site config needs a siteName');
287
283
  }
284
+ // Contract v2 moved per-concept URL policy out of the YAML and onto defineConcept. A leftover
285
+ // `content:` block here would silently do nothing while the concept defaulted its permalink, so a
286
+ // half-migrated site (one carrying a non-default datePrefix) would rewrite every post URL. Fail loud.
287
+ if ((parsed as SiteConfig).content !== undefined) {
288
+ throw new SiteConfigError(
289
+ 'cairn: site config no longer carries per-concept URL policy; move permalink/datePrefix into defineConcept (Contract v2)',
290
+ );
291
+ }
288
292
  return parsed as SiteConfig;
289
293
  }
290
294
 
@@ -295,11 +299,6 @@ export function extractMenu(config: SiteConfig, name: string, maxDepth: number):
295
299
  return validateNavTree(menu, maxDepth);
296
300
  }
297
301
 
298
- /** The per-concept URL policy from a parsed config, or an empty policy when the `content` key is absent. */
299
- export function urlPolicyFrom(config: SiteConfig): Record<string, ConceptUrlPolicy> {
300
- return config.content ?? {};
301
- }
302
-
303
302
  /**
304
303
  * Replace one named menu in the YAML site-config text and reserialize, preserving every other
305
304
  * top-level key (siteName, other menus, settings). Parses into a Document so the rest of the file
@@ -9,17 +9,17 @@ const COLON = ':';
9
9
 
10
10
  function attrBlock(def: ComponentDef, values: ComponentValues): string {
11
11
  const parts: string[] = [];
12
- for (const field of def.attributes ?? []) {
13
- const v = values.attributes[field.key];
12
+ for (const [name, field] of Object.entries(def.attributes ?? {})) {
13
+ const v = values.attributes[name];
14
14
  if (field.type === 'boolean') {
15
- if (v === true) parts.push(`${field.key}="true"`);
15
+ if (v === true) parts.push(`${name}="true"`);
16
16
  } else if (typeof v === 'string' && v !== '') {
17
17
  // The directive attribute grammar (mdast-util-directive) treats a literal `"` as the value
18
18
  // terminator and decodes HTML entities, so a backslash escape does not survive a round-trip.
19
19
  // Encode `&` first (so existing entities are not double-decoded) then `"`; the parser decodes
20
20
  // both back. A backslash is literal in this grammar and needs no escaping.
21
21
  const escaped = v.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
22
- parts.push(`${field.key}="${escaped}"`);
22
+ parts.push(`${name}="${escaped}"`);
23
23
  }
24
24
  }
25
25
  return parts.length ? `{${parts.join(' ')}}` : '';
@@ -103,10 +103,10 @@ function valuesFromRoot(root: (RootContent & DirectiveNode) | undefined, def: Co
103
103
  const values = emptyComponentValues(def);
104
104
  if (!root) return values;
105
105
 
106
- for (const field of def.attributes ?? []) {
107
- const raw = root.attributes?.[field.key];
108
- if (field.type === 'boolean') values.attributes[field.key] = raw === 'true';
109
- else if (typeof raw === 'string') values.attributes[field.key] = raw;
106
+ for (const [name, field] of Object.entries(def.attributes ?? {})) {
107
+ const raw = root.attributes?.[name];
108
+ if (field.type === 'boolean') values.attributes[name] = raw === 'true';
109
+ else if (typeof raw === 'string') values.attributes[name] = raw;
110
110
  }
111
111
 
112
112
  const titleSlot = slotByName(def, 'title');
@@ -181,7 +181,7 @@ export async function componentRoundTripSafety(markdown: string, def: ComponentD
181
181
  const root = findComponentRoot(markdown, def);
182
182
  if (!root) return { safe: false, reason: 'not-a-component' };
183
183
 
184
- const declaredKeys = new Set((def.attributes ?? []).map((f) => f.key));
184
+ const declaredKeys = new Set(Object.keys(def.attributes ?? {}));
185
185
  for (const key of parseRawAttributeKeys(markdown, def)) {
186
186
  if (!declaredKeys.has(key)) return { safe: false, reason: 'unknown-attribute' };
187
187
  }
@@ -220,7 +220,7 @@ export async function parseComponentWithRawKeys(
220
220
  // here; the parse must overwrite only the fields actually present in the markdown.
221
221
  function emptyComponentValues(def: ComponentDef): ComponentValues {
222
222
  const attributes: Record<string, string | boolean> = {};
223
- for (const f of def.attributes ?? []) attributes[f.key] = f.type === 'boolean' ? false : '';
223
+ for (const [name, field] of Object.entries(def.attributes ?? {})) attributes[name] = field.type === 'boolean' ? false : '';
224
224
  const slots: Record<string, string | string[]> = {};
225
225
  for (const s of def.slots ?? []) slots[s.name] = s.kind === 'repeatable' ? [] : '';
226
226
  return { attributes, slots };
@@ -27,9 +27,10 @@ function componentSection(def: ComponentDef): string {
27
27
  /** Seed example values that show every declared field: an ellipsis for strings, one sample list item. */
28
28
  function exampleValues(def: ComponentDef): ComponentValues {
29
29
  const values = emptyValues(def);
30
- for (const field of def.attributes ?? []) {
31
- if (field.type === 'boolean') values.attributes[field.key] = false;
32
- else values.attributes[field.key] = field.options?.[0] ?? '…';
30
+ for (const [name, field] of Object.entries(def.attributes ?? {})) {
31
+ if (field.type === 'boolean') values.attributes[name] = false;
32
+ else if (field.type === 'select') values.attributes[name] = field.options[0] ?? '…';
33
+ else values.attributes[name] = '…';
33
34
  }
34
35
  for (const slot of def.slots ?? []) {
35
36
  if (slot.kind === 'repeatable') values.slots[slot.name] = ['…'];
@@ -1,38 +1,25 @@
1
1
  import { parseComponentWithRawKeys } from './component-grammar.js';
2
- import type { ComponentDef, ComponentValues } from './registry.js';
2
+ import { fieldset } from '../content/fieldset.js';
3
+ import type { ComponentDef } from './registry.js';
3
4
 
4
5
  /** A validation verdict: ok, or field-keyed error messages. */
5
6
  export type ComponentValidation = { ok: true } | { ok: false; errors: Record<string, string> };
6
7
 
7
8
  /**
8
- *
9
+ * Validate a serialized component directive against its definition: the attributes through the same
10
+ * `fieldset` validator a concept field uses (coercion, constraints, required, select domain, pattern,
11
+ * and any per-attribute `behavior.validate`), then the two component-only checks, an unknown attribute
12
+ * key and an unfilled required slot.
9
13
  */
10
14
  export async function validateComponent(markdown: string, def: ComponentDef): Promise<ComponentValidation> {
11
15
  const { values, rawKeys } = await parseComponentWithRawKeys(markdown, def);
12
16
  const errors: Record<string, string> = {};
13
- const declared = new Set((def.attributes ?? []).map((f) => f.key));
14
17
 
15
- for (const field of def.attributes ?? []) {
16
- const v = values.attributes[field.key];
17
- const filled = field.type === 'boolean' ? true : typeof v === 'string' && v !== '';
18
- if (field.required && !filled) {
19
- errors[field.key] = `${field.label} is required.`;
20
- continue;
21
- }
22
- if (field.type === 'select' && typeof v === 'string' && v !== '' && !(field.options ?? []).includes(v)) {
23
- errors[field.key] = `${field.label} must be one of: ${(field.options ?? []).join(', ')}.`;
24
- continue;
25
- }
26
- if (field.pattern && typeof v === 'string' && v !== '' && !new RegExp(field.pattern.source).test(v)) {
27
- errors[field.key] = field.pattern.message;
28
- continue;
29
- }
30
- if (field.validate) {
31
- const message = runFieldValidator(def, field.key, () => field.validate!(v, values));
32
- if (typeof message === 'string') errors[field.key] = message;
33
- }
34
- }
18
+ const schema = def.attributeSchema ?? fieldset(def.attributes ?? {}, { behavior: def.behavior });
19
+ const result = schema.validate(values.attributes, '');
20
+ if (!result.ok) Object.assign(errors, result.errors);
35
21
 
22
+ const declared = new Set(Object.keys(def.attributes ?? {}));
36
23
  for (const key of rawKeys) {
37
24
  if (!declared.has(key)) errors[key] = `Unknown attribute "${key}".`;
38
25
  }
@@ -46,15 +33,3 @@ export async function validateComponent(markdown: string, def: ComponentDef): Pr
46
33
 
47
34
  return Object.keys(errors).length ? { ok: false, errors } : { ok: true };
48
35
  }
49
-
50
- // Run a site-supplied attribute validator. The validator is author code, so a throw is contained:
51
- // the field is treated as valid and a dev-time warning names the component and field so the author
52
- // can find the bug. A returned string is the field error; anything else (null) is clean.
53
- function runFieldValidator(def: ComponentDef, key: string, call: () => string | null): string | null {
54
- try {
55
- return call();
56
- } catch (err) {
57
- console.warn(`cairn: validate() for component "${def.name}" field "${key}" threw; treating the field as valid.`, err);
58
- return null;
59
- }
60
- }
@@ -20,12 +20,6 @@ import { defineRegistry, type ComponentRegistry } from './registry.js';
20
20
  import type { LinkResolve } from '../content/links.js';
21
21
 
22
22
  export interface RendererOptions {
23
- /**
24
- * Stamp a `data-rise` ordinal (0, 1, 2, …) on each top-level component so a site's
25
- * CSS can drive an entrance-cascade delay off it. Omit for no stagger. The ordinal
26
- * is inert, so a consumer's sanitize floor can keep `data-rise` and drop `style`.
27
- */
28
- stagger?: boolean;
29
23
  /**
30
24
  * Extend the sanitize allowlist. Receives cairn's default schema (defaultSchema plus the
31
25
  * directive markers and the common benign tags) and returns the schema to use. Add to the
@@ -73,7 +67,7 @@ export function createRenderer(
73
67
  const rehypePlugins: PluggableList = [
74
68
  rehypeRaw,
75
69
  ...floor,
76
- [rehypeDispatch, registry, options.stagger],
70
+ [rehypeDispatch, registry],
77
71
  rehypeSlug,
78
72
  // Name each GFM task-list checkbox from its item text. It runs after the sanitize floor (which
79
73
  // does not allow aria-label) so the added attribute survives, and is content-not-sink, so it is
@@ -4,33 +4,9 @@
4
4
  // parser, the render dispatch, and the editor never drift apart. The adapter references
5
5
  // `ComponentRegistry` from here.
6
6
  import type { Element, ElementContent } from 'hast';
7
-
8
- /** The input types a component attribute or repeatable item field can take. */
9
- export type FieldType = 'text' | 'select' | 'icon' | 'boolean';
10
-
11
- /** One `{key="value"}` attribute on a component directive, or one field of a repeatable item. */
12
- export interface AttributeField {
13
- /** The attribute name as it appears in the directive, e.g. `icon`. */
14
- key: string;
15
- /** The form label. */
16
- label: string;
17
- type: FieldType;
18
- required?: boolean;
19
- /** Initial value; a string for text/select/icon, a boolean for boolean. */
20
- default?: string | boolean;
21
- /** Allowed values for `type: 'select'`. */
22
- options?: readonly string[];
23
- /** Helper text shown under the field. */
24
- help?: string;
25
- /** A RegExp `source` to validate the value against, plus the message to show on a mismatch. */
26
- pattern?: { source: string; message: string };
27
- /**
28
- * A pure, browser-safe cross-field validator. Returns an error string, or null when valid.
29
- * Receives the field's value and the full {@link ComponentValues} so a rule can read sibling
30
- * fields. The picker wraps the call in try/catch so an author's throw never crashes the form.
31
- */
32
- validate?: (value: string | boolean, all: ComponentValues) => string | null;
33
- }
7
+ import type { FieldDescriptor } from '../content/fields.js';
8
+ import type { BehaviorTable, Fieldset } from '../content/fieldset.js';
9
+ import { fieldset } from '../content/fieldset.js';
34
10
 
35
11
  export type SlotKind = 'markdown' | 'inline' | 'repeatable';
36
12
 
@@ -45,7 +21,7 @@ export interface SlotDef {
45
21
  required?: boolean;
46
22
  help?: string;
47
23
  /** For `kind: 'repeatable'`: the fields composing each list item (v1 uses the first field). */
48
- itemFields?: AttributeField[];
24
+ itemFields?: Record<string, FieldDescriptor>;
49
25
  /**
50
26
  * For `kind: 'repeatable'`: derives a row's label from its item values and zero-based index.
51
27
  * When it returns nothing, the picker falls back to `${label} ${index + 1}`.
@@ -82,10 +58,18 @@ export interface ComponentDef {
82
58
  insertTemplate?: string;
83
59
  /**
84
60
  * Build the final hast element from the component context (attributes plus partitioned
85
- * slots). The engine stamps the entrance-stagger ordinal (`data-rise`) on the top-level
61
+ * slots). The engine stamps the entrance ordinal (`data-rise`) on the top-level
86
62
  * result, so a build fn stays free of any motion concern.
87
63
  */
88
64
  build: (ctx: ComponentContext) => Element;
65
+ /**
66
+ * Opt this directive into client hydration (phase 4b islands). `true` mounts the island eagerly on
67
+ * first load and after client-side navigation; `'visible'` defers the mount to first intersection.
68
+ * The engine wraps {@link ComponentDef.build}'s output in an island boundary, and the site registers
69
+ * the live Svelte component under the same name on `rendering.islands`. Absent leaves the directive a
70
+ * static, server-only component.
71
+ */
72
+ hydrate?: boolean | 'visible';
89
73
  /**
90
74
  * Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. Maps a free-string role to a
91
75
  * glyph key in the site IconSet; choose a logically representative glyph and prefer glyphs
@@ -95,8 +79,18 @@ export interface ComponentDef {
95
79
  defaultIconByRole?: Record<string, string>;
96
80
  /** One line on when to reach for this component; feeds the picker and the reference file. */
97
81
  use?: string;
98
- /** The `{key="value"}` attributes this component accepts. */
99
- attributes?: AttributeField[];
82
+ /** The `{key="value"}` attributes this component accepts, keyed by attribute name. */
83
+ attributes?: Record<string, FieldDescriptor>;
84
+ /**
85
+ * Per-attribute function-valued behavior (a cross-field `validate`), keyed by attribute name.
86
+ * {@link defineComponent} bundles it into the attribute {@link Fieldset}.
87
+ */
88
+ behavior?: BehaviorTable;
89
+ /**
90
+ * The attribute validator {@link defineComponent} builds from `attributes` and `behavior`.
91
+ * Engine-internal: the constructor sets it, and {@link validateComponent} runs it.
92
+ */
93
+ attributeSchema?: Fieldset;
100
94
  /** The named content regions this component accepts. */
101
95
  slots?: SlotDef[];
102
96
  /**
@@ -123,8 +117,8 @@ export interface ComponentRegistry {
123
117
  names: string[];
124
118
  get(name: string): ComponentDef | undefined;
125
119
  defaultIcon(name: string, role?: string): string | undefined;
126
- /** The component's first `type:'icon'` attribute, or undefined when it declares none. */
127
- iconField(name: string): AttributeField | undefined;
120
+ /** The name of the component's first `type:'icon'` attribute, or undefined when it declares none. */
121
+ iconField(name: string): string | undefined;
128
122
  }
129
123
 
130
124
  /**
@@ -137,12 +131,12 @@ export function dataAttrProp(key: string): string {
137
131
  }
138
132
 
139
133
  /**
140
- * A component's first `type:'icon'` attribute, or undefined when it declares none. Both the
141
- * construction-time guard and the registry's `iconField` derive the icon field from this one
134
+ * The name of a component's first `type:'icon'` attribute, or undefined when it declares none. Both
135
+ * the construction-time guard and the registry's `iconField` derive the icon field from this one
142
136
  * predicate rather than spelling the `type === 'icon'` find twice.
143
137
  */
144
- function findIconField(def: ComponentDef): AttributeField | undefined {
145
- return def.attributes?.find((field) => field.type === 'icon');
138
+ function findIconField(def: ComponentDef): string | undefined {
139
+ return Object.entries(def.attributes ?? {}).find(([, field]) => field.type === 'icon')?.[0];
146
140
  }
147
141
 
148
142
  /**
@@ -209,8 +203,8 @@ export interface ComponentValues {
209
203
  */
210
204
  export function emptyValues(def: ComponentDef): ComponentValues {
211
205
  const attributes: Record<string, string | boolean> = {};
212
- for (const field of def.attributes ?? []) {
213
- attributes[field.key] = field.default ?? (field.type === 'boolean' ? false : '');
206
+ for (const [name, field] of Object.entries(def.attributes ?? {})) {
207
+ attributes[name] = field.default ?? (field.type === 'boolean' ? false : '');
214
208
  }
215
209
  const slots: Record<string, string | string[]> = {};
216
210
  for (const slot of def.slots ?? []) {
@@ -232,3 +226,28 @@ export function previewValues(def: ComponentDef): ComponentValues {
232
226
  slots: { ...base.slots, ...def.preview.slots },
233
227
  };
234
228
  }
229
+
230
+ /** The descriptor types that serialize to a single directive-attribute string (decision 2). */
231
+ const ATTRIBUTE_TYPES = new Set(['text', 'textarea', 'number', 'select', 'url', 'email', 'date', 'datetime', 'boolean', 'icon']);
232
+
233
+ /** Reject an attribute type that cannot serialize to a single directive-attribute string (decision 2). */
234
+ function checkComponentAttributes(name: string, attributes: Record<string, FieldDescriptor>): void {
235
+ for (const [key, field] of Object.entries(attributes)) {
236
+ if (!ATTRIBUTE_TYPES.has(field.type)) {
237
+ throw new Error(
238
+ `cairn: component "${name}" attribute "${key}" is type "${field.type}"; a directive attribute must be a single-value scalar (text, textarea, number, select, url, email, date, datetime, boolean, or icon).`,
239
+ );
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Declare a site component, building its attribute validator from the `fields.*` descriptors and
246
+ * validating the component at declaration. Mirrors {@link defineConcept}: a malformed attribute type
247
+ * or pattern fails at module load. The built `attributeSchema` is what {@link validateComponent} runs.
248
+ */
249
+ export function defineComponent<const D extends ComponentDef>(def: D): D & { attributeSchema: Fieldset } {
250
+ const attributes = def.attributes ?? {};
251
+ checkComponentAttributes(def.name, attributes);
252
+ return { ...def, attributeSchema: fieldset(attributes, { behavior: def.behavior }) };
253
+ }
@@ -69,7 +69,7 @@ export function markFirstList(children: ElementContent[]): Element | undefined {
69
69
 
70
70
  // Recurse into a node's children, transforming any nested primitive sections
71
71
  // (a grid inside a card, panels inside a split). Nested primitives never carry the
72
- // entrance stagger; only top-level ones do (stamped in the transformer below).
72
+ // entrance ordinal; only top-level ones do (stamped in the transformer below).
73
73
  function transformChildren(children: ElementContent[], registry: ComponentRegistry): ElementContent[] {
74
74
  return children.map((c) => {
75
75
  if (isElement(c) && c.properties?.dataPrimitive) return transformNode(c, registry);
@@ -82,10 +82,10 @@ function transformChildren(children: ElementContent[], registry: ComponentRegist
82
82
  // 'true'/'false'; everything else is the literal string the author wrote.
83
83
  function readAttributes(node: Element, def: ComponentDef): Record<string, string | boolean> {
84
84
  const out: Record<string, string | boolean> = {};
85
- for (const field of def.attributes ?? []) {
86
- const value = strProp(node, dataAttrProp(field.key));
85
+ for (const [name, field] of Object.entries(def.attributes ?? {})) {
86
+ const value = strProp(node, dataAttrProp(name));
87
87
  if (value == null) continue;
88
- out[field.key] = field.type === 'boolean' ? value === 'true' : value;
88
+ out[name] = field.type === 'boolean' ? value === 'true' : value;
89
89
  }
90
90
  return out;
91
91
  }
@@ -135,6 +135,40 @@ function partitionSlots(node: Element): {
135
135
  };
136
136
  }
137
137
 
138
+ // Serialize a hydrate component's declared attributes into the island prop payload. A `number` field is
139
+ // coerced from its stamped string to a JSON number here; a `boolean` already arrived as a real boolean
140
+ // from readAttributes (which coerces 'true'/'false' upstream), and every other field stays the literal
141
+ // string the author wrote. The result is JSON.stringify-ed into data-cairn-props and parsed on the client.
142
+ function serializeIslandProps(
143
+ def: ComponentDef,
144
+ attributes: Record<string, string | boolean>,
145
+ ): Record<string, string | number | boolean> {
146
+ const out: Record<string, string | number | boolean> = {};
147
+ for (const [key, value] of Object.entries(attributes)) {
148
+ const type = def.attributes?.[key]?.type;
149
+ out[key] = type === 'number' && typeof value === 'string' ? Number(value) : value;
150
+ }
151
+ return out;
152
+ }
153
+
154
+ // Wrap a hydrate component's static fallback in its island boundary. The boundary carries the directive
155
+ // name and the JSON prop payload; a 'visible' island also carries data-cairn-hydrate="visible". The
156
+ // boundary attributes are inert data-* and survive both the sanitize floor (this runs after it) and the
157
+ // sink guard (which strips only style/on*). The fallback is build()'s no-JS, first-paint representation.
158
+ function islandBoundary(
159
+ name: string,
160
+ def: ComponentDef,
161
+ attributes: Record<string, string | boolean>,
162
+ fallback: Element,
163
+ ): Element {
164
+ const properties: Record<string, string> = {
165
+ dataCairnIsland: name,
166
+ dataCairnProps: JSON.stringify(serializeIslandProps(def, attributes)),
167
+ };
168
+ if (def.hydrate === 'visible') properties.dataCairnHydrate = 'visible';
169
+ return { type: 'element', tagName: 'div', properties, children: [fallback] };
170
+ }
171
+
138
172
  function transformNode(node: Element, registry: ComponentRegistry): Element {
139
173
  node.children = transformChildren(node.children as ElementContent[], registry);
140
174
  const name = strProp(node, 'dataPrimitive');
@@ -147,24 +181,25 @@ function transformNode(node: Element, registry: ComponentRegistry): Element {
147
181
  items: parts.items,
148
182
  node,
149
183
  };
150
- return def.build(ctx);
184
+ const built = def.build(ctx);
185
+ return def.hydrate ? islandBoundary(name!, def, ctx.attributes, built) : built;
151
186
  }
152
187
 
153
188
  /**
154
189
  * Rehype transformer: dispatch each stamped element through its registry `build`
155
- * fn. When `stagger` is on, each top-level primitive gets a `data-rise` attribute
156
- * carrying its document-order index (0, 1, 2, …); the site's CSS maps that ordinal
157
- * to an entrance delay. The index is inert, so a consumer's sanitize floor can keep
190
+ * fn. Each top-level primitive gets a `data-rise` attribute carrying its
191
+ * document-order index (0, 1, 2, …); the site's CSS maps that ordinal to an
192
+ * entrance delay. The index is inert, so a consumer's sanitize floor can keep
158
193
  * `data-rise` while dropping `style`. Nested primitives never get it. Non-primitive
159
194
  * content (lede, intro paragraphs, the page-toc nav) passes through untouched.
160
195
  */
161
- export function rehypeDispatch(registry: ComponentRegistry, stagger?: boolean) {
196
+ export function rehypeDispatch(registry: ComponentRegistry) {
162
197
  return (tree: Root) => {
163
198
  let idx = 0;
164
199
  tree.children = (tree.children as ElementContent[]).map((child) => {
165
200
  if (isElement(child) && child.properties?.dataPrimitive) {
166
201
  const el = transformNode(child, registry);
167
- if (stagger) el.properties = { ...el.properties, dataRise: String(idx++) };
202
+ el.properties = { ...el.properties, dataRise: String(idx++) };
168
203
  return el;
169
204
  }
170
205
  if (isElement(child)) child.children = transformChildren(child.children as ElementContent[], registry);
@@ -62,8 +62,7 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
62
62
  const def = registry.get(node.name);
63
63
  const attrs = node.attributes ?? {};
64
64
  const role = attrs.role || undefined;
65
- const iconField = registry.iconField(node.name);
66
- const iconKey = iconField?.key ?? 'icon';
65
+ const iconKey = registry.iconField(node.name) ?? 'icon';
67
66
  let icon = attrs[iconKey] || undefined;
68
67
  if (!icon && role) icon = registry.defaultIcon(node.name, role);
69
68
 
@@ -76,9 +75,9 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
76
75
  // back to that default the same way a missing one does. data-attr-<key> survives to the
77
76
  // element; build() consumes it and returns a fresh element, so the marker never reaches the
78
77
  // published DOM.
79
- for (const field of def?.attributes ?? []) {
80
- const raw = field === iconField ? icon : attrs[field.key];
81
- if (raw != null) properties[dataAttrProp(field.key)] = raw;
78
+ for (const name of Object.keys(def?.attributes ?? {})) {
79
+ const raw = name === iconKey ? icon : attrs[name];
80
+ if (raw != null) properties[dataAttrProp(name)] = raw;
82
81
  }
83
82
 
84
83
  const data = node.data ?? (node.data = {});
@@ -23,7 +23,7 @@ export function buildSanitizeSchema(
23
23
  registry: ComponentRegistry,
24
24
  extend?: (defaults: Schema) => Schema,
25
25
  ): Schema {
26
- const attrMarkers = registry.defs.flatMap((d) => (d.attributes ?? []).map((a) => dataAttrProp(a.key)));
26
+ const attrMarkers = registry.defs.flatMap((d) => Object.keys(d.attributes ?? {}).map((key) => dataAttrProp(key)));
27
27
  const markers = [...FIXED_MARKERS, ...attrMarkers];
28
28
  const attributes = defaultSchema.attributes ?? {};
29
29
  // defaultSchema's `a` entry carries a className tuple (`['className', 'data-footnote-backref']`)
@@ -22,7 +22,7 @@ import { createEditorRoutes } from './editors-routes.js';
22
22
  import { createNavRoutes, type NavLoadData } from './nav-routes.js';
23
23
  import type { AuthBranding, SendMagicLink } from '../email.js';
24
24
  import type { AuthEnv, Editor } from '../auth/types.js';
25
- import type { GithubKeyEnv } from '../github/credentials.js';
25
+ import type { BackendEnv } from '../github/credentials.js';
26
26
  import type { CairnRuntime } from '../content/types.js';
27
27
  import type { CookieJar, EventBase } from './types.js';
28
28
 
@@ -31,20 +31,20 @@ import type { CookieJar, EventBase } from './types.js';
31
31
  * (ContentEvent minus params, which the dispatcher synthesizes, plus RequestContext's cookies
32
32
  * and setHeaders). A real SvelteKit RequestEvent satisfies it.
33
33
  */
34
- export interface AdminEvent extends EventBase<GithubKeyEnv & AuthEnv> {
34
+ export interface AdminEvent extends EventBase<BackendEnv & AuthEnv> {
35
35
  cookies: CookieJar;
36
36
  setHeaders(headers: Record<string, string>): void;
37
37
  }
38
38
 
39
39
  /**
40
40
  * Injectable dependencies. Branding defaults from the runtime's siteName and sender, so a
41
- * site overrides it only to change the magic-link email identity; `send` and `mintToken`
42
- * are the same seams the underlying factories take.
41
+ * site overrides it only to change the magic-link email identity; `send` is the same seam the
42
+ * underlying auth factory takes. The content backend rides `event.locals.backend` (the dev double)
43
+ * or the adapter's provider, so it is not a dep here.
43
44
  */
44
45
  export interface CairnAdminDeps {
45
46
  branding?: AuthBranding;
46
47
  send?: SendMagicLink;
47
- mintToken?: ContentRoutesDeps['mintToken'];
48
48
  /**
49
49
  * Build the Anthropic client for the tidy action. Forwarded to the content routes; a site that
50
50
  * enables tidy injects a stub here to avoid a real network call. Defaults to the real SDK client.
@@ -83,13 +83,12 @@ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {
83
83
  };
84
84
  const auth = createAuthRoutes({ branding, send: deps.send });
85
85
  const content = createContentRoutes(runtime, {
86
- mintToken: deps.mintToken,
87
86
  anthropic: deps.anthropic,
88
87
  tidyTimeoutMs: deps.tidyTimeoutMs,
89
88
  });
90
89
  const editors = createEditorRoutes();
91
90
  // The nav surface exists only when the site configures a menu; without one its view is a 404.
92
- const nav = runtime.navMenu ? createNavRoutes(runtime, { mintToken: deps.mintToken }) : null;
91
+ const nav = runtime.navMenu ? createNavRoutes(runtime) : null;
93
92
 
94
93
  /**
95
94
  * Build the event a wrapped content load reads. The catch-all route carries only a rest
@@ -111,8 +110,8 @@ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {
111
110
 
112
111
  /**
113
112
  * Serve the admin view the pathname names, or a 404 for any shape the parser refuses.
114
- * The authed views run the layout load and the view load concurrently; both mint a GitHub
115
- * token, and the installation-token cache coalesces the mints into one signing.
113
+ * The authed views run the layout load and the view load concurrently; both resolve the same
114
+ * backend, and the installation-token cache coalesces their lazy mints into one signing.
116
115
  */
117
116
  async function load(event: AdminEvent): Promise<AdminData> {
118
117
  const view = parseAdminPath(event.url.pathname, runtime.concepts);