canopycms 0.0.67 → 0.0.68-int.100

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 (268) hide show
  1. package/README.md +21 -14
  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 +111 -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 +10 -1
  23. package/dist/api/github-sync.js +20 -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/index.d.ts +1 -1
  36. package/dist/assets/materialize.d.ts +122 -0
  37. package/dist/assets/materialize.js +305 -0
  38. package/dist/assets/store-local.d.ts +4 -0
  39. package/dist/assets/store-local.js +11 -0
  40. package/dist/assets/store-s3.d.ts +10 -1
  41. package/dist/assets/store-s3.js +55 -3
  42. package/dist/assets/transform-directives.d.ts +52 -13
  43. package/dist/assets/transform-directives.js +87 -27
  44. package/dist/assets/transform.d.ts +3 -3
  45. package/dist/assets/transform.js +13 -8
  46. package/dist/assets/types.d.ts +14 -1
  47. package/dist/auth/file-based-auth-cache.js +7 -8
  48. package/dist/authorization/content.d.ts +5 -4
  49. package/dist/authorization/content.js +5 -5
  50. package/dist/authorization/path.d.ts +11 -6
  51. package/dist/authorization/path.js +11 -6
  52. package/dist/authorization/types.d.ts +2 -2
  53. package/dist/branch-health.d.ts +7 -1
  54. package/dist/branch-health.js +8 -3
  55. package/dist/branch-metadata.d.ts +5 -5
  56. package/dist/branch-metadata.js +44 -36
  57. package/dist/branch-provisioning.d.ts +167 -0
  58. package/dist/branch-provisioning.js +501 -0
  59. package/dist/branch-schema-cache.d.ts +5 -3
  60. package/dist/branch-schema-cache.js +29 -11
  61. package/dist/branch-sparse.d.ts +23 -0
  62. package/dist/branch-sparse.js +118 -0
  63. package/dist/branch-workspace.d.ts +42 -3
  64. package/dist/branch-workspace.js +240 -94
  65. package/dist/build/asset-refs.d.ts +93 -0
  66. package/dist/build/asset-refs.js +251 -0
  67. package/dist/build-identity.d.ts +3 -0
  68. package/dist/build-identity.js +13 -0
  69. package/dist/cli/asset-refs.d.ts +30 -0
  70. package/dist/cli/asset-refs.js +168 -0
  71. package/dist/cli/cli.d.ts +2 -1
  72. package/dist/cli/cli.js +4843 -1870
  73. package/dist/cli/generate-ai-content.js +1584 -444
  74. package/dist/cli/github-app-manifest.d.ts +4 -3
  75. package/dist/cli/github-app-manifest.js +4 -3
  76. package/dist/cli/init.js +30 -27
  77. package/dist/cli/migrate.js +12 -8
  78. package/dist/cli/sync.js +83 -37
  79. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  80. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  81. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  82. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  83. package/dist/config/helpers.js +1 -2
  84. package/dist/config/schemas/config.d.ts +19 -32
  85. package/dist/config/schemas/config.js +9 -11
  86. package/dist/config/schemas/field.js +1 -0
  87. package/dist/config/schemas/media.d.ts +4 -13
  88. package/dist/config/schemas/media.js +5 -8
  89. package/dist/config/schemas/url.d.ts +4 -20
  90. package/dist/config/schemas/url.js +6 -22
  91. package/dist/config/types.d.ts +30 -20
  92. package/dist/config/validation.d.ts +6 -0
  93. package/dist/config/validation.js +37 -0
  94. package/dist/content-listing.d.ts +12 -12
  95. package/dist/content-listing.js +10 -6
  96. package/dist/content-reader.d.ts +11 -2
  97. package/dist/content-reader.js +22 -14
  98. package/dist/content-store.d.ts +37 -9
  99. package/dist/content-store.js +70 -43
  100. package/dist/content-tree.d.ts +1 -1
  101. package/dist/content-tree.js +2 -2
  102. package/dist/context.d.ts +27 -9
  103. package/dist/context.js +154 -70
  104. package/dist/editor/BranchManager.d.ts +7 -1
  105. package/dist/editor/BranchManager.js +29 -14
  106. package/dist/editor/CanopyEditor.d.ts +1 -1
  107. package/dist/editor/CanopyEditor.js +2 -6
  108. package/dist/editor/Editor.d.ts +4 -3
  109. package/dist/editor/Editor.js +24 -20
  110. package/dist/editor/FormRenderer.js +34 -18
  111. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  112. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  113. package/dist/editor/components/BranchesDrawer.js +4 -0
  114. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  115. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  116. package/dist/editor/context/AssetContext.d.ts +18 -35
  117. package/dist/editor/context/AssetContext.js +20 -29
  118. package/dist/editor/context/index.d.ts +1 -1
  119. package/dist/editor/context/index.js +1 -1
  120. package/dist/editor/editor-config.d.ts +1 -2
  121. package/dist/editor/editor-config.js +0 -23
  122. package/dist/editor/editor-utils.d.ts +24 -21
  123. package/dist/editor/editor-utils.js +75 -62
  124. package/dist/editor/fields/BlockField.d.ts +1 -0
  125. package/dist/editor/fields/BlockField.js +6 -4
  126. package/dist/editor/fields/CodeField.d.ts +1 -0
  127. package/dist/editor/fields/CodeField.js +2 -2
  128. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  129. package/dist/editor/fields/DateTimeField.js +2 -2
  130. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  131. package/dist/editor/fields/FieldDescription.js +11 -0
  132. package/dist/editor/fields/ImageField.d.ts +1 -0
  133. package/dist/editor/fields/ImageField.js +3 -2
  134. package/dist/editor/fields/InlineGroupField.js +4 -1
  135. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  136. package/dist/editor/fields/MarkdownField.js +115 -20
  137. package/dist/editor/fields/NumberField.d.ts +1 -0
  138. package/dist/editor/fields/NumberField.js +2 -2
  139. package/dist/editor/fields/NumberListField.d.ts +1 -0
  140. package/dist/editor/fields/NumberListField.js +2 -2
  141. package/dist/editor/fields/ObjectField.d.ts +1 -0
  142. package/dist/editor/fields/ObjectField.js +5 -2
  143. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  144. package/dist/editor/fields/ReferenceField.js +5 -4
  145. package/dist/editor/fields/SelectField.d.ts +1 -0
  146. package/dist/editor/fields/SelectField.js +2 -2
  147. package/dist/editor/fields/StringListField.d.ts +1 -0
  148. package/dist/editor/fields/StringListField.js +2 -2
  149. package/dist/editor/fields/TextField.d.ts +1 -0
  150. package/dist/editor/fields/TextField.js +2 -2
  151. package/dist/editor/fields/ToggleField.d.ts +1 -0
  152. package/dist/editor/fields/ToggleField.js +7 -4
  153. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  154. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  155. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  156. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  157. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  158. package/dist/editor/hooks/create-branch-request.js +52 -0
  159. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  160. package/dist/editor/hooks/useBranchActions.js +48 -17
  161. package/dist/editor/hooks/useBranchManager.d.ts +11 -0
  162. package/dist/editor/hooks/useBranchManager.js +80 -2
  163. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  164. package/dist/editor/hooks/useBranchesData.js +9 -3
  165. package/dist/editor/hooks/useCommentSystem.js +25 -6
  166. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  167. package/dist/editor/hooks/useDraftManager.js +193 -36
  168. package/dist/editor/hooks/useEntriesData.js +3 -3
  169. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  170. package/dist/editor/hooks/useEntryManager.js +34 -19
  171. package/dist/editor/media/AssetCard.d.ts +1 -1
  172. package/dist/editor/media/crop-math.d.ts +3 -4
  173. package/dist/editor/media/crop-math.js +11 -22
  174. package/dist/editor/media/editor-image-src.d.ts +7 -0
  175. package/dist/editor/media/editor-image-src.js +12 -0
  176. package/dist/editor/preview-asset-base.d.ts +8 -0
  177. package/dist/editor/preview-asset-base.js +13 -0
  178. package/dist/editor/preview-bridge.d.ts +8 -1
  179. package/dist/editor/preview-bridge.js +15 -2
  180. package/dist/editor/preview-path.d.ts +10 -0
  181. package/dist/editor/preview-path.js +20 -0
  182. package/dist/editor/theme.js +2 -2
  183. package/dist/entry-schema-registry.d.ts +4 -4
  184. package/dist/entry-schema-registry.js +10 -6
  185. package/dist/entry-schema.d.ts +27 -4
  186. package/dist/entry-schema.js +20 -3
  187. package/dist/git-manager.d.ts +173 -6
  188. package/dist/git-manager.js +594 -76
  189. package/dist/github-service.d.ts +8 -0
  190. package/dist/github-service.js +5 -1
  191. package/dist/http/handler.js +37 -13
  192. package/dist/http/index.d.ts +2 -0
  193. package/dist/http/index.js +2 -0
  194. package/dist/http/router.d.ts +2 -0
  195. package/dist/http/router.js +2 -1
  196. package/dist/http/types.d.ts +2 -0
  197. package/dist/http/worker-not-ready.d.ts +10 -0
  198. package/dist/http/worker-not-ready.js +24 -0
  199. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  200. package/dist/operating-mode/types.d.ts +1 -4
  201. package/dist/paths/branch-name.d.ts +5 -0
  202. package/dist/paths/branch-name.js +9 -0
  203. package/dist/paths/branch.d.ts +6 -2
  204. package/dist/paths/branch.js +16 -10
  205. package/dist/paths/index.d.ts +3 -3
  206. package/dist/paths/index.js +3 -3
  207. package/dist/paths/normalize.d.ts +7 -0
  208. package/dist/paths/normalize.js +9 -0
  209. package/dist/paths/validation.js +0 -2
  210. package/dist/reference-resolver.d.ts +5 -21
  211. package/dist/reference-resolver.js +9 -44
  212. package/dist/resolve-canopy-user.js +2 -1
  213. package/dist/schema/schema-store.d.ts +54 -31
  214. package/dist/schema/schema-store.js +77 -31
  215. package/dist/server.d.ts +24 -9
  216. package/dist/server.js +22 -8
  217. package/dist/services.d.ts +18 -6
  218. package/dist/services.js +85 -70
  219. package/dist/settings-workspace.d.ts +23 -3
  220. package/dist/settings-workspace.js +197 -45
  221. package/dist/static/seo.d.ts +2 -14
  222. package/dist/static/seo.js +2 -25
  223. package/dist/submission-attribution.d.ts +73 -0
  224. package/dist/submission-attribution.js +221 -0
  225. package/dist/sync-core.d.ts +12 -1
  226. package/dist/sync-core.js +31 -13
  227. package/dist/task-queue/worker-status.d.ts +8 -0
  228. package/dist/task-queue/worker-status.js +17 -0
  229. package/dist/types.d.ts +31 -2
  230. package/dist/utils/content-write-lock.d.ts +18 -7
  231. package/dist/utils/content-write-lock.js +18 -9
  232. package/dist/utils/debug.d.ts +8 -0
  233. package/dist/utils/debug.js +10 -2
  234. package/dist/utils/git.d.ts +32 -0
  235. package/dist/utils/git.js +42 -0
  236. package/dist/utils/occ-json-write.js +10 -2
  237. package/dist/utils/provision-log.d.ts +25 -0
  238. package/dist/utils/provision-log.js +49 -0
  239. package/dist/utils/provisioning-lock.d.ts +35 -5
  240. package/dist/utils/provisioning-lock.js +73 -12
  241. package/dist/utils/request-timing.d.ts +25 -0
  242. package/dist/utils/request-timing.js +101 -0
  243. package/dist/utils/sanitize-href.d.ts +8 -22
  244. package/dist/utils/sanitize-href.js +11 -28
  245. package/dist/utils/url-prefix.d.ts +30 -0
  246. package/dist/utils/url-prefix.js +61 -0
  247. package/dist/validation/entry-validator.js +9 -3
  248. package/dist/version.d.ts +1 -0
  249. package/dist/version.js +3 -0
  250. package/dist/worker/canopy-state.d.ts +45 -0
  251. package/dist/worker/canopy-state.js +75 -0
  252. package/dist/worker/cms-worker.d.ts +8 -3
  253. package/dist/worker/cms-worker.js +38 -2
  254. package/dist/worker/git-sync.d.ts +30 -12
  255. package/dist/worker/git-sync.js +253 -58
  256. package/dist/worker/history-rewrite.d.ts +1 -1
  257. package/dist/worker/history-rewrite.js +1 -1
  258. package/dist/worker/provisioned-workspace.d.ts +35 -0
  259. package/dist/worker/provisioned-workspace.js +50 -0
  260. package/dist/worker/rebase.d.ts +1 -1
  261. package/dist/worker/rebase.js +95 -23
  262. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  263. package/dist/worker/remote-git-maintenance.js +11 -0
  264. package/dist/worker/sparse-cone.d.ts +20 -0
  265. package/dist/worker/sparse-cone.js +132 -0
  266. package/dist/worker/task-runner.js +39 -4
  267. package/dist/worker/worker-context.d.ts +6 -1
  268. package/package.json +2 -1
@@ -90,34 +90,22 @@ export function neutralizeImplicitOffOrigin(url) {
90
90
  * this file either way, so the two surfaces cannot drift into disagreeing about which schemes
91
91
  * are acceptable (that drift is exactly why `utils/url-prefix.ts` exists — see its header).
92
92
  *
93
- * `allowProtocolRelative` exists because the two callers genuinely differ, and the difference
94
- * is blast radius rather than taste:
95
- *
96
- * - A READ-side prefix (`media.publicBaseUrl`, joined onto `/assets/…`) may legitimately be
97
- * `//cdn.example.com`. `utils/url-prefix.ts`'s `isAbsoluteUrl` documents that literal
98
- * spelling as an intentionally-supported off-site pointer, so rejecting it here would break
99
- * a working configuration. A bad value costs a broken `<img>`.
100
- * - A WRITE-side endpoint (`media.uploadUrl`, where the browser POSTs a presigned upload) must
101
- * not be protocol-relative even when spelled literally, because such a URL is AMBIGUOUS: it
102
- * resolves to http or https depending on the scheme of whichever editor page happens to
103
- * issue the upload, so the stored config does not determine where a live presigned credential
104
- * and the user's file bytes are sent. That is the same principle every other rule here
105
- * enforces — a config value must describe what actually happens.
106
- *
107
- * Note this is NOT an argument that http is forbidden on the write side; bare `http://` is
108
- * accepted, because a loopback or in-cluster S3-compatible endpoint is a real need and a
109
- * browser on an https editor blocks the mixed-content request anyway, so it fails closed. The
110
- * objection to `//host` is that it is undetermined, not that it might be insecure.
93
+ * A protocol-relative `//host` is refused even when spelled literally, because it is AMBIGUOUS:
94
+ * it resolves to http or https depending on the page issuing the request, so the stored config
95
+ * does not determine where, for `media.uploadUrl`, a live presigned credential and the user's
96
+ * file bytes are sent. Bare `http://` is accepted: a loopback or in-cluster S3-compatible
97
+ * endpoint is a real need, and a browser on an https editor blocks the mixed-content request
98
+ * anyway, so it fails closed.
111
99
  *
112
100
  * Every spelling that READS as site-relative while redefining the authority — `/\host`,
113
- * `\\host`, `\/host`, `///host` — is rejected for both, in both modes. Those look site-relative
101
+ * `\\host`, `\/host`, `///host` — is rejected too. Those look site-relative
114
102
  * to a human and to a naive `startsWith('/')` check, but WHATWG URL resolves each to a
115
103
  * different authority (measured: `///x` resolves to host `x`, not to pathname `/x`), so they
116
104
  * are never what an adopter meant. A scheme-qualified absolute URL is a separate case and is
117
105
  * accepted — `https:///cdn.example.com/` is unusual but unambiguous, resolving identically
118
106
  * standalone and against any base.
119
107
  */
120
- export function isHttpUrlOrSameOriginPath(value, opts = {}) {
108
+ export function isHttpUrlOrSameOriginPath(value) {
121
109
  if (value === '')
122
110
  return false;
123
111
  // Reject ASCII control characters and spaces ANYWHERE in the value, not only at the edges.
@@ -132,7 +120,7 @@ export function isHttpUrlOrSameOriginPath(value, opts = {}) {
132
120
  }
133
121
  // A query or fragment is meaningless on both sides this predicate serves. S3's POST Object
134
122
  // takes no query parameters and a browser never transmits a fragment; and on the read side a
135
- // prefix carrying either produces `https://cdn.example.com/?x=1/assets/…` once joined.
123
+ // prefix carrying either produces `https://cdn.example.com/?x=1/…` once joined.
136
124
  if (value.includes('?') || value.includes('#'))
137
125
  return false;
138
126
  // A backslash is never legitimate in a configured URL, and it is not merely cosmetic: WHATWG
@@ -169,13 +157,8 @@ export function isHttpUrlOrSameOriginPath(value, opts = {}) {
169
157
  return false;
170
158
  }
171
159
  }
172
- // A LITERAL `//host` is the only off-origin spelling that can be intentional, and only for
173
- // callers that opt in. `value[2] !== '/'` matters: `///x` and `////x` are also "implicitly
174
- // off-origin" and also start with `//`, but resolve to a host named `x` rather than to a
175
- // path — so they must not ride in on the opt-in.
176
- if (isImplicitlyOffOrigin(value)) {
177
- return opts.allowProtocolRelative === true && value.startsWith('//') && value[2] !== '/';
178
- }
160
+ if (isImplicitlyOffOrigin(value))
161
+ return false;
179
162
  // Site-relative, with exactly one leading slash. The second clause also covers `//`, which
180
163
  // `isImplicitlyOffOrigin` reports as false only because `new URL('//', base)` throws.
181
164
  return value.startsWith('/') && value[1] !== '/';
@@ -119,3 +119,33 @@ export declare function sanitizeUnprefixedPath(url: string): string;
119
119
  * neutralizing it would silently rewrite a legitimate protocol-relative CDN base into a path.
120
120
  */
121
121
  export declare function joinUrlPrefix(prefix: string | undefined, path: string): string;
122
+ /**
123
+ * Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
124
+ *
125
+ * Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
126
+ * `/blog/rss.xml`) alone, and never doubles an existing slash. So it never produces a URL that
127
+ * Next's `trailingSlash: true` redirects (`next/dist/lib/load-custom-routes.js:489,502` in
128
+ * 15.5.21): Next adds a slash only to a last segment with no dot, and strips one from a segment
129
+ * ending `.ext`.
130
+ *
131
+ * A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
132
+ * and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
133
+ * `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
134
+ * with a trailing slash" means and breaks the URL).
135
+ */
136
+ export declare function withTrailingSlash(path: string): string;
137
+ /**
138
+ * Give a URL's path the trailing-slash form a Next host serves, so loading it draws no 308:
139
+ * `withTrailingSlash`'s rule when `trailingSlash` is true, else no trailing slash on any path
140
+ * but the root (Next also redirects `<basePath>/` to `<basePath>`, `load-custom-routes.js:528`). An
141
+ * absolute URL's origin, and any query or fragment, are kept as they are.
142
+ */
143
+ export declare function matchTrailingSlash(url: string, trailingSlash: boolean): string;
144
+ /**
145
+ * Whether the host is built with Next's `trailingSlash: true`. `withCanopy` sets
146
+ * `CANOPY_TRAILING_SLASH` in Next's `env` config, which Next substitutes for this literal member
147
+ * expression in server and browser bundles (`getNextConfigEnv`, `next/dist/build/define-env.js:54`).
148
+ * The try/catch covers a host whose bundler neither substitutes it nor shims `process` in the
149
+ * browser.
150
+ */
151
+ export declare function readTrailingSlashEnv(): boolean;
@@ -156,3 +156,64 @@ export function joinUrlPrefix(prefix, path) {
156
156
  : `/${trimmedPrefix}`;
157
157
  return `${normalizedPrefix}${normalizedPath}`;
158
158
  }
159
+ /**
160
+ * Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
161
+ *
162
+ * Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
163
+ * `/blog/rss.xml`) alone, and never doubles an existing slash. So it never produces a URL that
164
+ * Next's `trailingSlash: true` redirects (`next/dist/lib/load-custom-routes.js:489,502` in
165
+ * 15.5.21): Next adds a slash only to a last segment with no dot, and strips one from a segment
166
+ * ending `.ext`.
167
+ *
168
+ * A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
169
+ * and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
170
+ * `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
171
+ * with a trailing slash" means and breaks the URL).
172
+ */
173
+ export function withTrailingSlash(path) {
174
+ const splitIndex = path.search(/[?#]/);
175
+ const base = splitIndex === -1 ? path : path.slice(0, splitIndex);
176
+ const suffix = splitIndex === -1 ? '' : path.slice(splitIndex);
177
+ const withLeading = base.startsWith('/') ? base : `/${base}`;
178
+ if (withLeading === '/' || withLeading.endsWith('/'))
179
+ return withLeading + suffix;
180
+ const lastSegment = withLeading.slice(withLeading.lastIndexOf('/') + 1);
181
+ if (lastSegment.includes('.'))
182
+ return withLeading + suffix;
183
+ return `${withLeading}/${suffix}`;
184
+ }
185
+ // Linear: `[^/?#]*` cannot overlap the literal `//` before it, so there is one way to match.
186
+ // eslint-disable-next-line security/detect-unsafe-regex
187
+ const URL_ORIGIN = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/?#]*/i;
188
+ /**
189
+ * Give a URL's path the trailing-slash form a Next host serves, so loading it draws no 308:
190
+ * `withTrailingSlash`'s rule when `trailingSlash` is true, else no trailing slash on any path
191
+ * but the root (Next also redirects `<basePath>/` to `<basePath>`, `load-custom-routes.js:528`). An
192
+ * absolute URL's origin, and any query or fragment, are kept as they are.
193
+ */
194
+ export function matchTrailingSlash(url, trailingSlash) {
195
+ const origin = isAbsoluteUrl(url) ? (URL_ORIGIN.exec(url)?.[0] ?? '') : '';
196
+ const rest = url.slice(origin.length);
197
+ if (trailingSlash)
198
+ return origin + withTrailingSlash(rest);
199
+ const splitIndex = rest.search(/[?#]/);
200
+ const base = splitIndex === -1 ? rest : rest.slice(0, splitIndex);
201
+ const suffix = splitIndex === -1 ? '' : rest.slice(splitIndex);
202
+ const trimmed = stripTrailingSlashes(base);
203
+ return origin + (trimmed || (origin ? '' : '/')) + suffix;
204
+ }
205
+ /**
206
+ * Whether the host is built with Next's `trailingSlash: true`. `withCanopy` sets
207
+ * `CANOPY_TRAILING_SLASH` in Next's `env` config, which Next substitutes for this literal member
208
+ * expression in server and browser bundles (`getNextConfigEnv`, `next/dist/build/define-env.js:54`).
209
+ * The try/catch covers a host whose bundler neither substitutes it nor shims `process` in the
210
+ * browser.
211
+ */
212
+ export function readTrailingSlashEnv() {
213
+ try {
214
+ return process.env.CANOPY_TRAILING_SLASH === 'true';
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
@@ -12,7 +12,7 @@
12
12
  * `ReferenceValidator` at the write boundary.
13
13
  */
14
14
  import { fieldTypes } from '../config/index.js';
15
- import { isValidCropRect } from '../assets/transform-directives.js';
15
+ import { isValidCropRect, roundCropRect } from '../assets/transform-directives.js';
16
16
  import { BLOCK_STRUCTURAL_KEYS } from './block-structural-keys.js';
17
17
  import { resolveBlockItem, traverseFields } from './field-traversal.js';
18
18
  import { findBodyFieldName } from '../utils/body-field.js';
@@ -27,7 +27,10 @@ function isPlainRecord(value) {
27
27
  function isPositiveInt(value) {
28
28
  return typeof value === 'number' && Number.isInteger(value) && value > 0;
29
29
  }
30
- /** True when `value` is a well-formed `{ x, y, w, h }` normalized crop rect. */
30
+ /**
31
+ * True when `value` is a well-formed `{ x, y, w, h }` normalized crop rect that is still valid
32
+ * after `roundCropRect`, the rounding its transform URL applies.
33
+ */
31
34
  function isValidImageCropValue(value) {
32
35
  if (!isPlainRecord(value))
33
36
  return false;
@@ -38,7 +41,10 @@ function isValidImageCropValue(value) {
38
41
  typeof h !== 'number') {
39
42
  return false;
40
43
  }
41
- return isValidCropRect(x, y, w, h);
44
+ if (!isValidCropRect(x, y, w, h))
45
+ return false;
46
+ const rounded = roundCropRect({ x, y, w, h });
47
+ return isValidCropRect(rounded.x, rounded.y, rounded.w, rounded.h);
42
48
  }
43
49
  /**
44
50
  * Validate a structured `image` field value: `{ src, alt, width?, height?, crop? }`
@@ -0,0 +1 @@
1
+ export declare const CANOPYCMS_VERSION = "0.0.68-int.100";
@@ -0,0 +1,3 @@
1
+ // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
+ // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
+ export const CANOPYCMS_VERSION = '0.0.68-int.100';
@@ -0,0 +1,45 @@
1
+ import type { FileStatusResult, SimpleGit, StatusResult } from 'simple-git';
2
+ /**
3
+ * How the sync loop treats canopycms's own state (`.canopy-meta/`) in a branch clone. It is
4
+ * never content, so untracked state never makes a clone "dirty" for sync, but git refuses some
5
+ * operations over it while the adopter's repo tracks it. Only the adopter can stop that; once
6
+ * they have, the base clone follows on its next fast-forward ({@link untrackInIndex}), and a
7
+ * branch clone with modified tracked state needs an operator.
8
+ */
9
+ /** The fix an operator applies when an adopter repo tracks `.canopy-meta/`. */
10
+ export declare const TRACKED_CANOPY_STATE_FIX = "run `git rm -r --cached .canopy-meta` in the site repo, add `.canopy-meta/` to its .gitignore, and commit";
11
+ /** Paths listed in worker-status.json per category; the admin panel shows them in a tooltip. */
12
+ export declare const MAX_REPORTED_PATHS = 10;
13
+ export declare function isUntracked(file: FileStatusResult): boolean;
14
+ /** Files under `.canopy-meta/` that the clone's index tracks. */
15
+ export declare function listTrackedCanopyState(git: SimpleGit): Promise<string[]>;
16
+ /**
17
+ * Discard local changes to the schema cache's retired in-tree copy, so a clone of a repo that
18
+ * committed it can sync again. Nothing reads or writes that path in a clone any more, so its
19
+ * content is dead; but while it differs from HEAD, `git rebase` refuses to start and a
20
+ * fast-forward that touches it (including the adopter's own commit untracking it) refuses too.
21
+ * An untracked copy blocks neither and a newly staged one has nothing in HEAD to restore, so
22
+ * both are left alone. Returns whether it restored anything; the caller re-reads status if so.
23
+ */
24
+ export declare function restoreRetiredSchemaCache(git: SimpleGit, status: StatusResult): Promise<boolean>;
25
+ /**
26
+ * The changed tracked `.canopy-meta/` files in `status`: what git refuses to rebase over, and
27
+ * what a fast-forward refuses to overwrite where it touches them.
28
+ */
29
+ export declare function trackedCanopyStateChanges(status: StatusResult): string[];
30
+ /**
31
+ * Split `paths` by whether the commit `tip` still tracks them. Those it no longer tracks, the
32
+ * adopter has untracked upstream, so a fast-forward onto `tip` would delete them from disk unless
33
+ * {@link untrackInIndex} takes them out of the index first.
34
+ */
35
+ export declare function splitByUpstreamTracking(git: SimpleGit, paths: string[], tip: string): Promise<{
36
+ droppedUpstream: string[];
37
+ stillTracked: string[];
38
+ }>;
39
+ /**
40
+ * Remove `paths` from this clone's index, leaving them on disk as untracked, excluded state.
41
+ * Index-only, so a concurrent write to them (branch metadata, comments) is not lost. Only for a
42
+ * clone with no commits of its own: a rebase that replays a commit touching these paths writes
43
+ * that commit's bytes over them. Paths already out of the index are ignored.
44
+ */
45
+ export declare function untrackInIndex(git: SimpleGit, paths: string[]): Promise<void>;
@@ -0,0 +1,75 @@
1
+ import { SCHEMA_CACHE_FILE } from '../branch-schema-cache.js';
2
+ import { CANOPY_META_DIR, isCanopyInternalPath } from '../utils/git.js';
3
+ /**
4
+ * How the sync loop treats canopycms's own state (`.canopy-meta/`) in a branch clone. It is
5
+ * never content, so untracked state never makes a clone "dirty" for sync, but git refuses some
6
+ * operations over it while the adopter's repo tracks it. Only the adopter can stop that; once
7
+ * they have, the base clone follows on its next fast-forward ({@link untrackInIndex}), and a
8
+ * branch clone with modified tracked state needs an operator.
9
+ */
10
+ /** The fix an operator applies when an adopter repo tracks `.canopy-meta/`. */
11
+ export const TRACKED_CANOPY_STATE_FIX = 'run `git rm -r --cached .canopy-meta` in the site repo, add `.canopy-meta/` to its .gitignore, and commit';
12
+ /** Paths listed in worker-status.json per category; the admin panel shows them in a tooltip. */
13
+ export const MAX_REPORTED_PATHS = 10;
14
+ /** The schema cache's location in a clone before it moved under `.git/` (branch-schema-cache.ts). */
15
+ const RETIRED_SCHEMA_CACHE_PATH = `${CANOPY_META_DIR}/${SCHEMA_CACHE_FILE}`;
16
+ export function isUntracked(file) {
17
+ return file.index === '?' && file.working_dir === '?';
18
+ }
19
+ /** NUL-separated git output, which is never quoted, unlike `core.quotePath` line output. */
20
+ function splitNul(out) {
21
+ return out.split('\0').filter((entry) => entry.length > 0);
22
+ }
23
+ /** Files under `.canopy-meta/` that the clone's index tracks. */
24
+ export async function listTrackedCanopyState(git) {
25
+ return splitNul(await git.raw(['ls-files', '-z', '--', CANOPY_META_DIR]));
26
+ }
27
+ /**
28
+ * Discard local changes to the schema cache's retired in-tree copy, so a clone of a repo that
29
+ * committed it can sync again. Nothing reads or writes that path in a clone any more, so its
30
+ * content is dead; but while it differs from HEAD, `git rebase` refuses to start and a
31
+ * fast-forward that touches it (including the adopter's own commit untracking it) refuses too.
32
+ * An untracked copy blocks neither and a newly staged one has nothing in HEAD to restore, so
33
+ * both are left alone. Returns whether it restored anything; the caller re-reads status if so.
34
+ */
35
+ export async function restoreRetiredSchemaCache(git, status) {
36
+ const entry = status.files.find((f) => f.path === RETIRED_SCHEMA_CACHE_PATH);
37
+ if (!entry || entry.index === '?' || entry.index === 'A')
38
+ return false;
39
+ await git.raw(['checkout', 'HEAD', '--', RETIRED_SCHEMA_CACHE_PATH]);
40
+ return true;
41
+ }
42
+ /**
43
+ * The changed tracked `.canopy-meta/` files in `status`: what git refuses to rebase over, and
44
+ * what a fast-forward refuses to overwrite where it touches them.
45
+ */
46
+ export function trackedCanopyStateChanges(status) {
47
+ return status.files
48
+ .filter((f) => isCanopyInternalPath(f.path) && !isUntracked(f))
49
+ .map((f) => f.path);
50
+ }
51
+ /**
52
+ * Split `paths` by whether the commit `tip` still tracks them. Those it no longer tracks, the
53
+ * adopter has untracked upstream, so a fast-forward onto `tip` would delete them from disk unless
54
+ * {@link untrackInIndex} takes them out of the index first.
55
+ */
56
+ export async function splitByUpstreamTracking(git, paths, tip) {
57
+ if (paths.length === 0)
58
+ return { droppedUpstream: [], stillTracked: [] };
59
+ const tracked = new Set(splitNul(await git.raw(['ls-tree', '-r', '-z', '--name-only', tip, '--', CANOPY_META_DIR])));
60
+ return {
61
+ droppedUpstream: paths.filter((p) => !tracked.has(p)),
62
+ stillTracked: paths.filter((p) => tracked.has(p)),
63
+ };
64
+ }
65
+ /**
66
+ * Remove `paths` from this clone's index, leaving them on disk as untracked, excluded state.
67
+ * Index-only, so a concurrent write to them (branch metadata, comments) is not lost. Only for a
68
+ * clone with no commits of its own: a rebase that replays a commit touching these paths writes
69
+ * that commit's bytes over them. Paths already out of the index are ignored.
70
+ */
71
+ export async function untrackInIndex(git, paths) {
72
+ if (paths.length === 0)
73
+ return;
74
+ await git.raw(['rm', '--cached', '--ignore-unmatch', '-q', '--', ...paths]);
75
+ }
@@ -15,11 +15,11 @@ export type AuthCacheRefresher = () => Promise<void>;
15
15
  * interface for the two shapes and why an App is optional.
16
16
  */
17
17
  export interface CmsWorkerConfig extends GitHubAuthConfig {
18
- /** Path to workspace root on EFS (e.g., /mnt/efs/workspace) */
18
+ /** Path to workspace root on EFS (e.g., /mnt/efs) */
19
19
  workspacePath: string;
20
- /** GitHub owner (e.g., 'safeinsights') */
20
+ /** GitHub owner (e.g., 'acme') */
21
21
  githubOwner: string;
22
- /** GitHub repo name (e.g., 'docs-site') */
22
+ /** GitHub repo name (e.g., 'site') */
23
23
  githubRepo: string;
24
24
  /** Called periodically to update the auth metadata cache on EFS. */
25
25
  refreshAuthCache?: AuthCacheRefresher;
@@ -203,6 +203,11 @@ export declare class CmsWorker {
203
203
  * survives, so a failed scrub is never indistinguishable from a clean one.
204
204
  */
205
205
  private scrubPersistedRemote;
206
+ /**
207
+ * Best-effort: without these settings remote.git still works, and a boot that
208
+ * cannot write its own config has a louder problem to report elsewhere.
209
+ */
210
+ private applyRemoteGitConfig;
206
211
  /**
207
212
  * Ensure the remote.git bare repo exists, cloning it from GitHub on first
208
213
  * run.
@@ -5,10 +5,12 @@ import lockfile from 'proper-lockfile';
5
5
  import { recoverOrphanedTasks, cmsTaskQueueLogger } from '../task-queue/cms-task-queue.js';
6
6
  import { createCanopyOctokit } from '../github-service.js';
7
7
  import { isTransientAuthFailure, resolveWorkerGitHubAuth, } from './github-auth.js';
8
+ import { ensureRemoteGitConfig } from '../git-manager.js';
8
9
  import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
9
10
  import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
10
11
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
11
- import { writeWorkerStatus } from '../task-queue/worker-status.js';
12
+ import { readLastFatalError, writeWorkerStatus } from '../task-queue/worker-status.js';
13
+ import { CANOPYCMS_VERSION } from '../version.js';
12
14
  import { workerLog, workerLogWarn, workerLogError } from './log.js';
13
15
  import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
14
16
  import { pollMergeState, runRebaseCycle } from './rebase.js';
@@ -67,7 +69,12 @@ export class CmsWorker {
67
69
  ensureStatusReport() {
68
70
  if (!this.statusReport) {
69
71
  const now = new Date().toISOString();
70
- this.statusReport = { version: 1, startedAt: now, updatedAt: now };
72
+ this.statusReport = {
73
+ version: 1,
74
+ workerVersion: CANOPYCMS_VERSION,
75
+ startedAt: now,
76
+ updatedAt: now,
77
+ };
71
78
  }
72
79
  return this.statusReport;
73
80
  }
@@ -144,6 +151,21 @@ export class CmsWorker {
144
151
  workerLog('CMS Worker starting...');
145
152
  this.ensureStatusReport();
146
153
  await this.acquireLock();
154
+ // Replace the previous holder's status file now: the first sync can take
155
+ // minutes, and until then System health would report the old worker's
156
+ // version. Best-effort, like the startup-failure write below. The previous
157
+ // `lastFatalError` rides along in this snapshot only, so a crash loop keeps
158
+ // its alert between restarts while the first successful sync still clears it.
159
+ try {
160
+ const lastFatalError = await readLastFatalError(this.taskDir);
161
+ await writeWorkerStatus(this.taskDir, {
162
+ ...this.ensureStatusReport(),
163
+ ...(lastFatalError ? { lastFatalError } : {}),
164
+ });
165
+ }
166
+ catch (err) {
167
+ workerLogError('Failed to write worker status after acquiring the lock:', getErrorMessage(err));
168
+ }
147
169
  // Everything below runs while holding the cross-host worker lock. A failure
148
170
  // here (most notably the empty-remote guard inside ensureRemoteGit) means
149
171
  // the process is about to exit and systemd (Restart=always) will retry —
@@ -411,6 +433,18 @@ export class CmsWorker {
411
433
  `shared storage. Refusing to continue.`);
412
434
  }
413
435
  }
436
+ /**
437
+ * Best-effort: without these settings remote.git still works, and a boot that
438
+ * cannot write its own config has a louder problem to report elsewhere.
439
+ */
440
+ async applyRemoteGitConfig(gitDir) {
441
+ try {
442
+ await ensureRemoteGitConfig(gitDir);
443
+ }
444
+ catch (err) {
445
+ workerLogWarn(`Could not apply remote.git config in ${gitDir}: ${getErrorMessage(err)}`);
446
+ }
447
+ }
414
448
  /**
415
449
  * Ensure the remote.git bare repo exists, cloning it from GitHub on first
416
450
  * run.
@@ -450,6 +484,7 @@ export class CmsWorker {
450
484
  // deletion is the operator's call.
451
485
  throw new Error(`remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (likely cloned while the GitHub repo was empty). Delete ${this.remoteGitPath} and restart the worker to re-clone.`);
452
486
  }
487
+ await this.applyRemoteGitConfig(this.remoteGitPath);
453
488
  return; // Already exists and has the base branch
454
489
  }
455
490
  workerLog('Initializing remote.git from GitHub...');
@@ -468,6 +503,7 @@ export class CmsWorker {
468
503
  // swallowing) if the scrub does not take.
469
504
  await this.scrubPersistedRemote(stagingPath);
470
505
  await this.verifyBaseBranchExists(stagingPath);
506
+ await this.applyRemoteGitConfig(stagingPath);
471
507
  }
472
508
  catch (err) {
473
509
  workerLogError(`remote.git clone failed: ${redactCredentials(getErrorMessage(err))}`);
@@ -1,4 +1,5 @@
1
1
  import { simpleGit } from 'simple-git';
2
+ import type { BaseRefreshReport } from '../types.js';
2
3
  import { type RebaseContext } from './rebase.js';
3
4
  import type { WorkerContext } from './worker-context.js';
4
5
  /**
@@ -6,7 +7,10 @@ import type { WorkerContext } from './worker-context.js';
6
7
  * slower of the worker's two poll loops (default 5 minutes, against the task
7
8
  * queue's 5 seconds).
8
9
  *
9
- * One cycle, in order: fetch every GitHub branch into the tracking namespace,
10
+ * One cycle, in order: repair what killed provisioning and deletes left under
11
+ * the branches root (`repairBranchDirResidue`), move sparse clones to a changed
12
+ * content root's cone (sparse-cone.ts), repack `remote.git` when it needs it
13
+ * (remote-git-maintenance.ts), fetch every GitHub branch into the tracking namespace,
10
14
  * bring `refs/heads/*` toward it non-destructively (`reconcileTrackedBranches`),
11
15
  * push this deployment's own settings branch, fast-forward the base branch's
12
16
  * workspace, rebase every branch that is behind it (rebase.ts), then sweep old
@@ -14,7 +18,7 @@ import type { WorkerContext } from './worker-context.js';
14
18
  *
15
19
  * One ordering is load-bearing and nothing enforces it: `runRebaseCycle` MUST
16
20
  * follow `reconcileTrackedBranches`. Branch clones fetch the base tip from
17
- * `remote.git` (`origin`), and `reconcileTrackedBranches` is what advances
21
+ * `remote.git`, and `reconcileTrackedBranches` is what advances
18
22
  * `remote.git`'s `refs/heads/*` toward what the fetch above put in the tracking
19
23
  * namespace; reorder them and every branch rebases onto the PREVIOUS cycle's
20
24
  * base tip -- not corrupting, but silently a cycle behind.
@@ -39,27 +43,41 @@ export type GitSyncContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBr
39
43
  */
40
44
  export declare function pushSettingsBranches(ctx: GitSyncContext, git: ReturnType<typeof simpleGit>, trackedNames: ReadonlySet<string>): Promise<void>;
41
45
  export declare function syncGit(ctx: GitSyncContext): Promise<void>;
46
+ /**
47
+ * Repair what killed provisioning and deletes left under the branches root: quarantine residue
48
+ * at a branch's name (branch-provisioning.ts's `classifyFinalDir`) once it has been quiet for
49
+ * {@link ORPHAN_YOUTH_THRESHOLD_MS}, then sweep stale `.prov-*`, `.deleting-*` and `.repair-*`
50
+ * siblings and settings leftovers. Each directory is best-effort, like [SYNC-M2]. The provisioning
51
+ * lock is taken zero-retry, so a live provisioner or admin action wins and the directory is
52
+ * revisited next cycle.
53
+ * @internal Exported for tests; `syncGit` runs it.
54
+ */
55
+ export declare function repairBranchDirResidue(ctx: Pick<GitSyncContext, 'contentBranchesPath' | 'remoteGitPath'>, now?: number): Promise<void>;
42
56
  /**
43
57
  * [C1] Remove `.trash-*` branch directories (created by the admin purge action,
44
- * api/admin-branch-health.ts) whose name-embedded stamp is older than
45
- * {@link TRASH_RETENTION_MS}. A name that doesn't match `.trash-{dirName}-
46
- * {STAMP}`, or whose stamp fails to parse, is left alone and logged once per
47
- * cycle: purge is the only writer of this naming scheme, so an unparseable name
48
- * is unexpected and worth a human looking rather than a silent skip.
58
+ * api/admin-branch-health.ts, and by residue quarantine, branch-provisioning.ts)
59
+ * whose name-embedded stamp is older than {@link TRASH_RETENTION_MS}. A name
60
+ * whose trailing stamp fails to parse is left alone and logged once per cycle:
61
+ * both writers end every name with one, so it is worth a human looking.
49
62
  */
50
63
  export declare function cleanupTrashedBranchDirs(ctx: Pick<GitSyncContext, 'contentBranchesPath'>): Promise<number>;
51
64
  /**
52
65
  * Fast-forward the base branch's own working-tree clone
53
- * (content-branches/<baseBranch>) to match origin/<baseBranch>, every sync
66
+ * (content-branches/<baseBranch>) to match remote.git's <baseBranch>, every sync
54
67
  * cycle, so the drift window is bounded by gitSyncInterval.
55
68
  *
56
69
  * A dedicated, explicit and LOUD step rather than a side effect of the rebase
57
70
  * loop, whose skip paths (a dirty tree, a missing .git) are silent: a wedged
58
71
  * base clone otherwise leaves no diagnosable signal, and an editor forking a
59
- * new branch "from base" silently gets a stale snapshot.
72
+ * new branch "from base" silently gets a stale snapshot. The returned outcome
73
+ * goes to worker-status.json for the same reason.
60
74
  *
61
- * ff-only on purpose: this clone must stay a linear mirror of
62
- * origin/<baseBranch>, so a merge that isn't a fast-forward (diverged local
75
+ * ff-only on purpose: this clone must stay a linear mirror of remote.git's
76
+ * <baseBranch>, so a merge that isn't a fast-forward (diverged local
63
77
  * history) is left untouched rather than force-resolved.
78
+ *
79
+ * Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
80
+ * content-write lock, both try-only: the base branch is writable in dev, and
81
+ * a save racing the merge's working-tree update can be overwritten by it.
64
82
  */
65
- export declare function refreshBaseBranchWorkspace(ctx: GitSyncContext): Promise<void>;
83
+ export declare function refreshBaseBranchWorkspace(ctx: GitSyncContext): Promise<BaseRefreshReport>;