Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/markdown-mdx-node-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@platejs/markdown": patch
---

- Apply `disallowedNodes` to HTML and MDX elements by the key of their deserialize rule, so with `remark-mdx`, `disallowedNodes: ['a']` also removes `<a href>`.
9 changes: 7 additions & 2 deletions content/docs/(plugins)/(serializing)/markdown.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,16 @@ The core plugin configuration object. Use `MarkdownPlugin.configure({ options: {
<APIItem name="allowedNodes" type="PlateType | null">
Whitelist specific node types (Plate types and Markdown AST types like
`strong`). Cannot be used with `disallowedNodes`. If set, only listed
types are processed. Default: `null` (all allowed).
types are processed. With `remark-mdx`, `mdxJsxTextElement` admits
inline HTML and MDX elements and `mdxJsxFlowElement` admits block ones.
An admitted element with a deserialize rule converts without its rule key
listed. Default: `null` (all allowed).
</APIItem>
<APIItem name="disallowedNodes" type="PlateType | null">
Blacklist specific node types. Cannot be used with `allowedNodes`. Listed
types are filtered out. Default: `null`.
types are filtered out. With `remark-mdx`, this includes HTML and MDX
elements by the key of their deserialize rule, such as `a` for
`<a href>`, `underline` for `<u>` or `br` for `<br>`. Default: `null`.
</APIItem>
<APIItem name="allowNode" type="AllowNodeConfig">
Fine-grained node filtering with custom functions, applied *after*
Expand Down
42 changes: 42 additions & 0 deletions docs/plans/5140-mdx-element-node-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Issue 5140: node filters for inline HTML elements

Status: in review
PR: https://github.com/udecode/plate/pull/5141

## Outcome

[Issue 5140](https://github.com/udecode/plate/issues/5140): with `remark-mdx`, `deserializeMd` checked `allowedNodes` and `disallowedNodes` against an inline HTML element's mdast type, `mdxJsxTextElement`, and never against the Plate type it becomes. So `disallowedNodes: ['a']` dropped Markdown links but kept `<a href>` anchors, and since PR 5139 those anchors carry a working URL.

After the fix, `disallowedNodes` also checks an HTML or MDX element by the key of its deserialize rule, at the top level and inside marks: `a` for `<a href>`, `underline` for `<u>`, `br` for `<br>`. `disallowedNodes: ['a']` removes `<a href>`. Allowlists behave exactly as on `main`, so `<br>` and `<span>` styles keep their text wherever `main` kept it. Elements without a rule still fall back to source text.

## Main changes

- `customMdxDeserialize` resolves the element's rule key once: the plugin key the tag resolves to, or the tag name when no plugin owns it. When a rule exists and `disallowedNodes` lists that key, it returns nothing before the rule runs.
- `allowNode.deserialize` is unchanged. It still receives the MDX node, with `name` available for element checks.
- The `disallowedNodes` JSDoc in `MarkdownPlugin.ts` and the Markdown docs page state the new behavior with `remark-mdx`. The `allowedNodes` JSDoc and docs now say what `main` already did: `mdxJsxTextElement` admits inline HTML and MDX elements, `mdxJsxFlowElement` admits block ones, and an admitted element with a rule converts without its rule key listed. A patch changeset describes the change from `main`.

## Defaults

| Decision | Pick | Alternative | Word |
| --- | --- | --- | --- |
| Allowlist rule | Unchanged from `main`; the key check applies to `disallowedNodes` only | Check allowlists by key too, which drops `<br>` and `<span>` text from allowlists that admit `mdxJsxTextElement` | "check allowlists" |
| Cell-list fallback after filtering | A table-cell list whose blocked block child the filter removed converts, keeping only allowed content | Keep the text fallback whenever the unfiltered item held a block | "keep fallback" |
| `allowNode` | Unchanged; it still sees the MDX node | Call it with the resolved type | "allowNode resolved" |
| Chinese docs | Left to languine, as with PR 5139 | Translate the two sentences by hand | "translate cn" |
| `next` | Not ported here; `next` has its own markdown package and the main-to-next sync owns it | Port the fix now | "port" |

## Proof

- Red then green: `a<br/>b <u>c</u> <a href="https://platejs.org">d</a>` under `disallowedNodes: ['underline']` keeps `<u>` without the check line and removes only `<u>` with it, keeping the line break and the link (`deserializer/deserializeMd.spec.ts`). The issue's own case, `<a href>` under `disallowedNodes: ['a']`, failed on `main` at `ef90c27119` and passed with the first version of the test.
- Red then green for allowlists: `a<br/>b <u>c</u>` under `allowedNodes: ['p', 'text', 'mdxJsxTextElement']` lost `<br>` and `<u>` with the first version of the fix, which also checked allowlists, and keeps both now (`deserializer/deserializeMd.spec.ts`).
- `pnpm --filter @platejs/markdown test` in the worktree: 274 pass, 0 fail. `pnpm turbo typecheck --filter=./packages/markdown`: 13/13.
- Probes (deleted after): inside bold, `<a href>` is removed under `disallowedNodes: ['a']`, while `**[l](https://p.org)**` keeps its link, which is the pre-existing bypass listed under Follow-ups. `<foo>b</foo>` falls back to source text.

## Follow-ups

- Node filters skip Markdown children of `**`, `*`, `~~` and list items (`x **[l](https://p.org)**` keeps its link under `disallowedNodes: ['a']`); filed as a follow-up issue.
- `<code>` resolves to the rule key `code_block` through the Markdown type table, so `disallowedNodes: ['code_block']` removes inline `<code>` and `['code']` does not. Pre-existing mapping; owner: zbeyens, untracked.
- An HTML element nested in an unknown tag, or in a tail re-parsed without `remark-mdx` after an MDX error, stays literal source text and is not removed. No live element is created on either path. Owner: zbeyens, untracked.
- An element left with no children after filtering, such as a heading holding only a removed link, is not padded. `main` has the same gap for Markdown children. Owner: zbeyens, untracked.
- `allowNode.deserialize` still sees `mdxJsxTextElement` for HTML elements. Owner: zbeyens, untracked.
- The unknown-element fallback builds a block `p` that no allowlist checks, and drops the mark of its context, so `a **<foo>bar</foo>**` returns unbolded tag text. Pre-existing; owner: zbeyens, untracked.
8 changes: 6 additions & 2 deletions packages/markdown/src/lib/MarkdownPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,16 @@ export type MarkdownConfig = PluginConfig<
{
/**
* Configuration for allowed node types. Cannot be combined with
* disallowedNodes.
* disallowedNodes. With remark-mdx, `mdxJsxTextElement` admits inline HTML
* and MDX elements and `mdxJsxFlowElement` admits block ones. An admitted
* element with a deserialize rule converts without its rule key listed.
*/
allowedNodes: PlateType[] | null;
/**
* Configuration for disallowed node types. Cannot be combined with
* allowedNodes.
* allowedNodes. With remark-mdx, this also removes HTML and MDX elements by
* the key of their deserialize rule, such as `a` for `<a href>`,
* `underline` for `<u>` or `br` for `<br>`.
*
* @default null
*/
Expand Down
47 changes: 47 additions & 0 deletions packages/markdown/src/lib/deserializer/deserializeMd.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,53 @@ describe('deserializeMd', () => {
]);
});

it('removes inline html elements whose deserialize rule key is disallowed', () => {
const editor = createTestEditor();

expect(
deserializeMd(
editor,
'a<br/>b <u>c</u> <a href="https://platejs.org">d</a>',
{ disallowedNodes: ['underline'] }
)
).toEqual([
{
children: [
{ text: 'a' },
{ text: '\n' },
{ text: 'b ' },
{ text: ' ' },
{
children: [{ text: 'd' }],
type: 'a',
url: 'https://platejs.org',
},
],
type: 'p',
},
]);
});

it('keeps inline html under an allowlist that admits mdxJsxTextElement', () => {
const editor = createTestEditor();

expect(
deserializeMd(editor, 'a<br/>b <u>c</u>', {
allowedNodes: ['p', 'text', 'mdxJsxTextElement'],
})
).toEqual([
{
children: [
{ text: 'a' },
{ text: '\n' },
{ text: 'b ' },
{ text: 'c', underline: true },
],
type: 'p',
},
]);
});

it('preserves raw html blocks as editable source text paragraphs', () => {
const editor = createTestEditor();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,17 @@ export const customMdxDeserialize = (
getPluginKey(options.editor!, customJsxElementKey as any) ?? mdastNode.name;

if (key) {
const nodeParserDeserialize = getDeserializerByKey(
mdastToPlate(options.editor!, key as any),
options
);
const ruleKey = mdastToPlate(options.editor!, key as any);
const nodeParserDeserialize = getDeserializerByKey(ruleKey, options);

if (nodeParserDeserialize) {
// disallowedNodes names rule keys, such as `a` or `underline`, which an
// mdx node's type does not reveal. Allowlists are not checked here, since
// rule keys like `br` and `span` have no plugin a user would list.
if (options.disallowedNodes?.includes(ruleKey)) return [];

if (nodeParserDeserialize)
return nodeParserDeserialize(mdastNode, deco, options) as any;
}
} else {
console.warn(
'This MDX node does not have a parser for deserialization',
Expand Down
Loading