@glw907/cairn-cms 0.62.1 → 0.68.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 (55) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/dist/auth/types.d.ts +7 -0
  3. package/dist/components/ComponentInsertDialog.svelte +17 -6
  4. package/dist/components/ConceptList.svelte +25 -4
  5. package/dist/components/cairn-admin.css +175 -2
  6. package/dist/content/advisories.d.ts +5 -0
  7. package/dist/content/advisories.js +17 -9
  8. package/dist/content/field-rules.d.ts +15 -0
  9. package/dist/content/field-rules.js +39 -0
  10. package/dist/content/fields.d.ts +121 -0
  11. package/dist/content/fields.js +30 -0
  12. package/dist/content/fieldset.d.ts +86 -0
  13. package/dist/content/fieldset.js +233 -0
  14. package/dist/content/schema.js +16 -20
  15. package/dist/delivery/public-routes.d.ts +8 -0
  16. package/dist/delivery/public-routes.js +10 -1
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.js +5 -0
  19. package/dist/log/events.d.ts +1 -1
  20. package/dist/media/index.d.ts +1 -1
  21. package/dist/media/index.js +1 -1
  22. package/dist/media/manifest.d.ts +11 -0
  23. package/dist/media/manifest.js +13 -0
  24. package/dist/render/highlight.d.ts +9 -0
  25. package/dist/render/highlight.js +206 -0
  26. package/dist/render/pipeline.js +12 -1
  27. package/dist/render/registry.d.ts +10 -2
  28. package/dist/render/registry.js +21 -1
  29. package/dist/render/rehype-dispatch.d.ts +2 -6
  30. package/dist/render/rehype-dispatch.js +2 -6
  31. package/dist/render/sanitize-schema.d.ts +10 -0
  32. package/dist/render/sanitize-schema.js +29 -0
  33. package/dist/sveltekit/content-routes.js +9 -7
  34. package/dist/sveltekit/guard.js +10 -0
  35. package/package.json +13 -2
  36. package/src/lib/auth/types.ts +7 -0
  37. package/src/lib/components/ComponentInsertDialog.svelte +17 -6
  38. package/src/lib/components/ConceptList.svelte +41 -4
  39. package/src/lib/content/advisories.ts +24 -15
  40. package/src/lib/content/field-rules.ts +40 -0
  41. package/src/lib/content/fields.ts +127 -0
  42. package/src/lib/content/fieldset.ts +307 -0
  43. package/src/lib/content/schema.ts +9 -13
  44. package/src/lib/delivery/public-routes.ts +19 -1
  45. package/src/lib/index.ts +7 -0
  46. package/src/lib/log/events.ts +1 -0
  47. package/src/lib/media/index.ts +1 -0
  48. package/src/lib/media/manifest.ts +14 -0
  49. package/src/lib/render/highlight.ts +259 -0
  50. package/src/lib/render/pipeline.ts +12 -1
  51. package/src/lib/render/registry.ts +30 -3
  52. package/src/lib/render/rehype-dispatch.ts +2 -6
  53. package/src/lib/render/sanitize-schema.ts +31 -0
  54. package/src/lib/sveltekit/content-routes.ts +9 -7
  55. package/src/lib/sveltekit/guard.ts +15 -0
@@ -2,6 +2,7 @@
2
2
  // declaration yields a plain-data field projection for the editor form, a generated validator,
3
3
  // and an inferred frontmatter type. Plan 1 builds the additive primitive; the adapter-contract
4
4
  // cutover and the typed reads are Plan 2.
5
+ import { compilePattern, dateBoundsError, patternError, stringLengthError } from './field-rules.js';
5
6
  import type { FrontmatterField, ImageValue, ValidationResult } from './types.js';
6
7
  import { validateFields } from './validate.js';
7
8
 
@@ -77,16 +78,15 @@ export type Infer<S> = S extends ConceptSchema<infer F> ? InferFields<F> : never
77
78
  function applyRules(field: FrontmatterField, value: unknown, errors: Record<string, string>, patterns: Map<string, RegExp>): void {
78
79
  if (typeof value !== 'string' || value === '') return;
79
80
  if (field.type === 'text' || field.type === 'textarea') {
80
- if (field.min != null && value.length < field.min) errors[field.name] = `${field.label} must be at least ${field.min} characters`;
81
- else if (field.max != null && value.length > field.max) errors[field.name] = `${field.label} must be at most ${field.max} characters`;
82
- else if (field.length != null && value.length !== field.length) errors[field.name] = `${field.label} must be exactly ${field.length} characters`;
83
- else if (field.pattern != null) {
84
- const re = patterns.get(field.name);
85
- if (re && !re.test(value)) errors[field.name] = `${field.label} is not in the expected format`;
81
+ const lengthError = stringLengthError(value, field, field.label);
82
+ if (lengthError != null) errors[field.name] = lengthError;
83
+ else {
84
+ const formatError = patternError(value, patterns.get(field.name), field.label);
85
+ if (formatError != null) errors[field.name] = formatError;
86
86
  }
87
87
  } else if (field.type === 'date') {
88
- if (field.min != null && value < field.min) errors[field.name] = `${field.label} must be on or after ${field.min}`;
89
- else if (field.max != null && value > field.max) errors[field.name] = `${field.label} must be on or before ${field.max}`;
88
+ const boundsError = dateBoundsError(value, field, field.label);
89
+ if (boundsError != null) errors[field.name] = boundsError;
90
90
  }
91
91
  }
92
92
 
@@ -105,11 +105,7 @@ function compilePatterns(fields: FrontmatterField[]): Map<string, RegExp> {
105
105
  const compiled = new Map<string, RegExp>();
106
106
  for (const field of fields) {
107
107
  if ((field.type === 'text' || field.type === 'textarea') && field.pattern != null) {
108
- try {
109
- compiled.set(field.name, new RegExp(field.pattern));
110
- } catch (cause) {
111
- throw new Error(`cairn: field "${field.name}" has an invalid pattern: ${field.pattern}`, { cause });
112
- }
108
+ compiled.set(field.name, compilePattern(field.pattern, field.name));
113
109
  }
114
110
  }
115
111
  return compiled;
@@ -13,6 +13,7 @@ import { buildLinkResolver } from './site-resolver.js';
13
13
  import type { LinkResolve } from '../content/links.js';
14
14
  import type { MediaResolve } from '../render/resolve-media.js';
15
15
  import { parseMediaToken } from '../media/reference.js';
16
+ import { log } from '../log/index.js';
16
17
 
17
18
  /** Injected dependencies for the public loaders. */
18
19
  export interface PublicRoutesDeps {
@@ -36,6 +37,14 @@ export interface PublicRoutesDeps {
36
37
  * media is off and no `heroImage` projection is derived.
37
38
  */
38
39
  resolveMedia?: MediaResolve;
40
+ /**
41
+ * Whether the site configured media on, read from `runtime.resolvedAssets.enabled`. It exists only
42
+ * to diagnose a forgotten wire-point: media on but no `resolveMedia` reached this factory, which
43
+ * renders public hero and body images as bare `media:` tokens. When true and `resolveMedia` is
44
+ * absent, the factory emits `media.resolver_absent` once at construction. It does not change
45
+ * resolution; `resolveMedia` alone still gates the hero projection.
46
+ */
47
+ assetsEnabled?: boolean;
39
48
  }
40
49
 
41
50
  /** The archive and tag list data: summaries the template renders. */
@@ -74,7 +83,16 @@ export interface EntryData {
74
83
 
75
84
  /** Build the public loaders for a site's unified index. */
76
85
  export function createPublicRoutes(deps: PublicRoutesDeps) {
77
- const { site, render, origin, siteName, description, feeds, defaultImage, resolveMedia } = deps;
86
+ const { site, render, origin, siteName, description, feeds, defaultImage, resolveMedia, assetsEnabled } = deps;
87
+
88
+ // Diagnose a forgotten wire-point: media is configured on but no resolver reached this factory, so
89
+ // every public hero and body `media:` token renders bare (the ecxc 0.57.0 finding). The condition
90
+ // is a property of the wiring, not of any one load, so it is checked once here at construction
91
+ // rather than per entryLoad or per image, which keeps the warning loud-once and out of the
92
+ // prerender hot path. Resolution is unchanged; resolveMedia alone still gates the hero projection.
93
+ if (assetsEnabled && !resolveMedia) {
94
+ log.warn('media.resolver_absent', { enabled: true });
95
+ }
78
96
 
79
97
  /**
80
98
  * Derive the hero projection from an entry's frontmatter, without mutating it (locked decision 5).
package/src/lib/index.ts CHANGED
@@ -45,6 +45,13 @@ export {
45
45
  export { defineFields } from './content/schema.js';
46
46
  export { defineAdapter } from './content/adapter.js';
47
47
  export type { ConceptSchema, Infer, InferFields, DefineFieldsOptions, StandardInput, StandardSchemaV1 } from './content/schema.js';
48
+ // The Contract v2 field vocabulary, additive beside `defineFields`. The individual *Field
49
+ // interfaces and the bare `Infer` stay module-local: the old `FrontmatterField` model above
50
+ // already exports those names, and the cutover plan frees them.
51
+ export { fields } from './content/fields.js';
52
+ export type { FieldDescriptor } from './content/fields.js';
53
+ export { fieldset, initialValues } from './content/fieldset.js';
54
+ export type { Fieldset, InferFieldset, FieldsetOptions, BehaviorTable } from './content/fieldset.js';
48
55
  export {
49
56
  isValidId,
50
57
  idFromFilename,
@@ -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
@@ -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';
@@ -74,6 +75,16 @@ export function createRenderer(
74
75
  ...floor,
75
76
  [rehypeDispatch, registry, options.stagger],
76
77
  rehypeSlug,
78
+ // Name each GFM task-list checkbox from its item text. It runs after the sanitize floor (which
79
+ // does not allow aria-label) so the added attribute survives, and is content-not-sink, so it is
80
+ // not gated by unsafeDisableSanitize.
81
+ rehypeTaskListA11y,
82
+ // Build-time syntax highlighting. It emits class-only output (the cairn-tok-* ramp, no inline
83
+ // style), so it is class-driven like the rest of the pipeline and needs no special placement
84
+ // relative to the sanitize floor or the sink guard: the token classes survive the floor because
85
+ // `className` is already allowed on `*`. It runs unconditionally (a code fence is content, not a
86
+ // sink) and ships no client highlighter (Shiki is build-only behind a dynamic import).
87
+ rehypeCairnHighlight,
77
88
  ];
78
89
  if (rel !== false) rehypePlugins.push([rehypeAnchorRel, rel]);
79
90
  // The sink guard runs last, over the fully-built tree, so it neutralizes a sink a component
@@ -86,7 +86,12 @@ export interface ComponentDef {
86
86
  * result, so a build fn stays free of any motion concern.
87
87
  */
88
88
  build: (ctx: ComponentContext) => Element;
89
- /** Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. */
89
+ /**
90
+ * Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. Maps a free-string role to a
91
+ * glyph key in the site IconSet; choose a logically representative glyph and prefer glyphs
92
+ * distinct across roles so the picker stays scannable. Overrides the engine
93
+ * {@link DEFAULT_ICON_BY_ROLE} fallback for the roles it names.
94
+ */
90
95
  defaultIconByRole?: Record<string, string>;
91
96
  /** One line on when to reach for this component; feeds the picker and the reference file. */
92
97
  use?: string;
@@ -94,7 +99,10 @@ export interface ComponentDef {
94
99
  attributes?: AttributeField[];
95
100
  /** The named content regions this component accepts. */
96
101
  slots?: SlotDef[];
97
- /** A glyph key from the site IconSet, shown beside the label in the picker. */
102
+ /**
103
+ * A glyph key from the site IconSet, shown beside the label in the picker. Choose a logically
104
+ * representative glyph and prefer glyphs distinct across components so the picker stays scannable.
105
+ */
98
106
  icon?: string;
99
107
  /** A category heading for the picker. Components order by declaration within a group. */
100
108
  group?: string;
@@ -137,6 +145,20 @@ function findIconField(def: ComponentDef): AttributeField | undefined {
137
145
  return def.attributes?.find((field) => field.type === 'icon');
138
146
  }
139
147
 
148
+ /**
149
+ * The engine's role-to-glyph-key fallback for the conventional admonition roles, which a site's
150
+ * IconSet may satisfy. A component's own {@link ComponentDef.defaultIconByRole} overrides it.
151
+ */
152
+ const DEFAULT_ICON_BY_ROLE: Record<string, string> = {
153
+ note: 'info',
154
+ tip: 'lightbulb',
155
+ important: 'star',
156
+ warning: 'warning',
157
+ caution: 'alert-triangle',
158
+ info: 'info',
159
+ danger: 'flame',
160
+ };
161
+
140
162
  /**
141
163
  * Build a registry from a site's component definitions. The single source the render
142
164
  * pipeline (directive stamp plus rehype dispatch) and the editor palette both read.
@@ -159,7 +181,12 @@ export function defineRegistry({ components }: { components: ComponentDef[] }):
159
181
  defs: components,
160
182
  names: components.map((c) => c.name),
161
183
  get: (name) => byName.get(name),
162
- defaultIcon: (name, role) => (role ? byName.get(name)?.defaultIconByRole?.[role] : undefined),
184
+ defaultIcon: (name, role) => {
185
+ if (!role) return undefined;
186
+ const def = byName.get(name);
187
+ if (!def || !findIconField(def)) return undefined;
188
+ return def.defaultIconByRole?.[role] ?? DEFAULT_ICON_BY_ROLE[role];
189
+ },
163
190
  iconField: (name) => {
164
191
  const def = byName.get(name);
165
192
  return def ? findIconField(def) : undefined;
@@ -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;
@@ -1,6 +1,7 @@
1
1
  import { defaultSchema, type Schema } from 'hast-util-sanitize';
2
2
  import type { Root, Element } from 'hast';
3
3
  import { visit } from 'unist-util-visit';
4
+ import { toString } from 'hast-util-to-string';
4
5
  import { dataAttrProp, type ComponentRegistry } from './registry.js';
5
6
 
6
7
  // The fixed directive markers the stamp writes and the dispatch reads. They are inert data
@@ -68,6 +69,36 @@ export function rehypeAnchorRel(rel: string) {
68
69
  };
69
70
  }
70
71
 
72
+ /**
73
+ * Give every GFM task-list checkbox an accessible name from its item text. remark-gfm emits a real
74
+ * `<input type="checkbox" disabled>` with no label, which axe's `label` rule flags as a critical
75
+ * violation even though the control is read-only; the visible label is the surrounding `<li>` text,
76
+ * not associated programmatically. This sets `aria-label` on each task-list checkbox to its item's
77
+ * text so the name travels with the control, keeping the engine's real disabled input (the bar's
78
+ * non-color cue) while clearing the violation on every site. It must run after the sanitize floor,
79
+ * which does not allow `aria-label`, so the attribute is added once the floor has run.
80
+ */
81
+ export function rehypeTaskListA11y() {
82
+ return (tree: Root) => {
83
+ visit(tree, 'element', (node: Element) => {
84
+ const className = node.properties?.className;
85
+ const isTaskItem =
86
+ node.tagName === 'li' && Array.isArray(className) && className.includes('task-list-item');
87
+ if (!isTaskItem) return;
88
+ const checkbox = node.children.find(
89
+ (child): child is Element =>
90
+ child.type === 'element' &&
91
+ child.tagName === 'input' &&
92
+ child.properties?.type === 'checkbox',
93
+ );
94
+ if (!checkbox) return;
95
+ const label = toString(node).trim();
96
+ // Only when there is text to name it; an empty item leaves the box unnamed rather than blank.
97
+ if (label) (checkbox.properties ??= {})['ariaLabel'] = label;
98
+ });
99
+ };
100
+ }
101
+
71
102
  // URL-bearing hast properties the post-dispatch guard scheme-checks. hast camelCases attribute
72
103
  // names through property-information (srcset -> srcSet, xlink:href -> xLinkHref with a capital L,
73
104
  // formaction -> formAction). data is the <object data> URL attribute; data-* attributes camelCase
@@ -8,7 +8,7 @@ import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../conten
8
8
  import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
9
9
  import { deriveExcerpt } from '../content/excerpt.js';
10
10
  import { asString, entryIdentity } from '../content/identity.js';
11
- import { buildAddressIndex, addressCollision, type AdvisoryNotice, type AddressEntry } from '../content/advisories.js';
11
+ import { buildAddressIndex, mainAddressIndex, addressCollision, type AdvisoryNotice, type AddressEntry } from '../content/advisories.js';
12
12
  import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
13
13
  import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
14
14
  import { listMarkdown, readRaw, commitFile, commitFiles, type FileChange } from '../github/repo.js';
@@ -1072,14 +1072,16 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1072
1072
  inbound = inboundLinks(manifest, concept.id, id);
1073
1073
  }
1074
1074
 
1075
- // The cross-branch address-collision advisory: warn-and-allow, never a gate. Build it from the
1076
- // same manifest read above (no second read) and degrade to no notice on any read failure, so a
1077
- // transient GitHub error never blocks the editor. Skip the build with no manifest to index.
1075
+ // The address-collision advisory: warn-and-allow, never a gate. At edit-load it checks the
1076
+ // published corpus only, built synchronously from the same manifest read above (no extra GitHub
1077
+ // read per editor open); publishAction re-checks the full cross-branch index before it lands. The
1078
+ // try/catch degrades to no notice if entryIdentity throws on a malformed-date entry. Skip the build
1079
+ // with no manifest to index.
1078
1080
  let advisories: AdvisoryNotice[] = [];
1079
1081
  if (manifest !== null) {
1080
1082
  try {
1081
1083
  const identity = entryIdentity(concept, path, parsed.frontmatter);
1082
- const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
1084
+ const addressIndex = mainAddressIndex(manifest);
1083
1085
  const other = addressCollision(addressIndex, { concept: concept.id, id }, identity.permalink);
1084
1086
  if (other) {
1085
1087
  const otherConcept = findConcept(runtime.concepts, other.concept);
@@ -1093,8 +1095,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1093
1095
  },
1094
1096
  ];
1095
1097
  }
1096
- } catch (err) {
1097
- log.warn('github.unreachable', { scope: 'edit-advisories', error: String(err) });
1098
+ } catch {
1099
+ // A malformed-date entry that cannot resolve its permalink degrades to no advisory, fail open.
1098
1100
  }
1099
1101
  }
1100
1102
 
@@ -41,6 +41,21 @@ export function createAuthGuard() {
41
41
  return async function handle({ event, resolve }: HandleInput): Promise<Response> {
42
42
  const { pathname } = event.url;
43
43
 
44
+ // Fail closed if the dev-backend flag is set in a deployed runtime. Read both env sources: a
45
+ // Cloudflare Worker var lands on platform.env, an adapter-node OS var on process.env. A correct
46
+ // production build already eliminated the dev backend (the consumer gates it on the build-foldable
47
+ // `dev`), so a set flag signals a polluted environment; refuse loudly.
48
+ const platformFlag = event.platform?.env?.CAIRN_DEV_BACKEND;
49
+ const processFlag =
50
+ typeof process !== 'undefined' ? process.env?.CAIRN_DEV_BACKEND : undefined;
51
+ if (platformFlag === '1' || platformFlag === true || processFlag === '1') {
52
+ log.error('guard.rejected', { reason: 'dev_backend_in_prod', path: pathname });
53
+ return new Response(
54
+ 'cairn: the dev backend flag is set in a deployed environment. Unset CAIRN_DEV_BACKEND.',
55
+ { status: 503 },
56
+ );
57
+ }
58
+
44
59
  // Rule 2 - non-admin: restore the framework's strict Origin check the consumer disabled when
45
60
  // they set checkOrigin: false to hand cairn the admin CSRF authority.
46
61
  if (!isAdminPath(pathname)) {