Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 43 additions & 21 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/lib/docs/server.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
24 changes: 24 additions & 0 deletions src/lib/server/marked.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
34 changes: 34 additions & 0 deletions src/lib/server/packages/content.test.ts
Original file line number Diff line number Diff line change
@@ -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('<a href="docs/usage.md">x</a>', '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"');
});
});
30 changes: 2 additions & 28 deletions src/lib/server/packages/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Awaited<ReturnType<typeof getPackageByFullName>>>;
Expand All @@ -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<PackageContent> {
const [readme, tags, contents, zonContent] = await Promise.allSettled([
client.getReadme(pkg.owner, pkg.name),
Expand All @@ -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);
}
Expand Down
29 changes: 29 additions & 0 deletions src/lib/server/packages/readme-html.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading