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:

  1. Hoist shared computation. listComponents() and resolveInlineStylesHtml() each run once, in parallel, before any page is processed. The results are passed to every page rather than re-computed per page.
  2. Transpile each page. By default, pages are transpiled sequentially on the main thread. If useWorkers: true is set in bascik.config.ts, a WorkerPool is created instead with Math.min(os.cpus().length, pageCount) workers, and each worker is initialized with the shared componentList and globalStylesHtml via workerData. 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 the useWorkers config option.
  3. 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 inside storePage() runs in the background and does not block the page from being marked ready or served.
  4. 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:

  1. 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).
  2. Extract body and head. The inner content of <body> and <head> are extracted separately so component injection can happen in both zones independently.
  3. 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 from src/components/ at this point.
  4. Run component phase. recursivelyTranspile is called on both the body and head HTML strings. Each call returns a TranspileResult containing the resolved HTML and the list of components that were used.
  5. Collect and deduplicate CSS. All CSS from used components is gathered. Since multiple instances of the same component share identical scoped class names, deduplicateCss emits a single <style> block regardless of how many times a component appears on the page. Any global stylesheets configured via inlineStyles are also injected into <head> at this stage.
  6. Inject live-reload script. In dev mode only, a small <script> that opens a Server-Sent Events connection to /bascik-live-reload is appended to the body.
  7. 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.
  8. Reassemble HTML. The resolved body and head are placed back into the original HTML document structure.
  9. 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.
  10. 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:

text
node_modules/.cache/bascik/script-cache/<sha256>.json

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

  1. The cache version integer.
  2. The trimmed script content.
  3. "1" or "0" for build vs. dev mode (isBuild), since the same script may produce different output in each mode via the BASCIK_BUILD env var.
  4. The current page file path (BASCIK_PAGE_FILE). This is critical: scripts like canonical.ts and open-graph.ts have identical content on every page but use process.env.BASCIK_PAGE_FILE to produce page-specific output (different URLs). Without this component the cache would return the first page's output for every subsequent page.
  5. The site URL (BASCIK_SITE_URL), since it can influence output and changes rarely.
  6. 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:

text
'./content/foo.md'          → included in key
'scripts/md-renderer.ts'  → included in key

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

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

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

  1. prefixElementAttribute(c, "id", instanceId): scopes id attributes and all corresponding JS DOM selector references.
  2. prefixElementAttribute(c, "name", instanceId): scopes name attributes and getElementsByName calls.
  3. prefixElementAttribute(c, "class", instanceId): scopes class names in HTML attributes, CSS, and JS selector calls.
  4. namespaceScriptTags(c): wraps every inline <script> in an IIFE, preserves line positioning, and appends a //# sourceURL comment for browser DevTools source attribution.

Each step is skipped if disabled in bascik.config.ts.

Step 2: Template resolution

  1. Props. injectProps replaces every data-bascik-prop-* placeholder in the component template with the corresponding attribute value from the usage tag.
  2. Named slots. replaceNamedSlots fills each data-bascik-slot="name" zone in the template with the matching <div data-bascik-slot="name"> content from the usage site.
  3. 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.
  4. Attribute inheritance. mergeAttributesOntoRoot copies 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:

text
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-btn

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