canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
package/dist/services.js CHANGED
@@ -9,8 +9,13 @@ 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';
18
+ import { withContentWriteLock } from './utils/content-write-lock.js';
14
19
  /**
15
20
  * A per-instance active-branch detector with its own 5s TTL cache, in priority
16
21
  * order: an explicitly configured value; `defaultBaseBranch ?? 'main'` with no
@@ -97,7 +102,7 @@ async function _createCanopyServicesInternal(config, options) {
97
102
  const branchSchemaCache = options.branchSchemaCache ?? new BranchSchemaCache(config.mode);
98
103
  const checkBranchAccess = createCheckBranchAccess(config.defaultBranchAccess ?? 'deny', config);
99
104
  // Content access loads permissions dynamically from the settings branch (orphan git branch)
100
- const getSettingsBranchRoot = options.getSettingsBranchRoot ??
105
+ const ensureSettingsBranchRoot = options.getSettingsBranchRoot ??
101
106
  (async () => {
102
107
  const strategy = operatingStrategy(config.mode);
103
108
  const settingsRoot = strategy.getSettingsRoot();
@@ -111,9 +116,10 @@ async function _createCanopyServicesInternal(config, options) {
111
116
  });
112
117
  return settingsRoot;
113
118
  });
119
+ const getSettingsBranchRoot = () => timeRequestPhase('settingsRoot', ensureSettingsBranchRoot);
114
120
  const contentAccessDeps = {
115
121
  checkBranchAccess,
116
- loadPathPermissions,
122
+ loadPathPermissions: (repoRoot, mode) => timeRequestPhase('permissions', () => loadPathPermissions(repoRoot, mode)),
117
123
  defaultPathAccess: config.defaultPathAccess ?? 'deny',
118
124
  mode: config.mode,
119
125
  getSettingsBranchRoot,
@@ -137,8 +143,13 @@ async function _createCanopyServicesInternal(config, options) {
137
143
  name: config.gitBotAuthorName,
138
144
  email: config.gitBotAuthorEmail,
139
145
  });
140
- await git.add(options.files);
141
- await git.commit(options.message);
146
+ // [SYNC-C1] Unlocked, a commit made while a rebase is stopped on a conflict
147
+ // lands on its detached head with the rebase's half-resolved index, and the
148
+ // rebase's `--abort` discards it.
149
+ await withContentWriteLock(options.context.branchRoot, async () => {
150
+ await git.add(options.files);
151
+ await git.commit(options.message);
152
+ });
142
153
  };
143
154
  const submitBranch = async (options) => {
144
155
  // Defense-in-depth: refuse to push the base branch to itself even if the
@@ -156,23 +167,53 @@ async function _createCanopyServicesInternal(config, options) {
156
167
  name: config.gitBotAuthorName,
157
168
  email: config.gitBotAuthorEmail,
158
169
  });
159
- await git.checkoutBranch(options.context.branch.name);
160
- const status = await git.status();
161
- // Commit and push answer two DIFFERENT questions. Committing cleans the
162
- // working tree, so one combined "tree is dirty" gate makes a retry after a
163
- // failed push a silent no-op: nothing left to commit, the block is skipped,
164
- // and success is reported though the commit never reached the remote. Push
165
- // whenever there is something new to send — we just committed, or the local
166
- // branch already had unpushed commits from an earlier attempt.
167
- let committed = false;
168
- if (status.files.length > 0) {
169
- await git.add('.');
170
- await git.commit(options.message ?? `Submit ${options.context.branch.name}`);
171
- committed = true;
170
+ // [SYNC-C1] Commits the whole working tree, so it holds the branch's
171
+ // content-write lock from checkout through push. Unlocked, its checkout
172
+ // succeeds while the worker's rebase is stopped on a conflict, the commit
173
+ // lands on the branch, and the rebase's `--abort` resets the branch past it
174
+ // after this reported success. The push stays inside so no rebase rewrites
175
+ // the commit between commit and push.
176
+ const submitted = await withContentWriteLock(options.context.branchRoot, async () => {
177
+ await git.checkoutBranch(options.context.branch.name);
178
+ const status = await git.status();
179
+ // Commit and push answer two DIFFERENT questions. Committing cleans the
180
+ // working tree, so one combined "tree is dirty" gate makes a retry after a
181
+ // failed push a silent no-op: nothing left to commit, the block is skipped,
182
+ // and success is reported though the commit never reached the remote. Push
183
+ // whenever there is something new to send — we just committed, or the local
184
+ // branch already had unpushed commits from an earlier attempt.
185
+ // canopycms's own state under .canopy-meta/ is never content, so it neither
186
+ // makes a commit worth creating nor gets staged into one.
187
+ let committed = false;
188
+ if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
189
+ await git.addAllExceptCanopyState();
190
+ const trailers = buildEditorTrailers(options.submitter ? [options.submitter] : [], {
191
+ editedBy: config.gitEditedByTrailers ?? true,
192
+ coAuthoredBy: config.gitCoAuthoredByTrailers ?? false,
193
+ });
194
+ await git.commit(appendTrailers(options.message ?? `Submit ${options.context.branch.name}`, trailers));
195
+ committed = true;
196
+ }
197
+ if (committed || (await git.hasUnpushedCommits(options.context.branch.name))) {
198
+ await git.push(options.context.branch.name);
199
+ }
200
+ return status;
201
+ });
202
+ // The list only feeds the PR body, so a failure to compute it falls back to
203
+ // this submit's own working-tree changes rather than failing a submit that
204
+ // has already been pushed.
205
+ let changedPaths;
206
+ try {
207
+ changedPaths = await git.listChangedPathsSinceBase();
172
208
  }
173
- if (committed || (await git.hasUnpushedCommits(options.context.branch.name))) {
174
- await git.push(options.context.branch.name);
209
+ catch (err) {
210
+ console.warn(`CanopyCMS: Could not list the changes on ${options.context.branch.name} against its base; ` +
211
+ "the PR body lists only this submit's changes:", redactCredentials(getErrorMessage(err)));
212
+ changedPaths = submitted.files.map((f) => f.path);
175
213
  }
214
+ return {
215
+ changedPaths: changedPaths.filter((p) => !p.startsWith(`${BRANCH_META_DIR}/`)),
216
+ };
176
217
  };
177
218
  // Must be initialized before closures that reference it (commitToSettingsBranch)
178
219
  let githubService;
@@ -233,57 +274,31 @@ async function _createCanopyServicesInternal(config, options) {
233
274
  error: error instanceof Error ? error.message : 'Push failed',
234
275
  };
235
276
  }
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
- }
277
+ // The settings branch is an orphan: it shares no history with the base
278
+ // branch, so GitHub rejects a PR for it. The change took effect when it
279
+ // was committed and pushed above (permissions and groups are read live
280
+ // from the settings workspace); the only GitHub step is mirroring the
281
+ // branch, so there is never a PR.
282
+ if (!operatingStrategy(mode).supportsPullRequests()) {
283
+ return { committed: true, pushed: true };
284
+ }
285
+ // With a githubService the push above already reached GitHub: both it and
286
+ // the workspace remote come from `defaultRemoteUrl`. Without one (prod
287
+ // Lambda has no internet) the worker pushes it.
288
+ if (githubService) {
289
+ return { committed: true, pushed: true, syncStatus: 'synced' };
290
+ }
291
+ try {
292
+ await enqueueTask(getTaskQueueDir(config), {
293
+ action: 'push-branch',
294
+ payload: { branch: settingsBranch },
295
+ });
296
+ return { committed: true, pushed: true, syncStatus: 'pending-sync' };
297
+ }
298
+ catch (err) {
299
+ console.warn('Failed to enqueue settings push task:', err);
300
+ return { committed: true, pushed: true, syncStatus: 'sync-failed' };
285
301
  }
286
- return { committed: true, pushed: true };
287
302
  }
288
303
  catch (error) {
289
304
  return {
@@ -8,8 +8,8 @@ import type { OperatingMode } from './operating-mode/index.js';
8
8
  * its target, and `GitManager.initializeWorkspace` clones INTO the settings
9
9
  * root, which `git clone` refuses if anything is already there. A dedicated
10
10
  * dot-directory also keeps this marker out of `path.dirname(settingsRoot)`,
11
- * where `ensureLocalSimulatedRemote` puts `.remote-init.lock` — settings init
12
- * calls into that while holding this lock, so separate directories make the
11
+ * where `ensureLocalSimulatedRemote` puts `.remote-init.lock` — an in-place
12
+ * settings re-init calls into that while holding this lock, so separate directories make the
13
13
  * nesting obvious rather than incidental. For how a lock's anchor path is
14
14
  * chosen, see utils/provisioning-lock.ts and docs/concurrency.md.
15
15
  * @internal Exported for tests.
@@ -27,12 +27,32 @@ export interface EnsureSettingsWorkspaceOptions {
27
27
  * BranchWorkspaceManager this writes no metadata files and never touches the
28
28
  * branch registry.
29
29
  *
30
+ * A first clone follows [PROV-1] (branch-provisioning.ts): it is built in a
31
+ * staging sibling and renamed into place, so a process killed mid-clone never
32
+ * leaves a half-made workspace at the settings root. Never sparse: the orphan
33
+ * init's `git rm -rf .` would leave out-of-cone base files in the tree.
34
+ *
30
35
  * Two locking layers, mirroring BranchWorkspaceManager: an in-memory promise
31
36
  * lock within the process, and `acquireProvisioningLock` (proper-lockfile)
32
- * across processes and hosts.
37
+ * across processes and hosts around the publish and every in-place init.
33
38
  */
34
39
  export declare class SettingsWorkspaceManager {
35
40
  private readonly config;
36
41
  constructor(config: CanopyConfig);
37
42
  ensureGitWorkspace(options: EnsureSettingsWorkspaceOptions): Promise<void>;
43
+ private initOptions;
44
+ /**
45
+ * Layer 2 around the init itself (proper-lockfile: heartbeat-refreshed while the holder lives,
46
+ * so a slow EFS operation is not mistaken for a crash, and patient retries so a loser WAITS
47
+ * instead of racing into a concurrent init).
48
+ */
49
+ private withInitLock;
50
+ /** Re-init an existing workspace where it is; false when, under the lock, there is none. */
51
+ private ensureInPlace;
52
+ /**
53
+ * Clone and set up the settings branch in a staging sibling, then publish it under the init
54
+ * lock, moving an interrupted first init aside to `.trash-settings-*`. A workspace someone
55
+ * else published meanwhile wins and the staging copy is dropped.
56
+ */
57
+ private provisionStaged;
38
58
  }
@@ -1,14 +1,61 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { GitManager } from './git-manager.js';
4
+ import { removeLeftoverDir, settingsStagingDirName, settingsTrashDirName, } from './branch-provisioning.js';
4
5
  import { createDebugLogger } from './utils/debug.js';
5
- import { getErrorMessage } from './utils/error.js';
6
+ import { getErrorMessage, isNodeError } from './utils/error.js';
7
+ import { canopyLogWarn } from './utils/logger.js';
8
+ import { ProvisionLog } from './utils/provision-log.js';
6
9
  import { acquireProvisioningLock } from './utils/provisioning-lock.js';
7
10
  import { RESERVED_SETTINGS_BRANCH_PREFIX } from './paths/index.js';
8
11
  const log = createDebugLogger({ prefix: 'SettingsWorkspace' });
9
12
  // In-memory lock against concurrent init within one process. One lock suffices,
10
13
  // unlike content branches, which need one per branch.
11
14
  let settingsInitLock = null;
15
+ /**
16
+ * Settings workspaces this process has fully ensured, keyed by {@link ensuredKey}. A hit
17
+ * skips the guard, the init lock and initializeWorkspace's dozen git subprocesses, which
18
+ * otherwise ran on every API request. It is sound because settings freshness never comes from
19
+ * that pass: it reads the remote only to provision the settings branch or repair an empty one
20
+ * (GitManager.createOrphanSettingsBranch), saves pull, and every process reads the one shared
21
+ * workspace. Everything it verified is fixed for the process: the settings-branch name resolves once
22
+ * from config, the remote URL comes from config, and nothing in CanopyCMS checks the settings
23
+ * workspace out onto another branch. groups.json and permissions.json are still read from
24
+ * disk on every request; only the provisioning is memoized. Failures are never recorded. In
25
+ * dev, a hit also skips re-seeding a deleted `.canopy-dev/remote.git`; a dev-server restart
26
+ * re-seeds it.
27
+ *
28
+ * A hit still reads `.git/HEAD`, so a workspace removed, re-cloned onto another branch, or
29
+ * caught mid-clone by another process (HEAD still on the base branch) misses and runs the full
30
+ * path, guard and lock included.
31
+ */
32
+ const ensuredSettingsWorkspaces = new Set();
33
+ function ensuredKey(options) {
34
+ return `${path.resolve(options.settingsRoot)}\0${options.branchName}`;
35
+ }
36
+ async function headBranch(settingsRoot) {
37
+ try {
38
+ const head = (await fs.readFile(path.join(settingsRoot, '.git', 'HEAD'), 'utf-8')).trim();
39
+ return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ async function checkedOutOn(settingsRoot, branchName) {
46
+ return (await headBranch(settingsRoot)) === branchName;
47
+ }
48
+ async function hasLocalBranch(settingsRoot, branchName) {
49
+ const gitDir = path.join(settingsRoot, '.git');
50
+ try {
51
+ await fs.access(path.join(gitDir, 'refs', 'heads', branchName));
52
+ return true;
53
+ }
54
+ catch {
55
+ const packed = await fs.readFile(path.join(gitDir, 'packed-refs'), 'utf-8').catch(() => '');
56
+ return packed.split('\n').some((line) => line.endsWith(` refs/heads/${branchName}`));
57
+ }
58
+ }
12
59
  const SETTINGS_INIT_LOCK_DIR = '.settings-init';
13
60
  const SETTINGS_INIT_LOCK_NAME = 'lock';
14
61
  /**
@@ -19,8 +66,8 @@ const SETTINGS_INIT_LOCK_NAME = 'lock';
19
66
  * its target, and `GitManager.initializeWorkspace` clones INTO the settings
20
67
  * root, which `git clone` refuses if anything is already there. A dedicated
21
68
  * dot-directory also keeps this marker out of `path.dirname(settingsRoot)`,
22
- * where `ensureLocalSimulatedRemote` puts `.remote-init.lock` — settings init
23
- * calls into that while holding this lock, so separate directories make the
69
+ * where `ensureLocalSimulatedRemote` puts `.remote-init.lock` — an in-place
70
+ * settings re-init calls into that while holding this lock, so separate directories make the
24
71
  * nesting obvious rather than incidental. For how a lock's anchor path is
25
72
  * chosen, see utils/provisioning-lock.ts and docs/concurrency.md.
26
73
  * @internal Exported for tests.
@@ -48,14 +95,49 @@ async function settingsFilesPresent(settingsRoot) {
48
95
  }
49
96
  return false;
50
97
  }
98
+ /**
99
+ * What occupies the settings root.
100
+ *
101
+ * `interrupted` is a first init that died before its settings branch existed: not on this
102
+ * deployment's settings branch or any `canopycms-settings-*` one, no local ref for it, and no
103
+ * settings files -- what the rename guard lets through, minus a local settings branch that could
104
+ * hold unpushed commits. A clone killed in place leaves this behind, often with a stale
105
+ * `config.lock` that fails every later git config write.
106
+ */
107
+ async function settingsRootState(options) {
108
+ let entries;
109
+ try {
110
+ entries = await fs.readdir(options.settingsRoot);
111
+ }
112
+ catch (err) {
113
+ if (isNodeError(err) && err.code === 'ENOENT')
114
+ return 'vacant';
115
+ if (isNodeError(err) && err.code === 'ENOTDIR')
116
+ return 'interrupted';
117
+ throw err;
118
+ }
119
+ if (entries.length === 0)
120
+ return 'vacant';
121
+ if (await settingsFilesPresent(options.settingsRoot))
122
+ return 'in-use';
123
+ if (!(await GitManager.repoExistsAt(options.settingsRoot)))
124
+ return 'interrupted';
125
+ const head = await headBranch(options.settingsRoot);
126
+ if (head === options.branchName || head?.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX)) {
127
+ return 'in-use';
128
+ }
129
+ if (await hasLocalBranch(options.settingsRoot, options.branchName))
130
+ return 'in-use';
131
+ return 'interrupted';
132
+ }
51
133
  /**
52
134
  * Rename guard (settings-branch protection): refuse to boot when an
53
135
  * ALREADY-POPULATED settings workspace would be re-orphaned under a different
54
136
  * branch name.
55
137
  *
56
138
  * Without it, GitManager.initializeWorkspace sees an existing .git, skips the
57
- * clone, and calls createOrphanSettingsBranch(branchName); for an unknown name
58
- * git then runs `checkout --orphan <name>` + `rm -rf .` + an empty commit.
139
+ * clone, and calls createOrphanSettingsBranch(branchName); for a name neither the
140
+ * workspace nor its remote has, git then runs `checkout --orphan <name>` + `rm -rf .` + an empty commit.
59
141
  * Orphan branches share no history, so that is not a migration — it
60
142
  * PERMANENTLY WIPES permissions.json/groups.json with nothing to recover from.
61
143
  * The trigger is almost always deploymentName / settingsBranch /
@@ -103,8 +185,9 @@ async function assertSettingsWorkspaceIdentity(options) {
103
185
  `deploymentName, settingsBranch, or CANOPYCMS_DEPLOYMENT_NAME changed on a ` +
104
186
  `deployment that already has a populated settings workspace. To resolve: restore ` +
105
187
  `the previous value so this resolves back to ` +
106
- `'${currentBranch ?? options.branchName}', or, if starting fresh is genuinely ` +
107
- `intended, move ${options.settingsRoot} aside manually first.`);
188
+ `'${currentBranch ?? options.branchName}', or, if switching is genuinely intended, ` +
189
+ `move ${options.settingsRoot} aside manually first: the next start checks out ` +
190
+ `'${options.branchName}' from the remote, or starts it empty if the remote has none.`);
108
191
  }
109
192
  }
110
193
  /**
@@ -113,15 +196,26 @@ async function assertSettingsWorkspaceIdentity(options) {
113
196
  * BranchWorkspaceManager this writes no metadata files and never touches the
114
197
  * branch registry.
115
198
  *
199
+ * A first clone follows [PROV-1] (branch-provisioning.ts): it is built in a
200
+ * staging sibling and renamed into place, so a process killed mid-clone never
201
+ * leaves a half-made workspace at the settings root. Never sparse: the orphan
202
+ * init's `git rm -rf .` would leave out-of-cone base files in the tree.
203
+ *
116
204
  * Two locking layers, mirroring BranchWorkspaceManager: an in-memory promise
117
205
  * lock within the process, and `acquireProvisioningLock` (proper-lockfile)
118
- * across processes and hosts.
206
+ * across processes and hosts around the publish and every in-place init.
119
207
  */
120
208
  export class SettingsWorkspaceManager {
121
209
  constructor(config) {
122
210
  this.config = config;
123
211
  }
124
212
  async ensureGitWorkspace(options) {
213
+ const key = ensuredKey(options);
214
+ if (ensuredSettingsWorkspaces.has(key)) {
215
+ if (await checkedOutOn(options.settingsRoot, options.branchName))
216
+ return;
217
+ ensuredSettingsWorkspaces.delete(key);
218
+ }
125
219
  return log.timed('workspace', 'ensureGitWorkspace', async () => {
126
220
  // Layer 1: In-memory lock (prevents redundant async calls within same process)
127
221
  if (settingsInitLock) {
@@ -141,44 +235,11 @@ export class SettingsWorkspaceManager {
141
235
  // Running it here also means no path reaches initializeWorkspace
142
236
  // without the guard having run in THIS process.
143
237
  await assertSettingsWorkspaceIdentity(options);
144
- // Layer 2: cross-process/cross-host lock around the init itself
145
- // (proper-lockfile: heartbeat-refreshed while the holder lives, so a
146
- // slow EFS clone is not mistaken for a crash, and patient retries so a
147
- // loser WAITS instead of racing into a concurrent clone).
148
- const releaseLock = await acquireProvisioningLock(settingsInitLockTarget(options.settingsRoot), SETTINGS_INIT_LOCK_NAME);
149
- try {
150
- // Re-run the guard on the now-stable state: a previous holder may
151
- // have created the workspace, or moved it onto its own settings
152
- // branch, after we sampled it above, and acting on that stale
153
- // sample is the destructive path the guard exists to stop. Not
154
- // gated on any "did I win the race" flag — by design there is none;
155
- // every process here either holds the lock or has already thrown.
156
- await assertSettingsWorkspaceIdentity(options);
157
- // initializeWorkspace is idempotent (it checks for .git), so this
158
- // is safe even if another process just finished init.
159
- await GitManager.initializeWorkspace({
160
- workspacePath: options.settingsRoot,
161
- branchName: options.branchName,
162
- mode: options.mode,
163
- baseBranch: this.config.defaultBaseBranch,
164
- sourceRoot: this.config.sourceRoot,
165
- defaultRemoteUrl: this.config.defaultRemoteUrl,
166
- remoteUrl: options.remoteUrl,
167
- remoteName: this.config.defaultRemoteName,
168
- allowNetworkRemoteInProd: this.config.allowNetworkRemoteInProd,
169
- branchType: 'orphan', // Key difference: orphan branch for settings
170
- gitBotAuthorName: this.config.gitBotAuthorName,
171
- gitBotAuthorEmail: this.config.gitBotAuthorEmail,
172
- });
173
- }
174
- finally {
175
- try {
176
- await releaseLock();
177
- }
178
- catch (err) {
179
- log.debug('workspace', 'Failed to release settings-init lock', { err });
180
- }
238
+ if ((await settingsRootState(options)) !== 'in-use' ||
239
+ !(await this.ensureInPlace(options))) {
240
+ await this.provisionStaged(options);
181
241
  }
242
+ ensuredSettingsWorkspaces.add(key);
182
243
  }
183
244
  finally {
184
245
  settingsInitLock = null;
@@ -187,4 +248,95 @@ export class SettingsWorkspaceManager {
187
248
  await settingsInitLock;
188
249
  });
189
250
  }
251
+ initOptions(options, workspacePath) {
252
+ return {
253
+ workspacePath,
254
+ branchName: options.branchName,
255
+ mode: options.mode,
256
+ baseBranch: this.config.defaultBaseBranch,
257
+ sourceRoot: this.config.sourceRoot,
258
+ defaultRemoteUrl: this.config.defaultRemoteUrl,
259
+ remoteUrl: options.remoteUrl,
260
+ remoteName: this.config.defaultRemoteName,
261
+ allowNetworkRemoteInProd: this.config.allowNetworkRemoteInProd,
262
+ branchType: 'orphan',
263
+ gitBotAuthorName: this.config.gitBotAuthorName,
264
+ gitBotAuthorEmail: this.config.gitBotAuthorEmail,
265
+ };
266
+ }
267
+ /**
268
+ * Layer 2 around the init itself (proper-lockfile: heartbeat-refreshed while the holder lives,
269
+ * so a slow EFS operation is not mistaken for a crash, and patient retries so a loser WAITS
270
+ * instead of racing into a concurrent init).
271
+ */
272
+ async withInitLock(options, run) {
273
+ const releaseLock = await acquireProvisioningLock(settingsInitLockTarget(options.settingsRoot), SETTINGS_INIT_LOCK_NAME);
274
+ try {
275
+ // Re-run the guard on the now-stable state: a previous holder may
276
+ // have created the workspace, or moved it onto its own settings
277
+ // branch, after we sampled it above, and acting on that stale
278
+ // sample is the destructive path the guard exists to stop. Not
279
+ // gated on any "did I win the race" flag — by design there is none;
280
+ // every process here either holds the lock or has already thrown.
281
+ await assertSettingsWorkspaceIdentity(options);
282
+ return await run();
283
+ }
284
+ finally {
285
+ try {
286
+ await releaseLock();
287
+ }
288
+ catch (err) {
289
+ log.debug('workspace', 'Failed to release settings-init lock', { err });
290
+ }
291
+ }
292
+ }
293
+ /** Re-init an existing workspace where it is; false when, under the lock, there is none. */
294
+ async ensureInPlace(options) {
295
+ return this.withInitLock(options, async () => {
296
+ if ((await settingsRootState(options)) !== 'in-use')
297
+ return false;
298
+ await GitManager.initializeWorkspace(this.initOptions(options, options.settingsRoot));
299
+ return true;
300
+ });
301
+ }
302
+ /**
303
+ * Clone and set up the settings branch in a staging sibling, then publish it under the init
304
+ * lock, moving an interrupted first init aside to `.trash-settings-*`. A workspace someone
305
+ * else published meanwhile wins and the staging copy is dropped.
306
+ */
307
+ async provisionStaged(options) {
308
+ const settingsRoot = path.resolve(options.settingsRoot);
309
+ const workspaceRoot = path.dirname(settingsRoot);
310
+ await fs.mkdir(workspaceRoot, { recursive: true });
311
+ const stagingPath = path.join(workspaceRoot, settingsStagingDirName());
312
+ const provisionLog = new ProvisionLog('settings');
313
+ let published = false;
314
+ try {
315
+ await GitManager.initializeWorkspace({
316
+ ...this.initOptions(options, stagingPath),
317
+ provisionLog,
318
+ });
319
+ published = await provisionLog.step('publish', () => this.withInitLock(options, async () => {
320
+ const state = await settingsRootState(options);
321
+ if (state === 'in-use')
322
+ return false;
323
+ if (state === 'interrupted') {
324
+ const trashName = settingsTrashDirName();
325
+ await fs.rename(settingsRoot, path.join(workspaceRoot, trashName));
326
+ canopyLogWarn(`[canopy] Moved an unfinished settings workspace clone aside as ${trashName}`);
327
+ }
328
+ await fs.rename(stagingPath, settingsRoot);
329
+ return true;
330
+ }));
331
+ }
332
+ catch (err) {
333
+ provisionLog.finish('error');
334
+ throw err;
335
+ }
336
+ finally {
337
+ if (!published)
338
+ await removeLeftoverDir(stagingPath);
339
+ }
340
+ provisionLog.finish(published ? 'ok' : 'exists');
341
+ }
190
342
  }
@@ -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
  *