@glw907/cairn-cms 0.5.0 → 0.6.0-rc.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 (216) hide show
  1. package/dist/auth/crypto.d.ts +13 -0
  2. package/dist/auth/crypto.d.ts.map +1 -0
  3. package/dist/auth/crypto.js +31 -0
  4. package/dist/auth/store.d.ts +41 -0
  5. package/dist/auth/store.d.ts.map +1 -0
  6. package/dist/auth/store.js +115 -0
  7. package/dist/auth/types.d.ts +25 -0
  8. package/dist/auth/types.d.ts.map +1 -0
  9. package/dist/auth/types.js +1 -0
  10. package/dist/components/AdminLayout.svelte +58 -108
  11. package/dist/components/AdminLayout.svelte.d.ts +14 -9
  12. package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
  13. package/dist/components/ComponentPalette.svelte +50 -0
  14. package/dist/components/ComponentPalette.svelte.d.ts +16 -0
  15. package/dist/components/ComponentPalette.svelte.d.ts.map +1 -0
  16. package/dist/components/ConceptList.svelte +81 -0
  17. package/dist/components/ConceptList.svelte.d.ts +13 -0
  18. package/dist/components/ConceptList.svelte.d.ts.map +1 -0
  19. package/dist/components/ConfirmPage.svelte +23 -20
  20. package/dist/components/ConfirmPage.svelte.d.ts +6 -0
  21. package/dist/components/ConfirmPage.svelte.d.ts.map +1 -1
  22. package/dist/components/EditPage.svelte +160 -103
  23. package/dist/components/EditPage.svelte.d.ts +17 -7
  24. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  25. package/dist/components/LoginPage.svelte +42 -52
  26. package/dist/components/LoginPage.svelte.d.ts +12 -0
  27. package/dist/components/LoginPage.svelte.d.ts.map +1 -1
  28. package/dist/components/ManageEditors.svelte +81 -0
  29. package/dist/components/ManageEditors.svelte.d.ts +24 -0
  30. package/dist/components/ManageEditors.svelte.d.ts.map +1 -0
  31. package/dist/components/MarkdownEditor.svelte +81 -0
  32. package/dist/components/MarkdownEditor.svelte.d.ts +20 -0
  33. package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -0
  34. package/dist/components/NavTree.svelte +138 -0
  35. package/dist/components/NavTree.svelte.d.ts +17 -0
  36. package/dist/components/NavTree.svelte.d.ts.map +1 -0
  37. package/dist/components/cairn-admin.css +42 -0
  38. package/dist/components/index.d.ts +5 -2
  39. package/dist/components/index.d.ts.map +1 -1
  40. package/dist/components/index.js +7 -4
  41. package/dist/content/compose.d.ts +7 -0
  42. package/dist/content/compose.d.ts.map +1 -0
  43. package/dist/content/compose.js +32 -0
  44. package/dist/content/concepts.d.ts +17 -0
  45. package/dist/content/concepts.d.ts.map +1 -0
  46. package/dist/content/concepts.js +41 -0
  47. package/dist/content/frontmatter.d.ts +18 -0
  48. package/dist/content/frontmatter.d.ts.map +1 -0
  49. package/dist/content/frontmatter.js +58 -0
  50. package/dist/content/ids.d.ts +17 -0
  51. package/dist/content/ids.d.ts.map +1 -0
  52. package/dist/content/ids.js +33 -0
  53. package/dist/content/types.d.ts +210 -0
  54. package/dist/content/types.d.ts.map +1 -0
  55. package/dist/content/types.js +1 -0
  56. package/dist/content/validate.d.ts +13 -0
  57. package/dist/content/validate.d.ts.map +1 -0
  58. package/dist/content/validate.js +45 -0
  59. package/dist/email.d.ts +25 -12
  60. package/dist/email.d.ts.map +1 -1
  61. package/dist/email.js +24 -24
  62. package/dist/env.d.ts +24 -0
  63. package/dist/env.d.ts.map +1 -0
  64. package/dist/env.js +29 -0
  65. package/dist/github/credentials.d.ts +12 -0
  66. package/dist/github/credentials.d.ts.map +1 -0
  67. package/dist/github/credentials.js +11 -0
  68. package/dist/github/repo.d.ts +49 -0
  69. package/dist/github/repo.d.ts.map +1 -0
  70. package/dist/github/repo.js +123 -0
  71. package/dist/github/signing.d.ts +17 -0
  72. package/dist/github/signing.d.ts.map +1 -0
  73. package/dist/github/signing.js +79 -0
  74. package/dist/github/types.d.ts +35 -0
  75. package/dist/github/types.d.ts.map +1 -0
  76. package/dist/github/types.js +19 -0
  77. package/dist/index.d.ts +27 -6
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +21 -8
  80. package/dist/nav/site-config.d.ts +50 -0
  81. package/dist/nav/site-config.d.ts.map +1 -0
  82. package/dist/nav/site-config.js +100 -0
  83. package/dist/render/glyph.d.ts +1 -1
  84. package/dist/render/glyph.d.ts.map +1 -1
  85. package/dist/render/index.d.ts +5 -5
  86. package/dist/render/index.d.ts.map +1 -1
  87. package/dist/render/index.js +6 -6
  88. package/dist/render/pipeline.d.ts +3 -3
  89. package/dist/render/pipeline.d.ts.map +1 -1
  90. package/dist/render/pipeline.js +4 -4
  91. package/dist/render/registry.d.ts +6 -4
  92. package/dist/render/registry.d.ts.map +1 -1
  93. package/dist/render/registry.js +8 -6
  94. package/dist/render/rehype-dispatch.d.ts +1 -1
  95. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  96. package/dist/render/remark-directives.d.ts +1 -1
  97. package/dist/render/remark-directives.d.ts.map +1 -1
  98. package/dist/render/sanitize.d.ts +8 -0
  99. package/dist/render/sanitize.d.ts.map +1 -0
  100. package/dist/render/sanitize.js +26 -0
  101. package/dist/sveltekit/auth-routes.d.ts +23 -0
  102. package/dist/sveltekit/auth-routes.d.ts.map +1 -0
  103. package/dist/sveltekit/auth-routes.js +85 -0
  104. package/dist/sveltekit/content-routes.d.ts +80 -0
  105. package/dist/sveltekit/content-routes.d.ts.map +1 -0
  106. package/dist/sveltekit/content-routes.js +183 -0
  107. package/dist/sveltekit/editors-routes.d.ts +24 -0
  108. package/dist/sveltekit/editors-routes.d.ts.map +1 -0
  109. package/dist/sveltekit/editors-routes.js +73 -0
  110. package/dist/sveltekit/guard.d.ts +9 -0
  111. package/dist/sveltekit/guard.d.ts.map +1 -0
  112. package/dist/sveltekit/guard.js +43 -0
  113. package/dist/sveltekit/health.d.ts +19 -0
  114. package/dist/sveltekit/health.d.ts.map +1 -0
  115. package/dist/sveltekit/health.js +12 -0
  116. package/dist/sveltekit/index.d.ts +9 -83
  117. package/dist/sveltekit/index.d.ts.map +1 -1
  118. package/dist/sveltekit/index.js +8 -149
  119. package/dist/sveltekit/nav-routes.d.ts +30 -0
  120. package/dist/sveltekit/nav-routes.d.ts.map +1 -0
  121. package/dist/sveltekit/nav-routes.js +103 -0
  122. package/dist/sveltekit/types.d.ts +32 -0
  123. package/dist/sveltekit/types.d.ts.map +1 -0
  124. package/dist/sveltekit/types.js +1 -0
  125. package/package.json +38 -58
  126. package/src/lib/auth/crypto.ts +37 -0
  127. package/src/lib/auth/store.ts +158 -0
  128. package/src/lib/auth/types.ts +27 -0
  129. package/src/lib/components/AdminLayout.svelte +58 -108
  130. package/src/lib/components/ComponentPalette.svelte +50 -0
  131. package/src/lib/components/ConceptList.svelte +81 -0
  132. package/src/lib/components/ConfirmPage.svelte +23 -20
  133. package/src/lib/components/EditPage.svelte +160 -103
  134. package/src/lib/components/LoginPage.svelte +42 -52
  135. package/src/lib/components/ManageEditors.svelte +81 -0
  136. package/src/lib/components/MarkdownEditor.svelte +81 -0
  137. package/src/lib/components/NavTree.svelte +138 -0
  138. package/src/lib/components/cairn-admin.css +42 -0
  139. package/src/lib/components/index.ts +7 -4
  140. package/src/lib/content/compose.ts +39 -0
  141. package/src/lib/content/concepts.ts +57 -0
  142. package/src/lib/content/frontmatter.ts +71 -0
  143. package/src/lib/content/ids.ts +38 -0
  144. package/src/lib/content/types.ts +235 -0
  145. package/src/lib/content/validate.ts +51 -0
  146. package/src/lib/email.ts +52 -38
  147. package/src/lib/env.ts +32 -0
  148. package/src/lib/github/credentials.ts +27 -0
  149. package/src/lib/github/repo.ts +138 -0
  150. package/src/lib/github/signing.ts +97 -0
  151. package/src/lib/github/types.ts +46 -0
  152. package/src/lib/index.ts +86 -8
  153. package/src/lib/nav/site-config.ts +124 -0
  154. package/src/lib/render/glyph.ts +6 -6
  155. package/src/lib/render/index.ts +6 -6
  156. package/src/lib/render/pipeline.ts +22 -22
  157. package/src/lib/render/registry.ts +33 -26
  158. package/src/lib/render/rehype-dispatch.ts +47 -47
  159. package/src/lib/render/remark-directives.ts +46 -46
  160. package/src/lib/render/sanitize.ts +27 -0
  161. package/src/lib/sveltekit/auth-routes.ts +107 -0
  162. package/src/lib/sveltekit/content-routes.ts +261 -0
  163. package/src/lib/sveltekit/editors-routes.ts +82 -0
  164. package/src/lib/sveltekit/guard.ts +47 -0
  165. package/src/lib/sveltekit/health.ts +24 -0
  166. package/src/lib/sveltekit/index.ts +19 -235
  167. package/src/lib/sveltekit/nav-routes.ts +139 -0
  168. package/src/lib/sveltekit/types.ts +33 -0
  169. package/dist/adapter.d.ts +0 -69
  170. package/dist/adapter.d.ts.map +0 -1
  171. package/dist/adapter.js +0 -30
  172. package/dist/auth/admins.d.ts +0 -33
  173. package/dist/auth/admins.d.ts.map +0 -1
  174. package/dist/auth/admins.js +0 -90
  175. package/dist/auth/config.d.ts +0 -2097
  176. package/dist/auth/config.d.ts.map +0 -1
  177. package/dist/auth/config.js +0 -78
  178. package/dist/auth/guard.d.ts +0 -34
  179. package/dist/auth/guard.d.ts.map +0 -1
  180. package/dist/auth/guard.js +0 -47
  181. package/dist/auth/index.d.ts +0 -4
  182. package/dist/auth/index.d.ts.map +0 -1
  183. package/dist/auth/index.js +0 -6
  184. package/dist/auth/schema.d.ts +0 -750
  185. package/dist/auth/schema.d.ts.map +0 -1
  186. package/dist/auth/schema.js +0 -93
  187. package/dist/carta.d.ts +0 -39
  188. package/dist/carta.d.ts.map +0 -1
  189. package/dist/carta.js +0 -30
  190. package/dist/components/AdminList.svelte +0 -33
  191. package/dist/components/AdminList.svelte.d.ts +0 -10
  192. package/dist/components/AdminList.svelte.d.ts.map +0 -1
  193. package/dist/components/ManageAdmins.svelte +0 -84
  194. package/dist/components/ManageAdmins.svelte.d.ts +0 -10
  195. package/dist/components/ManageAdmins.svelte.d.ts.map +0 -1
  196. package/dist/content.d.ts +0 -3
  197. package/dist/content.d.ts.map +0 -1
  198. package/dist/content.js +0 -10
  199. package/dist/github.d.ts +0 -72
  200. package/dist/github.d.ts.map +0 -1
  201. package/dist/github.js +0 -171
  202. package/dist/utils.d.ts +0 -3
  203. package/dist/utils.d.ts.map +0 -1
  204. package/dist/utils.js +0 -11
  205. package/src/lib/adapter.ts +0 -119
  206. package/src/lib/auth/admins.ts +0 -106
  207. package/src/lib/auth/config.ts +0 -108
  208. package/src/lib/auth/guard.ts +0 -60
  209. package/src/lib/auth/index.ts +0 -6
  210. package/src/lib/auth/schema.ts +0 -112
  211. package/src/lib/carta.ts +0 -59
  212. package/src/lib/components/AdminList.svelte +0 -33
  213. package/src/lib/components/ManageAdmins.svelte +0 -84
  214. package/src/lib/content.ts +0 -11
  215. package/src/lib/github.ts +0 -220
  216. package/src/lib/utils.ts +0 -12
@@ -0,0 +1,124 @@
1
+ // The navigation tree and its YAML site-config. A menu lives in the site's git-committed config
2
+ // under `menus.<name>`, read at build time by the public layout and edited from /admin/nav, which
3
+ // commits the file back through the GitHub-App pipeline. This module is pure: parse, validate, and
4
+ // rewrite only. The engine returns data; each site renders the tree with its own markup.
5
+ import { parse as parseYaml, parseDocument } from 'yaml';
6
+
7
+ /** One navigation node. An omitted or empty `url` is a label-only grouping header; no `children` is a leaf. */
8
+ export interface NavNode {
9
+ label: string;
10
+ url?: string;
11
+ children?: NavNode[];
12
+ }
13
+
14
+ /** Total node cap across the whole tree, a guard against a runaway payload. */
15
+ export const MAX_NAV_NODES = 200;
16
+
17
+ /** Maximum character length for a node label. */
18
+ export const MAX_LABEL_LENGTH = 500;
19
+
20
+ /** Maximum character length for a node URL. */
21
+ export const MAX_URL_LENGTH = 2048;
22
+
23
+ /** Allowlist for safe URL schemes: site-relative, in-page anchors, http(s), mailto, and tel. */
24
+ const SAFE_URL = /^(\/|#|https?:\/\/|mailto:|tel:)/i;
25
+
26
+ export class NavValidationError extends Error {
27
+ constructor(message: string) {
28
+ super(message);
29
+ this.name = 'NavValidationError';
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Validate and normalize an untrusted value into a NavNode[]: arrays only, non-empty labels, depth
35
+ * within `maxDepth` (1 is flat), a bounded node count, and only the three known keys kept. Throws
36
+ * NavValidationError on any violation. Used by navSave before writing.
37
+ */
38
+ export function validateNavTree(value: unknown, maxDepth: number): NavNode[] {
39
+ let count = 0;
40
+
41
+ function walk(nodes: unknown, depth: number): NavNode[] {
42
+ if (!Array.isArray(nodes)) throw new NavValidationError('Navigation must be a list of items');
43
+ if (depth > maxDepth) throw new NavValidationError(`Navigation is nested deeper than ${maxDepth} levels`);
44
+ return nodes.map((raw) => {
45
+ if (typeof raw !== 'object' || raw === null) throw new NavValidationError('Each item must be an object');
46
+ const item = raw as Record<string, unknown>;
47
+ const label = typeof item.label === 'string' ? item.label.trim() : '';
48
+ if (!label) throw new NavValidationError('Each item needs a label');
49
+ if (label.length > MAX_LABEL_LENGTH) throw new NavValidationError('Label is too long (max 500 characters)');
50
+ if (++count > MAX_NAV_NODES) throw new NavValidationError('Too many navigation items');
51
+ const node: NavNode = { label };
52
+ if (typeof item.url === 'string' && item.url.trim()) {
53
+ const url = item.url.trim();
54
+ if (url.length > MAX_URL_LENGTH) throw new NavValidationError('URL is too long (max 2048 characters)');
55
+ if (!SAFE_URL.test(url)) throw new NavValidationError('URL must start with /, #, http(s)://, mailto:, or tel:');
56
+ node.url = url;
57
+ }
58
+ if (item.children !== undefined) {
59
+ const children = walk(item.children, depth + 1);
60
+ if (children.length) node.children = children;
61
+ }
62
+ return node;
63
+ });
64
+ }
65
+
66
+ return walk(value, 1);
67
+ }
68
+
69
+ /**
70
+ * Shape of the YAML site-config file. Unknown keys are ignored so the file can grow without an
71
+ * engine change. Read at build time by the public site.
72
+ */
73
+ export interface SiteConfig {
74
+ siteName: string;
75
+ description?: string;
76
+ author?: string;
77
+ url?: string;
78
+ locale?: string;
79
+ /** Named navigation menus, each a NavNode[] (normalized by extractMenu). */
80
+ menus?: Record<string, unknown>;
81
+ [key: string]: unknown;
82
+ }
83
+
84
+ export class SiteConfigError extends Error {
85
+ constructor(message: string) {
86
+ super(message);
87
+ this.name = 'SiteConfigError';
88
+ }
89
+ }
90
+
91
+ /** Parse the YAML site-config text into a typed object. Throws SiteConfigError on a malformed root. */
92
+ export function parseSiteConfig(raw: string): SiteConfig {
93
+ const parsed = parseYaml(raw) as unknown;
94
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
95
+ throw new SiteConfigError('Site config must be a YAML mapping');
96
+ }
97
+ const { siteName } = parsed as SiteConfig;
98
+ if (typeof siteName !== 'string' || !siteName.trim()) {
99
+ throw new SiteConfigError('Site config needs a siteName');
100
+ }
101
+ return parsed as SiteConfig;
102
+ }
103
+
104
+ /** Extract one named menu from a parsed config and validate it. Returns [] when the menu is absent. */
105
+ export function extractMenu(config: SiteConfig, name: string, maxDepth: number): NavNode[] {
106
+ const menu = config.menus?.[name];
107
+ if (menu === undefined) return [];
108
+ return validateNavTree(menu, maxDepth);
109
+ }
110
+
111
+ /**
112
+ * Replace one named menu in the YAML site-config text and reserialize, preserving every other
113
+ * top-level key (siteName, other menus, settings). Parses into a Document so the rest of the file
114
+ * round-trips. YAML comments are not preserved (an accepted trade); data keys are. A leaf node
115
+ * serializes without `url`/`children` keys.
116
+ */
117
+ export function setMenu(raw: string, name: string, tree: NavNode[]): string {
118
+ const doc = parseDocument(raw);
119
+ if (doc.get('siteName') === undefined) {
120
+ throw new SiteConfigError('Site config must be a mapping with a siteName');
121
+ }
122
+ doc.setIn(['menus', name], tree);
123
+ return doc.toString();
124
+ }
@@ -1,14 +1,14 @@
1
1
  import { s } from 'hastscript';
2
2
  import type { Element } from 'hast';
3
3
 
4
- /** A glyph name SVG path-data map (the site owns the icon set). */
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
7
  /** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill. */
8
8
  export function glyph(name: string, icons: IconSet): Element {
9
- return s(
10
- 'svg',
11
- { className: ['ec-glyph'], viewBox: '0 0 256 256', fill: 'currentColor', ariaHidden: 'true' },
12
- [s('path', { d: icons[name] })],
13
- );
9
+ return s(
10
+ 'svg',
11
+ { className: ['ec-glyph'], viewBox: '0 0 256 256', fill: 'currentColor', ariaHidden: 'true' },
12
+ [s('path', { d: icons[name] })],
13
+ );
14
14
  }
@@ -1,8 +1,8 @@
1
- // cairn-cms render engine: a directive-driven markdown HTML pipeline whose
1
+ // cairn-cms render engine: a directive-driven markdown to HTML pipeline whose
2
2
  // component vocabulary is supplied by a site's component registry. The site owns the
3
3
  // component builders, class names, icon set, and CSS; the engine owns the machinery.
4
- export * from './registry';
5
- export * from './glyph';
6
- export * from './remark-directives';
7
- export * from './rehype-dispatch';
8
- export * from './pipeline';
4
+ export * from './registry.js';
5
+ export * from './glyph.js';
6
+ export * from './remark-directives.js';
7
+ export * from './rehype-dispatch.js';
8
+ export * from './pipeline.js';
@@ -6,32 +6,32 @@ 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 { remarkDirectiveStamp } from './remark-directives';
10
- import { rehypeDispatch } from './rehype-dispatch';
11
- import type { ComponentRegistry } from './registry';
9
+ import { remarkDirectiveStamp } from './remark-directives.js';
10
+ import { rehypeDispatch } from './rehype-dispatch.js';
11
+ import type { ComponentRegistry } from './registry.js';
12
12
 
13
13
  export interface RendererOptions {
14
- /** A site's per-index motion formula for the top-level rise stagger
15
- * (e.g. ecnordic's `(i) => '--rise:' + …`). Omit for no stagger. */
16
- rise?: (idx: number) => string;
14
+ /** A site's per-index motion formula for the top-level rise stagger
15
+ * (e.g. ecnordic's `(i) => '--rise:' + …`). Omit for no stagger. */
16
+ rise?: (idx: number) => string;
17
17
  }
18
18
 
19
- /** Compose a site's render pipeline from its component registry: directive syntax
20
- * stamped markers registry-built hast. Returns `renderMarkdown` plus the remark/
19
+ /** Compose a site's render pipeline from its component registry: directive syntax to
20
+ * stamped markers to registry-built hast. Returns `renderMarkdown` plus the remark/
21
21
  * rehype plugin arrays (so the Carta editor preview can reuse the exact same set). */
22
22
  export function createRenderer(registry: ComponentRegistry, options: RendererOptions = {}) {
23
- const remarkPlugins: PluggableList = [remarkDirective, [remarkDirectiveStamp, registry]];
24
- const rehypePlugins: PluggableList = [rehypeRaw, [rehypeDispatch, registry, options.rise], rehypeSlug];
25
- const processor = unified()
26
- .use(remarkParse)
27
- .use(remarkGfm)
28
- .use(remarkPlugins)
29
- .use(remarkRehype, { allowDangerousHtml: true })
30
- .use(rehypePlugins)
31
- .use(rehypeStringify);
32
- return {
33
- remarkPlugins,
34
- rehypePlugins,
35
- renderMarkdown: async (content: string): Promise<string> => String(await processor.process(content)),
36
- };
23
+ const remarkPlugins: PluggableList = [remarkDirective, [remarkDirectiveStamp, registry]];
24
+ const rehypePlugins: PluggableList = [rehypeRaw, [rehypeDispatch, registry, options.rise], rehypeSlug];
25
+ const processor = unified()
26
+ .use(remarkParse)
27
+ .use(remarkGfm)
28
+ .use(remarkPlugins)
29
+ .use(remarkRehype, { allowDangerousHtml: true })
30
+ .use(rehypePlugins)
31
+ .use(rehypeStringify);
32
+ return {
33
+ remarkPlugins,
34
+ rehypePlugins,
35
+ renderMarkdown: async (content: string): Promise<string> => String(await processor.process(content)),
36
+ };
37
37
  }
@@ -1,36 +1,43 @@
1
+ // cairn-cms: the directive component registry (seam 3). One declaration per component,
2
+ // carrying how it inserts in the editor and how it renders in rehype. The render pipeline
3
+ // (Plan 04) and the future component palette both derive from this single source, so the
4
+ // parser, the render dispatch, and the editor never drift apart. The adapter references
5
+ // `ComponentRegistry` from here.
1
6
  import type { Element } from 'hast';
2
7
 
3
8
  /** A site component: how it inserts (editor) and how it renders (rehype). */
4
9
  export interface ComponentDef {
5
- /** Directive name, e.g. 'card' (matches `:::card`). */
6
- name: string;
7
- /** Palette label. */
8
- label: string;
9
- /** Palette description. */
10
- description: string;
11
- /** Markdown scaffold inserted at the cursor by the editor palette. */
12
- insertTemplate: string;
13
- /** Build the final hast element from the stamped directive element. */
14
- build: (node: Element, rise?: string) => Element;
15
- /** Optional roledefault-icon (e.g. `{ caution: 'warning' }`). */
16
- defaultIconByRole?: Record<string, string>;
10
+ /** Directive name, e.g. 'card' (matches `:::card`). */
11
+ name: string;
12
+ /** Palette label. */
13
+ label: string;
14
+ /** Palette description. */
15
+ description: string;
16
+ /** Markdown scaffold inserted at the cursor by the editor palette. */
17
+ insertTemplate: string;
18
+ /** Build the final hast element from the stamped directive element. */
19
+ build: (node: Element, rise?: string) => Element;
20
+ /** Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. */
21
+ defaultIconByRole?: Record<string, string>;
17
22
  }
18
23
 
19
24
  export interface ComponentRegistry {
20
- defs: ComponentDef[];
21
- names: string[];
22
- get(name: string): ComponentDef | undefined;
23
- defaultIcon(name: string, role?: string): string | undefined;
25
+ defs: ComponentDef[];
26
+ names: string[];
27
+ get(name: string): ComponentDef | undefined;
28
+ defaultIcon(name: string, role?: string): string | undefined;
24
29
  }
25
30
 
26
- /** Build a registry from a site's component definitions. The single source the
27
- * render pipeline (directive stamp + rehype dispatch) and the editor palette read. */
28
- export function defineRegistry(input: { components: ComponentDef[] }): ComponentRegistry {
29
- const byName = new Map(input.components.map((c) => [c.name, c]));
30
- return {
31
- defs: input.components,
32
- names: input.components.map((c) => c.name),
33
- get: (name) => byName.get(name),
34
- defaultIcon: (name, role) => (role ? byName.get(name)?.defaultIconByRole?.[role] : undefined),
35
- };
31
+ /**
32
+ * Build a registry from a site's component definitions. The single source the render
33
+ * pipeline (directive stamp plus rehype dispatch) and the editor palette both read.
34
+ */
35
+ export function defineRegistry({ components }: { components: ComponentDef[] }): ComponentRegistry {
36
+ const byName = new Map(components.map((c) => [c.name, c]));
37
+ return {
38
+ defs: components,
39
+ names: components.map((c) => c.name),
40
+ get: (name) => byName.get(name),
41
+ defaultIcon: (name, role) => (role ? byName.get(name)?.defaultIconByRole?.[role] : undefined),
42
+ };
36
43
  }
@@ -1,23 +1,23 @@
1
1
  import type { Root, Element, ElementContent, Properties } from 'hast';
2
2
  import { h } from 'hastscript';
3
- import type { ComponentRegistry } from './registry';
3
+ import type { ComponentRegistry } from './registry.js';
4
4
 
5
5
  export function isElement(node: ElementContent | undefined): node is Element {
6
- return !!node && node.type === 'element';
6
+ return !!node && node.type === 'element';
7
7
  }
8
8
 
9
9
  // hast Properties values are PropertyValue (string | number | boolean | array | null).
10
10
  // Directive markers (dataIcon/dataRole/dataPrimitive) are always stamped as strings;
11
11
  // this reads them back with that guarantee instead of casting at each call site.
12
12
  export function strProp(node: Element, name: string): string | undefined {
13
- const value = node.properties?.[name];
14
- return typeof value === 'string' ? value : undefined;
13
+ const value = node.properties?.[name];
14
+ return typeof value === 'string' ? value : undefined;
15
15
  }
16
16
 
17
17
  /** Wrap a pre-built glyph in an ec-icon span; secondary role adds the modifier. */
18
18
  export function iconSpan(glyphEl: Element, role?: string): Element {
19
- const className = role === 'secondary' ? ['ec-icon', 'ec-icon-secondary'] : ['ec-icon'];
20
- return h('span', { className }, [glyphEl]);
19
+ const className = role === 'secondary' ? ['ec-icon', 'ec-icon-secondary'] : ['ec-icon'];
20
+ return h('span', { className }, [glyphEl]);
21
21
  }
22
22
 
23
23
  /** A site's icon factory: turn a stamped icon name + role into a hast element. */
@@ -28,55 +28,55 @@ export type MakeIcon = (name: string, role?: string) => Element;
28
28
  // `makeIcon` (site-supplied) turns the stamped data-icon into an element; omit it
29
29
  // for a head with no icon.
30
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 };
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
42
  }
43
43
 
44
44
  /** Section wrapper: `<section class=…><div class="card-body">…</div></section>`,
45
45
  * with an optional inline rise style. */
46
46
  export function cardShell(classes: string[], rise: string | undefined, body: ElementContent[]): Element {
47
- const properties: Properties = { className: classes };
48
- if (rise) properties.style = rise;
49
- return h('section', properties, [h('div', { className: ['card-body'] }, body)]);
47
+ const properties: Properties = { className: classes };
48
+ if (rise) properties.style = rise;
49
+ return h('section', properties, [h('div', { className: ['card-body'] }, body)]);
50
50
  }
51
51
 
52
52
  /** Tag the first <ul> among children with `ec-grid` and strip its whitespace-only
53
53
  * text nodes so the bare list serializes without newlines. Returns that <ul>. */
54
54
  export function markFirstList(children: ElementContent[]): Element | undefined {
55
- const ul = children.find((c) => isElement(c) && c.tagName === 'ul') as Element | undefined;
56
- if (ul) {
57
- ul.properties = { ...ul.properties, className: ['ec-grid'] };
58
- ul.children = (ul.children as ElementContent[]).filter(
59
- (c) => !(c.type === 'text' && /^\s*$/.test(c.value)),
60
- );
61
- }
62
- return ul;
55
+ const ul = children.find((c) => isElement(c) && c.tagName === 'ul') as Element | undefined;
56
+ if (ul) {
57
+ ul.properties = { ...ul.properties, className: ['ec-grid'] };
58
+ ul.children = (ul.children as ElementContent[]).filter(
59
+ (c) => !(c.type === 'text' && /^\s*$/.test(c.value)),
60
+ );
61
+ }
62
+ return ul;
63
63
  }
64
64
 
65
65
  // Recurse into a node's children, transforming any nested primitive sections
66
66
  // (a grid inside a card, panels inside a split) WITHOUT a rise stagger.
67
67
  function transformChildren(children: ElementContent[], registry: ComponentRegistry): ElementContent[] {
68
- return children.map((c) => {
69
- if (isElement(c) && c.properties?.dataPrimitive) return transformNode(c, registry);
70
- if (isElement(c)) c.children = transformChildren(c.children as ElementContent[], registry);
71
- return c;
72
- });
68
+ return children.map((c) => {
69
+ if (isElement(c) && c.properties?.dataPrimitive) return transformNode(c, registry);
70
+ if (isElement(c)) c.children = transformChildren(c.children as ElementContent[], registry);
71
+ return c;
72
+ });
73
73
  }
74
74
 
75
75
  function transformNode(node: Element, registry: ComponentRegistry, rise?: string): Element {
76
- node.children = transformChildren(node.children as ElementContent[], registry);
77
- const name = strProp(node, 'dataPrimitive');
78
- const def = name ? registry.get(name) : undefined;
79
- return def ? def.build(node, rise) : node;
76
+ node.children = transformChildren(node.children as ElementContent[], registry);
77
+ const name = strProp(node, 'dataPrimitive');
78
+ const def = name ? registry.get(name) : undefined;
79
+ return def ? def.build(node, rise) : node;
80
80
  }
81
81
 
82
82
  /** Rehype transformer: dispatch each stamped element through its registry `build`
@@ -84,14 +84,14 @@ function transformNode(node: Element, registry: ComponentRegistry, rise?: string
84
84
  * supplied (a site's per-index motion formula); nested ones don't. Non-primitive
85
85
  * content (lede, intro paragraphs, the page-toc nav) passes through untouched. */
86
86
  export function rehypeDispatch(registry: ComponentRegistry, rise?: (idx: number) => string) {
87
- return (tree: Root) => {
88
- let idx = 0;
89
- tree.children = (tree.children as ElementContent[]).map((child) => {
90
- if (isElement(child) && child.properties?.dataPrimitive) {
91
- return transformNode(child, registry, rise ? rise(idx++) : undefined);
92
- }
93
- if (isElement(child)) child.children = transformChildren(child.children as ElementContent[], registry);
94
- return child;
95
- });
96
- };
87
+ return (tree: Root) => {
88
+ let idx = 0;
89
+ tree.children = (tree.children as ElementContent[]).map((child) => {
90
+ if (isElement(child) && child.properties?.dataPrimitive) {
91
+ return transformNode(child, registry, rise ? rise(idx++) : undefined);
92
+ }
93
+ if (isElement(child)) child.children = transformChildren(child.children as ElementContent[], registry);
94
+ return child;
95
+ });
96
+ };
97
97
  }
@@ -1,20 +1,20 @@
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';
4
+ import type { ComponentRegistry } from './registry.js';
5
5
 
6
6
  // Reconstruct a directive's authored attribute block (`{#id .class key="value"}`).
7
7
  // Accidental prose directives carry none, so this is almost always empty.
8
8
  function serializeAttributes(attributes?: Record<string, string | null | undefined> | null): string {
9
- if (!attributes) return '';
10
- const tokens: string[] = [];
11
- for (const [key, value] of Object.entries(attributes)) {
12
- if (value == null) tokens.push(key);
13
- else if (key === 'id') tokens.push(`#${value}`);
14
- else if (key === 'class') for (const c of value.split(/\s+/).filter(Boolean)) tokens.push(`.${c}`);
15
- else tokens.push(`${key}="${value}"`);
16
- }
17
- return tokens.length ? `{${tokens.join(' ')}}` : '';
9
+ if (!attributes) return '';
10
+ const tokens: string[] = [];
11
+ for (const [key, value] of Object.entries(attributes)) {
12
+ if (value == null) tokens.push(key);
13
+ else if (key === 'id') tokens.push(`#${value}`);
14
+ else if (key === 'class') for (const c of value.split(/\s+/).filter(Boolean)) tokens.push(`.${c}`);
15
+ else tokens.push(`${key}="${value}"`);
16
+ }
17
+ return tokens.length ? `{${tokens.join(' ')}}` : '';
18
18
  }
19
19
 
20
20
  // The vocabulary is container-only (`:::name`). A text directive (`:name`) or
@@ -22,14 +22,14 @@ function serializeAttributes(attributes?: Record<string, string | null | undefin
22
22
  // ("4:00", "9:30", "ratio 16:9") that micromark tokenized as a directive.
23
23
  // Restore it to its literal source text so prose renders verbatim.
24
24
  function restoreLiteral(node: TextDirective | LeafDirective): PhrasingContent[] {
25
- const marker = node.type === 'leafDirective' ? '::' : ':';
26
- const attrs = serializeAttributes(node.attributes);
27
- if (node.children.length === 0) {
28
- return [{ type: 'text', value: marker + node.name + attrs }];
29
- }
30
- const open: Text = { type: 'text', value: `${marker}${node.name}[` };
31
- const close: Text = { type: 'text', value: `]${attrs}` };
32
- return [open, ...(node.children as PhrasingContent[]), close];
25
+ const marker = node.type === 'leafDirective' ? '::' : ':';
26
+ const attrs = serializeAttributes(node.attributes);
27
+ if (node.children.length === 0) {
28
+ return [{ type: 'text', value: marker + node.name + attrs }];
29
+ }
30
+ const open: Text = { type: 'text', value: `${marker}${node.name}[` };
31
+ const close: Text = { type: 'text', value: `]${attrs}` };
32
+ return [open, ...(node.children as PhrasingContent[]), close];
33
33
  }
34
34
 
35
35
  // Stamp each registered container directive with data-* markers carrying its
@@ -37,35 +37,35 @@ function restoreLiteral(node: TextDirective | LeafDirective): PhrasingContent[]
37
37
  // dispatcher rewrites the marked elements once their children are hast.
38
38
  // Text and leaf directives are restored to literal text (accidental prose colons).
39
39
  export function remarkDirectiveStamp(registry: ComponentRegistry) {
40
- const known = new Set(registry.names);
41
- return (tree: Root) => {
42
- visit(tree, 'containerDirective', (node: ContainerDirective) => {
43
- if (!known.has(node.name)) return;
44
- const attrs = node.attributes ?? {};
45
- const role = attrs.role || undefined;
46
- let icon = attrs.icon || undefined;
47
- if (!icon && role) icon = registry.defaultIcon(node.name, role);
40
+ const known = new Set(registry.names);
41
+ return (tree: Root) => {
42
+ visit(tree, 'containerDirective', (node: ContainerDirective) => {
43
+ if (!known.has(node.name)) return;
44
+ const attrs = node.attributes ?? {};
45
+ const role = attrs.role || undefined;
46
+ let icon = attrs.icon || undefined;
47
+ if (!icon && role) icon = registry.defaultIcon(node.name, role);
48
48
 
49
- const properties: Record<string, string> = { dataPrimitive: node.name };
50
- if (icon) properties.dataIcon = icon;
51
- if (role) properties.dataRole = role;
49
+ const properties: Record<string, string> = { dataPrimitive: node.name };
50
+ if (icon) properties.dataIcon = icon;
51
+ if (role) properties.dataRole = role;
52
52
 
53
- const data = node.data ?? (node.data = {});
54
- data.hName = 'div';
55
- data.hProperties = properties;
56
- });
53
+ const data = node.data ?? (node.data = {});
54
+ data.hName = 'div';
55
+ data.hProperties = properties;
56
+ });
57
57
 
58
- visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
59
- if (!parent || index == null) return;
60
- const literal = restoreLiteral(node as TextDirective | LeafDirective);
61
- if (node.type === 'leafDirective') {
62
- // Leaf directives sit at block level; wrap the restored text in a paragraph.
63
- const paragraph: Paragraph = { type: 'paragraph', children: literal };
64
- parent.children.splice(index, 1, paragraph);
65
- } else {
66
- parent.children.splice(index, 1, ...literal);
67
- }
68
- return index;
69
- });
70
- };
58
+ visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
59
+ if (!parent || index == null) return;
60
+ const literal = restoreLiteral(node as TextDirective | LeafDirective);
61
+ if (node.type === 'leafDirective') {
62
+ // Leaf directives sit at block level; wrap the restored text in a paragraph.
63
+ const paragraph: Paragraph = { type: 'paragraph', children: literal };
64
+ parent.children.splice(index, 1, paragraph);
65
+ } else {
66
+ parent.children.splice(index, 1, ...literal);
67
+ }
68
+ return index;
69
+ });
70
+ };
71
71
  }
@@ -0,0 +1,27 @@
1
+ // The live preview's sanitize floor. Carta runs with `sanitizer: false` behind the MarkdownEditor
2
+ // seam, so the admin preview pane is the one barrier between editor-authored markdown and the DOM.
3
+ // DOMPurify needs a DOM, and the preview renders only in the browser after mount, so DOMPurify
4
+ // loads through a dynamic import: the module never evaluates a DOM library on the Worker, and a
5
+ // server import of this file pulls in nothing.
6
+ let purify: { sanitize(html: string, config?: Record<string, unknown>): string; addHook(event: string, cb: (node: Element) => void): void } | null = null;
7
+
8
+ /**
9
+ * Sanitize rendered preview HTML before it reaches `{@html}`. Strips scripts, inline event
10
+ * handlers, and dangerous URL schemes (`javascript:`, `data:`) while keeping ordinary formatting.
11
+ * Also forces `rel="noopener noreferrer"` on any anchor with `target="_blank"` to prevent
12
+ * reverse-tabnabbing. Browser-only; resolves the same string DOMPurify would return.
13
+ */
14
+ export async function sanitizePreviewHtml(html: string): Promise<string> {
15
+ if (!purify) {
16
+ const mod = await import('dompurify');
17
+ purify = mod.default;
18
+ purify.addHook('afterSanitizeAttributes', (node) => {
19
+ if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
20
+ node.setAttribute('rel', 'noopener noreferrer');
21
+ }
22
+ });
23
+ }
24
+ // ADD_ATTR: ['target'] allows target="_blank" through so the afterSanitizeAttributes hook
25
+ // can enforce rel="noopener noreferrer" on those anchors before they reach the DOM.
26
+ return purify.sanitize(html, { ADD_ATTR: ['target'] });
27
+ }