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:
@@ -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
26
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
31
src/content-render/liquid/prompt.js
Normal file
31
src/content-render/liquid/prompt.js
Normal 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>`
|
||||||
|
},
|
||||||
|
}
|
||||||
203
src/content-render/tests/copilot-code-blocks.js
Normal file
203
src/content-render/tests/copilot-code-blocks.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
11
src/content-render/tests/prompt.js
Normal file
11
src/content-render/tests/prompt.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 || {}
|
||||||
|
|||||||
75
src/content-render/unified/copilot-prompt.js
Normal file
75
src/content-render/unified/copilot-prompt.js
Normal 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
|
||||||
|
}
|
||||||
@@ -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]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user