canopycms 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +187 -47
- package/dist/api/admin-branch-health.js +47 -20
- package/dist/api/admin.d.ts +13 -1
- package/dist/api/admin.js +14 -24
- package/dist/api/branch-changes.d.ts +87 -0
- package/dist/api/branch-changes.js +269 -0
- package/dist/api/branch-entry-changes.d.ts +83 -0
- package/dist/api/branch-entry-changes.js +262 -0
- package/dist/api/branch-merge.js +2 -2
- package/dist/api/branch-review.js +35 -4
- package/dist/api/branch-status.js +14 -5
- package/dist/api/branch-withdraw.js +7 -0
- package/dist/api/branch.d.ts +13 -1
- package/dist/api/branch.js +171 -97
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.js +13 -1
- package/dist/api/content.d.ts +18 -18
- package/dist/api/content.js +1 -1
- package/dist/api/github-sync.js +7 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/pr-comment-sync.d.ts +9 -8
- package/dist/api/pr-comment-sync.js +23 -13
- package/dist/api/routes.js +4 -0
- package/dist/api/types.d.ts +3 -1
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/protected-branch.d.ts +3 -3
- package/dist/authorization/protected-branch.js +3 -3
- package/dist/branch-metadata-file.d.ts +84 -0
- package/dist/branch-metadata-file.js +9 -0
- package/dist/branch-metadata.d.ts +4 -4
- package/dist/branch-metadata.js +4 -5
- package/dist/branch-param.d.ts +24 -0
- package/dist/branch-param.js +87 -0
- package/dist/branch-provisioning.d.ts +6 -3
- package/dist/branch-provisioning.js +12 -10
- package/dist/branch-registry.d.ts +6 -0
- package/dist/branch-registry.js +12 -3
- package/dist/branch-workspace.js +22 -15
- package/dist/cli/cli.js +1060 -172
- package/dist/cli/generate-ai-content.js +732 -101
- package/dist/cli/init.js +126 -0
- package/dist/comment-store.d.ts +3 -0
- package/dist/comment-store.js +4 -1
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -0
- package/dist/config/types.d.ts +11 -0
- package/dist/content-id-index.d.ts +5 -0
- package/dist/content-id-index.js +1 -1
- package/dist/content-listing.d.ts +53 -0
- package/dist/content-listing.js +123 -27
- package/dist/content-reader.js +23 -16
- package/dist/context.js +8 -7
- package/dist/editor/BranchManager.d.ts +7 -3
- package/dist/editor/BranchManager.js +51 -6
- package/dist/editor/Editor.js +166 -34
- package/dist/editor/EntryNavigator.d.ts +34 -10
- package/dist/editor/EntryNavigator.js +185 -29
- package/dist/editor/admin/SystemHealthPanel.js +36 -1
- package/dist/editor/admin/useSystemHealth.js +1 -0
- package/dist/editor/branch-names.d.ts +43 -0
- package/dist/editor/branch-names.js +75 -0
- package/dist/editor/components/BranchPill.d.ts +2 -2
- package/dist/editor/components/BranchPill.js +2 -2
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +2 -2
- package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
- package/dist/editor/components/EntryChangesDrawer.js +363 -0
- package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
- package/dist/editor/components/EntryChangesSkeleton.js +7 -0
- package/dist/editor/components/EntryHeader.d.ts +9 -1
- package/dist/editor/components/EntryHeader.js +25 -3
- package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
- package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
- package/dist/editor/components/ReviewDialogs.js +3 -3
- package/dist/editor/context/ApiClientContext.d.ts +9 -0
- package/dist/editor/context/ApiClientContext.js +26 -2
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/copy.d.ts +1 -0
- package/dist/editor/copy.js +1 -0
- package/dist/editor/editor-utils.d.ts +2 -0
- package/dist/editor/editor-utils.js +15 -0
- package/dist/editor/entry-diff.d.ts +143 -0
- package/dist/editor/entry-diff.js +593 -0
- package/dist/editor/hooks/create-branch-request.d.ts +11 -1
- package/dist/editor/hooks/create-branch-request.js +16 -6
- package/dist/editor/hooks/useBranchActions.d.ts +19 -2
- package/dist/editor/hooks/useBranchActions.js +29 -20
- package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
- package/dist/editor/hooks/useBranchChangesData.js +134 -0
- package/dist/editor/hooks/useBranchManager.d.ts +16 -0
- package/dist/editor/hooks/useBranchManager.js +142 -54
- package/dist/editor/hooks/useDraftManager.js +6 -2
- package/dist/editor/hooks/useEntryManager.d.ts +18 -2
- package/dist/editor/hooks/useEntryManager.js +36 -10
- package/dist/editor/utils/editor-errors.d.ts +5 -2
- package/dist/git-manager.d.ts +71 -0
- package/dist/git-manager.js +270 -1
- package/dist/http/handler.js +11 -9
- package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
- package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
- package/dist/operating-mode/branch-namespace.d.ts +34 -0
- package/dist/operating-mode/branch-namespace.js +121 -0
- package/dist/operating-mode/client-unsafe-strategy.js +7 -0
- package/dist/operating-mode/types.d.ts +8 -0
- package/dist/paths/branch-name.d.ts +11 -19
- package/dist/paths/branch-name.js +17 -27
- package/dist/paths/index.d.ts +2 -2
- package/dist/paths/index.js +2 -2
- package/dist/paths/resolve.d.ts +0 -1
- package/dist/paths/resolve.js +0 -1
- package/dist/paths/validation.d.ts +40 -1
- package/dist/paths/validation.js +83 -4
- package/dist/pr-comments.d.ts +17 -9
- package/dist/pr-comments.js +53 -15
- package/dist/services.js +2 -1
- package/dist/submission-attribution.d.ts +22 -0
- package/dist/submission-attribution.js +62 -6
- package/dist/task-queue/worker-status.d.ts +6 -1
- package/dist/task-queue/worker-status.js +23 -2
- package/dist/types.d.ts +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +111 -16
- package/dist/worker/branch-policy.js +222 -17
- package/dist/worker/cms-worker.d.ts +24 -2
- package/dist/worker/cms-worker.js +35 -24
- package/dist/worker/git-sync.d.ts +3 -3
- package/dist/worker/git-sync.js +42 -1
- package/dist/worker/github-gateway-http.js +2 -2
- package/dist/worker/github-gateway-wire.d.ts +40 -25
- package/dist/worker/github-gateway-wire.js +14 -3
- package/dist/worker/github-gateway.d.ts +17 -6
- package/dist/worker/github-gateway.js +62 -6
- package/dist/worker/github-mirror.d.ts +7 -4
- package/dist/worker/github-mirror.js +27 -16
- package/dist/worker/history-rewrite.d.ts +23 -10
- package/dist/worker/history-rewrite.js +43 -20
- package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
- package/dist/worker/legacy-branch-fixtures.js +71 -0
- package/dist/worker/rebase.d.ts +2 -1
- package/dist/worker/rebase.js +93 -36
- package/dist/worker/task-runner.d.ts +4 -1
- package/dist/worker/task-runner.js +77 -48
- package/dist/worker/unpublishable-branches.d.ts +34 -0
- package/dist/worker/unpublishable-branches.js +182 -0
- package/package.json +2 -1
package/dist/cli/init.js
CHANGED
|
@@ -557,6 +557,8 @@ var init_config = __esm({
|
|
|
557
557
|
// (operating-mode/deployment-name.ts) implements — config would always "win" over
|
|
558
558
|
// modeDefault, masking dev's real default of 'local'. Do not remove `.optional()`.
|
|
559
559
|
deploymentName: deploymentNameSchema.optional(),
|
|
560
|
+
// Checked where it is resolved (operating-mode/branch-namespace.ts), with the deployment name.
|
|
561
|
+
branchNamespace: z5.string().optional(),
|
|
560
562
|
contentRoot: contentRootSchema.default("content"),
|
|
561
563
|
sourceRoot: sourceRootSchema.optional(),
|
|
562
564
|
basePath: basePathSchema,
|
|
@@ -1599,6 +1601,60 @@ var DevClientSafeStrategy = class {
|
|
|
1599
1601
|
// dist/operating-mode/client-unsafe-strategy.js
|
|
1600
1602
|
import path from "node:path";
|
|
1601
1603
|
|
|
1604
|
+
// dist/paths/branch-name.js
|
|
1605
|
+
function sanitizeBranchName(branchName) {
|
|
1606
|
+
const replaced = branchName.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
1607
|
+
const squashed = replaced.replace(/-+/g, "-");
|
|
1608
|
+
const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
|
|
1609
|
+
return trimmedDots || "branch";
|
|
1610
|
+
}
|
|
1611
|
+
var RESERVED_SETTINGS_BRANCH_PREFIX = "canopycms-settings-";
|
|
1612
|
+
|
|
1613
|
+
// dist/paths/validation.js
|
|
1614
|
+
var BASE58_PATTERN = "[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]";
|
|
1615
|
+
var CONTENT_ID_PATTERN = new RegExp(`^${BASE58_PATTERN}{12}$`);
|
|
1616
|
+
var PHYSICAL_SEGMENT_PATTERN = new RegExp(`\\.${BASE58_PATTERN}{12}(?:\\.[a-z]+)?$`);
|
|
1617
|
+
var MAX_BRANCH_NAME_LENGTH = 250;
|
|
1618
|
+
function parseBranchRef(name) {
|
|
1619
|
+
if (!name || typeof name !== "string") {
|
|
1620
|
+
return { ok: false, error: "Branch name is required" };
|
|
1621
|
+
}
|
|
1622
|
+
if (name.length > MAX_BRANCH_NAME_LENGTH) {
|
|
1623
|
+
return { ok: false, error: `Branch name too long (max ${MAX_BRANCH_NAME_LENGTH} characters)` };
|
|
1624
|
+
}
|
|
1625
|
+
if (name.includes("..")) {
|
|
1626
|
+
return { ok: false, error: 'Branch name cannot contain ".."' };
|
|
1627
|
+
}
|
|
1628
|
+
if (name.startsWith("/") || name.endsWith("/") || name.includes("//")) {
|
|
1629
|
+
return { ok: false, error: "Invalid branch name format (invalid slashes)" };
|
|
1630
|
+
}
|
|
1631
|
+
if (name.includes(" ")) {
|
|
1632
|
+
return { ok: false, error: "Branch name cannot contain spaces" };
|
|
1633
|
+
}
|
|
1634
|
+
if (name.startsWith("-")) {
|
|
1635
|
+
return { ok: false, error: 'Branch name cannot start with "-"' };
|
|
1636
|
+
}
|
|
1637
|
+
if (name.startsWith(".") || name.endsWith(".")) {
|
|
1638
|
+
return { ok: false, error: "Branch name cannot start or end with a dot" };
|
|
1639
|
+
}
|
|
1640
|
+
if (name.includes("@{")) {
|
|
1641
|
+
return { ok: false, error: 'Branch name cannot contain "@{"' };
|
|
1642
|
+
}
|
|
1643
|
+
if (name === "HEAD" || name === "@") {
|
|
1644
|
+
return { ok: false, error: 'Branch name cannot be the reserved ref "HEAD" or "@"' };
|
|
1645
|
+
}
|
|
1646
|
+
if (/[~^:?*[\\\x00-\x1f\x7f]/.test(name)) {
|
|
1647
|
+
return { ok: false, error: "Branch name contains invalid characters" };
|
|
1648
|
+
}
|
|
1649
|
+
if (name.endsWith(".lock")) {
|
|
1650
|
+
return { ok: false, error: 'Branch name cannot end with ".lock"' };
|
|
1651
|
+
}
|
|
1652
|
+
return { ok: true, name };
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// dist/operating-mode/branch-namespace.js
|
|
1656
|
+
init_logger();
|
|
1657
|
+
|
|
1602
1658
|
// dist/operating-mode/deployment-name.js
|
|
1603
1659
|
init_logger();
|
|
1604
1660
|
var warned = false;
|
|
@@ -1619,6 +1675,70 @@ function resolveDeploymentName(config, modeDefault) {
|
|
|
1619
1675
|
return resolved;
|
|
1620
1676
|
}
|
|
1621
1677
|
|
|
1678
|
+
// dist/operating-mode/branch-namespace.js
|
|
1679
|
+
var warned2 = false;
|
|
1680
|
+
function warnOnce(message) {
|
|
1681
|
+
if (warned2)
|
|
1682
|
+
return;
|
|
1683
|
+
warned2 = true;
|
|
1684
|
+
canopyLogWarn(message);
|
|
1685
|
+
}
|
|
1686
|
+
var DEPLOYMENT_PLACEHOLDER = "{deployment}";
|
|
1687
|
+
var DEFAULT_BRANCH_NAMESPACE_TEMPLATE = `canopycms/${DEPLOYMENT_PLACEHOLDER}/`;
|
|
1688
|
+
var BRANCH_NAMESPACE_ENV = "CANOPYCMS_BRANCH_NAMESPACE";
|
|
1689
|
+
function branchNamespaceProblem(namespace) {
|
|
1690
|
+
if (!namespace.endsWith("/"))
|
|
1691
|
+
return 'it must end with "/"';
|
|
1692
|
+
const sample = `${namespace}x`;
|
|
1693
|
+
if (!/^[\x21-\x7e]+$/.test(namespace))
|
|
1694
|
+
return "it is not printable ASCII without spaces";
|
|
1695
|
+
if (/["%{}]/.test(namespace))
|
|
1696
|
+
return 'it contains ", %, { or }';
|
|
1697
|
+
if (/^(?:refs|heads|tags|remotes)\//i.test(namespace)) {
|
|
1698
|
+
return "it starts with 'refs/', 'heads/', 'tags/' or 'remotes/'";
|
|
1699
|
+
}
|
|
1700
|
+
const parsed = parseBranchRef(sample);
|
|
1701
|
+
if (!parsed.ok)
|
|
1702
|
+
return `a branch in it would be refused (${parsed.error})`;
|
|
1703
|
+
if (sample.split("/").some((part) => part.startsWith(".") || part.endsWith(".lock"))) {
|
|
1704
|
+
return "a part of it starts with '.' or ends with '.lock'";
|
|
1705
|
+
}
|
|
1706
|
+
const directoryPrefix = sanitizeBranchName(namespace);
|
|
1707
|
+
if (RESERVED_SETTINGS_BRANCH_PREFIX.startsWith(directoryPrefix) || sanitizeBranchName(sample).startsWith(RESERVED_SETTINGS_BRANCH_PREFIX)) {
|
|
1708
|
+
return `some branches in it would be ${RESERVED_SETTINGS_BRANCH_PREFIX}* settings branches`;
|
|
1709
|
+
}
|
|
1710
|
+
return null;
|
|
1711
|
+
}
|
|
1712
|
+
function resolveBranchNamespaceTemplate(template, deploymentName, source) {
|
|
1713
|
+
const unknown = template.replaceAll(DEPLOYMENT_PLACEHOLDER, "").match(/\{[^}]*\}?/);
|
|
1714
|
+
if (unknown) {
|
|
1715
|
+
throw new Error(`CanopyCMS: invalid branch namespace ${JSON.stringify(template)} (from ${source}): ${JSON.stringify(unknown[0])} is not a placeholder; the only one is ${DEPLOYMENT_PLACEHOLDER}`);
|
|
1716
|
+
}
|
|
1717
|
+
const namespace = template.replaceAll(DEPLOYMENT_PLACEHOLDER, deploymentName);
|
|
1718
|
+
const problem = branchNamespaceProblem(namespace);
|
|
1719
|
+
if (problem !== null) {
|
|
1720
|
+
throw new Error(`CanopyCMS: invalid branch namespace ${JSON.stringify(namespace)} (from ${source}): ${problem}`);
|
|
1721
|
+
}
|
|
1722
|
+
return namespace;
|
|
1723
|
+
}
|
|
1724
|
+
function resolveBranchNamespace(config, modeDefault, env = process.env) {
|
|
1725
|
+
const fromEnv = env[BRANCH_NAMESPACE_ENV]?.trim();
|
|
1726
|
+
const [template, source] = fromEnv ? [fromEnv, BRANCH_NAMESPACE_ENV] : config.branchNamespace !== void 0 ? [config.branchNamespace, "config.branchNamespace"] : [DEFAULT_BRANCH_NAMESPACE_TEMPLATE, "the default"];
|
|
1727
|
+
const deploymentName = resolveDeploymentName({ deploymentName: config.deploymentName }, modeDefault);
|
|
1728
|
+
const namespace = resolveBranchNamespaceTemplate(template, deploymentName, source);
|
|
1729
|
+
if (fromEnv && config.branchNamespace !== void 0) {
|
|
1730
|
+
let fromConfig = null;
|
|
1731
|
+
try {
|
|
1732
|
+
fromConfig = resolveBranchNamespaceTemplate(config.branchNamespace, deploymentName, "config.branchNamespace");
|
|
1733
|
+
} catch {
|
|
1734
|
+
}
|
|
1735
|
+
if (fromConfig !== namespace) {
|
|
1736
|
+
warnOnce(`CanopyCMS: ${BRANCH_NAMESPACE_ENV} (${JSON.stringify(fromEnv)}) and config.branchNamespace (${JSON.stringify(config.branchNamespace)}) do not resolve to the same valid namespace; using the environment variable, which infrastructure stamps per stack.`);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return namespace;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1622
1742
|
// dist/operating-mode/client-unsafe-strategy.js
|
|
1623
1743
|
init_config2();
|
|
1624
1744
|
var ProdStrategy = class extends ProdClientSafeStrategy {
|
|
@@ -1659,6 +1779,9 @@ var ProdStrategy = class extends ProdClientSafeStrategy {
|
|
|
1659
1779
|
return config.settingsBranch;
|
|
1660
1780
|
return `canopycms-settings-${resolveDeploymentName(config, "prod")}`;
|
|
1661
1781
|
}
|
|
1782
|
+
getBranchNamespace(config) {
|
|
1783
|
+
return resolveBranchNamespace(config, "prod");
|
|
1784
|
+
}
|
|
1662
1785
|
getSettingsRoot(sourceRoot) {
|
|
1663
1786
|
return path.join(this.getWorkspaceRoot(sourceRoot), "settings");
|
|
1664
1787
|
}
|
|
@@ -1708,6 +1831,9 @@ var DevStrategy = class extends DevClientSafeStrategy {
|
|
|
1708
1831
|
return config.settingsBranch;
|
|
1709
1832
|
return `canopycms-settings-${resolveDeploymentName(config, "local")}`;
|
|
1710
1833
|
}
|
|
1834
|
+
getBranchNamespace(config) {
|
|
1835
|
+
return resolveBranchNamespace(config, "local");
|
|
1836
|
+
}
|
|
1711
1837
|
getSettingsRoot(sourceRoot) {
|
|
1712
1838
|
return path.join(this.getWorkspaceRoot(sourceRoot), "settings");
|
|
1713
1839
|
}
|
package/dist/comment-store.d.ts
CHANGED
|
@@ -35,6 +35,9 @@ export interface CommentsFile {
|
|
|
35
35
|
writeId?: string;
|
|
36
36
|
threads: Record<string, CommentThread>;
|
|
37
37
|
}
|
|
38
|
+
/** comments.json parses but has no `threads` object; text that does not parse throws SyntaxError. */
|
|
39
|
+
export declare class CommentsFileMalformedError extends Error {
|
|
40
|
+
}
|
|
38
41
|
/**
|
|
39
42
|
* Comment storage for a branch workspace, in .canopy-meta/comments.json and
|
|
40
43
|
* never committed to git.
|
package/dist/comment-store.js
CHANGED
|
@@ -20,6 +20,9 @@ function ownThread(data, threadId) {
|
|
|
20
20
|
? data.threads[threadId]
|
|
21
21
|
: undefined;
|
|
22
22
|
}
|
|
23
|
+
/** comments.json parses but has no `threads` object; text that does not parse throws SyntaxError. */
|
|
24
|
+
export class CommentsFileMalformedError extends Error {
|
|
25
|
+
}
|
|
23
26
|
/**
|
|
24
27
|
* Comment storage for a branch workspace, in .canopy-meta/comments.json and
|
|
25
28
|
* never committed to git.
|
|
@@ -65,7 +68,7 @@ export class CommentStore {
|
|
|
65
68
|
? parsed.threads
|
|
66
69
|
: undefined;
|
|
67
70
|
if (typeof threads !== 'object' || threads === null) {
|
|
68
|
-
throw new
|
|
71
|
+
throw new CommentsFileMalformedError('comments.json has no threads object');
|
|
69
72
|
}
|
|
70
73
|
return Object.values(threads);
|
|
71
74
|
}
|
|
@@ -68,6 +68,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
|
|
|
68
68
|
allowNetworkRemoteInProd: z.ZodOptional<z.ZodBoolean>;
|
|
69
69
|
settingsBranch: z.ZodOptional<z.ZodString>;
|
|
70
70
|
deploymentName: z.ZodOptional<z.ZodDefault<z.ZodString>>;
|
|
71
|
+
branchNamespace: z.ZodOptional<z.ZodString>;
|
|
71
72
|
contentRoot: z.ZodDefault<z.ZodDefault<z.ZodEffects<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>, string, string>>>;
|
|
72
73
|
sourceRoot: z.ZodOptional<z.ZodOptional<z.ZodString>>;
|
|
73
74
|
basePath: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
|
|
@@ -200,6 +201,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
|
|
|
200
201
|
githubTokenEnvVar?: string | undefined;
|
|
201
202
|
allowNetworkRemoteInProd?: boolean | undefined;
|
|
202
203
|
deploymentName?: string | undefined;
|
|
204
|
+
branchNamespace?: string | undefined;
|
|
203
205
|
sourceRoot?: string | undefined;
|
|
204
206
|
basePath?: string | undefined;
|
|
205
207
|
editorUrl?: string | undefined;
|
|
@@ -267,6 +269,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
|
|
|
267
269
|
deployedAs?: "static" | "server" | undefined;
|
|
268
270
|
allowNetworkRemoteInProd?: boolean | undefined;
|
|
269
271
|
deploymentName?: string | undefined;
|
|
272
|
+
branchNamespace?: string | undefined;
|
|
270
273
|
contentRoot?: string | undefined;
|
|
271
274
|
sourceRoot?: string | undefined;
|
|
272
275
|
basePath?: string | undefined;
|
|
@@ -120,6 +120,8 @@ export const CanopyConfigSchema = z
|
|
|
120
120
|
// (operating-mode/deployment-name.ts) implements — config would always "win" over
|
|
121
121
|
// modeDefault, masking dev's real default of 'local'. Do not remove `.optional()`.
|
|
122
122
|
deploymentName: deploymentNameSchema.optional(),
|
|
123
|
+
// Checked where it is resolved (operating-mode/branch-namespace.ts), with the deployment name.
|
|
124
|
+
branchNamespace: z.string().optional(),
|
|
123
125
|
contentRoot: contentRootSchema.default('content'),
|
|
124
126
|
sourceRoot: sourceRootSchema.optional(),
|
|
125
127
|
basePath: basePathSchema,
|
package/dist/config/types.d.ts
CHANGED
|
@@ -433,6 +433,7 @@ export interface EntryValidationIssue {
|
|
|
433
433
|
export interface ValidateEntryInput {
|
|
434
434
|
/** Logical entry path including the content root (e.g. 'content/posts/hello-world'). */
|
|
435
435
|
entryPath: string;
|
|
436
|
+
/** The branch's full name, as git holds it (`canopycms/prod/spring-update`). */
|
|
436
437
|
branch: string;
|
|
437
438
|
/**
|
|
438
439
|
* The entry type the save is validated and written as: an existing entry's on-disk type, else
|
|
@@ -474,6 +475,8 @@ export interface CanopyConfig {
|
|
|
474
475
|
allowNetworkRemoteInProd?: boolean;
|
|
475
476
|
settingsBranch?: string;
|
|
476
477
|
deploymentName?: string;
|
|
478
|
+
/** The branch namespace template — see {@link CanopyConfigInput.branchNamespace}. */
|
|
479
|
+
branchNamespace?: string;
|
|
477
480
|
contentRoot: ContentRoot;
|
|
478
481
|
sourceRoot?: SourceRoot;
|
|
479
482
|
/** Deployment prefix the host app is served under — see {@link CanopyConfigInput.basePath}. */
|
|
@@ -539,6 +542,14 @@ export interface CanopyConfigInput {
|
|
|
539
542
|
allowNetworkRemoteInProd?: boolean;
|
|
540
543
|
settingsBranch?: string;
|
|
541
544
|
deploymentName?: string;
|
|
545
|
+
/**
|
|
546
|
+
* The prefix of every branch editors create, which the GitHub gateway confines its writes to,
|
|
547
|
+
* as a template in which `{deployment}` is the deployment name, ending with `/` (default
|
|
548
|
+
* `canopycms/{deployment}/`). `CANOPYCMS_BRANCH_NAMESPACE` wins over it, as the deployment
|
|
549
|
+
* name's variable does; a CDK deployment sets that variable for the CMS and for the GitHub
|
|
550
|
+
* gateway from `CanopyCmsService`'s `branchNamespace`.
|
|
551
|
+
*/
|
|
552
|
+
branchNamespace?: string;
|
|
542
553
|
contentRoot?: string;
|
|
543
554
|
sourceRoot?: string;
|
|
544
555
|
/**
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { type LogicalPath, type PhysicalPath, type Slug, type ContentId } from './paths/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Strips the embedded ID from each physical segment to produce a logical path:
|
|
4
|
+
* "content/posts.a1b2c3d4e5f6" → "content/posts".
|
|
5
|
+
*/
|
|
6
|
+
export declare function toLogicalCollectionPath(physicalPath: string): LogicalPath;
|
|
2
7
|
/**
|
|
3
8
|
* The path the editor lists the entry a file stores under (`EditorEntry.path`, which its
|
|
4
9
|
* `entry` param matches), or null for a file that cannot be a listed entry: outside
|
package/dist/content-id-index.js
CHANGED
|
@@ -34,7 +34,7 @@ const EMPTY_LOGICAL_PATH = '';
|
|
|
34
34
|
* Strips the embedded ID from each physical segment to produce a logical path:
|
|
35
35
|
* "content/posts.a1b2c3d4e5f6" → "content/posts".
|
|
36
36
|
*/
|
|
37
|
-
function toLogicalCollectionPath(physicalPath) {
|
|
37
|
+
export function toLogicalCollectionPath(physicalPath) {
|
|
38
38
|
if (physicalPath === '.')
|
|
39
39
|
return EMPTY_LOGICAL_PATH;
|
|
40
40
|
return physicalPath
|
|
@@ -18,7 +18,60 @@ export interface CollectionListItem {
|
|
|
18
18
|
data: Record<string, unknown>;
|
|
19
19
|
updatedAt?: string;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Parse an entry file's text into its data and, for md/mdx, its markdown content apart, as
|
|
23
|
+
* `ContentStore.read` shapes a document. Throws on text that does not parse.
|
|
24
|
+
*/
|
|
25
|
+
export declare const parseEntryParts: (raw: string, format: ContentFormat) => {
|
|
26
|
+
data: Record<string, unknown>;
|
|
27
|
+
body?: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Parse an entry file's text. For md/mdx: frontmatter fields plus the body content under
|
|
31
|
+
* `bodyFieldName` (defaults to `'body'`); for json/yaml, the parsed object. Throws on text
|
|
32
|
+
* that does not parse.
|
|
33
|
+
*/
|
|
34
|
+
export declare const parseEntryData: (raw: string, format: ContentFormat, bodyFieldName?: string) => Record<string, unknown>;
|
|
21
35
|
export { parseTypedFilename } from './utils/typed-filename.js';
|
|
36
|
+
/** What a repo-relative file path is to the editor: an entry, a collection's settings, or neither. */
|
|
37
|
+
export type RepoPathRef = {
|
|
38
|
+
kind: 'entry';
|
|
39
|
+
logicalPath: LogicalPath;
|
|
40
|
+
contentId: ContentId;
|
|
41
|
+
entryType: string;
|
|
42
|
+
collectionPath: LogicalPath;
|
|
43
|
+
physicalPath: string;
|
|
44
|
+
} | {
|
|
45
|
+
kind: 'collection-meta';
|
|
46
|
+
collectionPath: LogicalPath;
|
|
47
|
+
physicalPath: string;
|
|
48
|
+
} | {
|
|
49
|
+
kind: 'other';
|
|
50
|
+
physicalPath: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Map a repo-relative file path, as git prints it, to the entry or collection settings it holds
|
|
54
|
+
* under `flatSchema`. Reads nothing from disk, so it maps a file that no longer exists.
|
|
55
|
+
* `contentRoot` is the config's (`'content'`): logical paths start with it, and a branch clone's
|
|
56
|
+
* root is the repo root, so its git paths do too.
|
|
57
|
+
*
|
|
58
|
+
* A file is an entry only as `listCollectionEntries` would list it: in a collection's
|
|
59
|
+
* directory, named `{type}.{slug}.{id}` with one of that collection's entry types and that
|
|
60
|
+
* type's extension. Directory segments below `contentRoot` compare lowercased, as
|
|
61
|
+
* `resolveCollectionPath` reads them. Anything else is `other`, including a path outside
|
|
62
|
+
* `contentRoot` and one with an empty, `.` or `..` segment, which no collection segment matches.
|
|
63
|
+
*/
|
|
64
|
+
export declare function entryRefFromRepoPath(repoPath: string, flatSchema: readonly FlatSchemaItem[], contentRoot: string): RepoPathRef;
|
|
65
|
+
/**
|
|
66
|
+
* An entry or collection-settings ref read from `repoPath`'s shape alone, with no schema, for a
|
|
67
|
+
* file the current schema can't place: one deleted with its collection, under a collection's old
|
|
68
|
+
* name, or of an entry type since removed. The collection path strips the ids from the
|
|
69
|
+
* directory names, as the ContentId index does, and `entryType` is the filename's. Undefined
|
|
70
|
+
* for a path outside `contentRoot` or a filename of neither shape.
|
|
71
|
+
*/
|
|
72
|
+
export declare function entryRefFromRepoPathShape(repoPath: string, contentRoot: string): Exclude<RepoPathRef, {
|
|
73
|
+
kind: 'other';
|
|
74
|
+
}> | undefined;
|
|
22
75
|
/**
|
|
23
76
|
* A flat entry item from listEntries.
|
|
24
77
|
* Structural metadata is always present; `data` is controlled by the extract option.
|
package/dist/content-listing.js
CHANGED
|
@@ -8,8 +8,9 @@ import { parse as yamlParse } from 'yaml';
|
|
|
8
8
|
import { findBodyFieldName } from './utils/body-field.js';
|
|
9
9
|
import { computeEntryUrl } from './utils/entry-url.js';
|
|
10
10
|
import { asRecord, getFormatExtension } from './utils/format.js';
|
|
11
|
-
import { resolveCollectionPath } from './content-id-index.js';
|
|
11
|
+
import { resolveCollectionPath, toLogicalCollectionPath } from './content-id-index.js';
|
|
12
12
|
import { entryLogicalPath, validateAndNormalizePath } from './paths/index.js';
|
|
13
|
+
import { resolveLogicalPath } from './paths/resolve.js';
|
|
13
14
|
import { isNotFoundError, getErrorMessage } from './utils/error.js';
|
|
14
15
|
import { createDebugLogger } from './utils/debug.js';
|
|
15
16
|
import { ContentStore, ContentStoreError, createReferenceResolveCache, } from './content-store.js';
|
|
@@ -25,35 +26,45 @@ const normalizePath = (root, target) => {
|
|
|
25
26
|
return result.normalizedPath;
|
|
26
27
|
};
|
|
27
28
|
/**
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
30
|
-
|
|
29
|
+
* Parse an entry file's text into its data and, for md/mdx, its markdown content apart, as
|
|
30
|
+
* `ContentStore.read` shapes a document. Throws on text that does not parse.
|
|
31
|
+
*/
|
|
32
|
+
export const parseEntryParts = (raw, format) => {
|
|
33
|
+
if (format === 'json')
|
|
34
|
+
return { data: asRecord(JSON.parse(raw)) };
|
|
35
|
+
if (format === 'yaml')
|
|
36
|
+
return { data: asRecord(yamlParse(raw)) };
|
|
37
|
+
// Read-only here, and the result is copied immediately below before anything
|
|
38
|
+
// is written into it -- see that comment for the hazard being avoided.
|
|
39
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
40
|
+
const parsed = matter(raw);
|
|
41
|
+
// A copy, never gray-matter's own object. gray-matter keeps a PROCESS-GLOBAL cache keyed by
|
|
42
|
+
// file content and hands every caller the same `data` object instance, so writing the body
|
|
43
|
+
// into it in place wrote it into a shared object that later, unrelated `matter()` calls then
|
|
44
|
+
// saw as frontmatter: listing a collection containing an md entry poisoned the cache, and a
|
|
45
|
+
// reference resolution to that same entry (through `ContentStore.read()`, whose md branch
|
|
46
|
+
// calls `matter()` again) returned the body as a frontmatter field -- one entry with two
|
|
47
|
+
// shapes, decided by unrelated listing scope.
|
|
48
|
+
return { data: { ...(parsed.data ?? {}) }, body: parsed.content };
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Parse an entry file's text. For md/mdx: frontmatter fields plus the body content under
|
|
52
|
+
* `bodyFieldName` (defaults to `'body'`); for json/yaml, the parsed object. Throws on text
|
|
53
|
+
* that does not parse.
|
|
54
|
+
*/
|
|
55
|
+
export const parseEntryData = (raw, format, bodyFieldName = 'body') => {
|
|
56
|
+
const { data, body } = parseEntryParts(raw, format);
|
|
57
|
+
if (body)
|
|
58
|
+
data[bodyFieldName] = body;
|
|
59
|
+
return data;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Read entry data from a file, parsed by {@link parseEntryData}. Returns an empty object on
|
|
63
|
+
* read/parse failure.
|
|
31
64
|
*/
|
|
32
65
|
const readEntryData = async (filePath, format, bodyFieldName = 'body') => {
|
|
33
66
|
try {
|
|
34
|
-
|
|
35
|
-
if (format === 'json') {
|
|
36
|
-
return asRecord(JSON.parse(raw));
|
|
37
|
-
}
|
|
38
|
-
if (format === 'yaml') {
|
|
39
|
-
return asRecord(yamlParse(raw));
|
|
40
|
-
}
|
|
41
|
-
// Read-only here, and the result is copied immediately below before anything
|
|
42
|
-
// is written into it -- see that comment for the hazard being avoided.
|
|
43
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
44
|
-
const parsed = matter(raw);
|
|
45
|
-
// Copy before writing the body in. gray-matter keeps a PROCESS-GLOBAL cache keyed by file
|
|
46
|
-
// content and hands every caller the same `data` object instance, so mutating it in place
|
|
47
|
-
// wrote the body into a shared object that later, unrelated `matter()` calls then saw as
|
|
48
|
-
// frontmatter: listing a collection containing an md entry poisoned the cache, and a
|
|
49
|
-
// reference resolution to that same entry (through `ContentStore.read()`, whose md branch
|
|
50
|
-
// calls `matter()` again) returned the body as a frontmatter field -- one entry with two
|
|
51
|
-
// shapes, decided by unrelated listing scope.
|
|
52
|
-
const data = { ...(parsed.data ?? {}) };
|
|
53
|
-
if (parsed.content) {
|
|
54
|
-
data[bodyFieldName] = parsed.content;
|
|
55
|
-
}
|
|
56
|
-
return data;
|
|
67
|
+
return parseEntryData(await fs.readFile(filePath, 'utf8'), format, bodyFieldName);
|
|
57
68
|
}
|
|
58
69
|
catch (err) {
|
|
59
70
|
if (isNotFoundError(err))
|
|
@@ -67,6 +78,91 @@ const readEntryData = async (filePath, format, bodyFieldName = 'body') => {
|
|
|
67
78
|
// and `canopycms/server` -- import it from here.
|
|
68
79
|
export { parseTypedFilename } from './utils/typed-filename.js';
|
|
69
80
|
import { parseTypedFilename } from './utils/typed-filename.js';
|
|
81
|
+
/**
|
|
82
|
+
* Map a repo-relative file path, as git prints it, to the entry or collection settings it holds
|
|
83
|
+
* under `flatSchema`. Reads nothing from disk, so it maps a file that no longer exists.
|
|
84
|
+
* `contentRoot` is the config's (`'content'`): logical paths start with it, and a branch clone's
|
|
85
|
+
* root is the repo root, so its git paths do too.
|
|
86
|
+
*
|
|
87
|
+
* A file is an entry only as `listCollectionEntries` would list it: in a collection's
|
|
88
|
+
* directory, named `{type}.{slug}.{id}` with one of that collection's entry types and that
|
|
89
|
+
* type's extension. Directory segments below `contentRoot` compare lowercased, as
|
|
90
|
+
* `resolveCollectionPath` reads them. Anything else is `other`, including a path outside
|
|
91
|
+
* `contentRoot` and one with an empty, `.` or `..` segment, which no collection segment matches.
|
|
92
|
+
*/
|
|
93
|
+
export function entryRefFromRepoPath(repoPath, flatSchema, contentRoot) {
|
|
94
|
+
const physicalPath = repoPath.replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
|
|
95
|
+
const other = { kind: 'other', physicalPath };
|
|
96
|
+
const segments = physicalPath.split('/');
|
|
97
|
+
const rootSegments = contentRoot.split('/').filter(Boolean);
|
|
98
|
+
if (segments.length <= rootSegments.length ||
|
|
99
|
+
rootSegments.some((segment, i) => segments[i] !== segment)) {
|
|
100
|
+
return other;
|
|
101
|
+
}
|
|
102
|
+
const filename = segments[segments.length - 1];
|
|
103
|
+
const collectionDir = [
|
|
104
|
+
...rootSegments,
|
|
105
|
+
...segments.slice(rootSegments.length, -1).map((segment) => segment.toLowerCase()),
|
|
106
|
+
].join('/');
|
|
107
|
+
// resolveLogicalPath echoes a path it cannot match, so only a listed collection counts.
|
|
108
|
+
const resolved = resolveLogicalPath(collectionDir, flatSchema);
|
|
109
|
+
const collection = flatSchema.find((item) => item.type === 'collection' && item.logicalPath === resolved);
|
|
110
|
+
if (!collection)
|
|
111
|
+
return other;
|
|
112
|
+
if (filename === '.collection.json') {
|
|
113
|
+
return { kind: 'collection-meta', collectionPath: collection.logicalPath, physicalPath };
|
|
114
|
+
}
|
|
115
|
+
const entryTypes = collection.entries ?? [];
|
|
116
|
+
const parsed = parseTypedFilename(filename, entryTypes);
|
|
117
|
+
const entryType = parsed ? entryTypes.find((e) => e.name === parsed.type) : undefined;
|
|
118
|
+
if (!parsed || !entryType || !filename.endsWith(getFormatExtension(entryType.format))) {
|
|
119
|
+
return other;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
kind: 'entry',
|
|
123
|
+
logicalPath: entryLogicalPath(collection.logicalPath, parsed.slug),
|
|
124
|
+
contentId: parsed.id,
|
|
125
|
+
entryType: parsed.type,
|
|
126
|
+
collectionPath: collection.logicalPath,
|
|
127
|
+
physicalPath,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const CONTENT_EXTENSIONS = ['.md', '.mdx', '.json', '.yaml'];
|
|
131
|
+
/**
|
|
132
|
+
* An entry or collection-settings ref read from `repoPath`'s shape alone, with no schema, for a
|
|
133
|
+
* file the current schema can't place: one deleted with its collection, under a collection's old
|
|
134
|
+
* name, or of an entry type since removed. The collection path strips the ids from the
|
|
135
|
+
* directory names, as the ContentId index does, and `entryType` is the filename's. Undefined
|
|
136
|
+
* for a path outside `contentRoot` or a filename of neither shape.
|
|
137
|
+
*/
|
|
138
|
+
export function entryRefFromRepoPathShape(repoPath, contentRoot) {
|
|
139
|
+
const physicalPath = repoPath.replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
|
|
140
|
+
const segments = physicalPath.split('/');
|
|
141
|
+
const rootSegments = contentRoot.split('/').filter(Boolean);
|
|
142
|
+
if (segments.length <= rootSegments.length ||
|
|
143
|
+
rootSegments.some((segment, i) => segments[i] !== segment) ||
|
|
144
|
+
segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
const filename = segments[segments.length - 1];
|
|
148
|
+
const collectionPath = [
|
|
149
|
+
...rootSegments,
|
|
150
|
+
...segments.slice(rootSegments.length, -1).map((segment) => toLogicalCollectionPath(segment)),
|
|
151
|
+
].join('/');
|
|
152
|
+
if (filename === '.collection.json')
|
|
153
|
+
return { kind: 'collection-meta', collectionPath, physicalPath };
|
|
154
|
+
const parsed = parseTypedFilename(filename);
|
|
155
|
+
if (!parsed || !CONTENT_EXTENSIONS.some((ext) => filename.endsWith(ext)))
|
|
156
|
+
return undefined;
|
|
157
|
+
return {
|
|
158
|
+
kind: 'entry',
|
|
159
|
+
logicalPath: entryLogicalPath(collectionPath, parsed.slug),
|
|
160
|
+
contentId: parsed.id,
|
|
161
|
+
entryType: parsed.type,
|
|
162
|
+
collectionPath,
|
|
163
|
+
physicalPath,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
70
166
|
/**
|
|
71
167
|
* Build the `ContentStore` + shared cache that a batch listing resolves references through.
|
|
72
168
|
*
|
package/dist/content-reader.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { loadOrCreateBranchContext } from './branch-workspace.js';
|
|
2
|
+
import { resolveBranchParam } from './branch-param.js';
|
|
2
3
|
import { SchemaUnavailableError } from './schema/schema-unavailable-error.js';
|
|
3
4
|
import { ContentStore, ContentStoreError } from './content-store.js';
|
|
4
5
|
import { namesNoWorkspace, resolveBranchPaths, } from './paths/index.js';
|
|
5
6
|
import { trimSlashes } from './paths/normalize.js';
|
|
7
|
+
import { exactBranchName, isExactBranchForm } from './paths/validation.js';
|
|
6
8
|
import { computeEntryUrl, isIndexSlug } from './utils/entry-url.js';
|
|
7
9
|
import { isDeployedStatic, isBuildMode, readsFromCheckout } from './build-mode.js';
|
|
8
10
|
import { isNotFoundError } from './utils/error.js';
|
|
@@ -40,28 +42,33 @@ export const createContentReader = (options) => {
|
|
|
40
42
|
throw new ContentStoreError(`Branch not found: ${branchName}`, 'NOT_FOUND');
|
|
41
43
|
return resolved;
|
|
42
44
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
// An existing branch resolves first, so a short name reads its namespace branch; only a
|
|
46
|
+
// name that resolves to none is provisioned.
|
|
47
|
+
const existing = await resolveBranchParam(branchName, services.config, {
|
|
48
|
+
basePathOverride,
|
|
49
|
+
}).catch((err) => {
|
|
50
|
+
if (namesNoWorkspace(err))
|
|
51
|
+
return null;
|
|
52
|
+
throw err;
|
|
53
|
+
});
|
|
54
|
+
if (existing)
|
|
55
|
+
return existing;
|
|
56
|
+
// The exact form provisions the branch its full name names; a malformed one names none.
|
|
57
|
+
const named = isExactBranchForm(branchName) ? exactBranchName(branchName) : branchName;
|
|
58
|
+
if (allowCreateBranch && named !== null) {
|
|
59
|
+
const context = await loadOrCreateBranchContext({
|
|
45
60
|
config: services.config,
|
|
46
|
-
branchName,
|
|
61
|
+
branchName: named,
|
|
47
62
|
mode: operatingMode,
|
|
48
63
|
basePathOverride,
|
|
49
64
|
createdBy,
|
|
50
65
|
remoteUrl: services.config.defaultRemoteUrl,
|
|
51
66
|
});
|
|
67
|
+
// A workspace already at this name's key that holds another branch is that branch's.
|
|
68
|
+
if (context.branch.name === named)
|
|
69
|
+
return context;
|
|
52
70
|
}
|
|
53
|
-
|
|
54
|
-
branchName,
|
|
55
|
-
mode: operatingMode,
|
|
56
|
-
basePathOverride,
|
|
57
|
-
}).catch((err) => {
|
|
58
|
-
if (namesNoWorkspace(err))
|
|
59
|
-
return null;
|
|
60
|
-
throw err;
|
|
61
|
-
});
|
|
62
|
-
if (!existing)
|
|
63
|
-
throw new ContentStoreError(`Branch not found: ${branchName}`, 'NOT_FOUND');
|
|
64
|
-
return existing;
|
|
71
|
+
throw new ContentStoreError(`Branch not found: ${branchName}`, 'NOT_FOUND');
|
|
65
72
|
};
|
|
66
73
|
const resolveStore = async (branchName) => {
|
|
67
74
|
const context = await resolveBranchContext(branchName);
|
package/dist/context.js
CHANGED
|
@@ -3,7 +3,8 @@ import { createContentReader } from './content-reader.js';
|
|
|
3
3
|
import { ContentStoreError } from './content-store.js';
|
|
4
4
|
import { createLogicalPath, namesNoWorkspace, parseSlug, resolveBranchPaths, } from './paths/index.js';
|
|
5
5
|
import { resolveUrlPathCandidates } from './url-path-resolver.js';
|
|
6
|
-
import {
|
|
6
|
+
import { loadOrCreateBranchContext } from './branch-workspace.js';
|
|
7
|
+
import { resolveBranchParam } from './branch-param.js';
|
|
7
8
|
import { buildContentTree as buildContentTreeImpl, } from './content-tree.js';
|
|
8
9
|
import { listEntries as listEntriesImpl, } from './content-listing.js';
|
|
9
10
|
import { createDebugLogger } from './utils/debug.js';
|
|
@@ -34,13 +35,13 @@ function isBranchOption(branch) {
|
|
|
34
35
|
return branch == null || typeof branch === 'string';
|
|
35
36
|
}
|
|
36
37
|
/**
|
|
37
|
-
*
|
|
38
|
-
* is null too (`namesNoWorkspace`): it arrives from the request, so it reads as
|
|
39
|
-
*
|
|
38
|
+
* The existing branch `param` names (`resolveBranchParam`), or null. A name that cannot name one
|
|
39
|
+
* is null too (`namesNoWorkspace`): it arrives from the request, so it reads as not-found rather
|
|
40
|
+
* than failing the page.
|
|
40
41
|
*/
|
|
41
|
-
async function loadExistingBranch(
|
|
42
|
+
async function loadExistingBranch(param, config) {
|
|
42
43
|
try {
|
|
43
|
-
return await
|
|
44
|
+
return await resolveBranchParam(param, config);
|
|
44
45
|
}
|
|
45
46
|
catch (err) {
|
|
46
47
|
if (namesNoWorkspace(err))
|
|
@@ -119,7 +120,7 @@ export function createCanopyContext(options) {
|
|
|
119
120
|
remoteUrl: services.config.defaultRemoteUrl,
|
|
120
121
|
});
|
|
121
122
|
}
|
|
122
|
-
const context = await loadExistingBranch(requested, services.config
|
|
123
|
+
const context = await loadExistingBranch(requested, services.config);
|
|
123
124
|
if (!context)
|
|
124
125
|
return null;
|
|
125
126
|
if (enforceAcls && !services.checkBranchAccess(context, user).allowed)
|
|
@@ -3,9 +3,12 @@ import type { OperatingMode } from '../operating-mode/index.js';
|
|
|
3
3
|
import type { ConflictStatus, PullRequestState, SyncStatus } from '../types.js';
|
|
4
4
|
import type { CommentThread } from '../comment-store.js';
|
|
5
5
|
import type { UserSearchResult } from '../auth/types.js';
|
|
6
|
+
import type { CreateRefusal } from './hooks/create-branch-request.js';
|
|
6
7
|
/** @internal Exported for tests. */
|
|
7
8
|
export interface BranchSummary {
|
|
8
9
|
name: string;
|
|
10
|
+
/** What the card shows for the branch (`displayBranchName`); the full `name` keys it. */
|
|
11
|
+
shortName?: string;
|
|
9
12
|
status: string;
|
|
10
13
|
createdBy?: string;
|
|
11
14
|
updatedAt?: string;
|
|
@@ -78,14 +81,15 @@ export interface BranchManagerProps {
|
|
|
78
81
|
/** Awaited before the spinner starts, so a confirmation it shows never reads as a hung create. `false` abandons the create. */
|
|
79
82
|
onBeforeCreate?: () => Promise<boolean>;
|
|
80
83
|
/**
|
|
81
|
-
* Resolving `false` means the branch was not created, so the form stays open
|
|
82
|
-
*
|
|
84
|
+
* Resolving `false` means the branch was not created, so the form stays open with the user's
|
|
85
|
+
* values; `{ refusal }` also shows why under the name field. Any other return (including none)
|
|
86
|
+
* closes it.
|
|
83
87
|
*/
|
|
84
88
|
onCreate?: (branch: {
|
|
85
89
|
name: string;
|
|
86
90
|
title?: string;
|
|
87
91
|
description?: string;
|
|
88
|
-
}) => Promise<boolean> | void;
|
|
92
|
+
}) => Promise<boolean | CreateRefusal> | void;
|
|
89
93
|
onDelete?: (name: string) => void;
|
|
90
94
|
onSubmit?: (name: string) => void;
|
|
91
95
|
onWithdraw?: (name: string) => void;
|