Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ebdb817
Disable per-page ResizeObserver: it caught no real reflows (~130ms sa…
KubaO May 22, 2026
78fccf0
Add Chrome trace analysis.
KubaO May 22, 2026
1c665b2
Compress book.html; layer :post_render hooks by role.
KubaO May 22, 2026
272d47b
Disable WhiteSpaceFilter by default; opt-in via PagedConfig.
KubaO May 22, 2026
688ad1f
Strip all async from paged.js render chain; RunMicrotasks 6333->0.56ms.
KubaO May 23, 2026
7886aed
Hybrid trace: embed V8 cpu_profiler samples in --tracing output.
KubaO May 23, 2026
0a968d9
Add analyze-hybrid.mjs: bottom-up + callees view across JS and Blink.
KubaO May 23, 2026
4ce5289
Per-section CSS cost attribution via ab-css.mjs; defer pageRanges sha…
KubaO May 23, 2026
c08fc86
ab-css: pair-paired diffs + Windows /affinity auto-relaunch for stabl…
KubaO May 23, 2026
4e4be3c
ab-css: sweep rouge.css and print.css extras; document findings.
KubaO May 23, 2026
df395ef
Extract pin-cpu.mjs; auto-pin measure, profile-load, profile-roundtri…
KubaO May 23, 2026
7809dfc
Ship --disable-gpu pair; add memory + parallel-generate probes.
KubaO May 23, 2026
921f2a4
probe-renderer-mem: add --heap-snapshot for retainer-chain investigat…
KubaO May 23, 2026
98b292b
Retention investigation: it's unswept Oilpan garbage, not a JS leak.
KubaO May 23, 2026
929f579
Document Chromium-internal PDF-generation approaches in CHROMIUM.md.
KubaO May 23, 2026
6a0235c
Factor the performance README.
KubaO May 23, 2026
1142534
Make --detach-pages the default.
KubaO May 23, 2026
1b4fad6
Make --timing opt-in rather than opt-out (--no-timing).
KubaO May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ From `docs/`:
- `check.bat` — link check (offline Lychee against `_site/`).
- `book.bat` — renders the PDF from `_site-pdf/book.html` via `pagedjs-cli` into `_pdf/book.pdf`. Run `build.bat` first to populate `_site-pdf/`.

The HTML whitespace compression that wraps every page's render chain is handled by `_plugins/html-compress.rb` rather than the just-the-docs theme's `vendor/compress.html` Liquid layout — see [_plugins/html-compress.md](docs/_plugins/html-compress.md) for the full writeup. The Liquid layout's per-page cost in the profile was ~2.4s of Liquid filter dispatch (a `split: " " | join: " "` over the outside-of-`<pre>` content, lowering to a per-page Array allocation of every whitespace-delimited token across 837 pages — millions of small `String` objects). The layout is short-circuited via `compress_html.ignore.envs: all` in `_config.yml`; it then outputs a bare `{{ content }}` and the plugin takes over at `:pages, :post_render` / `:documents, :post_render` with `priority :high`, doing the same pre-block-protected whitespace collapse via `content.split(PRE_BLOCK_RE).each { |s| s.split(" ").join(" ") }` in C-implemented Ruby. The `priority :high` annotation places this hook before offlinify and pdfify (both `:normal`) so they see the compressed bytes. Pages whose layout chain doesn't reach `vendor/compress` are gated out via a `:site, :pre_render` precompute that walks `site.layouts[name].data["layout"]` for every layout key and marks the entire compress-reaching chain (default → table_wrappers → vendor/compress) -- jekyll-redirect-from stubs, the SCSS-derived CSS pages, `assets/js/zzzz-search-data.json`, and `book.html` (which uses the minimal `book-combined` layout that has no parent) all stay un-gated and pass through verbatim, matching exactly what the Liquid layout would have processed. Output is byte-identical to the layout-based version: a recursive `diff -rq` of `_site/` against a vendor/compress.html baseline reports zero differences across all ~840 HTML pages, 290 redirect stubs, every CSS / JSON / SVG / image asset. The plugin's correctness depended on two non-obvious details that broke an earlier cut -- the layout-chain walk has to compare against the layout *key* (`"vendor/compress"`) rather than `layout.name` (which carries the `.html` extension), and the per-segment `split(" ").join(" ")` strips trailing whitespace that the Liquid layout's *template* re-adds via its trailing-newline source character, so the plugin captures `content.end_with?("\n")` before the split and re-appends a `\n` after the join. Both regressions surfaced as nonzero `diff -rq` counts during development and are flagged in the plugin's header comment and [_plugins/html-compress.md](docs/_plugins/html-compress.md).
The HTML whitespace compression that wraps every page's render chain is handled by `_plugins/html-compress.rb` rather than the just-the-docs theme's `vendor/compress.html` Liquid layout — see [_plugins/html-compress.md](docs/_plugins/html-compress.md) for the full writeup. The Liquid layout's per-page cost in the profile was ~2.4s of Liquid filter dispatch (a `split: " " | join: " "` over the outside-of-`<pre>` content, lowering to a per-page Array allocation of every whitespace-delimited token across 837 pages — millions of small `String` objects). The layout is short-circuited via `compress_html.ignore.envs: all` in `_config.yml`; it then outputs a bare `{{ content }}` and the plugin takes over at `:pages, :post_render` / `:documents, :post_render` with `priority :normal`, doing the same pre-block-protected whitespace collapse via `content.split(PRE_BLOCK_RE).each { |s| s.split(" ").join(" ") }` in C-implemented Ruby. The `:normal` priority is the *middle* tier of a three-level convention across the site's `:post_render` hooks: mutators (`book-href-rewrite`) run at `:high`, this cleanup pass at `:normal`, readers (`pdfify`, `offlinify`) at `:low`. The invariant "compress runs after every mutator and before every reader" therefore holds by construction; no downstream plugin has to be whitespace-aware. Pages whose layout chain doesn't reach `vendor/compress` are gated out via a `:site, :pre_render` precompute that walks `site.layouts[name].data["layout"]` for every layout key and marks the entire compress-reaching chain (default → table_wrappers → vendor/compress) -- jekyll-redirect-from stubs, the SCSS-derived CSS pages, and `assets/js/zzzz-search-data.json` all stay un-gated and pass through verbatim. `book.html` (which uses the minimal `book-combined` layout that has no parent) is *also* outside that chain but is explicitly added to the compress-eligible set at the end of the precompute, so the same whitespace collapse runs on it -- saves paged.js's render-time `WhiteSpaceFilter` ~37k DOM mutations (~28k `textContent` overwrites + ~9k `removeChild` calls) at the cost of ~480 ms once per Jekyll build. Output is byte-identical to the layout-based version: a recursive `diff -rq` of `_site/` against a vendor/compress.html baseline reports zero differences across all ~840 HTML pages, 290 redirect stubs, every CSS / JSON / SVG / image asset. The plugin's correctness depended on two non-obvious details that broke an earlier cut -- the layout-chain walk has to compare against the layout *key* (`"vendor/compress"`) rather than `layout.name` (which carries the `.html` extension), and the per-segment `split(" ").join(" ")` strips trailing whitespace that the Liquid layout's *template* re-adds via its trailing-newline source character, so the plugin captures `content.end_with?("\n")` before the split and re-appends a `\n` after the join. Both regressions surfaced as nonzero `diff -rq` counts during development and are flagged in the plugin's header comment and [_plugins/html-compress.md](docs/_plugins/html-compress.md).

### Profiling the build

Expand Down
6 changes: 5 additions & 1 deletion docs/_plugins/book-href-rewrite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,11 @@ def self.process(page)
end
end

Jekyll::Hooks.register :pages, :post_render do |page|
# :high so this MUTATOR runs before html-compress (priority :normal).
# Otherwise the landing-heading strip leaves a double-space run that
# no downstream pass cleans up. See html-compress.rb's priority
# convention comment for the full layering.
Jekyll::Hooks.register :pages, :post_render, priority: :high do |page|
next unless page.path == "book.html"
BookHrefRewrite.process(page)
end
34 changes: 28 additions & 6 deletions docs/_plugins/html-compress.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# HtmlCompress

`_plugins/html-compress.rb` runs the HTML whitespace compression that wraps every page's render chain — the same job just-the-docs's vendor/compress.html Liquid layout was doing, but in Ruby instead of Liquid filters. Output is byte-identical to the layout-based version (verified by recursive diff of every file in `_site/` against a vendor/compress.html baseline). The Liquid layout is short-circuited to a `{{ content }}` passthrough via `compress_html.ignore.envs: all` in `_config.yml`; the plugin then runs at `:pages, :post_render` / `:documents, :post_render` with `priority :high`, so the compressed bytes are what offlinify and Jekyll's writer see.
`_plugins/html-compress.rb` runs the HTML whitespace compression that wraps every page's render chain — the same job just-the-docs's vendor/compress.html Liquid layout was doing, but in Ruby instead of Liquid filters. Output is byte-identical to the layout-based version for the 837 vendor/compress-reaching pages (verified by recursive diff of every file in `_site/` against a vendor/compress.html baseline). The Liquid layout is short-circuited to a `{{ content }}` passthrough via `compress_html.ignore.envs: all` in `_config.yml`; the plugin then runs at `:pages, :post_render` / `:documents, :post_render` with `priority :normal` as the *cleanup* step in a three-tier `:high` → `:normal` → `:low` ordering (mutators → compress → readers — see [Hook priority convention](#hook-priority-convention) below). It also picks up one page the original layout didn't process, `book.html`, via an explicit `book-combined` addition to the compress-eligible set — see [book.html inclusion](#bookhtml-inclusion).

This file sits in `_plugins/` for the same reasons as `offlinify.md` and `pdfify.md`: it lives next to the code it documents, and Jekyll's `_plugins/` folder is plugin-only territory, so this Markdown never gets rendered into the public site.

Expand Down Expand Up @@ -74,7 +74,7 @@ page.md (layout: default)
└── vendor/compress.html (no layout)
```

Pages that don't use any of these layouts — jekyll-redirect-from stubs, the SCSS-derived CSS pages, `assets/js/zzzz-search-data.json`, `book.html` (which uses the minimal `book-combined` layout that has no parent) — were left untouched by the layout. The plugin has to match that gating, otherwise it would compress files that compress.html doesn't, breaking byte-identity.
Pages that don't use any of these layouts — jekyll-redirect-from stubs, the SCSS-derived CSS pages, `assets/js/zzzz-search-data.json` — were left untouched by the layout. The plugin has to match that gating, otherwise it would compress files that compress.html doesn't, breaking byte-identity. `book.html` (which uses the minimal `book-combined` layout that has no parent) was originally in this list, but is now explicitly added to the compress-eligible set — see [book.html inclusion](#bookhtml-inclusion).

The gate is precomputed once at `:site, :pre_render`:

Expand Down Expand Up @@ -114,20 +114,42 @@ Jekyll::Hooks.register :site, :pre_render do |site|
HtmlCompress.precompute_compress_layouts!(site)
end

Jekyll::Hooks.register :pages, :post_render, priority: :high do |page|
Jekyll::Hooks.register :pages, :post_render, priority: :normal do |page|
next unless page.output.is_a?(String)
next unless HtmlCompress.compress?(page)
HtmlCompress.compress!(page.output)
end

Jekyll::Hooks.register :documents, :post_render, priority: :high do |doc|
Jekyll::Hooks.register :documents, :post_render, priority: :normal do |doc|
next unless doc.output.is_a?(String)
next unless HtmlCompress.compress?(doc)
HtmlCompress.compress!(doc.output)
end
```

The `priority: :high` is what places the plugin *before* `offlinify.rb` and `pdfify.rb` in the per-page render-hook order — both of those use the default `:normal` priority and rely on reading the final compressed `page.output`. Jekyll runs `:post_render` hooks in descending priority, so `:high` (30) fires before `:normal` (20). Without the priority annotation the order would be insertion-order across all `.rb` files in `_plugins/`, which is not a stable contract.
## Hook priority convention

The `priority: :normal` is the middle tier of a three-level ordering for `:pages, :post_render` and `:documents, :post_render` hooks across the plugin set. Jekyll runs hooks in descending priority (`:high` (30) → `:normal` (20) → `:low` (10)), and the three tiers carry distinct roles:

| Tier | Role | Plugins |
| --- | --- | --- |
| `:high` (30) | **Mutators.** Modify `page.output` so the final bytes reflect this pass. | `book-href-rewrite` (chapter href rewrites + landing-heading strip on `book.html`). |
| `:normal` (20) | **Compress.** The cleanup pass. Sandwiched between mutators and readers so any whitespace runs left behind by a mutator's `gsub` get collapsed before any reader captures the bytes. | `html-compress` (this plugin). |
| `:low` (10) | **Readers.** Snapshot or consume `page.output` after the cleanup pass. | `pdfify` (captures `book.html` for the PDF pipeline), `offlinify` (per-page href / src rewrites + write to `_site-offline/`). |

The layering was originally implicit: the plugin sat at `:high` next to no other priority-annotated `:post_render` hooks. That worked until `book-href-rewrite` joined the set at default `:normal`. Its landing-heading strip ran *after* compress, removing `<h2>` blocks but leaving the (already-collapsed) single-space runs on either side adjacent — producing literal `> <` blobs in three chapter openings that paged.js's WhiteSpaceFilter then had to handle at render time. Promoting `book-href-rewrite` to `:high` and demoting compress to `:normal` makes the invariant "compress is the last cleanup step among mutators" hold by construction; demoting the readers to `:low` makes "readers see the final compressed output" hold by construction. Future plugins choose their tier by their role and the ordering composes automatically.

The full priority story is documented as a comment block above the `Jekyll::Hooks.register` calls in [`html-compress.rb`](html-compress.rb); each of the four affected plugins (this one, `book-href-rewrite`, `pdfify`, `offlinify`) carries a one-line note pointing back to that block.

## book.html inclusion

The layout-chain walk above only marks layouts that reach `vendor/compress`. `book.html` uses the minimal `book-combined` layout, which has no parent, so the walk never reaches it and the page was originally skipped (matching the layout's behaviour). After investigation of paged.js's per-render `WhiteSpaceFilter` work (see [`perf/README.md`](../../perf/README.md)) showed it doing ~37k DOM mutations at render time to handle whitespace text nodes that *would* have been collapsed if the page had been compressed at Jekyll build time, the precompute was extended to mark `book-combined` explicitly:

```ruby
@compress_layouts << "book-combined" if site.layouts.key?("book-combined")
```

at the end of `precompute_compress_layouts!`. Output: `book.html` now passes through `compress!` once per build (~480 ms of additional `String#split` work on the ~5.5 MB document), saving roughly the same wall-clock at paged.js render time (~28k `textContent` overwrites + ~9k `removeChild` calls eliminated). Net is approximately wall-clock-neutral for full builds, and a small net win for incremental Jekyll workflows that skip the PDF (`also_build_pdf: false`) — the compress cost is paid once per Jekyll build, the render saving is paid every PDF build, and decoupling the two is the structural improvement.

## Verification

Expand Down Expand Up @@ -157,6 +179,6 @@ In source order in [`html-compress.rb`](html-compress.rb):

- `precompute_compress_layouts!(site)` — `:site, :pre_render` entry. Walks every layout chain via `data["layout"]`, marks each layout on the path as compress-ending the moment the walk hits `vendor/compress`. Idempotent; the resulting `@compress_layouts` set persists across builds in `jekyll serve` and gets rebuilt fresh each `:pre_render`.

- `compress?(page)` — gate check. Returns `true` when the page's `data["layout"]` is in `@compress_layouts`. Pages without a layout (jekyll-redirect-from stubs, SCSS-derived CSS, JSON-via-page-rendering, `book.html` via `book-combined`) return `false` and skip the compression entirely.
- `compress?(page)` — gate check. Returns `true` when the page's `data["layout"]` is in `@compress_layouts`. Pages without a layout (jekyll-redirect-from stubs, SCSS-derived CSS, JSON-via-page-rendering) return `false` and skip the compression entirely. `book.html` (which uses `book-combined`, a minimal layout with no parent) used to land here too; it is now explicitly added to the set by `precompute_compress_layouts!` — see [book.html inclusion](#bookhtml-inclusion).

- `compress!(content)` — the actual compression, in place. Captures the trailing-newline state, splits by `PRE_BLOCK_RE` with the capture group so pre bodies are preserved in the result array, runs `split(" ").join(" ")` on every outside-of-pre segment, joins, restores the trailing newline if needed, then mutates the input string via `String#replace`. The `replace` is what lets us hand back the same string object the caller passed in — Jekyll's writer reads `page.output` after `:post_render`, so in-place mutation is the cheapest way to update what gets written.
43 changes: 38 additions & 5 deletions docs/_plugins/html-compress.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ def self.precompute_compress_layouts!(site)
cur_name = cur ? cur.data["layout"] : nil
end
end
# book-combined is a minimal layout with no parent, so the walk
# above doesn't reach it. Compressing its only consumer (book.html)
# at Jekyll time saves paged.js's WhiteSpaceFilter ~37k DOM
# mutations and ~300-400 ms once per render -- see
# perf/README.md "WhiteSpaceFilter that wasn't" section.
@compress_layouts << "book-combined" if site.layouts.key?("book-combined")
end

# True when `page` (or document) uses a layout chain ending in
Expand Down Expand Up @@ -117,16 +123,43 @@ def self.compress!(content)
HtmlCompress.precompute_compress_layouts!(site)
end

# Run before offlinify (default :normal priority) so the offline-tree
# rewrites see the compressed page.output, and before Jekyll's
# `:site, :post_write` writes _site/ for the same reason.
Jekyll::Hooks.register :pages, :post_render, priority: :high do |page|
# Priority convention for :pages, :post_render hooks in this site:
#
# :high = MUTATORS. Plugins that modify page.output. Run first so
# their mutations are visible to compress and downstream
# readers. Examples: book-href-rewrite (landing heading
# strip + in-book href rewrites).
#
# :normal = COMPRESS. This plugin. The cleanup pass, sandwiched
# between mutators and readers so any whitespace runs left
# behind by a mutator's gsub get collapsed before anyone
# reads the final bytes.
#
# :low = READERS. Plugins that snapshot or consume page.output
# after all mutations and the compress pass. Run last so
# they see final output. Examples: pdfify (captures
# book.html for the PDF pipeline), offlinify (rewrites
# root-absolute hrefs and writes to _site-offline/).
#
# Without this layering, a mutator running after compress leaves
# adjacent whitespace runs that no downstream pass collapses; a
# reader running before compress captures uncompressed bytes. Both
# regressions surfaced when book-href-rewrite (default :normal) ran
# after html-compress (originally :high) -- its 3 landing-heading
# strips left double-space artifacts that paged.js's WhiteSpaceFilter
# had to handle at render time.
#
# Offlinify also runs at :site, :post_write (a later phase entirely),
# where it always sees the final compressed bytes regardless of
# per-page priority. The :low designation here governs its per-page
# capture hook specifically.
Jekyll::Hooks.register :pages, :post_render, priority: :normal do |page|
next unless page.output.is_a?(String)
next unless HtmlCompress.compress?(page)
HtmlCompress.compress!(page.output)
end

Jekyll::Hooks.register :documents, :post_render, priority: :high do |doc|
Jekyll::Hooks.register :documents, :post_render, priority: :normal do |doc|
next unless doc.output.is_a?(String)
next unless HtmlCompress.compress?(doc)
HtmlCompress.compress!(doc.output)
Expand Down
6 changes: 4 additions & 2 deletions docs/_plugins/offlinify.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1443,11 +1443,13 @@ def self.decode(path)
Offlinify.setup(site)
end

Jekyll::Hooks.register :pages, :post_render do |page|
# :low so these READERS see page.output after html-compress (:normal)
# has run. See html-compress.rb's priority convention.
Jekyll::Hooks.register :pages, :post_render, priority: :low do |page|
Offlinify.process_page(page)
end

Jekyll::Hooks.register :documents, :post_render do |doc|
Jekyll::Hooks.register :documents, :post_render, priority: :low do |doc|
Offlinify.process_page(doc)
end

Expand Down
4 changes: 3 additions & 1 deletion docs/_plugins/pdfify.rb
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,9 @@ def self.copy_file(src, dst)
Pdfify.setup(site)
end

Jekyll::Hooks.register :pages, :post_render do |page|
# :low so this READER captures page.output after html-compress
# (:normal) has run. See html-compress.rb's priority convention.
Jekyll::Hooks.register :pages, :post_render, priority: :low do |page|
Pdfify.maybe_capture(page)
end

Expand Down
Loading
Loading