@glw907/cairn-cms 0.68.0 → 0.76.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/CHANGELOG.md +82 -0
- package/dist/ambient.d.ts +2 -0
- package/dist/components/CairnAdmin.svelte.d.ts +2 -7
- package/dist/components/ComponentForm.svelte +44 -27
- package/dist/components/ComponentInsertDialog.svelte +5 -5
- package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
- package/dist/components/EditPage.svelte +29 -107
- package/dist/components/EditPage.svelte.d.ts +2 -7
- package/dist/components/EntryPicker.svelte +117 -0
- package/dist/components/EntryPicker.svelte.d.ts +35 -0
- package/dist/components/FieldInput.svelte +218 -0
- package/dist/components/FieldInput.svelte.d.ts +51 -0
- package/dist/components/IconPicker.svelte +2 -2
- package/dist/components/IconPicker.svelte.d.ts +2 -0
- package/dist/components/LinkPicker.svelte +8 -75
- package/dist/components/LinkPicker.svelte.d.ts +4 -5
- package/dist/components/MediaHeroField.svelte +8 -5
- package/dist/components/MediaHeroField.svelte.d.ts +4 -0
- package/dist/components/ObjectGroupField.svelte +54 -0
- package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
- package/dist/components/ReferenceField.svelte +94 -0
- package/dist/components/ReferenceField.svelte.d.ts +27 -0
- package/dist/components/RepeatableField.svelte +221 -0
- package/dist/components/RepeatableField.svelte.d.ts +53 -0
- package/dist/components/cairn-admin.css +4 -0
- package/dist/components/preview-doc.js +5 -1
- package/dist/components/tidy-validate.js +1 -1
- package/dist/content/adapter.js +18 -0
- package/dist/content/advisories.d.ts +2 -2
- package/dist/content/advisories.js +3 -5
- package/dist/content/compose.d.ts +7 -6
- package/dist/content/compose.js +26 -20
- package/dist/content/concepts.d.ts +21 -15
- package/dist/content/concepts.js +55 -32
- package/dist/content/field-rules.js +3 -4
- package/dist/content/fields.d.ts +49 -1
- package/dist/content/fields.js +11 -0
- package/dist/content/fieldset.d.ts +31 -10
- package/dist/content/fieldset.js +262 -109
- package/dist/content/frontmatter-region.d.ts +38 -0
- package/dist/content/frontmatter-region.js +75 -0
- package/dist/content/frontmatter.d.ts +35 -2
- package/dist/content/frontmatter.js +232 -11
- package/dist/content/manifest.d.ts +34 -0
- package/dist/content/manifest.js +80 -4
- package/dist/content/media-refs.d.ts +2 -2
- package/dist/content/media-rewrite.js +1 -69
- package/dist/content/reference-index.d.ts +56 -0
- package/dist/content/reference-index.js +95 -0
- package/dist/content/references.d.ts +40 -0
- package/dist/content/references.js +0 -0
- package/dist/content/standard-schema.d.ts +30 -0
- package/dist/content/standard-schema.js +4 -0
- package/dist/content/types.d.ts +127 -178
- package/dist/delivery/data.d.ts +2 -2
- package/dist/delivery/data.js +1 -1
- package/dist/delivery/public-routes.d.ts +2 -5
- package/dist/delivery/public-routes.js +15 -1
- package/dist/delivery/site-descriptors.d.ts +5 -1
- package/dist/delivery/site-descriptors.js +8 -3
- package/dist/delivery/site-indexes.d.ts +2 -2
- package/dist/delivery/site-resolver.d.ts +25 -0
- package/dist/delivery/site-resolver.js +49 -0
- package/dist/doctor/checks-local.js +6 -11
- package/dist/github/backend.d.ts +83 -0
- package/dist/github/backend.js +76 -0
- package/dist/github/credentials.d.ts +11 -5
- package/dist/github/credentials.js +3 -3
- package/dist/github/repo.d.ts +8 -19
- package/dist/github/repo.js +69 -80
- package/dist/github/types.d.ts +1 -1
- package/dist/github/types.js +4 -4
- package/dist/index.d.ts +16 -12
- package/dist/index.js +7 -8
- package/dist/islands/index.d.ts +12 -0
- package/dist/islands/index.js +83 -0
- package/dist/islands/types.d.ts +7 -0
- package/dist/islands/types.js +1 -0
- package/dist/media/rewrite-plan.d.ts +2 -3
- package/dist/media/rewrite-plan.js +2 -3
- package/dist/media/usage.d.ts +2 -2
- package/dist/media/usage.js +3 -5
- package/dist/nav/site-config.d.ts +0 -6
- package/dist/nav/site-config.js +6 -4
- package/dist/render/component-grammar.js +11 -11
- package/dist/render/component-reference.js +5 -3
- package/dist/render/component-validate.d.ts +4 -1
- package/dist/render/component-validate.js +10 -35
- package/dist/render/pipeline.d.ts +0 -6
- package/dist/render/pipeline.js +1 -1
- package/dist/render/registry.d.ts +34 -34
- package/dist/render/registry.js +26 -5
- package/dist/render/rehype-dispatch.d.ts +4 -4
- package/dist/render/rehype-dispatch.js +36 -11
- package/dist/render/remark-directives.js +4 -5
- package/dist/render/sanitize-schema.js +1 -1
- package/dist/sveltekit/cairn-admin.d.ts +5 -5
- package/dist/sveltekit/cairn-admin.js +3 -4
- package/dist/sveltekit/content-routes.d.ts +10 -8
- package/dist/sveltekit/content-routes.js +269 -181
- package/dist/sveltekit/health.d.ts +7 -3
- package/dist/sveltekit/health.js +9 -3
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/nav-routes.d.ts +6 -5
- package/dist/sveltekit/nav-routes.js +22 -20
- package/dist/sveltekit/types.d.ts +2 -0
- package/dist/vite/index.d.ts +3 -3
- package/dist/vite/index.js +17 -8
- package/package.json +5 -1
- package/src/lib/ambient.ts +7 -0
- package/src/lib/components/CairnAdmin.svelte +2 -6
- package/src/lib/components/ComponentForm.svelte +48 -27
- package/src/lib/components/ComponentInsertDialog.svelte +9 -8
- package/src/lib/components/EditPage.svelte +43 -119
- package/src/lib/components/EntryPicker.svelte +154 -0
- package/src/lib/components/FieldInput.svelte +262 -0
- package/src/lib/components/IconPicker.svelte +4 -2
- package/src/lib/components/LinkPicker.svelte +10 -81
- package/src/lib/components/MediaHeroField.svelte +12 -5
- package/src/lib/components/ObjectGroupField.svelte +97 -0
- package/src/lib/components/ReferenceField.svelte +126 -0
- package/src/lib/components/RepeatableField.svelte +310 -0
- package/src/lib/components/preview-doc.ts +5 -1
- package/src/lib/components/tidy-validate.ts +1 -1
- package/src/lib/content/adapter.ts +21 -0
- package/src/lib/content/advisories.ts +4 -7
- package/src/lib/content/compose.ts +30 -23
- package/src/lib/content/concepts.ts +68 -40
- package/src/lib/content/field-rules.ts +3 -4
- package/src/lib/content/fields.ts +52 -1
- package/src/lib/content/fieldset.ts +291 -128
- package/src/lib/content/frontmatter-region.ts +90 -0
- package/src/lib/content/frontmatter.ts +231 -15
- package/src/lib/content/manifest.ts +101 -4
- package/src/lib/content/media-refs.ts +2 -2
- package/src/lib/content/media-rewrite.ts +7 -80
- package/src/lib/content/reference-index.ts +159 -0
- package/src/lib/content/references.ts +0 -0
- package/src/lib/content/standard-schema.ts +25 -0
- package/src/lib/content/types.ts +128 -195
- package/src/lib/delivery/data.ts +2 -2
- package/src/lib/delivery/public-routes.ts +17 -3
- package/src/lib/delivery/site-descriptors.ts +8 -3
- package/src/lib/delivery/site-indexes.ts +2 -2
- package/src/lib/delivery/site-resolver.ts +64 -0
- package/src/lib/doctor/checks-local.ts +6 -14
- package/src/lib/github/backend.ts +161 -0
- package/src/lib/github/credentials.ts +10 -7
- package/src/lib/github/repo.ts +79 -83
- package/src/lib/github/types.ts +5 -5
- package/src/lib/index.ts +38 -23
- package/src/lib/islands/index.ts +84 -0
- package/src/lib/islands/types.ts +11 -0
- package/src/lib/media/rewrite-plan.ts +4 -6
- package/src/lib/media/usage.ts +4 -7
- package/src/lib/nav/site-config.ts +8 -9
- package/src/lib/render/component-grammar.ts +10 -10
- package/src/lib/render/component-reference.ts +4 -3
- package/src/lib/render/component-validate.ts +10 -35
- package/src/lib/render/pipeline.ts +1 -7
- package/src/lib/render/registry.ts +58 -39
- package/src/lib/render/rehype-dispatch.ts +45 -10
- package/src/lib/render/remark-directives.ts +4 -5
- package/src/lib/render/sanitize-schema.ts +1 -1
- package/src/lib/sveltekit/cairn-admin.ts +8 -9
- package/src/lib/sveltekit/content-routes.ts +330 -221
- package/src/lib/sveltekit/health.ts +13 -6
- package/src/lib/sveltekit/index.ts +2 -2
- package/src/lib/sveltekit/nav-routes.ts +33 -29
- package/src/lib/sveltekit/types.ts +5 -1
- package/src/lib/vite/index.ts +20 -11
- package/dist/content/schema.d.ts +0 -87
- package/dist/content/schema.js +0 -85
- package/dist/content/validate.d.ts +0 -17
- package/dist/content/validate.js +0 -93
- package/src/lib/content/schema.ts +0 -163
- package/src/lib/content/validate.ts +0 -90
package/dist/index.js
CHANGED
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
// GitHub read-and-commit backend in Plan 03; render and nav follow.
|
|
3
3
|
export { requireOrigin } from './env.js';
|
|
4
4
|
export { buildMagicLinkMessage, cloudflareSend } from './email.js';
|
|
5
|
-
export {
|
|
5
|
+
export { normalizeConcepts, findConcept, defineConcept } from './content/concepts.js';
|
|
6
6
|
export { composeRuntime } from './content/compose.js';
|
|
7
7
|
export { frontmatterFromForm, dateInputValue, serializeMarkdown, parseMarkdown, } from './content/frontmatter.js';
|
|
8
|
-
export { defineFields } from './content/schema.js';
|
|
9
8
|
export { defineAdapter } from './content/adapter.js';
|
|
10
|
-
// The Contract v2 field vocabulary
|
|
11
|
-
// interfaces and the bare `Infer` stay module-local: the old `FrontmatterField` model above
|
|
12
|
-
// already exports those names, and the cutover plan frees them.
|
|
9
|
+
// The Contract v2 field vocabulary: the one live field system.
|
|
13
10
|
export { fields } from './content/fields.js';
|
|
14
11
|
export { fieldset, initialValues } from './content/fieldset.js';
|
|
15
12
|
export { isValidId, idFromFilename, filenameFromId, slugify, slugFromId, composeDatedId, } from './content/ids.js';
|
|
@@ -17,9 +14,9 @@ export { isValidId, idFromFilename, filenameFromId, slugify, slugFromId, compose
|
|
|
17
14
|
// builder and the request-time resolver ship from the delivery entry; this surface is the
|
|
18
15
|
// grammar, the manifest operations, and their types a migrating site adopts.
|
|
19
16
|
export { parseCairnToken, extractCairnLinks, formatCairnToken, escapeLinkText } from './content/links.js';
|
|
20
|
-
export { serializeManifest, parseManifest, emptyManifest, verifyManifest, diffManifests, upsertEntry, removeEntry, manifestEntryFromFile, manifestLinkResolver, inboundLinks, } from './content/manifest.js';
|
|
17
|
+
export { serializeManifest, parseManifest, emptyManifest, verifyManifest, verifyReferences, diffManifests, upsertEntry, removeEntry, manifestEntryFromFile, manifestLinkResolver, inboundLinks, } from './content/manifest.js';
|
|
21
18
|
// Render engine (Plan 04): generic directive pipeline; sites own the component registry.
|
|
22
|
-
export { defineRegistry, emptyValues } from './render/registry.js';
|
|
19
|
+
export { defineRegistry, defineComponent, emptyValues } from './render/registry.js';
|
|
23
20
|
export { serializeComponent, parseComponent } from './render/component-grammar.js';
|
|
24
21
|
export { validateComponent } from './render/component-validate.js';
|
|
25
22
|
export { buildComponentInsert } from './render/component-insert.js';
|
|
@@ -32,5 +29,7 @@ export { remarkDirectiveStamp } from './render/remark-directives.js';
|
|
|
32
29
|
// path. See docs/superpowers/specs/2026-06-05-cairn-render-authoring-surface-design.md.
|
|
33
30
|
export { createRenderer } from './render/pipeline.js';
|
|
34
31
|
export { CommitConflictError } from './github/types.js';
|
|
32
|
+
// The Backend seam (Contract v2 backend phase): the store interface and its default GitHub provider.
|
|
33
|
+
export { githubApp } from './github/backend.js';
|
|
35
34
|
// Nav tree and site-config helpers (Plan 06).
|
|
36
|
-
export { parseSiteConfig,
|
|
35
|
+
export { parseSiteConfig, extractMenu, setMenu, validateNavTree, MAX_NAV_NODES, NavValidationError, SiteConfigError, } from './nav/site-config.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IslandRegistry } from './types.js';
|
|
2
|
+
export type { IslandRegistry } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Mount each island in `root` (default `document`) over its server-rendered fallback. Call it after each
|
|
5
|
+
* client-side navigation, once the new DOM is in place (an `afterNavigate` callback): it tears down the
|
|
6
|
+
* previous pass first, so it is idempotent and leak-free. An eager island (`hydrate: true`) mounts at once;
|
|
7
|
+
* a `'visible'` island mounts on first intersection. An unknown directive name, a malformed prop payload,
|
|
8
|
+
* or a component that throws leaves the static fallback in place, so one bad island never breaks the page.
|
|
9
|
+
* Mount-and-replace clears the fallback, so an island whose fallback holds a focusable control should
|
|
10
|
+
* restore focus itself; the shipped fallbacks are non-interactive.
|
|
11
|
+
*/
|
|
12
|
+
export declare function hydrateIslands(islands: IslandRegistry, root?: ParentNode): void;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// cairn-cms islands (@glw907/cairn-cms/islands): the client runtime that mounts a site's live Svelte
|
|
2
|
+
// components over the static fallbacks the render pipeline emits. cairn is Svelte-only by design, so this
|
|
3
|
+
// mounts with Svelte's own mount()/unmount() directly, with no framework abstraction. A site imports this
|
|
4
|
+
// dynamically, gated on a non-empty registry, so a static site never ships it (zero cost when unused).
|
|
5
|
+
import { mount, unmount } from 'svelte';
|
|
6
|
+
// The live Svelte instances of the current pass and the observers still waiting to fire, kept module-level
|
|
7
|
+
// so the next pass can tear the previous one down. A layout calls hydrateIslands once per navigation, and
|
|
8
|
+
// the previous mounts must unmount before the next mount over the same DOM.
|
|
9
|
+
let mounted = [];
|
|
10
|
+
let observers = [];
|
|
11
|
+
// Tear down the previous pass: unmount live instances and disconnect observers that never fired. unmount
|
|
12
|
+
// runs with outro: false so teardown is synchronous and deterministic on navigation; an island declaring an
|
|
13
|
+
// out: transition would otherwise linger and briefly double-render against the next pass's fresh mount.
|
|
14
|
+
function teardown() {
|
|
15
|
+
for (const o of observers)
|
|
16
|
+
o.disconnect();
|
|
17
|
+
observers = [];
|
|
18
|
+
for (const instance of mounted) {
|
|
19
|
+
try {
|
|
20
|
+
void unmount(instance, { outro: false });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// a component that throws on teardown must not block the rest
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
mounted = [];
|
|
27
|
+
}
|
|
28
|
+
// Mount one island over its boundary: parse props (try/catch, a malformed payload leaves the fallback),
|
|
29
|
+
// clear the fallback, mount, and on a mount failure restore the fallback so the reader still sees content.
|
|
30
|
+
// WATCH: props are trusted to equal the directive's declared scalar attributes (serializeIslandProps emits
|
|
31
|
+
// only those). If a directive ever carries an attribute its island does not declare, this forwards it as-is.
|
|
32
|
+
function mountIsland(node, Comp) {
|
|
33
|
+
let props;
|
|
34
|
+
try {
|
|
35
|
+
props = JSON.parse(node.getAttribute('data-cairn-props') ?? '{}');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const fallback = [...node.childNodes];
|
|
41
|
+
node.replaceChildren();
|
|
42
|
+
try {
|
|
43
|
+
mounted.push(mount(Comp, { target: node, props }));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
node.replaceChildren(...fallback);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Defer a 'visible' island to first intersection, then mount once and stop observing.
|
|
50
|
+
function observeIsland(node, Comp) {
|
|
51
|
+
const observer = new IntersectionObserver((entries, self) => {
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (entry.isIntersecting) {
|
|
54
|
+
self.disconnect();
|
|
55
|
+
mountIsland(node, Comp);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
observer.observe(node);
|
|
60
|
+
observers.push(observer);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Mount each island in `root` (default `document`) over its server-rendered fallback. Call it after each
|
|
64
|
+
* client-side navigation, once the new DOM is in place (an `afterNavigate` callback): it tears down the
|
|
65
|
+
* previous pass first, so it is idempotent and leak-free. An eager island (`hydrate: true`) mounts at once;
|
|
66
|
+
* a `'visible'` island mounts on first intersection. An unknown directive name, a malformed prop payload,
|
|
67
|
+
* or a component that throws leaves the static fallback in place, so one bad island never breaks the page.
|
|
68
|
+
* Mount-and-replace clears the fallback, so an island whose fallback holds a focusable control should
|
|
69
|
+
* restore focus itself; the shipped fallbacks are non-interactive.
|
|
70
|
+
*/
|
|
71
|
+
export function hydrateIslands(islands, root = document) {
|
|
72
|
+
teardown();
|
|
73
|
+
for (const node of root.querySelectorAll('[data-cairn-island]')) {
|
|
74
|
+
const name = node.getAttribute('data-cairn-island');
|
|
75
|
+
const Comp = name ? islands[name] : undefined;
|
|
76
|
+
if (!Comp)
|
|
77
|
+
continue;
|
|
78
|
+
if (node.getAttribute('data-cairn-hydrate') === 'visible')
|
|
79
|
+
observeIsland(node, Comp);
|
|
80
|
+
else
|
|
81
|
+
mountIsland(node, Comp);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* A site's island components, keyed by directive name. Each value is the live Svelte component
|
|
4
|
+
* {@link hydrateIslands} mounts over the matching `hydrate` directive's static fallback. The props a
|
|
5
|
+
* component receives are the directive's declared scalar attributes (see the island boundary contract).
|
|
6
|
+
*/
|
|
7
|
+
export type IslandRegistry = Record<string, Component<Record<string, unknown>>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConceptDescriptor } from '../content/types.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { Backend } from '../github/backend.js';
|
|
3
3
|
import type { Manifest } from '../content/manifest.js';
|
|
4
4
|
/**
|
|
5
5
|
* One main entry the rewrite will touch: its identity, its file path, the transform's per-placement
|
|
@@ -59,8 +59,7 @@ export interface RewritePlan<P = unknown> {
|
|
|
59
59
|
* editor surface and node-safe; the only IO is the usage index build and the per-entry reads.
|
|
60
60
|
*/
|
|
61
61
|
export declare function planMediaRewrite<P = unknown>(args: {
|
|
62
|
-
backend:
|
|
63
|
-
token: string;
|
|
62
|
+
backend: Backend;
|
|
64
63
|
concepts: ConceptDescriptor[];
|
|
65
64
|
contentManifest: Manifest;
|
|
66
65
|
hash: string;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { findConcept } from '../content/concepts.js';
|
|
2
2
|
import { filenameFromId } from '../content/ids.js';
|
|
3
|
-
import { readRaw } from '../github/repo.js';
|
|
4
3
|
import { buildUsageIndex } from './usage.js';
|
|
5
4
|
/**
|
|
6
5
|
* Plan a media rewrite for one asset hash. Builds the cross-branch usage index in strict mode (so an
|
|
@@ -22,7 +21,7 @@ import { buildUsageIndex } from './usage.js';
|
|
|
22
21
|
export async function planMediaRewrite(args) {
|
|
23
22
|
// Strict so an unverifiable branch read rejects here rather than degrading to an absent reference.
|
|
24
23
|
// Do NOT wrap this: the throw is the fail-closed contract the apply relies on.
|
|
25
|
-
const index = await buildUsageIndex(args.backend, args.
|
|
24
|
+
const index = await buildUsageIndex(args.backend, args.concepts, args.contentManifest, {
|
|
26
25
|
strict: true,
|
|
27
26
|
});
|
|
28
27
|
const rows = index.get(args.hash) ?? [];
|
|
@@ -35,7 +34,7 @@ export async function planMediaRewrite(args) {
|
|
|
35
34
|
if (!concept)
|
|
36
35
|
return null;
|
|
37
36
|
const path = `${concept.dir}/${filenameFromId(row.id)}`;
|
|
38
|
-
const markdown = await
|
|
37
|
+
const markdown = await args.backend.readFile(path, args.backend.defaultBranch);
|
|
39
38
|
if (markdown === null)
|
|
40
39
|
return null;
|
|
41
40
|
const result = args.transform(markdown);
|
package/dist/media/usage.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConceptDescriptor } from '../content/types.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { Backend } from '../github/backend.js';
|
|
3
3
|
import type { Manifest } from '../content/manifest.js';
|
|
4
4
|
/** Where a reference lives: the published corpus on main, or a named open edit branch. */
|
|
5
5
|
export type UsageOrigin = {
|
|
@@ -49,4 +49,4 @@ export interface BuildUsageOptions {
|
|
|
49
49
|
* failure so the caller fails closed. Pass `branches` to reuse a branch list the caller already has
|
|
50
50
|
* (the load path lists once for the media-union) rather than listing them a second time.
|
|
51
51
|
*/
|
|
52
|
-
export declare function buildUsageIndex(
|
|
52
|
+
export declare function buildUsageIndex(backend: Backend, concepts: ConceptDescriptor[], manifest: Manifest, opts?: BuildUsageOptions): Promise<UsageIndex>;
|
package/dist/media/usage.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { listBranches } from '../github/branches.js';
|
|
2
|
-
import { readRaw } from '../github/repo.js';
|
|
3
1
|
import { PENDING_PREFIX, parsePendingBranch } from '../content/pending.js';
|
|
4
2
|
import { findConcept } from '../content/concepts.js';
|
|
5
3
|
import { isValidId, filenameFromId } from '../content/ids.js';
|
|
@@ -24,7 +22,7 @@ function push(index, hash, entry) {
|
|
|
24
22
|
* failure so the caller fails closed. Pass `branches` to reuse a branch list the caller already has
|
|
25
23
|
* (the load path lists once for the media-union) rather than listing them a second time.
|
|
26
24
|
*/
|
|
27
|
-
export async function buildUsageIndex(
|
|
25
|
+
export async function buildUsageIndex(backend, concepts, manifest, opts = {}) {
|
|
28
26
|
const index = new Map();
|
|
29
27
|
// The main arm: the manifest already carries each entry's mediaRefs, so this is a pure reverse
|
|
30
28
|
// map with no per-file read.
|
|
@@ -41,7 +39,7 @@ export async function buildUsageIndex(repo, token, concepts, manifest, opts = {}
|
|
|
41
39
|
}
|
|
42
40
|
// The branch arm: read each open cairn/* branch's one edited file. The path is derivable from the
|
|
43
41
|
// branch name, so no tree-listing is needed. The branch list is reused when the caller passes it.
|
|
44
|
-
const names = opts.branches ?? (await listBranches(
|
|
42
|
+
const names = opts.branches ?? (await backend.listBranches(PENDING_PREFIX));
|
|
45
43
|
// Read the branches in parallel rather than one at a time, so the latency floor is one round trip
|
|
46
44
|
// instead of N. workerd self-throttles to 6 simultaneous outbound connections, so this batch and
|
|
47
45
|
// the load path's media-union batch each stay under the limit; do NOT merge the two into one
|
|
@@ -58,7 +56,7 @@ export async function buildUsageIndex(repo, token, concepts, manifest, opts = {}
|
|
|
58
56
|
return [];
|
|
59
57
|
const path = `${concept.dir}/${filenameFromId(ref.id)}`;
|
|
60
58
|
try {
|
|
61
|
-
const raw = await
|
|
59
|
+
const raw = await backend.readFile(path, name);
|
|
62
60
|
if (raw === null)
|
|
63
61
|
return []; // The file is absent on the branch: nothing to extract.
|
|
64
62
|
const { frontmatter, body } = parseMarkdown(raw);
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { ConceptUrlPolicy } from '../content/types.js';
|
|
2
1
|
/** One navigation node. An omitted or empty `url` is a label-only grouping header; no `children` is a leaf. */
|
|
3
2
|
export interface NavNode {
|
|
4
3
|
label: string;
|
|
@@ -28,12 +27,9 @@ export interface SiteConfig {
|
|
|
28
27
|
siteName: string;
|
|
29
28
|
description?: string;
|
|
30
29
|
author?: string;
|
|
31
|
-
url?: string;
|
|
32
30
|
locale?: string;
|
|
33
31
|
/** Named navigation menus, each a NavNode[] (normalized by extractMenu). */
|
|
34
32
|
menus?: Record<string, unknown>;
|
|
35
|
-
/** Per-concept URL policy: the permalink pattern and date-prefix granularity, keyed by concept id. */
|
|
36
|
-
content?: Record<string, ConceptUrlPolicy>;
|
|
37
33
|
/**
|
|
38
34
|
* The editor spellcheck settings. The dialect is declared once per site (spec 1.2), so a British
|
|
39
35
|
* site loads the British word list and "colour" reads as correct. Today only US English ships, so an
|
|
@@ -141,8 +137,6 @@ export declare class SiteConfigError extends Error {
|
|
|
141
137
|
export declare function parseSiteConfig(raw: string): SiteConfig;
|
|
142
138
|
/** Extract one named menu from a parsed config and validate it. Returns [] when the menu is absent. */
|
|
143
139
|
export declare function extractMenu(config: SiteConfig, name: string, maxDepth: number): NavNode[];
|
|
144
|
-
/** The per-concept URL policy from a parsed config, or an empty policy when the `content` key is absent. */
|
|
145
|
-
export declare function urlPolicyFrom(config: SiteConfig): Record<string, ConceptUrlPolicy>;
|
|
146
140
|
/**
|
|
147
141
|
* Replace one named menu in the YAML site-config text and reserialize, preserving every other
|
|
148
142
|
* top-level key (siteName, other menus, settings). Parses into a Document so the rest of the file
|
package/dist/nav/site-config.js
CHANGED
|
@@ -185,6 +185,12 @@ export function parseSiteConfig(raw) {
|
|
|
185
185
|
if (typeof siteName !== 'string' || !siteName.trim()) {
|
|
186
186
|
throw new SiteConfigError('Site config needs a siteName');
|
|
187
187
|
}
|
|
188
|
+
// Contract v2 moved per-concept URL policy out of the YAML and onto defineConcept. A leftover
|
|
189
|
+
// `content:` block here would silently do nothing while the concept defaulted its permalink, so a
|
|
190
|
+
// half-migrated site (one carrying a non-default datePrefix) would rewrite every post URL. Fail loud.
|
|
191
|
+
if (parsed.content !== undefined) {
|
|
192
|
+
throw new SiteConfigError('cairn: site config no longer carries per-concept URL policy; move permalink/datePrefix into defineConcept (Contract v2)');
|
|
193
|
+
}
|
|
188
194
|
return parsed;
|
|
189
195
|
}
|
|
190
196
|
/** Extract one named menu from a parsed config and validate it. Returns [] when the menu is absent. */
|
|
@@ -194,10 +200,6 @@ export function extractMenu(config, name, maxDepth) {
|
|
|
194
200
|
return [];
|
|
195
201
|
return validateNavTree(menu, maxDepth);
|
|
196
202
|
}
|
|
197
|
-
/** The per-concept URL policy from a parsed config, or an empty policy when the `content` key is absent. */
|
|
198
|
-
export function urlPolicyFrom(config) {
|
|
199
|
-
return config.content ?? {};
|
|
200
|
-
}
|
|
201
203
|
/**
|
|
202
204
|
* Replace one named menu in the YAML site-config text and reserialize, preserving every other
|
|
203
205
|
* top-level key (siteName, other menus, settings). Parses into a Document so the rest of the file
|
|
@@ -5,11 +5,11 @@ import remarkStringify from 'remark-stringify';
|
|
|
5
5
|
const COLON = ':';
|
|
6
6
|
function attrBlock(def, values) {
|
|
7
7
|
const parts = [];
|
|
8
|
-
for (const field of def.attributes ??
|
|
9
|
-
const v = values.attributes[
|
|
8
|
+
for (const [name, field] of Object.entries(def.attributes ?? {})) {
|
|
9
|
+
const v = values.attributes[name];
|
|
10
10
|
if (field.type === 'boolean') {
|
|
11
11
|
if (v === true)
|
|
12
|
-
parts.push(`${
|
|
12
|
+
parts.push(`${name}="true"`);
|
|
13
13
|
}
|
|
14
14
|
else if (typeof v === 'string' && v !== '') {
|
|
15
15
|
// The directive attribute grammar (mdast-util-directive) treats a literal `"` as the value
|
|
@@ -17,7 +17,7 @@ function attrBlock(def, values) {
|
|
|
17
17
|
// Encode `&` first (so existing entities are not double-decoded) then `"`; the parser decodes
|
|
18
18
|
// both back. A backslash is literal in this grammar and needs no escaping.
|
|
19
19
|
const escaped = v.replace(/&/g, '&').replace(/"/g, '"');
|
|
20
|
-
parts.push(`${
|
|
20
|
+
parts.push(`${name}="${escaped}"`);
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
return parts.length ? `{${parts.join(' ')}}` : '';
|
|
@@ -80,12 +80,12 @@ function valuesFromRoot(root, def) {
|
|
|
80
80
|
const values = emptyComponentValues(def);
|
|
81
81
|
if (!root)
|
|
82
82
|
return values;
|
|
83
|
-
for (const field of def.attributes ??
|
|
84
|
-
const raw = root.attributes?.[
|
|
83
|
+
for (const [name, field] of Object.entries(def.attributes ?? {})) {
|
|
84
|
+
const raw = root.attributes?.[name];
|
|
85
85
|
if (field.type === 'boolean')
|
|
86
|
-
values.attributes[
|
|
86
|
+
values.attributes[name] = raw === 'true';
|
|
87
87
|
else if (typeof raw === 'string')
|
|
88
|
-
values.attributes[
|
|
88
|
+
values.attributes[name] = raw;
|
|
89
89
|
}
|
|
90
90
|
const titleSlot = slotByName(def, 'title');
|
|
91
91
|
const bodySlot = slotByName(def, 'body');
|
|
@@ -144,7 +144,7 @@ export async function componentRoundTripSafety(markdown, def) {
|
|
|
144
144
|
const root = findComponentRoot(markdown, def);
|
|
145
145
|
if (!root)
|
|
146
146
|
return { safe: false, reason: 'not-a-component' };
|
|
147
|
-
const declaredKeys = new Set((def.attributes ??
|
|
147
|
+
const declaredKeys = new Set(Object.keys(def.attributes ?? {}));
|
|
148
148
|
for (const key of parseRawAttributeKeys(markdown, def)) {
|
|
149
149
|
if (!declaredKeys.has(key))
|
|
150
150
|
return { safe: false, reason: 'unknown-attribute' };
|
|
@@ -177,8 +177,8 @@ export async function parseComponentWithRawKeys(markdown, def) {
|
|
|
177
177
|
// here; the parse must overwrite only the fields actually present in the markdown.
|
|
178
178
|
function emptyComponentValues(def) {
|
|
179
179
|
const attributes = {};
|
|
180
|
-
for (const
|
|
181
|
-
attributes[
|
|
180
|
+
for (const [name, field] of Object.entries(def.attributes ?? {}))
|
|
181
|
+
attributes[name] = field.type === 'boolean' ? false : '';
|
|
182
182
|
const slots = {};
|
|
183
183
|
for (const s of def.slots ?? [])
|
|
184
184
|
slots[s.name] = s.kind === 'repeatable' ? [] : '';
|
|
@@ -18,11 +18,13 @@ function componentSection(def) {
|
|
|
18
18
|
/** Seed example values that show every declared field: an ellipsis for strings, one sample list item. */
|
|
19
19
|
function exampleValues(def) {
|
|
20
20
|
const values = emptyValues(def);
|
|
21
|
-
for (const field of def.attributes ??
|
|
21
|
+
for (const [name, field] of Object.entries(def.attributes ?? {})) {
|
|
22
22
|
if (field.type === 'boolean')
|
|
23
|
-
values.attributes[
|
|
23
|
+
values.attributes[name] = false;
|
|
24
|
+
else if (field.type === 'select')
|
|
25
|
+
values.attributes[name] = field.options[0] ?? '…';
|
|
24
26
|
else
|
|
25
|
-
values.attributes[
|
|
27
|
+
values.attributes[name] = '…';
|
|
26
28
|
}
|
|
27
29
|
for (const slot of def.slots ?? []) {
|
|
28
30
|
if (slot.kind === 'repeatable')
|
|
@@ -7,6 +7,9 @@ export type ComponentValidation = {
|
|
|
7
7
|
errors: Record<string, string>;
|
|
8
8
|
};
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Validate a serialized component directive against its definition: the attributes through the same
|
|
11
|
+
* `fieldset` validator a concept field uses (coercion, constraints, required, select domain, pattern,
|
|
12
|
+
* and any per-attribute `behavior.validate`), then the two component-only checks, an unknown attribute
|
|
13
|
+
* key and an unfilled required slot.
|
|
11
14
|
*/
|
|
12
15
|
export declare function validateComponent(markdown: string, def: ComponentDef): Promise<ComponentValidation>;
|
|
@@ -1,32 +1,19 @@
|
|
|
1
1
|
import { parseComponentWithRawKeys } from './component-grammar.js';
|
|
2
|
+
import { fieldset } from '../content/fieldset.js';
|
|
2
3
|
/**
|
|
3
|
-
*
|
|
4
|
+
* Validate a serialized component directive against its definition: the attributes through the same
|
|
5
|
+
* `fieldset` validator a concept field uses (coercion, constraints, required, select domain, pattern,
|
|
6
|
+
* and any per-attribute `behavior.validate`), then the two component-only checks, an unknown attribute
|
|
7
|
+
* key and an unfilled required slot.
|
|
4
8
|
*/
|
|
5
9
|
export async function validateComponent(markdown, def) {
|
|
6
10
|
const { values, rawKeys } = await parseComponentWithRawKeys(markdown, def);
|
|
7
11
|
const errors = {};
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
errors[field.key] = `${field.label} is required.`;
|
|
14
|
-
continue;
|
|
15
|
-
}
|
|
16
|
-
if (field.type === 'select' && typeof v === 'string' && v !== '' && !(field.options ?? []).includes(v)) {
|
|
17
|
-
errors[field.key] = `${field.label} must be one of: ${(field.options ?? []).join(', ')}.`;
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
if (field.pattern && typeof v === 'string' && v !== '' && !new RegExp(field.pattern.source).test(v)) {
|
|
21
|
-
errors[field.key] = field.pattern.message;
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
if (field.validate) {
|
|
25
|
-
const message = runFieldValidator(def, field.key, () => field.validate(v, values));
|
|
26
|
-
if (typeof message === 'string')
|
|
27
|
-
errors[field.key] = message;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
12
|
+
const schema = def.attributeSchema ?? fieldset(def.attributes ?? {}, { behavior: def.behavior });
|
|
13
|
+
const result = schema.validate(values.attributes, '');
|
|
14
|
+
if (!result.ok)
|
|
15
|
+
Object.assign(errors, result.errors);
|
|
16
|
+
const declared = new Set(Object.keys(def.attributes ?? {}));
|
|
30
17
|
for (const key of rawKeys) {
|
|
31
18
|
if (!declared.has(key))
|
|
32
19
|
errors[key] = `Unknown attribute "${key}".`;
|
|
@@ -41,15 +28,3 @@ export async function validateComponent(markdown, def) {
|
|
|
41
28
|
}
|
|
42
29
|
return Object.keys(errors).length ? { ok: false, errors } : { ok: true };
|
|
43
30
|
}
|
|
44
|
-
// Run a site-supplied attribute validator. The validator is author code, so a throw is contained:
|
|
45
|
-
// the field is treated as valid and a dev-time warning names the component and field so the author
|
|
46
|
-
// can find the bug. A returned string is the field error; anything else (null) is clean.
|
|
47
|
-
function runFieldValidator(def, key, call) {
|
|
48
|
-
try {
|
|
49
|
-
return call();
|
|
50
|
-
}
|
|
51
|
-
catch (err) {
|
|
52
|
-
console.warn(`cairn: validate() for component "${def.name}" field "${key}" threw; treating the field as valid.`, err);
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
@@ -4,12 +4,6 @@ import { type MediaResolve } from './resolve-media.js';
|
|
|
4
4
|
import { type ComponentRegistry } from './registry.js';
|
|
5
5
|
import type { LinkResolve } from '../content/links.js';
|
|
6
6
|
export interface RendererOptions {
|
|
7
|
-
/**
|
|
8
|
-
* Stamp a `data-rise` ordinal (0, 1, 2, …) on each top-level component so a site's
|
|
9
|
-
* CSS can drive an entrance-cascade delay off it. Omit for no stagger. The ordinal
|
|
10
|
-
* is inert, so a consumer's sanitize floor can keep `data-rise` and drop `style`.
|
|
11
|
-
*/
|
|
12
|
-
stagger?: boolean;
|
|
13
7
|
/**
|
|
14
8
|
* Extend the sanitize allowlist. Receives cairn's default schema (defaultSchema plus the
|
|
15
9
|
* directive markers and the common benign tags) and returns the schema to use. Add to the
|
package/dist/render/pipeline.js
CHANGED
|
@@ -39,7 +39,7 @@ export function createRenderer(registry = defineRegistry({ components: [] }), op
|
|
|
39
39
|
const rehypePlugins = [
|
|
40
40
|
rehypeRaw,
|
|
41
41
|
...floor,
|
|
42
|
-
[rehypeDispatch, registry
|
|
42
|
+
[rehypeDispatch, registry],
|
|
43
43
|
rehypeSlug,
|
|
44
44
|
// Name each GFM task-list checkbox from its item text. It runs after the sanitize floor (which
|
|
45
45
|
// does not allow aria-label) so the added attribute survives, and is content-not-sink, so it is
|
|
@@ -1,32 +1,6 @@
|
|
|
1
1
|
import type { Element, ElementContent } from 'hast';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
/** One `{key="value"}` attribute on a component directive, or one field of a repeatable item. */
|
|
5
|
-
export interface AttributeField {
|
|
6
|
-
/** The attribute name as it appears in the directive, e.g. `icon`. */
|
|
7
|
-
key: string;
|
|
8
|
-
/** The form label. */
|
|
9
|
-
label: string;
|
|
10
|
-
type: FieldType;
|
|
11
|
-
required?: boolean;
|
|
12
|
-
/** Initial value; a string for text/select/icon, a boolean for boolean. */
|
|
13
|
-
default?: string | boolean;
|
|
14
|
-
/** Allowed values for `type: 'select'`. */
|
|
15
|
-
options?: readonly string[];
|
|
16
|
-
/** Helper text shown under the field. */
|
|
17
|
-
help?: string;
|
|
18
|
-
/** A RegExp `source` to validate the value against, plus the message to show on a mismatch. */
|
|
19
|
-
pattern?: {
|
|
20
|
-
source: string;
|
|
21
|
-
message: string;
|
|
22
|
-
};
|
|
23
|
-
/**
|
|
24
|
-
* A pure, browser-safe cross-field validator. Returns an error string, or null when valid.
|
|
25
|
-
* Receives the field's value and the full {@link ComponentValues} so a rule can read sibling
|
|
26
|
-
* fields. The picker wraps the call in try/catch so an author's throw never crashes the form.
|
|
27
|
-
*/
|
|
28
|
-
validate?: (value: string | boolean, all: ComponentValues) => string | null;
|
|
29
|
-
}
|
|
2
|
+
import type { FieldDescriptor } from '../content/fields.js';
|
|
3
|
+
import type { BehaviorTable, Fieldset } from '../content/fieldset.js';
|
|
30
4
|
export type SlotKind = 'markdown' | 'inline' | 'repeatable';
|
|
31
5
|
/**
|
|
32
6
|
* One named content region of a component. The slots named `title` and `body` are special: `title`
|
|
@@ -39,7 +13,7 @@ export interface SlotDef {
|
|
|
39
13
|
required?: boolean;
|
|
40
14
|
help?: string;
|
|
41
15
|
/** For `kind: 'repeatable'`: the fields composing each list item (v1 uses the first field). */
|
|
42
|
-
itemFields?:
|
|
16
|
+
itemFields?: Record<string, FieldDescriptor>;
|
|
43
17
|
/**
|
|
44
18
|
* For `kind: 'repeatable'`: derives a row's label from its item values and zero-based index.
|
|
45
19
|
* When it returns nothing, the picker falls back to `${label} ${index + 1}`.
|
|
@@ -74,10 +48,18 @@ export interface ComponentDef {
|
|
|
74
48
|
insertTemplate?: string;
|
|
75
49
|
/**
|
|
76
50
|
* Build the final hast element from the component context (attributes plus partitioned
|
|
77
|
-
* slots). The engine stamps the entrance
|
|
51
|
+
* slots). The engine stamps the entrance ordinal (`data-rise`) on the top-level
|
|
78
52
|
* result, so a build fn stays free of any motion concern.
|
|
79
53
|
*/
|
|
80
54
|
build: (ctx: ComponentContext) => Element;
|
|
55
|
+
/**
|
|
56
|
+
* Opt this directive into client hydration (phase 4b islands). `true` mounts the island eagerly on
|
|
57
|
+
* first load and after client-side navigation; `'visible'` defers the mount to first intersection.
|
|
58
|
+
* The engine wraps {@link ComponentDef.build}'s output in an island boundary, and the site registers
|
|
59
|
+
* the live Svelte component under the same name on `rendering.islands`. Absent leaves the directive a
|
|
60
|
+
* static, server-only component.
|
|
61
|
+
*/
|
|
62
|
+
hydrate?: boolean | 'visible';
|
|
81
63
|
/**
|
|
82
64
|
* Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. Maps a free-string role to a
|
|
83
65
|
* glyph key in the site IconSet; choose a logically representative glyph and prefer glyphs
|
|
@@ -87,8 +69,18 @@ export interface ComponentDef {
|
|
|
87
69
|
defaultIconByRole?: Record<string, string>;
|
|
88
70
|
/** One line on when to reach for this component; feeds the picker and the reference file. */
|
|
89
71
|
use?: string;
|
|
90
|
-
/** The `{key="value"}` attributes this component accepts. */
|
|
91
|
-
attributes?:
|
|
72
|
+
/** The `{key="value"}` attributes this component accepts, keyed by attribute name. */
|
|
73
|
+
attributes?: Record<string, FieldDescriptor>;
|
|
74
|
+
/**
|
|
75
|
+
* Per-attribute function-valued behavior (a cross-field `validate`), keyed by attribute name.
|
|
76
|
+
* {@link defineComponent} bundles it into the attribute {@link Fieldset}.
|
|
77
|
+
*/
|
|
78
|
+
behavior?: BehaviorTable;
|
|
79
|
+
/**
|
|
80
|
+
* The attribute validator {@link defineComponent} builds from `attributes` and `behavior`.
|
|
81
|
+
* Engine-internal: the constructor sets it, and {@link validateComponent} runs it.
|
|
82
|
+
*/
|
|
83
|
+
attributeSchema?: Fieldset;
|
|
92
84
|
/** The named content regions this component accepts. */
|
|
93
85
|
slots?: SlotDef[];
|
|
94
86
|
/**
|
|
@@ -114,8 +106,8 @@ export interface ComponentRegistry {
|
|
|
114
106
|
names: string[];
|
|
115
107
|
get(name: string): ComponentDef | undefined;
|
|
116
108
|
defaultIcon(name: string, role?: string): string | undefined;
|
|
117
|
-
/** The component's first `type:'icon'` attribute, or undefined when it declares none. */
|
|
118
|
-
iconField(name: string):
|
|
109
|
+
/** The name of the component's first `type:'icon'` attribute, or undefined when it declares none. */
|
|
110
|
+
iconField(name: string): string | undefined;
|
|
119
111
|
}
|
|
120
112
|
/**
|
|
121
113
|
* The hast property name carrying one declared attribute from stamp to dispatch, e.g. `tone`
|
|
@@ -149,3 +141,11 @@ export declare function emptyValues(def: ComponentDef): ComponentValues;
|
|
|
149
141
|
* the def declares no `preview`, returns exactly the {@link emptyValues} output.
|
|
150
142
|
*/
|
|
151
143
|
export declare function previewValues(def: ComponentDef): ComponentValues;
|
|
144
|
+
/**
|
|
145
|
+
* Declare a site component, building its attribute validator from the `fields.*` descriptors and
|
|
146
|
+
* validating the component at declaration. Mirrors {@link defineConcept}: a malformed attribute type
|
|
147
|
+
* or pattern fails at module load. The built `attributeSchema` is what {@link validateComponent} runs.
|
|
148
|
+
*/
|
|
149
|
+
export declare function defineComponent<const D extends ComponentDef>(def: D): D & {
|
|
150
|
+
attributeSchema: Fieldset;
|
|
151
|
+
};
|
package/dist/render/registry.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fieldset } from '../content/fieldset.js';
|
|
1
2
|
/**
|
|
2
3
|
* The hast property name carrying one declared attribute from stamp to dispatch, e.g. `tone`
|
|
3
4
|
* becomes `dataAttrTone`. The directive stamp writes it and the rehype dispatch reads it, so both
|
|
@@ -7,12 +8,12 @@ export function dataAttrProp(key) {
|
|
|
7
8
|
return `dataAttr${key.charAt(0).toUpperCase()}${key.slice(1)}`;
|
|
8
9
|
}
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
-
* construction-time guard and the registry's `iconField` derive the icon field from this one
|
|
11
|
+
* The name of a component's first `type:'icon'` attribute, or undefined when it declares none. Both
|
|
12
|
+
* the construction-time guard and the registry's `iconField` derive the icon field from this one
|
|
12
13
|
* predicate rather than spelling the `type === 'icon'` find twice.
|
|
13
14
|
*/
|
|
14
15
|
function findIconField(def) {
|
|
15
|
-
return def.attributes
|
|
16
|
+
return Object.entries(def.attributes ?? {}).find(([, field]) => field.type === 'icon')?.[0];
|
|
16
17
|
}
|
|
17
18
|
/**
|
|
18
19
|
* The engine's role-to-glyph-key fallback for the conventional admonition roles, which a site's
|
|
@@ -65,8 +66,8 @@ export function defineRegistry({ components }) {
|
|
|
65
66
|
*/
|
|
66
67
|
export function emptyValues(def) {
|
|
67
68
|
const attributes = {};
|
|
68
|
-
for (const field of def.attributes ??
|
|
69
|
-
attributes[
|
|
69
|
+
for (const [name, field] of Object.entries(def.attributes ?? {})) {
|
|
70
|
+
attributes[name] = field.default ?? (field.type === 'boolean' ? false : '');
|
|
70
71
|
}
|
|
71
72
|
const slots = {};
|
|
72
73
|
for (const slot of def.slots ?? []) {
|
|
@@ -88,3 +89,23 @@ export function previewValues(def) {
|
|
|
88
89
|
slots: { ...base.slots, ...def.preview.slots },
|
|
89
90
|
};
|
|
90
91
|
}
|
|
92
|
+
/** The descriptor types that serialize to a single directive-attribute string (decision 2). */
|
|
93
|
+
const ATTRIBUTE_TYPES = new Set(['text', 'textarea', 'number', 'select', 'url', 'email', 'date', 'datetime', 'boolean', 'icon']);
|
|
94
|
+
/** Reject an attribute type that cannot serialize to a single directive-attribute string (decision 2). */
|
|
95
|
+
function checkComponentAttributes(name, attributes) {
|
|
96
|
+
for (const [key, field] of Object.entries(attributes)) {
|
|
97
|
+
if (!ATTRIBUTE_TYPES.has(field.type)) {
|
|
98
|
+
throw new Error(`cairn: component "${name}" attribute "${key}" is type "${field.type}"; a directive attribute must be a single-value scalar (text, textarea, number, select, url, email, date, datetime, boolean, or icon).`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Declare a site component, building its attribute validator from the `fields.*` descriptors and
|
|
104
|
+
* validating the component at declaration. Mirrors {@link defineConcept}: a malformed attribute type
|
|
105
|
+
* or pattern fails at module load. The built `attributeSchema` is what {@link validateComponent} runs.
|
|
106
|
+
*/
|
|
107
|
+
export function defineComponent(def) {
|
|
108
|
+
const attributes = def.attributes ?? {};
|
|
109
|
+
checkComponentAttributes(def.name, attributes);
|
|
110
|
+
return { ...def, attributeSchema: fieldset(attributes, { behavior: def.behavior }) };
|
|
111
|
+
}
|