@glw907/cairn-cms 0.4.0 → 0.5.1

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 (114) hide show
  1. package/README.md +4 -4
  2. package/dist/adapter.d.ts +34 -1
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/auth/capabilities.d.ts +7 -0
  5. package/dist/auth/capabilities.d.ts.map +1 -0
  6. package/dist/auth/capabilities.js +26 -0
  7. package/dist/auth/config.d.ts +9 -9
  8. package/dist/auth/config.d.ts.map +1 -1
  9. package/dist/auth/config.js +5 -5
  10. package/dist/auth/guard.d.ts +1 -1
  11. package/dist/auth/guard.d.ts.map +1 -1
  12. package/dist/auth/guard.js +2 -2
  13. package/dist/auth/index.d.ts +1 -0
  14. package/dist/auth/index.d.ts.map +1 -1
  15. package/dist/auth/index.js +1 -0
  16. package/dist/carta.d.ts +1 -1
  17. package/dist/carta.d.ts.map +1 -1
  18. package/dist/components/AdminLayout.svelte +74 -18
  19. package/dist/components/AdminLayout.svelte.d.ts +9 -0
  20. package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
  21. package/dist/components/CollectionList.svelte +96 -0
  22. package/dist/components/CollectionList.svelte.d.ts +8 -0
  23. package/dist/components/CollectionList.svelte.d.ts.map +1 -0
  24. package/dist/components/ComponentPalette.svelte +34 -0
  25. package/dist/components/ComponentPalette.svelte.d.ts +9 -0
  26. package/dist/components/ComponentPalette.svelte.d.ts.map +1 -0
  27. package/dist/components/ConfirmPage.svelte +2 -2
  28. package/dist/components/EditPage.svelte +69 -31
  29. package/dist/components/EditPage.svelte.d.ts +2 -0
  30. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  31. package/dist/components/LoginPage.svelte +5 -5
  32. package/dist/components/NavTree.svelte +128 -0
  33. package/dist/components/NavTree.svelte.d.ts +8 -0
  34. package/dist/components/NavTree.svelte.d.ts.map +1 -0
  35. package/dist/components/index.d.ts +3 -1
  36. package/dist/components/index.d.ts.map +1 -1
  37. package/dist/components/index.js +3 -1
  38. package/dist/editor.d.ts +25 -0
  39. package/dist/editor.d.ts.map +1 -0
  40. package/dist/editor.js +20 -0
  41. package/dist/email.js +4 -4
  42. package/dist/frontmatter.d.ts +3 -0
  43. package/dist/frontmatter.d.ts.map +1 -0
  44. package/dist/frontmatter.js +16 -0
  45. package/dist/github.d.ts +22 -2
  46. package/dist/github.d.ts.map +1 -1
  47. package/dist/github.js +40 -5
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +3 -0
  51. package/dist/nav.d.ts +58 -0
  52. package/dist/nav.d.ts.map +1 -0
  53. package/dist/nav.js +86 -0
  54. package/dist/render/glyph.d.ts +6 -0
  55. package/dist/render/glyph.d.ts.map +1 -0
  56. package/dist/render/glyph.js +5 -0
  57. package/dist/render/index.d.ts +6 -0
  58. package/dist/render/index.d.ts.map +1 -0
  59. package/dist/render/index.js +8 -0
  60. package/dist/render/pipeline.d.ts +16 -0
  61. package/dist/render/pipeline.d.ts.map +1 -0
  62. package/dist/render/pipeline.js +29 -0
  63. package/dist/render/registry.d.ts +28 -0
  64. package/dist/render/registry.d.ts.map +1 -0
  65. package/dist/render/registry.js +11 -0
  66. package/dist/render/rehype-dispatch.d.ts +24 -0
  67. package/dist/render/rehype-dispatch.d.ts.map +1 -0
  68. package/dist/render/rehype-dispatch.js +86 -0
  69. package/dist/render/remark-directives.d.ts +4 -0
  70. package/dist/render/remark-directives.d.ts.map +1 -0
  71. package/dist/render/remark-directives.js +74 -0
  72. package/dist/slug.d.ts +7 -0
  73. package/dist/slug.d.ts.map +1 -0
  74. package/dist/slug.js +15 -0
  75. package/dist/sveltekit/index.d.ts +118 -13
  76. package/dist/sveltekit/index.d.ts.map +1 -1
  77. package/dist/sveltekit/index.js +250 -24
  78. package/dist/utils.d.ts +1 -1
  79. package/dist/utils.d.ts.map +1 -1
  80. package/dist/utils.js +2 -2
  81. package/package.json +20 -3
  82. package/src/lib/adapter.ts +37 -3
  83. package/src/lib/auth/capabilities.ts +35 -0
  84. package/src/lib/auth/config.ts +6 -6
  85. package/src/lib/auth/guard.ts +3 -3
  86. package/src/lib/auth/index.ts +1 -0
  87. package/src/lib/carta.ts +2 -2
  88. package/src/lib/components/AdminLayout.svelte +74 -18
  89. package/src/lib/components/CollectionList.svelte +96 -0
  90. package/src/lib/components/ComponentPalette.svelte +34 -0
  91. package/src/lib/components/ConfirmPage.svelte +2 -2
  92. package/src/lib/components/EditPage.svelte +69 -31
  93. package/src/lib/components/LoginPage.svelte +5 -5
  94. package/src/lib/components/NavTree.svelte +128 -0
  95. package/src/lib/components/index.ts +3 -1
  96. package/src/lib/editor.ts +38 -0
  97. package/src/lib/email.ts +4 -4
  98. package/src/lib/frontmatter.ts +17 -0
  99. package/src/lib/github.ts +38 -6
  100. package/src/lib/index.ts +3 -0
  101. package/src/lib/nav.ts +117 -0
  102. package/src/lib/render/glyph.ts +14 -0
  103. package/src/lib/render/index.ts +8 -0
  104. package/src/lib/render/pipeline.ts +37 -0
  105. package/src/lib/render/registry.ts +36 -0
  106. package/src/lib/render/rehype-dispatch.ts +97 -0
  107. package/src/lib/render/remark-directives.ts +71 -0
  108. package/src/lib/slug.ts +16 -0
  109. package/src/lib/sveltekit/index.ts +355 -37
  110. package/src/lib/utils.ts +2 -2
  111. package/dist/components/AdminList.svelte +0 -33
  112. package/dist/components/AdminList.svelte.d.ts +0 -10
  113. package/dist/components/AdminList.svelte.d.ts.map +0 -1
  114. package/src/lib/components/AdminList.svelte +0 -33
package/dist/github.js CHANGED
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // Reads (Pass B) list a collection directory and fetch a file's raw markdown; the token
4
4
  // is optional because ecnordic's repo is public. Writes (Pass C) mint a short-lived
5
- // GitHub App installation token App JWT (RS256) signed with Web Crypto, no octokit
6
- // dependency and commit through the contents API with author = editor, committer = the
5
+ // GitHub App installation token (App JWT, RS256 signed with Web Crypto, no octokit
6
+ // dependency) and commit through the contents API with author = editor, committer = the
7
7
  // App (cairn-cms[bot]). The same token also lifts reads to the authenticated rate limit
8
8
  // and unlocks private repos (e.g. 907-life).
9
9
  import { bytesToB64url } from './utils';
@@ -64,7 +64,7 @@ function derLength(n) {
64
64
  }
65
65
  // AlgorithmIdentifier for rsaEncryption (OID 1.2.840.113549.1.1.1) with NULL parameters.
66
66
  const RSA_ALG_ID = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00];
67
- /** Wrap a PKCS#1 RSAPrivateKey (DER) as PKCS#8 the only RSA form Web Crypto importKey takes. */
67
+ /** Wrap a PKCS#1 RSAPrivateKey (DER) as PKCS#8 (the only RSA form Web Crypto importKey takes). */
68
68
  function pkcs1ToPkcs8(pkcs1) {
69
69
  const octet = [0x04, ...derLength(pkcs1.length), ...pkcs1];
70
70
  const body = [0x02, 0x01, 0x00, ...RSA_ALG_ID, ...octet];
@@ -97,7 +97,7 @@ export async function installationToken(creds) {
97
97
  throw new Error(`GitHub installation token failed: ${res.status}`);
98
98
  return (await res.json()).token;
99
99
  }
100
- /** Standard (padded) base64 of UTF-8 text the encoding the contents API expects. */
100
+ /** Standard (padded) base64 of UTF-8 text, as the contents API expects. */
101
101
  function toBase64(text) {
102
102
  return btoa(Array.from(encoder.encode(text), (b) => String.fromCharCode(b)).join(''));
103
103
  }
@@ -110,11 +110,25 @@ export async function fileSha(repo, path, token) {
110
110
  throw new Error(`GitHub stat ${path} failed: ${res.status}`);
111
111
  return (await res.json()).sha;
112
112
  }
113
+ /**
114
+ * A concurrent edit lost the SHA race (C3): the file changed between the read and the PUT,
115
+ * from another editor or the site's own CI. Thrown so callers can fail safe (re-fetch and ask
116
+ * the editor to reapply) instead of surfacing a raw 409. Defined and caught inside the package
117
+ * so `instanceof` is reliable (no peer-boundary identity split, unlike kit's `redirect`/`error`).
118
+ */
119
+ export class CommitConflictError extends Error {
120
+ path;
121
+ constructor(path) {
122
+ super(`Commit conflict on ${path}: it changed since it was opened`);
123
+ this.path = path;
124
+ this.name = 'CommitConflictError';
125
+ }
126
+ }
113
127
  /**
114
128
  * Commit `content` to `path` on the configured branch via the contents API. Author is the
115
129
  * editor; committer is omitted so GitHub attributes it to the App (cairn-cms[bot]). Updates
116
130
  * the file in place when it exists (passing its sha), creates it otherwise. Returns the
117
- * commit sha.
131
+ * commit sha. A stale-sha 409 (someone committed in between) becomes a `CommitConflictError`.
118
132
  */
119
133
  export async function commitFile(repo, path, content, opts, token) {
120
134
  const sha = await fileSha(repo, path, token);
@@ -130,7 +144,28 @@ export async function commitFile(repo, path, content, opts, token) {
130
144
  ...(sha ? { sha } : {}),
131
145
  }),
132
146
  });
147
+ // 409 = the blob sha we read is no longer current. Fail safe: the caller re-fetches and the
148
+ // editor reapplies. (Full three-way merge stays out of scope; see ARCHITECTURE §5.)
149
+ if (res.status === 409)
150
+ throw new CommitConflictError(path);
133
151
  if (!res.ok)
134
152
  throw new Error(`GitHub commit ${path} failed: ${res.status} ${await res.text()}`);
135
153
  return (await res.json()).commit.sha;
136
154
  }
155
+ /**
156
+ * Deploy-time self-test for the GitHub App signer (M2): sign a dummy JWT with the configured
157
+ * private key. Exercises the brittle PKCS#1→PKCS#8 conversion + Web Crypto import/sign without
158
+ * any network call or secret in the result, so `/admin/healthz` catches a bad/rotated key
159
+ * before an editor's save fails. Returns `{ ok: false, detail }` rather than throwing.
160
+ */
161
+ export async function signingSelfTest(appId, privateKeyB64) {
162
+ try {
163
+ const jwt = await appJwt(appId, atob(privateKeyB64));
164
+ if (jwt.split('.').length !== 3)
165
+ return { ok: false, detail: 'malformed JWT' };
166
+ return { ok: true };
167
+ }
168
+ catch (err) {
169
+ return { ok: false, detail: err instanceof Error ? err.message : 'sign failed' };
170
+ }
171
+ }
package/dist/index.d.ts CHANGED
@@ -3,4 +3,7 @@ export * from './github';
3
3
  export * from './carta';
4
4
  export * from './content';
5
5
  export * from './adapter';
6
+ export * from './slug';
7
+ export * from './render';
8
+ export * from './nav';
6
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAEA,cAAc,SAAS,CAAC;AACxB,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAEA,cAAc,SAAS,CAAC;AACxB,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC;AAC1B,cAAc,QAAQ,CAAC;AACvB,cAAc,UAAU,CAAC;AACzB,cAAc,OAAO,CAAC"}
package/dist/index.js CHANGED
@@ -5,3 +5,6 @@ export * from './github';
5
5
  export * from './carta';
6
6
  export * from './content';
7
7
  export * from './adapter';
8
+ export * from './slug';
9
+ export * from './render';
10
+ export * from './nav';
package/dist/nav.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ /** One navigation node. `url` omitted/empty is a label-only grouping header; `children` omitted is a leaf. */
2
+ export interface NavNode {
3
+ label: string;
4
+ url?: string;
5
+ children?: NavNode[];
6
+ }
7
+ /** Total node cap across the whole tree, a guard against a runaway payload. */
8
+ export declare const MAX_NAV_NODES = 200;
9
+ export declare class NavValidationError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ /**
13
+ * Validate and normalize an untrusted value into a NavNode[]: arrays only, non-empty labels,
14
+ * depth within `maxDepth` (1 = flat), bounded node count, and only the three known keys kept.
15
+ * Throws NavValidationError on any violation. Used by `navSave` before writing.
16
+ */
17
+ export declare function validateNavTree(value: unknown, maxDepth: number): NavNode[];
18
+ /**
19
+ * Shape of the YAML site-config file. Unknown keys are ignored so the file can grow without
20
+ * an engine change. Read at build time by the public site.
21
+ */
22
+ export interface SiteConfig {
23
+ siteName: string;
24
+ description?: string;
25
+ author?: string;
26
+ url?: string;
27
+ locale?: string;
28
+ /** Named navigation menus, each a NavNode[] (normalized by extractMenu). */
29
+ menus?: Record<string, unknown>;
30
+ email?: {
31
+ sender?: string;
32
+ senderName?: string;
33
+ };
34
+ footer?: {
35
+ copyrightName?: string;
36
+ };
37
+ settings?: {
38
+ feedMaxItems?: number;
39
+ homepageFeaturedCount?: number;
40
+ postTags?: string[];
41
+ [key: string]: unknown;
42
+ };
43
+ }
44
+ export declare class SiteConfigError extends Error {
45
+ constructor(message: string);
46
+ }
47
+ /** Parse the YAML site-config text into a typed object. Throws SiteConfigError on a malformed root. */
48
+ export declare function parseSiteConfig(raw: string): SiteConfig;
49
+ /** Extract one named menu from a parsed config and validate it. Returns [] when the menu is absent. */
50
+ export declare function extractMenu(config: SiteConfig, name: string, maxDepth: number): NavNode[];
51
+ /**
52
+ * Replace one named menu in the YAML site-config text and re-serialize, preserving every other
53
+ * top-level key (siteName, other menus, settings, ...). The `/admin/nav` editor commits the result.
54
+ * Parses into a Document so the rest of the file round-trips; YAML comments are not preserved
55
+ * (an accepted trade), but data keys are. A leaf node serializes without `url`/`children` keys.
56
+ */
57
+ export declare function setMenu(raw: string, name: string, tree: NavNode[]): string;
58
+ //# sourceMappingURL=nav.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nav.d.ts","sourceRoot":"","sources":["../src/lib/nav.ts"],"names":[],"mappings":"AAOA,8GAA8G;AAC9G,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;CACtB;AAED,+EAA+E;AAC/E,eAAO,MAAM,aAAa,MAAM,CAAC;AAEjC,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,EAAE,CAuB3E;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,KAAK,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACjD,MAAM,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACpC,QAAQ,CAAC,EAAE;QACT,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,qBAAqB,CAAC,EAAE,MAAM,CAAC;QAC/B,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;CACH;AAED,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,OAAO,EAAE,MAAM;CAI5B;AAED,uGAAuG;AACvG,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAUvD;AAED,uGAAuG;AACvG,wBAAgB,WAAW,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,EAAE,CAIzF;AAED;;;;;GAKG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,MAAM,CAO1E"}
package/dist/nav.js ADDED
@@ -0,0 +1,86 @@
1
+ // cairn-core: the navigation tree. A menu lives in the site's git-committed `site.config.yaml`
2
+ // under `menus.<name>`, read at build time by the public layout and edited from `/admin/nav`,
3
+ // which commits the file back through the GitHub-App pipeline. The engine returns data only; each
4
+ // site renders the tree with its own header markup.
5
+ import { parse as parseYaml, parseDocument } from 'yaml';
6
+ /** Total node cap across the whole tree, a guard against a runaway payload. */
7
+ export const MAX_NAV_NODES = 200;
8
+ export class NavValidationError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = 'NavValidationError';
12
+ }
13
+ }
14
+ /**
15
+ * Validate and normalize an untrusted value into a NavNode[]: arrays only, non-empty labels,
16
+ * depth within `maxDepth` (1 = flat), bounded node count, and only the three known keys kept.
17
+ * Throws NavValidationError on any violation. Used by `navSave` before writing.
18
+ */
19
+ export function validateNavTree(value, maxDepth) {
20
+ let count = 0;
21
+ function walk(nodes, depth) {
22
+ if (!Array.isArray(nodes))
23
+ throw new NavValidationError('Navigation must be a list of items');
24
+ if (depth > maxDepth)
25
+ throw new NavValidationError(`Navigation is nested deeper than ${maxDepth} levels`);
26
+ return nodes.map((raw) => {
27
+ if (typeof raw !== 'object' || raw === null)
28
+ throw new NavValidationError('Each item must be an object');
29
+ const item = raw;
30
+ const label = typeof item.label === 'string' ? item.label.trim() : '';
31
+ if (!label)
32
+ throw new NavValidationError('Each item needs a label');
33
+ if (++count > MAX_NAV_NODES)
34
+ throw new NavValidationError('Too many navigation items');
35
+ const node = { label };
36
+ if (typeof item.url === 'string' && item.url.trim())
37
+ node.url = item.url.trim();
38
+ if (item.children !== undefined) {
39
+ const children = walk(item.children, depth + 1);
40
+ if (children.length)
41
+ node.children = children;
42
+ }
43
+ return node;
44
+ });
45
+ }
46
+ return walk(value, 1);
47
+ }
48
+ export class SiteConfigError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = 'SiteConfigError';
52
+ }
53
+ }
54
+ /** Parse the YAML site-config text into a typed object. Throws SiteConfigError on a malformed root. */
55
+ export function parseSiteConfig(raw) {
56
+ const parsed = parseYaml(raw);
57
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
58
+ throw new SiteConfigError('Site config must be a YAML mapping');
59
+ }
60
+ const { siteName } = parsed;
61
+ if (typeof siteName !== 'string' || !siteName.trim()) {
62
+ throw new SiteConfigError('Site config needs a siteName');
63
+ }
64
+ return parsed;
65
+ }
66
+ /** Extract one named menu from a parsed config and validate it. Returns [] when the menu is absent. */
67
+ export function extractMenu(config, name, maxDepth) {
68
+ const menu = config.menus?.[name];
69
+ if (menu === undefined)
70
+ return [];
71
+ return validateNavTree(menu, maxDepth);
72
+ }
73
+ /**
74
+ * Replace one named menu in the YAML site-config text and re-serialize, preserving every other
75
+ * top-level key (siteName, other menus, settings, ...). The `/admin/nav` editor commits the result.
76
+ * Parses into a Document so the rest of the file round-trips; YAML comments are not preserved
77
+ * (an accepted trade), but data keys are. A leaf node serializes without `url`/`children` keys.
78
+ */
79
+ export function setMenu(raw, name, tree) {
80
+ const doc = parseDocument(raw);
81
+ if (doc.get('siteName') === undefined) {
82
+ throw new SiteConfigError('Site config must be a mapping with a siteName');
83
+ }
84
+ doc.setIn(['menus', name], tree);
85
+ return doc.toString();
86
+ }
@@ -0,0 +1,6 @@
1
+ import type { Element } from 'hast';
2
+ /** A glyph name → SVG path-data map (the site owns the icon set). */
3
+ export type IconSet = Record<string, string>;
4
+ /** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill. */
5
+ export declare function glyph(name: string, icons: IconSet): Element;
6
+ //# sourceMappingURL=glyph.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"glyph.d.ts","sourceRoot":"","sources":["../../src/lib/render/glyph.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,qEAAqE;AACrE,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE7C,4FAA4F;AAC5F,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAM3D"}
@@ -0,0 +1,5 @@
1
+ import { s } from 'hastscript';
2
+ /** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill. */
3
+ export function glyph(name, icons) {
4
+ return s('svg', { className: ['ec-glyph'], viewBox: '0 0 256 256', fill: 'currentColor', ariaHidden: 'true' }, [s('path', { d: icons[name] })]);
5
+ }
@@ -0,0 +1,6 @@
1
+ export * from './registry';
2
+ export * from './glyph';
3
+ export * from './remark-directives';
4
+ export * from './rehype-dispatch';
5
+ export * from './pipeline';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/render/index.ts"],"names":[],"mappings":"AAGA,cAAc,YAAY,CAAC;AAC3B,cAAc,SAAS,CAAC;AACxB,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,YAAY,CAAC"}
@@ -0,0 +1,8 @@
1
+ // cairn-cms render engine: a directive-driven markdown → HTML pipeline whose
2
+ // component vocabulary is supplied by a site's component registry. The site owns the
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';
@@ -0,0 +1,16 @@
1
+ import { type PluggableList } from 'unified';
2
+ import type { ComponentRegistry } from './registry';
3
+ export interface RendererOptions {
4
+ /** A site's per-index motion formula for the top-level rise stagger
5
+ * (e.g. ecnordic's `(i) => '--rise:' + …`). Omit for no stagger. */
6
+ rise?: (idx: number) => string;
7
+ }
8
+ /** Compose a site's render pipeline from its component registry: directive syntax →
9
+ * stamped markers → registry-built hast. Returns `renderMarkdown` plus the remark/
10
+ * rehype plugin arrays (so the Carta editor preview can reuse the exact same set). */
11
+ export declare function createRenderer(registry: ComponentRegistry, options?: RendererOptions): {
12
+ remarkPlugins: PluggableList;
13
+ rehypePlugins: PluggableList;
14
+ renderMarkdown: (content: string) => Promise<string>;
15
+ };
16
+ //# sourceMappingURL=pipeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/lib/render/pipeline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAUtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD,MAAM,WAAW,eAAe;IAC/B;yEACqE;IACrE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;CAC/B;AAED;;uFAEuF;AACvF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,EAAE,OAAO,GAAE,eAAoB;;;8BAavD,MAAM,KAAG,OAAO,CAAC,MAAM,CAAC;EAEzD"}
@@ -0,0 +1,29 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import remarkDirective from 'remark-directive';
5
+ import remarkRehype from 'remark-rehype';
6
+ import rehypeRaw from 'rehype-raw';
7
+ import rehypeSlug from 'rehype-slug';
8
+ import rehypeStringify from 'rehype-stringify';
9
+ import { remarkDirectiveStamp } from './remark-directives';
10
+ import { rehypeDispatch } from './rehype-dispatch';
11
+ /** Compose a site's render pipeline from its component registry: directive syntax →
12
+ * stamped markers → registry-built hast. Returns `renderMarkdown` plus the remark/
13
+ * rehype plugin arrays (so the Carta editor preview can reuse the exact same set). */
14
+ export function createRenderer(registry, options = {}) {
15
+ const remarkPlugins = [remarkDirective, [remarkDirectiveStamp, registry]];
16
+ const rehypePlugins = [rehypeRaw, [rehypeDispatch, registry, options.rise], rehypeSlug];
17
+ const processor = unified()
18
+ .use(remarkParse)
19
+ .use(remarkGfm)
20
+ .use(remarkPlugins)
21
+ .use(remarkRehype, { allowDangerousHtml: true })
22
+ .use(rehypePlugins)
23
+ .use(rehypeStringify);
24
+ return {
25
+ remarkPlugins,
26
+ rehypePlugins,
27
+ renderMarkdown: async (content) => String(await processor.process(content)),
28
+ };
29
+ }
@@ -0,0 +1,28 @@
1
+ import type { Element } from 'hast';
2
+ /** A site component: how it inserts (editor) and how it renders (rehype). */
3
+ export interface ComponentDef {
4
+ /** Directive name, e.g. 'card' (matches `:::card`). */
5
+ name: string;
6
+ /** Palette label. */
7
+ label: string;
8
+ /** Palette description. */
9
+ description: string;
10
+ /** Markdown scaffold inserted at the cursor by the editor palette. */
11
+ insertTemplate: string;
12
+ /** Build the final hast element from the stamped directive element. */
13
+ build: (node: Element, rise?: string) => Element;
14
+ /** Optional role→default-icon (e.g. `{ caution: 'warning' }`). */
15
+ defaultIconByRole?: Record<string, string>;
16
+ }
17
+ export interface ComponentRegistry {
18
+ defs: ComponentDef[];
19
+ names: string[];
20
+ get(name: string): ComponentDef | undefined;
21
+ defaultIcon(name: string, role?: string): string | undefined;
22
+ }
23
+ /** Build a registry from a site's component definitions. The single source the
24
+ * render pipeline (directive stamp + rehype dispatch) and the editor palette read. */
25
+ export declare function defineRegistry(input: {
26
+ components: ComponentDef[];
27
+ }): ComponentRegistry;
28
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/lib/render/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,6EAA6E;AAC7E,MAAM,WAAW,YAAY;IAC5B,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAC;IACb,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,cAAc,EAAE,MAAM,CAAC;IACvB,uEAAuE;IACvE,KAAK,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,kEAAkE;IAClE,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,iBAAiB;IACjC,IAAI,EAAE,YAAY,EAAE,CAAC;IACrB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAAC;IAC5C,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAC7D;AAED;uFACuF;AACvF,wBAAgB,cAAc,CAAC,KAAK,EAAE;IAAE,UAAU,EAAE,YAAY,EAAE,CAAA;CAAE,GAAG,iBAAiB,CAQvF"}
@@ -0,0 +1,11 @@
1
+ /** Build a registry from a site's component definitions. The single source the
2
+ * render pipeline (directive stamp + rehype dispatch) and the editor palette read. */
3
+ export function defineRegistry(input) {
4
+ const byName = new Map(input.components.map((c) => [c.name, c]));
5
+ return {
6
+ defs: input.components,
7
+ names: input.components.map((c) => c.name),
8
+ get: (name) => byName.get(name),
9
+ defaultIcon: (name, role) => (role ? byName.get(name)?.defaultIconByRole?.[role] : undefined),
10
+ };
11
+ }
@@ -0,0 +1,24 @@
1
+ import type { Root, Element, ElementContent } from 'hast';
2
+ import type { ComponentRegistry } from './registry';
3
+ export declare function isElement(node: ElementContent | undefined): node is Element;
4
+ export declare function strProp(node: Element, name: string): string | undefined;
5
+ /** Wrap a pre-built glyph in an ec-icon span; secondary role adds the modifier. */
6
+ export declare function iconSpan(glyphEl: Element, role?: string): Element;
7
+ /** A site's icon factory: turn a stamped icon name + role into a hast element. */
8
+ export type MakeIcon = (name: string, role?: string) => Element;
9
+ export declare function splitHead(node: Element, makeIcon?: MakeIcon): {
10
+ head: Element;
11
+ rest: ElementContent[];
12
+ };
13
+ /** Section wrapper: `<section class=…><div class="card-body">…</div></section>`,
14
+ * with an optional inline rise style. */
15
+ export declare function cardShell(classes: string[], rise: string | undefined, body: ElementContent[]): Element;
16
+ /** Tag the first <ul> among children with `ec-grid` and strip its whitespace-only
17
+ * text nodes so the bare list serializes without newlines. Returns that <ul>. */
18
+ export declare function markFirstList(children: ElementContent[]): Element | undefined;
19
+ /** Rehype transformer: dispatch each stamped element through its registry `build`
20
+ * fn. Top-level primitives get a document-order rise stagger when `rise` is
21
+ * supplied (a site's per-index motion formula); nested ones don't. Non-primitive
22
+ * content (lede, intro paragraphs, the page-toc nav) passes through untouched. */
23
+ export declare function rehypeDispatch(registry: ComponentRegistry, rise?: (idx: number) => string): (tree: Root) => void;
24
+ //# sourceMappingURL=rehype-dispatch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rehype-dispatch.d.ts","sourceRoot":"","sources":["../../src/lib/render/rehype-dispatch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,cAAc,EAAc,MAAM,MAAM,CAAC;AAEtE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD,wBAAgB,SAAS,CAAC,IAAI,EAAE,cAAc,GAAG,SAAS,GAAG,IAAI,IAAI,OAAO,CAE3E;AAKD,wBAAgB,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGvE;AAED,mFAAmF;AACnF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAGjE;AAED,kFAAkF;AAClF,MAAM,MAAM,QAAQ,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC;AAMhE,wBAAgB,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,QAAQ,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,cAAc,EAAE,CAAA;CAAE,CAYvG;AAED;0CAC0C;AAC1C,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG,OAAO,CAItG;AAED;kFACkF;AAClF,wBAAgB,aAAa,CAAC,QAAQ,EAAE,cAAc,EAAE,GAAG,OAAO,GAAG,SAAS,CAS7E;AAmBD;;;mFAGmF;AACnF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,IACjF,MAAM,IAAI,UAUlB"}
@@ -0,0 +1,86 @@
1
+ import { h } from 'hastscript';
2
+ export function isElement(node) {
3
+ return !!node && node.type === 'element';
4
+ }
5
+ // hast Properties values are PropertyValue (string | number | boolean | array | null).
6
+ // Directive markers (dataIcon/dataRole/dataPrimitive) are always stamped as strings;
7
+ // this reads them back with that guarantee instead of casting at each call site.
8
+ export function strProp(node, name) {
9
+ const value = node.properties?.[name];
10
+ return typeof value === 'string' ? value : undefined;
11
+ }
12
+ /** Wrap a pre-built glyph in an ec-icon span; secondary role adds the modifier. */
13
+ export function iconSpan(glyphEl, role) {
14
+ const className = role === 'secondary' ? ['ec-icon', 'ec-icon-secondary'] : ['ec-icon'];
15
+ return h('span', { className }, [glyphEl]);
16
+ }
17
+ // Pull the section's <h2> out, retag it .card-title, and build the .ec-head row
18
+ // (optional icon + heading). Returns the head plus the remaining body children.
19
+ // `makeIcon` (site-supplied) turns the stamped data-icon into an element; omit it
20
+ // for a head with no icon.
21
+ export function splitHead(node, makeIcon) {
22
+ const children = node.children;
23
+ const i = children.findIndex((c) => isElement(c) && c.tagName === 'h2');
24
+ const h2 = children[i];
25
+ h2.properties = { ...h2.properties, className: ['card-title'] };
26
+ const rest = children.filter((_, j) => j !== i);
27
+ const icon = strProp(node, 'dataIcon');
28
+ const role = strProp(node, 'dataRole');
29
+ const headKids = [];
30
+ if (makeIcon && icon)
31
+ headKids.push(makeIcon(icon, role));
32
+ headKids.push(h2);
33
+ return { head: h('div', { className: ['ec-head'] }, headKids), rest };
34
+ }
35
+ /** Section wrapper: `<section class=…><div class="card-body">…</div></section>`,
36
+ * with an optional inline rise style. */
37
+ export function cardShell(classes, rise, body) {
38
+ const properties = { className: classes };
39
+ if (rise)
40
+ properties.style = rise;
41
+ return h('section', properties, [h('div', { className: ['card-body'] }, body)]);
42
+ }
43
+ /** Tag the first <ul> among children with `ec-grid` and strip its whitespace-only
44
+ * text nodes so the bare list serializes without newlines. Returns that <ul>. */
45
+ export function markFirstList(children) {
46
+ const ul = children.find((c) => isElement(c) && c.tagName === 'ul');
47
+ if (ul) {
48
+ ul.properties = { ...ul.properties, className: ['ec-grid'] };
49
+ ul.children = ul.children.filter((c) => !(c.type === 'text' && /^\s*$/.test(c.value)));
50
+ }
51
+ return ul;
52
+ }
53
+ // Recurse into a node's children, transforming any nested primitive sections
54
+ // (a grid inside a card, panels inside a split) WITHOUT a rise stagger.
55
+ function transformChildren(children, registry) {
56
+ return children.map((c) => {
57
+ if (isElement(c) && c.properties?.dataPrimitive)
58
+ return transformNode(c, registry);
59
+ if (isElement(c))
60
+ c.children = transformChildren(c.children, registry);
61
+ return c;
62
+ });
63
+ }
64
+ function transformNode(node, registry, rise) {
65
+ node.children = transformChildren(node.children, registry);
66
+ const name = strProp(node, 'dataPrimitive');
67
+ const def = name ? registry.get(name) : undefined;
68
+ return def ? def.build(node, rise) : node;
69
+ }
70
+ /** Rehype transformer: dispatch each stamped element through its registry `build`
71
+ * fn. Top-level primitives get a document-order rise stagger when `rise` is
72
+ * supplied (a site's per-index motion formula); nested ones don't. Non-primitive
73
+ * content (lede, intro paragraphs, the page-toc nav) passes through untouched. */
74
+ export function rehypeDispatch(registry, rise) {
75
+ return (tree) => {
76
+ let idx = 0;
77
+ tree.children = tree.children.map((child) => {
78
+ if (isElement(child) && child.properties?.dataPrimitive) {
79
+ return transformNode(child, registry, rise ? rise(idx++) : undefined);
80
+ }
81
+ if (isElement(child))
82
+ child.children = transformChildren(child.children, registry);
83
+ return child;
84
+ });
85
+ };
86
+ }
@@ -0,0 +1,4 @@
1
+ import type { Root } from 'mdast';
2
+ import type { ComponentRegistry } from './registry';
3
+ export declare function remarkDirectiveStamp(registry: ComponentRegistry): (tree: Root) => void;
4
+ //# sourceMappingURL=remark-directives.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remark-directives.d.ts","sourceRoot":"","sources":["../../src/lib/render/remark-directives.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA8B,IAAI,EAAQ,MAAM,OAAO,CAAC;AAGpE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAmCpD,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,iBAAiB,IAEvD,MAAM,IAAI,UA8BlB"}
@@ -0,0 +1,74 @@
1
+ import { visit } from 'unist-util-visit';
2
+ // Reconstruct a directive's authored attribute block (`{#id .class key="value"}`).
3
+ // Accidental prose directives carry none, so this is almost always empty.
4
+ function serializeAttributes(attributes) {
5
+ if (!attributes)
6
+ return '';
7
+ const tokens = [];
8
+ for (const [key, value] of Object.entries(attributes)) {
9
+ if (value == null)
10
+ tokens.push(key);
11
+ else if (key === 'id')
12
+ tokens.push(`#${value}`);
13
+ else if (key === 'class')
14
+ for (const c of value.split(/\s+/).filter(Boolean))
15
+ tokens.push(`.${c}`);
16
+ else
17
+ tokens.push(`${key}="${value}"`);
18
+ }
19
+ return tokens.length ? `{${tokens.join(' ')}}` : '';
20
+ }
21
+ // The vocabulary is container-only (`:::name`). A text directive (`:name`) or
22
+ // leaf directive (`::name`) is therefore always an accidental colon in prose
23
+ // ("4:00", "9:30", "ratio 16:9") that micromark tokenized as a directive.
24
+ // Restore it to its literal source text so prose renders verbatim.
25
+ function restoreLiteral(node) {
26
+ const marker = node.type === 'leafDirective' ? '::' : ':';
27
+ const attrs = serializeAttributes(node.attributes);
28
+ if (node.children.length === 0) {
29
+ return [{ type: 'text', value: marker + node.name + attrs }];
30
+ }
31
+ const open = { type: 'text', value: `${marker}${node.name}[` };
32
+ const close = { type: 'text', value: `]${attrs}` };
33
+ return [open, ...node.children, close];
34
+ }
35
+ // Stamp each registered container directive with data-* markers carrying its
36
+ // component name, icon, and role. No structure is built here; the rehype
37
+ // dispatcher rewrites the marked elements once their children are hast.
38
+ // Text and leaf directives are restored to literal text (accidental prose colons).
39
+ export function remarkDirectiveStamp(registry) {
40
+ const known = new Set(registry.names);
41
+ return (tree) => {
42
+ visit(tree, 'containerDirective', (node) => {
43
+ if (!known.has(node.name))
44
+ return;
45
+ const attrs = node.attributes ?? {};
46
+ const role = attrs.role || undefined;
47
+ let icon = attrs.icon || undefined;
48
+ if (!icon && role)
49
+ icon = registry.defaultIcon(node.name, role);
50
+ const properties = { dataPrimitive: node.name };
51
+ if (icon)
52
+ properties.dataIcon = icon;
53
+ if (role)
54
+ properties.dataRole = role;
55
+ const data = node.data ?? (node.data = {});
56
+ data.hName = 'div';
57
+ data.hProperties = properties;
58
+ });
59
+ visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
60
+ if (!parent || index == null)
61
+ return;
62
+ const literal = restoreLiteral(node);
63
+ if (node.type === 'leafDirective') {
64
+ // Leaf directives sit at block level; wrap the restored text in a paragraph.
65
+ const paragraph = { type: 'paragraph', children: literal };
66
+ parent.children.splice(index, 1, paragraph);
67
+ }
68
+ else {
69
+ parent.children.splice(index, 1, ...literal);
70
+ }
71
+ return index;
72
+ });
73
+ };
74
+ }
package/dist/slug.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Lowercase a title into a filename-safe slug stem.
3
+ * Apostrophes are dropped so "Geoff's" becomes "geoffs" (no spurious hyphen).
4
+ * All other non-alphanumeric runs become a single hyphen; leading/trailing hyphens are trimmed.
5
+ */
6
+ export declare function slugify(title: string): string;
7
+ //# sourceMappingURL=slug.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slug.d.ts","sourceRoot":"","sources":["../src/lib/slug.ts"],"names":[],"mappings":"AAIA;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAM7C"}
package/dist/slug.js ADDED
@@ -0,0 +1,15 @@
1
+ // cairn-core: derive a filename-safe slug stem from a human title, for the create-entry form.
2
+ // The admin is filename-based (Pass E): this produces the editable stem an author can adjust,
3
+ // matching the server-side SLUG_RE (lowercase alphanumerics and internal hyphens). Pure.
4
+ /**
5
+ * Lowercase a title into a filename-safe slug stem.
6
+ * Apostrophes are dropped so "Geoff's" becomes "geoffs" (no spurious hyphen).
7
+ * All other non-alphanumeric runs become a single hyphen; leading/trailing hyphens are trimmed.
8
+ */
9
+ export function slugify(title) {
10
+ return title
11
+ .toLowerCase()
12
+ .replace(/'/g, '')
13
+ .replace(/[^a-z0-9]+/g, '-')
14
+ .replace(/^-+|-+$/g, '');
15
+ }