canopycms 0.0.61 → 0.0.63

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 (237) hide show
  1. package/dist/ai/index.d.ts +9 -0
  2. package/dist/ai/index.d.ts.map +1 -1
  3. package/dist/ai/index.js +9 -0
  4. package/dist/ai/index.js.map +1 -1
  5. package/dist/ai/json-to-markdown.js +1 -2
  6. package/dist/ai/json-to-markdown.js.map +1 -1
  7. package/dist/ai/to-plain-text.d.ts +57 -0
  8. package/dist/ai/to-plain-text.d.ts.map +1 -0
  9. package/dist/ai/to-plain-text.js +255 -0
  10. package/dist/ai/to-plain-text.js.map +1 -0
  11. package/dist/ai/transform-components.d.ts.map +1 -1
  12. package/dist/ai/transform-components.js +70 -5
  13. package/dist/ai/transform-components.js.map +1 -1
  14. package/dist/api/__test__/mock-client.d.ts +5 -1
  15. package/dist/api/__test__/mock-client.d.ts.map +1 -1
  16. package/dist/api/__test__/mock-client.js +9 -2
  17. package/dist/api/__test__/mock-client.js.map +1 -1
  18. package/dist/api/admin-branch-health.d.ts +36 -1
  19. package/dist/api/admin-branch-health.d.ts.map +1 -1
  20. package/dist/api/admin-branch-health.js +176 -2
  21. package/dist/api/admin-branch-health.js.map +1 -1
  22. package/dist/api/admin.d.ts +8 -1
  23. package/dist/api/admin.d.ts.map +1 -1
  24. package/dist/api/admin.js.map +1 -1
  25. package/dist/api/branch.d.ts.map +1 -1
  26. package/dist/api/branch.js +16 -2
  27. package/dist/api/branch.js.map +1 -1
  28. package/dist/api/client.d.ts +5 -1
  29. package/dist/api/client.d.ts.map +1 -1
  30. package/dist/api/client.js +6 -0
  31. package/dist/api/client.js.map +1 -1
  32. package/dist/api/content.d.ts +11 -5
  33. package/dist/api/content.d.ts.map +1 -1
  34. package/dist/api/content.js +79 -6
  35. package/dist/api/content.js.map +1 -1
  36. package/dist/api/entries.d.ts +10 -0
  37. package/dist/api/entries.d.ts.map +1 -1
  38. package/dist/api/entries.js +40 -15
  39. package/dist/api/entries.js.map +1 -1
  40. package/dist/api/schema.d.ts +4 -4
  41. package/dist/api/schema.d.ts.map +1 -1
  42. package/dist/api/schema.js +13 -13
  43. package/dist/api/schema.js.map +1 -1
  44. package/dist/assets/transform.d.ts.map +1 -1
  45. package/dist/assets/transform.js +19 -6
  46. package/dist/assets/transform.js.map +1 -1
  47. package/dist/authorization/branch.d.ts +55 -3
  48. package/dist/authorization/branch.d.ts.map +1 -1
  49. package/dist/authorization/branch.js +55 -9
  50. package/dist/authorization/branch.js.map +1 -1
  51. package/dist/authorization/types.d.ts +1 -1
  52. package/dist/authorization/types.d.ts.map +1 -1
  53. package/dist/branch-health.d.ts +13 -0
  54. package/dist/branch-health.d.ts.map +1 -1
  55. package/dist/branch-health.js +23 -0
  56. package/dist/branch-health.js.map +1 -1
  57. package/dist/build-canopy.d.ts +49 -0
  58. package/dist/build-canopy.d.ts.map +1 -0
  59. package/dist/build-canopy.js +51 -0
  60. package/dist/build-canopy.js.map +1 -0
  61. package/dist/cli/cli.js +935 -562
  62. package/dist/cli/generate-ai-content.js +837 -480
  63. package/dist/cli/init.js +25 -7
  64. package/dist/cli/template-files/Dockerfile.cms.template +19 -1
  65. package/dist/cli/template-files/canopycms.config.ts.template +25 -1
  66. package/dist/cli/template-files/cms-stack.ts.template +7 -1
  67. package/dist/client.d.ts +1 -0
  68. package/dist/client.d.ts.map +1 -1
  69. package/dist/client.js.map +1 -1
  70. package/dist/config/schemas/field.d.ts +15 -15
  71. package/dist/config/types.d.ts +2 -2
  72. package/dist/config/types.d.ts.map +1 -1
  73. package/dist/config/types.js +0 -1
  74. package/dist/config/types.js.map +1 -1
  75. package/dist/config/validation.d.ts +9 -0
  76. package/dist/config/validation.d.ts.map +1 -1
  77. package/dist/config/validation.js +14 -0
  78. package/dist/config/validation.js.map +1 -1
  79. package/dist/content-id-index.d.ts +97 -5
  80. package/dist/content-id-index.d.ts.map +1 -1
  81. package/dist/content-id-index.js +170 -18
  82. package/dist/content-id-index.js.map +1 -1
  83. package/dist/content-listing.d.ts +110 -5
  84. package/dist/content-listing.d.ts.map +1 -1
  85. package/dist/content-listing.js +164 -23
  86. package/dist/content-listing.js.map +1 -1
  87. package/dist/content-reader.d.ts +41 -4
  88. package/dist/content-reader.d.ts.map +1 -1
  89. package/dist/content-reader.js +17 -4
  90. package/dist/content-reader.js.map +1 -1
  91. package/dist/content-store.d.ts +91 -8
  92. package/dist/content-store.d.ts.map +1 -1
  93. package/dist/content-store.js +443 -258
  94. package/dist/content-store.js.map +1 -1
  95. package/dist/content-tree.d.ts +31 -2
  96. package/dist/content-tree.d.ts.map +1 -1
  97. package/dist/content-tree.js +22 -5
  98. package/dist/content-tree.js.map +1 -1
  99. package/dist/context.d.ts +30 -10
  100. package/dist/context.d.ts.map +1 -1
  101. package/dist/context.js +55 -4
  102. package/dist/context.js.map +1 -1
  103. package/dist/editor/BranchManager.d.ts.map +1 -1
  104. package/dist/editor/BranchManager.js +18 -6
  105. package/dist/editor/BranchManager.js.map +1 -1
  106. package/dist/editor/CanopyEditor.d.ts.map +1 -1
  107. package/dist/editor/CanopyEditor.js +2 -2
  108. package/dist/editor/CanopyEditor.js.map +1 -1
  109. package/dist/editor/CanopyEditorPage.d.ts +9 -1
  110. package/dist/editor/CanopyEditorPage.d.ts.map +1 -1
  111. package/dist/editor/CanopyEditorPage.js +9 -2
  112. package/dist/editor/CanopyEditorPage.js.map +1 -1
  113. package/dist/editor/Editor.d.ts +9 -1
  114. package/dist/editor/Editor.d.ts.map +1 -1
  115. package/dist/editor/Editor.js +58 -24
  116. package/dist/editor/Editor.js.map +1 -1
  117. package/dist/editor/FormRenderer.d.ts.map +1 -1
  118. package/dist/editor/FormRenderer.js +12 -0
  119. package/dist/editor/FormRenderer.js.map +1 -1
  120. package/dist/editor/admin/SystemHealthPanel.js +6 -2
  121. package/dist/editor/admin/SystemHealthPanel.js.map +1 -1
  122. package/dist/editor/components/EntryCreateModal.d.ts +9 -1
  123. package/dist/editor/components/EntryCreateModal.d.ts.map +1 -1
  124. package/dist/editor/components/EntryCreateModal.js +4 -1
  125. package/dist/editor/components/EntryCreateModal.js.map +1 -1
  126. package/dist/editor/fields/DateTimeField.d.ts +46 -0
  127. package/dist/editor/fields/DateTimeField.d.ts.map +1 -0
  128. package/dist/editor/fields/DateTimeField.js +66 -0
  129. package/dist/editor/fields/DateTimeField.js.map +1 -0
  130. package/dist/editor/fields/NumberField.d.ts +34 -0
  131. package/dist/editor/fields/NumberField.d.ts.map +1 -0
  132. package/dist/editor/fields/NumberField.js +53 -0
  133. package/dist/editor/fields/NumberField.js.map +1 -0
  134. package/dist/editor/fields/NumberListField.d.ts +25 -0
  135. package/dist/editor/fields/NumberListField.d.ts.map +1 -0
  136. package/dist/editor/fields/NumberListField.js +53 -0
  137. package/dist/editor/fields/NumberListField.js.map +1 -0
  138. package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
  139. package/dist/editor/hooks/useBranchManager.js +115 -26
  140. package/dist/editor/hooks/useBranchManager.js.map +1 -1
  141. package/dist/editor/hooks/useDraftManager.d.ts +8 -0
  142. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  143. package/dist/editor/hooks/useDraftManager.js +261 -21
  144. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  145. package/dist/editor/hooks/useEntryManager.d.ts +21 -0
  146. package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
  147. package/dist/editor/hooks/useEntryManager.js +25 -2
  148. package/dist/editor/hooks/useEntryManager.js.map +1 -1
  149. package/dist/editor/theme.d.ts +8 -8
  150. package/dist/entry-schema.d.ts +228 -8
  151. package/dist/entry-schema.d.ts.map +1 -1
  152. package/dist/entry-schema.js +124 -0
  153. package/dist/entry-schema.js.map +1 -1
  154. package/dist/git-manager.d.ts +51 -7
  155. package/dist/git-manager.d.ts.map +1 -1
  156. package/dist/git-manager.js +105 -14
  157. package/dist/git-manager.js.map +1 -1
  158. package/dist/http/handler.d.ts.map +1 -1
  159. package/dist/http/handler.js +70 -27
  160. package/dist/http/handler.js.map +1 -1
  161. package/dist/http/router.d.ts.map +1 -1
  162. package/dist/http/router.js +40 -3
  163. package/dist/http/router.js.map +1 -1
  164. package/dist/index.d.ts +1 -0
  165. package/dist/index.d.ts.map +1 -1
  166. package/dist/index.js +7 -0
  167. package/dist/index.js.map +1 -1
  168. package/dist/operating-mode/deployment-name-fixtures.d.ts +29 -0
  169. package/dist/operating-mode/deployment-name-fixtures.d.ts.map +1 -0
  170. package/dist/operating-mode/deployment-name-fixtures.js +55 -0
  171. package/dist/operating-mode/deployment-name-fixtures.js.map +1 -0
  172. package/dist/operating-mode/deployment-name.d.ts +9 -0
  173. package/dist/operating-mode/deployment-name.d.ts.map +1 -1
  174. package/dist/operating-mode/deployment-name.js +9 -1
  175. package/dist/operating-mode/deployment-name.js.map +1 -1
  176. package/dist/operating-mode/index.d.ts +2 -1
  177. package/dist/operating-mode/index.d.ts.map +1 -1
  178. package/dist/operating-mode/index.js +4 -1
  179. package/dist/operating-mode/index.js.map +1 -1
  180. package/dist/operating-mode/mode-env.d.ts +58 -0
  181. package/dist/operating-mode/mode-env.d.ts.map +1 -0
  182. package/dist/operating-mode/mode-env.js +97 -0
  183. package/dist/operating-mode/mode-env.js.map +1 -0
  184. package/dist/resolve-canopy-user.d.ts +34 -0
  185. package/dist/resolve-canopy-user.d.ts.map +1 -0
  186. package/dist/resolve-canopy-user.js +55 -0
  187. package/dist/resolve-canopy-user.js.map +1 -0
  188. package/dist/schema/schema-store.d.ts +53 -2
  189. package/dist/schema/schema-store.d.ts.map +1 -1
  190. package/dist/schema/schema-store.js +92 -21
  191. package/dist/schema/schema-store.js.map +1 -1
  192. package/dist/server.d.ts +60 -0
  193. package/dist/server.d.ts.map +1 -1
  194. package/dist/server.js +58 -0
  195. package/dist/server.js.map +1 -1
  196. package/dist/services.d.ts +1 -2
  197. package/dist/services.d.ts.map +1 -1
  198. package/dist/services.js +42 -12
  199. package/dist/services.js.map +1 -1
  200. package/dist/settings-workspace.d.ts +40 -0
  201. package/dist/settings-workspace.d.ts.map +1 -1
  202. package/dist/settings-workspace.js +139 -151
  203. package/dist/settings-workspace.js.map +1 -1
  204. package/dist/static/index.d.ts +65 -5
  205. package/dist/static/index.d.ts.map +1 -1
  206. package/dist/static/index.js +65 -12
  207. package/dist/static/index.js.map +1 -1
  208. package/dist/static/seo.d.ts +150 -0
  209. package/dist/static/seo.d.ts.map +1 -0
  210. package/dist/static/seo.js +189 -0
  211. package/dist/static/seo.js.map +1 -0
  212. package/dist/types.d.ts +10 -0
  213. package/dist/types.d.ts.map +1 -1
  214. package/dist/utils/content-write-lock.d.ts +127 -0
  215. package/dist/utils/content-write-lock.d.ts.map +1 -0
  216. package/dist/utils/content-write-lock.js +170 -0
  217. package/dist/utils/content-write-lock.js.map +1 -0
  218. package/dist/utils/error.d.ts +1 -1
  219. package/dist/utils/error.js +1 -1
  220. package/dist/utils/git.d.ts +16 -0
  221. package/dist/utils/git.d.ts.map +1 -1
  222. package/dist/utils/git.js +22 -0
  223. package/dist/utils/git.js.map +1 -1
  224. package/dist/utils/sanitize-href.d.ts +74 -3
  225. package/dist/utils/sanitize-href.d.ts.map +1 -1
  226. package/dist/utils/sanitize-href.js +125 -6
  227. package/dist/utils/sanitize-href.js.map +1 -1
  228. package/dist/validation/entry-link-validator.d.ts +1 -1
  229. package/dist/validation/entry-link-validator.js +3 -3
  230. package/dist/validation/entry-link-validator.js.map +1 -1
  231. package/dist/validation/entry-validator.js +1 -1
  232. package/dist/validation/entry-validator.js.map +1 -1
  233. package/dist/worker/cms-worker.d.ts +13 -0
  234. package/dist/worker/cms-worker.d.ts.map +1 -1
  235. package/dist/worker/cms-worker.js +326 -264
  236. package/dist/worker/cms-worker.js.map +1 -1
  237. package/package.json +2 -2
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Framework-agnostic SEO field extraction and URL shaping.
3
+ *
4
+ * This module is pure: it maps an entry's raw data onto a neutral `SeoFields` shape and
5
+ * resolves URLs. Framework adapters (e.g. canopycms-next's `entryToMetadata`) turn that shape
6
+ * into their own metadata objects; nothing here imports a framework or a node built-in.
7
+ *
8
+ * The field names default to the recommended SEO field group — see `defineSeoFieldGroup` in
9
+ * entry-schema.ts, which builds a schema group from these same names so the schema side and
10
+ * the read side cannot drift.
11
+ */
12
+ /** og:type values covered by the recommended group. */
13
+ export type SeoOgType = 'website' | 'article' | 'profile';
14
+ /** twitter:card values covered by the recommended group. */
15
+ export type SeoTwitterCard = 'summary' | 'summary_large_image';
16
+ /** The neutral, framework-agnostic SEO shape. */
17
+ export interface SeoFields {
18
+ title?: string;
19
+ description?: string;
20
+ ogImage?: string;
21
+ ogType?: SeoOgType;
22
+ canonical?: string;
23
+ noindex?: boolean;
24
+ twitterCard?: SeoTwitterCard;
25
+ }
26
+ /** Entry field names backing each neutral field. */
27
+ export type SeoFieldNames = Record<keyof SeoFields, string>;
28
+ /**
29
+ * The recommended SEO field names. `defineSeoFieldGroup()` emits a schema group using exactly
30
+ * these names, so an adopter who uses both ends needs no configuration at all.
31
+ */
32
+ export declare const DEFAULT_SEO_FIELD_NAMES: {
33
+ readonly title: "metaTitle";
34
+ readonly description: "metaDescription";
35
+ readonly ogImage: "ogImage";
36
+ readonly ogType: "ogType";
37
+ readonly canonical: "canonical";
38
+ readonly noindex: "noindex";
39
+ readonly twitterCard: "twitterCard";
40
+ };
41
+ export interface ExtractSeoFieldsOptions {
42
+ /** Per-field name overrides (e.g. `{ title: 'seoTitle' }`). */
43
+ fields?: Partial<SeoFieldNames>;
44
+ /**
45
+ * Read the fields from a nested object under this key instead of from flat entry fields.
46
+ * Pair with `defineSeoFieldGroup({ group: 'seo' })`, which nests the same fields under that
47
+ * key in the content file. Omit for the flat (inline-group) convention, which is the default
48
+ * on both ends.
49
+ */
50
+ group?: string;
51
+ /** Used when the entry has no meta title (typically the entry's own title/heading). */
52
+ fallbackTitle?: string;
53
+ /** Used when the entry has no meta description. */
54
+ fallbackDescription?: string;
55
+ }
56
+ /** Just the parts of `ExtractSeoFieldsOptions` that locate the fields, with no fallbacks. */
57
+ export type SeoFieldLocation = Pick<ExtractSeoFieldsOptions, 'fields' | 'group'>;
58
+ /**
59
+ * Map an entry's SEO fields onto the neutral shape.
60
+ *
61
+ * Entry data is `unknown` — every read is guarded, unknown enum values are dropped rather than
62
+ * passed through, and non-object data (null, a string, an array) yields an all-undefined result
63
+ * rather than throwing.
64
+ */
65
+ export declare function extractSeoFields(entryData: unknown, opts?: ExtractSeoFieldsOptions): SeoFields;
66
+ /**
67
+ * THE `noindex` predicate. Every surface that suppresses an entry must route through this one
68
+ * function rather than re-deriving the flag:
69
+ *
70
+ * - the page's own `robots: { index: false }` (canopycms-next's `entryToMetadata`), and
71
+ * - exclusion from the sitemap (canopycms-next's `generateContentSitemap`).
72
+ *
73
+ * Those two shipping from one predicate is the point of shipping them together. Where they were
74
+ * derived separately, a `noindex` entry stayed advertised in one surface while correctly absent
75
+ * from the other, and nothing warned.
76
+ *
77
+ * NOT applied to enumeration: `noindex` means "don't advertise this", not "don't build this".
78
+ * `collectRoutableEntries` and `generateStaticParams` must still emit the entry, so its URL
79
+ * resolves for anyone holding the link.
80
+ */
81
+ export declare function isNoindexEntry(entryData: unknown, opts?: SeoFieldLocation): boolean;
82
+ /**
83
+ * An off-site URL: either scheme-qualified (`https://…`) or LITERAL protocol-relative
84
+ * (`//cdn…`). Both name a host we don't control, so neither may be rewritten against the site
85
+ * origin — `resolveSeoUrl` passes a `true` result through verbatim.
86
+ *
87
+ * Deliberately narrower than "resolves off-origin": a WHATWG-backslash-equivalent spelling
88
+ * (`/\evil.com`, `\\evil.com`, `\/evil.com` — see `utils/sanitize-href.ts`'s
89
+ * `isImplicitlyOffOrigin`) also resolves off-origin in a browser, but is NOT recognized here as
90
+ * an intentional off-site pointer the way a literal `//cdn…` is. `resolveSeoUrl` handles that
91
+ * case separately, by neutralizing (`neutralizeImplicitOffOrigin`) rather than passing through: a
92
+ * `false` result here isn't a guarantee the value is a safe site-relative path on its own, only
93
+ * that it isn't a *declared* off-site one.
94
+ */
95
+ export declare function isAbsoluteUrl(url: string): boolean;
96
+ /**
97
+ * Strip trailing slashes without a regex.
98
+ *
99
+ * The obvious `replace(/\/+$/, '')` is a polynomial-ReDoS shape (CodeQL
100
+ * `js/polynomial-redos`, flagged high): on a value that is mostly slashes but does not end in
101
+ * one, the engine retries `\/+` from every position and the match cost is quadratic in the
102
+ * input length. `siteUrl` is adopter-supplied and can reach here from config or an env var, so
103
+ * it counts as uncontrolled. A character scan is linear and needs no reasoning about
104
+ * backtracking.
105
+ *
106
+ * Exported (from `canopycms/server`) so adopter code normalizing its own site-origin env var —
107
+ * e.g. a `SITE_URL` constant built from `NEXT_PUBLIC_SITE_URL` — has a package-provided linear
108
+ * way to do it instead of reaching for the same regex this function replaced.
109
+ */
110
+ export declare function stripTrailingSlashes(value: string): string;
111
+ /**
112
+ * Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
113
+ *
114
+ * Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
115
+ * `/blog/rss.xml`) alone, and never doubles an existing slash.
116
+ *
117
+ * A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
118
+ * and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
119
+ * `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
120
+ * with a trailing slash" means and breaks the URL).
121
+ */
122
+ export declare function withTrailingSlash(path: string): string;
123
+ export interface ResolveSeoUrlOptions {
124
+ /** Site origin (e.g. `https://example.com`). Trailing slashes are stripped. */
125
+ siteUrl?: string;
126
+ /**
127
+ * Emit site-relative paths with a trailing slash. CanopyCMS has no knowledge of your
128
+ * framework's routing config, so this must be stated explicitly — see the note on
129
+ * `generateContentSitemap`.
130
+ */
131
+ trailingSlash?: boolean;
132
+ }
133
+ /**
134
+ * Resolve a possibly-relative URL for public emission (canonical tag, sitemap URL, OG image).
135
+ *
136
+ * ORDER MATTERS — the absolute check runs FIRST. An absolute or protocol-relative value is a
137
+ * deliberate off-site pointer (syndication, a partner-hosted copy, a CDN image) and passes
138
+ * through verbatim; only a site-relative path gets trailing-slash normalization and the site
139
+ * origin. Normalizing first turns `https://other.org/page` into
140
+ * `<siteUrl>/https://other.org/page/`.
141
+ *
142
+ * With no `siteUrl`, a site-relative path is returned still relative (normalized), which is
143
+ * what you want when the framework resolves relative metadata URLs itself.
144
+ *
145
+ * A NOT-absolute value is also run through `neutralizeImplicitOffOrigin` before normalization,
146
+ * so a WHATWG-backslash-equivalent spelling that `isAbsoluteUrl` correctly declines to call
147
+ * "absolute" (see its doc) can't still be read by a browser as protocol-relative once emitted.
148
+ */
149
+ export declare function resolveSeoUrl(pathOrUrl: string, opts?: ResolveSeoUrlOptions): string;
150
+ //# sourceMappingURL=seo.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo.d.ts","sourceRoot":"","sources":["../../src/static/seo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,uDAAuD;AACvD,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAA;AAEzD,4DAA4D;AAC5D,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,qBAAqB,CAAA;AAE9D,iDAAiD;AACjD,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,WAAW,CAAC,EAAE,cAAc,CAAA;CAC7B;AAED,oDAAoD;AACpD,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,SAAS,EAAE,MAAM,CAAC,CAAA;AAE3D;;;GAGG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;CAQF,CAAA;AAElC,MAAM,WAAW,uBAAuB;IACtC,+DAA+D;IAC/D,MAAM,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAA;IAC/B;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uFAAuF;IACvF,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mDAAmD;IACnD,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,6FAA6F;AAC7F,MAAM,MAAM,gBAAgB,GAAG,IAAI,CAAC,uBAAuB,EAAE,QAAQ,GAAG,OAAO,CAAC,CAAA;AA6ChF;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,OAAO,EAClB,IAAI,GAAE,uBAA4B,GACjC,SAAS,CAcX;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,OAAO,EAAE,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAEvF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAElD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAI1D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAUtD;AAED,MAAM,WAAW,oBAAoB;IACnC,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,GAAE,oBAAyB,GAAG,MAAM,CAUxF"}
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Framework-agnostic SEO field extraction and URL shaping.
3
+ *
4
+ * This module is pure: it maps an entry's raw data onto a neutral `SeoFields` shape and
5
+ * resolves URLs. Framework adapters (e.g. canopycms-next's `entryToMetadata`) turn that shape
6
+ * into their own metadata objects; nothing here imports a framework or a node built-in.
7
+ *
8
+ * The field names default to the recommended SEO field group — see `defineSeoFieldGroup` in
9
+ * entry-schema.ts, which builds a schema group from these same names so the schema side and
10
+ * the read side cannot drift.
11
+ */
12
+ import { neutralizeImplicitOffOrigin } from '../utils/sanitize-href.js';
13
+ /**
14
+ * The recommended SEO field names. `defineSeoFieldGroup()` emits a schema group using exactly
15
+ * these names, so an adopter who uses both ends needs no configuration at all.
16
+ */
17
+ export const DEFAULT_SEO_FIELD_NAMES = {
18
+ title: 'metaTitle',
19
+ description: 'metaDescription',
20
+ ogImage: 'ogImage',
21
+ ogType: 'ogType',
22
+ canonical: 'canonical',
23
+ noindex: 'noindex',
24
+ twitterCard: 'twitterCard',
25
+ };
26
+ const asRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
27
+ ? value
28
+ : undefined;
29
+ /**
30
+ * A non-empty trimmed string, or undefined.
31
+ *
32
+ * Empty is treated as UNSET, deliberately: CanopyCMS writes optional fields present-but-empty
33
+ * rather than omitting them, so `metaTitle: ''` is what an untouched SEO group looks like on
34
+ * disk. Treating it as a value would let a blank field beat the fallback title on every entry
35
+ * whose author never opened the SEO section.
36
+ */
37
+ const asText = (value) => {
38
+ if (typeof value !== 'string')
39
+ return undefined;
40
+ const trimmed = value.trim();
41
+ return trimmed === '' ? undefined : trimmed;
42
+ };
43
+ const asFlag = (value) => typeof value === 'boolean' ? value : undefined;
44
+ /**
45
+ * An image URL from either convention: a plain string field, or an `image` field, whose value
46
+ * is a structured `{ src, alt, ... }` object. Supporting both means switching the recommended
47
+ * `ogImage` field to `type: 'image'` doesn't silently drop every OG image.
48
+ */
49
+ const asImageUrl = (value) => {
50
+ const text = asText(value);
51
+ if (text)
52
+ return text;
53
+ return asText(asRecord(value)?.src);
54
+ };
55
+ const asOgType = (value) => {
56
+ const text = asText(value);
57
+ return text === 'website' || text === 'article' || text === 'profile' ? text : undefined;
58
+ };
59
+ const asTwitterCard = (value) => {
60
+ const text = asText(value);
61
+ return text === 'summary' || text === 'summary_large_image' ? text : undefined;
62
+ };
63
+ /**
64
+ * Map an entry's SEO fields onto the neutral shape.
65
+ *
66
+ * Entry data is `unknown` — every read is guarded, unknown enum values are dropped rather than
67
+ * passed through, and non-object data (null, a string, an array) yields an all-undefined result
68
+ * rather than throwing.
69
+ */
70
+ export function extractSeoFields(entryData, opts = {}) {
71
+ const names = { ...DEFAULT_SEO_FIELD_NAMES, ...opts.fields };
72
+ const entry = asRecord(entryData);
73
+ const source = opts.group ? asRecord(entry?.[opts.group]) : entry;
74
+ return {
75
+ title: asText(source?.[names.title]) ?? asText(opts.fallbackTitle),
76
+ description: asText(source?.[names.description]) ?? asText(opts.fallbackDescription),
77
+ ogImage: asImageUrl(source?.[names.ogImage]),
78
+ ogType: asOgType(source?.[names.ogType]),
79
+ canonical: asText(source?.[names.canonical]),
80
+ noindex: asFlag(source?.[names.noindex]),
81
+ twitterCard: asTwitterCard(source?.[names.twitterCard]),
82
+ };
83
+ }
84
+ /**
85
+ * THE `noindex` predicate. Every surface that suppresses an entry must route through this one
86
+ * function rather than re-deriving the flag:
87
+ *
88
+ * - the page's own `robots: { index: false }` (canopycms-next's `entryToMetadata`), and
89
+ * - exclusion from the sitemap (canopycms-next's `generateContentSitemap`).
90
+ *
91
+ * Those two shipping from one predicate is the point of shipping them together. Where they were
92
+ * derived separately, a `noindex` entry stayed advertised in one surface while correctly absent
93
+ * from the other, and nothing warned.
94
+ *
95
+ * NOT applied to enumeration: `noindex` means "don't advertise this", not "don't build this".
96
+ * `collectRoutableEntries` and `generateStaticParams` must still emit the entry, so its URL
97
+ * resolves for anyone holding the link.
98
+ */
99
+ export function isNoindexEntry(entryData, opts = {}) {
100
+ return extractSeoFields(entryData, opts).noindex === true;
101
+ }
102
+ /**
103
+ * An off-site URL: either scheme-qualified (`https://…`) or LITERAL protocol-relative
104
+ * (`//cdn…`). Both name a host we don't control, so neither may be rewritten against the site
105
+ * origin — `resolveSeoUrl` passes a `true` result through verbatim.
106
+ *
107
+ * Deliberately narrower than "resolves off-origin": a WHATWG-backslash-equivalent spelling
108
+ * (`/\evil.com`, `\\evil.com`, `\/evil.com` — see `utils/sanitize-href.ts`'s
109
+ * `isImplicitlyOffOrigin`) also resolves off-origin in a browser, but is NOT recognized here as
110
+ * an intentional off-site pointer the way a literal `//cdn…` is. `resolveSeoUrl` handles that
111
+ * case separately, by neutralizing (`neutralizeImplicitOffOrigin`) rather than passing through: a
112
+ * `false` result here isn't a guarantee the value is a safe site-relative path on its own, only
113
+ * that it isn't a *declared* off-site one.
114
+ */
115
+ export function isAbsoluteUrl(url) {
116
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith('//');
117
+ }
118
+ /**
119
+ * Strip trailing slashes without a regex.
120
+ *
121
+ * The obvious `replace(/\/+$/, '')` is a polynomial-ReDoS shape (CodeQL
122
+ * `js/polynomial-redos`, flagged high): on a value that is mostly slashes but does not end in
123
+ * one, the engine retries `\/+` from every position and the match cost is quadratic in the
124
+ * input length. `siteUrl` is adopter-supplied and can reach here from config or an env var, so
125
+ * it counts as uncontrolled. A character scan is linear and needs no reasoning about
126
+ * backtracking.
127
+ *
128
+ * Exported (from `canopycms/server`) so adopter code normalizing its own site-origin env var —
129
+ * e.g. a `SITE_URL` constant built from `NEXT_PUBLIC_SITE_URL` — has a package-provided linear
130
+ * way to do it instead of reaching for the same regex this function replaced.
131
+ */
132
+ export function stripTrailingSlashes(value) {
133
+ let end = value.length;
134
+ while (end > 0 && value.charCodeAt(end - 1) === 47 /* '/' */)
135
+ end--;
136
+ return value.slice(0, end);
137
+ }
138
+ /**
139
+ * Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
140
+ *
141
+ * Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
142
+ * `/blog/rss.xml`) alone, and never doubles an existing slash.
143
+ *
144
+ * A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
145
+ * and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
146
+ * `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
147
+ * with a trailing slash" means and breaks the URL).
148
+ */
149
+ export function withTrailingSlash(path) {
150
+ const splitIndex = path.search(/[?#]/);
151
+ const base = splitIndex === -1 ? path : path.slice(0, splitIndex);
152
+ const suffix = splitIndex === -1 ? '' : path.slice(splitIndex);
153
+ const withLeading = base.startsWith('/') ? base : `/${base}`;
154
+ if (withLeading === '/' || withLeading.endsWith('/'))
155
+ return withLeading + suffix;
156
+ const lastSegment = withLeading.slice(withLeading.lastIndexOf('/') + 1);
157
+ if (lastSegment.includes('.'))
158
+ return withLeading + suffix;
159
+ return `${withLeading}/${suffix}`;
160
+ }
161
+ /**
162
+ * Resolve a possibly-relative URL for public emission (canonical tag, sitemap URL, OG image).
163
+ *
164
+ * ORDER MATTERS — the absolute check runs FIRST. An absolute or protocol-relative value is a
165
+ * deliberate off-site pointer (syndication, a partner-hosted copy, a CDN image) and passes
166
+ * through verbatim; only a site-relative path gets trailing-slash normalization and the site
167
+ * origin. Normalizing first turns `https://other.org/page` into
168
+ * `<siteUrl>/https://other.org/page/`.
169
+ *
170
+ * With no `siteUrl`, a site-relative path is returned still relative (normalized), which is
171
+ * what you want when the framework resolves relative metadata URLs itself.
172
+ *
173
+ * A NOT-absolute value is also run through `neutralizeImplicitOffOrigin` before normalization,
174
+ * so a WHATWG-backslash-equivalent spelling that `isAbsoluteUrl` correctly declines to call
175
+ * "absolute" (see its doc) can't still be read by a browser as protocol-relative once emitted.
176
+ */
177
+ export function resolveSeoUrl(pathOrUrl, opts = {}) {
178
+ if (isAbsoluteUrl(pathOrUrl))
179
+ return pathOrUrl;
180
+ const safePathOrUrl = neutralizeImplicitOffOrigin(pathOrUrl);
181
+ const normalized = opts.trailingSlash
182
+ ? withTrailingSlash(safePathOrUrl)
183
+ : safePathOrUrl.startsWith('/')
184
+ ? safePathOrUrl
185
+ : `/${safePathOrUrl}`;
186
+ const origin = opts.siteUrl === undefined ? undefined : stripTrailingSlashes(opts.siteUrl);
187
+ return origin ? origin + normalized : normalized;
188
+ }
189
+ //# sourceMappingURL=seo.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo.js","sourceRoot":"","sources":["../../src/static/seo.ts"],"names":[],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -124,6 +124,16 @@ export interface WorkerStatusReport {
124
124
  durationMs: number;
125
125
  rebased: string[];
126
126
  skippedDirty: string[];
127
+ /**
128
+ * [SYNC-C1] Branches skipped because a content write held the branch's
129
+ * cross-host content-write lock (utils/content-write-lock.ts) -- the
130
+ * worker yields and retries next cycle.
131
+ *
132
+ * Optional for the same reason as `tracked` below: absent from a
133
+ * worker-status.json written by a worker predating that lock. Readers must
134
+ * tolerate its absence.
135
+ */
136
+ skippedLocked?: string[];
127
137
  failed: {
128
138
  branch: string;
129
139
  error: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,127 @@
1
+ /**
2
+ * [SYNC-C1] Cross-host mutual exclusion between the worker's rebase loop and
3
+ * content writes against the same branch working tree.
4
+ *
5
+ * Production runs a Lambda (API) and an EC2 worker over one EFS filesystem.
6
+ * `ContentStore`'s write/delete/rename critical sections were guarded only by
7
+ * the in-process mutex (`utils/async-mutex`), which does not cross that
8
+ * boundary, while `CmsWorker.rebaseActiveBranches()` rewrites the very same
9
+ * working tree. A write landing after `git rebase` started is destroyed two
10
+ * ways -- `git checkout --theirs <file>` overwrites it with the branch's
11
+ * committed version (and the rebase then SUCCEEDS, logging nothing), and
12
+ * `git rebase --abort` hard-resets the tree. Either way the editor already got
13
+ * its 200. This lock is what serializes the two.
14
+ *
15
+ * **Asymmetric by design.** The worker retries every branch on its next sync
16
+ * cycle (~5 minutes) while the editor is a person waiting on a save, so the
17
+ * worker yields and the writer waits:
18
+ *
19
+ * - Worker: {@link tryAcquireContentWriteLock} -- zero retries. On contention
20
+ * it skips that branch this cycle (an extension of the existing
21
+ * skip-dirty-branches behavior) and picks it up next cycle.
22
+ * - Writers: {@link withContentWriteLock} -- a short bounded wait, then a
23
+ * retriable failure the API surfaces as a 409.
24
+ *
25
+ * **Reads never take this lock.** An extra EFS round-trip on every read is not
26
+ * an acceptable price, and a read racing a rebase gets a consistent-enough
27
+ * older or newer file, never a destroyed one.
28
+ *
29
+ * ## Why the lock is anchored on `{branchRoot}/.canopy-meta`
30
+ *
31
+ * proper-lockfile keys its module-level `locks{}` bookkeeping (refresh timer,
32
+ * release function) by the **target path** passed to `lock()`, NOT by
33
+ * `lockfilePath`. The provisioning lock targets `path.dirname(branchRoot)` --
34
+ * the shared content-branches directory -- so anchoring here on that same
35
+ * directory with a different lock NAME would land on the same registry key:
36
+ * one process holding both (a Lambda container provisioning a workspace and
37
+ * writing content) would have the second acquisition clobber the first's
38
+ * bookkeeping. Targeting `{branchRoot}/.canopy-meta` instead gives:
39
+ *
40
+ * - a distinct in-process registry key from the provisioning lock (and from
41
+ * every other branch's content lock -- the provisioning locks already share
42
+ * one key across branches);
43
+ * - a distinct on-disk lock directory, so the server-enforced mkdir cannot
44
+ * collide either; and
45
+ * - no deadlock potential: the two locks are never both required, and the only
46
+ * possible acquisition order (provision, then write) is consistent.
47
+ *
48
+ * `.canopy-meta/` is git-excluded in every branch clone (`ensureGitExclude`),
49
+ * so the lock directory can never dirty the working tree or be swept into
50
+ * `git add .` at publish time.
51
+ *
52
+ * ## Honest caveat (do not oversell this)
53
+ *
54
+ * `proper-lockfile` decides a holder is dead by reading the lock directory's
55
+ * mtime with `fs.stat`, which on EFS is served through the NFS attribute
56
+ * cache. A live holder refreshes every `stale/2`, but a waiter can read a
57
+ * cached mtime and conclude the lock is stale when it is not, taking it over.
58
+ * The failure mode of a bad takeover is exactly today's behavior (two
59
+ * unsynchronized writers), so this is a strict improvement -- not a proof of
60
+ * mutual exclusion. See docs/concurrency.md.
61
+ */
62
+ /** On-disk name of the lock marker (a directory, created by mkdir). */
63
+ export declare const CONTENT_WRITE_LOCK_NAME = "content-write.lock";
64
+ /**
65
+ * Default bounded wait for a content write.
66
+ *
67
+ * Deliberately short. A rebase holds this lock for a fetch plus a replay plus
68
+ * N conflict rounds of git subprocesses on EFS -- far longer than any wait an
69
+ * interactive save can absorb, and longer than the API's own latency budget.
70
+ * So the wait is not sized to outlast a rebase (nothing reasonable could); it
71
+ * is sized to absorb lock handoff and the short holds of other writers, and to
72
+ * turn everything longer into a fast, explicit "retry" the editor can act on
73
+ * rather than a request that hangs toward the Lambda timeout. The worker
74
+ * revisits the branch next cycle, so the blocked state is transient.
75
+ *
76
+ * This is ALSO the writer-vs-writer budget, not only the rebase one. The lock
77
+ * is per-branch-root, so every write to one branch now serializes behind it
78
+ * where in-process serialization used to be per-entry -- bounded on Lambda
79
+ * (one invocation per container), but real under `next dev` and build-time
80
+ * provisioning across worker processes. The case to watch is a write whose
81
+ * in-lock path triggers a full `idIndex()` rescan of a large tree over EFS:
82
+ * that can plausibly exceed this budget and start 409ing unrelated saves.
83
+ * Making it configurable for prod tuning is tracked in
84
+ * .claude/future-tasks/content-write-lock-tuning-and-granularity.md.
85
+ */
86
+ export declare const DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2000;
87
+ /**
88
+ * Thrown when the bounded wait expires with the branch's content lock still
89
+ * held. Retriable -- callers translate this into a 409 with a message that
90
+ * says so.
91
+ *
92
+ * The message says "syncing OR another save" because BOTH produce it. The
93
+ * lock is taken by `write`/`delete`/`renameEntry` and by the admin
94
+ * repair-content-duplicates action as well as by the worker's rebase, so a
95
+ * message naming only the rebase was a confident claim about something that
96
+ * may not be happening. `api/content.ts` routes this error ahead of the
97
+ * generic conflict specifically so the editor sees this wording, which makes
98
+ * the wording load-bearing rather than cosmetic.
99
+ */
100
+ export declare class ContentWriteLockBusyError extends Error {
101
+ constructor(message?: string);
102
+ }
103
+ /**
104
+ * Acquire the branch's content-write lock WITHOUT waiting.
105
+ *
106
+ * Throws an error with `code === 'ELOCKED'` when a live holder has it. Used by
107
+ * the worker's rebase loop (which skips the branch and retries next cycle) and
108
+ * by tests standing in for a writer.
109
+ */
110
+ export declare function tryAcquireContentWriteLock(branchRoot: string): Promise<() => Promise<void>>;
111
+ /**
112
+ * Acquire the branch's content-write lock with a short bounded wait.
113
+ *
114
+ * Retries ONLY on genuine contention (`ELOCKED`), the same discipline
115
+ * `withOccFileLock` uses: proper-lockfile's own retry loop retries blindly on
116
+ * any error, which would burn the whole budget re-hitting e.g. ENOENT after
117
+ * the branch directory was deleted out from under the caller.
118
+ *
119
+ * @throws ContentWriteLockBusyError when the budget expires under contention.
120
+ */
121
+ export declare function acquireContentWriteLock(branchRoot: string, waitMs?: number): Promise<() => Promise<void>>;
122
+ /**
123
+ * Run `fn` holding the branch's content-write lock, releasing it in a
124
+ * `finally` so a throw can never strand it.
125
+ */
126
+ export declare function withContentWriteLock<T>(branchRoot: string, fn: () => Promise<T>, waitMs?: number): Promise<T>;
127
+ //# sourceMappingURL=content-write-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-write-lock.d.ts","sourceRoot":"","sources":["../../src/utils/content-write-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4DG;AAUH,uEAAuE;AACvE,eAAO,MAAM,uBAAuB,uBAAuB,CAAA;AAE3D;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,kCAAkC,OAAO,CAAA;AAEtD;;;;;;;;;;;;GAYG;AACH,qBAAa,yBAA0B,SAAQ,KAAK;gBAEhD,OAAO,SAAuI;CAKjJ;AAYD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAE3F;AAED;;;;;;;;;GASG;AACH,wBAAsB,uBAAuB,CAC3C,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,MAA2C,GAClD,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAe9B;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,UAAU,EAAE,MAAM,EAClB,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,MAAM,GAAE,MAA2C,GAClD,OAAO,CAAC,CAAC,CAAC,CAOZ"}