Skip to content

Commit bc0fa2f

Browse files
committed
added headings handling and toc (only scroll behavior needs to be changed
1 parent b345df1 commit bc0fa2f

File tree

5 files changed

+89
-30
lines changed

5 files changed

+89
-30
lines changed

app/components/Changelog/Card.vue

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,17 @@ const { release } = defineProps<{
77
</script>
88
<template>
99
<section class="border border-border rounded-lg p-4 sm:p-6">
10-
<h2 class="text-1xl sm:text-2xl font-medium min-w-0 break-words py-2">
11-
{{ release.title }}
12-
</h2>
10+
<div class="flex justify-between">
11+
<h2 class="text-1xl sm:text-2xl font-medium min-w-0 break-words py-2">
12+
{{ release.title }}
13+
</h2>
14+
<ReadmeTocDropdown
15+
v-if="release?.toc && release.toc.length > 1"
16+
:toc="release.toc"
17+
class="justify-self-end"
18+
/>
19+
<!-- :active-id="activeTocId" -->
20+
</div>
1321
<Readme v-if="release.html" :html="release.html"></Readme>
1422
</section>
1523
</template>

server/api/changelog/releases/[provider]/[owner]/[repo].ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ReleaseData } from '~~/shared/types/changelog'
33
import { ERROR_CHANGELOG_RELEASES_FAILED, THROW_INCOMPLETE_PARAM } from '~~/shared/utils/constants'
44
import { GithubReleaseCollectionSchama } from '~~/shared/schemas/changelog/release'
55
import { parse } from 'valibot'
6-
import { changelogRenderer, sanitizeRawHTML } from '~~/server/utils/changelog/markdown'
6+
import { changelogRenderer } from '~~/server/utils/changelog/markdown'
77

88
export default defineCachedEventHandler(async event => {
99
const provider = getRouterParam(event, 'provider')
@@ -46,15 +46,16 @@ async function getReleasesFromGithub(owner: string, repo: string) {
4646

4747
const render = await changelogRenderer()
4848

49-
return releases.map(
50-
r =>
51-
({
52-
id: r.id,
53-
html: r.markdown ? sanitizeRawHTML(render(r.markdown) as string) : null,
54-
title: r.name,
55-
draft: r.draft,
56-
prerelease: r.prerelease,
57-
publishedAt: r.publishedAt,
58-
}) satisfies ReleaseData,
59-
)
49+
return releases.map(r => {
50+
const { html, toc } = render(r.markdown, r.id)
51+
return {
52+
id: r.id,
53+
html,
54+
title: r.name,
55+
draft: r.draft,
56+
prerelease: r.prerelease,
57+
toc,
58+
publishedAt: r.publishedAt,
59+
} satisfies ReleaseData
60+
})
6061
}

server/utils/changelog/markdown.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,64 @@
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'
33
import sanitizeHtml from 'sanitize-html'
44

55
export async function changelogRenderer() {
66
const renderer = new marked.Renderer()
77

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[] = []
911

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+
}
1462
}
1563

1664
export function sanitizeRawHTML(rawHtml: string) {
@@ -99,11 +147,11 @@ export function sanitizeRawHTML(rawHtml: string) {
99147
// attribs.href = resolvedHref
100148
// return { tagName, attribs }
101149
// },
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,
107155
},
108156
})
109157
}

server/utils/readme.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export const ALLOWED_ATTR: Record<string, string[]> = {
183183
* - Remove special characters (keep alphanumeric, hyphens, underscores)
184184
* - Collapse multiple hyphens
185185
*/
186-
function slugify(text: string): string {
186+
export function slugify(text: string): string {
187187
return text
188188
.replace(/<[^>]*>/g, '') // Strip HTML tags
189189
.toLowerCase()
@@ -319,7 +319,7 @@ function resolveImageUrl(url: string, packageName: string, repoInfo?: Repository
319319
}
320320

321321
// Helper to prefix id attributes with 'user-content-'
322-
function prefixId(tagName: string, attribs: sanitizeHtml.Attributes) {
322+
export function prefixId(tagName: string, attribs: sanitizeHtml.Attributes) {
323323
if (attribs.id && !attribs.id.startsWith('user-content-')) {
324324
attribs.id = `user-content-${attribs.id}`
325325
}
@@ -329,7 +329,7 @@ function prefixId(tagName: string, attribs: sanitizeHtml.Attributes) {
329329
// README h1 always becomes h3
330330
// For deeper levels, ensure sequential order
331331
// Don't allow jumping more than 1 level deeper than previous
332-
function calculateSemanticDepth(depth: number, lastSemanticLevel: number) {
332+
export function calculateSemanticDepth(depth: number, lastSemanticLevel: number) {
333333
if (depth === 1) return 3
334334
const maxAllowed = Math.min(lastSemanticLevel + 1, 6)
335335
return Math.min(depth + 2, maxAllowed)

shared/types/changelog.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ProviderId } from '../utils/git-providers'
2+
import type { TocItem } from './readme'
23

34
export interface ChangelogReleaseInfo {
45
type: 'release'
@@ -24,4 +25,5 @@ export interface ReleaseData {
2425
draft?: boolean
2526
id: string | number
2627
publishedAt?: string
28+
toc?: TocItem[]
2729
}

0 commit comments

Comments
 (0)