mirror of
https://github.com/sbrow/thor.git
synced 2026-10-25 10:11:55 -04:00
Compare commits
8
Commits
7cb1f5be32
...
b932b22f15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b932b22f15 | ||
|
|
4251efc173 | ||
|
|
5809eac195 | ||
|
|
72ef6111cc | ||
|
|
f6ce63dd4c | ||
|
|
317264b45b | ||
|
|
814b1c9996 | ||
|
|
7a8173832e |
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
- name: Generate site
|
||||
working-directory: site
|
||||
run: ../result/bin/thor -base-url=https://sbrow.github.io/thor -minify
|
||||
run: ../result/bin/thor -base-url=https://oneidiotdeveloper.com/thor -minify
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# Asset Pipeline Research: Bring-Your-Own CSS Preprocessor
|
||||
|
||||
Research and design notes for letting thor users bring their own CSS toolchain
|
||||
(Sass, PostCSS/Tailwind, Less, Lightning CSS, …) instead of thor embedding or
|
||||
tracking any of them.
|
||||
|
||||
## Problem statement
|
||||
|
||||
The CSS pre-processor landscape is large and moving (Tailwind, PostCSS, Less,
|
||||
Sass, Lightning CSS, …). thor is a ~1.9 MB self-contained binary that tracks its
|
||||
own size (see `AUDITING_BINARY_SIZE.md`); bundling any one preprocessor — let
|
||||
alone keeping up with all of them — is a non-starter on both size and
|
||||
maintenance grounds. So "bring your own" has to mean **thor orchestrates the
|
||||
tool the user already installed**, never reimplements it.
|
||||
|
||||
Today `copy_assets_dir` (`assets.odin`) copies everything under `assets/` to the
|
||||
output dir and optionally minifies `.css` via the tree-sitter pass in
|
||||
`minify.odin`. There is no preprocessing seam.
|
||||
|
||||
## What we are NOT doing, and why
|
||||
|
||||
- **Bundle a specific preprocessor** — rejected: binary-size-hostile, and picks
|
||||
winners in a space we said we can't keep up with.
|
||||
- **Hugo Pipes-style template resource pipeline** (`resources.Get | css.Sass |
|
||||
minify | fingerprint`) — powerful, but needs a resource abstraction and a
|
||||
non-logic-less template language to host the chained syntax. thor's engine is
|
||||
logic-less mustache; wrong fit.
|
||||
- **Curated shell-out for named tools only** (what Hugo actually ships — only
|
||||
`css.Sass` and `css.PostCSS` shell out) — this just re-picks winners.
|
||||
|
||||
The chosen direction reuses thor's **existing mustache pipe grammar** (the same
|
||||
mechanism as the `rel_url` pipe) to express asset transforms declaratively,
|
||||
where the asset is referenced.
|
||||
|
||||
## Correcting the record on Hugo
|
||||
|
||||
An earlier draft attributed a generic "pre/post-build shell hook" model to Hugo.
|
||||
That is wrong. Hugo has **no** generic build hooks. Its CSS handling is Hugo
|
||||
Pipes — a *template-invoked resource pipeline* of fixed, named functions
|
||||
(`css.Sass`, `css.PostCSS`, `minify`, `fingerprint`, `resources.Concat`, …).
|
||||
Only two of those shell out: `css.PostCSS` (to a local `postcss-cli` under Node)
|
||||
and `css.Sass` (embedded LibSass, or a `dart-sass` binary). Generic
|
||||
"run any command pre/post build" is closer to npm-scripts / Makefile glue, or
|
||||
Eleventy's `before`/`after` events (which run JavaScript, not shell commands).
|
||||
No mainstream SSG markets a generic command hook as its asset story.
|
||||
|
||||
---
|
||||
|
||||
## Tool CLI research
|
||||
|
||||
Five representative tools were researched for their exact shell-out contract.
|
||||
The questions that determine thor's interface are the same for each: stdin/stdout
|
||||
vs. file-path args, whether a config file is mandatory, whether it needs to scan
|
||||
rendered HTML, watch support, minify overlap, and runtime dependencies.
|
||||
|
||||
### Mechanical matrix
|
||||
|
||||
| Tool | stdin→stdout | Config file | Self-contained (no Node) | Self-minifies | Errors |
|
||||
|---|---|---|---|---|---|
|
||||
| **Dart Sass** | yes (`sass -`) | none | yes — standalone binary | `--style=compressed` | stderr, exit 65 |
|
||||
| **PostCSS** (`postcss-cli`) | yes (`< in > out`) | optional (`--use`) | **no** — needs Node + node_modules | via cssnano plugin | stderr, exit 1 |
|
||||
| **lessc** | yes (`-` / positional) | none | **no** — needs Node | weak/deprecated (`-x`) | stderr, nonzero |
|
||||
| **Lightning CSS** | file-in, stdout-out | none (flags) | yes — Rust binary (npm-distributed) | `--minify` (its core purpose) | stderr, nonzero |
|
||||
| **Tailwind v4** | yes (`-i - -o -`) | none (CSS-first) | yes — standalone binary (Bun) | `--minify` | stderr, nonzero |
|
||||
| **Tailwind v3** | no (needs `-i` file) | `tailwind.config.js` | yes — standalone (pkg/Node embed) | `--minify` | stderr, nonzero |
|
||||
|
||||
Two properties are uniform and load-bearing:
|
||||
|
||||
- **Every tool writes errors to stderr and returns nonzero on failure.** thor can
|
||||
gate on exit code and surface stderr through a single code path for all tools.
|
||||
- **Every tool can stream to stdout.** Differences (Lightning CSS wants a path
|
||||
not stdin; lessc's output is positional; Tailwind v3 needs an input file) are
|
||||
all absorbable by a per-tool command template with placeholders.
|
||||
|
||||
### Per-tool detail
|
||||
|
||||
**Dart Sass** — `sass input.scss output.css`, or `sass -` for stdin, prints to
|
||||
stdout when no output arg. No config file exists or is supported; configured by
|
||||
CLI flags + `@use`/`@import` in source. Load paths via `--load-path`/`-I`.
|
||||
`--style=compressed` minifies (overlaps thor's minifier). Source maps on by
|
||||
default (`--no-source-map` to disable). Exit 64 = usage error, 65 = compile
|
||||
error. **Distribution:** standalone release bundles the Dart VM + snapshot, no
|
||||
Node; the npm `sass` package is dart2js and needs Node. Prefer the standalone.
|
||||
|
||||
**PostCSS** (`postcss-cli`, binary `postcss`) — positional input files or stdin;
|
||||
stdout is the default when no `-o`/`--dir`/`--replace`. Config (`postcss.config.js`
|
||||
/ `.postcssrc`, cosmiconfig, walks up) is **not required** — plugins can be given
|
||||
with `-u/--use` (e.g. `postcss in.css -u autoprefixer -u cssnano -o out.css`).
|
||||
Zero plugins = near pass-through. **postcss is only a plugin host** — autoprefixer,
|
||||
tailwind, cssnano are separate npm packages resolved from `node_modules`.
|
||||
`-w/--watch` tracks the full `@import` dependency graph via PostCSS `dependency`
|
||||
messages. Exit 1 on error, errors to stderr, stdout stays clean for piping.
|
||||
**Hard Node + node_modules dependency.** Invoke via `npx postcss` to resolve
|
||||
local `.bin`.
|
||||
|
||||
**lessc** — `lessc [opts] <source> [destination]`; `-` reads stdin; stdout when
|
||||
destination omitted (**no `-o` flag** — output is positional). No config file;
|
||||
`.less` source + flags. `--include-path` for import search. `-x`/`--compress` is
|
||||
deprecated/weak — let thor's minifier compress. **No CLI watch mode** (watch only
|
||||
exists in the browser runtime). Errors to stderr, nonzero on compile error.
|
||||
**Hard Node dependency** (`bin/lessc` is a Node script).
|
||||
|
||||
**Lightning CSS** (`lightningcss`) — input file positional, `-o`/`--output` or
|
||||
stdout when omitted. No config, flag-driven. `--minify` is the tool's core
|
||||
purpose (**directly overlaps thor's minifier** — pick one). `--bundle` inlines
|
||||
`@import`s; `--targets "<browserslist>"` downlevels modern CSS. No built-in watch.
|
||||
Errors to stderr, nonzero. **Runtime: self-contained Rust binary, no Node to
|
||||
run** — but distribution is npm-only (`lightningcss-cli` pulls a platform package
|
||||
like `lightningcss-cli-linux-x64-gnu`); no official GitHub/Homebrew/Winget
|
||||
binaries. Can extract the binary from the npm registry tarball and run it
|
||||
standalone.
|
||||
|
||||
**Tailwind CSS standalone CLI** (v3 and v4) — both ship a self-contained
|
||||
per-platform binary (`./tailwindcss`), no Node required (v3 via Vercel pkg, v4
|
||||
via Bun). `-i`/`--input`, `-o`/`--output` (defaults to stdout in v4). v4 reads
|
||||
stdin with `-i -`; v3 effectively needs an input file. Input is a CSS entry file:
|
||||
v3 uses `@tailwind base/components/utilities;`, v4 uses `@import "tailwindcss";`.
|
||||
**Config:** v3 uses `tailwind.config.js` (auto-discovered, `-c` to override) and
|
||||
in practice needs it (or `--content`) to know what to scan; v4 is CSS-first and
|
||||
genuinely zero-config. `--minify` in both. Errors to stderr, nonzero (codes not
|
||||
formally enumerated — check `exit != 0`).
|
||||
|
||||
**Content scanning (the critical Tailwind property):** Tailwind scans
|
||||
source/template files **as plain text** for complete, static class-name tokens.
|
||||
It does not parse code and does not strictly require rendered HTML — it scans
|
||||
whatever files you point it at. But for an SSG the safe strategy is to scan the
|
||||
**generated output** after render, so classes that only appear post-render aren't
|
||||
missed. v3: `content: ["./public/**/*.html"]` or `--content`. v4: automatic
|
||||
detection, but it **ignores `.gitignore`d paths** — so a git-ignored `public/`
|
||||
must be added explicitly with `@source "../public"`.
|
||||
|
||||
---
|
||||
|
||||
## Design conclusions
|
||||
|
||||
### The one split that matters: two tool shapes
|
||||
|
||||
The research draws a hard line between two categories that need **different
|
||||
integration surfaces**:
|
||||
|
||||
1. **Per-asset transformers** — Sass, Less, Lightning CSS, PostCSS-as-compiler.
|
||||
Input = one source file → output = one CSS file. These fit a mustache pipe:
|
||||
`{{ "css/main.scss" | tool sass }}`. Each tool's CLI quirk (stdin vs. path,
|
||||
positional output, etc.) is absorbed by a per-tool command template.
|
||||
|
||||
2. **Whole-output scanners** — Tailwind, alone. Its "input" is not one asset; it
|
||||
is an entry CSS file **plus every rendered HTML file it scans**. At the moment
|
||||
a per-page pipe would fire, the full output does not exist yet. Tailwind must
|
||||
run **once, after all HTML is written, pointed at `output_dir`**. That is a
|
||||
post-render step, categorically not a pipe.
|
||||
|
||||
### The chicken-and-egg, and the fix
|
||||
|
||||
A `<link>` wants a **fingerprinted (cache-busted) URL**, but for Tailwind the CSS
|
||||
isn't produced until **after** render — so at render time the template cannot
|
||||
know the hash. Hugo dodges this with a lazy multi-pass resource graph; thor is
|
||||
single-pass.
|
||||
|
||||
**Resolution — emit a placeholder at render, resolve at the end.** A pipe like
|
||||
`{{ "css/main.scss" | tool sass | fingerprint }}` writes a **stable token** (e.g.
|
||||
`⟦asset:css/main.scss⟧`) into the HTML. After render, thor runs the tools,
|
||||
populates a **content-addressed asset registry** (logical name → final hashed
|
||||
URL), then does one cheap string-replace pass over the output swapping tokens →
|
||||
real URLs.
|
||||
|
||||
This single mechanism dissolves every constraint encountered:
|
||||
|
||||
- **Cache-busting:** the hash is known at resolve time, so the token becomes
|
||||
`/css/main.a1b2c3.css` uniformly, even for tools that rename output themselves.
|
||||
- **Hash-after-minify:** resolution is the last step, so the hash is always of the
|
||||
exact final served bytes.
|
||||
- **Same asset → same hash across N pages:** the registry is computed once; every
|
||||
token resolves identically. (This is also the memoization that keeps a
|
||||
per-page pipe from shelling out N times for the same file.)
|
||||
- **Tailwind:** its post-render output lands in the registry like any other asset;
|
||||
tokens that referenced it resolve correctly despite the CSS not existing at
|
||||
render time.
|
||||
|
||||
### Cache-busting: two regimes
|
||||
|
||||
- **Regime A — thor owns the hash (recommended, and what every researched
|
||||
compiler needs).** Tool → stdout → thor minifies → thor hashes the final bytes →
|
||||
writes `main.<hash>.css` → registers the URL. None of Sass/PostCSS/Less/
|
||||
Lightning CSS/Tailwind fingerprint on their own; fingerprinting is a
|
||||
bundler/plugin behavior. thor stays in full control of the name because it
|
||||
writes the file. Mirrors Hugo's `| fingerprint` → `.RelPermalink`.
|
||||
- **Regime B — the tool owns the hash** (bundlers: esbuild/vite/parcel, or
|
||||
PostCSS + `postcss-hash`). The tool renames output to a name thor can't predict,
|
||||
and stdout capture doesn't reveal it. The standard answer these tools already
|
||||
provide is a **manifest** (`manifest.json` mapping `"main.css"` →
|
||||
`"main.a1b2c3.css"`). Supporting this class means thor ingests that manifest
|
||||
into the same registry rather than scanning for files.
|
||||
|
||||
Both regimes converge on the same abstraction: **a build-scoped, content-addressed
|
||||
asset registry** (logical name → final emitted URL), populated by thor's own
|
||||
transform+fingerprint or by an external tool's manifest, and resolved through the
|
||||
placeholder pass.
|
||||
|
||||
## Proposed interface shape
|
||||
|
||||
- **`thor.json` `tools` table** — one command template per tool. Keeps shell
|
||||
commands out of templates (a single trust surface), and absorbs each CLI's
|
||||
quirks. `$in` = source path; stdout is captured:
|
||||
|
||||
```json
|
||||
"tools": {
|
||||
"sass": "sass --no-source-map $in",
|
||||
"postcss": "postcss -u autoprefixer $in",
|
||||
"lightningcss": "lightningcss --minify $in",
|
||||
"tailwind": { "run": "tailwindcss -i $in -o -", "scan": "output" }
|
||||
}
|
||||
```
|
||||
|
||||
The `scan: output` marker flips a tool from per-asset (pipe) to post-render
|
||||
(whole-output) mode.
|
||||
|
||||
- **Pipe ops** — `tool <name>` (transform → asset handle), `minify`,
|
||||
`fingerprint` — all resolving through the registry, chainable with the existing
|
||||
`rel_url`:
|
||||
|
||||
```
|
||||
<link href="{{ "css/main.scss" | tool sass | minify | fingerprint | rel_url }}">
|
||||
```
|
||||
|
||||
A lookup-direction op resolves a logical name to its final URL:
|
||||
`{{ "css/main.css" | asset }}` → `/css/main.a1b2c3.css`.
|
||||
|
||||
- **Final resolve pass** over `output_dir` replacing asset tokens with registry
|
||||
URLs. This is also where post-render scanners (Tailwind) have already run.
|
||||
|
||||
### Fit with the existing mustache pipe grammar
|
||||
|
||||
`{{ "css/main.css" | tool postcss }}` already parses under `mustache/pipes.odin`:
|
||||
the key is a quoted literal (`tokenize_fields` keeps the quotes so a literal is
|
||||
distinguishable from a context key), `tool` is the op, `postcss` is one arg
|
||||
(under `MAX_PIPE_ARGS = 2`). Adding an op is the documented one-liner path — a
|
||||
variant on `Pipe_Op` plus a `case` in `apply_filter`. The only architectural
|
||||
departure from existing ops (all pure value→value string transforms) is that
|
||||
`tool`/`fingerprint` have side effects (subprocess, file write) and must be
|
||||
backed by the registry — which is why the placeholder + deferred-resolve model
|
||||
matters: it keeps render itself side-effect-light and pushes the real work to a
|
||||
defined post-render phase.
|
||||
|
||||
## Cross-cutting decisions
|
||||
|
||||
1. **Node dependency is not thor's problem.** PostCSS and lessc require Node +
|
||||
node_modules; Sass/Lightning CSS/Tailwind ship self-contained binaries. thor's
|
||||
job is to exec whatever is on PATH and **fail gracefully with the tool's
|
||||
stderr** if it's absent — never bundle, never assume a runtime. The Nix flake
|
||||
makes pinning the toolchain pleasant for users who want reproducibility.
|
||||
2. **Minification must be explicit, never doubled.** Every one of these tools can
|
||||
self-minify, and thor has its own minifier. Make `minify` an explicit pipe
|
||||
step and do **not** auto-apply thor's global minify to tool-produced assets —
|
||||
otherwise `| tool lightningcss` (already minified) gets re-minified. The pipe
|
||||
chain is the single source of truth for whether/when an asset is minified.
|
||||
3. **thor drives the watch; ignore the tools' own `--watch`.** thor already polls
|
||||
(5 s). Invoke each tool once per build as a pipeline step. Sass/PostCSS/
|
||||
Tailwind have watch modes, but running them would put a second watcher in
|
||||
competition with thor's loop — avoid it.
|
||||
|
||||
## Headline
|
||||
|
||||
The feature is not "a `tool` pipe." It is a **content-addressed asset registry
|
||||
with deferred token resolution**, exposed through two pipe ops (`tool`,
|
||||
`fingerprint`/`asset`) plus one post-render hook (for whole-output scanners like
|
||||
Tailwind). That is the minimum that handles both tool shapes *and* cache-busting
|
||||
without introducing a multi-pass render engine.
|
||||
|
||||
## Open questions for the design phase
|
||||
|
||||
- Placeholder token format and collision-safety (must survive HTML minification
|
||||
untouched — likely treat like the `pre`/`code` preserve ranges in `minify.odin`).
|
||||
- Registry lifetime and keying (content hash vs. path+mtime) and how it threads
|
||||
through the watch loop for incremental rebuilds.
|
||||
- Whether `fingerprint` is terminal-only, or the resolve pass always hashes
|
||||
post-minify regardless of chain position.
|
||||
- Manifest ingestion format for Regime B (adopt Vite/webpack `manifest.json`
|
||||
shape?).
|
||||
- Error policy: hard-fail the build vs. warn-and-skip when a tool exits nonzero
|
||||
or is missing from PATH (probably configurable, defaulting to fail).
|
||||
|
||||
## Sources
|
||||
|
||||
- Tailwind CLI (v4): https://tailwindcss.com/docs/installation/tailwind-cli ·
|
||||
content detection: https://tailwindcss.com/docs/detecting-classes-in-source-files ·
|
||||
standalone: https://tailwindcss.com/blog/standalone-cli ·
|
||||
v3: https://v3.tailwindcss.com/docs/installation ·
|
||||
https://v3.tailwindcss.com/docs/content-configuration
|
||||
- PostCSS CLI: https://github.com/postcss/postcss-cli (README + `index.js` +
|
||||
`lib/args.js`)
|
||||
- Dart Sass CLI: https://sass-lang.com/documentation/cli/dart-sass/ ·
|
||||
https://github.com/sass/dart-sass
|
||||
- Less: https://lesscss.org/usage/ ·
|
||||
https://github.com/less/less-docs/blob/master/content/usage/command-line-usage.md
|
||||
- Lightning CSS: https://lightningcss.dev/docs.html ·
|
||||
https://github.com/parcel-bundler/lightningcss ·
|
||||
https://www.npmjs.com/package/lightningcss-cli ·
|
||||
distribution discussion: https://github.com/parcel-bundler/lightningcss/discussions/732
|
||||
@@ -0,0 +1,96 @@
|
||||
# Visualizing & Managing `thor` Binary Size
|
||||
|
||||
How to inspect what makes up the compiled `thor` binary, and how to keep that
|
||||
inspection useful once we bundle layout/asset files into the binary.
|
||||
|
||||
## Quick inspection (tools already available)
|
||||
|
||||
The binary is a not-stripped ELF, so it ships symbols we can break down.
|
||||
|
||||
```bash
|
||||
# Section totals (text / data / bss)
|
||||
size ./thor
|
||||
|
||||
# Top symbols by size
|
||||
nm --print-size --size-sort --radix=d ./thor \
|
||||
| awk '{sz=$2+0; sub(/^[0-9a-fA-F]+ +[0-9]+ +[A-Za-z] +/,""); printf "%8d %s\n", sz, $0}' \
|
||||
| sort -rn | head -25
|
||||
|
||||
# Aggregate symbol sizes by Odin module namespace (foo::bar -> "foo")
|
||||
nm --print-size --size-sort --radix=d ./thor \
|
||||
| awk '{sz=$2+0; rest=$0; sub(/^[0-9a-fA-F]+ +[0-9]+ +[A-Za-z] +/,"",rest);
|
||||
ns=rest; if (rest ~ /::/){sub(/::.*/,"",ns)}
|
||||
else if (rest ~ /^ts_/){ns="treesitter (ts_*)"} else {ns="<other/runtime>"};
|
||||
s[ns]+=sz} END{for (k in s) printf "%9d %s\n", s[k], k}' \
|
||||
| sort -rn | head -25
|
||||
```
|
||||
|
||||
As of this writing the top contributors were: tree-sitter tables (~118 KB),
|
||||
`main` (~96 KB), runtime/other (~93 KB), `mustache` (~67 KB), then `fmt` /
|
||||
`encoding_json` from the stdlib.
|
||||
|
||||
## Better visualization: `bloaty`
|
||||
|
||||
`bloaty` (Google's binary-size profiler) is the right tool. Not installed, but
|
||||
runnable via Nix:
|
||||
|
||||
```bash
|
||||
bloaty ./thor # by section
|
||||
bloaty ./thor -d symbols -n 30 # by symbol, hierarchical
|
||||
bloaty ./thor -d sections,symbols # section then symbols within each
|
||||
# via nix (no install):
|
||||
nix run nixpkgs#bloaty -- ./thor -d symbols -n 30
|
||||
```
|
||||
|
||||
For a stripped-size estimate:
|
||||
|
||||
```bash
|
||||
readelf -S ./thor | grep -E 'debug|symtab|strtab' # strippable overhead
|
||||
strip -s ./thor -o thor.stripped && size thor.stripped
|
||||
```
|
||||
|
||||
Odin also supports smaller builds: `odin build . -o:size -no-bounds-check`.
|
||||
|
||||
## IMPORTANT: keeping bloaty useful once we bundle layouts into the binary
|
||||
|
||||
Today the VFS (`vfs.odin`) reads layout/asset files lazily from disk at runtime
|
||||
(`VFS_Entry.fs_path` + `os.read_entire_file_from_path`); `VFS_Entry.data` is
|
||||
never populated at build time. So embedded content is **not** in `./thor` yet.
|
||||
|
||||
When we switch to embedding via Odin `#load(...)`, the file bytes land in the
|
||||
binary's `.rodata` section. Whether bloaty can show *per-file* sizes depends on
|
||||
how we emit the embed:
|
||||
|
||||
- **Section total — always visible.** `.rodata` grows by ~the total bundled
|
||||
size. `bloaty ./thor -d sections` (or `size`) shows the aggregate, and we can
|
||||
diff before/after. This always works.
|
||||
|
||||
- **Per-file breakdown — only if each file is a NAMED symbol.**
|
||||
- A generated `map[string]VFS_Entry` literal full of inline `#load(...)`
|
||||
expressions makes each blob an **anonymous** `.rodata` constant. Bloaty
|
||||
lumps them together — no per-file breakdown.
|
||||
- Assigning each file to its own **package-level variable** makes each a named
|
||||
global symbol, so `bloaty ./thor -d symbols` (and the `nm` recipe above)
|
||||
show each file individually:
|
||||
|
||||
```odin
|
||||
layout_base_html := #load("defaults/layouts/base.html")
|
||||
layout_single_html := #load("defaults/layouts/single.html")
|
||||
// ...then reference these in the VFS map instead of inlining #load.
|
||||
```
|
||||
|
||||
### Decision to bake into the bundling codegen
|
||||
|
||||
When we build the codegen that replaces the runtime `mount_dir` disk walk, have
|
||||
it emit **one named package-level variable per embedded file** (not an anonymous
|
||||
map literal). That single choice is what makes bloaty / `nm` per-file analysis
|
||||
possible — cheap to do up front, painful to retrofit.
|
||||
|
||||
Additional options that help regardless of symbolization:
|
||||
|
||||
1. Build with Odin `-debug` (DWARF) and use `bloaty ./thor -d compileunits` to
|
||||
attribute embedded bytes to the generated source file.
|
||||
2. Since the generator already knows every file and its size, emit a size
|
||||
manifest at bundle time (`path -> bytes`, sorted / treemap). This is the most
|
||||
reliable per-file view and can't drift from reality; bloaty then just
|
||||
confirms the `.rodata` total matches.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Grammar cache thread-safety & allocator lifetime
|
||||
|
||||
Status: **investigation complete, fix deferred.** This document records what we
|
||||
found and the direction we chose, so the next person doesn't have to rediscover
|
||||
it. No behavior has been changed yet — only documentation comments were added to
|
||||
`treesitter/treesitter.odin`.
|
||||
|
||||
## How we got here
|
||||
|
||||
While adding a feature to minify inline `<style>` blocks (route the CSS body
|
||||
through `minify_css`), the new unit tests exercised `minify_html` / `minify_css`
|
||||
for the first time. Those are the first tests to touch the tree-sitter parser
|
||||
cache, and under the multi-threaded test runner (default 16 threads) they failed
|
||||
**intermittently** with a rotating cast of symptoms:
|
||||
|
||||
- `panic: unable to insert into a map`
|
||||
- tree-sitter internal aborts: `ts_subtree_retain: Assertion 'self.ptr->ref_count > 0' failed`, `_array__erase: Assertion 'index < *size' failed`
|
||||
- `malloc(): unaligned tcache chunk detected`
|
||||
- assertions passing on the wrong data (e.g. minified output missing `<style>`)
|
||||
|
||||
The flakiness comes from **three distinct problems**, two thread-safety and one
|
||||
allocator-lifetime. They are orthogonal — fixing one does not fix the others.
|
||||
|
||||
## The three issues
|
||||
|
||||
### #1a — concurrent cache **writes** (data race)
|
||||
|
||||
`ensure_parser` (`treesitter.odin`) reads and writes `grammar_store.cache`
|
||||
with **no lock**. `cache_mutex` exists but is only taken on the parallel preload
|
||||
path (`grammar_worker`), never in the lazy `ensure_parser` / `load_grammar`
|
||||
paths. Two threads first-touching `"html"`/`"css"` at once corrupt the map.
|
||||
|
||||
### #1b — concurrent parser **use** (data race)
|
||||
|
||||
There is exactly **one cached `Grammar_Cache` (and one `parser`) per language**.
|
||||
When two threads both call `minify_html`, they fetch the *same* `gc.parser` and
|
||||
call `parser_parse_string` on it simultaneously. A tree-sitter `TSParser`
|
||||
mutates its internal subtree pool during a parse, so concurrent parses corrupt
|
||||
it — that's the `ts_subtree_retain` / `_array__erase` aborts.
|
||||
|
||||
This is **not fixed by preloading/"loading html+css statically."** Loading gives
|
||||
you *one shared* parser; the race is about *using* that one parser from two
|
||||
threads. It is fixed only by serializing the parse or giving each thread its own
|
||||
parser.
|
||||
|
||||
### #2 — allocator lifetime (not a race)
|
||||
|
||||
`init_persistent` captures `context.allocator` into the process-lifetime
|
||||
`grammar_store`. In `main` that happens to be the heap (it runs before the site
|
||||
arena is installed at `main.odin`), so production is fine. But whatever
|
||||
allocator is live at that instant becomes the cache's owner forever:
|
||||
|
||||
- Under the **test runner**, `context.allocator` is a **per-test tracking
|
||||
allocator** that is torn down when that test ends → the global cache is left
|
||||
pointing at dead memory (→ `unable to insert into a map`, garbage reads).
|
||||
- A **lazy** first call during rendering would capture the **site arena**
|
||||
(`context.allocator` after `main.odin` swaps it in), which is destroyed every
|
||||
watch-mode rebuild → dangling parsers across builds.
|
||||
|
||||
This would break even single-threaded; threading just decides *which* test wins
|
||||
the init race.
|
||||
|
||||
## tree-sitter's threading model (the root fact)
|
||||
|
||||
tree-sitter objects have specific, documented threading rules:
|
||||
|
||||
| Object | Shareable across threads? | Rule |
|
||||
|---|---|---|
|
||||
| `TSLanguage` (`tree_sitter_html()` etc.) | Yes | Immutable; share freely. |
|
||||
| `TSParser` (`gc.parser`) | **No** | One per thread; never parse on one parser from two threads. |
|
||||
| `TSTree` | Only via `ts_tree_copy` | Copy before using on another thread. |
|
||||
| `TSQuery` (`gc.query`) | Yes | Immutable after compile. |
|
||||
| `TSQueryCursor` (`gc.cursor`) | **No** | One per thread, like the parser. |
|
||||
|
||||
This explains the existing design:
|
||||
|
||||
- The preload fan-out (`preload_grammar` / `grammar_worker`) creates a parser
|
||||
**per worker** and only publishes the finished `Grammar_Cache` under
|
||||
`cache_mutex` — because parsers can't be shared during construction/use.
|
||||
- `ensure_parser` + the shared cached parser is fine in production **only
|
||||
because rendering is single-threaded**.
|
||||
|
||||
A cached `Grammar_Cache` mixes shareable (`language`, `query`) and per-thread
|
||||
(`parser`, `cursor`) objects in one shared struct, which makes it
|
||||
single-threaded-use by construction — a fact that was previously undocumented.
|
||||
|
||||
## Where this actually bites
|
||||
|
||||
**Only in tests.** Confirmed by grep:
|
||||
|
||||
- Production parses on one thread: every real caller — `minify.odin` (html/css)
|
||||
and `markdown/highlight.odin` — runs inside `main`'s single-threaded render
|
||||
loop. No concurrent `ensure_parser` / `parser_parse_string`.
|
||||
- The one threaded path in production, `preload_grammars`, is already safe (own
|
||||
parser per worker + `cache_mutex` on the write) and only handles non-builtin
|
||||
grammars.
|
||||
- Only `minify_test.odin` touches tree-sitter from tests, and the test runner
|
||||
runs those on many threads.
|
||||
|
||||
So production is correct **by convention** (single-threaded-from-heap usage),
|
||||
not **by construction** — nothing stops a future parallel render from breaking
|
||||
it, and until now nothing documented the constraint.
|
||||
|
||||
## Chosen direction
|
||||
|
||||
**For now:** keep the single global grammar cache and make it safe by
|
||||
**serializing the non-thread-safe behavior with a mutex** — only one thread uses
|
||||
a parser at a time. This is simple, matches how production already behaves, and
|
||||
unblocks the tests.
|
||||
|
||||
**Later (TODO):** decide whether true multi-threaded parsing is worth it for
|
||||
performance. If yes, give each thread its own parser + cursor (as the preload
|
||||
workers already do) rather than sharing one behind a lock.
|
||||
|
||||
## Implementation options (deferred — pick when we resume)
|
||||
|
||||
All assume the allocator fix (#2) below, which is independent and unambiguous.
|
||||
|
||||
- **#2 allocator (do regardless):** pin the heap allocator explicitly —
|
||||
`grammar_store.allocator = runtime.heap_allocator()` (or `os.heap_allocator()`)
|
||||
instead of `context.allocator`. Makes the cache persistent from any context;
|
||||
behavior-identical in production. Optionally make `init_persistent` idempotent
|
||||
and/or an `@(init)` so callers need no ordering.
|
||||
|
||||
Then, for the races:
|
||||
|
||||
- **Option A — library mutex (sustainable):** take `cache_mutex` in
|
||||
`ensure_parser` (fixes #1a) and add a locking `parse` helper that wraps
|
||||
`parser_parse_string` (fixes #1b), wired into the three call sites
|
||||
(`minify.odin` ×2, `markdown/highlight.odin` ×1). Tests then need no
|
||||
scaffolding. Serializes all parsing.
|
||||
|
||||
- **Option B — test-side only (minimal):** leave the library single-threaded by
|
||||
contract; make the tests not violate it. Either fold the minify tests into one
|
||||
`@(test)` (no concurrency at all → no locks), or wrap the `minify_*` calls in a
|
||||
test-local mutex. Production stays lock-free.
|
||||
|
||||
Recommendation captured during the discussion: the allocator one-liner is
|
||||
worth doing immediately; for the races, Option A is the "correct" long-term
|
||||
answer but only necessary if/when we want concurrent parsing — otherwise
|
||||
Option B is enough.
|
||||
|
||||
## Pointers
|
||||
|
||||
- `treesitter/treesitter.odin`: doc comments on `Grammar_Cache`, `cache_mutex`,
|
||||
`init_persistent`, `ensure_parser` reference this file.
|
||||
- Original feature that surfaced this: minify inline `<style>` bodies via
|
||||
`minify_css` (reverted; to be re-landed alongside whichever fix we choose).
|
||||
@@ -87,6 +87,7 @@
|
||||
- [ ] don't use bullshit "sub-tokens", add filters and pipes as proper tokens.
|
||||
- [ ] Create a Location struct that somewhat matches Odin's [Source_Code_Location](https://pkg.odin-lang.org/base/runtime/#Source_Code_Location)?
|
||||
- Note that odin's version doesn't contain the stack trace.
|
||||
- [ ] Link checker / linter that warns if links don't resolve. ("please use {{whatever | rel_url }}").
|
||||
|
||||
### Memory Management
|
||||
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
|
||||
## Priority Unclear (Evaluate Later)
|
||||
|
||||
- [ ] Decide whether the grammar cache should support multi-threaded parsing.
|
||||
- For now it stays a single global cache used single-threaded; the plan is to
|
||||
guard the non-thread-safe parser/cursor use with a mutex when tests (or any
|
||||
future parallel caller) need it. A `TSParser`/`TSQueryCursor` is one-per-thread.
|
||||
- Alternative later: give each thread its own parser+cursor for real parallelism.
|
||||
- Also pin `init_persistent` to the heap allocator (lifetime bug, independent).
|
||||
- Full write-up: `GRAMMAR_CACHE_THREADING.md`.
|
||||
- [ ] instead of `warnings: [dynamic]Error` we should use `warnings: [dynamic; 8]Error`
|
||||
- when limit reached, the template fails and stops rendering.
|
||||
- [ ] Brainstorm plugin/tool/(postccs/less/tailwind/sass) support.
|
||||
|
||||
## General
|
||||
|
||||
@@ -43,6 +51,9 @@ Remember to polish existing features before moving on to new ones.
|
||||
- do we want to do this or just use DIAGNOSTIC_TESTS.md as the tests?
|
||||
- [ ] also update to use file logger so they don't clog up the output
|
||||
- [ ] narrow highlight in diagnostic to relevent part of line.
|
||||
- [ ] page.url and page.permalink are confusing names. Switch to url + page_url
|
||||
- There is confusion over how to do this and whethor or not `| rel_url` should be needed to render these.
|
||||
Requires **careful thought**.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
+54
-1
@@ -71,6 +71,9 @@ site_load_content :: proc(site: ^Site) {
|
||||
|
||||
for &page in site.pages {
|
||||
page.url = fmt.tprintf("%s%s", site.base_url, page.permalink)
|
||||
if site.base_path != "" {
|
||||
page.content = rewrite_root_urls(page.content, site.base_path, site_allocator(site))
|
||||
}
|
||||
}
|
||||
|
||||
build_menus(site)
|
||||
@@ -289,6 +292,57 @@ load_page :: proc(
|
||||
return
|
||||
}
|
||||
|
||||
// rewrite_root_urls prefixes root-relative href/src attribute values with
|
||||
// base_path so links authored in content (e.g. [x](/ideas)) resolve under a
|
||||
// subpath deployment. Only values beginning with a single '/' are rewritten;
|
||||
// absolute ("http://…"), protocol-relative ("//cdn…"), fragment ("#x"),
|
||||
// mailto:, and document-relative ("foo/bar", "./foo") values all fail that
|
||||
// test and pass through untouched. Assumes cmark's double-quoted output.
|
||||
rewrite_root_urls :: proc(html, base_path: string, allocator := context.allocator) -> string {
|
||||
b := strings.builder_make(allocator)
|
||||
strings.builder_grow(&b, len(html))
|
||||
|
||||
i := 0
|
||||
for i < len(html) {
|
||||
n := attr_opener_at(html, i) // ` href="` or ` src="`
|
||||
if n == 0 {
|
||||
strings.write_byte(&b, html[i])
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
strings.write_string(&b, html[i:i + n]) // copy the opener verbatim
|
||||
i += n
|
||||
|
||||
end := strings.index_byte(html[i:], '"')
|
||||
if end < 0 { // malformed; bail cleanly
|
||||
strings.write_string(&b, html[i:])
|
||||
break
|
||||
}
|
||||
value := html[i:i + end]
|
||||
if url_is_root_relative(value) {
|
||||
strings.write_string(&b, base_path)
|
||||
}
|
||||
strings.write_string(&b, value) // closing quote copied next loop
|
||||
i += end
|
||||
}
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
// url_is_root_relative reports whether value is a site-root path ("/faq/") as
|
||||
// opposed to protocol-relative ("//cdn") or anything not starting with '/'.
|
||||
url_is_root_relative :: proc(value: string) -> bool {
|
||||
return len(value) >= 1 && value[0] == '/' && !strings.has_prefix(value, "//")
|
||||
}
|
||||
|
||||
// attr_opener_at returns the length of a ` href="` / ` src="` attribute opener
|
||||
// at s[i:], or 0. The leading space anchors the match to a real HTML attribute
|
||||
// so we don't rewrite the strings inside link text or code blocks.
|
||||
attr_opener_at :: proc(s: string, i: int) -> int {
|
||||
if strings.has_prefix(s[i:], " href=\"") do return 7
|
||||
if strings.has_prefix(s[i:], " src=\"") do return 6
|
||||
return 0
|
||||
}
|
||||
|
||||
is_content_file :: proc(name: string) -> bool {
|
||||
return strings.has_suffix(name, ".md") || strings.has_suffix(name, ".html")
|
||||
}
|
||||
@@ -300,4 +354,3 @@ strip_extension :: proc(name: string) -> string {
|
||||
}
|
||||
return name[:dot]
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{&content}}
|
||||
</header>
|
||||
<ul>
|
||||
{{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date}}<time
|
||||
{{#pages}} <li><a href="{{permalink | rel_url}}">{{&title}}</a><span>{{#date}}<time
|
||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
||||
</li>
|
||||
{{/pages}}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<header>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/">{{> home-link}}</a></li>
|
||||
<li><a href="{{site.base_path}}/">{{> home-link}}</a></li>
|
||||
{{#menus.main}}
|
||||
<li><a href="{{url}}">{{name}}</a></li>
|
||||
<li><a href="{{url | rel_url}}">{{name}}</a></li>
|
||||
{{/menus.main}}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
<style>
|
||||
/* Nav */
|
||||
header nav ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Base layout */
|
||||
body {
|
||||
margin-left: auto;
|
||||
@@ -220,4 +228,4 @@ label.margin-toggle:not(.sidenote-number) {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{{#params.stylesheets}}<link rel="stylesheet" href="{{.}}">{{/params.stylesheets}}
|
||||
{{#params.stylesheets}}<link rel="stylesheet" href="{{.|rel_url}}">{{/params.stylesheets}}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<section>
|
||||
<h2>{{key}}</h2>
|
||||
<ul>
|
||||
{{#items}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date}}<time
|
||||
{{#items}} <li><a href="{{permalink | rel_url}}">{{&title}}</a><span>{{#date}}<time
|
||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
||||
</li>
|
||||
{{/items}}
|
||||
|
||||
@@ -167,6 +167,8 @@
|
||||
gdb
|
||||
perf
|
||||
|
||||
bloaty # du for binaries?
|
||||
|
||||
# IDE
|
||||
unstable.helix
|
||||
claude-code
|
||||
|
||||
+52
-5
@@ -38,13 +38,16 @@ minify_html :: proc(source: string) -> string {
|
||||
defer delete(comments)
|
||||
preserves: [dynamic]Range
|
||||
defer delete(preserves)
|
||||
styles: [dynamic]Range
|
||||
defer delete(styles)
|
||||
|
||||
collect_html_ranges(root, source, &comments, &preserves)
|
||||
collect_html_ranges(root, source, &comments, &preserves, &styles)
|
||||
|
||||
sb := strings.builder_make()
|
||||
|
||||
ci := 0
|
||||
pi := 0
|
||||
si := 0
|
||||
i := 0
|
||||
last_written: u8 = 0
|
||||
|
||||
@@ -61,6 +64,21 @@ minify_html :: proc(source: string) -> string {
|
||||
continue
|
||||
}
|
||||
|
||||
if si < len(styles) && u32(i) >= styles[si].start {
|
||||
s := styles[si]
|
||||
// NOTE: minify_css may return either a fresh allocation or the
|
||||
// input slice unchanged (on parser failure), so the result is not
|
||||
// freed here — matching this package's one-shot allocation style.
|
||||
minified := minify_css(source[i:s.end])
|
||||
strings.write_string(&sb, minified)
|
||||
if len(minified) > 0 {
|
||||
last_written = minified[len(minified) - 1]
|
||||
}
|
||||
i = int(s.end)
|
||||
si += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if ci < len(comments) && u32(i) >= comments[ci].start {
|
||||
i = int(comments[ci].end)
|
||||
ci += 1
|
||||
@@ -101,6 +119,7 @@ collect_html_ranges :: proc(
|
||||
source: string,
|
||||
comments: ^[dynamic]Range,
|
||||
preserves: ^[dynamic]Range,
|
||||
styles: ^[dynamic]Range,
|
||||
) {
|
||||
child_count := ts.node_named_child_count(node)
|
||||
for i in 0 ..< child_count {
|
||||
@@ -112,7 +131,17 @@ collect_html_ranges :: proc(
|
||||
comments,
|
||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||
)
|
||||
} else if type_str == "script_element" || type_str == "style_element" {
|
||||
} else if type_str == "style_element" {
|
||||
// Preserve the <style> tags but minify the CSS body (the raw_text
|
||||
// child) via the CSS-aware pass.
|
||||
raw, ok := style_raw_text(child)
|
||||
if ok {
|
||||
append(
|
||||
styles,
|
||||
Range{start = ts.node_start_byte(raw), end = ts.node_end_byte(raw)},
|
||||
)
|
||||
}
|
||||
} else if type_str == "script_element" {
|
||||
append(
|
||||
preserves,
|
||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||
@@ -125,14 +154,27 @@ collect_html_ranges :: proc(
|
||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||
)
|
||||
} else {
|
||||
collect_html_ranges(child, source, comments, preserves)
|
||||
collect_html_ranges(child, source, comments, preserves, styles)
|
||||
}
|
||||
} else {
|
||||
collect_html_ranges(child, source, comments, preserves)
|
||||
collect_html_ranges(child, source, comments, preserves, styles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// style_raw_text returns the raw_text (CSS body) child of a style_element.
|
||||
// ok is false for an empty <style></style>, which has no raw_text child.
|
||||
style_raw_text :: proc(style_element: ts.Node) -> (raw: ts.Node, ok: bool) {
|
||||
child_count := ts.node_named_child_count(style_element)
|
||||
for i in 0 ..< child_count {
|
||||
child := ts.node_named_child(style_element, u32(i))
|
||||
if string(ts.node_type(child)) == "raw_text" {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
return {}, false
|
||||
}
|
||||
|
||||
html_tag_name :: proc(element: ts.Node, source: string) -> string {
|
||||
child_count := ts.node_named_child_count(element)
|
||||
for i in 0 ..< child_count {
|
||||
@@ -224,7 +266,12 @@ minify_css :: proc(source: string) -> string {
|
||||
next = source[j]
|
||||
}
|
||||
|
||||
if !is_css_delim(last_written) && !is_css_delim(next) {
|
||||
// Emit a single separating space, but never a leading space
|
||||
// (last_written == 0), a doubled space (last_written == ' ',
|
||||
// which happens when a stripped comment sits between two
|
||||
// whitespace runs), or one adjacent to a delimiter.
|
||||
if last_written != 0 && last_written != ' ' &&
|
||||
!is_css_delim(last_written) && !is_css_delim(next) {
|
||||
strings.write_byte(&sb, ' ')
|
||||
last_written = ' '
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#+test
|
||||
package main
|
||||
|
||||
import "core:strings"
|
||||
import "core:sync"
|
||||
import "core:testing"
|
||||
import "treesitter"
|
||||
|
||||
// The treesitter parser store is normally initialized by main() via
|
||||
// treesitter.init_persistent(); the test runner never calls main. init_once
|
||||
// ensures it happens exactly once no matter which test thread arrives first,
|
||||
// and preloads the builtin parsers on that single thread — after which the
|
||||
// concurrent tests only *read* the cache (the unlocked writes in ensure_parser
|
||||
// are what corrupt it, not reads).
|
||||
@(private = "file")
|
||||
init_once: sync.Once
|
||||
|
||||
setup_treesitter :: proc() {
|
||||
sync.once_do(&init_once, proc() {
|
||||
treesitter.init_persistent()
|
||||
treesitter.ensure_parser("html")
|
||||
treesitter.ensure_parser("css")
|
||||
})
|
||||
}
|
||||
|
||||
// The builtin parsers are cached and shared per-language, and tree-sitter
|
||||
// parsers are not reentrant — concurrent parser_parse_string corrupts their
|
||||
// internal subtree pool. The multi-threaded test runner would otherwise race,
|
||||
// so serialize parser use behind this mutex. (Production renders on a single
|
||||
// thread, so it needs no such lock.)
|
||||
@(private = "file")
|
||||
parse_mu: sync.Mutex
|
||||
|
||||
ts_minify_html :: proc(s: string) -> string {
|
||||
sync.mutex_lock(&parse_mu)
|
||||
defer sync.mutex_unlock(&parse_mu)
|
||||
return minify_html(s)
|
||||
}
|
||||
|
||||
ts_minify_css :: proc(s: string) -> string {
|
||||
sync.mutex_lock(&parse_mu)
|
||||
defer sync.mutex_unlock(&parse_mu)
|
||||
return minify_css(s)
|
||||
}
|
||||
|
||||
// --- minify_html: inline <style> bodies get CSS-minified ---
|
||||
|
||||
@(test)
|
||||
test_minify_html_inline_style :: proc(t: ^testing.T) {
|
||||
setup_treesitter()
|
||||
input := `<html><head><style>
|
||||
body {
|
||||
color: red;
|
||||
}
|
||||
</style></head><body><p>hi</p></body></html>`
|
||||
result := ts_minify_html(input)
|
||||
|
||||
// The CSS body is collapsed by minify_css (delimiters lose surrounding
|
||||
// whitespace)...
|
||||
testing.expect(t, strings.contains(result, "body{color:red;}"))
|
||||
// ...while the <style> tags themselves survive.
|
||||
testing.expect(t, strings.contains(result, "<style>"))
|
||||
testing.expect(t, strings.contains(result, "</style>"))
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_minify_html_empty_style :: proc(t: ^testing.T) {
|
||||
setup_treesitter()
|
||||
// An empty <style></style> has no raw_text child; must not crash.
|
||||
input := `<html><head><style></style></head><body></body></html>`
|
||||
result := ts_minify_html(input)
|
||||
testing.expect(t, strings.contains(result, "<style></style>"))
|
||||
}
|
||||
|
||||
// --- minify_css: whitespace around stripped comments / leading edge ---
|
||||
|
||||
@(test)
|
||||
test_minify_css_leading_and_comment :: proc(t: ^testing.T) {
|
||||
setup_treesitter()
|
||||
// Leading whitespace and a comment between two whitespace runs must not
|
||||
// leave a leading space or a doubled space.
|
||||
testing.expect_value(t, ts_minify_css("\n/* c */\na {}"), "a{}")
|
||||
testing.expect_value(t, ts_minify_css(" a {}"), "a{}")
|
||||
testing.expect_value(t, ts_minify_css("a {}\n/* trailing */"), "a{}")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_minify_html_script_preserved :: proc(t: ^testing.T) {
|
||||
setup_treesitter()
|
||||
// <script> stays byte-for-byte (whitespace-significant JS).
|
||||
input := "<html><body><script>\nconst s = \"a b\";\n</script></body></html>"
|
||||
result := ts_minify_html(input)
|
||||
testing.expect(t, strings.contains(result, "const s = \"a b\";"))
|
||||
}
|
||||
@@ -4,3 +4,4 @@
|
||||
- [ ] Simplify `trim_standalone_whitespace` — four temp arrays (`li_buf`, `ri_buf`, `left_done`, `right_done`) could potentially be reduced to two by merging the done-tracking into the index buffers using sentinel values (e.g. `-2` = already trimmed).
|
||||
- [ ] Adjacent standalone tags don't capture indent for the second tag (e.g. ` {{>a}}\n {{>b}}` — `{{>b}}` gets `indent=""` because its left text was consumed by `{{>a}}`'s right-trim). Blocks recover via `deindent_blocks`, but partials/parents have no fallback.
|
||||
- [ ] lazy load partials ? (with mutex)
|
||||
- [ ] Support changing delimiters (Set Delimiter tag `{{=<% %>=}}`). Delimiters `{{`/`}}`/`{{{`/`}}}` are hardcoded in [tokenizer.odin](./tokenizer.odin); needs an `=` sigil case, mutable open/close delimiters threaded through `tokenize`, and wiring up `spec/specs/delimiters.json` in `spec_test.odin` (fixture present but not run).
|
||||
|
||||
+78
-19
@@ -24,6 +24,7 @@ Pipe_Op :: enum {
|
||||
Group_By,
|
||||
First,
|
||||
Last,
|
||||
Rel_Url,
|
||||
}
|
||||
|
||||
// Pipe op names are derived from the enum via reflection (lowercased).
|
||||
@@ -60,10 +61,10 @@ pipe_op_candidates :: proc(allocator := context.temp_allocator) -> []string {
|
||||
}
|
||||
|
||||
Pipe_Filter :: struct {
|
||||
op: string,
|
||||
args: [dynamic; MAX_PIPE_ARGS]string,
|
||||
op_pos: int,
|
||||
end_pos: int,
|
||||
op: string,
|
||||
args: [dynamic; MAX_PIPE_ARGS]string,
|
||||
op_pos: int,
|
||||
end_pos: int,
|
||||
}
|
||||
|
||||
Group :: struct {
|
||||
@@ -345,10 +346,56 @@ apply_filter :: proc(
|
||||
case .Last:
|
||||
span := filter.end_pos - filter.op_pos
|
||||
return take(value, filter.args[:], false, tmpl, filter.op_pos, span)
|
||||
case .Rel_Url:
|
||||
str, ok := reflect.as_string(value)
|
||||
if !ok {
|
||||
return value, Error_Body {
|
||||
msg = "rel_url may only be used on strings",
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
result := rel_url(str, ctx)
|
||||
return any{new_clone(result, context.temp_allocator), typeid_of(string)}, nil
|
||||
}
|
||||
return {}, nil
|
||||
}
|
||||
|
||||
// rel_url prefixes a root-relative path (e.g. "/faq/") with the site's
|
||||
// base_path so links resolve correctly under a subpath deployment. Values that
|
||||
// are already absolute — a scheme ("http://"), protocol-relative ("//"), an
|
||||
// in-page fragment ("#"), or a "mailto:" — are returned unchanged, which keeps
|
||||
// external menu and footer links intact. base_path is read from the context
|
||||
// stack the same way `format` reads date_format/timezone.
|
||||
rel_url :: proc(path: string, ctx: []any) -> string {
|
||||
if path == "" {
|
||||
return path
|
||||
}
|
||||
if strings.has_prefix(path, "http://") ||
|
||||
strings.has_prefix(path, "https://") ||
|
||||
strings.has_prefix(path, "//") ||
|
||||
strings.has_prefix(path, "#") ||
|
||||
strings.has_prefix(path, "mailto:") {
|
||||
return path
|
||||
}
|
||||
|
||||
base := ""
|
||||
if raw := resolve_name("base_path", ctx); raw != nil {
|
||||
if s, ok := reflect.as_string(raw); ok {
|
||||
base = s
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
return path
|
||||
}
|
||||
|
||||
// Only prefix root-relative paths; leave document-relative ones alone.
|
||||
if path[0] == '/' {
|
||||
return fmt.tprintf("%s%s", base, path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
apply_format :: proc(
|
||||
iso: string,
|
||||
args: []string,
|
||||
@@ -386,7 +433,15 @@ apply_format :: proc(
|
||||
}
|
||||
|
||||
// Groups preserve first-appearance order from the input list.
|
||||
apply_group_by :: proc(value: any, args: []string, pos: int, span: int) -> (result: any, err: Error) {
|
||||
apply_group_by :: proc(
|
||||
value: any,
|
||||
args: []string,
|
||||
pos: int,
|
||||
span: int,
|
||||
) -> (
|
||||
result: any,
|
||||
err: Error,
|
||||
) {
|
||||
if len(args) != 1 {
|
||||
hint := ""
|
||||
if len(args) == 0 {
|
||||
@@ -406,7 +461,12 @@ apply_group_by :: proc(value: any, args: []string, pos: int, span: int) -> (resu
|
||||
|
||||
elem_info, count, data := list_info(value)
|
||||
if elem_info == nil {
|
||||
return nil, Error_Body{msg = "group_by expects a list", pos = pos, span = span, kind = .Data}
|
||||
return nil, Error_Body {
|
||||
msg = "group_by expects a list",
|
||||
pos = pos,
|
||||
span = span,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
|
||||
groups := make([dynamic]Group, 0, 8, context.temp_allocator)
|
||||
@@ -486,13 +546,13 @@ take :: proc(
|
||||
n := 1
|
||||
if !is_list && len(args) == 0 {
|
||||
warning = Error_Body {
|
||||
msg = "defaulting to n=1 for string",
|
||||
pos = op_pos,
|
||||
span = span,
|
||||
kind = .Warning,
|
||||
msg = "defaulting to n=1 for string",
|
||||
pos = op_pos,
|
||||
span = span,
|
||||
kind = .Warning,
|
||||
source = tmpl.source,
|
||||
path = tmpl.path,
|
||||
hint = "consider specifying n explicitly",
|
||||
path = tmpl.path,
|
||||
hint = "consider specifying n explicitly",
|
||||
}
|
||||
}
|
||||
if len(args) > 0 {
|
||||
@@ -525,13 +585,13 @@ take :: proc(
|
||||
op_name := from_start ? "first" : "last"
|
||||
alt_name := from_start ? "last" : "first"
|
||||
warning = Error_Body {
|
||||
msg = fmt.tprintf("%s %d is equivalent to %s %d", op_name, n, alt_name, 0 - n),
|
||||
pos = op_pos,
|
||||
span = span,
|
||||
kind = .Warning,
|
||||
msg = fmt.tprintf("%s %d is equivalent to %s %d", op_name, n, alt_name, 0 - n),
|
||||
pos = op_pos,
|
||||
span = span,
|
||||
kind = .Warning,
|
||||
source = tmpl.source,
|
||||
path = tmpl.path,
|
||||
hint = "consider using the clearer form",
|
||||
path = tmpl.path,
|
||||
hint = "consider using the clearer form",
|
||||
}
|
||||
from_start = !from_start
|
||||
n = 0 - n
|
||||
@@ -557,4 +617,3 @@ take :: proc(
|
||||
return any{new_clone(str[start:end], context.temp_allocator), typeid_of(string)}, warning
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ Site_Context :: struct {
|
||||
title: string,
|
||||
description: string,
|
||||
base_url: string,
|
||||
// base_path is the path component of base_url, normalized to a leading slash
|
||||
// with no trailing slash ("" for root-hosted sites). Templates prefix internal
|
||||
// root-relative links with it (directly, or via the `rel_url` pipe) so that
|
||||
// subpath deployments — e.g. project pages at https://user.github.io/proj —
|
||||
// resolve correctly.
|
||||
base_path: string,
|
||||
params: json.Object,
|
||||
og: Open_Graph,
|
||||
menus: map[string][]Menu_Entry,
|
||||
@@ -127,6 +133,7 @@ init_site :: proc(site: ^Site, flags: Flags) {
|
||||
}
|
||||
|
||||
site_apply_cli_flags(site, flags)
|
||||
site.base_path = site_base_path(site.base_url, site_allocator(site))
|
||||
site.config_path = path
|
||||
|
||||
site.og = og_for_site(site)
|
||||
@@ -213,6 +220,48 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string)
|
||||
site.queries = expand_path(config.queries, site_allocator(site))
|
||||
}
|
||||
|
||||
// site_base_path extracts the path component of base_url, normalized to a
|
||||
// leading slash with no trailing slash. Returns "" for root-hosted sites.
|
||||
//
|
||||
// "https://ex.com/thor/" → "/thor"
|
||||
// "https://ex.com/a/b" → "/a/b"
|
||||
// "https://ex.com" → ""
|
||||
// "http://localhost:8080/" → ""
|
||||
site_base_path :: proc(base_url: string, allocator := context.allocator) -> string {
|
||||
url := base_url
|
||||
|
||||
// Strip scheme so the first '/' we find is the start of the path, not the
|
||||
// "//" separating scheme and host.
|
||||
if idx := strings.index(url, "://"); idx >= 0 {
|
||||
url = url[idx + 3:]
|
||||
}
|
||||
|
||||
// The path begins at the first '/' after the host.
|
||||
slash := strings.index_byte(url, '/')
|
||||
if slash < 0 {
|
||||
return ""
|
||||
}
|
||||
path := url[slash:]
|
||||
|
||||
// Drop any query or fragment.
|
||||
if idx := strings.index_byte(path, '?'); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
if idx := strings.index_byte(path, '#'); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
|
||||
// Trim trailing slashes; a bare "/" normalizes to "".
|
||||
for len(path) > 0 && path[len(path) - 1] == '/' {
|
||||
path = path[:len(path) - 1]
|
||||
}
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.clone(path, allocator)
|
||||
}
|
||||
|
||||
// expand_path replaces a leading ~/ with $HOME/.
|
||||
expand_path :: proc(path: string, allocator := context.allocator) -> string {
|
||||
if len(path) >= 2 && path[0] == '~' && path[1] == '/' {
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"date": {
|
||||
"timezone": "America/New_York"
|
||||
"timezone": "America/New_York",
|
||||
"format": "January 2, 2006"
|
||||
},
|
||||
"markdown_extensions": {
|
||||
"footnotes": true
|
||||
|
||||
@@ -120,6 +120,12 @@ foreign css_grammar {
|
||||
tree_sitter_css :: proc() -> Language ---
|
||||
}
|
||||
|
||||
// THREAD-SAFETY: a Grammar_Cache bundles shareable tree-sitter objects
|
||||
// (`language`, `query` — immutable, safe to read from many threads) with
|
||||
// per-thread ones (`parser`, `cursor` — a TSParser mutates its internal
|
||||
// subtree pool during a parse, and a TSQueryCursor is likewise stateful).
|
||||
// A cached Grammar_Cache is therefore SINGLE-THREADED-USE: exactly one thread
|
||||
// may drive `parser`/`cursor` at a time. See GRAMMAR_CACHE_THREADING.md.
|
||||
Grammar_Cache :: struct {
|
||||
language: Language,
|
||||
parser: Parser,
|
||||
@@ -133,6 +139,12 @@ Get_Language_Proc :: #type proc() -> Language
|
||||
SPALL :: #config(SPALL, false)
|
||||
|
||||
grammar_store: Grammar_Store
|
||||
|
||||
// cache_mutex guards writes to grammar_store.cache. NOTE: today it is only
|
||||
// taken on the parallel preload path (grammar_worker), NOT in ensure_parser /
|
||||
// load_grammar. Those lazy paths are intentionally unlocked because production
|
||||
// only ever calls them single-threaded. See GRAMMAR_CACHE_THREADING.md before
|
||||
// relying on this cache from multiple threads.
|
||||
cache_mutex: sync.Mutex
|
||||
|
||||
Grammar_Store :: struct {
|
||||
@@ -140,6 +152,14 @@ Grammar_Store :: struct {
|
||||
allocator: mem.Allocator,
|
||||
}
|
||||
|
||||
// KNOWN ISSUE (allocator lifetime): grammar_store is a process-lifetime global,
|
||||
// but this captures whatever `context.allocator` is live at call time. In main
|
||||
// that is deliberately the heap (init_persistent runs before the site arena is
|
||||
// installed), so it works. But it is fragile: called from any other context
|
||||
// (e.g. a per-test tracking allocator, or lazily once the site arena is active)
|
||||
// it would bind the cache to a transient allocator and leave it dangling once
|
||||
// that allocator is torn down. The intended fix is to pin the heap allocator
|
||||
// explicitly (runtime.heap_allocator()). See GRAMMAR_CACHE_THREADING.md.
|
||||
init_persistent :: proc() {
|
||||
grammar_store.allocator = context.allocator
|
||||
grammar_store.cache = make(map[string]^Grammar_Cache, grammar_store.allocator)
|
||||
@@ -219,6 +239,12 @@ load_language :: proc(lang: string) -> (language: Language, ok: bool) {
|
||||
return
|
||||
}
|
||||
|
||||
// THREAD-SAFETY: ensure_parser is NOT safe to call concurrently. It reads and
|
||||
// writes grammar_store.cache without taking cache_mutex, and it returns a shared
|
||||
// per-language Grammar_Cache whose `parser` is not reentrant. It is written for
|
||||
// single-threaded lazy use (minify, syntax highlighting). Callers that need
|
||||
// parsing on multiple threads must serialize with a lock or give each thread
|
||||
// its own parser. See GRAMMAR_CACHE_THREADING.md.
|
||||
ensure_parser :: proc(lang: string) -> ^Grammar_Cache {
|
||||
if cached, ok := grammar_store.cache[lang]; ok {
|
||||
return cached
|
||||
|
||||
Reference in New Issue
Block a user