fumadocs-core
Version:
The library for building a documentation website in Next.js
764 lines (754 loc) • 21.5 kB
JavaScript
import {
basename,
dirname,
extname,
joinPath,
parseFilePath,
parseFolderPath,
slash,
splitPath
} from "../chunk-BDG7Y4PS.js";
import {
normalizeUrl
} from "../chunk-PFNP6PEB.js";
import "../chunk-JSBRDJBE.js";
// src/source/page-tree/transformer-fallback.ts
function transformerFallback() {
const addedFiles = /* @__PURE__ */ new Set();
return {
name: "fumadocs:fallback",
root(root) {
const isolatedStorage = new FileSystem();
for (const file of this.storage.getFiles()) {
if (addedFiles.has(file)) continue;
const content = this.storage.read(file);
if (content) isolatedStorage.write(file, content);
}
if (isolatedStorage.getFiles().length === 0) return root;
root.fallback = this.builder.build({
...this.options,
id: `fallback-${root.$id ?? ""}`,
storage: isolatedStorage,
generateFallback: false
});
addedFiles.clear();
return root;
},
file(node, file) {
if (file) addedFiles.add(file);
return node;
},
folder(node, _dir, metaPath) {
if (metaPath) addedFiles.add(metaPath);
return node;
}
};
}
// src/source/page-tree/builder.ts
var group = /^\((?<name>.+)\)$/;
var link = /^(?:\[(?<icon>[^\]]+)])?\[(?<name>[^\]]+)]\((?<url>[^)]+)\)$/;
var separator = /^---(?:\[(?<icon>[^\]]+)])?(?<name>.+)---|^---$/;
var rest = "...";
var restReversed = "z...a";
var extractPrefix = "...";
var excludePrefix = "!";
function buildAll(paths, ctx, reversed = false) {
const items = [];
const folders = [];
const sortedPaths = paths.sort(
(a, b) => a.localeCompare(b) * (reversed ? -1 : 1)
);
for (const path of sortedPaths) {
ctx.visitedPaths.add(path);
const fileNode = buildFileNode(path, ctx);
if (fileNode) {
if (basename(path, extname(path)) === "index") items.unshift(fileNode);
else items.push(fileNode);
continue;
}
const dirNode = buildFolderNode(path, false, ctx);
if (dirNode) folders.push(dirNode);
}
return [...items, ...folders];
}
function resolveFolderItem(folderPath, item, ctx, idx) {
if (item === rest || item === restReversed) return item;
const { resolveName } = ctx;
let match = separator.exec(item);
if (match?.groups) {
let node = {
$id: `${folderPath}#${idx}`,
type: "separator",
icon: match.groups.icon,
name: match.groups.name
};
for (const transformer of ctx.transformers) {
if (!transformer.separator) continue;
node = transformer.separator.call(ctx, node);
}
return [node];
}
match = link.exec(item);
if (match?.groups) {
const { icon, url, name } = match.groups;
const isRelative = url.startsWith("/") || url.startsWith("#") || url.startsWith(".");
let node = {
type: "page",
icon,
name,
url,
external: !isRelative
};
for (const transformer of ctx.transformers) {
if (!transformer.file) continue;
node = transformer.file.call(ctx, node);
}
return [node];
}
const isExcept = item.startsWith(excludePrefix);
const isExtract = !isExcept && item.startsWith(extractPrefix);
let filename = item;
if (isExcept) {
filename = item.slice(excludePrefix.length);
} else if (isExtract) {
filename = item.slice(extractPrefix.length);
}
const path = resolveName(joinPath(folderPath, filename), "page");
ctx.visitedPaths.add(path);
if (isExcept) return [];
const dirNode = buildFolderNode(path, false, ctx);
if (dirNode) {
return isExtract ? dirNode.children : [dirNode];
}
const fileNode = buildFileNode(path, ctx);
return fileNode ? [fileNode] : [];
}
function buildFolderNode(folderPath, isGlobalRoot, ctx) {
const { storage, options, resolveName, transformers } = ctx;
const files = storage.readDir(folderPath);
if (!files) return;
const metaPath = resolveName(joinPath(folderPath, "meta"), "meta");
const indexPath = resolveName(joinPath(folderPath, "index"), "page");
let meta = storage.read(metaPath);
if (meta?.format !== "meta") {
meta = void 0;
}
const isRoot = meta?.data.root ?? isGlobalRoot;
let index;
let children;
function setIndexIfUnused() {
if (isRoot || ctx.visitedPaths.has(indexPath)) return;
ctx.visitedPaths.add(indexPath);
index = buildFileNode(indexPath, ctx);
}
if (!meta?.data.pages) {
setIndexIfUnused();
children = buildAll(
files.filter((file) => !ctx.visitedPaths.has(file)),
ctx
);
} else {
const resolved = meta.data.pages.flatMap((item, i) => resolveFolderItem(folderPath, item, ctx, i));
setIndexIfUnused();
for (let i = 0; i < resolved.length; i++) {
const item = resolved[i];
if (item !== rest && item !== restReversed) continue;
const items = buildAll(
files.filter((file) => !ctx.visitedPaths.has(file)),
ctx,
item === restReversed
);
resolved.splice(i, 1, ...items);
break;
}
children = resolved;
}
let name = meta?.data.title ?? index?.name;
if (!name) {
const folderName = basename(folderPath);
name = pathToName(group.exec(folderName)?.[1] ?? folderName);
}
let node = {
type: "folder",
name,
icon: meta?.data.icon ?? index?.icon,
root: meta?.data.root,
defaultOpen: meta?.data.defaultOpen,
description: meta?.data.description,
index,
children,
$id: folderPath,
$ref: !options.noRef && meta ? {
metaFile: metaPath
} : void 0
};
for (const transformer of transformers) {
if (!transformer.folder) continue;
node = transformer.folder.call(ctx, node, folderPath, metaPath);
}
return node;
}
function buildFileNode(path, ctx) {
const { options, getUrl, storage, locale, transformers } = ctx;
const page = storage.read(path);
if (page?.format !== "page") return;
const { title, description, icon } = page.data;
let item = {
$id: path,
type: "page",
name: title ?? pathToName(basename(path, extname(path))),
description,
icon,
url: getUrl(page.slugs, locale),
$ref: !options.noRef ? {
file: path
} : void 0
};
for (const transformer of transformers) {
if (!transformer.file) continue;
item = transformer.file.call(ctx, item, path);
}
return item;
}
function build(id, ctx) {
const folder = buildFolderNode("", true, ctx);
let root = {
$id: id,
name: folder.name || "Docs",
children: folder.children
};
for (const transformer of ctx.transformers) {
if (!transformer.root) continue;
root = transformer.root.call(ctx, root);
}
return root;
}
function createPageTreeBuilder(getUrl) {
function getTransformers(options, generateFallback) {
const transformers = [];
for (const plugin of options.plugins ?? []) {
if (plugin.transformPageTree) transformers.push(plugin.transformPageTree);
}
if (generateFallback) {
transformers.push(transformerFallback());
}
return transformers;
}
function createFlattenPathResolver(storage) {
const map = /* @__PURE__ */ new Map();
const files = storage.getFiles();
for (const file of files) {
const content = storage.read(file);
const flattenPath = file.substring(0, file.length - extname(file).length);
map.set(flattenPath + "." + content.format, file);
}
return (name, format) => {
return map.get(name + "." + format);
};
}
return {
build({ storage, id, ...options }) {
const key = "";
return this.buildI18n({
id,
storages: { [key]: storage },
...options
})[key];
},
buildI18n({ id, storages, generateFallback = true, ...options }) {
const transformers = getTransformers(options, generateFallback);
const out = {};
for (const [locale, storage] of Object.entries(storages)) {
const resolve = createFlattenPathResolver(storage);
const branch = locale.length === 0 ? "root" : locale;
out[locale] = build(id ? `${id}-${branch}` : branch, {
transformers,
builder: this,
options,
getUrl,
locale,
storage,
storages,
visitedPaths: /* @__PURE__ */ new Set(),
resolveName(name, format) {
return resolve(name, format) ?? name;
}
});
}
return out;
}
};
}
function pathToName(name) {
const result = [];
for (const c of name) {
if (result.length === 0) result.push(c.toLocaleUpperCase());
else if (c === "-") result.push(" ");
else result.push(c);
}
return result.join("");
}
// src/source/file-system.ts
var FileSystem = class {
constructor(inherit) {
this.files = /* @__PURE__ */ new Map();
this.folders = /* @__PURE__ */ new Map();
if (inherit) {
for (const [k, v] of inherit.folders) {
this.folders.set(k, v);
}
for (const [k, v] of inherit.files) {
this.files.set(k, v);
}
} else {
this.folders.set("", []);
}
}
read(path) {
return this.files.get(path);
}
/**
* get the direct children of folder (in virtual file path)
*/
readDir(path) {
return this.folders.get(path);
}
write(path, file) {
if (this.files.has(path)) {
this.files.set(path, file);
return;
}
const dir = dirname(path);
this.makeDir(dir);
this.readDir(dir)?.push(path);
this.files.set(path, file);
}
delete(path) {
return this.files.delete(path);
}
deleteDir(path) {
return this.folders.delete(path);
}
getFiles() {
return Array.from(this.files.keys());
}
makeDir(path) {
const segments = splitPath(path);
for (let i = 0; i < segments.length; i++) {
const segment = segments.slice(0, i + 1).join("/");
if (this.folders.has(segment)) continue;
this.folders.set(segment, []);
this.folders.get(dirname(segment)).push(segment);
}
}
};
// src/source/load-files.ts
function isLocaleValid(locale) {
return locale.length > 0 && !/\d+/.test(locale);
}
var parsers = {
dir(path) {
const [locale, ...segs] = path.split("/");
if (locale && segs.length > 0 && isLocaleValid(locale))
return [segs.join("/"), locale];
return [path];
},
dot(path) {
const dir = dirname(path);
const base = basename(path);
const parts = base.split(".");
if (parts.length < 3) return [path];
const [locale] = parts.splice(parts.length - 2, 1);
if (!isLocaleValid(locale)) return [path];
return [joinPath(dir, parts.join(".")), locale];
},
none(path) {
return [path];
}
};
function loadFiles(files, options, i18n) {
const { buildFile, plugins = [] } = options;
const parser = parsers[i18n.parser ?? "dot"];
const storages = {};
const normalized = files.map(
(file) => buildFile({
...file,
path: normalizePath(file.path)
})
);
const fallbackLang = i18n.fallbackLanguage !== null ? i18n.fallbackLanguage ?? i18n.defaultLanguage : null;
function scan(lang) {
if (storages[lang]) return;
let storage;
if (fallbackLang && fallbackLang !== lang) {
scan(fallbackLang);
storage = new FileSystem(storages[fallbackLang]);
} else {
storage = new FileSystem();
}
for (const item of normalized) {
const [path, locale = i18n.defaultLanguage] = parser(item.path);
if (locale === lang) storage.write(path, item);
}
const context = {
storage
};
for (const plugin of plugins) {
plugin.transformStorage?.(context);
}
storages[lang] = storage;
}
for (const lang of i18n.languages) scan(lang);
return storages;
}
function normalizePath(path) {
const segments = splitPath(slash(path));
if (segments[0] === "." || segments[0] === "..")
throw new Error("It must not start with './' or '../'");
return segments.join("/");
}
// src/source/plugins/index.ts
function buildPlugins(plugins) {
return plugins;
}
// src/source/plugins/slugs.ts
function slugsPlugin(slugsFn) {
function isIndex(file) {
return basename(file, extname(file)) === "index";
}
return {
transformStorage({ storage }) {
const indexFiles = /* @__PURE__ */ new Set();
const taken = /* @__PURE__ */ new Set();
const autoIndex = slugsFn === void 0;
for (const path of storage.getFiles()) {
const file = storage.read(path);
if (!file || file.format !== "page" || file.slugs) continue;
if (isIndex(path) && autoIndex) {
indexFiles.add(path);
continue;
}
file.slugs = slugsFn ? slugsFn(parseFilePath(path)) : getSlugs(path);
const key = file.slugs.join("/");
if (taken.has(key)) throw new Error("Duplicated slugs");
taken.add(key);
}
for (const path of indexFiles) {
const file = storage.read(path);
if (file?.format !== "page") continue;
file.slugs = getSlugs(path);
if (taken.has(file.slugs.join("/"))) file.slugs.push("index");
}
}
};
}
// src/source/plugins/compat.ts
function compatPlugin(loader2, {
attachFile,
attachSeparator,
attachFolder,
transformers
}) {
const chunk = [];
chunk.push({
transformPageTree: {
file(node, file) {
if (!attachFile) return node;
const content = file ? this.storage.read(file) : void 0;
return attachFile(
node,
content?.format === "page" ? content : void 0
);
},
folder(node, folderPath, metaPath) {
if (!attachFolder) return node;
const files = this.storage.readDir(folderPath) ?? [];
const meta = metaPath ? this.storage.read(metaPath) : void 0;
return attachFolder(
node,
{
children: files.flatMap((file) => this.storage.read(file) ?? [])
},
meta?.format === "meta" ? meta : void 0
);
},
separator(node) {
if (!attachSeparator) return node;
return attachSeparator(node);
}
}
});
for (const transformer of loader2.transformers ?? []) {
chunk.push(fromStorageTransformer(transformer));
}
for (const transformer of transformers ?? []) {
chunk.push(fromPageTreeTransformer(transformer));
}
return chunk;
}
function fromPageTreeTransformer(transformer) {
return {
transformPageTree: transformer
};
}
function fromStorageTransformer(transformer) {
return {
transformStorage: transformer
};
}
// src/source/plugins/icon.ts
function iconPlugin(resolveIcon) {
function replaceIcon(node) {
if (node.icon === void 0 || typeof node.icon === "string")
node.icon = resolveIcon(node.icon);
return node;
}
return {
transformPageTree: {
file: replaceIcon,
folder: replaceIcon,
separator: replaceIcon
}
};
}
// src/source/loader.ts
function indexPages(storages, getUrl) {
const result = {
// (locale.slugs -> page)
pages: /* @__PURE__ */ new Map(),
// (locale.path -> page)
pathToMeta: /* @__PURE__ */ new Map(),
// (locale.path -> meta)
pathToPage: /* @__PURE__ */ new Map()
};
for (const [lang, storage] of Object.entries(storages)) {
for (const filePath of storage.getFiles()) {
const item = storage.read(filePath);
const path = `${lang}.${filePath}`;
if (item.format === "meta") {
result.pathToMeta.set(path, fileToMeta(item));
continue;
}
const page = fileToPage(item, getUrl, lang);
result.pathToPage.set(path, page);
result.pages.set(`${lang}.${page.slugs.join("/")}`, page);
}
}
return result;
}
function createGetUrl(baseUrl, i18n) {
const baseSlugs = baseUrl.split("/");
return (slugs, locale) => {
const hideLocale = i18n?.hideLocale ?? "never";
let urlLocale;
if (hideLocale === "never") {
urlLocale = locale;
} else if (hideLocale === "default-locale" && locale !== i18n?.defaultLanguage) {
urlLocale = locale;
}
const paths = [...baseSlugs, ...slugs];
if (urlLocale) paths.unshift(urlLocale);
return `/${paths.filter((v) => v.length > 0).join("/")}`;
};
}
function loader(options) {
return createOutput(options);
}
function loadSource(source) {
const out = [];
for (const item of Array.isArray(source) ? source : [source]) {
if (typeof item.files === "function") {
out.push(...item.files());
} else {
out.push(...item.files);
}
}
return out;
}
function createOutput(options) {
if (!options.url && !options.baseUrl) {
console.warn("`loader()` now requires a `baseUrl` option to be defined.");
}
const { source, baseUrl = "/", i18n, slugs: slugsFn, url: urlFn } = options;
const getUrl = urlFn ? (...args) => normalizeUrl(urlFn(...args)) : createGetUrl(baseUrl, i18n);
const defaultLanguage = i18n?.defaultLanguage ?? "";
const files = loadSource(source);
const plugins = [slugsPlugin(slugsFn)];
if (options.icon) {
plugins.push(iconPlugin(options.icon));
}
if (options.plugins) {
plugins.push(...buildPlugins(options.plugins));
}
if (options.pageTree) {
plugins.push(...compatPlugin(options, options.pageTree));
}
const storages = loadFiles(
files,
{
buildFile(file) {
if (file.type === "page") {
return {
format: "page",
path: file.path,
slugs: file.slugs,
data: file.data,
absolutePath: file.absolutePath ?? ""
};
}
return {
format: "meta",
path: file.path,
absolutePath: file.absolutePath ?? "",
data: file.data
};
},
plugins
},
i18n ?? {
defaultLanguage,
parser: "none",
languages: [defaultLanguage]
}
);
const walker = indexPages(storages, getUrl);
const builder = createPageTreeBuilder(getUrl);
let pageTree;
return {
_i18n: i18n,
get pageTree() {
pageTree ??= builder.buildI18n({
storages,
plugins,
...options.pageTree
});
return i18n ? pageTree : pageTree[defaultLanguage];
},
set pageTree(v) {
if (i18n) {
pageTree = v;
} else {
pageTree = {
[defaultLanguage]: v
};
}
},
getPageByHref(href, { dir = "", language = defaultLanguage } = {}) {
const [value, hash] = href.split("#", 2);
let target;
if (value.startsWith(".") && (value.endsWith(".md") || value.endsWith(".mdx"))) {
const path = joinPath(dir, value);
target = walker.pathToPage.get(`${language}.${path}`);
} else {
target = this.getPages(language).find((item) => item.url === value);
}
if (target)
return {
page: target,
hash
};
},
getPages(language) {
const pages = [];
for (const [key, value] of walker.pages.entries()) {
if (language === void 0 || key.startsWith(`${language}.`)) {
pages.push(value);
}
}
return pages;
},
getLanguages() {
const list = [];
if (!options.i18n) return list;
for (const language of options.i18n.languages) {
list.push({
language,
pages: this.getPages(language)
});
}
return list;
},
getPage(slugs = [], language = defaultLanguage) {
return walker.pages.get(`${language}.${slugs.join("/")}`);
},
getNodeMeta(node, language = defaultLanguage) {
const ref = node.$ref?.metaFile;
if (!ref) return;
return walker.pathToMeta.get(`${language}.${ref}`);
},
getNodePage(node, language = defaultLanguage) {
const ref = node.$ref?.file;
if (!ref) return;
return walker.pathToPage.get(`${language}.${ref}`);
},
getPageTree(locale) {
if (options.i18n) {
return this.pageTree[locale ?? defaultLanguage];
}
return this.pageTree;
},
// @ts-expect-error -- ignore this
generateParams(slug, lang) {
if (options.i18n) {
return this.getLanguages().flatMap(
(entry) => entry.pages.map((page) => ({
[slug ?? "slug"]: page.slugs,
[lang ?? "lang"]: entry.language
}))
);
}
return this.getPages().map((page) => ({
[slug ?? "slug"]: page.slugs
}));
}
};
}
function fileToMeta(file) {
return {
path: file.path,
absolutePath: file.absolutePath,
get file() {
return parseFilePath(this.path);
},
data: file.data
};
}
function fileToPage(file, getUrl, locale) {
return {
get file() {
return parseFilePath(this.path);
},
absolutePath: file.absolutePath,
path: file.path,
url: getUrl(file.slugs, locale),
slugs: file.slugs,
data: file.data,
locale
};
}
var GroupRegex = /^\(.+\)$/;
function getSlugs(file) {
if (typeof file !== "string") return getSlugs(file.path);
const dir = dirname(file);
const name = basename(file, extname(file));
const slugs = [];
for (const seg of dir.split("/")) {
if (seg.length > 0 && !GroupRegex.test(seg)) slugs.push(encodeURI(seg));
}
if (GroupRegex.test(name))
throw new Error(`Cannot use folder group in file names: ${file}`);
if (name !== "index") {
slugs.push(encodeURI(name));
}
return slugs;
}
export {
FileSystem,
createGetUrl,
createPageTreeBuilder,
getSlugs,
loadFiles,
loader,
parseFilePath,
parseFolderPath
};