From b8337eb7e76bfa7f8594b415bf7ac8ee5881f78c Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 5 Oct 2026 12:00:46 +0200 Subject: [PATCH 1/2] swatches: add a match mode and extra delimiters, per site, language and block Co-Authored-By: Claude Opus 5.5 (1M context) --- .agents/DECISIONS.md | 2 +- .../content/docs/features/colour-swatches.mdx | 33 +++++++- .../src/expressive-code/swatches.ts | 79 ++++++++++++++++--- packages/starlight-codeblocks/src/options.ts | 44 ++++++++++- .../starlight-codeblocks/src/reference.ts | 8 ++ .../test/swatches.test.ts | 64 ++++++++++++++- .../references/configuration.md | 3 +- .../references/readability.md | 2 + 8 files changed, 218 insertions(+), 17 deletions(-) diff --git a/.agents/DECISIONS.md b/.agents/DECISIONS.md index 05d3e56..7a01132 100644 --- a/.agents/DECISIONS.md +++ b/.agents/DECISIONS.md @@ -57,7 +57,7 @@ The choices that the code does not explain, with the options that were rejected. - **Line states derive every tint from one colour**, so custom states match built-in ones. A state with no message shows its name once per run (WCAG 1.4.1, and forced colours). State names cannot be another attribute's name, or `label` or `prefix` (they collide with classes), or the aliases `note` and `warn`. `prefix: false` and `lineStates.prefix=false` hide every visible name, also on lines with no message, by the author's choice; screen readers still hear it, and the accessibility page lists it as an exception. - **Messages on `[!code ++]`, `[!code --]` and `[!code highlight]` belong to line states**, which declares those directives again with `text: true`. With line states off, the text stays in the comment instead of disappearing. The label has no name: Expressive Code's marker already carries the meaning. - **Colourised brackets use a language-agnostic string and comment scanner.** EC exposes no token scopes. Brackets are inline-style annotations, not class colours, so the contrast passes on tints correct them. The caret also outlines its pair, for keyboard users. *Rejected:* `@shikijs/colorized-brackets` (does not fit this stack); a tokeniser per language. -- **Colour swatches are on by default, and careful by context.** Stylesheets match every colour in a declaration value; other code needs quotes or a `:`, `=` or `,` before the colour; prose skips all-digit hex (issue numbers), short all-letter hex (`#cafe`) and bare colour names. A false positive in docs costs more than a missed swatch. A function gets a swatch only with literal numbers, because the browser draws the swatch from the text. Each colour becomes a button only once the client module loads, so a page without JavaScript has no dead tab stops. `` source panes set `swatches=false`, so they show what the author writes. +- **Colour swatches are on by default, and careful by context.** Stylesheets match every colour in a declaration value; other code needs quotes or a `:`, `=` or `,` before the colour; prose skips all-digit hex (issue numbers), short all-letter hex (`#cafe`) and bare colour names. A false positive in docs costs more than a missed swatch. A function gets a swatch only with literal numbers, because the browser draws the swatch from the text. Each colour becomes a button only once the client module loads, so a page without JavaScript has no dead tab stops. `` source panes set `swatches=false`, so they show what the author writes. For other syntaxes, such as `| #4caf50 |` in nf-metro, `delimiters` add to the text around a value, and `match: 'all'` drops the value test for hex and functions, but still skips `#123` and `#add` outside quotes. Per-language settings are a separate `byLanguage` map, because `languages` already picks the blocks and a map there could not say "all languages, plus these settings". A `byLanguage` entry replaces the site `match` and `delimiters` for its language. *Rejected:* `languages` as a map (breaks `'all'`); delimiters on the fence line (`swatches.match="all"` covers one block). - **File icons default to vscode-icons**, by the user's choice: coloured icons look like an editor. `@iconify-json/vscode-icons` (3.7 MB) is therefore a runtime dependency. The `seti` set uses the icons and file name rules of Starlight's ``, so a file looks the same in a tree and in a block. Starlight does not export them, so `scripts/file-icons.mjs` copies them into `file-icons-data.ts`, and a test fails when the installed Starlight differs. After the file name, the icon comes from the block language, then the default file icon, as in xt0rted/expressive-code-file-icons, whose `icon` and `no-icon` attributes it also reads. GitHub files (`.github/**`, `.gitattributes`) get the octocat from rules on the whole title path, which come before the set's rules; the user found Seti's YAML "!" ugly as the first icon on the homepage. Coloured sets (`material`, `vscode-icons`, `catppuccin`) take their icons from `@iconify-json/*` packages; `material` and `catppuccin` are optional peer dependencies, so a site installs only the set it uses (0.9 to 3.9 MB, against 6.7 MB for `material-icon-theme`). Their file name rules come from each VS Code icon theme manifest, which `scripts/icon-sets.mjs` copies into `icon-sets/*.ts` (loaded only for that set), keeping only icons that the Iconify package has. Ids are prefixed per icon. Light variants switch per theme through two style settings: `-light` icons for Material and vscode-icons, and Catppuccin Macchiato colours swapped for Latte. A name that a set lacks falls back to Seti. On by default, in editor frames with a title only. Colours from a site or a block are an inline CSS variable, so `light-dark()` covers both themes; a tile in a custom colour gets black or white from CSS relative colours, as the swatch chip does. The code tabs menu shares the lookup. *Rejected:* Seti as the default (one colour, plainer); `vscode-icons-js` for rules (unchanged since 2023); every set as a full dependency (megabytes for every site); the file-icons set (Atom), whose VS Code theme draws glyphs from five icon fonts, and about a quarter of them, Python included, are not in any SVG package; importing Starlight's files by path (outside its exports); Seti colours per language (hex defaults, against the theme principle). - **Visible whitespace draws its glyph on an `aria-hidden` element** over the real character, so copy and selection are unchanged. Glyphs are the code foreground at low alpha, with no contrast target, because they carry no meaning. - **Word-level diff pairs `del` and `ins` runs through EC annotations**, so syntax colours survive with no second Shiki pass. Changes have a bar under them, removed words are also struck through, and both carry ARIA `insertion` and `deletion` roles, so the tint is not the only mark. Word tints are 25% in dark themes and 40% in light ones, with a 1 px bar in the diff colour under the change. Lower dark tints (15%) kept the syntax colours but made the change hard to see; a stronger dark tint makes the contrast pass wash the words out to plain text. diff --git a/docs/src/content/docs/features/colour-swatches.mdx b/docs/src/content/docs/features/colour-swatches.mdx index 51fc1d0..3cc88ab 100644 --- a/docs/src/content/docs/features/colour-swatches.mdx +++ b/docs/src/content/docs/features/colour-swatches.mdx @@ -34,6 +34,8 @@ Each attribute goes on the code block fence line. | `swatches.shape="square"` | Draws square swatches in the block. | | `swatches.shape="rounded"` | Draws swatches with rounded corners in the block. | | `swatches.shape="circle"` | Draws round swatches in the block. | +| `swatches.match="all"` | Shows a swatch for every hex colour and colour function in the block. | +| `swatches.match="value"` | Shows a swatch only for colours that look like values in the block. | ## Examples @@ -112,6 +114,19 @@ const theme = { +### Every colour in a block + +Some languages put colours where they do not look like values, such as after a `|`. Add `swatches.match="all"` to show a swatch for every hex colour and colour function: + +export const all = ` +\`\`\`text swatches.match="all" +%%metro line: main | Main | #4caf50 +%%metro line: qc | Quality Control | #2196f3 | dashed +\`\`\` +`; + + + ### Turn off swatches for one block Add `swatches=false` to the fence line. The fence line is the first line of the code block, with the language: @@ -149,7 +164,8 @@ The brand colour is #ff5f1f. Use `rebeccapurple` for links. - In CSS, Sass, Less and Stylus blocks, ID selectors such as `#bad { }` and classes such as `.red`. - Variables such as `$green` and `--blue`, and SVG references such as `url(#fade)`. - In other languages, colour names that are not in quotes, such as `paint(red)`. - - In other languages, hex that does not look like a value, such as `// TODO #add`. + - In other languages, hex that does not look like a value, such as `// TODO #add`. The `swatches.delimiters` option adds text, such as `|`, that can come before or after a value. + - With `swatches.match="all"`, issue numbers such as `#123` and words such as `#add` that are not in quotes. - In the prose, issue numbers such as #123, tags such as #cafe, and colour names such as red that are not inline code. - Hover and copy: - Hovering over a colour shows it on a chip in its own colour, with a darker border. The text turns black or white, whichever reads on the colour. @@ -173,6 +189,21 @@ codeblocks({ With `swatches.languages` set, add `swatches` to the fence line of a block of another language to turn on its swatches. +To find colours after and before a `|` in Mermaid blocks only, and every colour in `metro` blocks: + +```js title="astro.config.mjs" +codeblocks({ + swatches: { + byLanguage: { + mermaid: { delimiters: { before: ['|'], after: ['|'] } }, + metro: { match: 'all' }, + }, + }, +}); +``` + +The `swatches.match` and `swatches.delimiters` options set the same for every language. A language in `swatches.byLanguage` uses its own settings in place of them. + ## Limitations diff --git a/packages/starlight-codeblocks/src/expressive-code/swatches.ts b/packages/starlight-codeblocks/src/expressive-code/swatches.ts index f1784ae..06ac328 100644 --- a/packages/starlight-codeblocks/src/expressive-code/swatches.ts +++ b/packages/starlight-codeblocks/src/expressive-code/swatches.ts @@ -7,6 +7,15 @@ import { PREFIX } from './styles.ts'; export const swatchFormats = ['hex', 'rgb', 'hsl', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color', 'named'] as const; export type SwatchFormat = (typeof swatchFormats)[number]; export type SwatchShape = 'square' | 'rounded' | 'circle'; +export type SwatchMatch = 'value' | 'all'; +export interface SwatchDelimiters { + before?: string[]; + after?: string[]; +} +export interface SwatchMatching { + match?: SwatchMatch; + delimiters?: SwatchDelimiters; +} export interface SwatchSettings { languages: 'all' | string[]; @@ -16,6 +25,9 @@ export interface SwatchSettings { hover: boolean; copy: boolean; prose: boolean; + match: SwatchMatch; + delimiters: SwatchDelimiters; + byLanguage: Record; } /** @@ -58,8 +70,31 @@ export interface ColourMatch { const QUOTES = `'"\``; +interface Matcher { + all: boolean; + before: RegExp; + after: RegExp; +} + +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const anyOf = (defaults: string, extra: string[] = []) => [defaults, ...extra.map(escapeRegExp)].join('|'); + +/** How `code` matches colours: all of them, or only values, with the site's extra delimiters. */ +export const swatchMatcher = ({ match, delimiters }: SwatchMatching = {}): Matcher => ({ + all: match === 'all', + before: new RegExp(String.raw`(?:${anyOf('[:=,(]', delimiters?.before)})\s*$`), + after: new RegExp(String.raw`^\s*(?:${anyOf(String.raw`[,;)}\]]|$`, delimiters?.after)})`), +}); + +const valueMatcher = swatchMatcher(); + /** Finds CSS colours in one line of `text`. See `SwatchContext` for how careful each context is. */ -export function findColours(text: string, context: SwatchContext, formats: readonly SwatchFormat[]): ColourMatch[] { +export function findColours( + text: string, + context: SwatchContext, + formats: readonly SwatchFormat[], + matcher = valueMatcher, +): ColourMatch[] { const matches: ColourMatch[] = []; for (const match of text.matchAll(PATTERN)) { const colour = match[0]; @@ -76,26 +111,36 @@ export function findColours(text: string, context: SwatchContext, formats: reado // An SVG fragment, such as `url(#fade)`. if (/url\(\s*['"]?$/i.test(before)) continue; const quoted = QUOTES.includes(prev) && next === prev; - if (format === 'hex' && !keepHex(colour, context, quoted, before, text.slice(end))) continue; + if (format === 'hex' && !keepHex(colour, context, quoted, before, text.slice(end), matcher)) continue; if (format === 'named' && !keepNamed(context, quoted, before, text.slice(end))) continue; - if (format !== 'hex' && format !== 'named' && context === 'code' && !looksLikeValue(prev, before)) continue; + const isFunction = format !== 'hex' && format !== 'named'; + if (isFunction && context === 'code' && !matcher.all && !looksLikeValue(prev, before, matcher)) continue; matches.push({ start, end, colour }); } return matches; } -/** After a quote, a bracket, or a `:`, `=` or `,`, as in `color: #fff` or `fill="#fff"`. */ -const looksLikeValue = (prev: string, before: string) => /['"`([]/.test(prev) || /[:=,(]\s*$/.test(before); +/** After a quote, a bracket, a `:`, `=` or `,`, or an extra delimiter, as in `color: #fff` or `fill="#fff"`. */ +const looksLikeValue = (prev: string, before: string, matcher: Matcher) => + /['"`([]/.test(prev) || matcher.before.test(before); -function keepHex(colour: string, context: SwatchContext, quoted: boolean, before: string, after: string) { +function keepHex( + colour: string, + context: SwatchContext, + quoted: boolean, + before: string, + after: string, + matcher: Matcher, +) { const digits = colour.slice(1); const onlyDigits = /^\d+$/.test(digits); // `#123` and `#1234` are issue and pull request numbers. if (onlyDigits && digits.length <= 4 && !quoted) return context === 'stylesheet'; if (context === 'stylesheet') return !/^[^;()]*\{/.test(after); - // Not `// TODO #add`, which a comment can hold in any language. + // Not `// TODO #add`, which a comment can hold in any language. In all mode, short hex needs a digit. + if (context === 'code' && matcher.all) return quoted || digits.length >= 6 || /\d/.test(digits); if (context === 'code') - return quoted || (looksLikeValue(before.at(-1) ?? '', before) && /^\s*([,;)}\]]|$)/.test(after)); + return quoted || (looksLikeValue(before.at(-1) ?? '', before, matcher) && matcher.after.test(after)); // In prose, `#add`, `#cafe` and `#123456` are tags or numbers more often than colours. return quoted || (!onlyDigits && (digits.length >= 6 || /\d/.test(digits))); } @@ -252,6 +297,7 @@ class SwatchAnnotation extends ExpressiveCodeAnnotation { /** Shows a swatch before each CSS colour in code. `swatches=false` turns it off for one block. */ export function pluginSwatches(settings: SwatchSettings): CodeblocksPlugin { const ids = settings.languages === 'all' ? undefined : new Set(settings.languages.map(languageId)); + const byLanguage = new Map(Object.entries(settings.byLanguage).map(([name, value]) => [languageId(name), value])); return { name: 'starlight-codeblocks:swatches', baseStyles: ({ cssVar }) => @@ -265,12 +311,23 @@ export function pluginSwatches(settings: SwatchSettings): CodeblocksPlugin { ), ...(settings.copy && { jsModules: clientJsModules }), hooks: { - annotateCode({ codeBlock }) { - const on = codeBlock.metaOptions.getBoolean('swatches') ?? (!ids || ids.has(languageId(codeBlock.language))); + annotateCode(hookContext) { + const { codeBlock } = hookContext; + const id = languageId(codeBlock.language); + const on = codeBlock.metaOptions.getBoolean('swatches') ?? (!ids || ids.has(id)); if (!on) return; const context = blockContext(codeBlock.language); + const language = byLanguage.get(id); + const match = blockSetting( + hookContext, + 'swatches.match', + (raw) => (raw === 'value' || raw === 'all' ? raw : undefined), + language?.match ?? settings.match, + '`"value"` or `"all"`', + ); + const matcher = swatchMatcher({ match, delimiters: language?.delimiters ?? settings.delimiters }); for (const line of codeBlock.getLines()) { - for (const { start, end, colour } of findColours(line.text, context, settings.formats)) { + for (const { start, end, colour } of findColours(line.text, context, settings.formats, matcher)) { line.addAnnotation(new SwatchAnnotation(colour, { columnStart: start, columnEnd: end })); } } diff --git a/packages/starlight-codeblocks/src/options.ts b/packages/starlight-codeblocks/src/options.ts index f33ac12..a3d32d5 100644 --- a/packages/starlight-codeblocks/src/options.ts +++ b/packages/starlight-codeblocks/src/options.ts @@ -2,7 +2,14 @@ import { nextflow } from './adapters/nextflow.ts'; import { python } from './adapters/python.ts'; import type { FileIconLanguage, FileIconSet, FileIconStyle } from './expressive-code/file-icons.ts'; import { fileIconSets, isCssColour } from './expressive-code/file-icons.ts'; -import { type SwatchFormat, type SwatchShape, swatchFormats } from './expressive-code/swatches.ts'; +import { + type SwatchDelimiters, + type SwatchFormat, + type SwatchMatch, + type SwatchMatching, + type SwatchShape, + swatchFormats, +} from './expressive-code/swatches.ts'; export interface LineStateDefinition { label: string; @@ -93,6 +100,9 @@ export interface CodeblocksOptions { hover?: boolean; copy?: boolean; prose?: boolean; + match?: SwatchMatch; + delimiters?: SwatchDelimiters; + byLanguage?: Record; }; fileIcons?: | false @@ -178,6 +188,11 @@ const oneOf = (...values: string[]) => (value: unknown) => values.includes(value as string); +const isDelimiters = (value: unknown) => + isObject(value) && + Object.entries(value).every( + ([key, list]) => ['before', 'after'].includes(key) && isStringArray(list) && !(list as string[]).includes(''), + ); // Attributes and directives of other features, which a custom state name would clash with. const reservedStateNames = new Set( @@ -366,6 +381,33 @@ export const optionsReference: Record = { 'Also show swatches in the text of Markdown pages, and in inline code that is one colour. Named colours get a swatch only in inline code.', valid: (value) => typeof value === 'boolean', }, + match: { + type: "'value' | 'all'", + default: 'value', + description: + 'Outside stylesheets, `value` shows a swatch only for a colour that looks like a value. `all` shows a swatch for every hex colour and colour function. A code block can set its own on its fence line.', + valid: oneOf('value', 'all'), + }, + delimiters: { + type: '{ before?: string[]; after?: string[] }', + default: {}, + description: + 'More text that can come before or after a colour that looks like a value, such as `|`. They add to the built-in delimiters.', + valid: isDelimiters, + }, + byLanguage: { + type: "Record", + default: {}, + description: + 'A `match` and `delimiters` for the blocks of each language. They replace the site settings for that language.', + valid: isRecordOf( + (language) => + isObject(language) && + Object.keys(language).every((key) => ['match', 'delimiters'].includes(key)) && + (language.match === undefined || oneOf('value', 'all')(language.match)) && + (language.delimiters === undefined || isDelimiters(language.delimiters)), + ), + }, }, }, fileIcons: { diff --git a/packages/starlight-codeblocks/src/reference.ts b/packages/starlight-codeblocks/src/reference.ts index 9b622e6..42fe8b5 100644 --- a/packages/starlight-codeblocks/src/reference.ts +++ b/packages/starlight-codeblocks/src/reference.ts @@ -100,6 +100,14 @@ export const attributesReference: AttributeDoc[] = [ page: 'features/colour-swatches', example: { lang: 'css', meta: 'swatches.shape="circle"', code: '.badge {\n background: #2563eb;\n}' }, }, + { + name: 'swatches.match', + syntax: ['swatches.match="all"', 'swatches.match="value"'], + description: + 'Sets which colours get a swatch in the block. `"all"` shows a swatch for every hex colour and colour function. It overrides the `swatches.match` option for the block.', + page: 'features/colour-swatches', + example: { lang: 'text', meta: 'swatches.match="all"', code: 'line: main | Main | #4caf50' }, + }, { name: 'icon', syntax: ['icon=""', 'icon=false', 'no-icon'], diff --git a/packages/starlight-codeblocks/test/swatches.test.ts b/packages/starlight-codeblocks/test/swatches.test.ts index 111ea6c..2295120 100644 --- a/packages/starlight-codeblocks/test/swatches.test.ts +++ b/packages/starlight-codeblocks/test/swatches.test.ts @@ -1,6 +1,6 @@ import { createMarkdownProcessor } from '@astrojs/markdown-remark'; import { expect, test } from 'vitest'; -import { findColours, swatchFormats, wholeColour } from '../src/expressive-code/swatches.ts'; +import { findColours, swatchFormats, swatchMatcher, wholeColour } from '../src/expressive-code/swatches.ts'; import { resolveOptions } from '../src/options.ts'; import { mdastPlugins } from '../src/satteri/index.ts'; import { remarkFromSatteri } from '../src/satteri/remark.ts'; @@ -51,6 +51,26 @@ test('in other code, finds only colours that look like values', () => { expect(found('page.html#facade', 'code')).toEqual([]); }); +const metro = ['%%metro line: main | Main | #4CAF50', '%%metro line: qc | Quality Control | #2196F3 | dashed']; + +test('extra delimiters and all mode find colours that do not look like values', () => { + const code = (text: string, matching: Parameters[0]) => + findColours(text, 'code', all, swatchMatcher(matching)).map((m) => m.colour); + expect(metro.flatMap((line) => found(line, 'code'))).toEqual([]); + const pipes = { delimiters: { before: ['|'], after: ['|'] } }; + expect(metro.flatMap((line) => code(line, pipes))).toEqual(['#4CAF50', '#2196F3']); + expect(code('a | #fff b', pipes)).toEqual([]); + expect(code('mix(#fff -> #000)', { delimiters: { after: ['->'] } })).toEqual(['#fff']); + expect(metro.flatMap((line) => code(line, { match: 'all' }))).toEqual(['#4CAF50', '#2196F3']); + expect(code('fill #1e1e2e then hsl(0 0% 0%) and #0f0', { match: 'all' })).toEqual([ + '#1e1e2e', + 'hsl(0 0% 0%)', + '#0f0', + ]); + expect(code('// TODO #add the red tan button, see #123 and #4567', { match: 'all' })).toEqual([]); + expect(code(".red { a: '#123' }", { match: 'all' })).toEqual(['#123']); +}); + test('in prose, skips issue numbers, tags and colour names', () => { expect(found('Fixed in #123 and #4567, tagged #cafe and #added, and red text.', 'prose')).toEqual([]); expect(found('The brand colour is #ff5f1f, with #0f0 and rgb(0 0 0 / 0.5) accents.', 'prose')).toEqual([ @@ -100,6 +120,29 @@ test('swatches=false, the option, languages and copy each turn parts off', async expect((await render(css, { swatches: { copy: false } })).html).not.toContain('data-scb-swatches'); }); +test('match and delimiters apply per site, per language and per block', async () => { + const count = async (markdown: string, options = {}) => + (await render(markdown, options)).html.match(/class="scb-swatch-text"/g)?.length ?? 0; + const metroBlock = block('metro', ...metro); + const pipes = { delimiters: { before: ['|'], after: ['|'] } }; + expect(await count(metroBlock)).toBe(0); + expect(await count(metroBlock, { swatches: pipes })).toBe(2); + expect(await count(metroBlock, { swatches: { match: 'all' } })).toBe(2); + expect(await count(metroBlock, { swatches: { byLanguage: { metro: pipes } } })).toBe(2); + expect(await count(metroBlock, { swatches: { byLanguage: { metro: { match: 'all' } } } })).toBe(2); + expect(await count(block('yaml', 'a: x | #fff'), { swatches: { byLanguage: { metro: pipes } } })).toBe(0); + expect(await count(metroBlock, { swatches: { match: 'all', byLanguage: { metro: { match: 'value' } } } })).toBe(0); + const { html, copyText, warnings } = await render(block('metro swatches.match="all"', ...metro)); + expect(html.match(/class="scb-swatch-text"/g)).toHaveLength(2); + expect(copyText).toBe(metro.join('\n')); + // Only the warning that `metro` has no grammar. + expect(warnings).toEqual([expect.stringContaining('language could not be found')]); + expect(await count(block('metro swatches.match="value"', ...metro), { swatches: { match: 'all' } })).toBe(0); + const bad = await render(block('metro swatches.match="every"', ...metro)); + expect(bad.warnings).toContainEqual(expect.stringContaining('`swatches.match="every"` must be')); + expect(await count(block('md swatches.match="all"', 'Fixed in #123, see #add.'))).toBe(0); +}); + test('shape, size and hover change the styles', async () => { const css = await baseStyles({ swatches: { shape: 'circle', size: '10px', hover: false } }); expect(css).toContain('border-radius:50%'); @@ -127,7 +170,24 @@ test('the hover chip is solid, with a darker border and readable text', async () }); test('validates the options', () => { - expect(resolveOptions().swatches).toMatchObject({ languages: 'all', shape: 'rounded', prose: false, copy: true }); + expect(resolveOptions().swatches).toMatchObject({ + languages: 'all', + shape: 'rounded', + prose: false, + copy: true, + match: 'value', + delimiters: {}, + byLanguage: {}, + }); + expect(() => resolveOptions({ swatches: { match: 'some' as never } })).toThrow(/swatches.match/); + expect(() => resolveOptions({ swatches: { delimiters: { before: '|' as never } } })).toThrow(/swatches.delimiters/); + expect(() => resolveOptions({ swatches: { delimiters: { after: [''] } } })).toThrow(/swatches.delimiters/); + expect(() => resolveOptions({ swatches: { byLanguage: { metro: { mode: 'all' } as never } } })).toThrow( + /swatches.byLanguage/, + ); + expect(() => + resolveOptions({ swatches: { match: 'all', byLanguage: { metro: { delimiters: { before: ['|'] } } } } }), + ).not.toThrow(); expect(() => resolveOptions({ swatches: { shape: 'star' as never } })).toThrow(/swatches.shape/); expect(() => resolveOptions({ swatches: { size: '10' } })).toThrow(/swatches.size/); expect(() => resolveOptions({ swatches: { formats: ['cmyk' as never] } })).toThrow(/swatches.formats/); diff --git a/skills/starlight-codeblocks/references/configuration.md b/skills/starlight-codeblocks/references/configuration.md index 845e944..ce049e7 100644 --- a/skills/starlight-codeblocks/references/configuration.md +++ b/skills/starlight-codeblocks/references/configuration.md @@ -29,8 +29,8 @@ codeblocks({ | `wordDiff` | `minSimilarity`: from 0 to 1, default `0.4` | Word-level diff | | `whitespace` | None | Visible whitespace | | `brackets` | `languages`: default `[]` | Colourised brackets | -| `swatches` | `languages`: default `'all'`. `formats`: default every format. `shape`: `'square'`, `'rounded'` or `'circle'`, default `'rounded'`. `size`: default `'0.8em'`. `hover`, `copy`: default `true`. `prose`: default `false` | Colour swatches | | `fileIcons` | `set`: `'vscode-icons'`, `'material'`, `'catppuccin'` or `'seti'`, default `'vscode-icons'`. Material and Catppuccin need their package, such as `@iconify-json/catppuccin`. `style`: `'plain'` or `'tile'`, default `'plain'`. `languages`: `icon`, `colour` and `style` for each language. `files`: icon names, or `false` for no icon, by file name, extension or path pattern. `icons`: custom icons by name | File icons | +| `swatches` | `languages`: default `'all'`. `formats`: default every format. `shape`: `'square'`, `'rounded'` or `'circle'`, default `'rounded'`. `size`: default `'0.8em'`. `hover`, `copy`: default `true`. `prose`: default `false`. `match`: `'value'` or `'all'`, default `'value'`. `delimiters`: `{ before?, after? }` lists of extra text around a value. `byLanguage`: `match` and `delimiters` per language | Colour swatches | | `codeLinks` | None | Code links | | `apiLinks` | `adapters`: default `[python(), nextflow()]` | API auto-linking | | `expandable` | `lines`: default `12`. `auto`: a line count, default `false` | Expandable blocks | @@ -65,6 +65,7 @@ Put a setting on the fence line, with its name from `codeblocks()`, to change it - `placeholders.storage="session"` - `placeholders.storage="none"` - `swatches.shape="square"`, `swatches.shape="rounded"`, `swatches.shape="circle"` +- `swatches.match="all"`, `swatches.match="value"` - `fileIcons.set=""`, `fileIcons.style="plain"`, `fileIcons.style="tile"`, `fileIcons.colour=""` - `runnable.label=""` - `runnable.againLabel=""` diff --git a/skills/starlight-codeblocks/references/readability.md b/skills/starlight-codeblocks/references/readability.md index 050bb4a..f452e54 100644 --- a/skills/starlight-codeblocks/references/readability.md +++ b/skills/starlight-codeblocks/references/readability.md @@ -101,9 +101,11 @@ Starts on its own in every block. A small swatch in the colour goes before each | `swatches=false` | Code block fence line. Turns off the swatches for one block. | | `swatches` | Code block fence line. Turns on the swatches for a block outside `swatches.languages`. | | `swatches.shape="square"`, `swatches.shape="rounded"`, `swatches.shape="circle"` | Code block fence line. Sets the shape for one block. | +| `swatches.match="all"`, `swatches.match="value"` | Code block fence line. `"all"` shows a swatch for every hex colour and colour function in one block. | - In stylesheets, every colour in a declaration value gets a swatch. ID selectors, classes, variables and `url(#id)` do not. - In other languages, a colour gets a swatch only in quotes or after `:`, `=` or `,`. Colour names need quotes. +- For a language that puts colours after other text, such as `| #4caf50 |`: set `swatches.delimiters` (`{ before: ['|'], after: ['|'] }`), or `swatches.match: 'all'`. `swatches.byLanguage` sets both per language, such as `{ mermaid: { match: 'all' } }`. In `'all'` mode, `#123` and `#add` still get no swatch. - A colour function with `var()` or `calc()` in it has no swatch. - The swatches copy no text. Copy needs JavaScript; the swatches do not. - Options: `swatches.languages` (default `'all'`) and `swatches.formats` (default every format) choose what gets a swatch. From 239623357f1f62fd613596b9bf5d6bb86783865f Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 5 Oct 2026 12:30:35 +0200 Subject: [PATCH 2/2] swatches: write the all-mode issue number example as prose, so it gets no swatch Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/src/content/docs/features/colour-swatches.mdx | 2 +- skills/starlight-codeblocks/references/configuration.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/features/colour-swatches.mdx b/docs/src/content/docs/features/colour-swatches.mdx index 3cc88ab..75e9ee7 100644 --- a/docs/src/content/docs/features/colour-swatches.mdx +++ b/docs/src/content/docs/features/colour-swatches.mdx @@ -165,7 +165,7 @@ The brand colour is #ff5f1f. Use `rebeccapurple` for links. - Variables such as `$green` and `--blue`, and SVG references such as `url(#fade)`. - In other languages, colour names that are not in quotes, such as `paint(red)`. - In other languages, hex that does not look like a value, such as `// TODO #add`. The `swatches.delimiters` option adds text, such as `|`, that can come before or after a value. - - With `swatches.match="all"`, issue numbers such as `#123` and words such as `#add` that are not in quotes. + - With `swatches.match="all"`, issue numbers such as #123 and words such as #add that are not in quotes. - In the prose, issue numbers such as #123, tags such as #cafe, and colour names such as red that are not inline code. - Hover and copy: - Hovering over a colour shows it on a chip in its own colour, with a darker border. The text turns black or white, whichever reads on the colour. diff --git a/skills/starlight-codeblocks/references/configuration.md b/skills/starlight-codeblocks/references/configuration.md index ce049e7..03805a5 100644 --- a/skills/starlight-codeblocks/references/configuration.md +++ b/skills/starlight-codeblocks/references/configuration.md @@ -29,8 +29,8 @@ codeblocks({ | `wordDiff` | `minSimilarity`: from 0 to 1, default `0.4` | Word-level diff | | `whitespace` | None | Visible whitespace | | `brackets` | `languages`: default `[]` | Colourised brackets | -| `fileIcons` | `set`: `'vscode-icons'`, `'material'`, `'catppuccin'` or `'seti'`, default `'vscode-icons'`. Material and Catppuccin need their package, such as `@iconify-json/catppuccin`. `style`: `'plain'` or `'tile'`, default `'plain'`. `languages`: `icon`, `colour` and `style` for each language. `files`: icon names, or `false` for no icon, by file name, extension or path pattern. `icons`: custom icons by name | File icons | | `swatches` | `languages`: default `'all'`. `formats`: default every format. `shape`: `'square'`, `'rounded'` or `'circle'`, default `'rounded'`. `size`: default `'0.8em'`. `hover`, `copy`: default `true`. `prose`: default `false`. `match`: `'value'` or `'all'`, default `'value'`. `delimiters`: `{ before?, after? }` lists of extra text around a value. `byLanguage`: `match` and `delimiters` per language | Colour swatches | +| `fileIcons` | `set`: `'vscode-icons'`, `'material'`, `'catppuccin'` or `'seti'`, default `'vscode-icons'`. Material and Catppuccin need their package, such as `@iconify-json/catppuccin`. `style`: `'plain'` or `'tile'`, default `'plain'`. `languages`: `icon`, `colour` and `style` for each language. `files`: icon names, or `false` for no icon, by file name, extension or path pattern. `icons`: custom icons by name | File icons | | `codeLinks` | None | Code links | | `apiLinks` | `adapters`: default `[python(), nextflow()]` | API auto-linking | | `expandable` | `lines`: default `12`. `auto`: a line count, default `false` | Expandable blocks |