diff --git a/apps/svelte.dev/src/routes/content.json/+server.ts b/apps/svelte.dev/src/routes/content.json/+server.ts index 1dd2fb4898..c6795a06c3 100644 --- a/apps/svelte.dev/src/routes/content.json/+server.ts +++ b/apps/svelte.dev/src/routes/content.json/+server.ts @@ -1,7 +1,7 @@ import type { Tokens } from 'marked'; import { index, docs as _docs, examples } from '$lib/server/content'; import { json } from '@sveltejs/kit'; -import { transform, slugify, clean } from '@sveltejs/site-kit/markdown'; +import { transform, slugify, clean, create_slug_deduper } from '@sveltejs/site-kit/markdown'; import type { Block } from '@sveltejs/site-kit/search'; import { get_slug } from '../tutorial/[...slug]/content.server'; @@ -38,6 +38,8 @@ async function content() { const intro = sections?.shift()?.trim()!; const rank = +metadata.rank; + const dedupe = create_slug_deduper(); + blocks.push({ breadcrumbs: [...breadcrumbs, clean(metadata.title ?? '')], href: get_href([slug]), @@ -53,13 +55,15 @@ async function content() { continue; } + const h2_slug = dedupe(slugify(h2)); + const content = lines.join('\n'); const subsections = content.trim().split('## '); const intro = subsections?.shift()?.trim(); if (intro) { blocks.push({ breadcrumbs: [...breadcrumbs, clean(metadata.title), clean(h2)], - href: get_href([slug, slugify(h2)]), + href: get_href([slug, h2_slug]), content: await plaintext(intro), rank }); @@ -73,9 +77,11 @@ async function content() { continue; } + const h3_slug = dedupe(`${h2_slug}-${slugify(h3)}`); + blocks.push({ breadcrumbs: [...breadcrumbs, clean(metadata.title), clean(h2), clean(h3)], - href: get_href([slug, slugify(h2) + '-' + slugify(h3)]), + href: get_href([slug, h3_slug]), content: await plaintext(lines.join('\n').trim()), rank }); diff --git a/packages/site-kit/src/lib/markdown/index.ts b/packages/site-kit/src/lib/markdown/index.ts index a523e1d603..59f73e1096 100644 --- a/packages/site-kit/src/lib/markdown/index.ts +++ b/packages/site-kit/src/lib/markdown/index.ts @@ -1,6 +1,6 @@ export { render_content_markdown } from './renderer.ts'; -export { transform, slugify, clean, strip_origin } from './utils.ts'; +export { transform, slugify, clean, strip_origin, create_slug_deduper } from './utils.ts'; // TODO none of these really belong here export type Modules = Array<{ diff --git a/packages/site-kit/src/lib/markdown/renderer.ts b/packages/site-kit/src/lib/markdown/renderer.ts index bf5fee6978..5c52483744 100644 --- a/packages/site-kit/src/lib/markdown/renderer.ts +++ b/packages/site-kit/src/lib/markdown/renderer.ts @@ -12,6 +12,7 @@ import { transformerTwoslash, rendererRich } from '@shikijs/twoslash'; import { createFileSystemTypesCache } from '@shikijs/vitepress-twoslash/cache-fs'; import { compress_and_encode_text } from 'gzip'; import { + create_slug_deduper, decode_html_entities, SHIKI_LANGUAGE_MAP, slugify, @@ -329,6 +330,7 @@ export async function render_content_markdown( twoslashBanner?: TwoslashBanner ) { const headings: string[] = []; + const dedupe_slug = create_slug_deduper(); const { check = true, references } = options ?? {}; interface CodeBlockFile { @@ -598,9 +600,16 @@ export async function render_content_markdown( const text = this.parser!.parseInline(tokens); const html = text.replace(/<\/?code>/g, ''); - headings[depth - 1] = slugify(text); + const parent = headings + .slice(0, depth - 1) + .filter(Boolean) + .join('-'); + const raw_segment = slugify(text); + const slug = dedupe_slug(parent ? `${parent}-${raw_segment}` : raw_segment); + const segment = parent ? slug.slice(parent.length + 1) : slug; + + headings[depth - 1] = segment; headings.length = depth; - const slug = headings.filter(Boolean).join('-'); return `${html}`; }, diff --git a/packages/site-kit/src/lib/markdown/utils.ts b/packages/site-kit/src/lib/markdown/utils.ts index 3f55abe8ba..fb2ee94eb6 100644 --- a/packages/site-kit/src/lib/markdown/utils.ts +++ b/packages/site-kit/src/lib/markdown/utils.ts @@ -85,6 +85,15 @@ export const slugify = (str: string) => { ); }; +export function create_slug_deduper() { + const seen = new Map(); + return (slug: string) => { + const count = seen.get(slug) ?? 0; + seen.set(slug, count + 1); + return count > 0 ? `${slug}-${count}` : slug; + }; +} + export function smart_quotes( str: string, { first = true, html = false }: { first?: boolean; html?: boolean } = {} diff --git a/packages/site-kit/src/lib/server/content/index.ts b/packages/site-kit/src/lib/server/content/index.ts index addaec4bf7..d765dcf566 100644 --- a/packages/site-kit/src/lib/server/content/index.ts +++ b/packages/site-kit/src/lib/server/content/index.ts @@ -1,4 +1,10 @@ -import { extract_frontmatter, is_in_code_block, slugify, smart_quotes } from '../../markdown/utils'; +import { + create_slug_deduper, + extract_frontmatter, + is_in_code_block, + slugify, + smart_quotes +} from '../../markdown/utils'; import type { Document, Section } from '../../types'; export async function create_index( @@ -30,6 +36,8 @@ export async function create_index( '$1' ); + const dedupe = create_slug_deduper(); + const sections = Array.from(body.matchAll(/^#{2,3}\s+(.*)$/gm)).reduce((arr, match) => { if (is_in_code_block(body, match.index || 0)) return arr; const title = match[1]; @@ -43,10 +51,13 @@ export async function create_index( if (match[0].startsWith('###')) { const section = arr.at(-1); if (section) { - section.subsections.push({ slug: `${section.slug}-${slug}`, title: displayed_title }); + section.subsections.push({ + slug: dedupe(`${section.slug}-${slug}`), + title: displayed_title + }); } } else { - arr.push({ slug, title: displayed_title, subsections: [] }); + arr.push({ slug: dedupe(slug), title: displayed_title, subsections: [] }); } return arr;