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', '', '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);