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 +1 -1
- package/package.json +4 -4
- package/template/README.md +7 -5
- package/template/docs/RUNBOOK.md +13 -0
- package/template/src/env.d.ts +3 -0
- package/template/src/lib/pages.ts +118 -0
- package/template/src/pages/[...slug].astro +76 -0
- package/template/src/pages/index.astro +16 -69
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
|
|
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.
|
|
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.
|
|
36
|
-
"astroidjs": "0.
|
|
35
|
+
"@louise-toolkit/astro": "^0.5.0",
|
|
36
|
+
"astroidjs": "0.19.0",
|
|
37
37
|
"better-auth": "^1.7.2",
|
|
38
|
-
"louise-toolkit": "^0.
|
|
38
|
+
"louise-toolkit": "^0.36.0"
|
|
39
39
|
},
|
|
40
40
|
"engines": {
|
|
41
41
|
"node": ">=26.0.0"
|
package/template/README.md
CHANGED
|
@@ -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
|
|
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`
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
package/template/docs/RUNBOOK.md
CHANGED
|
@@ -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
|
package/template/src/env.d.ts
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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>
|