diff --git a/src/syntax/inline-props.ts b/src/syntax/inline-props.ts index 822d889..dd66638 100644 --- a/src/syntax/inline-props.ts +++ b/src/syntax/inline-props.ts @@ -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,61 +65,33 @@ export const MarkdownItInlineProps: MarkdownIt.PluginWithOptions { - 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 `
` 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(/ {
+    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) => {
+    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)
diff --git a/test/input/14.list-handling.md b/test/input/14.list-handling.md
index e7c18fe..69e9e29 100644
--- a/test/input/14.list-handling.md
+++ b/test/input/14.list-handling.md
@@ -3,6 +3,16 @@
 - List item
 ::
 
+::ol{.cool-list}
+1. List item{.foo}
+2. List item
+::
+
+::ol{.cool-list-2}
+2. List item{.foo}
+3. List item
+::
+
 - List item{.foo}
 - List item
   - List item {.bar}
@@ -10,5 +20,9 @@
     ::ul{.another-list}
     - List item
     ::
+  - List item
+    ::ol{.another-list}
+    1. List item
+    ::
 
 > Foo {.foo}
diff --git a/test/input/18.wrapper-blocks.md b/test/input/18.wrapper-blocks.md
new file mode 100644
index 0000000..16ce503
--- /dev/null
+++ b/test/input/18.wrapper-blocks.md
@@ -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
+::
+
+
+
+::pre{.multi}
+```ts
+a
+```
+```js
+b
+```
+::
+
+::ol{.outer}
+:::ol{.inner}
+4. Only the innermost wrapper folds
+:::
+::
+
+::blockquote{.mixed}
+> Quote
+
+Paragraph after
+::
diff --git a/test/input/6.inline-props.md b/test/input/6.inline-props.md
index 8bf8875..e0c5948 100644
--- a/test/input/6.inline-props.md
+++ b/test/input/6.inline-props.md
@@ -1,5 +1,9 @@
 # Hello{.text-red}
 
+## h2 with _italic_ {style="color: red"}
+
+## h2 with _italic_{style="color: red"}
+
 Hello World{class="text-green text-xl"}
 
 [Link](https://nuxt.com){class="nuxt"}
diff --git a/test/input/8.inline-span.md b/test/input/8.inline-span.md
index 8c2bea3..0541aa8 100644
--- a/test/input/8.inline-span.md
+++ b/test/input/8.inline-span.md
@@ -5,3 +5,9 @@ Hello [World]{.bg-blue-500}!
 Hello [World\] Yes]!
 
 Hello [World]{style=""}!
+
+A paragraph [span] {attr=value}
+
+A paragraph [span]  {data-spaces="two"}
+
+A paragraph [span]{attr=value}
diff --git a/test/output/14.list-handling.html b/test/output/14.list-handling.html
index 0f7ad56..41a9a06 100644
--- a/test/output/14.list-handling.html
+++ b/test/output/14.list-handling.html
@@ -2,6 +2,14 @@
   
  • List item
  • List item
  • +
      +
    1. List item
    2. +
    3. List item
    4. +
    +
      +
    1. List item
    2. +
    3. List item
    4. +
    • List item
    • @@ -14,6 +22,12 @@
    • List item
    +
  • + List item +
      +
    1. List item
    2. +
    +
  • diff --git a/test/output/18.wrapper-blocks.html b/test/output/18.wrapper-blocks.html new file mode 100644 index 0000000..8df945e --- /dev/null +++ b/test/output/18.wrapper-blocks.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + +
    col1col2
    ab
    +
    +

    Paragraph 1

    +

    Paragraph 2

    +
    +
    const variable = "value"
    +
    +
    indented code
    +
    + +
      +
    1. Explicit start on the wrapper wins
    2. +
    + +
    +
    a
    +
    +
    b
    +
    +
    +
      +
        +
      1. Only the innermost wrapper folds
      2. +
      +
    +
    +
    +

    Quote

    +
    +

    Paragraph after

    +
    diff --git a/test/output/6.inline-props.html b/test/output/6.inline-props.html index 7a4013a..72a41db 100644 --- a/test/output/6.inline-props.html +++ b/test/output/6.inline-props.html @@ -1,4 +1,6 @@

    Hello

    +

    h2 with italic

    +

    h2 with italic

    Hello World

    Link

    diff --git a/test/output/8.inline-span.html b/test/output/8.inline-span.html index ee4f676..871f59f 100644 --- a/test/output/8.inline-span.html +++ b/test/output/8.inline-span.html @@ -2,3 +2,6 @@

    Hello World!

    Hello World] Yes!

    Hello World!

    +

    A paragraph span

    +

    A paragraph span

    +

    A paragraph span