Internals
Transpilation Pipeline
Bascik transforms source HTML into deployable HTML by replacing every custom component tag with its resolved, scoped content. The pipeline runs in two nested phases: the page phase and the component phase.
Overview
Every time a source page or component file changes, pageProcessing(filePath) in processing.ts is called. This is the top-level entry point for the whole pipeline.
Build scripts (<script data-bascik-build>) run first, before component resolution, so their output can contain component tags. Body HTML minification runs after component resolution so that whitespace-sensitive content from resolved components (e.g. <pre> blocks from <code-block>) is preserved intact.
Multi-Page Startup: processAllPages
On startup (and whenever a component is added), the watch system calls processAllPages() instead of invoking pageProcessing() once per file. This avoids redundant I/O:
- Hoist shared computation.
listComponents()andresolveInlineStylesHtml()each run once, in parallel, before any page is processed. The results are passed to every page rather than re-computed per page. - Transpile each page. By default, pages are transpiled sequentially on the main thread. If
useWorkers: trueis set inbascik.config.ts, aWorkerPoolis created instead withMath.min(os.cpus().length, pageCount)workers, and each worker is initialized with the sharedcomponentListandglobalStylesHtmlviaworkerData. The main thread dispatches page paths through the pool's queue and awaits all results. Worker startup has a fixed cost (each worker loads the transpiler's module graph independently), so this only pays off for larger sites or CPU-heavy per-page work, see theuseWorkersconfig option. - Apply side effects on the main thread. After transpilation completes, the main thread runs
mem.storePage()and emits the"transpiled"event for each result. Brotli compression insidestorePage()runs in the background and does not block the page from being marked ready or served. - Write to disk only in build mode. In dev mode, pages are served entirely from the in-memory store, no
dist/writes happen, so the server is ready as soon as memory is populated.
Phase 1: Page Phase (pageProcessing)
The page phase prepares the source HTML document and orchestrates the component phase:
- Execute build scripts. Any
<script data-bascik-build>blocks are run as Node.js ESM modules. Their stdout replaces the script tag. The result can contain component tags, these will be resolved in step 4. Output is cached on disk so unchanged scripts skip the child-process spawn on subsequent builds (see Build Script Output Cache below). - Extract body and head. The inner content of
<body>and<head>are extracted separately so component injection can happen in both zones independently. - Obtain component list. On the multi-page startup path (
processAllPages), the list is pre-computed once and passed in. On a single-page re-transpilation, it is loaded fromsrc/components/at this point. - Run component phase.
recursivelyTranspileis called on both the body and head HTML strings. Each call returns aTranspileResultcontaining the resolved HTML and the list of components that were used. - Collect and deduplicate CSS. All CSS from used components is gathered. Since multiple instances of the same component share identical scoped class names,
deduplicateCssemits a single<style>block regardless of how many times a component appears on the page. Any global stylesheets configured viainlineStylesare also injected into<head>at this stage. - Inject live-reload script. In dev mode only, a small
<script>that opens a Server-Sent Events connection to/bascik-live-reloadis appended to the body. - Minify. HTML comments are stripped and excess whitespace is collapsed via
minifyHtml. This runs after component resolution so that whitespace-sensitive content inside resolved components (e.g.<pre>blocks from<code-block>) is preserved intact. - Reassemble HTML. The resolved body and head are placed back into the original HTML document structure.
- Write output. In build mode, the finished HTML is written to
dist/. In dev mode, no disk write occurs, the result is stored in the in-memory page store so the HTTP/2 server can serve it instantly. - Emit transpiled event.
eventEmitter.emit("transpiled")triggers live-reload for any connected browser.
Incremental Disk Writes and dist/ Persistence
Build mode (bascik --build) writes transpiled files incrementally into dist/. It does not clear or delete the dist/ directory before populating it.
If source files are deleted from src/pages/ between separate build runs, their previously compiled output in dist/ remains until dist/ is cleaned manually. In dev mode (bascik), the active file watcher listens for deletion events (unlink and unlinkDir) and removes corresponding files from dist/ dynamically during the dev session.
Build Script Output Cache
Every <script data-bascik-build> block spawns a fresh Node.js child process, which carries a ~50–150 ms V8 startup cost even for a trivial script. On a site with many pages and many build-script blocks this cost dominates total build time. The cache eliminates that cost for scripts whose inputs have not changed.
Location
Cache entries live under node_modules/.cache/bascik/script-cache/ as individual JSON files named by their cache key:
node_modules/.cache/bascik/script-cache/<sha256>.jsonEach file contains { "v": <version>, "output": "<html>" }. The v field is a hard-coded integer in build-scripts.ts; bumping it at the source level immediately invalidates every existing entry across all projects.
Cache key
The key is the SHA-256 hex digest of:
- The cache version integer.
- The trimmed script content.
"1"or"0"for build vs. dev mode (isBuild), since the same script may produce different output in each mode via theBASCIK_BUILDenv var.- The current page file path (
BASCIK_PAGE_FILE). This is critical: scripts likecanonical.tsandopen-graph.tshave identical content on every page but useprocess.env.BASCIK_PAGE_FILEto produce page-specific output (different URLs). Without this component the cache would return the first page's output for every subsequent page. - The site URL (
BASCIK_SITE_URL), since it can influence output and changes rarely. - The full content of every local file the script references, concatenated in order.
File references are extracted by extractScriptDeps() (exported from build-scripts.ts), which scans the script source for quoted path literals matching content/*.md or scripts/*.{mjs,js,ts} patterns:
'./content/foo.md' → included in key
'scripts/md-renderer.ts' → included in keyIf the script contains no detectable references, only items 1–5 contribute to the key.
Invalidation
Because the content of every referenced file is hashed into the key, editing a content file produces a new key for any script that references it, giving a cache miss. Scripts on other pages that do not reference that file keep their old keys and continue to hit the cache.
To bust the entire cache manually, for example after upgrading marked or another build-time dependency that scripts/*.{mjs,js,ts} files import, delete the cache directory:
rm -rf node_modules/.cache/bascik/script-cacheInteraction with useWorkers
When useWorkers: true is set, worker threads share the same filesystem and therefore the same cache directory. On the first (cold) build, multiple workers may independently get a cache miss for the same script, spawn child processes, and write the same entry. Because every worker writes the same content for the same key, the last write wins harmlessly. On subsequent builds all workers benefit from the cached entries.
Phase 2: Component Phase (recursivelyTranspile)
The component phase recurses until no custom component tags remain in the HTML string. On each pass it finds the first component tag, fully resolves it, and substitutes it. It then repeats until no more tags are found.
Raw-text masking
Component tags are only markup outside of raw-text elements. Text like <my-card> inside a <script> (for example a JSON-LD string), a <style> comment, or a <textarea> is content, not a component usage, and resolving it there would inject component markup, including a stray </script>, into the middle of script content and corrupt the page.
To prevent this, every tag search runs against a masked copy of the HTML produced by maskRawTextContent. The mask replaces the inner content of <script>, <style>, and <textarea> elements with an equal number of spaces, so the masked string has exactly the same length as the original. Searches (getFirstComponent, findOpenTag, the self-closing fallbacks, and the findMatchingClose depth counter) find indices in the masked string, and those indices are then used to slice and splice the original string. The same masking idea is used by the unresolved-tag warning scanner, which strips raw-text content before looking for leftover hyphenated tags.
For each component tag found:
Step 1: Scoping pipeline
A fresh instanceId (a random 8-byte hex string) is generated for this occurrence of the component. An ordered list of transform functions is assembled and applied in a pipeline (each step receives the output of the previous):
prefixElementAttribute(c, "id", instanceId): scopesidattributes and all corresponding JS DOM selector references.prefixElementAttribute(c, "name", instanceId): scopesnameattributes andgetElementsByNamecalls.prefixElementAttribute(c, "class", instanceId): scopes class names in HTML attributes, CSS, and JS selector calls.namespaceScriptTags(c): wraps every inline<script>in an IIFE, preserves line positioning, and appends a//# sourceURLcomment for browser DevTools source attribution.
Each step is skipped if disabled in bascik.config.ts.
Step 2: Template resolution
- Props.
injectPropsreplaces everydata-bascik-prop-*placeholder in the component template with the corresponding attribute value from the usage tag. - Named slots.
replaceNamedSlotsfills eachdata-bascik-slot="name"zone in the template with the matching<div data-bascik-slot="name">content from the usage site. - Default slot. The inner content of the usage tag is placed into the element carrying
data-bascik-slot(no value). If the usage tag has no inner content, the template's fallback content is preserved. - Attribute inheritance.
mergeAttributesOntoRootcopies pass-through attributes (aria-*,data-*,class, etc.) from the usage tag onto the component's root element. If the component template contains multiple root elements (or leading comments,<script>, or<style>blocks), attributes are merged onto the first root HTML element.
Step 3: Substitution
replaceTag replaces the original usage tag in the parent HTML string with the fully resolved component HTML. The outer loop runs again on the updated string. Because component templates can themselves contain other component tags, this naturally handles any depth of nesting.
Termination
The recursion terminates when getFirstComponent no longer finds any custom tag in the HTML string, i.e., when all recognized component names have been replaced with vanilla HTML.
Performance note: Each call to recursivelyTranspile uses the same in-memory ComponentList built once at the start of the pipeline. In the multi-page startup path, this list is pre-computed once and passed to every worker via workerData, components are never re-read from disk per page or per worker.
Selective Re-transpilation
When a component file changes during dev, Bascik does not reprocess every page. The memory store maintains a reverse index mapping each component name to the set of pages that use it. selectivelyProcessPages uses this index to retranspile only the affected pages.
Scoped Name Format
All scoped attribute names follow this pattern:
bascik__<componentName>__<instanceId>__<originalName>
# id and name attributes include instanceId for uniqueness:
bascik__my-nav__a1b2c3d4__search-input
# class attributes use component name only (no instanceId):
bascik__my-nav__toggle-btnWhen minify.identifiers is enabled (the default for builds), each full scoped name is hashed to a short alphanumeric string using SHA-256 with Base62 encoding before being written to the output, e.g. b2Y4G9eD1K8b. See Scoping System for full details.