create-astroid 0.10.0 → 0.12.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/index.mjs CHANGED
@@ -13,8 +13,17 @@
13
13
  // wrangler) fills them in. The generators are the SAME ones `astroid generate`
14
14
  // uses, so a fresh project is already in sync.
15
15
 
16
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
17
- import { basename, dirname, join, resolve } from "node:path";
16
+ import { execFileSync } from "node:child_process";
17
+ import {
18
+ existsSync,
19
+ mkdirSync,
20
+ readdirSync,
21
+ readFileSync,
22
+ realpathSync,
23
+ statSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
18
27
  import { createInterface } from "node:readline/promises";
19
28
  import { fileURLToPath } from "node:url";
20
29
  import {
@@ -31,8 +40,41 @@ import {
31
40
  generateAstroidSecretsEnv,
32
41
  generateAstroidWrangler,
33
42
  } from "astroidjs";
43
+ import {
44
+ addWorkspacePackage,
45
+ INTO_REPOSITORY_FILES,
46
+ intoPathProblem,
47
+ intoRootScripts,
48
+ intoScriptName,
49
+ mergeRootScripts,
50
+ workersBuildsSettings,
51
+ } from "./into.mjs";
34
52
  import { toolkitRanges } from "./toolkit-ranges.mjs";
35
53
 
54
+ /** The repository root holding `cwd`, or null outside a git checkout. */
55
+ function gitRoot(cwd) {
56
+ try {
57
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
58
+ cwd,
59
+ encoding: "utf8",
60
+ stdio: ["ignore", "pipe", "ignore"],
61
+ }).trim();
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /** Whether git ignores `path` (relative to `root`); null when git can't say. */
68
+ function gitIgnores(root, path) {
69
+ try {
70
+ execFileSync("git", ["check-ignore", "-q", path], { cwd: root, stdio: "ignore" });
71
+ return true;
72
+ } catch (error) {
73
+ // Exit 1 is "not ignored"; anything else is git failing to answer.
74
+ return error?.status === 1 ? false : null;
75
+ }
76
+ }
77
+
36
78
  const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), "template");
37
79
 
38
80
  // Files (and dirs) whose leading `_` is stripped on copy (npm strips real
@@ -45,6 +87,24 @@ const DOTFILE_RENAMES = {
45
87
  _github: ".github",
46
88
  };
47
89
 
90
+ // The editor-free app shape (`--app`, `editor: false`). It starts from the same
91
+ // template, leaves out the editor's files, and lays `template/_app/` over the
92
+ // rest, so the two shapes share every file that doesn't depend on an editor.
93
+ const APP_OVERLAY = "_app";
94
+ const APP_SKIP = new Set([
95
+ "migrations/0000_content.sql",
96
+ "scripts/seed-editors.mjs",
97
+ "src/auth.ts",
98
+ "src/components/Hero.astro",
99
+ "src/components/LouiseEdit.astro",
100
+ "src/layouts/Site.astro",
101
+ "src/lib/pages.ts",
102
+ "src/pages/[...slug].astro",
103
+ "src/pages/api/auth/[...all].ts",
104
+ "src/pages/contact.astro",
105
+ "src/pages/login.astro",
106
+ ]);
107
+
48
108
  // Archetype → default editable home sections. Imported from astroidjs rather
49
109
  // than duplicated here: as a literal in this file it could name a section that
50
110
  // doesn't exist and nothing would say so (it did—`marquee`, `featured`,
@@ -99,15 +159,21 @@ async function prompt(question, fallback) {
99
159
  }
100
160
 
101
161
  // --- scaffold --------------------------------------------------------------
102
- function copyTemplate(srcDir, destDir, tokens) {
103
- mkdirSync(destDir, { recursive: true });
162
+ // `skip` holds template-relative POSIX paths to leave out. Directories are made
163
+ // only when a file lands in them, so skipping a directory's every file leaves
164
+ // no empty directory behind.
165
+ function copyTemplate(srcDir, destDir, tokens, { skip = new Set(), rel = "" } = {}) {
104
166
  for (const entry of readdirSync(srcDir)) {
167
+ // The app overlay is copied on its own, over the rest, and only for `--app`.
168
+ if (rel === "" && entry === APP_OVERLAY) continue;
105
169
  const src = join(srcDir, entry);
170
+ const path = rel ? `${rel}/${entry}` : entry;
106
171
  const renamed = DOTFILE_RENAMES[entry] ?? entry;
107
172
  const dest = join(destDir, renamed);
108
173
  if (statSync(src).isDirectory()) {
109
- copyTemplate(src, dest, tokens);
110
- } else {
174
+ copyTemplate(src, dest, tokens, { skip, rel: path });
175
+ } else if (!skip.has(path)) {
176
+ mkdirSync(destDir, { recursive: true });
111
177
  const raw = readFileSync(src, "utf8");
112
178
  writeFileSync(dest, applyTokens(raw, tokens));
113
179
  }
@@ -128,12 +194,15 @@ function astroidConfigSource(config) {
128
194
  "export default defineAstroid({",
129
195
  ` key: ${JSON.stringify(config.key)},`,
130
196
  ` archetype: ${JSON.stringify(config.archetype)},`,
197
+ // Must be emitted: every generator reads the shape from THIS file, so a
198
+ // config without it would regenerate an editor this app has no seam for.
199
+ ...(config.editor === false ? [" editor: false,"] : []),
131
200
  ...(config.hosts?.length ? [` hosts: ${JSON.stringify(config.hosts)},`] : []),
132
201
  " theme: {",
133
202
  ` name: ${JSON.stringify(config.theme.name)},`,
134
203
  ` colors: { brand: ${JSON.stringify(config.theme.colors.brand)} },`,
135
204
  " },",
136
- ` sections: ${JSON.stringify(config.sections)},`,
205
+ ...(config.sections ? [` sections: ${JSON.stringify(config.sections)},`] : []),
137
206
  // `square` must be emitted too: `astroid doctor` derives the required
138
207
  // secrets from THIS file, so a multi-location project whose config lost the
139
208
  // option would be told it is missing a SQUARE_LOCATION_ID it must not have.
@@ -169,6 +238,15 @@ function astroidConfigSource(config) {
169
238
  " },",
170
239
  ]
171
240
  : []),
241
+ // Must be emitted: the scaffold's layout reads `credit` from THIS file at
242
+ // render time, so a config without it renders no footer.
243
+ ...(config.credit
244
+ ? [
245
+ ` credit: { name: ${JSON.stringify(config.credit.name)}, href: ${JSON.stringify(
246
+ config.credit.href,
247
+ )} },`,
248
+ ]
249
+ : []),
172
250
  ' deploy: { platform: "cloudflare" },',
173
251
  "});",
174
252
  "",
@@ -206,11 +284,24 @@ Options:
206
284
  with presence, field sync, and a rich-text soft-lock
207
285
  --portal Add a customer/member portal: a second, isolated auth
208
286
  instance plus role-gated routes
287
+ --into <path> Scaffold one app into an existing repository at <path>
288
+ (for example, workers/order), beside the app already
289
+ there: adds it to the root pnpm-workspace.yaml and adds
290
+ namespaced root scripts, and leaves the rest alone
291
+ --app Scaffold an app with no pages to edit (editor: false):
292
+ no editor, sign-in, or content tables, and a versioned
293
+ JSON API under /api/v1. Its settings stay in the editor
294
+ of a site that has one
295
+ --credit-name <name> Credit who built the site in the footer ("Site by <name>")
296
+ --credit-href <url> Where the credit links; needs --credit-name, and the
297
+ other way round
209
298
  -h, --help Show this help
210
299
  -v, --version Show the create-astroid version
211
300
 
212
301
  Anything not passed as a flag is prompted for; in a non-TTY every prompt takes
213
302
  its default, so the command is CI-safe. The target directory must be empty.
303
+ With --app there is no archetype prompt; --archetype still sets the business
304
+ type in structured data.
214
305
  `;
215
306
 
216
307
  async function main() {
@@ -231,15 +322,45 @@ async function main() {
231
322
  return;
232
323
  }
233
324
 
234
- const dirArg = positionals[0] ?? flags.dir;
325
+ // `--into <path>`: one app into a repository that already holds one. The
326
+ // path is the app's directory, relative to where this runs, and has to land
327
+ // inside the repository, whose root gets the workspace entry and scripts.
328
+ const intoArg = flags.into;
329
+ if (intoArg !== undefined && (typeof intoArg !== "string" || positionals[0] || flags.dir)) {
330
+ process.stderr.write(
331
+ typeof intoArg !== "string"
332
+ ? "create-astroid: --into needs a path, for example --into workers/order\n"
333
+ : "create-astroid: --into names the directory, so it can't go with --dir or a positional one\n",
334
+ );
335
+ process.exit(1);
336
+ }
337
+ const into = typeof intoArg === "string";
338
+ // Real paths on both sides: git reports the root through symlinks (macOS's
339
+ // /tmp is /private/tmp), and a relative path across the two would climb out.
340
+ const cwd = realpathSync(process.cwd());
341
+ const repoRoot = into ? (gitRoot(cwd) ?? cwd) : null;
342
+ const intoPath = into ? relative(repoRoot, resolve(cwd, intoArg)).split(sep).join("/") : null;
343
+ if (into) {
344
+ const problem = intoPathProblem(intoPath);
345
+ if (problem) {
346
+ process.stderr.write(`create-astroid: ${problem}\n`);
347
+ process.exit(1);
348
+ }
349
+ }
350
+
351
+ const dirArg = into ? intoArg : (positionals[0] ?? flags.dir);
235
352
  const rawName = flags.name || (dirArg ? basename(resolve(dirArg)) : undefined);
236
353
  const name = await prompt("Brand / site name", rawName || "My Astroid Site");
237
354
  const key = slugify(
238
355
  flags.key || (await prompt("Project key (slug)", slugify(name) || "my-site")),
239
356
  );
240
357
  const dir = resolve(dirArg || (await prompt("Directory", key)) || key);
358
+ // An app with no pages to edit (`editor: false`). Its archetype chooses no
359
+ // sections, so it isn't prompted for; it only sets the structured-data type.
360
+ const app = flags.app === true || flags.app === "true";
241
361
  const archetypeRaw = (
242
- flags.archetype || (await prompt(`Archetype (${ARCHETYPES.join("/")})`, "marketing"))
362
+ flags.archetype ||
363
+ (app ? "marketing" : await prompt(`Archetype (${ARCHETYPES.join("/")})`, "marketing"))
243
364
  ).toLowerCase();
244
365
  const archetype = ARCHETYPES.includes(archetypeRaw) ? archetypeRaw : "marketing";
245
366
  const color = flags.color || (await prompt("Brand color (hex)", "#5b4bff"));
@@ -247,7 +368,9 @@ async function main() {
247
368
  // The customer PORTAL is opt-in via --portal, but a storefront IMPLIES one—a
248
369
  // shop has customers who sign in, reorder, and track orders—so enable it there
249
370
  // by default. (Commerce below stays opt-in: infra a marketing site shouldn't carry.)
250
- const portal = flags.portal === true || flags.portal === "true" || archetype === "storefront";
371
+ // An app gets one only when asked: its customers may sign in on the site.
372
+ const portal =
373
+ flags.portal === true || flags.portal === "true" || (archetype === "storefront" && !app);
251
374
  // The map module is opt-in and pulls real weight (maplibre-gl is ~1 MB), so
252
375
  // it is never on by default.
253
376
  const map = flags.map === true || flags.map === "true";
@@ -289,18 +412,70 @@ async function main() {
289
412
  process.exit(1);
290
413
  }
291
414
 
415
+ // A pair or nothing: a credit with no link, or a link with nothing to show,
416
+ // is a half-typed flag rather than a choice.
417
+ const creditName = typeof flags["credit-name"] === "string" ? flags["credit-name"] : undefined;
418
+ const creditHref = typeof flags["credit-href"] === "string" ? flags["credit-href"] : undefined;
419
+ if (
420
+ (flags["credit-name"] !== undefined || flags["credit-href"] !== undefined) &&
421
+ !(creditName && creditHref)
422
+ ) {
423
+ process.stderr.write("create-astroid: --credit-name and --credit-href go together\n");
424
+ process.exit(1);
425
+ }
426
+
427
+ // Refused here with the flag's name, rather than by `defineAstroid` with a
428
+ // stack trace: live editing needs an editor to edit with.
429
+ if (app && realtime) {
430
+ process.stderr.write("create-astroid: --realtime needs an editor, so it can't go with --app\n");
431
+ process.exit(1);
432
+ }
433
+
292
434
  if (existsSync(dir) && readdirSync(dir).length > 0) {
293
435
  process.stderr.write(`create-astroid: target directory is not empty: ${dir}\n`);
294
436
  process.exit(1);
295
437
  }
296
438
 
439
+ // Every root edit `--into` makes, worked out before anything is written, so
440
+ // a root file it can't edit stops the scaffold rather than leaving half of it.
441
+ const rootEdits = [];
442
+ if (into) {
443
+ const workspacePath = join(repoRoot, "pnpm-workspace.yaml");
444
+ if (existsSync(workspacePath)) {
445
+ const current = readFileSync(workspacePath, "utf8");
446
+ const next = addWorkspacePackage(current, intoPath);
447
+ if (next === null) {
448
+ process.stderr.write(
449
+ `create-astroid: can't add ${intoPath} to pnpm-workspace.yaml's \`packages\` safely. ` +
450
+ "Add it by hand, then run this again.\n",
451
+ );
452
+ process.exit(1);
453
+ }
454
+ if (next !== current) {
455
+ rootEdits.push({
456
+ path: workspacePath,
457
+ contents: next,
458
+ what: "pnpm-workspace.yaml (added the app)",
459
+ });
460
+ }
461
+ // The app's install runs from the root, under the root's build approvals.
462
+ if (!/workerd/.test(current)) {
463
+ rootEdits.push({
464
+ note:
465
+ "pnpm-workspace.yaml approves no `workerd` build, so `pnpm install` may refuse it. " +
466
+ "Add `allowBuilds: { esbuild: true, workerd: true }`.",
467
+ });
468
+ }
469
+ }
470
+ }
471
+
297
472
  // Validate + normalize through the real config surface (throws on a bad shape).
298
473
  const config = defineAstroid({
299
474
  key,
300
475
  archetype,
301
476
  ...(host ? { hosts: [host] } : {}),
302
477
  theme: { name, colors: { brand: color } },
303
- sections: ARCHETYPE_SECTIONS[archetype],
478
+ ...(app ? { editor: false } : { sections: ARCHETYPE_SECTIONS[archetype] }),
304
479
  ...(commerce
305
480
  ? {
306
481
  commerce: {
@@ -317,6 +492,7 @@ async function main() {
317
492
  // : {})` spreads would let the later one overwrite the earlier, silently
318
493
  // dropping a module whenever both were passed.
319
494
  ...(modules.length > 0 ? { modules } : {}),
495
+ ...(creditName && creditHref ? { credit: { name: creditName, href: creditHref } } : {}),
320
496
  deploy: { platform: "cloudflare" },
321
497
  });
322
498
 
@@ -328,6 +504,21 @@ async function main() {
328
504
  const realtimeEnv = generateAstroidRealtimeEnv(config);
329
505
  // The Square Web Payments public vars, or nothing.
330
506
  const checkoutEnv = generateAstroidCheckoutEnv(config);
507
+ // An app's portal is the only thing in it that signs anyone in or sends mail,
508
+ // so the session secret, the mail binding, and the sender come with it. The
509
+ // editor shape's env.d.ts and .env.example declare these for every project.
510
+ const appPortalEnv =
511
+ app && portal
512
+ ? [
513
+ " /** Cloudflare Email Sending: the portal's password-reset mail. */",
514
+ ' EMAIL: import("louise-toolkit/email").EmailSender;',
515
+ " /** Signs the portal's Better Auth sessions (`wrangler secret put SESSION_SECRET`). */",
516
+ " SESSION_SECRET: string;",
517
+ " /** `from` address for the portal's mail. */",
518
+ " MAIL_FROM: string;",
519
+ ].join("\n")
520
+ : "";
521
+ const envMembers = [appPortalEnv, envBindings, realtimeEnv, checkoutEnv].filter(Boolean);
331
522
  const tokens = {
332
523
  KEY: key,
333
524
  BRAND_NAME: name,
@@ -337,9 +528,7 @@ async function main() {
337
528
  // Extra CloudflareEnv members the queue pipeline needs, or nothing. A
338
529
  // declaration is a promise—a marketing site must not claim a binding its
339
530
  // wrangler.jsonc never creates.
340
- ASTROID_ENV_BINDINGS: [envBindings, realtimeEnv, checkoutEnv].filter(Boolean).join("\n")
341
- ? `\n${[envBindings, realtimeEnv, checkoutEnv].filter(Boolean).join("\n")}`
342
- : "",
531
+ ASTROID_ENV_BINDINGS: envMembers.length > 0 ? `\n${envMembers.join("\n")}` : "",
343
532
  // The portal session on App.Locals, or nothing—a project that types a
344
533
  // local it never sets invites a null-check nobody needs.
345
534
  ASTROID_PORTAL_LOCALS: portalLocals ? `\n${portalLocals}` : "",
@@ -348,10 +537,84 @@ async function main() {
348
537
  // module takes its dormant path deliberately rather than tripping over
349
538
  // an undefined binding. Empty for a project with no credentialed module.
350
539
  ASTROID_MODULE_SECRETS: generateAstroidSecretsEnv(config),
540
+ // The app shape's .env.example: the portal's secrets, or nothing.
541
+ ASTROID_APP_SECRETS:
542
+ app && portal
543
+ ? [
544
+ "",
545
+ "# --- portal ---------------------------------------------------------------",
546
+ "#",
547
+ "# Signs the portal's Better Auth sessions. Generate: `openssl rand -base64 32`.",
548
+ "# Empty is fine under `pnpm dev`, which serves on localhost.",
549
+ "SESSION_SECRET=",
550
+ "",
551
+ "# `from` address for the portal's password-reset mail.",
552
+ `MAIL_FROM=no-reply@${key}.example`,
553
+ ].join("\n")
554
+ : "",
351
555
  };
352
556
 
353
557
  // 1. The static floor (Astro app, auth seam, config files) with tokens filled.
354
- copyTemplate(TEMPLATE_DIR, dir, tokens);
558
+ // An app leaves out the editor's files and lays its own over the rest.
559
+ // `--into` leaves out the repository's files too; see 1a.
560
+ const skip = new Set([...(app ? APP_SKIP : []), ...(into ? INTO_REPOSITORY_FILES : [])]);
561
+ copyTemplate(TEMPLATE_DIR, dir, tokens, { skip });
562
+ if (app) copyTemplate(join(TEMPLATE_DIR, APP_OVERLAY), dir, tokens);
563
+
564
+ // 1a. `--into`: the repository's files. Each is the root's, so it's written
565
+ // there only when the root has none, and an existing one is left alone
566
+ // apart from the edits worked out above.
567
+ const intoNotes = [];
568
+ const intoWrote = [];
569
+ if (into) {
570
+ const fromTemplate = (rel) =>
571
+ applyTokens(readFileSync(join(TEMPLATE_DIR, rel), "utf8"), tokens);
572
+ for (const edit of rootEdits) {
573
+ if (edit.note) intoNotes.push(edit.note);
574
+ else {
575
+ writeFileSync(edit.path, edit.contents);
576
+ intoWrote.push(edit.what);
577
+ }
578
+ }
579
+ if (!existsSync(join(repoRoot, "pnpm-workspace.yaml"))) {
580
+ // The template's header says it isn't a workspace, which this one is.
581
+ const workspace = fromTemplate("pnpm-workspace.yaml").replace(
582
+ /^# Not a workspace — .*\n# .*\n/m,
583
+ "# The workspace for this repository's apps. pnpm reads its settings from\n" +
584
+ "# THIS file, and `overrides` in package.json is silently ignored.\n",
585
+ );
586
+ write(repoRoot, "pnpm-workspace.yaml", addWorkspacePackage(workspace, intoPath));
587
+ intoWrote.push("pnpm-workspace.yaml");
588
+ }
589
+ for (const doc of ["docs/ARCHITECTURE.md", "docs/DECISIONS.md", "docs/RUNBOOK.md"]) {
590
+ if (existsSync(join(repoRoot, doc))) continue;
591
+ write(repoRoot, doc, fromTemplate(doc));
592
+ intoWrote.push(doc);
593
+ }
594
+ // The root's .gitignore when there is one, as long as it keeps this app's
595
+ // secrets out. A pattern anchored to the root (`/.dev.vars`) wouldn't, so
596
+ // git is asked, and when it can't say, the app gets its own.
597
+ if (!existsSync(join(repoRoot, ".gitignore"))) {
598
+ write(repoRoot, ".gitignore", fromTemplate("_gitignore"));
599
+ intoWrote.push(".gitignore");
600
+ } else {
601
+ const ignored = gitIgnores(repoRoot, `${intoPath}/.dev.vars`);
602
+ if (ignored !== true) {
603
+ write(dir, ".gitignore", fromTemplate("_gitignore"));
604
+ intoNotes.push(
605
+ ignored === false
606
+ ? `The root .gitignore doesn't ignore ${intoPath}/.dev.vars, so the app has its own .gitignore.`
607
+ : `git couldn't say whether ${intoPath}/.dev.vars is ignored, so the app has its own .gitignore.`,
608
+ );
609
+ }
610
+ }
611
+ // The workflow runs the root app's checks from the root, so the second
612
+ // app's would be new steps in it; they're the repository's to add.
613
+ intoNotes.push(
614
+ `CI: add \`pnpm run doctor:${intoScriptName(intoPath)}\` and ` +
615
+ `\`pnpm run build:${intoScriptName(intoPath)}\` to the repository's workflow.`,
616
+ );
617
+ }
355
618
 
356
619
  // 1b. Toolkit versions + module dependencies, merged into the copied package.json.
357
620
  //
@@ -374,9 +637,38 @@ async function main() {
374
637
  pkg.dependencies = Object.fromEntries(
375
638
  Object.entries({ ...pkg.dependencies, ...extraDeps }).sort(([a], [b]) => a.localeCompare(b)),
376
639
  );
640
+ // An app has no editors to seed, and no script to seed them with.
641
+ if (app) delete pkg.scripts["seed:editors"];
642
+ // The root's pnpm runs the workspace, so the app doesn't pin its own.
643
+ if (into) delete pkg.packageManager;
377
644
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
378
645
  }
379
646
 
647
+ // 1c. `--into`: the root scripts that run this app from the root, added
648
+ // beside the existing ones and never over one. A root with no
649
+ // package.json gets a private one, pinning pnpm the way a scaffold does.
650
+ let intoScripts = { added: [], skipped: [] };
651
+ if (into) {
652
+ const rootPkgPath = join(repoRoot, "package.json");
653
+ const template = JSON.parse(readFileSync(join(TEMPLATE_DIR, "package.json"), "utf8"));
654
+ const rootPkg = existsSync(rootPkgPath)
655
+ ? JSON.parse(readFileSync(rootPkgPath, "utf8"))
656
+ : {
657
+ name: slugify(basename(repoRoot)) || "workspace",
658
+ private: true,
659
+ packageManager: template.packageManager,
660
+ };
661
+ intoScripts = mergeRootScripts(
662
+ rootPkg.scripts ?? {},
663
+ intoRootScripts(intoScriptName(intoPath), intoPath),
664
+ );
665
+ rootPkg.scripts = intoScripts.scripts;
666
+ writeFileSync(rootPkgPath, `${JSON.stringify(rootPkg, null, 2)}\n`);
667
+ for (const name of intoScripts.skipped) {
668
+ intoNotes.push(`The root already has a \`${name}\` script, so it was left as it was.`);
669
+ }
670
+ }
671
+
380
672
  // 2. The typed config the generators + the app read.
381
673
  write(dir, "astroid.config.ts", astroidConfigSource(config));
382
674
 
@@ -387,7 +679,8 @@ async function main() {
387
679
  // 3a. The home page seed, built from the config's own `sections`. It used to be
388
680
  // a fixed template file that seeded the marketing sections for every
389
681
  // archetype, and token substitution can't escape a brand name for SQL.
390
- write(dir, "seed/home.seed.sql", generateAstroidHomeSeed(config));
682
+ // An app has no pages, so it seeds none.
683
+ if (!app) write(dir, "seed/home.seed.sql", generateAstroidHomeSeed(config));
391
684
 
392
685
  // 3b. Every scaffold-once module file this config implies—the queue seam and
393
686
  // webhook receivers, the portfolio gallery page, the PWA service worker +
@@ -420,13 +713,19 @@ async function main() {
420
713
  // resolvable at scaffold time. If not, leave a stub + a one-liner to generate
421
714
  // it after install (the project has `louise` on its path then).
422
715
  let authMigrationOk = false;
716
+ // An app has no editor instance, so its portal's tables are its first.
717
+ const portalMigration = app
718
+ ? "migrations/0001_portal_auth.sql"
719
+ : "migrations/0002_portal_auth.sql";
423
720
  try {
424
721
  const { generateAuthSchemaSql } = await import("louise-toolkit/auth");
425
722
  // The EDITOR instance's tables—`louise_`-prefixed (the editor convention),
426
723
  // leaving the unprefixed `user`/`session` names free for a second/portal
427
724
  // instance. Must match the `tablePrefix` in src/auth.ts and the `louise_user`
428
725
  // table the generated `editorsRoute` reads.
429
- write(dir, "migrations/0001_auth.sql", generateAuthSchemaSql({ tablePrefix: "louise_" }));
726
+ if (!app) {
727
+ write(dir, "migrations/0001_auth.sql", generateAuthSchemaSql({ tablePrefix: "louise_" }));
728
+ }
430
729
  // The portal's own auth tables—a SECOND Better Auth instance sharing one D1
431
730
  // but never a row, so a portal account can't sign into the studio and an
432
731
  // editor doesn't appear in the portal. `customers: true` (email + password)
@@ -436,31 +735,110 @@ async function main() {
436
735
  if (config.portal?.enabled) {
437
736
  write(
438
737
  dir,
439
- "migrations/0002_portal_auth.sql",
738
+ portalMigration,
440
739
  generateAuthSchemaSql({ customers: true, tablePrefix: config.portal.tablePrefix ?? "" }),
441
740
  );
442
741
  }
443
742
  authMigrationOk = true;
444
743
  } catch {
445
- write(
446
- dir,
447
- "migrations/0001_auth.sql",
448
- "-- Better Auth tables (editor, louise_ prefix) — generate after install:\n-- pnpm exec louise gen-auth-schema --table-prefix louise_ --out migrations/0001_auth.sql\n",
449
- );
744
+ if (!app) {
745
+ write(
746
+ dir,
747
+ "migrations/0001_auth.sql",
748
+ "-- Better Auth tables (editor, louise_ prefix) — generate after install:\n-- pnpm exec louise gen-auth-schema --table-prefix louise_ --out migrations/0001_auth.sql\n",
749
+ );
750
+ }
450
751
  // Same stub for the portal's prefixed set. Without it a portal scaffold
451
752
  // looks complete, builds, and fails on the first sign-in with a missing
452
753
  // table—the one failure mode a stub exists to prevent.
453
754
  if (config.portal?.enabled) {
454
755
  write(
455
756
  dir,
456
- "migrations/0002_portal_auth.sql",
757
+ portalMigration,
457
758
  "-- Portal Better Auth tables (customers, unprefixed) — generate after install:\n" +
458
- "-- pnpm exec louise gen-auth-schema --out migrations/0002_portal_auth.sql\n",
759
+ `-- pnpm exec louise gen-auth-schema --out ${portalMigration}\n`,
459
760
  );
460
761
  }
461
762
  }
462
763
 
764
+ // An app with no tables of its own still gets the directory, because
765
+ // `astroid ship` applies migrations from it on every deploy until the config
766
+ // says another app owns the database (`deploy.migrations: false`).
767
+ if (app && !existsSync(join(dir, "migrations"))) write(dir, "migrations/.gitkeep", "");
768
+
769
+ if (into) {
770
+ // Everything runs from the root: one install, one lockfile, and the root
771
+ // scripts added above.
772
+ const scriptName = intoScriptName(intoPath);
773
+ const inApp = `pnpm --dir ${intoPath}`;
774
+ const settings = workersBuildsSettings(intoPath);
775
+ const width = Math.max(...settings.map(([label]) => label.length));
776
+ process.stdout.write(
777
+ [
778
+ "",
779
+ `✓ Scaffolded ${name} → ${intoPath}${app ? " (an app with no editor)" : ""}`,
780
+ ...(intoWrote.length ? [` at the repository root: ${intoWrote.join(", ")}`] : []),
781
+ ...(intoScripts.added.length ? [` root scripts: ${intoScripts.added.join(", ")}`] : []),
782
+ "",
783
+ "Next steps, from the repository root:",
784
+ " pnpm install",
785
+ ...(authMigrationOk
786
+ ? []
787
+ : [
788
+ " # generate the Better Auth migration(s), as the stub files in its",
789
+ " # migrations/ directory say, before applying migrations",
790
+ ]),
791
+ ` ${inApp} exec astroid provision`,
792
+ ...(app
793
+ ? []
794
+ : [
795
+ ` ${inApp} exec wrangler d1 migrations apply DB --remote`,
796
+ ` ${inApp} exec wrangler d1 execute DB --remote --file seed/home.seed.sql`,
797
+ ` OWNER_EMAIL=you@example.com ${inApp} run seed:editors`,
798
+ ]),
799
+ ` pnpm run dev:${scriptName}`,
800
+ ` pnpm run doctor:${scriptName}`,
801
+ "",
802
+ "A second app is a second Workers Builds project. Create one with:",
803
+ ...settings.map(([label, value]) => ` ${`${label}:`.padEnd(width + 2)}${value}`),
804
+ "Both projects deploy from the same release tag, through the one",
805
+ ".github/workflows/release.yml `astroid generate` writes at the root.",
806
+ ...(intoNotes.length ? ["", ...intoNotes.map((note) => `Note: ${note}`)] : []),
807
+ "",
808
+ ].join("\n"),
809
+ );
810
+ return;
811
+ }
812
+
463
813
  const rel = dir === process.cwd() ? "." : basename(dir);
814
+ if (app) {
815
+ process.stdout.write(
816
+ [
817
+ "",
818
+ `✓ Scaffolded ${name} → ${rel} (an app with no editor)`,
819
+ "",
820
+ "Next steps:",
821
+ ` cd ${rel}`,
822
+ " pnpm install",
823
+ ...(authMigrationOk || !config.portal?.enabled
824
+ ? []
825
+ : [
826
+ " # generate the portal's Better Auth migration:",
827
+ ` pnpm exec louise gen-auth-schema --out ${portalMigration}`,
828
+ ]),
829
+ " # create the Cloudflare resources wrangler.jsonc names, filling in their ids.",
830
+ " # To share another app's database instead, bind it by id and set",
831
+ " # `deploy: { migrations: false }` in astroid.config.ts.",
832
+ " pnpm exec astroid provision",
833
+ " # develop / ship:",
834
+ " pnpm dev # astroid dev (regenerates, then astro dev)",
835
+ " pnpm run doctor # validate config + bindings (`run` is required)",
836
+ " pnpm exec astroid ship production",
837
+ "",
838
+ ].join("\n"),
839
+ );
840
+ return;
841
+ }
464
842
  process.stdout.write(
465
843
  [
466
844
  "",
package/into.mjs ADDED
@@ -0,0 +1,203 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // `create-astroid --into <path>`: one app scaffolded into a repository that
4
+ // already holds one. The pure half of it, a module of its own so the test suite
5
+ // can import it without running the scaffolder: which workspace globs cover a
6
+ // path, how to add one, the root scripts, and the Workers Builds settings.
7
+ //
8
+ // Every edit here is text, not a parse and re-serialize. These are files a
9
+ // person owns, with their comments and their formatting, and an edit that
10
+ // rewrote the whole file to add one line would bury that line in a diff nobody
11
+ // reads.
12
+
13
+ /**
14
+ * Template-relative paths that belong to the repository rather than an app.
15
+ * `--into` never writes them into the app; each is written at the root only
16
+ * when the root has none.
17
+ */
18
+ export const INTO_REPOSITORY_FILES = [
19
+ "pnpm-workspace.yaml",
20
+ "_gitignore",
21
+ "_github/workflows/ci.yml",
22
+ "docs/ARCHITECTURE.md",
23
+ "docs/DECISIONS.md",
24
+ "docs/RUNBOOK.md",
25
+ ];
26
+
27
+ /**
28
+ * Why a path can't be scaffolded into, or null when it can. The path goes into
29
+ * root scripts and a workspace glob unquoted, so it's held to characters that
30
+ * need no quoting in either.
31
+ */
32
+ export function intoPathProblem(path) {
33
+ if (!path || path === ".")
34
+ return "--into needs a path inside the repository, such as workers/order";
35
+ if (path.startsWith("..") || path.startsWith("/")) {
36
+ return `--into path "${path}" is outside the repository`;
37
+ }
38
+ if (!/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(path)) {
39
+ return `--into path "${path}" may use only letters, digits, ".", "_", "-", and "/"`;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** A workspace glob as a regular expression over a POSIX path. */
45
+ function globToRegExp(glob) {
46
+ const clean = glob.replace(/^\.\//, "").replace(/\/+$/, "");
47
+ let source = "";
48
+ for (let i = 0; i < clean.length; i++) {
49
+ const c = clean[i];
50
+ if (c === "*" && clean[i + 1] === "*") {
51
+ source += ".*";
52
+ i++;
53
+ } else if (c === "*") source += "[^/]*";
54
+ else if (c === "?") source += "[^/]";
55
+ else source += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
56
+ }
57
+ return new RegExp(`^${source}$`);
58
+ }
59
+
60
+ /**
61
+ * The `packages` list of a pnpm-workspace.yaml, or null when it has none. Reads
62
+ * the two shapes pnpm's own docs use, a block list and a flow list.
63
+ */
64
+ export function workspacePackages(yaml) {
65
+ const lines = yaml.split("\n");
66
+ const at = lines.findIndex((line) => /^packages\s*:/.test(line));
67
+ if (at === -1) return null;
68
+ const unquote = (s) => s.trim().replace(/^["']|["']$/g, "");
69
+ const rest = lines[at]
70
+ .replace(/^packages\s*:/, "")
71
+ .replace(/\s+#.*$/, "")
72
+ .trim();
73
+ if (rest.startsWith("[")) {
74
+ return rest
75
+ .replace(/^\[|\]$/g, "")
76
+ .split(",")
77
+ .map(unquote)
78
+ .filter(Boolean);
79
+ }
80
+ const items = [];
81
+ for (const line of lines.slice(at + 1)) {
82
+ if (/^\s*(#.*)?$/.test(line)) continue;
83
+ const item = line.match(/^\s+-\s*(.+?)\s*(#.*)?$/);
84
+ if (!item) break;
85
+ items.push(unquote(item[1]));
86
+ }
87
+ return items;
88
+ }
89
+
90
+ /** Whether a workspace's `packages` globs already include `path`. */
91
+ export function workspaceCovers(patterns, path) {
92
+ const matches = (glob) => globToRegExp(glob).test(path);
93
+ const included = patterns.filter((p) => !p.startsWith("!")).some(matches);
94
+ const excluded = patterns.filter((p) => p.startsWith("!")).some((p) => matches(p.slice(1)));
95
+ return included && !excluded;
96
+ }
97
+
98
+ /**
99
+ * The workspace file with `path` added to its `packages`, the same text when
100
+ * a glob already covers it, or null when the list is in a shape this can't
101
+ * edit safely (the caller then asks for it by hand).
102
+ */
103
+ export function addWorkspacePackage(yaml, path) {
104
+ const patterns = workspacePackages(yaml);
105
+ if (patterns && workspaceCovers(patterns, path)) return yaml;
106
+ const lines = yaml.split("\n");
107
+
108
+ if (patterns === null) {
109
+ // No list yet. Insert one before the first top-level key, and before the
110
+ // comment that introduces that key, so the comment stays with it.
111
+ let at = lines.findIndex((line) => /^[A-Za-z]/.test(line));
112
+ if (at === -1) at = lines.length;
113
+ while (at > 0 && lines[at - 1].startsWith("#")) at--;
114
+ const block = [
115
+ "# The workspace. pnpm always includes the root project; each app",
116
+ "# `create-astroid --into` scaffolds is listed here.",
117
+ "packages:",
118
+ ` - ${path}`,
119
+ "",
120
+ ];
121
+ return [...lines.slice(0, at), ...block, ...lines.slice(at)].join("\n");
122
+ }
123
+
124
+ const at = lines.findIndex((line) => /^packages\s*:/.test(line));
125
+ const rest = lines[at].replace(/^packages\s*:/, "").trim();
126
+ if (rest.startsWith("[")) {
127
+ // A flow list with no comment after it is one line to rewrite; anything
128
+ // fancier is left to a person.
129
+ if (!/^\[[^\]]*\]$/.test(rest)) return null;
130
+ const inner = rest.slice(1, -1).trim();
131
+ lines[at] = `packages: [${inner ? `${inner}, ` : ""}${JSON.stringify(path)}]`;
132
+ return lines.join("\n");
133
+ }
134
+ if (rest && !rest.startsWith("#")) return null;
135
+
136
+ // A block list: append after its last item, at its indentation.
137
+ let last = at;
138
+ let indent = " ";
139
+ for (let i = at + 1; i < lines.length; i++) {
140
+ if (/^\s*(#.*)?$/.test(lines[i])) continue;
141
+ const item = lines[i].match(/^(\s+)-/);
142
+ if (!item) break;
143
+ last = i;
144
+ indent = item[1];
145
+ }
146
+ lines.splice(last + 1, 0, `${indent}- ${path}`);
147
+ return lines.join("\n");
148
+ }
149
+
150
+ /** The name an app's root scripts are namespaced by: its directory's name. */
151
+ export function intoScriptName(path) {
152
+ return path
153
+ .split("/")
154
+ .pop()
155
+ .toLowerCase()
156
+ .replace(/[^a-z0-9-]+/g, "-");
157
+ }
158
+
159
+ /** The root scripts that run one app's commands from the repository root. */
160
+ export function intoRootScripts(name, path) {
161
+ const inApp = `pnpm --dir ${path}`;
162
+ return {
163
+ [`dev:${name}`]: `${inApp} run dev`,
164
+ [`build:${name}`]: `${inApp} run build`,
165
+ [`doctor:${name}`]: `${inApp} run doctor`,
166
+ [`ship:${name}:production`]: `${inApp} exec astroid ship production`,
167
+ [`ship:${name}:preview`]: `${inApp} exec astroid ship preview`,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * The root package.json's scripts with the app's added, never replacing one
173
+ * that exists: `skipped` names each that was already taken, so the caller can
174
+ * say so rather than overwrite someone's script.
175
+ */
176
+ export function mergeRootScripts(existing, scripts) {
177
+ const merged = { ...existing };
178
+ const added = [];
179
+ const skipped = [];
180
+ for (const [name, command] of Object.entries(scripts)) {
181
+ if (name in merged) skipped.push(name);
182
+ else {
183
+ merged[name] = command;
184
+ added.push(name);
185
+ }
186
+ }
187
+ return { scripts: merged, added, skipped };
188
+ }
189
+
190
+ /**
191
+ * The settings of the new app's Workers Builds project. A second app is a
192
+ * second project, building from its own directory and deploying through
193
+ * `astroid ship`, so the deploy steps stay in the repository.
194
+ */
195
+ export function workersBuildsSettings(path) {
196
+ return [
197
+ ["Root directory", path],
198
+ ["Build command", "pnpm run build"],
199
+ ["Deploy command", "pnpm exec astroid ship production"],
200
+ ["Non-production branch deploy command", "pnpm exec astroid ship preview"],
201
+ ["Production branch", "deploy/production"],
202
+ ];
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astroid",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Scaffold a new Astroid site — an editable, multi-editor Astro app on Cloudflare Workers — in one command.",
5
5
  "keywords": [
6
6
  "astro",
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "files": [
25
25
  "index.mjs",
26
+ "into.mjs",
26
27
  "template",
27
28
  "toolkit-ranges.mjs"
28
29
  ],
@@ -32,10 +33,10 @@
32
33
  },
33
34
  "dependencies": {
34
35
  "@better-auth/passkey": "^1.7.2",
35
- "@louise-toolkit/astro": "^0.5.0",
36
- "astroidjs": "0.19.0",
36
+ "@louise-toolkit/astro": "^0.6.0",
37
+ "astroidjs": "0.21.0",
37
38
  "better-auth": "^1.7.2",
38
- "louise-toolkit": "^0.36.0"
39
+ "louise-toolkit": "^0.37.0"
39
40
  },
40
41
  "engines": {
41
42
  "node": ">=26.0.0"
@@ -0,0 +1,62 @@
1
+ # __BRAND_NAME__
2
+
3
+ An app on Cloudflare Workers with no pages to edit, scaffolded with
4
+ [Astroid](https://docs.astroidjs.org) (Astro + Louise Toolkit) in its
5
+ editor-free shape (`editor: false`).
6
+
7
+ It has no Louise editor: no sign-in for editors, no content tables, no media
8
+ library. Its settings, if it reads any, belong to a site that has an editor, and
9
+ that site stays the only place they're edited. What Astroid still generates
10
+ here is the rate limiter, the Content-Security-Policy, the security headers,
11
+ the public status route, and whatever modules the config switches on: a
12
+ customer portal, an installable app (PWA), or commerce.
13
+
14
+ The whole shape lives in one typed config, [`astroid.config.ts`](./astroid.config.ts).
15
+ `src/schema.ts`, `src/worker.ts`, and `src/middleware.ts` are **generated** from
16
+ it (they carry a "do not hand-edit" banner). Run `pnpm generate` after any
17
+ config change, or use `pnpm dev` and `pnpm build`, which regenerate first.
18
+
19
+ ## Develop
20
+
21
+ ```sh
22
+ pnpm install
23
+ cp .env.example .dev.vars # local secrets for `astro dev`
24
+ pnpm dev # astroid dev: regenerate, then astro dev
25
+ ```
26
+
27
+ ## The API
28
+
29
+ The web app is the first client of a versioned JSON API under `/api/v1`
30
+ (`src/pages/api/v1/`). A native client later calls the same routes, so each
31
+ one answers JSON, reads its input from the body or the URL, and needs no
32
+ browser-only header. The middleware rate-limits every POST under `/api/v1`.
33
+
34
+ ## Deploy
35
+
36
+ Astroid wrote `wrangler.jsonc` with placeholder binding ids:
37
+
38
+ ```sh
39
+ pnpm astroid provision # create the D1 database and the RL namespace, filling in their ids
40
+ pnpm run doctor # validate config, bindings, and generated-file freshness
41
+ pnpm astroid ship production
42
+ ```
43
+
44
+ ### Sharing another app's database
45
+
46
+ To read tables another app owns, such as its `site_settings`, bind its D1
47
+ database by id in `wrangler.jsonc` and set `deploy: { migrations: false }` in
48
+ `astroid.config.ts`. One app owns a database's schema: this one then applies no
49
+ migrations, and an additive migration in the other app ships in a release
50
+ before this app's code reads it.
51
+
52
+ ## Layout
53
+
54
+ | Path | What |
55
+ | --- | --- |
56
+ | `astroid.config.ts` | The one typed config. |
57
+ | `src/schema.ts` · `src/worker.ts` · `src/middleware.ts` | **Generated**—don't hand-edit. |
58
+ | `src/schema.site.ts` | This app's own Drizzle tables, if it has any. |
59
+ | `wrangler.jsonc` | Yours to edit—real binding ids, routes, secrets. |
60
+ | `src/pages/api/v1/` | The versioned JSON API. |
61
+ | `src/pages/` · `src/components/` · `src/layouts/` | Your Astro app. |
62
+ | `docs/` | ARCHITECTURE · RUNBOOK · DECISIONS—stubs to fill in as you go. |
@@ -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
+ # Astroid's convention: an unprovisioned secret leaves its feature DORMANT, never
5
+ # broken. A secret that is empty, or still holds the DUMMY_REPLACE_ME sentinel,
6
+ # reads as "not configured", so a fresh clone boots and runs with no external
7
+ # accounts at all. Replace a value to switch that feature on.
8
+ #
9
+ # This app has no editor, so it needs no editor sign-in secrets. Anything below
10
+ # belongs to a module your config switched on.
11
+ __ASTROID_APP_SECRETS__
12
+ __ASTROID_MODULE_SECRETS__
@@ -0,0 +1,37 @@
1
+ // @ts-check
2
+ import cloudflare from "@astrojs/cloudflare";
3
+ import { cacheCloudflare } from "@astrojs/cloudflare/cache";
4
+ import solid from "@astrojs/solid-js";
5
+ import tailwindcss from "@tailwindcss/vite";
6
+ import { ASTROID_VITE_BUILD, astroidSecurity } from "astroidjs/astro";
7
+ import { defineConfig } from "astro/config";
8
+ import astroidConfig from "./astroid.config.ts";
9
+
10
+ // SSR (`output: server`) because an app answers per request: its JSON API under
11
+ // /api/v1, and pages that read live data. Solid islands for the interactive UI.
12
+ // Tailwind v4 + daisyUI drive the theme (src/styles/site.css). Cloudflare
13
+ // *bindings* are read via `import { env } from "cloudflare:workers"` (typed in
14
+ // src/env.d.ts), so there is no astro:env schema here.
15
+ export default defineConfig({
16
+ site: "__SITE_URL__",
17
+ output: "server",
18
+ adapter: cloudflare(),
19
+ integrations: [solid()],
20
+ vite: {
21
+ plugins: [tailwindcss()],
22
+ build: { ...ASTROID_VITE_BUILD },
23
+ },
24
+ // Route caching (ADR 0004). This provider is what turns `Astro.cache.set(...)`
25
+ // into a `Cloudflare-CDN-Cache-Control` header, which the generated worker's
26
+ // `withEdgeCache` layer reads as its "store this" signal and then strips, so
27
+ // Cloudflare's own cookie-blind edge cache never sees it. Nothing is cached
28
+ // unless a route opts in, and a route that reads a signed-in customer never
29
+ // should.
30
+ cache: { provider: cacheCloudflare() },
31
+ // Content-Security-Policy, composed by Astroid from your config: the origins
32
+ // your modules need (a commerce provider's card SDK) plus the hash of Solid's
33
+ // hydration bootstrap. Astro owns `script-src`, so avoid is:inline and
34
+ // define:vars scripts, which can't be hashed and would be blocked. Need
35
+ // another origin? Add it to `security.cspOrigins` in astroid.config.ts.
36
+ security: astroidSecurity(astroidConfig),
37
+ });
@@ -0,0 +1,33 @@
1
+ /// <reference path="../.astro/types.d.ts" />
2
+ /// <reference types="astro/client" />
3
+ /// <reference types="@cloudflare/workers-types" />
4
+
5
+ // The Cloudflare bindings this app's Worker exposes (wrangler.jsonc), read via
6
+ // `import { env } from "cloudflare:workers"`. An app with no editor binds only
7
+ // what it uses. Add a binding here when you add one to wrangler.jsonc (a KV
8
+ // namespace for a cache, a Queue, a Durable Object).
9
+ type CloudflareEnv = {
10
+ /** D1: this app's own tables, or the database of the app that owns the schema. */
11
+ DB: D1Database;
12
+ /** The app's public origin, declared in wrangler.jsonc `vars`. */
13
+ SITE_URL: string;
14
+ /** KV: the security rate limiter. */
15
+ RL: KVNamespace;
16
+ /** Static assets (bound by the @astrojs/cloudflare adapter). */
17
+ ASSETS: Fetcher;__ASTROID_ENV_BINDINGS__
18
+ };
19
+
20
+ // `env` from `cloudflare:workers` is typed as the augmentable `Cloudflare.Env`.
21
+ declare namespace Cloudflare {
22
+ interface Env extends CloudflareEnv {}
23
+ }
24
+
25
+ // Middleware sets these; bindings themselves come from `cloudflare:workers`.
26
+ declare namespace App {
27
+ interface Locals {
28
+ /** Always null: this app has no editor, so no request resolves to one. */
29
+ editor: null;
30
+ /** Always false, for the same reason. The shared middleware still sets it. */
31
+ editMode: boolean;__ASTROID_PORTAL_LOCALS__
32
+ }
33
+ }
@@ -0,0 +1,49 @@
1
+ ---
2
+ // The base HTML shell. This app has no editor, so there's no settings row it
3
+ // owns and no edit mode: the head comes from astroid.config.ts, and a page
4
+ // overrides the title or description through props.
5
+ //
6
+ // Reading another app's settings (opening hours, a tagline) is a query against
7
+ // its table: import `siteSettings` from "louise-toolkit/db" where you need it.
8
+ import Credit from "astroidjs/components/Credit.astro";
9
+ import Seo from "astroidjs/components/Seo.astro";
10
+ import astroidConfig from "../../astroid.config.js";
11
+ import "../styles/site.css";
12
+
13
+ interface Props {
14
+ title?: string;
15
+ description?: string;
16
+ /** Keep this page out of search indexes (account, order status). */
17
+ noindex?: boolean;
18
+ }
19
+
20
+ const { title, description, noindex } = Astro.props;
21
+ ---
22
+
23
+ <!doctype html>
24
+ <html lang="en" data-theme="light">
25
+ <head>
26
+ <meta charset="utf-8" />
27
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
28
+ <Seo
29
+ settings={{ siteName: astroidConfig.theme.name }}
30
+ title={title}
31
+ description={description}
32
+ noindex={noindex}
33
+ titleTemplate={astroidConfig.seo?.titleTemplate}
34
+ twitterHandle={astroidConfig.seo?.twitterHandle}
35
+ locale={astroidConfig.seo?.locale}
36
+ />
37
+ </head>
38
+ <body class="min-h-screen bg-base-100 text-base-content">
39
+ <slot />
40
+ {
41
+ /* The agency credit, when astroid.config.ts sets `credit`. */
42
+ astroidConfig.credit && (
43
+ <footer class="mx-auto flex max-w-6xl justify-center px-6 py-8">
44
+ <Credit config={astroidConfig} />
45
+ </footer>
46
+ )
47
+ }
48
+ </body>
49
+ </html>
@@ -0,0 +1,22 @@
1
+ // GET /api/v1—the root of this app's versioned JSON API.
2
+ //
3
+ // Every route the app's clients call lives under /api/v1: the web app first,
4
+ // and a native client later against the same routes. So each one answers JSON,
5
+ // reads its input from the body or the URL (never an HTML form post), and
6
+ // needs no browser-only header. A breaking change is a new prefix, /api/v2,
7
+ // beside this one, because a native client on a customer's phone updates on
8
+ // its own schedule.
9
+ //
10
+ // The generated middleware rate-limits every POST under /api/v1 per client IP.
11
+ // Add a tighter rule for one route with `security.rateRules` in
12
+ // astroid.config.ts.
13
+ import type { APIRoute } from "astro";
14
+ import astroidConfig from "../../../../astroid.config.js";
15
+
16
+ export const prerender = false;
17
+
18
+ export const GET: APIRoute = () =>
19
+ Response.json(
20
+ { name: astroidConfig.theme.name, version: "v1" },
21
+ { headers: { "cache-control": "no-store" } },
22
+ );
@@ -0,0 +1,18 @@
1
+ ---
2
+ // The app's first screen. Replace it with yours. It reads its data from the
3
+ // same versioned JSON API a native client would call (src/pages/api/v1/), so
4
+ // the web app stays that API's first client rather than a special case.
5
+ import App from "../layouts/App.astro";
6
+
7
+ export const prerender = false;
8
+ ---
9
+
10
+ <App>
11
+ <main class="mx-auto flex min-h-screen max-w-xl flex-col justify-center gap-4 px-6 py-16">
12
+ <h1 class="text-4xl font-bold">__BRAND_NAME__</h1>
13
+ <p class="text-base-content/70">
14
+ This app has no pages to edit. Build its screens here, and its API under
15
+ <code>/api/v1</code>.
16
+ </p>
17
+ </main>
18
+ </App>
@@ -0,0 +1,24 @@
1
+ // sitemap.xml—the app's public screens. Scaffolded once and yours to edit: add
2
+ // each screen a search engine should find to `entries`.
3
+ //
4
+ // It doesn't read a `pages` table, even when this app shares a database with a
5
+ // site that has one: those pages are served from the site's origin, not this
6
+ // app's. `astroidSitemapXml` drops anything matching the config's noindex
7
+ // prefixes, so this file and robots.txt can never disagree.
8
+ import type { APIRoute } from "astro";
9
+ import { astroidSitemapXml, type SitemapEntry } from "astroidjs";
10
+ import astroidConfig from "../../astroid.config.js";
11
+
12
+ export const prerender = false;
13
+
14
+ export const GET: APIRoute = (context) => {
15
+ const origin = new URL(context.request.url).origin;
16
+ const entries: SitemapEntry[] = [{ path: "/" }];
17
+
18
+ return new Response(astroidSitemapXml(astroidConfig, entries, { origin }), {
19
+ headers: {
20
+ "content-type": "application/xml; charset=utf-8",
21
+ "cache-control": "public, max-age=3600",
22
+ },
23
+ });
24
+ };
@@ -6,6 +6,7 @@
6
6
  // description, and OG image, and a page overrides any of them via props.
7
7
  // <StructuredData> emits the schema.org graph (business + WebSite), with the
8
8
  // business `@type` chosen from your archetype.
9
+ import Credit from "astroidjs/components/Credit.astro";
9
10
  import Seo from "astroidjs/components/Seo.astro";
10
11
  import StructuredData from "astroidjs/components/StructuredData.astro";
11
12
  import { env } from "cloudflare:workers";
@@ -96,6 +97,15 @@ const settings = {
96
97
  </head>
97
98
  <body class="min-h-screen bg-base-100 text-base-content" data-edit-mode={editMode ? "" : undefined}>
98
99
  <slot />
100
+ {
101
+ /* The agency credit, when astroid.config.ts sets `credit`. A site with
102
+ its own footer can move <Credit config={astroidConfig} /> into it. */
103
+ astroidConfig.credit && (
104
+ <footer class="mx-auto flex max-w-6xl justify-center px-6 py-8">
105
+ <Credit config={astroidConfig} />
106
+ </footer>
107
+ )
108
+ }
99
109
  <LouiseEdit versionedPageId={versionedPageId} sections={sections} />
100
110
  {
101
111
  /* Real-visitor Core Web Vitals. A static file from public/, so it is
@@ -2,7 +2,12 @@
2
2
  // both call this, so every page renders the same way: a visitor sees the live
3
3
  // row, and an editor in edit mode sees the latest pending draft laid over it, so
4
4
  // in-progress edits resume across reloads.
5
+ import { astroidPageDraft } from "astroidjs/pages";
5
6
  import { isPageLive } from "louise-toolkit/content";
7
+ import astroidConfig from "../../astroid.config.js";
8
+
9
+ /** The bindings a page read uses: the database, and the draft buffer. */
10
+ type PageEnv = Pick<CloudflareEnv, "DB" | "DRAFTS">;
6
11
 
7
12
  /** The columns a page render reads. */
8
13
  export interface PageRow {
@@ -58,13 +63,13 @@ function parseSections(raw: unknown): unknown[] {
58
63
  * `redirectFor` then answers a renamed page's old URL with a redirect.
59
64
  */
60
65
  export async function readPage(
61
- db: D1Database,
66
+ env: PageEnv,
62
67
  slug: string,
63
68
  { editMode, requireLive = true }: ReadPageOptions,
64
69
  ): Promise<RenderedPage | null> {
65
70
  let row: PageRow | null = null;
66
71
  try {
67
- row = await db
72
+ row = await env.DB
68
73
  .prepare(
69
74
  "SELECT id, slug, title, body, sections, status, seo_title, seo_description, og_image, noindex FROM pages WHERE slug = ?",
70
75
  )
@@ -86,30 +91,19 @@ export async function readPage(
86
91
  };
87
92
  if (!editMode) return page;
88
93
 
89
- // The latest PENDING draft: newer than every version ever published. An
90
- // older draft is superseded, since a publish already moved past it, so it
91
- // must not come back just because it's the newest row marked `draft`.
94
+ // The editor's work-in-progress: the DRAFTS buffer first, since every save
95
+ // writes through it and reaches D1 only when it flushes, then the newest
96
+ // pending draft in D1. Reading D1 alone showed an editor who reloaded before
97
+ // the flush an older page than the one they had just saved.
92
98
  try {
93
- const draft = await db
94
- .prepare(
95
- "SELECT version_data FROM pages_versions WHERE parent_id = ?1 AND status = 'draft'" +
96
- " AND id > COALESCE((SELECT MAX(id) FROM pages_versions WHERE parent_id = ?1 AND status = 'published'), 0)" +
97
- " ORDER BY id DESC LIMIT 1",
98
- )
99
- .bind(row.id)
100
- .first<{ version_data: string }>();
101
- if (draft?.version_data) {
102
- const d = JSON.parse(draft.version_data) as {
103
- title?: unknown;
104
- body?: unknown;
105
- sections?: unknown;
106
- };
107
- if (typeof d.title === "string") page.title = d.title;
108
- if (typeof d.body === "string") page.body = d.body;
99
+ const draft = await astroidPageDraft(astroidConfig, env, row.id);
100
+ if (draft) {
101
+ if (typeof draft.title === "string") page.title = draft.title;
102
+ if (typeof draft.body === "string") page.body = draft.body;
109
103
  // Sections stage as drafts like any other field, so edit mode renders the
110
104
  // draft's array; otherwise section edits would vanish on reload while
111
105
  // title edits survive.
112
- if (d.sections !== undefined) page.sections = parseSections(d.sections);
106
+ if (draft.sections !== undefined) page.sections = parseSections(draft.sections);
113
107
  }
114
108
  } catch {
115
109
  // Non-fatal: fall back to the live row.
@@ -25,7 +25,7 @@ const slug = Astro.params.slug ?? "";
25
25
  // `home` is served at "/", never at "/home".
26
26
  if (slug === "home") return Astro.redirect("/", 301);
27
27
 
28
- const page = slug ? await readPage(env.DB, slug, { editMode }) : null;
28
+ const page = slug ? await readPage(env, slug, { editMode }) : null;
29
29
 
30
30
  // Edge caching (ADR 0004), gated exactly as on the home page: never in edit
31
31
  // mode, only when ASTROID_EDGE_CACHE is "true", and never for a 404, so a page
@@ -42,7 +42,7 @@ if (editMode || env.ASTROID_EDGE_CACHE !== "true") {
42
42
  // `requireLive: false`: an unpublished home keeps rendering rather than turning
43
43
  // the site's front door into a 404. Every other page is visitor-visible only
44
44
  // while it's live (src/pages/[...slug].astro).
45
- const page = await readPage(env.DB, "home", { editMode, requireLive: false });
45
+ const page = await readPage(env, "home", { editMode, requireLive: false });
46
46
  const title = page?.title ?? "__BRAND_NAME__";
47
47
  const sections = page?.sections ?? [];
48
48
  ---