diff --git a/.agents/DECISIONS.md b/.agents/DECISIONS.md index e666a61..9b605ad 100644 --- a/.agents/DECISIONS.md +++ b/.agents/DECISIONS.md @@ -75,6 +75,14 @@ The choices that the code does not explain, with the options that were rejected. - **A callout is a sibling of its line**, so line tints and blur do not cover it. It takes the highlight of the lines around it when both sides match. Its width uses `cqi` of the `pre`, except in a side annotation grid, where containment would collapse the code column. - **A callout's text is selectable only in a selection that starts in its bubble.** A `pointerdown` in a bubble turns on `user-select: text` for that bubble until the next `pointerdown`, so a drag across the code still leaves every bubble out, and what readers see selected is what they copy. *Rejected:* `user-select: contain` (no browser support); a selectable bubble with a `copy` handler that removes callout text (the paste would differ from the selection, as for hidden lines). +### Squiggles + +- **Squiggles mimic VS Code diagnostics**: `[!error /text/]`, `[!warning /text/]` and `[!info /text/]` (with `warn` and `note`, as in line states), placed like callouts. The bare names keep them apart from line states, which use `[!code error]`. *Rejected:* `[!squiggle error]` (longer for no gain); VS Code's hint severity (three faint dots, below 3:1 at any size) and its unnecessary and deprecated tags (fade and strikethrough are not squiggles); a separate syntax for the source and code, such as `ts(2552)` (authors put it in the message). +- **The wave is VS Code's own 6 × 3 px SVG, as a CSS mask over a background colour**, so the colour comes from a theme variable. *Rejected:* `text-decoration: wavy` (a different, rounder shape in each browser); an SVG background (a data URI cannot read CSS variables). +- **The squiggle annotation renders in the `latest` phase**, so EC merges the tokens of the range into one element and the wave has no breaks. A later annotation that overlaps only part of the range still splits it. +- **Every squiggle has a hover, also with no message**: the icon and the name of the severity tell error from warning without colour (WCAG 1.4.1). The message is a `popover="manual"` inside the squiggle, so hover over the message keeps it open. It opens after 300 ms, as in VS Code, at once on keyboard focus, and Escape closes it (WCAG 1.4.13). Screen readers read the severity and the message from `scb-sr-only` text after the code. +- **The script moves a message back inside the window**: when the block scrolls the text past the window edge, the text's position area reaches past it too, so `position-try` sees no overflow. + ### Hidden lines, expandable blocks - **Expandable blocks collapse only in the client**, with `hidden="until-found"`, so a block is complete without JavaScript and find in page reveals lines. Collapsed lines use `content-visibility: hidden` because EC's `all: revert` removes the browser's until-found style. `auto` skips code tabs variants, runnable blocks and collapsible sections; the components remove an `auto` cap. diff --git a/.github/assets/readme/squiggles.png b/.github/assets/readme/squiggles.png new file mode 100644 index 0000000..2c36bb7 Binary files /dev/null and b/.github/assets/readme/squiggles.png differ diff --git a/AGENTS.md b/AGENTS.md index 21d3284..6895b5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agent instructions -`starlight-codeblocks` is a Starlight plugin that adds 26 code block features on top of Expressive Code (EC), which renders every code block in Starlight. `packages/starlight-codeblocks/` is the plugin, `docs/` its Starlight docs site (it uses the plugin through `workspace:*`, and defines each feature's syntax and behaviour), `examples/astro/` a minimal Astro site without Starlight, and `skills/starlight-codeblocks/` an agent skill for people who use the plugin. +`starlight-codeblocks` is a Starlight plugin that adds 27 code block features on top of Expressive Code (EC), which renders every code block in Starlight. `packages/starlight-codeblocks/` is the plugin, `docs/` its Starlight docs site (it uses the plugin through `workspace:*`, and defines each feature's syntax and behaviour), `examples/astro/` a minimal Astro site without Starlight, and `skills/starlight-codeblocks/` an agent skill for people who use the plugin. - `.agents/DECISIONS.md`: design decisions and the rejected options. Search it before you change existing behaviour. - `.agents/WRITING-STYLE.md`: how the docs read, the feature page template and the glossary. diff --git a/README.md b/README.md index f64533e..42df31e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ starlight-codeblocks -A Starlight and Astro plugin that adds 26 features to code blocks, such as focus, line states, annotations, links to API docs and runnable examples. It builds on Expressive Code, so the code blocks you already have keep working. +A Starlight and Astro plugin that adds 27 features to code blocks, such as focus, line states, annotations, links to API docs and runnable examples. It builds on Expressive Code, so the code blocks you already have keep working. Using an agent? [Point it at the bundled skill](https://ewels.github.io/starlight-codeblocks/agent-skill/). @@ -189,6 +189,22 @@ for name in sys.argv[1:] # [!code error] SyntaxError: expected ':' +
+Squiggles + +Draw a wavy error, warning or info line under code, as a code editor does, with a message that shows on hover. + +```ts +// [!error /readFle/] Cannot find name `readFle`. Did you mean `readFile`? +const config = JSON.parse(await readFle('config.json', 'utf8')); +``` + +A TypeScript block with a yellow wavy line under the name join and a red wavy line under readFle. + +[Squiggles documentation](https://ewels.github.io/starlight-codeblocks/features/squiggles/) + +
+
Code mentions diff --git a/docs/e2e/api-links.test.ts b/docs/e2e/api-links.test.ts index a06065e..bcaa476 100644 --- a/docs/e2e/api-links.test.ts +++ b/docs/e2e/api-links.test.ts @@ -37,7 +37,7 @@ test('hovering shows the card under the link, the pointer can move onto it, and await expect(card(page).locator('.scb-api-card-head')).toHaveText('function json.loads'); // The summary comes from the live docs.python.org page, so its words can change. await expect(card(page).locator('.scb-api-card-summary')).not.toBeEmpty(); - await expect(card(page).locator('.scb-api-card-source')).toHaveText('Python 3.14 documentation'); + await expect(card(page).locator('.scb-api-card-source')).toHaveText(/^Python 3\.\d+ documentation$/); const l = await link.boundingBox(); const c = await card(page).boundingBox(); expect(c && l && c.y).toBeGreaterThanOrEqual((l?.y ?? 0) + (l?.height ?? 0)); diff --git a/docs/e2e/carousel.test.ts b/docs/e2e/carousel.test.ts index 642ce11..d548b3f 100644 --- a/docs/e2e/carousel.test.ts +++ b/docs/e2e/carousel.test.ts @@ -149,7 +149,7 @@ test('a button or a dot shows its example, announces it and marks itself, and th await expect(page.locator('.carousel .slide:not([data-current])').first()).toBeHidden(); await expect(page.locator('.carousel .tile[aria-pressed="true"]')).toHaveCount(1); await expect(tile(page, 'Word-level diff')).toHaveAttribute('aria-pressed', 'true'); - await expect(page.locator('.carousel [data-status]')).toHaveText('Word-level diff, 17 of 25'); + await expect(page.locator('.carousel [data-status]')).toHaveText('Word-level diff, 18 of 26'); // The current button differs by more than colour. expect(await css(tile(page, 'Word-level diff'), 'fontWeight')).toBe('600'); expect(await css(tile(page, 'Footnotes'), 'fontWeight')).toBe('400'); @@ -159,11 +159,11 @@ test('a button or a dot shows its example, announces it and marks itself, and th await expect(dot(page, 'Focus')).toHaveAttribute('aria-current', 'true'); await expect(dot(page, 'Word-level diff')).toHaveAttribute('aria-current', 'false'); await expect(tile(page, 'Focus')).toHaveAttribute('aria-pressed', 'true'); - await expect(page.locator('.carousel [data-status]')).toHaveText(/^Focus, \d+ of 25$/); + await expect(page.locator('.carousel [data-status]')).toHaveText(/^Focus, \d+ of 26$/); await expect(page.locator('.carousel .rotation')).toHaveAccessibleName('Play'); }); -test('the buttons are a flat list in sidebar order, in even columns, with one dot each', async ({ page }) => { +test('the buttons are a flat list in sidebar order, in columns, with one dot each', async ({ page }) => { await page.goto('./install/starlight/'); const sidebarIds = await page .locator('nav[aria-label="Main"] a[href*="/features/"]') @@ -173,8 +173,8 @@ test('the buttons are a flat list in sidebar order, in even columns, with one do await page.goto('./'); await expect(page.locator('.carousel .group-label')).toHaveCount(0); const tiles = page.locator('.carousel .tile'); - await expect(tiles).toHaveCount(25); - await expect(page.locator('.carousel .tile svg[aria-hidden], .carousel .tile [aria-hidden] svg')).toHaveCount(25); + await expect(tiles).toHaveCount(26); + await expect(page.locator('.carousel .tile svg[aria-hidden], .carousel .tile [aria-hidden] svg')).toHaveCount(26); const tileIds = await tiles.evaluateAll((els) => els.map((el) => el.getAttribute('data-feature'))); expect(tileIds).toEqual(sidebarIds.filter((id) => tileIds.includes(id))); @@ -183,14 +183,13 @@ test('the buttons are a flat list in sidebar order, in even columns, with one do ); const columns = [...new Set(boxes.map(([x]) => x))].map((x) => boxes.filter(([bx]) => bx === x).map(([, y]) => y)); const lengths = columns.map((c) => c.length); - expect(Math.max(...lengths) - Math.min(...lengths)).toBeLessThanOrEqual(1); // Each column holds the next run of tiles in order, so the x of the tiles never goes back. expect(boxes.every(([x], i) => i === 0 || x >= (boxes[i - 1]?.[0] ?? 0))).toBe(true); for (const column of columns) expect(column).toEqual((columns[0] ?? []).slice(0, column.length)); // The phone viewport (360px) is too narrow for the auto column width, so it's forced to two. - if (phone()) expect(lengths).toEqual([13, 12]); + if (phone()) expect(lengths).toEqual([13, 13]); - await expect(page.locator('.carousel .dot')).toHaveCount(25); + await expect(page.locator('.carousel .dot')).toHaveCount(26); const order = await page.locator('.carousel .controls > *').evaluateAll((els) => els.map((el) => el.className)); expect(order.at(-1)).toContain('rotation'); }); @@ -315,7 +314,7 @@ test.describe('without JavaScript', () => { 'href', '/starlight-codeblocks/features/focus/', ); - await expect(page.locator('.carousel a.tile')).toHaveCount(25); + await expect(page.locator('.carousel a.tile')).toHaveCount(26); const docsLink = page.getByRole('link', { name: 'Read docs : Annotations' }); await expect(docsLink).toBeVisible(); await expect(docsLink).toHaveAttribute('href', '/starlight-codeblocks/features/annotations/'); diff --git a/docs/e2e/squiggles.test.ts b/docs/e2e/squiggles.test.ts new file mode 100644 index 0000000..09aeb63 --- /dev/null +++ b/docs/e2e/squiggles.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; +import { copyFromKeyboard, css, example, phone, reduced } from './helpers.ts'; + +test.beforeEach(async ({ page }) => { + await page.goto('./features/squiggles/'); +}); + +test('draws the wave under the text in the severity colour, and the copy button leaves out the messages', async ({ + page, +}) => { + const block = example(page); + const error = block.locator('.scb-squiggle-error'); + await expect(error).toHaveCount(1); + const wave = await error.evaluate((el) => { + const after = getComputedStyle(el, '::after'); + return { height: after.height, mask: after.maskImage, background: after.backgroundColor }; + }); + expect(wave.height).toBe('3px'); + expect(wave.mask).toContain('svg'); + expect(wave.background).not.toBe('rgba(0, 0, 0, 0)'); + expect(await copyFromKeyboard(block)).not.toContain('Cannot find'); +}); + +test('keyboard focus opens the message above the text at once, and Escape closes it', async ({ page }) => { + const block = example(page); + const squiggle = block.locator('.scb-squiggle-error'); + const message = squiggle.locator('.scb-squiggle-popover'); + await expect(message).toBeHidden(); + await block.locator('.scb-squiggle-warning').focus(); + await page.keyboard.press('Tab'); + await expect(squiggle).toBeFocused(); + await expect(message).toBeVisible(); + await expect(message).toHaveText(/^ErrorCannot find name readFle\./); + const [text, box] = await Promise.all([squiggle.boundingBox(), message.boundingBox()]); + expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual(text?.y ?? 0); + // From the start of the text, as in VS Code. On a phone, the window edge can move it. + if (!phone()) expect(Math.abs((box?.x ?? 0) - (text?.x ?? 0))).toBeLessThan(2); + expect(box?.x ?? -1).toBeGreaterThanOrEqual(12); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) - 12); + expect(await css(squiggle, 'outlineStyle')).toBe('solid'); + await page.keyboard.press('Escape'); + await expect(message).toBeHidden(); + await expect(squiggle).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(block.locator('.scb-squiggle-warning .scb-squiggle-popover')).toBeVisible(); + await expect(message).toBeHidden(); +}); + +test.describe('hover', () => { + test.skip(({ isMobile }) => isMobile, 'Phones have no hover.'); + + test('hovering shows the message after a delay, and it stays while the pointer moves into it', async ({ page }) => { + const block = example(page); + const squiggle = block.locator('.scb-squiggle-warning'); + const message = squiggle.locator('.scb-squiggle-popover'); + await squiggle.hover(); + await page.waitForTimeout(100); + await expect(message).toBeHidden(); + await expect(message).toBeVisible(); + if (reduced()) expect(await css(message, 'transitionDuration')).toBe('0s'); + await message.hover(); + await page.waitForTimeout(400); + await expect(message).toBeVisible(); + await page.mouse.move(5, 5); + await expect(message).toBeHidden(); + }); + + test('Escape closes a hovered message until the pointer leaves', async ({ page }) => { + const squiggle = example(page).locator('.scb-squiggle-error'); + const message = squiggle.locator('.scb-squiggle-popover'); + await squiggle.hover(); + await expect(message).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(message).toBeHidden(); + await page.mouse.move(5, 5); + await squiggle.hover(); + await expect(message).toBeVisible(); + }); +}); + +test('a squiggle without a message names its severity', async ({ page }) => { + const squiggle = example(page, 3).locator('.scb-squiggle-warning'); + await squiggle.focus(); + await expect(squiggle.locator('.scb-squiggle-popover')).toHaveText('Warning'); + await expect(squiggle.locator('.scb-sr-only')).toHaveText(' (Warning)'); +}); diff --git a/docs/src/components/FeatureCarousel.astro b/docs/src/components/FeatureCarousel.astro index 5917997..1294e56 100644 --- a/docs/src/components/FeatureCarousel.astro +++ b/docs/src/components/FeatureCarousel.astro @@ -12,6 +12,7 @@ const icons: Record = { 'features/scrollycoding': lucide.ScrollText, 'features/focus': lucide.Focus, 'features/line-states': lucide.TriangleAlert, + 'features/squiggles': lucide.Waves, 'features/code-mentions': lucide.AtSign, 'features/word-level-diff': lucide.Diff, 'features/code-walkthrough': lucide.Replace, @@ -397,8 +398,8 @@ if (order.some((at, i) => i > 0 && at < (order[i - 1] ?? 0))) height: 1rem; } /* Columns, so that the tiles read down each column in sidebar order, as the tab order goes. - "12rem" gives five even columns of five on a desktop screen. Below 44rem, it's forced to two - (still column-major, an even 13/12 split) up to where the auto column width would give three. */ + "12rem" gives five columns on a desktop screen. Below 44rem, it's forced to two + (still column-major) up to where the auto column width would give three. */ .picker { columns: 12rem; column-gap: 0.5rem; diff --git a/docs/src/content/docs/features/squiggles.mdx b/docs/src/content/docs/features/squiggles.mdx new file mode 100644 index 0000000..45d22d8 --- /dev/null +++ b/docs/src/content/docs/features/squiggles.mdx @@ -0,0 +1,132 @@ +--- +title: Squiggles +description: Draw a wavy error, warning or info line under code, as a code editor does, with a message that shows on hover. +--- + +import { LinkCard } from '@astrojs/starlight/components'; +import Example from '../../../components/Example.astro'; +import Options from '../../../components/Options.astro'; + +A code editor draws a wavy line under code that has a problem, and shows the message when you hover over it. A code block in a page shows the code, but not the problem. A squiggle draws the same wavy line under the text, in red for an error, yellow for a warning or blue for info. + +:::tip[Good for:] +A mistake in one name or expression that readers must find, as a compiler or linter reports it. +To mark a whole line, use [line states](/starlight-codeblocks/features/line-states/). +::: + +export const basic = ` +\`\`\`ts title="load.ts" +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; // [!warning /join/] \`join\` is declared but its value is never read. + +// [!error /readFle/] Cannot find name \`readFle\`. Did you mean \`readFile\`? +const config = JSON.parse(await readFle('config.json', 'utf8')); +\`\`\` +`; + + + +Each directive is gone from the output. A wavy line is under the text between the slashes. Hover over the line to see the message, or press Tab to move to it. + +## Syntax + +| Syntax | Where | +|---|---| +| `[!error /text/] message` | Comment on or above the line | +| `[!warning /text/] message` | Comment on or above the line | +| `[!info /text/] message` | Comment on or above the line | +| `[!error] message` | Comment on or above the line | +| `warn`, `note` | Comment on or above the line | + +`warn` is another name for `warning`, and `note` for `info`. The directive goes at the end of the line, or on its own line directly above it. `/text/` is literal text, not a regular expression. The wavy line goes under the first match on the line. Without `/text/`, it goes under the whole line, from the first character that is not a space. + +The message is optional. It can hold inline code, links and bold text. + +## Examples + +### A whole line + +A squiggle without `/text/` is under all of the code on its line: + +export const whole = ` +\`\`\`py +import sqlite3 + +db = sqlite3.connect("notes.db") +db.execute("SELECT body FROM notes") # [!info] Returns nothing until you call \`fetchall()\`. +\`\`\` +`; + + + +### Two squiggles on one line + +Two directives on one line draw two wavy lines, each with its own message: + +export const two = ` +\`\`\`js +// [!error /lenght/] Property \`lenght\` does not exist. Did you mean \`length\`? +// [!warning /var/] Use \`let\` or \`const\` instead. +for (var i = 0; i < items.lenght; i++) render(items[i]); +\`\`\` +`; + + + +### A squiggle without a message + +Without a message, the wavy line is enough. A reader who hovers over it sees only the severity: + +export const bare = ` +\`\`\`rust +fn main() { + let unused = 42; // [!warning /unused/] + println!("Hello, world!"); +} +\`\`\` +`; + + + +## Behaviour + +- How it looks: + - The wavy line is VS Code's own shape, under the text. Its colour comes from the theme's error, warning or info colour. + - The line is continuous under text in several colours, such as a function call with its arguments. +- Opening the message: + - Hover over the text for 300 ms to show the message. The mouse cursor can move into the message, for example to click a link. + - Each squiggle is in the tab order. Keyboard focus shows the message at once. + - Escape closes the message. The message closes when the mouse cursor or the focus leaves. +- Where the message opens: + - Above the text, from its first character, as in VS Code. If there is no room above, it opens below. + - The message starts with an icon and the name of the severity, **Error**, **Warning** or **Info**. The colour is not the only sign of the severity. + - A long message wraps at the popover width, 340 px by default. +- Screen readers: + - Screen readers read the severity and the message after the text, such as "(Error: Cannot find name)". +- Copy and print: + - The copy button copies the code without the messages. A manual selection leaves them out too. + - In print, the wavy lines stay, and the messages are a list under the block, with their line numbers. +- Without JavaScript: + - The wavy lines show, but the messages do not open. Screen readers still read them. +- Motion: + - The message fades in and out in 120 ms. With reduced motion, it shows and hides at once. + +## Options + + + +With `squiggles: false`, the plugin does not know the `[!error]`, `[!warning]` and `[!info]` directives. It leaves each comment in the code as written, and logs a build warning. + + + +## Limitations + +- A squiggle is on one line. For a problem that spans lines, put one squiggle on each line. +- `/text/` finds the first match on the line. To mark a later match, make `/text/` longer, so that it is unique on the line. +- Readers see a message only when they hover over the text or focus it. For a message that is always visible, use [line states](/starlight-codeblocks/features/line-states/). + +## Related + +- [Line states](/starlight-codeblocks/features/line-states/): tint a whole line as an error, a warning or a note, with the message always visible after the code. +- [Inline callouts](/starlight-codeblocks/features/inline-callouts/): a short, always visible note above the line, with an arrow at one name. +- [Annotations](/starlight-codeblocks/features/annotations/): a numbered marker that opens a note in a popover. diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 5b8bea0..fa6d239 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -1,10 +1,10 @@ --- title: starlight-codeblocks -description: A plugin for Starlight and Astro that adds focus, line states, annotations, API auto-linking and 22 more features to the code blocks of a site. +description: A plugin for Starlight and Astro that adds focus, line states, annotations, API auto-linking and 23 more features to the code blocks of a site. template: splash hero: tagline: | - This plugin adds 26 features to the Expressive Code blocks of + This plugin adds 27 features to the Expressive Code blocks of Starlight and Astro sites. Use them to enrich code blocks. Using an agent? Point it at the bundled skill. @@ -191,6 +191,18 @@ count = len(sys.argv) # [!code warning] Includes the script name + + +```ts title="load.ts" apiLinks=false +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; // [!warning /join/] `join` is declared but its value is never read. + +// [!error /readFle/] Cannot find name `readFle`. Did you mean `readFile`? +const config = JSON.parse(await readFle('config.json', 'utf8')); +``` + + + The [base case](#mention:base) stops the recursion. The [recursive step](#mention:step) calls the function again with a smaller number. diff --git a/docs/src/content/docs/reference/expressive-code-plugins.mdx b/docs/src/content/docs/reference/expressive-code-plugins.mdx index 3e16b16..4041cd0 100644 --- a/docs/src/content/docs/reference/expressive-code-plugins.mdx +++ b/docs/src/content/docs/reference/expressive-code-plugins.mdx @@ -55,6 +55,7 @@ Each plugin takes the settings of its option, as the [options reference](/starli | `pluginCallouts()` | `callouts` | [Inline callouts](/starlight-codeblocks/features/inline-callouts/) | | `pluginAnnotations()` | `annotations` | [Annotations](/starlight-codeblocks/features/annotations/) and [side annotations](/starlight-codeblocks/features/side-annotations/) | | `pluginFootnotes(settings)` | `footnotes` | [Footnotes](/starlight-codeblocks/features/footnotes/) | +| `pluginSquiggles()` | `squiggles` | [Squiggles](/starlight-codeblocks/features/squiggles/) | `pluginCodeLinks()` and `pluginApiLinks()` take the `base` of the Astro site, for links that start with `/`. With `codeblocks()`, they read it from Astro. `pluginApiLinks()` has no default adapters. Give it the adapters from `starlight-codeblocks/adapters/python` and `starlight-codeblocks/adapters/nextflow`, or your own. @@ -66,7 +67,7 @@ If you add plugins one by one, follow these rules: 2. Add `pluginNotation()` second, if any plugin after it reads directives. 3. Add the other plugins in the order of the table. -The order matters. For example, `pluginPlayground()` sends the copied text, so it must come after `pluginShellCopy()` and `pluginPlaceholders()`, which change that text. `pluginCallouts()` must come after `pluginHiddenLines()`, which rebuilds the lines of the block. +The order matters. For example, `pluginPlayground()` sends the copied text, so it must come after `pluginShellCopy()` and `pluginPlaceholders()`, which change that text. `pluginCallouts()` must come after `pluginHiddenLines()`, which rebuilds the lines of the block. `pluginSquiggles()` comes last, so that its wavy line is one element around the code of other plugins. ## Without codeblocks() diff --git a/docs/src/sidebar.mjs b/docs/src/sidebar.mjs index ead7279..93ca435 100644 --- a/docs/src/sidebar.mjs +++ b/docs/src/sidebar.mjs @@ -23,7 +23,7 @@ export const sidebar = [ }, { label: 'Draw attention', - items: ['features/focus', 'features/line-states', 'features/code-mentions'], + items: ['features/focus', 'features/line-states', 'features/squiggles', 'features/code-mentions'], }, { label: 'Make code easier to read', diff --git a/packages/starlight-codeblocks/README.md b/packages/starlight-codeblocks/README.md index f64533e..42df31e 100644 --- a/packages/starlight-codeblocks/README.md +++ b/packages/starlight-codeblocks/README.md @@ -2,7 +2,7 @@ starlight-codeblocks -A Starlight and Astro plugin that adds 26 features to code blocks, such as focus, line states, annotations, links to API docs and runnable examples. It builds on Expressive Code, so the code blocks you already have keep working. +A Starlight and Astro plugin that adds 27 features to code blocks, such as focus, line states, annotations, links to API docs and runnable examples. It builds on Expressive Code, so the code blocks you already have keep working. Using an agent? [Point it at the bundled skill](https://ewels.github.io/starlight-codeblocks/agent-skill/). @@ -189,6 +189,22 @@ for name in sys.argv[1:] # [!code error] SyntaxError: expected ':'
+
+Squiggles + +Draw a wavy error, warning or info line under code, as a code editor does, with a message that shows on hover. + +```ts +// [!error /readFle/] Cannot find name `readFle`. Did you mean `readFile`? +const config = JSON.parse(await readFle('config.json', 'utf8')); +``` + +A TypeScript block with a yellow wavy line under the name join and a red wavy line under readFle. + +[Squiggles documentation](https://ewels.github.io/starlight-codeblocks/features/squiggles/) + +
+
Code mentions diff --git a/packages/starlight-codeblocks/src/client/squiggles.ts b/packages/starlight-codeblocks/src/client/squiggles.ts new file mode 100644 index 0000000..33c11e7 --- /dev/null +++ b/packages/starlight-codeblocks/src/client/squiggles.ts @@ -0,0 +1,90 @@ +import { anchored, EDGE, place } from './shared/position.ts'; + +const SQUIGGLE = '.scb-squiggle[tabindex]'; +const stops = new WeakMap void>(); +const hiding = new WeakMap(); +// Closed with Escape: the pointer must leave before hover shows it again. +const dismissed = new WeakSet(); +let showing = 0; + +const popoverOf = (squiggle: Element) => squiggle.querySelector(':scope > .scb-squiggle-popover'); + +function show(squiggle: HTMLElement) { + const popover = popoverOf(squiggle); + if (!popover || popover.matches(':popover-open')) return; + popover.style.translate = ''; + popover.showPopover(); + if (!anchored()) { + stops.set(popover, place(popover, squiggle)); + return; + } + // Text that the block scrolls past the edge of the window still fits its own position area. + const { left, right } = popover.getBoundingClientRect(); + const width = document.documentElement.clientWidth; + const shift = right > width - EDGE ? width - EDGE - right : left < EDGE ? EDGE - left : 0; + if (shift) popover.style.translate = `${shift}px`; +} + +function hide(squiggle: Element) { + const popover = popoverOf(squiggle); + if (!popover?.matches(':popover-open')) return; + popover.hidePopover(); + stops.get(popover)?.(); +} + +const squiggleAt = (target: EventTarget | null) => (target as Element | null)?.closest?.(SQUIGGLE); + +/** Hover shows the message after 300 ms, as in VS Code. The popover is inside the squiggle, so the pointer can move into it. */ +function pointerOver(event: PointerEvent) { + const squiggle = squiggleAt(event.target); + if (!squiggle || event.pointerType !== 'mouse') return; + clearTimeout(hiding.get(squiggle)); + if (dismissed.has(squiggle)) return; + clearTimeout(showing); + showing = window.setTimeout(() => show(squiggle), 300); +} + +function pointerOut(event: PointerEvent) { + const squiggle = squiggleAt(event.target); + if (!squiggle || squiggleAt(event.relatedTarget) === squiggle) return; + clearTimeout(showing); + dismissed.delete(squiggle); + if (squiggle.contains(document.activeElement)) return; + hiding.set( + squiggle, + window.setTimeout(() => hide(squiggle), 200), + ); +} + +function focusIn(event: FocusEvent) { + const squiggle = squiggleAt(event.target); + if (squiggle && event.target === squiggle && squiggle.matches(':focus-visible')) show(squiggle); +} + +function focusOut(event: FocusEvent) { + const squiggle = squiggleAt(event.target); + if (squiggle && squiggleAt(event.relatedTarget) !== squiggle && !squiggle.matches(':hover')) hide(squiggle); +} + +function keydown(event: KeyboardEvent) { + if (event.key !== 'Escape') return; + for (const popover of document.querySelectorAll('.scb-squiggle-popover:popover-open')) { + const squiggle = popover.parentElement as HTMLElement; + dismissed.add(squiggle); + hide(squiggle); + if (squiggle.contains(document.activeElement)) squiggle.focus(); + } +} + +let ready = false; + +/** Opens the message of a squiggle on hover and on keyboard focus. Escape closes it. */ +export default function initSquiggles() { + if (ready) return; + ready = true; + document.addEventListener('pointerover', pointerOver); + document.addEventListener('pointerout', pointerOut); + document.addEventListener('focusin', focusIn); + document.addEventListener('focusout', focusOut); + document.addEventListener('keydown', keydown); +} diff --git a/packages/starlight-codeblocks/src/expressive-code/core.ts b/packages/starlight-codeblocks/src/expressive-code/core.ts index 0efe439..294caf3 100644 --- a/packages/starlight-codeblocks/src/expressive-code/core.ts +++ b/packages/starlight-codeblocks/src/expressive-code/core.ts @@ -72,6 +72,8 @@ const decorations = [ 'footnote-badge', 'footnote-num', 'callout', + 'squiggle-popover', + 'squiggle-list', 'hidden-marker', 'expandable-bar', 'steps-stepper', diff --git a/packages/starlight-codeblocks/src/expressive-code/index.ts b/packages/starlight-codeblocks/src/expressive-code/index.ts index 34bd2a5..7d200d8 100644 --- a/packages/starlight-codeblocks/src/expressive-code/index.ts +++ b/packages/starlight-codeblocks/src/expressive-code/index.ts @@ -21,6 +21,7 @@ import { pluginPlaceholders } from './placeholders.ts'; import { pluginPlayground } from './playground.ts'; import { pluginRunnable } from './runnable.ts'; import { pluginShellCopy } from './shell-copy.ts'; +import { pluginSquiggles } from './squiggles.ts'; import { pluginSwatches } from './swatches.ts'; import { pluginWalkthrough } from './walkthrough.ts'; import { pluginWhitespace } from './whitespace.ts'; @@ -48,6 +49,7 @@ export { pluginPlayground, pluginRunnable, pluginShellCopy, + pluginSquiggles, pluginSwatches, pluginWalkthrough, pluginWhitespace, @@ -102,5 +104,7 @@ export function createPlugins(options: ResolvedOptions, base?: string): Expressi ...(options.callouts ? [pluginCallouts()] : []), ...(options.annotations ? [pluginAnnotations(options.annotations)] : []), ...(options.footnotes ? [pluginFootnotes(options.footnotes)] : []), + // Last, so that its wavy line wraps the annotations of the other plugins. + ...(options.squiggles ? [pluginSquiggles()] : []), ]; } diff --git a/packages/starlight-codeblocks/src/expressive-code/squiggles.ts b/packages/starlight-codeblocks/src/expressive-code/squiggles.ts new file mode 100644 index 0000000..6c58a94 --- /dev/null +++ b/packages/starlight-codeblocks/src/expressive-code/squiggles.ts @@ -0,0 +1,282 @@ +import { + type AnnotationRenderOptions, + AttachedPluginData, + ExpressiveCodeAnnotation, + type ExpressiveCodeLine, + ensureColorContrastOnBackground, + PluginStyleSettings, + type StyleResolverFn, + type UnresolvedStyleValue, +} from '@expressive-code/core'; +import { type Element, type ElementContent, h, select, toText } from '@expressive-code/core/hast'; +import { clientJsModules } from '../client-modules.ts'; +import { blockUid, type CodeblocksPlugin, lineNumber, warn } from './core.ts'; +import { inlineMarkdown } from './inline-markdown.ts'; +import { type DirectiveSpecs, getDirectives } from './notation.ts'; +import { onCode, PREFIX, themeColour } from './styles.ts'; + +export interface SquigglesStyleSettings { + /** The wavy line and the icon of an error. Needs 3:1 contrast on the code and on the popover background. */ + error: UnresolvedStyleValue; + warning: UnresolvedStyleValue; + info: UnresolvedStyleValue; +} + +declare module '@expressive-code/core' { + export interface StyleSettings { + codeblocksSquiggles: SquigglesStyleSettings; + } +} + +type Context = Parameters[0]; + +export const severities = { + error: { label: 'Error', themeColour: 'editorError.foreground' }, + warning: { label: 'Warning', themeColour: 'editorWarning.foreground' }, + info: { label: 'Info', themeColour: 'editorInfo.foreground' }, +} as const; + +type Severity = keyof typeof severities; + +/** Other names, as in line states. */ +const aliases: Record = { warn: 'warning', note: 'info' }; + +const colour = (key: string) => (context: Context) => + ensureColorContrastOnBackground( + onCode(context, themeColour(context, key), 3), + context.resolveSetting('codeblocks.popoverBackground'), + 3, + ); + +const styleSettings = new PluginStyleSettings({ + defaultValues: { + codeblocksSquiggles: { + error: colour(severities.error.themeColour), + warning: colour(severities.warning.themeColour), + info: colour(severities.info.themeColour), + }, + }, +}); + +const cls = (suffix = '') => `${PREFIX}-squiggle${suffix}`; + +/** VS Code's squiggle: 6 × 3 px, repeated along the bottom of the text. A mask, so that the colour can be a CSS variable. */ +const WAVE = + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 3' width='6' height='3'%3E%3Cpolygon points='5.5,0 2.5,3 1.1,3 4.1,0'/%3E%3Cpolygon points='4,0 6,2 6,0.6 5.4,0'/%3E%3Cpolygon points='0,2 1,3 2.4,3 0,0.6'/%3E%3C/svg%3E\")"; + +const icon = (path: string) => + `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='${path}'/%3E%3C/svg%3E")`; + +const ICONS: Record = { + error: icon( + 'M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm2.8 9.1-.7.7L8 8.7l-2.1 2.1-.7-.7L7.3 8 5.2 5.9l.7-.7L8 7.3l2.1-2.1.7.7L8.7 8z', + ), + warning: icon('M8 1 0.5 14.5h15zm.6 11.5h-1.2v-1.2h1.2zm0-2.4h-1.2V5.8h1.2z'), + info: icon('M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm.6 11H7.4V7h1.2zm0-6.4H7.4V4.4h1.2z'), +}; + +interface Squiggle { + severity: Severity; + text: ElementContent[]; + line: ExpressiveCodeLine; +} + +const squiggleData = new AttachedPluginData<{ squiggles: Squiggle[] }>(() => ({ squiggles: [] })); + +class SquiggleAnnotation extends ExpressiveCodeAnnotation { + constructor( + private readonly squiggle: Squiggle, + private readonly anchor: string, + inlineRange: { columnStart: number; columnEnd: number }, + ) { + // Last, so that the tokens of the range merge into one node and the wave runs on without a break. + super({ inlineRange, renderPhase: 'latest' }); + } + render({ nodesToTransform }: AnnotationRenderOptions) { + const { severity, text } = this.squiggle; + const { label } = severities[severity]; + const kind = `${cls()} ${cls(`-${severity}`)}`; + // A later annotation that overlaps part of the range splits it. Only the first part opens the message. + return nodesToTransform.map((node, i) => + i > 0 + ? h('span', { class: kind }, [node]) + : h('span', { class: kind, tabindex: '0', style: `anchor-name:${this.anchor}` }, [ + node, + h( + 'span', + { class: `${PREFIX}-sr-only` }, + ` (${label}${text.length ? `: ${toText(h('span', text))}` : ''})`, + ), + h( + 'span', + { + class: `${PREFIX}-float ${cls('-popover')} ${PREFIX}-no-print`, + popover: 'manual', + style: `position-anchor:${this.anchor}`, + }, + [h('span', { class: cls('-label') }, label), ...text], + ), + ]), + ); + } +} + +const directives: DirectiveSpecs = Object.fromEntries([ + ...Object.entries(severities).map(([name, { label }]) => [ + name, + { + text: true, + docs: { + description: `Draws a wavy ${label.toLowerCase()} line under \`/text/\` on the line. Text after the directive shows when a reader hovers over the text or focuses it.`, + args: 'Optional. `/text/` to underline, else the whole line. Then the message.', + example: { + lang: 'js', + code: `const port = proces.env.PORT // [!${name} /proces/] Cannot find name \`proces\`.`, + }, + page: 'features/squiggles', + }, + }, + ]), + ...Object.entries(aliases).map(([name, severity]) => [ + name, + { + text: true, + docs: { + description: `The same as \`[!${severity}]\`.`, + example: { lang: 'js', code: `let count = 0 // [!${name} /count/]` }, + page: 'features/squiggles', + }, + }, + ]), +]); + +/** Draws `[!error /text/]`, `[!warning /text/]` and `[!info /text/]` as a wavy line under the text, with an optional message. */ +export function pluginSquiggles(): CodeblocksPlugin { + return { + name: 'starlight-codeblocks:squiggles', + directives, + styleSettings, + jsModules: clientJsModules, + baseStyles: ({ cssVar }) => ` +${Object.keys(severities) + .map( + (s) => + `.${cls(`-${s}`)} { --scb-squiggle: ${cssVar(`codeblocksSquiggles.${s}` as never)}; --scb-squiggle-icon: ${ICONS[s as Severity]}; }`, + ) + .join('\n')} +.${cls()} { position: relative; } +.${cls()}::after { + content: ''; + position: absolute; + inset-inline: 0; + bottom: -1px; + height: 3px; + background: var(--scb-squiggle); + -webkit-mask: ${WAVE} repeat-x left bottom / 6px 3px; + mask: ${WAVE} repeat-x left bottom / 6px 3px; + pointer-events: none; + print-color-adjust: exact; + -webkit-print-color-adjust: exact; +} +.${cls()}[tabindex] { border-radius: 2px; } +.${cls()} .${PREFIX}-sr-only { user-select: none; -webkit-user-select: none; } +/* Above the text and from its start, as in VS Code. */ +.${cls('-popover')} { + width: max-content; + max-width: min(${cssVar('codeblocks.popoverMaxWidth')}, calc(100vw - 24px)); + margin: 6px 0; + padding: 0.35rem 0.65rem; + position-area: block-start span-inline-end; + justify-self: auto; + position-try-fallbacks: flip-inline, flip-block, flip-block flip-inline; + opacity: 0; + user-select: text; + -webkit-user-select: text; + transition: opacity 120ms ease-out, display 120ms allow-discrete, overlay 120ms allow-discrete; +} +.${cls('-popover')}:popover-open { display: block; opacity: 1; } +@starting-style { + .${cls('-popover')}:popover-open { opacity: 0; } +} +.${cls('-label')} { margin-inline-end: 0.5em; font-weight: 600; } +.${cls('-label')}::before { + content: ''; + display: inline-block; + width: 13px; + height: 13px; + margin-inline-end: 5px; + vertical-align: -2px; + background: var(--scb-squiggle); + -webkit-mask: var(--scb-squiggle-icon) center / contain no-repeat; + mask: var(--scb-squiggle-icon) center / contain no-repeat; +} +@media (forced-colors: active) { + .${cls()}::after, .${cls('-label')}::before { forced-color-adjust: none; background: CanvasText; } +} +.${cls('-list')} { display: none; } +@media print { + .${cls('-list')} { + display: block; + margin: 0; + padding: 0.6rem 1rem 0.75rem 1.5rem; + border-top: ${cssVar('borderWidth')} solid ${cssVar('borderColor')}; + font-size: 0.85em; + } +}`, + hooks: { + annotateCode(context) { + const { codeBlock } = context; + const found = [...Object.keys(severities), ...Object.keys(aliases)].flatMap((name) => + getDirectives(codeBlock, name).map((directive) => ({ + directive, + severity: aliases[name] ?? (name as Severity), + })), + ); + if (found.length === 0) return; + const data = squiggleData.getOrCreateFor(codeBlock); + found.sort((a, b) => a.directive.sourceLine - b.directive.sourceLine); + const uid = blockUid(context); + for (const { directive, severity } of found) { + const line = directive.lines[0]; + if (!line) continue; + const { match } = directive; + const start = match ? line.text.indexOf(match) : line.text.search(/\S/); + const end = match ? start + match.length : line.text.trimEnd().length; + if (start < 0 || end <= start) { + warn( + context, + match + ? `\`/${match}/\` in \`[!${directive.name}]\` is not on its line. The line renders without it.` + : `\`[!${directive.name}]\` has no code to underline on its line.`, + directive.sourceLine, + ); + continue; + } + const squiggle = { severity, text: inlineMarkdown(directive.text ?? ''), line }; + data.squiggles.push(squiggle); + line.addAnnotation( + new SquiggleAnnotation(squiggle, `--${cls()}-${uid}-${data.squiggles.length}`, { + columnStart: start, + columnEnd: end, + }), + ); + } + }, + postprocessRenderedBlock({ codeBlock, renderData }) { + const { squiggles } = squiggleData.getOrCreateFor(codeBlock); + const figure = select('figure', renderData.blockAst); + const pre = figure && select('pre', figure); + if (squiggles.length === 0 || !figure || !pre) return; + (pre as Element).properties.dataScbSquiggles = ''; + // The popovers do not print, so their messages go in a list under the block. + const items = squiggles.map(({ severity, text, line }) => + h('li', [ + `Line ${lineNumber(codeBlock, line)}, ${severities[severity].label.toLowerCase()}`, + ...(text.length ? [': ', ...text] : []), + ]), + ); + figure.children.splice(figure.children.indexOf(pre) + 1, 0, h('ul', { class: cls('-list') }, items)); + }, + }, + }; +} diff --git a/packages/starlight-codeblocks/src/expressive-code/styles.ts b/packages/starlight-codeblocks/src/expressive-code/styles.ts index 0abb856..83239cf 100644 --- a/packages/starlight-codeblocks/src/expressive-code/styles.ts +++ b/packages/starlight-codeblocks/src/expressive-code/styles.ts @@ -228,7 +228,7 @@ ${floatLook(cssVar)} /* Callouts and hidden-line markers sit outside the lines, so they read the width of the line numbers plugin's gutter from here. */ figure:has(.ec-line > .gutter > .ln) { --scb-gutter: calc(var(--lnWidth, 2ch) + 4ch); } /* .frame outweighs Expressive Code's square top corners for code in titled blocks. */ -.frame :is(.${PREFIX}-annotation-popover, .${PREFIX}-callout-bubble, .${PREFIX}-footnotes) code { +.frame :is(.${PREFIX}-annotation-popover, .${PREFIX}-squiggle-popover, .${PREFIX}-callout-bubble, .${PREFIX}-footnotes) code { padding: 0 4px; border-radius: 3px; background: color-mix(in srgb, currentColor 12%, transparent); @@ -237,7 +237,7 @@ figure:has(.ec-line > .gutter > .ln) { --scb-gutter: calc(var(--lnWidth, 2ch) + -webkit-box-decoration-break: clone; box-decoration-break: clone; } -:is(.${PREFIX}-annotation-popover, .${PREFIX}-callout-bubble, .${PREFIX}-footnotes) a:not(.${PREFIX}-footnote-num) { +:is(.${PREFIX}-annotation-popover, .${PREFIX}-squiggle-popover, .${PREFIX}-callout-bubble, .${PREFIX}-footnotes) a:not(.${PREFIX}-footnote-num) { color: inherit; text-underline-offset: 3px; } diff --git a/packages/starlight-codeblocks/src/options.ts b/packages/starlight-codeblocks/src/options.ts index a3d32d5..7884a5b 100644 --- a/packages/starlight-codeblocks/src/options.ts +++ b/packages/starlight-codeblocks/src/options.ts @@ -83,6 +83,7 @@ export interface CodeblocksOptions { lineStates?: false | { states?: Record; prefix?: boolean }; notation?: false | { comments?: Record }; callouts?: false; + squiggles?: false; annotations?: false | { style?: 'filled' | 'outline' }; footnotes?: false | { sticky?: boolean; style?: 'filled' | 'outline' }; hiddenLines?: false; @@ -268,6 +269,10 @@ export const optionsReference: Record = { }, }, callouts: { description: 'Shows a note in a bubble above a line.', page: 'features/inline-callouts' }, + squiggles: { + description: 'Draws wavy error, warning and info lines under code, with an optional message.', + page: 'features/squiggles', + }, annotations: { description: 'Adds numbered markers that open a note.', page: 'features/annotations', diff --git a/packages/starlight-codeblocks/src/reference.ts b/packages/starlight-codeblocks/src/reference.ts index 42fe8b5..373db17 100644 --- a/packages/starlight-codeblocks/src/reference.ts +++ b/packages/starlight-codeblocks/src/reference.ts @@ -735,6 +735,26 @@ export const styleSettingsReference: Record = { }, }, }, + codeblocksSquiggles: { + page: 'features/squiggles', + settings: { + error: { + description: + 'The wavy line and the icon of an error. Needs 3:1 contrast on the code and on the popover background.', + derived: `${fromTheme('editorError.foreground', '3:1')} Also 3:1 on \`codeblocks.popoverBackground\`.`, + }, + warning: { + description: + 'The wavy line and the icon of a warning. Needs 3:1 contrast on the code and on the popover background.', + derived: `${fromTheme('editorWarning.foreground', '3:1')} Also 3:1 on \`codeblocks.popoverBackground\`.`, + }, + info: { + description: + 'The wavy line and the icon of an info. Needs 3:1 contrast on the code and on the popover background.', + derived: `${fromTheme('editorInfo.foreground', '3:1')} Also 3:1 on \`codeblocks.popoverBackground\`.`, + }, + }, + }, codeblocksCallouts: { page: 'features/inline-callouts', settings: { diff --git a/packages/starlight-codeblocks/test/decorations.test.ts b/packages/starlight-codeblocks/test/decorations.test.ts index 052e198..e3dcdb2 100644 --- a/packages/starlight-codeblocks/test/decorations.test.ts +++ b/packages/starlight-codeblocks/test/decorations.test.ts @@ -7,6 +7,7 @@ const blocks = [ block('js', 'a(); // [!code error] Fails', 'b(); // [!code warning]'), block('js', 'run(a); // [!annotate] Runs it.', 'done(); // [!ref] The end.'), block('js', '// [!callout /signal/] Aborts.', 'fetch(url, { signal });'), + block('ts', '// [!error /readFle(path)/] Cannot find `readFle`.', 'const a = await readFle(path); // [!warning]'), block('js title="a.js" hidden={1} id="x" playground="typescript" run', 'setup();', 'main();'), block('js annotations="side"', 'run(a); // [!annotate] Runs it.'), block('sh placeholder="TOKEN"', 'curl -H "Bearer TOKEN"'), diff --git a/packages/starlight-codeblocks/test/options.test.ts b/packages/starlight-codeblocks/test/options.test.ts index a774277..e10c15e 100644 --- a/packages/starlight-codeblocks/test/options.test.ts +++ b/packages/starlight-codeblocks/test/options.test.ts @@ -5,6 +5,7 @@ test('turns every feature on with defaults, in new objects on each call', () => const options = resolveOptions(); expect(options.focus).toEqual({ style: 'blur' }); expect(options.callouts).toBe(true); + expect(options.squiggles).toBe(true); expect(options.annotations).toEqual({ style: 'filled' }); expect(options.footnotes).toEqual({ sticky: false, style: 'outline' }); expect(options.shellCopy).toEqual({ prompts: ['$ ', '> '] }); @@ -47,6 +48,7 @@ test('rejects options that are not valid, with an OptionsError that names the op [{ focus: { blur: 2 } }, 'unknown option `focus.blur`'], [{ focus: true }, '`focus` must be `false` or an object, got true'], [{ callouts: {} }, '`callouts` must be `false` or left out'], + [{ squiggles: true }, '`squiggles` must be `false` or left out'], [{ annotations: { style: 'solid' } }, '`annotations.style` must be'], [{ footnotes: { style: 'solid' } }, '`footnotes.style` must be'], [{ expandable: { lines: 0 } }, '`expandable.lines` must be number, got 0'], diff --git a/packages/starlight-codeblocks/test/squiggles.test.ts b/packages/starlight-codeblocks/test/squiggles.test.ts new file mode 100644 index 0000000..95f67c4 --- /dev/null +++ b/packages/starlight-codeblocks/test/squiggles.test.ts @@ -0,0 +1,141 @@ +import { getColorContrast } from '@expressive-code/core'; +import { expect, test } from 'vitest'; +import { pluginCodeblocks } from '../src/expressive-code/index.ts'; +import { variants } from './contrast.ts'; +import { block, render } from './render.ts'; + +/** The squiggles in the HTML: severity, the code text under the wave, and the message in the popover. */ +function squiggles(html: string) { + return [ + ...html.matchAll( + /]*>(.*?)(.*?)<\/span>(\w+)<\/span>(.*?)<\/span><\/span>/g, + ), + ].map(([, severity, code, sr, label, message]) => ({ + severity, + code: code?.replace(/<[^>]+>/g, ''), + sr, + label, + message, + })); +} + +test('draws a squiggle under /text/ with its message, and leaves the copied text alone', async () => { + const { html, copyText, warnings } = await render( + block('ts', "import { join } from 'node:path'; // [!warning /join/] Unused `join`."), + ); + expect(squiggles(html)).toEqual([ + { + severity: 'warning', + code: 'join', + sr: ' (Warning: Unused join.)', + label: 'Warning', + message: 'Unused join.', + }, + ]); + expect(html).toContain('data-scb-squiggles=""'); + expect(html).toContain('popover="manual"'); + expect(copyText).toBe("import { join } from 'node:path';"); + expect(warnings).toEqual([]); +}); + +test('wraps every token of the range in one element, so that the wave runs on', async () => { + const { html } = await render( + block('ts', "const c = await readFle('config.json'); // [!error /readFle('config.json')/] Nope"), + ); + const [squiggle] = squiggles(html); + expect(squiggle?.code).toBe("readFle('config.json')"); + expect(html.match(/class="scb-squiggle /g)).toHaveLength(1); +}); + +test('reads each severity and its other names', async () => { + const lines = ['a(); // [!error]', 'b(); // [!warning]', 'c(); // [!info]', 'd(); // [!warn]', 'e(); // [!note]']; + const { html, warnings } = await render(block('js', ...lines)); + expect(squiggles(html).map((s) => [s.severity, s.label])).toEqual([ + ['error', 'Error'], + ['warning', 'Warning'], + ['info', 'Info'], + ['warning', 'Warning'], + ['info', 'Info'], + ]); + expect(warnings).toEqual([]); +}); + +test('without /text/, underlines the line from its first character that is not a space', async () => { + const { html } = await render(block('py', 'def f():', ' return x # [!error] `x` is not defined.')); + expect(squiggles(html)[0]?.code).toBe('return x'); + expect(squiggles(html)[0]?.sr).toBe(' (Error: x is not defined.)'); +}); + +test('a squiggle with no message still names its severity', async () => { + const { html } = await render(block('rs', 'let unused = 42; // [!warning /unused/]')); + expect(squiggles(html)).toEqual([ + { severity: 'warning', code: 'unused', sr: ' (Warning)', label: 'Warning', message: '' }, + ]); +}); + +test('a directive on its own line applies to the line below, and several stack in source order', async () => { + const above = await render( + block('js', '// [!error /lenght/] Typo', '// [!warning /var/] Old', 'for (var i = 0; i < a.lenght; i++) {}'), + ); + expect(squiggles(above.html).map((s) => [s.severity, s.code])).toEqual([ + ['warning', 'var'], + ['error', 'lenght'], + ]); + expect(above.copyText).toBe('for (var i = 0; i < a.lenght; i++) {}'); + const inline = await render(block('js', 'go(signal); // [!info /signal/] Note')); + const own = await render(block('js', '// [!info /signal/] Note', 'go(signal);')); + expect(inline.html.replace(/--scb-squiggle-\w+-/g, '')).toBe(own.html.replace(/--scb-squiggle-\w+-/g, '')); +}); + +test('gives each squiggle its own anchor', async () => { + const { html } = await render(block('js', 'a(b); // [!error /a/] One', 'c(d); // [!error /d/] Two')); + const anchors = [...html.matchAll(/anchor-name:(--[\w-]+)/g)].map((m) => m[1]); + expect(new Set(anchors).size).toBe(2); + for (const anchor of anchors) expect(html).toContain(`position-anchor:${anchor}`); +}); + +test('lists the messages under the block for print', async () => { + const { html } = await render(block('js', 'a();', 'b(); // [!error /b/] Not defined.', 'c(); // [!note]')); + expect(html).toContain( + '
  • Line 2, error: Not defined.
  • Line 3, info
', + ); +}); + +test('renders inline code and links in the message', async () => { + const { html } = await render(block('js', 'go(); // [!info /go/] See [the docs](https://example.com) for `go`.')); + expect(squiggles(html)[0]?.message).toBe('See the docs for go.'); +}); + +test('warns about a directive with no code to underline', async () => { + const { html, warnings } = await render(block('js', 'go(); // [!error /nope/] Missing')); + expect(html).not.toContain('scb-squiggle'); + expect(warnings.join('\n')).toContain('does not match'); +}); + +test('leaves line states alone', async () => { + const { html } = await render(block('js', 'a(); // [!code error] Fails', 'b(); // [!error /b/] Fails')); + expect(html).toContain('scb-state-error'); + expect(squiggles(html).map((s) => s.code)).toEqual(['b']); +}); + +test('renders as without the feature when a block has no squiggles, and keeps the directive with a warning when it is off', async () => { + const md = block('js title="a.js"', 'a()', 'b() // [!code error] Fails'); + expect((await render(md)).html).toBe((await render(md, { squiggles: false })).html); + const { html, copyText, warnings } = await render(block('js', 'go(); // [!error /go/] Nope'), { squiggles: false }); + expect(html).not.toContain('scb-squiggle'); + expect(copyText).toBe('go(); // [!error /go/] Nope'); + expect(warnings.join('\n')).toContain('is not a known directive'); +}); + +test('every squiggle colour meets 3:1 on the code and on the popover, in the dark and the light theme', async () => { + for (const v of await variants(pluginCodeblocks())) { + for (const severity of ['error', 'warning', 'info']) { + const colour = v.get(`codeblocksSquiggles.${severity}`); + expect(getColorContrast(colour, v.get('codeBackground')), `${severity}, ${v.name}`).toBeGreaterThanOrEqual(3); + expect( + getColorContrast(colour, v.get('codeblocks.popoverBackground')), + `${severity}, ${v.name}`, + ).toBeGreaterThanOrEqual(3); + } + } +}); diff --git a/scripts/readme-media.mjs b/scripts/readme-media.mjs index 629eee7..5ba20fb 100644 --- a/scripts/readme-media.mjs +++ b/scripts/readme-media.mjs @@ -31,6 +31,7 @@ const features = [ }, }, { slug: 'line-states' }, + { slug: 'squiggles' }, { slug: 'inline-callouts' }, { slug: 'annotations', diff --git a/skills/starlight-codeblocks/SKILL.md b/skills/starlight-codeblocks/SKILL.md index e83e925..f8182f8 100644 --- a/skills/starlight-codeblocks/SKILL.md +++ b/skills/starlight-codeblocks/SKILL.md @@ -1,12 +1,12 @@ --- name: starlight-codeblocks -description: Write, choose and configure code block features on Astro Starlight sites that use the starlight-codeblocks plugin. Covers focus, line states, comment notation directives, annotations, footnotes, inline callouts, side annotations, scrollycoding, code mentions, word-level diff, code walkthrough, hidden lines, expandable blocks, visible whitespace, colourised brackets, colour swatches, file icons, inline code highlighting, code links, API auto-linking, line permalinks, code tabs, fill-in placeholders, smart shell copy, open in playground and run code. Use when you install or configure starlight-codeblocks, when you write or edit code blocks in .md, .mdx or .mdoc pages of a site that has it, when you add these features to the existing pages of a site, or when you must choose how to explain, highlight, compare, shorten, link or run code in Starlight docs. +description: Write, choose and configure code block features on Astro Starlight sites that use the starlight-codeblocks plugin. Covers focus, line states, squiggles, comment notation directives, annotations, footnotes, inline callouts, side annotations, scrollycoding, code mentions, word-level diff, code walkthrough, hidden lines, expandable blocks, visible whitespace, colourised brackets, colour swatches, file icons, inline code highlighting, code links, API auto-linking, line permalinks, code tabs, fill-in placeholders, smart shell copy, open in playground and run code. Use when you install or configure starlight-codeblocks, when you write or edit code blocks in .md, .mdx or .mdoc pages of a site that has it, when you add these features to the existing pages of a site, or when you must choose how to explain, highlight, compare, shorten, link or run code in Starlight docs. license: MIT --- # starlight-codeblocks -starlight-codeblocks is a Starlight plugin. It adds 26 features to the code blocks that Expressive Code renders. A feature starts only when a code block uses its attribute or its directive, so the plugin does not change existing pages. A few features apply to every block of a type: they are in [Features that start on their own](#features-that-start-on-their-own). +starlight-codeblocks is a Starlight plugin. It adds 27 features to the code blocks that Expressive Code renders. A feature starts only when a code block uses its attribute or its directive, so the plugin does not change existing pages. A few features apply to every block of a type: they are in [Features that start on their own](#features-that-start-on-their-own). Full docs: https://ewels.github.io/starlight-codeblocks/. Each page has a Markdown version at the same address with `.md` at the end. https://ewels.github.io/starlight-codeblocks/llms-full.txt has every page in one file. @@ -66,6 +66,7 @@ Use scrollycoding when prose explains the code step by step as the reader scroll |---|---|---| | The text is about a few lines, but readers need the rest for context | Focus: `focus={4-7}` or `[!code focus]` | Blurs the other lines. Hover or keyboard focus makes every line sharp. | | A line is wrong, needs care, or needs a note, as in an editor | Line states: `[!code error] message`, `[!code warning]`, `[!code info]`, `[!code success]` | A tint, a bar and an optional message after the code. | +| One name or expression has a problem, as a linter or compiler reports it | Squiggles: `[!error /name/] message`, `[!warning /name/]`, `[!info /name/]` | A wavy line under the text, as in VS Code. The message shows on hover and keyboard focus. | | A neutral highlight with no meaning | `{3}` on the fence line or `[!code highlight]` | The `mark` of Expressive Code. | | A state that is not error, warning, info or success, such as "To do" | A custom state in `lineStates.states` | Its name becomes an attribute and a directive. | diff --git a/skills/starlight-codeblocks/references/configuration.md b/skills/starlight-codeblocks/references/configuration.md index 60ff72f..5862a56 100644 --- a/skills/starlight-codeblocks/references/configuration.md +++ b/skills/starlight-codeblocks/references/configuration.md @@ -22,6 +22,7 @@ codeblocks({ | `lineStates` | `states`: custom states by name, each `{ label, colour: { dark, light } }`. `prefix`: default `true`. `false` hides the state name on screen | Line states | | `notation` | `comments`: comment syntax for each language, added to the built-in map | Comment notation | | `callouts` | None | Inline callouts | +| `squiggles` | None | Squiggles | | `annotations` | `style`: `'filled'` or `'outline'`, default `'filled'` | Annotations and side annotations | | `footnotes` | `sticky`: default `false`. `style`: `'filled'` or `'outline'`, default `'outline'` | Footnotes | | `hiddenLines` | None | Hidden lines | diff --git a/skills/starlight-codeblocks/references/draw-attention.md b/skills/starlight-codeblocks/references/draw-attention.md index d20a6c0..f4a244e 100644 --- a/skills/starlight-codeblocks/references/draw-attention.md +++ b/skills/starlight-codeblocks/references/draw-attention.md @@ -1,9 +1,10 @@ # Draw attention -Focus and line states mark lines for every reader. Code mentions, in [explain-code.md](explain-code.md), mark lines only when the reader hovers over a link in the prose. +Focus, line states and squiggles mark code for every reader. Code mentions, in [explain-code.md](explain-code.md), mark lines only when the reader hovers over a link in the prose. - If the text is about a few lines and the rest is context, use focus. - If a line has a problem or a status, and the reader needs its name or a message, use a line state. +- If one name or expression on a line has the problem, use a squiggle. - If the highlight has no meaning of its own, use `{3}` or `[!code highlight]`, the `mark` of Expressive Code. ## Focus @@ -84,3 +85,32 @@ count = len(sys.argv) # [!code warning] Includes the script name Then use `todo={9}` or `[!code todo]`. A name uses lower-case letters, digits and hyphens, and cannot be the name of another attribute, such as `title` or `focus`. Each colour needs a contrast of 3:1 or more on the code background. A state with the name `error`, `warning`, `info` or `success` changes the built-in state. `note` and `warn` cannot be custom names. - Option: `lineStates: false` leaves the directives in the code. - Limits: a message is one line of text. `[!code ]` on a line with only a comment marks the line below, and the comment line goes. + +## Squiggles + +Docs: https://ewels.github.io/starlight-codeblocks/features/squiggles/ + +Use to draw a wavy error, warning or info line under one name or expression, as a code editor does for a linter problem. The message shows when the reader hovers over the text or focuses it. Do not use to mark a whole line with a message that is always visible: use a line state. + +| Syntax | Where | +|---|---| +| `[!error /text/] message` | Comment on or above the line. The message is optional. | +| `[!warning /text/] message` | Comment on or above the line | +| `[!info /text/] message` | Comment on or above the line | +| `[!warn /text/]`, `[!note /text/]` | Other names for `warning` and `info` | +| `[!error] message` | Without `/text/`: the whole line, from the first character that is not a space | + +```ts +import { join } from 'node:path'; // [!warning /join/] `join` is declared but its value is never read. +// [!error /readFle/] Cannot find name `readFle`. Did you mean `readFile`? +const config = await readFle('config.json'); +``` + +- These directives have no `code` before the name. `[!code error]` is a line state, and `[!error]` is a squiggle. +- `/text/` is literal text, and the line goes under its first match. Text that is not on the line logs a build warning, and the line renders without the squiggle. +- Several squiggles can go on one line. Put each directive on its own comment line above the line. +- The message starts with an icon and the name of the severity: **Error**, **Warning** or **Info**. Each squiggle is in the tab order, and Escape closes the message. Screen readers hear the severity and the message after the text. +- The copy button and a manual selection leave out the messages. In print, the messages are a list under the block. Without JavaScript, the wavy lines show but the messages do not open. +- Option: `squiggles: false` leaves the directives in the code. +- Style settings: `codeblocksSquiggles.error`, `codeblocksSquiggles.warning` and `codeblocksSquiggles.info`. +- Limits: one line per squiggle. The message opens only on hover or focus.