From 45db9893e0add3ab78ca49d47d4e982231829ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Est=C3=A9ban?= Date: Tue, 5 Nov 2024 11:16:47 +0100 Subject: [PATCH] feat: support (at least) dual themes (#26) --- bin/renderer.js | 142 ++++++++++++------ bin/shiki.js | 109 ++++++++++---- src/Shiki.php | 7 +- tests/ShikiTest.php | 15 +- ...an_receive_a_light_and_a_dark_theme__1.txt | 1 + 5 files changed, 191 insertions(+), 83 deletions(-) create mode 100644 tests/__snapshots__/ShikiTest__it_can_receive_a_light_and_a_dark_theme__1.txt diff --git a/bin/renderer.js b/bin/renderer.js index a924c42..d62637e 100644 --- a/bin/renderer.js +++ b/bin/renderer.js @@ -3,89 +3,131 @@ const FontStyle = { None: 0, Italic: 1, Bold: 2, - Underline: 4 -} + Underline: 4, +}; const FONT_STYLE_TO_CSS = { - [FontStyle.Italic]: 'font-style: italic', - [FontStyle.Bold]: 'font-weight: bold', - [FontStyle.Underline]: 'text-decoration: underline' -} - -const renderToHtml = function(lines, options = {}) { - const bg = options.bg || '#fff' + [FontStyle.Italic]: "font-style: italic", + [FontStyle.Bold]: "font-weight: bold", + [FontStyle.Underline]: "text-decoration: underline", +}; + +const renderToHtml = function (lines, options = {}) { + const theme = options.theme; + const themes = options.themes; const highlightedLines = makeHighlightSet(options.highlightLines); const addLines = makeHighlightSet(options.addLines); const deleteLines = makeHighlightSet(options.deleteLines); const focusLines = makeHighlightSet(options.focusLines); - let className = 'shiki'; + let className = "shiki"; if (highlightedLines.size) { - className += ' highlighted' + className += " highlighted"; } if (addLines.size) { - className += ' added' + className += " added"; } if (deleteLines.size) { - className += ' deleted' + className += " deleted"; } if (focusLines.size) { - className += ' focus' + className += " focus"; } - let html = '' + let html = ""; + + if (theme) { + html += `
`;
+    } else if (themes) {
+        const backgroundStyles = Object.entries(themes).map(
+            ([theme, theme$]) => {
+                if (theme === "light") {
+                    return `background-color:${theme$.theme.bg};`;
+                }
+
+                return `--shiki-${theme}-bg:${theme$.theme.bg};`;
+            }
+        );
+
+        const foregroundStyles = Object.entries(themes).map(
+            ([theme, theme$]) => {
+                if (theme === "light") {
+                    return `color:${theme$.theme.fg};`;
+                }
+
+                return `--shiki-${theme}:${theme$.theme.fg};`;
+            }
+        );
+
+        const classes = `${className} shiki-themes ${Object.values(themes)
+            .map((theme) => theme.theme.name)
+            .join(" ")}`;
+
+        html += `
`;
+    }
 
-    html += `
`
     if (options.langId) {
-        html += `
${options.langId}
` + html += `
${options.langId}
`; } - html += `` + html += ``; lines.forEach((l, index) => { const lineNumber = index + 1; - let lineClass = 'line' + let lineClass = "line"; if (highlightedLines.has(lineNumber)) { - lineClass += ' highlight' + lineClass += " highlight"; } if (addLines.has(lineNumber)) { - lineClass += ' add' + lineClass += " add"; } if (deleteLines.has(lineNumber)) { - lineClass += ' del' + lineClass += " del"; } if (focusLines.has(lineNumber)) { - lineClass += ' focus' + lineClass += " focus"; } - html += `` + html += ``; - l.forEach(token => { - const cssDeclarations = [`color: ${token.color || options.fg}`] - if (token.fontStyle > FontStyle.None) { - cssDeclarations.push(FONT_STYLE_TO_CSS[token.fontStyle]) + l.forEach((token) => { + const cssDeclarations = []; + if (theme) { + cssDeclarations.push(`color: ${token.color || theme.theme.fg}`); + } else if (themes) { + cssDeclarations.push(token.htmlStyle); } - html += `${escapeHtml(token.content)}` - }) - html += `\n` - }) - html = html.replace(/\n*$/, '') // Get rid of final new lines - html += `
` - - return html -} -const makeHighlightSet = function(highlightLines) { + if (token.fontStyle > FontStyle.None) { + cssDeclarations.push(FONT_STYLE_TO_CSS[token.fontStyle]); + } + html += `${escapeHtml( + token.content + )}`; + }); + html += `\n`; + }); + html = html.replace(/\n*$/, ""); // Get rid of final new lines + html += `
`; + + return html; +}; + +const makeHighlightSet = function (highlightLines) { const lines = new Set(); - if (! highlightLines) { + if (!highlightLines) { return lines; } for (let lineSpec of highlightLines) { - if (lineSpec.toString().includes('-')) { - const [begin, end] = lineSpec.split('-').map(lineNo => Number(lineNo)) + if (lineSpec.toString().includes("-")) { + const [begin, end] = lineSpec + .split("-") + .map((lineNo) => Number(lineNo)); for (let line = begin; line <= end; line++) { lines.add(line); } @@ -94,19 +136,19 @@ const makeHighlightSet = function(highlightLines) { } } - return lines -} + return lines; +}; const htmlEscapes = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' -} + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; function escapeHtml(html) { - return html.replace(/[&<>"']/g, chr => htmlEscapes[chr]) + return html.replace(/[&<>"']/g, (chr) => htmlEscapes[chr]); } exports.renderToHtml = renderToHtml; diff --git a/bin/shiki.js b/bin/shiki.js index 798f543..7ba1193 100644 --- a/bin/shiki.js +++ b/bin/shiki.js @@ -1,21 +1,21 @@ -const fs = require('fs'); -const path = require('path'); -const renderer = require('./renderer'); +const fs = require("fs"); +const path = require("path"); +const renderer = require("./renderer"); const args = JSON.parse(process.argv.slice(2)); const customLanguages = { antlers: { - scopeName: 'text.html.statamic', - embeddedLangs: ['html'], + scopeName: "text.html.statamic", + embeddedLangs: ["html"], }, blade: { - scopeName: 'text.html.php.blade', - embeddedLangs: ['html', 'php'], + scopeName: "text.html.php.blade", + embeddedLangs: ["html", "php"], }, }; async function main(args) { - const shiki = await import('shiki'); + const shiki = await import("shiki"); const highlighter = await shiki.getHighlighter({}); for (const [lang, spec] of Object.entries(customLanguages)) { @@ -23,21 +23,46 @@ async function main(args) { await highlighter.loadLanguage(embedded); } - await highlighter.loadLanguage({ ...spec, ...loadLanguage(lang), name: lang }); + await highlighter.loadLanguage({ + ...spec, + ...loadLanguage(lang), + name: lang, + }); } - const language = args[1] || 'php'; - let theme = args[2] || 'nord'; + const language = args[1] || "php"; + + /** + * If only one theme is provided, the variable `theme` will be a string. The variable `themes` will be null. + * + * If multiple themes are provided, the variable `themes` will be an array and the variable `theme` will be null. + */ + let theme = args[2] || "nord"; + let themes = null; + if (typeof args[2] === "object") { + theme = null; + themes = args[2]; + } - if (fs.existsSync(theme)) { - theme = JSON.parse(fs.readFileSync(theme, 'utf-8')); - } else { - await highlighter.loadTheme(theme); + if (theme) { + if (fs.existsSync(theme)) { + theme = loadLocalTheme(theme); + } else { + await highlighter.loadTheme(theme); + } + } else if (themes) { + for (const theme of Object.values(themes)) { + if (fs.existsSync(theme)) { + themes[theme] = loadLocalTheme(theme); + } else { + await highlighter.loadTheme(theme); + } + } } if (!customLanguages[language]) await highlighter.loadLanguage(language); - if (args[0] === 'languages') { + if (args[0] === "languages") { process.stdout.write( JSON.stringify([ ...Object.keys(shiki.bundledLanguagesBase), @@ -47,7 +72,7 @@ async function main(args) { return; } - if (args[0] === 'aliases') { + if (args[0] === "aliases") { process.stdout.write( JSON.stringify([ ...Object.keys(shiki.bundledLanguages), @@ -57,33 +82,50 @@ async function main(args) { return; } - if (args[0] === 'themes') { + if (args[0] === "themes") { process.stdout.write(JSON.stringify(Object.keys(shiki.bundledThemes))); return; } - const { theme: theme$ } = highlighter.setTheme(theme) - - const result = highlighter.codeToTokens(args[0], { - theme: theme$, + const codeToTokensOptions = { lang: language, - }); + }; + + if (theme) { + codeToTokensOptions.theme = theme; + } else if (themes) { + codeToTokensOptions.themes = themes; + } + + const result = highlighter.codeToTokens(args[0], codeToTokensOptions); const options = args[3] || {}; - const rendered = renderer.renderToHtml(result.tokens, { - fg: theme$.fg, - bg: theme$.bg, + const renderToHtmlOptions = { highlightLines: options.highlightLines, addLines: options.addLines, deleteLines: options.deleteLines, focusLines: options.focusLines, - }); + }; + + if (theme) { + renderToHtmlOptions.theme = highlighter.setTheme(theme); + } else if (themes) { + const themes$ = {}; + + for (const [theme, theme$] of Object.entries(themes)) { + themes$[theme] = highlighter.setTheme(theme$); + } + + renderToHtmlOptions.themes = themes$; + } + + const rendered = renderer.renderToHtml(result.tokens, renderToHtmlOptions); process.stdout.write(rendered); } -main(args) +main(args); function loadLanguage(language) { const path = getLanguagePath(language); @@ -93,7 +135,16 @@ function loadLanguage(language) { } function getLanguagePath(language) { - const url = path.join(__dirname, '..', 'languages', `${language}.tmLanguage.json`); + const url = path.join( + __dirname, + "..", + "languages", + `${language}.tmLanguage.json` + ); return path.normalize(url); } + +function loadLocalTheme(theme) { + return JSON.parse(fs.readFileSync(theme, "utf-8")); +} diff --git a/src/Shiki.php b/src/Shiki.php index 955bbb7..d13a39f 100644 --- a/src/Shiki.php +++ b/src/Shiki.php @@ -17,10 +17,13 @@ public static function setCustomWorkingDirPath(?string $path) static::$customWorkingDirPath = $path; } + /** + * @param string|array|null $theme Can be a single theme or an array with a light and a dark theme. + */ public static function highlight( string $code, ?string $language = null, - ?string $theme = null, + mixed $theme = null, ?array $highlightLines = null, ?array $addLines = null, ?array $deleteLines = null, @@ -74,7 +77,7 @@ public function themeIsAvailable(string $theme): bool return in_array($theme, $this->getAvailableThemes()); } - public function highlightCode(string $code, string $language, ?string $theme = null, ?array $options = []): string + public function highlightCode(string $code, string $language, mixed $theme = null, ?array $options = []): string { $theme = $theme ?? $this->defaultTheme; diff --git a/tests/ShikiTest.php b/tests/ShikiTest.php index 6599740..a261403 100644 --- a/tests/ShikiTest.php +++ b/tests/ShikiTest.php @@ -4,11 +4,11 @@ use function Spatie\Snapshots\assertMatchesSnapshot; -beforeAll(fn () => Shiki::setCustomWorkingDirPath(null)); +beforeAll(fn() => Shiki::setCustomWorkingDirPath(null)); it('can get the default workingDirPath', function () { expect((new Shiki())->getWorkingDirPath()) - ->toBeString()->toBe(dirname(__DIR__).'/bin'); + ->toBeString()->toBe(dirname(__DIR__) . '/bin'); }); it('can highlight php', function () { @@ -115,6 +115,17 @@ assertMatchesSnapshot($highlightedCode); }); +it('can receive a light and a dark theme', function () { + $code = ''; + + $highlightedCode = Shiki::highlight($code, null, [ + 'light' => 'github-light', + 'dark' => 'github-dark', + ]); + + assertMatchesSnapshot($highlightedCode); +}); + it('throws on invalid theme', function () { $code = ''; diff --git a/tests/__snapshots__/ShikiTest__it_can_receive_a_light_and_a_dark_theme__1.txt b/tests/__snapshots__/ShikiTest__it_can_receive_a_light_and_a_dark_theme__1.txt new file mode 100644 index 0000000..5cd73d2 --- /dev/null +++ b/tests/__snapshots__/ShikiTest__it_can_receive_a_light_and_a_dark_theme__1.txt @@ -0,0 +1 @@ +
<?php echo "Hello World"; ?>
\ No newline at end of file