# @spfn/pages
> **Push markdown to a GitHub repo, and that is the deploy**
Serve a website from a public GitHub repo. The repo opts in with a root-level
`spfn.site.yaml`, keeps AI-authored markdown content under a content root
(default `site/`), and this package turns it into a validated, sanitized,
render-ready site model. No build pipeline on the author's side — push is deploy.
This is the first source driver of SPFN's **frontend serving primitive**: the
tenant edge (registration, `*.spfn.app` subdomains, custom domains, caching) is
shared infrastructure; content sources plug in via the `ContentSource` interface.
## Installation
```bash
pnpm add @spfn/pages
```
## The site spec
A publishable repo looks like this:
```
spfn.site.yaml # REQUIRED at repo root — opt-in proof + site meta + content root
site/ # content root (config `root`, default 'site'; existing repos keep their structure)
AGENTS.md # instructions for the authoring AI (the spec's real interface)
pages/
index.md # '/' — default layout: landing
about.md # '/about' — default layout: doc
docs/intro.md # '/docs/intro'
playground.html # '/playground' — escape hatch: full document served verbatim
posts/
2026-07-21-launch.md # '/posts/launch' — default layout: post, sorted by date desc
theme/
tokens.json # design tokens → CSS variables (--sf-*)
custom.css # free-form CSS layered after the variables
public/ # static assets
favicon.svg # well-known: auto-detected favicon (also .png/.ico/.jpg/.jpeg, in that order after svg)
og.png # well-known: site-wide OG image default (also .jpg/.jpeg/.webp)
```
Well-known `public/` assets surface on the loaded site as `site.favicon` /
`site.ogImage` (served URLs, e.g. `/favicon.svg`) — renderers emit the
`` and default `og:image` from them. SVG/PNG favicons are
natively supported by browsers, so no `.ico` conversion is ever needed. A page's
frontmatter `og:` overrides the site-wide OG image.
### `spfn.site.yaml`
The presence of this file is the repo owner's consent to publish — registration
must reject repos without it (prevents serving third-party repos under a
phishing subdomain).
```yaml
name: Superfunction # required
description: ... # optional
root: site # optional, default 'site' ('.' = repo root)
url: https://example.com # optional — canonical origin; makes og:image URLs absolute
repo: https://github.com/spfn/spfn # optional — canonical repo; non-doc links resolve here
locale: ko # optional
nav: # optional
- label: Docs
path: /docs
social: # optional — lowercase keys; renderers display brand
github: https://github.com/spfn/spfn # casing via socialLabel() ('github' → 'GitHub')
footerNote: © 2026 FXY Inc. # optional — short footer line (copyright etc.)
analytics: # optional — opt-in visitor analytics, YOUR keys
posthog:
key: phc_... # public client key (ships in every page by design)
host: https://us.i.posthog.com # optional, this is the default
mounts: # optional — repo docs served as site routes
- source: packages/core/README.md # a .md file, or a directory
route: /packages/core
title: "@spfn/core" # optional title override
```
### Mounts — repo docs served on-site
Mounts pull documentation that lives with the code (READMEs, guide trees)
into the site, so docs are consumed on-site instead of on GitHub. A `.md`
source serves that one file at `route`; a directory source serves every `.md`
beneath it at `route/` (README/index files collapse onto their
directory). Mounted docs are parsed leniently: frontmatter optional, title
falls back to the first `#` heading (stripped from the body), then the file
name. Loaded docs surface as `site.mounted`, routable like pages.
Link resolution inside any rendered markdown, in order: a relative link to a
served markdown file (authored, referenced, or mounted) → its route; a `public/`
asset → its served URL; any other existing repo file → `/blob/main/`
(images use `/raw/`), which is why mounts want `repo` set — without it those
links stay as written and a problem is reported. Conflicts (a mount route
already served by a page, post, or earlier mount) skip the file and land in
`problems`.
### Source pages — curated references
The curated alternative to a directory mount: a page whose frontmatter has
`source: .md` serves that repo doc's content at the page's own
route. The page file's location picks the URL — `pages/packages/core/db.md`
referencing `packages/core/src/db/README.md` serves at `/packages/core/db`,
with no `src/` leaking into the URL the way a directory mount would.
- The page body is an **optional preface**, rendered before the referenced doc.
- Links inside the referenced content resolve from the *referenced file's* repo
location; links in the preface resolve from the page's.
- The page's frontmatter governs `title`/`description`/`layout`/`draft`; the
referenced doc is parsed leniently like a mount (its first `#` heading is
stripped — the layout renders the page title).
- Relative links anywhere on the site to the referenced repo file rewrite to
the curated page's route (a source page's claim beats a mount's for the same
file; the first page to reference a file wins).
- A `source` that is missing from the repo or not a `.md` file is a per-file
problem: reported, page skipped.
### Frontmatter (minimal by design)
```yaml
title: Intro # required
description: ... # optional
layout: doc # optional — landing | doc | post (defaults by location)
date: 2026-07-21 # YYYY-MM-DD; posts convention
draft: true # optional — excluded from the loaded site
og: cover.png # optional — OG image path inside public/
order: 1 # optional — sidebar position among siblings (unordered pages sort after, by title)
navTitle: db # optional — short sidebar label when title is too long for navigation
source: packages/core/src/db/README.md # optional — repo doc served at this page's route
```
### Markdown rendering
Markdown bodies are rendered with GFM and **sanitized** (script tags, iframes,
event handlers stripped) — published repos are untrusted input. On top of that
the pipeline produces, in order:
- **Repo-aware links** — relative references are rewritten to served routes:
`./about.md` → `/about`, `../../posts/2026-07-21-launch.md` → `/posts/launch`,
`../public/img/cover.png` → `/img/cover.png`. Absolute URLs, site-absolute
paths, and `#anchors` pass through untouched; authors link files the way the
repo is laid out and the site just works.
- **Heading anchor ids** — GitHub-style slugs (`## Getting Started` →
`id="getting-started"`), added after sanitization and without a clobber prefix
so plain `#getting-started` links work with no client JS.
- **Code highlighting** — shiki dual-theme output (`github-light` inline,
`github-dark` via `--shiki-*` CSS variables; the flip is included in
`site.themeCss`). Unknown languages fall back to plain text.
### HTML pages (the escape hatch)
`pages/*.html` files are served **verbatim as full standalone documents** — no
layout wrapping, no sanitization, scripts included; the author owns the content.
Slug rules match markdown pages (`pages/playground.html` → `/playground`); a slug
already taken by a markdown page or post is a per-file conflict (the HTML file is
skipped and reported). The title comes from the document's `` tag. Theme
tokens are opt-in: link the site's theme stylesheet if you want them.
Because HTML pages run author scripts, tenant isolation (serving each site on
its own registrable subdomain — Public Suffix List registration for the hosted
edge) is a prerequisite before opening registration to third parties.
## Usage
```typescript
import { GithubContentSource, loadSite, validateSite } from '@spfn/pages/server';
const source = new GithubContentSource('https://github.com/owner/repo');
// Registration-time check: [] means publishable, entries are actionable feedback
const problems = await validateSite(source);
// Serving: full site model
const site = await loadSite(source);
site.config; // SiteConfig (name, nav, ...)
site.pages; // PageDoc[] — slug, frontmatter, sanitized html
site.posts; // PageDoc[] — newest first
site.htmlPages; // HtmlPage[] — raw full documents, served verbatim
site.sections; // NavNode[] — per-section nav trees derived from doc slugs (sidebar source)
site.themeCss; // code-theme flip + :root { --sf-* } variables + custom.css, ready to inline
site.problems; // per-file failures (file skipped, site still loads)
```
- `GithubContentSource` fetches the tree via the GitHub API and files via
raw.githubusercontent.com, both with ETag conditional requests (304s don't
count against the API rate limit). Pass `{ token }` to raise limits.
- **SHA pinning (hosted serving):** `source.resolveHeadSha()` returns the commit
the bound ref points to (ETag-revalidated — cheap to poll), and
`source.atRef(sha)` (or `{ ref }` in the constructor) pins every read to that
commit. Content at a SHA is immutable, so anything cached under
`repo@sha` stays valid regardless of later pushes — cache invalidation
reduces to "what is HEAD now".
- Every source also serves raw bytes via `getBinary(path)` (images, fonts —
`public/` assets in hosted mode).
- `MemoryContentSource` backs tests and local previews (values may be strings
or `Uint8Array`s).
- Per-file failures never take the site down — they land in `problems` and the
file is skipped. Only a missing/invalid `spfn.site.yaml` is fatal.
## Exports
- `@spfn/pages` — types, TypeBox schemas (`SiteConfigSchema`, `FrontmatterSchema`), errors
- `@spfn/pages/server` — `loadSite`, `validateSite`, `renderMarkdown`,
`rehypeRewriteRefs`, `parseSiteConfig`, `parseDocument`, `tokensToCss`,
`buildThemeCss`, `GithubContentSource`, `parseGithubUrl`,
`MemoryContentSource`, `ContentSource`
## Status / roadmap
Beta. This package is the renderer core. The Next.js integration (layouts,
catch-all route helpers, static export + sync CLI) lives in
[`@spfn/pages-next`](../pages-next/README.md); self-deploy targets are Vercel,
Cloudflare Pages, and any static host. Coming next: the site template repo
(with `site/AGENTS.md` authoring contract), then the hosted tenant edge
(registration, `*.spfn.app` subdomains, custom domains).