UNPKG

fumadocs-core

Version:

The library for building a documentation website in Next.js

764 lines (754 loc) 21.5 kB
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 };