Skip to content
canopy-pagecanopy-page on GitHub

The settings file

A site is configured by one file, settings.json, in the folder that holds its markdown. Every field is an override, so {} is a valid settings file: a folder of markdown builds with its navigation derived from the folder tree. Settings exist for what a tree cannot say by itself — the order of a release log, a label that is not a directory name, a draft folder that stays unpublished.

This is the file that produced the site you are reading:

{
  "$schema": "https://iyulab.github.io/canopy-page/settings.schema.json",
  "title": "canopy-page",
  "description": "Documentation for canopy-page: one settings file, integrity checks, and a build — built with canopy-page itself, published on every push.",
  "lang": "en",
  "icon": "assets/logo.svg",
  "logo": "assets/logo.svg",
  "styles": "brand.css",
  "home": { "url": "https://github.com/iyulab/canopy-page", "label": "canopy-page on GitHub" },
  "siteUrl": "https://iyulab.github.io/canopy-page",
  "exclude": ["_drafts"],
  "rehypePlugins": ["rehype-declart", "rehype-mermaid"],
  "sections": [
    {
      "path": "guide",
      "items": [
        "guide/install",
        {
          "path": "guide/writing/index",
          "items": ["guide/writing/code-and-math", "guide/writing/diagrams"]
        },
        "guide/reading",
        { "path": "guide/localizing", "items": ["guide/한국어-예시/index"] },
        "guide/host-site",
        "guide/deploying"
      ]
    },
    { "path": "reference", "label": "Reference" },
    { "path": "release-notes", "order": "desc", "feed": true },
    {
      "path": "showcase/host-blog",
      "label": "Showcase: a blog in a host site",
      "profile": "stream",
      "pageSize": 2,
      "featured": ["showcase/host-blog/2026-09-20-capture"],
      "regions": {
        "head": "showcase/host-blog/_host/head.html",
        "header": "showcase/host-blog/_host/header.html",
        "afterArticle": "showcase/host-blog/_host/cta.html",
        "footer": "showcase/host-blog/_host/footer.html"
      }
    }
  ]
}

Validation is strict: an unknown key is rejected rather than ignored, because a mistyped key that is quietly dropped looks like a tool disobeying its configuration. Every message names the file and the position it is about, down to sections[0].items[1]. Every path in the file is relative to the file itself, written with forward slashes, and has to stay inside the site.

The settings file itself is never published, and neither is anything exclude names. A file named settings.json deeper in the site is content, and ships.

Top-level fields

FieldMeaning
$schemaOptional. Points an editor (VS Code, JetBrains, any editor with JSON Schema support) at settings.schema.json, for completion and inline validation as you type. canopy-page itself reads and ignores it
titleSite name, shown in the top bar and the browser tab. Defaults to the site folder's own name
descriptionFills <meta name="description"> — what a link preview shows — for every page with no description: of its own
langA BCP 47 tag for <html lang> ("en", "ko-KR"). Changes that one attribute and nothing else — see Publishing a non-English site
colorScheme"dark" or "light", for a site that has only one colour scheme: every page is drawn in it whatever the reader's system prefers, and there is no theme toggle — see Dark mode
stringsThe reader-facing text canopy-page writes around your content, in your language — see Strings
iconFavicon, relative to the settings file. Must be a published file
logoImage shown beside the site title, relative to the settings file. Must be a published file — see Branding
stylesOne CSS file or a list, relative to the settings file, linked after everything canopy-page writes — see Branding
homeA link back to the site this documentation sits beside — see home
siteUrlAbsolute http(s) URL the built site will stand at. Turns on everything that has to be absolute — see Where the site stands
previewImageImage link previews show (og:image) for any page with no image: of its own, relative to the settings file. Must be a published file. Needs siteUrl
alternatesThe site's other language editions: { "<hreflang>": "<that edition's siteUrl>" }, "x-default" allowed. Needs siteUrl — see Where the site stands
excludePaths to leave unpublished — see What exclude takes
rehypePluginsInstalled npm package names of rehype plugins to run on every page — see Extending what a page can render
profile"manual" (the default: a tree to browse) or "stream" (dated pages, newest first), for the whole site; each section can choose its own. See Profiles
pageSizeWith "profile": "stream", how many posts the front page lists; the rest continue on page/2.html, page/3.html …. Defaults to 10
featuredWith "profile": "stream", posts the front page puts first — like a section's featured
regionsHTML fragments that fill the parts of a page around the article — see Regions
sectionsOrdered regions of the site — see Sections
knownBrokenPages whose broken links and images are known and being fixed — see knownBroken

Sections

sections name ordered regions within one site — a guide, a release log — rather than separate builds:

FieldMeaning
pathThe directory this section covers, relative to the settings file. Matched ignoring letter case; what the build writes follows the directory's own spelling. A directory is one section: naming it twice (in any letter case) is an error
labelHeading shown for the section. Defaults to the name the section's own index page gives itself (its title:, else its opening heading), then the directory name — which check warns about
order"asc" or "desc" for the pages inside. "desc" is what a release log wants — newest first
itemsExplicit contents, in display order — see Items. Cannot be combined with order: a list is an order
feedtrue publishes an Atom feed of the section's dated pages at <path>/feed.xml — see Feeds. Needs siteUrl
profile"manual" or "stream" for this section; the site's profile applies where it is not set. A stream section takes neither order nor items
pageSizeWith this section's own "profile": "stream", how many posts its index lists; the rest continue on <path>/page/2.html on. Defaults to 10
featuredWith this section's own "profile": "stream", posts it puts first, in this order — relative to the settings file, with or without .md. They stand atop the first page of its list, out of the dated pages, and in what to read next after each post. An entry that is not a post of the section is an error
regionsOverrides the site's regions key by key; "" turns one off in this section

Note what this site's settings do not contain: a label for guide or for release-notes. Those sections have index pages, and a page that opens with a heading has already said what it is called. Reference is labelled because this section has no index page of its own to ask.

Ordering

release-notes uses "order": "desc", which is why the newest notes come first. Ordering derived this way follows filenames, not headings — filenames are what you see in the folder you are ordering, and a log of dated files is exactly the case it serves.

Whatever a section does not list — all of it, when the section has no items — is filled in from the section's directory, the way any folder is: subfolders first, a folder's index page as its heading link, ordered by file name, ascending unless order says "desc". With no sections at all, the whole navigation is derived from the folder tree this way.

A stream section takes neither order nor items: it is ordered newest first by each page's date:, with undated pages last — and check names them.

Items

guide uses items instead, which is a list and therefore already an order. An entry in items is a page path ("guide/install"), or a group:

FieldMeaning
pathA page — the group's own heading links it. Written out in full, so an index page is "guide/writing/index"; a group's path names a page, not a directory
labelThe group's heading. Required when the group has no path; otherwise it overrides the name that page gives itself
itemsThe pages (or further groups) beneath it, in display order
{ "label": "Orders", "items": ["guide/orders/list", "guide/orders/detail"] }

Paths may be written with or without their .md extension. Two glob shapes are understood: dir/* is the pages directly in a directory, dir/** is every page beneath it. A glob means the pages there that are not placed already, which is what makes ["guide/install", "guide/*"] read the way it looks — this page first, then the rest.

A section's heading already links its own index page, so naming that page in items asks for what is there rather than for a second copy, and is not counted as placing it twice. A page placed twice otherwise, or a path that matches no page, is an error. A page no section reaches is placed anyway and reported — see What check reports.

Feeds

release-notes also sets "feed": true, so this site publishes release-notes/feed.xml — an Atom feed a reader can subscribe to — and every page in that section links it (<link rel="alternate" type="application/atom+xml">) for browsers and feed readers to find.

An entry is a page beneath the section whose frontmatter names a date: (see Dated pages), newest first: its name, its date: and updated:, its own description: as the summary, and its author: if it has one. The section's index page describes the series rather than being an entry, so it is left out. A feed's links are absolute, which is why feed needs siteUrl; a section with no dated page publishes no feed. A feed is independent of order.

A stream section has a feed by default once siteUrl is set; "feed": false turns it off.

check warns about a page in a feed section that has no date: — it is silently missing from the feed otherwise — and about any date: or updated: that is not a date.

What exclude takes

A directory ("_drafts" or "_drafts/**"), an extension at any depth ("*.tmp"), or one exact path ("notes/scratch.md"). A shape outside that list — "images/*.md" — is refused rather than left to match nothing quietly. A pattern that is valid but matched nothing is a warning, except an extension pattern: *.tmp in a site with no scratch files is a rule about what may never ship rather than a claim that something is there. Nor is a pattern naming a dot-file, a dot-folder or something under node_modules: those are never published anyway, so the entry is redundant but harmless — keep it as a guard if you like.

This site excludes _drafts, and the page inside it is not in the sidebar, not in the output, and not reachable. Its broken link is not reported either: an excluded page is not part of the site.

Strings

lang changes only what <html lang> declares. Every word canopy-page writes around your content is its own interface text, not your content, so it stays English until strings says otherwise:

{ "lang": "ko-KR", "strings": { "search": "검색", "toggleTheme": "테마 전환" } }

Every key is optional, and keeps its English default when left out:

KeyEnglish defaultWhere it appears
searchSearchThe search box's placeholder and accessible label
toggleThemeToggle color themeThe dark/light toggle's accessible label
siteNavSite navigationThe sidebar's accessible label
pageNavPage navigationThe prev/next cards' accessible label
onThisPageOn this pageThe on-page outline's heading and accessible label
indexTitleContentsTitle and heading of the contents page written at the site root when it has no index page (a whole-site stream without a title names its front page this way too)
backlinksLinked referencesThe heading above a page's backlinks
breadcrumbBreadcrumbThe top bar's trail's accessible label
searchFailedSearch failed to load.Shown in the results list when the search index fails to load
readingTime{n} min readA stream page's reading time under its heading. {n}, where the minutes go, is required
languageLanguagesThe language links' accessible label
skipToContentSkip to contentThe link every page opens with for keyboard readers, past the header and navigation
newerPostNewer postOver the link at a stream post's end to the post published after it
olderPostOlder postOver the link at a stream post's end to the post published before it
pageOfPage {n} of {total}Where a page of a stream's list is. {n}, its number, is required; {total}, how many, is optional
newerPostsNewer postsThe link to the page of a stream's list with newer posts
olderPostsOlder postsThe link to the page of a stream's list with older posts
tagsTagsA stream post's tags' label, and the title of a stream's list of tags
readNextRead nextOver what to read next, when a page's readNext: or its section's featured chose any of it
relatedRelated postsOver what to read next after a stream post, when all of it was found by shared tags and links

There is no built-in translation table — canopy-page has no way to guess what your language calls "Search". Publishing a non-English site has a complete set for Korean.

Branding

FieldNot set
stylesOnly canopy-page's own look applies
logoThe sidebar header shows the title text alone
homeNo link back to a surrounding site is rendered

styles names CSS files linked after canopy-page's own, outside its cascade layers, so a file naming one custom property — --accent, say — keeps every other default, and a rule restyling a part of the page wins without a specificity contest. Like icon and logo, each file is published where it stands and every page links it there, so a relative url() inside it — a font beside it, say — resolves exactly as written. A missing or excluded one is a check error. This site's own brand.css is one: open the built output and it is at the site's root, linked from every page. Theming is the full contract: the tokens, dark mode, and the class names that are safe to select on.

logo is separate from icon: icon is the favicon a browser tab shows, logo is the image beside the title in the sidebar itself, and the two are free to differ. This site happens to use the same file for both. It renders with an empty alt, deliberately: the site title right beside it already names the site, so there is no separate text for a screen reader to add.

home

A link back to the site this documentation sits beside, in the top bar:

{ "home": { "url": "https://example.com", "label": "Back to Example" } }
FieldMeaning
urlAn absolute URL when the target is another site. A relative path ("../") when it is a sibling of the published site — each page resolves it against its own depth, like any other internal link
labelThe link text. No default: link text has to be written in the site's own language, and canopy-page has no way to know what that is

Both or neither: a settings file with only one is rejected rather than built with a guess at the other. A url that leaves the site gets an icon after its label, so a reader knows before clicking — see What a reader gets.

Regions

regions fills the parts of a page around the article — head, header, beforeArticle, afterArticle, footer — with HTML fragments from the site, on the site as a whole or per section, where a key overrides the site's and "" turns it off:

{ "regions": { "header": "partials/header.html", "footer": "partials/footer.html" } }

A fragment is a file in the site, read by the build and not published itself — one file, so a path with *, ?, [ or ] in it is refused rather than read as a pattern. Where each region goes, how links in a fragment are written, and the <canopy-slot> elements that place canopy-page's own controls inside one are in Theming. Hosting a blog in your own site walks through one end to end.

Where the site stands

siteUrl exists for nothing except what a relative-link site cannot say about itself: the things that have to be absolute. Every link canopy writes into a page is relative, which is what lets the same output be served from any sub-path and opened from a local folder — and exactly why some things need an absolute address given separately. Without siteUrl none of them is written; with it:

  • build writes sitemap.xml and a robots.txt pointing at it — or keeps the site's own robots.txt, if it publishes one;
  • every page's <head> gains <link rel="canonical">, og:url, og:image (previewImage, or the page's own image:) and the hreflang links alternates names;
  • a feed section can publish its feed, and a stream section does by default;
  • check starts warning about pages with no description: of their own, since setting it says the site is meant to be found — every page of this site has one, which is why its build log doesn't;
  • a path in it says where root-absolute links point — see Root-absolute references.

A directory's index page is addressed as the directory (guide/index.html → …/guide/), in the sitemap and in the page's own canonical alike, so both name a page by the same string. This site's own are built from https://iyulab.github.io/canopy-page, the address it is actually published at.

Each sitemap entry carries <lastmod> when one can be trusted: the page's frontmatter updated: if it names one, otherwise its source file's last git commit date. A page with no source file, an untracked one, or any page at all on a shallow clone is written without it — no <lastmod> beats a guessed one.

Two more fields lean on it. previewImage names the image link previews show for any page whose frontmatter has no image: (a page's image: is a site path, or an absolute URL used as given). alternates maps hreflang tags (or x-default) to the site URLs of this site's other language editions, and every page then lists its counterpart at the same path under each of them, its own first — in <head> and as xhtml:link entries in the sitemap. That is a declaration the build cannot verify, since it sees one edition at a time: the editions keep it true by mirroring each other's structure. Both fields are rejected without siteUrl. Neither is set here: this site has one edition and no cover image.

Extending what a page can render

rehypePlugins names installed packages, not files:

{ "rehypePlugins": ["rehype-declart"] }

Each one runs on every page, after canopy's own HTML sanitizing and before syntax highlighting — a fixed position, so a plugin claiming a fenced code block by its language always sees it before Shiki would otherwise render that fence as plain highlighted text. A site names the package it depends on; the plugin itself is an ordinary dependency, installed the same way any other one is. See Diagrams for what this looks like end to end.

A relative path (./plugins/mine.js) is refused, for the same reason every other path here is resolved against the settings file rather than left to the shell that happened to start the build: a plugin loaded by canopy's own process would otherwise resolve against whatever directory the build was run from, and get it right by accident or not at all.

knownBroken

The pages whose broken links and images are known and being fixed, so the rest of the site can ship meanwhile:

{ "knownBroken": [{ "path": "help/statistics/kpi/**", "reason": "Screenshots being retaken" }] }
FieldMeaning
pathOne page, "dir/*" for the pages directly in a directory, or "dir/**" for every page beneath it
reasonRequired: why these pages are let through. Printed with every warning the entry produces

What it changes, and how the list is kept shrinking, is in Exit codes.

One settings file is one site

sections name ordered regions within a single site built in one pass — not separate builds. That is what lets a link from the guide into the release notes resolve, and lets the release note know it is linked from the guide. Two genuinely independent sites are two settings files.

An editor-readable description of the whole file is published at settings.schema.json; point $schema at its address, https://iyulab.github.io/canopy-page/settings.schema.json.