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
31 changes: 30 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,40 @@ This file summarizes notable repository changes in a compact, release-oriented f

### <a id='unreleased-git-log'></a>Unreleased Git Log

Used git range: `v1.0.2..HEAD`
Used git range: `v1.0.3..HEAD`

```txt
```

## <a id='v1.0.3-changes'></a>v1.0.3 Changes (2026-10-15)

### <a id='v1.0.3-overview'></a>v1.0.3 Overview

* Fix [#123](https://github.com/rokath/mdtoc/issues/123): ToC labels preserve literal underscores, inline code, emphasis, escapes, entities, and authored whitespace. Brackets are safely escaped and nested link destinations are omitted.
* Anchor generation preserves literal and escaped identifier characters and decodes HTML entities outside code, with regression coverage for all slug profiles and file-based generate/strip/check workflows.
* CLI binaries and the six platform-specific VS Code extension packages share release version `1.0.3`.
* Include the extension dependency updates merged since v1.0.2: `@vscode/vsce`, `brace-expansion`, `fast-uri`, `form-data`, `js-yaml`, `linkify-it`, `markdown-it`, and `undici`.

### <a id='v1.0.3-git-log'></a>v1.0.3 Git Log

Used git range: `v1.0.2..v1.0.3`

```txt
* ff52cd5 fix: preserve heading markup and identifier anchors (#123)
* f15097d build(deps-dev): bump markdown-it from 14.2.0 to 14.3.2 in /extension
* 594e29a build(deps-dev): bump undici from 7.29.0 to 7.30.0 in /extension
* 3b60488 build(deps-dev): bump js-yaml from 4.3.1 to 4.3.2 in /extension
* e985ed7 build(deps-dev): bump fast-uri from 3.1.5 to 3.1.7 in /extension
* 60238a6 build(deps-dev): bump js-yaml from 4.3.0 to 4.3.1 in /extension
* bc412d6 build(deps-dev): bump fast-uri from 3.1.2 to 3.1.5 in /extension
* a30ed1b build(deps-dev): bump undici from 7.25.0 to 7.29.0 in /extension
* ce64936 build(deps-dev): bump linkify-it from 5.0.0 to 5.0.2 in /extension
* a91b41b build(deps): bump brace-expansion and @vscode/vsce in /extension
* 53a8f32 build(deps-dev): bump js-yaml from 4.1.1 to 4.3.0 in /extension
* e92684f build(deps-dev): bump form-data from 4.0.5 to 4.0.6 in /extension
* d2509b1 build(deps-dev): bump markdown-it from 14.1.1 to 14.2.0 in /extension
```

## <a id='v1.0.2-changes'></a>v1.0.2 Changes (2026-05-31)

### <a id='v1.0.1-overview'></a>v1.0.2 Overview
Expand Down
25 changes: 18 additions & 7 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ ManagedHeading
- line_index
- level
- title_markup // Title area as it appears in the document, but without managed numbering and without the managed inline anchor
- title_text // Plain-text interpretation of title_markup; source for ToC link text and anchor ID
- title_text // Plain-text interpretation of title_markup; source for semantic anchor IDs
- number // derived or empty
- anchor_id // derived or empty
```
Expand Down Expand Up @@ -601,7 +601,7 @@ Render rules:
* Each additional level relative to `min` is indented by two spaces.
* Every entry is a Markdown list item.
* If `link=true`, the list item text is a Markdown link.
* If `link=false`, the list item contains plain text only.
* If `link=false`, the list item contains the formatted title without a wrapping link.
* The list marker is chosen according to `bullets`.

Example:
Expand All @@ -624,6 +624,15 @@ Displayed in the link text:
* with `numbering=true`: `number + title`
* with `numbering=false`: title only

The title is derived from `title_markup`, preserving literal underscores, inline
code delimiters, emphasis, escapes, entities, and whitespace. Closing ATX hash
markers are omitted. Existing inline links and images contribute their label or
alt text with its inline formatting; HTML tags are omitted to avoid copying
anchors into the ToC. Literal square brackets outside code are escaped so they
cannot break the surrounding link. These label transformations do not supply the
slug input. For example, ``Missing `__COUNTER__` `` keeps its code span in the ToC
label and uses semantic text `Missing __COUNTER__` for GitHub/GitLab slugs.

Link target when `link=true`:

* with `anchor=true`: `#` + `anchor_id`
Expand All @@ -644,7 +653,7 @@ Behavior of `slug`:
* `slug=crossnote` uses the Crossnote / Markdown Preview Enhanced rules in section 11.8
* For `slug=github`, `title_source` is the plain heading text after inline-markup removal but before whitespace collapsing.
* For `slug=gitlab`, `title_source` is `title_text`.
* For `slug=crossnote`, `title_source` is `title_markup`.
* For `slug=crossnote`, `title_source` is `title_markup` with HTML entities decoded outside code spans and escaped ampersands.
* ATX closing hash markers are stripped from `title_text`, but remain visible to the Crossnote `title_markup` slug path. Therefore `## An ATX title with closing hash markers ####` targets `#an-atx-title-with-closing-hash-markers--` when `slug=crossnote`, `anchor=false`, and `link=true`.

_Explanation:_
Expand Down Expand Up @@ -680,7 +689,7 @@ For every managed heading, the following applies:
```text
slug_source := title_text // gitlab
slug_source := title_markup without inline markup // github
slug_source := title_markup // crossnote
slug_source := title_markup with entities decoded outside code/escapes // crossnote
anchor_id := slugify(slug_source)
```

Expand All @@ -694,14 +703,16 @@ The following also applies:
* backtick code spans contribute only their visible content
* Markdown links and images contribute only their visible label or alt text
* HTML tags are removed
* inline formatting markers `*`, `_`, and `~` are removed
* paired inline formatting markers `*`, `_`, and `~` are removed; intraword underscores and unmatched markers remain literal
* backslash-escaped ASCII punctuation contributes the escaped character
* HTML entities are decoded once, except inside code spans or after an escaped ampersand
* whitespace is collapsed to single spaces and trimmed at the ends
* For `slug=github`, `mdtoc` derives a second whitespace-preserving plain-text form from `title_markup` so repeated spaces remain visible to the slugger while the rendered `title_text` stays normalized for display.
* For `slug=github`, `mdtoc` derives a second whitespace-preserving plain-text form from `title_markup` so repeated spaces remain visible to the slugger. ToC labels independently retain the authored title markup.

_Explanation:_

* The current implementation intentionally keeps this extraction logic small and self-contained.
* Only this keeps slug/anchor generation, ToC link text, and profile behavior consistent.
* Semantic extraction serves slug/anchor generation; preserving authored ToC markup avoids changing how identifiers and inline code render.

### 11.3 GitHub-compatible basic rules

Expand Down
4 changes: 2 additions & 2 deletions extension/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "mdtoc",
"displayName": "mdtoc - Markdown ToC with section numbering and stable links",
"description": "Generate, strip and configure a Markdown table of contents. Toggle ToC, numbering and links; choose heading levels, bullet style and slug method.",
"version": "1.0.2",
"version": "1.0.3",
"publisher": "rokath",
"license": "MIT",
"icon": "mdtoc_icon_128.png",
Expand Down
38 changes: 37 additions & 1 deletion internal/mdtoc/cli_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,42 @@ func TestRunnerFileWorkflowGenerateStripRootRestoreCheck(t *testing.T) {
}
}

// TestRunnerFileWorkflowPreservesHeadingIdentifiers exercises #123 through real
// command dispatch and the virtual filesystem used by both CLI and extension
// workflows. Equivalent literal spellings must share collision handling without
// losing their distinct authored labels during generate, strip, and restoration.
func TestRunnerFileWorkflowPreservesHeadingIdentifiers(t *testing.T) {
const path = "identifiers.md"
input := "# Guide\n\n## Xxxx_Yyyy\n## Missing `__COUNTER__`\n## Missing \\_\\_COUNTER\\_\\_\n## Missing &#95;&#95;COUNTER&#95;&#95;\n"
fs := newMemoryFileSystem(map[string]string{path: input})
runFileCommand(t, fs, "generate", "--file", path)
generated := fs.fileString(path)
for _, entry := range []string{
"* [1. Xxxx_Yyyy](#xxxx_yyyy)",
"* [2. Missing `__COUNTER__`](#missing-__counter__)",
`* [3. Missing \_\_COUNTER\_\_](#missing-__counter__-1)`,
"* [4. Missing &#95;&#95;COUNTER&#95;&#95;](#missing-__counter__-2)",
} {
if !strings.Contains(generated, entry+"\n") {
t.Fatalf("file workflow is missing %q:\n%s", entry, generated)
}
}
runFileCommand(t, fs, "check", "--file", path)
runFileCommand(t, fs, "--file", path)
if fs.fileString(path) != generated {
t.Fatal("root regeneration changed identifier labels or targets")
}
runFileCommand(t, fs, "strip", "--file", path)
if !strings.Contains(fs.fileString(path), input) {
t.Fatalf("strip changed authored heading markup:\n%s", fs.fileString(path))
}
runFileCommand(t, fs, "--file", path)
if fs.fileString(path) != generated {
t.Fatalf("restoring stripped identifiers changed output:\n%s", fs.fileString(path))
}
runFileCommand(t, fs, "check", "--file", path)
}

// TestRunnerFileWorkflowGenerateWithEmptyRedirectedStdin verifies that file
// workflows still run when CI provides non-interactive stdin that is already at EOF.
func TestRunnerFileWorkflowGenerateWithEmptyRedirectedStdin(t *testing.T) {
Expand Down Expand Up @@ -593,7 +629,7 @@ func TestRunnerFileWorkflowGitLabAnchorProfileDiffersFromGitHub(t *testing.T) {
if !strings.Contains(gotGitLab, "slug=gitlab") || !strings.Contains(gotGitLab, `<a id="foo-bar"></a>`) {
t.Fatalf("generate did not render the GitLab-specific anchor ID:\n%s", gotGitLab)
}
if !strings.Contains(gotGitLab, "* [1. foo bar](#foo-bar)") {
if !strings.Contains(gotGitLab, "* [1. foo bar](#foo-bar)") {
t.Fatalf("generate did not render the GitLab-specific ToC target:\n%s", gotGitLab)
}

Expand Down
Loading
Loading