├── .changes
├── header.tpl.md
├── unreleased
│ └── .gitkeep
├── v0.1.0.md
├── v0.10.0.md
├── v0.11.0.md
├── v0.12.0.md
├── v0.2.0.md
├── v0.2.1.md
├── v0.3.0.md
├── v0.4.0.md
├── v0.5.0.md
├── v0.6.0.md
├── v0.7.0.md
├── v0.8.0.md
└── v0.9.0.md
├── .changie.yaml
├── .github
└── workflows
│ ├── ci.yml
│ └── doc.yml
├── .gitignore
├── .golangci.yml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── demo
├── Makefile
├── go.mod
├── go.sum
├── main.go
└── static
│ ├── .gitignore
│ └── index.html
├── doc.go
├── example_test.go
├── extend.go
├── go.mod
├── go.sum
├── inspect.go
├── inspect_test.go
├── integration_test.go
├── mise.lock
├── mise.oldstable.toml
├── mise.toml
├── render.go
├── render_test.go
├── renovate.json
├── testdata
├── rapid
│ ├── TestInspectCompactRandomHeadings
│ │ └── TestInspectCompactRandomHeadings-20230911051733-28626.fail
│ └── TestInspect_rapid
│ │ └── TestInspect_rapid-20230911050748-19573.fail
└── tests.yaml
├── toc.go
├── transform.go
└── transform_test.go
/.changes/header.tpl.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
6 | and is generated by [Changie](https://github.com/miniscruff/changie).
7 |
--------------------------------------------------------------------------------
/.changes/unreleased/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/abhinav/goldmark-toc/e468a84b5e36546d43fdd62f5e31bb0feb3a2749/.changes/unreleased/.gitkeep
--------------------------------------------------------------------------------
/.changes/v0.1.0.md:
--------------------------------------------------------------------------------
1 | ## v0.1.0 - 2021-03-23
2 | - Initial release.
3 |
--------------------------------------------------------------------------------
/.changes/v0.10.0.md:
--------------------------------------------------------------------------------
1 | ## v0.10.0 - 2024-02-27
2 | ### Added
3 | - {Extender, Transformer}: Add TitleDepth field to change the level of the Table of Contents heading.
4 | ### Changed
5 | - Relicense to BSD3.
6 |
--------------------------------------------------------------------------------
/.changes/v0.11.0.md:
--------------------------------------------------------------------------------
1 | ## v0.11.0 - 2025-02-02
2 | ### Changed
3 | - goldmark: Raise minimum version to v1.7.8. This version of goldmark deprecates `Node.Text`.
4 |
--------------------------------------------------------------------------------
/.changes/v0.12.0.md:
--------------------------------------------------------------------------------
1 | ## v0.12.0 - 2025-05-11
2 | ### Added
3 | - Support ordered lists for table of contents with 'RenderOrderedList'.
4 |
--------------------------------------------------------------------------------
/.changes/v0.2.0.md:
--------------------------------------------------------------------------------
1 | ## v0.2.0 - 2021-04-04
2 | ### Added
3 | - Add `toc.Transformer` to generate a table of contents to the front of any
4 | document parsed by a Goldmark parser.
5 | - Add `toc.Extender` to extend a `goldmark.Markdown` object with the
6 | transformer.
7 |
--------------------------------------------------------------------------------
/.changes/v0.2.1.md:
--------------------------------------------------------------------------------
1 | ## v0.2.1 - 2021-12-15
2 | ### Fixed
3 | - inspect: Correctly handle escaped punctuation in titles.
4 | - render: Don't unintentionally interpret escape sequences in titles.
5 |
--------------------------------------------------------------------------------
/.changes/v0.3.0.md:
--------------------------------------------------------------------------------
1 | ## v0.3.0 - 2022-12-19
2 | ### Changed
3 | - Change the module path to `go.abhg.dev/goldmark/toc`.
4 |
--------------------------------------------------------------------------------
/.changes/v0.4.0.md:
--------------------------------------------------------------------------------
1 | ## v0.4.0 - 2023-03-02
2 | ### Added
3 | - Extender: Add Title field to change the Table of Contents title.
4 | - Inspect: Add MaxDepth option to limit the depth of the Table of Contents.
5 |
--------------------------------------------------------------------------------
/.changes/v0.5.0.md:
--------------------------------------------------------------------------------
1 | ## v0.5.0 - 2023-09-02
2 | ### Added
3 | - Add a ListID attribute to Extender and Transformer.
4 | If set, the rendered `
` will have an `id` attribute with this value.
5 |
--------------------------------------------------------------------------------
/.changes/v0.6.0.md:
--------------------------------------------------------------------------------
1 | ## v0.6.0 - 2023-09-11
2 | ### Added
3 | - The new `Compact` option removes empty nodes in the TOC. If you have >1 level of difference between headings, this will render a cleaner TOC.
4 |
--------------------------------------------------------------------------------
/.changes/v0.7.0.md:
--------------------------------------------------------------------------------
1 | ## v0.7.0 - 2023-09-11
2 | ### Added
3 | - The new MinDepth option allows ignoring headings below the specified level.
4 |
--------------------------------------------------------------------------------
/.changes/v0.8.0.md:
--------------------------------------------------------------------------------
1 | ## v0.8.0 - 2023-11-24
2 | ### Added
3 | - Add a HeadingID option to specify a custom ID for the Table of Contents heading.
4 | ### Changed
5 | - Table of Contents heading now automatically gets an ID if the Parser was given an IDs generator.
6 |
--------------------------------------------------------------------------------
/.changes/v0.9.0.md:
--------------------------------------------------------------------------------
1 | ## v0.9.0 - 2023-11-24
2 | ### Changed
3 | - Rename HeadingID option to TitleID. HeadingID is too generic.
4 |
--------------------------------------------------------------------------------
/.changie.yaml:
--------------------------------------------------------------------------------
1 | changesDir: .changes
2 | unreleasedDir: unreleased
3 | headerPath: header.tpl.md
4 | changelogPath: CHANGELOG.md
5 | versionExt: md
6 | versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}'
7 | kindFormat: '### {{.Kind}}'
8 | changeFormat: '- {{.Body}}'
9 | kinds:
10 | - label: Added
11 | auto: minor
12 | - label: Changed
13 | auto: major
14 | - label: Deprecated
15 | auto: minor
16 | - label: Removed
17 | auto: major
18 | - label: Fixed
19 | auto: patch
20 | - label: Security
21 | auto: patch
22 | newlines:
23 | afterChangelogHeader: 0
24 | beforeChangelogVersion: 1
25 | endOfVersion: 1
26 | envPrefix: CHANGIE_
27 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ '*' ]
8 |
9 | jobs:
10 |
11 | lint:
12 | name: Lint
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v4
16 | name: Check out repository
17 | - name: Set up mise
18 | uses: jdx/mise-action@v2
19 | - run: mise run lint
20 | name: Lint
21 |
22 | test:
23 | name: Test/ Go (${{ matrix.mise-env }})
24 | runs-on: ubuntu-latest
25 | strategy:
26 | matrix:
27 | mise-env: ["stable", "oldstable"]
28 |
29 | env:
30 | MISE_ENV: ${{ matrix.mise-env }}
31 |
32 | steps:
33 | - uses: actions/checkout@v4
34 | - name: Set up mise
35 | uses: jdx/mise-action@v2
36 | with:
37 | cache_key_prefix: mise-v0-${{ matrix.mise-env }}
38 | - name: Test
39 | run: mise run cover
40 | - name: Upload coverage
41 | uses: codecov/codecov-action@v5
42 |
--------------------------------------------------------------------------------
/.github/workflows/doc.yml:
--------------------------------------------------------------------------------
1 | name: Publish documentation
2 |
3 | on:
4 | # Publish documentation when a new release is tagged.
5 | push:
6 | tags: ['v*']
7 |
8 | # Allow manually publishing documentation from a specific hash.
9 | workflow_dispatch:
10 | inputs:
11 | head:
12 | description: "Git commit to publish documentation for."
13 | required: true
14 | type: string
15 |
16 | # If two concurrent runs are started,
17 | # prefer the latest one.
18 | concurrency:
19 | group: "pages"
20 | cancel-in-progress: true
21 |
22 | jobs:
23 |
24 | build:
25 | name: Build website
26 | runs-on: ubuntu-latest
27 | steps:
28 | - name: Checkout
29 | uses: actions/checkout@v4
30 | with:
31 | # Check out head specified by workflow_dispatch,
32 | # or the tag if this fired from the push event.
33 | ref: ${{ inputs.head || github.ref }}
34 | - name: Set up mise
35 | uses: jdx/mise-action@v2
36 | with:
37 | cache_key_prefix: mise-v0-stable
38 | - name: Generate API reference
39 | run: doc2go -home go.abhg.dev/goldmark/toc ./...
40 | - name: Build demo website
41 | run: |
42 | make -C demo
43 | cp -r demo/static _site/demo
44 | - name: Upload pages
45 | uses: actions/upload-pages-artifact@v3
46 |
47 | publish:
48 | name: Publish website
49 | # Don't run until the build has finished running.
50 | needs: build
51 |
52 | # Grants the GITHUB_TOKEN used by this job
53 | # permissions needed to publish the website.
54 | permissions:
55 | pages: write
56 | id-token: write
57 |
58 | # Deploy to the github-pages environment
59 | environment:
60 | name: github-pages
61 | url: ${{ steps.deployment.outputs.page_url }}
62 |
63 | runs-on: ubuntu-latest
64 | steps:
65 | - name: Deploy to GitHub Pages
66 | id: deployment
67 | uses: actions/deploy-pages@v4
68 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /bin
2 | /cover.out
3 | /cover.html
4 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 |
3 | issues:
4 | max-issues-per-linter: 0
5 | max-same-issues: 0
6 |
7 | linters:
8 | enable:
9 | - nolintlint
10 | - revive
11 | settings:
12 | govet:
13 | enable:
14 | - nilness
15 | - reflectvaluecompare
16 | - sortslice
17 | - unusedwrite
18 | exclusions:
19 | generated: lax
20 |
21 | formatters:
22 | enable:
23 | - gofumpt
24 | exclusions:
25 | generated: lax
26 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
6 | and is generated by [Changie](https://github.com/miniscruff/changie).
7 |
8 | ## v0.12.0 - 2025-05-11
9 | ### Added
10 | - Support ordered lists for table of contents with 'RenderOrderedList'.
11 |
12 | ## v0.11.0 - 2025-02-02
13 | ### Changed
14 | - goldmark: Raise minimum version to v1.7.8. This version of goldmark deprecates `Node.Text`.
15 |
16 | ## v0.10.0 - 2024-02-27
17 | ### Added
18 | - {Extender, Transformer}: Add TitleDepth field to change the level of the Table of Contents heading.
19 | ### Changed
20 | - Relicense to BSD3.
21 |
22 | ## v0.9.0 - 2023-11-24
23 | ### Changed
24 | - Rename HeadingID option to TitleID. HeadingID is too generic.
25 |
26 | ## v0.8.0 - 2023-11-24
27 | ### Added
28 | - Add a HeadingID option to specify a custom ID for the Table of Contents heading.
29 | ### Changed
30 | - Table of Contents heading now automatically gets an ID if the Parser was given an IDs generator.
31 |
32 | ## v0.7.0 - 2023-09-11
33 | ### Added
34 | - The new MinDepth option allows ignoring headings below the specified level.
35 |
36 | ## v0.6.0 - 2023-09-11
37 | ### Added
38 | - The new `Compact` option removes empty nodes in the TOC. If you have >1 level of difference between headings, this will render a cleaner TOC.
39 |
40 | ## v0.5.0 - 2023-09-02
41 | ### Added
42 | - Add a ListID attribute to Extender and Transformer.
43 | If set, the rendered `` will have an `id` attribute with this value.
44 |
45 | ## v0.4.0 - 2023-03-02
46 | ### Added
47 | - Extender: Add Title field to change the Table of Contents title.
48 | - Inspect: Add MaxDepth option to limit the depth of the Table of Contents.
49 |
50 | ## v0.3.0 - 2022-12-19
51 | ### Changed
52 | - Change the module path to `go.abhg.dev/goldmark/toc`.
53 |
54 | ## v0.2.1 - 2021-12-15
55 | ### Fixed
56 | - inspect: Correctly handle escaped punctuation in titles.
57 | - render: Don't unintentionally interpret escape sequences in titles.
58 |
59 | ## v0.2.0 - 2021-04-04
60 | ### Added
61 | - Add `toc.Transformer` to generate a table of contents to the front of any
62 | document parsed by a Goldmark parser.
63 | - Add `toc.Extender` to extend a `goldmark.Markdown` object with the
64 | transformer.
65 |
66 | ## v0.1.0 - 2021-03-23
67 | - Initial release.
68 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2023, Abhinav Gupta
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # goldmark-toc
2 |
3 | [](https://pkg.go.dev/go.abhg.dev/goldmark/toc)
4 | [](https://github.com/abhinav/goldmark-toc/actions/workflows/ci.yml)
5 | [](https://codecov.io/gh/abhinav/goldmark-toc)
6 |
7 | goldmark-toc is an add-on for the [goldmark] Markdown parser that adds support
8 | for rendering a table-of-contents.
9 |
10 | [goldmark]: http://github.com/yuin/goldmark
11 |
12 | **Demo**:
13 | A web-based demonstration of the extension is available at
14 | .
15 |
16 | ## Installation
17 |
18 | ```bash
19 | go get go.abhg.dev/goldmark/toc@latest
20 | ```
21 |
22 | ## Usage
23 |
24 | To use goldmark-toc, import the `toc` package.
25 |
26 | ```go
27 | import "go.abhg.dev/goldmark/toc"
28 | ```
29 |
30 | Following that, you have three options for using this package:
31 |
32 | - [Extension][]: This is the easiest way to get a table of contents into your
33 | document and provides very little control over the output.
34 | - [Transformer][]: This is the next easiest option and provides more control
35 | over the output.
36 | - [Manual][]: This option requires the most work but also provides the most
37 | control.
38 |
39 | [Extension]: #extension
40 | [Transformer]: #transformer
41 | [Manual]: #manual
42 |
43 | ### Extension
44 |
45 | To use this package as a simple Goldmark extension, install the `Extender`
46 | when constructing the `goldmark.Markdown` object.
47 |
48 | ```go
49 | markdown := goldmark.New(
50 | // ...
51 | goldmark.WithParserOptions(parser.WithAutoHeadingID()),
52 | goldmark.WithExtensions(
53 | // ...
54 | &toc.Extender{},
55 | ),
56 | )
57 | ```
58 |
59 | This will add a "Table of Contents" section to the top of every Markdown
60 | document parsed by this Markdown object.
61 |
62 | > NOTE: The example above enables `parser.WithAutoHeadingID`. Without this or
63 | > a custom implementation of `parser.IDs`, none of the headings in the
64 | > document will have links generated for them.
65 |
66 | #### Changing the title
67 |
68 | If you want to use a title other than "Table of Contents",
69 | set the `Title` field of `Extender`.
70 |
71 | ```go
72 | &toc.Extender{
73 | Title: "Contents",
74 | }
75 | ```
76 |
77 | You can specify an ID for the title heading with the `TitleID` option.
78 |
79 | ```go
80 | &toc.Extender{
81 | Title: "Contents",
82 | TitleID: "toc-header",
83 | }
84 | ```
85 |
86 | #### Adding an ID
87 |
88 | If you want the rendered HTML list to include an id,
89 | set the `ListID` field of `Extender`.
90 |
91 | ```go
92 | &toc.Extender{
93 | ListID: "toc",
94 | }
95 | ```
96 |
97 | This will render:
98 |
99 | ```html
100 |
103 | ```
104 |
105 | #### Limiting the Table of Contents
106 |
107 | By default, goldmark-toc will include all headers in the table of contents.
108 | If you want to limit the depth of the table of contents,
109 | use the `MinDepth` and `MaxDepth` field.
110 |
111 | ```go
112 | &toc.Extender{
113 | MinDepth: 2,
114 | MaxDepth: 3,
115 | }
116 | ```
117 |
118 | Headers with a level lower or higher than the specified values
119 | will not be included in the table of contents.
120 |
121 | #### Compacting the Table of Contents
122 |
123 | The Table of Contents generated by goldmark-toc matches your heading hierarchy
124 | exactly.
125 | This can be a problem if you have multiple levels of difference between items.
126 | For example, if you have the document:
127 |
128 | ```markdown
129 | # h1
130 | ### h3
131 | ```
132 |
133 | goldmark-toc will generate a TOC with the equivalent of the following,
134 | resulting in an empty entry between h1 and h3.
135 |
136 | ```markdown
137 | - h1
138 | -
139 | - h3
140 | ```
141 |
142 | You can use the `Compact` option to collapse away these intermediate items.
143 |
144 | ```go
145 | &toc.Extender{
146 | Compact: true,
147 | }
148 | ```
149 |
150 | With this option enabled, the hierarchy above
151 | will render as the equivalent of the following.
152 |
153 | ```markdown
154 | - h1
155 | - h3
156 | ```
157 |
158 | ### Transformer
159 |
160 | Installing this package as an AST Transformer provides slightly more control
161 | over the output.
162 | To use it, install the AST transformer on the Goldmark Markdown parser.
163 |
164 | ```go
165 | markdown := goldmark.New(...)
166 | markdown.Parser().AddOptions(
167 | parser.WithAutoHeadingID(),
168 | parser.WithASTTransformers(
169 | util.Prioritized(&toc.Transformer{
170 | Title: "Contents",
171 | }, 100),
172 | ),
173 | )
174 | ```
175 |
176 | This will generate a "Contents" section at the top of all Markdown documents
177 | parsed by this parser.
178 |
179 | As with the previous example, this enables `parser.WithAutoHeadingID` to get
180 | auto-generated heading IDs.
181 |
182 | ### Manual
183 |
184 | If you use this package manually to generate Tables of Contents, you have a
185 | lot more control over the behavior. This requires a few steps.
186 |
187 | #### Parse Markdown
188 |
189 | Parse a Markdown document with goldmark.
190 |
191 | ```go
192 | markdown := goldmark.New(...)
193 | markdown.Parser().AddOptions(parser.WithAutoHeadingID())
194 | doc := markdown.Parser().Parse(text.NewReader(src))
195 | ```
196 |
197 | Note that the parser must be configured to generate IDs for headers or the
198 | headers in the table of contents won't have anything to point to. This can be
199 | accomplished by adding the `parser.WithAutoHeadingID` option as in the example
200 | above, or with a custom implementation of [`goldmark/parser.IDs`] by using the
201 | snippet below.
202 |
203 | [`goldmark/parser.IDs`]: https://pkg.go.dev/github.com/yuin/goldmark/parser#IDs
204 |
205 | ```go
206 | markdown := goldmark.New(...)
207 | pctx := parser.NewContext(parser.WithIDs(ids))
208 | doc := parser.Parse(text.NewReader(src), parser.WithContext(pctx))
209 | ```
210 |
211 | #### Build a table of contents
212 |
213 | After parsing a Markdown document, inspect it with `toc`.
214 |
215 | ```go
216 | tree, err := toc.Inspect(doc, src)
217 | if err != nil {
218 | // handle the error
219 | }
220 | ```
221 |
222 | If you need to limit the depth of the table of contents,
223 | use the `MinDepth` and `MaxDepth` option.
224 |
225 | ```go
226 | tree, err := toc.Inspect(doc, src, toc.MinDepth(2), toc.MaxDepth(3))
227 | ```
228 |
229 | #### Generate a Markdown list
230 |
231 | You can render the table of contents into a Markdown list with
232 | `toc.RenderList` or `toc.RenderOrderedList`.
233 |
234 | ```go
235 | list := toc.RenderList(tree) // will produce
236 | list := toc.RenderOrderedList(tree) // will produce
237 | ```
238 |
239 | This builds a list representation of the table of contents to be rendered as
240 | Markdown or HTML.
241 |
242 | You may manipulate the `tree` before rendering the list.
243 |
244 | #### Render HTML
245 |
246 | Finally, render this table of contents along with your Markdown document:
247 |
248 | ```go
249 | // Render the table of contents.
250 | if list != nil {
251 | // list will be nil if the table of contents is empty
252 | // because there were no headings in the document.
253 | markdown.Renderer().Render(output, src, list)
254 | }
255 |
256 | // Render the document.
257 | markdown.Renderer().Render(output, src, doc)
258 | ```
259 |
260 | Alternatively, include the table of contents into your Markdown document in
261 | your desired position and render it using your Markdown renderer.
262 |
263 | ```go
264 | // Prepend table of contents to the front of the document.
265 | if list != nil {
266 | doc.InsertBefore(doc, doc.FirstChild(), list)
267 | }
268 |
269 | // Render the document.
270 | markdown.Renderer().Render(output, src, doc)
271 | ```
272 |
273 | ##### Customize TOC attributes
274 |
275 | If you want the rendered TOC to have an id or other attributes,
276 | use [Node.SetAttribute](https://pkg.go.dev/github.com/yuin/goldmark/ast#Node.SetAttribute)
277 | on the `ast.Node` returned by `toc.RenderList`.
278 |
279 | For example, with the following:
280 |
281 | ```go
282 | list := toc.RenderList(tree)
283 | list.SetAttribute([]byte("id"), []byte("toc"))
284 | ```
285 |
286 | The output will take the form:
287 |
288 | ```html
289 |
292 | ```
293 |
--------------------------------------------------------------------------------
/demo/Makefile:
--------------------------------------------------------------------------------
1 | OUT = static
2 |
3 | .PHONY: all
4 | all: $(OUT)/wasm_exec.js $(OUT)/main.wasm
5 |
6 | $(OUT)/wasm_exec.js:
7 | @mkdir -p $(OUT)
8 | cp "$(shell go env GOROOT)/lib/wasm/wasm_exec.js" $@
9 |
10 | $(OUT)/main.wasm: $(wildcard *.go)
11 | @mkdir -p $(OUT)
12 | GOOS=js GOARCH=wasm go build -o $@
13 |
--------------------------------------------------------------------------------
/demo/go.mod:
--------------------------------------------------------------------------------
1 | module go.abhg.dev/goldmark/toc/demo
2 |
3 | go 1.22
4 |
5 | toolchain go1.24.3
6 |
7 | replace go.abhg.dev/goldmark/toc => ../
8 |
9 | require (
10 | github.com/yuin/goldmark v1.7.12
11 | go.abhg.dev/goldmark/toc v0.12.0
12 | )
13 |
--------------------------------------------------------------------------------
/demo/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
7 | github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
8 | github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
11 | pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
12 | pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
13 |
--------------------------------------------------------------------------------
/demo/main.go:
--------------------------------------------------------------------------------
1 | // demo implements a WASM module that can be used to format markdown
2 | // with the goldmark-toc extension.
3 | package main
4 |
5 | import (
6 | "bytes"
7 | "syscall/js"
8 |
9 | "github.com/yuin/goldmark"
10 | "github.com/yuin/goldmark/parser"
11 | "go.abhg.dev/goldmark/toc"
12 | )
13 |
14 | func main() {
15 | js.Global().Set("formatMarkdown", js.FuncOf(func(this js.Value, args []js.Value) any {
16 | var req request
17 | req.Decode(args[0])
18 | return formatMarkdown(req)
19 | }))
20 | select {}
21 | }
22 |
23 | type request struct {
24 | Markdown string
25 | Title string
26 | MinDepth int
27 | MaxDepth int
28 | Compact bool
29 | }
30 |
31 | func (r *request) Decode(v js.Value) {
32 | r.Markdown = v.Get("markdown").String()
33 | r.Title = v.Get("title").String()
34 | r.MinDepth = v.Get("minDepth").Int()
35 | r.MaxDepth = v.Get("maxDepth").Int()
36 | r.Compact = v.Get("compact").Bool()
37 | }
38 |
39 | func formatMarkdown(req request) string {
40 | md := goldmark.New(
41 | goldmark.WithParserOptions(
42 | parser.WithAutoHeadingID(),
43 | ),
44 | goldmark.WithExtensions(
45 | &toc.Extender{
46 | Title: req.Title,
47 | MinDepth: req.MinDepth,
48 | MaxDepth: req.MaxDepth,
49 | Compact: req.Compact,
50 | },
51 | ),
52 | )
53 |
54 | var buf bytes.Buffer
55 | if err := md.Convert([]byte(req.Markdown), &buf); err != nil {
56 | return err.Error()
57 | }
58 | return buf.String()
59 | }
60 |
--------------------------------------------------------------------------------
/demo/static/.gitignore:
--------------------------------------------------------------------------------
1 | /wasm_exec.js
2 | /main.wasm
3 |
--------------------------------------------------------------------------------
/demo/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | goldmark-toc
7 |
8 |
14 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
97 |
98 |
99 |
123 |
124 |
--------------------------------------------------------------------------------
/doc.go:
--------------------------------------------------------------------------------
1 | // Package toc provides support for building a Table of Contents from a
2 | // goldmark Markdown document.
3 | //
4 | // The package operates in two stages: inspection and rendering. During
5 | // inspection, the package analyzes an existing Markdown document, and builds
6 | // a Table of Contents from it.
7 | //
8 | // markdown := goldmark.New(...)
9 | //
10 | // parser := markdown.Parser()
11 | // doc := parser.Parse(text.NewReader(src))
12 | // tocTree, err := toc.Inspect(doc, src)
13 | //
14 | // During rendering, it converts the Table of Contents into a list of headings
15 | // with nested items under each as a goldmark Markdown document. You may
16 | // manipulate the TOC, removing items from it or simplifying it, before
17 | // rendering.
18 | //
19 | // if len(tocTree.Items) == 0 {
20 | // // No headings in the document.
21 | // return
22 | // }
23 | // tocList := toc.RenderList(tocTree)
24 | //
25 | // You can render that Markdown document using goldmark into whatever form you
26 | // prefer.
27 | //
28 | // renderer := markdown.Renderer()
29 | // renderer.Render(out, src, tocList)
30 | //
31 | // The following diagram summarizes the flow of information with goldmark-toc.
32 | //
33 | // src
34 | // +--------+ +-------------------+
35 | // | | goldmark/Parser.Parse | |
36 | // | []byte :---------------------------> goldmark/ast.Node |
37 | // | | | |
38 | // +---.----+ +-------.-----.-----+
39 | // | | |
40 | // '----------------. .-----------------' |
41 | // \ / |
42 | // \ / |
43 | // | |
44 | // | toc.Inspect |
45 | // | |
46 | // +----v----+ |
47 | // | | |
48 | // | toc.TOC | |
49 | // | | |
50 | // +----.----+ |
51 | // | |
52 | // | toc/Renderer.Render |
53 | // | |
54 | // +---------v---------+ |
55 | // | | |
56 | // | goldmark/ast.Node | |
57 | // | | |
58 | // +---------.---------+ |
59 | // | |
60 | // '-------. .--------------'
61 | // \ /
62 | // |
63 | // goldmark/Renderer.Render |
64 | // |
65 | // v
66 | // +------+
67 | // | HTML |
68 | // +------+
69 | package toc
70 |
--------------------------------------------------------------------------------
/example_test.go:
--------------------------------------------------------------------------------
1 | package toc_test
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/yuin/goldmark"
7 | "github.com/yuin/goldmark/parser"
8 | "github.com/yuin/goldmark/text"
9 | "go.abhg.dev/goldmark/toc"
10 | )
11 |
12 | func Example() {
13 | src := []byte(`
14 | # A section
15 |
16 | Hello
17 |
18 | # Another section
19 |
20 | ## A sub-section
21 |
22 | ### A sub-sub-section
23 |
24 | Bye
25 | `)
26 |
27 | markdown := goldmark.New()
28 |
29 | // Request that IDs are automatically assigned to headers.
30 | markdown.Parser().AddOptions(parser.WithAutoHeadingID())
31 | // Alternatively, we can provide our own implementation of parser.IDs
32 | // and use,
33 | //
34 | // pctx := parser.NewContext(parser.WithIDs(ids))
35 | // doc := parser.Parse(text.NewReader(src), parser.WithContext(pctx))
36 |
37 | doc := markdown.Parser().Parse(text.NewReader(src))
38 |
39 | // Inspect the parsed Markdown document to find headers and build a
40 | // tree for the table of contents.
41 | tree, err := toc.Inspect(doc, src)
42 | if err != nil {
43 | panic(err)
44 | }
45 |
46 | if len(tree.Items) == 0 {
47 | return
48 | // No table of contents because there are no headers.
49 | }
50 |
51 | // Render the tree as-is into a Markdown list.
52 | treeList := toc.RenderList(tree)
53 |
54 | // Render the Markdown list into HTML.
55 | if err := markdown.Renderer().Render(os.Stdout, src, treeList); err != nil {
56 | panic(err)
57 | }
58 |
59 | // Output:
60 | //
74 | }
75 |
--------------------------------------------------------------------------------
/extend.go:
--------------------------------------------------------------------------------
1 | package toc
2 |
3 | import (
4 | "github.com/yuin/goldmark"
5 | "github.com/yuin/goldmark/parser"
6 | "github.com/yuin/goldmark/util"
7 | )
8 |
9 | // Extender extends a Goldmark Markdown parser and renderer to always include
10 | // a table of contents in the output.
11 | //
12 | // To use this, install it into your Goldmark Markdown object.
13 | //
14 | // md := goldmark.New(
15 | // // ...
16 | // goldmark.WithParserOptions(parser.WithAutoHeadingID()),
17 | // goldmark.WithExtensions(
18 | // // ...
19 | // &toc.Extender{
20 | // },
21 | // ),
22 | // )
23 | //
24 | // This will install the default Transformer. For more control, install the
25 | // Transformer directly on the Markdown Parser.
26 | //
27 | // NOTE: Unless you've supplied your own parser.IDs implementation, you'll
28 | // need to enable the WithAutoHeadingID option on the parser to generate IDs
29 | // and links for headings.
30 | type Extender struct {
31 | // Title is the title of the table of contents section.
32 | // Defaults to "Table of Contents" if unspecified.
33 | Title string
34 |
35 | // TitleDepth is the heading depth for the Title.
36 | // Defaults to 1 () if unspecified.
37 | TitleDepth int
38 |
39 | // MinDepth is the minimum depth of the table of contents.
40 | // Headings with a level lower than the specified depth will be ignored.
41 | // See the documentation for MinDepth for more information.
42 | //
43 | // Defaults to 0 (no limit) if unspecified.
44 | MinDepth int
45 |
46 | // MaxDepth is the maximum depth of the table of contents.
47 | // Headings with a level greater than the specified depth will be ignored.
48 | // See the documentation for MaxDepth for more information.
49 | //
50 | // Defaults to 0 (no limit) if unspecified.
51 | MaxDepth int
52 |
53 | // ListID is the id for the list of TOC items rendered in the HTML.
54 | //
55 | // See the documentation for Transformer.ListID for more information.
56 | ListID string
57 |
58 | // TitleID is the id for the Title heading rendered in the HTML.
59 | //
60 | // See the documentation for Transformer.TitleID for more information.
61 | TitleID string
62 |
63 | // Compact controls whether empty items should be removed
64 | // from the table of contents.
65 | //
66 | // See the documentation for Compact for more information.
67 | Compact bool
68 | }
69 |
70 | // Extend adds support for rendering a table of contents to the provided
71 | // Markdown parser/renderer.
72 | func (e *Extender) Extend(md goldmark.Markdown) {
73 | md.Parser().AddOptions(
74 | parser.WithASTTransformers(
75 | util.Prioritized(&Transformer{
76 | Title: e.Title,
77 | TitleDepth: e.TitleDepth,
78 | MinDepth: e.MinDepth,
79 | MaxDepth: e.MaxDepth,
80 | ListID: e.ListID,
81 | TitleID: e.TitleID,
82 | Compact: e.Compact,
83 | }, 100),
84 | ),
85 | )
86 | }
87 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module go.abhg.dev/goldmark/toc
2 |
3 | go 1.22
4 |
5 | toolchain go1.24.3
6 |
7 | require (
8 | github.com/stretchr/testify v1.10.0
9 | github.com/yuin/goldmark v1.7.12
10 | gopkg.in/yaml.v3 v3.0.1
11 | pgregory.net/rapid v1.2.0
12 | )
13 |
14 | require (
15 | github.com/davecgh/go-spew v1.1.1 // indirect
16 | github.com/pmezard/go-difflib v1.0.0 // indirect
17 | )
18 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
7 | github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
8 | github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
13 | pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
14 | pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
15 |
--------------------------------------------------------------------------------
/inspect.go:
--------------------------------------------------------------------------------
1 | package toc
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "io"
7 |
8 | "github.com/yuin/goldmark/ast"
9 | "github.com/yuin/goldmark/util"
10 | )
11 |
12 | // InspectOption customizes the behavior of Inspect.
13 | type InspectOption interface {
14 | apply(*inspectOptions)
15 | }
16 |
17 | type inspectOptions struct {
18 | minDepth int
19 | maxDepth int
20 | compact bool
21 | }
22 |
23 | // MinDepth limits the depth of the table of contents.
24 | // Headings with a level lower than the specified depth will be ignored.
25 | //
26 | // For example, given the following:
27 | //
28 | // # Foo
29 | // ## Bar
30 | // ### Baz
31 | // # Quux
32 | // ## Qux
33 | //
34 | // MinDepth(3) will result in the following:
35 | //
36 | // TOC{Items: ...}
37 | // |
38 | // +--- &Item{Title: "Baz", ID: "baz"}
39 | //
40 | // Whereas, MinDepth(2) will result in the following:
41 | //
42 | // TOC{Items: ...}
43 | // |
44 | // +--- &Item{Title: "Bar", ID: "bar", Items: ...}
45 | // | |
46 | // | +--- &Item{Title: "Baz", ID: "baz"}
47 | // |
48 | // +--- &Item{Title: "Qux", ID: "qux"}
49 | //
50 | // A value of 0 or less will result in no limit.
51 | //
52 | // The default is no limit.
53 | func MinDepth(depth int) InspectOption {
54 | return minDepthOption(depth)
55 | }
56 |
57 | type minDepthOption int
58 |
59 | func (d minDepthOption) apply(opts *inspectOptions) {
60 | opts.minDepth = int(d)
61 | }
62 |
63 | func (d minDepthOption) String() string {
64 | return fmt.Sprintf("MinDepth(%d)", int(d))
65 | }
66 |
67 | // MaxDepth limits the depth of the table of contents.
68 | // Headings with a level greater than the specified depth will be ignored.
69 | //
70 | // For example, given the following:
71 | //
72 | // # Foo
73 | // ## Bar
74 | // ### Baz
75 | // # Quux
76 | // ## Qux
77 | //
78 | // MaxDepth(1) will result in the following:
79 | //
80 | // TOC{Items: ...}
81 | // |
82 | // +--- &Item{Title: "Foo", ID: "foo"}
83 | // |
84 | // +--- &Item{Title: "Quux", ID: "quux", Items: ...}
85 | //
86 | // Whereas, MaxDepth(2) will result in the following:
87 | //
88 | // TOC{Items: ...}
89 | // |
90 | // +--- &Item{Title: "Foo", ID: "foo", Items: ...}
91 | // | |
92 | // | +--- &Item{Title: "Bar", ID: "bar"}
93 | // |
94 | // +--- &Item{Title: "Quux", ID: "quux", Items: ...}
95 | // |
96 | // +--- &Item{Title: "Qux", ID: "qux"}
97 | //
98 | // A value of 0 or less will result in no limit.
99 | //
100 | // The default is no limit.
101 | func MaxDepth(depth int) InspectOption {
102 | return maxDepthOption(depth)
103 | }
104 |
105 | type maxDepthOption int
106 |
107 | func (d maxDepthOption) apply(opts *inspectOptions) {
108 | opts.maxDepth = int(d)
109 | }
110 |
111 | func (d maxDepthOption) String() string {
112 | return fmt.Sprintf("MaxDepth(%d)", int(d))
113 | }
114 |
115 | // Compact instructs Inspect to remove empty items from the table of contents.
116 | // Children of removed items will be promoted to the parent item.
117 | //
118 | // For example, given the following:
119 | //
120 | // # A
121 | // ### B
122 | // #### C
123 | // # D
124 | // #### E
125 | //
126 | // Compact(false), which is the default, will result in the following:
127 | //
128 | // TOC{Items: ...}
129 | // |
130 | // +--- &Item{Title: "A", ...}
131 | // | |
132 | // | +--- &Item{Title: "", ...}
133 | // | |
134 | // | +--- &Item{Title: "B", ...}
135 | // | |
136 | // | +--- &Item{Title: "C"}
137 | // |
138 | // +--- &Item{Title: "D", ...}
139 | // |
140 | // +--- &Item{Title: "", ...}
141 | // |
142 | // +--- &Item{Title: "", ...}
143 | // |
144 | // +--- &Item{Title: "E", ...}
145 | //
146 | // Whereas, Compact(true) will result in the following:
147 | //
148 | // TOC{Items: ...}
149 | // |
150 | // +--- &Item{Title: "A", ...}
151 | // | |
152 | // | +--- &Item{Title: "B", ...}
153 | // | |
154 | // | +--- &Item{Title: "C"}
155 | // |
156 | // +--- &Item{Title: "D", ...}
157 | // |
158 | // +--- &Item{Title: "E", ...}
159 | //
160 | // Notice that the empty items have been removed
161 | // and the generated TOC is more compact.
162 | func Compact(compact bool) InspectOption {
163 | return compactOption(compact)
164 | }
165 |
166 | type compactOption bool
167 |
168 | func (c compactOption) apply(opts *inspectOptions) {
169 | opts.compact = bool(c)
170 | }
171 |
172 | func (c compactOption) String() string {
173 | return fmt.Sprintf("Compact(%v)", bool(c))
174 | }
175 |
176 | // Inspect builds a table of contents by inspecting the provided document.
177 | //
178 | // The table of contents is represents as a tree where each item represents a
179 | // heading or a heading level with zero or more children.
180 | // The returned TOC will be empty if there are no headings in the document.
181 | //
182 | // For example,
183 | //
184 | // # Section 1
185 | // ## Subsection 1.1
186 | // ## Subsection 1.2
187 | // # Section 2
188 | // ## Subsection 2.1
189 | // # Section 3
190 | //
191 | // Will result in the following items.
192 | //
193 | // TOC{Items: ...}
194 | // |
195 | // +--- &Item{Title: "Section 1", ID: "section-1", Items: ...}
196 | // | |
197 | // | +--- &Item{Title: "Subsection 1.1", ID: "subsection-1-1"}
198 | // | |
199 | // | +--- &Item{Title: "Subsection 1.2", ID: "subsection-1-2"}
200 | // |
201 | // +--- &Item{Title: "Section 2", ID: "section-2", Items: ...}
202 | // | |
203 | // | +--- &Item{Title: "Subsection 2.1", ID: "subsection-2-1"}
204 | // |
205 | // +--- &Item{Title: "Section 3", ID: "section-3"}
206 | //
207 | // You may analyze or manipulate the table of contents before rendering it.
208 | func Inspect(n ast.Node, src []byte, options ...InspectOption) (*TOC, error) {
209 | var opts inspectOptions
210 | for _, opt := range options {
211 | opt.apply(&opts)
212 | }
213 |
214 | // Appends an empty subitem to the given node
215 | // and returns a reference to it.
216 | appendChild := func(n *Item) *Item {
217 | child := new(Item)
218 | n.Items = append(n.Items, child)
219 | return child
220 | }
221 |
222 | // Returns the last subitem of the given node,
223 | // creating it if necessary.
224 | lastChild := func(n *Item) *Item {
225 | if len(n.Items) > 0 {
226 | return n.Items[len(n.Items)-1]
227 | }
228 | return appendChild(n)
229 | }
230 |
231 | var root Item
232 |
233 | stack := []*Item{&root} // inv: len(stack) >= 1
234 | err := ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
235 | if !entering {
236 | return ast.WalkContinue, nil
237 | }
238 |
239 | heading, ok := n.(*ast.Heading)
240 | if !ok {
241 | return ast.WalkContinue, nil
242 | }
243 | if opts.minDepth > 0 && heading.Level < opts.minDepth {
244 | return ast.WalkSkipChildren, nil
245 | }
246 |
247 | if opts.maxDepth > 0 && heading.Level > opts.maxDepth {
248 | return ast.WalkSkipChildren, nil
249 | }
250 |
251 | // The heading is deeper than the current depth.
252 | // Append empty items to match the heading's level.
253 | for len(stack) < heading.Level {
254 | parent := stack[len(stack)-1]
255 | stack = append(stack, lastChild(parent))
256 | }
257 |
258 | // The heading is shallower than the current depth.
259 | // Move back up the stack until we reach the heading's level.
260 | if len(stack) > heading.Level {
261 | stack = stack[:heading.Level]
262 | }
263 |
264 | parent := stack[len(stack)-1]
265 | target := lastChild(parent)
266 | if len(target.Title) > 0 || len(target.Items) > 0 {
267 | target = appendChild(parent)
268 | }
269 |
270 | target.Title = util.UnescapePunctuations(nodeText(src, heading))
271 | if id, ok := n.AttributeString("id"); ok {
272 | target.ID, _ = id.([]byte)
273 | }
274 |
275 | return ast.WalkSkipChildren, nil
276 | })
277 |
278 | if opts.compact {
279 | compactItems(&root.Items)
280 | }
281 |
282 | return &TOC{Items: root.Items}, err
283 | }
284 |
285 | // compactItems removes items with no titles
286 | // from the given list of items.
287 | //
288 | // Children of removed items will be promoted to the parent item.
289 | func compactItems(items *Items) {
290 | for i := 0; i < len(*items); i++ {
291 | item := (*items)[i]
292 | if len(item.Title) > 0 {
293 | compactItems(&item.Items)
294 | continue
295 | }
296 |
297 | children := item.Items
298 | newItems := make(Items, 0, len(*items)-1+len(children))
299 | newItems = append(newItems, (*items)[:i]...)
300 | newItems = append(newItems, children...)
301 | newItems = append(newItems, (*items)[i+1:]...)
302 | *items = newItems
303 | i-- // start with first child
304 | }
305 | }
306 |
307 | func nodeText(src []byte, n ast.Node) []byte {
308 | var buf bytes.Buffer
309 | writeNodeText(src, &buf, n)
310 | return buf.Bytes()
311 | }
312 |
313 | func writeNodeText(src []byte, dst io.Writer, n ast.Node) {
314 | switch n := n.(type) {
315 | case *ast.Text:
316 | _, _ = dst.Write(n.Segment.Value(src))
317 | case *ast.String:
318 | _, _ = dst.Write(n.Value)
319 | default:
320 | for c := n.FirstChild(); c != nil; c = c.NextSibling() {
321 | writeNodeText(src, dst, c)
322 | }
323 | }
324 | }
325 |
--------------------------------------------------------------------------------
/inspect_test.go:
--------------------------------------------------------------------------------
1 | package toc
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "strconv"
7 | "strings"
8 | "testing"
9 |
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | "github.com/yuin/goldmark/parser"
13 | "github.com/yuin/goldmark/text"
14 | "pgregory.net/rapid"
15 | )
16 |
17 | func item(title, id string, items ...*Item) *Item {
18 | n := new(Item)
19 | if len(title) > 0 {
20 | n.Title = []byte(title)
21 | }
22 | if len(id) > 0 {
23 | n.ID = []byte(id)
24 | }
25 | for _, item := range items {
26 | n.Items = append(n.Items, item)
27 | }
28 | return n
29 | }
30 |
31 | func TestInspect(t *testing.T) {
32 | t.Parallel()
33 |
34 | tests := []struct {
35 | desc string
36 | give []string // lines of a doc
37 | opts []InspectOption
38 | want Items
39 | }{
40 | {
41 | desc: "empty",
42 | give: nil,
43 | },
44 | {
45 | desc: "single level",
46 | give: []string{
47 | "# Foo",
48 | "# Bar",
49 | "# Baz",
50 | },
51 | want: Items{
52 | item("Foo", "foo"),
53 | item("Bar", "bar"),
54 | item("Baz", "baz"),
55 | },
56 | },
57 | {
58 | desc: "subitems",
59 | give: []string{
60 | "# Foo",
61 | "## Bar",
62 | "## Baz",
63 | },
64 | want: Items{
65 | item("Foo", "foo",
66 | item("Bar", "bar"),
67 | item("Baz", "baz"),
68 | ),
69 | },
70 | },
71 | {
72 | desc: "decrease level",
73 | give: []string{
74 | "# Foo",
75 | "## Bar",
76 | "# Baz",
77 | "# Qux",
78 | },
79 | want: Items{
80 | item("Foo", "foo",
81 | item("Bar", "bar"),
82 | ),
83 | item("Baz", "baz"),
84 | item("Qux", "qux"),
85 | },
86 | },
87 | {
88 | desc: "alternating levels",
89 | give: []string{
90 | "# Foo",
91 | "## Bar",
92 | "# Baz",
93 | "## Qux",
94 | "# Quux",
95 | },
96 | want: Items{
97 | item("Foo", "foo",
98 | item("Bar", "bar"),
99 | ),
100 | item("Baz", "baz",
101 | item("Qux", "qux"),
102 | ),
103 | item("Quux", "quux"),
104 | },
105 | },
106 | {
107 | desc: "several levels offset",
108 | give: []string{
109 | "# A",
110 | "###### B",
111 | "### C",
112 | "##### D",
113 | "## E",
114 | "# F",
115 | "# G",
116 | },
117 | // Levels:
118 | // 1 2 3 4 5 6
119 | want: Items{
120 | item("A", "a",
121 | item("", "",
122 | item("", "",
123 | item("", "",
124 | item("", "",
125 | item("B", "b"),
126 | ),
127 | ),
128 | ),
129 | item("C", "c",
130 | item("", "",
131 | item("D", "d"),
132 | ),
133 | ),
134 | ),
135 | item("E", "e"),
136 | ),
137 | item("F", "f"),
138 | item("G", "g"),
139 | },
140 | },
141 | {
142 | desc: "escaped punctuation in title",
143 | give: []string{
144 | `# Foo\-Bar`,
145 | `## Bar\-Baz`,
146 | },
147 | want: Items{
148 | item("Foo-Bar", "foo-bar",
149 | item("Bar-Baz", "bar-baz"),
150 | ),
151 | },
152 | },
153 | {
154 | desc: "minDepth",
155 | give: []string{
156 | "# A",
157 | "###### B",
158 | "### C",
159 | "##### D",
160 | "## E",
161 | "# F",
162 | "# G",
163 | },
164 | opts: []InspectOption{MinDepth(3)},
165 | want: Items{
166 | item("", "",
167 | item("", "",
168 | item("", "",
169 | item("", "",
170 | item("", "",
171 | item("B", "b")))),
172 | item("C", "c",
173 | item("", "",
174 | item("D", "d"))))),
175 | },
176 | },
177 | {
178 | desc: "minDepth/compact",
179 | give: []string{
180 | "# A",
181 | "###### B",
182 | "### C",
183 | "##### D",
184 | "## E",
185 | "# F",
186 | "# G",
187 | },
188 | opts: []InspectOption{MinDepth(3), Compact(true)},
189 | want: Items{
190 | item("B", "b"),
191 | item("C", "c",
192 | item("D", "d")),
193 | },
194 | },
195 | {
196 | desc: "maxDepth",
197 | give: []string{
198 | "# A",
199 | "###### B",
200 | "### C",
201 | "##### D",
202 | "## E",
203 | "# F",
204 | "# G",
205 | },
206 | opts: []InspectOption{MaxDepth(3)},
207 | want: Items{
208 | item("A", "a",
209 | item("", "",
210 | item("C", "c")),
211 | item("E", "e")),
212 | item("F", "f"),
213 | item("G", "g"),
214 | },
215 | },
216 | {
217 | desc: "compact",
218 | give: []string{
219 | "# A",
220 | "### B",
221 | "#### C",
222 | "# D",
223 | "#### E",
224 | },
225 | opts: []InspectOption{Compact(true)},
226 | want: Items{
227 | item("A", "a",
228 | item("B", "b",
229 | item("C", "c"),
230 | ),
231 | ),
232 | item("D", "d",
233 | item("E", "e"),
234 | ),
235 | },
236 | },
237 | {
238 | desc: "compact complex",
239 | give: []string{
240 | "## A",
241 | "##### B",
242 | "###### C",
243 | "## D",
244 | "# E",
245 | "### F",
246 | "# G",
247 | "#### H",
248 | "### I",
249 | "## J",
250 | },
251 | opts: []InspectOption{Compact(true)},
252 | want: Items{
253 | item("A", "a",
254 | item("B", "b",
255 | item("C", "c"),
256 | ),
257 | ),
258 | item("D", "d"),
259 | item("E", "e",
260 | item("F", "f"),
261 | ),
262 | item("G", "g",
263 | item("H", "h"),
264 | item("I", "i"),
265 | item("J", "j"),
266 | ),
267 | },
268 | },
269 | }
270 |
271 | for _, tt := range tests {
272 | tt := tt
273 | t.Run(tt.desc, func(t *testing.T) {
274 | t.Parallel()
275 |
276 | src := []byte(strings.Join(tt.give, "\n") + "\n")
277 | doc := parser.NewParser(
278 | parser.WithInlineParsers(parser.DefaultInlineParsers()...),
279 | parser.WithBlockParsers(parser.DefaultBlockParsers()...),
280 | parser.WithAutoHeadingID(),
281 | ).Parse(text.NewReader(src))
282 |
283 | got, err := Inspect(doc, src, tt.opts...)
284 | require.NoError(t, err, "inspect error")
285 | assert.Equal(t, &TOC{Items: tt.want}, got)
286 | })
287 | }
288 | }
289 |
290 | func TestInspectOption_String(t *testing.T) {
291 | t.Parallel()
292 |
293 | tests := []struct {
294 | give InspectOption
295 | want string
296 | }{
297 | {give: MinDepth(3), want: "MinDepth(3)"},
298 | {give: MinDepth(0), want: "MinDepth(0)"},
299 | {give: MinDepth(-1), want: "MinDepth(-1)"},
300 | {give: MaxDepth(3), want: "MaxDepth(3)"},
301 | {give: MaxDepth(0), want: "MaxDepth(0)"},
302 | {give: MaxDepth(-1), want: "MaxDepth(-1)"},
303 | {give: Compact(true), want: "Compact(true)"},
304 | }
305 |
306 | for _, tt := range tests {
307 | tt := tt
308 | t.Run(tt.want, func(t *testing.T) {
309 | t.Parallel()
310 |
311 | assert.Equal(t, tt.want, fmt.Sprint(tt.give))
312 | })
313 | }
314 | }
315 |
316 | func TestInspectRandomHeadings(t *testing.T) {
317 | t.Parallel()
318 |
319 | rapid.Check(t, testInspectRandomHeadings)
320 | }
321 |
322 | func FuzzInspectRandomHeadings(f *testing.F) {
323 | f.Fuzz(rapid.MakeFuzz(testInspectRandomHeadings))
324 | }
325 |
326 | func testInspectRandomHeadings(t *rapid.T) {
327 | // Generate a random hierarchy.
328 | levels := rapid.SliceOf(rapid.IntRange(1, 6)).Draw(t, "levels")
329 | var buf bytes.Buffer
330 | for i, level := range levels {
331 | buf.WriteString(strings.Repeat("#", level))
332 | buf.WriteString(" Heading ")
333 | buf.WriteString(strconv.Itoa(i))
334 | buf.WriteByte('\n')
335 | }
336 |
337 | src := buf.Bytes()
338 | doc := parser.NewParser(
339 | parser.WithInlineParsers(parser.DefaultInlineParsers()...),
340 | parser.WithBlockParsers(parser.DefaultBlockParsers()...),
341 | parser.WithAutoHeadingID(),
342 | ).Parse(text.NewReader(src))
343 |
344 | toc, err := Inspect(doc, src)
345 | require.NoError(t, err, "inspect error")
346 |
347 | // Verify that the number of items in the TOC is the same as the number
348 | // of headings in the document.
349 | assert.Equal(t, len(levels), nonEmptyItems(toc.Items),
350 | "number of non-empty items in TOC "+
351 | "does not match number of headings in document:\n%s", src)
352 | }
353 |
354 | func TestInspectCompactRandomHeadings(t *testing.T) {
355 | t.Parallel()
356 |
357 | rapid.Check(t, testInspectCompactRandomHeadings)
358 | }
359 |
360 | func FuzzInspectCompactRandomHeadings(f *testing.F) {
361 | f.Fuzz(rapid.MakeFuzz(testInspectCompactRandomHeadings))
362 | }
363 |
364 | func testInspectCompactRandomHeadings(t *rapid.T) {
365 | // Generate a random hierarchy.
366 | levels := rapid.SliceOf(rapid.IntRange(1, 6)).Draw(t, "levels")
367 | var buf bytes.Buffer
368 | for i, level := range levels {
369 | buf.WriteString(strings.Repeat("#", level))
370 | buf.WriteString(" Heading ")
371 | buf.WriteString(strconv.Itoa(i))
372 | buf.WriteByte('\n')
373 | }
374 |
375 | src := buf.Bytes()
376 | doc := parser.NewParser(
377 | parser.WithInlineParsers(parser.DefaultInlineParsers()...),
378 | parser.WithBlockParsers(parser.DefaultBlockParsers()...),
379 | parser.WithAutoHeadingID(),
380 | ).Parse(text.NewReader(src))
381 |
382 | toc, err := Inspect(doc, src, Compact(true))
383 | require.NoError(t, err, "inspect error")
384 |
385 | // There must be no empty items in the TOC.
386 | assert.Equal(t, nonEmptyItems(toc.Items), totalItems(toc.Items),
387 | "number of non-empty items in TOC "+
388 | "does not match number of items in TOC:\n%s", src)
389 | assert.Equal(t, len(levels), totalItems(toc.Items),
390 | "number of items in TOC "+
391 | "does not match number of headings in document:\n%s", src)
392 | }
393 |
394 | func totalItems(items Items) (total int) {
395 | for _, item := range items {
396 | total++
397 | total += totalItems(item.Items)
398 | }
399 | return total
400 | }
401 |
402 | func nonEmptyItems(items Items) (total int) {
403 | for _, item := range items {
404 | if len(item.Title) > 0 {
405 | total++
406 | }
407 | total += nonEmptyItems(item.Items)
408 | }
409 | return total
410 | }
411 |
--------------------------------------------------------------------------------
/integration_test.go:
--------------------------------------------------------------------------------
1 | package toc_test
2 |
3 | import (
4 | "bytes"
5 | "os"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/require"
9 | "github.com/yuin/goldmark"
10 | "github.com/yuin/goldmark/parser"
11 | "go.abhg.dev/goldmark/toc"
12 | "gopkg.in/yaml.v3"
13 | )
14 |
15 | func TestIntegration(t *testing.T) {
16 | t.Parallel()
17 |
18 | testsdata, err := os.ReadFile("testdata/tests.yaml")
19 | require.NoError(t, err)
20 |
21 | var tests []struct {
22 | Desc string `yaml:"desc"`
23 | Give string `yaml:"give"`
24 | Want string `yaml:"want"`
25 | Title string `yaml:"title"`
26 | TitleDepth int `yaml:"titleDepth"`
27 | ListID string `yaml:"listID"`
28 | TitleID string `yaml:"titleID"`
29 |
30 | MinDepth int `yaml:"minDepth"`
31 | MaxDepth int `yaml:"maxDepth"`
32 | Compact bool `yaml:"compact"`
33 | }
34 | require.NoError(t, yaml.Unmarshal(testsdata, &tests))
35 |
36 | for _, tt := range tests {
37 | tt := tt
38 | t.Run(tt.Desc, func(t *testing.T) {
39 | t.Parallel()
40 |
41 | md := goldmark.New(
42 | goldmark.WithExtensions(&toc.Extender{
43 | Title: tt.Title,
44 | TitleDepth: tt.TitleDepth,
45 | MinDepth: tt.MinDepth,
46 | MaxDepth: tt.MaxDepth,
47 | Compact: tt.Compact,
48 | ListID: tt.ListID,
49 | TitleID: tt.TitleID,
50 | }),
51 | goldmark.WithParserOptions(parser.WithAutoHeadingID()),
52 | )
53 |
54 | var buf bytes.Buffer
55 | require.NoError(t, md.Convert([]byte(tt.Give), &buf))
56 | require.Equal(t, tt.Want, buf.String())
57 | })
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/mise.lock:
--------------------------------------------------------------------------------
1 | [tools."aqua:golangci/golangci-lint"]
2 | version = "2.1.2"
3 | backend = "aqua:golangci/golangci-lint"
4 |
5 | [tools."aqua:golangci/golangci-lint".checksums]
6 | "golangci-lint-2.1.2-darwin-arm64.tar.gz" = "sha256:1cff60651d7c95a4248fa72f0dd020bffed1d2dc4dd8c2c77aee89a0731fa615"
7 |
8 | [tools.go]
9 | version = "1.24.1"
10 | backend = "core:go"
11 |
12 | [tools."ubi:abhinav/doc2go"]
13 | version = "0.8.1"
14 | backend = "ubi:abhinav/doc2go"
15 |
16 | [tools."ubi:miniscruff/changie"]
17 | version = "1.21.1"
18 | backend = "ubi:miniscruff/changie"
19 |
--------------------------------------------------------------------------------
/mise.oldstable.toml:
--------------------------------------------------------------------------------
1 | # Run with 'MISE_ENV=oldstable' to run with the oldstable environment.
2 | [tools]
3 | go = "1.23"
4 |
--------------------------------------------------------------------------------
/mise.toml:
--------------------------------------------------------------------------------
1 | [env]
2 | GOBIN = "{{ config_root }}/bin"
3 | _.path = ["{{ config_root }}/bin"]
4 |
5 | [tools]
6 | "ubi:abhinav/doc2go" = "latest"
7 | "aqua:golangci/golangci-lint" = "latest"
8 | "ubi:miniscruff/changie" = "latest"
9 | go = "latest"
10 |
11 | [tasks.build]
12 | run = "go build ./..."
13 | description = "Build the project"
14 |
15 | [tasks.test]
16 | description = "Run tests"
17 | run = "go test -race ./..."
18 |
19 | [tasks.cover]
20 | description = "Run tests with coverage"
21 | run = [
22 | "go test -race -coverprofile=cover.out -coverpkg=./... ./...",
23 | "go tool cover -html=cover.out -o cover.html"
24 | ]
25 |
26 | [tasks.lint]
27 | description = "Run all linters"
28 | depends = ["lint:*"]
29 |
30 | [tasks."lint:tidy"]
31 | description = "Ensure go.mod is tidy"
32 | run = "go mod tidy -diff"
33 |
34 | [tasks."lint:golangci"]
35 | description = "Run golangci-lint"
36 | run = "golangci-lint run"
37 |
38 | [tasks."release:prepare"]
39 | description = "Prepare a release"
40 | run = [
41 | "changie batch {{arg(name='version')}}",
42 | "changie merge",
43 | ]
44 |
45 |
--------------------------------------------------------------------------------
/render.go:
--------------------------------------------------------------------------------
1 | package toc
2 |
3 | import "github.com/yuin/goldmark/ast"
4 |
5 | const _defaultMarker = '*'
6 |
7 | // RenderList renders a table of contents as a nested list with a sane,
8 | // default configuration for the ListRenderer.
9 | //
10 | // If the TOC is nil or empty, nil is returned.
11 | // Do not call Goldmark's renderer if the returned node is nil.
12 | func RenderList(toc *TOC) ast.Node {
13 | return new(ListRenderer).Render(toc)
14 | }
15 |
16 | // RenderOrderedList renders a table of contents as a nested, ordered
17 | // list with a sane, default configuration for the ListRenderer.
18 | //
19 | // If the TOC is nil or empty, nil is returned.
20 | // Do not call Goldmark's renderer if the returned node is nil.
21 | func RenderOrderedList(toc *TOC) ast.Node {
22 | renderer := ListRenderer{Marker: '.'}
23 | return renderer.Render(toc)
24 | }
25 |
26 | // ListRenderer builds a nested list from a table of contents.
27 | //
28 | // For example,
29 | //
30 | // # Foo
31 | // ## Bar
32 | // ## Baz
33 | // # Qux
34 | //
35 | // // becomes
36 | //
37 | // - Foo
38 | // - Bar
39 | // - Baz
40 | // - Qux
41 | type ListRenderer struct {
42 | // Marker for elements of the list, e.g. '-', '*', etc.
43 | //
44 | // Defaults to '*'.
45 | Marker byte
46 | }
47 |
48 | // Render renders the table of contents into Markdown.
49 | //
50 | // If the TOC is nil or empty, nil is returned.
51 | // Do not call Goldmark's renderer if the returned node is nil.
52 | func (r *ListRenderer) Render(toc *TOC) ast.Node {
53 | if toc == nil {
54 | return nil
55 | }
56 | return r.renderItems(toc.Items)
57 | }
58 |
59 | func (r *ListRenderer) renderItems(items Items) ast.Node {
60 | if len(items) == 0 {
61 | return nil
62 | }
63 |
64 | mkr := r.Marker
65 | if mkr == 0 {
66 | mkr = _defaultMarker
67 | }
68 |
69 | list := ast.NewList(mkr)
70 | if list.IsOrdered() {
71 | list.Start = 1
72 | }
73 | for _, item := range items {
74 | list.AppendChild(list, r.renderItem(item))
75 | }
76 | return list
77 | }
78 |
79 | func (r *ListRenderer) renderItem(n *Item) ast.Node {
80 | item := ast.NewListItem(0)
81 |
82 | if t := n.Title; len(t) > 0 {
83 | title := ast.NewString(t)
84 | title.SetRaw(true)
85 | if len(n.ID) > 0 {
86 | link := ast.NewLink()
87 | link.Destination = append([]byte("#"), n.ID...)
88 | link.AppendChild(link, title)
89 | item.AppendChild(item, link)
90 | } else {
91 | item.AppendChild(item, title)
92 | }
93 | }
94 |
95 | if items := r.renderItems(n.Items); items != nil {
96 | item.AppendChild(item, items)
97 | }
98 |
99 | return item
100 | }
101 |
--------------------------------------------------------------------------------
/render_test.go:
--------------------------------------------------------------------------------
1 | package toc
2 |
3 | import (
4 | "bytes"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | "github.com/stretchr/testify/require"
9 | "github.com/yuin/goldmark"
10 | "github.com/yuin/goldmark/ast"
11 | )
12 |
13 | type list []*listItem
14 |
15 | func (tt list) Match(t *testing.T, got ast.Node) {
16 | t.Helper()
17 |
18 | if len(tt) == 0 {
19 | assert.Nil(t, got, "should not have a list")
20 | return
21 | }
22 |
23 | assert.Equal(t, len(tt), got.ChildCount(), "child count mismatch")
24 |
25 | child := got.FirstChild()
26 | for _, want := range tt {
27 | li, ok := child.(*ast.ListItem)
28 | if assert.True(t, ok, "child must be ListItem, got %T", child) {
29 | want.Match(t, li)
30 | }
31 | child = child.NextSibling()
32 | }
33 | }
34 |
35 | type listItem struct {
36 | Text string
37 |
38 | // If non-empty, Text should be inside a link.
39 | Href string
40 |
41 | List list
42 | }
43 |
44 | func (tt *listItem) Match(t *testing.T, got *ast.ListItem) {
45 | t.Helper()
46 |
47 | childCount := 0
48 | if len(tt.Text) > 0 {
49 | childCount++
50 | }
51 | if len(tt.List) > 0 {
52 | childCount++
53 | }
54 |
55 | assert.Equal(t, childCount, got.ChildCount(), "child count mismatch")
56 |
57 | child := got.FirstChild()
58 | if want := tt.Text; len(want) > 0 {
59 | if href := tt.Href; len(href) > 0 {
60 | a, ok := child.(*ast.Link)
61 | if assert.True(t, ok, "expected link, got %T", child) {
62 | assert.Equal(t, href, string(a.Destination), "destination mismatch")
63 | }
64 | }
65 |
66 | assert.Equal(t, want, string(nodeText(nil /* src */, child)))
67 | child = child.NextSibling()
68 | }
69 |
70 | if want := tt.List; len(want) > 0 {
71 | ul, ok := child.(*ast.List)
72 | if assert.True(t, ok, "child must be List, got %T", child) {
73 | want.Match(t, ul)
74 | }
75 | }
76 | }
77 |
78 | func TestRenderList(t *testing.T) {
79 | t.Parallel()
80 |
81 | tests := []struct {
82 | desc string
83 | give Items
84 | want list
85 | }{
86 | {
87 | desc: "empty",
88 | want: list{},
89 | },
90 | {
91 | desc: "plain",
92 | give: Items{
93 | item("foo", ""),
94 | },
95 | want: list{
96 | {Text: "foo"},
97 | },
98 | },
99 | {
100 | desc: "id",
101 | give: Items{
102 | item("foo", "foo"),
103 | },
104 | want: list{
105 | {Text: "foo", Href: "#foo"},
106 | },
107 | },
108 | {
109 | desc: "siblings",
110 | give: Items{
111 | item("foo", "foo"),
112 | item("bar", ""),
113 | item("baz", "baz"),
114 | item("qux", ""),
115 | },
116 | want: list{
117 | {Text: "foo", Href: "#foo"},
118 | {Text: "bar"},
119 | {Text: "baz", Href: "#baz"},
120 | {Text: "qux"},
121 | },
122 | },
123 | {
124 | desc: "subitems",
125 | give: Items{
126 | item("Foo", "foo",
127 | item("Bar", "bar"),
128 | item("Baz", "baz"),
129 | ),
130 | },
131 | want: list{
132 | {
133 | Text: "Foo",
134 | Href: "#foo",
135 | List: list{
136 | {Text: "Bar", Href: "#bar"},
137 | {Text: "Baz", Href: "#baz"},
138 | },
139 | },
140 | },
141 | },
142 | {
143 | desc: "decrease level",
144 | give: Items{
145 | item("Foo", "foo",
146 | item("Bar", "bar"),
147 | ),
148 | item("Baz", "baz"),
149 | item("Qux", "qux"),
150 | },
151 | want: list{
152 | {
153 | Text: "Foo",
154 | Href: "#foo",
155 | List: list{
156 | {Text: "Bar", Href: "#bar"},
157 | },
158 | },
159 | {Text: "Baz", Href: "#baz"},
160 | {Text: "Qux", Href: "#qux"},
161 | },
162 | },
163 | {
164 | desc: "several levels offset",
165 | // 1 2 3 4 5 6
166 | give: Items{
167 | item("A", "a",
168 | item("", "",
169 | item("", "",
170 | item("", "",
171 | item("", "",
172 | item("B", "b"),
173 | ),
174 | ),
175 | ),
176 | item("C", "c",
177 | item("", "",
178 | item("D", "d"),
179 | ),
180 | ),
181 | ),
182 | item("E", "e"),
183 | ),
184 | item("F", "f"),
185 | item("G", "g"),
186 | },
187 | // 1 2 3 4 5 6
188 | want: list{
189 | {
190 | Text: "A",
191 | Href: "#a",
192 | List: list{
193 | {
194 | List: list{
195 | {
196 | List: list{
197 | {
198 | List: list{
199 | {
200 | List: list{
201 | {
202 | Text: "B",
203 | Href: "#b",
204 | },
205 | },
206 | },
207 | },
208 | },
209 | },
210 | },
211 | {
212 | Text: "C",
213 | Href: "#c",
214 | List: list{
215 | {
216 | List: list{
217 | {Text: "D", Href: "#d"},
218 | },
219 | },
220 | },
221 | },
222 | },
223 | },
224 | {Text: "E", Href: "#e"},
225 | },
226 | },
227 | {Text: "F", Href: "#f"},
228 | {Text: "G", Href: "#g"},
229 | },
230 | },
231 | }
232 |
233 | for _, tt := range tests {
234 | tt := tt
235 | t.Run(tt.desc, func(t *testing.T) {
236 | t.Parallel()
237 |
238 | got := RenderList(&TOC{Items: tt.give})
239 | tt.want.Match(t, got)
240 | })
241 | }
242 | }
243 |
244 | func TestRenderList_id(t *testing.T) {
245 | t.Parallel()
246 |
247 | node := RenderList(&TOC{
248 | Items: Items{
249 | item("Foo", "foo",
250 | item("Bar", "bar"),
251 | item("Baz", "baz"),
252 | ),
253 | },
254 | })
255 | node.SetAttribute([]byte("id"), []byte("toc"))
256 |
257 | var buf bytes.Buffer
258 | err := goldmark.DefaultRenderer().Render(&buf, nil, node)
259 | require.NoError(t, err)
260 |
261 | assert.Contains(t, buf.String(), ``)
262 | }
263 |
264 | func TestRenderList_nil(t *testing.T) {
265 | t.Parallel()
266 |
267 | assert.Nil(t, RenderList(nil))
268 | }
269 |
270 | func TestOrderedList(t *testing.T) {
271 | t.Parallel()
272 |
273 | node := RenderOrderedList(&TOC{
274 | Items: Items{
275 | item("Foo", "foo",
276 | item("Bar", "bar"),
277 | item("Baz", "baz"),
278 | ),
279 | },
280 | })
281 |
282 | var buf bytes.Buffer
283 | err := goldmark.DefaultRenderer().Render(&buf, nil, node)
284 | require.NoError(t, err)
285 |
286 | assert.Contains(t, buf.String(), ``)
287 | assert.NotContains(t, buf.String(), "start=")
288 | }
289 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "local>abhinav/renovate-config"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/testdata/rapid/TestInspectCompactRandomHeadings/TestInspectCompactRandomHeadings-20230911051733-28626.fail:
--------------------------------------------------------------------------------
1 | # 2023/09/11 05:17:33.722915 [TestInspectCompactRandomHeadings] [rapid] draw levels: []int{2, 5, 6, 2, 1, 3, 1, 4, 3, 2}
2 | # 2023/09/11 05:17:33.723216 [TestInspectCompactRandomHeadings]
3 | # Error Trace: inspect_test.go:308
4 | # engine.go:368
5 | # engine.go:377
6 | # engine.go:203
7 | # engine.go:118
8 | # inspect_test.go:279
9 | # Error: Not equal:
10 | # expected: 10
11 | # actual : 11
12 | # Test: TestInspectCompactRandomHeadings
13 | # Messages: number of items in TOC does not match the number of headings in document:
14 | # ## Heading 0
15 | # ##### Heading 1
16 | # ###### Heading 2
17 | # ## Heading 3
18 | # # Heading 4
19 | # ### Heading 5
20 | # # Heading 6
21 | # #### Heading 7
22 | # ### Heading 8
23 | # ## Heading 9
24 | #
25 | v0.4.8#15085304404995422177
26 | 0x19e69db68bc036
27 | 0x24b2760ec7eea
28 | 0x361cf0f89a682
29 | 0x1
30 | 0x114bbf838dcdca
31 | 0xa99b52221b8f2
32 | 0xaa728ac0c3547
33 | 0x4
34 | 0xc319bcd62bde9
35 | 0x1619b1a7f18de4
36 | 0x1f3f0819edfced
37 | 0xffffffffffffffff
38 | 0xf33d03ec7011b
39 | 0xe7a41614a516f
40 | 0x11f801679f14d
41 | 0x1
42 | 0x1e0c62b0aef7f3
43 | 0x1083214304416c
44 | 0x15c0d0227625e1
45 | 0x7
46 | 0x0
47 | 0x93b9472e87f78
48 | 0x18d7fd4765e191
49 | 0xa2002bbe747c1
50 | 0x2
51 | 0xf90b18698fc9b
52 | 0x88c2784cdf072
53 | 0x1d89327185965b
54 | 0x0
55 | 0x1cd1834e693805
56 | 0x1ca752d0b81d79
57 | 0x1aaf49eefd05f7
58 | 0x6
59 | 0x3
60 | 0x13e6badd851116
61 | 0x1566b82ad6e58b
62 | 0x1e1087cd9c9328
63 | 0x2
64 | 0x19fd0c5ddd4b5f
65 | 0x11063d20c9ab63
66 | 0xe15850039e3cb
67 | 0x1
68 | 0x2921518ffb8f7
--------------------------------------------------------------------------------
/testdata/rapid/TestInspect_rapid/TestInspect_rapid-20230911050748-19573.fail:
--------------------------------------------------------------------------------
1 | # 2023/09/11 05:07:48.217912 [TestInspect_rapid] [rapid] draw levels: []int{5, 5, 3}
2 | # 2023/09/11 05:07:48.218098 [TestInspect_rapid]
3 | # Error Trace: inspect_test.go:263
4 | # engine.go:368
5 | # engine.go:377
6 | # engine.go:203
7 | # engine.go:118
8 | # inspect_test.go:241
9 | # Error: Not equal:
10 | # expected: 5
11 | # actual : 3
12 | # Test: TestInspect_rapid
13 | # Messages: number of items in TOC does not match number of headings in document for headings at levels: [5 5 3]
14 | #
15 | v0.4.8#11432231941130037350
16 | 0x1a2114dbb9dddc
17 | 0xace527c94949d
18 | 0x1971bf7670446a
19 | 0x4
20 | 0x8525d57e698fe
21 | 0x124700ca6cd931
22 | 0x15703ea2934efe
23 | 0x4
24 | 0x7ea3fc979834b
25 | 0x17be3bd12902a7
26 | 0x1a32d19baee9a6
27 | 0x2
28 | 0x19d8bf33c52c7
--------------------------------------------------------------------------------
/testdata/tests.yaml:
--------------------------------------------------------------------------------
1 | - desc: no headers
2 | give: |
3 | No headers.
4 | want: |
5 | No headers.
6 |
7 | - desc: single
8 | give: |
9 | # Hello
10 |
11 | World
12 | want: |
13 | Table of Contents
14 |
18 | Hello
19 | World
20 |
21 | - desc: multiple levels
22 | give: |
23 | # Foo
24 |
25 | ## Bar
26 |
27 | # Baz
28 |
29 | ### Qux
30 | want: |
31 | Table of Contents
32 |
33 | -
34 | Foo
38 |
39 | -
40 | Baz
48 |
49 |
50 | Foo
51 | Bar
52 | Baz
53 | Qux
54 |
55 | - desc: with slash
56 | give: |
57 | # Foo\-Bar
58 |
59 | ## Bar\-Baz
60 | want: |
61 | Table of Contents
62 |
70 | Foo-Bar
71 | Bar-Baz
72 |
73 | - desc: escaped slash
74 | give: |
75 | # Foo\\\-Bar
76 | want: |
77 | Table of Contents
78 |
82 | Foo\-Bar
83 |
84 | - desc: formatted
85 | give: |
86 | # **Formatted** `header`
87 | want: |
88 | Table of Contents
89 |
93 |
94 |
95 | - desc: title change
96 | title: Contents
97 | give: |
98 | # Hello
99 |
100 | World
101 | want: |
102 | Contents
103 |
107 | Hello
108 | World
109 |
110 | - desc: maxDepth
111 | maxDepth: 1
112 | give: |
113 | # Foo
114 |
115 | ## Bar
116 |
117 | # Baz
118 |
119 | ### Qux
120 | want: |
121 | Table of Contents
122 |
123 | -
124 | Foo
125 | -
126 | Baz
127 |
128 | Foo
129 | Bar
130 | Baz
131 | Qux
132 | - desc: minDepth
133 | minDepth: 3
134 | give: |
135 | # Foo
136 |
137 | ## Bar
138 |
139 | # Baz
140 |
141 | ### Qux
142 | want: |
143 | Table of Contents
144 |
156 | Foo
157 | Bar
158 | Baz
159 | Qux
160 |
161 | - desc: list id
162 | listID: my-toc
163 | give: |
164 | # Hello
165 |
166 | # World
167 | want: |
168 | Table of Contents
169 |
175 | Hello
176 | World
177 |
178 | # From https://github.com/abhinav/goldmark-toc/issues/42
179 | - desc: compact single
180 | compact: true
181 | give: |
182 | ### h3
183 | want: |
184 | Table of Contents
185 |
189 | h3
190 |
191 | - desc: compact multiple
192 | compact: true
193 | give: |
194 | # h1
195 | ### h3
196 | want: |
197 | Table of Contents
198 |
199 | -
200 | h1
204 |
205 |
206 | h1
207 | h3
208 |
209 | - desc: custom title ID
210 | titleID: toc-title
211 | give: |
212 | # Foo
213 |
214 | ## Bar
215 |
216 | # Baz
217 |
218 | ### Qux
219 | want: |
220 | Table of Contents
221 |
222 | -
223 | Foo
227 |
228 | -
229 | Baz
237 |
238 |
239 | Foo
240 | Bar
241 | Baz
242 | Qux
243 |
244 | # From: https://github.com/abhinav/goldmark-toc/issues/61
245 | - desc: custom title depth of 2
246 | titleDepth: 2
247 | give: |
248 | # Foo
249 |
250 | ## Bar
251 |
252 | # Baz
253 |
254 | ### Qux
255 | want: |
256 | Table of Contents
257 |
258 | -
259 | Foo
263 |
264 | -
265 | Baz
273 |
274 |
275 | Foo
276 | Bar
277 | Baz
278 | Qux
279 |
280 | - desc: title depth > 6
281 | titleDepth: 7
282 | give: |
283 | # Foo
284 |
285 | ## Bar
286 |
287 | # Baz
288 |
289 | ### Qux
290 | want: |
291 | Table of Contents
292 |
293 | -
294 | Foo
298 |
299 | -
300 | Baz
308 |
309 |
310 | Foo
311 | Bar
312 | Baz
313 | Qux
314 |
--------------------------------------------------------------------------------
/toc.go:
--------------------------------------------------------------------------------
1 | package toc
2 |
3 | // TOC is the table of contents. It's the top-level object under which the
4 | // rest of the table of contents resides.
5 | type TOC struct {
6 | // Items holds the top-level headings under the table of contents.
7 | //
8 | // Items is empty if there are no headings in the document.
9 | Items Items
10 | }
11 |
12 | // Item is a single item in the table of contents.
13 | type Item struct {
14 | // Title of this item in the table of contents.
15 | //
16 | // This may be blank for items that don't refer to a heading, and only
17 | // have sub-items.
18 | Title []byte
19 |
20 | // ID is the identifier for the heading that this item refers to. This
21 | // is the fragment portion of the link without the "#".
22 | //
23 | // This may be blank if the item doesn't have an id assigned to it, or
24 | // if it doesn't have a title.
25 | //
26 | // Enable AutoHeadingID in your parser if you expected these to be set
27 | // but they weren't.
28 | ID []byte
29 |
30 | // Items references children of this item.
31 | //
32 | // For a heading at level 3, Items, contains the headings at level 4
33 | // under that section.
34 | Items Items
35 | }
36 |
37 | // Items is a list of items in a table of contents.
38 | type Items []*Item
39 |
--------------------------------------------------------------------------------
/transform.go:
--------------------------------------------------------------------------------
1 | package toc
2 |
3 | import (
4 | "github.com/yuin/goldmark/ast"
5 | "github.com/yuin/goldmark/parser"
6 | "github.com/yuin/goldmark/text"
7 | )
8 |
9 | const (
10 | _defaultTitle = "Table of Contents"
11 |
12 | // Title depth is [1, 6] inclusive.
13 | _defaultTitleDepth = 1
14 | _maxTitleDepth = 6
15 | )
16 |
17 | // Transformer is a Goldmark AST transformer adds a TOC to the top of a
18 | // Markdown document.
19 | //
20 | // To use this, either install the Extender on the goldmark.Markdown object,
21 | // or install the AST transformer on the Markdown parser like so.
22 | //
23 | // markdown := goldmark.New(...)
24 | // markdown.Parser().AddOptions(
25 | // parser.WithAutoHeadingID(),
26 | // parser.WithASTTransformers(
27 | // util.Prioritized(&toc.Transformer{}, 100),
28 | // ),
29 | // )
30 | //
31 | // NOTE: Unless you've supplied your own parser.IDs implementation, you'll
32 | // need to enable the WithAutoHeadingID option on the parser to generate IDs
33 | // and links for headings.
34 | type Transformer struct {
35 | // Title is the title of the table of contents section.
36 | // Defaults to "Table of Contents" if unspecified.
37 | Title string
38 |
39 | // TitleDepth is the heading depth for the Title.
40 | // Defaults to 1 () if unspecified.
41 | TitleDepth int
42 |
43 | // MinDepth is the minimum depth of the table of contents.
44 | // See the documentation for MinDepth for more information.
45 | MinDepth int
46 |
47 | // MaxDepth is the maximum depth of the table of contents.
48 | // See the documentation for MaxDepth for more information.
49 | MaxDepth int
50 |
51 | // ListID is the id for the list of TOC items rendered in the HTML.
52 | //
53 | // For example, if ListID is "toc", the table of contents will be
54 | // rendered as:
55 | //
56 | //
59 | //
60 | // The HTML element does not have an ID if ListID is empty.
61 | ListID string
62 |
63 | // TitleID is the id for the Title heading rendered in the HTML.
64 | //
65 | // For example, if TitleID is "toc-title",
66 | // the title will be rendered as:
67 | //
68 | // Table of Contents
69 | //
70 | // If TitleID is empty, a value will be requested
71 | // from the Goldmark Parser.
72 | TitleID string
73 |
74 | // Compact controls whether empty items should be removed
75 | // from the table of contents.
76 | // See the documentation for Compact for more information.
77 | Compact bool
78 | }
79 |
80 | var _ parser.ASTTransformer = (*Transformer)(nil) // interface compliance
81 |
82 | // Transform adds a table of contents to the provided Markdown document.
83 | //
84 | // Errors encountered while transforming are ignored. For more fine-grained
85 | // control, use Inspect and transform the document manually.
86 | func (t *Transformer) Transform(doc *ast.Document, reader text.Reader, ctx parser.Context) {
87 | toc, err := Inspect(doc, reader.Source(), MinDepth(t.MinDepth), MaxDepth(t.MaxDepth), Compact(t.Compact))
88 | if err != nil {
89 | // There are currently no scenarios under which Inspect
90 | // returns an error but we have to account for it anyway.
91 | return
92 | }
93 |
94 | // Don't add anything for documents with no headings.
95 | if len(toc.Items) == 0 {
96 | return
97 | }
98 |
99 | listNode := RenderList(toc)
100 | if id := t.ListID; len(id) > 0 {
101 | listNode.SetAttributeString("id", []byte(id))
102 | }
103 |
104 | doc.InsertBefore(doc, doc.FirstChild(), listNode)
105 |
106 | title := t.Title
107 | if len(title) == 0 {
108 | title = _defaultTitle
109 | }
110 |
111 | titleDepth := t.TitleDepth
112 | if titleDepth < 1 {
113 | titleDepth = _defaultTitleDepth
114 | }
115 | if titleDepth > _maxTitleDepth {
116 | titleDepth = _maxTitleDepth
117 | }
118 |
119 | titleBytes := []byte(title)
120 | heading := ast.NewHeading(titleDepth)
121 | heading.AppendChild(heading, ast.NewString(titleBytes))
122 | if id := t.TitleID; len(id) > 0 {
123 | heading.SetAttributeString("id", []byte(id))
124 | } else if ids := ctx.IDs(); ids != nil {
125 | id := ids.Generate(titleBytes, heading.Kind())
126 | heading.SetAttributeString("id", id)
127 | }
128 |
129 | doc.InsertBefore(doc, doc.FirstChild(), heading)
130 | }
131 |
--------------------------------------------------------------------------------
/transform_test.go:
--------------------------------------------------------------------------------
1 | package toc
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/assert"
9 | "github.com/stretchr/testify/require"
10 | "github.com/yuin/goldmark/ast"
11 | "github.com/yuin/goldmark/parser"
12 | "github.com/yuin/goldmark/text"
13 | "github.com/yuin/goldmark/util"
14 | )
15 |
16 | func TestTransformer(t *testing.T) {
17 | t.Parallel()
18 |
19 | src := []byte(strings.Join([]string{
20 | "# Foo",
21 | "## Bar",
22 | "# Baz",
23 | "### Qux",
24 | "## Quux",
25 | }, "\n") + "\n")
26 |
27 | tests := []struct {
28 | desc string
29 | giveTitle string
30 | wantTitle string
31 | }{
32 | {
33 | desc: "default title",
34 | wantTitle: _defaultTitle,
35 | },
36 | {
37 | desc: "custom title",
38 | giveTitle: "Contents",
39 | wantTitle: "Contents",
40 | },
41 | }
42 |
43 | for _, tt := range tests {
44 | tt := tt // for t.Parallel
45 | t.Run(tt.desc, func(t *testing.T) {
46 | t.Parallel()
47 |
48 | doc := parser.NewParser(
49 | parser.WithInlineParsers(parser.DefaultInlineParsers()...),
50 | parser.WithBlockParsers(parser.DefaultBlockParsers()...),
51 | parser.WithAutoHeadingID(),
52 | parser.WithASTTransformers(
53 | util.Prioritized(&Transformer{
54 | Title: tt.giveTitle,
55 | }, 100),
56 | ),
57 | ).Parse(text.NewReader(src))
58 |
59 | heading, ok := doc.FirstChild().(*ast.Heading)
60 | require.True(t, ok, "first child must be a heading, got %T", doc.FirstChild())
61 | gotTitle := nodeText(src, heading)
62 | assert.Equal(t, tt.wantTitle, string(gotTitle), "title mismatch")
63 | })
64 | }
65 | }
66 |
67 | // From: https://github.com/abhinav/goldmark-toc/issues/61
68 | func TestTransformerWithTitleDepth(t *testing.T) {
69 | t.Parallel()
70 |
71 | src := []byte(strings.Join([]string{
72 | "# Hey",
73 | "## Now",
74 | "# Then",
75 | "### There",
76 | "## Now",
77 | }, "\n") + "\n")
78 |
79 | type testCase struct {
80 | desc string
81 | giveDepth int
82 | wantDepth int
83 | }
84 |
85 | tests := []testCase{
86 | {
87 | desc: "default",
88 | wantDepth: _defaultTitleDepth,
89 | },
90 | {
91 | desc: "< 1",
92 | giveDepth: -1,
93 | wantDepth: 1,
94 | },
95 | {
96 | desc: "> 6",
97 | giveDepth: 7,
98 | wantDepth: 6,
99 | },
100 | {
101 | desc: "absurd",
102 | giveDepth: 130931,
103 | wantDepth: 6,
104 | },
105 | }
106 |
107 | for i := _defaultTitleDepth; i <= _maxTitleDepth; i++ {
108 | tests = append(tests, testCase{
109 | desc: fmt.Sprintf("valid/%d", i),
110 | giveDepth: i,
111 | wantDepth: i,
112 | })
113 | }
114 |
115 | for _, tt := range tests {
116 | tt := tt // for t.Parallel
117 | t.Run(tt.desc, func(t *testing.T) {
118 | t.Parallel()
119 |
120 | doc := parser.NewParser(
121 | parser.WithInlineParsers(parser.DefaultInlineParsers()...),
122 | parser.WithBlockParsers(parser.DefaultBlockParsers()...),
123 | parser.WithAutoHeadingID(),
124 | parser.WithASTTransformers(
125 | util.Prioritized(&Transformer{
126 | TitleDepth: tt.giveDepth,
127 | }, 100),
128 | ),
129 | ).Parse(text.NewReader(src))
130 |
131 | // Should definitely still be a heading
132 | heading, ok := doc.FirstChild().(*ast.Heading)
133 |
134 | require.True(t, ok, "first child must be a heading, got %T", doc.FirstChild())
135 | assert.Equal(t, tt.wantDepth, heading.Level, "level mismatch")
136 | })
137 | }
138 | }
139 |
--------------------------------------------------------------------------------