create-website-build-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/index.mjs +149 -0
- package/package.json +42 -0
- package/template/.dev.vars.example +3 -0
- package/template/.github/workflows/gates.yml +58 -0
- package/template/.node-version +1 -0
- package/template/.pa11yci.json +24 -0
- package/template/BUILD-STATE.md +47 -0
- package/template/CLAUDE.md +153 -0
- package/template/astro.config.mjs +150 -0
- package/template/docs/analytics.md +86 -0
- package/template/docs/content.md +138 -0
- package/template/docs/handover.md +182 -0
- package/template/docs/handover.pdf +0 -0
- package/template/docs/runbook.md +661 -0
- package/template/docs/traps.md +903 -0
- package/template/gitignore +31 -0
- package/template/package-lock.json +8159 -0
- package/template/package.json +53 -0
- package/template/public/_headers +61 -0
- package/template/public/_redirects +39 -0
- package/template/public/site.webmanifest +13 -0
- package/template/scripts/a11y-evidence.mjs +258 -0
- package/template/scripts/check-console.mjs +125 -0
- package/template/scripts/check-env.mjs +99 -0
- package/template/scripts/check-reflow.mjs +148 -0
- package/template/scripts/check-sitemap.mjs +113 -0
- package/template/scripts/dns-snapshot.mjs +267 -0
- package/template/scripts/extract.mjs +317 -0
- package/template/scripts/indexnow.mjs +154 -0
- package/template/scripts/lastmod.mjs +147 -0
- package/template/scripts/lib/inventory.mjs +104 -0
- package/template/scripts/lib/preserved.mjs +42 -0
- package/template/scripts/lib/routes.mjs +92 -0
- package/template/scripts/md-to-pdf.mjs +335 -0
- package/template/scripts/og-cards.config.mjs +114 -0
- package/template/scripts/og-cards.mjs +487 -0
- package/template/scripts/optimize-media.mjs +380 -0
- package/template/scripts/recon.mjs +480 -0
- package/template/scripts/redirects.mjs +298 -0
- package/template/scripts/shots.mjs +447 -0
- package/template/scripts/staging-headers.mjs +102 -0
- package/template/scripts/tells.mjs +268 -0
- package/template/scripts/verify.mjs +1069 -0
- package/template/src/components/ContactForm.astro +405 -0
- package/template/src/components/CtaBand.astro +82 -0
- package/template/src/components/EnvBadge.astro +146 -0
- package/template/src/components/Footer.astro +210 -0
- package/template/src/components/Header.astro +530 -0
- package/template/src/components/Icon.astro +56 -0
- package/template/src/components/Img.astro +129 -0
- package/template/src/components/PageHero.astro +88 -0
- package/template/src/components/Seo.astro +119 -0
- package/template/src/components/StructuredData.astro +173 -0
- package/template/src/content/blog/.gitkeep +5 -0
- package/template/src/content/legal/.gitkeep +0 -0
- package/template/src/content.config.ts +81 -0
- package/template/src/data/areas.ts +31 -0
- package/template/src/data/business.ts +121 -0
- package/template/src/data/categories.ts +37 -0
- package/template/src/data/fonts.ts +25 -0
- package/template/src/data/image-manifest.json +1 -0
- package/template/src/data/lastmod.json +1 -0
- package/template/src/data/nav.ts +49 -0
- package/template/src/data/services.ts +39 -0
- package/template/src/data/site.ts +136 -0
- package/template/src/env.d.ts +28 -0
- package/template/src/layouts/Base.astro +223 -0
- package/template/src/lib/brevo.ts +96 -0
- package/template/src/lib/hast-media.mjs +55 -0
- package/template/src/lib/lastmod.mjs +47 -0
- package/template/src/lib/lead.ts +92 -0
- package/template/src/lib/legal-routes.mjs +31 -0
- package/template/src/lib/legal.ts +75 -0
- package/template/src/lib/posts.ts +64 -0
- package/template/src/lib/runtime.ts +33 -0
- package/template/src/pages/404.astro +51 -0
- package/template/src/pages/[slug].astro +111 -0
- package/template/src/pages/accessibility.astro +128 -0
- package/template/src/pages/api/contact.ts +191 -0
- package/template/src/pages/api/leads.csv.ts +82 -0
- package/template/src/pages/contact.astro +112 -0
- package/template/src/pages/index.astro +84 -0
- package/template/src/pages/robots.txt.ts +38 -0
- package/template/src/pages/rss.xml.ts +27 -0
- package/template/src/styles/global.css +463 -0
- package/template/src/styles/project.css +14 -0
- package/template/src/styles/prose.css +182 -0
- package/template/src/styles/tokens.css +218 -0
- package/template/tsconfig.json +5 -0
- package/template/wrangler.jsonc +63 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The old site's URL inventory — one reader, one format.
|
|
3
|
+
*
|
|
4
|
+
* `recon` writes `recon/urls.txt` and SKILL.md calls it "the inventory every
|
|
5
|
+
* later step reads". Three things read it — `redirects`, `shots` and the
|
|
6
|
+
* coverage check in `verify` — and until this module they each parsed it
|
|
7
|
+
* themselves, differently:
|
|
8
|
+
*
|
|
9
|
+
* redirects kept every line that was not blank or a `#` comment
|
|
10
|
+
* shots kept every line starting with `/`
|
|
11
|
+
*
|
|
12
|
+
* Which is fine until a real inventory turns up that does not match. One did:
|
|
13
|
+
* a file of ABSOLUTE URLs (`https://site.com/about/`) rather than paths,
|
|
14
|
+
* hand-made rather than written by `recon`. `redirects` then compared
|
|
15
|
+
* `https://site.com/about/` against `/about/` and matched nothing, proposing an
|
|
16
|
+
* empty map; `shots` found zero paths and silently reported the migration as
|
|
17
|
+
* greenfield with no before side. Neither said anything was wrong.
|
|
18
|
+
*
|
|
19
|
+
* So: both forms are accepted here, once, and every consumer gets the same
|
|
20
|
+
* answer. Same reason `lib/routes.mjs` and `lib/preserved.mjs` exist.
|
|
21
|
+
*
|
|
22
|
+
* ── ALREADY-DEAD URLS ARE MARKED, NOT DROPPED ─────────────────────────────
|
|
23
|
+
* `recon` pulls URLs from the Wayback Machine, and some of them were ALREADY
|
|
24
|
+
* 404 on the old site. They belong in the inventory — they still hold
|
|
25
|
+
* backlinks, so they are redirect targets — but they are not pages the
|
|
26
|
+
* migration lost. A coverage check that failed on them would go red on every
|
|
27
|
+
* healthy migration, and a check that goes red for a non-reason gets switched
|
|
28
|
+
* off. recon tags them; this reports them separately.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Lines tagged with this were 404 on the OLD site before the migration began. */
|
|
32
|
+
export const GONE_TAG = '# gone-on-old-site';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse an inventory file's contents.
|
|
36
|
+
*
|
|
37
|
+
* Returns `{ live, gone, origin }` — `live` and `gone` are arrays of paths.
|
|
38
|
+
* Accepts paths and absolute URLs interchangeably.
|
|
39
|
+
*/
|
|
40
|
+
export function parseInventory(text) {
|
|
41
|
+
const live = [];
|
|
42
|
+
const gone = [];
|
|
43
|
+
let origin = '';
|
|
44
|
+
|
|
45
|
+
for (const raw of text.split('\n')) {
|
|
46
|
+
const line = raw.trim();
|
|
47
|
+
if (!line) continue;
|
|
48
|
+
|
|
49
|
+
if (line.startsWith('#')) {
|
|
50
|
+
/* `# Inventory of https://old-site.com` — the only header worth keeping. */
|
|
51
|
+
const found = /Inventory of\s+(https?:\/\/\S+)/i.exec(line);
|
|
52
|
+
if (found) origin = found[1].replace(/\/$/, '');
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isGone = line.includes(GONE_TAG);
|
|
57
|
+
const value = line.split('#')[0].trim();
|
|
58
|
+
if (!value) continue;
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* Absolute or relative, the answer is the path. A hand-made inventory of
|
|
62
|
+
* absolute URLs is the case that broke two scripts silently.
|
|
63
|
+
*/
|
|
64
|
+
let path;
|
|
65
|
+
if (/^https?:\/\//i.test(value)) {
|
|
66
|
+
try {
|
|
67
|
+
const u = new URL(value);
|
|
68
|
+
path = u.pathname + u.search;
|
|
69
|
+
if (!origin) origin = u.origin;
|
|
70
|
+
} catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
} else if (value.startsWith('/')) {
|
|
74
|
+
path = value;
|
|
75
|
+
} else {
|
|
76
|
+
continue; // not a path or a URL — prose, or a stray line
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
(isGone ? gone : live).push(path);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
live: [...new Set(live)].sort(),
|
|
84
|
+
gone: [...new Set(gone)].sort(),
|
|
85
|
+
origin,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read the inventory from disk, or null when there is none.
|
|
91
|
+
*
|
|
92
|
+
* Null is the normal greenfield answer, not an error: there is no old site, so
|
|
93
|
+
* there is nothing to have lost. Callers report it rather than failing.
|
|
94
|
+
*/
|
|
95
|
+
export function readInventory(readFileSync, file = 'recon/urls.txt') {
|
|
96
|
+
let text;
|
|
97
|
+
try {
|
|
98
|
+
text = readFileSync(file, 'utf8');
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const parsed = parseInventory(text);
|
|
103
|
+
return parsed.live.length || parsed.gone.length ? parsed : null;
|
|
104
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paths other systems point at, which must keep resolving after a migration.
|
|
3
|
+
*
|
|
4
|
+
* Shared by `recon` (which finds them on the OLD site) and `verify` (which
|
|
5
|
+
* confirms they still resolve on the NEW one). Those were two separate lists
|
|
6
|
+
* until they were not: recon reported that `/feed/` had to survive and nothing
|
|
7
|
+
* ever checked whether it had. A list in one file cannot drift from itself.
|
|
8
|
+
*
|
|
9
|
+
* `build.md` §7 names this as a definition-of-done row. See `stacks.md` §1d for
|
|
10
|
+
* why each one is silent when it breaks.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const PRESERVED = [
|
|
14
|
+
['/robots.txt', 'The Sitemap: line is the discovery route for every crawler'],
|
|
15
|
+
['/ads.txt', 'Programmatic revenue stops without it, with no error anywhere'],
|
|
16
|
+
['/app-ads.txt', 'Same, for apps'],
|
|
17
|
+
['/BingSiteAuth.xml', 'Bing ownership verification'],
|
|
18
|
+
['/feed/', 'Subscribers and syndication partners — 301, never drop'],
|
|
19
|
+
['/comments/feed/', 'Same'],
|
|
20
|
+
['/rss.xml', 'Same'],
|
|
21
|
+
['/.well-known/security.txt', 'Copy the .well-known directory wholesale'],
|
|
22
|
+
['/.well-known/apple-app-site-association', 'App deep links break silently'],
|
|
23
|
+
['/favicon.ico', 'Referenced by things that never re-read your HTML'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read back what `recon` actually found on the old site, so `verify` checks
|
|
28
|
+
* the paths that MATTERED for this migration rather than the generic list.
|
|
29
|
+
*
|
|
30
|
+
* Returns null when there is no recon output — a greenfield build has no old
|
|
31
|
+
* site, and inventing an expectation there would be noise.
|
|
32
|
+
*/
|
|
33
|
+
export function preservedFromRecon(readFileSync) {
|
|
34
|
+
try {
|
|
35
|
+
const md = readFileSync('recon/preserved.md', 'utf8');
|
|
36
|
+
const section = md.split('## Present on the old site')[1]?.split('##')[0] ?? '';
|
|
37
|
+
const paths = [...section.matchAll(/^\|\s*`([^`]+)`/gm)].map((m) => m[1]);
|
|
38
|
+
return paths.length ? paths : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover the site's routes, without a list anyone has to maintain.
|
|
3
|
+
*
|
|
4
|
+
* A hardcoded route array in a script is a second source of truth for what the
|
|
5
|
+
* site contains. It goes stale silently — the script keeps passing while
|
|
6
|
+
* testing pages that no longer exist and skipping the ones that do — and it is
|
|
7
|
+
* how one project's routes ended up shipped inside this template.
|
|
8
|
+
*
|
|
9
|
+
* Production: the deployed sitemap, which is what a crawler sees.
|
|
10
|
+
* Staging or local: dist/, because staging emits no sitemap by design.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { join, relative } from 'node:path';
|
|
15
|
+
|
|
16
|
+
/** Routes this build emitted, as absolute paths. 404 is excluded — it is not a route. */
|
|
17
|
+
export function routesFromDist() {
|
|
18
|
+
const root = ['dist/client', 'dist'].find((d) => existsSync(d));
|
|
19
|
+
if (!root) return [];
|
|
20
|
+
|
|
21
|
+
const walk = (dir) =>
|
|
22
|
+
readdirSync(dir).flatMap((entry) => {
|
|
23
|
+
const full = join(dir, entry);
|
|
24
|
+
return statSync(full).isDirectory() ? walk(full) : [full];
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return walk(root)
|
|
28
|
+
.filter((f) => f.endsWith('.html') && !f.endsWith('404.html'))
|
|
29
|
+
.map((f) => '/' + relative(root, f).replace(/index\.html$/, '').replace(/\.html$/, '/'))
|
|
30
|
+
.map((p) => (p.endsWith('/') ? p : `${p}/`))
|
|
31
|
+
.sort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every `<loc>` in the deployed sitemap, following the index to its children.
|
|
36
|
+
* Returns [] when there is no sitemap, which is the normal staging case.
|
|
37
|
+
*/
|
|
38
|
+
export async function routesFromSitemap(origin, fetcher = fetch) {
|
|
39
|
+
const get = async (url) => {
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetcher(url);
|
|
42
|
+
return res.status === 200 ? await res.text() : '';
|
|
43
|
+
} catch {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const locs = (xml) => [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* A sitemap index lists its children as ABSOLUTE URLs at the host the build
|
|
52
|
+
* was made for. Following them literally means a local preview of a
|
|
53
|
+
* production build fetches example.com's sitemap, gets nothing, and silently
|
|
54
|
+
* falls back to dist/ — reporting fewer routes than the site has, with no
|
|
55
|
+
* error. Re-root onto the origin being asked about; a no-op when they match.
|
|
56
|
+
*/
|
|
57
|
+
const reroot = (url) => {
|
|
58
|
+
try {
|
|
59
|
+
const u = new URL(url);
|
|
60
|
+
return u.origin === new URL(origin).origin ? url : new URL(u.pathname + u.search, origin).href;
|
|
61
|
+
} catch {
|
|
62
|
+
return url;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const index = await get(`${origin}/sitemap-index.xml`);
|
|
67
|
+
const maps = index ? locs(index).map(reroot) : [`${origin}/sitemap-0.xml`];
|
|
68
|
+
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const map of maps) out.push(...locs(await get(map)));
|
|
71
|
+
return [...new Set(out.map(reroot))].sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sitemap first, dist/ as the fallback. Returns absolute URLs plus a `source`
|
|
76
|
+
* string, so a caller can say where its list came from — a route list of
|
|
77
|
+
* unclear origin is the thing this module exists to prevent.
|
|
78
|
+
*/
|
|
79
|
+
export async function discoverRoutes(origin, fetcher = fetch) {
|
|
80
|
+
const fromSitemap = await routesFromSitemap(origin, fetcher);
|
|
81
|
+
if (fromSitemap.length) return { routes: fromSitemap, source: 'the deployed sitemap' };
|
|
82
|
+
|
|
83
|
+
const fromDist = routesFromDist();
|
|
84
|
+
if (fromDist.length) {
|
|
85
|
+
return {
|
|
86
|
+
routes: fromDist.map((p) => origin + p),
|
|
87
|
+
source: 'dist/ (no sitemap — expected on staging)',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { routes: [], source: 'nothing — no sitemap and no dist/' };
|
|
92
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a markdown document to a PDF in the project's own type and palette.
|
|
3
|
+
*
|
|
4
|
+
* node scripts/md-to-pdf.mjs docs/client-note.md
|
|
5
|
+
* node scripts/md-to-pdf.mjs docs/client-note.md out/note.pdf
|
|
6
|
+
*
|
|
7
|
+
* For client-facing documents — proposals, estimates, handover notes. Point it
|
|
8
|
+
* at the site's own typefaces (see PROJECT CONFIG below) and a PDF you send
|
|
9
|
+
* looks like the site you built, rather than a text file someone printed. It
|
|
10
|
+
* runs on a fresh template too, in the system stack.
|
|
11
|
+
*
|
|
12
|
+
* ── WHY A HAND-ROLLED CONVERTER ────────────────────────────────────────────
|
|
13
|
+
* There is no pandoc, python-markdown or weasyprint on this machine, and
|
|
14
|
+
* adding a markdown library to a static marketing site's dependencies to
|
|
15
|
+
* format the occasional proposal is a poor trade. This handles the subset our
|
|
16
|
+
* docs actually use: headings, tables, lists, bold, italic, links, code, rules
|
|
17
|
+
* and paragraphs. It is not a spec-compliant parser and does not pretend to be
|
|
18
|
+
* — if a document stops rendering correctly, the document is using something
|
|
19
|
+
* this does not support, and the fix is either to add it here or to simplify
|
|
20
|
+
* the document.
|
|
21
|
+
*
|
|
22
|
+
* Chrome comes from puppeteer, which arrives as part of pa11y-ci — already a
|
|
23
|
+
* devDependency for the accessibility suite, so this adds nothing. The Chrome
|
|
24
|
+
* binary itself lives in a shared ~/.cache/puppeteer, not in node_modules.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
28
|
+
import { dirname, basename, resolve } from 'node:path';
|
|
29
|
+
|
|
30
|
+
import puppeteer from 'puppeteer';
|
|
31
|
+
|
|
32
|
+
const [, , input, outputArg] = process.argv;
|
|
33
|
+
if (!input) {
|
|
34
|
+
console.error('usage: node scripts/md-to-pdf.mjs <file.md> [out.pdf]');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const output = outputArg ?? input.replace(/\.md$/, '.pdf');
|
|
38
|
+
|
|
39
|
+
/* ── Markdown → HTML ───────────────────────────────────────────────────── */
|
|
40
|
+
|
|
41
|
+
const esc = (s) =>
|
|
42
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
43
|
+
|
|
44
|
+
/** Inline formatting. Order matters: code first, so its contents are literal. */
|
|
45
|
+
function inline(text) {
|
|
46
|
+
const codes = [];
|
|
47
|
+
let s = text.replace(/`([^`]+)`/g, (_, c) => {
|
|
48
|
+
codes.push(c);
|
|
49
|
+
return `\u0000${codes.length - 1}\u0000`;
|
|
50
|
+
});
|
|
51
|
+
s = esc(s);
|
|
52
|
+
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
53
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
54
|
+
s = s.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
|
55
|
+
s = s.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${esc(codes[Number(i)])}</code>`);
|
|
56
|
+
return s;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const splitRow = (line) =>
|
|
60
|
+
line
|
|
61
|
+
.trim()
|
|
62
|
+
.replace(/^\||\|$/g, '')
|
|
63
|
+
.split('|')
|
|
64
|
+
.map((c) => c.trim());
|
|
65
|
+
|
|
66
|
+
function toHtml(md) {
|
|
67
|
+
const lines = md.split('\n');
|
|
68
|
+
const out = [];
|
|
69
|
+
let i = 0;
|
|
70
|
+
let list = null;
|
|
71
|
+
|
|
72
|
+
const closeList = () => {
|
|
73
|
+
if (list) {
|
|
74
|
+
out.push(`</${list}>`);
|
|
75
|
+
list = null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
while (i < lines.length) {
|
|
80
|
+
const line = lines[i];
|
|
81
|
+
|
|
82
|
+
if (/^\s*$/.test(line)) {
|
|
83
|
+
closeList();
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (/^---+\s*$/.test(line)) {
|
|
89
|
+
closeList();
|
|
90
|
+
out.push('<hr>');
|
|
91
|
+
i++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
96
|
+
if (h) {
|
|
97
|
+
closeList();
|
|
98
|
+
out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`);
|
|
99
|
+
i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Table: a header row followed by a |---|---| separator.
|
|
104
|
+
if (line.includes('|') && /^\s*\|?[\s:-]*-[\s:|-]*\|/.test(lines[i + 1] ?? '')) {
|
|
105
|
+
closeList();
|
|
106
|
+
const head = splitRow(line);
|
|
107
|
+
i += 2;
|
|
108
|
+
const rows = [];
|
|
109
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
|
|
110
|
+
rows.push(splitRow(lines[i]));
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
out.push('<table><thead><tr>');
|
|
114
|
+
head.forEach((c) => out.push(`<th>${inline(c)}</th>`));
|
|
115
|
+
out.push('</tr></thead><tbody>');
|
|
116
|
+
rows.forEach((r) => {
|
|
117
|
+
out.push('<tr>');
|
|
118
|
+
r.forEach((c) => out.push(`<td>${inline(c)}</td>`));
|
|
119
|
+
out.push('</tr>');
|
|
120
|
+
});
|
|
121
|
+
out.push('</tbody></table>');
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const li = /^\s*([-*]|\d+\.)\s+(.*)$/.exec(line);
|
|
126
|
+
if (li) {
|
|
127
|
+
const want = /^\d/.test(li[1]) ? 'ol' : 'ul';
|
|
128
|
+
if (list !== want) {
|
|
129
|
+
closeList();
|
|
130
|
+
out.push(`<${want}>`);
|
|
131
|
+
list = want;
|
|
132
|
+
}
|
|
133
|
+
// Continuation lines are indented under the marker.
|
|
134
|
+
let body = li[2];
|
|
135
|
+
while (/^\s{2,}\S/.test(lines[i + 1] ?? '') && !/^\s*([-*]|\d+\.)\s/.test(lines[i + 1])) {
|
|
136
|
+
body += ' ' + lines[++i].trim();
|
|
137
|
+
}
|
|
138
|
+
out.push(`<li>${inline(body)}</li>`);
|
|
139
|
+
i++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Consecutive `>` lines are ONE blockquote. Emitting one per line gives
|
|
144
|
+
each its own border and margin, so a five-line note renders as five
|
|
145
|
+
stacked quotes with gaps through it — which is what a long callout in a
|
|
146
|
+
handover document looks like if you do not do this. A blank `>` line
|
|
147
|
+
starts a new paragraph inside the same quote. */
|
|
148
|
+
if (/^>\s?/.test(line)) {
|
|
149
|
+
closeList();
|
|
150
|
+
const quoted = [];
|
|
151
|
+
while (i < lines.length && /^>/.test(lines[i])) {
|
|
152
|
+
quoted.push(lines[i].replace(/^>\s?/, ''));
|
|
153
|
+
i++;
|
|
154
|
+
}
|
|
155
|
+
const paras = quoted
|
|
156
|
+
.join('\n')
|
|
157
|
+
.split(/\n\s*\n/)
|
|
158
|
+
.map((p) => p.trim().replace(/\n/g, ' '))
|
|
159
|
+
.filter(Boolean);
|
|
160
|
+
out.push(`<blockquote>${paras.map((p) => `<p>${inline(p)}</p>`).join('')}</blockquote>`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Paragraph: gather until a blank line or a block-level marker.
|
|
165
|
+
closeList();
|
|
166
|
+
let para = line;
|
|
167
|
+
while (
|
|
168
|
+
i + 1 < lines.length &&
|
|
169
|
+
lines[i + 1].trim() &&
|
|
170
|
+
!/^(#{1,6}\s|---+\s*$|>\s?|\s*([-*]|\d+\.)\s)/.test(lines[i + 1]) &&
|
|
171
|
+
!lines[i + 1].includes('|')
|
|
172
|
+
) {
|
|
173
|
+
para += ' ' + lines[++i].trim();
|
|
174
|
+
}
|
|
175
|
+
out.push(`<p>${inline(para)}</p>`);
|
|
176
|
+
i++;
|
|
177
|
+
}
|
|
178
|
+
closeList();
|
|
179
|
+
return out.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ── Page ──────────────────────────────────────────────────────────────── */
|
|
183
|
+
|
|
184
|
+
const md = readFileSync(input, 'utf8');
|
|
185
|
+
const title = (/^#\s+(.*)$/m.exec(md)?.[1] ?? basename(input, '.md')).trim();
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
* ⚠ PROJECT CONFIG — the type and the palette.
|
|
189
|
+
*
|
|
190
|
+
* Defaults are the system stack and a neutral ink ramp, so this runs on a fresh
|
|
191
|
+
* template and produces a plain, readable document. Point BODY_FONT and
|
|
192
|
+
* DISPLAY_FONT at the project's own woff2 in public/fonts/ and the PDF starts
|
|
193
|
+
* looking like the site — which is the whole reason to send one.
|
|
194
|
+
*
|
|
195
|
+
* Mirror the colours from src/styles/tokens.css. Do not invent values here.
|
|
196
|
+
*
|
|
197
|
+
* A missing font file is not an error: the document falls back to the system
|
|
198
|
+
* stack and says so, rather than refusing to render a proposal because a
|
|
199
|
+
* typeface moved.
|
|
200
|
+
*/
|
|
201
|
+
const BODY_FONT = null; // e.g. 'display.woff2'
|
|
202
|
+
const DISPLAY_FONT = null; // e.g. 'script.woff2', for the H1 only
|
|
203
|
+
const PALETTE = { ink: '#14171a', muted: '#5b656e', accent: '#454e56', line: '#c7ced4' };
|
|
204
|
+
|
|
205
|
+
const fontDir = resolve('public/fonts');
|
|
206
|
+
const face = (family, file, weight) => {
|
|
207
|
+
if (!file) return '';
|
|
208
|
+
try {
|
|
209
|
+
const b64 = readFileSync(resolve(fontDir, file)).toString('base64');
|
|
210
|
+
return `@font-face{font-family:'${family}';src:url(data:font/woff2;base64,${b64}) format('woff2');${weight}}`;
|
|
211
|
+
} catch {
|
|
212
|
+
console.warn(`md-to-pdf: ${file} not found in public/fonts — falling back to the system stack.`);
|
|
213
|
+
return '';
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const bodyStack = `${BODY_FONT ? "'DocBody', " : ''}system-ui, sans-serif`;
|
|
218
|
+
const displayStack = `${DISPLAY_FONT ? "'DocDisplay', " : ''}${bodyStack}`;
|
|
219
|
+
|
|
220
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>
|
|
221
|
+
<style>
|
|
222
|
+
${face('DocBody', BODY_FONT, 'font-weight:100 900;')}
|
|
223
|
+
${face('DocDisplay', DISPLAY_FONT, '')}
|
|
224
|
+
@page { size: A4; margin: 18mm 16mm 20mm; }
|
|
225
|
+
|
|
226
|
+
:root {
|
|
227
|
+
--ink: ${PALETTE.ink};
|
|
228
|
+
--muted: ${PALETTE.muted};
|
|
229
|
+
--accent: ${PALETTE.accent};
|
|
230
|
+
--line: ${PALETTE.line};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
body {
|
|
234
|
+
font-family: ${bodyStack};
|
|
235
|
+
color: var(--ink);
|
|
236
|
+
font-size: 10.5pt;
|
|
237
|
+
line-height: 1.55;
|
|
238
|
+
margin: 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
h1 {
|
|
242
|
+
font-family: ${displayStack};
|
|
243
|
+
font-size: 30pt;
|
|
244
|
+
font-weight: 400;
|
|
245
|
+
line-height: 1.1;
|
|
246
|
+
margin: 0 0 4mm;
|
|
247
|
+
}
|
|
248
|
+
h2 {
|
|
249
|
+
font-size: 15pt;
|
|
250
|
+
font-weight: 600;
|
|
251
|
+
margin: 9mm 0 3mm;
|
|
252
|
+
/* A heading stranded at the foot of a page is the classic PDF tell. */
|
|
253
|
+
break-after: avoid;
|
|
254
|
+
}
|
|
255
|
+
h3 { font-size: 12pt; font-weight: 600; margin: 6mm 0 2mm; break-after: avoid; }
|
|
256
|
+
|
|
257
|
+
p { margin: 0 0 3.5mm; }
|
|
258
|
+
strong { font-weight: 600; }
|
|
259
|
+
em { font-style: italic; color: var(--muted); }
|
|
260
|
+
a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
|
|
261
|
+
code {
|
|
262
|
+
font-family: ui-monospace, monospace;
|
|
263
|
+
font-size: 9.5pt;
|
|
264
|
+
background: color-mix(in srgb, var(--line) 35%, white);
|
|
265
|
+
padding: 0 2px;
|
|
266
|
+
border-radius: 2px;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
hr { border: 0; border-block-start: 1px solid var(--line); margin: 7mm 0; }
|
|
270
|
+
|
|
271
|
+
ul, ol { margin: 0 0 4mm; padding-inline-start: 6mm; }
|
|
272
|
+
li { margin-block-end: 1.5mm; }
|
|
273
|
+
|
|
274
|
+
table {
|
|
275
|
+
inline-size: 100%;
|
|
276
|
+
border-collapse: collapse;
|
|
277
|
+
margin: 0 0 5mm;
|
|
278
|
+
font-size: 10pt;
|
|
279
|
+
/* Keep a table whole where it fits — a two-row orphan reads as an error. */
|
|
280
|
+
break-inside: avoid;
|
|
281
|
+
}
|
|
282
|
+
th, td {
|
|
283
|
+
text-align: start;
|
|
284
|
+
padding: 2mm 3mm 2mm 0;
|
|
285
|
+
border-block-end: 1px solid var(--line);
|
|
286
|
+
vertical-align: top;
|
|
287
|
+
}
|
|
288
|
+
th { font-weight: 600; border-block-end-color: var(--ink); }
|
|
289
|
+
|
|
290
|
+
blockquote p:last-child { margin-bottom: 0; }
|
|
291
|
+
blockquote {
|
|
292
|
+
margin: 0 0 4mm;
|
|
293
|
+
padding-inline-start: 4mm;
|
|
294
|
+
border-inline-start: 2px solid var(--accent);
|
|
295
|
+
color: var(--muted);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* The closing note, set quieter than the body. */
|
|
299
|
+
body > p:last-of-type em { font-size: 9pt; }
|
|
300
|
+
</style></head><body>
|
|
301
|
+
${toHtml(md)}
|
|
302
|
+
</body></html>`;
|
|
303
|
+
|
|
304
|
+
/*
|
|
305
|
+
* Both temp files carry the PID. Two concurrent runs otherwise write and then
|
|
306
|
+
* delete each other's files, and the failure reads as a corrupt document rather
|
|
307
|
+
* than a collision.
|
|
308
|
+
*/
|
|
309
|
+
const tmp = `.md-pdf.${process.pid}.html`;
|
|
310
|
+
writeFileSync(tmp, html);
|
|
311
|
+
|
|
312
|
+
/*
|
|
313
|
+
* puppeteer is a devDependency (it arrives with pa11y-ci), so a bare import
|
|
314
|
+
* resolves normally.
|
|
315
|
+
*
|
|
316
|
+
* This used to locate Chrome by searching $HOME/.npm/_npx for whatever
|
|
317
|
+
* puppeteer an `npx --yes pa11y-ci` had left behind, then write a runner script
|
|
318
|
+
* INSIDE that directory so a bare import would resolve — because NODE_PATH is a
|
|
319
|
+
* CommonJS mechanism that ESM ignores. It worked, took the first of however
|
|
320
|
+
* many cached copies existed, and broke whenever the cache was cleared.
|
|
321
|
+
* Declaring the dependency deleted all of it.
|
|
322
|
+
*/
|
|
323
|
+
mkdirSync(dirname(resolve(output)), { recursive: true });
|
|
324
|
+
|
|
325
|
+
const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
|
|
326
|
+
try {
|
|
327
|
+
const page = await browser.newPage();
|
|
328
|
+
await page.goto(`file://${resolve(tmp)}`, { waitUntil: 'networkidle0' });
|
|
329
|
+
await page.pdf({ path: resolve(output), format: 'A4', printBackground: true });
|
|
330
|
+
} finally {
|
|
331
|
+
await browser.close();
|
|
332
|
+
rmSync(tmp, { force: true });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
console.log(`${output}`);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⚠ PROJECT CONFIG for `npm run cards`. The template ships this as a STUB.
|
|
3
|
+
*
|
|
4
|
+
* Everything a designer chooses lives here. scripts/og-cards.mjs holds only the
|
|
5
|
+
* machinery — the crop-safe box, the contrast measurement, the composite order —
|
|
6
|
+
* and none of it renders until this file is filled in.
|
|
7
|
+
*
|
|
8
|
+
* That split is deliberate. A social card is the one image the whole internet
|
|
9
|
+
* sees before it sees the site, so it has to carry the project's identity and
|
|
10
|
+
* not the kit's. A generator that shipped with a palette and a type scale would
|
|
11
|
+
* give every site built from this kit the same card.
|
|
12
|
+
*
|
|
13
|
+
* Fill this in AFTER the design exists — you need the real ramp, the two faces
|
|
14
|
+
* in public/fonts/, and a wordmark. `npm run cards` refuses until then, with a
|
|
15
|
+
* message saying which of them is missing.
|
|
16
|
+
*
|
|
17
|
+
* Prerequisites for the generator itself: ImageMagick, rsvg-convert, and
|
|
18
|
+
* python3 with fontTools. See docs/runbook.md §1.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Colours, as hex. MIRROR src/styles/tokens.css — do not invent values here.
|
|
23
|
+
* ImageMagick cannot read CSS custom properties, which is the only reason these
|
|
24
|
+
* are duplicated at all. Two copies drift; check them when the ramp changes.
|
|
25
|
+
*
|
|
26
|
+
* ink the plain card's ground, for legal and utility pages
|
|
27
|
+
* veil the wash laid over a photograph so type has something to sit on.
|
|
28
|
+
* Usually the darkest ink in the ramp
|
|
29
|
+
* fg title and footer over a photograph. Usually the lightest
|
|
30
|
+
* accent eyebrow and footer on the PLAIN card only. A mid tone has nothing
|
|
31
|
+
* left to give once a photograph is underneath — see og-cards.mjs
|
|
32
|
+
*/
|
|
33
|
+
export const PALETTE = null;
|
|
34
|
+
// export const PALETTE = { ink: '#…', veil: '#…', fg: '#…', accent: '#…' };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The project's own two faces, as paths to the woff2 the SITE serves.
|
|
38
|
+
*
|
|
39
|
+
* Converted to TTF at run time rather than committed as a second copy: one
|
|
40
|
+
* source of truth, and a card that cannot drift from what the page renders.
|
|
41
|
+
* Keep in step with the @font-face block in src/styles/global.css.
|
|
42
|
+
*/
|
|
43
|
+
export const FONTS = null;
|
|
44
|
+
// export const FONTS = {
|
|
45
|
+
// title: 'public/fonts/display.woff2',
|
|
46
|
+
// eyebrow: 'public/fonts/display.woff2', // a second face here if there is one
|
|
47
|
+
// };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The logo, read from the component that already draws it — never committed as
|
|
51
|
+
* a second copy of the artwork. A shared link that unfurls with different
|
|
52
|
+
* branding reads as a different business, and two copies drift.
|
|
53
|
+
*
|
|
54
|
+
* source a file containing one <svg> with a viewBox and <path d="…">
|
|
55
|
+
* width drawn width in px. Roughly a quarter of the 1200px card reads at
|
|
56
|
+
* the size a timeline actually renders it
|
|
57
|
+
*
|
|
58
|
+
* Set to null for cards with no logo. If the project has no wordmark component,
|
|
59
|
+
* point `source` at a static SVG — but keep it generated from ONE source.
|
|
60
|
+
*/
|
|
61
|
+
export const WORDMARK = null;
|
|
62
|
+
// export const WORDMARK = { source: 'src/components/Wordmark.astro', width: 300 };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Type scale, in px. The title size is picked by line count, so a long headline
|
|
66
|
+
* shrinks rather than overflowing.
|
|
67
|
+
*/
|
|
68
|
+
export const TYPE = {
|
|
69
|
+
title: { 1: 94, 2: 82, 3: 72 },
|
|
70
|
+
eyebrow: 54,
|
|
71
|
+
footer: 23,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The footer line, on every card. Keep it short — it is set small, and it is
|
|
76
|
+
* the first thing a hard crop takes.
|
|
77
|
+
*
|
|
78
|
+
* Read it from src/data/business.ts rather than typing it twice, if you prefer;
|
|
79
|
+
* this file is plain JavaScript and can import.
|
|
80
|
+
*/
|
|
81
|
+
export const FOOTER = '';
|
|
82
|
+
// export const FOOTER = 'Town, State · example.com';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One row per page. Adding a page means adding a row and re-running — Seo.astro
|
|
86
|
+
* resolves the card from the canonical path, so there is nothing to wire.
|
|
87
|
+
*
|
|
88
|
+
* route the URL this card belongs to. '/' becomes og-home.jpg
|
|
89
|
+
* slug optional name override. Only the fallback needs one
|
|
90
|
+
* title the headline. NOT the <title> tag — that carries "| Business Name"
|
|
91
|
+
* for search, which is wasted space on a card that already shows the
|
|
92
|
+
* name. `\n` is a deliberate line break
|
|
93
|
+
* eyebrow small line in the second face above the title, or null
|
|
94
|
+
* photo manifest key for the background, or null for the plain card
|
|
95
|
+
*
|
|
96
|
+
* Legal and utility pages take the plain card: a photograph on a privacy policy
|
|
97
|
+
* is decoration pretending to be information.
|
|
98
|
+
*
|
|
99
|
+
* ── THE FALLBACK ROW IS NOT OPTIONAL ──────────────────────────────────────
|
|
100
|
+
* The last row, with `slug: 'default'` and no route, is the card every page
|
|
101
|
+
* without one of its own gets. Generate it; never hand-make it.
|
|
102
|
+
*
|
|
103
|
+
* A hand-made og-default.jpg goes stale silently. On one site the artwork
|
|
104
|
+
* predated a rebrand by two days and went on serving the old wordmark from a
|
|
105
|
+
* live site — the file existed, the manifest entry was valid, the build was
|
|
106
|
+
* green, and the only page using it was one nobody opens. Generating it means
|
|
107
|
+
* changing the type or the ink changes the fallback too.
|
|
108
|
+
*/
|
|
109
|
+
export const CARDS = [];
|
|
110
|
+
// export const CARDS = [
|
|
111
|
+
// { route: '/', title: 'A headline for\nthe home page.', eyebrow: 'Welcome', photo: 'photos/hero' },
|
|
112
|
+
// { route: '/contact/', title: 'Contact us', eyebrow: 'Say hello', photo: null },
|
|
113
|
+
// { slug: 'default', route: null, title: 'A one-line\ndescription.', eyebrow: null, photo: 'photos/hero' },
|
|
114
|
+
// ];
|