Skip to content
Merged
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
54 changes: 54 additions & 0 deletions packages/html2ffm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# @fuyeor/html2ffm

Convert HTML fragments into formatted [Fuyeor Flavored Markdown](https://reference.fuyeor.com/zh-hans/ffm/tutorials/syntax/basic) (FFM).

## Installation

```sh
pnpm add @fuyeor/html2ffm
```

The package is source-first and can be consumed by modern Node.js or browser applications through a normal ESM import.

## Usage

```ts
import { toFFM } from '@fuyeor/html2ffm';

const output = toFFM('<h1>Hello</h1><p><strong>World</strong></p>');

console.log(output);
// # Hello
//
// **World**
```

`toFFM` accepts an HTML fragment and returns a formatted string. It does not fetch external resources, execute scripts, or read stylesheets.

## Supported features

The converter supports headings (`h1`–`h6`), paragraphs, semantic block containers, horizontal rules, line breaks, emphasis, strong emphasis, underline, deletion, inline code, fenced code blocks, links, images, ordered and unordered lists, nested lists, blockquotes, and GFM-style tables.

Inline `style` attributes support `color` and `font-size`. CSS named colors, hexadecimal colors, RGB/RGBA colors, and HSL/HSLA colors are normalized to lowercase hexadecimal values. Opaque colors use six-digit hexadecimal output; alpha-bearing colors use eight-digit hexadecimal output. Unsupported or invalid declarations are ignored.

The parser decodes HTML entities. Unknown ordinary element wrappers are removed while their contents are recursively converted. `script`, `style`, `template`, `svg`, and `math` elements, including their contents, are discarded.

## Development

Run the package tests:

```sh
pnpm --filter @fuyeor/html2ffm test
```

Run the package typecheck:

```sh
pnpm --filter @fuyeor/html2ffm typecheck
```

Run the standalone benchmark:

```sh
pnpm --filter @fuyeor/html2ffm bench
```
31 changes: 31 additions & 0 deletions packages/html2ffm/bench/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// packages/html2ffm/bench/index.ts
import { toFFM } from '@fuyeor/html2ffm';

const ITERATIONS = 1_000;
const html = `
<article>
<h1>Benchmark article</h1>
<p><strong>Fuyeor</strong> converts <span style="color:red;font-size:20px">HTML</span> into FFM.</p>
<blockquote><p>One</p><p>Two</p><p>Three</p></blockquote>
<ul><li>First<ul><li>Nested</li></ul></li><li>Second</li></ul>
<table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>A</td><td>1</td></tr></tbody></table>
</article>
`;

// Measure steady-state conversion throughput and the process RSS delta for a repeatable local benchmark.
const initialRss = process.memoryUsage().rss;
const startedAt = process.hrtime.bigint();
let output = '';
for (let iteration = 0; iteration < ITERATIONS; iteration++) {
output = toFFM(html);
}
const elapsedMilliseconds =
Number(process.hrtime.bigint() - startedAt) / 1_000_000;
const finalRss = process.memoryUsage().rss;
const throughput = ITERATIONS / (elapsedMilliseconds / 1_000);

console.log(`iterations: ${ITERATIONS}`);
console.log(`elapsed_ms: ${elapsedMilliseconds.toFixed(2)}`);
console.log(`throughput_per_second: ${throughput.toFixed(2)}`);
console.log(`rss_delta_bytes: ${finalRss - initialRss}`);
console.log(`output_bytes: ${Buffer.byteLength(output, 'utf8')}`);
29 changes: 29 additions & 0 deletions packages/html2ffm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@fuyeor/html2ffm",
"version": "0.1.0",
"description": "Convert HTML fragments to Fuyeor Flavored Markdown.",
"license": "MIT",
"author": "Fuyeor <https://www.fuyeor.com/@Fuyeor>",
"type": "module",
"sideEffects": false,
"scripts": {
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
"bench": "node --experimental-strip-types bench/index.ts"
},
"imports": {
"#/*": "./src/*"
},
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@fuyeor/markdown-formatter": "workspace:*",
"htmlparser2": "^10.1.0"
},
"devDependencies": {
"@types/node": "^25.6.0",
"typescript": "^6.0.3",
"vitest": "^4.1.0"
}
}
72 changes: 72 additions & 0 deletions packages/html2ffm/src/fixtures/conversions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
[
{
"section": "inline formatting",
"text": "<b>Bold</b> <strong>Strong</strong> <i>Italic</i> <em>Emphasis</em> <u>Underline</u> <ins>Insert</ins> <del>Delete</del> <code>code</code>",
"expect": "**Bold** **Strong** *Italic* *Emphasis* __Underline__ __Insert__ --Delete-- `code`"
},
{
"section": "paragraphs and headings",
"text": "<h1>Title</h1><p>First</p><div>Second</div><section>Third</section>",
"expect": "# Title\n\nFirst\n\nSecond\n\nThird"
},
{
"section": "line breaks and horizontal rules",
"text": "<p>Before<br>After<br><br>End</p><hr>",
"expect": "Before\nAfter\n\nEnd\n\n---"
},
{
"section": "styled text",
"text": "<span style=\"color:red\">Red</span> <span style=\"font-size:20px\">Large</span> <span style=\"color:#ABC;font-size:20px\">Both</span>",
"expect": "[Red](color = #ff0000) [Large](font = {size = 20px}) [Both](color = #aabbcc, font = {size = 20px})"
},
{
"section": "nested style and underline",
"text": "<span style=\"color: rgb(255, 0, 0); font-size: 1.25rem\"><u>Text</u></span>",
"expect": "[__Text__](color = #ff0000, font = {size = 1.25rem})"
},
{
"section": "alpha colors",
"text": "<span style=\"color:rgba(255, 0, 0, 0.5)\">Half</span> <span style=\"color:transparent\">Clear</span>",
"expect": "[Half](color = #ff000080) Clear"
},
{
"section": "entity decoding and unknown tags",
"text": "<custom><b>Tom &amp; Jerry</b></custom><script>bad</script><style>.x{color:red}</style>",
"expect": "**Tom & Jerry**"
},
{
"section": "links and images",
"text": "<a href=\"/docs?a=1&amp;b=2\">Docs</a> <a href=\"javascript:alert(1)\">Unsafe</a> <img src=\"cat.jpg\" alt=\"Cat &amp; friend\"> <img src=\"missing-alt.jpg\">",
"expect": "[Docs](/docs?a=1&b=2) Unsafe ![Cat & friend](cat.jpg) ![](missing-alt.jpg)"
},
{
"section": "nested ordered and unordered lists",
"text": "<ol start=\"3\"><li>Three<ul><li><b>Child</b></li><li>Child two</li></ul></li><li>Four</li></ol>",
"expect": "3. Three\n - **Child**\n - Child two\n4. Four"
},
{
"section": "blockquote",
"text": "<blockquote><p>One</p><p>Two</p><p>Three</p></blockquote>",
"expect": "```quote\nOne\nTwo\nThree\n```"
},
{
"section": "header table",
"text": "<table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>A</td><td>1</td></tr></tbody></table>",
"expect": "| Name | Value |\n| --- | --- |\n| A | 1 |"
},
{
"section": "table without header",
"text": "<table><tr><td>A</td><td>1</td></tr><tr><td>B</td><td>2</td></tr></table>",
"expect": "| --- | --- |\n| A | 1 |\n| B | 2 |"
},
{
"section": "code block with a longer fence",
"text": "<pre><code>const value = ```text```;</code></pre>",
"expect": "````\nconst value = ```text```;\n````"
},
{
"section": "missing image source",
"text": "<img alt=\"No source\"><p>After</p>",
"expect": "After"
}
]
117 changes: 117 additions & 0 deletions packages/html2ffm/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// packages/html2ffm/src/index.spec.ts
import { describe, expect, it } from 'vitest';
import fixtureData from './fixtures/conversions.json';
import { toFFM } from './index';

type ConversionFixture = {
section: string;
text: string;
expect: string;
};

const fixtures = fixtureData as ConversionFixture[];

describe('toFFM fixtures', () => {
for (const fixture of fixtures) {
it(fixture.section, () => {
expect(toFFM(fixture.text)).toBe(fixture.expect);
});
}
});

describe('toFFM edge cases', () => {
it('normalizes RGB and HSL colors, including alpha', () => {
expect(toFFM('<span style="color:rgb(100% 0% 0% / 50%)">RGB</span>')).toBe(
'[RGB](color = #ff000080)',
);
expect(toFFM('<span style="color:hsl(0 100% 50%)">HSL</span>')).toBe(
'[HSL](color = #ff0000)',
);
expect(toFFM('<span style="color:#abcd">Short</span>')).toBe(
'[Short](color = #aabbccdd)',
);
});

it('applies the last valid declaration and suppresses transparent color', () => {
expect(toFFM('<span style="color:red;color:not-a-color">Keep</span>')).toBe(
'[Keep](color = #ff0000)',
);
expect(
toFFM('<span style="color:red;color:transparent">Clear</span>'),
).toBe('Clear');
expect(
toFFM(
'<span style="color:red"><span style="color:rgba(0,0,0,0)">Child</span></span>',
),
).toBe('Child');
});

it('inherits and overrides inline styles through nested elements', () => {
expect(
toFFM(
'<span style="color:red;font-size:20px">A <b>B</b> <span style="color:blue">C</span></span>',
),
).toBe(
'[A **B** ](color = #ff0000, font = {size = 20px})[C](color = #0000ff, font = {size = 20px})',
);
});

it('drops indentation-only whitespace while retaining inline spaces', () => {
expect(toFFM(`\n <p>First <b>item</b></p>\n <p>Second</p>\n`)).toBe(
'First **item**\n\nSecond',
);
});

it('keeps inline labels from adding line breaks and parses case-insensitively', () => {
expect(toFFM('<P><SPAN STYLE="COLOR:RED">Text</SPAN></P>')).toBe(
'[Text](color = #ff0000)',
);
});

it('preserves list and quote block structure', () => {
expect(
toFFM('<ul><li>One<ol><li>Nested</li></ol></li><li>Two</li></ul>'),
).toBe('- One\n 1. Nested\n- Two');
expect(toFFM('<blockquote><p>One</p><p>Two</p></blockquote>')).toBe(
'> One\n> Two',
);
});

it('uses th rows as headers without requiring thead', () => {
expect(
toFFM(
'<table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>',
),
).toBe('| A | B |\n| --- | --- |\n| 1 | 2 |');
});

it('does not render dangerous URLs or content elements', () => {
expect(
toFFM(
'<a href="data:text/plain,unsafe">Text</a><svg><b>Hidden</b></svg>',
),
).toBe('Text');
});

it('preserves code text and ignores inline markup inside pre', () => {
expect(toFFM('<pre><b>&lt;literal&gt;</b>\nvalue</pre>')).toBe(
'```\n<literal>\nvalue\n```',
);
});

it('accepts incomplete HTML fragments', () => {
expect(toFFM('<p><b>Unclosed')).toBe('**Unclosed**');
});
});

describe('toFFM input validation', () => {
it('fails fast for non-string input', () => {
expect(() => toFFM(null as unknown as string)).toThrow(
new TypeError('Input must be a string'),
);
});

it('accepts an empty fragment', () => {
expect(toFFM('')).toBe('');
});
});
Loading
Loading