@glw907/cairn-cms 0.62.2 → 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 +216 -0
- package/dist/ambient.d.ts +2 -0
- package/dist/auth/types.d.ts +7 -0
- package/dist/components/CairnAdmin.svelte.d.ts +2 -7
- package/dist/components/ComponentForm.svelte +44 -27
- package/dist/components/ComponentInsertDialog.svelte +22 -11
- package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
- package/dist/components/ConceptList.svelte +25 -4
- 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 +179 -2
- 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.d.ts +15 -0
- package/dist/content/field-rules.js +38 -0
- package/dist/content/fields.d.ts +169 -0
- package/dist/content/fields.js +41 -0
- package/dist/content/fieldset.d.ts +107 -0
- package/dist/content/fieldset.js +386 -0
- 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 +10 -5
- package/dist/delivery/public-routes.js +25 -2
- 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 +18 -10
- package/dist/index.js +9 -5
- 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/log/events.d.ts +1 -1
- package/dist/media/index.d.ts +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/manifest.d.ts +11 -0
- package/dist/media/manifest.js +13 -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/highlight.d.ts +9 -0
- package/dist/render/highlight.js +206 -0
- package/dist/render/pipeline.d.ts +0 -6
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.d.ts +44 -36
- package/dist/render/registry.js +47 -6
- package/dist/render/rehype-dispatch.d.ts +6 -10
- package/dist/render/rehype-dispatch.js +38 -17
- package/dist/render/remark-directives.js +4 -5
- package/dist/render/sanitize-schema.d.ts +10 -0
- package/dist/render/sanitize-schema.js +30 -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/guard.js +10 -0
- 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 +17 -2
- package/src/lib/ambient.ts +7 -0
- package/src/lib/auth/types.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 +26 -14
- package/src/lib/components/ConceptList.svelte +41 -4
- 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 +39 -0
- package/src/lib/content/fields.ts +178 -0
- package/src/lib/content/fieldset.ts +470 -0
- 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 +36 -4
- 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 +40 -18
- package/src/lib/islands/index.ts +84 -0
- package/src/lib/islands/types.ts +11 -0
- package/src/lib/log/events.ts +1 -0
- package/src/lib/media/index.ts +1 -0
- package/src/lib/media/manifest.ts +14 -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/highlight.ts +259 -0
- package/src/lib/render/pipeline.ts +13 -8
- package/src/lib/render/registry.ts +88 -42
- package/src/lib/render/rehype-dispatch.ts +47 -16
- package/src/lib/render/remark-directives.ts +4 -5
- package/src/lib/render/sanitize-schema.ts +32 -1
- package/src/lib/sveltekit/cairn-admin.ts +8 -9
- package/src/lib/sveltekit/content-routes.ts +330 -221
- package/src/lib/sveltekit/guard.ts +15 -0
- 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 -89
- package/dist/content/validate.d.ts +0 -17
- package/dist/content/validate.js +0 -93
- package/src/lib/content/schema.ts +0 -167
- package/src/lib/content/validate.ts +0 -90
package/src/lib/index.ts
CHANGED
|
@@ -9,17 +9,10 @@ export { buildMagicLinkMessage, cloudflareSend } from './email.js';
|
|
|
9
9
|
export type {
|
|
10
10
|
CairnAdapter,
|
|
11
11
|
ConceptConfig,
|
|
12
|
-
|
|
13
|
-
TextField,
|
|
14
|
-
TextareaField,
|
|
15
|
-
DateField,
|
|
16
|
-
BooleanField,
|
|
17
|
-
TagsField,
|
|
18
|
-
FreeTagsField,
|
|
19
|
-
ImageField,
|
|
12
|
+
NamedField,
|
|
20
13
|
ImageValue,
|
|
21
14
|
ValidationResult,
|
|
22
|
-
|
|
15
|
+
ValidationIssue,
|
|
23
16
|
SenderConfig,
|
|
24
17
|
NavMenuConfig,
|
|
25
18
|
PreviewConfig,
|
|
@@ -30,10 +23,11 @@ export type {
|
|
|
30
23
|
ConceptUrlPolicy,
|
|
31
24
|
CairnExtension,
|
|
32
25
|
CairnRuntime,
|
|
26
|
+
SiteRender,
|
|
33
27
|
AdminPanel,
|
|
34
28
|
FieldTypeDef,
|
|
35
29
|
} from './content/types.js';
|
|
36
|
-
export {
|
|
30
|
+
export { normalizeConcepts, findConcept, defineConcept } from './content/concepts.js';
|
|
37
31
|
export { composeRuntime } from './content/compose.js';
|
|
38
32
|
export type { ComposeInput } from './content/compose.js';
|
|
39
33
|
export {
|
|
@@ -42,9 +36,30 @@ export {
|
|
|
42
36
|
serializeMarkdown,
|
|
43
37
|
parseMarkdown,
|
|
44
38
|
} from './content/frontmatter.js';
|
|
45
|
-
export { defineFields } from './content/schema.js';
|
|
46
39
|
export { defineAdapter } from './content/adapter.js';
|
|
47
|
-
export type {
|
|
40
|
+
export type { StandardInput, StandardSchemaV1 } from './content/standard-schema.js';
|
|
41
|
+
// The Contract v2 field vocabulary: the one live field system.
|
|
42
|
+
export { fields } from './content/fields.js';
|
|
43
|
+
export type {
|
|
44
|
+
FieldDescriptor,
|
|
45
|
+
TextField,
|
|
46
|
+
TextareaField,
|
|
47
|
+
NumberField,
|
|
48
|
+
SelectField,
|
|
49
|
+
MultiselectField,
|
|
50
|
+
UrlField,
|
|
51
|
+
EmailField,
|
|
52
|
+
DateField,
|
|
53
|
+
DatetimeField,
|
|
54
|
+
BooleanField,
|
|
55
|
+
IconField,
|
|
56
|
+
ImageField,
|
|
57
|
+
ObjectField,
|
|
58
|
+
ReferenceField,
|
|
59
|
+
ArrayField,
|
|
60
|
+
} from './content/fields.js';
|
|
61
|
+
export { fieldset, initialValues } from './content/fieldset.js';
|
|
62
|
+
export type { Fieldset, InferFieldset, FieldsetOptions, BehaviorTable, FieldBehavior } from './content/fieldset.js';
|
|
48
63
|
export {
|
|
49
64
|
isValidId,
|
|
50
65
|
idFromFilename,
|
|
@@ -64,6 +79,7 @@ export {
|
|
|
64
79
|
parseManifest,
|
|
65
80
|
emptyManifest,
|
|
66
81
|
verifyManifest,
|
|
82
|
+
verifyReferences,
|
|
67
83
|
diffManifests,
|
|
68
84
|
upsertEntry,
|
|
69
85
|
removeEntry,
|
|
@@ -71,14 +87,17 @@ export {
|
|
|
71
87
|
manifestLinkResolver,
|
|
72
88
|
inboundLinks,
|
|
73
89
|
} from './content/manifest.js';
|
|
74
|
-
export type { Manifest, ManifestEntry, ManifestDiff, ManifestEntryDiff, LinkTarget, InboundLink } from './content/manifest.js';
|
|
90
|
+
export type { Manifest, ManifestEntry, ManifestDiff, ManifestEntryDiff, LinkTarget, InboundLink, InboundReference } from './content/manifest.js';
|
|
91
|
+
export type { ReferenceEdge } from './content/references.js';
|
|
92
|
+
// The read-model resolution of a reference edge to its target's identity lives at the cross-concept
|
|
93
|
+
// site-resolver layer (a per-concept index cannot reach a different concept's entries). The resolver
|
|
94
|
+
// function ships from the /delivery subpath; this is the type a route reads off the resolved map.
|
|
95
|
+
export type { ResolvedReference } from './delivery/site-resolver.js';
|
|
75
96
|
// Render engine (Plan 04): generic directive pipeline; sites own the component registry.
|
|
76
|
-
export { defineRegistry, emptyValues } from './render/registry.js';
|
|
97
|
+
export { defineRegistry, defineComponent, emptyValues } from './render/registry.js';
|
|
77
98
|
export type {
|
|
78
99
|
ComponentDef,
|
|
79
100
|
ComponentRegistry,
|
|
80
|
-
FieldType,
|
|
81
|
-
AttributeField,
|
|
82
101
|
SlotKind,
|
|
83
102
|
SlotDef,
|
|
84
103
|
ComponentValues,
|
|
@@ -100,13 +119,16 @@ export { createRenderer } from './render/pipeline.js';
|
|
|
100
119
|
export type { RendererOptions } from './render/pipeline.js';
|
|
101
120
|
|
|
102
121
|
// GitHub read-and-commit backend (Plan 03).
|
|
103
|
-
export type {
|
|
122
|
+
export type { RepoFile, CommitAuthor } from './github/types.js';
|
|
104
123
|
export { CommitConflictError } from './github/types.js';
|
|
124
|
+
// The Backend seam (Contract v2 backend phase): the store interface and its default GitHub provider.
|
|
125
|
+
export { githubApp } from './github/backend.js';
|
|
126
|
+
export type { Backend, BackendProvider, GithubAppProvider, BackendEnv } from './github/backend.js';
|
|
127
|
+
export type { FileChange } from './github/repo.js';
|
|
105
128
|
|
|
106
129
|
// Nav tree and site-config helpers (Plan 06).
|
|
107
130
|
export {
|
|
108
131
|
parseSiteConfig,
|
|
109
|
-
urlPolicyFrom,
|
|
110
132
|
extractMenu,
|
|
111
133
|
setMenu,
|
|
112
134
|
validateNavTree,
|
|
@@ -0,0 +1,84 @@
|
|
|
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, type Component } from 'svelte';
|
|
6
|
+
import type { IslandRegistry } from './types.js';
|
|
7
|
+
|
|
8
|
+
export type { IslandRegistry } from './types.js';
|
|
9
|
+
|
|
10
|
+
// The live Svelte instances of the current pass and the observers still waiting to fire, kept module-level
|
|
11
|
+
// so the next pass can tear the previous one down. A layout calls hydrateIslands once per navigation, and
|
|
12
|
+
// the previous mounts must unmount before the next mount over the same DOM.
|
|
13
|
+
let mounted: Record<string, unknown>[] = [];
|
|
14
|
+
let observers: IntersectionObserver[] = [];
|
|
15
|
+
|
|
16
|
+
// Tear down the previous pass: unmount live instances and disconnect observers that never fired. unmount
|
|
17
|
+
// runs with outro: false so teardown is synchronous and deterministic on navigation; an island declaring an
|
|
18
|
+
// out: transition would otherwise linger and briefly double-render against the next pass's fresh mount.
|
|
19
|
+
function teardown(): void {
|
|
20
|
+
for (const o of observers) o.disconnect();
|
|
21
|
+
observers = [];
|
|
22
|
+
for (const instance of mounted) {
|
|
23
|
+
try {
|
|
24
|
+
void unmount(instance, { outro: false });
|
|
25
|
+
} catch {
|
|
26
|
+
// a component that throws on teardown must not block the rest
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
mounted = [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Mount one island over its boundary: parse props (try/catch, a malformed payload leaves the fallback),
|
|
33
|
+
// clear the fallback, mount, and on a mount failure restore the fallback so the reader still sees content.
|
|
34
|
+
// WATCH: props are trusted to equal the directive's declared scalar attributes (serializeIslandProps emits
|
|
35
|
+
// only those). If a directive ever carries an attribute its island does not declare, this forwards it as-is.
|
|
36
|
+
function mountIsland(node: Element, Comp: Component<Record<string, unknown>>): void {
|
|
37
|
+
let props: Record<string, unknown>;
|
|
38
|
+
try {
|
|
39
|
+
props = JSON.parse(node.getAttribute('data-cairn-props') ?? '{}') as Record<string, unknown>;
|
|
40
|
+
} catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const fallback = [...node.childNodes];
|
|
44
|
+
node.replaceChildren();
|
|
45
|
+
try {
|
|
46
|
+
mounted.push(mount(Comp, { target: node as HTMLElement, props }));
|
|
47
|
+
} catch {
|
|
48
|
+
node.replaceChildren(...fallback);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Defer a 'visible' island to first intersection, then mount once and stop observing.
|
|
53
|
+
function observeIsland(node: Element, Comp: Component<Record<string, unknown>>): void {
|
|
54
|
+
const observer = new IntersectionObserver((entries, self) => {
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (entry.isIntersecting) {
|
|
57
|
+
self.disconnect();
|
|
58
|
+
mountIsland(node, Comp);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
observer.observe(node);
|
|
63
|
+
observers.push(observer);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Mount each island in `root` (default `document`) over its server-rendered fallback. Call it after each
|
|
68
|
+
* client-side navigation, once the new DOM is in place (an `afterNavigate` callback): it tears down the
|
|
69
|
+
* previous pass first, so it is idempotent and leak-free. An eager island (`hydrate: true`) mounts at once;
|
|
70
|
+
* a `'visible'` island mounts on first intersection. An unknown directive name, a malformed prop payload,
|
|
71
|
+
* or a component that throws leaves the static fallback in place, so one bad island never breaks the page.
|
|
72
|
+
* Mount-and-replace clears the fallback, so an island whose fallback holds a focusable control should
|
|
73
|
+
* restore focus itself; the shipped fallbacks are non-interactive.
|
|
74
|
+
*/
|
|
75
|
+
export function hydrateIslands(islands: IslandRegistry, root: ParentNode = document): void {
|
|
76
|
+
teardown();
|
|
77
|
+
for (const node of root.querySelectorAll('[data-cairn-island]')) {
|
|
78
|
+
const name = node.getAttribute('data-cairn-island');
|
|
79
|
+
const Comp = name ? islands[name] : undefined;
|
|
80
|
+
if (!Comp) continue;
|
|
81
|
+
if (node.getAttribute('data-cairn-hydrate') === 'visible') observeIsland(node, Comp);
|
|
82
|
+
else mountIsland(node, Comp);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// cairn-cms islands (@glw907/cairn-cms/islands): the type contract shared by the adapter and the client
|
|
2
|
+
// runtime. Kept in its own runtime-free module so the adapter types can import it without pulling
|
|
3
|
+
// Svelte's mount() into the server graph.
|
|
4
|
+
import type { Component } from 'svelte';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A site's island components, keyed by directive name. Each value is the live Svelte component
|
|
8
|
+
* {@link hydrateIslands} mounts over the matching `hydrate` directive's static fallback. The props a
|
|
9
|
+
* component receives are the directive's declared scalar attributes (see the island boundary contract).
|
|
10
|
+
*/
|
|
11
|
+
export type IslandRegistry = Record<string, Component<Record<string, unknown>>>;
|
package/src/lib/log/events.ts
CHANGED
package/src/lib/media/index.ts
CHANGED
|
@@ -38,6 +38,20 @@ export function parseMediaManifest(json: unknown): MediaManifest {
|
|
|
38
38
|
return json as MediaManifest;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Read the committed media manifest from an `import.meta.glob` eager result, degrading a missing
|
|
43
|
+
* file to an empty manifest. A static import of an absent `media.json` fails the Vite build before
|
|
44
|
+
* any runtime degrade can run, so a fresh site with no manifest cannot build. A glob result is the
|
|
45
|
+
* build-safe read: `import.meta.glob` returns `{}` when nothing matches rather than throwing, and
|
|
46
|
+
* this helper extracts the single matched value and parses it, so a missing file reads a clean `{}`.
|
|
47
|
+
* @param globResult - The eager glob result for the committed manifest, an empty object when the
|
|
48
|
+
* file is absent. The consumer passes
|
|
49
|
+
* `import.meta.glob('<path-to-media.json>', { eager: true, import: 'default' })`.
|
|
50
|
+
*/
|
|
51
|
+
export function readCommittedManifest(globResult: Record<string, unknown>): MediaManifest {
|
|
52
|
+
return parseMediaManifest(Object.values(globResult)[0]);
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
/**
|
|
42
56
|
* Validate one posted value as a MediaEntry, returning it narrowed or undefined. The trust boundary
|
|
43
57
|
* for an optimistic record the client re-posts: the upload action server-owned each field at
|
|
@@ -16,11 +16,10 @@
|
|
|
16
16
|
// injected, so the planner never imports the editor surface. It is internal, exported from no package
|
|
17
17
|
// subpath, so it carries no reference page.
|
|
18
18
|
import type { ConceptDescriptor } from '../content/types.js';
|
|
19
|
-
import type {
|
|
19
|
+
import type { Backend } from '../github/backend.js';
|
|
20
20
|
import type { Manifest } from '../content/manifest.js';
|
|
21
21
|
import { findConcept } from '../content/concepts.js';
|
|
22
22
|
import { filenameFromId } from '../content/ids.js';
|
|
23
|
-
import { readRaw } from '../github/repo.js';
|
|
24
23
|
import { buildUsageIndex } from './usage.js';
|
|
25
24
|
|
|
26
25
|
/**
|
|
@@ -81,8 +80,7 @@ export interface RewritePlan<P = unknown> {
|
|
|
81
80
|
* editor surface and node-safe; the only IO is the usage index build and the per-entry reads.
|
|
82
81
|
*/
|
|
83
82
|
export async function planMediaRewrite<P = unknown>(args: {
|
|
84
|
-
backend:
|
|
85
|
-
token: string;
|
|
83
|
+
backend: Backend;
|
|
86
84
|
concepts: ConceptDescriptor[];
|
|
87
85
|
contentManifest: Manifest;
|
|
88
86
|
hash: string;
|
|
@@ -90,7 +88,7 @@ export async function planMediaRewrite<P = unknown>(args: {
|
|
|
90
88
|
}): Promise<RewritePlan<P>> {
|
|
91
89
|
// Strict so an unverifiable branch read rejects here rather than degrading to an absent reference.
|
|
92
90
|
// Do NOT wrap this: the throw is the fail-closed contract the apply relies on.
|
|
93
|
-
const index = await buildUsageIndex(args.backend, args.
|
|
91
|
+
const index = await buildUsageIndex(args.backend, args.concepts, args.contentManifest, {
|
|
94
92
|
strict: true,
|
|
95
93
|
});
|
|
96
94
|
const rows = index.get(args.hash) ?? [];
|
|
@@ -104,7 +102,7 @@ export async function planMediaRewrite<P = unknown>(args: {
|
|
|
104
102
|
const concept = findConcept(args.concepts, row.concept);
|
|
105
103
|
if (!concept) return null;
|
|
106
104
|
const path = `${concept.dir}/${filenameFromId(row.id)}`;
|
|
107
|
-
const markdown = await
|
|
105
|
+
const markdown = await args.backend.readFile(path, args.backend.defaultBranch);
|
|
108
106
|
if (markdown === null) return null;
|
|
109
107
|
const result = args.transform(markdown);
|
|
110
108
|
if (result.placements.length === 0) return null;
|
package/src/lib/media/usage.ts
CHANGED
|
@@ -19,10 +19,8 @@
|
|
|
19
19
|
// is therefore "found in N entries" / "no references found", never a bare "unused": absence of a row
|
|
20
20
|
// means no reference was found, not a proof that none exists.
|
|
21
21
|
import type { ConceptDescriptor } from '../content/types.js';
|
|
22
|
-
import type {
|
|
22
|
+
import type { Backend } from '../github/backend.js';
|
|
23
23
|
import type { Manifest } from '../content/manifest.js';
|
|
24
|
-
import { listBranches } from '../github/branches.js';
|
|
25
|
-
import { readRaw } from '../github/repo.js';
|
|
26
24
|
import { PENDING_PREFIX, parsePendingBranch } from '../content/pending.js';
|
|
27
25
|
import { findConcept } from '../content/concepts.js';
|
|
28
26
|
import { isValidId, filenameFromId } from '../content/ids.js';
|
|
@@ -84,8 +82,7 @@ function push(index: UsageIndex, hash: string, entry: UsageEntry): void {
|
|
|
84
82
|
* (the load path lists once for the media-union) rather than listing them a second time.
|
|
85
83
|
*/
|
|
86
84
|
export async function buildUsageIndex(
|
|
87
|
-
|
|
88
|
-
token: string,
|
|
85
|
+
backend: Backend,
|
|
89
86
|
concepts: ConceptDescriptor[],
|
|
90
87
|
manifest: Manifest,
|
|
91
88
|
opts: BuildUsageOptions = {},
|
|
@@ -108,7 +105,7 @@ export async function buildUsageIndex(
|
|
|
108
105
|
|
|
109
106
|
// The branch arm: read each open cairn/* branch's one edited file. The path is derivable from the
|
|
110
107
|
// branch name, so no tree-listing is needed. The branch list is reused when the caller passes it.
|
|
111
|
-
const names = opts.branches ?? (await listBranches(
|
|
108
|
+
const names = opts.branches ?? (await backend.listBranches(PENDING_PREFIX));
|
|
112
109
|
// Read the branches in parallel rather than one at a time, so the latency floor is one round trip
|
|
113
110
|
// instead of N. workerd self-throttles to 6 simultaneous outbound connections, so this batch and
|
|
114
111
|
// the load path's media-union batch each stay under the limit; do NOT merge the two into one
|
|
@@ -125,7 +122,7 @@ export async function buildUsageIndex(
|
|
|
125
122
|
|
|
126
123
|
const path = `${concept.dir}/${filenameFromId(ref.id)}`;
|
|
127
124
|
try {
|
|
128
|
-
const raw = await
|
|
125
|
+
const raw = await backend.readFile(path, name);
|
|
129
126
|
if (raw === null) return []; // The file is absent on the branch: nothing to extract.
|
|
130
127
|
const { frontmatter, body } = parseMarkdown(raw);
|
|
131
128
|
const fmTitle = frontmatter.title;
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// commits the file back through the GitHub-App pipeline. This module is pure: parse, validate, and
|
|
4
4
|
// rewrite only. The engine returns data; each site renders the tree with its own markup.
|
|
5
5
|
import { parse as parseYaml, parseDocument } from 'yaml';
|
|
6
|
-
import type { ConceptUrlPolicy } from '../content/types.js';
|
|
7
6
|
|
|
8
7
|
/** One navigation node. An omitted or empty `url` is a label-only grouping header; no `children` is a leaf. */
|
|
9
8
|
export interface NavNode {
|
|
@@ -75,12 +74,9 @@ export interface SiteConfig {
|
|
|
75
74
|
siteName: string;
|
|
76
75
|
description?: string;
|
|
77
76
|
author?: string;
|
|
78
|
-
url?: string;
|
|
79
77
|
locale?: string;
|
|
80
78
|
/** Named navigation menus, each a NavNode[] (normalized by extractMenu). */
|
|
81
79
|
menus?: Record<string, unknown>;
|
|
82
|
-
/** Per-concept URL policy: the permalink pattern and date-prefix granularity, keyed by concept id. */
|
|
83
|
-
content?: Record<string, ConceptUrlPolicy>;
|
|
84
80
|
/**
|
|
85
81
|
* The editor spellcheck settings. The dialect is declared once per site (spec 1.2), so a British
|
|
86
82
|
* site loads the British word list and "colour" reads as correct. Today only US English ships, so an
|
|
@@ -285,6 +281,14 @@ export function parseSiteConfig(raw: string): SiteConfig {
|
|
|
285
281
|
if (typeof siteName !== 'string' || !siteName.trim()) {
|
|
286
282
|
throw new SiteConfigError('Site config needs a siteName');
|
|
287
283
|
}
|
|
284
|
+
// Contract v2 moved per-concept URL policy out of the YAML and onto defineConcept. A leftover
|
|
285
|
+
// `content:` block here would silently do nothing while the concept defaulted its permalink, so a
|
|
286
|
+
// half-migrated site (one carrying a non-default datePrefix) would rewrite every post URL. Fail loud.
|
|
287
|
+
if ((parsed as SiteConfig).content !== undefined) {
|
|
288
|
+
throw new SiteConfigError(
|
|
289
|
+
'cairn: site config no longer carries per-concept URL policy; move permalink/datePrefix into defineConcept (Contract v2)',
|
|
290
|
+
);
|
|
291
|
+
}
|
|
288
292
|
return parsed as SiteConfig;
|
|
289
293
|
}
|
|
290
294
|
|
|
@@ -295,11 +299,6 @@ export function extractMenu(config: SiteConfig, name: string, maxDepth: number):
|
|
|
295
299
|
return validateNavTree(menu, maxDepth);
|
|
296
300
|
}
|
|
297
301
|
|
|
298
|
-
/** The per-concept URL policy from a parsed config, or an empty policy when the `content` key is absent. */
|
|
299
|
-
export function urlPolicyFrom(config: SiteConfig): Record<string, ConceptUrlPolicy> {
|
|
300
|
-
return config.content ?? {};
|
|
301
|
-
}
|
|
302
|
-
|
|
303
302
|
/**
|
|
304
303
|
* Replace one named menu in the YAML site-config text and reserialize, preserving every other
|
|
305
304
|
* top-level key (siteName, other menus, settings). Parses into a Document so the rest of the file
|
|
@@ -9,17 +9,17 @@ const COLON = ':';
|
|
|
9
9
|
|
|
10
10
|
function attrBlock(def: ComponentDef, values: ComponentValues): string {
|
|
11
11
|
const parts: string[] = [];
|
|
12
|
-
for (const field of def.attributes ??
|
|
13
|
-
const v = values.attributes[
|
|
12
|
+
for (const [name, field] of Object.entries(def.attributes ?? {})) {
|
|
13
|
+
const v = values.attributes[name];
|
|
14
14
|
if (field.type === 'boolean') {
|
|
15
|
-
if (v === true) parts.push(`${
|
|
15
|
+
if (v === true) parts.push(`${name}="true"`);
|
|
16
16
|
} else if (typeof v === 'string' && v !== '') {
|
|
17
17
|
// The directive attribute grammar (mdast-util-directive) treats a literal `"` as the value
|
|
18
18
|
// terminator and decodes HTML entities, so a backslash escape does not survive a round-trip.
|
|
19
19
|
// Encode `&` first (so existing entities are not double-decoded) then `"`; the parser decodes
|
|
20
20
|
// both back. A backslash is literal in this grammar and needs no escaping.
|
|
21
21
|
const escaped = v.replace(/&/g, '&').replace(/"/g, '"');
|
|
22
|
-
parts.push(`${
|
|
22
|
+
parts.push(`${name}="${escaped}"`);
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
return parts.length ? `{${parts.join(' ')}}` : '';
|
|
@@ -103,10 +103,10 @@ function valuesFromRoot(root: (RootContent & DirectiveNode) | undefined, def: Co
|
|
|
103
103
|
const values = emptyComponentValues(def);
|
|
104
104
|
if (!root) return values;
|
|
105
105
|
|
|
106
|
-
for (const field of def.attributes ??
|
|
107
|
-
const raw = root.attributes?.[
|
|
108
|
-
if (field.type === 'boolean') values.attributes[
|
|
109
|
-
else if (typeof raw === 'string') values.attributes[
|
|
106
|
+
for (const [name, field] of Object.entries(def.attributes ?? {})) {
|
|
107
|
+
const raw = root.attributes?.[name];
|
|
108
|
+
if (field.type === 'boolean') values.attributes[name] = raw === 'true';
|
|
109
|
+
else if (typeof raw === 'string') values.attributes[name] = raw;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
const titleSlot = slotByName(def, 'title');
|
|
@@ -181,7 +181,7 @@ export async function componentRoundTripSafety(markdown: string, def: ComponentD
|
|
|
181
181
|
const root = findComponentRoot(markdown, def);
|
|
182
182
|
if (!root) return { safe: false, reason: 'not-a-component' };
|
|
183
183
|
|
|
184
|
-
const declaredKeys = new Set((def.attributes ??
|
|
184
|
+
const declaredKeys = new Set(Object.keys(def.attributes ?? {}));
|
|
185
185
|
for (const key of parseRawAttributeKeys(markdown, def)) {
|
|
186
186
|
if (!declaredKeys.has(key)) return { safe: false, reason: 'unknown-attribute' };
|
|
187
187
|
}
|
|
@@ -220,7 +220,7 @@ export async function parseComponentWithRawKeys(
|
|
|
220
220
|
// here; the parse must overwrite only the fields actually present in the markdown.
|
|
221
221
|
function emptyComponentValues(def: ComponentDef): ComponentValues {
|
|
222
222
|
const attributes: Record<string, string | boolean> = {};
|
|
223
|
-
for (const
|
|
223
|
+
for (const [name, field] of Object.entries(def.attributes ?? {})) attributes[name] = field.type === 'boolean' ? false : '';
|
|
224
224
|
const slots: Record<string, string | string[]> = {};
|
|
225
225
|
for (const s of def.slots ?? []) slots[s.name] = s.kind === 'repeatable' ? [] : '';
|
|
226
226
|
return { attributes, slots };
|
|
@@ -27,9 +27,10 @@ function componentSection(def: ComponentDef): string {
|
|
|
27
27
|
/** Seed example values that show every declared field: an ellipsis for strings, one sample list item. */
|
|
28
28
|
function exampleValues(def: ComponentDef): ComponentValues {
|
|
29
29
|
const values = emptyValues(def);
|
|
30
|
-
for (const field of def.attributes ??
|
|
31
|
-
if (field.type === 'boolean') values.attributes[
|
|
32
|
-
else values.attributes[
|
|
30
|
+
for (const [name, field] of Object.entries(def.attributes ?? {})) {
|
|
31
|
+
if (field.type === 'boolean') values.attributes[name] = false;
|
|
32
|
+
else if (field.type === 'select') values.attributes[name] = field.options[0] ?? '…';
|
|
33
|
+
else values.attributes[name] = '…';
|
|
33
34
|
}
|
|
34
35
|
for (const slot of def.slots ?? []) {
|
|
35
36
|
if (slot.kind === 'repeatable') values.slots[slot.name] = ['…'];
|
|
@@ -1,38 +1,25 @@
|
|
|
1
1
|
import { parseComponentWithRawKeys } from './component-grammar.js';
|
|
2
|
-
import
|
|
2
|
+
import { fieldset } from '../content/fieldset.js';
|
|
3
|
+
import type { ComponentDef } from './registry.js';
|
|
3
4
|
|
|
4
5
|
/** A validation verdict: ok, or field-keyed error messages. */
|
|
5
6
|
export type ComponentValidation = { ok: true } | { ok: false; errors: Record<string, string> };
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
|
-
*
|
|
9
|
+
* Validate a serialized component directive against its definition: the attributes through the same
|
|
10
|
+
* `fieldset` validator a concept field uses (coercion, constraints, required, select domain, pattern,
|
|
11
|
+
* and any per-attribute `behavior.validate`), then the two component-only checks, an unknown attribute
|
|
12
|
+
* key and an unfilled required slot.
|
|
9
13
|
*/
|
|
10
14
|
export async function validateComponent(markdown: string, def: ComponentDef): Promise<ComponentValidation> {
|
|
11
15
|
const { values, rawKeys } = await parseComponentWithRawKeys(markdown, def);
|
|
12
16
|
const errors: Record<string, string> = {};
|
|
13
|
-
const declared = new Set((def.attributes ?? []).map((f) => f.key));
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (field.required && !filled) {
|
|
19
|
-
errors[field.key] = `${field.label} is required.`;
|
|
20
|
-
continue;
|
|
21
|
-
}
|
|
22
|
-
if (field.type === 'select' && typeof v === 'string' && v !== '' && !(field.options ?? []).includes(v)) {
|
|
23
|
-
errors[field.key] = `${field.label} must be one of: ${(field.options ?? []).join(', ')}.`;
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
if (field.pattern && typeof v === 'string' && v !== '' && !new RegExp(field.pattern.source).test(v)) {
|
|
27
|
-
errors[field.key] = field.pattern.message;
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
if (field.validate) {
|
|
31
|
-
const message = runFieldValidator(def, field.key, () => field.validate!(v, values));
|
|
32
|
-
if (typeof message === 'string') errors[field.key] = message;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
18
|
+
const schema = def.attributeSchema ?? fieldset(def.attributes ?? {}, { behavior: def.behavior });
|
|
19
|
+
const result = schema.validate(values.attributes, '');
|
|
20
|
+
if (!result.ok) Object.assign(errors, result.errors);
|
|
35
21
|
|
|
22
|
+
const declared = new Set(Object.keys(def.attributes ?? {}));
|
|
36
23
|
for (const key of rawKeys) {
|
|
37
24
|
if (!declared.has(key)) errors[key] = `Unknown attribute "${key}".`;
|
|
38
25
|
}
|
|
@@ -46,15 +33,3 @@ export async function validateComponent(markdown: string, def: ComponentDef): Pr
|
|
|
46
33
|
|
|
47
34
|
return Object.keys(errors).length ? { ok: false, errors } : { ok: true };
|
|
48
35
|
}
|
|
49
|
-
|
|
50
|
-
// Run a site-supplied attribute validator. The validator is author code, so a throw is contained:
|
|
51
|
-
// the field is treated as valid and a dev-time warning names the component and field so the author
|
|
52
|
-
// can find the bug. A returned string is the field error; anything else (null) is clean.
|
|
53
|
-
function runFieldValidator(def: ComponentDef, key: string, call: () => string | null): string | null {
|
|
54
|
-
try {
|
|
55
|
-
return call();
|
|
56
|
-
} catch (err) {
|
|
57
|
-
console.warn(`cairn: validate() for component "${def.name}" field "${key}" threw; treating the field as valid.`, err);
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
}
|