@drawnagency/primitives 0.1.72 → 0.1.73

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 (30) hide show
  1. package/dist/{chunk-2HZB675H.js → chunk-7T2WQSWF.js} +2 -2
  2. package/dist/{chunk-7SIZ2LV5.js → chunk-IFHRHV7S.js} +1 -1
  3. package/dist/{chunk-BTGHF3SW.js → chunk-N6AE6NBB.js} +1 -1
  4. package/dist/{chunk-VHINNGIF.js → chunk-NKVZGPR6.js} +1 -1
  5. package/dist/{chunk-TOBPMMRM.js → chunk-S55OZ7F2.js} +8 -0
  6. package/dist/{chunk-WCALKWPO.js → chunk-T7MP7ANP.js} +11 -7
  7. package/dist/{chunk-UBK2IZ6G.js → chunk-XTRTK26E.js} +5 -5
  8. package/dist/components/editor/index.js +3 -3
  9. package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
  10. package/dist/components/primitives/index.js +4 -4
  11. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -1
  12. package/dist/components/sections/brand-guide.js +4 -4
  13. package/dist/components/sections/register-schemas.js +7 -7
  14. package/dist/components/sections/register.js +7 -7
  15. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  16. package/dist/hooks/useEditorPublish.d.ts +4 -1
  17. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  18. package/dist/index.js +5 -1
  19. package/dist/lib/save-chunks.d.ts +28 -0
  20. package/dist/lib/save-chunks.d.ts.map +1 -0
  21. package/dist/media/index.js +5 -1
  22. package/dist/media/resolve.d.ts +14 -0
  23. package/dist/media/resolve.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/components/primitives/ResolvedMedia.tsx +10 -5
  26. package/src/components/sections/Media/MediaBlock.tsx +2 -1
  27. package/src/components/shell/EditorShell.tsx +8 -2
  28. package/src/hooks/useEditorPublish.ts +134 -50
  29. package/src/lib/save-chunks.ts +53 -0
  30. package/src/media/resolve.ts +18 -0
@@ -4,7 +4,7 @@ import {
4
4
  MediaBlock,
5
5
  containerGridClass,
6
6
  useEditableCollection
7
- } from "./chunk-WCALKWPO.js";
7
+ } from "./chunk-T7MP7ANP.js";
8
8
  import {
9
9
  Checkbox,
10
10
  FormLabel,
@@ -15,7 +15,7 @@ import {
15
15
  DeleteIcon,
16
16
  DragHandle,
17
17
  IconButton
18
- } from "./chunk-7SIZ2LV5.js";
18
+ } from "./chunk-IFHRHV7S.js";
19
19
  import {
20
20
  DEFAULT_LINK,
21
21
  LinkValueSchema
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getMediaProvider
3
- } from "./chunk-TOBPMMRM.js";
3
+ } from "./chunk-S55OZ7F2.js";
4
4
  import {
5
5
  cn
6
6
  } from "./chunk-7EOSDFS3.js";
@@ -14,7 +14,7 @@ import {
14
14
  SettingsIcon,
15
15
  useMediaLibrary,
16
16
  useResolvedMedia
17
- } from "./chunk-7SIZ2LV5.js";
17
+ } from "./chunk-IFHRHV7S.js";
18
18
  import {
19
19
  cn
20
20
  } from "./chunk-7EOSDFS3.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  EditablePlainText
3
- } from "./chunk-WCALKWPO.js";
3
+ } from "./chunk-T7MP7ANP.js";
4
4
  import {
5
5
  Button,
6
6
  usePages
@@ -220,6 +220,12 @@ function resolveManifestReferences(content, manifest, sizes, provider) {
220
220
  }
221
221
  return content;
222
222
  }
223
+ var BAKED_MEDIA_KEYS = ["src", "srcset", "alt", "width", "height", "poster"];
224
+ function rebindImageRef(ref, imageId) {
225
+ const fresh = { ...ref, imageId };
226
+ for (const key of BAKED_MEDIA_KEYS) delete fresh[key];
227
+ return fresh;
228
+ }
223
229
 
224
230
  // src/media/videoPoster.ts
225
231
  function generateVideoPoster(blob, quality) {
@@ -312,6 +318,8 @@ export {
312
318
  ProcessingQueue,
313
319
  resolveMedia,
314
320
  resolveManifestReferences,
321
+ BAKED_MEDIA_KEYS,
322
+ rebindImageRef,
315
323
  generateVideoPoster,
316
324
  setMediaProvider,
317
325
  getMediaProvider
@@ -6,7 +6,10 @@ import {
6
6
  SettingsIcon,
7
7
  useMediaLibrary,
8
8
  useResolvedMedia
9
- } from "./chunk-7SIZ2LV5.js";
9
+ } from "./chunk-IFHRHV7S.js";
10
+ import {
11
+ rebindImageRef
12
+ } from "./chunk-S55OZ7F2.js";
10
13
  import {
11
14
  cn
12
15
  } from "./chunk-7EOSDFS3.js";
@@ -140,13 +143,14 @@ function ResolvedMedia({
140
143
  invertFrom
141
144
  }) {
142
145
  const resolved = useResolvedMedia(imageId);
143
- const src = propSrc || resolved.src;
144
- const srcset = propSrcset || resolved.srcset;
146
+ const hasResolved = !!resolved.src;
147
+ const src = hasResolved ? resolved.src : propSrc;
148
+ const srcset = hasResolved ? resolved.srcset : propSrcset;
145
149
  const poster = resolved.poster;
146
- const alt = propAlt ?? resolved.alt;
150
+ const alt = hasResolved ? resolved.alt : propAlt ?? "";
147
151
  const kind = resolved.kind;
148
- const width = propWidth ?? resolved.width;
149
- const height = propHeight ?? resolved.height;
152
+ const width = hasResolved ? resolved.width : propWidth;
153
+ const height = hasResolved ? resolved.height : propHeight;
150
154
  const [loaded, setLoaded] = useState2(false);
151
155
  const invertClass = invertFrom === "light" ? "invert dark:invert-0" : invertFrom === "dark" ? "dark:invert" : void 0;
152
156
  const mediaClass = cn("relative h-full w-full", imgClassName, invertClass);
@@ -259,7 +263,7 @@ function MediaBlock({ refValue, options, link, isEditMode, onRefChange }) {
259
263
  const fitClass = fit === "cover" ? "object-cover" : "object-contain";
260
264
  const showBorder = refValue.border ?? options.border;
261
265
  const captionStr = refValue.caption ? Array.isArray(refValue.caption) ? refValue.caption.join("\n") : refValue.caption : "";
262
- const replace = onRefChange ? (imageId) => onRefChange({ ...refValue, imageId }) : void 0;
266
+ const replace = onRefChange ? (imageId) => onRefChange(rebindImageRef(refValue, imageId)) : void 0;
263
267
  const baked = refValue;
264
268
  const bakedWidth = typeof baked.width === "number" ? baked.width : void 0;
265
269
  const bakedHeight = typeof baked.height === "number" ? baked.height : void 0;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  EditableRichText,
3
3
  defineHeadingSection
4
- } from "./chunk-VHINNGIF.js";
4
+ } from "./chunk-NKVZGPR6.js";
5
5
  import {
6
6
  embedUrl
7
7
  } from "./chunk-RBTSUR5V.js";
@@ -10,7 +10,7 @@ import {
10
10
  pluralize,
11
11
  stripHtmlToPlainText,
12
12
  truncate
13
- } from "./chunk-2HZB675H.js";
13
+ } from "./chunk-7T2WQSWF.js";
14
14
  import {
15
15
  EditablePlainText,
16
16
  ImageDropZone,
@@ -19,7 +19,7 @@ import {
19
19
  colSpanClass,
20
20
  containerGridClass,
21
21
  spacerHiddenClass
22
- } from "./chunk-WCALKWPO.js";
22
+ } from "./chunk-T7MP7ANP.js";
23
23
  import {
24
24
  DeleteButton,
25
25
  DragHandle,
@@ -31,11 +31,11 @@ import {
31
31
  buildBlockDropData,
32
32
  canDropBlock,
33
33
  isBlockDragData
34
- } from "./chunk-BTGHF3SW.js";
34
+ } from "./chunk-N6AE6NBB.js";
35
35
  import {
36
36
  useMediaLibrary,
37
37
  useResolvedMedia
38
- } from "./chunk-7SIZ2LV5.js";
38
+ } from "./chunk-IFHRHV7S.js";
39
39
  import {
40
40
  DEFAULT_LINK,
41
41
  LinkValueSchema
@@ -11,14 +11,14 @@ import {
11
11
  buildBlockDropData,
12
12
  canDropBlock,
13
13
  isBlockDragData
14
- } from "../../chunk-BTGHF3SW.js";
14
+ } from "../../chunk-N6AE6NBB.js";
15
15
  import "../../chunk-ZSPCE6MZ.js";
16
16
  import "../../chunk-5THS4K5D.js";
17
17
  import {
18
18
  AddIcon,
19
19
  IconButton
20
- } from "../../chunk-7SIZ2LV5.js";
21
- import "../../chunk-TOBPMMRM.js";
20
+ } from "../../chunk-IFHRHV7S.js";
21
+ import "../../chunk-S55OZ7F2.js";
22
22
  import "../../chunk-677NTHAZ.js";
23
23
  import {
24
24
  cn
@@ -1 +1 @@
1
- {"version":3,"file":"ResolvedMedia.d.ts","sourceRoot":"","sources":["../../../src/components/primitives/ResolvedMedia.tsx"],"names":[],"mappings":"AAKA,UAAU,kBAAkB;IAC1B,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,aAAa,CAAC,EAC5B,OAAO,EACP,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,SAAS,EAChB,MAAM,EAAE,UAAU,EAClB,SAAS,EACT,YAAY,EACZ,UAAU,GACX,EAAE,kBAAkB,2CAsEpB"}
1
+ {"version":3,"file":"ResolvedMedia.d.ts","sourceRoot":"","sources":["../../../src/components/primitives/ResolvedMedia.tsx"],"names":[],"mappings":"AAKA,UAAU,kBAAkB;IAC1B,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,aAAa,CAAC,EAC5B,OAAO,EACP,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,SAAS,EAChB,MAAM,EAAE,UAAU,EAClB,SAAS,EACT,YAAY,EACZ,UAAU,GACX,EAAE,kBAAkB,2CA2EpB"}
@@ -2,7 +2,7 @@ import {
2
2
  EditableRichText,
3
3
  LinkPopover,
4
4
  defineHeadingSection
5
- } from "../../chunk-VHINNGIF.js";
5
+ } from "../../chunk-NKVZGPR6.js";
6
6
  import {
7
7
  EditableGrid,
8
8
  EditablePlainText,
@@ -10,14 +10,14 @@ import {
10
10
  MediaBlock,
11
11
  ResolvedMedia,
12
12
  useEditableCollection
13
- } from "../../chunk-WCALKWPO.js";
13
+ } from "../../chunk-T7MP7ANP.js";
14
14
  import "../../chunk-ZSPCE6MZ.js";
15
15
  import {
16
16
  DeleteIcon,
17
17
  DragHandle,
18
18
  IconButton
19
- } from "../../chunk-7SIZ2LV5.js";
20
- import "../../chunk-TOBPMMRM.js";
19
+ } from "../../chunk-IFHRHV7S.js";
20
+ import "../../chunk-S55OZ7F2.js";
21
21
  import "../../chunk-53DKVAB6.js";
22
22
  import {
23
23
  cn
@@ -1 +1 @@
1
- {"version":3,"file":"MediaBlock.d.ts","sourceRoot":"","sources":["../../../../src/components/sections/Media/MediaBlock.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AACpE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAOvD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,WAAW,CAAC,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;KAAE,CAAC;IACxG,4EAA4E;IAC5E,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,oBAAoB,KAAK,IAAI,CAAC;CACnD;AAED,wBAAgB,UAAU,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,WAAW,EAAE,EAAE,eAAe,2CA2F/F"}
1
+ {"version":3,"file":"MediaBlock.d.ts","sourceRoot":"","sources":["../../../../src/components/sections/Media/MediaBlock.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AACpE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAQvD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,WAAW,CAAC,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;KAAE,CAAC;IACxG,4EAA4E;IAC5E,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,oBAAoB,KAAK,IAAI,CAAC;CACnD;AAED,wBAAgB,UAAU,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,WAAW,EAAE,EAAE,eAAe,2CA2F/F"}
@@ -2,11 +2,11 @@ import {
2
2
  ColorItemSchema,
3
3
  ColorSpaceSchema,
4
4
  brandGuideSectionDefs
5
- } from "../../chunk-2HZB675H.js";
6
- import "../../chunk-WCALKWPO.js";
5
+ } from "../../chunk-7T2WQSWF.js";
6
+ import "../../chunk-T7MP7ANP.js";
7
7
  import "../../chunk-5THS4K5D.js";
8
- import "../../chunk-7SIZ2LV5.js";
9
- import "../../chunk-TOBPMMRM.js";
8
+ import "../../chunk-IFHRHV7S.js";
9
+ import "../../chunk-S55OZ7F2.js";
10
10
  import "../../chunk-PZJL2S3Z.js";
11
11
  import "../../chunk-WDSVHKAH.js";
12
12
  import "../../chunk-7EOSDFS3.js";
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  allSectionDefs
3
- } from "../../chunk-UBK2IZ6G.js";
4
- import "../../chunk-VHINNGIF.js";
3
+ } from "../../chunk-XTRTK26E.js";
4
+ import "../../chunk-NKVZGPR6.js";
5
5
  import "../../chunk-RBTSUR5V.js";
6
- import "../../chunk-2HZB675H.js";
7
- import "../../chunk-WCALKWPO.js";
8
- import "../../chunk-BTGHF3SW.js";
6
+ import "../../chunk-7T2WQSWF.js";
7
+ import "../../chunk-T7MP7ANP.js";
8
+ import "../../chunk-N6AE6NBB.js";
9
9
  import "../../chunk-ZSPCE6MZ.js";
10
10
  import "../../chunk-5THS4K5D.js";
11
- import "../../chunk-7SIZ2LV5.js";
12
- import "../../chunk-TOBPMMRM.js";
11
+ import "../../chunk-IFHRHV7S.js";
12
+ import "../../chunk-S55OZ7F2.js";
13
13
  import "../../chunk-PZJL2S3Z.js";
14
14
  import "../../chunk-53DKVAB6.js";
15
15
  import "../../chunk-WDSVHKAH.js";
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  allSectionDefs
3
- } from "../../chunk-UBK2IZ6G.js";
4
- import "../../chunk-VHINNGIF.js";
3
+ } from "../../chunk-XTRTK26E.js";
4
+ import "../../chunk-NKVZGPR6.js";
5
5
  import "../../chunk-RBTSUR5V.js";
6
- import "../../chunk-2HZB675H.js";
7
- import "../../chunk-WCALKWPO.js";
8
- import "../../chunk-BTGHF3SW.js";
6
+ import "../../chunk-7T2WQSWF.js";
7
+ import "../../chunk-T7MP7ANP.js";
8
+ import "../../chunk-N6AE6NBB.js";
9
9
  import "../../chunk-ZSPCE6MZ.js";
10
10
  import "../../chunk-5THS4K5D.js";
11
- import "../../chunk-7SIZ2LV5.js";
12
- import "../../chunk-TOBPMMRM.js";
11
+ import "../../chunk-IFHRHV7S.js";
12
+ import "../../chunk-S55OZ7F2.js";
13
13
  import "../../chunk-PZJL2S3Z.js";
14
14
  import "../../chunk-53DKVAB6.js";
15
15
  import "../../chunk-WDSVHKAH.js";
@@ -1 +1 @@
1
- {"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AA0EjD,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAQxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAIhB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;IAIhE,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,YAAY,EACZ,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,EACX,sBAA8B,GAC/B,EAAE,KAAK,2CAy8BP"}
1
+ {"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AA0EjD,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAQxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAIhB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;IAIhE,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,YAAY,EACZ,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,EACX,sBAA8B,GAC/B,EAAE,KAAK,2CA08BP"}
@@ -20,11 +20,14 @@ interface PublishDeps {
20
20
  onShasUpdated: (savedSha: string | null, mainSha: string | null) => void;
21
21
  onPublishComplete?: () => void;
22
22
  getBaseVersion: () => string | null;
23
+ /** Test hook: overrides the per-request payload budget (default 4MB). */
24
+ saveChunkBudget?: number;
23
25
  }
24
26
  export type PublishAction = "idle" | "saving" | "publishing";
25
- export declare function useEditorPublish({ flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, onPublishComplete, getBaseVersion, }: PublishDeps): {
27
+ export declare function useEditorPublish({ flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, onPublishComplete, getBaseVersion, saveChunkBudget, }: PublishDeps): {
26
28
  publishAction: PublishAction;
27
29
  publishFeedback: string | null;
30
+ saveProgress: string | null;
28
31
  handleSave: () => Promise<void>;
29
32
  handlePublish: () => Promise<void>;
30
33
  handleSaveAndPublish: () => Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"useEditorPublish.d.ts","sourceRoot":"","sources":["../../src/hooks/useEditorPublish.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAe/D,UAAU,WAAW;IACnB,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,kBAAkB,EAAE,MAAM,IAAI,CAAC;IAC/B,aAAa,EAAE,MAAM,OAAO,CAAC;IAC7B,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,YAAY,EAAE,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IACzC,UAAU,EAAE,UAAU,GAAG,IAAI,CAAC;IAC9B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,EAAE,OAAO,CAAC;IACvB,kBAAkB,EAAE,MAAM,IAAI,CAAC;IAC/B,iBAAiB,EAAE,SAAS,EAAE,CAAC;IAC/B,qBAAqB,EAAE,MAAM,EAAE,CAAC;IAChC,gBAAgB,EAAE,CAAC,cAAc,EAAE,SAAS,EAAE,EAAE,kBAAkB,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IACtF,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACzE,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAC;IAI/B,cAAc,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CACrC;AAQD,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,QAAQ,GAAG,YAAY,CAAC;AAE7D,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,kBAAkB,EAClB,aAAa,EACb,gBAAgB,EAChB,YAAY,EACZ,UAAU,EACV,QAAQ,EACR,iBAAiB,EACjB,SAAS,EACT,aAAa,EACb,aAAa,EACb,kBAAkB,EAClB,iBAAiB,EACjB,qBAAqB,EACrB,gBAAgB,EAChB,aAAa,EACb,iBAAiB,EACjB,cAAc,GACf,EAAE,WAAW;;;;;;EAiUb"}
1
+ {"version":3,"file":"useEditorPublish.d.ts","sourceRoot":"","sources":["../../src/hooks/useEditorPublish.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AA6C/D,UAAU,WAAW;IACnB,QAAQ,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,kBAAkB,EAAE,MAAM,IAAI,CAAC;IAC/B,aAAa,EAAE,MAAM,OAAO,CAAC;IAC7B,gBAAgB,EAAE,MAAM,IAAI,CAAC;IAC7B,YAAY,EAAE,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;IACzC,UAAU,EAAE,UAAU,GAAG,IAAI,CAAC;IAC9B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,EAAE,OAAO,CAAC;IACvB,kBAAkB,EAAE,MAAM,IAAI,CAAC;IAC/B,iBAAiB,EAAE,SAAS,EAAE,CAAC;IAC/B,qBAAqB,EAAE,MAAM,EAAE,CAAC;IAChC,gBAAgB,EAAE,CAAC,cAAc,EAAE,SAAS,EAAE,EAAE,kBAAkB,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IACtF,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACzE,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAC;IAI/B,cAAc,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACpC,yEAAyE;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAQD,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,QAAQ,GAAG,YAAY,CAAC;AAE7D,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,kBAAkB,EAClB,aAAa,EACb,gBAAgB,EAChB,YAAY,EACZ,UAAU,EACV,QAAQ,EACR,iBAAiB,EACjB,SAAS,EACT,aAAa,EACb,aAAa,EACb,kBAAkB,EAClB,iBAAiB,EACjB,qBAAqB,EACrB,gBAAgB,EAChB,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,eAAe,GAChB,EAAE,WAAW;;;;;;;EAoXb"}
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  verifySessionToken
14
14
  } from "./chunk-EM5DFHDZ.js";
15
15
  import {
16
+ BAKED_MEDIA_KEYS,
16
17
  EXT_TO_MIME,
17
18
  MIME_TO_EXT,
18
19
  ProcessingQueue,
@@ -23,11 +24,12 @@ import {
23
24
  getMediaProvider,
24
25
  hashFileBuffer,
25
26
  mimeToExt,
27
+ rebindImageRef,
26
28
  resolveManifestReferences,
27
29
  resolveMedia,
28
30
  sanitizeMediaName,
29
31
  setMediaProvider
30
- } from "./chunk-TOBPMMRM.js";
32
+ } from "./chunk-S55OZ7F2.js";
31
33
  import {
32
34
  AudienceColorSchema,
33
35
  AudienceNameSchema,
@@ -140,6 +142,7 @@ export {
140
142
  AudienceColorSchema,
141
143
  AudienceNameSchema,
142
144
  AudienceSchema,
145
+ BAKED_MEDIA_KEYS,
143
146
  ChatbotConfigSchema,
144
147
  ChatbotTaskSchema,
145
148
  DEFAULT_LINK,
@@ -210,6 +213,7 @@ export {
210
213
  mimeToExt,
211
214
  navChangeEvent,
212
215
  normalizeSiteIndex,
216
+ rebindImageRef,
213
217
  registerSchema,
214
218
  registerSection,
215
219
  requireSessionSecret,
@@ -0,0 +1,28 @@
1
+ import type { MediaItem } from "../media/types";
2
+ export interface MediaUploadPayload {
3
+ item: MediaItem;
4
+ blobs: {
5
+ path: string;
6
+ base64: string;
7
+ }[];
8
+ }
9
+ /**
10
+ * Serialized-payload budget per /api/save request. Netlify rejects function
11
+ * request bodies over ~6MB at the platform level (before the app runs); 4MB
12
+ * leaves headroom for the JSON envelope plus sections/index/config riding the
13
+ * final chunk.
14
+ */
15
+ export declare const SAVE_CHUNK_BUDGET_BYTES: number;
16
+ /** Approximate serialized size of one upload: base64 dominates; the fixed
17
+ * allowances cover JSON keys, quoting, and the item's manifest metadata. */
18
+ export declare function uploadSizeBytes(upload: MediaUploadPayload): number;
19
+ export interface PackedUploads {
20
+ chunks: MediaUploadPayload[][];
21
+ oversized: MediaUploadPayload[];
22
+ }
23
+ /** Greedy in-order packing of uploads into budget-sized chunks. An item whose
24
+ * blobs alone exceed the budget can never be sent — it lands in `oversized`
25
+ * for the caller to reject with a per-file error. Items are never split
26
+ * across chunks (the manifest may only reference fully-committed items). */
27
+ export declare function packUploads(uploads: MediaUploadPayload[], budget?: number): PackedUploads;
28
+ //# sourceMappingURL=save-chunks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"save-chunks.d.ts","sourceRoot":"","sources":["../../src/lib/save-chunks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC3C;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,QAAkB,CAAC;AAEvD;6EAC6E;AAC7E,wBAAgB,eAAe,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAElE;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,kBAAkB,EAAE,EAAE,CAAC;IAC/B,SAAS,EAAE,kBAAkB,EAAE,CAAC;CACjC;AAED;;;6EAG6E;AAC7E,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,EAAE,EAAE,MAAM,SAA0B,GAAG,aAAa,CAsB1G"}
@@ -1,4 +1,5 @@
1
1
  import {
2
+ BAKED_MEDIA_KEYS,
2
3
  EXT_TO_MIME,
3
4
  MIME_TO_EXT,
4
5
  ProcessingQueue,
@@ -9,11 +10,12 @@ import {
9
10
  getMediaProvider,
10
11
  hashFileBuffer,
11
12
  mimeToExt,
13
+ rebindImageRef,
12
14
  resolveManifestReferences,
13
15
  resolveMedia,
14
16
  sanitizeMediaName,
15
17
  setMediaProvider
16
- } from "../chunk-TOBPMMRM.js";
18
+ } from "../chunk-S55OZ7F2.js";
17
19
  import "../chunk-4YEOTFVU.js";
18
20
  import {
19
21
  ImageManifestSchema,
@@ -23,6 +25,7 @@ import {
23
25
  } from "../chunk-SWIHUIFT.js";
24
26
  import "../chunk-JSBRDJBE.js";
25
27
  export {
28
+ BAKED_MEDIA_KEYS,
26
29
  EXT_TO_MIME,
27
30
  ImageManifestSchema,
28
31
  MIME_TO_EXT,
@@ -37,6 +40,7 @@ export {
37
40
  getMediaProvider,
38
41
  hashFileBuffer,
39
42
  mimeToExt,
43
+ rebindImageRef,
40
44
  resolveManifestReferences,
41
45
  resolveMedia,
42
46
  sanitizeMediaName,
@@ -12,4 +12,18 @@ export declare function resolveMedia(item: {
12
12
  * without needing to know about the manifest themselves.
13
13
  */
14
14
  export declare function resolveManifestReferences(content: unknown, manifest: MediaManifest, sizes: number[], provider: MediaProvider): unknown;
15
+ /**
16
+ * Keys stamped onto media refs by resolveManifestReferences above — derived
17
+ * from the referenced media item, never authored by the editor. Must stay in
18
+ * sync with the `patch` object built in resolveManifestReferences.
19
+ */
20
+ export declare const BAKED_MEDIA_KEYS: readonly ["src", "srcset", "alt", "width", "height", "poster"];
21
+ /**
22
+ * Re-point a media ref at a different library item. Drops every baked field so
23
+ * stale values resolved for the PREVIOUS image can't shadow the new one in the
24
+ * editor (ResolvedMedia prefers an explicit src prop over live resolution).
25
+ */
26
+ export declare function rebindImageRef<T extends {
27
+ imageId?: string;
28
+ }>(ref: T, imageId: string): T;
15
29
  //# sourceMappingURL=resolve.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/media/resolve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,wBAAgB,YAAY,CAC1B,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACxC,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,aAAa,GAAG,IAAI,CAKtB;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,OAAO,CA0CT"}
1
+ {"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../../src/media/resolve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,wBAAgB,YAAY,CAC1B,IAAI,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACxC,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,aAAa,GAAG,IAAI,CAKtB;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,EAAE,EACf,QAAQ,EAAE,aAAa,GACtB,OAAO,CA0CT;AAED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,gEAAiE,CAAC;AAE/F;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS;IAAE,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,CAAC,CAIzF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.72",
3
+ "version": "0.1.73",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -27,13 +27,18 @@ export function ResolvedMedia({
27
27
  invertFrom,
28
28
  }: ResolvedMediaProps) {
29
29
  const resolved = useResolvedMedia(imageId);
30
- const src = propSrc || resolved.src;
31
- const srcset = propSrcset || resolved.srcset;
30
+ // A successful live resolution is always fresher than baked props (the ref
31
+ // may have been re-pointed since the server baked them) — prefer it, and use
32
+ // the baked values only where resolution is unavailable (zero-JS viewer,
33
+ // deleted/unknown id).
34
+ const hasResolved = !!resolved.src;
35
+ const src = hasResolved ? resolved.src : propSrc;
36
+ const srcset = hasResolved ? resolved.srcset : propSrcset;
32
37
  const poster = resolved.poster;
33
- const alt = propAlt ?? resolved.alt;
38
+ const alt = hasResolved ? resolved.alt : (propAlt ?? "");
34
39
  const kind = resolved.kind;
35
- const width = propWidth ?? resolved.width;
36
- const height = propHeight ?? resolved.height;
40
+ const width = hasResolved ? resolved.width : propWidth;
41
+ const height = hasResolved ? resolved.height : propHeight;
37
42
 
38
43
  // `loaded` fades the placeholder out via onLoad in the hydrated editor. In the
39
44
  // zero-JS viewer this stays false (no JS), so the placeholder remains painted
@@ -5,6 +5,7 @@ import { ResolvedMedia } from "../../primitives/ResolvedMedia";
5
5
  import { ImageDropZone } from "../../primitives/ImageDropZone";
6
6
  import { EditablePlainText } from "../../primitives/EditablePlainText";
7
7
  import { useMediaLibrary } from "../../shell/MediaLibraryContext";
8
+ import { rebindImageRef } from "../../../media/resolve";
8
9
 
9
10
  export interface MediaBlockProps {
10
11
  refValue: SingleMediaReference;
@@ -25,7 +26,7 @@ export function MediaBlock({ refValue, options, link, isEditMode, onRefChange }:
25
26
  ? Array.isArray(refValue.caption) ? refValue.caption.join("\n") : refValue.caption
26
27
  : "";
27
28
 
28
- const replace = onRefChange ? (imageId: string) => onRefChange({ ...refValue, imageId }) : undefined;
29
+ const replace = onRefChange ? (imageId: string) => onRefChange(rebindImageRef(refValue, imageId)) : undefined;
29
30
 
30
31
  // The zero-JS viewer has no MediaLibraryContext, so useResolvedMedia() can't
31
32
  // resolve the URL. The SSR pass (resolveManifestReferences) bakes src/srcset/alt
@@ -232,7 +232,7 @@ export default function EditorShell({
232
232
 
233
233
  const buildStatus = useBuildStatus();
234
234
 
235
- const { publishAction, publishFeedback, handleSave, handlePublish, handleSaveAndPublish } = useEditorPublish({
235
+ const { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish } = useEditorPublish({
236
236
  flushNow: persistence.flushNow,
237
237
  cancelPendingFlush: persistence.cancelPendingFlush,
238
238
  isConfigDirty: persistence.isConfigDirty,
@@ -884,6 +884,7 @@ export default function EditorShell({
884
884
  localChangesExist={localChangesExist}
885
885
  publishAction={publishAction}
886
886
  publishFeedback={publishFeedback}
887
+ saveProgress={saveProgress}
887
888
  onSave={handleSave}
888
889
  onPublish={handlePublish}
889
890
  onSaveAndPublish={handleSaveAndPublish}
@@ -1489,12 +1490,14 @@ function DismissButton({ onClick }: { onClick: () => void }) {
1489
1490
  function StatusText({
1490
1491
  publishAction,
1491
1492
  publishFeedback,
1493
+ saveProgress,
1492
1494
  buildState,
1493
1495
  buildElapsed,
1494
1496
  onDismiss,
1495
1497
  }: {
1496
1498
  publishAction: "idle" | "saving" | "publishing";
1497
1499
  publishFeedback: string | null;
1500
+ saveProgress: string | null;
1498
1501
  buildState: "idle" | "building" | "ready" | "error" | "fading";
1499
1502
  buildElapsed: number;
1500
1503
  onDismiss: () => void;
@@ -1502,7 +1505,7 @@ function StatusText({
1502
1505
  if (publishAction === "saving") {
1503
1506
  return (
1504
1507
  <span className="relative text-xs font-medium text-base-content/60">
1505
- Saving changes<LoadingEllipsis />
1508
+ {saveProgress ?? <>Saving changes<LoadingEllipsis /></>}
1506
1509
  </span>
1507
1510
  );
1508
1511
  }
@@ -1550,6 +1553,7 @@ function EditorToolbar({
1550
1553
  localChangesExist,
1551
1554
  publishAction,
1552
1555
  publishFeedback,
1556
+ saveProgress,
1553
1557
  onSave,
1554
1558
  onPublish,
1555
1559
  onSaveAndPublish,
@@ -1569,6 +1573,7 @@ function EditorToolbar({
1569
1573
  localChangesExist: boolean;
1570
1574
  publishAction: "idle" | "saving" | "publishing";
1571
1575
  publishFeedback: string | null;
1576
+ saveProgress: string | null;
1572
1577
  onSave: () => void;
1573
1578
  onPublish: () => void;
1574
1579
  onSaveAndPublish: () => void;
@@ -1640,6 +1645,7 @@ function EditorToolbar({
1640
1645
  <StatusText
1641
1646
  publishAction={publishAction}
1642
1647
  publishFeedback={publishFeedback}
1648
+ saveProgress={saveProgress}
1643
1649
  buildState={buildState}
1644
1650
  buildElapsed={buildElapsed}
1645
1651
  onDismiss={onBuildDismiss}
@@ -3,6 +3,7 @@ import type { SiteIndex, SiteConfig } from "../schemas/site-config";
3
3
  import type { LoadedSection } from "../lib/loader";
4
4
  import type { MediaManifest, MediaItem } from "../media/types";
5
5
  import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, clearContentCache, getPendingMediaBlobs, clearPendingMediaByIds } from "../lib/dexie";
6
+ import { packUploads, type MediaUploadPayload } from "../lib/save-chunks";
6
7
 
7
8
  function blobToBase64(blob: Blob): Promise<string> {
8
9
  return new Promise((resolve, reject) => {
@@ -16,6 +17,35 @@ function blobToBase64(blob: Blob): Promise<string> {
16
17
  });
17
18
  }
18
19
 
20
+ /** One /api/save request with honest failure semantics: the server's message
21
+ * is preserved verbatim; non-JSON bodies (platform-level rejections) get a
22
+ * status-coded fallback — or a size hint when the request body itself was
23
+ * near Netlify's ~6MB limit (should be unreachable once saves are chunked). */
24
+ async function postSave(payload: object): Promise<{ sha: string }> {
25
+ const body = JSON.stringify(payload);
26
+ let response: Response;
27
+ try {
28
+ response = await fetch("/api/save", {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ body,
32
+ });
33
+ } catch {
34
+ throw new Error("Network error while saving — check your connection and try again.");
35
+ }
36
+ if (!response.ok) {
37
+ const errorBody = await response.json().catch(() => null) as { message?: string; error?: string } | null;
38
+ const serverMessage = errorBody?.message || errorBody?.error;
39
+ const fallback = !errorBody && body.length > 5_500_000
40
+ ? "This save is too large to send in one request — remove some images from the media library and try again."
41
+ : `Save failed (HTTP ${response.status})`;
42
+ throw Object.assign(new Error(serverMessage || fallback), { status: response.status });
43
+ }
44
+ const data = await response.json().catch(() => null) as { sha?: string } | null;
45
+ if (!data?.sha) throw new Error("Save response missing SHA");
46
+ return data as { sha: string };
47
+ }
48
+
19
49
  interface PublishDeps {
20
50
  flushNow: () => Promise<void>;
21
51
  cancelPendingFlush: () => void;
@@ -38,6 +68,8 @@ interface PublishDeps {
38
68
  // no draft exists yet). Sent with saves so the server can 409 on a concurrent
39
69
  // edit instead of silently overwriting it. Read at save time (latest value).
40
70
  getBaseVersion: () => string | null;
71
+ /** Test hook: overrides the per-request payload budget (default 4MB). */
72
+ saveChunkBudget?: number;
41
73
  }
42
74
 
43
75
  interface GatheredMedia {
@@ -67,9 +99,11 @@ export function useEditorPublish({
67
99
  onShasUpdated,
68
100
  onPublishComplete,
69
101
  getBaseVersion,
102
+ saveChunkBudget,
70
103
  }: PublishDeps) {
71
104
  const [publishAction, setPublishAction] = useState<PublishAction>("idle");
72
105
  const [publishFeedback, setPublishFeedback] = useState<string | null>(null);
106
+ const [saveProgress, setSaveProgress] = useState<string | null>(null);
73
107
  const feedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
74
108
  const inFlightRef = useRef(false);
75
109
 
@@ -148,6 +182,7 @@ export function useEditorPublish({
148
182
  pendingMediaDeletions: string[];
149
183
  mediaManifest: MediaManifest;
150
184
  manifestDirty: boolean;
185
+ saveChunkBudget?: number;
151
186
  }): Promise<{ sha: string; savedSections: { sectionId: string; updatedAt: string }[]; uploadedItems: MediaItem[] }> {
152
187
  const dirty = await getDirtySectionRows();
153
188
  const gathered = await gatherMediaPayload(
@@ -157,6 +192,14 @@ export function useEditorPublish({
157
192
  args.manifestDirty,
158
193
  );
159
194
 
195
+ const { chunks, oversized } = packUploads(gathered.mediaUploads, args.saveChunkBudget);
196
+ if (oversized.length > 0) {
197
+ const names = oversized.map((u) => `"${u.item.originalName}"`).join(", ");
198
+ throw new Error(
199
+ `${names} ${oversized.length === 1 ? "is" : "are"} too large to save — resize or compress the file, re-add it to the media library, and try again.`,
200
+ );
201
+ }
202
+
160
203
  let siteIndex = args.siteIndexRef.current;
161
204
  if (args.deletedSectionIds?.length) {
162
205
  const deleteSet = new Set(args.deletedSectionIds);
@@ -171,48 +214,71 @@ export function useEditorPublish({
171
214
  };
172
215
  }
173
216
 
174
- const response = await fetch("/api/save", {
175
- method: "POST",
176
- headers: { "Content-Type": "application/json" },
177
- body: JSON.stringify({
178
- sections: dirty.map(({ sectionId, content }) => ({
179
- id: sectionId,
180
- content,
181
- })),
182
- siteIndex,
183
- baseVersion: getBaseVersion(),
184
- ...(args.deletedSectionIds?.length ? { deletedSectionIds: args.deletedSectionIds } : {}),
185
- ...(args.isConfigDirty() ? { siteConfig: args.siteConfig } : {}),
186
- ...(gathered.hasMediaChanges ? {
187
- media: {
188
- uploads: gathered.mediaUploads.map(({ item, blobs }) => ({ item, blobs })),
189
- deletions: args.pendingMediaDeletions.map((id) => ({
190
- id,
191
- folder: args.mediaManifest.images[id]?.folder,
192
- })).filter((d) => d.folder),
193
- manifest: gathered.updatedManifest,
194
- },
195
- } : {}),
196
- }),
197
- });
198
-
199
- if (!response.ok) {
200
- const errorBody = await response.json().catch(() => ({}));
201
- const message = errorBody.message || errorBody.error || "Save failed";
202
- throw Object.assign(new Error(message), { status: response.status });
217
+ let baseVersion = getBaseVersion();
218
+ const uploadedItems: MediaItem[] = [];
219
+ const totalUploads = gathered.mediaUploads.length;
220
+
221
+ // Lead chunks: media-only saves. The server skips the index write for
222
+ // these, so the draft never references section files that don't exist
223
+ // yet; a failure after chunk k leaves chunks 1..k committed AND in the
224
+ // committed manifest. The remainder stays pending in Dexie, but the
225
+ // caller's savedShaRef only advances on full success — so an IMMEDIATE
226
+ // retry may 409 against the moved draft head; after a reload the save
227
+ // resumes cleanly with only the uncommitted remainder.
228
+ const leadChunks = chunks.slice(0, -1); // empty when chunks.length <= 1
229
+ const finalUploads: MediaUploadPayload[] = chunks.length > 0 ? chunks[chunks.length - 1] : [];
230
+ const committedImages: Record<string, MediaItem> = {};
231
+ let sent = 0;
232
+
233
+ for (const chunk of leadChunks) {
234
+ setSaveProgress(`Saving images ${sent + chunk.length}/${totalUploads}…`);
235
+ for (const upload of chunk) committedImages[upload.item.id] = upload.item;
236
+ const manifest: MediaManifest = { images: { ...args.mediaManifest.images, ...committedImages } };
237
+ const data = await postSave({
238
+ baseVersion,
239
+ media: { uploads: chunk, deletions: [], manifest },
240
+ });
241
+ baseVersion = data.sha;
242
+ sent += chunk.length;
243
+ const chunkItems = chunk.map((u) => u.item);
244
+ uploadedItems.push(...chunkItems);
245
+ // Promote committed items immediately so a later failure can't re-send
246
+ // them. A failure HERE is tolerable: re-sending identical blobs on the
247
+ // next save is harmless (same content, same paths).
248
+ try {
249
+ await clearPendingMediaByIds(chunkItems.map((i) => i.id), []);
250
+ onMediaPublished(chunkItems, []);
251
+ } catch (promoteError) {
252
+ console.error("Failed to promote committed media chunk locally:", promoteError);
253
+ }
203
254
  }
204
255
 
205
- const responseData = await response.json().catch(() => null);
206
- if (!responseData?.sha) {
207
- throw new Error("Save response missing SHA");
256
+ if (leadChunks.length > 0) {
257
+ setSaveProgress(finalUploads.length > 0 ? `Saving images ${totalUploads}/${totalUploads}…` : null);
208
258
  }
259
+
260
+ const responseData = await postSave({
261
+ sections: dirty.map(({ sectionId, content }) => ({ id: sectionId, content })),
262
+ siteIndex,
263
+ baseVersion,
264
+ ...(args.deletedSectionIds?.length ? { deletedSectionIds: args.deletedSectionIds } : {}),
265
+ ...(args.isConfigDirty() ? { siteConfig: args.siteConfig } : {}),
266
+ ...(gathered.hasMediaChanges ? {
267
+ media: {
268
+ uploads: finalUploads,
269
+ deletions: args.pendingMediaDeletions.map((id) => ({
270
+ id,
271
+ folder: args.mediaManifest.images[id]?.folder,
272
+ })).filter((d) => d.folder),
273
+ manifest: gathered.updatedManifest,
274
+ },
275
+ } : {}),
276
+ });
277
+
209
278
  return {
210
- sha: responseData.sha as string,
279
+ sha: responseData.sha,
211
280
  savedSections: dirty.map(({ sectionId, updatedAt }) => ({ sectionId, updatedAt })),
212
- // Only the items whose blobs were actually written to GitHub. Items skipped
213
- // by gatherMediaPayload (blobs not yet persisted) are excluded so the caller
214
- // never promotes them into the manifest as orphans.
215
- uploadedItems: gathered.mediaUploads.map(({ item }) => item),
281
+ uploadedItems: [...uploadedItems, ...finalUploads.map((u) => u.item)],
216
282
  };
217
283
  }
218
284
 
@@ -242,6 +308,7 @@ export function useEditorPublish({
242
308
  siteConfig: siteConfig!,
243
309
  siteIndexRef, deletedSectionIds, isConfigDirty,
244
310
  pendingMediaItems, pendingMediaDeletions, mediaManifest, manifestDirty,
311
+ saveChunkBudget,
245
312
  });
246
313
  }
247
314
 
@@ -269,8 +336,9 @@ export function useEditorPublish({
269
336
 
270
337
  function reportSaveError(error: unknown, failLabel: string): void {
271
338
  console.error(`${failLabel} failed:`, error);
339
+ const message = error instanceof Error && error.message ? error.message : `${failLabel} failed`;
272
340
  const isConflict = (error as { status?: number })?.status === 409;
273
- showFeedback(isConflict ? (error as Error).message : `${failLabel} failed`, isConflict ? 8000 : 5000);
341
+ showFeedback(message, isConflict ? 8000 : 6000);
274
342
  }
275
343
 
276
344
  const handleSave = useCallback(async () => {
@@ -286,19 +354,26 @@ export function useEditorPublish({
286
354
  }
287
355
 
288
356
  const { sha, savedSections, uploadedItems } = await runPerformSave();
289
-
290
- await commitSavedRows(savedSections, uploadedItems);
291
- finishSave(uploadedItems);
292
357
  onShasUpdated(sha, null);
293
358
 
359
+ try {
360
+ await commitSavedRows(savedSections, uploadedItems);
361
+ finishSave(uploadedItems);
362
+ } catch (cleanupError) {
363
+ console.error("Save succeeded but local cleanup failed:", cleanupError);
364
+ showFeedback("Saved, but the local cache couldn't be updated — reload the editor.", 8000);
365
+ return;
366
+ }
367
+
294
368
  showFeedback("Saved", 3000);
295
369
  } catch (error) {
296
370
  reportSaveError(error, "Save");
297
371
  } finally {
298
372
  inFlightRef.current = false;
299
373
  setPublishAction("idle");
374
+ setSaveProgress(null);
300
375
  }
301
- }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback]);
376
+ }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, saveChunkBudget]);
302
377
 
303
378
  const handlePublish = useCallback(async () => {
304
379
  setPublishAction("publishing");
@@ -369,12 +444,20 @@ export function useEditorPublish({
369
444
  const { sha } = publishData;
370
445
 
371
446
  if (hasLocalEdits) {
372
- await commitSavedRows(savedSections, uploadedItems);
373
- // Re-cache against the PUBLISHED sha (commitSavedRows just invalidated the
374
- // cache); the draft branch is gone after publish, so this is the snapshot a
375
- // later cache hit restores.
376
- await cacheContent(sha, sections, siteIndexRef.current, siteConfig!);
377
- finishSave(uploadedItems);
447
+ try {
448
+ await commitSavedRows(savedSections, uploadedItems);
449
+ // Re-cache against the PUBLISHED sha (commitSavedRows just invalidated the
450
+ // cache); the draft branch is gone after publish, so this is the snapshot a
451
+ // later cache hit restores.
452
+ await cacheContent(sha, sections, siteIndexRef.current, siteConfig!);
453
+ finishSave(uploadedItems);
454
+ } catch (cleanupError) {
455
+ console.error("Publish succeeded but local cleanup failed:", cleanupError);
456
+ onShasUpdated(null, sha);
457
+ onPublishComplete?.();
458
+ showFeedback("Published, but the local cache couldn't be updated — reload the editor.", 8000);
459
+ return;
460
+ }
378
461
  }
379
462
 
380
463
  onShasUpdated(null, sha);
@@ -384,8 +467,9 @@ export function useEditorPublish({
384
467
  } finally {
385
468
  inFlightRef.current = false;
386
469
  setPublishAction("idle");
470
+ setSaveProgress(null);
387
471
  }
388
- }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete]);
472
+ }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete, saveChunkBudget]);
389
473
 
390
- return { publishAction, publishFeedback, handleSave, handlePublish, handleSaveAndPublish };
474
+ return { publishAction, publishFeedback, saveProgress, handleSave, handlePublish, handleSaveAndPublish };
391
475
  }
@@ -0,0 +1,53 @@
1
+ import type { MediaItem } from "../media/types";
2
+
3
+ export interface MediaUploadPayload {
4
+ item: MediaItem;
5
+ blobs: { path: string; base64: string }[];
6
+ }
7
+
8
+ /**
9
+ * Serialized-payload budget per /api/save request. Netlify rejects function
10
+ * request bodies over ~6MB at the platform level (before the app runs); 4MB
11
+ * leaves headroom for the JSON envelope plus sections/index/config riding the
12
+ * final chunk.
13
+ */
14
+ export const SAVE_CHUNK_BUDGET_BYTES = 4 * 1024 * 1024;
15
+
16
+ /** Approximate serialized size of one upload: base64 dominates; the fixed
17
+ * allowances cover JSON keys, quoting, and the item's manifest metadata. */
18
+ export function uploadSizeBytes(upload: MediaUploadPayload): number {
19
+ return upload.blobs.reduce((sum, b) => sum + b.base64.length + b.path.length + 64, 512);
20
+ }
21
+
22
+ export interface PackedUploads {
23
+ chunks: MediaUploadPayload[][];
24
+ oversized: MediaUploadPayload[];
25
+ }
26
+
27
+ /** Greedy in-order packing of uploads into budget-sized chunks. An item whose
28
+ * blobs alone exceed the budget can never be sent — it lands in `oversized`
29
+ * for the caller to reject with a per-file error. Items are never split
30
+ * across chunks (the manifest may only reference fully-committed items). */
31
+ export function packUploads(uploads: MediaUploadPayload[], budget = SAVE_CHUNK_BUDGET_BYTES): PackedUploads {
32
+ const chunks: MediaUploadPayload[][] = [];
33
+ const oversized: MediaUploadPayload[] = [];
34
+ let current: MediaUploadPayload[] = [];
35
+ let currentBytes = 0;
36
+
37
+ for (const upload of uploads) {
38
+ const size = uploadSizeBytes(upload);
39
+ if (size > budget) {
40
+ oversized.push(upload);
41
+ continue;
42
+ }
43
+ if (current.length > 0 && currentBytes + size > budget) {
44
+ chunks.push(current);
45
+ current = [];
46
+ currentBytes = 0;
47
+ }
48
+ current.push(upload);
49
+ currentBytes += size;
50
+ }
51
+ if (current.length > 0) chunks.push(current);
52
+ return { chunks, oversized };
53
+ }
@@ -68,3 +68,21 @@ export function resolveManifestReferences(
68
68
 
69
69
  return content;
70
70
  }
71
+
72
+ /**
73
+ * Keys stamped onto media refs by resolveManifestReferences above — derived
74
+ * from the referenced media item, never authored by the editor. Must stay in
75
+ * sync with the `patch` object built in resolveManifestReferences.
76
+ */
77
+ export const BAKED_MEDIA_KEYS = ["src", "srcset", "alt", "width", "height", "poster"] as const;
78
+
79
+ /**
80
+ * Re-point a media ref at a different library item. Drops every baked field so
81
+ * stale values resolved for the PREVIOUS image can't shadow the new one in the
82
+ * editor (ResolvedMedia prefers an explicit src prop over live resolution).
83
+ */
84
+ export function rebindImageRef<T extends { imageId?: string }>(ref: T, imageId: string): T {
85
+ const fresh = { ...ref, imageId } as Record<string, unknown>;
86
+ for (const key of BAKED_MEDIA_KEYS) delete fresh[key];
87
+ return fresh as T;
88
+ }