Static site generator powered by MDX and IIIF. The stable app entry at app/scripts/canopy-build.mjs orchestrates UI assets and calls the library to build from content/ into site/.
- Install:
npm install - Develop:
npm run dev(serveshttp://localhost:5001) - Build:
npm run build
Entrypoint details
- Both commands run
node app/scripts/canopy-build.mjs. - Dev mode starts the UI watcher (
@canopy-iiif/app→ui:watch) and the dev server from@canopy-iiif/app. - Build mode builds the UI once, then runs the site build from
@canopy-iiif/app.
content/
_layout.mdx # optional: site-wide layout wrapper
_styles.css # optional: site-wide CSS copied to site/styles.css
index.mdx # homepage → site/index.html
sitemap.mdx # sitemap page (receives props.pages) → site/sitemap.html
docs/
getting-started.mdx
guide.mdx
works/
_layout.mdx # layout for IIIF manifests (receives props.manifest)
Build output goes to site/. Development cache lives in .cache/:
.cache/mdx: transient compiled MDX modules used to render MDX/JSX..cache/iiif: IIIF cache used by the builder:index.json: primary index storingbyId(Collection/Manifest ids to slugs) andcollectionmetadata (uri, hash, updatedAt).manifests/{slug}.json: cached, normalized Manifest JSON per work page.- Legacy files like
manifest-index.jsonmay be removed as part of migrations. - Clear this cache by deleting
.cache/iiif/if you need a fresh fetch.
Place static files under assets/ and they will be copied to the site root, preserving subpaths. For example:
assets/images/example.jpg→site/images/example.jpgassets/downloads/file.pdf→site/downloads/file.pdf
- Run
npm run devto start a local server athttp://localhost:5001with live reload. - Editing MDX under
content/triggers a site rebuild and automatic browser reload. - Editing files under
assets/copies only the changed files intosite/(no full rebuild) and reloads the browser.
Canopy ships a small Tailwind preset and plugin so you can opt into sensible defaults with semantic CSS, or disable them entirely.
- Preset (tokens + plugin):
@canopy-iiif/app/ui/canopy-iiif-preset - Plugin (component CSS only):
@canopy-iiif/app/ui/canopy-iiif-plugin
Defaults (recommended): Canopy now ships a Tailwind config internally, so you only edit app/styles/index.css:
/* app/styles/index.css */
@import 'tailwindcss';
@import '@canopy-iiif/app/ui/styles/index.css';
@theme {
--color-accent-500: #4f46e5;
--font-serif: 'Fraunces', Georgia, 'Times New Roman', serif;
}
@utility font-display {
font-family: var(--font-serif);
font-weight: 600;
}- The preset (design tokens) and plugin (component CSS/utilities) are loaded automatically; just override tokens via
@theme(or regular custom properties) when you need custom colors, fonts, radii, etc. - Want full control? Create your own
tailwind.config.mtsand usedefineCanopyTailwindConfig(import.meta.url, …)from@canopy-iiif/app/ui/tailwind-configto opt out or add additional presets/plugins. - The UI components use semantic selectors (e.g.,
.canopy-card). Override them in@layerblocks as needed. - CSS changes still hot‑swap during
npm run dev(Tailwind picks upapp/styles/**plus the Canopy UI preset/plugin).
Add a theme block to canopy.yml to override the default Indigo/Slate palette. Accent and gray colors map to Radix color families (e.g., indigo, cyan, slate). An optional appearance flag switches between the light and dark Radix ramps while keeping token names (50, 100, …) consistent.
theme:
accentColor: cyan
grayColor: slate
appearance: dark # light by defaultWhen appearance: dark is set, Canopy pulls from the *Dark Radix palettes, flips the CSS color-scheme to dark, and continues to expose Tailwind utilities such as bg-brand-500 and text-gray-900 with values appropriate for the darker surface.
- Layout: add
content/works/_layout.mdxto enable IIIF work page generation. The layout receivesprops.manifest(normalized to Presentation 3). - Source: collection URIs come from
canopy.yml(collection, either a string or an array). When omitted,CANOPY_COLLECTION_URIcan provide a single fallback URI. - Behavior:
- Recursively walks the collection and subcollections, fetching Manifests.
- Normalizes resources using
@iiif/helpersto v3 where possible. - Caches fetched Manifests in
.cache/iiif/manifests/and tracks ids/slugs in.cache/iiif/index.json. - Emits one HTML page per Manifest under
site/works/<slug>.html.
- Performance: tune with environment variables —
CANOPY_CHUNK_SIZE(default20) andCANOPY_FETCH_CONCURRENCY(default5). - Cache notes: updating the configured collection URIs resets the manifest cache; you can also delete
.cache/iiif/to force a refetch.
Canopy can derive new IIIF Presentation 3 Collections from a single source Collection by faceting on Manifest metadata labels you choose.
- Configure labels in
canopy.yml:metadata:then a YAML list (e.g.,- Date,- Subject,- Creator). Matching is case-insensitive (subjectandSubjectbehave the same).
- Aggregation: during the build, Canopy scans each Manifest’s
metadata[]and collects values (across all languages) for the configured labels.- Internal cache:
.cache/iiif/facets.json(implementation detail; not served).
- Internal cache:
- Output (IIIF-only API under
site/api/facet/**):/api/facet/index.json: top-level IIIF Collection listing all facet labels./api/facet/{label}.json: IIIF Collection of child value Collections for that label./api/facet/{label}/{value}.json: IIIF Collection of Manifests that have that label/value.- All
idfields are absolute URLs (see Base URL notes below).
- Items included in value Collections:
id: Manifest URI (IIIF Presentation URL for the work)type:Manifestlabel:{ none: [title] }thumbnail: optional,{ id, type: 'Image' }homepage: site page for the work (absolute URL), typed asText
Base URL rules for IIIF ids
- Absolute ids/links use this priority:
- GitHub Actions: auto‑detected in the Pages workflow (
owner.github.io[/repo]). CANOPY_BASE_URLenv var (e.g.,https://canopy-iiif.github.io/canopy-iiif).canopy.yml→site.baseUrl.- Dev default
http://localhost:5001(orPORTenv).
- GitHub Actions: auto‑detected in the Pages workflow (
Why this is cool
- From a single IIIF Collection, Canopy synthesizes many new IIIF-compliant Collections (one per facet label, plus one per label/value). These can be browsed, linked to, and reused by external tools that speak the IIIF Presentation API.
- Controls come from environment variables:
CANOPY_THUMBNAIL_SIZE(default400) — target width/height in pixels when selecting a thumbnail.CANOPY_THUMBNAILS_UNSAFE(true/1to enable) — opt into an expanded strategy that may perform extra requests to find a representative image.
- Behavior: during the IIIF build, a thumbnail URL is resolved for each Manifest and stored on its entry in
.cache/iiif/index.jsonasthumbnail. - Safety: with the unsafe flag disabled, a simpler/safer selection is used; enabling it allows additional probing for better imagery when size requirements are stricter.
Interactive components render safely in MDX and hydrate client-side:
- Viewer:
<Viewer iiifContent="…" />— wraps@samvera/clover-iiifand hydrates client-side. - Image:
<Image iiifContent="…" />— wraps@samvera/clover-iiif/imageto display a single IIIF resource with client-side hydration. - Interstitial hero:
<Interstitials.Hero … />— rotates featured IIIF works declared incanopy.yml → featured, falling back to cached manifest thumbnails. Accepts props such asheadline,description,links,height,background="theme" | "transparent",index, andrandomto control the content and layout. - Slider:
<Slider iiifContent="…" />— wraps Clover’s slider; hydrates client-side via a separate bundle. - Related items:
<RelatedItems top={3} iiifContent?="…" />— facet-driven related sliders.- Without
iiifContent(e.g., homepage): picks one top value per indexed facet label and renders one slider per label. - With
iiifContent(work pages): reads the Manifest’s metadata, intersects with indexed facets, then picks one of the Manifest’s values at random for each label and renders exactly one slider per label. Facets not present on the Manifest are skipped.
- Without
- Search (composable): place any of these where you want on the page and they hydrate client-side:
<SearchForm />— search input + submit button wired to the shared store<SearchSummary />— summary text (query/type aware)<SearchResults />— results list<SearchTabs />— type tabs (e.g., work/pages/docs)<SearchTotal />— live count of visible results
How it works:
- MDX is rendered on the server. Browser-only components emit a lightweight placeholder element.
- The build injects
site/scripts/react-globals.jsand the relevant hydration script(s) into pages that need them. - On load, the hydration script finds placeholders, reads props (embedded as JSON), and mounts the React component.
- Viewer runtime:
site/scripts/canopy-viewer.js - Interstitial hero runtime:
site/scripts/canopy-hero-slider.js(loaded only on pages that include<Interstitials.Hero />) - Slider runtime:
site/scripts/canopy-slider.js(loaded only on pages that include<Slider />) - Related items runtime:
site/scripts/canopy-related-items.js(loaded only on pages that include<RelatedItems />)
- Viewer runtime:
Usage examples:
// content/index.mdx
<Interstitials.Hero
headline="Create fast & lightweight digital projects with Canopy IIIF"
description="Canopy IIIF is an open-source static site generator for creating discovery-focused digital scholarship and collections websites with IIIF APIs."
background="transparent"
links={[
{ href: "/search", title: "Browse Collection", type: "primary" },
{ href: "/about", title: "About Canopy", type: "secondary" }
]}
/>
## Demo
<Viewer iiifContent="https://api.dc.library.northwestern.edu/api/v2/works/…?as=iiif" />
// content/search/_layout.mdx
# Search
<SearchTabs />
<SearchSummary />
<SearchResults />
Notes:
- You do not need to import components in MDX; they are auto‑provided by the MDX provider from
@canopy-iiif/ui. - The Viewer and Search placeholders render minimal HTML on the server and hydrate in the browser.
- The search runtime (
site/search.js) uses FlexSearch and supports filtering bytype(e.g.,work,page,docs). These subcomponents share a single client store so they stay in sync.
@canopy-iiif/uiexposes a MasonryGridused bySearchResults.- You can control the layout via a prop on the MDX placeholder:
<SearchResults layout="grid" />(default)<SearchResults layout="list" />
- The Grid is implemented with
react-masonry-cssand scoped styles; it renders columns client‑side after hydration.
SSR safety and bundling
- Server render imports UI from
@canopy-iiif/ui/server— a server‑safe entry that only exports MDX placeholders and other SSR‑compatible components. - Browser UI (
@canopy-iiif/ui) is built as an ESM library with externals for heavy globals:- Externals:
react,react-dom,react-dom/client,react-masonry-css,flexsearch.
- Externals:
- The search runtime bundles the client UI, and provides shims so those externals resolve to browser globals:
- React shims come from
site/scripts/react-globals.js(injected when needed). - FlexSearch is also shimmed to
window.FlexSearchin the runtime bundle.
- React shims come from
Advanced layout (optional, future):
- If you need full control over the search page layout, we'll expose a composable Search API (slots or render props) so you can place the form, summary, and results anywhere. Until then,
<Search />renders a sensible default.
Dot‑notation (future): we may also expose these as <Search.Form />, <Search.Results />, <Search.Summary />, <Search.Total /> if needed.
- Workflow:
.github/workflows/deploy-pages.ymlbuildssite/and deploys to Pages. - Enable Pages: in repository Settings → Pages → set Source to "GitHub Actions" (or use the workflow’s automatic enablement if allowed).
- Trigger: pushes to
main(or run manually via Actions → "Deploy to GitHub Pages"). - Output: the workflow uploads the
site/folder as the Pages artifact and deploys it.
- CI tuning (optional):
- Set
CANOPY_CHUNK_SIZEandCANOPY_FETCH_CONCURRENCYto control fetch/build parallelism. - Env overrides (in workflow):
CANOPY_CHUNK_SIZE,CANOPY_FETCH_CONCURRENCY, andCANOPY_COLLECTION_URI(use a small collection for faster CI).
- Set
- Project Pages base path: links currently use absolute
/…. If deploying under/<repo>you may want base‑path aware links; open an issue if you want this wired in.
- This repository (
app) maintains a separate template repository (template). - On push to
main,.github/workflows/release-and-template.ymlpublishes packages and, when a publish occurs, builds a clean template and force‑pushes it tocanopy-iiif/template(branchmain). - The workflow:
- Excludes dev-only paths (
.git,node_modules,packages,.cache,.changeset, internal workflows/docs). - Rewrites
package.jsonto remove workspaces and depend on published@canopy-iiif/lib/@canopy-iiif/uiversions; setsbuild/devscripts to runnode app/scripts/canopy-build.mjs. - Streams the template’s deploy workflow without additional post-build verification to keep the deploy path lean.
- Stages the generated template into
.template-build/(override withTEMPLATE_OUT_DIR) so nothing checked into this repo needs to stay in sync with the published template.
- Excludes dev-only paths (
- Setup:
- Create the
templaterepo under thecanopy-iiiforg (or your chosen owner) and add aTEMPLATE_PUSH_TOKENsecret (PAT with repo write access) to this repo’s secrets. - Optionally mark
templateas a Template repository so users can click “Use this template”.
- Create the
See CONTRIBUTING.md for repository structure, versioning with Changesets, release flow, and the template-branch workflow.
Troubleshooting
- Dynamic require error: if you see “Dynamic require of 'react' is not supported” in the browser, ensure the UI build treats
react,react-dom,react-dom/client,react-masonry-css, andflexsearchas externals. The search runtime supplies React and FlexSearch globals and shims imports towindow.React*/window.FlexSearch. - SSR import safety: the server should import UI via
@canopy-iiif/ui/serverto avoid pulling browser‑only code (like the Masonry Grid) during MDX SSR. - No columns rendering: if Masonry renders a single row, verify that the DOM includes
.canopy-grid_columnwrappers. If not, the Masonry lib likely failed to load; check the build externals and thatsite/scripts/react-globals.jsis injected on the page (it is when interactive components are present).