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.

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.

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 and preserves line-offset padding in 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).

1:1 Line Number Preservation

Bascik inserts leading newline padding when scoping component <script> tags so that line numbers in the compiled output match the original component file line numbers exactly. When you set breakpoints or inspect errors, line 14 in DevTools corresponds to line 14 of your source template.

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.

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 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. Their stdout 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';
  const md = await readFile('./content/posts/intro.md', 'utf8');
  console.log(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.

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, 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.

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 preserves line-offset padding and appends //# sourceURL=src/components/name.html directives to client script blocks. 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.