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.

src/pages/build-scripts.html

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.

html
<script data-bascik-build>
  import { readFile } from 'node:fs/promises';
  import { marked } from 'marked';

  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*/, '');
    return `
      <feature-card
        data-bascik-prop-title="${title}"
        data-bascik-prop-desc="${marked.parseInline(paragraph)}">
      </feature-card>
    `;
  }
</script>
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.
html
<!-- The script tag is replaced by the returned HTML string -->
<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 exports a default callable function that returns an HTML string, which replaces the tag in the final HTML.

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):

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 a promise resolving to a string) 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.

Handler Contract & Console Logging

A build script defines its output through a callable default export:

html
<script data-bascik-build>
  export default async function () {
    console.log('Rendering static content at build time');
    return '<p>Generated content</p>';
  }
</script>
  • Callable default export: The module must provide a default export that is a callable function (function, async function, or arrow function). Bascik invokes it with zero arguments.
  • Return value: The function must return a string or a promise resolving to a string. Returning undefined, null, numbers, or objects throws an error.
  • Empty output: To render nothing, return an explicit empty string return ''.
  • Console logging: console.log() and console.error() output travels directly to the terminal for diagnostic logging. Bascik transfers returned HTML through a separate isolated transport, so logs never leak into generated markup.
  • External scripts: Scripts using src="..." follow the identical contract: the referenced file must have a callable default export returning HTML markup.
  • Cached execution: When a script's output is cached, neither the module initialization nor the default export runs on a cache hit, so logs do not replay.

Example Patterns

Reading Markdown

A common pattern is converting Markdown content to HTML at build time:

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:

An editor above a browser. The editor shows content/notes.md. Changing today to on Friday and saving updates the paragraph in the browser. Adding a second paragraph and saving adds it to the page

Generating Navigation from JSON

Read a JSON data file and render HTML markup from it:

html
<script data-bascik-build>
  import { readFile } from 'node:fs/promises';

  export default async function () {
    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');
    return `<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:

html
<script data-bascik-build>
  const escape = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

  export default async function () {
    const res = await fetch('https://api.example.com/posts/latest');
    const { title, excerpt } = await res.json();
    return `<h2>${escape(title)}</h2><p>${escape(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.

Passing Data to Client Scripts (JSON Hydration)

A build script must return a string (not a raw 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:

html
<!-- src/pages/products.html -->
<script data-bascik-build>
  import { readFile } from 'node:fs/promises';

  export default async function () {
    const raw = await readFile('./content/products.json', 'utf8');
    const catalog = JSON.parse(raw);

    // Escape < to prevent closing script tags in JSON strings from breaking out
    const safeJson = JSON.stringify(catalog).replaceAll('<', () => '\\u003c');
    return `<script type="application/json" id="catalog-data">${safeJson}<\/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. Always escape < as \u003c in the serialized JSON to prevent strings containing </script> from breaking out of the tag. 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.

Printing HTML You Did Not Write

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:

  • An ordinary <script>, an onerror handler, or a javascript: link runs in every visitor's browser.
  • A component tag such as <site-header> expands into your component.

Pass such HTML through an allowlist sanitizer (for example sanitize-html) before printing it, and escape plain text.

Directive scripts are the exception Bascik handles itself. A <script data-bascik-build>, <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:

terminal
[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.

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:

html
<!-- 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" />
html
<!-- 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';
    export default () => `<meta name="api-url" content="${apiUrl}" />`;
  </script>
</head>

Imports and npm Packages

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:

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.

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:

html
<script data-bascik-build>
  const apiUrl = process.env.API_URL ?? 'https://api.example.com';
  export default () => `<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":

html
<script data-bascik-build>
  export default function () {
    if ('BASCIK_SITE_URL' in process.env) {
      return `<link rel="canonical" href="${process.env.BASCIK_SITE_URL}/about" />`;
    }
    return '';
  }
</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:

html
<!-- Emit the base once, at build time, into a data attribute. -->
<script data-bascik-build>
  export default () => `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:

html
<script data-bascik-build>
  export default function () {
    const { params, data } = JSON.parse(process.env.BASCIK_ROUTE || '{}');
    return `<h1>${data?.title || params?.slug}</h1>`;
  }
</script>

Route data can be as large as the routes script prints, such as a whole post body. Payloads over 32 KB reach the script through a temporary file instead of the process environment, and process.env.BASCIK_ROUTE reads the same either way.

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.

In the dev server, the error also appears as an overlay in every open browser, and saving the fix dismisses it:

An editor above a browser. A typo in a build script, String changed to Strin, is saved and the browser shows a Bascik Build Error overlay naming the file and the ReferenceError. Restoring the letter and saving dismisses the overlay

Terminal Error Formatting

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:

text
[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:

text
[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.

Configuring Error Behavior

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.
ts
// 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-build with data-bascik-server or data-bascik-stream is disallowed (a script runs at build time or request time, not both).
  • Combining data-bascik-server and data-bascik-stream is disallowed (choose buffered or streaming execution).
  • Combining data-bascik-routes with 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:

ts
// 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 streams, and its own environment, so execution isolation 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.
  • Returned HTML results travel across an isolated result transport. Child process stdout and stderr streams are streamed directly to terminal logs up to 10 MB per script run.
  • Avoid calling process.exit() inside build scripts, as it terminates the subprocess for that single script.
  • Children share Node's on-disk compile cache in 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.

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
  • The contents of the source and page files, when the script or a local helper it imports names BASCIK_SOURCE_FILE or BASCIK_PAGE_FILE, so a helper that reads its own page re-runs when the page changes
  • 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.

How a build script is cached
  1. Hash the inputs Script body, local files it imports, page, site URL, base, route, and each imported package's entry file and manifest.
  2. Look up the hash node_modules/.cache/bascik/script-cache/ holds one entry per hash.
  3. Hit: reuse the HTML No child process starts. Unchanged scripts cost almost nothing.
  4. Miss: run and store The script runs in a fresh Node.js process and its output is saved.

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:

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.

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:

ts
// 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:

sh
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 complete HTML string returned by the default export is awaited 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, or pipeline.watchPaths rebuild dependent pages. Files outside all of those need a restart or a pipeline.watchPaths entry (see Watch Paths).
  • ESM only: Build scripts run as ES modules with standard import/export syntax and a callable default export. Write helpers as .ts (preferred on Node 22.18+), .js, or .mjs.
  • Node.js only: Browser globals like window and document are not available in build scripts.
  • Printed directives never run: A directive script in a build script's output is removed with a warning. Directives only run where they are written in a source file. See Printing HTML You Did Not Write.

For per-request server-side rendering, see Server Scripts.