Skip to content
Documentation
Browse the library

docs/PAGEHARE_BLOCKS.md · Simplified edition

On this page

PageHare Blocks — Canonical Spec

Authors write content. PageHare's rules choose its layout without changing the words. Markdown is the source of truth. It is stored as com.pagehare.content.markdown inside site.standard.document on the author's Personal Data Server (PDS). The same input always produces the same block choices. AI Format is a planned option for sections that the rules leave as prose.

This document defines the block rules. Run npm run docs:blocks to build its generated tables from code. Never edit inside the generated markers. CI fails if the tables differ from the code.

The engine in one paragraph

packages/core/src/lib/blocks/ exports segment, classify, blockSchemas, and rules. Remark parses Markdown into a syntax tree called mdast. segment(ast) splits this tree into sections. classify(sections) checks the rules in order and uses the first match. If no specific rule matches, the section stays as prose.

Rules, AI proposals, and manual choices use these same section boundaries. The Zod blockSchemas object defines the allowed block shapes. It validates rule and AI output and defines the types passed to the renderer.

Rules (generated)

Check rules in array order and use the first match. Do not combine rules or add weights or confidence scores. If a rule needs a setting, use two rules or an AI case.

<!-- BEGIN GENERATED: rules -->

Engine versions: segmenter seg-1, ruleset rules-1, hash fnv1a64-1.

OrderRuleBlockWhen it matches
1header-headingheaderA standalone heading, or the document-leading H1 with a short lead paragraph, becomes a header. A short lead (≤120 chars) is the subtitle; a longer one is the description.
2qa-alternatingqaTwo or more alternating question paragraphs (ending in ?) and answer paragraphs become a Q&A. Each question keeps its ? and each answer may span multiple paragraphs.
3list-itemsitemsAn unordered list of three or more items becomes an items grid. A leading bold phrase is the item label and the rest its explanation; a trailing " — key: value" segment becomes item metadata.
4prose-fallbackproseAnything else renders as prose, verbatim.
<!-- END GENERATED: rules -->

Fence-tag registry (generated)

Exotic blocks that markdown cannot express structurally enter via the fence info string — a deterministic rule that degrades gracefully in any other markdown client. Fence payloads are parsed and Zod-validated only; content is data, never code. Fence tags whose blocks accumulate external state (a future poll, form, counter) are marked stateful in the registry and carry a stable instance id in their payload — external state is keyed (document AT-URI, instanceId), never by section hash, and lives in PageHare's store, never in the markdown (ENGINE_INVARIANTS #16/#17).

<!-- BEGIN GENERATED: fence-tags -->
Fence tagPayloadBlockPurpose
chartcsvreservedReserved for the future chart block: a fenced code block tagged chart carrying CSV data. Falls through to prose until the chart block ships.
<!-- END GENERATED: fence-tags -->

Block shapes

Block shapes are defined only in packages/core/src/lib/blocks/schemas.ts (Zod). Rich-text props are markdown strings — verbatim slices of the canonical markdown; plain-text props are plaintext projections. Summary (see the schemas for authority):

  • prose — { body: markdown }

  • header — { eyebrow?, heading, subtitle?, description?: markdown, ctas?: {label, href}[] (max 2) }. eyebrow and ctas are never rule-filled (markdown headings cannot express them); AI or manual decisions may fill them.

  • items — { heading?, description?: markdown, items: {label, explanation?: markdown, meta?: Record<string,string>}[] (min 3) }

  • qa — { heading?, description?: markdown, items: {prompt, response: markdown, meta?}[] (min 2) }

Shapes scale, variants don't. Classification targets data shapes (bounded, ~20–30 long-term). Presentational variants live in the renderer layer (packages/app/src/lib/components/public/blocks/) as presets and never add classification rules. Presets are props on the renderer, not fields on the schema, so presentation never round-trips through the PDS. Today exactly one exists: prose's lead, set by the route on the document's opening prose block.

Note that a preset may not contradict a DS invariant: card/bordered/shadowed variants of items and qa are rejected, because structure comes from whitespace and faint tone, not boxes (PAGEHARE_DS.md §2.1).

Tier 2 token surface

Each block defines its tokens on its own selector in packages/app/src/lib/styles/blocks.css — never at :root — and its component reads them directly, with no fallback value (PAGEHARE_DS.md §3/§5). Block visuals live in the component's scoped styles, where lint:tokens can see them; blocks.css is lint-exempt and holds the token surface only.

BlockSelectorTokens
prose.ph-prose--prose-font, --prose-size, --prose-leading, --prose-measure, --prose-color, --prose-link, --prose-link-underline, --prose-flow, --prose-lead-size, --prose-lead-leading, --prose-heading-size, --prose-heading-space, --prose-quote-rule, --prose-quote-color, --prose-quote-indent, --prose-code-font, --prose-code-size, --prose-code-bg, --prose-code-pad, --prose-pre-pad
header.ph-header--header-font, --header-ui-font, --header-size, --header-weight, --header-leading, --header-tracking, --header-color, --header-sub-color, --header-space, --header-eyebrow-color, --header-eyebrow-size, --header-eyebrow-tracking, --header-eyebrow-space, --header-sub-size, --header-sub-space, --header-desc-space, --header-cta-space, --header-cta-gap
items.ph-items--items-gap, --items-pad, --items-bg, --items-radius, --items-border, --items-space, --items-heading-size, --items-heading-space, --items-desc-color, --items-desc-space, --items-label-font, --items-label-weight, --items-label-space, --items-body-size, --items-body-color, --items-body-leading, --items-meta-color, --items-meta-size, --items-meta-gap
qa.ph-qa--qa-font, --qa-q-weight, --qa-q-color, --qa-a-color, --qa-a-space, --qa-rule, --qa-indent, --qa-gap, --qa-space, --qa-marker, --qa-marker-size, --qa-heading-size, --qa-heading-space, --qa-desc-color, --qa-desc-space, --qa-meta-color, --qa-meta-size

Rendering contracts. qa is a native <details>/<summary> disclosure — its answers are always in the DOM, so they are indexable and readable with JS disabled; the first item is open by default. items goes two-up via a container query on its wrapper at 48rem, deliberately wider than --ph-measure, so the reading column never splits and only wide embeds and editor previews do. Blocks carry no viewport media queries: they render at non-viewport widths.

Authoring conventions

Items: labels and metadata

  • A leading bold phrase in a list item is its label; the rest (after :, —, –, or -) is the explanation. - **Fast**: ships in seconds

  • A trailing — key: value segment in the explanation becomes item metadata: - **Fast** — ships in 2s — since: 2024 → meta: { since: "2024" }

  • Plain single-paragraph items map with the whole text as the label.

Q&A: question detection

A paragraph is a question when its trimmed plain text ends with ? (or ?), or when it is a single bold phrase ending in ?. Two or more alternating question/answer paragraphs form a Q&A; answers may span multiple paragraphs.

Segmentation behavior worth knowing

  • Headings and horizontal rules (---) open section boundaries; the --- itself belongs to no section and is not rendered.

  • Every heading is its own section, with exactly one exception: a document-leading H1 plus its short (≤200 chars) following paragraph merge into a hero section (the hero-lead exception — the only entry in the segmenter's exception registry).

  • Noise terminates runs strictly. A list dropped between two Q/A pairs splits them into two sections — no tolerance windows. Merging across noise is exclusively AI or manual territory.

  • Tables fall through to prose. A markdown table tells you structure, not intent. Charts enter only via AI Format or the explicit ```chart fence.

Known limitations (explicit scope — do not "fix" with heuristics)

  • Question detection is the trailing-? convention only. No leading-¿ Spanish detection, no locale-aware interrogative analysis, no support for scripts without a question-mark convention. These would break the determinism guarantee; locale-aware classification is AI Format territory.

  • Table cells flatten to inline text when serialized from the editor (GFM pipe tables cannot carry block content).

  • The meta key syntax accepts Latin identifier keys only ([A-Za-z][\w-]*).

Decision provenance

Every classification is { block, props, source: 'rule' | 'ai' | 'manual', ruleId? } plus version stamps (rulesetVersion, segmenterVersion, hashVersion). One decision type, three sources; merge precedence is manual > ai > rule. Saved decisions are keyed by (document AT-URI, sectionHash, hashVersion, rulesetVersion) and live in PageHare's own store — presentation is never written into the PDS record.

See docs/ENGINE_INVARIANTS.md for the full contract.

Article page layout (reader chrome)

ArticleLayout.svelte arranges existing blocks on the article page. It is not a content block, and the engine does not classify it. The styleguide renders the real component. It uses these .ph-article-layout tokens: --article-measure, --article-rail, --article-gap, --article-small-gap, --article-link-pad, --article-top, --article-bottom, --article-pad, --article-ui-font, --article-ui-size, --article-leading, --article-color, --article-muted, --article-link, --article-rule, --article-title-font, and --article-title-size. Their definitions are in styles/blocks.css.

Headings:

  • The opening H1 header stays above the byline and the cover.

  • Other heading blocks keep their heading level from the source. The header renderer's level preset does this. The canonical markdown and the block schemas do not change.

  • Section anchors use occurrence numbers, so readers can go to each of two headings with the same text.

Section navigation:

  • A page with two or more sections gets a list of contents.
  • On a compact layout, this list is a native collapsible element.
  • On a wide layout, it is a navigation rail that stays in view while the reader scrolls (sticky).

The reading list comes after the content, then the profile. This is the order in the DOM and on mobile. On a wide layout, the rail holds only section navigation.

Settings → Article layout has three presets: Three columns, Article + right sidebar, and Focused. It also has toggles for the author, the author link, section navigation, related reading, and reading time.

  • The byline can show without the full profile. It is text only (the avatar is in the profile). It ends with the recommend and bookmark links.
  • The profile holds the Subscribe button. If there is no profile, Subscribe is in the row after the article.
  • An empty rail takes no space.

"Keep reading" is one list after the article, in the article's column:

  • It shows pages that share a tag first, then the author's newest pages.
  • It shows five pages at most, and never the current article.
  • Each row shows the title, two lines of the stored description, and the date of first publication.
  • The list ends with a link to the archive.
  • It uses visible native documents from the author's latest 50 records.
  • If the lookup fails, the page shows no list.

Publication page layout

PublicationLayout.svelte uses the same frame around a list. It is the author's publication page: /a/[handle], or the root of the author's subdomain. It reads the .ph-article-layout tokens through .ph-publication-layout, in one rule in styles/blocks.css. So the two pages always share the same measure, rail and gaps. The styleguide renders it after the article example.

In the source order and on a narrow screen, the page shows:

  1. The author card: the page's h1, bio, Subscribe, Share, RSS and the Bluesky link.

  2. Topics.

  3. The list.

From 64rem, the card is a sticky right rail. From 80rem, Topics is a sticky left rail if the author has tags.

Topics:

  • Topics are the author's 20 most-used tags, most used first. Spellings that differ only in case, accents, spacing or punctuation count as one topic.

  • Each topic is a plain link to the same page with ?tag=. The server filters the list.

  • The canonical address stays the unfiltered page.

  • A tag that no page uses shows an empty list, with status 200.

The list is PageCardGrid with one column. Share is ReaderActions (compact, with no intent links), given the publication's address.

Validation (2026-10-08): targeted checks covered API ownership, storage persistence, article visibility, identity resolution, reading with JavaScript off, resizing from desktop to mobile, all three presets, hidden and empty rails, saved settings, and keyboard tab navigation. Default light and dark body and link contrast was checked; the lowest sampled ratio was 4.91:1. The article layout keeps the reader action links that next added. Settings use the shared service-DID helper from next. PH-63 tracks a separate review of the publication-page UX.