@cparkerwebm/webmonterey 1.1.0 → 1.2.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 +49 -0
- package/dist/webm.mjs +260 -19
- package/package.json +5 -3
- package/skills/launch/SKILL.md +45 -5
- package/skills/start/SKILL.md +4 -0
- package/skills/traps/SKILL.md +9 -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 +6 -6
- package/src/cli/checks.ts +9 -7
- package/src/emails/footer.ts +3 -3
- package/src/includes/cloudflare/r2/media.ts +1 -1
- 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 +66 -3
- package/src/integration/virtual.d.ts +16 -1
- package/src/layouts/base.astro +28 -8
- package/src/package.test.ts +20 -0
- package/src/pages/robots.txt.ts +12 -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/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/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,55 @@ build. See `/webm:upgrade`.
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
+
## 1.2.0 — 2026-09-02
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Every site has a `/webmaster` page.** Injected by the integration, rendered with the site's
|
|
19
|
+
own chrome, indexable and in the sitemap. It says who designed, built and manages the site and
|
|
20
|
+
who to contact when something is wrong, carries its own share image (served from the package at
|
|
21
|
+
`/webmaster/og.png`, so a redesign reaches every site on `npm update`) and its own structured
|
|
22
|
+
data — a `WebPage` about the agency's `Organization`, using the same `@id` the agency's own site
|
|
23
|
+
declares. Every word is overridable through `copy.webmaster`. Off switch: `webmaster: false` on
|
|
24
|
+
the integration.
|
|
25
|
+
|
|
26
|
+
- **`webm audit`** — the pre-launch checks only a build can answer: images with no `alt`
|
|
27
|
+
attribute (an explicit `alt=""` is fine), internal links that land on no page or Worker route,
|
|
28
|
+
and a sitemap that is missing, unadvertised, or lists a page that was not built. External links
|
|
29
|
+
are probed and reported as warnings. `/webm:launch` runs it and says how to act on each finding.
|
|
30
|
+
|
|
31
|
+
- **Branch previews are a different build.** On any Workers Builds branch other than the
|
|
32
|
+
production one (`main`, or `productionBranch` on the integration), every page is noindex with
|
|
33
|
+
no canonical, there is no sitemap, `robots.txt` disallows everything, Google Tag Manager does
|
|
34
|
+
not load, and `/webmaster` emits no graph. Detected from the `WORKERS_CI_BRANCH` variable
|
|
35
|
+
Workers Builds injects; a local build is production. Mail on previews was already redirected by
|
|
36
|
+
the `workers.dev` hostname. Nothing to do on a site.
|
|
37
|
+
|
|
38
|
+
- **`/webm:launch` asks two questions out loud** before the flip: is Google Tag Manager
|
|
39
|
+
configured and published for this site, and has the launch annotation been added in Google
|
|
40
|
+
Analytics. Neither is knowable from the repo, so the skill waits for the answer.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **The default share image is `public/opengraph.png`** (was `open-graph.png`). `webm new` seeds
|
|
45
|
+
the WebMonterey artwork there - the same image the `/webmaster` page uses - and a client replaces
|
|
46
|
+
it with their own; `webm doctor` flags the seed until they do. **On an existing site:** rename
|
|
47
|
+
`public/open-graph.png` to `public/opengraph.png`, or every page's `og:image` points at a file
|
|
48
|
+
that is not there.
|
|
49
|
+
|
|
50
|
+
- **The footer credit is an internal link.** `Powered by WebMonterey` now goes to the site's own
|
|
51
|
+
`/webmaster` page instead of leaving the site; that page carries the one outbound link, with
|
|
52
|
+
the UTM parameters (`utm_campaign=webmaster`). The email footer keeps the outbound link — an
|
|
53
|
+
email cannot usefully point at a page on the site it is about.
|
|
54
|
+
|
|
55
|
+
**On an existing site:** the import moves —
|
|
56
|
+
`@cparkerwebm/webmonterey/webmonterey/credits/Credit.astro` is now
|
|
57
|
+
`@cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro`, and the module
|
|
58
|
+
`webmonterey/credits` is `webmonterey/webmaster`. The doctor check is `webmaster-credit`. The
|
|
59
|
+
site's `structuredData` component is not rendered on `/webmaster`; the page emits its own.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
14
63
|
## 1.1.0 — 2026-09-02
|
|
15
64
|
|
|
16
65
|
### Changed
|
package/dist/webm.mjs
CHANGED
|
@@ -1955,17 +1955,17 @@ var init_checks = __esm({
|
|
|
1955
1955
|
}
|
|
1956
1956
|
},
|
|
1957
1957
|
{
|
|
1958
|
-
id: "
|
|
1959
|
-
title: "Something renders the
|
|
1960
|
-
silentAs: 'the site ships with no "Powered by WebMonterey" and nobody notices for months',
|
|
1958
|
+
id: "webmaster-credit",
|
|
1959
|
+
title: "Something renders the webmaster credit",
|
|
1960
|
+
silentAs: 'the site ships with no "Powered by WebMonterey", the /webmaster page is orphaned, and nobody notices for months',
|
|
1961
1961
|
run(ctx) {
|
|
1962
1962
|
if (ctx.components.size === 0) return pass;
|
|
1963
1963
|
if (ctx.site.domain === "webmonterey.com") return pass;
|
|
1964
1964
|
for (const src of ctx.components.values()) {
|
|
1965
|
-
if (/webmonterey\/
|
|
1965
|
+
if (/webmonterey\/webmaster/.test(stripComments(src))) return pass;
|
|
1966
1966
|
}
|
|
1967
1967
|
return warn(
|
|
1968
|
-
"no component imports @cparkerwebm/webmonterey/webmonterey/
|
|
1968
|
+
"no component imports @cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro. The footer component is where it goes; it links to the /webmaster page."
|
|
1969
1969
|
);
|
|
1970
1970
|
}
|
|
1971
1971
|
},
|
|
@@ -2087,17 +2087,17 @@ function parseJsonc(source) {
|
|
|
2087
2087
|
}
|
|
2088
2088
|
function readTree(root, dir, exts) {
|
|
2089
2089
|
const out = /* @__PURE__ */ new Map();
|
|
2090
|
-
const
|
|
2090
|
+
const walk4 = (d) => {
|
|
2091
2091
|
if (!existsSync8(d)) return;
|
|
2092
2092
|
for (const entry2 of readdirSync6(d, { withFileTypes: true })) {
|
|
2093
2093
|
const full = join7(d, entry2.name);
|
|
2094
|
-
if (entry2.isDirectory())
|
|
2094
|
+
if (entry2.isDirectory()) walk4(full);
|
|
2095
2095
|
else if (exts.some((e) => entry2.name.endsWith(e))) {
|
|
2096
2096
|
out.set(relative3(root, full), readFileSync7(full, "utf8"));
|
|
2097
2097
|
}
|
|
2098
2098
|
}
|
|
2099
2099
|
};
|
|
2100
|
-
|
|
2100
|
+
walk4(join7(root, dir));
|
|
2101
2101
|
return out;
|
|
2102
2102
|
}
|
|
2103
2103
|
function placeholderFiles(siteRoot) {
|
|
@@ -2213,6 +2213,243 @@ var init_doctor = __esm({
|
|
|
2213
2213
|
}
|
|
2214
2214
|
});
|
|
2215
2215
|
|
|
2216
|
+
// src/cli/audit.ts
|
|
2217
|
+
var audit_exports = {};
|
|
2218
|
+
__export(audit_exports, {
|
|
2219
|
+
attr: () => attr,
|
|
2220
|
+
audit: () => audit,
|
|
2221
|
+
auditSitemap: () => auditSitemap,
|
|
2222
|
+
hasAttr: () => hasAttr,
|
|
2223
|
+
imagesWithoutAlt: () => imagesWithoutAlt,
|
|
2224
|
+
links: () => links,
|
|
2225
|
+
locs: () => locs,
|
|
2226
|
+
resolves: () => resolves,
|
|
2227
|
+
run: () => run6
|
|
2228
|
+
});
|
|
2229
|
+
import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
|
|
2230
|
+
import { join as join8, relative as relative4, resolve as resolve2 } from "node:path";
|
|
2231
|
+
function attr(tag, name) {
|
|
2232
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
|
|
2233
|
+
if (!m) return null;
|
|
2234
|
+
return m[1] ?? m[2] ?? m[3] ?? "";
|
|
2235
|
+
}
|
|
2236
|
+
function hasAttr(tag, name) {
|
|
2237
|
+
return new RegExp(`\\s${name}(?=[\\s=>/])`, "i").test(tag);
|
|
2238
|
+
}
|
|
2239
|
+
function imagesWithoutAlt(html) {
|
|
2240
|
+
const out = [];
|
|
2241
|
+
for (const m of html.matchAll(/<img\b[^>]*>/gi)) {
|
|
2242
|
+
const tag = m[0];
|
|
2243
|
+
if (hasAttr(tag, "alt")) continue;
|
|
2244
|
+
out.push(attr(tag, "src") ?? "(no src)");
|
|
2245
|
+
}
|
|
2246
|
+
return out;
|
|
2247
|
+
}
|
|
2248
|
+
function links(html, origin) {
|
|
2249
|
+
const internal = /* @__PURE__ */ new Set();
|
|
2250
|
+
const external = /* @__PURE__ */ new Set();
|
|
2251
|
+
for (const m of html.matchAll(/<a\b[^>]*>/gi)) {
|
|
2252
|
+
const href = attr(m[0], "href");
|
|
2253
|
+
if (!href || SKIP_SCHEMES.test(href)) continue;
|
|
2254
|
+
if (/^https?:\/\//i.test(href)) {
|
|
2255
|
+
if (origin && href.startsWith(origin)) internal.add(href.slice(origin.length) || "/");
|
|
2256
|
+
else external.add(href);
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
if (href.startsWith("//")) {
|
|
2260
|
+
external.add(`https:${href}`);
|
|
2261
|
+
continue;
|
|
2262
|
+
}
|
|
2263
|
+
internal.add(href);
|
|
2264
|
+
}
|
|
2265
|
+
return { internal: [...internal], external: [...external] };
|
|
2266
|
+
}
|
|
2267
|
+
function resolves(href, input) {
|
|
2268
|
+
let path = href.split("#")[0].split("?")[0];
|
|
2269
|
+
try {
|
|
2270
|
+
path = decodeURIComponent(path);
|
|
2271
|
+
} catch {
|
|
2272
|
+
}
|
|
2273
|
+
if (!path.startsWith("/")) return true;
|
|
2274
|
+
if (path === "/") return input.exists("index.html");
|
|
2275
|
+
const bare = path.replace(/\/$/, "");
|
|
2276
|
+
const rel = bare.slice(1);
|
|
2277
|
+
if (input.exists(rel) || input.exists(`${rel}.html`) || input.exists(`${rel}/index.html`)) {
|
|
2278
|
+
return true;
|
|
2279
|
+
}
|
|
2280
|
+
return input.workerFirst.some(
|
|
2281
|
+
(entry2) => entry2.endsWith("/*") ? bare === entry2.slice(0, -2) || bare.startsWith(entry2.slice(0, -1)) : entry2 === bare || entry2 === `${bare}/`
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
function locs(xml) {
|
|
2285
|
+
return [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/g)].map((m) => m[1]);
|
|
2286
|
+
}
|
|
2287
|
+
function auditSitemap(input) {
|
|
2288
|
+
const problems = [];
|
|
2289
|
+
const index = input.read("sitemap-index.xml");
|
|
2290
|
+
if (!index) {
|
|
2291
|
+
return {
|
|
2292
|
+
problems: [
|
|
2293
|
+
input.origin ? "sitemap-index.xml was not built" : "no sitemap - `domain` in webmonterey.json is unset, so nothing has an absolute URL"
|
|
2294
|
+
],
|
|
2295
|
+
urls: 0
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
const robots = input.read("robots.txt") ?? "";
|
|
2299
|
+
if (!/^Sitemap:\s*\S+/m.test(robots)) problems.push("robots.txt has no Sitemap: line");
|
|
2300
|
+
let urls = 0;
|
|
2301
|
+
for (const childUrl of locs(index)) {
|
|
2302
|
+
const childRel = childUrl.replace(/^https?:\/\/[^/]+\//, "");
|
|
2303
|
+
const child = input.read(childRel);
|
|
2304
|
+
if (!child) {
|
|
2305
|
+
problems.push(`${childRel} is listed in the index and was not built`);
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
for (const url of locs(child)) {
|
|
2309
|
+
urls++;
|
|
2310
|
+
if (input.origin && !url.startsWith(input.origin)) {
|
|
2311
|
+
problems.push(`${url} is not on ${input.origin}`);
|
|
2312
|
+
continue;
|
|
2313
|
+
}
|
|
2314
|
+
const path = url.replace(/^https?:\/\/[^/]+/, "") || "/";
|
|
2315
|
+
if (!resolves(path, input)) problems.push(`${url} is in the sitemap and has no page`);
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
if (urls === 0) problems.push("the sitemap lists no URLs");
|
|
2319
|
+
return { problems, urls };
|
|
2320
|
+
}
|
|
2321
|
+
function audit(input) {
|
|
2322
|
+
const missingAlt = [];
|
|
2323
|
+
const brokenInternal = [];
|
|
2324
|
+
const external = /* @__PURE__ */ new Set();
|
|
2325
|
+
for (const [page, html] of input.pages) {
|
|
2326
|
+
for (const src of imagesWithoutAlt(html)) missingAlt.push({ page, src });
|
|
2327
|
+
const found = links(html, input.origin);
|
|
2328
|
+
for (const href of found.internal) {
|
|
2329
|
+
if (!resolves(href, input)) brokenInternal.push({ page, href });
|
|
2330
|
+
}
|
|
2331
|
+
for (const url of found.external) external.add(url);
|
|
2332
|
+
}
|
|
2333
|
+
return {
|
|
2334
|
+
missingAlt,
|
|
2335
|
+
brokenInternal,
|
|
2336
|
+
external: [...external].sort(),
|
|
2337
|
+
sitemap: auditSitemap(input)
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
function walk3(dir) {
|
|
2341
|
+
if (!existsSync9(dir)) return [];
|
|
2342
|
+
return readdirSync7(dir, { withFileTypes: true }).flatMap((e) => {
|
|
2343
|
+
const full = join8(dir, e.name);
|
|
2344
|
+
return e.isDirectory() ? walk3(full) : [full];
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
function parseJsonc2(source) {
|
|
2348
|
+
const stripped = source.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => comment ? "" : m).replace(/,(\s*[}\]])/g, "$1");
|
|
2349
|
+
return JSON.parse(stripped);
|
|
2350
|
+
}
|
|
2351
|
+
async function probe(urls) {
|
|
2352
|
+
const bad = [];
|
|
2353
|
+
const queue = [...urls];
|
|
2354
|
+
const worker = async () => {
|
|
2355
|
+
for (let url = queue.shift(); url; url = queue.shift()) {
|
|
2356
|
+
try {
|
|
2357
|
+
let res = await fetch(url, {
|
|
2358
|
+
method: "HEAD",
|
|
2359
|
+
redirect: "follow",
|
|
2360
|
+
signal: AbortSignal.timeout(8e3)
|
|
2361
|
+
});
|
|
2362
|
+
if (res.status === 405 || res.status === 403) {
|
|
2363
|
+
res = await fetch(url, {
|
|
2364
|
+
method: "GET",
|
|
2365
|
+
redirect: "follow",
|
|
2366
|
+
signal: AbortSignal.timeout(8e3)
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
if (res.status >= 400) bad.push({ url, status: String(res.status) });
|
|
2370
|
+
} catch (error) {
|
|
2371
|
+
bad.push({ url, status: error instanceof Error ? error.name : "error" });
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
};
|
|
2375
|
+
await Promise.all(Array.from({ length: 6 }, worker));
|
|
2376
|
+
return bad.sort((a, b) => a.url.localeCompare(b.url));
|
|
2377
|
+
}
|
|
2378
|
+
async function run6(argv2) {
|
|
2379
|
+
const dist = resolve2(argv2.find((a) => !a.startsWith("-")) ?? "dist/client");
|
|
2380
|
+
const noExternal = argv2.includes("--no-external");
|
|
2381
|
+
if (!existsSync9(join8(dist, "index.html"))) {
|
|
2382
|
+
console.error(`webm audit: no build at ${dist}. Run \`npm run build\` first.`);
|
|
2383
|
+
return 1;
|
|
2384
|
+
}
|
|
2385
|
+
const siteRoot = process.cwd();
|
|
2386
|
+
const files = walk3(dist);
|
|
2387
|
+
const rels = new Set(files.map((f) => relative4(dist, f)));
|
|
2388
|
+
const pages = new Map(
|
|
2389
|
+
files.filter((f) => f.endsWith(".html")).map((f) => [relative4(dist, f), readFileSync8(f, "utf8")])
|
|
2390
|
+
);
|
|
2391
|
+
const wranglerPath = ["wrangler.jsonc", "wrangler.json"].map((f) => join8(siteRoot, f)).find(existsSync9);
|
|
2392
|
+
const wrangler = wranglerPath ? parseJsonc2(readFileSync8(wranglerPath, "utf8")) : null;
|
|
2393
|
+
const sitePath = join8(siteRoot, "webmonterey.json");
|
|
2394
|
+
const site = existsSync9(sitePath) ? JSON.parse(readFileSync8(sitePath, "utf8")) : {};
|
|
2395
|
+
const origin = site.domain && site.domain !== "CHANGEME" ? `https://${site.domain}` : void 0;
|
|
2396
|
+
const report = audit({
|
|
2397
|
+
pages,
|
|
2398
|
+
exists: (rel) => rels.has(rel),
|
|
2399
|
+
read: (rel) => rels.has(rel) && statSync3(join8(dist, rel)).isFile() ? readFileSync8(join8(dist, rel), "utf8") : null,
|
|
2400
|
+
workerFirst: wrangler?.assets?.run_worker_first ?? [],
|
|
2401
|
+
origin
|
|
2402
|
+
});
|
|
2403
|
+
let failed = 0;
|
|
2404
|
+
const section = (ok, title) => console.log(`${ok ? " ok " : "FAIL "} ${title}`);
|
|
2405
|
+
section(report.missingAlt.length === 0, `Every image declares alt text (${pages.size} pages)`);
|
|
2406
|
+
for (const { page, src } of report.missingAlt)
|
|
2407
|
+
console.log(` ${page}: <img src="${src}"> has no alt attribute`);
|
|
2408
|
+
if (report.missingAlt.length) {
|
|
2409
|
+
failed++;
|
|
2410
|
+
console.log(
|
|
2411
|
+
` Write alt text for each - what the image shows, in context - or alt="" if it is decorative.`
|
|
2412
|
+
);
|
|
2413
|
+
}
|
|
2414
|
+
section(
|
|
2415
|
+
report.brokenInternal.length === 0,
|
|
2416
|
+
"Every internal link lands on a page or a Worker route"
|
|
2417
|
+
);
|
|
2418
|
+
for (const { page, href } of report.brokenInternal) console.log(` ${page}: ${href}`);
|
|
2419
|
+
if (report.brokenInternal.length) failed++;
|
|
2420
|
+
section(
|
|
2421
|
+
report.sitemap.problems.length === 0,
|
|
2422
|
+
`The sitemap is complete and advertised (${report.sitemap.urls} URLs)`
|
|
2423
|
+
);
|
|
2424
|
+
for (const p of report.sitemap.problems) console.log(` ${p}`);
|
|
2425
|
+
if (report.sitemap.problems.length) failed++;
|
|
2426
|
+
if (noExternal) {
|
|
2427
|
+
console.log(` -- ${report.external.length} external links not probed (--no-external)`);
|
|
2428
|
+
} else if (report.external.length) {
|
|
2429
|
+
const bad = await probe(report.external);
|
|
2430
|
+
console.log(
|
|
2431
|
+
`${bad.length ? "warn " : " ok "} ${report.external.length} external links respond (${bad.length} did not)`
|
|
2432
|
+
);
|
|
2433
|
+
for (const { url, status } of bad) console.log(` ${status.padEnd(12)} ${url}`);
|
|
2434
|
+
if (bad.length)
|
|
2435
|
+
console.log(
|
|
2436
|
+
` Open each in a browser before deciding it is broken - many sites refuse bots.`
|
|
2437
|
+
);
|
|
2438
|
+
} else {
|
|
2439
|
+
console.log(" ok no external links");
|
|
2440
|
+
}
|
|
2441
|
+
console.log(`
|
|
2442
|
+
${failed === 0 ? "audit clean" : `${failed} check(s) failed`}`);
|
|
2443
|
+
return failed ? 1 : 0;
|
|
2444
|
+
}
|
|
2445
|
+
var SKIP_SCHEMES;
|
|
2446
|
+
var init_audit = __esm({
|
|
2447
|
+
"src/cli/audit.ts"() {
|
|
2448
|
+
"use strict";
|
|
2449
|
+
SKIP_SCHEMES = /^(mailto:|tel:|sms:|javascript:|data:|#)/i;
|
|
2450
|
+
}
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2216
2453
|
// src/cli/codemods.ts
|
|
2217
2454
|
function compareVersions(a, b) {
|
|
2218
2455
|
const pa = a.split(".").map(Number);
|
|
@@ -2234,24 +2471,24 @@ var init_codemods = __esm({
|
|
|
2234
2471
|
// src/cli/upgrade.ts
|
|
2235
2472
|
var upgrade_exports = {};
|
|
2236
2473
|
__export(upgrade_exports, {
|
|
2237
|
-
run: () =>
|
|
2474
|
+
run: () => run7
|
|
2238
2475
|
});
|
|
2239
2476
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2240
|
-
import { existsSync as
|
|
2241
|
-
import { join as
|
|
2477
|
+
import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
|
|
2478
|
+
import { join as join9 } from "node:path";
|
|
2242
2479
|
function git(siteRoot, args) {
|
|
2243
2480
|
return execFileSync2("git", args, { cwd: siteRoot, encoding: "utf8" }).trim();
|
|
2244
2481
|
}
|
|
2245
2482
|
function installedVersion(siteRoot) {
|
|
2246
|
-
const path =
|
|
2247
|
-
if (!
|
|
2248
|
-
return JSON.parse(
|
|
2483
|
+
const path = join9(siteRoot, "node_modules", PACKAGE, "package.json");
|
|
2484
|
+
if (!existsSync10(path)) return null;
|
|
2485
|
+
return JSON.parse(readFileSync9(path, "utf8")).version;
|
|
2249
2486
|
}
|
|
2250
|
-
function
|
|
2487
|
+
function run7(argv2) {
|
|
2251
2488
|
const siteRoot = process.cwd();
|
|
2252
2489
|
const target = argv2.find((a) => !a.startsWith("-")) ?? "latest";
|
|
2253
2490
|
const dryRun = argv2.includes("--dry-run");
|
|
2254
|
-
if (!
|
|
2491
|
+
if (!existsSync10(join9(siteRoot, "webmonterey.json"))) {
|
|
2255
2492
|
console.error(`webm upgrade: no webmonterey.json here. Not a WebMonterey site.`);
|
|
2256
2493
|
return 1;
|
|
2257
2494
|
}
|
|
@@ -2317,9 +2554,9 @@ var init_upgrade = __esm({
|
|
|
2317
2554
|
|
|
2318
2555
|
// bin/webm.mjs
|
|
2319
2556
|
init_package_root();
|
|
2320
|
-
import { readFileSync as
|
|
2321
|
-
import { join as
|
|
2322
|
-
var pkg = JSON.parse(
|
|
2557
|
+
import { readFileSync as readFileSync10 } from "node:fs";
|
|
2558
|
+
import { join as join10 } from "node:path";
|
|
2559
|
+
var pkg = JSON.parse(readFileSync10(join10(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
2323
2560
|
var COMMANDS = {
|
|
2324
2561
|
new: {
|
|
2325
2562
|
blurb: "Scaffold a new client site from a domain",
|
|
@@ -2341,6 +2578,10 @@ var COMMANDS = {
|
|
|
2341
2578
|
blurb: "Check this site against the traps that fail silently",
|
|
2342
2579
|
load: () => Promise.resolve().then(() => (init_doctor(), doctor_exports))
|
|
2343
2580
|
},
|
|
2581
|
+
audit: {
|
|
2582
|
+
blurb: "Check a BUILD: image alt text, broken links, the sitemap. For launch.",
|
|
2583
|
+
load: () => Promise.resolve().then(() => (init_audit(), audit_exports))
|
|
2584
|
+
},
|
|
2344
2585
|
upgrade: {
|
|
2345
2586
|
blurb: "Move this site to a newer framework version",
|
|
2346
2587
|
load: () => Promise.resolve().then(() => (init_upgrade(), upgrade_exports))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cparkerwebm/webmonterey",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The WebMonterey Astro framework: plumbing, design system, and Claude Code skills for client sites on Cloudflare Workers",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
"./pages/slug": "./src/pages/[...slug].astro",
|
|
50
50
|
"./pages/robots": "./src/pages/robots.txt.ts",
|
|
51
51
|
"./pages/webm": "./src/pages/webm.astro",
|
|
52
|
+
"./pages/webmaster": "./src/pages/webmaster.astro",
|
|
53
|
+
"./pages/webmaster-og": "./src/pages/webmaster-og.png.ts",
|
|
52
54
|
"./pages/404.astro": "./src/pages/404.astro",
|
|
53
55
|
"./emails": "./src/emails/index.ts",
|
|
54
56
|
"./cloudflare/d1": "./src/includes/cloudflare/d1/client.ts",
|
|
@@ -64,8 +66,8 @@
|
|
|
64
66
|
"./webmonterey/compliance/ConsentInit.astro": "./src/includes/webmonterey/compliance/ConsentInit.astro",
|
|
65
67
|
"./webmonterey/forms": "./src/includes/webmonterey/forms/honeypot.ts",
|
|
66
68
|
"./webmonterey/prose": "./src/includes/webmonterey/prose/inline.ts",
|
|
67
|
-
"./webmonterey/
|
|
68
|
-
"./webmonterey/
|
|
69
|
+
"./webmonterey/webmaster": "./src/includes/webmonterey/webmaster/webmaster.ts",
|
|
70
|
+
"./webmonterey/webmaster/Webmaster.astro": "./src/includes/webmonterey/webmaster/Webmaster.astro",
|
|
69
71
|
"./webmonterey/scroll-top/ScrollTop.astro": "./src/includes/webmonterey/scroll-top/ScrollTop.astro",
|
|
70
72
|
"./structured-data": "./src/includes/webmonterey/structured-data/nodes.ts",
|
|
71
73
|
"./schema/design.json": "./schema/design.json",
|
package/skills/launch/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: launch
|
|
3
|
-
description: Launch a WebMonterey client site onto its real domain - structured data, Turnstile, sending domain, production secrets, remote migrations, custom domain, verification, and the environment flip. Use for "launch the site", "point the domain at it", "we're going live", "take it out of preview".
|
|
3
|
+
description: Launch a WebMonterey client site onto its real domain - build audit (alt text, links, sitemap), structured data, Turnstile, sending domain, production secrets, remote migrations, custom domain, verification, analytics confirmation, and the environment flip. Use for "launch the site", "point the domain at it", "we're going live", "take it out of preview".
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Launch
|
|
@@ -15,9 +15,34 @@ npx webm doctor
|
|
|
15
15
|
npm run build
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Both clean. Doctor's warnings about placeholder artwork and the missing
|
|
18
|
+
Both clean. Doctor's warnings about placeholder artwork and the missing webmaster credit become
|
|
19
19
|
blocking here: replace the seeded favicons and share image with the client's own, and confirm
|
|
20
|
-
the footer imports `
|
|
20
|
+
the footer imports `Webmaster.astro` - it links to the `/webmaster` page every site has, and
|
|
21
|
+
without the credit that page is reachable from nothing.
|
|
22
|
+
|
|
23
|
+
## 1b. Audit the build
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npx webm audit
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
It reads `dist/client` and reports three things a person never checks exhaustively on a
|
|
30
|
+
forty-page site. Fix each and rebuild until it is clean:
|
|
31
|
+
|
|
32
|
+
- **Images with no `alt` attribute.** Write the alt text yourself: open the image, read the
|
|
33
|
+
copy around it, and describe what it shows in that context - not what it is ("photo"), what it
|
|
34
|
+
says. A purely decorative image gets `alt=""`, declared explicitly. The alt lives wherever the
|
|
35
|
+
`<img>` is authored: the component's markup, or the block's page JSON if the schema carries it.
|
|
36
|
+
Do not leave one for later; a missing alt on a launched site is an accessibility failure and a
|
|
37
|
+
lost image-search result.
|
|
38
|
+
- **Broken internal links.** Every `href` must land on a built page or a route in
|
|
39
|
+
`run_worker_first`. A typo'd slug, a page that was renamed, a form action that moved.
|
|
40
|
+
- **The sitemap.** Built, advertised in `robots.txt`, every URL on the production domain and
|
|
41
|
+
landing on a page. After launch, fetch `https://<domain>/sitemap-index.xml` in a browser and
|
|
42
|
+
submit it in Search Console.
|
|
43
|
+
|
|
44
|
+
External links are probed too and reported as warnings: many sites refuse anything that is not a
|
|
45
|
+
browser, so open each flagged one before calling it broken.
|
|
21
46
|
|
|
22
47
|
## 2. Structured data
|
|
23
48
|
|
|
@@ -159,10 +184,25 @@ curl -H "Sec-Fetch-Dest: document" -H "Sec-Fetch-Mode: navigate" https://<domain
|
|
|
159
184
|
That header is what the asset router branches on. Without it curl gets the real page and Chrome
|
|
160
185
|
gets the 404.
|
|
161
186
|
|
|
162
|
-
**A form test before step
|
|
187
|
+
**A form test before step 11 goes to `stagingEmail`** with `[staging → …]` in the subject naming
|
|
163
188
|
who it was really for. That is the system working - check that inbox, not the client's.
|
|
164
189
|
|
|
165
|
-
## 10.
|
|
190
|
+
## 10. Analytics - two confirmations, asked out loud
|
|
191
|
+
|
|
192
|
+
Neither of these can be checked from the repo, so **ask, and wait for the answer.** Do not
|
|
193
|
+
proceed on an assumption, and do not mark either done because the field is filled in.
|
|
194
|
+
|
|
195
|
+
1. **"Is Google Tag Manager configured for this site?"** - meaning the container exists, its
|
|
196
|
+
tags are published (not just saved), and `gtmId` in `webmonterey.json` is that container. A
|
|
197
|
+
`gtmId` with an unpublished container loads a script that fires nothing. If the site has no
|
|
198
|
+
analytics by agreement, confirm that instead and move on.
|
|
199
|
+
2. **"Has the launch annotation been added in Google Analytics?"** - a dated note on the
|
|
200
|
+
property for the launch, so the traffic change that follows has an explanation next to it
|
|
201
|
+
when someone looks a year from now.
|
|
202
|
+
|
|
203
|
+
Record the answers in the launch commit message.
|
|
204
|
+
|
|
205
|
+
## 11. Hand the site its email back, and record the launch
|
|
166
206
|
|
|
167
207
|
In one change:
|
|
168
208
|
|
package/skills/start/SKILL.md
CHANGED
|
@@ -119,5 +119,9 @@ Workers Builds comments the preview URL on every PR - that is the client's revie
|
|
|
119
119
|
hostnames use the slug, so Chrome's lookalike warning should not appear; if it does, it is a
|
|
120
120
|
URL-shape false positive and **Ignore is safe**.
|
|
121
121
|
|
|
122
|
+
A preview build is safe to hand out: every page is noindex, there is no sitemap, robots.txt
|
|
123
|
+
disallows everything, analytics does not load, and mail is redirected to `stagingEmail`. The
|
|
124
|
+
client can click anything. The production branch is `main`; anything else previews.
|
|
125
|
+
|
|
122
126
|
Next: `/webm:new-component` for each block, then `/webm:launch` when the site is
|
|
123
127
|
content-complete and approved on a preview.
|
package/skills/traps/SKILL.md
CHANGED
|
@@ -134,6 +134,15 @@ _Worker_, not a _version_, so it can only serve production, and per-version prev
|
|
|
134
134
|
**`workers_dev: false` does NOT disable preview URLs.** Separate switches, which is why
|
|
135
135
|
wrangler.jsonc sets both explicitly.
|
|
136
136
|
|
|
137
|
+
**A branch preview is a different build, on purpose.** Workers Builds injects
|
|
138
|
+
`WORKERS_CI_BRANCH`; on any branch but the production one (`main`, or the integration's
|
|
139
|
+
`productionBranch`) every page is noindex with no canonical, there is no sitemap, `robots.txt`
|
|
140
|
+
disallows everything, GTM does not load, and the `/webmaster` page emits no graph. Email is
|
|
141
|
+
already redirected by the `workers.dev` hostname. So a client's review link can neither be
|
|
142
|
+
indexed nor show up in their analytics. **A local build is not a preview** - `npm run preview`
|
|
143
|
+
builds production, which is what you want to inspect. To see the preview shape locally:
|
|
144
|
+
`WORKERS_CI_BRANCH=x npm run build`.
|
|
145
|
+
|
|
137
146
|
**`wrangler deployments list` misleads twice.** `Source: Unknown (deployment)` appears even for
|
|
138
147
|
Workers Builds deployments — it describes the author, not the origin. And the list pages at 10
|
|
139
148
|
entries, so "has the count gone up?" can never become a way of waiting for a deploy.
|
|
Binary file
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { attr, audit, auditSitemap, hasAttr, imagesWithoutAlt, links, resolves } from './audit.ts';
|
|
4
|
+
|
|
5
|
+
const build = (
|
|
6
|
+
files: Record<string, string>,
|
|
7
|
+
extra: Partial<Parameters<typeof audit>[0]> = {},
|
|
8
|
+
) => ({
|
|
9
|
+
pages: new Map(Object.entries(files).filter(([k]) => k.endsWith('.html'))),
|
|
10
|
+
exists: (rel: string) => rel in files,
|
|
11
|
+
read: (rel: string) => files[rel] ?? null,
|
|
12
|
+
workerFirst: ['/_actions/*'],
|
|
13
|
+
origin: 'https://example.com',
|
|
14
|
+
...extra,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('attr reads quoted and bare values, and hasAttr sees a valueless attribute', () => {
|
|
18
|
+
assert.equal(attr('<img src="a.png" alt="A thing">', 'alt'), 'A thing');
|
|
19
|
+
assert.equal(attr("<img src='a.png' alt=''>", 'alt'), '');
|
|
20
|
+
assert.equal(attr('<img src=a.png>', 'src'), 'a.png');
|
|
21
|
+
assert.equal(attr('<img src="a.png">', 'alt'), null);
|
|
22
|
+
assert.equal(hasAttr('<img src="a.png" alt>', 'alt'), true);
|
|
23
|
+
assert.equal(hasAttr('<img src="a.png" data-alt="x">', 'alt'), false, 'data-alt is not alt');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('an image with no alt attribute is reported; an empty alt is a decorative declaration', () => {
|
|
27
|
+
const html = `<img src="/hero.jpg"><img src="/deco.svg" alt=""><img alt="Fine" src="/ok.png"><IMG SRC="/caps.png">`;
|
|
28
|
+
assert.deepEqual(imagesWithoutAlt(html), ['/hero.jpg', '/caps.png']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('links are split into internal paths and external URLs, skipping non-navigations', () => {
|
|
32
|
+
const html = `
|
|
33
|
+
<a href="/about">a</a> <a href="/about/">b</a> <a href="mailto:x@y.z">m</a> <a href="#top">t</a>
|
|
34
|
+
<a href="https://example.com/contact">own origin</a> <a href="https://other.test/x">ext</a>
|
|
35
|
+
<a href="//cdn.test/file">proto-relative</a> <a href="tel:+1">p</a>`;
|
|
36
|
+
const { internal, external } = links(html, 'https://example.com');
|
|
37
|
+
assert.deepEqual(internal.sort(), ['/about', '/about/', '/contact']);
|
|
38
|
+
assert.deepEqual(external.sort(), ['https://cdn.test/file', 'https://other.test/x']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('an internal link resolves to a built file in any of the forms the router serves', () => {
|
|
42
|
+
const input = build({
|
|
43
|
+
'index.html': '',
|
|
44
|
+
'about/index.html': '',
|
|
45
|
+
'privacy.html': '',
|
|
46
|
+
'a.pdf': '',
|
|
47
|
+
});
|
|
48
|
+
for (const href of [
|
|
49
|
+
'/',
|
|
50
|
+
'/about',
|
|
51
|
+
'/about/',
|
|
52
|
+
'/about#team',
|
|
53
|
+
'/about?x=1',
|
|
54
|
+
'/privacy',
|
|
55
|
+
'/a.pdf',
|
|
56
|
+
]) {
|
|
57
|
+
assert.equal(resolves(href, input), true, href);
|
|
58
|
+
}
|
|
59
|
+
assert.equal(resolves('/missing', input), false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('an on-demand route in run_worker_first is not reported as broken', () => {
|
|
63
|
+
const input = build(
|
|
64
|
+
{ 'index.html': '' },
|
|
65
|
+
{ workerFirst: ['/_actions/*', '/contact', '/contact/', '/app/*'] },
|
|
66
|
+
);
|
|
67
|
+
assert.equal(resolves('/contact', input), true);
|
|
68
|
+
assert.equal(resolves('/contact/', input), true);
|
|
69
|
+
assert.equal(resolves('/app/dashboard', input), true);
|
|
70
|
+
assert.equal(resolves('/app', input), true);
|
|
71
|
+
assert.equal(resolves('/elsewhere', input), false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the sitemap must exist, be advertised, and every URL must land', () => {
|
|
75
|
+
const ok = build({
|
|
76
|
+
'index.html': '',
|
|
77
|
+
'about/index.html': '',
|
|
78
|
+
'robots.txt': 'User-agent: *\nAllow: /\n\nSitemap: https://example.com/sitemap-index.xml\n',
|
|
79
|
+
'sitemap-index.xml':
|
|
80
|
+
'<sitemapindex><sitemap><loc>https://example.com/sitemap-0.xml</loc></sitemap></sitemapindex>',
|
|
81
|
+
'sitemap-0.xml':
|
|
82
|
+
'<urlset><url><loc>https://example.com/</loc></url><url><loc>https://example.com/about/</loc></url></urlset>',
|
|
83
|
+
});
|
|
84
|
+
assert.deepEqual(auditSitemap(ok), { problems: [], urls: 2 });
|
|
85
|
+
|
|
86
|
+
const bad = build({
|
|
87
|
+
'index.html': '',
|
|
88
|
+
'robots.txt': 'User-agent: *\n',
|
|
89
|
+
'sitemap-index.xml':
|
|
90
|
+
'<sitemapindex><sitemap><loc>https://example.com/sitemap-0.xml</loc></sitemap></sitemapindex>',
|
|
91
|
+
'sitemap-0.xml':
|
|
92
|
+
'<urlset><url><loc>https://example.com/gone/</loc></url><url><loc>https://wrong.test/</loc></url></urlset>',
|
|
93
|
+
});
|
|
94
|
+
const { problems } = auditSitemap(bad);
|
|
95
|
+
assert.ok(problems.some((p) => p.includes('robots.txt')));
|
|
96
|
+
assert.ok(problems.some((p) => p.includes('/gone/') && p.includes('no page')));
|
|
97
|
+
assert.ok(problems.some((p) => p.includes('wrong.test')));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a missing sitemap says why when the domain is unset', () => {
|
|
101
|
+
const { problems } = auditSitemap(build({ 'index.html': '' }, { origin: undefined }));
|
|
102
|
+
assert.match(problems[0]!, /domain/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('audit assembles every finding with the page it was found on', () => {
|
|
106
|
+
const report = audit(
|
|
107
|
+
build({
|
|
108
|
+
'index.html': '<img src="/x.png"><a href="/nope">n</a><a href="https://ext.test/">e</a>',
|
|
109
|
+
'about/index.html': '<img src="/y.png" alt="Y"><a href="/">home</a>',
|
|
110
|
+
'robots.txt': 'Sitemap: https://example.com/sitemap-index.xml',
|
|
111
|
+
'sitemap-index.xml':
|
|
112
|
+
'<sitemapindex><sitemap><loc>https://example.com/sitemap-0.xml</loc></sitemap></sitemapindex>',
|
|
113
|
+
'sitemap-0.xml': '<urlset><url><loc>https://example.com/</loc></url></urlset>',
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
assert.deepEqual(report.missingAlt, [{ page: 'index.html', src: '/x.png' }]);
|
|
117
|
+
assert.deepEqual(report.brokenInternal, [{ page: 'index.html', href: '/nope' }]);
|
|
118
|
+
assert.deepEqual(report.external, ['https://ext.test/']);
|
|
119
|
+
assert.deepEqual(report.sitemap.problems, []);
|
|
120
|
+
});
|