@drawnagency/primitives 0.1.114 → 0.1.115
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-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-DFKTF3CH.js +0 -0
- package/dist/chunk-DJABADOO.js +131 -0
- package/dist/{chunk-IP2SEBAJ.js → chunk-DJOGPSRU.js} +31 -72
- package/dist/{chunk-N3G7DOU3.js → chunk-FHTKSELX.js} +6 -6
- package/dist/chunk-GNF6KFQ7.js +18 -0
- package/dist/{chunk-QHMKIODQ.js → chunk-HHNPSKPT.js} +2 -89
- package/dist/{chunk-PZPGJZRG.js → chunk-JXATNKLV.js} +5 -5
- package/dist/{chunk-PLANHXS7.js → chunk-NX7TFHA4.js} +9 -9
- 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-7SAAJVDE.js → chunk-T26HAKW3.js} +37 -35
- package/dist/chunk-VY3IFGE3.js +70 -0
- package/dist/{chunk-VUYCRBJK.js → chunk-WYOFQHKI.js} +6 -4
- package/dist/{chunk-Y5QECJX7.js → chunk-YV3YSZEB.js} +5 -5
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +15 -15
- package/dist/components/editor/index.js +13 -13
- package/dist/components/primitives/index.js +16 -11
- package/dist/components/sections/brand-guide.js +13 -8
- package/dist/components/sections/register-schemas.js +20 -15
- package/dist/components/sections/register.js +20 -15
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/deck-builder/index.js +9 -9
- 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 +85 -56
- 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/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 +43 -22
- 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/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/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 +21 -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/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/sidecar-diagnostics.ts +29 -0
- package/src/lib/sidecars.ts +221 -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,221 @@
|
|
|
1
|
+
import type { ZodType } from "zod";
|
|
2
|
+
import { NavEntrySchema, NavFileSchema, type NavEntry } from "../schemas/nav";
|
|
3
|
+
import { TagEntrySchema, TagsFileSchema, type TagEntry } from "../schemas/tags";
|
|
4
|
+
import type { SiteIndex } from "../schemas/site-config";
|
|
5
|
+
import { reconcileNavReport } from "./nav-tree";
|
|
6
|
+
import { mergeTags, reconcileTagsReport } from "./tags";
|
|
7
|
+
import type { SidecarDiagnostic } from "./sidecar-diagnostics";
|
|
8
|
+
|
|
9
|
+
export type { SidecarDiagnostic };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One definition per Sidecar — a content file kept beside index.json rather than
|
|
13
|
+
* on it, so a writer that predates it cannot drop it (CONTEXT.md). Every rule a
|
|
14
|
+
* caller would otherwise re-implement per file lives behind these: lenient
|
|
15
|
+
* parse, lifting the wire key off an index before IndexSchema strips it, and the
|
|
16
|
+
* two places the Sidecars genuinely differ — how a list is repaired against the
|
|
17
|
+
* index, and how concurrent edits rebase. Pure and browser-safe: the editor uses
|
|
18
|
+
* it during hydration, and callers do their own I/O.
|
|
19
|
+
*/
|
|
20
|
+
export interface SidecarDefinition<E> {
|
|
21
|
+
/** Also the key the Sidecar travels under on the wire (`index.nav`, `index.tags`). */
|
|
22
|
+
name: "nav" | "tags";
|
|
23
|
+
path: string;
|
|
24
|
+
/** The key holding the entry list inside the file. */
|
|
25
|
+
fileKey: "entries" | "tags";
|
|
26
|
+
entrySchema: ZodType<E>;
|
|
27
|
+
fileSchema: ZodType<unknown>;
|
|
28
|
+
/** Total, pure and idempotent; also reports what it repaired. */
|
|
29
|
+
reconcile(entries: E[], index: SiteIndex): { entries: E[]; diagnostics: SidecarDiagnostic[] };
|
|
30
|
+
/** The list after a collab rebase; `undefined` when no side carries one. */
|
|
31
|
+
rebase(args: { base: E[] | undefined; local: E[] | undefined; remote: E[] | undefined }): E[] | undefined;
|
|
32
|
+
/** Words for a declined write's warning. */
|
|
33
|
+
words: {
|
|
34
|
+
/** What the file holds: "navigation entries". */
|
|
35
|
+
entries: string;
|
|
36
|
+
/** What did not land: "Page grouping changes". */
|
|
37
|
+
changes: string;
|
|
38
|
+
/** What a valid payload is: "a valid navigation tree". */
|
|
39
|
+
valid: string;
|
|
40
|
+
/** The expected wire shape, for an agent that sent the wrong one. */
|
|
41
|
+
shape: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const NAV: SidecarDefinition<NavEntry> = {
|
|
46
|
+
name: "nav",
|
|
47
|
+
path: "src/content/nav.json",
|
|
48
|
+
fileKey: "entries",
|
|
49
|
+
entrySchema: NavEntrySchema,
|
|
50
|
+
fileSchema: NavFileSchema,
|
|
51
|
+
reconcile: (entries, index) => reconcileNavReport(entries, index.pages),
|
|
52
|
+
// Grouping is edited rarely and has no dirty tracking, so persisted remote
|
|
53
|
+
// state beats unsaved local edits. A remote without the Sidecar (an older
|
|
54
|
+
// server) keeps ours rather than flattening it.
|
|
55
|
+
rebase: ({ local, remote }) => (remote !== undefined ? remote : local),
|
|
56
|
+
words: {
|
|
57
|
+
entries: "navigation entries",
|
|
58
|
+
changes: "Page grouping changes",
|
|
59
|
+
valid: "a valid navigation tree",
|
|
60
|
+
shape: 'an array of {kind:"page",pageId} / {kind:"group",id,label,startCollapsed,pageIds} entries',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const TAGS: SidecarDefinition<TagEntry> = {
|
|
65
|
+
name: "tags",
|
|
66
|
+
path: "src/content/tags.json",
|
|
67
|
+
fileKey: "tags",
|
|
68
|
+
entrySchema: TagEntrySchema,
|
|
69
|
+
fileSchema: TagsFileSchema,
|
|
70
|
+
reconcile: (entries, index) => reconcileTagsReport(entries, index.sections),
|
|
71
|
+
// Tagging is edited from every section's chrome, so remote-wins would drop
|
|
72
|
+
// unsaved tagging whenever a peer saved anything. See mergeTags.
|
|
73
|
+
rebase: mergeTags,
|
|
74
|
+
words: {
|
|
75
|
+
entries: "tag entries",
|
|
76
|
+
changes: "Tag changes",
|
|
77
|
+
valid: "a valid tag list",
|
|
78
|
+
shape: "an array of {id,label,sectionIds} entries",
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Widened once here: the definitions are invariant in their entry type, so a
|
|
83
|
+
// loop over both needs the common type.
|
|
84
|
+
export const SIDECARS: readonly SidecarDefinition<unknown>[] = [NAV, TAGS] as readonly SidecarDefinition<unknown>[];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A Sidecar as read. `degraded` means the file was present but held something
|
|
88
|
+
* this package version could not represent, so `entries` is missing it: the read
|
|
89
|
+
* is a Degraded read, never to be cached, posted back, or written over the file.
|
|
90
|
+
*/
|
|
91
|
+
export type SidecarRead<E> = { entries: E[]; degraded: boolean };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Tolerance is per entry: one unknown entry kind (a newer package's addition, an
|
|
95
|
+
* authoring typo) drops that entry alone, not every group or tag on the site.
|
|
96
|
+
*/
|
|
97
|
+
function readEntries<E>(def: SidecarDefinition<E>, raw: unknown): SidecarRead<E> {
|
|
98
|
+
if (!Array.isArray(raw)) {
|
|
99
|
+
console.warn(`[sidecars] ${def.name}: entry list is not a list; reading it as empty and degraded`);
|
|
100
|
+
return { entries: [], degraded: true };
|
|
101
|
+
}
|
|
102
|
+
const entries: E[] = [];
|
|
103
|
+
let dropped = 0;
|
|
104
|
+
for (const item of raw) {
|
|
105
|
+
const parsed = def.entrySchema.safeParse(item);
|
|
106
|
+
if (parsed.success) entries.push(parsed.data);
|
|
107
|
+
else dropped++;
|
|
108
|
+
}
|
|
109
|
+
if (dropped > 0) {
|
|
110
|
+
console.warn(`[sidecars] ${def.name}: dropped ${dropped} entr${dropped === 1 ? "y" : "ies"} this package version cannot represent; the read is degraded`);
|
|
111
|
+
}
|
|
112
|
+
return { entries, degraded: dropped > 0 };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Read a parsed Sidecar file. Absent (null/undefined) is an empty list, not degraded. */
|
|
116
|
+
export function readSidecar<E>(def: SidecarDefinition<E>, file: unknown): SidecarRead<E> {
|
|
117
|
+
if (file === undefined || file === null) return { entries: [], degraded: false };
|
|
118
|
+
return readEntries(def, (file as Record<string, unknown>)[def.fileKey]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Read a Sidecar file's raw text. Present-but-unparseable JSON is degraded, not
|
|
123
|
+
* absent: a site whose nav.json is corrupt still has groups, and treating it as
|
|
124
|
+
* "never had any" would let a writer replace them.
|
|
125
|
+
*/
|
|
126
|
+
export function readSidecarText<E>(def: SidecarDefinition<E>, text: string | undefined): SidecarRead<E> {
|
|
127
|
+
if (text === undefined) return { entries: [], degraded: false };
|
|
128
|
+
let file: unknown;
|
|
129
|
+
try {
|
|
130
|
+
file = JSON.parse(text);
|
|
131
|
+
} catch {
|
|
132
|
+
console.warn(`[sidecars] ${def.name}: ${def.path} is not valid JSON; the read is degraded`);
|
|
133
|
+
return { entries: [], degraded: true };
|
|
134
|
+
}
|
|
135
|
+
return readSidecar(def, file);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Lift the Sidecars off an index as it arrives on the wire, BEFORE anything
|
|
140
|
+
* parses it with IndexSchema — the parse strips unknown keys, and an index
|
|
141
|
+
* round-tripped through it has lost its Sidecars. A key the index does not carry
|
|
142
|
+
* is `undefined` (omitted: writers leave the file alone); a carried key is read
|
|
143
|
+
* with the file's per-entry leniency, into fresh objects that never alias the
|
|
144
|
+
* caller's.
|
|
145
|
+
*/
|
|
146
|
+
export function liftSidecars(rawIndex: unknown): { nav?: SidecarRead<NavEntry>; tags?: SidecarRead<TagEntry> } {
|
|
147
|
+
const raw = rawIndex !== null && typeof rawIndex === "object" ? (rawIndex as Record<string, unknown>) : {};
|
|
148
|
+
return {
|
|
149
|
+
...(raw.nav !== undefined ? { nav: readEntries(NAV, raw.nav) } : {}),
|
|
150
|
+
...(raw.tags !== undefined ? { tags: readEntries(TAGS, raw.tags) } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The Sidecar file on the branch a write lands on, as the writer read it. */
|
|
155
|
+
export type SidecarFileState =
|
|
156
|
+
| { kind: "absent" }
|
|
157
|
+
| { kind: "present"; text: string }
|
|
158
|
+
/** The read itself failed, so nothing is known about the file. */
|
|
159
|
+
| { kind: "unreadable" };
|
|
160
|
+
|
|
161
|
+
export type SidecarWritePlan =
|
|
162
|
+
| { action: "untouched" }
|
|
163
|
+
| { action: "write"; path: string; content: string }
|
|
164
|
+
/** Declined. The rest of the write still lands, so the warning is what tells the caller. */
|
|
165
|
+
| { action: "skip"; warning: string };
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What a write does to one Sidecar file, given what was posted (from
|
|
169
|
+
* liftSidecars) and the file on the branch. Every Sidecar write REPLACES the
|
|
170
|
+
* file, and the posted list was built from what this package version could
|
|
171
|
+
* READ — so a file holding anything this version cannot fully parse (an entry
|
|
172
|
+
* from a newer package, or a corrupt file) is read-only: replacing it would
|
|
173
|
+
* destroy those entries with a clean write. The same holds for a posted
|
|
174
|
+
* Degraded read.
|
|
175
|
+
*
|
|
176
|
+
* Emptiness is judged on the POSTED list, before reconcile: an empty list never
|
|
177
|
+
* creates a file (every editor save carries the Sidecars, and nav's reconcile
|
|
178
|
+
* would turn [] into the page list), while an empty list over an existing file
|
|
179
|
+
* replaces it wholesale. A written list is reconciled against the index being
|
|
180
|
+
* written, so it never names a page or section that write removed.
|
|
181
|
+
*/
|
|
182
|
+
export function planSidecarWrite<E>(
|
|
183
|
+
def: SidecarDefinition<E>,
|
|
184
|
+
posted: SidecarRead<E> | undefined,
|
|
185
|
+
branch: SidecarFileState,
|
|
186
|
+
index: SiteIndex,
|
|
187
|
+
): SidecarWritePlan {
|
|
188
|
+
if (posted === undefined) return { action: "untouched" };
|
|
189
|
+
const skip = (reason: string): SidecarWritePlan => ({
|
|
190
|
+
action: "skip",
|
|
191
|
+
warning: `${def.path} was left unchanged: ${reason} ${def.words.changes} from this save did not land. Upgrade the site's @drawnagency packages (or repair ${def.path} directly in the repo), then retry.`,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (posted.degraded) {
|
|
195
|
+
// Leaving the file alone is the right action; saying nothing is not — an
|
|
196
|
+
// agent that posted a malformed list would otherwise believe it landed.
|
|
197
|
+
return {
|
|
198
|
+
action: "skip",
|
|
199
|
+
warning: `${def.name} was not ${def.words.valid}, so ${def.path} was left unchanged (it held entries this package version cannot read). Expected ${def.words.shape}.`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (branch.kind === "unreadable") {
|
|
203
|
+
return skip("the current file could not be read to confirm the replacement loses nothing.");
|
|
204
|
+
}
|
|
205
|
+
if (branch.kind === "present") {
|
|
206
|
+
let existing: unknown;
|
|
207
|
+
try {
|
|
208
|
+
existing = JSON.parse(branch.text);
|
|
209
|
+
} catch {
|
|
210
|
+
return skip("the current file is not valid JSON, so this version cannot prove a replacement keeps its entries.");
|
|
211
|
+
}
|
|
212
|
+
if (!def.fileSchema.safeParse(existing).success) {
|
|
213
|
+
return skip(`the current file holds ${def.words.entries} this package version cannot represent (likely written by a newer version).`);
|
|
214
|
+
}
|
|
215
|
+
} else if (posted.entries.length === 0) {
|
|
216
|
+
return { action: "untouched" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const { entries } = def.reconcile(posted.entries, index);
|
|
220
|
+
return { action: "write", path: def.path, content: JSON.stringify({ [def.fileKey]: entries }, null, 2) };
|
|
221
|
+
}
|
package/src/lib/slug.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The one slugifier for page slugs and media names. Its own leaf, with no
|
|
2
|
+
// imports, so the media worker can use it without bundling the schemas
|
|
3
|
+
// lib/pages pulls in.
|
|
4
|
+
export function slugifyPageSlug(input: string): string {
|
|
5
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
6
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DOCUMENT_GIT_MAX_BYTES, DOCUMENT_BUCKET_MAX_BYTES } from "../schemas/documents";
|
|
2
|
+
import { MEDIA_GIT_MAX_BYTES, MEDIA_BUCKET_MAX_BYTES, type MediaConfig } from "../schemas/media";
|
|
3
|
+
|
|
4
|
+
// Where a file's bytes live, decided by its size: committed to the site repo
|
|
5
|
+
// (git), stored in the optional bucket, or refused. Every upload path and the
|
|
6
|
+
// write path's backstop decide this the same way, and every message quotes the
|
|
7
|
+
// limits through formatBytes, so a limit changes in one place.
|
|
8
|
+
|
|
9
|
+
export { DOCUMENT_GIT_MAX_BYTES, DOCUMENT_BUCKET_MAX_BYTES, MEDIA_GIT_MAX_BYTES, MEDIA_BUCKET_MAX_BYTES };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The MCP connector's own document ceiling, half the bucket cap. The binding
|
|
13
|
+
* constraint is memory, not time: a presigned PUT cannot stream, so the whole
|
|
14
|
+
* body is buffered (peak about 2x the file). The browser path has no such
|
|
15
|
+
* limit because those bytes never pass through a function.
|
|
16
|
+
*/
|
|
17
|
+
export const DOCUMENT_CONNECTOR_MAX_BYTES = 104_857_600;
|
|
18
|
+
|
|
19
|
+
export type StorageTier = "git" | "bucket" | "too_large";
|
|
20
|
+
|
|
21
|
+
type MediaLimits = Pick<MediaConfig, "maxFileSize" | "maxAssetSize">;
|
|
22
|
+
|
|
23
|
+
/** Media limits come from the site's `media` config; documents' are fixed. */
|
|
24
|
+
export function tierFor(
|
|
25
|
+
kind: "document" | "media",
|
|
26
|
+
bytes: number,
|
|
27
|
+
media: MediaLimits = { maxFileSize: MEDIA_GIT_MAX_BYTES, maxAssetSize: MEDIA_BUCKET_MAX_BYTES },
|
|
28
|
+
): StorageTier {
|
|
29
|
+
const [gitMax, bucketMax] =
|
|
30
|
+
kind === "document" ? [DOCUMENT_GIT_MAX_BYTES, DOCUMENT_BUCKET_MAX_BYTES] : [media.maxFileSize, media.maxAssetSize];
|
|
31
|
+
if (bytes <= gitMax) return "git";
|
|
32
|
+
return bytes <= bucketMax ? "bucket" : "too_large";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** "2.5 MB", "100 MB" — one decimal at most, in binary megabytes. */
|
|
36
|
+
export function formatBytes(bytes: number): string {
|
|
37
|
+
return `${Math.round((bytes / 1024 / 1024) * 10) / 10} MB`;
|
|
38
|
+
}
|
package/src/lib/tags.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TagEntry } from "../schemas/tags";
|
|
2
2
|
import type { SiteIndex } from "../schemas/site-config";
|
|
3
|
+
import { diagnosticsCollector, type SidecarDiagnostic } from "./sidecar-diagnostics";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Repair the tag sidecar against the current section set.
|
|
@@ -19,6 +20,15 @@ import type { SiteIndex } from "../schemas/site-config";
|
|
|
19
20
|
* React state, where a shared array would let a later edit mutate prior state).
|
|
20
21
|
*/
|
|
21
22
|
export function reconcileTags(entries: TagEntry[], sections: SiteIndex["sections"]): TagEntry[] {
|
|
23
|
+
return reconcileTagsReport(entries, sections).entries;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** reconcileTags plus what the repair changed — what a validator reports. */
|
|
27
|
+
export function reconcileTagsReport(
|
|
28
|
+
entries: TagEntry[],
|
|
29
|
+
sections: SiteIndex["sections"],
|
|
30
|
+
): { entries: TagEntry[]; diagnostics: SidecarDiagnostic[] } {
|
|
31
|
+
const report = diagnosticsCollector();
|
|
22
32
|
const entryList = Array.isArray(entries) ? entries : [];
|
|
23
33
|
const validIds = new Set(sections && typeof sections === "object" ? Object.keys(sections) : []);
|
|
24
34
|
|
|
@@ -26,17 +36,24 @@ export function reconcileTags(entries: TagEntry[], sections: SiteIndex["sections
|
|
|
26
36
|
const result: TagEntry[] = [];
|
|
27
37
|
for (const entry of entryList) {
|
|
28
38
|
if (!entry || typeof entry !== "object") continue;
|
|
29
|
-
if (typeof entry.id !== "string" || entry.id === ""
|
|
39
|
+
if (typeof entry.id !== "string" || entry.id === "") continue;
|
|
40
|
+
if (seenTagIds.has(entry.id)) {
|
|
41
|
+
report.add("duplicate-id", entry.id);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
30
44
|
seenTagIds.add(entry.id);
|
|
31
45
|
|
|
32
46
|
const claimed = Array.isArray(entry.sectionIds) ? entry.sectionIds : [];
|
|
33
47
|
const sectionIds: string[] = [];
|
|
34
48
|
for (const id of claimed) {
|
|
35
|
-
if (typeof id
|
|
49
|
+
if (typeof id !== "string") continue;
|
|
50
|
+
if (!validIds.has(id)) report.add("dangling", id);
|
|
51
|
+
else if (sectionIds.includes(id)) report.add("duplicate-member", id);
|
|
52
|
+
else sectionIds.push(id);
|
|
36
53
|
}
|
|
37
54
|
result.push({ id: entry.id, label: typeof entry.label === "string" ? entry.label : "", sectionIds });
|
|
38
55
|
}
|
|
39
|
-
return result;
|
|
56
|
+
return { entries: result, diagnostics: report.list() };
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
/**
|
package/src/media/queue.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MediaKind } from "./types";
|
|
2
|
+
import type { ImageVariantPlan } from "../lib/image-variant-plan";
|
|
2
3
|
|
|
3
4
|
export interface QueueInput {
|
|
4
5
|
buffer: ArrayBuffer;
|
|
@@ -23,6 +24,7 @@ export interface QueueItem {
|
|
|
23
24
|
posterBlob?: Blob;
|
|
24
25
|
width: number;
|
|
25
26
|
height: number;
|
|
27
|
+
plan?: ImageVariantPlan;
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -116,6 +118,7 @@ export class ProcessingQueue {
|
|
|
116
118
|
posterBlob: msg.posterBlob,
|
|
117
119
|
width: msg.width,
|
|
118
120
|
height: msg.height,
|
|
121
|
+
plan: msg.plan,
|
|
119
122
|
};
|
|
120
123
|
this.options.onEvent({ type: "complete", item: { ...item } });
|
|
121
124
|
// The event above carries the only reference anyone reads. Holding ours
|
package/src/media/utils.ts
CHANGED
package/src/media/worker.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { planImageVariants, type ImageVariantPlan } from "../lib/image-variant-plan";
|
|
2
|
+
|
|
1
3
|
interface ProcessMessage {
|
|
2
4
|
type: "process";
|
|
3
5
|
id: string;
|
|
@@ -24,6 +26,8 @@ interface CompleteMessage {
|
|
|
24
26
|
posterBlob?: Blob;
|
|
25
27
|
width: number;
|
|
26
28
|
height: number;
|
|
29
|
+
/** Static images only — the shared plan the variants were rendered from. */
|
|
30
|
+
plan?: ImageVariantPlan;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
interface ErrorMessage {
|
|
@@ -77,13 +81,17 @@ self.onmessage = async (e: MessageEvent<ProcessMessage>) => {
|
|
|
77
81
|
|
|
78
82
|
post({ type: "progress", id: msg.id, percent: 25 });
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
// Widths, names and paths come from the shared plan (the sharp path uses
|
|
85
|
+
// the same one); this worker only draws. Encoded largest first so the
|
|
86
|
+
// primary blob is the largest variant.
|
|
87
|
+
const plan = planImageVariants({
|
|
88
|
+
hash: msg.hash,
|
|
89
|
+
fileName: msg.originalName,
|
|
90
|
+
width: origWidth,
|
|
91
|
+
height: origHeight,
|
|
92
|
+
media: { sizes: msg.sizes, quality: msg.quality },
|
|
93
|
+
});
|
|
94
|
+
const applicableSizes = plan.variants.map((v) => v.width).reverse();
|
|
87
95
|
|
|
88
96
|
const variants: { width: number; height: number; size: number; blob: Blob }[] = [];
|
|
89
97
|
const totalVariants = applicableSizes.length;
|
|
@@ -97,7 +105,7 @@ self.onmessage = async (e: MessageEvent<ProcessMessage>) => {
|
|
|
97
105
|
const ctx = canvas.getContext("2d")!;
|
|
98
106
|
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
|
|
99
107
|
|
|
100
|
-
const blob = await canvas.convertToBlob({ type: "image/webp", quality:
|
|
108
|
+
const blob = await canvas.convertToBlob({ type: "image/webp", quality: plan.quality / 100 });
|
|
101
109
|
variants.push({ width: targetWidth, height: targetHeight, size: blob.size, blob });
|
|
102
110
|
|
|
103
111
|
const percent = 25 + Math.round(((i + 1) / totalVariants) * 75);
|
|
@@ -114,6 +122,7 @@ self.onmessage = async (e: MessageEvent<ProcessMessage>) => {
|
|
|
114
122
|
primaryBlob,
|
|
115
123
|
width: origWidth,
|
|
116
124
|
height: origHeight,
|
|
125
|
+
plan,
|
|
117
126
|
});
|
|
118
127
|
} catch (err) {
|
|
119
128
|
const message = err instanceof Error ? err.message : "Processing failed";
|
package/src/schemas/media.ts
CHANGED
|
@@ -55,13 +55,18 @@ export const ImageManifestSchema = z.object({
|
|
|
55
55
|
|
|
56
56
|
export type MediaManifest = z.infer<typeof ImageManifestSchema>;
|
|
57
57
|
|
|
58
|
+
/** Default git-tier cap for media (bytes); a site may override it as `media.maxFileSize`. */
|
|
59
|
+
export const MEDIA_GIT_MAX_BYTES = 5_242_880;
|
|
60
|
+
/** Default bucket-tier cap for media (bytes); a site may override it as `media.maxAssetSize`. */
|
|
61
|
+
export const MEDIA_BUCKET_MAX_BYTES = 209_715_200;
|
|
62
|
+
|
|
58
63
|
export const MediaConfigSchema = z.object({
|
|
59
64
|
sizes: z.array(z.number()).default([640, 1080, 1920]),
|
|
60
|
-
maxFileSize: z.number().default(
|
|
65
|
+
maxFileSize: z.number().default(MEDIA_GIT_MAX_BYTES),
|
|
61
66
|
quality: z.number().min(1).max(100).default(85),
|
|
62
67
|
// Per-file cap for bucket-backed video uploads (bytes). The git-path cap is
|
|
63
68
|
// maxFileSize; anything between the two routes to the AssetStore.
|
|
64
|
-
maxAssetSize: z.number().default(
|
|
69
|
+
maxAssetSize: z.number().default(MEDIA_BUCKET_MAX_BYTES),
|
|
65
70
|
});
|
|
66
71
|
|
|
67
72
|
export type MediaConfig = z.infer<typeof MediaConfigSchema>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IDEMPOTENCY_TRAILER,
|
|
3
|
+
NothingToPublishError,
|
|
4
|
+
StorageConflictError,
|
|
5
|
+
type FileWrite,
|
|
6
|
+
type StorageProvider,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
// An in-memory StorageProvider with real commit history: a `main` branch, an
|
|
10
|
+
// optional draft branch, and commits addressable by sha. It exists so the write
|
|
11
|
+
// path can be tested end to end without mocking it, which is only worth
|
|
12
|
+
// anything if it behaves like the GitHub adapter — the storage contract suite
|
|
13
|
+
// runs the same cases against both. Anything that differs from GitHub here is a
|
|
14
|
+
// bug in this file.
|
|
15
|
+
|
|
16
|
+
type Tree = Map<string, Uint8Array>;
|
|
17
|
+
type Commit = { tree: Tree; parent: string | null; message: string };
|
|
18
|
+
type Files = Record<string, string | Uint8Array | null>;
|
|
19
|
+
|
|
20
|
+
// Mirrors promoteDraft in the GitHub adapter: only these subtrees publish;
|
|
21
|
+
// everything else on main is inherited from main.
|
|
22
|
+
const PUBLISHED_PREFIXES = ["src/content/", "assets/"];
|
|
23
|
+
|
|
24
|
+
export type MemoryStorage = StorageProvider & {
|
|
25
|
+
/**
|
|
26
|
+
* Commit straight to a branch as another writer would — a concurrent editor
|
|
27
|
+
* save on the draft (created from main if absent), or a merge onto main.
|
|
28
|
+
* Returns the new commit's sha.
|
|
29
|
+
*/
|
|
30
|
+
commit(branch: "main" | "draft", files: Files, message?: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Run `fn` once at the start of the next `writeFiles`, before its
|
|
33
|
+
* `baseVersion` check: a writer landing between a caller's read and its write.
|
|
34
|
+
*/
|
|
35
|
+
beforeNextWrite(fn: () => void): void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function createMemoryStorage(seed: { main?: Files; draft?: Files } = {}): MemoryStorage {
|
|
39
|
+
const commits = new Map<string, Commit>();
|
|
40
|
+
let counter = 0;
|
|
41
|
+
let mainHead: string;
|
|
42
|
+
let draftHead: string | null = null;
|
|
43
|
+
let pendingHook: (() => void) | null = null;
|
|
44
|
+
|
|
45
|
+
function newCommit(tree: Tree, parent: string | null, message: string): string {
|
|
46
|
+
counter += 1;
|
|
47
|
+
const sha = counter.toString(16).padStart(40, "0");
|
|
48
|
+
commits.set(sha, { tree, parent, message });
|
|
49
|
+
return sha;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function applyFiles(base: Tree, files: Files): Tree {
|
|
53
|
+
const tree = new Map(base);
|
|
54
|
+
for (const [path, content] of Object.entries(files)) {
|
|
55
|
+
if (content === null) tree.delete(path);
|
|
56
|
+
else tree.set(path, toBytes(content));
|
|
57
|
+
}
|
|
58
|
+
return tree;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function commitTo(branch: "main" | "draft", files: Files, message = "commit"): string {
|
|
62
|
+
if (branch === "main") {
|
|
63
|
+
mainHead = newCommit(applyFiles(commits.get(mainHead)!.tree, files), mainHead, message);
|
|
64
|
+
return mainHead;
|
|
65
|
+
}
|
|
66
|
+
const parent = draftHead ?? mainHead;
|
|
67
|
+
draftHead = newCommit(applyFiles(commits.get(parent)!.tree, files), parent, message);
|
|
68
|
+
return draftHead;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Unknown branch or sha reads as absent, as GitHub's 404 does.
|
|
72
|
+
function treeFor(options?: { draft?: boolean; at?: string }): Tree | null {
|
|
73
|
+
if (options?.at !== undefined) return commits.get(options.at)?.tree ?? null;
|
|
74
|
+
if (options?.draft) return draftHead === null ? null : commits.get(draftHead)!.tree;
|
|
75
|
+
return commits.get(mainHead)!.tree;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function checkBase(options?: { baseVersion?: string | null }): void {
|
|
79
|
+
if (options && "baseVersion" in options) {
|
|
80
|
+
const expected = options.baseVersion ?? null;
|
|
81
|
+
if (expected !== draftHead) throw new StorageConflictError(draftHead);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
mainHead = newCommit(applyFiles(new Map(), seed.main ?? {}), null, "initial");
|
|
86
|
+
if (seed.draft) commitTo("draft", seed.draft, "seed draft");
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
commit: commitTo,
|
|
90
|
+
|
|
91
|
+
beforeNextWrite(fn) {
|
|
92
|
+
pendingHook = fn;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async writeFiles(files: FileWrite[], message: string, options?: { baseVersion?: string | null }) {
|
|
96
|
+
const hook = pendingHook;
|
|
97
|
+
pendingHook = null;
|
|
98
|
+
hook?.();
|
|
99
|
+
|
|
100
|
+
checkBase(options);
|
|
101
|
+
if (files.length === 0) return { version: draftHead ?? mainHead };
|
|
102
|
+
|
|
103
|
+
const parentTree = commits.get(draftHead ?? mainHead)!.tree;
|
|
104
|
+
const changes: Files = {};
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
// GitHub's createTree rejects deleting a path its base tree does not hold.
|
|
107
|
+
if (file.content === null && !parentTree.has(file.path)) {
|
|
108
|
+
throw new Error(`Cannot delete ${file.path}: it is not on the branch`);
|
|
109
|
+
}
|
|
110
|
+
changes[file.path] =
|
|
111
|
+
file.content === null || file.content instanceof Uint8Array || file.encoding !== "base64"
|
|
112
|
+
? file.content
|
|
113
|
+
: fromBase64(file.content);
|
|
114
|
+
}
|
|
115
|
+
return { version: commitTo("draft", changes, message) };
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async readFile(path, options) {
|
|
119
|
+
return treeFor(options)?.get(path) ?? null;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async listDirectory(path, options) {
|
|
123
|
+
const tree = treeFor(options);
|
|
124
|
+
if (!tree) return [];
|
|
125
|
+
const prefix = path.endsWith("/") ? path : `${path}/`;
|
|
126
|
+
const names: string[] = [];
|
|
127
|
+
for (const key of tree.keys()) {
|
|
128
|
+
if (!key.startsWith(prefix)) continue;
|
|
129
|
+
const rest = key.slice(prefix.length);
|
|
130
|
+
if (!rest.includes("/")) names.push(rest);
|
|
131
|
+
}
|
|
132
|
+
return names;
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async getVersion() {
|
|
136
|
+
return mainHead;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async getDraftVersion() {
|
|
140
|
+
return draftHead;
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async promoteDraft() {
|
|
144
|
+
if (draftHead === null) throw new NothingToPublishError();
|
|
145
|
+
const draft = commits.get(draftHead)!.tree;
|
|
146
|
+
const main = commits.get(mainHead)!.tree;
|
|
147
|
+
const published = (p: string) => PUBLISHED_PREFIXES.some((prefix) => p.startsWith(prefix));
|
|
148
|
+
if (![...draft.keys(), ...main.keys()].some(published)) {
|
|
149
|
+
throw new Error("No editor-managed content found on the saved branch — refusing to publish.");
|
|
150
|
+
}
|
|
151
|
+
const tree = new Map([...main].filter(([p]) => !published(p)));
|
|
152
|
+
for (const [p, bytes] of draft) if (published(p)) tree.set(p, bytes);
|
|
153
|
+
mainHead = newCommit(tree, mainHead, "Publish content via Brand Portal");
|
|
154
|
+
draftHead = null;
|
|
155
|
+
return { version: mainHead };
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async findCommitByIdempotencyKey(key, options) {
|
|
159
|
+
const limit = Math.min(Math.max(options?.limit ?? 30, 1), 100);
|
|
160
|
+
const trailer = `${IDEMPOTENCY_TRAILER}: ${key}`;
|
|
161
|
+
let sha = draftHead;
|
|
162
|
+
for (let seen = 0; sha !== null && seen < limit; seen += 1) {
|
|
163
|
+
const commit = commits.get(sha)!;
|
|
164
|
+
if (commit.message.split("\n").some((line) => line.trim() === trailer)) return sha;
|
|
165
|
+
sha = commit.parent;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function toBytes(content: string | Uint8Array): Uint8Array {
|
|
173
|
+
return typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function fromBase64(content: string): Uint8Array {
|
|
177
|
+
return Uint8Array.from(atob(content), (c) => c.charCodeAt(0));
|
|
178
|
+
}
|
package/src/storage/types.ts
CHANGED
|
@@ -34,16 +34,29 @@ export interface StorageProvider {
|
|
|
34
34
|
options?: { baseVersion?: string | null },
|
|
35
35
|
): Promise<{ version: string }>;
|
|
36
36
|
|
|
37
|
+
// `draft` reads the draft branch by name: null when there is no draft — a
|
|
38
|
+
// caller that wants "draft, else main" falls back itself. `at` reads as of a
|
|
39
|
+
// commit and wins over `draft`. Two reads by branch name can see two different
|
|
40
|
+
// commits moments after a write, so a read that must agree with another read
|
|
41
|
+
// (an index and the listing it is diffed against) pins both to one commit.
|
|
37
42
|
readFile(
|
|
38
43
|
path: string,
|
|
39
|
-
options?: { draft?: boolean },
|
|
44
|
+
options?: { draft?: boolean; at?: string },
|
|
40
45
|
): Promise<Uint8Array | null>;
|
|
41
46
|
|
|
42
47
|
promoteDraft(): Promise<{ version: string }>;
|
|
43
48
|
|
|
49
|
+
/** The published branch's head. Never a valid `baseVersion`. */
|
|
44
50
|
getVersion(): Promise<string>;
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The draft branch's head, or null when no draft exists — exactly the value
|
|
54
|
+
* `writeFiles` accepts as `baseVersion`.
|
|
55
|
+
*/
|
|
56
|
+
getDraftVersion(): Promise<string | null>;
|
|
57
|
+
|
|
58
|
+
/** File basenames directly under `path` (no subdirectories); `[]` when absent. Same options as `readFile`. */
|
|
59
|
+
listDirectory(path: string, options?: { draft?: boolean; at?: string }): Promise<string[]>;
|
|
47
60
|
|
|
48
61
|
/**
|
|
49
62
|
* Find a recent commit on the draft branch whose message carries the given
|
|
File without changes
|