npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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-

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-www

Hardened: 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 sharp requires).
  • make-app-icons on your PATH (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 the favicon block 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-LD Organization, 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 assets
  • robots.txt, sitemap.xml — domain-correct, from the config’s canonical host
  • .gitignore, README.md (with the Cloudflare-Pages connect steps), EFFORT.md stub
  • the favicon/icon set, via make-app-icons run against your favicon master
  • img/og-image.png — a 1200×630 social-share card: the logo rasterized and centered on the palette background, via sharp
  • git 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 with git 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 commit

Config 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. An http(s) href opens in a new tab (target="_blank" rel="noopener"); anything else (only mailto: in practice) stays in the same tab with rel="noopener" alone.
  • {{contact}} → a mailto: link built from contact.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 &copy;, &nbsp;-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&nbsp;[Jane&nbsp;Founder](https://founder.example/).'],
},

&nbsp; 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 own font-family stack, separate from the global fonts.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 (what glyph mode 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’s brands icon 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 to palette.ink (override with social.badgeColor); the reversed-out glyph is palette.bg; hover/focus swaps the badge background to accentColor.
  • glyph — the original behavior: each network’s Simple Icons mark (CC0), fill: currentColor (so it always matches the surrounding text color), transitioning to accentColor on 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.containerId allows the bootstrap and GTM’s own domain, and nothing more. The tags inside the container are invisible to this tool. gtm.ga4: true adds the sources Google’s CSP guide lists for Analytics with its advertising features (the set it recommends for initial setup): *.google-analytics.com, *.google.com and *.g.doubleclick.net in img-src and connect-src, plus pagead2.googlesyndication.com in connect-src. It needs gtm.enabled and adds nothing until gtm.containerId is set.
  • Cloudflare Web Analytics. Turned on in the Cloudflare dashboard, Cloudflare injects its beacon into every page at the edge. cloudflare.webAnalytics: true allows its script (static.cloudflareinsights.com, in script-src) and its reports (cloudflareinsights.com, in connect-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 with npm 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-icons itself already picks up lithocinch on PATH for its own --optimize flag, independent of this tool.)
  • Simple Icons (CC0 1.0) — source of the brand mark path data lib/social-icons.mjs uses in social.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.mjs uses in social.iconStyle: 'badge' mode (the brands set: 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:

  1. MAKE_APP_ICONS_BIN environment variable — a path to its index.js, or an executable on PATH
  2. tools.makeAppIconsBin in the site config — same, from config instead of the environment
  3. make-app-icons resolved on PATH (npm i -g make-app-icons, or an npm 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 test

Golden-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, font srcs and ogImage.path resolve against the current directory, not the config file, and their existence isn’t checked up front: a missing file fails mid-run with a raw ENOENT.
  • 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.lines and intro.lines are 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.extra or social.links as an object, a font format with no src, an unparsable palette color, a misspelled top-level key) give a raw error or are silently ignored instead of an Invalid config line.
  • make-app-icons is a separate install, found through MAKE_APP_ICONS_BIN, tools.makeAppIconsBin or PATH. On Windows the PATH lookup may miss npm’s .cmd shim (untested); point MAKE_APP_ICONS_BIN at its index.js instead.
  • 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.
  • --force regenerates the plumbing files, index.html included. Re-run it before design work starts, or make the change by hand afterwards.
  • Long caching on unhashed names. _headers marks /img/*, /*.svg, /*.png, /*.ico (and the manifest and /fonts/* when present) immutable for 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, or csp.extra; see CSP additions), and without them their data is blocked. gtm.ga4 covers google.com but not Google’s country domains (google.co.uk and 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 @import p.typekit.net, which style-src doesn’t allow: a console CSP error, though the fonts still load (unverified).
  • The generated site docs don’t follow every flag. With --skip-og-image the site README still describes img/og-image.png (and og:image points at it); the EFFORT.md row always lists favicons, OG image and git; “What’s here” always says Organization JSON-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 --help exits 1 (init --help exits 0); there’s no --version; --out or --config with no value isn’t caught as a usage error; and the closing (favicons skipped — see warning above) also appears when the config simply has no favicon block.
  • ~~csp.extra directive 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.
  • ~~localDate is only tested off UTC (F7, 2026-11-05): its test doesn’t bite on a UTC runner, and the sitemap lastmod and EFFORT.md date 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_SOURCES and can drift from it unnoticed.~~ Closed in 200480d: the list is built from GA4_SOURCES.
  • 'none' stays when csp.extra adds to it (F9, 2026-11-05): frame-ancestors 'none' https://partner.example, with X-Frame-Options: DENY still sent.
  • csp.extra sources 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.ga4 and no container yet, the site README’s by-hand GTM steps don’t list GA4’s img-src/connect-src sources (F12, 2026-11-05).
  • GTM’s base connect-src lacks https://www.google.com (F13, 2026-11-05), which Google’s guide lists for the container itself; set gtm.ga4 or add it in csp.extra.
  • A non-object cloudflare or csp block is silently ignored (F14, 2026-11-05): cloudflare: true scaffolds with no error and no CSP change.
  • The site README mentions csp.extra only in its GTM section (F15, 2026-11-05), worded as the container’s tags; with no container it isn’t mentioned at all.
  • sitemap.xml and EFFORT.md read 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'"] with worker-src sources gives worker-src 'none' https://…; the meaning is right, the 'none' is inert text.
  • frame-ancestors keeps its 'none' when csp.extra adds 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'.