From 236b3e2124df0b0691280d8ed2a11412c42606ba Mon Sep 17 00:00:00 2001 From: pablofdezr Date: Fri, 24 Jul 2026 14:20:12 +0200 Subject: [PATCH 1/2] fix(core): detect showLineNumbers anywhere in the meta string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `showLineNumbers` was detected with a reversed-string regex: /srebmuNeniLwohs(?!(.*)(\/))/.test(reverseString(meta)) The negative lookahead was meant to ignore `/showLineNumbers/` used as a highlight word, but it actually rejected `showLineNumbers` whenever any `/` appeared before it in the meta. Since highlight tokens such as `/age/#v` contain slashes, `showLineNumbers` placed after them was silently ignored — so it only worked at the start of the meta string. Replace the reverse-string hack with a token-boundary match that is position-independent and captures the optional start-at number directly: meta.match(/(?:^|\s)showLineNumbers(?:\{(\d+)\})?(?=\s|$)/) Bounding on whitespace/string-ends still excludes `/showLineNumbers/` (slash-delimited), so the highlight-word cases keep working. The `reverseString` import is now unused in this module and removed. Added a fixture covering `showLineNumbers` (and `showLineNumbers{N}`) after highlight tokens; all existing snapshots are unchanged. Closes #204 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/index.ts | 23 +++--- .../fixtures/showLineNumbersAfterHighlight.md | 19 +++++ .../showLineNumbersAfterHighlight.html | 81 +++++++++++++++++++ 3 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 packages/core/test/fixtures/showLineNumbersAfterHighlight.md create mode 100644 packages/core/test/results/showLineNumbersAfterHighlight.html diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0aa6313..aa6fc67 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,7 +11,6 @@ import rangeParser from 'parse-numeric-range'; import { unified, type Transformer } from 'unified'; import rehypeParse from 'rehype-parse'; import { charsHighlighter } from './chars/charsHighlighter'; -import { reverseString } from './chars/utils'; import { isElement, isText, @@ -427,24 +426,24 @@ export function rehypePrettyCode( counterMap: new Map(), }; + // Detect `showLineNumbers` (optionally `showLineNumbers{N}`) as a + // standalone meta token, wherever it appears in the meta string. + // Bounding on whitespace/string-ends avoids matching a + // `/showLineNumbers/` highlight word, which is slash-delimited. + const showLineNumbersMatch = meta.match( + /(?:^|\s)showLineNumbers(?:\{(\d+)\})?(?=\s|$)/, + ); + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: visit(codeTree, 'element', (element) => { - if ( - element.tagName === 'code' && - /srebmuNeniLwohs(?!(.*)(\/))/.test(reverseString(meta)) - ) { + if (element.tagName === 'code' && showLineNumbersMatch) { if (element.properties) { element.properties['data-line-numbers'] = ''; } - const lineNumbersStartAtMatch = reverseString(meta).match( - /(?:\}(\d+){)?srebmuNeniLwohs(?!(.*)(\/))/, - ); - const startNumberString = lineNumbersStartAtMatch?.[1]; + const startNumberString = showLineNumbersMatch[1]; if (startNumberString) { - const startAt = startNumberString - ? Number(reverseString(startNumberString)) - 1 - : 0; + const startAt = Number(startNumberString) - 1; lineNumbersMaxDigits = startAt; if (element.properties) { element.properties.style = `counter-set: line ${startAt};`; diff --git a/packages/core/test/fixtures/showLineNumbersAfterHighlight.md b/packages/core/test/fixtures/showLineNumbersAfterHighlight.md new file mode 100644 index 0000000..176d69a --- /dev/null +++ b/packages/core/test/fixtures/showLineNumbersAfterHighlight.md @@ -0,0 +1,19 @@ +# showLineNumbers after highlight tokens (#204) + +showLineNumbers at the start (already worked): + +```js showLineNumbers /const/ +const answer = 42; +``` + +showLineNumbers at the end, after highlight tokens (#204): + +```js /const/ showLineNumbers +const answer = 42; +``` + +showLineNumbers{5} at the end, after highlight tokens (#204): + +```js /const/ showLineNumbers{5} +const answer = 42; +``` diff --git a/packages/core/test/results/showLineNumbersAfterHighlight.html b/packages/core/test/results/showLineNumbersAfterHighlight.html new file mode 100644 index 0000000..2630703 --- /dev/null +++ b/packages/core/test/results/showLineNumbersAfterHighlight.html @@ -0,0 +1,81 @@ + + +

showLineNumbers after highlight tokens (#204)

+

showLineNumbers at the start (already worked):

+
+
const answer = 42;
+
+

showLineNumbers at the end, after highlight tokens (#204):

+
+
const answer = 42;
+
+

showLineNumbers{5} at the end, after highlight tokens (#204):

+
+
const answer = 42;
+
From 58b027a1518117d41731fbede87670efb4e1f9dd Mon Sep 17 00:00:00 2001 From: pablofdezr Date: Fri, 24 Jul 2026 22:45:56 +0200 Subject: [PATCH 2/2] fix(core): ignore showLineNumbers inside a char-highlight annotation Whitespace boundaries alone do not prove the token is an option: a character highlight may legitimately span whitespace, so `/foo showLineNumbers bar/` highlighted the text correctly but also gave the `` a spurious `data-line-numbers`. Move the detection into `getShowLineNumbers`, which skips matches overlapping the spans already claimed by the parsed `charsMatches` annotations. Only lookahead is used, so this stays parseable on Safari < 16.4. Fixture gains the multi-word case both on its own and alongside a real `showLineNumbers` option; the latter keeps line numbers and highlights both occurrences. Also adds the patch changeset. --- .changeset/fair-ads-begin.md | 5 +++ packages/core/src/index.ts | 22 +++++++---- packages/core/src/utils.ts | 37 +++++++++++++++++++ .../fixtures/showLineNumbersAfterHighlight.md | 14 +++++++ .../showLineNumbersAfterHighlight.html | 24 ++++++++++++ 5 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 .changeset/fair-ads-begin.md diff --git a/.changeset/fair-ads-begin.md b/.changeset/fair-ads-begin.md new file mode 100644 index 0000000..deb8585 --- /dev/null +++ b/.changeset/fair-ads-begin.md @@ -0,0 +1,5 @@ +--- +"rehype-pretty-code": patch +--- + +fix: detect `showLineNumbers` wherever it appears in the meta string, not only before the highlight annotations, while ignoring the word when it is part of a character highlight such as `/foo showLineNumbers bar/` (#204) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aa6fc67..e25e48a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,6 +22,7 @@ import { getThemeNames, replaceLineClass, getLineId, + getShowLineNumbers, } from './utils'; export type { Options, LineElement, CharsElement, Theme } from './types'; @@ -428,22 +429,27 @@ export function rehypePrettyCode( // Detect `showLineNumbers` (optionally `showLineNumbers{N}`) as a // standalone meta token, wherever it appears in the meta string. - // Bounding on whitespace/string-ends avoids matching a - // `/showLineNumbers/` highlight word, which is slash-delimited. - const showLineNumbersMatch = meta.match( - /(?:^|\s)showLineNumbers(?:\{(\d+)\})?(?=\s|$)/, + // Occurrences inside a character-highlight annotation, which may + // legitimately span whitespace (`/foo showLineNumbers bar/`), are + // highlight text rather than an option, so the spans those + // annotations already claim are excluded. + const showLineNumbers = getShowLineNumbers( + meta, + (charsMatches ?? []).map((match) => { + const start = match.index ?? 0; + return [start, start + match[0].length]; + }), ); // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: visit(codeTree, 'element', (element) => { - if (element.tagName === 'code' && showLineNumbersMatch) { + if (element.tagName === 'code' && showLineNumbers) { if (element.properties) { element.properties['data-line-numbers'] = ''; } - const startNumberString = showLineNumbersMatch[1]; - if (startNumberString) { - const startAt = Number(startNumberString) - 1; + if (showLineNumbers.startAt !== null) { + const startAt = showLineNumbers.startAt - 1; lineNumbersMaxDigits = startAt; if (element.properties) { element.properties.style = `counter-set: line ${startAt};`; diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 54b3942..8665e15 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -111,6 +111,43 @@ export function replaceLineClass(element: Element) { } } +// `showLineNumbers`, optionally `showLineNumbers{N}`, bounded by +// whitespace or the ends of the meta string. Only lookahead is used, so +// this stays parseable on Safari < 16.4, which lacks lookbehind. +const showLineNumbersRegex = /(?:^|\s)(showLineNumbers(?:\{(\d+)\})?)(?=\s|$)/g; + +/** + * Finds the `showLineNumbers` option in a block meta string. + * + * Whitespace boundaries alone do not prove the token is an option: it can + * also be part of a delimited character-highlight annotation, as in + * `/foo showLineNumbers bar/`, where it is highlight text. `takenSpans` + * holds the `[start, end)` offsets already claimed by those annotations, + * and any match overlapping one of them is skipped. + * + * Returns `null` when the option is absent, otherwise `startAt` — the + * line number given by `showLineNumbers{N}`, or `null` for a bare token. + */ +export function getShowLineNumbers( + meta: string, + takenSpans: Array<[number, number]>, +) { + for (const match of meta.matchAll(showLineNumbersRegex)) { + const token = match[1]; + if (!token) continue; + + // `match[0]` may carry a leading whitespace boundary character. + const start = (match.index ?? 0) + (match[0].length - token.length); + const end = start + token.length; + + if (takenSpans.some(([from, to]) => start < to && end > from)) continue; + + return { startAt: match[2] ? Number(match[2]) : null }; + } + + return null; +} + export function getLineId(lineNumber: number, meta: string) { const segments = meta.match(/\{[^}]+\}#[a-zA-Z0-9]+/g); if (!segments) return null; diff --git a/packages/core/test/fixtures/showLineNumbersAfterHighlight.md b/packages/core/test/fixtures/showLineNumbersAfterHighlight.md index 176d69a..ec29546 100644 --- a/packages/core/test/fixtures/showLineNumbersAfterHighlight.md +++ b/packages/core/test/fixtures/showLineNumbersAfterHighlight.md @@ -17,3 +17,17 @@ showLineNumbers{5} at the end, after highlight tokens (#204): ```js /const/ showLineNumbers{5} const answer = 42; ``` + +showLineNumbers inside a multi-word highlight is highlight text, not an +option, so no line numbers here: + +```js /foo showLineNumbers bar/ +const value = 'foo showLineNumbers bar'; +``` + +The same word as a real option alongside a multi-word highlight that also +contains it — line numbers, and both occurrences highlighted: + +```js /foo showLineNumbers bar/ showLineNumbers +const value = 'foo showLineNumbers bar'; +``` diff --git a/packages/core/test/results/showLineNumbersAfterHighlight.html b/packages/core/test/results/showLineNumbersAfterHighlight.html index 2630703..eba0943 100644 --- a/packages/core/test/results/showLineNumbersAfterHighlight.html +++ b/packages/core/test/results/showLineNumbersAfterHighlight.html @@ -79,3 +79,27 @@

showLineNumbers after highlight tokens (#204)

data-theme="github-dark" >const answer = 42; +

+ showLineNumbers inside a multi-word highlight is highlight text, not an + option, so no line numbers here: +

+
+
const value = 'foo showLineNumbers bar';
+
+

+ The same word as a real option alongside a multi-word highlight that also + contains it — line numbers, and both occurrences highlighted: +

+
+
const value = 'foo showLineNumbers bar';
+