Skip to content

Commit 1af0f9d

Browse files
committed
Add remark-mark-plus as a refactored local component to allow using ===my marked text=== to wrap the 'my marked text' in <mark> elements
1 parent e5abb86 commit 1af0f9d

11 files changed

Lines changed: 206 additions & 10 deletions

File tree

‎_TODO.md‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -249,16 +249,6 @@ Calculations: {Math.PI * 2}.
249249
Components: { <MyComponent /> }
250250
```
251251

252-
### remark-mark-plus
253-
254-
Inserts indicated text in a pair of `<mark>` elements
255-
256-
```markdown
257-
my ==marked== text
258-
```
259-
260-
my ==marked== text
261-
262252
### Youtube embedding
263253

264254
- `rehype-video` for self-hosted videos, you have to pass the video filename into the unified `process()` pipeline along with the plugin

‎package-lock.json‎

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
"remark-deflist": "^1.0.0",
114114
"remark-emoji": "^5.0.2",
115115
"remark-linkify-regex": "^1.2.1",
116+
"remark-mark-plus": "^1.0.21",
116117
"remark-parse": "^11.0.0",
117118
"remark-stringify": "^11.0.0",
118119
"remark-supersub": "^1.0.0",

‎src/content/articles/demo/index.mdx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,16 @@ Term 1
648648

649649
: Definition 1
650650

651+
### remark-mark-plus
652+
653+
Inserts indicated text in a pair of `<mark>` elements
654+
655+
```markdown
656+
my ==marked== text
657+
```
658+
659+
my ==marked== text
660+
651661
### remark-supersub
652662

653663
Subscript and superscript text

‎src/content/test-fixtures/markdown/index.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ Subscript: a~i~
6161

6262
Superscript: e^x^
6363

64+
## Mark Plus (remark-mark-plus)
65+
66+
My ==marked== text.
67+
6468
## Heading Anchors
6569

6670
### My Section Heading

‎src/lib/config/markdown.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ Object.defineProperty(remarkSupersub, 'name', { value: 'remarkSupersub' })
6262
import remarkCustomBlocks from '../markdown/plugins/remark-custom-blocks'
6363
Object.defineProperty(remarkCustomBlocks, 'name', { value: 'remarkCustomBlocks' })
6464

65+
import remarkMarkPlus from '../markdown/plugins/remark-mark-plus'
66+
Object.defineProperty(remarkMarkPlus, 'name', { value: 'remarkMarkPlus' })
67+
6568
import remarkLinkifyRegex from 'remark-linkify-regex'
6669

6770
import remarkGridTables from '@adobe/remark-gridtables'
@@ -279,6 +282,8 @@ export const markdownConfig: Partial<MdxOptions> = {
279282
[remarkCustomBlocks, remarkCustomBlocksConfig],
280283
/** Subscript and superscript via ~sub~ and ^sup^ */
281284
remarkSupersub,
285+
/** Highlights via ==marked== */
286+
remarkMarkPlus,
282287
/** Definition lists (PHP Markdown Extra style) */
283288
remarkDeflist,
284289
/** Parse grid tables (+---+ / |...| syntax) into standard table nodes */
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// @vitest-environment happy-dom
2+
/**
3+
* Layer 3: E2E Tests - remarkMarkPlus
4+
*/
5+
6+
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
7+
import { cleanup, render } from '@testing-library/preact'
8+
import { MarkdownOutput } from '@lib/markdown/helpers/markdownLoader'
9+
import { processWithFullPipeline } from '@lib/markdown/helpers/processors'
10+
11+
let html: string
12+
13+
beforeAll(async () => {
14+
const markdown = 'My ==marked== text.'
15+
html = await processWithFullPipeline(markdown)
16+
})
17+
18+
afterEach(() => {
19+
cleanup()
20+
})
21+
22+
describe('Layer 3: E2E - remarkMarkPlus', () => {
23+
it('should render mark tags', () => {
24+
const { container } = render(<MarkdownOutput html={html} />)
25+
26+
expect(container.querySelector('mark')?.textContent).toBe('marked')
27+
})
28+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, it, expect } from 'vitest'
2+
import remarkMarkPlus from '@lib/markdown/plugins/remark-mark-plus'
3+
import { processWithAstroSettings } from '@lib/markdown/helpers/processors'
4+
5+
describe('remark-mark-plus (Layer 2: Astro Pipeline)', () => {
6+
it('should convert ==marked== into <mark>marked</mark>', async () => {
7+
const markdown = 'My ==marked== text.'
8+
9+
const html = await processWithAstroSettings({ markdown, plugin: remarkMarkPlus })
10+
11+
expect(html).toContain('<mark>')
12+
expect(html).toContain('marked')
13+
expect(html).toContain('</mark>')
14+
})
15+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, it, expect } from 'vitest'
2+
import remarkMarkPlus from '@lib/markdown/plugins/remark-mark-plus'
3+
import { processIsolated } from '@lib/markdown/helpers/processors'
4+
5+
describe('remark-mark-plus (Layer 1: Isolated)', () => {
6+
it('should convert ==marked== into <mark>marked</mark>', async () => {
7+
const markdown = 'My ==marked== text.'
8+
9+
const html = await processIsolated({ markdown, plugin: remarkMarkPlus })
10+
11+
expect(html).toContain('<mark>')
12+
expect(html).toContain('marked')
13+
expect(html).toContain('</mark>')
14+
})
15+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { Root, Text, Parent } from 'mdast'
2+
import type { Plugin } from 'unified'
3+
import { visit } from 'unist-util-visit'
4+
5+
type MarkNode = {
6+
type: 'mark'
7+
children: Text[]
8+
data: {
9+
hName: 'mark'
10+
}
11+
}
12+
13+
type HasChildren = Parent & { children: unknown[] }
14+
15+
function isHasChildren(node: unknown): node is HasChildren {
16+
if (!node || typeof node !== 'object') return false
17+
if (!('children' in (node as HasChildren))) return false
18+
return Array.isArray((node as HasChildren).children)
19+
}
20+
21+
function splitMarkedText(value: string): Array<Text | MarkNode> {
22+
const parts: Array<Text | MarkNode> = []
23+
let hasMark = false
24+
25+
let cursor = 0
26+
27+
while (cursor < value.length) {
28+
const start = value.indexOf('==', cursor)
29+
if (start === -1) {
30+
const tail = value.slice(cursor)
31+
if (tail) parts.push({ type: 'text', value: tail })
32+
break
33+
}
34+
35+
const before = value.slice(cursor, start)
36+
if (before) parts.push({ type: 'text', value: before })
37+
38+
const contentStart = start + 2
39+
const end = value.indexOf('==', contentStart)
40+
41+
// No closing delimiter; treat remainder as plain text.
42+
if (end === -1) {
43+
parts.push({ type: 'text', value: value.slice(start) })
44+
break
45+
}
46+
47+
const marked = value.slice(contentStart, end)
48+
49+
// Empty highlights are treated as plain text (avoids producing empty <mark/>).
50+
if (!marked) {
51+
parts.push({ type: 'text', value: '====' })
52+
cursor = end + 2
53+
continue
54+
}
55+
56+
parts.push({
57+
type: 'mark',
58+
children: [{ type: 'text', value: marked }],
59+
data: { hName: 'mark' },
60+
})
61+
62+
hasMark = true
63+
64+
cursor = end + 2
65+
}
66+
67+
// If no marks were created, keep the original text intact.
68+
// This prevents breaking other token-based plugins (e.g., `<==>` in remark-replacements).
69+
if (!hasMark) return [{ type: 'text', value }]
70+
71+
return parts
72+
}
73+
74+
const remarkMarkPlus: Plugin<[], Root> = () => {
75+
return tree => {
76+
visit(tree, 'text', (node: Text, index, parent) => {
77+
if (!parent || typeof index !== 'number') return
78+
if (!isHasChildren(parent)) return
79+
80+
if (!node.value.includes('==')) return
81+
82+
const replacement = splitMarkedText(node.value)
83+
if (replacement.length === 1) {
84+
const first = replacement.at(0)
85+
if (first?.type === 'text') return
86+
}
87+
88+
;(parent.children as unknown[]).splice(index, 1, ...replacement)
89+
90+
// Tell unist-util-visit to skip over the nodes we just inserted.
91+
return index + replacement.length
92+
})
93+
}
94+
}
95+
96+
export default remarkMarkPlus

0 commit comments

Comments
 (0)