diff --git a/MacDown/Code/Document/MPRenderer.m b/MacDown/Code/Document/MPRenderer.m index 9aec3617..c4021bef 100644 --- a/MacDown/Code/Document/MPRenderer.m +++ b/MacDown/Code/Document/MPRenderer.m @@ -423,6 +423,7 @@ NS_INLINE void add_to_languages( flags, tocLevel); htmlRenderer->blockcode = hoedown_patch_render_blockcode; htmlRenderer->listitem = hoedown_patch_render_listitem; + htmlRenderer->header = hoedown_patch_render_header; hoedown_html_renderer_state_extra *extra = hoedown_malloc(sizeof(hoedown_html_renderer_state_extra)); diff --git a/MacDown/Code/Extension/hoedown_html_patch.c b/MacDown/Code/Extension/hoedown_html_patch.c index 8cd5cec4..d2959b47 100644 --- a/MacDown/Code/Extension/hoedown_html_patch.c +++ b/MacDown/Code/Extension/hoedown_html_patch.c @@ -161,6 +161,85 @@ void hoedown_patch_render_listitem( HOEDOWN_BUFPUTSL(ob, "\n"); } +// Build a stable text-derived slug from a heading's HTML content. +// Strips HTML tags, lowercases ASCII, converts spaces to hyphens, +// drops ASCII punctuation, preserves UTF-8 multi-byte sequences so +// accented characters survive (e.g. "Introducción" -> "introducción"). +static void slugify(hoedown_buffer *out, const hoedown_buffer *content) +{ + if (!content || !content->size) + return; + + int in_tag = 0; + int last_was_dash = 1; + + for (size_t i = 0; i < content->size; i++) + { + uint8_t c = content->data[i]; + + if (in_tag) + { + if (c == '>') in_tag = 0; + continue; + } + if (c == '<') + { + in_tag = 1; + continue; + } + + if (c >= 0x80) + { + hoedown_buffer_putc(out, c); + last_was_dash = 0; + continue; + } + + if (c >= 'A' && c <= 'Z') + c += 32; + + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') + { + hoedown_buffer_putc(out, c); + last_was_dash = 0; + } + else if (c == ' ' || c == '\t' || c == '-') + { + if (!last_was_dash) + { + hoedown_buffer_putc(out, '-'); + last_was_dash = 1; + } + } + } + + while (out->size > 0 && out->data[out->size - 1] == '-') + out->size--; +} + +// rndr_header replacement that always emits a text-derived id, independent +// of the TOC nesting level. Enables [link](#section-name) navigation. +void hoedown_patch_render_header( + hoedown_buffer *ob, const hoedown_buffer *content, int level, + const hoedown_renderer_data *data) +{ + (void)data; + if (ob->size) hoedown_buffer_putc(ob, '\n'); + + hoedown_buffer *slug = hoedown_buffer_new(content ? content->size : 16); + slugify(slug, content); + if (slug->size == 0) + HOEDOWN_BUFPUTSL(slug, "section"); + + hoedown_buffer_printf(ob, "data, slug->size); + HOEDOWN_BUFPUTSL(ob, "\">"); + if (content) hoedown_buffer_put(ob, content->data, content->size); + hoedown_buffer_printf(ob, "\n", level); + + hoedown_buffer_free(slug); +} + // Adds a "toc" class to the outmost UL element to support TOC styling. void hoedown_patch_render_toc_header( hoedown_buffer *ob, const hoedown_buffer *content, int level, @@ -195,7 +274,15 @@ void hoedown_patch_render_toc_header( HOEDOWN_BUFPUTSL(ob,"\n
  • \n"); } - hoedown_buffer_printf(ob, "", state->toc_data.header_count++); + hoedown_buffer *slug = hoedown_buffer_new(content ? content->size : 16); + slugify(slug, content); + if (slug->size == 0) + HOEDOWN_BUFPUTSL(slug, "section"); + HOEDOWN_BUFPUTSL(ob, "data, slug->size); + HOEDOWN_BUFPUTSL(ob, "\">"); + hoedown_buffer_free(slug); + state->toc_data.header_count++; if (content) hoedown_buffer_put(ob, content->data, content->size); HOEDOWN_BUFPUTSL(ob, "\n"); } diff --git a/MacDown/Code/Extension/hoedown_html_patch.h b/MacDown/Code/Extension/hoedown_html_patch.h index fed3ceec..0b85c8db 100644 --- a/MacDown/Code/Extension/hoedown_html_patch.h +++ b/MacDown/Code/Extension/hoedown_html_patch.h @@ -38,6 +38,10 @@ void hoedown_patch_render_listitem( hoedown_buffer *ob, const hoedown_buffer *text, hoedown_list_flags flags, const hoedown_renderer_data *data); +void hoedown_patch_render_header( + hoedown_buffer *ob, const hoedown_buffer *content, int level, + const hoedown_renderer_data *data); + void hoedown_patch_render_toc_header( hoedown_buffer *ob, const hoedown_buffer *content, int level, const hoedown_renderer_data *data); diff --git a/MacDownCore/MPQuickLookRenderer.m b/MacDownCore/MPQuickLookRenderer.m index 212f2fe4..4c434dd2 100644 --- a/MacDownCore/MPQuickLookRenderer.m +++ b/MacDownCore/MPQuickLookRenderer.m @@ -180,6 +180,84 @@ #pragma mark - Hoedown Renderer Callbacks +// Build a stable text-derived slug from a heading's HTML content. +// Mirrors slugify() in MacDown/Code/Extension/hoedown_html_patch.c so the +// preview and Quick Look render identical heading ids. +static void mp_quicklook_slugify(hoedown_buffer *out, const hoedown_buffer *content) +{ + if (!content || !content->size) + return; + + int in_tag = 0; + int last_was_dash = 1; + + for (size_t i = 0; i < content->size; i++) + { + uint8_t c = content->data[i]; + + if (in_tag) + { + if (c == '>') in_tag = 0; + continue; + } + if (c == '<') + { + in_tag = 1; + continue; + } + + if (c >= 0x80) + { + hoedown_buffer_putc(out, c); + last_was_dash = 0; + continue; + } + + if (c >= 'A' && c <= 'Z') + c += 32; + + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') + { + hoedown_buffer_putc(out, c); + last_was_dash = 0; + } + else if (c == ' ' || c == '\t' || c == '-') + { + if (!last_was_dash) + { + hoedown_buffer_putc(out, '-'); + last_was_dash = 1; + } + } + } + + while (out->size > 0 && out->data[out->size - 1] == '-') + out->size--; +} + +// Emit headings with text-derived id attributes so anchor links work in +// Quick Look previews, matching the main MacDown preview behavior. +static void mp_quicklook_render_header( + hoedown_buffer *ob, const hoedown_buffer *content, int level, + const hoedown_renderer_data *data) +{ + (void)data; + if (ob->size) hoedown_buffer_putc(ob, '\n'); + + hoedown_buffer *slug = hoedown_buffer_new(content ? content->size : 16); + mp_quicklook_slugify(slug, content); + if (slug->size == 0) + HOEDOWN_BUFPUTSL(slug, "section"); + + hoedown_buffer_printf(ob, "data, slug->size); + HOEDOWN_BUFPUTSL(ob, "\">"); + if (content) hoedown_buffer_put(ob, content->data, content->size); + hoedown_buffer_printf(ob, "\n", level); + + hoedown_buffer_free(slug); +} + /** * Custom blockcode renderer that adds Prism language classes without scripts. */ @@ -310,6 +388,7 @@ - (NSString *)parseMarkdownToHTML:(NSString *)markdown // Preserve Prism language classes, but Quick Look never executes Prism JS. renderer->blockcode = mp_quicklook_render_blockcode; + renderer->header = mp_quicklook_render_header; // Create document hoedown_document *document = hoedown_document_new( diff --git a/MacDownTests/Fixtures/autolinks.html b/MacDownTests/Fixtures/autolinks.html index c615e0d6..938f303c 100644 --- a/MacDownTests/Fixtures/autolinks.html +++ b/MacDownTests/Fixtures/autolinks.html @@ -1,6 +1,6 @@ -

    Autolink Tests

    +

    Autolink Tests

    -

    URL Autolinks

    +

    https://example.com

    @@ -10,7 +10,7 @@

    URL Autolinks

    https://example.com/path?query=param&other=value

    -

    Email Autolinks

    +

    user@example.com

    @@ -18,17 +18,17 @@

    Email Autolinks

    contact+tag@example.com

    -

    Plain URLs (may or may not auto-link depending on parser)

    +

    Visit https://example.com for more.

    Check out http://www.example.org too.

    -

    Mixed Content

    +

    Mixed Content

    Email me at user@example.com or visit https://example.com.

    -

    Edge Cases

    +

    Edge Cases

    Multiple autolinks: https://first.com and https://second.com.

    @@ -36,7 +36,7 @@

    Edge Cases

    End with autolink: https://end.com

    -

    In Lists

    +

    In Lists

    -

    Test Case 2: Ordered list after colon

    +

    Test Case 2: Ordered list after colon

    My grocery list:

    @@ -20,7 +20,7 @@

    Test Case 2: Ordered list after colon

  • Bread
  • -

    Test Case 3: Nested list after colon

    +

    Test Case 3: Nested list after colon

    My tasks:

    @@ -39,7 +39,7 @@

    Test Case 3: Nested list after colon

    -

    Test Case 4: List with proper blank line (works correctly)

    +

    Test Case 4: List with proper blank line (works correctly)

    This should work:

    diff --git a/MacDownTests/Fixtures/regression-issue36.html b/MacDownTests/Fixtures/regression-issue36.html index 01a69e15..6f06e48d 100644 --- a/MacDownTests/Fixtures/regression-issue36.html +++ b/MacDownTests/Fixtures/regression-issue36.html @@ -1,6 +1,6 @@ -

    Code Blocks Without Blank Lines (Issue #36)

    +

    Code Blocks Without Blank Lines (Issue #36)

    -

    Test Case 1: Fenced code block immediately after text

    +

    Test Case 1: Fenced code block immediately after text

    Here is some code:

    @@ -8,7 +8,7 @@

    Test Case 1: Fenced code block immediately after text

    return true; }
    -

    Test Case 2: Multiple code blocks in sequence

    +

    Test Case 2: Multiple code blocks in sequence

    First block:

    @@ -19,7 +19,7 @@

    Test Case 2: Multiple code blocks in sequence

    y = 100
    -

    Test Case 3: Code block with proper blank line (works correctly)

    +

    Test Case 3: Code block with proper blank line (works correctly)

    This should work:

    @@ -27,7 +27,7 @@

    Test Case 3: Code block with proper blank line (works correctly)

    return 'yes'; } -

    Test Case 4: Code block in list without blank line

    +

    Test Case 4: Code block in list without blank line

    Steps to follow:

    diff --git a/MacDownTests/Fixtures/regression-issue37.html b/MacDownTests/Fixtures/regression-issue37.html index 07e9e6f1..f3ae8cf1 100644 --- a/MacDownTests/Fixtures/regression-issue37.html +++ b/MacDownTests/Fixtures/regression-issue37.html @@ -1,19 +1,19 @@ -

    Square Brackets in Code (Issue #37)

    +

    Square Brackets in Code (Issue #37)

    -

    Test Case 1: TypeScript index signature

    +

    Test Case 1: TypeScript index signature

    interface MyType {
       [key: string]​: any;
     }
    -

    Test Case 2: JavaScript array access

    +

    Test Case 2: JavaScript array access

    const value = array[index];
     const obj = {
       [computed]​: 'value'
     };
    -

    Test Case 3: Multiple bracket patterns

    +

    Test Case 3: Multiple bracket patterns

    type Dict = {
       [id: number]​: string;
    @@ -25,11 +25,11 @@ 

    Test Case 3: Multiple bracket patterns

    }; }
    -

    Test Case 4: Inline code with brackets

    +

    Test Case 4: Inline code with brackets

    Here's an example: array[0] and obj[key] should work.

    -

    Test Case 5: Python dictionary syntax

    +

    Test Case 5: Python dictionary syntax

    my_dict = {
         "key": "value"
    diff --git a/MacDownTests/Fixtures/strikethrough.html b/MacDownTests/Fixtures/strikethrough.html
    index f1baac64..a6323467 100644
    --- a/MacDownTests/Fixtures/strikethrough.html
    +++ b/MacDownTests/Fixtures/strikethrough.html
    @@ -1,12 +1,12 @@
    -

    Strikethrough Tests

    +

    Strikethrough Tests

    -

    Basic Strikethrough

    +

    Basic Strikethrough

    This is strikethrough text.

    Entire line strikethrough

    -

    Combined with Other Formatting

    +

    Combined with Other Formatting

    This is strikethrough with bold.

    @@ -16,11 +16,11 @@

    Combined with Other Formatting

    This is italic with strikethrough.

    -

    Multiple Strikethroughs

    +

    Multiple Strikethroughs

    First and second strikethrough.

    -

    Edge Cases

    +

    Edge Cases

    Not~strikethrough (single tilde).

    @@ -30,7 +30,7 @@

    Edge Cases

    End of line has strikethrough.

    -

    In Lists

    +

    In Lists

    • Strikethrough list item
    • diff --git a/MacDownTests/Fixtures/syntax-highlighting-aliases.html b/MacDownTests/Fixtures/syntax-highlighting-aliases.html index e1341c77..b724674c 100644 --- a/MacDownTests/Fixtures/syntax-highlighting-aliases.html +++ b/MacDownTests/Fixtures/syntax-highlighting-aliases.html @@ -1,16 +1,16 @@ -

      Language Aliases Test

      +

      Language Aliases Test

      -

      JavaScript (js alias)

      +

      JavaScript (js alias)

      const greeting = "Hello, World!";
       console.log(greeting);
      -

      Objective-C (objc alias)

      +

      Objective-C (objc alias)

      NSString *greeting = @"Hello, World!";
       NSLog(@"%@", greeting);
      -

      HTML (maps to markup)

      +

      HTML (maps to markup)

      <!DOCTYPE html>
       <html>
      @@ -19,12 +19,12 @@ 

      HTML (maps to markup)

      </body> </html>
      -

      Shell (sh alias for bash)

      +

      Shell (sh alias for bash)

      #!/bin/sh
       echo "Hello, World!"
      -

      C++ (c++ and cpp aliases)

      +

      C++ (c++ and cpp aliases)

      #include <iostream>
       int main() {
      diff --git a/MacDownTests/Fixtures/syntax-highlighting-languages.html b/MacDownTests/Fixtures/syntax-highlighting-languages.html
      index e947631a..454bba11 100644
      --- a/MacDownTests/Fixtures/syntax-highlighting-languages.html
      +++ b/MacDownTests/Fixtures/syntax-highlighting-languages.html
      @@ -1,41 +1,41 @@
      -

      Common Programming Languages

      +

      Common Programming Languages

      -

      JavaScript

      +

      JavaScript

      function fibonacci(n) {
           if (n <= 1) return n;
           return fibonacci(n - 1) + fibonacci(n - 2);
       }
      -

      Python

      +

      Python

      def fibonacci(n):
           if n <= 1:
               return n
           return fibonacci(n - 1) + fibonacci(n - 2)
      -

      C

      +

      C

      int fibonacci(int n) {
           if (n <= 1) return n;
           return fibonacci(n - 1) + fibonacci(n - 2);
       }
      -

      C++

      +

      C++

      int fibonacci(int n) {
           if (n <= 1) return n;
           return fibonacci(n - 1) + fibonacci(n - 2);
       }
      -

      Java

      +

      Java

      public int fibonacci(int n) {
           if (n <= 1) return n;
           return fibonacci(n - 1) + fibonacci(n - 2);
       }
      -

      Go

      +

      Go

      func fibonacci(n int) int {
           if n <= 1 {
      @@ -44,7 +44,7 @@ 

      Go

      return fibonacci(n-1) + fibonacci(n-2) }
      -

      Rust

      +

      Rust

      fn fibonacci(n: u32) -> u32 {
           match n {
      @@ -54,7 +54,7 @@ 

      Rust

      } }
      -

      TypeScript

      +

      TypeScript

      function fibonacci(n: number): number {
           if (n <= 1) return n;
      diff --git a/MacDownTests/Fixtures/syntax-highlighting-mixed.html b/MacDownTests/Fixtures/syntax-highlighting-mixed.html
      index d6aafc0f..4b1dc860 100644
      --- a/MacDownTests/Fixtures/syntax-highlighting-mixed.html
      +++ b/MacDownTests/Fixtures/syntax-highlighting-mixed.html
      @@ -1,24 +1,24 @@
      -

      Mixed Code Blocks

      +

      Mixed Code Blocks

      -

      Labeled Code Block

      +

      Labeled Code Block

      def hello():
           print("Hello from Python")
      -

      Unlabeled Code Block

      +

      Unlabeled Code Block

      This code has no language specified.
       It should get the "language-none" class.
      -

      Another Labeled Block

      +

      Another Labeled Block

      console.log("Hello from JavaScript");
      -

      Indented Code Block

      +

      Indented Code Block

      This is indented code.
       It should not have language classes.
      -

      Inline Code

      +

      Inline Code

      Here is some inline code that should not have language classes.

      diff --git a/MacDownTests/Fixtures/tables.html b/MacDownTests/Fixtures/tables.html index ffc2b0b5..c96e4978 100644 --- a/MacDownTests/Fixtures/tables.html +++ b/MacDownTests/Fixtures/tables.html @@ -1,6 +1,6 @@ -

      Table Tests

      +

      Table Tests

      -

      Basic Table

      +

      Basic Table

      @@ -25,7 +25,7 @@

      Basic Table

      -

      Table with Alignment

      +

      Table with Alignment

      @@ -55,14 +55,14 @@

      Table with Alignment

      -

      Minimal Table

      +

      Minimal Table

      | A | B | | - | - | | 1 | 2 | | 3 | 4 |

      -

      Table with Inline Formatting

      +

      Table with Inline Formatting

      @@ -87,7 +87,7 @@

      Table with Inline Formatting

      -

      Table with Different Column Widths

      +

      Table with Different Column Widths

      @@ -112,7 +112,7 @@

      Table with Different Column Widths

      -

      Empty Cells

      +

      Empty Cells

      diff --git a/MacDownTests/Fixtures/task-lists.html b/MacDownTests/Fixtures/task-lists.html index e197a15d..b3b3f267 100644 --- a/MacDownTests/Fixtures/task-lists.html +++ b/MacDownTests/Fixtures/task-lists.html @@ -1,6 +1,6 @@ -

      Task List Tests

      +

      Task List Tests

      -

      Basic Task Lists

      +

      Basic Task Lists

      • Unchecked task @@ -12,7 +12,7 @@

        Basic Task Lists

      • [X] Checked with capital X
      -

      Nested Task Lists

      +

      Nested Task Lists

      • Parent task @@ -30,7 +30,7 @@

        Nested Task Lists

      -

      Mixed with Regular Lists

      +

      Mixed with Regular Lists

      • Regular list item
      • @@ -42,7 +42,7 @@

        Mixed with Regular Lists

      • Final regular item
      -

      Tasks with Inline Formatting

      +

      Tasks with Inline Formatting

      • Task with bold text @@ -55,7 +55,7 @@

        Tasks with Inline Formatting

      -

      Numbered Task Lists

      +

      Numbered Task Lists

      1. First task @@ -66,7 +66,7 @@

        Numbered Task Lists

      -

      Edge Cases

      +

      Edge Cases

      • [] Invalid (no space)
      • diff --git a/MacDownTests/Fixtures/unicode.html b/MacDownTests/Fixtures/unicode.html index 70cb12a7..81672610 100644 --- a/MacDownTests/Fixtures/unicode.html +++ b/MacDownTests/Fixtures/unicode.html @@ -1,82 +1,82 @@ -

        Unicode Test

        +

        Unicode Test

        -

        Basic Latin and Symbols

        +

        Basic Latin and Symbols

        Regular text with special characters: © ® ™ § ¶

        -

        Accented Characters

        +

        Accented Characters

        Café, naïve, résumé, piñata, Zürich

        -

        Currency Symbols

        +

        Currency Symbols

        Dollar: $ Euro: € Pound: £ Yen: ¥ Bitcoin: ₿

        -

        Mathematical Symbols

        +

        Mathematical Symbols

        ∀ ∃ ∅ ∞ ∫ ∑ ∏ ≈ ≠ ≤ ≥ ± × ÷

        -

        Arrows

        +

        Arrows

        ← → ↑ ↓ ↔ ⇐ ⇒ ⇔

        -

        Chinese Characters

        +

        Chinese Characters

        你好世界 (Hello World)

        中文字符测试

        -

        Japanese Characters

        +

        Japanese Characters

        こんにちは世界 (Hello World)

        ひらがな、カタカナ、漢字

        -

        Korean Characters

        +

        Korean Characters

        안녕하세요 세계 (Hello World)

        한글 테스트

        -

        Emoji

        +

        Emoji

        😀 😃 😄 😁 🚀 🌟 ⭐ 💻 📱 🎉 ✨

        -

        Arabic

        +

        Arabic

        مرحبا بالعالم (Hello World)

        -

        Hebrew

        +

        Hebrew

        שלום עולם (Hello World)

        -

        Cyrillic

        +

        Cyrillic

        Привет мир (Hello World)

        -

        Greek

        +

        Greek

        Γεια σου κόσμε (Hello World)

        -

        Special Punctuation

        +

        Special Punctuation

        Em dash: — En dash: – Ellipsis: … Quotes: " " ' '

        -

        Combining Characters

        +

        Combining Characters

        Café (with combining accent: Café)

        -

        Zero-Width Characters

        +

        Zero-Width Characters

        Zero-width space: ​ (invisible)

        -

        Code Block with Unicode

        +

        Code Block with Unicode

        def greet():
             print("Hello 世界 🌍")
             # Comment with unicode: ñ á é í ó ú
        -

        Bullet List with Unicode

        +

        Bullet List with Unicode

        • Item with emoji 🎯
        • @@ -85,10 +85,10 @@

          Bullet List with Unicode

        • Item with special char ©
        -

        Links with Unicode

        +

        Unicode Test 测试

        -

        Emphasis with Unicode

        +

        Emphasis with Unicode

        Bold 粗体 and italic 斜体

        diff --git a/MacDownTests/MPHTMLExportTests.m b/MacDownTests/MPHTMLExportTests.m index 7344b518..9ab68f9b 100644 --- a/MacDownTests/MPHTMLExportTests.m +++ b/MacDownTests/MPHTMLExportTests.m @@ -401,7 +401,7 @@ - (void)testHTMLExportWritesToFile error:&error]; XCTAssertNil(error, @"Should read file without error"); XCTAssertEqualObjects(readBack, html, @"Content should match"); - XCTAssertTrue([readBack containsString:@"

        Test Document

        "], @"Should contain heading"); + XCTAssertTrue([readBack containsString:@"

        Test Document

        "], @"Should contain heading"); XCTAssertTrue([readBack containsString:@"paragraph"], @"Should contain bold text"); } @@ -431,7 +431,7 @@ - (void)testHTMLExportOverwritesExistingFile NSString *readBack = [NSString stringWithContentsOfURL:fileURL encoding:NSUTF8StringEncoding error:&error]; - XCTAssertTrue([readBack containsString:@"

        New Content

        "], @"Should have new content"); + XCTAssertTrue([readBack containsString:@"

        New Content

        "], @"Should have new content"); XCTAssertFalse([readBack containsString:@"Initial"], @"Should not have old content"); } @@ -460,8 +460,8 @@ - (void)testHTMLExportCreatesValidStructure @"Should be complete HTML document"); XCTAssertTrue([content containsString:@""], @"Should have head"); XCTAssertTrue([content containsString:@""], @"Should have h1"); - XCTAssertTrue([content containsString:@"

        "], @"Should have h2"); + XCTAssertTrue([content containsString:@"

        "], @"Should have list"); XCTAssertTrue([content containsString:@"
      • "], @"Should have list items"); XCTAssertTrue([content containsString:@"
        "] || [content containsString:@""],
        diff --git a/MacDownTests/MPImageExportTests.m b/MacDownTests/MPImageExportTests.m
        index 64a48244..9c297443 100644
        --- a/MacDownTests/MPImageExportTests.m
        +++ b/MacDownTests/MPImageExportTests.m
        @@ -405,7 +405,7 @@ - (void)testExportWithImagesAndText
             [self.renderer parseMarkdown:self.dataSource.markdown];
             NSString *html = [self.renderer HTMLForExportWithStyles:NO highlighting:NO];
         
        -    XCTAssertTrue([html containsString:@"

        "], @"Should have heading"); + XCTAssertTrue([html containsString:@"

        "], - @"CRLF heading should render as

        "); + XCTAssertTrue([crlfHtml containsString:@"

        element"); XCTAssertTrue([crlfHtml containsString:@"Heading"], @"CRLF heading text should appear in output"); XCTAssertTrue([crlfHtml containsString:@"

        "], @@ -753,4 +753,50 @@ - (void)testFencedCodeAfterTextWithCRLFLineEndings @"CRLF and LF fenced-code-after-text should produce identical HTML"); } + +#pragma mark - Heading Anchor ID Tests + +// Headings should always receive a text-derived id attribute so that +// CommonMark/GFM-style anchor links like [text](#section) navigate to +// the corresponding heading in the preview. + +- (void)testHeadingHasSlugBasedAnchorId +{ + NSString *html = [self renderMarkdown:@"## Foo Bar" + withExtensions:0 + rendererFlags:0]; + XCTAssertTrue([html containsString:@"id=\"foo-bar\""], + @"Heading should have a slug-based id derived from its text. Got: %@", html); +} + +- (void)testHeadingAnchorIdPreservesUTF8 +{ + NSString *html = [self renderMarkdown:@"## Introducción" + withExtensions:0 + rendererFlags:0]; + XCTAssertTrue([html containsString:@"id=\"introducción\""], + @"Heading id should preserve UTF-8 multi-byte characters. Got: %@", html); +} + +- (void)testHeadingAnchorIdStripsPunctuation +{ + NSString *html = [self renderMarkdown:@"# Hello, World!" + withExtensions:0 + rendererFlags:0]; + XCTAssertTrue([html containsString:@"id=\"hello-world\""], + @"Heading id should drop ASCII punctuation. Got: %@", html); +} + +- (void)testHeadingAnchorIdEmittedWithoutTOCPreference +{ + // Regression guard: ids must be emitted regardless of the + // "Detect TOC token" preference state. + self.delegate.renderTOC = NO; + NSString *html = [self renderMarkdown:@"### Some Section" + withExtensions:0 + rendererFlags:0]; + XCTAssertTrue([html containsString:@"id=\"some-section\""], + @"Heading id must be emitted independent of TOC preference. Got: %@", html); +} + @end diff --git a/MacDownTests/MPQuickLookRendererTests.m b/MacDownTests/MPQuickLookRendererTests.m index 90146a02..075dee4e 100644 --- a/MacDownTests/MPQuickLookRendererTests.m +++ b/MacDownTests/MPQuickLookRendererTests.m @@ -124,8 +124,8 @@ - (void)testRenderSimpleMarkdown NSString *html = [self.renderer renderMarkdown:markdown]; XCTAssertNotNil(html, @"Should return non-nil HTML"); - XCTAssertTrue([html containsString:@"

        "], - @"Should render heading as

        "); + XCTAssertTrue([html containsString:@"

        element"); XCTAssertTrue([html containsString:@"Hello World"], @"Should include heading text"); XCTAssertTrue([html containsString:@"

        "], @@ -640,8 +640,8 @@ - (void)testRenderMarkdownFromURLWithCRLFLineEndings XCTAssertNil(error, @"Should render CRLF file without error"); XCTAssertNotNil(html, @"Should produce HTML from CRLF file"); - XCTAssertTrue([html containsString:@"

        "], - @"CRLF heading should render as

        "); + XCTAssertTrue([html containsString:@"

        element"); XCTAssertTrue([html containsString:@"Heading"], @"Heading text should appear in output"); XCTAssertTrue([html containsString:@"

        "], diff --git a/MacDownTests/MPRendererEdgeCaseTests.m b/MacDownTests/MPRendererEdgeCaseTests.m index 63a2909f..64039d9c 100644 --- a/MacDownTests/MPRendererEdgeCaseTests.m +++ b/MacDownTests/MPRendererEdgeCaseTests.m @@ -534,8 +534,8 @@ - (void)testRendererWithManyHeadings NSString *html = [self.renderer HTMLForExportWithStyles:NO highlighting:NO]; XCTAssertNotNil(html, @"Should handle many headings"); - XCTAssertTrue([html containsString:@"

        "], @"Should have h1"); - XCTAssertTrue([html containsString:@"

        "], @"Should have h6"); + XCTAssertTrue([html containsString:@"

        "], + XCTAssertTrue([html containsString:@"

        "], + XCTAssertTrue([html containsString:@"