create-astroid 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -7
- package/index.mjs +249 -16
- 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/scripts/seed-editors.mjs +2 -2
- package/template/seed/home.seed.sql +48 -1
- package/template/src/auth.ts +24 -14
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Seed the first editor(s) — an admin `
|
|
2
|
+
// Seed the first editor(s) — an admin `louise_user` row per OWNER_EMAIL / ENGINEER_EMAIL.
|
|
3
3
|
// A row here IS an editor and IS the magic-link allowlist, so this bootstraps
|
|
4
4
|
// access before anyone can sign in. Idempotent (INSERT OR IGNORE on unique email).
|
|
5
5
|
// After this, add more editors from the Users panel (never by editing env).
|
|
@@ -46,7 +46,7 @@ for (const email of emails) {
|
|
|
46
46
|
const id = randomUUID();
|
|
47
47
|
const name = email.split("@")[0];
|
|
48
48
|
const sql =
|
|
49
|
-
"INSERT OR IGNORE INTO
|
|
49
|
+
"INSERT OR IGNORE INTO louise_user " +
|
|
50
50
|
"(id, name, email, emailVerified, createdAt, updatedAt, role, firstName, lastName) VALUES " +
|
|
51
51
|
`(${q(id)}, ${q(name)}, ${q(email)}, 1, ${q(now)}, ${q(now)}, 'admin', NULL, NULL);`;
|
|
52
52
|
console.log(`Seeding editor ${email} (${target}) …`);
|
|
@@ -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
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// The auth seam Astroid's generated worker.ts + middleware.ts import
|
|
2
2
|
// (`resolveEditor`), plus the Better Auth catch-all the /api/auth route calls.
|
|
3
3
|
//
|
|
4
|
-
// DB-managed editors: an admin `
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// DB-managed editors: an admin `louise_user` row IS an editor. The editor
|
|
5
|
+
// instance's tables are `louise_`-prefixed (the editor convention), leaving the
|
|
6
|
+
// unprefixed `user`/`session` names free for a second/portal instance. That
|
|
7
|
+
// table is BOTH the role source and the magic-link allowlist — `resolveAdmins`
|
|
8
|
+
// reads it, so only an existing editor's email can request a sign-in link. Seed
|
|
9
|
+
// the first editor with `pnpm seed:editors`; add more from the Users panel
|
|
10
|
+
// (editorsRoute), never by editing env. Magic-link + passkey come from
|
|
11
|
+
// `getLouiseAuth`; no passwords.
|
|
9
12
|
|
|
13
|
+
import { astroidMailTheme, magicLinkEmail } from "astroidjs";
|
|
10
14
|
import { env } from "cloudflare:workers";
|
|
11
15
|
import {
|
|
12
16
|
type EditorSession,
|
|
@@ -16,32 +20,38 @@ import {
|
|
|
16
20
|
type MagicLinkEmail,
|
|
17
21
|
resolveEditorSession,
|
|
18
22
|
} from "louise-toolkit/auth";
|
|
23
|
+
import astroidConfig from "../astroid.config.js";
|
|
19
24
|
|
|
20
25
|
const BRAND = "__BRAND_NAME__";
|
|
21
26
|
|
|
22
|
-
|
|
27
|
+
// Transactional mail theme, derived from your config's brand colours: the
|
|
28
|
+
// palette, the masthead colour band, and a contrast-corrected accent. Pass a
|
|
29
|
+
// second argument to override any slot.
|
|
30
|
+
const MAIL_THEME = astroidMailTheme(astroidConfig);
|
|
31
|
+
|
|
32
|
+
/** The DB-managed editor allowlist: every admin `louise_user` row. */
|
|
23
33
|
async function resolveAdmins(): Promise<string[]> {
|
|
24
34
|
const { results } = await env.DB.prepare(
|
|
25
|
-
"SELECT email FROM
|
|
35
|
+
"SELECT email FROM louise_user WHERE role = 'admin'",
|
|
26
36
|
).all<{ email: string }>();
|
|
27
37
|
return results.map((r) => r.email);
|
|
28
38
|
}
|
|
29
39
|
|
|
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
|
-
};
|
|
40
|
+
/** The branded sign-in email — Astroid's template over your mail theme. */
|
|
41
|
+
function renderMagicLinkEmail({ url, toEmail }: { url: string; toEmail: string }): MagicLinkEmail {
|
|
42
|
+
return magicLinkEmail(MAIL_THEME, { url, toEmail });
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
/** The request-scoped Better Auth instance (magic-link + passkey, DB allowlist).
|
|
45
|
+
/** The request-scoped Better Auth instance (magic-link + passkey, DB allowlist).
|
|
46
|
+
* `tablePrefix: "louise_"` namespaces the editor's Better Auth tables so a
|
|
47
|
+
* second (portal) instance can take the unprefixed `user`/`session` names. */
|
|
39
48
|
function getAuth(request: Request): Promise<LouiseAuth> {
|
|
40
49
|
return getLouiseAuth(env, new URL(request.url).origin, {
|
|
41
50
|
rpName: BRAND,
|
|
42
51
|
mailFrom: { email: env.MAIL_FROM, name: BRAND },
|
|
43
52
|
renderMagicLinkEmail,
|
|
44
53
|
resolveAdmins,
|
|
54
|
+
tablePrefix: "louise_",
|
|
45
55
|
});
|
|
46
56
|
}
|
|
47
57
|
|
|
@@ -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>
|