From 94cc6d8f117e4d3792e2818f3a34257f290895ae Mon Sep 17 00:00:00 2001 From: Michael Moore <5983927+MichaelMakesGames@users.noreply.github.com> Date: Wed, 13 Nov 2024 23:47:11 -0600 Subject: [PATCH] feat: port `@michael-makes/shiki-colorized-brackets` JSR package (#831) * feat: port @michael-makes/shiki-colorized-brackets JSR package * fix: colorized-brackets tests fail to find fixtures * fix: colorized-brackets test fails to find lang on Windows * fix: colorized-brackets test fails to find lang on Windows (again) * feat: add explicitTrigger option for transformerColorizedBrackets * docs: enable @shikijs/colorized-brackets in its own docs page --- .gitignore | 1 + docs/.vitepress/config.ts | 3 + docs/guide/transformers.md | 2 +- docs/package.json | 1 + docs/packages/colorized-brackets.md | 107 ++++++++++++ packages/colorized-brackets/README.md | 9 ++ packages/colorized-brackets/build.config.ts | 19 +++ packages/colorized-brackets/package.json | 42 +++++ .../colorized-brackets/scripts/prepare.ts | 72 +++++++++ .../src/colorizeBracketTokens.ts | 153 ++++++++++++++++++ packages/colorized-brackets/src/index.ts | 98 +++++++++++ .../src/splitBracketTokens.ts | 111 +++++++++++++ packages/colorized-brackets/src/types.ts | 40 +++++ packages/colorized-brackets/src/utils.ts | 95 +++++++++++ .../test/bracket-customization.test.ts | 51 ++++++ .../test/dual-themes.test.ts | 110 +++++++++++++ .../test/explicit-trigger.test.ts | 57 +++++++ .../colorized-brackets/test/fixtures.test.ts | 75 +++++++++ .../test/fixtures/csharp/generic.cs | 8 + .../test/fixtures/css/basic.css | 9 ++ .../test/fixtures/html/basic.html | 4 + .../test/fixtures/html/embedded.html | 14 ++ .../test/fixtures/java/generic.java | 11 ++ .../test/fixtures/jinja/basic.jinja | 12 ++ .../test/fixtures/liquid/basic.liquid | 16 ++ .../test/fixtures/python/basic.py | 23 +++ .../test/fixtures/rust/generic.rs | 19 +++ .../test/fixtures/rust/turbofish.rs | 12 ++ .../test/fixtures/svelte/embedded.svelte | 35 ++++ .../test/fixtures/ts/angle-brackets.ts | 11 ++ .../test/fixtures/ts/comments.ts | 5 + .../test/fixtures/ts/generic.ts | 9 ++ .../test/fixtures/ts/jsdoc.ts | 16 ++ .../test/fixtures/ts/strings.ts | 14 ++ .../test/fixtures/ts/template.ts | 4 + .../test/fixtures/ts/unexpected.txt | 13 ++ .../test/fixtures/tsx/basic.tsx | 6 + packages/colorized-brackets/test/utils.ts | 89 ++++++++++ pnpm-lock.yaml | 9 ++ 39 files changed, 1384 insertions(+), 1 deletion(-) create mode 100644 docs/packages/colorized-brackets.md create mode 100644 packages/colorized-brackets/README.md create mode 100644 packages/colorized-brackets/build.config.ts create mode 100644 packages/colorized-brackets/package.json create mode 100644 packages/colorized-brackets/scripts/prepare.ts create mode 100644 packages/colorized-brackets/src/colorizeBracketTokens.ts create mode 100644 packages/colorized-brackets/src/index.ts create mode 100644 packages/colorized-brackets/src/splitBracketTokens.ts create mode 100644 packages/colorized-brackets/src/types.ts create mode 100644 packages/colorized-brackets/src/utils.ts create mode 100644 packages/colorized-brackets/test/bracket-customization.test.ts create mode 100644 packages/colorized-brackets/test/dual-themes.test.ts create mode 100644 packages/colorized-brackets/test/explicit-trigger.test.ts create mode 100644 packages/colorized-brackets/test/fixtures.test.ts create mode 100644 packages/colorized-brackets/test/fixtures/csharp/generic.cs create mode 100644 packages/colorized-brackets/test/fixtures/css/basic.css create mode 100644 packages/colorized-brackets/test/fixtures/html/basic.html create mode 100644 packages/colorized-brackets/test/fixtures/html/embedded.html create mode 100644 packages/colorized-brackets/test/fixtures/java/generic.java create mode 100644 packages/colorized-brackets/test/fixtures/jinja/basic.jinja create mode 100644 packages/colorized-brackets/test/fixtures/liquid/basic.liquid create mode 100644 packages/colorized-brackets/test/fixtures/python/basic.py create mode 100644 packages/colorized-brackets/test/fixtures/rust/generic.rs create mode 100644 packages/colorized-brackets/test/fixtures/rust/turbofish.rs create mode 100644 packages/colorized-brackets/test/fixtures/svelte/embedded.svelte create mode 100644 packages/colorized-brackets/test/fixtures/ts/angle-brackets.ts create mode 100644 packages/colorized-brackets/test/fixtures/ts/comments.ts create mode 100644 packages/colorized-brackets/test/fixtures/ts/generic.ts create mode 100644 packages/colorized-brackets/test/fixtures/ts/jsdoc.ts create mode 100644 packages/colorized-brackets/test/fixtures/ts/strings.ts create mode 100644 packages/colorized-brackets/test/fixtures/ts/template.ts create mode 100644 packages/colorized-brackets/test/fixtures/ts/unexpected.txt create mode 100644 packages/colorized-brackets/test/fixtures/tsx/basic.tsx create mode 100644 packages/colorized-brackets/test/utils.ts diff --git a/.gitignore b/.gitignore index 451ecb150..0dde4780b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ logs node_modules temp tmp +packages/colorized-brackets/src/themes.ts packages/shiki/src/langs packages/shiki/src/themes packages/shiki/src/*.json diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6cb30dca3..c177b221a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -4,6 +4,7 @@ import { defineConfig } from 'vitepress' import { withMermaid } from 'vitepress-plugin-mermaid' import { version } from '../../package.json' +import { transformerColorizedBrackets } from '../../packages/colorized-brackets/src' import { transformerMetaWordHighlight, transformerNotationWordHighlight, transformerRemoveNotationEscape } from '../../packages/transformers/src' import { defaultHoverInfoProcessor, transformerTwoslash } from '../../packages/vitepress-twoslash/src/index' import vite from './vite.config' @@ -41,6 +42,7 @@ const INTEGRATIONS: DefaultTheme.NavItemWithLink[] = [ { text: 'Next', link: '/packages/next' }, { text: 'Astro', link: '/packages/astro' }, { text: 'Common Transformers', link: '/packages/transformers' }, + { text: 'Colorized Brackets', link: '/packages/colorized-brackets' }, { text: 'CLI', link: '/packages/cli' }, ] @@ -125,6 +127,7 @@ export default withMermaid(defineConfig({ }, }), transformerRemoveNotationEscape(), + transformerColorizedBrackets({ explicitTrigger: true }), ], }, diff --git a/docs/guide/transformers.md b/docs/guide/transformers.md index f50ff30f4..04b547780 100644 --- a/docs/guide/transformers.md +++ b/docs/guide/transformers.md @@ -28,7 +28,7 @@ const code = await codeToHtml('foo\bar', { }) ``` -We also provide some common transformers for you to use, see [`@shikijs/transforms`](/packages/transformers) for more details. +We also provide some common transformers for you to use, see [`@shikijs/transforms`](/packages/transformers) and [`@shikijs/colorized-brackets](/packages/colorized-brackets) for more details. ## Transformer Hooks diff --git a/docs/package.json b/docs/package.json index f4dd76acb..47dc3805a 100644 --- a/docs/package.json +++ b/docs/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@iconify-json/svg-spinners": "catalog:", + "@shikijs/colorized-brackets": "workspace:*", "@shikijs/transformers": "workspace:*", "@shikijs/twoslash": "workspace:*", "@unocss/reset": "catalog:", diff --git a/docs/packages/colorized-brackets.md b/docs/packages/colorized-brackets.md new file mode 100644 index 000000000..fca353c23 --- /dev/null +++ b/docs/packages/colorized-brackets.md @@ -0,0 +1,107 @@ +--- +outline: deep +--- + +# @shikijs/colorized-brackets + + + +VSCode-style colorized brackets transformer for Shiki. + +## Install + +```bash +npm i -D @shikijs/colorized-brackets +``` + +## Usage + +Add to your Shiki transformers: + +```ts colorize-brackets +import { transformerColorizedBrackets } from '@shikijs/colorized-brackets' +import { codeToHtml } from 'shiki' + +const html = await codeToHtml('let values: number[] = [];', { + lang: 'ts', + theme: 'dark-plus', + transformers: [transformerColorizedBrackets()], +}) +``` + +### Colors + +Brackets are automatically colored according to your Shiki theme (or themes if using [dual themes](https://shiki.style/guide/dual-themes)), with support for all of Shiki's built-in themes. However, you can customize colors if you've added custom themes to Shiki, or if you want to override the colors of a built-in theme: + +```ts colorize-brackets +const html = await codeToHtml('let values: number[] = [];', { + lang: 'ts', + theme: myCustomTheme, + transformers: [transformerColorizedBrackets({ + themes: { + 'my-custom-theme': ['goldenrod', 'blueviolet', 'dodgerblue', 'crimson'], + }, + })], +}) +``` + +The final color is the mismatched bracket color. The other colors are for each "level" of bracket pair. Any valid CSS color can be used. + +If no bracket colors are found for a theme, it falls back to the default `dark-plus` theme. + +### Brackets + +You can customize the bracket pairs: + +```ts colorize-brackets +const transformer = transformerColorizedBrackets({ + bracketPairs: [{ opener: '{', closer: '}' }], +}) +``` + +The above would only colorize `{}` curly brackets. The default config colorizes `[]` square brackets, `{}` curly brackets, `()` parentheses, and `<>` angle brackets (only in TS type annotations). + +For advanced usage, you can specify which TextMate scopes a bracket pair is allowed or denied in, using `scopesAllowList` and `scopesDenyList`. For example, the default config for `<>` angle brackets is: + +```ts colorize-brackets +const bracketPair = { + opener: '<', + closer: '>', + scopesAllowList: [ + 'punctuation.definition.typeparameters.begin.ts', + 'punctuation.definition.typeparameters.end.ts', + ], +} +``` + +### Language-specific Overrides + +All settings can be overridden for specific languages using the `langs` option: + +```ts colorize-brackets +const transformer = transformerColorizedBrackets({ + langs: { ts: myCustomTypescriptConfig }, +}) +``` + +### Explicit Trigger + +If you do not want colorized brackets for all code blocks, you can enable the `explicitTrigger` option: + +```ts colorize-brackets +const transformer = transformerColorizedBrackets({ + explicitTrigger: true, +}) +``` + +Then, only code blocks with the `colorize-brackets` [meta string](/guide/transformers#meta) will have bracket colorizing enabled. + +````md +```ts +// no bracket colorizing +``` + +```ts colorize-brackets +// brackets will be colorized +``` +```` diff --git a/packages/colorized-brackets/README.md b/packages/colorized-brackets/README.md new file mode 100644 index 000000000..61c2fa426 --- /dev/null +++ b/packages/colorized-brackets/README.md @@ -0,0 +1,9 @@ +# @shikijs/colorized-brackets + +VSCode-style colorized brackets transformer for [Shiki](https://github.com/shikijs/shiki). + +[Documentation](https://shiki.style/packages/colorized-brackets) + +## License + +MIT diff --git a/packages/colorized-brackets/build.config.ts b/packages/colorized-brackets/build.config.ts new file mode 100644 index 000000000..6afc6c962 --- /dev/null +++ b/packages/colorized-brackets/build.config.ts @@ -0,0 +1,19 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + entries: [ + 'src/index.ts', + ], + declaration: true, + rollup: { + emitCJS: false, + dts: { + compilerOptions: { + paths: {}, + }, + }, + }, + externals: [ + 'hast', + ], +}) diff --git a/packages/colorized-brackets/package.json b/packages/colorized-brackets/package.json new file mode 100644 index 000000000..9633d775e --- /dev/null +++ b/packages/colorized-brackets/package.json @@ -0,0 +1,42 @@ +{ + "name": "@shikijs/colorized-brackets", + "type": "module", + "version": "1.22.2", + "description": "Collective of common transformers transformers for Shiki", + "author": "Michael Moore ", + "license": "MIT", + "homepage": "https://github.com/shikijs/shiki#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/shikijs/shiki.git", + "directory": "packages/colorized-brackets" + }, + "bugs": "https://github.com/shikijs/shiki/issues", + "keywords": [ + "shiki", + "@shikijs/colorized-brackets" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "unbuild", + "dev": "unbuild --stub", + "prepare": "esno scripts/prepare.ts", + "prepublishOnly": "nr build", + "test": "vitest" + }, + "dependencies": { + "shiki": "workspace:*" + } +} diff --git a/packages/colorized-brackets/scripts/prepare.ts b/packages/colorized-brackets/scripts/prepare.ts new file mode 100644 index 000000000..15b4caba7 --- /dev/null +++ b/packages/colorized-brackets/scripts/prepare.ts @@ -0,0 +1,72 @@ +import fs from 'fs-extra' +import { themes as allThemes } from 'tm-themes' + +async function main() { + // if a theme doesn't define bracket colors, it falls back to these + // from vscode /src/vs/editor/common/core/editorColorRegistry.ts + const vsCodeBaseThemes: Record> = { + light: { + 'editorBracketHighlight.foreground1': '#0431FA', + 'editorBracketHighlight.foreground2': '#319331', + 'editorBracketHighlight.foreground3': '#7B3814', + 'editorBracketHighlight.unexpectedBracket.foreground': + 'rgba(255, 18, 18, 0.8)', + }, + dark: { + 'editorBracketHighlight.foreground1': '#FFD700', + 'editorBracketHighlight.foreground2': '#DA70D6', + 'editorBracketHighlight.foreground3': '#179FFF', + 'editorBracketHighlight.unexpectedBracket.foreground': + 'rgba(255, 18, 18, 0.8)', + }, + lightHighContrast: { + 'editorBracketHighlight.foreground1': '#0431FA', + 'editorBracketHighlight.foreground2': '#319331', + 'editorBracketHighlight.foreground3': '#7B3814', + 'editorBracketHighlight.unexpectedBracket.foreground': '#B5200D', + }, + darkHighContrast: { + 'editorBracketHighlight.foreground1': '#FFD700', + 'editorBracketHighlight.foreground2': '#DA70D6', + 'editorBracketHighlight.foreground3': '#87CEFA', + 'editorBracketHighlight.unexpectedBracket.foreground': + 'rgba(255, 50, 50, 1)', + }, + } + + const themes: Record = {} + for (const t of allThemes) { + const theme = await fs.readJSON(`./node_modules/shiki/node_modules/tm-themes/themes/${t.name}.json`) + const isHighContrast = t.name.includes('high-contrast') + const themeType = theme.type ?? 'dark' + const baseTheme = isHighContrast ? `${themeType}HighContrast` : themeType + const colors: Record = { + ...vsCodeBaseThemes[baseTheme], + ...theme.colors, + } + const bracketTheme = [ + colors['editorBracketHighlight.foreground1'], + colors['editorBracketHighlight.foreground2'], + colors['editorBracketHighlight.foreground3'], + colors['editorBracketHighlight.foreground4'], + colors['editorBracketHighlight.foreground5'], + colors['editorBracketHighlight.foreground6'], + colors['editorBracketHighlight.unexpectedBracket.foreground'], + ].filter(Boolean) + themes[t.name] = bracketTheme + } + + const sorted = Object.fromEntries( + Object.entries(themes).sort((a, b) => a[0].localeCompare(b[0])), + ) + + await fs.writeFile( + './src/themes.ts', + `// Generated by scripts/prepare.ts +export default ${JSON.stringify(sorted, undefined, 2)} as Record +`, + { encoding: 'utf-8' }, + ) +} + +main() diff --git a/packages/colorized-brackets/src/colorizeBracketTokens.ts b/packages/colorized-brackets/src/colorizeBracketTokens.ts new file mode 100644 index 000000000..34313ede1 --- /dev/null +++ b/packages/colorized-brackets/src/colorizeBracketTokens.ts @@ -0,0 +1,153 @@ +import type { CodeOptionsSingleTheme, CodeOptionsThemes, ThemedToken } from 'shiki' +import type { TransformerColorizedBracketsOptions } from './types' +import builtInThemes from './themes' +import { getEmbeddedLang, resolveConfig, shouldIgnoreToken } from './utils' + +export default function colorizeBracketTokens( + tokens: ThemedToken[], + config: TransformerColorizedBracketsOptions, + shikiOptions: CodeOptionsThemes, + lang: string, +): void { + const openerStack: ThemedToken[] = [] + + for (const token of tokens) { + const embeddedLang = getEmbeddedLang(token) + const resolvedConfig = resolveConfig(config, embeddedLang ?? lang) + const openers = new Set( + resolvedConfig.bracketPairs.map(pair => pair.opener), + ) + const closers = new Set( + resolvedConfig.bracketPairs.map(pair => pair.closer), + ) + const closerToOpener = Object.fromEntries( + resolvedConfig.bracketPairs.map(pair => [pair.closer, pair.opener]), + ) + + const pairDefinition = resolvedConfig.bracketPairs.find( + pair => + pair.opener === token.content.trim() + || pair.closer === token.content.trim(), + ) + if ( + !pairDefinition + || shouldIgnoreToken( + token, + pairDefinition.scopesAllowList, + pairDefinition.scopesDenyList, + ) + ) { + continue + } + if (openers.has(token.content.trim())) { + openerStack.push(token) + } + else if (closers.has(token.content.trim())) { + const opener = openerStack + .slice() + .reverse() + .find(t => t.content.trim() === closerToOpener[token.content.trim()]) + if (opener) { + while (openerStack.at(-1) !== opener) { + const unexpected = openerStack.pop() + if (unexpected) { + assignColorToToken( + unexpected, + resolvedConfig.themes, + shikiOptions, + -1, + ) + } + } + openerStack.pop() + assignColorToToken( + token, + resolvedConfig.themes, + shikiOptions, + openerStack.length, + ) + assignColorToToken( + opener, + resolvedConfig.themes, + shikiOptions, + openerStack.length, + ) + } + else { + assignColorToToken(token, resolvedConfig.themes, shikiOptions, -1) + } + } + } + + for (const token of openerStack) { + assignColorToToken( + token, + resolveConfig(config, lang).themes, + shikiOptions, + -1, + ) + } +} + +function assignColorToToken( + token: ThemedToken, + themes: Record, + shikiOptions: CodeOptionsThemes, + level: number, +): void { + if (isSingleTheme(shikiOptions)) { + const themeName + = typeof shikiOptions.theme === 'string' + ? shikiOptions.theme + : shikiOptions.theme.name + token.color = getColor(themes, themeName, level) + } + else { + const { defaultColor = 'light', cssVariablePrefix = '--shiki-' } + = shikiOptions + const styles: string[] = [] + + for (const [colorName, theme] of Object.entries(shikiOptions.themes)) { + const themeName = typeof theme === 'string' ? theme : theme?.name + const cssProperty + = colorName === defaultColor + ? 'color' + : `${cssVariablePrefix}${colorName}` + styles.push(`${cssProperty}:${getColor(themes, themeName, level)}`) + } + + token.htmlStyle = styles.join(';') + } +} + +function isSingleTheme( + shikiOptions: CodeOptionsThemes, +): shikiOptions is CodeOptionsSingleTheme { + return 'theme' in shikiOptions +} + +const DEFAULT_BRACKETS_COLORS = [ + '#FFD700', + '#DA70D6', + '#179FFF', + 'rgba(255, 18, 18, 0.8)', +] + +function getColor( + themes: Record, + themeName: string | undefined, + level: number, +): string { + const colors + = themeName == null + ? DEFAULT_BRACKETS_COLORS + : themes[themeName] ?? builtInThemes[themeName] ?? DEFAULT_BRACKETS_COLORS + + const isUnexpected = level === -1 + if (isUnexpected) { + return colors[colors.length - 1] + } + else { + return colors[level % (colors.length - 1)] + } +} diff --git a/packages/colorized-brackets/src/index.ts b/packages/colorized-brackets/src/index.ts new file mode 100644 index 000000000..467c7300b --- /dev/null +++ b/packages/colorized-brackets/src/index.ts @@ -0,0 +1,98 @@ +import type { + CodeToTokensOptions, + ShikiTransformer, +} from 'shiki' +import type { BracketPair, TransformerColorizedBracketsOptions } from './types' +import colorizeBracketTokens from './colorizeBracketTokens' +import splitBracketTokens from './splitBracketTokens' + +const jinjaLikeBracketPairs: BracketPair[] = [ + { opener: '[', closer: ']' }, + { opener: '{', closer: '}' }, + { opener: '(', closer: ')' }, + { opener: '{{', closer: '}}' }, + { opener: '{%', closer: '%}' }, +] + +/** + * Creates a new bracket colorizer transformer + * + * @example basic usage + * ```ts + * const html = await shiki.codeToHtml(code, { + * lang: 'ts', + * theme: 'dark-plus', + * transformers: [transformerColorizedBrackets()], + * }); + * ``` + * + * @param options + * @param options.themes - custom themes; all Shiki built-in themes are supported without additional configuration + * @param options.bracketPairs - bracket definitions; be default [], {}, (), and <> (TS-only) + * @param options.langs - language-specific overrides for themes and bracketPairs + * @returns Shiki transformer + */ +export function transformerColorizedBrackets( + options: Partial = {}, +): ShikiTransformer { + const config: TransformerColorizedBracketsOptions = { + themes: options.themes ?? {}, + bracketPairs: options.bracketPairs ?? [ + { opener: '[', closer: ']' }, + { opener: '{', closer: '}' }, + { opener: '(', closer: ')' }, + { + opener: '<', + closer: '>', + scopesAllowList: [ + 'punctuation.definition.typeparameters.begin.ts', + 'punctuation.definition.typeparameters.end.ts', + 'entity.name.type.instance.jsdoc', + ], + }, + ], + langs: { + html: { bracketPairs: [] }, + jinja: { bracketPairs: jinjaLikeBracketPairs }, + liquid: { bracketPairs: jinjaLikeBracketPairs }, + ...options.langs, + }, + explicitTrigger: options.explicitTrigger ?? false, + } + + const transformer: ShikiTransformer = { + name: 'colorizedBrackets', + preprocess(code, options) { + if (!isEnabled(config, this.options.meta?.__raw)) { + return + } + + // includeExplanation is a valid option for codeToTokens + // but is missing from the type definition here + (options as CodeToTokensOptions).includeExplanation ||= 'scopeName' + }, + tokens: function transformTokens(tokens) { + if (!isEnabled(config, this.options.meta?.__raw)) { + return + } + + const lang = this.options.lang + + for (let lineIndex = 0; lineIndex < tokens.length; lineIndex++) { + const line = tokens[lineIndex] + const newLine = line.flatMap(token => + splitBracketTokens(token, config, lang), + ) + tokens[lineIndex] = newLine + } + + colorizeBracketTokens(tokens.flat(), config, this.options, lang) + }, + } + return transformer +} + +const EXPLICIT_TRIGGER_REGEX = /(^|\s)colorize-brackets($|\s)/ +function isEnabled(config: TransformerColorizedBracketsOptions, meta: string | undefined): boolean { + return !config.explicitTrigger || meta?.match(EXPLICIT_TRIGGER_REGEX) != null +} diff --git a/packages/colorized-brackets/src/splitBracketTokens.ts b/packages/colorized-brackets/src/splitBracketTokens.ts new file mode 100644 index 000000000..56a789305 --- /dev/null +++ b/packages/colorized-brackets/src/splitBracketTokens.ts @@ -0,0 +1,111 @@ +import type { ThemedToken } from 'shiki' +import type { TransformerColorizedBracketsOptions } from './types' +import { escapeRegExp, getEmbeddedLang, resolveConfig, shouldIgnoreToken } from './utils' + +export default function splitBracketTokens( + rawToken: ThemedToken, + config: TransformerColorizedBracketsOptions, + lang: string, +): ThemedToken[] { + const embeddedLang = getEmbeddedLang(rawToken) + const resolvedConfig = resolveConfig(config, embeddedLang ?? lang) + + if (resolvedConfig.bracketPairs.length === 0 || shouldIgnoreToken(rawToken)) { + return [rawToken] + } + + const bracketsRegExp = new RegExp( + resolvedConfig.bracketPairs + .flatMap(pair => [pair.opener, pair.closer]) + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join('|'), + ) + + const tokens = [rawToken] + while (true) { + const token = tokens.pop() + if (!token) + break // shouldn't be possible, but it makes TS happy + + const match = token?.content.match(bracketsRegExp) + if (!match) { + tokens.push(token) + break + } + + // index is always set since we're not using /g regexp flag, but typescript can't infer that + const matchIndex = match.index ?? 0 + + if (matchIndex > 0) { + tokens.push({ + ...token, + content: token.content.substring(0, matchIndex), + }) + } + tokens.push({ + ...token, + content: match[0], + offset: token.offset + matchIndex, + }) + if (matchIndex + match[0].length < token.content.length) { + tokens.push({ + ...token, + content: token.content.substring(matchIndex + match[0].length), + offset: token.offset + matchIndex + match[0].length, + }) + } + else { + break + } + } + + const explanations = rawToken.explanation ?? [] + let currentExplanationStart = 0 + const explanationsWithStartEnd = (explanations ?? []).map( + (explanation, i) => { + const start = currentExplanationStart + let length = explanation.content.length + + // with shiki option mergeWhitespaces (default true), the leading/trailing whitespaces of the token and explanations do not necessarily match + if (explanations.length === 1) { + length = rawToken.content.length + } + else if (i === 0) { + length + = (rawToken.content.match(/^\s*/)?.[0].length ?? 0) + + explanation.content.trimStart().length + } + else if (i === explanations.length - 1) { + length + = explanation.content.trimEnd().length + + (rawToken.content.match(/\s*$/)?.[0].length ?? 0) + } + currentExplanationStart += length + return { + ...explanation, + start, + end: start + length - 1, + } + }, + ) + for (const token of tokens) { + const tokenStart = token.offset - rawToken.offset + const tokenEnd = tokenStart + token.content.length - 1 + const overlappingExplanations = explanationsWithStartEnd.filter( + explanation => + // token start in explanation range + (tokenStart >= explanation.start && tokenStart <= explanation.end) + // token end in explanation range + || (tokenEnd >= explanation.start && tokenEnd <= explanation.end) + // explanation start in token range + || (explanation.start >= tokenStart && explanation.start <= tokenEnd) + // explanation end in token range + || (explanation.end >= tokenStart && explanation.end <= tokenEnd), + ) + token.explanation = overlappingExplanations.map( + (exp, i) => explanations[i], + ) + } + return tokens +} diff --git a/packages/colorized-brackets/src/types.ts b/packages/colorized-brackets/src/types.ts new file mode 100644 index 000000000..1406459ac --- /dev/null +++ b/packages/colorized-brackets/src/types.ts @@ -0,0 +1,40 @@ +/** + * Colorized brackets plugin config + * + * @property themes - a record of theme names to bracket CSS colors; the final color is the unexpected bracket color + * @property bracketPairs - bracket pair definitions + * @property langs - language-specific configs that are merged with the base config + * @property explicitTrigger - if true, the transformer only runs for code blocks with the `colorize-brackets` meta string + */ +export interface TransformerColorizedBracketsOptions { + themes: Record + bracketPairs: BracketPair[] + langs: Record + explicitTrigger?: boolean +} + +/** + * Language-specific config + * + * @property themes - language-specific theme customizations; if not defined, it uses the theme customizations from the base config + * @property bracketPairs - language-specific bracket pairs; if not defined, it uses the bracket from the base config + */ +export interface ColorizedBracketsLangConfig { + themes?: Record + bracketPairs?: BracketPair[] +} + +/** + * Defines opening and closing brackets, and allowed Textmate scopes + * + * @property opener - the string that opens a bracket pair; multi-character strings are not yet supported + * @property closer - the string that closes a bracket pair; multi-character strings are not yet supported + * @property scopesAllowList - if defined, brackets will only be colored if at least 1 of their scopes matches a scope from this list + * @property scopesDenyList - if defined, brackets will not be colored if any of their scopes match a scope from this list + */ +export interface BracketPair { + opener: string + closer: string + scopesAllowList?: string[] + scopesDenyList?: string[] +} diff --git a/packages/colorized-brackets/src/utils.ts b/packages/colorized-brackets/src/utils.ts new file mode 100644 index 000000000..f676003c3 --- /dev/null +++ b/packages/colorized-brackets/src/utils.ts @@ -0,0 +1,95 @@ +import type { ThemedToken } from 'shiki' +import type { TransformerColorizedBracketsOptions } from './types' + +export function getEmbeddedLang(token: ThemedToken): string | undefined { + return token.explanation?.[0].scopes + .findLast(scope => scope.scopeName.match(/^source.\w+$/)) + ?.scopeName + .split('.')[1] +} + +export function resolveConfig( + config: TransformerColorizedBracketsOptions, + lang: string, +): Omit { + return { + themes: config.langs[lang]?.themes ?? config.themes, + bracketPairs: config.langs[lang]?.bracketPairs ?? config.bracketPairs, + } +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping +export function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string +} + +export function shouldIgnoreToken( + token: ThemedToken, + scopesAllowList?: string[], + scopesDenyList?: string[], +): boolean { + if (!token.explanation) + return true + + const commentLastIndex + = token.explanation?.[0].scopes.findLastIndex(scope => + scope.scopeName.startsWith('comment.'), + ) ?? -1 + const stringLastIndex + = token.explanation?.[0].scopes.findLastIndex(scope => + scope.scopeName.startsWith('string.'), + ) ?? -1 + const embeddedLastIndex + = token.explanation?.[0].scopes.findLastIndex( + scope => + scope.scopeName.startsWith('meta.embedded.') + || scope.scopeName.startsWith('scope.embedded.') + // jsdoc type declarations + || scope.scopeName === 'entity.name.type.instance.jsdoc' + // jsdoc default value declarations + || scope.scopeName === 'variable.other.jsdoc' + // liquid template {{ }} + || scope.scopeName === 'meta.object.liquid', + ) ?? -1 + // skip all comments and strings (but not if a deeper scope match is meta.embedded eg template expressions) + if ( + commentLastIndex > embeddedLastIndex + || stringLastIndex > embeddedLastIndex + ) { + return true + } + + if ( + scopesAllowList + && scopesAllowList.length + && !token.explanation?.some(explanation => + explanation.scopes.some(scope => + scopesAllowList.some( + allowed => + scope.scopeName === allowed + || scope.scopeName.startsWith(`${allowed}.`), + ), + ), + ) + ) { + return true + } + + if ( + scopesDenyList + && scopesDenyList.length + && token.explanation?.some(explanation => + explanation.scopes.some(scope => + scopesDenyList.some( + denied => + scope.scopeName === denied + || scope.scopeName.startsWith(`${denied}.`), + ), + ), + ) + ) { + return true + } + + return false +} diff --git a/packages/colorized-brackets/test/bracket-customization.test.ts b/packages/colorized-brackets/test/bracket-customization.test.ts new file mode 100644 index 000000000..23c00b08a --- /dev/null +++ b/packages/colorized-brackets/test/bracket-customization.test.ts @@ -0,0 +1,51 @@ +import { createHighlighter } from 'shiki' +import { describe, expect, it } from 'vitest' +import { transformerColorizedBrackets } from '../src' + +describe('bracket customization', async () => { + const lang = 'ts' + const theme = 'dark-plus' + const highlighter = await createHighlighter({ + langs: [lang], + themes: [theme], + }) + + it('denied scopes', () => { + const code = 'let values: number[] = [1, 2, 3];' + + expect( + highlighter.codeToHtml(code, { + lang, + theme, + transformers: [ + transformerColorizedBrackets({ + themes: { 'dark-plus': ['Y', 'P', 'B', 'R'] }, + }), + ], + }), + ).toContain('[]') + + expect( + highlighter.codeToHtml(code, { + lang, + theme, + transformers: [ + transformerColorizedBrackets({ + themes: { 'dark-plus': ['Y', 'P', 'B', 'R'] }, + bracketPairs: [ + { + opener: '[', + closer: ']', + scopesDenyList: ['meta.type.annotation'], + }, + { opener: '{', closer: '}' }, + { opener: '(', closer: ')' }, + ], + }), + ], + }), + ).toContain( + '[]', + ) + }) +}) diff --git a/packages/colorized-brackets/test/dual-themes.test.ts b/packages/colorized-brackets/test/dual-themes.test.ts new file mode 100644 index 000000000..4d635b5a8 --- /dev/null +++ b/packages/colorized-brackets/test/dual-themes.test.ts @@ -0,0 +1,110 @@ +import { createHighlighter } from 'shiki' +import { describe, expect, it } from 'vitest' +import { transformerColorizedBrackets } from '../src' + +describe('dual themes', async () => { + const lang = 'ts' + const highlighter = await createHighlighter({ + langs: [lang], + themes: [ + 'dark-plus', + 'light-plus', + 'red', + 'vesper', + 'material-theme-ocean', + ], + }) + + it('light and dark', () => { + const htmlStr = highlighter.codeToHtml('{}', { + lang, + themes: { light: 'light-plus', dark: 'dark-plus' }, + transformers: [ + transformerColorizedBrackets({ + themes: { + 'light-plus': ['Y', 'P', 'B', 'R'], + 'dark-plus': ['y', 'p', 'b', 'r'], + }, + }), + ], + }) + expect(htmlStr).toContain('{') + }) + + it('custom prefix', () => { + const htmlStr = highlighter.codeToHtml('{}', { + lang, + themes: { light: 'light-plus', dark: 'dark-plus' }, + cssVariablePrefix: '--custom-', + transformers: [ + transformerColorizedBrackets({ + themes: { + 'light-plus': ['Y', 'P', 'B', 'R'], + 'dark-plus': ['y', 'p', 'b', 'r'], + }, + }), + ], + }) + expect(htmlStr).toContain('{') + }) + + it('custom default', () => { + const htmlStr = highlighter.codeToHtml('{}', { + lang, + themes: { dark: 'dark-plus', light: 'light-plus' }, + defaultColor: 'dark', + transformers: [ + transformerColorizedBrackets({ + themes: { + 'light-plus': ['Y', 'P', 'B', 'R'], + 'dark-plus': ['y', 'p', 'b', 'r'], + }, + }), + ], + }) + expect(htmlStr).toContain('{') + }) + + it('no default', () => { + const htmlStr = highlighter.codeToHtml('{}', { + lang, + themes: { light: 'light-plus', dark: 'dark-plus' }, + defaultColor: false, + transformers: [ + transformerColorizedBrackets({ + themes: { + 'light-plus': ['Y', 'P', 'B', 'R'], + 'dark-plus': ['y', 'p', 'b', 'r'], + }, + }), + ], + }) + expect(htmlStr).toContain( + '{', + ) + }) + + it('arbitrary theme names', () => { + const htmlStr = highlighter.codeToHtml('{}', { + lang, + themes: { + cool: 'material-theme-ocean', + warm: 'red', + grayscale: 'vesper', + }, + defaultColor: false, + transformers: [ + transformerColorizedBrackets({ + themes: { + 'material-theme-ocean': ['blue', 'red'], + 'red': ['yellow', 'red'], + 'vesper': ['gray', 'white'], + }, + }), + ], + }) + expect(htmlStr).toContain( + '{', + ) + }) +}) diff --git a/packages/colorized-brackets/test/explicit-trigger.test.ts b/packages/colorized-brackets/test/explicit-trigger.test.ts new file mode 100644 index 000000000..1df9f69ca --- /dev/null +++ b/packages/colorized-brackets/test/explicit-trigger.test.ts @@ -0,0 +1,57 @@ +import { createHighlighter } from 'shiki' +import { describe, expect, it } from 'vitest' +import { transformerColorizedBrackets } from '../src' + +describe('explicitTrigger', async () => { + const lang = 'ts' + const theme = 'dark-plus' + const highlighter = await createHighlighter({ + langs: [lang], + themes: [theme], + }) + + const validMetaStrings = [ + ['colorize-brackets'], + ['foo colorize-brackets'], + ['foo colorize-brackets bar'], + ['colorize-brackets bar'], + ] + it.each(validMetaStrings)('should colorize brackets for meta string "%s"', (meta) => { + const code = 'let values: number[] = [1, 2, 3];' + expect( + highlighter.codeToHtml(code, { + lang, + theme, + transformers: [ + transformerColorizedBrackets({ + themes: { 'dark-plus': ['Y', 'P', 'B', 'R'] }, + explicitTrigger: true, + }), + ], + meta: { __raw: meta }, + }), + ).toContain('[]') + }) + + const invalidMetaStrings = [ + [''], + ['colorize-brackets-no-word-break'], + ['no-word-break-colorize-brackets'], + ] + it.each(invalidMetaStrings)('should not colorize brackets for meta string "%s"', (meta) => { + const code = 'let values: number[] = [1, 2, 3];' + expect( + highlighter.codeToHtml(code, { + lang, + theme, + transformers: [ + transformerColorizedBrackets({ + themes: { 'dark-plus': ['Y', 'P', 'B', 'R'] }, + explicitTrigger: true, + }), + ], + meta: { __raw: meta }, + }), + ).not.toContain('[]') + }) +}) diff --git a/packages/colorized-brackets/test/fixtures.test.ts b/packages/colorized-brackets/test/fixtures.test.ts new file mode 100644 index 000000000..fb87abbfa --- /dev/null +++ b/packages/colorized-brackets/test/fixtures.test.ts @@ -0,0 +1,75 @@ +import { lstatSync, readdirSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { join, sep } from 'node:path' +import c from 'picocolors' +import { createHighlighter } from 'shiki' +import { describe, expect, it } from 'vitest' +import { transformerColorizedBrackets } from '../src' +import { + parseActualBrackets, + parseExpectedBrackets, + prettifyBrackets, +} from './utils' + +/** + * `tests/samples` contains code snippets that annotate expected colors with `@colors` comments. + * `Y`, `P`, `B` are for the 3 levels of matched brackets (yellow, purple, blue), and `R` is for mismatched brackets (red). + * Values before `@colors` indicate the expected color of the bracket on the preceding line. + * Values after `@colors` are explicitly indexed. + * For example: + * + * ```ts + * function first(array: T[]) { + * // Y YY PPY Y @colors + * return array[0]; + * // P P @colors + * } + * // @colors 0=Y + * ``` + * + * In the final line, there's not room underneath the `}` to annotate the color, so an explicit index `0=Y` is used after `@colors`. + */ +describe('file-driven tests', async () => { + const testCaseFiles: [string][] = readdirSync(join(import.meta.dirname, 'fixtures'), { + recursive: true, + }) + .filter( + (fileName): fileName is string => + typeof fileName === 'string' + && lstatSync(join(import.meta.dirname, 'fixtures', fileName)).isFile(), + ) + .map<[string]>(fileName => [fileName]) + const langs = Array.from( + new Set(testCaseFiles.map(fileName => fileName[0].split(sep)[0])), + ) + const highlighter = await createHighlighter({ + langs, + themes: ['dark-plus'], + }) + + it.each(testCaseFiles)('%s', async (fileName) => { + const path = join(import.meta.dirname, 'fixtures', fileName) + const lang = fileName.split(sep).at(0) ?? 'text' + const content = await readFile(path, { encoding: 'utf-8' }) + const expectedBrackets = parseExpectedBrackets(content) + const html = highlighter.codeToHtml(content, { + lang, + theme: 'dark-plus', + transformers: [ + transformerColorizedBrackets({ + themes: { 'dark-plus': ['Y', 'P', 'B', 'R'] }, + }), + ], + }) + const actualBrackets = parseActualBrackets(html) + // Logging the colored brackets is much easier to read + /* eslint-disable no-console */ + console.log(c.bold(fileName)) + console.log(' Expected:', prettifyBrackets(expectedBrackets)) + console.log(' Actual: ', prettifyBrackets(actualBrackets)) + /* eslint-enable no-console */ + expect(prettifyBrackets(actualBrackets, { noAnsi: true })).toEqual( + prettifyBrackets(expectedBrackets, { noAnsi: true }), + ) + }) +}) diff --git a/packages/colorized-brackets/test/fixtures/csharp/generic.cs b/packages/colorized-brackets/test/fixtures/csharp/generic.cs new file mode 100644 index 000000000..c01aa37bb --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/csharp/generic.cs @@ -0,0 +1,8 @@ +public class GenericList +// note: angle brackets not colored +{ + // @colors 0=Y + public void Add(T input) { } + // P P P P @colors +} +// @colors 0=Y diff --git a/packages/colorized-brackets/test/fixtures/css/basic.css b/packages/colorized-brackets/test/fixtures/css/basic.css new file mode 100644 index 000000000..85fb393c7 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/css/basic.css @@ -0,0 +1,9 @@ +body[data-theme="dark"] { + /*Y Y Y @colors */ + a { + /* @colors 4=P */ + color: #AAAAFF + } + /* @colors 2=P */ +} +/* @colors 0=Y */ \ No newline at end of file diff --git a/packages/colorized-brackets/test/fixtures/html/basic.html b/packages/colorized-brackets/test/fixtures/html/basic.html new file mode 100644 index 000000000..97036ad44 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/html/basic.html @@ -0,0 +1,4 @@ +
+

Brackets in text are not colorized (like these parentheses)

+

Brackets in attributes are also not colorized.

+
\ No newline at end of file diff --git a/packages/colorized-brackets/test/fixtures/html/embedded.html b/packages/colorized-brackets/test/fixtures/html/embedded.html new file mode 100644 index 000000000..0bde5197c --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/html/embedded.html @@ -0,0 +1,14 @@ + +

+ Rock and stone to the bone! +

+ diff --git a/packages/colorized-brackets/test/fixtures/java/generic.java b/packages/colorized-brackets/test/fixtures/java/generic.java new file mode 100644 index 000000000..ce72a28b7 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/java/generic.java @@ -0,0 +1,11 @@ +public class Box { + // Y @colors + // note: the angle brackets are not highlighted + private T t; + + public void set(T t) { this.t = t; } + // P P P P @colors + public T get() { return t; } + // PP P P @colors +} +// @colors 0=Y \ No newline at end of file diff --git a/packages/colorized-brackets/test/fixtures/jinja/basic.jinja b/packages/colorized-brackets/test/fixtures/jinja/basic.jinja new file mode 100644 index 000000000..b0b58a6fd --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/jinja/basic.jinja @@ -0,0 +1,12 @@ +{% if test %} + {# @colors 0-1=Y 11-12=Y #} +

+ {{test(foo[1])}} + {# @colors 4-5=Y 10=P 14=B 16=B 17=P 18-19=Y #} +

+{% endif %} +{# @colors 0-1=Y 9-10=Y #} + \ No newline at end of file diff --git a/packages/colorized-brackets/test/fixtures/liquid/basic.liquid b/packages/colorized-brackets/test/fixtures/liquid/basic.liquid new file mode 100644 index 000000000..9ffbd55f4 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/liquid/basic.liquid @@ -0,0 +1,16 @@ +

Recommended Products

+ \ No newline at end of file diff --git a/packages/colorized-brackets/test/fixtures/python/basic.py b/packages/colorized-brackets/test/fixtures/python/basic.py new file mode 100644 index 000000000..8c4d0b1a6 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/python/basic.py @@ -0,0 +1,23 @@ +from collections.abc import Callable, Awaitable + +def feeder(get_next_item: Callable[[], str]) -> None: + # Y PBB PY @colors + pass + +def async_query(on_success: Callable[[int], None], + # PB B P @colors 15=Y + on_error: Callable[[int, Exception], None]) -> None: + # PB B PY @colors + pass + +async def on_update(value: str) -> None: + # Y Y @colors + pass + +callback: Callable[[str], Awaitable[None]] = on_update +# YP P P PY @colors + +l = [1, 2, 3] +# Y Y @colors +s = f"last: {l[-1]}" +# Y P PY @colors \ No newline at end of file diff --git a/packages/colorized-brackets/test/fixtures/rust/generic.rs b/packages/colorized-brackets/test/fixtures/rust/generic.rs new file mode 100644 index 000000000..b379a7542 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/rust/generic.rs @@ -0,0 +1,19 @@ +fn largest(list: &[T]) -> &T { + // Y P PY Y @colors + // note: the angle brackets are not highlighted + let mut largest = &list[0]; + // P P @colors + + for item in list { + // P @colors + if item > largest { + // B @colors + largest = item; + } + // @colors 4=B + } + // @colors 2=P + + largest +} +// @colors 0=Y diff --git a/packages/colorized-brackets/test/fixtures/rust/turbofish.rs b/packages/colorized-brackets/test/fixtures/rust/turbofish.rs new file mode 100644 index 000000000..5f86af027 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/rust/turbofish.rs @@ -0,0 +1,12 @@ + +#![allow(unused_variables)] +// P PY @colors 2=Y +fn main() { + // YY Y @colors + let v = Vec::::new(); + // PP @colors + // note: angle brackets not colored + println!("{:?}", v); + // P P @colors +} +// @colors 0=Y \ No newline at end of file diff --git a/packages/colorized-brackets/test/fixtures/svelte/embedded.svelte b/packages/colorized-brackets/test/fixtures/svelte/embedded.svelte new file mode 100644 index 000000000..b3d337951 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/svelte/embedded.svelte @@ -0,0 +1,35 @@ + + +

+ {#each rocks as rockWords} + + {#each rockWords as rock, i} + + {rock} + + {i < rocks.length - 1 ? "and" : ""} + + {/each} + + {/each} + + ! +

+ + diff --git a/packages/colorized-brackets/test/fixtures/ts/angle-brackets.ts b/packages/colorized-brackets/test/fixtures/ts/angle-brackets.ts new file mode 100644 index 000000000..4ec35e367 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/ts/angle-brackets.ts @@ -0,0 +1,11 @@ +const objectToEntries = (obj: Record) => { + // Y P PY Y @colors + if (Object.keys(obj).length > 0) { + // B B P P @colors 5=P + return Object.entries(obj); + // B B @colors + } + // @colors 2=P + return null; +}; +// @colors 0=Y diff --git a/packages/colorized-brackets/test/fixtures/ts/comments.ts b/packages/colorized-brackets/test/fixtures/ts/comments.ts new file mode 100644 index 000000000..858d644a4 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/ts/comments.ts @@ -0,0 +1,5 @@ +// ([{}][0]) +/* +including multiline comments +Record +*/ diff --git a/packages/colorized-brackets/test/fixtures/ts/generic.ts b/packages/colorized-brackets/test/fixtures/ts/generic.ts new file mode 100644 index 000000000..03ddf7712 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/ts/generic.ts @@ -0,0 +1,9 @@ +function first(array: T[]): T | undefined { + // Y YY PPY Y @colors + return array[0]; + // P P @colors +} +// @colors 0=Y + +first([1, 2, 3]); +// Y YYP PY @colors diff --git a/packages/colorized-brackets/test/fixtures/ts/jsdoc.ts b/packages/colorized-brackets/test/fixtures/ts/jsdoc.ts new file mode 100644 index 000000000..ed5ab2e89 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/ts/jsdoc.ts @@ -0,0 +1,16 @@ +/** + * + * @param {Array} [strings=["()"]] - description + * Y P PY Y P BB PY @colors + * note: the colored () inside the string in the default bracket + * there is not enough context from the scopes to do otherwise + * this matches VSCode behavior + * @returns {string[]} + * Y PPY @colors + */ +function reverse(strings: string[]) { + // Y PPY Y @colors + return strings.reverse(); + // PP @colors +} +// @colors 0=Y diff --git a/packages/colorized-brackets/test/fixtures/ts/strings.ts b/packages/colorized-brackets/test/fixtures/ts/strings.ts new file mode 100644 index 000000000..c223193e5 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/ts/strings.ts @@ -0,0 +1,14 @@ +let foo = { + // Y @colors + bar: ["()", "[]", "{}"], + // P P @colors +}; +// @colors 0=Y +`foo: + ${foo} + ${0} + [[]{}()] +`; + +`foo.bar[0]: ${foo.bar[0]}`; +// Y Y @colors diff --git a/packages/colorized-brackets/test/fixtures/ts/template.ts b/packages/colorized-brackets/test/fixtures/ts/template.ts new file mode 100644 index 000000000..451696f9d --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/ts/template.ts @@ -0,0 +1,4 @@ +`(outer) ${[`(inner) ${[[]]}`]}`; +// Y PBBP Y @colors +// note: vscode does not color the inner expression, but this plugin does +// for now, considering this desired behavior even though it is divergent diff --git a/packages/colorized-brackets/test/fixtures/ts/unexpected.txt b/packages/colorized-brackets/test/fixtures/ts/unexpected.txt new file mode 100644 index 000000000..bb1b02d83 --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/ts/unexpected.txt @@ -0,0 +1,13 @@ +// this needs to be a txt file, otherwise `jsr publish` fails while checking for slow types +if (true) { +// Y Y R @colors + + let obj = { + // P @colors + foo: 'foo'.split()( + // BBR @colors + }]; + // @colors 2=P 3=R + +let foo = ([[[[)]]]] +// PRRRRPRRRR @colors diff --git a/packages/colorized-brackets/test/fixtures/tsx/basic.tsx b/packages/colorized-brackets/test/fixtures/tsx/basic.tsx new file mode 100644 index 000000000..602823d2a --- /dev/null +++ b/packages/colorized-brackets/test/fixtures/tsx/basic.tsx @@ -0,0 +1,6 @@ +function Hello({ name }: { name: string }) { + // YP P P PY Y @colors + return

Hello, {name}

; + // P P @colors +} +// @colors 0=Y \ No newline at end of file diff --git a/packages/colorized-brackets/test/utils.ts b/packages/colorized-brackets/test/utils.ts new file mode 100644 index 000000000..238e7233b --- /dev/null +++ b/packages/colorized-brackets/test/utils.ts @@ -0,0 +1,89 @@ +import c from 'picocolors' + +interface ColoredBracket { + bracket: string + color: string +} + +export function parseExpectedBrackets(content: string): ColoredBracket[] { + const brackets: ColoredBracket[] = [] + const lines = content.split('\n') + const implicitIndexRegex = /[RYPB]/g + const explicitIndexRegex = /(\d+)(?:-(\d+))?=([RYPB])/g + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (line.includes('@colors')) { + const prev = lines[i - 1] + const [implicitIndexPart, explicitIndexPart] = line.split('@colors') + for (const match of explicitIndexPart.matchAll(explicitIndexRegex)) { + const start = Number.parseInt(match[1]) + const end = Number.parseInt(match[2] || match[1]) + 1 + const color = match[3] + brackets.push({ + bracket: prev.substring(start, end), + color, + }) + } + for (const match of implicitIndexPart.matchAll(implicitIndexRegex)) { + const index = match.index + const color = match[0] + brackets.push({ bracket: prev[index], color }) + } + } + } + return brackets +} + +export function parseActualBrackets(html: string): ColoredBracket[] { + const spanRegex + // eslint-disable-next-line regexp/no-super-linear-backtracking -- this is only run on input we control, so DoS is not a concern + = /\s*(&#x[0-9A-F]+;|..?)\s*<\/span>/g + const brackets = Array.from(html.matchAll(spanRegex)).map( + (match) => { + const color = match[1] + let bracket = match[2] + if (bracket.startsWith('&#x')) { + bracket = String.fromCharCode( + Number.parseInt(bracket.substring(3, bracket.length - 1), 16), + ) + } + return { color, bracket } + }, + ) + return brackets +} + +export function prettifyBrackets( + brackets: ColoredBracket[], + { noAnsi = false } = {}, +): string { + if (!brackets.length) + return noAnsi ? 'none' : c.gray('none') + return brackets + .map(b => getColoredBracketTerminalOutput(b, { noAnsi })) + .join(' ') +} + +function getColoredBracketTerminalOutput( + { bracket, color }: ColoredBracket, + { noAnsi = false } = {}, +): string { + const isCloser = [']', '}', ')', '>', '}}', '%}'].includes(bracket) + if (noAnsi) + return isCloser ? `${bracket}${color}` : `${color}${bracket}` + if (color === 'R') { + return c.red(bracket) + } + else if (color === 'Y') { + return c.yellow(bracket) + } + else if (color === 'P') { + return c.magenta(bracket) + } + else if (color === 'B') { + return c.blue(bracket) + } + else { + return `${color}${bracket}` + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52b1a3b06..26b91f3cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -436,6 +436,9 @@ importers: '@iconify-json/svg-spinners': specifier: 'catalog:' version: 1.2.1 + '@shikijs/colorized-brackets': + specifier: workspace:* + version: link:../packages/colorized-brackets '@shikijs/transformers': specifier: workspace:* version: link:../packages/transformers @@ -489,6 +492,12 @@ importers: specifier: 'catalog:' version: 1.2.5 + packages/colorized-brackets: + dependencies: + shiki: + specifier: workspace:* + version: link:../shiki + packages/compat: dependencies: '@shikijs/core':