{
const [readme, tags, contents, zonContent] = await Promise.allSettled([
client.getReadme(pkg.owner, pkg.name),
@@ -58,7 +32,7 @@ async function fetchContent(pkg: PackageWithContent, client: ContentClient): Pro
const readmeSource = readme.status === 'fulfilled' ? readme.value : null;
let readmeHtml = readmeSource ? await renderReadme(readmeSource.filename, readmeSource.content) : null;
if (readmeHtml) {
- readmeHtml = DOMPurify.sanitize(readmeHtml, { ADD_ATTR: ['align', 'media', 'target', 'id'] });
+ readmeHtml = DOMPurify.sanitize(readmeHtml, README_SANITIZE_OPTIONS);
if (SAFE_REPO_NAME.test(pkg.owner) && SAFE_REPO_NAME.test(pkg.name)) {
readmeHtml = rewriteRelativeUrls(readmeHtml, pkg.source, pkg.owner, pkg.name);
}
diff --git a/src/lib/server/packages/readme-html.ts b/src/lib/server/packages/readme-html.ts
new file mode 100644
index 0000000..05f9aba
--- /dev/null
+++ b/src/lib/server/packages/readme-html.ts
@@ -0,0 +1,29 @@
+import { rawUrl, blobUrl } from '$lib/providers';
+
+export const README_SANITIZE_OPTIONS = { ADD_ATTR: ['align', 'media', 'target', 'id'] };
+
+export function rewriteRelativeUrls(
+ html: string,
+ source: string,
+ owner: string,
+ repo: string,
+ branch = 'main'
+): string {
+ html = html.replace(
+ /href="(?!https?:\/\/|#|mailto:)([^"]+)"/g,
+ (_m, path) => `href="${blobUrl(source, owner, repo, branch, path)}"`
+ );
+ html = html.replace(
+ /src="(?!https?:\/\/)([^"]+)"/g,
+ (_m, path) => `src="${rawUrl(source, owner, repo, branch, path)}"`
+ );
+ // Rewrite absolute github.com blob URLs in src to raw.githubusercontent.com
+ // (GitHub-only: Codeberg READMEs don't embed github.com blob links).
+ if (source !== 'codeberg') {
+ html = html.replace(
+ /src="https:\/\/github\.com\/([^/]+\/[^/]+)\/blob\/([^"]+)"/g,
+ 'src="https://raw.githubusercontent.com/$1/$2"'
+ );
+ }
+ return html;
+}
diff --git a/src/lib/server/packages/readme-renderer.test.ts b/src/lib/server/packages/readme-renderer.test.ts
index 58be2d7..57aa745 100644
--- a/src/lib/server/packages/readme-renderer.test.ts
+++ b/src/lib/server/packages/readme-renderer.test.ts
@@ -76,6 +76,53 @@ describe('renderReadme', () => {
expect(html).toContain('bold');
});
+ it('adds GitHub-style id slugs to markdown headings', async () => {
+ const html = await renderReadme('README.md', '## Installation\n\n### Getting Started\n');
+ expect(html).toContain('Installation
');
+ expect(html).toContain('Getting Started
');
+ });
+
+ it('dedupes repeated heading slugs within a document', async () => {
+ const html = await renderReadme('README.md', '## Foo\n\n## Foo\n');
+ expect(html).toContain('id="foo"');
+ expect(html).toContain('id="foo-1"');
+ });
+
+ it('strips punctuation from slugs like GitHub', async () => {
+ const html = await renderReadme('README.md', "## What's New?\n");
+ expect(html).toContain('id="whats-new"');
+ });
+
+ it('preserves non-ASCII characters in slugs', async () => {
+ const html = await renderReadme('README.md', '## 使い方\n');
+ expect(html).toContain('id="使い方"');
+ });
+
+ it('resets slug dedup between documents', async () => {
+ const first = await renderReadme('README.md', '# A\n');
+ const second = await renderReadme('README.md', '# A\n');
+ expect(first).toContain('id="a"');
+ expect(second).toContain('id="a"');
+ expect(second).not.toContain('id="a-1"');
+ });
+
+ it('keeps in-document anchor links pointing at the hash', async () => {
+ const html = await renderReadme('README.md', '[Install](#install)\n\n## Install\n');
+ expect(html).toContain('href="#install"');
+ expect(html).toContain('id="install"');
+ });
+
+ it('highlights markdown code blocks alongside heading ids', async () => {
+ const html = await renderReadme('README.md', '## Title\n\n```zig\nconst x = 1;\n```\n');
+ expect(html).toContain('id="title"');
+ expect(html).toContain('hljs language-zig');
+ });
+
+ it('does not configure the global marked instance', async () => {
+ const { marked } = await import('marked');
+ expect(marked.parse('# A', { async: false })).not.toContain('id=');
+ });
+
it('renders plaintext inside a with escaped content', async () => {
const html = await renderReadme('README.txt', 'a < b & c');
expect(html).toContain('');
diff --git a/src/lib/server/packages/readme-renderer.ts b/src/lib/server/packages/readme-renderer.ts
index 770ee1b..6678c12 100644
--- a/src/lib/server/packages/readme-renderer.ts
+++ b/src/lib/server/packages/readme-renderer.ts
@@ -1,21 +1,12 @@
-import { marked } from 'marked';
-import { markedHighlight } from 'marked-highlight';
-import hljs from 'highlight.js';
-import hljsZig from 'highlightjs-zig';
+import { gfmHeadingId } from 'marked-gfm-heading-id';
import { convert as asciidocConvert } from '@asciidoctor/core';
import { RstToHtmlCompiler } from 'rst-compiler';
+import { createMarked } from '$lib/server/marked';
-hljs.registerLanguage('zig', hljsZig);
-
-marked.use(
- markedHighlight({
- langPrefix: 'hljs language-',
- highlight(code, lang) {
- const language = hljs.getLanguage(lang) ? lang : 'plaintext';
- return hljs.highlight(code, { language }).value;
- }
- })
-);
+// Isolated instance (see $lib/server/marked): gfmHeadingId adds
+// GitHub-compatible `id` slugs to headings so in-document `#anchor` links
+// resolve — READMEs only, other Markdown pipelines opt in via createMarked().
+const marked = createMarked(gfmHeadingId());
export type ReadmeFormat = 'markdown' | 'asciidoc' | 'rst' | 'plaintext';
diff --git a/src/routes/packages/[owner]/[repo]/+page.svelte b/src/routes/packages/[owner]/[repo]/+page.svelte
index 2faec6b..57f7ac2 100644
--- a/src/routes/packages/[owner]/[repo]/+page.svelte
+++ b/src/routes/packages/[owner]/[repo]/+page.svelte
@@ -271,7 +271,7 @@
-
+
diff --git a/src/routes/packages/[owner]/[repo]/components/tabs/readme-tab.svelte b/src/routes/packages/[owner]/[repo]/components/tabs/readme-tab.svelte
index ac543fc..82bf029 100644
--- a/src/routes/packages/[owner]/[repo]/components/tabs/readme-tab.svelte
+++ b/src/routes/packages/[owner]/[repo]/components/tabs/readme-tab.svelte
@@ -5,7 +5,7 @@
{#if readme}
{@html readme}