diff --git a/__tests__/lib/mdxish/utils/mdxish-component-tag-parser.test.ts b/__tests__/lib/mdxish/utils/mdxish-component-tag-parser.test.ts index 76398997b..14ed046f6 100644 --- a/__tests__/lib/mdxish/utils/mdxish-component-tag-parser.test.ts +++ b/__tests__/lib/mdxish/utils/mdxish-component-tag-parser.test.ts @@ -1,8 +1,11 @@ import type { MdxJsxAttributeValueExpression } from 'mdast-util-mdx-jsx'; +import * as rmdx from '@readme/markdown-legacy'; + import { mdxish } from '../../../../lib'; import { parseAttributes, parseTag } from '../../../../lib/utils/mdxish/mdxish-component-tag-parser'; -import { parseMdxishWithSource } from '../../../helpers'; +import { extractText } from '../../../../processor/transform/extract-text'; +import { findElementByTagName, parseMdxishWithSource } from '../../../helpers'; describe('parseAttributes', () => { describe('boolean attributes', () => { @@ -452,4 +455,43 @@ describe('lowercase html tags with JSX expressions are treated as MDX', () => { }], }); }); + + describe.each([ + ['MDXish', mdxish], + ['legacy RMDX', rmdx.hast], + ])('RM-16375 unquoted native HTML attributes in %s', (_engine, parse) => { + it.each([ + ['opening tag', '', '', { type: 'root' }, 'a', { href: 'https://example.com' }, undefined], + ['paired tag', 'Example', '', { type: 'root' }, 'a', { href: 'https://example.com' }, 'Example'], + ['inline class', 'Example', '', { type: 'root' }, 'span', { className: ['unquoted-class'] }, 'Example'], + ['block class', '
Example
', '', { type: 'root' }, 'div', { className: ['unquoted-class'] }, 'Example'], + ['self-closing tag', '', '', { type: 'root' }, 'img', { src: 'https://example.com/image.png' }, undefined], + ['attribute whitespace', '
Example
', '', { type: 'root' }, 'div', { className: ['unquoted-class'] }, 'Example'], + ['CRLF', '\r\nExample\r\n', '', { type: 'root' }, 'div', { className: ['unquoted-class'] }, undefined], + ['* emphasis', '*Example*', 'em', { type: 'element', tagName: 'em' }, 'a', { href: 'https://example.com' }, 'Example'], + ['_ emphasis', '_Example_', 'em', { type: 'element', tagName: 'em' }, 'a', { href: 'https://example.com' }, 'Example'], + ['nesting', 'Before Example after', 'span', { type: 'element', tagName: 'span', properties: { className: ['outer'] } }, 'a', { href: 'https://example.com' }, 'Example'], + ['blank lines', '
\n\nExample\n\n
', 'div', { type: 'element', tagName: 'div', properties: { className: ['outer'] } }, 'a', { href: 'https://example.com' }, 'Example'], + ['tab formatting', 'Before Example after', '', { type: 'root' }, 'a', { href: 'https://example.com' }, 'Example'], + ])('should parse the %s variant', (_case, source, parentTag, expectedParent, tagName, properties, value) => { + const tree = parse(source); + const parent = findElementByTagName(tree, parentTag) ?? tree; + + expect(parent).toMatchObject(expectedParent); + + expect(findElementByTagName(parent, tagName)).toMatchObject({ + type: 'element', + tagName, + properties, + ...(value !== undefined ? { children: [{ type: 'text', value }] } : {}), + }); + }); + + it('should leave an escaped opening tag as text', () => { + const tree = parse('Before \\Example after'); + + expect(findElementByTagName(tree, 'span')).toBeNull(); + expect(extractText(tree)).toBe('Before Example after'); + }); + }); }); diff --git a/__tests__/lib/micromark/mdx-component.test.ts b/__tests__/lib/micromark/mdx-component.test.ts new file mode 100644 index 000000000..6c13becb0 --- /dev/null +++ b/__tests__/lib/micromark/mdx-component.test.ts @@ -0,0 +1,68 @@ +import type { Nodes, Root } from 'mdast'; +import type { Event } from 'micromark-util-types'; + +import { fromMarkdown } from 'mdast-util-from-markdown'; +import { parse, postprocess, preprocess } from 'micromark'; + +import { mdxComponentFromMarkdown } from '../../../lib/mdast-util/mdx-component'; +import { mdxComponent } from '../../../lib/micromark/mdx-component'; + +const LINK_OPEN = ''; +const LINK = `${LINK_OPEN}Example`; +const FLOW = '
\nExample\n
'; + +const claimedComponents = (source: string): string[] => + postprocess(parse({ extensions: [mdxComponent()] }).document().write(preprocess()(source, 'utf8', true))) + .filter(([event, token]: Event) => event === 'enter' && token.type === 'mdxComponent') + .map(([, token]: Event) => source.slice(token.start.offset, token.end.offset)); + +const parseMdast = (source: string): Root => + fromMarkdown(source, { extensions: [mdxComponent()], mdastExtensions: [mdxComponentFromMarkdown()] }); + +const mdastTypes = (node: Nodes): string[] => [ + node.type, + ...('children' in node ? node.children.flatMap(mdastTypes) : []), +]; + +describe('RM-16375 unquoted native HTML attributes', () => { + it.each([ + ['flow', FLOW, [FLOW], ['root', 'html']], + ['text', `Before ${LINK} after`, [LINK_OPEN], ['root', 'paragraph', 'text', 'html', 'text', 'html', 'text']], + ])('claims %s input and emits the expected MDAST shape', (_context, source, claims, types) => { + expect(claimedComponents(source)).toStrictEqual(claims); + expect(mdastTypes(parseMdast(source))).toStrictEqual(types); + }); + + it.each([ + '', + '
Example
', + '\r\nExample\r\n', + ])('claims valid whitespace, line-ending, and self-closing variants: %s', source => { + expect(claimedComponents(source)).toStrictEqual([source]); + }); + + it.each([ + '', + '', + ...['"', "'", '<', '=', '`'].map(delimiter => ``), + ])('does not claim malformed or forbidden unquoted values: %s', source => { + expect(claimedComponents(source)).toStrictEqual([]); + }); + + it.each([ + ['flow emphasis', '
*Example*
', ['
*Example*
'], ['root', 'html']], + ['text * emphasis', `*${LINK}*`, [LINK_OPEN], ['root', 'paragraph', 'emphasis', 'html', 'text', 'html']], + ['text _ emphasis', `_${LINK}_`, [LINK_OPEN], ['root', 'paragraph', 'emphasis', 'html', 'text', 'html']], + ['escaped flow opening', '\\
Example
', [], ['root', 'paragraph', 'text', 'html']], + ['escaped text opening', 'Before \\Example after', [], ['root', 'paragraph', 'text', 'html', 'text']], + ['flow nesting', `
${LINK}
`, [`
${LINK}
`], ['root', 'html']], + ['text nesting', `Before ${LINK} after`, ['', LINK_OPEN], ['root', 'paragraph', 'text', 'html', 'html', 'text', 'html', 'html', 'text']], + ['flow blank lines', `
\n\n${LINK}\n\n
`, [`
\n\n${LINK}\n\n
`], ['root', 'html']], + ['text blank lines', `Before\n\n${LINK}\n\nAfter`, [LINK_OPEN], ['root', 'paragraph', 'text', 'paragraph', 'html', 'text', 'html', 'paragraph', 'text']], + ['flow formatting', '\nExample\n', ['\nExample\n'], ['root', 'html']], + ['text formatting', 'Before Example
after', [''], ['root', 'paragraph', 'text', 'html', 'text', 'html', 'text']], + ])('handles %s with the expected claim and MDAST shape', (_case, source, claims, types) => { + expect(claimedComponents(source)).toStrictEqual(claims); + expect(mdastTypes(parseMdast(source))).toStrictEqual(types); + }); +}); diff --git a/lib/micromark/mdx-component/syntax.ts b/lib/micromark/mdx-component/syntax.ts index e735920d8..548e2550b 100644 --- a/lib/micromark/mdx-component/syntax.ts +++ b/lib/micromark/mdx-component/syntax.ts @@ -93,13 +93,13 @@ function createTokenize(mode: 'flow' | 'text') { let tagName = ''; let depth = 0; let closingTagName = ''; - // For lowercase tags we only want to claim the block if it uses JSX - // attribute expression syntax (`attr={...}`). Plain HTML should fall - // through to CommonMark html-flow. Flow mode claims any PascalCase block - // component; text mode claims only inline PascalCase components - // (INLINE_COMPONENT_TAGS — Anchor, Glossary), also brace-gated. + // Lowercase tags are claimed when they use JSX attribute expressions or + // unquoted HTML attribute values, preventing URL-like values from being + // split into text and autolink nodes elsewhere in the MDXish pipeline. let isLowercaseTag = false; let sawBraceAttr = false; + let sawUnquotedAttr = false; + let awaitingAttrValue = false; // A plain lowercase block tag claimed without a `{…}` attribute, gated by // `plainClaimLineStart`: after a blank line it may only continue on a tag line. @@ -316,6 +316,7 @@ function createTokenize(mode: 'flow' | 'text') { tagName = String.fromCharCode(code); isLowercaseTag = false; sawBraceAttr = false; + sawUnquotedAttr = false; effects.consume(code); return tagNameRest; } @@ -327,6 +328,7 @@ function createTokenize(mode: 'flow' | 'text') { tagName = String.fromCharCode(code); isLowercaseTag = true; sawBraceAttr = false; + sawUnquotedAttr = false; effects.consume(code); return tagNameRest; } @@ -368,9 +370,10 @@ function createTokenize(mode: 'flow' | 'text') { if (code === null) return nok(code); // Everything except a flow-mode PascalCase block component must carry a - // `{…}` brace attribute to be claimed; plain HTML falls through to - // CommonMark. - const requiresBraceAttr = isLowercaseTag || !isFlow; + // `{…}` expression or unquoted attribute to be claimed; quoted HTML falls + // through to CommonMark. + const requiresSpecialAttr = isLowercaseTag || !isFlow; + const hasClaimableAttr = sawBraceAttr || (sawUnquotedAttr && (!isFlow || htmlFlowTagNames.has(tagName))); if (markdownLineEnding(code)) { if (!isFlow) return nok(code); @@ -380,21 +383,30 @@ function createTokenize(mode: 'flow' | 'text') { // Self-closing /> if (code === codes.slash) { - if (requiresBraceAttr && !sawBraceAttr) return nok(code); + if (awaitingAttrValue) { + awaitingAttrValue = false; + sawUnquotedAttr = true; + effects.consume(code); + return inUnquotedAttr; + } + if (requiresSpecialAttr && !hasClaimableAttr) return nok(code); effects.consume(code); return selfCloseGt; } // End of opening tag if (code === codes.greaterThan) { - if (requiresBraceAttr && !sawBraceAttr && !claimBraceLessTag()) return nok(code); + if (awaitingAttrValue) return nok(code); + if (requiresSpecialAttr && !hasClaimableAttr && !claimBraceLessTag()) return nok(code); effects.consume(code); + if (!isFlow && isLowercaseTag && sawUnquotedAttr && !sawBraceAttr) return doneOpeningTag; onOpenerLine = isFlow; return pendingBlockWrapperClaim ? blockWrapperOpenerRest : body; } // Quoted attribute value if (code === codes.quotationMark || code === codes.apostrophe) { + awaitingAttrValue = false; quoteChar = code; effects.consume(code); return inQuotedAttr; @@ -402,16 +414,56 @@ function createTokenize(mode: 'flow' | 'text') { // JSX expression attribute if (code === codes.leftCurlyBrace) { + awaitingAttrValue = false; braceDepth = 1; sawBraceAttr = true; effects.consume(code); return inBraceExpr; } + if (code === codes.equalsTo) { + if (awaitingAttrValue) return nok(code); + awaitingAttrValue = true; + effects.consume(code); + return afterOpenTagName; + } + + if (awaitingAttrValue && !markdownSpace(code)) { + if (code === codes.lessThan || code === codes.graveAccent) return nok(code); + awaitingAttrValue = false; + sawUnquotedAttr = true; + effects.consume(code); + return inUnquotedAttr; + } + effects.consume(code); return afterOpenTagName; } + function inUnquotedAttr(code: Code): State | undefined { + if ( + code === null || + code === codes.quotationMark || + code === codes.apostrophe || + code === codes.lessThan || + code === codes.equalsTo || + code === codes.graveAccent + ) { + return nok(code); + } + if (code === codes.greaterThan || markdownLineEnding(code) || markdownSpace(code)) { + return afterOpenTagName(code); + } + effects.consume(code); + return inUnquotedAttr; + } + + function doneOpeningTag(code: Code): State | undefined { + effects.exit('mdxComponentData'); + effects.exit('mdxComponent'); + return ok(code); + } + function inQuotedAttr(code: Code): State | undefined { if (code === null) return nok(code);