create-website-build-kit 0.1.16 → 0.1.18
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-website-build-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"description": "Scaffold a production marketing site \u2014 Astro on Cloudflare Workers, with the gates, the migration playbook and the accessibility work already wired.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
package/template/CLAUDE.md
CHANGED
|
@@ -111,6 +111,11 @@ weakened scrim.** On a real site two of three regions could not fail at any phot
|
|
|
111
111
|
exposure was a scrim lightened from 92% to 62% so a client's photography could show its colour.
|
|
112
112
|
This check is what makes weakening one safe.
|
|
113
113
|
|
|
114
|
+
**Navigation may be CMS-managed; redirects may not.** `check:cms` resolves every internal path in
|
|
115
|
+
CMS-managed data against `src/pages`, so a menu item pointing at a missing page fails the build
|
|
116
|
+
rather than 404ing for a visitor. A redirect has no such check — a client toggling one off is
|
|
117
|
+
silent traffic loss — so it stays in code.
|
|
118
|
+
|
|
114
119
|
⚠ **A CMS DELETES EVERY KEY ITS SCHEMA FORGOT.** It rewrites the whole file from the schema, so
|
|
115
120
|
anything undeclared is absent from what it writes back — the client changes one field, saves, and
|
|
116
121
|
the rest is gone, looking like an ordinary content commit. `npm run check:cms` refuses a
|
package/template/package.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"check:copy": "node scripts/check-copy.mjs",
|
|
21
21
|
"check:drift": "node scripts/check-drift.mjs",
|
|
22
22
|
"check:form": "node scripts/check-form.mjs",
|
|
23
|
+
"check:redirects": "node scripts/check-redirects.mjs",
|
|
23
24
|
"check:secrets": "node scripts/check-secrets.mjs",
|
|
24
25
|
"check:sitemap": "node scripts/check-sitemap.mjs",
|
|
25
26
|
"console": "node scripts/check-console.mjs",
|
|
@@ -122,10 +122,22 @@ step(process.execPath, ['scripts/check-env.mjs']);
|
|
|
122
122
|
|
|
123
123
|
if (env === 'production') {
|
|
124
124
|
step(process.execPath, ['scripts/check-sitemap.mjs']);
|
|
125
|
+
/* A redirect map is the one migration artefact edited by hand, in bulk, about
|
|
126
|
+
URLs nobody can see any more. Every failure it has is invisible at deploy. */
|
|
127
|
+
step(process.execPath, ['scripts/check-redirects.mjs']);
|
|
125
128
|
/* Production only: it measures the GENERATED images, and a staging build is
|
|
126
129
|
often run before `npm run media` has caught up. A no-op until a project
|
|
127
130
|
declares regions — the template has no design and therefore none. */
|
|
128
131
|
step(process.execPath, ['scripts/check-contrast.mjs']);
|
|
132
|
+
|
|
133
|
+
/*
|
|
134
|
+
* Advisory: it exits 0 whatever it finds, because drift is a decision and not
|
|
135
|
+
* an error. It runs here because a site is current on the day it is scaffolded
|
|
136
|
+
* and behind some months later — and the build is the only moment anybody is
|
|
137
|
+
* reliably looking. A check nobody remembers to run is the failure it exists
|
|
138
|
+
* to catch, applied to itself.
|
|
139
|
+
*/
|
|
140
|
+
step(process.execPath, ['scripts/check-drift.mjs']);
|
|
129
141
|
}
|
|
130
142
|
|
|
131
143
|
/* A sanity line, so the log says which environment actually ran rather than
|
|
@@ -42,6 +42,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
42
42
|
import { join, relative, sep } from 'node:path';
|
|
43
43
|
import { parse } from 'yaml';
|
|
44
44
|
import { literalImages } from './lib/literal-images.mjs';
|
|
45
|
+
import { routeExists, routesFromPages } from './lib/routes.mjs';
|
|
45
46
|
|
|
46
47
|
const RESET = '\x1b[0m';
|
|
47
48
|
const RED = '\x1b[31m';
|
|
@@ -322,6 +323,83 @@ for (const entry of entries) {
|
|
|
322
323
|
}
|
|
323
324
|
}
|
|
324
325
|
|
|
326
|
+
/* ── internal links a client can type ────────────────────────────────────── */
|
|
327
|
+
|
|
328
|
+
/*
|
|
329
|
+
* ⚠ THIS IS WHAT MAKES NAVIGATION SAFE TO PUT IN A CMS.
|
|
330
|
+
*
|
|
331
|
+
* `stacks.md` kept nav out of the CMS for a good reason — a bad value should
|
|
332
|
+
* fail the build, not publish. A typo'd path gives a menu item leading to a
|
|
333
|
+
* 404: the page renders, nothing errors, and only a visitor finds it.
|
|
334
|
+
*
|
|
335
|
+
* But navigation was missing from all five audited sites, so every client had
|
|
336
|
+
* to ask for a menu change. That is not a rule being respected, it is a gap
|
|
337
|
+
* the rule creates. The answer is not to forbid the field, it is to verify
|
|
338
|
+
* it — before the build, while somebody is still looking at the config.
|
|
339
|
+
*
|
|
340
|
+
* ⚠ A DYNAMIC ROUTE IS A PATTERN. `[slug].astro` serves every legal page, so
|
|
341
|
+
* treating routes as literal strings would report most of a site as broken.
|
|
342
|
+
* `routesFromPages` returns patterns for those and `routeExists` matches them.
|
|
343
|
+
*
|
|
344
|
+
* External links, `mailto:`, `tel:` and bare anchors are somebody else's
|
|
345
|
+
* problem — `verify` checks those against the deployed site, where they can
|
|
346
|
+
* actually be resolved.
|
|
347
|
+
*/
|
|
348
|
+
const LINKISH = /(^|\.)(href|url|link|to|target|destination)$/i;
|
|
349
|
+
const routes = routesFromPages();
|
|
350
|
+
const brokenLinks = [];
|
|
351
|
+
|
|
352
|
+
if (routes.static.size || routes.dynamic.length) {
|
|
353
|
+
for (const entry of entries) {
|
|
354
|
+
const documents = [];
|
|
355
|
+
if (entry?.type === 'collection' && existsSync(entry.path ?? '')) {
|
|
356
|
+
const walk = (d) =>
|
|
357
|
+
readdirSync(d).flatMap((e) => {
|
|
358
|
+
const full = join(d, e);
|
|
359
|
+
return statSync(full).isDirectory() ? walk(full) : [full];
|
|
360
|
+
});
|
|
361
|
+
for (const file of walk(entry.path).filter((f) => /\.mdx?$/.test(f))) {
|
|
362
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(file, 'utf8'));
|
|
363
|
+
if (!m) continue;
|
|
364
|
+
try {
|
|
365
|
+
documents.push({ where: rel(file), data: parse(m[1]) ?? {} });
|
|
366
|
+
} catch {
|
|
367
|
+
/* reported elsewhere */
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} else if (/\.json$/.test(entry?.path ?? '') && existsSync(entry.path)) {
|
|
371
|
+
try {
|
|
372
|
+
documents.push({ where: rel(entry.path), data: JSON.parse(readFileSync(entry.path, 'utf8')) });
|
|
373
|
+
} catch {
|
|
374
|
+
/* reported above */
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const doc of documents) {
|
|
379
|
+
const visit = (value, path) => {
|
|
380
|
+
if (Array.isArray(value)) return value.forEach((v) => visit(v, path));
|
|
381
|
+
if (value && typeof value === 'object') {
|
|
382
|
+
for (const [k, v] of Object.entries(value)) visit(v, path ? `${path}.${k}` : k);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (typeof value !== 'string' || !LINKISH.test(path)) return;
|
|
386
|
+
if (!value.startsWith('/')) return; // external, mailto:, tel:, #anchor
|
|
387
|
+
if (routeExists(value, routes)) return;
|
|
388
|
+
brokenLinks.push({ where: doc.where, path, value });
|
|
389
|
+
};
|
|
390
|
+
visit(doc.data, '');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (brokenLinks.length) {
|
|
396
|
+
problems.push({
|
|
397
|
+
label: 'internal links',
|
|
398
|
+
why: `${brokenLinks.length} point at a page this site does not serve`,
|
|
399
|
+
links: brokenLinks,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
325
403
|
/* ── coverage, and secrets ───────────────────────────────────────────────── */
|
|
326
404
|
|
|
327
405
|
/*
|
|
@@ -483,6 +561,16 @@ for (const p of problems) {
|
|
|
483
561
|
` the client will never touch — or move them out of a CMS-managed file.${RESET}`,
|
|
484
562
|
);
|
|
485
563
|
}
|
|
564
|
+
if (p.links) {
|
|
565
|
+
for (const l of p.links.slice(0, 8)) {
|
|
566
|
+
console.error(` ${DIM}${l.where} ${l.path} = ${JSON.stringify(l.value)}${RESET}`);
|
|
567
|
+
}
|
|
568
|
+
console.error(
|
|
569
|
+
` ${DIM}A menu item pointing at a missing page renders perfectly and 404s only\n` +
|
|
570
|
+
` for a visitor. This is what lets navigation be a CMS field at all: the\n` +
|
|
571
|
+
` value is checked before the build rather than trusted.${RESET}`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
486
574
|
if (p.picker) {
|
|
487
575
|
console.error(
|
|
488
576
|
` ${DIM}The site still renders this: <Img> accepts a manifest key as well as a\n` +
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse a redirect map that is silently wrong.
|
|
3
|
+
*
|
|
4
|
+
* npm run check:redirects
|
|
5
|
+
*
|
|
6
|
+
* Runs in `build:production`. A no-op when there is no `public/_redirects`.
|
|
7
|
+
*
|
|
8
|
+
* ── WHY THIS IS ITS OWN CHECK ──────────────────────────────────────────────
|
|
9
|
+
* `redirects.mjs` PROPOSES a map from the old site's inventory. Nothing has
|
|
10
|
+
* ever checked the map that a human then edited — and the editing is where the
|
|
11
|
+
* mistakes are, because a redirect file is the one artefact in a migration
|
|
12
|
+
* that is written by hand, in bulk, under time pressure, about URLs nobody can
|
|
13
|
+
* see any more.
|
|
14
|
+
*
|
|
15
|
+
* ⚠ EVERY FAILURE BELOW IS INVISIBLE AT DEPLOY. The file parses, the site
|
|
16
|
+
* builds, the pages are fine. What breaks is a URL that used to rank, weeks
|
|
17
|
+
* later, in somebody else's analytics.
|
|
18
|
+
*
|
|
19
|
+
* ── WHAT IT CATCHES, AND WHY EACH ONE MATTERS ──────────────────────────────
|
|
20
|
+
* **A duplicate source.** Cloudflare takes the FIRST match and ignores the
|
|
21
|
+
* rest, silently. So the second rule — usually the one somebody added later,
|
|
22
|
+
* on purpose, to fix something — never fires at all, and the fix appears not
|
|
23
|
+
* to work for reasons nothing explains.
|
|
24
|
+
*
|
|
25
|
+
* **A self-redirect.** `/a → /a` is a loop the browser stops after ~20 hops
|
|
26
|
+
* with ERR_TOO_MANY_REDIRECTS. The page is simply gone, and it is gone only in
|
|
27
|
+
* production, because nobody clicks the old URL in development.
|
|
28
|
+
*
|
|
29
|
+
* **A loop.** `/a → /b → /a`. The same, with an extra step to hide it.
|
|
30
|
+
*
|
|
31
|
+
* **A chain.** `/a → /b → /c` costs a redundant round trip on every visit and
|
|
32
|
+
* leaks a little PageRank at each hop. Cloudflare resolves only one hop per
|
|
33
|
+
* request, so a chain is also slower than it looks.
|
|
34
|
+
*
|
|
35
|
+
* **An unsupported status.** Cloudflare `_redirects` accepts only
|
|
36
|
+
* 200/301/302/303/307/308. Anything else makes the platform reject the rule —
|
|
37
|
+
* see `traps.md`, where a trailing comment rejected the whole FILE.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
41
|
+
|
|
42
|
+
const RESET = '\x1b[0m';
|
|
43
|
+
const RED = '\x1b[31m';
|
|
44
|
+
const GREEN = '\x1b[32m';
|
|
45
|
+
const YELLOW = '\x1b[33m';
|
|
46
|
+
const DIM = '\x1b[2m';
|
|
47
|
+
|
|
48
|
+
const FILE = 'public/_redirects';
|
|
49
|
+
const ALLOWED = new Set([200, 301, 302, 303, 307, 308]);
|
|
50
|
+
|
|
51
|
+
if (!existsSync(FILE)) {
|
|
52
|
+
console.log(`${DIM}·${RESET} no ${FILE} — nothing to validate`);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const problems = [];
|
|
57
|
+
const warnings = [];
|
|
58
|
+
const rules = [];
|
|
59
|
+
|
|
60
|
+
const lines = readFileSync(FILE, 'utf8').split('\n');
|
|
61
|
+
|
|
62
|
+
lines.forEach((raw, index) => {
|
|
63
|
+
const line = raw.trim();
|
|
64
|
+
if (!line || line.startsWith('#')) return;
|
|
65
|
+
|
|
66
|
+
/* ⚠ SPLIT ON WHITESPACE, NOT ON A SINGLE SPACE. Columns in a hand-edited
|
|
67
|
+
file are aligned with runs of spaces, and a naive split reports every
|
|
68
|
+
aligned rule as malformed — which on a real migration is all of them. */
|
|
69
|
+
const parts = line.split(/\s+/);
|
|
70
|
+
const [from, to, status] = parts;
|
|
71
|
+
|
|
72
|
+
if (!from || !to) {
|
|
73
|
+
problems.push({ line: index + 1, why: `is not a rule: ${JSON.stringify(line)}`, raw: line });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const code = status === undefined ? 301 : Number(status);
|
|
78
|
+
if (!Number.isInteger(code) || !ALLOWED.has(code)) {
|
|
79
|
+
problems.push({
|
|
80
|
+
line: index + 1,
|
|
81
|
+
why: `status ${JSON.stringify(status)} is not one Cloudflare accepts (200, 301, 302, 303, 307, 308)`,
|
|
82
|
+
raw: line,
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
rules.push({ line: index + 1, from, to, code, raw: line });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/* ── a duplicate source ───────────────────────────────────────────────────── */
|
|
91
|
+
|
|
92
|
+
const bySource = new Map();
|
|
93
|
+
for (const rule of rules) {
|
|
94
|
+
const key = rule.from;
|
|
95
|
+
if (bySource.has(key)) {
|
|
96
|
+
problems.push({
|
|
97
|
+
line: rule.line,
|
|
98
|
+
why: `duplicate source ${key} — first declared on line ${bySource.get(key).line}. Cloudflare takes the FIRST match, so this rule never fires`,
|
|
99
|
+
raw: rule.raw,
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
bySource.set(key, rule);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ── self-redirects, loops and chains ─────────────────────────────────────── */
|
|
107
|
+
|
|
108
|
+
/* Compare with and without a trailing slash: `/a` and `/a/` are the same page
|
|
109
|
+
to a reader and to Cloudflare's matcher, and a loop written across the two
|
|
110
|
+
forms is the one nobody spots by eye. */
|
|
111
|
+
const norm = (p) => (p.length > 1 ? p.replace(/\/+$/, '') : p);
|
|
112
|
+
|
|
113
|
+
for (const rule of rules) {
|
|
114
|
+
if (norm(rule.from) === norm(rule.to)) {
|
|
115
|
+
problems.push({
|
|
116
|
+
line: rule.line,
|
|
117
|
+
why: `redirects to itself — a browser stops after about twenty hops and the page is simply gone`,
|
|
118
|
+
raw: rule.raw,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const target = new Map(rules.map((r) => [norm(r.from), r]));
|
|
124
|
+
|
|
125
|
+
/*
|
|
126
|
+
* ⚠ WALK FIRST, THEN DECIDE. Reporting a hop as a chain the moment it is seen
|
|
127
|
+
* means a LOOP is announced as a chain and then as a loop — two messages, the
|
|
128
|
+
* first of them wrong, and the wrong one arrives first. Collect the walk, and
|
|
129
|
+
* only call it a chain if it actually terminates.
|
|
130
|
+
*/
|
|
131
|
+
for (const rule of rules) {
|
|
132
|
+
if (norm(rule.from) === norm(rule.to)) continue; // already reported
|
|
133
|
+
|
|
134
|
+
const path = [norm(rule.from)];
|
|
135
|
+
const hops = [];
|
|
136
|
+
let cursor = target.get(norm(rule.to));
|
|
137
|
+
let looped = false;
|
|
138
|
+
|
|
139
|
+
while (cursor && hops.length < 20) {
|
|
140
|
+
const here = norm(cursor.from);
|
|
141
|
+
if (path.includes(here)) {
|
|
142
|
+
looped = true;
|
|
143
|
+
path.push(here);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
path.push(here);
|
|
147
|
+
hops.push(cursor);
|
|
148
|
+
cursor = target.get(norm(cursor.to));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (looped) {
|
|
152
|
+
problems.push({
|
|
153
|
+
line: rule.line,
|
|
154
|
+
why: `is part of a redirect LOOP: ${path.join(' → ')}`,
|
|
155
|
+
raw: rule.raw,
|
|
156
|
+
});
|
|
157
|
+
} else if (hops.length) {
|
|
158
|
+
const last = hops[hops.length - 1];
|
|
159
|
+
warnings.push(
|
|
160
|
+
`line ${rule.line}: ${rule.from} reaches ${last.to} in ${hops.length + 1} hops. ` +
|
|
161
|
+
`Cloudflare resolves one per request, so point this rule straight at ${last.to}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* ── report ──────────────────────────────────────────────────────────────── */
|
|
167
|
+
|
|
168
|
+
const unique = [...new Set(warnings)];
|
|
169
|
+
for (const w of unique) console.log(` ${YELLOW}!${RESET} ${w}`);
|
|
170
|
+
|
|
171
|
+
if (!problems.length) {
|
|
172
|
+
console.log(
|
|
173
|
+
`${GREEN}✓${RESET} ${rules.length} redirect rule(s): no duplicates, no loops, every status supported`,
|
|
174
|
+
);
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.error(`\n${RED}✗ ${problems.length} problem(s) in ${FILE}${RESET}\n`);
|
|
179
|
+
for (const p of problems) {
|
|
180
|
+
console.error(` line ${p.line} ${p.why}`);
|
|
181
|
+
console.error(` ${DIM}${p.raw}${RESET}`);
|
|
182
|
+
}
|
|
183
|
+
console.error(
|
|
184
|
+
`\n ${DIM}None of these stop the file parsing or the site building. They break a URL\n` +
|
|
185
|
+
` that used to rank, weeks later, in somebody else's analytics.${RESET}\n`,
|
|
186
|
+
);
|
|
187
|
+
process.exit(1);
|
|
@@ -96,3 +96,70 @@ export async function discoverRoutes(origin, fetcher = fetch) {
|
|
|
96
96
|
|
|
97
97
|
return { routes: [], source: 'nothing — no sitemap and no dist/' };
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Routes derived from `src/pages`, without building anything.
|
|
102
|
+
*
|
|
103
|
+
* ── WHY NOT routesFromDist ─────────────────────────────────────────────────
|
|
104
|
+
* `check-cms.mjs` runs BEFORE the build, so `dist/` does not exist yet. The
|
|
105
|
+
* point of checking a navigation target early is to fail while somebody is
|
|
106
|
+
* still looking at the config, not after a deploy.
|
|
107
|
+
*
|
|
108
|
+
* Returns `{ static: Set<string>, dynamic: RegExp[] }`.
|
|
109
|
+
*
|
|
110
|
+
* ⚠ A DYNAMIC ROUTE IS A PATTERN, NOT A ROUTE. `[slug].astro` serves every
|
|
111
|
+
* legal page and `[...path].astro` serves any depth. Treating those as
|
|
112
|
+
* literal strings would report every real link through them as broken, which
|
|
113
|
+
* on a site with a `[slug]` catch-all is *most of the site* — a check that
|
|
114
|
+
* confident and that wrong gets switched off within a day.
|
|
115
|
+
*
|
|
116
|
+
* ⚠ ENDPOINTS ARE NOT PAGES. `robots.txt.ts` and `api/contact.ts` produce
|
|
117
|
+
* responses, never navigable pages, so they are excluded — nobody puts them
|
|
118
|
+
* in a menu and reporting them as available would be noise.
|
|
119
|
+
*/
|
|
120
|
+
export function routesFromPages(dir = 'src/pages') {
|
|
121
|
+
const out = { static: new Set(), dynamic: [] };
|
|
122
|
+
if (!existsSync(dir)) return out;
|
|
123
|
+
|
|
124
|
+
const walk = (d) =>
|
|
125
|
+
readdirSync(d, { withFileTypes: true }).flatMap((e) => {
|
|
126
|
+
const full = join(d, e.name);
|
|
127
|
+
return e.isDirectory() ? walk(full) : [full];
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
for (const file of walk(dir)) {
|
|
131
|
+
const rel = relative(dir, file).split(sep).join('/');
|
|
132
|
+
/* `_` prefixed files and directories are not routed by Astro. */
|
|
133
|
+
if (rel.split('/').some((part) => part.startsWith('_'))) continue;
|
|
134
|
+
if (!/\.(astro|md|mdx)$/.test(rel)) continue; // .ts endpoints are not pages
|
|
135
|
+
|
|
136
|
+
const path =
|
|
137
|
+
'/' +
|
|
138
|
+
rel
|
|
139
|
+
.replace(/\.(astro|md|mdx)$/, '')
|
|
140
|
+
.replace(/(^|\/)index$/, '$1')
|
|
141
|
+
.replace(/\/$/, '');
|
|
142
|
+
const route = path === '/' ? '/' : `${path}/`.replace(/\/+/g, '/');
|
|
143
|
+
|
|
144
|
+
if (route.includes('[')) {
|
|
145
|
+
/* [...rest] matches any depth; [slug] matches one segment. */
|
|
146
|
+
const pattern = route
|
|
147
|
+
.replace(/[.*+?^${}()|\\]/g, '\\$&')
|
|
148
|
+
.replace(/\[\.\.\.[^\]]+\]/g, '.+')
|
|
149
|
+
.replace(/\[[^\]]+\]/g, '[^/]+');
|
|
150
|
+
out.dynamic.push(new RegExp(`^${pattern}$`));
|
|
151
|
+
} else {
|
|
152
|
+
out.static.add(route);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Does `href` correspond to a page this site serves? */
|
|
160
|
+
export function routeExists(href, routes) {
|
|
161
|
+
const path = href.split('#')[0].split('?')[0];
|
|
162
|
+
const normalised = path.endsWith('/') || path === '' ? path || '/' : `${path}/`;
|
|
163
|
+
if (routes.static.has(normalised)) return true;
|
|
164
|
+
return routes.dynamic.some((re) => re.test(normalised));
|
|
165
|
+
}
|