Skip to content

Commit

Permalink
validate snippets
Browse files Browse the repository at this point in the history
  • Loading branch information
nialexsan committed Dec 2, 2024
1 parent 4ea906a commit 33ad204
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 38 deletions.
131 changes: 108 additions & 23 deletions src/plugins/code-reference.js
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string>}
*/
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<boolean>}
*/
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;
};

Expand Down
51 changes: 36 additions & 15 deletions src/plugins/code-reference.test.ts
Original file line number Diff line number Diff line change
@@ -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());

Expand Down Expand Up @@ -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);
});
});
3 changes: 3 additions & 0 deletions src/plugins/snippets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
This folder contains autogenerated files for code snippets verification

Don't edit it manually
1 change: 1 addition & 0 deletions src/plugins/snippets/testfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
foo

0 comments on commit 33ad204

Please sign in to comment.