Skip to content

Commit 6167d14

Browse files
heiskrCopilot
andauthored
Tighten code comments in src/content-render/unified (#63437)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571
1 parent 87ce794 commit 6167d14

20 files changed

Lines changed: 129 additions & 400 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Unified content rendering notes
2+
3+
## Annotated code blocks
4+
5+
The `annotate` plugin parses fenced code blocks whose info string includes `annotate`. It splits the rendered output into `.annotate-row` elements, with code in `.annotate-code` and rendered notes in `.annotate-note`.
6+
7+
Authoring rules:
8+
9+
- Include `annotate` in the info string.
10+
- Include a language on the opening code fence.
11+
- Start notes with the single-line comment marker for the fenced language: `#`, `//`, `<!--`, `%%`, or `--`.
12+
- Match the comment marker style to the code fence language.
13+
- Use single-line comments only. Multiline comment syntax is not supported.
14+
- Put a space between the comment marker and annotation text.
15+
- Leave text after the comment marker blank to create a blank annotation.
16+
- Do not create a blank code block.
17+
- Put Markdown after the comment marker. Inline Markdown is supported. Avoid block Markdown such as headings, blockquotes, horizontal rules, tables, lists, or code fences.
18+
- Consecutive lines with the comment marker become one annotation.
19+
- Empty lines and lines that contain only spaces are discarded.
20+
- Start the code section with a single-line comment, or rendering throws.
21+
- For HTML fences, add a line such as `<!-- -->` after the annotations to keep syntax highlighting.
22+
23+
`parse-info-string.ts` must run before `remark-rehype`, and `annotate` must run before `highlight`.

‎src/content-render/unified/alerts.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
/*
2-
Custom "Alerts", based on similar filter/styling in the monolith code.
3-
*/
1+
// Matches the monolith alert syntax and styling.
42

53
import { visit } from 'unist-util-visit'
64
import { h } from 'hastscript'
@@ -23,7 +21,7 @@ const alertTypes: Record<string, AlertType> = {
2321
CAUTION: { icon: 'stop', color: 'danger' },
2422
}
2523

26-
// Must contain one of [!NOTE], [!IMPORTANT], ...
24+
// Matches alert markers such as [!NOTE] and [!IMPORTANT].
2725
const ALERT_REGEXP = new RegExp(`\\[!(${Object.keys(alertTypes).join('|')})\\]`, 'gi')
2826
// Non-global version for .test() and .match() to avoid stateful lastIndex issues
2927
const ALERT_REGEXP_DETECT = new RegExp(`\\[!(${Object.keys(alertTypes).join('|')})\\]`, 'i')

‎src/content-render/unified/annotate.ts‎

Lines changed: 5 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,4 @@
1-
/*
2-
Parses fenced code blocks with `annotate` in info string.
3-
Results in single line comments split out, output format is:
4-
5-
.annotate
6-
.annotate-row (n)
7-
.annotate-code
8-
.annotate-note
9-
10-
Contributing rules:
11-
- You must include `annotate` in the info string
12-
- You must include a language on the starting ` ``` ` tag.
13-
- Notes must start with one of: `#`, `//`, `<!--`, `%%`. (comment tag)
14-
- The comment tag style must match the language on the code fence.
15-
- Multiline-style comments, such as `/*` are not supported.
16-
- You can include any number of spaces before the comment tag starts.
17-
- You can include any number of spaces after the comment tag ends.
18-
- You can leave after the comment tag blank to create a blank annotation.
19-
- You cannot create a blank code block however.
20-
- Anything after the comment tag will be parsed with Markdown.
21-
- You can use any inline Markdown tag in the comment; recommend against using block tags such as headings, blockquote, horizontal rules, tables, lists, or code fences.
22-
- Multiple lines in row with the comment tag will result in a single annotation.
23-
- Empty lines, or lines that contain only space characters, will be discarded.
24-
- You must start the code section with a single line comment, otherwise the two will be flipped.
25-
- For HTML style, you can include a line after your annotations such as `<!-- -->` to maintain syntax highlighting; this will not impact what renders.
26-
27-
`parse-info-string.ts` plugin is required for this to work, and must come before `remark-rehype`.
28-
`annotate` must come before the `highlight` plugin.
29-
*/
1+
// Annotate fences split single-line comments into rendered notes beside code examples.
302

313
import { load } from 'js-yaml'
324
import fs from 'fs'
@@ -89,22 +61,7 @@ const languages = load(fs.readFileSync('./data/code-languages.yml', 'utf8')) as
8961
>
9062

9163
const commentRegexes = {
92-
// Also known has hash or sharp; but the unicode name is "number sign".
93-
// The reason this has 2 variants is because the hash is used, in bash
94-
// for both hash-hang and for comments.
95-
// For example:
96-
//
97-
// #!/bin/bash
98-
//
99-
// ...is not a comment.
100-
// But if you only look for `#` followed by anything-but `!` it will not
101-
// match if the line is just `#`.
102-
//
103-
// > /^\s*#[^!]\s*/.test('#')
104-
// false
105-
//
106-
// Which makes sense, because the `#` is not followed by anything.
107-
// That's why we use the | operator to make an "exception" for that case.
64+
// Keep shebang lines in code, but treat a line that only contains a number sign as a comment.
10865
number: /^\s*#[^!]\s*|^\s*#$/,
10966
slash: /^\s*\/\/\s*/,
11067
xml: /^\s*<!--\s*/,
@@ -286,10 +243,10 @@ function processAutotitleInMdast(mdast: Root, context: Context): void {
286243
const page = findPage(node.url, context.pages, context.redirects)
287244
if (page) {
288245
try {
289-
// Use rawTitle for synchronous processing in annotations
246+
// rawTitle avoids async title rendering while annotation notes convert to HAST.
290247
child.value = page.rawTitle || 'AUTOTITLE'
291248
} catch (error) {
292-
// Keep AUTOTITLE if we can't get the title
249+
// Keep AUTOTITLE when title lookup fails.
293250
logger.warn('Could not resolve AUTOTITLE', {
294251
url: node.url,
295252
error: error instanceof Error ? error.message : String(error),
@@ -308,7 +265,6 @@ function removeComment(lang: string): (line: string) => string {
308265
}
309266

310267
function getPreMeta(node: ElementNode): { annotate?: boolean; [key: string]: unknown } {
311-
// Here's why this monstrosity works:
312-
// https://github.com/syntax-tree/mdast-util-to-hast/blob/c87cd606731c88a27dbce4bfeaab913a9589bf83/lib/handlers/code.js#L40-L42
268+
// mdast-util-to-hast stores code-fence metadata on the code child.
313269
return node.children[0]?.data?.meta || {}
314270
}

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

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
/**
2-
* Adds a bar above code blocks that shows the language and a copy button.
3-
* Optionally, adds a prompt button to Copilot Chat blocks.
4-
*/
5-
61
import { load } from 'js-yaml'
72
import fs from 'fs'
83
import { visit } from 'unist-util-visit'
@@ -51,7 +46,7 @@ function wrapCodeExample(node: Element, tree: Root): Element {
5146
const subnav = null // getSubnav() lives in annotate.ts, not needed for normal code blocks
5247
const hasPrompt: boolean = Boolean(getPreMeta(node).prompt)
5348
const promptResult = hasPrompt ? getPrompt(node, tree, code) : null
54-
const hasCopy: boolean = Boolean(getPreMeta(node).copy) // defaults to true
49+
const hasCopy: boolean = Boolean(getPreMeta(node).copy)
5550

5651
const headerHast = header(
5752
lang,
@@ -120,10 +115,9 @@ function btnIcon(): Element {
120115
return btnIconElement as Element
121116
}
122117

123-
// node can be various hast element types, return value contains meta properties from code blocks
118+
// mdast-util-to-hast stores code-fence metadata on the first child.
124119
export function getPreMeta(node: Element): Record<string, unknown> {
125-
// Here's why this monstrosity works:
126-
// https://github.com/syntax-tree/mdast-util-to-hast/blob/c87cd606731c88a27dbce4bfeaab913a9589bf83/lib/handlers/code.js#L40-L42
120+
// Code block callers pass pre elements whose code child owns the metadata.
127121
const firstChild = node.children[0] as Element | undefined
128122
return (firstChild?.data as Record<string, Record<string, unknown>> | undefined)?.meta || {}
129123
}

‎src/content-render/unified/collect-mini-toc.ts‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,8 @@ function getClassString(el: Element): string {
2222
return ''
2323
}
2424

25-
// Rehype plugin that collects heading data (href, title, level, platform)
26-
// during rendering, so callers don't need to re-parse the HTML.
27-
// Place this after heading-links in the processor chain.
25+
// Collect heading href, title, level, and platform during rendering, so callers
26+
// do not re-parse HTML. Run after heading-links so anchors exist.
2827
const collectMiniToc: Plugin<[CollectMiniTocOptions], Root> = ({ collectInto }) => {
2928
if (!collectInto) return
3029

@@ -34,7 +33,7 @@ const collectMiniToc: Plugin<[CollectMiniTocOptions], Root> = ({ collectInto })
3433
if (!/^h[1-6]$/.test(el.tagName)) return
3534
if (!el.properties?.id) return
3635

37-
// Skip headings inside hidden ancestors
36+
// Hidden headings must not appear in the mini TOC.
3837
for (const anc of ancestors) {
3938
if (anc.type === 'element') {
4039
const ancEl = anc as Element
@@ -44,7 +43,7 @@ const collectMiniToc: Plugin<[CollectMiniTocOptions], Root> = ({ collectInto })
4443

4544
const headingLevel = parseInt(el.tagName.charAt(1), 10)
4645

47-
// Find the anchor child that heading-links.ts created
46+
// heading-links.ts creates the anchor child that owns the rendered heading text.
4847
const anchor = el.children.find(
4948
(child): child is Element =>
5049
child.type === 'element' && child.tagName === 'a' && hasClassName(child, 'heading-link'),
@@ -54,9 +53,7 @@ const collectMiniToc: Plugin<[CollectMiniTocOptions], Root> = ({ collectInto })
5453
const href = anchor.properties?.href as string | undefined
5554
if (!href) return
5655

57-
// Filter out direct-child <span> elements (and their content).
58-
// heading-links.ts always inserts the heading-link-symbol span as a
59-
// direct child of the anchor, so filtering direct children is sufficient.
56+
// heading-links.ts inserts heading-link-symbol directly, so direct filtering is enough.
6057
const textChildren = (anchor.children || []).filter(
6158
(child: ElementContent) =>
6259
!(child.type === 'element' && (child as Element).tagName === 'span'),

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

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
/**
2-
* Adds a runnable prompt button in the header of Copilot Chat blocks.
3-
*/
4-
51
import { find } from 'unist-util-find'
62
import { h } from 'hastscript'
73
import octicons from '@primer/octicons'
@@ -24,7 +20,7 @@ export function getPrompt(
2420

2521
const { promptContent, ariaLabel } = buildPromptData(node, tree, code)
2622
const promptLink = `https://github.com/copilot?prompt=${encodeURIComponent(promptContent.trim())}`
27-
// Use murmur hash for deterministic ID (avoids hydration mismatch)
23+
// Murmur keeps the prompt ID deterministic and avoids hydration mismatches.
2824
const promptId: string = generatePromptId(promptContent)
2925

3026
const element = h(
@@ -50,17 +46,17 @@ function buildPromptData(
5046
const ref = getPreMeta(node).ref
5147

5248
if (!ref) {
53-
// If no 'ref=<id>' meta is found, use just the current code for the prompt link.
49+
// Without ref metadata, the prompt has no extra code context.
5450
return promptOnly(code)
5551
}
5652

57-
// If the 'ref=<id>' meta is found, find a matching code block to include as context in the prompt link.
53+
// ref metadata points to a code block that becomes prompt context.
5854
const matchingCodeEl = findMatchingCode(ref as string, tree)
5955
if (!matchingCodeEl) {
6056
logger.warn('Cannot find referenced code block', { ref })
6157
return promptOnly(code)
6258
}
63-
// AST structure: element -> code -> text node with value property
59+
// HAST nests fenced code text at element, code, then text.
6460
const codeChild = matchingCodeEl.children[0] as Element | undefined
6561
const textNode = codeChild?.children[0] as { value?: string } | undefined
6662
const matchingCode = textNode?.value || null

‎src/content-render/unified/index.ts‎

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,8 @@ export async function renderUnified(
2424
return html.trim()
2525
}
2626

27-
/**
28-
* Run the same unified pipeline as `renderUnified`, but stop before the terminal
29-
* rehype-stringify step and return the hast (HTML AST) tree instead of a string.
30-
*
31-
* The 18 transform plugins all run during `processor.run()`; rehype-stringify is
32-
* only invoked by `processor.stringify()`. So we get the fully transformed hast
33-
* from a single pass, then derive the legacy HTML string from that exact same
34-
* tree. This guarantees the string and hast can never drift, and avoids running
35-
* the pipeline twice for consumers that still need the string (mini-TOC text,
36-
* search indexing, validators).
37-
*/
27+
// renderUnifiedToHast derives HTML from the same transformed HAST tree it returns,
28+
// so string and tree consumers cannot drift or run the pipeline twice.
3829
export async function renderUnifiedToHast(
3930
template: string,
4031
context: Context,
@@ -43,18 +34,12 @@ export async function renderUnifiedToHast(
4334
const mdast = processor.parse(template)
4435
const hast = (await processor.run(mdast)) as HastNodes
4536
const html = processor.stringify(hast).toString()
46-
// Strip `position` data (line/column offsets) the parser leaves on every node.
47-
// It is useless to the client and meaningfully inflates the serialized tree we
48-
// ship through the Next props boundary. Done after deriving `html` so the
49-
// string output is byte-identical to the legacy path.
37+
// Strip positions after stringifying to shrink Next props without changing the HTML output.
5038
stripPositions(hast)
5139
return { hast: hast as HastRoot, html: html.trim() }
5240
}
5341

54-
/**
55-
* Recursively delete `position` fields from a hast/unist tree in place.
56-
* Avoids adding a dependency (unist-util-remove-position) for one field.
57-
*/
42+
// Delete position fields in place to avoid a dependency for one field.
5843
function stripPositions(node: HastNodes): void {
5944
if (node && typeof node === 'object') {
6045
if ('position' in node) delete (node as { position?: unknown }).position

‎src/content-render/unified/parse-info-string.ts‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
// Based on https://spec.commonmark.org/0.30/#info-string
2-
// Parse out info strings on fenced code blocks, example:
3-
// ```javascript lineNumbers:left copy:all annotate
4-
// becomes...
5-
// node.lang = javascript
6-
// node.meta = { lineNumbers: 'left', copy: 'all', annotate: true }
7-
// Also parse equals signs, where id=some-id becomes { id: 'some-id' }
1+
// CommonMark defines fenced-code info strings: https://spec.commonmark.org/0.30/#info-string
2+
// Colon and equals metadata extend that grammar before rehype reads code data.
3+
// javascript lineNumbers:left copy:all annotate sets node.lang to javascript.
4+
// It sets node.meta to { lineNumbers: 'left', copy: 'all', annotate: true }.
5+
// id=some-id becomes { id: 'some-id' }.
86

97
import { visit } from 'unist-util-visit'
108
import type { Node } from 'unist'
@@ -24,7 +22,7 @@ export default function parseInfoString() {
2422
if (!matcher(node)) return
2523
node.meta = strToObj(node.meta as string)
2624

27-
// Temporary, remove {:copy} to avoid highlight parse error in translations.
25+
// Translated code fences can include {:copy}, which highlight treats as part of the language.
2826
if (node.lang) {
2927
node.lang = node.lang.replace('{:copy}', '')
3028
}
@@ -37,7 +35,7 @@ function strToObj(str?: string): Record<string, string | boolean> {
3735
return Object.fromEntries(
3836
str
3937
.split(/\s+/g)
40-
.map((k: string) => k.split(/[:=]/)) // split by colon or equals sign
38+
.map((k: string) => k.split(/[:=]/))
4139
.map(([k, ...v]: string[]) => [k, v.length ? v.join(':') : true]),
4240
)
4341
}

‎src/content-render/unified/processor.ts‎

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -41,38 +41,24 @@ export function createProcessor(context: Context): UnifiedProcessor {
4141
.use(remarkParse)
4242
.use(removeHtmlComments)
4343
.use(gfm)
44-
// Markdown AST below vvv
4544
.use(parseInfoString)
46-
// Using type assertion because rewriteLocalLinks is a factory function that takes context
47-
// and returns a transformer, but TypeScript's unified plugin types don't handle this pattern
45+
// TypeScript's unified plugin types do not model context-bound transformer factories.
4846
.use(rewriteLocalLinks as unknown as (ctx: Context) => void, context)
4947
.use(emoji)
50-
// Markdown AST above ^^^
5148
.use(remark2rehype, { allowDangerousHtml: true })
52-
// HTML AST below vvv
5349
.use(slug)
54-
// useEnglishHeadings plugin requires context with englishHeadings property
5550
.use(useEnglishHeadings as unknown as (ctx: Context) => void, context || {})
5651
.use(headingLinks)
5752
.use(codeHeader)
5853
.use(annotate as unknown as (ctx: Context) => void, context)
59-
// Using type assertion for highlight plugin due to complex type mismatch between unified and rehype-highlight
54+
// TypeScript's unified plugin types do not model rehype-highlight's lowlight options.
6055
.use(highlight as unknown as (options: unknown) => void, {
6156
languages: { ...common, graphql, dockerfile, http, groovy, erb, powershell },
6257
subset: false,
6358
aliases: {
64-
// As of Jan 2024, 'jsonc' is not supported by highlight.js. It
65-
// just becomes plain text.
66-
// But 'jsonc' works great in github.com. For example, when
67-
// previewing and edited .md content in the browser. Or viewing
68-
// PR diffs in web view.
69-
// So by sticking to 'jsonc' where there's JSON with comments,
70-
// it's technically more correct, looks good in github.com,
71-
// but with this alias you get the nice syntax highlighting when
72-
// viewed on our site.
59+
// Register jsonc as a JSON alias so JSON with comments gets docs-site highlighting.
7360
json: 'jsonc',
74-
// Docs supports a custom 'copilot' language, which is useful for contributors,
75-
// but is not a supported highlight.js language, so alias to 'text'.
61+
// Map docs-only copilot fences to text so contributors can use them.
7662
text: 'copilot',
7763
},
7864
})
@@ -88,10 +74,8 @@ export function createProcessor(context: Context): UnifiedProcessor {
8874
.use(wrapCodeTerms)
8975
.use(rewriteImgSources)
9076
.use(rewriteAssetImgTags)
91-
// alerts plugin requires context with alertTitles property
9277
.use(alerts as unknown as (ctx: Context) => void, context || {})
93-
// HTML AST above ^^^
94-
.use(html) as unknown as UnifiedProcessor // String below vvv
78+
.use(html) as unknown as UnifiedProcessor
9579
)
9680
}
9781

@@ -100,8 +84,7 @@ export function createMarkdownOnlyProcessor(context: Context): UnifiedProcessor
10084
unified()
10185
.use(remarkParse)
10286
.use(gfm)
103-
// Using type assertion because rewriteLocalLinks is a factory function that takes context
104-
// and returns a transformer, but TypeScript's unified plugin types don't handle this pattern
87+
// TypeScript's unified plugin types do not model context-bound transformer factories.
10588
.use(rewriteLocalLinks as unknown as (ctx: Context) => void, context)
10689
.use(remarkStringify) as unknown as UnifiedProcessor
10790
)
@@ -112,8 +95,7 @@ export function createMinimalProcessor(context: Context): UnifiedProcessor {
11295
unified()
11396
.use(remarkParse)
11497
.use(gfm)
115-
// Using type assertion because rewriteLocalLinks is a factory function that takes context
116-
// and returns a transformer, but TypeScript's unified plugin types don't handle this pattern
98+
// TypeScript's unified plugin types do not model context-bound transformer factories.
11799
.use(rewriteLocalLinks as unknown as (ctx: Context) => void, context)
118100
.use(remark2rehype, { allowDangerousHtml: true })
119101
.use(slug)

0 commit comments

Comments
 (0)