diff --git a/.agents/DECISIONS.md b/.agents/DECISIONS.md index 1bffa09..05d3e56 100644 --- a/.agents/DECISIONS.md +++ b/.agents/DECISIONS.md @@ -42,6 +42,7 @@ The choices that the code does not explain, with the options that were rejected. - **Every directive works at the end of its line or on a line of its own above it.** A comment with other text keeps that text, and its directives apply to the comment line. *Rejected:* own-line-only directives for callouts, links and footnotes (a rule to learn, for no rendering gain). - **Features declare their directives on their plugin**, so each feature stays in its own file. *Rejected:* a central directive list. - **`notation.comments` maps a language to several syntaxes**; C-family, Vue, Svelte and Astro also accept `//` and `/* */`. Languages resolve through Shiki ids and aliases. The bracket scanner uses the same map. +- **A known directive outside every comment that the block reads warns**, and names the syntaxes to use. A directive in a string, or escaped, does not: a string can show one on purpose, and `[\!` unescapes only in a comment. A language with no comment syntax, or set to `[]`, reads no directives and never warns. *Rejected:* a new opt-out attribute. - **Code tabs are a Sätteri container directive**, not an MDX component: it works in `.md` and `.mdx`, and each variant stays its own EC block with its own title, frame and copy text. - **Code tabs show editor tabs by default, and a menu with `control="menu"`.** The user found the menu clumsy; tabs also make a group of files look like an editor. Each variant renders EC's own editor tab (a variant with no title gets its label as the title, and the editor frame), and the client module turns it into a row with every variant's tab, cloned from their titles. So the selected tab is the one EC draws, a file name comment works as a title, and without JavaScript the first variant shows with its own tab and no dead buttons. A label is not a file name, so its tab drops the automatic file icon; `icon="…"` still adds one. *Rejected:* building the row at build time (EC renders each block alone, so a block cannot see the other titles); keeping the terminal frame for shell variants (it has no tabs); the old name "code switcher". - **Inline highlighting:** the suffix inside the backticks is the documented form (`` `x{:js}` ``), as rehype-pretty-code; it needs no MDX escape and works with plugins that read `.md` as MDX. The form after the backtick still works. `{:txt}`, `{:plain}` and similar keep code plain. The inline engine reuses the site engine's captured themes with contrast correction off, and Astro's `shikiConfig` languages. diff --git a/docs/src/content/docs/comment-notation.mdx b/docs/src/content/docs/comment-notation.mdx index 1153210..066a9e2 100644 --- a/docs/src/content/docs/comment-notation.mdx +++ b/docs/src/content/docs/comment-notation.mdx @@ -261,9 +261,10 @@ The build logs a warning with the file, the code block and the line when: - a directive has an unknown name, or the feature it belongs to is off; - the `//` of a directive has no match on its target line; -- a directive on its own line has no line below it. +- a directive on its own line has no line below it; +- a directive is not in a comment that the block reads, such as `` in an `mdx` block. A directive in a string does not give this warning. -The directive then has no effect. An unknown directive stays in the code as written, so you can see it on the page. +The directive then has no effect. An unknown directive, or one outside a comment, stays in the code as written, so you can see it on the page. ## Options diff --git a/packages/starlight-codeblocks/src/expressive-code/notation.ts b/packages/starlight-codeblocks/src/expressive-code/notation.ts index e815970..7715e10 100644 --- a/packages/starlight-codeblocks/src/expressive-code/notation.ts +++ b/packages/starlight-codeblocks/src/expressive-code/notation.ts @@ -201,11 +201,24 @@ export function parseLine( const unchanged = { text, removed: false, directives: [] }; // A `[!word]` in code or in a string, such as a Markdown alert, comes before the comment that holds the directives. let comment: ReturnType; - for (const token of scanTokens(text)) { + const tokens = scanTokens(text); + for (const token of tokens) { comment = findComment(text, token.start, syntaxes); if (comment) break; } - if (!comment) return unchanged; + if (!comment) { + // A string can show a directive on purpose, and `[\!` only unescapes inside a comment. + for (const token of tokens) { + if (token.escaped || inString(text, token.start, token.start, [])) continue; + if ('problem' in interpret(token, specs, sourceLine)) continue; + const use = syntaxes.map(({ open, close }) => `\`${close ? `${open} ${close}` : open}\``).join(' or '); + report( + `\`${text.slice(token.start, token.end)}\` is not in a comment that this block reads. Use ${use}. It shows as text.`, + sourceLine, + ); + } + return unchanged; + } const [start, bodyStart, bodyEnd, end] = comment; const body = text.slice(bodyStart, bodyEnd); // Expressive Code strips a diff prefix only later, so `+ // [!code …]` still holds only directives. diff --git a/packages/starlight-codeblocks/test/notation-render.test.ts b/packages/starlight-codeblocks/test/notation-render.test.ts index de37b8a..360c71c 100644 --- a/packages/starlight-codeblocks/test/notation-render.test.ts +++ b/packages/starlight-codeblocks/test/notation-render.test.ts @@ -104,6 +104,31 @@ test('warns about unknown directives with the file and line, and keeps them', as expect((await render(block('constructor', 'a() // [!code focus]'))).html).toContain('[!code focus]'); }); +test('warns about a directive that is not in a comment the block reads, and keeps it', async () => { + const mdx = await render( + block('mdx', '', "import X from 'x'"), + ); + expect(mdx.copyText).toBe("\nimport X from 'x'"); + expect(mdx.warnings).toEqual([ + 'src/content/docs/example.md, mdx code block, line 1: `[!callout /import/]` is not in a comment that this block reads. Use `{/* */}`. It shows as text.', + ]); + expect((await render(block('py', 'x = 1 // [!code focus]'))).warnings).toEqual([ + 'src/content/docs/example.md, py code block, line 1: `[!code focus]` is not in a comment that this block reads. Use `#`. It shows as text.', + ]); +}); + +test.each([ + ['py', 'x = 1 # [!code focus]', {}], + ['js', 'a() // [\\!code focus]', {}], + ['py', 'x = 1 // [\\!code focus]', {}], + ['js', 'const s = "[!code focus]"', {}], + ['md', '> [!NOTE]', {}], + ['txt', 'a() # [!code focus]', {}], + ['mdx', '', { notation: { comments: { mdx: [] } } }], +])('does not warn about %s %j', async (lang, line, options) => { + expect((await render(block(lang, line), options)).warnings).toEqual([]); +}); + test('works with diff syntax and diff-prefixed directive lines', async () => { const diff = await render(block('diff lang="js"', '-a() // [!code focus]', '+b() // [!code ++]')); expect(diff.copyText).toBe('a()\nb()'); diff --git a/packages/starlight-codeblocks/test/notation.test.ts b/packages/starlight-codeblocks/test/notation.test.ts index 686b3fd..b8a2cae 100644 --- a/packages/starlight-codeblocks/test/notation.test.ts +++ b/packages/starlight-codeblocks/test/notation.test.ts @@ -55,11 +55,19 @@ describe('parseLine', () => { '/* a */ x = "[!code focus]"', 'const s = "// [!code focus]"', 'const a = "http://x", b = "[!code focus]";', - 'fetch("http://x") [!code focus]', + 'arr[!flag]', ])('leaves %j unchanged', (input) => { expect(parse(input)).toMatchObject({ text: input, directives: [], problems: [] }); }); + test('reports a known directive outside a comment, and keeps it', () => { + expect(parse('fetch("http://x") [!code focus]')).toMatchObject({ + text: 'fetch("http://x") [!code focus]', + directives: [], + problems: ['`[!code focus]` is not in a comment that this block reads. Use `//` or `/* */`. It shows as text.'], + }); + }); + test('reads a count, a message, a name and literal text, and ends a message at the next directive', () => { expect(parse('x // [!code focus:3]').directives[0]).toMatchObject({ count: 3 }); expect(parse('x // [!code error] Missing `await`').directives[0]).toMatchObject({ text: 'Missing `await`' }); diff --git a/skills/starlight-codeblocks/SKILL.md b/skills/starlight-codeblocks/SKILL.md index ae2e997..6a75036 100644 --- a/skills/starlight-codeblocks/SKILL.md +++ b/skills/starlight-codeblocks/SKILL.md @@ -170,7 +170,7 @@ If a page shows one feature, keep the others out of its examples with these attr - `` and `` work in MDX files only. Import them with `import { CodeWalkthrough, Scrollycoding, Step } from 'starlight-codeblocks/components';`. Put an empty line after the opening tag and before the closing tag. - A `:::code-tabs` directive can contain only code blocks. A paragraph inside it fails the build. - For inline code highlighting, put the suffix inside the backticks: `` `res.ok{:js}` ``. MDX reads `` `res.ok`{:js} `` as a JavaScript expression and the build fails. -- A directive with an unknown name, or of a feature that is off, stays in the code and logs a build warning. Read the build warnings after every change: each gives the file, the block and the line. +- A directive with an unknown name, or of a feature that is off, stays in the code and logs a build warning. So does a directive outside a comment that the block's language reads. Read the build warnings after every change: each gives the file, the block and the line. - Each `id` for line permalinks must be unique on the page, and must not match a heading id. - Hidden lines still run in the copied code, in playgrounds and with `runnable`. Code for a playground or the **Run code** button must be complete. - Fill-in placeholder values stay in the browser's `localStorage` by default. For secrets such as API tokens, set `placeholders: { storage: 'session' }`.