Module: Abqari::RenderContext::CoverHelpers

Included in:
Abqari::RenderContext
Defined in:
lib/abqari/render_context/cover_helpers.rb

Overview

Decorative cover-image generation for posts and other collections that opt in via the cover: enum in config/site.yml. Produces inline SVG title cards — the page title (word-wrapped to 1-3 lines) on a slug-hashed two-tone gradient, with a small cluster of decorative squares at the corners (echoing the hand-authored hero.svg pattern). No file output; the SVG embeds directly in the partial's HTML.

The four modes:

photo          — use the page's `image:` frontmatter if set;
               otherwise the basic flat-tint placeholder
               (`.post-list__cover-placeholder` etc.). The
               historical default.
photo_or_svg   — use the page's `image:` if set; otherwise
               render a decorative initial-tile SVG. Gives
               text-first authors a polished card without
               sourcing imagery.
svg            — always render the decorative SVG, even when
               `image:` is set. Useful when you want every
               card to read as a tile (uniform listing look)
               rather than a mix of photos and tiles.
none           — no cover slot at all (text-only cards). Same
               effect as the legacy `show_image: false`.

Frontmatter cover: on a single page overrides the collection default — cover: false is a shortcut for cover: none.

Constant Summary collapse

COVER_PALETTES =

Muted two-tone gradients. Slug-hashed so each post consistently gets the same tile across builds, but adjacent posts in a listing tend to draw different tones (palette entries × hash distribution = reasonably even spread on any one page).

The default set is hue-neutral, tuned for minimal (and any custom theme). The warm themes get their own sets in THEME_COVER_PALETTES below — a slug-hashed teal or violet tile on magazine's cream or notebook's paper page injects a saturated hue the theme never uses anywhere else, so each warm theme constrains its tiles to its own colour world.

[
  ['#1e3a5f', '#4a78a8'].freeze,  # deep blue → mid blue
  ['#5b3a8a', '#8a6abf'].freeze,  # violet
  ['#2d4a3d', '#5a8a6a'].freeze,  # forest
  ['#8a4a2a', '#bf7a4a'].freeze,  # rust
  ['#3a3a4a', '#6a6a8a'].freeze,  # slate
  ['#2a5a6a', '#5a8aa0'].freeze,  # teal
  ['#6a2a3a', '#a05a6a'].freeze,  # burgundy
  ['#5a5a3a', '#8a8a6a'].freeze   # olive
].freeze
THEME_COVER_PALETTES =

Theme-keyed tile palettes for the shipped warm themes. Every pair keeps the tile dark enough that the near-white title text stays legible. Themes not listed (minimal, custom, bootstrap, none) fall back to the neutral COVER_PALETTES.

{
  # Cream, rust, editorial — warm browns and rusts, one muted
  # navy (the theme's info hue) so grids don't go monochrome.
  'magazine' => [
    ['#8a4a2a', '#bf7a4a'].freeze,  # rust
    ['#6a2a3a', '#a05a6a'].freeze,  # burgundy
    ['#4a3226', '#7a5a42'].freeze,  # deep umber
    ['#5a5a3a', '#8a8a6a'].freeze,  # olive
    ['#3a4a5f', '#68809a'].freeze,  # muted navy
    ['#9a5230', '#c07a50'].freeze   # terracotta
  ].freeze,
  # Paper + ochre + graphite — the ochre family carries the
  # signature, dusty navy echoes `--color-info`.
  'notebook' => [
    ['#8a5c14', '#c4811f'].freeze,  # ochre
    ['#33353a', '#5c6066'].freeze,  # graphite
    ['#2a5d8f', '#5580ab'].freeze,  # dusty navy
    ['#565046', '#847c6e'].freeze,  # warm grey
    ['#6e4a12', '#a06419'].freeze,  # deep ochre
    ['#4a5a3a', '#75866a'].freeze   # moss
  ].freeze,
  # Sun-bleached earth — brick, clay, olive; dusty blue echoes
  # `--color-info`.
  'sand' => [
    ['#7a2018', '#a84438'].freeze,  # brick
    ['#8a5138', '#b57952'].freeze,  # clay
    ['#4f5233', '#7a7d55'].freeze,  # olive
    ['#6e5a3a', '#9a825c'].freeze,  # sand brown
    ['#4a342a', '#755446'].freeze,  # deep umber
    ['#3f5a80', '#6d88a8'].freeze   # dusty blue
  ].freeze
}.freeze
COVER_MODES =
%w[photo photo_or_svg svg none].freeze
DEFAULT_COVER_MODE =
'photo'

Instance Method Summary collapse

Instance Method Details

#balanced_word_partition(words, line_count) ⇒ Object

Try every contiguous partition of words into line_count non-empty groups; return the one whose longest joined line is shortest (ties broken by smallest length spread). 2-3 lines over a typical title (<10 words) is a handful of splits — cheap to enumerate.



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/abqari/render_context/cover_helpers.rb', line 273

def balanced_word_partition(words, line_count)
  return [words.join(' ')] if line_count <= 1
  return words.dup          if words.size == line_count

  best        = nil
  best_score  = nil
  enumerate_splits(words.size, line_count).each do |sizes|
    lines   = partition_by_sizes(words, sizes)
    lengths = lines.map(&:length)
    score   = [lengths.max, lengths.max - lengths.min]
    # Arrays don't define `<`, only `<=>`. Lexicographic compare
    # gives the behaviour we want: first the smaller max-line
    # wins, then the smaller spread breaks ties.
    if best_score.nil? || (score <=> best_score) < 0
      best       = lines
      best_score = score
    end
  end
  best
end

#cover_choice_for(page, context: :index) ⇒ Object

What to actually render for the cover slot, given the resolved mode and whether the page has an image: set. Returns one of:

:photo       — render the page's `image:` as an <img>
:placeholder — render the basic flat-tint SVG rect (the
             existing `.post-list__cover-placeholder` shape)
:svg         — render the decorative initial-tile SVG
             (call `cover_svg_for(page)`)
:none        — render nothing; caller skips the cover slot

Centralising the branching here keeps the partials a flat case over the four outcomes instead of repeating the mode/image cross-product.



145
146
147
148
149
150
151
152
153
154
155
# File 'lib/abqari/render_context/cover_helpers.rb', line 145

def cover_choice_for(page, context: :index)
  mode = cover_mode_for(page, context: context)
  return :none if mode == 'none'

  has_image = page.respond_to?(:frontmatter) && page.frontmatter['image']
  case mode
  when 'photo'        then has_image ? :photo : :placeholder
  when 'photo_or_svg' then has_image ? :photo : :svg
  when 'svg'          then :svg
  end
end

#cover_image_path(page) ⇒ Object

cover: is overloaded: it's usually a MODE enum (svg, none, …) but a site can also point it at an image path. This returns the value ONLY when it's an actual image path — a non-mode, non-boolean string — so the head partial doesn't turn cover: svg into an og:image of /posts/foo/svg. Returns nil for modes, booleans, and blanks.



124
125
126
127
128
129
130
# File 'lib/abqari/render_context/cover_helpers.rb', line 124

def cover_image_path(page)
  fm = page.respond_to?(:frontmatter) ? page.frontmatter['cover'] : nil
  return nil unless fm.is_a?(String)
  return nil if fm.strip.empty? || COVER_MODES.include?(fm)

  fm
end

#cover_mode_for(page, context: :index) ⇒ Object

Resolve the effective cover mode for a page. Three layers, frontmatter wins:

1. Frontmatter `cover:` — explicit string value (one of
 `COVER_MODES`) is honoured directly. Boolean `false` is
 sugar for `'none'`.
2. Collection config `<collection>.<context>.cover`.
3. Default `'photo'` (the historical behaviour).

context: is :index for listing partials, :show for the detail page. Same enum on both sides, independent config.



107
108
109
110
111
112
113
114
115
116
# File 'lib/abqari/render_context/cover_helpers.rb', line 107

def cover_mode_for(page, context: :index)
  fm = page.respond_to?(:frontmatter) ? page.frontmatter['cover'] : nil
  return 'none'  if fm == false
  return fm.to_s if COVER_MODES.include?(fm.to_s)

  col = page.respond_to?(:collection) ? page.collection : nil
  return DEFAULT_COVER_MODE unless col

  (site.collection_config(col).dig(context.to_s, 'cover') || DEFAULT_COVER_MODE).to_s
end

#cover_paletteObject

The tile palette for the active theme — theme-keyed when the theme ships one, the neutral default set otherwise.



227
228
229
# File 'lib/abqari/render_context/cover_helpers.rb', line 227

def cover_palette
  THEME_COVER_PALETTES.fetch(site.config['theme'].to_s, COVER_PALETTES)
end

#cover_svg_for(page) ⇒ Object

Inline SVG markup for the decorative title-card cover. Designed for the 4:3 cover slot but preserveAspectRatio="xMidYMid slice" makes it scale gracefully to wider/taller containers too — safe to use for the larger show-view hero as well.

Layout:

* Slug-hashed two-tone gradient background.
* Two clusters of three faint white squares (top-left + bottom-
right) — same motif as the hand-authored hero.svg shipped
with the fixture's "Building with Abqari" post.
* Page title, centred, word-wrapped to 1-3 lines. Font size
auto-scales based on the longest wrapped line so long titles
stay inside the viewBox.

The <linearGradient> id is suffixed with a slug-hash so multiple cover SVGs on one page (a posts index card grid, the home modules, etc.) don't collide on a shared id.



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/abqari/render_context/cover_helpers.rb', line 174

def cover_svg_for(page)
  title         = page.respond_to?(:title) ? page.title.to_s.strip : ''
  display_title = title.empty? ? '?' : title

  # Slug-keyed where possible (deterministic per page across
  # builds); falls back to the title. `Zlib.crc32` is stable
  # across processes — `String#hash` is salted and would change
  # between builds, scrambling the tile colours each time.
  key      = page.respond_to?(:slug) && page.slug ? page.slug.to_s : title
  crc      = Zlib.crc32(key)
  palette  = cover_palette
  bg_a, bg_b = palette[crc % palette.size]
  grad_id  = "cover-grad-#{crc.to_s(36)}"

  lines       = svg_wrap_title(display_title)
  font_size   = svg_title_font_size(lines)
  line_height = (font_size * 1.15).round
  block_h     = line_height * lines.size
  # First baseline = top of text block + ~0.78 * font_size
  # (typical cap-height ratio for sans-serif).
  first_y     = (150 - block_h / 2.0 + font_size * 0.78).round

  text_block = lines.each_with_index.map do |line, i|
    y = first_y + (line_height * i)
    %(<text x="200" y="#{y}" text-anchor="middle" font-size="#{font_size}" font-weight="600" letter-spacing="-0.5" fill="rgba(255,255,255,0.96)">#{h(line)}</text>)
  end.join

  <<~SVG.gsub(/\n\s+/, ' ').strip
    <svg class="cover-svg" viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
      <defs>
        <linearGradient id="#{grad_id}" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stop-color="#{bg_a}"/>
          <stop offset="100%" stop-color="#{bg_b}"/>
        </linearGradient>
      </defs>
      <rect width="400" height="300" fill="url(##{grad_id})"/>
      <g fill="#fff" opacity="0.10">
        <rect x="20"  y="20"  width="30" height="30" rx="3"/>
        <rect x="56"  y="20"  width="30" height="30" rx="3"/>
        <rect x="20"  y="56"  width="30" height="30" rx="3"/>
        <rect x="314" y="250" width="30" height="30" rx="3"/>
        <rect x="350" y="250" width="30" height="30" rx="3"/>
        <rect x="350" y="214" width="30" height="30" rx="3"/>
      </g>
      <g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif">
        #{text_block}
      </g>
    </svg>
  SVG
end

#enumerate_splits(total, parts) ⇒ Object

All compositions of total into parts positive integers, e.g. enumerate_splits(3, 2) → [[1,2], [2,1]].



296
297
298
299
300
301
302
303
304
305
306
# File 'lib/abqari/render_context/cover_helpers.rb', line 296

def enumerate_splits(total, parts)
  return [[total]] if parts == 1

  out = []
  (1..(total - parts + 1)).each do |head|
    enumerate_splits(total - head, parts - 1).each do |rest|
      out << [head, *rest]
    end
  end
  out
end

#partition_by_sizes(words, sizes) ⇒ Object

Split words into consecutive groups of the given sizes and join each group with a single space.



310
311
312
313
314
315
316
317
318
# File 'lib/abqari/render_context/cover_helpers.rb', line 310

def partition_by_sizes(words, sizes)
  out = []
  i   = 0
  sizes.each do |sz|
    out << words[i, sz].join(' ')
    i += sz
  end
  out
end

#svg_title_font_size(lines) ⇒ Object

Pick a font size that fits the longest wrapped line into the 400-wide viewBox with margins. Calibrated against the sans-serif stack declared in cover_svg_for. The largest tier is deliberately modest (36pt) so short titles like "Upgrading Abqari" or "Quickstart" read as a comfortable label rather than dominating the tile — title-card SVGs are decoration, not a billboard.



327
328
329
330
331
332
333
334
335
# File 'lib/abqari/render_context/cover_helpers.rb', line 327

def svg_title_font_size(lines)
  longest = lines.map(&:length).max.to_i
  case longest
  when 0..10  then 36
  when 11..15 then 32
  when 16..22 then 28
  else             24
  end
end

#svg_wrap_title(title) ⇒ Object

Word-wrap a title into 1-3 lines for the SVG title-card. SVG <text> has no flow, so the layout has to be precomputed. We pick a line count from the total title length, then pick the most balanced partition of the words across those lines — minimising the longest line so the font-size step (svg_title_font_size) gets the best chance of staying large. Greedy packing would orphan trailing long words onto a short final line (e.g. "Crisis / Response Foundations"); the balanced search picks "Crisis Response / Foundations" instead.

Single-word titles return as-is — long single words just shrink via the font-size fallback.

Thresholds are tuned so the rendered text block sits well inside the 400-wide viewBox — roughly 16% horizontal padding at the largest font tier, more at smaller tiers. Tighter thresholds than these and the text crowds the decorative corner squares; looser and the title looks small in a mostly-blank tile.



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/abqari/render_context/cover_helpers.rb', line 251

def svg_wrap_title(title)
  words = title.split(/\s+/).reject(&:empty?)
  return [title] if words.size <= 1

  max_lines = if title.length <= 11
                1
              elsif title.length <= 26
                2
              else
                3
              end
  return [title] if max_lines == 1

  line_count = [max_lines, words.size].min
  balanced_word_partition(words, line_count)
end