@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 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
- # Temporary Holding Version
1
+ # ud-mdtoc
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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
+ }
@@ -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
+ }