canopycms 0.0.67 → 0.0.68-int.94

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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +12 -7
  4. package/dist/api/admin.d.ts +21 -9
  5. package/dist/api/admin.js +18 -2
  6. package/dist/api/branch-status.js +19 -4
  7. package/dist/api/branch.js +11 -11
  8. package/dist/api/client.d.ts +12 -0
  9. package/dist/api/client.js +34 -11
  10. package/dist/api/content.d.ts +6 -5
  11. package/dist/api/content.js +44 -41
  12. package/dist/api/entries.js +16 -15
  13. package/dist/api/github-sync.d.ts +10 -1
  14. package/dist/api/github-sync.js +20 -10
  15. package/dist/api/reference-options.js +1 -1
  16. package/dist/api/resolve-references.js +14 -40
  17. package/dist/api/settings-helpers.d.ts +1 -1
  18. package/dist/api/settings-helpers.js +1 -4
  19. package/dist/api/user.d.ts +3 -0
  20. package/dist/api/user.js +3 -0
  21. package/dist/assets/factory.d.ts +5 -0
  22. package/dist/assets/factory.js +5 -0
  23. package/dist/authorization/content.d.ts +5 -4
  24. package/dist/authorization/content.js +5 -5
  25. package/dist/authorization/path.d.ts +11 -6
  26. package/dist/authorization/path.js +11 -6
  27. package/dist/authorization/types.d.ts +2 -2
  28. package/dist/branch-health.js +3 -3
  29. package/dist/branch-schema-cache.d.ts +5 -3
  30. package/dist/branch-schema-cache.js +29 -11
  31. package/dist/branch-workspace.js +8 -3
  32. package/dist/build-identity.d.ts +3 -0
  33. package/dist/build-identity.js +13 -0
  34. package/dist/cli/cli.js +1070 -388
  35. package/dist/cli/generate-ai-content.js +537 -150
  36. package/dist/cli/github-app-manifest.d.ts +4 -3
  37. package/dist/cli/github-app-manifest.js +4 -3
  38. package/dist/cli/init.js +21 -11
  39. package/dist/cli/migrate.js +12 -8
  40. package/dist/cli/sync.js +83 -37
  41. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  42. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  43. package/dist/cli/template-files/cms-stack.ts.template +6 -0
  44. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  45. package/dist/config/schemas/config.d.ts +11 -3
  46. package/dist/config/schemas/config.js +5 -1
  47. package/dist/config/schemas/field.js +1 -0
  48. package/dist/config/schemas/url.d.ts +2 -0
  49. package/dist/config/schemas/url.js +7 -0
  50. package/dist/config/types.d.ts +28 -4
  51. package/dist/config/validation.d.ts +6 -0
  52. package/dist/config/validation.js +37 -0
  53. package/dist/content-listing.d.ts +12 -12
  54. package/dist/content-listing.js +10 -6
  55. package/dist/content-reader.d.ts +11 -2
  56. package/dist/content-reader.js +22 -14
  57. package/dist/content-store.d.ts +37 -9
  58. package/dist/content-store.js +70 -43
  59. package/dist/content-tree.d.ts +1 -1
  60. package/dist/content-tree.js +2 -2
  61. package/dist/context.d.ts +27 -9
  62. package/dist/context.js +154 -70
  63. package/dist/editor/CanopyEditor.d.ts +1 -1
  64. package/dist/editor/CanopyEditor.js +1 -1
  65. package/dist/editor/Editor.d.ts +2 -0
  66. package/dist/editor/Editor.js +6 -5
  67. package/dist/editor/FormRenderer.js +20 -5
  68. package/dist/editor/admin/SystemHealthPanel.js +45 -7
  69. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  70. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  71. package/dist/editor/editor-utils.d.ts +17 -15
  72. package/dist/editor/editor-utils.js +43 -36
  73. package/dist/editor/hooks/useBranchesData.js +3 -1
  74. package/dist/editor/hooks/useCommentSystem.js +5 -1
  75. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  76. package/dist/editor/hooks/useDraftManager.js +2 -1
  77. package/dist/editor/hooks/useEntriesData.js +2 -2
  78. package/dist/editor/hooks/useEntryManager.js +17 -11
  79. package/dist/editor/preview-bridge.js +4 -1
  80. package/dist/editor/preview-path.d.ts +10 -0
  81. package/dist/editor/preview-path.js +20 -0
  82. package/dist/entry-schema-registry.d.ts +4 -4
  83. package/dist/entry-schema-registry.js +10 -6
  84. package/dist/entry-schema.d.ts +27 -4
  85. package/dist/entry-schema.js +20 -3
  86. package/dist/git-manager.d.ts +79 -5
  87. package/dist/git-manager.js +308 -18
  88. package/dist/github-service.d.ts +8 -0
  89. package/dist/github-service.js +5 -1
  90. package/dist/http/handler.js +35 -13
  91. package/dist/http/index.d.ts +2 -0
  92. package/dist/http/index.js +2 -0
  93. package/dist/http/router.d.ts +2 -0
  94. package/dist/http/router.js +2 -1
  95. package/dist/http/worker-not-ready.d.ts +9 -0
  96. package/dist/http/worker-not-ready.js +17 -0
  97. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  98. package/dist/operating-mode/types.d.ts +0 -3
  99. package/dist/paths/branch-name.d.ts +5 -0
  100. package/dist/paths/branch-name.js +9 -0
  101. package/dist/paths/branch.d.ts +6 -1
  102. package/dist/paths/branch.js +15 -2
  103. package/dist/paths/index.d.ts +3 -3
  104. package/dist/paths/index.js +3 -3
  105. package/dist/paths/normalize.d.ts +7 -0
  106. package/dist/paths/normalize.js +9 -0
  107. package/dist/reference-resolver.d.ts +5 -21
  108. package/dist/reference-resolver.js +9 -44
  109. package/dist/resolve-canopy-user.js +2 -1
  110. package/dist/schema/schema-store.d.ts +48 -22
  111. package/dist/schema/schema-store.js +66 -22
  112. package/dist/services.d.ts +18 -6
  113. package/dist/services.js +85 -70
  114. package/dist/settings-workspace.js +42 -4
  115. package/dist/static/seo.d.ts +2 -14
  116. package/dist/static/seo.js +2 -25
  117. package/dist/submission-attribution.d.ts +73 -0
  118. package/dist/submission-attribution.js +221 -0
  119. package/dist/sync-core.d.ts +12 -1
  120. package/dist/sync-core.js +31 -13
  121. package/dist/task-queue/worker-status.d.ts +8 -0
  122. package/dist/task-queue/worker-status.js +17 -0
  123. package/dist/types.d.ts +31 -2
  124. package/dist/utils/content-write-lock.d.ts +18 -7
  125. package/dist/utils/content-write-lock.js +18 -9
  126. package/dist/utils/debug.d.ts +8 -0
  127. package/dist/utils/debug.js +10 -2
  128. package/dist/utils/git.d.ts +28 -0
  129. package/dist/utils/git.js +38 -0
  130. package/dist/utils/provisioning-lock.d.ts +15 -5
  131. package/dist/utils/provisioning-lock.js +31 -11
  132. package/dist/utils/request-timing.d.ts +25 -0
  133. package/dist/utils/request-timing.js +101 -0
  134. package/dist/utils/url-prefix.d.ts +30 -0
  135. package/dist/utils/url-prefix.js +61 -0
  136. package/dist/version.d.ts +1 -0
  137. package/dist/version.js +3 -0
  138. package/dist/worker/canopy-state.d.ts +45 -0
  139. package/dist/worker/canopy-state.js +75 -0
  140. package/dist/worker/cms-worker.js +23 -2
  141. package/dist/worker/git-sync.d.ts +8 -2
  142. package/dist/worker/git-sync.js +138 -24
  143. package/dist/worker/provisioned-workspace.d.ts +35 -0
  144. package/dist/worker/provisioned-workspace.js +50 -0
  145. package/dist/worker/rebase.d.ts +1 -1
  146. package/dist/worker/rebase.js +83 -12
  147. package/dist/worker/task-runner.js +39 -4
  148. package/package.json +1 -1
@@ -0,0 +1,17 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from '@mantine/core';
3
+ /**
4
+ * Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
5
+ * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
6
+ * locked or protected branch shows its own banner in the header and a read-only form instead.
7
+ */
8
+ export function NoEditPermissionNotice({ entryPath }) {
9
+ return (_jsx("div", { role: "status", "data-testid": "no-edit-permission-notice", style: {
10
+ display: 'flex',
11
+ alignItems: 'center',
12
+ justifyContent: 'center',
13
+ height: '100%',
14
+ padding: '0 var(--mantine-spacing-md)',
15
+ textAlign: 'center',
16
+ }, children: _jsxs(Text, { size: "sm", c: "dimmed", children: ["You don't have edit access to \"", entryPath, "\". Ask a CanopyCMS admin to grant it in Manage Permissions."] }) }));
17
+ }
@@ -9,29 +9,31 @@ export { normalizeCollectionPath };
9
9
  export interface PreviewContext {
10
10
  branchName?: string;
11
11
  previewBaseByCollection?: Record<string, string>;
12
+ /** `editor.previewPrefix`: where the host mounts the pages the preview pane loads. */
13
+ previewPrefix?: string;
12
14
  }
13
- /**
14
- * Builds the preview iframe `src` for an entry, prefixed with the deployment `basePath`
15
- * (`CanopyClientConfig.basePath`, e.g. `/preview-123`) when configured.
16
- *
17
- * This matters twice: the raw `<iframe src>` (`PreviewFrame` in preview-bridge.tsx) 404s
18
- * without the prefix under a basePath, and `resolvePreviewPath` there compares the same
19
- * string against `window.location.pathname` -- which browsers report WITH the basePath --
20
- * so an unprefixed `previewSrc` also breaks draft sync / click-to-focus even when the
21
- * iframe itself resolves.
22
- *
23
- * Applied via `joinUrlPrefix`: a no-op when `basePath` is unset, and passes an
24
- * already-absolute `previewSrc` (a cross-origin override) through untouched.
25
- */
26
- export declare const buildPreviewSrc: (entry: {
15
+ type PreviewEntry = {
27
16
  collectionPath?: string;
28
17
  collectionName?: string;
29
18
  slug?: string;
30
19
  itemType?: string;
31
20
  previewSrc?: string;
32
- }, context: PreviewContext & {
21
+ };
22
+ /**
23
+ * Builds the preview iframe `src` for an entry: its route under `previewPrefix`, under the
24
+ * deployment `basePath` (`CanopyClientConfig.basePath`, e.g. `/preview-123`), in the host's
25
+ * trailing-slash form, with `?branch=`. An absolute prefix skips the `basePath`. An absolute route
26
+ * (from `previewBaseByCollection`) skips all three and gets only `?branch=`. An entry's own
27
+ * `previewSrc` gets only the `basePath`.
28
+ *
29
+ * The result must equal the framed page's own URL: the `<iframe src>` (`PreviewFrame` in
30
+ * preview-bridge.tsx) 404s without the prefixes, and a URL the host redirects costs a round trip
31
+ * on every load. `trailingSlash` defaults to the value `withCanopy` inlines at build time.
32
+ */
33
+ export declare const buildPreviewSrc: (entry: PreviewEntry, context: PreviewContext & {
33
34
  contentRoot?: string;
34
35
  basePath?: string;
36
+ trailingSlash?: boolean;
35
37
  }) => string;
36
38
  export declare const normalizeContentPayload: (raw: unknown) => FormValue;
37
39
  export declare const buildWritePayload: (entry: {
@@ -2,7 +2,7 @@
2
2
  import { normalizeCollectionPath } from '../paths/normalize.js';
3
3
  import { isIndexSlug } from '../utils/entry-url.js';
4
4
  import { isDataOnlyFormat } from '../utils/format.js';
5
- import { joinUrlPrefix } from '../utils/url-prefix.js';
5
+ import { isAbsoluteUrl, joinUrlPrefix, matchTrailingSlash, readTrailingSlashEnv, stripTrailingSlashes, } from '../utils/url-prefix.js';
6
6
  /** @internal Exported for tests. */
7
7
  export { normalizeCollectionPath };
8
8
  /**
@@ -22,31 +22,34 @@ const encodeSlug = (value) => (value ?? '')
22
22
  .filter(Boolean)
23
23
  .map((segment) => encodeURIComponent(segment))
24
24
  .join('/');
25
+ /** Splits `url` before its query or fragment, so a path can be extended without entering either. */
26
+ const splitPathSuffix = (url) => {
27
+ const index = url.search(/[?#]/);
28
+ return index === -1 ? [url, ''] : [url.slice(0, index), url.slice(index)];
29
+ };
30
+ /** Adds `?branch=` to the query, ahead of any fragment. */
31
+ const appendBranch = (url, branchName) => {
32
+ if (!branchName)
33
+ return url;
34
+ const hashIndex = url.indexOf('#');
35
+ const beforeHash = hashIndex === -1 ? url : url.slice(0, hashIndex);
36
+ const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
37
+ const separator = beforeHash.includes('?') ? '&' : '?';
38
+ return `${beforeHash}${separator}branch=${encodeURIComponent(branchName)}${hash}`;
39
+ };
25
40
  /**
26
- * Builds the (unprefixed) preview URL -- see `buildPreviewSrc` below, which wraps this with the
27
- * deployment `basePath`. Split out so that prefixing happens exactly once, at the end, uniformly
28
- * across every branch (including the `entry.previewSrc` escape hatch).
41
+ * The entry's route on the host site: its `previewBaseByCollection` value when one matches,
42
+ * otherwise its collection path plus slug. Site-relative unless a matching value is absolute.
29
43
  */
30
- const buildRawPreviewSrc = (entry, { branchName, previewBaseByCollection, contentRoot }) => {
31
- if (entry.previewSrc)
32
- return entry.previewSrc;
33
- const appendBranch = (url) => {
34
- if (!branchName)
35
- return url;
36
- const separator = url.includes('?') ? '&' : '?';
37
- return `${url}${separator}branch=${encodeURIComponent(branchName)}`;
38
- };
44
+ const buildPreviewRoute = (entry, { previewBaseByCollection, contentRoot }) => {
39
45
  // Root-level entries have collectionPath === contentRoot (e.g., 'content')
40
46
  const isRootEntry = contentRoot && entry.collectionPath === contentRoot;
41
47
  if (isRootEntry) {
42
- const customPreview = previewBaseByCollection?.[`${contentRoot}/${entry.slug}`];
43
- if (customPreview) {
44
- return appendBranch(customPreview);
45
- }
46
- return appendBranch('/');
48
+ return previewBaseByCollection?.[`${contentRoot}/${entry.slug}`] || '/';
47
49
  }
48
50
  const base = (entry.collectionPath && previewBaseByCollection?.[entry.collectionPath]) ??
49
51
  (entry.collectionName && previewBaseByCollection?.[entry.collectionName]);
52
+ const encoded = encodePreviewSlug(entry.slug);
50
53
  if (!base) {
51
54
  // Pass contentRoot through so a non-default (or multi-segment, e.g.
52
55
  // "cms/content") configured root is stripped too; normalizeCollectionPath
@@ -54,31 +57,35 @@ const buildRawPreviewSrc = (entry, { branchName, previewBaseByCollection, conten
54
57
  const collectionPath = entry.collectionPath
55
58
  ? normalizeCollectionPath(entry.collectionPath, contentRoot)
56
59
  : '';
57
- const encoded = encodePreviewSlug(entry.slug);
58
60
  const segments = [collectionPath, encoded].filter(Boolean);
59
- const url = segments.length > 0 ? `/${segments.join('/')}` : '/';
60
- return appendBranch(url);
61
+ return segments.length > 0 ? `/${segments.join('/')}` : '/';
61
62
  }
62
- const trimmed = base.endsWith('/') ? base.slice(0, -1) : base;
63
- const encoded = encodePreviewSlug(entry.slug);
64
- const url = encoded ? `${trimmed}/${encoded}` : trimmed || '/';
65
- return appendBranch(url);
63
+ const [basePathPart, suffix] = splitPathSuffix(base);
64
+ const trimmed = stripTrailingSlashes(basePathPart);
65
+ return `${encoded ? `${trimmed}/${encoded}` : basePathPart || '/'}${suffix}`;
66
66
  };
67
67
  /**
68
- * Builds the preview iframe `src` for an entry, prefixed with the deployment `basePath`
69
- * (`CanopyClientConfig.basePath`, e.g. `/preview-123`) when configured.
68
+ * Builds the preview iframe `src` for an entry: its route under `previewPrefix`, under the
69
+ * deployment `basePath` (`CanopyClientConfig.basePath`, e.g. `/preview-123`), in the host's
70
+ * trailing-slash form, with `?branch=`. An absolute prefix skips the `basePath`. An absolute route
71
+ * (from `previewBaseByCollection`) skips all three and gets only `?branch=`. An entry's own
72
+ * `previewSrc` gets only the `basePath`.
70
73
  *
71
- * This matters twice: the raw `<iframe src>` (`PreviewFrame` in preview-bridge.tsx) 404s
72
- * without the prefix under a basePath, and `resolvePreviewPath` there compares the same
73
- * string against `window.location.pathname` -- which browsers report WITH the basePath --
74
- * so an unprefixed `previewSrc` also breaks draft sync / click-to-focus even when the
75
- * iframe itself resolves.
76
- *
77
- * Applied via `joinUrlPrefix`: a no-op when `basePath` is unset, and passes an
78
- * already-absolute `previewSrc` (a cross-origin override) through untouched.
74
+ * The result must equal the framed page's own URL: the `<iframe src>` (`PreviewFrame` in
75
+ * preview-bridge.tsx) 404s without the prefixes, and a URL the host redirects costs a round trip
76
+ * on every load. `trailingSlash` defaults to the value `withCanopy` inlines at build time.
79
77
  */
80
78
  export const buildPreviewSrc = (entry, context) => {
81
- return joinUrlPrefix(context.basePath, buildRawPreviewSrc(entry, context));
79
+ if (entry.previewSrc)
80
+ return joinUrlPrefix(context.basePath, entry.previewSrc);
81
+ const route = buildPreviewRoute(entry, context);
82
+ // An absolute route is another site's URL, so the host's trailing-slash form says nothing
83
+ // about it.
84
+ if (isAbsoluteUrl(route))
85
+ return appendBranch(route, context.branchName);
86
+ const mounted = joinUrlPrefix(context.basePath, joinUrlPrefix(context.previewPrefix, route));
87
+ const shaped = matchTrailingSlash(mounted, context.trailingSlash ?? readTrailingSlashEnv());
88
+ return appendBranch(shaped, context.branchName);
82
89
  };
83
90
  export const normalizeContentPayload = (raw) => {
84
91
  const candidate = raw;
@@ -13,6 +13,7 @@
13
13
  * `mutate()` to revalidate.
14
14
  */
15
15
  import useSWR from 'swr';
16
+ import { isNonApiResponse } from '../../api/client.js';
16
17
  /** Cache key for the branches list. */
17
18
  export const BRANCHES_KEY = 'canopy:branches';
18
19
  /**
@@ -21,9 +22,10 @@ export const BRANCHES_KEY = 'canopy:branches';
21
22
  */
22
23
  export async function fetchBranches(apiClient) {
23
24
  const result = await apiClient.branches.list();
24
- if (result.status === 404) {
25
+ if (result.status === 404 && !isNonApiResponse(result)) {
25
26
  // No branch endpoint available; stay branchless rather than erroring --
26
27
  // the branch dropdown stays clickable so the user can retry from there.
28
+ // A proxy's 404 page means the API was not reached, so it is an error.
27
29
  return { branches: [] };
28
30
  }
29
31
  if (!result.ok) {
@@ -4,6 +4,7 @@ import { notifications } from '@mantine/notifications';
4
4
  import { normalizeCanopyPath } from '../canopy-path.js';
5
5
  import { useApiClient } from '../context/index.js';
6
6
  import { resolveMessageOrigin } from '../preview-bridge.js';
7
+ import { isSamePreviewPath } from '../preview-path.js';
7
8
  import { commentsKey, fetchComments, useCommentsData } from './useCommentsData.js';
8
9
  /**
9
10
  * Custom hook for managing the comment system.
@@ -121,8 +122,11 @@ export function useCommentSystem(options) {
121
122
  const msg = event.data;
122
123
  if (msg?.type !== 'canopycms:preview:focus')
123
124
  return;
125
+ const currentPath = options.currentEntry?.previewSrc ?? options.currentEntry?.path;
124
126
  if (msg.entryPath &&
125
- msg.entryPath !== (options.currentEntry?.previewSrc ?? options.currentEntry?.path))
127
+ (typeof msg.entryPath !== 'string' ||
128
+ currentPath === undefined ||
129
+ !isSamePreviewPath(msg.entryPath, currentPath)))
126
130
  return;
127
131
  const normalizedPath = msg.fieldPath ? normalizeCanopyPath(msg.fieldPath) : undefined;
128
132
  const target = normalizedPath
@@ -14,7 +14,7 @@ export interface UseDraftManagerOptions {
14
14
  * shown (useEntryManager's `getEntryVersion`). Stamps each draft with the
15
15
  * version it was based on, and detects at save time that the token has
16
16
  * since moved on. Optional: without it no base versions are recorded and
17
- * conflict detection falls back entirely to the server's 409.
17
+ * conflict detection falls back entirely to saveEntry's token check.
18
18
  */
19
19
  getEntryVersion?: (contentId: string) => number | undefined;
20
20
  setBusy: (busy: boolean) => void;
@@ -316,7 +316,8 @@ export function useDraftManager(options) {
316
316
  return 'ok';
317
317
  const currentVersion = options.getEntryVersion?.(currentId);
318
318
  // No server version known for this entry at all -- nothing to compare
319
- // against, so this check has no opinion (the server's 409 still applies).
319
+ // against, so this check has no opinion: saveEntry refuses a save without
320
+ // a token, and the server refuses a version-less update.
320
321
  if (currentVersion === undefined)
321
322
  return 'ok';
322
323
  const base = draftBaseVersionsRef.current[currentId];
@@ -40,7 +40,7 @@ export async function listAllEntries(apiClient, branch) {
40
40
  ...(cursor !== undefined ? { cursor } : {}),
41
41
  });
42
42
  if (!result.ok || !result.data)
43
- throw new Error(`Refresh failed: ${result.status}`);
43
+ throw new Error(`Refresh failed: ${result.status}${result.error ? ` — ${result.error}` : ''}`);
44
44
  const data = result.data;
45
45
  for (const entry of data.entries)
46
46
  byPath.set(entry.logicalPath, entry);
@@ -60,7 +60,7 @@ export async function fetchEntriesAndSchema(apiClient, branch, params) {
60
60
  // Fetch schema from schema API
61
61
  const schemaResult = await apiClient.schema.get({ branch });
62
62
  if (!schemaResult.ok || !schemaResult.data) {
63
- throw new Error(`Schema fetch failed: ${schemaResult.status}`);
63
+ throw new Error(`Schema fetch failed: ${schemaResult.status}${schemaResult.error ? ` — ${schemaResult.error}` : ''}`);
64
64
  }
65
65
  // Hydrate wire flatSchema: resolve schemaRef -> schema from entrySchemas dict
66
66
  const { entrySchemas } = schemaResult.data;
@@ -50,11 +50,9 @@ export function useEntryManager(options) {
50
50
  // case -- any first visit to a branch, for the whole duration of its fetch),
51
51
  // or a stale SWR slot. The editor then auto-selected one of those stale
52
52
  // entries (see the selection effect below), and a save could file its OCC
53
- // token under one contentId and look it up under another, silently skipping
54
- // conflict detection -- `content-store.ts` only compares mtimes when
55
- // `expectedVersion !== undefined`, so the write became a blind overwrite.
56
- // Deriving from a stamped record fixes that structurally instead of relying
57
- // on every code path remembering to clear.
53
+ // token under one contentId and look it up under another. Deriving from a
54
+ // stamped record fixes that structurally instead of relying on every code
55
+ // path remembering to clear.
58
56
  const [view, setView] = useState(() => ({
59
57
  branch: options.branchName,
60
58
  entries: options.initialEntries,
@@ -86,6 +84,9 @@ export function useEntryManager(options) {
86
84
  // branch can repopulate the map after the branch-change clear() below and
87
85
  // poison the next save with the old branch's mtime — a deterministic 409
88
86
  // ("modified by another editor") on save-after-switch, proven by e2e trace.
87
+ // The contentId half matches how useDraftManager keys drafts and loaded
88
+ // values, so a form value and its token always belong to the same file even
89
+ // when a path is deleted and recreated; it also survives a rename.
89
90
  const entryVersionsRef = useRef(new Map());
90
91
  const versionKey = (branch, contentId) => `${branch}:${contentId}`;
91
92
  // PER-BRANCH monotonic tokens guarding every commit of the fetched
@@ -192,7 +193,7 @@ export function useEntryManager(options) {
192
193
  path,
193
194
  });
194
195
  if (!result.ok)
195
- throw new Error(`Load failed: ${result.status}`);
196
+ throw new Error(`Load failed: ${result.status}${result.error ? ` — ${result.error}` : ''}`);
196
197
  // Capture OCC version token for next save
197
198
  if (entry.contentId && typeof result.data?.version === 'number') {
198
199
  entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
@@ -220,18 +221,23 @@ export function useEntryManager(options) {
220
221
  };
221
222
  if (entry.entryType)
222
223
  writeParams.entryType = entry.entryType;
223
- const expectedVersion = entry.contentId
224
- ? entryVersionsRef.current.get(versionKey(requestBranch, entry.contentId))
225
- : undefined;
224
+ const expectedVersion = entryVersionsRef.current.get(versionKey(requestBranch, entry.contentId));
225
+ // Every entry saved here already exists (creates go through handleCreateModalSubmit), so
226
+ // no token means this entry was never successfully read on this branch and nothing could check the save
227
+ // against other editors' work. Refused as a conflict; the server refuses it too.
228
+ if (expectedVersion === undefined) {
229
+ throw new SaveApiError(409, 'This entry has not been loaded from the server, so the save cannot be checked ' +
230
+ "against other editors' changes. Reload it and try again.");
231
+ }
226
232
  const writeBody = {
227
233
  ...payload,
228
- ...(expectedVersion !== undefined ? { expectedVersion } : {}),
234
+ expectedVersion,
229
235
  };
230
236
  const result = await apiClient.content.write(writeParams, writeBody);
231
237
  if (!result.ok)
232
238
  throw new SaveApiError(result.status, result.error, result.fieldErrors);
233
239
  // Update stored version token from write response
234
- if (entry.contentId && typeof result.data?.version === 'number') {
240
+ if (typeof result.data?.version === 'number') {
235
241
  entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
236
242
  }
237
243
  // Warning-level issues from the adopter's validateEntry hook: saved, but surface them
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
  import { formatCanopyPath } from './canopy-path.js';
5
+ import { isSamePreviewPath } from './preview-path.js';
5
6
  export const __CANOPY_PREVIEW_CLIENT__ = true;
6
7
  export const CANOPY_PREVIEW_MESSAGE = 'canopycms:draft:update';
7
8
  export const CANOPY_PREVIEW_FOCUS = 'canopycms:preview:focus';
@@ -116,7 +117,9 @@ export const usePreviewData = (path, initialData, opts) => {
116
117
  if (!isTrustedEditorMessage(event, editorOrigin))
117
118
  return;
118
119
  const msg = event.data;
119
- if (!msg || msg.type !== CANOPY_PREVIEW_MESSAGE || msg.path !== path)
120
+ if (!msg || msg.type !== CANOPY_PREVIEW_MESSAGE)
121
+ return;
122
+ if (typeof msg.path !== 'string' || !isSamePreviewPath(msg.path, path))
120
123
  return;
121
124
  setData(msg.data);
122
125
  if (msg.isLoading !== undefined) {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Reduces a preview URL to what identifies the page, its path and query, with no origin,
3
+ * fragment or trailing slash. The editor names a page by the `src` it built and the framed page
4
+ * by its own location. An absolute `src`, or a host redirect that adds or drops a trailing slash,
5
+ * spells the same page two ways, and the bridge must still match them.
6
+ * @internal Exported for tests.
7
+ */
8
+ export declare const normalizePreviewPath: (url: string) => string;
9
+ /** Whether two preview URLs name the same page; see `normalizePreviewPath`. */
10
+ export declare const isSamePreviewPath: (a: string, b: string) => boolean;
@@ -0,0 +1,20 @@
1
+ import { stripTrailingSlashes } from '../utils/url-prefix.js';
2
+ /**
3
+ * Reduces a preview URL to what identifies the page, its path and query, with no origin,
4
+ * fragment or trailing slash. The editor names a page by the `src` it built and the framed page
5
+ * by its own location. An absolute `src`, or a host redirect that adds or drops a trailing slash,
6
+ * spells the same page two ways, and the bridge must still match them.
7
+ * @internal Exported for tests.
8
+ */
9
+ export const normalizePreviewPath = (url) => {
10
+ let parsed;
11
+ try {
12
+ parsed = new URL(url, 'http://preview.invalid');
13
+ }
14
+ catch {
15
+ return url;
16
+ }
17
+ return `${stripTrailingSlashes(parsed.pathname) || '/'}${parsed.search}`;
18
+ };
19
+ /** Whether two preview URLs name the same page; see `normalizePreviewPath`. */
20
+ export const isSamePreviewPath = (a, b) => normalizePreviewPath(a) === normalizePreviewPath(b);
@@ -12,10 +12,10 @@ import type { EntrySchemaRegistry } from './schema/types.js';
12
12
  * interface to maintain. Keyed any other way, the derived map is keyed by that
13
13
  * string instead and will not plug into `TEntryTypes`.
14
14
  *
15
- * Rejected at call time: an empty registry; a schema that is not a non-empty
16
- * `EntrySchema` array; more than one `isTitle` per schema, or one on a non-string
17
- * field or inside a list; more than one `isBody`, or one on a field that is not
18
- * markdown/mdx or is named one of `RESOLVED_REFERENCE_KEYS`. This is also the one
15
+ * Rejected at call time: an empty registry; a schema that is not a non-empty `EntrySchema`
16
+ * array; more than one `isTitle` per schema, or one on a non-string field or inside a list;
17
+ * more than one `isBody`, or one on a field that is not markdown/mdx or is named one of
18
+ * `RESOLVED_REFERENCE_KEYS`; a top-level or inline-group field named `unavailable`. This is also the one
19
19
  * place the shared field-shape checks run: select fields must have options,
20
20
  * reference fields must have `collections` or `entryTypes`, no inline groups
21
21
  * inside object/block fields, no field-name collisions after group flattening.
@@ -1,8 +1,8 @@
1
1
  import { countTitleFields, findInvalidTitleFields, findTitleFieldsInLists, } from './utils/title-field.js';
2
2
  import { countBodyFields, findInvalidBodyFields, findReservedBodyFieldName, } from './utils/body-field.js';
3
- import { RESOLVED_REFERENCE_KEYS } from './entry-schema.js';
3
+ import { RESOLVED_REFERENCE_KEYS, RESTRICTED_REFERENCE_MARKER } from './entry-schema.js';
4
4
  import { flattenGroupFields } from './utils/flatten-group-fields.js';
5
- import { ensureSelectFieldsHaveOptions, ensureReferenceFieldsHaveScope, ensureNoFlattenedFieldNameCollisions, ensureNoGroupsInsideComplexFields, } from './config/validation.js';
5
+ import { ensureSelectFieldsHaveOptions, ensureReferenceFieldsHaveScope, ensureItemTitleFieldsExist, ensureNoFlattenedFieldNameCollisions, ensureNoGroupsInsideComplexFields, } from './config/validation.js';
6
6
  /** Look up a field's type by dotted path (e.g., "meta.order").
7
7
  * Groups are transparent — their children are searched at the same path level. */
8
8
  function findFieldType(fields, dottedPath) {
@@ -35,10 +35,10 @@ function findFieldType(fields, dottedPath) {
35
35
  * interface to maintain. Keyed any other way, the derived map is keyed by that
36
36
  * string instead and will not plug into `TEntryTypes`.
37
37
  *
38
- * Rejected at call time: an empty registry; a schema that is not a non-empty
39
- * `EntrySchema` array; more than one `isTitle` per schema, or one on a non-string
40
- * field or inside a list; more than one `isBody`, or one on a field that is not
41
- * markdown/mdx or is named one of `RESOLVED_REFERENCE_KEYS`. This is also the one
38
+ * Rejected at call time: an empty registry; a schema that is not a non-empty `EntrySchema`
39
+ * array; more than one `isTitle` per schema, or one on a non-string field or inside a list;
40
+ * more than one `isBody`, or one on a field that is not markdown/mdx or is named one of
41
+ * `RESOLVED_REFERENCE_KEYS`; a top-level or inline-group field named `unavailable`. This is also the one
42
42
  * place the shared field-shape checks run: select fields must have options,
43
43
  * reference fields must have `collections` or `entryTypes`, no inline groups
44
44
  * inside object/block fields, no field-name collisions after group flattening.
@@ -92,8 +92,12 @@ export function createEntrySchemaRegistry(registry) {
92
92
  if (reservedBodyField) {
93
93
  throw new Error(`Entry schema registry entry "${key}": field "${reservedBodyField}" has isBody: true but "${reservedBodyField}" is reserved — reference resolution sets ${RESOLVED_REFERENCE_KEYS.map((k) => `"${k}"`).join(', ')} on a resolved reference, and a body field with one of those names would overwrite it. Rename the field (the body's field name is yours to choose; only these four are reserved).`);
94
94
  }
95
+ if (flattenGroupFields(schema).some((f) => f.name === RESTRICTED_REFERENCE_MARKER)) {
96
+ throw new Error(`Entry schema registry entry "${key}": field "${RESTRICTED_REFERENCE_MARKER}" is reserved — a resolved reference carries "${RESTRICTED_REFERENCE_MARKER}: true" only when the reader may not read its target, so a field with that name cannot be delivered on a reference to this entry type. Rename the field.`);
97
+ }
95
98
  ensureSelectFieldsHaveOptions(schema);
96
99
  ensureReferenceFieldsHaveScope(schema);
100
+ ensureItemTitleFieldsExist(schema);
97
101
  ensureNoGroupsInsideComplexFields(schema);
98
102
  ensureNoFlattenedFieldNameCollisions(schema, `entry schema "${key}"`);
99
103
  }
@@ -81,9 +81,6 @@ export interface ResolvedReferenceMeta {
81
81
  * Assemble a resolved reference: the target's own data, then its body if the field asked to
82
82
  * embed it, then the reserved metadata.
83
83
  *
84
- * Exists so the two places that construct one — the server resolver in content-store.ts and
85
- * the editor's live-preview endpoint in api/resolve-references.ts — cannot drift.
86
- *
87
84
  * The ordering is the contract, not a detail. Metadata LAST means a target that models `id` as
88
85
  * a content field cannot shadow the real content ID, which the write boundary recovers from
89
86
  * `value.id` (`referenceValueId`) — a shadowed id makes a re-save persist the wrong value and
@@ -95,6 +92,30 @@ export declare function buildResolvedReference(data: Record<string, unknown>, me
95
92
  fieldName: string;
96
93
  value: string | undefined;
97
94
  }): Record<string, unknown>;
95
+ /**
96
+ * The key that marks a `RestrictedReference`. Reserved as an entry field name, at the top level
97
+ * of any schema, because any schema can be a reference target.
98
+ */
99
+ export declare const RESTRICTED_REFERENCE_MARKER = "unavailable";
100
+ /**
101
+ * What a reference resolves to when the reader may not read its target: enough to render a
102
+ * link (a title and the URL, which a denied reader can follow to a sign-in page) and nothing
103
+ * else of the target's data.
104
+ *
105
+ * `unavailable: true` is the marker a renderer branches on, so a denied target never arrives
106
+ * looking like a full one with its fields `undefined`; `reason` says why, leaving room for
107
+ * other reasons a target cannot be shown. A reference the reader may see in full never carries
108
+ * `unavailable`. `id` is always present, so a save of the referring entry writes the reference
109
+ * back unchanged (`normalizeReferenceValues` recovers it from `value.id`).
110
+ */
111
+ export type RestrictedReference = Simplify<ResolvedReferenceMeta & {
112
+ /** The target's display title, by `resolveEntryTitle`'s fallback chain. */
113
+ title: string;
114
+ unavailable: true;
115
+ reason: 'restricted';
116
+ }>;
117
+ /** Assemble a {@link RestrictedReference}. Takes no target data, so none can leak into it. */
118
+ export declare function buildRestrictedReference(meta: ResolvedReferenceMeta, title: string): RestrictedReference;
98
119
  /**
99
120
  * Recursively flatten inline groups (type: 'group') out of a field tuple: they contribute
100
121
  * no key to the content shape, so their children merge into the parent level and
@@ -205,7 +226,9 @@ type FieldValue<F extends InferableField> = F extends {
205
226
  } ? ScalarValue<F, SelectValue<F>> : F extends {
206
227
  type: 'reference';
207
228
  resolvedSchema: infer S;
208
- } ? ScalarValue<F, (InferContentShape<Extract<S, readonly InferableField[]>> & ResolvedReferenceMeta) | null> : F extends {
229
+ } ? ScalarValue<F, (InferContentShape<Extract<S, readonly InferableField[]>> & ResolvedReferenceMeta & {
230
+ unavailable?: undefined;
231
+ }) | RestrictedReference | null> : F extends {
209
232
  type: 'reference';
210
233
  } ? ScalarValue<F, string | null> : F extends {
211
234
  type: 'image';
@@ -14,9 +14,6 @@ export const RESOLVED_REFERENCE_KEYS = ['id', 'slug', 'collection', 'urlPath'];
14
14
  * Assemble a resolved reference: the target's own data, then its body if the field asked to
15
15
  * embed it, then the reserved metadata.
16
16
  *
17
- * Exists so the two places that construct one — the server resolver in content-store.ts and
18
- * the editor's live-preview endpoint in api/resolve-references.ts — cannot drift.
19
- *
20
17
  * The ordering is the contract, not a detail. Metadata LAST means a target that models `id` as
21
18
  * a content field cannot shadow the real content ID, which the write boundary recovers from
22
19
  * `value.id` (`referenceValueId`) — a shadowed id makes a re-save persist the wrong value and
@@ -35,8 +32,28 @@ export function buildResolvedReference(data, meta, body) {
35
32
  resolved.slug = meta.slug;
36
33
  resolved.collection = meta.collection;
37
34
  resolved.urlPath = meta.urlPath;
35
+ // Backstop for the registry's rejection of a field with this name (entry-schema-registry.ts):
36
+ // a full reference carrying the marker would read as one the reader may not see.
37
+ delete resolved[RESTRICTED_REFERENCE_MARKER];
38
38
  return resolved;
39
39
  }
40
+ /**
41
+ * The key that marks a `RestrictedReference`. Reserved as an entry field name, at the top level
42
+ * of any schema, because any schema can be a reference target.
43
+ */
44
+ export const RESTRICTED_REFERENCE_MARKER = 'unavailable';
45
+ /** Assemble a {@link RestrictedReference}. Takes no target data, so none can leak into it. */
46
+ export function buildRestrictedReference(meta, title) {
47
+ return {
48
+ id: meta.id,
49
+ slug: meta.slug,
50
+ collection: meta.collection,
51
+ urlPath: meta.urlPath,
52
+ title,
53
+ unavailable: true,
54
+ reason: 'restricted',
55
+ };
56
+ }
40
57
  /** Define an entry schema's field array with literal inference, without sprinkling `as const`. */
41
58
  export const defineEntrySchema = (fields) => fields;
42
59
  /**