astroidjs 0.1.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.
@@ -0,0 +1,5 @@
1
+ /** Thrown when a `defineAstroid` config violates an invariant (no brands, a
2
+ * duplicate brand key, …). */
3
+ export declare class AstroidConfigError extends Error {
4
+ constructor(message: string);
5
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,13 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Typed errors for the Astroid config surface, mirroring Louise's own error
4
+ // classes (louise-toolkit/errors) so a bad config throws something recognizable
5
+ // rather than a bare Error.
6
+ /** Thrown when a `defineAstroid` config violates an invariant (no brands, a
7
+ * duplicate brand key, …). */
8
+ export class AstroidConfigError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "AstroidConfigError";
12
+ }
13
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./config.js";
2
+ export * from "./errors.js";
3
+ export * from "./project/index.js";
4
+ export * from "./schema/index.js";
5
+ export * from "./worker/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // astroidjs — the opinionated meta-framework over Louise Toolkit + Astro.
4
+ // Public entry. The configuration surface (`defineAstroid`) is the first
5
+ // inhabitant; the generator, theme system, and section library follow.
6
+ export * from "./config.js";
7
+ export * from "./errors.js";
8
+ export * from "./project/index.js";
9
+ export * from "./schema/index.js";
10
+ export * from "./worker/index.js";
@@ -0,0 +1,26 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /** A generated file: a project-root-relative POSIX path + its full contents. */
3
+ export interface GeneratedFile {
4
+ /** Path relative to the project root, POSIX-separated (e.g. `"src/worker.ts"`). */
5
+ path: string;
6
+ contents: string;
7
+ }
8
+ /**
9
+ * The regenerated trio — the files that are a pure function of the Astroid config
10
+ * and carry a "do not hand-edit" banner. `astroid generate` writes exactly these,
11
+ * and `astroid doctor` regenerates them in-memory to diff against disk. Scaffold-
12
+ * once files (wrangler.jsonc, astro.config, auth.ts) are NOT here by design.
13
+ */
14
+ export declare function generateAstroidProject(config: AstroidConfig): GeneratedFile[];
15
+ /**
16
+ * Generate a floor `wrangler.jsonc` from the config: the Worker name + editable
17
+ * bindings a baseline Louise site needs (D1, R2 media, the rate-limit + autosave
18
+ * KV, Cloudflare Images), custom-domain routes from `hosts`, and the `vars` the
19
+ * media route + editor read. Binding ids are placeholders — real ids are filled by
20
+ * `wrangler … create` (or, later, `astroid deploy`); `astroid doctor` flags any
21
+ * still-unresolved placeholder.
22
+ *
23
+ * This is a SCAFFOLD-ONCE file: emitted by `create-astroid`, then owned by the
24
+ * developer. It is intentionally not part of {@link generateAstroidProject}.
25
+ */
26
+ export declare function generateAstroidWrangler(config: AstroidConfig): string;
@@ -0,0 +1,110 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Project generation — the config → files layer the `astroid` CLI writes.
4
+ //
5
+ // Two tiers of generated file, deliberately kept apart:
6
+ //
7
+ // 1. The REGENERATED trio (`generateAstroidProject`) — `src/schema.ts`,
8
+ // `src/worker.ts`, `src/middleware.ts`. Pure functions of the config, marked
9
+ // "do not hand-edit". `astroid generate` (and `dev`/`build`) rewrite these on
10
+ // every run, and `astroid doctor` diffs them to catch drift.
11
+ //
12
+ // 2. The SCAFFOLD-ONCE files (`generateAstroidWrangler`, …) — `wrangler.jsonc`
13
+ // and friends. `create-astroid` writes them once; the developer then owns
14
+ // them (fills real binding ids, secrets, account). `astroid generate` must
15
+ // NEVER clobber them, or it would wipe provisioned ids — so they live in a
16
+ // separate function the regenerate path doesn't call.
17
+ import { generateAstroidSchema } from "../schema/generate.js";
18
+ import { generateAstroidMiddleware, generateAstroidWorker } from "../worker/generate.js";
19
+ /**
20
+ * The regenerated trio — the files that are a pure function of the Astroid config
21
+ * and carry a "do not hand-edit" banner. `astroid generate` writes exactly these,
22
+ * and `astroid doctor` regenerates them in-memory to diff against disk. Scaffold-
23
+ * once files (wrangler.jsonc, astro.config, auth.ts) are NOT here by design.
24
+ */
25
+ export function generateAstroidProject(config) {
26
+ return [
27
+ { path: "src/schema.ts", contents: generateAstroidSchema(config) },
28
+ { path: "src/worker.ts", contents: generateAstroidWorker(config) },
29
+ { path: "src/middleware.ts", contents: generateAstroidMiddleware(config) },
30
+ ];
31
+ }
32
+ // Pinned compatibility date for the emitted Worker. A literal (Astroid's
33
+ // generators are pure — no `Date.now()`), bumped deliberately when the runtime
34
+ // baseline moves; matches the reference site's wrangler.jsonc.
35
+ const COMPATIBILITY_DATE = "2026-06-20";
36
+ /**
37
+ * Generate a floor `wrangler.jsonc` from the config: the Worker name + editable
38
+ * bindings a baseline Louise site needs (D1, R2 media, the rate-limit + autosave
39
+ * KV, Cloudflare Images), custom-domain routes from `hosts`, and the `vars` the
40
+ * media route + editor read. Binding ids are placeholders — real ids are filled by
41
+ * `wrangler … create` (or, later, `astroid deploy`); `astroid doctor` flags any
42
+ * still-unresolved placeholder.
43
+ *
44
+ * This is a SCAFFOLD-ONCE file: emitted by `create-astroid`, then owned by the
45
+ * developer. It is intentionally not part of {@link generateAstroidProject}.
46
+ */
47
+ export function generateAstroidWrangler(config) {
48
+ const key = config.key;
49
+ const mediaBase = config.deploy?.mediaBase ?? "/media";
50
+ const hosts = config.hosts ?? [];
51
+ const primaryHost = hosts[0];
52
+ const lines = [];
53
+ const p = (s = "") => lines.push(s);
54
+ p("{");
55
+ p(' "$schema": "node_modules/wrangler/config-schema.json",');
56
+ p(" // The Cloudflare Worker name — also the default *.workers.dev subdomain.");
57
+ p(` "name": ${JSON.stringify(key)},`);
58
+ p(" // Pin your account so deploys don't prompt (or set CLOUDFLARE_ACCOUNT_ID).");
59
+ p(' // "account_id": "<your-cloudflare-account-id>",');
60
+ p(` "compatibility_date": ${JSON.stringify(COMPATIBILITY_DATE)},`);
61
+ p(' "compatibility_flags": ["nodejs_compat"],');
62
+ p(" // @astrojs/cloudflare builds this entry and wires the static assets under dist/.");
63
+ p(' "main": "src/worker.ts",');
64
+ if (primaryHost) {
65
+ p(" // Custom domains this Worker serves (from your defineAstroid `hosts`).");
66
+ p(' "routes": [');
67
+ for (const host of hosts) {
68
+ p(` { "pattern": ${JSON.stringify(host)}, "custom_domain": true },`);
69
+ }
70
+ p(" ],");
71
+ }
72
+ else {
73
+ p(" // No `hosts` in your config → deploys to <name>.workers.dev. Add a");
74
+ p(' // "routes" block with a custom_domain pattern to serve a real domain.');
75
+ }
76
+ p(" // D1 holds pages / site_settings / media / inquiries (schema in src/schema.ts,");
77
+ p(" // migrations in ./migrations). Create it: `wrangler d1 create <name>`.");
78
+ p(' "d1_databases": [');
79
+ p(" {");
80
+ p(' "binding": "DB",');
81
+ p(` "database_name": ${JSON.stringify(key)},`);
82
+ p(' "database_id": "<run: wrangler d1 create ' + key + '>",');
83
+ p(' "migrations_dir": "migrations",');
84
+ p(" },");
85
+ p(" ],");
86
+ p(" // R2 bucket for uploaded media, streamed back through the Worker at MEDIA_URL");
87
+ p(" // (no public bucket). Create it: `wrangler r2 bucket create <name>-media`.");
88
+ p(` "r2_buckets": [{ "binding": "MEDIA", "bucket_name": ${JSON.stringify(`${key}-media`)} }],`);
89
+ p(" // Cloudflare Images: the media route reads upload dimensions + backs server-");
90
+ p(" // side re-encode. Also @astrojs/cloudflare's production image service.");
91
+ p(' "images": { "binding": "IMAGES" },');
92
+ p(" // KV: RL = the security rate limiter; DRAFTS = the autosave write-buffer.");
93
+ p(" // Create each: `wrangler kv namespace create <RL|DRAFTS>`.");
94
+ p(' "kv_namespaces": [');
95
+ p(' { "binding": "RL", "id": "<run: wrangler kv namespace create RL>" },');
96
+ p(' { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create DRAFTS>" },');
97
+ p(" ],");
98
+ p(" // Public base for media URLs; same-origin keeps media self-contained. Read off");
99
+ p(" // the runtime env by the framework-agnostic media route, so it stays a `var`.");
100
+ p(' "vars": {');
101
+ p(` "MEDIA_URL": ${JSON.stringify(mediaBase)},`);
102
+ p(` "SITE_URL": ${JSON.stringify(primaryHost ? `https://${primaryHost}` : `https://${key}.workers.dev`)},`);
103
+ p(' // The editor allowlist / owner. Wire this into your auth seam (src/auth.ts).');
104
+ p(' "OWNER_EMAIL": "",');
105
+ p(" },");
106
+ p(' "observability": { "enabled": true },');
107
+ p("}");
108
+ p();
109
+ return lines.join("\n");
110
+ }
@@ -0,0 +1 @@
1
+ export * from "./generate.js";
@@ -0,0 +1,5 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Project generation — config → the files the `astroid` CLI writes (the
4
+ // regenerated schema/worker/middleware trio + the scaffold-once wrangler.jsonc).
5
+ export * from "./generate.js";
@@ -0,0 +1,20 @@
1
+ import { type CollectionConfig, type ContentConfig } from "louise-toolkit/content";
2
+ import type { AstroidConfig } from "../config.js";
3
+ /**
4
+ * The opinionated `pages` collection — the EDITABLE page fields, versioned
5
+ * drafts, and full-text search. Keyed to the same names as Louise's `pagesColumns`
6
+ * so a publish's `.set()` maps straight onto the physical columns; bookkeeping
7
+ * columns (`id`/`status`/timestamps/`publishedVersionId`) live on the table via
8
+ * `pagesColumns`, never here — matching the site's `pages-collection.ts`.
9
+ *
10
+ * Validated by `defineCollection` at build time, so a malformed field shape throws
11
+ * here rather than at codegen.
12
+ */
13
+ export declare function astroidPagesCollection(config: AstroidConfig): CollectionConfig;
14
+ /**
15
+ * The Louise `ContentConfig` for an Astroid project. Today: the `pages`
16
+ * collection. Archetype- and module-specific collections (e.g. a portfolio
17
+ * `gallery`) layer in here as they land — this is the single place that maps
18
+ * brand config down to Louise content.
19
+ */
20
+ export declare function astroidContentConfig(config: AstroidConfig): ContentConfig;
@@ -0,0 +1,64 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Astroid → Louise content mapping. This is the opinionated seam: given an
4
+ // Astroid project config, derive the Louise `CollectionConfig`(s) a site needs.
5
+ // Astroid decides WHICH collections and fields exist (its opinions); Louise's
6
+ // codegen decides HOW they become D1 tables. Dependency flows one way — this
7
+ // imports `louise-toolkit/content`, never the reverse.
8
+ import { defineCollection, } from "louise-toolkit/content";
9
+ import { sanitizeRichHtml } from "louise-toolkit/security";
10
+ /**
11
+ * The opinionated `pages` collection — the EDITABLE page fields, versioned
12
+ * drafts, and full-text search. Keyed to the same names as Louise's `pagesColumns`
13
+ * so a publish's `.set()` maps straight onto the physical columns; bookkeeping
14
+ * columns (`id`/`status`/timestamps/`publishedVersionId`) live on the table via
15
+ * `pagesColumns`, never here — matching the site's `pages-collection.ts`.
16
+ *
17
+ * Validated by `defineCollection` at build time, so a malformed field shape throws
18
+ * here rather than at codegen.
19
+ */
20
+ export function astroidPagesCollection(config) {
21
+ // The `body` is rich HTML edited in place (`<Editable type="richtext">`) and
22
+ // staged as a draft, so sanitize it on every write — never store raw HTML. A
23
+ // pasted `<img>` pointing off-origin (a hotlink) is dropped: body images must
24
+ // live in the media library. Mirrors the reference site's pages-collection hook.
25
+ const mediaBase = config.deploy?.mediaBase ?? "/media";
26
+ const fields = {};
27
+ fields.slug = { type: "text", required: true };
28
+ fields.title = { type: "text", required: true };
29
+ // Sanitized rich HTML (a string), not TipTap JSON — matches `pagesColumns.body`.
30
+ fields.body = { type: "text" };
31
+ fields.seoTitle = { type: "text" };
32
+ fields.seoDescription = { type: "text" };
33
+ fields.ogImage = { type: "text" };
34
+ fields.noindex = { type: "checkbox" };
35
+ fields.sortOrder = { type: "number" };
36
+ // Structured page-builder blocks (the editable home) — deep-validated against
37
+ // the section catalog on write in a later slice.
38
+ fields.sections = { type: "json" };
39
+ return defineCollection({
40
+ slug: "pages",
41
+ fields,
42
+ hooks: {
43
+ beforeChange: [
44
+ ({ data }) => {
45
+ if (typeof data.body === "string") {
46
+ return { ...data, body: sanitizeRichHtml(data.body, { mediaBase }) };
47
+ }
48
+ return data;
49
+ },
50
+ ],
51
+ },
52
+ versions: { drafts: true },
53
+ search: { fields: ["title", "body", "sections"] },
54
+ });
55
+ }
56
+ /**
57
+ * The Louise `ContentConfig` for an Astroid project. Today: the `pages`
58
+ * collection. Archetype- and module-specific collections (e.g. a portfolio
59
+ * `gallery`) layer in here as they land — this is the single place that maps
60
+ * brand config down to Louise content.
61
+ */
62
+ export function astroidContentConfig(config) {
63
+ return { collections: [astroidPagesCollection(config)] };
64
+ }
@@ -0,0 +1,13 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /** A ready-made table Astroid re-exports from `louise-toolkit/db`. */
3
+ export type AstroidFrameworkTable = "inquiries" | "media" | "siteSettings";
4
+ /** True when the site captures inquiries — a contact section or a
5
+ * wholesale-inquiry module. Shared by table selection (here) and route selection
6
+ * (the worker route plan). */
7
+ export declare function capturesInquiries(config: AstroidConfig): boolean;
8
+ /**
9
+ * The framework tables this project needs, sorted alphabetically (so the emitted
10
+ * import/export lists are stable). `media` + `siteSettings` always; `inquiries`
11
+ * when a brand captures them.
12
+ */
13
+ export declare function astroidFrameworkTables(config: AstroidConfig): AstroidFrameworkTable[];
@@ -0,0 +1,27 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Which ready-made Louise tables (louise-toolkit/db) an Astroid project needs.
4
+ // Astroid re-exports these rather than redefining them, so the core content
5
+ // tables never drift from Louise. `media` and `siteSettings` are universal;
6
+ // `inquiries` is pulled in only when a brand actually captures inquiries (a
7
+ // contact section, or a wholesale-inquiry module).
8
+ /** True when the site captures inquiries — a contact section or a
9
+ * wholesale-inquiry module. Shared by table selection (here) and route selection
10
+ * (the worker route plan). */
11
+ export function capturesInquiries(config) {
12
+ const wantsWholesale = (mods) => (mods ?? []).includes("wholesaleInquiry");
13
+ return ((config.sections ?? []).includes("contact") ||
14
+ wantsWholesale(config.modules) ||
15
+ wantsWholesale(config.portal?.features));
16
+ }
17
+ /**
18
+ * The framework tables this project needs, sorted alphabetically (so the emitted
19
+ * import/export lists are stable). `media` + `siteSettings` always; `inquiries`
20
+ * when a brand captures them.
21
+ */
22
+ export function astroidFrameworkTables(config) {
23
+ const tables = ["media", "siteSettings"];
24
+ if (capturesInquiries(config))
25
+ tables.push("inquiries");
26
+ return tables.sort();
27
+ }
@@ -0,0 +1,8 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /**
3
+ * Generate the TypeScript source of a site's Drizzle schema from an Astroid
4
+ * config. The output is a drop-in replacement for the hand-written `schema.ts`:
5
+ * a `pages` table composed from `pagesColumns`, a `pages_versions` snapshot
6
+ * table, and the re-exported framework tables the config selects.
7
+ */
8
+ export declare function generateAstroidSchema(config: AstroidConfig): string;
@@ -0,0 +1,48 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // generateAstroidSchema — emit the Drizzle schema source (a site's `schema.ts`)
4
+ // from an Astroid config. This is the boilerplate every Louise site hand-writes:
5
+ // compose the framework `pagesColumns` into a `pages` table, add the versions
6
+ // table + publish pointer, and re-export the ready-made framework tables. Astroid
7
+ // generates it, driven by the single-brand config.
8
+ //
9
+ // Pure string generation, mirroring louise-toolkit/content's own
10
+ // `generateSchemaSource`: the caller (the Astroid CLI, a later slice) writes the
11
+ // result to disk and formats it. Import ordering matches the site's schema.ts so
12
+ // a formatter never re-flags the generated file.
13
+ import { astroidFrameworkTables } from "./framework.js";
14
+ /**
15
+ * Generate the TypeScript source of a site's Drizzle schema from an Astroid
16
+ * config. The output is a drop-in replacement for the hand-written `schema.ts`:
17
+ * a `pages` table composed from `pagesColumns`, a `pages_versions` snapshot
18
+ * table, and the re-exported framework tables the config selects.
19
+ */
20
+ export function generateAstroidSchema(config) {
21
+ const framework = astroidFrameworkTables(config); // sorted
22
+ // pagesColumns is spread into `pages`; the framework tables are re-exported.
23
+ // One import brings them all in (matching the site) so nothing is unused.
24
+ const dbImports = [...framework, "pagesColumns"].sort();
25
+ return [
26
+ "// Generated by astroidjs — do not hand-edit.",
27
+ "// Source: your defineAstroid config.",
28
+ 'import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";',
29
+ 'import { collectionVersionsTable, type JsonValue } from "louise-toolkit/content";',
30
+ `import { ${dbImports.join(", ")} } from "louise-toolkit/db";`,
31
+ "",
32
+ 'export const pages = sqliteTable("pages", {',
33
+ " ...pagesColumns,",
34
+ " // Structured page-builder blocks (the editable home), rendered by the",
35
+ " // site's own section components.",
36
+ ' sections: text("sections", { mode: "json" }).$type<JsonValue>(),',
37
+ " // Nullable pointer to the live version (pages_versions.id); NULL = unpublished.",
38
+ ' publishedVersionId: integer("published_version_id"),',
39
+ "});",
40
+ "",
41
+ "// Version snapshots for pages. Field-independent — each saved version is one",
42
+ "// JSON blob — so the table only needs the slug.",
43
+ 'export const pagesVersions = collectionVersionsTable({ slug: "pages", fields: {} });',
44
+ "",
45
+ `export { ${framework.join(", ")} };`,
46
+ "",
47
+ ].join("\n");
48
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./collections.js";
2
+ export * from "./framework.js";
3
+ export * from "./generate.js";
@@ -0,0 +1,6 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Schema generation — config → Louise content config → Drizzle schema source.
4
+ export * from "./collections.js";
5
+ export * from "./framework.js";
6
+ export * from "./generate.js";
@@ -0,0 +1,24 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /**
3
+ * Generate the Worker entrypoint (`worker.ts`) from an Astroid config: the editor
4
+ * routes in collision-free order, an R2 media-asset route, and the `composeWorker`
5
+ * default export over Astro's SSR handler. Inquiry routes + the contact form are
6
+ * emitted only when a brand captures inquiries.
7
+ */
8
+ export declare function generateAstroidWorker(config: AstroidConfig): string;
9
+ /**
10
+ * Generate the Astro middleware (`middleware.ts`) from an Astroid config: the
11
+ * shared Louise flow (rate-limit the unauthenticated POST surface → resolve editor
12
+ * session + sticky `?louise` edit mode → content-freshness + security headers) via
13
+ * `createLouiseMiddleware`. The default rate rule caps `POST /api/auth/*` (magic-link
14
+ * sign-in) against the provisioned `RL` KV; auth is the same seam as the worker.
15
+ *
16
+ * CSP: `astro.config.mjs` enables `security.csp`, so Astro emits a hash-based
17
+ * `content-security-policy` response header on every SSR page. The `cspStyleSrc`
18
+ * below tells `createLouiseMiddleware` to rewrite that header's `style-src` to
19
+ * `'self' 'unsafe-inline'` — the hash-based `style-src` Astro emits would block
20
+ * Louise's data-driven `style=""` carriers and the editor's runtime-injected
21
+ * `<style>`. Script hashes are left verbatim (the template's inline scripts are
22
+ * kept hashable), and the inlined `data:` brand font is auto-allowed.
23
+ */
24
+ export declare function generateAstroidMiddleware(_config: AstroidConfig): string;
@@ -0,0 +1,200 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // generateAstroidWorker / generateAstroidMiddleware — emit the Cloudflare Worker
4
+ // entrypoint and the Astro middleware a Louise site would otherwise hand-write.
5
+ // The worker's editor routes are composed in the fixed order from the route plan
6
+ // (routes.ts), so the "versionsRoute/searchRoute before pagesRoute" collision is
7
+ // impossible by construction. Pure string generation, like generateAstroidSchema.
8
+ //
9
+ // Two seams are marked with TODO(astroid) and filled by later slices: the auth
10
+ // `resolveEditor`, and the section-catalog `validate` on the pages routes.
11
+ import { capturesInquiries } from "../schema/framework.js";
12
+ import { astroidEditorRoutePlan } from "./routes.js";
13
+ // Astroid's default editable site_settings surface — the columns the Settings
14
+ // panel may write, and which of them hold a media-library image URL.
15
+ const DEFAULT_SETTINGS_COLUMNS = [
16
+ "siteName",
17
+ "tagline",
18
+ "logoUrl",
19
+ "faviconUrl",
20
+ "brandColor",
21
+ "secondaryColor",
22
+ "tertiaryColor",
23
+ "contactEmail",
24
+ "contactPhone",
25
+ "contactAddress",
26
+ "socialLinks",
27
+ "navLinks",
28
+ "metaDescription",
29
+ "defaultOgImageUrl",
30
+ "disableIndexing",
31
+ ];
32
+ const DEFAULT_SETTINGS_IMAGE_KEYS = ["logoUrl", "faviconUrl", "defaultOgImageUrl"];
33
+ /**
34
+ * Generate the Worker entrypoint (`worker.ts`) from an Astroid config: the editor
35
+ * routes in collision-free order, an R2 media-asset route, and the `composeWorker`
36
+ * default export over Astro's SSR handler. Inquiry routes + the contact form are
37
+ * emitted only when a brand captures inquiries.
38
+ */
39
+ export function generateAstroidWorker(config) {
40
+ const inquiries = capturesInquiries(config);
41
+ const mediaBase = config.deploy?.mediaBase ?? "/media";
42
+ const seedName = config.theme.name;
43
+ const plan = astroidEditorRoutePlan(config);
44
+ const editorImports = [
45
+ "DEFAULT_PAGE_FIELDS",
46
+ ...new Set(plan.map((route) => route.factory)),
47
+ ].sort();
48
+ const tables = [
49
+ "media",
50
+ "pages",
51
+ "pagesVersions",
52
+ "siteSettings",
53
+ ...(inquiries ? ["inquiries"] : []),
54
+ ].sort();
55
+ const routeCall = (name) => {
56
+ switch (name) {
57
+ case "versions":
58
+ return "versionsRoute({ table: pages, versionsTable: pagesVersions, config: pagesCollection, resolveEditor })";
59
+ case "search":
60
+ return "searchRoute({ table: pages, config: pagesCollection, resolveEditor })";
61
+ case "pages":
62
+ return 'pagesRoute({ table: pages, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"] })';
63
+ case "save":
64
+ return 'saveRoute({ resolveEditor, collections: { pages: { table: pages, fields: ["title", "seoTitle", "seoDescription"] } } })';
65
+ case "settings":
66
+ return "settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: MEDIA_BASE })";
67
+ case "media":
68
+ return "mediaRoute({ table: media, resolveEditor })";
69
+ case "editors":
70
+ // Better Auth owns the `user` table (a NAME, not a Drizzle table), so this
71
+ // route takes the default `"user"`; a `tablePrefix` would rename it.
72
+ return 'editorsRoute({ table: "user", resolveEditor })';
73
+ case "form":
74
+ return "formRoute({ form: contactForm, rateLimitKv: (env) => env.RL })";
75
+ case "inquiries":
76
+ return "inquiriesRoute({ table: inquiries, resolveEditor })";
77
+ case "seed":
78
+ return `seedRoute({ table: siteSettings, resolveEditor, defaults: { siteName: ${JSON.stringify(seedName)} } })`;
79
+ }
80
+ };
81
+ const lines = [];
82
+ const p = (s = "") => lines.push(s);
83
+ p("// Generated by astroidjs — do not hand-edit.");
84
+ p("// Source: your defineAstroid config. The editor route ORDER is fixed by");
85
+ p("// Astroid to avoid matcher collisions — see each route's note below.");
86
+ p('import { handle } from "@astrojs/cloudflare/handler";');
87
+ p("import {");
88
+ for (const name of editorImports)
89
+ p(` ${name},`);
90
+ p('} from "louise-toolkit/editor";');
91
+ if (inquiries)
92
+ p('import { defineForm } from "louise-toolkit/forms";');
93
+ p('import { composeWorker, type WorkerRoute } from "louise-toolkit/worker";');
94
+ if (inquiries)
95
+ p('import { inquiriesForm } from "louise-toolkit/db";');
96
+ p(`import { ${tables.join(", ")} } from "./schema.js";`);
97
+ p('import { astroidPagesCollection } from "astroidjs";');
98
+ p('import astroidConfig from "./astroid.config.js";');
99
+ p("// TODO(astroid): your AUTH seam. resolveEditor resolves the editor session");
100
+ p("// from a request; a truthy result authorizes editor writes. A generated auth");
101
+ p("// module is a later slice.");
102
+ p('import { resolveEditor } from "./auth.js";');
103
+ p();
104
+ p(`const MEDIA_BASE = ${JSON.stringify(mediaBase)};`);
105
+ p("const pagesCollection = astroidPagesCollection(astroidConfig);");
106
+ p();
107
+ p("// Editable site_settings columns the Settings panel may write, and which of");
108
+ p("// them resolve to a media-library asset.");
109
+ p(`const SETTINGS_COLUMNS = ${JSON.stringify(DEFAULT_SETTINGS_COLUMNS)};`);
110
+ p(`const SETTINGS_IMAGE_KEYS = ${JSON.stringify(DEFAULT_SETTINGS_IMAGE_KEYS)};`);
111
+ if (inquiries) {
112
+ p();
113
+ p("// Public contact form: the built-in inquiries fields + silent spam");
114
+ p("// heuristics (a honeypot + a minimum time-since-render).");
115
+ p('const contactForm = defineForm({ name: "inquiries", fields: inquiriesForm.fields, spam: { honeypot: "website", minSeconds: 2, rateLimit: { max: 5, windowSec: 60 } } });');
116
+ }
117
+ p();
118
+ p("// TODO(astroid): sections validation (assertValidSections) is wired onto the");
119
+ p("// pages/versions routes once the section catalog lands (a later slice).");
120
+ p("const editorRoutes: WorkerRoute<CloudflareEnv>[] = [");
121
+ for (const route of plan) {
122
+ p(` // ${route.note}`);
123
+ p(` ${routeCall(route.name)},`);
124
+ }
125
+ p("];");
126
+ p();
127
+ p("// Stream uploaded media back from R2 at MEDIA_BASE (self-hosted, no public bucket).");
128
+ p("const mediaAssetRoute: WorkerRoute<CloudflareEnv> = async (request, env) => {");
129
+ p(" const url = new URL(request.url);");
130
+ p(" if (!url.pathname.startsWith(`${MEDIA_BASE}/`)) return undefined;");
131
+ p(" const key = decodeURIComponent(url.pathname.slice(MEDIA_BASE.length + 1));");
132
+ p(" if (!key) return undefined;");
133
+ p(" const obj = await env.MEDIA.get(key);");
134
+ p(' if (!obj) return new Response("Not found", { status: 404 });');
135
+ p(" const headers = new Headers();");
136
+ p(" obj.writeHttpMetadata(headers);");
137
+ p(' headers.set("etag", obj.httpEtag);');
138
+ p(' headers.set("cache-control", "public, max-age=31536000, immutable");');
139
+ p(' headers.set("x-content-type-options", "nosniff");');
140
+ p(" return new Response(obj.body, { headers });");
141
+ p("};");
142
+ p();
143
+ p("export default composeWorker<CloudflareEnv>({");
144
+ p(" routes: [...editorRoutes, mediaAssetRoute],");
145
+ p(" fetch: (request, env, ctx) => handle(request, env, ctx),");
146
+ p("});");
147
+ p();
148
+ return lines.join("\n");
149
+ }
150
+ /**
151
+ * Generate the Astro middleware (`middleware.ts`) from an Astroid config: the
152
+ * shared Louise flow (rate-limit the unauthenticated POST surface → resolve editor
153
+ * session + sticky `?louise` edit mode → content-freshness + security headers) via
154
+ * `createLouiseMiddleware`. The default rate rule caps `POST /api/auth/*` (magic-link
155
+ * sign-in) against the provisioned `RL` KV; auth is the same seam as the worker.
156
+ *
157
+ * CSP: `astro.config.mjs` enables `security.csp`, so Astro emits a hash-based
158
+ * `content-security-policy` response header on every SSR page. The `cspStyleSrc`
159
+ * below tells `createLouiseMiddleware` to rewrite that header's `style-src` to
160
+ * `'self' 'unsafe-inline'` — the hash-based `style-src` Astro emits would block
161
+ * Louise's data-driven `style=""` carriers and the editor's runtime-injected
162
+ * `<style>`. Script hashes are left verbatim (the template's inline scripts are
163
+ * kept hashable), and the inlined `data:` brand font is auto-allowed.
164
+ */
165
+ export function generateAstroidMiddleware(_config) {
166
+ // Louise's brand font is bundled + base64-inlined (no Google Fonts host to
167
+ // allow); createLouiseMiddleware auto-allows `data:` fonts in the CSP, so the
168
+ // inlined @font-face needs no manual `font-src` entry.
169
+ const cspStyleSrc = "'self' 'unsafe-inline'";
170
+ return [
171
+ "// Generated by astroidjs — do not hand-edit.",
172
+ "// The shared Louise middleware: rate-limit the unauthenticated POST surfaces,",
173
+ "// then resolve the editor session + sticky ?louise edit mode, then apply",
174
+ "// content-freshness + transport-security headers, and rewrite the style-src of",
175
+ "// the CSP header Astro's security.csp emits so Louise's data-driven inline",
176
+ "// styles + inlined data: brand font are allowed.",
177
+ 'import { env } from "cloudflare:workers";',
178
+ 'import { createLouiseMiddleware } from "louise-toolkit/astro";',
179
+ "// TODO(astroid): your AUTH seam — same resolveEditor as the generated worker.ts.",
180
+ 'import { resolveEditor } from "./auth.js";',
181
+ "",
182
+ "// Rate-limit the public, unauthenticated POST surface, keyed by client IP",
183
+ "// (fixed-window KV counter that fails open). The magic-link sign-in is the one",
184
+ "// that matters: without a cap, anyone who knows an editor's email could trigger",
185
+ "// unbounded sign-in emails (inbox flooding + Email/Worker spend). `env.RL` is",
186
+ "// read per request (a getter) — the KV binding is only valid in request scope.",
187
+ "const RATE_RULES = [",
188
+ ' { name: "auth", method: "POST", match: (p: string) => p.startsWith("/api/auth/"), limit: 10, windowSec: 60 },',
189
+ "];",
190
+ "",
191
+ "export const onRequest = createLouiseMiddleware({",
192
+ " resolveEditor: (request) => resolveEditor(request),",
193
+ " rateLimit: { rules: RATE_RULES, kv: () => env.RL },",
194
+ " // Rewrite Astro's hash-based style-src (enabled via security.csp in",
195
+ " // astro.config.mjs) to permit Louise's data-driven style=\"\" + editor styles.",
196
+ ` cspStyleSrc: ${JSON.stringify(cspStyleSrc)},`,
197
+ "});",
198
+ "",
199
+ ].join("\n");
200
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./routes.js";
2
+ export * from "./generate.js";
@@ -0,0 +1,6 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Worker + middleware generation — config → the collision-free editor route plan
4
+ // → the Worker entrypoint and Astro middleware a site would otherwise hand-write.
5
+ export * from "./routes.js";
6
+ export * from "./generate.js";
@@ -0,0 +1,17 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ export type AstroidEditorRouteName = "versions" | "search" | "pages" | "save" | "settings" | "media" | "editors" | "form" | "inquiries" | "seed";
3
+ export interface AstroidEditorRoute {
4
+ /** Stable key for this route. */
5
+ name: AstroidEditorRouteName;
6
+ /** The `louise-toolkit/editor` factory that builds it. */
7
+ factory: string;
8
+ /** Why it's here, and any ordering constraint that pins its position. */
9
+ note: string;
10
+ }
11
+ /**
12
+ * The ordered editor route plan for a project. Order is load-bearing: the two
13
+ * routes with `/pages/:id/...` sub-paths (`versions`, `search`) come first, so
14
+ * `pages`' catch-all `/:id` matcher can't swallow them. Inquiry routes are
15
+ * included only when a brand captures inquiries; `seed` is always last.
16
+ */
17
+ export declare function astroidEditorRoutePlan(config: AstroidConfig): AstroidEditorRoute[];