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/index.mjs +404 -26
- package/into.mjs +203 -0
- package/package.json +5 -4
- package/template/README.md +7 -5
- package/template/_app/README.md +62 -0
- package/template/_app/_env.example +12 -0
- package/template/_app/astro.config.mjs +37 -0
- package/template/_app/src/env.d.ts +33 -0
- package/template/_app/src/layouts/App.astro +49 -0
- package/template/_app/src/pages/api/v1/index.ts +22 -0
- package/template/_app/src/pages/index.astro +18 -0
- package/template/_app/src/pages/sitemap.xml.ts +24 -0
- package/template/src/layouts/Site.astro +10 -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/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.
|
|
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.
|
|
36
|
-
"astroidjs": "0.
|
|
36
|
+
"@louise-toolkit/astro": "^0.6.0",
|
|
37
|
+
"astroidjs": "0.20.0",
|
|
37
38
|
"better-auth": "^1.7.2",
|
|
38
|
-
"louise-toolkit": "^0.
|
|
39
|
+
"louise-toolkit": "^0.37.0"
|
|
39
40
|
},
|
|
40
41
|
"engines": {
|
|
41
42
|
"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
|
|
|
@@ -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
|
+
}
|