create-astroid 0.9.2 → 0.11.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/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.9.2",
3
+ "version": "0.11.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.18.0",
36
+ "@louise-toolkit/astro": "^0.6.0",
37
+ "astroidjs": "0.20.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"
@@ -84,15 +84,17 @@ There are no passwords and no editor list in env to keep in sync.
84
84
  bar** appears with **Settings** and **Done**.
85
85
  3. The home page's **title and body are editable in place**—click into them and
86
86
  type. Edits stage a **draft**; **Publish** (in the edit bar) promotes it live.
87
- **Settings** opens the drawer: **Pages** (create/edit other pages), **Media**,
87
+ **Settings** opens the drawer: **Pages** (create/edit other pages, each served
88
+ at its slug once published, such as `/about`), **Media**,
88
89
  **Settings** (brand, nav, contact, SEO), and **Users** (invite/remove editors).
89
90
  **Done** leaves edit mode.
90
91
 
91
92
  Inline editing uses Astroid's [`<Editable>`](https://github.com/bowenlabs/louise-toolkit)
92
- primitive (`src/pages/index.astro`): it stamps the `data-louise-*` markers only in
93
- edit mode, so the public HTML stays clean. Wrap any page field in `<Editable
94
- collection="pages" key={page.id} field="…">` and pass `versionedPageId` to make it
95
- editable. Body HTML is sanitized on every save.
93
+ primitive (`src/pages/index.astro` for the home page, `src/pages/[...slug].astro`
94
+ for every other page, both reading through `src/lib/pages.ts`): it stamps the
95
+ `data-louise-*` markers only in edit mode, so the public HTML stays clean. Wrap
96
+ any page field in `<Editable collection="pages" key={page.row.id} field="…">` and
97
+ pass `versionedPageId` to make it editable. Body HTML is sanitized on every save.
96
98
 
97
99
  ## Redeploy
98
100
 
@@ -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
@@ -0,0 +1,118 @@
1
+ // Read one `pages` row for rendering. The home page and the catch-all page route
2
+ // both call this, so every page renders the same way: a visitor sees the live
3
+ // row, and an editor in edit mode sees the latest pending draft laid over it, so
4
+ // in-progress edits resume across reloads.
5
+ import { isPageLive } from "louise-toolkit/content";
6
+
7
+ /** The columns a page render reads. */
8
+ export interface PageRow {
9
+ id: number;
10
+ slug: string;
11
+ /** The on-page H1. The head's <title> comes from `seo_title`, else this. */
12
+ title: string;
13
+ body: string | null;
14
+ /** The page-builder array, stored as a JSON string in D1. */
15
+ sections: string | null;
16
+ status: string | null;
17
+ seo_title: string | null;
18
+ seo_description: string | null;
19
+ og_image: string | null;
20
+ noindex: number | null;
21
+ }
22
+
23
+ /** What a page renders: its row, plus the title, body, and sections to show. */
24
+ export interface RenderedPage {
25
+ row: PageRow;
26
+ title: string;
27
+ body: string;
28
+ sections: unknown[];
29
+ }
30
+
31
+ interface ReadPageOptions {
32
+ /** Edit mode shows every page, live or not, with its pending draft. */
33
+ editMode: boolean;
34
+ /**
35
+ * Show a visitor only a live page (`status = 'published'`). Default `true`.
36
+ * The home page passes `false`, so an unpublished home keeps rendering
37
+ * rather than turning the site's front door into a 404.
38
+ */
39
+ requireLive?: boolean;
40
+ }
41
+
42
+ // `sections` is a JSON string in D1. Bad JSON is a render-time non-event: the
43
+ // page falls back to its prose body rather than failing on one malformed row.
44
+ function parseSections(raw: unknown): unknown[] {
45
+ if (Array.isArray(raw)) return raw;
46
+ if (typeof raw !== "string" || !raw) return [];
47
+ try {
48
+ const parsed = JSON.parse(raw);
49
+ return Array.isArray(parsed) ? parsed : [];
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The page at `slug`, or `null` when there's none a visitor may see (or no
57
+ * database yet, before provisioning). A `null` is a 404, and the middleware's
58
+ * `redirectFor` then answers a renamed page's old URL with a redirect.
59
+ */
60
+ export async function readPage(
61
+ db: D1Database,
62
+ slug: string,
63
+ { editMode, requireLive = true }: ReadPageOptions,
64
+ ): Promise<RenderedPage | null> {
65
+ let row: PageRow | null = null;
66
+ try {
67
+ row = await db
68
+ .prepare(
69
+ "SELECT id, slug, title, body, sections, status, seo_title, seo_description, og_image, noindex FROM pages WHERE slug = ?",
70
+ )
71
+ .bind(slug)
72
+ .first<PageRow>();
73
+ } catch {
74
+ // No DB binding yet (pre-provision).
75
+ return null;
76
+ }
77
+ if (!row) return null;
78
+ // The toolkit's one definition of "a visitor can see it" (ADR 0021).
79
+ if (!editMode && requireLive && !isPageLive({ status: row.status ?? undefined })) return null;
80
+
81
+ const page: RenderedPage = {
82
+ row,
83
+ title: row.title,
84
+ body: row.body ?? "",
85
+ sections: parseSections(row.sections),
86
+ };
87
+ if (!editMode) return page;
88
+
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`.
92
+ 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;
109
+ // Sections stage as drafts like any other field, so edit mode renders the
110
+ // draft's array; otherwise section edits would vanish on reload while
111
+ // title edits survive.
112
+ if (d.sections !== undefined) page.sections = parseSections(d.sections);
113
+ }
114
+ } catch {
115
+ // Non-fatal: fall back to the live row.
116
+ }
117
+ return page;
118
+ }