Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion scripts/markdownTokenColours.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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`);
}
});
66 changes: 56 additions & 10 deletions src/lib/utils/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,51 @@ export function sanitizeThemeColors(colors: unknown): Record<string, string> {

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 `<strong>`, `##` as an `<h2>`. 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<readonly [kind: string, cssVar: string]> = [
['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<string, string> {
const byKind = new Map<string, string>();
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<string, string> {
return kindColorsOf(monacoTokenRules(tokenColors));
}

/**
* Every rule an imported theme's editor gets, semantic layer included.
*
Expand Down Expand Up @@ -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<string, string>();
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]);
Expand Down Expand Up @@ -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');
Expand Down
31 changes: 28 additions & 3 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down
Loading