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.
- package/README.md +6 -7
- package/bin/astroid.mjs +107 -75
- package/dist/commerce/adapters.js +3 -3
- package/dist/commerce/checkout.js +1 -1
- package/dist/commerce/mirror.js +5 -4
- package/dist/commerce/roles.js +3 -3
- package/dist/config.d.ts +57 -14
- package/dist/config.js +27 -7
- package/dist/portal/guard.js +1 -1
- package/dist/project/build-output.d.ts +18 -0
- package/dist/project/build-output.js +64 -0
- package/dist/project/generate.d.ts +19 -0
- package/dist/project/generate.js +38 -4
- package/dist/project/index.d.ts +3 -0
- package/dist/project/index.js +3 -0
- package/dist/project/migrations.d.ts +26 -0
- package/dist/project/migrations.js +78 -0
- package/dist/project/scaffold.d.ts +23 -0
- package/dist/project/scaffold.js +86 -2
- package/dist/project/ship.d.ts +42 -0
- package/dist/project/ship.js +115 -0
- package/dist/queues/index.d.ts +2 -2
- package/dist/queues/index.js +2 -2
- package/dist/queues/messages.d.ts +6 -0
- package/dist/queues/messages.js +6 -0
- package/dist/queues/scaffold.js +30 -17
- package/dist/queues/webhook.d.ts +31 -2
- package/dist/queues/webhook.js +31 -0
- package/dist/schema/collections.d.ts +6 -0
- package/dist/schema/collections.js +14 -2
- package/dist/schema/framework.d.ts +4 -3
- package/dist/schema/framework.js +8 -7
- package/dist/worker/gateway.d.ts +12 -0
- package/dist/worker/gateway.js +32 -0
- package/dist/worker/generate.js +60 -6
- package/dist/worker/index.d.ts +1 -0
- package/dist/worker/index.js +1 -0
- package/dist/worker/routes.d.ts +1 -1
- package/dist/worker/routes.js +6 -1
- package/dist/workflow/advance.js +1 -1
- package/dist/workflow/config.js +1 -1
- package/package.json +3 -3
- package/src/components/StageBar.astro +1 -1
- package/src/components/media-meta.ts +1 -1
package/dist/queues/scaffold.js
CHANGED
|
@@ -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
|
|
109
|
-
"//
|
|
110
|
-
|
|
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
|
-
//
|
|
139
|
-
//
|
|
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
|
-
" //
|
|
143
|
-
" //
|
|
144
|
-
" //
|
|
145
|
-
" //
|
|
146
|
-
" //
|
|
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
|
-
" //
|
|
150
|
-
" //
|
|
151
|
-
" //
|
|
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
|
-
"//
|
|
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 {
|
|
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
|
-
|
|
218
|
-
" inline: (message) => handleQueueMessage(env, message),",
|
|
231
|
+
" queue: commerceQueue(env),",
|
|
219
232
|
` verify: ({ raw, secret }) => ${p.call},`,
|
|
220
233
|
" });",
|
|
221
234
|
"};",
|
package/dist/queues/webhook.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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.
|
package/dist/queues/webhook.js
CHANGED
|
@@ -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
|
|
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: [...
|
|
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`
|
|
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[];
|
package/dist/schema/framework.js
CHANGED
|
@@ -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`
|
|
6
|
-
// `inquiries` is pulled in only when a brand actually captures
|
|
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
|
-
// (
|
|
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`
|
|
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
|
+
}
|
package/dist/worker/generate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
package/dist/worker/index.d.ts
CHANGED
package/dist/worker/index.js
CHANGED
package/dist/worker/routes.d.ts
CHANGED
|
@@ -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;
|
package/dist/worker/routes.js
CHANGED
|
@@ -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
|
|
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({
|
package/dist/workflow/advance.js
CHANGED
|
@@ -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.
|
|
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
|
package/dist/workflow/config.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// `defineWorkflow`—staged, audited pipelines.
|
|
4
4
|
//
|
|
5
|
-
// The shape this generalizes is
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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
|