astroidjs 0.1.1 → 0.2.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.
Files changed (154) hide show
  1. package/README.md +240 -5
  2. package/bin/astroid.mjs +185 -9
  3. package/dist/analytics/index.d.ts +37 -0
  4. package/dist/analytics/index.js +108 -0
  5. package/dist/astro/csp.d.ts +64 -0
  6. package/dist/astro/csp.js +173 -0
  7. package/dist/astro/index.d.ts +1 -0
  8. package/dist/astro/index.js +7 -0
  9. package/dist/commerce/adapters.d.ts +60 -0
  10. package/dist/commerce/adapters.js +90 -0
  11. package/dist/commerce/checkout-scaffold.d.ts +42 -0
  12. package/dist/commerce/checkout-scaffold.js +306 -0
  13. package/dist/commerce/checkout.d.ts +72 -0
  14. package/dist/commerce/checkout.js +124 -0
  15. package/dist/commerce/index.d.ts +8 -0
  16. package/dist/commerce/index.js +9 -0
  17. package/dist/commerce/loader.d.ts +71 -0
  18. package/dist/commerce/loader.js +90 -0
  19. package/dist/commerce/mirror.d.ts +67 -0
  20. package/dist/commerce/mirror.js +203 -0
  21. package/dist/commerce/roles.d.ts +38 -0
  22. package/dist/commerce/roles.js +93 -0
  23. package/dist/commerce/secrets.d.ts +74 -0
  24. package/dist/commerce/secrets.js +129 -0
  25. package/dist/commerce/sync.d.ts +86 -0
  26. package/dist/commerce/sync.js +154 -0
  27. package/dist/components/sections.d.ts +577 -0
  28. package/dist/components/sections.js +425 -0
  29. package/dist/config.d.ts +174 -12
  30. package/dist/config.js +43 -1
  31. package/dist/email/index.d.ts +4 -0
  32. package/dist/email/index.js +5 -0
  33. package/dist/email/inquiry.d.ts +33 -0
  34. package/dist/email/inquiry.js +63 -0
  35. package/dist/email/send.d.ts +120 -0
  36. package/dist/email/send.js +196 -0
  37. package/dist/email/templates.d.ts +24 -0
  38. package/dist/email/templates.js +184 -0
  39. package/dist/email/theme.d.ts +24 -0
  40. package/dist/email/theme.js +150 -0
  41. package/dist/errors.d.ts +14 -0
  42. package/dist/errors.js +17 -0
  43. package/dist/index.d.ts +14 -0
  44. package/dist/index.js +14 -0
  45. package/dist/map/index.d.ts +3 -0
  46. package/dist/map/index.js +4 -0
  47. package/dist/map/pmtiles.d.ts +92 -0
  48. package/dist/map/pmtiles.js +130 -0
  49. package/dist/map/scaffold.d.ts +29 -0
  50. package/dist/map/scaffold.js +212 -0
  51. package/dist/map/style.d.ts +58 -0
  52. package/dist/map/style.js +154 -0
  53. package/dist/portal/config.d.ts +26 -0
  54. package/dist/portal/config.js +50 -0
  55. package/dist/portal/guard.d.ts +48 -0
  56. package/dist/portal/guard.js +64 -0
  57. package/dist/portal/index.d.ts +5 -0
  58. package/dist/portal/index.js +6 -0
  59. package/dist/portal/nav.d.ts +26 -0
  60. package/dist/portal/nav.js +35 -0
  61. package/dist/portal/scaffold.d.ts +28 -0
  62. package/dist/portal/scaffold.js +140 -0
  63. package/dist/portal/session.d.ts +36 -0
  64. package/dist/portal/session.js +86 -0
  65. package/dist/portfolio/index.d.ts +1 -0
  66. package/dist/portfolio/index.js +4 -0
  67. package/dist/portfolio/scaffold.d.ts +9 -0
  68. package/dist/portfolio/scaffold.js +93 -0
  69. package/dist/project/actions.d.ts +3 -0
  70. package/dist/project/actions.js +106 -0
  71. package/dist/project/generate.d.ts +15 -0
  72. package/dist/project/generate.js +144 -2
  73. package/dist/project/index.d.ts +2 -0
  74. package/dist/project/index.js +2 -0
  75. package/dist/project/scaffold.d.ts +29 -0
  76. package/dist/project/scaffold.js +140 -0
  77. package/dist/pwa/generate.d.ts +49 -0
  78. package/dist/pwa/generate.js +218 -0
  79. package/dist/pwa/index.d.ts +1 -0
  80. package/dist/pwa/index.js +2 -0
  81. package/dist/queues/consumer.d.ts +29 -0
  82. package/dist/queues/consumer.js +37 -0
  83. package/dist/queues/index.d.ts +4 -0
  84. package/dist/queues/index.js +5 -0
  85. package/dist/queues/messages.d.ts +60 -0
  86. package/dist/queues/messages.js +71 -0
  87. package/dist/queues/scaffold.d.ts +44 -0
  88. package/dist/queues/scaffold.js +204 -0
  89. package/dist/queues/webhook.d.ts +60 -0
  90. package/dist/queues/webhook.js +81 -0
  91. package/dist/realtime/index.d.ts +1 -0
  92. package/dist/realtime/index.js +4 -0
  93. package/dist/realtime/scaffold.d.ts +30 -0
  94. package/dist/realtime/scaffold.js +159 -0
  95. package/dist/schema/collections.d.ts +42 -8
  96. package/dist/schema/collections.js +102 -8
  97. package/dist/schema/generate.js +10 -1
  98. package/dist/secrets.d.ts +54 -0
  99. package/dist/secrets.js +80 -0
  100. package/dist/security/index.d.ts +1 -0
  101. package/dist/security/index.js +2 -0
  102. package/dist/security/rate-rules.d.ts +21 -0
  103. package/dist/security/rate-rules.js +107 -0
  104. package/dist/seo/index.d.ts +3 -0
  105. package/dist/seo/index.js +4 -0
  106. package/dist/seo/resolve.d.ts +68 -0
  107. package/dist/seo/resolve.js +73 -0
  108. package/dist/seo/routes.d.ts +44 -0
  109. package/dist/seo/routes.js +104 -0
  110. package/dist/seo/structured-data.d.ts +51 -0
  111. package/dist/seo/structured-data.js +105 -0
  112. package/dist/status.d.ts +51 -0
  113. package/dist/status.js +113 -0
  114. package/dist/worker/generate.d.ts +18 -10
  115. package/dist/worker/generate.js +325 -37
  116. package/dist/worker/routes.d.ts +1 -1
  117. package/dist/worker/routes.js +42 -0
  118. package/dist/workflow/advance.d.ts +102 -0
  119. package/dist/workflow/advance.js +145 -0
  120. package/dist/workflow/config.d.ts +60 -0
  121. package/dist/workflow/config.js +73 -0
  122. package/dist/workflow/generate.d.ts +22 -0
  123. package/dist/workflow/generate.js +138 -0
  124. package/dist/workflow/index.d.ts +3 -0
  125. package/dist/workflow/index.js +4 -0
  126. package/package.json +21 -5
  127. package/src/components/Editable.astro +33 -9
  128. package/src/components/JustifiedGallery.astro +254 -0
  129. package/src/components/MediaSlot.astro +178 -0
  130. package/src/components/PortalShell.astro +80 -0
  131. package/src/components/RegisterSW.astro +45 -0
  132. package/src/components/Section.astro +101 -35
  133. package/src/components/Sections.astro +64 -0
  134. package/src/components/Seo.astro +57 -0
  135. package/src/components/StageBar.astro +137 -0
  136. package/src/components/StructuredData.astro +33 -0
  137. package/src/components/justify.ts +170 -0
  138. package/src/components/media-meta.ts +174 -0
  139. package/src/components/sections/AboutIntro.astro +46 -0
  140. package/src/components/sections/Banner.astro +31 -0
  141. package/src/components/sections/Contact.astro +22 -9
  142. package/src/components/sections/Cta.astro +33 -10
  143. package/src/components/sections/Faq.astro +50 -0
  144. package/src/components/sections/FeatureGrid.astro +40 -11
  145. package/src/components/sections/Gallery.astro +46 -0
  146. package/src/components/sections/Hero.astro +40 -12
  147. package/src/components/sections/LocationHours.astro +59 -0
  148. package/src/components/sections/Media.astro +44 -0
  149. package/src/components/sections/PricingTiers.astro +79 -0
  150. package/src/components/sections/ProductGrid.astro +73 -0
  151. package/src/components/sections/SplitImage.astro +61 -0
  152. package/src/components/sections/Steps.astro +58 -0
  153. package/src/components/sections/Testimonial.astro +51 -0
  154. package/src/components/sections.ts +452 -67
@@ -0,0 +1,106 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // `src/actions/index.ts` — the Astro-native, typed mutation surface (ADR 0001
4
+ // layer 2), beside the framework-agnostic `/api/louise/*` routes.
5
+ //
6
+ // Astroid generated only the route half. That is not a missing convenience: the
7
+ // two entrypoints write the SAME rows, and the whole reason `louise-toolkit/astro`
8
+ // exposes these factories is that each one shares the raw route's store path —
9
+ // `applyFieldSave`, `applySettingsPatch`, `applySaveDraft`. A project that wired
10
+ // its own Actions by hand would get a second write path, and a second write path
11
+ // is where validation, sanitization, and draft-merge semantics drift apart
12
+ // silently (#138).
13
+ //
14
+ // So this file is SCAFFOLD-ONCE and is meant to be added to — the reference site
15
+ // keeps its own bespoke actions right beside these — but the three below come
16
+ // pre-wired against the same tables and the same collection config the generated
17
+ // worker uses.
18
+ /** `src/actions/index.ts` — the typed mutation surface, scaffolded once. */
19
+ export function generateAstroidActions(_config) {
20
+ return [
21
+ "// The typed Astro Actions surface — ADR 0001 layer 2.",
22
+ "//",
23
+ "// Scaffolded once and yours to ADD to: put your own `defineAction`s in the",
24
+ "// `server` object below, alongside these.",
25
+ "//",
26
+ "// What matters about the three that ship here is that they are NOT a second",
27
+ "// implementation. Each factory shares the store path of its raw",
28
+ "// `/api/louise/*` counterpart (`applyFieldSave`, `applySettingsPatch`,",
29
+ "// `applySaveDraft`), so a field is validated once and written in exactly one",
30
+ "// place however it was called. Hand-rolling an Action that writes the same",
31
+ "// row is how sanitization and draft-merge semantics drift apart without",
32
+ "// anything failing.",
33
+ 'import { ActionError, defineAction } from "astro:actions";',
34
+ 'import { env } from "cloudflare:workers";',
35
+ "import {",
36
+ " louiseSaveAction,",
37
+ " louiseSaveDraftAction,",
38
+ " louiseSettingsAction,",
39
+ '} from "louise-toolkit/astro";',
40
+ "import {",
41
+ " ASTROID_SETTINGS_COLUMNS,",
42
+ " ASTROID_SETTINGS_IMAGE_KEYS,",
43
+ " astroidPagesCollection,",
44
+ '} from "astroidjs";',
45
+ 'import astroidConfig from "../../astroid.config.js";',
46
+ 'import { pages, pagesVersions, siteSettings } from "../schema.js";',
47
+ "",
48
+ "const pagesCollection = astroidPagesCollection(astroidConfig);",
49
+ "",
50
+ "// Astro v6+ removed `Astro.locals.runtime.env`, so the bindings are resolved",
51
+ "// from `cloudflare:workers` — the same env the raw routes read.",
52
+ "const getEnv = () => env as unknown as CloudflareEnv;",
53
+ "",
54
+ "// `getEditor` is left to its default (`locals.editor`), which the generated",
55
+ "// middleware sets for a signed-in editor. A falsy result answers 401, so these",
56
+ "// carry the same gate as the routes rather than a parallel one.",
57
+ "const deps = { ActionError, getEnv };",
58
+ "",
59
+ "export const server = {",
60
+ " louise: {",
61
+ " // Inline field save (title, SEO) — the live, non-versioned path.",
62
+ " save: defineAction(",
63
+ " louiseSaveAction({",
64
+ " ...deps,",
65
+ " collections: {",
66
+ " pages: {",
67
+ " table: pages,",
68
+ ' fields: ["title", "seoTitle", "seoDescription"],',
69
+ " },",
70
+ " },",
71
+ " }),",
72
+ " ),",
73
+ "",
74
+ " // The versioned body/sections save — stages a DRAFT, exactly as",
75
+ " // versionsRoute does, through the same `applySaveDraft`.",
76
+ " saveDraft: defineAction(",
77
+ " louiseSaveDraftAction({",
78
+ " ...deps,",
79
+ " table: pages,",
80
+ " versionsTable: pagesVersions,",
81
+ " config: pagesCollection,",
82
+ " // The same KV write-buffer the route uses. Both entrypoints coalesce",
83
+ " // through one buffer, so an autosave burst is one D1 write however",
84
+ " // the client happened to call in.",
85
+ " bufferKv: (e) => e.DRAFTS,",
86
+ " }),",
87
+ " ),",
88
+ "",
89
+ " // The Settings-panel patch (brand, nav, contact, SEO defaults).",
90
+ " settings: defineAction(",
91
+ " louiseSettingsAction({",
92
+ " ...deps,",
93
+ " table: siteSettings,",
94
+ " // The SAME allowlist the generated worker enforces, imported rather",
95
+ " // than copied — a second literal here is a list that drifts from the",
96
+ " // one the routes check against, and nothing would fail when it did.",
97
+ " columns: ASTROID_SETTINGS_COLUMNS,",
98
+ " imageKeys: ASTROID_SETTINGS_IMAGE_KEYS,",
99
+ ' mediaBase: astroidConfig.deploy?.mediaBase ?? "/media",',
100
+ " }),",
101
+ " ),",
102
+ " },",
103
+ "};",
104
+ "",
105
+ ].join("\n");
106
+ }
@@ -12,6 +12,21 @@ export interface GeneratedFile {
12
12
  * once files (wrangler.jsonc, astro.config, auth.ts) are NOT here by design.
13
13
  */
14
14
  export declare function generateAstroidProject(config: AstroidConfig): GeneratedFile[];
15
+ /**
16
+ * The module-secret block `create-astroid` substitutes into `.env.example`.
17
+ *
18
+ * Every name is seeded with the placeholder sentinel rather than left empty,
19
+ * which is the whole trick behind a scaffold that runs with no accounts: the
20
+ * bindings all EXIST and all read as unconfigured, so each module takes its
21
+ * dormant path deliberately instead of hitting an undefined-binding error. The
22
+ * names come from {@link commerceSecretNames}, the same declaration the runtime
23
+ * gate and the generated `env.d.ts` read.
24
+ *
25
+ * Empty string when the project enables no module that needs credentials — the
26
+ * core secrets (session, Turnstile, mail) are already in the template file, with
27
+ * their own prose.
28
+ */
29
+ export declare function generateAstroidSecretsEnv(config: AstroidConfig): string;
15
30
  /**
16
31
  * Generate a floor `wrangler.jsonc` from the config: the Worker name + editable
17
32
  * bindings a baseline Louise site needs (D1, R2 media, the rate-limit + autosave
@@ -14,6 +14,13 @@
14
14
  // them (fills real binding ids, secrets, account). `astroid generate` must
15
15
  // NEVER clobber them, or it would wipe provisioned ids — so they live in a
16
16
  // separate function the regenerate path doesn't call.
17
+ import { ASTROID_VITALS_BINDING, astroidVitalsDataset, } from "../analytics/index.js";
18
+ import { astroidCheckoutVars } from "../commerce/checkout-scaffold.js";
19
+ import { astroidCommerceProviders } from "../commerce/roles.js";
20
+ import { COMMERCE_PROVIDER_SECRETS, COMMERCE_PROVIDER_SETUP, commerceSecretNames, } from "../commerce/secrets.js";
21
+ import { ASTROID_QUEUE_BINDING, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
22
+ import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, ASTROID_REALTIME_MIGRATION_TAG, usesRealtime, } from "../realtime/scaffold.js";
23
+ import { ASTROID_SECRET_PLACEHOLDER } from "../secrets.js";
17
24
  import { generateAstroidSchema } from "../schema/generate.js";
18
25
  import { generateAstroidMiddleware, generateAstroidWorker } from "../worker/generate.js";
19
26
  /**
@@ -29,6 +36,43 @@ export function generateAstroidProject(config) {
29
36
  { path: "src/middleware.ts", contents: generateAstroidMiddleware(config) },
30
37
  ];
31
38
  }
39
+ /**
40
+ * The module-secret block `create-astroid` substitutes into `.env.example`.
41
+ *
42
+ * Every name is seeded with the placeholder sentinel rather than left empty,
43
+ * which is the whole trick behind a scaffold that runs with no accounts: the
44
+ * bindings all EXIST and all read as unconfigured, so each module takes its
45
+ * dormant path deliberately instead of hitting an undefined-binding error. The
46
+ * names come from {@link commerceSecretNames}, the same declaration the runtime
47
+ * gate and the generated `env.d.ts` read.
48
+ *
49
+ * Empty string when the project enables no module that needs credentials — the
50
+ * core secrets (session, Turnstile, mail) are already in the template file, with
51
+ * their own prose.
52
+ */
53
+ export function generateAstroidSecretsEnv(config) {
54
+ const providers = astroidCommerceProviders(config.commerce);
55
+ if (providers.length === 0)
56
+ return "";
57
+ const lines = [
58
+ "",
59
+ "# --- commerce -------------------------------------------------------------",
60
+ "#",
61
+ "# Seeded with the DUMMY_REPLACE_ME sentinel, which reads as NOT CONFIGURED.",
62
+ "# Commerce is dormant until every value below is real: the D1 catalog mirror",
63
+ "# still serves whatever it last synced, the webhook receiver answers 503 (so",
64
+ "# the provider retries rather than dropping events), and checkout is",
65
+ "# simulated. Nothing calls the provider with a placeholder credential.",
66
+ ];
67
+ for (const provider of providers) {
68
+ const spec = COMMERCE_PROVIDER_SECRETS[provider];
69
+ lines.push("#", `# ${provider}: ${COMMERCE_PROVIDER_SETUP[provider]}`);
70
+ for (const name of [...spec.credentials, spec.webhook]) {
71
+ lines.push(`${name}=${ASTROID_SECRET_PLACEHOLDER}`);
72
+ }
73
+ }
74
+ return lines.join("\n");
75
+ }
32
76
  // Pinned compatibility date for the emitted Worker. A literal (Astroid's
33
77
  // generators are pure — no `Date.now()`), bumped deliberately when the runtime
34
78
  // baseline moves; matches the reference site's wrangler.jsonc.
@@ -73,6 +117,50 @@ export function generateAstroidWrangler(config) {
73
117
  p(" // No `hosts` in your config → deploys to <name>.workers.dev. Add a");
74
118
  p(' // "routes" block with a custom_domain pattern to serve a real domain.');
75
119
  }
120
+ if (usesRealtime(config)) {
121
+ // The per-page live editing session (ADR 0002). Two halves, and BOTH are
122
+ // required — a binding with no migration is a deploy error, and the class
123
+ // must also be exported from the worker entry (the generated src/worker.ts
124
+ // re-exports it) or wrangler can't resolve `class_name`.
125
+ p(" // Durable Object: the per-page live editing session (realtime module).");
126
+ p(' "durable_objects": {');
127
+ p(` "bindings": [{ "name": ${JSON.stringify(ASTROID_REALTIME_BINDING)}, "class_name": ${JSON.stringify(ASTROID_EDIT_SESSION_CLASS)} }]`);
128
+ p(" },");
129
+ p(" // A DO class needs a migration tag. `new_sqlite_classes` (NOT");
130
+ p(" // `new_classes`) because the session keeps its authoritative state in");
131
+ p(" // `ctx.storage`, which is the SQLite-backed store — and the storage");
132
+ p(" // backend cannot be changed after the class is first deployed.");
133
+ p(" \"migrations\": [");
134
+ p(` { "tag": ${JSON.stringify(ASTROID_REALTIME_MIGRATION_TAG)}, "new_sqlite_classes": [${JSON.stringify(ASTROID_EDIT_SESSION_CLASS)}] }`);
135
+ p(" ],");
136
+ }
137
+ // Crons. ONE `scheduled` handler receives all of them and tells them apart by
138
+ // `controller.cron`, so this list and the handler's dispatch must agree
139
+ // exactly — both come from `astroidCrons`, which is why it exists.
140
+ //
141
+ // Daily: the site-health scan (broken links, missing alt text, SEO gaps).
142
+ // Hourly (commerce only): the catalog re-sync safety net, so a missed or DLQ'd
143
+ // webhook can only leave the site stale until the next tick.
144
+ p(` "triggers": { "crons": ${JSON.stringify(astroidCrons(config))} },`);
145
+ if (astroidUsesQueues(config)) {
146
+ const { queue, dlq } = astroidQueueNames(config);
147
+ p(" // Provider webhooks are verified at the edge, then enqueued here so the");
148
+ p(" // receiver can return fast. Retries + DLQ routing are Cloudflare's, not");
149
+ p(" // the consumer's — set them here, not in code.");
150
+ p(` // Create both: \`wrangler queues create ${queue}\` and \`… ${dlq}\`.`);
151
+ p(' "queues": {');
152
+ p(` "producers": [{ "queue": ${JSON.stringify(queue)}, "binding": ${JSON.stringify(ASTROID_QUEUE_BINDING)} }],`);
153
+ p(' "consumers": [');
154
+ p(" {");
155
+ p(` "queue": ${JSON.stringify(queue)},`);
156
+ p(` "max_batch_size": ${config.queues?.maxBatchSize ?? 10},`);
157
+ p(` "max_batch_timeout": ${config.queues?.maxBatchTimeout ?? 30},`);
158
+ p(` "max_retries": ${config.queues?.maxRetries ?? 5},`);
159
+ p(` "dead_letter_queue": ${JSON.stringify(dlq)},`);
160
+ p(" },");
161
+ p(" ],");
162
+ p(" },");
163
+ }
76
164
  p(" // D1 holds pages / site_settings / media / inquiries (schema in src/schema.ts,");
77
165
  p(" // migrations in ./migrations). Create it: `wrangler d1 create <name>`.");
78
166
  p(' "d1_databases": [');
@@ -89,20 +177,74 @@ export function generateAstroidWrangler(config) {
89
177
  p(" // Cloudflare Images: the media route reads upload dimensions + backs server-");
90
178
  p(" // side re-encode. Also @astrojs/cloudflare's production image service.");
91
179
  p(' "images": { "binding": "IMAGES" },');
92
- p(" // KV: RL = the security rate limiter; DRAFTS = the autosave write-buffer.");
180
+ p(" // Analytics Engine: real-visitor Core Web Vitals. Free, and the ingest");
181
+ p(" // route accepts-and-drops without it, so it costs nothing unused. Reading");
182
+ p(" // the p75 back out needs CF_ACCOUNT_ID + CF_API_TOKEN (see .env.example) —");
183
+ p(" // until those are real the Health badge reads 'not measured yet'.");
184
+ p(` "analytics_engine_datasets": [{ "binding": ${JSON.stringify(ASTROID_VITALS_BINDING)}, "dataset": ${JSON.stringify(astroidVitalsDataset(config))} }],`);
185
+ p(" // Workers AI. Powers the editor's rewrite + SEO-suggest buttons and alt-text");
186
+ p(" // generation on upload — all of which SHIP IN THE EDITOR DRAWER already and,");
187
+ p(" // without this binding, were permanently invisible: their routes answer 503");
188
+ p(" // and the client hides the button. No account setup beyond the binding, and");
189
+ p(" // every call is editor-gated, so a visitor can never spend your AI budget.");
190
+ p(' "ai": { "binding": "AI" },');
191
+ p(" // KV: RL = the security rate limiter (it also holds the daily site-health");
192
+ p(" // summary under its own key — one small singleton blob, not worth a binding");
193
+ p(" // someone has to remember to provision); DRAFTS = the autosave write-buffer.");
93
194
  p(" // Create each: `wrangler kv namespace create <RL|DRAFTS>`.");
94
195
  p(' "kv_namespaces": [');
95
196
  p(' { "binding": "RL", "id": "<run: wrangler kv namespace create RL>" },');
96
197
  p(' { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create DRAFTS>" },');
97
198
  p(" ],");
199
+ // Email Sending. NOT optional decoration: `src/env.d.ts` declares EMAIL as a
200
+ // required member, and Better Auth's magic-link path console-logs the link in
201
+ // dev but calls `env.EMAIL.send(...)` unconditionally in production. Without
202
+ // this binding that call is a TypeError on a binding that was never created —
203
+ // so sign-in was impossible on every DEPLOYED site, while every local build
204
+ // and every CI scaffold passed. Nothing in this repo runs a deployed scaffold,
205
+ // which is why it survived.
206
+ p(" // Cloudflare Email Sending — magic-link sign-in + inquiry notifications.");
207
+ p(" // Sign-in DEPENDS on this: in production the magic link is emailed, not logged.");
208
+ p(" // Enable Email Sending for your zone, then verify the address in MAIL_FROM.");
209
+ p(' "send_email": [{ "name": "EMAIL" }],');
98
210
  p(" // Public base for media URLs; same-origin keeps media self-contained. Read off");
99
211
  p(" // the runtime env by the framework-agnostic media route, so it stays a `var`.");
100
212
  p(' "vars": {');
101
213
  p(` "MEDIA_URL": ${JSON.stringify(mediaBase)},`);
102
214
  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).');
215
+ p(" // The editor allowlist / owner. Wire this into your auth seam (src/auth.ts).");
104
216
  p(' "OWNER_EMAIL": "",');
217
+ p(" // Edge caching for published pages (ADR 0004). OFF by default, and the");
218
+ p(" // default is the safe state: with it off every render is `no-store` and");
219
+ p(" // the Worker cache layer stores nothing.");
220
+ p(" //");
221
+ p(" // Turn it on for a PREVIEW deploy first and walk the activation runbook");
222
+ p(" // (docs/adr/0004-edge-caching.md). `caches.default` is not cleared by");
223
+ p(" // Cloudflare Dev Mode or Purge Everything, so a bad prod flip is hard to");
224
+ p(" // undo — this feature was reverted twice for exactly that.");
225
+ p(' "ASTROID_EDGE_CACHE": "false",');
226
+ for (const v of astroidCheckoutVars(config)) {
227
+ // Public, not secret — the app id ships to the browser to mount the card
228
+ // field, and the environment is a choice. Keeping them out of the secret
229
+ // roster also keeps them out of the dormancy gate, which asks whether we can
230
+ // safely CALL Square, not whether a card field can render.
231
+ p(` ${JSON.stringify(v.name)}: ${JSON.stringify(v.value)},`);
232
+ }
105
233
  p(" },");
234
+ // Secrets are NOT vars: they belong in .dev.vars locally and in `wrangler
235
+ // secret put` / Secrets Store when deployed. Listing the names here is
236
+ // deliberate — this is the file someone opens when provisioning, and the list
237
+ // is generated from the same declaration the runtime dormancy gate reads.
238
+ const secretNames = commerceSecretNames(config.commerce);
239
+ if (secretNames.length > 0) {
240
+ p(" // Commerce secrets — set OUTSIDE this file (it's committed):");
241
+ p(" // local: .dev.vars (see .env.example, seeded with DUMMY_REPLACE_ME)");
242
+ p(" // deployed: `wrangler secret put <NAME>`, or a Secrets Store binding");
243
+ p(" // Until each is real, commerce stays dormant: the D1 mirror serves, the");
244
+ p(" // webhook receiver answers 503, and nothing calls the provider.");
245
+ for (const name of secretNames)
246
+ p(` // ${name}`);
247
+ }
106
248
  p(' "observability": { "enabled": true },');
107
249
  p("}");
108
250
  p();
@@ -1 +1,3 @@
1
1
  export * from "./generate.js";
2
+ export * from "./actions.js";
3
+ export * from "./scaffold.js";
@@ -3,3 +3,5 @@
3
3
  // Project generation — config → the files the `astroid` CLI writes (the
4
4
  // regenerated schema/worker/middleware trio + the scaffold-once wrangler.jsonc).
5
5
  export * from "./generate.js";
6
+ export * from "./actions.js";
7
+ export * from "./scaffold.js";
@@ -0,0 +1,29 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /**
3
+ * A file written once, then owned by the project.
4
+ *
5
+ * `apply` is the whole contract. `"skip"` (the default) leaves an existing file
6
+ * alone. `"append-once"` is for the files a project accumulates into rather than
7
+ * owns outright — `public/_headers` gets a stanza per module, and a second
8
+ * module must not erase the first one's.
9
+ */
10
+ export interface ScaffoldFile {
11
+ /** Path relative to the project root, POSIX-separated. */
12
+ path: string;
13
+ contents: string;
14
+ /** What to do when the path already exists. Default `"skip"`. */
15
+ apply?: "skip" | "append-once";
16
+ /**
17
+ * For `"append-once"`: a substring that proves this stanza is already there.
18
+ * Without it a re-run would append a duplicate every time.
19
+ */
20
+ marker?: string;
21
+ }
22
+ /**
23
+ * Every scaffold-once file this config implies.
24
+ *
25
+ * Ordered by module so a `generate` that writes several prints them in a stable
26
+ * sequence. Returns `[]` for a plain marketing site with no modules — the
27
+ * baseline floor is entirely the regenerated trio plus the static template.
28
+ */
29
+ export declare function generateAstroidScaffoldFiles(config: AstroidConfig): ScaffoldFile[];
@@ -0,0 +1,140 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // Every SCAFFOLD-ONCE file a config implies, in one list.
4
+ //
5
+ // This exists because the split between "generated" and "scaffolded" was drawn
6
+ // in the wrong place. `generateAstroidProject` returns the regenerated trio, and
7
+ // that trio emits STATIC IMPORTS of scaffold-once modules:
8
+ //
9
+ // src/worker.ts → import { handleQueueMessage } from "./queue.js"
10
+ // src/middleware.ts → import { resolvePortalUser } from "./portal-auth.js"
11
+ //
12
+ // The files behind those imports were written in exactly one place —
13
+ // `create-astroid`'s CLI — and nothing else could produce them. So turning a
14
+ // module on AFTER scaffold, by editing the one typed config the framework is
15
+ // built around, regenerated a trio importing files that did not exist. `astroid
16
+ // doctor` reported "healthy" and the project failed in Vite.
17
+ //
18
+ // Aggregating them here makes `astroid generate` able to complete a config
19
+ // change, `astroid doctor` able to notice one it hasn't, and `create-astroid`
20
+ // stop hand-listing the same nine files a third time.
21
+ //
22
+ // SCAFFOLD-ONCE means what it says: every file here is written only when ABSENT.
23
+ // Each one exists to be edited (what a catalog refresh means, which events
24
+ // matter, what a reset email says), so regenerating over it would destroy the
25
+ // work the seam exists to hold.
26
+ import { generateAstroidCheckoutRoute, generateAstroidSquareCard, } from "../commerce/checkout-scaffold.js";
27
+ import { generateCatalogMigrationSql } from "../commerce/mirror.js";
28
+ import { generateAstroidVitalsBeacon } from "../analytics/index.js";
29
+ import { generateAstroidActions } from "./actions.js";
30
+ import { cwvBeaconScript } from "louise-toolkit/analytics";
31
+ import { generateMapEmbedComponent, generateMapTileRoute } from "../map/scaffold.js";
32
+ import { generateAstroidGalleryPage } from "../portfolio/scaffold.js";
33
+ import { generateAstroidPortalAuth, generateAstroidPortalAuthRoute } from "../portal/scaffold.js";
34
+ import { generateAstroidEditSession } from "../realtime/scaffold.js";
35
+ import { generatePwaHeaders, generateServiceWorker, generateWebManifest } from "../pwa/generate.js";
36
+ import { astroidUsesQueues } from "../queues/messages.js";
37
+ import { generateAstroidQueueSeam, generateAstroidWebhookRoutes } from "../queues/scaffold.js";
38
+ /**
39
+ * Every scaffold-once file this config implies.
40
+ *
41
+ * Ordered by module so a `generate` that writes several prints them in a stable
42
+ * sequence. Returns `[]` for a plain marketing site with no modules — the
43
+ * baseline floor is entirely the regenerated trio plus the static template.
44
+ */
45
+ export function generateAstroidScaffoldFiles(config) {
46
+ const files = [];
47
+ // --- commerce: the catalog table's migration ------------------------------
48
+ // Numbered 0003 so it lands after the template's 0000_content and the auth
49
+ // pair (0001, 0002) that `create-astroid` writes. Without it `--commerce`
50
+ // scaffolded a `products` table into src/schema.ts that no migration ever
51
+ // created, and the first sync wrote nothing while reporting success.
52
+ const catalogSql = generateCatalogMigrationSql(config);
53
+ if (catalogSql)
54
+ files.push({ path: "migrations/0003_catalog.sql", contents: catalogSql });
55
+ // --- the CWV beacon -------------------------------------------------------
56
+ // A static file under public/, so it is same-origin and covered by
57
+ // `script-src 'self'` — an inline script carrying generated content could not
58
+ // be hashed into the CSP and would be blocked.
59
+ const beacon = generateAstroidVitalsBeacon(config, cwvBeaconScript());
60
+ files.push({ path: beacon.path, contents: beacon.contents });
61
+ // --- the typed Astro Actions surface --------------------------------------
62
+ // Always: every project has editable pages, and the routes alone leave the
63
+ // Astro-native half of ADR 0001 unbuilt. Scaffold-once because it is meant to
64
+ // be added to.
65
+ files.push({ path: "src/actions/index.ts", contents: generateAstroidActions(config) });
66
+ // --- commerce: the server-authoritative payment seam ----------------------
67
+ // Scaffold-once: a real store adds shipping, tax, an order row, a receipt.
68
+ // What's fixed is the sequence that keeps a charge correct.
69
+ const checkoutRoute = generateAstroidCheckoutRoute(config);
70
+ if (checkoutRoute)
71
+ files.push({ path: "src/pages/api/checkout.ts", contents: checkoutRoute });
72
+ const squareCard = generateAstroidSquareCard(config);
73
+ if (squareCard)
74
+ files.push({ path: "src/components/SquareCard.astro", contents: squareCard });
75
+ // --- queues: the consumer seam + one receiver per commerce provider --------
76
+ // Both exist to be edited (what a refresh means; which events matter), which
77
+ // is why `astroid generate` must never rewrite them.
78
+ if (astroidUsesQueues(config)) {
79
+ files.push({ path: "src/queue.ts", contents: generateAstroidQueueSeam(config) });
80
+ // One receiver per provider — a site can run two (invoicing + storefront).
81
+ for (const route of generateAstroidWebhookRoutes(config)) {
82
+ files.push({ path: route.path, contents: route.contents });
83
+ }
84
+ }
85
+ // --- portfolio: the gallery page -----------------------------------------
86
+ // "Which assets appear, in what order" is the first thing a portfolio changes.
87
+ const gallery = generateAstroidGalleryPage(config);
88
+ if (gallery)
89
+ files.push({ path: "src/pages/work.astro", contents: gallery });
90
+ // --- pwa: the service worker, manifest, and its headers -------------------
91
+ // Static files under public/, not generated source — a service worker is not
92
+ // bundled, and `_headers` is shared with whatever else writes to it.
93
+ const sw = generateServiceWorker(config);
94
+ if (sw) {
95
+ files.push({ path: "public/sw.js", contents: sw });
96
+ const manifest = generateWebManifest(config);
97
+ if (manifest)
98
+ files.push({ path: "public/manifest.webmanifest", contents: manifest });
99
+ const headers = generatePwaHeaders(config);
100
+ if (headers) {
101
+ files.push({
102
+ path: "public/_headers",
103
+ contents: headers,
104
+ apply: "append-once",
105
+ // The service-worker path is the one token this stanza always contains
106
+ // and nothing else in a `_headers` file would.
107
+ marker: "/sw.js",
108
+ });
109
+ }
110
+ }
111
+ // --- map: the tile route + embed component --------------------------------
112
+ // Generated into the project rather than shipped in astroidjs so maplibre-gl
113
+ // stays a dependency of the projects that actually draw a map.
114
+ const tileRoute = generateMapTileRoute(config);
115
+ if (tileRoute)
116
+ files.push({ path: "src/pages/map/basemap.pmtiles.ts", contents: tileRoute });
117
+ const mapEmbed = generateMapEmbedComponent(config);
118
+ if (mapEmbed)
119
+ files.push({ path: "src/components/MapEmbed.astro", contents: mapEmbed });
120
+ // --- realtime: the per-page edit-session Durable Object -------------------
121
+ // Scaffold-once because it must import `cloudflare:workers` (runtime-only) and
122
+ // because `persist` is the seam a project tunes.
123
+ const editSession = generateAstroidEditSession(config);
124
+ if (editSession)
125
+ files.push({ path: "src/edit-session.ts", contents: editSession });
126
+ // --- portal: the second auth instance + its mounted catch-all -------------
127
+ // A site edits the reset email and the role a new account gets, but not the
128
+ // mount/cookie/table prefixes that keep the two instances isolated.
129
+ const portalAuth = generateAstroidPortalAuth(config);
130
+ if (portalAuth) {
131
+ files.push({ path: "src/portal-auth.ts", contents: portalAuth });
132
+ const route = generateAstroidPortalAuthRoute(config);
133
+ // Always non-null alongside portalAuth (same `astroidPortal` gate), but the
134
+ // types don't know that and a silent drop here is a portal that cannot
135
+ // authenticate — so assert it rather than `?.`-ing it away.
136
+ if (route)
137
+ files.push({ path: "src/pages/api/portal-auth/[...all].ts", contents: route });
138
+ }
139
+ return files;
140
+ }
@@ -0,0 +1,49 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /** Tuning for the PWA scaffold. Every field has a sensible derivation. */
3
+ export interface PwaConfig {
4
+ /**
5
+ * URL prefix the app is installed at. Default `"/"`.
6
+ *
7
+ * A narrower scope (`"/order"`) is usually right: it keeps the worker off the
8
+ * marketing pages entirely, which is both safer and a smaller cache.
9
+ */
10
+ scope?: string;
11
+ /** Home screen name. Defaults to the brand name. */
12
+ shortName?: string;
13
+ description?: string;
14
+ /** `standalone` (default) hides browser chrome; `browser` opts out of it. */
15
+ display?: "standalone" | "minimal-ui" | "fullscreen" | "browser";
16
+ orientation?: "any" | "portrait" | "landscape";
17
+ /** Splash background. Defaults to white. */
18
+ backgroundColor?: string;
19
+ /** Theme colour. Defaults to the brand colour. */
20
+ themeColor?: string;
21
+ /** Extra paths to precache alongside the scope root. */
22
+ shell?: string[];
23
+ }
24
+ /** True when this project switched the PWA on. */
25
+ export declare const usesPwa: (config: AstroidConfig) => boolean;
26
+ /** Resolved PWA settings — config over derivation over default. */
27
+ export declare function resolvePwa(config: AstroidConfig): Required<Omit<PwaConfig, "shell">> & {
28
+ shell: string[];
29
+ };
30
+ /**
31
+ * `public/manifest.webmanifest`.
32
+ *
33
+ * Icons are declared but NOT generated — a brand's icon is not something a
34
+ * scaffold can invent, and emitting placeholders would produce an installable
35
+ * app with a grey square for a face. The generated README step says to add them.
36
+ */
37
+ export declare function generateWebManifest(config: AstroidConfig): string | null;
38
+ /** `public/sw.js`. Plain JS — a service worker is not bundled. */
39
+ export declare function generateServiceWorker(config: AstroidConfig): string | null;
40
+ /**
41
+ * The `public/_headers` block the PWA needs.
42
+ *
43
+ * `Service-Worker-Allowed` is emitted ONLY when the scope is broader than the
44
+ * script's own location — which, with `sw.js` at the root, never is. Emitting it
45
+ * unconditionally (as the reference does) is harmless but misleading: it implies
46
+ * a requirement that isn't there, and someone later moving the script will trust
47
+ * a header that no longer says what they need.
48
+ */
49
+ export declare function generatePwaHeaders(config: AstroidConfig): string | null;