@glw907/cairn-cms 0.4.0 → 0.5.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 (68) hide show
  1. package/README.md +4 -4
  2. package/dist/adapter.d.ts +10 -1
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/auth/config.d.ts +9 -9
  5. package/dist/auth/config.d.ts.map +1 -1
  6. package/dist/auth/config.js +5 -5
  7. package/dist/auth/guard.d.ts +1 -1
  8. package/dist/auth/guard.d.ts.map +1 -1
  9. package/dist/auth/guard.js +2 -2
  10. package/dist/carta.d.ts +1 -1
  11. package/dist/carta.d.ts.map +1 -1
  12. package/dist/components/AdminLayout.svelte +3 -3
  13. package/dist/components/AdminList.svelte +1 -1
  14. package/dist/components/ConfirmPage.svelte +2 -2
  15. package/dist/components/EditPage.svelte +5 -5
  16. package/dist/components/LoginPage.svelte +5 -5
  17. package/dist/email.js +4 -4
  18. package/dist/github.d.ts +22 -2
  19. package/dist/github.d.ts.map +1 -1
  20. package/dist/github.js +40 -5
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/render/glyph.d.ts +6 -0
  25. package/dist/render/glyph.d.ts.map +1 -0
  26. package/dist/render/glyph.js +5 -0
  27. package/dist/render/index.d.ts +6 -0
  28. package/dist/render/index.d.ts.map +1 -0
  29. package/dist/render/index.js +8 -0
  30. package/dist/render/pipeline.d.ts +16 -0
  31. package/dist/render/pipeline.d.ts.map +1 -0
  32. package/dist/render/pipeline.js +29 -0
  33. package/dist/render/registry.d.ts +28 -0
  34. package/dist/render/registry.d.ts.map +1 -0
  35. package/dist/render/registry.js +11 -0
  36. package/dist/render/rehype-dispatch.d.ts +24 -0
  37. package/dist/render/rehype-dispatch.d.ts.map +1 -0
  38. package/dist/render/rehype-dispatch.js +86 -0
  39. package/dist/render/remark-directives.d.ts +4 -0
  40. package/dist/render/remark-directives.d.ts.map +1 -0
  41. package/dist/render/remark-directives.js +74 -0
  42. package/dist/sveltekit/index.d.ts +17 -2
  43. package/dist/sveltekit/index.d.ts.map +1 -1
  44. package/dist/sveltekit/index.js +33 -6
  45. package/dist/utils.d.ts +1 -1
  46. package/dist/utils.d.ts.map +1 -1
  47. package/dist/utils.js +2 -2
  48. package/package.json +15 -3
  49. package/src/lib/adapter.ts +12 -3
  50. package/src/lib/auth/config.ts +6 -6
  51. package/src/lib/auth/guard.ts +3 -3
  52. package/src/lib/carta.ts +2 -2
  53. package/src/lib/components/AdminLayout.svelte +3 -3
  54. package/src/lib/components/AdminList.svelte +1 -1
  55. package/src/lib/components/ConfirmPage.svelte +2 -2
  56. package/src/lib/components/EditPage.svelte +5 -5
  57. package/src/lib/components/LoginPage.svelte +5 -5
  58. package/src/lib/email.ts +4 -4
  59. package/src/lib/github.ts +38 -6
  60. package/src/lib/index.ts +1 -0
  61. package/src/lib/render/glyph.ts +14 -0
  62. package/src/lib/render/index.ts +8 -0
  63. package/src/lib/render/pipeline.ts +37 -0
  64. package/src/lib/render/registry.ts +36 -0
  65. package/src/lib/render/rehype-dispatch.ts +97 -0
  66. package/src/lib/render/remark-directives.ts +71 -0
  67. package/src/lib/sveltekit/index.ts +54 -13
  68. package/src/lib/utils.ts +2 -2
@@ -1,13 +1,13 @@
1
1
  <script lang="ts">
2
2
  // The magic-link sign-in page. Requests a link via the better-auth client (client-side, same
3
- // origin). To avoid enumeration the UI shows the SAME neutral copy whether or not the email is
4
- // on the allowlist the server only emails actual editors (see auth/config.ts send gate).
3
+ // origin). To avoid enumeration the UI shows the same neutral copy whether or not the email is
4
+ // on the allowlist. The server only emails actual editors (see auth/config.ts send gate).
5
5
  import { createAuthClient } from 'better-auth/svelte';
6
6
  import { magicLinkClient } from 'better-auth/client/plugins';
7
7
 
8
8
  // The browser client lives in the one component that needs it (requesting a link). Sign-out
9
- // and editor management go through server endpoints, so no shared client module is needed
10
- // and a component-local const keeps better-auth's deep client types out of the packaged .d.ts.
9
+ // and editor management go through server endpoints, so no shared client module is needed.
10
+ // A component-local const keeps better-auth's deep client types out of the packaged .d.ts.
11
11
  const authClient = createAuthClient({ plugins: [magicLinkClient()] });
12
12
 
13
13
  interface Props {
@@ -23,7 +23,7 @@
23
23
  event.preventDefault();
24
24
  busy = true;
25
25
  // The magic-link email points at our /admin/auth/confirm page (built in config.ts), not a
26
- // GET-verify URL so the result is the same regardless of allowlist membership.
26
+ // GET-verify URL, so the result is the same regardless of allowlist membership.
27
27
  await authClient.signIn.magicLink({ email });
28
28
  busy = false;
29
29
  requested = true;
package/src/lib/email.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // cairn-core: pluggable magic-link email sender.
2
2
  //
3
- // Default adapter is Cloudflare Email Service Email Sending (transactional, arbitrary
4
- // recipients) distinct from Email Routing's recipient-restricted `EmailMessage` flow.
5
- // It is reached through the same `send_email` binding (configured without a
6
- // destination_address) but a different call shape: `binding.send({ to, from, ... })`.
3
+ // The default adapter targets Cloudflare Email Service (Email Sending, transactional,
4
+ // arbitrary recipients), distinct from Email Routing's recipient-restricted `EmailMessage`
5
+ // flow. Both share the same `send_email` binding (configured without a destination_address)
6
+ // but use a different call shape: `binding.send({ to, from, ... })`.
7
7
  // Resend can slot in behind the same `sendMagicLink` signature if needed.
8
8
 
9
9
  /** Cloudflare Email Sending binding surface (the object-form `send`, not the MIME form). */
package/src/lib/github.ts 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
 
@@ -90,7 +90,7 @@ function derLength(n: number): number[] {
90
90
  // AlgorithmIdentifier for rsaEncryption (OID 1.2.840.113549.1.1.1) with NULL parameters.
91
91
  const RSA_ALG_ID = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00];
92
92
 
93
- /** Wrap a PKCS#1 RSAPrivateKey (DER) as PKCS#8 the only RSA form Web Crypto importKey takes. */
93
+ /** Wrap a PKCS#1 RSAPrivateKey (DER) as PKCS#8 (the only RSA form Web Crypto importKey takes). */
94
94
  function pkcs1ToPkcs8(pkcs1: Uint8Array): Uint8Array {
95
95
  const octet = [0x04, ...derLength(pkcs1.length), ...pkcs1];
96
96
  const body = [0x02, 0x01, 0x00, ...RSA_ALG_ID, ...octet];
@@ -124,7 +124,7 @@ export async function appJwt(appId: string, privateKeyPem: string): Promise<stri
124
124
  export interface AppCredentials {
125
125
  appId: string;
126
126
  installationId: string;
127
- /** The stored GITHUB_APP_PRIVATE_KEY_B64 base64 of the PEM, single line. */
127
+ /** The stored GITHUB_APP_PRIVATE_KEY_B64: base64 of the PEM, single line. */
128
128
  privateKeyB64: string;
129
129
  }
130
130
 
@@ -139,7 +139,7 @@ export async function installationToken(creds: AppCredentials): Promise<string>
139
139
  return ((await res.json()) as { token: string }).token;
140
140
  }
141
141
 
142
- /** Standard (padded) base64 of UTF-8 text the encoding the contents API expects. */
142
+ /** Standard (padded) base64 of UTF-8 text, as the contents API expects. */
143
143
  function toBase64(text: string): string {
144
144
  return btoa(Array.from(encoder.encode(text), (b) => String.fromCharCode(b)).join(''));
145
145
  }
@@ -157,11 +157,24 @@ export interface CommitAuthor {
157
157
  email: string;
158
158
  }
159
159
 
160
+ /**
161
+ * A concurrent edit lost the SHA race (C3): the file changed between the read and the PUT,
162
+ * from another editor or the site's own CI. Thrown so callers can fail safe (re-fetch and ask
163
+ * the editor to reapply) instead of surfacing a raw 409. Defined and caught inside the package
164
+ * so `instanceof` is reliable (no peer-boundary identity split, unlike kit's `redirect`/`error`).
165
+ */
166
+ export class CommitConflictError extends Error {
167
+ constructor(public readonly path: string) {
168
+ super(`Commit conflict on ${path}: it changed since it was opened`);
169
+ this.name = 'CommitConflictError';
170
+ }
171
+ }
172
+
160
173
  /**
161
174
  * Commit `content` to `path` on the configured branch via the contents API. Author is the
162
175
  * editor; committer is omitted so GitHub attributes it to the App (cairn-cms[bot]). Updates
163
176
  * the file in place when it exists (passing its sha), creates it otherwise. Returns the
164
- * commit sha.
177
+ * commit sha. A stale-sha 409 (someone committed in between) becomes a `CommitConflictError`.
165
178
  */
166
179
  export async function commitFile(
167
180
  repo: RepoRef,
@@ -183,6 +196,25 @@ export async function commitFile(
183
196
  ...(sha ? { sha } : {}),
184
197
  }),
185
198
  });
199
+ // 409 = the blob sha we read is no longer current. Fail safe: the caller re-fetches and the
200
+ // editor reapplies. (Full three-way merge stays out of scope; see ARCHITECTURE §5.)
201
+ if (res.status === 409) throw new CommitConflictError(path);
186
202
  if (!res.ok) throw new Error(`GitHub commit ${path} failed: ${res.status} ${await res.text()}`);
187
203
  return ((await res.json()) as { commit: { sha: string } }).commit.sha;
188
204
  }
205
+
206
+ /**
207
+ * Deploy-time self-test for the GitHub App signer (M2): sign a dummy JWT with the configured
208
+ * private key. Exercises the brittle PKCS#1→PKCS#8 conversion + Web Crypto import/sign without
209
+ * any network call or secret in the result, so `/admin/healthz` catches a bad/rotated key
210
+ * before an editor's save fails. Returns `{ ok: false, detail }` rather than throwing.
211
+ */
212
+ export async function signingSelfTest(appId: string, privateKeyB64: string): Promise<{ ok: boolean; detail?: string }> {
213
+ try {
214
+ const jwt = await appJwt(appId, atob(privateKeyB64));
215
+ if (jwt.split('.').length !== 3) return { ok: false, detail: 'malformed JWT' };
216
+ return { ok: true };
217
+ } catch (err) {
218
+ return { ok: false, detail: err instanceof Error ? err.message : 'sign failed' };
219
+ }
220
+ }
package/src/lib/index.ts CHANGED
@@ -5,3 +5,4 @@ export * from './github';
5
5
  export * from './carta';
6
6
  export * from './content';
7
7
  export * from './adapter';
8
+ export * from './render';
@@ -0,0 +1,14 @@
1
+ import { s } from 'hastscript';
2
+ import type { Element } from 'hast';
3
+
4
+ /** A glyph name → SVG path-data map (the site owns the icon set). */
5
+ export type IconSet = Record<string, string>;
6
+
7
+ /** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill. */
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
+ );
14
+ }
@@ -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,37 @@
1
+ import { unified, type PluggableList } 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
+ import type { ComponentRegistry } from './registry';
12
+
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;
17
+ }
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/
21
+ * rehype plugin arrays (so the Carta editor preview can reuse the exact same set). */
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
+ };
37
+ }
@@ -0,0 +1,36 @@
1
+ import type { Element } from 'hast';
2
+
3
+ /** A site component: how it inserts (editor) and how it renders (rehype). */
4
+ 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 role→default-icon (e.g. `{ caution: 'warning' }`). */
16
+ defaultIconByRole?: Record<string, string>;
17
+ }
18
+
19
+ export interface ComponentRegistry {
20
+ defs: ComponentDef[];
21
+ names: string[];
22
+ get(name: string): ComponentDef | undefined;
23
+ defaultIcon(name: string, role?: string): string | undefined;
24
+ }
25
+
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
+ };
36
+ }
@@ -0,0 +1,97 @@
1
+ import type { Root, Element, ElementContent, Properties } from 'hast';
2
+ import { h } from 'hastscript';
3
+ import type { ComponentRegistry } from './registry';
4
+
5
+ export function isElement(node: ElementContent | undefined): node is Element {
6
+ return !!node && node.type === 'element';
7
+ }
8
+
9
+ // hast Properties values are PropertyValue (string | number | boolean | array | null).
10
+ // Directive markers (dataIcon/dataRole/dataPrimitive) are always stamped as strings;
11
+ // this reads them back with that guarantee instead of casting at each call site.
12
+ export function strProp(node: Element, name: string): string | undefined {
13
+ const value = node.properties?.[name];
14
+ return typeof value === 'string' ? value : undefined;
15
+ }
16
+
17
+ /** Wrap a pre-built glyph in an ec-icon span; secondary role adds the modifier. */
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]);
21
+ }
22
+
23
+ /** A site's icon factory: turn a stamped icon name + role into a hast element. */
24
+ export type MakeIcon = (name: string, role?: string) => Element;
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
+ /** Section wrapper: `<section class=…><div class="card-body">…</div></section>`,
45
+ * with an optional inline rise style. */
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)]);
50
+ }
51
+
52
+ /** Tag the first <ul> among children with `ec-grid` and strip its whitespace-only
53
+ * text nodes so the bare list serializes without newlines. Returns that <ul>. */
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;
63
+ }
64
+
65
+ // Recurse into a node's children, transforming any nested primitive sections
66
+ // (a grid inside a card, panels inside a split) WITHOUT a rise stagger.
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
+ });
73
+ }
74
+
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;
80
+ }
81
+
82
+ /** Rehype transformer: dispatch each stamped element through its registry `build`
83
+ * fn. Top-level primitives get a document-order rise stagger when `rise` is
84
+ * supplied (a site's per-index motion formula); nested ones don't. Non-primitive
85
+ * content (lede, intro paragraphs, the page-toc nav) passes through untouched. */
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
+ };
97
+ }
@@ -0,0 +1,71 @@
1
+ import type { Paragraph, PhrasingContent, Root, Text } from 'mdast';
2
+ import type { ContainerDirective, LeafDirective, TextDirective } from 'mdast-util-directive';
3
+ import { visit } from 'unist-util-visit';
4
+ import type { ComponentRegistry } from './registry';
5
+
6
+ // Reconstruct a directive's authored attribute block (`{#id .class key="value"}`).
7
+ // Accidental prose directives carry none, so this is almost always empty.
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(' ')}}` : '';
18
+ }
19
+
20
+ // The vocabulary is container-only (`:::name`). A text directive (`:name`) or
21
+ // leaf directive (`::name`) is therefore always an accidental colon in prose
22
+ // ("4:00", "9:30", "ratio 16:9") that micromark tokenized as a directive.
23
+ // Restore it to its literal source text so prose renders verbatim.
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];
33
+ }
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: 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);
48
+
49
+ const properties: Record<string, string> = { dataPrimitive: node.name };
50
+ if (icon) properties.dataIcon = icon;
51
+ if (role) properties.dataRole = role;
52
+
53
+ const data = node.data ?? (node.data = {});
54
+ data.hName = 'div';
55
+ data.hProperties = properties;
56
+ });
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
+ };
71
+ }
@@ -2,20 +2,28 @@
2
2
  // route files are thin shims (`export const load = (event) => editLoad(event, cairn)`).
3
3
  //
4
4
  // SvelteKit's filesystem routing requires the route *files* to live in each site's
5
- // `src/routes/`, but their bodies are identical across sites only the adapter differs.
5
+ // `src/routes/`, but their bodies are identical across sites. Only the adapter differs.
6
6
  // These functions take the SvelteKit event (typed structurally, to avoid depending on the
7
7
  // site-generated `App.*` ambient types) plus the site `CairnAdapter`, and throw
8
8
  // `redirect`/`error` from `@sveltejs/kit` (a peer dependency, so the thrown objects share
9
- // class identity with the host's runtime else the redirect 500s). Auth/session/manage-editors
9
+ // class identity with the host's runtime; otherwise the redirect 500s). Auth/session/manage-editors
10
10
  // logic lives under `@glw907/cairn-cms/auth`; this module is content-only (list/edit/save).
11
11
  import { redirect, error } from '@sveltejs/kit';
12
12
  import matter from 'gray-matter';
13
13
  import type { CairnUser } from '../auth/guard';
14
- import { listMarkdown, readRaw, commitFile, installationToken, type RepoFile } from '../github';
14
+ import {
15
+ listMarkdown,
16
+ readRaw,
17
+ commitFile,
18
+ installationToken,
19
+ signingSelfTest,
20
+ CommitConflictError,
21
+ type RepoFile,
22
+ } from '../github';
15
23
  import { serializeMarkdown } from '../content';
16
24
  import { findCollection, frontmatterFromForm, type CairnAdapter, type CairnField } from '../adapter';
17
25
 
18
- /** The `platform.env` bindings the content routes read. All optional the handlers guard. */
26
+ /** The `platform.env` bindings the content routes read. All optional; the handlers guard. */
19
27
  export interface AdminEnv {
20
28
  GITHUB_APP_ID?: string;
21
29
  GITHUB_APP_INSTALLATION_ID?: string;
@@ -30,7 +38,7 @@ interface PlatformEvent {
30
38
  * Mint a GitHub App installation token for *reads* when the App is configured, else undefined
31
39
  * (reads then fall back to anonymous). Authenticated reads get the 5000/hr limit; anonymous
32
40
  * reads share GitHub's 60/hr-per-IP budget across Cloudflare's egress IPs, so they 403 in prod.
33
- * A mint failure degrades gracefully to anonymous rather than 500ing unlike the commit path,
41
+ * A mint failure degrades gracefully to anonymous rather than 500ing. Unlike the commit path,
34
42
  * where a missing App is fatal, a read can still succeed unauthenticated.
35
43
  */
36
44
  async function readToken(env: AdminEnv | undefined): Promise<string | undefined> {
@@ -59,7 +67,7 @@ export interface AdminLayoutData {
59
67
 
60
68
  /**
61
69
  * Branding + session for every admin page. `siteName` flows from the adapter without pulling
62
- * its plugin graph into client bundles the import stays server-side in the layout load.
70
+ * its plugin graph into client bundles; the import stays server-side in the layout load.
63
71
  * `pathname` lets the shared shell highlight the active nav item without a `$app/*` import
64
72
  * (those kit virtual modules have no types outside a kit app, so they can't live in the
65
73
  * package); reading `event.url` here also opts the layout load into rerunning on navigation.
@@ -182,13 +190,46 @@ export async function saveCommit(
182
190
  privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
183
191
  });
184
192
 
185
- await commitFile(
186
- adapter.backend,
187
- `${collection.dir}/${id}.md`,
188
- markdown,
189
- { message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: user.name, email: user.email } },
190
- token,
191
- );
193
+ try {
194
+ await commitFile(
195
+ adapter.backend,
196
+ `${collection.dir}/${id}.md`,
197
+ markdown,
198
+ { message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: user.name, email: user.email } },
199
+ token,
200
+ );
201
+ } catch (err) {
202
+ // Concurrent-edit 409 (C3): fail safe. Bounce back with a reload prompt; the editor reloads
203
+ // the current version and reapplies. Any other error is unexpected, so rethrow.
204
+ if (err instanceof CommitConflictError) {
205
+ const message = 'This file changed since you opened it. Reload and reapply your edits.';
206
+ throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}`);
207
+ }
208
+ throw err;
209
+ }
192
210
 
193
211
  throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
194
212
  }
213
+
214
+ // ── /admin/healthz (GET) ──────────────────────────────────────────────────────
215
+
216
+ export interface HealthData {
217
+ ok: boolean;
218
+ checks: { githubAppSigning: { ok: boolean; detail?: string } };
219
+ }
220
+
221
+ /**
222
+ * Deploy-time health check (M2): signs a dummy App JWT to prove the GitHub App key loads and
223
+ * the PKCS#1→PKCS#8 conversion still works, before an editor hits it on save. Behind the
224
+ * `/admin` guard (signed-in editors only); returns ok/fail with no secret in the body.
225
+ */
226
+ export async function healthLoad(event: PlatformEvent): Promise<HealthData> {
227
+ const env = event.platform?.env;
228
+ let githubAppSigning: { ok: boolean; detail?: string };
229
+ if (env?.GITHUB_APP_ID && env.GITHUB_APP_PRIVATE_KEY_B64) {
230
+ githubAppSigning = await signingSelfTest(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY_B64);
231
+ } else {
232
+ githubAppSigning = { ok: false, detail: 'GitHub App not configured' };
233
+ }
234
+ return { ok: githubAppSigning.ok, checks: { githubAppSigning } };
235
+ }
package/src/lib/utils.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  // cairn-core: internal encoding helpers shared across modules.
2
2
  //
3
- // Deliberately NOT re-exported from index.ts these are implementation details of the
3
+ // Deliberately NOT re-exported from index.ts. These are implementation details of the
4
4
  // auth/github crypto, not part of the public API (auth.ts signs tokens, github.ts builds
5
5
  // the App JWT; both need base64url). Keeping them here stops bytesToB64url leaking through
6
6
  // the `export *` barrel.
7
7
 
8
- /** Encode bytes as unpadded base64url (RFC 4648 §5) the JWT/token wire format. */
8
+ /** Encode bytes as unpadded base64url (RFC 4648 §5), the JWT/token wire format. */
9
9
  export function bytesToB64url(bytes: Uint8Array): string {
10
10
  const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join('');
11
11
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');