Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@ footer: |
| 85 | 🔲 | [Increase test coverage to 95% by extracting shared rule helpers](plan/85_coverage-to-95-percent.md) |
| 86 | 🔲 | [Markdown flavor validation](plan/86_markdown-flavor-validation.md) |
| 87 | 🔲 | [Flavor validation for GitHub Alerts](plan/87_markdown-flavor-github-alerts.md) |
| 88 | 🔲 | [TOC directive migration aid](plan/88_toc-directive-migration.md) |
| 88 | | [TOC directive migration aid](plan/88_toc-directive-migration.md) |
| 89 | 🔲 | [TOC generator directive and MDS035 auto-fix](plan/89_toc-generator-directive.md) |
<?/catalog?>
1 change: 1 addition & 0 deletions cmd/mdsmith/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import (
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
_ "github.com/jeduden/mdsmith/internal/rules/tableformat"
_ "github.com/jeduden/mdsmith/internal/rules/tablereadability"
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
_ "github.com/jeduden/mdsmith/internal/rules/unclosedcodeblock"
)
Expand Down
19 changes: 19 additions & 0 deletions docs/background/markdown-linters.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,24 @@ mdsmith has the strongest cross-file and project-level
features. The merge driver and regenerable sections are
unique to mdsmith.

### Renderer Portability

Several Markdown renderers expand non-standard
tokens into tables of contents. Common
variants are `[TOC]` (Python-Markdown),
`[[_TOC_]]` (GitLab, Azure DevOps), `[[toc]]`
(markdown-it, VitePress), and `${toc}` (some
VitePress configs). CommonMark and goldmark —
the engine mdsmith uses — expand none of
them. They render as literal text.

[MDS035][mds035] (toc-directive, opt-in) flags
each of the four tokens on its own line. For
`[TOC]`, the rule suppresses the diagnostic
when a matching link reference definition
makes it a legitimate link. No other linter
in this comparison detects these tokens.

### Runtime and Integration

| Property | mdsmith | markdownlint | remark-lint | Prettier | Vale | textlint | LLM |
Expand Down Expand Up @@ -456,6 +474,7 @@ relaxed rules) for presentation files.
[mds028]: ../../internal/rules/MDS028-token-budget/README.md
[mds029]: ../../internal/rules/MDS029-conciseness-scoring/README.md
[mds030]: ../../internal/rules/MDS030-empty-section-body/README.md
[mds035]: ../../internal/rules/MDS035-toc-directive/README.md
<!-- markdownlint links -->
[markdownlint]: https://github.com/DavidAnson/markdownlint
[markdownlint-cli2]: https://github.com/DavidAnson/markdownlint-cli2
Expand Down
1 change: 1 addition & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import (
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
_ "github.com/jeduden/mdsmith/internal/rules/tableformat"
_ "github.com/jeduden/mdsmith/internal/rules/tablereadability"
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
)

Expand Down
1 change: 1 addition & 0 deletions internal/engine/categories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
_ "github.com/jeduden/mdsmith/internal/rules/notrailingpunctuation"
_ "github.com/jeduden/mdsmith/internal/rules/notrailingspaces"
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
)

Expand Down
22 changes: 16 additions & 6 deletions internal/integration/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
Expand Down Expand Up @@ -47,6 +48,7 @@ import (
_ "github.com/jeduden/mdsmith/internal/rules/singletrailingnewline"
_ "github.com/jeduden/mdsmith/internal/rules/tableformat"
_ "github.com/jeduden/mdsmith/internal/rules/tablereadability"
_ "github.com/jeduden/mdsmith/internal/rules/tocdirective"
_ "github.com/jeduden/mdsmith/internal/rules/tokenbudget"
_ "github.com/jeduden/mdsmith/internal/rules/unclosedcodeblock"

Expand Down Expand Up @@ -101,8 +103,11 @@ func parseFixtureFrontMatter(
return fm.Settings, fm.Diagnostics, content
}

// applySettingsToRule applies fixture settings to a rule. It saves and restores
// the default settings after the test to avoid polluting the global singleton.
// applySettingsToRule applies fixture settings to a rule. It snapshots the
// rule's value before the change and restores it on test cleanup, so that
// rules whose internal state cannot be recreated from DefaultSettings
// alone (e.g. directory-structure's `configured` flag) do not leak state
// into later tests.
func applySettingsToRule(
t *testing.T, r rule.Rule, settings map[string]any,
) {
Expand All @@ -119,10 +124,15 @@ func applySettingsToRule(
)
}

defaults := cr.DefaultSettings()
t.Cleanup(func() {
_ = cr.ApplySettings(defaults)
})
// Snapshot via reflect so cleanup fully restores the pre-test state.
rv := reflect.ValueOf(r)
if rv.Kind() == reflect.Ptr && !rv.IsNil() {
snapshot := reflect.New(rv.Elem().Type()).Elem()
snapshot.Set(rv.Elem())
t.Cleanup(func() {
rv.Elem().Set(snapshot)
})
}

if err := cr.ApplySettings(settings); err != nil {
t.Fatalf("applying settings: %v", err)
Expand Down
18 changes: 13 additions & 5 deletions internal/lint/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ func (f *File) GetGitignore() *GitignoreMatcher {
return f.gitignoreVal
}

// NewFile parses source as Markdown and returns a File.
func NewFile(path string, source []byte) (*File, error) {
reader := text.NewReader(source)
p := parser.NewParser(
// NewParser returns a goldmark parser configured identically to the one
// used by NewFile. Rules that need to re-inspect a document (for example,
// to consult the link reference definition map) should use this so that
// processing-instruction blocks and other mdsmith-specific parsing
// decisions stay consistent with the original lint parse.
func NewParser() parser.Parser {
return parser.NewParser(
parser.WithBlockParsers(
append(parser.DefaultBlockParsers(),
PIBlockParserPrioritized(),
Expand All @@ -71,7 +74,12 @@ func NewFile(path string, source []byte) (*File, error) {
parser.DefaultParagraphTransformers()...,
),
)
node := p.Parse(reader)
}

// NewFile parses source as Markdown and returns a File.
func NewFile(path string, source []byte) (*File, error) {
reader := text.NewReader(source)
node := NewParser().Parse(reader)

lines := bytes.Split(source, []byte("\n"))

Expand Down
127 changes: 127 additions & 0 deletions internal/rules/MDS035-toc-directive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
id: MDS035
name: toc-directive
status: ready
description: Flag renderer-specific TOC directives that render as literal text on CommonMark and goldmark.
---
# MDS035: toc-directive

Flag renderer-specific TOC directives that
render as literal text on CommonMark and
goldmark.

- **ID**: MDS035
- **Name**: `toc-directive`
- **Status**: ready
- **Default**: disabled (opt-in)
- **Fixable**: no
- **Implementation**:
[source](./)
- **Category**: meta

## What it detects

Four directive variants appear in the wild,
each expanded by a different renderer:

| Token | Expanded by |
|-------------|----------------------------------------|
| `[TOC]` | Python-Markdown, MultiMarkdown, Pandoc |
| `[[_TOC_]]` | GitLab Flavored Markdown, Azure DevOps |
| `[[toc]]` | markdown-it-toc-done-right, VitePress |
| `${toc}` | some VitePress configurations |

CommonMark and goldmark do not expand any of
them; authors see the directive token in the
rendered output instead of a table of
contents. The rule flags each token when it
appears on its own line inside a paragraph so
authors can replace it, delete it, or maintain
the list manually.

`[TOC]` alone is also valid CommonMark
shortcut reference link syntax. When the
document contains a matching
`[TOC]: <url>` definition, the rule
suppresses the diagnostic because the token
resolves to a real link rather than rendering
as literal text.

## Why not auto-fix

The right replacement depends on intent. For
file-index usage — an index page listing
sibling documents — mdsmith's
[`<?catalog?>`](../MDS019-catalog/README.md)
directive is the replacement. For in-document
heading TOCs, mdsmith has no equivalent; the
author must drop the directive or maintain a
manual list. The rule is detection-only and
names both cases in its message.

## Config

Enable:

```yaml
rules:
toc-directive: true
```

Disable (default):

```yaml
rules:
toc-directive: false
```

## Examples

### Good

<?include
file: good/default.md
wrap: markdown
?>

````markdown
# Document with no TOC directives

This document has no renderer-specific TOC
markers, so MDS035 stays silent.

Normal prose is unaffected, and inline code
like `[TOC]` or `${toc}` is not flagged because
the tokens are inside code spans.

```text
[TOC]
[[_TOC_]]
[[toc]]
${toc}
```

Even the fenced block above is a code block,
not a paragraph, so nothing is reported.
````

<?/include?>

### Bad

<?include
file: bad/bracketed.md
wrap: markdown
?>

```markdown
# Python-Markdown TOC directive

[TOC]

Everything below the directive renders fine,
but the directive itself appears as literal
text on CommonMark and goldmark renderers.
```

<?/include?>
13 changes: 13 additions & 0 deletions internal/rules/MDS035-toc-directive/bad/bracketed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
diagnostics:
- line: 3
column: 1
message: "unsupported TOC directive `[TOC]`; mdsmith has no heading TOC equivalent; use `<?catalog?>` for file indexes (MDS019)"
---
# Python-Markdown TOC directive

[TOC]

Everything below the directive renders fine,
but the directive itself appears as literal
text on CommonMark and goldmark renderers.
13 changes: 13 additions & 0 deletions internal/rules/MDS035-toc-directive/bad/gitlab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
diagnostics:
- line: 3
column: 1
message: "unsupported TOC directive `[[_TOC_]]`; mdsmith has no heading TOC equivalent; use `<?catalog?>` for file indexes (MDS019)"
---
# GitLab-flavored TOC directive

[[_TOC_]]

GitLab Flavored Markdown and Azure DevOps
expand this into a TOC; CommonMark and
goldmark render it as plain text.
13 changes: 13 additions & 0 deletions internal/rules/MDS035-toc-directive/bad/markdown-it.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
diagnostics:
- line: 3
column: 1
message: "unsupported TOC directive `[[toc]]`; mdsmith has no heading TOC equivalent; use `<?catalog?>` for file indexes (MDS019)"
---
# markdown-it / VitePress TOC directive

[[toc]]

markdown-it-toc-done-right and VitePress
replace this with a generated heading TOC;
CommonMark leaves it as literal text.
13 changes: 13 additions & 0 deletions internal/rules/MDS035-toc-directive/bad/vitepress-dollar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
diagnostics:
- line: 3
column: 1
message: "unsupported TOC directive `${toc}`; mdsmith has no heading TOC equivalent; use `<?catalog?>` for file indexes (MDS019)"
---
# VitePress dollar-brace TOC directive

${toc}

Some VitePress configurations expand this
token. CommonMark and goldmark render it as
literal text.
22 changes: 22 additions & 0 deletions internal/rules/MDS035-toc-directive/good/default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
settings:
diagnostics:
---
# Document with no TOC directives

This document has no renderer-specific TOC
markers, so MDS035 stays silent.

Normal prose is unaffected, and inline code
like `[TOC]` or `${toc}` is not flagged because
the tokens are inside code spans.

```text
[TOC]
[[_TOC_]]
[[toc]]
${toc}
```

Even the fenced block above is a code block,
not a paragraph, so nothing is reported.
13 changes: 13 additions & 0 deletions internal/rules/MDS035-toc-directive/good/with-link-ref.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
settings:
diagnostics:
---
# TOC as legitimate link

[TOC]: https://example.com/toc

See the [TOC] above for the full list.

A matching link reference definition is
present, so `[TOC]` renders as a link. The
rule does not flag this file.
Loading
Loading