|
| 1 | +import * as fs from 'node:fs/promises'; |
| 2 | +import {existsSync, readFileSync} from 'node:fs'; |
| 3 | +import path from 'node:path'; |
| 4 | +import fg from 'fast-glob'; |
| 5 | +import matter from 'gray-matter'; |
| 6 | +import remark from 'remark'; |
| 7 | +import remarkStringify from 'remark-stringify'; |
| 8 | +import remarkMdx from 'remark-mdx'; |
| 9 | +import visit from 'unist-util-visit'; |
| 10 | + |
| 11 | +const CONTENT_ROOT = './src/content'; |
| 12 | +const BASE_URL = 'https://react.dev'; |
| 13 | + |
| 14 | +type SidebarSection = 'learn' | 'reference'; |
| 15 | +type ExtraSectionName = Extract<SectionName, 'warnings' | 'errors'>; |
| 16 | + |
| 17 | +const SIDEBAR_CONFIGS: Array<{file: string; section: SidebarSection}> = [ |
| 18 | + {file: 'src/sidebarLearn.json', section: 'learn'}, |
| 19 | + {file: 'src/sidebarReference.json', section: 'reference'}, |
| 20 | +]; |
| 21 | + |
| 22 | +export const SECTION_ORDER = [ |
| 23 | + 'learn', |
| 24 | + 'reference', |
| 25 | + 'warnings', |
| 26 | + 'errors', |
| 27 | +] as const; |
| 28 | +export type SectionName = typeof SECTION_ORDER[number]; |
| 29 | + |
| 30 | +const EXTRA_SECTIONS: Array<{section: ExtraSectionName; glob: string}> = [ |
| 31 | + {section: 'warnings', glob: `${CONTENT_ROOT}/warnings/**/*.md`}, |
| 32 | + {section: 'errors', glob: `${CONTENT_ROOT}/errors/**/*.md`}, |
| 33 | +]; |
| 34 | + |
| 35 | +let docsBySectionPromise: Promise<Record<SectionName, string[]>> | null = null; |
| 36 | + |
| 37 | +export async function scanDocumentationFiles(): Promise<string[]> { |
| 38 | + const bySection = await getDocsBySection(); |
| 39 | + return SECTION_ORDER.flatMap((section) => bySection[section]); |
| 40 | +} |
| 41 | + |
| 42 | +export async function getDocsBySection(): Promise< |
| 43 | + Record<SectionName, string[]> |
| 44 | +> { |
| 45 | + if (!docsBySectionPromise) { |
| 46 | + docsBySectionPromise = buildDocsBySection(); |
| 47 | + } |
| 48 | + return docsBySectionPromise; |
| 49 | +} |
| 50 | + |
| 51 | +async function buildDocsBySection(): Promise<Record<SectionName, string[]>> { |
| 52 | + const sidebarDocs = getSidebarOrderedDocs(); |
| 53 | + const extras = await getExtraSectionDocs(); |
| 54 | + |
| 55 | + return { |
| 56 | + learn: dedupeList(sidebarDocs.learn), |
| 57 | + reference: dedupeList(sidebarDocs.reference), |
| 58 | + warnings: dedupeList(extras.warnings), |
| 59 | + errors: dedupeList(extras.errors), |
| 60 | + }; |
| 61 | +} |
| 62 | + |
| 63 | +export async function parseFileContent(filePath: string) { |
| 64 | + const absolutePath = path.join(process.cwd(), filePath); |
| 65 | + const [fileContent, stats] = await Promise.all([ |
| 66 | + fs.readFile(absolutePath), |
| 67 | + fs.stat(absolutePath), |
| 68 | + ]); |
| 69 | + const parsed = matter(fileContent.toString()); |
| 70 | + const updatedFromFrontmatter = |
| 71 | + parsed.data?.updated || parsed.data?.lastUpdated || parsed.data?.date; |
| 72 | + const updatedAt = updatedFromFrontmatter |
| 73 | + ? new Date(updatedFromFrontmatter).toISOString() |
| 74 | + : stats.mtime.toISOString(); |
| 75 | + return {...parsed, updatedAt}; |
| 76 | +} |
| 77 | + |
| 78 | +export async function processMarkdownContent(content: string): Promise<string> { |
| 79 | + const file = await remark() |
| 80 | + // @ts-expect-error remark-mdx has mismatched typings with remark v12 |
| 81 | + .use(remarkMdx) |
| 82 | + .use(stripMdxElements) |
| 83 | + // @ts-expect-error remark-stringify typings expect older processor signatures |
| 84 | + .use(remarkStringify, { |
| 85 | + bullet: '-', |
| 86 | + fences: true, |
| 87 | + }) |
| 88 | + .process(content); |
| 89 | + |
| 90 | + return collapseWhitespace(String(file)); |
| 91 | +} |
| 92 | + |
| 93 | +export function formatFilePath(filePath: string): string { |
| 94 | + const normalized = filePath.replace(/\\/g, '/'); |
| 95 | + const withoutPrefix = normalized.replace(/^\.?(?:\/)?src\/content/, ''); |
| 96 | + const withLeadingSlash = withoutPrefix.startsWith('/') |
| 97 | + ? withoutPrefix |
| 98 | + : `/${withoutPrefix}`; |
| 99 | + return withLeadingSlash.replace(/\.mdx?$/, '.md'); |
| 100 | +} |
| 101 | + |
| 102 | +export function formatMarkdownUrl(filePath: string): string { |
| 103 | + const pathWithExt = formatFilePath(filePath); |
| 104 | + const markdownPath = pathWithExt.endsWith('/index.md') |
| 105 | + ? pathWithExt.replace(/\/index\.md$/, '.md') |
| 106 | + : pathWithExt; |
| 107 | + return `${BASE_URL}${markdownPath}`; |
| 108 | +} |
| 109 | + |
| 110 | +export function inferSection(filePath: string): string { |
| 111 | + const relative = formatFilePath(filePath).replace(/^\//, ''); |
| 112 | + return relative.split('/')[0] || 'root'; |
| 113 | +} |
| 114 | + |
| 115 | +function getSidebarOrderedDocs(): Record<SidebarSection, string[]> { |
| 116 | + const docs: Record<SidebarSection, string[]> = { |
| 117 | + learn: [], |
| 118 | + reference: [], |
| 119 | + }; |
| 120 | + for (const {file, section} of SIDEBAR_CONFIGS) { |
| 121 | + const config = loadJSON(file); |
| 122 | + const routes = config.routes || []; |
| 123 | + collectFromRoutes(routes, docs[section]); |
| 124 | + } |
| 125 | + return docs; |
| 126 | +} |
| 127 | + |
| 128 | +function collectFromRoutes(routes: any[], docs: string[]) { |
| 129 | + for (const route of routes) { |
| 130 | + if (route?.hasSectionHeader) continue; |
| 131 | + |
| 132 | + if (route?.path) { |
| 133 | + const filePath = sidebarPathToFile(route.path); |
| 134 | + if (filePath && existsSync(path.join(process.cwd(), filePath))) { |
| 135 | + docs.push(filePath); |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + if (route?.routes?.length) { |
| 140 | + collectFromRoutes(route.routes, docs); |
| 141 | + } |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +function sidebarPathToFile(urlPath: string): string | null { |
| 146 | + const cleaned = urlPath.replace(/^\/+/, ''); |
| 147 | + if (!cleaned) { |
| 148 | + return null; |
| 149 | + } |
| 150 | + |
| 151 | + const parts = cleaned.split('/'); |
| 152 | + if (parts.length === 1) { |
| 153 | + return `${CONTENT_ROOT}/${parts[0]}/index.md`; |
| 154 | + } |
| 155 | + |
| 156 | + return `${CONTENT_ROOT}/${parts.join('/')}.md`; |
| 157 | +} |
| 158 | + |
| 159 | +async function getExtraSectionDocs(): Promise< |
| 160 | + Record<ExtraSectionName, string[]> |
| 161 | +> { |
| 162 | + const files: Record<ExtraSectionName, string[]> = { |
| 163 | + warnings: [], |
| 164 | + errors: [], |
| 165 | + }; |
| 166 | + for (const {glob: pattern, section} of EXTRA_SECTIONS) { |
| 167 | + const matches = await fg(pattern, { |
| 168 | + cwd: process.cwd(), |
| 169 | + dot: false, |
| 170 | + onlyFiles: true, |
| 171 | + }); |
| 172 | + files[section] = matches.sort(); |
| 173 | + } |
| 174 | + return files; |
| 175 | +} |
| 176 | + |
| 177 | +function loadJSON(relativePath: string) { |
| 178 | + const absolute = path.join(process.cwd(), relativePath); |
| 179 | + return JSON.parse(readFileSync(absolute, 'utf8')); |
| 180 | +} |
| 181 | + |
| 182 | +function collapseWhitespace(text: string): string { |
| 183 | + return text |
| 184 | + .replace(/\r\n/g, '\n') |
| 185 | + .replace(/\n{3,}/g, '\n\n') |
| 186 | + .trim(); |
| 187 | +} |
| 188 | + |
| 189 | +function stripMdxElements() { |
| 190 | + const COMPONENT_NAMES = new Set([ |
| 191 | + 'Intro', |
| 192 | + 'YouWillLearn', |
| 193 | + 'YouWillBuild', |
| 194 | + 'DeepDive', |
| 195 | + 'Note', |
| 196 | + 'Warning', |
| 197 | + 'Hint', |
| 198 | + 'Diagram', |
| 199 | + 'Recipe', |
| 200 | + 'Sandpack', |
| 201 | + 'Video', |
| 202 | + 'ComponentPreview', |
| 203 | + ]); |
| 204 | + const startsWithUppercase = (name?: string) => !!name && /^[A-Z]/.test(name); |
| 205 | + |
| 206 | + return (tree: any) => { |
| 207 | + visit(tree as any, (node: any, index: number | null, parent: any) => { |
| 208 | + if (!parent || typeof index !== 'number') { |
| 209 | + return; |
| 210 | + } |
| 211 | + |
| 212 | + if (node.type === 'mdxjsEsm') { |
| 213 | + parent.children.splice(index, 1); |
| 214 | + return [visit.SKIP, index]; |
| 215 | + } |
| 216 | + |
| 217 | + if ( |
| 218 | + node.type === 'mdxJsxFlowElement' || |
| 219 | + node.type === 'mdxJsxTextElement' |
| 220 | + ) { |
| 221 | + const name: string | undefined = node.name; |
| 222 | + if (startsWithUppercase(name) || (name && COMPONENT_NAMES.has(name))) { |
| 223 | + if (node.children && node.children.length > 0) { |
| 224 | + parent.children.splice(index, 1, ...node.children); |
| 225 | + } else { |
| 226 | + parent.children.splice(index, 1); |
| 227 | + } |
| 228 | + return [visit.SKIP, index]; |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + return undefined; |
| 233 | + }); |
| 234 | + |
| 235 | + return tree; |
| 236 | + }; |
| 237 | +} |
| 238 | + |
| 239 | +function dedupeList(list: string[]): string[] { |
| 240 | + const seen = new Set<string>(); |
| 241 | + const result: string[] = []; |
| 242 | + for (const item of list) { |
| 243 | + if (!seen.has(item)) { |
| 244 | + seen.add(item); |
| 245 | + result.push(item); |
| 246 | + } |
| 247 | + } |
| 248 | + return result; |
| 249 | +} |
0 commit comments