UNPKG

@docubook/create

Version:

CLI to create DocuBook projects

305 lines (274 loc) 8.62 kB
import { compileMDX } from "next-mdx-remote/rsc"; import path from "path"; import { promises as fs } from "fs"; import remarkGfm from "remark-gfm"; import rehypePrism from "rehype-prism-plus"; import rehypeAutolinkHeadings from "rehype-autolink-headings"; import rehypeSlug from "rehype-slug"; import rehypeCodeTitles from "rehype-code-titles"; import { page_routes, ROUTES } from "./routes"; import { visit } from "unist-util-visit"; import type { Node, Parent } from "unist"; import matter from "gray-matter"; // Type definitions for unist-util-visit interface Element extends Node { type: string; tagName?: string; properties?: Record<string, unknown> & { className?: string[]; raw?: string; }; children?: Node[]; value?: string; raw?: string; // For internal use in processing } interface TextNode extends Node { type: 'text'; value: string; } // custom components imports import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell } from "@/components/ui/table"; import Pre from "@/components/markdown/PreMdx"; import Note from "@/components/markdown/NoteMdx"; import { Stepper, StepperItem } from "@/components/markdown/StepperMdx"; import Image from "@/components/markdown/ImageMdx"; import Link from "@/components/markdown/LinkMdx"; import Outlet from "@/components/markdown/OutletMdx"; import Youtube from "@/components/markdown/YoutubeMdx"; import Tooltip from "@/components/markdown/TooltipsMdx"; import Card from "@/components/markdown/CardMdx"; import Button from "@/components/markdown/ButtonMdx"; import Accordion from "@/components/markdown/AccordionMdx"; import CardGroup from "@/components/markdown/CardGroupMdx"; import Kbd from "@/components/markdown/KeyboardMdx"; import { Release, Changes } from "@/components/markdown/ReleaseMdx"; import { File, Files, Folder } from "@/components/markdown/FileTreeMdx"; import AccordionGroup from "@/components/markdown/AccordionGroupMdx"; // add custom components const components = { Tabs, TabsContent, TabsList, TabsTrigger, Youtube, Tooltip, Card, Button, Accordion, AccordionGroup, CardGroup, Kbd, // Table Components table: Table, thead: TableHeader, tbody: TableBody, tfoot: TableFooter, tr: TableRow, th: TableHead, td: TableCell, // Release Note Components Release, Changes, // File Tree Components File, Files, Folder, pre: Pre, Note, Stepper, StepperItem, img: Image, a: Link, Outlet, }; // helper function to handle rehype code titles, since by default we can't inject into the className of rehype-code-titles const handleCodeTitles = () => (tree: Node) => { visit(tree, "element", (node: Element, index: number | null, parent: Parent | null) => { // Ensure the visited node is valid if (!parent || index === null || node.tagName !== 'div') { return; } // Check if this is the title div from rehype-code-titles const isTitleDiv = node.properties?.className?.includes('rehype-code-title'); if (!isTitleDiv) { return; } // Find the next <pre> element, skipping over other nodes like whitespace text let nextElement = null; for (let i = index + 1; i < parent.children.length; i++) { const sibling = parent.children[i]; if (sibling.type === 'element') { nextElement = sibling as Element; break; } } // If the next element is a <pre>, move the title to it if (nextElement && nextElement.tagName === 'pre') { const titleNode = node.children?.[0] as TextNode; if (titleNode && titleNode.type === 'text') { if (!nextElement.properties) { nextElement.properties = {}; } nextElement.properties['data-title'] = titleNode.value; // Remove the original title div parent.children.splice(index, 1); // Return the same index to continue visiting from the correct position return index; } } }); }; // can be used for other pages like blogs, Guides etc async function parseMdx<Frontmatter>(rawMdx: string) { return await compileMDX<Frontmatter>({ source: rawMdx, options: { parseFrontmatter: true, mdxOptions: { rehypePlugins: [ preProcess, rehypeCodeTitles, handleCodeTitles, rehypePrism, rehypeSlug, rehypeAutolinkHeadings, postProcess, ], remarkPlugins: [remarkGfm], }, }, components, }); } // logic for docs export type BaseMdxFrontmatter = { title: string; description: string; image: string; date: string; }; export async function getDocsForSlug(slug: string) { try { const { content, filePath } = await getRawMdx(slug); const mdx = await parseMdx<BaseMdxFrontmatter>(content); return { ...mdx, filePath, }; } catch (err) { console.log(err); } } export async function getDocsTocs(slug: string) { const { content } = await getRawMdx(slug); const rawMdx = content; // Regex to match code blocks (```...```), standard markdown headings (##), and <Release> tags const combinedRegex = /(```[\s\S]*?```)|^(#{2,4})\s(.+)$|<Release[^>]*version="([^"]+)"/gm; let match; const extractedHeadings = []; while ((match = combinedRegex.exec(rawMdx)) !== null) { // match[1] -> Code block content (ignore) if (match[1]) continue; // match[2] & match[3] -> Markdown headings if (match[2]) { const headingLevel = match[2].length; const headingText = match[3].trim(); const slug = sluggify(headingText); extractedHeadings.push({ level: headingLevel, text: headingText, href: `#${slug}`, }); } // match[4] -> Release component version else if (match[4]) { const version = match[4]; extractedHeadings.push({ level: 2, text: `v${version}`, href: `#${version}`, }); } } return extractedHeadings; } export function getPreviousNext(path: string) { const index = page_routes.findIndex(({ href }) => href == `/${path}`); return { prev: page_routes[index - 1], next: page_routes[index + 1], }; } function sluggify(text: string) { const slug = text.toLowerCase().replace(/\s+/g, "-"); return slug.replace(/[^a-z0-9-]/g, ""); } async function getRawMdx(slug: string) { const commonPath = path.join(process.cwd(), "/docs/"); const paths = [ path.join(commonPath, `${slug}.mdx`), path.join(commonPath, slug, "index.mdx"), ]; for (const p of paths) { try { const content = await fs.readFile(p, "utf-8"); return { content, filePath: `docs/${path.relative(commonPath, p)}`, }; } catch { // ignore and try next } } throw new Error(`Could not find mdx file for slug: ${slug}`); } function justGetFrontmatterFromMD<Frontmatter>(rawMd: string): Frontmatter { return matter(rawMd).data as Frontmatter; } export async function getAllChilds(pathString: string) { const items = pathString.split("/").filter((it) => it !== ""); let page_routes_copy = ROUTES; let prevHref = ""; for (const it of items) { const found = page_routes_copy.find((innerIt) => innerIt.href == `/${it}`); if (!found) break; prevHref += found.href; page_routes_copy = found.items ?? []; } if (!prevHref) return []; return await Promise.all( page_routes_copy.map(async (it) => { const slug = path.join(prevHref, it.href); const { content } = await getRawMdx(slug); return { ...justGetFrontmatterFromMD<BaseMdxFrontmatter>(content), href: `/docs${prevHref}${it.href}`, }; }) ); } // for copying the code in pre const preProcess = () => (tree: Node) => { visit(tree, (node: Node) => { const element = node as Element; if (element?.type === "element" && element?.tagName === "pre" && element.children) { const [codeEl] = element.children as Element[]; if (codeEl.tagName !== "code" || !codeEl.children?.[0]) return; const textNode = codeEl.children[0] as TextNode; if (textNode.type === 'text' && textNode.value) { element.raw = textNode.value; } } }); }; const postProcess = () => (tree: Node) => { visit(tree, "element", (node: Node) => { const element = node as Element; if (element?.type === "element" && element?.tagName === "pre") { if (element.properties && element.raw) { element.properties.raw = element.raw; } } }); };