diff --git a/Makefile b/Makefile index 77023c2f193..a195fe99380 100644 --- a/Makefile +++ b/Makefile @@ -1036,6 +1036,12 @@ clean-docs: @rm -rf docs/dist docs/node_modules docs/.astro @echo "✓ Documentation artifacts cleaned" +.PHONY: test-docs-remark +test-docs-remark: + @echo "Running docs remark plugin unit tests..." + @node docs/src/lib/remark/inlineMarkdownInHtml.test.js + @echo "✓ Docs remark plugin unit tests passed" + # Sync templates from .github to pkg/cli/templates # Sync action pins from .github/aw to pkg/actionpins/data and pkg/workflow/data .PHONY: sync-action-pins diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index ccce1b3e3d6..3ac9e4cd02a 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url'; import { unified } from '@astrojs/markdown-remark'; import remarkStripEmojis from './src/lib/remark/stripEmojis.js'; import remarkTableDataLabels from './src/lib/remark/tableDataLabels.js'; +import remarkInlineMarkdownInHtml from './src/lib/remark/inlineMarkdownInHtml.js'; import rehypeTableWrapper from './src/lib/rehype/tableWrapper.js'; import { WORKSHOP_SLUGS } from './src/lib/workshop/config.ts'; @@ -40,7 +41,7 @@ export default defineConfig({ trailingSlash: 'always', markdown: { processor: unified({ - remarkPlugins: [remarkStripEmojis, remarkTableDataLabels], + remarkPlugins: [remarkStripEmojis, remarkTableDataLabels, remarkInlineMarkdownInHtml], rehypePlugins: [rehypeTableWrapper], }), }, diff --git a/docs/src/lib/remark/inlineMarkdownInHtml.js b/docs/src/lib/remark/inlineMarkdownInHtml.js new file mode 100644 index 00000000000..9e678836851 --- /dev/null +++ b/docs/src/lib/remark/inlineMarkdownInHtml.js @@ -0,0 +1,112 @@ +// @ts-check + +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import rehypeStringify from 'rehype-stringify'; + +/** + * Remark plugin that applies markdown transformation to the content of + * specific HTML tags that are treated as opaque HTML blocks by remark-parse. + * + * CommonMark block-level HTML elements such as `
` and `` + * are treated as opaque HTML blocks by remark-parse, so any markdown syntax + * they contain is left as raw text rather than being processed. This plugin + * runs after remark-parse to fill that gap: it visits every `html` MDAST node + * and applies a full markdown transformation to the content of `` + * (and similar inline-content elements), producing proper HTML. + * + * This is the correct, AST-level fix for GFM alerts that contain + * `
/` with markdown links — replacing the previous + * approach of manipulating rendered HTML strings after compilation. + */ + +/** + * Reusable processor for converting markdown inline content to HTML. + * + * `allowDangerousHtml` is required to preserve inline HTML (e.g. ``, + * ``) that authors place inside target tags alongside their markdown. + * This is safe here because: + * - the processor runs **at build time**, never in a browser; + * - inputs are documentation source files committed by repository maintainers + * and are not derived from end-user input; + * - no output is injected into an HTTP response or an untrusted context. + */ +const inlineProcessor = unified() + .use(remarkParse) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeStringify, { allowDangerousHtml: true }); + +/** + * Apply markdown transformation to text, producing HTML. + * remark-parse wraps inline content in a `

` element; that wrapper is + * stripped so the result can be placed back inside the original HTML tag. + * + * @param {string} text + * @returns {string} + */ +function applyMarkdownTransformation(text) { + const result = String(inlineProcessor.processSync(text)); + // Strip the outer

wrapper added by remark for a single paragraph. + return result.replace(/^

([\s\S]*?)<\/p>\n?$/, '$1'); +} + +/** + * @returns {(tree: import('unist').Node) => void} + */ +export default function remarkInlineMarkdownInHtml() { + return function transform(tree) { + visit(tree); + }; +} + +/** + * @param {any} node + */ +function visit(node) { + if (!node || typeof node !== 'object') return; + + if (node.type === 'html' && typeof node.value === 'string') { + node.value = processMarkdownInHtml(node.value); + } + + const { children } = node; + if (Array.isArray(children)) { + for (const child of children) visit(child); + } +} + +/** + * Tags whose text content should have markdown transformed to HTML. + * These are inline-text contexts that appear as block-level HTML in markdown + * and therefore bypass normal remark inline processing. + * + * @type {string[]} + */ +const INLINE_TEXT_TAGS = ['summary', 'figcaption', 'caption', 'dt', 'dd', 'th', 'td', 'li']; + +/** + * Apply markdown transformation inside the content of specific HTML tags. + * + * - Only targets known inline-text tags listed in INLINE_TEXT_TAGS. + * - Text that already contains an `]*)?>)([\\s\\S]*?)(<\\/(?:${tagPattern})>)`, + 'gi', + ); + + return html.replace(tagRe, (_match, openTag, content, closeTag) => { + // Skip content that already contains an anchor tag to avoid double-processing. + if (/]/i.test(content)) return _match; + + const processed = applyMarkdownTransformation(content); + return openTag + processed + closeTag; + }); +} diff --git a/docs/src/lib/remark/inlineMarkdownInHtml.test.js b/docs/src/lib/remark/inlineMarkdownInHtml.test.js new file mode 100644 index 00000000000..c980e7e76bf --- /dev/null +++ b/docs/src/lib/remark/inlineMarkdownInHtml.test.js @@ -0,0 +1,162 @@ +#!/usr/bin/env node +// @ts-check + +/** + * Unit tests for remarkInlineMarkdownInHtml. + * + * Run with: node docs/src/lib/remark/inlineMarkdownInHtml.test.js + */ + +import remarkInlineMarkdownInHtml from './inlineMarkdownInHtml.js'; + +let passed = 0; +let failed = 0; + +function assertEqual(actual, expected, label) { + if (actual === expected) { + console.log(` ✓ ${label}`); + passed++; + } else { + console.error(` ✗ ${label}`); + console.error(` expected: ${JSON.stringify(expected)}`); + console.error(` actual: ${JSON.stringify(actual)}`); + failed++; + } +} + +function makeHtmlNode(value) { + return { type: 'html', value }; +} + +function makeTree(...htmlNodes) { + return { type: 'root', children: htmlNodes }; +} + +function runTransform(tree) { + const transform = remarkInlineMarkdownInHtml(); + transform(tree); + return tree; +} + +// ------------------------------------------------------------------- +// Test: markdown link inside

is converted to +// ------------------------------------------------------------------- +console.log('\nmarkdown link inside :'); +{ + const tree = makeTree( + makeHtmlNode('Open a Codespace terminal in [Step 6](06-install-gh-aw.md) to install'), + ); + runTransform(tree); + assertEqual( + tree.children[0].value, + 'Open a Codespace terminal in Step 6 to install', + 'single link converted', + ); +} + +// ------------------------------------------------------------------- +// Test: multiple links inside +// ------------------------------------------------------------------- +console.log('\nmultiple links inside :'); +{ + const tree = makeTree( + makeHtmlNode('See [Step 6](step6.md) and [Step 7](step7.md) for details'), + ); + runTransform(tree); + assertEqual( + tree.children[0].value, + 'See Step 6 and Step 7 for details', + 'both links converted', + ); +} + +// ------------------------------------------------------------------- +// Test: backtick code spans inside are converted to +// ------------------------------------------------------------------- +console.log('\nbacktick code in :'); +{ + const tree = makeTree( + makeHtmlNode('Install `gh-aw` via [Step 6](step6.md)'), + ); + runTransform(tree); + assertEqual( + tree.children[0].value, + 'Install gh-aw via Step 6', + 'backtick converted to , link converted', + ); +} + +// ------------------------------------------------------------------- +// Test: link-like syntax inside backtick code span is NOT converted +// ------------------------------------------------------------------- +console.log('\nlink syntax inside code span is not converted:'); +{ + const tree = makeTree( + makeHtmlNode('Use `[var](param)` to configure'), + ); + runTransform(tree); + assertEqual( + tree.children[0].value, + 'Use [var](param) to configure', + 'code span link syntax not converted, backtick rendered as ', + ); +} + +// ------------------------------------------------------------------- +// Test: that already contains tags is left untouched +// ------------------------------------------------------------------- +console.log('\n with existing tag not double-processed:'); +{ + const input = 'See Step 6 for details'; + const tree = makeTree(makeHtmlNode(input)); + runTransform(tree); + assertEqual(tree.children[0].value, input, 'existing anchor tag not rewritten'); +} + +// ------------------------------------------------------------------- +// Test: html nodes without recognised tags are left unchanged +// ------------------------------------------------------------------- +console.log('\nhtml nodes without target tags:'); +{ + const input = '
[link text](url) text
'; + const tree = makeTree(makeHtmlNode(input)); + runTransform(tree); + assertEqual(tree.children[0].value, input, 'non-target tag left unchanged'); +} + +// ------------------------------------------------------------------- +// Test: link inside GFM alert + (the full real-world case) +// ------------------------------------------------------------------- +console.log('\nfull GFM alert with
/:'); +{ + const tree = makeTree( + makeHtmlNode( + '
\nUsing the CCA? Open a terminal in [Step 6](06-install-gh-aw.md) to install `gh-aw`.\n\n**More info:** See [Step 1](01-prerequisites.md) for the checklist.\n\n
', + ), + ); + runTransform(tree); + const expected = + '
\nUsing the CCA? Open a terminal in Step 6 to install gh-aw.\n\n**More info:** See [Step 1](01-prerequisites.md) for the checklist.\n\n
'; + assertEqual(tree.children[0].value, expected, 'link in converted, body outside tag unchanged'); +} + +// ------------------------------------------------------------------- +// Test: nested transform (html nodes inside paragraph children) +// ------------------------------------------------------------------- +console.log('\nhtml node nested inside paragraph:'); +{ + const htmlNode = makeHtmlNode('See [Step 6](step6.md)'); + const tree = { type: 'root', children: [{ type: 'paragraph', children: [htmlNode] }] }; + runTransform(tree); + assertEqual( + htmlNode.value, + 'See Step 6', + 'nested html node processed', + ); +} + +// ------------------------------------------------------------------- +// Summary +// ------------------------------------------------------------------- +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1);