Class: Abqari::Page
- Inherits:
-
Object
- Object
- Abqari::Page
- Includes:
- Bundle, Incremental, Parser, Path
- Defined in:
- lib/abqari/page.rb,
lib/abqari/page/path.rb,
lib/abqari/page/bundle.rb,
lib/abqari/page/parser.rb,
lib/abqari/page/incremental.rb
Overview
A single .md page under content/. Pages parse their frontmatter
-
body at construction, then expose:
- Identity:
title,description,date,tags,slug - Collection:
collection,post?,disabled?,draft?,future? - Content:
body,raw_body,content - Render:
render,write
- Identity:
Cross-cutting concerns live in mixin modules so the core class
stays focused. Each module operates on Page instance state (the
@source_path, @site, @frontmatter, @body, @content
ivars) — there's no separate "Page collaborator" object to keep
in sync.
- Page::Parser : initial file read + frontmatter parsing +
lazy body reload (see `forget_body!`).
- Page::Path : `output_path`, `url`, `layout_name`,
render_permalink, relative_* helpers.
Routes user-input `permalink:` through
PathSafe.join_under so traversal is
impossible by construction.
- Page::Bundle : bundle directory discovery + asset
enumeration (with symlink filtering).
- Page::Incremental : `needs_render?`, `lastmod`, `forget_body!`.
Defined Under Namespace
Modules: Bundle, Incremental, Parser, Path
Constant Summary collapse
- FRONTMATTER_RE =
Frontmatter parser — re-exposed here so existing call sites (
page/parser.rb) keep theirPage::FRONTMATTER_REreference. The canonical definition lives inAbqari::FRONTMATTER_RE. Abqari::FRONTMATTER_RE
- DATE_PREFIX_RE =
/\A(\d{4})-(\d{2})-(\d{2})-/.freeze
- MARKDOWN_OPTIONS =
render.unsafe: truelets authors drop raw HTML directly into a markdown body —<mark>,<details>/<summary>, custom<div>wrappers, embeds, anything Markdown doesn't have a syntax for. This is safe in Abqari because content is the author's own; we're not rendering untrusted markdown from anonymous visitors. See docs/security.md §1. { extension: { table: true, strikethrough: true, autolink: true, tasklist: true, footnotes: true, header_ids: '' }, render: { unsafe: true, # Explicit, not inherited: commonmarker 2.x defaults this to # TRUE, which turns every soft line break into a `<br />`. # Prose in this project (and most markdown) is hard-wrapped in # the source, so inheriting that default reflows every # paragraph into ragged lines. Pin it and the wrap stays a # source-formatting detail, invisible in the output. hardbreaks: false }, plugins: { syntax_highlighter: nil # Rouge handles this in Site#post_process } }.freeze
Constants included from Bundle
Bundle::CONTENT_EXTENSIONS, Bundle::IMAGE_EXTENSIONS, Bundle::PUBLISHABLE_EXTENSIONS
Constants included from Path
Path::LAYOUT_ALLOWED, Path::PERMALINK_ALLOWED
Instance Attribute Summary collapse
-
#frontmatter ⇒ Object
readonly
Returns the value of attribute frontmatter.
-
#paginator ⇒ Object
Returns the value of attribute paginator.
-
#site ⇒ Object
readonly
Returns the value of attribute site.
-
#source_path ⇒ Object
readonly
Returns the value of attribute source_path.
Instance Method Summary collapse
-
#body ⇒ Object
Markdown body, lazily reloaded from disk if a previous write has released it via
forget_body!. -
#collection ⇒ Object
Which collection does this page belong to, or nil for top-level pages like content/about.md.
-
#content ⇒ Object
Rendered HTML for the page body.
- #date ⇒ Object
-
#description ⇒ Object
Resolution chain: 1.
-
#disabled? ⇒ Boolean
Page-level on/off switch —
enabled: falsein frontmatter skips the page entirely from the build (no HTML output, no nav entry, no sitemap entry, no feed entry, no taxonomy contribution). - #draft? ⇒ Boolean
- #future? ⇒ Boolean
-
#initialize(source_path, site) ⇒ Page
constructor
A new instance of Page.
-
#post? ⇒ Boolean
Backwards-compatible "is this in the posts collection?" check.
-
#raw_body ⇒ Object
Same content as
#body; kept as a distinct method name because callers (first_paragraph_text) want to be explicit they're reading the un-rendered source markdown, not the ERB-evaluated form thatrendered_bodywould emit. -
#slug ⇒ Object
The collection-relative slug.
- #tags ⇒ Object
-
#title ⇒ Object
Title resolution: publications use
title; bundles + series usename(per the canonical schema in docs/publications.md). -
#write ⇒ Object
-------------------------------------------------------------- Render + write --------------------------------------------------------------.
Methods included from Incremental
#forget_body!, #lastmod, #needs_render?
Methods included from Bundle
#bundle_assets, #bundle_dir, #bundle_entries, #bundle_images, #publishable_bundle_file?, #unpublishable_bundle_files
Methods included from Path
#layout_name, #output_path, #url
Constructor Details
#initialize(source_path, site) ⇒ Page
Returns a new instance of Page.
82 83 84 85 86 87 |
# File 'lib/abqari/page.rb', line 82 def initialize(source_path, site) @source_path = source_path @site = site @paginator = nil parse end |
Instance Attribute Details
#frontmatter ⇒ Object (readonly)
Returns the value of attribute frontmatter.
79 80 81 |
# File 'lib/abqari/page.rb', line 79 def frontmatter @frontmatter end |
#paginator ⇒ Object
Returns the value of attribute paginator.
80 81 82 |
# File 'lib/abqari/page.rb', line 80 def paginator @paginator end |
#site ⇒ Object (readonly)
Returns the value of attribute site.
79 80 81 |
# File 'lib/abqari/page.rb', line 79 def site @site end |
#source_path ⇒ Object (readonly)
Returns the value of attribute source_path.
79 80 81 |
# File 'lib/abqari/page.rb', line 79 def source_path @source_path end |
Instance Method Details
#body ⇒ Object
Markdown body, lazily reloaded from disk if a previous write has
released it via forget_body!. The first read happens at parse
time so frontmatter is parsed in a single I/O — body is just the
remainder. After write, the body is dropped to free memory; if
another page later needs this page's description (which falls
back to its first paragraph), the file is re-read on demand.
The reload is benign-racy under parallel rendering: concurrent readers might both miss the cache and both re-read, but both end up writing the same content. No mutex needed.
255 256 257 258 259 260 |
# File 'lib/abqari/page.rb', line 255 def body return @body if @body load_body_from_file! @body end |
#collection ⇒ Object
Which collection does this page belong to, or nil for top-level
pages like content/about.md. A collection is a config-defined
subdirectory of content/ — e.g. content/posts/,
content/photos/, content/workshops/ — with its own permalink
pattern and default layout. Users declare additional ones in
collections: in config/site.yml.
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 |
# File 'lib/abqari/page.rb', line 193 def collection return @collection if defined?(@collection) @collection = nil site.collections.each do |name, cfg| # Disabled collections are invisible — their files don't get # claimed as collection items, their permalinks don't apply, # and SiteLoader#load_pages skips them entirely. next unless cfg.fetch('enabled', true) source = File.join(site.site_root, cfg['source']) next unless source_path.start_with?(source + File::SEPARATOR) # Bundle-style: content/<name>/<slug>/index.md (default for posts). if cfg['bundle'] != false next unless File.basename(source_path) == 'index.md' relative = source_path.sub("#{source}/", '').split('/') @collection = name if relative.length == 2 else # Flat: content/<name>/<slug>.md relative = source_path.sub("#{source}/", '').split('/') @collection = name if relative.length == 1 end break if @collection end @collection end |
#content ⇒ Object
Rendered HTML for the page body.
By default, the markdown body is passed straight through
commonmarker. Pages that need dynamic content opt in by setting
erb: true in their frontmatter; the body then runs through ERB
before commonmarker with the same RenderContext layouts use, so
page, site, partials, and helpers are all available.
Subject to the two-gate model — see rendered_body and
docs/security.md §3.
288 289 290 |
# File 'lib/abqari/page.rb', line 288 def content @content ||= Commonmarker.to_html(rendered_body, options: MARKDOWN_OPTIONS) end |
#date ⇒ Object
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/abqari/page.rb', line 134 def date return @date if defined?(@date) source_for_date = bundle_dir ? File.basename(bundle_dir) : File.basename(source_path) prefix_match = source_for_date.match(DATE_PREFIX_RE) @date = if frontmatter['date'] fm_date = begin Helpers.to_time(frontmatter['date']) rescue ArgumentError, TypeError => e raise "invalid date #{frontmatter['date'].inspect} in #{source_path}: #{e.}" end # Front matter wins over the filename prefix, but warn if # they disagree — a silent mismatch is a common "why is my # URL/order wrong?" gotcha. if prefix_match && fm_date.strftime('%Y-%m-%d') != prefix_match[0].chomp('-') Log.warn "date mismatch in #{source_path}: frontmatter " \ "#{fm_date.strftime('%Y-%m-%d')} overrides filename prefix #{prefix_match[0].chomp('-')}" end fm_date elsif prefix_match Time.new(prefix_match[1].to_i, prefix_match[2].to_i, prefix_match[3].to_i) end end |
#description ⇒ Object
Resolution chain:
1. Frontmatter explicit value (per-collection key:
- publications → `tagline` then `excerpt`
- everything else → `description`)
2. The page's first paragraph, with markdown stripped. Lets
writers ship a post without setting `description:` and still
get a useful meta description / lede for SEO and social
cards. Matches Hugo / Eleventy `firstParagraph` behaviour.
3. Site-wide `description:` from config — final fallback.
The auto-derived path truncates to ~200 chars and trims trailing punctuation so it reads as a complete sentence.
Memoised — listing partials (_post_list, related-items, feeds,
sitemap) all call description on every page in their slice,
and the auto-derived path re-reads the body from disk if
forget_body! has already released it. Without memoisation a
1000-post archive triggers 1000 cold-cache file reads when it
renders after the per-post pages. Memoised, the cost is one
read per page across the whole build.
121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/abqari/page.rb', line 121 def description return @description if defined?(@description) explicit = explicit_description_value @description = if !explicit.to_s.strip.empty? explicit elsif !(auto = first_paragraph_text).to_s.strip.empty? auto else site.config['description'] end end |
#disabled? ⇒ Boolean
Page-level on/off switch — enabled: false in frontmatter skips
the page entirely from the build (no HTML output, no nav entry,
no sitemap entry, no feed entry, no taxonomy contribution).
Distinct from published: false (drafts), which renders in
development with a banner so you can preview unpublished work.
enabled: false is hidden in BOTH dev and prod — "off," not "in
progress."
229 230 231 |
# File 'lib/abqari/page.rb', line 229 def disabled? frontmatter['enabled'] == false end |
#draft? ⇒ Boolean
233 234 235 |
# File 'lib/abqari/page.rb', line 233 def draft? frontmatter['published'] == false || frontmatter['draft'] == true end |
#future? ⇒ Boolean
237 238 239 |
# File 'lib/abqari/page.rb', line 237 def future? date && date > Time.now end |
#post? ⇒ Boolean
Backwards-compatible "is this in the posts collection?" check. Used throughout the engine to opt into post-specific behaviour: bundle assets, paginated indexes, RSS, JSON-LD Article schema.
183 184 185 |
# File 'lib/abqari/page.rb', line 183 def post? collection == 'posts' end |
#raw_body ⇒ Object
Same content as #body; kept as a distinct method name because
callers (first_paragraph_text) want to be explicit they're
reading the un-rendered source markdown, not the ERB-evaluated
form that rendered_body would emit.
IO failures are rescued so a transient miss (e.g. a source file
deleted mid-incremental-rebuild) doesn't crash a build that
only wanted a description fallback. The rescue is logged at
warn so the operator still sees the issue rather than
silently shipping the site-wide description fallback.
272 273 274 275 276 277 |
# File 'lib/abqari/page.rb', line 272 def raw_body body rescue StandardError => e Log.warn "raw_body failed for #{source_path}: #{e.class}: #{e.}" nil end |
#slug ⇒ Object
The collection-relative slug. Used externally by Site#featured and
by book/bundle layouts to look up publication data. For bundle-
style content (content/<col>/<slug>/index.md) this returns the
parent dir name; for flat content (content/<col>/<slug>.md) the
filename minus date prefix. Returns nil for top-level pages
outside any collection.
169 170 171 172 173 174 |
# File 'lib/abqari/page.rb', line 169 def slug return nil unless collection base = bundle_dir ? File.basename(bundle_dir) : File.basename(source_path, '.md') Helpers.slugify(base.sub(DATE_PREFIX_RE, '')) end |
#tags ⇒ Object
159 160 161 |
# File 'lib/abqari/page.rb', line 159 def Array(frontmatter['tags']) end |
#title ⇒ Object
Title resolution: publications use title; bundles + series use
name (per the canonical schema in docs/publications.md). Posts,
photos, workshops, and untyped pages continue to use title.
96 97 98 99 |
# File 'lib/abqari/page.rb', line 96 def title key = title_key_for_collection frontmatter[key] || frontmatter['title'] || derived_title end |
#write ⇒ Object
Render + write
296 297 298 299 300 301 |
# File 'lib/abqari/page.rb', line 296 def write html = site.post_process(render, page: self) FileUtils.mkdir_p(File.dirname(output_path)) File.write(output_path, html) forget_body! end |