Docs

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 in an empty directory (or an existing one) to lay down a starter project and boot the dev server:

npx skrapa init

Existing files are left in place unless you pass -f / --force to overwrite them with the scaffolded versions.

Commands

Four commands, each also runnable through an npm script. The config flags (--input, --root, and the rest from Configuration) override skrapa.config.json for any command.

CommandWhat it doesParams
npx skrapa initScaffold a new project, then start the dev server (the default in a directory that has no skrapa.config.json yet).-f / --force, --no-dev, --root
npx skrapa devServe the build over HTTP with live reload on every change.--port, --host, -v / --verbose, plus the build flags
npx skrapa buildRender the site to static HTML in the output dir.--input, --output, --assets, --base, --root
npx skrapa page "<name>" [parent]Scaffold a new page dir: index.tsx + index.html + style.css + client.ts.name (positional), parent (positional), --input, --root

What gets scaffolded

init writes a complete, runnable site. 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/
      • deploy.ymlGitHub Pages deploy, gated on the commit message
  • .gitignorecreated or updated to ignore .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.config.jsonbuild + dev-server settings
  • skrapa.d.tsglobal types (Page, jsx, VERSION), managed by skrapa
  • src/
    • 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

Pages & routing

Every <dir>/index.tsx that exports a Page function is a page. Its directory, relative to src/, 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 src/components/button.tsx never becomes a route: it's imported by a page, not exported as one.

The Page type

All types are stored in skrapa.d.ts, a managed file skrapa rewrites on every run, so treat it as read-only. One very important type is Page, the shape every page's Page() function returns:

skrapa.d.ts
type Page =
    | string
    | {
          /** Replaces the text of the shell's existing <title> */
          title?: string;
          /** Appended to the shell's <body>, just before </body> */
          body?: string;
          /** Appended to the shell's <head>, just before </head> */
          head?: string;
          /** Merged into the shell's <html> element */
          htmlAttrs?: Record<string, unknown>;
      };

Returning a string (or JSX, which the runtime renders to a string) is shorthand for { body: <string> }. The simplest page just returns its markup:

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

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> element itself:

src/about/index.tsx
export function Page(): Page {
  return {
    htmlAttrs: { 'data-page': '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>
  <h1>About</h1>
</body>

The HTML shell

Every page renders into an index.html shell: the shared one at src/index.html, or a page-specific one dropped into that page's own directory. Skrapa walks up from the page's directory to the input 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, htmlAttrs, and body your page's Page() function returned (a page that returns a bare string 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 input root.

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 assets/ first and leaves the tag alone if the file is there, since that whole directory is copied across anyway; otherwise it looks in the input root and copies that file over. Either way the href stays exactly as you wrote it.

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 assets/ (configurable) is copied to the root of dist untouched: images, fonts, CNAME, or any hand-written CSS not tied to a specific page.

Configuration

skrapa.config.json in the project root, with all fields optional:

FieldDefaultDescription
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
port8080Dev server port
host"localhost"Dev server host
base"/"Served-from base path, injected as <base href>

CLI flags override config file values:

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

Deploying

init writes .github/workflows/deploy.yml, a GitHub Pages workflow that runs npm ci && npm run build on Node 24 and publishes dist/ through actions/deploy-pages. 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 skrapa.config.json. Skrapa injects it as , which is what keeps your root-relative links and script tags resolving. 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