Skrapa Documentation

One command scaffolds a working project. This page walks through what init drops on disk, then how those files fit together to produce static HTML.

Initialize a project

Run init to lay down a starter site and boot the dev server:

npx skrapa init

In a terminal it asks one question first: Is this a new project?

Either way, typescript is installed as a dev dependency only when the project does not already depend on it, and the dev server starts from the skrapa root (pass --no-dev to skip it). Existing files are left in place unless you pass -f / --force to overwrite them with the scaffolded versions. An existing tsconfig.json is kept with a warning, since pages need its JSX settings, and given a skrapa key if it has none.

Bare npx skrapa runs init too, but only in a directory with no tsconfig.json, no package.json, and no skrapa.config.ts or skrapa.config.json from an older version. Anywhere else it says why, and what to run instead, and exits rather than scaffold over an existing project. To scaffold anyway, run npx skrapa init by name.

Commands

Five commands, each also runnable through an npm script. Every command but init runs in the skrapa root, the directory holding tsconfig.json (which is why a new project's scripts cd src first), and takes the config flags (--output, --port, --projectRoot, and the rest from Configuration), which override the skrapa key in that file. A flag with nothing after it stops the command rather than being dropped. --root and --input are gone, and --ignore is --linkCheckIgnore now: pass an old flag and the command stops and says what replaced it.

CommandWhat it doesParams
npx skrapa initScaffold a site, as a new project or added to an existing one, then start the dev server. Bare npx skrapa does this too, but only in a directory with no tsconfig.json, no package.json and no old skrapa.config.ts.--new, --projectRoot <dir>, -f / --force, --no-dev
npx skrapa devServe the build over HTTP with live reload on every change.--port, --host, --origin, -v / --verbose, plus the build flags
npx skrapa buildRender the site to static HTML in the output dir.--output, --assets, --base, --linkCheckIgnore, --projectRoot, --source
npx skrapa page "<name>" [parent]Scaffold a new page dir in the skrapa root: index.tsx + index.html + style.css + client.ts.name (positional), parent (positional), plus the config flags
npx skrapa fixMigrate a project written against an older skrapa: settings in skrapa.config.ts (or .json) move into a skrapa key (in a copy of the template tsconfig.json in src/ when the pages lived there), and the old global type names (Page, Props, Tag, ...) become their Skrapa.* equivalents. Idempotent, so it is safe to run twice.--output

dev watches the skrapa root and the assets dir, where a changed file is copied straight to the output and a deleted one is removed from it. When its port is busy, it names what holds the port and how to free it:

Error: port 8090 is already in use by PID 43577 in web:
  node /usr/local/bin/skrapa dev
Stop it with `kill 43577`, or pass --port <number> to use another.

If the project root's package.json has a postbuild-skrapa script, dev runs it from the project root after its first build and after every rebuild. Anything written after the build is not there when the link check runs, so a page linking to it needs that path in linkCheckIgnore.

While an old skrapa.config.ts or skrapa.config.json is still in the project, every command but fix refuses to run and points you at npx skrapa fix (init too, unless you pass --force). fix validates the old settings, deletes the old file, carries ignore over as linkCheckIgnore, and reports anything it dropped. When pages used to live in src/, it copies the template's tsconfig.json into src/ with the old settings as its skrapa key, rebased to reach the same places from there, and points the package.json scripts that ran skrapa at src/. Otherwise the settings go into the tsconfig.json beside the old file, comments intact.

What gets scaffolded

init writes a complete, runnable site. In a new project the pages sit in src/, with assets/, skrapa.d.ts and the project files beside it. The two files that matter most are src/index.html (the shell every page renders into) and src/index.tsx (the home page):

  • .github/
    • workflows/
      • skrapa-pages.ymlGitHub Pages deploy, gated on the commit message
  • .gitignorecreated or updated to ignore src/.skrapa, node_modules, dist
  • assets/
    • github.svgicon
    • skrapa.svglogo
    • style.cssglobal stylesheet
  • package.jsoncreated or updated to add dev + build scripts
  • README.mdstarter readme for your project
  • skrapa.d.tsglobal types (Skrapa.Page, jsx, raw, VERSION), managed by skrapa
  • src/the skrapa root: pages, shells and tsconfig.json
    • about/
      • about.tshelper module imported by client.ts
      • client.tsbrowser JS scoped to /about
      • index.htmlper-page shell that overrides the shared one
      • index.tsx/about page, a nested dir is a nested route
      • style.cssper-page styles, linked from this shell
    • client.tsbrowser JS, compiled and linked via <script src>
    • components/
      • button.tsxexample component (imported, never routed)
    • index.htmlshared HTML shell (head + body)
    • index.tsxhome page, Page() → dist/index.html
    • tsconfig.jsonwires the jsx / Fragment runtime; skrapa settings go under "skrapa"

src/tsconfig.json builds to ../dist from ../assets, so the site lands in dist/ beside src/.

Two roots

Skrapa works with two directories, one level apart in a new project.

Every build writes skrapa.d.ts into the project root, so a tsconfig.json in a subdirectory lists "../skrapa.d.ts" in its include.

Pages & routing

Every <dir>/index.tsx that exports a Page function is a page. Its directory, relative to the skrapa root, is the route: src/about/index.tsx builds dist/about/index.html. Anything else (components, helpers, client.ts) is ignored, even if it sits in the same directory as a page. That's why components/button.tsx never becomes a route: it's imported by a page, not exported as one.

Pages are found anywhere under the skrapa root except under dot-directories (.skrapa, .git, .github), node_modules, the output dir and the assets dir. A page also has to be compiled: if the include in tsconfig.json leaves one out, the build stops and names the file. And an output dir inside the skrapa root that holds an index.tsx ("output": "docs" beside a docs/ page) is refused rather than cleaned.

The Page type

All types are stored in skrapa.d.ts, a managed file skrapa rewrites in the project root on every build, so treat it as read-only. They live under the Skrapa namespace, so nothing skrapa ships can collide with a type of your own. One very important type is Skrapa.Page, the shape every page's Page() function returns:

skrapa.d.ts
type Page =
    | JSX.Element
    | {
          /** Replaces the text of the shell's existing <title> */
          title?: string;
          /** Appended to the shell's <body>, just before </body> */
          body?: JSX.Element;
          /** Appended to the shell's <head>, just before </head> */
          head?: string;
          /** Set on the shell's opening <html> / <body> tags */
          shellAttrs?: {
              html?: Record<string, string | ((prev: string) => string)>;
              body?: Record<string, string | ((prev: string) => string)>;
          };
      };

Returning JSX is shorthand for { body: <that JSX> }. The simplest page just returns its markup:

src/about/index.tsx
export function Page(): Skrapa.Page {
  return <h1>About</h1>;
}

Note that it is JSX.Element and not string. Strings are escaped wherever they land in a child position, so <p>{text}</p> renders text as visible text even when it contains markup. Attribute values are escaped too. That makes interpolating content from an API, a CMS, or a form safe by default: it can no longer inject markup or scripts into the built page.

When you genuinely do have markup in a string, hand it to the global raw(), which is the one documented way to opt out of escaping. Nothing inside is checked, so never build one from untrusted input.

src/about/index.tsx
export function Page(): Skrapa.Page {
  const rows = ['one', 'two'];

  return (
    <ul>
      {/* escaped: renders the tags as visible text */}
      <li>{'<b>not bold</b>'}</li>
      {/* not escaped: renders as markup */}
      {raw(rows.map((r) => `<li>${r}</li>`).join(''))}
    </ul>
  );
}

Return an object instead when a page needs its own <title>, extra <head> markup such as a meta description, a canonical link, or a page-specific <style>, or attributes on the <html> or <body> element itself. Given this shell:

src/index.html
<html lang="en">
  <head>
    <title>Skrapa</title>
  </head>
  <body class="page"></body>
</html>

a page returning the object on the left builds the document on the right. Note what the shell keeps: lang="en", because the page never names it, and its own class="page", because the page passed a function and appended to it instead of overwriting.

src/about/index.tsx
export function Page(): Skrapa.Page {
  return {
    shellAttrs: {
      html: { 'data-page': 'about' },
      body: { class: (prev) => `${prev} about` },
    },
    title: 'About - Skrapa',
    head: '<meta name="description" content="..." />',
    body: <h1>About</h1>,
  };
}
dist/about/index.html
<html lang="en" data-page="about">
<head>
  <base href="/" />
  <title>About - Skrapa</title>
  <meta name="description" content="..." />
</head>
<body class="page about">
  <h1>About</h1>
</body>

The HTML shell

Every page renders into an index.html shell: the shared one at the top of the skrapa root, or a page-specific one dropped into that page's own directory. Skrapa walks up from the page's directory to the skrapa root looking for the nearest index.html, so a page without its own shell inherits its closest ancestor's.

At build time, skrapa splices five things into that shell, in this order. The first comes from your config; the other four are the head, title, shellAttrs, and body your page's Page() function returned (a page that returns bare JSX supplies only body; the rest are left untouched):

Client scripts

Any <script src="....ts"> (or .tsx) is compiled, bundled into a standalone .js file in dist, and has its src rewritten to point at that file. Write it in a shell, shared or page-specific, or directly in a page's body JSX.

A relative src resolves against the directory of the file it was written in: the shell's own directory for a shell, the page's own directory for page JSX. A leading-/ src resolves from the skrapa root.

One asymmetry to remember: a relative path never reaches the assets directory. Assets are copied to the output root, so only a leading-/ path can name one. A stylesheet at assets/style.css is /style.css, never ./style.css, whichever file references it. Get it wrong and the build warns and names the file it found in assets.

A src or href pointing at another origin (https://cdn/lib.css, or the protocol-relative //cdn/lib.css) is left exactly as written. The browser fetches it directly, so there is nothing on disk to resolve, and Skrapa says nothing about it.

Stylesheets

<link rel="stylesheet" href="....css"> works in the same two places, and a relative href resolves exactly like a script's src: against the shell's directory, or the page's own directory when written in page JSX. The file is copied into dist and href is rewritten to a root-relative path.

A root-relative href is the one case that differs from scripts, because a stylesheet can also come from the assets dir. Skrapa looks in the assets dir first and copies nothing if the file is there, since that whole directory is copied across anyway; otherwise it looks in the skrapa root and copies that file over. Either way the href is written back naming the same file, with the base on it, which at the default base is the path you already wrote.

A stylesheet is only ever copied from the site's own source files: never out of the output dir, the assets dir, a dot-directory or node_modules, and never from outside the skrapa root (../shared.css). A path that lands in one of those is reported as not found.

That is 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 next to that page's shell.

Assets

Everything in the assets dir (assets/ beside src/ in a new project; configurable) is copied to the root of dist untouched: images, fonts, CNAME, or any hand-written CSS not tied to a specific page.

Once the output is written, every local URL in it is checked against the files the build actually emitted. A reference that names nothing fails the build:

Reference does not resolve:
  in              dist/resume/index.html
  reference       /brian_reed_resume.pdf
  did you mean    /Brian_Reed_Resume.pdf   (differs only in case)

Case gets called out on its own because it is the failure local testing cannot reproduce. macOS and Windows fold case when resolving a path, so a page linking /fonts/archivo.woff2 against an emitted fonts/Archivo.woff2 renders perfectly on the machine that built it and 404s on GitHub Pages: a dead download, or a preload and @font-face pair that quietly falls back to a system font. The check compares against a list of the real filenames instead of asking the filesystem, so it gives the same answer wherever it runs. Any other broken reference ends by saying to fix it, or to add its path to linkCheckIgnore if something other than the build serves it.

References are read from HTML (href, src, srcset) and from CSS (url(), @import, image-set()), inline <style> included. Query strings and fragments are ignored, /about and /about/ both resolve to about/index.html, and every broken reference in the build is reported in one run rather than one per build.

Anything that names a file outside the build is skipped: another origin (https://, //cdn/), a data:, mailto: or tel: URI, and a bare #fragment.

Every other reference is checked, unless its path matches a pattern in linkCheckIgnore. That list is for URLs the deployed site serves but the build does not write: a proxied /api route, files uploaded to the host by hand, a sitemap generated after the build.

src/tsconfig.json
"skrapa": {
  "linkCheckIgnore": ["/api/**", "/uploads/*.pdf", "/sitemap.xml"]
}

Each pattern is a path from the site root, starting with /. * matches within one path segment, ** across any number of them, ? one character, and a trailing /** covers the path itself too, so /api/** also skips a link to /api. A reference is matched by where it points, not how it was written, so /uploads/*.pdf covers ../uploads/brief.pdf on a nested page too, and under a base path a pattern matches with or without the base in front. As a flag the patterns are comma-separated: --linkCheckIgnore "/api/**,/uploads/*.pdf".

In dev the same check runs, but a broken reference is a warning rather than a failure. Mid-edit it usually means a link to a file that is about to exist, and the server you are watching stays up.

Under a base path there is a second way to get this wrong, and it is the same bug wearing a different hat. <base href> governs relative URLs only. A root-relative /style.css ignores it completely and means the domain root, so on a project site at https://user.github.io/repo/ it is a request for a file that is not there. It resolves against a dev server rooted at / and 404s once deployed. Skrapa writes the base into every path it rewrites for you, so a bundled script lands as /repo/client.js; for links you write by hand, either include the base or keep them relative (about/, ./) and let <base href> do the work. The check names the URL with the base on it when it finds one missing.

Configuration

Settings live under a skrapa key in the skrapa root's tsconfig.json, next to the compiler options. The key is required: "skrapa": true takes every default, and an object sets the fields it names. A tsconfig.json without it is not a Skrapa project. A new project's src/tsconfig.json:

src/tsconfig.json
{
  "skrapa": {
    "output": "../dist",
    "assets": "../assets",
    "port": 8080,
    "projectRoot": "../"
  },
  "compilerOptions": { ... },
  "include": ["**/*.ts", "**/*.tsx", "../skrapa.d.ts"]
}

Skrapa only ever reads ./tsconfig.json in the directory it runs in, and that directory is the skrapa root: pages live under it, and output, assets and projectRoot resolve against it. It never walks up from a subdirectory to find one, and a skrapa key inherited through extends is not read. Comments and trailing commas are fine, as they are for tsc.

Every command stops before it does anything when the config has a problem: a tsconfig.json that is missing or will not parse, no skrapa key or one that is neither true nor an object, an unknown key, a value of the wrong type, a port outside 1-65535, an empty output, assets, host or projectRoot, a linkCheckIgnore pattern that does not start with /, an old skrapa.config.ts or skrapa.config.json beside it, a config flag with no value after it, or a removed flag. Every problem is listed in the same run, so one pass fixes them all.

compilerOptions.outDir and rootDir are ignored. Skrapa always runs tsc -p tsconfig.json --outDir .skrapa --rootDir . in the skrapa root, so compiled output lands in .skrapa/ beside tsconfig.json whatever the file says. That directory is scratch space: a build deletes it when it finishes (failed builds too), and dev keeps it for its rebuilds until it stops.

FieldDefaultDescription
output"dist"Build output directory
assets"assets"Static files copied as-is to output; skipped if not present
port8080Dev server port
host"localhost"Interface the dev server binds to
origin""Public URL when a proxy or tunnel fronts the dev server
base"/"Served-from base path, injected as <base href>
linkCheckIgnore[]URL paths the link check skips, for what the site serves but the build does not write
projectRoot"."The project root, where package.json, .gitignore and .github live and skrapa.d.ts is written
source"."Where the pages live, when tsconfig.json sits in the project root instead. Not with projectRoot

Because the config is JSON, it cannot compute a value or read an env var. Pass a flag for anything that changes per environment. Flags override the skrapa key, which overrides the defaults:

npx skrapa dev --port 3000
npx skrapa build --output public --base "$BASE"

Deploying

init writes .github/workflows/skrapa-pages.yml, a GitHub Pages workflow that runs npm ci && npm run build on Node 24 and publishes dist/ through actions/deploy-pages. For a site added to an existing project it runs npm run skrapa:build and publishes the site's own dist/ instead. Enable it once under Settings → Pages → Source → GitHub Actions; the workflow already requests the pages: write and id-token: write permissions it needs.

Pushing to main is not enough on its own. The job is gated, and runs only when one of these is true:

So ordinary commits land on main without republishing the site, and you choose when a deploy happens by how you word the commit. To deploy on every push instead, delete the if: block from the deploy job.

One setting catches people out. A project site is served from https://user.github.io/repo/, not the domain root, so set base to "/repo/" in the skrapa key of tsconfig.json. Skrapa injects it as <base href> and writes it into every path it rewrites for you, so your script and stylesheet tags land on the right URLs; links you write by hand need it too, or need to be relative (see links & references). Leave base at "/" for a user or organization site, or for a custom domain. For a custom domain, drop a CNAME file into assets/ and it is copied to the root of the build.

Nothing about the output is GitHub-specific. dist/ is plain static files, so any host that serves a directory works: Netlify, Cloudflare Pages, S3, or an rsync to a box you own.

← Back home