canopycms 0.2.0-int.119 → 0.2.0

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 (56) hide show
  1. package/CHANGELOG.md +123 -62
  2. package/dist/api/assets.js +20 -10
  3. package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
  4. package/dist/assets/editor-thumbnail-widths.js +7 -0
  5. package/dist/assets/finalize.d.ts +7 -6
  6. package/dist/assets/finalize.js +15 -9
  7. package/dist/assets/materialize.d.ts +17 -2
  8. package/dist/assets/materialize.js +57 -4
  9. package/dist/assets/store-s3.d.ts +7 -3
  10. package/dist/assets/store-s3.js +25 -6
  11. package/dist/assets/types.d.ts +7 -2
  12. package/dist/cli/cli.js +40 -23
  13. package/dist/config/types.d.ts +3 -2
  14. package/dist/dev-content-watcher.js +8 -1
  15. package/dist/editor/Editor.js +60 -7
  16. package/dist/editor/EntryNavigator.d.ts +9 -4
  17. package/dist/editor/EntryNavigator.js +39 -17
  18. package/dist/editor/PreviewFrame.d.ts +3 -1
  19. package/dist/editor/PreviewFrame.js +43 -2
  20. package/dist/editor/PreviewPane.d.ts +2 -0
  21. package/dist/editor/components/BranchLockBanner.d.ts +7 -8
  22. package/dist/editor/components/BranchLockBanner.js +25 -2
  23. package/dist/editor/components/BranchRowLock.d.ts +14 -0
  24. package/dist/editor/components/BranchRowLock.js +21 -0
  25. package/dist/editor/components/EditorHeader.js +3 -18
  26. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  27. package/dist/editor/components/PreviewLinkToast.js +33 -0
  28. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  29. package/dist/editor/fields/FieldLabel.js +1 -1
  30. package/dist/editor/fields/ImageField.d.ts +1 -1
  31. package/dist/editor/fields/ImageField.js +36 -27
  32. package/dist/editor/hooks/editor-history.d.ts +7 -0
  33. package/dist/editor/hooks/editor-history.js +80 -0
  34. package/dist/editor/hooks/useBranchActions.js +3 -1
  35. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  36. package/dist/editor/hooks/useBranchManager.js +6 -2
  37. package/dist/editor/hooks/useEntryManager.d.ts +9 -2
  38. package/dist/editor/hooks/useEntryManager.js +131 -17
  39. package/dist/editor/media/AssetCard.js +4 -2
  40. package/dist/editor/media/AssetListRow.js +4 -2
  41. package/dist/editor/media/CropStep.d.ts +8 -10
  42. package/dist/editor/media/CropStep.js +39 -27
  43. package/dist/editor/media/crop-availability.d.ts +6 -0
  44. package/dist/editor/media/crop-availability.js +19 -0
  45. package/dist/editor/media/crop-math.d.ts +19 -13
  46. package/dist/editor/media/crop-math.js +50 -11
  47. package/dist/editor/preview-bridge.d.ts +7 -0
  48. package/dist/editor/preview-bridge.js +93 -1
  49. package/dist/editor/preview-path.d.ts +5 -0
  50. package/dist/editor/preview-path.js +18 -0
  51. package/dist/editor/theme.d.ts +1 -0
  52. package/dist/editor/theme.js +38 -1
  53. package/dist/server.d.ts +2 -0
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/package.json +2 -2
@@ -15,12 +15,13 @@ export type FinalizeAssetResult = {
15
15
  * Run the finalize pipeline over raw bytes and persist the result.
16
16
  *
17
17
  * Order matters here too: dedup FIRST (after hashing, inside the pipeline) -
18
- * if an identical blob already exists, return the existing meta and skip all
19
- * writes entirely (first-name-wins). Otherwise write the original, then the
20
- * optional public object, then `putMetaIfAbsent` LAST: meta is the commit
21
- * point for an asset's existence, so a crash between `putOriginal` and here
22
- * leaves only orphaned (harmless, content-addressed) blobs, never a meta
23
- * record pointing at a missing blob.
18
+ * if an identical blob already exists, return the existing meta, touching
19
+ * only its hidden marker and any editor thumbnails it lacks (first-name-wins).
20
+ * Otherwise write the original, then the optional public object, then
21
+ * `putMetaIfAbsent` LAST: meta is the commit point for an asset's existence,
22
+ * so a crash between `putOriginal` and here leaves only orphaned (harmless,
23
+ * content-addressed) blobs, never a meta record pointing at a missing blob.
24
+ * The editor thumbnails come after the commit.
24
25
  * @internal Exported for tests.
25
26
  */
26
27
  export declare function finalizeAsset(store: AssetStore, input: FinalizeInput): Promise<FinalizeAssetResult>;
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { runFinalizePipeline } from './pipeline.js';
7
7
  import { ASSET_PREFIXES } from './keys.js';
8
+ import { storeEditorThumbnails } from './materialize.js';
8
9
  import { getErrorMessage } from '../utils/error.js';
9
10
  import { canopyLogWarn } from '../utils/logger.js';
10
11
  /**
@@ -25,12 +26,13 @@ export function isValidStagingKey(key) {
25
26
  * Run the finalize pipeline over raw bytes and persist the result.
26
27
  *
27
28
  * Order matters here too: dedup FIRST (after hashing, inside the pipeline) -
28
- * if an identical blob already exists, return the existing meta and skip all
29
- * writes entirely (first-name-wins). Otherwise write the original, then the
30
- * optional public object, then `putMetaIfAbsent` LAST: meta is the commit
31
- * point for an asset's existence, so a crash between `putOriginal` and here
32
- * leaves only orphaned (harmless, content-addressed) blobs, never a meta
33
- * record pointing at a missing blob.
29
+ * if an identical blob already exists, return the existing meta, touching
30
+ * only its hidden marker and any editor thumbnails it lacks (first-name-wins).
31
+ * Otherwise write the original, then the optional public object, then
32
+ * `putMetaIfAbsent` LAST: meta is the commit point for an asset's existence,
33
+ * so a crash between `putOriginal` and here leaves only orphaned (harmless,
34
+ * content-addressed) blobs, never a meta record pointing at a missing blob.
35
+ * The editor thumbnails come after the commit.
34
36
  * @internal Exported for tests.
35
37
  */
36
38
  export async function finalizeAsset(store, input) {
@@ -50,6 +52,8 @@ export async function finalizeAsset(store, input) {
50
52
  catch (err) {
51
53
  canopyLogWarn(`[canopycms] could not restore removed asset ${meta.hash32} to the library on re-upload: ${getErrorMessage(err)}`);
52
54
  }
55
+ // Stores any thumbnail the existing asset lacks; this upload's bytes are its original's.
56
+ await storeEditorThumbnails(store, existing, data, { skipStored: true });
53
57
  return { ok: true, meta: existing };
54
58
  }
55
59
  // Either put may find its key taken (`already-exists`): by an earlier attempt that stopped
@@ -62,11 +66,13 @@ export async function finalizeAsset(store, input) {
62
66
  const putResult = await store.putMetaIfAbsent(meta.hash32, meta);
63
67
  if (putResult === 'already-exists') {
64
68
  // Lost a race against a concurrent identical upload - the winner's meta
65
- // (already committed) is authoritative; return it rather than ours.
69
+ // (already committed) is authoritative; return it rather than ours. The
70
+ // winner's own finalize stores its thumbnails.
66
71
  const winner = await store.getMeta(meta.hash32);
67
- if (winner)
68
- return { ok: true, meta: winner };
72
+ return { ok: true, meta: winner ?? meta };
69
73
  }
74
+ // After the commit point: a thumbnail is keyed by the committed meta's slug.
75
+ await storeEditorThumbnails(store, meta, data);
70
76
  return { ok: true, meta };
71
77
  }
72
78
  /**
@@ -6,7 +6,7 @@
6
6
  * before that build is released) and canopycms-cdk's lazy transform Lambda.
7
7
  */
8
8
  import { type ParsedTransformPath } from './transform-directives.js';
9
- import type { AssetStore, CreateOnlyResult } from './types.js';
9
+ import type { AssetMeta, AssetStore, CreateOnlyResult } from './types.js';
10
10
  /** Every transform output is stored under a content-addressed key, so it never changes. */
11
11
  export declare const TRANSFORM_CACHE_CONTROL = "public, max-age=31536000, immutable";
12
12
  /**
@@ -39,11 +39,26 @@ export type StoreTransformResult = {
39
39
  * `canonicalizeTransformPath`, so the stored pixels always match their key. A rejection is a fact
40
40
  * about the asset or the URL and is never worth retrying; a thrown error belongs to the store or
41
41
  * the environment. A 404's `error` names what was missing, so a caller answering a browser
42
- * replaces it.
42
+ * replaces it. A caller already holding the asset's committed meta and original bytes passes them
43
+ * as `source`, and the store is not read.
43
44
  */
44
45
  export declare function storeTransform(store: AssetStore, parsed: ParsedTransformPath, key: string, options?: {
45
46
  tags?: Readonly<Record<string, string>>;
47
+ source?: {
48
+ meta: AssetMeta;
49
+ data: Uint8Array;
50
+ };
46
51
  }): Promise<StoreTransformResult>;
52
+ /**
53
+ * Store the editor thumbnails of a raster asset whose meta is committed, from `data`, its
54
+ * original's bytes. Best effort: the raw route computes a missing one on first view, so a failure
55
+ * is logged, never thrown, and the first one stops the rest, which would decode the same bytes in
56
+ * the same environment. One width at a time, so one decode is in memory. A multi-page (animated)
57
+ * source is skipped. `skipStored` HEADs each key first, for an asset that may already have them.
58
+ */
59
+ export declare function storeEditorThumbnails(store: AssetStore, meta: AssetMeta, data: Uint8Array, options?: {
60
+ skipStored?: boolean;
61
+ }): Promise<void>;
47
62
  /** One key a build references, and where it was referenced, for the failure report. */
48
63
  export interface MaterializeTarget {
49
64
  /** `assets/t/{directives}/{hash32}/{slug}.{ext}`. */
@@ -6,10 +6,12 @@
6
6
  * before that build is released) and canopycms-cdk's lazy transform Lambda.
7
7
  */
8
8
  import { getErrorMessage, isNodeError } from '../utils/error.js';
9
+ import { canopyLogWarn } from '../utils/logger.js';
9
10
  import { ASSET_PREFIXES } from './asset-prefixes.js';
10
11
  import { loadSharp } from './sharp-loader.js';
11
12
  import { applyTransform } from './transform.js';
12
- import { canonicalizeTransformPath } from './transform-directives.js';
13
+ import { EDITOR_THUMBNAIL_WIDTHS } from './editor-thumbnail-widths.js';
14
+ import { canonicalizeTransformPath, formatDirectives, } from './transform-directives.js';
13
15
  /** Every transform output is stored under a content-addressed key, so it never changes. */
14
16
  export const TRANSFORM_CACHE_CONTROL = 'public, max-age=31536000, immutable';
15
17
  /**
@@ -25,10 +27,15 @@ export const LAZY_TRANSFORM_TAG = { key: 'canopy-transform', value: 'lazy' };
25
27
  * `canonicalizeTransformPath`, so the stored pixels always match their key. A rejection is a fact
26
28
  * about the asset or the URL and is never worth retrying; a thrown error belongs to the store or
27
29
  * the environment. A 404's `error` names what was missing, so a caller answering a browser
28
- * replaces it.
30
+ * replaces it. A caller already holding the asset's committed meta and original bytes passes them
31
+ * as `source`, and the store is not read.
29
32
  */
30
33
  export async function storeTransform(store, parsed, key, options = {}) {
31
- const meta = await store.getMeta(parsed.hash32);
34
+ const { source } = options;
35
+ if (source && source.meta.hash32 !== parsed.hash32) {
36
+ throw new Error(`source is asset ${source.meta.hash32}, not ${parsed.hash32}`);
37
+ }
38
+ const meta = source?.meta ?? (await store.getMeta(parsed.hash32));
32
39
  if (!meta) {
33
40
  return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no meta in the store` };
34
41
  }
@@ -51,7 +58,9 @@ export async function storeTransform(store, parsed, key, options = {}) {
51
58
  if (requestedFormat === undefined && parsed.ext !== meta.ext) {
52
59
  return { ok: false, status: 400, error: 'Extension does not match the source format' };
53
60
  }
54
- const original = await store.readOriginal(parsed.hash32, meta.ext);
61
+ const original = source
62
+ ? { data: source.data, ext: meta.ext }
63
+ : await store.readOriginal(parsed.hash32, meta.ext);
55
64
  if (!original) {
56
65
  return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no original in the store` };
57
66
  }
@@ -68,6 +77,50 @@ export async function storeTransform(store, parsed, key, options = {}) {
68
77
  });
69
78
  return { ok: true, data: transformed.data, contentType: transformed.contentType, stored };
70
79
  }
80
+ /** The canonical keys of an asset's `EDITOR_THUMBNAIL_WIDTHS`, the keys the raw route reads them at. */
81
+ function editorThumbnailTargets(meta) {
82
+ return Object.values(EDITOR_THUMBNAIL_WIDTHS).map((width) => {
83
+ const canonical = canonicalizeTransformPath([formatDirectives({ identity: false, width }), meta.hash32, `${meta.slug}.${meta.ext}`], 'any');
84
+ if (!canonical.ok)
85
+ throw new Error(canonical.error);
86
+ return { key: `${ASSET_PREFIXES.transform}/${canonical.canonicalPath}`, parsed: canonical };
87
+ });
88
+ }
89
+ /**
90
+ * Store the editor thumbnails of a raster asset whose meta is committed, from `data`, its
91
+ * original's bytes. Best effort: the raw route computes a missing one on first view, so a failure
92
+ * is logged, never thrown, and the first one stops the rest, which would decode the same bytes in
93
+ * the same environment. One width at a time, so one decode is in memory. A multi-page (animated)
94
+ * source is skipped. `skipStored` HEADs each key first, for an asset that may already have them.
95
+ */
96
+ export async function storeEditorThumbnails(store, meta, data, options = {}) {
97
+ if (meta.kind !== 'raster')
98
+ return;
99
+ let failure;
100
+ try {
101
+ // An animated source re-encodes every frame at every width, over 15 s for a large GIF: too
102
+ // long to hold an upload for, so its first view transforms it instead. The probe decodes no
103
+ // pixels.
104
+ const sharp = await loadSharp();
105
+ if (((await sharp(data).metadata()).pages ?? 1) > 1)
106
+ return;
107
+ for (const { key, parsed } of editorThumbnailTargets(meta)) {
108
+ if (options.skipStored && (await store.hasPublicObject(key)))
109
+ continue;
110
+ const result = await storeTransform(store, parsed, key, { source: { meta, data } });
111
+ if (!result.ok) {
112
+ failure = `${key}: ${result.error}`;
113
+ break;
114
+ }
115
+ }
116
+ }
117
+ catch (err) {
118
+ failure = getErrorMessage(err);
119
+ }
120
+ if (failure !== undefined) {
121
+ canopyLogWarn(`[canopycms] could not store editor thumbnails of ${meta.hash32}: ${failure}`);
122
+ }
123
+ }
71
124
  /** The `schemaVersion` of every `MaterializeReport`; a consumer gating on the JSON checks it. */
72
125
  export const MATERIALIZE_REPORT_SCHEMA_VERSION = 1;
73
126
  /** sharp cannot load in this process, so nothing missing can be produced here. */
@@ -5,7 +5,7 @@
5
5
  * six asset prefixes, plus a caller's output prefix for `copyPublicObject`.
6
6
  */
7
7
  import { type AssetPrefixes } from './keys.js';
8
- import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, HiddenAssetMarker, ListedAssetMeta, PublicObject, StagedUploadTarget } from './types.js';
8
+ import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, HiddenAssetMarker, ListedAssetMeta, PresignedRead, PublicObject, StagedUploadTarget } from './types.js';
9
9
  export interface S3AssetStoreOptions {
10
10
  bucket: string;
11
11
  region: string;
@@ -103,8 +103,12 @@ export declare class S3AssetStore implements AssetStore {
103
103
  hasPublicObject(key: string): Promise<boolean>;
104
104
  listPublicObjectKeys(prefix: string): AsyncIterable<string>;
105
105
  private listKeys;
106
- /** HEADs first: signing is local and succeeds for a missing key too. */
107
- presignPublicObjectRead(key: string): Promise<string | null>;
106
+ /**
107
+ * HEADs first: signing is local and succeeds for a missing key too. A URL signed with temporary
108
+ * credentials dies when they expire, whatever its own expiry, so `maxAgeSeconds` also stops short
109
+ * of the credentials' `expiration` when the provider reports one.
110
+ */
111
+ presignPublicObjectRead(key: string): Promise<PresignedRead | null>;
108
112
  putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
109
113
  getMeta(hash32: string): Promise<AssetMeta | null>;
110
114
  /**
@@ -14,8 +14,15 @@ const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
14
14
  /** What `metaKey` and `hiddenKey` put after the hash32. */
15
15
  const JSON_SUFFIX = '.json';
16
16
  const PRESIGN_EXPIRY_SECONDS = 15 * 60;
17
- /** A presigned read is followed by the browser at once; it is never stored or cached. */
18
- const PRESIGNED_READ_EXPIRY_SECONDS = 5 * 60;
17
+ /**
18
+ * A presigned read is signed at the start of its window, so every read of one key inside a window
19
+ * gets the same URL, and a browser can reuse both the redirect to it and the object behind it.
20
+ */
21
+ const PRESIGNED_READ_WINDOW_SECONDS = 60 * 60;
22
+ /** Valid a full window past the window's end, so a redirect cached until then never expires first. */
23
+ const PRESIGNED_READ_EXPIRY_SECONDS = 2 * PRESIGNED_READ_WINDOW_SECONDS;
24
+ /** How long before its signing credentials expire a presigned read stops being reusable. */
25
+ const CREDENTIAL_EXPIRY_MARGIN_SECONDS = 60;
19
26
  function matchesAwsError(err, name, httpStatusCode) {
20
27
  if (!(err instanceof Error))
21
28
  return false;
@@ -248,13 +255,25 @@ export class S3AssetStore {
248
255
  }
249
256
  }
250
257
  }
251
- /** HEADs first: signing is local and succeeds for a missing key too. */
258
+ /**
259
+ * HEADs first: signing is local and succeeds for a missing key too. A URL signed with temporary
260
+ * credentials dies when they expire, whatever its own expiry, so `maxAgeSeconds` also stops short
261
+ * of the credentials' `expiration` when the provider reports one.
262
+ */
252
263
  async presignPublicObjectRead(key) {
253
264
  if (!(await this.hasPublicObject(key)))
254
265
  return null;
255
- return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), {
256
- expiresIn: PRESIGNED_READ_EXPIRY_SECONDS,
257
- });
266
+ // Read before signing: a provider refresh in between leaves the signing credentials the newer.
267
+ const { expiration } = await this.client.config.credentials();
268
+ const nowSeconds = Math.floor(Date.now() / 1000);
269
+ const windowStart = nowSeconds - (nowSeconds % PRESIGNED_READ_WINDOW_SECONDS);
270
+ const url = await getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: PRESIGNED_READ_EXPIRY_SECONDS, signingDate: new Date(windowStart * 1000) });
271
+ let maxAgeSeconds = windowStart + PRESIGNED_READ_WINDOW_SECONDS - nowSeconds;
272
+ if (expiration) {
273
+ const credentialSeconds = Math.floor(expiration.getTime() / 1000) - nowSeconds;
274
+ maxAgeSeconds = Math.min(maxAgeSeconds, credentialSeconds - CREDENTIAL_EXPIRY_MARGIN_SECONDS);
275
+ }
276
+ return { url, maxAgeSeconds: Math.max(0, maxAgeSeconds) };
258
277
  }
259
278
  async putMetaIfAbsent(hash32, meta) {
260
279
  return this.putIfAbsent({
@@ -50,6 +50,11 @@ export interface PublicObject {
50
50
  contentDisposition?: string;
51
51
  cacheControl?: string;
52
52
  }
53
+ /** A presigned GET, and how long a client may keep reusing that URL (0: not at all). */
54
+ export interface PresignedRead {
55
+ url: string;
56
+ maxAgeSeconds: number;
57
+ }
53
58
  /**
54
59
  * What a create-only write did. Every key the store writes this way is content-addressed, so no
55
60
  * correct writer ever replaces an object: `already-exists` means an object was already at the key
@@ -102,8 +107,8 @@ export interface AssetStore {
102
107
  hasPublicObject(key: string): Promise<boolean>;
103
108
  /** Every public object key under `prefix`, in the store's own pages. S3 only. */
104
109
  listPublicObjectKeys?(prefix: string): AsyncIterable<string>;
105
- /** A short-lived URL a browser can GET `key` from directly, or `null` if absent. S3 only. */
106
- presignPublicObjectRead?(key: string): Promise<string | null>;
110
+ /** A URL a browser can GET `key` from directly, or `null` if absent. S3 only. */
111
+ presignPublicObjectRead?(key: string): Promise<PresignedRead | null>;
107
112
  /**
108
113
  * Meta is create-only and never deleted: every transform not yet stored, at upload, release or
109
114
  * after a lazy output expires, is computed from it, and content anywhere may still name the file.
package/dist/cli/cli.js CHANGED
@@ -13321,6 +13321,21 @@ var init_generate_ai_content = __esm({
13321
13321
  }
13322
13322
  });
13323
13323
 
13324
+ // dist/utils/logger.js
13325
+ function canopyLogWarn3(...args) {
13326
+ active3.warn(...args);
13327
+ }
13328
+ function canopyLogError2(...args) {
13329
+ active3.error(...args);
13330
+ }
13331
+ var active3;
13332
+ var init_logger3 = __esm({
13333
+ "dist/utils/logger.js"() {
13334
+ "use strict";
13335
+ active3 = console;
13336
+ }
13337
+ });
13338
+
13324
13339
  // dist/assets/asset-prefixes.js
13325
13340
  var ASSET_PREFIXES;
13326
13341
  var init_asset_prefixes = __esm({
@@ -13339,21 +13354,6 @@ var init_asset_prefixes = __esm({
13339
13354
  }
13340
13355
  });
13341
13356
 
13342
- // dist/utils/logger.js
13343
- function canopyLogWarn3(...args) {
13344
- active3.warn(...args);
13345
- }
13346
- function canopyLogError2(...args) {
13347
- active3.error(...args);
13348
- }
13349
- var active3;
13350
- var init_logger3 = __esm({
13351
- "dist/utils/logger.js"() {
13352
- "use strict";
13353
- active3 = console;
13354
- }
13355
- });
13356
-
13357
13357
  // dist/assets/sharp-loader.js
13358
13358
  function loadSharp() {
13359
13359
  if (!loading) {
@@ -13723,7 +13723,11 @@ var init_transform = __esm({
13723
13723
 
13724
13724
  // dist/assets/materialize.js
13725
13725
  async function storeTransform(store, parsed, key, options = {}) {
13726
- const meta = await store.getMeta(parsed.hash32);
13726
+ const { source } = options;
13727
+ if (source && source.meta.hash32 !== parsed.hash32) {
13728
+ throw new Error(`source is asset ${source.meta.hash32}, not ${parsed.hash32}`);
13729
+ }
13730
+ const meta = source?.meta ?? await store.getMeta(parsed.hash32);
13727
13731
  if (!meta) {
13728
13732
  return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no meta in the store` };
13729
13733
  }
@@ -13741,7 +13745,7 @@ async function storeTransform(store, parsed, key, options = {}) {
13741
13745
  if (requestedFormat === void 0 && parsed.ext !== meta.ext) {
13742
13746
  return { ok: false, status: 400, error: "Extension does not match the source format" };
13743
13747
  }
13744
- const original = await store.readOriginal(parsed.hash32, meta.ext);
13748
+ const original = source ? { data: source.data, ext: meta.ext } : await store.readOriginal(parsed.hash32, meta.ext);
13745
13749
  if (!original) {
13746
13750
  return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no original in the store` };
13747
13751
  }
@@ -14144,7 +14148,7 @@ function matchesAwsError(err2, name, httpStatusCode) {
14144
14148
  const shaped = err2;
14145
14149
  return shaped.name === name || shaped.$metadata?.httpStatusCode === httpStatusCode;
14146
14150
  }
14147
- var DEFAULT_MAX_UPLOAD_BYTES, JSON_SUFFIX, PRESIGN_EXPIRY_SECONDS, PRESIGNED_READ_EXPIRY_SECONDS, isPreconditionFailed, isConditionalRequestConflict, CONFLICT_ATTEMPTS, CONFLICT_BASE_DELAY_MS, isNoSuchKey, S3AssetStore;
14151
+ var DEFAULT_MAX_UPLOAD_BYTES, JSON_SUFFIX, PRESIGN_EXPIRY_SECONDS, PRESIGNED_READ_WINDOW_SECONDS, PRESIGNED_READ_EXPIRY_SECONDS, CREDENTIAL_EXPIRY_MARGIN_SECONDS, isPreconditionFailed, isConditionalRequestConflict, CONFLICT_ATTEMPTS, CONFLICT_BASE_DELAY_MS, isNoSuchKey, S3AssetStore;
14148
14152
  var init_store_s3 = __esm({
14149
14153
  "dist/assets/store-s3.js"() {
14150
14154
  "use strict";
@@ -14153,7 +14157,9 @@ var init_store_s3 = __esm({
14153
14157
  DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
14154
14158
  JSON_SUFFIX = ".json";
14155
14159
  PRESIGN_EXPIRY_SECONDS = 15 * 60;
14156
- PRESIGNED_READ_EXPIRY_SECONDS = 5 * 60;
14160
+ PRESIGNED_READ_WINDOW_SECONDS = 60 * 60;
14161
+ PRESIGNED_READ_EXPIRY_SECONDS = 2 * PRESIGNED_READ_WINDOW_SECONDS;
14162
+ CREDENTIAL_EXPIRY_MARGIN_SECONDS = 60;
14157
14163
  isPreconditionFailed = (err2) => matchesAwsError(err2, "PreconditionFailed", 412);
14158
14164
  isConditionalRequestConflict = (err2) => err2 instanceof Error && err2.name === "ConditionalRequestConflict";
14159
14165
  CONFLICT_ATTEMPTS = 4;
@@ -14358,13 +14364,24 @@ var init_store_s3 = __esm({
14358
14364
  }
14359
14365
  }
14360
14366
  }
14361
- /** HEADs first: signing is local and succeeds for a missing key too. */
14367
+ /**
14368
+ * HEADs first: signing is local and succeeds for a missing key too. A URL signed with temporary
14369
+ * credentials dies when they expire, whatever its own expiry, so `maxAgeSeconds` also stops short
14370
+ * of the credentials' `expiration` when the provider reports one.
14371
+ */
14362
14372
  async presignPublicObjectRead(key) {
14363
14373
  if (!await this.hasPublicObject(key))
14364
14374
  return null;
14365
- return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), {
14366
- expiresIn: PRESIGNED_READ_EXPIRY_SECONDS
14367
- });
14375
+ const { expiration } = await this.client.config.credentials();
14376
+ const nowSeconds = Math.floor(Date.now() / 1e3);
14377
+ const windowStart = nowSeconds - nowSeconds % PRESIGNED_READ_WINDOW_SECONDS;
14378
+ const url = await getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: PRESIGNED_READ_EXPIRY_SECONDS, signingDate: new Date(windowStart * 1e3) });
14379
+ let maxAgeSeconds = windowStart + PRESIGNED_READ_WINDOW_SECONDS - nowSeconds;
14380
+ if (expiration) {
14381
+ const credentialSeconds = Math.floor(expiration.getTime() / 1e3) - nowSeconds;
14382
+ maxAgeSeconds = Math.min(maxAgeSeconds, credentialSeconds - CREDENTIAL_EXPIRY_MARGIN_SECONDS);
14383
+ }
14384
+ return { url, maxAgeSeconds: Math.max(0, maxAgeSeconds) };
14368
14385
  }
14369
14386
  async putMetaIfAbsent(hash32, meta) {
14370
14387
  return this.putIfAbsent({
@@ -144,8 +144,9 @@ export interface ReferenceFieldConfig extends BaseFieldConfig {
144
144
  export interface ImageFieldConfig extends BaseFieldConfig {
145
145
  type: 'image';
146
146
  /**
147
- * "W:H" aspect ratio (e.g. "16:9", "1:1") that triggers a crop step in the
148
- * editor. Validated at config-parse time: positive integers on both sides.
147
+ * "W:H" aspect ratio (e.g. "16:9", "1:1"). Picking an image opens a crop step locked to it,
148
+ * and Crop keeps that shape. Without it, a pick isn't cropped and Crop is free-form.
149
+ * Validated at config-parse time: positive integers on both sides.
149
150
  */
150
151
  aspect?: string;
151
152
  /** Allow an empty `alt`. Default: alt text is required (accessibility). */
@@ -194,7 +194,14 @@ export function startDevContentWatcher(services, options = {}) {
194
194
  }
195
195
  }
196
196
  };
197
- const watcher = chokidar.watch(workingTreeContentDir, { ignoreInitial: true });
197
+ // A save can truncate and then write. chokidar's 50 ms per-path throttle passes only the first
198
+ // `change`, which can land between the two steps: a check then reads a half-written file and
199
+ // reports a divergence nothing retracts. `awaitWriteFinish` emits only once the file's size has
200
+ // held still, and bypasses that throttle.
201
+ const watcher = chokidar.watch(workingTreeContentDir, {
202
+ ignoreInitial: true,
203
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 25 },
204
+ });
198
205
  watcher.on('add', () => void check());
199
206
  watcher.on('change', () => void check());
200
207
  watcher.on('unlink', () => void check());
@@ -9,6 +9,8 @@ const noop = () => { };
9
9
  import { EntryNavigator } from './EntryNavigator.js';
10
10
  import { FormRenderer } from './FormRenderer.js';
11
11
  import { PreviewPane } from './PreviewPane.js';
12
+ import { displayPreviewPath, previewPageKey } from './preview-path.js';
13
+ import { showPreviewLinkOutcome } from './components/PreviewLinkToast.js';
12
14
  import { clientOperatingStrategy } from '../operating-mode/client.js';
13
15
  import { EditorShell } from './shell/EditorShell.js';
14
16
  import { PaneLayout } from './shell/PaneLayout.js';
@@ -31,7 +33,7 @@ import { EntrySchemaUnavailableError } from './hooks/useEntryManager.js';
31
33
  import { useEntryLinkResolution } from './hooks/useEntryLinkResolution.js';
32
34
  import { useReferenceResolution } from './hooks/useReferenceResolution.js';
33
35
  import { usePreviewMarks } from './hooks/usePreviewMarks.js';
34
- import { BranchLockBanner, EditorHeader, EditorRail } from './components/index.js';
36
+ import { BranchLockBanner, describeBranchLock, EditorHeader, EditorRail } from './components/index.js';
35
37
  import { showSaveAllResult } from './components/SaveAllResult.js';
36
38
  import { RenameEntryModal } from './components/RenameEntryModal.js';
37
39
  import { QuickOpenPalette } from './components/QuickOpenPalette.js';
@@ -60,6 +62,7 @@ import { findFieldTarget, focusFieldTarget } from './field-target.js';
60
62
  import { UndoToastMessage } from './components/UndoToastMessage.js';
61
63
  import { getNotificationDuration } from './utils/env.js';
62
64
  import { MayHaveLandedError } from './unanswered.js';
65
+ import { sanitizeBranchName } from '../paths/branch-name.js';
63
66
  // Stable fallbacks, so reference resolution's memo and effect don't re-run on every render
64
67
  // while no entry is open.
65
68
  const EMPTY_SCHEMA = [];
@@ -155,7 +158,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
155
158
  labels: [],
156
159
  }));
157
160
  const applySubmitStampsRef = useRef(() => { });
158
- const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
161
+ const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
159
162
  initialBranch: branchName,
160
163
  operatingMode,
161
164
  setBusy: setBranchesLoading,
@@ -176,6 +179,12 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
176
179
  // of unlocked that invites a click the server rejects -- and the pane shows a
177
180
  // loading state during that window anyway.
178
181
  const branchContentLocked = currentBranch?.writeBlocked ?? true;
182
+ const branchLoading = !currentBranch && (branchesLoading || branchSummaries.length === 0);
183
+ // A row's lock reason on an unanswered branch would be "details could not be loaded", which is
184
+ // not yet true; once the fetch has failed it is, and the lock says so.
185
+ const branchRowsPending = branchLoading && !branchesLoadFailed;
186
+ // Set once the branch actions below exist; they depend on the drafts, which depend on the entries.
187
+ const switchBranchRef = useRef(async () => { });
179
188
  // 2. Entry manager (depends on branchNameState, owns selectedPath)
180
189
  const { selectedPath, setSelectedPath, closeEntry, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, entriesLoadFailed, navigatorOpen, setNavigatorOpen, refreshEntries, refreshContent, handleCreateEntry, renameEntry, loadEntry, readEntryValue, saveEntry, getEntryVersion, adoptEntryVersion, followMovedEntry, recreateEntry, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
181
190
  initialEntries: entries,
@@ -191,6 +200,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
191
200
  }),
192
201
  resolvePreviewRoute: (entry) => buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
193
202
  setBusy: setEntriesLoading,
203
+ switchBranch: (branch) => switchBranchRef.current(branch),
204
+ // To the entry menu, which names the entry opened; a dialog keeps focus.
205
+ onHistoryRestore: () => requestAnimationFrame(() => {
206
+ if (!document.activeElement?.closest('[role="dialog"]')) {
207
+ navigatorToggleRef.current?.focus({ preventScroll: true });
208
+ }
209
+ }),
194
210
  });
195
211
  // The content overlay and a maximized preview hide the form; `navigatorOpen` is only the
196
212
  // overlay's. Anything that focuses something in the form reveals it first. Opening an entry
@@ -205,6 +221,26 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
205
221
  setSelectedPath(path);
206
222
  setNavigatorOpen(false);
207
223
  };
224
+ // A preview link opens its entry, or a page no entry has in a new tab. The opener is cut by hand,
225
+ // since `noopener` would hide whether the browser blocked the tab.
226
+ const openPreviewLink = (href) => {
227
+ const mount = { basePath, previewPrefix };
228
+ const base = window.location.href;
229
+ const page = previewPageKey(href, mount, base);
230
+ const entry = page === undefined
231
+ ? undefined
232
+ : entriesState.find((e) => e.previewSrc && previewPageKey(e.previewSrc, mount, base) === page);
233
+ if (entry) {
234
+ openEntry(entry.path);
235
+ return;
236
+ }
237
+ const opened = window.open(href, '_blank');
238
+ if (opened)
239
+ opened.opener = null;
240
+ const url = new URL(href);
241
+ const shown = url.origin === window.location.origin ? `${url.pathname}${url.search}` : href;
242
+ showPreviewLinkOutcome(href, displayPreviewPath(shown, mount), Boolean(opened));
243
+ };
208
244
  // An open overlay docks open, as the URL's ask for no entry does at load; `navigatorOpen` is only
209
245
  // the overlay's, so a docked panel never resurfaces as one. Waits for the stored preferences, so
210
246
  // the open state it sets lands on them, not on the defaults they replace.
@@ -270,6 +306,17 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
270
306
  onBranchCreated: addCreatedBranch,
271
307
  userId: userContext?.userId,
272
308
  });
309
+ switchBranchRef.current = async (branch) => {
310
+ const listed = (name) => name === branch || name === sanitizeBranchName(branch);
311
+ if (branchSummaries.length > 0 && !branchSummaries.some((b) => listed(b.name))) {
312
+ notifications.show({
313
+ message: `Branch “${branch}” no longer exists or you can no longer open it, so you're still on “${branchNameState}”.`,
314
+ color: 'yellow',
315
+ });
316
+ throw new Error(`Branch ${branch} is not listed`);
317
+ }
318
+ await handleBranchChange(branch);
319
+ };
273
320
  // 5. Comment system (depends on branchNameState)
274
321
  const { comments, getCommentUser, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, } = useCommentSystem({
275
322
  branchName: branchNameState,
@@ -859,8 +906,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
859
906
  setDeletingEntryReferencedBy(result.referencedBy);
860
907
  return;
861
908
  }
862
- if (result.ok && selectedPath === deletingEntryPath)
863
- closeEntry();
909
+ if (result.ok)
910
+ closeEntry(deletingEntryPath, branchNameState);
864
911
  closeDeleteEntryModal();
865
912
  }
866
913
  finally {
@@ -1208,7 +1255,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1208
1255
  if (maximized && !contentPanelDocked)
1209
1256
  setNavigatorOpen(false);
1210
1257
  setPreviewMaximized(maximized);
1211
- }, resizing: paneResizing, siteChrome: siteChrome, onSiteChromeChange: setSiteChrome, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
1258
+ }, resizing: paneResizing, siteChrome: siteChrome, onSiteChromeChange: setSiteChrome, onNavigate: openPreviewLink, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
1212
1259
  ? `${previewErrorField}: ${previewError.message}`
1213
1260
  : previewError.message })) })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "var(--mantine-color-body)", style: {
1214
1261
  display: 'flex',
@@ -1266,7 +1313,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1266
1313
  missing: e.status === 'missing' || undefined,
1267
1314
  noPage: !e.previewSrc || undefined,
1268
1315
  readOnly: e.canEdit === false || undefined,
1269
- })), selectedPath: selectedPath, onSelect: openEntry, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: userIsAdmin ? handleReorderEntry : undefined, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked, dirtyPaths: dirtyPaths, revealCollection: collectionReveal, onRevealed: () => setCollectionReveal(undefined) }) }) }));
1316
+ })), selectedPath: selectedPath, onSelect: openEntry, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: userIsAdmin ? handleReorderEntry : undefined, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked, readOnlyReason: branchRowsPending
1317
+ ? undefined
1318
+ : describeBranchLock({
1319
+ branchStatus: currentBranch?.status,
1320
+ branchReadOnly: currentBranch?.readOnly,
1321
+ branchWriteBlocked: branchContentLocked,
1322
+ }), dirtyPaths: dirtyPaths, revealCollection: collectionReveal, onRevealed: () => setCollectionReveal(undefined) }) }) }));
1270
1323
  return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { currentEntry: currentEntry, branchName: branchNameState, operatingMode: operatingMode, branchStatus: currentBranch?.status, busy: busy, breadcrumbSegments: breadcrumbSegments, editedFiles: editedFiles, modifiedCount: modifiedCount,
1271
1324
  // A draft kept for an unavailable entry stays in storage but cannot be saved.
1272
1325
  hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
@@ -1279,7 +1332,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1279
1332
  // fail-closed computation of "are writes blocked" in this component.
1280
1333
  branchIsProtected: currentBranch?.isProtected ?? true, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked,
1281
1334
  // A failed fetch leaves the list empty too; the banner says that one.
1282
- branchLoading: !currentBranch && (branchesLoading || branchSummaries.length === 0), onCollectionOpen: revealCollection, navigatorToggleRef: navigatorToggleRef, branchToggleRef: branchToggleRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), entryUnavailable: currentEntryUnavailable, onEntrySelect: openEntry, onBranchReloadData: () => {
1335
+ branchLoading: branchLoading, onCollectionOpen: revealCollection, navigatorToggleRef: navigatorToggleRef, branchToggleRef: branchToggleRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), entryUnavailable: currentEntryUnavailable, onEntrySelect: openEntry, onBranchReloadData: () => {
1283
1336
  void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
1284
1337
  }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onSave: currentEntryUnavailable ? noop : handleSave, onSaveAll: runSaveAll, saveAllProgress: saveAllProgress, saving: singleSaving?.branch === branchNameState &&
1285
1338
  singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
@@ -62,12 +62,17 @@ export interface EntryNavigatorProps {
62
62
  /** When true and the tree is empty, show a loading indicator instead of "No content" */
63
63
  loading?: boolean;
64
64
  /**
65
- * When true, hides all entry/collection mutation affordances (create,
66
- * rename, delete, reorder, add/edit collection) -- selection/navigation
67
- * stays enabled. Set on a read-only protected base branch, where writes
68
- * would only 403 after the fact. Default false.
65
+ * When true, replaces each row's mutation menu (create, rename, delete, reorder, add/edit
66
+ * collection) with a lock -- selection/navigation stays enabled. Set on a branch that takes no
67
+ * writes, where they would only 403 after the fact. An entry the user may not edit keeps its own
68
+ * lock and gets none here. Default false.
69
69
  */
70
70
  readOnly?: boolean;
71
+ /**
72
+ * Why the branch takes no writes (`describeBranchLock`), the tooltip of each row's lock. While
73
+ * readOnly without one, the lock's slot stays empty.
74
+ */
75
+ readOnlyReason?: string;
71
76
  /** Entry paths with unsaved changes; each shows a dot, and so does every collection holding one. */
72
77
  dirtyPaths?: ReadonlySet<string>;
73
78
  /**