Class: Abqari::Site
- Inherits:
-
Object
- Object
- Abqari::Site
- Defined in:
- lib/abqari/site.rb
Overview
Site is the public façade and orchestrator. The heavy lifting lives in three collaborators:
- SiteLoader : config + pages + data ingestion, nav/footer cascade
- Renderer : page generators, parallel render, post-process,
template cache, shared-deps mtime
- ArtifactWriter: output dir reset, public/ + fonts + d3 copying,
sitemap/feed/robots/etc.
Site retains all read-mostly query helpers (collections, taxonomies, related, featured, posts_by_term, …) and the lookup accessors used from RenderContext and partials (assets, images, visualizations, folio, data, nav_links, footer_links, social_links). The split is internal — the public API is unchanged.
Constant Summary collapse
- DEFAULT_COLLECTIONS =
Built-in collections.
bundlesandseriesare first-class meta- collections — they grouppublications(or anything else the consuming site decides to bundle) into retailable sets. They work standalone (manual content atcontent/bundles/<slug>/andcontent/series/<slug>/) and Folio populates them automatically when configured — same collection name, same layout, same URL pattern either way. { 'posts' => { 'source' => 'content/posts', 'permalink' => '/posts/:slug/', 'layout' => 'post' }, 'photos' => { 'source' => 'content/photos', 'permalink' => '/photos/:slug/', 'layout' => 'photo' }, 'publications' => { 'source' => 'content/publications', 'permalink' => '/publications/:slug/', 'layout' => 'publication', 'bundle' => true }, 'bundles' => { 'source' => 'content/bundles', 'permalink' => '/bundles/:slug/', 'layout' => 'bundle', 'bundle' => true }, 'series' => { 'source' => 'content/series', 'permalink' => '/series/:slug/', 'layout' => 'series', 'bundle' => true }, 'workshops' => { 'source' => 'content/workshops', 'permalink' => '/workshops/:slug/', 'layout' => 'workshop', 'bundle' => true } }.freeze
- DEFAULT_TAXONOMIES =
{ 'tags' => { 'permalink' => '/tags/:slug/', 'layout' => 'tag' } }.freeze
- INVARIANT_SLUGS =
Naive singularizer for deriving a layout name from a plural slug. Handles the cases collection slugs actually take:
stories → story (ies → y) books → book (s → '') photos → photo (s → '') publications → publicationEnglish exceptions — words that look plural but aren't, or whose singular form matches a different rule. Without the allow-list:
news → new (wrong) series → serie (wrong; the singular is also "series") lens → len (wrong)Words ending in
ss(boss, press) also stay as-is — covered by the explicit guard so adding new exceptions only requires one place to edit.The narrow scope is deliberate: collection slugs are usually short English nouns, not arbitrary text. If a consuming site picks a slug that doesn't singularize cleanly here, the docs recommend setting
layout:explicitly in the collection config. %w[news series lens species means physics analytics].freeze
Instance Attribute Summary collapse
-
#artifacts ⇒ Object
readonly
Returns the value of attribute artifacts.
-
#config ⇒ Object
readonly
Returns the value of attribute config.
-
#env ⇒ Object
readonly
Returns the value of attribute env.
-
#generated_pages ⇒ Object
readonly
Returns the value of attribute generated_pages.
-
#loader ⇒ Object
readonly
Returns the value of attribute loader.
-
#pages ⇒ Object
readonly
Returns the value of attribute pages.
-
#renderer ⇒ Object
readonly
Returns the value of attribute renderer.
Instance Method Summary collapse
-
#add_generated_page(page) ⇒ Object
Internal — collaborators only.
-
#all_pages ⇒ Object
------------------------------------------------------------------ Page accessors ------------------------------------------------------------------.
-
#assert_collection_open!(page) ⇒ Object
Raise if a page tries to join a collection after the barrier.
-
#assets ⇒ Object
------------------------------------------------------------------ Pipelines + data ------------------------------------------------------------------.
-
#build(incremental: false, side_effects: true) ⇒ Object
build(incremental: false) wipes _site/ and rebuilds everything (default).
-
#bundle_asset_extensions ⇒ Object
Extensions published out of a bundle directory: the built-in allow-list (
Page::Bundle::PUBLISHABLE_EXTENSIONS) plus anything the site adds via. -
#collection(name) ⇒ Object
Pages in a named collection, sorted newest-first by date when present.
-
#collection_config(name) ⇒ Object
Merged config for a single collection.
-
#collection_index_name(page) ⇒ Object
Resolve a page like
content/photos/index.mdto the collection name (photos) it acts as the index for. -
#collection_url_index(name) ⇒ Object
url → index hash for
collection(name). -
#collections ⇒ Object
All collections — built-ins plus any user-defined ones from
collections:in config/site.yml. - #collections_final? ⇒ Boolean
-
#compiled_template(path) ⇒ Object
Lazily compile and cache an ERB template.
- #data ⇒ Object
-
#engine_root ⇒ Object
------------------------------------------------------------------ Path resolution — two roots.
-
#external_symlink?(path) ⇒ Boolean
True when
pathis a symlink whose target lives outside site_root. -
#featured(collection_name, slug) ⇒ Object
Featured-item lookup.
-
#finalise_collections! ⇒ Object
Close the contribute phase.
-
#find_in_paths(rel) ⇒ Object
Resolve a relative path with site-first, engine-fallback semantics.
- #folio ⇒ Object
- #footer_links ⇒ Object
- #images ⇒ Object
- #incremental? ⇒ Boolean
-
#initialize ⇒ Site
constructor
A new instance of Site.
-
#layout_deps_mtime(layout_name) ⇒ Object
Per-layout mtime — used by Page#needs_render? to skip pages whose layout chain hasn't been touched since their last build.
- #minify_html? ⇒ Boolean
-
#nav_links ⇒ Object
------------------------------------------------------------------ Nav / footer / social ------------------------------------------------------------------.
-
#next_in_collection(page) ⇒ Object
The "next" page in a dated collection's chronological order.
-
#next_in_series(page) ⇒ Object
The "next" page within the same series — the page with the smallest
series_positionstrictly greater than this page's. -
#output_dir ⇒ Object
Absolute, always.
- #post_process(html, page: nil) ⇒ Object
-
#posts ⇒ Object
Memoised —
postsis read by the posts index, every per-postnext_in_collection, the feed, the sitemap, the home page's latest-post module, and several JSON-LD partials. - #posts_by_tag ⇒ Object
-
#posts_by_term(taxonomy) ⇒ Object
Index dated pages by taxonomy term.
-
#prev_in_series(page) ⇒ Object
The "previous" page within the same series — the page with the largest
series_positionstrictly less than this page's. -
#related(page, limit: nil, match_by: nil) ⇒ Object
Related pages — candidates from the same collection, scored by how many tags / series / countries / etc.
-
#resolve_collection_name(name) ⇒ Object
Resolve a name to its canonical collection name.
-
#series_taxonomy_for(collection_name) ⇒ Object
Which taxonomy owns the series index for
collection_name— the first taxonomy whosefield:isseriesand whosecollection:scope matches. - #shared_deps_mtime ⇒ Object
-
#shared_term_count(a, b) ⇒ Object
Shared-term count between two frontmatter values.
- #singularize_slug(word) ⇒ Object
- #site_root ⇒ Object
-
#social_links ⇒ Object
Social icons — emitted in the footer when
social:is set in site.yml. -
#taxonomies ⇒ Object
User config and defaults are merged so the built-in
tagstaxonomy stays available even when the user defines their own. - #taxonomy_config(name) ⇒ Object
-
#taxonomy_pages ⇒ Object
Pages that taxonomies index — every page in every collection.
- #visualizations ⇒ Object
-
#webmentions_for(url) ⇒ Object
IndieWeb — webmentions received for a given page URL.
Constructor Details
#initialize ⇒ Site
Returns a new instance of Site.
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/abqari/site.rb', line 78 def initialize @env = (ENV['ABQARI_ENV'] || 'development').to_sym @loader = SiteLoader.new(self) raw = @loader.load_config SiteConfig.validate!(raw, env: @env) # Fold static defaults under the user's config so call sites can # do `config['indexable']` instead of `config.fetch(..., true)`. # Dynamic defaults (minify, env-based) stay in their callers. @config = SiteConfig.with_defaults(raw) @pages = [] @generated_pages = [] # Contribute phase is open until Renderer#generate_all closes it. @collections_final = false # Per-build memo registry. All `||=` caches on Site route # through `memo(key) { … }` so a rebuild only has to clear one # hash; adding a new memo can never go out of sync with the # rebuild-reset code. See `reset_state_for_rebuild!`. @memos = {} @memo_mutex = Mutex.new @renderer = Renderer.new(self) @artifacts = ArtifactWriter.new(self) # Load site-local plugins (plugins/**/*.rb). Plugins register # themselves with `Abqari::Hooks.register(...)` at top level — # the registrations land in the global hook table and fire on # subsequent `Hooks.run` / `run_filter` calls during build. PluginLoader.load_from(site_root) end |
Instance Attribute Details
#artifacts ⇒ Object (readonly)
Returns the value of attribute artifacts.
76 77 78 |
# File 'lib/abqari/site.rb', line 76 def artifacts @artifacts end |
#config ⇒ Object (readonly)
Returns the value of attribute config.
76 77 78 |
# File 'lib/abqari/site.rb', line 76 def config @config end |
#env ⇒ Object (readonly)
Returns the value of attribute env.
76 77 78 |
# File 'lib/abqari/site.rb', line 76 def env @env end |
#generated_pages ⇒ Object (readonly)
Returns the value of attribute generated_pages.
76 77 78 |
# File 'lib/abqari/site.rb', line 76 def generated_pages @generated_pages end |
#loader ⇒ Object (readonly)
Returns the value of attribute loader.
76 77 78 |
# File 'lib/abqari/site.rb', line 76 def loader @loader end |
#pages ⇒ Object (readonly)
Returns the value of attribute pages.
76 77 78 |
# File 'lib/abqari/site.rb', line 76 def pages @pages end |
#renderer ⇒ Object (readonly)
Returns the value of attribute renderer.
76 77 78 |
# File 'lib/abqari/site.rb', line 76 def renderer @renderer end |
Instance Method Details
#add_generated_page(page) ⇒ Object
Internal — collaborators only. Generators push into the list with
site.add_generated_page(page) so the public API stays read-only.
Adding a page invalidates the per-collection caches because
generated pages that implement #collection (PublisherShowPage
in particular) join their collection — a memoised
collection(name) result from before this add is now stale.
The pagination / related generators call site.collection
mid-generate_all, so invalidating at every add keeps the memo
honest without requiring careful generator-ordering.
199 200 201 202 203 204 205 206 |
# File 'lib/abqari/site.rb', line 199 def add_generated_page(page) assert_collection_open!(page) @generated_pages << page @memos.delete(:collection_cache) @memos.delete(:collection_url_index_cache) @memos.delete(:posts) if page.respond_to?(:post?) && page.post? end |
#all_pages ⇒ Object
Page accessors
688 689 690 |
# File 'lib/abqari/site.rb', line 688 def all_pages @pages + @generated_pages end |
#assert_collection_open!(page) ⇒ Object
Raise if a page tries to join a collection after the barrier.
Pagination reads a collection and freezes the slices into
Paginator objects; anything added afterwards is silently missing
from the archive and every shard. That's the worst shape of bug
this codebase can produce — output that is quietly incomplete, with
a green build and nothing to investigate. Better a loud failure at
the moment the ordering is violated, naming the page.
Only collection-joining pages are refused. Taxonomy pages, paginated shards, visualizations and related indexes are all produced BY the derive phase and belong to no collection, so they add freely.
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 |
# File 'lib/abqari/site.rb', line 233 def assert_collection_open!(page) return unless collections_final? return unless page.respond_to?(:collection) && page.collection raise <<~MSG #{page.class} joins the '#{page.collection}' collection but was added after collections were finalised (url: #{page.respond_to?(:url) ? page.url : '?'}). Pages that join a collection must be generated in the contribute phase of Renderer#generate_all, before Site#finalise_collections!. Adding one later means pagination has already sliced the collection without it, so it would be missing from the archive and its shards with no other symptom. MSG end |
#assets ⇒ Object
Pipelines + data
718 719 720 |
# File 'lib/abqari/site.rb', line 718 def assets memo(:assets) { AssetPipeline.new(self) } end |
#build(incremental: false, side_effects: true) ⇒ Object
build(incremental: false) wipes _site/ and rebuilds everything (default). build(incremental: true) preserves _site/ and uses mtime-aware copying so unchanged files are skipped — used by the dev-server watcher.
apply_timezone! mutates ENV['TZ'] so naked dates parse against
one consistent zone regardless of where the build runs. That's a
process-wide side effect, so we save the prior value and restore
it in ensure — host processes (test harnesses, dev-server
watchers calling build repeatedly, applications embedding the
gem) get TZ back at the value they had before the build, not
'UTC' (or whatever the site configured).
side_effects: false skips the outbound network steps (POSSE
syndication, webmention sending). bin/audit builds in production
to exercise the real CSP/minify/fingerprint paths, but it must not
broadcast the posts it's auditing — an audit is a dry run, not a
publish.
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/abqari/site.rb', line 127 def build(incremental: false, side_effects: true) @incremental = incremental prev_tz = ENV['TZ'] apply_timezone! Hooks.run(:before_build, self) # Theme config is validated before ANY filesystem effect — a # typo'd or traversal-shaped `theme:` must abort here, not after # the output dir is wiped or mid-render with per-page errors. assets.validate_theme! @artifacts.reset_output unless incremental reset_state_for_rebuild! @pages = @loader.load_pages Hooks.run(:after_load_pages, self) Folio.new(self).fetch # publications + bundles, derives series Indieweb::Fetcher.new(self).fetch # webmentions (received via webmention.io) @renderer.generate_all # tag pages, paginated shards, viz, folio, related Hooks.run(:after_generate, self) assets.process images.process @artifacts.copy_fonts @artifacts.copy_d3 @renderer.render_pages BundleAssets.new(self).copy @artifacts.copy_public @artifacts.write_deploy_artifacts visualizations.write_data Search.new(self).run if side_effects Indieweb::Sender.new(self).send_all # POST webmentions for new outbound links (opt-in) Syndication::Dispatcher.new(self).run # POSSE: crosspost to Mastodon/Bluesky (opt-in) end # Build-complete sentinel (`Abqari::BUILD_OK_SENTINEL`). Written # ONLY on the success path — if any step above raises, this line # never runs and the file stays absent. Deployers (bin/deploy, # CI pipelines that rsync/scp _site/) check for it before # publishing so a half-built _site/ from a crashed build can't # accidentally ship over a working site. Includes the build # timestamp + the gem version so a stale sentinel (older than # the source files under content/) is detectable. File.write( File.join(output_dir, Abqari::BUILD_OK_SENTINEL), "version: #{Abqari::VERSION}\nbuilt_at: #{Time.now.utc.iso8601}\n" ) Log.info "Built #{all_pages.size} page(s) in #{env}#{incremental ? ' (incremental)' : ''} → _site/" ensure Hooks.run(:after_build, self) if defined?(@incremental) ENV['TZ'] = prev_tz end |
#bundle_asset_extensions ⇒ Object
Extensions published out of a bundle directory: the built-in
allow-list (Page::Bundle::PUBLISHABLE_EXTENSIONS) plus anything
the site adds via
bundle_assets:
extensions: [.gpx, .stl]
Additive rather than replacing, so a site that wants one extra
download type doesn't have to restate the media defaults (and
silently lose an image format when the defaults grow). Entries are
normalised to lower-case and dot-prefixed, so GPX, .gpx and
gpx all work.
393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 |
# File 'lib/abqari/site.rb', line 393 def bundle_asset_extensions memo(:bundle_asset_extensions) do extra = Array((config['bundle_assets'] || {})['extensions']) .map { |e| e.to_s.strip.downcase } .reject(&:empty?) .map { |e| e.start_with?('.') ? e : ".#{e}" } # Markdown can't be opted back in. It's content, and publishing # it as an asset is precisely the bug this allow-list closes — # a `published: false` sibling shipped verbatim to production. content, allowed = extra.partition { |e| Page::Bundle::CONTENT_EXTENSIONS.include?(e) } unless content.empty? Log.warn "Ignoring #{content.join(', ')} in `bundle_assets.extensions:` — " \ 'markdown is content, not an asset, and publishing it would expose ' \ 'files marked `published: false`. Move the file out of the bundle if ' \ 'you want it published.' end (Page::Bundle::PUBLISHABLE_EXTENSIONS + allowed).to_set end end |
#collection(name) ⇒ Object
Pages in a named collection, sorted newest-first by date when present. Accepts either the canonical collection name or its slug alias — both return the same pages.
Memoised per-canonical-name. Templates that render a collection
listing call this repeatedly across a build; the underlying
@pages.select is O(N), so without memoisation each render is
O(N) work × number of partial calls. Reset on reset_state_for_rebuild!.
442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 |
# File 'lib/abqari/site.rb', line 442 def collection(name) canonical = resolve_collection_name(name) cache = memo(:collection_cache) { {} } cache[canonical] ||= begin # Manual content pages (Page objects) AND generated pages that # opt in by exposing `#collection` returning the canonical # name. PublisherShowPage is the primary example — # externally-sourced publications, bundles, and series join # their respective collections so templates iterate one # merged list. manual = @pages.select { |p| p.collection == canonical } generated = @generated_pages.select do |p| p.respond_to?(:collection) && p.collection == canonical end # Manual wins on slug collision — an override page at # `content/publications/<slug>/index.md` suppresses the # auto-generated externally-sourced entry for the same slug. manual_slugs = manual.map { |p| p.respond_to?(:slug) ? p.slug : nil }.compact.to_set generated.reject! { |p| p.respond_to?(:slug) && manual_slugs.include?(p.slug) } pages_in = manual + generated if pages_in.any? { |p| p.respond_to?(:date) && p.date } pages_in.sort_by { |p| (p.respond_to?(:date) && p.date) || Time.at(0) }.reverse else pages_in.sort_by { |p| p.title.to_s } end end end |
#collection_config(name) ⇒ Object
Merged config for a single collection.
Defaults come from DEFAULT_COLLECTIONS. User overrides under
collections.<name>: win.
Slug aliasing: when the user sets slug: on a built-in collection,
the URL permalink and layout name derive from it. The source
directory always stays canonical so the on-disk content layout is
consistent across sites — only public-facing URLs and the layout
name change.
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 |
# File 'lib/abqari/site.rb', line 315 def collection_config(name) defaults = DEFAULT_COLLECTIONS[name] || {} override = (config['collections'] || {})[name] || {} slug = override['slug'].to_s if !slug.empty? derived = { 'permalink' => "/#{slug}/:slug/", 'layout' => singularize_slug(slug) } defaults.merge(derived).merge(override) else defaults.merge(override) end end |
#collection_index_name(page) ⇒ Object
Resolve a page like content/photos/index.md to the collection
name (photos) it acts as the index for. Returns nil for pages
that aren't a collection index. Bundle-style collections like
posts and photos exclude their own index.md from
Page#collection, so this helper closes the gap for callers that
need "what collection does this index page represent?".
421 422 423 424 425 426 427 428 429 430 431 432 |
# File 'lib/abqari/site.rb', line 421 def collection_index_name(page) # Generated pages (PublisherShowPage, RelatedIndexPage, paginated # shards, etc.) don't have a real source_path — return nil. return nil unless page.respond_to?(:source_path) && page.source_path return nil unless File.basename(page.source_path) == 'index.md' collections.each do |name, cfg| source = File.join(site_root, cfg['source']) return name if page.source_path == File.join(source, 'index.md') end nil end |
#collection_url_index(name) ⇒ Object
url → index hash for collection(name). Built once on first
access per collection and cached. Lets next_in_collection and
related answer "which slot is this page in?" in O(1) instead
of O(N) — and since both are called once per show-view render,
the total cost across a build is O(pages) instead of O(pages²).
476 477 478 479 480 481 482 483 484 |
# File 'lib/abqari/site.rb', line 476 def collection_url_index(name) canonical = resolve_collection_name(name) cache = memo(:collection_url_index_cache) { {} } cache[canonical] ||= begin index = {} collection(canonical).each_with_index { |page, i| index[page.url] = i } index end end |
#collections ⇒ Object
All collections — built-ins plus any user-defined ones from
collections: in config/site.yml. Returns name => merged-config
hash. Page#collection iterates this to find which collection a
source file belongs to.
374 375 376 377 378 379 |
# File 'lib/abqari/site.rb', line 374 def collections result = {} DEFAULT_COLLECTIONS.each_key { |name| result[name] = collection_config(name) } (config['collections'] || {}).each_key { |name| result[name] ||= collection_config(name) } result end |
#collections_final? ⇒ Boolean
216 217 218 |
# File 'lib/abqari/site.rb', line 216 def collections_final? @collections_final == true end |
#compiled_template(path) ⇒ Object
Lazily compile and cache an ERB template. Safe to call from threads.
782 783 784 |
# File 'lib/abqari/site.rb', line 782 def compiled_template(path) @renderer.compiled_template(path) end |
#data ⇒ Object
734 735 736 |
# File 'lib/abqari/site.rb', line 734 def data memo(:data) { @loader.load_data } end |
#engine_root ⇒ Object
Path resolution — two roots.
engine_root is the abqari gem's location. Where the structural
assets live: _common.css, layouts/partials, built-in themes,
built-in icons, vendored 3rd-party assets (heroicons, d3).
site_root is the consuming site's location. Where the site-
specific stuff lives: config/site.yml, content/, data/,
vendor/folio/, public/, _site/ (the build output), audit/.
261 262 263 |
# File 'lib/abqari/site.rb', line 261 def engine_root Abqari::ROOT end |
#external_symlink?(path) ⇒ Boolean
True when path is a symlink whose target lives outside site_root.
In-tree symlinks (e.g. linking one bundle's asset into another) are
still allowed — that's a legitimate authoring pattern. Dangling /
looping symlinks are skipped with a warning rather than crashing
the build.
Set ABQARI_ALLOW_EXTERNAL_SYMLINKS=true to bypass.
816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 |
# File 'lib/abqari/site.rb', line 816 def external_symlink?(path) return false if ENV['ABQARI_ALLOW_EXTERNAL_SYMLINKS'] == 'true' lstat = File.lstat(path) return false unless lstat.symlink? # realpath on both sides so platform path quirks (macOS resolves # /var → /private/var) don't make an in-tree symlink look external. target = File.realpath(path) root = File.realpath(site_root) escaped = !(target == root || target.start_with?(root + File::SEPARATOR)) Log.warn "Skipping symlink that escapes site_root: #{path} -> #{target}" if escaped escaped rescue Errno::ENOENT, Errno::ELOOP => e # Dangling or looping symlink. Either way, skip and warn — don't # crash the build. Log.warn "Skipping unreadable symlink #{path}: #{e.class}: #{e.}" true end |
#featured(collection_name, slug) ⇒ Object
Featured-item lookup. site.featured('publications', 'the-field-guide')
returns the matching page or nil — useful for "show this one item
prominently on the home page" patterns.
489 490 491 492 493 494 495 496 |
# File 'lib/abqari/site.rb', line 489 def featured(collection_name, slug) return nil unless slug && !slug.to_s.empty? collection(collection_name).find do |p| p.slug == slug.to_s || p.url.split('/').reject(&:empty?).last == slug.to_s end end |
#finalise_collections! ⇒ Object
Close the contribute phase. Called by Renderer#generate_all once
every generator that can add pages to a collection has run; from
here on, collections are what the deriving generators (pagination,
taxonomies, related indexes) will see.
212 213 214 |
# File 'lib/abqari/site.rb', line 212 def finalise_collections! @collections_final = true end |
#find_in_paths(rel) ⇒ Object
Resolve a relative path with site-first, engine-fallback semantics. Returns the site path if a file/dir exists there, otherwise returns the engine path (which may itself not exist — caller checks).
272 273 274 275 276 277 |
# File 'lib/abqari/site.rb', line 272 def find_in_paths(rel) site_path = File.join(site_root, rel) return site_path if File.exist?(site_path) File.join(engine_root, rel) end |
#folio ⇒ Object
730 731 732 |
# File 'lib/abqari/site.rb', line 730 def folio memo(:folio) { Folio.new(self) } end |
#footer_links ⇒ Object
764 765 766 |
# File 'lib/abqari/site.rb', line 764 def @loader. end |
#images ⇒ Object
722 723 724 |
# File 'lib/abqari/site.rb', line 722 def images memo(:images) { ImagePipeline.new(self) } end |
#incremental? ⇒ Boolean
185 186 187 |
# File 'lib/abqari/site.rb', line 185 def incremental? @incremental == true end |
#layout_deps_mtime(layout_name) ⇒ Object
Per-layout mtime — used by Page#needs_render? to skip pages whose layout chain hasn't been touched since their last build.
792 793 794 |
# File 'lib/abqari/site.rb', line 792 def layout_deps_mtime(layout_name) @renderer.layout_deps_mtime(layout_name) end |
#minify_html? ⇒ Boolean
800 801 802 |
# File 'lib/abqari/site.rb', line 800 def minify_html? @renderer.minify_html? end |
#nav_links ⇒ Object
Nav / footer / social
760 761 762 |
# File 'lib/abqari/site.rb', line 760 def nav_links @loader.nav_links end |
#next_in_collection(page) ⇒ Object
The "next" page in a dated collection's chronological order. "Next"
means strictly newer — the post that came after this one in time.
Returns nil when the source is the newest in the collection, isn't
in the collection, or next.enabled is false.
545 546 547 548 549 550 551 552 553 554 555 556 557 558 |
# File 'lib/abqari/site.rb', line 545 def next_in_collection(page) return nil unless page.respond_to?(:collection) && page.collection cfg = collection_config(page.collection).dig('show', 'next') || {} return nil if cfg['enabled'] == false pages = collection(page.collection) # newest first idx = collection_url_index(page.collection)[page.url] return nil unless idx # Newer neighbour sits at the smaller index. When idx is 0 we're # already at the newest — render nothing. idx.positive? ? pages[idx - 1] : nil end |
#next_in_series(page) ⇒ Object
The "next" page within the same series — the page with the
smallest series_position strictly greater than this page's.
Requires the page to have BOTH series: and series_position:
in frontmatter, and to belong to a collection (so the series is
scoped consistently with the per-collection series taxonomies).
Returns nil when:
- The page isn't in a series (no `series:` frontmatter).
- The page has no `series_position:` (can't order it).
- The page is the last position in the series.
- The page is outside a collection.
Used by the post show view to render a "Next in the series" card in place of the chronological "Up next" card when a series relationship exists. Generic across collections — works for posts, photos, publications, workshops alike.
576 577 578 |
# File 'lib/abqari/site.rb', line 576 def next_in_series(page) series_sibling(page, :forward) end |
#output_dir ⇒ Object
Absolute, always. Page#url derives every internal URL by
stripping this prefix off the page's output path, and the output
path is absolute — so a relative ABQARI_OUTPUT_DIR=_site used to
strip nothing and leave the developer's full filesystem path
embedded in every href, <loc> and feed link:
<loc>https://example.com/Users/me/dev/mysite//about/</loc>
Files still landed in the right place, so the build reported
success and shipped a sitemap advertising local paths to the world.
A trailing slash was the mirror-image bug: it consumed the leading
/ and produced scheme-relative URLs. expand_path normalises
both, and matches what ArtifactWriter#reset_output already does
before it deletes anything — the two had drifted, which is how a
value safe enough to delete under could still corrupt every URL.
294 295 296 297 298 299 |
# File 'lib/abqari/site.rb', line 294 def output_dir raw = ENV['ABQARI_OUTPUT_DIR'] return File.join(site_root, '_site') if raw.nil? || raw.strip.empty? File.(raw, site_root) end |
#post_process(html, page: nil) ⇒ Object
796 797 798 |
# File 'lib/abqari/site.rb', line 796 def post_process(html, page: nil) @renderer.post_process(html, page: page) end |
#posts ⇒ Object
Memoised — posts is read by the posts index, every per-post
next_in_collection, the feed, the sitemap, the home page's
latest-post module, and several JSON-LD partials. Recomputing
on each call is O(N); cache once per build.
696 697 698 699 700 701 702 |
# File 'lib/abqari/site.rb', line 696 def posts # Guard the sort against a date-less post (no `date:` frontmatter # and no `YYYY-MM-DD-` dir prefix): `sort_by(&:date)` on a nil # raises `comparison of NilClass with Time failed`. Undated posts # sort to the end, matching `taxonomy_pages`. memo(:posts) { @pages.select(&:post?).sort_by { |p| p.date || Time.at(0) }.reverse } end |
#posts_by_tag ⇒ Object
704 705 706 707 708 709 710 711 712 |
# File 'lib/abqari/site.rb', line 704 def posts_by_tag memo(:posts_by_tag) do result = Hash.new { |h, k| h[k] = [] } posts.each do |post| post..each { |tag| result[tag] << post } end result end end |
#posts_by_term(taxonomy) ⇒ Object
Index dated pages by taxonomy term. Reads frontmatter[field]
where field defaults to the taxonomy name (overridable via
field: in config).
When the taxonomy config sets collection: <name>, the
aggregation is scoped to that collection — items in other
collections with the same field value don't appear. Used by the
per-collection series taxonomies (posts_series, photos_series,
…) so a photo with series: "Notes 2026" and a post with
series: "Notes 2026" are independent series, not a shared one.
No collection: in the config → cross-collection aggregation,
the historical behaviour. The default tags taxonomy keeps this
so a post and a photo both tagged travel land on /tags/travel/
together.
657 658 659 660 661 662 663 664 665 666 667 668 669 |
# File 'lib/abqari/site.rb', line 657 def posts_by_term(taxonomy) cfg = taxonomy_config(taxonomy) field = cfg['field'] || taxonomy scope = cfg['collection'] result = Hash.new { |h, k| h[k] = [] } taxonomy_pages.each do |page| next if scope && page.collection != scope Array(page.frontmatter[field]).each { |term| result[term] << page } end result end |
#prev_in_series(page) ⇒ Object
The "previous" page within the same series — the page with the
largest series_position strictly less than this page's. Exact
mirror of next_in_series: same guards, same nil semantics, same
per-collection scoping.
Exists because course-shaped collections (a module of ordered
lessons) need to navigate backwards as well as forwards. The
_series_pager partial pairs the two into one prev/next nav;
_next_in_series remains the forward-only card.
589 590 591 |
# File 'lib/abqari/site.rb', line 589 def prev_in_series(page) series_sibling(page, :back) end |
#related(page, limit: nil, match_by: nil) ⇒ Object
Related pages — candidates from the same collection, scored by how many tags / series / countries / etc. they share with the source page. By default returns ALL matches with positive overlap, ranked highest-score first.
site.(page) # full ranked list
site.(page, limit: 3) # top 3
site.(page, match_by: %w[tags series])
506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 |
# File 'lib/abqari/site.rb', line 506 def (page, limit: nil, match_by: nil) return [] unless page.respond_to?(:collection) && page.collection cfg = collection_config(page.collection).dig('show', 'related') || {} return [] if cfg['enabled'] == false effective_match_by = Array(match_by || cfg.fetch('match_by', 'tags')) # Use the url-index lookup to skip the source page in O(1); the # earlier `.reject { |p| p.url == page.url }` did an O(N) scan # per page rendered, which the related-list call sites repeat # across the whole collection. idx_map = collection_url_index(page.collection) pages_in = collection(page.collection) source_idx = idx_map[page.url] candidates = source_idx ? pages_in.reject.with_index { |_, i| i == source_idx } : pages_in # Earlier fields in match_by weight more — count them with a # multiplier so "matches series first, then country" comes out # as expected even when both share with the source. scored = candidates.map do |candidate| score = effective_match_by.each_with_index.sum do |field, idx| weight = effective_match_by.size - idx shared_term_count(page.frontmatter[field], candidate.frontmatter[field]) * weight end [score, candidate] end sorted = scored.select { |score, _| score.positive? } .sort_by { |score, candidate| [-score, -(candidate.respond_to?(:date) && candidate.date ? candidate.date.to_i : 0)] } .map(&:last) limit ? sorted.first(limit) : sorted end |
#resolve_collection_name(name) ⇒ Object
Resolve a name to its canonical collection name. Accepts the canonical key ('posts') or the user-configured slug ('stories' when posts has slug: stories) and returns the canonical key.
363 364 365 366 367 368 |
# File 'lib/abqari/site.rb', line 363 def resolve_collection_name(name) collections.each do |canonical, cfg| return canonical if cfg['slug'].to_s == name.to_s end name.to_s end |
#series_taxonomy_for(collection_name) ⇒ Object
Which taxonomy owns the series index for collection_name — the
first taxonomy whose field: is series and whose collection:
scope matches. Returns the taxonomy name (e.g. 'posts_series')
or nil when the collection has no series taxonomy configured.
Layouts must not hardcode 'posts_series': the post layout is
deliberately reusable (cookbook, glossary, lessons), and every
collection that reuses it has its own <collection>_series
taxonomy. A hardcoded name sends every series breadcrumb in every
reusing collection to the posts series index instead of its own.
631 632 633 634 635 636 637 638 639 640 |
# File 'lib/abqari/site.rb', line 631 def series_taxonomy_for(collection_name) return nil if collection_name.to_s.empty? memo("series_taxonomy_#{collection_name}") do taxonomies.find do |_name, cfg| cfg['field'].to_s == 'series' && cfg['collection'].to_s == collection_name.to_s end&.first end end |
#shared_deps_mtime ⇒ Object
786 787 788 |
# File 'lib/abqari/site.rb', line 786 def shared_deps_mtime @renderer.shared_deps_mtime end |
#shared_term_count(a, b) ⇒ Object
Shared-term count between two frontmatter values. Both can be
scalars (country: Syria) or arrays (tags: [a, b]); we use
Array() to normalise. Returns 0 when either side is missing.
596 597 598 599 600 601 602 |
# File 'lib/abqari/site.rb', line 596 def shared_term_count(a, b) return 0 if a.nil? || b.nil? a_set = Array(a).map(&:to_s).to_set b_set = Array(b).map(&:to_s).to_set (a_set & b_set).size end |
#singularize_slug(word) ⇒ Object
353 354 355 356 357 358 |
# File 'lib/abqari/site.rb', line 353 def singularize_slug(word) return word if INVARIANT_SLUGS.include?(word) return word if word.end_with?('ss') word.sub(/ies$/, 'y').sub(/s$/, '') end |
#site_root ⇒ Object
265 266 267 |
# File 'lib/abqari/site.rb', line 265 def site_root @site_root ||= ENV['ABQARI_SITE_ROOT'] || Dir.pwd end |
#social_links ⇒ Object
Social icons — emitted in the footer when social: is set in
site.yml. Each key must match an SVG filename in app/icons/
770 771 772 773 774 775 |
# File 'lib/abqari/site.rb', line 770 def raw = config['social'] return [] unless raw.is_a?(Hash) raw.map { |key, url| { 'icon' => key.to_s, 'url' => url, 'label' => key.to_s.capitalize } } end |
#taxonomies ⇒ Object
User config and defaults are merged so the built-in tags
taxonomy stays available even when the user defines their own.
610 611 612 613 |
# File 'lib/abqari/site.rb', line 610 def taxonomies user = config['taxonomies'] || {} DEFAULT_TAXONOMIES.merge(user) end |
#taxonomy_config(name) ⇒ Object
615 616 617 618 619 |
# File 'lib/abqari/site.rb', line 615 def taxonomy_config(name) defaults = DEFAULT_TAXONOMIES[name] || {} override = taxonomies[name] || {} defaults.merge(override) end |
#taxonomy_pages ⇒ Object
Pages that taxonomies index — every page in every collection.
Sorted newest-first; pages without a date (workshops have
date_from, not date; some collections may opt out of dating
entirely) sort to the end as Time.at(0). Memoised — same shape
as posts, called from every taxonomy partial.
676 677 678 679 680 681 682 |
# File 'lib/abqari/site.rb', line 676 def taxonomy_pages memo(:taxonomy_pages) do @pages.select(&:collection) .sort_by { |p| p.date || Time.at(0) } .reverse end end |
#visualizations ⇒ Object
726 727 728 |
# File 'lib/abqari/site.rb', line 726 def visualizations memo(:visualizations) { Visualizations.new(self) } end |
#webmentions_for(url) ⇒ Object
IndieWeb — webmentions received for a given page URL. Returns an
array of mention hashes (newest first) or [] when none are
cached for that URL. The mention shape mirrors webmention.io's
JF2 output: { 'wm-property' => 'in-reply-to' | 'like-of' | ..., 'author' => { 'name' => ..., 'url' => ..., 'photo' => ... }, 'content' => { 'text' => ... }, 'published' => '2024-01-15T...', 'url' => 'https://reply.example.com/post' }.
The lookup is path-normalised (trailing slash added) so
/posts/foo/ matches whether the original mention pointed at
https://example.com/posts/foo or https://example.com/posts/foo/.
749 750 751 752 753 754 |
# File 'lib/abqari/site.rb', line 749 def webmentions_for(url) return [] unless data['webmentions'].is_a?(Hash) path = url.to_s path = "#{path}/" unless path.end_with?('/') data['webmentions'][path] || [] end |