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.
- package/README.md +247 -3
- package/bin/caracms.mjs +32 -16
- package/bin/cloudflare-ips.mjs +38 -0
- package/bin/gate.mjs +277 -4
- package/bin/init.mjs +65 -20
- package/bin/templates/catch-all.astro.txt +3 -2
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +17 -3
- package/skills/caracms-adopt/look-same.mjs +24 -11
- package/skills/caracms-blocks/SKILL.md +69 -4
- package/skills/caracms-content/SKILL.md +2 -1
- package/skills/caracms-tina/SKILL.md +431 -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/cli/validate.ts +10 -5
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +20 -1
- package/src/components/Form.astro +76 -10
- package/src/components/Head.astro +4 -2
- package/src/components/Media.astro +27 -1
- package/src/components/RichText.astro +12 -4
- package/src/components/Text.astro +32 -9
- package/src/components/form-client.ts +7 -4
- package/src/components/sitemap.xml.ts +2 -1
- package/src/config.ts +32 -1
- package/src/content-schema.ts +7 -4
- package/src/content.ts +28 -0
- package/src/document.ts +15 -2
- package/src/editor/client/block-list/summary.ts +7 -1
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +17 -12
- package/src/editor/client/fields/ReferenceField.tsx +6 -0
- package/src/editor/client/fields/RichTextField.tsx +38 -32
- package/src/editor/client/fields/host.tsx +5 -0
- package/src/editor/client/fields/parts.tsx +2 -1
- package/src/editor/client/fields/rich-text-doc.ts +28 -2
- package/src/editor/client/fields/rich-text-schema.ts +41 -2
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/nav/DocRow.tsx +6 -0
- package/src/editor/client/overlays/rich-text-bar.ts +6 -6
- package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/preview/driver.ts +5 -5
- package/src/editor/client/preview/field-text.ts +40 -2
- package/src/editor/client/preview/page-edit.ts +2 -2
- package/src/editor/client/preview/page-editor-registry.ts +4 -3
- package/src/editor/client/preview/page-editor.ts +3 -3
- package/src/editor/client/state/document-store.ts +20 -2
- package/src/editor/client/state/keeper.ts +51 -6
- package/src/editor/client/state/live.ts +45 -6
- package/src/editor/middleware.ts +3 -2
- package/src/editor/routes/try-again.ts +2 -1
- package/src/editor/server/admin-props.ts +2 -1
- package/src/editor/server/mode.ts +13 -3
- package/src/editor/styles/editor.css +2 -0
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +27 -6
- package/src/fields.ts +63 -15
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/forms/rules.ts +9 -1
- package/src/head.ts +17 -4
- package/src/index.ts +53 -7
- package/src/links.ts +35 -0
- package/src/live/README.md +5 -2
- package/src/live/auth/auth.ts +4 -1
- package/src/live/auth/guard.ts +3 -2
- package/src/live/auth/index.ts +6 -7
- package/src/live/auth/login.ts +17 -8
- package/src/live/auth/middleware.ts +7 -3
- package/src/live/auth/request.ts +92 -11
- package/src/live/auth/views.ts +9 -4
- package/src/live/autosaves.ts +59 -16
- package/src/live/backend.ts +54 -24
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +4 -3
- package/src/live/editor.ts +92 -67
- package/src/live/git.ts +112 -34
- package/src/live/github.ts +128 -13
- package/src/live/locks.ts +92 -26
- package/src/live/mode.ts +177 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +15 -4
- package/src/live/status-cache.ts +219 -0
- package/src/live/status.ts +14 -2
- package/src/live/timings.ts +24 -0
- package/src/media.ts +40 -7
- package/src/read.ts +2 -1
- package/src/rich-text.ts +30 -5
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
@@ -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`, `
|
|
72
|
+
- `package.json`: `@astrojs/node`, `"start": "caracms start"`;
|
|
73
73
|
`.gitignore`: `.caracms/`; the tsconfig's `allowImportingTsExtensions`;
|
|
74
|
-
-
|
|
75
|
-
|
|
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
|