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 +21 -0
- package/README.md +71 -0
- package/index.mjs +249 -0
- package/package.json +39 -0
- package/template/README.md +106 -0
- package/template/_env.example +12 -0
- package/template/_gitignore +19 -0
- package/template/astro.config.mjs +30 -0
- package/template/drizzle.config.ts +16 -0
- package/template/migrations/0000_content.sql +97 -0
- package/template/package.json +40 -0
- package/template/scripts/seed-editors.mjs +55 -0
- package/template/seed/home.seed.sql +14 -0
- package/template/src/auth.ts +60 -0
- package/template/src/components/Hero.astro +16 -0
- package/template/src/components/LouiseEdit.astro +65 -0
- package/template/src/env.d.ts +49 -0
- package/template/src/layouts/Site.astro +30 -0
- package/template/src/pages/api/auth/[...all].ts +9 -0
- package/template/src/pages/index.astro +62 -0
- package/template/src/pages/login.astro +60 -0
- package/template/src/styles/site.css +10 -0
- package/template/tsconfig.json +10 -0
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
|
+
[](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,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
|
+
}
|