# Asset Pipeline Plan Design for letting thor users bring their own CSS toolchain (Sass, PostCSS, Lightning CSS, Tailwind, …). ## Thesis Let thor users bring their own CSS toolchain by **orchestrating the tool they already installed** — never bundling or reimplementing one. This is expressed through thor's existing mustache pipe grammar with two new ops: `tool` and `fingerprint`. The earlier research design (content-addressed registry + deferred token resolution + a separate post-render path for "whole-output scanners") is **discarded**. It was overbuilt. Every tool — Tailwind included — scans *source*, which exists at render time, so every transform runs **eagerly, synchronously, and memoized**. No placeholder tokens, no post-render string-replace pass, no second integration surface. ## The two ops ```html ``` ```json5 // thor.json { "tools": { "sass": "sass $in $out", "postcss": "postcss $in -o $out", "lightningcss": "lightningcss --minify $in -o $out", "tailwind": "tailwindcss -i $in -o $out" } } ``` ## Core model The value flowing through the pipe is **bytes held by thor**, with a logical name attached (`css/main.css`). Each stage transforms the bytes; the **final stage publishes** one file to the output dir and returns its URL. - A literal (`"css/main.scss"`) or context key names the **source** file on disk. - `| tool ` runs a subprocess, replacing the carried bytes with its output. - `| fingerprint` writes the content-addressed file and returns its hashed URL. - The chain composes with the existing `rel_url` for base_url subpaths. **Memoization:** results are cached per build, keyed by (op-chain, source). The first page to reference an asset runs the tool; every other page hits the cache. This is what stops N pages from shelling out N times. ## `| tool ` Looks up `` in the `thor.json` `tools` table and runs its command template. `$in` and `$out` are **optional per-tool I/O adapters**: | Template has | Behavior | |---|---| | `$in` | thor writes current bytes to a scratch path, substitutes it | | no `$in` | thor pipes current bytes to the tool's **stdin** | | `$out` | thor substitutes a scratch path, reads it back after | | no `$out` | thor captures the tool's **stdout** | Defaulting to stdin/stdout avoids scratch-file overhead when the tool supports it. Tools that can't stream (Lightning CSS wants a path; Tailwind v3 needs an input file) just get `$in`/`$out` in their template. Either way the bytes are thor's, so chaining works regardless of each tool's preference: `| tool sass | tool postcss` composes even if sass streams and postcss wants files. - **`$out` is always thor-controlled scratch** — the tool never picks the published filename. - **Extension derivation:** thor owns the output name; `main.scss` through `tool sass` becomes `main.css`. - **Errors:** thor gates on exit code and surfaces the tool's stderr through one code path. If the tool is missing from PATH, fail with its stderr — never bundle, never assume a runtime. - **A bare `tool` (no `fingerprint`) still publishes** — to the logical unhashed path (`output/css/main.css`), returning `/css/main.css`. ## `| fingerprint` Content-addresses whatever bytes it currently holds. It has **two outputs**: - **Side effect:** writes `css/main..css` to the output dir. - **Return value:** the hashed URL string `/css/main..css` (feeds `rel_url`). Properties: - **Works standalone**, no `tool` needed: `{{ "css/main.css" | fingerprint }}` is plain cache-busting of a hand-written file — probably the most common use. - **Position decides what's hashed** — it hashes current bytes, so it belongs **last** in the chain. No terminal-only special-casing; the rule is just "put it last." - **Hash format:** `name..ext` (CDN-friendly), 8 hex chars of SHA-256. ## Tailwind is not special It scans your **source** (`layouts/`, `content/`, partials) — which exists at render time — so it runs synchronously like sass. Its `thor.json` entry just needs content globs pointing at the source tree (or v4 auto-detection). The **one** asymmetry is watch invalidation: sass re-runs when `main.scss` changes; Tailwind must re-run when *any* scanned source changes. That's a cache-key breadth difference, not an architectural one. The cost of scanning source vs. output is losing dynamically-assembled class tokens (`col-{{n}}`) — a Tailwind anti-pattern whose sanctioned fix is a safelist, not warping the build. ## Interaction: `copy_assets_dir` Today thor copies everything under `assets/` to the output dir. Any source consumed by a pipe must be **excluded from that plain copy** — otherwise you'd ship the raw `main.scss` (or a dead unhashed `main.css` alongside the hashed one). The pipe owns the write for assets it touches. ## Implementation steps (separable) 1. **`| fingerprint`** — no subprocess, no config. Hash + write + return URL, plus the `copy_assets_dir` exclusion. Smallest blast radius, independently useful, ships first. 2. **`| tool `** — `thor.json` tools table, the `$in`/`$out`/stdin/stdout adapter, subprocess + exit-code/stderr handling, extension derivation, memoization. Composes with step 1. ## Open questions for implementation - **Watch invalidation:** simplest correct v1 recomputes the memo each build (cheap for the common single-asset case). A cross-build skip keyed on source content/mtime is a later optimization — and Tailwind's broad content-glob key is where it matters most. - **Source-map sidecars:** sass/lightning emit `.map` files next to `$out`; disable in the template or ignore the sidecar. Minor. - **Error policy default:** hard-fail the build vs. warn-and-skip on tool failure/absence. Lean fail; possibly configurable later.