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 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
+ [![build status](https://github.com/adhi-jp/rehype-slug-link/actions/workflows/ci.yml/badge.svg)](https://github.com/adhi-jp/rehype-slug-link/actions)
4
+ [![npm version](https://img.shields.io/npm/v/rehype-slug-link.svg)](https://www.npmjs.com/package/rehype-slug-link)
5
+ [![codecov](https://codecov.io/gh/adhi-jp/rehype-slug-link/graph/badge.svg?token=2NJE12TPJX)](https://codecov.io/gh/adhi-jp/rehype-slug-link)
6
+ [![bundle size](https://deno.bundlejs.com/?q=rehype-slug-link&badge)](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
+ }