|
1 | | -import { marked } from 'marked' |
2 | | -import { ALLOWED_ATTR, ALLOWED_TAGS } from '../readme' |
| 1 | +import { marked, type Tokens } from 'marked' |
| 2 | +import { ALLOWED_ATTR, ALLOWED_TAGS, calculateSemanticDepth, prefixId, slugify } from '../readme' |
3 | 3 | import sanitizeHtml from 'sanitize-html' |
4 | 4 |
|
5 | 5 | export async function changelogRenderer() { |
6 | 6 | const renderer = new marked.Renderer() |
7 | 7 |
|
8 | | - // settings will need to be added still |
| 8 | + return (markdown: string | null, releaseId: string | number) => { |
| 9 | + // Collect table of contents items during parsing |
| 10 | + const toc: TocItem[] = [] |
9 | 11 |
|
10 | | - return (markdown: string) => |
11 | | - marked.parse(markdown, { |
12 | | - renderer, |
13 | | - }) |
| 12 | + if (!markdown) { |
| 13 | + return { |
| 14 | + html: null, |
| 15 | + toc, |
| 16 | + } |
| 17 | + } |
| 18 | + |
| 19 | + // Track used heading slugs to handle duplicates (GitHub-style: foo, foo-1, foo-2) |
| 20 | + const usedSlugs = new Map<string, number>() |
| 21 | + |
| 22 | + // settings will need to be added still |
| 23 | + let lastSemanticLevel = 2 // Start after h2 (the "Readme" section heading) |
| 24 | + renderer.heading = function ({ tokens, depth }: Tokens.Heading) { |
| 25 | + // Calculate the target semantic level based on document structure |
| 26 | + // Start at h3 (since page h1 + section h2 already exist) |
| 27 | + // But ensure we never skip levels - can only go down by 1 or stay same/go up |
| 28 | + const semanticLevel = calculateSemanticDepth(depth, lastSemanticLevel) |
| 29 | + lastSemanticLevel = semanticLevel |
| 30 | + const text = this.parser.parseInline(tokens) |
| 31 | + |
| 32 | + // Generate GitHub-style slug for anchor links |
| 33 | + // adding release id to prevent conflicts |
| 34 | + let slug = slugify(text) |
| 35 | + if (!slug) slug = 'heading' // Fallback for empty headings |
| 36 | + |
| 37 | + // Handle duplicate slugs (GitHub-style: foo, foo-1, foo-2) |
| 38 | + const count = usedSlugs.get(slug) ?? 0 |
| 39 | + usedSlugs.set(slug, count + 1) |
| 40 | + const uniqueSlug = count === 0 ? slug : `${slug}-${count}` |
| 41 | + |
| 42 | + // Prefix with 'user-content-' to avoid collisions with page IDs |
| 43 | + // (e.g., #install, #dependencies, #versions are used by the package page) |
| 44 | + const id = `user-content-${releaseId}-${uniqueSlug}` |
| 45 | + |
| 46 | + // Collect TOC item with plain text (HTML stripped) |
| 47 | + const plainText = text.replace(/<[^>]*>/g, '').trim() |
| 48 | + if (plainText) { |
| 49 | + toc.push({ text: plainText, id, depth }) |
| 50 | + } |
| 51 | + |
| 52 | + return `<h${semanticLevel} id="${id}" data-level="${depth}">${text}</h${semanticLevel}>\n` |
| 53 | + } |
| 54 | + |
| 55 | + return { |
| 56 | + html: marked.parse(markdown, { |
| 57 | + renderer, |
| 58 | + }) as string, |
| 59 | + toc, |
| 60 | + } |
| 61 | + } |
14 | 62 | } |
15 | 63 |
|
16 | 64 | export function sanitizeRawHTML(rawHtml: string) { |
@@ -99,11 +147,11 @@ export function sanitizeRawHTML(rawHtml: string) { |
99 | 147 | // attribs.href = resolvedHref |
100 | 148 | // return { tagName, attribs } |
101 | 149 | // }, |
102 | | - // div: prefixId, |
103 | | - // p: prefixId, |
104 | | - // span: prefixId, |
105 | | - // section: prefixId, |
106 | | - // article: prefixId, |
| 150 | + div: prefixId, |
| 151 | + p: prefixId, |
| 152 | + span: prefixId, |
| 153 | + section: prefixId, |
| 154 | + article: prefixId, |
107 | 155 | }, |
108 | 156 | }) |
109 | 157 | } |
0 commit comments