canopycms 0.7.1 → 0.8.1

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 (152) hide show
  1. package/CHANGELOG.md +197 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-schema-cache.d.ts +22 -2
  39. package/dist/branch-schema-cache.js +88 -12
  40. package/dist/branch-workspace.js +22 -15
  41. package/dist/cli/cli.js +1122 -181
  42. package/dist/cli/generate-ai-content.js +791 -109
  43. package/dist/cli/init.js +126 -0
  44. package/dist/comment-store.d.ts +3 -0
  45. package/dist/comment-store.js +4 -1
  46. package/dist/config/schemas/config.d.ts +3 -0
  47. package/dist/config/schemas/config.js +2 -0
  48. package/dist/config/types.d.ts +11 -0
  49. package/dist/content-id-index.d.ts +5 -0
  50. package/dist/content-id-index.js +1 -1
  51. package/dist/content-listing.d.ts +53 -0
  52. package/dist/content-listing.js +123 -27
  53. package/dist/content-reader.d.ts +24 -10
  54. package/dist/content-reader.js +66 -22
  55. package/dist/context.js +51 -48
  56. package/dist/editor/BranchManager.d.ts +7 -3
  57. package/dist/editor/BranchManager.js +51 -6
  58. package/dist/editor/Editor.js +166 -34
  59. package/dist/editor/EntryNavigator.d.ts +34 -10
  60. package/dist/editor/EntryNavigator.js +185 -29
  61. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  62. package/dist/editor/admin/useSystemHealth.js +1 -0
  63. package/dist/editor/branch-names.d.ts +43 -0
  64. package/dist/editor/branch-names.js +75 -0
  65. package/dist/editor/components/BranchPill.d.ts +2 -2
  66. package/dist/editor/components/BranchPill.js +2 -2
  67. package/dist/editor/components/EditorHeader.d.ts +3 -1
  68. package/dist/editor/components/EditorHeader.js +2 -2
  69. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  70. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  71. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  72. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  73. package/dist/editor/components/EntryHeader.d.ts +9 -1
  74. package/dist/editor/components/EntryHeader.js +25 -3
  75. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  76. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  77. package/dist/editor/components/ReviewDialogs.js +3 -3
  78. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  79. package/dist/editor/context/ApiClientContext.js +26 -2
  80. package/dist/editor/context/index.d.ts +1 -1
  81. package/dist/editor/context/index.js +1 -1
  82. package/dist/editor/copy.d.ts +1 -0
  83. package/dist/editor/copy.js +1 -0
  84. package/dist/editor/editor-utils.d.ts +2 -0
  85. package/dist/editor/editor-utils.js +15 -0
  86. package/dist/editor/entry-diff.d.ts +143 -0
  87. package/dist/editor/entry-diff.js +593 -0
  88. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  89. package/dist/editor/hooks/create-branch-request.js +16 -6
  90. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  91. package/dist/editor/hooks/useBranchActions.js +29 -20
  92. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  93. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  94. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  95. package/dist/editor/hooks/useBranchManager.js +142 -54
  96. package/dist/editor/hooks/useDraftManager.js +6 -2
  97. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  98. package/dist/editor/hooks/useEntryManager.js +36 -10
  99. package/dist/editor/utils/editor-errors.d.ts +5 -2
  100. package/dist/git-manager.d.ts +71 -0
  101. package/dist/git-manager.js +270 -1
  102. package/dist/http/handler.js +11 -9
  103. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  104. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  105. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  106. package/dist/operating-mode/branch-namespace.js +121 -0
  107. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  108. package/dist/operating-mode/types.d.ts +8 -0
  109. package/dist/paths/branch-name.d.ts +11 -19
  110. package/dist/paths/branch-name.js +17 -27
  111. package/dist/paths/index.d.ts +2 -2
  112. package/dist/paths/index.js +2 -2
  113. package/dist/paths/resolve.d.ts +0 -1
  114. package/dist/paths/resolve.js +0 -1
  115. package/dist/paths/validation.d.ts +40 -1
  116. package/dist/paths/validation.js +83 -4
  117. package/dist/pr-comments.d.ts +17 -9
  118. package/dist/pr-comments.js +53 -15
  119. package/dist/server.d.ts +6 -1
  120. package/dist/server.js +6 -1
  121. package/dist/services.js +2 -1
  122. package/dist/submission-attribution.d.ts +22 -0
  123. package/dist/submission-attribution.js +62 -6
  124. package/dist/task-queue/worker-status.d.ts +6 -1
  125. package/dist/task-queue/worker-status.js +23 -2
  126. package/dist/types.d.ts +59 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/dist/worker/branch-policy.d.ts +111 -16
  130. package/dist/worker/branch-policy.js +222 -17
  131. package/dist/worker/cms-worker.d.ts +24 -2
  132. package/dist/worker/cms-worker.js +35 -24
  133. package/dist/worker/git-sync.d.ts +3 -3
  134. package/dist/worker/git-sync.js +42 -1
  135. package/dist/worker/github-gateway-http.js +2 -2
  136. package/dist/worker/github-gateway-wire.d.ts +42 -27
  137. package/dist/worker/github-gateway-wire.js +14 -3
  138. package/dist/worker/github-gateway.d.ts +17 -6
  139. package/dist/worker/github-gateway.js +62 -6
  140. package/dist/worker/github-mirror.d.ts +7 -4
  141. package/dist/worker/github-mirror.js +27 -16
  142. package/dist/worker/history-rewrite.d.ts +23 -10
  143. package/dist/worker/history-rewrite.js +43 -20
  144. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  145. package/dist/worker/legacy-branch-fixtures.js +71 -0
  146. package/dist/worker/rebase.d.ts +2 -1
  147. package/dist/worker/rebase.js +93 -36
  148. package/dist/worker/task-runner.d.ts +4 -1
  149. package/dist/worker/task-runner.js +77 -48
  150. package/dist/worker/unpublishable-branches.d.ts +34 -0
  151. package/dist/worker/unpublishable-branches.js +182 -0
  152. 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
  }
@@ -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.
@@ -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 Error('comments.json has no threads object');
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,
@@ -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
@@ -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.
@@ -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
- * Read entry data from a file. For md/mdx: frontmatter fields plus the body content under
29
- * `bodyFieldName` (defaults to `'body'`); for json/yaml, the parsed object. Returns an empty
30
- * object on read/parse failure.
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
- const raw = await fs.readFile(filePath, 'utf8');
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
  *
@@ -89,20 +89,34 @@ export interface ContentReadMeta {
89
89
  /** The entry's URL path with no query, by the rule `listEntries` uses. */
90
90
  urlPath: string;
91
91
  }
92
+ /** What a content read resolves to. */
93
+ export interface ContentReadResult<T = unknown> {
94
+ data: T;
95
+ /**
96
+ * The requested `entryPath` and slug as a percent-encoded URL path, plus `?branch=<name>`.
97
+ * Through an entry-type `entryPath` it is not the entry's URL. Compare entries by
98
+ * `meta.urlPath`.
99
+ */
100
+ path: string;
101
+ meta: ContentReadMeta;
102
+ }
92
103
  export interface ContentReader {
93
- read: <T = unknown>(input: ReadContentInput, message?: string) => Promise<{
94
- data: T;
95
- /**
96
- * The requested `entryPath` and slug as a percent-encoded URL path, plus `?branch=<name>`.
97
- * Through an entry-type `entryPath` it is not the entry's URL. Compare entries by
98
- * `meta.urlPath`.
99
- */
100
- path: string;
101
- meta: ContentReadMeta;
102
- }>;
104
+ read: <T = unknown>(input: ReadContentInput, message?: string) => Promise<ContentReadResult<T>>;
105
+ }
106
+ /** @internal The reader `CanopyContext` (context.ts) builds on. */
107
+ export interface ContentReaderInternal extends ContentReader {
108
+ /**
109
+ * Read the first of `inputs` that resolves, against one store resolved once for their branch,
110
+ * so a lookup trying several paths resolves the branch and its schema once. Every input must
111
+ * name the same branch and user. An error `isMiss` accepts moves on to the next input; null
112
+ * when every input misses, or when resolving the branch itself throws a miss.
113
+ */
114
+ readFirst: <T = unknown>(inputs: readonly ReadContentInput[], isMiss: (err: unknown, input: ReadContentInput) => boolean) => Promise<ContentReadResult<T> | null>;
103
115
  }
104
116
  /**
105
117
  * Server-side helper to read content directly from a branch workspace. A branch with no workspace
106
118
  * reads as NOT_FOUND unless `allowCreateBranch` or a `getBranchContext` resolver says otherwise.
107
119
  */
108
120
  export declare const createContentReader: (options: ContentReaderOptions) => ContentReader;
121
+ /** @internal {@link createContentReader} plus `readFirst`, for context.ts. */
122
+ export declare const createContentReaderInternal: (options: ContentReaderOptions) => ContentReaderInternal;