@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
package/dist/index.js CHANGED
@@ -2,14 +2,11 @@
2
2
  // GitHub read-and-commit backend in Plan 03; render and nav follow.
3
3
  export { requireOrigin } from './env.js';
4
4
  export { buildMagicLinkMessage, cloudflareSend } from './email.js';
5
- export { CONCEPT_ROUTING, normalizeConcepts, findConcept } from './content/concepts.js';
5
+ export { normalizeConcepts, findConcept, defineConcept } from './content/concepts.js';
6
6
  export { composeRuntime } from './content/compose.js';
7
7
  export { frontmatterFromForm, dateInputValue, serializeMarkdown, parseMarkdown, } from './content/frontmatter.js';
8
- export { defineFields } from './content/schema.js';
9
8
  export { defineAdapter } from './content/adapter.js';
10
- // The Contract v2 field vocabulary, additive beside `defineFields`. The individual *Field
11
- // interfaces and the bare `Infer` stay module-local: the old `FrontmatterField` model above
12
- // already exports those names, and the cutover plan frees them.
9
+ // The Contract v2 field vocabulary: the one live field system.
13
10
  export { fields } from './content/fields.js';
14
11
  export { fieldset, initialValues } from './content/fieldset.js';
15
12
  export { isValidId, idFromFilename, filenameFromId, slugify, slugFromId, composeDatedId, } from './content/ids.js';
@@ -17,9 +14,9 @@ export { isValidId, idFromFilename, filenameFromId, slugify, slugFromId, compose
17
14
  // builder and the request-time resolver ship from the delivery entry; this surface is the
18
15
  // grammar, the manifest operations, and their types a migrating site adopts.
19
16
  export { parseCairnToken, extractCairnLinks, formatCairnToken, escapeLinkText } from './content/links.js';
20
- export { serializeManifest, parseManifest, emptyManifest, verifyManifest, diffManifests, upsertEntry, removeEntry, manifestEntryFromFile, manifestLinkResolver, inboundLinks, } from './content/manifest.js';
17
+ export { serializeManifest, parseManifest, emptyManifest, verifyManifest, verifyReferences, diffManifests, upsertEntry, removeEntry, manifestEntryFromFile, manifestLinkResolver, inboundLinks, } from './content/manifest.js';
21
18
  // Render engine (Plan 04): generic directive pipeline; sites own the component registry.
22
- export { defineRegistry, emptyValues } from './render/registry.js';
19
+ export { defineRegistry, defineComponent, emptyValues } from './render/registry.js';
23
20
  export { serializeComponent, parseComponent } from './render/component-grammar.js';
24
21
  export { validateComponent } from './render/component-validate.js';
25
22
  export { buildComponentInsert } from './render/component-insert.js';
@@ -32,5 +29,7 @@ export { remarkDirectiveStamp } from './render/remark-directives.js';
32
29
  // path. See docs/superpowers/specs/2026-06-05-cairn-render-authoring-surface-design.md.
33
30
  export { createRenderer } from './render/pipeline.js';
34
31
  export { CommitConflictError } from './github/types.js';
32
+ // The Backend seam (Contract v2 backend phase): the store interface and its default GitHub provider.
33
+ export { githubApp } from './github/backend.js';
35
34
  // Nav tree and site-config helpers (Plan 06).
36
- export { parseSiteConfig, urlPolicyFrom, extractMenu, setMenu, validateNavTree, MAX_NAV_NODES, NavValidationError, SiteConfigError, } from './nav/site-config.js';
35
+ export { parseSiteConfig, extractMenu, setMenu, validateNavTree, MAX_NAV_NODES, NavValidationError, SiteConfigError, } from './nav/site-config.js';
@@ -0,0 +1,12 @@
1
+ import type { IslandRegistry } from './types.js';
2
+ export type { IslandRegistry } from './types.js';
3
+ /**
4
+ * Mount each island in `root` (default `document`) over its server-rendered fallback. Call it after each
5
+ * client-side navigation, once the new DOM is in place (an `afterNavigate` callback): it tears down the
6
+ * previous pass first, so it is idempotent and leak-free. An eager island (`hydrate: true`) mounts at once;
7
+ * a `'visible'` island mounts on first intersection. An unknown directive name, a malformed prop payload,
8
+ * or a component that throws leaves the static fallback in place, so one bad island never breaks the page.
9
+ * Mount-and-replace clears the fallback, so an island whose fallback holds a focusable control should
10
+ * restore focus itself; the shipped fallbacks are non-interactive.
11
+ */
12
+ export declare function hydrateIslands(islands: IslandRegistry, root?: ParentNode): void;
@@ -0,0 +1,83 @@
1
+ // cairn-cms islands (@glw907/cairn-cms/islands): the client runtime that mounts a site's live Svelte
2
+ // components over the static fallbacks the render pipeline emits. cairn is Svelte-only by design, so this
3
+ // mounts with Svelte's own mount()/unmount() directly, with no framework abstraction. A site imports this
4
+ // dynamically, gated on a non-empty registry, so a static site never ships it (zero cost when unused).
5
+ import { mount, unmount } from 'svelte';
6
+ // The live Svelte instances of the current pass and the observers still waiting to fire, kept module-level
7
+ // so the next pass can tear the previous one down. A layout calls hydrateIslands once per navigation, and
8
+ // the previous mounts must unmount before the next mount over the same DOM.
9
+ let mounted = [];
10
+ let observers = [];
11
+ // Tear down the previous pass: unmount live instances and disconnect observers that never fired. unmount
12
+ // runs with outro: false so teardown is synchronous and deterministic on navigation; an island declaring an
13
+ // out: transition would otherwise linger and briefly double-render against the next pass's fresh mount.
14
+ function teardown() {
15
+ for (const o of observers)
16
+ o.disconnect();
17
+ observers = [];
18
+ for (const instance of mounted) {
19
+ try {
20
+ void unmount(instance, { outro: false });
21
+ }
22
+ catch {
23
+ // a component that throws on teardown must not block the rest
24
+ }
25
+ }
26
+ mounted = [];
27
+ }
28
+ // Mount one island over its boundary: parse props (try/catch, a malformed payload leaves the fallback),
29
+ // clear the fallback, mount, and on a mount failure restore the fallback so the reader still sees content.
30
+ // WATCH: props are trusted to equal the directive's declared scalar attributes (serializeIslandProps emits
31
+ // only those). If a directive ever carries an attribute its island does not declare, this forwards it as-is.
32
+ function mountIsland(node, Comp) {
33
+ let props;
34
+ try {
35
+ props = JSON.parse(node.getAttribute('data-cairn-props') ?? '{}');
36
+ }
37
+ catch {
38
+ return;
39
+ }
40
+ const fallback = [...node.childNodes];
41
+ node.replaceChildren();
42
+ try {
43
+ mounted.push(mount(Comp, { target: node, props }));
44
+ }
45
+ catch {
46
+ node.replaceChildren(...fallback);
47
+ }
48
+ }
49
+ // Defer a 'visible' island to first intersection, then mount once and stop observing.
50
+ function observeIsland(node, Comp) {
51
+ const observer = new IntersectionObserver((entries, self) => {
52
+ for (const entry of entries) {
53
+ if (entry.isIntersecting) {
54
+ self.disconnect();
55
+ mountIsland(node, Comp);
56
+ }
57
+ }
58
+ });
59
+ observer.observe(node);
60
+ observers.push(observer);
61
+ }
62
+ /**
63
+ * Mount each island in `root` (default `document`) over its server-rendered fallback. Call it after each
64
+ * client-side navigation, once the new DOM is in place (an `afterNavigate` callback): it tears down the
65
+ * previous pass first, so it is idempotent and leak-free. An eager island (`hydrate: true`) mounts at once;
66
+ * a `'visible'` island mounts on first intersection. An unknown directive name, a malformed prop payload,
67
+ * or a component that throws leaves the static fallback in place, so one bad island never breaks the page.
68
+ * Mount-and-replace clears the fallback, so an island whose fallback holds a focusable control should
69
+ * restore focus itself; the shipped fallbacks are non-interactive.
70
+ */
71
+ export function hydrateIslands(islands, root = document) {
72
+ teardown();
73
+ for (const node of root.querySelectorAll('[data-cairn-island]')) {
74
+ const name = node.getAttribute('data-cairn-island');
75
+ const Comp = name ? islands[name] : undefined;
76
+ if (!Comp)
77
+ continue;
78
+ if (node.getAttribute('data-cairn-hydrate') === 'visible')
79
+ observeIsland(node, Comp);
80
+ else
81
+ mountIsland(node, Comp);
82
+ }
83
+ }
@@ -0,0 +1,7 @@
1
+ import type { Component } from 'svelte';
2
+ /**
3
+ * A site's island components, keyed by directive name. Each value is the live Svelte component
4
+ * {@link hydrateIslands} mounts over the matching `hydrate` directive's static fallback. The props a
5
+ * component receives are the directive's declared scalar attributes (see the island boundary contract).
6
+ */
7
+ export type IslandRegistry = Record<string, Component<Record<string, unknown>>>;
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,5 @@
1
1
  import type { ConceptDescriptor } from '../content/types.js';
2
- import type { RepoRef } from '../github/types.js';
2
+ import type { Backend } from '../github/backend.js';
3
3
  import type { Manifest } from '../content/manifest.js';
4
4
  /**
5
5
  * One main entry the rewrite will touch: its identity, its file path, the transform's per-placement
@@ -59,8 +59,7 @@ export interface RewritePlan<P = unknown> {
59
59
  * editor surface and node-safe; the only IO is the usage index build and the per-entry reads.
60
60
  */
61
61
  export declare function planMediaRewrite<P = unknown>(args: {
62
- backend: RepoRef;
63
- token: string;
62
+ backend: Backend;
64
63
  concepts: ConceptDescriptor[];
65
64
  contentManifest: Manifest;
66
65
  hash: string;
@@ -1,6 +1,5 @@
1
1
  import { findConcept } from '../content/concepts.js';
2
2
  import { filenameFromId } from '../content/ids.js';
3
- import { readRaw } from '../github/repo.js';
4
3
  import { buildUsageIndex } from './usage.js';
5
4
  /**
6
5
  * Plan a media rewrite for one asset hash. Builds the cross-branch usage index in strict mode (so an
@@ -22,7 +21,7 @@ import { buildUsageIndex } from './usage.js';
22
21
  export async function planMediaRewrite(args) {
23
22
  // Strict so an unverifiable branch read rejects here rather than degrading to an absent reference.
24
23
  // Do NOT wrap this: the throw is the fail-closed contract the apply relies on.
25
- const index = await buildUsageIndex(args.backend, args.token, args.concepts, args.contentManifest, {
24
+ const index = await buildUsageIndex(args.backend, args.concepts, args.contentManifest, {
26
25
  strict: true,
27
26
  });
28
27
  const rows = index.get(args.hash) ?? [];
@@ -35,7 +34,7 @@ export async function planMediaRewrite(args) {
35
34
  if (!concept)
36
35
  return null;
37
36
  const path = `${concept.dir}/${filenameFromId(row.id)}`;
38
- const markdown = await readRaw(args.backend, path, args.token);
37
+ const markdown = await args.backend.readFile(path, args.backend.defaultBranch);
39
38
  if (markdown === null)
40
39
  return null;
41
40
  const result = args.transform(markdown);
@@ -1,5 +1,5 @@
1
1
  import type { ConceptDescriptor } from '../content/types.js';
2
- import type { RepoRef } from '../github/types.js';
2
+ import type { Backend } from '../github/backend.js';
3
3
  import type { Manifest } from '../content/manifest.js';
4
4
  /** Where a reference lives: the published corpus on main, or a named open edit branch. */
5
5
  export type UsageOrigin = {
@@ -49,4 +49,4 @@ export interface BuildUsageOptions {
49
49
  * failure so the caller fails closed. Pass `branches` to reuse a branch list the caller already has
50
50
  * (the load path lists once for the media-union) rather than listing them a second time.
51
51
  */
52
- export declare function buildUsageIndex(repo: RepoRef, token: string, concepts: ConceptDescriptor[], manifest: Manifest, opts?: BuildUsageOptions): Promise<UsageIndex>;
52
+ export declare function buildUsageIndex(backend: Backend, concepts: ConceptDescriptor[], manifest: Manifest, opts?: BuildUsageOptions): Promise<UsageIndex>;
@@ -1,5 +1,3 @@
1
- import { listBranches } from '../github/branches.js';
2
- import { readRaw } from '../github/repo.js';
3
1
  import { PENDING_PREFIX, parsePendingBranch } from '../content/pending.js';
4
2
  import { findConcept } from '../content/concepts.js';
5
3
  import { isValidId, filenameFromId } from '../content/ids.js';
@@ -24,7 +22,7 @@ function push(index, hash, entry) {
24
22
  * failure so the caller fails closed. Pass `branches` to reuse a branch list the caller already has
25
23
  * (the load path lists once for the media-union) rather than listing them a second time.
26
24
  */
27
- export async function buildUsageIndex(repo, token, concepts, manifest, opts = {}) {
25
+ export async function buildUsageIndex(backend, concepts, manifest, opts = {}) {
28
26
  const index = new Map();
29
27
  // The main arm: the manifest already carries each entry's mediaRefs, so this is a pure reverse
30
28
  // map with no per-file read.
@@ -41,7 +39,7 @@ export async function buildUsageIndex(repo, token, concepts, manifest, opts = {}
41
39
  }
42
40
  // The branch arm: read each open cairn/* branch's one edited file. The path is derivable from the
43
41
  // branch name, so no tree-listing is needed. The branch list is reused when the caller passes it.
44
- const names = opts.branches ?? (await listBranches(repo, PENDING_PREFIX, token));
42
+ const names = opts.branches ?? (await backend.listBranches(PENDING_PREFIX));
45
43
  // Read the branches in parallel rather than one at a time, so the latency floor is one round trip
46
44
  // instead of N. workerd self-throttles to 6 simultaneous outbound connections, so this batch and
47
45
  // the load path's media-union batch each stay under the limit; do NOT merge the two into one
@@ -58,7 +56,7 @@ export async function buildUsageIndex(repo, token, concepts, manifest, opts = {}
58
56
  return [];
59
57
  const path = `${concept.dir}/${filenameFromId(ref.id)}`;
60
58
  try {
61
- const raw = await readRaw({ ...repo, branch: name }, path, token);
59
+ const raw = await backend.readFile(path, name);
62
60
  if (raw === null)
63
61
  return []; // The file is absent on the branch: nothing to extract.
64
62
  const { frontmatter, body } = parseMarkdown(raw);
@@ -1,4 +1,3 @@
1
- import type { ConceptUrlPolicy } from '../content/types.js';
2
1
  /** One navigation node. An omitted or empty `url` is a label-only grouping header; no `children` is a leaf. */
3
2
  export interface NavNode {
4
3
  label: string;
@@ -28,12 +27,9 @@ export interface SiteConfig {
28
27
  siteName: string;
29
28
  description?: string;
30
29
  author?: string;
31
- url?: string;
32
30
  locale?: string;
33
31
  /** Named navigation menus, each a NavNode[] (normalized by extractMenu). */
34
32
  menus?: Record<string, unknown>;
35
- /** Per-concept URL policy: the permalink pattern and date-prefix granularity, keyed by concept id. */
36
- content?: Record<string, ConceptUrlPolicy>;
37
33
  /**
38
34
  * The editor spellcheck settings. The dialect is declared once per site (spec 1.2), so a British
39
35
  * site loads the British word list and "colour" reads as correct. Today only US English ships, so an
@@ -141,8 +137,6 @@ export declare class SiteConfigError extends Error {
141
137
  export declare function parseSiteConfig(raw: string): SiteConfig;
142
138
  /** Extract one named menu from a parsed config and validate it. Returns [] when the menu is absent. */
143
139
  export declare function extractMenu(config: SiteConfig, name: string, maxDepth: number): NavNode[];
144
- /** The per-concept URL policy from a parsed config, or an empty policy when the `content` key is absent. */
145
- export declare function urlPolicyFrom(config: SiteConfig): Record<string, ConceptUrlPolicy>;
146
140
  /**
147
141
  * Replace one named menu in the YAML site-config text and reserialize, preserving every other
148
142
  * top-level key (siteName, other menus, settings). Parses into a Document so the rest of the file
@@ -185,6 +185,12 @@ export function parseSiteConfig(raw) {
185
185
  if (typeof siteName !== 'string' || !siteName.trim()) {
186
186
  throw new SiteConfigError('Site config needs a siteName');
187
187
  }
188
+ // Contract v2 moved per-concept URL policy out of the YAML and onto defineConcept. A leftover
189
+ // `content:` block here would silently do nothing while the concept defaulted its permalink, so a
190
+ // half-migrated site (one carrying a non-default datePrefix) would rewrite every post URL. Fail loud.
191
+ if (parsed.content !== undefined) {
192
+ throw new SiteConfigError('cairn: site config no longer carries per-concept URL policy; move permalink/datePrefix into defineConcept (Contract v2)');
193
+ }
188
194
  return parsed;
189
195
  }
190
196
  /** Extract one named menu from a parsed config and validate it. Returns [] when the menu is absent. */
@@ -194,10 +200,6 @@ export function extractMenu(config, name, maxDepth) {
194
200
  return [];
195
201
  return validateNavTree(menu, maxDepth);
196
202
  }
197
- /** The per-concept URL policy from a parsed config, or an empty policy when the `content` key is absent. */
198
- export function urlPolicyFrom(config) {
199
- return config.content ?? {};
200
- }
201
203
  /**
202
204
  * Replace one named menu in the YAML site-config text and reserialize, preserving every other
203
205
  * top-level key (siteName, other menus, settings). Parses into a Document so the rest of the file
@@ -5,11 +5,11 @@ import remarkStringify from 'remark-stringify';
5
5
  const COLON = ':';
6
6
  function attrBlock(def, values) {
7
7
  const parts = [];
8
- for (const field of def.attributes ?? []) {
9
- const v = values.attributes[field.key];
8
+ for (const [name, field] of Object.entries(def.attributes ?? {})) {
9
+ const v = values.attributes[name];
10
10
  if (field.type === 'boolean') {
11
11
  if (v === true)
12
- parts.push(`${field.key}="true"`);
12
+ parts.push(`${name}="true"`);
13
13
  }
14
14
  else if (typeof v === 'string' && v !== '') {
15
15
  // The directive attribute grammar (mdast-util-directive) treats a literal `"` as the value
@@ -17,7 +17,7 @@ function attrBlock(def, values) {
17
17
  // Encode `&` first (so existing entities are not double-decoded) then `"`; the parser decodes
18
18
  // both back. A backslash is literal in this grammar and needs no escaping.
19
19
  const escaped = v.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
20
- parts.push(`${field.key}="${escaped}"`);
20
+ parts.push(`${name}="${escaped}"`);
21
21
  }
22
22
  }
23
23
  return parts.length ? `{${parts.join(' ')}}` : '';
@@ -80,12 +80,12 @@ function valuesFromRoot(root, def) {
80
80
  const values = emptyComponentValues(def);
81
81
  if (!root)
82
82
  return values;
83
- for (const field of def.attributes ?? []) {
84
- const raw = root.attributes?.[field.key];
83
+ for (const [name, field] of Object.entries(def.attributes ?? {})) {
84
+ const raw = root.attributes?.[name];
85
85
  if (field.type === 'boolean')
86
- values.attributes[field.key] = raw === 'true';
86
+ values.attributes[name] = raw === 'true';
87
87
  else if (typeof raw === 'string')
88
- values.attributes[field.key] = raw;
88
+ values.attributes[name] = raw;
89
89
  }
90
90
  const titleSlot = slotByName(def, 'title');
91
91
  const bodySlot = slotByName(def, 'body');
@@ -144,7 +144,7 @@ export async function componentRoundTripSafety(markdown, def) {
144
144
  const root = findComponentRoot(markdown, def);
145
145
  if (!root)
146
146
  return { safe: false, reason: 'not-a-component' };
147
- const declaredKeys = new Set((def.attributes ?? []).map((f) => f.key));
147
+ const declaredKeys = new Set(Object.keys(def.attributes ?? {}));
148
148
  for (const key of parseRawAttributeKeys(markdown, def)) {
149
149
  if (!declaredKeys.has(key))
150
150
  return { safe: false, reason: 'unknown-attribute' };
@@ -177,8 +177,8 @@ export async function parseComponentWithRawKeys(markdown, def) {
177
177
  // here; the parse must overwrite only the fields actually present in the markdown.
178
178
  function emptyComponentValues(def) {
179
179
  const attributes = {};
180
- for (const f of def.attributes ?? [])
181
- attributes[f.key] = f.type === 'boolean' ? false : '';
180
+ for (const [name, field] of Object.entries(def.attributes ?? {}))
181
+ attributes[name] = field.type === 'boolean' ? false : '';
182
182
  const slots = {};
183
183
  for (const s of def.slots ?? [])
184
184
  slots[s.name] = s.kind === 'repeatable' ? [] : '';
@@ -18,11 +18,13 @@ function componentSection(def) {
18
18
  /** Seed example values that show every declared field: an ellipsis for strings, one sample list item. */
19
19
  function exampleValues(def) {
20
20
  const values = emptyValues(def);
21
- for (const field of def.attributes ?? []) {
21
+ for (const [name, field] of Object.entries(def.attributes ?? {})) {
22
22
  if (field.type === 'boolean')
23
- values.attributes[field.key] = false;
23
+ values.attributes[name] = false;
24
+ else if (field.type === 'select')
25
+ values.attributes[name] = field.options[0] ?? '…';
24
26
  else
25
- values.attributes[field.key] = field.options?.[0] ?? '…';
27
+ values.attributes[name] = '…';
26
28
  }
27
29
  for (const slot of def.slots ?? []) {
28
30
  if (slot.kind === 'repeatable')
@@ -7,6 +7,9 @@ export type ComponentValidation = {
7
7
  errors: Record<string, string>;
8
8
  };
9
9
  /**
10
- *
10
+ * Validate a serialized component directive against its definition: the attributes through the same
11
+ * `fieldset` validator a concept field uses (coercion, constraints, required, select domain, pattern,
12
+ * and any per-attribute `behavior.validate`), then the two component-only checks, an unknown attribute
13
+ * key and an unfilled required slot.
11
14
  */
12
15
  export declare function validateComponent(markdown: string, def: ComponentDef): Promise<ComponentValidation>;
@@ -1,32 +1,19 @@
1
1
  import { parseComponentWithRawKeys } from './component-grammar.js';
2
+ import { fieldset } from '../content/fieldset.js';
2
3
  /**
3
- *
4
+ * Validate a serialized component directive against its definition: the attributes through the same
5
+ * `fieldset` validator a concept field uses (coercion, constraints, required, select domain, pattern,
6
+ * and any per-attribute `behavior.validate`), then the two component-only checks, an unknown attribute
7
+ * key and an unfilled required slot.
4
8
  */
5
9
  export async function validateComponent(markdown, def) {
6
10
  const { values, rawKeys } = await parseComponentWithRawKeys(markdown, def);
7
11
  const errors = {};
8
- const declared = new Set((def.attributes ?? []).map((f) => f.key));
9
- for (const field of def.attributes ?? []) {
10
- const v = values.attributes[field.key];
11
- const filled = field.type === 'boolean' ? true : typeof v === 'string' && v !== '';
12
- if (field.required && !filled) {
13
- errors[field.key] = `${field.label} is required.`;
14
- continue;
15
- }
16
- if (field.type === 'select' && typeof v === 'string' && v !== '' && !(field.options ?? []).includes(v)) {
17
- errors[field.key] = `${field.label} must be one of: ${(field.options ?? []).join(', ')}.`;
18
- continue;
19
- }
20
- if (field.pattern && typeof v === 'string' && v !== '' && !new RegExp(field.pattern.source).test(v)) {
21
- errors[field.key] = field.pattern.message;
22
- continue;
23
- }
24
- if (field.validate) {
25
- const message = runFieldValidator(def, field.key, () => field.validate(v, values));
26
- if (typeof message === 'string')
27
- errors[field.key] = message;
28
- }
29
- }
12
+ const schema = def.attributeSchema ?? fieldset(def.attributes ?? {}, { behavior: def.behavior });
13
+ const result = schema.validate(values.attributes, '');
14
+ if (!result.ok)
15
+ Object.assign(errors, result.errors);
16
+ const declared = new Set(Object.keys(def.attributes ?? {}));
30
17
  for (const key of rawKeys) {
31
18
  if (!declared.has(key))
32
19
  errors[key] = `Unknown attribute "${key}".`;
@@ -41,15 +28,3 @@ export async function validateComponent(markdown, def) {
41
28
  }
42
29
  return Object.keys(errors).length ? { ok: false, errors } : { ok: true };
43
30
  }
44
- // Run a site-supplied attribute validator. The validator is author code, so a throw is contained:
45
- // the field is treated as valid and a dev-time warning names the component and field so the author
46
- // can find the bug. A returned string is the field error; anything else (null) is clean.
47
- function runFieldValidator(def, key, call) {
48
- try {
49
- return call();
50
- }
51
- catch (err) {
52
- console.warn(`cairn: validate() for component "${def.name}" field "${key}" threw; treating the field as valid.`, err);
53
- return null;
54
- }
55
- }
@@ -4,12 +4,6 @@ import { type MediaResolve } from './resolve-media.js';
4
4
  import { type ComponentRegistry } from './registry.js';
5
5
  import type { LinkResolve } from '../content/links.js';
6
6
  export interface RendererOptions {
7
- /**
8
- * Stamp a `data-rise` ordinal (0, 1, 2, …) on each top-level component so a site's
9
- * CSS can drive an entrance-cascade delay off it. Omit for no stagger. The ordinal
10
- * is inert, so a consumer's sanitize floor can keep `data-rise` and drop `style`.
11
- */
12
- stagger?: boolean;
13
7
  /**
14
8
  * Extend the sanitize allowlist. Receives cairn's default schema (defaultSchema plus the
15
9
  * directive markers and the common benign tags) and returns the schema to use. Add to the
@@ -39,7 +39,7 @@ export function createRenderer(registry = defineRegistry({ components: [] }), op
39
39
  const rehypePlugins = [
40
40
  rehypeRaw,
41
41
  ...floor,
42
- [rehypeDispatch, registry, options.stagger],
42
+ [rehypeDispatch, registry],
43
43
  rehypeSlug,
44
44
  // Name each GFM task-list checkbox from its item text. It runs after the sanitize floor (which
45
45
  // does not allow aria-label) so the added attribute survives, and is content-not-sink, so it is
@@ -1,32 +1,6 @@
1
1
  import type { Element, ElementContent } from 'hast';
2
- /** The input types a component attribute or repeatable item field can take. */
3
- export type FieldType = 'text' | 'select' | 'icon' | 'boolean';
4
- /** One `{key="value"}` attribute on a component directive, or one field of a repeatable item. */
5
- export interface AttributeField {
6
- /** The attribute name as it appears in the directive, e.g. `icon`. */
7
- key: string;
8
- /** The form label. */
9
- label: string;
10
- type: FieldType;
11
- required?: boolean;
12
- /** Initial value; a string for text/select/icon, a boolean for boolean. */
13
- default?: string | boolean;
14
- /** Allowed values for `type: 'select'`. */
15
- options?: readonly string[];
16
- /** Helper text shown under the field. */
17
- help?: string;
18
- /** A RegExp `source` to validate the value against, plus the message to show on a mismatch. */
19
- pattern?: {
20
- source: string;
21
- message: string;
22
- };
23
- /**
24
- * A pure, browser-safe cross-field validator. Returns an error string, or null when valid.
25
- * Receives the field's value and the full {@link ComponentValues} so a rule can read sibling
26
- * fields. The picker wraps the call in try/catch so an author's throw never crashes the form.
27
- */
28
- validate?: (value: string | boolean, all: ComponentValues) => string | null;
29
- }
2
+ import type { FieldDescriptor } from '../content/fields.js';
3
+ import type { BehaviorTable, Fieldset } from '../content/fieldset.js';
30
4
  export type SlotKind = 'markdown' | 'inline' | 'repeatable';
31
5
  /**
32
6
  * One named content region of a component. The slots named `title` and `body` are special: `title`
@@ -39,7 +13,7 @@ export interface SlotDef {
39
13
  required?: boolean;
40
14
  help?: string;
41
15
  /** For `kind: 'repeatable'`: the fields composing each list item (v1 uses the first field). */
42
- itemFields?: AttributeField[];
16
+ itemFields?: Record<string, FieldDescriptor>;
43
17
  /**
44
18
  * For `kind: 'repeatable'`: derives a row's label from its item values and zero-based index.
45
19
  * When it returns nothing, the picker falls back to `${label} ${index + 1}`.
@@ -74,10 +48,18 @@ export interface ComponentDef {
74
48
  insertTemplate?: string;
75
49
  /**
76
50
  * Build the final hast element from the component context (attributes plus partitioned
77
- * slots). The engine stamps the entrance-stagger ordinal (`data-rise`) on the top-level
51
+ * slots). The engine stamps the entrance ordinal (`data-rise`) on the top-level
78
52
  * result, so a build fn stays free of any motion concern.
79
53
  */
80
54
  build: (ctx: ComponentContext) => Element;
55
+ /**
56
+ * Opt this directive into client hydration (phase 4b islands). `true` mounts the island eagerly on
57
+ * first load and after client-side navigation; `'visible'` defers the mount to first intersection.
58
+ * The engine wraps {@link ComponentDef.build}'s output in an island boundary, and the site registers
59
+ * the live Svelte component under the same name on `rendering.islands`. Absent leaves the directive a
60
+ * static, server-only component.
61
+ */
62
+ hydrate?: boolean | 'visible';
81
63
  /**
82
64
  * Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. Maps a free-string role to a
83
65
  * glyph key in the site IconSet; choose a logically representative glyph and prefer glyphs
@@ -87,8 +69,18 @@ export interface ComponentDef {
87
69
  defaultIconByRole?: Record<string, string>;
88
70
  /** One line on when to reach for this component; feeds the picker and the reference file. */
89
71
  use?: string;
90
- /** The `{key="value"}` attributes this component accepts. */
91
- attributes?: AttributeField[];
72
+ /** The `{key="value"}` attributes this component accepts, keyed by attribute name. */
73
+ attributes?: Record<string, FieldDescriptor>;
74
+ /**
75
+ * Per-attribute function-valued behavior (a cross-field `validate`), keyed by attribute name.
76
+ * {@link defineComponent} bundles it into the attribute {@link Fieldset}.
77
+ */
78
+ behavior?: BehaviorTable;
79
+ /**
80
+ * The attribute validator {@link defineComponent} builds from `attributes` and `behavior`.
81
+ * Engine-internal: the constructor sets it, and {@link validateComponent} runs it.
82
+ */
83
+ attributeSchema?: Fieldset;
92
84
  /** The named content regions this component accepts. */
93
85
  slots?: SlotDef[];
94
86
  /**
@@ -114,8 +106,8 @@ export interface ComponentRegistry {
114
106
  names: string[];
115
107
  get(name: string): ComponentDef | undefined;
116
108
  defaultIcon(name: string, role?: string): string | undefined;
117
- /** The component's first `type:'icon'` attribute, or undefined when it declares none. */
118
- iconField(name: string): AttributeField | undefined;
109
+ /** The name of the component's first `type:'icon'` attribute, or undefined when it declares none. */
110
+ iconField(name: string): string | undefined;
119
111
  }
120
112
  /**
121
113
  * The hast property name carrying one declared attribute from stamp to dispatch, e.g. `tone`
@@ -149,3 +141,11 @@ export declare function emptyValues(def: ComponentDef): ComponentValues;
149
141
  * the def declares no `preview`, returns exactly the {@link emptyValues} output.
150
142
  */
151
143
  export declare function previewValues(def: ComponentDef): ComponentValues;
144
+ /**
145
+ * Declare a site component, building its attribute validator from the `fields.*` descriptors and
146
+ * validating the component at declaration. Mirrors {@link defineConcept}: a malformed attribute type
147
+ * or pattern fails at module load. The built `attributeSchema` is what {@link validateComponent} runs.
148
+ */
149
+ export declare function defineComponent<const D extends ComponentDef>(def: D): D & {
150
+ attributeSchema: Fieldset;
151
+ };
@@ -1,3 +1,4 @@
1
+ import { fieldset } from '../content/fieldset.js';
1
2
  /**
2
3
  * The hast property name carrying one declared attribute from stamp to dispatch, e.g. `tone`
3
4
  * becomes `dataAttrTone`. The directive stamp writes it and the rehype dispatch reads it, so both
@@ -7,12 +8,12 @@ export function dataAttrProp(key) {
7
8
  return `dataAttr${key.charAt(0).toUpperCase()}${key.slice(1)}`;
8
9
  }
9
10
  /**
10
- * A component's first `type:'icon'` attribute, or undefined when it declares none. Both the
11
- * construction-time guard and the registry's `iconField` derive the icon field from this one
11
+ * The name of a component's first `type:'icon'` attribute, or undefined when it declares none. Both
12
+ * the construction-time guard and the registry's `iconField` derive the icon field from this one
12
13
  * predicate rather than spelling the `type === 'icon'` find twice.
13
14
  */
14
15
  function findIconField(def) {
15
- return def.attributes?.find((field) => field.type === 'icon');
16
+ return Object.entries(def.attributes ?? {}).find(([, field]) => field.type === 'icon')?.[0];
16
17
  }
17
18
  /**
18
19
  * The engine's role-to-glyph-key fallback for the conventional admonition roles, which a site's
@@ -65,8 +66,8 @@ export function defineRegistry({ components }) {
65
66
  */
66
67
  export function emptyValues(def) {
67
68
  const attributes = {};
68
- for (const field of def.attributes ?? []) {
69
- attributes[field.key] = field.default ?? (field.type === 'boolean' ? false : '');
69
+ for (const [name, field] of Object.entries(def.attributes ?? {})) {
70
+ attributes[name] = field.default ?? (field.type === 'boolean' ? false : '');
70
71
  }
71
72
  const slots = {};
72
73
  for (const slot of def.slots ?? []) {
@@ -88,3 +89,23 @@ export function previewValues(def) {
88
89
  slots: { ...base.slots, ...def.preview.slots },
89
90
  };
90
91
  }
92
+ /** The descriptor types that serialize to a single directive-attribute string (decision 2). */
93
+ const ATTRIBUTE_TYPES = new Set(['text', 'textarea', 'number', 'select', 'url', 'email', 'date', 'datetime', 'boolean', 'icon']);
94
+ /** Reject an attribute type that cannot serialize to a single directive-attribute string (decision 2). */
95
+ function checkComponentAttributes(name, attributes) {
96
+ for (const [key, field] of Object.entries(attributes)) {
97
+ if (!ATTRIBUTE_TYPES.has(field.type)) {
98
+ throw new Error(`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).`);
99
+ }
100
+ }
101
+ }
102
+ /**
103
+ * Declare a site component, building its attribute validator from the `fields.*` descriptors and
104
+ * validating the component at declaration. Mirrors {@link defineConcept}: a malformed attribute type
105
+ * or pattern fails at module load. The built `attributeSchema` is what {@link validateComponent} runs.
106
+ */
107
+ export function defineComponent(def) {
108
+ const attributes = def.attributes ?? {};
109
+ checkComponentAttributes(def.name, attributes);
110
+ return { ...def, attributeSchema: fieldset(attributes, { behavior: def.behavior }) };
111
+ }