caracms 0.0.0-stage → 0.1.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 (274) 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 +711 -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 +24 -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 +496 -0
  16. package/skills/caracms-adopt/look-same.mjs +948 -0
  17. package/skills/caracms-blocks/SKILL.md +329 -0
  18. package/skills/caracms-content/SKILL.md +200 -0
  19. package/src/cards.ts +158 -0
  20. package/src/cli/schemas.ts +122 -0
  21. package/src/cli/site.ts +66 -0
  22. package/src/cli/validate.ts +466 -0
  23. package/src/code-routes-disk.ts +67 -0
  24. package/src/code-routes.ts +134 -0
  25. package/src/columns.ts +39 -0
  26. package/src/components/Block.astro +46 -0
  27. package/src/components/Blocks.astro +120 -0
  28. package/src/components/Buttons.astro +116 -0
  29. package/src/components/Fonts.astro +14 -0
  30. package/src/components/Form.astro +155 -0
  31. package/src/components/Head.astro +61 -0
  32. package/src/components/Link.astro +68 -0
  33. package/src/components/Lockup.astro +116 -0
  34. package/src/components/Media.astro +98 -0
  35. package/src/components/MediaPlayer.astro +102 -0
  36. package/src/components/RenderDocument.astro +64 -0
  37. package/src/components/RichText.astro +51 -0
  38. package/src/components/Text.astro +58 -0
  39. package/src/components/attributes.ts +33 -0
  40. package/src/components/descriptor.ts +45 -0
  41. package/src/components/form-client.ts +179 -0
  42. package/src/components/media-player.ts +46 -0
  43. package/src/components/media-props.ts +35 -0
  44. package/src/components/sitemap.xml.ts +35 -0
  45. package/src/config.ts +794 -0
  46. package/src/content-schema.ts +163 -0
  47. package/src/content.ts +396 -0
  48. package/src/document.ts +566 -0
  49. package/src/editor/README.md +405 -0
  50. package/src/editor/addresses.ts +61 -0
  51. package/src/editor/brand.ts +118 -0
  52. package/src/editor/client/block-list/BlockList.tsx +342 -0
  53. package/src/editor/client/block-list/Picker.tsx +105 -0
  54. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  55. package/src/editor/client/block-list/insert-points.ts +56 -0
  56. package/src/editor/client/block-list/link.ts +71 -0
  57. package/src/editor/client/block-list/reorder.ts +105 -0
  58. package/src/editor/client/block-list/summary.ts +112 -0
  59. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  60. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  61. package/src/editor/client/collections/table.ts +270 -0
  62. package/src/editor/client/context.tsx +27 -0
  63. package/src/editor/client/documents/DocMenu.tsx +154 -0
  64. package/src/editor/client/documents/actions.ts +192 -0
  65. package/src/editor/client/documents/dialogs.tsx +687 -0
  66. package/src/editor/client/editor.ts +995 -0
  67. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  68. package/src/editor/client/fields/DateField.tsx +200 -0
  69. package/src/editor/client/fields/FieldControl.tsx +61 -0
  70. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  71. package/src/editor/client/fields/GroupField.tsx +113 -0
  72. package/src/editor/client/fields/LinkField.tsx +302 -0
  73. package/src/editor/client/fields/ListField.tsx +642 -0
  74. package/src/editor/client/fields/LockupField.tsx +365 -0
  75. package/src/editor/client/fields/MediaField.tsx +823 -0
  76. package/src/editor/client/fields/PageFields.tsx +82 -0
  77. package/src/editor/client/fields/ReferenceField.tsx +203 -0
  78. package/src/editor/client/fields/RichTextField.tsx +300 -0
  79. package/src/editor/client/fields/SelectField.tsx +63 -0
  80. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  81. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  82. package/src/editor/client/fields/TextField.tsx +51 -0
  83. package/src/editor/client/fields/date.ts +73 -0
  84. package/src/editor/client/fields/host.tsx +167 -0
  85. package/src/editor/client/fields/list-summary.ts +85 -0
  86. package/src/editor/client/fields/lockup-rules.ts +181 -0
  87. package/src/editor/client/fields/parts.tsx +457 -0
  88. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  89. package/src/editor/client/fields/rich-text-doc.ts +130 -0
  90. package/src/editor/client/fields/rich-text-schema.ts +185 -0
  91. package/src/editor/client/fields/stale.ts +58 -0
  92. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  93. package/src/editor/client/inbox/store.ts +311 -0
  94. package/src/editor/client/live/format.ts +77 -0
  95. package/src/editor/client/live/marks.tsx +90 -0
  96. package/src/editor/client/live/notices.tsx +213 -0
  97. package/src/editor/client/live/publish.tsx +617 -0
  98. package/src/editor/client/live/session.tsx +162 -0
  99. package/src/editor/client/main.tsx +12 -0
  100. package/src/editor/client/media/LibraryStage.tsx +75 -0
  101. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  102. package/src/editor/client/media/store.ts +618 -0
  103. package/src/editor/client/nav/DocRow.tsx +116 -0
  104. package/src/editor/client/nav/search.ts +71 -0
  105. package/src/editor/client/nav/tree.ts +93 -0
  106. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  107. package/src/editor/client/overlays/editor-marks.ts +159 -0
  108. package/src/editor/client/overlays/index.ts +50 -0
  109. package/src/editor/client/overlays/layer.ts +437 -0
  110. package/src/editor/client/overlays/overlay-css.ts +177 -0
  111. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  112. package/src/editor/client/pane/Pane.tsx +91 -0
  113. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  114. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  115. package/src/editor/client/pane/levels/DocumentLevel.tsx +90 -0
  116. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  117. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  118. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  119. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  120. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  121. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  122. package/src/editor/client/pane/parts.tsx +179 -0
  123. package/src/editor/client/pane/switcher.tsx +165 -0
  124. package/src/editor/client/preview/driver.ts +640 -0
  125. package/src/editor/client/preview/field-text.ts +33 -0
  126. package/src/editor/client/preview/follow-links.ts +234 -0
  127. package/src/editor/client/preview/layout.ts +37 -0
  128. package/src/editor/client/preview/link-target.ts +50 -0
  129. package/src/editor/client/preview/markers.ts +86 -0
  130. package/src/editor/client/preview/page-edit.ts +632 -0
  131. package/src/editor/client/preview/page-editor-registry.ts +62 -0
  132. package/src/editor/client/preview/page-editor.ts +235 -0
  133. package/src/editor/client/preview/render-sequencer.ts +113 -0
  134. package/src/editor/client/preview/selection.ts +116 -0
  135. package/src/editor/client/preview/warnings.ts +61 -0
  136. package/src/editor/client/shell/App.tsx +101 -0
  137. package/src/editor/client/shell/Bar.tsx +197 -0
  138. package/src/editor/client/shell/Brand.tsx +33 -0
  139. package/src/editor/client/shell/Icons.tsx +87 -0
  140. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  141. package/src/editor/client/shell/Popovers.tsx +47 -0
  142. package/src/editor/client/shell/Stage.tsx +132 -0
  143. package/src/editor/client/shell/Toast.tsx +61 -0
  144. package/src/editor/client/state/document-store.ts +447 -0
  145. package/src/editor/client/state/keeper.ts +713 -0
  146. package/src/editor/client/state/live.ts +257 -0
  147. package/src/editor/client/state/schema.ts +42 -0
  148. package/src/editor/client/state/store.ts +39 -0
  149. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  150. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  151. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  152. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  153. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  154. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  155. package/src/editor/middleware.ts +62 -0
  156. package/src/editor/routes/_live.ts +26 -0
  157. package/src/editor/routes/admin.astro +80 -0
  158. package/src/editor/routes/create.ts +19 -0
  159. package/src/editor/routes/delete.ts +19 -0
  160. package/src/editor/routes/document.ts +39 -0
  161. package/src/editor/routes/documents.ts +30 -0
  162. package/src/editor/routes/github.ts +23 -0
  163. package/src/editor/routes/history.ts +9 -0
  164. package/src/editor/routes/lock.ts +9 -0
  165. package/src/editor/routes/login/assets.d.ts +10 -0
  166. package/src/editor/routes/login/login.ts +43 -0
  167. package/src/editor/routes/media.ts +16 -0
  168. package/src/editor/routes/move.ts +17 -0
  169. package/src/editor/routes/notice.ts +9 -0
  170. package/src/editor/routes/preview.astro +77 -0
  171. package/src/editor/routes/publish.ts +9 -0
  172. package/src/editor/routes/status.ts +9 -0
  173. package/src/editor/routes/submissions.ts +16 -0
  174. package/src/editor/routes/try-again.ts +9 -0
  175. package/src/editor/routes/write.ts +18 -0
  176. package/src/editor/server/admin-props.ts +151 -0
  177. package/src/editor/server/cards.ts +61 -0
  178. package/src/editor/server/create.ts +414 -0
  179. package/src/editor/server/drafts-store.ts +127 -0
  180. package/src/editor/server/media/alt.ts +107 -0
  181. package/src/editor/server/media/bucket.ts +295 -0
  182. package/src/editor/server/media/config.ts +78 -0
  183. package/src/editor/server/media/handler.ts +221 -0
  184. package/src/editor/server/media/library.ts +254 -0
  185. package/src/editor/server/media/memory.ts +184 -0
  186. package/src/editor/server/media/mux.ts +241 -0
  187. package/src/editor/server/media/sniff.ts +599 -0
  188. package/src/editor/server/media/upload.ts +175 -0
  189. package/src/editor/server/mode.ts +63 -0
  190. package/src/editor/server/move.ts +295 -0
  191. package/src/editor/server/render.tsx +9 -0
  192. package/src/editor/server/replace.ts +72 -0
  193. package/src/editor/server/settings.ts +35 -0
  194. package/src/editor/server/write.ts +366 -0
  195. package/src/editor/styles/editor.css +500 -0
  196. package/src/editor/styles/fonts.css +40 -0
  197. package/src/editor/styles/levels/README.md +4 -0
  198. package/src/editor/styles/levels/collections.css +99 -0
  199. package/src/editor/styles/levels/document-level.css +10 -0
  200. package/src/editor/styles/levels/documents.css +9 -0
  201. package/src/editor/styles/levels/fields.css +171 -0
  202. package/src/editor/styles/levels/files.css +95 -0
  203. package/src/editor/styles/levels/live.css +10 -0
  204. package/src/editor/styles/levels/m5a-documents.css +51 -0
  205. package/src/editor/styles/levels/m6.css +82 -0
  206. package/src/editor/styles/levels/media.css +175 -0
  207. package/src/editor/styles/levels/switcher.css +3 -0
  208. package/src/editor/styles/tokens.css +114 -0
  209. package/src/editor/tsconfig.json +16 -0
  210. package/src/editor/types.ts +879 -0
  211. package/src/editor/ui-state.ts +113 -0
  212. package/src/email.ts +67 -0
  213. package/src/fields.ts +1081 -0
  214. package/src/files-route.ts +201 -0
  215. package/src/files.ts +116 -0
  216. package/src/forms/email.ts +149 -0
  217. package/src/forms/endpoint.ts +347 -0
  218. package/src/forms/inbox.ts +273 -0
  219. package/src/forms/rate-limit.ts +130 -0
  220. package/src/forms/route.ts +24 -0
  221. package/src/forms/rules.ts +270 -0
  222. package/src/forms/store.ts +486 -0
  223. package/src/head.ts +121 -0
  224. package/src/index.ts +434 -0
  225. package/src/json-source.ts +116 -0
  226. package/src/links.ts +86 -0
  227. package/src/live/README.md +42 -0
  228. package/src/live/auth/allow-rules.ts +70 -0
  229. package/src/live/auth/allow.ts +128 -0
  230. package/src/live/auth/auth.ts +155 -0
  231. package/src/live/auth/email.ts +84 -0
  232. package/src/live/auth/guard.ts +111 -0
  233. package/src/live/auth/index.ts +68 -0
  234. package/src/live/auth/links.ts +101 -0
  235. package/src/live/auth/login.ts +245 -0
  236. package/src/live/auth/middleware.ts +53 -0
  237. package/src/live/auth/request.ts +56 -0
  238. package/src/live/auth/routes.ts +9 -0
  239. package/src/live/auth/session.ts +181 -0
  240. package/src/live/auth/views.ts +355 -0
  241. package/src/live/autosaves.ts +306 -0
  242. package/src/live/backend.ts +634 -0
  243. package/src/live/drafts-store.ts +591 -0
  244. package/src/live/editor.ts +465 -0
  245. package/src/live/git.ts +1021 -0
  246. package/src/live/github.ts +271 -0
  247. package/src/live/locals.ts +24 -0
  248. package/src/live/locks.ts +273 -0
  249. package/src/live/merge.ts +197 -0
  250. package/src/live/messages.ts +151 -0
  251. package/src/live/mode.ts +164 -0
  252. package/src/live/redis.ts +533 -0
  253. package/src/live/runtime.ts +161 -0
  254. package/src/live/status.ts +109 -0
  255. package/src/live/sweep.ts +228 -0
  256. package/src/live/timings.ts +47 -0
  257. package/src/live/webhook.ts +39 -0
  258. package/src/lockup-css.ts +58 -0
  259. package/src/measure.ts +145 -0
  260. package/src/media-value.ts +239 -0
  261. package/src/media.ts +247 -0
  262. package/src/read.ts +255 -0
  263. package/src/redirect-middleware.ts +49 -0
  264. package/src/redirects.ts +76 -0
  265. package/src/relink.ts +293 -0
  266. package/src/reserved.ts +24 -0
  267. package/src/rich-text.ts +665 -0
  268. package/src/schema-key.ts +63 -0
  269. package/src/seo.ts +124 -0
  270. package/src/serialize.ts +688 -0
  271. package/src/skipped.ts +35 -0
  272. package/src/slug.ts +121 -0
  273. package/src/text-styles.ts +62 -0
  274. package/src/virtual.d.ts +28 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Caracode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
1
+ # caracms
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A visual CMS for Astro sites, with the content in git. Editors change a site in its own
4
+ pages, at `/admin`; what they publish is JSON files in the site's repository, which the
5
+ site builds from.
6
+
7
+ **caracms is unsupported.** It's published so that the sites built on it can be installed
8
+ and run by whoever looks after them. There's no issue tracker and no support: use it as it
9
+ is, under the MIT license.
10
+
11
+ ## Start
12
+
13
+ caracms goes into an existing Astro site (Astro 7, on Node 22.12 or later). From the
14
+ site's root, in a clean git checkout, install it and commit the install:
15
+
16
+ ```sh
17
+ npm install caracms --save-exact
18
+ git add package.json package-lock.json
19
+ git commit -m "Add caracms"
20
+ ```
21
+
22
+ That commit matters: installing changes `package.json` and the lockfile, and
23
+ `init --write` refuses while tracked files have uncommitted changes, so its own change is
24
+ one diff to review. Then look at what `init` would do:
25
+
26
+ ```sh
27
+ npx caracms init
28
+ ```
29
+
30
+ It prints every file it would add and every edit it would make, as a diff, and changes
31
+ nothing. When it looks right, apply it and install what it added:
32
+
33
+ ```sh
34
+ npx caracms init --write
35
+ npm install
36
+ ```
37
+
38
+ `init` adds the plumbing (the Node adapter, the integration, a minimal `caracms.config.ts`
39
+ and page component, the route that serves documents, the schemas) and links caracms's
40
+ skills into `.claude/skills/`. It doesn't touch the site's pages. Bringing them in is the
41
+ job of the `caracms-adopt` skill, one page at a time.
42
+
43
+ ## Commands
44
+
45
+ Run from the site's root:
46
+
47
+ - `caracms init [--write]`: add caracms to an Astro site, or bring a set-up site's skills,
48
+ schemas and CLAUDE.md block up to date.
49
+ - `caracms validate [--fix] [--json]`: check the content against `caracms.config.ts`.
50
+ Exits 1 when there are errors. `--fix` runs `caracms schemas` first, then lays each
51
+ content file out as the editor saves it (two-space JSON; only whitespace changes).
52
+ - `caracms schemas`: write `schemas/` from the config, and give each content file its
53
+ `$schema`.
54
+ - `caracms media import <files…>`: put local images into the site's media bucket.
55
+ - `caracms start`: serve the built site.
56
+
57
+ The skills (`caracms-content`, `caracms-blocks` and `caracms-adopt`, in `skills/`) say how
58
+ the rest works, matching the installed version.
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ // The `caracms` command (M8 → Contracts): `init`, `schemas`, `validate`, `media import`
3
+ // and `start`, each run from the site's root. Exit codes: 0 fine, 1 problems found or
4
+ // refused, 2 bad usage.
5
+ //
6
+ // Plain JavaScript importing only Node built-ins, so it runs on Node 22.12 with no flags.
7
+ // `schemas` and `validate` need the site's caracms.config.ts, which is TypeScript: they
8
+ // load the package's src/cli/*.ts through the site's own Vite (site-runner.mjs).
9
+ //
10
+ // `caracms start`: the site's production server (M6 Decision 2).
11
+ //
12
+ // The Node adapter's standalone server sends prerendered pages and static files
13
+ // before Astro's middleware runs, so a password or a `noindex` header can't be added
14
+ // there. This serves the adapter's own handler (static files, then Astro) behind the
15
+ // gate (gate.mjs): robots.txt, the pre-launch password, then `X-Robots-Tag`.
16
+ //
17
+ // It runs from the site's root (the directory with `dist/` and `content/`) and reads,
18
+ // once at start:
19
+ // PORT (default 8080), HOST (default 0.0.0.0), SITE_URL, CARACMS_PRELAUNCH
20
+ // (`user:password`), and content/site-settings.json → brand.name.
21
+
22
+ import { existsSync, readFileSync } from 'node:fs';
23
+ import { createServer } from 'node:http';
24
+ import { join } from 'node:path';
25
+ import { pathToFileURL } from 'node:url';
26
+ import { createGate, parsePrelaunch, parseSiteUrl } from './gate.mjs';
27
+
28
+ const USAGE = `Usage: caracms <command>, from the site's root
29
+
30
+ init [--write] Add caracms to an existing Astro site. Shows the change; --write applies it.
31
+ schemas Write schemas/ from caracms.config.ts, and add $schema to content files.
32
+ validate [--fix] [--json] Check content against caracms.config.ts. --fix runs \`schemas\` first,
33
+ then lays content files out as the editor saves them;
34
+ --json prints { errors, warnings }.
35
+ media import <files…> Put local images into the site's media bucket; prints JSON.
36
+ start Serve the built site (dist/server/entry.mjs).
37
+ `;
38
+
39
+ /** The command running, for `fatal`'s sentence: set by `main`. */
40
+ let running = '';
41
+
42
+ const fatal = (message) => {
43
+ console.error(`caracms${running ? ` ${running}` : ''}: ${message}`);
44
+ process.exit(1);
45
+ };
46
+
47
+ function readBrandName(root) {
48
+ try {
49
+ const settings = JSON.parse(readFileSync(join(root, 'content/site-settings.json'), 'utf8'));
50
+ const name = settings?.brand?.name;
51
+ return typeof name === 'string' ? name : undefined;
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ async function start() {
58
+ const root = process.cwd();
59
+ const entry = join(root, 'dist/server/entry.mjs');
60
+ if (!existsSync(entry)) fatal(`there's no ${entry}. Run \`astro build\` in the site's root first.`);
61
+
62
+ const portText = process.env.PORT ?? '';
63
+ const port = portText === '' ? 8080 : Number(portText);
64
+ if (!Number.isInteger(port) || port < 0 || port > 65535) fatal(`PORT must be a port number, not "${portText}".`);
65
+ const host = process.env.HOST || '0.0.0.0';
66
+
67
+ // A built site is a production server: the submissions store and the email sender read NODE_ENV (M6 Decision 7),
68
+ // and this command is the one thing that starts the built site. An explicit NODE_ENV is left as it is.
69
+ process.env.NODE_ENV ??= 'production';
70
+ // The adapter starts its own server on import unless told not to.
71
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
72
+ const built = await import(pathToFileURL(entry).href);
73
+ if (typeof built.handler !== 'function') fatal(`${entry} exports no handler. Is the site built with @astrojs/node?`);
74
+ if (built.options && built.options.mode !== 'standalone') fatal(`the site's Node adapter must be in standalone mode, not "${built.options.mode}".`);
75
+
76
+ const siteUrl = process.env.SITE_URL;
77
+ const prelaunch = process.env.CARACMS_PRELAUNCH;
78
+ const gate = createGate({ handler: built.handler, siteUrl, prelaunch, brandName: readBrandName(root) });
79
+
80
+ const server = createServer(gate);
81
+ server.on('error', (err) => fatal(err.message));
82
+ server.listen(port, host, () => {
83
+ const address = server.address();
84
+ const shown = typeof address === 'object' && address ? `${address.address.includes(':') ? `[${address.address}]` : address.address}:${address.port}` : `${host}:${port}`;
85
+ const site = parseSiteUrl(siteUrl);
86
+ const indexing = site?.indexable ? `indexable on ${site.host} only` : 'never indexable (SITE_URL is unset, or not a custom domain)';
87
+ const password = parsePrelaunch(prelaunch) ? 'on (CARACMS_PRELAUNCH)' : 'off';
88
+ console.log(`caracms start: listening on http://${shown}\n indexing: ${indexing}\n pre-launch password: ${password}`);
89
+ });
90
+
91
+ const stop = () => {
92
+ server.close(() => process.exit(0));
93
+ server.closeIdleConnections?.();
94
+ setTimeout(() => process.exit(0), 10_000).unref();
95
+ };
96
+ process.once('SIGTERM', stop);
97
+ process.once('SIGINT', stop);
98
+ }
99
+
100
+ /** Bad usage: the sentence and the usage, exit 2. */
101
+ function usage(message) {
102
+ process.stderr.write(`caracms: ${message}\n\n${USAGE}`);
103
+ process.exitCode = 2;
104
+ }
105
+
106
+ /** Load the package's src/cli/<name>.ts through the site's Vite and run its `command`. */
107
+ async function viaSite(name, ...args) {
108
+ const { withPackageModule } = await import('./site-runner.mjs');
109
+ const root = process.cwd();
110
+ try {
111
+ const { code, out, err } = await withPackageModule(root, name, (m) => m.command(root, ...args));
112
+ if (out) process.stdout.write(out);
113
+ if (err) process.stderr.write(err);
114
+ process.exitCode = code;
115
+ } catch (error) {
116
+ // A CommandError (src/cli/site.ts) or the runner's own: one sentence, no stack.
117
+ process.stderr.write(`caracms ${name}: ${error?.message ?? String(error)}\n`);
118
+ process.exitCode = 1;
119
+ }
120
+ }
121
+
122
+ /** Flags out of `args`: every one must be in `allowed`; no other arguments. */
123
+ function flags(command, args, allowed) {
124
+ const set = new Set();
125
+ for (const a of args) {
126
+ if (!allowed.includes(a)) return { error: `"${a}" isn't an option of ${command}.` };
127
+ set.add(a);
128
+ }
129
+ return { set };
130
+ }
131
+
132
+ async function main(argv) {
133
+ const [command, ...rest] = argv;
134
+ running = command === 'media' && rest[0] && !rest[0].startsWith('-') ? `media ${rest[0]}` : (command ?? '');
135
+ switch (command) {
136
+ case 'start':
137
+ if (rest.length) return usage(`start takes no arguments.`);
138
+ return start();
139
+ case 'init': {
140
+ const f = flags('init', rest, ['--write']);
141
+ if (f.error) return usage(f.error);
142
+ const { run } = await import('./init.mjs');
143
+ process.exitCode = await run({ root: process.cwd(), write: f.set.has('--write') });
144
+ return;
145
+ }
146
+ case 'schemas':
147
+ if (rest.length) return usage('schemas takes no arguments.');
148
+ return viaSite('schemas');
149
+ case 'validate': {
150
+ const f = flags('validate', rest, ['--fix', '--json']);
151
+ if (f.error) return usage(f.error);
152
+ return viaSite('validate', { fix: f.set.has('--fix'), json: f.set.has('--json') });
153
+ }
154
+ case 'media': {
155
+ // The media commands are their own module (bin/media.mjs): it takes the arguments after `media`.
156
+ const { run } = await import('./media.mjs');
157
+ process.exitCode = await run(rest);
158
+ return;
159
+ }
160
+ case undefined:
161
+ process.stdout.write(USAGE);
162
+ process.exitCode = 2;
163
+ return;
164
+ case '--help':
165
+ case '-h':
166
+ case 'help':
167
+ process.stdout.write(USAGE);
168
+ return;
169
+ default:
170
+ return usage(`unknown command "${argv.join(' ')}".`);
171
+ }
172
+ }
173
+
174
+ main(process.argv.slice(2)).catch((err) => fatal(err?.stack ?? String(err)));
package/bin/gate.mjs ADDED
@@ -0,0 +1,275 @@
1
+ // The gate in front of the Node adapter's handler (M6 Decisions 2, 10, 11).
2
+ //
3
+ // Plain JavaScript with Node built-ins only: `caracms start` must run on Node 22.12
4
+ // with no flags, so nothing here may import the package's TypeScript.
5
+ //
6
+ // Its order, per request:
7
+ // 1. `/robots.txt` is answered here, before the password.
8
+ // 2. The pre-launch password (`CARACMS_PRELAUNCH`), except for the editor and its
9
+ // API, `/_astro/…` and `/health`.
10
+ // 3. `X-Robots-Tag` on the way out, kept whatever the adapter writes.
11
+ // 4. The adapter's own handler (static files first, then Astro).
12
+
13
+ import { createHash, timingSafeEqual } from 'node:crypto';
14
+
15
+ /** The header the gate owns. */
16
+ const ROBOTS_HEADER = 'X-Robots-Tag';
17
+ const NOINDEX = 'noindex';
18
+ const NOINDEX_NOFOLLOW = 'noindex, nofollow';
19
+
20
+ /** Used when Site Settings has no brand name, or none that survives cleaning. */
21
+ export const NEUTRAL_REALM_NAME = 'This site';
22
+
23
+ // ── Paths ───────────────────────────────────────────────────────────────────────
24
+
25
+ /** The path part of a request target, or null for anything but origin-form (`/…`). */
26
+ function rawPathOf(url) {
27
+ if (typeof url !== 'string' || !url.startsWith('/')) return null;
28
+ const end = url.search(/[?#]/);
29
+ return end === -1 ? url : url.slice(0, end);
30
+ }
31
+
32
+ const safeDecode = (s) => {
33
+ try {
34
+ return decodeURIComponent(s);
35
+ } catch {
36
+ return null;
37
+ }
38
+ };
39
+
40
+ /** Resolves `.` and `..` segments on an absolute path, never above the root. */
41
+ function resolveDots(path) {
42
+ const out = [];
43
+ const parts = path.split('/');
44
+ for (let i = 1; i < parts.length; i++) {
45
+ const part = parts[i];
46
+ if (part === '.') {
47
+ if (i === parts.length - 1) out.push('');
48
+ } else if (part === '..') {
49
+ out.pop();
50
+ if (i === parts.length - 1) out.push('');
51
+ } else out.push(part);
52
+ }
53
+ return '/' + out.join('/');
54
+ }
55
+
56
+ /**
57
+ * Two views of a path, and the gate only exempts what both agree on:
58
+ * - `app`: what Astro's router and the guard see. The adapter builds the request's
59
+ * URL from the raw target (WHATWG resolves dot segments, `%2e` ones too), then the
60
+ * guard decodes, turns backslashes into slashes and collapses repeated slashes.
61
+ * - `full`: decoded first, then slashes collapsed and dot segments resolved, so
62
+ * `/_astro/..%2Fcontact` is `/contact`.
63
+ * Either is null when the path won't decode (or isn't a path at all).
64
+ */
65
+ export function pathViews(url) {
66
+ const raw = rawPathOf(url);
67
+ if (raw === null) return { app: null, full: null };
68
+ let app = null;
69
+ try {
70
+ const decoded = safeDecode(new URL('http://gate.invalid' + raw).pathname);
71
+ if (decoded !== null) app = decoded.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
72
+ } catch {}
73
+ const decoded = safeDecode(raw);
74
+ const full = decoded === null ? null : resolveDots(decoded.replace(/\\/g, '/').replace(/\/{2,}/g, '/'));
75
+ return { app, full };
76
+ }
77
+
78
+ /** The guard's prefix rule (`live/auth/guard.ts` → isGuardedPath) on a normalised path. */
79
+ function isGuardedView(view) {
80
+ const path = view.toLowerCase();
81
+ return path === '/admin' || path.startsWith('/admin/') || path.startsWith('/admin.') || path === '/_caracms' || path.startsWith('/_caracms/');
82
+ }
83
+
84
+ /** The editor and its API: a path that won't decode counts, as in the guard. */
85
+ export function isGuarded(views) {
86
+ if (views.app === null || views.full === null) return true;
87
+ return isGuardedView(views.app) || isGuardedView(views.full);
88
+ }
89
+
90
+ function isExemptView(view) {
91
+ return isGuardedView(view) || view.startsWith('/_astro/') || view === '/health';
92
+ }
93
+
94
+ /** Reached without the site's password: only when both views agree. */
95
+ export function isPasswordExempt(views) {
96
+ if (views.app === null || views.full === null) return false;
97
+ return isExemptView(views.app) && isExemptView(views.full);
98
+ }
99
+
100
+ const isRobotsTxt = (views) => views.full === '/robots.txt';
101
+
102
+ // ── Settings, read once at start ────────────────────────────────────────────────
103
+
104
+ /** Strips what can't go in a quoted header value: quotes, backslashes, controls, non-ASCII. */
105
+ export function cleanRealmName(name) {
106
+ if (typeof name !== 'string') return '';
107
+ return name
108
+ .replace(/[^\x20-\x7e]/g, '')
109
+ .replace(/["\\]/g, '')
110
+ .replace(/ {2,}/g, ' ')
111
+ .trim();
112
+ }
113
+
114
+ /** The pre-launch value with surrounding whitespace and newlines dropped (as `SITE_URL` is trimmed), or '' when it isn't a string. */
115
+ const trimPrelaunch = (value) => (typeof value === 'string' ? value.trim() : '');
116
+
117
+ /** `user:password`, split at the first colon, after trimming the whole value. Null (the site is public) unless both are there. */
118
+ export function parsePrelaunch(raw) {
119
+ const value = trimPrelaunch(raw);
120
+ if (value === '') return null;
121
+ const colon = value.indexOf(':');
122
+ if (colon <= 0 || colon === value.length - 1) return null;
123
+ return { user: value.slice(0, colon) };
124
+ }
125
+
126
+ /** `SITE_URL`'s host and origin, or null when it's unset or not an http(s) URL. */
127
+ export function parseSiteUrl(value) {
128
+ if (typeof value !== 'string' || value.trim() === '') return null;
129
+ let url;
130
+ try {
131
+ url = new URL(value.trim());
132
+ } catch {
133
+ return null;
134
+ }
135
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
136
+ const hostname = url.hostname.toLowerCase();
137
+ const indexable = hostname !== 'localhost' && !hostname.endsWith('.localhost') && !hostname.endsWith('.up.railway.app');
138
+ return { host: url.host.toLowerCase(), origin: url.origin, indexable };
139
+ }
140
+
141
+ const sha256 = (buf) => createHash('sha256').update(buf).digest();
142
+
143
+ /**
144
+ * The gate, as `(req, res) => void` around the adapter's `handler`.
145
+ * @param {{ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => unknown,
146
+ * siteUrl?: string, prelaunch?: string, brandName?: string }} options
147
+ */
148
+ export function createGate({ handler, siteUrl, prelaunch, brandName }) {
149
+ const site = parseSiteUrl(siteUrl);
150
+ const password = parsePrelaunch(prelaunch) ? sha256(Buffer.from(trimPrelaunch(prelaunch), 'utf8')) : null;
151
+ const realm = `${cleanRealmName(brandName) || NEUTRAL_REALM_NAME} (not launched yet)`;
152
+
153
+ const robotsIndexable = site ? `User-agent: *\nDisallow: /admin\nDisallow: /_caracms/\nSitemap: ${site.origin}/sitemap.xml\n` : '';
154
+ const robotsClosed = 'User-agent: *\nDisallow: /\n';
155
+
156
+ const isIndexableHost = (req) => {
157
+ if (!site || !site.indexable) return false;
158
+ const host = req.headers.host;
159
+ return typeof host === 'string' && host.toLowerCase() === site.host;
160
+ };
161
+
162
+ const hasPassword = (req) => {
163
+ const header = req.headers.authorization;
164
+ if (typeof header !== 'string') return false;
165
+ const match = /^\s*basic\s+(\S+)\s*$/i.exec(header);
166
+ if (!match) return false;
167
+ // Hashing both sides first gives equal lengths, so the comparison is constant-time
168
+ // whatever was sent.
169
+ return timingSafeEqual(sha256(Buffer.from(match[1], 'base64')), password);
170
+ };
171
+
172
+ const gate = (req, res) => {
173
+ const views = pathViews(req.url);
174
+ const indexable = isIndexableHost(req);
175
+
176
+ // 1. robots.txt, before the password.
177
+ if (isRobotsTxt(views)) {
178
+ const headers = { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=300' };
179
+ if (!indexable) headers[ROBOTS_HEADER] = NOINDEX;
180
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
181
+ res.writeHead(405, { ...headers, Allow: 'GET, HEAD' });
182
+ res.end();
183
+ return;
184
+ }
185
+ const body = indexable ? robotsIndexable : robotsClosed;
186
+ res.writeHead(200, { ...headers, 'Content-Length': Buffer.byteLength(body) });
187
+ res.end(req.method === 'HEAD' ? undefined : body);
188
+ return;
189
+ }
190
+
191
+ const guarded = isGuarded(views);
192
+
193
+ // 2. The password.
194
+ if (password && !isPasswordExempt(views) && !hasPassword(req)) {
195
+ const body = "This site isn't launched yet.\n";
196
+ res.writeHead(401, {
197
+ 'WWW-Authenticate': `Basic realm="${realm}"`,
198
+ [ROBOTS_HEADER]: guarded ? NOINDEX_NOFOLLOW : NOINDEX,
199
+ 'Content-Type': 'text/plain; charset=utf-8',
200
+ 'Content-Length': Buffer.byteLength(body),
201
+ 'Cache-Control': 'no-store',
202
+ });
203
+ res.end(req.method === 'HEAD' ? undefined : body);
204
+ return;
205
+ }
206
+
207
+ // 3. The header, kept on whatever the adapter writes.
208
+ const robots = guarded ? NOINDEX_NOFOLLOW : indexable ? null : NOINDEX;
209
+ if (robots) holdHeader(res, ROBOTS_HEADER, robots);
210
+
211
+ // 4. The adapter.
212
+ try {
213
+ const result = handler(req, res);
214
+ if (result && typeof result.catch === 'function') result.catch((err) => fail(res, err));
215
+ } catch (err) {
216
+ fail(res, err);
217
+ }
218
+ };
219
+ return gate;
220
+ }
221
+
222
+ function fail(res, err) {
223
+ console.error('caracms start: the request failed:', err);
224
+ if (!res.headersSent) {
225
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
226
+ res.end('Internal server error');
227
+ } else res.destroy();
228
+ }
229
+
230
+ /**
231
+ * Sets a header and keeps it: whatever the handler sets, appends, removes or passes
232
+ * to `writeHead` under that name is replaced by this value.
233
+ */
234
+ function holdHeader(res, name, value) {
235
+ const lower = name.toLowerCase();
236
+ const isIt = (key) => typeof key === 'string' && key.toLowerCase() === lower;
237
+ const setHeader = res.setHeader.bind(res);
238
+ setHeader(name, value);
239
+
240
+ res.setHeader = (key, v) => (isIt(key) ? setHeader(name, value) : setHeader(key, v));
241
+ if (typeof res.appendHeader === 'function') {
242
+ const appendHeader = res.appendHeader.bind(res);
243
+ res.appendHeader = (key, v) => (isIt(key) ? setHeader(name, value) : appendHeader(key, v));
244
+ }
245
+ const removeHeader = res.removeHeader.bind(res);
246
+ res.removeHeader = (key) => (isIt(key) ? undefined : removeHeader(key));
247
+
248
+ const writeHead = res.writeHead.bind(res);
249
+ res.writeHead = (status, ...rest) => {
250
+ const i = rest.findIndex((a) => a !== undefined && typeof a !== 'string');
251
+ if (i !== -1) rest[i] = withHeader(rest[i], name, value, isIt);
252
+ if (!res.headersSent) setHeader(name, value);
253
+ return writeHead(status, ...rest);
254
+ };
255
+ }
256
+
257
+ /** A `writeHead` headers argument (object, `[k, v]` pairs or a flat list) with the held value. */
258
+ function withHeader(headers, name, value, isIt) {
259
+ if (Array.isArray(headers)) {
260
+ if (headers.length > 0 && Array.isArray(headers[0])) {
261
+ return [...headers.filter(([k]) => !isIt(k)), [name, value]];
262
+ }
263
+ const out = [];
264
+ for (let i = 0; i < headers.length; i += 2) if (!isIt(headers[i])) out.push(headers[i], headers[i + 1]);
265
+ out.push(name, value);
266
+ return out;
267
+ }
268
+ if (headers && typeof headers === 'object') {
269
+ const out = {};
270
+ for (const key of Object.keys(headers)) if (!isIt(key)) out[key] = headers[key];
271
+ out[name] = value;
272
+ return out;
273
+ }
274
+ return headers;
275
+ }