1
0
mirror of synced 2025-12-19 09:57:42 -05:00

Add Copilot prompt blocks and inline prompts (#57272)

Co-authored-by: Sarah Schneider <sarahs@github.com>
Co-authored-by: Sarah Schneider <sarahs@users.noreply.github.com>
This commit is contained in:
hubwriter
2025-09-10 09:14:13 +01:00
committed by GitHub
parent 60d6619820
commit 270fc3bf8e
11 changed files with 389 additions and 19 deletions

View File

@@ -7,6 +7,9 @@ bash:
bicep: bicep:
name: Bicep name: Bicep
comment: slash comment: slash
copilot:
name: Copilot Chat prompt
comment: none
csharp: csharp:
name: C# name: C#
comment: slash comment: slash

26
package-lock.json generated
View File

@@ -96,6 +96,7 @@
"tcp-port-used": "1.0.2", "tcp-port-used": "1.0.2",
"tsx": "^4.19.4", "tsx": "^4.19.4",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-find": "^3.0.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"url-template": "^3.1.1", "url-template": "^3.1.1",
"walk-sync": "^4.0.1" "walk-sync": "^4.0.1"
@@ -10563,6 +10564,11 @@
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
}, },
"node_modules/lodash.iteratee": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.iteratee/-/lodash.iteratee-4.7.0.tgz",
"integrity": "sha512-yv3cSQZmfpbIKo4Yo45B1taEvxjNvcpF1CEOc0Y6dEyvhPIfEJE3twDwPgWTPQubcSgXyBwBKG6wpQvWMDOf6Q=="
},
"node_modules/lodash.kebabcase": { "node_modules/lodash.kebabcase": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz",
@@ -15653,6 +15659,20 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.0.tgz",
"integrity": "sha512-MFETx3tbTjE7Uk6vvnWINA/1iJ7LuMdO4fcq8UfF0pRbj01aGLduVvQcRyswuACJdpnHgg8E3rQLhaRdNEJS0w==" "integrity": "sha512-MFETx3tbTjE7Uk6vvnWINA/1iJ7LuMdO4fcq8UfF0pRbj01aGLduVvQcRyswuACJdpnHgg8E3rQLhaRdNEJS0w=="
}, },
"node_modules/unist-util-find": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unist-util-find/-/unist-util-find-3.0.0.tgz",
"integrity": "sha512-T7ZqS7immLjYyC4FCp2hDo3ksZ1v+qcbb+e5+iWxc2jONgHOLXPCpms1L8VV4hVxCXgWTxmBHDztuEZFVwC+Gg==",
"dependencies": {
"@types/unist": "^3.0.0",
"lodash.iteratee": "^4.0.0",
"unist-util-visit": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-find-after": { "node_modules/unist-util-find-after": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
@@ -15683,6 +15703,12 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/unist-util-find/node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/unist-util-is": { "node_modules/unist-util-is": {
"version": "5.1.1", "version": "5.1.1",
"license": "MIT", "license": "MIT",

View File

@@ -243,6 +243,7 @@
"tcp-port-used": "1.0.2", "tcp-port-used": "1.0.2",
"tsx": "^4.19.4", "tsx": "^4.19.4",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-find": "^3.0.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"url-template": "^3.1.1", "url-template": "^3.1.1",
"walk-sync": "^4.0.1" "walk-sync": "^4.0.1"

View File

@@ -6,6 +6,7 @@ import Octicon from './octicon'
import Ifversion from './ifversion' import Ifversion from './ifversion'
import { Tool, tags as toolTags } from './tool' import { Tool, tags as toolTags } from './tool'
import { Spotlight, tags as spotlightTags } from './spotlight' import { Spotlight, tags as spotlightTags } from './spotlight'
import { Prompt } from './prompt'
export const engine = new Liquid({ export const engine = new Liquid({
extname: '.html', extname: '.html',
@@ -25,6 +26,8 @@ for (const tag in spotlightTags) {
engine.registerTag(tag, Spotlight) engine.registerTag(tag, Spotlight)
} }
engine.registerTag('prompt', Prompt)
/** /**
* Like the `size` filter, but specifically for * Like the `size` filter, but specifically for
* getting the number of keys in an object * getting the number of keys in an object

View File

@@ -0,0 +1,31 @@
// src/content-render/liquid/prompt.js
// Defines {% prompt %}…{% endprompt %} to wrap its content in <code> and append the Copilot icon.
import octicons from '@primer/octicons'
export const Prompt = {
type: 'block',
// Collect everything until {% endprompt %}
parse(tagToken, remainTokens) {
this.templates = []
const stream = this.liquid.parser.parseStream(remainTokens)
stream
.on('template', (tpl) => this.templates.push(tpl))
.on('tag:endprompt', () => stream.stop())
.on('end', () => {
throw new Error(`{% prompt %} tag not closed`)
})
stream.start()
},
// Render the inner Markdown, wrap in <code>, then append the SVG
render: function* (scope) {
const content = yield this.liquid.renderer.renderTemplates(this.templates, scope)
// build a URL with the prompt text encoded as query parameter
const promptParam = encodeURIComponent(content)
const href = `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>`
},
}

View File

@@ -0,0 +1,203 @@
import { describe, it, expect, vi } from 'vitest'
import { renderContent } from '@/content-render/index'
describe('code-header plugin', () => {
describe('copilot language code blocks', () => {
it('should render basic copilot code block without header (no copy meta)', async () => {
const markdown = '```copilot\nImprove the variable names in this function\n```'
const html = await renderContent(markdown)
// Should keep copilot as the language (not convert to text without copy meta)
expect(html).toContain('language-copilot')
// Should NOT wrap in code-example div since no copy meta
expect(html).not.toContain('code-example')
// Should NOT have header since no copy meta
expect(html).not.toContain('<header')
})
it('should render copilot code block with copy button when copy meta is present', async () => {
const markdown = '```copilot copy\nImprove the variable names in this function\n```'
const html = await renderContent(markdown)
// Should be wrapped in code-example div
expect(html).toContain('code-example')
// Should have header with copy button
expect(html).toContain('<header')
expect(html).toContain('js-btn-copy')
expect(html).toContain('language-copilot')
// Should NOT have prompt button (no prompt meta)
expect(html).not.toContain('https://github.com/copilot?prompt=')
})
it('should render copilot code block with prompt button only (no copy meta)', async () => {
const markdown = '```copilot prompt\nImprove the variable names in this function\n```'
const html = await renderContent(markdown)
// Should be wrapped in code-example div
expect(html).toContain('code-example')
// Should have header
expect(html).toContain('<header')
// Should have prompt button
expect(html).toContain('https://github.com/copilot?prompt=')
expect(html).toContain('language-copilot')
// Should NOT have copy button
expect(html).not.toContain('js-btn-copy')
})
it('should render copilot code block with both copy and prompt buttons when prompt meta is present', async () => {
const markdown = '```copilot copy prompt\nImprove the variable names in this function\n```'
const html = await renderContent(markdown)
// Should be wrapped in code-example div
expect(html).toContain('code-example')
// Should have header with copy button
expect(html).toContain('<header')
expect(html).toContain('js-btn-copy')
// Should have prompt button with encoded URL
expect(html).toContain('https://github.com/copilot?prompt=')
expect(html).toContain('Improve%20the%20variable%20names%20in%20this%20function')
// Should have Copilot icon button
expect(html).toContain('aria-label="Run this prompt in Copilot Chat"')
expect(html).toContain('language-copilot')
})
it('should render copilot code block with context reference when ref meta is present', async () => {
const markdown = `
\`\`\`javascript id=js-age
function logPersonsAge(a, b, c) {
if (c) {
console.log(a + " is " + b + " years old.");
} else {
console.log(a + " does not want to reveal their age.");
}
}
\`\`\`
\`\`\`copilot copy prompt ref=js-age
Improve the variable names in this function
\`\`\`
`
const html = await renderContent(markdown)
// Should have prompt button with both code blocks in URL
expect(html).toContain('https://github.com/copilot?prompt=')
// Should contain encoded content from both the referenced code and the prompt
expect(html).toContain('function%20logPersonsAge')
expect(html).toContain('Improve%20the%20variable%20names')
// Should have different aria-label indicating context
expect(html).toContain('aria-label="Run this prompt with context in Copilot Chat"')
})
it('should render copilot code block with prompt and ref only (no copy meta)', async () => {
const markdown = `
\`\`\`javascript id=js-age
function logPersonsAge(a, b, c) {
if (c) {
console.log(a + " is " + b + " years old.");
} else {
console.log(a + " does not want to reveal their age.");
}
}
\`\`\`
\`\`\`copilot prompt ref=js-age
Improve the variable names in this function
\`\`\`
`
const html = await renderContent(markdown)
// Should have prompt button with both code blocks in URL
expect(html).toContain('https://github.com/copilot?prompt=')
// Should contain encoded content from both the referenced code and the prompt
expect(html).toContain('function%20logPersonsAge')
expect(html).toContain('Improve%20the%20variable%20names')
// Should have different aria-label indicating context
expect(html).toContain('aria-label="Run this prompt with context in Copilot Chat"')
// Should NOT have copy button
expect(html).not.toContain('js-btn-copy')
})
})
describe('edge cases', () => {
it('should handle missing reference gracefully and fall back to current code only', async () => {
// Mock console.warn to capture warning
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const markdown =
'```copilot copy prompt ref=nonexistent-id\nImprove the variable names in this function\n```'
const html = await renderContent(markdown)
// Should warn about missing reference
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Can't find referenced code block with id=nonexistent-id"),
)
// Should still render with prompt button using current code only
expect(html).toContain('https://github.com/copilot?prompt=')
expect(html).toContain('Improve%20the%20variable%20names%20in%20this%20function')
// Should NOT contain any referenced code since none was found
expect(html).not.toContain('function%20logPersonsAge')
// Should have standard aria-label (not context version)
expect(html).toContain('aria-label="Run this prompt in Copilot Chat"')
// Should not crash or fail
expect(html).toContain('code-example')
// Restore console.warn
consoleWarnSpy.mockRestore()
})
it('should not process annotated code blocks', async () => {
const markdown = `\`\`\`javascript copy annotate
// This is an annotation
function test() {}
\`\`\``
const html = await renderContent(markdown)
// Should NOT wrap in code-example div (annotated blocks are excluded)
expect(html).not.toContain('code-example')
})
it('should handle regular code blocks with copy', async () => {
const markdown = '```javascript copy\nfunction test() {}\n```'
const html = await renderContent(markdown)
// Should render with copy button
expect(html).toContain('code-example')
expect(html).toContain('js-btn-copy')
expect(html).toContain('language-javascript')
})
})
describe('URL encoding', () => {
it('should properly encode special characters in prompt URLs', async () => {
const markdown = '```copilot copy prompt\nHow do I handle "quotes" and & symbols?\n```'
const html = await renderContent(markdown)
// Should encode quotes and ampersands properly
expect(html).toContain('%22quotes%22')
expect(html).toContain('%26%20symbols')
})
it('should handle multiline prompts correctly', async () => {
const markdown = `\`\`\`copilot copy prompt
This is line 1
This is line 2
\`\`\``
const html = await renderContent(markdown)
// Should encode newlines properly
expect(html).toContain('This%20is%20line%201%0AThis%20is%20line%202')
})
})
})

View File

@@ -0,0 +1,11 @@
import { describe, expect, test } from 'vitest'
import { renderContent } from '@/content-render/index'
describe('prompt tag', () => {
test('wraps content in <code> and appends svg', async () => {
const input = 'Here is your prompt: {% prompt %}example prompt text{% endprompt %}.'
const output = await renderContent(input)
expect(output).toContain('<code>example prompt text</code><a')
expect(output).toContain('<svg')
})
})

View File

@@ -1,5 +1,6 @@
/** /**
* Adds a bar above code blocks that shows the language and a copy button * Adds a bar above code blocks that shows the language and a copy button.
* Optionally, adds a prompt button to Copilot Chat blocks.
*/ */
import yaml from 'js-yaml' import yaml from 'js-yaml'
@@ -10,34 +11,43 @@ 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 murmur from 'imurmurhash' import murmur from 'imurmurhash'
import { getPrompt } from './copilot-prompt'
const languages = yaml.load(fs.readFileSync('./data/code-languages.yml', 'utf8')) const languages = yaml.load(fs.readFileSync('./data/code-languages.yml', 'utf8'))
const matcher = (node) => const matcher = (node) =>
node.type === 'element' && node.type === 'element' &&
node.tagName === 'pre' && node.tagName === 'pre' &&
// For now, limit to ones with the copy meta, // For now, limit to ones with the copy or prompt meta,
// but we may enable for all examples later. // but we may enable for all examples later.
getPreMeta(node).copy && (getPreMeta(node).copy || getPreMeta(node).prompt) &&
// Don't add this header for annotated examples. // Don't add this header for annotated examples.
!getPreMeta(node).annotate !getPreMeta(node).annotate
export default function codeHeader() { export default function codeHeader() {
return (tree) => { return (tree) => {
visit(tree, matcher, (node, index, parent) => { visit(tree, matcher, (node, index, parent) => {
parent.children[index] = wrapCodeExample(node) parent.children[index] = wrapCodeExample(node, tree)
}) })
} }
} }
function wrapCodeExample(node) { function wrapCodeExample(node, tree) {
const lang = node.children[0].properties.className?.[0].replace('language-', '') const lang = node.children[0].properties.className?.[0].replace('language-', '')
const code = node.children[0].children[0].value const code = node.children[0].children[0].value
return h('div', { className: 'code-example' }, [header(lang, code), node])
const subnav = null // getSubnav() lives in annotate.js, not needed for normal code blocks
const prompt = getPrompt(node, tree, code) // returns null if there's no prompt
const hasCopy = Boolean(getPreMeta(node).copy) // defaults to true
const headerHast = header(lang, code, subnav, prompt, hasCopy)
return h('div', { className: 'code-example' }, [headerHast, node])
} }
export function header(lang, code, subnav) { export function header(lang, code, subnav = null, prompt = null, hasCopy = true) {
const codeId = murmur('js-btn-copy').hash(code).result() const codeId = murmur('js-btn-copy').hash(code).result()
return h( return h(
'header', 'header',
{ {
@@ -56,15 +66,18 @@ export function header(lang, code, subnav) {
[ [
h('span', { className: 'flex-1' }, languages[lang]?.name), h('span', { className: 'flex-1' }, languages[lang]?.name),
subnav, subnav,
h( prompt,
'button', hasCopy
{ ? h(
class: ['js-btn-copy', 'btn', 'btn-sm', 'tooltipped', 'tooltipped-nw'], 'button',
'aria-label': `Copy ${languages[lang]?.name} code to clipboard`, {
'data-clipboard': codeId, class: ['js-btn-copy', 'btn', 'btn-sm', 'tooltipped', 'tooltipped-nw'],
}, 'aria-label': `Copy ${languages[lang]?.name} code to clipboard`,
btnIcon(), 'data-clipboard': codeId,
), },
btnIcon(),
)
: null,
h('pre', { hidden: true, 'data-clipboard': codeId }, code), h('pre', { hidden: true, 'data-clipboard': codeId }, code),
], ],
) )
@@ -77,7 +90,7 @@ function btnIcon() {
return btnIcon return btnIcon
} }
function getPreMeta(node) { export function getPreMeta(node) {
// Here's why this monstrosity works: // Here's why this monstrosity works:
// https://github.com/syntax-tree/mdast-util-to-hast/blob/c87cd606731c88a27dbce4bfeaab913a9589bf83/lib/handlers/code.js#L40-L42 // https://github.com/syntax-tree/mdast-util-to-hast/blob/c87cd606731c88a27dbce4bfeaab913a9589bf83/lib/handlers/code.js#L40-L42
return node.children[0]?.data?.meta || {} return node.children[0]?.data?.meta || {}

View File

@@ -0,0 +1,75 @@
/**
* Adds a runnable prompt button in the header of Copilot Chat blocks.
*/
import { find } from 'unist-util-find'
import { h } from 'hastscript'
import octicons from '@primer/octicons'
import { parse } from 'parse5'
import { fromParse5 } from 'hast-util-from-parse5'
import { getPreMeta } from './code-header'
export function getPrompt(node, tree, code) {
const hasPrompt = Boolean(getPreMeta(node).prompt)
if (!hasPrompt) return null
const { promptContent, ariaLabel } = buildPromptData(node, tree, code)
const promptLink = `https://github.com/copilot?prompt=${encodeURIComponent(promptContent.trim())}`
return h(
'a',
{
href: promptLink,
target: '_blank',
class: ['btn', 'btn-sm', 'mr-1', 'tooltipped', 'tooltipped-nw', 'no-underline'],
'aria-label': ariaLabel,
},
copilotIcon(),
)
}
function buildPromptData(node, tree, code) {
// Find a ref meta in the format 'ref=<id>'
const ref = getPreMeta(node).ref
if (!ref) {
// If no 'ref=<id>' meta is found, use just the current code for the prompt link.
return promptOnly(code)
}
// If the 'ref=<id>' meta is found, find a matching code block to include as context in the prompt link.
const matchingCodeEl = findMatchingCode(ref, tree)
if (!matchingCodeEl) {
console.warn(`Can't find referenced code block with id=${ref}`)
return promptOnly(code)
}
const matchingCode = matchingCodeEl?.children[0].children[0].value || null
return promptAndContext(code, matchingCode)
}
function promptOnly(code) {
return {
promptContent: code,
ariaLabel: 'Run this prompt in Copilot Chat',
}
}
function promptAndContext(code, matchingCode) {
return {
promptContent: `${matchingCode}\n${code}`,
ariaLabel: 'Run this prompt with context in Copilot Chat',
}
}
function findMatchingCode(ref, tree) {
return find(tree, (node) => {
return node.type === 'element' && node.tagName === 'pre' && getPreMeta(node).id === ref
})
}
function copilotIcon() {
const copilotIconHtml = octicons.copilot.toSVG()
const copilotIconAst = parse(String(copilotIconHtml), { sourceCodeLocationInfo: true })
const copilotIcon = fromParse5(copilotIconAst, { file: copilotIconHtml })
return copilotIcon
}

View File

@@ -4,6 +4,7 @@
// becomes... // becomes...
// node.lang = javascript // node.lang = javascript
// node.meta = { lineNumbers: 'left', copy: 'all', annotate: true } // node.meta = { lineNumbers: 'left', copy: 'all', annotate: true }
// Also parse equals signs, where id=some-id becomes { id: 'some-id' }
import { visit } from 'unist-util-visit' import { visit } from 'unist-util-visit'
@@ -25,7 +26,7 @@ function strToObj(str) {
return Object.fromEntries( return Object.fromEntries(
str str
.split(/\s+/g) .split(/\s+/g)
.map((k) => k.split(':')) .map((k) => k.split(/[:=]/)) // split by colon or equals sign
.map(([k, ...v]) => [k, v.length ? v.join(':') : true]), .map(([k, ...v]) => [k, v.length ? v.join(':') : true]),
) )
} }

View File

@@ -57,7 +57,7 @@ export function createProcessor(context: Context): UnifiedProcessor {
subset: false, subset: false,
aliases: { aliases: {
// As of Jan 2024, 'jsonc' is not supported by highlight.js. It // As of Jan 2024, 'jsonc' is not supported by highlight.js. It
// just because plain text. // just becomes plain text.
// But 'jsonc' works great in github.com. For example, when // But 'jsonc' works great in github.com. For example, when
// previewing and edited .md content in the browser. Or viewing // previewing and edited .md content in the browser. Or viewing
// PR diffs in web view. // PR diffs in web view.
@@ -66,6 +66,9 @@ export function createProcessor(context: Context): UnifiedProcessor {
// but with this alias you get the nice syntax highlighting when // but with this alias you get the nice syntax highlighting when
// viewed on our site. // viewed on our site.
json: 'jsonc', json: 'jsonc',
// Docs supports a custom 'copilot' language, which is useful for contributors,
// but is not a supported highlight.js language, so alias to 'text'.
text: 'copilot',
}, },
}) })
.use(raw) .use(raw)