diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 3f42ffa0..6a152eeb 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -22,7 +22,7 @@ jobs: - name: Setup Tools uses: ./.github/setup - name: Fix formatting - run: pnpm prettier:write + run: pnpm run prettier:write - name: Apply fixes uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 with: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bda00fe7..2f746d0a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -30,6 +30,8 @@ jobs: main-branch-name: main - name: Run Checks run: pnpm run test:pr + - name: Verify Links + run: pnpm run verify-links preview: name: Preview runs-on: ubuntu-latest diff --git a/docs/overview.md b/docs/overview.md index fa62b958..13bb7b4c 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -39,12 +39,12 @@ pnpm add -D @tanstack/vite-config ## Utilities -- [ESLint](./eslint.md) -- [Publish](./publish.md) -- [Vite](./vite.md) +- [ESLint](../eslint.md) +- [Publish](../publish.md) +- [Vite](../vite.md) ## Conventions -- [CI/CD](./ci-cd.md) -- [Dependencies](./dependencies.md) -- [Package Structure](./package-structure.md) +- [CI/CD](../ci-cd.md) +- [Dependencies](../dependencies.md) +- [Package Structure](../package-structure.md) diff --git a/docs/package-structure.md b/docs/package-structure.md index 73074d19..2086da37 100644 --- a/docs/package-structure.md +++ b/docs/package-structure.md @@ -19,7 +19,7 @@ The following structure ensures packages work optimally with our monorepo/Nx wor ### `./vite.config.ts` -- Includes config for Vitest, and for Vite if [@tanstack/config/vite](./vite.md) is used +- Includes config for Vitest, and for Vite if [@tanstack/config/vite](../vite.md) is used ### `./src` diff --git a/package.json b/package.json index da26304c..91a0579f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dev": "pnpm run watch", "prettier": "prettier --experimental-cli --ignore-unknown '**/*'", "prettier:write": "pnpm run prettier --write", + "verify-links": "node scripts/verify-links.ts", "changeset": "changeset", "changeset:version": "changeset version && pnpm install --no-frozen-lockfile && pnpm prettier:write", "changeset:publish": "changeset publish" @@ -39,10 +40,13 @@ "@types/node": "catalog:", "eslint": "catalog:", "jsdom": "catalog:", + "markdown-link-extractor": "catalog:", "nx": "catalog:", "prettier": "catalog:", "publint": "catalog:", "sherif": "catalog:", - "typescript": "catalog:" + "tinyglobby": "catalog:", + "typescript": "catalog:", + "typescript-eslint": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 272ef35b..f6a9e587 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ catalogs: jsonfile: specifier: ^6.1.0 version: 6.1.0 + markdown-link-extractor: + specifier: ^4.0.2 + version: 4.0.2 nx: specifier: ^21.3.1 version: 21.3.1 @@ -99,6 +102,9 @@ catalogs: simple-git: specifier: ^3.28.0 version: 3.28.0 + tinyglobby: + specifier: ^0.2.14 + version: 0.2.14 type-fest: specifier: ^4.41.0 version: 4.41.0 @@ -161,6 +167,9 @@ importers: jsdom: specifier: 'catalog:' version: 26.1.0 + markdown-link-extractor: + specifier: 'catalog:' + version: 4.0.2 nx: specifier: 'catalog:' version: 21.3.1 @@ -173,9 +182,15 @@ importers: sherif: specifier: 'catalog:' version: 1.6.1 + tinyglobby: + specifier: 'catalog:' + version: 0.2.14 typescript: specifier: 'catalog:' version: 5.8.3 + typescript-eslint: + specifier: 'catalog:' + version: 8.37.0(eslint@9.31.0)(typescript@5.8.3) integrations/react: dependencies: @@ -1462,6 +1477,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -1514,6 +1532,13 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.1.2: + resolution: {integrity: sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==} + engines: {node: '>=20.18.1'} + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -1580,6 +1605,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + cssstyle@4.3.1: resolution: {integrity: sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==} engines: {node: '>=18'} @@ -1635,6 +1667,19 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -1661,6 +1706,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -2007,6 +2055,12 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-link-extractor@1.0.5: + resolution: {integrity: sha512-ADd49pudM157uWHwHQPUSX4ssMsvR/yHIswOR5CUfBdK9g9ZYGMhVSE6KZVHJ6kCkR0gH4htsfzU6zECDNVwyw==} + + htmlparser2@10.0.0: + resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -2246,6 +2300,14 @@ packages: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true + markdown-link-extractor@4.0.2: + resolution: {integrity: sha512-5cUOu4Vwx1wenJgxaudsJ8xwLUMN7747yDJX3V/L7+gi3e4MsCm7w5nbrDQQy8nEfnl4r5NV3pDXMAjhGXYXAw==} + + marked@12.0.2: + resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2343,6 +2405,9 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nwsapi@2.2.20: resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} @@ -2422,6 +2487,12 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -2905,6 +2976,10 @@ packages: undici-types@7.8.0: resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + undici@7.12.0: + resolution: {integrity: sha512-GrKEsc3ughskmGA9jevVlIOPMiiAHJ4OFUtaAH+NhfTUSiZ1wMPIQqQvAJUrJspFXJt3EBWgpAeoHEDVT1IBug==} + engines: {node: '>=20.18.1'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -4346,6 +4421,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + boolbase@1.0.0: {} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -4401,6 +4478,29 @@ snapshots: check-error@2.1.1: {} + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.1.2: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.0.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.12.0 + whatwg-mimetype: 4.0.0 + ci-info@3.9.0: {} cli-cursor@3.1.0: @@ -4461,6 +4561,16 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + cssstyle@4.3.1: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -4501,6 +4611,24 @@ snapshots: dependencies: path-type: 4.0.0 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -4523,6 +4651,11 @@ snapshots: emoji-regex@8.0.0: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + end-of-stream@1.4.4: dependencies: once: 1.4.0 @@ -4910,6 +5043,17 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-link-extractor@1.0.5: + dependencies: + cheerio: 1.1.2 + + htmlparser2@10.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 6.0.0 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 @@ -5141,6 +5285,13 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-link-extractor@4.0.2: + dependencies: + html-link-extractor: 1.0.5 + marked: 12.0.2 + + marked@12.0.2: {} + math-intrinsics@1.1.0: {} mdurl@2.0.0: {} @@ -5215,6 +5366,10 @@ snapshots: dependencies: path-key: 3.1.1 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nwsapi@2.2.20: {} nx@21.3.1: @@ -5340,6 +5495,15 @@ snapshots: dependencies: callsites: 3.1.0 + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + parse5@7.3.0: dependencies: entities: 6.0.0 @@ -5749,6 +5913,8 @@ snapshots: undici-types@7.8.0: {} + undici@7.12.0: {} + universalify@0.1.2: {} universalify@2.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f1b10eff..a17c7ad1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,6 +25,7 @@ catalog: jsdom: ^26.1.0 jsonc-eslint-parser: ^2.4.0 jsonfile: ^6.1.0 + markdown-link-extractor: ^4.0.2 nx: ^21.3.1 prettier: ^3.6.2 publint: ^0.3.12 @@ -34,6 +35,7 @@ catalog: semver: ^7.7.2 sherif: ^1.6.1 simple-git: ^3.28.0 + tinyglobby: ^0.2.14 type-fest: ^4.41.0 typedoc: 0.27.9 typedoc-plugin-frontmatter: 1.2.1 diff --git a/scripts/verify-links.ts b/scripts/verify-links.ts new file mode 100644 index 00000000..268a0ac9 --- /dev/null +++ b/scripts/verify-links.ts @@ -0,0 +1,132 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import path, { resolve } from 'node:path' +import { glob } from 'tinyglobby' +// @ts-ignore Could not find a declaration file for module 'markdown-link-extractor'. +import markdownLinkExtractor from 'markdown-link-extractor' + +function isRelativeLink(link: string) { + return ( + link && + !link.startsWith('http://') && + !link.startsWith('https://') && + !link.startsWith('//') && + !link.startsWith('#') && + !link.startsWith('mailto:') + ) +} + +function normalizePath(p: string): string { + // Remove any trailing .md + p = p.replace(`${path.extname(p)}`, '') + return p +} + +function fileExistsForLink( + link: string, + markdownFile: string, + errors: Array, +): boolean { + // Remove hash if present + const filePart = link.split('#')[0] + // If the link is empty after removing hash, it's not a file + if (!filePart) return false + + // Normalize the markdown file path + markdownFile = normalizePath(markdownFile) + + // Normalize the path + const normalizedPath = normalizePath(filePart) + + // Resolve the path relative to the markdown file's directory + let absPath = resolve(markdownFile, normalizedPath) + + // Ensure the resolved path is within /docs + const docsRoot = resolve('docs') + if (!absPath.startsWith(docsRoot)) { + errors.push({ + link, + markdownFile, + resolvedPath: absPath, + reason: 'navigates above /docs, invalid', + }) + return false + } + + // Check if this is an example path + const isExample = absPath.includes('/examples/') + + let exists = false + + if (isExample) { + // Transform /docs/framework/{framework}/examples/ to /examples/{framework}/ + absPath = absPath.replace( + /\/docs\/framework\/([^/]+)\/examples\//, + '/examples/$1/', + ) + // For examples, we want to check if the directory exists + exists = existsSync(absPath) && statSync(absPath).isDirectory() + } else { + // For non-examples, we want to check if the .md file exists + if (!absPath.endsWith('.md')) { + absPath = `${absPath}.md` + } + exists = existsSync(absPath) + } + + if (!exists) { + errors.push({ + link, + markdownFile, + resolvedPath: absPath, + reason: 'not found', + }) + } + return exists +} + +async function findMarkdownLinks() { + // Find all markdown files in docs directory + const markdownFiles = await glob('docs/**/*.md', { + ignore: ['**/node_modules/**'], + }) + + console.log(`Found ${markdownFiles.length} markdown files\n`) + + const errors: Array = [] + + // Process each file + for (const file of markdownFiles) { + const content = readFileSync(file, 'utf-8') + const links: Array = markdownLinkExtractor(content) + + const filteredLinks = links.filter((link: any) => { + if (typeof link === 'string') { + return isRelativeLink(link) + } else if (link && typeof link.href === 'string') { + return isRelativeLink(link.href) + } + return false + }) + + if (filteredLinks.length > 0) { + filteredLinks.forEach((link) => { + const href = typeof link === 'string' ? link : link.href + fileExistsForLink(href, file, errors) + }) + } + } + + if (errors.length > 0) { + console.log(`\n❌ Found ${errors.length} broken links:`) + errors.forEach((err) => { + console.log( + `${err.link}\n in: ${err.markdownFile}\n path: ${err.resolvedPath}\n why: ${err.reason}\n`, + ) + }) + process.exit(1) + } else { + console.log('\n✅ No broken links found!') + } +} + +findMarkdownLinks().catch(console.error) diff --git a/tsconfig.json b/tsconfig.json index 02396232..d35179f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,10 @@ "strict": true, "target": "ES2020" }, - "include": ["eslint.config.js", "prettier.config.js", "tanstack.config.js"] + "include": [ + "eslint.config.js", + "prettier.config.js", + "tanstack.config.js", + "scripts" + ] }