Repository: shikijs/shiki (TypeScript)

Issue:
Feature proposal: concat spans with the same style
### Clear and concise description of the problem

Here is a snippet of Shiki output (yaml, `min-light`):

```html
<span class="line">
  <span style="color:#D32F2F;">  image</span>
  <span style="color:#D32F2F;">:</span>
  <span style="color:#22863A;"> node:20-alpine</span>
</span>
```

As you can see, `  image` and `:` spans have the same color. It doesn't make sense to render them as a separate `<span>`s, and a single one `  image:` would be sufficient.



### Suggested solution

Concatenate consecutive spans with the same styling:

```html
<span class="line">
  <span style="color:#D32F2F;">  image:</span>
  <span style="color:#22863A;"> node:20-alpine</span>
</span>
```

### Alternative

_No response_

### Additional context

This change could significantly reduce amount of generated HTML and DOM nodes, which is clearly a performance benefit in web.

The gain varies depending on a theme. The less variety of colors there is, the more compaction effect there will be.

### Validations

- [x] Follow our [Code of Conduct](https://github.com/shikijs/shiki/blob/main/CODE_OF_CONDUCT.md)
- [x] Read the [Contributing Guide](https://github.com/shikijs/shiki/blob/main/CONTRIBUTING.md).
- [x] Check that there isn't already an issue that request the same feature to avoid creating a duplicate.


Discussion:
It deps on which level user consumes this data, at token level the might have different scope information. I am up to have an option to do this on token-to-hast level, similar to mergeWhitespaces, PR welcome if anyone wants to work on that :)



Patch:
diff --git a/packages/core/src/highlight/code-to-hast.ts b/packages/core/src/highlight/code-to-hast.ts
index 68b3767da..5ace2996e 100644
--- a/packages/core/src/highlight/code-to-hast.ts
+++ b/packages/core/src/highlight/code-to-hast.ts
@@ -47,6 +47,7 @@ export function codeToHast(
 
   const {
     mergeWhitespaces = true,
+    mergeSameStyleTokens = false,
   } = options
 
   if (mergeWhitespaces === true)
@@ -54,6 +55,10 @@ export function codeToHast(
   else if (mergeWhitespaces === 'never')
     tokens = splitWhitespaceTokens(tokens)
 
+  if (mergeSameStyleTokens) {
+    tokens = mergeAdjacentStyledTokens(tokens)
+  }
+
   const contextSource = {
     ...transformerContext,
     get source() {
@@ -306,3 +311,35 @@ function splitWhitespaceTokens(tokens: ThemedToken[][]): ThemedToken[][] {
     })
   })
 }
+
+function mergeAdjacentStyledTokens(tokens: ThemedToken[][]): ThemedToken[][] {
+  return tokens.map((line) => {
+    const newLine: ThemedToken[] = []
+    for (const token of line) {
+      if (newLine.length === 0) {
+        newLine.push({ ...token })
+        continue
+      }
+
+      const prevToken = newLine[newLine.length - 1]
+      const prevStyle = prevToken.htmlStyle || stringifyTokenStyle(getTokenStyleObject(prevToken))
+      const currentStyle = token.htmlStyle || stringifyTokenStyle(getTokenStyleObject(token))
+      const isPrevDecorated = prevToken.fontStyle && (
+        (prevToken.fontStyle & FontStyle.Underline)
+        || (prevToken.fontStyle & FontStyle.Strikethrough)
+      )
+      const isDecorated = token.fontStyle && (
+        (token.fontStyle & FontStyle.Underline)
+        || (token.fontStyle & FontStyle.Strikethrough)
+      )
+
+      if (!isPrevDecorated && !isDecorated && prevStyle === currentStyle) {
+        prevToken.content += token.content
+      }
+      else {
+        newLine.push({ ...token })
+      }
+    }
+    return newLine
+  })
+}
diff --git a/packages/types/src/options.ts b/packages/types/src/options.ts
index ef989b856..588329b88 100644
--- a/packages/types/src/options.ts
+++ b/packages/types/src/options.ts
@@ -142,6 +142,14 @@ export interface CodeToHastOptionsCommon<Languages extends string = string>
    */
   mergeWhitespaces?: boolean | 'never'
 
+  /**
+   * Merge consecutive tokens with the same style to reduce the number of DOM nodes.
+   * This can improve rendering performance but may affect the structure of the output.
+   *
+   * @default false
+   */
+  mergeSameStyleTokens?: boolean
+
   /**
    * The structure of the generated HAST and HTML.
    *


Test patch:
diff --git a/packages/core/test/hast.test.ts b/packages/core/test/hast.test.ts
index ba0bf4d9b..2a802526b 100644
--- a/packages/core/test/hast.test.ts
+++ b/packages/core/test/hast.test.ts
@@ -94,3 +94,44 @@ it('render whitespace', async () => {
       <span class="line"><span style="color:#59873A">		tab</span><span style="color:#999999">()</span></span></code></pre>"
     `)
 })
+
+describe('merge same style', () => {
+  it('merges adjacent tokens with same style', async () => {
+    using shiki = await createHighlighter({
+      themes: ['min-light'],
+      langs: ['yaml'],
+    })
+
+    const code = 'name: CI'
+    const html = await shiki.codeToHtml(code, {
+      lang: 'yaml',
+      theme: 'min-light',
+      mergeSameStyleTokens: true,
+    })
+
+    expect(html).toMatchInlineSnapshot(`"<pre class="shiki min-light" style="background-color:#ffffff;color:#24292eff" tabindex="0"><code><span class="line"><span style="color:#D32F2F">name:</span><span style="color:#22863A"> CI</span></span></code></pre>"`)
+  })
+
+  it('does not merge tokens with decorations', async () => {
+    using shiki = await createHighlighter({
+      themes: ['min-light'],
+      langs: ['yaml'],
+    })
+
+    const code = 'name: CI'
+    const html = await shiki.codeToHtml(code, {
+      lang: 'yaml',
+      theme: 'min-light',
+      mergeSameStyleTokens: true,
+      decorations: [
+        {
+          start: { line: 0, character: 0 },
+          end: { line: 0, character: 4 },
+          properties: { class: 'highlighted-word' },
+        },
+      ],
+    })
+
+    expect(html).toMatchInlineSnapshot(`"<pre class="shiki min-light" style="background-color:#ffffff;color:#24292eff" tabindex="0"><code><span class="line"><span style="color:#D32F2F" class="highlighted-word">name</span><span style="color:#D32F2F">:</span><span style="color:#22863A"> CI</span></span></code></pre>"`)
+  })
+})