Output Adapter
By default every generated page is written to the local filesystem, under rootPath/baseURL. The outputAdapter setting replaces that destination with one of your own β an object store, a headless CMS, a database, an in-memory map in a test β without forking the renderer.
This page covers what the renderer expects from an adapter, with two worked examples. For the setting itself, see outputAdapter in the settings reference.
The contractβ
interface OutputAdapter {
writeFile: (filePath: string, content: string) => Promise<void>;
readFile: (filePath: string) => Promise<string | undefined>;
ensureDir?: (
dirPath: string,
options?: { forceEmpty?: boolean },
) => Promise<void>;
}
writeFile and readFile are required. ensureDir is optional, so a destination with no directory concept can leave it out β but read Deleted types before you do.
When each method is calledβ
| Method | Called |
|---|---|
ensureDir | once for the output directory when generation starts, then once per category directory by the Docusaurus and Hugo presets |
writeFile | once per type page, once for the homepage, plus any navigation file the preset maintains (_category_.yml, _index.md, toc.yml, SUMMARY.md) |
readFile | once per page for the DocFX, mdBook and MkDocs presets, once per category for Docusaurus, and once per navigation file a preset maintains |
An adapter is used for the whole run: there is no per-page opt-out.
Paths are keysβ
The paths handed to an adapter are the ones the filesystem writer would use. Two properties matter if you map them onto something that is not a filesystem:
They are relative to the working directory, not absolute, unless rootPath is itself absolute. With the default rootPath: "./docs" and baseURL: "schema", a page arrives as docs/schema/types/objects/book.md. Key off them directly, or make them relative to outputDir β do not assume a leading /.
Because they are relative, a key derived from them depends on the working directory the generator was started from. Resolve both sides before comparing them, and the two forms can no longer be mixed β a relative rootPath against an absolute path yields a key that climbs out of the tree with ...
One path sits outside the output directory. mdBook requires SUMMARY.md one level above the generated pages, so an adapter computing keys with path.relative(outputDir, filePath) gets a leading .. for that one file, which many object stores reject.
Do not strip the ..: that moves SUMMARY.md in among the pages, and the links it holds β schema/index.md and the like β then resolve one level too deep. Key off rootPath instead, which keeps every path inside the tree and preserves the layout:
const toKey = (location) =>
path
.relative(path.resolve(rootPath), path.resolve(location))
.split(path.sep)
.join("/");
| Written | Key |
|---|---|
docs/schema/types/objects/book.md | schema/types/objects/book.md |
docs/schema/index.md | schema/index.md |
docs/SUMMARY.md | SUMMARY.md |
Use forward slashes in keys regardless of the OS that generated them, so the same schema produces the same keys everywhere.
Reading backβ
Some presets post-process what they just wrote: DocFX rewrites uid values and builds toc.yml, mdBook and MkDocs rewrite internal links to relative paths, and Docusaurus checks whether a _category_.yml already exists before replacing it.
That is what readFile is for. It returns undefined when there is nothing at the path β a normal answer, not a failure.
A destination that genuinely cannot serve back what it wrote may return undefined always, but then:
- DocFX, mdBook and MkDocs cannot rewrite internal links, which stay as absolute paths, and DocFX produces no
toc.yml - Docusaurus regenerates
_category_.ymlon every run, discarding edits made to it by hand
Starlight, Fumadocs, Vocs, Hugo and HonKit never read back their output, so a write-only destination costs them nothing.
The first page that cannot be read back is reported once for that adapter, rather than once per page, so a broken destination is visible without burying the run in identical errors.
Deleted typesβ
ensureDir receives { forceEmpty: true } when force is set, which is how a run clears out what a previous one left behind. Omit ensureDir and force has nothing to act on: pages for types deleted from the schema stay in the destination forever.
A destination with no directories still usually wants ensureDir for exactly that reason β deleting by key prefix is the natural equivalent, as in the Cloudflare R2 adapter below. Leave it out only when the destination is disposable or pruned elsewhere.
Formattingβ
Content arrives already formatted, so an adapter never handles pretty itself. Prettifying happens before the adapter is called, so every destination receives identical bytes.
An adapter for testsβ
The smallest useful adapter keeps pages in memory, which makes generated output straightforward to assert against:
const pages = new Map();
export const memoryOutputAdapter = {
writeFile: async (filePath, content) => {
pages.set(filePath, content);
},
readFile: async (filePath) => pages.get(filePath),
ensureDir: async (dirPath, options) => {
if (options?.forceEmpty !== true) {
return;
}
for (const filePath of pages.keys()) {
if (filePath.startsWith(dirPath)) {
pages.delete(filePath);
}
}
},
};
This supports read-back, so every preset works against it.
A Cloudflare R2 adapterβ
graphql-markdown/demo-astro-r2 is a live example: it generates straight into an R2 bucket and builds an Astro/Starlight site that reads the pages back out at build time. It reaches the bucket through Wrangler's R2 binding rather than the S3 API, but the adapter contract is the same.
The example below publishes the generated pages to a Cloudflare R2 bucket instead of writing them to disk, using R2's S3-compatible API:
const path = require("node:path");
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
DeleteObjectsCommand,
} = require("@aws-sdk/client-s3");
const rootPath = "./docs";
const baseURL = "schema";
const BUCKET = "docs";
const r2 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
});
// Object keys, derived as described in "Paths are keys" above.
const toKey = (location) =>
path
.relative(path.resolve(rootPath), path.resolve(location))
.split(path.sep)
.join("/");
module.exports = {
// ...
rootPath,
baseURL,
outputAdapter: {
writeFile: async (filePath, content) => {
await r2.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: toKey(filePath),
Body: content,
ContentType: "text/markdown",
}),
);
},
// Required: the formatters that post-process their own output read each
// page back. A missing object is "there is nothing here", not a failure.
readFile: async (filePath) => {
try {
const object = await r2.send(
new GetObjectCommand({ Bucket: BUCKET, Key: toKey(filePath) }),
);
return await object.Body.transformToString();
} catch (error) {
if (error.name === "NoSuchKey") {
return undefined;
}
throw error;
}
},
// R2 has no directories, so there is nothing to create. The work worth
// doing is honouring `forceEmpty`: without it, pages for types deleted
// from the schema would stay in the bucket forever.
ensureDir: async (dirPath, options) => {
if (options?.forceEmpty !== true) {
return;
}
// S3 prefix matching is literal, not directory aware: without the
// trailing delimiter, clearing `schema` would also delete `schema-v2/`.
const dirKey = toKey(dirPath);
const Prefix = dirKey === "" ? "" : `${dirKey}/`;
let ContinuationToken;
do {
const listed = await r2.send(
new ListObjectsV2Command({
Bucket: BUCKET,
Prefix,
ContinuationToken,
}),
);
if (listed.KeyCount) {
await r2.send(
new DeleteObjectsCommand({
Bucket: BUCKET,
Delete: {
Objects: listed.Contents.map(({ Key }) => ({ Key })),
},
}),
);
}
ContinuationToken = listed.IsTruncated
? listed.NextContinuationToken
: undefined;
} while (ContinuationToken);
},
},
};
Limitationsβ
Two things are worth knowing before you rely on an adapter in an automated pipeline.
A failed hook does not fail the processβ
If a formatter's post-processing throws β an adapter rejecting the write of mdBook's SUMMARY.md, say β the error is logged and the run reports that the output is incomplete, but the process still exits 0. Handler errors are collected rather than raised, so one failing hook does not abort a generation that otherwise succeeded.
A pipeline that must not publish incomplete documentation should not treat a zero exit status as sufficient. Check the log for the incomplete-output line, or assert on what actually landed in the destination β for an object store, that the page count matches the number of types, and that any navigation file the preset needs is present.
State is kept per adapter instance, not per runβ
Two pieces of bookkeeping live for as long as the adapter object does, rather than for the length of a run:
- DocFX records which directories it has already given an "Overview" entry, so the check runs once per directory rather than once per page.
- The first page that cannot be read back is reported once, so a write-only destination does not repeat the same error for every page.
Generating once per process β which is what the CLI and every framework plugin do β is unaffected. Calling generateDocFromSchema repeatedly inside one process while passing the same adapter object carries that state into the later runs: a second run over a directory cleared by force can leave its toc.yml without the "Overview" entry, and a destination that still cannot serve reads stays quiet after the first report.
Construct a new adapter object per run to avoid it. An adapter that holds an expensive client can keep the client in a shared scope and return a fresh wrapper object around it.
Generation still runs under Nodeβ
outputAdapter changes where documentation is written, not what runs it. @graphql-markdown/core and its dependencies use Node built-ins, so generation happens under Node β in a build step or a CI job β and the adapter publishes the result to its destination.