Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion __tests__/lib/mdxish/utils/mdxish-component-tag-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { MdxJsxAttributeValueExpression } from 'mdast-util-mdx-jsx';

import { mdxish } from '../../../../lib';
import { parseAttributes, parseTag } from '../../../../lib/utils/mdxish/mdxish-component-tag-parser';
import { parseMdxishWithSource } from '../../../helpers';
import { findElementByTagName, parseMdxishWithSource } from '../../../helpers';

describe('parseAttributes', () => {
describe('boolean attributes', () => {
Expand Down Expand Up @@ -452,4 +452,21 @@ describe('lowercase html tags with JSX expressions are treated as MDX', () => {
}],
});
});

it.each([
['<a href=https://example.com>', 'a', { href: 'https://example.com' }, undefined],
['<a href=https://example.com>Example</a>', 'a', { href: 'https://example.com' }, 'Example'],
['<span class=unquoted-class>Example</span>', 'span', { className: ['unquoted-class'] }, 'Example'],
['<div class=unquoted-class>Example</div>', 'div', { className: ['unquoted-class'] }, 'Example'],
])('should parse native HTML with unquoted attributes: %s', (source, tagName, properties, value) => {
const hast = mdxish(source);
const element = findElementByTagName(hast, tagName);

expect(element).toMatchObject({
type: 'element',
tagName,
properties,
...(value ? { children: [{ type: 'text', value }] } : {}),
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});
72 changes: 62 additions & 10 deletions lib/micromark/mdx-component/syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. CommonMark does not allow `/` in an
// unquoted value, so URLs otherwise split into text and an autolink.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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.
Expand Down Expand Up @@ -316,6 +316,7 @@ function createTokenize(mode: 'flow' | 'text') {
tagName = String.fromCharCode(code);
isLowercaseTag = false;
sawBraceAttr = false;
sawUnquotedAttr = false;
effects.consume(code);
return tagNameRest;
}
Expand All @@ -327,6 +328,7 @@ function createTokenize(mode: 'flow' | 'text') {
tagName = String.fromCharCode(code);
isLowercaseTag = true;
sawBraceAttr = false;
sawUnquotedAttr = false;
effects.consume(code);
return tagNameRest;
}
Expand Down Expand Up @@ -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);
Expand All @@ -380,38 +383,87 @@ 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;
}

// 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);

Expand Down