canopycms 0.0.67 → 0.0.68-int.93

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 (107) hide show
  1. package/dist/ai/handler.js +8 -0
  2. package/dist/api/admin-branch-health.js +12 -7
  3. package/dist/api/admin.d.ts +8 -8
  4. package/dist/api/branch-status.js +6 -2
  5. package/dist/api/client.d.ts +12 -0
  6. package/dist/api/client.js +35 -11
  7. package/dist/api/content.d.ts +6 -5
  8. package/dist/api/content.js +38 -39
  9. package/dist/api/entries.js +6 -8
  10. package/dist/api/github-sync.d.ts +10 -1
  11. package/dist/api/github-sync.js +20 -10
  12. package/dist/api/reference-options.js +1 -1
  13. package/dist/api/request-url.d.ts +8 -0
  14. package/dist/api/request-url.js +15 -0
  15. package/dist/api/resolve-references.js +2 -2
  16. package/dist/api/settings-helpers.d.ts +1 -1
  17. package/dist/api/settings-helpers.js +1 -4
  18. package/dist/authorization/content.d.ts +5 -4
  19. package/dist/authorization/content.js +5 -5
  20. package/dist/authorization/path.d.ts +11 -6
  21. package/dist/authorization/path.js +11 -6
  22. package/dist/authorization/types.d.ts +2 -2
  23. package/dist/branch-health.js +3 -3
  24. package/dist/branch-schema-cache.d.ts +5 -3
  25. package/dist/branch-schema-cache.js +29 -11
  26. package/dist/branch-workspace.js +2 -2
  27. package/dist/cli/cli.js +285 -168
  28. package/dist/cli/generate-ai-content.js +190 -109
  29. package/dist/cli/github-app-manifest.d.ts +4 -3
  30. package/dist/cli/github-app-manifest.js +4 -3
  31. package/dist/cli/init.js +15 -10
  32. package/dist/cli/sync.js +15 -13
  33. package/dist/config/schemas/config.d.ts +6 -3
  34. package/dist/config/schemas/config.js +3 -1
  35. package/dist/config/schemas/field.js +1 -0
  36. package/dist/config/types.d.ts +11 -2
  37. package/dist/config/validation.d.ts +6 -0
  38. package/dist/config/validation.js +37 -0
  39. package/dist/content-listing.d.ts +2 -2
  40. package/dist/content-listing.js +3 -3
  41. package/dist/content-reader.js +4 -4
  42. package/dist/content-store.d.ts +2 -0
  43. package/dist/content-store.js +2 -1
  44. package/dist/content-tree.js +1 -1
  45. package/dist/context.d.ts +16 -7
  46. package/dist/context.js +116 -67
  47. package/dist/editor/Editor.js +4 -4
  48. package/dist/editor/FormRenderer.js +20 -5
  49. package/dist/editor/admin/SystemHealthPanel.js +37 -6
  50. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  51. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  52. package/dist/editor/hooks/useBranchesData.js +3 -1
  53. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  54. package/dist/editor/hooks/useDraftManager.js +2 -1
  55. package/dist/editor/hooks/useEntriesData.js +2 -2
  56. package/dist/editor/hooks/useEntryManager.js +17 -11
  57. package/dist/entry-schema-registry.js +2 -1
  58. package/dist/git-manager.d.ts +20 -0
  59. package/dist/git-manager.js +50 -4
  60. package/dist/github-service.d.ts +8 -0
  61. package/dist/github-service.js +5 -1
  62. package/dist/http/handler.js +24 -9
  63. package/dist/http/index.d.ts +2 -0
  64. package/dist/http/index.js +2 -0
  65. package/dist/http/router.d.ts +2 -0
  66. package/dist/http/router.js +2 -1
  67. package/dist/http/worker-not-ready.d.ts +9 -0
  68. package/dist/http/worker-not-ready.js +17 -0
  69. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  70. package/dist/operating-mode/types.d.ts +0 -3
  71. package/dist/paths/index.d.ts +1 -1
  72. package/dist/paths/index.js +1 -1
  73. package/dist/paths/normalize.d.ts +7 -0
  74. package/dist/paths/normalize.js +9 -0
  75. package/dist/reference-resolver.d.ts +3 -3
  76. package/dist/reference-resolver.js +3 -2
  77. package/dist/resolve-canopy-user.js +2 -1
  78. package/dist/services.d.ts +12 -5
  79. package/dist/services.js +56 -56
  80. package/dist/settings-workspace.js +36 -0
  81. package/dist/static/seo.d.ts +2 -14
  82. package/dist/static/seo.js +2 -25
  83. package/dist/submission-attribution.d.ts +73 -0
  84. package/dist/submission-attribution.js +221 -0
  85. package/dist/sync-core.js +7 -8
  86. package/dist/types.d.ts +23 -2
  87. package/dist/utils/content-write-lock.d.ts +3 -7
  88. package/dist/utils/content-write-lock.js +6 -4
  89. package/dist/utils/debug.d.ts +8 -0
  90. package/dist/utils/debug.js +10 -2
  91. package/dist/utils/git.d.ts +28 -0
  92. package/dist/utils/git.js +38 -0
  93. package/dist/utils/provisioning-lock.d.ts +15 -5
  94. package/dist/utils/provisioning-lock.js +31 -11
  95. package/dist/utils/request-timing.d.ts +25 -0
  96. package/dist/utils/request-timing.js +101 -0
  97. package/dist/utils/url-prefix.d.ts +15 -0
  98. package/dist/utils/url-prefix.js +26 -0
  99. package/dist/worker/canopy-state.d.ts +45 -0
  100. package/dist/worker/canopy-state.js +75 -0
  101. package/dist/worker/git-sync.d.ts +4 -2
  102. package/dist/worker/git-sync.js +111 -24
  103. package/dist/worker/provisioned-workspace.d.ts +35 -0
  104. package/dist/worker/provisioned-workspace.js +50 -0
  105. package/dist/worker/rebase.js +75 -12
  106. package/dist/worker/task-runner.js +39 -4
  107. package/package.json +1 -1
package/dist/services.js CHANGED
@@ -9,8 +9,12 @@ import { operatingStrategy } from './operating-mode/index.js';
9
9
  import { BranchSchemaCache } from './branch-schema-cache.js';
10
10
  import { enqueueTask } from './task-queue/cms-task-queue.js';
11
11
  import { getTaskQueueDir } from './task-queue/task-queue-config.js';
12
- import { detectHeadBranch } from './utils/git.js';
12
+ import { detectHeadBranch, isCanopyInternalPath } from './utils/git.js';
13
13
  import { readsFromCheckout } from './build-mode.js';
14
+ import { timeRequestPhase } from './utils/request-timing.js';
15
+ import { BRANCH_META_DIR } from './branch-metadata-file.js';
16
+ import { appendTrailers, buildEditorTrailers, } from './submission-attribution.js';
17
+ import { getErrorMessage, redactCredentials } from './utils/error.js';
14
18
  /**
15
19
  * A per-instance active-branch detector with its own 5s TTL cache, in priority
16
20
  * order: an explicitly configured value; `defaultBaseBranch ?? 'main'` with no
@@ -97,7 +101,7 @@ async function _createCanopyServicesInternal(config, options) {
97
101
  const branchSchemaCache = options.branchSchemaCache ?? new BranchSchemaCache(config.mode);
98
102
  const checkBranchAccess = createCheckBranchAccess(config.defaultBranchAccess ?? 'deny', config);
99
103
  // Content access loads permissions dynamically from the settings branch (orphan git branch)
100
- const getSettingsBranchRoot = options.getSettingsBranchRoot ??
104
+ const ensureSettingsBranchRoot = options.getSettingsBranchRoot ??
101
105
  (async () => {
102
106
  const strategy = operatingStrategy(config.mode);
103
107
  const settingsRoot = strategy.getSettingsRoot();
@@ -111,9 +115,10 @@ async function _createCanopyServicesInternal(config, options) {
111
115
  });
112
116
  return settingsRoot;
113
117
  });
118
+ const getSettingsBranchRoot = () => timeRequestPhase('settingsRoot', ensureSettingsBranchRoot);
114
119
  const contentAccessDeps = {
115
120
  checkBranchAccess,
116
- loadPathPermissions,
121
+ loadPathPermissions: (repoRoot, mode) => timeRequestPhase('permissions', () => loadPathPermissions(repoRoot, mode)),
117
122
  defaultPathAccess: config.defaultPathAccess ?? 'deny',
118
123
  mode: config.mode,
119
124
  getSettingsBranchRoot,
@@ -164,15 +169,36 @@ async function _createCanopyServicesInternal(config, options) {
164
169
  // and success is reported though the commit never reached the remote. Push
165
170
  // whenever there is something new to send — we just committed, or the local
166
171
  // branch already had unpushed commits from an earlier attempt.
172
+ // canopycms's own state under .canopy-meta/ is never content, so it neither
173
+ // makes a commit worth creating nor gets staged into one.
167
174
  let committed = false;
168
- if (status.files.length > 0) {
169
- await git.add('.');
170
- await git.commit(options.message ?? `Submit ${options.context.branch.name}`);
175
+ if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
176
+ await git.addAllExceptCanopyState();
177
+ const trailers = buildEditorTrailers(options.submitter ? [options.submitter] : [], {
178
+ editedBy: config.gitEditedByTrailers ?? true,
179
+ coAuthoredBy: config.gitCoAuthoredByTrailers ?? false,
180
+ });
181
+ await git.commit(appendTrailers(options.message ?? `Submit ${options.context.branch.name}`, trailers));
171
182
  committed = true;
172
183
  }
173
184
  if (committed || (await git.hasUnpushedCommits(options.context.branch.name))) {
174
185
  await git.push(options.context.branch.name);
175
186
  }
187
+ // The list only feeds the PR body, so a failure to compute it falls back to
188
+ // this submit's own working-tree changes rather than failing a submit that
189
+ // has already been pushed.
190
+ let changedPaths;
191
+ try {
192
+ changedPaths = await git.listChangedPathsSinceBase();
193
+ }
194
+ catch (err) {
195
+ console.warn(`CanopyCMS: Could not list the changes on ${options.context.branch.name} against its base; ` +
196
+ "the PR body lists only this submit's changes:", redactCredentials(getErrorMessage(err)));
197
+ changedPaths = status.files.map((f) => f.path);
198
+ }
199
+ return {
200
+ changedPaths: changedPaths.filter((p) => !p.startsWith(`${BRANCH_META_DIR}/`)),
201
+ };
176
202
  };
177
203
  // Must be initialized before closures that reference it (commitToSettingsBranch)
178
204
  let githubService;
@@ -233,57 +259,31 @@ async function _createCanopyServicesInternal(config, options) {
233
259
  error: error instanceof Error ? error.message : 'Push failed',
234
260
  };
235
261
  }
236
- // Create or update PR — dual-path like content branches (api/github-sync.ts)
237
- if (options.createPR !== false) {
238
- // Permissions and groups are read live from the settings workspace
239
- // (getSettingsBranchRoot), never from this PR's base branch, so the
240
- // change took effect when it was committed and pushed above, before
241
- // this PR existed. Merging re-activates nothing; it only records the
242
- // change on `base` for review and audit history.
243
- const settingsPRBody = 'Automated PR for permission and group changes. These changes already took ' +
244
- 'effect in the CMS when they were saved — merging this PR does not change ' +
245
- "what's live; it only records the change here for review and audit history.";
246
- // Direct path: githubService available (has internet)
247
- if (githubService) {
248
- let prUrl;
249
- try {
250
- // Settings-branch PRs never pass markReadyIfDraft: a settings sync
251
- // has no explicit "submit for review" step the way a content submit
252
- // does, so an existing draft PR stays draft until an admin says so.
253
- const result = await githubService.createOrUpdatePR({
254
- head: settingsBranch,
255
- base: config.defaultBaseBranch ?? 'main',
256
- title: 'Update permissions and groups',
257
- body: settingsPRBody,
258
- });
259
- prUrl = result.url;
260
- }
261
- catch (err) {
262
- console.warn('Failed to create/update PR:', err);
263
- return { committed: true, pushed: true, syncStatus: 'sync-failed' };
264
- }
265
- return { committed: true, pushed: true, prUrl, syncStatus: 'synced' };
266
- }
267
- // Async path: queue task for worker (prod Lambda has no internet)
268
- const taskDir = getTaskQueueDir(config);
269
- try {
270
- await enqueueTask(taskDir, {
271
- action: 'push-and-create-or-update-pr',
272
- payload: {
273
- branch: settingsBranch,
274
- baseBranch: config.defaultBaseBranch ?? 'main',
275
- title: 'Update permissions and groups',
276
- body: settingsPRBody,
277
- },
278
- });
279
- return { committed: true, pushed: true, syncStatus: 'pending-sync' };
280
- }
281
- catch (err) {
282
- console.warn('Failed to enqueue settings PR task:', err);
283
- return { committed: true, pushed: true, syncStatus: 'sync-failed' };
284
- }
262
+ // The settings branch is an orphan: it shares no history with the base
263
+ // branch, so GitHub rejects a PR for it. The change took effect when it
264
+ // was committed and pushed above (permissions and groups are read live
265
+ // from the settings workspace); the only GitHub step is mirroring the
266
+ // branch, so there is never a PR.
267
+ if (!operatingStrategy(mode).supportsPullRequests()) {
268
+ return { committed: true, pushed: true };
269
+ }
270
+ // With a githubService the push above already reached GitHub: both it and
271
+ // the workspace remote come from `defaultRemoteUrl`. Without one (prod
272
+ // Lambda has no internet) the worker pushes it.
273
+ if (githubService) {
274
+ return { committed: true, pushed: true, syncStatus: 'synced' };
275
+ }
276
+ try {
277
+ await enqueueTask(getTaskQueueDir(config), {
278
+ action: 'push-branch',
279
+ payload: { branch: settingsBranch },
280
+ });
281
+ return { committed: true, pushed: true, syncStatus: 'pending-sync' };
282
+ }
283
+ catch (err) {
284
+ console.warn('Failed to enqueue settings push task:', err);
285
+ return { committed: true, pushed: true, syncStatus: 'sync-failed' };
285
286
  }
286
- return { committed: true, pushed: true };
287
287
  }
288
288
  catch (error) {
289
289
  return {
@@ -9,6 +9,35 @@ const log = createDebugLogger({ prefix: 'SettingsWorkspace' });
9
9
  // In-memory lock against concurrent init within one process. One lock suffices,
10
10
  // unlike content branches, which need one per branch.
11
11
  let settingsInitLock = null;
12
+ /**
13
+ * Settings workspaces this process has fully ensured, keyed by {@link ensuredKey}. A hit
14
+ * skips the guard, the init lock and initializeWorkspace's dozen git subprocesses, which
15
+ * otherwise ran on every API request. It is sound because that pass never fetched, pulled or
16
+ * reset the settings branch, so settings freshness never came from it: every process reads the
17
+ * one shared workspace. Everything it verified is fixed for the process: the settings-branch name resolves once
18
+ * from config, the remote URL comes from config, and nothing in CanopyCMS checks the settings
19
+ * workspace out onto another branch. groups.json and permissions.json are still read from
20
+ * disk on every request; only the provisioning is memoized. Failures are never recorded. In
21
+ * dev, a hit also skips re-seeding a deleted `.canopy-dev/remote.git`; a dev-server restart
22
+ * re-seeds it.
23
+ *
24
+ * A hit still reads `.git/HEAD`, so a workspace removed, re-cloned onto another branch, or
25
+ * caught mid-clone by another process (HEAD still on the base branch) misses and runs the full
26
+ * path, guard and lock included.
27
+ */
28
+ const ensuredSettingsWorkspaces = new Set();
29
+ function ensuredKey(options) {
30
+ return `${path.resolve(options.settingsRoot)}\0${options.branchName}`;
31
+ }
32
+ async function checkedOutOn(settingsRoot, branchName) {
33
+ try {
34
+ const head = await fs.readFile(path.join(settingsRoot, '.git', 'HEAD'), 'utf-8');
35
+ return head.trim() === `ref: refs/heads/${branchName}`;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
12
41
  const SETTINGS_INIT_LOCK_DIR = '.settings-init';
13
42
  const SETTINGS_INIT_LOCK_NAME = 'lock';
14
43
  /**
@@ -122,6 +151,12 @@ export class SettingsWorkspaceManager {
122
151
  this.config = config;
123
152
  }
124
153
  async ensureGitWorkspace(options) {
154
+ const key = ensuredKey(options);
155
+ if (ensuredSettingsWorkspaces.has(key)) {
156
+ if (await checkedOutOn(options.settingsRoot, options.branchName))
157
+ return;
158
+ ensuredSettingsWorkspaces.delete(key);
159
+ }
125
160
  return log.timed('workspace', 'ensureGitWorkspace', async () => {
126
161
  // Layer 1: In-memory lock (prevents redundant async calls within same process)
127
162
  if (settingsInitLock) {
@@ -170,6 +205,7 @@ export class SettingsWorkspaceManager {
170
205
  gitBotAuthorName: this.config.gitBotAuthorName,
171
206
  gitBotAuthorEmail: this.config.gitBotAuthorEmail,
172
207
  });
208
+ ensuredSettingsWorkspaces.add(key);
173
209
  }
174
210
  finally {
175
211
  try {
@@ -9,14 +9,14 @@
9
9
  * entry-schema.ts, which builds a schema group from these same names so the schema side and
10
10
  * the read side cannot drift.
11
11
  */
12
- import { isAbsoluteUrl, stripTrailingSlashes } from '../utils/url-prefix.js';
12
+ import { isAbsoluteUrl, stripTrailingSlashes, withTrailingSlash } from '../utils/url-prefix.js';
13
13
  /**
14
14
  * Re-exported so this module's public surface (and `canopycms/server`'s, via `static/index.ts`)
15
15
  * is unchanged by their move to `utils/url-prefix.ts` — where they now sit beside
16
16
  * `joinUrlPrefix`, the shared join that `assets/asset-url.ts` also needs and that must not
17
17
  * import anything under `static/`.
18
18
  */
19
- export { isAbsoluteUrl, stripTrailingSlashes };
19
+ export { isAbsoluteUrl, stripTrailingSlashes, withTrailingSlash };
20
20
  /** og:type values covered by the recommended group. */
21
21
  export type SeoOgType = 'website' | 'article' | 'profile';
22
22
  /** twitter:card values covered by the recommended group. */
@@ -87,18 +87,6 @@ export declare function extractSeoFields(entryData: unknown, opts?: ExtractSeoFi
87
87
  * resolves for anyone holding the link.
88
88
  */
89
89
  export declare function isNoindexEntry(entryData: unknown, opts?: SeoFieldLocation): boolean;
90
- /**
91
- * Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
92
- *
93
- * Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
94
- * `/blog/rss.xml`) alone, and never doubles an existing slash.
95
- *
96
- * A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
97
- * and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
98
- * `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
99
- * with a trailing slash" means and breaks the URL).
100
- */
101
- export declare function withTrailingSlash(path: string): string;
102
90
  export interface ResolveSeoUrlOptions {
103
91
  /** Site origin (e.g. `https://example.com`). Trailing slashes are stripped. */
104
92
  siteUrl?: string;
@@ -9,14 +9,14 @@
9
9
  * entry-schema.ts, which builds a schema group from these same names so the schema side and
10
10
  * the read side cannot drift.
11
11
  */
12
- import { isAbsoluteUrl, joinUrlPrefix, stripTrailingSlashes, toSameOriginPath, } from '../utils/url-prefix.js';
12
+ import { isAbsoluteUrl, joinUrlPrefix, stripTrailingSlashes, toSameOriginPath, withTrailingSlash, } from '../utils/url-prefix.js';
13
13
  /**
14
14
  * Re-exported so this module's public surface (and `canopycms/server`'s, via `static/index.ts`)
15
15
  * is unchanged by their move to `utils/url-prefix.ts` — where they now sit beside
16
16
  * `joinUrlPrefix`, the shared join that `assets/asset-url.ts` also needs and that must not
17
17
  * import anything under `static/`.
18
18
  */
19
- export { isAbsoluteUrl, stripTrailingSlashes };
19
+ export { isAbsoluteUrl, stripTrailingSlashes, withTrailingSlash };
20
20
  /**
21
21
  * The recommended SEO field names. `defineSeoFieldGroup()` emits a schema group using exactly
22
22
  * these names, so an adopter who uses both ends needs no configuration at all.
@@ -106,29 +106,6 @@ export function extractSeoFields(entryData, opts = {}) {
106
106
  export function isNoindexEntry(entryData, opts = {}) {
107
107
  return extractSeoFields(entryData, opts).noindex === true;
108
108
  }
109
- /**
110
- * Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
111
- *
112
- * Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
113
- * `/blog/rss.xml`) alone, and never doubles an existing slash.
114
- *
115
- * A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
116
- * and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
117
- * `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
118
- * with a trailing slash" means and breaks the URL).
119
- */
120
- export function withTrailingSlash(path) {
121
- const splitIndex = path.search(/[?#]/);
122
- const base = splitIndex === -1 ? path : path.slice(0, splitIndex);
123
- const suffix = splitIndex === -1 ? '' : path.slice(splitIndex);
124
- const withLeading = base.startsWith('/') ? base : `/${base}`;
125
- if (withLeading === '/' || withLeading.endsWith('/'))
126
- return withLeading + suffix;
127
- const lastSegment = withLeading.slice(withLeading.lastIndexOf('/') + 1);
128
- if (lastSegment.includes('.'))
129
- return withLeading + suffix;
130
- return `${withLeading}/${suffix}`;
131
- }
132
109
  /**
133
110
  * Resolve a possibly-relative URL for public emission (canonical tag, sitemap URL, OG image).
134
111
  *
@@ -0,0 +1,73 @@
1
+ import type { CanopyUser } from './user.js';
2
+ /**
3
+ * Who submitted (and edited) a branch, recorded in the submit commit's trailers
4
+ * and the pull request body. The bot stays the commit author; these records are
5
+ * the only place the editing user appears.
6
+ *
7
+ * Display names, ids and emails come from the auth provider and end up in git
8
+ * history and in GitHub-rendered Markdown, so each passes through a sanitizer
9
+ * below before it is written anywhere.
10
+ */
11
+ export interface SubmissionEditor {
12
+ userId: string;
13
+ name?: string;
14
+ email?: string;
15
+ }
16
+ /**
17
+ * The PR body region canopycms owns. On update only the text between these
18
+ * markers is replaced, so anything a human wrote around it survives.
19
+ * @internal Exported for tests.
20
+ */
21
+ export declare const PR_SECTION_START = "<!-- canopycms:submission:start -->";
22
+ /** @internal Exported for tests. */
23
+ export declare const PR_SECTION_END = "<!-- canopycms:submission:end -->";
24
+ /**
25
+ * A display name safe for a single line of git trailer or Markdown, or undefined.
26
+ * @internal Exported for tests.
27
+ */
28
+ export declare function sanitizeDisplayName(raw: string | undefined): string | undefined;
29
+ /**
30
+ * The auth user id unchanged, or undefined if it holds characters unsafe to record.
31
+ * @internal Exported for tests.
32
+ */
33
+ export declare function sanitizeUserId(raw: string | undefined): string | undefined;
34
+ /**
35
+ * The email as given if it is a plain single-address email, otherwise undefined.
36
+ * @internal Exported for tests.
37
+ */
38
+ export declare function sanitizeEmail(raw: string | undefined): string | undefined;
39
+ /** The submitter as a SubmissionEditor, or undefined for an anonymous user. */
40
+ export declare function submissionEditorFromUser(user: CanopyUser): SubmissionEditor | undefined;
41
+ export interface CommitTrailerOptions {
42
+ /** One `Edited-by: Name (id)` per editor. */
43
+ editedBy: boolean;
44
+ /** One `Co-authored-by: Name <email>` per editor whose email is valid. */
45
+ coAuthoredBy: boolean;
46
+ }
47
+ /** Git trailer lines for the given editors, deduplicated by user id. */
48
+ export declare function buildEditorTrailers(editors: readonly SubmissionEditor[], options: CommitTrailerOptions): string[];
49
+ /**
50
+ * `subject`, then a blank line and the trailer block when there are trailers.
51
+ * Git reads trailers only from the last paragraph, so the blank line is what
52
+ * makes them trailers rather than part of the subject.
53
+ */
54
+ export declare function appendTrailers(subject: string, trailers: readonly string[]): string;
55
+ export interface PrSectionInput {
56
+ /** The branch description the editor wrote; free Markdown. */
57
+ description?: string;
58
+ submitter?: SubmissionEditor;
59
+ /** Editors other than the submitter, when known. */
60
+ editors?: readonly SubmissionEditor[];
61
+ /** Repo-relative paths changed on the branch. */
62
+ changedPaths: readonly string[];
63
+ }
64
+ /** The canopycms-owned PR body section, including its start and end markers. */
65
+ export declare function buildPrSection(input: PrSectionInput): string;
66
+ /**
67
+ * `existing` with its canopycms section replaced by `section`, keeping every
68
+ * character outside the markers. The section is the first end marker together
69
+ * with the nearest start marker before it, so a lone start marker a human quoted
70
+ * earlier in the body is skipped. With no such pair, stray markers are
71
+ * removed and `section` is appended after the human text.
72
+ */
73
+ export declare function mergePrSection(existing: string | null | undefined, section: string): string;
@@ -0,0 +1,221 @@
1
+ import { canopyLogWarn } from './utils/logger.js';
2
+ const MAX_NAME_LENGTH = 80;
3
+ const MAX_ID_LENGTH = 128;
4
+ const MAX_EMAIL_LENGTH = 254;
5
+ const MAX_DESCRIPTION_LENGTH = 10_000;
6
+ const MAX_LISTED_ENTRIES = 100;
7
+ /**
8
+ * The PR body region canopycms owns. On update only the text between these
9
+ * markers is replaced, so anything a human wrote around it survives.
10
+ * @internal Exported for tests.
11
+ */
12
+ export const PR_SECTION_START = '<!-- canopycms:submission:start -->';
13
+ /** @internal Exported for tests. */
14
+ export const PR_SECTION_END = '<!-- canopycms:submission:end -->';
15
+ // Cc (C0/C1 controls incl. newlines), Cf (zero-width and bidi overrides), Zl/Zp
16
+ // (Unicode line/paragraph separators) can forge a trailer line or hide text; Cs
17
+ // (a lone surrogate) reaches git as U+FFFD, since Node encodes it so.
18
+ const INVISIBLE_OR_CONTROL = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/gu;
19
+ // `<>` would end a Co-authored-by email or open HTML / a section marker, `()`
20
+ // would let a name forge the id that follows it, a backtick would close the
21
+ // code span names render in, and a backslash is Markdown's escape character.
22
+ const STRUCTURAL = /[<>()`\\]/g;
23
+ function truncate(value, max) {
24
+ const chars = Array.from(value);
25
+ return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : value;
26
+ }
27
+ /**
28
+ * A changed path for a code span: only what could break out of the span or out
29
+ * of the section is removed, so `app/(group)/page.mdx` stays distinct from
30
+ * `app/page.mdx`. No NFKC, for the same reason.
31
+ */
32
+ function cleanPath(raw) {
33
+ const cleaned = raw.replace(INVISIBLE_OR_CONTROL, ' ').replace(/[<>`]/g, '').trim();
34
+ return cleaned ? truncate(cleaned, 300) : undefined;
35
+ }
36
+ function cleanText(raw, max) {
37
+ if (raw === undefined)
38
+ return undefined;
39
+ const cleaned = raw
40
+ .normalize('NFKC')
41
+ .replace(INVISIBLE_OR_CONTROL, ' ')
42
+ .replace(STRUCTURAL, '')
43
+ .replace(/\s+/g, ' ')
44
+ .trim();
45
+ return cleaned ? truncate(cleaned, max) : undefined;
46
+ }
47
+ /**
48
+ * A display name safe for a single line of git trailer or Markdown, or undefined.
49
+ * @internal Exported for tests.
50
+ */
51
+ export function sanitizeDisplayName(raw) {
52
+ return cleanText(raw, MAX_NAME_LENGTH);
53
+ }
54
+ // Ids and emails are accepted as the provider issued them or rejected, never
55
+ // cleaned: cleaning could merge two users or attribute an edit to someone else.
56
+ // A trailer still swaps in look-alikes for display (trailerText).
57
+ const UNSAFE_VERBATIM_CHAR = /[\s\p{Cc}\p{Cf}\p{Cs}<>()`\\]/u;
58
+ /**
59
+ * The auth user id unchanged, or undefined if it holds characters unsafe to record.
60
+ * @internal Exported for tests.
61
+ */
62
+ export function sanitizeUserId(raw) {
63
+ if (!raw || Array.from(raw).length > MAX_ID_LENGTH || UNSAFE_VERBATIM_CHAR.test(raw))
64
+ return undefined;
65
+ return raw;
66
+ }
67
+ const EMAIL_SHAPE = /^[^\s@<>()"',;:\\[\]]+@[^\s@<>()"',;:\\[\]]+\.[^\s@<>()"',;:\\[\]]+$/;
68
+ /**
69
+ * The email as given if it is a plain single-address email, otherwise undefined.
70
+ * @internal Exported for tests.
71
+ */
72
+ export function sanitizeEmail(raw) {
73
+ if (raw === undefined)
74
+ return undefined;
75
+ const trimmed = raw.trim();
76
+ // `\s` misses most C0/C1 controls, and a NUL makes git's spawn throw. `#` is
77
+ // legal in an address but GitHub links `#12` in a commit message.
78
+ if (trimmed.length > MAX_EMAIL_LENGTH ||
79
+ UNSAFE_VERBATIM_CHAR.test(trimmed) ||
80
+ trimmed.includes('#'))
81
+ return undefined;
82
+ return EMAIL_SHAPE.test(trimmed) ? trimmed : undefined;
83
+ }
84
+ /** The submitter as a SubmissionEditor, or undefined for an anonymous user. */
85
+ export function submissionEditorFromUser(user) {
86
+ if (user.type !== 'authenticated')
87
+ return undefined;
88
+ return { userId: user.userId, name: user.name, email: user.email };
89
+ }
90
+ function cleanEditors(editors) {
91
+ const seen = new Set();
92
+ const result = [];
93
+ for (const editor of editors) {
94
+ const userId = sanitizeUserId(editor.userId);
95
+ if (!userId) {
96
+ // The id itself stays out of the log: it is the unsafe value.
97
+ if (editor.userId) {
98
+ canopyLogWarn('CanopyCMS: Not recording an editor whose user id is unsafe to write to git');
99
+ }
100
+ continue;
101
+ }
102
+ if (seen.has(userId))
103
+ continue;
104
+ seen.add(userId);
105
+ result.push({
106
+ userId,
107
+ name: sanitizeDisplayName(editor.name),
108
+ email: sanitizeEmail(editor.email),
109
+ });
110
+ }
111
+ return result;
112
+ }
113
+ /**
114
+ * GitHub autolinks commit messages: `@user` mentions, `scheme://` URLs, and `#12` /
115
+ * `GH-12` issue references, which a closing keyword ("Closes #12") turns into an
116
+ * issue close once the commit reaches the default branch. A trailer value gets
117
+ * `@` and `#` swapped for look-alikes and the other two patterns broken. Its
118
+ * input is already single-line and structure-free.
119
+ */
120
+ function trailerText(value) {
121
+ return value
122
+ .replace(/@/g, '@')
123
+ .replace(/#/g, '#')
124
+ .replace(/:\/\//g, ': //')
125
+ .replace(/\bGH-(?=\d)/gi, (m) => `${m.slice(0, 2)}‐`);
126
+ }
127
+ /** Git trailer lines for the given editors, deduplicated by user id. */
128
+ export function buildEditorTrailers(editors, options) {
129
+ const trailers = [];
130
+ const clean = cleanEditors(editors);
131
+ if (options.editedBy) {
132
+ for (const editor of clean) {
133
+ trailers.push(editor.name
134
+ ? `Edited-by: ${trailerText(editor.name)} (${trailerText(editor.userId)})`
135
+ : `Edited-by: ${trailerText(editor.userId)}`);
136
+ }
137
+ }
138
+ if (options.coAuthoredBy) {
139
+ for (const editor of clean) {
140
+ if (!editor.email)
141
+ continue;
142
+ trailers.push(`Co-authored-by: ${trailerText(editor.name ?? editor.userId)} <${editor.email}>`);
143
+ }
144
+ }
145
+ return trailers;
146
+ }
147
+ /**
148
+ * `subject`, then a blank line and the trailer block when there are trailers.
149
+ * Git reads trailers only from the last paragraph, so the blank line is what
150
+ * makes them trailers rather than part of the subject.
151
+ */
152
+ export function appendTrailers(subject, trailers) {
153
+ return trailers.length > 0 ? `${subject.trimEnd()}\n\n${trailers.join('\n')}` : subject;
154
+ }
155
+ /**
156
+ * Renders as inline code: GitHub neither autolinks, mentions nor interprets HTML
157
+ * inside a code span. The input is already free of backticks.
158
+ */
159
+ function codeSpan(value) {
160
+ return `\`${value}\``;
161
+ }
162
+ /**
163
+ * Free Markdown with every HTML comment opener and closer escaped, so the text
164
+ * can neither close the canopycms section early nor open a comment that hides
165
+ * the rest of the body. Escaped comments render as their literal text.
166
+ */
167
+ function neutralizeComments(text) {
168
+ return text.replace(/<!--/g, '&lt;!--').replace(/--!?>/g, '--&gt;');
169
+ }
170
+ function editorLabel(editor) {
171
+ return editor.name
172
+ ? `${codeSpan(editor.name)} (${codeSpan(editor.userId)})`
173
+ : codeSpan(editor.userId);
174
+ }
175
+ /** The canopycms-owned PR body section, including its start and end markers. */
176
+ export function buildPrSection(input) {
177
+ const parts = [];
178
+ const [submitter] = input.submitter ? cleanEditors([input.submitter]) : [];
179
+ parts.push(submitter
180
+ ? `Submitted by ${editorLabel(submitter)} via CanopyCMS.`
181
+ : 'Submitted via CanopyCMS.');
182
+ const others = cleanEditors(input.editors ?? []).filter((e) => e.userId !== submitter?.userId);
183
+ if (others.length > 0) {
184
+ parts.push(`Also edited by: ${others.map(editorLabel).join(', ')}`);
185
+ }
186
+ const paths = [...new Set(input.changedPaths)]
187
+ .map(cleanPath)
188
+ .filter((p) => p !== undefined);
189
+ if (paths.length > 0) {
190
+ const listed = paths.slice(0, MAX_LISTED_ENTRIES).map((p) => `- ${codeSpan(p)}`);
191
+ if (paths.length > MAX_LISTED_ENTRIES) {
192
+ listed.push(`- …and ${paths.length - MAX_LISTED_ENTRIES} more`);
193
+ }
194
+ parts.push(`**Changed entries (${paths.length})**\n\n${listed.join('\n')}`);
195
+ }
196
+ // Last, so an unclosed fence or `<details>` in free Markdown cannot swallow
197
+ // the attribution above it.
198
+ const description = input.description?.trim();
199
+ if (description)
200
+ parts.push(neutralizeComments(truncate(description, MAX_DESCRIPTION_LENGTH)));
201
+ return `${PR_SECTION_START}\n${parts.join('\n\n')}\n${PR_SECTION_END}`;
202
+ }
203
+ /**
204
+ * `existing` with its canopycms section replaced by `section`, keeping every
205
+ * character outside the markers. The section is the first end marker together
206
+ * with the nearest start marker before it, so a lone start marker a human quoted
207
+ * earlier in the body is skipped. With no such pair, stray markers are
208
+ * removed and `section` is appended after the human text.
209
+ */
210
+ export function mergePrSection(existing, section) {
211
+ const body = existing ?? '';
212
+ for (let end = body.indexOf(PR_SECTION_END); end >= 0;) {
213
+ const start = body.lastIndexOf(PR_SECTION_START, end - PR_SECTION_START.length);
214
+ if (start >= 0) {
215
+ return body.slice(0, start) + section + body.slice(end + PR_SECTION_END.length);
216
+ }
217
+ end = body.indexOf(PR_SECTION_END, end + PR_SECTION_END.length);
218
+ }
219
+ const human = body.split(PR_SECTION_START).join('').split(PR_SECTION_END).join('').trimEnd();
220
+ return human ? `${human}\n\n${section}` : section;
221
+ }
package/dist/sync-core.js CHANGED
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { simpleGit } from 'simple-git';
9
9
  import { invalidateBranchContentCaches } from './content-index-generation.js';
10
10
  import { filePathExists } from './utils/fs.js';
11
+ import { isCanopyInternalPath, stageAllExceptCanopyState } from './utils/git.js';
11
12
  /** Git tag marking the last known sync point, used as the merge base for `sync both` 3-way merges. */
12
13
  export const SYNC_BASE_TAG = 'canopycms-sync-base';
13
14
  /** Validate that a resolved path stays within the expected parent directory. */
@@ -154,15 +155,13 @@ export async function pushContentToWorkspace(options) {
154
155
  // The content dir was replaced wholesale, so ContentStore ID indexes rooted
155
156
  // at this workspace must be marked stale — in this process and (via the
156
157
  // on-disk generation marker) in every other process sharing the filesystem.
157
- // Done in the finally AFTER the git add/commit below: the marker lives under
158
- // .canopy-meta/ inside the clone, and writing it first would stage it into
159
- // the sync commit via `add -A` (production workspaces git-exclude
160
- // .canopy-meta/, but this function shouldn't depend on that).
158
+ // Done in the finally AFTER the git add/commit below, and staging skips
159
+ // .canopy-meta/, where the marker lives.
161
160
  try {
162
161
  const wsGit = simpleGit({ baseDir: branchPath });
163
- await wsGit.add('-A');
164
- const postStatus = await wsGit.status();
165
- if (postStatus.files.length === 0) {
162
+ await stageAllExceptCanopyState(wsGit);
163
+ const staged = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
164
+ if (staged.length === 0) {
166
165
  if (baseTag)
167
166
  await wsGit.tag(['-f', baseTag]);
168
167
  return { fileCount: 0 };
@@ -170,7 +169,7 @@ export async function pushContentToWorkspace(options) {
170
169
  await wsGit.commit(commitMessage ?? 'sync: update content from working tree');
171
170
  if (baseTag)
172
171
  await wsGit.tag(['-f', baseTag]);
173
- return { fileCount: postStatus.files.length };
172
+ return { fileCount: staged.length };
174
173
  }
175
174
  finally {
176
175
  await invalidateBranchContentCaches(branchPath);