Build-time 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 and passed its first paragraph to a component as a prop.

src/pages/build-scripts.html

Generated from Markdown

> Bascik is a build tool for HTML components. Write your components in vanilla HTML, CSS, and JavaScript. Bascik scopes and assembles them at build time, outputting vanilla HTML pages with zero JavaScript added. Supports static site generation (SSG) out of the box.

html
<script data-bascik-build>
  import { readFile } from 'node:fs/promises';
  import { marked } from 'marked';
  const md = await readFile('./content/index.md', 'utf8');
  const firstPara = md.split('\n\n')[1];
  console.log(`
    <feature-card
      data-bascik-prop-title="Generated from Markdown"
      data-bascik-prop-desc="${marked.parseInline(firstPara)}">
    </feature-card>
  `);
</script>
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.
html
<!-- The script is replaced by its stdout output -->
<div class="bascik__feature-card__fcard">
  <h3 class="bascik__feature-card__el__h3">Generated from Markdown</h3>
  <p class="bascik__feature-card__el__p">Bascik is a build tool for HTML components...</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.

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

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. Paths are relative to the project root (where you run bascik). Write output with console.log(). Runs 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.

How Error Handling Works

Build scripts run as isolated Node.js ESM modules during transpilation. When a build script throws an exception, such as a missing file (ENOENT), a syntax error, or a failed network request, Bascik intercepts the error and reports it cleanly without crashing the dev server or CLI runner.

Terminal Error Formatting & Stack Remapping

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 intercepts child-process stack traces, filters out noisy Node.js internal files, stack frames, and Command failed: headers, and remaps temporary execution files back to your source file and line offset:

text
[bascik] build script error in "src/pages/deploying.html" at (line 14, column 3):
Error: Cannot find module 'marked'
    at src/pages/deploying.html:18:12

By filtering out the noise of internal V8 loader frames and child process execution headers, you only see the stack trace that relates directly to your templates and helper scripts. In VS Code or terminal emulators, you can Cmd + Click (or Ctrl + Click) the file reference directly in the error log to jump to the exact line in your source HTML file where the script failed.

Configuring Error Behavior

You can control how script failures affect your build using the onScriptError option in bascik.config.ts:

ts
// bascik.config.ts
export default {
  onScriptError: 'error', // 'error' | 'warn' | 'halt'
};

Bascik supports three error modes:

  • 'warn' (default in dev): Logs a warning to stderr and replaces the failing script tag with an empty string so the dev server stays active while you edit.
  • 'error' (default in --build and --serve): Logs the error to stderr and throws an exception to immediately stop the build/transpilation step.
  • 'halt': Alias for 'error'. Throws an exception and stops compilation immediately.

Conflict Errors

One combination always hard-fails regardless of onScriptError: putting both data-bascik-build and data-bascik-server on the same <script> tag. A script can run at build time or at request time, but not both. Bascik throws an error with the file name and line position. The VS Code extension also highlights this as an error as you type.

Best Practices for Resilient Scripts

When your build scripts read local files or fetch remote data, wrap file and network operations in try / catch blocks. Returning fallback markup or logging a warning keeps your page layout intact even if an external resource is temporarily missing:

ts
// scripts/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>`;
  }
}

Example: Reading a Markdown File

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';

  const md = await readFile('./content/intro.md', 'utf8');
  console.log(marked(md));
</script>

Example: Generating a Nav from JSON

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

html
<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>

Example: Fetching at Build Time

Node.js includes a global fetch. Use it to pull remote data at build time so the result is baked into the page:

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

Components work inside <head> as well as <body>. This lets you extract repeated meta tags, link tags, or any other head content into a reusable component:

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>
</head>

Bascik resolves the component tag the same way it does in body content, the component HTML is substituted in place. CSS scoping and prop injection work normally.

When to Use Build Scripts

Build scripts are the right tool when:

  • Content lives in a file or API outside the HTML source (Markdown, JSON, CSV, remote endpoints).
  • You need to repeat the same transformation across multiple pages without copy-pasting logic.
  • Generated markup should be part of the static HTML output rather than rendered on the client.

Prefer plain hardcoded HTML when the content is short, stable, and doesn't come from an external source.

npm Packages

A Bascik project is a Node.js project, any npm package can be installed and used in build scripts. Install it once and import it anywhere:

sh
npm install gray-matter
html
<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>

Shared Scripts

Build scripts are just ESM modules. You can write utility functions in your project and import them across pages with no special Bascik API required:

js
// scripts/render-cards.js
import { readFile } from 'node:fs/promises';

export async function renderCards(jsonPath) {
  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');
}
html
<script data-bascik-build>
  import { join } from 'node:path';
  import { pathToFileURL } from 'node:url';
  import { renderCards } from pathToFileURL(join(process.cwd(), 'scripts/render-cards.js')).href;

  console.log(await renderCards('./content/team.json'));
</script>

Import paths in build scripts: Node.js requires absolute paths when importing local modules from a dynamically executed script. Use pathToFileURL(join(process.cwd(), 'path/to/your-script.js')).href to import your own modules reliably from any build script.

Environment Variables

Environment variables set in your shell or a .env file (loaded with a tool like dotenv) 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';
  console.log(`<meta name="api-url" content="${apiUrl}" />`);
</script>

Concurrent Execution

All build scripts on a page run concurrently. Bascik collects every <script data-bascik-build> tag at once and starts them all in parallel using Promise.all. A semaphore caps how many Node.js subprocesses are alive simultaneously based on available memory, but there is no document-order sequencing, so script 4 can finish before script 1. The outputs are stitched back into the page in their original positions once all scripts have resolved, so the HTML order is always preserved.

html
<!-- These four scripts all start at the same time. -->
<head>
  <script data-bascik-build>
    const { canonical } = await import(…);
    console.log(await canonical());        <!-- may finish 2nd -->
  </script>
  <script data-bascik-build>
    const { openGraph } = await import(…);
    console.log(await openGraph());        <!-- may finish 4th -->
  </script>
  <script data-bascik-build>
    const { breadcrumbLd } = await import(…);
    console.log(await breadcrumbLd());     <!-- may finish 1st -->
  </script>
  <script data-bascik-build>
    const { articleSchema } = await import(…);
    console.log(await articleSchema());    <!-- may finish 3rd -->
  </script>
</head>
<!-- Output is always assembled in document order regardless of finish order. -->

Script Caching

Bascik caches build script output to node_modules/.cache/bascik/script-cache/ so subsequent builds skip the Node.js subprocess entirely for unchanged scripts. The cache key is a SHA-256 hash of:

  • The script body
  • The contents of any local scripts/ or content/ files the script imports
  • The isBuild flag and siteUrl

If a dependency file changes (because you edited it or switched branches), the cache entry is invalid and the script re-runs automatically.

How dependency tracking works. Bascik does not instrument your script at runtime. Instead it statically scans the script source for quoted string literals that look like local file references, patterns matching content/*.md or scripts/*.{mjs,js,ts}, and hashes the content of each matched file into the cache key. If any of those files changes, the key changes and the cache misses. Paths computed at runtime (e.g. a variable built with string concatenation) are invisible to the scanner and will not be tracked. For those cases, set buildScriptCache: false. See Build Script Output Cache in the internals docs for the full key specification.

Cold start vs. warm restarts. The first time you start the dev server, or after clearing the cache, every script runs in full. On a site with many build scripts, this first build is noticeably slower. Once the cache is warm, restarting the dev server is much faster: scripts whose inputs haven't changed are served from disk in milliseconds instead of spawning a new Node.js process for each one.

To clear the cache manually (useful after a branch switch that changes shared scripts):

sh
rm -rf node_modules/.cache/bascik/script-cache

Disable for scripts that read external state. The cache key only covers files Bascik can watch: the script body and local scripts//content/ files. If a script fetches data from a source Bascik cannot watch, such as a live API, a database, a remote CMS, or a file referenced by a dynamic path computed at runtime, the cached output will go stale silently. Set buildScriptCache: false in your config for those scripts, or globally, so the script always runs fresh:

ts
// bascik.config.ts
export default defineConfig({
  buildScriptCache: false,
});

Limitations

  • No streaming: the full stdout of the script is collected before injection. You cannot stream HTML into the page incrementally.
  • No HMR awareness: in dev mode Bascik watches source files. If a build script reads an external file, changes to that file won't automatically re-trigger the script. Restart the dev server to re-run.
  • ESM only: Build scripts run as ES modules. Use import/export syntax; require() is not available. Write helpers as .ts (preferred on Node 22.18+), .js, or .mjs. The .mjs extension explicitly marks a file as ESM regardless of package.json settings; the "m" stands for "module." In a Bascik project with "type": "module" in package.json (the default), plain .js and .ts work identically, so .mjs is usually unnecessary.
  • Node.js only: browser globals like window and document are not available in build scripts.

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