/** * 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: ConstructorParameters[0] = {}): MarkifyService { return new MarkifyService({ headingStyle: 'atx', codeBlockStyle: 'fenced', fence: '```', gfm: true, ...opts, }); } /** Convert an HTML string to markdown using a one-shot MarkifyService. */ function md(html: string, opts: ConstructorParameters[0] = {}): string { return mkService(opts).markify(parseDoc(html)); } // --------------------------------------------------------------------------- // Headings // --------------------------------------------------------------------------- describe('headings: ATX style (default)', () => { it('h1 → # heading', () => { assert.equal(md('

Hello

'), '# Hello'); }); it('h2 → ## heading', () => { assert.equal(md('

Section

'), '## Section'); }); it('h3 → ### heading', () => { assert.equal(md('

Sub

'), '### Sub'); }); it('h4 → #### heading', () => { assert.equal(md('

Deep

'), '#### Deep'); }); it('h5 → ##### heading', () => { assert.equal(md('
Deeper
'), '##### Deeper'); }); it('h6 → ###### heading', () => { assert.equal(md('
Deepest
'), '###### Deepest'); }); it('empty heading produces no output', () => { assert.equal(md('

'), ''); }); it('heading preserves inner text with surrounding content', () => { assert.equal(md('

Title

body

'), '# Title\n\nbody'); }); }); describe('headings: setext style', () => { it('h1 → underlined with = signs', () => { assert.equal(md('

Top

', { headingStyle: 'setext' }), 'Top\n==='); }); it('h2 → underlined with - signs (underline length matches text)', () => { assert.equal(md('

Sub Two

', { headingStyle: 'setext' }), 'Sub Two\n-------'); }); it('h3 falls back to ATX even with setext option', () => { assert.equal(md('

Section

', { headingStyle: 'setext' }), '### Section'); }); it('h4 falls back to ATX even with setext option', () => { assert.equal(md('

Sub

', { headingStyle: 'setext' }), '#### Sub'); }); }); // --------------------------------------------------------------------------- // Inline formatting // --------------------------------------------------------------------------- describe('strong/bold', () => { it('wraps content with ** by default', () => { assert.equal(md('bold'), '**bold**'); }); it('wraps content with __ when strongDelimiter is __', () => { assert.equal(md('bold', { strongDelimiter: '__' }), '__bold__'); }); it(' is treated the same as ', () => { assert.equal(md('bold'), '**bold**'); }); it('empty strong produces no output', () => { assert.equal(md(''), ''); }); }); describe('emphasis/italic', () => { it('wraps content with _ by default', () => { assert.equal(md('italic'), '_italic_'); }); it('wraps content with * when emDelimiter is *', () => { assert.equal(md('italic', { emDelimiter: '*' }), '*italic*'); }); it(' is treated the same as ', () => { assert.equal(md('italic'), '_italic_'); }); it('underscores inside em text are escaped as \\_', () => { assert.equal(md('hello_world'), '_hello\\_world_'); }); it('empty em produces no output', () => { assert.equal(md(''), ''); }); }); // --------------------------------------------------------------------------- // Links // --------------------------------------------------------------------------- describe('links: inlined style (default)', () => { it('renders [text](url) for a basic link', () => { assert.equal(md('click'), '[click](https://example.com)'); }); it('includes title attribute as "title" suffix', () => { assert.equal( md('click'), '[click](https://x.com "Site")', ); }); it('link with no href produces [text]() and does NOT add to links array', () => { const svc = mkService(); const result = svc.markify(parseDoc('anchor')); assert.equal(result, '[anchor]()'); assert.equal(svc.links.length, 0); }); it('link with empty href produces [text]() and does NOT add to links array', () => { const svc = mkService(); svc.markify(parseDoc('empty')); assert.equal(svc.links.length, 0); }); it('resolves relative path against baseUrl', () => { assert.equal( md('page', { baseUrl: 'https://example.com/test' }), '[page](https://example.com/page)', ); }); it('ignores blob: baseUrl — relative URLs stay as-is', () => { assert.equal( md('page', { baseUrl: 'blob:https://example.com/abc' }), '[page](/page)', ); }); it('populates .links array with href, text, ref, and title', () => { const svc = mkService(); svc.markify(parseDoc('Alpha')); assert.equal(svc.links.length, 1); assert.equal(svc.links[0].href, 'https://a.com'); assert.equal(svc.links[0].text, 'Alpha'); assert.equal(svc.links[0].ref, 1); assert.equal(svc.links[0].title, 'A'); }); it('assigns incremental ref numbers to multiple links', () => { const svc = mkService(); svc.markify(parseDoc('A B')); assert.equal(svc.links.length, 2); assert.equal(svc.links[0].ref, 1); assert.equal(svc.links[1].ref, 2); }); }); describe('links: discarded style', () => { it('renders only the link text, omitting the URL', () => { assert.equal( md('click here', { linkStyle: 'discarded' }), 'click here', ); }); }); describe('links: referenced style', () => { it('full: renders [text][N] and appends numbered link definitions', () => { const svc = mkService({ linkStyle: 'referenced', linkReferenceStyle: 'full' }); assert.equal( svc.markify(parseDoc('Click')), '[Click][1]\n\n[1]: https://x.com', ); }); it('collapsed: renders [text][] and appends [text]: URL definitions', () => { const svc = mkService({ linkStyle: 'referenced', linkReferenceStyle: 'collapsed' }); assert.equal( svc.markify(parseDoc('Click')), '[Click][]\n\n[Click]: https://x.com', ); }); it('shortcut: renders [text] and appends [text]: URL definitions', () => { const svc = mkService({ linkStyle: 'referenced', linkReferenceStyle: 'shortcut' }); assert.equal( svc.markify(parseDoc('Click')), '[Click]\n\n[Click]: https://x.com', ); }); }); describe('links', () => { it('returns href, text, and ref for each collected link', () => { const svc = mkService(); svc.markify(parseDoc('Alpha')); const links = svc.links; assert.equal(links.length, 1); assert.equal(links[0].href, 'https://a.com'); assert.equal(links[0].text, 'Alpha'); assert.equal(links[0].ref, 1); }); }); // --------------------------------------------------------------------------- // Images // --------------------------------------------------------------------------- describe('images', () => { it('renders ![alt](src) for an image with alt text', () => { assert.equal(md('a photo'), '![a photo](img.png)'); }); it('renders ![](src) when alt attribute is absent', () => { assert.equal(md(''), '![](img.png)'); }); it('renders ![](src) when alt is an empty string', () => { assert.equal(md(''), '![](img.png)'); }); it('appends title after the URL', () => { assert.equal( md('desc'), '![desc](x.png "My Title")', ); }); it('populates .images array with src, alt, and ref', () => { const svc = mkService(); svc.markify(parseDoc('AB')); 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('onetwothree')); 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('
  1. First
  2. Second
  3. 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('
  1. a
    1. 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 = `
NameAge
Alice30
`; 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 = '
AB
12
'; 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, /