Features
Build Scripts
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.
See it in action
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.
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.
<script data-bascik-build>
import { readFile } from 'node:fs/promises';
import { marked } from 'marked';
const md = await readFile('./content/why-bascik.md', 'utf8');
const [heading, paragraph] = md.split('\n\n');
const title = heading.replace(/^#\s*/, '');
console.log(`
<feature-card
data-bascik-prop-title="${title}"
data-bascik-prop-desc="${marked.parseInline(paragraph)}">
</feature-card>
`);
</script> <!-- The script is replaced by its stdout output -->
<div class="bascik__feature-card__fcard">
<h3 class="bascik__feature-card__el__h3">Why Bascik</h3>
<p class="bascik__feature-card__el__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>
</div> data-bascik-build
Tag any <script> block with data-bascik-build and Bascik will execute it as a Node.js ESM module during transpilation. The script's stdout output replaces the tag in the final HTML.
<!-- src/pages/index.html -->
<!DOCTYPE html>
<html lang="en">
<body>
<script data-bascik-build>
console.log('<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. 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()). Write output with console.log() or process.stdout.write(). Build scripts run during both dev and production builds. Component tags in the output are resolved normally, so your build script can emit <my-card> and it will be transpiled.
Why console.log() instead of return?
If you are coming from frameworks like Next.js where components or data-fetching functions use return markup, using console.log() may feel unexpected at first.
Build scripts (data-bascik-build) run as top-level Node.js ESM modules rather than functions wrapped in a framework runtime. In standard JavaScript, a top-level return outside a function is a SyntaxError: Illegal return statement.
Because each build script is executed as a standalone module in its own Node.js process, it communicates by writing directly to standard output (stdout). Using console.log() (or process.stdout.write()) follows standard Node.js module semantics, supports streaming chunks without holding everything in one variable, and requires no proprietary wrapper or compiler transform. In contrast, request-time server scripts (data-bascik-server) export a handler function (export default function(request) { return ... }), where returning a string is valid JavaScript.
Example Patterns
Reading Markdown
A common pattern is converting Markdown content to HTML at build time:
<script data-bascik-build>
import { readFile } from 'node:fs/promises';
import { marked } from 'marked';
const md = await readFile('./content/intro.md', 'utf8');
console.log(marked(md));
</script> Generating Navigation from JSON
Read a JSON data file and render HTML markup from it:
<script data-bascik-build>
import { readFile } from 'node:fs/promises';
const items = JSON.parse(await readFile('./content/nav.json', 'utf8'));
const links = items.map(item => `<li><a href="${item.href}">${item.label}</a></li>`).join('\n');
console.log(`<ul>\n${links}\n</ul>`);
</script> Fetching Remote Data at Build Time
Node.js includes a global fetch. Use it to pull remote data at build time so the result is baked into static HTML:
<script data-bascik-build>
const res = await fetch('https://api.example.com/posts/latest');
const { title, excerpt } = await res.json();
console.log(`<h2>${title}</h2><p>${excerpt}</p>`);
</script> Head Components & Dynamic Metadata
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>
const apiUrl = process.env.API_URL ?? 'https://api.example.com';
console.log(`<meta name="api-url" content="${apiUrl}" />`);
</script>
</head> Shared Modules & Import Aliases
Build scripts are standard Node.js ESM modules. You can write utility functions in your project and import them across pages with no special Bascik API required:
// scripts/render-cards.ts
import { readFile } from 'node:fs/promises';
export async function renderCards(jsonPath: string) {
const items = JSON.parse(await readFile(jsonPath, 'utf8'));
return items.map(item => `
<div class="card">
<h3>${item.title}</h3>
<p>${item.description}</p>
</div>
`).join('\n');
} Import paths in build scripts: You can use standard static imports, bare imports, export ... from, and dynamic import(). Bascik rewrites genuine ./ and ../ ESM specifiers relative to the containing page, component, or external script file.
Import Root Aliases (@/)
Relative paths change shape with the depth of the file that contains them (../lib/x.ts at one level, ../../lib/x.ts at the next), so the same helper import cannot be pasted into every page. For shared helpers, import from the import root instead. The default recommendation is @/:
<script data-bascik-build>
import { renderMd } from '@/lib/md-renderer.ts';
console.log(await renderMd('./content/about.md'));
</script> @/ resolves against scripts.importRoot (default src), so @/lib/md-renderer.ts is src/lib/md-renderer.ts from any page or component, at any nesting depth. The alias also works in <script data-bascik-server>, <script data-bascik-stream>, <script data-bascik-routes>, and in the src="…" attribute on those tags.
- Helper resolution: Helper
.tsand.jsfiles loaded from disk are executed by Node as-is, so a helper that imports another helper must use standard./or../. - Data paths: Quoted data paths passed to functions (
readFile('./content/x.md')) are not module specifiers and keep their standardprocess.cwd()semantics. - Bare leading
/is an error:import x from '/lib/x.ts'(orsrc="/lib/x.ts") inside a Bascik script is rejected because a leading slash is ambiguous between the filesystem root and site root. The error names both valid rewrites:@/lib/x.tsfor the import root or./lib/x.tsfor a path relative to the file. Plain client<script>tags are unaffected.
Editing an alias-imported helper rebuilds dependent pages only when its path is covered by directory.* or pipeline.watchPaths. The import-root watcher invalidates request-time modules, but does not trigger compilation. Add build helper directories such as src/lib/ to pipeline.watchPaths. See Configuration for scripts.importRoot and Monorepos for pointing it at a shared directory.
npm Packages in Build Scripts
A Bascik project is a standard Node.js project, so any npm package can be installed and used in build scripts. Install it once and import it anywhere:
npm install gray-matter <script data-bascik-build>
import { readFile } from 'node:fs/promises';
import matter from 'gray-matter';
const raw = await readFile('./content/post.md', 'utf8');
const { data, content } = matter(raw);
console.log(`
<article>
<h1>${data.title}</h1>
<p class="date">${data.date}</p>
</article>
`);
</script> Environment Variables
Environment variables set in your shell or a .env file (Bascik loads ./.env automatically at startup; no dependency needed) are available via process.env. Use this for API keys, deployment URLs, or feature flags that should be baked into the build without shipping to the browser:
<script data-bascik-build>
const apiUrl = process.env.API_URL ?? 'https://api.example.com';
console.log(`<meta name="api-url" content="${apiUrl}" />`);
</script> BASCIK_SITE_URL
As an input, you set the site URL for the build via --site-url, the BASCIK_SITE_URL environment variable, or a .env file (see Configuration precedence). As an output, Bascik forwards the resolved value to every build script as BASCIK_SITE_URL, so scripts can emit absolute canonical or Open Graph URLs.
When no source provides a value, the variable is absent from the script's environment rather than set to an empty string, so scripts can distinguish "unset" from "empty":
<script data-bascik-build>
if ('BASCIK_SITE_URL' in process.env) {
console.log(`<link rel="canonical" href="${process.env.BASCIK_SITE_URL}/about" />`);
}
</script> BASCIK_BASE
Bascik forwards the normalized base config value to every build script as BASCIK_BASE. It is always present and defaults to /. This is useful when build output must expose the deployment prefix to client code, because Bascik deliberately does not rewrite URLs assembled inside JavaScript:
<!-- Emit the base once, at build time, into a data attribute. -->
<script data-bascik-build>
console.log(`data-base="${process.env.BASCIK_BASE ?? '/'}"`);
</script> Client code can read the emitted data attribute and prefix a runtime URL.
Dynamic Route Context (BASCIK_ROUTE)
When a build script runs inside a dynamic route template (for example, src/pages/blog/[slug].html), Bascik populates the BASCIK_ROUTE environment variable with a JSON string containing the current route parameters and custom data payload:
<script data-bascik-build>
const { params, data } = JSON.parse(process.env.BASCIK_ROUTE || '{}');
console.log(`<h1>${data?.title || params?.slug}</h1>`);
</script> See Dynamic Routes for the complete guide to dynamic route generation, and see Environment Variables for the full reference of variables available to build scripts.
Error Handling & Stack Remapping
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.
Terminal Error Formatting
When a build script fails, Bascik prints the page file path along with the exact line and column number of the <script data-bascik-build> tag. Bascik automatically filters out internal V8 and child-process execution noise, remapping execution offsets back to your source file:
[bascik] build script error in "src/pages/about.html" at (line 14, column 3):
Error: Cannot find module 'marked'
at src/pages/about.html:18:12 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.
Configuring Error Behavior
Control failure handling using scripts.onBuildScriptError in bascik.config.ts:
'error'(default): Logs tostderrand immediately halts execution.'warn': Logs a warning tostderrand 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'
},
}; Directive Conflicts
Directives (data-bascik-build, data-bascik-routes, data-bascik-server, data-bascik-stream) are mutually exclusive and cannot be combined on the same <script> tag:
- Combining
data-bascik-buildwithdata-bascik-serverordata-bascik-streamis disallowed (a script runs at build time or request time, not both). - Combining
data-bascik-serveranddata-bascik-streamis disallowed (choose buffered or streaming execution). - Combining
data-bascik-routeswith other directives is disallowed (route scripts run independently).
Best Practices for Resilient Scripts
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 { marked } from 'marked';
export async function renderMd(filePath: string): Promise<string> {
try {
const md = await readFile(filePath, 'utf8');
return marked(md);
} catch (err) {
console.warn(`[md-renderer] Could not read ${filePath}: ${(err as Error).message}`);
return `<div class="callout"><p><strong>File not found:</strong> <code>${filePath}</code></p></div>`;
}
} Performance: Output Caching
Isolated per-script execution
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, and its own environment, so output ownership is deterministic regardless of cache temperature or neighboring misses:
- Shared helper modules are loaded fresh per script, so a helper's process-local state (counters, singletons) is never shared between siblings.
- Detached async output (
setImmediate,Promise.resolve().then, timers) is attributed to the script that scheduled it and never captured by a sibling's transport. - Output is attributed by tag index in document order. Standard stdout is buffered up to 10 MB per script run.
- Avoid calling
process.exit()inside build scripts, as it terminates the subprocess for that single script.
SHA-256 Script Caching
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:
- The authored script body before temporary-module import rewriting
- The normalized paths and contents of detected local dependencies
- The component file path (if in a component), page path, site URL (
BASCIK_SITE_URL), deployment base (BASCIK_BASE), and dynamic route parameters - Resolved package identity for every bare, scoped, and subpath package the script imports: the resolved entry file contents plus the package manifest, resolved with Node ESM semantics and walked as a bounded graph with cycle detection, so a package upgrade or an in-place edit to a workspace/
file:-linked package invalidates cached output even when the script text is unchanged
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.
Package resolution parity
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.
Invalidation Limits & Cache Exclusions
Bascik statically scans genuine relative ESM specifiers, quoted local data paths, and package imports. It cannot detect runtime dependencies such as:
- Network API calls and database queries
- Directory reads (
readdir) - Computed or dynamic file paths (e.g.
join(dir, name)or template strings) - Native modules, environment variables not tracked in the key, or other external side effects
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:
// 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/**'],
},
},
}); Declaring environment inputs
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 Rules & Limitations
- When to use build scripts: Use build scripts when content lives in a file or API outside HTML source (Markdown, JSON, CSV, remote endpoints), when repeating transformations across pages, or when markup should be static. Prefer vanilla hardcoded HTML when content is short, stable, and self-contained.
- No streaming: The full stdout of a build script is collected before injection. For streaming HTML chunks at request time, see Stream Scripts.
- No HMR awareness: Local files imported or read by a build script are tracked as dependencies; edits under the import root,
directory.pages,directory.components, orpipeline.watchPathsrebuild dependent pages. Files outside all of those need a restart or apipeline.watchPathsentry (see Watch Paths). - ESM only: Build scripts run as ES modules with standard
import/exportsyntax. Write helpers as.ts(preferred on Node 22.18+),.js, or.mjs. - Node.js only: Browser globals like
windowanddocumentare not available in build scripts.
For per-request server-side rendering, see Server Scripts.