Why Bascik
Bascik is a build tool for HTML components with automatically scoped CSS and JS. Zero runtime. The code that ships is the code you wrote.
Scripts & Routing
Build-time scripts let you run Node.js code at transpile time and inject the output directly into the page with no client-side JavaScript required. Use them to pull in Markdown files, generate navigation from JSON, or fetch remote data at build time.
This card was generated by a build script that read a Markdown file (why-bascik.md) and passed its heading and intro paragraph to a component as props.
Bascik is a build tool for HTML components with automatically scoped CSS and JS. Zero runtime. The code that ships is the code you wrote.
<!-- src/pages/scripts/build.html -->
<script data-bascik-build>
import { readFile } from "node:fs/promises";
import { html } from "@bascik/bascik/html";
export default async function () {
const md = await readFile("./content/why-bascik.md", "utf8");
const [heading, paragraph] = md.split("\n\n");
const title = heading.replace(/^#\s*/, "");
// Props are text: html escapes each value into its attribute.
return html`
<feature-card
data-bascik-prop-title="${title}"
data-bascik-prop-desc="${paragraph}"
>
</feature-card>
`;
}
</script> <!-- The script tag is replaced by the returned HTML string -->
<article class="b_el_feature-card_article">
<h3 class="b_el_feature-card_h3">Why Bascik</h3>
<p class="b_el_feature-card_p">
Bascik is a build tool for HTML components with automatically scoped CSS and
JS. Zero runtime. The code that ships is the code you wrote.
</p>
</article> Tag any <script> block with data-bascik-build and Bascik will execute it as a Node.js ESM module during transpilation. The script exports a default callable function that returns an HTML string, which replaces the tag in the final HTML.
<!-- src/pages/index.html -->
<!DOCTYPE html>
<html lang="en">
<body>
<script data-bascik-build>
export default async function () {
return "<p>This text was generated at build time.</p>";
}
</script>
</body>
</html> Output in the compiled HTML (dist/index.html):
<!-- dist/index.html -->
<!DOCTYPE html>
<html lang="en">
<body>
<p>This text was generated at build time.</p>
</body>
</html> A few rules to know: Top-level import and await are supported at module scope. Relative ESM imports resolve from the page, component, or external script file that contains them. Generic local data paths passed to functions such as readFile('./content/data.json') remain relative to the project root (process.cwd()). Return an HTML string (or an html value, or a promise resolving to either) from the default export. Build scripts run during both dev and production builds. Component tags in the returned markup are resolved normally, so your build script can emit <my-card> and it will be transpiled. Standard console methods (console.log(), console.warn(), console.error()) write to the terminal for debugging and logging only; they never generate markup.
Build markup with html. A build script, or a helper it imports, that puts a value into markup builds it with html from @bascik/bascik/html, the same tag server and stream scripts use: return html`<h2>${title}</h2>`. It escapes each value for where it lands and turns javascript: URLs into about:invalid, so the project needs no escaper of its own. Insert Markdown or CMS HTML you trust or sanitized with raw(), and write JSON-LD with json().
A build script defines its output through a callable default export:
<!-- src/pages/index.html -->
<script data-bascik-build>
export default async function () {
console.log("Rendering static content at build time");
return "<p>Generated content</p>";
}
</script> function, async function, or arrow function). Bascik invokes it with zero arguments.html value from @bascik/bascik/html, or a promise resolving to either. Returning undefined, null, numbers, or other objects throws an error.return ''.console.log() and console.error() output is printed to the terminal for diagnostic logging when the script finishes. Bascik transfers returned HTML through a separate isolated transport, so logs never leak into generated markup.src="..." follow the identical contract: the referenced file must have a callable default export returning HTML markup. A src= file that cannot be read is the script's error, reported at the tag like any other failure (Cannot read build script src "./nav.ts" (...)). A tag with both src= and an inline body is rejected, for build, routes, server and stream scripts alike: as in a browser, src= means the file, so move the body into it or remove src=.A build script is TypeScript when its src= names a .ts or .mts file, as in <script data-bascik-build src="./nav.ts"></script>, or when its tag is marked type="text/typescript", as a browser script is. Bascik strips the types before it runs the script, the same way it strips browser TypeScript (scripts.typescript), so errors and stack traces name the TypeScript source's own lines.
An unmarked inline block is JavaScript. A syntax error, or syntax Node's strip cannot erase such as an enum, fails the build with the file, line, and column and a code frame; only the enum kind of error suggests a scripts.typescript compiler. Build-once, routes, server, and stream scripts follow the same rule.
The script below is marked type="text/typescript" and reads BASCIK_PAGE_PATH, so it prints a different trail on every page. That page dependence is why it is a data-bascik-build script; output that is the same on every page, such as a copyright year, belongs in data-bascik-build-once. The Preview is this docs page running the same script, so its trail names this page.
The Output is the page's <body> as bascik --build writes it. The types are gone, and the trail names scripts / build because that is the page being built.
<!-- src/pages/scripts/build.html -->
<!doctype html>
<html lang="en">
<head>
<title>Build scripts</title>
</head>
<body>
<script data-bascik-build type="text/typescript">
import { html, type SafeHtml } from "@bascik/bascik/html";
const path: string = process.env.BASCIK_PAGE_PATH ?? "/";
const trail: string[] = ["Home", ...path.split("/").filter(Boolean)];
export default (): SafeHtml =>
html`<nav aria-label="Breadcrumb">${trail.join(" / ")}</nav>`;
</script>
</body>
</html> <nav aria-label="Breadcrumb">Home / scripts / build</nav> data-bascik-build-once<script data-bascik-build-once> runs its script once and reuses the markup wherever the file is used. It has the same handler contract as data-bascik-build, but no page or route context, so use it for output that is identical everywhere: a copyright year, a list of social links, an inlined SVG logo.
Its main use is in components. A footer appears on every page, but its year is the same on all of them:
<!-- src/components/site-footer/site-footer.html -->
<footer class="site-footer">
<p>
©
<script data-bascik-build-once>
export default () => String(new Date().getFullYear());
</script>
Example Co. All rights reserved.
</p>
<nav aria-label="Footer">
<a href="/privacy">Privacy</a>
<a href="/contact">Contact</a>
</nav>
</footer> Bascik runs the script once, when it registers the component, and every page that uses <site-footer /> gets the same markup:
<footer class="site-footer">
<p>© 2026 Example Co. All rights reserved.</p>
<nav aria-label="Footer">
<a href="/privacy">Privacy</a>
<a href="/contact">Contact</a>
</nav>
</footer> The generated markup then goes through the component's usual scoping and expansion, so its classes match the component's styles and its IDs are unique per use. With data-bascik-build, the footer's script would run again for every page that uses it, with that page's context, to print the same year each time.
It works in pages too. In a dynamic route template, a data-bascik-build-once script runs once for the template and every generated page shares its output, while data-bascik-build runs in the context of each route.
| Where it is written | Runs | Output shared by |
|---|---|---|
| A component | Once, when the component is registered | Every use of that component |
| A page | Once, before the page's build scripts | That page |
| A dynamic route page | Once for the template | Every page the template generates |
The directive takes no value. BASCIK_SOURCE_FILE names the file the script is written in; BASCIK_PAGE_FILE and BASCIK_PAGE_PATH are empty and BASCIK_ROUTE is unset. Use data-bascik-build when output depends on the page or the route. Its output is cached like any build script's (see Performance: Output Caching), so a warm start reuses it until the script, a file it imports, or another cache input changes. A script whose output changes with no input changing, such as one that prints the current year, keeps its cached value until then; list it in scripts.cache.exclude to run it on every build.
One run serves every page, so there is no single page to describe. Suppose a site-head component used on every page had a data-bascik-build-once script that read BASCIK_PAGE_PATH to pick the hero image to preload. The script would see whichever page the compiler reached first, and every page would get that page's preload. Pages build in parallel, so the page that comes first can change from one build to the next, and the wrong preload would move around too. Leaving the page variables empty makes this mistake show up as an obvious empty value instead of output that is quietly wrong on most pages. When output depends on the page, use data-bascik-build, which runs once per page with that page's context.
Imports, TypeScript (type="text/typescript" or a .ts src=), error handling, and dependency watching work the same as for data-bascik-build. Output is shared only within one file, never between different components or pages. Use a pre exec script for work that must run once for the whole project.
A common pattern is converting Markdown content to HTML at build time:
<!-- src/pages/about.html -->
<script data-bascik-build>
import { readFile } from "node:fs/promises";
import { marked } from "marked";
export default async function () {
const md = await readFile("./content/intro.md", "utf8");
return marked(md);
}
</script> The dev server does not watch files outside src/ on its own. Add the folder to pipeline.watchPaths and saving the Markdown rebuilds the page that reads it:

Read a JSON data file and render HTML markup from it. Build the markup with html, as in any script: it escapes each label and URL for where it lands, and an array of html values needs no .join(""):
<!-- src/pages/index.html -->
<script data-bascik-build>
import { readFile } from "node:fs/promises";
import { html } from "@bascik/bascik/html";
export default async function () {
const items = JSON.parse(await readFile("./content/nav.json", "utf8"));
const links = items.map(
(item) => html`<li><a href="${item.href}">${item.label}</a></li>`,
);
return html`<ul>
${links}
</ul>`;
}
</script> Node.js includes a global fetch. Use it to pull remote data at build time so the result is baked into static HTML:
<!-- src/pages/products.html -->
<script data-bascik-build>
import { html } from "@bascik/bascik/html";
export default async function () {
const res = await fetch("https://api.example.com/posts/latest");
const { title, excerpt } = await res.json();
return html`<h2>${title}</h2>
<p>${excerpt}</p>`;
}
</script> A network response is invisible to the output cache, so list the page in scripts.cache.exclude or a rebuild reuses the old response.
A build script returns a string or an html value (not a plain object). When client JavaScript needs structured build-time data for interactivity, search, or filtering, return a <script type="application/json"> block.
This gives the same client hydration capability as getStaticProps in Next.js (which serialized props into a <script id="__NEXT_DATA__"> tag), but without a framework runtime. You control the exact tag, ID, and schema:
<!-- src/pages/products.html -->
<script data-bascik-build>
import { readFile } from "node:fs/promises";
import { html, json } from "@bascik/bascik/html";
export default async function () {
const catalog = JSON.parse(
await readFile("./content/products.json", "utf8"),
);
// json() writes <, > and & as JSON escapes, so no string in the data can end the script early.
const data = json(catalog);
return html`<script type="application/json" id="catalog-data">
${data}
<\/script>`;
}
</script>
<script>
// Browser client script parses the static payload
const catalog = JSON.parse(
document.getElementById("catalog-data").textContent,
);
console.log("Hydrated products:", catalog);
</script> Safety and Scoping. Serialize the data with json(value) from @bascik/bascik/html, never a bare JSON.stringify: it writes <, > and & as \u003c, \u003e and \u0026, so a string containing </script> cannot break out of the tag, and html refuses anything else in that spot. When this pattern lives inside a component, write document.getElementById('catalog-data') with the source ID as usual. Bascik automatically rewrites both the element ID and the selector at build time to the same scoped identifier.
Build script output is transpiled again, so it can contain component tags and client scripts. When a script prints HTML from somewhere else, such as a CMS post body, an API response, or converted Markdown, that HTML lands in the page with the same power as your own markup:
<script>, an onerror handler, or a javascript: link runs in every visitor's browser.<site-header> expands into your component.Pass such HTML through an allowlist sanitizer (for example sanitize-html), insert the result with raw(), and build everything around it with html, which escapes plain text.
Directive scripts are the exception Bascik handles itself. A <script data-bascik-build>, <script data-bascik-build-once>, <script data-bascik-server>, <script data-bascik-stream>, or <script data-bascik-routes> that appears in a build script's output is removed and never runs, and the build warns:
[bascik] warning: build script output in "pages/index.html" contained <script data-bascik-build>.
Printed directive scripts never run; they were removed. Write directives in source files, and
sanitize HTML from a CMS or an API before printing it. Write directives in page and component source files. To run more build-time code for printed markup, call a helper from the script that prints it. Bascik removes printed directive scripts before processing the generated markup, so they do not execute during the build or register as server scripts.
A build script that prints directives from a template in your own repository, never from content editors, can keep them with security.stripDirectiveScripts: false. Printed directives then run like ones you wrote, with no warning, so CMS content can run code during your build or on your server. Leave it on unless every byte the script prints is yours.
Build scripts work inside <head> as well as <body>. This lets you extract repeated meta tags or link tags into dynamic reusable components:
<!-- src/components/site-meta.html -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="My site description" />
<link rel="icon" href="/favicon.ico" /> <!-- src/pages/index.html -->
<head>
<title>Home</title>
<site-meta></site-meta>
<script data-bascik-build>
import { html } from "@bascik/bascik/html";
const apiUrl = process.env.API_URL ?? "https://api.example.com";
export default () => html`<meta name="api-url" content="${apiUrl}" />`;
</script>
</head> Build scripts are standard Node.js ESM modules, so they can import shared helpers and any installed npm package. Use the @/ import root alias to reach src/lib/ helpers from a page at any depth:
<!-- src/pages/about.html -->
<script data-bascik-build>
import { renderMd } from "@/lib/md-renderer.ts";
export default async () => await renderMd("./content/about.md");
</script> The alias, the rules for relative paths, and npm packages work the same in every script kind. See Script Imports for the full guide. To rebuild pages when a helper changes, add its directory to pipeline.watchPaths; see Watch Paths.
A build script reads process.env: values from your shell, from ./.env (Bascik loads it at startup; no dependency needed), and the variables Bascik sets for the page being built, such as BASCIK_PAGE_PATH, BASCIK_SITE_URL, BASCIK_BASE, and BASCIK_ROUTE in a dynamic route. Use them for API endpoints, deployment URLs, or feature flags that should be baked into the build:
<!-- src/pages/index.html -->
<script data-bascik-build>
import { html } from "@bascik/bascik/html";
const apiUrl = process.env.API_URL ?? "https://api.example.com";
export default () => html`<meta name="api-url" content="${apiUrl}" />`;
</script> What a build script prints is in the page every visitor downloads, so never print a secret. A variable of your own that changes the output must be declared in scripts.cache.environment, or the cached output from the previous value is reused; see Declaring environment inputs.
Environment variables on the scripts overview compares what each kind of script gets, and Environment Variables describes each variable.
Build scripts run as isolated Node.js ESM modules during transpilation. When a build script throws an exception (such as ENOENT, a syntax error, or a failed network fetch), Bascik intercepts the error and reports it cleanly without crashing the dev server or CLI runner.
In the dev server, the error also appears as an overlay in every open browser, and saving the fix dismisses it:

When a build script fails, Bascik prints the error once. The header names the file and the line and column of the <script data-bascik-build> tag, as a path from the project root, the same text the stack shows. The stack below it gives the line and column of the failing expression in that same file, including in a component, where markup and <style> blocks above the script are moved out before it runs. Frames from Node.js internals and from Bascik's own script runner are removed:
[bascik] build script error in "src/pages/about.html" at (line 9, column 5):
Error: Could not load the team list
at Module.default (src/pages/about.html:13:15) A syntax error is shown with the failing line and a caret under the token:
[bascik] build script error in "src/pages/about.html" at (line 9, column 5):
src/pages/about.html:11
return "x" +;
^
SyntaxError: Unexpected token ';' A script loaded with src= is its own file, so its frames name that file and its lines. Errors thrown in an imported helper keep the helper's path and line.
In VS Code or modern terminal emulators, Cmd + Click (or Ctrl + Click) the file reference directly in the error log to jump straight to the source HTML line where the script failed.
Control failure handling using scripts.onBuildScriptError in bascik.config.ts:
'error' (default): Logs to stderr and immediately halts execution.'warn': Logs a warning to stderr and continues execution with an empty string replacement so the dev server stays active while you edit.'ignore': Silently replaces the failed script with an empty string.// bascik.config.ts
export default {
scripts: {
onBuildScriptError: "error", // 'error' | 'warn' | 'ignore'
onRoutesScriptError: "error", // 'error' | 'warn' | 'ignore'
onServerScriptError: "error", // 'error' | 'warn' | 'ignore'
},
}; Directives (data-bascik-build, data-bascik-build-once, data-bascik-routes, data-bascik-server, data-bascik-stream) are mutually exclusive and cannot be combined on the same <script> tag:
data-bascik-server and data-bascik-stream is disallowed (choose buffered or streaming execution).data-bascik-routes with other directives is disallowed (route scripts run independently).When build scripts read local files or fetch remote data, wrap operations in try / catch blocks. Returning fallback markup or logging a warning keeps your page layout intact even if an external resource is temporarily unavailable:
// src/lib/md-renderer.ts
import { readFile } from "node:fs/promises";
import { html, raw, type SafeHtml } from "@bascik/bascik/html";
import { marked } from "marked";
export async function renderMd(filePath: string): Promise<SafeHtml> {
try {
const md = await readFile(filePath, "utf8");
// Your own Markdown is trusted, so raw() inserts the rendered HTML as it is.
return raw(marked.parse(md, { async: false }) as string);
} catch (err) {
console.warn(
`[md-renderer] Could not read ${filePath}: ${(err as Error).message}`,
);
return html`<div class="callout">
<p><strong>File not found:</strong> <code>${filePath}</code></p>
</div>`;
}
} Every uncached build script on a page is written to its own temporary module and executed in its OWN fresh Node.js child process, independent of how many sibling scripts are cache misses. Each script gets its own ESM module registry, its own stdout/stderr streams, and its own environment, so execution isolation is deterministic regardless of cache temperature or neighboring misses:
setImmediate, Promise.resolve().then, timers) is attributed to the script that scheduled it and never captured by a sibling's transport.pipeline.exec script, whose output goes straight to the terminal.process.exit() inside build scripts, as it terminates the subprocess for that single script.node_modules/.cache/bascik/ (build-<time>-<id>.mjs, routes-<time>-<id>.mjs), deleted when it finishes. One left behind by a process that was stopped mid-script (Ctrl-C, a crash, a CI timeout) is deleted by a later build once it is a day old.node_modules/.cache/bascik/compile-cache/, so a module one child compiled (a shared helper or a package) is not recompiled by the next. The cache stores compiled code only, never module state, so isolation is unchanged. Bascik keeps your own NODE_COMPILE_CACHE value, and NODE_DISABLE_COMPILE_CACHE=1 turns the cache off.When a page needs the same data in several places, read or fetch it once at page level in a single build script and pass it down, rather than re-fetching per component instance (see the fetch-once pattern).
Bascik caches build script output in node_modules/.cache/bascik/script-cache/. Subsequent builds skip subprocess execution entirely for unchanged scripts. The cache key is a SHA-256 hash of:
type="text/typescript" tag or a .ts src=) and through a scripts.typescript compiler, so removing the marker re-runs the same text as JavaScriptBASCIK_SITE_URL), deployment base (BASCIK_BASE), and dynamic route parametersBASCIK_SOURCE_FILE or BASCIK_PAGE_FILE, so a helper that reads its own page re-runs when the page changesfile:-linked package invalidates cached output even when the script text is unchangedOutput shared across pages. A component's data-bascik-build script, and a dynamic route page's script, run in a different page context on every page. When a run never reads BASCIK_PAGE_PATH, BASCIK_PAGE_FILE, or BASCIK_ROUTE (directly, through a helper, or by enumerating process.env), its output cannot depend on the page, so Bascik stores it under a second, page-independent key and reuses it on the other pages instead of starting a process for each one. A script that reads any of them runs for every page as before. Uses of a component on the same page each still run.
Package identity is computed per imported package (not per page) and cached, so unrelated installed packages are never hashed on every page. Node core modules (node:fs, fs) contribute only their runtime name, never a filesystem hash.
The mode is part of the key: the dev server and bascik --build keep separate entries side by side, and an entry is pruned seven days after it was written. The first start in a mode that has no live entries, such as the first bascik --build on a project that has only run the dev server, or a mode whose entries have all aged out, runs every build script again; the next start in that mode is warm, and a project that alternates dev and build stays warm in both. Editing a helper that many scripts import invalidates every one of their entries at once, because each key folds in the contents of the files the script imports. Either turns a transpile that usually takes a few seconds into one many times longer, for that one run. The output says which it was. The summary counts the scripts that ran against those served from the cache: ✓ 102 pages transpiled in 13.2s (969 build scripts ran, 0 from cache). A page whose scripts ran shows their cost on its own line, transpiled: pages/components.html in 114ms (+5.1s in 52 build scripts): the time before the parenthesis is the page's own compile work, with the child processes that ran its scripts excluded, which is why on a cold site the page lines add up to far less than the summary.
Bascik resolves bare packages the same way the executed child does (from node_modules/.cache/bascik/, honoring the project's hoisted node_modules, import export conditions, and subpath exports), so the cache identity matches the exact module the child loads. A shared resolver stub in the temp directory reproduces that resolution deterministically across fresh processes, worker/serial modes, and package managers.
Bascik statically scans genuine relative ESM specifiers, quoted local data paths, and package imports. It cannot detect runtime dependencies such as:
readdir)join(dir, name) or template strings)A script whose dependency graph cannot be statically known (a dynamic import() of a non-literal specifier, such as import(name) or a template string) is automatically classified non-cacheable: it re-runs on every build and never writes a cache entry. This is an explicit contract, not a blanket cache disable.
If your script reads from any of the undetectable sources above, configure scripts.cache.exclude in bascik.config.ts:
include and exclude globs match script source paths relative to the project root, such as src/pages/live-feed/index.html, even when Bascik passes an absolute path internally. Existing patterns prefixed with **/ continue to match.
// bascik.config.ts
import { defineConfig } from "@bascik/bascik/config";
export default defineConfig({
scripts: {
cache: {
enabled: true,
exclude: ["src/pages/live-feed/**", "src/components/api-cards/**"],
},
},
}); Environment variables are not part of the cache key by default, so a script that reads process.env would otherwise reuse stale cached output when the variable changes. Declare the exact variable names you read in scripts.cache.environment to fold their resolved values into the cache key:
// bascik.config.ts
import { defineConfig } from "@bascik/bascik/config";
export default defineConfig({
scripts: {
cache: {
enabled: true,
environment: ["MY_FEATURE_FLAG", "API_BASE_URL"],
},
},
}); When a declared variable's value changes, the cache key changes and the script re-runs. Unchanged values reuse the cached output. Only exact names are supported; globs are not. The resolved value reflects the same precedence as the rest of Bascik: a real shell environment variable beats a .env file value, and a later --env-file beats an earlier one.
Values are hashed into the key and never persisted or displayed, so it is safe to declare secrets. Missing and empty are distinct: a variable that is unset produces a different key than one set to an empty string. Declaration order does not matter; the key is deterministic.
When scripts.cache.environment is configured, Bascik warns about statically visible process.env.NAME reads that are not declared, so you can catch a variable you forgot to add. The warning is advisory only and never fails the build. It does not fire when the declaration is absent, so existing projects see no new noise.
To clear the cache manually:
rm -rf node_modules/.cache/bascik/script-cache directory.pages, directory.components, or pipeline.watchPaths rebuild dependent pages. Other files, including helpers under the import root (src/lib/), need a pipeline.watchPaths entry or a restart (see Watch Paths).import/export syntax and a callable default export. Write helpers as .ts (preferred on Node 22.22+), .js, or .mjs.window and document are not available in build scripts.For per-request server-side rendering, see Server Scripts.