From c7d4909dee3904e44d28c70b37dfb44b2b556909 Mon Sep 17 00:00:00 2001 From: autocarl Date: Sat, 4 Jul 2026 15:24:50 -0400 Subject: [PATCH 1/6] ci: add docs validation script --- package.json | 1 + scripts/validate-docs.mjs | 178 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 scripts/validate-docs.mjs 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..5b5cb63 --- /dev/null +++ b/scripts/validate-docs.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const errors = []; + +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); + } + + 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 projectIndex = args.indexOf('--project'); + const noBuildIndex = args.indexOf('--no-build'); + if (noBuildIndex === -1 || noBuildIndex > projectIndex) { + fail( + block.file, + block.startLine, + `MCP host server "${name}" runs a .csproj with dotnet run but does not place "--no-build" before "--project".` + ); + } + } + } +} + +function validateMcpHostDocs(file, text) { + if (/Repl\.Mcp\s+is\s+(?:an?|the)\s+MCP server/i.test(text)) { + fail(file, 1, '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 (text.includes('Repl.Mcp') && /Repl\.Mcp\s+is\s+(?:an?|the)\s+MCP server/i.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).`); From 362879d28d48f4c8f7656cd4a180c30d15d2429a Mon Sep 17 00:00:00 2001 From: autocarl Date: Sun, 5 Jul 2026 08:07:29 -0400 Subject: [PATCH 2/6] ci: address docs validation review feedback --- scripts/validate-docs.mjs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/validate-docs.mjs b/scripts/validate-docs.mjs index 5b5cb63..1df6ee3 100644 --- a/scripts/validate-docs.mjs +++ b/scripts/validate-docs.mjs @@ -59,6 +59,10 @@ function extractJsonBlocks(file, text) { if (inJson) buffer.push(line); } + if (inJson) { + fail(file, startLine - 1, 'JSON code fence must be closed with ``` before end of file.'); + } + return blocks; } @@ -100,13 +104,14 @@ function validateServerMap(block, serverMap, key) { && args.some((arg) => typeof arg === 'string' && arg.endsWith('.csproj')); if (launchesDotnetProject) { - const projectIndex = args.indexOf('--project'); const noBuildIndex = args.indexOf('--no-build'); - if (noBuildIndex === -1 || noBuildIndex > projectIndex) { + 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 place "--no-build" before "--project".` + `MCP host server "${name}" runs a .csproj with dotnet run but does not include "--no-build" before the "--" application-argument separator.` ); } } @@ -115,7 +120,7 @@ function validateServerMap(block, serverMap, key) { function validateMcpHostDocs(file, text) { if (/Repl\.Mcp\s+is\s+(?:an?|the)\s+MCP server/i.test(text)) { - fail(file, 1, 'Describe Repl.Mcp as the component used to build MCP servers, not as the MCP server itself.'); + 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')) { From 2a89d6796aa688a3c45e8ce127ca1b7c48ecd097 Mon Sep 17 00:00:00 2001 From: autocarl Date: Sun, 5 Jul 2026 08:21:33 -0400 Subject: [PATCH 3/6] ci: catch inline-code Repl.Mcp wording --- scripts/validate-docs.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/validate-docs.mjs b/scripts/validate-docs.mjs index 1df6ee3..9e86474 100644 --- a/scripts/validate-docs.mjs +++ b/scripts/validate-docs.mjs @@ -4,6 +4,7 @@ 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; @@ -119,7 +120,7 @@ function validateServerMap(block, serverMap, key) { } function validateMcpHostDocs(file, text) { - if (/Repl\.Mcp\s+is\s+(?:an?|the)\s+MCP server/i.test(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.'); } @@ -151,7 +152,7 @@ function validateLlmsTxt(file, text) { fail(file, findLine(text, 'For Coding Agents'), 'llms.txt mentions For Coding Agents but does not link the canonical page.'); } - if (text.includes('Repl.Mcp') && /Repl\.Mcp\s+is\s+(?:an?|the)\s+MCP server/i.test(text)) { + 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.'); } } From 6a6f767be8bc11f8f62e6f7ec0e6c1152a603c41 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 08:30:35 -0400 Subject: [PATCH 4/6] ci: wire docs validation and astro build into PR checks autocarl's token lacked the workflow scope needed to push .github/workflows changes; adding the workflow proposed in this PR's description now that a token with that scope is available. Runs on pull_request, push to main, and workflow_dispatch: npm ci, then npm run docs:validate, then an Astro build to catch build regressions. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- .github/workflows/validate-docs.yml | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/validate-docs.yml diff --git a/.github/workflows/validate-docs.yml b/.github/workflows/validate-docs.yml new file mode 100644 index 0000000..f673c20 --- /dev/null +++ b/.github/workflows/validate-docs.yml @@ -0,0 +1,35 @@ +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 + + - name: Build Astro content + run: npm run astro -- build + env: + ASTRO_TELEMETRY_DISABLED: '1' From cd04aca22b3c62700291f8da3f9c8e072cda68df Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 08:38:15 -0400 Subject: [PATCH 5/6] ci: build real API reference in docs validation workflow The workflow proposed in the PR description ran 'npm run astro -- build' directly, which fails: ApiRef.astro imports src/generated/api-reference.json, a file only produced by 'docs:api' from DocFX metadata over the Repl source (confirmed failing in the initial run of this workflow). Mirror the Repl source checkout, .NET setup, and DocFX install already proven in .github/workflows/deploy.yml, then run 'npm run build' (which chains docs:api -> astro build) instead of building Astro alone. Keep 'docs:validate' as an early, fast-failing step before the heavier checkout. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB --- .github/workflows/validate-docs.yml | 32 +++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate-docs.yml b/.github/workflows/validate-docs.yml index f673c20..ab4eb49 100644 --- a/.github/workflows/validate-docs.yml +++ b/.github/workflows/validate-docs.yml @@ -29,7 +29,35 @@ jobs: - name: Validate documentation invariants run: npm run docs:validate - - name: Build Astro content - run: npm run astro -- build + # 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' From 2cfcfde8b9f432750a5885b293e65ed318dde01c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Jul 2026 09:06:26 -0400 Subject: [PATCH 6/6] ci: Removed the daily auto-deploy: it was useless to do it each day --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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