diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 232a2de..f04b1ca 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,8 +6,8 @@ on: workflow_dispatch: repository_dispatch: types: [repl-released] - schedule: - - cron: '0 6 * * *' +# schedule: +# - cron: '0 6 * * *' permissions: contents: read diff --git a/.github/workflows/validate-docs.yml b/.github/workflows/validate-docs.yml new file mode 100644 index 0000000..ab4eb49 --- /dev/null +++ b/.github/workflows/validate-docs.yml @@ -0,0 +1,63 @@ +name: Validate docs + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + docs: + runs-on: ubuntu-latest + + steps: + - name: Checkout website + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install npm dependencies + run: npm ci + + - name: Validate documentation invariants + run: npm run docs:validate + + # The Astro build needs generated API-reference content (src/generated/api-reference.json), + # produced by `docs:api` from DocFX metadata over the Repl source — mirrors the Repl source + # checkout and DocFX setup already proven in .github/workflows/deploy.yml. + - name: Resolve latest Repl release tag + id: latest-tag + run: | + tag=$(gh release list --repo yllibed/repl --limit 1 --json tagName -q '.[0].tagName') + echo "Resolved tag: $tag" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Checkout Repl source at release tag + uses: actions/checkout@v4 + with: + repository: yllibed/repl + ref: ${{ steps.latest-tag.outputs.tag }} + path: ./repl-source + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + - name: Install DocFX + run: dotnet tool install -g docfx + + - name: Build API reference and Astro site + run: npm run build + env: + ASTRO_TELEMETRY_DISABLED: '1' diff --git a/package.json b/package.json index 3d4b6b2..30ba80a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "astro dev", "docs:api": "node ./scripts/build-api-docs.mjs", "docs:api:mdx": "node ./scripts/generate-api-mdx.mjs", + "docs:validate": "node ./scripts/validate-docs.mjs", "prebuild": "npm run docs:api", "build": "astro build", "preview": "astro preview", diff --git a/scripts/validate-docs.mjs b/scripts/validate-docs.mjs new file mode 100644 index 0000000..9e86474 --- /dev/null +++ b/scripts/validate-docs.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const errors = []; +const replMcpAsServerPattern = /`?Repl\.Mcp`?[\s`'".,;:)\]]+\bis\s+(?:an?|the)\s+MCP server/i; + +function fail(file, line, message) { + const location = line ? `${file}:${line}` : file; + errors.push(`${location}\n ${message}`); +} + +function readText(file) { + return fs.readFileSync(path.join(repoRoot, file), 'utf8'); +} + +function walk(dir) { + const absolute = path.join(repoRoot, dir); + if (!fs.existsSync(absolute)) return []; + const entries = fs.readdirSync(absolute, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const relative = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (['.git', 'node_modules', 'dist', '.astro'].includes(entry.name)) continue; + files.push(...walk(relative)); + } else if (/\.(mdx|md|txt)$/.test(entry.name)) { + files.push(relative.replaceAll(path.sep, '/')); + } + } + return files; +} + +function extractJsonBlocks(file, text) { + const lines = text.split(/\r?\n/); + const blocks = []; + let inJson = false; + let startLine = 0; + let buffer = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + + if (!inJson && /^```json\b/.test(trimmed)) { + inJson = true; + startLine = index + 2; + buffer = []; + continue; + } + + if (inJson && trimmed === '```') { + blocks.push({ file, startLine, text: buffer.join('\n') }); + inJson = false; + buffer = []; + continue; + } + + if (inJson) buffer.push(line); + } + + if (inJson) { + fail(file, startLine - 1, 'JSON code fence must be closed with ``` before end of file.'); + } + + return blocks; +} + +function validateServerConfig(block) { + const relevant = /"(?:mcpServers|servers)"|McpServerSample\.csproj/.test(block.text); + if (!relevant) return; + + let parsed; + try { + parsed = JSON.parse(block.text); + } catch (error) { + fail(block.file, block.startLine, `MCP host JSON must be valid copy/paste JSON: ${error.message}`); + return; + } + + validateServerMap(block, parsed.mcpServers, 'mcpServers'); + validateServerMap(block, parsed.servers, 'servers'); +} + +function validateServerMap(block, serverMap, key) { + if (!serverMap || typeof serverMap !== 'object' || Array.isArray(serverMap)) return; + + for (const [name, server] of Object.entries(serverMap)) { + if (!server || typeof server !== 'object') continue; + + if (key === 'servers' && server.command && server.type !== 'stdio') { + fail( + block.file, + block.startLine, + `VS Code-style server "${name}" uses top-level "servers" but is missing "type": "stdio".` + ); + } + + const command = server.command; + const args = Array.isArray(server.args) ? server.args : []; + const launchesDotnetProject = command === 'dotnet' + && args.includes('run') + && args.includes('--project') + && args.some((arg) => typeof arg === 'string' && arg.endsWith('.csproj')); + + if (launchesDotnetProject) { + const noBuildIndex = args.indexOf('--no-build'); + const dashDashIndex = args.indexOf('--'); + const hasNoBuildOption = noBuildIndex !== -1 && (dashDashIndex === -1 || noBuildIndex < dashDashIndex); + if (!hasNoBuildOption) { + fail( + block.file, + block.startLine, + `MCP host server "${name}" runs a .csproj with dotnet run but does not include "--no-build" before the "--" application-argument separator.` + ); + } + } + } +} + +function validateMcpHostDocs(file, text) { + if (replMcpAsServerPattern.test(text)) { + fail(file, findLine(text, 'Repl.Mcp'), 'Describe Repl.Mcp as the component used to build MCP servers, not as the MCP server itself.'); + } + + if (file.endsWith('cookbook/mcp-server.mdx') && text.includes('## Agent-host setup')) { + const section = text.split('## Agent-host setup', 2)[1].split('\n## ', 1)[0]; + + if (!section.includes('dotnet build samples/08-mcp-server/McpServerSample.csproj')) { + fail(file, findLine(text, '## Agent-host setup'), 'Agent-host setup should tell users to build the local sample before configuring an MCP host.'); + } + + if (!section.includes('"--no-build"')) { + fail(file, findLine(text, '## Agent-host setup'), 'Agent-host setup must use "--no-build" for dotnet-run host configs.'); + } + + if (!section.includes('"repl-contacts-sample"')) { + fail(file, findLine(text, '## Agent-host setup'), 'Agent-host setup should use the shared sample server name "repl-contacts-sample".'); + } + + if (section.includes('.vscode/mcp.json') && !section.includes('"servers"')) { + fail(file, findLine(text, '.vscode/mcp.json'), 'VS Code .vscode/mcp.json examples must use top-level "servers", not only "mcpServers".'); + } + } +} + +function validateLlmsTxt(file, text) { + if (!file.endsWith('llms.txt')) return; + + if (text.includes('For Coding Agents') && !text.includes('/getting-started/for-coding-agents/')) { + fail(file, findLine(text, 'For Coding Agents'), 'llms.txt mentions For Coding Agents but does not link the canonical page.'); + } + + if (replMcpAsServerPattern.test(text)) { + fail(file, findLine(text, 'Repl.Mcp'), 'llms.txt should say Repl.Mcp is the component; the app is the MCP server.'); + } +} + +function findLine(text, needle) { + const lines = text.split(/\r?\n/); + const index = lines.findIndex((line) => line.includes(needle)); + return index === -1 ? 1 : index + 1; +} + +const files = [ + ...walk('src/content/docs'), + ...walk('public'), +].sort(); + +for (const file of files) { + const text = readText(file); + validateMcpHostDocs(file, text); + validateLlmsTxt(file, text); + for (const block of extractJsonBlocks(file, text)) validateServerConfig(block); +} + +if (errors.length > 0) { + console.error(`Documentation validation failed with ${errors.length} issue(s):\n`); + for (const error of errors) console.error(`- ${error}\n`); + process.exit(1); +} + +console.log(`Documentation validation passed (${files.length} files checked).`);