UNPKG

fumadocs-core

Version:

The library for building a documentation website in Next.js

605 lines (600 loc) 17.4 kB
import { basename, dirname, extname, parseFilePath, parseFolderPath } from "../chunk-7GNSIKII.js"; import { joinPath, slash, splitPath } from "../chunk-3JSIVMCJ.js"; import "../chunk-JSBRDJBE.js"; // 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, filter, reversed = false) { const output = []; const sortedPaths = (filter ? paths.filter(filter) : [...paths]).sort( (a, b) => a.localeCompare(b) * (reversed ? -1 : 1) ); for (const path of sortedPaths) { const fileNode = buildFileNode(path, ctx); if (!fileNode) continue; if (basename(path, extname(path)) === "index") output.unshift(fileNode); else output.push(fileNode); } for (const dir of sortedPaths) { const dirNode = buildFolderNode(dir, false, ctx); if (dirNode) output.push(dirNode); } return output; } function resolveFolderItem(folderPath, item, ctx, idx, restNodePaths) { if (item === rest || item === restReversed) return item; const { options, resolveName } = ctx; let match = separator.exec(item); if (match?.groups) { const node = { $id: `${folderPath}#${idx}`, type: "separator", icon: options.resolveIcon?.(match.groups.icon), name: match.groups.name }; return [options.attachSeparator?.(node) ?? node]; } match = link.exec(item); if (match?.groups) { const { icon, url, name } = match.groups; const isRelative = url.startsWith("/") || url.startsWith("#") || url.startsWith("."); const node = { type: "page", icon: options.resolveIcon?.(icon), name, url, external: !isRelative }; return [options.attachFile?.(node) ?? 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"); restNodePaths.delete(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, localeStorage, options, resolveName } = 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 = localeStorage?.read(metaPath) ?? storage.read(metaPath); if (meta?.format !== "meta") { meta = void 0; } const isRoot = meta?.data.root ?? isGlobalRoot; let indexDisabled = false; let children; if (!meta?.data.pages) { children = buildAll(files, ctx, (file) => isRoot || file !== indexPath); } else { const restItems = new Set(files); const resolved = meta.data.pages.flatMap((item, i) => resolveFolderItem(folderPath, item, ctx, i, restItems)); if (!isRoot && !restItems.has(indexPath)) { indexDisabled = true; } for (let i = 0; i < resolved.length; i++) { const item = resolved[i]; if (item !== rest && item !== restReversed) continue; const items = buildAll( files, ctx, // index files are not included in ... unless it's a root folder (file) => (file !== indexPath || isRoot) && restItems.has(file), item === restReversed ); resolved.splice(i, 1, ...items); break; } children = resolved; } const index = !indexDisabled ? buildFileNode(indexPath, ctx) : void 0; let name = meta?.data.title ?? index?.name; if (!name) { const folderName = basename(folderPath); name = pathToName(group.exec(folderName)?.[1] ?? folderName); } const node = { type: "folder", name, icon: options.resolveIcon?.(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 }; return options.attachFolder?.( node, { get children() { return files.flatMap((file) => storage.read(file) ?? []); } }, meta ) ?? node; } function buildFileNode(path, { options, getUrl, storage, localeStorage, locale }) { const page = localeStorage?.read(path) ?? storage.read(path); if (page?.format !== "page") return; const { title, description, icon } = page.data; const item = { $id: path, type: "page", name: title ?? pathToName(basename(path, extname(path))), description, icon: options.resolveIcon?.(icon), url: getUrl(page.slugs, locale), $ref: !options.noRef ? { file: path } : void 0 }; return options.attachFile?.(item, page) ?? item; } function build(ctx) { const folder = buildFolderNode("", true, ctx); return { $id: ctx.locale ?? "root", name: folder.name, children: folder.children }; } function createPageTreeBuilder(getUrl) { 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(options) { const resolve = createFlattenPathResolver(options.storage); return build({ options, builder: this, storage: options.storage, getUrl, resolveName(name, format) { return resolve(name, format) ?? name; } }); }, buildI18n({ i18n, ...options }) { const storage = options.storages[i18n.defaultLanguage]; const resolve = createFlattenPathResolver(storage); const entries = i18n.languages.map((lang) => { const tree = build({ options, getUrl, builder: this, locale: lang, storage, localeStorage: options.storages[lang], resolveName(name, format) { return resolve(name, format) ?? name; } }); return [lang, tree]; }); return Object.fromEntries(entries); } }; } 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() { this.files = /* @__PURE__ */ new Map(); this.folders = /* @__PURE__ */ new Map(); 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) { const dir = dirname(path); this.makeDir(dir); this.readDir(dir)?.push(path); this.files.set(path, file); } 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 loadFiles(files, options) { const { transformers = [] } = options; const storage = new FileSystem(); const normalized = files.map((file) => ({ ...file, path: normalizePath(file.path) })); for (const item of options.buildFiles(normalized)) { storage.write(item.path, item); } for (const transformer of transformers) { transformer({ storage, options }); } return storage; } function loadFilesI18n(files, options, i18n) { const parser = i18n.parser === "dir" ? dirParser : dotParser; const storages = {}; for (const lang of i18n.languages) { storages[lang] = loadFiles( files.flatMap((file) => { const [path, locale] = parser(normalizePath(file.path)); if ((locale ?? i18n.defaultLanguage) === lang) { return { ...file, path }; } return []; }), options ); } return storages; } function dirParser(path) { const parsed = path.split("/"); if (parsed.length >= 2) return [parsed.slice(1).join("/"), parsed[0]]; return [path]; } function dotParser(path) { const segs = path.split("/"); if (segs.length === 0) return [path]; const name = segs[segs.length - 1].split("."); if (name.length >= 3) { const locale = name.splice(name.length - 2, 1)[0]; if (locale.length > 0 && !/\d+/.test(locale)) { segs[segs.length - 1] = name.join("."); return [segs.join("/"), locale]; } } return [path]; } 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/loader.ts function indexPages(storages, getUrl, i18n) { const result = { // (locale.slugs -> page) pages: /* @__PURE__ */ new Map(), // (locale.path -> page) pathToMeta: /* @__PURE__ */ new Map(), // (locale.path -> meta) pathToPage: /* @__PURE__ */ new Map() }; const defaultLanguage = i18n?.defaultLanguage ?? ""; for (const filePath of storages[defaultLanguage].getFiles()) { const item = storages[defaultLanguage].read(filePath); if (item.format === "meta") { result.pathToMeta.set( `${defaultLanguage}.${item.path}`, fileToMeta(item) ); } if (item.format === "page") { const page = fileToPage(item, getUrl, defaultLanguage); result.pathToPage.set(`${defaultLanguage}.${item.path}`, page); result.pages.set(`${defaultLanguage}.${page.slugs.join("/")}`, page); if (!i18n) continue; for (const lang of i18n.languages) { if (lang === defaultLanguage) continue; const localizedItem = storages[lang].read(filePath); const localizedPage = fileToPage( localizedItem?.format === "page" ? localizedItem : item, getUrl, lang ); if (localizedItem) { result.pathToPage.set(`${lang}.${item.path}`, localizedPage); } result.pages.set( `${lang}.${localizedPage.slugs.join("/")}`, localizedPage ); } } } 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 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: getUrl = createGetUrl(baseUrl ?? "/", i18n), transformers } = options; const defaultLanguage = i18n?.defaultLanguage ?? ""; const files = typeof source.files === "function" ? source.files() : source.files; function buildFiles(files2) { const indexFiles = []; const taken = /* @__PURE__ */ new Set(); for (const file of files2) { if (file.type !== "page" || file.slugs) continue; if (isIndex(file.path) && !slugsFn) { indexFiles.push(file); continue; } file.slugs = slugsFn ? slugsFn(parseFilePath(file.path)) : getSlugs(file.path); const key = file.slugs.join("/"); if (taken.has(key)) throw new Error("Duplicated slugs"); taken.add(key); } for (const file of indexFiles) { file.slugs = getSlugs(file.path); if (taken.has(file.slugs.join("/"))) file.slugs.push("index"); } return files2.map((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 }; }); } const storages = i18n ? loadFilesI18n( files, { buildFiles, transformers }, { ...i18n, parser: i18n.parser ?? "dot" } ) : { "": loadFiles(files, { transformers, buildFiles }) }; const walker = indexPages(storages, getUrl, i18n); const builder = createPageTreeBuilder(getUrl); let pageTree; return { _i18n: i18n, get pageTree() { if (i18n) { pageTree ??= builder.buildI18n({ storages, resolveIcon: options.icon, i18n, ...options.pageTree }); } else { pageTree ??= builder.build({ storage: storages[""], resolveIcon: options.icon, ...options.pageTree }); } return pageTree; }, set pageTree(v) { pageTree = v; }, getPageByHref(href, { dir = "", language } = {}) { 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 = defaultLanguage) { const pages = []; for (const [key, value] of walker.pages.entries()) { if (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 isIndex(file) { return basename(file, extname(file)) === "index"; } 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 };