Class: Abqari::Page

Inherits:
Object
  • Object
show all
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

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 their Page::FRONTMATTER_RE reference. The canonical definition lives in Abqari::FRONTMATTER_RE.

Abqari::FRONTMATTER_RE
DATE_PREFIX_RE =
/\A(\d{4})-(\d{2})-(\d{2})-/.freeze
MARKDOWN_OPTIONS =

render.unsafe: true lets 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

Instance Method Summary collapse

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

#frontmatterObject (readonly)

Returns the value of attribute frontmatter.



79
80
81
# File 'lib/abqari/page.rb', line 79

def frontmatter
  @frontmatter
end

#paginatorObject

Returns the value of attribute paginator.



80
81
82
# File 'lib/abqari/page.rb', line 80

def paginator
  @paginator
end

#siteObject (readonly)

Returns the value of attribute site.



79
80
81
# File 'lib/abqari/page.rb', line 79

def site
  @site
end

#source_pathObject (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

#bodyObject

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

#collectionObject

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

#contentObject

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

#dateObject



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

#descriptionObject

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."

Returns:

  • (Boolean)


229
230
231
# File 'lib/abqari/page.rb', line 229

def disabled?
  frontmatter['enabled'] == false
end

#draft?Boolean

Returns:

  • (Boolean)


233
234
235
# File 'lib/abqari/page.rb', line 233

def draft?
  frontmatter['published'] == false || frontmatter['draft'] == true
end

#future?Boolean

Returns:

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

Returns:

  • (Boolean)


183
184
185
# File 'lib/abqari/page.rb', line 183

def post?
  collection == 'posts'
end

#raw_bodyObject

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

#slugObject

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

#tagsObject



159
160
161
# File 'lib/abqari/page.rb', line 159

def tags
  Array(frontmatter['tags'])
end

#titleObject

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

#writeObject


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