caracms 0.1.0 → 0.3.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 (93) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +65 -20
  6. package/bin/templates/catch-all.astro.txt +3 -2
  7. package/package.json +1 -1
  8. package/skills/caracms-adopt/SKILL.md +17 -3
  9. package/skills/caracms-adopt/look-same.mjs +24 -11
  10. package/skills/caracms-blocks/SKILL.md +69 -4
  11. package/skills/caracms-content/SKILL.md +2 -1
  12. package/skills/caracms-tina/SKILL.md +431 -0
  13. package/skills/caracms-tina/field-kinds.md +88 -0
  14. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  15. package/src/block-runs.ts +84 -0
  16. package/src/cli/validate.ts +10 -5
  17. package/src/components/BlockRunTree.astro +45 -0
  18. package/src/components/Blocks.astro +20 -1
  19. package/src/components/Form.astro +76 -10
  20. package/src/components/Head.astro +4 -2
  21. package/src/components/Media.astro +27 -1
  22. package/src/components/RichText.astro +12 -4
  23. package/src/components/Text.astro +32 -9
  24. package/src/components/form-client.ts +7 -4
  25. package/src/components/sitemap.xml.ts +2 -1
  26. package/src/config.ts +32 -1
  27. package/src/content-schema.ts +7 -4
  28. package/src/content.ts +28 -0
  29. package/src/document.ts +15 -2
  30. package/src/editor/client/block-list/summary.ts +7 -1
  31. package/src/editor/client/editor.ts +24 -6
  32. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  33. package/src/editor/client/fields/LinkField.tsx +17 -12
  34. package/src/editor/client/fields/ReferenceField.tsx +6 -0
  35. package/src/editor/client/fields/RichTextField.tsx +38 -32
  36. package/src/editor/client/fields/host.tsx +5 -0
  37. package/src/editor/client/fields/parts.tsx +2 -1
  38. package/src/editor/client/fields/rich-text-doc.ts +28 -2
  39. package/src/editor/client/fields/rich-text-schema.ts +41 -2
  40. package/src/editor/client/live/publish.tsx +20 -10
  41. package/src/editor/client/nav/DocRow.tsx +6 -0
  42. package/src/editor/client/overlays/rich-text-bar.ts +6 -6
  43. package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
  44. package/src/editor/client/pane/parts.tsx +34 -2
  45. package/src/editor/client/preview/driver.ts +5 -5
  46. package/src/editor/client/preview/field-text.ts +40 -2
  47. package/src/editor/client/preview/page-edit.ts +2 -2
  48. package/src/editor/client/preview/page-editor-registry.ts +4 -3
  49. package/src/editor/client/preview/page-editor.ts +3 -3
  50. package/src/editor/client/state/document-store.ts +20 -2
  51. package/src/editor/client/state/keeper.ts +51 -6
  52. package/src/editor/client/state/live.ts +45 -6
  53. package/src/editor/middleware.ts +3 -2
  54. package/src/editor/routes/try-again.ts +2 -1
  55. package/src/editor/server/admin-props.ts +2 -1
  56. package/src/editor/server/mode.ts +13 -3
  57. package/src/editor/styles/editor.css +2 -0
  58. package/src/editor/styles/levels/live.css +1 -1
  59. package/src/editor/types.ts +27 -6
  60. package/src/fields.ts +63 -15
  61. package/src/files-route.ts +9 -4
  62. package/src/forms/endpoint.ts +11 -16
  63. package/src/forms/rules.ts +9 -1
  64. package/src/head.ts +17 -4
  65. package/src/index.ts +53 -7
  66. package/src/links.ts +35 -0
  67. package/src/live/README.md +5 -2
  68. package/src/live/auth/auth.ts +4 -1
  69. package/src/live/auth/guard.ts +3 -2
  70. package/src/live/auth/index.ts +6 -7
  71. package/src/live/auth/login.ts +17 -8
  72. package/src/live/auth/middleware.ts +7 -3
  73. package/src/live/auth/request.ts +92 -11
  74. package/src/live/auth/views.ts +9 -4
  75. package/src/live/autosaves.ts +59 -16
  76. package/src/live/backend.ts +54 -24
  77. package/src/live/branches.ts +19 -0
  78. package/src/live/drafts-store.ts +4 -3
  79. package/src/live/editor.ts +92 -67
  80. package/src/live/git.ts +112 -34
  81. package/src/live/github.ts +128 -13
  82. package/src/live/locks.ts +92 -26
  83. package/src/live/mode.ts +177 -18
  84. package/src/live/notices.ts +100 -0
  85. package/src/live/redis.ts +161 -39
  86. package/src/live/runtime.ts +15 -4
  87. package/src/live/status-cache.ts +219 -0
  88. package/src/live/status.ts +14 -2
  89. package/src/live/timings.ts +24 -0
  90. package/src/media.ts +40 -7
  91. package/src/read.ts +2 -1
  92. package/src/rich-text.ts +30 -5
  93. package/src/serialize.ts +10 -1
package/bin/gate.mjs CHANGED
@@ -4,6 +4,10 @@
4
4
  // with no flags, so nothing here may import the package's TypeScript.
5
5
  //
6
6
  // Its order, per request:
7
+ // 0. The visitor's IP, from the one source `CARACMS_CLIENT_IP_HEADER` names (or the
8
+ // host's default), set as `x-caracms-client-ip` on every request, replacing any
9
+ // copy the request brought (runs-anywhere spec, Decision 8 and Contracts). The
10
+ // header is trusted only in a process `caracms start` marked (`GATED_MARK`).
7
11
  // 1. `/robots.txt` is answered here, before the password.
8
12
  // 2. The pre-launch password (`CARACMS_PRELAUNCH`), except for the editor and its
9
13
  // API, `/_astro/…` and `/health`.
@@ -11,6 +15,10 @@
11
15
  // 4. The adapter's own handler (static files first, then Astro).
12
16
 
13
17
  import { createHash, timingSafeEqual } from 'node:crypto';
18
+ import { readFileSync } from 'node:fs';
19
+ import { isIP } from 'node:net';
20
+ import { join } from 'node:path';
21
+ import { CLOUDFLARE_IPV4, CLOUDFLARE_IPV6 } from './cloudflare-ips.mjs';
14
22
 
15
23
  /** The header the gate owns. */
16
24
  const ROBOTS_HEADER = 'X-Robots-Tag';
@@ -123,6 +131,60 @@ export function parsePrelaunch(raw) {
123
131
  return { user: value.slice(0, colon) };
124
132
  }
125
133
 
134
+ /**
135
+ * The file the build writes beside the server's entry (`BUILD_INFO_FILE` in the package's
136
+ * src/index, runs-anywhere Decisions 7 and 9): `{ "site": "https://…" | null, "commit": … }`,
137
+ * `site` being Astro's `site` as built. Read by path, since this file imports only built-ins.
138
+ */
139
+ export const BUILD_INFO_FILE = 'caracms-build.json';
140
+
141
+ /**
142
+ * `dist/server/caracms-build.json` under `root`, or null when it's missing, unreadable or
143
+ * not an object (a site built before the file existed): the address chain then starts at
144
+ * `SITE_URL`.
145
+ * @returns {{ site: string | null, commit: string | null } | null}
146
+ */
147
+ export function readBuildInfo(root) {
148
+ let info;
149
+ try {
150
+ info = JSON.parse(readFileSync(join(root, 'dist/server', BUILD_INFO_FILE), 'utf8'));
151
+ } catch {
152
+ return null;
153
+ }
154
+ if (!info || typeof info !== 'object' || Array.isArray(info)) return null;
155
+ const text = (v) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : null);
156
+ return { site: text(info.site), commit: text(info.commit) };
157
+ }
158
+
159
+ /**
160
+ * The site's public address at run time, by Decision 7's whole chain: Astro's `site` as
161
+ * built (from `caracms-build.json`: the config's `site`, else `SITE_URL` at build), then
162
+ * `SITE_URL`, then Railway's `RAILWAY_PUBLIC_DOMAIN`. The same order as `addressFrom()` in
163
+ * the package's src/live/mode, which takes the built `site` through a define instead. A
164
+ * built `site` that isn't an http(s) URL is passed over, as `addressFrom()` does.
165
+ * @param {{ site?: string | null } | null} [built] what `readBuildInfo()` read
166
+ * @returns {{ value: string, from: "Astro's site" | 'SITE_URL' | 'RAILWAY_PUBLIC_DOMAIN' } | null}
167
+ */
168
+ export function publicAddress(env = process.env, built = null) {
169
+ const astroSite = typeof built?.site === 'string' ? built.site.trim() : '';
170
+ if (astroSite && parseSiteUrl(astroSite)) return { value: astroSite, from: "Astro's site" };
171
+ const siteUrl = typeof env.SITE_URL === 'string' ? env.SITE_URL.trim() : '';
172
+ if (siteUrl) return { value: siteUrl, from: 'SITE_URL' };
173
+ const railway = typeof env.RAILWAY_PUBLIC_DOMAIN === 'string' ? env.RAILWAY_PUBLIC_DOMAIN.trim() : '';
174
+ if (railway) return { value: `https://${railway}`, from: 'RAILWAY_PUBLIC_DOMAIN' };
175
+ return null;
176
+ }
177
+
178
+ /** Hosts' own addresses, never indexable whatever `SITE_URL` says (Decision 13): the domain itself or any name under it. */
179
+ const PLATFORM_DOMAINS = ['localhost', 'up.railway.app', 'onrender.com', 'fly.dev'];
180
+
181
+ /** True for a hostname a search engine should never index: localhost, a host's platform address, or a bare IP. */
182
+ export function isNeverIndexableHostname(hostname) {
183
+ const name = String(hostname).toLowerCase().replace(/\.+$/, '');
184
+ if (name === '' || isIP(name.replace(/^\[|\]$/g, '')) !== 0) return true;
185
+ return PLATFORM_DOMAINS.some((domain) => name === domain || name.endsWith('.' + domain));
186
+ }
187
+
126
188
  /** `SITE_URL`'s host and origin, or null when it's unset or not an http(s) URL. */
127
189
  export function parseSiteUrl(value) {
128
190
  if (typeof value !== 'string' || value.trim() === '') return null;
@@ -133,19 +195,222 @@ export function parseSiteUrl(value) {
133
195
  return null;
134
196
  }
135
197
  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');
198
+ const indexable = !isNeverIndexableHostname(url.hostname);
138
199
  return { host: url.host.toLowerCase(), origin: url.origin, indexable };
139
200
  }
140
201
 
202
+ // ── The visitor's IP (Decision 8) ───────────────────────────────────────────────
203
+
204
+ /** The header the gate sets on every request; `clientIp()` (src/live/auth/request.ts) reads only this in production. */
205
+ export const CLIENT_IP_HEADER = 'x-caracms-client-ip';
206
+
207
+ /**
208
+ * The mark that says this process is behind the gate: `globalThis[Symbol.for('caracms.gated')] === true`.
209
+ * `caracms start` sets it (`markProcessGated()`) before it imports the built site, which
210
+ * runs in this same process, and `clientIp()` (src/live/auth/request.ts, `GATED_MARK`)
211
+ * trusts `x-caracms-client-ip` in production only when it's there. Without it, a server
212
+ * started some other way (`node dist/server/entry.mjs`) has nothing replacing the header,
213
+ * so a visitor could send their own copy and pick their IP for the limits; with the mark
214
+ * off, that server answers 503 instead, whatever the request carries. A request can't set
215
+ * it: it lives in the process, not on the wire.
216
+ */
217
+ export const GATED_MARK = Symbol.for('caracms.gated');
218
+
219
+ /** Marks this process as gated. Only `caracms start` calls it, and only once the adapter's own server is off, so every request reaches the site through the gate. */
220
+ export function markProcessGated() {
221
+ globalThis[GATED_MARK] = true;
222
+ }
223
+
224
+ /** The exact fix, printed when no source is known or the one set isn't allowed. `caracms init` and the hosting docs print the same. */
225
+ export const CLIENT_IP_FIX =
226
+ 'Set CARACMS_CLIENT_IP_HEADER to the header your proxy sets: x-real-ip for nginx with `proxy_set_header X-Real-IP $remote_addr`, x-forwarded-for for Caddy, or socket if nothing sits in front of this server.';
227
+
228
+ /**
229
+ * Where the IP may come from. `socket`: the connection's own address. `x-forwarded-for`:
230
+ * its last entry, the one the nearest proxy added. Any other: a header the nearest proxy
231
+ * sets and overwrites. (Render documents no such header, so it has none here.)
232
+ */
233
+ export const CLIENT_IP_SOURCES = ['socket', 'x-forwarded-for', 'x-real-ip', 'fly-client-ip', 'cf-connecting-ip'];
234
+
235
+ /**
236
+ * Hosts detected from a variable they set, with their default source.
237
+ * - Railway: its edge overwrites X-Real-IP (relied on since M4).
238
+ * - Fly: its proxy sets Fly-Client-IP (docs.fly.io/networking/request-headers) and
239
+ * replaces a forged one (checked against debug.fly.dev, 2026-10-24). Its
240
+ * X-Forwarded-For can't be used: Fly's docs say the last entry is the app's own address.
241
+ * - Render: no overwritten header is documented, so the spec's fallback: X-Forwarded-For's last entry.
242
+ */
243
+ export const HOST_DEFAULTS = [
244
+ { variable: 'RAILWAY_ENVIRONMENT_ID', host: 'Railway', source: 'x-real-ip' },
245
+ { variable: 'FLY_APP_NAME', host: 'Fly', source: 'fly-client-ip' },
246
+ { variable: 'RENDER', host: 'Render', source: 'x-forwarded-for' },
247
+ ];
248
+
249
+ const isSet = (value) => typeof value === 'string' && value.trim() !== '';
250
+
251
+ /**
252
+ * Reads `CARACMS_CLIENT_IP_HEADER`, or the detected host's default. A set value always
253
+ * wins. `problem` is the sentence `caracms start` exits with.
254
+ * @returns {{ source: string, from: string, problem: null } | { source: null, from: null, problem: string }}
255
+ */
256
+ export function readClientIpSource(env = process.env) {
257
+ const raw = env.CARACMS_CLIENT_IP_HEADER;
258
+ if (raw !== undefined && raw.trim() !== '') {
259
+ const source = raw.trim().toLowerCase();
260
+ if (!CLIENT_IP_SOURCES.includes(source)) {
261
+ return { source: null, from: null, problem: `CARACMS_CLIENT_IP_HEADER is "${raw.trim()}", which isn't one caracms can use. ${CLIENT_IP_FIX}` };
262
+ }
263
+ return { source, from: 'CARACMS_CLIENT_IP_HEADER', problem: null };
264
+ }
265
+ const detected = HOST_DEFAULTS.find((h) => isSet(env[h.variable]));
266
+ if (detected) return { source: detected.source, from: `${detected.host}'s default, from ${detected.variable}`, problem: null };
267
+ return { source: null, from: null, problem: `no host was detected, so caracms can't tell where a visitor's IP comes from. ${CLIENT_IP_FIX}` };
268
+ }
269
+
270
+ /** The line `caracms start` prints. */
271
+ export function describeClientIpSource({ source, from }) {
272
+ const where = source === 'socket' ? 'the socket' : source === 'x-forwarded-for' ? "X-Forwarded-For's last entry" : headerName(source);
273
+ return source === 'cf-connecting-ip' ? `${where} (${from})` : `${where} (${from}); CF-Connecting-IP when that address is Cloudflare's, on the site's own domain`;
274
+ }
275
+
276
+ const headerName = (lower) => lower.replace(/(^|-)([a-z])/g, (_, dash, c) => dash + c.toUpperCase()).replace(/\bIp\b/, 'IP').replace(/^Cf-/, 'CF-');
277
+
278
+ /** An address in canonical form (IPv4-mapped IPv6 as IPv4, IPv6 lowercased), or null when it isn't one. Brackets and an IPv4 port are dropped. */
279
+ export function normaliseIp(value) {
280
+ if (typeof value !== 'string') return null;
281
+ let text = value.trim();
282
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(text);
283
+ if (bracketed) text = bracketed[1];
284
+ else if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(text)) text = text.slice(0, text.lastIndexOf(':'));
285
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(text);
286
+ if (mapped) text = mapped[1];
287
+ const kind = isIP(text);
288
+ if (kind === 4) return text;
289
+ if (kind === 6) return text.toLowerCase();
290
+ return null;
291
+ }
292
+
293
+ function ipv4ToInt(ip) {
294
+ return ip.split('.').reduce((n, part) => n * 256 + Number(part), 0);
295
+ }
296
+
297
+ function ipv6ToBigInt(ip) {
298
+ let text = ip;
299
+ // A trailing dotted quad (`::1.2.3.4`) as two groups.
300
+ const quad = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
301
+ if (quad) {
302
+ const n = ipv4ToInt(quad[1]);
303
+ text = text.slice(0, -quad[1].length) + ((n >>> 16) & 0xffff).toString(16) + ':' + (n & 0xffff).toString(16);
304
+ }
305
+ const [head, tail] = text.split('::');
306
+ const headParts = head ? head.split(':') : [];
307
+ const tailParts = tail === undefined ? [] : tail ? tail.split(':') : [];
308
+ const groups = tail === undefined ? headParts : [...headParts, ...Array(8 - headParts.length - tailParts.length).fill('0'), ...tailParts];
309
+ return groups.reduce((n, g) => (n << 16n) + BigInt(parseInt(g, 16)), 0n);
310
+ }
311
+
312
+ const V4_RANGES = CLOUDFLARE_IPV4.map((cidr) => {
313
+ const [base, bits] = cidr.split('/');
314
+ const size = 2 ** (32 - Number(bits));
315
+ const start = Math.floor(ipv4ToInt(base) / size) * size;
316
+ return [start, start + size];
317
+ });
318
+ const V6_RANGES = CLOUDFLARE_IPV6.map((cidr) => {
319
+ const [base, bits] = cidr.split('/');
320
+ const shift = BigInt(128 - Number(bits));
321
+ return [ipv6ToBigInt(base) >> shift, shift];
322
+ });
323
+
324
+ /** True when a normalised address is in Cloudflare's published ranges (cloudflare-ips.mjs). */
325
+ export function isCloudflareIp(ip) {
326
+ const kind = isIP(ip);
327
+ if (kind === 4) {
328
+ const n = ipv4ToInt(ip);
329
+ return V4_RANGES.some(([start, end]) => n >= start && n < end);
330
+ }
331
+ if (kind === 6) {
332
+ const n = ipv6ToBigInt(ip);
333
+ return V6_RANGES.some(([prefix, shift]) => n >> shift === prefix);
334
+ }
335
+ return false;
336
+ }
337
+
338
+ /** The last comma-separated entry of a header (Node joins repeated lines with ", "), normalised. */
339
+ function lastEntry(value) {
340
+ if (Array.isArray(value)) value = value.join(',');
341
+ if (typeof value !== 'string') return null;
342
+ const entries = value.split(',');
343
+ return normaliseIp(entries[entries.length - 1]);
344
+ }
345
+
346
+ /**
347
+ * True when the request's Host is a host's own address (Decision 13's list: localhost,
348
+ * `*.up.railway.app`, `*.onrender.com`, `*.fly.dev`, a bare IP), or missing or unreadable.
349
+ */
350
+ export function isPlatformHost(host) {
351
+ if (typeof host !== 'string' || host.trim() === '') return true;
352
+ let hostname;
353
+ try {
354
+ hostname = new URL(`http://${host.trim()}`).hostname;
355
+ } catch {
356
+ return true;
357
+ }
358
+ return isNeverIndexableHostname(hostname);
359
+ }
360
+
361
+ /**
362
+ * The visitor's IP for one request: from `source`; then, when that address is
363
+ * Cloudflare's and the request's Host isn't a host's own address, from CF-Connecting-IP
364
+ * (kept as the Cloudflare address if that's missing or not an address). `missing` is
365
+ * true when the source header was missing or held no address, so the socket's address
366
+ * was used instead.
367
+ *
368
+ * Why the Host matters: a Cloudflare Worker's fetch to an origin outside Cloudflare
369
+ * leaves from Cloudflare's ranges and may set CF-Connecting-IP to anything
370
+ * (developers.cloudflare.com/fundamentals/reference/http-headers/). So anyone's Worker
371
+ * calling `x.up.railway.app` or `x.fly.dev` directly arrives with a Cloudflare address in
372
+ * the host's header and a CF-Connecting-IP of its choosing. A site behind Cloudflare's
373
+ * proxy is reached at its own domain, where the request passes through the site's zone
374
+ * and Cloudflare sets CF-Connecting-IP itself, a Worker's fetch included. On a host's own
375
+ * address the Cloudflare address is kept: those requests share one bucket, which is
376
+ * what a Worker hammering it deserves.
377
+ * @param {{ headers: Record<string, string | string[] | undefined>, socket?: { remoteAddress?: string } }} req
378
+ */
379
+ export function resolveClientIp(req, source) {
380
+ const socketIp = normaliseIp(req.socket?.remoteAddress) ?? 'unknown';
381
+ let ip = source === 'socket' ? socketIp : lastEntry(req.headers[source]);
382
+ const missing = ip === null;
383
+ if (missing) ip = socketIp;
384
+ if (source !== 'cf-connecting-ip' && isCloudflareIp(ip) && !isPlatformHost(req.headers.host)) {
385
+ const viaCloudflare = lastEntry(req.headers['cf-connecting-ip']);
386
+ if (viaCloudflare) ip = viaCloudflare;
387
+ }
388
+ return { ip, missing };
389
+ }
390
+
391
+ /** Puts the IP on the request as the only `x-caracms-client-ip`, in `headers` and `rawHeaders` alike. */
392
+ function setClientIp(req, ip) {
393
+ req.headers[CLIENT_IP_HEADER] = ip;
394
+ const raw = req.rawHeaders;
395
+ if (Array.isArray(raw)) {
396
+ const kept = [];
397
+ for (let i = 0; i < raw.length; i += 2) if (String(raw[i]).toLowerCase() !== CLIENT_IP_HEADER) kept.push(raw[i], raw[i + 1]);
398
+ kept.push(CLIENT_IP_HEADER, ip);
399
+ raw.splice(0, raw.length, ...kept);
400
+ }
401
+ }
402
+
141
403
  const sha256 = (buf) => createHash('sha256').update(buf).digest();
142
404
 
143
405
  /**
144
406
  * The gate, as `(req, res) => void` around the adapter's `handler`.
145
407
  * @param {{ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => unknown,
146
- * siteUrl?: string, prelaunch?: string, brandName?: string }} options
408
+ * clientIpSource: string, siteUrl?: string, prelaunch?: string, brandName?: string,
409
+ * log?: (line: string) => void }} options
147
410
  */
148
- export function createGate({ handler, siteUrl, prelaunch, brandName }) {
411
+ export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandName, log = (line) => console.error(line) }) {
412
+ if (!CLIENT_IP_SOURCES.includes(clientIpSource)) throw new Error(`createGate: clientIpSource must be one of ${CLIENT_IP_SOURCES.join(', ')}.`);
413
+ let warnedMissing = false;
149
414
  const site = parseSiteUrl(siteUrl);
150
415
  const password = parsePrelaunch(prelaunch) ? sha256(Buffer.from(trimPrelaunch(prelaunch), 'utf8')) : null;
151
416
  const realm = `${cleanRealmName(brandName) || NEUTRAL_REALM_NAME} (not launched yet)`;
@@ -170,6 +435,14 @@ export function createGate({ handler, siteUrl, prelaunch, brandName }) {
170
435
  };
171
436
 
172
437
  const gate = (req, res) => {
438
+ // 0. The visitor's IP, before anything can answer.
439
+ const { ip, missing } = resolveClientIp(req, clientIpSource);
440
+ setClientIp(req, ip);
441
+ if (missing && !warnedMissing) {
442
+ warnedMissing = true;
443
+ log(`caracms start: a request came without ${headerName(clientIpSource)}, so its socket's address was used. If every request says so, CARACMS_CLIENT_IP_HEADER doesn't match the proxy in front. (Said once.)`);
444
+ }
445
+
173
446
  const views = pathViews(req.url);
174
447
  const indexable = isIndexableHost(req);
175
448
 
package/bin/init.mjs CHANGED
@@ -1,5 +1,5 @@
1
- // `caracms init [--write]` (M8 Decisions 7–13): add caracms's plumbing to an existing
2
- // Astro site, and nothing about its design.
1
+ // `caracms init [--write] [--railway]` (M8 Decisions 7–13): add caracms's plumbing to an
2
+ // existing Astro site, and nothing about its design.
3
3
  //
4
4
  // - Run plainly, it prints every file it would add and every edit as a diff, and writes
5
5
  // nothing. `--write` applies exactly that, then writes the schemas (`caracms schemas`).
@@ -8,6 +8,10 @@
8
8
  // writes nothing. One run reports every problem, the git checks included.
9
9
  // - An existing file it can't edit safely (an astro.config it can't follow, an existing
10
10
  // `.railway/railway.ts`) gets the lines to add printed instead, and is left alone.
11
+ // - `.railway/railway.ts` and the `railway` dependency come only with `--railway`, or are
12
+ // left as they are on a site that already has the file (runs-anywhere Decision 12).
13
+ // Otherwise init writes no host file and prints the variables any host needs
14
+ // (HOST_VARIABLES), on the dry run and on `--write`.
11
15
  // - Run again, it re-links the skills, refreshes the CLAUDE.md block and the schemas, and
12
16
  // reports the rest as already done.
13
17
  //
@@ -25,6 +29,41 @@ const templates = fileURLToPath(new URL('./templates/', import.meta.url));
25
29
  const template = (name) => readFileSync(join(templates, name), 'utf8');
26
30
  const ownPackage = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
27
31
 
32
+ /**
33
+ * The variables any host needs (runs-anywhere Decision 12): the one list, printed by init
34
+ * without `--railway`. The package README's Hosting section matches it. Names as
35
+ * `src/live/mode.ts` reads them, and the gate's `CARACMS_CLIENT_IP_HEADER`.
36
+ */
37
+ export const HOST_VARIABLES = [
38
+ { name: 'GITHUB_REPO', need: 'required', what: "The site's repository, as owner/name." },
39
+ { name: 'CARACMS_GITHUB_TOKEN', need: 'required, or the App', what: 'A fine-grained token (github_pat_…) on that one repo: Contents read and write, Metadata read (Deployments read is optional). Never GITHUB_TOKEN.' },
40
+ { name: 'GITHUB_APP_ID', need: 'instead of the token', what: "The GitHub App's ID, for anyone setting up many sites. Set the token or the App, not both." },
41
+ { name: 'GITHUB_APP_PRIVATE_KEY', need: 'with GITHUB_APP_ID', what: "The App's private key." },
42
+ { name: 'SITE_URL', need: 'required', what: "The site's public address, https://. The same value at build and at run time." },
43
+ { name: 'REDIS_URL', need: 'required', what: "The editor's drafts and sessions: redis:// or rediss:// (Upstash works, in the site's region)." },
44
+ { name: 'CARACMS_SECRET', need: 'required', what: "Signs the editors' sessions. A long random string." },
45
+ { name: 'CARACMS_CLIENT_IP_HEADER', need: 'required off Railway, Fly and Render', what: "Where the visitor's IP comes from: x-real-ip for nginx with `proxy_set_header X-Real-IP $remote_addr`, x-forwarded-for for Caddy, or socket if nothing sits in front of this server. `caracms start` won't start without it." },
46
+ { name: 'CARACMS_ALLOW', need: 'for email sign-in', what: 'Who may sign in, comma-separated: addresses (aoife@fernhill.ie) or whole domains (@fernhill.ie). The client adds their own people in Site Settings.' },
47
+ { name: 'RESEND_API_KEY', need: 'for email sign-in', what: 'Sends the sign-in links (and form notifications) through Resend.' },
48
+ { name: 'FORMS_FROM', need: 'for email sign-in', what: 'The address those emails come from, on a domain verified in Resend.' },
49
+ { name: 'CARACMS_BACKUP_USER', need: 'for the backup sign-in', what: "The backup sign-in, for when email sign-in links can't arrive. Both or neither." },
50
+ { name: 'CARACMS_BACKUP_PASSWORD', need: 'for the backup sign-in', what: "The backup sign-in's password." },
51
+ { name: 'CARACMS_RUNNING_COMMIT', need: 'optional', what: "The commit this server runs, where the host doesn't say (Railway and Render do)." },
52
+ { name: 'CARACMS_DEPLOY_HOOK_URL', need: 'optional', what: "Try again on a publish POSTs here to redeploy the site (the host's deploy hook)." },
53
+ { name: 'GITHUB_WEBHOOK_SECRET', need: 'optional', what: "For a webhook added in the repo's settings. Without it, a check once a minute does the same job." },
54
+ ];
55
+
56
+ /** HOST_VARIABLES as init prints it: one table, name, whether it's needed, what it's for. */
57
+ export function hostVariablesText() {
58
+ const rows = [{ name: 'Name', need: 'Needed', what: "What it's for" }, ...HOST_VARIABLES];
59
+ const w1 = Math.max(...rows.map((r) => r.name.length));
60
+ const w2 = Math.max(...rows.map((r) => r.need.length));
61
+ return [
62
+ "The variables any host needs, on the site's server (`caracms start` runs it; one instance, behind HTTPS). Set email sign-in, the backup sign-in or both, or nobody can sign in. Images, video and forms have their own variables: the caracms README's Hosting section lists them. `caracms init --railway` writes a Railway setup instead.",
63
+ ...rows.map((r) => ` ${r.name.padEnd(w1)} ${r.need.padEnd(w2)} ${r.what}`),
64
+ ].join('\n');
65
+ }
66
+
28
67
  /** What init adds to a site's package.json (Decisions 7 and 11). */
29
68
  export const VERSIONS = { node: '^11.1.7', railway: '^3.16.0' };
30
69
 
@@ -43,6 +82,7 @@ const SKILL_BLURBS = {
43
82
  'caracms-content': 'changing content: the JSON files in content/, rich text, images, references, Site Settings, and working on a branch',
44
83
  'caracms-blocks': 'building the site: blocks and their fields, collections, text styles, the page component, forms, and scripts beside the editor',
45
84
  'caracms-adopt': 'bringing the site\'s existing pages onto caracms, one page at a time, and checking they look the same',
85
+ 'caracms-tina': 'porting a site built on Tina (a Tina template site) onto caracms with the reference conversion script, re-runnable on newer content',
46
86
  };
47
87
 
48
88
  const posix = (p) => p.split(sep).join('/');
@@ -294,7 +334,7 @@ function listFiles(dir) {
294
334
  * 'unlink' | 'schemas', file, before?, after?, target? }), notes (printed, nothing written),
295
335
  * and what's already done.
296
336
  */
297
- function changesOf(site) {
337
+ function changesOf(site, { railway = false } = {}) {
298
338
  const { root } = site;
299
339
  const changes = [];
300
340
  const notes = [];
@@ -308,9 +348,10 @@ function changesOf(site) {
308
348
  else changes.push({ kind: before === null ? 'add' : 'edit', file, before, after });
309
349
  };
310
350
 
311
- // package.json: @astrojs/node, `start`, and railway when its file is made.
351
+ // package.json: @astrojs/node, `start`, and railway when its file is made (only with --railway).
312
352
  const railwayFile = '.railway/railway.ts';
313
- const makeRailway = !existsSync(join(root, railwayFile));
353
+ const hasRailway = existsSync(join(root, railwayFile));
354
+ const makeRailway = railway && !hasRailway;
314
355
  {
315
356
  const pkg = structuredClone(site.pkg);
316
357
  pkg.dependencies ??= {};
@@ -340,7 +381,7 @@ function changesOf(site) {
340
381
  const healthFile = `${posix(site.srcDir)}/pages/health.ts`;
341
382
  if (!health) changes.push({ kind: 'add', file: healthFile, after: template('health.ts.txt') });
342
383
  else if (health.file === healthFile && read(join(root, healthFile)) === template('health.ts.txt')) done.push(healthFile);
343
- else notes.push(`The site already has a /health route (${health.file}), so init leaves it alone. Railway's healthcheck asks /health for a 200.`);
384
+ else notes.push(`The site already has a /health route (${health.file}), so init leaves it alone. Hosts' healthchecks (Railway's, for one) ask /health for a 200.`);
344
385
 
345
386
  {
346
387
  const name = titleCase(site.pkg.name ?? basename(root)) || 'Site';
@@ -363,8 +404,11 @@ function changesOf(site) {
363
404
  else edit('tsconfig.json', before, after, 'tsconfig.json (allowImportingTsExtensions)');
364
405
  }
365
406
 
366
- // Railway (Decision 11).
367
- if (makeRailway) {
407
+ // Railway (Decision 11): with --railway, or the site's existing file. Otherwise no host file
408
+ // and report() prints HOST_VARIABLES (runs-anywhere Decision 12).
409
+ if (!railway && !hasRailway) {
410
+ // nothing to write
411
+ } else if (makeRailway) {
368
412
  const remote = git(root, ['remote', 'get-url', 'origin']).out.trim();
369
413
  const repo = /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(remote)?.[1] ?? null;
370
414
  const repoName = basename(git(root, ['rev-parse', '--show-toplevel']).out.trim() || root);
@@ -414,7 +458,7 @@ function changesOf(site) {
414
458
  // MEC trial item 1: what Tina left behind, untracked (tinaOf).
415
459
  const { leftover } = tinaOf(site);
416
460
  if (leftover) notes.push(`${leftover.length ? leftover.join(', ') : 'tina/'} ${leftover.length > 1 ? 'are' : 'is'} left over from Tina (nothing under tina/ is tracked): delete ${leftover.length > 1 ? 'them' : 'it'}.`);
417
- return { changes, notes, done };
461
+ return { changes, notes, done, hostVariables: !railway && !hasRailway };
418
462
  }
419
463
 
420
464
  /** `object` with `key` added, in alphabetical order when the object already is. */
@@ -516,7 +560,7 @@ function railwayNeeds(file) {
516
560
  ' A media bucket: uploaded images. The site gets S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY.',
517
561
  ' A submissions bucket: form submissions, apart from the media imgproxy can read. The site gets SUBMISSIONS_S3_BUCKET, SUBMISSIONS_S3_ACCESS_KEY_ID and SUBMISSIONS_S3_SECRET_ACCESS_KEY.',
518
562
  ' imgproxy (ghcr.io/imgproxy/imgproxy), reading only the media bucket: it serves the images, signed. The site gets CARACMS_MEDIA_URL (imgproxy\'s public address), IMGPROXY_KEY and IMGPROXY_SALT (the same as imgproxy\'s).',
519
- ' The site: NODE_ENV=production, SITE_URL (its public address), GITHUB_REPO (owner/name), start command `caracms start`, healthcheck /health; set by hand: GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET, CARACMS_SECRET, CARACMS_ALLOW, CARACMS_BACKUP_USER, CARACMS_BACKUP_PASSWORD, CARACMS_RAILWAY_TOKEN, MUX_TOKEN_ID, MUX_TOKEN_SECRET, RESEND_API_KEY, FORMS_FROM.',
563
+ ' The site: NODE_ENV=production, SITE_URL (its public address), GITHUB_REPO (owner/name), start command `caracms start`, healthcheck /health; set by hand: CARACMS_GITHUB_TOKEN (a fine-grained token on that one repo; or, instead, the App: GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY), GITHUB_WEBHOOK_SECRET, CARACMS_SECRET, CARACMS_ALLOW, CARACMS_BACKUP_USER, CARACMS_BACKUP_PASSWORD, CARACMS_RAILWAY_TOKEN, MUX_TOKEN_ID, MUX_TOKEN_SECRET, RESEND_API_KEY, FORMS_FROM.',
520
564
  ' The whole of it, as caracms\'s starter writes it: node_modules/caracms/bin/templates/railway.ts.txt.',
521
565
  ].join('\n');
522
566
  }
@@ -607,7 +651,7 @@ function describe(change) {
607
651
  }
608
652
  }
609
653
 
610
- function report({ changes, notes, done }, { write, pm }) {
654
+ function report({ changes, notes, done, hostVariables }, { write, pm }) {
611
655
  const parts = [];
612
656
  if (changes.length === 0) {
613
657
  parts.push('caracms init: nothing to do. caracms is set up here.');
@@ -621,6 +665,7 @@ function report({ changes, notes, done }, { write, pm }) {
621
665
  }
622
666
  if (done.length && changes.length) parts.push(`Already done: ${done.join('; ')}.`);
623
667
  if (notes.length) parts.push(`Notes:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
668
+ if (hostVariables) parts.push(hostVariablesText());
624
669
  if (changes.length) {
625
670
  const install = `${pm} install`;
626
671
  parts.push(
@@ -649,17 +694,17 @@ async function schemaChanges(site) {
649
694
  // ── The command ─────────────────────────────────────────────────────────────────
650
695
 
651
696
  /** The plan: problems (nothing happens when there are any), or the changes. */
652
- export async function plan(root, { write = false } = {}) {
697
+ export async function plan(root, { write = false, railway = false } = {}) {
653
698
  const site = inspect(root);
654
699
  const problems = problemsOf(site, write);
655
- if (problems.length) return { site, problems, changes: [], notes: [], done: [] };
656
- const { changes, notes, done } = changesOf(site);
700
+ if (problems.length) return { site, problems, changes: [], notes: [], done: [], hostVariables: false };
701
+ const { changes, notes, done, hostVariables } = changesOf(site, { railway });
657
702
  try {
658
703
  changes.push(...(await schemaChanges(site)));
659
704
  } catch (error) {
660
- return { site, problems: [`The schemas couldn't be made from caracms.config.ts: ${error.message}`], changes: [], notes: [], done: [] };
705
+ return { site, problems: [`The schemas couldn't be made from caracms.config.ts: ${error.message}`], changes: [], notes: [], done: [], hostVariables: false };
661
706
  }
662
- return { site, problems, changes, notes, done };
707
+ return { site, problems, changes, notes, done, hostVariables };
663
708
  }
664
709
 
665
710
  function apply(root, changes) {
@@ -685,11 +730,11 @@ function apply(root, changes) {
685
730
  }
686
731
 
687
732
  /**
688
- * `caracms init [--write]`, from `root`. Returns the exit code; prints to `out` and `err` (stdout and stderr).
689
- * @param {{ root: string, write?: boolean, out?: { write(text: string): unknown }, err?: { write(text: string): unknown } }} options
733
+ * `caracms init [--write] [--railway]`, from `root`. Returns the exit code; prints to `out` and `err` (stdout and stderr).
734
+ * @param {{ root: string, write?: boolean, railway?: boolean, out?: { write(text: string): unknown }, err?: { write(text: string): unknown } }} options
690
735
  */
691
- export async function run({ root, write = false, out = process.stdout, err = process.stderr }) {
692
- const p = await plan(root, { write });
736
+ export async function run({ root, write = false, railway = false, out = process.stdout, err = process.stderr }) {
737
+ const p = await plan(root, { write, railway });
693
738
  if (p.problems.length) {
694
739
  err.write(`caracms init: ${p.problems.length === 1 ? 'one thing is' : `${p.problems.length} things are`} in the way, so nothing was ${write ? 'written' : 'planned'}:\n${p.problems.map((x) => ` - ${x}`).join('\n')}\n`);
695
740
  return 1;
@@ -12,8 +12,9 @@ import { site } from 'virtual:caracms/config';
12
12
  export const prerender = true;
13
13
 
14
14
  export async function getStaticPaths() {
15
- // A file that can't be opened (invalid JSON, no title, a nested index.json) has no page.
16
- const entries = (await listDocuments(site, 'pages')).filter((e) => !e.warning);
15
+ // 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 entries = (await listDocuments(site, 'pages')).filter((e) => !e.warning && !e.hidden);
17
18
  return entries.map((e) => ({ params: { path: e.path === 'index' ? undefined : e.path }, props: { path: e.path } }));
18
19
  }
19
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caracms",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A visual CMS for Astro sites, with content in git.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -69,10 +69,12 @@ unapproved, and don't approve it in passing (`pnpm approve` also reviews build s
69
69
  it), and `schemas/`;
70
70
  - `astro.config` edits: the Node adapter (standalone), `integrations: [caracms(site)]`,
71
71
  `devToolbar: { enabled: false }`;
72
- - `package.json`: `@astrojs/node`, `railway` (dev), `"start": "caracms start"`;
72
+ - `package.json`: `@astrojs/node`, `"start": "caracms start"`;
73
73
  `.gitignore`: `.caracms/`; the tsconfig's `allowImportingTsExtensions`;
74
- - `.railway/railway.ts` (when the site had none), the skill links and a block in
75
- `CLAUDE.md`.
74
+ - the skill links and a block in `CLAUDE.md`;
75
+ - with `--railway` only, `.railway/railway.ts` (when the site had none) and `railway`
76
+ (dev). Without it, `init` prints the variables any host needs instead. Where and how
77
+ the site runs (Railway, Render, Fly, a VPS) is the caracms README's Hosting section.
76
78
 
77
79
  Then `astro build`: every existing page must still build. The pages stay prerendered, but
78
80
  the output moves to `dist/client/` and `dist/server/`, and `npm start` is now
@@ -142,6 +144,11 @@ form, such as `<brand> | <tagline>`, goes in the home page's own `seo.title`
142
144
  the home page, where an editor changing it will look, and it wins whatever Site Settings
143
145
  holds.
144
146
 
147
+ If the old site showed one site-wide title on every page without its own (a Tina template
148
+ does), set the config's `titleFallback: 'site'` beside `titleSeparator`: step 2 then holds
149
+ on every page, not only the home page (the paged-listing ` — page N` still follows). The
150
+ default, `'page-and-brand'`, is the list above.
151
+
145
152
  **Per-page layout props.** The old routes often passed the layout something by hand: the
146
153
  header's colour or variant, `<main>`'s classes, a script in `<head>` for one page, the
147
154
  current page for the nav's highlight. A document has no route to do that, so the page
@@ -461,6 +468,13 @@ node .claude/skills/caracms-adopt/look-same.mjs --playwright /tmp/look-same \
461
468
  --pages pages.txt --out /tmp/look-same/report \
462
469
  --ignore-attr 'data-tina-*' --mask-ids auto
463
470
  ```
471
+ - **An element that varies run to run on the old site itself** (a wordmark fitted to its box
472
+ while the display font loads, so two shots of the same build differ) can't be fixed by
473
+ either site. `--css <rules>` adds the same CSS to both pages just before each shot: hide
474
+ the element or pin its size, e.g.
475
+ `--css '[data-wordmark-wrap]{visibility:hidden!important}'`. It changes the shots only,
476
+ not the HTML diff, and the report names the rules; keep them to the one element, since
477
+ whatever they cover is no longer compared.
464
478
  - **Spaces**: a space between inline content shows as `␣` at the start of the next text,
465
479
  wherever it sat (`<span>sub </span><em>` and `<span>sub</span> <em>` are the same), and a
466
480
  space beside a block tag is dropped, since neither shows. So a `␣` line in the diff is a