diff --git a/internal/markdown/convert.go b/internal/markdown/convert.go index c749886..9b09c6e 100644 --- a/internal/markdown/convert.go +++ b/internal/markdown/convert.go @@ -59,9 +59,10 @@ func Convert(source []byte, plantumlServer string) ([]byte, error) { // Pre-process: replace svg, mermaid, plantuml code blocks with raw HTML source = PreprocessCodeBlocks(source, plantumlServer) - // Parse into AST + // Parse into AST using GFM-compatible heading ID generation. reader := text.NewReader(source) - doc := md.Parser().Parse(reader) + ctx := parser.NewContext(parser.WithIDs(newGFMIDs())) + doc := md.Parser().Parse(reader, parser.WithContext(ctx)) // Insert line anchors into AST source = insertLineAnchors(doc, source) diff --git a/internal/markdown/convert_test.go b/internal/markdown/convert_test.go index 06ebfcb..688de83 100644 --- a/internal/markdown/convert_test.go +++ b/internal/markdown/convert_test.go @@ -5,6 +5,45 @@ import ( "testing" ) +func TestConvert_NonASCIIHeadingAnchor(t *testing.T) { + // Headings with non-ASCII (e.g. Japanese) characters must produce an id + // matching GitHub GFM behavior so that links like [text](#ヘディング) work. + tests := []struct { + input string + wantID string + wantLink string + }{ + { + // goldmark percent-encodes non-ASCII in href; browsers decode before + // fragment matching, so "#%E3%83%98..." navigates to id="ヘディング". + input: "# ヘディング\n\n[link](#ヘディング)\n", + wantID: `id="ヘディング"`, + wantLink: `href="#%E3%83%98%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0"`, + }, + { + input: "# Hello World\n", + wantID: `id="hello-world"`, + }, + { + input: "# 見出し Test\n", + wantID: `id="見出し-test"`, + }, + } + for _, tt := range tests { + html, err := Convert([]byte(tt.input), "") + if err != nil { + t.Fatalf("Convert error: %v", err) + } + s := string(html) + if !strings.Contains(s, tt.wantID) { + t.Errorf("expected %q in output:\n%s", tt.wantID, s) + } + if tt.wantLink != "" && !strings.Contains(s, tt.wantLink) { + t.Errorf("expected %q in output:\n%s", tt.wantLink, s) + } + } +} + func TestConvert_CRLFMathBlock(t *testing.T) { // Regression: CRLF line endings must not break $$ math block rendering. input := []byte("$$\r\ny = x^2\r\n$$\r\n") diff --git a/internal/markdown/headingid.go b/internal/markdown/headingid.go new file mode 100644 index 0000000..951011b --- /dev/null +++ b/internal/markdown/headingid.go @@ -0,0 +1,60 @@ +package markdown + +import ( + "bytes" + "fmt" + "unicode" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" +) + +// newGFMIDs returns a parser.IDs that generates heading IDs following the +// GitHub Flavored Markdown convention: Unicode letters and digits are kept +// (lowercased where applicable), spaces/hyphens become "-", others are +// dropped. This preserves non-ASCII characters such as Japanese kana/kanji, +// unlike goldmark's default which strips all multi-byte characters. +func newGFMIDs() parser.IDs { + return &gfmIDs{values: map[string]bool{}} +} + +type gfmIDs struct { + values map[string]bool +} + +func (s *gfmIDs) Generate(value []byte, kind ast.NodeKind) []byte { + value = bytes.TrimSpace(value) + var result []rune + for _, r := range string(value) { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + result = append(result, unicode.ToLower(r)) + case unicode.IsSpace(r) || r == '-' || r == '_': + result = append(result, '-') + } + } + res := []byte(string(result)) + if len(res) == 0 { + if kind == ast.KindHeading { + res = []byte("heading") + } else { + res = []byte("id") + } + } + key := string(res) + if !s.values[key] { + s.values[key] = true + return res + } + for i := 1; ; i++ { + newKey := fmt.Sprintf("%s-%d", key, i) + if !s.values[newKey] { + s.values[newKey] = true + return []byte(newKey) + } + } +} + +func (s *gfmIDs) Put(value []byte) { + s.values[string(value)] = true +}