@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.
Files changed (119) hide show
  1. package/dist/chunk-26V4YNOR.js +108 -0
  2. package/dist/{chunk-OAGJBCSH.js → chunk-4WQ36ZP7.js} +19 -3
  3. package/dist/{chunk-PTY2RG2K.js → chunk-67P34WNW.js} +75 -130
  4. package/dist/chunk-APIJPTFG.js +64 -0
  5. package/dist/chunk-CDBURDPX.js +90 -0
  6. package/dist/{chunk-PLANHXS7.js → chunk-CEFFP3CH.js} +2 -2
  7. package/dist/chunk-DFKTF3CH.js +0 -0
  8. package/dist/chunk-DJABADOO.js +131 -0
  9. package/dist/{chunk-IP2SEBAJ.js → chunk-DJOGPSRU.js} +31 -72
  10. package/dist/chunk-DKZCHCV3.js +64 -0
  11. package/dist/chunk-FGQXHUAE.js +39 -0
  12. package/dist/chunk-GNF6KFQ7.js +18 -0
  13. package/dist/{chunk-QHMKIODQ.js → chunk-HHNPSKPT.js} +2 -89
  14. package/dist/{chunk-PZPGJZRG.js → chunk-HUOW3ZV2.js} +3 -3
  15. package/dist/{chunk-Y5QECJX7.js → chunk-L7WTERU5.js} +2 -2
  16. package/dist/{chunk-N3G7DOU3.js → chunk-LNQUUC2S.js} +2 -2
  17. package/dist/{chunk-7SAAJVDE.js → chunk-MSBHMY3C.js} +17 -15
  18. package/dist/{chunk-PNSCDBU7.js → chunk-O37AV27P.js} +6 -49
  19. package/dist/{chunk-5KSAS4VY.js → chunk-RIITGTV3.js} +1 -1
  20. package/dist/chunk-S7QRHUWO.js +8 -0
  21. package/dist/chunk-VY3IFGE3.js +70 -0
  22. package/dist/{chunk-VUYCRBJK.js → chunk-W72BPGR3.js} +7 -5
  23. package/dist/collab/rebase.d.ts.map +1 -1
  24. package/dist/collab/rebase.js +15 -15
  25. package/dist/components/editor/index.js +8 -8
  26. package/dist/components/primitives/index.js +14 -9
  27. package/dist/components/sections/brand-guide.js +12 -7
  28. package/dist/components/sections/register-schemas.js +17 -12
  29. package/dist/components/sections/register.js +17 -12
  30. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  31. package/dist/deck-builder/index.js +6 -6
  32. package/dist/hooks/useEditorPublish.d.ts +4 -1
  33. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  34. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  35. package/dist/index.js +78 -49
  36. package/dist/lib/dexie.d.ts +7 -1
  37. package/dist/lib/dexie.d.ts.map +1 -1
  38. package/dist/lib/dexie.js +3 -2
  39. package/dist/lib/document-refs.d.ts +8 -0
  40. package/dist/lib/document-refs.d.ts.map +1 -1
  41. package/dist/lib/document-refs.js +14 -0
  42. package/dist/lib/image-variant-plan.d.ts +42 -0
  43. package/dist/lib/image-variant-plan.d.ts.map +1 -0
  44. package/dist/lib/image-variant-plan.js +53 -0
  45. package/dist/lib/index-ops.d.ts.map +1 -1
  46. package/dist/lib/index-ops.js +20 -16
  47. package/dist/lib/index.d.ts +2 -1
  48. package/dist/lib/index.d.ts.map +1 -1
  49. package/dist/lib/index.js +36 -15
  50. package/dist/lib/loader.d.ts +16 -17
  51. package/dist/lib/loader.d.ts.map +1 -1
  52. package/dist/lib/media-ops.d.ts +73 -0
  53. package/dist/lib/media-ops.d.ts.map +1 -0
  54. package/dist/lib/media-ops.js +60 -0
  55. package/dist/lib/nav-tree.d.ts +6 -0
  56. package/dist/lib/nav-tree.d.ts.map +1 -1
  57. package/dist/lib/pages.d.ts +2 -1
  58. package/dist/lib/pages.d.ts.map +1 -1
  59. package/dist/lib/reference-validation.js +4 -33
  60. package/dist/lib/section-check.d.ts +49 -0
  61. package/dist/lib/section-check.d.ts.map +1 -0
  62. package/dist/lib/section-check.js +14 -0
  63. package/dist/lib/sidecar-diagnostics.d.ts +20 -0
  64. package/dist/lib/sidecar-diagnostics.d.ts.map +1 -0
  65. package/dist/lib/sidecars.d.ts +118 -0
  66. package/dist/lib/sidecars.d.ts.map +1 -0
  67. package/dist/lib/sidecars.js +23 -0
  68. package/dist/lib/site-validation.d.ts +123 -0
  69. package/dist/lib/site-validation.d.ts.map +1 -0
  70. package/dist/lib/site-validation.js +230 -0
  71. package/dist/lib/slug.d.ts +2 -0
  72. package/dist/lib/slug.d.ts.map +1 -0
  73. package/dist/lib/storage-tier.d.ts +17 -0
  74. package/dist/lib/storage-tier.d.ts.map +1 -0
  75. package/dist/lib/storage-tier.js +29 -0
  76. package/dist/lib/tags.d.ts +6 -0
  77. package/dist/lib/tags.d.ts.map +1 -1
  78. package/dist/media/index.js +15 -15
  79. package/dist/media/queue.d.ts +2 -0
  80. package/dist/media/queue.d.ts.map +1 -1
  81. package/dist/media/worker.d.ts +1 -35
  82. package/dist/media/worker.d.ts.map +1 -1
  83. package/dist/schemas/index.js +18 -12
  84. package/dist/schemas/media.d.ts +4 -0
  85. package/dist/schemas/media.d.ts.map +1 -1
  86. package/dist/storage/index.js +2 -1
  87. package/dist/storage/memory.d.ts +21 -0
  88. package/dist/storage/memory.d.ts.map +1 -0
  89. package/dist/storage/memory.js +129 -0
  90. package/dist/storage/types.d.ts +9 -0
  91. package/dist/storage/types.d.ts.map +1 -1
  92. package/package.json +34 -1
  93. package/src/collab/rebase.ts +12 -16
  94. package/src/components/shell/EditorShell.tsx +7 -4
  95. package/src/hooks/useEditorPublish.ts +9 -4
  96. package/src/hooks/useMediaPipeline.ts +29 -23
  97. package/src/lib/dexie.ts +9 -1
  98. package/src/lib/document-refs.ts +30 -0
  99. package/src/lib/image-variant-plan.ts +92 -0
  100. package/src/lib/index-ops.ts +9 -18
  101. package/src/lib/index.ts +15 -0
  102. package/src/lib/loader.ts +18 -77
  103. package/src/lib/media-ops.ts +112 -0
  104. package/src/lib/nav-tree.ts +33 -4
  105. package/src/lib/pages.ts +2 -3
  106. package/src/lib/section-check.ts +166 -0
  107. package/src/lib/sidecar-diagnostics.ts +29 -0
  108. package/src/lib/sidecars.ts +221 -0
  109. package/src/lib/site-validation.ts +269 -0
  110. package/src/lib/slug.ts +6 -0
  111. package/src/lib/storage-tier.ts +38 -0
  112. package/src/lib/tags.ts +20 -3
  113. package/src/media/queue.ts +3 -0
  114. package/src/media/utils.ts +1 -1
  115. package/src/media/worker.ts +17 -8
  116. package/src/schemas/media.ts +7 -2
  117. package/src/storage/memory.ts +178 -0
  118. package/src/storage/types.ts +15 -2
  119. /package/dist/{chunk-45FA7Z55.js → chunk-UUUSJDIG.js} +0 -0
@@ -0,0 +1,21 @@
1
+ import { type StorageProvider } from "./types";
2
+ type Files = Record<string, string | Uint8Array | null>;
3
+ export type MemoryStorage = StorageProvider & {
4
+ /**
5
+ * Commit straight to a branch as another writer would — a concurrent editor
6
+ * save on the draft (created from main if absent), or a merge onto main.
7
+ * Returns the new commit's sha.
8
+ */
9
+ commit(branch: "main" | "draft", files: Files, message?: string): string;
10
+ /**
11
+ * Run `fn` once at the start of the next `writeFiles`, before its
12
+ * `baseVersion` check: a writer landing between a caller's read and its write.
13
+ */
14
+ beforeNextWrite(fn: () => void): void;
15
+ };
16
+ export declare function createMemoryStorage(seed?: {
17
+ main?: Files;
18
+ draft?: Files;
19
+ }): MemoryStorage;
20
+ export {};
21
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/storage/memory.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,eAAe,EACrB,MAAM,SAAS,CAAC;AAWjB,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAAC,CAAC;AAMxD,MAAM,MAAM,aAAa,GAAG,eAAe,GAAG;IAC5C;;;;OAIG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzE;;;OAGG;IACH,eAAe,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;CACvC,CAAC;AAEF,wBAAgB,mBAAmB,CAAC,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,KAAK,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAO,GAAG,aAAa,CAoI7F"}
@@ -0,0 +1,129 @@
1
+ import {
2
+ IDEMPOTENCY_TRAILER,
3
+ NothingToPublishError,
4
+ StorageConflictError
5
+ } from "../chunk-UUUSJDIG.js";
6
+ import "../chunk-JSBRDJBE.js";
7
+
8
+ // src/storage/memory.ts
9
+ var PUBLISHED_PREFIXES = ["src/content/", "assets/"];
10
+ function createMemoryStorage(seed = {}) {
11
+ const commits = /* @__PURE__ */ new Map();
12
+ let counter = 0;
13
+ let mainHead;
14
+ let draftHead = null;
15
+ let pendingHook = null;
16
+ function newCommit(tree, parent, message) {
17
+ counter += 1;
18
+ const sha = counter.toString(16).padStart(40, "0");
19
+ commits.set(sha, { tree, parent, message });
20
+ return sha;
21
+ }
22
+ function applyFiles(base, files) {
23
+ const tree = new Map(base);
24
+ for (const [path, content] of Object.entries(files)) {
25
+ if (content === null) tree.delete(path);
26
+ else tree.set(path, toBytes(content));
27
+ }
28
+ return tree;
29
+ }
30
+ function commitTo(branch, files, message = "commit") {
31
+ if (branch === "main") {
32
+ mainHead = newCommit(applyFiles(commits.get(mainHead).tree, files), mainHead, message);
33
+ return mainHead;
34
+ }
35
+ const parent = draftHead ?? mainHead;
36
+ draftHead = newCommit(applyFiles(commits.get(parent).tree, files), parent, message);
37
+ return draftHead;
38
+ }
39
+ function treeFor(options) {
40
+ if (options?.at !== void 0) return commits.get(options.at)?.tree ?? null;
41
+ if (options?.draft) return draftHead === null ? null : commits.get(draftHead).tree;
42
+ return commits.get(mainHead).tree;
43
+ }
44
+ function checkBase(options) {
45
+ if (options && "baseVersion" in options) {
46
+ const expected = options.baseVersion ?? null;
47
+ if (expected !== draftHead) throw new StorageConflictError(draftHead);
48
+ }
49
+ }
50
+ mainHead = newCommit(applyFiles(/* @__PURE__ */ new Map(), seed.main ?? {}), null, "initial");
51
+ if (seed.draft) commitTo("draft", seed.draft, "seed draft");
52
+ return {
53
+ commit: commitTo,
54
+ beforeNextWrite(fn) {
55
+ pendingHook = fn;
56
+ },
57
+ async writeFiles(files, message, options) {
58
+ const hook = pendingHook;
59
+ pendingHook = null;
60
+ hook?.();
61
+ checkBase(options);
62
+ if (files.length === 0) return { version: draftHead ?? mainHead };
63
+ const parentTree = commits.get(draftHead ?? mainHead).tree;
64
+ const changes = {};
65
+ for (const file of files) {
66
+ if (file.content === null && !parentTree.has(file.path)) {
67
+ throw new Error(`Cannot delete ${file.path}: it is not on the branch`);
68
+ }
69
+ changes[file.path] = file.content === null || file.content instanceof Uint8Array || file.encoding !== "base64" ? file.content : fromBase64(file.content);
70
+ }
71
+ return { version: commitTo("draft", changes, message) };
72
+ },
73
+ async readFile(path, options) {
74
+ return treeFor(options)?.get(path) ?? null;
75
+ },
76
+ async listDirectory(path, options) {
77
+ const tree = treeFor(options);
78
+ if (!tree) return [];
79
+ const prefix = path.endsWith("/") ? path : `${path}/`;
80
+ const names = [];
81
+ for (const key of tree.keys()) {
82
+ if (!key.startsWith(prefix)) continue;
83
+ const rest = key.slice(prefix.length);
84
+ if (!rest.includes("/")) names.push(rest);
85
+ }
86
+ return names;
87
+ },
88
+ async getVersion() {
89
+ return mainHead;
90
+ },
91
+ async getDraftVersion() {
92
+ return draftHead;
93
+ },
94
+ async promoteDraft() {
95
+ if (draftHead === null) throw new NothingToPublishError();
96
+ const draft = commits.get(draftHead).tree;
97
+ const main = commits.get(mainHead).tree;
98
+ const published = (p) => PUBLISHED_PREFIXES.some((prefix) => p.startsWith(prefix));
99
+ if (![...draft.keys(), ...main.keys()].some(published)) {
100
+ throw new Error("No editor-managed content found on the saved branch \u2014 refusing to publish.");
101
+ }
102
+ const tree = new Map([...main].filter(([p]) => !published(p)));
103
+ for (const [p, bytes] of draft) if (published(p)) tree.set(p, bytes);
104
+ mainHead = newCommit(tree, mainHead, "Publish content via Brand Portal");
105
+ draftHead = null;
106
+ return { version: mainHead };
107
+ },
108
+ async findCommitByIdempotencyKey(key, options) {
109
+ const limit = Math.min(Math.max(options?.limit ?? 30, 1), 100);
110
+ const trailer = `${IDEMPOTENCY_TRAILER}: ${key}`;
111
+ let sha = draftHead;
112
+ for (let seen = 0; sha !== null && seen < limit; seen += 1) {
113
+ const commit = commits.get(sha);
114
+ if (commit.message.split("\n").some((line) => line.trim() === trailer)) return sha;
115
+ sha = commit.parent;
116
+ }
117
+ return null;
118
+ }
119
+ };
120
+ }
121
+ function toBytes(content) {
122
+ return typeof content === "string" ? new TextEncoder().encode(content) : content;
123
+ }
124
+ function fromBase64(content) {
125
+ return Uint8Array.from(atob(content), (c) => c.charCodeAt(0));
126
+ }
127
+ export {
128
+ createMemoryStorage
129
+ };
@@ -25,13 +25,22 @@ export interface StorageProvider {
25
25
  }>;
26
26
  readFile(path: string, options?: {
27
27
  draft?: boolean;
28
+ at?: string;
28
29
  }): Promise<Uint8Array | null>;
29
30
  promoteDraft(): Promise<{
30
31
  version: string;
31
32
  }>;
33
+ /** The published branch's head. Never a valid `baseVersion`. */
32
34
  getVersion(): Promise<string>;
35
+ /**
36
+ * The draft branch's head, or null when no draft exists — exactly the value
37
+ * `writeFiles` accepts as `baseVersion`.
38
+ */
39
+ getDraftVersion(): Promise<string | null>;
40
+ /** File basenames directly under `path` (no subdirectories); `[]` when absent. Same options as `readFile`. */
33
41
  listDirectory(path: string, options?: {
34
42
  draft?: boolean;
43
+ at?: string;
35
44
  }): Promise<string[]>;
36
45
  /**
37
46
  * Find a recent commit on the draft branch whose message carries the given
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.114",
3
+ "version": "0.1.116",
4
4
  "description": "Browser-safe foundation of the Brand Portal framework: Zod schemas, section components, editor shell, hooks, pure libs, collab contract, document engine.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,6 +81,22 @@
81
81
  "types": "./dist/lib/registry.d.ts",
82
82
  "default": "./dist/lib/registry.js"
83
83
  },
84
+ "./lib/storage-tier": {
85
+ "types": "./dist/lib/storage-tier.d.ts",
86
+ "default": "./dist/lib/storage-tier.js"
87
+ },
88
+ "./lib/image-variant-plan": {
89
+ "types": "./dist/lib/image-variant-plan.d.ts",
90
+ "default": "./dist/lib/image-variant-plan.js"
91
+ },
92
+ "./lib/media-ops": {
93
+ "types": "./dist/lib/media-ops.d.ts",
94
+ "default": "./dist/lib/media-ops.js"
95
+ },
96
+ "./lib/sidecars": {
97
+ "types": "./dist/lib/sidecars.d.ts",
98
+ "default": "./dist/lib/sidecars.js"
99
+ },
84
100
  "./lib/index-ops": {
85
101
  "types": "./dist/lib/index-ops.d.ts",
86
102
  "default": "./dist/lib/index-ops.js"
@@ -89,6 +105,18 @@
89
105
  "types": "./dist/lib/reference-validation.d.ts",
90
106
  "default": "./dist/lib/reference-validation.js"
91
107
  },
108
+ "./lib/section-check": {
109
+ "types": "./dist/lib/section-check.d.ts",
110
+ "default": "./dist/lib/section-check.js"
111
+ },
112
+ "./lib/site-validation": {
113
+ "types": "./dist/lib/site-validation.d.ts",
114
+ "default": "./dist/lib/site-validation.js"
115
+ },
116
+ "./lib/document-refs": {
117
+ "types": "./dist/lib/document-refs.d.ts",
118
+ "default": "./dist/lib/document-refs.js"
119
+ },
92
120
  "./lib/deck-access": {
93
121
  "types": "./dist/lib/deck-access.d.ts",
94
122
  "default": "./dist/lib/deck-access.js"
@@ -165,6 +193,10 @@
165
193
  "types": "./dist/storage/index.d.ts",
166
194
  "default": "./dist/storage/index.js"
167
195
  },
196
+ "./storage/memory": {
197
+ "types": "./dist/storage/memory.d.ts",
198
+ "default": "./dist/storage/memory.js"
199
+ },
168
200
  "./types/database": {
169
201
  "types": "./dist/types/database.d.ts",
170
202
  "default": "./dist/types/database.js"
@@ -193,6 +225,7 @@
193
225
  "clsx": "^2.1.1",
194
226
  "dexie": "^4.4.2",
195
227
  "dompurify": "^3.2.6",
228
+ "entities": "^6.0.1",
196
229
  "jose": "^6.2.2",
197
230
  "lucide-react": "^1.7.0",
198
231
  "marked": "^18.0.5",
@@ -4,9 +4,7 @@
4
4
  import type { LoadedSection } from "../lib/loader";
5
5
  import type { SiteIndex } from "../schemas/site-config";
6
6
  import { IndexSchema } from "../schemas/site-config";
7
- import { NavFileSchema } from "../schemas/nav";
8
- import { TagsFileSchema } from "../schemas/tags";
9
- import { mergeTags } from "../lib/tags";
7
+ import { liftSidecars, NAV, TAGS } from "../lib/sidecars";
10
8
 
11
9
  export interface RebaseArgs {
12
10
  localSections: LoadedSection[];
@@ -110,19 +108,17 @@ export function reconcileRemoteIndex(remoteIndex: SiteIndex, localIndex: SiteInd
110
108
  * intact rather than flattening it. The `tags` sidecar is lifted the same way,
111
109
  * for the same reason. */
112
110
  export function applyRemoteIndexUpdate(rawRemoteIndex: unknown, localIndex: SiteIndex): SiteIndex | null {
113
- const rawNav = (rawRemoteIndex as { nav?: unknown } | null | undefined)?.nav;
114
- const rawTags = (rawRemoteIndex as { tags?: unknown } | null | undefined)?.tags;
111
+ const lifted = liftSidecars(rawRemoteIndex);
112
+ // safeParse, not parse: this arrives off the network, and a throw inside a
113
+ // collab callback would take down the session rather than one bad message.
115
114
  const parsed = IndexSchema.safeParse(rawRemoteIndex);
116
115
  if (!parsed.success) return null;
117
116
 
118
- // safeParse, not parse: this arrives off the network, and a throw inside a
119
- // collab callback would take down the session rather than one bad message.
120
- const parsedNav = rawNav === undefined ? null : NavFileSchema.safeParse({ entries: rawNav });
121
- const nav = parsedNav?.success ? parsedNav.data.entries : localIndex.nav;
122
- // Tags get the identical lift. Their section ids are not pruned to our index
123
- // here — siteTags reconciles on read, and the next tags write reconciles too.
124
- const parsedTags = rawTags === undefined ? null : TagsFileSchema.safeParse({ tags: rawTags });
125
- const tags = parsedTags?.success ? parsedTags.data.tags : localIndex.tags;
117
+ // A peer's Degraded read is never adopted: installing a lossy tree would arm
118
+ // our next save to write it over the file. Tags' section ids are not pruned
119
+ // to our index here — siteTags reconciles on read, and tags writes reconcile.
120
+ const nav = lifted.nav && !lifted.nav.degraded ? lifted.nav.entries : localIndex.nav;
121
+ const tags = lifted.tags && !lifted.tags.degraded ? lifted.tags.entries : localIndex.tags;
126
122
 
127
123
  const reconciled = reconcileRemoteIndex(parsed.data, localIndex);
128
124
  return {
@@ -237,16 +233,16 @@ export function rebaseContent(args: RebaseArgs): RebaseResult {
237
233
  // conflict dialog, so persisted-beats-unsaved is the safe rule. A remote with
238
234
  // no nav (an older server that doesn't attach the sidecar) leaves ours intact
239
235
  // rather than flattening it; readers repair any drift via reconcileNav.
240
- const nav = args.remoteIndex.nav !== undefined ? args.remoteIndex.nav : args.localIndex.nav;
236
+ const nav = NAV.rebase({ base: args.baselineIndex?.nav, local: args.localIndex.nav, remote: args.remoteIndex.nav });
241
237
  if (nav !== undefined) index = { ...index, nav };
242
238
 
243
- // Tags: three-way, not nav's "persisted wins" — see mergeTags for why and for
239
+ // Tags: three-way, not nav's "persisted wins" — see TAGS.rebase (mergeTags) for why and for
244
240
  // the exact rules. Only the dirty branch merges: the clean branch above took
245
241
  // the remote's tags as-is, including their absence from an older server,
246
242
  // which leaves tags unloaded (so no write touches tags.json) rather than
247
243
  // reviving a local copy nothing local edited.
248
244
  if (args.localIndexDirty) {
249
- const tags = mergeTags({ base: args.baselineIndex?.tags, local: args.localIndex.tags, remote: args.remoteIndex.tags });
245
+ const tags = TAGS.rebase({ base: args.baselineIndex?.tags, local: args.localIndex.tags, remote: args.remoteIndex.tags });
250
246
  // undefined only when neither local nor remote carries tags, in which case
251
247
  // the localIndex-derived index above carries none either.
252
248
  if (tags !== undefined) index = { ...index, tags };
@@ -689,6 +689,7 @@ export default function EditorShell({
689
689
  media: remote.mediaManifest ?? mediaManifestRef.current,
690
690
  documents: remote.documentManifest ?? documentManifestRef.current ?? {},
691
691
  },
692
+ remote.tagsDegraded,
692
693
  {
693
694
  savedBranchSha: remote.savedBranchSha,
694
695
  changedSectionIds: remote.changedSectionIds,
@@ -775,6 +776,7 @@ export default function EditorShell({
775
776
  }
776
777
  setSections(result.sections);
777
778
  setSiteIndex(result.index);
779
+ setTagsDegraded(remote.tagsDegraded);
778
780
  setSavedSha(remote.savedBranchSha);
779
781
  setChangedSectionIds(new Set(remote.changedSectionIds));
780
782
  setMainIndex(remote.mainIndex);
@@ -800,6 +802,7 @@ export default function EditorShell({
800
802
  media: remote.mediaManifest ?? mediaManifestRef.current,
801
803
  documents: remote.documentManifest ?? documentManifestRef.current ?? {},
802
804
  },
805
+ remote.tagsDegraded,
803
806
  {
804
807
  savedBranchSha: remote.savedBranchSha,
805
808
  changedSectionIds: remote.changedSectionIds,
@@ -957,6 +960,7 @@ export default function EditorShell({
957
960
  clearConfigDirty: persistence.clearConfigDirty,
958
961
  siteIndexRef,
959
962
  siteConfig,
963
+ tagsDegraded,
960
964
  sections,
961
965
  deletedSectionIds: Array.from(deletedSections),
962
966
  onSaveWarnings: setSaveWarnings,
@@ -1069,6 +1073,7 @@ export default function EditorShell({
1069
1073
  // onMediaPublished / onDocumentsPublished callbacks only refresh through a
1070
1074
  // state effect and can still lag by a render at this point.
1071
1075
  { media: committedManifest, documents: committedDocumentManifest },
1076
+ tagsDegraded,
1072
1077
  {
1073
1078
  savedBranchSha: sha,
1074
1079
  changedSectionIds: [...changed],
@@ -1318,12 +1323,10 @@ export default function EditorShell({
1318
1323
  loadedIndex = data.index;
1319
1324
  loadedConfig = SiteConfigSchema.parse(data.siteConfig);
1320
1325
  if (data.mediaManifest) loadedManifest = data.mediaManifest;
1321
- // A degraded tags read is cached as "not loaded", so a later cache-hit
1322
- // load stays read-only for tags instead of trusting the partial list.
1323
- const { tags: _partialTags, ...untaggedIndex } = data.index;
1324
1326
  await cacheContent(
1325
- data.sha, data.sections, data.tagsDegraded ? untaggedIndex : data.index, data.siteConfig,
1327
+ data.sha, data.sections, data.index, data.siteConfig,
1326
1328
  { media: loadedManifest, documents: data.documentManifest ?? {} },
1329
+ data.tagsDegraded,
1327
1330
  {
1328
1331
  savedBranchSha: data.savedBranchSha,
1329
1332
  changedSectionIds: data.changedSectionIds,
@@ -10,6 +10,7 @@ import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent
10
10
  import { packUploads, packDocumentUploads, type MediaUploadPayload, type DocumentUploadPayload } from "../lib/save-chunks";
11
11
  import type { DocumentManifest, DocumentManifestEntry } from "../schemas/documents";
12
12
  import { assembleSavedManifest } from "../lib/media-folders";
13
+ import { imageVariantPath } from "../lib/image-variant-plan";
13
14
  import { buildCommittedBaseline } from "../collab/recovery";
14
15
 
15
16
  function blobToBase64(blob: Blob): Promise<string> {
@@ -63,6 +64,9 @@ interface PublishDeps {
63
64
  clearConfigDirty: () => void;
64
65
  siteIndexRef: React.RefObject<SiteIndex>;
65
66
  siteConfig: SiteConfig | null;
67
+ /** This session's tags read was degraded; the cache re-primes record tags
68
+ * as not loaded (see cacheContent). */
69
+ tagsDegraded: boolean;
66
70
  sections: LoadedSection[];
67
71
  deletedSectionIds?: string[];
68
72
  /** Receives the ids that were actually committed this save (so the caller can
@@ -148,6 +152,7 @@ export function useEditorPublish({
148
152
  clearConfigDirty,
149
153
  siteIndexRef,
150
154
  siteConfig,
155
+ tagsDegraded,
151
156
  sections,
152
157
  deletedSectionIds,
153
158
  onSuccess,
@@ -208,7 +213,7 @@ export function useEditorPublish({
208
213
  const ext = mimeExt[item.mimeType] ?? "bin";
209
214
  blobPayloads.push({ path: `assets/images/${item.folder}/original.${ext}`, base64 });
210
215
  } else {
211
- blobPayloads.push({ path: `assets/images/${item.folder}/${key}.webp`, base64 });
216
+ blobPayloads.push({ path: imageVariantPath(item.folder, key), base64 });
212
217
  }
213
218
  }
214
219
 
@@ -815,7 +820,7 @@ export function useEditorPublish({
815
820
  await cacheContent(saved.sha, baselineSections, saved.sentIndex, siteConfig!, {
816
821
  media: saved.committedManifest,
817
822
  documents: saved.committedDocumentManifest,
818
- });
823
+ }, tagsDegraded);
819
824
  finishSave(saved.uploadedItems, saved.savedSections.map((r) => r.sectionId));
820
825
  if (saved.committedDocumentIds.length > 0) onDocumentsPublished?.(saved.committedDocumentIds);
821
826
  if (saved.committedDocumentDeletions.length > 0) onDocumentsDeleted?.(saved.committedDocumentDeletions);
@@ -857,7 +862,7 @@ export function useEditorPublish({
857
862
  await cacheContent(sha, baselineSections, sentIndex, siteConfig!, {
858
863
  media: committedManifest!,
859
864
  documents: committedDocumentManifest!,
860
- });
865
+ }, tagsDegraded);
861
866
  } catch (cleanupError) {
862
867
  console.error("Publish succeeded but the cache re-key failed:", cleanupError);
863
868
  onShasUpdated(null, sha);
@@ -877,7 +882,7 @@ export function useEditorPublish({
877
882
  setPublishAction("idle");
878
883
  setSaveProgress(null);
879
884
  }
880
- }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete, recoverConflict, saveChunkBudget, getEditGeneration, pendingDocumentDeletions]);
885
+ }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete, recoverConflict, saveChunkBudget, getEditGeneration, pendingDocumentDeletions, tagsDegraded]);
881
886
 
882
887
  return { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish };
883
888
  }
@@ -1,5 +1,6 @@
1
1
  import { useState, useCallback, useEffect, useRef, useMemo } from "react";
2
- import { hashFileBuffer, sanitizeMediaName } from "../media/utils";
2
+ import { hashFileBuffer } from "../media/utils";
3
+ import { mediaIdentity, imageManifestEntry } from "../lib/image-variant-plan";
3
4
  import { ProcessingQueue, type QueueItem, type QueueEvent } from "../media/queue";
4
5
  import type { MediaManifest, MediaItem as LibraryMediaItem } from "../media/types";
5
6
  import type { SiteConfig } from "../schemas/site-config";
@@ -235,7 +236,16 @@ export function useMediaPipeline({
235
236
  if (event.type === "complete" && event.item.result) {
236
237
  const result = event.item.result;
237
238
  const kind = event.item.kind;
238
- const sanitizedName = sanitizeMediaName(event.item.originalName);
239
+ // A still image's manifest entry comes only from the plan the worker
240
+ // rendered. One without a plan (a stale cached worker after a deploy)
241
+ // is a failed upload, never a quiet entry with no variants.
242
+ if (kind === "image" && !result.plan) {
243
+ uploadTargetsRef.current.delete(event.item.hash);
244
+ hiddenUploadsRef.current.delete(event.item.hash);
245
+ uploadCallbacksRef.current.delete(event.item.hash);
246
+ onUploadErrorRef.current?.(`"${event.item.originalName}" couldn't be processed — reload the editor and try again.`);
247
+ return;
248
+ }
239
249
 
240
250
  const finalize = async (
241
251
  localUrls: Record<string, string>,
@@ -245,23 +255,23 @@ export function useMediaPipeline({
245
255
  ) => {
246
256
  if (destroyedRef.current) return;
247
257
  const targetFolderId = uploadTargetsRef.current.get(event.item.hash) ?? null;
258
+ // A still image's entry comes from the plan the worker rendered;
259
+ // animated and video items have no variants, only the identity.
260
+ const base: LibraryMediaItem = result.plan
261
+ ? imageManifestEntry(result.plan, result.variants, result.primaryBlob.size)
262
+ : {
263
+ ...mediaIdentity(event.item.hash, event.item.originalName),
264
+ kind,
265
+ width,
266
+ height,
267
+ mimeType: event.item.mimeType,
268
+ size: result.primaryBlob.size,
269
+ variants: [],
270
+ alt: "",
271
+ storage: "github",
272
+ };
248
273
  const item: LibraryMediaItem = {
249
- id: event.item.hash,
250
- hash: event.item.hash,
251
- kind,
252
- originalName: sanitizedName,
253
- width,
254
- height,
255
- mimeType: kind === "image" ? "image/webp" : event.item.mimeType,
256
- size: result.primaryBlob.size,
257
- folder: `${sanitizedName}-${event.item.hash}`,
258
- variants: result.variants.map((v) => ({
259
- width: v.width,
260
- height: v.height,
261
- size: v.size,
262
- })),
263
- alt: "",
264
- storage: "github",
274
+ ...base,
265
275
  ...(targetFolderId ? { folderId: targetFolderId } : {}),
266
276
  ...(hiddenUploadsRef.current.get(event.item.hash) ? { hidden: true } : {}),
267
277
  };
@@ -408,17 +418,13 @@ export function useMediaPipeline({
408
418
 
409
419
  try {
410
420
  const { posterBlob, width, height, duration } = await generateVideoPoster(file, mediaConfig.quality);
411
- const sanitizedName = sanitizeMediaName(file.name);
412
421
  const item: LibraryMediaItem = {
413
- id: hash,
414
- hash,
422
+ ...mediaIdentity(hash, file.name),
415
423
  kind: "video",
416
- originalName: sanitizedName,
417
424
  width,
418
425
  height,
419
426
  mimeType: file.type,
420
427
  size: file.size,
421
- folder: `${sanitizedName}-${hash}`,
422
428
  variants: [],
423
429
  alt: "",
424
430
  storage: "assets",
package/src/lib/dexie.ts CHANGED
@@ -511,6 +511,12 @@ export async function persistAll(
511
511
  * cache hit then served pre-upload media ("No Image" on images the published site
512
512
  * rendered fine) and an empty document manifest ("File missing" on committed
513
513
  * documents). Passing them positionally makes forgetting a compile error.
514
+ *
515
+ * `tagsDegraded` is required for the same reason. A degraded tags read caches
516
+ * as "not loaded" (the index without `tags`), so a later cache-hit load stays
517
+ * read-only for tags instead of trusting the partial list. Only the cold load
518
+ * used to strip it, so a conflict recovery, remote sync or save re-primed the
519
+ * row with the partial list and the next cache hit re-enabled tag editing.
514
520
  */
515
521
  export async function cacheContent(
516
522
  sha: string,
@@ -518,6 +524,7 @@ export async function cacheContent(
518
524
  index: SiteIndex,
519
525
  siteConfig: Record<string, unknown>,
520
526
  manifests: { media: MediaManifest; documents: DocumentManifest },
527
+ tagsDegraded: boolean,
521
528
  diff?: {
522
529
  savedBranchSha?: string | null;
523
530
  changedSectionIds?: string[];
@@ -526,11 +533,12 @@ export async function cacheContent(
526
533
  },
527
534
  ): Promise<void> {
528
535
  const now = new Date().toISOString();
536
+ const { tags: _partialTags, ...untaggedIndex } = index;
529
537
  await getDb().contentCache.put({
530
538
  key: "current",
531
539
  sha,
532
540
  sections,
533
- index,
541
+ index: tagsDegraded ? untaggedIndex : index,
534
542
  siteConfig,
535
543
  updatedAt: now,
536
544
  savedBranchSha: diff?.savedBranchSha ?? null,
@@ -81,3 +81,33 @@ export function clearDocumentRefs(
81
81
  if (!changed) return { changed: false, section };
82
82
  return { changed: true, section: { ...section, content: { ...section.content, children: next } } };
83
83
  }
84
+
85
+ /**
86
+ * The non-empty `documentId`s referenced by unvalidated section JSON, including
87
+ * ones nested in a container's children — `tallyDocumentIds` for a body that
88
+ * has not been through a schema, so every step is shape-checked. Walks by node
89
+ * type like the rest of this module, so an unrelated field called `doc` is
90
+ * never mistaken for a reference.
91
+ */
92
+ export function collectDocumentIds(value: unknown, out: Set<string> = new Set()): Set<string> {
93
+ if (Array.isArray(value)) {
94
+ for (const item of value) collectDocumentIds(item, out);
95
+ return out;
96
+ }
97
+ if (typeof value !== "object" || value === null) return out;
98
+ const node = value as { type?: unknown; content?: unknown };
99
+ const content = node.content;
100
+ if (typeof content !== "object" || content === null || Array.isArray(content)) return out;
101
+ const record = content as Record<string, unknown>;
102
+
103
+ if (node.type === "document") {
104
+ const doc = record.doc;
105
+ const documentId =
106
+ typeof doc === "object" && doc !== null ? (doc as Record<string, unknown>).documentId : undefined;
107
+ // Empty string is the routine unconfigured slot (the schema defaults to
108
+ // it), not a broken reference.
109
+ if (typeof documentId === "string" && documentId !== "") out.add(documentId);
110
+ }
111
+ if (Array.isArray(record.children)) collectDocumentIds(record.children, out);
112
+ return out;
113
+ }