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.
- package/README.md +3 -2
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +12 -7
- package/dist/api/admin.d.ts +21 -9
- package/dist/api/admin.js +18 -2
- package/dist/api/branch-status.js +19 -4
- package/dist/api/branch.js +11 -11
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.js +34 -11
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.js +3 -3
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-workspace.js +8 -3
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/cli.js +1070 -388
- package/dist/cli/generate-ai-content.js +537 -150
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +21 -11
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +6 -0
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/config/schemas/config.d.ts +11 -3
- package/dist/config/schemas/config.js +5 -1
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/url.d.ts +2 -0
- package/dist/config/schemas/url.js +7 -0
- package/dist/config/types.d.ts +28 -4
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -1
- package/dist/editor/Editor.d.ts +2 -0
- package/dist/editor/Editor.js +6 -5
- package/dist/editor/FormRenderer.js +20 -5
- package/dist/editor/admin/SystemHealthPanel.js +45 -7
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/editor-utils.d.ts +17 -15
- package/dist/editor/editor-utils.js +43 -36
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useCommentSystem.js +5 -1
- package/dist/editor/hooks/useDraftManager.d.ts +1 -1
- package/dist/editor/hooks/useDraftManager.js +2 -1
- package/dist/editor/hooks/useEntriesData.js +2 -2
- package/dist/editor/hooks/useEntryManager.js +17 -11
- package/dist/editor/preview-bridge.js +4 -1
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +79 -5
- package/dist/git-manager.js +308 -18
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +35 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/worker-not-ready.d.ts +9 -0
- package/dist/http/worker-not-ready.js +17 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -1
- package/dist/paths/branch.js +15 -2
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +48 -22
- package/dist/schema/schema-store.js +66 -22
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.js +42 -4
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +38 -0
- package/dist/utils/provisioning-lock.d.ts +15 -5
- package/dist/utils/provisioning-lock.js +31 -11
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.js +23 -2
- package/dist/worker/git-sync.d.ts +8 -2
- package/dist/worker/git-sync.js +138 -24
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +83 -12
- package/dist/worker/task-runner.js +39 -4
- 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
|
-
}
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
return appendBranch(url);
|
|
61
|
+
return segments.length > 0 ? `/${segments.join('/')}` : '/';
|
|
61
62
|
}
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
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
|
|
69
|
-
* (`CanopyClientConfig.basePath`, e.g. `/preview-123`)
|
|
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
|
-
*
|
|
72
|
-
* without the
|
|
73
|
-
*
|
|
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
|
-
|
|
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 !==
|
|
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
|
|
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
|
|
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
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
}
|
package/dist/entry-schema.d.ts
CHANGED
|
@@ -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
|
|
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';
|
package/dist/entry-schema.js
CHANGED
|
@@ -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
|
/**
|