caracms 0.3.3 → 0.4.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/README.md +59 -3
- package/bin/caracms.mjs +30 -6
- package/bin/gate.mjs +157 -6
- package/bin/templates/catch-all.astro.txt +10 -9
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +21 -4
- package/skills/caracms-blocks/SKILL.md +36 -13
- package/skills/caracms-content/SKILL.md +27 -6
- package/skills/caracms-tina/SKILL.md +53 -11
- package/skills/caracms-tina/field-kinds.md +2 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
- package/src/cli/validate.ts +51 -10
- package/src/code-routes.ts +26 -0
- package/src/components/Head.astro +5 -5
- package/src/components/RenderDocument.astro +3 -3
- package/src/components/sitemap.xml.ts +5 -4
- package/src/config.ts +57 -11
- package/src/content-schema.ts +5 -2
- package/src/content.ts +148 -8
- package/src/document.ts +8 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +61 -16
- package/src/editor/client/documents/dialogs.tsx +104 -15
- package/src/editor/client/editor.ts +22 -1
- package/src/editor/client/fields/HiddenToggle.tsx +18 -2
- package/src/editor/client/nav/DocRow.tsx +8 -1
- package/src/editor/client/nav/tree.ts +26 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +19 -1
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +24 -9
- package/src/editor/server/move.ts +266 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/collections.css +2 -0
- package/src/editor/styles/levels/m5a-documents.css +15 -0
- package/src/editor/types.ts +31 -5
- package/src/held.ts +116 -0
- package/src/index.ts +4 -2
- package/src/live/README.md +3 -2
- package/src/live/auth/middleware.ts +11 -0
- package/src/live/autosaves.ts +88 -1
- package/src/live/cloud.ts +131 -0
- package/src/live/drafts-store.ts +122 -87
- package/src/live/editor.ts +22 -4
- package/src/live/git.ts +63 -12
- package/src/live/github.ts +69 -7
- package/src/live/mode.ts +42 -6
- package/src/live/runtime.ts +18 -2
- package/src/redirect-middleware.ts +3 -0
- package/src/redirects.ts +32 -1
- package/src/relink.ts +40 -9
- package/src/serialize.ts +1 -1
- package/src/slug.ts +15 -4
package/README.md
CHANGED
|
@@ -36,7 +36,8 @@ npm install
|
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
`init` adds the plumbing (the Node adapter, the integration, a minimal `caracms.config.ts`
|
|
39
|
-
and page component, the route that serves
|
|
39
|
+
and page component, the one catch-all route that serves every document with an address,
|
|
40
|
+
the schemas) and links caracms's
|
|
40
41
|
skills into `.claude/skills/`. It doesn't touch the site's pages. Bringing them in is the
|
|
41
42
|
job of the `caracms-adopt` skill, one page at a time.
|
|
42
43
|
|
|
@@ -58,6 +59,27 @@ Run from the site's root:
|
|
|
58
59
|
The skills (`caracms-content`, `caracms-blocks` and `caracms-adopt`, in `skills/`) say how
|
|
59
60
|
the rest works, matching the installed version.
|
|
60
61
|
|
|
62
|
+
## Collections and addresses
|
|
63
|
+
|
|
64
|
+
A collection that has pages of its own gets its address from exactly one of three places:
|
|
65
|
+
|
|
66
|
+
| Source | In the config | In the content | A document's address |
|
|
67
|
+
|---|---|---|---|
|
|
68
|
+
| **Pageless** | no `urlBase` | none | none: records other pages list (a team, awards, logos) |
|
|
69
|
+
| **A prefix in code** | `urlBase: '/tags/'` | none | `urlBase` + slug: `/tags/<slug>`, with no page at `/tags` |
|
|
70
|
+
| **Held by a page** | `heldBy: 'pages'` | the page's file says `"holds": "journal"` | the page's address + `/` + slug: posts at `/journal/<slug>`, and at `/blog/<slug>` once the page moves |
|
|
71
|
+
|
|
72
|
+
`heldBy` and `urlBase` are exclusive, and a held collection is flat. A page holds at most
|
|
73
|
+
one collection (never Home), and `caracms validate` reports a `holds` that doesn't match a
|
|
74
|
+
`heldBy`.
|
|
75
|
+
|
|
76
|
+
One catch-all route, `src/pages/[...path].astro`, serves all three. It lists what to build
|
|
77
|
+
with `addressedDocuments(site)` from `caracms/content` (`{ collection, path, url }` for
|
|
78
|
+
every public page, leaving out hidden ones and files that can't be opened), and a site that
|
|
79
|
+
renders on demand uses `documentAt(site, pathname)`. A collection needs no route of its
|
|
80
|
+
own; a prefix collection whose base keeps its own route file is left out of the reader,
|
|
81
|
+
and a route with parameters left at a held collection's address is a `validate` error.
|
|
82
|
+
|
|
61
83
|
## Hosting
|
|
62
84
|
|
|
63
85
|
caracms runs on any host that keeps one Node server running behind HTTPS: Railway,
|
|
@@ -90,8 +112,9 @@ These go on the site's server. `caracms init` prints the same list.
|
|
|
90
112
|
|
|
91
113
|
| Name | Needed | What it's for |
|
|
92
114
|
|---|---|---|
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
115
|
+
| `CARACMS_CLOUD_KEY` | instead of the GitHub variables | The site's key from CaraCMS Cloud (`cck_…`). It replaces the token or App and `GITHUB_REPO`: see [CaraCMS Cloud](#caracms-cloud). |
|
|
116
|
+
| `GITHUB_REPO` | required, but not with the cloud key | The site's repository, as owner/name. |
|
|
117
|
+
| `CARACMS_GITHUB_TOKEN` | required, or the App, or the cloud key | A fine-grained token (`github_pat_…`) on that one repo: Contents read and write, Metadata read (Deployments read is optional). Never `GITHUB_TOKEN`. |
|
|
95
118
|
| `GITHUB_APP_ID` | instead of the token | The GitHub App's ID, for anyone setting up many sites. Set the token or the App, not both. |
|
|
96
119
|
| `GITHUB_APP_PRIVATE_KEY` | with `GITHUB_APP_ID` | The App's private key. |
|
|
97
120
|
| `SITE_URL` | required | The site's public address, https://. The same value at build and at run time. |
|
|
@@ -275,6 +298,39 @@ site, signs every commit, and isn't tied to a person. Use it for anyone running
|
|
|
275
298
|
a handful of sites, or a repo that requires signed commits. Install it on the site's repo
|
|
276
299
|
only, with the same permissions as the token.
|
|
277
300
|
|
|
301
|
+
### CaraCMS Cloud
|
|
302
|
+
|
|
303
|
+
A site run by CaraCMS can take its GitHub access from CaraCMS Cloud instead of a token or
|
|
304
|
+
an App of its own. The cloud gives the site's server a short-lived token for the site's
|
|
305
|
+
repo, and only that repo, about once an hour.
|
|
306
|
+
|
|
307
|
+
1. CaraCMS sets the site's repo in CaraCMS Cloud.
|
|
308
|
+
2. Install the caracms GitHub App on that repo, choosing only that repo. The site's page in
|
|
309
|
+
app.caracms.com says whether it's installed, and links to the App's install page when
|
|
310
|
+
it isn't.
|
|
311
|
+
3. Make a key for the site in app.caracms.com and set it as `CARACMS_CLOUD_KEY` on the
|
|
312
|
+
site's server.
|
|
313
|
+
|
|
314
|
+
The key replaces `CARACMS_GITHUB_TOKEN` (or `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`)
|
|
315
|
+
and `GITHUB_REPO`: the repo comes from the cloud when the server starts, so setting
|
|
316
|
+
`GITHUB_REPO` beside the key is refused, and so is setting a token or an App as well.
|
|
317
|
+
Everything else in [the variables](#the-variables) is still the site's own: `SITE_URL`,
|
|
318
|
+
`REDIS_URL`, `CARACMS_SECRET`, the sign-in variables, and the image, video and form
|
|
319
|
+
variables. There's no webhook: a check once a minute keeps the editor's Publish status up
|
|
320
|
+
to date.
|
|
321
|
+
|
|
322
|
+
If the cloud says no when the server starts (the App isn't installed on the repo, the
|
|
323
|
+
site has no repo yet, the key was revoked), `/admin` answers 503 with the cloud's own
|
|
324
|
+
sentence, and the public site still serves. The next visit to `/admin` tries again. If it
|
|
325
|
+
says no later, while the editor is open, the editor shows the same sentence until the
|
|
326
|
+
cloud gives a token again. A repo changed in CaraCMS Cloud reaches the site at its next
|
|
327
|
+
start.
|
|
328
|
+
|
|
329
|
+
**On a laptop**, a cloud key turns `astro dev` into live mode on the site's real repo,
|
|
330
|
+
as the App's variables do. Use a key from CaraCMS Cloud's dev deployment there (set its
|
|
331
|
+
address as `CARACMS_CLOUD_URL`), never a production key: the dev deployment only gives
|
|
332
|
+
out tokens for throwaway repos. Leave `CARACMS_CLOUD_URL` unset everywhere else.
|
|
333
|
+
|
|
278
334
|
### Behind Cloudflare
|
|
279
335
|
|
|
280
336
|
caracms sees through Cloudflare's proxy (the orange cloud) by itself: when the address
|
package/bin/caracms.mjs
CHANGED
|
@@ -12,20 +12,22 @@
|
|
|
12
12
|
// The Node adapter's standalone server sends prerendered pages and static files
|
|
13
13
|
// before Astro's middleware runs, so a password or a `noindex` header can't be added
|
|
14
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,
|
|
15
|
+
// gate (gate.mjs): robots.txt, the pre-launch password, `X-Robots-Tag`, a page beside a
|
|
16
|
+
// folder of the same name, then Site Settings' redirects.
|
|
16
17
|
//
|
|
17
18
|
// It runs from the site's root (the directory with `dist/` and `content/`) and reads,
|
|
18
19
|
// once at start:
|
|
19
20
|
// PORT (default 8080), HOST (default 0.0.0.0), the site's address (Astro's `site` from
|
|
20
21
|
// dist/server/caracms-build.json, else SITE_URL, else RAILWAY_PUBLIC_DOMAIN),
|
|
21
22
|
// CARACMS_PRELAUNCH (`user:password`), CARACMS_CLIENT_IP_HEADER (else the detected
|
|
22
|
-
// host's default; with neither, it exits with the fix), and content/site-settings.json →
|
|
23
|
+
// host's default; with neither, it exits with the fix), and content/site-settings.json →
|
|
24
|
+
// brand.name and redirects.
|
|
23
25
|
|
|
24
26
|
import { existsSync, readFileSync } from 'node:fs';
|
|
25
27
|
import { createServer } from 'node:http';
|
|
26
|
-
import { join } from 'node:path';
|
|
27
|
-
import { pathToFileURL } from 'node:url';
|
|
28
|
-
import { createGate, describeClientIpSource, markProcessGated, parsePrelaunch, parseSiteUrl, publicAddress, readBuildInfo, readClientIpSource } from './gate.mjs';
|
|
28
|
+
import { join, relative, resolve } from 'node:path';
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
30
|
+
import { createGate, describeClientIpSource, markProcessGated, parsePrelaunch, parseSiteUrl, publicAddress, readBuildInfo, readClientIpSource, readRedirects } from './gate.mjs';
|
|
29
31
|
|
|
30
32
|
const USAGE = `Usage: caracms <command>, from the site's root
|
|
31
33
|
|
|
@@ -56,6 +58,20 @@ function readBrandName(root) {
|
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The adapter's static files folder, as it finds it (@astrojs/node → resolveClientDir):
|
|
63
|
+
* the built server-to-client path from its options, applied to `dist/server` here.
|
|
64
|
+
* `dist/client` when the options don't say.
|
|
65
|
+
*/
|
|
66
|
+
function clientDirOf(options, root) {
|
|
67
|
+
const serverDir = join(root, 'dist/server');
|
|
68
|
+
try {
|
|
69
|
+
return resolve(serverDir, relative(fileURLToPath(new URL(options.server)), fileURLToPath(new URL(options.client))));
|
|
70
|
+
} catch {
|
|
71
|
+
return join(root, 'dist/client');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
async function start() {
|
|
60
76
|
const root = process.cwd();
|
|
61
77
|
const entry = join(root, 'dist/server/entry.mjs');
|
|
@@ -87,7 +103,15 @@ async function start() {
|
|
|
87
103
|
const publicSite = publicAddress(process.env, readBuildInfo(root));
|
|
88
104
|
const siteUrl = publicSite?.value;
|
|
89
105
|
const prelaunch = process.env.CARACMS_PRELAUNCH;
|
|
90
|
-
const gate = createGate({
|
|
106
|
+
const gate = createGate({
|
|
107
|
+
handler: built.handler,
|
|
108
|
+
clientIpSource: ipSource.source,
|
|
109
|
+
siteUrl,
|
|
110
|
+
prelaunch,
|
|
111
|
+
brandName: readBrandName(root),
|
|
112
|
+
clientDir: clientDirOf(built.options, root),
|
|
113
|
+
redirects: readRedirects(root),
|
|
114
|
+
});
|
|
91
115
|
|
|
92
116
|
const server = createServer(gate);
|
|
93
117
|
server.on('error', (err) => fatal(err.message));
|
package/bin/gate.mjs
CHANGED
|
@@ -12,12 +12,17 @@
|
|
|
12
12
|
// 2. The pre-launch password (`CARACMS_PRELAUNCH`), except for the editor and its
|
|
13
13
|
// API, `/_astro/…` and `/health`.
|
|
14
14
|
// 3. `X-Robots-Tag` on the way out, kept whatever the adapter writes.
|
|
15
|
-
// 4.
|
|
15
|
+
// 4. A page beside a folder of the same name (`services/advisory.html` and
|
|
16
|
+
// `services/advisory/`) is asked for by its file: the adapter would look in the
|
|
17
|
+
// folder for an index.html and answer 404.
|
|
18
|
+
// 5. Site Settings' redirects, on what would be a 404: a prerendered 404 page never
|
|
19
|
+
// reaches Astro's middleware (redirect-middleware.ts, which serves them in dev).
|
|
20
|
+
// 6. The adapter's own handler (static files first, then Astro).
|
|
16
21
|
|
|
17
22
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
18
|
-
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
19
24
|
import { isIP } from 'node:net';
|
|
20
|
-
import { join } from 'node:path';
|
|
25
|
+
import { join, resolve, sep } from 'node:path';
|
|
21
26
|
import { CLOUDFLARE_IPV4, CLOUDFLARE_IPV6 } from './cloudflare-ips.mjs';
|
|
22
27
|
|
|
23
28
|
/** The header the gate owns. */
|
|
@@ -199,6 +204,88 @@ export function parseSiteUrl(value) {
|
|
|
199
204
|
return { host: url.host.toLowerCase(), origin: url.origin, indexable };
|
|
200
205
|
}
|
|
201
206
|
|
|
207
|
+
// ── Pages beside a folder, and redirects ────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
const isFile = (path) => {
|
|
210
|
+
try {
|
|
211
|
+
return statSync(path).isFile();
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const isDirectory = (path) => {
|
|
217
|
+
try {
|
|
218
|
+
return statSync(path).isDirectory();
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The file to serve for a page whose address is also a folder: with `build.format:
|
|
226
|
+
* 'file'`, `/services/advisory` is `services/advisory.html`, beside a `services/advisory/`
|
|
227
|
+
* folder of the pages under it. The adapter sees the folder, looks for its index.html
|
|
228
|
+
* and answers 404, so the gate asks for the `.html` itself. Null for anything else: a
|
|
229
|
+
* path the two views don't agree on, `/`, a trailing slash, a file extension, a folder
|
|
230
|
+
* with its own index.html, or no such file under `clientDir`.
|
|
231
|
+
*/
|
|
232
|
+
export function pageBesideFolder(clientDir, views) {
|
|
233
|
+
const path = views.full;
|
|
234
|
+
if (!clientDir || path === null || views.app !== path || path === '/' || path.endsWith('/')) return null;
|
|
235
|
+
if (/\.[^/]*$/.test(path.slice(path.lastIndexOf('/')))) return null;
|
|
236
|
+
const root = resolve(clientDir);
|
|
237
|
+
const folder = resolve(root, '.' + path);
|
|
238
|
+
if (!folder.startsWith(root + sep)) return null;
|
|
239
|
+
if (!isDirectory(folder) || isFile(join(folder, 'index.html')) || !isFile(folder + '.html')) return null;
|
|
240
|
+
return path + '.html';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Site Settings' redirects, as the package reads them (src/redirects.ts; kept in step by
|
|
245
|
+
* gate.test.ts): `/path` addresses without a trailing slash; an entry that isn't two of
|
|
246
|
+
* them, goes nowhere or repeats a `from` is dropped.
|
|
247
|
+
*/
|
|
248
|
+
export function redirectAddress(value) {
|
|
249
|
+
if (typeof value !== 'string') return null;
|
|
250
|
+
const v = value.trim();
|
|
251
|
+
if (!/^\/(?!\/)[^\s?#]*$/.test(v)) return null;
|
|
252
|
+
const trimmed = v.replace(/\/+$/, '');
|
|
253
|
+
return trimmed === '' ? '/' : trimmed;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function parseRedirects(raw) {
|
|
257
|
+
if (!Array.isArray(raw)) return [];
|
|
258
|
+
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
259
|
+
const out = [];
|
|
260
|
+
for (const entry of raw) {
|
|
261
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
262
|
+
const from = redirectAddress(entry.from);
|
|
263
|
+
const to = redirectAddress(entry.to);
|
|
264
|
+
if (!from || !to || same(from, to) || out.some((r) => same(r.from, from))) continue;
|
|
265
|
+
out.push({ from, to });
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Where a request for `pathname` (as sent) goes, or null: matched decoded, without a trailing slash; letter case counts. */
|
|
271
|
+
export function redirectFor(list, pathname) {
|
|
272
|
+
let path = pathname;
|
|
273
|
+
try {
|
|
274
|
+
path = decodeURI(pathname);
|
|
275
|
+
} catch {}
|
|
276
|
+
path = path.replace(/\/+$/, '') || '/';
|
|
277
|
+
return list.find((r) => r.from === path)?.to ?? null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `content/site-settings.json` → `redirects` under `root`, read once at start (the file is the build's). Empty when it's missing or unreadable. */
|
|
281
|
+
export function readRedirects(root) {
|
|
282
|
+
try {
|
|
283
|
+
return parseRedirects(JSON.parse(readFileSync(join(root, 'content/site-settings.json'), 'utf8'))?.redirects);
|
|
284
|
+
} catch {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
202
289
|
// ── The visitor's IP (Decision 8) ───────────────────────────────────────────────
|
|
203
290
|
|
|
204
291
|
/** The header the gate sets on every request; `clientIp()` (src/live/auth/request.ts) reads only this in production. */
|
|
@@ -406,9 +493,9 @@ const sha256 = (buf) => createHash('sha256').update(buf).digest();
|
|
|
406
493
|
* The gate, as `(req, res) => void` around the adapter's `handler`.
|
|
407
494
|
* @param {{ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => unknown,
|
|
408
495
|
* clientIpSource: string, siteUrl?: string, prelaunch?: string, brandName?: string,
|
|
409
|
-
* log?: (line: string) => void }} options
|
|
496
|
+
* clientDir?: string, redirects?: { from: string, to: string }[], log?: (line: string) => void }} options
|
|
410
497
|
*/
|
|
411
|
-
export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandName, log = (line) => console.error(line) }) {
|
|
498
|
+
export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandName, clientDir, redirects = [], log = (line) => console.error(line) }) {
|
|
412
499
|
if (!CLIENT_IP_SOURCES.includes(clientIpSource)) throw new Error(`createGate: clientIpSource must be one of ${CLIENT_IP_SOURCES.join(', ')}.`);
|
|
413
500
|
let warnedMissing = false;
|
|
414
501
|
const site = parseSiteUrl(siteUrl);
|
|
@@ -481,7 +568,24 @@ export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandN
|
|
|
481
568
|
const robots = guarded ? NOINDEX_NOFOLLOW : indexable ? null : NOINDEX;
|
|
482
569
|
if (robots) holdHeader(res, ROBOTS_HEADER, robots);
|
|
483
570
|
|
|
484
|
-
|
|
571
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
572
|
+
// 4. A page beside a folder of the same name, by its file.
|
|
573
|
+
// (As sent, plus `.html`: the views are decoded, the request target mustn't be.)
|
|
574
|
+
const file = guarded ? null : pageBesideFolder(clientDir, views);
|
|
575
|
+
const raw = rawPathOf(req.url);
|
|
576
|
+
if (file && raw !== null) {
|
|
577
|
+
req.url = raw + '.html' + req.url.slice(raw.length);
|
|
578
|
+
} else if (!guarded && redirects.length) {
|
|
579
|
+
// 5. A redirect, only where the site answers 404: a real page always wins.
|
|
580
|
+
const to = raw === null ? null : redirectFor(redirects, raw);
|
|
581
|
+
if (to) {
|
|
582
|
+
const query = req.url.indexOf('?');
|
|
583
|
+
redirectOn404(res, to + (query === -1 ? '' : req.url.slice(query)));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 6. The adapter.
|
|
485
589
|
try {
|
|
486
590
|
const result = handler(req, res);
|
|
487
591
|
if (result && typeof result.catch === 'function') result.catch((err) => fail(res, err));
|
|
@@ -492,6 +596,53 @@ export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandN
|
|
|
492
596
|
return gate;
|
|
493
597
|
}
|
|
494
598
|
|
|
599
|
+
/**
|
|
600
|
+
* Turns the response into a 301 to `location` if the handler answers 404, however it
|
|
601
|
+
* writes it (`writeHead`, or a status and an implicit head on the first write or end).
|
|
602
|
+
* The 404's own headers and body are dropped; the held robots header stays.
|
|
603
|
+
*/
|
|
604
|
+
function redirectOn404(res, location) {
|
|
605
|
+
const writeHead = res.writeHead.bind(res);
|
|
606
|
+
const write = res.write.bind(res);
|
|
607
|
+
const end = res.end.bind(res);
|
|
608
|
+
let redirected = false;
|
|
609
|
+
const redirect = () => {
|
|
610
|
+
redirected = true;
|
|
611
|
+
for (const name of res.getHeaderNames()) res.removeHeader(name);
|
|
612
|
+
writeHead(301, { Location: location, 'Cache-Control': 'no-cache', 'Content-Length': 0 });
|
|
613
|
+
end();
|
|
614
|
+
};
|
|
615
|
+
const callback = (args) => {
|
|
616
|
+
const cb = args.find((a) => typeof a === 'function');
|
|
617
|
+
if (cb) queueMicrotask(cb);
|
|
618
|
+
};
|
|
619
|
+
const is404 = () => !res.headersSent && res.statusCode === 404;
|
|
620
|
+
res.writeHead = (status, ...rest) => {
|
|
621
|
+
if (redirected) return res;
|
|
622
|
+
if (status === 404 && !res.headersSent) {
|
|
623
|
+
redirect();
|
|
624
|
+
return res;
|
|
625
|
+
}
|
|
626
|
+
return writeHead(status, ...rest);
|
|
627
|
+
};
|
|
628
|
+
res.write = (...args) => {
|
|
629
|
+
if (!redirected && is404()) redirect();
|
|
630
|
+
if (redirected) {
|
|
631
|
+
callback(args);
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
return write(...args);
|
|
635
|
+
};
|
|
636
|
+
res.end = (...args) => {
|
|
637
|
+
if (!redirected && is404()) redirect();
|
|
638
|
+
if (redirected) {
|
|
639
|
+
callback(args);
|
|
640
|
+
return res;
|
|
641
|
+
}
|
|
642
|
+
return end(...args);
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
495
646
|
function fail(res, err) {
|
|
496
647
|
console.error('caracms start: the request failed:', err);
|
|
497
648
|
if (!res.headersSent) {
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Every document
|
|
4
|
-
* file's path is the address
|
|
3
|
+
* Every document with an address: the Pages collection (content/pages/, nested ones
|
|
4
|
+
* included: the file's path is the address), and every collection a page holds or that
|
|
5
|
+
* has a urlBase of its own. Written by `caracms init`. It serves documents only. The
|
|
5
6
|
* site's other routes in src/pages/ keep serving their own pages, and their addresses are
|
|
6
7
|
* reserved: a page is code or a document, never both.
|
|
7
8
|
*/
|
|
8
9
|
import RenderDocument from 'caracms/components/RenderDocument.astro';
|
|
9
|
-
import {
|
|
10
|
+
import { addressedDocuments, readDocument } from 'caracms/content';
|
|
10
11
|
import { site } from 'virtual:caracms/config';
|
|
11
12
|
|
|
12
13
|
export const prerender = true;
|
|
13
14
|
|
|
14
15
|
export async function getStaticPaths() {
|
|
15
16
|
// A file that can't be opened (invalid JSON, no title, a nested index.json) has no page,
|
|
16
|
-
// and nor has a hidden one (or one under it): visitors get the 404 page.
|
|
17
|
-
const
|
|
18
|
-
return
|
|
17
|
+
// and nor has a hidden one (or one under it, or held by it): visitors get the 404 page.
|
|
18
|
+
const documents = await addressedDocuments(site);
|
|
19
|
+
return documents.map((d) => ({ params: { path: d.url === '/' ? undefined : d.url.slice(1) }, props: { collection: d.collection, path: d.path } }));
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
const { path } = Astro.props;
|
|
22
|
-
const { document } = await readDocument(site,
|
|
22
|
+
const { collection, path } = Astro.props;
|
|
23
|
+
const { document } = await readDocument(site, collection, path);
|
|
23
24
|
---
|
|
24
25
|
|
|
25
|
-
<RenderDocument document={document} collection=
|
|
26
|
+
<RenderDocument document={document} collection={collection} />
|
package/package.json
CHANGED
|
@@ -63,8 +63,9 @@ unapproved, and don't approve it in passing (`pnpm approve` also reviews build s
|
|
|
63
63
|
- `caracms.config.ts`: a `pages` collection at `/` (nested), one `text` block, no text
|
|
64
64
|
styles;
|
|
65
65
|
- `src/components/caracms/Page.astro` (the page component) and `TextBlock.astro`;
|
|
66
|
-
- `src/pages/[...path].astro`, which serves
|
|
67
|
-
|
|
66
|
+
- `src/pages/[...path].astro`, the one catch-all, which serves every document with an
|
|
67
|
+
address (the pages today; a collection added later needs no route of its own, section
|
|
68
|
+
6) through `addressedDocuments` from `caracms/content`, and `src/pages/health.ts`;
|
|
68
69
|
- `content/site-settings.json` (its `brand.name` is the package name title-cased: correct
|
|
69
70
|
it), and `schemas/`;
|
|
70
71
|
- `astro.config` edits: the Node adapter (standalone), `integrations: [caracms(site)]`,
|
|
@@ -304,7 +305,7 @@ something in the site reads the marker, keep it as a `toggle()` field on the col
|
|
|
304
305
|
otherwise drop it. Either way, say so in the pull request.
|
|
305
306
|
|
|
306
307
|
**Code routes.** Every route in `src/pages/` other than caracms's `[...path].astro` (and a
|
|
307
|
-
collection's own route,
|
|
308
|
+
prefix collection's own route, section 6) is the site's code, and its address is reserved: a static
|
|
308
309
|
route (`contact.astro`) reserves exactly `/contact`; a route with parameters
|
|
309
310
|
(`work/[slug].astro`) reserves everything under `/work/`. The editor refuses a page there
|
|
310
311
|
("… is served by the site's own code (…), so a page can't go there."), and `validate`
|
|
@@ -353,7 +354,23 @@ go only when nothing in the code still uses them.
|
|
|
353
354
|
The site's Astro content collections (`src/content.config.ts`) become caracms collections
|
|
354
355
|
when editors should change them:
|
|
355
356
|
|
|
356
|
-
-
|
|
357
|
+
- **Pick the address source for each**, one of three:
|
|
358
|
+
|
|
359
|
+
| Source | In the config | In the content | A document's address |
|
|
360
|
+
|---|---|---|---|
|
|
361
|
+
| **Pageless** | no `urlBase` | none | none (a team, an FAQ, logos) |
|
|
362
|
+
| **A prefix in code** | `urlBase: '/tags/'` | none | `urlBase` + slug, with no page at the prefix |
|
|
363
|
+
| **Held by a page** | `heldBy: 'pages'` | the page's file says `"holds": "journal"` | the page's address + `/` + slug |
|
|
364
|
+
|
|
365
|
+
A collection whose old site had a page at its base (a Journal page and the posts under
|
|
366
|
+
it, a Work page and its case studies) is **held** by that page: `heldBy: 'pages'` in
|
|
367
|
+
place of `urlBase`, and `"holds": "<collection>"` in the page's file, after `title`.
|
|
368
|
+
Its documents are served from the page's address and follow the page if it moves. A
|
|
369
|
+
prefix with no page at it (tags) keeps `urlBase`. Neither needs a route:
|
|
370
|
+
`[...path].astro` serves them through `addressedDocuments` (`documentAt` for a site
|
|
371
|
+
that renders on demand). Delete the old collection's `[slug].astro`; one left at a
|
|
372
|
+
held collection's address is a `validate` error. A prefix collection's route file you
|
|
373
|
+
keep is the site's own, and the reader leaves that collection out.
|
|
357
374
|
- A collection only other pages show (a team, an FAQ, logos): `kind: 'fields'` with **no
|
|
358
375
|
`urlBase`** and a `component` (the editor's preview of one document). A block shows them
|
|
359
376
|
with `cms.read.documents('<name>')`, which orders them **by title**: keep the old order
|
|
@@ -148,15 +148,32 @@ const { block, cms } = Astro.props;
|
|
|
148
148
|
|
|
149
149
|
## Collections
|
|
150
150
|
|
|
151
|
+
A collection with pages gets its address from exactly one of three places:
|
|
152
|
+
|
|
153
|
+
| Source | In the config | In the content | A document's address |
|
|
154
|
+
|---|---|---|---|
|
|
155
|
+
| **Pageless** | no `urlBase` | none | none (a team, an FAQ): other pages show them |
|
|
156
|
+
| **A prefix in code** | `urlBase: '/tags/'` | none | `urlBase` + slug, with no page at the prefix |
|
|
157
|
+
| **Held by a page** | `heldBy: 'pages'` | the page's file says `"holds": "journal"` | the page's address + `/` + slug; move the page and they follow |
|
|
158
|
+
|
|
151
159
|
```ts
|
|
152
|
-
//
|
|
153
|
-
work: { kind: 'fields', folder: 'content/work',
|
|
154
|
-
// A
|
|
160
|
+
// Held by a page: the Work page's file says "holds": "work", so a case study is at /work/<slug>
|
|
161
|
+
work: { kind: 'fields', folder: 'content/work', heldBy: 'pages', component: 'src/components/caracms/CaseStudy.astro', fields: { … } },
|
|
162
|
+
// A second section builder, held by the Journal page ("holds": "journal"): posts at /journal/<slug>
|
|
163
|
+
journal: { kind: 'sections', folder: 'content/journal', heldBy: 'pages', fields: { date: date() }, blocks: ['text', 'image'] },
|
|
164
|
+
// A prefix in code, with no Tags page: /tags/<slug>
|
|
165
|
+
tags: { kind: 'sections', folder: 'content/tags', urlBase: '/tags/' },
|
|
166
|
+
// A collection with no pages (a team, an FAQ): leave out urlBase and heldBy
|
|
155
167
|
people: { kind: 'fields', folder: 'content/people', component: 'src/components/caracms/Person.astro', fields: { role: text(), photo: media({ only: 'image' }) } },
|
|
156
|
-
// A second section builder (a journal)
|
|
157
|
-
journal: { kind: 'sections', folder: 'content/journal', urlBase: '/journal/', fields: { date: date() }, blocks: ['text', 'image'] },
|
|
158
168
|
```
|
|
159
169
|
|
|
170
|
+
- **`heldBy` names the nested section builder whose page holds the collection** (`'pages'`),
|
|
171
|
+
and is exclusive with `urlBase`. The held collection is flat (not `nested`), either
|
|
172
|
+
kind. The mount is in the content, not here: one page of `pages` says
|
|
173
|
+
`"holds": "journal"` (a document key after `title` and `hidden`; the editor shows it
|
|
174
|
+
on the page and doesn't change it), one collection per page and one page per
|
|
175
|
+
collection, never Home. Until a page holds it the collection has no addresses; `caracms validate` errors
|
|
176
|
+
on a collection no page holds, two pages holding one, and a `holds` anywhere else.
|
|
160
177
|
- A `fields` collection's `component` renders one document: it gets
|
|
161
178
|
`{ document, cms }` (`FieldsDocumentProps` from `caracms/document`), with
|
|
162
179
|
`document.title` and each field's value; draw fields with `field="<key>"`. The page
|
|
@@ -172,14 +189,20 @@ journal: { kind: 'sections', folder: 'content/journal', urlBase: '/journal/', fi
|
|
|
172
189
|
gets them), **ordered by title**. For another order, give the documents a `number()`
|
|
173
190
|
field and sort in the component, or let a page pick and order them with
|
|
174
191
|
`list(reference({ to: 'people' }))`. `cms.read.document('people', path)` reads one.
|
|
175
|
-
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
192
|
+
- **Routes: one catch-all serves every document with an address.** `src/pages/[...path].astro`
|
|
193
|
+
(`caracms init` writes it) builds from `addressedDocuments(site)` in `caracms/content`,
|
|
194
|
+
which lists `{ collection, path, url }` for every public page of Pages, every held
|
|
195
|
+
collection and every prefix collection, and renders with `RenderDocument` from
|
|
196
|
+
`caracms/components/RenderDocument.astro` and `readDocument`. A site that renders on
|
|
197
|
+
demand asks `documentAt(site, pathname)` for the document at a request path (or
|
|
198
|
+
`null`). Both leave out entries with a `warning` (a file that can't be opened) and
|
|
199
|
+
`hidden` ones (a page hidden from the site, one under it, and the documents of a hidden
|
|
200
|
+
holder): those answer the 404 page. **Don't write a route per collection.** A collection
|
|
201
|
+
doesn't need `src/pages/work/[slug].astro`, and one left at a held collection's address
|
|
202
|
+
is a `validate` error. A prefix collection that keeps its own route file
|
|
203
|
+
(`src/pages/tags/[slug].astro`) is left out of the reader, so the page is never built
|
|
204
|
+
twice. Every other route in `src/pages/` is the site's own code, and its address can't
|
|
205
|
+
hold a document.
|
|
183
206
|
- Shared blocks (`content/shared/`) and Site Settings are built in; no config.
|
|
184
207
|
|
|
185
208
|
## Site Settings fields
|
|
@@ -23,10 +23,20 @@ a file that's laid out otherwise, and `validate --fix` rewrites it the editor's
|
|
|
23
23
|
`content/pages/about.json` is `/about`, `content/pages/services/print.json` is
|
|
24
24
|
`/services/print`, and `content/pages/index.json` is Home. An `index.json` anywhere
|
|
25
25
|
deeper is an error: `/services` itself is `content/pages/services.json`.
|
|
26
|
-
- `kind: 'fields'`: each document is a title plus the collection's `fields`.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
- `kind: 'fields'`: each document is a title plus the collection's `fields`.
|
|
27
|
+
- **A collection with pages gets its address from one of three places:**
|
|
28
|
+
|
|
29
|
+
| Source | In the config | In the content | A document's address |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| Pageless | no `urlBase` | none | none: a team, an FAQ; other pages show them |
|
|
32
|
+
| A prefix in code | `urlBase: '/tags/'` | none | `urlBase` + slug: `content/tags/news.json` is `/tags/news` |
|
|
33
|
+
| Held by a page | `heldBy: 'pages'` | the page's file says `"holds": "journal"` | the page's address + `/` + slug: with `content/pages/journal.json` holding it, `content/journal/a-day.json` is `/journal/a-day` |
|
|
34
|
+
|
|
35
|
+
A held collection's posts follow their page: move the page to `/blog` and they are at
|
|
36
|
+
`/blog/<slug>`. Only the config's `heldBy` and the page's `"holds"` say so, and
|
|
37
|
+
they must agree (`validate` errors when they don't). One catch-all route
|
|
38
|
+
(`src/pages/[...path].astro`) serves all three through `addressedDocuments` from
|
|
39
|
+
`caracms/content`; a collection needs no route of its own.
|
|
30
40
|
- **Shared blocks** (one block used on several pages): `content/shared/<name>.json`
|
|
31
41
|
(or the config's `sharedFolder`).
|
|
32
42
|
- **Site Settings**: `content/site-settings.json`, one file.
|
|
@@ -65,6 +75,10 @@ A page (`sections`):
|
|
|
65
75
|
- `{ "shared": "<name>" }` places the shared block `content/shared/<name>.json` there.
|
|
66
76
|
- If the collection has `fields` of its own (page fields), they're top-level keys after
|
|
67
77
|
`title` and before `blocks`.
|
|
78
|
+
- A page that holds a collection says so with `"holds": "<collection key>"`, after `title`
|
|
79
|
+
(and `hidden`) and before the page fields, `seo` and `blocks`. Only a page of the
|
|
80
|
+
collection the held collection's `heldBy` names can hold one, never Home, one page per
|
|
81
|
+
collection and one collection per page. Don't put it anywhere else.
|
|
68
82
|
- An optional `"seo": { "title", "description", "image" }` sets the page's search and
|
|
69
83
|
share text; leave it out to use Site Settings' defaults.
|
|
70
84
|
|
|
@@ -152,8 +166,15 @@ rename or delete the file, fix every `href` and `reference` that pointed at it (
|
|
|
152
166
|
`content/` for the old address and path), and for a page that was public add
|
|
153
167
|
`{ "from": "/old-address", "to": "/new-address" }` to Site Settings' `redirects`.
|
|
154
168
|
|
|
155
|
-
A page
|
|
156
|
-
|
|
169
|
+
**A page that holds a collection takes its documents with it**, and so does every page
|
|
170
|
+
under it: in the editor, Change address… moves them all and adds a redirect for each old
|
|
171
|
+
address. By hand, the held documents' files don't move (their address is the page's plus
|
|
172
|
+
the slug), but every `href` to each of them changes and each old address that was public
|
|
173
|
+
needs its own `redirects` entry. A holder can't be deleted while it holds: take its
|
|
174
|
+
`"holds"` line out first, or move the documents to another page.
|
|
175
|
+
|
|
176
|
+
A page can't go at an address the site's own code serves (a route in `src/pages/`), at
|
|
177
|
+
a held document's address (`/journal/a-day`), or at `/admin`, `/_caracms` or `/files`.
|
|
157
178
|
|
|
158
179
|
## Choosing blocks for a page
|
|
159
180
|
|