Repository navigation
fix(inline-prop): apply the block attributes #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1455373
1773447
acc2b54
044df2a
13ff16f
594ab17
ec0e97e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import type MarkdownIt from 'markdown-it' | ||
| import type Renderer from 'markdown-it/lib/renderer.mjs' | ||
| import type Token from 'markdown-it/lib/token.mjs' | ||
|
|
||
| import TokenClass from 'markdown-it/lib/token.mjs' | ||
| import { searchProps } from '../parse/props' | ||
|
|
@@ -64,68 +65,148 @@ export const MarkdownItInlineProps: MarkdownIt.PluginWithOptions<MdcInlinePropsO | |
| return | ||
|
|
||
| // list item handling | ||
| if (token.hidden && next.type === 'inline') | ||
| if (token.hidden && next?.type === 'inline') | ||
| token = next | ||
|
|
||
| if ( | ||
| token.type === 'inline' | ||
| && token.children?.length === 2 | ||
| && token.children[0].type === 'text' | ||
| && token.children[1].type === 'mdc_inline_props' | ||
| ) { | ||
| const props = token.children[1].attrs | ||
| token.children.splice(1, 1) | ||
| props?.forEach(([key, value]) => { | ||
| if (key === 'class') | ||
| prev.attrJoin('class', value) | ||
| else | ||
| prev.attrSet(key, value) | ||
| }) | ||
| } | ||
| if (token.type === 'inline') | ||
| applyTrailingPropsToParent(token, prev) | ||
| }) | ||
|
|
||
| // When using `::ul` syntax, to wrap with a list, | ||
| // we deduplicate the `ul` tag and hide the original one | ||
| // when it's exactly the only child of the `mdc_block_open` token | ||
| tokens.forEach((tokenOpen, index) => { | ||
| if (tokenOpen.type !== 'bullet_list_open') | ||
| return | ||
|
|
||
| const prev = tokens[index - 1] | ||
| if (!prev || prev.type !== 'mdc_block_open' || prev.tag !== 'ul') | ||
| return | ||
|
|
||
| // find the matching close token | ||
| let closeIndex = index + 1 | ||
| while (closeIndex < tokens.length) { | ||
| const close = tokens[closeIndex] | ||
| if (close.type === 'bullet_list_close' && close.level === tokenOpen.level) | ||
| break | ||
| closeIndex += 1 | ||
| } | ||
| const tokenClose = tokens[closeIndex] | ||
| if (tokenClose.type !== 'bullet_list_close') | ||
| return | ||
|
|
||
| // when prev and next are both `mdc_block` and `ul`, | ||
| // we hide the original `ul` token | ||
| const next = tokens[closeIndex + 1] | ||
| if (next.type === 'mdc_block_close' && next.tag === 'ul') { | ||
| tokenOpen.hidden = true | ||
| tokenClose.hidden = true | ||
| } | ||
| }) | ||
| foldWrapperBlocks(tokens) | ||
|
|
||
| return tokens | ||
| } | ||
|
|
||
| // `::pre` wrappers are folded into the fence / code block itself, so the | ||
| // wrapper attributes have to be injected into the rendered `<pre>` tag. | ||
| for (const type of ['fence', 'code_block'] as const) { | ||
| const original = md.renderer.rules[type] | ||
| md.renderer.rules[type] = function (tokens, idx, options, env, self) { | ||
| const html = original | ||
| ? original.call(this, tokens, idx, options, env, self) | ||
| : self.renderToken(tokens, idx, options) | ||
| const attrs = tokens[idx].meta?.mdcWrapperAttrs as [string, string][] | undefined | ||
| if (!attrs?.length) | ||
| return html | ||
| return html.replace(/<pre\b/, `<pre${self.renderAttrs({ attrs } as Token)}`) | ||
| } | ||
| } | ||
|
|
||
| // Replace the inline renderer to apply the props to the previous token | ||
| md.renderer.renderInline = wrapRenderInline(md.renderer.renderInline) | ||
| // Support https://github.com/serkodev/markdown-exit/blob/fe1351070a5841426223ab4a0a5c7874ba2b1257/packages/markdown-exit/src/renderer.ts#L389 | ||
| if ('renderInlineAsync' in md.renderer) | ||
| md.renderer.renderInlineAsync = wrapRenderInline(md.renderer.renderInlineAsync as any) | ||
| } | ||
|
|
||
| function applyAttrs(token: Token, attrs: [string, string][] | null) { | ||
| attrs?.forEach(([key, value]) => { | ||
| if (key === 'class') | ||
| token.attrJoin('class', value) | ||
| else | ||
| token.attrSet(key, value) | ||
| }) | ||
| } | ||
|
|
||
| const WRAPPER_TAGS = new Set(['ul', 'ol', 'table', 'blockquote', 'pre']) | ||
| const NATIVE_WRAPPED_TYPES = new Set(['bullet_list_open', 'ordered_list_open', 'table_open', 'blockquote_open']) | ||
|
|
||
| /** | ||
| * When `::ul`, `::ol`, `::table`, `::blockquote` or `::pre` wraps exactly one | ||
| * child with the same tag, markdown-it renders the tag twice. Fold them into a | ||
| * single element carrying the attributes of both (the wrapper's win). | ||
| */ | ||
| function foldWrapperBlocks(tokens: Token[]) { | ||
| tokens.forEach((wrapperOpen, index) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Fold nested wrappers from the inside out. If an outer 🤖 Prompt for AI Agents |
||
| if (wrapperOpen.type !== 'mdc_block_open' || !WRAPPER_TAGS.has(wrapperOpen.tag)) | ||
| return | ||
|
|
||
| const inner = tokens[index + 1] | ||
| if (!inner) | ||
| return | ||
|
|
||
| // `::pre` wrapping a fence / indented code block (single self-contained token) | ||
| if (wrapperOpen.tag === 'pre') { | ||
| if (inner.type !== 'fence' && inner.type !== 'code_block') | ||
| return | ||
| const wrapperClose = tokens[index + 2] | ||
| if (wrapperClose?.type !== 'mdc_block_close' || wrapperClose.tag !== 'pre') | ||
| return | ||
|
|
||
| inner.meta = { ...inner.meta, mdcWrapperAttrs: wrapperOpen.attrs ?? [] } | ||
| wrapperOpen.hidden = true | ||
| wrapperClose.hidden = true | ||
| return | ||
| } | ||
|
|
||
| // `::ul`, `::ol`, `::table`, `::blockquote` wrapping the matching native element. | ||
| // Only native markdown tokens are folded: a wrapper around another wrapper | ||
| // (e.g. `::ol` > `:::ol`) is left alone so wrapper logic runs once per element. | ||
| if (!NATIVE_WRAPPED_TYPES.has(inner.type) || inner.tag !== wrapperOpen.tag) | ||
| return | ||
|
|
||
| const closeType = inner.type.replace(/_open$/, '_close') | ||
| let closeIndex = index + 2 | ||
| while (closeIndex < tokens.length) { | ||
| const close = tokens[closeIndex] | ||
| if (close.type === closeType && close.level === inner.level) | ||
| break | ||
| closeIndex += 1 | ||
| } | ||
| const innerClose = tokens[closeIndex] | ||
| if (innerClose?.type !== closeType) | ||
| return | ||
|
|
||
| // The inner element must be the wrapper's sole child | ||
| const wrapperClose = tokens[closeIndex + 1] | ||
| if (wrapperClose?.type !== 'mdc_block_close' || wrapperClose.tag !== wrapperOpen.tag) | ||
| return | ||
|
|
||
| // Carry over attributes from the hidden element (e.g. `start` on `ol`) | ||
| mergeAttrs(wrapperOpen, inner.attrs) | ||
| inner.hidden = true | ||
| innerClose.hidden = true | ||
| }) | ||
| } | ||
|
|
||
| function mergeAttrs(target: Token, attrs: [string, string][] | null) { | ||
| attrs?.forEach(([key, value]) => { | ||
| if (key === 'class') | ||
| target.attrJoin('class', value) | ||
| else if (target.attrIndex(key) < 0) | ||
| target.attrSet(key, value) | ||
| }) | ||
| } | ||
|
|
||
| function applyTrailingPropsToParent(inline: Token, parentOpen: Token) { | ||
| const children = inline.children | ||
| if (!children || children.length < 2) | ||
| return false | ||
|
|
||
| const propsIndex = children.length - 1 | ||
| const props = children[propsIndex] | ||
| const previous = children[propsIndex - 1] | ||
|
|
||
| if ( | ||
| props.type !== 'mdc_inline_props' | ||
| || previous.type !== 'text' | ||
| ) { | ||
| return false | ||
| } | ||
|
|
||
| applyAttrs(parentOpen, props.attrs) | ||
|
|
||
| if (previous.content.trim()) { | ||
| previous.content = previous.content.replace(/[ \t]+$/, '') | ||
| children.splice(propsIndex, 1) | ||
| } | ||
| else { | ||
| children.splice(propsIndex - 1, 2) | ||
| } | ||
|
|
||
| return true | ||
| } | ||
|
|
||
| function wrapRenderInline(renderInline: Renderer['renderInline']): Renderer['renderInline'] { | ||
| return function (this: Renderer, tokens, options, env) { | ||
| tokens = [...tokens] | ||
|
|
@@ -170,12 +251,7 @@ function wrapRenderInline(renderInline: Renderer['renderInline']): Renderer['ren | |
|
|
||
| // console.log('apply', token.attrs, 'to', prev) | ||
|
|
||
| token.attrs?.forEach(([key, value]) => { | ||
| if (key === 'class') | ||
| prev.attrJoin('class', value) | ||
| else | ||
| prev.attrSet(key, value) | ||
| }) | ||
| applyAttrs(prev, token.attrs) | ||
| } | ||
| }) | ||
| return renderInline.call(this, tokens, options, env) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| ::table{attr="value"} | ||
| | col1 | col2 | | ||
| | ---- | ---- | | ||
| | a | b | | ||
| :: | ||
|
|
||
| ::blockquote{attr="value"} | ||
| > Paragraph 1 | ||
| > | ||
| > Paragraph 2 | ||
| :: | ||
|
|
||
| ::pre{attr="value"} | ||
| ```ts | ||
| const variable = "value" | ||
| ``` | ||
| :: | ||
|
|
||
| ::pre{.indented} | ||
| indented code | ||
| :: | ||
|
|
||
| ::ol{.cool-list start="7"} | ||
| 3. Explicit start on the wrapper wins | ||
| :: | ||
|
|
||
| <!-- not folded: more than one child --> | ||
|
|
||
| ::pre{.multi} | ||
| ```ts | ||
| a | ||
| ``` | ||
| ```js | ||
| b | ||
| ``` | ||
| :: | ||
|
|
||
| ::ol{.outer} | ||
| :::ol{.inner} | ||
| 4. Only the innermost wrapper folds | ||
| ::: | ||
| :: | ||
|
|
||
| ::blockquote{.mixed} | ||
| > Quote | ||
|
|
||
| Paragraph after | ||
| :: |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| <table attr="value"> | ||
| <thead> | ||
| <tr> | ||
| <th>col1</th> | ||
| <th>col2</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| <tr> | ||
| <td>a</td> | ||
| <td>b</td> | ||
| </tr> | ||
| </tbody> | ||
| </table> | ||
| <blockquote attr="value"> | ||
| <p>Paragraph 1</p> | ||
| <p>Paragraph 2</p> | ||
| </blockquote> | ||
| <pre attr="value"><code class="language-ts">const variable = "value" | ||
| </code></pre> | ||
| <pre class="indented"><code>indented code | ||
| </code></pre> | ||
|
|
||
| <ol class="cool-list" start="7"> | ||
| <li>Explicit start on the wrapper wins</li> | ||
| </ol> | ||
| <!-- not folded: more than one child --> | ||
| <pre class="multi"> | ||
| <pre><code class="language-ts">a | ||
| </code></pre> | ||
| <pre><code class="language-js">b | ||
| </code></pre> | ||
| </pre> | ||
| <ol class="outer"> | ||
| <ol class="inner" start="4"> | ||
| <li>Only the innermost wrapper folds</li> | ||
| </ol> | ||
| </ol> | ||
| <blockquote class="mixed"> | ||
| <blockquote> | ||
| <p>Quote</p> | ||
| </blockquote> | ||
| <p>Paragraph after</p> | ||
| </blockquote> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Merge attributes already present on the rendered
<pre>.If a fence highlighter returns
<pre class="hljs">, this replacement produces twoclassattributes when the wrapper has a class. The wrapper class and highlighter class will not both apply reliably. Merge the classes into one attribute instead of inserting a second attribute. Markdown-it explicitly permits a highlighter to return its own<pre>element. (raw.githubusercontent.com)🤖 Prompt for AI Agents