Skip to content

Commit 705dc5f

Browse files
committed
astro(fix[prompt-space]): Include trailing whitespace in non-selectable prefix
why: Cycle 75 split the bundled prompt into a select-none span, but the trailing space stayed in the selectable rest-of-line span. Triple-clicking the line still selected a leading space before pip, which the user flagged as still-broken on /packages/sphinx- autodoc-api-style/. what: - Widen the bundled-prompt regex to capture trailing whitespace in the non-selectable prefix. Selection now starts at the command body, not at a leading space. - Add hoistTrailingWhitespace() running after the exact-match pass: when a prompt span was tagged, check its next-sibling span for leading whitespace and split it into a select-none prefix between them. Mirrors the bundled-prompt fix for Shikis Python tokenization where the prompt and its trailing space land in adjacent spans. Verified live: pip install line splits as <span class=select-none>$ </span><span>pip install ...</span> with computed user-select: none on the prefix. Triple-click starts at pip. 1446 pytest, 246 vitest theme + 81 dogfood, biome/mypy/ruff clean, 242-page build green.
1 parent 1191d5e commit 705dc5f

2 files changed

Lines changed: 78 additions & 45 deletions

File tree

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

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -99,53 +99,81 @@ export function formatCopyText(text: string): string {
9999
const PROMPT_TOKENS: ReadonlySet<string> = new Set(['>>>', '...', '$', '#'])
100100

101101
/**
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.
102+
* Regex matching a recognised prompt at the START of a string,
103+
* INCLUDING the trailing whitespace separator. The full match is
104+
* what gets moved into a non-selectable prefix span so triple-
105+
* click selection starts cleanly at the command body rather than
106+
* at a leading space.
106107
*/
107-
const PROMPT_PREFIX = /^(>>>|\.\.\.|\$|#)(\s)/
108+
const PROMPT_PREFIX = /^(>>>|\.\.\.|\$|#)(\s+)/
108109

109110
function splitPromptIfBundled(span: HTMLElement): void {
110111
// 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.
112+
// tokenize the entire line as a single coloured span. Detect a
113+
// leading prompt + whitespace, hoist BOTH into a non-selectable
114+
// prefix so the selection cursor stops at the command body.
117115
if (span.children.length > 0) {
118-
// Already split or contains nested markup — skip.
119116
return
120117
}
121118
const text = span.textContent ?? ''
122119
const match = text.match(PROMPT_PREFIX)
123120
if (match === null) {
124121
return
125122
}
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.
123+
const prefix = match[0]
124+
const rest = text.slice(prefix.length)
132125
const promptSpan = document.createElement('span')
133126
promptSpan.classList.add('select-none')
134127
if (span.getAttribute('style') !== null) {
135128
promptSpan.setAttribute('style', span.getAttribute('style') ?? '')
136129
}
137-
promptSpan.textContent = prompt
138-
// Reduce the original span to the separator + remaining body.
139-
span.textContent = `${separator}${rest}`
130+
promptSpan.textContent = prefix
131+
span.textContent = rest
140132
span.parentNode?.insertBefore(promptSpan, span)
141133
}
142134

135+
/**
136+
* After a span has been tagged exact-match as a prompt token
137+
* (cycle 74's first pass), check its NEXT-sibling span. If that
138+
* sibling starts with whitespace, hoist the leading whitespace
139+
* into a select-none sibling between them so the selection cursor
140+
* stops at the command body, not before the leading space. Mirrors
141+
* the bundled-prompt split for the non-bundled case.
142+
*/
143+
function hoistTrailingWhitespace(promptSpan: HTMLElement): void {
144+
const next = promptSpan.nextElementSibling
145+
if (!(next instanceof HTMLElement)) {
146+
return
147+
}
148+
if (next.children.length > 0) {
149+
return
150+
}
151+
const text = next.textContent ?? ''
152+
const wsMatch = text.match(/^(\s+)(.*)$/s)
153+
if (wsMatch === null) {
154+
return
155+
}
156+
const ws = wsMatch[1] ?? ''
157+
const rest = wsMatch[2] ?? ''
158+
const wsSpan = document.createElement('span')
159+
wsSpan.classList.add('select-none')
160+
if (next.getAttribute('style') !== null) {
161+
wsSpan.setAttribute('style', next.getAttribute('style') ?? '')
162+
}
163+
wsSpan.textContent = ws
164+
next.textContent = rest
165+
next.parentNode?.insertBefore(wsSpan, next)
166+
}
167+
143168
function markPromptSpans(code: Element): void {
144169
// First pass: tag spans whose text is exactly a prompt token
145170
// (Shiki's Python tokenization, where ``>>>`` is its own span).
171+
// Then hoist the leading whitespace from the next sibling so the
172+
// separator after the prompt is also non-selectable.
146173
for (const span of code.querySelectorAll<HTMLElement>('span')) {
147174
if (PROMPT_TOKENS.has(span.textContent ?? '')) {
148175
span.classList.add('select-none')
176+
hoistTrailingWhitespace(span)
149177
}
150178
}
151179
// Second pass: split bundled-prompt spans (Shiki's ``console``
@@ -154,9 +182,6 @@ function markPromptSpans(code: Element): void {
154182
// ``<span class="line">`` — a ``$`` mid-line is not a prompt.
155183
const lines = code.querySelectorAll<HTMLElement>('span.line')
156184
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``.
160185
for (const span of Array.from(code.children).filter(
161186
(c) => c instanceof HTMLElement,
162187
) as HTMLElement[]) {

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

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -164,20 +164,30 @@ function makeShikiPre(html: string): HTMLPreElement {
164164

165165
describe('enhanceCodeBlocks — prompt non-selection', () => {
166166
test('marks ``>>> `` Python REPL prompt spans as non-selectable', () => {
167+
// Shiki's Python lexer puts ``>>>`` in its own span; the
168+
// following space is typically inside the next span carrying
169+
// the variable name. We split the next span's leading
170+
// whitespace into the non-selectable prefix so triple-click
171+
// selection starts at ``spec`` rather than `` spec``.
167172
const root = document.createElement('div')
168173
root.appendChild(
169174
makeShikiPre(
170-
'<span class="line"><span style="color:#D73A49">&gt;&gt;&gt;</span> spec = 1</span>',
175+
'<span class="line"><span style="color:#D73A49">&gt;&gt;&gt;</span><span style="color:#005CC5"> spec</span><span style="color:#000"> = 1</span></span>',
171176
),
172177
)
173178
document.body.appendChild(root)
174179
enhanceCodeBlocks(root)
175-
const spans = root.querySelectorAll('code span')
176-
const promptSpan = Array.from(spans).find(
177-
(s) => s.textContent === '>>>',
178-
) as HTMLElement | undefined
179-
expect(promptSpan).toBeDefined()
180+
const spans = Array.from(root.querySelectorAll('code span'))
181+
const promptSpan = spans.find((s) => s.textContent === '>>>') as
182+
| HTMLElement
183+
| undefined
180184
expect(promptSpan?.classList.contains('select-none')).toBe(true)
185+
// The leading space from the next span (`` spec``) is hoisted
186+
// into a select-none sibling so selection starts at ``spec``.
187+
const leadingSpaceSpan = spans.find(
188+
(s) => s.textContent === ' ' && s.classList.contains('select-none'),
189+
)
190+
expect(leadingSpaceSpan).toBeDefined()
181191
})
182192

183193
test('marks ``$`` shell prompt span as non-selectable', () => {
@@ -197,13 +207,12 @@ describe('enhanceCodeBlocks — prompt non-selection', () => {
197207
expect(promptSpan?.classList.contains('select-none')).toBe(true)
198208
})
199209

200-
test('splits ``$ `` prefix when Shiki bundles it into the rest of the line', () => {
210+
test('splits ``$ `` prefix INCLUDING trailing space as non-selectable', () => {
201211
// 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.
212+
// single span. The fix splits both the ``$`` AND the
213+
// separator space into the non-selectable prefix so a
214+
// triple-click selection starts cleanly at ``pip`` rather
215+
// than `` pip``.
207216
const root = document.createElement('div')
208217
root.appendChild(
209218
makeShikiPre(
@@ -213,19 +222,18 @@ describe('enhanceCodeBlocks — prompt non-selection', () => {
213222
document.body.appendChild(root)
214223
enhanceCodeBlocks(root)
215224
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.
219225
const promptSpan = Array.from(line?.querySelectorAll('span') ?? []).find(
220-
(s) => s.textContent === '$' && s.classList.contains('select-none'),
226+
(s) => s.classList.contains('select-none'),
221227
)
222228
expect(promptSpan).toBeDefined()
223-
// The full line text content is preserved end-to-end (a single
224-
// space between prompt and command).
229+
// The non-selectable prefix carries BOTH the prompt token and
230+
// the trailing space — so selection cleanly starts at ``pip``.
231+
expect(promptSpan?.textContent).toBe('$ ')
232+
// The full line text content is preserved end-to-end.
225233
expect(line?.textContent).toBe('$ pip install foo')
226234
})
227235

228-
test('splits ``>>> `` prefix when bundled with command in single span', () => {
236+
test('splits ``>>> `` prefix INCLUDING trailing space when bundled with command', () => {
229237
const root = document.createElement('div')
230238
root.appendChild(
231239
makeShikiPre(
@@ -236,9 +244,9 @@ describe('enhanceCodeBlocks — prompt non-selection', () => {
236244
enhanceCodeBlocks(root)
237245
const line = root.querySelector('span.line')
238246
const promptSpan = Array.from(line?.querySelectorAll('span') ?? []).find(
239-
(s) => s.textContent === '>>>' && s.classList.contains('select-none'),
247+
(s) => s.classList.contains('select-none'),
240248
)
241-
expect(promptSpan).toBeDefined()
249+
expect(promptSpan?.textContent).toBe('>>> ')
242250
expect(line?.textContent).toBe('>>> print("hi")')
243251
})
244252

0 commit comments

Comments
 (0)