diff --git a/scripts/markdownTokenColours.test.ts b/scripts/markdownTokenColours.test.ts index 9875f410..306038ce 100644 --- a/scripts/markdownTokenColours.test.ts +++ b/scripts/markdownTokenColours.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { monacoTokenRules } from '../src/lib/utils/theme.js'; +import { PREVIEW_KIND_VARS, markdownKindColors, monacoTokenRules } from '../src/lib/utils/theme.js'; +import { readSource } from './sourceTree.js'; // #676: an imported VS Code theme colours TextMate scopes, Monaco's markdown // tokenizer emits names of its own, and until now nothing renamed one into the @@ -89,3 +90,46 @@ test('an imported theme reaches the semantic names too, not only the grammar one assert.equal(byToken.get('quote'), '5c6370'); }); +// #682: the same scopes, read as colours rather than as Monaco rules, because +// the preview renders the constructs as HTML and takes them through CSS. + +test('a theme\'s Markdown scopes are readable as a colour per construct', () => { + const colors = markdownKindColors([ + { scope: 'markup.bold.markdown', settings: { foreground: '#ff0000' } }, + { scope: ['markup.heading', 'markup.quote'], settings: { foreground: '#00ff00' } }, + { scope: 'markup.underline.link', settings: { foreground: '#0000ff', fontStyle: 'underline' } }, + // Not Markdown, and not a colour: neither may reach the preview. + { scope: 'keyword.control', settings: { foreground: '#123456' } }, + { scope: 'markup.italic', settings: { fontStyle: 'italic' } }, + ]); + + assert.equal(colors.get('strong'), 'ff0000'); + assert.equal(colors.get('heading'), '00ff00'); + assert.equal(colors.get('quote'), '00ff00'); + assert.equal(colors.get('link'), '0000ff'); + assert.equal(colors.has('emph'), false); + assert.equal([...colors.keys()].length, 4); +}); + +test('a theme with no token colours at all leaves the preview alone', () => { + // The built-in themes, and a theme file that only paints the workbench. An + // empty map writes no variable, and every rule falls back to what the + // stylesheet already said. + for (const tokenColors of [undefined, null, [], 'markup.bold']) { + assert.equal(markdownKindColors(tokenColors).size, 0); + } +}); + +test('every construct colour the importer writes is read by the stylesheet', () => { + // The two halves are joined by a variable name and nothing else: the + // importer writes `--md-strong`, `styles.css` reads it, and no type or + // import holds them together. Renaming one silently drops the colour. + const styles = readSource('src/styles.css'); + + for (const [kind, cssVar] of PREVIEW_KIND_VARS) { + // The comma is the fallback. Without one, a construct the theme does not + // name renders with no colour at all rather than with the value the rule + // carried before the import. + assert.ok(styles.includes(`var(${cssVar},`), `${cssVar} is written for \`${kind}\` and read by no rule with a fallback`); + } +}); diff --git a/src/lib/utils/theme.ts b/src/lib/utils/theme.ts index 421af0bb..105ca7b8 100644 --- a/src/lib/utils/theme.ts +++ b/src/lib/utils/theme.ts @@ -72,6 +72,51 @@ export function sanitizeThemeColors(colors: unknown): Record { export type MonacoTokenRule = { token: string; foreground?: string; fontStyle?: string }; +/** + * The constructs the preview takes from an imported theme, and the variable + * each is written to. + * + * The preview renders what the editor tokenizes, but as HTML with the markup + * gone: `**bold**` arrives as ``, `##` as an `

`. So the colours + * reach it through CSS rather than through Monaco, and they reach the + * construct's *text*, which is all that is left of it (#682). + * + * A kind the theme does not name is simply absent here, and the stylesheet's + * own fallback stands — which is what keeps the built-in themes' preview, and + * every preview that was rendered before an import, exactly as it was. + */ +export const PREVIEW_KIND_VARS: ReadonlyArray = [ + ['heading', '--md-heading'], + ['strong', '--md-strong'], + ['emph', '--md-emph'], + ['code', '--md-code'], + ['link', '--md-link'], + ['strike', '--md-strike'], + ['quote', '--md-quote'], +]; + +/** The colour the theme gave each construct, by the kind the extractor emits. */ +function kindColorsOf(rules: readonly MonacoTokenRule[]): Map { + const byKind = new Map(); + for (const rule of rules) { + // Read off the scope the theme wrote, which `monacoTokenRules` keeps + // beside every alias it derives, so a language-qualified spelling like + // `markup.bold.markdown` is matched by the same prefix rule. + const alias = markdownAliasFor(rule.token); + if (alias && rule.foreground) byKind.set(alias.kind, rule.foreground); + } + return byKind; +} + +/** + * The colour an imported theme gives each Markdown construct, for a caller that + * needs the colours rather than Monaco's rules — the preview, which renders the + * same constructs as HTML. + */ +export function markdownKindColors(tokenColors: unknown): Map { + return kindColorsOf(monacoTokenRules(tokenColors)); +} + /** * Every rule an imported theme's editor gets, semantic layer included. * @@ -104,16 +149,7 @@ export type MonacoTokenRule = { token: string; foreground?: string; fontStyle?: */ export function importedThemeRules(tokenColors: unknown, isDark: boolean): MonacoTokenRule[] { const themeRules = monacoTokenRules(tokenColors); - - /** The colour the theme gave each construct, by the kind the extractor emits. */ - const byKind = new Map(); - for (const rule of themeRules) { - // Read off the scope the theme wrote, which `monacoTokenRules` keeps - // beside every alias it derives, so a language-qualified spelling like - // `markup.bold.markdown` is matched by the same prefix rule. - const alias = markdownAliasFor(rule.token); - if (alias && rule.foreground) byKind.set(alias.kind, rule.foreground); - } + const byKind = kindColorsOf(themeRules); const base = semanticTokenRules(isDark ? 'dark' : 'light').map((rule) => { const foreground = byKind.get(rule.token.split('.')[0]); @@ -280,6 +316,16 @@ export async function parseAndApplyVscodeTheme(themeJsonStr: string, name: strin cssVars['--hljs-variable'] = cssVars['--color-fg-default']; cssVars['--hljs-type'] = cssVars['--color-fg-default']; + // `tokenColors` reached the editor and nothing else, so an imported theme + // arrived on one half of the window and stopped (#682). The values are hex + // literals `monacoTokenRules` already validated, and are re-checked below + // with the rest. + const kindColors = markdownKindColors(theme.tokenColors); + for (const [kind, cssVar] of PREVIEW_KIND_VARS) { + const foreground = kindColors.get(kind); + if (foreground) cssVars[cssVar] = `#${foreground}`; + } + let styleTag = document.getElementById('vscode-theme-style'); if (!styleTag) { styleTag = document.createElement('style'); diff --git a/src/styles.css b/src/styles.css index 74aa6b65..450f6816 100644 --- a/src/styles.css +++ b/src/styles.css @@ -500,7 +500,7 @@ body { .markdown-body a { background-color: transparent; - color: var(--color-accent-fg); + color: var(--md-link, var(--color-accent-fg)); text-decoration: none; } @@ -509,9 +509,26 @@ body { text-decoration: underline dotted; } +/* + * The colours an imported VS Code theme gives Markdown, applied to the rendered + * construct rather than to the markup that produced it (#682). Every fallback + * is the value the rule had before, so a built-in theme — which has no + * `tokenColors` to take — renders exactly as it did. + */ .markdown-body b, .markdown-body strong { font-weight: 600; + color: var(--md-strong, inherit); +} + +.markdown-body em, +.markdown-body i { + color: var(--md-emph, inherit); +} + +.markdown-body del, +.markdown-body s { + color: var(--md-strike, inherit); } .markdown-body dfn { @@ -870,7 +887,11 @@ body { .markdown-body h6 { font-weight: 600; font-size: 0.85em; - color: var(--color-fg-muted); + color: var(--md-heading, var(--color-fg-muted)); +} + +.markdown-body :is(h1, h2, h3, h4, h5) { + color: var(--md-heading, inherit); } .markdown-body p { @@ -883,7 +904,7 @@ body { .markdown-body blockquote { margin: 0; padding: 0 1em; - color: var(--color-fg-muted); + color: var(--md-quote, var(--color-fg-muted)); border-left: 0.25em solid var(--color-border-default); } @@ -937,6 +958,10 @@ body { margin-bottom: 16px; } +.markdown-body :not(pre) > code { + color: var(--md-code, inherit); +} + .markdown-body code { padding: 0.2em 0.4em; margin: 0;