caracms 0.2.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 +64 -20
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +5 -3
- package/skills/caracms-adopt/look-same.mjs +8 -4
- package/skills/caracms-blocks/SKILL.md +20 -2
- package/skills/caracms-tina/SKILL.md +14 -10
- package/src/components/Head.astro +3 -2
- package/src/content.ts +2 -0
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/pane/parts.tsx +34 -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/levels/live.css +1 -1
- package/src/editor/types.ts +25 -6
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/head.ts +10 -1
- package/src/index.ts +53 -7
- package/src/live/README.md +4 -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 +13 -1
- 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 +131 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +11 -3
- 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 +26 -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
|
|
|
@@ -295,7 +334,7 @@ function listFiles(dir) {
|
|
|
295
334
|
* 'unlink' | 'schemas', file, before?, after?, target? }), notes (printed, nothing written),
|
|
296
335
|
* and what's already done.
|
|
297
336
|
*/
|
|
298
|
-
function changesOf(site) {
|
|
337
|
+
function changesOf(site, { railway = false } = {}) {
|
|
299
338
|
const { root } = site;
|
|
300
339
|
const changes = [];
|
|
301
340
|
const notes = [];
|
|
@@ -309,9 +348,10 @@ function changesOf(site) {
|
|
|
309
348
|
else changes.push({ kind: before === null ? 'add' : 'edit', file, before, after });
|
|
310
349
|
};
|
|
311
350
|
|
|
312
|
-
// 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).
|
|
313
352
|
const railwayFile = '.railway/railway.ts';
|
|
314
|
-
const
|
|
353
|
+
const hasRailway = existsSync(join(root, railwayFile));
|
|
354
|
+
const makeRailway = railway && !hasRailway;
|
|
315
355
|
{
|
|
316
356
|
const pkg = structuredClone(site.pkg);
|
|
317
357
|
pkg.dependencies ??= {};
|
|
@@ -341,7 +381,7 @@ function changesOf(site) {
|
|
|
341
381
|
const healthFile = `${posix(site.srcDir)}/pages/health.ts`;
|
|
342
382
|
if (!health) changes.push({ kind: 'add', file: healthFile, after: template('health.ts.txt') });
|
|
343
383
|
else if (health.file === healthFile && read(join(root, healthFile)) === template('health.ts.txt')) done.push(healthFile);
|
|
344
|
-
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.`);
|
|
345
385
|
|
|
346
386
|
{
|
|
347
387
|
const name = titleCase(site.pkg.name ?? basename(root)) || 'Site';
|
|
@@ -364,8 +404,11 @@ function changesOf(site) {
|
|
|
364
404
|
else edit('tsconfig.json', before, after, 'tsconfig.json (allowImportingTsExtensions)');
|
|
365
405
|
}
|
|
366
406
|
|
|
367
|
-
// Railway (Decision 11).
|
|
368
|
-
|
|
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) {
|
|
369
412
|
const remote = git(root, ['remote', 'get-url', 'origin']).out.trim();
|
|
370
413
|
const repo = /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(remote)?.[1] ?? null;
|
|
371
414
|
const repoName = basename(git(root, ['rev-parse', '--show-toplevel']).out.trim() || root);
|
|
@@ -415,7 +458,7 @@ function changesOf(site) {
|
|
|
415
458
|
// MEC trial item 1: what Tina left behind, untracked (tinaOf).
|
|
416
459
|
const { leftover } = tinaOf(site);
|
|
417
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'}.`);
|
|
418
|
-
return { changes, notes, done };
|
|
461
|
+
return { changes, notes, done, hostVariables: !railway && !hasRailway };
|
|
419
462
|
}
|
|
420
463
|
|
|
421
464
|
/** `object` with `key` added, in alphabetical order when the object already is. */
|
|
@@ -517,7 +560,7 @@ function railwayNeeds(file) {
|
|
|
517
560
|
' A media bucket: uploaded images. The site gets S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY.',
|
|
518
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.',
|
|
519
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).',
|
|
520
|
-
' 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.',
|
|
521
564
|
' The whole of it, as caracms\'s starter writes it: node_modules/caracms/bin/templates/railway.ts.txt.',
|
|
522
565
|
].join('\n');
|
|
523
566
|
}
|
|
@@ -608,7 +651,7 @@ function describe(change) {
|
|
|
608
651
|
}
|
|
609
652
|
}
|
|
610
653
|
|
|
611
|
-
function report({ changes, notes, done }, { write, pm }) {
|
|
654
|
+
function report({ changes, notes, done, hostVariables }, { write, pm }) {
|
|
612
655
|
const parts = [];
|
|
613
656
|
if (changes.length === 0) {
|
|
614
657
|
parts.push('caracms init: nothing to do. caracms is set up here.');
|
|
@@ -622,6 +665,7 @@ function report({ changes, notes, done }, { write, pm }) {
|
|
|
622
665
|
}
|
|
623
666
|
if (done.length && changes.length) parts.push(`Already done: ${done.join('; ')}.`);
|
|
624
667
|
if (notes.length) parts.push(`Notes:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
|
|
668
|
+
if (hostVariables) parts.push(hostVariablesText());
|
|
625
669
|
if (changes.length) {
|
|
626
670
|
const install = `${pm} install`;
|
|
627
671
|
parts.push(
|
|
@@ -650,17 +694,17 @@ async function schemaChanges(site) {
|
|
|
650
694
|
// ── The command ─────────────────────────────────────────────────────────────────
|
|
651
695
|
|
|
652
696
|
/** The plan: problems (nothing happens when there are any), or the changes. */
|
|
653
|
-
export async function plan(root, { write = false } = {}) {
|
|
697
|
+
export async function plan(root, { write = false, railway = false } = {}) {
|
|
654
698
|
const site = inspect(root);
|
|
655
699
|
const problems = problemsOf(site, write);
|
|
656
|
-
if (problems.length) return { site, problems, changes: [], notes: [], done: [] };
|
|
657
|
-
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 });
|
|
658
702
|
try {
|
|
659
703
|
changes.push(...(await schemaChanges(site)));
|
|
660
704
|
} catch (error) {
|
|
661
|
-
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 };
|
|
662
706
|
}
|
|
663
|
-
return { site, problems, changes, notes, done };
|
|
707
|
+
return { site, problems, changes, notes, done, hostVariables };
|
|
664
708
|
}
|
|
665
709
|
|
|
666
710
|
function apply(root, changes) {
|
|
@@ -686,11 +730,11 @@ function apply(root, changes) {
|
|
|
686
730
|
}
|
|
687
731
|
|
|
688
732
|
/**
|
|
689
|
-
* `caracms init [--write]`, from `root`. Returns the exit code; prints to `out` and `err` (stdout and stderr).
|
|
690
|
-
* @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
|
|
691
735
|
*/
|
|
692
|
-
export async function run({ root, write = false, out = process.stdout, err = process.stderr }) {
|
|
693
|
-
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 });
|
|
694
738
|
if (p.problems.length) {
|
|
695
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`);
|
|
696
740
|
return 1;
|
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
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
// Exit codes: 0 when the report is written (whatever the scores, statuses or image caps), 1 for
|
|
111
111
|
// a usage error, 2 when the report is written but some page couldn't be fetched at all.
|
|
112
112
|
|
|
113
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
113
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
114
114
|
import { createRequire } from 'node:module';
|
|
115
115
|
import { join, resolve } from 'node:path';
|
|
116
116
|
import { pathToFileURL } from 'node:url';
|
|
@@ -142,7 +142,9 @@ function parseArgs(argv) {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
function readPages(spec) {
|
|
145
|
-
|
|
145
|
+
// A list file only when it's a file: `--pages /` is the home page, not the disk's root.
|
|
146
|
+
const isFile = existsSync(spec) && statSync(spec).isFile();
|
|
147
|
+
const lines = isFile ? readFileSync(spec, 'utf8').split('\n') : spec.split(',');
|
|
146
148
|
return lines
|
|
147
149
|
.map((line) => line.replace(/#.*/, '').trim())
|
|
148
150
|
.filter(Boolean)
|
|
@@ -337,11 +339,13 @@ async function shoot(browser, url, width, imageWaitMs, css) {
|
|
|
337
339
|
const bottom = document.documentElement.scrollHeight - window.innerHeight;
|
|
338
340
|
if (y >= bottom) break;
|
|
339
341
|
y = Math.min(y + Math.round(window.innerHeight * 0.75), bottom);
|
|
340
|
-
|
|
342
|
+
// Instant, whatever the page's own `scroll-behavior`: a smooth scroll still moving at
|
|
343
|
+
// the shot puts a sticky header somewhere else each run (Urban Return port).
|
|
344
|
+
window.scrollTo({ top: y, behavior: 'instant' });
|
|
341
345
|
await pause(120);
|
|
342
346
|
}
|
|
343
347
|
await pause(300);
|
|
344
|
-
window.scrollTo(0,
|
|
348
|
+
window.scrollTo({ top: 0, behavior: 'instant' });
|
|
345
349
|
});
|
|
346
350
|
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
|
|
347
351
|
await page.evaluate(() => document.fonts.ready);
|
|
@@ -272,8 +272,26 @@ editor's preview alike. It gets `PageProps` (`caracms/document`): `document`,
|
|
|
272
272
|
collection's component).
|
|
273
273
|
- `document.kind === 'shared'` is a shared block's own preview: render it without the
|
|
274
274
|
header and footer.
|
|
275
|
-
-
|
|
276
|
-
|
|
275
|
+
- **An image in the page's frame** (a header logo, a mega-menu photo, a footer badge) is a
|
|
276
|
+
`media()` field in Site Settings, drawn with `settingsImageUrl` from `caracms/content`
|
|
277
|
+
(`<Media>` needs a block's `cms`, which the frame doesn't have):
|
|
278
|
+
|
|
279
|
+
```astro
|
|
280
|
+
---
|
|
281
|
+
import { settingsImageUrl, siteSettings } from 'caracms/content';
|
|
282
|
+
import { site } from 'virtual:caracms/config';
|
|
283
|
+
const settings = await siteSettings(site, Astro.locals);
|
|
284
|
+
const photo = settingsImageUrl(settings.megaPhoto, { width: 640, height: 400 });
|
|
285
|
+
---
|
|
286
|
+
<div class="mega" data-image={photo ?? '/images/placeholder.svg'}></div>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
It gives one URL by `<Media>`'s rules: one side is a `fit`, both are a `fill` cropped to
|
|
290
|
+
the focal point, a GIF or an SVG passes through unresized, no size is a `fit` at 1920. Ask
|
|
291
|
+
for the size it's shown at (twice it for a sharp 2× screen); there's no `srcset`. It's
|
|
292
|
+
null when the field is empty or the media isn't set up, so give the frame a fallback. It
|
|
293
|
+
works in the build, in the editor's preview (the draft's image) and under
|
|
294
|
+
`CARACMS_MEDIA=memory`.
|
|
277
295
|
|
|
278
296
|
## Forms
|
|
279
297
|
|
|
@@ -55,8 +55,9 @@ Work on a branch (`caracms-port`, say) in a worktree of the site. Commit after e
|
|
|
55
55
|
before anything else changes. If the site pins its own `vite`, bump it with Astro:
|
|
56
56
|
left behind, it leaves a second Vite in the tree and `astro check` fails in the Astro
|
|
57
57
|
config. **Bump `.nvmrc` and `engines.node` in `package.json` with Astro** (Astro 7.4
|
|
58
|
-
needs Node 22.23.4 or later, and so does caracms):
|
|
59
|
-
|
|
58
|
+
needs Node 22.23.4 or later, and so does caracms): a host that reads them builds on
|
|
59
|
+
the older Node otherwise (Railway fails with exit 1 and no error text in the build
|
|
60
|
+
log).
|
|
60
61
|
2. **Strip Tina** (section 3): `tina/`, `server.mjs`, `server/`, Tina's routes and admin,
|
|
61
62
|
**including the untracked `tina/__generated__/` and `public/admin/`** (they're in
|
|
62
63
|
`.gitignore`; delete them from disk too, or `init` and the build trip on them).
|
|
@@ -65,11 +66,12 @@ Work on a branch (`caracms-port`, say) in a worktree of the site. Commit after e
|
|
|
65
66
|
`caracms start` serves `dist/server/entry.mjs` and refuses any other mode.
|
|
66
67
|
5. **`npx caracms init`**, read the dry run, then **`npx caracms init --write`** and install
|
|
67
68
|
again (caracms-adopt → section 1). It links the four skills, writes the config, the
|
|
68
|
-
CLAUDE.md block
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
leaves
|
|
72
|
-
environment up with the Railway CLI or MCP, scoped to that environment
|
|
69
|
+
CLAUDE.md block and the health route. **A template site already has a
|
|
70
|
+
`.railway/railway.ts` that describes production: leave it alone and never apply it**
|
|
71
|
+
(applying it can switch production's CDN off). `init` treats a site that has one as
|
|
72
|
+
a Railway site and leaves the file untouched, which is the expected outcome. Set the
|
|
73
|
+
branch environment up with the Railway CLI or MCP, scoped to that environment
|
|
74
|
+
(section 9).
|
|
73
75
|
6. **Copy the script and run it** (section 2) against a **built** checkout of `main`.
|
|
74
76
|
First run with `--scaffold-config` for a starting `caracms.config.ts`. It names a
|
|
75
77
|
component per block before any is written, so nothing builds until they exist: point
|
|
@@ -365,7 +367,9 @@ conversion: caracms's inbox reads the template's records as they are (`<id>.json
|
|
|
365
367
|
|
|
366
368
|
## 9. The branch environment
|
|
367
369
|
|
|
368
|
-
The port is deployed from its branch to its own environment, **never production
|
|
370
|
+
The port is deployed from its branch to its own environment, **never production**. The
|
|
371
|
+
template sites run on Railway, so this section is written for it. A site on another host
|
|
372
|
+
needs the same pieces there (the caracms README's Hosting section has each host):
|
|
369
373
|
|
|
370
374
|
- A new environment in the site's Railway project (`caracms`), with its own `web`, Redis, a
|
|
371
375
|
signed imgproxy and its own submissions bucket. It reads the live media bucket (caracms
|
|
@@ -378,8 +382,8 @@ The port is deployed from its branch to its own environment, **never production*
|
|
|
378
382
|
commits to the port branch and redeploys the environment, and the live site's `main`
|
|
379
383
|
never moves. With the defaults, the first Publish would land caracms's content on the
|
|
380
384
|
live site's `main`.
|
|
381
|
-
- A GitHub
|
|
382
|
-
|
|
385
|
+
- A GitHub credential on the environment, as for any caracms site: a fine-grained token
|
|
386
|
+
on the site's repo, or the App (the caracms README → Hosting → GitHub).
|
|
383
387
|
- Convert with `--recipients <agency address>`, so test submissions never reach the client.
|
|
384
388
|
- **Never `railway config apply` against production**: production's variables are set
|
|
385
389
|
with `railway variables --set` (a template's Railway file can switch its CDN off).
|