Skip to content

Commit 9bc51e5

Browse files
authored
Update dynamic Copilot prompts for screen reader accessibility (#58351)
1 parent 844e3a2 commit 9bc51e5

File tree

5 files changed

+143
-9
lines changed

5 files changed

+143
-9
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import murmur from 'imurmurhash'
2+
3+
/**
4+
* Generate a deterministic ID for a prompt based on its content.
5+
* Uses MurmurHash to create a unique ID that remains consistent across renders,
6+
* avoiding hydration mismatches in the client.
7+
*/
8+
export function generatePromptId(promptContent: string): string {
9+
return murmur('prompt').hash(promptContent).result().toString()
10+
}

src/content-render/liquid/prompt.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Defines {% prompt %}…{% endprompt %} to wrap its content in <code> and append the Copilot icon.
33

44
import octicons from '@primer/octicons'
5+
import { generatePromptId } from '../lib/prompt-id'
56

67
interface LiquidTag {
78
type: 'block'
@@ -30,10 +31,13 @@ export const Prompt: LiquidTag = {
3031
// Render the inner Markdown, wrap in <code>, then append the SVG
3132
*render(scope: any): Generator<any, string, unknown> {
3233
const content = yield this.liquid.renderer.renderTemplates(this.templates, scope)
34+
const contentString = String(content)
3335

3436
// build a URL with the prompt text encoded as query parameter
35-
const promptParam: string = encodeURIComponent(content as string)
37+
const promptParam: string = encodeURIComponent(contentString)
3638
const href: string = `https://github.com/copilot?prompt=${promptParam}`
37-
return `<code>${content}</code><a href="${href}" target="_blank" class="tooltipped tooltipped-nw ml-1" aria-label="Run this prompt in Copilot Chat" style="text-decoration:none;">${octicons.copilot.toSVG()}</a>`
39+
// Use murmur hash for deterministic ID (avoids hydration mismatch)
40+
const promptId: string = generatePromptId(contentString)
41+
return `<pre hidden id="${promptId}">${content}</pre><code>${content}</code><a href="${href}" target="_blank" class="tooltipped tooltipped-nw ml-1" aria-label="Run this prompt in Copilot Chat" aria-describedby="${promptId}" style="text-decoration:none;">${octicons.copilot.toSVG()}</a>`
3842
},
3943
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { generatePromptId } from '@/content-render/lib/prompt-id'
3+
4+
describe('generatePromptId', () => {
5+
test('generates consistent IDs for same content', () => {
6+
const content = 'example prompt text'
7+
const id1 = generatePromptId(content)
8+
const id2 = generatePromptId(content)
9+
expect(id1).toBe(id2)
10+
})
11+
12+
test('generates different IDs for different content', () => {
13+
const id1 = generatePromptId('prompt one')
14+
const id2 = generatePromptId('prompt two')
15+
expect(id1).not.toBe(id2)
16+
})
17+
18+
test('generates numeric string IDs', () => {
19+
const id = generatePromptId('test prompt')
20+
expect(typeof id).toBe('string')
21+
expect(Number.isNaN(Number(id))).toBe(false)
22+
})
23+
24+
test('handles empty strings', () => {
25+
const id = generatePromptId('')
26+
expect(typeof id).toBe('string')
27+
expect(id.length).toBeGreaterThan(0)
28+
})
29+
30+
test('handles special characters', () => {
31+
const id1 = generatePromptId('prompt with\nnewlines')
32+
const id2 = generatePromptId('prompt with\ttabs')
33+
const id3 = generatePromptId('prompt with "quotes"')
34+
expect(typeof id1).toBe('string')
35+
expect(typeof id2).toBe('string')
36+
expect(typeof id3).toBe('string')
37+
expect(id1).not.toBe(id2)
38+
expect(id2).not.toBe(id3)
39+
})
40+
41+
test('generates deterministic IDs (regression test)', () => {
42+
// These specific values ensure the hash function remains consistent
43+
expect(generatePromptId('hello world')).toBe('1730621824')
44+
expect(generatePromptId('test')).toBe('4180565944')
45+
})
46+
47+
test('handles prompts with code context (ref pattern)', () => {
48+
// When ref= is used, the prompt includes referenced code + prompt text separated by newline
49+
const codeContext =
50+
'function logPersonAge(name, age, revealAge) {\n if (revealAge) {\n console.log(name);\n }\n}'
51+
const promptText = 'Improve the variable names in this function'
52+
const combinedPrompt = `${codeContext}\n${promptText}`
53+
54+
const id = generatePromptId(combinedPrompt)
55+
expect(typeof id).toBe('string')
56+
expect(id.length).toBeGreaterThan(0)
57+
58+
// Should be different from just the prompt text alone
59+
expect(id).not.toBe(generatePromptId(promptText))
60+
})
61+
62+
test('handles very long prompts', () => {
63+
// Real-world prompts can include entire code blocks (100+ lines)
64+
const longCode = 'x\n'.repeat(500) // 500 lines
65+
const id = generatePromptId(longCode)
66+
expect(typeof id).toBe('string')
67+
expect(id.length).toBeGreaterThan(0)
68+
})
69+
70+
test('handles prompts with backticks and template literals', () => {
71+
// Prompts often include inline code with backticks
72+
const prompt = "In JavaScript I'd write: `The ${numCats === 1 ? 'cat is' : 'cats are'} hungry.`"
73+
const id = generatePromptId(prompt)
74+
expect(typeof id).toBe('string')
75+
expect(id.length).toBeGreaterThan(0)
76+
})
77+
78+
test('handles prompts with placeholders', () => {
79+
// Content uses placeholders like NEW-LANGUAGE, OWNER/REPOSITORY
80+
const id1 = generatePromptId('What is NEW-LANGUAGE best suited for?')
81+
const id2 = generatePromptId('In OWNER/REPOSITORY, create a feature request')
82+
expect(id1).not.toBe(id2)
83+
expect(typeof id1).toBe('string')
84+
expect(typeof id2).toBe('string')
85+
})
86+
87+
test('handles unicode and international characters', () => {
88+
// May encounter non-ASCII characters in prompts
89+
const id1 = generatePromptId('Explique-moi le code en français')
90+
const id2 = generatePromptId('コードを説明してください')
91+
const id3 = generatePromptId('Объясните этот код')
92+
expect(typeof id1).toBe('string')
93+
expect(typeof id2).toBe('string')
94+
expect(typeof id3).toBe('string')
95+
expect(id1).not.toBe(id2)
96+
expect(id2).not.toBe(id3)
97+
})
98+
})

src/content-render/unified/code-header.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { parse } from 'parse5'
1212
import { fromParse5 } from 'hast-util-from-parse5'
1313
import murmur from 'imurmurhash'
1414
import { getPrompt } from './copilot-prompt'
15+
import { generatePromptId } from '../lib/prompt-id'
1516
import type { Element } from 'hast'
1617

1718
interface LanguageConfig {
@@ -52,10 +53,18 @@ function wrapCodeExample(node: any, tree: any): Element {
5253
const code: string = node.children[0].children[0].value
5354

5455
const subnav = null // getSubnav() lives in annotate.ts, not needed for normal code blocks
55-
const prompt = getPrompt(node, tree, code) // returns null if there's no prompt
56+
const hasPrompt: boolean = Boolean(getPreMeta(node).prompt)
57+
const promptResult = hasPrompt ? getPrompt(node, tree, code) : null
5658
const hasCopy: boolean = Boolean(getPreMeta(node).copy) // defaults to true
5759

58-
const headerHast = header(lang, code, subnav, prompt, hasCopy)
60+
const headerHast = header(
61+
lang,
62+
code,
63+
subnav,
64+
promptResult?.element ?? null,
65+
hasCopy,
66+
promptResult?.promptContent,
67+
)
5968

6069
return h('div', { className: 'code-example' }, [headerHast, node])
6170
}
@@ -66,6 +75,7 @@ export function header(
6675
subnav: Element | null = null,
6776
prompt: Element | null = null,
6877
hasCopy: boolean = true,
78+
promptContent?: string,
6979
): Element {
7080
const codeId: string = murmur('js-btn-copy').hash(code).result().toString()
7181

@@ -100,6 +110,9 @@ export function header(
100110
)
101111
: null,
102112
h('pre', { hidden: true, 'data-clipboard': codeId }, code),
113+
promptContent
114+
? h('pre', { hidden: true, id: generatePromptId(promptContent) }, promptContent)
115+
: null,
103116
],
104117
)
105118
}

src/content-render/unified/copilot-prompt.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,36 @@ import octicons from '@primer/octicons'
88
import { parse } from 'parse5'
99
import { fromParse5 } from 'hast-util-from-parse5'
1010
import { getPreMeta } from './code-header'
11+
import { generatePromptId } from '../lib/prompt-id'
1112

12-
// Using any because node and tree are hast/unist AST nodes without proper TypeScript definitions
13-
// node is a pre element from the AST, tree is the full document AST
14-
// Returns a hast element node for the prompt button, or null if no prompt meta exists
15-
export function getPrompt(node: any, tree: any, code: string): any {
13+
// node and tree are hast/unist AST nodes without proper TypeScript definitions
14+
// Returns an object with the prompt button element and the full prompt content
15+
export function getPrompt(
16+
node: any,
17+
tree: any,
18+
code: string,
19+
): { element: any; promptContent: string } | null {
1620
const hasPrompt = Boolean(getPreMeta(node).prompt)
1721
if (!hasPrompt) return null
1822

1923
const { promptContent, ariaLabel } = buildPromptData(node, tree, code)
2024
const promptLink = `https://github.com/copilot?prompt=${encodeURIComponent(promptContent.trim())}`
25+
// Use murmur hash for deterministic ID (avoids hydration mismatch)
26+
const promptId: string = generatePromptId(promptContent)
2127

22-
return h(
28+
const element = h(
2329
'a',
2430
{
2531
href: promptLink,
2632
target: '_blank',
2733
class: ['btn', 'btn-sm', 'mr-1', 'tooltipped', 'tooltipped-nw', 'no-underline'],
2834
'aria-label': ariaLabel,
35+
'aria-describedby': promptId,
2936
},
3037
copilotIcon(),
3138
)
39+
40+
return { element, promptContent }
3241
}
3342

3443
// Using any because node and tree are hast/unist AST nodes without proper TypeScript definitions

0 commit comments

Comments
 (0)