create-astroid 0.1.2 → 0.2.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.
@@ -2,11 +2,58 @@
2
2
  -- Idempotent. Apply once after migrations:
3
3
  -- wrangler d1 execute DB --file seed/home.seed.sql --remote
4
4
  -- (drop --remote for the local dev D1)
5
- INSERT OR IGNORE INTO pages (slug, title, body, status, sort_order, created_at, updated_at)
5
+ --
6
+ -- `sections` is the page-builder array — the same shape the on-canvas editor
7
+ -- reads and the server validates against the section catalog on write. Each item
8
+ -- is `{"_type": …, …fields, "_settings": {…}}`, and `_settings` holds TOKENS
9
+ -- (colorway/align), never CSS, so a re-theme needs no content change.
10
+ --
11
+ -- Seeding it is deliberate: an empty array renders an empty page, and a blank
12
+ -- canvas is a worse first run than having something real to click on and edit.
13
+ --
14
+ -- Full-text search (the editor's Pages search) is kept in sync on PUBLISH, so a
15
+ -- row inserted with raw SQL like this is NOT in the index until you either
16
+ -- publish an edit to it or backfill once with `POST /api/louise/pages/reindex`
17
+ -- (signed in). The page renders fine either way — only in-editor search is
18
+ -- affected.
19
+ --
20
+ -- To override just the <head> title/description (not the on-page H1), add
21
+ -- `seo_title` / `seo_description` columns below. `seo_title` is run through the
22
+ -- config's title template, which already appends the brand — so set the page
23
+ -- part only ("Pricing"), not "Pricing | __BRAND_NAME__".
24
+ INSERT OR IGNORE INTO pages (slug, title, body, sections, status, sort_order, created_at, updated_at)
6
25
  VALUES (
7
26
  'home',
8
27
  '__BRAND_NAME__',
9
28
  '<p>Welcome to __BRAND_NAME__. Sign in at <code>/login</code>, switch on edit mode, and change this text in place — then hit Publish.</p>',
29
+ json_array(
30
+ json_object(
31
+ '_type', 'hero',
32
+ 'heading', '__BRAND_NAME__',
33
+ 'subheading', 'Edit this in place — sign in, switch on edit mode, and type.',
34
+ 'ctaLabel', 'Get in touch',
35
+ 'ctaHref', '/contact',
36
+ '_settings', json_object('colorway', 'base', 'align', 'center')
37
+ ),
38
+ json_object(
39
+ '_type', 'featureGrid',
40
+ 'heading', 'What we do',
41
+ 'items', json_array(
42
+ json_object('title', 'First thing', 'body', 'Describe it here.'),
43
+ json_object('title', 'Second thing', 'body', 'And this one.'),
44
+ json_object('title', 'Third thing', 'body', 'And this one too.')
45
+ ),
46
+ '_settings', json_object('colorway', 'base')
47
+ ),
48
+ json_object(
49
+ '_type', 'cta',
50
+ 'heading', 'Ready when you are',
51
+ 'body', 'Swap this copy for your own.',
52
+ 'ctaLabel', 'Contact us',
53
+ 'ctaHref', '/contact',
54
+ '_settings', json_object('colorway', 'brand', 'align', 'center')
55
+ )
56
+ ),
10
57
  'published',
11
58
  0,
12
59
  unixepoch(),
@@ -7,6 +7,7 @@
7
7
  // `pnpm seed:editors`; add more from the Users panel (editorsRoute), never by
8
8
  // editing env. Magic-link + passkey come from `getLouiseAuth`; no passwords.
9
9
 
10
+ import { astroidMailTheme, magicLinkEmail } from "astroidjs";
10
11
  import { env } from "cloudflare:workers";
11
12
  import {
12
13
  type EditorSession,
@@ -16,9 +17,15 @@ import {
16
17
  type MagicLinkEmail,
17
18
  resolveEditorSession,
18
19
  } from "louise-toolkit/auth";
20
+ import astroidConfig from "../astroid.config.js";
19
21
 
20
22
  const BRAND = "__BRAND_NAME__";
21
23
 
24
+ // Transactional mail theme, derived from your config's brand colours: the
25
+ // palette, the masthead colour band, and a contrast-corrected accent. Pass a
26
+ // second argument to override any slot.
27
+ const MAIL_THEME = astroidMailTheme(astroidConfig);
28
+
22
29
  /** The DB-managed editor allowlist: every admin `user` row. */
23
30
  async function resolveAdmins(): Promise<string[]> {
24
31
  const { results } = await env.DB.prepare(
@@ -27,12 +34,9 @@ async function resolveAdmins(): Promise<string[]> {
27
34
  return results.map((r) => r.email);
28
35
  }
29
36
 
30
- function renderMagicLinkEmail({ url }: { url: string; toEmail: string }): MagicLinkEmail {
31
- return {
32
- subject: `Sign in to ${BRAND}`,
33
- text: `Sign in to ${BRAND}:\n\n${url}\n\nThis link expires in 15 minutes. If you didn't request it, ignore this email.`,
34
- html: `<p>Sign in to <strong>${BRAND}</strong>:</p><p><a href="${url}">Open the ${BRAND} editor</a></p><p style="color:#666">This link expires in 15 minutes. If you didn't request it, ignore this email.</p>`,
35
- };
37
+ /** The branded sign-in email — Astroid's template over your mail theme. */
38
+ function renderMagicLinkEmail({ url, toEmail }: { url: string; toEmail: string }): MagicLinkEmail {
39
+ return magicLinkEmail(MAIL_THEME, { url, toEmail });
36
40
  }
37
41
 
38
42
  /** The request-scoped Better Auth instance (magic-link + passkey, DB allowlist). */
@@ -9,8 +9,25 @@
9
9
  //
10
10
  // Pass `versionedPageId` from a page whose `<Editable>` fields should stage drafts
11
11
  // + publish (the layout forwards it). Omit it and the bar is Settings-only.
12
+ import astroidConfig from "../../astroid.config.js";
13
+
12
14
  const { editMode, editor } = Astro.locals;
13
- const { versionedPageId } = Astro.props as { versionedPageId?: number };
15
+ const { versionedPageId, sections } = Astro.props as {
16
+ versionedPageId?: number;
17
+ sections?: unknown;
18
+ };
19
+
20
+ // The section editor needs the page's CURRENT sections array to seed its store.
21
+ // It rides as JSON on the boot marker for the same reason the other values do:
22
+ // a `define:vars` script would be `is:inline` with per-request content, which
23
+ // can't be hashed into `script-src` and so would be CSP-blocked.
24
+ const sectionsJson = Array.isArray(sections) ? JSON.stringify(sections) : undefined;
25
+
26
+ // Is the live multi-editor session on? Read from the config rather than baked in
27
+ // at scaffold time, so adding `realtime` to `modules` later takes effect here
28
+ // too — `astroid generate` regenerates the worker and scaffolds the Durable
29
+ // Object, but this file is yours and is never rewritten.
30
+ const realtimeOn = (astroidConfig.modules ?? []).includes("realtime");
14
31
  ---
15
32
 
16
33
  {
@@ -28,6 +45,8 @@ const { versionedPageId } = Astro.props as { versionedPageId?: number };
28
45
  hidden
29
46
  data-louise-user={editor?.name ?? "Editor"}
30
47
  data-louise-versioned-page={versionedPageId != null ? String(versionedPageId) : undefined}
48
+ data-louise-sections={sectionsJson}
49
+ data-louise-realtime={realtimeOn ? "1" : undefined}
31
50
  />
32
51
  <script>
33
52
  // Lazy-import the editor client only in edit mode. `mountLouise` renders
@@ -41,18 +60,42 @@ const { versionedPageId } = Astro.props as { versionedPageId?: number };
41
60
  Promise.all([
42
61
  import("louise-toolkit/client"),
43
62
  import("louise-toolkit/client/settings"),
63
+ import("astroidjs/components/sections"),
44
64
  ])
45
- .then(([client, settings]) => {
65
+ .then(([client, settings, lib]) => {
46
66
  settings.mountSettings({
47
67
  userName: el.dataset.louiseUser || "Editor",
48
68
  users: true,
49
69
  });
50
70
  const versioned = el.dataset.louiseVersionedPage;
71
+ const pageId = versioned ? Number(versioned) : undefined;
51
72
  client.mountLouise({
52
73
  onOpenSettings: () =>
53
74
  window.dispatchEvent(new Event(settings.OPEN_SETTINGS_EVENT)),
54
- versionedPageId: versioned ? Number(versioned) : undefined,
75
+ versionedPageId: pageId,
76
+ // Live multi-editor session (ADR 0002). Present only when the
77
+ // realtime module is on; the client degrades to the debounced
78
+ // fetch auto-save whenever it is off OR the socket can't open,
79
+ // so this is an accelerator, never a dependency.
80
+ realtime: el.dataset.louiseRealtime === "1",
55
81
  });
82
+
83
+ // The on-canvas section editor. It edits the SAME catalog the
84
+ // server validates writes against (astroidSectionCatalog), so a
85
+ // field can't be editable-but-invalid.
86
+ //
87
+ // Mounted only when this page actually rendered sections: the host
88
+ // is emitted by <Sections>, and the array rides on the boot marker.
89
+ // A page with neither (the login page, a bespoke route) skips it.
90
+ const host = document.querySelector<HTMLElement>("[data-louise-sections-host]");
91
+ const raw = el.dataset.louiseSections;
92
+ if (host && raw && pageId != null) {
93
+ client.mountSections(host, {
94
+ catalog: lib.astroidSectionCatalog,
95
+ pageId,
96
+ initial: JSON.parse(raw),
97
+ });
98
+ }
56
99
  })
57
100
  .catch((err) => console.error("[louise] editor bootstrap failed", err));
58
101
  }
@@ -13,24 +13,73 @@ type CloudflareEnv = {
13
13
  MEDIA: R2Bucket;
14
14
  /** Public base for media URLs (the framework-agnostic media route reads this). */
15
15
  MEDIA_URL: string;
16
+ /** The site's public origin — declared in wrangler.jsonc `vars`. Read by the
17
+ * health scan to know what to crawl, and by anything else that needs an
18
+ * absolute URL from inside the Worker. */
19
+ SITE_URL: string;
20
+ /**
21
+ * Edge caching for published pages — `"true"` to enable (ADR 0004). A string,
22
+ * not a boolean, because wrangler `vars` are strings.
23
+ *
24
+ * Off by default, and the default is the safe state. Enable on a PREVIEW
25
+ * deploy and walk the activation runbook first: `caches.default` is not
26
+ * cleared by Cloudflare Dev Mode or Purge Everything.
27
+ */
28
+ ASTROID_EDGE_CACHE: string;
16
29
  /** Cloudflare Images — upload dimensions + server-side transforms. */
17
30
  IMAGES: ImagesBinding;
31
+ /**
32
+ * Analytics Engine dataset for real-visitor Core Web Vitals. The ingest route
33
+ * accepts-and-drops without it, so leaving it unbound is safe — the Health
34
+ * panel's CWV badge just stays "not measured yet".
35
+ */
36
+ VITALS?: AnalyticsEngineDataset;
37
+ /**
38
+ * Workers AI — the editor's rewrite + SEO-suggest assists and alt-text
39
+ * generation on upload. Every consumer degrades on its own: the routes answer
40
+ * 503 without it (the client then hides the button) and alt-text generation is
41
+ * best-effort, so a project that never wants AI simply never calls it.
42
+ */
43
+ AI: Ai;
18
44
  /** KV — the security rate limiter. */
19
45
  RL: KVNamespace;
20
46
  /** KV — the autosave draft write-buffer (optional; falls back to direct D1). */
21
47
  DRAFTS?: KVNamespace;
22
- /** Cloudflare Email Sending — magic-link + notification email. */
23
- EMAIL: SendEmail;
48
+ /**
49
+ * Cloudflare Email Sending — magic-link + notification email. Typed as the
50
+ * toolkit's `EmailSender`, i.e. the modern object-form `send({to, from, …})`
51
+ * API. NOT workers-types' `SendEmail`, which is the legacy `cloudflare:email`
52
+ * binding: that one routes through Email Routing and can only deliver to
53
+ * *verified* destination addresses.
54
+ */
55
+ EMAIL: import("louise-toolkit/email").EmailSender;
24
56
  /** Static assets (bound by the @astrojs/cloudflare adapter). */
25
57
  ASSETS: Fetcher;
26
- /** Signs Better Auth sessions (`wrangler secret put SESSION_SECRET`). */
58
+ /**
59
+ * Signs Better Auth sessions (`wrangler secret put SESSION_SECRET`). A
60
+ * Secrets Store binding works here too — Louise reads either shape.
61
+ */
27
62
  SESSION_SECRET: string;
63
+ /**
64
+ * Turnstile secret. Scaffolded with the DUMMY_REPLACE_ME sentinel, which
65
+ * reads as "not configured" — captcha stays off until this AND a real
66
+ * (non-test) site key are both set, so half-provisioning can't lock you out.
67
+ */
68
+ TURNSTILE_SECRET?: string;
69
+ /** Public Turnstile site key; the always-passing test key keeps captcha off. */
70
+ TURNSTILE_SITE_KEY?: string;
71
+ /** Cloudflare account id — ONLY for reading Core Web Vitals back out of the
72
+ * Analytics Engine SQL API (it is account-scoped and has no binding).
73
+ * Unset → the Health panel's CWV badge stays "not measured yet". */
74
+ CF_ACCOUNT_ID?: string;
75
+ /** API token with Account Analytics:Read, for the same query. */
76
+ CF_API_TOKEN?: string;
28
77
  /** First editor's email — seeded as an admin, then part of the DB allowlist. */
29
78
  OWNER_EMAIL: string;
30
79
  /** Optional second bootstrap editor (e.g. your engineer). */
31
80
  ENGINEER_EMAIL?: string;
32
81
  /** `from` address for outbound email. */
33
- MAIL_FROM: string;
82
+ MAIL_FROM: string;__ASTROID_ENV_BINDINGS__
34
83
  };
35
84
 
36
85
  // `env` from `cloudflare:workers` is typed as the augmentable `Cloudflare.Env`.
@@ -44,6 +93,6 @@ declare namespace App {
44
93
  /** Resolved editor session (authorizes writes). Null when not signed in. */
45
94
  editor: import("louise-toolkit/auth").EditorSession | null;
46
95
  /** Whether the page should render edit affordances. */
47
- editMode: boolean;
96
+ editMode: boolean;__ASTROID_PORTAL_LOCALS__
48
97
  }
49
98
  }
@@ -1,18 +1,80 @@
1
1
  ---
2
2
  // The base HTML shell. `Astro.locals.editMode` is set by the generated
3
3
  // middleware.ts (from your auth seam); LouiseEdit boots the editor when it's on.
4
+ //
5
+ // SEO is settings-driven: the `site_settings` row supplies the site-wide title,
6
+ // description, and OG image, and a page overrides any of them via props.
7
+ // <StructuredData> emits the schema.org graph (business + WebSite), with the
8
+ // business `@type` chosen from your archetype.
9
+ import Seo from "astroidjs/components/Seo.astro";
10
+ import StructuredData from "astroidjs/components/StructuredData.astro";
11
+ import { env } from "cloudflare:workers";
12
+ import astroidConfig from "../../astroid.config.js";
4
13
  import LouiseEdit from "../components/LouiseEdit.astro";
5
14
  import "../styles/site.css";
6
15
 
7
16
  interface Props {
8
17
  title?: string;
9
18
  description?: string;
19
+ /** Absolute or root-relative share image; falls back to the site default. */
20
+ ogImage?: string;
21
+ /** Keep this page out of search indexes (cart, checkout, account). */
22
+ noindex?: boolean;
10
23
  /** A `pages` row id whose `<Editable>` fields stage drafts + publish. */
11
24
  versionedPageId?: number;
25
+ /** The page's stored `sections` array, forwarded to the on-canvas section
26
+ * editor so it can seed its store with what's actually rendered. */
27
+ sections?: unknown;
12
28
  }
13
29
 
14
- const { title = "__BRAND_NAME__", description, versionedPageId } = Astro.props;
30
+ const { title, description, ogImage, noindex, versionedPageId, sections } = Astro.props;
15
31
  const { editMode } = Astro.locals;
32
+
33
+ // The editable settings row drives the head. Missing (pre-provision, pre-seed)
34
+ // falls back to the brand name from your config, so a fresh clone still renders
35
+ // a sane <title> rather than an empty one.
36
+ type Settings = {
37
+ site_name: string | null;
38
+ tagline: string | null;
39
+ meta_description: string | null;
40
+ default_og_image_url: string | null;
41
+ logo_url: string | null;
42
+ contact_email: string | null;
43
+ contact_phone: string | null;
44
+ contact_address: string | null;
45
+ social_links: string | null;
46
+ disable_indexing: number | null;
47
+ };
48
+ let row: Settings | null = null;
49
+ try {
50
+ row = await env.DB.prepare(
51
+ `SELECT site_name, tagline, meta_description, default_og_image_url, logo_url,
52
+ contact_email, contact_phone, contact_address, social_links, disable_indexing
53
+ FROM site_settings WHERE id = 1`,
54
+ ).first<Settings>();
55
+ } catch {
56
+ // No DB binding yet — fall through to the config defaults below.
57
+ }
58
+
59
+ const settings = {
60
+ siteName: row?.site_name ?? astroidConfig.theme.name,
61
+ tagline: row?.tagline,
62
+ metaDescription: row?.meta_description,
63
+ defaultOgImageUrl: row?.default_og_image_url,
64
+ logoUrl: row?.logo_url,
65
+ contactEmail: row?.contact_email,
66
+ contactPhone: row?.contact_phone,
67
+ contactAddress: row?.contact_address,
68
+ // Stored as a JSON string; a malformed value must not take down the page.
69
+ socialLinks: (() => {
70
+ try {
71
+ return row?.social_links ? JSON.parse(row.social_links) : undefined;
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ })(),
76
+ disableIndexing: Boolean(row?.disable_indexing),
77
+ };
16
78
  ---
17
79
 
18
80
  <!doctype html>
@@ -20,11 +82,29 @@ const { editMode } = Astro.locals;
20
82
  <head>
21
83
  <meta charset="utf-8" />
22
84
  <meta name="viewport" content="width=device-width, initial-scale=1" />
23
- <title>{title}</title>
24
- {description && <meta name="description" content={description} />}
85
+ <Seo
86
+ settings={settings}
87
+ title={title}
88
+ description={description}
89
+ ogImage={ogImage}
90
+ noindex={noindex}
91
+ titleTemplate={astroidConfig.seo?.titleTemplate}
92
+ twitterHandle={astroidConfig.seo?.twitterHandle}
93
+ locale={astroidConfig.seo?.locale}
94
+ />
95
+ <StructuredData config={astroidConfig} settings={settings} />
25
96
  </head>
26
97
  <body class="min-h-screen bg-base-100 text-base-content" data-edit-mode={editMode ? "" : undefined}>
27
98
  <slot />
28
- <LouiseEdit versionedPageId={versionedPageId} />
99
+ <LouiseEdit versionedPageId={versionedPageId} sections={sections} />
100
+ {
101
+ /* Real-visitor Core Web Vitals. A static file from public/, so it is
102
+ same-origin and already covered by `script-src 'self'` — an inline
103
+ script with generated content couldn't be hashed into the CSP.
104
+ `defer` keeps it off the critical path; it reports once per page on
105
+ visibilitychange via sendBeacon. Skipped in edit mode: an editor's
106
+ session is not representative field data. */
107
+ !editMode && <script src="/vitals.js" defer />
108
+ }
29
109
  </body>
30
110
  </html>
@@ -0,0 +1,122 @@
1
+ ---
2
+ // The public contact form.
3
+ //
4
+ // The `contact` section — and the scaffolded hero/cta defaults — link here, so
5
+ // without this page a fresh site ships a dead link. It posts to the generated
6
+ // worker's `formRoute`, mounted at /api/louise/forms/inquiries: same-origin POST
7
+ // only, a honeypot field plus a minimum time-on-page, a KV rate limit, then
8
+ // validation against the inquiries form before the row is written. Submissions
9
+ // land in the Inquiries tab of the editor drawer.
10
+ //
11
+ // Yours to edit — the copy, the fields, the layout. (If you remove the `contact`
12
+ // section from astroid.config.ts, the form route stops being mounted and this
13
+ // page's POST will 404; delete the page too, or keep the section.)
14
+ import Site from "../layouts/Site.astro";
15
+
16
+ export const prerender = false;
17
+
18
+ // Never cache a form page: `set(false)` emits `no-store`, so neither the worker's
19
+ // edge-cache layer nor anything downstream holds on to it.
20
+ Astro.cache.set(false);
21
+
22
+ // Stamped at render and posted back, so the form route's `minSeconds` heuristic
23
+ // can tell a person filling in a form from a bot that submitted the instant it
24
+ // loaded.
25
+ const renderedAt = Date.now();
26
+ ---
27
+
28
+ <Site title="Contact" description="Get in touch.">
29
+ <main class="mx-auto max-w-xl px-6 py-16">
30
+ <h1 class="text-4xl font-bold">Get in touch</h1>
31
+ <p class="mt-3 opacity-80">Send us a message and we'll get back to you.</p>
32
+
33
+ <form id="contact-form" class="mt-8 grid gap-4" method="post" action="/api/louise/forms/inquiries">
34
+ <input type="hidden" name="louise_ts" value={renderedAt} />
35
+ {/* The honeypot the form route checks. Hidden from people, left in the DOM
36
+ for bots; a non-empty value gets a fake 201 and is never stored. */}
37
+ <div class="hidden" aria-hidden="true">
38
+ <label for="website">Website</label>
39
+ <input id="website" name="website" type="text" tabindex="-1" autocomplete="off" />
40
+ </div>
41
+
42
+ <div class="grid gap-4 sm:grid-cols-2">
43
+ <label class="form-control">
44
+ <span class="label-text">First name</span>
45
+ <input name="firstName" type="text" autocomplete="given-name" class="input input-bordered w-full" />
46
+ </label>
47
+ <label class="form-control">
48
+ <span class="label-text">Last name</span>
49
+ <input name="lastName" type="text" autocomplete="family-name" class="input input-bordered w-full" />
50
+ </label>
51
+ </div>
52
+
53
+ <label class="form-control">
54
+ <span class="label-text">Email <span aria-hidden="true">*</span></span>
55
+ <input name="email" type="email" required autocomplete="email" class="input input-bordered w-full" />
56
+ </label>
57
+
58
+ <label class="form-control">
59
+ <span class="label-text">Regarding</span>
60
+ <input name="regarding" type="text" class="input input-bordered w-full" />
61
+ </label>
62
+
63
+ <label class="form-control">
64
+ <span class="label-text">Message <span aria-hidden="true">*</span></span>
65
+ <textarea name="message" required rows="6" class="textarea textarea-bordered w-full"></textarea>
66
+ </label>
67
+
68
+ <button type="submit" class="btn btn-primary justify-self-start">Send</button>
69
+
70
+ {/* Announced, not merely coloured: a status change that's only visual is
71
+ invisible to a screen reader. */}
72
+ <p id="contact-status" class="text-sm" role="status" aria-live="polite"></p>
73
+ </form>
74
+ </main>
75
+ </Site>
76
+
77
+ <script>
78
+ // Progressive enhancement: without JS the form still submits (the route accepts
79
+ // form-encoded bodies) — the visitor just lands on the JSON response. A
80
+ // processed <script> (no is:inline) is bundled to a same-origin file, which is
81
+ // what keeps it inside the CSP's `script-src 'self'`.
82
+ const form = document.querySelector<HTMLFormElement>("#contact-form");
83
+ const status = document.querySelector<HTMLParagraphElement>("#contact-status");
84
+
85
+ form?.addEventListener("submit", async (event) => {
86
+ event.preventDefault();
87
+ const button = form.querySelector<HTMLButtonElement>('button[type="submit"]');
88
+ if (button) button.disabled = true;
89
+ if (status) status.textContent = "Sending…";
90
+
91
+ try {
92
+ const body = Object.fromEntries(new FormData(form).entries());
93
+ const response = await fetch(form.action, {
94
+ method: "POST",
95
+ headers: { "content-type": "application/json" },
96
+ body: JSON.stringify(body),
97
+ });
98
+
99
+ if (response.ok) {
100
+ form.reset();
101
+ if (status) status.textContent = "Thanks — we've got it, and we'll be in touch.";
102
+ return;
103
+ }
104
+
105
+ // 422 carries per-field violations; 429 is the rate limit. Anything else is
106
+ // reported as itself rather than as a generic failure.
107
+ const payload = (await response.json().catch(() => null)) as
108
+ | { error?: string; violations?: { field?: string; message?: string }[] }
109
+ | null;
110
+ if (status) {
111
+ status.textContent =
112
+ payload?.violations?.map((v) => `${v.field ?? "form"}: ${v.message ?? "invalid"}`).join(", ") ||
113
+ payload?.error ||
114
+ `Something went wrong (${response.status}).`;
115
+ }
116
+ } catch {
117
+ if (status) status.textContent = "Could not reach the server. Please try again.";
118
+ } finally {
119
+ if (button) button.disabled = false;
120
+ }
121
+ });
122
+ </script>
@@ -5,6 +5,7 @@
5
5
  // Louise client turns into inline editors; saves stage a draft (versionedPageId),
6
6
  // and Publish promotes it. Seed the row once with `seed/home.seed.sql`.
7
7
  import Editable from "astroidjs/components/Editable.astro";
8
+ import Sections from "astroidjs/components/Sections.astro";
8
9
  import { env } from "cloudflare:workers";
9
10
  import Hero from "../components/Hero.astro";
10
11
  import Site from "../layouts/Site.astro";
@@ -13,15 +14,63 @@ export const prerender = false;
13
14
 
14
15
  const { editMode } = Astro.locals;
15
16
 
16
- let page: { id: number; title: string; body: string | null } | null = null;
17
+ // Edge caching (ADR 0004). Opting in emits `Cloudflare-CDN-Cache-Control`, which
18
+ // the worker's `withEdgeCache` layer reads as "store this", then strips.
19
+ //
20
+ // Both conditions matter. Edit mode must NEVER opt in — a cached editor render
21
+ // would carry draft content and the inline-edit hooks into the shared public
22
+ // entry. And the var is off by default because `caches.default` survives
23
+ // Cloudflare Dev Mode and Purge Everything; walk the activation runbook in
24
+ // docs/adr/0004-edge-caching.md on a preview deploy before turning it on.
25
+ //
26
+ // `set(false)` is the safe state, not merely the "off" one: it emits `no-store`,
27
+ // so nothing downstream caches this HTML either.
28
+ if (editMode || env.ASTROID_EDGE_CACHE !== "true") {
29
+ Astro.cache.set(false);
30
+ } else {
31
+ // Short on purpose: `caches.default` is per-colo with no global purge, so this
32
+ // max-age is the real worldwide freshness floor after a publish.
33
+ Astro.cache.set({ maxAge: 60 });
34
+ }
35
+
36
+ // `seo_*` are the page's SEO overrides, deliberately separate from `title` —
37
+ // which is the on-page H1. The home page passes NO title to <Site>, so <title>
38
+ // is the site-wide default ("Acme Coffee", not "Acme Coffee | Acme Coffee");
39
+ // set `seo_title` on the row to override just the head.
40
+ interface HomeRow {
41
+ id: number;
42
+ title: string;
43
+ body: string | null;
44
+ /** The page-builder array, stored as a JSON string in D1. */
45
+ sections: string | null;
46
+ seo_title: string | null;
47
+ seo_description: string | null;
48
+ og_image: string | null;
49
+ noindex: number | null;
50
+ }
51
+ let page: HomeRow | null = null;
17
52
  try {
18
- page = await env.DB.prepare("SELECT id, title, body FROM pages WHERE slug = 'home'").first();
53
+ page = await env.DB.prepare(
54
+ "SELECT id, title, body, sections, seo_title, seo_description, og_image, noindex FROM pages WHERE slug = 'home'",
55
+ ).first<HomeRow>();
19
56
  } catch {
20
57
  // No DB binding yet (pre-provision) — fall through to the seed-me prompt.
21
58
  }
22
59
 
23
60
  let title = page?.title ?? "__BRAND_NAME__";
24
61
  let body = page?.body ?? "";
62
+ // `sections` is a JSON string in D1. Bad JSON is a render-time non-event — the
63
+ // page falls back to its prose body rather than 500ing on one malformed row.
64
+ function parseSections(raw: string | null | undefined): unknown[] {
65
+ if (!raw) return [];
66
+ try {
67
+ const parsed = JSON.parse(raw);
68
+ return Array.isArray(parsed) ? parsed : [];
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+ let sections = parseSections(page?.sections);
25
74
 
26
75
  // In edit mode, prefer the latest unpublished draft so edits resume across reloads.
27
76
  if (editMode && page) {
@@ -32,9 +81,18 @@ if (editMode && page) {
32
81
  .bind(page.id)
33
82
  .first<{ version_data: string }>();
34
83
  if (draft?.version_data) {
35
- const d = JSON.parse(draft.version_data) as { title?: string; body?: string };
84
+ const d = JSON.parse(draft.version_data) as {
85
+ title?: string;
86
+ body?: string;
87
+ sections?: unknown;
88
+ };
36
89
  if (typeof d.title === "string") title = d.title;
37
90
  if (typeof d.body === "string") body = d.body;
91
+ // Sections stage as drafts like any other field, so edit mode must render
92
+ // the draft's array — otherwise an editor's in-progress section edits
93
+ // vanish on reload while their title edits survive.
94
+ if (Array.isArray(d.sections)) sections = d.sections;
95
+ else if (typeof d.sections === "string") sections = parseSections(d.sections);
38
96
  }
39
97
  } catch {
40
98
  // Non-fatal — fall back to the live row.
@@ -42,16 +100,31 @@ if (editMode && page) {
42
100
  }
43
101
  ---
44
102
 
45
- <Site title={title} description={undefined} versionedPageId={page?.id}>
103
+ <Site
104
+ title={page?.seo_title ?? undefined}
105
+ description={page?.seo_description ?? undefined}
106
+ ogImage={page?.og_image ?? undefined}
107
+ noindex={Boolean(page?.noindex)}
108
+ versionedPageId={page?.id}
109
+ sections={sections}
110
+ >
46
111
  {
47
112
  page ? (
48
- <main class="mx-auto max-w-3xl px-6 py-16">
113
+ <main>
114
+ {/* The page-builder sections, edited on-canvas. <Sections> resolves every
115
+ image's alt/caption from the media registry in ONE query for the whole
116
+ page, then hands each section its own path so `data-louise-sfield`
117
+ markers land at the right depth. */}
118
+ <Sections items={sections} db={env.DB} mediaBase={env.MEDIA_URL} />
119
+
120
+ <div class="mx-auto max-w-3xl px-6 py-16">
49
121
  <Editable as="h1" collection="pages" key={page.id} field="title" class="text-5xl font-bold">
50
122
  {title}
51
123
  </Editable>
52
124
  <Editable as="div" collection="pages" key={page.id} field="body" type="richtext" class="prose mt-6 max-w-none">
53
125
  <Fragment set:html={body} />
54
126
  </Editable>
127
+ </div>
55
128
  </main>
56
129
  ) : (
57
130
  <main>