@glw907/cairn-cms 0.56.2 → 0.57.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 +96 -0
- package/dist/components/AdminLayout.svelte +3 -0
- package/dist/components/CairnAdmin.svelte +8 -1
- package/dist/components/CairnAdmin.svelte.d.ts +2 -0
- package/dist/components/CairnMediaLibrary.svelte +929 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- package/dist/components/EditPage.svelte +347 -7
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +283 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
- package/dist/components/MediaCaptureCard.svelte +135 -0
- package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
- package/dist/components/MediaFigureControl.svelte +247 -0
- package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
- package/dist/components/MediaHeroField.svelte +569 -0
- package/dist/components/MediaHeroField.svelte.d.ts +67 -0
- package/dist/components/MediaInsertPopover.svelte +449 -0
- package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
- package/dist/components/MediaPicker.svelte +257 -0
- package/dist/components/MediaPicker.svelte.d.ts +41 -0
- package/dist/components/admin-icons.d.ts +12 -0
- package/dist/components/admin-icons.js +12 -0
- package/dist/components/cairn-admin.css +901 -9
- package/dist/components/client-ingest.d.ts +142 -0
- package/dist/components/client-ingest.js +297 -0
- package/dist/components/editor-media.d.ts +11 -0
- package/dist/components/editor-media.js +206 -0
- package/dist/components/editor-placeholder.d.ts +26 -0
- package/dist/components/editor-placeholder.js +166 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +12 -0
- package/dist/components/markdown-directives.js +42 -0
- package/dist/components/markdown-format.d.ts +89 -0
- package/dist/components/markdown-format.js +255 -0
- package/dist/components/media-upload-outcome.d.ts +52 -0
- package/dist/components/media-upload-outcome.js +48 -0
- package/dist/content/compose.js +3 -0
- package/dist/content/frontmatter.js +17 -0
- package/dist/content/manifest.d.ts +4 -0
- package/dist/content/manifest.js +41 -1
- package/dist/content/media-refs.d.ts +7 -0
- package/dist/content/media-refs.js +52 -0
- package/dist/content/schema.d.ts +5 -2
- package/dist/content/schema.js +17 -0
- package/dist/content/types.d.ts +62 -11
- package/dist/content/validate.js +27 -0
- package/dist/delivery/public-routes.d.ts +16 -0
- package/dist/delivery/public-routes.js +46 -3
- package/dist/delivery/seo-fields.js +7 -1
- package/dist/delivery/seo.d.ts +2 -0
- package/dist/delivery/seo.js +3 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +21 -0
- package/dist/doctor/index.d.ts +3 -1
- package/dist/doctor/index.js +11 -2
- package/dist/doctor/types.d.ts +3 -0
- package/dist/doctor/wrangler-config.d.ts +3 -0
- package/dist/doctor/wrangler-config.js +20 -0
- package/dist/env.d.ts +19 -0
- package/dist/env.js +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/media/config.d.ts +24 -0
- package/dist/media/config.js +69 -0
- package/dist/media/delivery-bucket.d.ts +34 -0
- package/dist/media/delivery-bucket.js +10 -0
- package/dist/media/index.d.ts +6 -0
- package/dist/media/index.js +13 -0
- package/dist/media/library-entry.d.ts +30 -0
- package/dist/media/library-entry.js +17 -0
- package/dist/media/manifest.d.ts +44 -0
- package/dist/media/manifest.js +105 -0
- package/dist/media/naming.d.ts +18 -0
- package/dist/media/naming.js +112 -0
- package/dist/media/reconcile.d.ts +36 -0
- package/dist/media/reconcile.js +45 -0
- package/dist/media/reference.d.ts +12 -0
- package/dist/media/reference.js +33 -0
- package/dist/media/sniff.d.ts +18 -0
- package/dist/media/sniff.js +106 -0
- package/dist/media/store.d.ts +25 -0
- package/dist/media/store.js +16 -0
- package/dist/media/transform-url.d.ts +26 -0
- package/dist/media/transform-url.js +38 -0
- package/dist/media/usage.d.ts +48 -0
- package/dist/media/usage.js +90 -0
- package/dist/render/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.js +3 -0
- package/dist/render/remark-figure.d.ts +4 -0
- package/dist/render/remark-figure.js +103 -0
- package/dist/render/resolve-media.d.ts +34 -0
- package/dist/render/resolve-media.js +78 -0
- package/dist/render/sanitize-schema.d.ts +4 -2
- package/dist/render/sanitize-schema.js +5 -3
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +5 -0
- package/dist/sveltekit/cairn-admin.d.ts +8 -1
- package/dist/sveltekit/cairn-admin.js +10 -2
- package/dist/sveltekit/content-routes.d.ts +68 -2
- package/dist/sveltekit/content-routes.js +461 -10
- package/dist/sveltekit/csrf.d.ts +16 -0
- package/dist/sveltekit/csrf.js +18 -0
- package/dist/sveltekit/guard.js +10 -3
- package/dist/sveltekit/index.d.ts +2 -1
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/media-route.d.ts +12 -0
- package/dist/sveltekit/media-route.js +137 -0
- package/dist/vite/index.d.ts +3 -0
- package/dist/vite/index.js +7 -2
- package/package.json +7 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +929 -0
- package/src/lib/components/EditPage.svelte +347 -7
- package/src/lib/components/MarkdownEditor.svelte +283 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +569 -0
- package/src/lib/components/MediaInsertPopover.svelte +449 -0
- package/src/lib/components/MediaPicker.svelte +257 -0
- package/src/lib/components/admin-icons.ts +12 -0
- package/src/lib/components/cairn-admin.css +37 -0
- package/src/lib/components/client-ingest.ts +380 -0
- package/src/lib/components/editor-media.ts +248 -0
- package/src/lib/components/editor-placeholder.ts +213 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +46 -0
- package/src/lib/components/markdown-format.ts +307 -1
- package/src/lib/components/media-upload-outcome.ts +83 -0
- package/src/lib/content/compose.ts +3 -0
- package/src/lib/content/frontmatter.ts +16 -1
- package/src/lib/content/manifest.ts +44 -1
- package/src/lib/content/media-refs.ts +58 -0
- package/src/lib/content/schema.ts +31 -7
- package/src/lib/content/types.ts +78 -13
- package/src/lib/content/validate.ts +26 -1
- package/src/lib/delivery/public-routes.ts +52 -3
- package/src/lib/delivery/seo-fields.ts +6 -1
- package/src/lib/delivery/seo.ts +5 -0
- package/src/lib/doctor/checks-local.ts +22 -0
- package/src/lib/doctor/index.ts +21 -3
- package/src/lib/doctor/types.ts +3 -0
- package/src/lib/doctor/wrangler-config.ts +23 -0
- package/src/lib/env.ts +28 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +8 -1
- package/src/lib/media/config.ts +103 -0
- package/src/lib/media/delivery-bucket.ts +41 -0
- package/src/lib/media/index.ts +22 -0
- package/src/lib/media/library-entry.ts +58 -0
- package/src/lib/media/manifest.ts +122 -0
- package/src/lib/media/naming.ts +130 -0
- package/src/lib/media/reconcile.ts +79 -0
- package/src/lib/media/reference.ts +40 -0
- package/src/lib/media/sniff.ts +114 -0
- package/src/lib/media/store.ts +57 -0
- package/src/lib/media/transform-url.ts +58 -0
- package/src/lib/media/usage.ts +152 -0
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +5 -0
- package/src/lib/render/remark-figure.ts +132 -0
- package/src/lib/render/resolve-media.ts +96 -0
- package/src/lib/render/sanitize-schema.ts +5 -3
- package/src/lib/sveltekit/admin-dispatch.ts +6 -1
- package/src/lib/sveltekit/cairn-admin.ts +13 -3
- package/src/lib/sveltekit/content-routes.ts +573 -12
- package/src/lib/sveltekit/csrf.ts +18 -0
- package/src/lib/sveltekit/guard.ts +12 -3
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/media-route.ts +158 -0
- package/src/lib/vite/index.ts +9 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { MediaEntry } from '../media/manifest.js';
|
|
2
|
+
import type { UploadResult } from '../sveltekit/content-routes.js';
|
|
3
|
+
import type { IngestFailureKind } from './client-ingest.js';
|
|
4
|
+
/** A failure the card surfaces. The ingest taxonomy plus a `generic` catch-all for a refuse reason
|
|
5
|
+
* with no specific author-facing card (a binding-missing, a length-required, a parse miss). */
|
|
6
|
+
export type UploadFailureKind = IngestFailureKind | 'generic';
|
|
7
|
+
/** The outcome the popover acts on. `inserted` swaps the placeholder for the reference and records
|
|
8
|
+
* the entry; `failed` cancels the placeholder and shows the typed card; `session-expired` cancels
|
|
9
|
+
* the placeholder and tells the author to sign in again. */
|
|
10
|
+
export type UploadOutcome = {
|
|
11
|
+
kind: 'inserted';
|
|
12
|
+
reference: string;
|
|
13
|
+
record: MediaEntry;
|
|
14
|
+
reused: boolean;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'failed';
|
|
17
|
+
failure: UploadFailureKind;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'session-expired';
|
|
20
|
+
};
|
|
21
|
+
/** The shape the popover hands in: either a parsed SvelteKit action result (success or failure) or a
|
|
22
|
+
* bare response signal for the redirect and network-error cases. The popover deserializes the body
|
|
23
|
+
* for the success and failure cases and passes the raw `response.type`/`response.status` for the
|
|
24
|
+
* redirect case, so this one mapper covers every branch. */
|
|
25
|
+
export type UploadEnvelope = {
|
|
26
|
+
type: 'success';
|
|
27
|
+
status?: number;
|
|
28
|
+
data: UploadResult;
|
|
29
|
+
} | {
|
|
30
|
+
type: 'failure';
|
|
31
|
+
status?: number;
|
|
32
|
+
data?: {
|
|
33
|
+
error?: string;
|
|
34
|
+
};
|
|
35
|
+
} | {
|
|
36
|
+
type: 'redirect';
|
|
37
|
+
status?: number;
|
|
38
|
+
} | {
|
|
39
|
+
type: 'error';
|
|
40
|
+
status?: number;
|
|
41
|
+
} | {
|
|
42
|
+
type: 'opaqueredirect';
|
|
43
|
+
status?: number;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Map a parsed upload envelope to the single outcome the popover acts on. A success envelope yields
|
|
47
|
+
* an `inserted` outcome carrying the reference, the record, and the dedup flag. A failure envelope
|
|
48
|
+
* maps its refuse reason to a typed card, with `session-expired` lifted to its own outcome. An
|
|
49
|
+
* opaque or status-0 response (the guard's `redirect: 'manual'` 303) is a session-expired signal, as
|
|
50
|
+
* is any redirect-typed result. An error-typed result with a real status is a generic failure.
|
|
51
|
+
*/
|
|
52
|
+
export declare function uploadOutcome(envelope: UploadEnvelope): UploadOutcome;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mediaToken } from '../media/reference.js';
|
|
2
|
+
// The server refuse reasons mapped to a card kind. `too-large` keeps its own card; an unsupported
|
|
3
|
+
// type reads as a decode failure to the author (the bytes the browser sent are a type the server
|
|
4
|
+
// will not store); `session-expired` is its own outcome. Every other reason (binding-missing,
|
|
5
|
+
// media-disabled, csrf, length-required, hash-collision) is an operational refusal with no
|
|
6
|
+
// author-actionable specifics, so it collapses to the generic card.
|
|
7
|
+
const REFUSE_TO_FAILURE = {
|
|
8
|
+
'too-large': 'too-large',
|
|
9
|
+
'unsupported-type': 'decode-unsupported',
|
|
10
|
+
'session-expired': 'session-expired',
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Map a parsed upload envelope to the single outcome the popover acts on. A success envelope yields
|
|
14
|
+
* an `inserted` outcome carrying the reference, the record, and the dedup flag. A failure envelope
|
|
15
|
+
* maps its refuse reason to a typed card, with `session-expired` lifted to its own outcome. An
|
|
16
|
+
* opaque or status-0 response (the guard's `redirect: 'manual'` 303) is a session-expired signal, as
|
|
17
|
+
* is any redirect-typed result. An error-typed result with a real status is a generic failure.
|
|
18
|
+
*/
|
|
19
|
+
export function uploadOutcome(envelope) {
|
|
20
|
+
switch (envelope.type) {
|
|
21
|
+
case 'success':
|
|
22
|
+
return {
|
|
23
|
+
kind: 'inserted',
|
|
24
|
+
// Re-derive the reference from the validated record fields rather than trusting the loose
|
|
25
|
+
// server `reference` string: the token is inserted unescaped into the markdown URL slot, so
|
|
26
|
+
// the insert depends only on grammar-constrained fields (a 16-hex hash, a slugified slug)
|
|
27
|
+
// instead of an arbitrary server string. Defense in depth, in case a future server path
|
|
28
|
+
// returns a reference that does not match the record.
|
|
29
|
+
reference: mediaToken({ slug: envelope.data.record.slug, hash: envelope.data.record.hash }),
|
|
30
|
+
record: envelope.data.record,
|
|
31
|
+
reused: envelope.data.reused,
|
|
32
|
+
};
|
|
33
|
+
case 'failure': {
|
|
34
|
+
const reason = envelope.data?.error ?? '';
|
|
35
|
+
const mapped = REFUSE_TO_FAILURE[reason];
|
|
36
|
+
if (mapped === 'session-expired')
|
|
37
|
+
return { kind: 'session-expired' };
|
|
38
|
+
return { kind: 'failed', failure: mapped ?? 'generic' };
|
|
39
|
+
}
|
|
40
|
+
case 'redirect':
|
|
41
|
+
case 'opaqueredirect':
|
|
42
|
+
return { kind: 'session-expired' };
|
|
43
|
+
case 'error':
|
|
44
|
+
// A manual-redirect Response surfaces as type 'opaqueredirect' or status 0; a status-0 error
|
|
45
|
+
// is that same expired-session signal. A real error status is a genuine transport failure.
|
|
46
|
+
return (envelope.status ?? 0) === 0 ? { kind: 'session-expired' } : { kind: 'failed', failure: 'generic' };
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/content/compose.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveConcepts } from './concepts.js';
|
|
2
|
+
import { normalizeAssets } from '../media/config.js';
|
|
2
3
|
/**
|
|
3
4
|
* Fold an adapter and any extensions into the composed runtime (seam 2). The per-concept URL policy
|
|
4
5
|
* is derived from the site config, the same source the delivery path uses, so the runtime and
|
|
@@ -33,6 +34,8 @@ export function composeRuntime({ adapter, siteConfig, extensions = [] }) {
|
|
|
33
34
|
navMenu: adapter.navMenu,
|
|
34
35
|
preview: adapter.preview,
|
|
35
36
|
assets: adapter.assets,
|
|
37
|
+
resolvedAssets: normalizeAssets(adapter.assets),
|
|
38
|
+
mediaManifestPath: adapter.mediaManifestPath ?? 'src/content/.cairn/media.json',
|
|
36
39
|
adminPanels,
|
|
37
40
|
fieldTypes,
|
|
38
41
|
};
|
|
@@ -23,6 +23,23 @@ export function frontmatterFromForm(fields, form) {
|
|
|
23
23
|
.filter(Boolean)),
|
|
24
24
|
];
|
|
25
25
|
break;
|
|
26
|
+
case 'image': {
|
|
27
|
+
// The hero submits three sub-fields under one key. An empty src means no hero, so omit the
|
|
28
|
+
// whole key. Alt is stored verbatim (it is not markdown, so no escaping). A blank caption
|
|
29
|
+
// is dropped so committed frontmatter stays minimal.
|
|
30
|
+
const src = String(form.get(`${field.name}.src`) ?? '').trim();
|
|
31
|
+
if (src === '')
|
|
32
|
+
break;
|
|
33
|
+
const value = {
|
|
34
|
+
src,
|
|
35
|
+
alt: String(form.get(`${field.name}.alt`) ?? ''),
|
|
36
|
+
};
|
|
37
|
+
const caption = String(form.get(`${field.name}.caption`) ?? '').trim();
|
|
38
|
+
if (caption !== '')
|
|
39
|
+
value.caption = caption;
|
|
40
|
+
data[field.name] = value;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
26
43
|
default:
|
|
27
44
|
// FormData.get returns null for an absent field; normalize to an empty string so
|
|
28
45
|
// a caller reading a text value never gets null.
|
|
@@ -10,6 +10,10 @@ export interface ManifestEntry {
|
|
|
10
10
|
summary?: string;
|
|
11
11
|
draft: boolean;
|
|
12
12
|
links: CairnRef[];
|
|
13
|
+
/** The content hashes of the media this entry references (its hero plus its body images). The
|
|
14
|
+
* main side of the media where-used index. Additive and optional: an entry with no media omits
|
|
15
|
+
* the key, and a manifest committed before this field still parses (absent reads as no refs). */
|
|
16
|
+
mediaRefs?: string[];
|
|
13
17
|
}
|
|
14
18
|
/** The whole corpus as one committed file. `version` guards a future shape migration. */
|
|
15
19
|
export interface Manifest {
|
package/dist/content/manifest.js
CHANGED
|
@@ -7,12 +7,16 @@ import { parseMarkdown } from './frontmatter.js';
|
|
|
7
7
|
import { deriveExcerpt } from './excerpt.js';
|
|
8
8
|
import { entryIdentity, asString } from './identity.js';
|
|
9
9
|
import { extractCairnLinks } from './links.js';
|
|
10
|
+
import { extractMediaRefs } from './media-refs.js';
|
|
10
11
|
/** Build one manifest entry from a content file. Drafts are included and flagged. The id, date, and
|
|
11
12
|
* permalink come from entryIdentity, the same source content-index uses, so a cairn: link resolves to
|
|
12
13
|
* one URL whether the admin preview reads the manifest or the public build reads the content index. */
|
|
13
14
|
export function manifestEntryFromFile(descriptor, file) {
|
|
14
15
|
const { frontmatter, body } = parseMarkdown(file.raw);
|
|
15
16
|
const { id, date, permalink } = entryIdentity(descriptor, file.path, frontmatter);
|
|
17
|
+
// Set mediaRefs only when non-empty, so an image-free entry's row stays byte-identical to before
|
|
18
|
+
// (matching the optional-spread for date and summary).
|
|
19
|
+
const mediaRefs = extractMediaRefs(frontmatter, body, descriptor.fields);
|
|
16
20
|
return {
|
|
17
21
|
id,
|
|
18
22
|
concept: descriptor.id,
|
|
@@ -24,6 +28,7 @@ export function manifestEntryFromFile(descriptor, file) {
|
|
|
24
28
|
summary: deriveExcerpt(body, { description: asString(frontmatter.description) }) || undefined,
|
|
25
29
|
draft: frontmatter.draft === true,
|
|
26
30
|
links: extractCairnLinks(body),
|
|
31
|
+
...(mediaRefs.length ? { mediaRefs } : {}),
|
|
27
32
|
};
|
|
28
33
|
}
|
|
29
34
|
/** An empty manifest, the starting point when no committed file exists yet. */
|
|
@@ -45,6 +50,7 @@ export function serializeManifest(manifest) {
|
|
|
45
50
|
...(e.summary ? { summary: e.summary } : {}),
|
|
46
51
|
draft: e.draft,
|
|
47
52
|
links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
|
|
53
|
+
...(e.mediaRefs && e.mediaRefs.length ? { mediaRefs: [...e.mediaRefs].sort() } : {}),
|
|
48
54
|
}));
|
|
49
55
|
return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
|
|
50
56
|
}
|
|
@@ -74,10 +80,21 @@ export function parseManifest(raw) {
|
|
|
74
80
|
typeof e.draft === 'boolean' &&
|
|
75
81
|
(e.date === undefined || typeof e.date === 'string') &&
|
|
76
82
|
(e.summary === undefined || typeof e.summary === 'string') &&
|
|
83
|
+
(e.mediaRefs === undefined || Array.isArray(e.mediaRefs)) &&
|
|
77
84
|
Array.isArray(e.links);
|
|
78
85
|
if (!ok) {
|
|
79
86
|
throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
|
|
80
87
|
}
|
|
88
|
+
// mediaRefs is additive and optional: an entry without it parses (the field reads as absent),
|
|
89
|
+
// so a manifest committed before this field still builds. When present, validate each element
|
|
90
|
+
// is a string, mirroring the link-element validation, so a hand-edited file fails loudly.
|
|
91
|
+
if (e.mediaRefs !== undefined) {
|
|
92
|
+
for (const hash of e.mediaRefs) {
|
|
93
|
+
if (typeof hash !== 'string') {
|
|
94
|
+
throw new Error(`content manifest: malformed mediaRefs element ${JSON.stringify(hash)} in entry ${JSON.stringify(e)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
81
98
|
// Validate each link element's shape, not just that links is an array. inboundLinks and the
|
|
82
99
|
// delete guard read l.concept and l.id, so a string, null, or id-less element would read as
|
|
83
100
|
// undefined and silently drop a real inbound linker. Reject it here instead.
|
|
@@ -132,11 +149,34 @@ export function verifyManifest(built, committedRaw) {
|
|
|
132
149
|
const builtRaw = serializeManifest(built);
|
|
133
150
|
if (committedRaw === builtRaw)
|
|
134
151
|
return;
|
|
152
|
+
// mediaRefs is additive: a site whose committed manifest predates the field must still build,
|
|
153
|
+
// even when its content references media (open risk 3, the migration landmine). Before diffing,
|
|
154
|
+
// normalize the built manifest against the committed one: for any built entry whose committed
|
|
155
|
+
// counterpart carries no mediaRefs key, drop mediaRefs from the built entry. An un-regenerated
|
|
156
|
+
// site (committed omits mediaRefs) then matches; a regenerated site (committed carries mediaRefs)
|
|
157
|
+
// still detects real drift in that field. The normalization is per entry and per missing key, so
|
|
158
|
+
// it never masks drift in any other field or in an entry the committed manifest already tracks.
|
|
159
|
+
const committed = parseManifest(committedRaw);
|
|
160
|
+
const committedByKey = new Map(committed.entries.map((e) => [keyOf(e), e]));
|
|
161
|
+
const normalized = {
|
|
162
|
+
version: 1,
|
|
163
|
+
entries: built.entries.map((b) => {
|
|
164
|
+
const c = committedByKey.get(keyOf(b));
|
|
165
|
+
if (b.mediaRefs && c && c.mediaRefs === undefined) {
|
|
166
|
+
const { mediaRefs: _dropped, ...rest } = b;
|
|
167
|
+
return rest;
|
|
168
|
+
}
|
|
169
|
+
return b;
|
|
170
|
+
}),
|
|
171
|
+
};
|
|
172
|
+
const normalizedRaw = serializeManifest(normalized);
|
|
173
|
+
if (committedRaw === normalizedRaw)
|
|
174
|
+
return;
|
|
135
175
|
// Diff the canonical built form, not the raw one. serializeManifest sorts each entry's links, so a
|
|
136
176
|
// build whose links are in extraction order would otherwise report a false (links) drift for an
|
|
137
177
|
// entry whose link set is identical and only the order differs. Reuse the serialized form so both
|
|
138
178
|
// sides are canonical.
|
|
139
|
-
const diff = diffManifests(parseManifest(
|
|
179
|
+
const diff = diffManifests(parseManifest(normalizedRaw), committed);
|
|
140
180
|
throw new Error('content manifest is stale: the committed file does not match the corpus.\n' +
|
|
141
181
|
formatDiff(diff) +
|
|
142
182
|
'\nRegenerate it (npm run cairn:manifest) and commit the result.');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FrontmatterField } from './types.js';
|
|
2
|
+
/** The content hashes one entry references, in first-occurrence order, deduped by hash. Reads the
|
|
3
|
+
* frontmatter hero `image.src` for each `image`-typed field plus every body image node. A
|
|
4
|
+
* non-media or malformed token is skipped, never thrown, so a stray `` does not break
|
|
5
|
+
* the manifest build. The body is parsed as mdast, so a `media:` token inside a code span or fence
|
|
6
|
+
* is never matched. */
|
|
7
|
+
export declare function extractMediaRefs(frontmatter: Record<string, unknown>, body: string, fields: FrontmatterField[]): string[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// cairn-cms: the media-reference extractor. Given one entry's parsed frontmatter and body, it
|
|
2
|
+
// returns the deduped content hashes the entry references. This is the main side of the media
|
|
3
|
+
// where-used index: manifestEntryFromFile records the result per entry, and the usage-index
|
|
4
|
+
// builder runs it directly over each open branch's edited markdown. It mirrors extractCairnLinks
|
|
5
|
+
// (the same remark pipeline, the same first-occurrence dedup) but visits image nodes and the
|
|
6
|
+
// frontmatter hero rather than link nodes.
|
|
7
|
+
//
|
|
8
|
+
// A media reference lives in two places, and both are load-bearing. Body image nodes carry the
|
|
9
|
+
// inline `` placements (a 3a :::figure also lands here, since the figure directive
|
|
10
|
+
// wraps a real image node). The frontmatter hero is the other site: a hero is `image: { src }` in
|
|
11
|
+
// frontmatter, outside the markdown body, so an extractor that visited only body nodes would read
|
|
12
|
+
// every in-use hero as orphaned and let safe-delete remove an in-use image.
|
|
13
|
+
//
|
|
14
|
+
// Every match is keyed by the parsed hash, the immutable truth, never the cosmetic slug, so a bare
|
|
15
|
+
// `media:<hash>` and a `media:<slug>.<hash>` for the same bytes collapse to one.
|
|
16
|
+
import { unified } from 'unified';
|
|
17
|
+
import remarkParse from 'remark-parse';
|
|
18
|
+
import remarkGfm from 'remark-gfm';
|
|
19
|
+
import { visit } from 'unist-util-visit';
|
|
20
|
+
import { parseMediaToken } from '../media/reference.js';
|
|
21
|
+
/** The content hashes one entry references, in first-occurrence order, deduped by hash. Reads the
|
|
22
|
+
* frontmatter hero `image.src` for each `image`-typed field plus every body image node. A
|
|
23
|
+
* non-media or malformed token is skipped, never thrown, so a stray `` does not break
|
|
24
|
+
* the manifest build. The body is parsed as mdast, so a `media:` token inside a code span or fence
|
|
25
|
+
* is never matched. */
|
|
26
|
+
export function extractMediaRefs(frontmatter, body, fields) {
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const hashes = [];
|
|
29
|
+
const add = (href) => {
|
|
30
|
+
const ref = parseMediaToken(href);
|
|
31
|
+
if (!ref || seen.has(ref.hash))
|
|
32
|
+
return;
|
|
33
|
+
seen.add(ref.hash);
|
|
34
|
+
hashes.push(ref.hash);
|
|
35
|
+
};
|
|
36
|
+
// The frontmatter hero arm: each `image`-typed field stores an ImageValue, so read its `.src`.
|
|
37
|
+
for (const field of fields) {
|
|
38
|
+
if (field.type !== 'image')
|
|
39
|
+
continue;
|
|
40
|
+
const value = frontmatter[field.name];
|
|
41
|
+
if (value && typeof value === 'object' && typeof value.src === 'string') {
|
|
42
|
+
add(value.src);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// The body arm: every image node's url. A 3a figure's inner image is a real image node.
|
|
46
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
|
|
47
|
+
visit(tree, 'image', (node) => {
|
|
48
|
+
if (node.url)
|
|
49
|
+
add(node.url);
|
|
50
|
+
});
|
|
51
|
+
return hashes;
|
|
52
|
+
}
|
package/dist/content/schema.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FrontmatterField, ValidationResult } from './types.js';
|
|
1
|
+
import type { FrontmatterField, ImageValue, ValidationResult } from './types.js';
|
|
2
2
|
/** The validate input the cairn adapter takes: the raw frontmatter and the body. */
|
|
3
3
|
export interface StandardInput {
|
|
4
4
|
frontmatter: Record<string, unknown>;
|
|
@@ -27,10 +27,13 @@ type StandardResult<Output> = {
|
|
|
27
27
|
}>;
|
|
28
28
|
};
|
|
29
29
|
/** Map one field descriptor to the TS type of its normalized value. text, textarea, and date
|
|
30
|
-
* normalize to a string; a closed-vocabulary `tags` field to the option-union array
|
|
30
|
+
* normalize to a string; a closed-vocabulary `tags` field to the option-union array; an `image`
|
|
31
|
+
* field to its nested object. */
|
|
31
32
|
type FieldValue<K extends FrontmatterField> = K extends {
|
|
32
33
|
type: 'boolean';
|
|
33
34
|
} ? boolean : K extends {
|
|
35
|
+
type: 'image';
|
|
36
|
+
} ? ImageValue : K extends {
|
|
34
37
|
type: 'tags';
|
|
35
38
|
options: readonly (infer O extends string)[];
|
|
36
39
|
} ? O[] : K extends {
|
package/dist/content/schema.js
CHANGED
|
@@ -41,10 +41,27 @@ function compilePatterns(fields) {
|
|
|
41
41
|
}
|
|
42
42
|
return compiled;
|
|
43
43
|
}
|
|
44
|
+
// True when an image field feeds the social card: an explicit `seo: true`, or the back-compat
|
|
45
|
+
// default that the field named `image` is the SEO image. The SEO unify (Task 4) reads this flag.
|
|
46
|
+
function isSeoImage(field) {
|
|
47
|
+
return field.type === 'image' && (field.seo === true || (field.seo === undefined && field.name === 'image'));
|
|
48
|
+
}
|
|
49
|
+
// A concept declares at most one SEO image field, so the social card is unambiguous. More than one
|
|
50
|
+
// is a site config error: a hero named `cover` plus an explicit `seo` on another, or two explicit
|
|
51
|
+
// `seo` fields. Fail loudly at declaration rather than emit a silent or wrong og:image.
|
|
52
|
+
function checkSeoImageFields(fields) {
|
|
53
|
+
const seo = fields.filter(isSeoImage);
|
|
54
|
+
if (seo.length > 1) {
|
|
55
|
+
const names = seo.map((field) => `"${field.name}"`).join(', ');
|
|
56
|
+
throw new Error(`cairn: a concept declares at most one SEO image field, but found ${seo.length} (${names}). ` +
|
|
57
|
+
'Set seo: false on all but one, or rename the extra image fields so only one feeds the social card.');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
44
60
|
/** Declare a concept's fields once. Returns the schema's faces derived from that one declaration. */
|
|
45
61
|
export function defineFields(fields, options = {}) {
|
|
46
62
|
const list = [...fields];
|
|
47
63
|
const patterns = compilePatterns(list);
|
|
64
|
+
checkSeoImageFields(list);
|
|
48
65
|
const validate = (frontmatter, body) => {
|
|
49
66
|
const base = validateFields(list, frontmatter);
|
|
50
67
|
if (!base.ok)
|
package/dist/content/types.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { IconSet } from '../render/glyph.js';
|
|
|
3
3
|
import type { DatePrefix } from './ids.js';
|
|
4
4
|
import type { ConceptSchema } from './schema.js';
|
|
5
5
|
import type { LinkResolve } from './links.js';
|
|
6
|
+
import type { VariantSpec } from '../media/transform-url.js';
|
|
6
7
|
/** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
|
|
7
8
|
interface FieldBase {
|
|
8
9
|
/** Frontmatter key and form input name. */
|
|
@@ -63,11 +64,32 @@ export interface FreeTagsField extends FieldBase {
|
|
|
63
64
|
placeholder?: string;
|
|
64
65
|
}
|
|
65
66
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* `
|
|
67
|
+
* A hero image set in frontmatter. The stored value is the nested object
|
|
68
|
+
* `{ src: string; alt: string; caption?: string }`, where `src` is a 2b `media:` reference, `alt`
|
|
69
|
+
* is the screen-reader description, and `caption` is an optional line the site template may show.
|
|
70
|
+
* One image serves two jobs: the template's lead image and the social-card image. The field feeding
|
|
71
|
+
* the social card is the `seo`-flagged one, defaulting to the field named `image`; a concept declares
|
|
72
|
+
* at most one SEO image field.
|
|
69
73
|
*/
|
|
70
|
-
export
|
|
74
|
+
export interface ImageField extends FieldBase {
|
|
75
|
+
type: 'image';
|
|
76
|
+
/** Whether this field feeds the social-card image. The field named `image` defaults to true. */
|
|
77
|
+
seo?: boolean;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The discriminated union the per-concept frontmatter form is generated from. A scalar field type
|
|
81
|
+
* is one variant here plus one decode arm in `frontmatterFromForm` and one in `validateFields`. The
|
|
82
|
+
* structured `image` field additionally needs a read-back arm in `formValues` and a type-inference
|
|
83
|
+
* arm in `schema.ts`, since its value is a nested object rather than a single string.
|
|
84
|
+
*/
|
|
85
|
+
export type FrontmatterField = TextField | TextareaField | DateField | BooleanField | TagsField | FreeTagsField | ImageField;
|
|
86
|
+
/** The stored value of an `image` field: a `media:` reference, a screen-reader description, and an
|
|
87
|
+
* optional caption. */
|
|
88
|
+
export interface ImageValue {
|
|
89
|
+
src: string;
|
|
90
|
+
alt: string;
|
|
91
|
+
caption?: string;
|
|
92
|
+
}
|
|
71
93
|
/**
|
|
72
94
|
* A validator's verdict. On success it carries the normalized frontmatter to commit; on
|
|
73
95
|
* failure it carries field-keyed error messages (the empty key is a form-level error).
|
|
@@ -163,12 +185,28 @@ export interface PreviewConfig {
|
|
|
163
185
|
/** The flat preview shape `editLoad` ships to the edit page: the top-level `PreviewConfig`
|
|
164
186
|
* values with the entry's concept override applied, and no `byConcept` map. */
|
|
165
187
|
export type ResolvedPreview = Omit<PreviewConfig, 'byConcept'>;
|
|
166
|
-
/**
|
|
188
|
+
/** A site's media configuration (seam 4). A site sets this to turn on R2-backed media: uploads,
|
|
189
|
+
* content-addressed storage, and Cloudflare Images variants. Omitting it leaves media off. The
|
|
190
|
+
* engine normalizes this into a `ResolvedAssetConfig` and merges the named variants over the
|
|
191
|
+
* built-in thumb, inline, card, and hero presets. */
|
|
167
192
|
export interface AssetConfig {
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
publicBase
|
|
193
|
+
/** The R2 bucket binding name on the Worker, e.g. "MEDIA_BUCKET". Required when a site declares media. */
|
|
194
|
+
bucketBinding: string;
|
|
195
|
+
/** The delivery base path. Defaults to "/media". */
|
|
196
|
+
publicBase?: string;
|
|
197
|
+
/** Whether the public URL carries the slug ("slug") or stays opaque ("opaque"). Defaults to "slug". */
|
|
198
|
+
urlForm?: 'slug' | 'opaque';
|
|
199
|
+
/** The maximum accepted upload size in bytes. Defaults to 25 MB. */
|
|
200
|
+
maxUploadBytes?: number;
|
|
201
|
+
/** The accepted upload MIME types. Defaults to the common web image types. */
|
|
202
|
+
allowedTypes?: string[];
|
|
203
|
+
/** Named transform presets, merged over the built-in thumb/inline/card/hero presets. */
|
|
204
|
+
variants?: Record<string, VariantSpec>;
|
|
205
|
+
/** Whether Cloudflare Image Transformations are enabled for the zone (default false). The feature
|
|
206
|
+
* is a per-zone setting that the dashboard or API turns on; it cannot be flipped from a Worker. With
|
|
207
|
+
* it off, the media resolver serves the bare full-size delivery path and ignores any preset, so
|
|
208
|
+
* thumbnails stay correct (full-size-but-correct) rather than pointing at a dead /cdn-cgi/image URL. */
|
|
209
|
+
transformations?: boolean;
|
|
172
210
|
}
|
|
173
211
|
/** The single seam the engine consumes. A site implements this at `src/lib/cairn.config.ts`. */
|
|
174
212
|
export interface CairnAdapter {
|
|
@@ -185,14 +223,19 @@ export interface CairnAdapter {
|
|
|
185
223
|
sender: SenderConfig;
|
|
186
224
|
/** The site's one renderer: the editor preview and every public page call it (design decision 4).
|
|
187
225
|
* `resolve` rewrites cairn: links to live permalinks; the build passes a site-resolver-backed
|
|
188
|
-
* one, the preview a manifest one.
|
|
226
|
+
* one, the preview a manifest one. The trailing `resolveMedia` is additive and optional: the build
|
|
227
|
+
* passes a site-resolver-backed media resolver, the preview a manifest-backed one. */
|
|
189
228
|
render(md: string, opts?: {
|
|
190
229
|
stagger?: boolean;
|
|
191
230
|
resolve?: LinkResolve;
|
|
231
|
+
resolveMedia?: import('../render/resolve-media.js').MediaResolve;
|
|
192
232
|
}): string | Promise<string>;
|
|
193
233
|
/** Repo-relative path to the committed content manifest. Defaults to src/content/.cairn/index.json
|
|
194
234
|
* in composeRuntime. It sits outside any concept directory, so content enumeration never globs it. */
|
|
195
235
|
manifestPath?: string;
|
|
236
|
+
/** Repo-relative path to the committed media manifest. Defaults to src/content/.cairn/media.json,
|
|
237
|
+
* applied in composeRuntime. Sits outside any concept directory, like the content manifest. */
|
|
238
|
+
mediaManifestPath?: string;
|
|
196
239
|
/** Directive component registry; the renderer and the future palette derive from it (seam 3). */
|
|
197
240
|
registry?: ComponentRegistry;
|
|
198
241
|
/** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
|
|
@@ -289,12 +332,20 @@ export interface CairnRuntime {
|
|
|
289
332
|
concepts: ConceptDescriptor[];
|
|
290
333
|
backend: BackendConfig;
|
|
291
334
|
sender: SenderConfig;
|
|
292
|
-
/** The site's one renderer: the editor preview and every public page call it (design decision 4).
|
|
335
|
+
/** The site's one renderer: the editor preview and every public page call it (design decision 4).
|
|
336
|
+
* The trailing `resolveMedia` is additive and optional: the build passes a site-resolver-backed
|
|
337
|
+
* media resolver, the preview a manifest-backed one. */
|
|
293
338
|
render(md: string, opts?: {
|
|
294
339
|
stagger?: boolean;
|
|
295
340
|
resolve?: LinkResolve;
|
|
341
|
+
resolveMedia?: import('../render/resolve-media.js').MediaResolve;
|
|
296
342
|
}): string | Promise<string>;
|
|
297
343
|
manifestPath: string;
|
|
344
|
+
/** The repo-relative path to the committed media manifest, defaulted in composeRuntime. */
|
|
345
|
+
mediaManifestPath: string;
|
|
346
|
+
/** The adapter's asset config resolved once at compose: `{ enabled: false }` for a no-media site,
|
|
347
|
+
* otherwise the filled config the upload, storage, delivery, and resolver paths read. */
|
|
348
|
+
resolvedAssets: import('../media/config.js').ResolvedAssetConfig;
|
|
298
349
|
registry?: ComponentRegistry;
|
|
299
350
|
/** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
|
|
300
351
|
icons?: IconSet;
|
package/dist/content/validate.js
CHANGED
|
@@ -39,6 +39,33 @@ export function validateFields(fields, frontmatter) {
|
|
|
39
39
|
data[field.name] = list;
|
|
40
40
|
break;
|
|
41
41
|
}
|
|
42
|
+
case 'image': {
|
|
43
|
+
// A hero is the nested object { src, alt, caption }. Normalize a well-formed value (default
|
|
44
|
+
// a missing alt to empty, since alt is debt and never a save block), and drop the key when
|
|
45
|
+
// src is empty or absent. A malformed value (a string, or an object without a string src)
|
|
46
|
+
// drops the key rather than throwing, so a hand-edit never breaks a save.
|
|
47
|
+
let src = '';
|
|
48
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
49
|
+
const obj = value;
|
|
50
|
+
src = typeof obj.src === 'string' ? obj.src.trim() : '';
|
|
51
|
+
if (src !== '') {
|
|
52
|
+
const normalized = {
|
|
53
|
+
src,
|
|
54
|
+
alt: typeof obj.alt === 'string' ? obj.alt : '',
|
|
55
|
+
};
|
|
56
|
+
const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
|
|
57
|
+
if (caption !== '')
|
|
58
|
+
normalized.caption = caption;
|
|
59
|
+
data[field.name] = normalized;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// A required image needs a src (the presence check), like the other arms; alt is never
|
|
63
|
+
// required, since alt is debt. The inferred type makes a required image non-optional, so the
|
|
64
|
+
// validator must enforce it or a save could omit it against the type.
|
|
65
|
+
if (field.required && src === '')
|
|
66
|
+
errors[field.name] = `${field.label} is required`;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
42
69
|
case 'date': {
|
|
43
70
|
const text = value instanceof Date ? dateInputValue(value) : typeof value === 'string' ? value.trim() : '';
|
|
44
71
|
if (field.required && text === '')
|
|
@@ -2,6 +2,7 @@ import type { ContentSummary, ContentEntry } from './content-index.js';
|
|
|
2
2
|
import type { SiteResolver } from './site-resolver.js';
|
|
3
3
|
import type { SeoMeta } from './seo.js';
|
|
4
4
|
import type { LinkResolve } from '../content/links.js';
|
|
5
|
+
import type { MediaResolve } from '../render/resolve-media.js';
|
|
5
6
|
/** Injected dependencies for the public loaders. */
|
|
6
7
|
export interface PublicRoutesDeps {
|
|
7
8
|
site: SiteResolver;
|
|
@@ -22,6 +23,10 @@ export interface PublicRoutesDeps {
|
|
|
22
23
|
/** A site-wide default OG image, used when an entry declares none. Resolved to absolute like the
|
|
23
24
|
* canonical URL, so a relative path such as "/og/default.png" works. */
|
|
24
25
|
defaultImage?: string;
|
|
26
|
+
/** Resolve a frontmatter `media:` hero reference to its delivery path. The site builds this from its
|
|
27
|
+
* committed `media.json` exactly as it builds the body resolver (`makeMediaResolver`). When absent,
|
|
28
|
+
* media is off and no `heroImage` projection is derived. */
|
|
29
|
+
resolveMedia?: MediaResolve;
|
|
25
30
|
}
|
|
26
31
|
/** The archive and tag list data: summaries the template renders. */
|
|
27
32
|
export interface ListData {
|
|
@@ -47,6 +52,17 @@ export interface EntryData {
|
|
|
47
52
|
seo: SeoMeta;
|
|
48
53
|
newer?: ContentSummary;
|
|
49
54
|
older?: ContentSummary;
|
|
55
|
+
/** The resolved hero image, a derived projection of the frontmatter `image` field. `url` is the
|
|
56
|
+
* root-relative delivery path for an `<img>`, `absoluteUrl` the origin-anchored form for the
|
|
57
|
+
* og:image, and `alt`/`caption` carry from the stored object. The canonical token is untouched:
|
|
58
|
+
* `entry.frontmatter.image.src` stays the `media:` token. Undefined when no hero is set, media is
|
|
59
|
+
* off, the reference does not parse, or the resolver finds no asset. */
|
|
60
|
+
heroImage?: {
|
|
61
|
+
url: string;
|
|
62
|
+
absoluteUrl?: string;
|
|
63
|
+
alt: string;
|
|
64
|
+
caption?: string;
|
|
65
|
+
};
|
|
50
66
|
}
|
|
51
67
|
/** Build the public loaders for a site's unified index. */
|
|
52
68
|
export declare function createPublicRoutes(deps: PublicRoutesDeps): {
|
|
@@ -7,9 +7,46 @@ import { error } from '@sveltejs/kit';
|
|
|
7
7
|
import { buildSeoMeta } from './seo.js';
|
|
8
8
|
import { readSeoFields, resolveImageUrl } from './seo-fields.js';
|
|
9
9
|
import { buildLinkResolver } from './site-resolver.js';
|
|
10
|
+
import { parseMediaToken } from '../media/reference.js';
|
|
10
11
|
/** Build the public loaders for a site's unified index. */
|
|
11
12
|
export function createPublicRoutes(deps) {
|
|
12
|
-
const { site, render, origin, siteName, description, feeds, defaultImage } = deps;
|
|
13
|
+
const { site, render, origin, siteName, description, feeds, defaultImage, resolveMedia } = deps;
|
|
14
|
+
/** Derive the hero projection from an entry's frontmatter, without mutating it (locked decision 5).
|
|
15
|
+
* The hero lives at the conventional `image` key as the validated nested object `{ src, alt, caption }`;
|
|
16
|
+
* only an image field's validate arm produces an object-with-string-`src` shape, so detecting that
|
|
17
|
+
* structure is enough (a text field stores a string, a tags field an array). Returns undefined when
|
|
18
|
+
* media is off, no hero is set, the token does not parse, or the resolver finds no asset.
|
|
19
|
+
*
|
|
20
|
+
* Scope: this resolves the `image` key, which is the back-compat SEO default the schema's `seo`
|
|
21
|
+
* flag also defaults to. A concept that renames its hero (e.g. `cover`) with `seo: true` validates
|
|
22
|
+
* and renders in the editor, but its delivery resolution is not wired here yet, since the field
|
|
23
|
+
* declarations are not reachable in the delivery read path. Honoring a renamed `seo`-flagged field
|
|
24
|
+
* (and a second image field per concept) at delivery is a carried follow-up; every consumer today
|
|
25
|
+
* uses `image`. */
|
|
26
|
+
function deriveHeroImage(frontmatter) {
|
|
27
|
+
if (!resolveMedia)
|
|
28
|
+
return undefined;
|
|
29
|
+
const value = frontmatter.image;
|
|
30
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
31
|
+
return undefined;
|
|
32
|
+
const obj = value;
|
|
33
|
+
if (typeof obj.src !== 'string' || obj.src === '')
|
|
34
|
+
return undefined;
|
|
35
|
+
const ref = parseMediaToken(obj.src);
|
|
36
|
+
if (!ref)
|
|
37
|
+
return undefined;
|
|
38
|
+
const path = resolveMedia(ref);
|
|
39
|
+
if (!path)
|
|
40
|
+
return undefined;
|
|
41
|
+
const hero = {
|
|
42
|
+
url: path,
|
|
43
|
+
absoluteUrl: resolveImageUrl(path, origin),
|
|
44
|
+
alt: typeof obj.alt === 'string' ? obj.alt : '',
|
|
45
|
+
};
|
|
46
|
+
if (typeof obj.caption === 'string' && obj.caption !== '')
|
|
47
|
+
hero.caption = obj.caption;
|
|
48
|
+
return hero;
|
|
49
|
+
}
|
|
13
50
|
/** Resolve one concept's index by id, or a 404 (the route names an unconfigured concept). */
|
|
14
51
|
function indexOf(conceptId) {
|
|
15
52
|
const index = site.concept(conceptId);
|
|
@@ -25,8 +62,13 @@ export function createPublicRoutes(deps) {
|
|
|
25
62
|
const { newer, older } = site.adjacent(entry);
|
|
26
63
|
const canonicalUrl = origin + entry.permalink;
|
|
27
64
|
const fields = readSeoFields(entry.frontmatter);
|
|
65
|
+
const heroImage = deriveHeroImage(entry.frontmatter);
|
|
66
|
+
// The SEO unify (locked decision 3): a resolved structured hero is the social card and wins over
|
|
67
|
+
// the back-compat string `image` field and the site default. A bare-string `image` keeps its
|
|
68
|
+
// origin-anchored behavior. An empty hero alt emits no twitter:image:alt.
|
|
28
69
|
const rawImage = fields.image ?? defaultImage;
|
|
29
|
-
const image = rawImage ? resolveImageUrl(rawImage, origin) : undefined;
|
|
70
|
+
const image = heroImage?.absoluteUrl ?? (rawImage ? resolveImageUrl(rawImage, origin) : undefined);
|
|
71
|
+
const imageAlt = heroImage?.alt && heroImage.alt.trim() !== '' ? heroImage.alt : undefined;
|
|
30
72
|
// A dated entry is an article; an undated one (a page) is a website.
|
|
31
73
|
const seo = buildSeoMeta({
|
|
32
74
|
title: entry.title,
|
|
@@ -37,11 +79,12 @@ export function createPublicRoutes(deps) {
|
|
|
37
79
|
...(entry.date ? { published: entry.date } : {}),
|
|
38
80
|
...(entry.updated ? { modified: entry.updated } : {}),
|
|
39
81
|
...(image ? { image } : {}),
|
|
82
|
+
...(imageAlt ? { imageAlt } : {}),
|
|
40
83
|
...(fields.robots ? { robots: fields.robots } : {}),
|
|
41
84
|
...(fields.author ? { author: fields.author } : {}),
|
|
42
85
|
...(entry.date ? { feeds } : {}),
|
|
43
86
|
});
|
|
44
|
-
return { concept: entry.concept, entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
|
|
87
|
+
return { concept: entry.concept, entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older, ...(heroImage ? { heroImage } : {}) };
|
|
45
88
|
}
|
|
46
89
|
/** The chronological archive for one concept: every non-draft summary, newest-first. */
|
|
47
90
|
function archiveLoad(conceptId) {
|
|
@@ -24,7 +24,13 @@ export function readSeoFields(frontmatter) {
|
|
|
24
24
|
* that path, per the WHATWG URL rules. */
|
|
25
25
|
export function resolveImageUrl(image, origin) {
|
|
26
26
|
try {
|
|
27
|
-
|
|
27
|
+
const url = new URL(image, origin);
|
|
28
|
+
// Guard the unresolved-`media:`-token failure mode: `media:photo.<hash>` is a valid URL scheme,
|
|
29
|
+
// so `new URL(...).href` returns the token verbatim and it would otherwise ship as the og:image.
|
|
30
|
+
// Only an http or https result is a real social-card URL; anything else degrades to no image.
|
|
31
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
32
|
+
return undefined;
|
|
33
|
+
return url.href;
|
|
28
34
|
}
|
|
29
35
|
catch {
|
|
30
36
|
return undefined;
|