From 186c80af28c36c09a0d4581e8ca688d956dc7f3c Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Thu, 14 May 2026 23:46:18 +0200 Subject: [PATCH 01/11] Save about 7s of build time. --- docs/_includes/components/breadcrumbs.html | 35 ++++++ docs/_plugins/breadcrumbs-precompute.rb | 125 +++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 docs/_includes/components/breadcrumbs.html create mode 100644 docs/_plugins/breadcrumbs-precompute.rb diff --git a/docs/_includes/components/breadcrumbs.html b/docs/_includes/components/breadcrumbs.html new file mode 100644 index 00000000..0e54ea08 --- /dev/null +++ b/docs/_includes/components/breadcrumbs.html @@ -0,0 +1,35 @@ +{%- comment -%} + Shadow of just-the-docs's _includes/components/breadcrumbs.html. + + Renders the breadcrumb trail above each page's body by iterating the + precomputed chain attached to each page by + `_plugins/breadcrumbs-precompute.rb`. The upstream version walks the + cached site_nav HTML per page (~7 s across the site); offloading the + walk to Ruby makes this include essentially free. + + Expected data shape on `page.breadcrumb_chain`: + Array of { "title" => String, "url" => String }, ordered root-first + (the ancestor nearest the site root is at index 0). The current + page itself is not in the chain -- it is rendered separately as the + final `
  • ` with a ``. + + If the plugin is absent or the page has no chain (e.g. root pages + without `parent`), `page.breadcrumb_chain` is nil and the for-loop + emits nothing; only the current-page `` is rendered, but the + outer guard `page.parent` prevents the whole block in that case. + + Output parity with the upstream version: anchors carry no class + attribute, items use `breadcrumb-nav-list-item`, and the current + page is wrapped in a `` rather than an ``. +{%- endcomment -%} + +{%- if page.url != "/" and page.parent and page.title -%} + +{%- endif -%} diff --git a/docs/_plugins/breadcrumbs-precompute.rb b/docs/_plugins/breadcrumbs-precompute.rb new file mode 100644 index 00000000..d0d63263 --- /dev/null +++ b/docs/_plugins/breadcrumbs-precompute.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +# Precomputes per-page breadcrumb chains so that the breadcrumbs include +# can iterate a ready-made array instead of resolving ancestors at render +# time. +# +# === Problem === +# +# just-the-docs's stock `_includes/components/breadcrumbs.html` walks the +# cached `site_nav` HTML (~150 KB) by string-splitting it at the current +# page's `` tag and stepping backward through `` boundaries to +# recover the ancestor anchors. That is roughly O(nav size) per page, so +# at ~830 pages it sits at ~7 s of the RENDER phase. +# +# An equivalent Liquid rewrite using `where:` filters on +# `site.html_pages` does not help: each `where:` is itself O(N_pages), +# and the total number of comparisons works out to the same order of +# magnitude as the upstream's string scanning. Liquid lacks a real +# hash-lookup primitive, so walking the `parent` / `grand_parent` chain +# inside a template cannot beat the upstream's complexity. +# +# === Approach === +# +# Move the chain resolution out of Liquid and into Ruby, where a hash +# lookup actually is O(1). At generate time (after `site.read`, before +# rendering) we: +# +# 1. Collect every page that has a `title`. Pages without a title are +# not nav-eligible -- they cannot appear in any breadcrumb chain. +# 2. Build a `title -> [pages]` hash. Most titles are unique on this +# site, but a handful (e.g. "Enumerations", which appears under +# WebView2, CEF, CustomControls, WinServicesLib, and +# WinNativeCommonCtls) need disambiguation. We keep the full list +# of pages per title and disambiguate on lookup. +# 3. For each page with a `parent`, walk the chain upward: +# - At each step, look up the next ancestor by title. +# - If the current page has `grand_parent`, narrow the candidate +# set to ancestors whose own `parent` matches `grand_parent`. +# When no candidate matches the narrowed criterion we fall back +# to the first title hit -- the convention on this site is to +# declare `grand_parent` only when needed, so the fall-through +# case is safe by construction. +# - Push the ancestor onto the chain and step up. The depth is +# bounded at MAX_DEPTH to defend against accidental cycles in +# frontmatter. +# 4. Store the resulting chain on `page.data['breadcrumb_chain']` as +# an Array of `{ "title" => String, "url" => String }` hashes, +# ordered root-first. The shape is deliberately a plain Hash (not +# a Page reference) so Liquid can render it without dragging in a +# full Page Drop -- which would defeat the point of precomputing. +# +# `_includes/components/breadcrumbs.html` consumes +# `page.breadcrumb_chain` directly. The rendered HTML is byte-identical +# to the upstream's output. +# +# === Compatibility === +# +# Reads only `page.data['title']`, `page.data['parent']`, and +# `page.data['grand_parent']`, plus `page.url`. These are all stable +# Jekyll/just-the-docs fields. The plugin neither registers Liquid +# filters nor mutates anything other than `page.data` -- if it is +# removed, the breadcrumbs include's `for entry in page.breadcrumb_chain` +# simply iterates `nil` and emits no ancestor entries (just the +# current-page span), which is a graceful failure mode. +# +# MAX_DEPTH is set to 8: the deepest chain on the site today is 5 +# levels (Reference Section -> Packages -> VBA Package -> Strings +# Module -> Len). 8 leaves comfortable headroom for future growth and +# guarantees termination on any frontmatter that contains a cycle. + +module BreadcrumbsPrecompute + MAX_DEPTH = 8 + + class Generator < Jekyll::Generator + safe true + priority :normal + + def generate(site) + # `site.html_pages` is a Liquid-drop method; from Ruby we filter + # `site.pages` directly. We only care about pages with a `title`, + # since pages without one cannot appear in any breadcrumb chain. + titled = site.pages.select { |p| p.data["title"] } + by_title = titled.group_by { |p| p.data["title"] } + + titled.each do |page| + page.data["breadcrumb_chain"] = chain_for(page, by_title) + end + end + + private + + def chain_for(page, by_title) + chain = [] + current = page + + MAX_DEPTH.times do + parent_title = current.data["parent"] + break if parent_title.nil? || parent_title.to_s.empty? + + parent = resolve_parent(parent_title, current.data["grand_parent"], by_title) + break unless parent + + chain.unshift( + "title" => parent.data["title"], + "url" => parent.url, + ) + current = parent + end + + chain + end + + def resolve_parent(parent_title, grand_parent_title, by_title) + candidates = by_title[parent_title] + return nil unless candidates + + if grand_parent_title + narrowed = candidates.find { |c| c.data["parent"] == grand_parent_title } + return narrowed if narrowed + end + + candidates.first + end + end +end From 8d85c6cfc1692df01468522d0c0a56b3d1aa3fe7 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Thu, 14 May 2026 23:58:37 +0200 Subject: [PATCH 02/11] Save about 1s of build time. --- docs/_includes/components/children_nav.html | 40 +++++ docs/_plugins/children-precompute.rb | 164 ++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 docs/_includes/components/children_nav.html create mode 100644 docs/_plugins/children-precompute.rb diff --git a/docs/_includes/components/children_nav.html b/docs/_includes/components/children_nav.html new file mode 100644 index 00000000..d24b3e71 --- /dev/null +++ b/docs/_includes/components/children_nav.html @@ -0,0 +1,40 @@ +{%- comment -%} + Shadow of just-the-docs's _includes/components/children_nav.html. + + Renders the auto-generated child-pages TOC at the bottom of any page + whose `has_toc != false`. The upstream implementation includes the + cached `site_nav` HTML with `all=true` to probe for the current + page's children, then re-derives the children via + `where:` / `group_by:` on `site.html_pages`. The probe is the only + thing that forces a second site_nav cache key (alongside + `sidebar.html`'s `all=nil` variant); eliminating the probe halves the + nav-rendering work. + + The list of immediate children is now precomputed at site-generate + time by `_plugins/children-precompute.rb` and exposed as + `page.children_in_nav`. Each entry is a hash: + + { "title" => String, "url" => String, "summary" => String | nil } + + ordered with the same nav_order / title precedence and + child_nav_order reversal the upstream applies. + + When the array is empty (a leaf page, or `has_toc != false` on a + page that genuinely has no children), the whole block collapses to + nothing -- byte-identical to the upstream's behaviour on the same + pages. +{%- endcomment -%} + +{%- if page.children_in_nav and page.children_in_nav.size >= 1 -%} + +
    +{% include toc_heading_custom.html %} +
      + {% for nav_child in page.children_in_nav %} +
    • + {{ nav_child.title }}{% if nav_child.summary %} - {{ nav_child.summary }}{% endif %} +
    • + {% endfor %} +
    + +{%- endif -%} diff --git a/docs/_plugins/children-precompute.rb b/docs/_plugins/children-precompute.rb new file mode 100644 index 00000000..402087d0 --- /dev/null +++ b/docs/_plugins/children-precompute.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +# Precomputes the per-page "children in nav" list so that +# `_includes/components/children_nav.html` can render the auto-table-of- +# contents at the bottom of each parent page by iterating a ready-made +# array, without consulting the cached `site_nav` HTML. +# +# === Problem === +# +# just-the-docs's stock `_includes/components/children_nav.html` does +# the following on every page where `has_toc != false`: +# +# 1. Reads `{%- include_cached components/site_nav.html all=true -%}` +# to obtain a rendered nav tree that *includes* `nav_exclude: true` +# pages. +# 2. Splits that HTML at the current page's `` to probe whether +# the next character is ` [pages]` map from frontmatter, the +# inverse of the breadcrumb plugin's title-to-pages map. Pages +# without a `title` are excluded -- they cannot appear in the nav. +# 2. For each titled page, look up its candidate children by title +# and filter: +# - If the child declares `grand_parent`, it must match the +# parent page's own `parent` (the same disambiguation used by +# `components/nav/children.html` for non-unique parent titles +# like "Enumerations"). +# 3. Sort the surviving children with the same precedence +# just-the-docs uses, partitioned by value type so that numeric +# and string `nav_order` / `title` values don't compare against +# each other: +# a. pages with a numeric `nav_order`, ascending +# b. pages with a string `nav_order`, lexicographic +# c. pages with no `nav_order` and a numeric `title`, ascending +# d. pages with no `nav_order` and a string `title`, lexicographic +# Case-insensitive ordering (`site.nav_sort == 'case_insensitive'`) +# is honoured for the string buckets. +# 4. Reverse the resulting array when `page.child_nav_order` is +# `'desc'` or `'reversed'`, matching the same flip the upstream +# `children_nav.html` applies after computing the list. +# 5. Store the result on `page.data['children_in_nav']` as an +# Array of `{ "title" => String, "url" => String, +# "summary" => String | nil }` hashes. Plain hashes (not Page +# references) are used so Liquid renders without dragging in a +# full Page Drop. +# +# `_includes/components/children_nav.html` then iterates the array +# directly. The shadow no longer calls `include_cached +# components/site_nav.html all=true`, and since the breadcrumbs shadow +# also stopped using that variant, nothing on the site triggers the +# `all=true` cache fill. The cached site nav renders exactly once +# (from `sidebar.html` with `all=nil`). +# +# === Output parity === +# +# Children lists are byte-identical to the upstream output when the +# site does not use the `ancestor:` frontmatter (this project does +# not; see WIP.md). The `summary` field, when present, is appended +# in the same form `Title - Summary`. +# +# === Compatibility === +# +# Reads `page.data['title']`, `page.data['parent']`, +# `page.data['grand_parent']`, `page.data['nav_order']`, +# `page.data['summary']`, `page.data['child_nav_order']`, plus +# `page.url`. Honours `site.config['nav_sort']` for case sensitivity. +# Skips pages without a `title`, matching the just-the-docs nav- +# inclusion rule. +# +# If the plugin is removed, the shadow children_nav.html iterates a +# nil array and emits nothing -- pages lose their auto-generated TOC +# but the build is otherwise intact. + +module ChildrenPrecompute + class Generator < Jekyll::Generator + safe true + priority :normal + + REVERSE_FLAGS = %w[desc reversed].freeze + + def generate(site) + # `site.html_pages` is a Liquid-drop accessor; from Ruby we + # filter `site.pages` directly. + titled = site.pages.select { |p| p.data["title"] } + by_parent_title = titled.group_by { |p| (p.data["parent"] || "").to_s } + case_insensitive = site.config["nav_sort"] == "case_insensitive" + + titled.each do |page| + children = children_for(page, by_parent_title) + children = sort_children(children, case_insensitive) + children = children.reverse if REVERSE_FLAGS.include?(page.data["child_nav_order"].to_s) + page.data["children_in_nav"] = children.map do |c| + { + "title" => c.data["title"], + "url" => c.url, + "summary" => c.data["summary"], + } + end + end + end + + private + + def children_for(page, by_parent_title) + (by_parent_title[page.data["title"]] || []).select do |child| + # When a child declares `grand_parent`, it must match the + # parent page's own `parent` -- the same disambiguation the + # upstream `components/nav/children.html` applies. When the + # child omits `grand_parent`, no constraint applies. + gp = child.data["grand_parent"] + gp.nil? || gp == page.data["parent"] + end + end + + # Mirrors the just-the-docs `_includes/components/nav/sorted.html` + # precedence: number/string partitions for each of `nav_order` and + # `title`, concatenated in that order. + def sort_children(pages, case_insensitive) + nav_num, nav_str, title_num, title_str = [], [], [], [] + + pages.each do |p| + if p.data["nav_order"] + (numeric?(p.data["nav_order"]) ? nav_num : nav_str) << p + else + (numeric?(p.data["title"]) ? title_num : title_str) << p + end + end + + nav_num.sort_by! { |p| p.data["nav_order"] } + nav_str.sort_by! { |p| sort_key(p.data["nav_order"], case_insensitive) } + title_num.sort_by! { |p| p.data["title"] } + title_str.sort_by! { |p| sort_key(p.data["title"], case_insensitive) } + + nav_num + nav_str + title_num + title_str + end + + def numeric?(value) + value.is_a?(Numeric) + end + + def sort_key(value, case_insensitive) + s = value.to_s + case_insensitive ? s.downcase : s + end + end +end From 4e5aff2a2a2a90ed55641424ff41b7909687df33 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Fri, 15 May 2026 00:25:50 +0200 Subject: [PATCH 03/11] Save about 7s of build time. --- docs/_includes/css/activation.scss.liquid | 132 +++++++++++ docs/_plugins/nav-levels-precompute.rb | 269 ++++++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 docs/_includes/css/activation.scss.liquid create mode 100644 docs/_plugins/nav-levels-precompute.rb diff --git a/docs/_includes/css/activation.scss.liquid b/docs/_includes/css/activation.scss.liquid new file mode 100644 index 00000000..14f21db8 --- /dev/null +++ b/docs/_includes/css/activation.scss.liquid @@ -0,0 +1,132 @@ +{%- comment -%} + Shadow of just-the-docs's _includes/css/activation.scss.liquid. + + Emits the per-page CSS that highlights the current page's nav entry, + unfolds its ancestor collections, and rotates their expander icons -- + the no-JS fallback that lives in the `` element just before ``. The gem +# author's stated reason (in a code comment) is that GitHub Pages's Jekyll +# sanitiser strips theme CSS additions, so inlining is their workaround -- +# but we build locally and deploy the rendered `_site/` to a static host, so +# the sanitiser never runs and the workaround is pure overhead. +# +# === What this patch does === +# +# The `:post_render` hook is registered unconditionally at gem load and +# there is no public API to deregister it. We instead let the gem populate +# `admonition_pages` normally during the GENERATE phase -- so the count log +# at the end of `#generate` reflects the real number of pages with +# admonitions -- and clear the array between the generator and the renderer +# so the post-render hook iterates an empty list and injects nothing. +# +# The clear is performed in a `Jekyll::Hooks.register :site, :pre_render` +# block. In the Jekyll site lifecycle, `:site, :pre_render` fires after +# every generator has finished but before any page is rendered (see +# `Jekyll::Site#render` in `lib/jekyll/site.rb`), which is exactly the gap +# we need: the gem's generator-end log message has already been written +# with the correct count, the gem's post-render hook has not yet run, and +# our clear empties the list so the post-render iteration becomes a no-op. +# +# Net effect: +# +# * Markdown -> HTML rewriting is untouched: every `> [!NOTE]` etc. +# becomes the same `
    ` +# element the unpatched gem would produce. +# * The end-of-generate log line reads +# `"GFMA: Converted admonitions in N file(s)."` with the real count. +# * `admonition_pages` is cleared before render starts, so the gem's +# :post_render hook iterates zero pages and no `