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.
- package/README.md +240 -5
- package/bin/astroid.mjs +185 -9
- package/dist/analytics/index.d.ts +37 -0
- package/dist/analytics/index.js +108 -0
- package/dist/astro/csp.d.ts +64 -0
- package/dist/astro/csp.js +173 -0
- package/dist/astro/index.d.ts +1 -0
- package/dist/astro/index.js +7 -0
- package/dist/commerce/adapters.d.ts +60 -0
- package/dist/commerce/adapters.js +90 -0
- package/dist/commerce/checkout-scaffold.d.ts +42 -0
- package/dist/commerce/checkout-scaffold.js +306 -0
- package/dist/commerce/checkout.d.ts +72 -0
- package/dist/commerce/checkout.js +124 -0
- package/dist/commerce/index.d.ts +8 -0
- package/dist/commerce/index.js +9 -0
- package/dist/commerce/loader.d.ts +71 -0
- package/dist/commerce/loader.js +90 -0
- package/dist/commerce/mirror.d.ts +67 -0
- package/dist/commerce/mirror.js +203 -0
- package/dist/commerce/roles.d.ts +38 -0
- package/dist/commerce/roles.js +93 -0
- package/dist/commerce/secrets.d.ts +74 -0
- package/dist/commerce/secrets.js +129 -0
- package/dist/commerce/sync.d.ts +86 -0
- package/dist/commerce/sync.js +154 -0
- package/dist/components/sections.d.ts +577 -0
- package/dist/components/sections.js +425 -0
- package/dist/config.d.ts +174 -12
- package/dist/config.js +43 -1
- package/dist/email/index.d.ts +4 -0
- package/dist/email/index.js +5 -0
- package/dist/email/inquiry.d.ts +33 -0
- package/dist/email/inquiry.js +63 -0
- package/dist/email/send.d.ts +120 -0
- package/dist/email/send.js +196 -0
- package/dist/email/templates.d.ts +24 -0
- package/dist/email/templates.js +184 -0
- package/dist/email/theme.d.ts +24 -0
- package/dist/email/theme.js +150 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/map/index.d.ts +3 -0
- package/dist/map/index.js +4 -0
- package/dist/map/pmtiles.d.ts +92 -0
- package/dist/map/pmtiles.js +130 -0
- package/dist/map/scaffold.d.ts +29 -0
- package/dist/map/scaffold.js +212 -0
- package/dist/map/style.d.ts +58 -0
- package/dist/map/style.js +154 -0
- package/dist/portal/config.d.ts +26 -0
- package/dist/portal/config.js +50 -0
- package/dist/portal/guard.d.ts +48 -0
- package/dist/portal/guard.js +64 -0
- package/dist/portal/index.d.ts +5 -0
- package/dist/portal/index.js +6 -0
- package/dist/portal/nav.d.ts +26 -0
- package/dist/portal/nav.js +35 -0
- package/dist/portal/scaffold.d.ts +28 -0
- package/dist/portal/scaffold.js +140 -0
- package/dist/portal/session.d.ts +36 -0
- package/dist/portal/session.js +86 -0
- package/dist/portfolio/index.d.ts +1 -0
- package/dist/portfolio/index.js +4 -0
- package/dist/portfolio/scaffold.d.ts +9 -0
- package/dist/portfolio/scaffold.js +93 -0
- package/dist/project/actions.d.ts +3 -0
- package/dist/project/actions.js +106 -0
- package/dist/project/generate.d.ts +15 -0
- package/dist/project/generate.js +144 -2
- package/dist/project/index.d.ts +2 -0
- package/dist/project/index.js +2 -0
- package/dist/project/scaffold.d.ts +29 -0
- package/dist/project/scaffold.js +140 -0
- package/dist/pwa/generate.d.ts +49 -0
- package/dist/pwa/generate.js +218 -0
- package/dist/pwa/index.d.ts +1 -0
- package/dist/pwa/index.js +2 -0
- package/dist/queues/consumer.d.ts +29 -0
- package/dist/queues/consumer.js +37 -0
- package/dist/queues/index.d.ts +4 -0
- package/dist/queues/index.js +5 -0
- package/dist/queues/messages.d.ts +60 -0
- package/dist/queues/messages.js +71 -0
- package/dist/queues/scaffold.d.ts +44 -0
- package/dist/queues/scaffold.js +204 -0
- package/dist/queues/webhook.d.ts +60 -0
- package/dist/queues/webhook.js +81 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +4 -0
- package/dist/realtime/scaffold.d.ts +30 -0
- package/dist/realtime/scaffold.js +159 -0
- package/dist/schema/collections.d.ts +42 -8
- package/dist/schema/collections.js +102 -8
- package/dist/schema/generate.js +10 -1
- package/dist/secrets.d.ts +54 -0
- package/dist/secrets.js +80 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/rate-rules.d.ts +21 -0
- package/dist/security/rate-rules.js +107 -0
- package/dist/seo/index.d.ts +3 -0
- package/dist/seo/index.js +4 -0
- package/dist/seo/resolve.d.ts +68 -0
- package/dist/seo/resolve.js +73 -0
- package/dist/seo/routes.d.ts +44 -0
- package/dist/seo/routes.js +104 -0
- package/dist/seo/structured-data.d.ts +51 -0
- package/dist/seo/structured-data.js +105 -0
- package/dist/status.d.ts +51 -0
- package/dist/status.js +113 -0
- package/dist/worker/generate.d.ts +18 -10
- package/dist/worker/generate.js +325 -37
- package/dist/worker/routes.d.ts +1 -1
- package/dist/worker/routes.js +42 -0
- package/dist/workflow/advance.d.ts +102 -0
- package/dist/workflow/advance.js +145 -0
- package/dist/workflow/config.d.ts +60 -0
- package/dist/workflow/config.js +73 -0
- package/dist/workflow/generate.d.ts +22 -0
- package/dist/workflow/generate.js +138 -0
- package/dist/workflow/index.d.ts +3 -0
- package/dist/workflow/index.js +4 -0
- package/package.json +21 -5
- package/src/components/Editable.astro +33 -9
- package/src/components/JustifiedGallery.astro +254 -0
- package/src/components/MediaSlot.astro +178 -0
- package/src/components/PortalShell.astro +80 -0
- package/src/components/RegisterSW.astro +45 -0
- package/src/components/Section.astro +101 -35
- package/src/components/Sections.astro +64 -0
- package/src/components/Seo.astro +57 -0
- package/src/components/StageBar.astro +137 -0
- package/src/components/StructuredData.astro +33 -0
- package/src/components/justify.ts +170 -0
- package/src/components/media-meta.ts +174 -0
- package/src/components/sections/AboutIntro.astro +46 -0
- package/src/components/sections/Banner.astro +31 -0
- package/src/components/sections/Contact.astro +22 -9
- package/src/components/sections/Cta.astro +33 -10
- package/src/components/sections/Faq.astro +50 -0
- package/src/components/sections/FeatureGrid.astro +40 -11
- package/src/components/sections/Gallery.astro +46 -0
- package/src/components/sections/Hero.astro +40 -12
- package/src/components/sections/LocationHours.astro +59 -0
- package/src/components/sections/Media.astro +44 -0
- package/src/components/sections/PricingTiers.astro +79 -0
- package/src/components/sections/ProductGrid.astro +73 -0
- package/src/components/sections/SplitImage.astro +61 -0
- package/src/components/sections/Steps.astro +58 -0
- package/src/components/sections/Testimonial.astro +51 -0
- package/src/components/sections.ts +452 -67
package/dist/worker/generate.js
CHANGED
|
@@ -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
|
-
//
|
|
10
|
-
// `resolveEditor
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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(
|
|
110
|
-
p(`const SETTINGS_IMAGE_KEYS = ${JSON.stringify(
|
|
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("//
|
|
119
|
-
p("//
|
|
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
|
-
|
|
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("
|
|
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`.
|
|
155
|
-
*
|
|
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
|
|
158
|
-
* `content-security-policy` response header on every SSR
|
|
159
|
-
*
|
|
160
|
-
* `
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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(
|
|
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).
|
|
184
|
-
"//
|
|
185
|
-
"//
|
|
186
|
-
"//
|
|
187
|
-
"
|
|
188
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
"",
|
package/dist/worker/routes.d.ts
CHANGED
|
@@ -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;
|
package/dist/worker/routes.js
CHANGED
|
@@ -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",
|