diff --git a/.github/workflows/check-upstream-notes.yml b/.github/workflows/check-upstream-notes.yml deleted file mode 100644 index 2cd139cb..00000000 --- a/.github/workflows/check-upstream-notes.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Check upstream notes - -on: - pull_request: - paths: - - 'docs/**/*.md' - -jobs: - check-upstream-notes: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 0 - - - name: Check for upstream comments in changed docs - run: | - base="${{ github.event.pull_request.base.sha }}" - head="${{ github.event.pull_request.head.sha }}" - - # Get changed .md files under docs/ - changed=$(git diff --name-only --diff-filter=AM "$base" "$head" -- 'docs/**/*.md') - - if [ -z "$changed" ]; then - echo "No docs markdown files changed." - exit 0 - fi - - # Paths to skip: synced files and stubs - skip_patterns=( - "docs/languages/motoko/" - "docs/guides/tools/migrating-from-dfx.md" - ) - - missing=() - for file in $changed; do - # Skip synced files - skip=false - for pattern in "${skip_patterns[@]}"; do - if [[ "$file" == $pattern* ]]; then - skip=true - break - fi - done - if [ "$skip" = true ]; then - continue - fi - - # Skip stubs (contain "TODO: Write content") - if grep -q "TODO: Write content" "$file"; then - continue - fi - - # Check for upstream comment - if ! grep -q ' comment" - done - echo "" - echo "Every non-stub docs page must include one of:" - echo ' ' - echo ' ' - echo ' ' - exit 1 - fi - - echo "All changed docs files have upstream comments." diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 00000000..fd336cf0 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,32 @@ +name: Validate docs + +on: + pull_request: + paths: + - 'docs/**/*.md' + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - run: npm ci + + - name: Validate changed docs + run: | + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + files=$(git diff --name-only --diff-filter=AM "$BASE" "$HEAD" -- 'docs/**/*.md') + if [ -z "$files" ]; then + echo "No docs files changed." + exit 0 + fi + node scripts/validate.js $files diff --git a/docs/guides/frontends/frameworks.md b/docs/guides/frontends/frameworks.md index a93374da..cebca6c0 100644 --- a/docs/guides/frontends/frameworks.md +++ b/docs/guides/frontends/frameworks.md @@ -28,7 +28,7 @@ The asset canister injects an `ic_env` cookie into every HTML response. This coo ## React with Vite -The [hello-world template](../../../getting-started/project-structure.md) uses React with Vite. It demonstrates the full stack: backend canister, auto-generated TypeScript bindings, and a React frontend that reads canister IDs at runtime. +The [hello-world template](../../getting-started/project-structure.md) uses React with Vite. It demonstrates the full stack: backend canister, auto-generated TypeScript bindings, and a React frontend that reads canister IDs at runtime. ### icp.yaml diff --git a/docs/guides/index.md b/docs/guides/index.md index 3917261f..4c8d8333 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -16,7 +16,7 @@ Practical how-to guides organized by development stage. Each guide solves a spec ## Quality and shipping -- **[Testing](testing/testing-strategies.md)** -- Unit testing, integration testing with PocketIC, and end-to-end strategies. +- **[Testing](testing/strategies.md)** -- Unit testing, integration testing with PocketIC, and end-to-end strategies. - **[Canister Management](canister-management/lifecycle.md)** -- Create, upgrade, configure, optimize, fund, deploy, and back up canisters. - **[Security](security/access-management.md)** -- Access control, encryption, data integrity, DoS prevention, and safe upgrades. @@ -29,5 +29,3 @@ Practical how-to guides organized by development stage. Each guide solves a spec ## Developer tools - **[Tools](tools/ai-coding-agents.md)** — AI coding agents with ICP skills, developer tools, and migrating from dfx. - - diff --git a/docs/reference/token-standards.md b/docs/reference/token-standards.md index dba2fab4..d334560d 100644 --- a/docs/reference/token-standards.md +++ b/docs/reference/token-standards.md @@ -100,7 +100,7 @@ type TransferError = variant { | `icrc1:decimals` | `Nat` | `8` | | `icrc1:fee` | `Nat` | `10000` | -For a few well-known ledger canister IDs and index canisters, see [Token ledgers](../../guides/defi/token-ledgers.md#well-known-token-ledgers). For a broader overview of tokens on ICP, see the [ICP Dashboard token list](https://dashboard.internetcomputer.org/tokens). +For a few well-known ledger canister IDs and index canisters, see [Token ledgers](../guides/defi/token-ledgers.md#well-known-token-ledgers). For a broader overview of tokens on ICP, see the [ICP Dashboard token list](https://dashboard.internetcomputer.org/tokens). [Read the full ICRC-1 standard](https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-1) diff --git a/package.json b/package.json index d46f9ff9..5b83f669 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "astro dev", "build": "astro build", "preview": "astro preview", - "astro": "astro" + "astro": "astro", + "validate": "node scripts/validate.js --all" }, "dependencies": { "@astrojs/starlight": "^0.38.1", diff --git a/scripts/validate.js b/scripts/validate.js new file mode 100644 index 00000000..95d88edb --- /dev/null +++ b/scripts/validate.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { globSync } from 'glob'; +import matter from 'gray-matter'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const DOCS_ROOT = path.join(ROOT, 'docs'); + +const SYNCED = [ + path.join(DOCS_ROOT, 'languages', 'motoko'), + path.join(DOCS_ROOT, 'guides', 'tools', 'migrating-from-dfx.md'), +]; + +function isSynced(file) { + return SYNCED.some(s => file === s || file.startsWith(s + path.sep)); +} + +function isStub(content) { + return content.includes('TODO: Write content'); +} + +function checkUpstream(file, content) { + if (isSynced(file) || isStub(content) || path.basename(file) === 'index.md') return []; + if (!/ comment']; + } + return []; +} + +function checkFrontmatter(file, content) { + if (isSynced(file)) return []; + try { + const { data } = matter(content); + const errors = []; + if (!data.title) errors.push('missing frontmatter: title'); + if (!data.description) errors.push('missing frontmatter: description'); + return errors; + } catch (e) { + return [`invalid frontmatter: ${e.message}`]; + } +} + +const FORBIDDEN = [ + { re: /mo:base/, msg: '"mo:base" is banned — use "mo:core" instead' }, + { re: /internetcomputer\.org\/docs/, msg: 'internetcomputer.org/docs URLs will break — link internally or inline' }, + { re: /docs\.internetcomputer\.org/, msg: 'docs.internetcomputer.org URLs will break — link internally or inline' }, +]; + +function checkForbiddenPatterns(file, content) { + if (isSynced(file)) return []; + const errors = []; + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const { re, msg } of FORBIDDEN) { + if (re.test(line)) errors.push(`line ${i + 1}: ${msg}`); + } + } + return errors; +} + +function checkInternalLinks(file, content) { + if (isSynced(file)) return []; + const errors = []; + const dir = path.dirname(file); + const re = /\[[^\]]*\]\(([^)]+)\)/g; + let m; + while ((m = re.exec(content)) !== null) { + const href = m[1]; + if (href.startsWith('http') || href.startsWith('#') || href.startsWith('/')) continue; + const [linkPath] = href.split('#'); + if (!linkPath?.endsWith('.md')) continue; + const resolved = path.resolve(dir, linkPath); + const resolvedMdx = resolved.replace(/\.md$/, '.mdx'); + if (!fs.existsSync(resolved) && !fs.existsSync(resolvedMdx)) { + errors.push(`broken link: ${href}`); + } + } + return errors; +} + +function validate(file) { + const content = fs.readFileSync(file, 'utf8'); + return [ + ...checkUpstream(file, content), + ...checkFrontmatter(file, content), + ...checkForbiddenPatterns(file, content), + ...checkInternalLinks(file, content), + ]; +} + +const args = process.argv.slice(2); +const useAll = args.includes('--all'); +const fileArgs = args.filter(a => !a.startsWith('--')); + +let files; +if (useAll) { + files = globSync('docs/**/*.md', { cwd: ROOT, absolute: true }); +} else if (fileArgs.length > 0) { + files = fileArgs.map(f => path.isAbsolute(f) ? f : path.resolve(ROOT, f)); +} else { + console.error('Usage: node scripts/validate.js --all | [...]'); + process.exit(1); +} + +let total = 0; +for (const file of files) { + const errors = validate(file); + if (errors.length) { + const rel = path.relative(ROOT, file); + errors.forEach(e => console.error(`${rel}: ${e}`)); + total += errors.length; + } +} + +if (total > 0) { + console.error(`\n${total} error(s) found across ${files.length} file(s).`); + process.exit(1); +} else { + console.log(`Validated ${files.length} file(s) — all checks passed.`); +}