npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/canopy

CLI

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"> and og:description, used by link previews and search results. A page whose frontmatter has its own description: 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, and hreflang alternates — 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 no image: 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 its hreflang tag (x-default allowed) 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-theme on <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 to en). 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 --strings overrides 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 no title), 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), newerPost and olderPost (over the links at a stream post's end), pageOf (with {n} and {total}), newerPosts and olderPosts (between the pages of a stream's list), tags (a stream post's tags, and the title of a stream's list of tags), readNext and related (over what to read after a page — see What to read next). No built-in translation table — the same reasoning --home-label already 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.ico guess 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 empty alt, 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 relative url() inside it resolves as written. The build fails if the path is missing or excluded. Canopy's CSS sits in the canopy cascade layer, so these win over it at any specificity — see docs/THEMING.md.
  • --stylesheet <path> — carry a CSS file from outside the vault into assets/stylesheet-<n>.css and link it after canopy's own. Repeatable; linked in the order given. A relative url() in it resolves from assets/.
  • --exclude <pattern> — leave part of the vault unpublished. Repeatable. Accepts a directory (drafts or drafts/**, 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-search form 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 into assets/script.js and 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 own node_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 filename

That 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 h1 as a <time datetime> (.canopy-date), spelled for the site language (--lang) — the day the author wrote, never re-read in another timezone.
  • <head> gains article:published_time and, from updated:, article:modified_time.
  • A schema.org Article block (<script type="application/ld+json"> — data, not a script) gives search engines the headline, description, dates, language and author; its image and url appear when they can be absolute (--site-url).
  • In a stream folder (--layout, see docs/THEMING.md) the byline also names the author: (.canopy-author), and the page's image: — 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 renders costs $5 and $10 total with "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) or stream (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 a stream rule, 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 a stream rule, 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 own readNext: in what to read next. An entry that is not a post of that folder fails the build (featuredProblems names 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. header and footer replace 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 it

For 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.