@cparkerwebm/webmonterey 1.1.0 → 1.3.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/CHANGELOG.md +104 -0
- package/README.md +6 -0
- package/dist/webm.mjs +445 -61
- package/package.json +5 -3
- package/skills/launch/SKILL.md +57 -9
- package/skills/start/SKILL.md +66 -12
- package/skills/traps/SKILL.md +17 -0
- package/src/assets/opengraph-webmaster.png +0 -0
- package/src/cli/audit.test.ts +120 -0
- package/src/cli/audit.ts +323 -0
- package/src/cli/checks.test.ts +46 -6
- package/src/cli/checks.ts +52 -12
- package/src/cli/doctor.ts +78 -2
- package/src/cli/scaffold.test.ts +9 -0
- package/src/cli/scaffold.ts +12 -30
- package/src/cli/settings.ts +106 -0
- package/src/cli/sync.test.ts +61 -0
- package/src/cli/sync.ts +64 -1
- package/src/emails/footer.ts +3 -3
- package/src/includes/cloudflare/r2/media.ts +1 -1
- package/src/includes/webmonterey/config.test.ts +51 -0
- package/src/includes/webmonterey/config.ts +49 -0
- package/src/includes/webmonterey/copy-defaults.ts +20 -0
- package/src/includes/webmonterey/webmaster/Webmaster.astro +52 -0
- package/src/includes/webmonterey/webmaster/webmaster.test.ts +74 -0
- package/src/includes/webmonterey/webmaster/webmaster.ts +89 -0
- package/src/integration/index.ts +91 -4
- package/src/integration/virtual.d.ts +19 -1
- package/src/layouts/base.astro +31 -8
- package/src/package.test.ts +20 -0
- package/src/pages/robots.txt.ts +14 -0
- package/src/pages/webmaster-og.png.ts +31 -0
- package/src/pages/webmaster.astro +121 -0
- package/template/public/opengraph.png +0 -0
- package/template/scripts/test-hooks.mjs +1 -1
- package/template/site/CLAUDE.md +25 -2
- package/src/includes/webmonterey/credits/Credit.astro +0 -80
- package/src/includes/webmonterey/credits/credit.test.ts +0 -111
- package/src/includes/webmonterey/credits/credit.ts +0 -59
- package/template/public/open-graph.png +0 -0
- /package/template/assets/{open-graph.png → opengraph.png} +0 -0
package/dist/webm.mjs
CHANGED
|
@@ -196,6 +196,53 @@ var init_mcp = __esm({
|
|
|
196
196
|
}
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
// src/cli/settings.ts
|
|
200
|
+
function projectSettings(repo) {
|
|
201
|
+
return {
|
|
202
|
+
"//": `Project settings for ${repo}.`,
|
|
203
|
+
"//mcp": "A server declared in .mcp.json is INERT until approved on each machine. Without this line the rules that say consult the Astro and MDN docs before using an API would depend on whoever cloned the repo happening to hit Approve.",
|
|
204
|
+
includeCoAuthoredBy: false,
|
|
205
|
+
enabledMcpjsonServers: [...MCP_NAMES],
|
|
206
|
+
"//permissions": PERMISSIONS_NOTE,
|
|
207
|
+
permissions: { deny: [...DENY_RULES] }
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function withDenyRules(settings) {
|
|
211
|
+
const permissions = settings.permissions && typeof settings.permissions === "object" ? { ...settings.permissions } : {};
|
|
212
|
+
const current = Array.isArray(permissions.deny) ? permissions.deny.filter((r) => typeof r === "string") : [];
|
|
213
|
+
const removed = current.filter((r) => STALE_RULES.includes(r));
|
|
214
|
+
const kept = current.filter((r) => !STALE_RULES.includes(r));
|
|
215
|
+
const added = DENY_RULES.filter((r) => !kept.includes(r));
|
|
216
|
+
if (!added.length && !removed.length) return { settings, added, removed };
|
|
217
|
+
permissions.deny = [...kept, ...added];
|
|
218
|
+
return {
|
|
219
|
+
settings: { ...settings, "//permissions": PERMISSIONS_NOTE, permissions },
|
|
220
|
+
added,
|
|
221
|
+
removed
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
var DENY_RULES, STALE_RULES, PERMISSIONS_NOTE;
|
|
225
|
+
var init_settings = __esm({
|
|
226
|
+
"src/cli/settings.ts"() {
|
|
227
|
+
"use strict";
|
|
228
|
+
init_mcp();
|
|
229
|
+
DENY_RULES = [
|
|
230
|
+
"Read(**/.dev.vars)",
|
|
231
|
+
"Read(**/.dev.vars.*)",
|
|
232
|
+
"Read(**/.env)",
|
|
233
|
+
"Read(**/.env.*)",
|
|
234
|
+
"Read(**/*.pem)",
|
|
235
|
+
"Read(**/*.key)",
|
|
236
|
+
"Read(**/.npmrc)",
|
|
237
|
+
"Edit(**/.dev.vars)",
|
|
238
|
+
"Edit(**/.env)",
|
|
239
|
+
"Edit(**/node_modules/**)"
|
|
240
|
+
];
|
|
241
|
+
STALE_RULES = ["Write(**/.dev.vars)", "Write(**/.env)"];
|
|
242
|
+
PERMISSIONS_NOTE = "The deny list is package-managed: `webm sync` adds any rule that is missing on every install and leaves everything else in this file alone. Edit(**/node_modules/**) is rule 12 of CLAUDE.md made mechanical - a session in this repo never edits the package.";
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
199
246
|
// src/cli/scaffold.ts
|
|
200
247
|
function shiftDays(date, days) {
|
|
201
248
|
const shifted = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
|
|
@@ -279,7 +326,7 @@ export default defineConfig({
|
|
|
279
326
|
worker: n.worker,
|
|
280
327
|
slug: n.slug,
|
|
281
328
|
launched: null,
|
|
282
|
-
"//environment": "What this deployment is FOR. 'staging' redirects EVERY email the site sends to stagingEmail below, so testing a form on a preview cannot reach the client's real contacts. A new site starts here; /webm:launch flips it to 'production'.
|
|
329
|
+
"//environment": "What this deployment is FOR. 'staging' makes every build a PREVIEW - every page noindex with no canonical, no sitemap, robots.txt disallowing everything, no Google Tag Manager - on every hostname, main included, so a site that has not launched cannot be indexed before it exists; and it redirects EVERY email the site sends to stagingEmail below, so testing a form on a preview cannot reach the client's real contacts. A new site starts here; /webm:launch flips it to 'production' once the custom domain is live, and that flip is what makes the site indexable. A branch other than main is a preview regardless, and anything served from workers.dev redirects its mail regardless, so a branch preview of a live site is covered too.",
|
|
283
330
|
environment: "staging",
|
|
284
331
|
"//stagingEmail": "Where staging email goes instead of its real recipients. REQUIRED while environment is staging - a staging site with nowhere to send refuses to send rather than guessing. webm doctor checks.",
|
|
285
332
|
stagingEmail: options.stagingEmail ?? "",
|
|
@@ -372,31 +419,7 @@ export default defineConfig({
|
|
|
372
419
|
null,
|
|
373
420
|
2
|
|
374
421
|
) + "\n";
|
|
375
|
-
files[".claude/settings.json"] = JSON.stringify(
|
|
376
|
-
{
|
|
377
|
-
"//": `Project settings for ${n.repo}.`,
|
|
378
|
-
"//mcp": "A server declared in .mcp.json is INERT until approved on each machine. Without this line the rules that say consult the Astro and MDN docs before using an API would depend on whoever cloned the repo happening to hit Approve.",
|
|
379
|
-
includeCoAuthoredBy: false,
|
|
380
|
-
enabledMcpjsonServers: MCP_NAMES,
|
|
381
|
-
permissions: {
|
|
382
|
-
deny: [
|
|
383
|
-
"Read(**/.dev.vars)",
|
|
384
|
-
"Read(**/.dev.vars.*)",
|
|
385
|
-
"Read(**/.env)",
|
|
386
|
-
"Read(**/.env.*)",
|
|
387
|
-
"Read(**/*.pem)",
|
|
388
|
-
"Read(**/*.key)",
|
|
389
|
-
"Read(**/.npmrc)",
|
|
390
|
-
"Edit(**/.dev.vars)",
|
|
391
|
-
"Edit(**/.env)",
|
|
392
|
-
"Write(**/.dev.vars)",
|
|
393
|
-
"Write(**/.env)"
|
|
394
|
-
]
|
|
395
|
-
}
|
|
396
|
-
},
|
|
397
|
-
null,
|
|
398
|
-
2
|
|
399
|
-
) + "\n";
|
|
422
|
+
files[".claude/settings.json"] = JSON.stringify(projectSettings(n.repo), null, 2) + "\n";
|
|
400
423
|
files[".mcp.json"] = JSON.stringify(mcpConfig(), null, 2) + "\n";
|
|
401
424
|
files["src/actions/index.ts"] = `/*
|
|
402
425
|
* Form handlers.
|
|
@@ -550,8 +573,11 @@ suffix - \`${n.slug}-portal\`.
|
|
|
550
573
|
|
|
551
574
|
## Deploying
|
|
552
575
|
|
|
553
|
-
|
|
554
|
-
|
|
576
|
+
The Worker is created ONCE from a laptop - \`npm run build && npx wrangler deploy\` - and the
|
|
577
|
+
repo is then connected to it in the dashboard (Worker \u2192 Settings \u2192 Builds). /webm:start does
|
|
578
|
+
both. From then on, push to deploy: a \`wrangler deploy\` from a laptop after that creates a
|
|
579
|
+
version no build produced, so history stops describing what is live and the next push
|
|
580
|
+
reverts it.
|
|
555
581
|
`;
|
|
556
582
|
return files;
|
|
557
583
|
}
|
|
@@ -560,6 +586,7 @@ var init_scaffold = __esm({
|
|
|
560
586
|
"use strict";
|
|
561
587
|
init_slug();
|
|
562
588
|
init_mcp();
|
|
589
|
+
init_settings();
|
|
563
590
|
}
|
|
564
591
|
});
|
|
565
592
|
|
|
@@ -578,7 +605,7 @@ import {
|
|
|
578
605
|
writeFileSync,
|
|
579
606
|
readdirSync
|
|
580
607
|
} from "node:fs";
|
|
581
|
-
import { basename, join as join2 } from "node:path";
|
|
608
|
+
import { basename, dirname as dirname2, join as join2 } from "node:path";
|
|
582
609
|
function syncDir(source, target) {
|
|
583
610
|
if (!existsSync2(source)) return [];
|
|
584
611
|
mkdirSync(target, { recursive: true });
|
|
@@ -604,6 +631,30 @@ function addMissing(source, target) {
|
|
|
604
631
|
}
|
|
605
632
|
return added.sort();
|
|
606
633
|
}
|
|
634
|
+
function ensureSettings(siteRoot) {
|
|
635
|
+
const path = join2(siteRoot, ".claude", "settings.json");
|
|
636
|
+
if (!existsSync2(path)) {
|
|
637
|
+
mkdirSync(dirname2(path), { recursive: true });
|
|
638
|
+
writeFileSync(path, JSON.stringify(projectSettings(basename(siteRoot)), null, 2) + "\n");
|
|
639
|
+
return { added: [...DENY_RULES], removed: [], created: true, skipped: null };
|
|
640
|
+
}
|
|
641
|
+
let parsed;
|
|
642
|
+
try {
|
|
643
|
+
parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
644
|
+
} catch {
|
|
645
|
+
return {
|
|
646
|
+
added: [],
|
|
647
|
+
removed: [],
|
|
648
|
+
created: false,
|
|
649
|
+
skipped: ".claude/settings.json is not valid JSON, so it was left alone"
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
const { settings, added, removed } = withDenyRules(parsed);
|
|
653
|
+
if (added.length || removed.length) {
|
|
654
|
+
writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
|
|
655
|
+
}
|
|
656
|
+
return { added, removed, created: false, skipped: null };
|
|
657
|
+
}
|
|
607
658
|
function listSkills(dir) {
|
|
608
659
|
if (!existsSync2(dir)) return [];
|
|
609
660
|
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync2(join2(dir, e.name, "SKILL.md"))).map((e) => e.name).sort();
|
|
@@ -660,7 +711,8 @@ function sync(siteRoot) {
|
|
|
660
711
|
* which is how code depending on an unpublished package reached main without a word.
|
|
661
712
|
*/
|
|
662
713
|
workflows: syncDir(join2(template, "workflows"), join2(siteRoot, ".github/workflows")),
|
|
663
|
-
migrations: addMissing(join2(template, "migrations"), join2(siteRoot, "migrations"))
|
|
714
|
+
migrations: addMissing(join2(template, "migrations"), join2(siteRoot, "migrations")),
|
|
715
|
+
settings: ensureSettings(siteRoot)
|
|
664
716
|
};
|
|
665
717
|
}
|
|
666
718
|
function ensureGitignored(siteRoot) {
|
|
@@ -691,6 +743,19 @@ function run(argv2) {
|
|
|
691
743
|
for (const m of result.migrations) {
|
|
692
744
|
console.log(` + migrations/${m} (apply it: npx wrangler d1 migrations apply <DB> --remote)`);
|
|
693
745
|
}
|
|
746
|
+
if (result.settings.created) {
|
|
747
|
+
console.log(` + .claude/settings.json`);
|
|
748
|
+
} else if (result.settings.skipped) {
|
|
749
|
+
console.log(` ${result.settings.skipped}`);
|
|
750
|
+
} else if (result.settings.added.length || result.settings.removed.length) {
|
|
751
|
+
const parts = [
|
|
752
|
+
result.settings.added.length && `+${result.settings.added.length} deny`,
|
|
753
|
+
result.settings.removed.length && `-${result.settings.removed.length} stale`
|
|
754
|
+
].filter(Boolean);
|
|
755
|
+
console.log(
|
|
756
|
+
` .claude/settings.json: ${parts.join(", ")} (package-managed rules; yours are kept)`
|
|
757
|
+
);
|
|
758
|
+
}
|
|
694
759
|
if (ensureGitignored(siteRoot)) {
|
|
695
760
|
console.log(` gitignored .claude/skills/${NAMESPACE}/`);
|
|
696
761
|
}
|
|
@@ -707,13 +772,14 @@ var init_sync = __esm({
|
|
|
707
772
|
"src/cli/sync.ts"() {
|
|
708
773
|
"use strict";
|
|
709
774
|
init_package_root();
|
|
775
|
+
init_settings();
|
|
710
776
|
NAMESPACE = "webm";
|
|
711
777
|
}
|
|
712
778
|
});
|
|
713
779
|
|
|
714
780
|
// src/cli/seed.ts
|
|
715
781
|
import { cpSync as cpSync2, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
716
|
-
import { dirname as
|
|
782
|
+
import { dirname as dirname3, join as join3, relative } from "node:path";
|
|
717
783
|
function walk(dir) {
|
|
718
784
|
if (!existsSync3(dir)) return [];
|
|
719
785
|
return readdirSync2(dir, { withFileTypes: true }).flatMap((e) => {
|
|
@@ -732,7 +798,7 @@ function seed(packageRoot, siteRoot) {
|
|
|
732
798
|
const rel = relative(source, file);
|
|
733
799
|
const dest = join3(siteRoot, to === "." ? rel : join3(to, rel));
|
|
734
800
|
if (existsSync3(dest)) continue;
|
|
735
|
-
mkdirSync2(
|
|
801
|
+
mkdirSync2(dirname3(dest), { recursive: true });
|
|
736
802
|
cpSync2(file, dest);
|
|
737
803
|
written.push(relative(siteRoot, dest));
|
|
738
804
|
}
|
|
@@ -800,7 +866,7 @@ __export(new_exports, {
|
|
|
800
866
|
});
|
|
801
867
|
import { execFileSync } from "node:child_process";
|
|
802
868
|
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync as readdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
803
|
-
import { dirname as
|
|
869
|
+
import { dirname as dirname4, join as join4, resolve } from "node:path";
|
|
804
870
|
function parseArgs(argv2) {
|
|
805
871
|
const positional = argv2.filter((a) => !a.startsWith("--"));
|
|
806
872
|
const flag = (name) => {
|
|
@@ -863,7 +929,7 @@ function run2(argv2) {
|
|
|
863
929
|
});
|
|
864
930
|
for (const [path, contents] of Object.entries(files)) {
|
|
865
931
|
const full = join4(root, path);
|
|
866
|
-
mkdirSync3(
|
|
932
|
+
mkdirSync3(dirname4(full), { recursive: true });
|
|
867
933
|
writeFileSync3(full, contents);
|
|
868
934
|
}
|
|
869
935
|
const seeded = seed(PACKAGE_ROOT, root);
|
|
@@ -1955,17 +2021,17 @@ var init_checks = __esm({
|
|
|
1955
2021
|
}
|
|
1956
2022
|
},
|
|
1957
2023
|
{
|
|
1958
|
-
id: "
|
|
1959
|
-
title: "Something renders the
|
|
1960
|
-
silentAs: 'the site ships with no "Powered by WebMonterey" and nobody notices for months',
|
|
2024
|
+
id: "webmaster-credit",
|
|
2025
|
+
title: "Something renders the webmaster credit",
|
|
2026
|
+
silentAs: 'the site ships with no "Powered by WebMonterey", the /webmaster page is orphaned, and nobody notices for months',
|
|
1961
2027
|
run(ctx) {
|
|
1962
2028
|
if (ctx.components.size === 0) return pass;
|
|
1963
2029
|
if (ctx.site.domain === "webmonterey.com") return pass;
|
|
1964
2030
|
for (const src of ctx.components.values()) {
|
|
1965
|
-
if (/webmonterey\/
|
|
2031
|
+
if (/webmonterey\/webmaster/.test(stripComments(src))) return pass;
|
|
1966
2032
|
}
|
|
1967
2033
|
return warn(
|
|
1968
|
-
"no component imports @cparkerwebm/webmonterey/webmonterey/
|
|
2034
|
+
"no component imports @cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro. The footer component is where it goes; it links to the /webmaster page."
|
|
1969
2035
|
);
|
|
1970
2036
|
}
|
|
1971
2037
|
},
|
|
@@ -1982,7 +2048,7 @@ var init_checks = __esm({
|
|
|
1982
2048
|
{
|
|
1983
2049
|
id: "environment",
|
|
1984
2050
|
title: "The declared environment matches where the site actually is",
|
|
1985
|
-
silentAs: "a launched site whose client email is still being diverted to the agency's inbox",
|
|
2051
|
+
silentAs: "a launched site whose client email is still being diverted to the agency's inbox, and whose every page is noindex",
|
|
1986
2052
|
run(ctx) {
|
|
1987
2053
|
const declared = ctx.site.environment;
|
|
1988
2054
|
if (declared !== void 0 && declared !== "production" && declared !== "staging") {
|
|
@@ -1992,12 +2058,37 @@ var init_checks = __esm({
|
|
|
1992
2058
|
}
|
|
1993
2059
|
if (declared === "staging" && isConfigured(ctx.site.launched)) {
|
|
1994
2060
|
return fail(
|
|
1995
|
-
`this site launched on ${ctx.site.launched} but is still declared staging, so every email it sends is being redirected away from its real recipients. Set "environment": "production" in webmonterey.json.`
|
|
2061
|
+
`this site launched on ${ctx.site.launched} but is still declared staging, so every email it sends is being redirected away from its real recipients - and since 1.3.0 every build of a staging site is a preview: noindex on every page, no canonical, no sitemap, robots.txt disallowing everything. The live site is dropping out of search. Set "environment": "production" in webmonterey.json.`
|
|
1996
2062
|
);
|
|
1997
2063
|
}
|
|
1998
2064
|
if (declared !== "staging" && !isConfigured(ctx.site.launched)) {
|
|
1999
2065
|
return warn(
|
|
2000
|
-
`this site has no launch date but is treated as production, so testing a form will email the client's real contacts. Set "environment": "staging" in webmonterey.json until /webm:launch.`
|
|
2066
|
+
`this site has no launch date but is treated as production, so testing a form will email the client's real contacts and every page is indexable on its workers.dev hostname. Set "environment": "staging" in webmonterey.json until /webm:launch.`
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
return pass;
|
|
2070
|
+
}
|
|
2071
|
+
},
|
|
2072
|
+
{
|
|
2073
|
+
/*
|
|
2074
|
+
* THE WORKER EXISTS. /webm:start used to end with a repo, a D1 database and an instruction
|
|
2075
|
+
* to create the Worker in the dashboard by hand - and on one site nobody did. Nothing local
|
|
2076
|
+
* notices: the build is green, every other check here is green, and the site is a
|
|
2077
|
+
* workers.dev hostname that answers nothing. The Worker is the one resource whose absence
|
|
2078
|
+
* has no symptom on disk, so this asks Cloudflare through wrangler - the one thing a laptop
|
|
2079
|
+
* can ask - and steps aside with a note when it cannot.
|
|
2080
|
+
*/
|
|
2081
|
+
id: "worker-exists",
|
|
2082
|
+
title: "The Worker exists",
|
|
2083
|
+
silentAs: "a site with a repo, a database and nothing serving",
|
|
2084
|
+
run(ctx) {
|
|
2085
|
+
if (ctx.worker.skipped) return { status: "pass", detail: `skipped: ${ctx.worker.skipped}` };
|
|
2086
|
+
if (!ctx.worker.name) {
|
|
2087
|
+
return warn("wrangler.jsonc names no Worker, so there is nothing to look for");
|
|
2088
|
+
}
|
|
2089
|
+
if (!ctx.worker.deployments) {
|
|
2090
|
+
return warn(
|
|
2091
|
+
`no deployment of a Worker named "${ctx.worker.name}" on this account. Create it once from the laptop - npm run build && npx wrangler deploy - then connect the repo to it in the dashboard (Worker \u2192 Settings \u2192 Builds). /webm:start, steps 5 and 6.`
|
|
2001
2092
|
);
|
|
2002
2093
|
}
|
|
2003
2094
|
return pass;
|
|
@@ -2079,25 +2170,27 @@ __export(doctor_exports, {
|
|
|
2079
2170
|
buildContext: () => buildContext,
|
|
2080
2171
|
run: () => run5
|
|
2081
2172
|
});
|
|
2173
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2082
2174
|
import { existsSync as existsSync8, readFileSync as readFileSync7, readdirSync as readdirSync6 } from "node:fs";
|
|
2083
|
-
import {
|
|
2175
|
+
import { createRequire } from "node:module";
|
|
2176
|
+
import { dirname as dirname5, join as join7, relative as relative3, resolve as resolve2 } from "node:path";
|
|
2084
2177
|
function parseJsonc(source) {
|
|
2085
2178
|
const stripped = source.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => comment ? "" : m).replace(/,(\s*[}\]])/g, "$1");
|
|
2086
2179
|
return JSON.parse(stripped);
|
|
2087
2180
|
}
|
|
2088
2181
|
function readTree(root, dir, exts) {
|
|
2089
2182
|
const out = /* @__PURE__ */ new Map();
|
|
2090
|
-
const
|
|
2183
|
+
const walk4 = (d) => {
|
|
2091
2184
|
if (!existsSync8(d)) return;
|
|
2092
2185
|
for (const entry2 of readdirSync6(d, { withFileTypes: true })) {
|
|
2093
2186
|
const full = join7(d, entry2.name);
|
|
2094
|
-
if (entry2.isDirectory())
|
|
2187
|
+
if (entry2.isDirectory()) walk4(full);
|
|
2095
2188
|
else if (exts.some((e) => entry2.name.endsWith(e))) {
|
|
2096
2189
|
out.set(relative3(root, full), readFileSync7(full, "utf8"));
|
|
2097
2190
|
}
|
|
2098
2191
|
}
|
|
2099
2192
|
};
|
|
2100
|
-
|
|
2193
|
+
walk4(join7(root, dir));
|
|
2101
2194
|
return out;
|
|
2102
2195
|
}
|
|
2103
2196
|
function placeholderFiles(siteRoot) {
|
|
@@ -2140,13 +2233,63 @@ function readMcp(siteRoot) {
|
|
|
2140
2233
|
enabled: read(".claude/settings.json", (p) => p.enabledMcpjsonServers ?? null)
|
|
2141
2234
|
};
|
|
2142
2235
|
}
|
|
2236
|
+
function workerState(siteRoot, name) {
|
|
2237
|
+
const worker = { name: name ?? null, deployments: null, skipped: null };
|
|
2238
|
+
if (!worker.name) return worker;
|
|
2239
|
+
let bin;
|
|
2240
|
+
try {
|
|
2241
|
+
const require2 = createRequire(join7(resolve2(siteRoot), "package.json"));
|
|
2242
|
+
bin = join7(dirname5(require2.resolve("wrangler/package.json")), "bin/wrangler.js");
|
|
2243
|
+
} catch {
|
|
2244
|
+
return {
|
|
2245
|
+
...worker,
|
|
2246
|
+
skipped: "wrangler is not installed here, so the Worker was not looked for"
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
try {
|
|
2250
|
+
const out = execFileSync2(
|
|
2251
|
+
process.execPath,
|
|
2252
|
+
[bin, "deployments", "list", "--name", worker.name, "--json"],
|
|
2253
|
+
{
|
|
2254
|
+
cwd: siteRoot,
|
|
2255
|
+
encoding: "utf8",
|
|
2256
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2257
|
+
timeout: 3e4,
|
|
2258
|
+
env: { ...process.env, WRANGLER_SEND_METRICS: "false", NO_COLOR: "1" }
|
|
2259
|
+
}
|
|
2260
|
+
);
|
|
2261
|
+
const start = out.indexOf("[");
|
|
2262
|
+
const parsed = start >= 0 ? JSON.parse(out.slice(start)) : [];
|
|
2263
|
+
return { ...worker, deployments: Array.isArray(parsed) ? parsed.length : 0 };
|
|
2264
|
+
} catch (error) {
|
|
2265
|
+
const e = error;
|
|
2266
|
+
const text = `${e.stdout ?? ""}
|
|
2267
|
+
${e.stderr ?? ""}
|
|
2268
|
+
${e.message ?? ""}`;
|
|
2269
|
+
if (/code: 10007\]|does not exist on your account/i.test(text)) {
|
|
2270
|
+
return { ...worker, deployments: 0 };
|
|
2271
|
+
}
|
|
2272
|
+
if (/CLOUDFLARE_API_TOKEN|not (logged in|authenticated)|Authentication error|code: (10000|6111|9109)\]/i.test(
|
|
2273
|
+
text
|
|
2274
|
+
)) {
|
|
2275
|
+
return {
|
|
2276
|
+
...worker,
|
|
2277
|
+
skipped: "wrangler is not logged in (npx wrangler login), so whether the Worker exists was not checked"
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
const line = text.split("\n").map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").trim()).find((l) => l && !l.startsWith("\u{1FAB5}"));
|
|
2281
|
+
return { ...worker, skipped: `wrangler could not answer: ${line ?? "no output"}` };
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2143
2284
|
function buildContext(siteRoot) {
|
|
2144
2285
|
const { site } = loadSiteFiles(siteRoot);
|
|
2145
2286
|
const wranglerPath = ["wrangler.jsonc", "wrangler.json"].map((f) => join7(siteRoot, f)).find(existsSync8);
|
|
2146
2287
|
const syncPath = join7(siteRoot, ".claude/skills/webm/.webm-sync.json");
|
|
2288
|
+
const wrangler = wranglerPath ? parseJsonc(readFileSync7(wranglerPath, "utf8")) : null;
|
|
2147
2289
|
return {
|
|
2148
2290
|
site,
|
|
2149
|
-
wrangler
|
|
2291
|
+
wrangler,
|
|
2292
|
+
worker: workerState(siteRoot, wrangler?.name),
|
|
2150
2293
|
pages: readTree(siteRoot, "src/pages", [".astro", ".ts"]),
|
|
2151
2294
|
components: readTree(siteRoot, "src/components", [".astro", ".ts"]),
|
|
2152
2295
|
today: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
@@ -2213,6 +2356,243 @@ var init_doctor = __esm({
|
|
|
2213
2356
|
}
|
|
2214
2357
|
});
|
|
2215
2358
|
|
|
2359
|
+
// src/cli/audit.ts
|
|
2360
|
+
var audit_exports = {};
|
|
2361
|
+
__export(audit_exports, {
|
|
2362
|
+
attr: () => attr,
|
|
2363
|
+
audit: () => audit,
|
|
2364
|
+
auditSitemap: () => auditSitemap,
|
|
2365
|
+
hasAttr: () => hasAttr,
|
|
2366
|
+
imagesWithoutAlt: () => imagesWithoutAlt,
|
|
2367
|
+
links: () => links,
|
|
2368
|
+
locs: () => locs,
|
|
2369
|
+
resolves: () => resolves,
|
|
2370
|
+
run: () => run6
|
|
2371
|
+
});
|
|
2372
|
+
import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
|
|
2373
|
+
import { join as join8, relative as relative4, resolve as resolve3 } from "node:path";
|
|
2374
|
+
function attr(tag, name) {
|
|
2375
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
|
|
2376
|
+
if (!m) return null;
|
|
2377
|
+
return m[1] ?? m[2] ?? m[3] ?? "";
|
|
2378
|
+
}
|
|
2379
|
+
function hasAttr(tag, name) {
|
|
2380
|
+
return new RegExp(`\\s${name}(?=[\\s=>/])`, "i").test(tag);
|
|
2381
|
+
}
|
|
2382
|
+
function imagesWithoutAlt(html) {
|
|
2383
|
+
const out = [];
|
|
2384
|
+
for (const m of html.matchAll(/<img\b[^>]*>/gi)) {
|
|
2385
|
+
const tag = m[0];
|
|
2386
|
+
if (hasAttr(tag, "alt")) continue;
|
|
2387
|
+
out.push(attr(tag, "src") ?? "(no src)");
|
|
2388
|
+
}
|
|
2389
|
+
return out;
|
|
2390
|
+
}
|
|
2391
|
+
function links(html, origin) {
|
|
2392
|
+
const internal = /* @__PURE__ */ new Set();
|
|
2393
|
+
const external = /* @__PURE__ */ new Set();
|
|
2394
|
+
for (const m of html.matchAll(/<a\b[^>]*>/gi)) {
|
|
2395
|
+
const href = attr(m[0], "href");
|
|
2396
|
+
if (!href || SKIP_SCHEMES.test(href)) continue;
|
|
2397
|
+
if (/^https?:\/\//i.test(href)) {
|
|
2398
|
+
if (origin && href.startsWith(origin)) internal.add(href.slice(origin.length) || "/");
|
|
2399
|
+
else external.add(href);
|
|
2400
|
+
continue;
|
|
2401
|
+
}
|
|
2402
|
+
if (href.startsWith("//")) {
|
|
2403
|
+
external.add(`https:${href}`);
|
|
2404
|
+
continue;
|
|
2405
|
+
}
|
|
2406
|
+
internal.add(href);
|
|
2407
|
+
}
|
|
2408
|
+
return { internal: [...internal], external: [...external] };
|
|
2409
|
+
}
|
|
2410
|
+
function resolves(href, input) {
|
|
2411
|
+
let path = href.split("#")[0].split("?")[0];
|
|
2412
|
+
try {
|
|
2413
|
+
path = decodeURIComponent(path);
|
|
2414
|
+
} catch {
|
|
2415
|
+
}
|
|
2416
|
+
if (!path.startsWith("/")) return true;
|
|
2417
|
+
if (path === "/") return input.exists("index.html");
|
|
2418
|
+
const bare = path.replace(/\/$/, "");
|
|
2419
|
+
const rel = bare.slice(1);
|
|
2420
|
+
if (input.exists(rel) || input.exists(`${rel}.html`) || input.exists(`${rel}/index.html`)) {
|
|
2421
|
+
return true;
|
|
2422
|
+
}
|
|
2423
|
+
return input.workerFirst.some(
|
|
2424
|
+
(entry2) => entry2.endsWith("/*") ? bare === entry2.slice(0, -2) || bare.startsWith(entry2.slice(0, -1)) : entry2 === bare || entry2 === `${bare}/`
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
function locs(xml) {
|
|
2428
|
+
return [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/g)].map((m) => m[1]);
|
|
2429
|
+
}
|
|
2430
|
+
function auditSitemap(input) {
|
|
2431
|
+
const problems = [];
|
|
2432
|
+
const index = input.read("sitemap-index.xml");
|
|
2433
|
+
if (!index) {
|
|
2434
|
+
return {
|
|
2435
|
+
problems: [
|
|
2436
|
+
input.origin ? "sitemap-index.xml was not built" : "no sitemap - `domain` in webmonterey.json is unset, so nothing has an absolute URL"
|
|
2437
|
+
],
|
|
2438
|
+
urls: 0
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
const robots = input.read("robots.txt") ?? "";
|
|
2442
|
+
if (!/^Sitemap:\s*\S+/m.test(robots)) problems.push("robots.txt has no Sitemap: line");
|
|
2443
|
+
let urls = 0;
|
|
2444
|
+
for (const childUrl of locs(index)) {
|
|
2445
|
+
const childRel = childUrl.replace(/^https?:\/\/[^/]+\//, "");
|
|
2446
|
+
const child = input.read(childRel);
|
|
2447
|
+
if (!child) {
|
|
2448
|
+
problems.push(`${childRel} is listed in the index and was not built`);
|
|
2449
|
+
continue;
|
|
2450
|
+
}
|
|
2451
|
+
for (const url of locs(child)) {
|
|
2452
|
+
urls++;
|
|
2453
|
+
if (input.origin && !url.startsWith(input.origin)) {
|
|
2454
|
+
problems.push(`${url} is not on ${input.origin}`);
|
|
2455
|
+
continue;
|
|
2456
|
+
}
|
|
2457
|
+
const path = url.replace(/^https?:\/\/[^/]+/, "") || "/";
|
|
2458
|
+
if (!resolves(path, input)) problems.push(`${url} is in the sitemap and has no page`);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
if (urls === 0) problems.push("the sitemap lists no URLs");
|
|
2462
|
+
return { problems, urls };
|
|
2463
|
+
}
|
|
2464
|
+
function audit(input) {
|
|
2465
|
+
const missingAlt = [];
|
|
2466
|
+
const brokenInternal = [];
|
|
2467
|
+
const external = /* @__PURE__ */ new Set();
|
|
2468
|
+
for (const [page, html] of input.pages) {
|
|
2469
|
+
for (const src of imagesWithoutAlt(html)) missingAlt.push({ page, src });
|
|
2470
|
+
const found = links(html, input.origin);
|
|
2471
|
+
for (const href of found.internal) {
|
|
2472
|
+
if (!resolves(href, input)) brokenInternal.push({ page, href });
|
|
2473
|
+
}
|
|
2474
|
+
for (const url of found.external) external.add(url);
|
|
2475
|
+
}
|
|
2476
|
+
return {
|
|
2477
|
+
missingAlt,
|
|
2478
|
+
brokenInternal,
|
|
2479
|
+
external: [...external].sort(),
|
|
2480
|
+
sitemap: auditSitemap(input)
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
function walk3(dir) {
|
|
2484
|
+
if (!existsSync9(dir)) return [];
|
|
2485
|
+
return readdirSync7(dir, { withFileTypes: true }).flatMap((e) => {
|
|
2486
|
+
const full = join8(dir, e.name);
|
|
2487
|
+
return e.isDirectory() ? walk3(full) : [full];
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
function parseJsonc2(source) {
|
|
2491
|
+
const stripped = source.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => comment ? "" : m).replace(/,(\s*[}\]])/g, "$1");
|
|
2492
|
+
return JSON.parse(stripped);
|
|
2493
|
+
}
|
|
2494
|
+
async function probe(urls) {
|
|
2495
|
+
const bad = [];
|
|
2496
|
+
const queue = [...urls];
|
|
2497
|
+
const worker = async () => {
|
|
2498
|
+
for (let url = queue.shift(); url; url = queue.shift()) {
|
|
2499
|
+
try {
|
|
2500
|
+
let res = await fetch(url, {
|
|
2501
|
+
method: "HEAD",
|
|
2502
|
+
redirect: "follow",
|
|
2503
|
+
signal: AbortSignal.timeout(8e3)
|
|
2504
|
+
});
|
|
2505
|
+
if (res.status === 405 || res.status === 403) {
|
|
2506
|
+
res = await fetch(url, {
|
|
2507
|
+
method: "GET",
|
|
2508
|
+
redirect: "follow",
|
|
2509
|
+
signal: AbortSignal.timeout(8e3)
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
if (res.status >= 400) bad.push({ url, status: String(res.status) });
|
|
2513
|
+
} catch (error) {
|
|
2514
|
+
bad.push({ url, status: error instanceof Error ? error.name : "error" });
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
};
|
|
2518
|
+
await Promise.all(Array.from({ length: 6 }, worker));
|
|
2519
|
+
return bad.sort((a, b) => a.url.localeCompare(b.url));
|
|
2520
|
+
}
|
|
2521
|
+
async function run6(argv2) {
|
|
2522
|
+
const dist = resolve3(argv2.find((a) => !a.startsWith("-")) ?? "dist/client");
|
|
2523
|
+
const noExternal = argv2.includes("--no-external");
|
|
2524
|
+
if (!existsSync9(join8(dist, "index.html"))) {
|
|
2525
|
+
console.error(`webm audit: no build at ${dist}. Run \`npm run build\` first.`);
|
|
2526
|
+
return 1;
|
|
2527
|
+
}
|
|
2528
|
+
const siteRoot = process.cwd();
|
|
2529
|
+
const files = walk3(dist);
|
|
2530
|
+
const rels = new Set(files.map((f) => relative4(dist, f)));
|
|
2531
|
+
const pages = new Map(
|
|
2532
|
+
files.filter((f) => f.endsWith(".html")).map((f) => [relative4(dist, f), readFileSync8(f, "utf8")])
|
|
2533
|
+
);
|
|
2534
|
+
const wranglerPath = ["wrangler.jsonc", "wrangler.json"].map((f) => join8(siteRoot, f)).find(existsSync9);
|
|
2535
|
+
const wrangler = wranglerPath ? parseJsonc2(readFileSync8(wranglerPath, "utf8")) : null;
|
|
2536
|
+
const sitePath = join8(siteRoot, "webmonterey.json");
|
|
2537
|
+
const site = existsSync9(sitePath) ? JSON.parse(readFileSync8(sitePath, "utf8")) : {};
|
|
2538
|
+
const origin = site.domain && site.domain !== "CHANGEME" ? `https://${site.domain}` : void 0;
|
|
2539
|
+
const report = audit({
|
|
2540
|
+
pages,
|
|
2541
|
+
exists: (rel) => rels.has(rel),
|
|
2542
|
+
read: (rel) => rels.has(rel) && statSync3(join8(dist, rel)).isFile() ? readFileSync8(join8(dist, rel), "utf8") : null,
|
|
2543
|
+
workerFirst: wrangler?.assets?.run_worker_first ?? [],
|
|
2544
|
+
origin
|
|
2545
|
+
});
|
|
2546
|
+
let failed = 0;
|
|
2547
|
+
const section = (ok, title) => console.log(`${ok ? " ok " : "FAIL "} ${title}`);
|
|
2548
|
+
section(report.missingAlt.length === 0, `Every image declares alt text (${pages.size} pages)`);
|
|
2549
|
+
for (const { page, src } of report.missingAlt)
|
|
2550
|
+
console.log(` ${page}: <img src="${src}"> has no alt attribute`);
|
|
2551
|
+
if (report.missingAlt.length) {
|
|
2552
|
+
failed++;
|
|
2553
|
+
console.log(
|
|
2554
|
+
` Write alt text for each - what the image shows, in context - or alt="" if it is decorative.`
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
section(
|
|
2558
|
+
report.brokenInternal.length === 0,
|
|
2559
|
+
"Every internal link lands on a page or a Worker route"
|
|
2560
|
+
);
|
|
2561
|
+
for (const { page, href } of report.brokenInternal) console.log(` ${page}: ${href}`);
|
|
2562
|
+
if (report.brokenInternal.length) failed++;
|
|
2563
|
+
section(
|
|
2564
|
+
report.sitemap.problems.length === 0,
|
|
2565
|
+
`The sitemap is complete and advertised (${report.sitemap.urls} URLs)`
|
|
2566
|
+
);
|
|
2567
|
+
for (const p of report.sitemap.problems) console.log(` ${p}`);
|
|
2568
|
+
if (report.sitemap.problems.length) failed++;
|
|
2569
|
+
if (noExternal) {
|
|
2570
|
+
console.log(` -- ${report.external.length} external links not probed (--no-external)`);
|
|
2571
|
+
} else if (report.external.length) {
|
|
2572
|
+
const bad = await probe(report.external);
|
|
2573
|
+
console.log(
|
|
2574
|
+
`${bad.length ? "warn " : " ok "} ${report.external.length} external links respond (${bad.length} did not)`
|
|
2575
|
+
);
|
|
2576
|
+
for (const { url, status } of bad) console.log(` ${status.padEnd(12)} ${url}`);
|
|
2577
|
+
if (bad.length)
|
|
2578
|
+
console.log(
|
|
2579
|
+
` Open each in a browser before deciding it is broken - many sites refuse bots.`
|
|
2580
|
+
);
|
|
2581
|
+
} else {
|
|
2582
|
+
console.log(" ok no external links");
|
|
2583
|
+
}
|
|
2584
|
+
console.log(`
|
|
2585
|
+
${failed === 0 ? "audit clean" : `${failed} check(s) failed`}`);
|
|
2586
|
+
return failed ? 1 : 0;
|
|
2587
|
+
}
|
|
2588
|
+
var SKIP_SCHEMES;
|
|
2589
|
+
var init_audit = __esm({
|
|
2590
|
+
"src/cli/audit.ts"() {
|
|
2591
|
+
"use strict";
|
|
2592
|
+
SKIP_SCHEMES = /^(mailto:|tel:|sms:|javascript:|data:|#)/i;
|
|
2593
|
+
}
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2216
2596
|
// src/cli/codemods.ts
|
|
2217
2597
|
function compareVersions(a, b) {
|
|
2218
2598
|
const pa = a.split(".").map(Number);
|
|
@@ -2234,24 +2614,24 @@ var init_codemods = __esm({
|
|
|
2234
2614
|
// src/cli/upgrade.ts
|
|
2235
2615
|
var upgrade_exports = {};
|
|
2236
2616
|
__export(upgrade_exports, {
|
|
2237
|
-
run: () =>
|
|
2617
|
+
run: () => run7
|
|
2238
2618
|
});
|
|
2239
|
-
import { execFileSync as
|
|
2240
|
-
import { existsSync as
|
|
2241
|
-
import { join as
|
|
2619
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2620
|
+
import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
|
|
2621
|
+
import { join as join9 } from "node:path";
|
|
2242
2622
|
function git(siteRoot, args) {
|
|
2243
|
-
return
|
|
2623
|
+
return execFileSync3("git", args, { cwd: siteRoot, encoding: "utf8" }).trim();
|
|
2244
2624
|
}
|
|
2245
2625
|
function installedVersion(siteRoot) {
|
|
2246
|
-
const path =
|
|
2247
|
-
if (!
|
|
2248
|
-
return JSON.parse(
|
|
2626
|
+
const path = join9(siteRoot, "node_modules", PACKAGE, "package.json");
|
|
2627
|
+
if (!existsSync10(path)) return null;
|
|
2628
|
+
return JSON.parse(readFileSync9(path, "utf8")).version;
|
|
2249
2629
|
}
|
|
2250
|
-
function
|
|
2630
|
+
function run7(argv2) {
|
|
2251
2631
|
const siteRoot = process.cwd();
|
|
2252
2632
|
const target = argv2.find((a) => !a.startsWith("-")) ?? "latest";
|
|
2253
2633
|
const dryRun = argv2.includes("--dry-run");
|
|
2254
|
-
if (!
|
|
2634
|
+
if (!existsSync10(join9(siteRoot, "webmonterey.json"))) {
|
|
2255
2635
|
console.error(`webm upgrade: no webmonterey.json here. Not a WebMonterey site.`);
|
|
2256
2636
|
return 1;
|
|
2257
2637
|
}
|
|
@@ -2270,7 +2650,7 @@ function run6(argv2) {
|
|
|
2270
2650
|
git(siteRoot, ["checkout", "-b", branch]);
|
|
2271
2651
|
console.log(`Branched to ${branch}. Never upgrade on main.`);
|
|
2272
2652
|
}
|
|
2273
|
-
|
|
2653
|
+
execFileSync3("npm", ["install", `${PACKAGE}@${target}`], { cwd: siteRoot, stdio: "inherit" });
|
|
2274
2654
|
const to = installedVersion(siteRoot);
|
|
2275
2655
|
if (!to) {
|
|
2276
2656
|
console.error("webm upgrade: install did not produce a version. Check the npm output above.");
|
|
@@ -2317,9 +2697,9 @@ var init_upgrade = __esm({
|
|
|
2317
2697
|
|
|
2318
2698
|
// bin/webm.mjs
|
|
2319
2699
|
init_package_root();
|
|
2320
|
-
import { readFileSync as
|
|
2321
|
-
import { join as
|
|
2322
|
-
var pkg = JSON.parse(
|
|
2700
|
+
import { readFileSync as readFileSync10 } from "node:fs";
|
|
2701
|
+
import { join as join10 } from "node:path";
|
|
2702
|
+
var pkg = JSON.parse(readFileSync10(join10(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
2323
2703
|
var COMMANDS = {
|
|
2324
2704
|
new: {
|
|
2325
2705
|
blurb: "Scaffold a new client site from a domain",
|
|
@@ -2341,6 +2721,10 @@ var COMMANDS = {
|
|
|
2341
2721
|
blurb: "Check this site against the traps that fail silently",
|
|
2342
2722
|
load: () => Promise.resolve().then(() => (init_doctor(), doctor_exports))
|
|
2343
2723
|
},
|
|
2724
|
+
audit: {
|
|
2725
|
+
blurb: "Check a BUILD: image alt text, broken links, the sitemap. For launch.",
|
|
2726
|
+
load: () => Promise.resolve().then(() => (init_audit(), audit_exports))
|
|
2727
|
+
},
|
|
2344
2728
|
upgrade: {
|
|
2345
2729
|
blurb: "Move this site to a newer framework version",
|
|
2346
2730
|
load: () => Promise.resolve().then(() => (init_upgrade(), upgrade_exports))
|