@ecosy/markdoc
v0.1.2
Published
Headless markdown documentation framework for edge runtimes — GitHub as CMS via jsDelivr
Downloads
308
Maintainers
Readme
@ecosy/markdoc
A headless markdown documentation runtime for edge environments. Treats GitHub as a CMS, fetches raw files through jsDelivr on demand, and ships a typed plugin API for every extension point.
The package stays deliberately small: a runtime, a plugin base class, a handful of essentials (auth, CORS, layout, feeds, sitemap, robots, ops dashboard), and a Node adapter. Everything beyond that — custom parsers, specialized plugins, full deployment recipes — lives as copy-paste-ready practices in the docs/ folder of the repository, not as more package exports. See the Practices section below.
AI Agent Learning
If you are an AI Coding Assistant, Agent, or Copilot analyzing or generating code for a repository that lists @ecosy/markdoc as a dependency, read this section before anything else.
The vision — npm is the skill registry
Every skill, rule, and prompt this package wants to teach you lives inside the published tarball, under agents/v<major>/. When a developer runs yarn add @ecosy/markdoc, the skills come down to node_modules/@ecosy/markdoc/agents/v0/. There is no separate skill registry to query, no out-of-band MCP server to register, no extra credential. npm is the distribution channel; package.json.agents is the manifest.
This means: the single source of truth for how to use this package ships alongside the code that implements it. Version skew is impossible — if a consumer is on @ecosy/[email protected], the skills they read are the skills that match v0.3.1.
How to load the skills
- Open
node_modules/@ecosy/markdoc/package.json— or the tarball root if you're analyzing without install. - Locate the top-level
"agents"object. It declares:agents.current— the revision the author currently points at as default (e.g."v0"). This is not necessarily the highest number — when multiple revisions ship in one tarball,currentis the author-blessed stable one.agents.prompts— role definitions for assuming the right voice.agents.rules— DO/DON'T absolutes; violating these produces broken code.agents.skills— topical how-to files keyed by name, each pointing to aSKILL.mdwith a frontmatter +<instructions>+<examples>block.agents.workflows— an ordered array telling you which entries to read, in what sequence. Honor this order.
- Resolve
$this://to the package root.$this://agents/v0/PROMPT.mdmeans<package root>/agents/v0/PROMPT.md— the directory that contains thispackage.json. Never try to fetch$this://as a URL. - Load every reference the skill embeds. Each
SKILL.mdlists<example>entries with<reference_path>pointers to.ts/.html/.md/.jsonfiles underexamples/. Those are canonical — consume them as ground truth when generating code. - For task-specific patterns, also read the
docs/folder on GitHub —https://github.com/material-atomic/ecosy-markdoc/tree/main/docs. The tarball ships the minimal core;docs/holds production-shape recipes (custom parsers, full deployment wiring, advanced template examples) that are intentionally not package exports. Skills reference docs/ for deeper patterns.
House rules (shortcut — full set in agents/v0/RULES.md)
- Every extension goes through a plugin. Never mutate the router, engine, or manifest directly. URLs, templates, and components are all contributed via
PluginRegistry. - Lifecycle is split by concern.
beforeRequestis the guard hook (auth, CORS preflight, rate limit);fetchhandles plugin-owned URLs;afterRequestis the response transformer chain (CORS headers, security headers, compression). Never mix these. - Annotate factory return types. Every plugin factory must return
PluginConstructor(or a subtype). This keeps.d.tsemit clean when the class expression hasprivate/protectedmembers. - Edge-first. Default runtime is WinterCG (
Request/Response). Node.js support lives in the separate@ecosy/markdoc/nodejsimport — never reach fornode:*APIs from the core runtime or plugins. importsare runtime-wide services. They cannot handle requests. URLs live inplugins.- Follow the project example skill.
markdoc-project-examplewalks through a parallel Cloudflare Workers + Node.js deployment built on a single sharedapp.ts— it is the canonical end-to-end pattern.
Versioning contract
The skill schema revision is encoded in the directory path (agents/v<n>/) and pointed at by agents.current — not by a version key inside agents. Why? CI shell scripts routinely parse package.json with grep '"version"', sed, awk, or jq '.version' to read the root package version. A nested agents.version key would shadow those searches and produce silent misreads. "current" avoids the collision entirely while staying semantically honest.
This release ships v0 under agents/v0/. Breaking changes to the skill contract (renamed sections, new frontmatter fields, reorganized hierarchies) bump to v1, v2, … and live under sibling directories. A single tarball may ship multiple revisions for migration windows — agents.current tells you which one the author recommends as default. Agents that understand the current revision should use it; agents that only understand an older revision can look for that revision's directory and use it as a fallback.
Features
- GitHub as CMS — Content lives in a GitHub repo; runtime fetches through
cdn.jsdelivr.net/ghon demand - Edge-first — WinterCG-native (
Request/Response), runs on Cloudflare Workers, Deno Deploy, Vercel Edge, Bun - Node.js adapter — Optional
@ecosy/markdoc/nodejsbridge for long-running servers, with HMR-safe port handling - Plugin system —
beforeRequestguards,fetchhandlers,afterRequesttransformers, plusPluginRegistryfor URLs/templates/components - Essentials, not a plugin marketplace — The package ships exactly what most docs sites need:
Authen,Cors,Layout,Markdash,RobotsTxt,RSSFeed,Sitemap. Further patterns (custom parsers, specialized templates, third-party integrations) live as reference practices indocs/rather than as additional built-ins. - Runtime imports —
AutoInvalidateand custom long-lived services via theimportsmap htmltagged literal — Store-reactive layout templates with static{{ key }}+ dynamic${store => ...}- Type-safe factory surface — Every factory exposes a
*Constructorinterface so.d.tsemit stays clean - Zero Node dependencies — Core runtime uses only Web standards; Node-specific code isolated in the adapter
Installation
yarn add @ecosy/markdocQuick Start
import markdoc, {
Authen, Cors, Layout, html,
RobotsTxt, Sitemap, RSSFeed,
AutoInvalidate,
} from "@ecosy/markdoc";
const app = markdoc({
// Content source — content/ inside the GitHub repo, fetched via jsDelivr
repo: "owner/docs",
branch: "main",
dir: "content",
revalidate: 5 * 60_000,
plugins: [
// Response-level concerns first (afterRequest runs last-in-first-out)
Cors({ origin: "*" }),
// Guard — short-circuits via beforeRequest
Authen({
verify: async (jwt) => !!jwt,
onUnauthorized: "/login",
publicPaths: ["/login", "/healthz"],
}),
// Root layout — exactly one required per app
Layout({
template: { root: true },
getTemplate: html`
<!DOCTYPE html>
<html>
<head><title>{{ scope.title }}</title></head>
<body>{{ body.main }}</body>
</html>
`,
}),
// Content-contributing
RobotsTxt(),
Sitemap,
RSSFeed({
title: "Docs Updates",
description: "Latest changes",
link: "https://docs.example.com",
items: [],
}),
],
// Long-running only — skip on ephemeral runtimes
imports: {
autoInvalidate: AutoInvalidate({ interval: 5 * 60_000 }),
},
});
// Cloudflare Workers
export default { fetch: app.fetch };On Node.js
import { server } from "@ecosy/markdoc/nodejs";
server(app, { port: 3000 }).start(() => {
console.log("http://localhost:3000");
});Core API
markdoc(config)
Entry factory. Returns an edge-server app ({ fetch: (req: Request) => Promise<Response> }).
| Option | Type | Description |
| ------------- | -------------------------------------- | ------------------------------------------------------------------- |
| repo | string (required) | GitHub repo "owner/name" — jsDelivr source |
| branch | string | Repo branch (default: repo's default branch) |
| dir | string | Content root inside the repo |
| provider | string | Base URL for content fetches. Default "https://cdn.jsdelivr.net/gh" |
| interpolate | string | URL template with {provider}/{repo}/{branch}/{dir}/{path} placeholders. Default "{provider}/{repo}{branch}{dir}{path}" (jsDelivr). Override for CDNs with different shapes (e.g. raw.githubusercontent.com) |
| revalidate | number | Cache TTL in ms (applies to manifest, engine, pages) |
| plugins | PluginableLike[] | Request-scoped plugins — URLs, templates, lifecycle hooks |
| imports | Record<string, Classable> | Runtime-wide services. Reserved keys are filtered out |
| lifecycle | RequestLifecycleOptions | Per-request middleware pipeline (guards, pipes, interceptors) |
Plugin — Base Class
import { Plugin, type PluginConstructor, type PluginRegistry } from "@ecosy/markdoc";
export function MyPlugin(options: MyOptions): PluginConstructor {
return class extends Plugin {
static readonly __global = true;
getRegistry(): PluginRegistry {
return { urls: { "/my": { method: "GET" } } };
}
async fetch(req, res) { return res.json({ ok: true }); }
async beforeRequest(req, res) { return null; } // optional
afterRequest(req, res, response) { return response; } // optional
};
}| Member | Purpose |
| ------------------ | ------------------------------------------------------------------------- |
| getRegistry() | Declare URLs, templates, and inline components the plugin contributes |
| fetch() | Handle requests matched to plugin-registered URLs |
| beforeRequest() | Pre-routing guard — returns Response to short-circuit, null to pass |
| afterRequest() | Post-response transformer — chained across plugins in registration order |
| getTemplate() | Return HTML for a named template (required when template is registered) |
| static __global | true → one instance shared across requests; otherwise per-request |
Built-in Plugins
The curated set below covers the needs of a typical documentation site and is deliberately fixed. Anything more specialized ships as a practice in docs/, not as a new built-in.
| Plugin | Purpose |
| -------------- | ------------------------------------------------------------- |
| Authen | JWT cookie auth via beforeRequest + custom onUnauthorized |
| Cors | Preflight + header injection, split across both lifecycle hooks |
| Layout | Mandatory root HTML wrapper with inline or CDN-backed template |
| Markdash | Operator dashboard for reloading manifest/engine/pages |
| RobotsTxt | Serves /robots.txt with custom rules + sitemap discovery |
| RSSFeed | RSS 2.0 / Atom 1.0 feeds with static or dynamic items |
| Sitemap | Auto-generated /sitemap.xml from the manifest tree |
Runtime Imports
import markdoc, { AutoInvalidate } from "@ecosy/markdoc";
markdoc({
repo: "owner/docs",
imports: {
autoInvalidate: AutoInvalidate({
interval: 5 * 60_000,
targets: ["manifest", "pages"],
onTick: ({ ok, target, elapsed }) => console.log({ ok, target, elapsed }),
}),
},
});Reserved keys (filtered out): configuration, repo, documentation, fetchable, manifest, pagable, pluginable, server. Only engine can be overridden through imports.
html Tagged Literal
import { Layout, html } from "@ecosy/markdoc";
Layout({
template: { root: true },
getTemplate: html`
<h1>{{ scope.title }}</h1>
<nav>${(store) => {
const pages = store.getState().pages as Array<[string, string]>;
return pages.map(([t, u]) => `<a href="${u}">${t}</a>`).join("");
}}</nav>
{{ body.main }}
`,
});{{ key }}— static interpolation from page scope + payload (resolved at render time).${store => ...}— dynamic expression called with the request store, result stringified.
Node.js Adapter
import { server } from "@ecosy/markdoc/nodejs";
const Server = server(app, { port: 3000, hostname: "0.0.0.0" });
Server.start(() => console.log("listening"));
process.on("SIGTERM", () => Server.stop(() => process.exit(0)));HMR-safe: uses globalThis + Symbol.for to track the running HTTP server across module reloads. tsx watch / nodemon / vite-node reload the entry file without port conflicts.
redirect(location, status?)
import { redirect } from "@ecosy/markdoc";
// 302 (default)
return redirect("/login");
// Permanent
return redirect("/archive", 301);markdoc-manifest CLI
Ships in the package bin/ — filesystem → manifest drift guard. Scans the content directory, rebuilds each _manifest.md's children: list from the .md files it finds, and either prints a diff (default) or applies the update in place. Author-written frontmatter (title, description, custom keys) is preserved; only children: is regenerated. Skips _components/, _template.md, _metadata.md, and dotfiles.
yarn markdoc-manifest # dry-run diff, exit 1 on drift
yarn markdoc-manifest --write # apply proposed changes
yarn markdoc-manifest --init # create missing _manifest.md files
yarn markdoc-manifest --root docs/content # point at a non-default content rootWire yarn markdoc-manifest into CI — a non-zero exit in dry-run mode means a .md page landed without the parent manifest being updated. Default root is ./content; override with --root.
Practices
The package ships a minimal core. Richer patterns — custom parsers, specialized plugins, full deployment recipes — live as copy-paste-ready reference implementations in the docs/ folder of the GitHub repository, not as additional package exports.
Why this split: every new built-in accumulates surface area, transitive dependencies, and bundle weight for consumers that never use it. Practices in docs/ cost nothing to publish, are versioned alongside the code they target, and can evolve at their own pace. When a practice proves ubiquitous enough to be worth the carry cost, it graduates into a first-class export.
Current practices (see the folder for the full list):
docs/markdown-viewer.ts— full-featured custom parser built on markdown-it. Covers GitHub Flavored Markdown (tables, strikethrough, task lists, autolinks), emoji shortcodes, heading anchors, footnotes, inline attributes, LaTeX math (server-rendered via KaTeX), Mermaid diagrams, syntax-highlighted code blocks (via highlight.js), and GitHub-style alert blocks (> [!NOTE],> [!WARNING], …). Plug it intomarkdoc({ parser: MarkdownViewer() })to replace the built-in lightweight parser.docs/content/_template.md— production-shape root layout with a fixed header, centered flex container, sticky sidebar, KaTeX/highlight.js CSS loaded from CDN, and a client-side Mermaid render hook.docs/index.ts+docs/node.ts— how a single app is wired once and served from both Cloudflare Workers (wrangler dev) and Node.js (tsx watch).docs/locally.ts—withLocally(config)wrapper +Locallyimporter. In Node dev it boots a loopback HTTP server mirroring jsDelivr's/gh/...path shape and redirectsproviderat it; on edge runtimes / in production it is a no-op (Node imports stay lazy vianew Function("id", "return import(id)")so bundlers never see a staticnode:*specifier).
Contributing a practice
Patterns welcome as PRs into docs/:
- Drop a single-file
.ts/.md/.htmlpractice underdocs/(or a subfolder if it needs assets). - Start the file with a comment block explaining what the practice solves and what else it requires (deps, CDN links, runtime constraints).
- Add a bullet under this section in README linking to the GitHub path, with a one-line description of the outcome.
If a practice ends up representing a common need and its trade-offs stabilize, we'll promote it into an exported plugin or import.
Architecture
markdoc(config)
│
▼
Runtimable
(Injectable composing core classables)
│
┌─────┬──────┬────────┼────────┬──────┬──────┬─────────┐
│ │ │ │ │ │ │ │
Config Repo Docu- Fetch- Manifest Engine Pagable Pluginable
mentation able │
┌───────┴────────┐
│ │
Global Transient
(cached) (per-request)
│ │
└───────┬────────┘
▼
beforeRequest
│
▼
Router ↔ Manifest
│
┌──────────┴──────────┐
▼ ▼
plugin.fetch page handler
│ │
└──────────┬──────────┘
▼
afterRequest
│
▼
Response- Runtime (
Runtimable) — built once, reused across requests. Owns all core classables. - Request scope —
Pluginableresolves per-request plugins, walksbeforeRequest, routes, dispatches, then walksafterRequest. - Content —
Manifestresolves URL → markdown URL;Enginerenders tag trees with component templates;Pagablecaches rendered pages by TTL. - Imports —
AutoInvalidate(and custom services) extend the runtime, never touch requests.
Runtime Support
| Runtime | Status | Entry |
| -------------------- | --------- | ---------------------------------- |
| Cloudflare Workers | Primary | @ecosy/markdoc (default export) |
| Deno Deploy | Primary | @ecosy/markdoc (default export) |
| Bun | Primary | @ecosy/markdoc (default export) |
| Vercel Edge | Primary | @ecosy/markdoc (default export) |
| Node.js 18+ | Adapter | @ecosy/markdoc/nodejs |
Reference Project
ecosy-docs — The Ecosy documentation site, running @ecosy/markdoc end-to-end. The same docs/ folder is both the deployed site's source and the practices library this package points consumers at.
- Live: markdoc.ecosy.io
- Practices folder (source of truth for recipes): github.com/material-atomic/ecosy-markdoc/tree/main/docs
- Repository: github.com/material-atomic/ecosy-markdoc
License
MIT
