From 71b3398778f7022c950e08b54e512541546433ee Mon Sep 17 00:00:00 2001 From: cc_cece Date: Wed, 12 Aug 2026 23:59:57 +0800 Subject: [PATCH] feat: add AI-friendly Markdown access --- docusaurus.config.ts | 10 +++ package-lock.json | 15 ++++ package.json | 3 +- scripts/generate-llms-index.mjs | 118 ++++++++++++++++++++++++++++++++ static/robots.txt | 4 ++ 5 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 scripts/generate-llms-index.mjs create mode 100644 static/robots.txt diff --git a/docusaurus.config.ts b/docusaurus.config.ts index ab2e7d4..cd70d93 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -22,6 +22,16 @@ const config: Config = { mermaid: true, }, themes: ['@docusaurus/theme-mermaid'], + plugins: [ + [ + 'docusaurus-plugin-copy-page-button', + { + placement: 'article', + enabledActions: ['copy'], + generateMarkdownRoutes: true, + }, + ], + ], future: { v4: true, diff --git a/package-lock.json b/package-lock.json index 0f86473..0944ecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@docusaurus/theme-mermaid": "^3.10.0", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", + "docusaurus-plugin-copy-page-button": "^0.8.4", "katex": "^0.16.45", "prism-react-renderer": "^2.3.0", "react": "^19.0.0", @@ -3496,6 +3497,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.0.tgz", "integrity": "sha512-mgLdQsO8xppnQZc3LPi+Mf+PkPeyxJeIx11AXAq/14fsaMefInQiMEZUUmrc7J+956G/f7MwE7tn8KZgi3iRcA==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/babel": "3.10.0", "@docusaurus/bundler": "3.10.0", @@ -9634,6 +9636,19 @@ "node": ">=6" } }, + "node_modules/docusaurus-plugin-copy-page-button": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/docusaurus-plugin-copy-page-button/-/docusaurus-plugin-copy-page-button-0.8.4.tgz", + "integrity": "sha512-OBWmUEFIqR3BJ5giae6D518b69ZfZQ5HKrazrY2IPA6tDNX2F3m1ejeyE0oqcVcE7GVkE+K0+vJ5T+kIos8u1w==", + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "@docusaurus/core": "^3.0.0", + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", diff --git a/package.json b/package.json index 8fa42dd..7b81b98 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start", - "build": "docusaurus build", + "build": "docusaurus build && node scripts/generate-llms-index.mjs", "check:frontmatter": "node scripts/validate-frontmatter.mjs", "check:encoding": "node scripts/check-encoding.mjs", "check:docs": "npm run check:frontmatter && npm run check:encoding && npm run build", @@ -24,6 +24,7 @@ "@docusaurus/theme-mermaid": "^3.10.0", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", + "docusaurus-plugin-copy-page-button": "^0.8.4", "katex": "^0.16.45", "prism-react-renderer": "^2.3.0", "react": "^19.0.0", diff --git a/scripts/generate-llms-index.mjs b/scripts/generate-llms-index.mjs new file mode 100644 index 0000000..8fa3e8e --- /dev/null +++ b/scripts/generate-llms-index.mjs @@ -0,0 +1,118 @@ +import {readdir, readFile, unlink, writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +const outputDirectory = path.resolve(process.argv[2] ?? 'build'); +const excludedLocaleDirectories = new Set(['en']); + +async function findMarkdownFiles(directory) { + const entries = await readdir(directory, {withFileTypes: true}); + const files = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(directory, entry.name); + return entry.isDirectory() ? findMarkdownFiles(entryPath) : [entryPath]; + }), + ); + + return files.flat().filter((file) => file.endsWith('.md')); +} + +function getTitle(markdown, relativePath) { + const heading = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim(); + if (heading) { + return heading; + } + + return path.basename(relativePath, '.md').replaceAll('-', ' '); +} + +function isExcludedLocale(relativePath) { + const [topLevelDirectory] = relativePath.split(path.sep); + return excludedLocaleDirectories.has(topLevelDirectory); +} + +function getHtmlPath(markdownFile) { + const relativePath = path.relative(outputDirectory, markdownFile); + if (relativePath === 'index.md') { + return path.join(outputDirectory, 'index.html'); + } + + const routePath = relativePath.slice(0, -path.extname(relativePath).length); + return path.join(outputDirectory, routePath, 'index.html'); +} + +async function isDocumentationPage(markdownFile) { + try { + const html = await readFile(getHtmlPath(markdownFile), 'utf8'); + return html.includes('docs-doc-page'); + } catch (error) { + if (error?.code === 'ENOENT') { + return false; + } + throw error; + } +} + +function getSiteOrigin(sitemap) { + const firstLocation = sitemap.match(/(https?:\/\/[^<]+)<\/loc>/)?.[1]; + if (!firstLocation) { + throw new Error('Unable to determine the site URL from build/sitemap.xml'); + } + + return new URL(firstLocation).origin; +} + +const allMarkdownFiles = await findMarkdownFiles(outputDirectory); +const documentationChecks = await Promise.all( + allMarkdownFiles.map((file) => isDocumentationPage(file)), +); +const defaultLocaleFiles = allMarkdownFiles.filter( + (file, index) => + !isExcludedLocale(path.relative(outputDirectory, file)) && + documentationChecks[index], +); +const excludedMarkdownFiles = allMarkdownFiles.filter( + (file) => !defaultLocaleFiles.includes(file), +); + +await Promise.all(excludedMarkdownFiles.map((file) => unlink(file))); + +if (defaultLocaleFiles.length === 0) { + throw new Error('No default-locale Markdown pages were generated'); +} + +const sitemap = await readFile(path.join(outputDirectory, 'sitemap.xml'), 'utf8'); +const siteOrigin = getSiteOrigin(sitemap); +const documents = await Promise.all( + defaultLocaleFiles.map(async (file) => { + const relativePath = path.relative(outputDirectory, file); + const publicPath = relativePath.split(path.sep).join('/'); + const markdown = await readFile(file, 'utf8'); + + return { + title: getTitle(markdown, relativePath), + url: new URL(`/${publicPath}`, siteOrigin).toString(), + }; + }), +); + +documents.sort((left, right) => + left.title.localeCompare(right.title, 'zh-CN'), +); + +const llmsIndex = [ + "# Prism's Docs", + '', + '> Prism-Committee 中文文档索引,为 AI 工具提供可直接读取的 Markdown 内容。', + '', + '## 中文文档', + '', + ...documents.map(({title, url}) => `- [${title}](${url})`), + '', +].join('\n'); + +await writeFile(path.join(outputDirectory, 'llms.txt'), llmsIndex, 'utf8'); + +console.log( + `Generated llms.txt with ${documents.length} Chinese documents; removed ${excludedMarkdownFiles.length} non-Chinese or non-document Markdown routes.`, +); diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..5994c09 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://docs.akihito.dpdns.org/sitemap.xml