From 70f5a9f1d465cbe57c4ccdd2215994bd8b9c952c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Wed, 29 Jan 2025 15:28:10 -0300 Subject: [PATCH] feat: create markdown snippets linter (#7431) * feat: create markdown snippets linter * chore: review * chore: rollback test --- apps/site/package.json | 3 +- .../en/learn/modules/how-to-use-streams.md | 68 +++++++------ .../pages/en/learn/test-runner/mocking.md | 9 +- apps/site/scripts/lint-snippets/index.mjs | 96 +++++++++++++++++++ apps/site/turbo.json | 4 + package-lock.json | 1 + package.json | 1 + turbo.json | 2 + 8 files changed, 152 insertions(+), 32 deletions(-) create mode 100755 apps/site/scripts/lint-snippets/index.mjs diff --git a/apps/site/package.json b/apps/site/package.json index 24ca5531520df..9c9d5a018a1b7 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -10,9 +10,10 @@ "deploy": "cross-env NEXT_PUBLIC_STATIC_EXPORT=true npm run build", "check-types": "tsc --noEmit", "lint:js": "eslint \"**/*.{js,mjs,ts,tsx}\"", + "lint:snippets": "node ./scripts/lint-snippets/index.mjs", "lint:md": "eslint \"**/*.md?(x)\" --cache --cache-strategy=content --cache-location=.eslintmdcache", "lint:css": "stylelint \"**/*.css\" --allow-empty-input --cache --cache-strategy=content --cache-location=.stylelintcache", - "lint": "turbo run lint:md lint:js lint:css", + "lint": "turbo run lint:md lint:snippets lint:js lint:css", "lint:fix": "turbo run lint:md lint:js lint:css --no-cache -- --fix", "sync-orama": "node ./scripts/orama-search/sync-orama-cloud.mjs", "storybook": "cross-env NODE_NO_WARNINGS=1 storybook dev -p 6006 --no-open", diff --git a/apps/site/pages/en/learn/modules/how-to-use-streams.md b/apps/site/pages/en/learn/modules/how-to-use-streams.md index 2dca4746165a4..2eb59c87ed5ac 100644 --- a/apps/site/pages/en/learn/modules/how-to-use-streams.md +++ b/apps/site/pages/en/learn/modules/how-to-use-streams.md @@ -248,18 +248,24 @@ class MyStream extends Writable { process.stdout.write(data.toString().toUpperCase() + '\n', cb); } } -const stream = new MyStream(); -for (let i = 0; i < 10; i++) { - const waitDrain = !stream.write('hello'); +async function main() { + const stream = new MyStream(); - if (waitDrain) { - console.log('>> wait drain'); - await once(stream, 'drain'); + for (let i = 0; i < 10; i++) { + const waitDrain = !stream.write('hello'); + + if (waitDrain) { + console.log('>> wait drain'); + await once(stream, 'drain'); + } } + + stream.end('world'); } -stream.end('world'); +// Call the async function +main().catch(console.error); ``` ```mjs @@ -663,15 +669,19 @@ Here's an example demonstrating the use of async iterators with a readable strea const fs = require('node:fs'); const { pipeline } = require('node:stream/promises'); -await pipeline( - fs.createReadStream(import.meta.filename), - async function* (source) { - for await (let chunk of source) { - yield chunk.toString().toUpperCase(); - } - }, - process.stdout -); +async function main() { + await pipeline( + fs.createReadStream(__filename), + async function* (source) { + for await (let chunk of source) { + yield chunk.toString().toUpperCase(); + } + }, + process.stdout + ); +} + +main().catch(console.error); ``` ```mjs @@ -798,18 +808,22 @@ The helper functions are useful if you need to return a Web Stream from a Node.j ```cjs const { pipeline } = require('node:stream/promises'); -const { body } = await fetch('https://nodejs.org/api/stream.html'); +async function main() { + const { body } = await fetch('https://nodejs.org/api/stream.html'); -await pipeline( - body, - new TextDecoderStream(), - async function* (source) { - for await (const chunk of source) { - yield chunk.toString().toUpperCase(); - } - }, - process.stdout -); + await pipeline( + body, + new TextDecoderStream(), + async function* (source) { + for await (const chunk of source) { + yield chunk.toString().toUpperCase(); + } + }, + process.stdout + ); +} + +main().catch(console.error); ``` ```mjs diff --git a/apps/site/pages/en/learn/test-runner/mocking.md b/apps/site/pages/en/learn/test-runner/mocking.md index 9da90c7869f02..fc26422db5fa4 100644 --- a/apps/site/pages/en/learn/test-runner/mocking.md +++ b/apps/site/pages/en/learn/test-runner/mocking.md @@ -149,7 +149,6 @@ This leverages [`mock`](https://nodejs.org/api/test.html#class-mocktracker) from import assert from 'node:assert/strict'; import { before, describe, it, mock } from 'node:test'; - describe('foo', { concurrency: true }, () => { let barMock = mock.fn(); let foo; @@ -157,12 +156,12 @@ describe('foo', { concurrency: true }, () => { before(async () => { const barNamedExports = await import('./bar.mjs') // discard the original default export - .then(({ default, ...rest }) => rest); + .then(({ default: _, ...rest }) => rest); // It's usually not necessary to manually call restore() after each // nor reset() after all (node does this automatically). mock.module('./bar.mjs', { - defaultExport: barMock + defaultExport: barMock, // Keep the other exports that you don't want to mock. namedExports: barNamedExports, }); @@ -173,7 +172,9 @@ describe('foo', { concurrency: true }, () => { }); it('should do the thing', () => { - barMock.mockImplementationOnce(function bar_mock() {/* … */}); + barMock.mockImplementationOnce(function bar_mock() { + /* … */ + }); assert.equal(foo(), 42); }); diff --git a/apps/site/scripts/lint-snippets/index.mjs b/apps/site/scripts/lint-snippets/index.mjs new file mode 100755 index 0000000000000..fd155ce33a774 --- /dev/null +++ b/apps/site/scripts/lint-snippets/index.mjs @@ -0,0 +1,96 @@ +import { readFile } from 'node:fs/promises'; + +import { parse } from 'acorn'; +import { glob } from 'glob'; +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; +import { visit } from 'unist-util-visit'; + +const SUPPORTED_LANGUAGES = ['js', 'mjs', 'cjs']; + +// Initialize the markdown parser +const markdownParser = unified().use(remarkParse); + +/** + * Parse JavaScript code using Acorn + * + * @param {string} code - The code to parse + * @param {string} language - The language identifier + * @returns {void} + * @throws {Error} If parsing fails + */ +function parseJavaScript(code, language) { + parse(code, { + ecmaVersion: 'latest', + sourceType: language === 'cjs' ? 'script' : 'module', + allowReturnOutsideFunction: true, + }); +} + +/** + * Validate code blocks in a markdown file + * + * @param {string} filePath - Path to the markdown file + * @returns {Array<{path: string, position: number, message: string}>} Array of errors + */ +async function validateFile(filePath) { + const errors = []; + + const content = await readFile(filePath, 'utf-8'); + const tree = markdownParser.parse(content); + + visit(tree, 'code', node => { + // TODO: Add TypeScript support + if (!SUPPORTED_LANGUAGES.includes(node.lang)) { + return; + } + + try { + parseJavaScript(node.value, node.lang); + } catch (err) { + errors.push({ + path: filePath, + position: node.position.start.line, + message: err.message, + }); + } + }); + + return errors; +} + +/** + * Print validation errors to console + * + * @param {Array<{path: string, position: number, message: string}>} errors + * @returns {void} + */ +function reportErrors(errors) { + if (errors.length === 0) { + return; + } + + console.error('Errors found in the following files:'); + errors.forEach(({ path, position, message }) => { + console.error(`- ${path}:${position}: ${message}`); + }); +} + +// Get all markdown files +const filePaths = await glob('**/*.md', { + root: process.cwd(), + cwd: 'apps/site/pages/en/learn/', + absolute: true, +}); + +// Validate all files and collect errors +const allErrors = await Promise.all(filePaths.map(validateFile)); + +// Flatten the array of errors +const flattenedErrors = allErrors.flat(); + +// Report errors if any +reportErrors(flattenedErrors); + +// Exit with appropriate code +process.exit(flattenedErrors.length > 0 ? 1 : 0); diff --git a/apps/site/turbo.json b/apps/site/turbo.json index ade8cb87f6e87..3e6e21152528f 100644 --- a/apps/site/turbo.json +++ b/apps/site/turbo.json @@ -108,6 +108,10 @@ "inputs": ["{app,pages}/**/*.{md,mdx}", "*.{md,mdx}"], "outputs": [".eslintmdcache"] }, + "lint:snippets": { + "inputs": ["{app,pages}/**/*.{md,mdx}", "*.{md,mdx}"], + "outputs": [] + }, "lint:css": { "inputs": ["{app,components,layouts,pages,styles}/**/*.css"], "outputs": [".stylelintcache"] diff --git a/package-lock.json b/package-lock.json index d98d323a46b7f..63d93b7745a74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "packages/*" ], "dependencies": { + "acorn": "^8.14.0", "husky": "9.1.7", "lint-staged": "15.3.0", "turbo": "2.3.3" diff --git a/package.json b/package.json index 17ceca5084fac..01d8cb251c6f7 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "prepare": "husky" }, "dependencies": { + "acorn": "^8.14.0", "husky": "9.1.7", "lint-staged": "15.3.0", "turbo": "2.3.3" diff --git a/turbo.json b/turbo.json index 80bcb76f5f7ff..91a077be9babc 100644 --- a/turbo.json +++ b/turbo.json @@ -11,6 +11,7 @@ "lint": { "dependsOn": [ "@node-core/website#lint:md", + "@node-core/website#lint:snippets", "@node-core/website#lint:css", "lint:js" ] @@ -18,6 +19,7 @@ "lint:lint-staged": { "dependsOn": [ "@node-core/website#lint:md", + "@node-core/website#lint:snippets", "@node-core/website#lint:css", "@node-core/website#lint:js" ]