create-astroid 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BowenLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # create-astroid
2
+
3
+ Scaffold a new **Astroid** site — an editable, multi-editor Astro app on
4
+ Cloudflare Workers — in one command.
5
+
6
+ ```sh
7
+ npm create astroid my-site
8
+ ```
9
+
10
+ > **Status: pre-1.0, experimental.** The scaffold's output will change between
11
+ > minor versions.
12
+
13
+ ## What you get
14
+
15
+ A working floor, not a blank page:
16
+
17
+ - `astroid.config.ts` — the one typed config the rest is generated from
18
+ - The generated trio — `src/schema.ts`, `src/worker.ts`, `src/middleware.ts`
19
+ (Drizzle schema, editor routes in collision-free order, the shared middleware)
20
+ - `wrangler.jsonc` with every binding stubbed and clearly marked for you to fill
21
+ - A baseline Astro app with an **inline-editable home page**, magic-link editor
22
+ sign-in, and the Louise editor wired up
23
+ - Migrations + a `seed:editors` script to create the first editor
24
+
25
+ ## Options
26
+
27
+ Anything you don't pass is prompted for. In a non-TTY every prompt takes its
28
+ default, so the command is CI-safe. The target directory must be empty.
29
+
30
+ ```
31
+ npm create astroid [directory] [options]
32
+
33
+ --dir <path> Target directory (also the first positional)
34
+ --name <name> Brand / site name
35
+ --key <slug> Project key (slug); defaults to a slug of --name
36
+ --archetype <type> marketing | storefront | wholesale | portfolio
37
+ --color <hex> Brand color
38
+ --host <domain> Primary domain, e.g. example.com
39
+ -h, --help Show help
40
+ -v, --version Show the version
41
+ ```
42
+
43
+ ## After scaffolding
44
+
45
+ ```sh
46
+ cd my-site
47
+ npm install
48
+ npx wrangler d1 create <name> # then paste the ids into wrangler.jsonc
49
+ npm run doctor # validates config, bindings, generated files
50
+ npm run dev
51
+ ```
52
+
53
+ `npm run doctor` flags any binding id you haven't filled in yet, plus generated
54
+ files that have drifted from the config.
55
+
56
+ ## How the pieces relate
57
+
58
+ ```
59
+ Astro → renderer / router / build
60
+ Louise → unopinionated primitives + framework glue (louise-toolkit)
61
+ Astroid → opinions: theme, sections, config, scaffold (astroidjs)
62
+ ```
63
+
64
+ - [`astroidjs`](https://github.com/bowenlabs/louise-toolkit/tree/main/packages/astroid)
65
+ — the meta-framework and the `astroid` CLI this scaffold writes a project for
66
+ - [`louise-toolkit`](https://github.com/bowenlabs/louise-toolkit/tree/main/packages/louise)
67
+ — the underlying toolkit
68
+
69
+ ## License
70
+
71
+ [MIT](https://github.com/bowenlabs/louise-toolkit/blob/main/LICENSE) © BowenLabs
package/index.mjs ADDED
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
3
+ //
4
+ // `create-astroid` — scaffold a new Astroid site in one command:
5
+ //
6
+ // npm create astroid@latest my-site
7
+ // npm create astroid@latest my-site -- --key coracle --name "Coracle Coffee" --color "#1f6f78" --host coracle.coffee
8
+ //
9
+ // It writes the floor: the `defineAstroid` config, the generated
10
+ // schema/worker/middleware trio + wrangler.jsonc (via astroidjs), the Better Auth
11
+ // migration (via louise-toolkit/auth), and the baseline Astro app from ./template.
12
+ // Binding ids are placeholders — provision them, then `astroid deploy` (or
13
+ // wrangler) fills them in. The generators are the SAME ones `astroid generate`
14
+ // uses, so a fresh project is already in sync.
15
+
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
17
+ import { basename, dirname, join, resolve } from "node:path";
18
+ import { createInterface } from "node:readline/promises";
19
+ import { fileURLToPath } from "node:url";
20
+ import { defineAstroid, generateAstroidProject, generateAstroidWrangler } from "astroidjs";
21
+
22
+ const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), "template");
23
+
24
+ // Files whose leading `_` is stripped on copy (npm strips real dotfiles from a
25
+ // published package, so they ship as `_gitignore` / `_env.example`).
26
+ const DOTFILE_RENAMES = { _gitignore: ".gitignore", "_env.example": ".env.example" };
27
+
28
+ // Archetype → default editable home sections, when the user doesn't override.
29
+ const ARCHETYPE_SECTIONS = {
30
+ marketing: ["hero", "featureGrid", "cta", "contact"],
31
+ storefront: ["hero", "marquee", "featured", "productGrid", "visit", "contact"],
32
+ wholesale: ["hero", "featureGrid", "story", "contact"],
33
+ portfolio: ["hero", "gallery", "story", "contact"],
34
+ };
35
+ const ARCHETYPES = Object.keys(ARCHETYPE_SECTIONS);
36
+
37
+ // --- args ------------------------------------------------------------------
38
+ function parseArgs(argv) {
39
+ const flags = {};
40
+ const positionals = [];
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const a = argv[i];
43
+ if (a.startsWith("--")) {
44
+ const key = a.slice(2);
45
+ const next = argv[i + 1];
46
+ if (next === undefined || next.startsWith("--")) flags[key] = true;
47
+ else flags[key] = argv[++i];
48
+ } else positionals.push(a);
49
+ }
50
+ return { flags, positionals };
51
+ }
52
+
53
+ const slugify = (s) =>
54
+ s.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
55
+
56
+ async function prompt(question, fallback) {
57
+ if (!process.stdin.isTTY) return fallback;
58
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
59
+ try {
60
+ const answer = (await rl.question(`${question}${fallback ? ` (${fallback})` : ""}: `)).trim();
61
+ return answer || fallback;
62
+ } finally {
63
+ rl.close();
64
+ }
65
+ }
66
+
67
+ // --- scaffold --------------------------------------------------------------
68
+ function copyTemplate(srcDir, destDir, tokens) {
69
+ mkdirSync(destDir, { recursive: true });
70
+ for (const entry of readdirSync(srcDir)) {
71
+ const src = join(srcDir, entry);
72
+ const renamed = DOTFILE_RENAMES[entry] ?? entry;
73
+ const dest = join(destDir, renamed);
74
+ if (statSync(src).isDirectory()) {
75
+ copyTemplate(src, dest, tokens);
76
+ } else {
77
+ const raw = readFileSync(src, "utf8");
78
+ writeFileSync(dest, applyTokens(raw, tokens));
79
+ }
80
+ }
81
+ }
82
+
83
+ function applyTokens(text, tokens) {
84
+ return text.replace(/__([A-Z0-9_]+)__/g, (m, key) => (key in tokens ? tokens[key] : m));
85
+ }
86
+
87
+ function astroidConfigSource(config) {
88
+ const parts = [
89
+ 'import { defineAstroid } from "astroidjs";',
90
+ "",
91
+ "// The whole shape of this site — one typed config. `astroid generate` (run by",
92
+ "// `astroid dev`/`build`) turns it into src/schema.ts, src/worker.ts, and",
93
+ "// src/middleware.ts; `astroid doctor` keeps them honest.",
94
+ "export default defineAstroid({",
95
+ ` key: ${JSON.stringify(config.key)},`,
96
+ ` archetype: ${JSON.stringify(config.archetype)},`,
97
+ ...(config.hosts?.length ? [` hosts: ${JSON.stringify(config.hosts)},`] : []),
98
+ " theme: {",
99
+ ` name: ${JSON.stringify(config.theme.name)},`,
100
+ ` colors: { brand: ${JSON.stringify(config.theme.colors.brand)} },`,
101
+ " },",
102
+ ` sections: ${JSON.stringify(config.sections)},`,
103
+ ' deploy: { platform: "cloudflare" },',
104
+ "});",
105
+ "",
106
+ ];
107
+ return parts.join("\n");
108
+ }
109
+
110
+ function write(destDir, relPath, contents) {
111
+ const abs = join(destDir, relPath);
112
+ mkdirSync(dirname(abs), { recursive: true });
113
+ writeFileSync(abs, contents);
114
+ }
115
+
116
+ const USAGE = `Scaffold a new Astroid site — an editable Astro app on Cloudflare Workers.
117
+
118
+ Usage:
119
+ npm create astroid [directory] [options]
120
+
121
+ Options:
122
+ --dir <path> Target directory (also accepted as the first positional)
123
+ --name <name> Brand / site name
124
+ --key <slug> Project key (slug); defaults to a slug of --name
125
+ --archetype <type> ${ARCHETYPES.join(" | ")} (default: marketing)
126
+ --color <hex> Brand color (default: #5b4bff)
127
+ --host <domain> Primary domain, e.g. example.com
128
+ -h, --help Show this help
129
+ -v, --version Show the create-astroid version
130
+
131
+ Anything not passed as a flag is prompted for; in a non-TTY every prompt takes
132
+ its default, so the command is CI-safe. The target directory must be empty.
133
+ `;
134
+
135
+ async function main() {
136
+ const argv = process.argv.slice(2);
137
+ const { flags, positionals } = parseArgs(argv);
138
+
139
+ // Handle these before any prompting — otherwise `--help` reads as a truthy
140
+ // flag and drops the user into the interactive scaffold instead. The short
141
+ // forms are read off argv directly: parseArgs only treats `--` as a flag, so
142
+ // a bare `-h` would otherwise be taken as the target directory.
143
+ if (flags.help || argv.includes("-h")) {
144
+ process.stdout.write(USAGE);
145
+ return;
146
+ }
147
+ if (flags.version || argv.includes("-v")) {
148
+ const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
149
+ process.stdout.write(`${pkg.version}\n`);
150
+ return;
151
+ }
152
+
153
+ const dirArg = positionals[0] ?? flags.dir;
154
+ const rawName = flags.name || (dirArg ? basename(resolve(dirArg)) : undefined);
155
+ const name = await prompt("Brand / site name", rawName || "My Astroid Site");
156
+ const key = slugify(flags.key || (await prompt("Project key (slug)", slugify(name) || "my-site")));
157
+ const dir = resolve(dirArg || (await prompt("Directory", key)) || key);
158
+ const archetypeRaw = (flags.archetype || (await prompt(`Archetype (${ARCHETYPES.join("/")})`, "marketing"))).toLowerCase();
159
+ const archetype = ARCHETYPES.includes(archetypeRaw) ? archetypeRaw : "marketing";
160
+ const color = flags.color || (await prompt("Brand color (hex)", "#5b4bff"));
161
+ const host = flags.host && flags.host !== true ? flags.host : undefined;
162
+
163
+ if (existsSync(dir) && readdirSync(dir).length > 0) {
164
+ process.stderr.write(`create-astroid: target directory is not empty: ${dir}\n`);
165
+ process.exit(1);
166
+ }
167
+
168
+ // Validate + normalize through the real config surface (throws on a bad shape).
169
+ const config = defineAstroid({
170
+ key,
171
+ archetype,
172
+ ...(host ? { hosts: [host] } : {}),
173
+ theme: { name, colors: { brand: color } },
174
+ sections: ARCHETYPE_SECTIONS[archetype],
175
+ deploy: { platform: "cloudflare" },
176
+ });
177
+
178
+ const siteUrl = host ? `https://${host}` : `https://${key}.workers.dev`;
179
+ const tokens = {
180
+ KEY: key,
181
+ BRAND_NAME: name,
182
+ BRAND_COLOR: color,
183
+ ARCHETYPE: archetype,
184
+ SITE_URL: siteUrl,
185
+ };
186
+
187
+ // 1. The static floor (Astro app, auth seam, config files) with tokens filled.
188
+ copyTemplate(TEMPLATE_DIR, dir, tokens);
189
+
190
+ // 2. The typed config the generators + the app read.
191
+ write(dir, "astroid.config.ts", astroidConfigSource(config));
192
+
193
+ // 3. The generated trio + the scaffold-once wrangler.jsonc (astroidjs).
194
+ for (const file of generateAstroidProject(config)) write(dir, file.path, file.contents);
195
+ write(dir, "wrangler.jsonc", generateAstroidWrangler(config));
196
+
197
+ // 4. The Better Auth migration (louise-toolkit) — auth tables are fenced out of
198
+ // drizzle-kit, so they're generated rather than diffed from schema.ts. Loaded
199
+ // dynamically: it pulls in `better-auth` (an optional peer), which may not be
200
+ // resolvable at scaffold time. If not, leave a stub + a one-liner to generate
201
+ // it after install (the project has `louise` on its path then).
202
+ let authMigrationOk = false;
203
+ try {
204
+ const { generateAuthSchemaSql } = await import("louise-toolkit/auth");
205
+ write(dir, "migrations/0001_auth.sql", generateAuthSchemaSql());
206
+ authMigrationOk = true;
207
+ } catch {
208
+ write(
209
+ dir,
210
+ "migrations/0001_auth.sql",
211
+ "-- Better Auth tables — generate after install:\n-- npx louise gen-auth-schema --out migrations/0001_auth.sql\n",
212
+ );
213
+ }
214
+
215
+ const rel = dir === process.cwd() ? "." : basename(dir);
216
+ process.stdout.write(
217
+ [
218
+ "",
219
+ `✓ Scaffolded ${name} → ${rel}`,
220
+ "",
221
+ "Next steps:",
222
+ ` cd ${rel}`,
223
+ " pnpm install",
224
+ " # provision the Cloudflare bindings, then fill the ids in wrangler.jsonc:",
225
+ " wrangler d1 create " + key,
226
+ " wrangler r2 bucket create " + key + "-media",
227
+ " wrangler kv namespace create RL && wrangler kv namespace create DRAFTS",
228
+ " # apply migrations + seed your first editor:",
229
+ " wrangler d1 migrations apply DB --remote",
230
+ " OWNER_EMAIL=you@example.com pnpm seed:editors",
231
+ " # develop / ship:",
232
+ " pnpm dev # astroid dev (regenerates, then astro dev)",
233
+ " pnpm doctor # validate config + bindings",
234
+ " wrangler deploy",
235
+ "",
236
+ ].join("\n"),
237
+ );
238
+ if (!authMigrationOk) {
239
+ process.stdout.write(
240
+ "Note: generate the Better Auth migration after install:\n" +
241
+ " npx louise gen-auth-schema --out migrations/0001_auth.sql\n\n",
242
+ );
243
+ }
244
+ }
245
+
246
+ main().catch((err) => {
247
+ process.stderr.write(`${err instanceof Error ? err.stack : String(err)}\n`);
248
+ process.exit(1);
249
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "create-astroid",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a new Astroid site — an editable, multi-editor Astro app on Cloudflare Workers — in one command.",
5
+ "keywords": [
6
+ "astro",
7
+ "astroid",
8
+ "cloudflare-workers",
9
+ "cms",
10
+ "create",
11
+ "louise-toolkit",
12
+ "scaffold"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "BowenLabs",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/bowenlabs/louise-toolkit.git",
19
+ "directory": "packages/create-astroid"
20
+ },
21
+ "type": "module",
22
+ "bin": {
23
+ "create-astroid": "./index.mjs"
24
+ },
25
+ "files": [
26
+ "index.mjs",
27
+ "template"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "dependencies": {
33
+ "louise-toolkit": "0.14.0",
34
+ "astroidjs": "0.1.0"
35
+ },
36
+ "engines": {
37
+ "node": ">=24.0.0"
38
+ }
39
+ }
@@ -0,0 +1,106 @@
1
+ # __BRAND_NAME__
2
+
3
+ An editable, multi-editor site on Cloudflare Workers — scaffolded with
4
+ [Astroid](https://github.com/bowenlabs/louise-toolkit) (Astro + Louise Toolkit).
5
+
6
+ The whole shape of this site lives in one typed config, [`astroid.config.ts`](./astroid.config.ts).
7
+ `src/schema.ts`, `src/worker.ts`, and `src/middleware.ts` are **generated** from it
8
+ (they carry a "do not hand-edit" banner) — run `pnpm generate` after any config
9
+ change, or just use `pnpm dev`/`pnpm build`, which regenerate first.
10
+
11
+ ## Develop
12
+
13
+ ```sh
14
+ pnpm install
15
+ cp .env.example .dev.vars # local secrets for `astro dev`; fill SESSION_SECRET + OWNER_EMAIL
16
+ pnpm dev # astroid dev: regenerate, then astro dev
17
+ ```
18
+
19
+ ## Deploy
20
+
21
+ Astroid wrote `wrangler.jsonc` with placeholder binding ids. Pick a path to
22
+ provision them and ship — then seed content + your first editor (below).
23
+
24
+ ### Zero-CLI — Deploy to Cloudflare
25
+
26
+ Push this repo to GitHub and drop this button in place (swap in your repo URL).
27
+ Cloudflare clones the repo, provisions the D1/R2/KV bindings declared in
28
+ `wrangler.jsonc`, and deploys — no local tooling:
29
+
30
+ [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=<YOUR_GITHUB_REPO_URL>)
31
+
32
+ ### One command — `astroid deploy`
33
+
34
+ Provisions the still-placeholder bindings, applies migrations, prompts for
35
+ secrets, and deploys — through your local `wrangler`:
36
+
37
+ ```sh
38
+ pnpm astroid deploy --dry-run # preview the exact commands it will run
39
+ pnpm astroid deploy # provision + migrate + secrets + deploy (asks first)
40
+ ```
41
+
42
+ ### By hand
43
+
44
+ ```sh
45
+ wrangler d1 create __KEY__
46
+ wrangler r2 bucket create __KEY__-media
47
+ wrangler kv namespace create RL && wrangler kv namespace create DRAFTS
48
+ # paste the printed ids into wrangler.jsonc, then:
49
+ wrangler secret put SESSION_SECRET # openssl rand -base64 32
50
+ wrangler d1 migrations apply DB --remote
51
+ wrangler deploy
52
+ ```
53
+
54
+ ### Seed content + your first editor
55
+
56
+ ```sh
57
+ wrangler d1 execute DB --file seed/home.seed.sql --remote
58
+ OWNER_EMAIL=you@example.com pnpm seed:editors
59
+ ```
60
+
61
+ ## Editors & auth
62
+
63
+ Editors sign in with a magic link (passkeys supported). The allowlist is
64
+ **DB-managed**: an admin `user` row *is* an editor. Seed the first one above; add
65
+ or remove the rest from the Users panel (backed by the generated `editorsRoute`).
66
+ There are no passwords and no editor list in env to keep in sync.
67
+
68
+ ### Editing your site
69
+
70
+ 1. Go to **`/login`** and enter a seeded editor's email. In local dev there's no
71
+ email binding, so the magic link is printed to the `wrangler`/`astro dev`
72
+ console — open it from there. In production it's emailed.
73
+ 2. The link signs you in and drops you at **`/?louise`** — edit mode. The **edit
74
+ bar** appears with **Settings** and **Done**.
75
+ 3. The home page's **title and body are editable in place** — click into them and
76
+ type. Edits stage a **draft**; **Publish** (in the edit bar) promotes it live.
77
+ **Settings** opens the drawer: **Pages** (create/edit other pages), **Media**,
78
+ **Settings** (brand, nav, contact, SEO), and **Users** (invite/remove editors).
79
+ **Done** leaves edit mode.
80
+
81
+ Inline editing uses Astroid's [`<Editable>`](https://github.com/bowenlabs/louise-toolkit)
82
+ primitive (`src/pages/index.astro`): it stamps the `data-louise-*` markers only in
83
+ edit mode, so the public HTML stays clean. Wrap any page field in `<Editable
84
+ collection="pages" key={page.id} field="…">` and pass `versionedPageId` to make it
85
+ editable. Body HTML is sanitized on every save.
86
+
87
+ ## Redeploy
88
+
89
+ Once provisioned, shipping changes is just:
90
+
91
+ ```sh
92
+ pnpm doctor # validate config, bindings, and generated-file freshness
93
+ wrangler deploy # or: pnpm astroid deploy
94
+ ```
95
+
96
+ ## Layout
97
+
98
+ | Path | What |
99
+ | --- | --- |
100
+ | `astroid.config.ts` | The one typed config — brand, archetype, sections. |
101
+ | `src/schema.ts` · `src/worker.ts` · `src/middleware.ts` | **Generated** — don't hand-edit. |
102
+ | `wrangler.jsonc` | Yours to edit — real binding ids, routes, secrets. |
103
+ | `src/auth.ts` | The editor auth seam (Better Auth, DB-managed editors). |
104
+ | `src/pages/` · `src/components/` · `src/layouts/` | Your Astro app. |
105
+ | `migrations/` | `0000_content.sql` (content + FTS) · `0001_auth.sql` (Better Auth). |
106
+ | `scripts/seed-editors.mjs` | Bootstrap the first editor. |
@@ -0,0 +1,12 @@
1
+ # Local dev secrets (wrangler reads .dev.vars; copy this there for `astro dev`).
2
+ # In production these are set with `wrangler secret put`, NOT committed.
3
+
4
+ # Signs Better Auth sessions. Generate: `openssl rand -base64 32`.
5
+ SESSION_SECRET=
6
+
7
+ # The first editor's email — seeded as an admin `user` row by `pnpm seed:editors`,
8
+ # and thereafter part of the DB-managed magic-link allowlist.
9
+ OWNER_EMAIL=you@example.com
10
+
11
+ # `from` address for magic-link + notification email (Cloudflare Email Sending).
12
+ MAIL_FROM=no-reply@__KEY__.example
@@ -0,0 +1,19 @@
1
+ # build output
2
+ dist/
3
+ .astro/
4
+
5
+ # dependencies
6
+ node_modules/
7
+
8
+ # cloudflare / wrangler
9
+ .wrangler/
10
+ .dev.vars
11
+
12
+ # local env + secrets
13
+ .env
14
+ .env.*
15
+ !.env.example
16
+
17
+ # logs / os
18
+ *.log
19
+ .DS_Store
@@ -0,0 +1,30 @@
1
+ // @ts-check
2
+ import cloudflare from "@astrojs/cloudflare";
3
+ import solid from "@astrojs/solid-js";
4
+ import tailwindcss from "@tailwindcss/vite";
5
+ import { defineConfig } from "astro/config";
6
+
7
+ // SSR (`output: server`) because Louise renders per-request edit affordances and
8
+ // reads pages from D1. Solid islands power the editor UI (ADR 0001). Tailwind v4 +
9
+ // daisyUI drive the theme (src/styles/site.css). Cloudflare *bindings* are read
10
+ // via `import { env } from "cloudflare:workers"` (typed in src/env.d.ts), so there
11
+ // is no astro:env schema here.
12
+ export default defineConfig({
13
+ site: "__SITE_URL__",
14
+ output: "server",
15
+ adapter: cloudflare(),
16
+ integrations: [solid()],
17
+ vite: { plugins: [tailwindcss()] },
18
+ // Content-Security-Policy. Astro hashes every processed script + style and emits
19
+ // a `content-security-policy` response header on on-demand (SSR) pages — which is
20
+ // all of ours. The generated src/middleware.ts (createLouiseMiddleware) then
21
+ // rewrites `style-src` to `'self' 'unsafe-inline'` so Louise's data-driven
22
+ // `style=""` carriers and the editor's runtime-injected <style> are allowed, and
23
+ // permits the inlined `data:` brand font. This is why the inline scripts here
24
+ // (login.astro, LouiseEdit.astro) avoid is:inline/define:vars — those can't be
25
+ // hashed and would be blocked.
26
+ //
27
+ // Using Square Web Payments? Allow its SDK host in script-src:
28
+ // security: { csp: { scriptDirective: { resources: ["'self'", "https://web.squarecdn.com"] } } }
29
+ security: { csp: true },
30
+ });
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ // Generates D1 (SQLite) migrations from src/schema.ts into ./migrations, applied
4
+ // by `wrangler d1 migrations apply DB`. The schema is the astroid-generated
5
+ // content tables (louise-toolkit/db column sets).
6
+ //
7
+ // Better Auth owns its own tables (user/session/account/verification/passkey) —
8
+ // they're generated by `louise gen-auth-schema` (see migrations/0001_auth.sql),
9
+ // NOT by Drizzle, so they're fenced out here to keep drizzle-kit from trying to
10
+ // drop or recreate them.
11
+ export default defineConfig({
12
+ dialect: "sqlite",
13
+ schema: "./src/schema.ts",
14
+ out: "./migrations",
15
+ tablesFilter: ["!user", "!session", "!account", "!verification", "!passkey"],
16
+ });
@@ -0,0 +1,97 @@
1
+ -- Content tables for Astroid: pages (page-builder), version snapshots, the media
2
+ -- library, editable site settings, inquiries, and the pages full-text index.
3
+ -- Applied by `wrangler d1 migrations apply DB`. Matches the astroid-generated
4
+ -- src/schema.ts; the Better Auth tables are in 0001_auth.sql.
5
+
6
+ CREATE TABLE `pages` (
7
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
8
+ `slug` text NOT NULL,
9
+ `title` text NOT NULL,
10
+ `body` text,
11
+ `status` text DEFAULT 'draft' NOT NULL,
12
+ `seo_title` text,
13
+ `seo_description` text,
14
+ `og_image` text,
15
+ `noindex` integer DEFAULT false NOT NULL,
16
+ `sort_order` real DEFAULT 0,
17
+ `created_at` integer,
18
+ `updated_at` integer,
19
+ `sections` text,
20
+ `published_version_id` integer
21
+ );
22
+ CREATE UNIQUE INDEX `pages_slug_unique` ON `pages` (`slug`);
23
+
24
+ CREATE TABLE `pages_versions` (
25
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
26
+ `parent_id` integer NOT NULL,
27
+ `version_data` text NOT NULL,
28
+ `status` text NOT NULL,
29
+ `created_at` integer,
30
+ `scheduled_at` integer
31
+ );
32
+
33
+ CREATE TABLE `media` (
34
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
35
+ `key` text NOT NULL,
36
+ `content_type` text,
37
+ `size` integer,
38
+ `width` integer,
39
+ `height` integer,
40
+ `alt` text,
41
+ `caption` text,
42
+ `uploaded_at` integer
43
+ );
44
+ CREATE UNIQUE INDEX `media_key_unique` ON `media` (`key`);
45
+
46
+ CREATE TABLE `site_settings` (
47
+ `id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
48
+ `site_name` text,
49
+ `tagline` text,
50
+ `logo_url` text,
51
+ `favicon_url` text,
52
+ `brand_color` text,
53
+ `secondary_color` text,
54
+ `tertiary_color` text,
55
+ `font_pairing` text,
56
+ `homepage_layout` text,
57
+ `dark_mode` integer DEFAULT false NOT NULL,
58
+ `theme` text,
59
+ `spacing_preset` text,
60
+ `type_tokens` text,
61
+ `nav_background` text,
62
+ `nav_text_color` text,
63
+ `footer_background` text,
64
+ `footer_text_color` text,
65
+ `page_background` text,
66
+ `surface_background` text,
67
+ `contact_email` text,
68
+ `contact_phone` text,
69
+ `contact_address` text,
70
+ `social_links` text,
71
+ `nav_links` text,
72
+ `meta_description` text,
73
+ `default_og_image_url` text,
74
+ `disable_indexing` integer DEFAULT false NOT NULL,
75
+ `primary_domain` text,
76
+ `domain_provider` text,
77
+ `nameserver_delegated` integer DEFAULT false NOT NULL,
78
+ `cf_account_id` text,
79
+ `cf_api_token_scoped` integer DEFAULT false NOT NULL,
80
+ `features` text,
81
+ `custom` text,
82
+ CONSTRAINT "site_settings_singleton" CHECK ("site_settings"."id" = 1)
83
+ );
84
+
85
+ CREATE TABLE `inquiries` (
86
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
87
+ `first_name` text,
88
+ `last_name` text,
89
+ `email` text NOT NULL,
90
+ `regarding` text,
91
+ `message` text NOT NULL,
92
+ `created_at` integer
93
+ );
94
+
95
+ -- Full-text search over pages (FTS5). A virtual table has no Drizzle schema, so
96
+ -- it's hand-authored; kept in sync on publish, backfill via POST /api/louise/pages/reindex.
97
+ CREATE VIRTUAL TABLE IF NOT EXISTS `pages_fts` USING fts5(`title`, `body`, `sections`);
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "__KEY__",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "astroid dev",
7
+ "build": "astroid build",
8
+ "preview": "astro preview",
9
+ "check": "astro check",
10
+ "generate": "astroid generate",
11
+ "doctor": "astroid doctor",
12
+ "seed:editors": "node scripts/seed-editors.mjs",
13
+ "astro": "astro"
14
+ },
15
+ "dependencies": {
16
+ "@astrojs/cloudflare": "^14.1.3",
17
+ "@astrojs/solid-js": "^7.0.1",
18
+ "@better-auth/passkey": "^1.6.23",
19
+ "astro": "^7.0.9",
20
+ "astroidjs": "^0.1.0",
21
+ "better-auth": "^1.6.23",
22
+ "drizzle-orm": "^0.45.2",
23
+ "louise-toolkit": "^0.14.0",
24
+ "solid-js": "^1.9.14",
25
+ "zod": "^4.4.3"
26
+ },
27
+ "devDependencies": {
28
+ "@astrojs/check": "^0.9.9",
29
+ "@cloudflare/workers-types": "^5.20260714.1",
30
+ "@tailwindcss/vite": "^4.1.0",
31
+ "daisyui": "^5.6.18",
32
+ "drizzle-kit": "^0.31.10",
33
+ "tailwindcss": "^4.1.0",
34
+ "typescript": "^5.9.3",
35
+ "wrangler": "^4.110.0"
36
+ },
37
+ "engines": {
38
+ "node": ">=24.0.0"
39
+ }
40
+ }
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // Seed the first editor(s) — an admin `user` row per OWNER_EMAIL / ENGINEER_EMAIL.
3
+ // A row here IS an editor and IS the magic-link allowlist, so this bootstraps
4
+ // access before anyone can sign in. Idempotent (INSERT OR IGNORE on unique email).
5
+ // After this, add more editors from the Users panel (never by editing env).
6
+ //
7
+ // OWNER_EMAIL=you@example.com pnpm seed:editors # seeds the remote D1
8
+ // OWNER_EMAIL=you@example.com pnpm seed:editors --local # seeds the local dev D1
9
+ //
10
+ // Mirrors the INSERT louise-toolkit's editorsRoute uses (ISO-string dates,
11
+ // emailVerified = 1, role 'admin').
12
+
13
+ import { execFileSync } from "node:child_process";
14
+ import { randomUUID } from "node:crypto";
15
+
16
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
17
+
18
+ const emails = [process.env.OWNER_EMAIL, process.env.ENGINEER_EMAIL]
19
+ .map((e) => e?.trim().toLowerCase())
20
+ .filter((e) => !!e);
21
+
22
+ if (emails.length === 0) {
23
+ console.error("Set OWNER_EMAIL (optionally ENGINEER_EMAIL) first, e.g.:");
24
+ console.error(" OWNER_EMAIL=you@example.com pnpm seed:editors");
25
+ process.exit(1);
26
+ }
27
+ for (const email of emails) {
28
+ if (!EMAIL_RE.test(email)) {
29
+ console.error(`Refusing to seed a malformed email: ${email}`);
30
+ process.exit(1);
31
+ }
32
+ }
33
+
34
+ const target = process.argv.includes("--local") ? "--local" : "--remote";
35
+ const now = new Date().toISOString();
36
+
37
+ /**
38
+ * Quote a value as a SQL string literal, doubling any embedded single quote.
39
+ * `wrangler d1 execute` takes raw SQL via `--command` with no parameter binding,
40
+ * so values have to be escaped rather than bound — and an apostrophe is legal in
41
+ * an email local part, so this is a correctness fix as much as a safety one.
42
+ */
43
+ const q = (value) => `'${String(value).replace(/'/g, "''")}'`;
44
+
45
+ for (const email of emails) {
46
+ const id = randomUUID();
47
+ const name = email.split("@")[0];
48
+ const sql =
49
+ "INSERT OR IGNORE INTO user " +
50
+ "(id, name, email, emailVerified, createdAt, updatedAt, role, firstName, lastName) VALUES " +
51
+ `(${q(id)}, ${q(name)}, ${q(email)}, 1, ${q(now)}, ${q(now)}, 'admin', NULL, NULL);`;
52
+ console.log(`Seeding editor ${email} (${target}) …`);
53
+ execFileSync("wrangler", ["d1", "execute", "DB", target, "--command", sql], { stdio: "inherit" });
54
+ }
55
+ console.log("Done — these emails can now request a magic-link sign-in.");
@@ -0,0 +1,14 @@
1
+ -- Seed the editable home page (slug `home`) that src/pages/index.astro renders.
2
+ -- Idempotent. Apply once after migrations:
3
+ -- wrangler d1 execute DB --file seed/home.seed.sql --remote
4
+ -- (drop --remote for the local dev D1)
5
+ INSERT OR IGNORE INTO pages (slug, title, body, status, sort_order, created_at, updated_at)
6
+ VALUES (
7
+ 'home',
8
+ '__BRAND_NAME__',
9
+ '<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>',
10
+ 'published',
11
+ 0,
12
+ unixepoch(),
13
+ unixepoch()
14
+ );
@@ -0,0 +1,60 @@
1
+ // The auth seam Astroid's generated worker.ts + middleware.ts import
2
+ // (`resolveEditor`), plus the Better Auth catch-all the /api/auth route calls.
3
+ //
4
+ // DB-managed editors: an admin `user` row IS an editor. That table is BOTH the
5
+ // role source and the magic-link allowlist — `resolveAdmins` reads it, so only an
6
+ // existing editor's email can request a sign-in link. Seed the first editor with
7
+ // `pnpm seed:editors`; add more from the Users panel (editorsRoute), never by
8
+ // editing env. Magic-link + passkey come from `getLouiseAuth`; no passwords.
9
+
10
+ import { env } from "cloudflare:workers";
11
+ import {
12
+ type EditorSession,
13
+ getLouiseAuth,
14
+ handleAuthRequest,
15
+ type LouiseAuth,
16
+ type MagicLinkEmail,
17
+ resolveEditorSession,
18
+ } from "louise-toolkit/auth";
19
+
20
+ const BRAND = "__BRAND_NAME__";
21
+
22
+ /** The DB-managed editor allowlist: every admin `user` row. */
23
+ async function resolveAdmins(): Promise<string[]> {
24
+ const { results } = await env.DB.prepare(
25
+ "SELECT email FROM user WHERE role = 'admin'",
26
+ ).all<{ email: string }>();
27
+ return results.map((r) => r.email);
28
+ }
29
+
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
+ };
36
+ }
37
+
38
+ /** The request-scoped Better Auth instance (magic-link + passkey, DB allowlist). */
39
+ function getAuth(request: Request): Promise<LouiseAuth> {
40
+ return getLouiseAuth(env, new URL(request.url).origin, {
41
+ rpName: BRAND,
42
+ mailFrom: { email: env.MAIL_FROM, name: BRAND },
43
+ renderMagicLinkEmail,
44
+ resolveAdmins,
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Re-derive the editor session from the signed Better Auth session on every
50
+ * request — the seam the generated worker.ts + middleware.ts call. Null when the
51
+ * caller isn't a signed-in editor, which is what denies edit/write access.
52
+ */
53
+ export async function resolveEditor(request: Request): Promise<EditorSession | null> {
54
+ return resolveEditorSession(await getAuth(request), request);
55
+ }
56
+
57
+ /** Better Auth catch-all with the magic-link allowlist gate (see /api/auth). */
58
+ export async function handleAuth(request: Request): Promise<Response> {
59
+ return handleAuthRequest(await getAuth(request), request, await resolveAdmins());
60
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ interface Props {
3
+ title: string;
4
+ tagline: string;
5
+ }
6
+ const { title, tagline } = Astro.props;
7
+ ---
8
+
9
+ <section class="hero min-h-[60vh]">
10
+ <div class="hero-content text-center">
11
+ <div class="max-w-2xl">
12
+ <h1 class="text-5xl font-bold">{title}</h1>
13
+ <p class="py-6 text-lg opacity-80">{tagline}</p>
14
+ </div>
15
+ </div>
16
+ </section>
@@ -0,0 +1,65 @@
1
+ ---
2
+ // Boots the in-page Louise editor — the edit bar + the Settings drawer (Pages,
3
+ // Media, Settings, Users). Drop it before </body> in your layout.
4
+ //
5
+ // Gated on `Astro.locals.editMode`, which the generated middleware sets for a
6
+ // signed-in editor who has toggled edit mode on (`?louise`). An anonymous visitor
7
+ // gets editMode=false, so the heavy Solid/ProseKit client is never even sent to
8
+ // them. The client self-injects its own styles.
9
+ //
10
+ // Pass `versionedPageId` from a page whose `<Editable>` fields should stage drafts
11
+ // + publish (the layout forwards it). Omit it and the bar is Settings-only.
12
+ const { editMode, editor } = Astro.locals;
13
+ const { versionedPageId } = Astro.props as { versionedPageId?: number };
14
+ ---
15
+
16
+ {
17
+ editMode && (
18
+ <>
19
+ {/* Per-render values (editor name, versioned page id) ride as data-* on this
20
+ marker element rather than a define:vars inline script. define:vars forces
21
+ is:inline, whose content varies per request and so can't be hashed by
22
+ Astro's security.csp — it would be CSP-blocked. The boot script below
23
+ reads them from here; being static, it hashes cleanly into script-src.
24
+ The element also gates the editor: no marker (a non-edit page swapped in
25
+ by a view transition) → boot() no-ops. */}
26
+ <div
27
+ id="louise-boot"
28
+ hidden
29
+ data-louise-user={editor?.name ?? "Editor"}
30
+ data-louise-versioned-page={versionedPageId != null ? String(versionedPageId) : undefined}
31
+ />
32
+ <script>
33
+ // Lazy-import the editor client only in edit mode. `mountLouise` renders
34
+ // the edit bar; `mountSettings` renders the drawer (Pages/Media/Settings +
35
+ // Users, backed by editorsRoute). When the page passes `versionedPageId`,
36
+ // the bar gains Save draft + Publish, and this page's `<Editable>` fields
37
+ // are edited in place — their changes stage a draft, promoted on Publish.
38
+ function boot() {
39
+ const el = document.getElementById("louise-boot");
40
+ if (!el) return;
41
+ Promise.all([
42
+ import("louise-toolkit/client"),
43
+ import("louise-toolkit/client/settings"),
44
+ ])
45
+ .then(([client, settings]) => {
46
+ settings.mountSettings({
47
+ userName: el.dataset.louiseUser || "Editor",
48
+ users: true,
49
+ });
50
+ const versioned = el.dataset.louiseVersionedPage;
51
+ client.mountLouise({
52
+ onOpenSettings: () =>
53
+ window.dispatchEvent(new Event(settings.OPEN_SETTINGS_EVENT)),
54
+ versionedPageId: versioned ? Number(versioned) : undefined,
55
+ });
56
+ })
57
+ .catch((err) => console.error("[louise] editor bootstrap failed", err));
58
+ }
59
+ boot();
60
+ // Re-boot after a view-transition navigation (the swapped-in body needs it).
61
+ document.addEventListener("astro:page-load", boot);
62
+ </script>
63
+ </>
64
+ )
65
+ }
@@ -0,0 +1,49 @@
1
+ /// <reference path="../.astro/types.d.ts" />
2
+ /// <reference types="astro/client" />
3
+ /// <reference types="@cloudflare/workers-types" />
4
+
5
+ // The Cloudflare bindings this Worker exposes (wrangler.jsonc), read via
6
+ // `import { env } from "cloudflare:workers"`. Astroid generated the wrangler
7
+ // bindings; this is the type over them. Add a binding here when you add one to
8
+ // wrangler.jsonc (e.g. AI, a Queue, a Durable Object).
9
+ type CloudflareEnv = {
10
+ /** D1 — pages / site_settings / media / inquiries + the Better Auth tables. */
11
+ DB: D1Database;
12
+ /** R2 — uploaded media, streamed back through the Worker at MEDIA_URL. */
13
+ MEDIA: R2Bucket;
14
+ /** Public base for media URLs (the framework-agnostic media route reads this). */
15
+ MEDIA_URL: string;
16
+ /** Cloudflare Images — upload dimensions + server-side transforms. */
17
+ IMAGES: ImagesBinding;
18
+ /** KV — the security rate limiter. */
19
+ RL: KVNamespace;
20
+ /** KV — the autosave draft write-buffer (optional; falls back to direct D1). */
21
+ DRAFTS?: KVNamespace;
22
+ /** Cloudflare Email Sending — magic-link + notification email. */
23
+ EMAIL: SendEmail;
24
+ /** Static assets (bound by the @astrojs/cloudflare adapter). */
25
+ ASSETS: Fetcher;
26
+ /** Signs Better Auth sessions (`wrangler secret put SESSION_SECRET`). */
27
+ SESSION_SECRET: string;
28
+ /** First editor's email — seeded as an admin, then part of the DB allowlist. */
29
+ OWNER_EMAIL: string;
30
+ /** Optional second bootstrap editor (e.g. your engineer). */
31
+ ENGINEER_EMAIL?: string;
32
+ /** `from` address for outbound email. */
33
+ MAIL_FROM: string;
34
+ };
35
+
36
+ // `env` from `cloudflare:workers` is typed as the augmentable `Cloudflare.Env`.
37
+ declare namespace Cloudflare {
38
+ interface Env extends CloudflareEnv {}
39
+ }
40
+
41
+ // Middleware sets these; bindings themselves come from `cloudflare:workers`.
42
+ declare namespace App {
43
+ interface Locals {
44
+ /** Resolved editor session (authorizes writes). Null when not signed in. */
45
+ editor: import("louise-toolkit/auth").EditorSession | null;
46
+ /** Whether the page should render edit affordances. */
47
+ editMode: boolean;
48
+ }
49
+ }
@@ -0,0 +1,30 @@
1
+ ---
2
+ // The base HTML shell. `Astro.locals.editMode` is set by the generated
3
+ // middleware.ts (from your auth seam); LouiseEdit boots the editor when it's on.
4
+ import LouiseEdit from "../components/LouiseEdit.astro";
5
+ import "../styles/site.css";
6
+
7
+ interface Props {
8
+ title?: string;
9
+ description?: string;
10
+ /** A `pages` row id whose `<Editable>` fields stage drafts + publish. */
11
+ versionedPageId?: number;
12
+ }
13
+
14
+ const { title = "__BRAND_NAME__", description, versionedPageId } = Astro.props;
15
+ const { editMode } = Astro.locals;
16
+ ---
17
+
18
+ <!doctype html>
19
+ <html lang="en" data-theme="light">
20
+ <head>
21
+ <meta charset="utf-8" />
22
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
23
+ <title>{title}</title>
24
+ {description && <meta name="description" content={description} />}
25
+ </head>
26
+ <body class="min-h-screen bg-base-100 text-base-content" data-edit-mode={editMode ? "" : undefined}>
27
+ <slot />
28
+ <LouiseEdit versionedPageId={versionedPageId} />
29
+ </body>
30
+ </html>
@@ -0,0 +1,9 @@
1
+ // Better Auth catch-all — magic-link sign-in, passkey, session. The allowlist
2
+ // gate (handleAuth → handleAuthRequest) rejects magic-link requests from
3
+ // non-editor emails before Better Auth runs, enumeration-safe.
4
+ import type { APIRoute } from "astro";
5
+ import { handleAuth } from "../../../auth";
6
+
7
+ export const prerender = false;
8
+
9
+ export const ALL: APIRoute = ({ request }) => handleAuth(request);
@@ -0,0 +1,62 @@
1
+ ---
2
+ // The home page — an editable `pages` row rendered in place. Reads the published
3
+ // `home` row for the public view; in edit mode it reads the latest DRAFT so an
4
+ // editor's in-progress edits resume. The <Editable> fields carry the markers the
5
+ // Louise client turns into inline editors; saves stage a draft (versionedPageId),
6
+ // and Publish promotes it. Seed the row once with `seed/home.seed.sql`.
7
+ import Editable from "astroidjs/components/Editable.astro";
8
+ import { env } from "cloudflare:workers";
9
+ import Hero from "../components/Hero.astro";
10
+ import Site from "../layouts/Site.astro";
11
+
12
+ export const prerender = false;
13
+
14
+ const { editMode } = Astro.locals;
15
+
16
+ let page: { id: number; title: string; body: string | null } | null = null;
17
+ try {
18
+ page = await env.DB.prepare("SELECT id, title, body FROM pages WHERE slug = 'home'").first();
19
+ } catch {
20
+ // No DB binding yet (pre-provision) — fall through to the seed-me prompt.
21
+ }
22
+
23
+ let title = page?.title ?? "__BRAND_NAME__";
24
+ let body = page?.body ?? "";
25
+
26
+ // In edit mode, prefer the latest unpublished draft so edits resume across reloads.
27
+ if (editMode && page) {
28
+ try {
29
+ const draft = await env.DB.prepare(
30
+ "SELECT version_data FROM pages_versions WHERE parent_id = ? AND status = 'draft' ORDER BY created_at DESC LIMIT 1",
31
+ )
32
+ .bind(page.id)
33
+ .first<{ version_data: string }>();
34
+ if (draft?.version_data) {
35
+ const d = JSON.parse(draft.version_data) as { title?: string; body?: string };
36
+ if (typeof d.title === "string") title = d.title;
37
+ if (typeof d.body === "string") body = d.body;
38
+ }
39
+ } catch {
40
+ // Non-fatal — fall back to the live row.
41
+ }
42
+ }
43
+ ---
44
+
45
+ <Site title={title} description={undefined} versionedPageId={page?.id}>
46
+ {
47
+ page ? (
48
+ <main class="mx-auto max-w-3xl px-6 py-16">
49
+ <Editable as="h1" collection="pages" key={page.id} field="title" class="text-5xl font-bold">
50
+ {title}
51
+ </Editable>
52
+ <Editable as="div" collection="pages" key={page.id} field="body" type="richtext" class="prose mt-6 max-w-none">
53
+ <Fragment set:html={body} />
54
+ </Editable>
55
+ </main>
56
+ ) : (
57
+ <main>
58
+ <Hero title={title} tagline="Seed the home page (seed/home.seed.sql) to edit it in place." />
59
+ </main>
60
+ )
61
+ }
62
+ </Site>
@@ -0,0 +1,60 @@
1
+ ---
2
+ // Editor sign-in. Emails a magic link (Better Auth) to an allowlisted editor;
3
+ // clicking it starts a session and lands on `/?louise` (edit mode on). The gate
4
+ // is enumeration-safe — the response is identical whether or not the email is an
5
+ // editor — so the UI always says "check your email".
6
+ import Site from "../layouts/Site.astro";
7
+
8
+ export const prerender = false;
9
+ ---
10
+
11
+ <Site title="Sign in">
12
+ <main class="hero min-h-screen">
13
+ <div class="hero-content w-full max-w-sm">
14
+ <form id="magic" class="card w-full bg-base-200 shadow-sm">
15
+ <div class="card-body gap-4">
16
+ <h1 class="card-title">Sign in to edit</h1>
17
+ <label class="form-control w-full">
18
+ <span class="label-text mb-1">Editor email</span>
19
+ <input
20
+ id="email"
21
+ name="email"
22
+ type="email"
23
+ required
24
+ autocomplete="email"
25
+ placeholder="you@example.com"
26
+ class="input input-bordered w-full"
27
+ />
28
+ </label>
29
+ <button type="submit" class="btn btn-primary">Email me a sign-in link</button>
30
+ <p id="msg" class="text-sm opacity-70" aria-live="polite"></p>
31
+ </div>
32
+ </form>
33
+ </div>
34
+ </main>
35
+
36
+ {/* Processed (NOT is:inline) so Astro's security.csp hashes it into script-src.
37
+ Keep it dependency-free and static — an is:inline or define:vars script here
38
+ would be unhashable and CSP-blocked. */}
39
+ <script>
40
+ const form = document.getElementById("magic");
41
+ const msg = document.getElementById("msg");
42
+ form?.addEventListener("submit", async (e) => {
43
+ e.preventDefault();
44
+ const input = document.getElementById("email") as HTMLInputElement | null;
45
+ const email = input?.value.trim();
46
+ if (!email || !msg) return;
47
+ msg.textContent = "Sending…";
48
+ try {
49
+ await fetch("/api/auth/sign-in/magic-link", {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json" },
52
+ body: JSON.stringify({ email, callbackURL: "/?louise" }),
53
+ });
54
+ msg.textContent = "If that email is an editor, a sign-in link is on its way.";
55
+ } catch {
56
+ msg.textContent = "Something went wrong — please try again.";
57
+ }
58
+ });
59
+ </script>
60
+ </Site>
@@ -0,0 +1,10 @@
1
+ /* The site's theme. Tailwind v4 + daisyUI; the Louise editor theme layers on top.
2
+ Your brand color is the single token below — Astroid wrote it from your config
3
+ (theme.colors.brand). Restyle by editing here; it flows through the utilities. */
4
+ @import "tailwindcss";
5
+ @plugin "daisyui";
6
+ @import "louise-toolkit/theme/louise.css";
7
+
8
+ :root {
9
+ --brand: __BRAND_COLOR__;
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "astro/tsconfigs/strict",
3
+ "include": [".astro/types.d.ts", "**/*"],
4
+ "exclude": ["dist"],
5
+ "compilerOptions": {
6
+ "jsx": "preserve",
7
+ "jsxImportSource": "solid-js",
8
+ "types": ["@cloudflare/workers-types"]
9
+ }
10
+ }