module

Hwaro::Content::Processors::InternalLinkResolver

Constants

ATTR_REGEX = /\s([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/

One attribute of a tag ($1 = name, quoted, or, after minification, unquoted value in $2/$3/$4). The leading whitespace keeps data-id= out.

FRAGMENT_LINK_REGEX = /(?<![\w:-])href="(@\/[^"#]*)?#([^"]*)"/

Matches a fragment link in not-yet-resolved content HTML: an href="@/path#frag" ($1 = "@/path") or a same-page href="#frag". The lookbehind keeps data-href= / xlink:href= out.

INTERNAL_LINK_REGEX = /href="@\/([^"#]*)(?:#([^"]*))?"/

Matches href="@/path" and href="@/path#anchor"

LINK_ATTR_PROBE = /(?:href|src|srcset)=/i

Cheap any-case probe for a link attribute, behind the byte probes.

LINK_ATTR_REGEX = /=\s*+"[^"]*+"|=\s*+'[^']*+'|\b((srcset|href|src)=)(?|(")([^"]*+)"|(')([^']*+)'|()([^\s"'`>\\][^\s>]*+))/i

One href/src/srcset attribute inside a LINK_TAG_REGEX match, in any case and quote style: $1 = name=, $2 = name, $3 = the quote (empty when unquoted), $4 = value. An unquoted value ends at whitespace or >, and never starts with \: href=\"/x/\" is a JS-escaped quote in a script string, not a value. The first two alternatives step over every other quoted value whole, so title="href='/x'" is not a link; \b keeps data-src= (lazy loaders) in.

LINK_TAG_REGEX = /<[a-zA-Z][^\s\/<>]*+(?:[^<>=hs]++|=\s*+"[^"]*+"|=\s*+'[^']*+'|=|(?!\b(?:srcset|href|src)=)[hs])*+\b(?:srcset|href|src)=(?:"[^"]*+"|'[^']*+')?+(?:[^<>=]++|=\s*+"[^"]*+"|=\s*+'[^']*+'|=)*+>/i

An opening tag carrying at least one href/src/srcset outside its quoted values (any case). As in HTML, a quote opens a value only right after = (a stray one, alt=don't, is text); quoted values are skipped whole. The unquoted run stops only at an h/s to test for a link name, so every other tag fails without a match being built. A < ends an unquoted run, so a stray < in text cannot drag a scan to the next >. Possessive, so nothing backtracks.

SCHEME_PREFIX_REGEX = /\A[a-zA-Z][a-zA-Z0-9+.\-]*:/

A URI scheme prefix (e.g. https:, mailto:, tel:, data:).

TAG_REGEX = /<([a-zA-Z][^\s\/>]*)((?:"[^"]*+"|'[^']*+'|[^>"']++)*+)>/

An opening tag: $1 = tag name, $2 = its attributes. Quoted values are skipped whole, so a > inside title="a>b" does not end the tag. Possessive, so a tag with thousands of attributes cannot exhaust the JIT stack (Regex::Error) on backtracking frames.

Instance methods

add_default_language_aliases(map : Hash(String, Models::Page), config : Models::Config) : Hash(String, Models::Page)

Index a default-language page whose file spells its suffix out (about.en.md) under the bare path too (about.md), the way get_page(path="about.md") finds it — @/about.md otherwise warned and shipped literally. A literal file of the bare name keeps its key. Multilingual sites only: elsewhere about.en.md is just a name. Call on a finished content-path map; returns it.

Source
anchor_exists?(fragment : String, ids : Set(String)) : Bool

True when fragment (as written in an href, possibly percent- and entity-encoded) names an anchor in ids. Like a browser, the raw fragment is tried before the percent-decoded one, a :~: fragment directive (scroll-to-text, #:~:text=…) is not part of the id, and #top always resolves (scrolls to the top when no element has it).

Source
anchor_ids(html : String) : Set(String)

Every anchor target in html, entity-decoded: an id on any element, or a name on <a> - the only element a browser scrolls to by name. <meta name="description"> and <input name="q"> are not targets, so #description must not pass as one.

Source
has_own_origin?(value : String) : Bool

True when value already carries its own origin: any scheme: URL (https:, mailto:, tel:, data:, …) or a protocol-relative //host/…. Such a value must never have a base_url or page-URL prefix glued onto it.

Single source of truth for every "is this already absolute?" test. The ad-hoc starts_with?("http") form it replaces was wrong in both directions: it missed mailto:/data:///cdn… (which then got a base_url prefix — and //cdn… even took the root-relative branch, yielding https://site.com//cdn.example.com/og.png), and it false-positived on a relative path that merely starts with the letters "http" (http-guide/cover.png), leaving it un-absolutized.

Source
page_for(pages_by_path : Hash(String, Models::Page), path : String) : Models::Page | Nil

The page an @/ path names. Markd percent-encodes a destination (@/my note.md arrives as @/my%20note.md), so the decoded path is tried first: %20 means a space, as it does to Markd. A literal % file name is still reached when nothing decodes to it (or as %25). A path captured from rendered HTML carries Markd's &amp; for a & (@/odd/a&amp;b.md), so that spelling is tried first.

A non-ASCII path that misses verbatim is retried NFC-folded on both sides: macOS, zip and Dropbox hand out decomposed (NFD) file names while links are typed precomposed, and the two never compare equal byte-wise. An exact spelling always wins; the fold scan only runs on a miss, so resolved links cost nothing extra.

Source
resolve(html : String, pages_by_path : Hash(String, Models::Page), source_path : String, base_url : String = "", misses : Array(Tuple(String, String)) | Nil = nil) : String

Resolve internal @/ links in HTML to actual page URLs.

  • html — rendered HTML string
  • pages_by_path — map from content path (e.g. "blog/post.md") to Page
  • source_path — path of the page being rendered (for warning messages)
  • base_url — site base_url, used to prepend the path component (e.g. "/noir") so links work when the site is served from a subpath. When empty or root, no prefix is added and behavior matches the previous output.
  • misses — optional accumulator for unresolved links. Each entry is {target, reason} with reason "page not found" or "empty link". Callers running under [links] broken_internal = "error" collect them to fail the build after the render fan-out; warnings are still logged either way. nil keeps warn-only behavior.
Source