|
| 1 | +#!/usr/bin/env node |
| 2 | +/* |
| 3 | + generate-md-urls.js |
| 4 | +
|
| 5 | + For each documentation page, generates a clean Markdown file at the |
| 6 | + URL-matching path under static/. This lets users (and LLMs) append |
| 7 | + .md to any docs URL to get raw Markdown. |
| 8 | +
|
| 9 | + Example: |
| 10 | + docs.multiversx.com/developers/overview |
| 11 | + → docs.multiversx.com/developers/overview.md (raw Markdown) |
| 12 | +
|
| 13 | + The files are placed in static/ so Docusaurus copies them as-is to |
| 14 | + the build output. MDX-specific syntax (imports, JSX components, |
| 15 | + Docusaurus comments) is stripped to produce clean, LLM-friendly |
| 16 | + Markdown. |
| 17 | +
|
| 18 | + Usage: node scripts/generate-md-urls.js |
| 19 | +*/ |
| 20 | + |
| 21 | +const fs = require('fs'); |
| 22 | +const fsp = require('fs/promises'); |
| 23 | +const path = require('path'); |
| 24 | + |
| 25 | +const ROOT = path.join(__dirname, '..'); |
| 26 | +const DOCS_DIR = path.join(ROOT, 'docs'); |
| 27 | +const STATIC_DIR = path.join(ROOT, 'static'); |
| 28 | + |
| 29 | +// --------------------------------------------------------------------------- |
| 30 | +// Sidebar parsing |
| 31 | +// --------------------------------------------------------------------------- |
| 32 | + |
| 33 | +function safeRequire(p) { |
| 34 | + try { |
| 35 | + return require(p); |
| 36 | + } catch { |
| 37 | + return null; |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +function collectDocIdsFromItems(items, acc) { |
| 42 | + if (!items) return; |
| 43 | + for (const it of items) { |
| 44 | + if (typeof it === 'string' || it instanceof String) { |
| 45 | + acc.add(String(it)); |
| 46 | + continue; |
| 47 | + } |
| 48 | + if (it && typeof it === 'object') { |
| 49 | + if (it.type === 'category') { |
| 50 | + if (it.link && it.link.type === 'doc' && it.link.id) { |
| 51 | + acc.add(String(it.link.id)); |
| 52 | + } |
| 53 | + collectDocIdsFromItems(it.items, acc); |
| 54 | + } else if (it.type === 'doc' && it.id) { |
| 55 | + acc.add(String(it.id)); |
| 56 | + } else if (it.id) { |
| 57 | + acc.add(String(it.id)); |
| 58 | + } |
| 59 | + } |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +// --------------------------------------------------------------------------- |
| 64 | +// File resolution (mirrors generate-llms-txt.js logic) |
| 65 | +// --------------------------------------------------------------------------- |
| 66 | + |
| 67 | +async function resolveDocPath(docId) { |
| 68 | + const directMd = path.join(DOCS_DIR, `${docId}.md`); |
| 69 | + const directMdx = path.join(DOCS_DIR, `${docId}.mdx`); |
| 70 | + if (fs.existsSync(directMd)) return directMd; |
| 71 | + if (fs.existsSync(directMdx)) return directMdx; |
| 72 | + |
| 73 | + const dir = path.join(DOCS_DIR, path.dirname(docId)); |
| 74 | + const base = path.basename(docId); |
| 75 | + const kebab = base.replace(/\s+/g, '-'); |
| 76 | + const kebabMd = path.join(dir, `${kebab}.md`); |
| 77 | + const kebabMdx = path.join(dir, `${kebab}.mdx`); |
| 78 | + if (fs.existsSync(kebabMd)) return kebabMd; |
| 79 | + if (fs.existsSync(kebabMdx)) return kebabMdx; |
| 80 | + |
| 81 | + try { |
| 82 | + const entries = await fsp.readdir(dir, { withFileTypes: true }); |
| 83 | + for (const e of entries) { |
| 84 | + if (!e.isFile() || !/\.(md|mdx)$/i.test(e.name)) continue; |
| 85 | + const full = path.join(dir, e.name); |
| 86 | + try { |
| 87 | + const content = await fsp.readFile(full, 'utf8'); |
| 88 | + if (content.startsWith('---')) { |
| 89 | + const end = content.indexOf('\n---', 3); |
| 90 | + if (end !== -1) { |
| 91 | + const block = content.slice(3, end); |
| 92 | + const idm = block.match(/^\s*id:\s*(["']?)(.+?)\1\s*$/m); |
| 93 | + if (idm && idm[2].trim() === base) return full; |
| 94 | + } |
| 95 | + } |
| 96 | + } catch {} |
| 97 | + } |
| 98 | + } catch {} |
| 99 | + |
| 100 | + return null; |
| 101 | +} |
| 102 | + |
| 103 | +// --------------------------------------------------------------------------- |
| 104 | +// Frontmatter helpers |
| 105 | +// --------------------------------------------------------------------------- |
| 106 | + |
| 107 | +function parseFrontmatter(mdContent) { |
| 108 | + const meta = {}; |
| 109 | + if (!mdContent.startsWith('---')) return meta; |
| 110 | + const end = mdContent.indexOf('\n---', 3); |
| 111 | + if (end === -1) return meta; |
| 112 | + const block = mdContent.slice(3, end); |
| 113 | + const pairs = { |
| 114 | + title: block.match(/^\s*title:\s*(["']?)(.+?)\1\s*$/m), |
| 115 | + slug: block.match(/^\s*slug:\s*(["']?)(.+?)\1\s*$/m), |
| 116 | + description: block.match(/^\s*description:\s*(["']?)([\s\S]*?)\1\s*$/m), |
| 117 | + }; |
| 118 | + if (pairs.title) meta.title = pairs.title[2].trim(); |
| 119 | + if (pairs.slug) meta.slug = pairs.slug[2].trim(); |
| 120 | + if (pairs.description) meta.description = pairs.description[2].trim(); |
| 121 | + meta._fmEnd = end + '\n---'.length; |
| 122 | + return meta; |
| 123 | +} |
| 124 | + |
| 125 | +// --------------------------------------------------------------------------- |
| 126 | +// URL path computation (without site URL prefix) |
| 127 | +// --------------------------------------------------------------------------- |
| 128 | + |
| 129 | +async function computeUrlPath(docId) { |
| 130 | + const filePath = await resolveDocPath(docId); |
| 131 | + let defaultPath = `/${docId}`; |
| 132 | + |
| 133 | + if (filePath) { |
| 134 | + const rel = path.relative(DOCS_DIR, filePath).replace(/\\/g, '/'); |
| 135 | + defaultPath = `/${rel.replace(/\.(md|mdx)$/i, '')}`; |
| 136 | + |
| 137 | + try { |
| 138 | + const content = await fsp.readFile(filePath, 'utf8'); |
| 139 | + const fm = parseFrontmatter(content); |
| 140 | + if (fm.slug && fm.slug.startsWith('/')) return fm.slug; |
| 141 | + } catch {} |
| 142 | + } |
| 143 | + |
| 144 | + return defaultPath; |
| 145 | +} |
| 146 | + |
| 147 | +// --------------------------------------------------------------------------- |
| 148 | +// MDX → clean Markdown |
| 149 | +// --------------------------------------------------------------------------- |
| 150 | + |
| 151 | +function cleanMdxContent(content) { |
| 152 | + // Strip frontmatter — we'll prepend our own header |
| 153 | + if (content.startsWith('---')) { |
| 154 | + const end = content.indexOf('\n---', 3); |
| 155 | + if (end !== -1) content = content.slice(end + 4); |
| 156 | + } |
| 157 | + |
| 158 | + // Remove ```mdx-code-block ... ``` fenced blocks (usually wrapping imports) |
| 159 | + content = content.replace(/```mdx-code-block\n[\s\S]*?```\n?/g, ''); |
| 160 | + |
| 161 | + // Remove standalone import statements |
| 162 | + content = content.replace(/^import\s+.+$/gm, ''); |
| 163 | + |
| 164 | + // Remove [comment]: # (...) lines |
| 165 | + content = content.replace(/^\[comment\]:\s*#\s*\(.*\)\s*$/gm, ''); |
| 166 | + |
| 167 | + // Remove JSX wrapper components (Tabs, TabItem) but keep inner content. |
| 168 | + // Opening tags can span multiple lines: <Tabs\n defaultValue=...\n ...> |
| 169 | + content = content.replace(/<Tabs[\s\S]*?>/g, ''); |
| 170 | + content = content.replace(/<\/Tabs>/g, ''); |
| 171 | + content = content.replace(/<TabItem[\s\S]*?>/g, ''); |
| 172 | + content = content.replace(/<\/TabItem>/g, ''); |
| 173 | + |
| 174 | + // Remove other common Docusaurus JSX wrappers |
| 175 | + content = content.replace(/<details[\s\S]*?>/gi, ''); |
| 176 | + content = content.replace(/<\/details>/gi, ''); |
| 177 | + content = content.replace(/<summary[\s\S]*?>/gi, ''); |
| 178 | + content = content.replace(/<\/summary>/gi, ''); |
| 179 | + |
| 180 | + // Collapse 3+ consecutive blank lines into 2 |
| 181 | + content = content.replace(/\n{3,}/g, '\n\n'); |
| 182 | + |
| 183 | + return content.trim(); |
| 184 | +} |
| 185 | + |
| 186 | +// --------------------------------------------------------------------------- |
| 187 | +// Main |
| 188 | +// --------------------------------------------------------------------------- |
| 189 | + |
| 190 | +async function main() { |
| 191 | + const sidebars = safeRequire(path.join(ROOT, 'sidebars.js')); |
| 192 | + if (!sidebars || !sidebars.docs) { |
| 193 | + console.error('Could not load sidebars.js or missing "docs" sidebar.'); |
| 194 | + process.exit(1); |
| 195 | + } |
| 196 | + |
| 197 | + // Collect all doc IDs from every sidebar category |
| 198 | + const allIds = new Set(); |
| 199 | + for (const items of Object.values(sidebars.docs)) { |
| 200 | + collectDocIdsFromItems(items, allIds); |
| 201 | + } |
| 202 | + |
| 203 | + let written = 0; |
| 204 | + let skipped = 0; |
| 205 | + const generatedPaths = []; |
| 206 | + |
| 207 | + for (const docId of allIds) { |
| 208 | + const filePath = await resolveDocPath(docId); |
| 209 | + if (!filePath) { |
| 210 | + skipped++; |
| 211 | + continue; |
| 212 | + } |
| 213 | + |
| 214 | + const urlPath = await computeUrlPath(docId); |
| 215 | + const rawContent = await fsp.readFile(filePath, 'utf8'); |
| 216 | + const fm = parseFrontmatter(rawContent); |
| 217 | + const cleaned = cleanMdxContent(rawContent); |
| 218 | + |
| 219 | + // Build a clean markdown file with a descriptive header |
| 220 | + const lines = []; |
| 221 | + if (fm.title) lines.push(`# ${fm.title}`); |
| 222 | + if (fm.description) lines.push('', `> ${fm.description}`); |
| 223 | + if (lines.length > 0) lines.push(''); |
| 224 | + lines.push(cleaned); |
| 225 | + |
| 226 | + // Write to static/ at the URL-matching path |
| 227 | + const outPath = path.join(STATIC_DIR, `${urlPath}.md`); |
| 228 | + await fsp.mkdir(path.dirname(outPath), { recursive: true }); |
| 229 | + await fsp.writeFile(outPath, lines.join('\n') + '\n', 'utf8'); |
| 230 | + |
| 231 | + generatedPaths.push(`${urlPath}.md`); |
| 232 | + written++; |
| 233 | + } |
| 234 | + |
| 235 | + console.log( |
| 236 | + `generate-md-urls: wrote ${written} files, skipped ${skipped} (unresolved)` |
| 237 | + ); |
| 238 | + |
| 239 | + return generatedPaths; |
| 240 | +} |
| 241 | + |
| 242 | +main().catch((err) => { |
| 243 | + console.error(err); |
| 244 | + process.exit(1); |
| 245 | +}); |
0 commit comments