create-astroid 0.9.1 → 0.10.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
@@ -4,7 +4,7 @@
4
4
  // `create-astroid`—scaffold a new Astroid site in one command:
5
5
  //
6
6
  // pnpm create astroid@latest my-site
7
- // pnpm create astroid@latest my-site --key coracle --name "Coracle Coffee" --color "#1f6f78" --host coracle.coffee
7
+ // pnpm create astroid@latest my-site --key example --name "Example Organization" --color "#5b4bff" --host example.com
8
8
  //
9
9
  // It writes the floor: the `defineAstroid` config, the generated
10
10
  // schema/worker/middleware trio + wrangler.jsonc (via astroidjs), the Better Auth
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astroid",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",
@@ -32,10 +32,10 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@better-auth/passkey": "^1.7.2",
35
- "@louise-toolkit/astro": "^0.3.0",
36
- "astroidjs": "0.17.0",
35
+ "@louise-toolkit/astro": "^0.5.0",
36
+ "astroidjs": "0.19.0",
37
37
  "better-auth": "^1.7.2",
38
- "louise-toolkit": "^0.34.0"
38
+ "louise-toolkit": "^0.36.0"
39
39
  },
40
40
  "engines": {
41
41
  "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
 
@@ -56,6 +56,19 @@ wrangler deploy
56
56
  <!-- Who deploys, from where, and what gates it? If deploys are automatic on push
57
57
  to main, say so here—that is the first thing a new person asks. -->
58
58
 
59
+ ## Is it up?
60
+
61
+ ```sh
62
+ curl -i https://<your-host>/api/louise/status
63
+ ```
64
+
65
+ 200 means D1 answers and the home page has content; 503 names the check that
66
+ failed, as a boolean, in the JSON body. Point an uptime monitor at this URL.
67
+ Add your own checks in `src/status-checks.ts` (turn it on with
68
+ `status: { checks: true }` in `astroid.config.ts`).
69
+
70
+ <!-- Which monitor watches this URL, and who it alerts. -->
71
+
59
72
  ## D1 migrations
60
73
 
61
74
  ```sh
@@ -17,6 +17,9 @@ type CloudflareEnv = {
17
17
  * health scan to know what to crawl, and by anything else that needs an
18
18
  * absolute URL from inside the Worker. */
19
19
  SITE_URL: string;
20
+ /** AI Gateway id for the editor's AI assists—declared in wrangler.jsonc
21
+ * `vars`. Empty calls Workers AI directly (see `astroidAiGateway`). */
22
+ AI_GATEWAY_ID?: string;
20
23
  /**
21
24
  * Edge caching for published pages — `"true"` to enable (ADR 0004). A string,
22
25
  * not a boolean, because wrangler `vars` are strings.
@@ -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
+ }
@@ -0,0 +1,76 @@
1
+ ---
2
+ // Every page an owner makes in the Pages panel, served at its slug: `about` at
3
+ // /about. The same render as the home page (src/lib/pages.ts): a visitor sees
4
+ // the live row, and an editor in edit mode sees the latest pending draft, with
5
+ // the same inline editing and Publish.
6
+ //
7
+ // A file route always wins over this one, so /contact and /login stay theirs.
8
+ // `pagesRoute` refuses those slugs (ASTROID_RESERVED_SLUGS), so a page can't be
9
+ // saved where it would never be reached.
10
+ //
11
+ // Anything else that matches nothing answers 404 here. That includes a hidden or
12
+ // never-published page, and a renamed page's old path, which the middleware's
13
+ // `redirectFor` then answers with a 301 to the new one.
14
+ import Editable from "astroidjs/components/Editable.astro";
15
+ import Sections from "astroidjs/components/Sections.astro";
16
+ import { env } from "cloudflare:workers";
17
+ import Site from "../layouts/Site.astro";
18
+ import { readPage } from "../lib/pages";
19
+
20
+ export const prerender = false;
21
+
22
+ const { editMode } = Astro.locals;
23
+ const slug = Astro.params.slug ?? "";
24
+
25
+ // `home` is served at "/", never at "/home".
26
+ if (slug === "home") return Astro.redirect("/", 301);
27
+
28
+ const page = slug ? await readPage(env.DB, slug, { editMode }) : null;
29
+
30
+ // Edge caching (ADR 0004), gated exactly as on the home page: never in edit
31
+ // mode, only when ASTROID_EDGE_CACHE is "true", and never for a 404, so a page
32
+ // published a minute later isn't hidden behind a cached miss.
33
+ if (!page || editMode || env.ASTROID_EDGE_CACHE !== "true") {
34
+ Astro.cache.set(false);
35
+ } else {
36
+ Astro.cache.set({ maxAge: 60 });
37
+ }
38
+
39
+ if (!page) Astro.response.status = 404;
40
+ ---
41
+
42
+ {
43
+ page ? (
44
+ <Site
45
+ title={page.row.seo_title ?? page.title}
46
+ description={page.row.seo_description ?? undefined}
47
+ ogImage={page.row.og_image ?? undefined}
48
+ noindex={Boolean(page.row.noindex)}
49
+ versionedPageId={page.row.id}
50
+ sections={page.sections}
51
+ >
52
+ <main>
53
+ <Sections items={page.sections} db={env.DB} mediaBase={env.MEDIA_URL} />
54
+
55
+ <div class="mx-auto max-w-3xl px-6 py-16">
56
+ <Editable as="h1" collection="pages" key={page.row.id} field="title" class="text-5xl font-bold">
57
+ {page.title}
58
+ </Editable>
59
+ <Editable as="div" collection="pages" key={page.row.id} field="body" type="richtext" class="prose mt-6 max-w-none">
60
+ <Fragment set:html={page.body} />
61
+ </Editable>
62
+ </div>
63
+ </main>
64
+ </Site>
65
+ ) : (
66
+ <Site title="Page not found" noindex>
67
+ <main class="mx-auto max-w-3xl px-6 py-24 text-center">
68
+ <h1 class="text-4xl font-bold">Page not found</h1>
69
+ <p class="mt-4">This page doesn't exist, or it has moved.</p>
70
+ <p class="mt-8">
71
+ <a class="link" href="/">Go to the home page</a>
72
+ </p>
73
+ </main>
74
+ </Site>
75
+ )
76
+ }
@@ -9,6 +9,7 @@ import Sections from "astroidjs/components/Sections.astro";
9
9
  import { env } from "cloudflare:workers";
10
10
  import Hero from "../components/Hero.astro";
11
11
  import Site from "../layouts/Site.astro";
12
+ import { readPage } from "../lib/pages";
12
13
 
13
14
  export const prerender = false;
14
15
 
@@ -37,75 +38,21 @@ if (editMode || env.ASTROID_EDGE_CACHE !== "true") {
37
38
  // is the on-page H1. The home page passes NO title to <Site>, so <title>
38
39
  // is the site-wide default ("Acme Coffee", not "Acme Coffee | Acme Coffee");
39
40
  // set `seo_title` on the row to override just the head.
40
- interface HomeRow {
41
- id: number;
42
- title: string;
43
- body: string | null;
44
- /** The page-builder array, stored as a JSON string in D1. */
45
- sections: string | null;
46
- seo_title: string | null;
47
- seo_description: string | null;
48
- og_image: string | null;
49
- noindex: number | null;
50
- }
51
- let page: HomeRow | null = null;
52
- try {
53
- page = await env.DB.prepare(
54
- "SELECT id, title, body, sections, seo_title, seo_description, og_image, noindex FROM pages WHERE slug = 'home'",
55
- ).first<HomeRow>();
56
- } catch {
57
- // No DB binding yet (pre-provision)—fall through to the seed-me prompt.
58
- }
59
-
60
- let title = page?.title ?? "__BRAND_NAME__";
61
- let body = page?.body ?? "";
62
- // `sections` is a JSON string in D1. Bad JSON is a render-time non-event—the
63
- // page falls back to its prose body rather than 500ing on one malformed row.
64
- function parseSections(raw: string | null | undefined): unknown[] {
65
- if (!raw) return [];
66
- try {
67
- const parsed = JSON.parse(raw);
68
- return Array.isArray(parsed) ? parsed : [];
69
- } catch {
70
- return [];
71
- }
72
- }
73
- let sections = parseSections(page?.sections);
74
-
75
- // In edit mode, prefer the latest unpublished draft so edits resume across reloads.
76
- if (editMode && page) {
77
- try {
78
- const draft = await env.DB.prepare(
79
- "SELECT version_data FROM pages_versions WHERE parent_id = ? AND status = 'draft' ORDER BY created_at DESC LIMIT 1",
80
- )
81
- .bind(page.id)
82
- .first<{ version_data: string }>();
83
- if (draft?.version_data) {
84
- const d = JSON.parse(draft.version_data) as {
85
- title?: string;
86
- body?: string;
87
- sections?: unknown;
88
- };
89
- if (typeof d.title === "string") title = d.title;
90
- if (typeof d.body === "string") body = d.body;
91
- // Sections stage as drafts like any other field, so edit mode must render
92
- // the draft's array—otherwise an editor's in-progress section edits
93
- // vanish on reload while their title edits survive.
94
- if (Array.isArray(d.sections)) sections = d.sections;
95
- else if (typeof d.sections === "string") sections = parseSections(d.sections);
96
- }
97
- } catch {
98
- // Non-fatal—fall back to the live row.
99
- }
100
- }
41
+ //
42
+ // `requireLive: false`: an unpublished home keeps rendering rather than turning
43
+ // the site's front door into a 404. Every other page is visitor-visible only
44
+ // while it's live (src/pages/[...slug].astro).
45
+ const page = await readPage(env.DB, "home", { editMode, requireLive: false });
46
+ const title = page?.title ?? "__BRAND_NAME__";
47
+ const sections = page?.sections ?? [];
101
48
  ---
102
49
 
103
50
  <Site
104
- title={page?.seo_title ?? undefined}
105
- description={page?.seo_description ?? undefined}
106
- ogImage={page?.og_image ?? undefined}
107
- noindex={Boolean(page?.noindex)}
108
- versionedPageId={page?.id}
51
+ title={page?.row.seo_title ?? undefined}
52
+ description={page?.row.seo_description ?? undefined}
53
+ ogImage={page?.row.og_image ?? undefined}
54
+ noindex={Boolean(page?.row.noindex)}
55
+ versionedPageId={page?.row.id}
109
56
  sections={sections}
110
57
  >
111
58
  {
@@ -118,11 +65,11 @@ if (editMode && page) {
118
65
  <Sections items={sections} db={env.DB} mediaBase={env.MEDIA_URL} />
119
66
 
120
67
  <div class="mx-auto max-w-3xl px-6 py-16">
121
- <Editable as="h1" collection="pages" key={page.id} field="title" class="text-5xl font-bold">
68
+ <Editable as="h1" collection="pages" key={page.row.id} field="title" class="text-5xl font-bold">
122
69
  {title}
123
70
  </Editable>
124
- <Editable as="div" collection="pages" key={page.id} field="body" type="richtext" class="prose mt-6 max-w-none">
125
- <Fragment set:html={body} />
71
+ <Editable as="div" collection="pages" key={page.row.id} field="body" type="richtext" class="prose mt-6 max-w-none">
72
+ <Fragment set:html={page.body} />
126
73
  </Editable>
127
74
  </div>
128
75
  </main>