canopycms 0.2.0-int.119 → 0.2.0-int.121

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 (50) hide show
  1. package/CHANGELOG.md +102 -46
  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/editor/Editor.js +37 -5
  15. package/dist/editor/EntryNavigator.d.ts +9 -4
  16. package/dist/editor/EntryNavigator.js +36 -16
  17. package/dist/editor/PreviewFrame.d.ts +3 -1
  18. package/dist/editor/PreviewFrame.js +43 -2
  19. package/dist/editor/PreviewPane.d.ts +2 -0
  20. package/dist/editor/components/BranchLockBanner.d.ts +7 -8
  21. package/dist/editor/components/BranchLockBanner.js +25 -2
  22. package/dist/editor/components/BranchRowLock.d.ts +12 -0
  23. package/dist/editor/components/BranchRowLock.js +19 -0
  24. package/dist/editor/components/EditorHeader.js +3 -18
  25. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  26. package/dist/editor/components/PreviewLinkToast.js +33 -0
  27. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  28. package/dist/editor/fields/FieldLabel.js +1 -1
  29. package/dist/editor/fields/ImageField.d.ts +1 -1
  30. package/dist/editor/fields/ImageField.js +36 -27
  31. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  32. package/dist/editor/hooks/useBranchManager.js +1 -0
  33. package/dist/editor/media/AssetCard.js +4 -2
  34. package/dist/editor/media/AssetListRow.js +4 -2
  35. package/dist/editor/media/CropStep.d.ts +8 -10
  36. package/dist/editor/media/CropStep.js +39 -27
  37. package/dist/editor/media/crop-availability.d.ts +6 -0
  38. package/dist/editor/media/crop-availability.js +19 -0
  39. package/dist/editor/media/crop-math.d.ts +19 -13
  40. package/dist/editor/media/crop-math.js +50 -11
  41. package/dist/editor/preview-bridge.d.ts +7 -0
  42. package/dist/editor/preview-bridge.js +93 -1
  43. package/dist/editor/preview-path.d.ts +5 -0
  44. package/dist/editor/preview-path.js +18 -0
  45. package/dist/editor/theme.d.ts +1 -0
  46. package/dist/editor/theme.js +17 -1
  47. package/dist/server.d.ts +2 -0
  48. package/dist/version.d.ts +1 -1
  49. package/dist/version.js +1 -1
  50. package/package.json +2 -2
@@ -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). */
@@ -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';
@@ -155,7 +157,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
155
157
  labels: [],
156
158
  }));
157
159
  const applySubmitStampsRef = useRef(() => { });
158
- const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
160
+ const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
159
161
  initialBranch: branchName,
160
162
  operatingMode,
161
163
  setBusy: setBranchesLoading,
@@ -176,6 +178,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
176
178
  // of unlocked that invites a click the server rejects -- and the pane shows a
177
179
  // loading state during that window anyway.
178
180
  const branchContentLocked = currentBranch?.writeBlocked ?? true;
181
+ const branchLoading = !currentBranch && (branchesLoading || branchSummaries.length === 0);
182
+ // A row's lock reason on an unanswered branch would be "details could not be loaded", which is
183
+ // not yet true; once the fetch has failed it is, and the lock says so.
184
+ const branchRowsPending = branchLoading && !branchesLoadFailed;
179
185
  // 2. Entry manager (depends on branchNameState, owns selectedPath)
180
186
  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
187
  initialEntries: entries,
@@ -205,6 +211,26 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
205
211
  setSelectedPath(path);
206
212
  setNavigatorOpen(false);
207
213
  };
214
+ // A preview link opens its entry, or a page no entry has in a new tab. The opener is cut by hand,
215
+ // since `noopener` would hide whether the browser blocked the tab.
216
+ const openPreviewLink = (href) => {
217
+ const mount = { basePath, previewPrefix };
218
+ const base = window.location.href;
219
+ const page = previewPageKey(href, mount, base);
220
+ const entry = page === undefined
221
+ ? undefined
222
+ : entriesState.find((e) => e.previewSrc && previewPageKey(e.previewSrc, mount, base) === page);
223
+ if (entry) {
224
+ openEntry(entry.path);
225
+ return;
226
+ }
227
+ const opened = window.open(href, '_blank');
228
+ if (opened)
229
+ opened.opener = null;
230
+ const url = new URL(href);
231
+ const shown = url.origin === window.location.origin ? `${url.pathname}${url.search}` : href;
232
+ showPreviewLinkOutcome(href, displayPreviewPath(shown, mount), Boolean(opened));
233
+ };
208
234
  // An open overlay docks open, as the URL's ask for no entry does at load; `navigatorOpen` is only
209
235
  // the overlay's, so a docked panel never resurfaces as one. Waits for the stored preferences, so
210
236
  // the open state it sets lands on them, not on the defaults they replace.
@@ -1208,7 +1234,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1208
1234
  if (maximized && !contentPanelDocked)
1209
1235
  setNavigatorOpen(false);
1210
1236
  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
1237
+ }, 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
1238
  ? `${previewErrorField}: ${previewError.message}`
1213
1239
  : previewError.message })) })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "var(--mantine-color-body)", style: {
1214
1240
  display: 'flex',
@@ -1266,7 +1292,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1266
1292
  missing: e.status === 'missing' || undefined,
1267
1293
  noPage: !e.previewSrc || undefined,
1268
1294
  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) }) }) }));
1295
+ })), 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
1296
+ ? undefined
1297
+ : describeBranchLock({
1298
+ branchStatus: currentBranch?.status,
1299
+ branchReadOnly: currentBranch?.readOnly,
1300
+ branchWriteBlocked: branchContentLocked,
1301
+ }), dirtyPaths: dirtyPaths, revealCollection: collectionReveal, onRevealed: () => setCollectionReveal(undefined) }) }) }));
1270
1302
  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
1303
  // A draft kept for an unavailable entry stays in storage but cannot be saved.
1272
1304
  hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
@@ -1279,7 +1311,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1279
1311
  // fail-closed computation of "are writes blocked" in this component.
1280
1312
  branchIsProtected: currentBranch?.isProtected ?? true, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked,
1281
1313
  // 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: () => {
1314
+ 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
1315
  void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
1284
1316
  }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onSave: currentEntryUnavailable ? noop : handleSave, onSaveAll: runSaveAll, saveAllProgress: saveAllProgress, saving: singleSaving?.branch === branchNameState &&
1285
1317
  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
  /**
@@ -8,6 +8,7 @@ import { matchesEntryQuery } from './entry-search.js';
8
8
  import { EDITOR_ACTIONS } from './copy.js';
9
9
  import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
10
10
  import { UnsavedMark } from './components/UnsavedMark.js';
11
+ import { BranchRowLock } from './components/BranchRowLock.js';
11
12
  import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
12
13
  import { UnavailableTypeMessage } from './components/UnavailableTypeMessage.js';
13
14
  const UnavailableTypeRow = ({ schemaRefs }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", align: "flex-start", mt: 4, "data-testid": "unavailable-type-message", children: [_jsx(Box, { c: "orange", style: { display: 'flex', flexShrink: 0, paddingTop: 2 }, children: _jsx(IconAlertTriangle, { size: 14, "aria-hidden": true }) }), _jsx(Text, { size: "xs", c: "orange", children: _jsx(UnavailableTypeMessage, { schemaRefs: schemaRefs }) })] }));
@@ -123,7 +124,7 @@ const stopTreeKeys = (event) => {
123
124
  event.stopPropagation();
124
125
  };
125
126
  const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: true, children: _jsx(UnsavedMark, { role: "img", "aria-label": label, "data-testid": "unsaved-dot" }) }));
126
- export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, dirtyPaths, revealCollection, onRevealed, }) => {
127
+ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, readOnlyReason, dirtyPaths, revealCollection, onRevealed, }) => {
127
128
  // Shadow the entry-mutation callbacks with undefined when readOnly, so every
128
129
  // downstream reference (hasEntryMenu, canReorder, the menu items themselves)
129
130
  // sees them as absent rather than threading a readOnly check through each
@@ -476,15 +477,16 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
476
477
  const status = node.nodeProps?.status;
477
478
  const entryMissing = !!node.nodeProps?.missing;
478
479
  const entryNoPage = !!node.nodeProps?.noPage;
479
- // Collection-level mutation callbacks are nulled when readOnly, same as
480
- // the entry-level ones above -- this removes the collection menu button
481
- // entirely (hasCollectionMenu below) rather than leaving it enabled.
482
- const onAdd = readOnly ? undefined : node.nodeProps?.onAdd;
483
- const onEdit = readOnly ? undefined : node.nodeProps?.onEdit;
484
- const onAddSubCollection = readOnly
485
- ? undefined
486
- : node.nodeProps?.onAddSubCollection;
487
- const onDelete = readOnly ? undefined : node.nodeProps?.onDelete;
480
+ // Collection-level mutation callbacks are nulled when readOnly, same as the entry-level ones
481
+ // above, so hasCollectionMenu is false; a row that would have had a menu shows its lock.
482
+ const rawOnAdd = node.nodeProps?.onAdd;
483
+ const rawOnEdit = node.nodeProps?.onEdit;
484
+ const rawOnAddSubCollection = node.nodeProps?.onAddSubCollection;
485
+ const rawOnDelete = node.nodeProps?.onDelete;
486
+ const onAdd = readOnly ? undefined : rawOnAdd;
487
+ const onEdit = readOnly ? undefined : rawOnEdit;
488
+ const onAddSubCollection = readOnly ? undefined : rawOnAddSubCollection;
489
+ const onDelete = readOnly ? undefined : rawOnDelete;
488
490
  const entryPath = node.nodeProps?.entryPath;
489
491
  const contentId = node.nodeProps?.contentId;
490
492
  const parentCollectionPath = node.nodeProps?.parentCollectionPath;
@@ -494,7 +496,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
494
496
  const isEntry = node.nodeProps?.isEntry;
495
497
  const conflictNotice = node.nodeProps?.conflictNotice;
496
498
  const unavailableSchemaRefs = node.nodeProps?.unavailableSchemaRefs;
497
- // A read-only navigator already says so once, in the branch banner.
499
+ // A read-only navigator already says writes are refused, so the add-refused line is not repeated.
498
500
  const addRefused = !readOnly && !!node.nodeProps?.addRefused;
499
501
  const isLeaf = !hasChildren || isEntry;
500
502
  const selected = node.value === selectedPath;
@@ -504,21 +506,36 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
504
506
  // Mantine only provides hasChildren=true if children.length > 0, but we want
505
507
  // collections to always be expandable
506
508
  const showChevron = hasChildren || Boolean(isCollection);
507
- const canReorder = onReorderEntry && contentId && parentCollectionPath && typeof childIndex === 'number';
509
+ const reorderPosition = contentId && parentCollectionPath && typeof childIndex === 'number';
510
+ const canReorder = onReorderEntry && reorderPosition;
508
511
  const canMoveUp = canReorder && childIndex > 0;
509
512
  const canMoveDown = canReorder && typeof totalChildrenCount === 'number' && childIndex < totalChildrenCount - 1;
510
513
  // Collections show menu for add/edit/delete actions OR for reordering (subcollections)
511
514
  const hasCollectionMenu = isCollection && (onAdd || onEdit || onAddSubCollection || onDelete || canReorder);
512
515
  // An unavailable entry cannot be written, so its menu (rename, delete, reorder) is not offered.
513
516
  const entryUnavailable = node.nodeProps?.unavailable;
514
- // The same holds for an entry this user may not edit. A read-only navigator already says so
515
- // once, in the branch banner, so only a lone read-only entry gets its own lock.
516
- const entryLocked = !readOnly && !!node.nodeProps?.itemReadOnly;
517
+ // The same holds for an entry this user may not edit, on any branch: its own lock stays, since a
518
+ // path rule, not the branch, refuses it, and the branch lock leaves that row's slot empty.
519
+ const entryLocked = !!node.nodeProps?.itemReadOnly;
517
520
  const hasEntryMenu = isEntry &&
518
521
  entryPath &&
519
522
  !entryUnavailable &&
520
523
  !entryLocked &&
521
524
  (onDeleteEntry || onRenameEntry || onReorderEntry);
525
+ // The lock stands where the menu would, on exactly the rows that would otherwise have one. It
526
+ // is always shown: on hover alone, touch and keyboard users would see neither menu nor reason.
527
+ const wouldHaveCollectionMenu = isCollection &&
528
+ !!(rawOnAdd ||
529
+ rawOnEdit ||
530
+ rawOnAddSubCollection ||
531
+ rawOnDelete ||
532
+ (onReorderEntryProp && reorderPosition));
533
+ const wouldHaveEntryMenu = isEntry &&
534
+ !!entryPath &&
535
+ !entryUnavailable &&
536
+ !entryLocked &&
537
+ !!(onDeleteEntryProp || onRenameEntryProp || onReorderEntryProp);
538
+ const showsLockSlot = readOnly && (wouldHaveCollectionMenu || wouldHaveEntryMenu);
522
539
  const showEntryDirtyDot = isEntry && !!entryPath && !!dirtyPaths?.has(entryPath);
523
540
  const showCollectionDirtyDot = !isEntry && dirtyCollections.has(node.value);
524
541
  const adminItems = onAddSubCollection || onEdit || onDelete;
@@ -566,7 +583,10 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
566
583
  }, children: "Edit collection\u2026" })), onDelete && (_jsx(DestructiveMenuItem, { leftSection: _jsx(IconTrash, { size: 14 }), onClick: (event) => {
567
584
  event.stopPropagation();
568
585
  onDelete();
569
- }, children: "Delete collection" }))] })] })), hasEntryMenu && (_jsxs(Menu, { shadow: "md", width: 180, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Entry actions", "data-testid": `entry-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onRenameEntry && (_jsxs(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
586
+ }, children: "Delete collection" }))] })] })), showsLockSlot &&
587
+ (readOnlyReason ? (_jsx(BranchRowLock, { label: isCollection ? 'Collection actions' : 'Entry actions', reason: readOnlyReason })) : (
588
+ // The branch's state is still loading: hold the slot so rows do not shift.
589
+ _jsx(Box, { w: 18, h: 18, "aria-hidden": true, "data-testid": "branch-lock-pending" }))), hasEntryMenu && (_jsxs(Menu, { shadow: "md", width: 180, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Entry actions", "data-testid": `entry-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onRenameEntry && (_jsxs(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
570
590
  event.stopPropagation();
571
591
  onRenameEntry(entryPath);
572
592
  }, "data-testid": "rename-entry-menu-item", children: [entryNoPage ? EDITOR_ACTIONS.changeSlug : EDITOR_ACTIONS.changeUrl, "\u2026"] })), onReorderEntry && contentId && parentCollectionPath && (_jsxs(_Fragment, { children: [_jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowUp, { size: 14 }), blockedReason: canMoveUp ? undefined : 'Already first', onClick: (event) => {
@@ -15,7 +15,7 @@ export type SiteChromeSupport = 'supported' | 'unsupported' | 'unanswered';
15
15
  * runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
16
16
  * to reload the preview. While `busy`, the page stays in view, dimmed and inert, under the bar.
17
17
  */
18
- export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, busy, }: {
18
+ export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, busy, }: {
19
19
  src: string;
20
20
  path: string;
21
21
  data?: unknown;
@@ -44,6 +44,8 @@ export declare const PreviewFrame: ({ src, path, data, isLoading, className, sty
44
44
  onReload?: () => void;
45
45
  /** Called with what the page's handshake says of the site's header and footer. */
46
46
  onSiteChrome?: (support: SiteChromeSupport) => void;
47
+ /** A preview link's absolute URL, unfollowed; focus follows into the next page unless moved. */
48
+ onNavigate?: (href: string) => void;
47
49
  /** The editor is loading the next page's data: this page stays in view, dimmed and inert. */
48
50
  busy?: boolean;
49
51
  }) => import("react/jsx-runtime").JSX.Element;