Class: Abqari::Site

Inherits:
Object
  • Object
show all
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. bundles and series are first-class meta- collections — they group publications (or anything else the consuming site decides to bundle) into retailable sets. They work standalone (manual content at content/bundles/<slug>/ and content/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  publication

English 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

Instance Method Summary collapse

Constructor Details

#initializeSite

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

#artifactsObject (readonly)

Returns the value of attribute artifacts.



76
77
78
# File 'lib/abqari/site.rb', line 76

def artifacts
  @artifacts
end

#configObject (readonly)

Returns the value of attribute config.



76
77
78
# File 'lib/abqari/site.rb', line 76

def config
  @config
end

#envObject (readonly)

Returns the value of attribute env.



76
77
78
# File 'lib/abqari/site.rb', line 76

def env
  @env
end

#generated_pagesObject (readonly)

Returns the value of attribute generated_pages.



76
77
78
# File 'lib/abqari/site.rb', line 76

def generated_pages
  @generated_pages
end

#loaderObject (readonly)

Returns the value of attribute loader.



76
77
78
# File 'lib/abqari/site.rb', line 76

def loader
  @loader
end

#pagesObject (readonly)

Returns the value of attribute pages.



76
77
78
# File 'lib/abqari/site.rb', line 76

def pages
  @pages
end

#rendererObject (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_pagesObject


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

#assetsObject


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_extensionsObject

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

#collectionsObject

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

Returns:

  • (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

#dataObject



734
735
736
# File 'lib/abqari/site.rb', line 734

def data
  memo(:data) { @loader.load_data }
end

#engine_rootObject


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.

Returns:

  • (Boolean)


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.message}"
  true
end

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

#folioObject



730
731
732
# File 'lib/abqari/site.rb', line 730

def folio
  memo(:folio) { Folio.new(self) }
end


764
765
766
# File 'lib/abqari/site.rb', line 764

def footer_links
  @loader.footer_links
end

#imagesObject



722
723
724
# File 'lib/abqari/site.rb', line 722

def images
  memo(:images) { ImagePipeline.new(self) }
end

#incremental?Boolean

Returns:

  • (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

Returns:

  • (Boolean)


800
801
802
# File 'lib/abqari/site.rb', line 800

def minify_html?
  @renderer.minify_html?
end



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_dirObject

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.expand_path(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

#postsObject

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_tagObject



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.tags.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 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.related(page)               # full ranked list
site.related(page, limit: 3)     # top 3
site.related(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 related(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_mtimeObject



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_rootObject



265
266
267
# File 'lib/abqari/site.rb', line 265

def site_root
  @site_root ||= ENV['ABQARI_SITE_ROOT'] || Dir.pwd
end

Social icons — emitted in the footer when social: is set in site.yml. Each key must match an SVG filename in app/icons/.svg.



770
771
772
773
774
775
# File 'lib/abqari/site.rb', line 770

def social_links
  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

#taxonomiesObject

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_pagesObject

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

#visualizationsObject



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