astroidjs 0.17.0 → 0.19.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 (44) hide show
  1. package/README.md +6 -7
  2. package/bin/astroid.mjs +107 -75
  3. package/dist/commerce/adapters.js +3 -3
  4. package/dist/commerce/checkout.js +1 -1
  5. package/dist/commerce/mirror.js +5 -4
  6. package/dist/commerce/roles.js +3 -3
  7. package/dist/config.d.ts +57 -14
  8. package/dist/config.js +27 -7
  9. package/dist/portal/guard.js +1 -1
  10. package/dist/project/build-output.d.ts +18 -0
  11. package/dist/project/build-output.js +64 -0
  12. package/dist/project/generate.d.ts +19 -0
  13. package/dist/project/generate.js +38 -4
  14. package/dist/project/index.d.ts +3 -0
  15. package/dist/project/index.js +3 -0
  16. package/dist/project/migrations.d.ts +26 -0
  17. package/dist/project/migrations.js +78 -0
  18. package/dist/project/scaffold.d.ts +23 -0
  19. package/dist/project/scaffold.js +86 -2
  20. package/dist/project/ship.d.ts +42 -0
  21. package/dist/project/ship.js +115 -0
  22. package/dist/queues/index.d.ts +2 -2
  23. package/dist/queues/index.js +2 -2
  24. package/dist/queues/messages.d.ts +6 -0
  25. package/dist/queues/messages.js +6 -0
  26. package/dist/queues/scaffold.js +30 -17
  27. package/dist/queues/webhook.d.ts +31 -2
  28. package/dist/queues/webhook.js +31 -0
  29. package/dist/schema/collections.d.ts +6 -0
  30. package/dist/schema/collections.js +14 -2
  31. package/dist/schema/framework.d.ts +4 -3
  32. package/dist/schema/framework.js +8 -7
  33. package/dist/worker/gateway.d.ts +12 -0
  34. package/dist/worker/gateway.js +32 -0
  35. package/dist/worker/generate.js +60 -6
  36. package/dist/worker/index.d.ts +1 -0
  37. package/dist/worker/index.js +1 -0
  38. package/dist/worker/routes.d.ts +1 -1
  39. package/dist/worker/routes.js +6 -1
  40. package/dist/workflow/advance.js +1 -1
  41. package/dist/workflow/config.js +1 -1
  42. package/package.json +3 -3
  43. package/src/components/StageBar.astro +1 -1
  44. package/src/components/media-meta.ts +1 -1
@@ -105,9 +105,20 @@ export function generateAstroidQueueSeam(config) {
105
105
  "// MEANS for this project.",
106
106
  "//",
107
107
  "// Throwing marks the message for retry. That's usually right—a failed",
108
- "// refresh means the site is serving stale data—and Cloudflare routes it to",
109
- "// the DLQ once it exceeds max_retries (wrangler.jsonc).",
110
- 'import { astroidQueueHandler, type AstroidQueueMessage } from "astroidjs";',
108
+ "// refresh means the site is serving stale data—and the queue owns the",
109
+ "// retries: it waits `retry_delay` between deliveries and routes the message",
110
+ "// to the DLQ once it exceeds max_retries (wrangler.jsonc).",
111
+ 'import { astroidQueue, astroidQueueHandler, type AstroidQueueMessage } from "astroidjs";',
112
+ "",
113
+ `// Send every message through this, never \`env.${ASTROID_QUEUE_BINDING}\` directly: the`,
114
+ "// queue binding when there is one, otherwise `handleQueueMessage` run in the",
115
+ "// request. A staging Preview has no queue, because a consumer can't target a",
116
+ "// Preview, so a producer that sends straight to the binding throws there.",
117
+ "// The inline path outlasts nothing but the request, with no retry and no",
118
+ "// DLQ, so it's a Preview fallback, not a production path.",
119
+ "export function commerceQueue(env: CloudflareEnv) {",
120
+ ` return astroidQueue(env.${ASTROID_QUEUE_BINDING}, (message) => handleQueueMessage(env, message));`,
121
+ "}",
111
122
  "",
112
123
  "export async function handleQueueMessage(",
113
124
  " env: CloudflareEnv,",
@@ -135,20 +146,22 @@ export function generateAstroidQueueSeam(config) {
135
146
  ` // const r = await astroidCatalogSync(items, { db: env.DB, table: ${JSON.stringify(table)} });`,
136
147
  ' // if (r.failed > 0) console.warn("[catalog] skipped", r.failed, r.errors);',
137
148
  " //",
138
- // Nobody is watching a queue message, which flips the retry trade-off that
139
- // an attended checkout route settles the other way.
149
+ // One layer owns retries, and it's the queue: it survives the isolate,
150
+ // waits between deliveries, and ends in the DLQ. Retries inside the handler
151
+ // multiply with the queue's (#61).
140
152
  ...(provider === "square"
141
153
  ? [
142
- " // Nobody is watching this run, so let the client retry: SquareConfig",
143
- " // takes `retry: { attempts: 3 }`, backing off on 429/5xx inside every",
144
- " // verb. It is off by default because a checkout route has a customer",
145
- " // watching a spinner, and there a fast failure beats a slow one. Here",
146
- " // the opposite holds—a catalog push that gives up halfway is worse.",
154
+ " // Leave SquareConfig's `retry` off here. The queue already redelivers",
155
+ " // a failed message, `retry_delay` apart, up to max_retries times",
156
+ " // (wrangler.jsonc). Client retries multiply with those deliveries:",
157
+ " // `retry: { attempts: 3 }` on a queue with max_retries 5 is up to 24",
158
+ " // Square calls for one message, against a provider that's already",
159
+ " // failing or rate limiting.",
147
160
  ]
148
161
  : [
149
- " // Nobody is watching this run, so ask for backoff on 429/5xx wherever",
150
- " // the client offers it. Attended paths are better off failing fast; a",
151
- " // catalog push that gives up halfway is not.",
162
+ " // Leave the client's own retries off here. The queue already",
163
+ " // redelivers a failed message, `retry_delay` apart, up to max_retries",
164
+ " // times (wrangler.jsonc), and client retries multiply with those.",
152
165
  ]),
153
166
  " //",
154
167
  " // The sync is idempotent (keyed on the provider's id) and never",
@@ -198,12 +211,13 @@ export function generateAstroidWebhookRoute(config, forProvider) {
198
211
  "// secret land afterwards instead of being lost.",
199
212
  "//",
200
213
  "// A staging Preview has no queue, since a consumer can't target one, so",
201
- "// there the event runs in the request through the same handler instead.",
214
+ "// `commerceQueue` runs the event in the request through the consumer's own",
215
+ "// handler instead (src/queue.ts).",
202
216
  'import type { APIRoute } from "astro";',
203
217
  'import { handleWebhook, readModuleSecret } from "astroidjs";',
204
218
  'import { env } from "cloudflare:workers";',
205
219
  `import { ${p.verifier} } from ${JSON.stringify(p.module)};`,
206
- 'import { handleQueueMessage } from "../../../queue";',
220
+ 'import { commerceQueue } from "../../../queue";',
207
221
  "",
208
222
  "export const prerender = false;",
209
223
  "",
@@ -214,8 +228,7 @@ export function generateAstroidWebhookRoute(config, forProvider) {
214
228
  " return handleWebhook(request, url, {",
215
229
  ` provider: ${JSON.stringify(provider)},`,
216
230
  ` secret: await readModuleSecret(env.${COMMERCE_PROVIDER_SECRETS[provider].webhook}),`,
217
- ` queue: env.${ASTROID_QUEUE_BINDING},`,
218
- " inline: (message) => handleQueueMessage(env, message),",
231
+ " queue: commerceQueue(env),",
219
232
  ` verify: ({ raw, secret }) => ${p.call},`,
220
233
  " });",
221
234
  "};",
@@ -10,6 +10,29 @@ import type { AstroidQueueMessage } from "./messages.js";
10
10
  export interface QueueProducer<T = AstroidQueueMessage> {
11
11
  send(message: T): Promise<unknown>;
12
12
  }
13
+ /**
14
+ * The queue binding when it's bound, and otherwise a stand-in producer that
15
+ * runs `handler` on each message in the request.
16
+ *
17
+ * A staging Preview leaves the queue unbound, because a queue consumer can't
18
+ * target a Preview (louise-toolkit ADR 0017), so every producer that sends
19
+ * straight to the binding throws there. Send through this instead, with the
20
+ * handler the queue consumer runs, and the same code works on both:
21
+ *
22
+ * ```ts
23
+ * export const commerceQueue = (env: CloudflareEnv) =>
24
+ * astroidQueue(env.COMMERCE_QUEUE, (message) => handleQueueMessage(env, message));
25
+ *
26
+ * await commerceQueue(env).send({ kind: "catalog_refresh" });
27
+ * ```
28
+ *
29
+ * It's a Preview fallback, not a production path. A message run inline lasts
30
+ * only as long as the request, gets no retry and no dead-letter queue, and a
31
+ * slow one holds the response open. The stand-in's `send` throws whatever the
32
+ * handler throws, where a real `send` would have succeeded and left the failure
33
+ * to the consumer. The binding always wins when it's there.
34
+ */
35
+ export declare function astroidQueue<T = AstroidQueueMessage>(queue: QueueProducer<T> | null | undefined, handler: (message: T) => Promise<void>): QueueProducer<T>;
13
36
  export interface WebhookVerifyInput {
14
37
  /** The raw request body, exactly as received. */
15
38
  raw: string;
@@ -28,10 +51,16 @@ export interface WebhookRouteOptions {
28
51
  secret: string | null;
29
52
  /** Signature check over the raw body—for example, `verifySquareSignature`. */
30
53
  verify: (input: WebhookVerifyInput) => boolean | Promise<boolean>;
31
- /** The queue binding, or null/undefined when Queues aren't provisioned. */
54
+ /**
55
+ * The queue binding, or null/undefined when Queues aren't provisioned. Pass
56
+ * {@link astroidQueue}'s producer to run the message in the request when the
57
+ * binding is absent, such as on a staging Preview.
58
+ */
32
59
  queue?: QueueProducer | null;
33
60
  /**
34
- * Run a message in the request instead, when `queue` is absent. A staging
61
+ * Run a message in the request instead, when `queue` is absent. Prefer
62
+ * passing {@link astroidQueue}'s producer as `queue`, which covers every
63
+ * producer in a site rather than this one route. A staging
35
64
  * Preview leaves the queue unbound, because a queue consumer can't target a
36
65
  * Preview (louise-toolkit ADR 0017), so without this every webhook a Preview
37
66
  * receives answers 503 and the provider retries it forever.
@@ -14,6 +14,37 @@
14
14
  // available: a 5xx means "try again", a 4xx means "never again", and returning
15
15
  // the wrong one either loses the event permanently or pins the provider in a
16
16
  // retry loop. Each code below is chosen for what it tells the sender to do.
17
+ /**
18
+ * The queue binding when it's bound, and otherwise a stand-in producer that
19
+ * runs `handler` on each message in the request.
20
+ *
21
+ * A staging Preview leaves the queue unbound, because a queue consumer can't
22
+ * target a Preview (louise-toolkit ADR 0017), so every producer that sends
23
+ * straight to the binding throws there. Send through this instead, with the
24
+ * handler the queue consumer runs, and the same code works on both:
25
+ *
26
+ * ```ts
27
+ * export const commerceQueue = (env: CloudflareEnv) =>
28
+ * astroidQueue(env.COMMERCE_QUEUE, (message) => handleQueueMessage(env, message));
29
+ *
30
+ * await commerceQueue(env).send({ kind: "catalog_refresh" });
31
+ * ```
32
+ *
33
+ * It's a Preview fallback, not a production path. A message run inline lasts
34
+ * only as long as the request, gets no retry and no dead-letter queue, and a
35
+ * slow one holds the response open. The stand-in's `send` throws whatever the
36
+ * handler throws, where a real `send` would have succeeded and left the failure
37
+ * to the consumer. The binding always wins when it's there.
38
+ */
39
+ export function astroidQueue(queue, handler) {
40
+ if (queue)
41
+ return queue;
42
+ return {
43
+ send: async (message) => {
44
+ await handler(message);
45
+ },
46
+ };
47
+ }
17
48
  const text = (body, status) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
18
49
  /** Default event-type reader: a top-level `type` string. */
19
50
  function defaultEventType(payload) {
@@ -69,6 +69,12 @@ export interface AstroidPagesHooks {
69
69
  * with a 422 instead.
70
70
  */
71
71
  export declare const ASTROID_RESERVED_SLUGS: readonly string[];
72
+ /**
73
+ * The reserved slugs for this config: {@link ASTROID_RESERVED_SLUGS}, plus the
74
+ * file routes a module scaffolds, minus any in `pages.allowSlugs`. A
75
+ * portfolio's gallery is `src/pages/work.astro`.
76
+ */
77
+ export declare function astroidReservedSlugs(config: AstroidConfig): string[];
72
78
  export declare function astroidPagesWriteHooks(config: AstroidConfig, site?: AstroidPagesHooks): {
73
79
  sanitize: (html: string) => string;
74
80
  transform: (data: Record<string, unknown>, ctx: AstroidPagesWriteContext) => Promise<Record<string, unknown>>;
@@ -47,7 +47,7 @@ const pageMediaBase = astroidMediaBase;
47
47
  * The section catalog a `pages` write is validated + sanitized against: the
48
48
  * site's own (`config.sectionCatalog`) when it registered bespoke sections, else
49
49
  * Astroid's built-in vocabulary. This is what lets a site with its own section
50
- * designs (coracle's 13) keep the same write contract as a stock Astroid site.
50
+ * designs keep the same write contract as a stock Astroid site.
51
51
  */
52
52
  function resolveSectionCatalog(config) {
53
53
  return config.sectionCatalog ?? astroidSectionCatalog;
@@ -109,9 +109,21 @@ export const ASTROID_RESERVED_SLUGS = [
109
109
  "_astro",
110
110
  "api",
111
111
  "cdn-cgi",
112
+ // The scaffold's own file routes, which every site has.
113
+ "contact",
114
+ "login",
112
115
  "robots.txt",
113
116
  "sitemap.xml",
114
117
  ];
118
+ /**
119
+ * The reserved slugs for this config: {@link ASTROID_RESERVED_SLUGS}, plus the
120
+ * file routes a module scaffolds, minus any in `pages.allowSlugs`. A
121
+ * portfolio's gallery is `src/pages/work.astro`.
122
+ */
123
+ export function astroidReservedSlugs(config) {
124
+ const allowed = new Set(config.pages?.allowSlugs ?? []);
125
+ return [...ASTROID_RESERVED_SLUGS, ...(config.archetype === "portfolio" ? ["work"] : [])].filter((slug) => !allowed.has(slug));
126
+ }
115
127
  export function astroidPagesWriteHooks(config, site = {}) {
116
128
  return {
117
129
  // `body` is a richField, so it goes through pagesRoute's own sanitize seam—with
@@ -127,7 +139,7 @@ export function astroidPagesWriteHooks(config, site = {}) {
127
139
  await site.validate?.(data, ctx);
128
140
  await assertAstroidPageSections(config, data, ctx.operation);
129
141
  },
130
- reservedSlugs: [...ASTROID_RESERVED_SLUGS, ...(site.reservedSlugs ?? [])],
142
+ reservedSlugs: [...astroidReservedSlugs(config), ...(site.reservedSlugs ?? [])],
131
143
  };
132
144
  }
133
145
  export function astroidPagesCollection(config) {
@@ -1,13 +1,14 @@
1
1
  import type { AstroidConfig } from "../config.js";
2
2
  /** A ready-made table Astroid re-exports from `louise-toolkit/db`. */
3
- export type AstroidFrameworkTable = "inquiries" | "media" | "siteSettings";
3
+ export type AstroidFrameworkTable = "inquiries" | "media" | "pageRedirects" | "siteSettings";
4
4
  /** True when the site captures inquiries—a contact section or a
5
5
  * wholesale-inquiry module. Shared by table selection (here) and route selection
6
6
  * (the worker route plan). */
7
7
  export declare function capturesInquiries(config: AstroidConfig): boolean;
8
8
  /**
9
9
  * The framework tables this project needs, sorted alphabetically (so the emitted
10
- * import/export lists are stable). `media` + `siteSettings` always; `inquiries`
11
- * when a brand captures them.
10
+ * import/export lists are stable). `media`, `pageRedirects`, and `siteSettings`
11
+ * always; `inquiries` when a brand captures them. `pageRedirects` keeps a
12
+ * renamed page's old URL working, which every site with a Pages panel needs.
12
13
  */
13
14
  export declare function astroidFrameworkTables(config: AstroidConfig): AstroidFrameworkTable[];
@@ -2,15 +2,15 @@
2
2
  //
3
3
  // Which ready-made Louise tables (louise-toolkit/db) an Astroid project needs.
4
4
  // Astroid re-exports these rather than redefining them, so the core content
5
- // tables never drift from Louise. `media` and `siteSettings` are universal;
6
- // `inquiries` is pulled in only when a brand actually captures inquiries (a
7
- // contact section, or a wholesale-inquiry module).
5
+ // tables never drift from Louise. `media`, `pageRedirects`, and `siteSettings`
6
+ // are universal; `inquiries` is pulled in only when a brand actually captures
7
+ // inquiries (a contact section, or a wholesale-inquiry module).
8
8
  /** True when the site captures inquiries—a contact section or a
9
9
  * wholesale-inquiry module. Shared by table selection (here) and route selection
10
10
  * (the worker route plan). */
11
11
  export function capturesInquiries(config) {
12
12
  // Explicit override wins—a site whose inquiry surface is a bespoke section
13
- // (coracle's custom `contactForm`) can't be detected from the built-in
13
+ // (a custom `contactForm`, say) can't be detected from the built-in
14
14
  // vocabulary, so it says so directly.
15
15
  if (typeof config.inquiries === "boolean")
16
16
  return config.inquiries;
@@ -21,11 +21,12 @@ export function capturesInquiries(config) {
21
21
  }
22
22
  /**
23
23
  * The framework tables this project needs, sorted alphabetically (so the emitted
24
- * import/export lists are stable). `media` + `siteSettings` always; `inquiries`
25
- * when a brand captures them.
24
+ * import/export lists are stable). `media`, `pageRedirects`, and `siteSettings`
25
+ * always; `inquiries` when a brand captures them. `pageRedirects` keeps a
26
+ * renamed page's old URL working, which every site with a Pages panel needs.
26
27
  */
27
28
  export function astroidFrameworkTables(config) {
28
- const tables = ["media", "siteSettings"];
29
+ const tables = ["media", "pageRedirects", "siteSettings"];
29
30
  if (capturesInquiries(config))
30
31
  tables.push("inquiries");
31
32
  return tables.sort();
@@ -0,0 +1,12 @@
1
+ import type { AiGatewayOptions } from "louise-toolkit/ai";
2
+ /** The `vars` entry that names the site's AI Gateway. */
3
+ export declare const ASTROID_AI_GATEWAY_VAR = "AI_GATEWAY_ID";
4
+ /**
5
+ * The AI Gateway options for this request's environment, or `undefined` to
6
+ * call Workers AI directly. Reads `AI_GATEWAY_ID`: unset, empty, or the
7
+ * placeholder sentinel all mean off.
8
+ *
9
+ * Takes `env` as `unknown`, like the toolkit's `aiRunner`, so a site whose
10
+ * `CloudflareEnv` doesn't declare the variable still compiles.
11
+ */
12
+ export declare function astroidAiGateway(env: unknown): AiGatewayOptions | undefined;
@@ -0,0 +1,32 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // AI Gateway for the editor's AI assists, off until a site names one.
4
+ //
5
+ // Every louise-toolkit AI route takes a `gateway` option, and without one no
6
+ // successful AI call is logged anywhere: no latency, no error rate, and no
7
+ // cache. A gateway gives all three with no toolkit change. It's a variable,
8
+ // not config, so production can log through one while a Preview, which sets
9
+ // no variable, calls Workers AI directly.
10
+ //
11
+ // The log holds the text an editor sends to the assists. Say so on the site's
12
+ // privacy page before you set the variable.
13
+ import { ASTROID_SECRET_PLACEHOLDER } from "../secrets.js";
14
+ /** The `vars` entry that names the site's AI Gateway. */
15
+ export const ASTROID_AI_GATEWAY_VAR = "AI_GATEWAY_ID";
16
+ /**
17
+ * The AI Gateway options for this request's environment, or `undefined` to
18
+ * call Workers AI directly. Reads `AI_GATEWAY_ID`: unset, empty, or the
19
+ * placeholder sentinel all mean off.
20
+ *
21
+ * Takes `env` as `unknown`, like the toolkit's `aiRunner`, so a site whose
22
+ * `CloudflareEnv` doesn't declare the variable still compiles.
23
+ */
24
+ export function astroidAiGateway(env) {
25
+ const id = env?.[ASTROID_AI_GATEWAY_VAR];
26
+ if (typeof id !== "string")
27
+ return undefined;
28
+ const trimmed = id.trim();
29
+ if (!trimmed || trimmed === ASTROID_SECRET_PLACEHOLDER)
30
+ return undefined;
31
+ return { id: trimmed };
32
+ }
@@ -69,10 +69,13 @@ export function generateAstroidWorker(config) {
69
69
  const editorImports = [
70
70
  "DEFAULT_PAGE_FIELDS",
71
71
  "type PagesWrite",
72
+ "MEDIA_ALT_MISSING_SQL",
73
+ "d1Check",
72
74
  ...new Set(plan.map((route) => route.factory).filter((f) => !realtimeRouteFactories.has(f))),
73
75
  ].sort();
74
76
  const tables = [
75
77
  "media",
78
+ "pageRedirects",
76
79
  "pages",
77
80
  "pagesVersions",
78
81
  "siteSettings",
@@ -90,10 +93,13 @@ export function generateAstroidWorker(config) {
90
93
  return `vitalsRoute({ dataset: (env) => env.${ASTROID_VITALS_BINDING} })`;
91
94
  case "health":
92
95
  return "healthRoute({ resolveEditor, read: readSiteHealth })";
96
+ case "status":
97
+ return "statusRoute({ checks: STATUS_CHECKS, reuseMs: STATUS_REUSE_MS })";
93
98
  case "realtime":
94
99
  return `realtimeRoute({ resolveEditor, namespace: (env) => env.${ASTROID_REALTIME_BINDING} })`;
95
100
  case "versions":
96
- return "versionsRoute({ table: pages, versionsTable: pagesVersions, config: pagesCollection, resolveEditor, bufferKv: (env) => env.DRAFTS })";
101
+ // `redirects` records `/old → /new` when a publish changes the slug.
102
+ return "versionsRoute({ table: pages, versionsTable: pagesVersions, config: pagesCollection, resolveEditor, bufferKv: (env) => env.DRAFTS, redirects: pageRedirects })";
97
103
  case "search":
98
104
  return "searchRoute({ table: pages, config: pagesCollection, resolveEditor })";
99
105
  case "pages":
@@ -107,7 +113,15 @@ export function generateAstroidWorker(config) {
107
113
  // which have no foreign key to the page and would otherwise orphan.
108
114
  // `afterWrite` syncs the written page's search index entry, which plain
109
115
  // CRUD writes leave stale.
110
- return 'pagesRoute({ table: pages, versionsTable: pagesVersions, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"], ...pagesWriteHooks, afterWrite: reindexPagesSearch })';
116
+ //
117
+ // `drafts` carries a Pages panel update into the page's pending draft,
118
+ // with the same config and buffer versionsRoute uses. Without it, a
119
+ // rename made while a draft was pending came undone at the next
120
+ // publish, which copies the whole draft snapshot onto the live row.
121
+ //
122
+ // `redirects` records `/old → /new` in the same batch as a slug change,
123
+ // and the middleware's `redirectFor` serves it.
124
+ return 'pagesRoute({ table: pages, versionsTable: pagesVersions, drafts: { config: pagesCollection, bufferKv: (env) => env.DRAFTS }, redirects: pageRedirects, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"], ...pagesWriteHooks, afterWrite: reindexPagesSearch })';
111
125
  case "save":
112
126
  // No `bufferKv` here, deliberately: `saveRoute` has no such option. It
113
127
  // writes live field saves (title, SEO) straight through, and the draft
@@ -131,10 +145,12 @@ export function generateAstroidWorker(config) {
131
145
  // LOUISE_AI kill switch, so all three assists share one definition of
132
146
  // "is generation on?" instead of each re-deriving it. Embeddings keep
133
147
  // binding-presence as their switch—see the helper's comment.
148
+ // `gateway` routes both through AI Gateway when the site sets
149
+ // `AI_GATEWAY_ID`, and calls Workers AI directly when it doesn't.
134
150
  case "ai":
135
- return "aiRoute({ resolveEditor, ai: aiRunner })";
151
+ return "aiRoute({ resolveEditor, ai: aiRunner, gateway: astroidAiGateway })";
136
152
  case "seoFix":
137
- return "seoFixRoute({ table: pages, resolveEditor, ai: aiRunner })";
153
+ return "seoFixRoute({ table: pages, resolveEditor, ai: aiRunner, gateway: astroidAiGateway })";
138
154
  case "media": {
139
155
  // `altText` fills a new upload's alt from the image itself. Best-effort
140
156
  // by contract—a model error or a missing binding never fails the
@@ -197,10 +213,14 @@ export function generateAstroidWorker(config) {
197
213
  p('import { aiRunner } from "louise-toolkit/ai";');
198
214
  }
199
215
  p('import { checkLinks } from "louise-toolkit/browser";');
216
+ p('import { reportDegraded } from "louise-toolkit/errors";');
200
217
  p('import { readHealthSummary, summarizeHealth, writeHealthSummary } from "louise-toolkit/health";');
201
218
  p('import { composeWorker, isEditRequest, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
202
219
  p(`import { ${tables.join(", ")} } from "./schema.js";`);
203
220
  const astroidImports = [
221
+ ...(plan.some((route) => route.name === "ai" || route.name === "seoFix")
222
+ ? ["astroidAiGateway"]
223
+ : []),
204
224
  "astroidPagesCollection",
205
225
  "astroidPagesWriteHooks",
206
226
  "readModuleSecret",
@@ -221,6 +241,11 @@ export function generateAstroidWorker(config) {
221
241
  p("// route. Scaffolded once and yours to edit.");
222
242
  p('import { pagesHooks } from "./pages-hooks.js";');
223
243
  }
244
+ if (config.status?.checks) {
245
+ p("// Your STATUS seam: the site's own checks for the public status route,");
246
+ p("// such as a catalog snapshot's age. Scaffolded once and yours to edit.");
247
+ p('import { statusChecks } from "./status-checks.js";');
248
+ }
224
249
  if (config.settings?.hooks) {
225
250
  p("// Your SETTINGS seam: per-key sanitizers and a GET transform for the");
226
251
  p("// Settings panel. Scaffolded once and yours to edit.");
@@ -314,7 +339,9 @@ export function generateAstroidWorker(config) {
314
339
  p(" const origin = env.SITE_URL ?? mediaBaseOf(env);");
315
340
  p(" const [brokenLinks, missingAlt, seoGaps] = await Promise.all([");
316
341
  p(' checkLinks({ base: origin, paths: ["/"] }).catch(() => []),');
317
- p(" countRows(env, \"SELECT COUNT(*) AS n FROM media WHERE alt IS NULL OR alt = ''\"),");
342
+ p(" // Only an unwritten alt (NULL) is missing. An empty one is an image the");
343
+ p(" // owner marked decorative, which HTML says to skip.");
344
+ p(" countRows(env, `SELECT COUNT(*) AS n FROM media WHERE ${MEDIA_ALT_MISSING_SQL}`),");
318
345
  p(" countRows(");
319
346
  p(" env,");
320
347
  p(" \"SELECT COUNT(*) AS n FROM pages WHERE status = 'published'\" +");
@@ -330,6 +357,27 @@ export function generateAstroidWorker(config) {
330
357
  p(" return summary;");
331
358
  p("}");
332
359
  p();
360
+ p("// --- public status ---------------------------------------------------------");
361
+ p("// What statusRoute answers an outside probe with: 200 when every check");
362
+ p("// passes, 503 when any fails, throws, or takes over two seconds. Anyone can");
363
+ p("// make these run, so each is one cheap read, and a burst of probes reuses one");
364
+ p("// result per isolate for STATUS_REUSE_MS.");
365
+ p("const STATUS_REUSE_MS = 10_000;");
366
+ p("const STATUS_CHECKS = {");
367
+ p(" // D1 answers `SELECT 1`.");
368
+ p(" d1: d1Check((env: CloudflareEnv) => env.DB),");
369
+ p(" // The public home page reads real content, not the seed-me fallback the");
370
+ p(" // page renders when its row is missing (an unseeded or wiped database,");
371
+ p(" // or a migration that never ran, which throws and fails the check too).");
372
+ p(" content: async (env: CloudflareEnv) =>");
373
+ p(` (await env.DB.prepare("SELECT 1 FROM pages WHERE slug = 'home'").first()) !== null,`);
374
+ if (config.status?.checks) {
375
+ p(" // The site's own, from src/status-checks.ts. Spread last, so a site");
376
+ p(" // check with the same name replaces Astroid's on purpose.");
377
+ p(" ...statusChecks,");
378
+ }
379
+ p("};");
380
+ p();
333
381
  p("/** One COUNT, degrading to 0—a missing table must not abort the scan. */");
334
382
  p("async function countRows(env: CloudflareEnv, sql: string): Promise<number> {");
335
383
  p(" try {");
@@ -476,7 +524,9 @@ export function generateAstroidWorker(config) {
476
524
  p(` if (controller.cron === ${JSON.stringify(ASTROID_HEALTH_CRON)}) {`);
477
525
  p(" // Daily site-health scan. `waitUntil` because the crawl outlives the");
478
526
  p(" // handler's return, and a scan that throws must not retry the cron.");
479
- p(" ctx.waitUntil(runHealthScan(env).catch(() => {}));");
527
+ p(" // Reported rather than swallowed: the Health panel keeps showing the");
528
+ p(" // last good scan, so this log line is the only sign that one failed.");
529
+ p(' ctx.waitUntil(runHealthScan(env).catch((error) => reportDegraded("health.scan", error)));');
480
530
  p(" return;");
481
531
  p(" }");
482
532
  if (cron) {
@@ -555,6 +605,7 @@ export function generateAstroidMiddleware(config) {
555
605
  "// styles + inlined data: brand font are allowed.",
556
606
  'import { env } from "cloudflare:workers";',
557
607
  'import { createLouiseMiddleware } from "@louise-toolkit/astro";',
608
+ 'import { db, pageRedirects, resolvePageRedirect } from "louise-toolkit/db";',
558
609
  // One `astroidjs` import, composed from what this config actually uses—two
559
610
  // import statements for the same module is legal and reads as an
560
611
  // oversight in a file nobody is supposed to hand-edit.
@@ -612,6 +663,9 @@ export function generateAstroidMiddleware(config) {
612
663
  " // anything the worker's routes didn't answer. A second check behind the",
613
664
  " // worker's gate, and free: the editor is resolved here on every request.",
614
665
  " apiGate: true,",
666
+ " // A renamed page's old URL answers a 301 to the new one. It runs only after",
667
+ " // the page answered 404, so a page later created on the old path wins.",
668
+ " redirectFor: (path) => resolvePageRedirect(db(env.DB), pageRedirects, path),",
615
669
  // `extend` runs once and may need to populate BOTH—a tenanted site with a
616
670
  // portal resolves a tenant and a customer on the same request.
617
671
  ...(portal || tenancy
@@ -1,2 +1,3 @@
1
1
  export * from "./routes.js";
2
2
  export * from "./generate.js";
3
+ export * from "./gateway.js";
@@ -4,3 +4,4 @@
4
4
  // → the Worker entrypoint and Astro middleware a site would otherwise hand-write.
5
5
  export * from "./routes.js";
6
6
  export * from "./generate.js";
7
+ export * from "./gateway.js";
@@ -1,5 +1,5 @@
1
1
  import type { AstroidConfig } from "../config.js";
2
- export type AstroidEditorRouteName = "ai" | "health" | "realtime" | "vitals" | "overview" | "seoFix" | "versions" | "search" | "pages" | "save" | "settings" | "media" | "editors" | "form" | "inquiries" | "seed";
2
+ export type AstroidEditorRouteName = "ai" | "health" | "realtime" | "vitals" | "status" | "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;
@@ -82,6 +82,11 @@ export function astroidEditorRoutePlan(config) {
82
82
  factory: "vitalsRoute",
83
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
84
  });
85
+ routes.push({
86
+ name: "status",
87
+ factory: "statusRoute",
88
+ note: "Public status for an outside probe (GET/HEAD /api/louise/status): 200 when every check passes, 503 when any fails. A publicRoute under ADR 0012, so the gate lets an anonymous probe through, and it answers booleans and ages, never error text. Owns its own path, so it collides with nothing.",
89
+ });
85
90
  routes.push({
86
91
  name: "health",
87
92
  factory: "healthRoute",
@@ -90,7 +95,7 @@ export function astroidEditorRoutePlan(config) {
90
95
  routes.push({
91
96
  name: "ai",
92
97
  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.",
98
+ note: "Editor AI assists—rewrite a selection (tighten, rephrase, simplify, or fix), 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
99
  });
95
100
  if (capturesInquiries(config)) {
96
101
  routes.push({
@@ -10,7 +10,7 @@
10
10
  // expected—`UPDATE … SET stage = ? WHERE id = ? AND stage = ?`—and treat "0 rows
11
11
  // changed" as the conflict signal rather than checking first and hoping.
12
12
  //
13
- // ORDERING MATTERS, and the reference gets it wrong. ghostfire's floor route
13
+ // ORDERING MATTERS, and the reference gets it wrong. Its floor route
14
14
  // inserts the sign-off row and THEN runs the guarded update, so a double submit
15
15
  // writes two audit rows even though only one advance lands. Here the guarded
16
16
  // update goes first and the audit row is written only if it actually moved the
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // `defineWorkflow`—staged, audited pipelines.
4
4
  //
5
- // The shape this generalizes is ghostfire.coffee's production floor, and the
5
+ // The shape this generalizes is a client site's production floor, and the
6
6
  // framing correction in #256 is the important part: despite the name "order
7
7
  // tracker", it is NOT queue- or Durable-Object-driven. It is a synchronous SSR
8
8
  // + D1 state machine—an integer `stage` column advanced by sign-off rows,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Astroid — an opinionated meta-framework over Louise Toolkit and Astro for building editable, multi-editor sites on Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "astro",
@@ -64,14 +64,14 @@
64
64
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
65
65
  "@vitest/coverage-v8": "4.1.11",
66
66
  "astro": "^7.2.9",
67
- "louise-toolkit": "^0.34.0",
67
+ "louise-toolkit": "^0.36.0",
68
68
  "solid-js": "^1.9.15",
69
69
  "typescript": "^6.0.3",
70
70
  "vitest": "^4.1.11"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "astro": "^7.0.9",
74
- "louise-toolkit": "^0.34.0",
74
+ "louise-toolkit": "^0.36.0",
75
75
  "solid-js": "^1.9.0"
76
76
  },
77
77
  "peerDependenciesMeta": {
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  // `<StageBar>`—a pipeline's progress as a segmented bar.
3
3
  //
4
- // Generalized from ghostfire's six-stage floor tracker, which hard-coded the
4
+ // Generalized from a client site's six-stage floor tracker, which hard-coded the
5
5
  // segment count, the corner radii, the brand hex codes, and a mascot image.
6
6
  // Here the stage list drives everything and the colours are theme tokens, so a
7
7
  // four-stage onboarding flow and an eight-stage production line both work and
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // So the whole page is resolved in ONE bounded `IN (...)` lookup before anything
11
11
  // renders, and the result is threaded down as `mediaMeta`. This is the pattern
12
- // ghostfire's `Sections.astro` arrived at independently, generalized.
12
+ // a client site's `Sections.astro` arrived at independently, generalized.
13
13
  //
14
14
  // The collection step is SCHEMA-DRIVEN: it walks the catalog looking for fields
15
15
  // of `type: "image"` rather than hardcoding field names. That's what keeps it