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 0aa6313..e25e48a 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, @@ -23,6 +22,7 @@ import { getThemeNames, replaceLineClass, getLineId, + getShowLineNumbers, } from './utils'; export type { Options, LineElement, CharsElement, Theme } from './types'; @@ -427,24 +427,29 @@ export function rehypePrettyCode( counterMap: new Map(), }; + // Detect `showLineNumbers` (optionally `showLineNumbers{N}`) as a + // standalone meta token, wherever it appears in the meta string. + // 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' && - /srebmuNeniLwohs(?!(.*)(\/))/.test(reverseString(meta)) - ) { + if (element.tagName === 'code' && showLineNumbers) { if (element.properties) { element.properties['data-line-numbers'] = ''; } - const lineNumbersStartAtMatch = reverseString(meta).match( - /(?:\}(\d+){)?srebmuNeniLwohs(?!(.*)(\/))/, - ); - const startNumberString = lineNumbersStartAtMatch?.[1]; - if (startNumberString) { - const startAt = startNumberString - ? Number(reverseString(startNumberString)) - 1 - : 0; + 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 new file mode 100644 index 0000000..ec29546 --- /dev/null +++ b/packages/core/test/fixtures/showLineNumbersAfterHighlight.md @@ -0,0 +1,33 @@ +# 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; +``` + +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 new file mode 100644 index 0000000..eba0943 --- /dev/null +++ b/packages/core/test/results/showLineNumbersAfterHighlight.html @@ -0,0 +1,105 @@ + + +

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;
+
+

+ 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';
+