@glw907/cairn-cms 0.11.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/crypto.d.ts +8 -2
- package/dist/auth/crypto.d.ts.map +1 -1
- package/dist/auth/crypto.js +12 -2
- package/dist/auth/store.d.ts +2 -0
- package/dist/auth/store.d.ts.map +1 -1
- package/dist/auth/store.js +17 -5
- package/dist/components/ComponentForm.svelte +33 -10
- package/dist/components/ComponentForm.svelte.d.ts.map +1 -1
- package/dist/components/EditPage.svelte +4 -6
- package/dist/components/EditPage.svelte.d.ts +1 -1
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/IconPicker.svelte +53 -7
- package/dist/components/IconPicker.svelte.d.ts +7 -3
- package/dist/components/IconPicker.svelte.d.ts.map +1 -1
- package/dist/content/adapter.d.ts +4 -0
- package/dist/content/adapter.d.ts.map +1 -0
- package/dist/content/adapter.js +4 -0
- package/dist/content/concepts.js +2 -2
- package/dist/content/schema.d.ts +75 -0
- package/dist/content/schema.d.ts.map +1 -0
- package/dist/content/schema.js +72 -0
- package/dist/content/types.d.ts +30 -7
- package/dist/content/types.d.ts.map +1 -1
- package/dist/content/validate.d.ts +5 -3
- package/dist/content/validate.d.ts.map +1 -1
- package/dist/content/validate.js +14 -7
- package/dist/delivery/content-index.d.ts +8 -0
- package/dist/delivery/content-index.d.ts.map +1 -1
- package/dist/delivery/content-index.js +23 -12
- package/dist/delivery/feeds.d.ts +1 -1
- package/dist/delivery/feeds.d.ts.map +1 -1
- package/dist/delivery/feeds.js +31 -16
- package/dist/delivery/index.d.ts +5 -1
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +2 -0
- package/dist/delivery/seo-fields.d.ts +22 -0
- package/dist/delivery/seo-fields.d.ts.map +1 -0
- package/dist/delivery/seo-fields.js +32 -0
- package/dist/delivery/site-index.d.ts +2 -2
- package/dist/delivery/site-index.d.ts.map +1 -1
- package/dist/delivery/site-index.js +16 -18
- package/dist/delivery/site-indexes.d.ts +26 -0
- package/dist/delivery/site-indexes.d.ts.map +1 -0
- package/dist/delivery/site-indexes.js +30 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +14 -0
- package/dist/github/signing.d.ts +12 -0
- package/dist/github/signing.d.ts.map +1 -1
- package/dist/github/signing.js +22 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/render/component-grammar.d.ts +7 -0
- package/dist/render/component-grammar.d.ts.map +1 -1
- package/dist/render/component-grammar.js +27 -8
- package/dist/render/component-validate.js +3 -3
- package/dist/render/glyph.d.ts +4 -1
- package/dist/render/glyph.d.ts.map +1 -1
- package/dist/render/glyph.js +6 -2
- package/dist/render/pipeline.d.ts +10 -0
- package/dist/render/pipeline.d.ts.map +1 -1
- package/dist/render/pipeline.js +15 -1
- package/dist/render/registry.d.ts +23 -5
- package/dist/render/registry.d.ts.map +1 -1
- package/dist/render/registry.js +6 -0
- package/dist/render/rehype-dispatch.d.ts +1 -5
- package/dist/render/rehype-dispatch.d.ts.map +1 -1
- package/dist/render/rehype-dispatch.js +71 -19
- package/dist/render/remark-directives.d.ts +1 -1
- package/dist/render/remark-directives.d.ts.map +1 -1
- package/dist/render/remark-directives.js +37 -0
- package/dist/render/sanitize-schema.d.ts +20 -0
- package/dist/render/sanitize-schema.d.ts.map +1 -0
- package/dist/render/sanitize-schema.js +48 -0
- package/dist/sveltekit/auth-routes.d.ts.map +1 -1
- package/dist/sveltekit/auth-routes.js +29 -11
- package/dist/sveltekit/content-routes.js +2 -2
- package/dist/sveltekit/guard.d.ts +1 -1
- package/dist/sveltekit/guard.d.ts.map +1 -1
- package/dist/sveltekit/guard.js +25 -10
- package/dist/sveltekit/nav-routes.js +2 -2
- package/dist/sveltekit/public-routes.d.ts +3 -0
- package/dist/sveltekit/public-routes.d.ts.map +1 -1
- package/dist/sveltekit/public-routes.js +10 -3
- package/dist/sveltekit/types.d.ts +6 -0
- package/dist/sveltekit/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/lib/auth/crypto.ts +14 -2
- package/src/lib/auth/store.ts +18 -5
- package/src/lib/components/ComponentForm.svelte +33 -10
- package/src/lib/components/EditPage.svelte +4 -6
- package/src/lib/components/IconPicker.svelte +53 -7
- package/src/lib/content/adapter.ts +10 -0
- package/src/lib/content/concepts.ts +2 -2
- package/src/lib/content/schema.ts +133 -0
- package/src/lib/content/types.ts +30 -7
- package/src/lib/content/validate.ts +10 -7
- package/src/lib/delivery/content-index.ts +32 -12
- package/src/lib/delivery/feeds.ts +34 -19
- package/src/lib/delivery/index.ts +5 -1
- package/src/lib/delivery/seo-fields.ts +43 -0
- package/src/lib/delivery/site-index.ts +15 -16
- package/src/lib/delivery/site-indexes.ts +64 -0
- package/src/lib/env.ts +13 -0
- package/src/lib/github/signing.ts +32 -0
- package/src/lib/index.ts +8 -2
- package/src/lib/render/component-grammar.ts +34 -10
- package/src/lib/render/component-validate.ts +3 -3
- package/src/lib/render/glyph.ts +6 -2
- package/src/lib/render/pipeline.ts +25 -1
- package/src/lib/render/registry.ts +27 -5
- package/src/lib/render/rehype-dispatch.ts +67 -20
- package/src/lib/render/remark-directives.ts +39 -1
- package/src/lib/render/sanitize-schema.ts +57 -0
- package/src/lib/sveltekit/auth-routes.ts +30 -11
- package/src/lib/sveltekit/content-routes.ts +2 -2
- package/src/lib/sveltekit/guard.ts +25 -10
- package/src/lib/sveltekit/nav-routes.ts +2 -2
- package/src/lib/sveltekit/public-routes.ts +13 -3
- package/src/lib/sveltekit/types.ts +5 -1
- package/dist/render/sanitize.d.ts +0 -8
- package/dist/render/sanitize.d.ts.map +0 -1
- package/dist/render/sanitize.js +0 -26
- package/src/lib/render/sanitize.ts +0 -27
package/dist/auth/crypto.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The session cookie name. On https the cookie is Secure and takes the __Host- prefix, which
|
|
3
|
+
* binds it to the origin (the browser enforces Secure, Path=/, and no Domain). On local http
|
|
4
|
+
* dev the prefix is dropped, since __Host- requires Secure and the dev cookie cannot set it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function sessionCookieName(secure: boolean): string;
|
|
3
7
|
/** Magic-link tokens live 10 minutes. */
|
|
4
8
|
export declare const TOKEN_TTL_MS: number;
|
|
5
9
|
/** Sessions live 30 days. */
|
|
6
10
|
export declare const SESSION_TTL_MS: number;
|
|
11
|
+
/** A magic link is sent at most once per email per minute, to throttle inbox flooding. */
|
|
12
|
+
export declare const SEND_COOLDOWN_MS: number;
|
|
7
13
|
/** A fresh 256-bit magic-link token, url-safe. */
|
|
8
14
|
export declare function generateToken(): string;
|
|
9
15
|
/** A fresh 256-bit session id, url-safe. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../src/lib/auth/crypto.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../src/lib/auth/crypto.ts"],"names":[],"mappings":"AAOA;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAEzD;AAED,yCAAyC;AACzC,eAAO,MAAM,YAAY,QAAiB,CAAC;AAE3C,6BAA6B;AAC7B,eAAO,MAAM,cAAc,QAA2B,CAAC;AAEvD,0FAA0F;AAC1F,eAAO,MAAM,gBAAgB,QAAY,CAAC;AAU1C,kDAAkD;AAClD,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED,4CAA4C;AAC5C,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED,oEAAoE;AACpE,wBAAsB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAI9D"}
|
package/dist/auth/crypto.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
// Token and session-id generation plus SHA-256 token hashing, on Web Crypto so the
|
|
2
2
|
// code runs unchanged in workerd. The store keeps only the hash of a token, never the
|
|
3
3
|
// token itself (spec 7.1).
|
|
4
|
-
/** The session cookie name. */
|
|
5
|
-
|
|
4
|
+
/** The base session cookie name, prefixed with __Host- when the cookie is Secure. */
|
|
5
|
+
const COOKIE_BASE = 'cairn_session';
|
|
6
|
+
/**
|
|
7
|
+
* The session cookie name. On https the cookie is Secure and takes the __Host- prefix, which
|
|
8
|
+
* binds it to the origin (the browser enforces Secure, Path=/, and no Domain). On local http
|
|
9
|
+
* dev the prefix is dropped, since __Host- requires Secure and the dev cookie cannot set it.
|
|
10
|
+
*/
|
|
11
|
+
export function sessionCookieName(secure) {
|
|
12
|
+
return secure ? `__Host-${COOKIE_BASE}` : COOKIE_BASE;
|
|
13
|
+
}
|
|
6
14
|
/** Magic-link tokens live 10 minutes. */
|
|
7
15
|
export const TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
8
16
|
/** Sessions live 30 days. */
|
|
9
17
|
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
18
|
+
/** A magic link is sent at most once per email per minute, to throttle inbox flooding. */
|
|
19
|
+
export const SEND_COOLDOWN_MS = 60 * 1000;
|
|
10
20
|
function randomBase64Url(byteLength = 32) {
|
|
11
21
|
const bytes = new Uint8Array(byteLength);
|
|
12
22
|
crypto.getRandomValues(bytes);
|
package/dist/auth/store.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { Editor, Role } from './types.js';
|
|
|
4
4
|
export declare function findEditor(db: D1Database, email: string): Promise<Editor | null>;
|
|
5
5
|
/** Replace any prior token for this email with a fresh one, atomically. */
|
|
6
6
|
export declare function issueToken(db: D1Database, email: string, tokenHash: string, expiresAt: number, now: number): Promise<void>;
|
|
7
|
+
/** True when a magic-link token for this email was issued at or after `since`, for the send cooldown. */
|
|
8
|
+
export declare function recentlyIssued(db: D1Database, email: string, since: number): Promise<boolean>;
|
|
7
9
|
/**
|
|
8
10
|
* Consume a token in one atomic statement. A returned email means the token was present and
|
|
9
11
|
* unexpired and is now gone, so the link is single-use by construction on strongly-consistent D1.
|
package/dist/auth/store.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/lib/auth/store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAQ/C,yCAAyC;AACzC,wBAAsB,UAAU,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAMtF;AAED,2EAA2E;AAC3E,wBAAsB,UAAU,CAC9B,EAAE,EAAE,UAAU,EACd,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/lib/auth/store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAQ/C,yCAAyC;AACzC,wBAAsB,UAAU,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAMtF;AAED,2EAA2E;AAC3E,wBAAsB,UAAU,CAC9B,EAAE,EAAE,UAAU,EACd,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAQf;AAED,yGAAyG;AACzG,wBAAsB,cAAc,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMnG;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAMzG;AAED,4BAA4B;AAC5B,wBAAsB,aAAa,CACjC,EAAE,EAAE,UAAU,EACd,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAQf;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAUpG;AAED,iCAAiC;AACjC,wBAAsB,aAAa,CAAC,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7E;AAED,2CAA2C;AAC3C,wBAAsB,WAAW,CAAC,EAAE,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAKnE;AAED,sCAAsC;AACtC,wBAAsB,YAAY,CAChC,EAAE,EAAE,UAAU,EACd,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,IAAI,EACV,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,0FAA0F;AAC1F,wBAAsB,YAAY,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM/E;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAe1F;AAED,iFAAiF;AACjF,wBAAsB,aAAa,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAE5F;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU1F"}
|
package/dist/auth/store.js
CHANGED
|
@@ -12,12 +12,21 @@ export async function findEditor(db, email) {
|
|
|
12
12
|
/** Replace any prior token for this email with a fresh one, atomically. */
|
|
13
13
|
export async function issueToken(db, email, tokenHash, expiresAt, now) {
|
|
14
14
|
await db.batch([
|
|
15
|
-
|
|
15
|
+
// Replace this email's prior token, and sweep any expired token while here (no cron needed).
|
|
16
|
+
db.prepare('DELETE FROM magic_token WHERE email = ? OR expires_at <= ?').bind(email, now),
|
|
16
17
|
db
|
|
17
18
|
.prepare('INSERT INTO magic_token (token_hash, email, expires_at, created_at) VALUES (?, ?, ?, ?)')
|
|
18
19
|
.bind(tokenHash, email, expiresAt, now),
|
|
19
20
|
]);
|
|
20
21
|
}
|
|
22
|
+
/** True when a magic-link token for this email was issued at or after `since`, for the send cooldown. */
|
|
23
|
+
export async function recentlyIssued(db, email, since) {
|
|
24
|
+
const row = await db
|
|
25
|
+
.prepare('SELECT 1 AS one FROM magic_token WHERE email = ? AND created_at >= ? LIMIT 1')
|
|
26
|
+
.bind(email, since)
|
|
27
|
+
.first();
|
|
28
|
+
return row != null;
|
|
29
|
+
}
|
|
21
30
|
/**
|
|
22
31
|
* Consume a token in one atomic statement. A returned email means the token was present and
|
|
23
32
|
* unexpired and is now gone, so the link is single-use by construction on strongly-consistent D1.
|
|
@@ -31,10 +40,13 @@ export async function consumeToken(db, tokenHash, now) {
|
|
|
31
40
|
}
|
|
32
41
|
/** Create a session row. */
|
|
33
42
|
export async function createSession(db, id, email, expiresAt, now) {
|
|
34
|
-
await db
|
|
35
|
-
|
|
36
|
-
.
|
|
37
|
-
|
|
43
|
+
await db.batch([
|
|
44
|
+
// Sweep expired sessions on login, so abandoned rows do not accumulate (no cron needed).
|
|
45
|
+
db.prepare('DELETE FROM session WHERE expires_at <= ?').bind(now),
|
|
46
|
+
db
|
|
47
|
+
.prepare('INSERT INTO session (id, email, expires_at, created_at) VALUES (?, ?, ?, ?)')
|
|
48
|
+
.bind(id, email, expiresAt, now),
|
|
49
|
+
]);
|
|
38
50
|
}
|
|
39
51
|
/**
|
|
40
52
|
* Resolve a session to its editor, joining `editor` so the role is read live. An expired
|
|
@@ -38,6 +38,32 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
38
38
|
return Array.isArray(v) ? v : [];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Stable per-item ids run parallel to each repeatable slot's value array, so the {#each} keys by
|
|
42
|
+
// identity instead of index. A mid-list removal then drops the right DOM node and the focused
|
|
43
|
+
// item follows the data. Ids come from a monotonic module-local counter, never Math.random or
|
|
44
|
+
// Date.now. The value arrays in values.slots stay the canonical string lists serializeComponent
|
|
45
|
+
// reads, so the emitted markdown is unchanged. emptyValues seeds every repeatable slot to [], so
|
|
46
|
+
// the id lists start empty and stay in lockstep with the values through addItem/removeItem.
|
|
47
|
+
let nextId = 0;
|
|
48
|
+
const itemIds = $state<Record<string, number[]>>(
|
|
49
|
+
untrack(() => Object.fromEntries((def.slots ?? []).filter((s) => s.kind === 'repeatable').map((s) => [s.name, []]))),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// emptyValues and the itemIds seed both cover every repeatable slot, so this read always hits.
|
|
53
|
+
function slotIds(name: string): number[] {
|
|
54
|
+
return itemIds[name] ?? [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addItem(name: string): void {
|
|
58
|
+
slotItems(name).push('');
|
|
59
|
+
slotIds(name).push(nextId++);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function removeItem(name: string, index: number): void {
|
|
63
|
+
slotItems(name).splice(index, 1);
|
|
64
|
+
slotIds(name).splice(index, 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
41
67
|
// Typed accessors over the unions so explicit value targets stay sound.
|
|
42
68
|
function asString(key: string): string {
|
|
43
69
|
const v = values.attributes[key];
|
|
@@ -79,7 +105,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
79
105
|
<input
|
|
80
106
|
class="checkbox checkbox-sm"
|
|
81
107
|
type="checkbox"
|
|
82
|
-
aria-label={field.label}
|
|
83
108
|
aria-invalid={Boolean(errors[field.key])}
|
|
84
109
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
85
110
|
checked={asBool(field.key)}
|
|
@@ -92,7 +117,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
92
117
|
<span class="text-sm font-medium">{field.label}</span>
|
|
93
118
|
<select
|
|
94
119
|
class="select"
|
|
95
|
-
aria-label={field.label}
|
|
96
120
|
aria-invalid={Boolean(errors[field.key])}
|
|
97
121
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
98
122
|
value={asString(field.key)}
|
|
@@ -107,6 +131,7 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
107
131
|
<span class="text-sm font-medium">{field.label}</span>
|
|
108
132
|
<IconPicker
|
|
109
133
|
{icons}
|
|
134
|
+
label={field.label}
|
|
110
135
|
value={asString(field.key)}
|
|
111
136
|
required={field.required ?? false}
|
|
112
137
|
onChange={(name) => (values.attributes[field.key] = name)}
|
|
@@ -117,7 +142,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
117
142
|
<span class="text-sm font-medium">{field.label}</span>
|
|
118
143
|
<input
|
|
119
144
|
class="input"
|
|
120
|
-
aria-label={field.label}
|
|
121
145
|
aria-invalid={Boolean(errors[field.key])}
|
|
122
146
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
123
147
|
value={asString(field.key)}
|
|
@@ -134,7 +158,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
134
158
|
<span class="text-sm font-medium">{slot.label}</span>
|
|
135
159
|
<textarea
|
|
136
160
|
class="textarea"
|
|
137
|
-
aria-label={slot.label}
|
|
138
161
|
aria-invalid={Boolean(errors[slot.name])}
|
|
139
162
|
aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
|
|
140
163
|
rows={3}
|
|
@@ -147,7 +170,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
147
170
|
<span class="text-sm font-medium">{slot.label}</span>
|
|
148
171
|
<input
|
|
149
172
|
class="input"
|
|
150
|
-
aria-label={slot.label}
|
|
151
173
|
aria-invalid={Boolean(errors[slot.name])}
|
|
152
174
|
aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
|
|
153
175
|
value={slotString(slot.name)}
|
|
@@ -160,16 +182,17 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
160
182
|
|
|
161
183
|
{#each repeatableSlots as slot (slot.name)}
|
|
162
184
|
{@const items = slotItems(slot.name)}
|
|
185
|
+
{@const ids = slotIds(slot.name)}
|
|
163
186
|
<fieldset class="rounded-box border border-base-300 flex flex-col gap-2 p-2">
|
|
164
187
|
<legend class="text-sm font-medium">{slot.label}</legend>
|
|
165
|
-
<!--
|
|
166
|
-
{#each
|
|
188
|
+
<!-- Keyed by the parallel stable id so a mid-list removal drops the right node and focus follows the data; the value still binds to the canonical items[i] string the serializer reads. -->
|
|
189
|
+
{#each ids as id, i (id)}
|
|
167
190
|
<div class="flex items-center gap-2">
|
|
168
|
-
<input class="input input-sm flex-1" aria-label={`${slot.label}
|
|
169
|
-
<button type="button" class="btn btn-ghost btn-sm" aria-label={`Remove item ${i + 1}`} onclick={() =>
|
|
191
|
+
<input class="input input-sm flex-1" aria-label={`${slot.label} ${i + 1}`} bind:value={items[i]} />
|
|
192
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label={`Remove item ${i + 1}`} onclick={() => removeItem(slot.name, i)}>✕</button>
|
|
170
193
|
</div>
|
|
171
194
|
{/each}
|
|
172
|
-
<button type="button" class="btn btn-sm self-start" onclick={() =>
|
|
195
|
+
<button type="button" class="btn btn-sm self-start" onclick={() => addItem(slot.name)}>Add item</button>
|
|
173
196
|
{#if errors[slot.name]}<span id={`err-${slot.name}`} role="alert" class="text-error text-xs">{errors[slot.name]}</span>{/if}
|
|
174
197
|
</fieldset>
|
|
175
198
|
{/each}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ComponentForm.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ComponentForm.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,EAAe,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAEvE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAIhD,UAAU,KAAK;IACb,GAAG,EAAE,YAAY,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mEAAmE;IACnE,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,4BAA4B;IAC5B,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;
|
|
1
|
+
{"version":3,"file":"ComponentForm.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ComponentForm.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,EAAe,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAEvE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAIhD,UAAU,KAAK;IACb,GAAG,EAAE,YAAY,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mEAAmE;IACnE,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,4BAA4B;IAC5B,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;AAyJH;;;;;GAKG;AACH,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -12,14 +12,13 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
12
12
|
import type { IconSet } from '../render/glyph.js';
|
|
13
13
|
import type { EditData } from '../sveltekit/content-routes.js';
|
|
14
14
|
import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
|
|
15
|
-
import { sanitizePreviewHtml } from '../render/sanitize.js';
|
|
16
15
|
|
|
17
16
|
interface Props {
|
|
18
17
|
/** The edit load's data, plus the site name for the heading. */
|
|
19
18
|
data: EditData & { siteName: string };
|
|
20
19
|
/** The site's component registry, for the insert palette. */
|
|
21
20
|
registry?: ComponentRegistry;
|
|
22
|
-
/** The site's design-accurate render pipeline; the preview pane
|
|
21
|
+
/** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
|
|
23
22
|
render?: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
|
|
24
23
|
/** The site's icon set, for the guided form's icon fields. */
|
|
25
24
|
icons?: IconSet;
|
|
@@ -46,8 +45,8 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
46
45
|
localStorage.setItem(PREVIEW_KEY, showPreview ? '1' : '0');
|
|
47
46
|
}
|
|
48
47
|
|
|
49
|
-
// Render the design-accurate preview as the body changes, debounced
|
|
50
|
-
//
|
|
48
|
+
// Render the design-accurate preview as the body changes, debounced. The site's render is the
|
|
49
|
+
// floored engine pipeline, so its output is already sanitized; the preview mirrors the page.
|
|
51
50
|
// previewRun is a plain counter (not reactive state) used as a latest-wins guard: if a slow earlier
|
|
52
51
|
// async render call resolves after a newer one has started, the stale result is discarded.
|
|
53
52
|
let previewRun = 0;
|
|
@@ -58,8 +57,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
58
57
|
const handle = setTimeout(async () => {
|
|
59
58
|
try {
|
|
60
59
|
const html = await render(md);
|
|
61
|
-
|
|
62
|
-
if (run === previewRun) previewHtml = safe;
|
|
60
|
+
if (run === previewRun) previewHtml = html;
|
|
63
61
|
} catch {
|
|
64
62
|
if (run === previewRun) previewHtml = '';
|
|
65
63
|
}
|
|
@@ -8,7 +8,7 @@ interface Props {
|
|
|
8
8
|
};
|
|
9
9
|
/** The site's component registry, for the insert palette. */
|
|
10
10
|
registry?: ComponentRegistry;
|
|
11
|
-
/** The site's design-accurate render pipeline; the preview pane
|
|
11
|
+
/** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
|
|
12
12
|
render?: (md: string, opts?: {
|
|
13
13
|
stagger?: boolean;
|
|
14
14
|
}) => string | Promise<string>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EditPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/EditPage.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"EditPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/EditPage.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAI7D,UAAU,KAAK;IACb,gEAAgE;IAChE,IAAI,EAAE,QAAQ,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,qIAAqI;IACrI,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAChF,8DAA8D;IAC9D,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAoJH;;;;GAIG;AACH,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
A visual icon choice over the site's IconSet.
|
|
4
|
-
aria-
|
|
5
|
-
|
|
3
|
+
A visual icon choice over the site's IconSet. The choices form a radiogroup; each glyph is a radio
|
|
4
|
+
button carrying aria-checked, and the selected one carries btn-primary for the visible state. When the
|
|
5
|
+
field is optional, a None radio clears the value. A roving tabindex keeps a single tab stop and arrow
|
|
6
|
+
keys move the selection, the standard radiogroup keyboard model. The glyph renders inline from the
|
|
7
|
+
IconSet path data, matching the renderer's 256-unit viewBox.
|
|
6
8
|
-->
|
|
7
9
|
<script lang="ts">
|
|
10
|
+
import { tick } from 'svelte';
|
|
8
11
|
import type { IconSet } from '../render/glyph.js';
|
|
9
12
|
|
|
10
13
|
interface Props {
|
|
@@ -16,20 +19,60 @@ the IconSet path data, matching the renderer's 256-unit viewBox.
|
|
|
16
19
|
required: boolean;
|
|
17
20
|
/** Called with the new glyph name (or '' for none). */
|
|
18
21
|
onChange: (name: string) => void;
|
|
22
|
+
/** The group's accessible name, threaded from the field label. Defaults to Icon. */
|
|
23
|
+
label?: string;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
let { icons, value, required, onChange }: Props = $props();
|
|
26
|
+
let { icons, value, required, onChange, label = 'Icon' }: Props = $props();
|
|
27
|
+
|
|
28
|
+
// The radiogroup container, used to move focus with the selection per the ARIA radiogroup pattern.
|
|
29
|
+
let group: HTMLDivElement;
|
|
22
30
|
|
|
23
31
|
const names = $derived(Object.keys(icons));
|
|
32
|
+
// The selectable keys in DOM order: the optional None choice ('') first, then each glyph name.
|
|
33
|
+
// Arrow-key navigation walks this list, and the roving tabindex marks the selected key (or the
|
|
34
|
+
// first key when nothing is selected) as the single tab stop.
|
|
35
|
+
const choices = $derived(required ? names : ['', ...names]);
|
|
36
|
+
const tabStop = $derived(choices.includes(value) ? value : choices[0]);
|
|
37
|
+
|
|
38
|
+
function move(delta: number): void {
|
|
39
|
+
// Navigate relative to the focused element (the current tab stop), not the bound value. In a
|
|
40
|
+
// required group with no value, tabStop is the first radio while value is '', so a value-based
|
|
41
|
+
// origin would skip the first step.
|
|
42
|
+
const from = Math.max(0, choices.indexOf(tabStop));
|
|
43
|
+
const next = (from + delta + choices.length) % choices.length;
|
|
44
|
+
onChange(choices[next]);
|
|
45
|
+
// The roving tabindex updates reactively, so wait for the DOM then move focus onto the new tab
|
|
46
|
+
// stop. The keydown handler runs only when focus is already inside the group, so this never
|
|
47
|
+
// steals focus on mount.
|
|
48
|
+
void tick().then(() => {
|
|
49
|
+
const target = group?.querySelector<HTMLElement>('[tabindex="0"]');
|
|
50
|
+
target?.focus();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
55
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
move(1);
|
|
58
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
move(-1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
24
63
|
</script>
|
|
25
64
|
|
|
26
|
-
<div class="flex flex-wrap gap-2" role="
|
|
65
|
+
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label={label} bind:this={group}>
|
|
27
66
|
{#if !required}
|
|
28
67
|
<button
|
|
29
68
|
type="button"
|
|
30
69
|
class="btn btn-sm"
|
|
31
70
|
class:btn-primary={value === ''}
|
|
32
|
-
|
|
71
|
+
role="radio"
|
|
72
|
+
aria-checked={value === ''}
|
|
73
|
+
aria-label="None"
|
|
74
|
+
tabindex={tabStop === '' ? 0 : -1}
|
|
75
|
+
onkeydown={onKeydown}
|
|
33
76
|
onclick={() => onChange('')}
|
|
34
77
|
>None</button>
|
|
35
78
|
{/if}
|
|
@@ -38,8 +81,11 @@ the IconSet path data, matching the renderer's 256-unit viewBox.
|
|
|
38
81
|
type="button"
|
|
39
82
|
class="btn btn-sm gap-1"
|
|
40
83
|
class:btn-primary={value === name}
|
|
41
|
-
|
|
84
|
+
role="radio"
|
|
85
|
+
aria-checked={value === name}
|
|
42
86
|
aria-label={name}
|
|
87
|
+
tabindex={tabStop === name ? 0 : -1}
|
|
88
|
+
onkeydown={onKeydown}
|
|
43
89
|
onclick={() => onChange(name)}
|
|
44
90
|
>
|
|
45
91
|
<svg class="ec-glyph" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true" width="16" height="16">
|
|
@@ -8,11 +8,15 @@ interface Props {
|
|
|
8
8
|
required: boolean;
|
|
9
9
|
/** Called with the new glyph name (or '' for none). */
|
|
10
10
|
onChange: (name: string) => void;
|
|
11
|
+
/** The group's accessible name, threaded from the field label. Defaults to Icon. */
|
|
12
|
+
label?: string;
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
|
-
* A visual icon choice over the site's IconSet.
|
|
14
|
-
* aria-
|
|
15
|
-
*
|
|
15
|
+
* A visual icon choice over the site's IconSet. The choices form a radiogroup; each glyph is a radio
|
|
16
|
+
* button carrying aria-checked, and the selected one carries btn-primary for the visible state. When the
|
|
17
|
+
* field is optional, a None radio clears the value. A roving tabindex keeps a single tab stop and arrow
|
|
18
|
+
* keys move the selection, the standard radiogroup keyboard model. The glyph renders inline from the
|
|
19
|
+
* IconSet path data, matching the renderer's 256-unit viewBox.
|
|
16
20
|
*/
|
|
17
21
|
declare const IconPicker: import("svelte").Component<Props, {}, "">;
|
|
18
22
|
type IconPicker = ReturnType<typeof IconPicker>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IconPicker.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/IconPicker.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"IconPicker.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/IconPicker.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAGhD,UAAU,KAAK;IACb,kDAAkD;IAClD,KAAK,EAAE,OAAO,CAAC;IACf,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,4CAA4C;IAC5C,QAAQ,EAAE,OAAO,CAAC;IAClB,uDAAuD;IACvD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,oFAAoF;IACpF,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA8DH;;;;;;GAMG;AACH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CairnAdapter } from './types.js';
|
|
2
|
+
/** Declare a site's adapter while preserving each concept's concrete schema type for typed reads. */
|
|
3
|
+
export declare function defineAdapter<const A extends CairnAdapter>(adapter: A): A;
|
|
4
|
+
//# sourceMappingURL=adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/lib/content/adapter.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,qGAAqG;AACrG,wBAAgB,aAAa,CAAC,KAAK,CAAC,CAAC,SAAS,YAAY,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,CAEzE"}
|
package/dist/content/concepts.js
CHANGED
|
@@ -37,8 +37,8 @@ export function normalizeConcepts(content, urlPolicy = {}, routing = CONCEPT_ROU
|
|
|
37
37
|
routing: routing[id] ?? DEFAULT_ROUTING,
|
|
38
38
|
permalink: policy.permalink ?? defaultPermalink(id),
|
|
39
39
|
datePrefix: policy.datePrefix ?? 'day',
|
|
40
|
-
fields: config.fields,
|
|
41
|
-
validate: config.validate,
|
|
40
|
+
fields: config.schema.fields,
|
|
41
|
+
validate: config.schema.validate,
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
44
|
return descriptors;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { FrontmatterField, ValidationResult } from './types.js';
|
|
2
|
+
/** The validate input the cairn adapter takes: the raw frontmatter and the body. */
|
|
3
|
+
export interface StandardInput {
|
|
4
|
+
frontmatter: Record<string, unknown>;
|
|
5
|
+
body: string;
|
|
6
|
+
}
|
|
7
|
+
/** A minimal local copy of the Standard Schema v1 interface (https://standardschema.dev), so the
|
|
8
|
+
* schema is a drop-in where the ecosystem accepts a validator, with no runtime dependency. */
|
|
9
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
10
|
+
readonly '~standard': {
|
|
11
|
+
readonly version: 1;
|
|
12
|
+
readonly vendor: string;
|
|
13
|
+
readonly validate: (value: unknown) => StandardResult<Output>;
|
|
14
|
+
readonly types?: {
|
|
15
|
+
readonly input: Input;
|
|
16
|
+
readonly output: Output;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
type StandardResult<Output> = {
|
|
21
|
+
readonly value: Output;
|
|
22
|
+
readonly issues?: undefined;
|
|
23
|
+
} | {
|
|
24
|
+
readonly issues: ReadonlyArray<{
|
|
25
|
+
readonly message: string;
|
|
26
|
+
readonly path?: ReadonlyArray<PropertyKey>;
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
/** Map one field descriptor to the TS type of its normalized value. text, textarea, and date
|
|
30
|
+
* normalize to a string; a closed-vocabulary `tags` field to the option-union array. */
|
|
31
|
+
type FieldValue<K extends FrontmatterField> = K extends {
|
|
32
|
+
type: 'boolean';
|
|
33
|
+
} ? boolean : K extends {
|
|
34
|
+
type: 'tags';
|
|
35
|
+
options: readonly (infer O extends string)[];
|
|
36
|
+
} ? O[] : K extends {
|
|
37
|
+
type: 'tags' | 'freetags';
|
|
38
|
+
} ? string[] : string;
|
|
39
|
+
/** Flatten an intersection into a single readable object type. */
|
|
40
|
+
type Prettify<T> = {
|
|
41
|
+
[K in keyof T]: T[K];
|
|
42
|
+
} & {};
|
|
43
|
+
/** The normalized frontmatter type inferred from a field tuple. A field declared
|
|
44
|
+
* `required: true` is a required key; every other field is optional. */
|
|
45
|
+
export type InferFields<F extends readonly FrontmatterField[]> = Prettify<{
|
|
46
|
+
[K in F[number] as K extends {
|
|
47
|
+
required: true;
|
|
48
|
+
} ? K['name'] : never]: FieldValue<K>;
|
|
49
|
+
} & {
|
|
50
|
+
[K in F[number] as K extends {
|
|
51
|
+
required: true;
|
|
52
|
+
} ? never : K['name']]?: FieldValue<K>;
|
|
53
|
+
}>;
|
|
54
|
+
/** A concept's schema: the plain-data field projection, the generated validator, and the
|
|
55
|
+
* Standard Schema conformance property. */
|
|
56
|
+
export interface ConceptSchema<F extends readonly FrontmatterField[] = readonly FrontmatterField[]> {
|
|
57
|
+
/** The declared fields as plain serializable data, for the editor form. */
|
|
58
|
+
readonly fields: FrontmatterField[];
|
|
59
|
+
/** Validate raw frontmatter, returning field-keyed errors or the normalized data. */
|
|
60
|
+
validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
|
|
61
|
+
/** Standard Schema v1 conformance, for ecosystem interop. A thin adapter over `validate`. */
|
|
62
|
+
readonly '~standard': StandardSchemaV1<StandardInput, InferFields<F>>['~standard'];
|
|
63
|
+
}
|
|
64
|
+
/** Extract the inferred frontmatter type from a `ConceptSchema`. */
|
|
65
|
+
export type Infer<S> = S extends ConceptSchema<infer F> ? InferFields<F> : never;
|
|
66
|
+
/** Options for `defineFields`. `refine` runs after the per-field rules pass, for cross-field and
|
|
67
|
+
* body-dependent checks. It is validation-only: it returns field-keyed errors to merge, or
|
|
68
|
+
* nothing, and never transforms the data. */
|
|
69
|
+
export interface DefineFieldsOptions<F extends readonly FrontmatterField[]> {
|
|
70
|
+
refine?: (data: InferFields<F>, body: string) => Record<string, string> | undefined;
|
|
71
|
+
}
|
|
72
|
+
/** Declare a concept's fields once. Returns the schema's faces derived from that one declaration. */
|
|
73
|
+
export declare function defineFields<const F extends readonly FrontmatterField[]>(fields: F, options?: DefineFieldsOptions<F>): ConceptSchema<F>;
|
|
74
|
+
export {};
|
|
75
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/lib/content/schema.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAGrE,oFAAoF;AACpF,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,IAAI,EAAE,MAAM,CAAC;CACd;AAED;+FAC+F;AAC/F,MAAM,WAAW,gBAAgB,CAAC,KAAK,GAAG,OAAO,EAAE,MAAM,GAAG,KAAK;IAC/D,QAAQ,CAAC,WAAW,EAAE;QACpB,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;QACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CAAC,MAAM,CAAC,CAAC;QAC9D,QAAQ,CAAC,KAAK,CAAC,EAAE;YAAE,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;YAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;SAAE,CAAC;KACrE,CAAC;CACH;AACD,KAAK,cAAc,CAAC,MAAM,IACtB;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,CAAC,WAAW,CAAC,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC;AAEjH;yFACyF;AACzF,KAAK,UAAU,CAAC,CAAC,SAAS,gBAAgB,IAAI,CAAC,SAAS;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACvE,OAAO,GACP,CAAC,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,SAAS,CAAC,MAAM,CAAC,SAAS,MAAM,CAAC,EAAE,CAAA;CAAE,GACtE,CAAC,EAAE,GACH,CAAC,SAAS;IAAE,IAAI,EAAE,MAAM,GAAG,UAAU,CAAA;CAAE,GACrC,MAAM,EAAE,GACR,MAAM,CAAC;AAEf,kEAAkE;AAClE,KAAK,QAAQ,CAAC,CAAC,IAAI;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CAAE,GAAG,EAAE,CAAC;AAEjD;yEACyE;AACzE,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,SAAS,gBAAgB,EAAE,IAAI,QAAQ,CACvE;KAAG,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS;QAAE,QAAQ,EAAE,IAAI,CAAA;KAAE,GAAG,CAAC,CAAC,MAAM,CAAC,GAAG,KAAK,GAAG,UAAU,CAAC,CAAC,CAAC;CAAE,GAAG;KACvF,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS;QAAE,QAAQ,EAAE,IAAI,CAAA;KAAE,GAAG,KAAK,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC;CACrF,CACF,CAAC;AAEF;4CAC4C;AAC5C,MAAM,WAAW,aAAa,CAAC,CAAC,SAAS,SAAS,gBAAgB,EAAE,GAAG,SAAS,gBAAgB,EAAE;IAChG,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC;IACpC,qFAAqF;IACrF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAAC;IAC/E,6FAA6F;IAC7F,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC,aAAa,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC;CACpF;AAED,oEAAoE;AACpE,MAAM,MAAM,KAAK,CAAC,CAAC,IAAI,CAAC,SAAS,aAAa,CAAC,MAAM,CAAC,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC;AAqBjF;;8CAE8C;AAC9C,MAAM,WAAW,mBAAmB,CAAC,CAAC,SAAS,SAAS,gBAAgB,EAAE;IACxE,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;CACrF;AAkBD,qGAAqG;AACrG,wBAAgB,YAAY,CAAC,KAAK,CAAC,CAAC,SAAS,SAAS,gBAAgB,EAAE,EACtE,MAAM,EAAE,CAAC,EACT,OAAO,GAAE,mBAAmB,CAAC,CAAC,CAAM,GACnC,aAAa,CAAC,CAAC,CAAC,CAwBlB"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { validateFields } from './validate.js';
|
|
2
|
+
// Enforce the declarative per-field rules on an already-coerced value. Rules run only on a
|
|
3
|
+
// present, non-empty string value, so an absent optional field is never flagged. The first
|
|
4
|
+
// failing rule per field wins, so the author sees one clear message at a time.
|
|
5
|
+
function applyRules(field, value, errors, patterns) {
|
|
6
|
+
if (typeof value !== 'string' || value === '')
|
|
7
|
+
return;
|
|
8
|
+
if (field.type === 'text' || field.type === 'textarea') {
|
|
9
|
+
if (field.min != null && value.length < field.min)
|
|
10
|
+
errors[field.name] = `${field.label} must be at least ${field.min} characters`;
|
|
11
|
+
else if (field.max != null && value.length > field.max)
|
|
12
|
+
errors[field.name] = `${field.label} must be at most ${field.max} characters`;
|
|
13
|
+
else if (field.length != null && value.length !== field.length)
|
|
14
|
+
errors[field.name] = `${field.label} must be exactly ${field.length} characters`;
|
|
15
|
+
else if (field.pattern != null) {
|
|
16
|
+
const re = patterns.get(field.name);
|
|
17
|
+
if (re && !re.test(value))
|
|
18
|
+
errors[field.name] = `${field.label} is not in the expected format`;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
else if (field.type === 'date') {
|
|
22
|
+
if (field.min != null && value < field.min)
|
|
23
|
+
errors[field.name] = `${field.label} must be on or after ${field.min}`;
|
|
24
|
+
else if (field.max != null && value > field.max)
|
|
25
|
+
errors[field.name] = `${field.label} must be on or before ${field.max}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Compile each declared text/textarea pattern once, so a malformed pattern fails loudly at
|
|
29
|
+
// declaration (a site config error) instead of throwing from inside validate() on every save.
|
|
30
|
+
function compilePatterns(fields) {
|
|
31
|
+
const compiled = new Map();
|
|
32
|
+
for (const field of fields) {
|
|
33
|
+
if ((field.type === 'text' || field.type === 'textarea') && field.pattern != null) {
|
|
34
|
+
try {
|
|
35
|
+
compiled.set(field.name, new RegExp(field.pattern));
|
|
36
|
+
}
|
|
37
|
+
catch (cause) {
|
|
38
|
+
throw new Error(`cairn: field "${field.name}" has an invalid pattern: ${field.pattern}`, { cause });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return compiled;
|
|
43
|
+
}
|
|
44
|
+
/** Declare a concept's fields once. Returns the schema's faces derived from that one declaration. */
|
|
45
|
+
export function defineFields(fields, options = {}) {
|
|
46
|
+
const list = [...fields];
|
|
47
|
+
const patterns = compilePatterns(list);
|
|
48
|
+
const validate = (frontmatter, body) => {
|
|
49
|
+
const base = validateFields(list, frontmatter);
|
|
50
|
+
if (!base.ok)
|
|
51
|
+
return base;
|
|
52
|
+
const errors = {};
|
|
53
|
+
for (const field of list)
|
|
54
|
+
applyRules(field, base.data[field.name], errors, patterns);
|
|
55
|
+
if (Object.keys(errors).length > 0)
|
|
56
|
+
return { ok: false, errors };
|
|
57
|
+
const refined = options.refine?.(base.data, body);
|
|
58
|
+
return refined && Object.keys(refined).length > 0 ? { ok: false, errors: refined } : base;
|
|
59
|
+
};
|
|
60
|
+
const standard = {
|
|
61
|
+
version: 1,
|
|
62
|
+
vendor: 'cairn',
|
|
63
|
+
validate: (value) => {
|
|
64
|
+
const { frontmatter = {}, body = '' } = (value ?? {});
|
|
65
|
+
const result = validate(frontmatter ?? {}, body ?? '');
|
|
66
|
+
return result.ok
|
|
67
|
+
? { value: result.data }
|
|
68
|
+
: { issues: Object.entries(result.errors).map(([field, message]) => ({ message, path: [field] })) };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
return { fields: list, validate, '~standard': standard };
|
|
72
|
+
}
|