Skip to content

Commit 6ad0f8b

Browse files
heiskrCopilot
andauthored
Move code-term wrapping from a client enhancer to a rehype plugin (#61773)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4eedb19 commit 6ad0f8b

6 files changed

Lines changed: 145 additions & 44 deletions

File tree

src/content-render/tests/render-content.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,57 @@ var a = 1
244244
// Generates a murmurhash based ID that matches a <pre>
245245
})
246246

247+
describe('wrap-code-terms (<wbr> in table code)', () => {
248+
const table = (term: string) =>
249+
nl(`
250+
| Term | Description |
251+
| --- | --- |
252+
| \`${term}\` | a long code term |
253+
`)
254+
255+
test('breaks camelCase code terms in tables', async () => {
256+
const html = await renderContent(table('hasOrganizationProjects'))
257+
const code = load(html)('td code').first()
258+
expect(code.html()).toBe('has<wbr>Organization<wbr>Projects')
259+
})
260+
261+
test('breaks long underscore code terms after the 12th character', async () => {
262+
const html = await renderContent(table('has_organization_projects_enabled'))
263+
const code = load(html)('td code').first()
264+
expect(code.html()).toBe('has_organization_<wbr>projects_enabled')
265+
})
266+
267+
test('breaks code terms separated by slashes', async () => {
268+
const html = await renderContent(table('actions/checkout/setup-node'))
269+
const code = load(html)('td code').first()
270+
expect(code.html()).toBe('actions/<wbr>checkout/<wbr>setup-node')
271+
})
272+
273+
test('leaves short code terms untouched', async () => {
274+
const html = await renderContent(table('shortTerm'))
275+
const code = load(html)('td code').first()
276+
expect(code.html()).toBe('shortTerm')
277+
})
278+
279+
test('does not insert <wbr> into code outside of tables', async () => {
280+
const html = await renderContent(nl('Inline `hasOrganizationProjects` term.'))
281+
expect(html).not.toContain('<wbr>')
282+
})
283+
284+
test('breaks a code term whose text lives inside a child anchor', async () => {
285+
const html = await renderContent(
286+
nl(`
287+
| Term | Description |
288+
| --- | --- |
289+
| [\`hasOrganizationProjects\`](/foo) | a long code term |
290+
`),
291+
)
292+
const $ = load(html)
293+
expect($('td a').attr('href')).toBe('/foo')
294+
expect($('td a code').html()).toBe('has<wbr>Organization<wbr>Projects')
295+
})
296+
})
297+
247298
test('renders alerts with data-container attribute for analytics', async () => {
248299
const template = nl(`
249300
> [!NOTE]

src/content-render/unified/processor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import rewriteEmptyTableRows from './rewrite-empty-table-rows'
2626
import rewriteForRowheaders from './rewrite-for-rowheaders'
2727
import rewriteTableCaptions from './rewrite-table-captions'
2828
import wrapProceduralImages from './wrap-procedural-images'
29+
import wrapCodeTerms from './wrap-code-terms'
2930
import parseInfoString from './parse-info-string'
3031
import annotate from './annotate'
3132
import alerts from './alerts'
@@ -84,6 +85,7 @@ export function createProcessor(context: Context): UnifiedProcessor {
8485
.use(rewriteTheadThScope)
8586
.use(rewriteForRowheaders)
8687
.use(rewriteTableCaptions)
88+
.use(wrapCodeTerms)
8789
.use(rewriteImgSources)
8890
.use(rewriteAssetImgTags)
8991
// alerts plugin requires context with alertTitles property
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { Root, Element, Text, ElementContent, Parents } from 'hast'
2+
import { visitParents } from 'unist-util-visit-parents'
3+
import type { Transformer } from 'unified'
4+
5+
// This plugin improves table rendering on reference pages by inserting a <wbr>
6+
// element into code terms that use camelcase, slashes, or underscores, inspired
7+
// by http://heap.ch/blog/2016/01/19/camelwrap/
8+
//
9+
// It runs server-side on the HTML AST so the break opportunities are present in
10+
// the rendered markup (both the HTML string and the hast that React renders),
11+
// rather than being mutated into the DOM imperatively on the client after
12+
// hydration. It only applies to `<code>` inside a `<table>`, matching the
13+
// previous client-side selector `#article-contents table code`.
14+
15+
const wordsLongerThan18Chars = /[\S]{18,}/g
16+
const camelCaseChars = /([a-z])([A-Z])/g
17+
const underscoresAfter12thChar = /([\w:]{12}[^_]*?)_/g
18+
const slashChars = /([/\\])/g
19+
20+
// A sentinel that cannot appear in source text; marks where a <wbr> goes.
21+
const WBR = '\u0000'
22+
23+
function withBreakOpportunities(value: string): string {
24+
return value.replace(wordsLongerThan18Chars, (str) =>
25+
str
26+
// GraphQL code terms use camelcase
27+
.replace(camelCaseChars, `$1${WBR}$2`)
28+
// REST code terms use underscores. To keep word breaks looking nice, only
29+
// break on underscores after the 12th char so `has_organization_projects`
30+
// breaks after `has_organization` instead of after `has_`.
31+
.replace(underscoresAfter12thChar, `$1_${WBR}`)
32+
// Some Actions reference pages have tables with code terms separated by slashes.
33+
.replace(slashChars, `$1${WBR}`),
34+
)
35+
}
36+
37+
// A fresh <wbr> element per insertion, so inserted nodes never share the same
38+
// `properties`/`children` object references (which could be mutated later).
39+
function createWbrElement(): Element {
40+
return { type: 'element', tagName: 'wbr', properties: {}, children: [] }
41+
}
42+
43+
// Replace a text node's value with a sequence of text nodes interleaved with
44+
// <wbr> elements at each break opportunity. Returns null when nothing changed.
45+
function splitTextNode(node: Text): ElementContent[] | null {
46+
const replaced = withBreakOpportunities(node.value)
47+
if (!replaced.includes(WBR)) return null
48+
49+
const out: ElementContent[] = []
50+
const parts = replaced.split(WBR)
51+
for (const [index, part] of parts.entries()) {
52+
if (part) out.push({ type: 'text', value: part })
53+
if (index < parts.length - 1) out.push(createWbrElement())
54+
}
55+
return out
56+
}
57+
58+
// Walk every descendant text node of `code`, inserting <wbr> elements in place.
59+
// This naturally handles the case where the code term's text lives inside a
60+
// child anchor element.
61+
function insertWordBreaks(code: Element): void {
62+
const transform = (parent: Element): void => {
63+
const next: ElementContent[] = []
64+
for (const child of parent.children) {
65+
if (child.type === 'text') {
66+
const split = splitTextNode(child)
67+
next.push(...(split ?? [child]))
68+
} else {
69+
if (child.type === 'element') transform(child)
70+
next.push(child)
71+
}
72+
}
73+
parent.children = next
74+
}
75+
transform(code)
76+
}
77+
78+
function hasTableAncestor(ancestors: Parents[]): boolean {
79+
return ancestors.some((ancestor) => ancestor.type === 'element' && ancestor.tagName === 'table')
80+
}
81+
82+
export default function wrapCodeTerms(): Transformer<Root> {
83+
return (tree: Root) =>
84+
visitParents(tree, 'element', (node, ancestors) => {
85+
const el = node as Element
86+
if (el.tagName !== 'code') return
87+
if (!hasTableAncestor(ancestors)) return
88+
insertWordBreaks(el)
89+
})
90+
}

src/frame/components/lib/wrap-code-terms.ts

Lines changed: 0 additions & 41 deletions
This file was deleted.

src/frame/components/ui/MarkdownContent/stylesheets/table.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818

1919
// We want to keep table-layout: auto so that column widths dynamically adjust;
2020
// otherwise entries get needlessly squashed into narrow columns. As a workaround,
21-
// we use components/lib/wrap-code-terms.ts to prevent some reference table content
21+
// we use the content-render/unified/wrap-code-terms.ts rehype plugin to prevent some
22+
// reference table content
2223
// from expanding beyond the horizontal boundaries of the parent element.
2324
@media (min-width: 544px) {
2425
table-layout: auto;

src/landings/pages/product.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import type { JourneyTrack } from '@/journeys/lib/journey-path-resolver'
1010
// typically operating on elements **within** an article.
1111
import copyCode from '@/frame/components/lib/copy-code'
1212
import toggleAnnotation from '@/frame/components/lib/toggle-annotations'
13-
import wrapCodeTerms from '@/frame/components/lib/wrap-code-terms'
1413

1514
import {
1615
MainContextT,
@@ -49,7 +48,6 @@ import { JourneyLanding } from '@/landings/components/journey/JourneyLanding'
4948

5049
function initiateArticleScripts() {
5150
copyCode()
52-
wrapCodeTerms()
5351
toggleAnnotation()
5452
}
5553

0 commit comments

Comments
 (0)