@mdit-vue/shared 0.2.0 → 0.3.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/CHANGELOG.md +7 -0
- package/README.md +10 -0
- package/dist/index.cjs +106 -0
- package/dist/index.d.ts +45 -1
- package/dist/index.mjs +99 -0
- package/package.json +9 -3
- package/src/html-escape.ts +18 -0
- package/src/html-unescape.ts +23 -0
- package/src/index.ts +5 -0
- package/src/resolve-headers-from-tokens.ts +88 -0
- package/src/resolve-title-from-token.ts +52 -0
- package/src/slugify.ts +25 -0
- package/src/types.ts +7 -0
- package/tests/html-escape.spec.ts +14 -0
- package/tests/html-unescape.spec.ts +21 -0
- package/tests/slugify.spec.ts +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [0.3.0](https://github.com/mdit-vue/mdit-vue/compare/v0.2.0...v0.3.0) (2022-06-25)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- **plugin-toc:** add toc plugin ([e64cc2c](https://github.com/mdit-vue/mdit-vue/commit/e64cc2ce3bbc89999d373f8a00fb3a049d6af58f))
|
|
11
|
+
- **shared:** add html escape and unescape utils ([e1103c2](https://github.com/mdit-vue/mdit-vue/commit/e1103c21998aec77c67cdefd3cbb4994c127124c))
|
|
12
|
+
|
|
6
13
|
# [0.2.0](https://github.com/mdit-vue/mdit-vue/compare/v0.1.1...v0.2.0) (2022-06-23)
|
|
7
14
|
|
|
8
15
|
### Features
|
package/README.md
CHANGED
|
@@ -12,6 +12,16 @@ npm i @mdit-vue/shared
|
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
15
|
+
Add more properties to `env` via [TypeScript module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation):
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
declare module '@mdit-vue/shared' {
|
|
19
|
+
interface MarkdownItEnv {
|
|
20
|
+
foo?: string[];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
15
25
|
```ts
|
|
16
26
|
import MarkdownIt from 'markdown-it';
|
|
17
27
|
import type { MarkdownItEnv } from '@mdit-vue/shared';
|
package/dist/index.cjs
CHANGED
|
@@ -1,2 +1,108 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
const htmlEscapeMap = {
|
|
6
|
+
"&": "&",
|
|
7
|
+
"<": "<",
|
|
8
|
+
">": ">",
|
|
9
|
+
"'": "'",
|
|
10
|
+
'"': """
|
|
11
|
+
};
|
|
12
|
+
const htmlEscapeRegexp = /[&<>'"]/g;
|
|
13
|
+
const htmlEscape = (str) => str.replace(htmlEscapeRegexp, (char) => htmlEscapeMap[char]);
|
|
14
|
+
|
|
15
|
+
const htmlUnescapeMap = {
|
|
16
|
+
"&": "&",
|
|
17
|
+
"&": "&",
|
|
18
|
+
"<": "<",
|
|
19
|
+
"<": "<",
|
|
20
|
+
">": ">",
|
|
21
|
+
">": ">",
|
|
22
|
+
"'": "'",
|
|
23
|
+
"'": "'",
|
|
24
|
+
""": '"',
|
|
25
|
+
""": '"'
|
|
26
|
+
};
|
|
27
|
+
const htmlUnescapeRegexp = /&(amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g;
|
|
28
|
+
const htmlUnescape = (str) => str.replace(htmlUnescapeRegexp, (char) => htmlUnescapeMap[char]);
|
|
29
|
+
|
|
30
|
+
const resolveTitleFromToken = (token, {
|
|
31
|
+
shouldAllowHtml,
|
|
32
|
+
shouldEscapeText
|
|
33
|
+
}) => {
|
|
34
|
+
const children = token.children ?? [];
|
|
35
|
+
const titleTokenTypes = ["text", "emoji", "code_inline"];
|
|
36
|
+
if (shouldAllowHtml) {
|
|
37
|
+
titleTokenTypes.push("html_inline");
|
|
38
|
+
}
|
|
39
|
+
const titleTokens = children.filter((item) => titleTokenTypes.includes(item.type) && !item.meta?.isPermalinkSymbol);
|
|
40
|
+
return titleTokens.reduce((result, item) => {
|
|
41
|
+
if (shouldEscapeText) {
|
|
42
|
+
if (item.type === "code_inline" || item.type === "text") {
|
|
43
|
+
return `${result}${htmlEscape(item.content)}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return `${result}${item.content}`;
|
|
47
|
+
}, "").trim();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const resolveHeadersFromTokens = (tokens, {
|
|
51
|
+
level,
|
|
52
|
+
shouldAllowHtml,
|
|
53
|
+
shouldEscapeText,
|
|
54
|
+
slugify,
|
|
55
|
+
format
|
|
56
|
+
}) => {
|
|
57
|
+
const headers = [];
|
|
58
|
+
const stack = [];
|
|
59
|
+
const push = (header) => {
|
|
60
|
+
while (stack.length !== 0 && header.level <= stack[0].level) {
|
|
61
|
+
stack.shift();
|
|
62
|
+
}
|
|
63
|
+
if (stack.length === 0) {
|
|
64
|
+
headers.push(header);
|
|
65
|
+
stack.push(header);
|
|
66
|
+
} else {
|
|
67
|
+
stack[0].children.push(header);
|
|
68
|
+
stack.unshift(header);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
tokens.forEach((_, idx) => {
|
|
72
|
+
const token = tokens[idx];
|
|
73
|
+
if (token?.type !== "heading_open") {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const headerLevel = Number.parseInt(token.tag.slice(1), 10);
|
|
77
|
+
if (!level.includes(headerLevel)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const nextToken = tokens[idx + 1];
|
|
81
|
+
if (!nextToken) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const title = resolveTitleFromToken(nextToken, {
|
|
85
|
+
shouldAllowHtml,
|
|
86
|
+
shouldEscapeText
|
|
87
|
+
});
|
|
88
|
+
const slug = token.attrGet("id") ?? slugify(title);
|
|
89
|
+
push({
|
|
90
|
+
level: headerLevel,
|
|
91
|
+
title: format?.(title) ?? title,
|
|
92
|
+
slug,
|
|
93
|
+
children: []
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
return headers;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const rControl = /[\u0000-\u001f]/g;
|
|
100
|
+
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
|
|
101
|
+
const rCombining = /[\u0300-\u036F]/g;
|
|
102
|
+
const slugify = (str) => str.normalize("NFKD").replace(rCombining, "").replace(rControl, "").replace(rSpecial, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "").replace(/^(\d)/, "_$1").toLowerCase();
|
|
103
|
+
|
|
104
|
+
exports.htmlEscape = htmlEscape;
|
|
105
|
+
exports.htmlUnescape = htmlUnescape;
|
|
106
|
+
exports.resolveHeadersFromTokens = resolveHeadersFromTokens;
|
|
107
|
+
exports.resolveTitleFromToken = resolveTitleFromToken;
|
|
108
|
+
exports.slugify = slugify;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
|
+
import Token from 'markdown-it/lib/token';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Escape html chars
|
|
5
|
+
*/
|
|
6
|
+
declare const htmlEscape: (str: string) => string;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Unescape html chars
|
|
10
|
+
*/
|
|
11
|
+
declare const htmlUnescape: (str: string) => string;
|
|
12
|
+
|
|
1
13
|
interface MarkdownItEnv {
|
|
2
14
|
}
|
|
15
|
+
interface MarkdownItHeader {
|
|
16
|
+
level: number;
|
|
17
|
+
title: string;
|
|
18
|
+
slug: string;
|
|
19
|
+
children: MarkdownItHeader[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve headers from markdown-it tokens
|
|
24
|
+
*/
|
|
25
|
+
declare const resolveHeadersFromTokens: (tokens: Token[], { level, shouldAllowHtml, shouldEscapeText, slugify, format, }: {
|
|
26
|
+
level: number[];
|
|
27
|
+
shouldAllowHtml: boolean;
|
|
28
|
+
shouldEscapeText: boolean;
|
|
29
|
+
slugify: (str: string) => string;
|
|
30
|
+
format?: ((str: string) => string) | undefined;
|
|
31
|
+
}) => MarkdownItHeader[];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve header title from markdown-it token
|
|
35
|
+
*
|
|
36
|
+
* Typically using the next token of `heading_open` token
|
|
37
|
+
*/
|
|
38
|
+
declare const resolveTitleFromToken: (token: Token, { shouldAllowHtml, shouldEscapeText, }: {
|
|
39
|
+
shouldAllowHtml: boolean;
|
|
40
|
+
shouldEscapeText: boolean;
|
|
41
|
+
}) => string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Default slugification function
|
|
45
|
+
*/
|
|
46
|
+
declare const slugify: (str: string) => string;
|
|
3
47
|
|
|
4
|
-
export { MarkdownItEnv };
|
|
48
|
+
export { MarkdownItEnv, MarkdownItHeader, htmlEscape, htmlUnescape, resolveHeadersFromTokens, resolveTitleFromToken, slugify };
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1,100 @@
|
|
|
1
|
+
const htmlEscapeMap = {
|
|
2
|
+
"&": "&",
|
|
3
|
+
"<": "<",
|
|
4
|
+
">": ">",
|
|
5
|
+
"'": "'",
|
|
6
|
+
'"': """
|
|
7
|
+
};
|
|
8
|
+
const htmlEscapeRegexp = /[&<>'"]/g;
|
|
9
|
+
const htmlEscape = (str) => str.replace(htmlEscapeRegexp, (char) => htmlEscapeMap[char]);
|
|
1
10
|
|
|
11
|
+
const htmlUnescapeMap = {
|
|
12
|
+
"&": "&",
|
|
13
|
+
"&": "&",
|
|
14
|
+
"<": "<",
|
|
15
|
+
"<": "<",
|
|
16
|
+
">": ">",
|
|
17
|
+
">": ">",
|
|
18
|
+
"'": "'",
|
|
19
|
+
"'": "'",
|
|
20
|
+
""": '"',
|
|
21
|
+
""": '"'
|
|
22
|
+
};
|
|
23
|
+
const htmlUnescapeRegexp = /&(amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g;
|
|
24
|
+
const htmlUnescape = (str) => str.replace(htmlUnescapeRegexp, (char) => htmlUnescapeMap[char]);
|
|
25
|
+
|
|
26
|
+
const resolveTitleFromToken = (token, {
|
|
27
|
+
shouldAllowHtml,
|
|
28
|
+
shouldEscapeText
|
|
29
|
+
}) => {
|
|
30
|
+
const children = token.children ?? [];
|
|
31
|
+
const titleTokenTypes = ["text", "emoji", "code_inline"];
|
|
32
|
+
if (shouldAllowHtml) {
|
|
33
|
+
titleTokenTypes.push("html_inline");
|
|
34
|
+
}
|
|
35
|
+
const titleTokens = children.filter((item) => titleTokenTypes.includes(item.type) && !item.meta?.isPermalinkSymbol);
|
|
36
|
+
return titleTokens.reduce((result, item) => {
|
|
37
|
+
if (shouldEscapeText) {
|
|
38
|
+
if (item.type === "code_inline" || item.type === "text") {
|
|
39
|
+
return `${result}${htmlEscape(item.content)}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return `${result}${item.content}`;
|
|
43
|
+
}, "").trim();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const resolveHeadersFromTokens = (tokens, {
|
|
47
|
+
level,
|
|
48
|
+
shouldAllowHtml,
|
|
49
|
+
shouldEscapeText,
|
|
50
|
+
slugify,
|
|
51
|
+
format
|
|
52
|
+
}) => {
|
|
53
|
+
const headers = [];
|
|
54
|
+
const stack = [];
|
|
55
|
+
const push = (header) => {
|
|
56
|
+
while (stack.length !== 0 && header.level <= stack[0].level) {
|
|
57
|
+
stack.shift();
|
|
58
|
+
}
|
|
59
|
+
if (stack.length === 0) {
|
|
60
|
+
headers.push(header);
|
|
61
|
+
stack.push(header);
|
|
62
|
+
} else {
|
|
63
|
+
stack[0].children.push(header);
|
|
64
|
+
stack.unshift(header);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
tokens.forEach((_, idx) => {
|
|
68
|
+
const token = tokens[idx];
|
|
69
|
+
if (token?.type !== "heading_open") {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const headerLevel = Number.parseInt(token.tag.slice(1), 10);
|
|
73
|
+
if (!level.includes(headerLevel)) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const nextToken = tokens[idx + 1];
|
|
77
|
+
if (!nextToken) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const title = resolveTitleFromToken(nextToken, {
|
|
81
|
+
shouldAllowHtml,
|
|
82
|
+
shouldEscapeText
|
|
83
|
+
});
|
|
84
|
+
const slug = token.attrGet("id") ?? slugify(title);
|
|
85
|
+
push({
|
|
86
|
+
level: headerLevel,
|
|
87
|
+
title: format?.(title) ?? title,
|
|
88
|
+
slug,
|
|
89
|
+
children: []
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
return headers;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const rControl = /[\u0000-\u001f]/g;
|
|
96
|
+
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
|
|
97
|
+
const rCombining = /[\u0300-\u036F]/g;
|
|
98
|
+
const slugify = (str) => str.normalize("NFKD").replace(rCombining, "").replace(rControl, "").replace(rSpecial, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "").replace(/^(\d)/, "_$1").toLowerCase();
|
|
99
|
+
|
|
100
|
+
export { htmlEscape, htmlUnescape, resolveHeadersFromTokens, resolveTitleFromToken, slugify };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mdit-vue/shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shared utils and types of mdit-vue",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown-it",
|
|
@@ -30,11 +30,17 @@
|
|
|
30
30
|
"build": "unbuild",
|
|
31
31
|
"test": "vitest"
|
|
32
32
|
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@types/markdown-it": "^12.2.3",
|
|
35
|
+
"markdown-it": "^13.0.1"
|
|
36
|
+
},
|
|
33
37
|
"devDependencies": {
|
|
34
|
-
"
|
|
38
|
+
"c8": "^7.11.3",
|
|
39
|
+
"unbuild": "^0.7.4",
|
|
40
|
+
"vitest": "^0.15.2"
|
|
35
41
|
},
|
|
36
42
|
"publishConfig": {
|
|
37
43
|
"access": "public"
|
|
38
44
|
},
|
|
39
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "bcbfd4705d747ee71d6c7c32f8be6155261e9c90"
|
|
40
46
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const htmlEscapeMap = {
|
|
2
|
+
'&': '&',
|
|
3
|
+
'<': '<',
|
|
4
|
+
'>': '>',
|
|
5
|
+
"'": ''',
|
|
6
|
+
'"': '"',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const htmlEscapeRegexp = /[&<>'"]/g;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Escape html chars
|
|
13
|
+
*/
|
|
14
|
+
export const htmlEscape = (str: string): string =>
|
|
15
|
+
str.replace(
|
|
16
|
+
htmlEscapeRegexp,
|
|
17
|
+
(char) => htmlEscapeMap[char as keyof typeof htmlEscapeMap],
|
|
18
|
+
);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const htmlUnescapeMap = {
|
|
2
|
+
'&': '&',
|
|
3
|
+
'&': '&',
|
|
4
|
+
'<': '<',
|
|
5
|
+
'<': '<',
|
|
6
|
+
'>': '>',
|
|
7
|
+
'>': '>',
|
|
8
|
+
''': "'",
|
|
9
|
+
''': "'",
|
|
10
|
+
'"': '"',
|
|
11
|
+
'"': '"',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const htmlUnescapeRegexp = /&(amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Unescape html chars
|
|
18
|
+
*/
|
|
19
|
+
export const htmlUnescape = (str: string): string =>
|
|
20
|
+
str.replace(
|
|
21
|
+
htmlUnescapeRegexp,
|
|
22
|
+
(char) => htmlUnescapeMap[char as keyof typeof htmlUnescapeMap],
|
|
23
|
+
);
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type Token from 'markdown-it/lib/token';
|
|
2
|
+
import { resolveTitleFromToken } from './resolve-title-from-token';
|
|
3
|
+
import type { MarkdownItHeader } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve headers from markdown-it tokens
|
|
7
|
+
*/
|
|
8
|
+
export const resolveHeadersFromTokens = (
|
|
9
|
+
tokens: Token[],
|
|
10
|
+
{
|
|
11
|
+
level,
|
|
12
|
+
shouldAllowHtml,
|
|
13
|
+
shouldEscapeText,
|
|
14
|
+
slugify,
|
|
15
|
+
format,
|
|
16
|
+
}: {
|
|
17
|
+
level: number[];
|
|
18
|
+
shouldAllowHtml: boolean;
|
|
19
|
+
shouldEscapeText: boolean;
|
|
20
|
+
slugify: (str: string) => string;
|
|
21
|
+
format?: (str: string) => string;
|
|
22
|
+
},
|
|
23
|
+
): MarkdownItHeader[] => {
|
|
24
|
+
// store the result of headers
|
|
25
|
+
const headers: MarkdownItHeader[] = [];
|
|
26
|
+
|
|
27
|
+
// a temp headers stack for generating the headers tree
|
|
28
|
+
const stack: MarkdownItHeader[] = [];
|
|
29
|
+
|
|
30
|
+
// push a header to the headers tree
|
|
31
|
+
const push = (header: MarkdownItHeader): void => {
|
|
32
|
+
while (stack.length !== 0 && header.level <= stack[0].level) {
|
|
33
|
+
stack.shift();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (stack.length === 0) {
|
|
37
|
+
headers.push(header);
|
|
38
|
+
stack.push(header);
|
|
39
|
+
} else {
|
|
40
|
+
stack[0].children.push(header);
|
|
41
|
+
stack.unshift(header);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
tokens.forEach((_, idx) => {
|
|
46
|
+
const token = tokens[idx];
|
|
47
|
+
|
|
48
|
+
// if the token type does not match, skip
|
|
49
|
+
if (token?.type !== 'heading_open') {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// get the level from the tag, h1 -> 1
|
|
54
|
+
const headerLevel = Number.parseInt(token.tag.slice(1), 10);
|
|
55
|
+
|
|
56
|
+
// if the level should not be extracted, skip
|
|
57
|
+
if (!level.includes(headerLevel)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// the next token of 'heading_open' contains the heading content
|
|
62
|
+
const nextToken = tokens[idx + 1];
|
|
63
|
+
|
|
64
|
+
// if the next token does not exist, skip
|
|
65
|
+
if (!nextToken) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const title = resolveTitleFromToken(nextToken, {
|
|
70
|
+
shouldAllowHtml,
|
|
71
|
+
shouldEscapeText,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// the id of the heading anchor is the slugify result of markdown-it-anchor
|
|
75
|
+
// if the id does not exist, slugify the title ourselves
|
|
76
|
+
const slug = token.attrGet('id') ?? slugify(title);
|
|
77
|
+
|
|
78
|
+
// push the header to tree
|
|
79
|
+
push({
|
|
80
|
+
level: headerLevel,
|
|
81
|
+
title: format?.(title) ?? title,
|
|
82
|
+
slug,
|
|
83
|
+
children: [],
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return headers;
|
|
88
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type Token from 'markdown-it/lib/token';
|
|
2
|
+
import { htmlEscape } from './html-escape';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve header title from markdown-it token
|
|
6
|
+
*
|
|
7
|
+
* Typically using the next token of `heading_open` token
|
|
8
|
+
*/
|
|
9
|
+
export const resolveTitleFromToken = (
|
|
10
|
+
token: Token,
|
|
11
|
+
{
|
|
12
|
+
shouldAllowHtml,
|
|
13
|
+
shouldEscapeText,
|
|
14
|
+
}: {
|
|
15
|
+
shouldAllowHtml: boolean;
|
|
16
|
+
shouldEscapeText: boolean;
|
|
17
|
+
},
|
|
18
|
+
): string => {
|
|
19
|
+
// children of the token contains the parsed result of the heading title
|
|
20
|
+
const children = token.children ?? [];
|
|
21
|
+
|
|
22
|
+
// type of tokens to be included in the heading title
|
|
23
|
+
const titleTokenTypes = ['text', 'emoji', 'code_inline'];
|
|
24
|
+
|
|
25
|
+
// include 'html_inline' or not
|
|
26
|
+
if (shouldAllowHtml) {
|
|
27
|
+
titleTokenTypes.push('html_inline');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// filter the token type to be included in the title
|
|
31
|
+
const titleTokens = children.filter(
|
|
32
|
+
(item) =>
|
|
33
|
+
titleTokenTypes.includes(item.type) &&
|
|
34
|
+
// filter permalink symbol that generated by markdown-it-anchor
|
|
35
|
+
!item.meta?.isPermalinkSymbol,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// get title from tokens
|
|
39
|
+
return titleTokens
|
|
40
|
+
.reduce((result, item) => {
|
|
41
|
+
if (shouldEscapeText) {
|
|
42
|
+
// escape the content of 'code_inline' and 'text'
|
|
43
|
+
if (item.type === 'code_inline' || item.type === 'text') {
|
|
44
|
+
return `${result}${htmlEscape(item.content)}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// keep the content of 'emoji' and 'html_inline'
|
|
49
|
+
return `${result}${item.content}`;
|
|
50
|
+
}, '')
|
|
51
|
+
.trim();
|
|
52
|
+
};
|
package/src/slugify.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// eslint-disable-next-line no-control-regex
|
|
2
|
+
const rControl = /[\u0000-\u001f]/g;
|
|
3
|
+
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
|
|
4
|
+
const rCombining = /[\u0300-\u036F]/g;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default slugification function
|
|
8
|
+
*/
|
|
9
|
+
export const slugify = (str: string): string =>
|
|
10
|
+
str
|
|
11
|
+
.normalize('NFKD')
|
|
12
|
+
// Remove accents
|
|
13
|
+
.replace(rCombining, '')
|
|
14
|
+
// Remove control characters
|
|
15
|
+
.replace(rControl, '')
|
|
16
|
+
// Replace special characters
|
|
17
|
+
.replace(rSpecial, '-')
|
|
18
|
+
// Remove continuos separators
|
|
19
|
+
.replace(/-{2,}/g, '-')
|
|
20
|
+
// Remove prefixing and trailing separators
|
|
21
|
+
.replace(/^-+|-+$/g, '')
|
|
22
|
+
// ensure it doesn't start with a number (#121)
|
|
23
|
+
.replace(/^(\d)/, '_$1')
|
|
24
|
+
// lowercase
|
|
25
|
+
.toLowerCase();
|
package/src/types.ts
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { htmlEscape } from '../src';
|
|
3
|
+
|
|
4
|
+
const testCases = [['&<>\'"', '&<>'"']];
|
|
5
|
+
|
|
6
|
+
describe('shared > html-escape', () => {
|
|
7
|
+
describe('should escape special chars', () => {
|
|
8
|
+
testCases.forEach(([source, expected]) => {
|
|
9
|
+
it(source, () => {
|
|
10
|
+
expect(htmlEscape(source)).toBe(expected);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { htmlUnescape } from '../src';
|
|
3
|
+
|
|
4
|
+
const testCases = [
|
|
5
|
+
['&&<<>>''""', '&&<<>>\'\'""'],
|
|
6
|
+
['&lt;', '<'],
|
|
7
|
+
[
|
|
8
|
+
'&lt;script&gt;&lt;/script&gt;',
|
|
9
|
+
'<script></script>',
|
|
10
|
+
],
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
describe('shared > html-unescape', () => {
|
|
14
|
+
describe('should unescape special chars', () => {
|
|
15
|
+
testCases.forEach(([source, expected]) => {
|
|
16
|
+
it(source, () => {
|
|
17
|
+
expect(htmlUnescape(source)).toBe(expected);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { slugify } from '../src';
|
|
3
|
+
|
|
4
|
+
const testCases: [string, string][] = [
|
|
5
|
+
['Привет', 'привет'],
|
|
6
|
+
['Лед üäöß', 'лед-uaoß'],
|
|
7
|
+
['hangul 가', 'hangul-가'],
|
|
8
|
+
['ع', 'ع'],
|
|
9
|
+
['džℍΩ', 'dzhω'],
|
|
10
|
+
['カi⁹', 'カi9'],
|
|
11
|
+
// ㌀ -> アパート'
|
|
12
|
+
['㌀', decodeURIComponent('%E3%82%A2%E3%83%8F%E3%82%9A%E3%83%BC%E3%83%88')],
|
|
13
|
+
['¼', '_1⁄4'],
|
|
14
|
+
['džℍΩカi⁹¼', 'dzhωカi91⁄4'],
|
|
15
|
+
['Iлtèrnåtïonɑlíƶatï߀ԉ', 'iлternationɑliƶati߀ԉ'],
|
|
16
|
+
[
|
|
17
|
+
'Båcòл ípѕùm ðoɭ߀r ѕït aϻèt âùþê aԉᏧ߀üïlɭê ƃëéf culρá fïlèt ϻiǥnòn cuρiᏧatat ut êлim tòлɢùê.',
|
|
18
|
+
'bacoл-ipѕum-ðoɭ߀r-ѕit-aϻet-auþe-aԉꮷ߀uilɭe-ƃeef-culρa-filet-ϻiǥnon-cuρiꮷatat-ut-eлim-toлɢue',
|
|
19
|
+
],
|
|
20
|
+
['ᴎᴑᴅᴇȷʂ', 'ᴎᴑᴅᴇȷʂ'],
|
|
21
|
+
['hambúrguer', 'hamburguer'],
|
|
22
|
+
['hŒllœ', 'hœllœ'],
|
|
23
|
+
['Fußball', 'fußball'],
|
|
24
|
+
['ABCDEFGHIJKLMNOPQRSTUVWXYZé', 'abcdefghijklmnopqrstuvwxyze'],
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe('shared > slugify', () => {
|
|
28
|
+
describe('should slugify string correctly', () => {
|
|
29
|
+
testCases.forEach(([source, expected]) => {
|
|
30
|
+
it(`${source} => ${expected}`, () => {
|
|
31
|
+
expect(slugify(source)).toBe(expected);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
});
|