astroidjs 0.15.0 → 0.17.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.
@@ -21,6 +21,7 @@
21
21
  // `louise-toolkit/src/...`—that resolves only because the workspace aliases the
22
22
  // package to its source, and would break the moment astroid consumes a published
23
23
  // tarball (#327).
24
+ import { astroidMediaBase } from "./media-base.js";
24
25
  import { defineCollection, } from "louise-toolkit/content/define";
25
26
  import { assertValidSections, sanitizeSectionsRichText } from "louise-toolkit/content/sections";
26
27
  import { sanitizeRichHtml } from "louise-toolkit/security";
@@ -39,10 +40,9 @@ import { astroidSectionCatalog } from "../components/sections.js";
39
40
  * Validated by `defineCollection` at build time, so a malformed field shape throws
40
41
  * here rather than at codegen.
41
42
  */
42
- /** The media base a `pages` write sanitizes rich content against (config, `/media` default). */
43
- function pageMediaBase(config) {
44
- return config.deploy?.mediaBase ?? "/media";
45
- }
43
+ /** The media base a `pages` write sanitizes rich content against, read when the
44
+ * write runs so a Preview checks against its own base (see media-base.ts). */
45
+ const pageMediaBase = astroidMediaBase;
46
46
  /**
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
@@ -113,12 +113,11 @@ export const ASTROID_RESERVED_SLUGS = [
113
113
  "sitemap.xml",
114
114
  ];
115
115
  export function astroidPagesWriteHooks(config, site = {}) {
116
- const mediaBase = pageMediaBase(config);
117
116
  return {
118
117
  // `body` is a richField, so it goes through pagesRoute's own sanitize seam—with
119
118
  // the project media base, matching the hook rather than the toolkit
120
119
  // default sanitizer that knows no media base.
121
- sanitize: (html) => sanitizeRichHtml(html, { mediaBase }),
120
+ sanitize: (html) => sanitizeRichHtml(html, { mediaBase: pageMediaBase(config) }),
122
121
  // `sections` is not a richField, so it's sanitized here in the transform,
123
122
  // which pagesRoute runs BEFORE validate—the hook's sanitize-then-validate
124
123
  // order. The site's own transform runs first, so Astroid's sanitize sees
@@ -136,7 +135,6 @@ export function astroidPagesCollection(config) {
136
135
  // staged as a draft, so sanitize it on every write—never store raw HTML. A
137
136
  // pasted `<img>` pointing off-origin (a hotlink) is dropped: body images must
138
137
  // live in the media library. Mirrors the reference site's pages-collection hook.
139
- const mediaBase = pageMediaBase(config);
140
138
  const fields = {};
141
139
  fields.slug = { type: "text", required: true };
142
140
  fields.title = { type: "text", required: true };
@@ -158,7 +156,10 @@ export function astroidPagesCollection(config) {
158
156
  async ({ data }) => {
159
157
  let next = data;
160
158
  if (typeof next.body === "string") {
161
- next = { ...next, body: sanitizeRichHtml(next.body, { mediaBase }) };
159
+ next = {
160
+ ...next,
161
+ body: sanitizeRichHtml(next.body, { mediaBase: pageMediaBase(config) }),
162
+ };
162
163
  }
163
164
  // Sanitize BEFORE validating: a richText field stores HTML, and
164
165
  // validating the raw value would pass content the sanitizer is about
@@ -1,3 +1,4 @@
1
1
  export * from "./collections.js";
2
2
  export * from "./framework.js";
3
3
  export * from "./generate.js";
4
+ export * from "./media-base.js";
@@ -4,3 +4,4 @@
4
4
  export * from "./collections.js";
5
5
  export * from "./framework.js";
6
6
  export * from "./generate.js";
7
+ export * from "./media-base.js";
@@ -0,0 +1,13 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /**
3
+ * Record the running deployment's media base, `vars.MEDIA_URL`. The generated
4
+ * worker calls this at startup; an empty or absent value leaves the config's
5
+ * base in effect.
6
+ */
7
+ export declare function setAstroidMediaBase(base: string | undefined): void;
8
+ /**
9
+ * The media base a write is checked against: the running deployment's when the
10
+ * worker recorded one, else `deploy.mediaBase`, else `/media`. Call it when the
11
+ * check runs, not when it's built.
12
+ */
13
+ export declare function astroidMediaBase(config: AstroidConfig): string;
@@ -0,0 +1,33 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The media base the running Worker serves from, for the checks that can't read
4
+ // a request's `env`.
5
+ //
6
+ // The page sanitizers and the settings action are built once, from the config,
7
+ // and drizzle-kit loads the schema that builds them outside a Worker. So they
8
+ // can't take `env.MEDIA_URL` the way the generated routes do. Left on the fixed
9
+ // `deploy.mediaBase`, a staging Preview, which serves media from its own
10
+ // `/media`, drops every image uploaded on it as a hotlink.
11
+ //
12
+ // So the generated worker records `MEDIA_URL` once, at startup, and these checks
13
+ // read it when they run. That's safe as module state because a Worker isolate
14
+ // runs one deployment, and `MEDIA_URL` is fixed per deployment: production's
15
+ // isolates hold production's base, and a Preview's hold the Preview's.
16
+ let runtimeMediaBase;
17
+ /**
18
+ * Record the running deployment's media base, `vars.MEDIA_URL`. The generated
19
+ * worker calls this at startup; an empty or absent value leaves the config's
20
+ * base in effect.
21
+ */
22
+ export function setAstroidMediaBase(base) {
23
+ const trimmed = base?.replace(/\/+$/, "");
24
+ runtimeMediaBase = trimmed ? trimmed : undefined;
25
+ }
26
+ /**
27
+ * The media base a write is checked against: the running deployment's when the
28
+ * worker recorded one, else `deploy.mediaBase`, else `/media`. Call it when the
29
+ * check runs, not when it's built.
30
+ */
31
+ export function astroidMediaBase(config) {
32
+ return runtimeMediaBase ?? (config.deploy?.mediaBase ?? "/media").replace(/\/+$/, "");
33
+ }
@@ -68,6 +68,7 @@ export function generateAstroidWorker(config) {
68
68
  const AI_ROUTES = new Set(["ai", "seoFix", "media"]);
69
69
  const editorImports = [
70
70
  "DEFAULT_PAGE_FIELDS",
71
+ "type PagesWrite",
71
72
  ...new Set(plan.map((route) => route.factory).filter((f) => !realtimeRouteFactories.has(f))),
72
73
  ].sort();
73
74
  const tables = [
@@ -104,8 +105,8 @@ export function generateAstroidWorker(config) {
104
105
  //
105
106
  // `versionsTable` makes a DELETE remove the page's version snapshots,
106
107
  // which have no foreign key to the page and would otherwise orphan.
107
- // `afterWrite` rebuilds the search index, which plain CRUD writes leave
108
- // stale.
108
+ // `afterWrite` syncs the written page's search index entry, which plain
109
+ // CRUD writes leave stale.
109
110
  return 'pagesRoute({ table: pages, versionsTable: pagesVersions, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"], ...pagesWriteHooks, afterWrite: reindexPagesSearch })';
110
111
  case "save":
111
112
  // No `bufferKv` here, deliberately: `saveRoute` has no such option. It
@@ -122,7 +123,9 @@ export function generateAstroidWorker(config) {
122
123
  // The site's sanitize + read hooks (config.settings.hooks), spread last
123
124
  // so they add to the call rather than replace any part of it.
124
125
  const hooksArg = config.settings?.hooks ? ", ...settingsHooks" : "";
125
- return `settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: MEDIA_BASE${customArg}${hooksArg} })`;
126
+ // Built per request, because the media base is per environment: a
127
+ // Preview's `MEDIA_URL` differs from production's (see `mediaBaseOf`).
128
+ return `(request, env, ctx) => settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: mediaBaseOf(env)${customArg}${hooksArg} })(request, env, ctx)`;
126
129
  }
127
130
  // `aiRunner` rather than `(env) => env.AI`: it reads the binding AND the
128
131
  // LOUISE_AI kill switch, so all three assists share one definition of
@@ -173,7 +176,7 @@ export function generateAstroidWorker(config) {
173
176
  p('import { env } from "cloudflare:workers";');
174
177
  p('import { handle } from "@astrojs/cloudflare/handler";');
175
178
  p('import type { EditorSession } from "louise-toolkit/auth";');
176
- p('import { createLocalApi } from "louise-toolkit/content";');
179
+ p('import { reindexDoc } from "louise-toolkit/content";');
177
180
  p(inquiries
178
181
  ? 'import { db, inquiriesForm } from "louise-toolkit/db";'
179
182
  : 'import { db } from "louise-toolkit/db";');
@@ -201,6 +204,7 @@ export function generateAstroidWorker(config) {
201
204
  "astroidPagesCollection",
202
205
  "astroidPagesWriteHooks",
203
206
  "readModuleSecret",
207
+ "setAstroidMediaBase",
204
208
  ...(inquiries ? ["sendInquiryMail"] : []),
205
209
  ...(queues ? ["type AstroidQueueMessage"] : []),
206
210
  ].sort();
@@ -228,7 +232,18 @@ export function generateAstroidWorker(config) {
228
232
  p('import { handleQueueMessage } from "./queue.js";');
229
233
  }
230
234
  p();
231
- p(`const MEDIA_BASE = ${JSON.stringify(mediaBase)};`);
235
+ p(`const DEFAULT_MEDIA_BASE = ${JSON.stringify(mediaBase)};`);
236
+ p("// The public media base for this request's environment. `vars.MEDIA_URL`");
237
+ p('// wins, so a Preview sets its own (a path such as "/media", served from the');
238
+ p("// Preview's own host, since a Preview can't know its hostname in advance).");
239
+ p("// Read per request, not baked in: one build serves production and every");
240
+ p("// Preview, so nothing that differs between them can be a constant.");
241
+ p("const mediaBaseOf = (env: CloudflareEnv): string =>");
242
+ p(' (env.MEDIA_URL || DEFAULT_MEDIA_BASE).replace(/\\/+$/, "");');
243
+ p("// The checks built once from the config (the page sanitizers, the settings");
244
+ p("// action) read the base when they run. Recorded at startup, since an isolate");
245
+ p("// runs one deployment and `MEDIA_URL` is fixed per deployment.");
246
+ p("setAstroidMediaBase(env.MEDIA_URL);");
232
247
  p("const pagesCollection = astroidPagesCollection(astroidConfig);");
233
248
  p("// Sanitize + section-catalog validation for the raw pagesRoute, which runs");
234
249
  p("// no collection hook—the same contract versionsRoute gets from the config.");
@@ -237,10 +252,12 @@ export function generateAstroidWorker(config) {
237
252
  : "const pagesWriteHooks = astroidPagesWriteHooks(astroidConfig);");
238
253
  p();
239
254
  p("// pagesRoute writes with plain Drizzle, so the full-text index doesn't see a");
240
- p("// title or slug change until something rebuilds it. Best-effort: pagesRoute");
241
- p("// swallows a throw here, so a stale index never fails the write itself.");
242
- p("async function reindexPagesSearch(editor: EditorSession): Promise<void> {");
243
- p(" await createLocalApi(db(env.DB), pages, pagesCollection).reindexSearch({ session: editor });");
255
+ p("// title or slug change until something syncs it. This syncs only the page the");
256
+ p("// write touched, rather than rebuilding the whole index on every save. After a");
257
+ p("// delete the row is gone, and reindexDoc removes its entry. Best-effort:");
258
+ p("// pagesRoute swallows a throw here, so a stale index never fails the write.");
259
+ p("async function reindexPagesSearch(_editor: EditorSession, { id }: PagesWrite): Promise<void> {");
260
+ p(" await reindexDoc(db(env.DB), pages, pagesCollection, id);");
244
261
  p("}");
245
262
  p();
246
263
  p("// Editable site_settings columns the Settings panel may write, and which of");
@@ -294,7 +311,7 @@ export function generateAstroidWorker(config) {
294
311
  p("// failed crawl or a failed COUNT yields zero rather than aborting the scan,");
295
312
  p("// because a partial health report is worth strictly more than none.");
296
313
  p("async function runHealthScan(env: CloudflareEnv) {");
297
- p(" const origin = env.SITE_URL ?? MEDIA_BASE;");
314
+ p(" const origin = env.SITE_URL ?? mediaBaseOf(env);");
298
315
  p(" const [brokenLinks, missingAlt, seoGaps] = await Promise.all([");
299
316
  p(' checkLinks({ base: origin, paths: ["/"] }).catch(() => []),');
300
317
  p(" countRows(env, \"SELECT COUNT(*) AS n FROM media WHERE alt IS NULL OR alt = ''\"),");
@@ -376,17 +393,26 @@ export function generateAstroidWorker(config) {
376
393
  }
377
394
  p("];");
378
395
  p();
379
- p("// Stream uploaded media back from R2 at MEDIA_BASE (self-hosted, no public bucket).");
396
+ p("// Stream uploaded media back from R2 at the media base (self-hosted, no public");
397
+ p("// bucket). The base takes two shapes, and each is matched its own way:");
380
398
  p("//");
381
- p("// Scoped by ORIGIN, not by path prefix: MEDIA_BASE is an origin, and a");
382
- p('// `url.pathname` ("/web/foo.jpg") can never start with one. Comparing the two');
383
- p("// against each other made the guard permanently false, so the route never ran");
384
- p("// and every uploaded asset fell through to the site's 404 page. On the media");
385
- p("// host the whole pathname is the R2 key.");
399
+ p('// - An ORIGIN ("https://media.example.com") is a media host, where the whole');
400
+ p("// pathname is the R2 key. Compared by origin, never by path prefix: a");
401
+ p('// `url.pathname` ("/web/foo.jpg") can never start with an origin, and a guard');
402
+ p("// that compared the two once never ran, so every upload 404'd.");
403
+ p('// - A PATH ("/media") is a prefix on the site\'s own host, which is how a');
404
+ p("// Preview serves media, since it can't know its hostname in advance.");
386
405
  p("const mediaAssetRoute: WorkerRoute<CloudflareEnv> = async (request, env) => {");
387
406
  p(" const url = new URL(request.url);");
388
- p(" if (url.origin !== MEDIA_BASE) return undefined;");
389
- p(" const key = decodeURIComponent(url.pathname.slice(1));");
407
+ p(" const base = mediaBaseOf(env);");
408
+ p(' const path = base.startsWith("/")');
409
+ p(" ? url.pathname.startsWith(`${base}/`)");
410
+ p(" ? url.pathname.slice(base.length + 1)");
411
+ p(' : ""');
412
+ p(" : url.origin === base");
413
+ p(" ? url.pathname.slice(1)");
414
+ p(' : "";');
415
+ p(" const key = decodeURIComponent(path);");
390
416
  p(" if (!key) return undefined;");
391
417
  p(" const obj = await env.MEDIA.get(key);");
392
418
  p(' if (!obj) return new Response("Not found", { status: 404 });');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.15.0",
3
+ "version": "0.17.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.33.0",
67
+ "louise-toolkit": "^0.34.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.33.0",
74
+ "louise-toolkit": "^0.34.0",
75
75
  "solid-js": "^1.9.0"
76
76
  },
77
77
  "peerDependenciesMeta": {