class

Hwaro::Core::Build::Builder

Inherits Hwaro::Core::Build::Phases::Finalize < Hwaro::Core::Build::Phases::Write < Hwaro::Core::Build::Phases::Generate < Hwaro::Core::Build::Phases::OutputFormats < Hwaro::Core::Build::Phases::Render < Hwaro::Core::Build::Phases::Transform < Hwaro::Core::Build::Phases::ParseContent < Hwaro::Core::Build::Phases::ReadContent < Hwaro::Core::Build::Phases::Initialize < Hwaro::Core::Build::ShortcodeProcessor < Reference < Object

Constants

LOAD_DATA_LITERAL_RE = /\bload_data\s*\(\s*(?:path\s*=\s*)?(?:"([^"]+)"|'([^']+)')/

load_data(path="static/prices.json") / load_data("…") with a literal path — the only form a template scan can resolve.

TEMPLATE_EXTENSION_REGEX = /\.(html|j2|jinja2|jinja|ecr)$/

Constructors

Instance methods

asset_bundle_source?(path : String) : Bool

True when a watcher-relative path (e.g. "static/js/app.js") is a source file of a configured [assets] bundle. The serve watcher uses this to know a plain (non-Sass) static save must re-run the bundle pipeline — bundle inputs are source_dir-joined bundle.files, exactly how Assets::Pipeline reads them.

Source
cache_manager

Access cache manager for external inspection

Source
claim_generated_output(path : String, static_copy : Bool = false, content_copy : Bool = false) : Nil

Record an output file this build produced that no cache entry covers. Public: the taxonomy generator is a module that reaches the builder through its builder: argument.

static_copy marks the Initialize phase's copy of static/: it is a claim like any other, but not a generator's (see @generator_output_claims); content_copy marks the Write phase's content copies (see @content_copy_claims).

Source
clear_page_derived_outputs(page_path : String) : Nil

Start this page's list over — called at the top of every render so a serve session's repeated rebuilds don't accumulate.

Source
config

The most recently loaded site config (nil before the first build). The serve watcher reads it to diff restart-only [serve] settings after a config-triggered rebuild.

Source
context

Access build context for external inspection (e.g. emitting JSON output after a build). Returns nil before run has been invoked.

Source
copy_changed_content_files(changed_files : Array(String), output_dir : String, verbose : Bool = false, minify : Bool = false)

Republish non-Markdown content assets (images, etc.) the way the full build's Write phase does, but only the files the watcher flagged:

  • [content.files] copies go to their path relative to content/ (minified like process_raw_files when minify), skipping extensions the config doesn't permit so the watcher can't smuggle a .md or a disallowed type into output;
  • page-bundle assets go to the owning page's URL, whether or not [content.files] is on and wherever a slug / path / permalink moved the page (process_assets).

@config is nil only before the initial build, which Server#run_with_options already performs before spawning the watcher, so the watcher always sees a loaded config.

Source
copy_changed_static(changed_files : Array(String), output_dir : String, verbose : Bool = false) : Bool

Copy only the specified static files to the output directory. Used by serve mode when only static files have changed.

Returns true when one of the copies would land on a file a PAGE owns (static/about/index.html beside a page whose url is /about/) or a generator writes (static/robots.txt, static/404.html, a taxonomy page, an alias stub — see generated_output_paths). The static-only rebuild strategy re-renders nothing, so the static bytes would sit on that URL for the rest of the session; the caller escalates to a full rebuild, where the render and the generators run after the copy and win — exactly the cold-build outcome. Such a file is not copied here; the full rebuild's static copy publishes it.

Source
feed_template_present?

True when a user feed template override is loaded. Also consulted by the Generate phase's skip-if-unchanged gate: a template-only edit doesn't touch content, so a warm --cache build would keep the stale feed on disk if feeds were skipped.

Source
feed_template_renderer

Renderer over user feed templates (templates/rss.xml.jinja / atom.xml.jinja, loaded under the keys "rss.xml"/"atom.xml"). Nil when no override template exists so the feed generators keep the zero-cost programmatic path. The proc renders with a FRESH Crinja environment per call — the shared env is not MT-safe (with_scope mutates the env) and the SEO tasks that generate feeds run in parallel fibers. create_fresh_crinja_env carries the snapshot template loader, so {% include %} inside a feed template works.

Source
forget_static_copy(path : String, cwd : String) : Nil

Once something generated over a copied path, it no longer holds the static file's bytes (the serve lazy OG handler).

Source
generate_body_template_changed?(changed_paths : Array(String)) : Bool

True when a modified template feeds a [[content.generate]] body_template — the body template itself, or a partial it (transitively) includes. Bodies are rendered while the content is GENERATED (Read phase), so only a full build re-runs them: the re-render strategies re-render pages from the bodies they already hold, and the body file (often .md) is not even in the template snapshot, so the edit read as "contents are identical". Inline template specs (body = "{% include 'partials/x.html' %}", a templated description, …) render at that same point, so a partial THEY include counts too. changed_paths are watcher paths (templates/gen/product.md).

Source
generated_output_claims

Everything claimed since the last reset.

Source
has_deferred_pages?

Are there any pages stashed by --fast-start waiting to render? Server checks this to decide whether to spawn the background fiber.

Source
include_source_changed?(paths : Array(String)) : Bool

True when one of paths (watcher paths) was read by an include call or a transclusion: the pages that include it must re-render.

Source
include_source_stale?(path : String, digest : String | Nil) : Bool

True when the include source path no longer holds the bytes the last build read (digest is the watcher's MD5, nil when missing).

Source
include_sources

Project-relative paths every include call and transclusion has read since the last full build (see Builder@include_sources).

Source
load_data_source_changed?(paths : Array(String)) : Bool

True when a template reads one of paths (watcher paths such as static/prices.json) through a literal load_data() path. Files under data/ take the full-rebuild lane already; one anywhere else rides the static or content-asset lane, which only copies it, so every page printing the data kept the pre-edit values. A path built at render time (load_data(path=page.extra.file)) is not seen.

Source
load_data_sources

Every literal load_data() path in the templates, normalized. The watcher stamps the ones outside its roots (extra/menu.json) like include sources, or editing one never rebuilt anything.

Source
mark_build_output_epoch

Stamp the start of this build's output writing (see @build_output_epoch).

Source
note_static_copy(path : String) : Nil

Record an output file the static copy just wrote over (see @static_copied_outputs). Public because the serve watcher's static-only lane (copy_changed_static) copies through the builder too and must record what it wrote for the same reason. path must already be canonical — see static_copied_output?.

Source
og_image_asset_changed?(paths : Array(String), options : Config::Options::BuildOptions) : Bool

True when one of paths (project-relative, as the watcher reports them) is the logo or background image that auto OG images embed and whose digest keys their manifest. The static / content-file lanes copy bytes only, so serve escalates to a build that regenerates the images. Never true where no image is generated eagerly (disabled, --skip-og-image, lazy serve mode regenerates on request).

Source
og_image_assets

The logo and background image auto OG images embed, as configured. The watcher stamps the ones outside its roots (brand/logo.png).

Source
owned_output_paths(output_dir : String, config : Models::Config | Nil = @config) : Set(String)

Every output file the CURRENT site claims — primary page outputs plus output-format siblings, computed exactly like collect_page_output_paths so the two can never drift. The serve watcher filters its pre-rebuild stale list through this AFTER the rebuild: a source deleted and re-created under a different path in one changeset (foo.md → foo/index.md) maps to the same output file, which the rebuild just rewrote and must not be deleted.

Source
previous_site

The site the build before the current one produced (serve mode only). A full rebuild replaces site with a fresh one whose pages learn their lazy og:image URLs only in BeforeRender; until then the lazy OG handler answers from these pages instead of 404ing. Held only while a rebuild runs, and only with lazy OG images on.

Source
privacy_rewrite(html : String, owner : String | Nil = nil) : String

[privacy]: point html's external assets at local copies. The files they now load are claimed, and recorded against owner (a page path) so a --cache hit, which skips the render, keeps them. Public: the taxonomy generator writes through it too.

Source
privacy_url(url : String) : String | Nil

[privacy]: the local URL for one external URL (the PWA precache list), or nil to keep it external.

Source
prune_unclaimed_outputs(candidates : Enumerable(String), output_dir : String) : Nil

The one choke point every serve prune deletes through. candidates are files an earlier pass wrote that the bookkeeping of THIS pass no longer names; each is deleted only when nothing in the current output still stands on it:

  • a page output of the current site (owned_output_paths);
  • an alias stub / pagination page any page's render recorded;
  • a generated-output claim — only while the claim set is this build's own (@generated_claims_current): an incremental pass re-claims nothing, so there the set still names the very files being pruned;
  • a file this pass wrote (written_this_build? — every serve pass re-stamps the epoch, see begin_serve_pass);
  • a live static/ or content/ file that publishes at that path, which is what an incremental pass cannot learn from its claims. A static source's bytes are copied back over the stale file — it was shadowing them, and a cold build publishes them there.

Paths are compared through kept_output?, which also treats a spelling that differs only by case as kept when it is the same file on disk (a case-folding filesystem: APFS, NTFS).

Source
recompile_sass(output_dir : String) : Bool

Recompile all SCSS entries into the output directory. Used by serve mode when a .scss source changes — such files publish as compiled .css, never verbatim. No-ops unless [sass] is enabled.

Returns true when reprocessing the asset bundles changed the manifest (a fingerprinted filename moved) — see reprocess_asset_bundles for what callers must do then.

Source
record_page_derived_output(page_path : String, output_path : String) : Nil

Record an alias stub / pagination page / localized [privacy] file page just wrote.

Source
regenerate_service_worker(site : Models::Site, output_dir : String, verbose : Bool) : Nil

Rewrite manifest.json + sw.js for a serve pass, like the full build's PWA hook (same [privacy] localizer, or precache URLs revert to the external ones). Public: serve's static / content-file lanes call it after copying bytes sw.js content-hashes. Warn-and-continue — a transient failure must not skip cache.save / the reload.

Source
register(hookable : Lifecycle::Hookable)

Register a Hookable module

Source
reject_withheld_content_sources(paths : Array(String)) : Array(String)

content/ source paths minus the files of withheld (draft / future / expired) bundles — see Phases::Write#withheld_content_file?.

Source
render_deferred(options : Config::Options::BuildOptions) : Int32

Render pages that were skipped on the initial --fast-start build. Runs after the dev server is already serving the priority subset, so user-visible "ready" time stays bounded on large sites. Regenerates SEO/search files at the end since feeds and the search index pull from page.content, which was empty for deferred pages during the initial Generate phase.

Also runs the BeforeRender hooks for the remaining work the cold pass deferred — OG image generation for non-priority pages and image resizing for static/content_file globs + non-priority page assets. Without this step those images never get produced in a fast-start serve session until the user saves a file.

Source
reprocess_asset_bundles(output_dir : String) : Bool

Re-run the asset pipeline so SCSS (or plain CSS/JS) bundle sources refresh under serve. Updates the AssetHooks class-level manifest so subsequent renders see new fingerprint paths.

Returns true when the manifest changed — with fingerprinting on, every already-rendered page still references the OLD hashed filename, so callers must follow up with a page re-render.

Source
reset_generated_output_claims
Source
reset_static_copied_outputs
Source
run(options : Config::Options::BuildOptions) : Bool

Full build. The incoming struct is stored on the BuildContext verbatim, so every field (full, serve_mode, …) reaches the phases and hooks that branch on them (--full cache clearing, [og.image] lazy_generate under serve).

Returns false when the build failed without raising (pre-hook failure or a phase abort) — the serve watcher branches on this to surface the failure instead of live-reloading onto a broken site.

Source
run_incremental(changed_content_files : Array(String), options : Config::Options::BuildOptions) : Bool

Incremental build: only re-parse and re-render pages whose source files have been modified. Falls back to a full build when the necessary state from a previous build is not available.

Optimizations over a full build:

  • Only re-parses changed files (not all pages)
  • Diff-based taxonomy update (not full rebuild)
  • Re-links navigation only for affected sections
  • Recomputes series/related posts only for affected pages
  • Selectively invalidates Crinja caches
Source
run_incremental_then_rerender(changed_content_files : Array(String), options : Config::Options::BuildOptions) : Bool

Incremental parse of changed content + full re-render with reloaded templates. Used when both content and templates changed simultaneously.

Source
run_rerender(options : Config::Options::BuildOptions, force_pages : Array(Models::Page) | Nil = nil, membership_changed : Bool = false, listing_sets : ListingSetSnapshot | Nil = nil) : Bool

Re-render pages using reloaded templates without re-parsing content. Useful when only template files have been modified. With template dependency tracking active, only the pages whose template closure includes an edited template are re-rendered; otherwise (tracking off, a dynamic include in the graph, or templates added/removed) every page re-renders as before.

force_pages are rendered regardless of template impact — callers that re-parsed content (run_incremental_then_rerender) pass them so the selective path can't skip a content-changed page. Their taxonomy membership may have changed too, so taxonomy pages regenerate whenever force_pages are present.

membership_changed signals that pages left the built set in this pass (a draft/expired/future flip): the SEO surfaces and taxonomy pages must refresh even when no template content changed, and the identical-templates early return must not skip that work.

listing_sets is the caller's pre-re-parse snapshot of the page-set projections (see snapshot_listing_sets): the selective render set must also take in the listing pages that print a projection the content re-parse moved. Nil for a pure template edit, which changes no page metadata.

Source
site

The most recently built site (nil before the first build). The dev server's lazy OG handler reads it to match a requested image path back to the page that owns it.

Source
stale_outputs_for_removed(removed_paths : Array(String), output_dir : String) : Array(String)

Map source paths that were removed from disk to the output files they produced in the last build. A rebuild rewrites surviving pages but never deletes what's gone, so the serve watcher captures this BEFORE rebuilding (while @site still knows the page's URL/slug) and removes the orphans after — otherwise a deleted page keeps serving 200 and ships with the next deploy of public/.

Source
static_copied_output?(path : String, cwd : String) : Bool

path may arrive in any spelling — get_output_path hands back the canonical absolute form while alias/pagination paths are built by joining the output directory — so it is resolved against cwd (the caller's, resolved once) before the lookup.

Source
static_copies_recorded?

Did the static copy write anything at all this build? The gate in filter_changed_pages resolves paths against the working directory, and this lets it skip that work entirely on the common warm build where no static file changed.

Source
sweep_stale_derived_outputs(output_dir : String) : Nil

Delete the alias stubs and /page/N/ files a page's previous render wrote and its render in the pass that just finished did not, plus every derived file of a page that is no longer in the site (deleted, drafted, render = false). Called once a render pass is complete, never per page: a stub one page dropped may be exactly the file another page claims in the same pass, so nothing a live page or any current stub still claims is deleted.

Source
take_page_derived_outputs(page_path : String) : Array(String)

Take (and forget) what this page derived, remembering it as what the page's render in this pass wrote (see @derived_outputs_this_pass).

Source
track_feed_outputs(written : Array(String), output_dir : String, prune : Bool = false) : Nil

Remember what this feed generation published and, on a serve pass (prune), delete the feeds the previous generation published and this one did not — a section whose generate_feeds was just turned off. Full builds leave that to the Finalize claims diff.

Source
track_taxonomy_outputs(output_dir : String, & : -> Array(Models::Section)) : Array(Models::Section)

Run one taxonomy generation pass, then delete the files the previous pass on this builder wrote and this one did not: a term whose last post was deleted, re-tagged or drafted, with its feed and pagination pages, or a whole taxonomy dropped from config.toml.

Paths are compared relative to output_dir, so a pass into a different output directory matches nothing and deletes nothing. A file the current site renders a page to is never deleted, and neither is anything when the pass raised (the previous set is kept and widened, so the next complete pass still sees it).

Source
written_this_build?(path : String) : Bool

True when path was (re)written by this build.

Exact on every filesystem with sub-second mtimes (APFS, ext4, btrfs, NTFS, xfs): a previous build's file is stamped strictly before this build's epoch, and everything this build writes strictly after. The comparison carries NO slack on purpose — a slack wide enough to cover a coarse filesystem is also wide enough to protect the previous build's output, which would disable pruning outright for any two builds run seconds apart.

On a filesystem that truncates mtimes to whole seconds a shadowed generator file written in the epoch's own second can read as older and be pruned. That is self-healing: the path leaves the claim list with it, and the next build's generator finds the file missing and writes it again.

Widening the comparison (a slack, or truncating the epoch to its own second) trades that for a PERMANENT failure instead. A path the prune skips is not re-claimed by the build that skipped it, so it never appears in a later build's stale list either — the leftover stays published forever. Two builds run seconds apart are ordinary, so the widened form also protects the PREVIOUS build's output as a matter of course: both widenings were measured against cache_stale_outputs_spec and disable pruning outright (12+ of its 22 examples). Erring toward pruning is the recoverable direction.

The stamped copies (static files, bundle assets — File.utime with the SOURCE mtime) deliberately read as old, and they are exactly the ones a live claim already protects.

Source

Nested types