Update dynamic Copilot prompts for screen reader accessibility (#58351)
This commit is contained in:
10
src/content-render/lib/prompt-id.ts
Normal file
10
src/content-render/lib/prompt-id.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import murmur from 'imurmurhash'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a deterministic ID for a prompt based on its content.
|
||||||
|
* Uses MurmurHash to create a unique ID that remains consistent across renders,
|
||||||
|
* avoiding hydration mismatches in the client.
|
||||||
|
*/
|
||||||
|
export function generatePromptId(promptContent: string): string {
|
||||||
|
return murmur('prompt').hash(promptContent).result().toString()
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// Defines {% prompt %}…{% endprompt %} to wrap its content in <code> and append the Copilot icon.
|
// Defines {% prompt %}…{% endprompt %} to wrap its content in <code> and append the Copilot icon.
|
||||||
|
|
||||||
import octicons from '@primer/octicons'
|
import octicons from '@primer/octicons'
|
||||||
|
import { generatePromptId } from '../lib/prompt-id'
|
||||||
|
|
||||||
interface LiquidTag {
|
interface LiquidTag {
|
||||||
type: 'block'
|
type: 'block'
|
||||||
@@ -30,10 +31,13 @@ export const Prompt: LiquidTag = {
|
|||||||
// Render the inner Markdown, wrap in <code>, then append the SVG
|
// Render the inner Markdown, wrap in <code>, then append the SVG
|
||||||
*render(scope: any): Generator<any, string, unknown> {
|
*render(scope: any): Generator<any, string, unknown> {
|
||||||
const content = yield this.liquid.renderer.renderTemplates(this.templates, scope)
|
const content = yield this.liquid.renderer.renderTemplates(this.templates, scope)
|
||||||
|
const contentString = String(content)
|
||||||
|
|
||||||
// build a URL with the prompt text encoded as query parameter
|
// build a URL with the prompt text encoded as query parameter
|
||||||
const promptParam: string = encodeURIComponent(content as string)
|
const promptParam: string = encodeURIComponent(contentString)
|
||||||
const href: string = `https://github.com/copilot?prompt=${promptParam}`
|
const href: string = `https://github.com/copilot?prompt=${promptParam}`
|
||||||
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>`
|
// Use murmur hash for deterministic ID (avoids hydration mismatch)
|
||||||
|
const promptId: string = generatePromptId(contentString)
|
||||||
|
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>`
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
98
src/content-render/tests/prompt-id.ts
Normal file
98
src/content-render/tests/prompt-id.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import { generatePromptId } from '@/content-render/lib/prompt-id'
|
||||||
|
|
||||||
|
describe('generatePromptId', () => {
|
||||||
|
test('generates consistent IDs for same content', () => {
|
||||||
|
const content = 'example prompt text'
|
||||||
|
const id1 = generatePromptId(content)
|
||||||
|
const id2 = generatePromptId(content)
|
||||||
|
expect(id1).toBe(id2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates different IDs for different content', () => {
|
||||||
|
const id1 = generatePromptId('prompt one')
|
||||||
|
const id2 = generatePromptId('prompt two')
|
||||||
|
expect(id1).not.toBe(id2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates numeric string IDs', () => {
|
||||||
|
const id = generatePromptId('test prompt')
|
||||||
|
expect(typeof id).toBe('string')
|
||||||
|
expect(Number.isNaN(Number(id))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles empty strings', () => {
|
||||||
|
const id = generatePromptId('')
|
||||||
|
expect(typeof id).toBe('string')
|
||||||
|
expect(id.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles special characters', () => {
|
||||||
|
const id1 = generatePromptId('prompt with\nnewlines')
|
||||||
|
const id2 = generatePromptId('prompt with\ttabs')
|
||||||
|
const id3 = generatePromptId('prompt with "quotes"')
|
||||||
|
expect(typeof id1).toBe('string')
|
||||||
|
expect(typeof id2).toBe('string')
|
||||||
|
expect(typeof id3).toBe('string')
|
||||||
|
expect(id1).not.toBe(id2)
|
||||||
|
expect(id2).not.toBe(id3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates deterministic IDs (regression test)', () => {
|
||||||
|
// These specific values ensure the hash function remains consistent
|
||||||
|
expect(generatePromptId('hello world')).toBe('1730621824')
|
||||||
|
expect(generatePromptId('test')).toBe('4180565944')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles prompts with code context (ref pattern)', () => {
|
||||||
|
// When ref= is used, the prompt includes referenced code + prompt text separated by newline
|
||||||
|
const codeContext =
|
||||||
|
'function logPersonAge(name, age, revealAge) {\n if (revealAge) {\n console.log(name);\n }\n}'
|
||||||
|
const promptText = 'Improve the variable names in this function'
|
||||||
|
const combinedPrompt = `${codeContext}\n${promptText}`
|
||||||
|
|
||||||
|
const id = generatePromptId(combinedPrompt)
|
||||||
|
expect(typeof id).toBe('string')
|
||||||
|
expect(id.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Should be different from just the prompt text alone
|
||||||
|
expect(id).not.toBe(generatePromptId(promptText))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles very long prompts', () => {
|
||||||
|
// Real-world prompts can include entire code blocks (100+ lines)
|
||||||
|
const longCode = 'x\n'.repeat(500) // 500 lines
|
||||||
|
const id = generatePromptId(longCode)
|
||||||
|
expect(typeof id).toBe('string')
|
||||||
|
expect(id.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles prompts with backticks and template literals', () => {
|
||||||
|
// Prompts often include inline code with backticks
|
||||||
|
const prompt = "In JavaScript I'd write: `The ${numCats === 1 ? 'cat is' : 'cats are'} hungry.`"
|
||||||
|
const id = generatePromptId(prompt)
|
||||||
|
expect(typeof id).toBe('string')
|
||||||
|
expect(id.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles prompts with placeholders', () => {
|
||||||
|
// Content uses placeholders like NEW-LANGUAGE, OWNER/REPOSITORY
|
||||||
|
const id1 = generatePromptId('What is NEW-LANGUAGE best suited for?')
|
||||||
|
const id2 = generatePromptId('In OWNER/REPOSITORY, create a feature request')
|
||||||
|
expect(id1).not.toBe(id2)
|
||||||
|
expect(typeof id1).toBe('string')
|
||||||
|
expect(typeof id2).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles unicode and international characters', () => {
|
||||||
|
// May encounter non-ASCII characters in prompts
|
||||||
|
const id1 = generatePromptId('Explique-moi le code en français')
|
||||||
|
const id2 = generatePromptId('コードを説明してください')
|
||||||
|
const id3 = generatePromptId('Объясните этот код')
|
||||||
|
expect(typeof id1).toBe('string')
|
||||||
|
expect(typeof id2).toBe('string')
|
||||||
|
expect(typeof id3).toBe('string')
|
||||||
|
expect(id1).not.toBe(id2)
|
||||||
|
expect(id2).not.toBe(id3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ import { parse } from 'parse5'
|
|||||||
import { fromParse5 } from 'hast-util-from-parse5'
|
import { fromParse5 } from 'hast-util-from-parse5'
|
||||||
import murmur from 'imurmurhash'
|
import murmur from 'imurmurhash'
|
||||||
import { getPrompt } from './copilot-prompt'
|
import { getPrompt } from './copilot-prompt'
|
||||||
|
import { generatePromptId } from '../lib/prompt-id'
|
||||||
import type { Element } from 'hast'
|
import type { Element } from 'hast'
|
||||||
|
|
||||||
interface LanguageConfig {
|
interface LanguageConfig {
|
||||||
@@ -52,10 +53,18 @@ function wrapCodeExample(node: any, tree: any): Element {
|
|||||||
const code: string = node.children[0].children[0].value
|
const code: string = node.children[0].children[0].value
|
||||||
|
|
||||||
const subnav = null // getSubnav() lives in annotate.ts, not needed for normal code blocks
|
const subnav = null // getSubnav() lives in annotate.ts, not needed for normal code blocks
|
||||||
const prompt = getPrompt(node, tree, code) // returns null if there's no prompt
|
const hasPrompt: boolean = Boolean(getPreMeta(node).prompt)
|
||||||
|
const promptResult = hasPrompt ? getPrompt(node, tree, code) : null
|
||||||
const hasCopy: boolean = Boolean(getPreMeta(node).copy) // defaults to true
|
const hasCopy: boolean = Boolean(getPreMeta(node).copy) // defaults to true
|
||||||
|
|
||||||
const headerHast = header(lang, code, subnav, prompt, hasCopy)
|
const headerHast = header(
|
||||||
|
lang,
|
||||||
|
code,
|
||||||
|
subnav,
|
||||||
|
promptResult?.element ?? null,
|
||||||
|
hasCopy,
|
||||||
|
promptResult?.promptContent,
|
||||||
|
)
|
||||||
|
|
||||||
return h('div', { className: 'code-example' }, [headerHast, node])
|
return h('div', { className: 'code-example' }, [headerHast, node])
|
||||||
}
|
}
|
||||||
@@ -66,6 +75,7 @@ export function header(
|
|||||||
subnav: Element | null = null,
|
subnav: Element | null = null,
|
||||||
prompt: Element | null = null,
|
prompt: Element | null = null,
|
||||||
hasCopy: boolean = true,
|
hasCopy: boolean = true,
|
||||||
|
promptContent?: string,
|
||||||
): Element {
|
): Element {
|
||||||
const codeId: string = murmur('js-btn-copy').hash(code).result().toString()
|
const codeId: string = murmur('js-btn-copy').hash(code).result().toString()
|
||||||
|
|
||||||
@@ -100,6 +110,9 @@ export function header(
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
h('pre', { hidden: true, 'data-clipboard': codeId }, code),
|
h('pre', { hidden: true, 'data-clipboard': codeId }, code),
|
||||||
|
promptContent
|
||||||
|
? h('pre', { hidden: true, id: generatePromptId(promptContent) }, promptContent)
|
||||||
|
: null,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,27 +8,36 @@ import octicons from '@primer/octicons'
|
|||||||
import { parse } from 'parse5'
|
import { parse } from 'parse5'
|
||||||
import { fromParse5 } from 'hast-util-from-parse5'
|
import { fromParse5 } from 'hast-util-from-parse5'
|
||||||
import { getPreMeta } from './code-header'
|
import { getPreMeta } from './code-header'
|
||||||
|
import { generatePromptId } from '../lib/prompt-id'
|
||||||
|
|
||||||
// Using any because node and tree are hast/unist AST nodes without proper TypeScript definitions
|
// node and tree are hast/unist AST nodes without proper TypeScript definitions
|
||||||
// node is a pre element from the AST, tree is the full document AST
|
// Returns an object with the prompt button element and the full prompt content
|
||||||
// Returns a hast element node for the prompt button, or null if no prompt meta exists
|
export function getPrompt(
|
||||||
export function getPrompt(node: any, tree: any, code: string): any {
|
node: any,
|
||||||
|
tree: any,
|
||||||
|
code: string,
|
||||||
|
): { element: any; promptContent: string } | null {
|
||||||
const hasPrompt = Boolean(getPreMeta(node).prompt)
|
const hasPrompt = Boolean(getPreMeta(node).prompt)
|
||||||
if (!hasPrompt) return null
|
if (!hasPrompt) return null
|
||||||
|
|
||||||
const { promptContent, ariaLabel } = buildPromptData(node, tree, code)
|
const { promptContent, ariaLabel } = buildPromptData(node, tree, code)
|
||||||
const promptLink = `https://github.com/copilot?prompt=${encodeURIComponent(promptContent.trim())}`
|
const promptLink = `https://github.com/copilot?prompt=${encodeURIComponent(promptContent.trim())}`
|
||||||
|
// Use murmur hash for deterministic ID (avoids hydration mismatch)
|
||||||
|
const promptId: string = generatePromptId(promptContent)
|
||||||
|
|
||||||
return h(
|
const element = h(
|
||||||
'a',
|
'a',
|
||||||
{
|
{
|
||||||
href: promptLink,
|
href: promptLink,
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
class: ['btn', 'btn-sm', 'mr-1', 'tooltipped', 'tooltipped-nw', 'no-underline'],
|
class: ['btn', 'btn-sm', 'mr-1', 'tooltipped', 'tooltipped-nw', 'no-underline'],
|
||||||
'aria-label': ariaLabel,
|
'aria-label': ariaLabel,
|
||||||
|
'aria-describedby': promptId,
|
||||||
},
|
},
|
||||||
copilotIcon(),
|
copilotIcon(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return { element, promptContent }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Using any because node and tree are hast/unist AST nodes without proper TypeScript definitions
|
// Using any because node and tree are hast/unist AST nodes without proper TypeScript definitions
|
||||||
|
|||||||
Reference in New Issue
Block a user