@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.
Files changed (103) hide show
  1. package/dist/{chunk-OAGJBCSH.js → chunk-4WQ36ZP7.js} +19 -3
  2. package/dist/{chunk-PTY2RG2K.js → chunk-67P34WNW.js} +75 -130
  3. package/dist/chunk-APIJPTFG.js +64 -0
  4. package/dist/chunk-CDBURDPX.js +90 -0
  5. package/dist/chunk-DFKTF3CH.js +0 -0
  6. package/dist/chunk-DJABADOO.js +131 -0
  7. package/dist/{chunk-IP2SEBAJ.js → chunk-DJOGPSRU.js} +31 -72
  8. package/dist/{chunk-N3G7DOU3.js → chunk-FHTKSELX.js} +6 -6
  9. package/dist/chunk-GNF6KFQ7.js +18 -0
  10. package/dist/{chunk-QHMKIODQ.js → chunk-HHNPSKPT.js} +2 -89
  11. package/dist/{chunk-PZPGJZRG.js → chunk-JXATNKLV.js} +5 -5
  12. package/dist/{chunk-PLANHXS7.js → chunk-NX7TFHA4.js} +9 -9
  13. package/dist/{chunk-PNSCDBU7.js → chunk-O37AV27P.js} +6 -49
  14. package/dist/{chunk-5KSAS4VY.js → chunk-RIITGTV3.js} +1 -1
  15. package/dist/chunk-S7QRHUWO.js +8 -0
  16. package/dist/{chunk-7SAAJVDE.js → chunk-T26HAKW3.js} +37 -35
  17. package/dist/chunk-VY3IFGE3.js +70 -0
  18. package/dist/{chunk-VUYCRBJK.js → chunk-WYOFQHKI.js} +6 -4
  19. package/dist/{chunk-Y5QECJX7.js → chunk-YV3YSZEB.js} +5 -5
  20. package/dist/collab/rebase.d.ts.map +1 -1
  21. package/dist/collab/rebase.js +15 -15
  22. package/dist/components/editor/index.js +13 -13
  23. package/dist/components/primitives/index.js +16 -11
  24. package/dist/components/sections/brand-guide.js +13 -8
  25. package/dist/components/sections/register-schemas.js +20 -15
  26. package/dist/components/sections/register.js +20 -15
  27. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  28. package/dist/deck-builder/index.js +9 -9
  29. package/dist/hooks/useEditorPublish.d.ts +4 -1
  30. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  31. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  32. package/dist/index.js +85 -56
  33. package/dist/lib/dexie.d.ts +7 -1
  34. package/dist/lib/dexie.d.ts.map +1 -1
  35. package/dist/lib/dexie.js +3 -2
  36. package/dist/lib/image-variant-plan.d.ts +42 -0
  37. package/dist/lib/image-variant-plan.d.ts.map +1 -0
  38. package/dist/lib/image-variant-plan.js +53 -0
  39. package/dist/lib/index-ops.d.ts.map +1 -1
  40. package/dist/lib/index-ops.js +20 -16
  41. package/dist/lib/index.d.ts +2 -1
  42. package/dist/lib/index.d.ts.map +1 -1
  43. package/dist/lib/index.js +43 -22
  44. package/dist/lib/loader.d.ts +16 -17
  45. package/dist/lib/loader.d.ts.map +1 -1
  46. package/dist/lib/media-ops.d.ts +73 -0
  47. package/dist/lib/media-ops.d.ts.map +1 -0
  48. package/dist/lib/media-ops.js +60 -0
  49. package/dist/lib/nav-tree.d.ts +6 -0
  50. package/dist/lib/nav-tree.d.ts.map +1 -1
  51. package/dist/lib/pages.d.ts +2 -1
  52. package/dist/lib/pages.d.ts.map +1 -1
  53. package/dist/lib/sidecar-diagnostics.d.ts +20 -0
  54. package/dist/lib/sidecar-diagnostics.d.ts.map +1 -0
  55. package/dist/lib/sidecars.d.ts +118 -0
  56. package/dist/lib/sidecars.d.ts.map +1 -0
  57. package/dist/lib/sidecars.js +23 -0
  58. package/dist/lib/slug.d.ts +2 -0
  59. package/dist/lib/slug.d.ts.map +1 -0
  60. package/dist/lib/storage-tier.d.ts +17 -0
  61. package/dist/lib/storage-tier.d.ts.map +1 -0
  62. package/dist/lib/storage-tier.js +29 -0
  63. package/dist/lib/tags.d.ts +6 -0
  64. package/dist/lib/tags.d.ts.map +1 -1
  65. package/dist/media/index.js +15 -15
  66. package/dist/media/queue.d.ts +2 -0
  67. package/dist/media/queue.d.ts.map +1 -1
  68. package/dist/media/worker.d.ts +1 -35
  69. package/dist/media/worker.d.ts.map +1 -1
  70. package/dist/schemas/index.js +18 -12
  71. package/dist/schemas/media.d.ts +4 -0
  72. package/dist/schemas/media.d.ts.map +1 -1
  73. package/dist/storage/index.js +2 -1
  74. package/dist/storage/memory.d.ts +21 -0
  75. package/dist/storage/memory.d.ts.map +1 -0
  76. package/dist/storage/memory.js +129 -0
  77. package/dist/storage/types.d.ts +9 -0
  78. package/dist/storage/types.d.ts.map +1 -1
  79. package/package.json +21 -1
  80. package/src/collab/rebase.ts +12 -16
  81. package/src/components/shell/EditorShell.tsx +7 -4
  82. package/src/hooks/useEditorPublish.ts +9 -4
  83. package/src/hooks/useMediaPipeline.ts +29 -23
  84. package/src/lib/dexie.ts +9 -1
  85. package/src/lib/image-variant-plan.ts +92 -0
  86. package/src/lib/index-ops.ts +9 -18
  87. package/src/lib/index.ts +15 -0
  88. package/src/lib/loader.ts +18 -77
  89. package/src/lib/media-ops.ts +112 -0
  90. package/src/lib/nav-tree.ts +33 -4
  91. package/src/lib/pages.ts +2 -3
  92. package/src/lib/sidecar-diagnostics.ts +29 -0
  93. package/src/lib/sidecars.ts +221 -0
  94. package/src/lib/slug.ts +6 -0
  95. package/src/lib/storage-tier.ts +38 -0
  96. package/src/lib/tags.ts +20 -3
  97. package/src/media/queue.ts +3 -0
  98. package/src/media/utils.ts +1 -1
  99. package/src/media/worker.ts +17 -8
  100. package/src/schemas/media.ts +7 -2
  101. package/src/storage/memory.ts +178 -0
  102. package/src/storage/types.ts +15 -2
  103. /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
+ }
@@ -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 === "" || seenTagIds.has(entry.id)) continue;
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 === "string" && validIds.has(id) && !sectionIds.includes(id)) sectionIds.push(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
  /**
@@ -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
@@ -1,4 +1,4 @@
1
- import { slugifyPageSlug } from "../lib/pages";
1
+ import { slugifyPageSlug } from "../lib/slug";
2
2
  import type { MediaItem } from "./types";
3
3
 
4
4
  export async function hashFileBuffer(buffer: ArrayBuffer): Promise<string> {
@@ -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
- const applicableSizes = msg.sizes
81
- .filter((s) => s <= origWidth)
82
- .sort((a, b) => b - a);
83
-
84
- if (applicableSizes.length === 0) {
85
- applicableSizes.push(origWidth);
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: msg.quality / 100 });
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";
@@ -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(5242880),
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(209_715_200),
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
+ }
@@ -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
- listDirectory(path: string, options?: { draft?: boolean }): Promise<string[]>;
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