rehype-slug-link 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/lib/index.d.ts +62 -0
- package/lib/index.js +404 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 adhi-jp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# rehype-slug-link
|
|
2
|
+
|
|
3
|
+
[](https://github.com/adhi-jp/rehype-slug-link/actions)
|
|
4
|
+
[](https://www.npmjs.com/package/rehype-slug-link)
|
|
5
|
+
[](https://codecov.io/gh/adhi-jp/rehype-slug-link)
|
|
6
|
+
[](https://bundlejs.com/?q=rehype-slug-link)
|
|
7
|
+
|
|
8
|
+
A [rehype](https://github.com/rehypejs/rehype) plugin that converts custom link syntax (e.g. `[{#slug}]`) in text nodes into anchor links to headings, by collecting heading IDs and their text content.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Overview
|
|
13
|
+
|
|
14
|
+
**rehype-slug-link** is a [unified](https://github.com/unifiedjs/unified) ([rehype](https://github.com/rehypejs/rehype)) plugin that enables you to write internal links to headings using a customizable inline syntax. It scans the document for headings, collects their IDs and text, and replaces matching patterns in text nodes with anchor links to those headings.
|
|
15
|
+
|
|
16
|
+
- **Customizable link syntax**: Default is `[{#slug}]`, but any RegExp can be used.
|
|
17
|
+
- **Flexible matching**: Supports multiple links per paragraph, deeply nested structures, and custom fallback behaviors.
|
|
18
|
+
- **Unicode normalization**: Optionally normalize heading text and slugs.
|
|
19
|
+
- **TypeScript support**: Includes type definitions.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## When Should You Use This Plugin?
|
|
24
|
+
|
|
25
|
+
Use **rehype-slug-link** if you want:
|
|
26
|
+
|
|
27
|
+
- To write internal links to headings using a simple, readable inline syntax.
|
|
28
|
+
- To support custom or non-standard link notations in your markdown/HTML.
|
|
29
|
+
- To automatically convert inline references to anchor links, even in complex or deeply nested content.
|
|
30
|
+
- To control how unmatched or invalid slugs are handled.
|
|
31
|
+
- To work with multilingual or Unicode content.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npm install rehype-slug-link
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Usage Example
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
import { rehype } from "rehype";
|
|
47
|
+
import rehypeSlug from "rehype-slug";
|
|
48
|
+
import rehypeSlugLink from "rehype-slug-link";
|
|
49
|
+
|
|
50
|
+
const file = await rehype()
|
|
51
|
+
.use(rehypeSlug) // Ensure headings have IDs
|
|
52
|
+
.use(rehypeSlugLink)
|
|
53
|
+
.process("<h1>Introduction</h1><p>See [{#introduction}]</p>");
|
|
54
|
+
|
|
55
|
+
console.log(String(file));
|
|
56
|
+
// <h1 id="introduction">Introduction</h1><p>See <a href="#introduction">Introduction</a></p>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Custom Pattern Example
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { rehype } from "rehype";
|
|
63
|
+
import rehypeSlug from "rehype-slug";
|
|
64
|
+
import rehypeSlugLink from "rehype-slug-link";
|
|
65
|
+
|
|
66
|
+
const file = await rehype()
|
|
67
|
+
.use(rehypeSlug)
|
|
68
|
+
.use(rehypeSlugLink, { pattern: /\[link:([^\]]+)\]/g })
|
|
69
|
+
.process("<h1>Intro</h1><p>See [link:intro]</p>");
|
|
70
|
+
|
|
71
|
+
console.log(String(file));
|
|
72
|
+
// <h1 id="intro">Intro</h1><p>See <a href="#intro">Intro</a></p>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Multiple Links Example
|
|
76
|
+
|
|
77
|
+
```html
|
|
78
|
+
<!-- Input -->
|
|
79
|
+
<h1>Chapter 1</h1>
|
|
80
|
+
<h2>Chapter 2</h2>
|
|
81
|
+
<p>Read [{#chapter-1}] before [{#chapter-2}].</p>
|
|
82
|
+
|
|
83
|
+
<!-- Output -->
|
|
84
|
+
<h1 id="chapter-1">Chapter 1</h1>
|
|
85
|
+
<h2 id="chapter-2">Chapter 2</h2>
|
|
86
|
+
<p>
|
|
87
|
+
Read <a href="#chapter-1">Chapter 1</a> before
|
|
88
|
+
<a href="#chapter-2">Chapter 2</a>.
|
|
89
|
+
</p>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### With Custom Heading IDs
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
import { rehype } from "rehype";
|
|
96
|
+
import rehypeHeadingSlug from "rehype-heading-slug";
|
|
97
|
+
import rehypeSlugLink from "rehype-slug-link";
|
|
98
|
+
|
|
99
|
+
const file = await rehype()
|
|
100
|
+
.use(rehypeHeadingSlug) // Supports {#custom-id} syntax
|
|
101
|
+
.use(rehypeSlugLink)
|
|
102
|
+
.process("<h1>Advanced Topics {#advanced}</h1><p>See [{#advanced}]</p>");
|
|
103
|
+
|
|
104
|
+
console.log(String(file));
|
|
105
|
+
// <h1 id="advanced">Advanced Topics</h1><p>See <a href="#advanced">Advanced Topics</a></p>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Advanced Examples
|
|
111
|
+
|
|
112
|
+
### Fallback to Heading Text
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
import { rehype } from "rehype";
|
|
116
|
+
import rehypeSlug from "rehype-slug";
|
|
117
|
+
import rehypeSlugLink from "rehype-slug-link";
|
|
118
|
+
|
|
119
|
+
const file = await rehype()
|
|
120
|
+
.use(rehypeSlug)
|
|
121
|
+
.use(rehypeSlugLink, {
|
|
122
|
+
fallbackToHeadingText: true,
|
|
123
|
+
pattern: /\[text:([^\]]+)\]/g,
|
|
124
|
+
})
|
|
125
|
+
.process("<h1>Introduction</h1><p>See [text:Introduction]</p>");
|
|
126
|
+
|
|
127
|
+
console.log(String(file));
|
|
128
|
+
// <h1 id="introduction">Introduction</h1><p>See <a href="#introduction">Introduction</a></p>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Unicode Normalization
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
import { rehype } from "rehype";
|
|
135
|
+
import rehypeHeadingSlug from "rehype-heading-slug";
|
|
136
|
+
import rehypeSlugLink from "rehype-slug-link";
|
|
137
|
+
|
|
138
|
+
const file = await rehype()
|
|
139
|
+
.use(rehypeHeadingSlug, { normalizeUnicode: true })
|
|
140
|
+
.use(rehypeSlugLink, { normalizeUnicode: true })
|
|
141
|
+
.process("<h1>café</h1><p>See [{#cafe}]</p>");
|
|
142
|
+
|
|
143
|
+
console.log(String(file));
|
|
144
|
+
// <h1 id="cafe">café</h1><p>See <a href="#cafe">café</a></p>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## API
|
|
150
|
+
|
|
151
|
+
### `rehype().use(rehypeSlugLink[, options])`
|
|
152
|
+
|
|
153
|
+
Replaces custom link syntax in text nodes with anchor links to headings, based on collected heading IDs and text.
|
|
154
|
+
|
|
155
|
+
#### Options
|
|
156
|
+
|
|
157
|
+
All options are optional:
|
|
158
|
+
|
|
159
|
+
| Name | Type | Default | Description |
|
|
160
|
+
| ----------------------- | ------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
161
|
+
| `pattern` | RegExp | `/\[\{#([a-zA-Z0-9-_]+)\}\]/g` | Regular expression to match link syntax. Must have a capture group for the slug. |
|
|
162
|
+
| `patternGroupMissing` | string | `"wrap"` | If `pattern` has no capture group: `"wrap"` (wrap whole pattern), or `"error"` (throw error). |
|
|
163
|
+
| `fallbackToHeadingText` | boolean | `false` | If `true`, use heading text as slug if ID not found. |
|
|
164
|
+
| `invalidSlug` | string | `"convert"` | How to handle invalid slugs: `"convert"` (auto-fix) or `"error"` (throw error). |
|
|
165
|
+
| `maintainCase` | boolean | `false` | Preserve case when generating slugs. |
|
|
166
|
+
| `normalizeUnicode` | boolean | `false` | Normalize Unicode characters in slugs and heading text. Only converts Latin-based accented characters to ASCII equivalents, preserving other character systems (Cyrillic, CJK, etc.). Enables case-insensitive matching between normalized slugs and heading text. |
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Security
|
|
171
|
+
|
|
172
|
+
**⚠️ Important:** This plugin generates anchor links based on heading IDs and text content. If your content is user-generated, always use [rehype-sanitize](https://github.com/rehypejs/rehype-sanitize) to prevent [XSS](https://en.wikipedia.org/wiki/Cross-site_scripting) and DOM clobbering risks.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Related Plugins
|
|
177
|
+
|
|
178
|
+
- [rehype-slug](https://github.com/rehypejs/rehype-slug): Simple plugin for generating heading slugs.
|
|
179
|
+
- [rehype-slug-custom-id](https://github.com/playfulprogramming/rehype-slug-custom-id): Simple ID assignment with explicit slug notation support.
|
|
180
|
+
- [rehype-heading-slug](https://github.com/adhi-jp/rehype-heading-slug): Heading slugger with explicit slug notation and additional options.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## AI-Assisted Development
|
|
185
|
+
|
|
186
|
+
This project was developed with the help of GitHub Copilot and other generative AI tools.
|
|
187
|
+
**Disclaimer:** Please review and test thoroughly before using in production.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
[MIT License](./LICENSE) © adhi-jp
|
package/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default, type RehypeSlugLinkOptions } from "./lib/index.js";
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./lib/index.js";
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Transformer } from "unified";
|
|
2
|
+
import type { Root } from "hast";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration options for the rehype-slug-link plugin.
|
|
6
|
+
*/
|
|
7
|
+
export interface RehypeSlugLinkOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Link syntax regular expression pattern.
|
|
10
|
+
* @default /\[\{#([a-zA-Z0-9-_\u00C0-\uFFFF]+)\}\]/g
|
|
11
|
+
*/
|
|
12
|
+
pattern?: RegExp;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Behavior when pattern has no capture groups.
|
|
16
|
+
* - 'wrap': Automatically wrap the pattern with capture group
|
|
17
|
+
* - 'error': Throw an error
|
|
18
|
+
* @default 'wrap'
|
|
19
|
+
*/
|
|
20
|
+
patternGroupMissing?: "wrap" | "error";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Use heading text as slug if id not found.
|
|
24
|
+
* @default false
|
|
25
|
+
*/
|
|
26
|
+
fallbackToHeadingText?: boolean;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How to handle invalid slugs.
|
|
30
|
+
* - 'convert': Convert invalid slugs using github-slugger
|
|
31
|
+
* - 'error': Throw an error
|
|
32
|
+
* @default 'convert'
|
|
33
|
+
*/
|
|
34
|
+
invalidSlug?: "convert" | "error";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Preserve case when generating slugs.
|
|
38
|
+
* @default false
|
|
39
|
+
*/
|
|
40
|
+
maintainCase?: boolean;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize Unicode characters to ASCII equivalents.
|
|
44
|
+
* Only converts Latin-based accented characters, preserving other character systems
|
|
45
|
+
* (Cyrillic, CJK, etc.). Uses NFD normalization to decompose characters, removes
|
|
46
|
+
* combining diacritical marks, and converts special characters like æ→ae, ø→o, etc.
|
|
47
|
+
* @default false
|
|
48
|
+
*/
|
|
49
|
+
normalizeUnicode?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rehype plugin that converts link syntax to heading links.
|
|
54
|
+
*
|
|
55
|
+
* @param options - Plugin configuration options
|
|
56
|
+
* @returns The transformer function
|
|
57
|
+
*/
|
|
58
|
+
declare function rehypeSlugLink(
|
|
59
|
+
options?: RehypeSlugLinkOptions,
|
|
60
|
+
): Transformer<Root, Root>;
|
|
61
|
+
|
|
62
|
+
export default rehypeSlugLink;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { visit } from "unist-util-visit";
|
|
2
|
+
import GithubSlugger from "github-slugger";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('./index.d.ts').RehypeSlugLinkOptions} RehypeSlugLinkOptions
|
|
6
|
+
* @typedef {import('unified').Transformer<import('hast').Root, import('hast').Root>} UnifiedTransformer
|
|
7
|
+
* @typedef {import('hast').Root} HastRoot
|
|
8
|
+
* @typedef {import('hast').Node} HastNode
|
|
9
|
+
* @typedef {import('hast').Element} HastElement
|
|
10
|
+
* @typedef {import('hast').Text} HastText
|
|
11
|
+
* @typedef {import('hast').Parent} HastParent
|
|
12
|
+
* @typedef {import('hast').Comment} HastComment
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} HeadingMaps
|
|
17
|
+
* @property {Record<string, string>} idToText - Maps heading IDs to text content
|
|
18
|
+
* @property {Record<string, string>} textToId - Maps text content to heading IDs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} ProcessedMatch
|
|
23
|
+
* @property {string} raw - Original matched text
|
|
24
|
+
* @property {string} slug - Processed slug
|
|
25
|
+
* @property {number} index - Start index in text
|
|
26
|
+
* @property {number} lastIndex - End index in text
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rehype plugin that converts link syntax to heading links.
|
|
31
|
+
*
|
|
32
|
+
* @param {RehypeSlugLinkOptions} [options={}] - Plugin configuration
|
|
33
|
+
* @returns {UnifiedTransformer} The transformer function
|
|
34
|
+
*/
|
|
35
|
+
export default function rehypeSlugLink(options = {}) {
|
|
36
|
+
const config = normalizeOptions(options);
|
|
37
|
+
const pattern = ensurePatternHasCaptureGroup(
|
|
38
|
+
config.pattern,
|
|
39
|
+
config.patternGroupMissing,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return (tree) => {
|
|
43
|
+
// Early return if already processed
|
|
44
|
+
if (tree.data?.rehypeSlugLinkProcessed) {
|
|
45
|
+
return tree;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const headingMaps = collectHeadingMaps(tree, config);
|
|
49
|
+
processTextNodes(tree, pattern, headingMaps, config);
|
|
50
|
+
|
|
51
|
+
// Mark as processed
|
|
52
|
+
(tree.data ??= {}).rehypeSlugLinkProcessed = true;
|
|
53
|
+
|
|
54
|
+
return tree;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Normalizes and validates plugin options.
|
|
60
|
+
* @param {RehypeSlugLinkOptions} options - Raw options from user
|
|
61
|
+
* @returns {Required<RehypeSlugLinkOptions>} Normalized options with defaults
|
|
62
|
+
*/
|
|
63
|
+
function normalizeOptions(options) {
|
|
64
|
+
return {
|
|
65
|
+
pattern: options.pattern ?? /\[\{#([a-zA-Z0-9-_\u00C0-\uFFFF]+)\}\]/g,
|
|
66
|
+
patternGroupMissing: options.patternGroupMissing ?? "wrap",
|
|
67
|
+
fallbackToHeadingText: options.fallbackToHeadingText ?? false,
|
|
68
|
+
invalidSlug: options.invalidSlug ?? "convert",
|
|
69
|
+
maintainCase: options.maintainCase ?? false,
|
|
70
|
+
normalizeUnicode: options.normalizeUnicode ?? false,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Ensures the pattern has at least one capture group.
|
|
76
|
+
* @param {RegExp} pattern - The pattern to check
|
|
77
|
+
* @param {'wrap'|'error'} patternGroupMissing - Behavior when no groups found
|
|
78
|
+
* @returns {RegExp} Pattern with guaranteed capture group
|
|
79
|
+
* @throws {Error} When patternGroupMissing is 'error' and no groups found
|
|
80
|
+
*/
|
|
81
|
+
function ensurePatternHasCaptureGroup(pattern, patternGroupMissing) {
|
|
82
|
+
// Efficiently count capture groups using match
|
|
83
|
+
const groupCount = (pattern.source.match(/\((?!\?[:?!])/g) || []).length;
|
|
84
|
+
|
|
85
|
+
if (groupCount === 0) {
|
|
86
|
+
if (patternGroupMissing === "wrap") {
|
|
87
|
+
return new RegExp(`(${pattern.source})`, pattern.flags);
|
|
88
|
+
}
|
|
89
|
+
throw new Error("rehypeSlugLink: pattern must contain a capture group");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return pattern;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Collects mappings between heading IDs and text content.
|
|
97
|
+
* @param {HastRoot} tree - The HAST tree to traverse
|
|
98
|
+
* @param {Required<RehypeSlugLinkOptions>} config - Plugin configuration
|
|
99
|
+
* @returns {HeadingMaps} Heading mappings
|
|
100
|
+
*/
|
|
101
|
+
function collectHeadingMaps(tree, config) {
|
|
102
|
+
const headingMaps = { idToText: {}, textToId: {} };
|
|
103
|
+
|
|
104
|
+
visit(tree, (/** @type {HastNode} */ node) => {
|
|
105
|
+
if (
|
|
106
|
+
node.type === "element" &&
|
|
107
|
+
/^h[1-6]$/.test(/** @type {HastElement} */ (node).tagName) &&
|
|
108
|
+
/** @type {HastElement} */ (node).properties?.id
|
|
109
|
+
) {
|
|
110
|
+
let text = extractText(/** @type {HastElement} */ (node));
|
|
111
|
+
if (config.normalizeUnicode) {
|
|
112
|
+
text = normalizeUnicodeToAscii(text);
|
|
113
|
+
}
|
|
114
|
+
const id = /** @type {HastElement} */ (node).properties.id;
|
|
115
|
+
headingMaps.idToText[id] = text;
|
|
116
|
+
headingMaps.textToId[text] = id;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return headingMaps;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Processes text nodes in the tree to convert link syntax.
|
|
125
|
+
* @param {HastRoot} tree - The HAST tree to process
|
|
126
|
+
* @param {RegExp} pattern - The pattern to match against
|
|
127
|
+
* @param {HeadingMaps} headingMaps - Heading mappings
|
|
128
|
+
* @param {Required<RehypeSlugLinkOptions>} config - Plugin configuration
|
|
129
|
+
*/
|
|
130
|
+
function processTextNodes(tree, pattern, headingMaps, config) {
|
|
131
|
+
const textNodesToProcess = [];
|
|
132
|
+
|
|
133
|
+
// Collect text nodes that need processing
|
|
134
|
+
visit(
|
|
135
|
+
tree,
|
|
136
|
+
"text",
|
|
137
|
+
(
|
|
138
|
+
/** @type {HastText} */ node,
|
|
139
|
+
/** @type {number} */ index,
|
|
140
|
+
/** @type {HastParent} */ parent,
|
|
141
|
+
) => {
|
|
142
|
+
if (shouldSkipTextNode(node, parent)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Quick check with test() before expensive processing
|
|
147
|
+
pattern.lastIndex = 0;
|
|
148
|
+
if (pattern.test(node.value)) {
|
|
149
|
+
textNodesToProcess.push({ node, index, parent });
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Process collected text nodes
|
|
155
|
+
for (const { node, index, parent } of textNodesToProcess) {
|
|
156
|
+
/* v8 ignore start */
|
|
157
|
+
// This line is unreachable because:
|
|
158
|
+
// 1. textNodesToProcess only contains nodes that passed the filter check
|
|
159
|
+
// 2. The same condition was already checked during collection
|
|
160
|
+
// 3. No code between collection and processing modifies the node.data.rehypeSlugLinkProcessed flag
|
|
161
|
+
// This check exists as defensive programming for potential future code changes
|
|
162
|
+
if (node.data?.rehypeSlugLinkProcessed) continue;
|
|
163
|
+
/* v8 ignore stop */
|
|
164
|
+
|
|
165
|
+
const replacementNodes = convertLinkSyntaxInText(
|
|
166
|
+
node.value,
|
|
167
|
+
pattern,
|
|
168
|
+
headingMaps,
|
|
169
|
+
config,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (
|
|
173
|
+
replacementNodes.length === 1 &&
|
|
174
|
+
replacementNodes[0].type === "text" &&
|
|
175
|
+
replacementNodes[0].value === node.value
|
|
176
|
+
) {
|
|
177
|
+
(node.data ??= {}).rehypeSlugLinkProcessed = true;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Mark all replacement nodes as processed
|
|
182
|
+
for (const newNode of replacementNodes) {
|
|
183
|
+
(newNode.data ??= {}).rehypeSlugLinkProcessed = true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (parent && typeof index === "number") {
|
|
187
|
+
parent.children.splice(index, 1, ...replacementNodes);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Determines if a text node should be skipped during processing.
|
|
194
|
+
* @param {HastText} node - The text node to check
|
|
195
|
+
* @param {HastParent} parent - The parent node
|
|
196
|
+
* @returns {boolean} True if the node should be skipped
|
|
197
|
+
*/
|
|
198
|
+
function shouldSkipTextNode(node, parent) {
|
|
199
|
+
return (
|
|
200
|
+
node.data?.rehypeSlugLinkProcessed ||
|
|
201
|
+
(parent?.type === "element" &&
|
|
202
|
+
/** @type {HastElement} */ (parent).tagName === "a") ||
|
|
203
|
+
!node.value
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Finds all matches of the pattern in text efficiently.
|
|
209
|
+
* @param {string} text - The text to search in
|
|
210
|
+
* @param {RegExp} pattern - The pattern to match
|
|
211
|
+
* @param {Required<RehypeSlugLinkOptions>} config - Plugin configuration
|
|
212
|
+
* @returns {Array<ProcessedMatch>} Found matches
|
|
213
|
+
*/
|
|
214
|
+
function findAllMatches(text, pattern, config) {
|
|
215
|
+
const matches = [];
|
|
216
|
+
let match;
|
|
217
|
+
let iterationCount = 0;
|
|
218
|
+
const maxIterations = text.length + 1;
|
|
219
|
+
|
|
220
|
+
// Reset lastIndex to ensure consistent behavior
|
|
221
|
+
pattern.lastIndex = 0;
|
|
222
|
+
|
|
223
|
+
while (
|
|
224
|
+
(match = pattern.exec(text)) !== null &&
|
|
225
|
+
iterationCount < maxIterations
|
|
226
|
+
) {
|
|
227
|
+
iterationCount++;
|
|
228
|
+
|
|
229
|
+
let slug = match[1] || match[0];
|
|
230
|
+
if (config.normalizeUnicode) {
|
|
231
|
+
slug = normalizeUnicodeToAscii(slug);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const processedSlug = processSlug(slug, config);
|
|
235
|
+
matches.push({
|
|
236
|
+
raw: match[0],
|
|
237
|
+
slug: processedSlug,
|
|
238
|
+
index: match.index,
|
|
239
|
+
lastIndex: pattern.lastIndex,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Prevent infinite loops on zero-width matches
|
|
243
|
+
if (pattern.lastIndex === match.index) {
|
|
244
|
+
pattern.lastIndex++;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (pattern.lastIndex > text.length) break;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return matches;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Converts link syntax in text to an array of nodes efficiently.
|
|
255
|
+
* @param {string} text - The text to process
|
|
256
|
+
* @param {RegExp} pattern - The pattern to match
|
|
257
|
+
* @param {HeadingMaps} headingMaps - Heading mappings
|
|
258
|
+
* @param {Required<RehypeSlugLinkOptions>} config - Plugin configuration
|
|
259
|
+
* @returns {Array<HastText | HastElement>} Array of text and element nodes
|
|
260
|
+
*/
|
|
261
|
+
function convertLinkSyntaxInText(text, pattern, headingMaps, config) {
|
|
262
|
+
const matches = findAllMatches(text, pattern, config);
|
|
263
|
+
|
|
264
|
+
/* v8 ignore start */
|
|
265
|
+
if (matches.length === 0) {
|
|
266
|
+
// This line is unreachable because:
|
|
267
|
+
// 1. convertLinkSyntaxInText is only called when pattern.test(node.value) returns true in processTextNodes (line 128)
|
|
268
|
+
// 2. findAllMatches resets pattern.lastIndex = 0 (line 181), ensuring exec() will find the same matches as test()
|
|
269
|
+
// 3. Therefore, if test() found matches, exec() will also find matches, making matches.length > 0 always true
|
|
270
|
+
// This return exists as defensive programming for potential future code changes
|
|
271
|
+
return [{ type: "text", value: text }];
|
|
272
|
+
}
|
|
273
|
+
/* v8 ignore stop */
|
|
274
|
+
|
|
275
|
+
const nodes = [];
|
|
276
|
+
let lastIndex = 0;
|
|
277
|
+
|
|
278
|
+
for (const match of matches) {
|
|
279
|
+
// Add text before match
|
|
280
|
+
if (match.index > lastIndex) {
|
|
281
|
+
nodes.push({ type: "text", value: text.slice(lastIndex, match.index) });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Add link node or fallback to original text
|
|
285
|
+
const linkNode = createLinkNode(match.slug, headingMaps, config);
|
|
286
|
+
nodes.push(linkNode || { type: "text", value: match.raw });
|
|
287
|
+
|
|
288
|
+
lastIndex = match.index + match.raw.length;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Add remaining text
|
|
292
|
+
if (lastIndex < text.length) {
|
|
293
|
+
nodes.push({ type: "text", value: text.slice(lastIndex) });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return nodes;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Normalize Unicode characters to ASCII equivalents
|
|
301
|
+
* Only converts Latin-based accented characters, preserving other character systems
|
|
302
|
+
* (Cyrillic, CJK, etc.)
|
|
303
|
+
* @param {string} text - The text to normalize
|
|
304
|
+
* @returns {string} The normalized text
|
|
305
|
+
*/
|
|
306
|
+
function normalizeUnicodeToAscii(text) {
|
|
307
|
+
return text
|
|
308
|
+
.normalize("NFD")
|
|
309
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
310
|
+
.replace(/[æ]/g, "ae")
|
|
311
|
+
.replace(/[Æ]/g, "AE")
|
|
312
|
+
.replace(/[ø]/g, "o")
|
|
313
|
+
.replace(/[Ø]/g, "O")
|
|
314
|
+
.replace(/[þ]/g, "th")
|
|
315
|
+
.replace(/[Þ]/g, "TH")
|
|
316
|
+
.replace(/[ð]/g, "dh")
|
|
317
|
+
.replace(/[Ð]/g, "DH")
|
|
318
|
+
.replace(/[ß]/g, "ss")
|
|
319
|
+
.normalize("NFC");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Processes and validates a slug.
|
|
324
|
+
* @param {string} slug - The slug to process
|
|
325
|
+
* @param {Required<RehypeSlugLinkOptions>} config - Plugin configuration
|
|
326
|
+
* @returns {string} Processed slug
|
|
327
|
+
* @throws {Error} When invalidSlug is 'error' and slug is invalid
|
|
328
|
+
*/
|
|
329
|
+
function processSlug(slug, config) {
|
|
330
|
+
if (/^[a-zA-Z0-9\-_]+$/.test(slug)) {
|
|
331
|
+
return slug;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (config.invalidSlug === "convert") {
|
|
335
|
+
const slugger = new GithubSlugger();
|
|
336
|
+
return slugger.slug(slug, config.maintainCase);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
throw new Error(`rehypeSlugLink: invalid slug: ${slug}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Creates a link node for the given slug.
|
|
344
|
+
* @param {string} slug - The slug to create a link for
|
|
345
|
+
* @param {HeadingMaps} headingMaps - Heading mappings
|
|
346
|
+
* @param {Required<RehypeSlugLinkOptions>} config - Plugin configuration
|
|
347
|
+
* @returns {HastElement | null} Link element or null if not found
|
|
348
|
+
*/
|
|
349
|
+
function createLinkNode(slug, headingMaps, config) {
|
|
350
|
+
let headingText = headingMaps.idToText[slug];
|
|
351
|
+
let id = slug;
|
|
352
|
+
|
|
353
|
+
// If not found and normalizeUnicode is enabled, try case-insensitive normalized matching
|
|
354
|
+
if (!headingText && config.normalizeUnicode) {
|
|
355
|
+
const normalizedSlug = normalizeUnicodeToAscii(slug).toLowerCase();
|
|
356
|
+
|
|
357
|
+
// Search for a heading text that normalizes to the same value (case-insensitive)
|
|
358
|
+
for (const [headingId, text] of Object.entries(headingMaps.idToText)) {
|
|
359
|
+
const normalizedText = normalizeUnicodeToAscii(text).toLowerCase();
|
|
360
|
+
if (normalizedText === normalizedSlug) {
|
|
361
|
+
headingText = text;
|
|
362
|
+
id = headingId;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!headingText && config.fallbackToHeadingText) {
|
|
369
|
+
id = headingMaps.textToId[slug];
|
|
370
|
+
headingText = slug;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return headingText && id
|
|
374
|
+
? {
|
|
375
|
+
type: "element",
|
|
376
|
+
tagName: "a",
|
|
377
|
+
properties: { href: `#${id}` },
|
|
378
|
+
children: [{ type: "text", value: headingText }],
|
|
379
|
+
}
|
|
380
|
+
: null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Extracts text content from a node recursively.
|
|
385
|
+
* @param {HastElement | HastText | HastComment} node - The node to extract text from
|
|
386
|
+
* @returns {string} The extracted text content
|
|
387
|
+
*/
|
|
388
|
+
function extractText(node) {
|
|
389
|
+
if (node.type === "text") {
|
|
390
|
+
return node.value;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if ("children" in node && node.children) {
|
|
394
|
+
return node.children.map(extractText).join("");
|
|
395
|
+
/* v8 ignore start */
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// This line is unreachable because:
|
|
399
|
+
// 1. All HAST Element nodes have 'children' property (even void elements have empty arrays)
|
|
400
|
+
// 2. Text and Comment nodes are handled by previous conditions
|
|
401
|
+
// 3. Other node types are not passed to this function in the current implementation
|
|
402
|
+
return "";
|
|
403
|
+
}
|
|
404
|
+
/* v8 ignore stop */
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rehype-slug-link",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A rehype plugin that converts custom link syntax to heading links",
|
|
5
|
+
"author": "adhi-jp",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"rehype",
|
|
9
|
+
"plugin",
|
|
10
|
+
"heading",
|
|
11
|
+
"link",
|
|
12
|
+
"slug"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/adhi-jp/rehype-slug-link.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/adhi-jp/rehype-slug-link/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/adhi-jp/rehype-slug-link#readme",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "index.js",
|
|
24
|
+
"types": "index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"import": "./index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"index.js",
|
|
33
|
+
"index.d.ts",
|
|
34
|
+
"lib/"
|
|
35
|
+
],
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/hast": "^3.0.4",
|
|
38
|
+
"@types/unist": "^3.0.3",
|
|
39
|
+
"@vitest/coverage-v8": "^3.2.3",
|
|
40
|
+
"husky": "^9.1.7",
|
|
41
|
+
"lint-staged": "^16.1.0",
|
|
42
|
+
"prettier": "^3.5.3",
|
|
43
|
+
"rehype": "^13.0.2",
|
|
44
|
+
"rehype-slug": "^6.0.0",
|
|
45
|
+
"typescript": "^5.8.3",
|
|
46
|
+
"unified": "^11.0.5",
|
|
47
|
+
"vitest": "^3.2.3"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"github-slugger": "^2.0.0",
|
|
51
|
+
"unist-util-visit": "^5.0.0"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"check": "tsc --noEmit",
|
|
55
|
+
"check:strict": "tsc --noEmit --skipLibCheck false",
|
|
56
|
+
"format": "prettier --write .",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:watch": "vitest",
|
|
59
|
+
"coverage": "vitest run --coverage"
|
|
60
|
+
}
|
|
61
|
+
}
|