Scoped JavaScript

Write <script> tags directly in your component HTML. Bascik automatically rewrites selector strings to match each instance's unique identifiers. Multiple instances of the same component on the same page stay completely independent. TypeScript is supported too. See TypeScript in Component Scripts.

See it in action

These two counters are independent. Change either count and the other stays put.

src/components/demo-counter/
Counter A 0
Counter B 0
html
<demo-counter data-bascik-prop-label="Counter A"></demo-counter>
<demo-counter data-bascik-prop-label="Counter B"></demo-counter>
html
<div class="ctr">
  <span class="ctr-label" data-bascik-prop-label>Counter</span>
  <span class="ctr-count" id="count">0</span>
  <div class="ctr-btns">
    <button class="ctr-dec" id="dec">−</button>
    <button class="ctr-inc" id="inc">+</button>
  </div>
</div>
css
.ctr {
  background: #242628;
  border: 1px solid #3a3d40;
  border-radius: 10px;
  padding: 24px 20px;
  transition: border-color .2s;
}
.ctr:hover { border-color: rgba(211,255,141,0.35); }
.ctr-label {
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .1em;
  color: #8d929e;
}
.ctr-count {
  font-family: monospace;
  font-size: 2.4rem;
  font-weight: 700;
  color: #d3ff8d;
  line-height: 1;
}
.ctr-dec, .ctr-inc {
  width: 40px; height: 40px;
  border-radius: 6px;
  font-size: 1.1rem;
  cursor: pointer;
  border: 1px solid #3a3d40;
  background: #1e2022;
  color: #f0f1f2;
}
.ctr-inc {
  background: rgba(211,255,141,0.12);
  border-color: rgba(211,255,141,0.3);
  color: #d3ff8d;
  font-weight: 700;
}
ts
const count = document.getElementById('count') as HTMLElement;
const dec   = document.getElementById('dec') as HTMLButtonElement;
const inc   = document.getElementById('inc') as HTMLButtonElement;
let n: number = 0;
dec.addEventListener('click', () => { n--; count.textContent = String(n); });
inc.addEventListener('click', () => { n++; count.textContent = String(n); });
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.
html
<div class="bascik__demo-counter__ctr">
  <span class="bascik__demo-counter__ctr-label">Counter A</span>
  <span class="bascik__demo-counter__ctr-count"
        id="bascik__demo-counter__a1b2__count">0</span>
  <div class="bascik__demo-counter__ctr-btns">
    <button class="bascik__demo-counter__ctr-dec"
            id="bascik__demo-counter__a1b2__dec">−</button>
    <button class="bascik__demo-counter__ctr-inc"
            id="bascik__demo-counter__a1b2__inc">+</button>
  </div>
</div>
css
.bascik__demo-counter__ctr {
  background: #242628;
  border: 1px solid #3a3d40;
  border-radius: 10px;
  padding: 24px 20px;
  transition: border-color .2s;
}
.bascik__demo-counter__ctr-count {
  font-family: monospace;
  font-size: 2.4rem;
  font-weight: 700;
  color: #d3ff8d;
}
/* class names are shared across all instances of demo-counter */
js
(function() {
  const count = document.getElementById("bascik__demo-counter__a1b2__count");
  const dec   = document.getElementById("bascik__demo-counter__a1b2__dec");
  const inc   = document.getElementById("bascik__demo-counter__a1b2__inc");
  let n = 0;
  dec.addEventListener("click", () => { n--; count.textContent = n; });
  inc.addEventListener("click", () => { n++; count.textContent = n; });
})();

Writing Component Scripts

Key rule. Use id attributes to identify elements you need to control in JS, and getElementById to find them. Bascik scopes each instance's id values uniquely, so every call returns exactly the right element.

A component script looks like ordinary JavaScript. Declare id on any element you need to reference, then use getElementById. Bascik rewrites the strings at build time:

Open Source → JS in the counter demo to see ordinary event listeners and getElementById calls. Output → JS shows the rewritten selectors and instance wrapper that Bascik generates.

Multiple Script Blocks

Component templates can contain multiple <script> tags. Bascik processes each script tag according to its attributes:

  • Client scripts: Standard JavaScript blocks are each wrapped in an isolated IIFE (function() { ... })(); when scoping.scriptBlocks is enabled. If you include multiple client <script> tags in a single component, each runs in its own IIFE so local variables do not collide.
  • Local script files (<script src="counter.ts">): References to local .ts, .js, or .mjs files inside the component's directory are resolved and inlined at build time, then scoped and wrapped as client scripts.
  • Build scripts (<script data-bascik-build>): Executed during build/dev time in Node.js to generate dynamic markup. Can also use src="..." to run a local script file.
  • Server scripts (<script data-bascik-server>): Executed on the server at request time in Node.js. Can also use src="..." to run a local script file.
  • Streaming scripts (<script data-bascik-stream>): Executed on the server at request time in Node.js with chunked HTTP streaming. Can also use src="..." to run a local script file.
  • Data scripts (e.g. type="application/ld+json"): Left untouched without IIFE wrapping or minification.

Directory Isolation Rule: Local src="..." script references are strictly confined to the component's own folder. A component in src/components/demo-counter/ can only reference script files inside src/components/demo-counter/. It cannot access files across other component directories. Recommended Pattern: Keeping separate, unrelated logic in dedicated <script> tags (such as one script block for form validation and another for UI animation) is recommended for clean, readable code. You don't need to break code into tiny scripts arbitrarily, but isolating independent concerns into separate script blocks keeps your component's JavaScript organized and prevents variable name collisions.

Multiple Instances

Place the same component on a page more than once and each instance runs independently with no extra work needed:

The two counters at the top of this page are the same component. Change either count, then inspect Output → HTML and Output → JS to compare their unique instance IDs.

For a larger case, the Tab Group example builds a tab list at run time and derives every tab and panel ID from its scoped root ID, so several groups on one page never collide.

Where Component Scripts Run

A component script is an inline classic script, and where it sits in the page depends on the mode:

  • Production (bascik --build). HTML minification (minify.html, on by default) moves component scripts to the end of the document, just before </body>, in document order. Every element in the page exists when they run, even when the component is nested inside <main> or another container.
  • Development server, or minify.html: false. Each script stays directly after its component's markup. An element that comes later in the document does not exist yet when the script runs.

Write scripts that behave the same in both. Use getElementById for the component's own elements, which are always earlier in the document. Look up any other element inside an event handler, or inside a DOMContentLoaded listener. A lookup at the top of the script can succeed in production and return null in development.

There is no unmount step. A link loads a new document, so a script that must react to a page restored from the browser's back/forward cache listens for pageshow.

Where a component script lands

Development server: the script stays after its component

html
<div id="panel">...</div>
<script>/* runs here */</script>
<p id="later">Not parsed yet</p>

Production build: the script moves to the end of the body

html
<div id="panel">...</div>
<p id="later">...</p>
<script>/* runs here, after every element */</script>
</body>

HTML ID References

Bascik keeps HTML relationships synchronized with scoped IDs. References such as <label for>, ARIA ID attributes, fragment-only links, SVG fragment attributes, and inline style="...url(#id)" values are rewritten automatically when the target ID is declared in the same component.

html
<label for="email">Email</label>
<input id="email" aria-describedby="email-help">
<p id="email-help">Use your work address.</p>

Each component instance receives its own scoped IDs, and every local reference points to the matching target in that instance. With identifier minification enabled, references reuse the exact declaration hash.

References that do not resolve in the same component remain byte-identical. This preserves component links to literal page-shell IDs, but a reference to an ID declared by another component cannot be scoped safely because that target is instance-specific. Keep related declarations and references in one component, move the target to the page shell, or use Preserve Scoping when an external system owns the literal ID.

usemap is the exception to the ID rule: <img usemap="#choices"> points to <map name="choices">, so it is rewritten only when name scoping is enabled.

References follow the id they point at

You write

html
<label for="email">Email</label>
<input id="email" aria-describedby="email-help">
<p id="email-help">Use your work address.</p>

Bascik emits

html
<label for="bascik__signup-form__a1b2c3d4__email">Email</label>
<input id="bascik__signup-form__a1b2c3d4__email"
  aria-describedby="bascik__signup-form__a1b2c3d4__email-help">
<p id="bascik__signup-form__a1b2c3d4__email-help">Use your work address.</p>

Pitfall: Class-Based DOM Lookups

Because class names are shared across all instances of the same component (for CSS deduplication), document.querySelector('.my-class') always returns the first matching element in the document. When the same component appears more than once, every instance's script ends up targeting the first instance's elements.

Use id + getElementById instead:

html
<!-- Broken for multiple instances - querySelector returns first instance's button -->
<button class="my-btn">Click</button>
<script>
  document.querySelector('.my-btn').addEventListener('click', () => { … });
</script>
html
<!-- Correct - id is scoped per instance -->
<button id="my-btn" class="my-btn">Click</button>
<script>
  document.getElementById('my-btn').addEventListener('click', () => { … });
</script>

The class attribute can still coexist on the same element for styling, just add an id for the JS lookup.

Rule of thumb. Use getElementById (or getElementsByName) for elements you need to control per-instance. Reserve querySelector/querySelectorAll for cases where you intentionally want to sweep across all instances.

Source Maps and DevTools Debugging

When you open browser DevTools (F12 or Cmd + Option + I / Ctrl + Shift + I), Bascik provides zero-overhead source mapping without generating external .map files:

Virtual Source Files in DevTools (//# sourceURL)

Bascik automatically appends a //# sourceURL=src/components/name.html directive to every component <script> block. In browser DevTools under the Sources (or Debugger) panel, your component scripts appear as virtual files matching your project folder structure (for example, src/components/card.html).

Each instance of a component on a page is its own file, because each instance's script has its own scoped IDs. The first instance is card.html, the second is card.html?instance=2, and so on. A component used once keeps the plain file name.

1:1 Line Number Preservation

Bascik pads each component script with blank lines so that line numbers in DevTools match the component file exactly, including when markup or <style> blocks sit above the script. When you set breakpoints or inspect errors, line 14 in DevTools corresponds to line 14 of your source template. A companion script (<script src="card.ts">) is numbered from line 1 of its own file.

Line numbers match when JavaScript is not minified, which is the default for the dev server. bascik --build minifies JavaScript by default, which joins lines; set minify.js: false to keep source line numbers in a build.

Setting Breakpoints and Inspecting State

Because component scripts appear as virtual source files in DevTools:

  1. Open the Sources panel in DevTools and press Cmd + P (or Ctrl + P).
  2. Search for your component file (for example, card.html).
  3. Click any line number to set a breakpoint, or place a debugger; statement directly inside your component <script>.
  4. Interact with the component in your browser to pause execution, inspect variables, and step through code.

A breakpoint pauses only the instance whose file it was set in. To pause every instance, write a debugger; statement in the component script instead.

A browser showing the starter's two counters above Chrome DevTools. Quick Open lists my-counter.html and my-counter.html?instance=2. my-counter.html opens at line 43, val.textContent = ++count, and a breakpoint is set there. Clicking the first counter's plus button pauses on line 43 with the call stack at my-counter.html:43, and resuming sets the count to 1

Accurate Console Stack Traces

When console.log() runs or an uncaught exception occurs inside a component script, the DevTools Console panel attributes the message directly to src/components/card.html:18 (or card.html?instance=2:18 for a second instance) instead of pointing to a line in the generated page HTML. The reported line number matches the line in your original source HTML file.

Dynamic Runtime Classes

If a class is only toggled at runtime (classList.toggle("is-open")) but doesn't appear on any element in the template HTML, Bascik's compiler won't register it. The CSS side minifies the name but the JS side doesn't, causing a silent mismatch at runtime.

The toggle below registers is-open on a hidden element, then safely applies that scoped class at runtime.

src/components/state-toggle/

Panel closed

Only this component instance changes.
html
<state-toggle />
html
<section class="state-toggle">
  <div class="is-open" hidden></div>
  <p id="status">Panel closed</p>
  <button id="toggle" type="button">Toggle panel</button>
  <div class="state-toggle-panel" id="panel">Only this component instance changes.</div>
</section>
css
.state-toggle-panel { opacity: 0.55; }
.state-toggle-panel.is-open {
  border-color: #d3ff8d;
  opacity: 1;
}
js
const status = document.getElementById('status');
const toggle = document.getElementById('toggle');
const panel = document.getElementById('panel');

toggle.addEventListener('click', () => {
  const isOpen = panel.classList.toggle('is-open');
  status.textContent = isOpen ? 'Panel open' : 'Panel closed';
});
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.
html
<section class="bascik__state-toggle__state-toggle">
  <div class="bascik__state-toggle__is-open" hidden></div>
  <p id="bascik__state-toggle__a1b2__status">Panel closed</p>
  <button id="bascik__state-toggle__a1b2__toggle" type="button">Toggle panel</button>
  <div class="bascik__state-toggle__state-toggle-panel"
       id="bascik__state-toggle__a1b2__panel">Only this component instance changes.</div>
</section>
js
const status = document.getElementById('bascik__state-toggle__a1b2__status');
const toggle = document.getElementById('bascik__state-toggle__a1b2__toggle');
const panel = document.getElementById('bascik__state-toggle__a1b2__panel');

toggle.addEventListener('click', () => {
  const isOpen = panel.classList.toggle('bascik__state-toggle__is-open');
  status.textContent = isOpen ? 'Panel open' : 'Panel closed';
});

Supported Selectors

All of the following DOM methods are rewritten when they reference a scoped attribute:

js
// id attribute
document.getElementById("my-id")
document.querySelector("#my-id")
document.querySelectorAll("#my-id")

// class attribute
document.getElementsByClassName("my-class")
document.querySelector(".my-class")
document.querySelectorAll(".my-class")

// name attribute
document.getElementsByName("my-name")

Additional rewritten forms include element.closest(), element.matches(), element.classList.add/remove/toggle/contains(), element.setAttribute("class", …) and element.setAttribute("id", …) with string literal values, and element.className setter forms.

Build-time Scripts

<script data-bascik-build> blocks run at transpile time as Node.js ESM modules. The script exports a default callable function returning an HTML string, which replaces the tag in the page, useful for pulling in Markdown, JSON, or other external content 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/posts/intro.md', 'utf8');
    return marked(md);
  }
</script>

Component tags work too. Build script output is processed in the component resolution step, so its output can contain Bascik component tags. Directive scripts in the output (data-bascik-build, data-bascik-server, data-bascik-stream, data-bascik-routes) are removed with a warning and never run; see Printing HTML You Did Not Write.

Non-JavaScript Script Types

Script tags with a type other than text/javascript are left completely untouched, no IIFE, no scoping:

html
<script type="application/json" id="config-data">
  { "theme": "dark" }
</script>

Tip: Disable JS scoping entirely by setting scoping.scriptBlocks to false in bascik.config.ts.

TypeScript in Component Scripts

Bascik ships vanilla JavaScript to the browser and never emits raw TypeScript on either of its two supported browser TypeScript paths. No configuration is required.

Companion .ts files (the default). Reference a .ts or .mts file from the component and Bascik strips the types when it inlines the script:

html
<span id="count">0</span>
<script src="counter.ts"></script>
ts
// src/components/counter/counter.ts
const count = document.getElementById('count') as HTMLElement;
let n: number = 0;
count.addEventListener('click', () => { n++; count.textContent = String(n); });

Inline blocks marked as TypeScript. For a short inline script, opt in with the text/typescript MIME type. Bascik strips the types and emits an ordinary <script>; the type attribute is removed in the output:

html
<script type="text/typescript">
  const count = document.getElementById('count') as HTMLElement;
  let n: number = 0;
  count.textContent = String(n);
</script>

Ordinary <script> blocks are JavaScript. Browsers do not execute TypeScript in an unmarked <script>, so Bascik leaves those blocks byte-for-byte on the JavaScript path and never changes their meaning. When it can positively identify erasable TypeScript syntax in one, the build logs a warning that names the file and points to the two supported paths above. Valid JavaScript is never rewritten or warned about.

The pipeline order is fixed: TypeScript strip, then scoping (IIFE wrapping, selector rewriting, //# sourceURL), then optional minify.js. The minifier only ever receives valid JavaScript, so minify.js: true and custom minifiers such as esbuild or terser work unchanged on TypeScript-authored components. The strip preserves line structure, so DevTools line numbers still match the .ts source.

Erasable syntax only by default. The default compiler is Node's strip-only TypeScript mode (Node 22.18+), which removes annotations, interfaces, type aliases, as casts, ! non-null assertions, and import type. Non-erasable syntax (enum, parameter properties, namespaces with runtime code) fails the build with the offending file path. Use the erasable equivalents (as const objects instead of enum), or plug in your own compiler.

Module-shaped .ts needs type="module". Stripping types never removes import/export declarations, they are valid JavaScript on their own. A classic <script src="counter.ts"> (no type attribute) is scoped and IIFE-wrapped, and import/export are illegal inside a classic script, so the build fails fast with an actionable error naming the file. Fix it one of two ways: add type="module" to the referencing <script> tag (native ES modules are never IIFE-wrapped, so module syntax is left alone), or bundle the file first with esbuild, Rolldown, swc, or another tool so no import/export remains. This applies to inline type="text/typescript" blocks the same way, since those are always classic scripts.

Need to bundle client dependencies? Bascik intentionally leaves third-party client module bundling to dedicated tools. See the how-to guide on Bundling npm Packages for setting up esbuild or Rolldown with pipeline.exec.

Bring your own compiler. Set scripts.typescript to a function to run esbuild, swc, or tsc instead of Node's stripper. It receives (code, { sourcePath, kind }) and returns JavaScript, and it sits at the same point in the pipeline, so scoping and minify.js still run afterwards. This is the path for enum, decorators, or downleveling to an older browser target. Set it to false to turn the transform off entirely when another tool already compiles what Bascik reads.

minify.js is not a TypeScript transform. Never configure minify.js merely to strip TypeScript. Bascik strips supported browser TypeScript automatically before this hook runs, so a minify.js function always receives plain JavaScript. Add a custom minify.js function only when the project requires non-default JavaScript minification.

How Scoping Works

Every component <script> is wrapped in an IIFE to prevent variable leaks, and every selector string that references a scoped attribute is rewritten to match:

Bascik also pads client script blocks to their source line and appends //# sourceURL=src/components/name.html directives to them. In browser DevTools, runtime errors and console statements point directly to the original component file and line number.

The counter demo's Output tabs show the complete compiled HTML, CSS, and JavaScript together. The runtime-class demo adds a focused view of classList.toggle() rewriting.

The scoping format:

  • class → bascik__<componentName>__<originalName> (shared across all instances)
  • id / name → bascik__<componentName>__<instanceId>__<originalName> (unique per instance)

Under the hood. Read the Scoping System internals for the compiler passes, attribute maps, selector rewriting, and CSS deduplication that produce this output.