astroidjs 0.5.0 → 0.7.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/dist/commerce/adapters.d.ts +64 -4
- package/dist/commerce/adapters.js +78 -5
- package/dist/commerce/checkout-scaffold.js +196 -57
- package/dist/commerce/checkout.d.ts +74 -2
- package/dist/commerce/checkout.js +29 -2
- package/dist/commerce/index.d.ts +2 -2
- package/dist/commerce/index.js +1 -1
- package/dist/components/sections.d.ts +1 -1
- package/dist/config.d.ts +86 -18
- package/dist/config.js +87 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/project/generate.js +13 -1
- package/dist/project/scaffold.js +23 -6
- package/dist/pwa/generate.d.ts +34 -1
- package/dist/pwa/generate.js +33 -4
- package/dist/queues/consumer.d.ts +21 -1
- package/dist/queues/consumer.js +6 -3
- package/dist/queues/messages.js +4 -0
- package/dist/queues/scaffold.js +29 -0
- package/dist/schema/collections.js +9 -4
- package/dist/tenancy/index.d.ts +35 -0
- package/dist/tenancy/index.js +101 -0
- package/dist/worker/generate.js +89 -9
- package/package.json +7 -2
- package/src/components/Editable.astro +22 -10
- package/src/components/Section.astro +5 -4
- package/src/components/Sections.astro +1 -1
- package/src/components/sections.ts +1 -1
package/dist/pwa/generate.js
CHANGED
|
@@ -39,9 +39,24 @@ export function resolvePwa(config) {
|
|
|
39
39
|
orientation: pwa.orientation ?? "any",
|
|
40
40
|
backgroundColor: pwa.backgroundColor ?? "#ffffff",
|
|
41
41
|
themeColor: pwa.themeColor ?? config.theme.colors.brand,
|
|
42
|
-
|
|
42
|
+
offlineFallback: pwa.offlineFallback ?? null,
|
|
43
|
+
emitDir: (pwa.emitDir ?? "").replace(/^\/+|\/+$/g, ""),
|
|
44
|
+
// The offline page is precached with the shell — a fallback fetched on
|
|
45
|
+
// demand is a fallback that isn't there when the network is.
|
|
46
|
+
shell: [
|
|
47
|
+
scope,
|
|
48
|
+
`${assetBase(pwa.emitDir)}/manifest.webmanifest`,
|
|
49
|
+
...(pwa.offlineFallback ? [pwa.offlineFallback] : []),
|
|
50
|
+
...(pwa.shell ?? []),
|
|
51
|
+
],
|
|
43
52
|
};
|
|
44
53
|
}
|
|
54
|
+
/** URL prefix the emitted `sw.js` + manifest are served from — `""` at the
|
|
55
|
+
* public root, `"/studio"` under an `emitDir`. */
|
|
56
|
+
function assetBase(emitDir) {
|
|
57
|
+
const dir = (emitDir ?? "").replace(/^\/+|\/+$/g, "");
|
|
58
|
+
return dir ? `/${dir}` : "";
|
|
59
|
+
}
|
|
45
60
|
/**
|
|
46
61
|
* `public/manifest.webmanifest`.
|
|
47
62
|
*
|
|
@@ -108,6 +123,14 @@ export function generateServiceWorker(config) {
|
|
|
108
123
|
"// present as 'my changes don't save'",
|
|
109
124
|
`const CACHE = ${JSON.stringify(cacheName)};`,
|
|
110
125
|
`const SCOPE = ${JSON.stringify(pwa.scope)};`,
|
|
126
|
+
...(pwa.offlineFallback
|
|
127
|
+
? [
|
|
128
|
+
"// A prerendered page with no session-specific markup. The scope root is",
|
|
129
|
+
"// the app SHELL, which on an auth-gated app is `Cache-Control: no-store`",
|
|
130
|
+
"// — so falling back to it serves either nothing or someone else's shell.",
|
|
131
|
+
`const OFFLINE = ${JSON.stringify(pwa.offlineFallback)};`,
|
|
132
|
+
]
|
|
133
|
+
: []),
|
|
111
134
|
`const SHELL = ${JSON.stringify([...new Set(pwa.shell)])};`,
|
|
112
135
|
"",
|
|
113
136
|
"self.addEventListener('install', (event) => {",
|
|
@@ -175,7 +198,9 @@ export function generateServiceWorker(config) {
|
|
|
175
198
|
" .catch(() => {});",
|
|
176
199
|
" return res;",
|
|
177
200
|
" })",
|
|
178
|
-
|
|
201
|
+
pwa.offlineFallback
|
|
202
|
+
? " .catch(() => caches.match(req).then((r) => r || caches.match(OFFLINE))),"
|
|
203
|
+
: " .catch(() => caches.match(req).then((r) => r || caches.match(SCOPE))),",
|
|
179
204
|
" );",
|
|
180
205
|
" return;",
|
|
181
206
|
" }",
|
|
@@ -213,14 +238,18 @@ export function generateServiceWorker(config) {
|
|
|
213
238
|
export function generatePwaHeaders(config) {
|
|
214
239
|
if (!usesPwa(config))
|
|
215
240
|
return null;
|
|
241
|
+
// Paths must match where the files are actually emitted — a stanza for
|
|
242
|
+
// `/sw.js` while the worker lives at `/studio/sw.js` sets headers on nothing,
|
|
243
|
+
// and the no-cache rule is what stops a bad worker sticking around.
|
|
244
|
+
const base = assetBase(config.pwa?.emitDir);
|
|
216
245
|
return [
|
|
217
246
|
"",
|
|
218
247
|
"# The service worker must revalidate on every load, or a bad worker sticks",
|
|
219
248
|
"# around until its cache entry expires — and it controls every page in scope.",
|
|
220
|
-
|
|
249
|
+
`${base}/sw.js`,
|
|
221
250
|
" Cache-Control: no-cache",
|
|
222
251
|
"",
|
|
223
|
-
|
|
252
|
+
`${base}/manifest.webmanifest`,
|
|
224
253
|
" Content-Type: application/manifest+json",
|
|
225
254
|
" Cache-Control: public, max-age=3600",
|
|
226
255
|
"",
|
|
@@ -4,10 +4,30 @@ export interface QueueHandlerOptions {
|
|
|
4
4
|
* Re-sync whatever the provider owns — the catalog mirror, a cache. Called
|
|
5
5
|
* for a periodic refresh and for webhooks that touched the catalog.
|
|
6
6
|
*
|
|
7
|
+
* Receives the message that triggered it, so a site running more than one
|
|
8
|
+
* commerce provider can branch on `message.provider` rather than refreshing
|
|
9
|
+
* everything for everything. A zero-argument seam stays valid — the parameter
|
|
10
|
+
* is there to be ignored until it's needed.
|
|
11
|
+
*
|
|
7
12
|
* Throwing marks the message for retry, which is usually right: a failed
|
|
8
13
|
* refresh means the site is serving stale data.
|
|
9
14
|
*/
|
|
10
|
-
refreshCatalog?: () => void | Promise<void>;
|
|
15
|
+
refreshCatalog?: (message: AstroidQueueMessage) => void | Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Which provider owns the catalog `refreshCatalog` re-syncs. Set it and only
|
|
18
|
+
* that provider's webhooks trigger a refresh; leave it unset and any
|
|
19
|
+
* catalog-affecting webhook does, which is correct for the single-provider
|
|
20
|
+
* case and is what every existing consumer gets.
|
|
21
|
+
*
|
|
22
|
+
* This exists because two providers is now a supported configuration, and the
|
|
23
|
+
* seam is provider-blind by construction: a site can run Fourthwall as its
|
|
24
|
+
* storefront and Square as its POS, at which point `refreshCatalog` means
|
|
25
|
+
* "re-pull Fourthwall" while Square emits `inventory.count.updated` on every
|
|
26
|
+
* single sale. Unscoped, a good Saturday becomes a sync storm against an
|
|
27
|
+
* unrelated provider's rate limit — and the periodic refresh is unaffected, so
|
|
28
|
+
* the site looks fine until the day it's busy.
|
|
29
|
+
*/
|
|
30
|
+
catalogProvider?: string;
|
|
11
31
|
/**
|
|
12
32
|
* Anything else this project queues. Runs for every message, after the
|
|
13
33
|
* catalog dispatch above, so a project can add its own kinds without
|
package/dist/queues/consumer.js
CHANGED
|
@@ -25,12 +25,15 @@ import { affectsCatalog } from "./messages.js";
|
|
|
25
25
|
* ```
|
|
26
26
|
*/
|
|
27
27
|
export function astroidQueueHandler(options = {}) {
|
|
28
|
+
const owns = (provider) => options.catalogProvider === undefined || provider === options.catalogProvider;
|
|
28
29
|
return async (message) => {
|
|
29
30
|
if (message.kind === "catalog_refresh") {
|
|
30
|
-
await options.refreshCatalog?.();
|
|
31
|
+
await options.refreshCatalog?.(message);
|
|
31
32
|
}
|
|
32
|
-
else if (message.kind === "webhook" &&
|
|
33
|
-
|
|
33
|
+
else if (message.kind === "webhook" &&
|
|
34
|
+
owns(message.provider) &&
|
|
35
|
+
affectsCatalog(message.provider, message.type)) {
|
|
36
|
+
await options.refreshCatalog?.(message);
|
|
34
37
|
}
|
|
35
38
|
await options.onMessage?.(message);
|
|
36
39
|
};
|
package/dist/queues/messages.js
CHANGED
|
@@ -44,6 +44,10 @@ export function astroidCrons(config) {
|
|
|
44
44
|
const catalog = astroidCron(config);
|
|
45
45
|
if (catalog)
|
|
46
46
|
crons.push(catalog);
|
|
47
|
+
// Project-declared triggers last, so the two derived ones keep their existing
|
|
48
|
+
// positions and an added cron can't renumber anything.
|
|
49
|
+
for (const cron of config.crons ?? [])
|
|
50
|
+
crons.push(cron.expression);
|
|
47
51
|
return crons;
|
|
48
52
|
}
|
|
49
53
|
/** Binding name for the project's queue producer. */
|
package/dist/queues/scaffold.js
CHANGED
|
@@ -92,6 +92,10 @@ export function generateAstroidQueueSeam(config) {
|
|
|
92
92
|
// invoicing-only provider has nothing for this hook to do.
|
|
93
93
|
const provider = astroidCommerceRoles(config.commerce).storefront;
|
|
94
94
|
const table = astroidCatalogMirror(config).table;
|
|
95
|
+
// Two providers means the catalog belongs to exactly one of them, and the
|
|
96
|
+
// other one's webhooks must not drag it through a re-sync. Scaffold the scope
|
|
97
|
+
// in rather than leaving it to be discovered on a busy day (#294).
|
|
98
|
+
const multiProvider = astroidCommerceProviders(config.commerce).length > 1;
|
|
95
99
|
return [
|
|
96
100
|
"// The queue consumer — what each message actually does.",
|
|
97
101
|
"//",
|
|
@@ -110,6 +114,15 @@ export function generateAstroidQueueSeam(config) {
|
|
|
110
114
|
" message: AstroidQueueMessage,",
|
|
111
115
|
"): Promise<void> {",
|
|
112
116
|
" await astroidQueueHandler({",
|
|
117
|
+
...(multiProvider && provider
|
|
118
|
+
? [
|
|
119
|
+
` // This project runs more than one commerce provider, and the catalog is`,
|
|
120
|
+
` // ${provider}'s. Scoping the refresh keeps the OTHER provider's webhooks from`,
|
|
121
|
+
` // triggering it — Square alone emits an inventory event on every sale, which`,
|
|
122
|
+
` // unscoped would re-sync ${provider} once per transaction.`,
|
|
123
|
+
` catalogProvider: ${JSON.stringify(provider)},`,
|
|
124
|
+
]
|
|
125
|
+
: []),
|
|
113
126
|
" refreshCatalog: async () => {",
|
|
114
127
|
provider
|
|
115
128
|
? ` // TODO: fetch the ${provider} catalog, normalize each item with`
|
|
@@ -122,6 +135,22 @@ export function generateAstroidQueueSeam(config) {
|
|
|
122
135
|
` // const r = await astroidCatalogSync(items, { db: env.DB, table: ${JSON.stringify(table)} });`,
|
|
123
136
|
' // if (r.failed > 0) console.warn("[catalog] skipped", r.failed, r.errors);',
|
|
124
137
|
" //",
|
|
138
|
+
// Nobody is watching a queue message, which flips the retry trade-off that
|
|
139
|
+
// an attended checkout route settles the other way.
|
|
140
|
+
...(provider === "square"
|
|
141
|
+
? [
|
|
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.",
|
|
147
|
+
]
|
|
148
|
+
: [
|
|
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.",
|
|
152
|
+
]),
|
|
153
|
+
" //",
|
|
125
154
|
" // The sync is idempotent (keyed on the provider's id) and never",
|
|
126
155
|
" // writes an owner-edited column, so it's safe to run on every event.",
|
|
127
156
|
" //",
|
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
// create-astroid's schema generators, which call this function but never run the
|
|
13
13
|
// beforeChange hook below). Both entries are drizzle-free: `content/define` for
|
|
14
14
|
// the config types/builders, and `content/sections` for the write-time section
|
|
15
|
-
// validators. That second entry is what the Rule-evaluator split
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// validators. That second entry is what the Rule-evaluator split added, so this
|
|
16
|
+
// hook can import the validators STATICALLY instead of the dynamic
|
|
17
|
+
// `import("louise-toolkit/content")` it used to need to keep the CLI's graph
|
|
18
|
+
// drizzle-free.
|
|
19
|
+
//
|
|
20
|
+
// Note the entry named here is the PUBLIC subpath. Astroid must never reach into
|
|
21
|
+
// `louise-toolkit/src/...` — that resolves only because the workspace aliases the
|
|
22
|
+
// package to its source, and would break the moment astroid consumes a published
|
|
23
|
+
// tarball (#327).
|
|
19
24
|
import { defineCollection, } from "louise-toolkit/content/define";
|
|
20
25
|
import { assertValidSections, sanitizeSectionsRichText } from "louise-toolkit/content/sections";
|
|
21
26
|
import { sanitizeRichHtml } from "louise-toolkit/security";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AstroidConfig, TenancyConfig } from "../config.js";
|
|
2
|
+
/** Default internal prefix a tenant request is rewritten to. */
|
|
3
|
+
export declare const ASTROID_TENANT_PREFIX = "/t";
|
|
4
|
+
/**
|
|
5
|
+
* The Cloudflare zone a wildcard pattern belongs to.
|
|
6
|
+
*
|
|
7
|
+
* Defaults to the pattern minus its leading `*.`, which is right whenever the
|
|
8
|
+
* wildcard sits directly under the apex. A deeper pattern needs `zone` set
|
|
9
|
+
* explicitly: `*.shop.example.com` is served by the `example.com` zone, and
|
|
10
|
+
* guessing `shop.example.com` there produces a deploy error naming a zone that
|
|
11
|
+
* does not exist.
|
|
12
|
+
*/
|
|
13
|
+
export declare function tenancyZone(tenancy: TenancyConfig): string;
|
|
14
|
+
/**
|
|
15
|
+
* The subdomain label for a host under the wildcard, or `null` when the host is
|
|
16
|
+
* not a tenant candidate at all.
|
|
17
|
+
*
|
|
18
|
+
* `null` covers three distinct cases that all mean "render the ordinary site":
|
|
19
|
+
* the apex itself (a wildcard does not match its own apex), a host outside the
|
|
20
|
+
* pattern (a preview domain, `localhost`), and a reserved label.
|
|
21
|
+
*
|
|
22
|
+
* Exported and pure so a site can unit-test its own reserved list without
|
|
23
|
+
* standing up a request.
|
|
24
|
+
*/
|
|
25
|
+
export declare function tenantLabel(host: string, tenancy: TenancyConfig): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* The scaffold-once `src/tenancy.ts` — the seam holding every decision Astroid
|
|
28
|
+
* refuses to make for a site.
|
|
29
|
+
*
|
|
30
|
+
* Written once and then yours: what a label resolves to, whether the lookup is
|
|
31
|
+
* cached, and what an unknown label means are all questions with site-specific
|
|
32
|
+
* answers, and a framework that guessed them would be wrong in a different way
|
|
33
|
+
* for each project.
|
|
34
|
+
*/
|
|
35
|
+
export declare function generateAstroidTenancy(config: AstroidConfig): string | null;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// Wildcard host dispatch: the parts that are the same for every site, and
|
|
4
|
+
// nothing that decides anything.
|
|
5
|
+
//
|
|
6
|
+
// Astroid owns two things a site cannot own on its own — the wildcard Worker
|
|
7
|
+
// route (`hosts` can only express custom domains) and the single middleware file
|
|
8
|
+
// Astro permits. What a subdomain MEANS, whether the lookup is cached, and what
|
|
9
|
+
// an unknown host should do are all site policy, and live in the scaffolded
|
|
10
|
+
// `src/tenancy.ts`.
|
|
11
|
+
/** Default internal prefix a tenant request is rewritten to. */
|
|
12
|
+
export const ASTROID_TENANT_PREFIX = "/t";
|
|
13
|
+
/**
|
|
14
|
+
* The Cloudflare zone a wildcard pattern belongs to.
|
|
15
|
+
*
|
|
16
|
+
* Defaults to the pattern minus its leading `*.`, which is right whenever the
|
|
17
|
+
* wildcard sits directly under the apex. A deeper pattern needs `zone` set
|
|
18
|
+
* explicitly: `*.shop.example.com` is served by the `example.com` zone, and
|
|
19
|
+
* guessing `shop.example.com` there produces a deploy error naming a zone that
|
|
20
|
+
* does not exist.
|
|
21
|
+
*/
|
|
22
|
+
export function tenancyZone(tenancy) {
|
|
23
|
+
return tenancy.zone ?? tenancy.hostPattern.replace(/^\*\./, "");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The subdomain label for a host under the wildcard, or `null` when the host is
|
|
27
|
+
* not a tenant candidate at all.
|
|
28
|
+
*
|
|
29
|
+
* `null` covers three distinct cases that all mean "render the ordinary site":
|
|
30
|
+
* the apex itself (a wildcard does not match its own apex), a host outside the
|
|
31
|
+
* pattern (a preview domain, `localhost`), and a reserved label.
|
|
32
|
+
*
|
|
33
|
+
* Exported and pure so a site can unit-test its own reserved list without
|
|
34
|
+
* standing up a request.
|
|
35
|
+
*/
|
|
36
|
+
export function tenantLabel(host, tenancy) {
|
|
37
|
+
const suffix = tenancy.hostPattern.replace(/^\*\./, "");
|
|
38
|
+
// Strip a port: `acme.example.com:8788` under `wrangler dev`.
|
|
39
|
+
const hostname = host.split(":")[0]?.toLowerCase() ?? "";
|
|
40
|
+
if (!hostname.endsWith(`.${suffix}`))
|
|
41
|
+
return null;
|
|
42
|
+
const label = hostname.slice(0, -(suffix.length + 1));
|
|
43
|
+
// Only a single label is a tenant. `a.b.example.com` under `*.example.com` is
|
|
44
|
+
// not `a.b` — Cloudflare's wildcard matches one level, and treating a dotted
|
|
45
|
+
// string as a slug would put a `/` in a rewrite path.
|
|
46
|
+
if (!label || label.includes("."))
|
|
47
|
+
return null;
|
|
48
|
+
const reserved = tenancy.reserved ?? [];
|
|
49
|
+
return reserved.includes(label) ? null : label;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The scaffold-once `src/tenancy.ts` — the seam holding every decision Astroid
|
|
53
|
+
* refuses to make for a site.
|
|
54
|
+
*
|
|
55
|
+
* Written once and then yours: what a label resolves to, whether the lookup is
|
|
56
|
+
* cached, and what an unknown label means are all questions with site-specific
|
|
57
|
+
* answers, and a framework that guessed them would be wrong in a different way
|
|
58
|
+
* for each project.
|
|
59
|
+
*/
|
|
60
|
+
export function generateAstroidTenancy(config) {
|
|
61
|
+
const tenancy = config.tenancy;
|
|
62
|
+
if (!tenancy)
|
|
63
|
+
return null;
|
|
64
|
+
const prefix = tenancy.rewritePrefix ?? ASTROID_TENANT_PREFIX;
|
|
65
|
+
const example = (tenancy.hostPattern ?? "*.example.com").replace(/^\*\./, "");
|
|
66
|
+
return [
|
|
67
|
+
"// Scaffolded once by astroidjs — yours to edit.",
|
|
68
|
+
"//",
|
|
69
|
+
"// What a subdomain MEANS. The generated middleware has already decided this",
|
|
70
|
+
"// host is a tenant candidate (it matches your wildcard and is not reserved);",
|
|
71
|
+
"// everything after that is your call.",
|
|
72
|
+
"//",
|
|
73
|
+
`// A match rewrites internally to \`${prefix}/<slug>/…\`, so put the pages under`,
|
|
74
|
+
`// \`src/pages${prefix}/[tenant]/\`. The visitor's URL never changes.`,
|
|
75
|
+
"",
|
|
76
|
+
"/** What your pages read off `Astro.locals.tenant`. Widen it freely. */",
|
|
77
|
+
"export interface Tenant {",
|
|
78
|
+
" /** Used to build the internal path, so keep it URL-safe. */",
|
|
79
|
+
" slug: string;",
|
|
80
|
+
"}",
|
|
81
|
+
"",
|
|
82
|
+
"/**",
|
|
83
|
+
" * Resolve a subdomain label to a tenant, or `null` if there isn't one.",
|
|
84
|
+
" *",
|
|
85
|
+
" * `null` falls through to the ordinary site — which is a real choice, not a",
|
|
86
|
+
" * default: a stranger's subdomain then renders your homepage. If that is wrong",
|
|
87
|
+
" * for this project, return null here and refuse it in the middleware's `guard`",
|
|
88
|
+
" * with a 404, so an unknown host is unambiguously not a page.",
|
|
89
|
+
" *",
|
|
90
|
+
" * This runs on EVERY request to a tenant host, so a database lookup here is a",
|
|
91
|
+
" * query per request. Cache it — a module-scope Map is enough within an isolate,",
|
|
92
|
+
" * KV if the tenant set is large or changes without a deploy.",
|
|
93
|
+
" */",
|
|
94
|
+
"export async function resolveTenant(label: string): Promise<Tenant | null> {",
|
|
95
|
+
` // TODO(astroid): look \`label\` up — a D1 table, a KV entry, or a literal map`,
|
|
96
|
+
` // while the set is small. Example: acme.${example} → { slug: "acme" }.`,
|
|
97
|
+
" return { slug: label };",
|
|
98
|
+
"}",
|
|
99
|
+
"",
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
package/dist/worker/generate.js
CHANGED
|
@@ -17,6 +17,7 @@ import { astroidPortal } from "../portal/config.js";
|
|
|
17
17
|
import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "../queues/messages.js";
|
|
18
18
|
import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, usesRealtime, } from "../realtime/scaffold.js";
|
|
19
19
|
import { capturesInquiries } from "../schema/framework.js";
|
|
20
|
+
import { ASTROID_TENANT_PREFIX } from "../tenancy/index.js";
|
|
20
21
|
import { astroidEditorRoutePlan } from "./routes.js";
|
|
21
22
|
// Astroid's default editable site_settings surface — the columns the Settings
|
|
22
23
|
// panel may write, and which of them hold a media-library image URL.
|
|
@@ -62,6 +63,8 @@ export function generateAstroidWorker(config) {
|
|
|
62
63
|
// scaffold, which is exactly how it got caught.
|
|
63
64
|
// Same trap as realtimeRoute: these live outside `louise-toolkit/editor`.
|
|
64
65
|
const realtimeRouteFactories = new Set(["realtimeRoute", "vitalsRoute"]);
|
|
66
|
+
// The routes that take an AI runner, and so need `aiRunner` imported.
|
|
67
|
+
const AI_ROUTES = new Set(["ai", "seoFix", "media"]);
|
|
65
68
|
const editorImports = [
|
|
66
69
|
"DEFAULT_PAGE_FIELDS",
|
|
67
70
|
...new Set(plan.map((route) => route.factory).filter((f) => !realtimeRouteFactories.has(f))),
|
|
@@ -112,15 +115,19 @@ export function generateAstroidWorker(config) {
|
|
|
112
115
|
: "";
|
|
113
116
|
return `settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: MEDIA_BASE${customArg} })`;
|
|
114
117
|
}
|
|
118
|
+
// `aiRunner` rather than `(env) => env.AI`: it reads the binding AND the
|
|
119
|
+
// LOUISE_AI kill switch, so all three assists share one definition of
|
|
120
|
+
// "is generation on?" instead of each re-deriving it. Embeddings keep
|
|
121
|
+
// binding-presence as their switch — see the helper's comment.
|
|
115
122
|
case "ai":
|
|
116
|
-
return "aiRoute({ resolveEditor, ai:
|
|
123
|
+
return "aiRoute({ resolveEditor, ai: aiRunner })";
|
|
117
124
|
case "seoFix":
|
|
118
|
-
return "seoFixRoute({ table: pages, resolveEditor, ai:
|
|
125
|
+
return "seoFixRoute({ table: pages, resolveEditor, ai: aiRunner })";
|
|
119
126
|
case "media":
|
|
120
127
|
// `altText` fills a new upload's alt from the image itself. Best-effort
|
|
121
128
|
// by contract — a model error or a missing binding never fails the
|
|
122
129
|
// upload — so it costs nothing on a project that doesn't want it.
|
|
123
|
-
return "mediaRoute({ table: media, resolveEditor, referenceSources: MEDIA_REFERENCE_SOURCES, altText:
|
|
130
|
+
return "mediaRoute({ table: media, resolveEditor, referenceSources: MEDIA_REFERENCE_SOURCES, altText: aiRunner })";
|
|
124
131
|
case "editors":
|
|
125
132
|
// The editor instance's user table is `louise_`-prefixed (the editor
|
|
126
133
|
// convention — the unprefixed `user` table is left for a second/portal
|
|
@@ -159,6 +166,11 @@ export function generateAstroidWorker(config) {
|
|
|
159
166
|
p('import { defineForm } from "louise-toolkit/forms";');
|
|
160
167
|
if (queues)
|
|
161
168
|
p('import { processBatch } from "louise-toolkit/queues";');
|
|
169
|
+
// Only when a route actually takes a runner — a project with no AI assists
|
|
170
|
+
// should not import one, and knip would flag it if it did.
|
|
171
|
+
if (plan.some((route) => AI_ROUTES.has(route.name))) {
|
|
172
|
+
p('import { aiRunner } from "louise-toolkit/ai";');
|
|
173
|
+
}
|
|
162
174
|
p('import { checkLinks } from "louise-toolkit/browser";');
|
|
163
175
|
p('import { readHealthSummary, summarizeHealth, writeHealthSummary } from "louise-toolkit/health";');
|
|
164
176
|
p('import { composeWorker, isEditRequest, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
|
|
@@ -393,6 +405,17 @@ export function generateAstroidWorker(config) {
|
|
|
393
405
|
p(" // serves stale data until a human notices. Enqueued rather than run");
|
|
394
406
|
p(" // inline so it takes the same retry + DLQ path as everything else.");
|
|
395
407
|
p(' ctx.waitUntil(env.COMMERCE_QUEUE.send({ kind: "catalog_refresh" }));');
|
|
408
|
+
p(" return;");
|
|
409
|
+
p(" }");
|
|
410
|
+
}
|
|
411
|
+
// Project-declared crons (`config.crons`). Emitted from the same list that
|
|
412
|
+
// feeds `triggers.crons`, so a trigger can't exist with no branch to match it.
|
|
413
|
+
for (const custom of config.crons ?? []) {
|
|
414
|
+
p(` if (controller.cron === ${JSON.stringify(custom.expression)}) {`);
|
|
415
|
+
p(" // Enqueued, not run inline: same retry + DLQ path as everything");
|
|
416
|
+
p(" // else, and a slow job can't hold the scheduled handler open.");
|
|
417
|
+
p(` ctx.waitUntil(env.COMMERCE_QUEUE.send(${JSON.stringify(custom.message)}));`);
|
|
418
|
+
p(" return;");
|
|
396
419
|
p(" }");
|
|
397
420
|
}
|
|
398
421
|
p(" },");
|
|
@@ -437,6 +460,8 @@ export function generateAstroidMiddleware(config) {
|
|
|
437
460
|
// inlined @font-face needs no manual `font-src` entry.
|
|
438
461
|
const cspStyleSrc = "'self' 'unsafe-inline'";
|
|
439
462
|
const portal = astroidPortal(config);
|
|
463
|
+
const tenancy = config.tenancy;
|
|
464
|
+
const rewritePrefix = tenancy?.rewritePrefix ?? ASTROID_TENANT_PREFIX;
|
|
440
465
|
return [
|
|
441
466
|
"// Generated by astroidjs — do not hand-edit.",
|
|
442
467
|
"// The shared Louise middleware: rate-limit the unauthenticated POST surfaces,",
|
|
@@ -446,10 +471,22 @@ export function generateAstroidMiddleware(config) {
|
|
|
446
471
|
"// styles + inlined data: brand font are allowed.",
|
|
447
472
|
'import { env } from "cloudflare:workers";',
|
|
448
473
|
'import { createLouiseMiddleware } from "louise-toolkit/astro";',
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
474
|
+
// One `astroidjs` import, composed from what this config actually uses —
|
|
475
|
+
// two import statements for the same module is legal and reads as an
|
|
476
|
+
// oversight in a file nobody is supposed to hand-edit.
|
|
477
|
+
`import { ${[
|
|
478
|
+
...(portal ? ["astroidPortalGuardConfig"] : []),
|
|
479
|
+
"astroidRateRules",
|
|
480
|
+
...(portal ? ["guardResponse", "portalGuard", "resolvePortalSession"] : []),
|
|
481
|
+
...(tenancy ? ["tenantLabel"] : []),
|
|
482
|
+
].join(", ")} } from "astroidjs";`,
|
|
452
483
|
'import astroidConfig from "../astroid.config.js";',
|
|
484
|
+
...(tenancy
|
|
485
|
+
? [
|
|
486
|
+
"// TODO(astroid): your TENANT seam — what a subdomain maps to (src/tenancy.ts).",
|
|
487
|
+
'import { resolveTenant } from "./tenancy.js";',
|
|
488
|
+
]
|
|
489
|
+
: []),
|
|
453
490
|
"// TODO(astroid): your AUTH seam — same resolveEditor as the generated worker.ts.",
|
|
454
491
|
'import { resolveEditor } from "./auth.js";',
|
|
455
492
|
// The portal's resolver lives in its OWN module, not the editor's auth
|
|
@@ -464,6 +501,14 @@ export function generateAstroidMiddleware(config) {
|
|
|
464
501
|
"// `env.RL` is read per request (a getter) — a KV binding is only valid in",
|
|
465
502
|
"// request scope.",
|
|
466
503
|
"const RATE_RULES = astroidRateRules(astroidConfig);",
|
|
504
|
+
...(tenancy
|
|
505
|
+
? [
|
|
506
|
+
"",
|
|
507
|
+
"// Read from the config rather than restated here, so the reserved list and",
|
|
508
|
+
"// the wildcard pattern can't drift from the Worker route generated for them.",
|
|
509
|
+
"const TENANCY = astroidConfig.tenancy!;",
|
|
510
|
+
]
|
|
511
|
+
: []),
|
|
467
512
|
...(portal
|
|
468
513
|
? [
|
|
469
514
|
"const PORTAL_GUARD = astroidPortalGuardConfig(astroidConfig)!;",
|
|
@@ -478,12 +523,31 @@ export function generateAstroidMiddleware(config) {
|
|
|
478
523
|
"export const onRequest = createLouiseMiddleware({",
|
|
479
524
|
" resolveEditor: (request) => resolveEditor(request),",
|
|
480
525
|
" rateLimit: { rules: RATE_RULES, kv: () => env.RL },",
|
|
481
|
-
|
|
526
|
+
// `extend` runs once and may need to populate BOTH — a tenanted site with a
|
|
527
|
+
// portal resolves a tenant and a customer on the same request.
|
|
528
|
+
...(portal || tenancy
|
|
482
529
|
? [
|
|
483
530
|
" extend: async (context) => {",
|
|
484
|
-
|
|
485
|
-
|
|
531
|
+
...(portal
|
|
532
|
+
? [
|
|
533
|
+
" const user = await resolvePortalSession(context.request, resolvePortalUser);",
|
|
534
|
+
" context.locals.portalUser = user;",
|
|
535
|
+
]
|
|
536
|
+
: []),
|
|
537
|
+
...(tenancy
|
|
538
|
+
? [
|
|
539
|
+
" // The label, or null for the apex / a reserved label / an off-pattern",
|
|
540
|
+
" // host. `resolveTenant` is yours (src/tenancy.ts): it decides what a",
|
|
541
|
+
" // label maps to and whether that lookup is cached.",
|
|
542
|
+
" const label = tenantLabel(context.url.hostname, TENANCY);",
|
|
543
|
+
" context.locals.tenant = label ? await resolveTenant(label) : null;",
|
|
544
|
+
]
|
|
545
|
+
: []),
|
|
486
546
|
" },",
|
|
547
|
+
]
|
|
548
|
+
: []),
|
|
549
|
+
...(portal
|
|
550
|
+
? [
|
|
487
551
|
" // Route guard: the declarative prefix→roles table from your config.",
|
|
488
552
|
" // An /api/* route always answers in JSON — redirecting fetch() to an",
|
|
489
553
|
" // HTML login page returns 200 and markup, which reads as success.",
|
|
@@ -499,6 +563,22 @@ export function generateAstroidMiddleware(config) {
|
|
|
499
563
|
" },",
|
|
500
564
|
]
|
|
501
565
|
: []),
|
|
566
|
+
...(tenancy
|
|
567
|
+
? [
|
|
568
|
+
" // Host dispatch: map the resolved tenant onto an internal path prefix.",
|
|
569
|
+
" // Runs after `guard`, so route policy stays written against the PUBLIC",
|
|
570
|
+
" // path. The visitor's URL is unchanged — an internal rewrite, not a",
|
|
571
|
+
" // redirect — so links built from Astro.url stay correct.",
|
|
572
|
+
" //",
|
|
573
|
+
" // An unknown subdomain is YOUR decision: `resolveTenant` returning null",
|
|
574
|
+
" // falls through to the ordinary site below. Return a 404 from `guard`",
|
|
575
|
+
" // instead if a stranger's subdomain should not render your homepage.",
|
|
576
|
+
" rewrite: (context) => {",
|
|
577
|
+
" const tenant = context.locals.tenant;",
|
|
578
|
+
` return tenant ? \`${rewritePrefix}/\${tenant.slug}\${context.url.pathname}\` : undefined;`,
|
|
579
|
+
" },",
|
|
580
|
+
]
|
|
581
|
+
: []),
|
|
502
582
|
" // Rewrite Astro's hash-based style-src (owned by astroidSecurity in",
|
|
503
583
|
' // astro.config.mjs) to permit Louise\'s data-driven style="" + editor styles.',
|
|
504
584
|
` cspStyleSrc: ${JSON.stringify(cspStyleSrc)},`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astroidjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -60,19 +60,24 @@
|
|
|
60
60
|
"access": "public"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"louise-toolkit": "0.
|
|
63
|
+
"louise-toolkit": "0.23.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@types/node": "^24.13.3",
|
|
67
67
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
68
|
+
"astro": "^7.0.9",
|
|
68
69
|
"solid-js": "^1.9.14",
|
|
69
70
|
"typescript": "^5.8.0",
|
|
70
71
|
"vitest": "^4.1.10"
|
|
71
72
|
},
|
|
72
73
|
"peerDependencies": {
|
|
74
|
+
"astro": "^7.0.9",
|
|
73
75
|
"solid-js": "^1.9.0"
|
|
74
76
|
},
|
|
75
77
|
"peerDependenciesMeta": {
|
|
78
|
+
"astro": {
|
|
79
|
+
"optional": true
|
|
80
|
+
},
|
|
76
81
|
"solid-js": {
|
|
77
82
|
"optional": true
|
|
78
83
|
}
|
|
@@ -12,10 +12,16 @@
|
|
|
12
12
|
// `data-louise-field="<collection>:<key>:<field>"` (saved as a
|
|
13
13
|
// versioned draft when the page is mounted with `versionedPageId`).
|
|
14
14
|
// • SECTION field — pass `base` (this item's path, e.g. `"2"` or `"2.blocks.0"`)
|
|
15
|
-
// plus `field`; emits `data-louise-
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// plus `field`; emits `data-louise-node="<base>.<field>"` for
|
|
16
|
+
// the on-canvas section editor. `sfield` stays as the escape
|
|
17
|
+
// hatch for a path you build yourself — an array entry:
|
|
18
|
+
// sfield={`${base}.items.${i}.title`}.
|
|
19
|
+
//
|
|
20
|
+
// Since ADR 0010 A2 this is the SAME attribute the section
|
|
21
|
+
// boundary carries, one path deeper. The catalog says whether a
|
|
22
|
+
// field is edited in place and with which editor, so `type` and
|
|
23
|
+
// `multiline` no longer affect a section field's markup — they
|
|
24
|
+
// are accepted for compatibility and ignored.
|
|
19
25
|
//
|
|
20
26
|
// The `base` form is what ADR 0005 §2 asks for: "a site author writes `<Editable
|
|
21
27
|
// field="heading">` and never hand-stamps the deeper path". `<Section>` supplies
|
|
@@ -76,20 +82,26 @@ const editing = edit ?? (Astro.locals as { editMode?: boolean }).editMode ?? fal
|
|
|
76
82
|
// spreading that into `Record<string, string>` is a type error.
|
|
77
83
|
const richText: Record<string, string> =
|
|
78
84
|
type === "richtext" ? { "data-louise-type": "richtext" } : {};
|
|
79
|
-
const multilineMarker: Record<string, string> = multiline ? { "data-louise-multiline": "" } : {};
|
|
80
85
|
|
|
81
86
|
let markers: Record<string, string> = {};
|
|
82
87
|
if (editing) {
|
|
83
88
|
if (sectionPath) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
// ONE marker (ADR 0010 A2). This used to emit `data-louise-sfield` plus
|
|
90
|
+
// `data-louise-type="richtext"` plus `data-louise-multiline` — three
|
|
91
|
+
// attributes describing a field the catalog already fully describes. The
|
|
92
|
+
// editor now reads the field's type to know whether it's rich text and
|
|
93
|
+
// whether it holds more than one line, so `type` and `multiline` are inert
|
|
94
|
+
// here and kept only so an existing call site doesn't fail to compile.
|
|
95
|
+
markers = { "data-louise-node": sectionPath };
|
|
89
96
|
} else if (collection && key !== undefined && field) {
|
|
97
|
+
// The PAGE-field contract is untouched: a collection row's field is not a
|
|
98
|
+
// node in a sections tree, so it keeps its own marker and its own type hint.
|
|
90
99
|
markers = { "data-louise-field": `${collection}:${key}:${field}`, ...richText };
|
|
91
100
|
}
|
|
92
101
|
}
|
|
102
|
+
// Referenced so the deprecated prop doesn't read as unused while it's still
|
|
103
|
+
// accepted; it no longer affects the markup.
|
|
104
|
+
void multiline;
|
|
93
105
|
---
|
|
94
106
|
|
|
95
107
|
<Tag {...rest} {...markers}><slot /></Tag>
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
// Two marker responsibilities:
|
|
12
12
|
// • the BOUNDARY (`data-louise-node="<path>"`, ADR 0010) is stamped here,
|
|
13
13
|
// because only the dispatcher knows an item's position;
|
|
14
|
-
// • the FIELDS
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// • the FIELDS are stamped by `<Editable base={base} field="…">` inside each
|
|
15
|
+
// component, because only the component knows which of its text nodes are
|
|
16
|
+
// editable. Since A2 those carry the SAME `data-louise-node` attribute, one
|
|
17
|
+
// path deeper — the catalog says whether a field is edited in place, so the
|
|
18
|
+
// marker no longer has to.
|
|
18
19
|
//
|
|
19
20
|
// Blocks recurse through this same component with a deeper `base`. That's the
|
|
20
21
|
// whole trick: a component never learns its own depth, so a type that renders as
|
|
@@ -53,7 +53,7 @@ const mediaMeta =
|
|
|
53
53
|
|
|
54
54
|
{/*
|
|
55
55
|
The host element. `mountSections(el, opts)` takes the element to scan for
|
|
56
|
-
`[data-louise-
|
|
56
|
+
`[data-louise-node]` markers and to hang on-canvas chrome off, so rendering
|
|
57
57
|
it here — rather than asking every page to remember a wrapper — is what makes
|
|
58
58
|
`<Sections>` self-sufficient: drop it on a page and the editor can find it.
|
|
59
59
|
A plain block wrapper, not `display: contents`, because the chrome positions
|