105 lines
3.4 KiB
TypeScript
105 lines
3.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
extractHeadings,
|
||
filterPublished,
|
||
headingId,
|
||
highlightCode,
|
||
isPublished,
|
||
readingMinutes,
|
||
renderBlocks,
|
||
renderInline,
|
||
slugify,
|
||
} from '#shared/content';
|
||
|
||
describe('slugify', () => {
|
||
it('produces a url-safe slug', () => {
|
||
expect(slugify('Hello, World!')).toBe('hello-world');
|
||
expect(slugify(' Already-slug ')).toBe('already-slug');
|
||
});
|
||
|
||
it('matches headingId for a heading', () => {
|
||
expect(headingId('Designing for dark mode')).toBe('designing-for-dark-mode');
|
||
});
|
||
});
|
||
|
||
describe('readingMinutes', () => {
|
||
it('is at least one minute for short content', () => {
|
||
expect(readingMinutes('a short paragraph')).toBe(1);
|
||
});
|
||
|
||
it('scales with word count and ignores code fences', () => {
|
||
expect(readingMinutes('word '.repeat(600))).toBe(3);
|
||
const fenced = `intro\n\n\`\`\`ts\n${'x'.repeat(2000)}\n\`\`\``;
|
||
expect(readingMinutes(fenced)).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('draft filtering', () => {
|
||
const items = [
|
||
{ slug: 'a', status: 'published' },
|
||
{ slug: 'b', status: 'draft' },
|
||
{ slug: 'c', status: 'published' },
|
||
];
|
||
|
||
it('detects a published status', () => {
|
||
expect(isPublished({ status: 'published' })).toBe(true);
|
||
expect(isPublished({ status: 'draft' })).toBe(false);
|
||
});
|
||
|
||
it('keeps only published items', () => {
|
||
expect(filterPublished(items).map((i) => i.slug)).toEqual(['a', 'c']);
|
||
});
|
||
});
|
||
|
||
describe('renderBlocks', () => {
|
||
it('parses a fenced code block with a language', () => {
|
||
const blocks = renderBlocks('```ts\nconst x = 1;\n```');
|
||
expect(blocks).toHaveLength(1);
|
||
expect(blocks[0]?.type).toBe('code');
|
||
expect(blocks[0]?.lang).toBe('ts');
|
||
expect(blocks[0]?.code).toBe('const x = 1;');
|
||
});
|
||
|
||
it('parses headings, lists and paragraphs', () => {
|
||
const blocks = renderBlocks('## Setup\n\nIntro text.\n\n- one\n- two\n\nDone.');
|
||
expect(blocks.map((b) => b.type)).toEqual(['heading', 'paragraph', 'list', 'paragraph']);
|
||
expect(blocks[2]?.items).toEqual(['one', 'two']);
|
||
expect(blocks[1]?.text).toBe('Intro text.');
|
||
});
|
||
});
|
||
|
||
describe('extractHeadings (table of contents)', () => {
|
||
it('collects h2–h4 with slugified anchors and ignores h1', () => {
|
||
const toc = extractHeadings('# Title\n\n## Setup\n\n### Details\n\n#### Deep\n\nplain');
|
||
expect(toc.map((t) => t.id)).toEqual(['setup', 'details', 'deep']);
|
||
expect(toc.map((t) => t.level)).toEqual([2, 3, 4]);
|
||
});
|
||
|
||
it('deduplicates repeated heading ids', () => {
|
||
const toc = extractHeadings('## Setup\n\n## Setup\n');
|
||
expect(toc.map((t) => t.id)).toEqual(['setup', 'setup-2']);
|
||
});
|
||
});
|
||
|
||
describe('renderInline', () => {
|
||
it('renders code, bold and links', () => {
|
||
expect(renderInline('a `b` c')).toContain('<code>b</code>');
|
||
expect(renderInline('**bold**')).toContain('<strong>bold</strong>');
|
||
expect(renderInline('[docs](/docs)')).toContain('<a href="/docs">docs</a>');
|
||
});
|
||
|
||
it('escapes HTML and drops unsafe link targets', () => {
|
||
expect(renderInline('<script>alert(1)</script>')).toBe('<script>alert(1)</script>');
|
||
expect(renderInline('[x](javascript:alert(1))')).toContain('href="#"');
|
||
});
|
||
});
|
||
|
||
describe('highlightCode', () => {
|
||
it('marks keywords and numbers without leaking raw tags', () => {
|
||
const html = highlightCode('const x = 1', 'ts');
|
||
expect(html).toContain('tok-kw');
|
||
expect(html).toContain('tok-num');
|
||
expect(highlightCode('<b>')).not.toContain('<b>');
|
||
});
|
||
});
|