diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 30f83ff2..dfa84e6a 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -50,6 +50,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: '3.14'
+ cache: 'pip'
- name: Install Python deps
run: pip install -r requirements.txt
- name: Check offline links (check_links.py)
diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml
index 346d02ad..351ee1e6 100644
--- a/.github/workflows/jekyll-gh-pages.yml
+++ b/.github/workflows/jekyll-gh-pages.yml
@@ -86,6 +86,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: '3.14'
+ cache: 'pip'
- name: Install Python deps
run: pip install -r requirements.txt
- name: Check offline links (check_links.py)
diff --git a/docs/Features/Fusion.md b/docs/Features/Fusion.md
index 344823ba..0fa7989c 100644
--- a/docs/Features/Fusion.md
+++ b/docs/Features/Fusion.md
@@ -66,7 +66,7 @@ If one or more controls are not registered for the current architecture then twi
When this occurs, you will see a note in the DEBUG CONSOLE:
-
+{:width="412" height="73"}
This additional EXE acts as the out-of-process container for those controls and is managed automatically by the twinBASIC IDE
@@ -76,7 +76,7 @@ A project-level setting allows you to control where the Fusion host EXE is gener
- **ActiveX Fusion Host EXE Output Path**
-
+{:width="800" height="400"}
If left blank (default), the standard build path set in the project settings is used. Unless overriden, the standard build path is:
${SourcePath}\Build${ProjectName}_${Architecture}.${FileExtension}
@@ -91,7 +91,7 @@ This allows Fusion host EXEs to be clearly distinguished from normal build outpu
Each COM reference (type library) exposes Fusion-specific options.
-
+{:width="737" height="323"}
### ActiveX Fusion Mode
diff --git a/docs/Reference/Attributes.md b/docs/Reference/Attributes.md
index bd7d11ea..b8627cd5 100644
--- a/docs/Reference/Attributes.md
+++ b/docs/Reference/Attributes.md
@@ -369,7 +369,7 @@ Calculate implicit enum values as a flag set (powers of 2).
> [!NOTE]
> To prevent confusion, once an explicit value is used, all remaining values after it must also be explicit)
-
+
## FloatingPointErrorChecks (optional Bool)
{: #floatingpointerrorchecks }
diff --git a/docs/Reference/Images/flags attribute.png b/docs/Reference/Images/flags-attribute.png
similarity index 100%
rename from docs/Reference/Images/flags attribute.png
rename to docs/Reference/Images/flags-attribute.png
diff --git a/docs/_includes/book-chapter-body.html b/docs/_includes/book-chapter-body.html
index 81d4a931..e1e693fe 100644
--- a/docs/_includes/book-chapter-body.html
+++ b/docs/_includes/book-chapter-body.html
@@ -88,8 +88,19 @@
{%- endif -%}
{%- endunless -%}
+{%- comment -%}
+ Strip the `src="/` prefix that `relative_url` injects when
+ `jekyll build --baseurl /` is passed (the CI deploy path uses
+ this for Pages project sites without a custom domain). With empty
+ baseurl the prefix collapses to `src="/`, matching the historical
+ leading-slash strip exactly. Once stripped, image paths inside
+ book.html are root-of-_site/-relative, which is what both pdfify's
+ source lookup and pagedjs's render-time fetch expect.
+{%- endcomment -%}
+{%- assign src_baseurl_strip = 'src="' | append: site.baseurl | append: '/' -%}
+
{%- assign body = body
- | replace: 'src="/', 'src="'
+ | replace: src_baseurl_strip, 'src="'
| replace: p1_search, p1_replace
| replace: p2_search, p2_replace
| replace: p3i12_search, p3i12_replace
diff --git a/docs/_plugins/book-href-rewrite.rb b/docs/_plugins/book-href-rewrite.rb
index d0b74869..fdb36bea 100644
--- a/docs/_plugins/book-href-rewrite.rb
+++ b/docs/_plugins/book-href-rewrite.rb
@@ -189,6 +189,28 @@ def self.resolve_href(href, parent_url)
nil
end
+ # Normalise `site.config["baseurl"]` to either "" or "/segment..."
+ # (no trailing slash) -- the exact prefix `relative_url` actually
+ # injects into rendered HTML. Mirrors `Offlinify.normalize_baseurl`;
+ # duplicated rather than cross-required to keep plugins independent.
+ def self.normalize_baseurl(raw_baseurl)
+ baseurl = (raw_baseurl || "").to_s.sub(%r{/+\z}, "")
+ baseurl = "/#{baseurl}" if !baseurl.empty? && !baseurl.start_with?("/")
+ baseurl
+ end
+
+ # Strip the baseurl prefix from a root-absolute path so the result
+ # matches the keys in `url_to_anchor` (which are built from
+ # `page.url` -- baseurl-less). Two forms are handled: the exact
+ # baseurl alone (`/twinBASIC-docs` -> `/`), and a normal subpath
+ # (`/twinBASIC-docs/foo` -> `/foo`). Anything else passes through.
+ def self.strip_baseurl(path, baseurl)
+ return path if baseurl.empty?
+ return "/" if path == baseurl
+ return path[baseurl.length..] if path.start_with?(baseurl + "/")
+ path
+ end
+
# Rewrite every `href="..."` in the article body. External and
# already-in-book anchor hrefs (`http`, `mailto:`, `#...`) pass
# through unchanged; the `#...` form has already been chapter-anchor
@@ -202,7 +224,12 @@ def self.resolve_href(href, parent_url)
# only the map-lookup step was selective). Keeps build output
# byte-comparable and makes broken out-of-book links easier to grep
# for during verification.
- def self.rewrite_body(body, parent_url, url_to_anchor)
+ #
+ # `baseurl` is the normalised `site.config["baseurl"]`; when CI runs
+ # `jekyll build --baseurl /` the `relative_url`-emitted hrefs
+ # carry that prefix and must be stripped before the lookup, since
+ # `url_to_anchor` keys come from `page.url` (baseurl-less).
+ def self.rewrite_body(body, parent_url, url_to_anchor, baseurl)
body.gsub(/href="([^"]*)"/) do |whole_match|
href = Regexp.last_match(1)
next whole_match if EXTERNAL_PREFIXES.any? { |pfx| href.start_with?(pfx) }
@@ -211,11 +238,18 @@ def self.rewrite_body(body, parent_url, url_to_anchor)
next whole_match unless abs && abs.start_with?("/")
path_part, frag_part = abs.split("#", 2)
- target = url_to_anchor[path_part]
+ lookup_path = strip_baseurl(path_part, baseurl)
+ target = url_to_anchor[lookup_path]
if target
frag_part ? %(href="##{target}-#{frag_part}") : %(href="##{target}")
else
- %(href="#{abs}")
+ # Out-of-book target: emit the baseurl-stripped form so the
+ # URL the PDF reader displays is stable across local builds
+ # and the `--baseurl /` CI deploy path. Dead in the PDF
+ # either way, but the canonical (baseurl-less) form is what
+ # matches the live site URL when read offline.
+ miss_path = frag_part ? "#{lookup_path}##{frag_part}" : lookup_path
+ %(href="#{miss_path}")
end
end
end
@@ -227,6 +261,7 @@ def self.process(page)
parent_map = build_anchor_to_parent(site)
return if parent_map.empty?
landing_anchors = build_landing_anchors(site)
+ baseurl = normalize_baseurl(site.config["baseurl"])
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -248,7 +283,7 @@ def self.process(page)
parent_url = parent_map[anchor_id]
if parent_url
- new_body = rewrite_body(body, parent_url, url_to_anchor)
+ new_body = rewrite_body(body, parent_url, url_to_anchor, baseurl)
rewritten += 1 if new_body != body
body = new_body
end
diff --git a/docs/_plugins/jekyll-relative-links-patch.rb b/docs/_plugins/jekyll-relative-links-patch.rb
index c2ec00f1..44f2128f 100644
--- a/docs/_plugins/jekyll-relative-links-patch.rb
+++ b/docs/_plugins/jekyll-relative-links-patch.rb
@@ -1,9 +1,11 @@
# frozen_string_literal: true
# Patch for jekyll-relative-links (>=0.7.0): replace the O(N) linear
-# scan in `url_for_path` with an O(1) hash lookup.
+# scan in `url_for_path` with an O(1) hash lookup, and extend lookup
+# to consult `permalink:` frontmatter and `redirect_from:` aliases
+# when a file-path match misses.
#
-# === The bug ===
+# === The perf bug ===
#
# `JekyllRelativeLinks::Generator#url_for_path` is invoked once for
# every markdown link match (both inline `[X](Y)` and reference-style
@@ -29,34 +31,67 @@
# bulk of GENERATE on a build that otherwise takes ~600ms in that
# phase.
#
-# === The fix ===
+# The perf fix builds a hash from `relative_path` (leading slash
+# stripped, matching the unpatched comparison) to the target object
+# once, and looks up by key thereafter. O(M*N) -> O(M+N). First-wins
+# semantics (`unless h.key?(key)`) match the unpatched `.find`.
#
-# Build a hash from `relative_path` (with the leading slash stripped,
-# to match the unpatched comparison) to the target object once, and
-# look up by key thereafter. Hash construction is O(N) once; each
-# subsequent lookup is O(1). Total cost drops from O(M*N) to O(M+N),
-# and the GENERATE phase shrinks accordingly.
+# === The semantic gap ===
#
-# The hash is built with first-wins semantics (`unless h.key?(key)`)
-# to match the unpatched `.find`, which returns the first matching
-# target. In practice `relative_path` is unique across pages, static
-# files, and docs, so this only matters as defence against an
-# unexpected duplicate -- but matching the upstream behaviour exactly
-# keeps the patch a safe drop-in.
+# Upstream only matches the link path against `relative_path` (the
+# file's on-disk path). Pages that use `permalink:` frontmatter to
+# rename their URL slug are invisible to the gem -- e.g. source
+# `[twinBASIC Videos](Videos/tB)` targets `docs/Videos/twinBASIC.md`
+# (`permalink: /Videos/tB`), but the gem looks for `Videos/tB.md`,
+# doesn't find one, and leaves the link unrewritten. The rendered
+# HTML keeps the relative path, which works online only by accident
+# of relative-path math, and falls back further on `redirect_from:`
+# stubs as an undocumented safety net. In the PDF book (where chapter
+# bodies get concatenated under `/book.html`) the same relative path
+# can no longer reach the target page, and the rewriter that turns
+# in-book hrefs into chapter anchors can't match the unresolved form
+# either -- so cross-references break.
+#
+# The fix adds two fallback hashes after the file-path table:
+#
+# potential_targets_by_url keys: leading-slash-stripped
+# `page.url`. Both with- and
+# without-trailing-slash forms
+# are indexed for folder-style
+# index pages whose permalinks
+# end in `/`, so
+# `[X](Tutorials/CEF)` and
+# `[X](Tutorials/CEF/)` both
+# resolve.
+#
+# potential_targets_by_redirect_from keys: leading-slash-stripped,
+# trailing-slash-trimmed
+# `redirect_from` aliases.
+# Returns the target page
+# whose canonical permalink is
+# `page.url`, so url_for_path
+# emits the canonical form
+# rather than relying on the
+# redirect stub at runtime.
+#
+# `url_for_path` chains all three: file-path first (upstream behaviour
+# -- author-intended file references always win), then permalink, then
+# redirect_from. First hit wins. Misses still return nil and the gem
+# leaves the link unrewritten, matching upstream's fail-open contract.
#
# === Compatibility ===
#
# Targets the upstream gem version pinned by Gemfile.lock (0.7.0). The
-# patch overrides only `url_for_path` and adds one new memoiser
-# (`potential_targets_by_path`); every other method is untouched. The
-# `unless method_defined?` guard makes the patch idempotent against
-# accidental double-load.
+# patch overrides only `url_for_path` and adds three new memoisers
+# (`potential_targets_by_path`, `..._by_url`, `..._by_redirect_from`);
+# every other method is untouched. The `unless method_defined?` guard
+# makes the patch idempotent against accidental double-load.
#
# If a future release rewrites `url_for_path`, re-verify that the
# replacement still resolves a path to a target by scanning
-# `potential_targets` (or an equivalent) and that swapping in a hash
-# lookup remains a faithful drop-in. If the upstream project takes a
-# PR for this, delete this file.
+# `potential_targets` (or an equivalent) and that swapping in the
+# three-tier hash lookup remains a faithful extension. If the upstream
+# project takes a PR for this, delete this file.
require "jekyll-relative-links"
@@ -70,9 +105,66 @@ def potential_targets_by_path
end
end
+ # Pages indexed by their rendered URL (permalink), leading slash
+ # stripped to match the form `path_from_root` produces. Folder-
+ # style permalinks (URL ending in `/`) are also indexed under
+ # their trimmed form so source markdown can drop the trailing
+ # slash. Restricted to pages and writable docs -- static files
+ # have a `url` but it's just the file path, which the by_path
+ # table already covers.
+ #
+ # `JekyllRedirectFrom::RedirectPage` instances are excluded:
+ # the jekyll-redirect-from plugin synthesizes a stub page for
+ # every `redirect_from` alias, each with `url` equal to the
+ # alias itself. Indexing those would route source links through
+ # the redirect stub (a one-hop intermediate that only works in
+ # a browser) instead of resolving straight to the canonical
+ # target. The `by_redirect_from` table below indexes the same
+ # aliases but points at the canonical page, which is what we
+ # want.
+ def potential_targets_by_url
+ @potential_targets_by_url ||= begin
+ is_redirect_stub = defined?(JekyllRedirectFrom::RedirectPage) \
+ ? ->(p) { p.is_a?(JekyllRedirectFrom::RedirectPage) } \
+ : ->(_p) { false }
+ (site.pages + site.docs_to_write).each_with_object({}) do |p, h|
+ next if is_redirect_stub.call(p)
+ url = p.url.to_s
+ next if url.empty? || url == "/"
+ key = url.sub(%r!\A/!, "")
+ h[key] = p unless h.key?(key)
+ if key.end_with?("/")
+ alt = key.chomp("/")
+ h[alt] = p unless h.key?(alt)
+ end
+ end
+ end
+ end
+
+ # Pages indexed by their `redirect_from` aliases (set by the
+ # jekyll-redirect-from plugin). Each alias is normalised to the
+ # leading-slash-stripped, trailing-slash-trimmed form so source
+ # markdown using a historical URL (e.g. a moved page's old slug)
+ # resolves to the page's current canonical URL.
+ def potential_targets_by_redirect_from
+ @potential_targets_by_redirect_from ||= begin
+ (site.pages + site.docs_to_write).each_with_object({}) do |p, h|
+ Array(p.data["redirect_from"]).each do |alias_url|
+ alias_str = alias_url.to_s
+ next if alias_str.empty?
+ key = alias_str.sub(%r!\A/!, "").chomp("/")
+ next if key.empty?
+ h[key] = p unless h.key?(key)
+ end
+ end
+ end
+ end
+
def url_for_path(path)
path = CGI.unescape(path)
- target = potential_targets_by_path[path]
+ target = potential_targets_by_path[path] ||
+ potential_targets_by_url[path.chomp("/")] ||
+ potential_targets_by_redirect_from[path.chomp("/")]
relative_url(target.url) if target&.url
end
end
diff --git a/docs/_plugins/offlinify.md b/docs/_plugins/offlinify.md
index 549a8db9..c75dd8d6 100644
--- a/docs/_plugins/offlinify.md
+++ b/docs/_plugins/offlinify.md
@@ -64,9 +64,11 @@ Fires at `:site, :pre_render` — once at the start of the build, after Jekyll h
5. **Seed state** on the module's `@state` ivar so the per-page and finish hooks can pick up where setup left off: caches (`seg_cache`, `result_cache`), counters, normalised baseurl, exclude patterns, dest paths, cumulative timer. Cleared at the end of `finish` so a fresh build starts clean.
+6. **Start the async write pool** *(Windows only — see [Async write pool](#async-write-pool))*. When `WRITE_POOL_ENABLED` (= `Gem.win_platform?`) is true, `WRITE_POOL_SIZE` (= 4) worker threads start and block on the shared `write_queue` waiting for `[out_path, content, time_key]` tuples that `process_page` will enqueue. On non-Windows platforms this step is skipped and writes happen synchronously on the main thread in step 4 of Phase 2.
+
### Phase 2: process_page
-Fires at `:pages, :post_render` and `:documents, :post_render` — once per page after Jekyll renders it. `page.output` is the final HTML/CSS/etc bytes; the plugin transforms it and writes the result to `_site-offline/`. Jekyll's WRITE phase writes the same `page.output` to `_site/` a moment later, so the online and offline files come from the same in-memory string — no re-read.
+Fires at `:pages, :post_render` and `:documents, :post_render` — once per page after Jekyll renders it. `page.output` is the final HTML/CSS/etc bytes; the plugin transforms it and writes the result to `_site-offline/` — synchronously on the main thread, or via the async write pool on Windows (see [Async write pool](#async-write-pool)). Jekyll's WRITE phase writes the same `page.output` to `_site/` a moment later, so the online and offline files come from the same in-memory string — no re-read.
For each page:
@@ -77,25 +79,29 @@ For each page:
3. **Detect jekyll-redirect-from stubs** by class-name string check (`page.class.name == "JekyllRedirectFrom::RedirectPage"`). The stubs are tiny HTML files whose meta-refresh, canonical link, `