Read your community's documentation out of a GitHub repository.
Fetches markdown from a repo so your site can render it, under two rules: failure is a value, not an exception, and nothing upstream is ever quoted back. Ships the URL-scheme allowlist that keeps a community-authored markdown link from becoming script execution in a reader's browser.
This is the read half of kingdom-community/github-docs.
The write half — landing edits as pull requests — is the Python package
github-docs.
npm install @kingdom-community/github-docsNode 18 or newer (it uses the global fetch). ESM. Types included.
import {createDocsClient} from '@kingdom-community/github-docs';
const docs = createDocsClient({
repo: 'acme-guild/handbook',
// Deny-by-default: only these are ever fetched, and anything else 404s
// without asking GitHub at all.
documents: [
{path: 'handbook/getting-started.md', title: 'Getting started', summary: 'Your first hour.'},
'handbook/rules.md',
'handbook/commands.md'
]
});
// An index page.
for (const doc of docs.documents) {
console.log(doc.slug, doc.title, doc.summary);
}
// A document page, at /docs/[slug].
const result = await docs.fetchMarkdown(slug);
switch (result.status) {
case 'ok':
return {props: {markdown: result.markdown}};
case 'not-listed':
return {notFound: true};
case 'not-configured':
// A deployment state, not an outage. Say so.
return {props: {panel: 'Documentation is not configured on this deployment.'}};
case 'unavailable':
return {props: {panel: `Read it on GitHub: ${docs.webUrl(slug)}`}};
}Note what does not appear: a try/catch. fetchMarkdown never rejects. It
returns a value for every way a fetch can go wrong — a 404, a rate limit, an
upstream 500, a timeout, a DNS failure, an oversized body — so a page cannot
forget to handle one and turn someone else's bad minute into your 5xx.
const docs = createDocsClient({
repo: 'acme-guild/internal-handbook',
token: process.env.DOCS_GITHUB_TOKEN, // server-side secret, see below
documents: process.env.DOCS_PUBLIC_FILES // "a/b.md, a/c.md"
});With a token, the client reads through the GitHub Contents API (which accepts
one) rather than raw.githubusercontent.com (which does not). Without a token
it reads the raw host and sends no credentials at all — because sending
credentials to a host that does not need them is how credentials end up
somewhere they should not be.
Every URL a rendered document is about to emit should pass through the allowlist first, in the renderer's component override or link callback — not against the markdown source, which misses reference-style links entirely.
import Markdown from 'markdown-to-jsx';
import {isExternalUrl, safeImageUrl, safeLinkUrl} from '@kingdom-community/github-docs';
const Link = ({href, children}: {href?: string; children?: React.ReactNode}) => {
const safe = safeLinkUrl(href);
if (safe === null) {
return <>{children}</>; // plain text, not a link to nowhere
}
const external = isExternalUrl(safe);
return (
<a href={safe} {...(external ? {target: '_blank', rel: 'noopener noreferrer'} : {})}>
{children}
</a>
);
};
const Image = ({src, alt}: {src?: string; alt?: string}) => {
const safe = safeImageUrl(src);
return safe === null ? null : <img src={safe} alt={alt ?? ''} />;
};
<Markdown options={{overrides: {a: Link, img: Image}}}>{markdown}</Markdown>;[Rules](rules.md) works when the document is read inside the repository. On
your site the browser would resolve it against your origin and 404. Point it
back at the source:
docs.resolveLink('rules.md', 'handbook/getting-started.md');
// https://github.com/acme-guild/handbook/blob/HEAD/handbook/rules.md| Option | Default | What it does |
|---|---|---|
repo |
(required) | owner/repo, or a GitHub URL. Validated when the client is built, and an unusable value throws there. (webUrl and rawUrl also throw, for a target that is not in the catalogue; fetchMarkdown returns not-listed instead.) |
ref |
'HEAD' |
The git ref to read. HEAD resolves to the default branch whatever it is called. |
token |
none | A read token, for a private repository. Omit for a public one. |
transport |
'api' with a token, 'raw' without |
Contents API vs raw.githubusercontent.com. |
documents |
(unset) | The catalogue. A list, or a comma-separated string. Unset means the whole repository is fetchable; an empty list means nothing is. |
timeoutMs |
5000 |
A page renders its panel rather than making a visitor wait on someone else's outage. |
maxDocumentBytes |
1048576 |
Anything larger is not the document that was asked for. |
apiBase |
https://api.github.com |
For GitHub Enterprise. |
fetchImpl |
global fetch |
Injectable, for tests. |
This package reads none itself — a library that reads process.env is a library
you cannot test twice with different settings. Read them in your app and pass
the values in. Whatever you name yours, the token variable must not carry a
client-bundle prefix (NEXT_PUBLIC_, VITE_, …), or your bundler will inline
the credential into the browser bundle.
createDocsClient({
repo: process.env.DOCS_REPO!,
token: process.env.DOCS_GITHUB_TOKEN,
documents: process.env.DOCS_PUBLIC_FILES
});type MarkdownFetch =
| {status: 'ok'; markdown: string}
| {status: 'unavailable'} // reached GitHub, did not get the document
| {status: 'not-configured'} // a private repo with no token: a deployment state
| {status: 'not-listed'}; // not in the catalogue; nothing was requestedunavailable deliberately does not say why. A page treats every upstream
failure the same way, and distinguishing them would mean deciding what to say
about an upstream status — which is the beginning of quoting upstream back.
Everything is exported from the package root.
Client — createDocsClient, fetchRawMarkdown, fetchApiMarkdown,
DEFAULT_TIMEOUT_MS, DEFAULT_MAX_DOCUMENT_BYTES.
Catalogue — parseCatalogue, entryForSlug, entryForPath,
isSafeDocPath, slugForPath, titleForSlug.
Repository — parseRepoSlug, requireRepoSlug, repoWebUrl,
releasesUrl.
URLs — rawUrl, blobUrl, contentsApiUrl, resolveDocLink,
resolveRepoPath, isSelfContainedTarget, encodeDocPath, DEFAULT_REF.
Markdown URL safety — safeLinkUrl, safeImageUrl, isExternalUrl,
ALLOWED_LINK_SCHEMES, ALLOWED_IMAGE_SCHEMES.
npm install
npm test # vitest
npm run typecheck
npm run buildMIT.
Extracted from the website and infrastructure stack behind a Minecraft community server, generalised and released under MIT.