@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
@@ -0,0 +1,259 @@
1
+ // cairn-cms: build-time syntax highlighting for the shared render pipeline. It tokenizes fenced
2
+ // code with Shiki and emits a <pre class="shiki"> whose tokens carry semantic CLASS names from a
3
+ // fixed cairn ramp (cairn-tok-keyword, cairn-tok-string, ...), never an inline style and never a
4
+ // baked hex literal. The colors live in the site theme, so recoloring the theme recolors code with
5
+ // no markup change.
6
+ //
7
+ // CLASS-DRIVEN (load-bearing): the output is class-only, with no `style` attribute on the <pre>,
8
+ // the <code>, or any token <span>. cairn is class-driven by design (the sink guard in
9
+ // sanitize-schema.ts strips every inline `style` wholesale), and class output survives that floor
10
+ // naturally because `className` is already allowed on `*`. The highlighter therefore needs no
11
+ // special placement and no ordering invariant: it runs as an ordinary rehype plugin. Never weaken
12
+ // the sanitize floor or the sink guard to accommodate this step, and never add `style` to an
13
+ // allowlist; the point is that class-only output needs neither.
14
+ //
15
+ // THE .cairn-tok-* CLASS CONTRACT: the engine owns the token class names (below); the site owns the
16
+ // colors. This mirrors the engine's .cairn-place-* figure-placement contract: a site styles the
17
+ // classes in its own theme. The showcase binds each class to a --cairn-code-* role variable in
18
+ // examples/showcase/src/lib/theme.css, so the syntax palette re-skins with the rest of the theme.
19
+ //
20
+ // .cairn-tok-keyword a language keyword, storage class, or control word
21
+ // .cairn-tok-string a string or quoted literal
22
+ // .cairn-tok-comment a comment
23
+ // .cairn-tok-function a function name, tag name, or property key
24
+ // .cairn-tok-number a numeric, boolean, or other constant literal
25
+ // .cairn-tok-punct punctuation and operators
26
+ //
27
+ // Default text carries no token class and inherits the code block's foreground (--cairn-code-ink).
28
+ //
29
+ // SERVER/BUILD-ONLY: Shiki is loaded through a dynamic import so it stays out of the static client
30
+ // graph (a separate async chunk). The highlighter runs only inside the async pipeline, at
31
+ // render/prerender and in the Worker SSR. The highlighter is created once and its promise cached.
32
+ import type { Root, Element } from 'hast';
33
+ import { toString } from 'hast-util-to-string';
34
+ import type { Highlighter, ThemeRegistrationRaw, ShikiTransformer } from 'shiki';
35
+
36
+ // The curated language set Shiki preloads. Each id pulls its aliases too (loading `bash` also
37
+ // registers `sh`, `shell`, `zsh`; loading `js` registers `javascript`, `mjs`, `cjs`). A fence whose
38
+ // language is absent or outside this set falls back to plaintext rather than throwing.
39
+ const LANGS = [
40
+ 'js',
41
+ 'ts',
42
+ 'jsx',
43
+ 'tsx',
44
+ 'svelte',
45
+ 'html',
46
+ 'css',
47
+ 'json',
48
+ 'bash',
49
+ 'markdown',
50
+ 'python',
51
+ 'yaml',
52
+ 'sql',
53
+ ] as const;
54
+
55
+ // The plaintext language id. Shiki special-cases it (and `plaintext`/`txt`/`ansi`) as always
56
+ // available, so it is the safe fallback for an unknown or absent fence language: it escapes the
57
+ // code text into the same <pre class="shiki"> wrapper with no token coloring.
58
+ const PLAINTEXT = 'text';
59
+
60
+ // Sentinel foreground colors, one per ramp slot. Shiki has no class-emitting mode, so the theme
61
+ // assigns each scope group a unique sentinel hex, then the transformer below maps the resolved
62
+ // sentinel back to its cairn-tok-* class and deletes the style. The sentinels never reach the
63
+ // output. They are arbitrary distinct values; only their uniqueness and exact round-trip matter.
64
+ const SENTINEL_INK = '#000000';
65
+ const SENTINEL_TO_CLASS: Record<string, string> = {
66
+ '#000010': 'cairn-tok-comment',
67
+ '#000020': 'cairn-tok-keyword',
68
+ '#000030': 'cairn-tok-string',
69
+ '#000040': 'cairn-tok-function',
70
+ '#000050': 'cairn-tok-number',
71
+ '#000060': 'cairn-tok-punct',
72
+ };
73
+
74
+ // The Shiki theme that drives the class mapping. Every scope group resolves to its sentinel color;
75
+ // the transformer turns the sentinel into a class. The background and default foreground are
76
+ // sentinels too, stripped from the <pre> by the transformer so the site theme owns the surround.
77
+ const CAIRN_CODE_THEME: ThemeRegistrationRaw = {
78
+ name: 'cairn-roles',
79
+ type: 'light',
80
+ fg: SENTINEL_INK,
81
+ bg: '#ffffff',
82
+ settings: [
83
+ { settings: { foreground: SENTINEL_INK, background: '#ffffff' } },
84
+ {
85
+ scope: ['comment', 'punctuation.definition.comment', 'string.comment'],
86
+ settings: { foreground: '#000010' },
87
+ },
88
+ {
89
+ scope: [
90
+ 'keyword',
91
+ 'keyword.control',
92
+ 'storage',
93
+ 'storage.type',
94
+ 'storage.modifier',
95
+ 'variable.language',
96
+ 'keyword.operator.new',
97
+ 'keyword.operator.expression',
98
+ ],
99
+ settings: { foreground: '#000020' },
100
+ },
101
+ {
102
+ scope: ['string', 'string.quoted', 'string.template', 'constant.other.symbol'],
103
+ settings: { foreground: '#000030' },
104
+ },
105
+ {
106
+ scope: [
107
+ 'entity.name.function',
108
+ 'support.function',
109
+ 'meta.function-call',
110
+ 'entity.name.tag',
111
+ 'support.type.property-name',
112
+ ],
113
+ settings: { foreground: '#000040' },
114
+ },
115
+ {
116
+ scope: [
117
+ 'constant.numeric',
118
+ 'constant.language',
119
+ 'constant.character',
120
+ 'constant.other',
121
+ 'keyword.other.unit',
122
+ ],
123
+ settings: { foreground: '#000050' },
124
+ },
125
+ {
126
+ scope: ['punctuation', 'meta.brace', 'keyword.operator', 'meta.delimiter'],
127
+ settings: { foreground: '#000060' },
128
+ },
129
+ ],
130
+ };
131
+
132
+ // Read the `color:#rrggbb` hex out of a token span's inline style, lowercased for the sentinel map.
133
+ function styleColor(style: unknown): string | undefined {
134
+ if (typeof style !== 'string') return undefined;
135
+ const match = /color:(#[0-9a-fA-F]{6})/.exec(style);
136
+ return match ? match[1].toLowerCase() : undefined;
137
+ }
138
+
139
+ // Append a class to a hast element's class list, building the string form Shiki uses.
140
+ function addClass(node: Element, className: string): void {
141
+ const existing = node.properties?.class;
142
+ const prefix = typeof existing === 'string' && existing.length > 0 ? `${existing} ` : '';
143
+ (node.properties ??= {}).class = `${prefix}${className}`;
144
+ }
145
+
146
+ // The transformer that converts Shiki's sentinel-colored inline styles into the cairn-tok-* classes
147
+ // and strips every style attribute, so the output is class-only. The <pre> loses its
148
+ // background/foreground style (the site theme owns the surround); each token <span> trades its
149
+ // sentinel color for the matching class, and a default-foreground span is left unclassed.
150
+ const cairnTokenClasses: ShikiTransformer = {
151
+ name: 'cairn-token-classes',
152
+ pre(node) {
153
+ if (node.properties) delete node.properties.style;
154
+ },
155
+ code(node) {
156
+ if (node.properties) delete node.properties.style;
157
+ },
158
+ span(node) {
159
+ const hex = styleColor(node.properties?.style);
160
+ if (node.properties) delete node.properties.style;
161
+ const className = hex ? SENTINEL_TO_CLASS[hex] : undefined;
162
+ if (className) addClass(node, className);
163
+ },
164
+ };
165
+
166
+ // The cached highlighter. Created once on first use and reused for every render. The dynamic import
167
+ // keeps Shiki off the static client graph; the promise is cached so the WASM grammar load and the
168
+ // theme registration happen at most once per process.
169
+ let highlighterPromise: Promise<Highlighter> | undefined;
170
+
171
+ function getHighlighter(): Promise<Highlighter> {
172
+ if (!highlighterPromise) {
173
+ highlighterPromise = import('shiki').then((shiki) =>
174
+ shiki.createHighlighter({ themes: [CAIRN_CODE_THEME], langs: [...LANGS] }),
175
+ );
176
+ }
177
+ return highlighterPromise;
178
+ }
179
+
180
+ // Read the fenced language off a <code> element's class list. Markdown emits the language as a
181
+ // `language-<id>` class on the inner <code>. An empty or missing language returns undefined, which
182
+ // the caller maps to plaintext.
183
+ function codeLanguage(code: Element): string | undefined {
184
+ const className = code.properties?.className;
185
+ const classes = Array.isArray(className) ? className.map(String) : [];
186
+ const langClass = classes.find((c) => c.startsWith('language-'));
187
+ const lang = langClass?.slice('language-'.length);
188
+ return lang && lang.length > 0 ? lang : undefined;
189
+ }
190
+
191
+ // A fenced-code <pre> is a <pre> whose single element child is a <code>. Inline code (a bare <code>
192
+ // with no <pre> parent) and any other <pre> are left untouched.
193
+ function fencedCode(pre: Element): Element | undefined {
194
+ const child = pre.children.find((c): c is Element => c.type === 'element');
195
+ return child?.tagName === 'code' ? child : undefined;
196
+ }
197
+
198
+ // One pending highlight: the original <pre> to replace, the inner <code> to read text from, and the
199
+ // fenced language. The <pre> is rewritten in place (mutated to become Shiki's <pre>) after the async
200
+ // tokenize, which keeps the parent-children typing uniform across Root and Element.
201
+ interface Job {
202
+ pre: Element;
203
+ code: Element;
204
+ lang: string;
205
+ }
206
+
207
+ // Descend the tree and collect every fenced-code <pre>. The walk is synchronous and gathers the
208
+ // targets up front, because the highlight itself is async (unist-util-visit cannot await).
209
+ function collectFencedCode(node: Root | Element, jobs: Job[]): void {
210
+ for (const child of node.children) {
211
+ if (child.type !== 'element') continue;
212
+ if (child.tagName === 'pre') {
213
+ const code = fencedCode(child);
214
+ if (code) {
215
+ jobs.push({ pre: child, code, lang: codeLanguage(code) ?? PLAINTEXT });
216
+ continue;
217
+ }
218
+ }
219
+ collectFencedCode(child, jobs);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * The Shiki rehype plugin. Highlights every fenced-code block into a `<pre class="shiki">` whose
225
+ * tokens carry the cairn-tok-* classes (no inline style), then leaves the rest of the tree
226
+ * untouched. It runs as an async transformer because Shiki tokenizes asynchronously. Because the
227
+ * output is class-only it needs no special placement; it is safe anywhere after `remarkRehype`.
228
+ * @returns A unified transformer that mutates the hast tree in place.
229
+ */
230
+ export function rehypeCairnHighlight() {
231
+ return async (tree: Root): Promise<void> => {
232
+ const jobs: Job[] = [];
233
+ collectFencedCode(tree, jobs);
234
+ if (jobs.length === 0) return;
235
+
236
+ const highlighter = await getHighlighter();
237
+ const loaded = new Set(highlighter.getLoadedLanguages());
238
+
239
+ for (const job of jobs) {
240
+ // Fall back to plaintext for any language outside the preloaded set so Shiki never throws on
241
+ // an unknown fence. Plaintext still produces the <pre class="shiki"> wrapper with escaped text.
242
+ const lang = loaded.has(job.lang) ? job.lang : PLAINTEXT;
243
+ const result = highlighter.codeToHast(toString(job.code), {
244
+ lang,
245
+ theme: 'cairn-roles',
246
+ transformers: [cairnTokenClasses],
247
+ });
248
+ const pre = result.children.find(
249
+ (c): c is Element => c.type === 'element' && c.tagName === 'pre',
250
+ );
251
+ if (!pre) continue;
252
+ // Rewrite the original <pre> into Shiki's <pre> in place: adopt its tag, properties, and
253
+ // children. Mutating the existing node avoids reindexing the parent's children array.
254
+ job.pre.tagName = pre.tagName;
255
+ job.pre.properties = pre.properties;
256
+ job.pre.children = pre.children;
257
+ }
258
+ };
259
+ }
@@ -9,7 +9,8 @@ import rehypeStringify from 'rehype-stringify';
9
9
  import rehypeSanitize from 'rehype-sanitize';
10
10
  import type { Schema } from 'hast-util-sanitize';
11
11
  import { VFile } from 'vfile';
12
- import { buildSanitizeSchema, rehypeAnchorRel, rehypeSinkGuard } from './sanitize-schema.js';
12
+ import { buildSanitizeSchema, rehypeAnchorRel, rehypeSinkGuard, rehypeTaskListA11y } from './sanitize-schema.js';
13
+ import { rehypeCairnHighlight } from './highlight.js';
13
14
  import { remarkDirectiveStamp } from './remark-directives.js';
14
15
  import { remarkFigure } from './remark-figure.js';
15
16
  import { remarkResolveCairnLinks, CAIRN_RESOLVE } from './resolve-links.js';
@@ -19,12 +20,6 @@ import { defineRegistry, type ComponentRegistry } from './registry.js';
19
20
  import type { LinkResolve } from '../content/links.js';
20
21
 
21
22
  export interface RendererOptions {
22
- /**
23
- * Stamp a `data-rise` ordinal (0, 1, 2, …) on each top-level component so a site's
24
- * CSS can drive an entrance-cascade delay off it. Omit for no stagger. The ordinal
25
- * is inert, so a consumer's sanitize floor can keep `data-rise` and drop `style`.
26
- */
27
- stagger?: boolean;
28
23
  /**
29
24
  * Extend the sanitize allowlist. Receives cairn's default schema (defaultSchema plus the
30
25
  * directive markers and the common benign tags) and returns the schema to use. Add to the
@@ -72,8 +67,18 @@ export function createRenderer(
72
67
  const rehypePlugins: PluggableList = [
73
68
  rehypeRaw,
74
69
  ...floor,
75
- [rehypeDispatch, registry, options.stagger],
70
+ [rehypeDispatch, registry],
76
71
  rehypeSlug,
72
+ // Name each GFM task-list checkbox from its item text. It runs after the sanitize floor (which
73
+ // does not allow aria-label) so the added attribute survives, and is content-not-sink, so it is
74
+ // not gated by unsafeDisableSanitize.
75
+ rehypeTaskListA11y,
76
+ // Build-time syntax highlighting. It emits class-only output (the cairn-tok-* ramp, no inline
77
+ // style), so it is class-driven like the rest of the pipeline and needs no special placement
78
+ // relative to the sanitize floor or the sink guard: the token classes survive the floor because
79
+ // `className` is already allowed on `*`. It runs unconditionally (a code fence is content, not a
80
+ // sink) and ships no client highlighter (Shiki is build-only behind a dynamic import).
81
+ rehypeCairnHighlight,
77
82
  ];
78
83
  if (rel !== false) rehypePlugins.push([rehypeAnchorRel, rel]);
79
84
  // The sink guard runs last, over the fully-built tree, so it neutralizes a sink a component
@@ -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,19 +58,45 @@ 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;
89
- /** Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. */
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';
73
+ /**
74
+ * Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. Maps a free-string role to a
75
+ * glyph key in the site IconSet; choose a logically representative glyph and prefer glyphs
76
+ * distinct across roles so the picker stays scannable. Overrides the engine
77
+ * {@link DEFAULT_ICON_BY_ROLE} fallback for the roles it names.
78
+ */
90
79
  defaultIconByRole?: Record<string, string>;
91
80
  /** One line on when to reach for this component; feeds the picker and the reference file. */
92
81
  use?: string;
93
- /** The `{key="value"}` attributes this component accepts. */
94
- 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;
95
94
  /** The named content regions this component accepts. */
96
95
  slots?: SlotDef[];
97
- /** A glyph key from the site IconSet, shown beside the label in the picker. */
96
+ /**
97
+ * A glyph key from the site IconSet, shown beside the label in the picker. Choose a logically
98
+ * representative glyph and prefer glyphs distinct across components so the picker stays scannable.
99
+ */
98
100
  icon?: string;
99
101
  /** A category heading for the picker. Components order by declaration within a group. */
100
102
  group?: string;
@@ -115,8 +117,8 @@ export interface ComponentRegistry {
115
117
  names: string[];
116
118
  get(name: string): ComponentDef | undefined;
117
119
  defaultIcon(name: string, role?: string): string | undefined;
118
- /** The component's first `type:'icon'` attribute, or undefined when it declares none. */
119
- 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;
120
122
  }
121
123
 
122
124
  /**
@@ -129,14 +131,28 @@ export function dataAttrProp(key: string): string {
129
131
  }
130
132
 
131
133
  /**
132
- * A component's first `type:'icon'` attribute, or undefined when it declares none. Both the
133
- * 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
134
136
  * predicate rather than spelling the `type === 'icon'` find twice.
135
137
  */
136
- function findIconField(def: ComponentDef): AttributeField | undefined {
137
- 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];
138
140
  }
139
141
 
142
+ /**
143
+ * The engine's role-to-glyph-key fallback for the conventional admonition roles, which a site's
144
+ * IconSet may satisfy. A component's own {@link ComponentDef.defaultIconByRole} overrides it.
145
+ */
146
+ const DEFAULT_ICON_BY_ROLE: Record<string, string> = {
147
+ note: 'info',
148
+ tip: 'lightbulb',
149
+ important: 'star',
150
+ warning: 'warning',
151
+ caution: 'alert-triangle',
152
+ info: 'info',
153
+ danger: 'flame',
154
+ };
155
+
140
156
  /**
141
157
  * Build a registry from a site's component definitions. The single source the render
142
158
  * pipeline (directive stamp plus rehype dispatch) and the editor palette both read.
@@ -159,7 +175,12 @@ export function defineRegistry({ components }: { components: ComponentDef[] }):
159
175
  defs: components,
160
176
  names: components.map((c) => c.name),
161
177
  get: (name) => byName.get(name),
162
- defaultIcon: (name, role) => (role ? byName.get(name)?.defaultIconByRole?.[role] : undefined),
178
+ defaultIcon: (name, role) => {
179
+ if (!role) return undefined;
180
+ const def = byName.get(name);
181
+ if (!def || !findIconField(def)) return undefined;
182
+ return def.defaultIconByRole?.[role] ?? DEFAULT_ICON_BY_ROLE[role];
183
+ },
163
184
  iconField: (name) => {
164
185
  const def = byName.get(name);
165
186
  return def ? findIconField(def) : undefined;
@@ -182,8 +203,8 @@ export interface ComponentValues {
182
203
  */
183
204
  export function emptyValues(def: ComponentDef): ComponentValues {
184
205
  const attributes: Record<string, string | boolean> = {};
185
- for (const field of def.attributes ?? []) {
186
- 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 : '');
187
208
  }
188
209
  const slots: Record<string, string | string[]> = {};
189
210
  for (const slot of def.slots ?? []) {
@@ -205,3 +226,28 @@ export function previewValues(def: ComponentDef): ComponentValues {
205
226
  slots: { ...base.slots, ...def.preview.slots },
206
227
  };
207
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
+ }
@@ -2,9 +2,7 @@ import type { Root, Element, ElementContent } from 'hast';
2
2
  import { h } from 'hastscript';
3
3
  import { dataAttrProp, type ComponentContext, type ComponentDef, type ComponentRegistry } from './registry.js';
4
4
 
5
- /**
6
- *
7
- */
5
+ /** Narrow a hast node to an Element, false for a text node, a comment, or undefined. */
8
6
  export function isElement(node: ElementContent | undefined): node is Element {
9
7
  return !!node && node.type === 'element';
10
8
  }
@@ -21,9 +19,7 @@ export function strAttr(ctx: ComponentContext, key: string): string | undefined
21
19
  // hast Properties values are PropertyValue (string | number | boolean | array | null).
22
20
  // Directive markers (dataPrimitive/dataRole/dataAttr<Key>) are always stamped as strings;
23
21
  // this reads them back with that guarantee instead of casting at each call site.
24
- /**
25
- *
26
- */
22
+ /** Read a hast element property as a string, or undefined when it is absent or non-string. */
27
23
  export function strProp(node: Element, name: string): string | undefined {
28
24
  const value = node.properties?.[name];
29
25
  return typeof value === 'string' ? value : undefined;
@@ -73,7 +69,7 @@ export function markFirstList(children: ElementContent[]): Element | undefined {
73
69
 
74
70
  // Recurse into a node's children, transforming any nested primitive sections
75
71
  // (a grid inside a card, panels inside a split). Nested primitives never carry the
76
- // 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).
77
73
  function transformChildren(children: ElementContent[], registry: ComponentRegistry): ElementContent[] {
78
74
  return children.map((c) => {
79
75
  if (isElement(c) && c.properties?.dataPrimitive) return transformNode(c, registry);
@@ -86,10 +82,10 @@ function transformChildren(children: ElementContent[], registry: ComponentRegist
86
82
  // 'true'/'false'; everything else is the literal string the author wrote.
87
83
  function readAttributes(node: Element, def: ComponentDef): Record<string, string | boolean> {
88
84
  const out: Record<string, string | boolean> = {};
89
- for (const field of def.attributes ?? []) {
90
- const value = strProp(node, dataAttrProp(field.key));
85
+ for (const [name, field] of Object.entries(def.attributes ?? {})) {
86
+ const value = strProp(node, dataAttrProp(name));
91
87
  if (value == null) continue;
92
- out[field.key] = field.type === 'boolean' ? value === 'true' : value;
88
+ out[name] = field.type === 'boolean' ? value === 'true' : value;
93
89
  }
94
90
  return out;
95
91
  }
@@ -139,6 +135,40 @@ function partitionSlots(node: Element): {
139
135
  };
140
136
  }
141
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
+
142
172
  function transformNode(node: Element, registry: ComponentRegistry): Element {
143
173
  node.children = transformChildren(node.children as ElementContent[], registry);
144
174
  const name = strProp(node, 'dataPrimitive');
@@ -151,24 +181,25 @@ function transformNode(node: Element, registry: ComponentRegistry): Element {
151
181
  items: parts.items,
152
182
  node,
153
183
  };
154
- return def.build(ctx);
184
+ const built = def.build(ctx);
185
+ return def.hydrate ? islandBoundary(name!, def, ctx.attributes, built) : built;
155
186
  }
156
187
 
157
188
  /**
158
189
  * Rehype transformer: dispatch each stamped element through its registry `build`
159
- * fn. When `stagger` is on, each top-level primitive gets a `data-rise` attribute
160
- * carrying its document-order index (0, 1, 2, …); the site's CSS maps that ordinal
161
- * 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
162
193
  * `data-rise` while dropping `style`. Nested primitives never get it. Non-primitive
163
194
  * content (lede, intro paragraphs, the page-toc nav) passes through untouched.
164
195
  */
165
- export function rehypeDispatch(registry: ComponentRegistry, stagger?: boolean) {
196
+ export function rehypeDispatch(registry: ComponentRegistry) {
166
197
  return (tree: Root) => {
167
198
  let idx = 0;
168
199
  tree.children = (tree.children as ElementContent[]).map((child) => {
169
200
  if (isElement(child) && child.properties?.dataPrimitive) {
170
201
  const el = transformNode(child, registry);
171
- if (stagger) el.properties = { ...el.properties, dataRise: String(idx++) };
202
+ el.properties = { ...el.properties, dataRise: String(idx++) };
172
203
  return el;
173
204
  }
174
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 = {});