Content Visibility

Introduction

Content visibility is ReeWeb's single home for the per-page visibility policy: given a page's frontmatter and the SSG script clock, it decides where a page is visible. It lives in lib/content_visibility.ts and consolidates logic that was previously scattered across several files behind ad-hoc frontmatter keys (rss, sitemap, noindex, plus "always rendered").

The core idea is that a draft is a per-post soft launch. A page that is not published - a draft, or a post whose published_at is still in the future - is still rendered and reachable by URL, but it is absent from every aggregation (listings, RSS/JSON feeds, sitemap) and carries robots: noindex. To the outside world (random visitors, crawlers, feed readers) it does not exist, yet the author can still hand the URL to a reviewer. This mirrors how soft-launch languages behave, keyed per-post instead of per-language.

The module is pure - no I/O, and no clock of its own (callers pass now) - so the policy is deterministic and unit-testable. Projects do not edit this file. They adjust the result per-page through the content_visibility project hook.

The five channels

Visibility is not a single boolean - it is five independent channels:

type Visibility = {
    render: boolean;   // emit the HTML page at its URL at all
    list: boolean;     // appear in on-page listings / pagination
    feed: boolean;     // appear in the RSS + JSON feeds
    sitemap: boolean;  // appear in sitemap.xml
    index: boolean;    // omit robots:noindex (true = indexable)
};
ChannelControls
renderWhether the page's HTML is emitted at its URL at all.
listWhether the page appears in on-page listings and pagination.
feedWhether the page appears in the RSS and JSON feeds.
sitemapWhether the page appears in sitemap.xml.
indexWhether the page is indexable (falserobots: noindex).

What counts as published

is_published(frontmatter, now) is the AND of two conjuncts:

  • not_draft - draft: true (or its inverse alias published: false) marks a page unpublished.
  • date_has_passed - a published_at (or its alias date) in the future marks the page unpublished. This is a correctness guard so a future-dated post never leaks before its own date - it is not scheduling: nothing auto-regenerates; the page simply goes public the first time the SSG script runs after its date. Absent or unparseable dates never suppress.

Frontmatter keys

KeyTypeDefaultEffect
draftbooleanfalsetrue → unpublished (generated but hidden everywhere, noindex).
publishedbooleantruefalse is an inverse alias for draft: true.
published_atdate string-A future date → unpublished until that date.
datedate string-Alias for published_at.
rssbooleantruefalse drops a published page from listings and feeds.
sitemapbooleantruefalse drops a published page from the sitemap.
noindexbooleanfalsetrue drops the page from listings, feeds, sitemap and marks it noindex.

The default policy

default_visibility(frontmatter, now) resolves the channels:

Not published (draft or future-dated) - generated but hidden:

renderlistfeedsitemapindex

Published - the historical per-key opt-outs apply exactly (so output is byte-identical to the pre-consolidation behaviour):

Frontmatterrenderlistfeedsitemapindex
(none)
rss: false
sitemap: false
noindex: true

Soft-launch-language noindex is applied separately (in page data) and is intentionally not modeled here - this function is per-page, not per-language.

Where it is applied

The same policy is consulted everywhere a page might be aggregated:

  • Markdown render (SSG phase 9) - evaluates visibility per page; unpublished pages are rendered but excluded from listings/feeds/sitemap and marked noindex.
  • Record collection (lib/collect_records.ts) - a record is dropped when both list and feed are false, so drafts and future-dated posts never reach a paginated listing or a feed.
  • Sitemap and RSS generators - honour the sitemap and feed channels.

See Pagination and SSG Pipeline for where these calls sit.

Project override

resolve_visibility() returns the default policy, optionally adjusted by a project override. The override is injected by the caller (the scripts pass project_hooks.content_visibility), so this framework module never imports project code. An absent override returns the default unchanged.

The override receives the page plus the default decision, and returns the final one:

// src/lib/project_hooks.ts
export const project_hooks: ProjectHooks = {
    content_visibility({ frontmatter, canonical_path, lang, default: base }) {
        // start from `base`, then express project-specific rules:
        if (frontmatter.status === "review") {
            return { ...base, list: false, feed: false, sitemap: false, index: false };
        }
        return base;
    },
};

Use it to express project statuses (draft / review / published), path-based rules, or to decouple channels the default couples (for example, listing a page but keeping it out of feeds). See Project Hooks for the full contract.