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
2 changes: 1 addition & 1 deletion .agents/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<Example>` 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. `<Example>` 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 `<FileTree>`, 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.
Expand Down
33 changes: 32 additions & 1 deletion docs/src/content/docs/features/colour-swatches.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -112,6 +114,19 @@ const theme = {

<Example code={js} layout="tabs" />

### 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
\`\`\`
`;

<Example code={all} layout="tabs" />

### 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:
Expand Down Expand Up @@ -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.
Expand All @@ -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.

<LinkCard title="Configuration" description="Learn how to set options for the whole site, or for a single code block." href="/starlight-codeblocks/configuration/" />

## Limitations
Expand Down
79 changes: 68 additions & 11 deletions packages/starlight-codeblocks/src/expressive-code/swatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -16,6 +25,9 @@ export interface SwatchSettings {
hover: boolean;
copy: boolean;
prose: boolean;
match: SwatchMatch;
delimiters: SwatchDelimiters;
byLanguage: Record<string, SwatchMatching>;
}

/**
Expand Down Expand Up @@ -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];
Expand All @@ -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)));
}
Expand Down Expand Up @@ -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 }) =>
Expand All @@ -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 }));
}
}
Expand Down
44 changes: 43 additions & 1 deletion packages/starlight-codeblocks/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,6 +100,9 @@ export interface CodeblocksOptions {
hover?: boolean;
copy?: boolean;
prose?: boolean;
match?: SwatchMatch;
delimiters?: SwatchDelimiters;
byLanguage?: Record<string, SwatchMatching>;
};
fileIcons?:
| false
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -366,6 +381,33 @@ export const optionsReference: Record<keyof CodeblocksOptions, Feature> = {
'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<string, { match?: 'value' | 'all'; delimiters?: { before?: string[]; after?: string[] } }>",
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: {
Expand Down
8 changes: 8 additions & 0 deletions packages/starlight-codeblocks/src/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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="<name>"', 'icon=false', 'no-icon'],
Expand Down
Loading
Loading