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.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- 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
|
-
#
|
|
1
|
+
# caracms
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/bin/caracms.mjs
ADDED
|
@@ -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
|
+
}
|