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

canopycms

v0.0.54

Published

CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.

Readme

CanopyCMS

CanopyCMS is a schema-driven, branch-aware CMS for websites that store their content in GitHub repositories.

What it does for you

  • Lets you keep your site code and content in one repo.
  • Lets your users edit your content without ever touch Git directly.
  • Keeps your users changes apart from each other on separate branches.
  • Enforces schema constraints on your content.
  • Limits which users can see/edit which branch and which content.
  • See edits immediately in a live preview of your site.
  • Use block-based page building, nested objects, Mermaid, MDX, and more.
  • Upload and manage assets in various stores (S3, LFS, etc)
  • Bring-your-own-auth (with pre-built adapter for Clerk)
  • Lets you deploy the editor within your public facing site or keep it in a separate site.
  • Minimal deployment requirements: a filesystem that common to your web request handlers.
  • Form pages are autogenerated from schemas.

Branch roots

  • Workspaces resolve per mode: prod uses $CANOPYCMS_WORKSPACE_ROOT/content-branches (default: /mnt/efs/workspace/content-branches), dev uses .canopy-dev/content-branches/<branch>.
  • For prod mode, you must set defaultRemoteUrl. For dev, defaultRemoteUrl is optional - if omitted, a local remote is auto-created at .canopy-dev/remote.git.
  • Optionally configure defaultRemoteName (default: origin) and defaultBaseBranch (default: main).
  • Git author identity is required for prod mode: set gitBotAuthorName and gitBotAuthorEmail so bot commits can be created reliably.
  • Branch names are sanitized and traversal is blocked before creating directories.
  • Metadata lives at <workspace>/.canopy-meta/branch.json; the registry lives at <branchesRoot>/branches.json and records the workspaceRoot for each branch.
  • BranchWorkspaceManager + loadBranchState keep metadata and registry entries in sync so APIs read/write against the correct workspace root.

Content formats

CanopyCMS supports content as

  • JSON
  • Markdown (.md) and MDX (.mdx) with frontmatter
  • Blocks and nested objects via schema definitions

How it works (behind the scenes)

When a user makes an edit in CanopyCMS, they do so on a branch they choose (or are defaulted into). That branch represents an underlying git branch that they don't see. Behind the scenes, CanopyCMS manages a set of git clones, each tuned to a different branch supporting the branches your users see in the editing interface. When a user saves a change, that change is written to disk. A user can change multiple files on a branch, e.g. to work on changes across files that work together. When they click a button to publish their branch, the changes are committed in git, and a pull request is made. Reviewers can comment on the submission within the editor and users can make changes and resubmit. Reviewers finally accept the change on GitHub by merging the pull request. CanopyCMS then marks the change as complete and archives the branch. Sync jobs refresh clones when upstream changes happen and surface conflicts without dropping a branch's changes. Authorization information for users and groups is stored on disk and managed by CanopyCMS.

How to integrate (Next.js)

  1. Define your schema/config (canopycms.config.ts)
import { defineCanopyConfig } from 'canopycms'

export default defineCanopyConfig({
  mode: 'dev', // or 'prod'
  gitBotAuthorName: 'Canopy Bot',
  gitBotAuthorEmail: '[email protected]',
  editor: {
    title: 'CanopyCMS Editor', // optional UI defaults
    subtitle: 'Edit content',
    theme: {
      colors: { brand: '#4f46e5' },
    },
    // previewBase: { 'content/posts': '/blog' }, // optional overrides
  },
  // For prod mode, defaultRemoteUrl is required.
  // For dev, it's optional - if omitted, uses auto-initialized local remote at .canopy-dev/remote.git
  // defaultRemoteUrl: 'https://github.com/your/repo.git',
  defaultBranchAccess: 'allow',
  // Optional: contentRoot defaults to "content"
  // contentRoot: 'content',
  schema: {
    collections: [
      {
        name: 'posts',
        path: 'posts', // resolves to content/posts by default
        entries: [
          {
            name: 'post',
            format: 'mdx',
            default: true,
            fields: [
              { name: 'title', type: 'string', required: true },
              { name: 'body', type: 'mdx', required: true, isBody: true },
            ],
          },
        ],
        collections: [
          {
            name: 'highlights',
            path: 'featured',
            entries: [
              {
                name: 'highlight',
                format: 'mdx',
                fields: [
                  { name: 'title', type: 'string' },
                  { name: 'body', type: 'mdx', isBody: true },
                ],
              },
            ],
          },
        ],
      },
    ],
    entries: [
      {
        name: 'home',
        format: 'json',
        maxItems: 1, // acts as a singleton - only one instance allowed
        fields: [
          {
            name: 'hero',
            type: 'object',
            fields: [{ name: 'headline', type: 'string' }],
          },
        ],
      },
    ],
  },
})

The schema object has two top-level keys: collections (nested collections with their own entry types) and entries (entry types at the root level). Collections can contain other collections via collections and define their allowed content via entries. Use maxItems: 1 on an entry type to restrict it to a single instance (like a singleton). contentRoot (default content) is prefixed when resolving filesystem paths and ids, so a path of posts becomes content/posts. Use the collection’s resolved path (id) when calling APIs or building editor URLs.

Body fields:

Mark exactly one markdown/MDX field per entry type with isBody: true to designate it as the body field. When reading a markdown or MDX file, the content after the frontmatter is mapped to the field with isBody: true. This lets you name the body field whatever makes sense for your schema (e.g., content, body, text) rather than relying on a hardcoded body key:

import { defineEntrySchema, type TypeFromEntrySchema } from 'canopycms'

export const postSchema = defineEntrySchema([
  { name: 'title', type: 'string', label: 'Title' },
  { name: 'body', type: 'mdx', label: 'Body', isBody: true },
])

export type PostContent = TypeFromEntrySchema<typeof postSchema>

If no field has isBody: true, the markdown content defaults to a field named body. The isBody flag is validated at schema registry load time: at most one per schema, and only on markdown or mdx fields.

TODO show how schemas can be defined across multiple files. Show all the configuration options for schemas.

  1. Set up the shared Canopy helper and API routes

The npx canopycms init command generates a shared helper that provides the Canopy context (for reading content in pages) and the API handler (for the editor). If you need to create it manually:

// app/lib/canopy.ts
import { createNextCanopyContext } from 'canopycms-next'
import { createClerkAuthPlugin } from 'canopycms-auth-clerk'
import { createDevAuthPlugin } from 'canopycms-auth-dev'
import config from '../../canopycms.config'
import { entrySchemaRegistry } from '../schemas'

const canopyContextPromise = createNextCanopyContext({
  config: config.server,
  authPlugin:
    process.env.CANOPY_AUTH_MODE === 'clerk'
      ? createClerkAuthPlugin({ useOrganizationsAsGroups: true })
      : createDevAuthPlugin(),
  entrySchemaRegistry,
})

// For server component pages
export const getCanopy = async () => {
  const context = await canopyContextPromise
  return context.getCanopy()
}

// For build-time functions (generateStaticParams, generateMetadata)
export const getCanopyForBuild = async () => {
  const context = await canopyContextPromise
  return context.getCanopyForBuild()
}

// For API routes
export const getHandler = async () => {
  const context = await canopyContextPromise
  return context.handler
}

This is the same code that npx canopycms init generates. It exports three helpers:

  • getCanopy() -- request-scoped context with auth from the current user
  • getCanopyForBuild() -- non-request-scoped context with full admin privileges (for generateStaticParams, generateMetadata, build scripts)
  • getHandler() -- the API route handler

Then wire up the catch-all API route:

// app/api/canopycms/[...canopycms]/route.ts
import { getHandler } from '../../../lib/canopy'
import type { NextRequest } from 'next/server'

const handler = getHandler()

type RouteContext = { params: Promise<Record<string, string | string[]>> }

export const GET = async (req: NextRequest, ctx: RouteContext) => (await handler)(req, ctx)
export const POST = async (req: NextRequest, ctx: RouteContext) => (await handler)(req, ctx)
export const PUT = async (req: NextRequest, ctx: RouteContext) => (await handler)(req, ctx)
export const DELETE = async (req: NextRequest, ctx: RouteContext) => (await handler)(req, ctx)

The authPlugin is required and handles authentication for all API requests. See canopycms-auth-clerk for Clerk integration or create your own plugin implementing the AuthPlugin interface.

Host styling is framework-agnostic: your public app can use Tailwind (the included example does) or anything else; Mantine is only required inside the CanopyCMS editor UI.

  1. Load content in your pages

Use readByUrlPath to resolve a URL path to content automatically. It tries a direct entry match first (last segment = slug), then falls back to an index entry. Returns null if nothing matches.

// app/docs/[[...slug]]/page.tsx (catch-all server component)
import { notFound } from 'next/navigation'
import { getCanopy, getCanopyForBuild } from '../../lib/canopy'
import type { DocContent } from '../../schemas'
import DocView from '../../components/DocView'

// Build-time: use getCanopyForBuild() (no request scope needed)
export async function generateStaticParams() {
  const canopy = await getCanopyForBuild()
  const entries = await canopy.listEntries({ rootPath: 'content/docs' })
  // urlPath has index collapsing applied — strip the route prefix for Next.js params
  return entries.map((entry) => ({
    slug: entry.urlPath.replace(/^\/docs\/?/, '').split('/').filter(Boolean),
  }))
}

export default async function DocPage({ params }: { params: { slug?: string[] } }) {
  const slugParts = params.slug || []

  // Request-time: use getCanopy() for auth-aware reads
  const canopy = await getCanopy()
  const urlPath = `/docs/${slugParts.join('/')}`
  const result = await canopy.readByUrlPath<DocContent>(urlPath)
  if (!result) return notFound()

  return <DocView data={result.data} />
}

For known, fixed paths you can still use read directly:

// app/page.tsx (server component)
import { getCanopy } from './lib/canopy'
import type { HomeContent } from './schemas'

export default async function Page() {
  const canopy = await getCanopy()
  const { data } = await canopy.read<HomeContent>({ entryPath: 'content/home' })
  return <HomeView data={data} />
}

Both methods return { data, path }. read throws if the content is missing; readByUrlPath returns null instead. Pass a branch option when you want branch-specific data (e.g., for preview); otherwise it defaults to your configured base branch. Both enforce the same branch/path access rules as the API handlers.

Index entries and URL resolution

Canopy uses an index entry convention for collection landing pages, similar to index.html in a web server:

  • An entry with slug index (filename {type}.index.{id}.{ext}) acts as the landing page for its collection
  • The URL for an index entry is the collection's URL, not {collection}/index. For example, an entry at content/guides/index has URL /guides, not /guides/index
  • readByUrlPath('/') resolves to the content root's index entry (if one exists)
  • Not all collections need an index entry — some are just organizational groupings

This convention is applied consistently across the API:

| API | Index handling | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | listEntries | Each item includes a urlPath field with index collapsing applied. pathSegments retains the raw structure for consumers that need it. | | readByUrlPath | Automatically tries slug: 'index' as a fallback when the direct entry doesn't match. Works for all paths including /. | | buildContentTree | Default buildPath collapses index entries so tree node paths match the URLs consumers would use. |

The round-trip property holds: for every item from listEntries, readByUrlPath(item.urlPath) resolves to the same entry.

Collections without index entries: readByUrlPath returns null for URLs that map to collections with no index entry. This is by design — Canopy resolves content, not routes. For collection-level pages, use the content tree:

const entry = await canopy.readByUrlPath(urlPath)
if (entry) return renderEntry(entry)

// No entry at this URL — check if it's a collection
const node = findInTree(contentTree, urlPath)
if (node?.kind === 'collection') return renderCollectionListing(node)

return notFound()
  1. Wire auth Provide an authPlugin in createNextCanopyContext (e.g., Clerk) so branch/path permissions can be enforced.

Permission Model:

  • Reserved Groups: Admins (full access to all operations), Reviewers (can review branches, request changes, approve PRs)
  • Bootstrap Admins: Set CANOPY_BOOTSTRAP_ADMIN_IDS=user_id1,user_id2 to grant admin access to specific users before the group system is configured
  • Path-based permissions: Define which groups can access which content paths
  • Branch permissions: Branch creators can edit their branches; Admins/Reviewers can see all branches
  1. Embed the editor in an editor-only build/app
import { CanopyEditorPage } from 'canopycms/client'
import config from '../canopycms.config'

export default CanopyEditorPage(config)

The editor loads entries on the client from /api/canopycms/[branch]/entries, so server prefetch is optional. Use previewBaseByCollection to control preview URLs per collection.

  1. Theme it Wrap editor surfaces with CanopyCMSProvider to load Mantine styles and customize brand/primary/neutral/accent colors and color scheme. Pass themeOptions into Editor if desired.

TODO show an example

  1. Split builds Keep your public build free of editor bundles by importing only from canopycms (server helpers + data loaders). Host the editor in a separate app or build target that imports from canopycms/client and mounts the API routes above.

TODO show real examples of what to do

Preview branch awareness

  • When building preview URLs, include the current branch as a query param (e.g., /?branch=feature-foo or /posts/hello?branch=feature-foo) so SSR preview pages read from the same branch workspace the editor is editing. The Editor component appends the branch param automatically to previewBaseByCollection; your page loaders should read searchParams.branch and pass it into createContentReader.
  • For public static builds, omit/ignore the branch param; this pattern is only for the editor/preview environment.
  • Likewise, include branch in your editor route (e.g., /edit?branch=feature-foo) and have your editor page pass it to <Editor> so reloads/links preserve the selected branch. The Editor will also reflect branch switches back into the query string.

Live preview with useCanopyPreview

The useCanopyPreview hook provides live updates to your preview components as content is edited in the CMS. It automatically receives draft changes from the editor iframe and returns updated data in real-time.

Basic usage:

'use client'

import { useCanopyPreview } from 'canopycms/client'
import type { PostContent } from './schemas'

export function PostView({ data }: { data: PostContent }) {
  const {
    data: liveData,
    isLoading,
    highlightEnabled,
    fieldProps,
  } = useCanopyPreview<PostContent>({
    initialData: data,
  })

  return (
    <article>
      <h1 {...fieldProps('title')}>{liveData.title}</h1>
      <p {...fieldProps('body')}>{liveData.body}</p>
    </article>
  )
}

Return values:

  • data: The current content data (initial data on first render, then live updates from editor)
  • isLoading: Object mirroring your data structure with boolean loading states for reference fields
  • highlightEnabled: Boolean indicating if field highlighting is active in the editor
  • fieldProps: Helper function to add data-canopy-path attributes for editor integration

Reference fields and loading states:

When using reference fields (foreign key relationships to other content), the editor resolves these references asynchronously. Use the isLoading object to show loading states for reference fields:

'use client'

import { useCanopyPreview } from 'canopycms/client'

export function PostView({ data }: { data: PostContent }) {
  const { data: liveData, isLoading } = useCanopyPreview<PostContent>({
    initialData: data,
  })

  return (
    <article>
      <h1>{liveData.title}</h1>
      <AuthorCard author={liveData.author} isLoading={isLoading.author} />
    </article>
  )
}

// Component receives clean types - no framework coupling
interface AuthorCardProps {
  author: AuthorContent | null
  isLoading?: boolean // Optional - only needed if you want loading UI
}

function AuthorCard({ author, isLoading }: AuthorCardProps) {
  if (isLoading) {
    return <p>Loading author...</p>
  }
  if (!author) {
    return null // Render nothing when no author
  }
  return <p>By {author.name}</p>
}

Loading state structure:

The isLoading object mirrors your data structure:

  • Single reference field: isLoading.author is a boolean
  • Array of references: isLoading.relatedPosts is an array of boolean[] values
  • Non-reference fields: Always false (no loading state needed)

Benefits:

  • ✅ Zero framework types in your components - just your content types + optional isLoading: boolean
  • ✅ Works for nested reference fields at any depth
  • ✅ Optional - only check loading state if you want to show loading UI
  • ✅ Familiar pattern - mirrors React Query's { data, isLoading } API

Modes (pick per environment)

  • dev (default): Full-featured local development with branching and git ops. Uses .canopy-dev/content-branches/ for per-branch clones.
    • Auto-initialization: If no defaultRemoteUrl is configured, CanopyCMS automatically creates a local remote at .canopy-dev/remote.git and seeds it with your current baseBranch (e.g., main). This allows fully local testing of branching and submission workflows without requiring an external GitHub remote.
    • Manual remote: You can still provide an explicit defaultRemoteUrl to use a real remote or custom local path.
    • Use npx canopycms sync push / npx canopycms sync pull to sync content between your working tree and the CMS.
  • prod: EFS-backed roots under $CANOPYCMS_WORKSPACE_ROOT (default: /mnt/efs/workspace). Requires defaultRemoteUrl.

Branch metadata lives in .canopy-meta/branch.json; registry in branches.json at the branches root. Content APIs resolve the workspace root from branch state + mode instead of relying on process.cwd().

Dev Mode Setup

Basic setup (auto-initialization):

// canopycms.config.ts
export default defineCanopyConfig({
  mode: 'dev',
  gitBotAuthorName: 'Canopy Bot',
  gitBotAuthorEmail: '[email protected]',
  // No defaultRemoteUrl needed - auto-creates .canopy-dev/remote.git
})

How it works:

  1. When you create your first branch, CanopyCMS automatically creates a bare git repository at .canopy-dev/remote.git
  2. Your current baseBranch (default: main) is pushed to this local remote
  3. Branch workspaces are cloned from this local remote into .canopy-dev/content-branches/<branch-name>/
  4. All git operations (push, fetch, etc.) work against the local remote

Requirements:

  • Your project must be a git repository with at least one commit
  • The baseBranch (e.g., main) must exist locally

Resetting dev state: If you change the defaultBaseBranch in your config or want to start fresh:

rm -rf .canopy-dev/
npm run dev  # Restart the server

This will reinitialize the local remote with the new base branch.

Using in a monorepo:

If your CanopyCMS config is in a subdirectory of a larger monorepo, set sourceRoot to tell CanopyCMS which directory to use as the source for the local remote:

// packages/my-app/canopycms.config.ts
export default defineCanopyConfig({
  mode: 'dev',
  sourceRoot: 'packages/my-app',  // Path relative to git repository root
  gitBotAuthorName: 'Canopy Bot',
  gitBotAuthorEmail: '[email protected]',
  schema: [...]
})

This ensures that:

  • Only the packages/my-app directory is pushed to the local remote (not the entire monorepo)
  • Branch clones contain only your app's directory structure
  • Content paths resolve correctly (e.g., content/home works as expected)

How it works:

  1. When sourceRoot is set, CanopyCMS resolves it relative to the git repository root (where process.cwd() returns)
  2. The local remote is created at <sourceRoot>/.canopy-dev/remote.git
  3. Only the sourceRoot directory is pushed to the remote using git subtree (using the baseBranch)
  4. Branch workspaces are cloned from this remote and contain only the source directory

When to use sourceRoot:

  • Your config is in a monorepo subdirectory (e.g., packages/my-app/)
  • You're developing/testing CanopyCMS examples within the CanopyCMS repo itself
  • Not needed if your config is at the root of a standalone repository (omit sourceRoot - defaults to git root)

Using a real remote: You can still provide an explicit remote URL if you want to test against a real repository:

export default defineCanopyConfig({
  mode: 'dev',
  defaultRemoteUrl: 'https://github.com/your/repo.git',
  // ... or use a local bare repo at a custom path
})

Deployment (placeholder)

  • Public build: ships only your site/pages; reads main/default branch content.
  • Editor build: ships the editor UI + CanopyCMS API routes; handles branch create/switch/save/submit and asset uploads.
  • Modes map to environments: dev for local development, prod on EFS. More deployment guidance will be documented as the branch-rooted APIs and submission flow land.

Assets

  • Local filesystem adapter (LocalAssetStore) is available now. S3 and LFS adapters are planned; the API handlers accept a provided adapter so you can swap storage without changing the editor.