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.
- package/README.md +16 -7
- package/index.mjs +244 -15
- package/package.json +5 -3
- package/template/README.md +10 -0
- package/template/_env.example +36 -0
- package/template/astro.config.mjs +29 -12
- package/template/package.json +5 -2
- package/template/pnpm-workspace.yaml +38 -0
- package/template/seed/home.seed.sql +48 -1
- package/template/src/auth.ts +10 -6
- package/template/src/components/LouiseEdit.astro +46 -3
- package/template/src/env.d.ts +54 -5
- package/template/src/layouts/Site.astro +84 -4
- package/template/src/pages/contact.astro +122 -0
- package/template/src/pages/index.astro +78 -5
- package/template/src/pages/login.astro +52 -2
- package/template/src/pages/robots.txt.ts +36 -0
- package/template/src/pages/sitemap.xml.ts +40 -0
- package/template/src/styles/site.css +9 -0
|
@@ -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
|
-
|
|
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(),
|
package/template/src/auth.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|
|
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:
|
|
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
|
}
|
package/template/src/env.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
23
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
<
|
|
24
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
|
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>
|