@cparkerwebm/webmonterey 1.0.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 +77 -0
- package/README.md +6 -1
- package/dist/webm.mjs +290 -48
- package/package.json +5 -3
- package/skills/launch/SKILL.md +47 -7
- package/skills/start/SKILL.md +25 -15
- package/skills/traps/SKILL.md +11 -2
- 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/cli/new.ts +34 -17
- package/src/cli/scaffold.test.ts +27 -20
- package/src/cli/scaffold.ts +8 -7
- package/src/cli/slug.test.ts +25 -28
- package/src/cli/slug.ts +31 -36
- package/src/cli/sync.ts +1 -1
- package/src/emails/footer.ts +3 -3
- package/src/includes/cloudflare/r2/README.md +3 -3
- package/src/includes/cloudflare/r2/media.ts +4 -4
- package/src/includes/webmonterey/config.test.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/migrations/README.md +1 -1
- package/template/public/opengraph.png +0 -0
- package/template/scripts/test-hooks.mjs +1 -1
- package/template/site/CLAUDE.md +6 -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/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,83 @@ 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
|
+
|
|
63
|
+
## 1.1.0 — 2026-09-02
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- **One name, everywhere.** `webm new` now derives a single name from the domain — `example.com`
|
|
68
|
+
becomes `example` — and uses it for the GitHub repo, the Worker, the D1 database, the R2 bucket
|
|
69
|
+
and any KV namespace. The `webm-` prefix and the `-db` / `-media` suffixes are gone from cloud
|
|
70
|
+
resources: Cloudflare scopes every one of those names to the account, so in an account that holds
|
|
71
|
+
nothing but client sites a prefix said nothing, and one-of-each needs no suffix. The TLD is still
|
|
72
|
+
dropped, for the same reason as before — it keeps a domain out of preview hostnames, which is
|
|
73
|
+
what trips Chrome's lookalike warning.
|
|
74
|
+
|
|
75
|
+
**Existing sites are untouched.** Every name is read from the site's own `webmonterey.json` and
|
|
76
|
+
`wrangler.jsonc`; nothing renames a resource that exists. A rebuild onto the new convention is a
|
|
77
|
+
new repo and new resources beside the old, tested in full, then a domain cutover — which is the
|
|
78
|
+
clean way to do it anyway.
|
|
79
|
+
|
|
80
|
+
- **No agency defaults left in the package.** `webm new` reads the GitHub owner from
|
|
81
|
+
`git config webm.org` and the staging inbox from `git config webm.stagingEmail` (falling back
|
|
82
|
+
to `user.email`); `--org` and `--staging-email` override for one run. Without an owner it refuses
|
|
83
|
+
and says how to set one. Set it once per machine:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
git config --global webm.org <your-github-owner>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
14
91
|
## 1.0.0 — 2026-09-02
|
|
15
92
|
|
|
16
93
|
The first public release, on npmjs. This is the framework as it stands after two private
|
package/README.md
CHANGED
|
@@ -23,11 +23,16 @@ client's repo. Every default has a documented way to opt out of it.
|
|
|
23
23
|
## Creating a client site
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
+
git config --global webm.org <your-github-owner> # once per machine
|
|
26
27
|
npx @cparkerwebm/webmonterey new example.com --client="Example Co"
|
|
27
|
-
cd
|
|
28
|
+
cd example
|
|
28
29
|
claude # then /webm:start
|
|
29
30
|
```
|
|
30
31
|
|
|
32
|
+
One name everywhere: `example.com` becomes `example` — the repo, the Worker, the D1 database and
|
|
33
|
+
the R2 bucket. The domain minus its TLD is the one shape every resource accepts, and it keeps a
|
|
34
|
+
domain out of preview hostnames, which is what trips Chrome's lookalike warning.
|
|
35
|
+
|
|
31
36
|
`webm new` writes the identity and design files, a working contact form, a home page and the
|
|
32
37
|
fleet skills, then runs `git init` and `npm install`. It touches nothing outside the directory.
|
|
33
38
|
`/webm:start` takes it from there: the GitHub repo, Cloudflare resources, Workers Builds, the
|
package/dist/webm.mjs
CHANGED
|
@@ -67,9 +67,6 @@ function normalizeDomain(input) {
|
|
|
67
67
|
}
|
|
68
68
|
return cleaned;
|
|
69
69
|
}
|
|
70
|
-
function repoName(domain) {
|
|
71
|
-
return normalizeDomain(domain).replace(/\./g, "_");
|
|
72
|
-
}
|
|
73
70
|
function slugFor(domain) {
|
|
74
71
|
const clean = normalizeDomain(domain);
|
|
75
72
|
const parts = clean.split(".");
|
|
@@ -78,16 +75,12 @@ function slugFor(domain) {
|
|
|
78
75
|
const kept = parts.slice(0, Math.max(1, parts.length - drop));
|
|
79
76
|
return kept.join("-");
|
|
80
77
|
}
|
|
78
|
+
function repoName(domain) {
|
|
79
|
+
return slugFor(domain);
|
|
80
|
+
}
|
|
81
81
|
function resourceNames(domain) {
|
|
82
82
|
const slug = slugFor(domain);
|
|
83
|
-
return {
|
|
84
|
-
slug,
|
|
85
|
-
repo: repoName(domain),
|
|
86
|
-
worker: `webm-${slug}`,
|
|
87
|
-
d1: `webm-${slug}-db`,
|
|
88
|
-
r2Media: `webm-${slug}-media`,
|
|
89
|
-
r2App: `webm-${slug}-app`
|
|
90
|
-
};
|
|
83
|
+
return { slug, repo: slug, worker: slug, d1: slug, r2: slug, kv: slug };
|
|
91
84
|
}
|
|
92
85
|
var TWO_PART_SUFFIXES, DomainError;
|
|
93
86
|
var init_slug = __esm({
|
|
@@ -213,7 +206,7 @@ function scaffold(options) {
|
|
|
213
206
|
const { domain, packageVersion: packageVersion2 } = options;
|
|
214
207
|
const n = resourceNames(domain);
|
|
215
208
|
const client = options.client ?? "CHANGEME";
|
|
216
|
-
const org = options
|
|
209
|
+
const { org } = options;
|
|
217
210
|
const { today } = options;
|
|
218
211
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(today)) {
|
|
219
212
|
throw new Error(`scaffold: today must be YYYY-MM-DD, got ${JSON.stringify(today)}`);
|
|
@@ -549,12 +542,11 @@ ${domain} \u2014 built on [@cparkerwebm/webmonterey](https://github.com/cparkerw
|
|
|
549
542
|
|
|
550
543
|
| | |
|
|
551
544
|
| --- | --- |
|
|
552
|
-
| Worker | \`${n.
|
|
553
|
-
| D1 | \`${n.d1}\` |
|
|
554
|
-
| R2 media | \`${n.r2Media}\` |
|
|
545
|
+
| Worker \xB7 D1 \xB7 R2 | \`${n.slug}\` |
|
|
555
546
|
|
|
556
|
-
|
|
557
|
-
|
|
547
|
+
One name everywhere: the domain minus its TLD. A preview hostname then never embeds a
|
|
548
|
+
domain Chrome could mistake for a lookalike. A second resource of one kind takes a purpose
|
|
549
|
+
suffix - \`${n.slug}-portal\`.
|
|
558
550
|
|
|
559
551
|
## Deploying
|
|
560
552
|
|
|
@@ -818,26 +810,33 @@ function parseArgs(argv2) {
|
|
|
818
810
|
return {
|
|
819
811
|
domain: positional[0],
|
|
820
812
|
client: flag("client"),
|
|
821
|
-
org: flag("org") ?? "
|
|
822
|
-
stagingEmail: flag("staging-email"),
|
|
813
|
+
org: flag("org") ?? gitConfig("webm.org"),
|
|
814
|
+
stagingEmail: flag("staging-email") ?? gitConfig("webm.stagingEmail") ?? gitConfig("user.email"),
|
|
823
815
|
into: flag("into"),
|
|
824
816
|
install: !argv2.includes("--no-install")
|
|
825
817
|
};
|
|
826
818
|
}
|
|
827
|
-
function
|
|
819
|
+
function gitConfig(key) {
|
|
828
820
|
try {
|
|
829
|
-
|
|
821
|
+
const value = execFileSync("git", ["config", key], { encoding: "utf8" }).trim();
|
|
822
|
+
return value || void 0;
|
|
830
823
|
} catch {
|
|
831
|
-
return
|
|
824
|
+
return void 0;
|
|
832
825
|
}
|
|
833
826
|
}
|
|
834
827
|
function run2(argv2) {
|
|
835
828
|
const args = parseArgs(argv2);
|
|
836
829
|
if (!args.domain) {
|
|
837
830
|
console.error(
|
|
838
|
-
'webm new <domain> [--client="Name"] [--org
|
|
831
|
+
'webm new <domain> [--client="Name"] [--org=<github-owner>] [--staging-email=you@example.com] [--into=path] [--no-install]'
|
|
832
|
+
);
|
|
833
|
+
console.error('\n webm new example.com --client="Example Co"');
|
|
834
|
+
return 1;
|
|
835
|
+
}
|
|
836
|
+
if (!args.org) {
|
|
837
|
+
console.error(
|
|
838
|
+
"webm new: no GitHub owner for the repo. Either pass --org=<owner> or set it once:\n\n git config --global webm.org <owner>\n"
|
|
839
839
|
);
|
|
840
|
-
console.error('\n webm new autire.com --client="Autire Technologies"');
|
|
841
840
|
return 1;
|
|
842
841
|
}
|
|
843
842
|
let domain;
|
|
@@ -857,7 +856,7 @@ function run2(argv2) {
|
|
|
857
856
|
domain,
|
|
858
857
|
client: args.client,
|
|
859
858
|
org: args.org,
|
|
860
|
-
stagingEmail: args.stagingEmail
|
|
859
|
+
stagingEmail: args.stagingEmail,
|
|
861
860
|
packageVersion: packageVersion(),
|
|
862
861
|
/* Real today, not a constant - see ScaffoldOptions.today for what a stale one does. */
|
|
863
862
|
today: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
|
|
@@ -874,12 +873,14 @@ function run2(argv2) {
|
|
|
874
873
|
console.log(` + ${seeded.length} seeded (favicons, headers, CLAUDE.md, a contact form)`);
|
|
875
874
|
console.log(` + ${kept.length} .gitkeep, so the empty directories survive a clone
|
|
876
875
|
`);
|
|
877
|
-
console.log(` repo
|
|
876
|
+
console.log(` repo ${args.org}/${names.repo}`);
|
|
878
877
|
console.log(
|
|
879
|
-
` worker ${names.
|
|
878
|
+
` worker \xB7 d1 \xB7 r2 ${names.slug} (the domain minus its TLD, so Chrome does not flag previews)`
|
|
879
|
+
);
|
|
880
|
+
console.log(
|
|
881
|
+
`
|
|
882
|
+
One name everywhere. If ${names.slug} is already taken in the account - ${domain.replace(/^[^.]+/, names.slug)} and another TLD both want it - pick another with --into and edit webmonterey.json.`
|
|
880
883
|
);
|
|
881
|
-
console.log(` d1 ${names.d1}`);
|
|
882
|
-
console.log(` r2 ${names.r2Media}`);
|
|
883
884
|
try {
|
|
884
885
|
execFileSync("git", ["init", "-q", "-b", "main"], { cwd: root });
|
|
885
886
|
} catch {
|
|
@@ -1954,17 +1955,17 @@ var init_checks = __esm({
|
|
|
1954
1955
|
}
|
|
1955
1956
|
},
|
|
1956
1957
|
{
|
|
1957
|
-
id: "
|
|
1958
|
-
title: "Something renders the
|
|
1959
|
-
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',
|
|
1960
1961
|
run(ctx) {
|
|
1961
1962
|
if (ctx.components.size === 0) return pass;
|
|
1962
1963
|
if (ctx.site.domain === "webmonterey.com") return pass;
|
|
1963
1964
|
for (const src of ctx.components.values()) {
|
|
1964
|
-
if (/webmonterey\/
|
|
1965
|
+
if (/webmonterey\/webmaster/.test(stripComments(src))) return pass;
|
|
1965
1966
|
}
|
|
1966
1967
|
return warn(
|
|
1967
|
-
"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."
|
|
1968
1969
|
);
|
|
1969
1970
|
}
|
|
1970
1971
|
},
|
|
@@ -2086,17 +2087,17 @@ function parseJsonc(source) {
|
|
|
2086
2087
|
}
|
|
2087
2088
|
function readTree(root, dir, exts) {
|
|
2088
2089
|
const out = /* @__PURE__ */ new Map();
|
|
2089
|
-
const
|
|
2090
|
+
const walk4 = (d) => {
|
|
2090
2091
|
if (!existsSync8(d)) return;
|
|
2091
2092
|
for (const entry2 of readdirSync6(d, { withFileTypes: true })) {
|
|
2092
2093
|
const full = join7(d, entry2.name);
|
|
2093
|
-
if (entry2.isDirectory())
|
|
2094
|
+
if (entry2.isDirectory()) walk4(full);
|
|
2094
2095
|
else if (exts.some((e) => entry2.name.endsWith(e))) {
|
|
2095
2096
|
out.set(relative3(root, full), readFileSync7(full, "utf8"));
|
|
2096
2097
|
}
|
|
2097
2098
|
}
|
|
2098
2099
|
};
|
|
2099
|
-
|
|
2100
|
+
walk4(join7(root, dir));
|
|
2100
2101
|
return out;
|
|
2101
2102
|
}
|
|
2102
2103
|
function placeholderFiles(siteRoot) {
|
|
@@ -2212,6 +2213,243 @@ var init_doctor = __esm({
|
|
|
2212
2213
|
}
|
|
2213
2214
|
});
|
|
2214
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
|
+
|
|
2215
2453
|
// src/cli/codemods.ts
|
|
2216
2454
|
function compareVersions(a, b) {
|
|
2217
2455
|
const pa = a.split(".").map(Number);
|
|
@@ -2233,24 +2471,24 @@ var init_codemods = __esm({
|
|
|
2233
2471
|
// src/cli/upgrade.ts
|
|
2234
2472
|
var upgrade_exports = {};
|
|
2235
2473
|
__export(upgrade_exports, {
|
|
2236
|
-
run: () =>
|
|
2474
|
+
run: () => run7
|
|
2237
2475
|
});
|
|
2238
2476
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2239
|
-
import { existsSync as
|
|
2240
|
-
import { join as
|
|
2477
|
+
import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
|
|
2478
|
+
import { join as join9 } from "node:path";
|
|
2241
2479
|
function git(siteRoot, args) {
|
|
2242
2480
|
return execFileSync2("git", args, { cwd: siteRoot, encoding: "utf8" }).trim();
|
|
2243
2481
|
}
|
|
2244
2482
|
function installedVersion(siteRoot) {
|
|
2245
|
-
const path =
|
|
2246
|
-
if (!
|
|
2247
|
-
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;
|
|
2248
2486
|
}
|
|
2249
|
-
function
|
|
2487
|
+
function run7(argv2) {
|
|
2250
2488
|
const siteRoot = process.cwd();
|
|
2251
2489
|
const target = argv2.find((a) => !a.startsWith("-")) ?? "latest";
|
|
2252
2490
|
const dryRun = argv2.includes("--dry-run");
|
|
2253
|
-
if (!
|
|
2491
|
+
if (!existsSync10(join9(siteRoot, "webmonterey.json"))) {
|
|
2254
2492
|
console.error(`webm upgrade: no webmonterey.json here. Not a WebMonterey site.`);
|
|
2255
2493
|
return 1;
|
|
2256
2494
|
}
|
|
@@ -2316,9 +2554,9 @@ var init_upgrade = __esm({
|
|
|
2316
2554
|
|
|
2317
2555
|
// bin/webm.mjs
|
|
2318
2556
|
init_package_root();
|
|
2319
|
-
import { readFileSync as
|
|
2320
|
-
import { join as
|
|
2321
|
-
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"));
|
|
2322
2560
|
var COMMANDS = {
|
|
2323
2561
|
new: {
|
|
2324
2562
|
blurb: "Scaffold a new client site from a domain",
|
|
@@ -2340,6 +2578,10 @@ var COMMANDS = {
|
|
|
2340
2578
|
blurb: "Check this site against the traps that fail silently",
|
|
2341
2579
|
load: () => Promise.resolve().then(() => (init_doctor(), doctor_exports))
|
|
2342
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
|
+
},
|
|
2343
2585
|
upgrade: {
|
|
2344
2586
|
blurb: "Move this site to a newer framework version",
|
|
2345
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",
|