make-landing-page
v1.5.1
Published
Config-driven scaffolder for Cloudflare-Pages-ready static landing pages: semantic HTML5 skeleton with correct SEO/OG/Twitter/JSON-LD head, favicon set via make-app-icons, generated OG image, security-header _headers, robots.txt, sitemap.xml, and clearly-
Maintainers
Readme
make-landing-page
Config-driven scaffolder for the Cloudflare-Pages-ready static landing
pages you build for client or vanity domains that don’t need a real site
yet — just a correct, deployable placeholder while a domain moves off a
legacy host. Extracted from four hand-built placeholder sites that all
turned out to share one pattern: same head structure, same _headers,
same favicon set, same footer-from-config shape, different palette/type/copy
each time.
npm i -g make-landing-page make-app-icons
make-landing-page init --config ./acme.config.mjs --out ./acme-wwwHardened: 2026-11-07 · 1.5.1 (fb3601e gated) · release gate FIX FIRST (F1), fix round, re-check CLOSED WITH FOLLOW-UPS, R1-FU1 fixed (gates/2026-11-07-1.5.1-release-gate.json, gates/2026-11-07-1.5.1-recheck.json); README typography only since. Before: 2026-11-05 · 1.5.0 (20a7285 gated) · re-check 2 CLOSED WITH FOLLOW-UPS
1.5.1: csp.extra now refuses every directive name outside the 27
it accepts: CSP Level 3’s 23, plus fenced-frame-src (Fenced Frame),
require-trusted-types-for and trusted-types (Trusted Types) and
upgrade-insecure-requests (Upgrade Insecure Requests). 1.5.0 accepted
any lowercase name and wrote it into _headers, where a browser ignores
it. This tightens a patch release. A config that carried such a name
through 1.5.0 now fails init, with the nearest accepted name in the
message (a rough guess for the obsolete ones). Refused names include,
for example, the obsolete, removed or other-spec
block-all-mixed-content, prefetch-src, navigate-to,
plugin-types, require-sri-for, treat-as-public-address,
disown-opener, referrer and reflected-xss; report-sample, which
is a source keyword, not a directive; and typos or made-up names such as
scirpt-src, frame-ancestor, foo and -. Each of the 27 still
behaves as it did in 1.5.0, except that a directive given no sources
(sandbox: [], upgrade-insecure-requests: [] …) is now written as its
bare name, without the trailing space 1.5.0 left after it; browsers read
both the same. No name 1.5.0 refused is accepted now. Drop the directive
from csp.extra, or fix the spelling.
Requirements
- Node 20.9 or newer (what
sharprequires). make-app-iconson yourPATH(npm i -g make-app-icons) to generate the favicon/icon set. It’s a separate CLI that this tool shells out to, not a bundled dependency. If it isn’t found, scaffolding still completes — it prints a warning that names the install command, skips icon generation, and leaves the favicon<link>tags pointing at the right filenames.--skip-favicons, or omitting thefaviconblock from the config, skips icons on purpose. See Finding make-app-icons.
What it does, and doesn’t
This tool emits plumbing, not design. Given a config, init produces:
index.html— semantic HTML5, a correct<head>(title/meta/canonical/ OG/Twitter/JSON-LDOrganization, populated only from fields the config actually sets — nothing invented), favicon<link>tags, a GTM slot if enabled, the footer rendered from config, and the page content wrapped in<!-- DESIGN SLOT -->markers_headers— Cloudflare Pages security headers (CSP scoped to exactly what the config turns on) plus long-cache rules for static assetsrobots.txt,sitemap.xml— domain-correct, from the config’s canonical host.gitignore,README.md(with the Cloudflare-Pages connect steps),EFFORT.mdstub- the favicon/icon set, via
make-app-iconsrun against your favicon master img/og-image.png— a 1200×630 social-share card: the logo rasterized and centered on the palette background, viasharpgit init+ one commit of the files this run wrote — no remote is created, nothing is pushed; skipped (with a note) when the output folder is already inside a git repo. Paths git ignores are left out of that commit (add one withgit add -f <path>, then commit), and the commit is skipped when every path is ignored
What it deliberately does not do: pick a palette, a typeface pairing, a
layout, or write any copy that isn’t in the config. Everything inside a
<!-- DESIGN SLOT --> region — the CSS custom properties, the type
styling, the page’s actual content markup — is a working, neutral default
(centered logo, any intro lines under it, footer below) meant to be replaced by a human or an
agent doing the actual design pass. Outside those regions, the HTML is
fixed plumbing and shouldn’t need to change unless the plumbing is wrong.
Quick start
cp "$(npm root -g)/make-landing-page/templates/example.config.mjs" ./my-site.config.mjs
# edit my-site.config.mjs: domain, logo/favicon paths, palette, footer, ...
make-landing-page init --config my-site.config.mjs --out ../my-site-www(make-landing-page init --help prints the example config’s full path too.)
Refuses to touch a non-empty output directory unless you pass --force.
With --force, index.html, _headers, robots.txt, sitemap.xml,
EFFORT.md and the generated images and icons are overwritten; an existing
.gitignore is appended to, never replaced; an existing README.md that
make-landing-page didn’t write is kept; and anything else in the folder is
left alone and never committed.
Options
init --config <path> [options]
-c, --config <path> Site config module (required)
-o, --out <dir> Output directory (default: current directory)
--force Scaffold into a non-empty directory anyway
--skip-favicons Don't run make-app-icons
--skip-og-image Don't generate img/og-image.png
--skip-git Don't run git init / first commitConfig schema
A config is a .mjs module whose default export is one object. JS, not
JSON — every real config this tool was built from carries footnote-of-record
comments next to specific fields (a font kit’s domain restriction, a
contact address confirmed from exactly one source), and JSON has no
comments. See templates/example.config.mjs for a complete worked example
(an invented studio, Acme Studio); the field reference:
| Field | Required | Notes |
|---|---|---|
| site.name | yes | Used in <title>, JSON-LD name, footer default alt text |
| site.tagline | no | <title> becomes "{name} {separator} {tagline}" |
| site.titleSeparator | no | The {separator} above; defaults to "\|" — set an em dash or anything else (e.g. —) |
| site.description | yes | Meta/OG/Twitter description |
| site.domain | yes | Bare lowercase hostname, e.g. acme.example (a subdomain is fine): no scheme, path, port or www. prefix — site.canonicalHost adds www.. Validated |
| site.canonicalHost | no | "www" (default) or "apex" — which host canonical/OG/sitemap URLs use |
| site.themeColor | no | <meta name="theme-color">; defaults to palette.bg |
| site.locale | no | <html lang>; default "en" |
| contact.email | no | Used by the {{contact}} footer token |
| contact.mailtoSubject | no | Appended to the mailto link as ?subject= |
| intro.lines | no | Array of strings set directly under the logo, centred with it; same templating as footer.lines — see Intro lines |
| footer.lines | no | Array of strings; see Footer templating. With no footer lines and no social row, no <footer> is emitted |
| palette.bg/ink/accent/accentHover | no | CSS custom properties inside the DESIGN SLOT |
| fonts.mode | no | "typekit-link" | "self-hosted" | "system" (default) |
| fonts.family | no | CSS font-family value used in the DESIGN SLOT |
| fonts.typekit.kitId | if typekit-link | Adobe Fonts kit id |
| fonts.selfHosted.files | if self-hosted | [{ weight, style, family, appliesTo, formats: [{ src, filename, format }] }] — one @font-face rule per entry, every listed format in its src; family/appliesTo are optional — see Self-hosted fonts |
| gtm.enabled | no | false (default) emits nothing at all |
| gtm.containerId | no | null (default, with enabled: true) emits TODO placeholders; a real "GTM-XXXXXXX" emits the working snippet and widens the CSP (GTM’s domain, plus the inline bootstrap’s sha256 in script-src) |
| social.links | no | [{ network, url }], ordered — omit or leave empty to emit nothing; see Social icons |
| social.accentColor | if social.links is set | Any valid CSS color — hover/focus color for the icon row |
| social.iconStyle | no | "badge" (default) or "glyph" — see Social icons |
| social.badgeShape | no | "squircle" (default), "square", or "circle" — only meaningful in iconStyle: "badge" |
| social.badgeColor | no | Any valid CSS color — badge background; defaults to palette.ink |
| logo.src | yes | Absolute path — copied into img/<basename> |
| logo.alt/width/height | no | <img> attributes |
| favicon | no | Omit the whole block to scaffold without icon generation — see Favicon is optional |
| favicon.masterSrc | if favicon is set | Absolute path — passed to make-app-icons |
| favicon.extraArgs | no | string[] — passthrough flags appended verbatim to the make-app-icons invocation (e.g. ['--dark-fill', '#f0f0f0']) |
| ogImage.generate | no | true (default) |
| ogImage.background/logoSrc | no | Default to palette.bg / logo.src |
| ogImage.alt | no | og:image:alt and twitter:image:alt; defaults to "{site.name} logo" |
| ogImage.path | no | Passthrough for a pre-existing OG image — copied through under its own basename instead of generating one; mutually exclusive with an explicit generate: true — see OG image passthrough |
| meta.extra | no | [{ name, content }] or [{ property, content }] — arbitrary <meta> tags this schema doesn’t otherwise model (e.g. author and google-site-verification) — see Extra meta tags |
| robots.extraLines | no | string[] — raw lines appended after the default Allow: / and before the Sitemap: line — see robots.txt customization |
| gtm.ga4 | no | true allows Google Analytics 4 in the container, with the sources Google’s CSP guide lists; needs gtm.enabled — see CSP additions |
| cloudflare.webAnalytics | no | true allows the beacon Cloudflare injects when Web Analytics is on for the project — see CSP additions |
| csp.extra | no | { directive: [sources] } merged into the _headers CSP, for anything else the page needs — see CSP additions |
| jsonLd.type | no | "Organization" (default) or "LocalBusiness" |
| jsonLd.alternateName | no | The organization’s other name (a string or an array), emitted right after name |
| jsonLd.description | no | A JSON-LD-specific blurb — deliberately not auto-copied from site.description; see JSON-LD |
| jsonLd.sameAs | no | Array of URLs |
| jsonLd.founder | no | { name, alternateName, url, sameAs: [] } — a Person; alternateName/url are nested extras beyond name/sameAs (a founder can carry both) |
| jsonLd.parentOrganization | no | { name, url } — e.g. a brand site under a parent company |
| jsonLd.address | no | { streetAddress, addressLocality, addressRegion, postalCode, addressCountry } — a PostalAddress, for a LocalBusiness |
| jsonLd.hasMap | no | A Google Maps URL — only meaningful alongside jsonLd.address |
| jsonLd.extra | no | Object merged into the emitted JSON-LD verbatim, last — an escape hatch for a one-off schema.org field (e.g. hasCertification) not otherwise modeled |
| tools.makeAppIconsBin | no | Explicit path/command override — see Companion tools |
Footer templating
footer.lines is deliberately the thinnest templating that covers what the
sites this tool was extracted from actually needed — not a markdown engine, just two
substitutions:
[label](href)→ an anchor tag. Anhttp(s)href opens in a new tab (target="_blank" rel="noopener"); anything else (onlymailto:in practice) stays in the same tab withrel="noopener"alone.{{contact}}→ amailto:link built fromcontact.email/contact.mailtoSubject.
footer: {
lines: [
'Acme Studio is a product design practice run by [Jane Founder](https://founder.example/) — see the [portfolio](https://portfolio.example/jane).',
'To start a project, write to {{contact}}',
],
},Nothing else in a footer line is interpreted — the copy is the config
author’s exact words; this tool’s only job is turning the two link forms
every exemplar used into correct markup. Footer text is not HTML-escaped
for exactly this reason: real footers (a ©, -joined body
copy) rely on writing literal HTML entities by hand.
site.title/site.description/logo.alt — meant to be pure text, never
markup — are escaped; see HTML escaping below.
Intro lines
intro.lines uses the same templating as footer.lines, but each line is
set inside <main> directly under the logo, as <p class="intro">, so the
logo and its line read as one centred group (2.5rem apart) rather than the
line sitting at the foot of the page. It suits the one sentence a
placeholder page leads with; contact lines and the social row still belong
in the footer:
intro: {
lines: ['Acme Studio is the office of [Jane Founder](https://founder.example/).'],
}, keeps a name from breaking onto its own line on a phone. A page
with intro lines but no footer lines and no social row gets no <footer>
at all. To style the intro’s links with a self-hosted face, point that
font’s appliesTo at .intro a.
Self-hosted fonts
fonts.selfHosted.files groups by weight/style, not by file: one entry per
@font-face rule, and every format in that entry’s formats array lands in
that one rule’s src, comma-joined (woff2 first, woff fallback). This
matches how a typical self-hosted set is authored — three entries
(regular/italic/bold), each with a woff2+woff pair, i.e. three @font-face
rules from six files, not six rules from six files.
fonts: {
mode: 'self-hosted',
family: 'BrandSans, sans-serif',
selfHosted: {
files: [
{ weight: 400, style: 'normal', formats: [
{ src: '/path/brand-sans.woff2', filename: 'brand-sans.woff2', format: 'woff2' },
{ src: '/path/brand-sans.woff', filename: 'brand-sans.woff', format: 'woff' },
] },
{ weight: 700, style: 'normal', formats: [ /* ... */ ] },
],
},
},An entry’s @font-face gets declared either way, but nothing applies it
anywhere except the global html, body { font-family: fonts.family } rule
in the DESIGN SLOT — fine for a page’s one main typeface, but not every
self-hosted face is the main typeface. A page might self-host an
italic face used only on one link (.intro a), never on the page’s
default body copy. Two optional per-entry fields cover that:
family— this entry’s ownfont-familystack, separate from the globalfonts.family(defaults to it if omitted)appliesTo— a CSS selector, or an array of selectors sharing one rule, that gets a scoped{ font-family; font-weight; font-style }rule instead of (or alongside) the global one
fonts: {
mode: 'self-hosted',
family: 'system-ui, sans-serif', // the page's default typeface — untouched by the entry below
selfHosted: {
files: [
{
weight: 500,
style: 'italic',
family: 'Fancy, Georgia, serif',
appliesTo: '.intro a', // only this selector gets Fancy — not html/body
formats: [
{ src: '/path/fancy-italic.woff2', filename: 'fancy-italic.woff2', format: 'woff2' },
{ src: '/path/fancy-italic.woff', filename: 'fancy-italic.woff', format: 'woff' },
],
},
],
},
},An entry with no appliesTo behaves exactly as before — it only ever rides
along on the global fonts.family rule.
Social icons
social.links is an ordered list of { network, url }. Omit the whole
block, or leave links empty, and the feature emits nothing at all — no
empty <div>, no unused CSS. Once at least one link is set, social.accentColor
is required and must be a valid CSS color (hex, rgb()/hsl()/etc., or a
named color — checked against css-color-names).
social: {
accentColor: '#2f62c8',
links: [
{ network: 'instagram', url: 'https://instagram.com/yourhandle' },
{ network: 'linkedin', url: 'https://linkedin.com/company/yourco' },
],
},Emits an inline-SVG icon row in the footer — no icon font, no CDN, no JS.
Built-in mark geometry (simplified official marks, not certified-pixel
reproductions) covers instagram, facebook, x, linkedin, youtube,
tiktok, and threads (case-insensitive); any other network string still
renders — as a plain underlined text label reading that string, in either
style — so a typo or an unsupported network never silently vanishes from
the page.
social.iconStyle picks how each mark is rendered — "badge" (the
default) or "glyph":
badge— every network sits inside one uniform dark container (social.badgeShape:"squircle"(default),"square", or"circle"), its mark reversed out. Reach for this whenever the row mixes networks: Simple Icons marks (whatglyphmode uses, below) are not a consistent container on their own — LinkedIn and Facebook’s marks already bake in their own filled shape (a rounded square, a circle), while Instagram/Threads/X/YouTube/TikTok are bare glyphs with no background of their own — and even the bare ones are drawn in Simple Icons’ particular hand, visibly different from another library’s. Badge mode sidesteps both problems by drawing every built-in network from Font Awesome Free’sbrandsicon set instead (CC BY 4.0 — fontawesome.com/license/free): one mark family for the whole row, with LinkedIn’s own square swapped for its bare “in” glyph and Facebook’s own circle for its bare “f” rather than nesting one filled shape inside another. Badge background defaults topalette.ink(override withsocial.badgeColor); the reversed-out glyph ispalette.bg; hover/focus swaps the badge background toaccentColor.glyph— the original behavior: each network’s Simple Icons mark (CC0),fill: currentColor(so it always matches the surrounding text color), transitioning toaccentColoron hover/focus. This is where LinkedIn’s and Facebook’s marks being their own filled container is the point, not a problem — fine for a row of only bare-glyph networks (or a single network) where nothing needs normalizing.
social: {
accentColor: '#2f62c8',
iconStyle: 'badge', // default; 'glyph' for the original bare-mark look
badgeShape: 'squircle', // 'square' | 'circle' | 'squircle' (default)
links: [
{ network: 'instagram', url: 'https://instagram.com/yourhandle' },
{ network: 'linkedin', url: 'https://linkedin.com/company/yourco' },
],
},Every icon carries an aria-label naming the network regardless of style.
Favicon is optional
Omit the favicon block entirely when there’s no master image chosen yet —
validation doesn’t force a placeholder path just to pass. index.html still
gets the standard favicon <link> tags (the filenames are fixed regardless
of who generates the files), so the page isn’t broken; the site’s generated
README explains which of three reasons icons weren’t generated (favicon
never configured, --skip-favicons passed, or make-app-icons couldn’t be
found) and how to fix each.
OG image passthrough
ogImage.generate: true (the default) rasterizes the logo onto the
palette background — fine when there’s no OG image yet. When one already
exists and is already correct, set
ogImage.path instead:
ogImage: {
path: '/path/to/job-folder/acme-og.png',
},The file is copied through byte-for-byte under its own basename (not
renamed to og-image.png), and index.html’s og:image/twitter:image
meta point at that filename. og:image:width/og:image:height reflect the
image’s actual dimensions (read with sharp), not a hardcoded 1200×630 —
if it isn’t exactly 1200×630, scaffolding still succeeds but prints a
warning, since a slightly-off pre-existing image is still usable, just
worth knowing about. ogImage.path and an explicit ogImage.generate: true
can’t both be set — pick one.
The image’s alt text (og:image:alt and twitter:image:alt) defaults to
"{site.name} logo", which describes a generated card. A passthrough image
is often more than the logo, so set ogImage.alt to describe it.
og:site_name is always site.name.
Extra meta tags
meta.extra is an escape hatch for <meta> tags this schema doesn’t
otherwise model — for example author and google-site-verification
tags, neither of which fit anywhere else here:
meta: {
extra: [
{ name: 'author', content: 'Jane Founder' },
{ name: 'google-site-verification', content: 'EXAMPLE-VERIFICATION-TOKEN' },
// { property: 'og:custom-field', content: '...' }, // property instead of name works too
],
},Each entry needs content and exactly one of name/property — same as a
real <meta> tag. content is escaped like site.description (plain
text, not markup).
robots.txt customization
The default robots.txt (Allow: / plus a Sitemap: line) covers the
sites this tool was extracted from, but a domain can carry legacy baggage
this tool never anticipated — a leftover /cgi-bin/ directory, say. robots.extraLines appends raw lines after
Allow: / and before the Sitemap: line, without touching the default:
robots: {
extraLines: ['Disallow: /cgi-bin/'],
},CSP additions
The CSP allows only what the config says is on the page, and two common
things never appear in index.html, so the CSP blocks them without a word
until they’re switched on:
- Google Analytics 4 in the GTM container. A real
gtm.containerIdallows the bootstrap and GTM’s own domain, and nothing more. The tags inside the container are invisible to this tool.gtm.ga4: trueadds the sources Google’s CSP guide lists for Analytics with its advertising features (the set it recommends for initial setup):*.google-analytics.com,*.google.comand*.g.doubleclick.netinimg-srcandconnect-src, pluspagead2.googlesyndication.cominconnect-src. It needsgtm.enabledand adds nothing untilgtm.containerIdis set. - Cloudflare Web Analytics. Turned on in the Cloudflare dashboard,
Cloudflare injects its beacon into every page at the edge.
cloudflare.webAnalytics: trueallows its script (static.cloudflareinsights.com, inscript-src) and its reports (cloudflareinsights.com, inconnect-src).
gtm: { enabled: true, containerId: 'GTM-XXXXXXX', ga4: true },
cloudflare: { webAnalytics: true },Anything else goes in csp.extra, which adds sources per directive after
the computed ones; duplicates are dropped, and a directive the tool doesn’t
list goes at the end. A directive name must be one
CSP Level 3 defines, or fenced-frame-src,
require-trusted-types-for, trusted-types or upgrade-insecure-requests
(each from its own spec); any other name, a typo like scirpt-src
included, is refused with the nearest known one. A fetch directive the computed policy doesn’t have
(script-src-elem, worker-src, manifest-src …) would replace the one
it falls back to under CSP3, so it starts from that fallback’s sources:
script-src-elem and -attr from script-src, style-src-elem and
-attr from style-src, worker-src from child-src or else
script-src, frame-src from child-src, the rest from default-src.
Adding one never takes away 'self' or the GTM hash. A directive with no
fallback (trusted-types, sandbox …) starts empty. 'none' alone is
taken as written (object-src: ["'none'"] gives object-src 'none'):
browsers ignore it beside any other source, so it is refused next to
other sources and for the directives the tool fills ('self' in the base
policy; frame-src once a GTM container is set). An empty list for a
*-src directive is refused, and keywords, nonces and hashes need their
single quotes ('unsafe-eval'; a bare unsafe-eval or a double-quoted
one is refused). The common case is a Custom JavaScript variable in
the container, which evaluates to undefined unless script-src
includes 'unsafe-eval':
csp: { extra: { 'script-src': ["'unsafe-eval'"] } },Read the container before deploying:
https://www.googletagmanager.com/gtm.js?id=GTM-… is public. A G- id in
it means GA4, and "function":"__jsm" means a Custom JavaScript variable.
After the first deploy, check the property’s real-time report, and in the
browser console run typeof __cfBeacon ("object" means Cloudflare’s
beacon ran).
JSON-LD (Organization / LocalBusiness)
jsonLd.type defaults to "Organization" (a founder, a parent org). Set it to
"LocalBusiness" for a site with a physical location — a LocalBusiness
carries a PostalAddress and a Google Maps hasMap link, plus, say, one
field this schema doesn’t model at all (hasCertification, an ISO 9001
certification block) folded in through jsonLd.extra, which is merged into
the output verbatim, last. Note that jsonLd.description is a distinct
field from site.description, not an alias for it — a JSON-LD description
is often a shorter blurb than the meta description, so this tool never
assumes they’re the same text.
jsonLd.alternateName is the organization’s own other name (“Acme” for
“Acme Studio”), placed right after name; it used to need jsonLd.extra,
which lands it at the end of the object.
jsonLd.founder is a Person and can go beyond the original name/sameAs
shape with two more nested fields — alternateName (a string, or an array
of them) and url — alternateName lists other names the founder
goes by and url points at their own site, distinct from sameAs
(social-profile links):
jsonLd: {
founder: {
name: 'Jane Founder',
alternateName: ['Janie Founder', 'Jane Q Founder'],
url: 'https://founder.example',
// sameAs: ['https://linkedin.com/in/...'],
},
},HTML escaping
site.title (and the tagline that composes it), site.description, and
logo.alt are HTML-escaped before being placed in <title>/content=/alt=
— they’re meant to be plain text, and an unescaped " or & in real prose
(“Widgets & Gadgets”, a title with a quoted word) would otherwise break the
surrounding attribute. footer.lines and intro.lines are the deliberate exception — see
Footer templating above.
Companion tools
- make-app-icons (MIT) —
generates the actual favicon/PWA icon set from
favicon.masterSrc. Install it withnpm i -g make-app-icons. This tool shells out to it rather than reimplementing icon generation; see Finding make-app-icons for how it’s located. If it can’t be found, scaffolding continues with a warning that names the install command —index.html’s favicon<link>tags still point at the right filenames, you just generate the files later. - lithocinch — a batch
image optimizer. Not a dependency of this tool; if a scaffolded site’s
logo, OG image, or self-hosted font files are heavier than they need to
be, run lithocinch over
img//fonts/afterward. (make-app-iconsitself already picks up lithocinch onPATHfor its own--optimizeflag, independent of this tool.) - Simple Icons (CC0 1.0) — source of the
brand mark path data
lib/social-icons.mjsuses insocial.iconStyle: ‘glyph’mode. - Font Awesome Free (icons: CC BY 4.0,
fontawesome.com/license/free) — source of the brand mark path data
lib/social-icons.mjsuses insocial.iconStyle: 'badge'mode (thebrandsset: instagram, facebook-f, x-twitter, linkedin-in, youtube, tiktok, threads).
Finding make-app-icons
Resolution order (first hit wins), so this works whether make-app-icons is installed globally or lives somewhere custom:
MAKE_APP_ICONS_BINenvironment variable — a path to itsindex.js, or an executable onPATHtools.makeAppIconsBinin the site config — same, from config instead of the environmentmake-app-iconsresolved onPATH(npm i -g make-app-icons, or annpm link)
If none of these resolves, the run prints a warning naming
npm i -g make-app-icons, skips icon generation, and carries on. After
installing it, re-run with --out <scratch folder> --force and copy the icon
files from there; --force on the site folder itself regenerates
index.html, so do that only before design work starts.
Testing this tool
For contributors working from a source checkout (the test suite isn’t part of the npm package):
npm testGolden-file style (node:test, zero extra dependencies beyond sharp):
a fixture config + fixture logo/favicon SVGs get scaffolded into a temp
directory, and the emitted tree/content is asserted against what the
placeholder-site pattern actually produces — head structure, footer
HTML, JSON-LD shape, _headers CSP variants (typekit / self-hosted / GTM
on), robots.txt/sitemap.xml, the non-empty-directory refusal, and the
git-commit path.
The suite doesn’t depend on the real make-app-icons repo being present:
test/fixtures/stub-make-app-icons.mjs is a minimal stand-in that
implements just enough of its real interface (<master> --out <dir> →
the modern six-file set + manifest.webmanifest + head-snippet.html) to
exercise lib/favicons.mjs’s real spawn/read/delete integration path.
It’s also the seam to extend if make-app-icons’ interface ever changes —
that stub and lib/favicons.mjs’s two call sites are the only places that
need to move together.
Design slots, in practice
After scaffolding, open index.html and look for the two
<!-- DESIGN SLOT --> regions: one in the <style> block (palette custom
properties, type styling, link states), one wrapping <main> (the actual
page content: a centered logo and any intro.lines). Everything else — the
<head> meta block, the CSS reset, the flex page shell, the footer, the
favicon links — is generated plumbing; leave it alone unless something
about the plumbing is wrong, and do the actual design work inside the
slots.
Known limits
- Give file paths in the config as absolute paths.
logo.src,favicon.masterSrc, fontsrcs andogImage.pathresolve against the current directory, not the config file, and their existence isn’t checked up front: a missing file fails mid-run with a rawENOENT. - A failed run can leave files behind (
img/,fonts/, or an empty output folder), so the corrected retry needs--force. - The config author is trusted.
footer.linesandintro.linesare raw HTML by design, and JSON-LD values aren’t escaped for</script>; don’t feed it untrusted text. - Config validation is shallow. Some wrong shapes (
meta.extraorsocial.linksas an object, a font format with nosrc, an unparsablepalettecolor, a misspelled top-level key) give a raw error or are silently ignored instead of anInvalid configline. make-app-iconsis a separate install, found throughMAKE_APP_ICONS_BIN,tools.makeAppIconsBinorPATH. On Windows thePATHlookup may miss npm’s.cmdshim (untested); pointMAKE_APP_ICONS_BINat itsindex.jsinstead.- The first commit needs a git identity. If git can’t work one out,
the commit fails after every file has been written; commit by hand or
pass
--skip-git. --forceregenerates the plumbing files,index.htmlincluded. Re-run it before design work starts, or make the change by hand afterwards.- Long caching on unhashed names.
_headersmarks/img/*,/*.svg,/*.png,/*.ico(and the manifest and/fonts/*when present)immutablefor a year, though the file names carry no content hash: when you replace the logo or a favicon, rename it or shorten that rule, or returning visitors keep the old one. - CSP and third-party scripts. The GTM bootstrap is allowed by its
sha256, so editing that<script>means updating the hash. The tags inside the container need their own sources (gtm.ga4, orcsp.extra; see CSP additions), and without them their data is blocked.gtm.ga4coversgoogle.combut not Google’s country domains (google.co.ukand the rest), which Google’s guide lists one by one for its advertising features. Tags that inject their own inline scripts (Custom HTML) need a nonce or'unsafe-inline', which this doesn’t emit. An Adobe Fonts kit’s CSS may@importp.typekit.net, whichstyle-srcdoesn’t allow: a console CSP error, though the fonts still load (unverified). - The generated site docs don’t follow every flag. With
--skip-og-imagethe site README still describesimg/og-image.png(andog:imagepoints at it); theEFFORT.mdrow always lists favicons, OG image and git; “What’s here” always saysOrganizationJSON-LD and lists the icon set. - File names with spaces aren’t percent-encoded in URLs (
og:image, the JSON-LD logo); use names without spaces. - CLI rough edges. Warnings go to stdout, not stderr; bare
make-landing-page --helpexits 1 (init --helpexits 0); there’s no--version;--outor--configwith no value isn’t caught as a usage error; and the closing(favicons skipped — see warning above)also appears when the config simply has nofaviconblock. - ~~
csp.extradirective names are checked for shape only (F4, 2026-11-05): a typo (scirpt-src) is emitted, ignored by browsers, and listed in the site README as if applied.~~ Closed in 4e42113: names are checked against the CSP3 list, and an unknown one is refused with the nearest known name. - ~~
localDateis only tested off UTC (F7, 2026-11-05): its test doesn’t bite on a UTC runner, and the sitemaplastmodandEFFORT.mddate call sites aren’t tested.~~ Closed in 39701fb: a CLI run under a time zone on another date than UTC must write that date to both. - ~~The site README’s GA4 source list is hand-copied (F8, 2026-11-05)
from
GA4_SOURCESand can drift from it unnoticed.~~ Closed in 200480d: the list is built fromGA4_SOURCES. 'none'stays whencsp.extraadds to it (F9, 2026-11-05):frame-ancestors 'none' https://partner.example, withX-Frame-Options: DENYstill sent.csp.extrasources aren’t limited to printable ASCII (F10, 2026-11-05): control characters and a non-ASCII host are written raw; give a host in punycode.- An uppercase directive name’s error doesn’t say the rule (F11,
2026-11-05):
"Script-Src"is refused without saying to write it lowercase. - With
gtm.ga4and no container yet, the site README’s by-hand GTM steps don’t list GA4’simg-src/connect-srcsources (F12, 2026-11-05). - GTM’s base
connect-srclackshttps://www.google.com(F13, 2026-11-05), which Google’s guide lists for the container itself; setgtm.ga4or add it incsp.extra. - A non-object
cloudflareorcspblock is silently ignored (F14, 2026-11-05):cloudflare: truescaffolds with no error and no CSP change. - The site README mentions
csp.extraonly in its GTM section (F15, 2026-11-05), worded as the container’s tags; with no container it isn’t mentioned at all. sitemap.xmlandEFFORT.mdread the clock separately (F16, 2026-11-05), so a run across local midnight can date them differently.- A seeded directive can copy a dead
'none'(re-check 2 R2-2, 2026-11-05):child-src: ["'none'"]withworker-srcsources givesworker-src 'none' https://…; the meaning is right, the'none'is inert text. frame-ancestorskeeps its'none'whencsp.extraadds a source (R2-3, 2026-11-05; the F9 case): the browser does what was asked, but the line shows the ignored construct.['none', 'none']is refused as “beside other sources” (R2-4, 2026-11-05), though the only other source is a second'none'.
