@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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// cairn-cms: the cross-branch reference index, the where-referenced core of the rename and delete
|
|
2
|
+
// gates. It answers "which entries reference this target entry" for every reference edge, keyed by the
|
|
3
|
+
// target's (concept, id) PAIR. The key is the pair and never an id alone (unlike media/usage.ts, which
|
|
4
|
+
// keys on a globally-unique content hash), because an id is unique only within a concept: pages/about
|
|
5
|
+
// and posts/about are distinct targets, and reverse-mapping by id alone would cross that boundary and
|
|
6
|
+
// refuse a rename or delete on a phantom inbound. The map unions two sources: the published corpus on
|
|
7
|
+
// main and every open cairn/* edit branch, so a target referenced only in an unpublished draft still
|
|
8
|
+
// counts as referenced and is not mistaken for safe to delete or freely rename.
|
|
9
|
+
//
|
|
10
|
+
// The main arm reads the content manifest's per-entry references (the edges manifestEntryFromFile
|
|
11
|
+
// records) and builds the reverse map; it never crawls the files, since the manifest already carries
|
|
12
|
+
// the edges. The branch arm cannot use a manifest (the content manifest is never committed to a
|
|
13
|
+
// branch), so it reconstructs each edited entry's path from the branch name, reads that one file, and
|
|
14
|
+
// runs the schema extractor directly.
|
|
15
|
+
import type { ConceptDescriptor } from './types.js';
|
|
16
|
+
import type { Backend } from '../github/backend.js';
|
|
17
|
+
import type { Manifest } from './manifest.js';
|
|
18
|
+
import { PENDING_PREFIX, parsePendingBranch } from './pending.js';
|
|
19
|
+
import { findConcept } from './concepts.js';
|
|
20
|
+
import { isValidId, filenameFromId } from './ids.js';
|
|
21
|
+
import { parseMarkdown } from './frontmatter.js';
|
|
22
|
+
import { extractReferenceEdges } from './references.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Where a reference lives: the published corpus on main, or a named open edit branch. Re-declared here
|
|
26
|
+
* (rather than imported from media/usage.ts) so the content layer does not depend on the media layer.
|
|
27
|
+
*/
|
|
28
|
+
export type UsageOrigin = { kind: 'published' } | { kind: 'branch'; branch: string };
|
|
29
|
+
|
|
30
|
+
/** One entry that references a target, in a shape the rename and delete gates name and group by. */
|
|
31
|
+
export interface ReferenceUsageEntry {
|
|
32
|
+
/** The referencing (source) entry's concept id, e.g. "posts". */
|
|
33
|
+
concept: string;
|
|
34
|
+
/** The referencing (source) entry's id (its filename stem). */
|
|
35
|
+
id: string;
|
|
36
|
+
/** The referencing entry's title for display, from the manifest (published) or frontmatter (branch). */
|
|
37
|
+
title: string;
|
|
38
|
+
/** The referencing entry's public permalink, present for a published entry (carried from the manifest). */
|
|
39
|
+
permalink?: string;
|
|
40
|
+
/** The frontmatter field the edge was declared on. */
|
|
41
|
+
field: string;
|
|
42
|
+
/** Published vs the cairn/* branch the edit lives on. */
|
|
43
|
+
origin: UsageOrigin;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The target's `${concept}/${id}` pair to the distinct entries that reference it. A pair with no row is
|
|
48
|
+
* not referenced anywhere the index could read (main plus the listed open branches).
|
|
49
|
+
*/
|
|
50
|
+
export type ReferenceIndex = Map<string, ReferenceUsageEntry[]>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build options. `branches` lets a caller that already listed the open cairn/* branches pass them in so
|
|
54
|
+
* the index does not list them a second time. `strict` flips the per-branch read from degrade-and-skip
|
|
55
|
+
* to fail-closed: a delete or rename gate must not treat a transient branch-read failure as an absent
|
|
56
|
+
* reference, so it rethrows instead.
|
|
57
|
+
*/
|
|
58
|
+
export interface BuildReferenceOptions {
|
|
59
|
+
/** The open cairn/* branch names, already listed. When present the index skips its own listing. */
|
|
60
|
+
branches?: string[];
|
|
61
|
+
/** When true a branch read that throws rejects the whole build, so the caller can fail closed. */
|
|
62
|
+
strict?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Append a row under its target pair key, creating the bucket on first use. */
|
|
66
|
+
function push(index: ReferenceIndex, key: string, entry: ReferenceUsageEntry): void {
|
|
67
|
+
const rows = index.get(key);
|
|
68
|
+
if (rows) rows.push(entry);
|
|
69
|
+
else index.set(key, [entry]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the pair-keyed reference index over main (from the manifest's per-entry references) plus every
|
|
74
|
+
* open cairn/* branch (parsed from its edited markdown).
|
|
75
|
+
*
|
|
76
|
+
* By default a single branch read that throws degrades that one branch and is skipped, the way the
|
|
77
|
+
* admin loaders degrade a failed read. That tolerance is wrong for the rename and delete gates: a
|
|
78
|
+
* transient branch-read failure would make a still-referenced target look free. Pass `strict: true` to
|
|
79
|
+
* rethrow a branch failure so the caller fails closed. Pass `branches` to reuse a branch list the
|
|
80
|
+
* caller already has rather than listing them a second time.
|
|
81
|
+
*/
|
|
82
|
+
export async function buildReferenceIndex(
|
|
83
|
+
backend: Backend,
|
|
84
|
+
concepts: ConceptDescriptor[],
|
|
85
|
+
manifest: Manifest,
|
|
86
|
+
opts: BuildReferenceOptions = {},
|
|
87
|
+
): Promise<ReferenceIndex> {
|
|
88
|
+
const index: ReferenceIndex = new Map();
|
|
89
|
+
|
|
90
|
+
// The main arm: the manifest already carries each entry's reference edges, so this is a pure reverse
|
|
91
|
+
// map with no per-file read. The KEY is the edge's TARGET (concept, id); the ROW is the source entry.
|
|
92
|
+
for (const entry of manifest.entries) {
|
|
93
|
+
for (const edge of entry.references ?? []) {
|
|
94
|
+
push(index, `${edge.concept}/${edge.id}`, {
|
|
95
|
+
concept: entry.concept,
|
|
96
|
+
id: entry.id,
|
|
97
|
+
title: entry.title,
|
|
98
|
+
permalink: entry.permalink,
|
|
99
|
+
field: edge.field,
|
|
100
|
+
origin: { kind: 'published' },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The branch arm: read each open cairn/* branch's one edited file. The path is derivable from the
|
|
106
|
+
// branch name, so no tree-listing is needed. The branch list is reused when the caller passes it.
|
|
107
|
+
const names = opts.branches ?? (await backend.listBranches(PENDING_PREFIX));
|
|
108
|
+
// Read the branches in parallel rather than one at a time, so the latency floor is one round trip
|
|
109
|
+
// instead of N. workerd self-throttles to 6 simultaneous outbound connections, so this batch and
|
|
110
|
+
// the load path's media-union and linker reads each stay under the limit; do NOT run this fan-out
|
|
111
|
+
// concurrently with those (a future combined safety gate must not wrap them in one Promise.all),
|
|
112
|
+
// since the merged fan-out would queue behind that throttle.
|
|
113
|
+
const perBranch = await Promise.all(
|
|
114
|
+
names.map(async (name): Promise<{ key: string; entry: ReferenceUsageEntry }[]> => {
|
|
115
|
+
// Resolve the branch name to a configured entry with the same guard the branch tooling uses: a
|
|
116
|
+
// malformed name, an id that fails the slug rule (entry paths are built from it, so this is the
|
|
117
|
+
// path confinement), or a concept this site does not configure is skipped, no read attempted.
|
|
118
|
+
const ref = parsePendingBranch(name);
|
|
119
|
+
if (!ref || !isValidId(ref.id)) return [];
|
|
120
|
+
const concept = findConcept(concepts, ref.concept);
|
|
121
|
+
if (!concept) return [];
|
|
122
|
+
|
|
123
|
+
const path = `${concept.dir}/${filenameFromId(ref.id)}`;
|
|
124
|
+
try {
|
|
125
|
+
const raw = await backend.readFile(path, name);
|
|
126
|
+
if (raw === null) return []; // The file is absent on the branch: nothing to extract.
|
|
127
|
+
const { frontmatter } = parseMarkdown(raw);
|
|
128
|
+
const fmTitle = frontmatter.title;
|
|
129
|
+
const title = typeof fmTitle === 'string' && fmTitle.trim() ? fmTitle : ref.id;
|
|
130
|
+
const rows: { key: string; entry: ReferenceUsageEntry }[] = [];
|
|
131
|
+
for (const edge of extractReferenceEdges(frontmatter, concept.fields)) {
|
|
132
|
+
rows.push({
|
|
133
|
+
key: `${edge.concept}/${edge.id}`,
|
|
134
|
+
entry: {
|
|
135
|
+
concept: concept.id,
|
|
136
|
+
id: ref.id,
|
|
137
|
+
title,
|
|
138
|
+
field: edge.field,
|
|
139
|
+
origin: { kind: 'branch', branch: name },
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return rows;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// In strict mode a branch failure fails the whole build so the gate can fail closed; otherwise
|
|
146
|
+
// degrade this one branch rather than sinking the screen.
|
|
147
|
+
if (opts.strict) throw err;
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Fold the per-branch rows back in, preserving the branch order so the index reads stably.
|
|
154
|
+
for (const rows of perBranch) {
|
|
155
|
+
for (const { key, entry } of rows) push(index, key, entry);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return index;
|
|
159
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// cairn-cms: the Standard Schema conformance types, shared by both the v1 schema and the v2
|
|
2
|
+
// fieldset validators. They live here, apart from either validator, so the v2 `fieldset` keeps
|
|
3
|
+
// importing them once the v1 `schema.ts` is removed at the Contract v2 cutover.
|
|
4
|
+
|
|
5
|
+
/** The validate input the cairn adapter takes: the raw frontmatter and the body. */
|
|
6
|
+
export interface StandardInput {
|
|
7
|
+
frontmatter: Record<string, unknown>;
|
|
8
|
+
body: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A minimal local copy of the Standard Schema v1 interface (https://standardschema.dev), so the
|
|
13
|
+
* schema is a drop-in where the ecosystem accepts a validator, with no runtime dependency.
|
|
14
|
+
*/
|
|
15
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
16
|
+
readonly '~standard': {
|
|
17
|
+
readonly version: 1;
|
|
18
|
+
readonly vendor: string;
|
|
19
|
+
readonly validate: (value: unknown) => StandardResult<Output>;
|
|
20
|
+
readonly types?: { readonly input: Input; readonly output: Output };
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
type StandardResult<Output> =
|
|
24
|
+
| { readonly value: Output; readonly issues?: undefined }
|
|
25
|
+
| { readonly issues: ReadonlyArray<{ readonly message: string; readonly path?: ReadonlyArray<PropertyKey> }> };
|
package/src/lib/content/types.ts
CHANGED
|
@@ -9,107 +9,14 @@
|
|
|
9
9
|
// boundary to the editor form.
|
|
10
10
|
import type { ComponentRegistry } from '../render/registry.js';
|
|
11
11
|
import type { IconSet } from '../render/glyph.js';
|
|
12
|
+
import type { IslandRegistry } from '../islands/types.js';
|
|
13
|
+
import type { BackendProvider } from '../github/backend.js';
|
|
12
14
|
import type { DatePrefix } from './ids.js';
|
|
13
|
-
import type {
|
|
15
|
+
import type { Fieldset } from './fieldset.js';
|
|
16
|
+
import type { FieldDescriptor } from './fields.js';
|
|
14
17
|
import type { LinkResolve } from './links.js';
|
|
15
18
|
import type { VariantSpec } from '../media/transform-url.js';
|
|
16
19
|
|
|
17
|
-
/** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
|
|
18
|
-
interface FieldBase {
|
|
19
|
-
/** Frontmatter key and form input name. */
|
|
20
|
-
name: string;
|
|
21
|
-
/** Form label. */
|
|
22
|
-
label: string;
|
|
23
|
-
/** A required field fails validation when empty (spec §7.4). */
|
|
24
|
-
required?: boolean;
|
|
25
|
-
/**
|
|
26
|
-
* One author-facing sentence shown under the field in the editor, in plain end-user language.
|
|
27
|
-
* Optional; render nothing when absent. Not a validation rule.
|
|
28
|
-
*/
|
|
29
|
-
description?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** A single-line text input. */
|
|
33
|
-
export interface TextField extends FieldBase {
|
|
34
|
-
type: 'text';
|
|
35
|
-
/** Minimum character length of a non-empty value. */
|
|
36
|
-
min?: number;
|
|
37
|
-
/** Maximum character length. */
|
|
38
|
-
max?: number;
|
|
39
|
-
/** Exact required character length. */
|
|
40
|
-
length?: number;
|
|
41
|
-
/**
|
|
42
|
-
* A regular-expression source string the value must match. Stored as a string so the field
|
|
43
|
-
* list stays plain serializable data; the validator compiles it.
|
|
44
|
-
*/
|
|
45
|
-
pattern?: string;
|
|
46
|
-
}
|
|
47
|
-
/** A multi-line text input. */
|
|
48
|
-
export interface TextareaField extends FieldBase {
|
|
49
|
-
type: 'textarea';
|
|
50
|
-
/** Visible rows; the editor picks a default when omitted. */
|
|
51
|
-
rows?: number;
|
|
52
|
-
/** Minimum character length of a non-empty value. */
|
|
53
|
-
min?: number;
|
|
54
|
-
/** Maximum character length. */
|
|
55
|
-
max?: number;
|
|
56
|
-
/** Exact required character length. */
|
|
57
|
-
length?: number;
|
|
58
|
-
/** A regular-expression source string the value must match. */
|
|
59
|
-
pattern?: string;
|
|
60
|
-
}
|
|
61
|
-
/** A `YYYY-MM-DD` date input. */
|
|
62
|
-
export interface DateField extends FieldBase {
|
|
63
|
-
type: 'date';
|
|
64
|
-
/** Earliest allowed date, as `YYYY-MM-DD`. */
|
|
65
|
-
min?: string;
|
|
66
|
-
/** Latest allowed date, as `YYYY-MM-DD`. */
|
|
67
|
-
max?: string;
|
|
68
|
-
}
|
|
69
|
-
/** A checkbox; absent means false. */
|
|
70
|
-
export interface BooleanField extends FieldBase {
|
|
71
|
-
type: 'boolean';
|
|
72
|
-
}
|
|
73
|
-
/** A closed-vocabulary tag set, rendered as checkboxes (ecnordic). */
|
|
74
|
-
export interface TagsField extends FieldBase {
|
|
75
|
-
type: 'tags';
|
|
76
|
-
/** The controlled vocabulary. */
|
|
77
|
-
options: readonly string[];
|
|
78
|
-
}
|
|
79
|
-
/** Free-form tags, edited as one comma-separated input (907). */
|
|
80
|
-
export interface FreeTagsField extends FieldBase {
|
|
81
|
-
type: 'freetags';
|
|
82
|
-
placeholder?: string;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* A hero image set in frontmatter. The stored value is the nested object
|
|
86
|
-
* `{ src: string; alt: string; caption?: string }`, where `src` is a 2b `media:` reference, `alt`
|
|
87
|
-
* is the screen-reader description, and `caption` is an optional line the site template may show.
|
|
88
|
-
* One image serves two jobs: the template's lead image and the social-card image. The field feeding
|
|
89
|
-
* the social card is the `seo`-flagged one, defaulting to the field named `image`; a concept declares
|
|
90
|
-
* at most one SEO image field.
|
|
91
|
-
*/
|
|
92
|
-
export interface ImageField extends FieldBase {
|
|
93
|
-
type: 'image';
|
|
94
|
-
/** Whether this field feeds the social-card image. The field named `image` defaults to true. */
|
|
95
|
-
seo?: boolean;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* The discriminated union the per-concept frontmatter form is generated from. A scalar field type
|
|
100
|
-
* is one variant here plus one decode arm in `frontmatterFromForm` and one in `validateFields`. The
|
|
101
|
-
* structured `image` field additionally needs a read-back arm in `formValues` and a type-inference
|
|
102
|
-
* arm in `schema.ts`, since its value is a nested object rather than a single string.
|
|
103
|
-
*/
|
|
104
|
-
export type FrontmatterField =
|
|
105
|
-
| TextField
|
|
106
|
-
| TextareaField
|
|
107
|
-
| DateField
|
|
108
|
-
| BooleanField
|
|
109
|
-
| TagsField
|
|
110
|
-
| FreeTagsField
|
|
111
|
-
| ImageField;
|
|
112
|
-
|
|
113
20
|
/**
|
|
114
21
|
* The stored value of an `image` field: a `media:` reference, a screen-reader description, and an
|
|
115
22
|
* optional caption.
|
|
@@ -122,59 +29,75 @@ export interface ImageValue {
|
|
|
122
29
|
decorative?: boolean;
|
|
123
30
|
}
|
|
124
31
|
|
|
32
|
+
/** One validation failure located by a path: a top-level key, then a row index and/or a leaf sub-key. */
|
|
33
|
+
export interface ValidationIssue {
|
|
34
|
+
/** The path to the failing field, e.g. ['faq', 0, 'question'] or ['address', 'city'] or ['title']. */
|
|
35
|
+
path: (string | number)[];
|
|
36
|
+
/** The author-facing message, naming the field's label. */
|
|
37
|
+
message: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
125
40
|
/**
|
|
126
|
-
* A validator's verdict. On success it carries the normalized frontmatter to commit; on
|
|
127
|
-
*
|
|
128
|
-
*
|
|
41
|
+
* A validator's verdict. On success it carries the normalized frontmatter to commit; on failure it
|
|
42
|
+
* carries field-keyed error messages (the empty key is a form-level error) and, additively, the
|
|
43
|
+
* located `issues` with multi-segment paths so the form can route a nested-container error to the
|
|
44
|
+
* right input. Invalid input bounces to the form and never reaches git (spec §7.4).
|
|
129
45
|
*/
|
|
130
46
|
export type ValidationResult =
|
|
131
47
|
| { ok: true; data: Record<string, unknown> }
|
|
132
|
-
| { ok: false; errors: Record<string, string
|
|
48
|
+
| { ok: false; errors: Record<string, string>; issues?: ValidationIssue[] };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A field descriptor with its frontmatter key re-attached as `name`. This is the normalized form
|
|
52
|
+
* `ConceptDescriptor.fields` carries: `normalizeConcepts` derives it from a concept's `fieldset`
|
|
53
|
+
* record so every consumer (the editor form, the form decoder, the media extractor) iterates an
|
|
54
|
+
* array and reads `name` rather than the keyed record.
|
|
55
|
+
*/
|
|
56
|
+
export type NamedField = FieldDescriptor & { name: string };
|
|
133
57
|
|
|
134
58
|
/**
|
|
135
|
-
* Per-site configuration for one content concept (spec §8). One `
|
|
136
|
-
* `
|
|
137
|
-
* inferred frontmatter type. Generic over the
|
|
138
|
-
* typed reads.
|
|
139
|
-
*
|
|
59
|
+
* Per-site configuration for one content concept (spec §8). One `fields` fieldset, built with
|
|
60
|
+
* `fieldset`, is the single source of truth for the editor form, the validator, and the
|
|
61
|
+
* inferred frontmatter type. Generic over the fieldset so a concept's concrete type survives for
|
|
62
|
+
* typed reads. A concept also declares its own routing and URL policy here (`routing`, `permalink`,
|
|
63
|
+
* `datePrefix`), resolved by `normalizeConcepts`.
|
|
140
64
|
*/
|
|
141
|
-
export interface ConceptConfig<S extends
|
|
65
|
+
export interface ConceptConfig<S extends Fieldset = Fieldset> {
|
|
142
66
|
/** Repo-relative content directory, e.g. "src/content/posts". */
|
|
143
67
|
dir: string;
|
|
144
68
|
/** Sidebar label; defaults from the concept id when omitted. */
|
|
145
69
|
label?: string;
|
|
146
70
|
/** The singular noun for the create affordances ("New post"); defaults to `label` when omitted. */
|
|
147
71
|
singular?: string;
|
|
148
|
-
/** The concept's
|
|
149
|
-
|
|
72
|
+
/** The concept's fieldset: the form projection, the generated validator, and the inferred type. */
|
|
73
|
+
fields: S;
|
|
74
|
+
/**
|
|
75
|
+
* This concept's routing. A named shorthand (`'feed'` dated and in feeds, `'page'` a routable
|
|
76
|
+
* static page, `'embedded'` not routable) or an explicit rule. Omitted means `'page'`.
|
|
77
|
+
*/
|
|
78
|
+
routing?: 'feed' | 'page' | 'embedded' | RoutingRule;
|
|
79
|
+
/** The permalink pattern, root-relative, e.g. `/blog/:year/:slug`. Defaults by concept id. */
|
|
80
|
+
permalink?: string;
|
|
81
|
+
/** Date-prefix granularity for a dated concept's id-to-slug stripping. Defaults to `day`. */
|
|
82
|
+
datePrefix?: DatePrefix;
|
|
150
83
|
/**
|
|
151
84
|
* Frontmatter keys to surface on each `ContentSummary.fields`, so a list card reads an authored
|
|
152
|
-
* field without a per-entry detail read. Each key should also be declared in `
|
|
85
|
+
* field without a per-entry detail read. Each key should also be declared in `fields`.
|
|
153
86
|
*/
|
|
154
87
|
summaryFields?: string[];
|
|
155
88
|
}
|
|
156
89
|
|
|
157
90
|
/**
|
|
158
|
-
* A concept's URL policy,
|
|
159
|
-
* a `/`-prefixed pattern of literal
|
|
160
|
-
*
|
|
161
|
-
* `normalizeConcepts` when omitted.
|
|
91
|
+
* A concept's URL policy, declared on the adapter concept itself (`ConceptConfig.permalink` and
|
|
92
|
+
* `ConceptConfig.datePrefix`) since Contract v2. `permalink` is a `/`-prefixed pattern of literal
|
|
93
|
+
* segments and the tokens `:slug`, `:year`, `:month`, `:day`. `datePrefix` is the filename
|
|
94
|
+
* date-prefix granularity for a dated concept. Both default in `normalizeConcepts` when omitted.
|
|
162
95
|
*/
|
|
163
96
|
export interface ConceptUrlPolicy {
|
|
164
97
|
permalink?: string;
|
|
165
98
|
datePrefix?: DatePrefix;
|
|
166
99
|
}
|
|
167
100
|
|
|
168
|
-
/** The GitHub App backend a site reads from and commits to (spec §8). Plain data the GitHub engine (Plan 03) consumes. */
|
|
169
|
-
export interface BackendConfig {
|
|
170
|
-
owner: string;
|
|
171
|
-
repo: string;
|
|
172
|
-
/** Commit target, e.g. "main". */
|
|
173
|
-
branch: string;
|
|
174
|
-
appId: string;
|
|
175
|
-
installationId: string;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
101
|
/** Magic-link sender identity for Cloudflare Email Sending. */
|
|
179
102
|
export interface SenderConfig {
|
|
180
103
|
from: string;
|
|
@@ -256,67 +179,73 @@ export interface AssetConfig {
|
|
|
256
179
|
transformations?: boolean;
|
|
257
180
|
}
|
|
258
181
|
|
|
259
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* The site's one renderer (design decision 4): the editor preview and every public page call it.
|
|
184
|
+
* Entry-aware so a custom renderer can vary output by concept or frontmatter; the default reads only
|
|
185
|
+
* `body` plus the resolvers. `resolve` rewrites cairn: links to live permalinks (the build passes a
|
|
186
|
+
* site-resolver-backed resolver, the preview a manifest-backed one); `resolveMedia` resolves media:
|
|
187
|
+
* references the same way. `concept` and `frontmatter` carry the entry's context for an entry render
|
|
188
|
+
* and are absent for the standalone component-insert preview.
|
|
189
|
+
*/
|
|
190
|
+
export type SiteRender = (input: {
|
|
191
|
+
body: string;
|
|
192
|
+
concept?: string;
|
|
193
|
+
frontmatter?: Record<string, unknown>;
|
|
194
|
+
resolve?: LinkResolve;
|
|
195
|
+
resolveMedia?: import('../render/resolve-media.js').MediaResolve;
|
|
196
|
+
}) => Promise<string>;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The single seam the engine consumes. A site implements this at `src/lib/cairn.config.ts`, in six
|
|
200
|
+
* subsystem groups (spec §8): the content concepts, the commit backend, the magic-link sender, the
|
|
201
|
+
* render subsystem, the optional media stack, and the admin-experience knobs. The internal manifest
|
|
202
|
+
* and dictionary paths are not here; `composeRuntime` defaults them by convention.
|
|
203
|
+
*/
|
|
260
204
|
export interface CairnAdapter {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
205
|
+
/** The site's concepts, keyed by id. Posts and pages are the documented defaults; a site may add more. */
|
|
206
|
+
content: Record<string, ConceptConfig>;
|
|
207
|
+
/** The commit backend provider, from `githubApp({ ... })` (the GitHub App today). */
|
|
208
|
+
backend: BackendProvider;
|
|
209
|
+
/** The magic-link sender. */
|
|
210
|
+
email: SenderConfig;
|
|
211
|
+
/** The render subsystem: the one renderer, its directive vocabulary, and its icons. */
|
|
212
|
+
rendering: {
|
|
213
|
+
/**
|
|
214
|
+
* The one renderer the editor preview and every public page call (design decision 4). `resolve`
|
|
215
|
+
* rewrites cairn: links to live permalinks; the build passes a site-resolver-backed one, the
|
|
216
|
+
* preview a manifest one. `resolveMedia` resolves media: references the same way.
|
|
217
|
+
*/
|
|
218
|
+
render: SiteRender;
|
|
219
|
+
/** Directive component registry; the renderer and the insert palette derive from it (seam 3). */
|
|
220
|
+
components?: ComponentRegistry;
|
|
221
|
+
/** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
|
|
222
|
+
icons?: IconSet;
|
|
223
|
+
/**
|
|
224
|
+
* The live Svelte components for hydrated directives, keyed by directive name (phase 4b islands).
|
|
225
|
+
* Every component whose {@link ComponentDef.hydrate} is set needs an entry here, and every entry
|
|
226
|
+
* needs a matching `hydrate` component; `defineAdapter` checks both. Absent leaves the site
|
|
227
|
+
* static, and the island client runtime is never imported.
|
|
228
|
+
*/
|
|
229
|
+
islands?: IslandRegistry;
|
|
230
|
+
};
|
|
231
|
+
/** R2-backed media (seam 4): the bucket binding and image variants. Absent leaves media off. */
|
|
232
|
+
media?: AssetConfig;
|
|
233
|
+
/** Admin-experience knobs: the preview frame, the nav menu, and the editor support contact. */
|
|
234
|
+
editor?: {
|
|
235
|
+
/**
|
|
236
|
+
* The live site's content styling for the preview frame. The admin's chrome isolation keeps
|
|
237
|
+
* the site's CSS out of the admin document, so the preview frame links these instead.
|
|
238
|
+
*/
|
|
239
|
+
preview?: PreviewConfig;
|
|
240
|
+
/** Which git-committed YAML menu the nav editor manages. */
|
|
241
|
+
nav?: NavMenuConfig;
|
|
242
|
+
/**
|
|
243
|
+
* Optional contact a stuck editor is pointed to from the in-admin help (an email address, a URL,
|
|
244
|
+
* or a name and instruction). The help renders the hand-off only when this is set. Plain string,
|
|
245
|
+
* passed through verbatim.
|
|
246
|
+
*/
|
|
247
|
+
supportContact?: string;
|
|
269
248
|
};
|
|
270
|
-
backend: BackendConfig;
|
|
271
|
-
sender: SenderConfig;
|
|
272
|
-
/**
|
|
273
|
-
* Optional contact a stuck editor is pointed to from the in-admin help (an email address, a URL,
|
|
274
|
-
* or a name and instruction). The help renders the hand-off only when this is set. Plain string,
|
|
275
|
-
* passed through verbatim.
|
|
276
|
-
*/
|
|
277
|
-
supportContact?: string;
|
|
278
|
-
/**
|
|
279
|
-
* The site's one renderer: the editor preview and every public page call it (design decision 4).
|
|
280
|
-
* `resolve` rewrites cairn: links to live permalinks; the build passes a site-resolver-backed
|
|
281
|
-
* one, the preview a manifest one. The trailing `resolveMedia` is additive and optional: the build
|
|
282
|
-
* passes a site-resolver-backed media resolver, the preview a manifest-backed one.
|
|
283
|
-
*/
|
|
284
|
-
render(
|
|
285
|
-
md: string,
|
|
286
|
-
opts?: {
|
|
287
|
-
stagger?: boolean;
|
|
288
|
-
resolve?: LinkResolve;
|
|
289
|
-
resolveMedia?: import('../render/resolve-media.js').MediaResolve;
|
|
290
|
-
},
|
|
291
|
-
): string | Promise<string>;
|
|
292
|
-
/**
|
|
293
|
-
* Repo-relative path to the committed content manifest. Defaults to src/content/.cairn/index.json
|
|
294
|
-
* in composeRuntime. It sits outside any concept directory, so content enumeration never globs it.
|
|
295
|
-
*/
|
|
296
|
-
manifestPath?: string;
|
|
297
|
-
/**
|
|
298
|
-
* Repo-relative path to the committed media manifest. Defaults to src/content/.cairn/media.json,
|
|
299
|
-
* applied in composeRuntime. Sits outside any concept directory, like the content manifest.
|
|
300
|
-
*/
|
|
301
|
-
mediaManifestPath?: string;
|
|
302
|
-
/**
|
|
303
|
-
* Repo-relative path to the committed personal dictionary file. Defaults to
|
|
304
|
-
* src/content/.cairn/dictionary.txt, applied in composeRuntime: the same `.cairn/` content root the
|
|
305
|
-
* manifests use, so the spec's `content/.cairn/dictionary.txt` resolves the same configurable way the
|
|
306
|
-
* manifest paths do. One word per line, sorted, comment lines allowed (see site-dictionary.ts).
|
|
307
|
-
*/
|
|
308
|
-
dictionaryPath?: string;
|
|
309
|
-
/** Directive component registry; the renderer and the future palette derive from it (seam 3). */
|
|
310
|
-
registry?: ComponentRegistry;
|
|
311
|
-
/** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
|
|
312
|
-
icons?: IconSet;
|
|
313
|
-
navMenu?: NavMenuConfig;
|
|
314
|
-
/**
|
|
315
|
-
* The live site's content styling for the preview frame. The admin's chrome isolation keeps
|
|
316
|
-
* the site's CSS out of the admin document, so the preview frame links these instead.
|
|
317
|
-
*/
|
|
318
|
-
preview?: PreviewConfig;
|
|
319
|
-
assets?: AssetConfig;
|
|
320
249
|
}
|
|
321
250
|
|
|
322
251
|
/**
|
|
@@ -351,7 +280,17 @@ export interface ConceptDescriptor {
|
|
|
351
280
|
permalink: string;
|
|
352
281
|
/** Filename date-prefix granularity for a dated concept; resolved by `normalizeConcepts`. */
|
|
353
282
|
datePrefix: DatePrefix;
|
|
354
|
-
|
|
283
|
+
/**
|
|
284
|
+
* The concept's fields in normalized form: each descriptor with its record key re-attached as
|
|
285
|
+
* `name`, derived by `normalizeConcepts` from the concept's `fieldset` record. Every consumer
|
|
286
|
+
* (the editor form, the form decoder, the media extractor) iterates this array and reads `name`.
|
|
287
|
+
*/
|
|
288
|
+
fields: NamedField[];
|
|
289
|
+
/**
|
|
290
|
+
* The concept's source fieldset, carried through so `editLoad` can resolve a create-form's
|
|
291
|
+
* initial values (a `default: 'today'` date) against a request-time clock via `initialValues`.
|
|
292
|
+
*/
|
|
293
|
+
schema: Fieldset;
|
|
355
294
|
/**
|
|
356
295
|
* Frontmatter keys the index copies onto each summary's `fields` record. `normalizeConcepts`
|
|
357
296
|
* resolves it to `[]` when a concept omits `summaryFields`.
|
|
@@ -412,23 +351,17 @@ export interface CairnExtension {
|
|
|
412
351
|
export interface CairnRuntime {
|
|
413
352
|
siteName: string;
|
|
414
353
|
concepts: ConceptDescriptor[];
|
|
415
|
-
backend
|
|
354
|
+
/** The commit backend provider, carried through from the adapter by `composeRuntime`. */
|
|
355
|
+
backend: BackendProvider;
|
|
416
356
|
sender: SenderConfig;
|
|
417
357
|
/** The support contact passed through from the adapter; the in-admin help reads it. Optional. */
|
|
418
358
|
supportContact?: string;
|
|
419
359
|
/**
|
|
420
360
|
* The site's one renderer: the editor preview and every public page call it (design decision 4).
|
|
421
|
-
* The
|
|
422
|
-
*
|
|
361
|
+
* The build passes a site-resolver-backed `resolve`/`resolveMedia` pair, the preview manifest-backed
|
|
362
|
+
* ones.
|
|
423
363
|
*/
|
|
424
|
-
render
|
|
425
|
-
md: string,
|
|
426
|
-
opts?: {
|
|
427
|
-
stagger?: boolean;
|
|
428
|
-
resolve?: LinkResolve;
|
|
429
|
-
resolveMedia?: import('../render/resolve-media.js').MediaResolve;
|
|
430
|
-
},
|
|
431
|
-
): string | Promise<string>;
|
|
364
|
+
render: SiteRender;
|
|
432
365
|
manifestPath: string;
|
|
433
366
|
/** The repo-relative path to the committed media manifest, defaulted in composeRuntime. */
|
|
434
367
|
mediaManifestPath: string;
|
package/src/lib/delivery/data.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// the graph. The full ./delivery barrel re-exports this and adds the route loaders.
|
|
4
4
|
export { createContentIndex, fromGlob } from './content-index.js';
|
|
5
5
|
export type { RawFile, ContentSummary, ContentEntry, ContentIndex, ContentProblem } from './content-index.js';
|
|
6
|
-
export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
|
|
7
|
-
export type { SiteResolver, ConceptIndex } from './site-resolver.js';
|
|
6
|
+
export { createSiteResolver, buildLinkResolver, resolveReferences } from './site-resolver.js';
|
|
7
|
+
export type { SiteResolver, ConceptIndex, ResolvedReference } from './site-resolver.js';
|
|
8
8
|
export { createSiteIndexes } from './site-indexes.js';
|
|
9
9
|
export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
|
|
10
10
|
export { siteDescriptors } from './site-descriptors.js';
|
|
@@ -10,14 +10,15 @@ import { buildSeoMeta } from './seo.js';
|
|
|
10
10
|
import type { SeoMeta } from './seo.js';
|
|
11
11
|
import { readSeoFields, resolveImageUrl } from './seo-fields.js';
|
|
12
12
|
import { buildLinkResolver } from './site-resolver.js';
|
|
13
|
-
import type {
|
|
13
|
+
import type { SiteRender } from '../content/types.js';
|
|
14
14
|
import type { MediaResolve } from '../render/resolve-media.js';
|
|
15
15
|
import { parseMediaToken } from '../media/reference.js';
|
|
16
|
+
import { log } from '../log/index.js';
|
|
16
17
|
|
|
17
18
|
/** Injected dependencies for the public loaders. */
|
|
18
19
|
export interface PublicRoutesDeps {
|
|
19
20
|
site: SiteResolver;
|
|
20
|
-
render:
|
|
21
|
+
render: SiteRender;
|
|
21
22
|
origin: string;
|
|
22
23
|
/** Site name for og:site_name and the SEO head. */
|
|
23
24
|
siteName: string;
|
|
@@ -36,6 +37,14 @@ export interface PublicRoutesDeps {
|
|
|
36
37
|
* media is off and no `heroImage` projection is derived.
|
|
37
38
|
*/
|
|
38
39
|
resolveMedia?: MediaResolve;
|
|
40
|
+
/**
|
|
41
|
+
* Whether the site configured media on, read from `runtime.resolvedAssets.enabled`. It exists only
|
|
42
|
+
* to diagnose a forgotten wire-point: media on but no `resolveMedia` reached this factory, which
|
|
43
|
+
* renders public hero and body images as bare `media:` tokens. When true and `resolveMedia` is
|
|
44
|
+
* absent, the factory emits `media.resolver_absent` once at construction. It does not change
|
|
45
|
+
* resolution; `resolveMedia` alone still gates the hero projection.
|
|
46
|
+
*/
|
|
47
|
+
assetsEnabled?: boolean;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
/** The archive and tag list data: summaries the template renders. */
|
|
@@ -74,7 +83,16 @@ export interface EntryData {
|
|
|
74
83
|
|
|
75
84
|
/** Build the public loaders for a site's unified index. */
|
|
76
85
|
export function createPublicRoutes(deps: PublicRoutesDeps) {
|
|
77
|
-
const { site, render, origin, siteName, description, feeds, defaultImage, resolveMedia } = deps;
|
|
86
|
+
const { site, render, origin, siteName, description, feeds, defaultImage, resolveMedia, assetsEnabled } = deps;
|
|
87
|
+
|
|
88
|
+
// Diagnose a forgotten wire-point: media is configured on but no resolver reached this factory, so
|
|
89
|
+
// every public hero and body `media:` token renders bare (the ecxc 0.57.0 finding). The condition
|
|
90
|
+
// is a property of the wiring, not of any one load, so it is checked once here at construction
|
|
91
|
+
// rather than per entryLoad or per image, which keeps the warning loud-once and out of the
|
|
92
|
+
// prerender hot path. Resolution is unchanged; resolveMedia alone still gates the hero projection.
|
|
93
|
+
if (assetsEnabled && !resolveMedia) {
|
|
94
|
+
log.warn('media.resolver_absent', { enabled: true });
|
|
95
|
+
}
|
|
78
96
|
|
|
79
97
|
/**
|
|
80
98
|
* Derive the hero projection from an entry's frontmatter, without mutating it (locked decision 5).
|
|
@@ -145,7 +163,21 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
|
|
|
145
163
|
...(fields.author ? { author: fields.author } : {}),
|
|
146
164
|
...(entry.date ? { feeds } : {}),
|
|
147
165
|
});
|
|
148
|
-
return {
|
|
166
|
+
return {
|
|
167
|
+
concept: entry.concept,
|
|
168
|
+
entry,
|
|
169
|
+
html: await render({
|
|
170
|
+
body: entry.body,
|
|
171
|
+
concept: entry.concept,
|
|
172
|
+
frontmatter: entry.frontmatter,
|
|
173
|
+
resolve: buildLinkResolver(site),
|
|
174
|
+
}),
|
|
175
|
+
canonicalUrl,
|
|
176
|
+
seo,
|
|
177
|
+
newer,
|
|
178
|
+
older,
|
|
179
|
+
...(heroImage ? { heroImage } : {}),
|
|
180
|
+
};
|
|
149
181
|
}
|
|
150
182
|
|
|
151
183
|
/** The chronological archive for one concept: every non-draft summary, newest-first. */
|