From 398eefc97a669675a7e20b6907f308ab3f27fa2f Mon Sep 17 00:00:00 2001 From: soramikan <80459567+soramikan@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:50:15 +0900 Subject: [PATCH] feat: add GitHub-style heading anchors to rendered READMEs Rendered Markdown headings had no id attributes, so in-document links like [Install](#install) and README tables of contents went nowhere. Register marked-gfm-heading-id on the README pipeline to emit GitHub-compatible slugs, and add scroll-mt so scrolled-to headings clear the sticky header. Markdown pipelines now build their Marked instance via a createMarked() factory instead of mutating the global singleton: READMEs get heading ids while docs keep highlight-only rendering, deterministically rather than depending on module load order. Also rename the install widget's id to install-command so a "## Install" README heading cannot shadow it. Closes zig-toolbelt/zigpkg#3 --- package-lock.json | 64 +++++++++++++------ package.json | 1 + src/lib/docs/server.ts | 4 +- src/lib/server/marked.ts | 24 +++++++ src/lib/server/packages/content.test.ts | 34 ++++++++++ src/lib/server/packages/content.ts | 30 +-------- src/lib/server/packages/readme-html.ts | 29 +++++++++ .../server/packages/readme-renderer.test.ts | 47 ++++++++++++++ src/lib/server/packages/readme-renderer.ts | 21 ++---- .../packages/[owner]/[repo]/+page.svelte | 2 +- .../[repo]/components/tabs/readme-tab.svelte | 2 +- 11 files changed, 191 insertions(+), 67 deletions(-) create mode 100644 src/lib/server/marked.ts create mode 100644 src/lib/server/packages/content.test.ts create mode 100644 src/lib/server/packages/readme-html.ts diff --git a/package-lock.json b/package-lock.json index 7a9e44f..ec6e461 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zigpkg", - "version": "1.0.6", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zigpkg", - "version": "1.0.6", + "version": "1.1.0", "dependencies": { "@asciidoctor/core": "^4.0.0", "@auth/drizzle-adapter": "^1.11.2", @@ -21,6 +21,7 @@ "isomorphic-dompurify": "^3.12.0", "lucide-svelte": "^1.0.1", "marked": "^18.0.3", + "marked-gfm-heading-id": "^4.1.4", "marked-highlight": "^2.2.4", "postgres": "^3.4.9", "rst-compiler": "^0.5.9", @@ -264,7 +265,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -307,7 +307,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -316,6 +315,27 @@ "version": "0.10.2", "license": "Apache-2.0" }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", @@ -2187,7 +2207,6 @@ "node_modules/@sveltejs/kit": { "version": "2.59.1", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -2227,7 +2246,6 @@ "node_modules/@sveltejs/vite-plugin-svelte": { "version": "7.1.2", "license": "MIT", - "peer": true, "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", @@ -2593,7 +2611,6 @@ "version": "25.6.2", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -2695,7 +2712,6 @@ "version": "4.1.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.1.5", "@vitest/mocker": "4.1.5", @@ -2824,7 +2840,6 @@ "node_modules/acorn": { "version": "8.15.0", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3838,6 +3853,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/graceful-fs": { "version": "4.2.11", "dev": true, @@ -4335,7 +4356,6 @@ "node_modules/marked": { "version": "18.0.3", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -4343,6 +4363,18 @@ "node": ">= 20" } }, + "node_modules/marked-gfm-heading-id": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/marked-gfm-heading-id/-/marked-gfm-heading-id-4.1.4.tgz", + "integrity": "sha512-CspnvVfHSkb/znqdPS4jUR8HtCjq3M/DnrsJCrfLBLvdrgbemmoINKpeWKQYkBiXAoBGejw0cV7xzqrPdup3WA==", + "license": "MIT", + "dependencies": { + "github-slugger": "^2.0.0" + }, + "peerDependencies": { + "marked": ">=13 <19" + } + }, "node_modules/marked-highlight": { "version": "2.2.4", "license": "MIT", @@ -4591,7 +4623,6 @@ "version": "1.59.1", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.59.1" }, @@ -4671,7 +4702,6 @@ "node_modules/postgres": { "version": "3.4.9", "license": "Unlicense", - "peer": true, "engines": { "node": ">=12" }, @@ -4685,7 +4715,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -4821,7 +4850,6 @@ "version": "4.60.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -5134,7 +5162,6 @@ "node_modules/svelte": { "version": "5.55.5", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -5186,7 +5213,6 @@ "node_modules/tailwind-merge": { "version": "3.6.0", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -5211,8 +5237,7 @@ }, "node_modules/tailwindcss": { "version": "4.3.0", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.3", @@ -5818,7 +5843,6 @@ "version": "6.0.3", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5965,7 +5989,6 @@ "node_modules/vite": { "version": "8.0.12", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6081,7 +6104,6 @@ "version": "4.1.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", diff --git a/package.json b/package.json index f7da3e2..6ca9541 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "isomorphic-dompurify": "^3.12.0", "lucide-svelte": "^1.0.1", "marked": "^18.0.3", + "marked-gfm-heading-id": "^4.1.4", "marked-highlight": "^2.2.4", "postgres": "^3.4.9", "rst-compiler": "^0.5.9", diff --git a/src/lib/docs/server.ts b/src/lib/docs/server.ts index 89de254..223860a 100644 --- a/src/lib/docs/server.ts +++ b/src/lib/docs/server.ts @@ -1,5 +1,7 @@ -import { marked } from 'marked'; import DOMPurify from 'isomorphic-dompurify'; +import { createMarked } from '$lib/server/marked'; + +const marked = createMarked(); export interface DocMeta { slug: string; diff --git a/src/lib/server/marked.ts b/src/lib/server/marked.ts new file mode 100644 index 0000000..3cc232e --- /dev/null +++ b/src/lib/server/marked.ts @@ -0,0 +1,24 @@ +import { Marked } from 'marked'; +import type { MarkedExtension } from 'marked'; +import { markedHighlight } from 'marked-highlight'; +import hljs from 'highlight.js'; +import hljsZig from 'highlightjs-zig'; + +hljs.registerLanguage('zig', hljsZig); + +// Consumers get their own configured instance — the global `marked` singleton +// is never mutated, so no pipeline can leak extensions into another. +// Instances are meant for synchronous `parse` only (`async: false`); some +// extensions (e.g. gfmHeadingId) keep module-level state reset per parse. +export function createMarked(...extensions: MarkedExtension[]) { + return new Marked( + markedHighlight({ + langPrefix: 'hljs language-', + highlight(code, lang) { + const language = hljs.getLanguage(lang) ? lang : 'plaintext'; + return hljs.highlight(code, { language }).value; + } + }), + ...extensions + ); +} diff --git a/src/lib/server/packages/content.test.ts b/src/lib/server/packages/content.test.ts new file mode 100644 index 0000000..0ef6c1c --- /dev/null +++ b/src/lib/server/packages/content.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import DOMPurify from 'isomorphic-dompurify'; +import { README_SANITIZE_OPTIONS, rewriteRelativeUrls } from '$lib/server/packages/readme-html'; +import { renderReadme } from '$lib/server/packages/readme-renderer'; + +describe('readme pipeline (render → sanitize → rewrite)', () => { + it('keeps heading ids and hash links through the full pipeline', async () => { + const raw = await renderReadme('README.md', '[Install](#install)\n\n## Install\n'); + const sanitized = DOMPurify.sanitize(raw, README_SANITIZE_OPTIONS); + const out = rewriteRelativeUrls(sanitized, 'github', 'o', 'r'); + expect(out).toContain('id="install"'); + expect(out).toContain('href="#install"'); + }); + + it('keeps non-ASCII heading ids through sanitize', async () => { + const raw = await renderReadme('README.md', '## 使い方\n'); + const out = DOMPurify.sanitize(raw, README_SANITIZE_OPTIONS); + expect(out).toContain('id="使い方"'); + }); + + it('still rewrites non-hash relative links to blob URLs', () => { + const out = rewriteRelativeUrls('x', 'github', 'o', 'r'); + expect(out).toContain('href="https://github.com/o/r/blob/main/docs/usage.md"'); + }); + + // Known limitation: DOMPurify's SANITIZE_DOM strips ids that would clobber + // DOM properties (e.g. `## Constructor` → id="constructor" is removed), so + // those anchors stay dead. GitHub avoids this with a `user-content-` prefix. + it('documents that clobbering ids are stripped by sanitize', async () => { + const raw = await renderReadme('README.md', '## Constructor\n'); + const out = DOMPurify.sanitize(raw, README_SANITIZE_OPTIONS); + expect(out).not.toContain('id="constructor"'); + }); +}); diff --git a/src/lib/server/packages/content.ts b/src/lib/server/packages/content.ts index 4302490..3182188 100644 --- a/src/lib/server/packages/content.ts +++ b/src/lib/server/packages/content.ts @@ -3,8 +3,8 @@ import { env } from '$env/dynamic/private'; import { parseZonFile } from '$lib/server/packages/zon-parser'; import { updatePackageContent } from '$lib/server/packages/queries'; import type { ContentClient } from '$lib/server/content-client'; -import { rawUrl, blobUrl } from '$lib/providers'; import { renderReadme } from '$lib/server/packages/readme-renderer'; +import { README_SANITIZE_OPTIONS, rewriteRelativeUrls } from '$lib/server/packages/readme-html'; import type { getPackageByFullName } from './queries'; type PackageWithContent = NonNullable>>; @@ -20,32 +20,6 @@ export type PackageContent = { const CONTENT_TTL_MS = parseInt(env.CONTENT_TTL_HOURS ?? '24') * 60 * 60 * 1000; -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; -} - async function fetchContent(pkg: PackageWithContent, client: ContentClient): Promise { 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}