@drawnagency/primitives 0.1.114 → 0.1.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-26V4YNOR.js +108 -0
- package/dist/{chunk-OAGJBCSH.js → chunk-4WQ36ZP7.js} +19 -3
- package/dist/{chunk-PTY2RG2K.js → chunk-67P34WNW.js} +75 -130
- package/dist/chunk-APIJPTFG.js +64 -0
- package/dist/chunk-CDBURDPX.js +90 -0
- package/dist/{chunk-PLANHXS7.js → chunk-CEFFP3CH.js} +2 -2
- package/dist/chunk-DFKTF3CH.js +0 -0
- package/dist/chunk-DJABADOO.js +131 -0
- package/dist/{chunk-IP2SEBAJ.js → chunk-DJOGPSRU.js} +31 -72
- package/dist/chunk-DKZCHCV3.js +64 -0
- package/dist/chunk-FGQXHUAE.js +39 -0
- package/dist/chunk-GNF6KFQ7.js +18 -0
- package/dist/{chunk-QHMKIODQ.js → chunk-HHNPSKPT.js} +2 -89
- package/dist/{chunk-PZPGJZRG.js → chunk-HUOW3ZV2.js} +3 -3
- package/dist/{chunk-Y5QECJX7.js → chunk-L7WTERU5.js} +2 -2
- package/dist/{chunk-N3G7DOU3.js → chunk-LNQUUC2S.js} +2 -2
- package/dist/{chunk-7SAAJVDE.js → chunk-MSBHMY3C.js} +17 -15
- package/dist/{chunk-PNSCDBU7.js → chunk-O37AV27P.js} +6 -49
- package/dist/{chunk-5KSAS4VY.js → chunk-RIITGTV3.js} +1 -1
- package/dist/chunk-S7QRHUWO.js +8 -0
- package/dist/chunk-VY3IFGE3.js +70 -0
- package/dist/{chunk-VUYCRBJK.js → chunk-W72BPGR3.js} +7 -5
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +15 -15
- package/dist/components/editor/index.js +8 -8
- package/dist/components/primitives/index.js +14 -9
- package/dist/components/sections/brand-guide.js +12 -7
- package/dist/components/sections/register-schemas.js +17 -12
- package/dist/components/sections/register.js +17 -12
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/deck-builder/index.js +6 -6
- package/dist/hooks/useEditorPublish.d.ts +4 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +78 -49
- package/dist/lib/dexie.d.ts +7 -1
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +3 -2
- package/dist/lib/document-refs.d.ts +8 -0
- package/dist/lib/document-refs.d.ts.map +1 -1
- package/dist/lib/document-refs.js +14 -0
- package/dist/lib/image-variant-plan.d.ts +42 -0
- package/dist/lib/image-variant-plan.d.ts.map +1 -0
- package/dist/lib/image-variant-plan.js +53 -0
- package/dist/lib/index-ops.d.ts.map +1 -1
- package/dist/lib/index-ops.js +20 -16
- package/dist/lib/index.d.ts +2 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +36 -15
- package/dist/lib/loader.d.ts +16 -17
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/media-ops.d.ts +73 -0
- package/dist/lib/media-ops.d.ts.map +1 -0
- package/dist/lib/media-ops.js +60 -0
- package/dist/lib/nav-tree.d.ts +6 -0
- package/dist/lib/nav-tree.d.ts.map +1 -1
- package/dist/lib/pages.d.ts +2 -1
- package/dist/lib/pages.d.ts.map +1 -1
- package/dist/lib/reference-validation.js +4 -33
- package/dist/lib/section-check.d.ts +49 -0
- package/dist/lib/section-check.d.ts.map +1 -0
- package/dist/lib/section-check.js +14 -0
- package/dist/lib/sidecar-diagnostics.d.ts +20 -0
- package/dist/lib/sidecar-diagnostics.d.ts.map +1 -0
- package/dist/lib/sidecars.d.ts +118 -0
- package/dist/lib/sidecars.d.ts.map +1 -0
- package/dist/lib/sidecars.js +23 -0
- package/dist/lib/site-validation.d.ts +123 -0
- package/dist/lib/site-validation.d.ts.map +1 -0
- package/dist/lib/site-validation.js +230 -0
- package/dist/lib/slug.d.ts +2 -0
- package/dist/lib/slug.d.ts.map +1 -0
- package/dist/lib/storage-tier.d.ts +17 -0
- package/dist/lib/storage-tier.d.ts.map +1 -0
- package/dist/lib/storage-tier.js +29 -0
- package/dist/lib/tags.d.ts +6 -0
- package/dist/lib/tags.d.ts.map +1 -1
- package/dist/media/index.js +15 -15
- package/dist/media/queue.d.ts +2 -0
- package/dist/media/queue.d.ts.map +1 -1
- package/dist/media/worker.d.ts +1 -35
- package/dist/media/worker.d.ts.map +1 -1
- package/dist/schemas/index.js +18 -12
- package/dist/schemas/media.d.ts +4 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/storage/index.js +2 -1
- package/dist/storage/memory.d.ts +21 -0
- package/dist/storage/memory.d.ts.map +1 -0
- package/dist/storage/memory.js +129 -0
- package/dist/storage/types.d.ts +9 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +34 -1
- package/src/collab/rebase.ts +12 -16
- package/src/components/shell/EditorShell.tsx +7 -4
- package/src/hooks/useEditorPublish.ts +9 -4
- package/src/hooks/useMediaPipeline.ts +29 -23
- package/src/lib/dexie.ts +9 -1
- package/src/lib/document-refs.ts +30 -0
- package/src/lib/image-variant-plan.ts +92 -0
- package/src/lib/index-ops.ts +9 -18
- package/src/lib/index.ts +15 -0
- package/src/lib/loader.ts +18 -77
- package/src/lib/media-ops.ts +112 -0
- package/src/lib/nav-tree.ts +33 -4
- package/src/lib/pages.ts +2 -3
- package/src/lib/section-check.ts +166 -0
- package/src/lib/sidecar-diagnostics.ts +29 -0
- package/src/lib/sidecars.ts +221 -0
- package/src/lib/site-validation.ts +269 -0
- package/src/lib/slug.ts +6 -0
- package/src/lib/storage-tier.ts +38 -0
- package/src/lib/tags.ts +20 -3
- package/src/media/queue.ts +3 -0
- package/src/media/utils.ts +1 -1
- package/src/media/worker.ts +17 -8
- package/src/schemas/media.ts +7 -2
- package/src/storage/memory.ts +178 -0
- package/src/storage/types.ts +15 -2
- /package/dist/{chunk-45FA7Z55.js → chunk-UUUSJDIG.js} +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { sanitizeMediaName } from "../media/utils";
|
|
2
|
+
import type { MediaConfig, MediaItem, Variant } from "../schemas/media";
|
|
3
|
+
|
|
4
|
+
// The one place an uploaded image's identity, storage folder, variant widths,
|
|
5
|
+
// variant paths and manifest entry are decided. Two pixel adapters sit behind
|
|
6
|
+
// it: the editor's OffscreenCanvas worker (media/worker.ts) and the sharp path
|
|
7
|
+
// (authoring processImageBuffers, which the MCP connector calls). They used to
|
|
8
|
+
// build all of this separately and agree only because a comment said so; the
|
|
9
|
+
// site's media settings and dedup each drifted between them once. Pure and
|
|
10
|
+
// browser-safe — the worker imports it.
|
|
11
|
+
|
|
12
|
+
export interface MediaIdentity {
|
|
13
|
+
id: string;
|
|
14
|
+
hash: string;
|
|
15
|
+
originalName: string;
|
|
16
|
+
/** Storage dir under assets/images/. Never changes once committed. */
|
|
17
|
+
folder: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `hash` is the 16-hex content hash both sides compute (hashFileBuffer / hashBuffer). */
|
|
21
|
+
export function mediaIdentity(hash: string, fileName: string): MediaIdentity {
|
|
22
|
+
const originalName = sanitizeMediaName(fileName);
|
|
23
|
+
return { id: hash, hash, originalName, folder: `${originalName}-${hash}` };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Repo path of one stored file in a media folder: a variant width, or `poster`. */
|
|
27
|
+
export function imageVariantPath(folder: string, key: number | string): string {
|
|
28
|
+
return `assets/images/${folder}/${key}.webp`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ImageVariantPlan extends MediaIdentity {
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
/** WebP quality, 1-100. */
|
|
35
|
+
quality: number;
|
|
36
|
+
/** Ascending by width. */
|
|
37
|
+
variants: { width: number; path: string }[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function planImageVariants(input: {
|
|
41
|
+
hash: string;
|
|
42
|
+
fileName: string;
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
media: Pick<MediaConfig, "sizes" | "quality">;
|
|
46
|
+
}): ImageVariantPlan {
|
|
47
|
+
const identity = mediaIdentity(input.hash, input.fileName);
|
|
48
|
+
// Never upscale; an image narrower than every configured size still gets one
|
|
49
|
+
// variant, at its own width.
|
|
50
|
+
const widths = input.media.sizes.filter((s) => s <= input.width).sort((a, b) => a - b);
|
|
51
|
+
if (widths.length === 0) widths.push(input.width);
|
|
52
|
+
return {
|
|
53
|
+
...identity,
|
|
54
|
+
width: input.width,
|
|
55
|
+
height: input.height,
|
|
56
|
+
quality: input.media.quality,
|
|
57
|
+
variants: widths.map((width) => ({ width, path: imageVariantPath(identity.folder, width) })),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ImageManifestEntry = Omit<MediaItem, "kind" | "storage" | "url" | "duration" | "folderId" | "hidden"> & {
|
|
62
|
+
kind: "image";
|
|
63
|
+
storage: "github";
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `rendered` is what the adapter measured for each planned width (heights come
|
|
68
|
+
* from the encoder, not the plan). `size` is the adapter's byte count for the
|
|
69
|
+
* item: the editor records its largest variant, the sharp path the original's
|
|
70
|
+
* length — they differ today and this module does not decide that.
|
|
71
|
+
*/
|
|
72
|
+
export function imageManifestEntry(plan: ImageVariantPlan, rendered: Variant[], size: number): ImageManifestEntry {
|
|
73
|
+
const variants = plan.variants.map(({ width }) => {
|
|
74
|
+
const out = rendered.find((r) => r.width === width);
|
|
75
|
+
if (!out) throw new Error(`[image-variant-plan] no rendered variant for planned width ${width}`);
|
|
76
|
+
return { width, height: out.height, size: out.size };
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
id: plan.id,
|
|
80
|
+
hash: plan.hash,
|
|
81
|
+
kind: "image",
|
|
82
|
+
originalName: plan.originalName,
|
|
83
|
+
width: plan.width,
|
|
84
|
+
height: plan.height,
|
|
85
|
+
mimeType: "image/webp",
|
|
86
|
+
size,
|
|
87
|
+
folder: plan.folder,
|
|
88
|
+
variants,
|
|
89
|
+
alt: "",
|
|
90
|
+
storage: "github",
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/lib/index-ops.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { IndexSchema, PageStatusSchema, RESERVED_SLUGS, SLUG_RE, type SiteIndex, type SectionMeta } from "../schemas/site-config";
|
|
3
|
-
import type {
|
|
3
|
+
import type { NavGroupEntry } from "../schemas/nav";
|
|
4
4
|
import type { NavLocation } from "./nav-tree";
|
|
5
5
|
// Page and nav mutations are shared with the editor rather than reimplemented:
|
|
6
6
|
// both surfaces must agree on what "delete a page" or "remove a group" means, and
|
|
7
7
|
// a second implementation here is how they would drift.
|
|
8
8
|
import { addPage as addPageToIndex, deletePage, slugifyPageSlug, uniquePageSlug } from "./pages";
|
|
9
|
+
import { liftSidecars } from "./sidecars";
|
|
9
10
|
import {
|
|
10
11
|
addGroup, deleteGroup, groupOfPage, moveNavEntry, navEntries, reconcileNav,
|
|
11
12
|
renameGroup, setGroupStartCollapsed,
|
|
@@ -254,11 +255,12 @@ export function applyIndexOps(
|
|
|
254
255
|
};
|
|
255
256
|
}
|
|
256
257
|
|
|
257
|
-
// IndexSchema strips `nav` (that strip is what keeps the
|
|
258
|
-
// index.json), so the authored nav
|
|
259
|
-
// available to the nav ops below
|
|
260
|
-
//
|
|
261
|
-
|
|
258
|
+
// IndexSchema strips `nav` (that strip is what keeps the Sidecar out of
|
|
259
|
+
// index.json), so the authored nav is lifted off the RAW input to be
|
|
260
|
+
// available to the nav ops below — as fresh entries, so no op can mutate the
|
|
261
|
+
// caller's index. reconcileNav is total, so an unreadable nav degrades to a
|
|
262
|
+
// flat one rather than failing.
|
|
263
|
+
const lifted = liftSidecars(rawIndex);
|
|
262
264
|
|
|
263
265
|
// Own copy: the caller's index is also its knownSiteTypes source and may be
|
|
264
266
|
// logged, so nothing here mutates it. Reassigned by the page/nav ops, which
|
|
@@ -267,18 +269,7 @@ export function applyIndexOps(
|
|
|
267
269
|
...base.data,
|
|
268
270
|
pages: base.data.pages.map((p) => ({ ...p, order: [...p.order] })),
|
|
269
271
|
sections: Object.fromEntries(Object.entries(base.data.sections).map(([k, v]) => [k, { ...v }])),
|
|
270
|
-
|
|
271
|
-
// op that spliced in place would mutate the caller's index — the one thing
|
|
272
|
-
// this function's own comment promises it never does.
|
|
273
|
-
...(Array.isArray(rawNav)
|
|
274
|
-
? {
|
|
275
|
-
nav: (rawNav as NavEntry[]).map((entry) =>
|
|
276
|
-
entry && typeof entry === "object" && "pageIds" in entry
|
|
277
|
-
? { ...entry, pageIds: [...(entry as { pageIds: string[] }).pageIds] }
|
|
278
|
-
: { ...entry },
|
|
279
|
-
),
|
|
280
|
-
}
|
|
281
|
-
: {}),
|
|
272
|
+
...(lifted.nav ? { nav: lifted.nav.entries } : {}),
|
|
282
273
|
};
|
|
283
274
|
|
|
284
275
|
const removed = new Set<string>();
|
package/src/lib/index.ts
CHANGED
|
@@ -5,6 +5,20 @@ export { resolveChatbotName, resolveThinkingPhrases, DEFAULT_CHATBOT_NAME } from
|
|
|
5
5
|
export { generateNavLinks, toSectionId, type NavItem } from "./nav";
|
|
6
6
|
export { navEntries, pagesInNavOrder, groupOfPage, groupDisplayLabel, reconcileNav } from "./nav-tree";
|
|
7
7
|
export { siteTags, tagDisplayLabel, reconcileTags } from "./tags";
|
|
8
|
+
export {
|
|
9
|
+
NAV,
|
|
10
|
+
TAGS,
|
|
11
|
+
SIDECARS,
|
|
12
|
+
readSidecar,
|
|
13
|
+
readSidecarText,
|
|
14
|
+
liftSidecars,
|
|
15
|
+
planSidecarWrite,
|
|
16
|
+
type SidecarDefinition,
|
|
17
|
+
type SidecarFileState,
|
|
18
|
+
type SidecarWritePlan,
|
|
19
|
+
type SidecarRead,
|
|
20
|
+
type SidecarDiagnostic,
|
|
21
|
+
} from "./sidecars";
|
|
8
22
|
export { resolveReference, type ReferenceContent } from "./references";
|
|
9
23
|
export { REFERENCE_TYPE } from "./reference-type";
|
|
10
24
|
export {
|
|
@@ -46,4 +60,5 @@ export {
|
|
|
46
60
|
loadStaticSiteContent,
|
|
47
61
|
type LoadedSection,
|
|
48
62
|
type SiteContent,
|
|
63
|
+
type SidecarReads,
|
|
49
64
|
} from "./loader";
|
package/src/lib/loader.ts
CHANGED
|
@@ -3,8 +3,9 @@ import { IndexSchema, type SiteIndex, type SectionMeta } from "../schemas/site-c
|
|
|
3
3
|
import { getAllSchemas } from "./registry";
|
|
4
4
|
import { blockDepth, MAX_BLOCK_DEPTH } from "../schemas/block";
|
|
5
5
|
import { upgradeLegacySection } from "./migrate-sections-transform";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import type { NavEntry } from "../schemas/nav";
|
|
7
|
+
import type { TagEntry } from "../schemas/tags";
|
|
8
|
+
import type { SidecarRead } from "./sidecars";
|
|
8
9
|
|
|
9
10
|
export interface LoadedSection {
|
|
10
11
|
section: Section;
|
|
@@ -17,11 +18,9 @@ export interface LoadedSection {
|
|
|
17
18
|
export interface SiteContent {
|
|
18
19
|
sections: LoadedSection[];
|
|
19
20
|
index: SiteIndex;
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* nav.json from a degraded read permanently destroys the entries this
|
|
24
|
-
* version dropped. */
|
|
21
|
+
/** A Degraded read of nav.json (see SidecarRead): the returned nav is missing
|
|
22
|
+
* entries, so writers treat it as read-only — wholesale-replacing nav.json
|
|
23
|
+
* from it permanently destroys the entries this version dropped. */
|
|
25
24
|
navDegraded: boolean;
|
|
26
25
|
/** Same contract as navDegraded, for tags.json: a degraded read is
|
|
27
26
|
* read-only for tags. */
|
|
@@ -29,68 +28,13 @@ export interface SiteContent {
|
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* the site. Any drop is reported as `degraded` so write paths can refuse to
|
|
38
|
-
* persist the lossy tree back over the original.
|
|
31
|
+
* The Sidecars a load read, from the Sidecar module's readers. A Sidecar the
|
|
32
|
+
* load did not look for is omitted. Tags then stay unattached ("not loaded":
|
|
33
|
+
* writers leave tags.json alone, where an attached `[]` saved back would delete
|
|
34
|
+
* every tag). Nav is attached as `[]` either way — the editor has no nav gate
|
|
35
|
+
* yet to make "not loaded" safe for nav (exploration B).
|
|
39
36
|
*/
|
|
40
|
-
|
|
41
|
-
if (!navFile) return { entries: [], degraded: false };
|
|
42
|
-
const rawEntries = (navFile as { entries?: unknown }).entries;
|
|
43
|
-
if (!Array.isArray(rawEntries)) {
|
|
44
|
-
console.warn("[loader] Invalid nav.json, falling back to a flat navigation");
|
|
45
|
-
return { entries: [], degraded: true };
|
|
46
|
-
}
|
|
47
|
-
const entries: NavEntry[] = [];
|
|
48
|
-
let dropped = 0;
|
|
49
|
-
for (const raw of rawEntries) {
|
|
50
|
-
const result = NavEntrySchema.safeParse(raw);
|
|
51
|
-
if (result.success) entries.push(result.data);
|
|
52
|
-
else dropped++;
|
|
53
|
-
}
|
|
54
|
-
if (dropped > 0) {
|
|
55
|
-
console.warn(`[loader] nav.json: dropped ${dropped} entr${dropped === 1 ? "y" : "ies"} this package version cannot represent; navigation is degraded`);
|
|
56
|
-
}
|
|
57
|
-
return { entries, degraded: dropped > 0 };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Parse the tags.json sidecar with parseNavFile's per-entry tolerance and the
|
|
62
|
-
* same `degraded` contract. Assignments to deleted sections are NOT pruned
|
|
63
|
-
* here — that is reconcileTags' job on every read (siteTags), so the runtime
|
|
64
|
-
* field stays a faithful copy of the file.
|
|
65
|
-
*/
|
|
66
|
-
function parseTagsFile(tagsFile: unknown): { tags: TagEntry[]; degraded: boolean } {
|
|
67
|
-
if (!tagsFile) return { tags: [], degraded: false };
|
|
68
|
-
const rawTags = (tagsFile as { tags?: unknown }).tags;
|
|
69
|
-
if (!Array.isArray(rawTags)) {
|
|
70
|
-
console.warn("[loader] Invalid tags.json, falling back to no tags");
|
|
71
|
-
return { tags: [], degraded: true };
|
|
72
|
-
}
|
|
73
|
-
const tags: TagEntry[] = [];
|
|
74
|
-
let dropped = 0;
|
|
75
|
-
for (const raw of rawTags) {
|
|
76
|
-
const result = TagEntrySchema.safeParse(raw);
|
|
77
|
-
if (result.success) tags.push(result.data);
|
|
78
|
-
else dropped++;
|
|
79
|
-
}
|
|
80
|
-
if (dropped > 0) {
|
|
81
|
-
console.warn(`[loader] tags.json: dropped ${dropped} entr${dropped === 1 ? "y" : "ies"} this package version cannot represent; tags are degraded`);
|
|
82
|
-
}
|
|
83
|
-
return { tags, degraded: dropped > 0 };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Whether a load looked for tags.json. Not loaded ⇒ no `index.tags` is attached
|
|
88
|
-
* and writers leave the file alone; loaded ⇒ `file` is its parsed contents,
|
|
89
|
-
* with null/undefined meaning absent (an empty registry). Unlike nav, the
|
|
90
|
-
* difference is load-bearing — an attached `[]` that a writer saves back
|
|
91
|
-
* deletes every tag — hence an explicit flag rather than undefined-vs-null.
|
|
92
|
-
*/
|
|
93
|
-
export type TagsSidecar = { loaded: false } | { loaded: true; file: unknown };
|
|
37
|
+
export type SidecarReads = { nav?: SidecarRead<NavEntry>; tags?: SidecarRead<TagEntry> };
|
|
94
38
|
|
|
95
39
|
/**
|
|
96
40
|
* Merge a validated index with raw section file contents.
|
|
@@ -100,8 +44,7 @@ export type TagsSidecar = { loaded: false } | { loaded: true; file: unknown };
|
|
|
100
44
|
export function mergeSiteContent(
|
|
101
45
|
index: SiteIndex,
|
|
102
46
|
sectionFiles: Record<string, unknown>,
|
|
103
|
-
|
|
104
|
-
tagsSidecar: TagsSidecar = { loaded: false },
|
|
47
|
+
sidecars: SidecarReads = {},
|
|
105
48
|
): SiteContent {
|
|
106
49
|
const sections: LoadedSection[] = [];
|
|
107
50
|
|
|
@@ -154,12 +97,11 @@ export function mergeSiteContent(
|
|
|
154
97
|
}
|
|
155
98
|
}
|
|
156
99
|
|
|
157
|
-
const nav =
|
|
158
|
-
const tags = tagsSidecar.loaded ? parseTagsFile(tagsSidecar.file) : null;
|
|
100
|
+
const { nav, tags } = sidecars;
|
|
159
101
|
return {
|
|
160
102
|
sections,
|
|
161
|
-
index: { ...index, nav: nav
|
|
162
|
-
navDegraded: nav
|
|
103
|
+
index: { ...index, nav: nav?.entries ?? [], ...(tags ? { tags: tags.entries } : {}) },
|
|
104
|
+
navDegraded: nav?.degraded ?? false,
|
|
163
105
|
tagsDegraded: tags?.degraded ?? false,
|
|
164
106
|
};
|
|
165
107
|
}
|
|
@@ -172,8 +114,7 @@ export function mergeSiteContent(
|
|
|
172
114
|
export function loadStaticSiteContent(
|
|
173
115
|
index: SiteIndex,
|
|
174
116
|
sectionGlob: Record<string, unknown>,
|
|
175
|
-
|
|
176
|
-
tagsSidecar: TagsSidecar = { loaded: false },
|
|
117
|
+
sidecars: SidecarReads = {},
|
|
177
118
|
): SiteContent {
|
|
178
119
|
const sectionFiles: Record<string, unknown> = {};
|
|
179
120
|
for (const [path, data] of Object.entries(sectionGlob)) {
|
|
@@ -181,5 +122,5 @@ export function loadStaticSiteContent(
|
|
|
181
122
|
const id = filename.replace(".json", "");
|
|
182
123
|
sectionFiles[id] = data;
|
|
183
124
|
}
|
|
184
|
-
return mergeSiteContent(index, sectionFiles,
|
|
125
|
+
return mergeSiteContent(index, sectionFiles, sidecars);
|
|
185
126
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { MediaItem, MediaManifest } from "../schemas/media";
|
|
2
|
+
import type { DocumentManifest, DocumentManifestEntry } from "../schemas/documents";
|
|
3
|
+
|
|
4
|
+
// Media intents (CONTEXT.md: Index op / Media intent): a change to the media
|
|
5
|
+
// library stated as what to do, applied by the write path to its own fresh read
|
|
6
|
+
// of the manifests. A writer that sent a whole manifest instead could only
|
|
7
|
+
// send back what it had read — dropping another writer's images, or `folders`,
|
|
8
|
+
// the moment its copy was stale. Intents leave everything they do not name as
|
|
9
|
+
// the branch has it. Pure: the write path does the reads and the commit.
|
|
10
|
+
|
|
11
|
+
export type MediaBlob = { path: string; base64: string };
|
|
12
|
+
|
|
13
|
+
export type MediaOp =
|
|
14
|
+
/**
|
|
15
|
+
* A new image and its variant files, keyed by its id (the content hash).
|
|
16
|
+
* `alt` is the caller's alt text, if it sent one — the only alt applied when
|
|
17
|
+
* the image turns out to be in the library already (`entry.alt` is just the
|
|
18
|
+
* new entry's default).
|
|
19
|
+
*/
|
|
20
|
+
| { op: "addImage"; entry: MediaItem; blobs: MediaBlob[]; alt?: string }
|
|
21
|
+
| { op: "setImageAlt"; imageId: string; alt: string }
|
|
22
|
+
| { op: "deleteImage"; imageId: string }
|
|
23
|
+
/**
|
|
24
|
+
* A document's manifest entry, with its bytes when they ride in the commit
|
|
25
|
+
* (git tier; bucket-tier bytes are uploaded beforehand). For an id already in
|
|
26
|
+
* the library — git-tier ids are the content hash, so the same bytes — only
|
|
27
|
+
* the filename is applied: its stored bytes and path stay. That is how a
|
|
28
|
+
* rename lands.
|
|
29
|
+
*/
|
|
30
|
+
| { op: "putDocument"; documentId: string; entry: DocumentManifestEntry; blob?: MediaBlob };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per op, in order. `existed` means `addImage` found the image already in the
|
|
34
|
+
* library: its bytes are not committed again, and only a differing alt text is
|
|
35
|
+
* applied.
|
|
36
|
+
*/
|
|
37
|
+
export type MediaOpOutcome = "added" | "existed" | "updated" | "deleted";
|
|
38
|
+
|
|
39
|
+
export type AppliedMediaOps = {
|
|
40
|
+
ok: true;
|
|
41
|
+
images: MediaManifest;
|
|
42
|
+
/** Null when no op touched documents, so the write leaves that file alone. */
|
|
43
|
+
documents: DocumentManifest | null;
|
|
44
|
+
uploads: { item: { id: string; folder: string }; blobs: MediaBlob[] }[];
|
|
45
|
+
/** Images whose variant folders the write deletes. */
|
|
46
|
+
deletions: { id: string; folder: string }[];
|
|
47
|
+
outcomes: MediaOpOutcome[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function applyMediaOps(
|
|
51
|
+
manifests: { images: MediaManifest; documents: DocumentManifest },
|
|
52
|
+
ops: MediaOp[],
|
|
53
|
+
): AppliedMediaOps | { ok: false; code: "not_found"; error: string } {
|
|
54
|
+
const images: MediaManifest = { ...manifests.images, images: { ...manifests.images.images } };
|
|
55
|
+
let documents: DocumentManifest | null = null;
|
|
56
|
+
const uploads: AppliedMediaOps["uploads"] = [];
|
|
57
|
+
const deletions: AppliedMediaOps["deletions"] = [];
|
|
58
|
+
const outcomes: MediaOpOutcome[] = [];
|
|
59
|
+
|
|
60
|
+
// Keys are content hashes and normally equal the entry's id; find by id so a
|
|
61
|
+
// caller naming the id it was given always reaches the right entry.
|
|
62
|
+
const keyOf = (imageId: string) =>
|
|
63
|
+
imageId in images.images ? imageId : Object.keys(images.images).find((key) => images.images[key].id === imageId);
|
|
64
|
+
const notFound = (imageId: string) =>
|
|
65
|
+
({ ok: false, code: "not_found", error: `No media with imageId "${imageId}" in the site's media library.` }) as const;
|
|
66
|
+
|
|
67
|
+
for (const op of ops) {
|
|
68
|
+
switch (op.op) {
|
|
69
|
+
case "addImage": {
|
|
70
|
+
const key = keyOf(op.entry.id);
|
|
71
|
+
if (key === undefined) {
|
|
72
|
+
images.images[op.entry.id] = op.entry;
|
|
73
|
+
uploads.push({ item: { id: op.entry.id, folder: op.entry.folder }, blobs: op.blobs });
|
|
74
|
+
outcomes.push("added");
|
|
75
|
+
} else {
|
|
76
|
+
if (op.alt !== undefined) images.images[key] = { ...images.images[key], alt: op.alt };
|
|
77
|
+
outcomes.push("existed");
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "setImageAlt": {
|
|
82
|
+
const key = keyOf(op.imageId);
|
|
83
|
+
if (key === undefined) return notFound(op.imageId);
|
|
84
|
+
images.images[key] = { ...images.images[key], alt: op.alt };
|
|
85
|
+
outcomes.push("updated");
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "deleteImage": {
|
|
89
|
+
const key = keyOf(op.imageId);
|
|
90
|
+
if (key === undefined) return notFound(op.imageId);
|
|
91
|
+
deletions.push({ id: key, folder: images.images[key].folder });
|
|
92
|
+
delete images.images[key];
|
|
93
|
+
outcomes.push("deleted");
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case "putDocument": {
|
|
97
|
+
documents ??= { ...manifests.documents };
|
|
98
|
+
const existing = documents[op.documentId];
|
|
99
|
+
if (existing) {
|
|
100
|
+
documents[op.documentId] = { ...existing, filename: op.entry.filename };
|
|
101
|
+
outcomes.push(existing.filename === op.entry.filename ? "existed" : "updated");
|
|
102
|
+
} else {
|
|
103
|
+
documents[op.documentId] = op.entry;
|
|
104
|
+
if (op.blob) uploads.push({ item: { id: op.documentId, folder: op.blob.path.split("/")[2] ?? "" }, blobs: [op.blob] });
|
|
105
|
+
outcomes.push("added");
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, images, documents, uploads, deletions, outcomes };
|
|
112
|
+
}
|
package/src/lib/nav-tree.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { NavEntry, NavGroupEntry } from "../schemas/nav";
|
|
2
2
|
import type { Page, SiteIndex } from "../schemas/site-config";
|
|
3
|
+
import { diagnosticsCollector, type SidecarDiagnostic } from "./sidecar-diagnostics";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Repair an authored nav against the current page set.
|
|
@@ -19,6 +20,15 @@ import type { Page, SiteIndex } from "../schemas/site-config";
|
|
|
19
20
|
* prior state and defeat identity-based re-render checks.
|
|
20
21
|
*/
|
|
21
22
|
export function reconcileNav(entries: NavEntry[], pages: Page[]): NavEntry[] {
|
|
23
|
+
return reconcileNavReport(entries, pages).entries;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** reconcileNav plus what the repair changed — what a validator reports. */
|
|
27
|
+
export function reconcileNavReport(
|
|
28
|
+
entries: NavEntry[],
|
|
29
|
+
pages: Page[],
|
|
30
|
+
): { entries: NavEntry[]; diagnostics: SidecarDiagnostic[] } {
|
|
31
|
+
const report = diagnosticsCollector();
|
|
22
32
|
const entryList = Array.isArray(entries) ? entries : [];
|
|
23
33
|
const pageList = Array.isArray(pages) ? pages : [];
|
|
24
34
|
|
|
@@ -33,7 +43,14 @@ export function reconcileNav(entries: NavEntry[], pages: Page[]): NavEntry[] {
|
|
|
33
43
|
|
|
34
44
|
for (const entry of entryList) {
|
|
35
45
|
if (entry?.kind === "page") {
|
|
36
|
-
if (!validIds.has(entry.pageId)
|
|
46
|
+
if (!validIds.has(entry.pageId)) {
|
|
47
|
+
report.add("dangling", entry.pageId);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (placed.has(entry.pageId)) {
|
|
51
|
+
report.add("duplicate-member", entry.pageId);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
37
54
|
placed.add(entry.pageId);
|
|
38
55
|
result.push({ ...entry });
|
|
39
56
|
continue;
|
|
@@ -46,12 +63,23 @@ export function reconcileNav(entries: NavEntry[], pages: Page[]): NavEntry[] {
|
|
|
46
63
|
// and "total" would be a lie. Dropping it spills its members to the tail
|
|
47
64
|
// append below: a group lost, never a page.
|
|
48
65
|
if (typeof entry.id !== "string" || entry.id === "") continue;
|
|
49
|
-
if (seenGroupIds.has(entry.id))
|
|
66
|
+
if (seenGroupIds.has(entry.id)) {
|
|
67
|
+
report.add("duplicate-id", entry.id);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
50
70
|
seenGroupIds.add(entry.id);
|
|
51
71
|
|
|
52
72
|
const claimed = Array.isArray(entry.pageIds) ? entry.pageIds : [];
|
|
53
73
|
const pageIds = claimed.filter((id) => {
|
|
54
|
-
if (typeof id !== "string"
|
|
74
|
+
if (typeof id !== "string") return false;
|
|
75
|
+
if (!validIds.has(id)) {
|
|
76
|
+
report.add("dangling", id);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (placed.has(id)) {
|
|
80
|
+
report.add("duplicate-member", id);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
55
83
|
placed.add(id);
|
|
56
84
|
return true;
|
|
57
85
|
});
|
|
@@ -70,10 +98,11 @@ export function reconcileNav(entries: NavEntry[], pages: Page[]): NavEntry[] {
|
|
|
70
98
|
for (const p of pageList) {
|
|
71
99
|
if (!p?.id || placed.has(p.id)) continue;
|
|
72
100
|
placed.add(p.id);
|
|
101
|
+
report.add("unlisted", p.id);
|
|
73
102
|
result.push({ kind: "page", pageId: p.id });
|
|
74
103
|
}
|
|
75
104
|
|
|
76
|
-
return result;
|
|
105
|
+
return { entries: result, diagnostics: report.list() };
|
|
77
106
|
}
|
|
78
107
|
|
|
79
108
|
/**
|
package/src/lib/pages.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { RESERVED_SLUGS } from "../schemas/site-config";
|
|
|
5
5
|
// block (mirrors the same sanctioned import in schemas/site-config.ts).
|
|
6
6
|
import { documentOptionsSchema, type DocumentOptions } from "../document/engine/settings";
|
|
7
7
|
import { navEntries } from "./nav-tree";
|
|
8
|
+
import { slugifyPageSlug } from "./slug";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Display fallback for blank page titles. addPage creates pages with an empty
|
|
@@ -15,9 +16,7 @@ export function pageDisplayTitle(title: string): string {
|
|
|
15
16
|
return title || "Untitled";
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
export
|
|
19
|
-
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
20
|
-
}
|
|
19
|
+
export { slugifyPageSlug };
|
|
21
20
|
|
|
22
21
|
export function uniquePageSlug(base: string, existing: string[]): string {
|
|
23
22
|
const taken = new Set(existing);
|