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
@@ -29,7 +29,7 @@
29
29
  * `CARACMS_PRELAUNCH` in this same process), so a shared cache never keeps it.
30
30
  */
31
31
  import type { APIRoute } from 'astro';
32
- import { clientIp } from './live/auth/request.ts';
32
+ import { clientIp, NO_GATE_MESSAGE } from './live/auth/request.ts';
33
33
  import { fileAddress, fileKeyOf, fileTypeOf } from './files.ts';
34
34
  import { s3Bucket, type Bucket } from './editor/server/media/bucket.ts';
35
35
  import { readImagesConfig } from './editor/server/media/config.ts';
@@ -67,7 +67,7 @@ export interface FileRouteContext {
67
67
  /** Default `process.env`, read on each request. */
68
68
  env?: Env;
69
69
  production?: boolean;
70
- /** The socket's address (Astro's `clientAddress`), read after `X-Real-IP`. */
70
+ /** The socket's address (Astro's `clientAddress`), read after `X-Real-IP` under `astro dev`. */
71
71
  clientAddress?: () => string | undefined;
72
72
  /** Default the process's own (`filesLimiter`). */
73
73
  limiter?: RateLimiter;
@@ -147,10 +147,15 @@ export async function handleFileRequest(request: Request, ctx: FileRouteContext
147
147
  }
148
148
  }
149
149
 
150
+ // Runs-anywhere Decision 8: a production server not started by `caracms start` has no gate, so no visitor's IP for the limit.
151
+ const production = ctx.production ?? isProductionBuild();
152
+ const ip = clientIp(request, ctx.clientAddress ?? (() => undefined), production);
153
+ if (ip === null) return text(503, NO_GATE_MESSAGE, {}, head);
154
+
150
155
  // 4. The media variables.
151
156
  const env = ctx.env ?? process.env;
152
157
  const log = ctx.log ?? ((line: string) => console.error(line));
153
- const config = readImagesConfig(env, ctx.production ?? isProductionBuild());
158
+ const config = readImagesConfig(env, production);
154
159
  if (config.kind === 'unset') {
155
160
  logOnce(log, `[caracms] files: /files/ answers 503: ${config.message}${config.missing.length ? ` (missing: ${config.missing.join(', ')})` : ''}`);
156
161
  return text(503, UNAVAILABLE_TEXT, {}, head);
@@ -158,7 +163,7 @@ export async function handleFileRequest(request: Request, ctx: FileRouteContext
158
163
 
159
164
  // 5. The visitor's allowance.
160
165
  const limiter = ctx.limiter ?? filesLimiter(env);
161
- if (await limiter.hit(clientIp(request, ctx.clientAddress ?? (() => undefined)))) {
166
+ if (await limiter.hit(ip)) {
162
167
  return text(429, RATE_LIMITED_TEXT, { 'Retry-After': String(Math.ceil(FILES_LIMIT.windowMs / 1000)) }, head);
163
168
  }
164
169
 
@@ -37,8 +37,9 @@
37
37
  */
38
38
  import type { ResolvedSite } from '../config.ts';
39
39
  import { publicOrigin } from '../live/auth/index.ts';
40
- import { clientIp } from '../live/auth/request.ts';
40
+ import { clientIp, NO_GATE_MESSAGE } from '../live/auth/request.ts';
41
41
  import { readCapped } from '../editor/server/media/upload.ts';
42
+ import { isProductionBuild } from '../media.ts';
42
43
  import { readFormSettings, sendNotification, type FormSettings } from './email.ts';
43
44
  import { formLimiter, type FormRateLimiter } from './rate-limit.ts';
44
45
  import { cleanReturn, FILE_KINDS, FILE_TYPES, formsAcceptFiles, HONEYPOT, looksLikeEmail, MAX_FILE_BYTES, MAX_FILES, type ResolvedForm } from './rules.ts';
@@ -65,24 +66,14 @@ export const BAD_EMAIL_MESSAGE = "That email address doesn't look right.";
65
66
  export const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
66
67
  const TURNSTILE_TIMEOUT_MS = 5_000;
67
68
 
68
- /** `SITE_URL` as an address (an http(s) origin), or undefined when it's unset or isn't one. */
69
- function siteUrlFromEnv(env: Env): URL | undefined {
70
- const raw = env['SITE_URL']?.trim();
71
- if (!raw) return undefined;
72
- try {
73
- const url = new URL(raw);
74
- return url.protocol === 'https:' || url.protocol === 'http:' ? url : undefined;
75
- } catch {
76
- return undefined;
77
- }
78
- }
79
-
80
69
  export { cleanReturn };
81
70
 
82
71
  export interface FormContext {
83
72
  site: Pick<ResolvedSite, 'root' | 'forms' | 'timeZone'>;
84
- /** The socket's address (Astro's `clientAddress`), read after `X-Real-IP`. */
73
+ /** The socket's address (Astro's `clientAddress`), read after `X-Real-IP` under `astro dev`. */
85
74
  clientAddress?: () => string | undefined;
75
+ /** A production server: the IP comes from `caracms start`'s gate only (runs-anywhere Decision 8). Default: the build's define. */
76
+ production?: boolean;
86
77
  env?: Env;
87
78
  /** Astro's `site` (the route passes `context.site`): the public origin for the email's editor link. */
88
79
  astroSite?: URL;
@@ -244,7 +235,11 @@ export async function handleForm(request: Request, ctx: FormContext): Promise<Re
244
235
  return reply(405, { error: 'This address takes a form post.' }, { Allow: 'POST' });
245
236
  }
246
237
 
247
- const ip = clientIp(request, ctx.clientAddress ?? (() => undefined));
238
+ const ip = clientIp(request, ctx.clientAddress ?? (() => undefined), ctx.production ?? isProductionBuild());
239
+ if (ip === null) {
240
+ await request.body?.cancel().catch(() => {});
241
+ return reply(503, { error: NO_GATE_MESSAGE });
242
+ }
248
243
  const takesFiles = formsAcceptFiles(ctx.site.forms);
249
244
  const declared = Number(request.headers.get('content-length') ?? NaN);
250
245
  const multipart = (request.headers.get('content-type') ?? '').toLowerCase().includes('multipart/form-data');
@@ -332,7 +327,7 @@ export async function handleForm(request: Request, ctx: FormContext): Promise<Re
332
327
  let email;
333
328
  try {
334
329
  const settings = await (ctx.readSettings ?? (() => readFormSettings(ctx.site.root)))();
335
- const siteUrl = publicOrigin(ctx.astroSite ?? siteUrlFromEnv(env), env);
330
+ const siteUrl = publicOrigin(ctx.astroSite, env);
336
331
  email = await sendNotification({ submission, form, settings, siteUrl, timeZone: ctx.site.timeZone, env, fetch: ctx.fetch, now, log });
337
332
  } catch (error) {
338
333
  email = { status: 'failed' as const, at: now().toISOString(), to: [], error: `Could not read Site Settings: ${error instanceof Error ? error.message : String(error)}` };
@@ -43,6 +43,8 @@ export interface FormFieldConfig {
43
43
  * Plain text, trimmed; blank is the same as leaving it out.
44
44
  */
45
45
  prefix?: string;
46
+ /** A textarea's height in lines (`rows`), a whole number from 1 to 50. Default 6. Textarea only. */
47
+ rows?: number;
46
48
  }
47
49
 
48
50
  /** One form (Contracts → The forms config). Its key is its id: stored on submissions, keys `formRecipients`; never renamed once live. */
@@ -66,6 +68,7 @@ export interface ResolvedFormField {
66
68
  error?: string;
67
69
  placeholder?: string;
68
70
  prefix?: string;
71
+ rows?: number;
69
72
  }
70
73
 
71
74
  /** A form as the package uses it: its fields as a list, in order, each with its type. Plain JSON. */
@@ -122,7 +125,7 @@ export const FILE_ACCEPT = Object.keys(FILE_TYPES)
122
125
  .join(',');
123
126
 
124
127
  /** The types that take a placeholder: the ones a visitor types into. */
125
- const PLACEHOLDER_TYPES: readonly FormFieldType[] = ['text', 'email', 'tel', 'textarea'];
128
+ export const PLACEHOLDER_TYPES: readonly FormFieldType[] = ['text', 'email', 'tel', 'textarea'];
126
129
  /** The types that take a prefix: a one-line box where a fixed lead-in reads naturally ("$", "+353"). */
127
130
  const PREFIX_TYPES: readonly FormFieldType[] = ['text', 'tel'];
128
131
 
@@ -188,6 +191,10 @@ export function checkForms(forms: unknown, fail: (message: string) => never): Re
188
191
  if (placeholder && !PLACEHOLDER_TYPES.includes(type)) fail(`${at}: a ${type} field has no box to type in, so it can't take a placeholder (only ${PLACEHOLDER_TYPES.join(', ')} can).`);
189
192
  const prefix = field.prefix?.trim() ?? '';
190
193
  if (prefix && !PREFIX_TYPES.includes(type)) fail(`${at}: only a text or tel field takes a prefix (this one is ${type}).`);
194
+ if (field.rows !== undefined) {
195
+ if (type !== 'textarea') fail(`${at}: only a textarea takes rows (this one is ${type}).`);
196
+ if (!Number.isInteger(field.rows) || field.rows < 1 || field.rows > 50) fail(`${at}: rows must be a whole number from 1 to 50 (got ${JSON.stringify(field.rows)}).`);
197
+ }
191
198
  fields.push({
192
199
  name,
193
200
  label: field.label!.trim(),
@@ -199,6 +206,7 @@ export function checkForms(forms: unknown, fail: (message: string) => never): Re
199
206
  ...(field.error ? { error: field.error } : {}),
200
207
  ...(placeholder ? { placeholder } : {}),
201
208
  ...(prefix ? { prefix } : {}),
209
+ ...(field.rows !== undefined ? { rows: field.rows } : {}),
202
210
  });
203
211
  }
204
212
  const email = fields.find((f) => f.name === 'email');
package/src/head.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * - **Title:** the page's `seo.title` as written; otherwise, for the home page, Site
6
6
  * Settings' `seo.title`, then the brand's name; otherwise `<document title> · <brand name>`,
7
- * where ` · ` is the config's `titleSeparator` (default ` · `, M8 trial round 2).
7
+ * where ` · ` is the config's `titleSeparator` (default ` · `, M8 trial round 2). With the
8
+ * config's `titleFallback: 'site'` (M9), every page falls back as the home page does.
8
9
  * - **Description and share image** fall back from the page's `seo` to Site Settings'.
9
10
  * - **Canonical link and `og:url`** only with the site's address (`SITE_URL`), and never
10
11
  * in a preview.
@@ -13,7 +14,7 @@
13
14
  * - **The Google tag** is an ID, never a script: `GTM-…` loads Tag Manager, `G-…` loads
14
15
  * gtag.js, anything else nothing. Only in a production build, never in a preview.
15
16
  */
16
- import { DEFAULT_TITLE_SEPARATOR } from './config.ts';
17
+ import { DEFAULT_TITLE_SEPARATOR, type TitleFallback } from './config.ts';
17
18
  import { parseSettingsBrand, type PageDocument } from './document.ts';
18
19
  import { imageUrl, isResizable, type MediaConfig } from './media.ts';
19
20
  import { googleTagKind, parseGoogleTag, parseSeo, SHARE_IMAGE, type Seo, type SeoImage } from './seo.ts';
@@ -33,6 +34,8 @@ export interface HeadInput {
33
34
  listPage?: number;
34
35
  /** The config's `titleSeparator` (ResolvedSite), spaces included; ` · ` when left out. */
35
36
  titleSeparator?: string;
37
+ /** The config's `titleFallback` (ResolvedSite); `'page-and-brand'` when left out. */
38
+ titleFallback?: TitleFallback;
36
39
  }
37
40
 
38
41
  export interface Head {
@@ -73,7 +76,8 @@ export function headOf(input: HeadInput): Head {
73
76
  const docTitle = document.title.trim();
74
77
  // Decision 16: " — page N" straight after the page's own part, before the brand, so ten pages don't share one title.
75
78
  const paged = (text: string) => (input.listPage && input.listPage > 1 && text ? `${text} — page ${input.listPage}` : text);
76
- const fallback = home ? paged(settings.seo.title || settings.brandName || docTitle) : [paged(docTitle), settings.brandName].filter(Boolean).join(input.titleSeparator ?? DEFAULT_TITLE_SEPARATOR);
79
+ const siteWide = home || input.titleFallback === 'site';
80
+ const fallback = siteWide ? paged(settings.seo.title || settings.brandName || docTitle) : [paged(docTitle), settings.brandName].filter(Boolean).join(input.titleSeparator ?? DEFAULT_TITLE_SEPARATOR);
77
81
  const title = paged(own.title ?? '') || fallback;
78
82
  const imageValue = own.image ?? settings.seo.image ?? null;
79
83
  const image = imageValue ? shareImage(imageValue, input.media) : null;
@@ -100,12 +104,20 @@ export function googleTagSnippet(tag: NonNullable<Head['googleTag']>): string {
100
104
  return `<script async src="https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(tag.id)}"></script><script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${id});</script>`;
101
105
  }
102
106
 
107
+ /**
108
+ * An address served from a `build.format: 'file'` prerender (`/about.html`, Home at
109
+ * `/index.html`) as the page's own address (`/about`, `/`): a document's address never ends in
110
+ * `.html` (Urban Return port). Others are kept as they are.
111
+ */
112
+ export const withoutHtml = (path: string): string => path.replace(/(?:\/index)?\.html$/, '') || '/';
113
+
103
114
  /**
104
115
  * PageProps' `path` (MEC trial item 2): the document's address, the same in a public render
105
116
  * and in its preview. A preview's is `previewUrl`, its document's URL (content.ts →
106
117
  * documentUrl; null when it has no page). A public render's is the address it's served at,
107
118
  * decoded, with no trailing slash, and a paged listing's `/page/N` taken off (`listPage` > 1):
108
- * `/journal/page/2` is `/journal`'s page 2.
119
+ * `/journal/page/2` is `/journal`'s page 2. A `build.format: 'file'` prerender's `.html` comes
120
+ * off too (`withoutHtml`).
109
121
  */
110
122
  export function documentPath(input: { preview: boolean; previewUrl: string | null; pathname: string; listPage: number }): string | null {
111
123
  if (input.preview) return input.previewUrl === null ? null : cleanPath(input.previewUrl) || '/';
@@ -116,6 +128,7 @@ export function documentPath(input: { preview: boolean; previewUrl: string | nul
116
128
  // Not valid percent-encoding: kept as it came.
117
129
  }
118
130
  path = cleanPath(path) || '/';
131
+ path = withoutHtml(path);
119
132
  if (input.listPage > 1) path = path.replace(new RegExp(`/page/${input.listPage}$`), '') || '/';
120
133
  return path;
121
134
  }
package/src/index.ts CHANGED
@@ -48,7 +48,8 @@
48
48
  */
49
49
  import type { AstroIntegration } from 'astro';
50
50
  import { fontProviders } from 'astro/config';
51
- import { readFileSync } from 'node:fs';
51
+ import { execFileSync } from 'node:child_process';
52
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
52
53
  import { createRequire } from 'node:module';
53
54
  import { isAbsolute, join, sep } from 'node:path';
54
55
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -59,6 +60,7 @@ import { writeSchemaFiles } from './cli/schemas.ts';
59
60
  import { configFile } from './cli/site.ts';
60
61
  import { readCodeRoutes } from './code-routes-disk.ts';
61
62
  import { GUARD_MIDDLEWARE, LOGIN_ROUTES } from './live/auth/routes.ts';
63
+ import { HOST_COMMIT_VARIABLES } from './live/mode.ts';
62
64
 
63
65
 
64
66
  export type { SiteConfig, ResolvedSite } from './config.ts';
@@ -169,6 +171,25 @@ export function siteAddress(astroSite: string | undefined, env: Record<string, s
169
171
  return { url: `${url.origin}${url.pathname}`.replace(/\/+$/, ''), problem: null };
170
172
  }
171
173
 
174
+ /**
175
+ * Runs-anywhere Decision 9: the commit a build is made from, baked into the server.
176
+ * CARACMS_RUNNING_COMMIT or a host's commit variable at build, else `git rev-parse HEAD`
177
+ * when the build has a `.git` folder (Railway's and most Docker builds don't); null
178
+ * when neither. A value at run time wins over it (live/mode.ts → `runningCommitFrom`).
179
+ */
180
+ export function buildCommit(root: string, env: Record<string, string | undefined> = process.env): string | null {
181
+ for (const name of ['CARACMS_RUNNING_COMMIT', ...HOST_COMMIT_VARIABLES]) {
182
+ const v = env[name]?.trim();
183
+ if (v) return v;
184
+ }
185
+ try {
186
+ const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString().trim();
187
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
172
193
  /** Pre-M7 item 8: Site Settings' redirects, served as 301s on the public site. */
173
194
  export const REDIRECT_MIDDLEWARE = 'redirect-middleware.ts';
174
195
 
@@ -198,10 +219,11 @@ export function contentWatchIgnores(site: Pick<ResolvedSite, 'collections' | 'ro
198
219
  /**
199
220
  * The hosts Astro may take a request's address from (`security.allowedDomains`).
200
221
  * Without them, Astro ignores the Host header and every request reads as
201
- * `localhost:<port>`. Behind Railway's edge that breaks the same-origin checks on the
222
+ * `localhost:<port>`. Behind a host's proxy that breaks the same-origin checks on the
202
223
  * sign-in form and the editor's writes, and the addresses the pages show. Taken from
203
- * Astro's `site`, then Railway's public domain: the same sources as emailed links
204
- * (live/auth/index.ts → `publicOrigin`). A site's own `allowedDomains` wins.
224
+ * Astro's `site` (SITE_URL when the config sets none: `siteAddress`), then Railway's
225
+ * public domain: the same chain as emailed links (live/mode.ts → `addressFrom`;
226
+ * runs-anywhere Decision 7). A site's own `allowedDomains` wins.
205
227
  * Read at build time: Astro puts the list in the server's manifest.
206
228
  */
207
229
  export function trustedDomains(
@@ -301,8 +323,23 @@ async function loadSiteEnv(mode: string, root: string): Promise<Record<string, s
301
323
  return loadEnv(mode, root, '');
302
324
  }
303
325
 
326
+ /**
327
+ * Runs-anywhere Decisions 7 and 9: what a build baked in, also written beside the server's
328
+ * entry (`dist/server/caracms-build.json`) for `caracms start`'s gate, which can't import
329
+ * the site: `{ "site": "https://fernhill.ie" | null, "commit": "<sha>" | null }`. `site` is
330
+ * Astro's `site` as built (the config's, else SITE_URL's: `siteAddress`); `commit` is
331
+ * `buildCommit()`'s.
332
+ */
333
+ export const BUILD_INFO_FILE = 'caracms-build.json';
334
+ export interface BuildInfo {
335
+ site: string | null;
336
+ commit: string | null;
337
+ }
338
+
304
339
  export default function caracms(config: SiteConfig): AstroIntegration {
305
340
  let site: ResolvedSite;
341
+ let buildInfo: BuildInfo | null = null;
342
+ let serverDir: URL | null = null;
306
343
  return {
307
344
  name: 'caracms',
308
345
  hooks: {
@@ -369,8 +406,10 @@ export default function caracms(config: SiteConfig): AstroIntegration {
369
406
  const domains = trustedDomains(astroConfig.site ?? address.url ?? undefined, astroConfig.security?.allowedDomains);
370
407
  if (command === 'build' && !astroConfig.security?.allowedDomains?.length) {
371
408
  if (domains.length) logger.info(`trusting the Host header for ${domains.map((d) => d.hostname).join(', ')}`);
372
- else logger.warn("the site's public address isn't known (set Astro's `site`, or build on Railway): behind a proxy, sign-in and the editor's saves will be refused as cross-origin.");
409
+ else logger.warn("the site's public address isn't known (set SITE_URL, at build and at run time): behind a proxy, sign-in and the editor's saves will be refused as cross-origin.");
373
410
  }
411
+ const commit = dev ? null : buildCommit(site.root);
412
+ if (command === 'build') buildInfo = { site: address.url, commit };
374
413
 
375
414
  updateConfig({
376
415
  ...(!astroConfig.site && address.url ? { site: address.url } : {}),
@@ -382,6 +421,9 @@ export default function caracms(config: SiteConfig): AstroIntegration {
382
421
  define: {
383
422
  __CARACMS_HARFBUZZ__: JSON.stringify(harfbuzz),
384
423
  __CARACMS_PRODUCTION__: JSON.stringify(!dev),
424
+ // Runs-anywhere Decisions 7 and 9: the address and the commit the build was made with (editor/server/mode.ts → bakedAtBuild).
425
+ __CARACMS_SITE__: JSON.stringify(astroConfig.site ?? address.url ?? null),
426
+ __CARACMS_BUILD_COMMIT__: JSON.stringify(commit),
385
427
  __CARACMS_TOKENS_CSS__: JSON.stringify(readFileSync(new URL('./editor/styles/tokens.css', import.meta.url), 'utf8')),
386
428
  },
387
429
  // Installed from npm, the package ships TypeScript and .astro source.
@@ -425,8 +467,12 @@ export default function caracms(config: SiteConfig): AstroIntegration {
425
467
  },
426
468
  // Posts and tags, Decision 15: a production build reads each document once (read.ts). Never in dev.
427
469
  'astro:build:start': () => shareBuildReads(true),
428
- 'astro:build:done': () => shareBuildReads(false),
429
- 'astro:config:done': ({ injectTypes }) => {
470
+ 'astro:build:done': () => {
471
+ shareBuildReads(false);
472
+ if (buildInfo && serverDir && existsSync(serverDir)) writeFileSync(new URL(BUILD_INFO_FILE, serverDir), `${JSON.stringify(buildInfo)}\n`);
473
+ },
474
+ 'astro:config:done': ({ injectTypes, config: done }) => {
475
+ serverDir = done.build.server;
430
476
  injectTypes({ filename: 'caracms.d.ts', content: '/// <reference types="caracms/virtual" />\n' });
431
477
  },
432
478
  },
package/src/links.ts CHANGED
@@ -76,6 +76,41 @@ export function normaliseAddress(url: string): string {
76
76
  * `/`, `/about`, `/journal/my-first-post`), from the store's listing (Phase 2
77
77
  * wires it). Never an error: a warning the field shows.
78
78
  */
79
+ /** M9 Decision 7: the hidden pages in a listing, as `WarningContext.hiddenPages` takes them (address → name). */
80
+ export function hiddenPages(entries: Iterable<{ url: string | null; label: string; hidden?: true }>): Map<string, string> {
81
+ const out = new Map<string, string>();
82
+ for (const e of entries) if (e.hidden && e.url !== null) out.set(normaliseAddress(e.url), e.label);
83
+ return out;
84
+ }
85
+
86
+ /** M9 Decision 7: the hidden documents in a listing, as `WarningContext.hiddenDocuments` takes them (`collection/path` → name and address). */
87
+ export function hiddenDocuments(entries: Iterable<{ collection: string; path: string; url: string | null; label: string; hidden?: true }>): Map<string, { label: string; url: string | null }> {
88
+ const out = new Map<string, { label: string; url: string | null }>();
89
+ for (const e of entries) if (e.hidden) out.set(`${e.collection}/${e.path}`, { label: e.label, url: e.url === null ? null : normaliseAddress(e.url) });
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * M9 Decision 7's warning for a reference (a `reference()` field) to a hidden page, worded as
95
+ * the link one is. Never an error: unhiding the page fixes it.
96
+ */
97
+ export function hiddenReferenceTarget(collection: string, path: string, hidden: ReadonlyMap<string, { label: string; url: string | null }>): string | undefined {
98
+ const doc = hidden.get(`${collection}/${path}`);
99
+ if (!doc) return undefined;
100
+ return `Points at ${doc.url ?? path}, the ${doc.label || path} page, which is hidden from the site. Visitors get its 404 until it's shown again.`;
101
+ }
102
+
103
+ /**
104
+ * M9 Decision 7's warning: a link to a hidden page. `hidden` maps a hidden page's address
105
+ * (normalised) to its name. Never an error: unhiding the page fixes it.
106
+ */
107
+ export function hiddenLinkTarget(href: unknown, hidden: ReadonlyMap<string, string>): string | undefined {
108
+ const path = internalLinkPath(href);
109
+ if (path === null) return undefined;
110
+ const name = hidden.get(path);
111
+ return name === undefined ? undefined : `Links to ${path}, the ${name} page, which is hidden from the site. Visitors get its 404 until it's shown again.`;
112
+ }
113
+
79
114
  export function missingLinkTarget(href: unknown, addresses: Iterable<string | null | undefined>): string | undefined {
80
115
  const path = internalLinkPath(href);
81
116
  if (path === null) return undefined;
@@ -13,12 +13,15 @@ live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on
13
13
  | `merge.ts` | `mergeFile({ base, draft, other })`: the per-key three-way merge, pure and byte-preserving |
14
14
  | `messages.ts` | The commit-message contract (`draftMessage`, `mergeMessage`, `publishMessage`, `parseMessage`), `Person`, `displayName` |
15
15
  | `status.ts` | Pure: `waitingFromCommits()`, `publishState()`, `PublishRecord` |
16
- | `redis.ts` | `RedisClient`; `connectRedis(url)` (ioredis with M1 → R2's retry and idle close) and `memoryRedis()` (the stand-in, with `calls` and `flush()`) |
16
+ | `redis.ts` | `RedisClient`; `connectRedis(url)` (ioredis with M1 → R2's retry and idle close) and `memoryRedis()` (the stand-in, with `calls` and `flush()`). Redis diet: `batch()`, a pipeline sent again whole only when every command in it is safe to send twice |
17
+ | `status-cache.ts` | Redis diet. `StatusCache` (one per process, `git.statusCache`): what the status poll reads, by Redis key, re-read once 30 s old, refreshes single-flight; the modules that write Redis tell it what landed (`string`, `hash`, `add`, `drop`, `landed`) |
18
+ | `notices.ts` | Redis diet. `Notices`: "published as it stood" notices and dismissed "draft kept" ones, written through the status cache |
17
19
  | `backend.ts` | `GitBackend`; `GitHubBackend`, `MemoryBackend` (test helpers: `seed`, `push`, `file`, `deployments`, `beforeRequest`); `StaleHeadError` |
20
+ | `branches.ts` | `Branch` (a role: `main` or `drafts`), `BranchNames` (the refs behind them), `DEFAULT_BRANCHES`. `CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH` (read by `mode.ts`, M9 Decision 6) name the branch Publish commits to and the one autosaves land on; unset, `main` and `drafts`. They're set together or not at all: one alone is a problem, so the mode is unconfigured (`/admin` answers 503 saying both must be set; the public site still serves). Only the backends turn a role into a ref name, so with both set a branch called `main` is never read or moved. `MemoryBackend.refs` is keyed by name |
18
21
  | `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts) |
19
22
  | `webhook.ts` | `handleGithubWebhook()`, behind `POST /_caracms/github` (`../editor/routes/github.ts`) |
20
23
  | `autosaves.ts` | Phase 2. Autosaves in Redis (`caracms:autosave:<file>`, a week; stored compare-and-set, ordered per tab by `X-Caracms-Seq`), the session's last commit (the merge's ancestor) and who last changed a document (Decision 11; no expiry, and `known: false` when neither Redis nor git can say). M5a: a new document's autosave carries `created: { over }` (its ancestor until committed), a deletion is a "gone" autosave (`text` and `document` null, `gone: { title }`) |
21
- | `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll, lapse only once committed, releasing, Edit here; presence |
24
+ | `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll (Redis diet: only when `needsBeat`), lapse only once committed, releasing, Edit here; presence, each record holding for twice its tab's poll interval plus 3 s |
22
25
  | `sweep.ts` | Phase 2. `Sweep`: one commit per tick for everything ready; ticks only while something is pending (no Redis call otherwise); `commitAll` fills `git.commitPending` |
23
26
  | `drafts-store.ts` | Phase 2. The live `DraftsStore`: the copy with autosaves on top (a gone one leaves the file out); `write` takes the lock and stores the autosave. M5a Phase 3: `create` (the lock, then the new document as an autosave) and `delete` (the lock, then a gone autosave, the lock released to drop once committed), with `editor/server/create.ts`'s rules. M5a: any kind of document; Site Settings' autosave is marked `sweepAtOnce` (the sweep commits it on its next tick, no idle wait); `source()` is the copy with every autosave on top (a preview's reads) |
24
27
  | `editor.ts` | Phase 2. `createLiveEditor(runtime)` / `liveEditor(site, settings)`: the store, sweep and locks, and the handlers for write, status, lock, publish, history, try-again, notice |
@@ -65,6 +65,8 @@ export interface AuthOptions {
65
65
 
66
66
  export interface Auth {
67
67
  readonly secure: boolean;
68
+ /** The site's public origin (`AuthOptions.siteOrigin`): the mismatch sentences name it (runs-anywhere Decision 7). */
69
+ readonly siteOrigin: string | null;
68
70
  readonly cookieName: string;
69
71
  readonly whitelist: Whitelist;
70
72
  readonly links: LinkStore;
@@ -97,6 +99,7 @@ export function createAuth(options: AuthOptions): Auth {
97
99
 
98
100
  return {
99
101
  secure: options.secure,
102
+ siteOrigin: options.siteOrigin,
100
103
  cookieName,
101
104
  whitelist,
102
105
  links,
@@ -136,7 +139,7 @@ export function createAuth(options: AuthOptions): Auth {
136
139
  const emailing = options.env['NODE_ENV'] === 'production';
137
140
  const origin = options.siteOrigin ?? (emailing ? null : requestOrigin);
138
141
  if (!origin) {
139
- log("no sign-in link sent: the site's public address isn't known (set Astro's `site`, or run on Railway, which sets RAILWAY_PUBLIC_DOMAIN).");
142
+ log("no sign-in link sent: the site's public address isn't known (set SITE_URL, when the site is built and when it runs).");
140
143
  return 'not-sent';
141
144
  }
142
145
  const token = await links.issue(email, next);
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import type { Person } from '../messages.ts';
24
24
  import type { Auth, SignedOutReason } from './auth.ts';
25
- import { isRead, isSameOrigin } from './request.ts';
25
+ import { addressMismatch, isRead, isSameOrigin } from './request.ts';
26
26
  import { cleanNext, LOGIN_PATH, loginHref } from './session.ts';
27
27
 
28
28
  /** Whether the guard looks at this path at all. */
@@ -93,7 +93,8 @@ export function guard(request: Request, auth: Auth): GuardResult {
93
93
  const api = isApi(raw);
94
94
 
95
95
  if (!isRead(request.method) && !isSameOrigin(request)) {
96
- const error = 'This only takes requests from the editor on this site.';
96
+ // Runs-anywhere Decision 7: the editor's own write, refused because the site's address isn't the one in use.
97
+ const error = addressMismatch(request, auth.siteOrigin) ?? 'This only takes requests from the editor on this site.';
97
98
  if (api) return { kind: 'respond', response: json(403, { error, code: 'cross-origin' } satisfies ForbiddenBody) };
98
99
  return { kind: 'respond', response: new Response(error, { status: 403, headers: { 'Content-Type': 'text/plain; charset=utf-8', ...NO_STORE } }) };
99
100
  }
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { SITE_SETTINGS_FILE, type ResolvedSite } from '../../config.ts';
21
21
  import { brandFrom } from '../../editor/brand.ts';
22
- import type { LiveSettings } from '../mode.ts';
22
+ import { addressFrom, type LiveSettings } from '../mode.ts';
23
23
  import { liveRuntime } from '../runtime.ts';
24
24
  import { createAuth, type Auth } from './auth.ts';
25
25
 
@@ -27,13 +27,12 @@ const SHARED = Symbol.for('caracms.auth');
27
27
  type Shared = { [SHARED]?: Promise<Auth> };
28
28
 
29
29
  /**
30
- * The site's public origin for emailed links: Astro's `site`, else Railway's public
31
- * domain. Never a request's Host header (auth.ts → `siteOrigin`).
30
+ * The site's public origin for emailed links: Astro's `site`, then SITE_URL, then
31
+ * Railway's public domain (runs-anywhere Decision 7: the chain is mode.ts → `addressFrom`).
32
+ * Never a request's Host header (auth.ts → `siteOrigin`).
32
33
  */
33
34
  export function publicOrigin(astroSite: URL | undefined, env: Record<string, string | undefined> = process.env): string | null {
34
- if (astroSite) return astroSite.origin;
35
- const domain = env['RAILWAY_PUBLIC_DOMAIN']?.trim();
36
- return domain ? `https://${domain}` : null;
35
+ return addressFrom(env, astroSite?.href);
37
36
  }
38
37
 
39
38
  /** The process's Auth, built on first use over the shared live runtime. */
@@ -50,7 +49,7 @@ export function authFor(site: ResolvedSite, settings: LiveSettings, astroSite?:
50
49
  readSettings: () => rt.git.read(SITE_SETTINGS_FILE),
51
50
  // M5a Decision 10: Site Settings' brand name as committed in `drafts`.
52
51
  siteName: () => brandFrom(rt.git.read(SITE_SETTINGS_FILE), site).name,
53
- siteOrigin: publicOrigin(astroSite),
52
+ siteOrigin: settings.address ?? publicOrigin(astroSite),
54
53
  env: process.env,
55
54
  now: rt.now,
56
55
  }),
@@ -37,7 +37,7 @@
37
37
  */
38
38
  import type { Auth } from './auth.ts';
39
39
  import { normaliseEmail } from './allow.ts';
40
- import { clientIp, isRead, isSameOrigin } from './request.ts';
40
+ import { addressMismatch, addressNotice, clientIp, isRead, isSameOrigin, NO_GATE_MESSAGE } from './request.ts';
41
41
  import { backupPerson, cleanNext, DEFAULT_NEXT, LOGIN_PATH, loginHref, safeEqual } from './session.ts';
42
42
  import {
43
43
  backupView,
@@ -115,8 +115,17 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
115
115
  extra.Allow = allow;
116
116
  return page(405, 'Not allowed', messageView('Not allowed', 'Use the form on the sign-in page.'));
117
117
  };
118
- const crossOrigin = () => page(403, 'Not allowed', messageView('Not allowed', 'Send the form from the sign-in page.'));
118
+ // Runs-anywhere Decision 7: refused because the site's address isn't the one in use, the sentence says so, naming SITE_URL.
119
+ const mismatch = () => addressMismatch(request, auth.siteOrigin);
120
+ const crossOrigin = () => page(403, 'Not allowed', messageView('Not allowed', mismatch() ?? 'Send the form from the sign-in page.'));
119
121
  const wantsJson = (request.headers.get('accept') ?? '').includes('application/json');
122
+ // Runs-anywhere Decision 8: on a production server (`auth.secure` is production), the IP
123
+ // comes from `caracms start`'s gate alone; without it, no sign-in at all (acceptance check 17).
124
+ const ip = clientIp(request, ctx.clientAddress, auth.secure);
125
+ if (ip === null) {
126
+ if (pathname === SIGNIN_PATH || wantsJson) return json(503, { error: NO_GATE_MESSAGE, reason: 'failed' });
127
+ return page(503, 'Sign-in is off', messageView('Sign-in is off', NO_GATE_MESSAGE));
128
+ }
120
129
 
121
130
  try {
122
131
  // ────────────────────────────────────────────────────────────── confirm
@@ -138,7 +147,7 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
138
147
  // ─────────────────────────────────────────────────────────────── signin
139
148
  if (pathname === SIGNIN_PATH) {
140
149
  if (method !== 'POST') return notAllowed('POST');
141
- if (!isSameOrigin(request)) return json(403, { error: 'Sign in from the sign-in page.', reason: 'failed' });
150
+ if (!isSameOrigin(request)) return json(403, { error: mismatch() ?? 'Sign in from the sign-in page.', reason: 'failed' });
142
151
  let body: Record<string, unknown>;
143
152
  try {
144
153
  const parsed: unknown = JSON.parse(await readBody(request));
@@ -161,7 +170,7 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
161
170
  // Every attempt counts, the right password included, so the limit can't confirm a guess.
162
171
  let over = true;
163
172
  try {
164
- over = await auth.limits.backupAttemptOver(clientIp(request, ctx.clientAddress));
173
+ over = await auth.limits.backupAttemptOver(ip);
165
174
  } catch (error) {
166
175
  auth.log(`could not count a backup sign-in attempt, so it was refused: ${error instanceof Error ? error.message : String(error)}`);
167
176
  }
@@ -179,7 +188,7 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
179
188
  // ────────────────────────────────────────────────────────────── signout
180
189
  if (pathname === SIGNOUT_PATH) {
181
190
  if (method !== 'POST') return notAllowed('POST');
182
- if (!isSameOrigin(request)) return wantsJson ? json(403, { error: 'Sign out from the editor.', reason: 'cross-origin' }) : crossOrigin();
191
+ if (!isSameOrigin(request)) return wantsJson ? json(403, { error: mismatch() ?? 'Sign out from the editor.', reason: 'cross-origin' }) : crossOrigin();
183
192
  const location = loginHref({ signedout: '1' });
184
193
  return wantsJson ? json(200, { ok: true, location }, auth.clearCookie()) : redirect(location, auth.clearCookie());
185
194
  }
@@ -199,8 +208,9 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
199
208
  const showBackup = query.get('backup') === '1' && auth.backup.enabled;
200
209
  if (isRead(method)) {
201
210
  const next = cleanNext(query.get('next'));
202
- if (showBackup) return page(200, 'Backup account', backupView({ next }), { script: true });
203
- return page(200, 'Sign in', emailView({ next, signedOut: query.get('signedout') === '1', backupEnabled: auth.backup.enabled }));
211
+ const notice = addressNotice(request, auth.siteOrigin);
212
+ if (showBackup) return page(200, 'Backup account', backupView({ next, notice }), { script: true });
213
+ return page(200, 'Sign in', emailView({ next, signedOut: query.get('signedout') === '1', backupEnabled: auth.backup.enabled, notice }));
204
214
  }
205
215
  if (method !== 'POST') return notAllowed('GET, HEAD, POST');
206
216
  if (!isSameOrigin(request)) return crossOrigin();
@@ -221,7 +231,6 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
221
231
  }
222
232
 
223
233
  // Every address counts against the limits, allowed or not, so the time taken is the same.
224
- const ip = clientIp(request, ctx.clientAddress);
225
234
  let over = true;
226
235
  try {
227
236
  over = await auth.limits.linkRequestOver(email, ip);
@@ -8,7 +8,11 @@
8
8
  * even building the runtime, so the public site never waits on GitHub or Redis.
9
9
  * A guarded path that passes records the person (`setPerson`) for the route.
10
10
  * - **Unconfigured** never reaches here in a correct setup (Phase 2's middleware
11
- * answers first); if it does, a guarded path gets a 503 rather than through.
11
+ * answers first); if it does, a guarded path gets a 503 rather than through. The
12
+ * public form endpoint's POST is the exception: Phase 2 lets it through, and so does this, since
13
+ * a site's forms work without the editor (M6 Decision 5; the Urban Return port found it
14
+ * answering 503 here until the GitHub App's variables were set). A GET to it is as
15
+ * unavailable as any other guarded path, as live mode's let-through is POST-only too.
12
16
  *
13
17
  * The mode is Phase 2's `editorMode()`, so the two agree on live and local.
14
18
  */
@@ -16,7 +20,7 @@ import type { MiddlewareHandler } from 'astro';
16
20
  import { site } from 'virtual:caracms/config';
17
21
  import { editorMode } from '../../editor/server/mode.ts';
18
22
  import { setPerson } from '../locals.ts';
19
- import { guard, isGuardedPath } from './guard.ts';
23
+ import { guard, isFormEndpoint, isGuardedPath } from './guard.ts';
20
24
  import { authFor } from './index.ts';
21
25
 
22
26
  const unavailable = (text: string) => new Response(text, { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'private, no-store' } });
@@ -25,7 +29,7 @@ export const onRequest: MiddlewareHandler = async (context, next) => {
25
29
  if (!isGuardedPath(context.url.pathname)) return next();
26
30
  const mode = editorMode();
27
31
  if (mode.kind === 'local') return next();
28
- if (mode.kind !== 'live') return unavailable("The editor isn't set up on this server yet.");
32
+ if (mode.kind !== 'live') return isFormEndpoint(context.url.pathname) && context.request.method === 'POST' ? next() : unavailable("The editor isn't set up on this server yet.");
29
33
 
30
34
  let auth;
31
35
  try {