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