Skip to content

Commit a43b863

Browse files
barbados-clemensclaude
authored andcommitted
fix(nx-dev): make headers and table options linkable (#34267)
- fix(nx-dev): always link headers regardless of mdoc or markdown content source (generated vs static file) - fix(nx-dev): make option/property columns in table linkable - the table column header is matched on `options`, `option`, `properties`, and property` (case insensitive) https://github.com/user-attachments/assets/7250b9d5-1030-4ebc-9e21-0a05f295bbf5 Note bc mdoc and `renderMarkdown` go through 2 different rendering pipelines, this logic must bc within the markdoc config and rehype (markdown) processing logic. tried to shared logic where I could --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> (cherry picked from commit 2511215)
1 parent f2142a9 commit a43b863

11 files changed

Lines changed: 366 additions & 18 deletions

astro-docs/astro.config.mjs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import react from '@astrojs/react';
66
import markdoc from '@astrojs/markdoc';
77
import tailwindcss from '@tailwindcss/vite';
88
import { sidebar } from './sidebar.mts';
9+
import rehypeTableOptionLinks from './src/plugins/utils/rehype-table-option-links.ts';
910

1011
const BASE = '/docs';
1112

@@ -33,6 +34,9 @@ export default defineConfig({
3334
},
3435
},
3536
},
37+
markdown: {
38+
rehypePlugins: [rehypeTableOptionLinks],
39+
},
3640
trailingSlash: 'never',
3741
// This adapter doesn't support local previews, so only load it on Netlify.
3842
adapter: process.env['NETLIFY'] ? netlify() : undefined,
@@ -94,10 +98,7 @@ export default defineConfig({
9498
'./src/plugins/canonical.middleware.ts',
9599
],
96100
markdown: {
97-
// this breaks the renderMarkdown function in the plugin loader due to starlight path normalization
98-
// as to _why_ it has to normalize a path?
99-
// idk just working around the issue for now but we'll want to have linked headers so will need to fix
100-
headingLinks: false,
101+
headingLinks: true,
101102
},
102103
social: [
103104
{ icon: 'github', label: 'GitHub', href: 'https://github.com/nrwl/nx' },

astro-docs/e2e/devkit-urls-fragments.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ test('links in descriptions of properties should correctly link to the same page
1111

1212
await page
1313
.getByTestId('main-pane')
14-
.getByRole('link', { name: 'nxCloudAccessToken' })
14+
.getByRole('link', { name: 'nxCloudAccessToken', exact: true })
1515
.click();
1616

1717
await expect(

astro-docs/markdoc.config.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@ import {
44
Markdoc,
55
} from '@astrojs/markdoc/config';
66
import starlightMarkdoc from '@astrojs/starlight-markdoc';
7+
import { transformOptionsTable } from './src/utils/markdoc-table-option-links';
78

89
export default defineMarkdocConfig({
910
extends: [starlightMarkdoc()],
11+
nodes: {
12+
table: {
13+
transform: transformOptionsTable,
14+
},
15+
},
1016
tags: {
1117
call_to_action: {
1218
render: component('./src/components/markdoc/CallToAction.astro'),

astro-docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@nx/nx-dev-ui-icons": "workspace:*",
1717
"@nx/nx-dev-ui-markdoc": "workspace:*",
1818
"@tailwindcss/vite": "^4.1.11",
19+
"@types/hast": "^3.0.4",
1920
"astro": "^5.10.1",
2021
"astro-og-canvas": "^0.7.0",
2122
"canvaskit-wasm": "^0.40.0",
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/** SVG path data for the chain-link icon used in anchor links (matches Starlight heading links). */
2+
export const LINK_ICON_PATH =
3+
'm12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z';
4+
5+
export const TABLE_HEADERS_TO_MATCH = [
6+
'option',
7+
'options',
8+
'properties',
9+
'property',
10+
];
11+
/**
12+
* Converts an option name to an anchor slug.
13+
*
14+
* Strips leading `--` or `-` prefixes, takes only the first (canonical) name
15+
* if aliases are present (comma-separated), lowercases, and replaces
16+
* non-alphanumeric characters with `-`.
17+
*
18+
* Examples:
19+
* `--distribute-on` → `distribute-on`
20+
* `nxCloudUrl` → `nxcloudurl`
21+
* `--output, -o` → `output`
22+
*/
23+
export function optionSlug(raw: string): string {
24+
// Take only the first name if comma-separated aliases exist
25+
let name = raw.split(',')[0].trim();
26+
// Strip leading dashes
27+
name = name.replace(/^-{1,2}/, '');
28+
// Lowercase and replace non-alphanumeric with hyphens
29+
return name
30+
.toLowerCase()
31+
.replace(/[^a-z0-9]+/g, '-')
32+
.replace(/^-|-$/g, '');
33+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Rehype plugin that adds anchor links to option names in documentation tables.
3+
*
4+
* Targets tables whose first header cell contains "Option". For each data row,
5+
* it extracts the option name from the first cell's `<code>` element, generates
6+
* an anchor slug, sets an `id` on the `<tr>`, and wraps the `<code>` in an
7+
* `<a>` link with the Starlight-style anchor icon.
8+
*/
9+
10+
import type { Element, ElementContent, Root, Text } from 'hast';
11+
import {
12+
LINK_ICON_PATH,
13+
optionSlug,
14+
TABLE_HEADERS_TO_MATCH,
15+
} from './option-slug';
16+
17+
function getTextContent(node: ElementContent | Root): string {
18+
if (node.type === 'text') return (node as Text).value;
19+
if ('children' in node) {
20+
return (node.children as ElementContent[]).map(getTextContent).join('');
21+
}
22+
return '';
23+
}
24+
25+
function isElement(node: ElementContent, tag?: string): node is Element {
26+
return node.type === 'element' && (!tag || node.tagName === tag);
27+
}
28+
29+
function findElement(
30+
children: ElementContent[],
31+
tag: string
32+
): Element | undefined {
33+
for (const child of children) {
34+
if (isElement(child)) {
35+
if (child.tagName === tag) return child;
36+
const found = findElement(child.children, tag);
37+
if (found) return found;
38+
}
39+
}
40+
return undefined;
41+
}
42+
43+
function findAllElements(children: ElementContent[], tag: string): Element[] {
44+
const results: Element[] = [];
45+
for (const child of children) {
46+
if (isElement(child, tag)) results.push(child);
47+
if (isElement(child)) {
48+
results.push(...findAllElements(child.children, tag));
49+
}
50+
}
51+
return results;
52+
}
53+
54+
function makeAnchorIcon(): Element {
55+
return {
56+
type: 'element',
57+
tagName: 'span',
58+
properties: { ariaHidden: 'true', className: ['sl-anchor-icon'] },
59+
children: [
60+
{
61+
type: 'element',
62+
tagName: 'svg',
63+
properties: {
64+
width: '16',
65+
height: '16',
66+
viewBox: '0 0 24 24',
67+
fill: 'currentcolor',
68+
},
69+
children: [
70+
{
71+
type: 'element',
72+
tagName: 'path',
73+
properties: { d: LINK_ICON_PATH },
74+
children: [],
75+
},
76+
],
77+
},
78+
],
79+
};
80+
}
81+
82+
function isOptionsTable(table: Element): boolean {
83+
const thead = findElement(table.children as ElementContent[], 'thead');
84+
if (!thead) return false;
85+
const firstTh = findElement(thead.children as ElementContent[], 'th');
86+
if (!firstTh) return false;
87+
const text = getTextContent(firstTh).trim();
88+
return TABLE_HEADERS_TO_MATCH.includes(text.toLowerCase());
89+
}
90+
91+
function processOptionsTable(table: Element): void {
92+
const tbody = findElement(table.children as ElementContent[], 'tbody');
93+
if (!tbody) return;
94+
95+
const rows = findAllElements(tbody.children as ElementContent[], 'tr');
96+
97+
for (const row of rows) {
98+
const cells = row.children.filter((c) => isElement(c, 'td')) as Element[];
99+
if (cells.length === 0) continue;
100+
101+
const firstCell = cells[0];
102+
const codeEl = findElement(firstCell.children as ElementContent[], 'code');
103+
if (!codeEl) continue;
104+
105+
const optionText = getTextContent(codeEl).trim();
106+
if (!optionText) continue;
107+
108+
const slug = optionSlug(optionText);
109+
if (!slug) continue;
110+
111+
// Set id on the row for anchor targeting
112+
row.properties = row.properties || {};
113+
row.properties.id = slug;
114+
115+
// Find the index of the code element in the cell's children
116+
const codeIndex = firstCell.children.indexOf(codeEl);
117+
if (codeIndex === -1) continue;
118+
119+
// Wrap the <code> element in an <a> link
120+
const anchorLink: Element = {
121+
type: 'element',
122+
tagName: 'a',
123+
properties: {
124+
href: `#${slug}`,
125+
className: ['sl-option-link'],
126+
},
127+
children: [codeEl, makeAnchorIcon()],
128+
};
129+
130+
firstCell.children[codeIndex] = anchorLink;
131+
}
132+
}
133+
134+
export default function rehypeTableOptionLinks() {
135+
return (tree: Root) => {
136+
const tables = findAllElements(tree.children as ElementContent[], 'table');
137+
for (const table of tables) {
138+
if (isOptionsTable(table)) {
139+
processOptionsTable(table);
140+
}
141+
}
142+
};
143+
}

astro-docs/src/styles/global.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,24 @@ table code {
431431
font-size: var(--text-sm);
432432
align-items: end; /* Fix tab alignment */
433433
}
434+
435+
/* Table option anchor links */
436+
.sl-markdown-content td .sl-option-link {
437+
color: inherit;
438+
text-decoration: none;
439+
}
440+
.sl-markdown-content td .sl-option-link .sl-anchor-icon {
441+
opacity: 0;
442+
margin-inline-start: 0.25em;
443+
display: inline-flex;
444+
vertical-align: middle;
445+
}
446+
.sl-markdown-content td .sl-option-link .sl-anchor-icon > svg {
447+
width: 0.75em;
448+
height: 0.75em;
449+
}
450+
@media (hover: hover) {
451+
.sl-markdown-content tr:hover .sl-option-link .sl-anchor-icon {
452+
opacity: 1;
453+
}
454+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import Markdoc from '@markdoc/markdoc';
2+
import {
3+
LINK_ICON_PATH,
4+
optionSlug,
5+
TABLE_HEADERS_TO_MATCH,
6+
} from '../plugins/utils/option-slug';
7+
8+
type MarkdocTag = InstanceType<typeof Markdoc.Tag>;
9+
10+
/**
11+
* Check if a value is a Markdoc Tag via `$$mdtype`
12+
* instead of `instanceof` to avoid the issue where the
13+
* `@markdoc/markdoc` instance loaded by this file differs from the one
14+
* loaded by `@astrojs/markdoc` at runtime.
15+
* otherwise the markdoc transform always noops for zero matches
16+
*/
17+
function isTag(value: unknown): value is MarkdocTag {
18+
return (
19+
typeof value === 'object' &&
20+
value !== null &&
21+
(value as Record<string, unknown>).$$mdtype === 'Tag'
22+
);
23+
}
24+
25+
function getTagText(tag: unknown): string {
26+
if (typeof tag === 'string') return tag;
27+
if (isTag(tag)) {
28+
return tag.children.map(getTagText).join('');
29+
}
30+
return '';
31+
}
32+
33+
function findChildTag(
34+
parent: MarkdocTag,
35+
name: string
36+
): MarkdocTag | undefined {
37+
return parent.children.find(
38+
(c): c is MarkdocTag => isTag(c) && c.name === name
39+
);
40+
}
41+
42+
function makeAnchorIcon(): MarkdocTag {
43+
return new Markdoc.Tag(
44+
'span',
45+
{ 'aria-hidden': 'true', class: 'sl-anchor-icon' },
46+
[
47+
new Markdoc.Tag(
48+
'svg',
49+
{
50+
width: '16',
51+
height: '16',
52+
viewBox: '0 0 24 24',
53+
fill: 'currentcolor',
54+
},
55+
[new Markdoc.Tag('path', { d: LINK_ICON_PATH }, [])]
56+
),
57+
]
58+
);
59+
}
60+
61+
/**
62+
* Transform a Markdoc table node, adding anchor links to option rows.
63+
* Non-option/property list tables pass through unmodified.
64+
*/
65+
export function transformOptionsTable(
66+
node: Parameters<
67+
NonNullable<import('@markdoc/markdoc').Schema['transform']>
68+
>[0],
69+
config: Parameters<
70+
NonNullable<import('@markdoc/markdoc').Schema['transform']>
71+
>[1]
72+
): MarkdocTag {
73+
const children = node.transformChildren(config);
74+
const table = new Markdoc.Tag(
75+
'table',
76+
node.transformAttributes(config),
77+
children
78+
);
79+
80+
const thead = findChildTag(table, 'thead');
81+
const tbody = findChildTag(table, 'tbody');
82+
if (!thead || !tbody) return table;
83+
84+
const headerRow = findChildTag(thead, 'tr');
85+
const firstTh = headerRow ? findChildTag(headerRow, 'th') : undefined;
86+
const headerText = firstTh ? getTagText(firstTh).trim() : '';
87+
88+
// make sure the links only show up for tables that 'options'
89+
if (!TABLE_HEADERS_TO_MATCH.includes(headerText.toLowerCase())) return table;
90+
91+
for (const row of tbody.children) {
92+
if (!isTag(row) || row.name !== 'tr') continue;
93+
94+
const firstTd = findChildTag(row, 'td');
95+
if (!firstTd) continue;
96+
97+
const codeTag = findChildTag(firstTd, 'code');
98+
if (!codeTag) continue;
99+
100+
const slug = optionSlug(getTagText(codeTag).trim());
101+
if (!slug) continue;
102+
103+
row.attributes.id = slug;
104+
105+
const codeIndex = firstTd.children.indexOf(codeTag);
106+
firstTd.children[codeIndex] = new Markdoc.Tag(
107+
'a',
108+
{ href: `#${slug}`, class: 'sl-option-link' },
109+
[codeTag, makeAnchorIcon()]
110+
);
111+
}
112+
113+
return table;
114+
}

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,10 @@
415415
"onlyBuiltDependencies": [
416416
"@nestjs/core",
417417
"nx"
418-
]
418+
],
419+
"patchedDependencies": {
420+
"@astrojs/starlight": "patches/@astrojs__starlight.patch"
421+
}
419422
},
420423
"nx": {
421424
"includedScripts": [

0 commit comments

Comments
 (0)