From c3cee25aaab557a4046a9aa1b71cccd209de8922 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Sun, 17 Sep 2023 22:33:10 -0700 Subject: [PATCH] feat: include renderer revamp --- README.md | 20 +++-- package.json | 2 +- src/__tests__/extractSnippets.spec.ts | 2 +- src/__tests__/patterns.spec.ts | 8 +- src/extractSnippets.ts | 9 +- src/patterns.ts | 21 +++-- src/renderer/include.ts | 74 ++++++++-------- src/renderer/markdown.ts | 117 ++++++++++++++++++++++++-- src/types.ts | 2 +- 9 files changed, 180 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 05cca30..9ae0792 100644 --- a/README.md +++ b/README.md @@ -15,23 +15,20 @@ This project was motivated by past experiences dealing with outdated or faulty c Coldsnip can be used as a library, as a CLI or through direct integrations with other platforms. Check the [getting started guide](https://roxlabs.github.io/coldsnip/getting-started/) in order to determine the best option for your needs. -### CLI Commands - - - ### Library - + ```ts const snippets = await extractSnippets([ { path: "src/__tests__", pattern: "snippets/twoSnippets.js" }, ]); ``` + The return type is an map between the key and the snippet information, as detailed bellow: - + ```ts /** * Represents a code snippet extracted from a source file. The field @@ -42,10 +39,14 @@ export interface Snippet { language: string; /** The file path relative to the working directory. */ sourcePath: string; + /** The name of the file, derived from `sourcePath`. */ + filename: string; /** The start line of the snippet. */ startLine: number; /** The end line of the snippet. */ endLine: number; + /** The lines to be highlighted, if any. */ + highlightedLines: number[]; /** The snippet content. Leading spaces are trimmed. */ content: string; /** The link to the file on the remote Git repo when available. */ @@ -65,6 +66,7 @@ export interface Snippet { */ export type Snippets = { [key: string]: Snippet[] }; ``` + ## Roadmap @@ -77,9 +79,9 @@ Contributions are what make the open source community such an amazing place to b 1. Make sure you read our [Code of Conduct](https://github.com/roxlabs/coldsnip/blob/main/CODE_OF_CONDUCT.md) 1. Fork the project and clone your fork 1. Setup the local environment with `npm install` -1. Create a feature branch (`git checkout -b feature/AmazingFeature`) or a bugfix branch (`git checkout -b fix/BoringBug`) -1. Commit the changes (`git commit -m 'Some meaningful message'`) -1. Push to the branch (`git push origin feature/AmazingFeature`) +1. Create a feature branch (`git checkout -b feature/cool-thing`) or a bugfix branch (`git checkout -b fix/bad-bug`) +1. Commit the changes (`git commit -m 'feat: some meaningful message'`) +1. Push to the branch (`git push origin feature/cool-thing`) 1. Open a Pull Request diff --git a/package.json b/package.json index 456feb4..7818704 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "build:cli": "tsc --project tsconfig.cli.json", "build": "npm run build:lib && npm run build:cli", "docs:readme": "cp README.md ./docs/_includes/home.md", - "docs:coldsnip": "./bin/run generate --out docs/snippets.json", + "docs:coldsnip": "./bin/run generate --source=./src --pattern=**/*.ts --format=include", "docs:typedoc": "typedoc --tsconfig tsconfig.lib.json", "docs": "npm run docs:readme & npm run docs:typedoc", "format:imports": "organize-imports-cli tsconfig.json", diff --git a/src/__tests__/extractSnippets.spec.ts b/src/__tests__/extractSnippets.spec.ts index 78e6eae..acc60fc 100644 --- a/src/__tests__/extractSnippets.spec.ts +++ b/src/__tests__/extractSnippets.spec.ts @@ -17,7 +17,7 @@ describe("the extractSnippets public API test suite", () => { }); it("should extract two snippets", async () => { - // @snippet:start(readme.lib) + // @snippet:start("readme.lib") const snippets = await extractSnippets([ { path: "src/__tests__", pattern: "snippets/twoSnippets.js" }, ]); diff --git a/src/__tests__/patterns.spec.ts b/src/__tests__/patterns.spec.ts index 0a1c19a..cf6636a 100644 --- a/src/__tests__/patterns.spec.ts +++ b/src/__tests__/patterns.spec.ts @@ -1,11 +1,9 @@ -import exp from "constants"; import { isSnippetStartTag, matchesEndTag, matchesStartTag, parseValue, } from "../patterns"; -import { parse } from "path"; describe("the pattern matching test suite", () => { it("should match the start tag with a key", () => { @@ -47,13 +45,13 @@ describe("the pattern matching test suite", () => { it("should match the snippet end tag", () => { // SQL style comments const comment = "-- @snippet:end"; - expect(matchesEndTag(comment)).toEqual({ name: "snippet" }); + expect(matchesEndTag(comment)).toEqual({ name: "snippet", indent: "" }); }); it("should match the highlight end tag", () => { // Python style comments - const comment = "# @highlight:end"; - expect(matchesEndTag(comment)).toEqual({ name: "highlight" }); + const comment = " # @highlight:end"; + expect(matchesEndTag(comment)).toEqual({ name: "highlight", indent: " " }); }); it("should parse values correctly", () => { diff --git a/src/extractSnippets.ts b/src/extractSnippets.ts index 5aa95c0..8f426d3 100644 --- a/src/extractSnippets.ts +++ b/src/extractSnippets.ts @@ -119,15 +119,8 @@ async function extractSnippetFromFile( key = openTag.args.id; qualifier = openTag.args.qualifier; startLine = lineNumber + 1; - } else if ( - lineContent.includes("@highlight") || - lineContent.includes("@highlight:start") - ) { - // TODO improve error state detection - throw new Error( - `Invalid state: '@highlight' or '@highlight:start' found outside a snippet at line ${lineNumber}`, - ); } + // TODO improve edge case handling and invalid tag handling break; case "INSIDE_SNIPPET": diff --git a/src/patterns.ts b/src/patterns.ts index 3d30153..f056c75 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -1,18 +1,20 @@ -export const COMMENT_TOKEN = /(\/\/|\/\*|#|--|\(\*|/; const CODE_FENCE_PATTERN = /^(\s*)```/; const CODE_FENCE = "```"; -type IncludeTag = { - key: string; - language?: string; - qualifier?: string; +type IncludeStartTag = OpenTag & { + args: { + id: string; + language?: string; + qualifier?: string; + } & CodeBlockOptions; }; -type FileRenderStep = "content" | "tag" | "codeblock"; - -function parseIncludeTag(value: string): IncludeTag { - const [key, language, qualifier] = value - .split(",") - .map((item) => item.trim()); - if (!key || key.length === 0) { - throw Error(""); - } - return { key, language, qualifier }; +export function isIncludeStartTag( + tag: OpenTag | undefined, +): tag is IncludeStartTag { + return ( + tag !== undefined && + tag.name === "include" && + typeof tag.args?.id === "string" + ); } +type FileRenderStep = "content" | "tag"; + type ParseInput = { file: string; snippets: Snippets; @@ -51,41 +56,38 @@ async function parseFile(input: ParseInput) { await forEachLine(file, (line) => { const { lineContent } = line; - const includeTagMatch = TAG_PATTERN.exec(lineContent); - const codeFenceMatch = CODE_FENCE_PATTERN.exec(lineContent); - + const includeTag = matchesStartTag(lineContent); switch (step) { case "content": content.push(lineContent); - if (includeTagMatch) { + if (isIncludeStartTag(includeTag)) { shouldRewrite = true; - const [, indent, args] = includeTagMatch; - const tag = parseIncludeTag(args); step = "tag"; + // const indent = includeTag.indent; - const snippet = lookupSnippet(snippets, tag); + const snippet = lookupSnippet(snippets, { + key: includeTag.args.id, + language: includeTag.args.language, + qualifier: includeTag.args.qualifier, + }); if (snippet) { - content.push(indent + CODE_FENCE + snippet.language); - content.push( - ...snippet.content.split("\n").map((value) => indent + value), - ); - content.push(indent + CODE_FENCE); + content.push(codeBlock(snippet, { ...includeTag.args })); } else { - console.warn(`Snippet with key "${tag.key}" not found`); + console.warn(`Snippet with key "${includeTag.args.id}" not found`); } } break; case "tag": - step = codeFenceMatch ? "codeblock" : "content"; - break; - case "codeblock": - step = codeFenceMatch ? "content" : "codeblock"; + if (matchesEndTag(lineContent)?.name === "include") { + step = "content"; + content.push(lineContent); + } break; } }); if (shouldRewrite) { - await writeFile(file, content.join("\n"), { encoding: "utf-8" }); + await writeFile(file, content.join("\n") + EOL, { encoding: "utf-8" }); } } @@ -98,7 +100,11 @@ export default class IncludeRenderer implements Renderer { async render(snippets: Snippets) { const { pattern } = this.options; - const files = await glob(pattern, { cwd: process.cwd(), absolute: true }); + const files = await glob(pattern, { + cwd: process.cwd(), + absolute: true, + ignore: ["./node_modules/**"], + }); const renderingQueue = queue(asyncify(parseFile)); for (const file of files) { diff --git a/src/renderer/markdown.ts b/src/renderer/markdown.ts index 3fc1c09..e309c05 100644 --- a/src/renderer/markdown.ts +++ b/src/renderer/markdown.ts @@ -18,17 +18,118 @@ const DEFAULT_OPTIONS: Options = { outputDir: "./coldsnip/", }; -function codeBlock(snippet: Snippet): string { +/** + * Serialize an array of highlighted line numbers to a string format + * compatible with syntax highlighters. + * + * Given an array of highlighted lines like `[2,5,6,7,15,20,21,23]`, + * the output format will be "2,5-7,15,20-21,23". + * + * @param {number[]} lines - Array of line numbers to serialize. + * @returns {string} Serialized string of highlighted lines. + */ +function formatHighlightedLines(lines: number[]): string { + if (lines.length === 0) { + return ""; + } + + lines.sort((a, b) => a - b); + const serialized: string[] = []; + + let start = lines[0]; + let end = lines[0]; + + for (let i = 1; i < lines.length; i++) { + if (lines[i] === end + 1) { + end = lines[i]; + } else { + if (start === end) { + serialized.push(start.toString()); + } else { + serialized.push(`${start}-${end}`); + } + + start = lines[i]; + end = lines[i]; + } + } + + // Handle the last group + if (start === end) { + serialized.push(start.toString()); + } else { + serialized.push(`${start}-${end}`); + } + + return serialized.join(","); +} + +/** + * Type definition for options that can be passed to the `codeBlock` function. + */ +export type CodeBlockOptions = { + /** + * Whether to highlight specific lines in the code block. + * Defaults to `true`. + */ + highlightLines?: boolean; + /** + * Whether to show line numbers in the code block. + * Defaults to `false`. + */ + showLineNumbers?: boolean; + /** + * Whether to show a permalink to the source code. + * Defaults to `false`. + */ + showPermalink?: boolean; + /** + * Whether to show the filename above the code block. + * Defaults to `false`. + */ + showFilename?: boolean; +}; + +/** + * Generates a Markdown-formatted code block from a code snippet object. + * + * This function takes a `Snippet` object and an optional `CodeBlockOptions` object + * to customize the output. It returns a string containing the Markdown code + * block, ready to be inserted into a Markdown document. + * + * @param {Snippet} snippet - The code snippet object. + * @param {CodeBlockOptions} [options] - Optional configuration options. + * @returns {string} Markdown-formatted code block. + */ +export function codeBlock( + snippet: Snippet, + options: CodeBlockOptions = {}, +): string { + const { + highlightLines = true, + showLineNumbers = false, + showPermalink = false, + showFilename = false, + } = options; let link = ""; - if (snippet.permalink) { + if (showPermalink && snippet.permalink) { link = `[${snippet.sourcePath}#${snippet.startLine}:${snippet.endLine}](${snippet.permalink})`; } - return ` - \`\`\`${snippet.language} - ${snippet.content} - \`\`\` - ${link} - `; + const config = [snippet.language]; + if (highlightLines && snippet.highlightedLines.length > 0) { + config.push(`{${formatHighlightedLines(snippet.highlightedLines)}}`); + } + if (showLineNumbers) { + config.push("showLineNumbers"); + } + if (showFilename) { + config.push(`filename=${snippet.filename}`); + } + + const block = [`\`\`\`${config.join(" ")}`]; + block.push(snippet.content); + block.push("```"); + return block.join("\n"); } async function process(input: ProcessingInput) { diff --git a/src/types.ts b/src/types.ts index 98f7920..e7e0650 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -// @snippet:start(readme.types) +// @snippet:start("readme.types") /** * Represents a code snippet extracted from a source file. The field * `permalink` is only present when the source is from a Git repository.