@glw907/cairn-cms 0.62.2 → 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 (196) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/dist/ambient.d.ts +2 -0
  3. package/dist/auth/types.d.ts +7 -0
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -7
  5. package/dist/components/ComponentForm.svelte +44 -27
  6. package/dist/components/ComponentInsertDialog.svelte +22 -11
  7. package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
  8. package/dist/components/ConceptList.svelte +25 -4
  9. package/dist/components/EditPage.svelte +29 -107
  10. package/dist/components/EditPage.svelte.d.ts +2 -7
  11. package/dist/components/EntryPicker.svelte +117 -0
  12. package/dist/components/EntryPicker.svelte.d.ts +35 -0
  13. package/dist/components/FieldInput.svelte +218 -0
  14. package/dist/components/FieldInput.svelte.d.ts +51 -0
  15. package/dist/components/IconPicker.svelte +2 -2
  16. package/dist/components/IconPicker.svelte.d.ts +2 -0
  17. package/dist/components/LinkPicker.svelte +8 -75
  18. package/dist/components/LinkPicker.svelte.d.ts +4 -5
  19. package/dist/components/MediaHeroField.svelte +8 -5
  20. package/dist/components/MediaHeroField.svelte.d.ts +4 -0
  21. package/dist/components/ObjectGroupField.svelte +54 -0
  22. package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
  23. package/dist/components/ReferenceField.svelte +94 -0
  24. package/dist/components/ReferenceField.svelte.d.ts +27 -0
  25. package/dist/components/RepeatableField.svelte +221 -0
  26. package/dist/components/RepeatableField.svelte.d.ts +53 -0
  27. package/dist/components/cairn-admin.css +179 -2
  28. package/dist/components/preview-doc.js +5 -1
  29. package/dist/components/tidy-validate.js +1 -1
  30. package/dist/content/adapter.js +18 -0
  31. package/dist/content/advisories.d.ts +2 -2
  32. package/dist/content/advisories.js +3 -5
  33. package/dist/content/compose.d.ts +7 -6
  34. package/dist/content/compose.js +26 -20
  35. package/dist/content/concepts.d.ts +21 -15
  36. package/dist/content/concepts.js +55 -32
  37. package/dist/content/field-rules.d.ts +15 -0
  38. package/dist/content/field-rules.js +38 -0
  39. package/dist/content/fields.d.ts +169 -0
  40. package/dist/content/fields.js +41 -0
  41. package/dist/content/fieldset.d.ts +107 -0
  42. package/dist/content/fieldset.js +386 -0
  43. package/dist/content/frontmatter-region.d.ts +38 -0
  44. package/dist/content/frontmatter-region.js +75 -0
  45. package/dist/content/frontmatter.d.ts +35 -2
  46. package/dist/content/frontmatter.js +232 -11
  47. package/dist/content/manifest.d.ts +34 -0
  48. package/dist/content/manifest.js +80 -4
  49. package/dist/content/media-refs.d.ts +2 -2
  50. package/dist/content/media-rewrite.js +1 -69
  51. package/dist/content/reference-index.d.ts +56 -0
  52. package/dist/content/reference-index.js +95 -0
  53. package/dist/content/references.d.ts +40 -0
  54. package/dist/content/references.js +0 -0
  55. package/dist/content/standard-schema.d.ts +30 -0
  56. package/dist/content/standard-schema.js +4 -0
  57. package/dist/content/types.d.ts +127 -178
  58. package/dist/delivery/data.d.ts +2 -2
  59. package/dist/delivery/data.js +1 -1
  60. package/dist/delivery/public-routes.d.ts +10 -5
  61. package/dist/delivery/public-routes.js +25 -2
  62. package/dist/delivery/site-descriptors.d.ts +5 -1
  63. package/dist/delivery/site-descriptors.js +8 -3
  64. package/dist/delivery/site-indexes.d.ts +2 -2
  65. package/dist/delivery/site-resolver.d.ts +25 -0
  66. package/dist/delivery/site-resolver.js +49 -0
  67. package/dist/doctor/checks-local.js +6 -11
  68. package/dist/github/backend.d.ts +83 -0
  69. package/dist/github/backend.js +76 -0
  70. package/dist/github/credentials.d.ts +11 -5
  71. package/dist/github/credentials.js +3 -3
  72. package/dist/github/repo.d.ts +8 -19
  73. package/dist/github/repo.js +69 -80
  74. package/dist/github/types.d.ts +1 -1
  75. package/dist/github/types.js +4 -4
  76. package/dist/index.d.ts +18 -10
  77. package/dist/index.js +9 -5
  78. package/dist/islands/index.d.ts +12 -0
  79. package/dist/islands/index.js +83 -0
  80. package/dist/islands/types.d.ts +7 -0
  81. package/dist/islands/types.js +1 -0
  82. package/dist/log/events.d.ts +1 -1
  83. package/dist/media/index.d.ts +1 -1
  84. package/dist/media/index.js +1 -1
  85. package/dist/media/manifest.d.ts +11 -0
  86. package/dist/media/manifest.js +13 -0
  87. package/dist/media/rewrite-plan.d.ts +2 -3
  88. package/dist/media/rewrite-plan.js +2 -3
  89. package/dist/media/usage.d.ts +2 -2
  90. package/dist/media/usage.js +3 -5
  91. package/dist/nav/site-config.d.ts +0 -6
  92. package/dist/nav/site-config.js +6 -4
  93. package/dist/render/component-grammar.js +11 -11
  94. package/dist/render/component-reference.js +5 -3
  95. package/dist/render/component-validate.d.ts +4 -1
  96. package/dist/render/component-validate.js +10 -35
  97. package/dist/render/highlight.d.ts +9 -0
  98. package/dist/render/highlight.js +206 -0
  99. package/dist/render/pipeline.d.ts +0 -6
  100. package/dist/render/pipeline.js +13 -2
  101. package/dist/render/registry.d.ts +44 -36
  102. package/dist/render/registry.js +47 -6
  103. package/dist/render/rehype-dispatch.d.ts +6 -10
  104. package/dist/render/rehype-dispatch.js +38 -17
  105. package/dist/render/remark-directives.js +4 -5
  106. package/dist/render/sanitize-schema.d.ts +10 -0
  107. package/dist/render/sanitize-schema.js +30 -1
  108. package/dist/sveltekit/cairn-admin.d.ts +5 -5
  109. package/dist/sveltekit/cairn-admin.js +3 -4
  110. package/dist/sveltekit/content-routes.d.ts +10 -8
  111. package/dist/sveltekit/content-routes.js +269 -181
  112. package/dist/sveltekit/guard.js +10 -0
  113. package/dist/sveltekit/health.d.ts +7 -3
  114. package/dist/sveltekit/health.js +9 -3
  115. package/dist/sveltekit/index.d.ts +1 -1
  116. package/dist/sveltekit/nav-routes.d.ts +6 -5
  117. package/dist/sveltekit/nav-routes.js +22 -20
  118. package/dist/sveltekit/types.d.ts +2 -0
  119. package/dist/vite/index.d.ts +3 -3
  120. package/dist/vite/index.js +17 -8
  121. package/package.json +17 -2
  122. package/src/lib/ambient.ts +7 -0
  123. package/src/lib/auth/types.ts +7 -0
  124. package/src/lib/components/CairnAdmin.svelte +2 -6
  125. package/src/lib/components/ComponentForm.svelte +48 -27
  126. package/src/lib/components/ComponentInsertDialog.svelte +26 -14
  127. package/src/lib/components/ConceptList.svelte +41 -4
  128. package/src/lib/components/EditPage.svelte +43 -119
  129. package/src/lib/components/EntryPicker.svelte +154 -0
  130. package/src/lib/components/FieldInput.svelte +262 -0
  131. package/src/lib/components/IconPicker.svelte +4 -2
  132. package/src/lib/components/LinkPicker.svelte +10 -81
  133. package/src/lib/components/MediaHeroField.svelte +12 -5
  134. package/src/lib/components/ObjectGroupField.svelte +97 -0
  135. package/src/lib/components/ReferenceField.svelte +126 -0
  136. package/src/lib/components/RepeatableField.svelte +310 -0
  137. package/src/lib/components/preview-doc.ts +5 -1
  138. package/src/lib/components/tidy-validate.ts +1 -1
  139. package/src/lib/content/adapter.ts +21 -0
  140. package/src/lib/content/advisories.ts +4 -7
  141. package/src/lib/content/compose.ts +30 -23
  142. package/src/lib/content/concepts.ts +68 -40
  143. package/src/lib/content/field-rules.ts +39 -0
  144. package/src/lib/content/fields.ts +178 -0
  145. package/src/lib/content/fieldset.ts +470 -0
  146. package/src/lib/content/frontmatter-region.ts +90 -0
  147. package/src/lib/content/frontmatter.ts +231 -15
  148. package/src/lib/content/manifest.ts +101 -4
  149. package/src/lib/content/media-refs.ts +2 -2
  150. package/src/lib/content/media-rewrite.ts +7 -80
  151. package/src/lib/content/reference-index.ts +159 -0
  152. package/src/lib/content/references.ts +0 -0
  153. package/src/lib/content/standard-schema.ts +25 -0
  154. package/src/lib/content/types.ts +128 -195
  155. package/src/lib/delivery/data.ts +2 -2
  156. package/src/lib/delivery/public-routes.ts +36 -4
  157. package/src/lib/delivery/site-descriptors.ts +8 -3
  158. package/src/lib/delivery/site-indexes.ts +2 -2
  159. package/src/lib/delivery/site-resolver.ts +64 -0
  160. package/src/lib/doctor/checks-local.ts +6 -14
  161. package/src/lib/github/backend.ts +161 -0
  162. package/src/lib/github/credentials.ts +10 -7
  163. package/src/lib/github/repo.ts +79 -83
  164. package/src/lib/github/types.ts +5 -5
  165. package/src/lib/index.ts +40 -18
  166. package/src/lib/islands/index.ts +84 -0
  167. package/src/lib/islands/types.ts +11 -0
  168. package/src/lib/log/events.ts +1 -0
  169. package/src/lib/media/index.ts +1 -0
  170. package/src/lib/media/manifest.ts +14 -0
  171. package/src/lib/media/rewrite-plan.ts +4 -6
  172. package/src/lib/media/usage.ts +4 -7
  173. package/src/lib/nav/site-config.ts +8 -9
  174. package/src/lib/render/component-grammar.ts +10 -10
  175. package/src/lib/render/component-reference.ts +4 -3
  176. package/src/lib/render/component-validate.ts +10 -35
  177. package/src/lib/render/highlight.ts +259 -0
  178. package/src/lib/render/pipeline.ts +13 -8
  179. package/src/lib/render/registry.ts +88 -42
  180. package/src/lib/render/rehype-dispatch.ts +47 -16
  181. package/src/lib/render/remark-directives.ts +4 -5
  182. package/src/lib/render/sanitize-schema.ts +32 -1
  183. package/src/lib/sveltekit/cairn-admin.ts +8 -9
  184. package/src/lib/sveltekit/content-routes.ts +330 -221
  185. package/src/lib/sveltekit/guard.ts +15 -0
  186. package/src/lib/sveltekit/health.ts +13 -6
  187. package/src/lib/sveltekit/index.ts +2 -2
  188. package/src/lib/sveltekit/nav-routes.ts +33 -29
  189. package/src/lib/sveltekit/types.ts +5 -1
  190. package/src/lib/vite/index.ts +20 -11
  191. package/dist/content/schema.d.ts +0 -87
  192. package/dist/content/schema.js +0 -89
  193. package/dist/content/validate.d.ts +0 -17
  194. package/dist/content/validate.js +0 -93
  195. package/src/lib/content/schema.ts +0 -167
  196. package/src/lib/content/validate.ts +0 -90
package/src/lib/index.ts CHANGED
@@ -9,17 +9,10 @@ export { buildMagicLinkMessage, cloudflareSend } from './email.js';
9
9
  export type {
10
10
  CairnAdapter,
11
11
  ConceptConfig,
12
- FrontmatterField,
13
- TextField,
14
- TextareaField,
15
- DateField,
16
- BooleanField,
17
- TagsField,
18
- FreeTagsField,
19
- ImageField,
12
+ NamedField,
20
13
  ImageValue,
21
14
  ValidationResult,
22
- BackendConfig,
15
+ ValidationIssue,
23
16
  SenderConfig,
24
17
  NavMenuConfig,
25
18
  PreviewConfig,
@@ -30,10 +23,11 @@ export type {
30
23
  ConceptUrlPolicy,
31
24
  CairnExtension,
32
25
  CairnRuntime,
26
+ SiteRender,
33
27
  AdminPanel,
34
28
  FieldTypeDef,
35
29
  } from './content/types.js';
36
- export { CONCEPT_ROUTING, normalizeConcepts, findConcept } from './content/concepts.js';
30
+ export { normalizeConcepts, findConcept, defineConcept } from './content/concepts.js';
37
31
  export { composeRuntime } from './content/compose.js';
38
32
  export type { ComposeInput } from './content/compose.js';
39
33
  export {
@@ -42,9 +36,30 @@ export {
42
36
  serializeMarkdown,
43
37
  parseMarkdown,
44
38
  } from './content/frontmatter.js';
45
- export { defineFields } from './content/schema.js';
46
39
  export { defineAdapter } from './content/adapter.js';
47
- export type { ConceptSchema, Infer, InferFields, DefineFieldsOptions, StandardInput, StandardSchemaV1 } from './content/schema.js';
40
+ export type { StandardInput, StandardSchemaV1 } from './content/standard-schema.js';
41
+ // The Contract v2 field vocabulary: the one live field system.
42
+ export { fields } from './content/fields.js';
43
+ export type {
44
+ FieldDescriptor,
45
+ TextField,
46
+ TextareaField,
47
+ NumberField,
48
+ SelectField,
49
+ MultiselectField,
50
+ UrlField,
51
+ EmailField,
52
+ DateField,
53
+ DatetimeField,
54
+ BooleanField,
55
+ IconField,
56
+ ImageField,
57
+ ObjectField,
58
+ ReferenceField,
59
+ ArrayField,
60
+ } from './content/fields.js';
61
+ export { fieldset, initialValues } from './content/fieldset.js';
62
+ export type { Fieldset, InferFieldset, FieldsetOptions, BehaviorTable, FieldBehavior } from './content/fieldset.js';
48
63
  export {
49
64
  isValidId,
50
65
  idFromFilename,
@@ -64,6 +79,7 @@ export {
64
79
  parseManifest,
65
80
  emptyManifest,
66
81
  verifyManifest,
82
+ verifyReferences,
67
83
  diffManifests,
68
84
  upsertEntry,
69
85
  removeEntry,
@@ -71,14 +87,17 @@ export {
71
87
  manifestLinkResolver,
72
88
  inboundLinks,
73
89
  } from './content/manifest.js';
74
- export type { Manifest, ManifestEntry, ManifestDiff, ManifestEntryDiff, LinkTarget, InboundLink } from './content/manifest.js';
90
+ export type { Manifest, ManifestEntry, ManifestDiff, ManifestEntryDiff, LinkTarget, InboundLink, InboundReference } from './content/manifest.js';
91
+ export type { ReferenceEdge } from './content/references.js';
92
+ // The read-model resolution of a reference edge to its target's identity lives at the cross-concept
93
+ // site-resolver layer (a per-concept index cannot reach a different concept's entries). The resolver
94
+ // function ships from the /delivery subpath; this is the type a route reads off the resolved map.
95
+ export type { ResolvedReference } from './delivery/site-resolver.js';
75
96
  // Render engine (Plan 04): generic directive pipeline; sites own the component registry.
76
- export { defineRegistry, emptyValues } from './render/registry.js';
97
+ export { defineRegistry, defineComponent, emptyValues } from './render/registry.js';
77
98
  export type {
78
99
  ComponentDef,
79
100
  ComponentRegistry,
80
- FieldType,
81
- AttributeField,
82
101
  SlotKind,
83
102
  SlotDef,
84
103
  ComponentValues,
@@ -100,13 +119,16 @@ export { createRenderer } from './render/pipeline.js';
100
119
  export type { RendererOptions } from './render/pipeline.js';
101
120
 
102
121
  // GitHub read-and-commit backend (Plan 03).
103
- export type { RepoRef, RepoFile, CommitAuthor, AppCredentials } from './github/types.js';
122
+ export type { RepoFile, CommitAuthor } from './github/types.js';
104
123
  export { CommitConflictError } from './github/types.js';
124
+ // The Backend seam (Contract v2 backend phase): the store interface and its default GitHub provider.
125
+ export { githubApp } from './github/backend.js';
126
+ export type { Backend, BackendProvider, GithubAppProvider, BackendEnv } from './github/backend.js';
127
+ export type { FileChange } from './github/repo.js';
105
128
 
106
129
  // Nav tree and site-config helpers (Plan 06).
107
130
  export {
108
131
  parseSiteConfig,
109
- urlPolicyFrom,
110
132
  extractMenu,
111
133
  setMenu,
112
134
  validateNavTree,
@@ -0,0 +1,84 @@
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, type Component } from 'svelte';
6
+ import type { IslandRegistry } from './types.js';
7
+
8
+ export type { IslandRegistry } from './types.js';
9
+
10
+ // The live Svelte instances of the current pass and the observers still waiting to fire, kept module-level
11
+ // so the next pass can tear the previous one down. A layout calls hydrateIslands once per navigation, and
12
+ // the previous mounts must unmount before the next mount over the same DOM.
13
+ let mounted: Record<string, unknown>[] = [];
14
+ let observers: IntersectionObserver[] = [];
15
+
16
+ // Tear down the previous pass: unmount live instances and disconnect observers that never fired. unmount
17
+ // runs with outro: false so teardown is synchronous and deterministic on navigation; an island declaring an
18
+ // out: transition would otherwise linger and briefly double-render against the next pass's fresh mount.
19
+ function teardown(): void {
20
+ for (const o of observers) o.disconnect();
21
+ observers = [];
22
+ for (const instance of mounted) {
23
+ try {
24
+ void unmount(instance, { outro: false });
25
+ } catch {
26
+ // a component that throws on teardown must not block the rest
27
+ }
28
+ }
29
+ mounted = [];
30
+ }
31
+
32
+ // Mount one island over its boundary: parse props (try/catch, a malformed payload leaves the fallback),
33
+ // clear the fallback, mount, and on a mount failure restore the fallback so the reader still sees content.
34
+ // WATCH: props are trusted to equal the directive's declared scalar attributes (serializeIslandProps emits
35
+ // only those). If a directive ever carries an attribute its island does not declare, this forwards it as-is.
36
+ function mountIsland(node: Element, Comp: Component<Record<string, unknown>>): void {
37
+ let props: Record<string, unknown>;
38
+ try {
39
+ props = JSON.parse(node.getAttribute('data-cairn-props') ?? '{}') as Record<string, unknown>;
40
+ } catch {
41
+ return;
42
+ }
43
+ const fallback = [...node.childNodes];
44
+ node.replaceChildren();
45
+ try {
46
+ mounted.push(mount(Comp, { target: node as HTMLElement, props }));
47
+ } catch {
48
+ node.replaceChildren(...fallback);
49
+ }
50
+ }
51
+
52
+ // Defer a 'visible' island to first intersection, then mount once and stop observing.
53
+ function observeIsland(node: Element, Comp: Component<Record<string, unknown>>): void {
54
+ const observer = new IntersectionObserver((entries, self) => {
55
+ for (const entry of entries) {
56
+ if (entry.isIntersecting) {
57
+ self.disconnect();
58
+ mountIsland(node, Comp);
59
+ }
60
+ }
61
+ });
62
+ observer.observe(node);
63
+ observers.push(observer);
64
+ }
65
+
66
+ /**
67
+ * Mount each island in `root` (default `document`) over its server-rendered fallback. Call it after each
68
+ * client-side navigation, once the new DOM is in place (an `afterNavigate` callback): it tears down the
69
+ * previous pass first, so it is idempotent and leak-free. An eager island (`hydrate: true`) mounts at once;
70
+ * a `'visible'` island mounts on first intersection. An unknown directive name, a malformed prop payload,
71
+ * or a component that throws leaves the static fallback in place, so one bad island never breaks the page.
72
+ * Mount-and-replace clears the fallback, so an island whose fallback holds a focusable control should
73
+ * restore focus itself; the shipped fallbacks are non-interactive.
74
+ */
75
+ export function hydrateIslands(islands: IslandRegistry, root: ParentNode = document): void {
76
+ teardown();
77
+ for (const node of root.querySelectorAll('[data-cairn-island]')) {
78
+ const name = node.getAttribute('data-cairn-island');
79
+ const Comp = name ? islands[name] : undefined;
80
+ if (!Comp) continue;
81
+ if (node.getAttribute('data-cairn-hydrate') === 'visible') observeIsland(node, Comp);
82
+ else mountIsland(node, Comp);
83
+ }
84
+ }
@@ -0,0 +1,11 @@
1
+ // cairn-cms islands (@glw907/cairn-cms/islands): the type contract shared by the adapter and the client
2
+ // runtime. Kept in its own runtime-free module so the adapter types can import it without pulling
3
+ // Svelte's mount() into the server graph.
4
+ import type { Component } from 'svelte';
5
+
6
+ /**
7
+ * A site's island components, keyed by directive name. Each value is the live Svelte component
8
+ * {@link hydrateIslands} mounts over the matching `hydrate` directive's static fallback. The props a
9
+ * component receives are the directive's declared scalar attributes (see the island boundary contract).
10
+ */
11
+ export type IslandRegistry = Record<string, Component<Record<string, unknown>>>;
@@ -22,6 +22,7 @@ export type CairnLogEvent =
22
22
  | 'media.delivery_failed'
23
23
  | 'media.orphan_reconcile'
24
24
  | 'media.resolve_missing'
25
+ | 'media.resolver_absent'
25
26
  | 'media.deleted'
26
27
  | 'media.delete_blocked'
27
28
  | 'media.bulk_deleted'
@@ -8,6 +8,7 @@
8
8
  export { normalizeAssets, type ResolvedAssetConfig } from './config.js';
9
9
  export {
10
10
  parseMediaManifest,
11
+ readCommittedManifest,
11
12
  findByHash,
12
13
  upsertMediaEntry,
13
14
  removeMediaEntry,
@@ -38,6 +38,20 @@ export function parseMediaManifest(json: unknown): MediaManifest {
38
38
  return json as MediaManifest;
39
39
  }
40
40
 
41
+ /**
42
+ * Read the committed media manifest from an `import.meta.glob` eager result, degrading a missing
43
+ * file to an empty manifest. A static import of an absent `media.json` fails the Vite build before
44
+ * any runtime degrade can run, so a fresh site with no manifest cannot build. A glob result is the
45
+ * build-safe read: `import.meta.glob` returns `{}` when nothing matches rather than throwing, and
46
+ * this helper extracts the single matched value and parses it, so a missing file reads a clean `{}`.
47
+ * @param globResult - The eager glob result for the committed manifest, an empty object when the
48
+ * file is absent. The consumer passes
49
+ * `import.meta.glob('<path-to-media.json>', { eager: true, import: 'default' })`.
50
+ */
51
+ export function readCommittedManifest(globResult: Record<string, unknown>): MediaManifest {
52
+ return parseMediaManifest(Object.values(globResult)[0]);
53
+ }
54
+
41
55
  /**
42
56
  * Validate one posted value as a MediaEntry, returning it narrowed or undefined. The trust boundary
43
57
  * for an optimistic record the client re-posts: the upload action server-owned each field at
@@ -16,11 +16,10 @@
16
16
  // injected, so the planner never imports the editor surface. It is internal, exported from no package
17
17
  // subpath, so it carries no reference page.
18
18
  import type { ConceptDescriptor } from '../content/types.js';
19
- import type { RepoRef } from '../github/types.js';
19
+ import type { Backend } from '../github/backend.js';
20
20
  import type { Manifest } from '../content/manifest.js';
21
21
  import { findConcept } from '../content/concepts.js';
22
22
  import { filenameFromId } from '../content/ids.js';
23
- import { readRaw } from '../github/repo.js';
24
23
  import { buildUsageIndex } from './usage.js';
25
24
 
26
25
  /**
@@ -81,8 +80,7 @@ export interface RewritePlan<P = unknown> {
81
80
  * editor surface and node-safe; the only IO is the usage index build and the per-entry reads.
82
81
  */
83
82
  export async function planMediaRewrite<P = unknown>(args: {
84
- backend: RepoRef;
85
- token: string;
83
+ backend: Backend;
86
84
  concepts: ConceptDescriptor[];
87
85
  contentManifest: Manifest;
88
86
  hash: string;
@@ -90,7 +88,7 @@ export async function planMediaRewrite<P = unknown>(args: {
90
88
  }): Promise<RewritePlan<P>> {
91
89
  // Strict so an unverifiable branch read rejects here rather than degrading to an absent reference.
92
90
  // Do NOT wrap this: the throw is the fail-closed contract the apply relies on.
93
- const index = await buildUsageIndex(args.backend, args.token, args.concepts, args.contentManifest, {
91
+ const index = await buildUsageIndex(args.backend, args.concepts, args.contentManifest, {
94
92
  strict: true,
95
93
  });
96
94
  const rows = index.get(args.hash) ?? [];
@@ -104,7 +102,7 @@ export async function planMediaRewrite<P = unknown>(args: {
104
102
  const concept = findConcept(args.concepts, row.concept);
105
103
  if (!concept) return null;
106
104
  const path = `${concept.dir}/${filenameFromId(row.id)}`;
107
- const markdown = await readRaw(args.backend, path, args.token);
105
+ const markdown = await args.backend.readFile(path, args.backend.defaultBranch);
108
106
  if (markdown === null) return null;
109
107
  const result = args.transform(markdown);
110
108
  if (result.placements.length === 0) return null;
@@ -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
- }