@glw907/cairn-cms 0.11.0 → 0.17.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 (124) hide show
  1. package/dist/auth/crypto.d.ts +8 -2
  2. package/dist/auth/crypto.d.ts.map +1 -1
  3. package/dist/auth/crypto.js +12 -2
  4. package/dist/auth/store.d.ts +2 -0
  5. package/dist/auth/store.d.ts.map +1 -1
  6. package/dist/auth/store.js +17 -5
  7. package/dist/components/ComponentForm.svelte +33 -10
  8. package/dist/components/ComponentForm.svelte.d.ts.map +1 -1
  9. package/dist/components/EditPage.svelte +4 -6
  10. package/dist/components/EditPage.svelte.d.ts +1 -1
  11. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  12. package/dist/components/IconPicker.svelte +53 -7
  13. package/dist/components/IconPicker.svelte.d.ts +7 -3
  14. package/dist/components/IconPicker.svelte.d.ts.map +1 -1
  15. package/dist/content/adapter.d.ts +4 -0
  16. package/dist/content/adapter.d.ts.map +1 -0
  17. package/dist/content/adapter.js +4 -0
  18. package/dist/content/concepts.js +2 -2
  19. package/dist/content/schema.d.ts +75 -0
  20. package/dist/content/schema.d.ts.map +1 -0
  21. package/dist/content/schema.js +72 -0
  22. package/dist/content/types.d.ts +30 -7
  23. package/dist/content/types.d.ts.map +1 -1
  24. package/dist/content/validate.d.ts +5 -3
  25. package/dist/content/validate.d.ts.map +1 -1
  26. package/dist/content/validate.js +14 -7
  27. package/dist/delivery/content-index.d.ts +8 -0
  28. package/dist/delivery/content-index.d.ts.map +1 -1
  29. package/dist/delivery/content-index.js +23 -12
  30. package/dist/delivery/feeds.d.ts +1 -1
  31. package/dist/delivery/feeds.d.ts.map +1 -1
  32. package/dist/delivery/feeds.js +31 -16
  33. package/dist/delivery/index.d.ts +5 -1
  34. package/dist/delivery/index.d.ts.map +1 -1
  35. package/dist/delivery/index.js +2 -0
  36. package/dist/delivery/seo-fields.d.ts +22 -0
  37. package/dist/delivery/seo-fields.d.ts.map +1 -0
  38. package/dist/delivery/seo-fields.js +32 -0
  39. package/dist/delivery/site-index.d.ts +2 -2
  40. package/dist/delivery/site-index.d.ts.map +1 -1
  41. package/dist/delivery/site-index.js +16 -18
  42. package/dist/delivery/site-indexes.d.ts +26 -0
  43. package/dist/delivery/site-indexes.d.ts.map +1 -0
  44. package/dist/delivery/site-indexes.js +30 -0
  45. package/dist/env.d.ts.map +1 -1
  46. package/dist/env.js +14 -0
  47. package/dist/github/signing.d.ts +12 -0
  48. package/dist/github/signing.d.ts.map +1 -1
  49. package/dist/github/signing.js +22 -0
  50. package/dist/index.d.ts +9 -3
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +5 -2
  53. package/dist/render/component-grammar.d.ts +7 -0
  54. package/dist/render/component-grammar.d.ts.map +1 -1
  55. package/dist/render/component-grammar.js +27 -8
  56. package/dist/render/component-validate.js +3 -3
  57. package/dist/render/glyph.d.ts +4 -1
  58. package/dist/render/glyph.d.ts.map +1 -1
  59. package/dist/render/glyph.js +6 -2
  60. package/dist/render/pipeline.d.ts +10 -0
  61. package/dist/render/pipeline.d.ts.map +1 -1
  62. package/dist/render/pipeline.js +15 -1
  63. package/dist/render/registry.d.ts +23 -5
  64. package/dist/render/registry.d.ts.map +1 -1
  65. package/dist/render/registry.js +6 -0
  66. package/dist/render/rehype-dispatch.d.ts +1 -5
  67. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  68. package/dist/render/rehype-dispatch.js +71 -19
  69. package/dist/render/remark-directives.d.ts +1 -1
  70. package/dist/render/remark-directives.d.ts.map +1 -1
  71. package/dist/render/remark-directives.js +37 -0
  72. package/dist/render/sanitize-schema.d.ts +20 -0
  73. package/dist/render/sanitize-schema.d.ts.map +1 -0
  74. package/dist/render/sanitize-schema.js +48 -0
  75. package/dist/sveltekit/auth-routes.d.ts.map +1 -1
  76. package/dist/sveltekit/auth-routes.js +29 -11
  77. package/dist/sveltekit/content-routes.js +2 -2
  78. package/dist/sveltekit/guard.d.ts +1 -1
  79. package/dist/sveltekit/guard.d.ts.map +1 -1
  80. package/dist/sveltekit/guard.js +25 -10
  81. package/dist/sveltekit/nav-routes.js +2 -2
  82. package/dist/sveltekit/public-routes.d.ts +3 -0
  83. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  84. package/dist/sveltekit/public-routes.js +10 -3
  85. package/dist/sveltekit/types.d.ts +6 -0
  86. package/dist/sveltekit/types.d.ts.map +1 -1
  87. package/package.json +3 -2
  88. package/src/lib/auth/crypto.ts +14 -2
  89. package/src/lib/auth/store.ts +18 -5
  90. package/src/lib/components/ComponentForm.svelte +33 -10
  91. package/src/lib/components/EditPage.svelte +4 -6
  92. package/src/lib/components/IconPicker.svelte +53 -7
  93. package/src/lib/content/adapter.ts +10 -0
  94. package/src/lib/content/concepts.ts +2 -2
  95. package/src/lib/content/schema.ts +133 -0
  96. package/src/lib/content/types.ts +30 -7
  97. package/src/lib/content/validate.ts +10 -7
  98. package/src/lib/delivery/content-index.ts +32 -12
  99. package/src/lib/delivery/feeds.ts +34 -19
  100. package/src/lib/delivery/index.ts +5 -1
  101. package/src/lib/delivery/seo-fields.ts +43 -0
  102. package/src/lib/delivery/site-index.ts +15 -16
  103. package/src/lib/delivery/site-indexes.ts +64 -0
  104. package/src/lib/env.ts +13 -0
  105. package/src/lib/github/signing.ts +32 -0
  106. package/src/lib/index.ts +8 -2
  107. package/src/lib/render/component-grammar.ts +34 -10
  108. package/src/lib/render/component-validate.ts +3 -3
  109. package/src/lib/render/glyph.ts +6 -2
  110. package/src/lib/render/pipeline.ts +25 -1
  111. package/src/lib/render/registry.ts +27 -5
  112. package/src/lib/render/rehype-dispatch.ts +67 -20
  113. package/src/lib/render/remark-directives.ts +39 -1
  114. package/src/lib/render/sanitize-schema.ts +57 -0
  115. package/src/lib/sveltekit/auth-routes.ts +30 -11
  116. package/src/lib/sveltekit/content-routes.ts +2 -2
  117. package/src/lib/sveltekit/guard.ts +25 -10
  118. package/src/lib/sveltekit/nav-routes.ts +2 -2
  119. package/src/lib/sveltekit/public-routes.ts +13 -3
  120. package/src/lib/sveltekit/types.ts +5 -1
  121. package/dist/render/sanitize.d.ts +0 -8
  122. package/dist/render/sanitize.d.ts.map +0 -1
  123. package/dist/render/sanitize.js +0 -26
  124. package/src/lib/render/sanitize.ts +0 -27
@@ -30,33 +30,32 @@ function normalizePath(path: string): string {
30
30
  return path.length > 1 ? path.replace(/\/+$/, '') : path;
31
31
  }
32
32
 
33
- /** Validate every entry (drafts included) against its concept, aggregating failures. */
34
- function validateAll(concepts: ConceptIndex[]): void {
33
+ /** Collect non-draft validation failures across concepts from each index's recorded verdicts. */
34
+ function siteProblems(concepts: ConceptIndex[]): string[] {
35
35
  const problems: string[] = [];
36
36
  for (const { descriptor, index } of concepts) {
37
- for (const summary of index.all({ includeDrafts: true })) {
38
- const entry = index.byId(summary.id);
39
- if (!entry) continue;
40
- const result = descriptor.validate(entry.frontmatter, entry.body);
41
- if (!result.ok) {
42
- for (const [field, message] of Object.entries(result.errors)) {
43
- problems.push(`${descriptor.dir}/${summary.id}: ${field}: ${message}`);
44
- }
37
+ for (const problem of index.problems()) {
38
+ if (problem.draft) continue; // a half-finished draft never ships, so it does not fail the build
39
+ for (const [field, message] of Object.entries(problem.errors)) {
40
+ problems.push(`${descriptor.dir}/${problem.id}: ${field}: ${message}`);
45
41
  }
46
42
  }
47
43
  }
48
- if (problems.length > 0) {
49
- throw new Error(`site index: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
50
- }
44
+ return problems;
51
45
  }
52
46
 
53
47
  /**
54
48
  * Union per-concept indexes into a site-level resolver. Throws on a duplicate permalink and,
55
- * unless `validate` is `false`, on any entry whose frontmatter fails its concept's validator,
56
- * so malformed content fails the build instead of shipping.
49
+ * unless `validate` is `false`, on any non-draft entry whose frontmatter fails its concept's
50
+ * validator, so malformed content fails the build instead of shipping.
57
51
  */
58
52
  export function createSiteIndex(concepts: ConceptIndex[], opts: { validate?: boolean } = {}): SiteIndex {
59
- if (opts.validate !== false) validateAll(concepts);
53
+ if (opts.validate !== false) {
54
+ const problems = siteProblems(concepts);
55
+ if (problems.length > 0) {
56
+ throw new Error(`site index: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
57
+ }
58
+ }
60
59
  const byPath = new Map<string, { index: ContentIndex; id: string }>();
61
60
  const byId = new Map<string, ContentIndex>();
62
61
  for (const { descriptor, index } of concepts) {
@@ -0,0 +1,64 @@
1
+ // cairn-cms: the full-auto typed site index (schema-source-of-truth design). It maps over a
2
+ // defineAdapter-typed adapter to give one typed per-concept index, with frontmatter typed as the
3
+ // concept's inferred schema type, plus a site resolver for the catch-all route. It is the typed
4
+ // convenience over createContentIndex and createSiteIndex, not a replacement: both stay the
5
+ // lower-level escape hatch. It imports only pure content and delivery code, so the delivery
6
+ // bundle stays backend-free.
7
+ import type { CairnAdapter, ConceptConfig } from '../content/types.js';
8
+ import type { Infer } from '../content/schema.js';
9
+ import type { SiteConfig } from '../nav/site-config.js';
10
+ import { siteDescriptors } from './site-descriptors.js';
11
+ import { createContentIndex, fromGlob } from './content-index.js';
12
+ import { createSiteIndex } from './site-index.js';
13
+ import type { ContentIndex } from './content-index.js';
14
+ import type { ConceptIndex, SiteIndex } from './site-index.js';
15
+
16
+ /** A per-concept raw glob record (`{ path: raw }`) keyed by concept id, from `import.meta.glob`. */
17
+ export type SiteGlobs<A extends CairnAdapter> = {
18
+ [K in keyof A['content']]?: Record<string, string>;
19
+ };
20
+
21
+ /** The typed per-concept indexes plus the cross-concept `site` resolver. A concept literally named
22
+ * `site` is not supported, since `site` is the reserved resolver key. */
23
+ export type SiteIndexes<A extends CairnAdapter> = {
24
+ [K in keyof A['content']]: ContentIndex<
25
+ NonNullable<A['content'][K]> extends ConceptConfig<infer S> ? Infer<S> : Record<string, unknown>
26
+ >;
27
+ } & { readonly site: SiteIndex };
28
+
29
+ /**
30
+ * Build typed per-concept indexes and a site resolver from one adapter. Pass the per-concept raw
31
+ * globs as `{ posts: import.meta.glob('...?raw', { eager: true }), ... }`; Vite needs the literal
32
+ * glob at the call site, so the engine cannot glob on the site's behalf. `validate: false` opts out
33
+ * of the build gate, exactly as on `createSiteIndex`.
34
+ */
35
+ export function createSiteIndexes<const A extends CairnAdapter>(
36
+ adapter: A,
37
+ config: SiteConfig,
38
+ globs: SiteGlobs<A>,
39
+ opts: { validate?: boolean } = {},
40
+ ): SiteIndexes<A> {
41
+ const descriptors = siteDescriptors(adapter, config);
42
+ const globRecord = globs as Record<string, Record<string, string> | undefined>;
43
+ const byConcept: Record<string, ContentIndex> = {};
44
+ const conceptIndexes: ConceptIndex[] = [];
45
+ for (const descriptor of descriptors) {
46
+ if (descriptor.id === 'site') {
47
+ throw new Error(
48
+ 'createSiteIndexes: a concept cannot be named "site", which is the reserved cross-concept resolver key',
49
+ );
50
+ }
51
+ if (!Object.prototype.hasOwnProperty.call(globRecord, descriptor.id)) {
52
+ const passed = Object.keys(globRecord);
53
+ throw new Error(
54
+ `createSiteIndexes: no glob passed for concept "${descriptor.id}"; pass its import.meta.glob (an empty {} for an intentionally empty concept). Globs passed: ${passed.length ? passed.join(', ') : '(none)'}`,
55
+ );
56
+ }
57
+ const record = globRecord[descriptor.id] ?? {};
58
+ const index = createContentIndex(fromGlob(record), descriptor);
59
+ byConcept[descriptor.id] = index;
60
+ conceptIndexes.push({ descriptor, index });
61
+ }
62
+ const site = createSiteIndex(conceptIndexes, opts);
63
+ return { ...byConcept, site } as SiteIndexes<A>;
64
+ }
package/src/lib/env.ts CHANGED
@@ -13,6 +13,19 @@ export function requireOrigin(env: { PUBLIC_ORIGIN?: string }): string {
13
13
  if (!origin) {
14
14
  throw new Error('PUBLIC_ORIGIN is not configured');
15
15
  }
16
+ let hostname: string;
17
+ try {
18
+ hostname = new URL(origin).hostname;
19
+ } catch {
20
+ throw new Error(`PUBLIC_ORIGIN is not a valid URL, got ${origin}`);
21
+ }
22
+ // The magic-link origin must be https in production so the link and the __Host- cookie are
23
+ // origin-bound. http is allowed only for local dev on localhost or 127.0.0.1, matched exactly so
24
+ // a lookalike host like localhost.example.com cannot skip the https requirement.
25
+ const isLocal = hostname === 'localhost' || hostname === '127.0.0.1';
26
+ if (!origin.startsWith('https://') && !isLocal) {
27
+ throw new Error(`PUBLIC_ORIGIN must be https in production, got ${origin}`);
28
+ }
16
29
  return origin;
17
30
  }
18
31
 
@@ -79,6 +79,38 @@ export async function installationToken(creds: AppCredentials): Promise<string>
79
79
  return ((await res.json()) as { token: string }).token;
80
80
  }
81
81
 
82
+ interface CachedToken {
83
+ token: string;
84
+ expiresAt: number;
85
+ }
86
+
87
+ /**
88
+ * Build an installation-token cache. A module-global instance memoizes the minted token per
89
+ * installation for most of its one-hour life, so a warm Worker isolate reuses it across requests
90
+ * instead of re-signing and re-calling GitHub on every list and commit. A cold isolate re-mints,
91
+ * which is always safe. This mirrors the default of @octokit/auth-app, which caches installation
92
+ * tokens in memory and returns them until expiry. The TTL stays under GitHub's documented one-hour
93
+ * lifetime, so a fixed margin avoids parsing the API expiry. `mint` and `now` are injected so the
94
+ * cache is testable with no network call and no real clock.
95
+ */
96
+ export function createInstallationTokenCache(
97
+ mint: (creds: AppCredentials) => Promise<string> = installationToken,
98
+ now: () => number = () => Date.now(),
99
+ ttlMs = 55 * 60 * 1000,
100
+ ): (creds: AppCredentials) => Promise<string> {
101
+ const cache = new Map<string, CachedToken>();
102
+ return async function get(creds: AppCredentials): Promise<string> {
103
+ const hit = cache.get(creds.installationId);
104
+ if (hit && hit.expiresAt > now()) return hit.token;
105
+ const token = await mint(creds);
106
+ cache.set(creds.installationId, { token, expiresAt: now() + ttlMs });
107
+ return token;
108
+ };
109
+ }
110
+
111
+ /** The shared installation-token cache, one instance per Worker isolate. */
112
+ export const cachedInstallationToken = createInstallationTokenCache();
113
+
82
114
  /**
83
115
  * Deploy-time self-test for the App signer: sign a dummy JWT with the configured key. It
84
116
  * exercises the brittle PKCS#1-to-PKCS#8 conversion and the Web Crypto import and sign with
package/src/lib/index.ts CHANGED
@@ -37,7 +37,9 @@ export {
37
37
  serializeMarkdown,
38
38
  parseMarkdown,
39
39
  } from './content/frontmatter.js';
40
- export { validateFields } from './content/validate.js';
40
+ export { defineFields } from './content/schema.js';
41
+ export { defineAdapter } from './content/adapter.js';
42
+ export type { ConceptSchema, Infer, InferFields, DefineFieldsOptions, StandardInput, StandardSchemaV1 } from './content/schema.js';
41
43
  export {
42
44
  isValidId,
43
45
  idFromFilename,
@@ -72,7 +74,6 @@ export {
72
74
  isElement,
73
75
  strProp,
74
76
  iconSpan,
75
- splitHead,
76
77
  cardShell,
77
78
  markFirstList,
78
79
  } from './render/rehype-dispatch.js';
@@ -119,9 +120,12 @@ export type {
119
120
  ContentSummary,
120
121
  ContentEntry,
121
122
  ContentIndex,
123
+ ContentProblem,
122
124
  } from './delivery/content-index.js';
123
125
  export { createSiteIndex } from './delivery/site-index.js';
124
126
  export type { SiteIndex, ConceptIndex } from './delivery/site-index.js';
127
+ export { createSiteIndexes } from './delivery/site-indexes.js';
128
+ export type { SiteIndexes, SiteGlobs } from './delivery/site-indexes.js';
125
129
  export { deriveExcerpt, wordCount } from './delivery/excerpt.js';
126
130
  export { buildRssFeed, buildJsonFeed } from './delivery/feeds.js';
127
131
  export type { FeedChannel, FeedItem } from './delivery/feeds.js';
@@ -130,5 +134,7 @@ export type { SitemapUrl } from './delivery/sitemap.js';
130
134
  export { buildRobots } from './delivery/robots.js';
131
135
  export { buildSeoMeta } from './delivery/seo.js';
132
136
  export type { SeoInput, SeoMeta } from './delivery/seo.js';
137
+ export { readSeoFields, resolveImageUrl } from './delivery/seo-fields.js';
138
+ export type { SeoFields } from './delivery/seo-fields.js';
133
139
  export { paginate } from './delivery/paginate.js';
134
140
  export type { Page } from './delivery/paginate.js';
@@ -84,14 +84,19 @@ function childrenToText(children: RootContent[]): string {
84
84
  return String(toMd.stringify(root)).trim();
85
85
  }
86
86
 
87
- /** Parse a serialized component directive back into guided-form values, the inverse of
88
- * {@link serializeComponent}. The grammar is reversible, so the editor can round-trip a
89
- * saved directive through the form. */
90
- export async function parseComponent(markdown: string, def: ComponentDef): Promise<ComponentValues> {
87
+ // Parse the markdown and find the component's opening container directive. The single seam both
88
+ // parseComponent and parseRawAttributeKeys (and the combined validator helper) build on, so one
89
+ // parse derives both the form values and the raw attribute keys.
90
+ function findComponentRoot(markdown: string, def: ComponentDef): (RootContent & DirectiveNode) | undefined {
91
91
  const tree = unified().use(remarkParse).use(remarkDirective).parse(markdown) as Root;
92
- const root = tree.children.find(
92
+ return tree.children.find(
93
93
  (c): c is RootContent & DirectiveNode => isContainer(c) && (c as DirectiveNode).name === def.name,
94
94
  );
95
+ }
96
+
97
+ // Build guided-form values from an already-found component root. Returns the empty base when the
98
+ // root is absent.
99
+ function valuesFromRoot(root: (RootContent & DirectiveNode) | undefined, def: ComponentDef): ComponentValues {
95
100
  const values = emptyComponentValues(def);
96
101
  if (!root) return values;
97
102
 
@@ -126,14 +131,33 @@ export async function parseComponent(markdown: string, def: ComponentDef): Promi
126
131
  return values;
127
132
  }
128
133
 
134
+ // The raw attribute keys on an already-found component root.
135
+ function rawKeysFromRoot(root: (RootContent & DirectiveNode) | undefined): string[] {
136
+ return Object.keys(root?.attributes ?? {});
137
+ }
138
+
139
+ /** Parse a serialized component directive back into guided-form values, the inverse of
140
+ * {@link serializeComponent}. The grammar is reversible, so the editor can round-trip a
141
+ * saved directive through the form. */
142
+ export async function parseComponent(markdown: string, def: ComponentDef): Promise<ComponentValues> {
143
+ return valuesFromRoot(findComponentRoot(markdown, def), def);
144
+ }
145
+
129
146
  /** The raw attribute keys present on the component's opening directive, read from the parsed tree
130
147
  * (quote-aware, unlike a regex over the source). Used by validation to flag unknown keys. */
131
148
  export function parseRawAttributeKeys(markdown: string, def: ComponentDef): string[] {
132
- const tree = unified().use(remarkParse).use(remarkDirective).parse(markdown) as Root;
133
- const root = tree.children.find(
134
- (c): c is RootContent & DirectiveNode => isContainer(c) && (c as DirectiveNode).name === def.name,
135
- );
136
- return Object.keys(root?.attributes ?? {});
149
+ return rawKeysFromRoot(findComponentRoot(markdown, def));
150
+ }
151
+
152
+ /** Parse the component once and derive both the guided-form values and the raw attribute keys.
153
+ * Validation needs both, so this seam spares it the double parse that calling
154
+ * {@link parseComponent} and {@link parseRawAttributeKeys} separately would cost. */
155
+ export async function parseComponentWithRawKeys(
156
+ markdown: string,
157
+ def: ComponentDef,
158
+ ): Promise<{ values: ComponentValues; rawKeys: string[] }> {
159
+ const root = findComponentRoot(markdown, def);
160
+ return { values: valuesFromRoot(root, def), rawKeys: rawKeysFromRoot(root) };
137
161
  }
138
162
 
139
163
  // A bare parse base: empty strings, false, and empty lists, with no attribute defaults applied. The
@@ -1,11 +1,11 @@
1
- import { parseComponent, parseRawAttributeKeys } from './component-grammar.js';
1
+ import { parseComponentWithRawKeys } from './component-grammar.js';
2
2
  import type { ComponentDef } from './registry.js';
3
3
 
4
4
  /** A validation verdict: ok, or field-keyed error messages. */
5
5
  export type ComponentValidation = { ok: true } | { ok: false; errors: Record<string, string> };
6
6
 
7
7
  export async function validateComponent(markdown: string, def: ComponentDef): Promise<ComponentValidation> {
8
- const values = await parseComponent(markdown, def);
8
+ const { values, rawKeys } = await parseComponentWithRawKeys(markdown, def);
9
9
  const errors: Record<string, string> = {};
10
10
  const declared = new Set((def.attributes ?? []).map((f) => f.key));
11
11
 
@@ -21,7 +21,7 @@ export async function validateComponent(markdown: string, def: ComponentDef): Pr
21
21
  }
22
22
  }
23
23
 
24
- for (const key of parseRawAttributeKeys(markdown, def)) {
24
+ for (const key of rawKeys) {
25
25
  if (!declared.has(key)) errors[key] = `Unknown attribute "${key}".`;
26
26
  }
27
27
 
@@ -4,11 +4,15 @@ import type { Element } from 'hast';
4
4
  /** A glyph name to SVG path-data map (the site owns the icon set). */
5
5
  export type IconSet = Record<string, string>;
6
6
 
7
- /** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill. */
7
+ /** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill.
8
+ * An unknown icon name yields the bare svg shell with no path child, so it never serializes
9
+ * a stray empty (or undefined) path. Callers always wrap the returned element, so the shell
10
+ * keeps them safe. */
8
11
  export function glyph(name: string, icons: IconSet): Element {
12
+ const d = icons[name];
9
13
  return s(
10
14
  'svg',
11
15
  { className: ['ec-glyph'], viewBox: '0 0 256 256', fill: 'currentColor', ariaHidden: 'true' },
12
- [s('path', { d: icons[name] })],
16
+ d == null ? [] : [s('path', { d })],
13
17
  );
14
18
  }
@@ -6,6 +6,9 @@ import remarkRehype from 'remark-rehype';
6
6
  import rehypeRaw from 'rehype-raw';
7
7
  import rehypeSlug from 'rehype-slug';
8
8
  import rehypeStringify from 'rehype-stringify';
9
+ import rehypeSanitize from 'rehype-sanitize';
10
+ import type { Schema } from 'hast-util-sanitize';
11
+ import { buildSanitizeSchema, rehypeAnchorRel } from './sanitize-schema.js';
9
12
  import { remarkDirectiveStamp } from './remark-directives.js';
10
13
  import { rehypeDispatch } from './rehype-dispatch.js';
11
14
  import type { ComponentRegistry } from './registry.js';
@@ -15,6 +18,15 @@ export interface RendererOptions {
15
18
  * CSS can drive an entrance-cascade delay off it. Omit for no stagger. The ordinal
16
19
  * is inert, so a consumer's sanitize floor can keep `data-rise` and drop `style`. */
17
20
  stagger?: boolean;
21
+ /** Extend the sanitize allowlist. Receives cairn's default schema (defaultSchema plus the
22
+ * directive markers and the common benign tags) and returns the schema to use. Add to the
23
+ * allowlist for the benign HTML a site's content needs; start from the argument so the
24
+ * dangerous strip is preserved. */
25
+ sanitizeSchema?: (defaults: Schema) => Schema;
26
+ /** Developer-only escape hatch: disable the sanitize floor entirely. This reintroduces the XSS
27
+ * vector the floor closes, so it is only for a site whose content is fully developer-controlled.
28
+ * It is a code-level adapter decision, never an editor-facing setting. */
29
+ unsafeDisableSanitize?: boolean;
18
30
  }
19
31
 
20
32
  /** Compose a site's render pipeline from its component registry: directive syntax to
@@ -22,7 +34,19 @@ export interface RendererOptions {
22
34
  * rehype plugin arrays (so the admin editor preview can reuse the exact same set). */
23
35
  export function createRenderer(registry: ComponentRegistry, options: RendererOptions = {}) {
24
36
  const remarkPlugins: PluggableList = [remarkDirective, [remarkDirectiveStamp, registry]];
25
- const rehypePlugins: PluggableList = [rehypeRaw, [rehypeDispatch, registry, options.stagger], rehypeSlug];
37
+ // The sanitize floor runs after rehype-raw (so author raw HTML is parsed, then cleaned) and
38
+ // before the dispatch (so the site's trusted build() output and its inline SVG icons are never
39
+ // sanitized). The anchor-rel hardening runs last so it also covers component-built anchors.
40
+ const floor: PluggableList = options.unsafeDisableSanitize
41
+ ? []
42
+ : [[rehypeSanitize, buildSanitizeSchema(registry, options.sanitizeSchema)]];
43
+ const rehypePlugins: PluggableList = [
44
+ rehypeRaw,
45
+ ...floor,
46
+ [rehypeDispatch, registry, options.stagger],
47
+ rehypeSlug,
48
+ rehypeAnchorRel,
49
+ ];
26
50
  const processor = unified()
27
51
  .use(remarkParse)
28
52
  .use(remarkGfm)
@@ -3,7 +3,7 @@
3
3
  // (Plan 04) and the future component palette both derive from this single source, so the
4
4
  // parser, the render dispatch, and the editor never drift apart. The adapter references
5
5
  // `ComponentRegistry` from here.
6
- import type { Element } from 'hast';
6
+ import type { Element, ElementContent } from 'hast';
7
7
 
8
8
  /** The input types a component attribute or repeatable item field can take. */
9
9
  export type FieldType = 'text' | 'select' | 'icon' | 'boolean';
@@ -38,6 +38,21 @@ export interface SlotDef {
38
38
  itemFields?: AttributeField[];
39
39
  }
40
40
 
41
+ /** The structured input a component's `build` receives. The engine stamps the component's
42
+ * attributes and partitions its slots from the rendered hast, so `build` arranges hast and
43
+ * never walks the tree. `slot(name)` returns a slot's rendered children (title, body, or any
44
+ * named slot); `items(name)` returns a repeatable slot's items, one child list per item. */
45
+ export interface ComponentContext {
46
+ /** Declared attribute values, keyed by attribute key. Booleans are real booleans. */
47
+ attributes: Record<string, string | boolean>;
48
+ /** A named slot's rendered children. Returns `[]` for an absent or empty slot. */
49
+ slot(name: string): ElementContent[];
50
+ /** A repeatable slot's items, each item its own list of rendered children. `[]` when absent. */
51
+ items(name: string): ElementContent[][];
52
+ /** The stamped component element, for an escape hatch. Most builds never need it. */
53
+ node: Element;
54
+ }
55
+
41
56
  /** A site component: how it inserts (editor) and how it renders (rehype). */
42
57
  export interface ComponentDef {
43
58
  /** Directive name, e.g. 'card' (matches `:::card`). */
@@ -48,10 +63,10 @@ export interface ComponentDef {
48
63
  description: string;
49
64
  /** Markdown scaffold inserted at the cursor by the editor palette. */
50
65
  insertTemplate?: string;
51
- /** Build the final hast element from the stamped directive element. The engine
52
- * stamps the entrance-stagger ordinal (`data-rise`) on the top-level result, so a
53
- * build fn stays free of any motion concern. */
54
- build: (node: Element) => Element;
66
+ /** Build the final hast element from the component context (attributes plus partitioned
67
+ * slots). The engine stamps the entrance-stagger ordinal (`data-rise`) on the top-level
68
+ * result, so a build fn stays free of any motion concern. */
69
+ build: (ctx: ComponentContext) => Element;
55
70
  /** Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. */
56
71
  defaultIconByRole?: Record<string, string>;
57
72
  /** One line on when to reach for this component; feeds the picker and the reference file. */
@@ -69,6 +84,13 @@ export interface ComponentRegistry {
69
84
  defaultIcon(name: string, role?: string): string | undefined;
70
85
  }
71
86
 
87
+ /** The hast property name carrying one declared attribute from stamp to dispatch, e.g. `tone`
88
+ * becomes `dataAttrTone`. The directive stamp writes it and the rehype dispatch reads it, so both
89
+ * sides derive the name from this one helper rather than spelling the capitalize twice. */
90
+ export function dataAttrProp(key: string): string {
91
+ return `dataAttr${key.charAt(0).toUpperCase()}${key.slice(1)}`;
92
+ }
93
+
72
94
  /**
73
95
  * Build a registry from a site's component definitions. The single source the render
74
96
  * pipeline (directive stamp plus rehype dispatch) and the editor palette both read.
@@ -1,6 +1,6 @@
1
1
  import type { Root, Element, ElementContent } from 'hast';
2
2
  import { h } from 'hastscript';
3
- import type { ComponentRegistry } from './registry.js';
3
+ import { dataAttrProp, type ComponentContext, type ComponentDef, type ComponentRegistry } from './registry.js';
4
4
 
5
5
  export function isElement(node: ElementContent | undefined): node is Element {
6
6
  return !!node && node.type === 'element';
@@ -23,24 +23,6 @@ export function iconSpan(glyphEl: Element, role?: string): Element {
23
23
  /** A site's icon factory: turn a stamped icon name + role into a hast element. */
24
24
  export type MakeIcon = (name: string, role?: string) => Element;
25
25
 
26
- // Pull the section's <h2> out, retag it .card-title, and build the .ec-head row
27
- // (optional icon + heading). Returns the head plus the remaining body children.
28
- // `makeIcon` (site-supplied) turns the stamped data-icon into an element; omit it
29
- // for a head with no icon.
30
- export function splitHead(node: Element, makeIcon?: MakeIcon): { head: Element; rest: ElementContent[] } {
31
- const children = node.children as ElementContent[];
32
- const i = children.findIndex((c) => isElement(c) && c.tagName === 'h2');
33
- const h2 = children[i] as Element;
34
- h2.properties = { ...h2.properties, className: ['card-title'] };
35
- const rest = children.filter((_, j) => j !== i);
36
- const icon = strProp(node, 'dataIcon');
37
- const role = strProp(node, 'dataRole');
38
- const headKids: ElementContent[] = [];
39
- if (makeIcon && icon) headKids.push(makeIcon(icon, role));
40
- headKids.push(h2);
41
- return { head: h('div', { className: ['ec-head'] }, headKids), rest };
42
- }
43
-
44
26
  /** Section wrapper: `<section class=…><div class="card-body">…</div></section>`. */
45
27
  export function cardShell(classes: string[], body: ElementContent[]): Element {
46
28
  return h('section', { className: classes }, [h('div', { className: ['card-body'] }, body)]);
@@ -70,11 +52,76 @@ function transformChildren(children: ElementContent[], registry: ComponentRegist
70
52
  });
71
53
  }
72
54
 
55
+ // Read a stamped attribute back into its typed value. Booleans arrive as the strings
56
+ // 'true'/'false'; everything else is the literal string the author wrote.
57
+ function readAttributes(node: Element, def: ComponentDef): Record<string, string | boolean> {
58
+ const out: Record<string, string | boolean> = {};
59
+ for (const field of def.attributes ?? []) {
60
+ const value = strProp(node, dataAttrProp(field.key));
61
+ if (value == null) continue;
62
+ out[field.key] = field.type === 'boolean' ? value === 'true' : value;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ // The title label paragraph carries data-slot="title"; build() wants its inline children, not
68
+ // the marked paragraph. Return the paragraph's children.
69
+ function stripSlotMarker(child: ElementContent): ElementContent[] {
70
+ return isElement(child) ? (child.children as ElementContent[]) : [child];
71
+ }
72
+
73
+ // Split a component's stamped children into named slots and the default body. A child marked
74
+ // data-slot="title"/<name> routes to that slot; an unmarked child is body. A repeatable slot
75
+ // wraps a <ul>, so its items are that list's <li> children, one child-list per item.
76
+ function partitionSlots(node: Element): {
77
+ slot(name: string): ElementContent[];
78
+ items(name: string): ElementContent[][];
79
+ } {
80
+ const named = new Map<string, ElementContent[]>();
81
+ const body: ElementContent[] = [];
82
+ for (const child of node.children as ElementContent[]) {
83
+ const slotName = isElement(child) ? strProp(child, 'dataSlot') : undefined;
84
+ if (slotName === 'title') named.set('title', stripSlotMarker(child));
85
+ else if (slotName) named.set(slotName, [child]);
86
+ else body.push(child);
87
+ }
88
+ return {
89
+ slot(name: string): ElementContent[] {
90
+ if (name === 'body') return body;
91
+ const wrap = named.get(name);
92
+ if (!wrap) return [];
93
+ // For title we stored the label's own children, so return them as-is. For a markdown or
94
+ // inline named slot the wrapper <div> holds the rendered children; unwrap it.
95
+ if (name === 'title') return wrap;
96
+ const div = wrap[0];
97
+ return isElement(div) ? (div.children as ElementContent[]) : wrap;
98
+ },
99
+ items(name: string): ElementContent[][] {
100
+ const wrap = named.get(name);
101
+ const div = wrap?.[0];
102
+ if (!div || !isElement(div)) return [];
103
+ const ul = (div.children as ElementContent[]).find((c) => isElement(c) && c.tagName === 'ul');
104
+ if (!ul || !isElement(ul)) return [];
105
+ return (ul.children as ElementContent[])
106
+ .filter((li) => isElement(li) && li.tagName === 'li')
107
+ .map((li) => (li as Element).children as ElementContent[]);
108
+ },
109
+ };
110
+ }
111
+
73
112
  function transformNode(node: Element, registry: ComponentRegistry): Element {
74
113
  node.children = transformChildren(node.children as ElementContent[], registry);
75
114
  const name = strProp(node, 'dataPrimitive');
76
115
  const def = name ? registry.get(name) : undefined;
77
- return def ? def.build(node) : node;
116
+ if (!def) return node;
117
+ const parts = partitionSlots(node);
118
+ const ctx: ComponentContext = {
119
+ attributes: readAttributes(node, def),
120
+ slot: parts.slot,
121
+ items: parts.items,
122
+ node,
123
+ };
124
+ return def.build(ctx);
78
125
  }
79
126
 
80
127
  /** Rehype transformer: dispatch each stamped element through its registry `build`
@@ -1,7 +1,22 @@
1
1
  import type { Paragraph, PhrasingContent, Root, Text } from 'mdast';
2
2
  import type { ContainerDirective, LeafDirective, TextDirective } from 'mdast-util-directive';
3
3
  import { visit } from 'unist-util-visit';
4
- import type { ComponentRegistry } from './registry.js';
4
+ import { dataAttrProp, type ComponentRegistry } from './registry.js';
5
+
6
+ // mdast-util-directive carries the `[label]` as a paragraph whose `data.directiveLabel` is set.
7
+ function isDirectiveLabel(node: unknown): boolean {
8
+ return Boolean((node as { data?: { directiveLabel?: boolean } }).data?.directiveLabel);
9
+ }
10
+
11
+ // Stamp data-slot on a child so the rehype dispatch partitioner can route it. For a nested
12
+ // container directive we also set hName so it renders as a <div> wrapper rather than being
13
+ // dropped as an unknown directive.
14
+ function markSlot(node: unknown, name: string): void {
15
+ const n = node as { type?: string; data?: { hName?: string; hProperties?: Record<string, string> } };
16
+ const data = n.data ?? (n.data = {});
17
+ if (n.type === 'containerDirective') data.hName = 'div';
18
+ data.hProperties = { ...(data.hProperties ?? {}), dataSlot: name };
19
+ }
5
20
 
6
21
  // Reconstruct a directive's authored attribute block (`{#id .class key="value"}`).
7
22
  // Accidental prose directives carry none, so this is almost always empty.
@@ -41,6 +56,7 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
41
56
  return (tree: Root) => {
42
57
  visit(tree, 'containerDirective', (node: ContainerDirective) => {
43
58
  if (!known.has(node.name)) return;
59
+ const def = registry.get(node.name);
44
60
  const attrs = node.attributes ?? {};
45
61
  const role = attrs.role || undefined;
46
62
  let icon = attrs.icon || undefined;
@@ -49,10 +65,32 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
49
65
  const properties: Record<string, string> = { dataPrimitive: node.name };
50
66
  if (icon) properties.dataIcon = icon;
51
67
  if (role) properties.dataRole = role;
68
+ // Carry every declared attribute to hast so the dispatch partitioner can build the
69
+ // component context. data-attr-<key> survives to the element; build() consumes it and
70
+ // returns a fresh element, so the marker never reaches the published DOM.
71
+ for (const field of def?.attributes ?? []) {
72
+ const raw = attrs[field.key];
73
+ if (raw != null) properties[dataAttrProp(field.key)] = raw;
74
+ }
52
75
 
53
76
  const data = node.data ?? (node.data = {});
54
77
  data.hName = 'div';
55
78
  data.hProperties = properties;
79
+
80
+ // Mark the title label paragraph and the nested slot directives so they survive to hast
81
+ // and the partitioner can find them. A slot named in the component schema (other than the
82
+ // default body) is a nested container directive; the title is the directive [label].
83
+ const slotNames = new Set((def?.slots ?? []).map((s) => s.name));
84
+ for (const child of node.children) {
85
+ if (isDirectiveLabel(child) && slotNames.has('title')) {
86
+ markSlot(child, 'title');
87
+ } else if (
88
+ (child as { type?: string }).type === 'containerDirective' &&
89
+ slotNames.has((child as { name: string }).name)
90
+ ) {
91
+ markSlot(child, (child as { name: string }).name);
92
+ }
93
+ }
56
94
  });
57
95
 
58
96
  visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {