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- scaffolds the default files
- installs
typescriptas a dev dependency - starts the dev server (pass
--no-devto skip it)
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.
| Command | What it does | Params |
|---|---|---|
npx skrapa init | Scaffold 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 dev | Serve the build over HTTP with live reload on every change. | --port, --host, -v / --verbose, plus the build flags |
npx skrapa build | Render 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, distassets/github.svgiconskrapa.svglogostyle.cssglobal stylesheet
package.jsoncreated or updated to add dev + build scriptsREADME.mdstarter readme for your projectskrapa.config.jsonbuild + dev-server settingsskrapa.d.tsglobal types (Page, jsx, VERSION), managed by skrapasrc/about/about.tshelper module imported by client.tsclient.tsbrowser JS scoped to /aboutindex.htmlper-page shell that overrides the shared oneindex.tsx/about page, a nested dir is a nested routestyle.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:
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:
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:
export function Page(): Page {
return {
htmlAttrs: { 'data-page': 'about' },
title: 'About - Skrapa',
head: '<meta name="description" content="..." />',
body: '<h1>About</h1>',
};
}<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):
<base href>comes from thebaseconfig, not thePageobject. Inserted as the first thing inside<head>so every relative URL below it resolves the same from/and from nested pages.Page.headis the returnedheadstring (empty if omitted), appended immediately before</head>. Use it for page-specific<head>markup like a meta description or canonical link.Page.titleis the returnedtitlestring. If set, it replaces the contents of the shell's existing<title>...</title>. The shell must already have a<title>tag for this to take effect; if the page omitstitle, the shell's own title is left as-is.Page.htmlAttrsis merged into the shell's opening<html>tag. An attribute the shell already sets is replaced rather than duplicated, so a page can override the shell'slang; attributes the page does not mention are left in place. Values serialize exactly as they would in JSX, so{ style: { colorScheme: 'dark' } }works the same way it does on any element. This page uses it to setclass="docs-page".Page.bodyis the returnedbodystring (empty if omitted), appended immediately before</body>.
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:
| 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" | 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 publicDeploying
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:
- the commit message starts with
deploy:(for exampledeploy: new about page) - the push is part of a pull request against
main - you trigger it by hand from the Actions tab (
workflow_dispatch)
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.