From 33ad20477d2f0c33f4b3775fe9510d8c187f6eb1 Mon Sep 17 00:00:00 2001 From: Alex Ni <12097569+nialexsan@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:30:16 -0500 Subject: [PATCH] validate snippets --- src/plugins/code-reference.js | 131 ++++++++++++++++++++++++----- src/plugins/code-reference.test.ts | 51 +++++++---- src/plugins/snippets/README.md | 3 + src/plugins/snippets/testfile | 1 + 4 files changed, 148 insertions(+), 38 deletions(-) create mode 100644 src/plugins/snippets/README.md create mode 100644 src/plugins/snippets/testfile diff --git a/src/plugins/code-reference.js b/src/plugins/code-reference.js index 34398c95a3..6146d76c11 100644 --- a/src/plugins/code-reference.js +++ b/src/plugins/code-reference.js @@ -1,10 +1,17 @@ import { visit } from 'unist-util-visit'; import fetch from 'node-fetch'; +import fs from 'fs/promises'; +import path from 'path'; const VALUE_STARTS_WITH = '!from '; const githubReplace = /^(https:\/\/)(www.)?github.com\/(.+)\/blob\/(.+)/; +/** + * + * @param {string} nodeValue + * @returns string + */ const getUrl = (nodeValue) => { const url = nodeValue.replace(VALUE_STARTS_WITH, '').trim(); @@ -64,6 +71,14 @@ export const getSnippetName = (url) => { return snippet; }; +/** + * + * @param {object} params + * @param {string,string | null} params.code + * @param {[string,string] | null} params.lines + * @param {string | null} params.snippetName + * @returns {string} + */ export const getCodeSnippet = ({ code, lines, snippetName }) => { if (lines != null) { const codeLines = code @@ -98,51 +113,121 @@ export const getCodeSnippet = ({ code, lines, snippetName }) => { break; } } - if (startLine != null && endLine != null) { - const codeLines = codeArray.slice(startLine, endLine).join('\n'); - return codeLines; + if (startLine == null) { + throw Error(`Nonexistent snippet: ${snippetName}`); } - return code; + if (endLine == null) { + throw Error(`Unclosed snippet: ${snippetName}`); + } + + const codeLines = codeArray.slice(startLine, endLine).join('\n'); + return codeLines; } return code; }; +/** + * + * @param {string} url + * @param {object} from + * @param {[string,string] | null} from.lines + * @param {string | null} from.snippetName + * @returns {Promise} + */ +export const fetchSnippet = async (url, { lines, snippetName }) => { + const codeResponse = await fetch(url); + if (!codeResponse.ok) { + throw new Error(`Failed to fetch code from ${url}: ${res.statusText}`); + } + const code = await codeResponse.text(); + const snippet = getCodeSnippet({ code, lines, snippetName }); + + return snippet; +}; + +/** + * + * @param {string} url + * @param {string} snippet + * + * @returns {Promise} + */ +export const verifySnippet = async (url, snippet) => { + const fileName = encodeURIComponent(url); + const filePath = path.resolve(`./src/plugins/snippets/${fileName}`); + let fileContent; + try { + const file = await fs.readFile(filePath); + fileContent = file.toString(); + } catch (e) { + if (e.code !== 'ENOENT') { + return false; + } + await fs.writeFile(filePath, snippet); + return true; + } + if (fileContent !== snippet) { + return false; + } + return true; +}; + const plugin = () => { const transformer = async (ast) => { const promises = []; visit(ast, 'code', (node) => { if (node.value?.startsWith(VALUE_STARTS_WITH)) { const url = getUrl(node.value); - if (!url) { + if (!url || typeof url !== 'string') { return; } const lines = getLines(url); const snippetName = getSnippetName(url); - const fetchPromise = fetch(url) - .then(async (res) => { - if (!res.ok) { - throw new Error( - `Failed to fetch code from ${url}: ${res.statusText}`, - ); + const parseSnippetPromise = (async () => { + try { + const snippet = await fetchSnippet(url, { + lines, + snippetName, + }); + if (lines != null || snippetName != null) { + const isVerifiedSnippet = await verifySnippet(url, snippet); + if (!isVerifiedSnippet) { + throw new Error( + `Snippet has changed for URL ${url}. Fix the reference or check the source.`, + ); + } } - return await res.text(); - }) - .then((code) => { - node.value = getCodeSnippet({ code, lines, snippetName }); - }) - .catch((err) => { - console.error(err); - node.value = `Error fetching code: ${err.message}`; - }); - - promises.push(fetchPromise); + node.value = snippet; + } catch (error) { + // Log the error for debugging + console.error( + `Error processing snippet from ${url}:`, + error.message, + ); + // Re-throw the error to propagate it + throw error; + } + })(); + + promises.push(parseSnippetPromise); } }); - await Promise.all(promises); + const results = await Promise.allSettled(promises); + const errors = results + .filter(({ status }) => status === 'rejected') + .map(({ reason }) => reason); + + if (errors.length > 0) { + // Aggregate error messages + const errorMessage = errors.map((err) => err.message).join('\n'); + // Throw a new error with all the messages + throw new Error(`Snippet parsing errors:\n${errorMessage}`); + } }; + return transformer; }; diff --git a/src/plugins/code-reference.test.ts b/src/plugins/code-reference.test.ts index 27f5867a6d..12753a6c80 100644 --- a/src/plugins/code-reference.test.ts +++ b/src/plugins/code-reference.test.ts @@ -1,4 +1,9 @@ -import { getCodeSnippet, getLines, getSnippetName } from './code-reference'; +import { + getCodeSnippet, + getLines, + getSnippetName, + verifySnippet, +} from './code-reference'; jest.mock('node-fetch', () => jest.fn().mockReturnThis()); @@ -83,21 +88,37 @@ function example() { }`); }); - it('should return all code for a non-existent snippet name', () => { - const result = getCodeSnippet({ - code, - lines: null, - snippetName: 'foo_bar', - }); - expect(result).toEqual(code); + it('should throw an error for a non-existent snippet name', () => { + const shouldThrow = (): void => + getCodeSnippet({ + code, + lines: null, + snippetName: 'foo_bar', + }); + expect(shouldThrow).toThrow(); }); - it('should return all code for non matching snippet name', () => { - const result = getCodeSnippet({ - code, - lines: null, - snippetName: 'foo', - }); - expect(result).toEqual(code); + it('should throw an error for a non-matching snippet name', () => { + const shouldThrow = (): void => + getCodeSnippet({ + code, + lines: null, + snippetName: 'foo', + }); + expect(shouldThrow).toThrow(); + }); +}); + +describe('verifySnippet', () => { + it('should create a snippet file', async () => { + const isVerifiedSnippet = await verifySnippet('testfile', 'foo'); + + expect(isVerifiedSnippet).toBe(true); + }); + + it('should create a snippet file', async () => { + const isVerifiedSnippet = await verifySnippet('testfile', 'bar'); + + expect(isVerifiedSnippet).toBe(false); }); }); diff --git a/src/plugins/snippets/README.md b/src/plugins/snippets/README.md new file mode 100644 index 0000000000..8cd3a806af --- /dev/null +++ b/src/plugins/snippets/README.md @@ -0,0 +1,3 @@ +This folder contains autogenerated files for code snippets verification + +Don't edit it manually diff --git a/src/plugins/snippets/testfile b/src/plugins/snippets/testfile new file mode 100644 index 0000000000..1910281566 --- /dev/null +++ b/src/plugins/snippets/testfile @@ -0,0 +1 @@ +foo \ No newline at end of file