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

skrapa

v0.6.1

Published

Build static sites with TypeScript JSX templates and TypeScript client-side code. No framework, no virtual DOM, no config. A single zero-dependency script, run with npx.

Readme

Skrapa

npm version

Build static sites with TypeScript JSX templates and TypeScript client-side code. No framework, no virtual DOM, no config. A single zero-dependency script, run with npx, does all of it and never becomes a dependency of your project.

Requires Node.js v24+. Runs on macOS and Linux; the CLI shells out to Unix tools like cp, rm, and tsc.

Website · GitHub · npm

How it works

JSX in src/ renders to raw HTML strings at build time. Every src/**/index.tsx that exports Page becomes its own page, client.ts is compiled to a standalone .js and linked via <script src>, and assets are copied as-is. CSS is yours to manage, so drop it in assets/ and link it from index.html.

src/index.html        →  shared HTML shell (head + body)
src/index.tsx         →  Page()  →  dist/index.html
src/about/index.tsx   →  Page()  →  dist/about/index.html
src/about/index.html  →  optional per-page shell, overrides the shared one for /about
src/client.ts         →  compiled to its own .js, linked via <script src>
assets/               →  copied as-is to dist/ (CSS, images, fonts)

A Page() returns Skrapa.Page, which is JSX (or an object whose body is JSX), not a string. Strings that land in a child position are HTML-escaped, so <p>{text}</p> renders text as visible text even when it contains markup, and interpolating content from an API, a CMS, or a form is safe by default. When you really do have markup sitting in a string, pass it through the global raw():

export function Page(): Skrapa.Page {
    return (
        <ul>
            <li>{'<b>not bold</b>'}</li>
            {raw(rows.map((r) => `<li>${r}</li>`).join(''))}
        </ul>
    );
}

Nothing inside raw() is checked, so never build one from untrusted input.

Each page uses src/index.html as its HTML shell. Drop an index.html into a page's own directory to override it for that page alone. That's handy when one page needs a different <head>, meta tags, or favicon. Whichever shell is used, Skrapa still injects the <base href> and the page's title, head, body, and shellAttrs (attributes set on the shell's <html> and <body> tags). A shellAttrs value is normally a string, which overwrites whatever the shell had; pass a function like (prev) => `${prev} about` to build on the shell's existing value instead of replacing it.

How src and href resolve

A <script src="....ts"> or <link rel="stylesheet" href="....css"> can go in a shell or in a page's own JSX. Either way the path is resolved at build time, the file is copied or bundled into dist/, and the tag's path is rewritten to the result:

  • Relative (./client.ts, ./style.css) resolves against the directory of the file it was written in: the shell's directory for a shell, the page's own directory for page JSX. It never reaches assets/.
  • Root-relative (/style.css) resolves from assets/ first, then from the input root. /client.ts likewise resolves from the input root.

That asymmetry is the one worth remembering: assets/ is copied to the output root, so only a root-relative path can name something in it. A stylesheet living in assets/style.css is /style.css, never ./style.css, no matter which file references it. Get it wrong and the build says so, and names the file it found in assets/.

  • Another origin (https://cdn/lib.css, //cdn/lib.css) is left exactly as written, since the browser fetches it directly.

That's why the scaffolded about page links both. /style.css picks up the global sheet in assets/, while ./style.css picks up src/about/style.css sitting next to that page's shell.

assets/ is copied over dist/ after every page is rendered, so a file in it can land on a path the build just wrote: assets/about/index.html would replace the page built from src/about/, and nothing about the output would look wrong. The build refuses instead, naming the two sources and the output path they meet at, and exits non-zero. Paths are compared case-insensitively, so assets/About/ is caught on Linux as well as on macOS. In dev mode the copy is skipped and logged rather than failing, so the server you are watching stays up.

Output paths are lowercase

Every path built from src/ has to be lowercase. src/About/ produces dist/About/index.html, which a case-insensitive host serves and a case-sensitive one 404s, so the same URL works on your laptop and breaks once deployed. The build names the file and the lowercase path it should have had, and stops.

It does not rename anything for you. Skrapa rewrites the src/href of the tags it resolves, but a page directory is reached by hand-written <a href> that skrapa never parses, so renaming on the way out would just move the breakage somewhere it cannot see. The rename belongs in the source tree, next to the links. skrapa page already slugifies names to lowercase, so scaffolded pages satisfy this by construction.

assets/ is not checked. Those files are copied verbatim and their names are yours: CNAME has to keep its case for GitHub Pages to read it, and there is no knowing what else a host or a third-party script expects to find spelled exactly one way. The collision check above still compares case-insensitively, so an asset landing on a generated path is caught whatever either one is called.

Quick Start

npx skrapa

Scaffolds a new project, installs TypeScript (the only dependency it adds), and starts the dev server. Then when you're ready:

npx skrapa build

Builds every index.tsx to a page under dist/, with markup rendered, client JS/CSS bundled and linked, and assets copied alongside. Ready to deploy.


Creates:

src/index.html                # shared HTML shell (head + body)
src/index.tsx                 # home page, Page()
src/about/index.html          # per-page shell that overrides the shared one for /about
src/about/index.tsx           # /about page (nested dir = nested route)
src/about/client.ts           # browser JS scoped to /about
src/about/about.ts            # helper module imported by /about/client.ts
src/about/style.css           # per-page styles, linked from the about shell
src/components/button.tsx     # example component (imported, never routed)
src/client.ts                 # browser JS entry point
assets/style.css              # global styles (linked from index.html)
assets/skrapa.svg             # logo
assets/github.svg             # icon
skrapa.config.ts              # project config
tsconfig.json                 # TypeScript config (wires the jsx / Fragment runtime)
skrapa.d.ts                   # global types, managed by skrapa
README.md                     # starter readme for your project
.github/workflows/deploy.yml  # GitHub Pages deploy, gated on the commit message

Commands

npx skrapa                     # scaffold a new project (only in a dir with no skrapa.config.ts)
npx skrapa dev                 # dev server with live reload
npx skrapa build               # production build
npx skrapa page "<name>" [parent]  # scaffold a new page (index.tsx + shell + css + client)
npx skrapa fix                 # migrate a project written against an older skrapa

Per-command flags:

| Command | Flags / args | | ------- | ------------ | | init (default) | -f / --force (overwrite existing files), --no-dev (skip the dev server) | | dev | --port, --host, --origin, -v / --verbose, plus the build flags | | build | --input, --output, --assets, --base, --root | | page | <name> and [parent] positionals, plus --input / --root | | fix | --root (the tree to migrate), --output (the dir to leave alone) |

Every command accepts the config flags (--input, --output, --assets, --port, --host, --origin, --base, --root), which override skrapa.config.ts.

If your package.json has a postbuild-skrapa script, dev runs it after each rebuild. Use it for anything that has to happen on top of the build, like generating a sitemap.

Upgrading an older project

npx skrapa fix walks the project and applies the migrations a pre-0.5 project needs:

  • skrapa.config.json is rewritten as skrapa.config.ts (export default { ... } satisfies Skrapa.Config) and the JSON file removed. A key skrapa does not recognize is carried over and reported rather than dropped.
  • The global type names skrapa used to declare become their namespaced equivalents: Page to Skrapa.Page, Props to Skrapa.Props, PropsWithChildren to Skrapa.PropsWithChildren, Tag to Skrapa.Tag, CSSProperties to Skrapa.CSSProps. Only type positions are touched, so export function Page(): Page keeps its function name, and a type your own code declares under one of those names is left alone.

It changes nothing when there is nothing to change, so it is safe to run twice. Every file it touches is listed as it goes; review the result with git diff.

Configuration

skrapa.config.ts in the project root. Every field is optional:

export default {
    input: 'src',
    output: 'dist',
    assets: 'assets',
    port: 8080,
} satisfies Skrapa.Config;

Skrapa.Config is a global from the managed skrapa.d.ts, so there is nothing to import. The satisfies gets you completion and typo-checking on every field while keeping the literal types. Node runs the file directly (it strips the types itself on v24+), so the config is real code: read an env var, branch on NODE_ENV, compute a value.

| Field | Default | Description | | -------- | -------- | ----------------------------------------------------------------------------------------------------------- | | input | src | Directory containing index.html, index.tsx, client.ts | | output | dist | Build output directory | | assets | assets | Static files copied as-is to output; skipped if not present | | port | 8080 | Dev server port | | host | localhost | Interface the dev server binds to. Use 0.0.0.0 to reach it from other devices on the network | | origin | "" | Public URL when a proxy or tunnel fronts the dev server, e.g. https://dev.localhost. See Behind a proxy | | base | / | Base URL the site is served from; injected as <base href>. Set to /repo/ for GitHub Pages project sites | | root | cwd | Directory input, output and assets resolve against |

root is really a CLI flag. Skrapa looks for skrapa.config.ts in the directory --root names (the working directory by default), so setting root inside that file moves where input, output and assets resolve, but not where the file itself was found.

CLI flags override config file values:

npx skrapa dev --port 3000
npx skrapa build --input app --output public

Behind a proxy

If a reverse proxy or tunnel maps a nicer URL onto the dev server, set origin to that URL. The server still binds to host:port, but the URL logged at startup is the one you actually open:

npx skrapa dev --origin https://dev.localhost
export default {
    port: 8080,
    origin: 'https://dev.localhost',
} satisfies Skrapa.Config;

The port is optional inside origin, so a proxy listening on 443 gives you a URL with no port on it. Include the scheme; a bare hostname is treated as http://.

Live reload needs no extra configuration. The injected client derives its WebSocket URL from the page's own location, so loading https://dev.localhost opens wss://dev.localhost/hmr on its own. Your proxy does have to forward WebSocket upgrade requests on /hmr through to the dev server, otherwise the page loads fine but never reloads.

About

All too often I wanted to spin up a simple static site and found the usual stack of Vite plus React plus TypeScript plus a pile of config to be total overkill. I didn't need a virtual DOM, client-side routing, or a hydration step. I just wanted to write some markup, get a few interactive bits, and ship plain HTML.

Skrapa is the result. It keeps the one thing I actually missed, writing layout as JSX in TypeScript, and throws out the rest. Pages compile to static HTML at build time with their client JS bundled into standalone files, so there's no framework and no runtime in the browser. A dev server with live reload keeps the feedback loop tight while you work.

I built it for myself and still use it daily: my personal site, throwaway prototypes, quick dashboards, and one-off reports. If you've ever wanted a static page without booting up an entire toolchain to get there, it might suit you too.

License

MIT © iambriansreed