Skip to content

Commit 1191d5e

Browse files
committed
astro(fix[prompt-split]): Split bundled prompt prefix in Shiki spans
why: cycle 74 only handled Shikis Python tokenization where each prompt is its own span. But Shikis console lexer tokenizes a whole shell line as ONE span - the whole pip install line is inside the same selectable span. Triple-clicking still selected the leading prompt, which was the users exact complaint on /packages/sphinx-autodoc-api-style/. what: - Add splitPromptIfBundled(span) running as a second pass after the exact-match pass. For each line span, find the first child span; if its textContent starts with a recognised prompt followed by whitespace, split into a new prompt-only span with select-none (inheriting the inline style for colour parity) and shrink the original to the trailing body. - Idempotent: skip spans that already have child elements. - Falls back to direct children of code when Shikis wrapper variant skips the line wrapper. - 2 RED-first vitest cases for the split, plus 1 regression test for COPY button compatibility post-split. Verified live: the pip install line at /packages/sphinx-autodoc- api-style/ now has the dollar in its own select-none span; the rest stays selectable. 1446 pytest, 246 vitest theme + 81 dogfood, biome/mypy/ruff clean, 242-page build green.
1 parent dd61683 commit 1191d5e

2 files changed

Lines changed: 136 additions & 1 deletion

File tree

astro/apps/gp-sphinx-docs/src/lib/code-copy.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,79 @@ export function formatCopyText(text: string): string {
9898
*/
9999
const PROMPT_TOKENS: ReadonlySet<string> = new Set(['>>>', '...', '$', '#'])
100100

101+
/**
102+
* Regex matching a recognised prompt at the START of a string.
103+
* Capture group 1 is the prompt token ALONE (without trailing
104+
* whitespace) so the tagged span carries exactly the prompt; the
105+
* trailing space stays with the command body.
106+
*/
107+
const PROMPT_PREFIX = /^(>>>|\.\.\.|\$|#)(\s)/
108+
109+
function splitPromptIfBundled(span: HTMLElement): void {
110+
// Shiki's ``console`` (and some Python shell renderings)
111+
// tokenize the entire line as a single coloured span:
112+
// ``<span style="…">$ pip install foo</span>``. Triple-clicking
113+
// selects the prompt because it's inside that span. Detect the
114+
// leading prompt + space, split into a non-selectable prefix
115+
// span carrying just the prompt, and shrink the original span
116+
// to the trailing command body.
117+
if (span.children.length > 0) {
118+
// Already split or contains nested markup — skip.
119+
return
120+
}
121+
const text = span.textContent ?? ''
122+
const match = text.match(PROMPT_PREFIX)
123+
if (match === null) {
124+
return
125+
}
126+
const prompt = match[1] ?? ''
127+
const separator = match[2] ?? ''
128+
const rest = text.slice(prompt.length + separator.length)
129+
// Build the prompt span carrying the prompt token, marked
130+
// non-selectable. Inherit the inline style so the colour grammar
131+
// stays consistent with the surrounding tokens.
132+
const promptSpan = document.createElement('span')
133+
promptSpan.classList.add('select-none')
134+
if (span.getAttribute('style') !== null) {
135+
promptSpan.setAttribute('style', span.getAttribute('style') ?? '')
136+
}
137+
promptSpan.textContent = prompt
138+
// Reduce the original span to the separator + remaining body.
139+
span.textContent = `${separator}${rest}`
140+
span.parentNode?.insertBefore(promptSpan, span)
141+
}
142+
101143
function markPromptSpans(code: Element): void {
102-
for (const span of code.querySelectorAll('span')) {
144+
// First pass: tag spans whose text is exactly a prompt token
145+
// (Shiki's Python tokenization, where ``>>>`` is its own span).
146+
for (const span of code.querySelectorAll<HTMLElement>('span')) {
103147
if (PROMPT_TOKENS.has(span.textContent ?? '')) {
104148
span.classList.add('select-none')
105149
}
106150
}
151+
// Second pass: split bundled-prompt spans (Shiki's ``console``
152+
// tokenization, where the entire line is one span). We only
153+
// consider the FIRST text-bearing span inside each
154+
// ``<span class="line">`` — a ``$`` mid-line is not a prompt.
155+
const lines = code.querySelectorAll<HTMLElement>('span.line')
156+
if (lines.length === 0) {
157+
// Some Shiki output (older versions, or when ``defaultColor``
158+
// is set differently) skips the line wrapper. Fall back to the
159+
// direct children of ``code``.
160+
for (const span of Array.from(code.children).filter(
161+
(c) => c instanceof HTMLElement,
162+
) as HTMLElement[]) {
163+
splitPromptIfBundled(span)
164+
}
165+
return
166+
}
167+
for (const line of lines) {
168+
const firstSpan = line.querySelector<HTMLElement>(':scope > span')
169+
if (firstSpan === null) {
170+
continue
171+
}
172+
splitPromptIfBundled(firstSpan)
173+
}
107174
}
108175

109176
export function enhanceCodeBlocks(root: ParentNode): void {

astro/apps/gp-sphinx-docs/test/lib/code-copy.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,74 @@ describe('enhanceCodeBlocks — prompt non-selection', () => {
197197
expect(promptSpan?.classList.contains('select-none')).toBe(true)
198198
})
199199

200+
test('splits ``$ `` prefix when Shiki bundles it into the rest of the line', () => {
201+
// Shiki's ``console`` language renders the whole line as a
202+
// single span — ``<span>$ pip install foo</span>``. Triple-
203+
// clicking the line still selects the ``$`` because it's
204+
// inside the same selectable span. The fix is to split: emit a
205+
// non-selectable span for the prompt prefix and a regular span
206+
// for the rest.
207+
const root = document.createElement('div')
208+
root.appendChild(
209+
makeShikiPre(
210+
'<span class="line"><span style="color:#000">$ pip install foo</span></span>',
211+
),
212+
)
213+
document.body.appendChild(root)
214+
enhanceCodeBlocks(root)
215+
const line = root.querySelector('span.line')
216+
expect(line).toBeDefined()
217+
// After split there should be a span carrying just the prompt
218+
// with select-none, plus a sibling carrying the rest.
219+
const promptSpan = Array.from(line?.querySelectorAll('span') ?? []).find(
220+
(s) => s.textContent === '$' && s.classList.contains('select-none'),
221+
)
222+
expect(promptSpan).toBeDefined()
223+
// The full line text content is preserved end-to-end (a single
224+
// space between prompt and command).
225+
expect(line?.textContent).toBe('$ pip install foo')
226+
})
227+
228+
test('splits ``>>> `` prefix when bundled with command in single span', () => {
229+
const root = document.createElement('div')
230+
root.appendChild(
231+
makeShikiPre(
232+
'<span class="line"><span style="color:#000">&gt;&gt;&gt; print("hi")</span></span>',
233+
),
234+
)
235+
document.body.appendChild(root)
236+
enhanceCodeBlocks(root)
237+
const line = root.querySelector('span.line')
238+
const promptSpan = Array.from(line?.querySelectorAll('span') ?? []).find(
239+
(s) => s.textContent === '>>>' && s.classList.contains('select-none'),
240+
)
241+
expect(promptSpan).toBeDefined()
242+
expect(line?.textContent).toBe('>>> print("hi")')
243+
})
244+
245+
test('after splitting a bundled prompt, copying still strips it correctly', async () => {
246+
// Regression test for the interaction between
247+
// ``splitPromptIfBundled`` and ``formatCopyText``: after the
248+
// DOM split, the code's textContent is unchanged
249+
// (``$ pip install foo``), so ``formatCopyText`` running on
250+
// that text still correctly strips the ``$ `` prefix and
251+
// writes ``pip install foo`` to the clipboard.
252+
const root = document.createElement('div')
253+
root.appendChild(
254+
makeShikiPre(
255+
'<span class="line"><span style="color:#000">$ pip install foo</span></span>',
256+
),
257+
)
258+
document.body.appendChild(root)
259+
enhanceCodeBlocks(root)
260+
const button = root.querySelector<HTMLButtonElement>(
261+
'[data-test-id="code-copy"]',
262+
)
263+
button?.click()
264+
await Promise.resolve()
265+
expect(writeText).toHaveBeenCalledWith('pip install foo')
266+
})
267+
200268
test('does not tag spans whose text is incidentally a prompt-like substring', () => {
201269
// A span whose text is ``$`` AS PART OF a longer token — e.g.
202270
// a price ``$10`` — should NOT be marked non-selectable. We

0 commit comments

Comments
 (0)