astroidjs 0.14.0 → 0.16.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 +7 -5
- package/bin/astroid.mjs +235 -6
- package/dist/map/scaffold.d.ts +1 -1
- package/dist/map/scaffold.js +32 -19
- package/dist/portfolio/scaffold.js +5 -1
- package/dist/project/actions.js +6 -1
- package/dist/project/generate.js +4 -3
- package/dist/project/index.d.ts +3 -0
- package/dist/project/index.js +3 -0
- package/dist/project/previews.d.ts +18 -0
- package/dist/project/previews.js +215 -0
- package/dist/project/provision.d.ts +26 -0
- package/dist/project/provision.js +65 -0
- package/dist/project/release.d.ts +6 -0
- package/dist/project/release.js +79 -0
- package/dist/queues/scaffold.js +5 -0
- package/dist/queues/webhook.d.ts +16 -0
- package/dist/queues/webhook.js +20 -3
- package/dist/schema/collections.js +9 -8
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.js +1 -0
- package/dist/schema/media-base.d.ts +13 -0
- package/dist/schema/media-base.js +33 -0
- package/dist/security/csp-origins.js +8 -9
- package/dist/worker/generate.js +34 -11
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -339,11 +339,13 @@ Astro app—in one step.
|
|
|
339
339
|
|
|
340
340
|
## Roadmap
|
|
341
341
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
342
|
+
Every item on the first roadmap has shipped:
|
|
343
|
+
|
|
344
|
+
1. **Config surface** (`defineAstroid`)—single brand per project.
|
|
345
|
+
2. Config → generated Drizzle schema.
|
|
346
|
+
3. Config → generated `worker.ts` + middleware (no hand-wired route ordering).
|
|
347
|
+
4. `<Section>` / `<Editable>` / `<Collection>` component primitives.
|
|
348
|
+
5. **CLI**—`astroid generate / doctor / dev / build / deploy`; `create-astroid`
|
|
347
349
|
scaffold (`pnpm create astroid`).
|
|
348
350
|
|
|
349
351
|
## License
|
package/bin/astroid.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// astroid dev [...astro args] generate, then `astro dev`
|
|
9
9
|
// astroid build [...astro args] generate, then `astro build`
|
|
10
10
|
// astroid deploy [--dry-run] [--yes] [--local] provision + migrate + secrets + deploy
|
|
11
|
+
// astroid ship production | preview migrate, then deploy or preview (Workers Builds)
|
|
11
12
|
//
|
|
12
13
|
// It loads the project's `astroid.config.ts` with Node's native TypeScript
|
|
13
14
|
// stripping (the config only imports the built `astroidjs`, so it resolves), and
|
|
@@ -23,6 +24,16 @@ import { pathToFileURL } from "node:url";
|
|
|
23
24
|
|
|
24
25
|
const GENERATORS_URL = new URL("../dist/index.js", import.meta.url).href;
|
|
25
26
|
|
|
27
|
+
/** The repository root, where `.github/` lives, or null outside a git checkout.
|
|
28
|
+
* A site's Astroid project can sit below it (`workers/site`). */
|
|
29
|
+
function gitRoot(cwd) {
|
|
30
|
+
try {
|
|
31
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim();
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
// --- tiny arg parser -------------------------------------------------------
|
|
27
38
|
// Splits at the first non-flag token into { command, flags, rest }. `rest` is
|
|
28
39
|
// everything after the command, preserved verbatim so `dev`/`build` can forward
|
|
@@ -77,7 +88,12 @@ async function loadConfig(cwd, explicit) {
|
|
|
77
88
|
|
|
78
89
|
// --- commands --------------------------------------------------------------
|
|
79
90
|
async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
|
|
80
|
-
const {
|
|
91
|
+
const {
|
|
92
|
+
generateAstroidProject,
|
|
93
|
+
generateAstroidScaffoldFiles,
|
|
94
|
+
generateAstroidReleaseWorkflow,
|
|
95
|
+
ASTROID_RELEASE_WORKFLOW_PATH,
|
|
96
|
+
} = await import(GENERATORS_URL);
|
|
81
97
|
const { config } = await loadConfig(cwd, flags.config);
|
|
82
98
|
const files = generateAstroidProject(config);
|
|
83
99
|
for (const file of files) {
|
|
@@ -87,6 +103,16 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
|
|
|
87
103
|
if (!quiet) out(` ✓ ${file.path}`);
|
|
88
104
|
}
|
|
89
105
|
|
|
106
|
+
// The release workflow, at the repository root rather than the project,
|
|
107
|
+
// because GitHub reads workflows only from there. Regenerated like the trio.
|
|
108
|
+
const root = gitRoot(cwd);
|
|
109
|
+
if (root) {
|
|
110
|
+
const abs = join(root, ASTROID_RELEASE_WORKFLOW_PATH);
|
|
111
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
112
|
+
writeFileSync(abs, generateAstroidReleaseWorkflow());
|
|
113
|
+
if (!quiet) out(` ✓ ${ASTROID_RELEASE_WORKFLOW_PATH} (repository root)`);
|
|
114
|
+
}
|
|
115
|
+
|
|
90
116
|
// Scaffold-once files for whatever modules the config switched on.
|
|
91
117
|
//
|
|
92
118
|
// Written only when ABSENT—each is a seam the project owns, so overwriting
|
|
@@ -124,8 +150,15 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
|
|
|
124
150
|
}
|
|
125
151
|
|
|
126
152
|
async function cmdDoctor(cwd, flags) {
|
|
127
|
-
const {
|
|
128
|
-
|
|
153
|
+
const {
|
|
154
|
+
generateAstroidProject,
|
|
155
|
+
generateAstroidScaffoldFiles,
|
|
156
|
+
astroidUsesQueues,
|
|
157
|
+
astroidCrons,
|
|
158
|
+
checkWranglerPreviews,
|
|
159
|
+
generateAstroidReleaseWorkflow,
|
|
160
|
+
ASTROID_RELEASE_WORKFLOW_PATH,
|
|
161
|
+
} = await import(GENERATORS_URL);
|
|
129
162
|
const { config, path: configPath } = await loadConfig(cwd, flags.config);
|
|
130
163
|
|
|
131
164
|
const problems = []; // { level: "error" | "warn", msg }
|
|
@@ -148,7 +181,7 @@ async function cmdDoctor(cwd, flags) {
|
|
|
148
181
|
// and are a pure function of the config, so "stale" means the project on
|
|
149
182
|
// disk is not the project the config describes—which is the single
|
|
150
183
|
// condition doctor exists to catch. As a warning it printed "healthy" and
|
|
151
|
-
// exited 0, so `pnpm doctor` could not gate CI on it.
|
|
184
|
+
// exited 0, so `pnpm run doctor` could not gate CI on it.
|
|
152
185
|
err(`${file.path} is stale (out of sync with your config) — run \`astroid generate\`.`);
|
|
153
186
|
} else {
|
|
154
187
|
ok(`${file.path} is up to date`);
|
|
@@ -264,6 +297,31 @@ async function cmdDoctor(cwd, flags) {
|
|
|
264
297
|
}
|
|
265
298
|
}
|
|
266
299
|
|
|
300
|
+
// 1c. The release workflow at the repository root: a tag on `main` moves
|
|
301
|
+
// `deploy/production`, which Workers Builds deploys. Stale is an error for
|
|
302
|
+
// the same reason the trio's is: it decides which commits reach production.
|
|
303
|
+
const root = gitRoot(cwd);
|
|
304
|
+
if (!root) {
|
|
305
|
+
warn(`not in a git checkout, so ${ASTROID_RELEASE_WORKFLOW_PATH} can't be checked.`);
|
|
306
|
+
} else {
|
|
307
|
+
const abs = join(root, ASTROID_RELEASE_WORKFLOW_PATH);
|
|
308
|
+
if (!existsSync(abs))
|
|
309
|
+
err(`${ASTROID_RELEASE_WORKFLOW_PATH} is missing — run \`astroid generate\`.`);
|
|
310
|
+
else if (readFileSync(abs, "utf8") !== generateAstroidReleaseWorkflow())
|
|
311
|
+
err(`${ASTROID_RELEASE_WORKFLOW_PATH} is stale — run \`astroid generate\`.`);
|
|
312
|
+
else ok(`${ASTROID_RELEASE_WORKFLOW_PATH} is up to date`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 2b. Staging: the `previews` block (louise-toolkit ADR 0017). A Preview
|
|
316
|
+
// inherits nothing, so a binding left out crashes it and one copied from
|
|
317
|
+
// production writes production data; see src/project/previews.ts.
|
|
318
|
+
if (existsSync(wranglerPath)) {
|
|
319
|
+
const previews = checkWranglerPreviews(readFileSync(wranglerPath, "utf8"));
|
|
320
|
+
for (const m of previews.ok) ok(m);
|
|
321
|
+
for (const m of previews.warnings) warn(m);
|
|
322
|
+
for (const m of previews.errors) err(m);
|
|
323
|
+
}
|
|
324
|
+
|
|
267
325
|
// 3. migrations directory: the D1 `migrations_dir` wrangler.jsonc declares,
|
|
268
326
|
// or `migrations/`, the generated default, when it names none. A site that
|
|
269
327
|
// keeps its migrations under another name (drizzle/) isn't missing them.
|
|
@@ -403,7 +461,10 @@ function provisionPlan(facts) {
|
|
|
403
461
|
}
|
|
404
462
|
for (const { binding, id } of facts.kv) {
|
|
405
463
|
if (isPlaceholder(id)) {
|
|
406
|
-
|
|
464
|
+
// The placeholder names the namespace (`<run: wrangler kv namespace
|
|
465
|
+
// create acme-rl>`); an older one without a name falls back to the binding.
|
|
466
|
+
const title = id.match(/kv namespace create ([^\s>]+)/)?.[1] ?? binding;
|
|
467
|
+
steps.push({ kind: "kv", name: title, binding, args: ["kv", "namespace", "create", title] });
|
|
407
468
|
}
|
|
408
469
|
}
|
|
409
470
|
// Queues carry no id, so there's no placeholder to test—creating one that
|
|
@@ -531,7 +592,7 @@ async function cmdDeploy(cwd, flags, rest) {
|
|
|
531
592
|
(rows) => rows.find((r) => typeof r.title === "string" && r.title.endsWith(s.name))?.id,
|
|
532
593
|
);
|
|
533
594
|
if (id) {
|
|
534
|
-
wrangler = patchKvId(wrangler, s.
|
|
595
|
+
wrangler = patchKvId(wrangler, s.binding, id);
|
|
535
596
|
writeFileSync(wranglerPath, wrangler);
|
|
536
597
|
out(` ↳ ${s.name} id = ${id}`);
|
|
537
598
|
} else {
|
|
@@ -597,10 +658,172 @@ Usage:
|
|
|
597
658
|
astroid dev [...astro args] regenerate, then run \`astro dev\`
|
|
598
659
|
astroid build [...astro args] regenerate, then run \`astro build\`
|
|
599
660
|
astroid deploy [--dry-run] [--yes] [--local] provision bindings + migrate + secrets + deploy
|
|
661
|
+
astroid ship production | preview migrate D1, then deploy or preview (Workers Builds runs this)
|
|
662
|
+
astroid provision [--dry-run] [--yes] create the resources wrangler.jsonc names by placeholder, staging included
|
|
600
663
|
|
|
601
664
|
New project: pnpm create astroid@latest
|
|
602
665
|
`;
|
|
603
666
|
|
|
667
|
+
// `astroid provision` creates what `wrangler.jsonc` still names by placeholder,
|
|
668
|
+
// top level and `previews` alike, and writes each new ID back in place of its
|
|
669
|
+
// placeholder. It never deploys, so it's safe to run before a site's first
|
|
670
|
+
// release, and re-running it only creates what's still missing. Secrets and
|
|
671
|
+
// dashboard settings need a person, so it prints those instead.
|
|
672
|
+
async function cmdProvision(cwd, rest) {
|
|
673
|
+
const dryRun = rest.includes("--dry-run");
|
|
674
|
+
const assumeYes = rest.includes("--yes") || rest.includes("-y");
|
|
675
|
+
const wranglerPath = join(cwd, "wrangler.jsonc");
|
|
676
|
+
if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
|
|
677
|
+
|
|
678
|
+
const { provisionPlan, applyProvisionedId } = await import(GENERATORS_URL);
|
|
679
|
+
let text = readFileSync(wranglerPath, "utf8");
|
|
680
|
+
const plan = provisionPlan(text);
|
|
681
|
+
|
|
682
|
+
out("astroid provision — plan:\n");
|
|
683
|
+
if (plan.steps.length === 0) out(" (nothing to create: every binding has an ID)");
|
|
684
|
+
for (const s of plan.steps) {
|
|
685
|
+
const note = s.kind === "r2" ? " (an existing bucket is fine)" : "";
|
|
686
|
+
out(` wrangler ${s.args.join(" ")}${note}`);
|
|
687
|
+
}
|
|
688
|
+
if (!plan.hasAccount && !process.env.CLOUDFLARE_ACCOUNT_ID) {
|
|
689
|
+
out(
|
|
690
|
+
"\n ! No account_id in wrangler.jsonc and no CLOUDFLARE_ACCOUNT_ID, so wrangler picks one.",
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
const printSecrets = () => {
|
|
694
|
+
if (plan.secrets.length === 0) return;
|
|
695
|
+
out("\nSecrets Store secrets it binds; create any that don't exist yet:");
|
|
696
|
+
for (const s of plan.secrets) {
|
|
697
|
+
out(
|
|
698
|
+
` [${s.environment}] wrangler secrets-store secret create ${s.storeId} ` +
|
|
699
|
+
`--name ${s.secretName} --scopes workers --remote`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
if (dryRun) {
|
|
705
|
+
printSecrets();
|
|
706
|
+
out("\n(dry run — nothing created)");
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (plan.steps.length > 0 && !assumeYes) {
|
|
710
|
+
if (!process.stdin.isTTY)
|
|
711
|
+
fail("Refusing to create resources non-interactively. Re-run with --yes.");
|
|
712
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
713
|
+
const answer = (await rl.question("\nCreate the above? [y/N] ")).trim().toLowerCase();
|
|
714
|
+
rl.close();
|
|
715
|
+
if (answer !== "y" && answer !== "yes") {
|
|
716
|
+
out("Aborted.");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
|
|
722
|
+
if (!wranglerBin) fail("Could not find `wrangler` in this project.");
|
|
723
|
+
for (const s of plan.steps) {
|
|
724
|
+
out(`\n▸ wrangler ${s.args.join(" ")}`);
|
|
725
|
+
// A create that fails because the resource exists is fine: the lookup
|
|
726
|
+
// below finds it, so a re-run after a partial one picks up where it was.
|
|
727
|
+
spawnSync(process.execPath, [wranglerBin, ...s.args], { cwd, stdio: "inherit" });
|
|
728
|
+
if (s.kind === "r2") continue;
|
|
729
|
+
const id =
|
|
730
|
+
s.kind === "d1"
|
|
731
|
+
? lookupId(
|
|
732
|
+
wranglerBin,
|
|
733
|
+
cwd,
|
|
734
|
+
["d1", "list", "--json"],
|
|
735
|
+
(rows) => rows.find((r) => r.name === s.name)?.uuid,
|
|
736
|
+
)
|
|
737
|
+
: lookupId(
|
|
738
|
+
wranglerBin,
|
|
739
|
+
cwd,
|
|
740
|
+
["kv", "namespace", "list"],
|
|
741
|
+
(rows) => rows.find((r) => r.title === s.name)?.id,
|
|
742
|
+
);
|
|
743
|
+
if (!id) fail(`Couldn't find the ID of ${s.name} after creating it. Fill it in by hand.`);
|
|
744
|
+
text = applyProvisionedId(text, s.placeholder, id);
|
|
745
|
+
writeFileSync(wranglerPath, text);
|
|
746
|
+
out(` ↳ ${s.name} = ${id}`);
|
|
747
|
+
}
|
|
748
|
+
printSecrets();
|
|
749
|
+
out("\n✓ Provisioned. Commit wrangler.jsonc; the dashboard steps are in the site's RUNBOOK.");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// `astroid ship` is what Workers Builds runs, so the deploy logic lives in the
|
|
753
|
+
// repository instead of a dashboard field. An account move once rewrote a
|
|
754
|
+
// site's dashboard deploy command to a bare `wrangler deploy`, and migrations
|
|
755
|
+
// silently stopped. Migrations run first, so new code never meets an old schema.
|
|
756
|
+
//
|
|
757
|
+
// astroid ship production the deploy/production build: migrate D1, then deploy
|
|
758
|
+
// astroid ship preview every other branch: migrate the staging D1, then
|
|
759
|
+
// `wrangler preview`, named for the branch
|
|
760
|
+
async function cmdShip(cwd, target) {
|
|
761
|
+
if (target !== "production" && target !== "preview") {
|
|
762
|
+
fail("Usage: astroid ship production | preview");
|
|
763
|
+
}
|
|
764
|
+
const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
|
|
765
|
+
if (!wranglerBin) fail("Could not find `wrangler` in this project.");
|
|
766
|
+
const run = (args) => {
|
|
767
|
+
out(`\n▸ wrangler ${args.join(" ")}`);
|
|
768
|
+
const res = spawnSync(process.execPath, [wranglerBin, ...args], { cwd, stdio: "inherit" });
|
|
769
|
+
if (res.status !== 0) process.exit(res.status ?? 1);
|
|
770
|
+
};
|
|
771
|
+
const wranglerPath = join(cwd, "wrangler.jsonc");
|
|
772
|
+
if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
|
|
773
|
+
|
|
774
|
+
if (target === "production") {
|
|
775
|
+
run(["d1", "migrations", "apply", "DB", "--remote"]);
|
|
776
|
+
run(["deploy"]);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// The staging database is declared only inside `previews`, and wrangler's
|
|
781
|
+
// migrations command reads top-level `d1_databases`. So write a throwaway
|
|
782
|
+
// config naming it, derived from wrangler.jsonc on every run, rather than a
|
|
783
|
+
// second committed file that could drift from the binding.
|
|
784
|
+
const { parseJsonc } = await import(GENERATORS_URL);
|
|
785
|
+
const config = parseJsonc(readFileSync(wranglerPath, "utf8"));
|
|
786
|
+
const prodDb = (config.d1_databases ?? []).find((d) => d.binding === "DB");
|
|
787
|
+
const stagingDb = (config.previews?.d1_databases ?? []).find((d) => d.binding === "DB");
|
|
788
|
+
if (stagingDb && prodDb) {
|
|
789
|
+
const migrationsDir = resolve(cwd, prodDb.migrations_dir ?? "migrations");
|
|
790
|
+
const tmp = join(cwd, ".wrangler", "astroid-preview-migrations.jsonc");
|
|
791
|
+
mkdirSync(dirname(tmp), { recursive: true });
|
|
792
|
+
writeFileSync(
|
|
793
|
+
tmp,
|
|
794
|
+
JSON.stringify(
|
|
795
|
+
{
|
|
796
|
+
d1_databases: [
|
|
797
|
+
{
|
|
798
|
+
binding: "PREVIEW_DB",
|
|
799
|
+
database_name: stagingDb.database_name,
|
|
800
|
+
database_id: stagingDb.database_id,
|
|
801
|
+
migrations_dir: migrationsDir,
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
},
|
|
805
|
+
null,
|
|
806
|
+
2,
|
|
807
|
+
),
|
|
808
|
+
);
|
|
809
|
+
run(["d1", "migrations", "apply", "PREVIEW_DB", "--remote", "--config", tmp]);
|
|
810
|
+
} else {
|
|
811
|
+
out("\n(no staging D1 in `previews`, so no staging migrations to apply)");
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Workers Builds names the branch in WORKERS_CI_BRANCH; a Preview name is a
|
|
815
|
+
// DNS label, so a branch like feature/12-login becomes feature-12-login.
|
|
816
|
+
const branch = process.env.WORKERS_CI_BRANCH;
|
|
817
|
+
const name = branch
|
|
818
|
+
? branch
|
|
819
|
+
.toLowerCase()
|
|
820
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
821
|
+
.replace(/^-+|-+$/g, "")
|
|
822
|
+
.slice(0, 63)
|
|
823
|
+
: undefined;
|
|
824
|
+
run(["preview", ...(name ? ["--name", name] : [])]);
|
|
825
|
+
}
|
|
826
|
+
|
|
604
827
|
async function main() {
|
|
605
828
|
const { command, flags, rest } = parseArgs(process.argv.slice(2));
|
|
606
829
|
const cwd = flags.cwd ? resolve(flags.cwd) : process.cwd();
|
|
@@ -622,6 +845,12 @@ async function main() {
|
|
|
622
845
|
case "deploy":
|
|
623
846
|
await cmdDeploy(cwd, flags, rest);
|
|
624
847
|
break;
|
|
848
|
+
case "ship":
|
|
849
|
+
await cmdShip(cwd, rest[0]);
|
|
850
|
+
break;
|
|
851
|
+
case "provision":
|
|
852
|
+
await cmdProvision(cwd, rest);
|
|
853
|
+
break;
|
|
625
854
|
case "help":
|
|
626
855
|
case "--help":
|
|
627
856
|
case "-h":
|
package/dist/map/scaffold.d.ts
CHANGED
|
@@ -24,6 +24,6 @@ export declare function generateMapTileRoute(config: AstroidConfig): string | nu
|
|
|
24
24
|
export declare function generateMapEmbedComponent(config: AstroidConfig): string | null;
|
|
25
25
|
/** The npm dependencies the map module needs in the scaffold. */
|
|
26
26
|
export declare const ASTROID_MAP_DEPENDENCIES: {
|
|
27
|
-
readonly "maplibre-gl": "^
|
|
27
|
+
readonly "maplibre-gl": "^6.11.2";
|
|
28
28
|
readonly pmtiles: "^4.4.0";
|
|
29
29
|
};
|
package/dist/map/scaffold.js
CHANGED
|
@@ -149,14 +149,19 @@ export function generateMapEmbedComponent(config) {
|
|
|
149
149
|
" const load = () =>",
|
|
150
150
|
" (loader ??= Promise.all([",
|
|
151
151
|
' import("maplibre-gl"),',
|
|
152
|
+
" // MapLibre 6 looks for its worker next to its own module, which a",
|
|
153
|
+
" // bundle moves, so hand it the worker. `?worker&url` sends the file",
|
|
154
|
+
" // through Vite's worker pipeline, which emits one self-contained",
|
|
155
|
+
" // chunk; a plain `?url` copies it without the shared module it",
|
|
156
|
+
" // imports, and then no tiles load. The worker is same-origin, so",
|
|
157
|
+
" // `worker-src 'self'` covers it with no `blob:`.",
|
|
158
|
+
' import("maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url"),',
|
|
152
159
|
' import("pmtiles"),',
|
|
153
160
|
' import("maplibre-gl/dist/maplibre-gl.css"),',
|
|
154
|
-
" ]).then(([
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
" Protocol: (pmtiles as any).Protocol,",
|
|
159
|
-
" })));",
|
|
161
|
+
" ]).then(([maplibregl, worker, pmtiles]) => {",
|
|
162
|
+
" maplibregl.setWorkerUrl(worker.default);",
|
|
163
|
+
" return { maplibregl, Protocol: pmtiles.Protocol };",
|
|
164
|
+
" }));",
|
|
160
165
|
"",
|
|
161
166
|
" const init = async (el: HTMLElement) => {",
|
|
162
167
|
" const lat = Number(el.dataset.lat);",
|
|
@@ -168,18 +173,26 @@ export function generateMapEmbedComponent(config) {
|
|
|
168
173
|
" // archive instead of requesting tile URLs.",
|
|
169
174
|
' maplibregl.addProtocol("pmtiles", new Protocol().tile);',
|
|
170
175
|
"",
|
|
171
|
-
"
|
|
172
|
-
"
|
|
173
|
-
|
|
174
|
-
"
|
|
175
|
-
|
|
176
|
-
"
|
|
177
|
-
"
|
|
178
|
-
"
|
|
179
|
-
"
|
|
180
|
-
"
|
|
181
|
-
"
|
|
182
|
-
"
|
|
176
|
+
" let map;",
|
|
177
|
+
" try {",
|
|
178
|
+
" map = new maplibregl.Map({",
|
|
179
|
+
" container: el,",
|
|
180
|
+
' style: JSON.parse(el.dataset.style ?? "{}"),',
|
|
181
|
+
" center: [lng, lat],",
|
|
182
|
+
" zoom: Number(el.dataset.zoom ?? 15),",
|
|
183
|
+
" // A map that swallows page scroll is a trap on mobile; cooperative",
|
|
184
|
+
" // gestures require an explicit modifier/two fingers to pan.",
|
|
185
|
+
" cooperativeGestures: true,",
|
|
186
|
+
" dragRotate: false,",
|
|
187
|
+
" pitchWithRotate: false,",
|
|
188
|
+
" touchPitch: false,",
|
|
189
|
+
" });",
|
|
190
|
+
" } catch (err) {",
|
|
191
|
+
" // MapLibre 6 needs WebGL2 and throws `GPUInitializationError` without",
|
|
192
|
+
" // it. Leave the tinted container in place rather than a broken canvas.",
|
|
193
|
+
" if (err instanceof maplibregl.GPUInitializationError) return;",
|
|
194
|
+
" throw err;",
|
|
195
|
+
" }",
|
|
183
196
|
' map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");',
|
|
184
197
|
"",
|
|
185
198
|
' const pin = document.createElement("div");',
|
|
@@ -213,6 +226,6 @@ export function generateMapEmbedComponent(config) {
|
|
|
213
226
|
}
|
|
214
227
|
/** The npm dependencies the map module needs in the scaffold. */
|
|
215
228
|
export const ASTROID_MAP_DEPENDENCIES = {
|
|
216
|
-
"maplibre-gl": "^
|
|
229
|
+
"maplibre-gl": "^6.11.2",
|
|
217
230
|
pmtiles: "^4.4.0",
|
|
218
231
|
};
|
|
@@ -62,8 +62,12 @@ export function generateAstroidGalleryPage(config) {
|
|
|
62
62
|
" // No DB binding yet (pre-provision)—render the empty state.",
|
|
63
63
|
"}",
|
|
64
64
|
"",
|
|
65
|
+
"// This environment's media base: production's, or a staging Preview's own",
|
|
66
|
+
"// `/media`, where the Preview's uploads live.",
|
|
67
|
+
`const mediaBase = (env.MEDIA_URL || ${JSON.stringify(mediaBase)}).replace(/\\/+$/, "");`,
|
|
68
|
+
"",
|
|
65
69
|
"const items: GalleryItem[] = rows.map((row) => ({",
|
|
66
|
-
|
|
70
|
+
" src: `${mediaBase}/${row.key}`,",
|
|
67
71
|
' // An asset with no alt gets "" rather than its filename: an empty alt makes',
|
|
68
72
|
" // a screen reader skip a decorative tile, while a filename is read aloud",
|
|
69
73
|
" // character by character and tells the listener nothing.",
|
package/dist/project/actions.js
CHANGED
|
@@ -61,6 +61,7 @@ export function generateAstroidActions(config) {
|
|
|
61
61
|
// as a literal, and an unused import is a lint error in its own file.
|
|
62
62
|
...(columnsOverride ? [] : [" ASTROID_SETTINGS_COLUMNS,"]),
|
|
63
63
|
" ASTROID_SETTINGS_IMAGE_KEYS,",
|
|
64
|
+
" astroidMediaBase,",
|
|
64
65
|
" astroidPagesCollection,",
|
|
65
66
|
'} from "astroidjs";',
|
|
66
67
|
'import astroidConfig from "../../astroid.config.js";',
|
|
@@ -117,7 +118,11 @@ export function generateAstroidActions(config) {
|
|
|
117
118
|
" // than copied—a second literal here is a list that drifts from the",
|
|
118
119
|
" // one the routes check against, and nothing would fail when it did.",
|
|
119
120
|
...settingsExtra,
|
|
120
|
-
|
|
121
|
+
" // Read on each save, so a staging Preview accepts images from its own",
|
|
122
|
+
" // `/media` (see astroidMediaBase).",
|
|
123
|
+
" get mediaBase() {",
|
|
124
|
+
" return astroidMediaBase(astroidConfig);",
|
|
125
|
+
" },",
|
|
121
126
|
" }),",
|
|
122
127
|
" ),",
|
|
123
128
|
" },",
|
package/dist/project/generate.js
CHANGED
|
@@ -209,10 +209,11 @@ export function generateAstroidWrangler(config) {
|
|
|
209
209
|
p(" // KV: RL = the security rate limiter (it also holds the daily site-health");
|
|
210
210
|
p(" // summary under its own key—one small singleton blob, not worth a binding");
|
|
211
211
|
p(" // someone has to remember to provision); DRAFTS = the autosave write-buffer.");
|
|
212
|
-
p(" //
|
|
212
|
+
p(" // Named for the project, so two sites in one account don't collide.");
|
|
213
|
+
p(" // `astroid provision` creates each and fills in its id.");
|
|
213
214
|
p(' "kv_namespaces": [');
|
|
214
|
-
p(
|
|
215
|
-
p(
|
|
215
|
+
p(` { "binding": "RL", "id": "<run: wrangler kv namespace create ${key}-rl>" },`);
|
|
216
|
+
p(` { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create ${key}-drafts>" },`);
|
|
216
217
|
p(" ],");
|
|
217
218
|
// Email Sending. NOT optional decoration: `src/env.d.ts` declares EMAIL as a
|
|
218
219
|
// required member, and Better Auth's magic-link path console-logs the link in
|
package/dist/project/index.d.ts
CHANGED
package/dist/project/index.js
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** One finding, in the order `doctor` prints them. */
|
|
2
|
+
export interface PreviewsFindings {
|
|
3
|
+
ok: string[];
|
|
4
|
+
errors: string[];
|
|
5
|
+
warnings: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Parse JSONC: strip `//` and block comments outside strings, then trailing
|
|
9
|
+
* commas. `wrangler.jsonc` is written by hand and commented heavily, which is
|
|
10
|
+
* why `doctor` reads the rest of it by regex; this check needs the structure.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseJsonc(text: string): unknown;
|
|
13
|
+
/**
|
|
14
|
+
* Check the `previews` block of a `wrangler.jsonc` against its production
|
|
15
|
+
* settings. Returns what passed and what didn't; an error means a Preview
|
|
16
|
+
* would crash or touch production.
|
|
17
|
+
*/
|
|
18
|
+
export declare function checkWranglerPreviews(text: string): PreviewsFindings;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The `previews` block check `astroid doctor` runs (louise-toolkit ADR 0017).
|
|
4
|
+
//
|
|
5
|
+
// A site's staging is Cloudflare's Worker Previews: `main` and every pull
|
|
6
|
+
// request run as Previews of the one Worker, with the settings in the
|
|
7
|
+
// `previews` block of `wrangler.jsonc`. Previews inherit NOTHING from the top
|
|
8
|
+
// level, which is the point and also the trap:
|
|
9
|
+
//
|
|
10
|
+
// - A binding the code reads that the block leaves out is `undefined` on a
|
|
11
|
+
// Preview, and the Worker throws (Cloudflare's error 1101).
|
|
12
|
+
// - A binding the block copies verbatim points a Preview at production's
|
|
13
|
+
// database or bucket, so a branch writes production data. That's the
|
|
14
|
+
// failure staging exists to prevent, and nothing else would catch it.
|
|
15
|
+
// - Crons, routes, and queue consumers don't target Previews, so putting
|
|
16
|
+
// them in the block does nothing and reads as if it did.
|
|
17
|
+
//
|
|
18
|
+
// Pure: text in, findings out, so it's tested directly and the CLI only prints.
|
|
19
|
+
/**
|
|
20
|
+
* Parse JSONC: strip `//` and block comments outside strings, then trailing
|
|
21
|
+
* commas. `wrangler.jsonc` is written by hand and commented heavily, which is
|
|
22
|
+
* why `doctor` reads the rest of it by regex; this check needs the structure.
|
|
23
|
+
*/
|
|
24
|
+
export function parseJsonc(text) {
|
|
25
|
+
let out = "";
|
|
26
|
+
let inString = false;
|
|
27
|
+
for (let i = 0; i < text.length; i++) {
|
|
28
|
+
const c = text[i];
|
|
29
|
+
const next = text[i + 1];
|
|
30
|
+
if (inString) {
|
|
31
|
+
out += c;
|
|
32
|
+
if (c === "\\")
|
|
33
|
+
out += text[++i] ?? "";
|
|
34
|
+
else if (c === '"')
|
|
35
|
+
inString = false;
|
|
36
|
+
}
|
|
37
|
+
else if (c === '"') {
|
|
38
|
+
inString = true;
|
|
39
|
+
out += c;
|
|
40
|
+
}
|
|
41
|
+
else if (c === "/" && next === "/") {
|
|
42
|
+
while (i < text.length && text[i] !== "\n")
|
|
43
|
+
i++;
|
|
44
|
+
out += "\n";
|
|
45
|
+
}
|
|
46
|
+
else if (c === "/" && next === "*") {
|
|
47
|
+
i += 2;
|
|
48
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
49
|
+
i++;
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
out += c;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
57
|
+
}
|
|
58
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
59
|
+
const list = (v) => (Array.isArray(v) ? v.filter(isObject) : []);
|
|
60
|
+
const STORAGE = [
|
|
61
|
+
{
|
|
62
|
+
what: "D1 database",
|
|
63
|
+
get: (c) => list(c.d1_databases),
|
|
64
|
+
name: (b) => b.binding,
|
|
65
|
+
resource: (b) => String(b.database_id ?? b.database_name ?? ""),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
what: "KV namespace",
|
|
69
|
+
get: (c) => list(c.kv_namespaces),
|
|
70
|
+
name: (b) => b.binding,
|
|
71
|
+
resource: (b) => String(b.id ?? ""),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
what: "R2 bucket",
|
|
75
|
+
get: (c) => list(c.r2_buckets),
|
|
76
|
+
name: (b) => b.binding,
|
|
77
|
+
resource: (b) => String(b.bucket_name ?? ""),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
what: "Secrets Store secret",
|
|
81
|
+
get: (c) => list(c.secrets_store_secrets),
|
|
82
|
+
name: (b) => b.binding,
|
|
83
|
+
resource: (b) => `${String(b.store_id ?? "")}/${String(b.secret_name ?? "")}`,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
what: "Analytics Engine dataset",
|
|
87
|
+
get: (c) => list(c.analytics_engine_datasets),
|
|
88
|
+
name: (b) => b.binding,
|
|
89
|
+
resource: (b) => String(b.dataset ?? ""),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
what: "Vectorize index",
|
|
93
|
+
get: (c) => list(c.vectorize),
|
|
94
|
+
name: (b) => b.binding,
|
|
95
|
+
resource: (b) => String(b.index_name ?? ""),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
what: "queue producer",
|
|
99
|
+
get: (c) => list(isObject(c.queues) ? c.queues.producers : undefined),
|
|
100
|
+
name: (b) => b.binding,
|
|
101
|
+
resource: (b) => String(b.queue ?? ""),
|
|
102
|
+
optional: "queue consumers can't target a Preview, so a Preview leaves the queue unbound and the side effects run inline",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
what: "Workflow",
|
|
106
|
+
get: (c) => list(c.workflows),
|
|
107
|
+
name: (b) => b.binding,
|
|
108
|
+
resource: (b) => String(b.name ?? ""),
|
|
109
|
+
optional: "a Preview calls the production Workflow's code and bindings, so it leaves the binding out and falls back",
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
/** Bindings with no staging resource behind them: a Preview needs the key, and
|
|
113
|
+
* nothing to provision. A Durable Object namespace is one: Cloudflare gives
|
|
114
|
+
* each Preview its own instances of the class. */
|
|
115
|
+
const API_BINDINGS = [
|
|
116
|
+
"ai",
|
|
117
|
+
"images",
|
|
118
|
+
"browser",
|
|
119
|
+
"send_email",
|
|
120
|
+
"version_metadata",
|
|
121
|
+
"durable_objects",
|
|
122
|
+
];
|
|
123
|
+
/**
|
|
124
|
+
* Check the `previews` block of a `wrangler.jsonc` against its production
|
|
125
|
+
* settings. Returns what passed and what didn't; an error means a Preview
|
|
126
|
+
* would crash or touch production.
|
|
127
|
+
*/
|
|
128
|
+
export function checkWranglerPreviews(text) {
|
|
129
|
+
const findings = { ok: [], errors: [], warnings: [] };
|
|
130
|
+
let config;
|
|
131
|
+
try {
|
|
132
|
+
const parsed = parseJsonc(text);
|
|
133
|
+
if (!isObject(parsed))
|
|
134
|
+
throw new Error("not an object");
|
|
135
|
+
config = parsed;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
findings.errors.push(`wrangler.jsonc doesn't parse (${err.message}).`);
|
|
139
|
+
return findings;
|
|
140
|
+
}
|
|
141
|
+
const previews = config.previews;
|
|
142
|
+
if (!isObject(previews)) {
|
|
143
|
+
findings.warnings.push("wrangler.jsonc has no `previews` block, so this site has no staging. Branch builds " +
|
|
144
|
+
"either fail or run with production's data (louise-toolkit ADR 0017).");
|
|
145
|
+
return findings;
|
|
146
|
+
}
|
|
147
|
+
for (const key of ["triggers", "routes"]) {
|
|
148
|
+
if (key in previews) {
|
|
149
|
+
findings.errors.push(`\`previews.${key}\` does nothing: ${key === "triggers" ? "Cron Triggers" : "routes"} ` +
|
|
150
|
+
"target production only. Remove it; Preview hosts come from a route with " +
|
|
151
|
+
"`previews_enabled` at the top level.");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (isObject(previews.queues) && "consumers" in previews.queues) {
|
|
155
|
+
findings.errors.push("`previews.queues.consumers` does nothing: queue consumers can't target a Preview. Remove it.");
|
|
156
|
+
}
|
|
157
|
+
for (const kind of STORAGE) {
|
|
158
|
+
const staging = new Map(kind.get(previews).map((b) => [kind.name(b), b]));
|
|
159
|
+
for (const prod of kind.get(config)) {
|
|
160
|
+
const name = String(kind.name(prod));
|
|
161
|
+
const preview = staging.get(kind.name(prod));
|
|
162
|
+
if (!preview) {
|
|
163
|
+
if (kind.optional)
|
|
164
|
+
findings.ok.push(`previews: ${kind.what} \`${name}\` left out (${kind.optional})`);
|
|
165
|
+
else
|
|
166
|
+
findings.errors.push(`\`previews\` has no ${kind.what} \`${name}\`. A Preview inherits nothing, so code ` +
|
|
167
|
+
`that reads \`env.${name}\` throws there. Bind it to a staging ${kind.what}.`);
|
|
168
|
+
}
|
|
169
|
+
else if (kind.resource(preview) === kind.resource(prod)) {
|
|
170
|
+
findings.errors.push(`\`previews\` binds ${kind.what} \`${name}\` to production's (${kind.resource(prod)}), ` +
|
|
171
|
+
"so every branch would read and write production data. Bind it to a staging one.");
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
findings.ok.push(`previews: ${kind.what} \`${name}\` bound to a staging resource`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const key of API_BINDINGS) {
|
|
179
|
+
if (!(key in config))
|
|
180
|
+
continue;
|
|
181
|
+
if (key in previews)
|
|
182
|
+
findings.ok.push(`previews: \`${key}\` binding present`);
|
|
183
|
+
else
|
|
184
|
+
findings.errors.push(`\`previews\` has no \`${key}\` binding. A Preview inherits nothing, so code that uses ` +
|
|
185
|
+
"it throws there. Copy the binding into `previews`; it needs no staging resource.");
|
|
186
|
+
}
|
|
187
|
+
const prodVars = isObject(config.vars) ? config.vars : {};
|
|
188
|
+
const stagingVars = isObject(previews.vars) ? previews.vars : {};
|
|
189
|
+
const missingVars = Object.keys(prodVars).filter((key) => !(key in stagingVars));
|
|
190
|
+
if (missingVars.length) {
|
|
191
|
+
findings.errors.push(`\`previews.vars\` is missing ${missingVars.map((v) => `\`${v}\``).join(", ")}. Vars aren't ` +
|
|
192
|
+
"inherited, so each is undefined on a Preview. Give each a staging value.");
|
|
193
|
+
}
|
|
194
|
+
else if (Object.keys(prodVars).length) {
|
|
195
|
+
findings.ok.push(`previews: all ${Object.keys(prodVars).length} vars have a staging value`);
|
|
196
|
+
}
|
|
197
|
+
for (const key of ["SITE_URL", "MEDIA_URL"]) {
|
|
198
|
+
const value = prodVars[key];
|
|
199
|
+
// A path such as `/media` resolves against whichever host serves it, so a
|
|
200
|
+
// Preview sharing production's path still serves its own bucket.
|
|
201
|
+
const isOrigin = typeof value === "string" && /^https?:\/\//.test(value);
|
|
202
|
+
if (key in prodVars && isOrigin && stagingVars[key] === value) {
|
|
203
|
+
findings.errors.push(`\`previews.vars.${key}\` is production's (${String(prodVars[key])}), so a Preview ` +
|
|
204
|
+
"links to or serves from production. Point it at staging.");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const previewHost = list(config.routes).find((r) => r.previews_enabled === true);
|
|
208
|
+
if (previewHost)
|
|
209
|
+
findings.ok.push(`previews: served on \`<name>.${String(previewHost.pattern)}\``);
|
|
210
|
+
else
|
|
211
|
+
findings.warnings.push("No route has `previews_enabled`, so Previews are reachable only on workers.dev. Add a " +
|
|
212
|
+
'preview-only custom domain: `{ "pattern": "staging.example.com", "custom_domain": true, ' +
|
|
213
|
+
'"previews_enabled": true, "enabled": false }`.');
|
|
214
|
+
return findings;
|
|
215
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** One resource to create. `placeholder` is the text its ID replaces. */
|
|
2
|
+
export interface ProvisionStep {
|
|
3
|
+
kind: "d1" | "kv" | "r2";
|
|
4
|
+
name: string;
|
|
5
|
+
/** The `wrangler` arguments that create it. */
|
|
6
|
+
args: string[];
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
}
|
|
9
|
+
/** A Secrets Store secret the config binds, which only a person can set. */
|
|
10
|
+
export interface ProvisionSecret {
|
|
11
|
+
binding: string;
|
|
12
|
+
storeId: string;
|
|
13
|
+
secretName: string;
|
|
14
|
+
/** `production` for a top-level binding, `staging` for one in `previews`. */
|
|
15
|
+
environment: "production" | "staging";
|
|
16
|
+
}
|
|
17
|
+
export interface ProvisionPlan {
|
|
18
|
+
steps: ProvisionStep[];
|
|
19
|
+
secrets: ProvisionSecret[];
|
|
20
|
+
/** Whether `account_id` is set, so wrangler doesn't have to pick one. */
|
|
21
|
+
hasAccount: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Build the plan from a `wrangler.jsonc`, top level and `previews` alike. */
|
|
24
|
+
export declare function provisionPlan(text: string): ProvisionPlan;
|
|
25
|
+
/** Put a created resource's ID in place of its placeholder, everywhere. */
|
|
26
|
+
export declare function applyProvisionedId(text: string, placeholder: string, id: string): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The plan `astroid provision` runs: which Cloudflare resources a site's
|
|
4
|
+
// `wrangler.jsonc` still names by placeholder, and which secrets it binds.
|
|
5
|
+
//
|
|
6
|
+
// A placeholder names the command that creates its resource, such as
|
|
7
|
+
// `<run: wrangler d1 create acme-staging>`, so the plan reads it rather than
|
|
8
|
+
// guessing a name. The same placeholder can stand for two bindings that share
|
|
9
|
+
// one namespace, and replacing the text everywhere it appears fills both.
|
|
10
|
+
// Buckets carry a name instead of an ID, so every bucket the file names is
|
|
11
|
+
// created, and one that already exists is fine.
|
|
12
|
+
//
|
|
13
|
+
// Pure: text in, plan out, so it's tested directly and the CLI only runs it.
|
|
14
|
+
import { parseJsonc } from "./previews.js";
|
|
15
|
+
const PLACEHOLDER = /<run:\s*wrangler\s+(d1\s+create|kv\s+namespace\s+create)\s+([^\s>]+)\s*>/g;
|
|
16
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
17
|
+
const list = (v) => (Array.isArray(v) ? v.filter(isObject) : []);
|
|
18
|
+
/** Build the plan from a `wrangler.jsonc`, top level and `previews` alike. */
|
|
19
|
+
export function provisionPlan(text) {
|
|
20
|
+
const steps = [];
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
for (const match of text.matchAll(PLACEHOLDER)) {
|
|
23
|
+
const placeholder = match[0];
|
|
24
|
+
if (seen.has(placeholder))
|
|
25
|
+
continue;
|
|
26
|
+
seen.add(placeholder);
|
|
27
|
+
const name = match[2];
|
|
28
|
+
steps.push(match[1].startsWith("d1")
|
|
29
|
+
? { kind: "d1", name, args: ["d1", "create", name], placeholder }
|
|
30
|
+
: { kind: "kv", name, args: ["kv", "namespace", "create", name], placeholder });
|
|
31
|
+
}
|
|
32
|
+
const config = parseJsonc(text);
|
|
33
|
+
const top = isObject(config) ? config : {};
|
|
34
|
+
const previews = isObject(top.previews) ? top.previews : {};
|
|
35
|
+
const buckets = new Set([...list(top.r2_buckets), ...list(previews.r2_buckets)]
|
|
36
|
+
.map((b) => b.bucket_name)
|
|
37
|
+
.filter((name) => typeof name === "string" && !name.startsWith("<")));
|
|
38
|
+
for (const name of buckets) {
|
|
39
|
+
steps.push({ kind: "r2", name, args: ["r2", "bucket", "create", name] });
|
|
40
|
+
}
|
|
41
|
+
const secrets = [];
|
|
42
|
+
for (const [environment, section] of [
|
|
43
|
+
["production", top],
|
|
44
|
+
["staging", previews],
|
|
45
|
+
]) {
|
|
46
|
+
for (const s of list(section.secrets_store_secrets)) {
|
|
47
|
+
secrets.push({
|
|
48
|
+
binding: String(s.binding ?? ""),
|
|
49
|
+
storeId: String(s.store_id ?? ""),
|
|
50
|
+
secretName: String(s.secret_name ?? ""),
|
|
51
|
+
environment,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const account = top.account_id;
|
|
56
|
+
return {
|
|
57
|
+
steps,
|
|
58
|
+
secrets,
|
|
59
|
+
hasAccount: typeof account === "string" && account.length > 0 && !account.startsWith("<"),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Put a created resource's ID in place of its placeholder, everywhere. */
|
|
63
|
+
export function applyProvisionedId(text, placeholder, id) {
|
|
64
|
+
return text.split(placeholder).join(id);
|
|
65
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The branch Workers Builds deploys to production from. */
|
|
2
|
+
export declare const ASTROID_DEPLOY_BRANCH = "deploy/production";
|
|
3
|
+
/** Where the workflow lives, relative to the repository root. */
|
|
4
|
+
export declare const ASTROID_RELEASE_WORKFLOW_PATH = ".github/workflows/release.yml";
|
|
5
|
+
/** The release workflow's contents. Pure, and the same for every site. */
|
|
6
|
+
export declare function generateAstroidReleaseWorkflow(): string;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The release workflow every site runs (louise-toolkit ADR 0017, amended).
|
|
4
|
+
//
|
|
5
|
+
// Releases are trunk-based: a `v<version>` tag on a commit of `main` is the
|
|
6
|
+
// release, and a `release/<version>` branch exists only when a released version
|
|
7
|
+
// needs a patch and `main` has moved on. Workers Builds deploys on branch
|
|
8
|
+
// pushes and can't watch tags, so the tag's workflow moves one branch,
|
|
9
|
+
// `deploy/production`, to the tagged commit, and Workers Builds deploys that.
|
|
10
|
+
// Nobody commits to the branch; it's a tag carried as a branch, and GitHub never
|
|
11
|
+
// holds a Cloudflare credential.
|
|
12
|
+
//
|
|
13
|
+
// A regenerated file, like the worker trio: `astroid generate` rewrites it and
|
|
14
|
+
// `astroid doctor` fails when it drifts, so a hand edit can't quietly change
|
|
15
|
+
// which commits reach production.
|
|
16
|
+
/** The branch Workers Builds deploys to production from. */
|
|
17
|
+
export const ASTROID_DEPLOY_BRANCH = "deploy/production";
|
|
18
|
+
/** Where the workflow lives, relative to the repository root. */
|
|
19
|
+
export const ASTROID_RELEASE_WORKFLOW_PATH = ".github/workflows/release.yml";
|
|
20
|
+
/** The release workflow's contents. Pure, and the same for every site. */
|
|
21
|
+
export function generateAstroidReleaseWorkflow() {
|
|
22
|
+
return `# Generated by astroid. Don't edit: \`astroid generate\` rewrites this file and
|
|
23
|
+
# \`astroid doctor\` fails when it drifts.
|
|
24
|
+
#
|
|
25
|
+
# A release is a tag, v<major>.<minor>.<patch>, on a commit of main, or of a
|
|
26
|
+
# release/<version> branch when a released version needs a patch. This moves
|
|
27
|
+
# ${ASTROID_DEPLOY_BRANCH} to the tagged commit, and Workers Builds deploys it to
|
|
28
|
+
# production. Nobody commits to ${ASTROID_DEPLOY_BRANCH}; a repository ruleset lets
|
|
29
|
+
# only this workflow update it. To roll back, tag the earlier commit with the
|
|
30
|
+
# next patch version.
|
|
31
|
+
name: Release
|
|
32
|
+
|
|
33
|
+
on:
|
|
34
|
+
push:
|
|
35
|
+
tags: ["v*"]
|
|
36
|
+
|
|
37
|
+
permissions:
|
|
38
|
+
contents: write
|
|
39
|
+
|
|
40
|
+
# One release at a time, in the order the tags arrived.
|
|
41
|
+
concurrency:
|
|
42
|
+
group: release
|
|
43
|
+
cancel-in-progress: false
|
|
44
|
+
|
|
45
|
+
jobs:
|
|
46
|
+
release:
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v4
|
|
50
|
+
with:
|
|
51
|
+
fetch-depth: 0
|
|
52
|
+
|
|
53
|
+
- name: Check the tag
|
|
54
|
+
run: |
|
|
55
|
+
tag="$GITHUB_REF_NAME"
|
|
56
|
+
if ! [[ "$tag" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then
|
|
57
|
+
echo "::error::A release tag is v<major>.<minor>.<patch>, such as v1.4.0. Got $tag."
|
|
58
|
+
exit 1
|
|
59
|
+
fi
|
|
60
|
+
sha="$(git rev-list -n 1 "$tag")"
|
|
61
|
+
if git merge-base --is-ancestor "$sha" origin/main; then
|
|
62
|
+
echo "$tag is on main."
|
|
63
|
+
elif git branch -r --contains "$sha" | grep -q "origin/release/"; then
|
|
64
|
+
echo "$tag is on a release branch."
|
|
65
|
+
else
|
|
66
|
+
echo "::error::$tag isn't on main or a release/ branch, so it can't be released."
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
echo "SHA=$sha" >> "$GITHUB_ENV"
|
|
70
|
+
|
|
71
|
+
- name: Move ${ASTROID_DEPLOY_BRANCH} to the tag
|
|
72
|
+
run: git push --force origin "$SHA:refs/heads/${ASTROID_DEPLOY_BRANCH}"
|
|
73
|
+
|
|
74
|
+
- name: Create the GitHub release
|
|
75
|
+
env:
|
|
76
|
+
GH_TOKEN: \${{ github.token }}
|
|
77
|
+
run: gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes
|
|
78
|
+
`;
|
|
79
|
+
}
|
package/dist/queues/scaffold.js
CHANGED
|
@@ -196,10 +196,14 @@ export function generateAstroidWebhookRoute(config, forProvider) {
|
|
|
196
196
|
"// Unprovisioned (the secret is absent or still the placeholder) answers 503,",
|
|
197
197
|
"// which keeps the provider retrying—so events delivered before you set the",
|
|
198
198
|
"// secret land afterwards instead of being lost.",
|
|
199
|
+
"//",
|
|
200
|
+
"// 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.",
|
|
199
202
|
'import type { APIRoute } from "astro";',
|
|
200
203
|
'import { handleWebhook, readModuleSecret } from "astroidjs";',
|
|
201
204
|
'import { env } from "cloudflare:workers";',
|
|
202
205
|
`import { ${p.verifier} } from ${JSON.stringify(p.module)};`,
|
|
206
|
+
'import { handleQueueMessage } from "../../../queue";',
|
|
203
207
|
"",
|
|
204
208
|
"export const prerender = false;",
|
|
205
209
|
"",
|
|
@@ -211,6 +215,7 @@ export function generateAstroidWebhookRoute(config, forProvider) {
|
|
|
211
215
|
` provider: ${JSON.stringify(provider)},`,
|
|
212
216
|
` secret: await readModuleSecret(env.${COMMERCE_PROVIDER_SECRETS[provider].webhook}),`,
|
|
213
217
|
` queue: env.${ASTROID_QUEUE_BINDING},`,
|
|
218
|
+
" inline: (message) => handleQueueMessage(env, message),",
|
|
214
219
|
` verify: ({ raw, secret }) => ${p.call},`,
|
|
215
220
|
" });",
|
|
216
221
|
"};",
|
package/dist/queues/webhook.d.ts
CHANGED
|
@@ -30,6 +30,22 @@ export interface WebhookRouteOptions {
|
|
|
30
30
|
verify: (input: WebhookVerifyInput) => boolean | Promise<boolean>;
|
|
31
31
|
/** The queue binding, or null/undefined when Queues aren't provisioned. */
|
|
32
32
|
queue?: QueueProducer | null;
|
|
33
|
+
/**
|
|
34
|
+
* Run a message in the request instead, when `queue` is absent. A staging
|
|
35
|
+
* Preview leaves the queue unbound, because a queue consumer can't target a
|
|
36
|
+
* Preview (louise-toolkit ADR 0017), so without this every webhook a Preview
|
|
37
|
+
* receives answers 503 and the provider retries it forever.
|
|
38
|
+
*
|
|
39
|
+
* Pass the same handler the queue consumer runs: `(message) =>
|
|
40
|
+
* handleQueueMessage(env, message)`. Throwing answers 503, so the provider
|
|
41
|
+
* redelivers, which is the retry the queue would have given it.
|
|
42
|
+
*
|
|
43
|
+
* It's a fallback, not an alternative: a sync that outlasts the provider's
|
|
44
|
+
* delivery timeout reads to the provider as a failure, which is why
|
|
45
|
+
* production enqueues. On staging, with a sandbox catalog, that's a fair
|
|
46
|
+
* trade, and the queue wins whenever both are there.
|
|
47
|
+
*/
|
|
48
|
+
inline?: (message: AstroidQueueMessage) => Promise<void>;
|
|
33
49
|
/**
|
|
34
50
|
* Pull the event type out of the parsed payload. Defaults to a `type` field;
|
|
35
51
|
* override for providers that name it differently (Fourthwall's `testMode`
|
package/dist/queues/webhook.js
CHANGED
|
@@ -65,10 +65,27 @@ export async function handleWebhook(request, url, options) {
|
|
|
65
65
|
if (options.accept && !options.accept(type, payload)) {
|
|
66
66
|
return text("Ignored", 202);
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const message = {
|
|
69
|
+
kind: "webhook",
|
|
70
|
+
provider: options.provider,
|
|
71
|
+
type,
|
|
72
|
+
payload,
|
|
73
|
+
};
|
|
74
|
+
if (!options.queue) {
|
|
75
|
+
if (!options.inline)
|
|
76
|
+
return text("Queue not configured", 503);
|
|
77
|
+
try {
|
|
78
|
+
await options.inline(message);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Same contract as a failed send: the event is real, so ask for it again.
|
|
82
|
+
return text("Processing failed", 503);
|
|
83
|
+
}
|
|
84
|
+
// 200, not 202: the work already happened.
|
|
85
|
+
return text("Processed", 200);
|
|
86
|
+
}
|
|
70
87
|
try {
|
|
71
|
-
await options.queue.send(
|
|
88
|
+
await options.queue.send(message);
|
|
72
89
|
}
|
|
73
90
|
catch {
|
|
74
91
|
// The signature was good, so this event is real and worth keeping. 503 asks
|
|
@@ -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
|
|
43
|
-
|
|
44
|
-
|
|
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 = {
|
|
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
|
package/dist/schema/index.d.ts
CHANGED
package/dist/schema/index.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -34,14 +34,13 @@ const COMMERCE_ORIGINS = {
|
|
|
34
34
|
// convention) and it must not need a rebuild to switch on—CSP is baked at
|
|
35
35
|
// build time, the secret is a runtime value.
|
|
36
36
|
const TURNSTILE = turnstileCsp();
|
|
37
|
-
// The map module
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// `
|
|
44
|
-
const MAP = { worker: ["blob:"] };
|
|
37
|
+
// The map module adds nothing, and that's the whole argument for the
|
|
38
|
+
// self-hosted basemap: the PMTiles archive is served same-origin, so
|
|
39
|
+
// `connect-src` stays `'self'` with no tile host and no API key to allow. The
|
|
40
|
+
// generated `<MapEmbed>` hands MapLibre 6 a same-origin worker URL, which it
|
|
41
|
+
// constructs directly, so `worker-src 'self'` covers it with no `blob:`.
|
|
42
|
+
// (MapLibre 5 built its workers from blob: URLs; a site still on 5 adds
|
|
43
|
+
// `worker: ["blob:"]` to its own `security.cspOrigins`.)
|
|
45
44
|
const DIRECTIVE_KEYS = ["script", "style", "frame", "connect", "font", "img", "worker"];
|
|
46
45
|
/** Merge origin lists, de-duplicated, order preserved. */
|
|
47
46
|
function mergeOrigins(...sets) {
|
|
@@ -57,7 +56,7 @@ function mergeOrigins(...sets) {
|
|
|
57
56
|
* what a config implies without rebuilding the whole policy.
|
|
58
57
|
*/
|
|
59
58
|
export function astroidCspOrigins(config) {
|
|
60
|
-
return mergeOrigins(TURNSTILE,
|
|
59
|
+
return mergeOrigins(TURNSTILE,
|
|
61
60
|
// EVERY provider in play, not "the" provider: a site can run Stripe for
|
|
62
61
|
// invoicing beside Fourthwall for the storefront, and a policy that allowed
|
|
63
62
|
// only one of them blocks the other's SDK at runtime.
|
package/dist/worker/generate.js
CHANGED
|
@@ -122,7 +122,9 @@ export function generateAstroidWorker(config) {
|
|
|
122
122
|
// The site's sanitize + read hooks (config.settings.hooks), spread last
|
|
123
123
|
// so they add to the call rather than replace any part of it.
|
|
124
124
|
const hooksArg = config.settings?.hooks ? ", ...settingsHooks" : "";
|
|
125
|
-
|
|
125
|
+
// Built per request, because the media base is per environment: a
|
|
126
|
+
// Preview's `MEDIA_URL` differs from production's (see `mediaBaseOf`).
|
|
127
|
+
return `(request, env, ctx) => settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: mediaBaseOf(env)${customArg}${hooksArg} })(request, env, ctx)`;
|
|
126
128
|
}
|
|
127
129
|
// `aiRunner` rather than `(env) => env.AI`: it reads the binding AND the
|
|
128
130
|
// LOUISE_AI kill switch, so all three assists share one definition of
|
|
@@ -201,6 +203,7 @@ export function generateAstroidWorker(config) {
|
|
|
201
203
|
"astroidPagesCollection",
|
|
202
204
|
"astroidPagesWriteHooks",
|
|
203
205
|
"readModuleSecret",
|
|
206
|
+
"setAstroidMediaBase",
|
|
204
207
|
...(inquiries ? ["sendInquiryMail"] : []),
|
|
205
208
|
...(queues ? ["type AstroidQueueMessage"] : []),
|
|
206
209
|
].sort();
|
|
@@ -228,7 +231,18 @@ export function generateAstroidWorker(config) {
|
|
|
228
231
|
p('import { handleQueueMessage } from "./queue.js";');
|
|
229
232
|
}
|
|
230
233
|
p();
|
|
231
|
-
p(`const
|
|
234
|
+
p(`const DEFAULT_MEDIA_BASE = ${JSON.stringify(mediaBase)};`);
|
|
235
|
+
p("// The public media base for this request's environment. `vars.MEDIA_URL`");
|
|
236
|
+
p('// wins, so a Preview sets its own (a path such as "/media", served from the');
|
|
237
|
+
p("// Preview's own host, since a Preview can't know its hostname in advance).");
|
|
238
|
+
p("// Read per request, not baked in: one build serves production and every");
|
|
239
|
+
p("// Preview, so nothing that differs between them can be a constant.");
|
|
240
|
+
p("const mediaBaseOf = (env: CloudflareEnv): string =>");
|
|
241
|
+
p(' (env.MEDIA_URL || DEFAULT_MEDIA_BASE).replace(/\\/+$/, "");');
|
|
242
|
+
p("// The checks built once from the config (the page sanitizers, the settings");
|
|
243
|
+
p("// action) read the base when they run. Recorded at startup, since an isolate");
|
|
244
|
+
p("// runs one deployment and `MEDIA_URL` is fixed per deployment.");
|
|
245
|
+
p("setAstroidMediaBase(env.MEDIA_URL);");
|
|
232
246
|
p("const pagesCollection = astroidPagesCollection(astroidConfig);");
|
|
233
247
|
p("// Sanitize + section-catalog validation for the raw pagesRoute, which runs");
|
|
234
248
|
p("// no collection hook—the same contract versionsRoute gets from the config.");
|
|
@@ -294,7 +308,7 @@ export function generateAstroidWorker(config) {
|
|
|
294
308
|
p("// failed crawl or a failed COUNT yields zero rather than aborting the scan,");
|
|
295
309
|
p("// because a partial health report is worth strictly more than none.");
|
|
296
310
|
p("async function runHealthScan(env: CloudflareEnv) {");
|
|
297
|
-
p(" const origin = env.SITE_URL ??
|
|
311
|
+
p(" const origin = env.SITE_URL ?? mediaBaseOf(env);");
|
|
298
312
|
p(" const [brokenLinks, missingAlt, seoGaps] = await Promise.all([");
|
|
299
313
|
p(' checkLinks({ base: origin, paths: ["/"] }).catch(() => []),');
|
|
300
314
|
p(" countRows(env, \"SELECT COUNT(*) AS n FROM media WHERE alt IS NULL OR alt = ''\"),");
|
|
@@ -376,17 +390,26 @@ export function generateAstroidWorker(config) {
|
|
|
376
390
|
}
|
|
377
391
|
p("];");
|
|
378
392
|
p();
|
|
379
|
-
p("// Stream uploaded media back from R2 at
|
|
393
|
+
p("// Stream uploaded media back from R2 at the media base (self-hosted, no public");
|
|
394
|
+
p("// bucket). The base takes two shapes, and each is matched its own way:");
|
|
380
395
|
p("//");
|
|
381
|
-
p(
|
|
382
|
-
p(
|
|
383
|
-
p(
|
|
384
|
-
p("//
|
|
385
|
-
p(
|
|
396
|
+
p('// - An ORIGIN ("https://media.example.com") is a media host, where the whole');
|
|
397
|
+
p("// pathname is the R2 key. Compared by origin, never by path prefix: a");
|
|
398
|
+
p('// `url.pathname` ("/web/foo.jpg") can never start with an origin, and a guard');
|
|
399
|
+
p("// that compared the two once never ran, so every upload 404'd.");
|
|
400
|
+
p('// - A PATH ("/media") is a prefix on the site\'s own host, which is how a');
|
|
401
|
+
p("// Preview serves media, since it can't know its hostname in advance.");
|
|
386
402
|
p("const mediaAssetRoute: WorkerRoute<CloudflareEnv> = async (request, env) => {");
|
|
387
403
|
p(" const url = new URL(request.url);");
|
|
388
|
-
p("
|
|
389
|
-
p(
|
|
404
|
+
p(" const base = mediaBaseOf(env);");
|
|
405
|
+
p(' const path = base.startsWith("/")');
|
|
406
|
+
p(" ? url.pathname.startsWith(`${base}/`)");
|
|
407
|
+
p(" ? url.pathname.slice(base.length + 1)");
|
|
408
|
+
p(' : ""');
|
|
409
|
+
p(" : url.origin === base");
|
|
410
|
+
p(" ? url.pathname.slice(1)");
|
|
411
|
+
p(' : "";');
|
|
412
|
+
p(" const key = decodeURIComponent(path);");
|
|
390
413
|
p(" if (!key) return undefined;");
|
|
391
414
|
p(" const obj = await env.MEDIA.get(key);");
|
|
392
415
|
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.
|
|
3
|
+
"version": "0.16.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.33.0",
|
|
68
68
|
"solid-js": "^1.9.15",
|
|
69
|
-
"typescript": "^
|
|
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.33.0",
|
|
75
75
|
"solid-js": "^1.9.0"
|
|
76
76
|
},
|
|
77
77
|
"peerDependenciesMeta": {
|