class

Hwaro::Models::Page

Inherits Reference < Object

Constants

MORE_MARKER_REGEX = /<!--\s*more\s*-->/i

Extract summary from content using <!-- more --> marker Returns content before the marker, or nil if no marker found Note: @raw_content has front matter already stripped during parsing

Fence-aware: a marker shown inside a fenced code block (a docs page demonstrating the feature) is literal text, not a split point. The per-line pattern no longer matches a marker whose parts sit on different lines — degenerate input the old multi-line \s* accepted.

Constructors

new(path : String)
Source

Instance methods

aliases
Source
aliases=(aliases : Array(String))
Source
ancestors

New: Ancestors - parent sections chain

Source
ancestors=(ancestors : Array(Page))

New: Ancestors - parent sections chain

Source
assets

New: Assets - static files in this page directory

Source
assets=(assets : Array(String))

New: Assets - static files in this page directory

Source
authors

New: Authors field (array of author names)

Source
authors=(authors : Array(String))

New: Authors field (array of author names)

Source
auto_summary

Automatic summary: plain text cut from the rendered body when the page has neither a <!-- more --> marker nor a description ([content] summary_length). Populated by ParseContent#render_page_summaries; nil when the fallback is disabled, when a higher-precedence source exists, or when the body has no prose. summary_truncated is true only when the text was cut.

Source
auto_summary=(auto_summary : String | Nil)

Automatic summary: plain text cut from the rendered body when the page has neither a <!-- more --> marker nor a description ([content] summary_length). Populated by ParseContent#render_page_summaries; nil when the fallback is disabled, when a higher-precedence source exists, or when the body has no prose. summary_truncated is true only when the text was cut.

Source
auto_summary_html

The automatic excerpt as HTML: text is escaped (it was decoded from the rendered body, so </& are literal characters) and wrapped in one paragraph. Nil when there is no automatic summary.

Source
build_warnings

Build warnings collected during rendering (used for error overlay in serve mode)

Source
build_warnings=(build_warnings : Array(String))

Build warnings collected during rendering (used for error overlay in serve mode)

Source
calculate_reading_time

Reading time in minutes from the counts calculate_word_count set: ~200 words per minute, and ~500 per minute for Chinese/Japanese characters (each counted as a word, but read much faster).

Source
calculate_word_count(text : String = @raw_content) : Int32

Calculate word count from raw content Note: @raw_content already has front matter stripped during parsing, so we only need to remove HTML tags and markdown syntax.

Source
cascade_fingerprint

Fingerprint of the merged section [cascade] values applied to this page (empty when none apply). Stored in the build cache so editing a parent _index.md's cascade invalidates descendant pages whose own source files did not change.

Source
cascade_fingerprint=(cascade_fingerprint : String)

Fingerprint of the merged section [cascade] values applied to this page (empty when none apply). Stored in the build cache so editing a parent _index.md's cascade invalidates descendant pages whose own source files did not change.

Source
collect_assets(content_dir : String, content_files : ContentFilesConfig | Nil = nil, bundle_dirs : Set(String) | Nil = nil) : Array(String)

Collect assets from page directory

When content_files is configured ([content.files] with allow_extensions), co-located bundle assets are filtered through the SAME allow/disallow rules as standalone content files. Without this, a root _index.md turns the whole content/ tree into one recursive bundle and republishes arbitrary non-markdown files (e.g. content/public/robots.txt) to the output regardless of allow_extensions. Without an allowlist every non-markdown file the deny rules leave is collected.

Two structural limits keep the recursion inside the bundle it belongs to (both hold regardless of [content.files], which many hand-written configs omit entirely):

  1. content/ itself is never a bundle. content/index.md is the homepage, not a bundle spanning the site, and treating it as one republished every non-markdown file anywhere under content/ (content/private/internal.pdf, notes.txt, *.bak, …) into the output root.
  2. Recursion stops at nested bundles/sections. A subdirectory holding its own index.md/_index.md is a different page and publishes its own assets; descending into it made every ancestor index re-copy the same files. bundle_dirs (content-relative directories that host their own bundle index, computed by Transform#collect_assets from the page set) extends nested-bundle detection to language-suffixed indexes (photos/index.ko.md) that the literal on-disk probe cannot see.
Source
content

Runtime / Computed Properties

Source
content=(content : String)

Runtime / Computed Properties

Source
custom_path
Source
custom_path=(custom_path : String | Nil)
Source
date
Source
date=(date : Time | Nil)
Source
description
Source
description=(description : String | Nil)
Source
draft
Source
draft=(draft : Bool)
Source
effective_summary

Get effective summary: <!-- more --> chunk > description > automatic excerpt. The excerpt is returned as a single escaped <p> so {{ page.summary | safe }} keeps working for every source.

Source
excluded_from_listings?

True when a page should be omitted from generated listings (taxonomy indexes, related posts, …): drafts, preview-only unpublished pages, headless (render = false) pages that are never written, and synthetic generated pages.

Source
expires
Source
expires=(expires : Time | Nil)
Source
extra

New: Extra field for arbitrary custom metadata from front matter. Values are recursive (ExtraValue) so nested [extra.*] subtables and arrays-of-tables round-trip into {{ page.extra.a.b }} in templates.

Source
extra=(extra : Hash(String, ExtraValue))

New: Extra field for arbitrary custom metadata from front matter. Values are recursive (ExtraValue) so nested [extra.*] subtables and arrays-of-tables round-trip into {{ page.extra.a.b }} in templates.

Source
extract_summary
Source
front_matter_keys
Source
front_matter_keys=(front_matter_keys : Array(String))
Source
generated
Source
generated=(generated : Bool)
Source
git

Commit metadata for this page's source file ([git] enabled = true). Nil when the feature is off, the file is uncommitted, the site is not a git checkout, or the page is synthesized/generated.

Source
git=(git : GitInfo | Nil)

Commit metadata for this page's source file ([git] enabled = true). Nil when the feature is off, the file is uncommitted, the site is not a git checkout, or the page is synthesized/generated.

Source
has_redirect?

Check if page has redirect

Source
higher
Source
higher=(higher : Page | Nil)
Source
home?

Is this the site (or per-language) homepage — the root index.md / _index.md? Its source sits directly under content/, so path has no / (index.md, index.ko.md, _index.md, …). Not is_index && section.empty?: a one-level page bundle like content/about/index.md also has an empty section (gh#601).

Source
image
Source
image=(image : String | Nil)
Source
in_search_index

New: In search index - whether to include in search index

Source
in_search_index=(in_search_index : Bool)

New: In search index - whether to include in search index

Source
in_sitemap
Source
in_sitemap=(in_sitemap : Bool)
Source
is_index
Source
is_index=(is_index : Bool)
Source
language
Source
language=(language : String | Nil)
Source
lower

New: Lower/Higher page references (previous/next in section)

Source
lower=(lower : Page | Nil)

New: Lower/Higher page references (previous/next in section)

Source
output_suppressed

True when the render phase resolved an output-path collision AGAINST this page, or found its URL unpublishable (a segment traverses out of the output directory), so no file was written for its URL (see compute_output_url_winners). Recomputed from scratch on every build and rerender — a collision introduced or resolved during a serve session must not leave a stale verdict behind.

The discovery surfaces have to honour it: without this the build warned about the collision, declined to write the page, and then advertised its URL anyway in sitemap.xml, llms.txt, the feeds and the search index — trading a silent overwrite for a guaranteed 404 that the site's own sitemap points at.

Source
output_suppressed=(output_suppressed : Bool)

True when the render phase resolved an output-path collision AGAINST this page, or found its URL unpublishable (a segment traverses out of the output directory), so no file was written for its URL (see compute_output_url_winners). Recomputed from scratch on every build and rerender — a collision introduced or resolved during a serve session must not leave a stale verdict behind.

The discovery surfaces have to honour it: without this the build warned about the collision, declined to write the page, and then advertised its URL anyway in sitemap.xml, llms.txt, the feeds and the search index — trading a silent overwrite for a guaranteed 404 that the site's own sitemap points at.

Source
parse_failed

Whether parsing (front-matter / markdown) failed for this page

Source
parse_failed=(parse_failed : Bool)

Whether parsing (front-matter / markdown) failed for this page

Source
path
Source
path=(path : String)
Source
plain_summary(limit : Int32 = 200) : String | Nil

Plain-text rendering of the <!-- more --> summary, safe to embed in single-line contexts like og:description, twitter:description, or a feed <description>. Uses the already-rendered summary_html and strips markup so raw markdown (## headings, code fences, math, literal newlines) never leaks into meta tags — see https://github.com/hahwul/hwaro/issues/491. Returns nil when the page has no summary. Soft-truncates to limit characters on a word boundary. Falls back to the automatic body excerpt (already plain text) when no marker summary exists.

Source
published_content?

True when the page's own HTML is published as content: rendered, not a draft / preview-only unpublished page, not suppressed by an output collision, and not a redirect_to stub. The sitemap and feeds advertise only such URLs.

Source
raw_content
Source
raw_content=(raw_content : String)
Source
reading_time
Source
reading_time=(reading_time : Int32)
Source
redirect_to

New: Redirect to - URL to redirect this page to

Source
redirect_to=(redirect_to : String | Nil)

New: Redirect to - URL to redirect this page to

Source
refresh_unpublished!(now : Time)

Recompute unpublished from the publication window (date in the future or expires in the past) relative to the build's single now. Called by the parse-phase filter and the incremental (--cache) re-parse path so every downstream consumer sees a consistent value.

Source
render
Source
render=(render : Bool)
Source
search_index_eligible?

True when a page is eligible for the search index / llms.txt: it emits HTML, isn't a draft or preview-only unpublished page, opts into the search index, isn't a synthetic generated listing page, and isn't a redirect_to stub (a meta-refresh bounce whose canonical is elsewhere).

Source
section
Source
section=(section : String)
Source
series

New: Series support

Source
series=(series : String | Nil)

New: Series support

Source
series_index
Source
series_index=(series_index : Int32)
Source
series_pages
Source
series_pages=(series_pages : Array(Page))
Source
series_weight
Source
series_weight=(series_weight : Int32)
Source
slug
Source
slug=(slug : String | Nil)
Source
social_image

image as the social / structured-data surfaces must resolve it. A relative value naming one of this page's own bundle assets (image = "cover.png" beside index.md) is published under the page's URL, like a relative ![](cover.png) in its body — resolving it against the site root (/cover.png) advertised a 404 og:image. Anything else is returned as written (root-relative, external, or a relative path meant from the site root, e.g. a static/ file).

Source
source_read

Stamp of the source file as parse read it: {mtime ms, size, digest}. The digest is only taken when the mtime was still inside its timestamp tick (racy-git); see Cache#update. Nil for pages without a disk file.

Source
source_read=(source_read : Tuple(Int64, Int64, String | Nil) | Nil)

Stamp of the source file as parse read it: {mtime ms, size, digest}. The digest is only taken when the mtime was still inside its timestamp tick (racy-git); see Cache#update. Nil for pages without a disk file.

Source
summary

New: Summary - content before <!-- more --> marker or auto-generated

Source
summary=(summary : String | Nil)

New: Summary - content before <!-- more --> marker or auto-generated

Source
summary_html

Rendered HTML for the chunk before <!-- more -->. Populated by the build pipeline after extract_summary runs and the markdown processor is available; nil for pages without a <!-- more --> marker (in which case page.summary falls back to description). See https://github.com/hahwul/hwaro/issues/491.

Source
summary_html=(summary_html : String | Nil)

Rendered HTML for the chunk before <!-- more -->. Populated by the build pipeline after extract_summary runs and the markdown processor is available; nil for pages without a <!-- more --> marker (in which case page.summary falls back to description). See https://github.com/hahwul/hwaro/issues/491.

Source
summary_truncated
Source
summary_truncated=(summary_truncated : Bool)
Source
synthesis
Source
synthesis=(synthesis : Synthesis | Nil)
Source
synthesized?

True for pages materialized from [[content.generate]].

Source
tags
Source
tags=(tags : Array(String))
Source
taxonomies
Source
taxonomies=(taxonomies : Hash(String, Array(String)))
Source
taxonomy_name
Source
taxonomy_name=(taxonomy_name : String | Nil)
Source
taxonomy_term
Source
taxonomy_term=(taxonomy_term : String | Nil)
Source
taxonomy_values(name : String) : Array(String)

Resolve the term list for a configured taxonomy name. Most taxonomies live in @taxonomies, but a few — tags and authors — are stored on dedicated Page properties so other features (the tags shortcut, the site.authors aggregation) can reach them without a hash lookup. Centralizing the fallback here keeps call sites (Taxonomies generator, related-posts scoring, …) from forgetting any of the special cases.

Source
template
Source
template=(template : String | Nil)
Source
template_summary

page.summary as templates see it: the rendered summary, else the description or excerpt. Never the raw <!-- more --> chunk — its shortcodes and Markdown unrendered — which is all a page has before the summary pass renders it (and what a summary shortcode reading that page would otherwise publish).

Source
term_metadata
Source
term_metadata=(term_metadata : Bool)
Source
term_page_url

A content/<taxonomy>/<slug>/_index.md matched to the term page this build generates at term_page_url (Content::Taxonomies. match_term_metadata). Such a section is headless: term_metadata (the file that won that page) lends it its title, description, extra, image, body and aliases; a losing duplicate only stays unpublished. Set after parsing (Builder#mark_term_metadata), reset by every parse.

Source
term_page_url=(term_page_url : String | Nil)

A content/<taxonomy>/<slug>/_index.md matched to the term page this build generates at term_page_url (Content::Taxonomies. match_term_metadata). Such a section is headless: term_metadata (the file that won that page) lends it its title, description, extra, image, body and aliases; a losing duplicate only stays unpublished. Set after parsing (Builder#mark_term_metadata), reset by every parse.

Source
title

Front Matter Properties

Source
title=(title : String)

Front Matter Properties

Source
toc=(toc : Bool)
Source
translation_key

Front matter translation_key: pages sharing it (within one version) are translations of each other, whatever their file names. Nil groups by file name (Content::Multilingual.link_translations!).

Source
translation_key=(translation_key : String | Nil)

Front matter translation_key: pages sharing it (within one version) are translations of each other, whatever their file names. Nil groups by file name (Content::Multilingual.link_translations!).

Source
unpublished

True when the page is outside its publication window at build time (future date or past expires). Such pages only survive the parse filter under --include-future / --include-expired — preview flags — and must then behave exactly like drafts under --drafts: the HTML page renders, but the page stays out of every public discovery surface (sitemap, feeds, search index, llms.txt) and out of generated listings (taxonomies, series, related posts, authors).

Source
unpublished=(unpublished : Bool)

True when the page is outside its publication window at build time (future date or past expires). Such pages only survive the parse filter under --include-future / --include-expired — preview flags — and must then behave exactly like drafts under --drafts: the HTML page renders, but the page stays out of every public discovery surface (sitemap, feeds, search index, llms.txt) and out of generated listings (taxonomies, series, related posts, authors).

Source
untitled?

No authored title: front matter left it unset (the "Untitled" placeholder) or blank.

Source
updated
Source
updated=(updated : Time | Nil)
Source
url=(value : String)

# and ? are legal in a filename (and in an explicit slug/path) but they END the path component of a URL: /posts/a#b/ is the page /posts/a with the fragment b. A page named a#b.md was written correctly to public/posts/a#b/index.html, yet every link hwaro emitted for it — page.url in listings, page.permalink, the sitemap <loc> — pointed at /posts/a, a 404. Encode them here, at the one place a page's URL is assigned, so templates that use page.url directly are right too and not just the permalink.

Little else is escaped. The output path is derived from this string by decoding it again (PathUtils.split_safe_segments), so %23 still lands in the directory named a#b that the URL now addresses — whereas encoding more (a non-ASCII slug, say) would rename the directory every existing site already publishes to. A valid %XX escape is kept as is, which also keeps this idempotent when a URL is assigned twice.

" is escaped for the same reason from the HTML side: templates print page.url into double-quoted attributes unescaped (autoescape is off), so with"quote.md cut its own href short and linked a 404.

A % that starts no escape (100%.md) is escaped: /posts/100%/ is not a valid URL, and servers that reject it answer 400. So is every % when the name's %XX runs do not decode to UTF-8 (100%deal.md): the output path decodes them and scrubs the invalid bytes, so /posts/100%deal/ was written to a 100<U+FFFD>al/ directory no request for that URL reaches. Either way the URL then names the literal file name.

Source
version

Documentation version this page belongs to ([[versions.list]]), resolved from its content directory in ReadContent; nil for unversioned content. See Content::Versions.

Source
version=(version : VersionConfig | Nil)

Documentation version this page belongs to ([[versions.list]]), resolved from its content directory in ReadContent; nil for unversioned content. See Content::Versions.

Source
warned_messages

Warnings already logged for this page by its summary pass and body render (see Logger.dedupe_warnings); cleared when the summary pass starts and when the body render ends.

Source
weight
Source
weight=(weight : Int32)
Source
word_count

New: Word count and reading time (computed)

Source
word_count=(word_count : Int32)

New: Word count and reading time (computed)

Source

Nested types