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: -tbFusionDebugConsole +![tbFusionDebugConsole](Images/569099635-bc9553a6-fcce-487d-a478-dbee557f33b1.png){: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** -tbFusionProjectSettings +![tbFusionProjectSettings](Images/569150839-9ffc87ac-250d-40a4-bb47-669b607ad76f.png){: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. -tbFusionPerLibraryOptions +![tbFusionPerLibraryOptions](Images/569100769-f1f2790a-0094-4843-809f-a8a9e928fd41.png){: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) -![image](Images/flags attribute.png) +![image](Images/flags-attribute.png) ## 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, `