@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.
- package/README.md +4 -4
- package/dist/adapter.d.ts +10 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/auth/config.d.ts +9 -9
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/config.js +5 -5
- package/dist/auth/guard.d.ts +1 -1
- package/dist/auth/guard.d.ts.map +1 -1
- package/dist/auth/guard.js +2 -2
- package/dist/carta.d.ts +1 -1
- package/dist/carta.d.ts.map +1 -1
- package/dist/components/AdminLayout.svelte +3 -3
- package/dist/components/AdminList.svelte +1 -1
- package/dist/components/ConfirmPage.svelte +2 -2
- package/dist/components/EditPage.svelte +5 -5
- package/dist/components/LoginPage.svelte +5 -5
- package/dist/email.js +4 -4
- package/dist/github.d.ts +22 -2
- package/dist/github.d.ts.map +1 -1
- package/dist/github.js +40 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/render/glyph.d.ts +6 -0
- package/dist/render/glyph.d.ts.map +1 -0
- package/dist/render/glyph.js +5 -0
- package/dist/render/index.d.ts +6 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +8 -0
- package/dist/render/pipeline.d.ts +16 -0
- package/dist/render/pipeline.d.ts.map +1 -0
- package/dist/render/pipeline.js +29 -0
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.d.ts.map +1 -0
- package/dist/render/registry.js +11 -0
- package/dist/render/rehype-dispatch.d.ts +24 -0
- package/dist/render/rehype-dispatch.d.ts.map +1 -0
- package/dist/render/rehype-dispatch.js +86 -0
- package/dist/render/remark-directives.d.ts +4 -0
- package/dist/render/remark-directives.d.ts.map +1 -0
- package/dist/render/remark-directives.js +74 -0
- package/dist/sveltekit/index.d.ts +17 -2
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +33 -6
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +2 -2
- package/package.json +15 -3
- package/src/lib/adapter.ts +12 -3
- package/src/lib/auth/config.ts +6 -6
- package/src/lib/auth/guard.ts +3 -3
- package/src/lib/carta.ts +2 -2
- package/src/lib/components/AdminLayout.svelte +3 -3
- package/src/lib/components/AdminList.svelte +1 -1
- package/src/lib/components/ConfirmPage.svelte +2 -2
- package/src/lib/components/EditPage.svelte +5 -5
- package/src/lib/components/LoginPage.svelte +5 -5
- package/src/lib/email.ts +4 -4
- package/src/lib/github.ts +38 -6
- package/src/lib/index.ts +1 -0
- package/src/lib/render/glyph.ts +14 -0
- package/src/lib/render/index.ts +8 -0
- package/src/lib/render/pipeline.ts +37 -0
- package/src/lib/render/registry.ts +36 -0
- package/src/lib/render/rehype-dispatch.ts +97 -0
- package/src/lib/render/remark-directives.ts +71 -0
- package/src/lib/sveltekit/index.ts +54 -13
- 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
|
|
4
|
-
// on the allowlist
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
4
|
-
// recipients)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
6
|
-
// dependency
|
|
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
|
|
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
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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)
|
|
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(/=+$/, '');
|