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
@@ -6,13 +6,25 @@
6
6
  // (routes.ts), so the "versionsRoute/searchRoute before pagesRoute" collision is
7
7
  // impossible by construction. Pure string generation, like generateAstroidSchema.
8
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.
9
+ // One seam is marked with TODO(astroid) and filled by the auth slice: the
10
+ // `resolveEditor` session resolver. The section-catalog validate + sanitize on
11
+ // the pages routes is wired here — versionsRoute runs it through the collection's
12
+ // beforeChange hook, and pagesRoute (which takes no collection config) through
13
+ // the `astroidPagesWriteHooks` spread, so both write paths enforce one contract.
14
+ import { ASTROID_VITALS_BINDING, generateAstroidCwvQuery, } from "../analytics/index.js";
15
+ import { astroidPortal } from "../portal/config.js";
16
+ import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "../queues/messages.js";
17
+ import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, usesRealtime, } from "../realtime/scaffold.js";
11
18
  import { capturesInquiries } from "../schema/framework.js";
12
19
  import { astroidEditorRoutePlan } from "./routes.js";
13
20
  // Astroid's default editable site_settings surface — the columns the Settings
14
21
  // panel may write, and which of them hold a media-library image URL.
15
- const DEFAULT_SETTINGS_COLUMNS = [
22
+ //
23
+ // EXPORTED because the generated worker is not the only consumer: the scaffolded
24
+ // Astro Actions surface needs the identical allowlist, and a second literal in an
25
+ // editable file is a list that drifts from the one the routes enforce. Both read
26
+ // this.
27
+ export const ASTROID_SETTINGS_COLUMNS = [
16
28
  "siteName",
17
29
  "tagline",
18
30
  "logoUrl",
@@ -29,7 +41,7 @@ const DEFAULT_SETTINGS_COLUMNS = [
29
41
  "defaultOgImageUrl",
30
42
  "disableIndexing",
31
43
  ];
32
- const DEFAULT_SETTINGS_IMAGE_KEYS = ["logoUrl", "faviconUrl", "defaultOgImageUrl"];
44
+ export const ASTROID_SETTINGS_IMAGE_KEYS = ["logoUrl", "faviconUrl", "defaultOgImageUrl"];
33
45
  /**
34
46
  * Generate the Worker entrypoint (`worker.ts`) from an Astroid config: the editor
35
47
  * routes in collision-free order, an R2 media-asset route, and the `composeWorker`
@@ -38,12 +50,20 @@ const DEFAULT_SETTINGS_IMAGE_KEYS = ["logoUrl", "faviconUrl", "defaultOgImageUrl
38
50
  */
39
51
  export function generateAstroidWorker(config) {
40
52
  const inquiries = capturesInquiries(config);
53
+ const queues = astroidUsesQueues(config);
54
+ const cron = astroidCron(config);
41
55
  const mediaBase = config.deploy?.mediaBase ?? "/media";
42
56
  const seedName = config.theme.name;
43
57
  const plan = astroidEditorRoutePlan(config);
58
+ // `realtimeRoute` lives in `louise-toolkit/realtime`, not `/editor` — it is the
59
+ // one factory in the plan that isn't an editor route. Importing it with the
60
+ // rest type-checks fine HERE (the plan is just strings) and fails only in the
61
+ // scaffold, which is exactly how it got caught.
62
+ // Same trap as realtimeRoute: these live outside `louise-toolkit/editor`.
63
+ const realtimeRouteFactories = new Set(["realtimeRoute", "vitalsRoute"]);
44
64
  const editorImports = [
45
65
  "DEFAULT_PAGE_FIELDS",
46
- ...new Set(plan.map((route) => route.factory)),
66
+ ...new Set(plan.map((route) => route.factory).filter((f) => !realtimeRouteFactories.has(f))),
47
67
  ].sort();
48
68
  const tables = [
49
69
  "media",
@@ -54,24 +74,58 @@ export function generateAstroidWorker(config) {
54
74
  ].sort();
55
75
  const routeCall = (name) => {
56
76
  switch (name) {
77
+ case "overview":
78
+ // `inbox` only when this project captures inquiries — an absent slice
79
+ // hides its card, which is right for an archetype with no contact form.
80
+ return inquiries
81
+ ? "overviewRoute({ resolveEditor, content: overviewContent, inbox: overviewInbox, health: overviewHealth })"
82
+ : "overviewRoute({ resolveEditor, content: overviewContent, health: overviewHealth })";
83
+ case "vitals":
84
+ return `vitalsRoute({ dataset: (env) => env.${ASTROID_VITALS_BINDING} })`;
85
+ case "health":
86
+ return "healthRoute({ resolveEditor, read: readSiteHealth })";
87
+ case "realtime":
88
+ return `realtimeRoute({ resolveEditor, namespace: (env) => env.${ASTROID_REALTIME_BINDING} })`;
57
89
  case "versions":
58
- return "versionsRoute({ table: pages, versionsTable: pagesVersions, config: pagesCollection, resolveEditor })";
90
+ return "versionsRoute({ table: pages, versionsTable: pagesVersions, config: pagesCollection, resolveEditor, bufferKv: (env) => env.DRAFTS })";
59
91
  case "search":
60
92
  return "searchRoute({ table: pages, config: pagesCollection, resolveEditor })";
61
93
  case "pages":
62
- return 'pagesRoute({ table: pages, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"] })';
94
+ // `...pagesWriteHooks` is load-bearing: pagesRoute writes straight to the
95
+ // table and runs NO collection hook, so without these the direct
96
+ // POST/PATCH path would persist an unknown section `_type`, a setting
97
+ // outside its options, or unsanitized section rich text. See
98
+ // `astroidPagesWriteHooks`.
99
+ return 'pagesRoute({ table: pages, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"], ...pagesWriteHooks })';
63
100
  case "save":
101
+ // No `bufferKv` here, deliberately: `saveRoute` has no such option. It
102
+ // writes live field saves (title, SEO) straight through, and the draft
103
+ // buffer belongs to the versioned body — i.e. to versionsRoute.
64
104
  return 'saveRoute({ resolveEditor, collections: { pages: { table: pages, fields: ["title", "seoTitle", "seoDescription"] } } })';
65
105
  case "settings":
66
106
  return "settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: MEDIA_BASE })";
107
+ case "ai":
108
+ return "aiRoute({ resolveEditor, ai: (env) => env.AI })";
109
+ case "seoFix":
110
+ return "seoFixRoute({ table: pages, resolveEditor, ai: (env) => env.AI })";
67
111
  case "media":
68
- return "mediaRoute({ table: media, resolveEditor })";
112
+ // `altText` fills a new upload's alt from the image itself. Best-effort
113
+ // by contract — a model error or a missing binding never fails the
114
+ // upload — so it costs nothing on a project that doesn't want it.
115
+ return "mediaRoute({ table: media, resolveEditor, referenceSources: MEDIA_REFERENCE_SOURCES, altText: (env) => env.AI })";
69
116
  case "editors":
70
117
  // Better Auth owns the `user` table (a NAME, not a Drizzle table), so this
71
118
  // route takes the default `"user"`; a `tablePrefix` would rename it.
72
119
  return 'editorsRoute({ table: "user", resolveEditor })';
73
120
  case "form":
74
- return "formRoute({ form: contactForm, rateLimitKv: (env) => env.RL })";
121
+ // `onSubmit` fires AFTER the insert and off the response path, so the
122
+ // notify + confirm pair is store-and-forward by construction: the
123
+ // submission is already durable and mail can fail without the visitor
124
+ // ever knowing. Unprovisioned mail logs instead of sending.
125
+ // The `await` + block body is load-bearing: `onSubmit` returns
126
+ // `void | Promise<void>`, and sendInquiryMail resolves to delivery
127
+ // results nobody here reads.
128
+ return "formRoute({ form: contactForm, rateLimitKv: (env) => env.RL, onSubmit: async (values, env) => { await sendInquiryMail(astroidConfig, env, values); } })";
75
129
  case "inquiries":
76
130
  return "inquiriesRoute({ table: inquiries, resolveEditor })";
77
131
  case "seed":
@@ -88,26 +142,146 @@ export function generateAstroidWorker(config) {
88
142
  for (const name of editorImports)
89
143
  p(` ${name},`);
90
144
  p('} from "louise-toolkit/editor";');
145
+ if (usesRealtime(config))
146
+ p('import { realtimeRoute } from "louise-toolkit/realtime";');
147
+ p('import { cwvSqlQuery, parseCwvRows, summarizeCwv, vitalsRoute } from "louise-toolkit/analytics";');
91
148
  if (inquiries)
92
149
  p('import { defineForm } from "louise-toolkit/forms";');
93
- p('import { composeWorker, type WorkerRoute } from "louise-toolkit/worker";');
150
+ if (queues)
151
+ p('import { processBatch } from "louise-toolkit/queues";');
152
+ p('import { checkLinks } from "louise-toolkit/browser";');
153
+ p('import { readHealthSummary, summarizeHealth, writeHealthSummary } from "louise-toolkit/health";');
154
+ p('import { composeWorker, isEditRequest, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
94
155
  if (inquiries)
95
156
  p('import { inquiriesForm } from "louise-toolkit/db";');
96
157
  p(`import { ${tables.join(", ")} } from "./schema.js";`);
97
- p('import { astroidPagesCollection } from "astroidjs";');
98
- p('import astroidConfig from "./astroid.config.js";');
158
+ const astroidImports = [
159
+ "astroidPagesCollection",
160
+ "astroidPagesWriteHooks",
161
+ "readModuleSecret",
162
+ ...(inquiries ? ["sendInquiryMail"] : []),
163
+ ...(queues ? ["type AstroidQueueMessage"] : []),
164
+ ].sort();
165
+ p(`import { ${astroidImports.join(", ")} } from "astroidjs";`);
166
+ // The config lives at the PROJECT ROOT (create-astroid writes it there); this
167
+ // file is src/worker.ts, so the specifier is `../`, not `./`.
168
+ p('import astroidConfig from "../astroid.config.js";');
99
169
  p("// TODO(astroid): your AUTH seam. resolveEditor resolves the editor session");
100
170
  p("// from a request; a truthy result authorizes editor writes. A generated auth");
101
171
  p("// module is a later slice.");
102
172
  p('import { resolveEditor } from "./auth.js";');
173
+ if (queues) {
174
+ p("// Your QUEUE seam: what each message actually does. Scaffolded once and");
175
+ p("// yours to edit — `astroidQueueHandler` there covers the catalog dispatch.");
176
+ p('import { handleQueueMessage } from "./queue.js";');
177
+ }
103
178
  p();
104
179
  p(`const MEDIA_BASE = ${JSON.stringify(mediaBase)};`);
105
180
  p("const pagesCollection = astroidPagesCollection(astroidConfig);");
181
+ p("// Sanitize + section-catalog validation for the raw pagesRoute, which runs");
182
+ p("// no collection hook — the same contract versionsRoute gets from the config.");
183
+ p("const pagesWriteHooks = astroidPagesWriteHooks(astroidConfig);");
106
184
  p();
107
185
  p("// Editable site_settings columns the Settings panel may write, and which of");
108
186
  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)};`);
187
+ p(`const SETTINGS_COLUMNS = ${JSON.stringify(ASTROID_SETTINGS_COLUMNS)};`);
188
+ p(`const SETTINGS_IMAGE_KEYS = ${JSON.stringify(ASTROID_SETTINGS_IMAGE_KEYS)};`);
189
+ p();
190
+ p("// Delete-safety for the media library: where a media key can be REFERENCED,");
191
+ p("// so deleting an asset that's live on a page warns instead of silently");
192
+ p("// breaking it. Without these the scan has nothing to look at and every");
193
+ p("// delete reports 'no references'. Column names are SQL, not Drizzle keys —");
194
+ p("// the scan is raw SQL over the table.");
195
+ p("const MEDIA_REFERENCE_SOURCES = [");
196
+ p(' { collection: "pages", table: "pages", columns: ["body", "sections", "og_image"], labelColumn: "title" },');
197
+ p(' { collection: "settings", table: "site_settings", columns: ["logo_url", "favicon_url", "default_og_image_url"], labelColumn: "site_name" },');
198
+ p("];");
199
+ p();
200
+ p();
201
+ p("// --- site health ----------------------------------------------------------");
202
+ p("// Stored in the RL namespace under its own key rather than a new binding:");
203
+ p("// it's one small singleton blob, and a binding you must provision before the");
204
+ p("// dashboard works is a binding people don't provision.");
205
+ p("const readSiteHealth = (env: CloudflareEnv) => readHealthSummary(env.RL);");
206
+ p();
207
+ p("// The same read, adapted for the overview slice. `readHealthSummary` yields");
208
+ p("// `null` for 'no scan yet' while a slice resolver signals absence with");
209
+ p("// `undefined` — the two types are otherwise identical, and this one-line");
210
+ p("// coercion is the whole difference.");
211
+ p("const overviewHealth = async (env: CloudflareEnv) =>");
212
+ p(" (await readSiteHealth(env)) ?? undefined;");
213
+ p();
214
+ for (const line of generateAstroidCwvQuery(config))
215
+ p(line);
216
+ p();
217
+ p("// The daily scan. Crawls the site's own pages for broken links and counts the");
218
+ p("// two accessibility/SEO gaps that are cheap to compute, then persists one");
219
+ p("// snapshot for the dashboard to read. Every part degrades on its own — a");
220
+ p("// failed crawl or a failed COUNT yields zero rather than aborting the scan,");
221
+ p("// because a partial health report is worth strictly more than none.");
222
+ p("async function runHealthScan(env: CloudflareEnv) {");
223
+ p(" const origin = env.SITE_URL ?? MEDIA_BASE;");
224
+ p(" const [brokenLinks, missingAlt, seoGaps] = await Promise.all([");
225
+ p(" checkLinks({ base: origin, paths: [\"/\"] }).catch(() => []),");
226
+ p(" countRows(env, \"SELECT COUNT(*) AS n FROM media WHERE alt IS NULL OR alt = ''\"),");
227
+ p(" countRows(");
228
+ p(" env,");
229
+ p(' "SELECT COUNT(*) AS n FROM pages WHERE status = \'published\'" +');
230
+ p(" \" AND (seo_title IS NULL OR seo_title = '' OR seo_description IS NULL OR seo_description = '')\",");
231
+ p(" ),");
232
+ p(" ]);");
233
+ p(" const summary = summarizeHealth({ brokenLinks, missingAlt, seoGaps });");
234
+ p(" // Field data, when the SQL API credentials are real. Absent leaves the");
235
+ p(" // Health badge at 'not measured yet' rather than failing the scan.");
236
+ p(" const cwv = await queryCwv(env);");
237
+ p(" if (cwv) summary.cwv = cwv;");
238
+ p(" await writeHealthSummary(env.RL, summary);");
239
+ p(" return summary;");
240
+ p("}");
241
+ p();
242
+ p("/** One COUNT, degrading to 0 — a missing table must not abort the scan. */");
243
+ p("async function countRows(env: CloudflareEnv, sql: string): Promise<number> {");
244
+ p(" try {");
245
+ p(" const row = await env.DB.prepare(sql).first<{ n: number }>();");
246
+ p(" return Number(row?.n ?? 0);");
247
+ p(" } catch {");
248
+ p(" return 0;");
249
+ p(" }");
250
+ p("}");
251
+ p();
252
+ if (inquiries) {
253
+ p();
254
+ p("// Unhandled inquiries. The COUNT is the whole table on purpose: the");
255
+ p("// Inquiries tab reviews and CLEARS submissions (GET lists, DELETE removes),");
256
+ p("// so a row that still exists is a message still waiting on you. There is no");
257
+ p("// read/unread column because deletion IS the acknowledgement — which also");
258
+ p("// means this number goes down as you work through them, rather than being a");
259
+ p("// total that only ever climbs.");
260
+ p("const overviewInbox = async (env: CloudflareEnv) => {");
261
+ p(' const n = await countRows(env, "SELECT COUNT(*) AS n FROM inquiries");');
262
+ p(" return { unread: n };");
263
+ p("};");
264
+ }
265
+ p("// The Home dashboard's content counts. Raw SQL because these are COUNTs over");
266
+ p("// THIS project's tables — the toolkit deliberately makes no assumption about");
267
+ p("// column names. A throw here degrades to a hidden card, never a 500.");
268
+ p("const overviewContent = async (env: CloudflareEnv) => {");
269
+ p(" const row = await env.DB.prepare(");
270
+ p(' "SELECT" +');
271
+ p(" \" (SELECT COUNT(*) FROM pages WHERE status = 'draft') AS drafts,\" +");
272
+ p(' " (SELECT COUNT(DISTINCT parent_id) FROM pages_versions WHERE status = \'draft\') AS unpublished," +');
273
+ p(' " (SELECT MAX(updated_at) FROM pages) AS last_edited",');
274
+ p(" ).first<{ drafts: number; unpublished: number; last_edited: number | null }>();");
275
+ p(" if (!row) return undefined;");
276
+ p(" return {");
277
+ p(" drafts: Number(row.drafts ?? 0),");
278
+ p(" unpublished: Number(row.unpublished ?? 0),");
279
+ p(" // Stored as a unix timestamp; the card wants ISO.");
280
+ p(" ...(row.last_edited");
281
+ p(" ? { lastEditedAt: new Date(Number(row.last_edited) * 1000).toISOString() }");
282
+ p(" : {}),");
283
+ p(" };");
284
+ p("};");
111
285
  if (inquiries) {
112
286
  p();
113
287
  p("// Public contact form: the built-in inquiries fields + silent spam");
@@ -115,8 +289,12 @@ export function generateAstroidWorker(config) {
115
289
  p('const contactForm = defineForm({ name: "inquiries", fields: inquiriesForm.fields, spam: { honeypot: "website", minSeconds: 2, rateLimit: { max: 5, windowSec: 60 } } });');
116
290
  }
117
291
  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).");
292
+ p("// `sections` writes are validated + sanitized against the section catalog");
293
+ p("// before they persist, on BOTH write paths: versionsRoute runs the pages");
294
+ p("// collection's beforeChange hook (via `config`), and pagesRoute — which takes");
295
+ p("// no collection config — gets the same contract from the `pagesWriteHooks`");
296
+ p("// spread. An unknown `_type`, a field of the wrong shape, or a setting outside");
297
+ p("// its declared options is a 422, not a hole in the page.");
120
298
  p("const editorRoutes: WorkerRoute<CloudflareEnv>[] = [");
121
299
  for (const route of plan) {
122
300
  p(` // ${route.note}`);
@@ -140,33 +318,105 @@ export function generateAstroidWorker(config) {
140
318
  p(" return new Response(obj.body, { headers });");
141
319
  p("};");
142
320
  p();
143
- p("export default composeWorker<CloudflareEnv>({");
321
+ // The queue message type parameter is what gives the `queue` consumer below a
322
+ // typed `MessageBatch` instead of `MessageBatch<unknown>`.
323
+ p(queues
324
+ ? "export default composeWorker<CloudflareEnv, AstroidQueueMessage>({"
325
+ : "export default composeWorker<CloudflareEnv>({");
144
326
  p(" routes: [...editorRoutes, mediaAssetRoute],");
145
- p(" fetch: (request, env, ctx) => handle(request, env, ctx),");
327
+ p(" // The SSR fallback, wrapped in the cookie-aware Worker cache (ADR 0004).");
328
+ p(" //");
329
+ p(" // Wrapped UNCONDITIONALLY, and that is safe: `withEdgeCache` only stores a");
330
+ p(" // response that carries a cacheable Cloudflare-CDN-Cache-Control directive,");
331
+ p(" // and a page emits one only via `Astro.cache.set(...)` — which the scaffold");
332
+ p(" // gates on ASTROID_EDGE_CACHE being \"true\" AND the request not being in edit");
333
+ p(" // mode. With the var off (the default) every render is `no-store`, so this");
334
+ p(" // layer stores nothing and is a transparent pass-through.");
335
+ p(" //");
336
+ p(" // It must be THIS cache and not Cloudflare's automatic edge cache: that one");
337
+ p(" // is keyed by URL, runs BEFORE the Worker, and is therefore cookie-blind —");
338
+ p(" // it will happily serve an editor a cached public page. That exact bug got");
339
+ p(" // this feature reverted twice (#163, #165). `withEdgeCache` strips the CDN");
340
+ p(" // directive from every response so the automatic cache never engages.");
341
+ p(" //");
342
+ p(" // Read the activation runbook in docs/adr/0004-edge-caching.md before");
343
+ p(" // flipping the var on: `caches.default` is NOT cleared by Cloudflare Dev");
344
+ p(" // Mode or Purge Everything, so a mistake in prod is hard to walk back.");
345
+ p(" fetch: withEdgeCache((request, env, ctx) => handle(request, env, ctx), {");
346
+ p(" // An editor never reads from, and never writes to, the shared entry.");
347
+ p(" bypass: isEditRequest,");
348
+ p(" }),");
349
+ if (queues) {
350
+ p(" // Queue consumer. `processBatch` acks or retries each message");
351
+ p(" // INDEPENDENTLY, so one poisoned message can't block the rest of the");
352
+ p(" // batch from acking; Cloudflare routes it to the DLQ once it exceeds");
353
+ p(" // max_retries (see wrangler.jsonc).");
354
+ p(" queue: (batch, env) => processBatch(batch, (message) => handleQueueMessage(env, message)),");
355
+ }
356
+ // ONE scheduled handler for every cron, dispatching on `controller.cron`.
357
+ // Cloudflare gives no other way to tell them apart, and the strings here have
358
+ // to match `astroidCrons` exactly — which is why both read the same constants
359
+ // rather than repeating a literal.
360
+ p(" // Cron. Cloudflare fires this for EVERY trigger in wrangler.jsonc and");
361
+ p(" // identifies which by `controller.cron`, so dispatch on it.");
362
+ p(" scheduled: (controller, env, ctx) => {");
363
+ p(` if (controller.cron === ${JSON.stringify(ASTROID_HEALTH_CRON)}) {`);
364
+ p(" // Daily site-health scan. `waitUntil` because the crawl outlives the");
365
+ p(" // handler's return, and a scan that throws must not retry the cron.");
366
+ p(" ctx.waitUntil(runHealthScan(env).catch(() => {}));");
367
+ p(" return;");
368
+ p(" }");
369
+ if (cron) {
370
+ p(` if (controller.cron === ${JSON.stringify(cron)}) {`);
371
+ p(" // Catalog safety net. Webhooks get missed — a provider outage, a");
372
+ p(" // deploy mid-delivery, a DLQ'd message — and without this the site");
373
+ p(" // serves stale data until a human notices. Enqueued rather than run");
374
+ p(" // inline so it takes the same retry + DLQ path as everything else.");
375
+ p(' ctx.waitUntil(env.COMMERCE_QUEUE.send({ kind: "catalog_refresh" }));');
376
+ p(" }");
377
+ }
378
+ p(" },");
146
379
  p("});");
147
380
  p();
381
+ if (usesRealtime(config)) {
382
+ // Re-exported from the ENTRY because wrangler resolves a Durable Object
383
+ // binding's `class_name` against the worker's exports — the class living in
384
+ // src/edit-session.ts is not enough on its own, and the failure is a deploy
385
+ // error about an unresolvable class rather than anything pointing here.
386
+ p("// The realtime edit-session Durable Object. Re-exported so wrangler can");
387
+ p("// resolve the `class_name` in the durable_objects binding.");
388
+ p(`export { ${ASTROID_EDIT_SESSION_CLASS} } from "./edit-session.js";`);
389
+ p();
390
+ }
148
391
  return lines.join("\n");
149
392
  }
150
393
  /**
151
394
  * Generate the Astro middleware (`middleware.ts`) from an Astroid config: the
152
395
  * shared Louise flow (rate-limit the unauthenticated POST surface → resolve editor
153
396
  * 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.
397
+ * `createLouiseMiddleware`.
398
+ *
399
+ * The rate rules are NOT emitted as literals here — the file calls
400
+ * `astroidRateRules(astroidConfig)`, so the set stays real data in the package
401
+ * (testable, and a `match` predicate survives, which a serialized literal could
402
+ * not). Enabling a portal or commerce in the config adds that surface's rules
403
+ * with no regeneration of this file at all.
156
404
  *
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.
405
+ * CSP: `astro.config.mjs` enables `security.csp` (via `astroidSecurity`), so
406
+ * Astro emits a hash-based `content-security-policy` response header on every SSR
407
+ * page and owns `script-src`. The `cspStyleSrc` below tells
408
+ * `createLouiseMiddleware` to rewrite that header's `style-src` to
409
+ * `'self' 'unsafe-inline'` — a hash-based `style-src` would, per spec, void the
410
+ * `'unsafe-inline'` that Louise's data-driven `style=""` carriers and the
411
+ * editor's runtime-injected `<style>` require. Script hashes are left verbatim,
412
+ * and the inlined `data:` brand font is auto-allowed.
164
413
  */
165
- export function generateAstroidMiddleware(_config) {
414
+ export function generateAstroidMiddleware(config) {
166
415
  // Louise's brand font is bundled + base64-inlined (no Google Fonts host to
167
416
  // allow); createLouiseMiddleware auto-allows `data:` fonts in the CSP, so the
168
417
  // inlined @font-face needs no manual `font-src` entry.
169
418
  const cspStyleSrc = "'self' 'unsafe-inline'";
419
+ const portal = astroidPortal(config);
170
420
  return [
171
421
  "// Generated by astroidjs — do not hand-edit.",
172
422
  "// The shared Louise middleware: rate-limit the unauthenticated POST surfaces,",
@@ -176,23 +426,61 @@ export function generateAstroidMiddleware(_config) {
176
426
  "// styles + inlined data: brand font are allowed.",
177
427
  'import { env } from "cloudflare:workers";',
178
428
  'import { createLouiseMiddleware } from "louise-toolkit/astro";',
429
+ portal
430
+ ? 'import { astroidPortalGuardConfig, astroidRateRules, guardResponse, portalGuard, resolvePortalSession } from "astroidjs";'
431
+ : 'import { astroidRateRules } from "astroidjs";',
432
+ 'import astroidConfig from "../astroid.config.js";',
179
433
  "// TODO(astroid): your AUTH seam — same resolveEditor as the generated worker.ts.",
180
434
  'import { resolveEditor } from "./auth.js";',
435
+ // The portal's resolver lives in its OWN module, not the editor's auth
436
+ // seam — they're separate Better Auth instances and must not share a file.
437
+ ...(portal ? ['import { resolvePortalUser } from "./portal-auth.js";'] : []),
181
438
  "",
182
439
  "// 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
- "];",
440
+ "// (fixed-window KV counter that fails open). Derived from your config: the",
441
+ "// editor magic-link always, plus the portal credential surfaces and checkout",
442
+ "// when those are enabled. Add your own via `security.rateRules` in the config —",
443
+ "// they're matched first, so they can also override a default's budget.",
444
+ "// `env.RL` is read per request (a getter) — a KV binding is only valid in",
445
+ "// request scope.",
446
+ "const RATE_RULES = astroidRateRules(astroidConfig);",
447
+ ...(portal
448
+ ? [
449
+ "const PORTAL_GUARD = astroidPortalGuardConfig(astroidConfig)!;",
450
+ "",
451
+ "// The PORTAL session — a second, cookie- and table-isolated Better Auth",
452
+ "// instance beside the editor's. `resolvePortalSession` shares the in-flight",
453
+ "// lookup per request, so the guard here and the handler that runs next",
454
+ "// don't each pay a session round-trip.",
455
+ ]
456
+ : []),
190
457
  "",
191
458
  "export const onRequest = createLouiseMiddleware({",
192
459
  " resolveEditor: (request) => resolveEditor(request),",
193
460
  " 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.",
461
+ ...(portal
462
+ ? [
463
+ " extend: async (context) => {",
464
+ " const user = await resolvePortalSession(context.request, resolvePortalUser);",
465
+ " context.locals.portalUser = user;",
466
+ " },",
467
+ " // Route guard: the declarative prefix→roles table from your config.",
468
+ " // An /api/* route always answers in JSON — redirecting fetch() to an",
469
+ " // HTML login page returns 200 and markup, which reads as success.",
470
+ " guard: (context) => {",
471
+ " const decision = portalGuard(",
472
+ " context.url.pathname,",
473
+ " context.locals.portalUser,",
474
+ " PORTAL_GUARD,",
475
+ " );",
476
+ " if (!decision) return undefined;",
477
+ ' if (decision.kind === "redirect") return context.redirect(decision.location);',
478
+ " return guardResponse(decision) ?? undefined;",
479
+ " },",
480
+ ]
481
+ : []),
482
+ " // Rewrite Astro's hash-based style-src (owned by astroidSecurity in",
483
+ ' // astro.config.mjs) to permit Louise\'s data-driven style="" + editor styles.',
196
484
  ` cspStyleSrc: ${JSON.stringify(cspStyleSrc)},`,
197
485
  "});",
198
486
  "",
@@ -1,5 +1,5 @@
1
1
  import type { AstroidConfig } from "../config.js";
2
- export type AstroidEditorRouteName = "versions" | "search" | "pages" | "save" | "settings" | "media" | "editors" | "form" | "inquiries" | "seed";
2
+ export type AstroidEditorRouteName = "ai" | "health" | "realtime" | "vitals" | "overview" | "seoFix" | "versions" | "search" | "pages" | "save" | "settings" | "media" | "editors" | "form" | "inquiries" | "seed";
3
3
  export interface AstroidEditorRoute {
4
4
  /** Stable key for this route. */
5
5
  name: AstroidEditorRouteName;
@@ -5,6 +5,7 @@
5
5
  // and searchRoute MUST precede pagesRoute" tribal knowledge lives: encoded once,
6
6
  // as data, instead of re-derived by hand (and mis-ordered) in every site's
7
7
  // worker.ts. The generator turns this plan into source; tests assert the order.
8
+ import { usesRealtime } from "../realtime/scaffold.js";
8
9
  import { capturesInquiries } from "../schema/framework.js";
9
10
  /**
10
11
  * The ordered editor route plan for a project. Order is load-bearing: the two
@@ -14,6 +15,11 @@ import { capturesInquiries } from "../schema/framework.js";
14
15
  */
15
16
  export function astroidEditorRoutePlan(config) {
16
17
  const routes = [
18
+ {
19
+ name: "overview",
20
+ factory: "overviewRoute",
21
+ note: "The Home dashboard's one aggregate read. NOT optional: `mountSettings` defaults `home: true` and Home is the drawer's initial panel, so without this route the FIRST screen an owner sees after opening the editor is an empty panel fetching a 404.",
22
+ },
17
23
  {
18
24
  name: "versions",
19
25
  factory: "versionsRoute",
@@ -24,6 +30,11 @@ export function astroidEditorRoutePlan(config) {
24
30
  factory: "searchRoute",
25
31
  note: "Full-text search over pages (/search + /reindex). Before pagesRoute, whose /:id matcher would else claim those non-integer segments.",
26
32
  },
33
+ {
34
+ name: "seoFix",
35
+ factory: "seoFixRoute",
36
+ note: "One-click SEO backfill for published pages missing a title/description. MUST precede pagesRoute for the same reason versions/search do: it mounts at /api/louise/pages/generate-seo, and pagesRoute claims EVERY path under /api/louise/pages/ as an item id — so mounted after, it would never be reached and the request would 400 on the non-integer id `generate-seo`.",
37
+ },
27
38
  {
28
39
  name: "pages",
29
40
  factory: "pagesRoute",
@@ -50,6 +61,37 @@ export function astroidEditorRoutePlan(config) {
50
61
  note: "Editor roster (the Users panel) over Better Auth's `user` table — a row IS an editor, and the same table is the magic-link allowlist (resolveAdmins).",
51
62
  },
52
63
  ];
64
+ // AI assists that own their own path prefix. `/api/louise/ai/*` collides with
65
+ // nothing, so this one's position is genuinely free.
66
+ //
67
+ // Mounted unconditionally: `louise-toolkit/ai` degrades by design (a missing
68
+ // binding or a model error yields null, never a throw) and the route answers
69
+ // 503 when `ai(env)` is undefined, which the client reads as "hide the
70
+ // button". So mounting it on a project that never uses AI costs nothing, while
71
+ // NOT mounting it left buttons that ship in the editor drawer permanently
72
+ // dead.
73
+ if (usesRealtime(config)) {
74
+ routes.push({
75
+ name: "realtime",
76
+ factory: "realtimeRoute",
77
+ note: "WebSocket upgrade for the per-page live editing session (ADR 0002). Owns /api/louise/realtime/*. Guards the handshake as a same-origin, session-gated mutation, then forwards to the per-page Durable Object with the SERVER-resolved editor identity — presence is never taken from the client.",
78
+ });
79
+ }
80
+ routes.push({
81
+ name: "vitals",
82
+ factory: "vitalsRoute",
83
+ note: "Public CWV ingestion (POST /api/louise/vitals). NOT session-gated — these are anonymous visitor beacons — but same-origin only, and it accepts-and-drops without the dataset binding. Always 204.",
84
+ });
85
+ routes.push({
86
+ name: "health",
87
+ factory: "healthRoute",
88
+ note: "The site-health panel's read of the persisted scan summary. Owns /api/louise/health. Like overviewRoute, its client half already ships in the editor drawer — unmounted, the Health card was dead UI. Returns `{ summary: null }` (a 200) until the daily cron writes the first scan, which the panel renders as 'not checked yet'.",
89
+ });
90
+ routes.push({
91
+ name: "ai",
92
+ factory: "aiRoute",
93
+ note: "Editor AI assists — rewrite/expand/shorten a selection, suggest SEO for a page. Owns /api/louise/ai/*, so it collides with nothing. POST-only and editor-gated, since each call spends AI budget.",
94
+ });
53
95
  if (capturesInquiries(config)) {
54
96
  routes.push({
55
97
  name: "form",