/** * Unit tests for MarkifyService — the HTML-to-markdown conversion engine. * * These tests import the compiled service and linkedom directly, so they run * without any HTTP server, database, or network access. */ import { describe, it, before } from 'node:test'; import assert from 'node:assert/strict'; import { MarkifyService } from '../../build/services/markify.js'; // linkedom is ESM; use dynamic import resolved before tests run let parseHTML: (html: string) => { window: any }; before(async () => { const linkedom = await import('linkedom'); parseHTML = linkedom.parseHTML as any; }); // --- helpers ---------------------------------------------------------------- function parseDoc(html: string): HTMLElement { const { window } = parseHTML(`
${html}`); return window.document.documentElement; } /** Create a fresh MarkifyService with fenced code and ATX headings by default. */ function mkService(opts: ConstructorParametersbody
'), '# Title\n\nbody'); }); }); describe('headings: setext style', () => { it('h1 → underlined with = signs', () => { assert.equal(md('
'), '');
});
it('renders  when alt attribute is absent', () => {
assert.equal(md('
'), '');
});
it('renders  when alt is an empty string', () => {
assert.equal(md('
'), '');
});
it('appends title after the URL', () => {
assert.equal(
md('
'),
'',
);
});
it('populates .images array with src, alt, and ref', () => {
const svc = mkService();
svc.markify(parseDoc('
'));
assert.equal(svc.images.length, 2);
assert.deepEqual(svc.images[0], { src: 'a.png', alt: 'A', ref: 1 });
assert.deepEqual(svc.images[1], { src: 'b.png', alt: 'B', ref: 2 });
});
it('assigns incremental ref numbers', () => {
const svc = mkService();
svc.markify(parseDoc('

'));
assert.equal(svc.images[0].ref, 1);
assert.equal(svc.images[1].ref, 2);
assert.equal(svc.images[2].ref, 3);
});
});
// ---------------------------------------------------------------------------
// Code
// ---------------------------------------------------------------------------
describe('code: inline', () => {
it('single-line code without a language class renders as inline `code`', () => {
assert.equal(md('const x = 1'), '`const x = 1`');
});
it('trims surrounding whitespace inside inline code', () => {
assert.equal(md(' trimmed '), '`trimmed`');
});
});
describe('code: fenced block', () => {
it(' with multiple lines renders as fenced block', () => {
assert.equal(
md('line1\nline2
'),
'```\nline1\nline2\n```',
);
});
it('language-js class adds js hint to the opening fence', () => {
assert.equal(
md('const x = 1\nconst y = 2
'),
'```js\nconst x = 1\nconst y = 2\n```',
);
});
it('lang-python class adds python hint to the opening fence', () => {
assert.equal(
md('x = 1\ny = 2
'),
'```python\nx = 1\ny = 2\n```',
);
});
it('trims leading/trailing whitespace from the outer code block content', () => {
// code.trim() strips outer whitespace; internal indentation is preserved
const result = md(' line1\n line2
');
assert.match(result, /^```\nline1/m);
});
});
describe('code: indented block', () => {
it('multiline code with preceding paragraph uses 4-space indentation', () => {
const result = md(
'intro
line1\nline2
',
{ codeBlockStyle: 'indented' },
);
assert.equal(result, 'intro\n\n line1\n line2');
});
});
// ---------------------------------------------------------------------------
// Lists
// ---------------------------------------------------------------------------
describe('unordered lists', () => {
it('uses * as default bullet marker (with 3 trailing spaces)', () => {
const result = md('- Item A
- Item B
');
assert.match(result, /^\* Item A$/m);
assert.match(result, /^\* Item B$/m);
});
it('uses - as bullet marker when bulletListMarker is -', () => {
const result = md('- X
', { bulletListMarker: '-' });
assert.match(result, /^- X$/m);
});
it('uses + as bullet marker when bulletListMarker is +', () => {
const result = md('- X
', { bulletListMarker: '+' });
assert.match(result, /^\+ X$/m);
});
it('renders all list items', () => {
const result = md('- a
- b
- c
');
assert.ok(result.includes('a') && result.includes('b') && result.includes('c'));
});
it('nested list indents the child by 4 spaces', () => {
assert.equal(
md('- Top
- Sub
'),
'* Top\n * Sub',
);
});
});
describe('ordered lists', () => {
it('numbers items starting at 1', () => {
const result = md('- First
- Second
- Third
');
assert.match(result, /^1\. First$/m);
assert.match(result, /^2\. Second$/m);
assert.match(result, /^3\. Third$/m);
});
it('nested ordered list indents child by 4 spaces', () => {
assert.equal(
md('- a
- b
'),
'1. a\n 1. b',
);
});
});
// ---------------------------------------------------------------------------
// Blockquote
// ---------------------------------------------------------------------------
describe('blockquotes', () => {
it('single-paragraph blockquote becomes > prefixed line', () => {
assert.equal(md('quoted text
'), '> quoted text');
});
it('multi-paragraph blockquote prefixes each line with >', () => {
const result = md('line one
line two
');
assert.match(result, /^> line one/m);
assert.match(result, /^> line two/m);
});
});
// ---------------------------------------------------------------------------
// Horizontal rule
// ---------------------------------------------------------------------------
describe('horizontal rule', () => {
it('renders * * * by default', () => {
assert.equal(md('
'), '* * *');
});
it('renders the custom hr string ---', () => {
assert.equal(md('
', { hr: '---' }), '---');
});
it('renders the custom hr string ***', () => {
assert.equal(md('
', { hr: '***' }), '***');
});
});
// ---------------------------------------------------------------------------
// Tables (GFM)
// ---------------------------------------------------------------------------
describe('tables (requires gfm: true)', () => {
it('renders header, separator, and data rows with pipe syntax', () => {
const html = `
Name Age
Alice 30
`;
const result = md(html);
assert.match(result, /\| Name \| Age \|/);
assert.match(result, /\| --- \| --- \|/);
assert.match(result, /\| Alice \| 30 \|/);
});
it('header row appears before separator, which appears before data', () => {
const html = 'A B 1 2
';
const result = md(html);
const lines = result.split('\n').filter(Boolean);
const headerIdx = lines.findIndex((l) => l.includes('| A |'));
const sepIdx = lines.findIndex((l) => l.includes('| --- |'));
const cellIdx = lines.findIndex((l) => l.includes('| 1 |'));
assert.ok(headerIdx < sepIdx, 'header should precede separator');
assert.ok(sepIdx < cellIdx, 'separator should precede data');
});
it('table without gfm is NOT rendered as markdown table', () => {
const html = 'A 1
';
const result = md(html, { gfm: false });
assert.doesNotMatch(result, /\| A \|/);
});
});
// ---------------------------------------------------------------------------
// GFM: strikethrough
// ---------------------------------------------------------------------------
describe('strikethrough (requires gfm: true)', () => {
it(' wraps text with ~~', () => {
assert.equal(md('gone'), '~~gone~~');
});
it(' wraps text with ~~', () => {
assert.equal(md('strikethrough'), '~~strikethrough~~');
});
it(' wraps text with ~~', () => {
assert.equal(md('old'), '~~old~~');
});
it('empty del produces no output', () => {
assert.equal(md(''), '');
});
});
// ---------------------------------------------------------------------------
// Custom rules
// ---------------------------------------------------------------------------
describe('addRule', () => {
it('replaces the default handler output for the matched tag', () => {
const svc = mkService();
svc.addRule('highlight', {
filter: 'mark',
replacement: (content) => `==${content}==`,
});
const result = svc.markify(parseDoc('Hello world!
'));
assert.match(result, /==world==/);
});
it('applies multiple rules on the same tag in registration order', () => {
const svc = mkService();
// rule-b receives the output of rule-a
svc.addRule('rule-a', { filter: 'mark', replacement: (c) => `[A:${c}]` });
svc.addRule('rule-b', { filter: 'mark', replacement: (c) => `[B:${c}]` });
const result = svc.markify(parseDoc('x'));
assert.match(result, /\[B:\[A:x\]\]/);
});
});
describe('keep', () => {
it('preserves a kept tag as raw HTML outerHTML', () => {
const svc = mkService();
svc.keep('mark');
const result = svc.markify(parseDoc('Hello world
'));
assert.match(result, /world<\/mark>/);
});
it('inner content of a kept tag is not processed as markdown', () => {
const svc = mkService();
svc.keep('aside');
const result = svc.markify(parseDoc(''));
assert.doesNotMatch(result, /^# Skipped heading/m);
assert.match(result, /