caracms 0.0.0-stage → 0.2.0

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/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
package/src/index.ts ADDED
@@ -0,0 +1,434 @@
1
+ /**
2
+ * The caracms integration.
3
+ *
4
+ * ## How the package loads the site (Decision 3; for Phase 2's builder)
5
+ *
6
+ * The package can't import the site, so the site hands itself over:
7
+ *
8
+ * 1. The site's `caracms.config.ts` is plain data (`defineSite`, from
9
+ * `caracms/config`): field descriptors, and components named by path. Its
10
+ * `astro.config.mjs` imports it and passes it in: `caracms(site)`.
11
+ * 2. In `astro:config:setup`, before the dev server or the build starts, the
12
+ * integration validates it and resolves its paths (`resolveSite`); a bad config
13
+ * throws a plain sentence and stops `astro dev` / `astro build`.
14
+ * 3. A Vite plugin serves four virtual modules from the resolved config. They work
15
+ * the same in dev, in the build and in tests (Vite's container renders), and any
16
+ * module, including routes the package injects, can import them:
17
+ *
18
+ * - `virtual:caracms/config` `{ site }`: the ResolvedSite as JSON (collections,
19
+ * block schemas, text styles, brand slots,
20
+ * phoneLinesWidth, absolute paths). No components.
21
+ * - `virtual:caracms/blocks` `{ blocks }`: block type → its Astro component,
22
+ * imported by absolute path.
23
+ * - `virtual:caracms/page` `{ Page }`: the site's page component.
24
+ * - `virtual:caracms/collections` `{ components }`: a list-of-fields collection's
25
+ * name → its component (M5a Decision 7).
26
+ * - `virtual:caracms/lockup.css` the lockup CSS with the site's phone-lines
27
+ * width in its media query (lockup-css.ts).
28
+ *
29
+ * They're split so nothing imports in a circle: a block's Lockup imports
30
+ * `config`; the Blocks loop imports `blocks`; only RenderDocument imports `page`.
31
+ * Types: `caracms/virtual` (src/virtual.d.ts), injected into the site's
32
+ * `.astro/types.d.ts` by `injectTypes`.
33
+ * 4. Fonts: every text style's web font is registered with Astro's fonts API from
34
+ * the style (`font-display: block`, Decision 14), so one definition drives the
35
+ * CSS and the measurement. A page puts `<Fonts />` in its head.
36
+ *
37
+ * Tree-shaking: the public side is `config`, `blocks` and the package's components;
38
+ * nothing here imports editor code.
39
+ *
40
+ * ## The editor (Phase 2; src/editor/README.md)
41
+ *
42
+ * The integration injects the editor's routes (EDITOR_ROUTES: /admin, the preview,
43
+ * document and write routes, and M4's live-mode routes, all on demand) in dev and in
44
+ * the build (M4 Decision 1, reversing M3 Decision 4), with the editor's middleware.
45
+ * Under `astro dev` only, it also takes the collection folders out of Vite's watch
46
+ * (Decision 5) and has Vite pre-bundle the editor's browser deps. The public pages
47
+ * have no script, no React and no editor markers in either.
48
+ */
49
+ import type { AstroIntegration } from 'astro';
50
+ import { fontProviders } from 'astro/config';
51
+ import { readFileSync } from 'node:fs';
52
+ import { createRequire } from 'node:module';
53
+ import { isAbsolute, join, sep } from 'node:path';
54
+ import { fileURLToPath, pathToFileURL } from 'node:url';
55
+ import { resolveSite, SITE_SETTINGS_FILE, type ResolvedSite, type SiteConfig } from './config.ts';
56
+ import { lockupCss } from './lockup-css.ts';
57
+ import { shareBuildReads } from './read.ts';
58
+ import { writeSchemaFiles } from './cli/schemas.ts';
59
+ import { configFile } from './cli/site.ts';
60
+ import { readCodeRoutes } from './code-routes-disk.ts';
61
+ import { GUARD_MIDDLEWARE, LOGIN_ROUTES } from './live/auth/routes.ts';
62
+
63
+
64
+ export type { SiteConfig, ResolvedSite } from './config.ts';
65
+
66
+ const VIRTUAL = {
67
+ config: 'virtual:caracms/config',
68
+ public: 'virtual:caracms/public',
69
+ blocks: 'virtual:caracms/blocks',
70
+ page: 'virtual:caracms/page',
71
+ collections: 'virtual:caracms/collections',
72
+ css: 'virtual:caracms/lockup.css',
73
+ } as const;
74
+
75
+ const resolvedId = (id: string) => `\0${id}`;
76
+
77
+ /**
78
+ * What the public site's components learn at build time (M6): the site's address
79
+ * (`SITE_URL`, Decision 3), Turnstile's site key (read at build, Decision 6), and whether
80
+ * this is a production build (the Google tag goes only there, Decision 16).
81
+ */
82
+ export interface PublicBuild {
83
+ /** Astro's `site` as it applies (the site's own, else `SITE_URL`), without a trailing slash; null when neither is set. */
84
+ siteUrl: string | null;
85
+ turnstileSiteKey: string | null;
86
+ /** `astro build` (and the built server), not `astro dev`. */
87
+ production: boolean;
88
+ }
89
+
90
+ /** The virtual modules' source, from a resolved site. Exported for tests. */
91
+ export function virtualModules(site: ResolvedSite, build: PublicBuild = { siteUrl: null, turnstileSiteKey: null, production: false }): Record<string, string> {
92
+ const blockEntries = Object.values(site.blocks);
93
+ const fieldsCollections = Object.values(site.collections).filter((c) => c.kind === 'fields' && c.component);
94
+ return {
95
+ [VIRTUAL.config]: `export const site = ${JSON.stringify(site)};\n`,
96
+ [VIRTUAL.public]: `export const build = ${JSON.stringify(build)};\n`,
97
+ [VIRTUAL.blocks]:
98
+ blockEntries.map((b, i) => `import Block${i} from ${JSON.stringify(b.component)};`).join('\n') +
99
+ `\nexport const blocks = {${blockEntries.map((b, i) => `${JSON.stringify(b.type)}: Block${i}`).join(', ')}};\n`,
100
+ [VIRTUAL.page]: `import Page from ${JSON.stringify(site.page)};\nexport { Page };\n`,
101
+ [VIRTUAL.collections]:
102
+ fieldsCollections.map((c, i) => `import Collection${i} from ${JSON.stringify(c.component)};`).join('\n') +
103
+ `\nexport const components = {${fieldsCollections.map((c, i) => `${JSON.stringify(c.name)}: Collection${i}`).join(', ')}};\n`,
104
+ [VIRTUAL.css]: lockupCss(site.phoneLinesWidth),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * The editor's routes, each on demand. M3 injected them under `astro dev` only; M4
110
+ * Decision 1 reverses that: they're in the build too, and the mode (local, live or
111
+ * unconfigured) is read at run time. The public pages still ship no script, no
112
+ * React and no markers. See editor/README.md for what each takes.
113
+ */
114
+ export const EDITOR_ROUTES = [
115
+ { pattern: '/admin/[...path]', entrypoint: 'editor/routes/admin.astro' },
116
+ { pattern: '/_caracms/preview', entrypoint: 'editor/routes/preview.astro' },
117
+ { pattern: '/_caracms/document', entrypoint: 'editor/routes/document.ts' },
118
+ { pattern: '/_caracms/documents', entrypoint: 'editor/routes/documents.ts' },
119
+ { pattern: '/_caracms/write', entrypoint: 'editor/routes/write.ts' },
120
+ // M5a Phase 3: creating and deleting documents, both modes (the guard's prefix rule covers them in live mode).
121
+ { pattern: '/_caracms/create', entrypoint: 'editor/routes/create.ts' },
122
+ { pattern: '/_caracms/delete', entrypoint: 'editor/routes/delete.ts' },
123
+ // Pre-M7 item 8: changing a document's address, both modes.
124
+ { pattern: '/_caracms/move', entrypoint: 'editor/routes/move.ts' },
125
+ // M5b Phase 2: the media library, uploads and Mux, both modes (types.ts → MEDIA_ENDPOINTS).
126
+ { pattern: '/_caracms/media/[...path]', entrypoint: 'editor/routes/media.ts' },
127
+ // M4 Decision 14: GitHub's push webhook (signature-checked; 404 with no secret).
128
+ { pattern: '/_caracms/github', entrypoint: 'editor/routes/github.ts' },
129
+ // M4 Phase 2, live mode (local mode: 404): the poll, locks, Publish, history, Try again, notices.
130
+ { pattern: '/_caracms/status', entrypoint: 'editor/routes/status.ts' },
131
+ { pattern: '/_caracms/lock', entrypoint: 'editor/routes/lock.ts' },
132
+ { pattern: '/_caracms/publish', entrypoint: 'editor/routes/publish.ts' },
133
+ { pattern: '/_caracms/history', entrypoint: 'editor/routes/history.ts' },
134
+ { pattern: '/_caracms/try-again', entrypoint: 'editor/routes/try-again.ts' },
135
+ { pattern: '/_caracms/notice', entrypoint: 'editor/routes/notice.ts' },
136
+ // M6 Decision 9: the inbox, both modes (the guard's prefix rule covers it in live mode).
137
+ { pattern: '/_caracms/submissions/[...path]', entrypoint: 'editor/routes/submissions.ts' },
138
+ ] as const;
139
+
140
+ /**
141
+ * The public site's own on-demand routes (M6): the forms' endpoint, public in every mode
142
+ * (the guard lets it through, the unconfigured 503 exempts it); and (M6c Decision 6) the
143
+ * library's files at `/files/<hash>/<name>.<ext>`, every path under `/files` reaching the
144
+ * route's own allow-list (files-route.ts), behind the pre-launch password like any page.
145
+ */
146
+ export const PUBLIC_ROUTES = [
147
+ { pattern: '/_caracms/form', entrypoint: 'forms/route.ts' },
148
+ { pattern: '/files/[...path]', entrypoint: 'files-route.ts' },
149
+ ] as const;
150
+
151
+ /** M6 Decision 12: the sitemap, prerendered, injected only when the site's address is known at build. */
152
+ export const SITEMAP_ROUTE = { pattern: '/sitemap.xml', entrypoint: 'components/sitemap.xml.ts' } as const;
153
+
154
+ /**
155
+ * M6 Decision 3: the site's public address. Astro's `site` when the site's config sets
156
+ * one, else `SITE_URL`; null when neither does (no sitemap, no canonical, a warning).
157
+ * `SITE_URL` must be an http(s) origin, optionally with a path.
158
+ */
159
+ export function siteAddress(astroSite: string | undefined, env: Record<string, string | undefined> = process.env): { url: string | null; problem: string | null } {
160
+ const raw = astroSite ?? env['SITE_URL']?.trim();
161
+ if (!raw) return { url: null, problem: null };
162
+ let url: URL;
163
+ try {
164
+ url = new URL(raw);
165
+ } catch {
166
+ return { url: null, problem: `SITE_URL must be the site's address, like https://fernhill.ie (got "${raw}").` };
167
+ }
168
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return { url: null, problem: `SITE_URL must start with https:// (got "${raw}").` };
169
+ return { url: `${url.origin}${url.pathname}`.replace(/\/+$/, ''), problem: null };
170
+ }
171
+
172
+ /** Pre-M7 item 8: Site Settings' redirects, served as 301s on the public site. */
173
+ export const REDIRECT_MIDDLEWARE = 'redirect-middleware.ts';
174
+
175
+ /** M4 Phase 2: the unconfigured server's 503, registered before Phase 3's guard. */
176
+ export const EDITOR_MIDDLEWARE = 'editor/middleware.ts';
177
+
178
+ /**
179
+ * Content files out of Vite's watch (Decision 5): a write never reloads `/admin`
180
+ * or the preview. One matcher per declared collection folder, and one for the Site
181
+ * Settings file (M4 Decision 15), which sits in `content/` beside them. M6: and the
182
+ * local submissions folder (`.caracms/`), so a form post or marking one read doesn't
183
+ * reload the editor.
184
+ */
185
+ export function contentWatchIgnores(site: Pick<ResolvedSite, 'collections' | 'root'>): ((path: string) => boolean)[] {
186
+ const settings = join(site.root, SITE_SETTINGS_FILE);
187
+ const local = join(site.root, '.caracms');
188
+ return [
189
+ // Every collection's folder, shared blocks' included; Site Settings is one file, matched below.
190
+ ...Object.values(site.collections)
191
+ .filter((c) => c.kind !== 'settings')
192
+ .map(({ dir }) => (path: string) => path === dir || path.startsWith(dir + sep)),
193
+ (path: string) => path === settings,
194
+ (path: string) => path === local || path.startsWith(local + sep),
195
+ ];
196
+ }
197
+
198
+ /**
199
+ * The hosts Astro may take a request's address from (`security.allowedDomains`).
200
+ * Without them, Astro ignores the Host header and every request reads as
201
+ * `localhost:<port>`. Behind Railway's edge that breaks the same-origin checks on the
202
+ * sign-in form and the editor's writes, and the addresses the pages show. Taken from
203
+ * Astro's `site`, then Railway's public domain: the same sources as emailed links
204
+ * (live/auth/index.ts → `publicOrigin`). A site's own `allowedDomains` wins.
205
+ * Read at build time: Astro puts the list in the server's manifest.
206
+ */
207
+ export function trustedDomains(
208
+ astroSite: string | undefined,
209
+ own: readonly unknown[] | undefined,
210
+ env: Record<string, string | undefined> = process.env,
211
+ ): { hostname: string; protocol: string }[] {
212
+ if (own?.length) return [];
213
+ const domains: { hostname: string; protocol: string }[] = [];
214
+ if (astroSite) {
215
+ const url = new URL(astroSite);
216
+ domains.push({ hostname: url.hostname, protocol: url.protocol.replace(':', '') });
217
+ }
218
+ const railway = env['RAILWAY_PUBLIC_DOMAIN']?.trim();
219
+ if (railway && !domains.some((d) => d.hostname === railway)) domains.push({ hostname: railway, protocol: 'https' });
220
+ return domains;
221
+ }
222
+
223
+ /**
224
+ * The browser side of the editor imports React and idiomorph from the package.
225
+ * Pre-bundled up front (dev only), so Vite doesn't discover them on the first
226
+ * visit to /admin and reload the page to optimise them (a flicker, M2 Decision 9).
227
+ */
228
+ /** Vite's cache for every command but `astro dev`, apart from dev's `node_modules/.vite` (see its use). Relative to the site's root. */
229
+ export const BUILD_VITE_CACHE = 'node_modules/.vite-caracms-build';
230
+
231
+ export const EDITOR_CLIENT_DEPS = [
232
+ 'caracms > react',
233
+ 'caracms > react/jsx-runtime',
234
+ 'caracms > react/jsx-dev-runtime',
235
+ 'caracms > react-dom/client',
236
+ 'caracms > idiomorph',
237
+ // M5a: the rich-text field's editor (Tiptap on ProseMirror), browser only.
238
+ 'caracms > @tiptap/core',
239
+ 'caracms > @tiptap/pm/history',
240
+ 'caracms > @tiptap/pm/model',
241
+ // The on-page editor's: left out, Vite finds them on the first /admin, optimizes again
242
+ // and reloads the page under whoever is on it (a browser test, mid-click).
243
+ 'caracms > @tiptap/pm/state',
244
+ 'caracms > @tiptap/pm/transform',
245
+ 'caracms > @tiptap/pm/view',
246
+ // M5b: a clip's upload straight to Mux, imported on the first one (client/media/store.ts).
247
+ 'caracms > @mux/upchunk',
248
+ // The date field's calendar (pre-M7 polish).
249
+ 'caracms > react-day-picker',
250
+ ];
251
+
252
+ /**
253
+ * The variables M5b Decision 5 names: the media pipeline's and Mux's. Only these are
254
+ * taken from the site's env files (copyMediaEnv); everything else in `.env` (the
255
+ * GitHub App, live mode) is left to the process's own environment, as before.
256
+ */
257
+ export const MEDIA_ENV_NAMES = [
258
+ 'CARACMS_MEDIA',
259
+ 'CARACMS_MEDIA_URL',
260
+ 'IMGPROXY_KEY',
261
+ 'IMGPROXY_SALT',
262
+ 'S3_ENDPOINT',
263
+ 'S3_REGION',
264
+ 'S3_BUCKET',
265
+ 'S3_ACCESS_KEY_ID',
266
+ 'S3_SECRET_ACCESS_KEY',
267
+ 'MUX_TOKEN_ID',
268
+ 'MUX_TOKEN_SECRET',
269
+ ] as const;
270
+
271
+ /**
272
+ * M6's variables a local run may take from the site's `.env` too: the site's address,
273
+ * Turnstile's keys, and the tests' submissions store. Copied the same way (a set value wins).
274
+ */
275
+ export const SITE_ENV_NAMES = ['SITE_URL', 'TURNSTILE_SITE_KEY', 'TURNSTILE_SECRET_KEY', 'CARACMS_SUBMISSIONS'] as const;
276
+
277
+ /**
278
+ * Copy Decision 5's variables from the env files' values (`loaded`, Vite's loadEnv) into
279
+ * `env`, **never over a value already set**: a real environment (Railway) and a test's
280
+ * `CARACMS_MEDIA=memory` always win. Other names are ignored. Returns the names copied.
281
+ */
282
+ export function copyMediaEnv(loaded: Record<string, string | undefined>, env: Record<string, string | undefined> = process.env, names: readonly string[] = MEDIA_ENV_NAMES): string[] {
283
+ const copied: string[] = [];
284
+ for (const name of names) {
285
+ const value = loaded[name];
286
+ if (value === undefined || value === '' || (env[name] !== undefined && env[name] !== '')) continue;
287
+ env[name] = value;
288
+ copied.push(name);
289
+ }
290
+ return copied;
291
+ }
292
+
293
+ /** The site's env files (`.env`, `.env.<mode>`, `.local` variants) through Vite's loadEnv, resolved from Astro's own Vite. */
294
+ async function loadSiteEnv(mode: string, root: string): Promise<Record<string, string>> {
295
+ const vite = createRequire(createRequire(import.meta.url).resolve('astro/package.json')).resolve('vite');
296
+ // A native import, out of Vite's reach. Installed from npm, this module is compiled by the Vite runner that
297
+ // loaded astro.config, which rewrites `import()` to go through it, and Astro has closed it before the hooks run
298
+ // ("Vite module runner has been closed"; found by M8's pack-and-install check).
299
+ const nativeImport = new Function('url', 'return import(url)') as (url: string) => Promise<unknown>;
300
+ const { loadEnv } = (await nativeImport(pathToFileURL(vite).href)) as { loadEnv: (mode: string, dir: string, prefixes: string | string[]) => Record<string, string> };
301
+ return loadEnv(mode, root, '');
302
+ }
303
+
304
+ export default function caracms(config: SiteConfig): AstroIntegration {
305
+ let site: ResolvedSite;
306
+ return {
307
+ name: 'caracms',
308
+ hooks: {
309
+ 'astro:config:setup': async ({ command, config: astroConfig, updateConfig, injectRoute, addMiddleware, addWatchFile, logger }) => {
310
+ site = resolveSite(config, fileURLToPath(astroConfig.root));
311
+ // A change to the site's caracms.config restarts the dev server, as one to astro.config does: the config
312
+ // is read once, here, and a new block would otherwise stay "Unknown block" until a hand restart.
313
+ // (A restart also regenerates schemas/, in astro:server:setup.) Only the config file itself: Astro's
314
+ // addWatchFile doesn't follow what it imports.
315
+ const configName = configFile(site.root);
316
+ if (configName) addWatchFile(join(site.root, configName));
317
+ // M8 Decision 10: the site's own routes, read once here, for the editor's server and browser alike.
318
+ site.codeRoutes = readCodeRoutes(site.root, astroConfig.srcDir ? fileURLToPath(astroConfig.srcDir) : 'src', Object.values(site.collections));
319
+ // M5b Decision 5: local dev (and a local build) read the media variables from the site's
320
+ // `.env`, which Astro doesn't put in process.env. Only those names; a set value wins.
321
+ const siteEnv = await loadSiteEnv(command === 'dev' ? 'development' : 'production', site.root);
322
+ copyMediaEnv(siteEnv);
323
+ copyMediaEnv(siteEnv, process.env, SITE_ENV_NAMES);
324
+ const dev = command === 'dev';
325
+ // M6 Decision 3: SITE_URL is Astro's `site` unless the site's config sets one.
326
+ const address = siteAddress(astroConfig.site);
327
+ if (address.problem) throw new Error(`caracms: ${address.problem}`);
328
+ if (command === 'build' && !address.url) logger.warn('SITE_URL is not set, so this build has no sitemap and its pages have no canonical link or og:url. Set it to the site\'s public address.');
329
+ const modules = virtualModules(site, { siteUrl: address.url, turnstileSiteKey: process.env.TURNSTILE_SITE_KEY?.trim() || null, production: !dev });
330
+ // M4 Decision 1: the editor's routes in dev and in the build alike; the mode is read at run time (editor/server/mode.ts).
331
+ {
332
+ for (const route of [...EDITOR_ROUTES, ...PUBLIC_ROUTES]) {
333
+ injectRoute({ pattern: route.pattern, entrypoint: new URL(`./${route.entrypoint}`, import.meta.url), prerender: false });
334
+ }
335
+ // M6 Decision 12: the sitemap, only with an address to put in it.
336
+ if (address.url) injectRoute({ pattern: SITEMAP_ROUTE.pattern, entrypoint: new URL(`./${SITEMAP_ROUTE.entrypoint}`, import.meta.url), prerender: true });
337
+ // M4 Phase 3: the sign-in pages (live/auth/routes.ts).
338
+ for (const route of LOGIN_ROUTES) injectRoute({ pattern: route.pattern, entrypoint: new URL(`./${route.entrypoint}`, import.meta.url), prerender: false });
339
+ // M4 Phase 2: an unconfigured production server's 503 on /admin and /_caracms/*. First, so it runs before the guard.
340
+ addMiddleware({ order: 'pre', entrypoint: new URL(`./${EDITOR_MIDDLEWARE}`, import.meta.url) });
341
+ // M4 Phase 3: the sign-in guard on /admin and /_caracms/ (live mode only). Must stay after the 503 middleware.
342
+ addMiddleware({ order: 'pre', entrypoint: new URL(`./${GUARD_MIDDLEWARE}`, import.meta.url) });
343
+ // Pre-M7 item 8: Site Settings' redirects, as 301s, for a public path that would be a 404.
344
+ addMiddleware({ order: 'pre', entrypoint: new URL(`./${REDIRECT_MIDDLEWARE}`, import.meta.url) });
345
+ }
346
+
347
+ const fontsFromStyles = Object.values(site.textStyles).map((style) => ({
348
+ provider: fontProviders.local(),
349
+ name: style.family,
350
+ cssVariable: style.cssVariable,
351
+ fallbacks: style.fallbacks ?? ['sans-serif'],
352
+ optimizedFallbacks: true,
353
+ // Decision 14: block, never a fallback that could make a line overflow (M1 → E6).
354
+ display: 'block' as const,
355
+ options: {
356
+ variants: [
357
+ {
358
+ src: [isAbsolute(style.fontFile.web) ? style.fontFile.web : `./${style.fontFile.web}`],
359
+ weight: style.weightRange,
360
+ style: 'normal' as const,
361
+ },
362
+ ],
363
+ },
364
+ }));
365
+
366
+ // measure.ts loads harfbuzzjs from here at runtime (see its comment).
367
+ const harfbuzz = pathToFileURL(createRequire(import.meta.url).resolve('harfbuzzjs')).href;
368
+
369
+ const domains = trustedDomains(astroConfig.site ?? address.url ?? undefined, astroConfig.security?.allowedDomains);
370
+ if (command === 'build' && !astroConfig.security?.allowedDomains?.length) {
371
+ if (domains.length) logger.info(`trusting the Host header for ${domains.map((d) => d.hostname).join(', ')}`);
372
+ else logger.warn("the site's public address isn't known (set Astro's `site`, or build on Railway): behind a proxy, sign-in and the editor's saves will be refused as cross-origin.");
373
+ }
374
+
375
+ updateConfig({
376
+ ...(!astroConfig.site && address.url ? { site: address.url } : {}),
377
+ fonts: fontsFromStyles,
378
+ ...(domains.length ? { security: { allowedDomains: domains } } : {}),
379
+ vite: {
380
+ // __CARACMS_PRODUCTION__: a production server is the build, so it's always live mode (M4 Decision 1; editor/server/mode.ts).
381
+ // __CARACMS_TOKENS_CSS__: editor/brand.ts reads its inks from tokens.css, which isn't beside the bundled chunk.
382
+ define: {
383
+ __CARACMS_HARFBUZZ__: JSON.stringify(harfbuzz),
384
+ __CARACMS_PRODUCTION__: JSON.stringify(!dev),
385
+ __CARACMS_TOKENS_CSS__: JSON.stringify(readFileSync(new URL('./editor/styles/tokens.css', import.meta.url), 'utf8')),
386
+ },
387
+ // Installed from npm, the package ships TypeScript and .astro source.
388
+ ssr: { noExternal: ['caracms'] },
389
+ ...(dev
390
+ ? {
391
+ server: { watch: { ignored: contentWatchIgnores(site) } },
392
+ optimizeDeps: { include: EDITOR_CLIENT_DEPS },
393
+ }
394
+ : {
395
+ // M8 review: `astro build` (and `astro sync`, `astro check`) starts a Vite server of its own, whose
396
+ // config differs from dev's, so Vite re-optimizes into the shared node_modules/.vite and deletes the
397
+ // running dev server's deps (react, idiomorph, the site's own): the editor and its preview answer 504
398
+ // "Outdated Optimize Dep" until dev restarts. Anything but dev keeps its cache apart.
399
+ cacheDir: BUILD_VITE_CACHE,
400
+ }),
401
+ plugins: [
402
+ {
403
+ name: 'caracms:virtual',
404
+ resolveId(id) {
405
+ return id in modules ? resolvedId(id) : undefined;
406
+ },
407
+ load(id) {
408
+ if (!id.startsWith('\0virtual:caracms/')) return undefined;
409
+ return modules[id.slice(1)];
410
+ },
411
+ },
412
+ ],
413
+ },
414
+ });
415
+ },
416
+ // M8 Decision 14: a dev server (at start, and on the restart a config change makes) regenerates
417
+ // schemas/ from the config. Never content files: a dev run only changes what your code change derives.
418
+ 'astro:server:setup': ({ logger }) => {
419
+ try {
420
+ const changed = writeSchemaFiles(site);
421
+ if (changed.length) logger.info(`updated ${changed.map((c) => c.file).join(', ')}`);
422
+ } catch (error) {
423
+ logger.warn(`couldn't write schemas/: ${(error as Error).message}`);
424
+ }
425
+ },
426
+ // Posts and tags, Decision 15: a production build reads each document once (read.ts). Never in dev.
427
+ 'astro:build:start': () => shareBuildReads(true),
428
+ 'astro:build:done': () => shareBuildReads(false),
429
+ 'astro:config:done': ({ injectTypes }) => {
430
+ injectTypes({ filename: 'caracms.d.ts', content: '/// <reference types="caracms/virtual" />\n' });
431
+ },
432
+ },
433
+ };
434
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * A JSON parser that remembers where every value sits in the source text, so the
3
+ * serializer can copy untouched values back byte for byte (Decision 7).
4
+ *
5
+ * Validity is JSON.parse's: the text is checked with it first, so error messages
6
+ * are the platform's and nothing this parser accepts is invalid JSON.
7
+ */
8
+
9
+ export type JsonNode = ObjectNode | ArrayNode | ScalarNode;
10
+
11
+ interface Span {
12
+ /** Offset of the value's first character. */
13
+ start: number;
14
+ /** Offset just past its last character. */
15
+ end: number;
16
+ }
17
+
18
+ export interface ObjectNode extends Span {
19
+ kind: 'object';
20
+ members: MemberNode[];
21
+ }
22
+
23
+ export interface MemberNode {
24
+ key: string;
25
+ value: JsonNode;
26
+ }
27
+
28
+ export interface ArrayNode extends Span {
29
+ kind: 'array';
30
+ items: JsonNode[];
31
+ }
32
+
33
+ export interface ScalarNode extends Span {
34
+ kind: 'scalar';
35
+ value: string | number | boolean | null;
36
+ }
37
+
38
+ export interface ParsedSource {
39
+ text: string;
40
+ root: JsonNode;
41
+ /** The plain value, as JSON.parse gives it. */
42
+ value: unknown;
43
+ }
44
+
45
+ export function parseSource(text: string): ParsedSource {
46
+ const value = JSON.parse(text);
47
+ let i = 0;
48
+
49
+ const ws = () => {
50
+ while (i < text.length && (text[i] === ' ' || text[i] === '\n' || text[i] === '\r' || text[i] === '\t')) i++;
51
+ };
52
+
53
+ const string = (): string => {
54
+ const start = i;
55
+ i++; // opening quote
56
+ while (text[i] !== '"') i += text[i] === '\\' ? 2 : 1;
57
+ i++; // closing quote
58
+ return JSON.parse(text.slice(start, i)) as string;
59
+ };
60
+
61
+ const node = (): JsonNode => {
62
+ ws();
63
+ const start = i;
64
+ const c = text[i];
65
+ if (c === '{') {
66
+ i++;
67
+ const members: MemberNode[] = [];
68
+ ws();
69
+ if (text[i] === '}') {
70
+ i++;
71
+ return { kind: 'object', start, end: i, members };
72
+ }
73
+ for (;;) {
74
+ ws();
75
+ const key = string();
76
+ ws();
77
+ i++; // ':'
78
+ members.push({ key, value: node() });
79
+ ws();
80
+ if (text[i++] === '}') return { kind: 'object', start, end: i, members };
81
+ }
82
+ }
83
+ if (c === '[') {
84
+ i++;
85
+ const items: JsonNode[] = [];
86
+ ws();
87
+ if (text[i] === ']') {
88
+ i++;
89
+ return { kind: 'array', start, end: i, items };
90
+ }
91
+ for (;;) {
92
+ items.push(node());
93
+ ws();
94
+ if (text[i++] === ']') return { kind: 'array', start, end: i, items };
95
+ }
96
+ }
97
+ if (c === '"') {
98
+ const s = string();
99
+ return { kind: 'scalar', start, end: i, value: s };
100
+ }
101
+ // number, true, false, null
102
+ while (i < text.length && !/[\s,\]}]/.test(text[i]!)) i++;
103
+ return { kind: 'scalar', start, end: i, value: JSON.parse(text.slice(start, i)) };
104
+ };
105
+
106
+ const root = node();
107
+ return { text, root, value };
108
+ }
109
+
110
+ /** The last member with this key (JSON.parse's rule for duplicate keys). */
111
+ export function member(node: ObjectNode, key: string): MemberNode | undefined {
112
+ for (let k = node.members.length - 1; k >= 0; k--) if (node.members[k]!.key === key) return node.members[k];
113
+ return undefined;
114
+ }
115
+
116
+ export const slice = (source: ParsedSource, node: JsonNode) => source.text.slice(node.start, node.end);
package/src/links.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The one link rule (M5a Decision 4), lifted from the template
3
+ * (`tina-astro-template/src/lib/links.ts` → `validateLink`). The editor's link
4
+ * inputs and the render both import it, so a link the field flags is exactly a
5
+ * link the page shows as plain text.
6
+ *
7
+ * Browser-safe: no node imports.
8
+ *
9
+ * validateLink(value) the rule's message, or undefined (blank passes)
10
+ * isLinkHref(value) true when the value is a usable href: non-blank and passes
11
+ * missingLinkTarget(href, urls) "Links to /contact, which no page or route serves", or undefined
12
+ *
13
+ * Anything that fails the rule is kept as typed, with the message on the field,
14
+ * and renders as plain text (Decision 15).
15
+ */
16
+
17
+ const RELATIVE_PATH = /^\/(?!\/)\S*$/;
18
+ const ABSOLUTE_URL = /^https?:\/\/\S+$/;
19
+ const MAILTO = /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/;
20
+ const TEL = /^tel:\+?[0-9][0-9\s().-]*$/;
21
+ /**
22
+ * A fragment: `#` alone (the top of the page, a placeholder link), or `#` then an HTML
23
+ * id (any characters but spaces, HTML's own rule for an id).
24
+ */
25
+ const ANCHOR = /^#\S*$/;
26
+
27
+ /** The rule's message: what the field says under a link that fails it. */
28
+ export const LINK_RULE_MESSAGE =
29
+ 'Enter a path starting with "/" (e.g. /contact), a full https:// URL, mailto:someone@example.com, tel:+353210000000, or an anchor on the page (#contact, or # alone).';
30
+
31
+ /**
32
+ * The link rule: a `/path`, an `http(s)://` URL, `mailto:`, `tel:`, or a fragment (`#`, `#anchor`).
33
+ * Blank passes (an empty link is a different matter: it shows nothing). Returns
34
+ * the rule's message, or undefined when the value passes.
35
+ */
36
+ export function validateLink(value: unknown): string | undefined {
37
+ if (typeof value !== 'string' || value.trim() === '') return undefined;
38
+ const candidate = value.trim();
39
+ if (RELATIVE_PATH.test(candidate) || ABSOLUTE_URL.test(candidate) || MAILTO.test(candidate) || TEL.test(candidate) || ANCHOR.test(candidate)) {
40
+ return undefined;
41
+ }
42
+ return LINK_RULE_MESSAGE;
43
+ }
44
+
45
+ /** A usable href: a non-blank string that passes the rule. What the render checks before writing `<a href>`. */
46
+ export function isLinkHref(value: unknown): value is string {
47
+ return typeof value === 'string' && value.trim() !== '' && validateLink(value) === undefined;
48
+ }
49
+
50
+ /**
51
+ * The site path an internal link points at, for comparing with documents'
52
+ * addresses: `/contact/?x#y` → `/contact`. Null for anything that isn't checked:
53
+ * not a `/path`, or a path to a file (an extension in its last part, such as
54
+ * `/brochure.pdf`, a file in `public/`).
55
+ */
56
+ export function internalLinkPath(href: unknown): string | null {
57
+ if (typeof href !== 'string') return null;
58
+ const candidate = href.trim();
59
+ if (!RELATIVE_PATH.test(candidate)) return null;
60
+ let path = candidate.replace(/[?#].*$/, '');
61
+ const last = path.split('/').pop() ?? '';
62
+ if (/\.[A-Za-z0-9]+$/.test(last)) return null;
63
+ path = normaliseAddress(path);
64
+ return path;
65
+ }
66
+
67
+ /** `/services/branding/` → `/services/branding`; `/` stays `/`. */
68
+ export function normaliseAddress(url: string): string {
69
+ const trimmed = url.replace(/\/+$/, '');
70
+ return trimmed === '' ? '/' : trimmed;
71
+ }
72
+
73
+ /**
74
+ * Decision 4's warning: a `/path` with no file extension that matches no
75
+ * document. `addresses` are the site's documents' URLs (DocumentEntry.url:
76
+ * `/`, `/about`, `/journal/my-first-post`), from the store's listing (Phase 2
77
+ * wires it). Never an error: a warning the field shows.
78
+ */
79
+ /** M9 Decision 7: the hidden pages in a listing, as `WarningContext.hiddenPages` takes them (address → name). */
80
+ export function hiddenPages(entries: Iterable<{ url: string | null; label: string; hidden?: true }>): Map<string, string> {
81
+ const out = new Map<string, string>();
82
+ for (const e of entries) if (e.hidden && e.url !== null) out.set(normaliseAddress(e.url), e.label);
83
+ return out;
84
+ }
85
+
86
+ /** M9 Decision 7: the hidden documents in a listing, as `WarningContext.hiddenDocuments` takes them (`collection/path` → name and address). */
87
+ export function hiddenDocuments(entries: Iterable<{ collection: string; path: string; url: string | null; label: string; hidden?: true }>): Map<string, { label: string; url: string | null }> {
88
+ const out = new Map<string, { label: string; url: string | null }>();
89
+ for (const e of entries) if (e.hidden) out.set(`${e.collection}/${e.path}`, { label: e.label, url: e.url === null ? null : normaliseAddress(e.url) });
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * M9 Decision 7's warning for a reference (a `reference()` field) to a hidden page, worded as
95
+ * the link one is. Never an error: unhiding the page fixes it.
96
+ */
97
+ export function hiddenReferenceTarget(collection: string, path: string, hidden: ReadonlyMap<string, { label: string; url: string | null }>): string | undefined {
98
+ const doc = hidden.get(`${collection}/${path}`);
99
+ if (!doc) return undefined;
100
+ return `Points at ${doc.url ?? path}, the ${doc.label || path} page, which is hidden from the site. Visitors get its 404 until it's shown again.`;
101
+ }
102
+
103
+ /**
104
+ * M9 Decision 7's warning: a link to a hidden page. `hidden` maps a hidden page's address
105
+ * (normalised) to its name. Never an error: unhiding the page fixes it.
106
+ */
107
+ export function hiddenLinkTarget(href: unknown, hidden: ReadonlyMap<string, string>): string | undefined {
108
+ const path = internalLinkPath(href);
109
+ if (path === null) return undefined;
110
+ const name = hidden.get(path);
111
+ return name === undefined ? undefined : `Links to ${path}, the ${name} page, which is hidden from the site. Visitors get its 404 until it's shown again.`;
112
+ }
113
+
114
+ export function missingLinkTarget(href: unknown, addresses: Iterable<string | null | undefined>): string | undefined {
115
+ const path = internalLinkPath(href);
116
+ if (path === null) return undefined;
117
+ for (const address of addresses) {
118
+ if (typeof address === 'string' && normaliseAddress(address) === path) return undefined;
119
+ }
120
+ return `Links to ${path}, which no page or route serves.`;
121
+ }