@iyulab/canopy
v0.28.4
Published
Publishing renderer: turn a tree of markdown notes into a static website.
Downloads
3,533
Readme
Canopy
Your markdown tree, blooming into a static site on the web.
Canopy is a publishing renderer that turns a tree of markdown notes into a static website. It maps your folder structure into navigation, the links between your notes into hyperlinks, and frontmatter into metadata, producing a deployable site bundle in a single build.
Just as a tree's canopy is the layer seen from the outside, Canopy renders the public face of your note tree.
What it does
- Input — markdown + frontmatter + folder tree
- Output — a static site bundle (HTML · assets · navigation · backlink graph · per-page outline · site shell)
- Stateless build — the same input always yields the same output
- No JavaScript of its own — every page works with none. A caller that wants
script-driven behavior (search, a theme toggle, analytics) supplies its own file via
--script; canopy carries it into the output but never authors or runs it. See docs/SCOPE.md.
Design principles
- App-agnostic. It knows nothing about any specific note app's internal format or hidden config directory. It only accepts the generic contract "markdown + frontmatter + tree", so any tool that wants publishing can use it.
- Read-only, one-way. The source is the user's local files; the published output is a derivative. Canopy never touches the source.
- Self-hostable. Being open source (MIT), you can render it yourself and deploy anywhere (GitHub Pages, Cloudflare Pages, etc.).
Canopy is a shared core with more than one consumer, so the boundary around it is load-bearing. docs/SCOPE.md states what canopy owns, what it deliberately leaves to its callers, and the test for deciding which side a proposed feature falls on — worth reading before opening a feature request. CONTRIBUTING.md covers development; CHANGELOG.md tracks what changed.
Install
npm install @iyulab/canopyCLI
Build a folder of markdown into a static site:
npx canopy build <vault-dir> [out-dir] [options]<vault-dir>— the folder of markdown notes to publish.[out-dir]— where to write the site bundle (defaults to./site). It may lie inside the vault (canopy build . site): the build never reads its own output back in as vault files. The vault itself is refused as the output.--site-title <title>— override the site title (defaults to the vault folder name).--site-description <text>— fill<meta name="description">andog:description, used by link previews and search results. A page whose frontmatter has its owndescription:uses that instead, so pages don't all present one identical summary.--site-url <url>— the absolute URL the site is published at. Every link canopy writes into a page stays relative regardless; this feeds only the<head>tags that are meaningless unless absolute —<link rel="canonical">,og:url,og:image, andhreflangalternates — none of which is written without it. An index page's canonical URL is its directory (guide/index.html→.../guide/), the same rule a sitemap should follow;pageUrl()is exported from the library so a caller writing one can use exactly that string.--site-image <path>— vault-relative image link previews show (og:image), for any page whose frontmatter has noimage:of its own (a site path, or an absolute URL used as given). Needs--site-url. The build fails if the path is missing or excluded.--alternate <lang>=<url>— another language edition of this same site, by itshreflangtag (x-defaultallowed) and that edition's own site URL. Repeatable. Each page then lists its counterpart at the same site path under every edition, its own included. Canopy sees one tree at a time and cannot check the other edition really has that page — the editions keep this true by mirroring each other's structure. Needs--site-url.--feed <dir>— publish an Atom feed of the dated pages beneath a vault folder at<dir>/feed.xml(.for the whole site), newest first, and link it from that folder's pages (<link rel="alternate" type="application/atom+xml">) so browsers and feed readers find it. Repeatable; needs--site-url. Entries are dated pages (see Dated pages), apart from the folder's own index page; a folder with none gets no feed and a warning.--color-scheme <light|dark>— for a site that has only one colour scheme: every page is drawn in it whatever the reader's system prefers (data-themeon<html>, plus<meta name="color-scheme">), and there is no theme toggle. Without it, pages follow the system preference and a script may switch them. See docs/THEMING.md.--lang <tag>— BCP 47 language tag for<html lang>(defaults toen). Worth setting for any non-English vault: assistive technology reads pronunciation rules from it. It changes only what<html lang>declares — the reader chrome's own text (search, theme toggle, nav landmarks) is canopy's UI, not vault content, so it stays English unless--stringsoverrides it.--strings <json>— a JSON object overriding the reader chrome's own text:search,toggleTheme,siteNav,pageNav,onThisPage,indexTitle(the auto-generated contents page's title/heading — and a whole-site stream's written front page, when the layout gives it notitle),backlinks(a page's "linked references" section heading),breadcrumb,language,readingTime(with{n}for the minutes),skipToContent(the link every page opens with, past the header and navigation to the content),newerPostandolderPost(over the links at a stream post's end),pageOf(with{n}and{total}),newerPostsandolderPosts(between the pages of a stream's list),tags(a stream post's tags, and the title of a stream's list of tags),readNextandrelated(over what to read after a page — see What to read next). No built-in translation table — the same reasoning--home-labelalready follows: this text has to be written in the site's own language, and canopy has no way to guess it. Keys left out keep their English default.--site-icon <path>— vault-relative favicon, linked from every page. Linked relatively, so it resolves from a sub-path — where the browser's implicit/favicon.icoguess fails. The build fails if the path is missing or excluded, rather than shipping a broken link.--site-logo <path>— vault-relative logo, shown beside the site title in the top bar. Linked relatively, so it resolves from a sub-path. The build fails if the path is missing or excluded. Decorative: it carries an emptyalt, since the site title beside it already names the site.--home-url <url>/--home-label <text>— a link back to the site this documentation sits beside. Both or neither: link text belongs in the site's own language, so there is no default worth guessing. A scheme (https://...) or protocol-relative URL is used exactly as given; anything else is treated as a path from the site's own root and resolved against each page's depth, the same as every other internal link canopy writes.--site-stylesheet <path>— link a vault-relative stylesheet the site publishes, at its own path, after every other stylesheet. Repeatable. A relativeurl()inside it resolves as written. The build fails if the path is missing or excluded. Canopy's CSS sits in thecanopycascade layer, so these win over it at any specificity — see docs/THEMING.md.--stylesheet <path>— carry a CSS file from outside the vault intoassets/stylesheet-<n>.cssand link it after canopy's own. Repeatable; linked in the order given. A relativeurl()in it resolves fromassets/.--exclude <pattern>— leave part of the vault unpublished. Repeatable. Accepts a directory (draftsordrafts/**, matching it and everything beneath), an extension at any depth (*.tmp), or one exact path (notes/scratch.md). Applies to markdown and assets alike.--nav <path>— a JSON file giving the navigation order and labels (see below). Without it, navigation is derived from the folder structure.--layout <path>— a JSON layout: each folder's profile (manual,stream) and the HTML fragments that fill its regions. See Profiles and regions.--search-index <path>— write a JSON search index (page path, title, headings, body text) to this output-relative path. Also gives the shell a hidden.canopy-searchform in the top bar for a caller's script to find and reveal — the index and its mount point are one feature.--script <path>— carry this file intoassets/script.jsand link it, deferred, from every page. Canopy neither reads nor runs it; the file's behavior is entirely the caller's.--rehype-plugin <specifier>— load a rehype plugin and run it in the render pipeline, after sanitize and before syntax highlighting. Repeatable. Accepts a bare package name (--rehype-plugin rehype-declart, resolved from your ownnode_modules) or a path to a local module (--rehype-plugin ./my-plugin.js), whose default export is used as the plugin. Unlike--script, canopy imports and runs this — the plugin itself decides what it does with the tree; canopy's only contract is when it runs. A plugin meaning to replace a fenced code block's default rendering (a diagram, say) has to claim it before Shiki does; its output survives untouched because it runs after canopy's own HTML sanitizing, the same trust level canopy's own KaTeX and syntax-highlighting output already has.
Canopy writes some files of its own into the output: tokens.css, styles.css, KaTeX's
assets/katex.css and assets/fonts/KaTeX_*, each page's .html, the index page of a stream
folder that has none, and whatever the flags above ask for (assets/stylesheet-<n>.css,
assets/script.js, the search index, each feed.xml). Those paths are reserved: a vault that
publishes a file at one of them fails the build, naming the file and what canopy writes there,
instead of one silently replacing the other. KaTeX's paths are reserved even on a site with no
math yet, so a first formula cannot break a working site. outputCollisions answers the same
question for a caller checking a site before it builds.
Listing what a build publishes
canopy list <vault-dir> [--exclude <pattern>]... [--layout <path>] [--out <dir>] [--json]Prints the vault-relative path of every file build would publish with the same --exclude
patterns, one per line, without building anything — the build's own walk, so a checker or a CI
gate that runs first sees exactly the files that will ship rather than a restatement of the rules.
A place-naming pattern that matched nothing (_archive where the folder is really
docs/_archive) is reported on stderr; an extension pattern such as *.tmp is not, since it is a
standing rule rather than a claim that something is there. Neither is a pattern naming a dot-file,
a dot-folder or node_modules: those are never published anyway, so the pattern is redundant
rather than wrong.
--out <dir> names where the build will write, so an output directory inside the vault is left out
of the listing as the build leaves it out of its input — whether or not it exists yet.
--json prints one object instead: {"pages": [...], "assets": [...], "unusedExcludes": [...]},
where pages are the markdown files build renders and assets everything it copies as-is.
Page names
A page is called by the name it gives itself, in this order:
frontmatter title → the opening h1 → the filenameThat one name reaches every place a page is named: the sidebar entry, the <title>, and the text
of each backlink pointing at it. A document opening with # Order list is called "Order list"
without a title: line, which matters most where filenames are ASCII identifiers and the prose is
not — the alternative is a sidebar in one language and documents in another.
A folder's index page names the folder it opens, since the two are one entry in the sidebar. A
page that names itself nowhere keeps its filename, and an index page that names itself nowhere is
called after what it opens — the site's front, or its folder.
Navigation
By default the sidebar follows the folder structure: the root index page first — it is the
site's front page, so it also opens the prev/next reading order — then folders before pages, each
alphabetical by the name above, folder labels taken from directory names. That suits a vault with
no order of its own.
--nav supplies one where there is. Array order is display order — nothing is re-sorted — and
a label overrides the directory name a URL happens to use:
{
"items": [
{ "label": "Home", "path": "index.md" },
{ "label": "Guide", "items": [
{ "path": "guide/install" },
{ "path": "guide/first-steps" }
]},
{ "label": "Release notes", "path": "releases/index.md", "items": [
{ "path": "releases/2026-08" },
{ "path": "releases/2026-04" }
]}
]
}An item is a page (path) or a group (items); a group may carry both, which makes its label
link to that page. Paths may be written with or without an extension. label is optional and
falls back to the page's own name (see above) — so a spec only has to name what it wants to
override.
A spec does not have to list everything. derive fills a group from a directory the way the
default navigation would — folders first, a folder's index page as its link — with every page
beneath it the spec does not place elsewhere, after any items the group lists itself:
{
"items": [
{ "derive": "guide", "items": [{ "path": "guide/install.md" }] },
{ "label": "Release notes", "derive": "release-notes", "order": "desc" }
],
"unplaced": "append"
}A derived group with no path takes the directory's index page as its link and its name, and
falls back to the directory name. Pages named explicitly anywhere in the spec are placed there
first; derived groups then take what is left, in the order they appear. order ("asc" or
"desc") sorts the derived part by file name in that direction, where the default sorts by
the name a reader sees — desc over dated files is a newest-first log.
Pages the spec places nowhere are left out of the navigation and reported, rather than dropped
silently or appended: whether an omission is deliberate is yours to decide, not canopy's.
"unplaced": "append" decides it the other way — they are placed after the spec's own items,
derived as above. A spec that is malformed fails the build, naming the position.
Markdown files become .html; every other file (images, etc.) is copied alongside, mirroring the
folder layout. Dot-prefixed directories and files (.git, .gitignore, .env, editor and
note-app config, build caches) are skipped, along with node_modules — a folder that is also a
code checkout does not put its secrets on the web. KaTeX styles and fonts are bundled into assets/
so math renders without a network dependency.
Every page also gets prev/next cards (.canopy-page-nav) linking to its neighbors in that same
order — the one a reader already sees in the sidebar, not a re-derived one. Omitted at either end
of the order, and for a single-page site.
Library API
import { build, emitSite } from "@iyulab/canopy";
const bundle = await build({
documents: [
{ path: "index.md", content: "# Home\n\nSee [[notes/idea]]." },
{ path: "notes/idea.md", content: "---\ntitle: Idea\n---\n# Idea" },
],
});
// Semantic bundle: pages (HTML body, frontmatter, backlinks) + navigation tree.
console.log(bundle.navigation);
// Turn the bundle into writable files (full HTML documents + stylesheets).
const files = emitSite(bundle, { siteTitle: "My Notes" });
// -> [{ path: "index.html", contents: "<!doctype html>…" }, …]build() is a pure transform with no filesystem access; emitSite() renders the site shell. The CLI
is the thin IO layer on top — read a folder, build, emitSite, write the files.
The rule canopy uses to decide where a markdown link points is exported as the same pure functions
the renderer calls, so a checker that validates links without rendering gives the renderer's answer
rather than its own: isExternalUrl (left exactly as written), parseLinkUrl (path vs.
query/fragment), decodeLinkPath (percent-decoding, per segment), resolveRelative (..
against the linking page, undefined above the vault root), and resolveMarkdownLink, which
combines them into the published target given a lookup that answers a site path with the page
there in the build's spelling (buildLinkIndex(...).page).
Input / output contract
Canopy is intentionally decoupled from any source app. It accepts only this generic shape:
interface SourceDocument {
path: string; // POSIX path relative to the vault root, e.g. "notes/idea.md"
content: string; // raw markdown, including any leading frontmatter block
}
interface SourceTree {
documents: SourceDocument[];
nav?: NavSpec; // optional order and labels; see Navigation above
rehypePlugins?: PluggableList; // optional rehype plugins; see --rehype-plugin above
}There is no pre-built tree, no app-specific metadata, and no hidden config. Without nav the
hierarchy is derived from the document paths alone; nav supplies an order for document sets that
have one, and says nothing about where that order came from. The build produces:
interface SiteBundle {
pages: RenderedPage[]; // { sourcePath, sitePath, frontmatter, html, backlinks, outline }
navigation: NavNode[]; // from `nav` when given, else derived from the paths
navReport?: AppliedNav; // pages a spec omitted, spec paths that matched nothing
}Each page also carries an outline — its h2/h3 headings with the ids the body already uses,
which the shell renders as an on-this-page contents list. Pages with fewer than two headings get
none, since a one-line contents list says nothing the page does not already show.
Dated pages
A page that names its publication date in frontmatter is treated as an article:
---
date: 2026-09-28 # when it was published — a day, or an ISO 8601 date-time
updated: 2026-10-01 # optional: when it last changed in substance
author: Jane Doe # optional: a person's name
---A file named by its day — 2026-09-28-launch.md, or 2026-09-28.md — is dated that day without
a date: line, the way most blog generators read such names. When both are there, date: wins
(it can carry a time, and re-dating a post should not mean renaming its URL); pageDate,
frontmatterDate and fileNameDate let a checker compare the two. A leading day that does not
exist (2026-02-30-…) is not a date.
- The date appears under the page's
h1as a<time datetime>(.canopy-date), spelled for the site language (--lang) — the day the author wrote, never re-read in another timezone. <head>gainsarticle:published_timeand, fromupdated:,article:modified_time.- A schema.org
Articleblock (<script type="application/ld+json">— data, not a script) gives search engines the headline, description, dates, language and author; itsimageandurlappear when they can be absolute (--site-url). - In a stream folder (
--layout, see docs/THEMING.md) the byline also names theauthor:(.canopy-author), and the page'simage:— the picture link previews show — is its cover under the byline and on the folder's listing (.canopy-cover).
A page that fronts others — a folder's index.md, typically, over a series of dated pages — can
list them with listing: true in its frontmatter: after its own content comes a list of the pages
beneath it in sidebar order (.canopy-listing), each with its name, its date: and its own
description:, so a series' index never has to restate by hand what each entry already says.
On the site's front page, the listing is the rest of the top level.
A folder of dated pages can be followed as a feed with --feed <dir> — each entry carries the
page's name, its date: and updated:, its own description: as the summary, and its author:.
A value that names no real day (2026-02-30, 28/09/2026) is not a date, and the page stays
undated. Canopy reads dates only from frontmatter — it keeps no history, so it never infers one.
frontmatterDate() is exported, so a tool reading the same frontmatter (a sitemap, a
checker) recognizes exactly the dates canopy does. Undated pages are unchanged.
What to read next
A page can name what to read after it — on any page, in any profile, in the order written:
---
readNext:
- install.md # a path, written as a markdown link from this page
- "[[configuration]]" # or a wikilink, quoted
---They close the article as a short list (.canopy-read-next), each entry with its name, date and
own description:, under "Read next". Every one named is shown. A value that names no page of
the site is left out; readNextProblems names each one, for a checker to report.
A stream's post (--layout, see docs/THEMING.md) always has the list,
filled to three: what its author named, then the folder's featured posts, then the posts most
like it, then the stream's newest. A post is like another by the tags they share — each adding
ln(N / df), so a tag few posts carry counts for more and one on every post adds nothing — and
by a link from either to the other (adding 1); ties go to the post published nearer. With nothing named or featured, the list is
titled "Related posts". pickReadNext, relatedPosts and resolveReadNext are exported, and
readNextPlanner answers every page of a site from one reading of its streams.
Markdown support
- CommonMark + GitHub Flavored Markdown (tables, strikethrough, task lists, autolinks)
- Math with KaTeX (
$inline$and$$display$$) — see the deliberate deviations below - Syntax highlighting with Shiki (light/dark dual theme via
prefers-color-scheme). Grammars load on demand, so a build pays only for the languages its notes actually use. An unlabelled fence, and a fence naming a language canopy cannot resolve, both render as themed plain-text code blocks — same background and font as every other fence, no syntax coloring — rather than an unstyled block that reads as a different kind of element. A typo in a fence never fails a build. - Headings get an id derived from their own text (
## Some Title→some-title), unless one is given explicitly with a trailing{#id}(## Some Title {#stable-id}) — for a heading whose wording is expected to change but whose fragment (used by[[note#heading]], or linked from outside the vault) should not. - Wikilinks:
[[note]],[[note|alias]],[[note#heading]]— resolved tree-wide to relative links, with a backlink graph. Unresolved links degrade to plain text. - Markdown links to other notes (
[text](note.md), including the reference-style[text][id]form) resolve the same way, relative to the linking document, and count in the backlink graph. Both match a page ignoring letter case and are written as the page is spelled ([x](Guide/Install.md)→guide/install.html), so a host that tells case apart still serves them. A folder link ([x](guide/)) reaches the folder's index page the same way and is written as it (guide/index.html); a folder without one is left as written. Files that are not pages (images, PDFs) keep the link's own spelling. Absolute URLs, root-absolute paths (/help/x.png), bare fragments, and targets that were not published are left exactly as written. - Raw HTML is sanitized: safe authoring tags survive, scripts and injection vectors are stripped.
Math: a conservative subset of remark-math
Canopy narrows vanilla remark-math in two deliberate ways, so prose about money never silently becomes a formula and display intent survives publishing:
- Currency-safe inline math. A
$..$span is not math when the opening$is followed by whitespace, the closing$is preceded by whitespace, or the closing$is immediately followed by a digit. Vanilla remark-math renderscosts $5 and $10 totalwith "5 and " as a formula; canopy keeps it literal text. Escaping follows backslash-run parity (\$is literal,\\$x$is a backslash then math). - Standalone
$$..$$lines are display math. remark-math only treats the fenced$$…$$(delimiters on their own lines) form as display and reads a single$$x+y$$line as inline math. Canopy promotes a paragraph consisting solely of standalone$$..$$lines to display blocks — matching what the author meant and how conservative line-based editors render it. A$$..$$mixed into a sentence stays inline.
The exact tokenization (including known edge divergences from line-based editor scanners) is pinned
by src/math-parity.golden.json; downstream editors keep a byte-identical copy and assert the
editor column against their scanner, so both sides of the parity move only on purpose.
Callouts
Top-level blockquotes that open with the > [!type] convention render as
styled callouts:
> [!tip] Optional title
> Body in regular markdown.Five core styles ship: note, tip, warning, danger, quote. Common
aliases map onto them (info → note, error → danger, …) and unknown types
fall back to note, so nothing breaks. The displayed title is the text after
the marker, or the typed word itself. Fold suffixes ([!type]- / [!type]+)
are accepted and ignored — content is always visible. Nested blockquotes stay
plain quotes. Recognition semantics are pinned in
src/callout-parity.golden.json; downstream editors keep a byte-identical
copy to stay aligned.
Profiles and regions
A layout is a site default plus per-folder rules; the longest folder containing a page decides for it.
{
"default": { "regions": { "head": "partials/head.html", "header": "partials/header.html" } },
"dirs": {
"blog": { "profile": "stream", "title": "Blog", "regions": { "afterArticle": "partials/cta.html" } }
}
}profile—manual(the default: a tree, an outline, backlinks) orstream(dated pages, newest first, one column, the folder's index listing them — written by canopy when the folder has none).title— the title of that written index page.pageSize— on astreamrule, how many posts the folder's index lists (default 10); the rest continue on<folder>/page/2.html,page/3.html…, each linking to the pages beside it. A tag's page is read the same way, continuing on<folder>/tags/<slug>/page/2.html….featured— on astreamrule, posts (vault paths,.md) the folder puts first, in this order: atop the first page of its list (.canopy-featured) and out of the dated pages after it, and after a post's ownreadNext:in what to read next. An entry that is not a post of that folder fails the build (featuredProblemsnames it).regions—head,header,beforeArticle,afterArticle,footer, each a vault path of an HTML fragment, or""to turn off one a shorter folder set. Fragments are read, not published.headerandfooterreplace canopy's own with the fragment's markup; canopy's controls go where its<canopy-slot>elements say.
canopy list --layout <path> --json answers with the build's view: fragments left out, and
generated naming the index pages, the later pages of a stream's list, and a stream's tag pages
the build will write. Slots, hooks and what each profile
draws: docs/THEMING.md.
Theming
Canopy's CSS sits in one cascade layer, canopy, so a caller's stylesheet —
--site-stylesheet/--stylesheet on the command line, emitSite(bundle, { siteStylesheets,
styles }) in code — wins over it at any specificity. The
shell reads a small set of CSS custom properties (see CANOPY_TOKENS), and a set of class names
is a stable contract (THEME_HOOKS). The full contract — layers, tokens, hooks, and what is not
part of it — is docs/THEMING.md.
A caller may restate the whole token vocabulary to match an app exactly, or override a single
value and keep the rest. A bare :root outside canopy's layer applies to both colour
schemes, so a dark value needs its own block — stated for the system preference and for an
explicit data-theme, the way canopy states its own:
:root { --accent: #0a7c5a; --accent-hover: #096a4d; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --accent: #4ecfa2; --accent-hover: #6fdcb5; }
}
:root[data-theme="dark"] { --accent: #4ecfa2; --accent-hover: #6fdcb5; }A custom property canopy doesn't read is not an error — it's silently ignored. There is no
warning if a stylesheet sets a variable the shell never looks at; the page just keeps canopy's
default for that value, and the build still exits 0. This is the trap moving an existing docs
site onto canopy tends to spring: a stylesheet carried over from a previous tool's own token
vocabulary (a VitePress --vp-c-* set, a Docusaurus --ifm-* set, .dark/.light class
toggles instead of an attribute) looks like it should work, and produces a page that renders
fine — just with none of the intended colors, because nothing in it actually matched. Check the
rendered page (inspect a computed --accent, or just look for the color you set), not only that
the build succeeded. Dark mode is an attribute, not a class: canopy toggles [data-theme="dark"]
on <html> (see --script, above), never adds a class to anything.
Self-hosting
The output of canopy build is a plain static site — host it anywhere:
GitHub Pages
npx canopy build ./my-vault ./site
# commit ./site to a `gh-pages` branch, or point Pages at itFor project pages served from /<repo>/, no extra config is needed: every internal link is relative,
so the site works from any sub-path.
Cloudflare Pages / Netlify / any static host
Point the host at the build output directory (./site). No server, no database, no build step beyond
canopy build.
Development
npm install
npm run check # type-check (tsc)
npm run lint # Biome
npm test # vitest
npm run build # emit dist/License
MIT
Canopy — the public face of your notes.
