spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : test/unit/markdown.test.mjs8 * Purpose : Unit tests — the flagship README rendering pipeline9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { describe, it, expect, beforeAll } from 'vitest';14import { renderMarkdown, githubSlug, resolveRelativeUrl } from '../../src/render/markdown.mjs';15import { initHighlighter } from '../../src/render/highlight.mjs';1617const CTX = { repo: 'demo', ref: 'main', basePath: '.', publicUrl: 'https://git.spboucher.ai' };1819beforeAll(async () => {20 await initHighlighter();21}, 60000);2223describe('githubSlug', () => {24 it('matches the GitHub algorithm', () => {25 expect(githubSlug('Installation')).toBe('installation');26 expect(githubSlug('Getting Started!')).toBe('getting-started');27 expect(githubSlug('API — v2.0 (beta)')).toBe('api--v20-beta');28 });29});3031describe('resolveRelativeUrl', () => {32 it('rewrites relative images to raw', () => {33 expect(resolveRelativeUrl('./docs/demo.png', CTX, 'raw')).toBe('/raw/demo/main/docs/demo.png');34 expect(resolveRelativeUrl('docs/demo.png', CTX, 'raw')).toBe('/raw/demo/main/docs/demo.png');35 });36 it('rewrites relative links to blob', () => {37 expect(resolveRelativeUrl('CONTRIBUTING.md', CTX, 'blob')).toBe('/demo/blob/main/CONTRIBUTING.md');38 });39 it('resolves against the document directory', () => {40 const nested = { ...CTX, basePath: 'docs' };41 expect(resolveRelativeUrl('img/x.png', nested, 'raw')).toBe('/raw/demo/main/docs/img/x.png');42 expect(resolveRelativeUrl('../top.png', nested, 'raw')).toBe('/raw/demo/main/top.png');43 });44 it('leaves absolute, anchor, and external URLs alone', () => {45 expect(resolveRelativeUrl('https://x.com/a.png', CTX, 'raw')).toBe('https://x.com/a.png');46 expect(resolveRelativeUrl('#section', CTX, 'blob')).toBe('#section');47 expect(resolveRelativeUrl('/already/rooted', CTX, 'blob')).toBe('/already/rooted');48 });49});5051describe('renderMarkdown', () => {52 it('keeps shields.io badges inline', async () => {53 const html = await renderMarkdown(' ', CTX);54 expect(html).toContain('src="https://img.shields.io/badge/x-y-green"');55 expect(html).toContain('loading="lazy"');56 });5758 it('renders GFM tables, strikethrough, task lists', async () => {59 const html = await renderMarkdown('| a |\n|---|\n| 1 |\n\n~~gone~~\n\n- [x] done\n- [ ] todo', CTX);60 expect(html).toContain('<table>');61 expect(html).toContain('<s>gone</s>');62 expect(html).toMatch(/checkbox[^>]*checked/);63 expect(html).toContain('disabled');64 });6566 it('adds GitHub-style heading anchors', async () => {67 const html = await renderMarkdown('## Getting Started', CTX);68 expect(html).toContain('id="getting-started"');69 expect(html).toContain('heading-anchor');70 });7172 it('strips scripts, event handlers, and iframes but keeps details/kbd/align', async () => {73 const source = [74 '<script>alert(1)</script>',75 '<img src="x.png" onerror="alert(1)">',76 '<iframe src="https://evil.com"></iframe>',77 '<details><summary>More</summary>Body</details>',78 '<kbd>Ctrl</kbd>',79 '<h1 align="center">Centered</h1>',80 ].join('\n\n');81 const html = await renderMarkdown(source, CTX);82 expect(html).not.toContain('<script');83 expect(html).not.toContain('onerror');84 expect(html).not.toContain('<iframe');85 expect(html).toContain('<details>');86 expect(html).toContain('<kbd>Ctrl</kbd>');87 expect(html).toContain('align="center"');88 });8990 it('rewrites relative HTML img src to the raw endpoint', async () => {91 const html = await renderMarkdown('<img src="./assets/logo.png" width="120">', CTX);92 expect(html).toContain('src="/raw/demo/main/assets/logo.png"');93 expect(html).toContain('width="120"');94 });9596 it('highlights fenced code with shiki dual themes', async () => {97 const html = await renderMarkdown('```js\nconst x = 1;\n```', CTX);98 expect(html).toContain('shiki');99 expect(html).toContain('--shiki-dark');100 });101102 it('emits mermaid placeholders for client rendering', async () => {103 const html = await renderMarkdown('```mermaid\ngraph TD; A-->B;\n```', CTX);104 expect(html).toContain('mermaid-block');105 expect(html).toContain('A-->B');106 });107108 it('converts emoji shortcodes', async () => {109 const html = await renderMarkdown('ship it :rocket:', CTX);110 expect(html).toContain('🚀');111 });112113 it('supports footnotes', async () => {114 const html = await renderMarkdown('text[^1]\n\n[^1]: the note', CTX);115 expect(html).toContain('footnote');116 });117118 it('never emits javascript: hrefs (markdown or raw HTML)', async () => {119 const md = await renderMarkdown('[click](javascript:alert(1))', CTX);120 expect(md).not.toMatch(/href\s*=\s*["']?javascript:/i);121 const html = await renderMarkdown('<a href="javascript:alert(1)">click</a>', CTX);122 expect(html).not.toMatch(/href\s*=\s*["']?javascript:/i);123 });124});125