Module: Abqari::Helpers

Extended by:
Helpers
Included in:
Helpers, RenderContext
Defined in:
lib/abqari/helpers.rb

Constant Summary collapse

SCRIPT_JSON_ESCAPE =

JSON serialiser for values that will be interpolated raw into a <script> block — JSON-LD, embedded config, etc. Plain JSON.generate does NOT escape <, >, &, or the line / paragraph separators U+2028/U+2029, any of which let a hostile string break out of the script tag — a Folio response with "title": "x</script><script>alert(1)</script>" ships as working HTML otherwise. Folio data is bounded-trust per docs/security.md §1; this helper closes the gap.

Strategy: replace HTML- and JS-significant characters with their JSON \uXXXX escape sequences. The output is still valid JSON (round-trips through JSON.parse), but contains no literal <, >, or &, so the host HTML parser sees it as inert text right up to the closing </script> we emit ourselves.

Always use <%== script_safe_json(value) %> inside a <script> block. Outside <script>, prefer h(value.to_json).

{
  '<'                    => '\u003c',
  '>'                    => '\u003e',
  '&'                    => '\u0026',
  ""               => '\u2028',
  ""               => '\u2029'
}.freeze
SCRIPT_JSON_RE =
/[<>&

]/.freeze
NON_DECOMPOSING_LETTERS =

Latin letters that NFKD leaves alone, because their stroke or ligature is part of the letterform rather than a combining mark. Without these they'd hit the [^a-z0-9] strip and vanish — the same silent-deletion bug diacritic folding fixes ("Ø" → "", so "Åland Ø" would slug to "aland"). Keyed on the lowercase form; downcase runs first.

{
  'ø' => 'o', 'đ' => 'd', 'ð' => 'd', 'ł' => 'l', 'ħ' => 'h',
  'ı' => 'i', 'ŧ' => 't', 'æ' => 'ae', 'œ' => 'oe', 'ß' => 'ss',
  'þ' => 'th'
}.freeze
NON_DECOMPOSING_LETTERS_RE =
Regexp.union(NON_DECOMPOSING_LETTERS.keys).freeze

Instance Method Summary collapse

Instance Method Details

#excerpt(text, words: 50, omission: '…') ⇒ Object



226
227
228
229
230
231
232
# File 'lib/abqari/helpers.rb', line 226

def excerpt(text, words: 50, omission: '')
  stripped = text.to_s.gsub(/<[^>]+>/, '').strip
  arr = stripped.split(/\s+/)
  return stripped if arr.length <= words

  "#{arr[0, words].join(' ')}#{omission}"
end

#external_http_url(url) ⇒ Object

The stricter sibling of sanitized_url, for URLs that arrive from a third party rather than from the site's own content — webmention authors, syndication payloads, anything a stranger can put on the wire. Returns the URL when it is an absolute http/https URL with a host, and nil otherwise.

Two differences from sanitized_url, both deliberate:

* `mailto:` is rejected. A remote profile link or avatar has no
business being a mail link, and it renders as a clickable
`href` the reader can't distinguish from a web link.
* Same-origin forms (`/path`, `#frag`) are rejected. A stranger's
"profile URL" resolving into YOUR site is either a mistake or
an attempt to borrow your origin's credibility.

What both reject, and the reason this exists at all: HTML-escaping does nothing to a URL scheme. javascript:alert(1) survives CGI.escapeHTML unchanged and stays executable inside an href, so escaping alone is not a defence for attacker-supplied URLs.



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/abqari/helpers.rb', line 112

def external_http_url(url)
  return nil if url.nil?

  s = url.to_s.strip
  return nil if s.empty?

  begin
    uri = URI.parse(s)
  rescue URI::InvalidURIError
    return nil
  end

  return nil unless uri.is_a?(URI::HTTP) # URI::HTTPS subclasses this
  return nil if uri.host.to_s.empty?

  uri.to_s
end

#h(text) ⇒ Object

HTML-escape a value for safe interpolation in markup. Templates render through Erubi with escape: true, so <%= value %> already escapes via this function automatically; call h() explicitly only when building raw HTML strings emitted via <%== (e.g. link_to, buy_button, bundle_picture_tag), or inside a <%== block that interpolates an untrusted value directly.



19
20
21
# File 'lib/abqari/helpers.rb', line 19

def h(text)
  CGI.escapeHTML(text.to_s)
end


216
217
218
219
220
221
222
223
224
# File 'lib/abqari/helpers.rb', line 216

def link_to(text, url, **attrs)
  # `sanitized_url` now returns a raw URL, so escape it here — this
  # method returns a full <a> tag emitted via `<%==` (raw), so no
  # auto-escaping downstream will do it for us.
  safe_url = h(sanitized_url(url) || '#')
  attrs_str = attrs.map { |k, v| %(#{h(k.to_s)}="#{h(v.to_s)}") }.join(' ')
  attrs_str = " #{attrs_str}" unless attrs_str.empty?
  %(<a href="#{safe_url}"#{attrs_str}>#{h(text)}</a>)
end

#local_time(time, format: '%-d %B %Y') ⇒ Object

Format a date or time. Default format is "1 January 2026" (day month-name year, no comma). Callers can override with any strftime format string — e.g. format: '%Y' for year-only.



170
171
172
# File 'lib/abqari/helpers.rb', line 170

def local_time(time, format: '%-d %B %Y')
  to_time(time).strftime(format)
end

#markdown(text) ⇒ Object

Render a markdown string to HTML. For frontmatter prose fields (publication excerpts, bundle/series descriptions) that authors write with bold, italics, links, paragraphs, lists — the same markdown they'd use in a page body. Uses the engine's standard MARKDOWN_OPTIONS so the dialect (tables, footnotes, autolinks, raw HTML) matches what Page#content produces.

Returns '' for nil/empty input so the caller can <%== markdown(...) %> unconditionally without an extra if guard.



294
295
296
297
298
# File 'lib/abqari/helpers.rb', line 294

def markdown(text)
  return '' if text.nil? || text.to_s.strip.empty?

  Commonmarker.to_html(text.to_s, options: Page::MARKDOWN_OPTIONS)
end

#pluralize(count, singular, plural: nil) ⇒ Object



147
148
149
150
# File 'lib/abqari/helpers.rb', line 147

def pluralize(count, singular, plural: nil)
  word = count.to_i == 1 ? singular : (plural || "#{singular}s")
  "#{count} #{word}"
end

#reading_time(text, wpm: 200) ⇒ Object



234
235
236
237
238
239
240
241
# File 'lib/abqari/helpers.rb', line 234

def reading_time(text, wpm: 200)
  stripped = text.to_s.gsub(/<[^>]+>/, '')
  word_count = stripped.split(/\s+/).length
  # Clamp wpm to ≥1 (a `wpm: 0` would divide to Infinity → FloatDomainError)
  # and the result to ≥1 minute (empty text shouldn't read "0 min read").
  minutes = [(word_count / [wpm.to_f, 1.0].max).ceil, 1].max
  "#{minutes} min read"
end

#sanitized_url(url) ⇒ Object

Validate a URL for use in href / src attributes. Returns the RAW (un-escaped) URL when it's a same-origin path or an http(s)/ mailto URL; returns nil otherwise. Blocks javascript:, data: (HTML), vbscript: and other scheme-based XSS vectors. Use whenever a URL comes from outside author-controlled content (Folio buy_url, fetched data, etc.).

HTML-escaping is the CALLER's job. In a template, <%= sanitized_url(u) %> escapes once via Erubi's auto-escaping — correct. Do NOT escape here as well: returning pre-escaped output double-escapes any URL with a query string (?a=1&b=2&amp;amp;) in every <%= context. Helpers that build raw HTML strings emitted via <%== (link_to, buy_button, bundle_picture_tag) wrap the result in h(...) themselves.

Protocol-relative URLs (//evil.com/x) are rejected. Browsers treat them as <current-scheme>://evil.com/x, so they're a cross-origin redirect vector disguised as a same-origin path. Same-origin paths must start with a single / followed by a non-/ character (or be a lone /).



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/abqari/helpers.rb', line 73

def sanitized_url(url)
  return nil if url.nil?

  s = url.to_s.strip
  return nil if s.empty?
  return nil if s.start_with?('//')
  return s if s.start_with?('/', '#')

  begin
    uri = URI.parse(s)
  rescue URI::InvalidURIError
    return nil
  end

  return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) || uri.is_a?(URI::MailTo)
  return nil if uri.is_a?(URI::HTTP) && uri.host.to_s.empty?

  uri.to_s
end

#script_safe_json(value) ⇒ Object



49
50
51
# File 'lib/abqari/helpers.rb', line 49

def script_safe_json(value)
  JSON.generate(value).gsub(SCRIPT_JSON_RE, SCRIPT_JSON_ESCAPE)
end

#slugify(text) ⇒ Object

Turn arbitrary text into a URL-safe slug.

Diacritics are FOLDED, not dropped. NFKD decomposition splits "é" into "e" plus a combining acute, and removing the combining marks leaves the base letter behind. Without that step the [^a-z0-9] strip below deletes the whole character: "Café" became "caf" and "Mabrūk" became "mabr-k". That matters because this function builds public taxonomy URLs (/tags/:slug/) as well as internal ids — a transliterated tag would otherwise get a mangled URL.

Non-Latin scripts (Arabic, CJK) have no Latin base letter to fold to and still slugify to "", which Page::Path#render_permalink catches with an explicit error.



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/abqari/helpers.rb', line 269

def slugify(text)
  s = text.to_s
  # Path-derived strings reach here tagged ASCII-8BIT, and
  # `unicode_normalize` refuses binary input outright. Re-tag as
  # UTF-8 (the bytes almost always already are), then drop any
  # sequence that isn't — `valid_encoding?` alone can't be trusted
  # here, since every byte string is "valid" as ASCII-8BIT.
  s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
  s = s.scrub('') unless s.valid_encoding?
  s = s.unicode_normalize(:nfkd).gsub(/\p{Mn}/, '')
  s.downcase
   .gsub(NON_DECOMPOSING_LETTERS_RE) { |c| NON_DECOMPOSING_LETTERS[c] }
   .gsub(/[^a-z0-9]+/, '-')
   .gsub(/\A-|-\z/, '')
end

#time_ago(time) ⇒ Object



186
187
188
189
190
191
192
193
194
195
196
# File 'lib/abqari/helpers.rb', line 186

def time_ago(time)
  seconds = (Time.now - to_time(time)).to_i

  case seconds
  when 0..59             then 'just now'
  when 60..3599          then "#{pluralize(seconds / 60,     'minute')} ago"
  when 3600..86_399      then "#{pluralize(seconds / 3600,   'hour')} ago"
  when 86_400..2_591_999 then "#{pluralize(seconds / 86_400, 'day')} ago"
  else local_time(time)
  end
end

#time_until(time) ⇒ Object

Inverse of time_ago. "in 3 hours", "in 2 weeks", "in 4 months". Used by the scheduled-post banner so the author sees how far away a future date actually is.



201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/abqari/helpers.rb', line 201

def time_until(time)
  seconds = (to_time(time) - Time.now).to_i
  return time_ago(time) if seconds <= 0

  case seconds
  when 0..59                 then 'in less than a minute'
  when 60..3599              then "in #{pluralize(seconds / 60,         'minute')}"
  when 3600..86_399          then "in #{pluralize(seconds / 3600,       'hour')}"
  when 86_400..604_799       then "in #{pluralize(seconds / 86_400,     'day')}"
  when 604_800..2_591_999    then "in #{pluralize(seconds / 604_800,    'week')}"
  when 2_592_000..31_535_999 then "in #{pluralize(seconds / 2_592_000,  'month')}"
  else                            "in #{pluralize(seconds / 31_536_000, 'year')}"
  end
end

#to_time(value) ⇒ Object



300
301
302
303
304
305
306
# File 'lib/abqari/helpers.rb', line 300

def to_time(value)
  case value
  when Time then value
  when Date then value.to_time
  else Time.parse(value.to_s)
  end
end

#truncate(text, length: 100, omission: '…') ⇒ Object

Truncate text so the visible result (kept content + omission) is at most length characters. When omission is longer than length the budget for content is zero — falling back to a negative slice would silently return the LAST N chars of the text (Ruby's String#[] with a negative second arg returns nil or the trailing window). Clamp the slice width to 0 so the caller gets just the omission, not a surprise suffix.



137
138
139
140
141
142
143
144
145
# File 'lib/abqari/helpers.rb', line 137

def truncate(text, length: 100, omission: '')
  return '' if text.nil?

  text = text.to_s
  return text if text.length <= length

  slice_width = [length - omission.length, 0].max
  text[0, slice_width].rstrip + omission
end

#word_count(text) ⇒ Object

Count words in a string. Used for JSON-LD wordCount and any UI that surfaces word counts.

Tags are stripped first (same as reading_time) because callers pass RENDERED HTML — page.content, not the markdown source. A naive \S+ split over markup counts every attribute as a word, which inflated wordCount by roughly the tag density of the page. Commonmarker 2.x made that starkly visible: its heading anchors repeat the heading text in both aria-label and data-heading-content, so each heading silently added several phantom "words" to the structured data search engines read.



163
164
165
# File 'lib/abqari/helpers.rb', line 163

def word_count(text)
  text.to_s.gsub(/<[^>]+>/, ' ').scan(/\S+/).length
end

#years_since(year_or_date, today: Date.today) ⇒ Object

Whole years between the given year (or date) and today. Useful in about pages — "with over <%= years_since(2002) %> years of experience" — so the number doesn't go stale every January.

Accepts an integer year or any value to_time understands.



179
180
181
182
183
184
# File 'lib/abqari/helpers.rb', line 179

def years_since(year_or_date, today: Date.today)
  from = year_or_date.is_a?(Integer) ? Date.new(year_or_date, 1, 1) : to_time(year_or_date).to_date
  diff = today.year - from.year
  diff -= 1 if today.month < from.month || (today.month == from.month && today.day < from.day)
  [diff, 0].max
end