@uniquedivine/ud-mdtoc 0.0.0-stage → 0.1.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 +22 -0
- package/README.md +155 -2
- package/package.json +90 -4
- package/src/cli.ts +152 -0
- package/src/mdtoc/generate.ts +421 -0
- package/src/mdtoc/index.ts +13 -0
- package/src/mdtoc/insert.ts +125 -0
- package/src/mdtoc/options.ts +73 -0
- package/src/mdtoc/remarkable.ts +168 -0
- package/src/mdtoc/toc.ts +41 -0
- package/src/vendor.d.ts +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2014-present, Jon Schlinkert.
|
|
4
|
+
Copyright (c) 2023-present, Unique Divine.
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in
|
|
14
|
+
all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
22
|
+
THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,156 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ud-mdtoc
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Generate a Markdown table of contents from headings, or update one between
|
|
4
|
+
`<!-- toc -->` and `<!-- tocstop -->` markers. Package
|
|
5
|
+
`@uniquedivine/ud-mdtoc` installs command `ud-mdtoc` and supports the `ud`
|
|
6
|
+
executable plugin interface.
|
|
7
|
+
|
|
8
|
+
Requires Bun 1.2 or later on Linux, macOS, or WSL. The executable uses
|
|
9
|
+
`env -S bun --no-install`, so missing dependencies fail instead of loading
|
|
10
|
+
packages from Bun's runtime auto-install cache.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
bun add --global @uniquedivine/ud-mdtoc@0.1.0
|
|
16
|
+
ud-mdtoc --version
|
|
17
|
+
ud-mdtoc --help
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The package ships TypeScript source and declares its runtime dependencies.
|
|
21
|
+
Installing from npm installs those dependencies independently of a Jiyuu checkout.
|
|
22
|
+
The separate upstream command `markdown-toc` can remain installed alongside it.
|
|
23
|
+
|
|
24
|
+
## Generate a Table of Contents
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
ud-mdtoc notes.md
|
|
28
|
+
ud-mdtoc 'notes with spaces.md' --bullets=- --maxdepth=3 --no-firsth1
|
|
29
|
+
printf '# Title\n\n## First\n\n### Second\n' |
|
|
30
|
+
ud-mdtoc - --bullets=- --maxdepth=3 --no-firsth1
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The last command prints:
|
|
34
|
+
|
|
35
|
+
```markdown
|
|
36
|
+
- [First](#first)
|
|
37
|
+
- [Second](#second)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Without `--bullets`, the list markers rotate through `-`, `*`, and `+` by
|
|
41
|
+
depth. `--maxdepth` limits the absolute heading level. `--no-firsth1` omits
|
|
42
|
+
the initial level-one title. Headings inside fenced code blocks are ignored.
|
|
43
|
+
|
|
44
|
+
## Update a File
|
|
45
|
+
|
|
46
|
+
Place an opening marker before the headings to list:
|
|
47
|
+
|
|
48
|
+
```markdown
|
|
49
|
+
# Notes
|
|
50
|
+
|
|
51
|
+
<!-- toc -->
|
|
52
|
+
|
|
53
|
+
## First
|
|
54
|
+
|
|
55
|
+
### Second
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then run:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
ud-mdtoc notes.md -i --bullets=- --maxdepth=3 --no-firsth1
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The command inserts the TOC and a closing `<!-- tocstop -->` marker. Later runs
|
|
65
|
+
replace the content between the markers. Only one TOC is supported per file.
|
|
66
|
+
The command writes the input file directly, so save editor changes first.
|
|
67
|
+
|
|
68
|
+
For Neovim, commands `:Toc` and `:TocCopy` are configured in the owner's dotfiles.
|
|
69
|
+
They use the installed command with these same bullet and heading flags.
|
|
70
|
+
|
|
71
|
+
## Options
|
|
72
|
+
|
|
73
|
+
| Option | Behavior |
|
|
74
|
+
| ----------------------------- | ------------------------------------------------ |
|
|
75
|
+
| `-i` | Update the input file at its TOC marker |
|
|
76
|
+
| `--json` | Print parsed heading metadata as JSON |
|
|
77
|
+
| `--bullets=-` | Use one marker at every depth |
|
|
78
|
+
| `--bullets '*' --bullets '-'` | Rotate the specified markers by depth |
|
|
79
|
+
| `--maxdepth=3` | Render heading levels one through three |
|
|
80
|
+
| `--no-firsth1` | Omit the first level-one heading |
|
|
81
|
+
| `--no-stripHeadingTags` | Preserve HTML tags when generating heading slugs |
|
|
82
|
+
| `--indent=' '` | Use four spaces per nesting level |
|
|
83
|
+
| `--append=TEXT` | Append text to the rendered TOC |
|
|
84
|
+
| `-h`, `--help` | Show usage |
|
|
85
|
+
| `-v`, `--version` | Show the installed version |
|
|
86
|
+
| `--plugin-info` | Print the `ud` plugin metadata as JSON |
|
|
87
|
+
|
|
88
|
+
Use `-` as the input filename to read stdin. In-place mode requires a file and
|
|
89
|
+
cannot be combined with `--json`. JSON exposes parsed heading metadata; the
|
|
90
|
+
rendered TOC's depth limit does not filter that metadata.
|
|
91
|
+
|
|
92
|
+
## Use as a ud Plugin
|
|
93
|
+
|
|
94
|
+
The `ud` dispatcher discovers executable plugins in its XDG plugin directory,
|
|
95
|
+
not on the general `PATH`. Register the installed command:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
plugin_dir="${XDG_DATA_HOME:-$HOME/.local/share}/ud/plugins"
|
|
99
|
+
mkdir -p "$plugin_dir"
|
|
100
|
+
ln -sfn "$(bun pm bin --global)/ud-mdtoc" "$plugin_dir/ud-mdtoc"
|
|
101
|
+
ud plugin info mdtoc
|
|
102
|
+
ud mdtoc --help
|
|
103
|
+
ud mdtoc notes.md --bullets=- --maxdepth=3 --no-firsth1
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The owner's dotfiles restore this link through `just sync` and inspect it through
|
|
107
|
+
`just health`.
|
|
108
|
+
|
|
109
|
+
## Develop and Validate
|
|
110
|
+
|
|
111
|
+
From this directory:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
just install
|
|
115
|
+
just test
|
|
116
|
+
just typecheck
|
|
117
|
+
just lint
|
|
118
|
+
just smoke-test
|
|
119
|
+
just validate
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`just smoke-test` packs the publication files, installs the tarball into a
|
|
123
|
+
disposable Bun global directory, and runs the installed command outside the
|
|
124
|
+
checkout. It checks metadata, stdin, filenames containing spaces, repeated
|
|
125
|
+
in-place updates, and failure after deleting a runtime dependency. It does not
|
|
126
|
+
replace the workstation's installed package.
|
|
127
|
+
|
|
128
|
+
`just validate` runs the tests, type checking, package lint, tarball smoke test,
|
|
129
|
+
and npm publication dry run. Type checking uses native `bun check`.
|
|
130
|
+
|
|
131
|
+
## Publish
|
|
132
|
+
|
|
133
|
+
Publishing follows the `tg-sai` workflow with pinned `npm@12.2.0`. Commands use
|
|
134
|
+
Bun, Just, `jq`, and `rg`. Run the checks before the publication command:
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
just validate
|
|
138
|
+
bunx npm@12.2.0 login --auth-type=web
|
|
139
|
+
just publish
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`just publish` verifies npm login, rejects a version that is already published,
|
|
143
|
+
and repeats the npm publication dry run. It then prints the browser
|
|
144
|
+
confirmation URL for the owner to complete 2FA. Publishing is explicit and is
|
|
145
|
+
never part of `just sync`.
|
|
146
|
+
|
|
147
|
+
For later releases, edit the version in `package.json`, run `just validate`,
|
|
148
|
+
commit the release changes, and run `just publish`. After publication, update
|
|
149
|
+
the exact version in the owner's dotfiles Bun manifest and refresh its lockfile.
|
|
150
|
+
|
|
151
|
+
## Attribution
|
|
152
|
+
|
|
153
|
+
This project is a TypeScript fork of
|
|
154
|
+
[Jon Schlinkert's markdown-toc](https://github.com/jonschlinkert/markdown-toc).
|
|
155
|
+
It retains the original contributors in `package.json` and the MIT license in
|
|
156
|
+
[LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,92 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniquedivine/ud-mdtoc",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"description": "Generate Markdown tables of contents with Bun or the ud command router.",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"homepage": "https://github.com/Unique-Divine/jiyuu/tree/main/mdtoc",
|
|
6
|
+
"author": "Unique Divine (https://github.com/Unique-Divine)",
|
|
7
|
+
"contributors": [
|
|
8
|
+
"Jon Schlinkert (http://twitter.com/jonschlinkert)",
|
|
9
|
+
"=^._.^= (http://maxogden.com)",
|
|
10
|
+
"Andrés (http://angrykoala.github.io)",
|
|
11
|
+
"Benjamin Schmitz (https://github.com/Vortex375)",
|
|
12
|
+
"Christian Raunitschka (raunitschka.de)",
|
|
13
|
+
"Daniel Chen (http://chendaniely.github.io)",
|
|
14
|
+
"Daniel Mietchen (http://about.me/daniel.mietchen)",
|
|
15
|
+
"David Mohl (https://dvcrn.github.io)",
|
|
16
|
+
"Federico Soave (https://github.com/Feder1co5oave)",
|
|
17
|
+
"Gary Green (https://github.com/garygreen)",
|
|
18
|
+
"Josh Duff (https://tehshrike.github.io)",
|
|
19
|
+
"Matt Ellis (http://sticklebackplastic.com)",
|
|
20
|
+
"Nicolas Morel (https://github.com/Marsup)",
|
|
21
|
+
"Rafael Steil (http://rafaelsteil.com)",
|
|
22
|
+
"Richard Bradley (https://github.com/RichardBradley)",
|
|
23
|
+
"Seth Vincent (https://sethvincent.com)",
|
|
24
|
+
"Stefan Walther (http://qliksite.io)",
|
|
25
|
+
"Tao Wang (https://github.com/twang2218)",
|
|
26
|
+
"(https://github.com/lu22do)",
|
|
27
|
+
"Zeke Sikelianos (http://zeke.sikelianos.com)"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/Unique-Divine/jiyuu.git",
|
|
32
|
+
"directory": "mdtoc"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/Unique-Divine/jiyuu/issues"
|
|
36
|
+
},
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"files": [
|
|
39
|
+
"src/**/*.ts",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"main": "src/mdtoc/index.ts",
|
|
44
|
+
"bin": {
|
|
45
|
+
"ud-mdtoc": "src/cli.ts"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"bun": ">=1.2.0"
|
|
49
|
+
},
|
|
50
|
+
"type": "module",
|
|
51
|
+
"scripts": {
|
|
52
|
+
"test": "bun test",
|
|
53
|
+
"typecheck": "bun check"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"concat-stream": "^1.5.2",
|
|
57
|
+
"diacritics-map": "^0.1.0",
|
|
58
|
+
"gray-matter": "^3.1.1",
|
|
59
|
+
"lazy-cache": "^2.0.2",
|
|
60
|
+
"list-item": "^1.1.1",
|
|
61
|
+
"markdown-link": "^0.1.1",
|
|
62
|
+
"minimist": "^1.2.0",
|
|
63
|
+
"mixin-deep": "^1.1.3",
|
|
64
|
+
"object.pick": "^1.2.0",
|
|
65
|
+
"remarkable": "^2.0.1",
|
|
66
|
+
"repeat-string": "^1.6.1",
|
|
67
|
+
"strip-color": "^0.1.0"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@types/bun": "^1.4.3",
|
|
71
|
+
"@types/concat-stream": "^2.0.3",
|
|
72
|
+
"@types/minimist": "^1.2.5",
|
|
73
|
+
"@types/remarkable": "^2.0.8",
|
|
74
|
+
"@types/strip-color": "^0.1.2",
|
|
75
|
+
"globals": "^15.4.0",
|
|
76
|
+
"gulp-format-md": "^2.0.0",
|
|
77
|
+
"jest": "^29.7.0",
|
|
78
|
+
"prettier": "^3.4.2",
|
|
79
|
+
"typescript": "^5.2.2"
|
|
80
|
+
},
|
|
81
|
+
"keywords": [
|
|
82
|
+
"markdown",
|
|
83
|
+
"table-of-contents",
|
|
84
|
+
"toc",
|
|
85
|
+
"bun",
|
|
86
|
+
"ud-plugin"
|
|
87
|
+
],
|
|
88
|
+
"private": false,
|
|
89
|
+
"publishConfig": {
|
|
90
|
+
"access": "public"
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env -S bun --no-install
|
|
2
|
+
|
|
3
|
+
import * as fs from "fs"
|
|
4
|
+
import { Readable } from "stream"
|
|
5
|
+
import { toc, minimist, concat, Toc } from "./mdtoc"
|
|
6
|
+
import { type TocResult } from "./mdtoc/toc"
|
|
7
|
+
import { version } from "../package.json"
|
|
8
|
+
// const utils = require("./lib/utils")
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse command-line arguments (`process.argv`) from index 2 into `args` using the
|
|
12
|
+
* `minimist` package. Depending on the command-line args, the script outputs
|
|
13
|
+
* the generated table of contents or JSON representation to stdout. If the -i
|
|
14
|
+
* flag is specified, the modified Markdown content is written back to the input
|
|
15
|
+
* file inplace. */
|
|
16
|
+
const args = minimist(process.argv.slice(2), {
|
|
17
|
+
// boolean flags: "-i", "--json", ...
|
|
18
|
+
boolean: [
|
|
19
|
+
"i",
|
|
20
|
+
"json",
|
|
21
|
+
"firsth1",
|
|
22
|
+
"stripHeadingTags",
|
|
23
|
+
"help",
|
|
24
|
+
"version",
|
|
25
|
+
"plugin-info",
|
|
26
|
+
],
|
|
27
|
+
alias: { h: "help", v: "version" },
|
|
28
|
+
// string flags: Flags that take string args
|
|
29
|
+
string: ["append", "bullets", "indent"],
|
|
30
|
+
// default values for flags
|
|
31
|
+
default: {
|
|
32
|
+
firsth1: true,
|
|
33
|
+
stripHeadingTags: true,
|
|
34
|
+
},
|
|
35
|
+
}) as CmdRootArgs
|
|
36
|
+
|
|
37
|
+
type CmdRootArgs = {
|
|
38
|
+
help: boolean
|
|
39
|
+
version: boolean
|
|
40
|
+
"plugin-info": boolean
|
|
41
|
+
/** i: Toggles whether to write back to the input file in-place. Cannot be used
|
|
42
|
+
* with --json. */
|
|
43
|
+
i: boolean
|
|
44
|
+
/** json: Toggles whether to print the TOC in JSON format */
|
|
45
|
+
json: boolean
|
|
46
|
+
/** firsth1: Toggles whether to include the first `<h1>` element in the TOC.
|
|
47
|
+
* This first heading is often exlcuded and assumed to be the title of the
|
|
48
|
+
* Markdown document. */
|
|
49
|
+
firsth1: boolean
|
|
50
|
+
stripHeadingTags: boolean
|
|
51
|
+
append: string
|
|
52
|
+
/** bullets: Characters to use for bullet points at each depth. */
|
|
53
|
+
bullets: string | string[]
|
|
54
|
+
indent: string
|
|
55
|
+
_: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (args["plugin-info"]) {
|
|
59
|
+
console.log(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
apiVersion: 1,
|
|
62
|
+
name: "mdtoc",
|
|
63
|
+
description: "Generate Markdown tables of contents",
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
process.exit(0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (args.version) {
|
|
70
|
+
console.log(`ud-mdtoc ${version}`)
|
|
71
|
+
process.exit(0)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// If the number of positional arguments is not exactly 1, the script exits with
|
|
75
|
+
// a failure status code and prints usage.
|
|
76
|
+
if (args.help || args._.length !== 1) {
|
|
77
|
+
console.info(
|
|
78
|
+
[
|
|
79
|
+
"Usage: ud-mdtoc [options] <input> ",
|
|
80
|
+
"",
|
|
81
|
+
" -h, --help: Show usage",
|
|
82
|
+
" -v, --version: Show the installed version",
|
|
83
|
+
" --plugin-info: Print ud plugin metadata as JSON",
|
|
84
|
+
"",
|
|
85
|
+
" input: The Markdown file to parse for table of contents,",
|
|
86
|
+
' or "-" to read from stdin.',
|
|
87
|
+
"",
|
|
88
|
+
" -i: Edit the <input> file directly, injecting the TOC at <!-- toc -->",
|
|
89
|
+
" (Without this flag, the default is to print the TOC to stdout.)",
|
|
90
|
+
"",
|
|
91
|
+
" --json: Print the TOC in JSON format",
|
|
92
|
+
"",
|
|
93
|
+
" --append: Append a string to the end of the TOC",
|
|
94
|
+
"",
|
|
95
|
+
" --bullets: Bullets to use for items in the generated TOC",
|
|
96
|
+
' (Supports multiple bullets: --bullets "*" --bullets "-" --bullets "+")',
|
|
97
|
+
' (Default is "-", "*", "+" by depth.)',
|
|
98
|
+
"",
|
|
99
|
+
" --maxdepth: Use headings whose depth is at most maxdepth",
|
|
100
|
+
" (Default is 6.)",
|
|
101
|
+
"",
|
|
102
|
+
" --no-firsth1: Omit the first h1-level heading from the TOC",
|
|
103
|
+
"",
|
|
104
|
+
" --no-stripHeadingTags: Do not strip extraneous HTML tags from heading",
|
|
105
|
+
" text before slugifying",
|
|
106
|
+
"",
|
|
107
|
+
" --indent: Provide the indentation to use - defaults to ' '",
|
|
108
|
+
" (to specify a tab, use the bash-escaped $'\\t')",
|
|
109
|
+
].join("\n"),
|
|
110
|
+
)
|
|
111
|
+
process.exit(args.help ? 0 : 1)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (args.i && args.json) {
|
|
115
|
+
console.error("ud-mdtoc: you cannot use both --json and -i")
|
|
116
|
+
process.exit(1)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (args.i && args._[0] === "-") {
|
|
120
|
+
console.error('ud-mdtoc: you cannot use -i with "-" (stdin) for input')
|
|
121
|
+
process.exit(1)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `process.stdin` is a `tty.ReadStream` and `fs.createReadStream` returns an
|
|
126
|
+
* `fs.ReadStream`, but both extend `stream.Readable`, so we type the variable
|
|
127
|
+
* with the shared base class.
|
|
128
|
+
* */
|
|
129
|
+
let input: Readable = process.stdin
|
|
130
|
+
if (args._[0] !== "-") input = fs.createReadStream(args._[0])
|
|
131
|
+
|
|
132
|
+
input.pipe(
|
|
133
|
+
concat((input: Buffer) => {
|
|
134
|
+
if (args.i) {
|
|
135
|
+
const newMarkdown = Toc.insert(input.toString(), args)
|
|
136
|
+
fs.writeFileSync(args._[0], newMarkdown)
|
|
137
|
+
} else {
|
|
138
|
+
const parsed = toc(input.toString(), args)
|
|
139
|
+
writeToStdOut(parsed, args.json)
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
input.on("error", (err) => {
|
|
145
|
+
console.error(err)
|
|
146
|
+
process.exit(1)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
function writeToStdOut(parsed: TocResult, useJson: boolean) {
|
|
150
|
+
if (useJson) return console.log(JSON.stringify(parsed.json, null, " "))
|
|
151
|
+
process.stdout.write(parsed.content)
|
|
152
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mdtoc/generate.ts
|
|
3
|
+
*
|
|
4
|
+
* markdown-toc <https://github.com/Unique-Divine/jiyuu/mdtoc>
|
|
5
|
+
*
|
|
6
|
+
* Forked from legacy code:
|
|
7
|
+
* - markdown-toc <https://github.com/jonschlinkert/markdown-toc>
|
|
8
|
+
*
|
|
9
|
+
* Released under the MIT License.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as querystring from "querystring" // NOTE querystring API is deprecated
|
|
13
|
+
import markdownLink from "markdown-link"
|
|
14
|
+
import listitem from "list-item"
|
|
15
|
+
import { RemarkablePlus } from "./remarkable"
|
|
16
|
+
import { slugify, type SlugifyOptions, getTitle } from "./options"
|
|
17
|
+
import { insert } from "./insert"
|
|
18
|
+
|
|
19
|
+
export { insert }
|
|
20
|
+
|
|
21
|
+
export { slugify }
|
|
22
|
+
|
|
23
|
+
export interface GenerateOptions extends SlugifyOptions {
|
|
24
|
+
firsth1?: boolean
|
|
25
|
+
maxdepth?: number
|
|
26
|
+
linkify?: boolean | LinkifyFn
|
|
27
|
+
num?: number
|
|
28
|
+
strip?: boolean | string[] | StripFn
|
|
29
|
+
titleize?: boolean | TitleizeFn
|
|
30
|
+
// Add other possible options here...
|
|
31
|
+
hLevel?: number
|
|
32
|
+
bullets?: string | string[]
|
|
33
|
+
chars?: string | string[]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* filter (`FilterFn`): returns truthy to include the item, falsy to exclude it.
|
|
37
|
+
* The return value is checked for truthiness, not used as a replacement.
|
|
38
|
+
* */
|
|
39
|
+
filter?: FilterFn
|
|
40
|
+
append?: string
|
|
41
|
+
highest?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type LinkifyFn = (
|
|
45
|
+
tok: Token,
|
|
46
|
+
text: string,
|
|
47
|
+
slug: string,
|
|
48
|
+
options: GenerateOptions,
|
|
49
|
+
) => string
|
|
50
|
+
|
|
51
|
+
type StripFn = (str: string, opts?: GenerateOptions) => string
|
|
52
|
+
|
|
53
|
+
type TitleizeFn = (str: string, options?: GenerateOptions) => string
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* FilterFn: returns truthy to include the item, falsy to exclude it.
|
|
57
|
+
* The return value is checked for truthiness, not used as a replacement.
|
|
58
|
+
* */
|
|
59
|
+
export type FilterFn = (content: string, ele: Token, arr: Token[]) => string
|
|
60
|
+
|
|
61
|
+
export interface Token {
|
|
62
|
+
content: string
|
|
63
|
+
type?: string
|
|
64
|
+
lvl: number
|
|
65
|
+
hLevel: number
|
|
66
|
+
i?: number
|
|
67
|
+
seen?: number
|
|
68
|
+
slug?: string
|
|
69
|
+
children?: Token[]
|
|
70
|
+
lines: number[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface JsonToken {
|
|
74
|
+
content: string
|
|
75
|
+
slug: string
|
|
76
|
+
lvl: number
|
|
77
|
+
i?: number
|
|
78
|
+
seen: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function generate(options?: GenerateOptions): any {
|
|
82
|
+
const opts = { ...{ firsth1: true, maxdepth: 6 }, ...options }
|
|
83
|
+
const stripFirst = opts.firsth1 === false
|
|
84
|
+
if (opts.linkify === undefined) opts.linkify = true
|
|
85
|
+
|
|
86
|
+
return function (md: RemarkablePlus) {
|
|
87
|
+
md.renderer.render = function (tokens: Token[]) {
|
|
88
|
+
const copiedTokens = [...tokens]
|
|
89
|
+
const seen: { [key: string]: number } = {}
|
|
90
|
+
let tocstart = -1
|
|
91
|
+
const arr: Token[] = []
|
|
92
|
+
const res: {
|
|
93
|
+
json?: JsonToken[]
|
|
94
|
+
highest?: number
|
|
95
|
+
tokens?: Token[]
|
|
96
|
+
content?: string
|
|
97
|
+
} = {}
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < copiedTokens.length; i++) {
|
|
100
|
+
const token = copiedTokens[i]
|
|
101
|
+
if (/<!--[ \t]*toc[ \t]*-->/.test(token.content)) {
|
|
102
|
+
tocstart = token.lines[1]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (token.type === "heading_open") {
|
|
106
|
+
copiedTokens[i + 1].lvl = copiedTokens[i].hLevel
|
|
107
|
+
copiedTokens[i + 1].i = i
|
|
108
|
+
arr.push(copiedTokens[i + 1])
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let result: Token[] = []
|
|
113
|
+
res.json = []
|
|
114
|
+
|
|
115
|
+
for (const tok of arr) {
|
|
116
|
+
if (tok.lines && tok.lines[0] > tocstart) {
|
|
117
|
+
let val = tok.content
|
|
118
|
+
if (seen[val] === undefined) seen[val] = 0
|
|
119
|
+
if (tok.children && tok.children[0].type === "link_open") {
|
|
120
|
+
if (tok.children[1].type === "text") {
|
|
121
|
+
val = tok.children[1].content
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
tok.seen = seen[val]
|
|
126
|
+
tok.slug = slugify(val, opts)
|
|
127
|
+
const jsonToken: JsonToken = {
|
|
128
|
+
content: tok.content,
|
|
129
|
+
slug: seen[val] === 0 ? tok.slug : `${tok.slug}-${seen[val]}`,
|
|
130
|
+
lvl: tok.lvl,
|
|
131
|
+
i: tok.i,
|
|
132
|
+
seen: tok.seen,
|
|
133
|
+
}
|
|
134
|
+
res.json.push(jsonToken)
|
|
135
|
+
|
|
136
|
+
if (opts.linkify) {
|
|
137
|
+
const tokLinkified = linkify(tok, opts)
|
|
138
|
+
result.push(tokLinkified)
|
|
139
|
+
} else {
|
|
140
|
+
result.push(tok)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
seen[val] += 1
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (stripFirst && result.length > 0 && result[0].lvl === 1) {
|
|
148
|
+
result = result.slice(1)
|
|
149
|
+
res.json = res.json.slice(1)
|
|
150
|
+
}
|
|
151
|
+
opts.highest = highest(result)
|
|
152
|
+
res.highest = opts.highest
|
|
153
|
+
res.tokens = copiedTokens
|
|
154
|
+
|
|
155
|
+
const { out, bList: _bList } = bullets(result, opts)
|
|
156
|
+
res.content = out
|
|
157
|
+
if (opts.append) res.content += opts.append
|
|
158
|
+
return res
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** ListItemFn: Func that creates a single markdown list item for the Toc. */
|
|
164
|
+
type ListItemFn = (level: number, content: string) => string
|
|
165
|
+
|
|
166
|
+
const isListItemFn = (fn: any): fn is ListItemFn => {
|
|
167
|
+
if (typeof fn !== "function") {
|
|
168
|
+
return false // wrong type of object
|
|
169
|
+
} else if (fn.length !== 2) {
|
|
170
|
+
return false // wrong number of args
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const tempResult = fn(1, "test")
|
|
175
|
+
return typeof tempResult === "string"
|
|
176
|
+
} catch (_error) {
|
|
177
|
+
// If calling the func throws an error, it's not a ListItemFn
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const newListitemFn = (
|
|
183
|
+
opts: GenerateOptions,
|
|
184
|
+
fn: FilterFn | null,
|
|
185
|
+
): ListItemFn => {
|
|
186
|
+
const liFn = listitem(opts, fn as Function)
|
|
187
|
+
if (isListItemFn(liFn)) return liFn
|
|
188
|
+
throw Error(`list item fn has the wrong type ${JSON.stringify(liFn)}`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Render markdown list bullets
|
|
193
|
+
*
|
|
194
|
+
* @param {Array} `arr` Array of Toc `Token` items
|
|
195
|
+
* @param {GenerateOptions} `options`
|
|
196
|
+
* @return {Object} `{ out: string; bList: BulletList }`, where
|
|
197
|
+
* - `out` is the Toc output string with bullets
|
|
198
|
+
* - `bList`: Structured object version of `out`. Great for debugging.
|
|
199
|
+
*/
|
|
200
|
+
export function bullets(
|
|
201
|
+
arr: Token[],
|
|
202
|
+
options?: GenerateOptions,
|
|
203
|
+
): { out: string; bList: BulletList } {
|
|
204
|
+
const opts = {
|
|
205
|
+
indent: " ",
|
|
206
|
+
...options,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
opts.chars = opts.chars || opts.bullets || ["-", "*", "+"]
|
|
210
|
+
|
|
211
|
+
const fn = typeof opts.filter === "function" ? opts.filter : null
|
|
212
|
+
const li: ListItemFn = newListitemFn(opts, fn)
|
|
213
|
+
|
|
214
|
+
const includeFirstH1 = opts.firsth1 === true
|
|
215
|
+
return new BulletsParser(arr).bullets(includeFirstH1, fn, li, opts)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface IBulletState {
|
|
219
|
+
firstIsH1: null | boolean
|
|
220
|
+
numH1s: number
|
|
221
|
+
noTokens: boolean
|
|
222
|
+
soloToken: boolean
|
|
223
|
+
tokens: Token[]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type BulletList = { bullet: string; lvl: number; content: string }[]
|
|
227
|
+
|
|
228
|
+
export class BulletsParser implements IBulletState {
|
|
229
|
+
firstIsH1: null | boolean
|
|
230
|
+
numH1s: number
|
|
231
|
+
noTokens: boolean
|
|
232
|
+
soloToken: boolean
|
|
233
|
+
tokens: Token[]
|
|
234
|
+
hs: { lvl: number; content: string }[]
|
|
235
|
+
|
|
236
|
+
constructor(tokens: Token[]) {
|
|
237
|
+
this.noTokens = tokens.length === 0
|
|
238
|
+
this.soloToken = tokens.length === 1
|
|
239
|
+
this.firstIsH1 = null
|
|
240
|
+
this.numH1s = 0
|
|
241
|
+
this.tokens = tokens
|
|
242
|
+
this.hs = []
|
|
243
|
+
if (tokens.length < 2) return
|
|
244
|
+
this.parseTokens()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
parseTokens = (): void => {
|
|
248
|
+
// TODO: Useful debug for feature development
|
|
249
|
+
// console.debug("DEBUG parseTokens: %o", this.tokens)
|
|
250
|
+
this.tokens.forEach((token, idx) => {
|
|
251
|
+
const isH1 = token.lvl === 1
|
|
252
|
+
if (isH1) this.numH1s += 1
|
|
253
|
+
if (idx === 0) {
|
|
254
|
+
this.firstIsH1 = isH1
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
otherH1sPresent = (): boolean => {
|
|
260
|
+
if (this.numH1s === 0) return false
|
|
261
|
+
else if (this.firstIsH1 && this.numH1s > 1) return true
|
|
262
|
+
else if (!this.firstIsH1 && this.numH1s > 0) return true
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
bulletList = (
|
|
267
|
+
includeFirstH1: boolean,
|
|
268
|
+
fn: FilterFn | null,
|
|
269
|
+
li: ListItemFn,
|
|
270
|
+
opts: GenerateOptions,
|
|
271
|
+
): BulletList => {
|
|
272
|
+
// TODO: Useful debug for feature development
|
|
273
|
+
// const indent: boolean = this.indent(includeFirstH1)
|
|
274
|
+
const unindent: number = !this.indent(includeFirstH1) ? 1 : 0
|
|
275
|
+
// console.debug("DEBUG obj: %o", { includeFirstH1, unindent, indent })
|
|
276
|
+
const bList: BulletList = []
|
|
277
|
+
this.tokens.forEach((ele) => {
|
|
278
|
+
ele.lvl -= unindent
|
|
279
|
+
|
|
280
|
+
if (fn && !fn(ele.content, ele, this.tokens)) {
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const maxDepth = opts.maxdepth ?? 3
|
|
285
|
+
// console.debug("DEBUG obj: %o", { opts, maxDepth, tokens: this.tokens })
|
|
286
|
+
if (ele.lvl > maxDepth) {
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// generate() sets highest before rendering the bullet list.
|
|
291
|
+
const lvl = ele.lvl - opts.highest!
|
|
292
|
+
const { content } = ele
|
|
293
|
+
bList.push({ bullet: li(lvl, content), lvl, content })
|
|
294
|
+
})
|
|
295
|
+
return bList
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
bullets = (
|
|
299
|
+
includeFirstH1: boolean,
|
|
300
|
+
fn: FilterFn | null,
|
|
301
|
+
li: ListItemFn,
|
|
302
|
+
opts: GenerateOptions,
|
|
303
|
+
): { out: string; bList: BulletList } => {
|
|
304
|
+
const bList = this.bulletList(includeFirstH1, fn, li, opts)
|
|
305
|
+
return {
|
|
306
|
+
out: bList.map((item) => item.bullet).join("\n"),
|
|
307
|
+
bList,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// assume arr.length >= 2 -> 2 headings
|
|
312
|
+
//
|
|
313
|
+
// case: h1, h2, ..., h1, ... -> indent
|
|
314
|
+
// "first is h1" AND "other h1s present"
|
|
315
|
+
//
|
|
316
|
+
// case: h2, h2, ... -> unindent
|
|
317
|
+
// NOT "first is h1" AND NOT "other h1s present"
|
|
318
|
+
//
|
|
319
|
+
// case: h1, h2, ... -> if includeFirstH1 ? indent : unindent
|
|
320
|
+
// "first is h1" AND NOT "other h1s present"
|
|
321
|
+
//
|
|
322
|
+
// case: h2, ..., h1, ... -> indent
|
|
323
|
+
// NOT "first is h1" AND "other h1s present"
|
|
324
|
+
//
|
|
325
|
+
// case: arr.length = 0 -> error
|
|
326
|
+
// case: arr.length = 1 -> unindent
|
|
327
|
+
indent = (includeFirstH1: boolean): boolean => {
|
|
328
|
+
const { firstIsH1 } = this
|
|
329
|
+
const otherH1sPresent = this.otherH1sPresent()
|
|
330
|
+
|
|
331
|
+
// TODO: Useful debug for feature development
|
|
332
|
+
// console.debug("DEBUG obj: %o", { otherH1sPresent, firstIsH1 })
|
|
333
|
+
|
|
334
|
+
if (firstIsH1 && otherH1sPresent) return true
|
|
335
|
+
else if (firstIsH1 && !otherH1sPresent) return includeFirstH1 ? true : false
|
|
336
|
+
else if (!firstIsH1 && otherH1sPresent) return true
|
|
337
|
+
else if (!firstIsH1 && !otherH1sPresent) return true
|
|
338
|
+
else return false
|
|
339
|
+
// if (this.soloToken) return false
|
|
340
|
+
// if (this.noTokens) return false
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Get the highest heading level in the array, so we can un-indent the proper
|
|
346
|
+
* number of levels. For example, a document with an "h1" heading has highest=1.
|
|
347
|
+
* If the topmost heading is an h2, then highest is 2.
|
|
348
|
+
*
|
|
349
|
+
* @param {Array} `arr` Array of tokens
|
|
350
|
+
* @return {Number} Highest level
|
|
351
|
+
*/
|
|
352
|
+
export function highest(arr: Token[]): number {
|
|
353
|
+
const sorted = arr.slice().sort((a, b) => a.lvl - b.lvl)
|
|
354
|
+
|
|
355
|
+
return sorted.length ? sorted[0].lvl : 0
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Turn headings into anchors
|
|
360
|
+
*/
|
|
361
|
+
export function linkify(tok: Token, options?: GenerateOptions): Token {
|
|
362
|
+
const opts = { ...options }
|
|
363
|
+
if (tok && tok.content) {
|
|
364
|
+
opts.num = tok.seen
|
|
365
|
+
const text = titleize(tok.content, opts)
|
|
366
|
+
const slug = slugify(tok.content, opts)
|
|
367
|
+
const escapedSlug = querystring.escape(slug)
|
|
368
|
+
|
|
369
|
+
// console.debug("DEBUG linkfiy: %o", { escapedSlug, opts, tok, text, slug, })
|
|
370
|
+
let linkifyFunc: LinkifyFn = (_tok, text, escapedSlug, _opts) =>
|
|
371
|
+
markdownLink(text, `#${escapedSlug}`)
|
|
372
|
+
if (opts && typeof opts.linkify === "function") {
|
|
373
|
+
linkifyFunc = opts.linkify
|
|
374
|
+
}
|
|
375
|
+
tok.content = linkifyFunc(tok, text, escapedSlug, opts)
|
|
376
|
+
}
|
|
377
|
+
return tok
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Titleize the title part of a markdown link.
|
|
381
|
+
*/
|
|
382
|
+
export function titleize(str: string, opts?: GenerateOptions): string {
|
|
383
|
+
if (opts && opts.strip) {
|
|
384
|
+
return strip(str, opts)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (opts && opts.titleize === false) return str
|
|
388
|
+
|
|
389
|
+
if (opts && typeof opts.titleize === "function") {
|
|
390
|
+
return opts.titleize(str, opts)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
str = getTitle(str)
|
|
394
|
+
|
|
395
|
+
str = str.split(/<\/?[^>]+>/).join("")
|
|
396
|
+
|
|
397
|
+
str = str.split(/[ \t]+/).join(" ")
|
|
398
|
+
|
|
399
|
+
return str.trim()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Optionally strip specified words from heading text (not url)
|
|
404
|
+
*/
|
|
405
|
+
export function strip(str: string, opts?: GenerateOptions): string {
|
|
406
|
+
if (!opts) return str
|
|
407
|
+
if (!opts.strip) return str
|
|
408
|
+
|
|
409
|
+
if (typeof opts.strip === "function") {
|
|
410
|
+
return opts.strip(str, opts)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (Array.isArray(opts.strip) && opts.strip.length) {
|
|
414
|
+
const res = opts.strip.join("|")
|
|
415
|
+
const re = new RegExp(res, "g")
|
|
416
|
+
str = str.trim().replace(re, "")
|
|
417
|
+
return str.replace(/^-|-$/g, "")
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return str
|
|
421
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import concat from "concat-stream"
|
|
2
|
+
import minimist from "minimist"
|
|
3
|
+
import { generate, insert, strip } from "./generate"
|
|
4
|
+
|
|
5
|
+
export { getTitle, slugify, replaceDiacritics } from "./options"
|
|
6
|
+
export { toc } from "./toc"
|
|
7
|
+
export const Toc = {
|
|
8
|
+
insert,
|
|
9
|
+
strip,
|
|
10
|
+
plugin: generate,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { minimist, concat }
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import matter from "gray-matter"
|
|
2
|
+
import { type SlugifyOptions } from "./options"
|
|
3
|
+
import { toc } from "./toc"
|
|
4
|
+
|
|
5
|
+
function split(str: string, re: RegExp) {
|
|
6
|
+
return str.split(re).map(trim)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function trim(str: string) {
|
|
10
|
+
return str.trim()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface InsertOptions extends SlugifyOptions {
|
|
14
|
+
/** Regex to find toc insertion points */
|
|
15
|
+
regex?: RegExp
|
|
16
|
+
|
|
17
|
+
/** Opening tag for toc */
|
|
18
|
+
open?: string
|
|
19
|
+
|
|
20
|
+
/** Closing tag for toc */
|
|
21
|
+
close?: string
|
|
22
|
+
|
|
23
|
+
/** Custom toc content to insert */
|
|
24
|
+
toc?: string
|
|
25
|
+
|
|
26
|
+
linkify?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const defaultInsertOptions: InsertOptions = {
|
|
30
|
+
regex: /(?:<!-- toc(?:\s*stop)? -->)/g,
|
|
31
|
+
open: "<!-- toc -->\n",
|
|
32
|
+
close: "<!-- tocstop -->",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The basic idea:
|
|
37
|
+
*
|
|
38
|
+
* 1. when front-matter exists, we need to avoid turning its properties into headings.
|
|
39
|
+
* 2. We need to detect toc markers on the page. For now it's a simple HTML code comment
|
|
40
|
+
* to ensure the markdown is compatible with any parser.
|
|
41
|
+
*
|
|
42
|
+
* @param {String} `str` Pass a string of markdown
|
|
43
|
+
* @param {Object} `options` Pass options to toc generation
|
|
44
|
+
* @return {String} Get the same string back with a TOC inserted
|
|
45
|
+
*/
|
|
46
|
+
export function insert(str: string, options?: InsertOptions): string {
|
|
47
|
+
options = { ...defaultInsertOptions, ...options }
|
|
48
|
+
|
|
49
|
+
let { close } = options
|
|
50
|
+
const { regex, open } = options
|
|
51
|
+
if (!close) close = defaultInsertOptions.close!
|
|
52
|
+
|
|
53
|
+
let newlines = ""
|
|
54
|
+
const m = /\n+$/.exec(str)
|
|
55
|
+
if (m) newlines = m[0]
|
|
56
|
+
|
|
57
|
+
let obj: MatterFile | null = null
|
|
58
|
+
// does the file have front-matter?
|
|
59
|
+
if (/^---/.test(str)) {
|
|
60
|
+
// extract it temporarily so the syntax
|
|
61
|
+
// doesn't get mistaken for a heading
|
|
62
|
+
obj = grayMatter(str)
|
|
63
|
+
str = obj.content
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sections = split(str, regex ?? defaultInsertOptions.regex!)
|
|
67
|
+
if (sections.length > 3) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
"markdown-toc only supports one Table of Contents per file.",
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const last = sections[sections.length - 1]
|
|
74
|
+
if (sections.length === 3 || sections.length === 2) {
|
|
75
|
+
const body = options.toc || toc(last, options).content
|
|
76
|
+
const block = `${open}${body}\n${close}`
|
|
77
|
+
if (sections.length === 3) {
|
|
78
|
+
sections.splice(1, 1, block)
|
|
79
|
+
} else {
|
|
80
|
+
sections.splice(1, 0, block)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const resultString = sections.join("\n\n") + newlines
|
|
85
|
+
// if front-matter was found, put it back now
|
|
86
|
+
if (obj) {
|
|
87
|
+
return matter.stringify(resultString, obj.data)
|
|
88
|
+
}
|
|
89
|
+
return resultString
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const grayMatter = (
|
|
93
|
+
str: string,
|
|
94
|
+
options?: matter.GrayMatterOption,
|
|
95
|
+
): MatterFile => matter(str, options) as MatterFile
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Object returned by the `matter` function from "gray-matter"
|
|
99
|
+
*/
|
|
100
|
+
export interface MatterFile {
|
|
101
|
+
/** Parsed front matter data */
|
|
102
|
+
data: { [key: string]: any }
|
|
103
|
+
|
|
104
|
+
/** Content string without front matter */
|
|
105
|
+
content: string
|
|
106
|
+
|
|
107
|
+
/** Excerpt extracted from content */
|
|
108
|
+
excerpt: string
|
|
109
|
+
|
|
110
|
+
/** Original unmodified input */
|
|
111
|
+
orig: string
|
|
112
|
+
|
|
113
|
+
/** Language defined in front matter */
|
|
114
|
+
language?: string
|
|
115
|
+
|
|
116
|
+
/** Raw front matter string */
|
|
117
|
+
matter?: string
|
|
118
|
+
|
|
119
|
+
/** File path if read from the file system */
|
|
120
|
+
path?: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// function split(str, re) {
|
|
124
|
+
// return str.split(re).map(trim);
|
|
125
|
+
// }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mdtoc/options.ts
|
|
3
|
+
*/
|
|
4
|
+
import stripColor from "strip-color"
|
|
5
|
+
import diacritics from "diacritics-map"
|
|
6
|
+
|
|
7
|
+
export { getTitle, replaceDiacritics, slugify, defaultOptions }
|
|
8
|
+
export interface SlugifyOptions {
|
|
9
|
+
slugify?: SlugifyFn | boolean
|
|
10
|
+
stripHeadingTags?: boolean
|
|
11
|
+
num?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SlugifyFn = (str: string, options: SlugifyOptions) => string
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get the "title" from a markdown link
|
|
18
|
+
*/
|
|
19
|
+
function getTitle(str: string): string {
|
|
20
|
+
if (/^\[[^\]]+\]\(/.test(str)) {
|
|
21
|
+
const m = /^\[([^\]]+)\]/.exec(str)
|
|
22
|
+
if (m) return m[1]
|
|
23
|
+
}
|
|
24
|
+
return str
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const defaultOptions: SlugifyOptions = {
|
|
28
|
+
slugify: (str: string, _options: SlugifyOptions) => str,
|
|
29
|
+
stripHeadingTags: false,
|
|
30
|
+
num: undefined,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Slugify the url part of a markdown link.
|
|
35
|
+
*
|
|
36
|
+
* @name options.slugify
|
|
37
|
+
* @param {String} `str` The string to slugify
|
|
38
|
+
* @param {Object} `options` Pass a custom slugify function on `options.slugify`
|
|
39
|
+
* @return {String}
|
|
40
|
+
* @api public
|
|
41
|
+
*/
|
|
42
|
+
function slugify(str: string, options?: SlugifyOptions): string {
|
|
43
|
+
if (!options) options = defaultOptions
|
|
44
|
+
|
|
45
|
+
if (options.slugify === false) return str
|
|
46
|
+
if (typeof options.slugify === "function") {
|
|
47
|
+
return options.slugify(str, options)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
str = getTitle(str)
|
|
51
|
+
str = stripColor(str)
|
|
52
|
+
str = str.toLowerCase()
|
|
53
|
+
|
|
54
|
+
// `.split()` is often (but not always) faster than `.replace()`
|
|
55
|
+
str = str.split(" ").join("-")
|
|
56
|
+
str = str.split(/\t/).join("--")
|
|
57
|
+
if (options.stripHeadingTags !== false) {
|
|
58
|
+
str = str.split(/<\/?[^>]+>/).join("")
|
|
59
|
+
}
|
|
60
|
+
str = str.split(/[|$&`~=\\\/@+*!?({[\]})<>=.,;:'"^]/).join("")
|
|
61
|
+
str = str
|
|
62
|
+
.split(/[。?!,、;:“”【】()〔〕[]﹃﹄“ ”‘’﹁﹂—…-~《》〈〉「」]/)
|
|
63
|
+
.join("")
|
|
64
|
+
str = replaceDiacritics(str)
|
|
65
|
+
if (options.num) {
|
|
66
|
+
str += `-${options.num}`
|
|
67
|
+
}
|
|
68
|
+
return str
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function replaceDiacritics(str: string) {
|
|
72
|
+
return str.replace(/[À-ž]/g, (ch) => diacritics[ch] || ch)
|
|
73
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mdtoc/remarkable.ts
|
|
3
|
+
*/
|
|
4
|
+
import { Remarkable } from "remarkable"
|
|
5
|
+
|
|
6
|
+
export interface IRemarkable extends RemarkableComponents {
|
|
7
|
+
// constructor(preset?: string, options?: RemarkableOptions): IRemarkable;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Set options as an alternative to passing them
|
|
11
|
+
* to the constructor.
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* md.set({typographer: true});
|
|
15
|
+
* ```
|
|
16
|
+
* @param {Object} `options`
|
|
17
|
+
* @api public
|
|
18
|
+
*/
|
|
19
|
+
set(options: RemarkableOptions): void
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Batch loader for components rules states, and options
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} `presets`
|
|
25
|
+
*/
|
|
26
|
+
configure(presets: RemarkablePresets): void
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Use a plugin.
|
|
30
|
+
*
|
|
31
|
+
* ```js
|
|
32
|
+
* var md = new Remarkable();
|
|
33
|
+
*
|
|
34
|
+
* md.use(plugin1)
|
|
35
|
+
* .use(plugin2, opts)
|
|
36
|
+
* .use(plugin3);
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @param {Function} `plugin`
|
|
40
|
+
* @param {Object} `options`
|
|
41
|
+
* @return {Object} `Remarkable` for chaining
|
|
42
|
+
*/
|
|
43
|
+
use(plugin: Remarkable.Plugin, options?: RemarkableOptions): IRemarkable
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse the input `string` and return a tokens array.
|
|
47
|
+
* Modifies `env` with definitions data.
|
|
48
|
+
*
|
|
49
|
+
* @param {String} `str`
|
|
50
|
+
* @param {Object} `env`
|
|
51
|
+
* @return {Array} Array of tokens
|
|
52
|
+
*/
|
|
53
|
+
parse(str: string, env?: RemarkableEnv): any[]
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The main `.render()` method that does all the magic :)
|
|
57
|
+
*
|
|
58
|
+
* @param {String} `str`
|
|
59
|
+
* @param {Object} `env`
|
|
60
|
+
* @return {string} Rendered HTML.
|
|
61
|
+
*/
|
|
62
|
+
render(str: string, env?: RemarkableEnv): string
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse the given content `string` as a single string.
|
|
66
|
+
*
|
|
67
|
+
* @param {String} `str`
|
|
68
|
+
* @param {Object} `env`
|
|
69
|
+
* @return {Array} Array of tokens
|
|
70
|
+
*/
|
|
71
|
+
parseInline(str: string, env?: RemarkableEnv): any[]
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Render a single content `string`, without wrapping it
|
|
75
|
+
* to paragraphs
|
|
76
|
+
*
|
|
77
|
+
* @param {String} `str`
|
|
78
|
+
* @param {Object} `env`
|
|
79
|
+
* @return {String}
|
|
80
|
+
*/
|
|
81
|
+
renderInline(str: string, env?: RemarkableEnv): string
|
|
82
|
+
|
|
83
|
+
renderer: any
|
|
84
|
+
ruler: any
|
|
85
|
+
options: RemarkableOptions
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface RemarkableOptions {
|
|
89
|
+
html?: boolean
|
|
90
|
+
xhtmlOut?: boolean
|
|
91
|
+
breaks?: boolean
|
|
92
|
+
langPrefix?: string
|
|
93
|
+
linkTarget?: string
|
|
94
|
+
typographer?: boolean
|
|
95
|
+
quotes?: string
|
|
96
|
+
highlight?: (str: string, lang: string) => string
|
|
97
|
+
maxNesting?: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface RemarkableComponents {
|
|
101
|
+
core: any
|
|
102
|
+
block: any
|
|
103
|
+
inline: any
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type RemarkableEnv = Remarkable.Env
|
|
107
|
+
|
|
108
|
+
type RemarkablePresets = Remarkable.Presets
|
|
109
|
+
|
|
110
|
+
export class RemarkablePlus implements IRemarkable, RemarkableComponents {
|
|
111
|
+
// Remarkable exposes these fields at runtime, but @types/remarkable omits them.
|
|
112
|
+
core: Remarkable & { ruler: unknown; options: RemarkableOptions }
|
|
113
|
+
|
|
114
|
+
constructor({
|
|
115
|
+
preset,
|
|
116
|
+
options,
|
|
117
|
+
}: {
|
|
118
|
+
preset?: "default" | "full" | "commonmark"
|
|
119
|
+
options?: RemarkableOptions
|
|
120
|
+
}) {
|
|
121
|
+
const core = new Remarkable(preset ?? "default", options)
|
|
122
|
+
this.core = core as typeof this.core
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
set = (options: RemarkableOptions): void => {
|
|
126
|
+
this.core.set(options)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
configure = (presets: RemarkablePresets): void => {
|
|
130
|
+
this.core.configure(presets)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
use = (plugin: Remarkable.Plugin, options?: RemarkableOptions): IRemarkable => {
|
|
134
|
+
this.core.use(plugin, options)
|
|
135
|
+
return this // Return `this` to allow for chaining.
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
parse = (str: string, env?: RemarkableEnv): any[] => this.core.parse(str, env ?? {})
|
|
139
|
+
|
|
140
|
+
render = (str: string, env?: RemarkableEnv): string =>
|
|
141
|
+
this.core.render(str, env)
|
|
142
|
+
|
|
143
|
+
parseInline = (str: string, env?: RemarkableEnv): any[] =>
|
|
144
|
+
this.core.parseInline(str, env ?? {})
|
|
145
|
+
|
|
146
|
+
renderInline = (str: string, env?: RemarkableEnv): string =>
|
|
147
|
+
this.core.renderInline(str, env)
|
|
148
|
+
|
|
149
|
+
get inline(): any {
|
|
150
|
+
return this.core.inline
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get block(): any {
|
|
154
|
+
return this.core.block
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get renderer(): any {
|
|
158
|
+
return this.core.renderer
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get ruler(): any {
|
|
162
|
+
return this.core.ruler
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get options(): RemarkableOptions {
|
|
166
|
+
return this.core.options
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/mdtoc/toc.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type GenerateOptions, type Token, generate } from "./generate"
|
|
2
|
+
import { RemarkablePlus } from "./remarkable"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generates a table of contents (TOC) from markdown headings.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} markdown - Markdown string to generate TOC from
|
|
8
|
+
* @param {GenerateOptions} opts - Options for TOC generation
|
|
9
|
+
* @returns {TocResult} TOC result
|
|
10
|
+
*/
|
|
11
|
+
export const toc = (markdown: string, opts?: GenerateOptions): TocResult =>
|
|
12
|
+
new RemarkablePlus({})
|
|
13
|
+
.use(generate(opts))
|
|
14
|
+
.render(markdown) as unknown as TocResult
|
|
15
|
+
|
|
16
|
+
/** Table of contents (TOC) result */
|
|
17
|
+
export interface TocResult {
|
|
18
|
+
/** Rendered TOC content string */
|
|
19
|
+
content: string
|
|
20
|
+
|
|
21
|
+
/** Highest heading level found */
|
|
22
|
+
highest: number
|
|
23
|
+
|
|
24
|
+
/** Array of markdown AST tokens */
|
|
25
|
+
tokens: Token[]
|
|
26
|
+
|
|
27
|
+
/** Array of heading objects */
|
|
28
|
+
json: Heading[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Heading object */
|
|
32
|
+
interface Heading {
|
|
33
|
+
/** Heading content text */
|
|
34
|
+
content: string
|
|
35
|
+
|
|
36
|
+
/** Heading level */
|
|
37
|
+
lvl: number
|
|
38
|
+
|
|
39
|
+
/** Slugified head ID */
|
|
40
|
+
slug: string
|
|
41
|
+
}
|
package/src/vendor.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
declare module "markdown-link" {
|
|
2
|
+
export default function link(anchor: string, href: string, title?: string): string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
declare module "list-item" {
|
|
6
|
+
export default function listItem(
|
|
7
|
+
options: { indent?: string; chars?: string | string[]; nobullet?: boolean },
|
|
8
|
+
transform?: Function | null,
|
|
9
|
+
): (level: number, content: string) => string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
declare module "diacritics-map" {
|
|
13
|
+
const diacritics: Record<string, string>
|
|
14
|
+
export default diacritics
|
|
15
|
+
}
|