From f4ea7dcb775b847054a3a375d1ffb9da4bea5ef7 Mon Sep 17 00:00:00 2001 From: Fuyeor Date: Sun, 30 Aug 2026 16:59:48 +0000 Subject: [PATCH 1/5] refactor: modularize @fuyeor/html2ffm --- packages/html2ffm/package.json | 2 +- packages/html2ffm/src/color.ts | 198 ++++ packages/html2ffm/src/constants.ts | 196 ++++ .../html2ffm/src/fixtures/conversions.json | 208 ++-- packages/html2ffm/src/index.spec.ts | 115 +-- packages/html2ffm/src/index.ts | 889 +----------------- packages/html2ffm/src/render.ts | 415 ++++++++ packages/html2ffm/src/style.ts | 60 ++ packages/html2ffm/src/types.ts | 46 + pnpm-lock.yaml | 26 +- pnpm-workspace.yaml | 2 + 11 files changed, 1076 insertions(+), 1081 deletions(-) create mode 100644 packages/html2ffm/src/color.ts create mode 100644 packages/html2ffm/src/constants.ts create mode 100644 packages/html2ffm/src/render.ts create mode 100644 packages/html2ffm/src/style.ts create mode 100644 packages/html2ffm/src/types.ts diff --git a/packages/html2ffm/package.json b/packages/html2ffm/package.json index 22ff09a..62013bd 100644 --- a/packages/html2ffm/package.json +++ b/packages/html2ffm/package.json @@ -1,6 +1,6 @@ { "name": "@fuyeor/html2ffm", - "version": "0.1.0", + "version": "0.1.1", "description": "Convert HTML fragments to Fuyeor Flavored Markdown.", "license": "MIT", "author": "Fuyeor ", diff --git a/packages/html2ffm/src/color.ts b/packages/html2ffm/src/color.ts new file mode 100644 index 0000000..5908246 --- /dev/null +++ b/packages/html2ffm/src/color.ts @@ -0,0 +1,198 @@ +// @fuyeor/html2ffm/src/color.ts +import { CSS_NAMED_COLORS } from './constants'; +import type { Rgba } from './types'; + +// Convert one clamped color channel to a two-digit lowercase hexadecimal value. +function normalizeHexChannel(channel: number): string { + return Math.max(0, Math.min(255, Math.round(channel))) + .toString(16) + .padStart(2, '0'); +} + +export function rgbaToHex(color: Rgba, forceAlpha = false): string | null { + if (color.alpha <= 0) return null; + const red = normalizeHexChannel(color.red); + const green = normalizeHexChannel(color.green); + const blue = normalizeHexChannel(color.blue); + if (color.alpha >= 1 && !forceAlpha) return `#${red}${green}${blue}`; + return `#${red}${green}${blue}${normalizeHexChannel(color.alpha * 255)}`; +} + +// Parse CSS numeric or percentage channels into a bounded numeric range. +function parsePercentageOrNumber(value: string, scale: number): number | null { + const trimmed = value.trim(); + if (trimmed.endsWith('%')) { + const percentage = Number(trimmed.slice(0, -1)); + return Number.isFinite(percentage) + ? Math.max(0, Math.min(scale, (percentage / 100) * scale)) + : null; + } + const number = Number(trimmed); + return Number.isFinite(number) ? Math.max(0, Math.min(scale, number)) : null; +} + +function parseAlpha(value: string): number | null { + const trimmed = value.trim(); + if (trimmed.endsWith('%')) { + const percentage = Number(trimmed.slice(0, -1)); + return Number.isFinite(percentage) + ? Math.max(0, Math.min(1, percentage / 100)) + : null; + } + const number = Number(trimmed); + return Number.isFinite(number) ? Math.max(0, Math.min(1, number)) : null; +} + +// Split modern space-separated and legacy comma-separated CSS color arguments. +function splitFunctionalColorArguments(value: string): string[] | null { + const body = value.slice(value.indexOf('(') + 1, -1).trim(); + if (!body) return null; + if (body.includes(',')) return body.split(',').map((part) => part.trim()); + const slashIndex = body.indexOf('/'); + const channels = (slashIndex === -1 ? body : body.slice(0, slashIndex)) + .trim() + .split(/\s+/u); + if (slashIndex === -1) return channels; + return [...channels, body.slice(slashIndex + 1).trim()]; +} + +export function parseRgbColor(value: string): Rgba | null { + const argumentsList = splitFunctionalColorArguments(value); + if ( + !argumentsList || + (argumentsList.length !== 3 && argumentsList.length !== 4) + ) + return null; + const red = parsePercentageOrNumber(argumentsList[0]!, 255); + const green = parsePercentageOrNumber(argumentsList[1]!, 255); + const blue = parsePercentageOrNumber(argumentsList[2]!, 255); + const alpha = argumentsList.length === 4 ? parseAlpha(argumentsList[3]!) : 1; + if (red === null || green === null || blue === null || alpha === null) + return null; + return { red, green, blue, alpha }; +} + +function parseHue(value: string): number | null { + const trimmed = value.trim().toLowerCase(); + const match = trimmed.match( + /^([+-]?(?:\d+(?:\.\d+)?|\.\d+))(deg|grad|rad|turn)?$/u, + ); + if (!match) return null; + const amount = Number(match[1]); + if (!Number.isFinite(amount)) return null; + const turns = + match[2] === 'grad' + ? amount / 400 + : match[2] === 'rad' + ? amount / (2 * Math.PI) + : match[2] === 'turn' + ? amount + : amount / 360; + return ((turns % 1) + 1) % 1; +} + +function hueToRgb(p: number, q: number, t: number): number { + let value = t; + if (value < 0) value += 1; + if (value > 1) value -= 1; + if (value < 1 / 6) return p + (q - p) * 6 * value; + if (value < 1 / 2) return q; + if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6; + return p; +} + +export function parseHslColor(value: string): Rgba | null { + const argumentsList = splitFunctionalColorArguments(value); + if ( + !argumentsList || + (argumentsList.length !== 3 && argumentsList.length !== 4) + ) + return null; + const hue = parseHue(argumentsList[0]!); + const saturation = argumentsList[1]!.trim(); + const lightness = argumentsList[2]!.trim(); + if (hue === null || !saturation.endsWith('%') || !lightness.endsWith('%')) + return null; + const saturationNumber = Number(saturation.slice(0, -1)); + const lightnessNumber = Number(lightness.slice(0, -1)); + const alpha = argumentsList.length === 4 ? parseAlpha(argumentsList[3]!) : 1; + if ( + !Number.isFinite(saturationNumber) || + !Number.isFinite(lightnessNumber) || + alpha === null + ) + return null; + const s = Math.max(0, Math.min(100, saturationNumber)) / 100; + const l = Math.max(0, Math.min(100, lightnessNumber)) / 100; + if (s === 0) { + const channel = l * 255; + return { red: channel, green: channel, blue: channel, alpha }; + } + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + return { + red: hueToRgb(p, q, hue + 1 / 3) * 255, + green: hueToRgb(p, q, hue) * 255, + blue: hueToRgb(p, q, hue - 1 / 3) * 255, + alpha, + }; +} + +// Normalize supported CSS colors into FFM-compatible hexadecimal notation. +export function parseColor(value: string): string | null { + const normalized = value.trim().toLowerCase(); + if (!normalized || normalized.includes('var(')) return null; + + const named = CSS_NAMED_COLORS[normalized]; + if (named) return named; + if (normalized === 'transparent') return null; + + const hexMatch = normalized.match(/^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/u); + if (hexMatch) { + const source = hexMatch[1]!; + const expanded = + source.length <= 4 + ? [...source].map((character) => `${character}${character}`).join('') + : source; + return rgbaToHex( + { + red: Number.parseInt(expanded.slice(0, 2), 16), + green: Number.parseInt(expanded.slice(2, 4), 16), + blue: Number.parseInt(expanded.slice(4, 6), 16), + alpha: + expanded.length === 8 + ? Number.parseInt(expanded.slice(6, 8), 16) / 255 + : 1, + }, + expanded.length === 8, + ); + } + + if (/^rgba?\(/u.test(normalized)) { + const color = parseRgbColor(normalized); + return color ? rgbaToHex(color, /^rgba\(/u.test(normalized)) : null; + } + if (/^hsla?\(/u.test(normalized)) { + const color = parseHslColor(normalized); + return color ? rgbaToHex(color, /^hsla\(/u.test(normalized)) : null; + } + return null; +} + +export function isTransparentColor(value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (normalized === 'transparent') return true; + const hexMatch = normalized.match(/^#([\da-f]{4}|[\da-f]{8})$/u); + if (hexMatch) { + const source = hexMatch[1]!; + const alpha = + source.length === 4 ? `${source[3]}${source[3]}` : source.slice(6, 8); + return alpha === '00'; + } + const color = /^rgba?\(/u.test(normalized) + ? parseRgbColor(normalized) + : /^hsla?\(/u.test(normalized) + ? parseHslColor(normalized) + : null; + return color !== null && color.alpha <= 0; +} diff --git a/packages/html2ffm/src/constants.ts b/packages/html2ffm/src/constants.ts new file mode 100644 index 0000000..985f572 --- /dev/null +++ b/packages/html2ffm/src/constants.ts @@ -0,0 +1,196 @@ +// @fuyeor/html2ffm/src/constants.ts +import type { Marks } from './types'; + +export const BLOCK_TAGS = new Set([ + 'article', + 'aside', + 'blockquote', + 'div', + 'footer', + 'header', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'nav', + 'ol', + 'p', + 'pre', + 'section', + 'table', + 'ul', +]); + +export const DROPPED_TAGS = new Set([ + 'math', + 'script', + 'style', + 'svg', + 'template', +]); +export const HEADING_PATTERN = /^h([1-6])$/u; +export const CSS_LENGTH_PATTERN = + /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%|pt|pc|in|cm|mm|q|ch|ex|cap|ic|lh|rlh|vw|vh|vmin|vmax|svw|svh|lvw|lvh|dvw|dvh|vi|vb)$/iu; +export const SAFE_SCHEME_PATTERN = /^(?:https?:|mailto:|tel:|ftp:)/iu; +export const DANGEROUS_URL_PATTERN = /^(?:javascript:|data:|vbscript:|file:)/iu; + +export const EMPTY_MARKS: Marks = { + bold: false, + italic: false, + underline: false, + strike: false, +}; + +export const CSS_NAMED_COLORS: Readonly> = { + aliceblue: '#f0f8ff', + antiquewhite: '#faebd7', + aqua: '#00ffff', + aquamarine: '#7fffd4', + azure: '#f0ffff', + beige: '#f5f5dc', + bisque: '#ffe4c4', + black: '#000000', + blanchedalmond: '#ffebcd', + blue: '#0000ff', + blueviolet: '#8a2be2', + brown: '#a52a2a', + burlywood: '#deb887', + cadetblue: '#5f9ea0', + chartreuse: '#7fff00', + chocolate: '#d2691e', + coral: '#ff7f50', + cornflowerblue: '#6495ed', + cornsilk: '#fff8dc', + crimson: '#dc143c', + cyan: '#00ffff', + darkblue: '#00008b', + darkcyan: '#008b8b', + darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', + darkgreen: '#006400', + darkgrey: '#a9a9a9', + darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', + darkolivegreen: '#556b2f', + darkorange: '#ff8c00', + darkorchid: '#9932cc', + darkred: '#8b0000', + darksalmon: '#e9967a', + darkseagreen: '#8fbc8f', + darkslateblue: '#483d8b', + darkslategray: '#2f4f4f', + darkslategrey: '#2f4f4f', + darkturquoise: '#00ced1', + darkviolet: '#9400d3', + deeppink: '#ff1493', + deepskyblue: '#00bfff', + dimgray: '#696969', + dimgrey: '#696969', + dodgerblue: '#1e90ff', + firebrick: '#b22222', + floralwhite: '#fffaf0', + forestgreen: '#228b22', + fuchsia: '#ff00ff', + gainsboro: '#dcdcdc', + ghostwhite: '#f8f8ff', + gold: '#ffd700', + goldenrod: '#daa520', + gray: '#808080', + green: '#008000', + greenyellow: '#adff2f', + grey: '#808080', + honeydew: '#f0fff0', + hotpink: '#ff69b4', + indianred: '#cd5c5c', + indigo: '#4b0082', + ivory: '#fffff0', + khaki: '#f0e68c', + lavender: '#e6e6fa', + lavenderblush: '#fff0f5', + lawngreen: '#7cfc00', + lemonchiffon: '#fffacd', + lightblue: '#add8e6', + lightcoral: '#f08080', + lightcyan: '#e0ffff', + lightgoldenrodyellow: '#fafad2', + lightgray: '#d3d3d3', + lightgreen: '#90ee90', + lightgrey: '#d3d3d3', + lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', + lightseagreen: '#20b2aa', + lightskyblue: '#87cefa', + lightslategray: '#778899', + lightslategrey: '#778899', + lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', + lime: '#00ff00', + limegreen: '#32cd32', + linen: '#faf0e6', + magenta: '#ff00ff', + maroon: '#800000', + mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', + mediumorchid: '#ba55d3', + mediumpurple: '#9370db', + mediumseagreen: '#3cb371', + mediumslateblue: '#7b68ee', + mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', + mediumvioletred: '#c71585', + midnightblue: '#191970', + mintcream: '#f5fffa', + mistyrose: '#ffe4e1', + moccasin: '#ffe4b5', + navajowhite: '#ffdead', + navy: '#000080', + oldlace: '#fdf5e6', + olive: '#808000', + olivedrab: '#6b8e23', + orange: '#ffa500', + orangered: '#ff4500', + orchid: '#da70d6', + palegoldenrod: '#eee8aa', + palegreen: '#98fb98', + paleturquoise: '#afeeee', + palevioletred: '#db7093', + papayawhip: '#ffefd5', + peachpuff: '#ffdab9', + peru: '#cd853f', + pink: '#ffc0cb', + plum: '#dda0dd', + powderblue: '#b0e0e6', + purple: '#800080', + rebeccapurple: '#663399', + red: '#ff0000', + rosybrown: '#bc8f8f', + royalblue: '#4169e1', + saddlebrown: '#8b4513', + salmon: '#fa8072', + sandybrown: '#f4a460', + seagreen: '#2e8b57', + seashell: '#fff5ee', + sienna: '#a0522d', + silver: '#c0c0c0', + skyblue: '#87ceeb', + slateblue: '#6a5acd', + slategray: '#708090', + slategrey: '#708090', + snow: '#fffafa', + springgreen: '#00ff7f', + steelblue: '#4682b4', + tan: '#d2b48c', + teal: '#008080', + thistle: '#d8bfd8', + tomato: '#ff6347', + turquoise: '#40e0d0', + violet: '#ee82ee', + wheat: '#f5deb3', + white: '#ffffff', + whitesmoke: '#f5f5f5', + yellow: '#ffff00', + yellowgreen: '#9acd32', +}; diff --git a/packages/html2ffm/src/fixtures/conversions.json b/packages/html2ffm/src/fixtures/conversions.json index ab822ae..0e5508b 100644 --- a/packages/html2ffm/src/fixtures/conversions.json +++ b/packages/html2ffm/src/fixtures/conversions.json @@ -1,72 +1,136 @@ -[ - { - "section": "inline formatting", - "text": "Bold Strong Italic Emphasis Underline Insert Delete code", - "expect": "**Bold** **Strong** *Italic* *Emphasis* __Underline__ __Insert__ --Delete-- `code`" - }, - { - "section": "paragraphs and headings", - "text": "

Title

First

Second
Third
", - "expect": "# Title\n\nFirst\n\nSecond\n\nThird" - }, - { - "section": "line breaks and horizontal rules", - "text": "

Before
After

End


", - "expect": "Before\nAfter\n\nEnd\n\n---" - }, - { - "section": "styled text", - "text": "Red Large Both", - "expect": "[Red](color = #ff0000) [Large](font = {size = 20px}) [Both](color = #aabbcc, font = {size = 20px})" - }, - { - "section": "nested style and underline", - "text": "Text", - "expect": "[__Text__](color = #ff0000, font = {size = 1.25rem})" - }, - { - "section": "alpha colors", - "text": "Half Clear", - "expect": "[Half](color = #ff000080) Clear" - }, - { - "section": "entity decoding and unknown tags", - "text": "Tom & Jerry", - "expect": "**Tom & Jerry**" - }, - { - "section": "links and images", - "text": "Docs Unsafe \"Cat ", - "expect": "[Docs](/docs?a=1&b=2) Unsafe ![Cat & friend](cat.jpg) ![](missing-alt.jpg)" - }, - { - "section": "nested ordered and unordered lists", - "text": "
  1. Three
    • Child
    • Child two
  2. Four
", - "expect": "3. Three\n - **Child**\n - Child two\n4. Four" - }, - { - "section": "blockquote", - "text": "

One

Two

Three

", - "expect": "```quote\nOne\nTwo\nThree\n```" - }, - { - "section": "header table", - "text": "
NameValue
A1
", - "expect": "| Name | Value |\n| --- | --- |\n| A | 1 |" - }, - { - "section": "table without header", - "text": "
A1
B2
", - "expect": "| --- | --- |\n| A | 1 |\n| B | 2 |" - }, - { - "section": "code block with a longer fence", - "text": "
const value = ```text```;
", - "expect": "````\nconst value = ```text```;\n````" - }, - { - "section": "missing image source", - "text": "\"No

After

", - "expect": "After" - } -] +{ + "inline formatting": [ + { + "desc": "bold", + "text": "Bold ...", + "expect": "**Bold** ..." + }, + { + "desc": "inline formatting", + "text": "Bold Strong Italic Emphasis Underline Insert Delete code", + "expect": "**Bold** **Strong** *Italic* *Emphasis* __Underline__ __Insert__ --Delete-- `code`" + }, + { + "desc": "alpha colors", + "text": "Half Clear", + "expect": "[Half](color = #ff000080) Clear" + } + ], + "blockquote": [ + { + "desc": "short blockquote (under 3 lines)", + "text": "

One

Two

", + "expect": "> One\n> Two" + }, + { + "desc": "long blockquote (3 lines or more)", + "text": "

One

Two

Three

", + "expect": "```quote\nOne\nTwo\nThree\n```" + }, + { + "desc": "blockquote", + "text": "

One

Two

Three

", + "expect": "```quote\nOne\nTwo\nThree\n```" + } + ], + "paragraphs and headings": [ + { + "desc": "paragraphs and headings", + "text": "

Title

First

Second
Third
", + "expect": "# Title\n\nFirst\n\nSecond\n\nThird" + } + ], + "line breaks and horizontal rules": [ + { + "desc": "line breaks and horizontal rules", + "text": "

Before
After

End


", + "expect": "Before\nAfter\n\nEnd\n\n---" + } + ], + "color and styles": [ + { + "desc": "color normalization - rgb with alpha", + "text": "RGB", + "expect": "[RGB](color = #ff000080)" + }, + { + "desc": "color", + "text": "Red", + "expect": "[Red](color = #ff0000)" + }, + { + "desc": "font-size", + "text": "Large", + "expect": "[Large](font = {size = 20px})" + }, + { + "desc": "color and font-size", + "text": "Both", + "expect": "[Both](color = #aabbcc, font = {size = 20px})" + }, + { + "desc": "background", + "text": "Text", + "expect": "[Text](background = #ffff99)" + }, + { + "desc": "nested style and underline", + "text": "Text", + "expect": "[__Text__](color = #ff0000, font = {size = 1.25rem})" + } + ], + "entity decoding and unknown tags": [ + { + "desc": "entity decoding and unknown tags", + "text": "Tom & Jerry", + "expect": "**Tom & Jerry**" + } + ], + "links and images": [ + { + "desc": "links and images", + "text": "Docs Unsafe \"Cat ", + "expect": "[Docs](/docs?a=1&b=2) Unsafe ![Cat & friend](cat.jpg) ![](missing-alt.jpg)" + } + ], + "nested ordered and unordered lists": [ + { + "desc": "nested ordered and unordered lists", + "text": "
  1. Three
    • Child
    • Child two
  2. Four
", + "expect": "3. Three\n - **Child**\n - Child two\n4. Four" + } + ], + "tables": [ + { + "desc": "header table", + "text": "
NameValue
A1
", + "expect": "| Name | Value |\n| --- | --- |\n| A | 1 |" + }, + { + "desc": "table without header", + "text": "
A1
B2
", + "expect": "| --- | --- |\n| A | 1 |\n| B | 2 |" + } + ], + "code block with a longer fence": [ + { + "desc": "code block with a longer fence", + "text": "
const value = ```text```;
", + "expect": "````\nconst value = ```text```;\n````" + } + ], + "missing image source": [ + { + "desc": "missing image source", + "text": "\"No

After

", + "expect": "After" + } + ], + "remove unnecessary elements": [ + { + "desc": "div", + "text": "
Text
", + "expect": "Text" + } + ] +} diff --git a/packages/html2ffm/src/index.spec.ts b/packages/html2ffm/src/index.spec.ts index 694013b..00491b6 100644 --- a/packages/html2ffm/src/index.spec.ts +++ b/packages/html2ffm/src/index.spec.ts @@ -1,117 +1,24 @@ -// packages/html2ffm/src/index.spec.ts +// @fuyeor/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); +import fixtures from './fixtures/conversions.json' with { type: 'json' }; + +describe('toFFM conversions', () => { + for (const [section, cases] of Object.entries(fixtures)) { + describe(section, () => { + for (const [index, { desc, text, expect: expected }] of cases.entries()) { + it(desc ?? `case ${index + 1}: ${text.slice(0, 30)}`, () => { + expect(toFFM(text)).toBe(expected); + }); + } }); } }); -describe('toFFM edge cases', () => { - it('normalizes RGB and HSL colors, including alpha', () => { - expect(toFFM('RGB')).toBe( - '[RGB](color = #ff000080)', - ); - expect(toFFM('HSL')).toBe( - '[HSL](color = #ff0000)', - ); - expect(toFFM('Short')).toBe( - '[Short](color = #aabbccdd)', - ); - }); - - it('applies the last valid declaration and suppresses transparent color', () => { - expect(toFFM('Keep')).toBe( - '[Keep](color = #ff0000)', - ); - expect( - toFFM('Clear'), - ).toBe('Clear'); - expect( - toFFM( - 'Child', - ), - ).toBe('Child'); - }); - - it('inherits and overrides inline styles through nested elements', () => { - expect( - toFFM( - 'A B C', - ), - ).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

First item

\n

Second

\n`)).toBe( - 'First **item**\n\nSecond', - ); - }); - - it('keeps inline labels from adding line breaks and parses case-insensitively', () => { - expect(toFFM('

Text

')).toBe( - '[Text](color = #ff0000)', - ); - }); - - it('preserves list and quote block structure', () => { - expect( - toFFM('
  • One
    1. Nested
  • Two
'), - ).toBe('- One\n 1. Nested\n- Two'); - expect(toFFM('

One

Two

')).toBe( - '> One\n> Two', - ); - }); - - it('uses th rows as headers without requiring thead', () => { - expect( - toFFM( - '
AB
12
', - ), - ).toBe('| A | B |\n| --- | --- |\n| 1 | 2 |'); - }); - - it('does not render dangerous URLs or content elements', () => { - expect( - toFFM( - 'TextHidden', - ), - ).toBe('Text'); - }); - - it('preserves code text and ignores inline markup inside pre', () => { - expect(toFFM('
<literal>\nvalue
')).toBe( - '```\n\nvalue\n```', - ); - }); - - it('accepts incomplete HTML fragments', () => { - expect(toFFM('

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(''); - }); }); diff --git a/packages/html2ffm/src/index.ts b/packages/html2ffm/src/index.ts index 23479a1..ee5af1a 100644 --- a/packages/html2ffm/src/index.ts +++ b/packages/html2ffm/src/index.ts @@ -1,883 +1,14 @@ -// packages/html2ffm/src/index.ts -import { parseDocument } from 'htmlparser2'; +// @fuyeor/html2ffm/src/index.ts import { format } from '@fuyeor/markdown-formatter'; - -type ParsedDocument = ReturnType; -type ChildNode = ParsedDocument['children'][number]; - -type ElementNode = ChildNode & { - name: string; - attribs: Record; - children: ChildNode[]; -}; - -type TextNode = ChildNode & { - data: string; -}; - -type Style = { - color?: string | null; - fontSize?: string; -}; - -type Marks = { - bold: boolean; - italic: boolean; - underline: boolean; - strike: boolean; - link?: string; -}; - -type InlinePiece = { - content: string; - style: Style; - marks: Marks; -}; - -type Rgba = { - red: number; - green: number; - blue: number; - alpha: number; -}; - -const BLOCK_TAGS = new Set([ - 'article', - 'aside', - 'blockquote', - 'div', - 'footer', - 'header', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'hr', - 'nav', - 'ol', - 'p', - 'pre', - 'section', - 'table', - 'ul', -]); - -const DROPPED_TAGS = new Set(['math', 'script', 'style', 'svg', 'template']); -const HEADING_PATTERN = /^h([1-6])$/u; -const CSS_LENGTH_PATTERN = - /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%|pt|pc|in|cm|mm|q|ch|ex|cap|ic|lh|rlh|vw|vh|vmin|vmax|svw|svh|lvw|lvh|dvw|dvh|vi|vb)$/iu; -const SAFE_SCHEME_PATTERN = /^(?:https?:|mailto:|tel:|ftp:)/iu; -const DANGEROUS_URL_PATTERN = /^(?:javascript:|data:|vbscript:|file:)/iu; - -// Keep the named-color table local so the browser bundle does not need a color dependency. -const CSS_NAMED_COLORS: Readonly> = { - aliceblue: '#f0f8ff', - antiquewhite: '#faebd7', - aqua: '#00ffff', - aquamarine: '#7fffd4', - azure: '#f0ffff', - beige: '#f5f5dc', - bisque: '#ffe4c4', - black: '#000000', - blanchedalmond: '#ffebcd', - blue: '#0000ff', - blueviolet: '#8a2be2', - brown: '#a52a2a', - burlywood: '#deb887', - cadetblue: '#5f9ea0', - chartreuse: '#7fff00', - chocolate: '#d2691e', - coral: '#ff7f50', - cornflowerblue: '#6495ed', - cornsilk: '#fff8dc', - crimson: '#dc143c', - cyan: '#00ffff', - darkblue: '#00008b', - darkcyan: '#008b8b', - darkgoldenrod: '#b8860b', - darkgray: '#a9a9a9', - darkgreen: '#006400', - darkgrey: '#a9a9a9', - darkkhaki: '#bdb76b', - darkmagenta: '#8b008b', - darkolivegreen: '#556b2f', - darkorange: '#ff8c00', - darkorchid: '#9932cc', - darkred: '#8b0000', - darksalmon: '#e9967a', - darkseagreen: '#8fbc8f', - darkslateblue: '#483d8b', - darkslategray: '#2f4f4f', - darkslategrey: '#2f4f4f', - darkturquoise: '#00ced1', - darkviolet: '#9400d3', - deeppink: '#ff1493', - deepskyblue: '#00bfff', - dimgray: '#696969', - dimgrey: '#696969', - dodgerblue: '#1e90ff', - firebrick: '#b22222', - floralwhite: '#fffaf0', - forestgreen: '#228b22', - fuchsia: '#ff00ff', - gainsboro: '#dcdcdc', - ghostwhite: '#f8f8ff', - gold: '#ffd700', - goldenrod: '#daa520', - gray: '#808080', - green: '#008000', - greenyellow: '#adff2f', - grey: '#808080', - honeydew: '#f0fff0', - hotpink: '#ff69b4', - indianred: '#cd5c5c', - indigo: '#4b0082', - ivory: '#fffff0', - khaki: '#f0e68c', - lavender: '#e6e6fa', - lavenderblush: '#fff0f5', - lawngreen: '#7cfc00', - lemonchiffon: '#fffacd', - lightblue: '#add8e6', - lightcoral: '#f08080', - lightcyan: '#e0ffff', - lightgoldenrodyellow: '#fafad2', - lightgray: '#d3d3d3', - lightgreen: '#90ee90', - lightgrey: '#d3d3d3', - lightpink: '#ffb6c1', - lightsalmon: '#ffa07a', - lightseagreen: '#20b2aa', - lightskyblue: '#87cefa', - lightslategray: '#778899', - lightslategrey: '#778899', - lightsteelblue: '#b0c4de', - lightyellow: '#ffffe0', - lime: '#00ff00', - limegreen: '#32cd32', - linen: '#faf0e6', - magenta: '#ff00ff', - maroon: '#800000', - mediumaquamarine: '#66cdaa', - mediumblue: '#0000cd', - mediumorchid: '#ba55d3', - mediumpurple: '#9370db', - mediumseagreen: '#3cb371', - mediumslateblue: '#7b68ee', - mediumspringgreen: '#00fa9a', - mediumturquoise: '#48d1cc', - mediumvioletred: '#c71585', - midnightblue: '#191970', - mintcream: '#f5fffa', - mistyrose: '#ffe4e1', - moccasin: '#ffe4b5', - navajowhite: '#ffdead', - navy: '#000080', - oldlace: '#fdf5e6', - olive: '#808000', - olivedrab: '#6b8e23', - orange: '#ffa500', - orangered: '#ff4500', - orchid: '#da70d6', - palegoldenrod: '#eee8aa', - palegreen: '#98fb98', - paleturquoise: '#afeeee', - palevioletred: '#db7093', - papayawhip: '#ffefd5', - peachpuff: '#ffdab9', - peru: '#cd853f', - pink: '#ffc0cb', - plum: '#dda0dd', - powderblue: '#b0e0e6', - purple: '#800080', - rebeccapurple: '#663399', - red: '#ff0000', - rosybrown: '#bc8f8f', - royalblue: '#4169e1', - saddlebrown: '#8b4513', - salmon: '#fa8072', - sandybrown: '#f4a460', - seagreen: '#2e8b57', - seashell: '#fff5ee', - sienna: '#a0522d', - silver: '#c0c0c0', - skyblue: '#87ceeb', - slateblue: '#6a5acd', - slategray: '#708090', - slategrey: '#708090', - snow: '#fffafa', - springgreen: '#00ff7f', - steelblue: '#4682b4', - tan: '#d2b48c', - teal: '#008080', - thistle: '#d8bfd8', - tomato: '#ff6347', - turquoise: '#40e0d0', - violet: '#ee82ee', - wheat: '#f5deb3', - white: '#ffffff', - whitesmoke: '#f5f5f5', - yellow: '#ffff00', - yellowgreen: '#9acd32', -}; - -const EMPTY_MARKS: Marks = { - bold: false, - italic: false, - underline: false, - strike: false, -}; - -// Narrow a parsed node to an element with attributes and children. -function isElement(node: ChildNode): node is ElementNode { - return 'name' in node && 'attribs' in node && 'children' in node; -} - -// Narrow a parsed node to a text node without depending on parser internals. -function isTextNode(node: ChildNode): node is TextNode { - return 'data' in node && !('name' in node); -} - -function cloneMarks(marks: Marks, patch: Partial): Marks { - return { ...marks, ...patch }; -} - -function cloneStyle(style: Style): Style { - return { ...style }; -} - -// Identify explicit blocks and unknown wrappers that contain block descendants. -function isBlockElement(element: ElementNode): boolean { - if (BLOCK_TAGS.has(element.name)) return true; - return element.children.some( - (child) => isElement(child) && isBlockElement(child), - ); -} - -function isDroppedElement(element: ElementNode): boolean { - return DROPPED_TAGS.has(element.name); -} - -// Convert one clamped color channel to a two-digit lowercase hexadecimal value. -function normalizeHexChannel(channel: number): string { - return Math.max(0, Math.min(255, Math.round(channel))) - .toString(16) - .padStart(2, '0'); -} - -function rgbaToHex(color: Rgba, forceAlpha = false): string | null { - if (color.alpha <= 0) return null; - const red = normalizeHexChannel(color.red); - const green = normalizeHexChannel(color.green); - const blue = normalizeHexChannel(color.blue); - if (color.alpha >= 1 && !forceAlpha) return `#${red}${green}${blue}`; - return `#${red}${green}${blue}${normalizeHexChannel(color.alpha * 255)}`; -} - -// Parse CSS numeric or percentage channels into a bounded numeric range. -function parsePercentageOrNumber(value: string, scale: number): number | null { - const trimmed = value.trim(); - if (trimmed.endsWith('%')) { - const percentage = Number(trimmed.slice(0, -1)); - return Number.isFinite(percentage) - ? Math.max(0, Math.min(scale, (percentage / 100) * scale)) - : null; - } - const number = Number(trimmed); - return Number.isFinite(number) ? Math.max(0, Math.min(scale, number)) : null; -} - -function parseAlpha(value: string): number | null { - const trimmed = value.trim(); - if (trimmed.endsWith('%')) { - const percentage = Number(trimmed.slice(0, -1)); - return Number.isFinite(percentage) - ? Math.max(0, Math.min(1, percentage / 100)) - : null; - } - const number = Number(trimmed); - return Number.isFinite(number) ? Math.max(0, Math.min(1, number)) : null; -} - -// Split modern space-separated and legacy comma-separated CSS color arguments. -function splitFunctionalColorArguments(value: string): string[] | null { - const body = value.slice(value.indexOf('(') + 1, -1).trim(); - if (!body) return null; - if (body.includes(',')) return body.split(',').map((part) => part.trim()); - const slashIndex = body.indexOf('/'); - const channels = (slashIndex === -1 ? body : body.slice(0, slashIndex)) - .trim() - .split(/\s+/u); - if (slashIndex === -1) return channels; - return [...channels, body.slice(slashIndex + 1).trim()]; -} - -function parseRgbColor(value: string): Rgba | null { - const argumentsList = splitFunctionalColorArguments(value); - if ( - !argumentsList || - (argumentsList.length !== 3 && argumentsList.length !== 4) - ) - return null; - const red = parsePercentageOrNumber(argumentsList[0]!, 255); - const green = parsePercentageOrNumber(argumentsList[1]!, 255); - const blue = parsePercentageOrNumber(argumentsList[2]!, 255); - const alpha = argumentsList.length === 4 ? parseAlpha(argumentsList[3]!) : 1; - if (red === null || green === null || blue === null || alpha === null) - return null; - return { red, green, blue, alpha }; -} - -function parseHue(value: string): number | null { - const trimmed = value.trim().toLowerCase(); - const match = trimmed.match( - /^([+-]?(?:\d+(?:\.\d+)?|\.\d+))(deg|grad|rad|turn)?$/u, - ); - if (!match) return null; - const amount = Number(match[1]); - if (!Number.isFinite(amount)) return null; - const turns = - match[2] === 'grad' - ? amount / 400 - : match[2] === 'rad' - ? amount / (2 * Math.PI) - : match[2] === 'turn' - ? amount - : amount / 360; - return ((turns % 1) + 1) % 1; -} - -function hueToRgb(p: number, q: number, t: number): number { - let value = t; - if (value < 0) value += 1; - if (value > 1) value -= 1; - if (value < 1 / 6) return p + (q - p) * 6 * value; - if (value < 1 / 2) return q; - if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6; - return p; -} - -function parseHslColor(value: string): Rgba | null { - const argumentsList = splitFunctionalColorArguments(value); - if ( - !argumentsList || - (argumentsList.length !== 3 && argumentsList.length !== 4) - ) - return null; - const hue = parseHue(argumentsList[0]!); - const saturation = argumentsList[1]!.trim(); - const lightness = argumentsList[2]!.trim(); - if (hue === null || !saturation.endsWith('%') || !lightness.endsWith('%')) - return null; - const saturationNumber = Number(saturation.slice(0, -1)); - const lightnessNumber = Number(lightness.slice(0, -1)); - const alpha = argumentsList.length === 4 ? parseAlpha(argumentsList[3]!) : 1; - if ( - !Number.isFinite(saturationNumber) || - !Number.isFinite(lightnessNumber) || - alpha === null - ) - return null; - const s = Math.max(0, Math.min(100, saturationNumber)) / 100; - const l = Math.max(0, Math.min(100, lightnessNumber)) / 100; - if (s === 0) { - const channel = l * 255; - return { red: channel, green: channel, blue: channel, alpha }; - } - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - return { - red: hueToRgb(p, q, hue + 1 / 3) * 255, - green: hueToRgb(p, q, hue) * 255, - blue: hueToRgb(p, q, hue - 1 / 3) * 255, - alpha, - }; -} - -// Normalize supported CSS colors into FFM-compatible hexadecimal notation. -function parseColor(value: string): string | null { - const normalized = value.trim().toLowerCase(); - if (!normalized || normalized.includes('var(')) return null; - - const named = CSS_NAMED_COLORS[normalized]; - if (named) return named; - if (normalized === 'transparent') return null; - - const hexMatch = normalized.match(/^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/u); - if (hexMatch) { - const source = hexMatch[1]!; - const expanded = - source.length <= 4 - ? [...source].map((character) => `${character}${character}`).join('') - : source; - return rgbaToHex( - { - red: Number.parseInt(expanded.slice(0, 2), 16), - green: Number.parseInt(expanded.slice(2, 4), 16), - blue: Number.parseInt(expanded.slice(4, 6), 16), - alpha: - expanded.length === 8 - ? Number.parseInt(expanded.slice(6, 8), 16) / 255 - : 1, - }, - expanded.length === 8, - ); - } - - if (/^rgba?\(/u.test(normalized)) { - const color = parseRgbColor(normalized); - return color ? rgbaToHex(color, /^rgba\(/u.test(normalized)) : null; - } - if (/^hsla?\(/u.test(normalized)) { - const color = parseHslColor(normalized); - return color ? rgbaToHex(color, /^hsla\(/u.test(normalized)) : null; - } - return null; -} - -function parseFontSize(value: string): string | null { - const normalized = value.trim().replace(/\s*!important\s*$/iu, ''); - return CSS_LENGTH_PATTERN.test(normalized) ? normalized : null; -} - -function isTransparentColor(value: string): boolean { - const normalized = value.trim().toLowerCase(); - if (normalized === 'transparent') return true; - const hexMatch = normalized.match(/^#([\da-f]{4}|[\da-f]{8})$/u); - if (hexMatch) { - const source = hexMatch[1]!; - const alpha = - source.length === 4 ? `${source[3]}${source[3]}` : source.slice(6, 8); - return alpha === '00'; - } - const color = /^rgba?\(/u.test(normalized) - ? parseRgbColor(normalized) - : /^hsla?\(/u.test(normalized) - ? parseHslColor(normalized) - : null; - return color !== null && color.alpha <= 0; -} - -// Parse supported inline declarations while preserving CSS last-valid semantics. -function parseInlineStyle(value: string | undefined): Style { - if (!value) return {}; - const style: Style = {}; - for (const declaration of value.split(';')) { - const colonIndex = declaration.indexOf(':'); - if (colonIndex === -1) continue; - const property = declaration.slice(0, colonIndex).trim().toLowerCase(); - const propertyValue = declaration - .slice(colonIndex + 1) - .trim() - .replace(/\s*!important\s*$/iu, ''); - if (property === 'color') { - const color = parseColor(propertyValue); - if (color !== null) style.color = color; - else if (isTransparentColor(propertyValue)) style.color = null; - } else if (property === 'font-size') { - const fontSize = parseFontSize(propertyValue); - if (fontSize !== null) style.fontSize = fontSize; - } - } - return style; -} - -function mergeStyle(parent: Style, own: Style): Style { - return { ...parent, ...own }; -} - -function styleKey(style: Style): string { - return `${style.color ?? ''}|${style.fontSize ?? ''}`; -} - -function marksKey(marks: Marks): string { - return `${marks.bold ? '1' : '0'}${marks.italic ? '1' : '0'}${marks.underline ? '1' : '0'}${marks.strike ? '1' : '0'}|${marks.link ?? ''}`; -} - -function samePieceFormatting(left: InlinePiece, right: InlinePiece): boolean { - return ( - styleKey(left.style) === styleKey(right.style) && - marksKey(left.marks) === marksKey(right.marks) && - !left.content.includes('\n') && - !right.content.includes('\n') - ); -} - -function applyTextMarks(content: string, marks: Marks): string { - let result = content; - if (marks.bold && marks.italic) result = `***${result}***`; - else if (marks.bold) result = `**${result}**`; - else if (marks.italic) result = `*${result}*`; - if (marks.strike) result = `--${result}--`; - if (marks.underline) result = `__${result}__`; - return result; -} - -function formatStyle(style: Style): string { - const attributes: string[] = []; - if (style.color) attributes.push(`color = ${style.color}`); - if (style.fontSize) attributes.push(`font = {size = ${style.fontSize}}`); - return attributes.length > 0 ? `(${attributes.join(', ')})` : ''; -} - -// Merge adjacent compatible pieces and wrap marks before applying overload styles. -function serializePieces(pieces: readonly InlinePiece[]): string { - const merged: InlinePiece[] = []; - for (const piece of pieces) { - if (!piece.content) continue; - const previous = merged.at(-1); - if (previous && samePieceFormatting(previous, piece)) { - previous.content += piece.content; - } else { - merged.push({ - content: piece.content, - style: cloneStyle(piece.style), - marks: { ...piece.marks }, - }); - } - } - - let result = ''; - for (let index = 0; index < merged.length; ) { - const first = merged[index]!; - const group = [first]; - index++; - while (index < merged.length) { - const next = merged[index]!; - if ( - styleKey(next.style) !== styleKey(first.style) || - next.marks.link !== first.marks.link || - next.content.includes('\n') || - first.content.includes('\n') - ) - break; - group.push(next); - index++; - } - const content = group - .map((piece) => applyTextMarks(piece.content, piece.marks)) - .join(''); - const marked = first.marks.link - ? `[${content}](${first.marks.link})` - : content; - const style = formatStyle(first.style); - result += style ? `[${marked}]${style}` : marked; - } - return result; -} - -// Extract descendant text for inline assets while ignoring dropped elements. -function getTextContent(nodes: readonly ChildNode[]): string { - let result = ''; - for (const node of nodes) { - if (isTextNode(node)) result += node.data; - else if (isElement(node) && !isDroppedElement(node)) - result += getTextContent(node.children); - } - return result; -} - -// Extract preformatted text without interpreting inline markup or styles. -function getPreTextContent(nodes: readonly ChildNode[]): string { - let result = ''; - for (const node of nodes) { - if (isTextNode(node)) result += node.data; - else if (isElement(node) && !isDroppedElement(node)) - result += node.name === 'br' ? '\n' : getPreTextContent(node.children); - } - return result; -} - -function longestBacktickRun(content: string): number { - let longest = 0; - for (const match of content.matchAll(/`+/gu)) { - longest = Math.max(longest, match[0].length); - } - return longest; -} - -// Render one inline node with inherited style and formatting state. -function renderInlineNode( - node: ChildNode, - style: Style, - marks: Marks, -): InlinePiece[] { - if (isTextNode(node)) { - if (!node.data) return []; - return [ - { content: node.data, style: cloneStyle(style), marks: { ...marks } }, - ]; - } - if (!isElement(node) || isDroppedElement(node)) return []; - - const ownStyle = parseInlineStyle(node.attribs.style); - const nextStyle = mergeStyle(style, ownStyle); - const name = node.name; - if (name === 'br') { - return [ - { content: '\n', style: cloneStyle(nextStyle), marks: { ...marks } }, - ]; - } - if (name === 'img') { - const source = node.attribs.src; - if (!source) return []; - const alt = node.attribs.alt ?? ''; - return [ - { - content: `![${alt}](${source})`, - style: cloneStyle(nextStyle), - marks: { ...marks }, - }, - ]; - } - if (name === 'code') { - const code = getTextContent(node.children); - if (!code) return []; - const fence = '`'.repeat(Math.max(1, longestBacktickRun(code) + 1)); - return [ - { - content: `${fence}${code}${fence}`, - style: cloneStyle(nextStyle), - marks: { - ...marks, - bold: false, - italic: false, - underline: false, - strike: false, - }, - }, - ]; - } - - let nextMarks = marks; - if (name === 'strong' || name === 'b') - nextMarks = cloneMarks(nextMarks, { bold: true }); - else if (name === 'em' || name === 'i') - nextMarks = cloneMarks(nextMarks, { italic: true }); - else if (name === 'u' || name === 'ins') - nextMarks = cloneMarks(nextMarks, { underline: true }); - else if (name === 's' || name === 'del' || name === 'strike') - nextMarks = cloneMarks(nextMarks, { strike: true }); - - if (name === 'a') { - const href = node.attribs.href; - const normalizedHref = href?.trim(); - const hasScheme = normalizedHref - ? /^[a-z][a-z\d+.-]*:/iu.test(normalizedHref) - : false; - const isSafeUrl = - normalizedHref !== undefined && - normalizedHref !== '' && - !DANGEROUS_URL_PATTERN.test(normalizedHref) && - (!hasScheme || SAFE_SCHEME_PATTERN.test(normalizedHref)); - if (isSafeUrl) nextMarks = cloneMarks(nextMarks, { link: href }); - } - - const pieces: InlinePiece[] = []; - for (const child of node.children) { - pieces.push(...renderInlineNode(child, nextStyle, nextMarks)); - } - return pieces; -} - -function stripBoundaryNewlines(content: string): string { - return content.replace(/^\n+/u, '').replace(/\n+$/u, ''); -} - -function renderInlineContent( - nodes: readonly ChildNode[], - style: Style, - marks: Marks, -): string { - const pieces: InlinePiece[] = []; - for (const node of nodes) { - if (isTextNode(node)) { - if (/^\s+$/u.test(node.data) && node.data.includes('\n')) continue; - pieces.push(...renderInlineNode(node, style, marks)); - } else if (isElement(node) && !isBlockElement(node)) { - pieces.push(...renderInlineNode(node, style, marks)); - } - } - return serializePieces(pieces); -} - -// Render mixed inline and block children while preserving document order. -function renderFlow(nodes: readonly ChildNode[], style: Style): string { - let output = ''; - let inlinePieces: InlinePiece[] = []; - const flushInline = () => { - if (inlinePieces.length === 0) return; - output += serializePieces(inlinePieces); - inlinePieces = []; - }; - - for (const node of nodes) { - if (isTextNode(node)) { - if (/^\s+$/u.test(node.data) && node.data.includes('\n')) continue; - inlinePieces.push(...renderInlineNode(node, style, EMPTY_MARKS)); - continue; - } - if (!isElement(node) || isDroppedElement(node)) continue; - if (isBlockElement(node)) { - flushInline(); - output += renderBlockElement(node, style); - } else { - inlinePieces.push(...renderInlineNode(node, style, EMPTY_MARKS)); - } - } - flushInline(); - return output; -} - -// Render ordered and unordered lists with canonical two-space nesting. -function renderList(element: ElementNode, style: Style, depth: number): string { - const ordered = element.name === 'ol'; - const parsedStart = Number.parseInt(element.attribs.start ?? '', 10); - let number = Number.isInteger(parsedStart) ? parsedStart : 1; - const lines: string[] = []; - for (const child of element.children) { - if (!isElement(child) || child.name !== 'li') continue; - const inlineChildren: ChildNode[] = []; - const nestedLists: ElementNode[] = []; - for (const itemChild of child.children) { - if ( - isElement(itemChild) && - (itemChild.name === 'ul' || itemChild.name === 'ol') - ) - nestedLists.push(itemChild); - else inlineChildren.push(itemChild); - } - const itemContent = renderFlow(inlineChildren, style) - .replace(/\n+/gu, ' ') - .trim(); - const marker = ordered ? `${number}.` : '-'; - number++; - lines.push( - `${' '.repeat(depth)}${marker}${itemContent ? ` ${itemContent}` : ''}`, - ); - for (const nestedList of nestedLists) { - const nested = renderList(nestedList, style, depth + 1).replace( - /\n+$/u, - '', - ); - if (nested) lines.push(nested); - } - } - return lines.length > 0 ? `${lines.join('\n')}\n\n` : ''; -} - -type TableRow = { - cells: ElementNode[]; - isHeader: boolean; -}; - -// Collect table rows and mark header rows from thead or th cells. -function getTableRows(element: ElementNode): TableRow[] { - const rows: TableRow[] = []; - const visit = (node: ElementNode, insideHead: boolean) => { - if (node.name === 'table' && node !== element) return; - const nextInsideHead = insideHead || node.name === 'thead'; - if (node.name === 'tr') { - rows.push({ - cells: node.children.filter( - (child): child is ElementNode => - isElement(child) && (child.name === 'th' || child.name === 'td'), - ), - isHeader: - nextInsideHead || - node.children.some( - (child) => isElement(child) && child.name === 'th', - ), - }); - return; - } - for (const child of node.children) { - if (isElement(child)) visit(child, nextInsideHead); - } - }; - visit(element, false); - return rows; -} - -// Render header and no-header tables using canonical FFM table rows. -function renderTable(element: ElementNode, style: Style): string { - const rows = getTableRows(element); - if (rows.length === 0) return ''; - const headerIndex = rows.findIndex((row) => row.isHeader); - const hasHeader = headerIndex !== -1; - const header = hasHeader ? rows[headerIndex]!.cells : null; - const dataRows = hasHeader - ? rows.filter((_row, index) => index !== headerIndex) - : rows; - const columnCount = Math.max(1, ...rows.map((row) => row.cells.length)); - const renderRow = (row: readonly ElementNode[]): string => { - const cells = Array.from({ length: columnCount }, (_value, index) => { - const cell = row[index]; - if (!cell) return ''; - return renderFlow(cell.children, style).replace(/\s+/gu, ' ').trim(); - }); - return `| ${cells.join(' | ')} |`; - }; - const lines: string[] = []; - if (header) lines.push(renderRow(header)); - lines.push( - `| ${Array.from({ length: columnCount }, () => '---').join(' | ')} |`, - ); - lines.push(...dataRows.map((row) => renderRow(row.cells))); - return `${lines.join('\n')}\n\n`; -} - -// Render preformatted content with a fence longer than any embedded backtick run. -function renderPre(element: ElementNode): string { - const content = getPreTextContent(element.children); - if (!content) return ''; - const fence = '`'.repeat(Math.max(3, longestBacktickRun(content) + 1)); - const body = content.endsWith('\n') ? content : `${content}\n`; - return `${fence}\n${body}${fence}\n\n`; -} - -// Render a block element and append the required paragraph boundary. -function renderBlockElement(element: ElementNode, style: Style): string { - if (isDroppedElement(element)) return ''; - const nextStyle = mergeStyle(style, parseInlineStyle(element.attribs.style)); - if (element.name === 'hr') return '\n\n---\n\n'; - if (element.name === 'pre') return renderPre(element); - if (element.name === 'ul' || element.name === 'ol') - return renderList(element, nextStyle, 0); - if (element.name === 'table') return renderTable(element, nextStyle); - if (element.name === 'blockquote') { - const content = stripBoundaryNewlines( - renderFlow(element.children, nextStyle), - ) - .replace(/\n{2,}/gu, '\n') - .trim(); - if (!content) return ''; - const quoted = content - .split('\n') - .map((line) => (line ? `> ${line}` : '>')) - .join('\n'); - return `${quoted}\n\n`; - } - - const headingMatch = element.name.match(HEADING_PATTERN); - if (headingMatch) { - const content = renderInlineContent( - element.children, - nextStyle, - EMPTY_MARKS, - ).trim(); - return content - ? `${'#'.repeat(Number(headingMatch[1]))} ${content}\n\n` - : ''; - } - - const content = stripBoundaryNewlines( - renderFlow(element.children, nextStyle), - ).trim(); - return content ? `${content}\n\n` : ''; -} +import { parseDocument } from 'htmlparser2'; +import { renderFlow } from './render'; +import type { ParsedDocument } from './types'; + +export * from './color'; +export * from './constants'; +export * from './render'; +export * from './style'; +export * from './types'; /** Convert an HTML fragment into formatted Fuyeor Flavored Markdown. */ export function toFFM(input: string): string { diff --git a/packages/html2ffm/src/render.ts b/packages/html2ffm/src/render.ts new file mode 100644 index 0000000..cb6fbff --- /dev/null +++ b/packages/html2ffm/src/render.ts @@ -0,0 +1,415 @@ +// @fuyeor/html2ffm/src/render.ts +import { + BLOCK_TAGS, + DANGEROUS_URL_PATTERN, + DROPPED_TAGS, + EMPTY_MARKS, + HEADING_PATTERN, + SAFE_SCHEME_PATTERN, +} from './constants'; +import { + cloneMarks, + cloneStyle, + formatStyle, + marksKey, + mergeStyle, + parseInlineStyle, + styleKey, +} from './style'; +import type { + ChildNode, + ElementNode, + InlinePiece, + Marks, + Style, + TableRow, + TextNode, +} from './types'; + +export function isElement(node: ChildNode): node is ElementNode { + return 'name' in node && 'attribs' in node && 'children' in node; +} + +export function isTextNode(node: ChildNode): node is TextNode { + return 'data' in node && !('name' in node); +} + +export function isDroppedElement(element: ElementNode): boolean { + return DROPPED_TAGS.has(element.name); +} + +export function isBlockElement(element: ElementNode): boolean { + if (BLOCK_TAGS.has(element.name)) return true; + return element.children.some( + (child) => isElement(child) && isBlockElement(child), + ); +} + +function getTextContent(nodes: readonly ChildNode[]): string { + let result = ''; + for (const node of nodes) { + if (isTextNode(node)) result += node.data; + else if (isElement(node) && !isDroppedElement(node)) + result += getTextContent(node.children); + } + return result; +} + +function getPreTextContent(nodes: readonly ChildNode[]): string { + let result = ''; + for (const node of nodes) { + if (isTextNode(node)) result += node.data; + else if (isElement(node) && !isDroppedElement(node)) + result += node.name === 'br' ? '\n' : getPreTextContent(node.children); + } + return result; +} + +function longestBacktickRun(content: string): number { + let longest = 0; + for (const match of content.matchAll(/`+/gu)) { + longest = Math.max(longest, match[0].length); + } + return longest; +} + +function stripBoundaryNewlines(content: string): string { + return content.replace(/^\n+/u, '').replace(/\n+$/u, ''); +} + +function applyTextMarks(content: string, marks: Marks): string { + let result = content; + if (marks.bold && marks.italic) result = `***${result}***`; + else if (marks.bold) result = `**${result}**`; + else if (marks.italic) result = `*${result}*`; + if (marks.strike) result = `--${result}--`; + if (marks.underline) result = `__${result}__`; + return result; +} + +function samePieceFormatting(left: InlinePiece, right: InlinePiece): boolean { + return ( + styleKey(left.style) === styleKey(right.style) && + marksKey(left.marks) === marksKey(right.marks) && + !left.content.includes('\n') && + !right.content.includes('\n') + ); +} + +export function serializePieces(pieces: readonly InlinePiece[]): string { + const merged: InlinePiece[] = []; + for (const piece of pieces) { + if (!piece.content) continue; + const previous = merged.at(-1); + if (previous && samePieceFormatting(previous, piece)) { + previous.content += piece.content; + } else { + merged.push({ + content: piece.content, + style: cloneStyle(piece.style), + marks: { ...piece.marks }, + }); + } + } + + let result = ''; + for (let index = 0; index < merged.length; ) { + const first = merged[index]!; + const group = [first]; + index++; + while (index < merged.length) { + const next = merged[index]!; + if ( + styleKey(next.style) !== styleKey(first.style) || + next.marks.link !== first.marks.link || + next.content.includes('\n') || + first.content.includes('\n') + ) + break; + group.push(next); + index++; + } + const content = group + .map((piece) => applyTextMarks(piece.content, piece.marks)) + .join(''); + const marked = first.marks.link + ? `[${content}](${first.marks.link})` + : content; + const style = formatStyle(first.style); + result += style ? `[${marked}]${style}` : marked; + } + return result; +} + +export function renderInlineNode( + node: ChildNode, + style: Style, + marks: Marks, +): InlinePiece[] { + if (isTextNode(node)) { + if (!node.data) return []; + return [ + { content: node.data, style: cloneStyle(style), marks: { ...marks } }, + ]; + } + if (!isElement(node) || isDroppedElement(node)) return []; + + const ownStyle = parseInlineStyle(node.attribs.style); + const nextStyle = mergeStyle(style, ownStyle); + const name = node.name; + if (name === 'br') { + return [ + { content: '\n', style: cloneStyle(nextStyle), marks: { ...marks } }, + ]; + } + if (name === 'img') { + const source = node.attribs.src; + if (!source) return []; + const alt = node.attribs.alt ?? ''; + return [ + { + content: `![${alt}](${source})`, + style: cloneStyle(nextStyle), + marks: { ...marks }, + }, + ]; + } + if (name === 'code') { + const code = getTextContent(node.children); + if (!code) return []; + const fence = '`'.repeat(Math.max(1, longestBacktickRun(code) + 1)); + return [ + { + content: `${fence}${code}${fence}`, + style: cloneStyle(nextStyle), + marks: { + ...marks, + bold: false, + italic: false, + underline: false, + strike: false, + }, + }, + ]; + } + + let nextMarks = marks; + if (name === 'strong' || name === 'b') + nextMarks = cloneMarks(nextMarks, { bold: true }); + else if (name === 'em' || name === 'i') + nextMarks = cloneMarks(nextMarks, { italic: true }); + else if (name === 'u' || name === 'ins') + nextMarks = cloneMarks(nextMarks, { underline: true }); + else if (name === 's' || name === 'del' || name === 'strike') + nextMarks = cloneMarks(nextMarks, { strike: true }); + + if (name === 'a') { + const href = node.attribs.href; + const normalizedHref = href?.trim(); + const hasScheme = normalizedHref + ? /^[a-z][a-z\d+.-]*:/iu.test(normalizedHref) + : false; + const isSafeUrl = + normalizedHref !== undefined && + normalizedHref !== '' && + !DANGEROUS_URL_PATTERN.test(normalizedHref) && + (!hasScheme || SAFE_SCHEME_PATTERN.test(normalizedHref)); + if (isSafeUrl) nextMarks = cloneMarks(nextMarks, { link: href }); + } + + const pieces: InlinePiece[] = []; + for (const child of node.children) { + pieces.push(...renderInlineNode(child, nextStyle, nextMarks)); + } + return pieces; +} + +export function renderInlineContent( + nodes: readonly ChildNode[], + style: Style, + marks: Marks, +): string { + const pieces: InlinePiece[] = []; + for (const node of nodes) { + if (isTextNode(node)) { + if (/^\s+$/u.test(node.data) && node.data.includes('\n')) continue; + pieces.push(...renderInlineNode(node, style, marks)); + } else if (isElement(node) && !isBlockElement(node)) { + pieces.push(...renderInlineNode(node, style, marks)); + } + } + return serializePieces(pieces); +} + +export function renderList( + element: ElementNode, + style: Style, + depth: number, +): string { + const ordered = element.name === 'ol'; + const parsedStart = Number.parseInt(element.attribs.start ?? '', 10); + let number = Number.isInteger(parsedStart) ? parsedStart : 1; + const lines: string[] = []; + for (const child of element.children) { + if (!isElement(child) || child.name !== 'li') continue; + const inlineChildren: ChildNode[] = []; + const nestedLists: ElementNode[] = []; + for (const itemChild of child.children) { + if ( + isElement(itemChild) && + (itemChild.name === 'ul' || itemChild.name === 'ol') + ) + nestedLists.push(itemChild); + else inlineChildren.push(itemChild); + } + const itemContent = renderFlow(inlineChildren, style) + .replace(/\n+/gu, ' ') + .trim(); + const marker = ordered ? `${number}.` : '-'; + number++; + lines.push( + `${' '.repeat(depth)}${marker}${itemContent ? ` ${itemContent}` : ''}`, + ); + for (const nestedList of nestedLists) { + const nested = renderList(nestedList, style, depth + 1).replace( + /\n+$/u, + '', + ); + if (nested) lines.push(nested); + } + } + return lines.length > 0 ? `${lines.join('\n')}\n\n` : ''; +} + +function getTableRows(element: ElementNode): TableRow[] { + const rows: TableRow[] = []; + const visit = (node: ElementNode, insideHead: boolean) => { + if (node.name === 'table' && node !== element) return; + const nextInsideHead = insideHead || node.name === 'thead'; + if (node.name === 'tr') { + rows.push({ + cells: node.children.filter( + (child): child is ElementNode => + isElement(child) && (child.name === 'th' || child.name === 'td'), + ), + isHeader: + nextInsideHead || + node.children.some( + (child) => isElement(child) && child.name === 'th', + ), + }); + return; + } + for (const child of node.children) { + if (isElement(child)) visit(child, nextInsideHead); + } + }; + visit(element, false); + return rows; +} + +export function renderTable(element: ElementNode, style: Style): string { + const rows = getTableRows(element); + if (rows.length === 0) return ''; + const headerIndex = rows.findIndex((row) => row.isHeader); + const hasHeader = headerIndex !== -1; + const header = hasHeader ? rows[headerIndex]!.cells : null; + const dataRows = hasHeader + ? rows.filter((_row, index) => index !== headerIndex) + : rows; + const columnCount = Math.max(1, ...rows.map((row) => row.cells.length)); + const renderRow = (row: readonly ElementNode[]): string => { + const cells = Array.from({ length: columnCount }, (_value, index) => { + const cell = row[index]; + if (!cell) return ''; + return renderFlow(cell.children, style).replace(/\s+/gu, ' ').trim(); + }); + return `| ${cells.join(' | ')} |`; + }; + const lines: string[] = []; + if (header) lines.push(renderRow(header)); + lines.push( + `| ${Array.from({ length: columnCount }, () => '---').join(' | ')} |`, + ); + lines.push(...dataRows.map((row) => renderRow(row.cells))); + return `${lines.join('\n')}\n\n`; +} + +export function renderPre(element: ElementNode): string { + const content = getPreTextContent(element.children); + if (!content) return ''; + const fence = '`'.repeat(Math.max(3, longestBacktickRun(content) + 1)); + const body = content.endsWith('\n') ? content : `${content}\n`; + return `${fence}\n${body}${fence}\n\n`; +} + +export function renderBlockElement(element: ElementNode, style: Style): string { + if (isDroppedElement(element)) return ''; + const nextStyle = mergeStyle(style, parseInlineStyle(element.attribs.style)); + if (element.name === 'hr') return '\n\n---\n\n'; + if (element.name === 'pre') return renderPre(element); + if (element.name === 'ul' || element.name === 'ol') + return renderList(element, nextStyle, 0); + if (element.name === 'table') return renderTable(element, nextStyle); + if (element.name === 'blockquote') { + const content = stripBoundaryNewlines( + renderFlow(element.children, nextStyle), + ) + .replace(/\n{2,}/gu, '\n') + .trim(); + if (!content) return ''; + const newlineCount = (content.match(/\n/gu) ?? []).length; + if (newlineCount >= 2) { + return `\`\`\`quote\n${content}\n\`\`\`\n\n`; + } + const quoted = content + .split('\n') + .map((line) => (line ? `> ${line}` : '>')) + .join('\n'); + return `${quoted}\n\n`; + } + + const headingMatch = element.name.match(HEADING_PATTERN); + if (headingMatch) { + const content = renderInlineContent( + element.children, + nextStyle, + EMPTY_MARKS, + ).trim(); + return content + ? `${'#'.repeat(Number(headingMatch[1]))} ${content}\n\n` + : ''; + } + + const content = stripBoundaryNewlines( + renderFlow(element.children, nextStyle), + ).trim(); + return content ? `${content}\n\n` : ''; +} + +export function renderFlow(nodes: readonly ChildNode[], style: Style): string { + let output = ''; + let inlinePieces: InlinePiece[] = []; + const flushInline = () => { + if (inlinePieces.length === 0) return; + output += serializePieces(inlinePieces); + inlinePieces = []; + }; + + for (const node of nodes) { + if (isTextNode(node)) { + if (/^\s+$/u.test(node.data) && node.data.includes('\n')) continue; + inlinePieces.push(...renderInlineNode(node, style, EMPTY_MARKS)); + continue; + } + if (!isElement(node) || isDroppedElement(node)) continue; + if (isBlockElement(node)) { + flushInline(); + output += renderBlockElement(node, style); + } else { + inlinePieces.push(...renderInlineNode(node, style, EMPTY_MARKS)); + } + } + flushInline(); + return output; +} diff --git a/packages/html2ffm/src/style.ts b/packages/html2ffm/src/style.ts new file mode 100644 index 0000000..bb88d8d --- /dev/null +++ b/packages/html2ffm/src/style.ts @@ -0,0 +1,60 @@ +// @fuyeor/html2ffm/src/style.ts +import { isTransparentColor, parseColor } from './color'; +import { CSS_LENGTH_PATTERN } from './constants'; +import type { Marks, Style } from './types'; + +export function parseFontSize(value: string): string | null { + const normalized = value.trim().replace(/\s*!important\s*$/iu, ''); + return CSS_LENGTH_PATTERN.test(normalized) ? normalized : null; +} + +// Parse supported inline declarations while preserving CSS last-valid semantics. +export function parseInlineStyle(value: string | undefined): Style { + if (!value) return {}; + const style: Style = {}; + for (const declaration of value.split(';')) { + const colonIndex = declaration.indexOf(':'); + if (colonIndex === -1) continue; + const property = declaration.slice(0, colonIndex).trim().toLowerCase(); + const propertyValue = declaration + .slice(colonIndex + 1) + .trim() + .replace(/\s*!important\s*$/iu, ''); + if (property === 'color') { + const color = parseColor(propertyValue); + if (color !== null) style.color = color; + else if (isTransparentColor(propertyValue)) style.color = null; + } else if (property === 'font-size') { + const fontSize = parseFontSize(propertyValue); + if (fontSize !== null) style.fontSize = fontSize; + } + } + return style; +} + +export function mergeStyle(parent: Style, own: Style): Style { + return { ...parent, ...own }; +} + +export function cloneStyle(style: Style): Style { + return { ...style }; +} + +export function cloneMarks(marks: Marks, patch: Partial): Marks { + return { ...marks, ...patch }; +} + +export function styleKey(style: Style): string { + return `${style.color ?? ''}|${style.fontSize ?? ''}`; +} + +export function marksKey(marks: Marks): string { + return `${marks.bold ? '1' : '0'}${marks.italic ? '1' : '0'}${marks.underline ? '1' : '0'}${marks.strike ? '1' : '0'}|${marks.link ?? ''}`; +} + +export function formatStyle(style: Style): string { + const attributes: string[] = []; + if (style.color) attributes.push(`color = ${style.color}`); + if (style.fontSize) attributes.push(`font = {size = ${style.fontSize}}`); + return attributes.length > 0 ? `(${attributes.join(', ')})` : ''; +} diff --git a/packages/html2ffm/src/types.ts b/packages/html2ffm/src/types.ts new file mode 100644 index 0000000..ed0268c --- /dev/null +++ b/packages/html2ffm/src/types.ts @@ -0,0 +1,46 @@ +// @fuyeor/html2ffm/src/types.ts +import type { parseDocument } from 'htmlparser2'; + +export type ParsedDocument = ReturnType; +export type ChildNode = ParsedDocument['children'][number]; + +export type ElementNode = ChildNode & { + name: string; + attribs: Record; + children: ChildNode[]; +}; + +export type TextNode = ChildNode & { + data: string; +}; + +export type Style = { + color?: string | null; + fontSize?: string; +}; + +export type Marks = { + bold: boolean; + italic: boolean; + underline: boolean; + strike: boolean; + link?: string; +}; + +export type InlinePiece = { + content: string; + style: Style; + marks: Marks; +}; + +export type Rgba = { + red: number; + green: number; + blue: number; + alpha: number; +}; + +export type TableRow = { + cells: ElementNode[]; + isHeader: boolean; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70ccc6f..785a799 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,25 +159,6 @@ importers: specifier: ^8.0.1 version: 8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0) - packages/vscode-extension: - dependencies: - '@fuyeor/markdown-formatter': - specifier: workspace:* - version: link:../markdown-formatter - devDependencies: - '@types/vscode': - specifier: ^1.80.0 - version: 1.134.0 - esbuild: - specifier: ^0.27.7 - version: 0.27.7 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - vitest: - specifier: ^4.1.0 - version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)) - packages: '@asamuzakjp/css-color@5.1.11': @@ -431,7 +412,7 @@ packages: optional: true '@fuyeor/locale@https://codeload.github.com/Fuyeor/webroamer/tar.gz/c502f739f4b633ab24386fe9f90fb77ed9f6ca9d#path:packages/locale': - resolution: {gitHosted: true, path: packages/locale, tarball: https://codeload.github.com/Fuyeor/webroamer/tar.gz/c502f739f4b633ab24386fe9f90fb77ed9f6ca9d} + resolution: {gitHosted: true, integrity: sha512-kQFZHVjbk2I1Li68UEKcPpzyK88tQ6t+Ju61JFvXVqbO4Q8qaXQvjzxanWyQT+aX3euW+Ck0aptTQ0k6kYN+Lg==, path: packages/locale, tarball: https://codeload.github.com/Fuyeor/webroamer/tar.gz/c502f739f4b633ab24386fe9f90fb77ed9f6ca9d} version: 1.0.0 peerDependencies: '@lit-labs/signals': '*' @@ -585,9 +566,6 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/vscode@1.134.0': - resolution: {integrity: sha512-NDEu0hg4sF7+vvFsADsktqUJ6f80LHSZvVK2Ovo1XiQ0/VHck1O3zst+ZZyVA/uvz6vo6LcuoqU2q48YMqOwWw==} - '@vitest/coverage-v8@4.1.4': resolution: {integrity: sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==} peerDependencies: @@ -1445,8 +1423,6 @@ snapshots: '@types/trusted-types@2.0.7': {} - '@types/vscode@1.134.0': {} - '@vitest/coverage-v8@4.1.4(vitest@4.1.4)': dependencies: '@bcoe/v8-coverage': 1.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 72b69d9..871f01b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ # /pnpm-workspace.yaml packages: - 'packages/*' +allowBuilds: + esbuild: true From c7b9c1ac1e8f627d1402a8792b402ae5bab0c831 Mon Sep 17 00:00:00 2001 From: Fuyeor Date: Sun, 30 Aug 2026 19:00:52 +0000 Subject: [PATCH 2/5] feat: Add escape, underline conversion --- .../html2ffm/src/fixtures/conversions.json | 89 ++++++++++++++----- packages/html2ffm/src/render.ts | 44 +++++---- packages/html2ffm/src/style.ts | 36 +++++++- packages/html2ffm/src/types.ts | 1 + packages/html2ffm/test.ts | 17 ++++ packages/html2ffm/tsconfig.json | 2 +- 6 files changed, 147 insertions(+), 42 deletions(-) create mode 100644 packages/html2ffm/test.ts diff --git a/packages/html2ffm/src/fixtures/conversions.json b/packages/html2ffm/src/fixtures/conversions.json index 0e5508b..26ed303 100644 --- a/packages/html2ffm/src/fixtures/conversions.json +++ b/packages/html2ffm/src/fixtures/conversions.json @@ -9,28 +9,33 @@ "desc": "inline formatting", "text": "Bold Strong Italic Emphasis Underline Insert Delete code", "expect": "**Bold** **Strong** *Italic* *Emphasis* __Underline__ __Insert__ --Delete-- `code`" - }, - { - "desc": "alpha colors", - "text": "Half Clear", - "expect": "[Half](color = #ff000080) Clear" } ], "blockquote": [ + { + "desc": "text within blockquote", + "text": "

Text

Text

", + "expect": "> **Text**\n>\n> Text" + }, + { + "desc": "bold text before blockquote", + "text": "

Text

Text

Text

", + "expect": "**Text**\n\n> **Text**\n>\n> Text" + }, { "desc": "short blockquote (under 3 lines)", "text": "

One

Two

", - "expect": "> One\n> Two" + "expect": "> One\n>\n> Two" }, { "desc": "long blockquote (3 lines or more)", "text": "

One

Two

Three

", - "expect": "```quote\nOne\nTwo\nThree\n```" + "expect": "```quote\nOne\n\nTwo\n\nThree\n```" }, { - "desc": "blockquote", - "text": "

One

Two

Three

", - "expect": "```quote\nOne\nTwo\nThree\n```" + "desc": "`

` blockquote", + "text": "

Line1

Line2

Line3

", + "expect": "```quote\nLine1\n\nLine2\n\n\n\nLine3\n```" } ], "paragraphs and headings": [ @@ -49,15 +54,35 @@ ], "color and styles": [ { - "desc": "color normalization - rgb with alpha", + "desc": "alpha color", + "text": "Half", + "expect": "[Half](color = #ff000080)" + }, + { + "desc": "rgb with alpha", "text": "RGB", "expect": "[RGB](color = #ff000080)" }, { - "desc": "color", + "desc": "transparent color", + "text": "Text", + "expect": "Text" + }, + { + "desc": "named color", "text": "Red", "expect": "[Red](color = #ff0000)" }, + { + "desc": "background", + "text": "Text", + "expect": "[Text](background = #ffff99)" + }, + { + "desc": "underline", + "text": "Text", + "expect": "__Text__" + }, { "desc": "font-size", "text": "Large", @@ -68,11 +93,6 @@ "text": "Both", "expect": "[Both](color = #aabbcc, font = {size = 20px})" }, - { - "desc": "background", - "text": "Text", - "expect": "[Text](background = #ffff99)" - }, { "desc": "nested style and underline", "text": "Text", @@ -87,6 +107,11 @@ } ], "links and images": [ + { + "desc": "multiple lines links", + "text": "

Link1

Link2

", + "expect": "[Link1](https://fuyeor.com)\n\n[Link2](https://fuyeor.com)" + }, { "desc": "links and images", "text": "Docs Unsafe \"Cat ", @@ -119,18 +144,38 @@ "expect": "````\nconst value = ```text```;\n````" } ], - "missing image source": [ + "remove unnecessary elements": [ + { + "desc": "remove div", + "text": "
Text
", + "expect": "Text" + }, { "desc": "missing image source", "text": "\"No

After

", "expect": "After" } ], - "remove unnecessary elements": [ + "escape markdown special symbols": [ { - "desc": "div", - "text": "
Text
", - "expect": "Text" + "desc": "escape first `*`", + "text": "
  • * Text
", + "expect": "- \\* Text" + }, + { + "desc": "escape inline asterisks and underscores in paragraph", + "text": "

hello_world and *italic* should be escaped

", + "expect": "hello\\_world and \\*italic\\* should be escaped" + }, + { + "desc": "escape leading block symbols to avoid syntax ambiguity", + "text": "

# Not heading

> Not quote

- Not list

", + "expect": "\\# Not heading\n\n\\> Not quote\n\n\\- Not list" + }, + { + "desc": "do not escape inside pre or code blocks", + "text": "
# Heading\n* List\nfoo_bar
", + "expect": "```\n# Heading\n* List\nfoo_bar\n```" } ] } diff --git a/packages/html2ffm/src/render.ts b/packages/html2ffm/src/render.ts index cb6fbff..45dbc28 100644 --- a/packages/html2ffm/src/render.ts +++ b/packages/html2ffm/src/render.ts @@ -13,7 +13,7 @@ import { formatStyle, marksKey, mergeStyle, - parseInlineStyle, + parseStyleAttribute, styleKey, } from './style'; import type { @@ -45,6 +45,13 @@ export function isBlockElement(element: ElementNode): boolean { ); } +export function escapeMarkdownText(text: string): string { + if (!text) return ''; + return text + .replace(/[*_]/gu, '\\$&') + .replace(/^(#{1,6}\s+|>\s*|[-+*]\s+|\d+\.\s+)/gmu, '\\$1'); +} + function getTextContent(nodes: readonly ChildNode[]): string { let result = ''; for (const node of nodes) { @@ -148,18 +155,24 @@ export function renderInlineNode( ): InlinePiece[] { if (isTextNode(node)) { if (!node.data) return []; + // escape text node + const escaped = escapeMarkdownText(node.data); return [ - { content: node.data, style: cloneStyle(style), marks: { ...marks } }, + { content: escaped, style: cloneStyle(style), marks: { ...marks } }, ]; } if (!isElement(node) || isDroppedElement(node)) return []; - const ownStyle = parseInlineStyle(node.attribs.style); + const { style: ownStyle, marks: ownMarks } = parseStyleAttribute( + node.attribs.style, + ); const nextStyle = mergeStyle(style, ownStyle); + let nextMarks = cloneMarks(marks, ownMarks); + const name = node.name; if (name === 'br') { return [ - { content: '\n', style: cloneStyle(nextStyle), marks: { ...marks } }, + { content: '\n', style: cloneStyle(nextStyle), marks: { ...nextMarks } }, ]; } if (name === 'img') { @@ -170,7 +183,7 @@ export function renderInlineNode( { content: `![${alt}](${source})`, style: cloneStyle(nextStyle), - marks: { ...marks }, + marks: { ...nextMarks }, }, ]; } @@ -183,7 +196,7 @@ export function renderInlineNode( content: `${fence}${code}${fence}`, style: cloneStyle(nextStyle), marks: { - ...marks, + ...nextMarks, bold: false, italic: false, underline: false, @@ -193,7 +206,6 @@ export function renderInlineNode( ]; } - let nextMarks = marks; if (name === 'strong' || name === 'b') nextMarks = cloneMarks(nextMarks, { bold: true }); else if (name === 'em' || name === 'i') @@ -345,24 +357,26 @@ export function renderPre(element: ElementNode): string { export function renderBlockElement(element: ElementNode, style: Style): string { if (isDroppedElement(element)) return ''; - const nextStyle = mergeStyle(style, parseInlineStyle(element.attribs.style)); + const { style: ownStyle } = parseStyleAttribute(element.attribs.style); + const nextStyle = mergeStyle(style, ownStyle); if (element.name === 'hr') return '\n\n---\n\n'; if (element.name === 'pre') return renderPre(element); if (element.name === 'ul' || element.name === 'ol') return renderList(element, nextStyle, 0); if (element.name === 'table') return renderTable(element, nextStyle); if (element.name === 'blockquote') { - const content = stripBoundaryNewlines( + // ✨ 规范化空行(保留最多双换行段落结构) + const rawContent = stripBoundaryNewlines( renderFlow(element.children, nextStyle), ) - .replace(/\n{2,}/gu, '\n') + .replace(/\n{3,}/gu, '\n\n') .trim(); - if (!content) return ''; - const newlineCount = (content.match(/\n/gu) ?? []).length; - if (newlineCount >= 2) { - return `\`\`\`quote\n${content}\n\`\`\`\n\n`; + if (!rawContent) return ''; + const textLines = rawContent.split(/\n+/u); + if (textLines.length >= 3) { + return `\`\`\`quote\n${rawContent}\n\`\`\`\n\n`; // ✨ 完整保留段落原本的换行结构 } - const quoted = content + const quoted = rawContent .split('\n') .map((line) => (line ? `> ${line}` : '>')) .join('\n'); diff --git a/packages/html2ffm/src/style.ts b/packages/html2ffm/src/style.ts index bb88d8d..85dbee4 100644 --- a/packages/html2ffm/src/style.ts +++ b/packages/html2ffm/src/style.ts @@ -9,9 +9,14 @@ export function parseFontSize(value: string): string | null { } // Parse supported inline declarations while preserving CSS last-valid semantics. -export function parseInlineStyle(value: string | undefined): Style { - if (!value) return {}; +export function parseStyleAttribute(value: string | undefined): { + style: Style; + marks: Partial; +} { + if (!value) return { style: {}, marks: {} }; const style: Style = {}; + const marks: Partial = {}; + for (const declaration of value.split(';')) { const colonIndex = declaration.indexOf(':'); if (colonIndex === -1) continue; @@ -19,17 +24,39 @@ export function parseInlineStyle(value: string | undefined): Style { const propertyValue = declaration .slice(colonIndex + 1) .trim() + .toLowerCase() .replace(/\s*!important\s*$/iu, ''); + if (property === 'color') { const color = parseColor(propertyValue); if (color !== null) style.color = color; else if (isTransparentColor(propertyValue)) style.color = null; + } else if (property === 'background-color' || property === 'background') { + // supports background-color and background + const background = parseColor(propertyValue); + if (background !== null) style.background = background; + else if (isTransparentColor(propertyValue)) style.background = null; } else if (property === 'font-size') { const fontSize = parseFontSize(propertyValue); if (fontSize !== null) style.fontSize = fontSize; + } else if ( + property === 'text-decoration' || + property === 'text-decoration-line' + ) { + if (propertyValue.includes('underline')) marks.underline = true; + if (propertyValue.includes('line-through')) marks.strike = true; + } else if (property === 'font-weight') { + if (['bold', 'bolder', '700', '800', '900'].includes(propertyValue)) { + marks.bold = true; + } + } else if (property === 'font-style') { + if (propertyValue === 'italic' || propertyValue === 'oblique') { + marks.italic = true; + } } } - return style; + + return { style, marks }; } export function mergeStyle(parent: Style, own: Style): Style { @@ -45,7 +72,7 @@ export function cloneMarks(marks: Marks, patch: Partial): Marks { } export function styleKey(style: Style): string { - return `${style.color ?? ''}|${style.fontSize ?? ''}`; + return `${style.color ?? ''}|${style.background ?? ''}|${style.fontSize ?? ''}`; } export function marksKey(marks: Marks): string { @@ -55,6 +82,7 @@ export function marksKey(marks: Marks): string { export function formatStyle(style: Style): string { const attributes: string[] = []; if (style.color) attributes.push(`color = ${style.color}`); + if (style.background) attributes.push(`background = ${style.background}`); if (style.fontSize) attributes.push(`font = {size = ${style.fontSize}}`); return attributes.length > 0 ? `(${attributes.join(', ')})` : ''; } diff --git a/packages/html2ffm/src/types.ts b/packages/html2ffm/src/types.ts index ed0268c..2defffc 100644 --- a/packages/html2ffm/src/types.ts +++ b/packages/html2ffm/src/types.ts @@ -16,6 +16,7 @@ export type TextNode = ChildNode & { export type Style = { color?: string | null; + background?: string | null; fontSize?: string; }; diff --git a/packages/html2ffm/test.ts b/packages/html2ffm/test.ts new file mode 100644 index 0000000..4843702 --- /dev/null +++ b/packages/html2ffm/test.ts @@ -0,0 +1,17 @@ +// @fuyeor/html2ffm/src/test.ts +// npx tsx test.ts +import { toFFM } from './src/index'; + +// HTML snippet wanted to test +const inputHtml = `

Hello

`; + +console.log('🟥 HTML'); + +// HTML snippet wanted to test +console.log(` +

Hello

+`); + +console.log('🟪 Fuyeor Flavored Markdown\n'); + +console.log(`${toFFM(inputHtml)}\n`); diff --git a/packages/html2ffm/tsconfig.json b/packages/html2ffm/tsconfig.json index 5bed8a7..210af38 100644 --- a/packages/html2ffm/tsconfig.json +++ b/packages/html2ffm/tsconfig.json @@ -14,6 +14,6 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "test.ts"], "exclude": ["src/**/*.spec.ts"] } From e130b8575508a241a1f1e8e84e1a76c2cf12d08a Mon Sep 17 00:00:00 2001 From: Fuyeor Date: Sun, 30 Aug 2026 19:10:57 +0000 Subject: [PATCH 3/5] refactor: Modularize @fuyeor/markdown-formatter --- packages/markdown-formatter/src/blocks.ts | 230 ++++++++++ packages/markdown-formatter/src/constants.ts | 16 + packages/markdown-formatter/src/index.spec.ts | 11 +- packages/markdown-formatter/src/index.ts | 412 +----------------- packages/markdown-formatter/src/text.ts | 146 +++++++ packages/markdown-formatter/src/types.ts | 15 + 6 files changed, 431 insertions(+), 399 deletions(-) create mode 100644 packages/markdown-formatter/src/blocks.ts create mode 100644 packages/markdown-formatter/src/constants.ts create mode 100644 packages/markdown-formatter/src/text.ts create mode 100644 packages/markdown-formatter/src/types.ts diff --git a/packages/markdown-formatter/src/blocks.ts b/packages/markdown-formatter/src/blocks.ts new file mode 100644 index 0000000..7793bc8 --- /dev/null +++ b/packages/markdown-formatter/src/blocks.ts @@ -0,0 +1,230 @@ +// @fuyeor/markdown-formatter/src/blocks.ts +import { semanticFenceLanguages } from './constants'; +import { formatText } from './text'; +import type { Fence, ListIndentContext, QuoteLine } from './types'; + +/** Normalize line endings before applying deterministic line-based formatting. */ +export function normalizeLineEndings(content: string): string { + return content.replace(/\r\n?/gu, '\n'); +} + +/** Return a fenced-block opener while leaving all fenced content untouched. */ +export function getFence(line: string): Fence | null { + const match = line.match(/^\s*(`{3,}|~{3,})([A-Za-z][A-Za-z0-9_+.-]*)?\s*$/u); + if (!match) return null; + return { + character: match[1]![0] as '`' | '~', + length: match[1]!.length, + language: match[2]?.toLowerCase(), + }; +} + +/** Check whether a line closes the currently active fence. */ +export function isFenceClose(line: string, fence: Fence): boolean { + const marker = fence.character === '`' ? '`' : '~'; + const expression = new RegExp(`^\\s*${marker}{${fence.length},}\\s*$`, 'u'); + return expression.test(line); +} + +/** Split a table row without treating escaped or inline-code pipes as separators. */ +export function splitTableCells(line: string): string[] { + const source = line.trim(); + const content = source.startsWith('|') ? source.slice(1) : source; + const cells: string[] = []; + let cell = ''; + let inlineCodeMarker = ''; + + for (let index = 0; index < content.length; index++) { + const character = content[index]!; + if (character === '\\' && content[index + 1] === '|') { + cell += '|'; + index++; + continue; + } + if (character === '`') { + let markerLength = 1; + while (content[index + markerLength] === '`') markerLength++; + const marker = '`'.repeat(markerLength); + inlineCodeMarker = + inlineCodeMarker === marker ? '' : inlineCodeMarker || marker; + cell += marker; + index += markerLength - 1; + continue; + } + if (character === '|' && !inlineCodeMarker) { + cells.push(cell.trim()); + cell = ''; + continue; + } + cell += character; + } + cells.push(cell.trim()); + if (cells.at(-1) === '') cells.pop(); + return cells; +} + +/** Identify the Markdown table delimiter row and its alignment cells. */ +export function getTableDelimiterCells(line: string): string[] | null { + const cells = splitTableCells(line); + if (cells.length === 0 || cells.some((cell) => !/^:?-{3,}:?$/u.test(cell))) { + return null; + } + return cells; +} + +/** Reduce table padding and delimiter runs to the canonical FFM representation. */ +export function formatTableRow(cells: readonly string[]): string { + return `| ${cells.map(formatText).join(' | ')} |`; +} + +/** Preserve alignment markers while removing redundant delimiter hyphens. */ +export function formatTableDelimiter(cells: readonly string[]): string { + return formatTableRow( + cells.map((cell) => { + const leftAligned = cell.startsWith(':'); + const rightAligned = cell.endsWith(':'); + return `${leftAligned ? ':' : ''}---${rightAligned ? ':' : ''}`; + }), + ); +} + +/** Normalize one list level to two spaces while preserving nested list depth. */ +export function formatListLine( + line: string, + context: ListIndentContext, +): string | null { + const match = line.match(/^(\s*)([-*]|\d+[.)])(?=\s+)/u); + if (!match) return null; + + const rawIndentation = match[1]!.replace(/\t/gu, ' ').length; + while (context.levels.length > 1 && rawIndentation < context.levels.at(-1)!) { + context.levels.pop(); + } + if (rawIndentation > context.levels.at(-1)!) { + context.levels.push(rawIndentation); + } + + const markerEnd = match[1]!.length + match[2]!.length; + const rest = formatText(line.slice(markerEnd).trimStart()); + const indentation = ' '.repeat((context.levels.length - 1) * 2); + return `${indentation}${match[2]} ${rest}`; +} + +/** Normalize one Markdown blockquote marker and its content spacing. */ +export function formatQuoteLine(line: string): string | null { + const match = line.match(/^\s*(>+)[ \t]*(.*)$/u); + if (!match) return null; + const content = formatText(match[2]!.trimStart()); + return content ? `${match[1]} ${content}` : match[1]!; +} + +/** Format one non-fenced line without changing its Markdown delimiters. */ +export function formatOrdinaryLine( + line: string, + context: ListIndentContext, +): string { + const quoteLine = formatQuoteLine(line); + const listLine = formatListLine(line, context); + const formatted = quoteLine ?? listLine ?? formatText(line).trimStart(); + if (!listLine) context.levels = [0]; + return formatted.replace(/[ \t]+$/u, ''); +} + +/** Parse a single line from a contiguous Markdown blockquote. */ +export function getQuoteLine(line: string): QuoteLine | null { + const match = line.match(/^\s*>+\s?(.*)$/u); + return match ? { content: match[1]! } : null; +} + +/** Convert a blockquote with at least three non-empty quoted lines to FFM quote syntax. */ +export function formatDeepQuote( + lines: readonly string[], + start: number, +): { lines: string[]; next: number } | null { + const first = getQuoteLine(lines[start]!); + if (!first) return null; + + const content = [first.content]; + let next = start + 1; + while (next < lines.length) { + const continuation = getQuoteLine(lines[next]!); + if (!continuation) break; + content.push(continuation.content); + next++; + } + + if (content.filter((line) => line.trim() !== '').length < 3) return null; + const listContext: ListIndentContext = { levels: [0] }; + return { + lines: [ + '```quote', + ...content.map((line) => formatOrdinaryLine(line, listContext)), + '```', + ], + next, + }; +} + +/** Format one complete Markdown table beginning at the supplied header line. */ +export function formatTable( + lines: readonly string[], + start: number, +): { lines: string[]; next: number } | null { + if (!lines[start]!.includes('|')) return null; + const delimiterCells = getTableDelimiterCells(lines[start + 1] ?? ''); + if (!delimiterCells) return null; + + const formatted = [ + formatTableRow(splitTableCells(lines[start]!)), + formatTableDelimiter(delimiterCells), + ]; + let next = start + 2; + while (next < lines.length && lines[next]!.includes('|')) { + formatted.push(formatTableRow(splitTableCells(lines[next]!))); + next++; + } + return { lines: formatted, next }; +} + +/** Format content inside FFM semantic fences while preserving their delimiters. */ +export function formatSemanticFence( + lines: readonly string[], + start: number, + fence: Fence, + formatFn: (content: string) => string, +): { lines: string[]; next: number } | null { + if (!fence.language || !semanticFenceLanguages.has(fence.language)) { + return null; + } + + let closingIndex = start + 1; + while (closingIndex < lines.length) { + if (isFenceClose(lines[closingIndex]!, fence)) break; + closingIndex++; + } + if (closingIndex >= lines.length) return null; + + const inner = formatFn(lines.slice(start + 1, closingIndex).join('\n')); + return { + lines: [ + lines[start]!, + ...(inner ? inner.split('\n') : []), + lines[closingIndex]!, + ], + next: closingIndex + 1, + }; +} + +/** Remove empty boundary lines without treating list indentation as disposable file whitespace. */ +export function trimDocumentBoundary(lines: readonly string[]): string { + let start = 0; + let end = lines.length; + while (start < end && lines[start]!.trim() === '') start++; + while (end > start && lines[end - 1]!.trim() === '') end--; + if (start === end) return ''; + + const body = lines.slice(start, end); + const listContext: ListIndentContext = { levels: [0] }; + body[0] = formatOrdinaryLine(body[0]!, listContext); + return body.join('\n').replace(/[ \t]+$/u, ''); +} diff --git a/packages/markdown-formatter/src/constants.ts b/packages/markdown-formatter/src/constants.ts new file mode 100644 index 0000000..5b7abfe --- /dev/null +++ b/packages/markdown-formatter/src/constants.ts @@ -0,0 +1,16 @@ +// @fuyeor/markdown-formatter/src/constants.ts +// NOTE: Avoid medieval SCREAMING_SNAKE_CASE; use camelCase for modern readability. +export const cjkCharacter = '\\p{Script=Han}'; +export const latinOrDigit = 'A-Za-z0-9'; +export const cjkLatinBoundary = new RegExp( + `(?<=[${cjkCharacter}])(?=[${latinOrDigit}])|(?<=[${latinOrDigit}])(?=[${cjkCharacter}])`, + 'gu', +); +export const inlineMarkupPattern = /(\*{1,3}|_{2}|--)([^\n]+?)\1/gu; +export const linkTargetPattern = /(!?\[[^\]\n]*\])\(\s*([^)]*?\S)\s*\)/gu; +export const semanticFenceLanguages = new Set([ + 'quote', + 'slide', + 'chain', + 'accordion', +]); diff --git a/packages/markdown-formatter/src/index.spec.ts b/packages/markdown-formatter/src/index.spec.ts index 86fbd53..689312e 100644 --- a/packages/markdown-formatter/src/index.spec.ts +++ b/packages/markdown-formatter/src/index.spec.ts @@ -1,15 +1,8 @@ // @fuyeor/markdown-formatter/src/index.spec.ts +// pnpm --filter @fuyeor/markdown-formatter test import { describe, expect, it } from 'vitest'; -import fixtureData from './fixtures/format.json'; import { format } from './index'; - -type FormatFixture = { - origin: string; - formatted: string; - section: string; -}; - -const fixtures = fixtureData as FormatFixture[]; +import fixtures from './fixtures/format.json' with { type: 'json' }; describe('format fixtures', () => { for (const fixture of fixtures) { diff --git a/packages/markdown-formatter/src/index.ts b/packages/markdown-formatter/src/index.ts index f055f31..6864cd9 100644 --- a/packages/markdown-formatter/src/index.ts +++ b/packages/markdown-formatter/src/index.ts @@ -1,393 +1,20 @@ // @fuyeor/markdown-formatter/src/index.ts - -const CJK_CHARACTER = '\\p{Script=Han}'; -const LATIN_OR_DIGIT = 'A-Za-z0-9'; -const CJK_LATIN_BOUNDARY = new RegExp( - `(?<=[${CJK_CHARACTER}])(?=[${LATIN_OR_DIGIT}])|(?<=[${LATIN_OR_DIGIT}])(?=[${CJK_CHARACTER}])`, - 'gu', -); -const INLINE_MARKUP_PATTERN = /(\*{1,3}|_{2}|--)([^\n]+?)\1/gu; -const LINK_TARGET_PATTERN = /(!?\[[^\]\n]*\])\(\s*([^)]*?\S)\s*\)/gu; -const SEMANTIC_FENCE_LANGUAGES = new Set([ - 'quote', - 'slide', - 'chain', - 'accordion', -]); - -type Fence = { - character: '`' | '~'; - length: number; - language?: string; -}; - -type QuoteLine = { - content: string; -}; - -type ListIndentContext = { - levels: number[]; -}; - -/** Normalize line endings before applying deterministic line-based formatting. */ -function normalizeLineEndings(content: string): string { - return content.replace(/\r\n?/gu, '\n'); -} - -/** Return a fenced-block opener while leaving all fenced content untouched. */ -function getFence(line: string): Fence | null { - const match = line.match(/^\s*(`{3,}|~{3,})([A-Za-z][A-Za-z0-9_+.-]*)?\s*$/u); - if (!match) return null; - return { - character: match[1]![0] as '`' | '~', - length: match[1]!.length, - language: match[2]?.toLowerCase(), - }; -} - -/** Check whether a line closes the currently active fence. */ -function isFenceClose(line: string, fence: Fence): boolean { - const marker = fence.character === '`' ? '`' : '~'; - const expression = new RegExp(`^\\s*${marker}{${fence.length},}\\s*$`, 'u'); - return expression.test(line); -} - -/** Split a table row without treating escaped or inline-code pipes as separators. */ -function splitTableCells(line: string): string[] { - const source = line.trim(); - const content = source.startsWith('|') ? source.slice(1) : source; - const cells: string[] = []; - let cell = ''; - let inlineCodeMarker = ''; - - for (let index = 0; index < content.length; index++) { - const character = content[index]!; - if (character === '\\' && content[index + 1] === '|') { - cell += '|'; - index++; - continue; - } - if (character === '`') { - let markerLength = 1; - while (content[index + markerLength] === '`') markerLength++; - const marker = '`'.repeat(markerLength); - inlineCodeMarker = - inlineCodeMarker === marker ? '' : inlineCodeMarker || marker; - cell += marker; - index += markerLength - 1; - continue; - } - if (character === '|' && !inlineCodeMarker) { - cells.push(cell.trim()); - cell = ''; - continue; - } - cell += character; - } - cells.push(cell.trim()); - if (cells.at(-1) === '') cells.pop(); - return cells; -} - -/** Identify the Markdown table delimiter row and its alignment cells. */ -function getTableDelimiterCells(line: string): string[] | null { - const cells = splitTableCells(line); - if (cells.length === 0 || cells.some((cell) => !/^:?-{3,}:?$/u.test(cell))) { - return null; - } - return cells; -} - -/** Reduce table padding and delimiter runs to the canonical FFM representation. */ -function formatTableRow(cells: readonly string[]): string { - return `| ${cells.map(formatText).join(' | ')} |`; -} - -/** Preserve alignment markers while removing redundant delimiter hyphens. */ -function formatTableDelimiter(cells: readonly string[]): string { - return formatTableRow( - cells.map((cell) => { - const leftAligned = cell.startsWith(':'); - const rightAligned = cell.endsWith(':'); - return `${leftAligned ? ':' : ''}---${rightAligned ? ':' : ''}`; - }), - ); -} - -/** Check whether a single character is a Han-script character. */ -function isCjkCharacter(character: string | undefined): boolean { - return character !== undefined && /^\p{Script=Han}$/u.test(character); -} - -/** Check whether a single character is a Latin letter or an ASCII digit. */ -function isLatinOrDigitCharacter(character: string | undefined): boolean { - return character !== undefined && /^[A-Za-z0-9]$/u.test(character); -} - -/** Collapse runs of horizontal whitespace in unprotected text to one space. */ -function normalizeHorizontalWhitespace(segment: string): string { - return segment.replace(/[ \t]+/gu, ' '); -} - -/** Apply CJK spacing to plain text without interpreting protected inline code. */ -function formatCjkBoundaries(segment: string): string { - return segment.replace(CJK_LATIN_BOUNDARY, ' '); -} - -/** Add spaces around inline markup when its content crosses a CJK boundary. */ -function formatInlineMarkupBoundaries(segment: string): string { - return segment.replace( - INLINE_MARKUP_PATTERN, - ( - full: string, - marker: string, - inner: string, - offset: number, - source: string, - ) => { - const previous = source[offset - 1]; - const next = source[offset + full.length]; - const leadingSpace = - isCjkCharacter(previous) && isLatinOrDigitCharacter(inner[0]) - ? ' ' - : ''; - const trailingSpace = - isCjkCharacter(next) && isLatinOrDigitCharacter(inner.at(-1)) - ? ' ' - : ''; - return `${leadingSpace}${marker}${formatCjkBoundaries(inner)}${marker}${trailingSpace}`; - }, - ); -} - -/** Trim only the outer whitespace of Markdown link destinations. */ -function trimLinkTargets(segment: string): string { - return segment.replace( - LINK_TARGET_PATTERN, - (_full: string, label: string, target: string) => `${label}(${target})`, - ); -} - -/** Apply inline spacing and link cleanup to an unprotected text segment. */ -function formatTextSegment(segment: string): string { - return formatCjkBoundaries( - formatInlineMarkupBoundaries( - normalizeHorizontalWhitespace(trimLinkTargets(segment)), - ), - ); -} - -/** Add CJK spacing around a protected inline token without changing its content. */ -function formatProtectedToken( - token: string, - previous: string | undefined, - next: string | undefined, -): string { - const marker = - token[0] === '`' - ? '`'.repeat(countMarkerCharacters(token, 0, '`')) - : token.startsWith('$$') - ? '$$' - : '$'; - const inner = token.slice(marker.length, -marker.length); - const leadingSpace = - isCjkCharacter(previous) && isLatinOrDigitCharacter(inner[0]) ? ' ' : ''; - const trailingSpace = - isCjkCharacter(next) && isLatinOrDigitCharacter(inner.at(-1)) ? ' ' : ''; - return `${leadingSpace}${token}${trailingSpace}`; -} - -/** Format ordinary text while preserving inline code and math tokens byte-for-byte. */ -function formatText(line: string): string { - let result = ''; - let segmentStart = 0; - let index = 0; - - const appendPlainText = (end: number) => { - result += formatTextSegment(line.slice(segmentStart, end)); - }; - - while (index < line.length) { - const character = line[index]!; - if (character === '`' || character === '$') { - const marker = - character === '`' - ? '`'.repeat(countMarkerCharacters(line, index, '`')) - : line.startsWith('$$', index) - ? '$$' - : '$'; - const contentStart = index + marker.length; - const closingIndex = line.indexOf(marker, contentStart); - if ( - closingIndex !== -1 && - (character !== '$' || marker === '$$' || closingIndex > contentStart) - ) { - appendPlainText(index); - const contentEnd = closingIndex + marker.length; - result += formatProtectedToken( - line.slice(index, contentEnd), - line[index - 1], - line[contentEnd], - ); - index = contentEnd; - segmentStart = index; - continue; - } - } - index++; - } - - appendPlainText(line.length); - return result; -} - -/** Count a contiguous run of the selected marker character. */ -function countMarkerCharacters( - line: string, - start: number, - marker: '`' | '$', -): number { - let count = 0; - while (line[start + count] === marker) count++; - return count; -} - -/** Normalize one list level to two spaces while preserving nested list depth. */ -function formatListLine( - line: string, - context: ListIndentContext, -): string | null { - const match = line.match(/^(\s*)([-*]|\d+[.)])(?=\s+)/u); - if (!match) return null; - - const rawIndentation = match[1]!.replace(/\t/gu, ' ').length; - while (context.levels.length > 1 && rawIndentation < context.levels.at(-1)!) { - context.levels.pop(); - } - if (rawIndentation > context.levels.at(-1)!) { - context.levels.push(rawIndentation); - } - - const markerEnd = match[1]!.length + match[2]!.length; - const rest = formatText(line.slice(markerEnd).trimStart()); - const indentation = ' '.repeat((context.levels.length - 1) * 2); - return `${indentation}${match[2]} ${rest}`; -} - -/** Normalize one Markdown blockquote marker and its content spacing. */ -function formatQuoteLine(line: string): string | null { - const match = line.match(/^\s*(>+)[ \t]*(.*)$/u); - if (!match) return null; - const content = formatText(match[2]!.trimStart()); - return content ? `${match[1]} ${content}` : match[1]!; -} - -/** Format one non-fenced line without changing its Markdown delimiters. */ -function formatOrdinaryLine(line: string, context: ListIndentContext): string { - const quoteLine = formatQuoteLine(line); - const listLine = formatListLine(line, context); - const formatted = quoteLine ?? listLine ?? formatText(line).trimStart(); - if (!listLine) context.levels = [0]; - return formatted.replace(/[ \t]+$/u, ''); -} - -/** Parse a single line from a contiguous Markdown blockquote. */ -function getQuoteLine(line: string): QuoteLine | null { - const match = line.match(/^\s*>+\s?(.*)$/u); - return match ? { content: match[1]! } : null; -} - -/** Convert a blockquote with at least three non-empty quoted lines to FFM quote syntax. */ -function formatDeepQuote( - lines: readonly string[], - start: number, -): { lines: string[]; next: number } | null { - const first = getQuoteLine(lines[start]!); - if (!first) return null; - - const content = [first.content]; - let next = start + 1; - while (next < lines.length) { - const continuation = getQuoteLine(lines[next]!); - if (!continuation) break; - content.push(continuation.content); - next++; - } - - if (content.filter((line) => line.trim() !== '').length < 3) return null; - const listContext: ListIndentContext = { levels: [0] }; - return { - lines: [ - '```quote', - ...content.map((line) => formatOrdinaryLine(line, listContext)), - '```', - ], - next, - }; -} - -/** Format one complete Markdown table beginning at the supplied header line. */ -function formatTable( - lines: readonly string[], - start: number, -): { lines: string[]; next: number } | null { - if (!lines[start]!.includes('|')) return null; - const delimiterCells = getTableDelimiterCells(lines[start + 1] ?? ''); - if (!delimiterCells) return null; - - const formatted = [ - formatTableRow(splitTableCells(lines[start]!)), - formatTableDelimiter(delimiterCells), - ]; - let next = start + 2; - while (next < lines.length && lines[next]!.includes('|')) { - formatted.push(formatTableRow(splitTableCells(lines[next]!))); - next++; - } - return { lines: formatted, next }; -} - -/** Format content inside FFM semantic fences while preserving their delimiters. */ -function formatSemanticFence( - lines: readonly string[], - start: number, - fence: Fence, -): { lines: string[]; next: number } | null { - if (!fence.language || !SEMANTIC_FENCE_LANGUAGES.has(fence.language)) { - return null; - } - - let closingIndex = start + 1; - while (closingIndex < lines.length) { - if (isFenceClose(lines[closingIndex]!, fence)) break; - closingIndex++; - } - if (closingIndex >= lines.length) return null; - - const inner = format(lines.slice(start + 1, closingIndex).join('\n')); - return { - lines: [ - lines[start]!, - ...(inner ? inner.split('\n') : []), - lines[closingIndex]!, - ], - next: closingIndex + 1, - }; -} - -/** Remove empty boundary lines without treating list indentation as disposable file whitespace. */ -function trimDocumentBoundary(lines: readonly string[]): string { - let start = 0; - let end = lines.length; - while (start < end && lines[start]!.trim() === '') start++; - while (end > start && lines[end - 1]!.trim() === '') end--; - if (start === end) return ''; - - const body = lines.slice(start, end); - const listContext: ListIndentContext = { levels: [0] }; - body[0] = formatOrdinaryLine(body[0]!, listContext); - return body.join('\n').replace(/[ \t]+$/u, ''); -} +import { + formatDeepQuote, + formatOrdinaryLine, + formatSemanticFence, + formatTable, + getFence, + isFenceClose, + normalizeLineEndings, + trimDocumentBoundary, +} from './blocks'; +import type { Fence, ListIndentContext } from './types'; + +export * from './blocks'; +export * from './constants'; +export * from './text'; +export * from './types'; /** Format a complete FFM document according to the editor's canonical style. */ export function format(content: string): string { @@ -415,7 +42,12 @@ export function format(content: string): string { const openingFence = getFence(line); if (openingFence) { - const semanticFence = formatSemanticFence(lines, index, openingFence); + const semanticFence = formatSemanticFence( + lines, + index, + openingFence, + format, + ); if (semanticFence) { formatted.push(...semanticFence.lines); index = semanticFence.next; diff --git a/packages/markdown-formatter/src/text.ts b/packages/markdown-formatter/src/text.ts new file mode 100644 index 0000000..09cf329 --- /dev/null +++ b/packages/markdown-formatter/src/text.ts @@ -0,0 +1,146 @@ +// @fuyeor/markdown-formatter/src/text.ts +import { + cjkLatinBoundary, + inlineMarkupPattern, + linkTargetPattern, +} from './constants'; + +/** Check whether a single character is a Han-script character. */ +export function isCjkCharacter(character: string | undefined): boolean { + return character !== undefined && /^\p{Script=Han}$/u.test(character); +} + +/** Check whether a single character is a Latin letter or an ASCII digit. */ +export function isLatinOrDigitCharacter( + character: string | undefined, +): boolean { + return character !== undefined && /^[A-Za-z0-9]$/u.test(character); +} + +/** Collapse runs of horizontal whitespace in unprotected text to one space. */ +export function normalizeHorizontalWhitespace(segment: string): string { + return segment.replace(/[ \t]+/gu, ' '); +} + +/** Apply CJK spacing to plain text without interpreting protected inline code. */ +export function formatCjkBoundaries(segment: string): string { + return segment.replace(cjkLatinBoundary, ' '); +} + +/** Add spaces around inline markup when its content crosses a CJK boundary. */ +export function formatInlineMarkupBoundaries(segment: string): string { + return segment.replace( + inlineMarkupPattern, + ( + full: string, + marker: string, + inner: string, + offset: number, + source: string, + ) => { + const previous = source[offset - 1]; + const next = source[offset + full.length]; + const leadingSpace = + isCjkCharacter(previous) && isLatinOrDigitCharacter(inner[0]) + ? ' ' + : ''; + const trailingSpace = + isCjkCharacter(next) && isLatinOrDigitCharacter(inner.at(-1)) + ? ' ' + : ''; + return `${leadingSpace}${marker}${formatCjkBoundaries(inner)}${marker}${trailingSpace}`; + }, + ); +} + +/** Trim only the outer whitespace of Markdown link destinations. */ +export function trimLinkTargets(segment: string): string { + return segment.replace( + linkTargetPattern, + (_full: string, label: string, target: string) => `${label}(${target})`, + ); +} + +/** Apply inline spacing and link cleanup to an unprotected text segment. */ +export function formatTextSegment(segment: string): string { + return formatCjkBoundaries( + formatInlineMarkupBoundaries( + normalizeHorizontalWhitespace(trimLinkTargets(segment)), + ), + ); +} + +/** Count a contiguous run of the selected marker character. */ +export function countMarkerCharacters( + line: string, + start: number, + marker: '`' | '$', +): number { + let count = 0; + while (line[start + count] === marker) count++; + return count; +} + +/** Add CJK spacing around a protected inline token without changing its content. */ +export function formatProtectedToken( + token: string, + previous: string | undefined, + next: string | undefined, +): string { + const marker = + token[0] === '`' + ? '`'.repeat(countMarkerCharacters(token, 0, '`')) + : token.startsWith('$$') + ? '$$' + : '$'; + const inner = token.slice(marker.length, -marker.length); + const leadingSpace = + isCjkCharacter(previous) && isLatinOrDigitCharacter(inner[0]) ? ' ' : ''; + const trailingSpace = + isCjkCharacter(next) && isLatinOrDigitCharacter(inner.at(-1)) ? ' ' : ''; + return `${leadingSpace}${token}${trailingSpace}`; +} + +/** Format ordinary text while preserving inline code and math tokens byte-for-byte. */ +export function formatText(line: string): string { + let result = ''; + let segmentStart = 0; + let index = 0; + + const appendPlainText = (end: number) => { + result += formatTextSegment(line.slice(segmentStart, end)); + }; + + while (index < line.length) { + const character = line[index]!; + if (character === '`' || character === '$') { + const marker = + character === '`' + ? '`'.repeat(countMarkerCharacters(line, index, '`')) + : line.startsWith('$$', index) + ? '$$' + : '$'; + const contentStart = index + marker.length; + const closingIndex = line.indexOf(marker, contentStart); + if ( + closingIndex !== -1 && + (character !== '$' || marker === '$$' || closingIndex > contentStart) + ) { + appendPlainText(index); + const contentEnd = closingIndex + marker.length; + result += formatProtectedToken( + line.slice(index, contentEnd), + line[index - 1], + line[contentEnd], + ); + index = contentEnd; + segmentStart = index; + continue; + } + } + index++; + } + + appendPlainText(line.length); + return result; +} diff --git a/packages/markdown-formatter/src/types.ts b/packages/markdown-formatter/src/types.ts new file mode 100644 index 0000000..89d0f6d --- /dev/null +++ b/packages/markdown-formatter/src/types.ts @@ -0,0 +1,15 @@ +// @fuyeor/markdown-formatter/src/types.ts + +export type Fence = { + character: '`' | '~'; + length: number; + language?: string; +}; + +export type QuoteLine = { + content: string; +}; + +export type ListIndentContext = { + levels: number[]; +}; From ca2d96f9aa460706d6ae1c57da4c302bce5bb0ca Mon Sep 17 00:00:00 2001 From: Fuyeor Date: Sun, 30 Aug 2026 19:24:09 +0000 Subject: [PATCH 4/5] feat: Add custom blank lines option to formatter --- packages/markdown-formatter/README.md | 60 +++++++++++++++++++ packages/markdown-formatter/package.json | 2 +- packages/markdown-formatter/src/blocks.ts | 15 ++++- packages/markdown-formatter/src/constants.ts | 7 +++ packages/markdown-formatter/src/index.spec.ts | 12 ++++ packages/markdown-formatter/src/index.ts | 27 ++++++++- packages/markdown-formatter/src/types.ts | 6 ++ 7 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 packages/markdown-formatter/README.md diff --git a/packages/markdown-formatter/README.md b/packages/markdown-formatter/README.md new file mode 100644 index 0000000..07dae16 --- /dev/null +++ b/packages/markdown-formatter/README.md @@ -0,0 +1,60 @@ +@fuyeor/markdown-formatter — A lightweight, deterministic Markdown and FFM (FuYeor Flavored Markdown) formatter with built-in CJK typographic spacing, table alignment, list indentation normalization, and semantic fence processing. + +## Features + +- **CJK Typography**: Automatic Pangu spacing between Chinese/Japanese/Korean and Latin/digits without altering protected tokens (inline code/math). +- **Table Normalization**: Canonical pipe table formatting and delimiter cleanup. +- **List Indentation**: Consistent 2-space indentation depth for nested ordered and unordered lists. +- **Semantic Fences**: Recursive formatting inside semantic containers (`quote`, `slide`, `chain`, `accordion`). +- **Configurable Blank Lines**: Fine-grained control over consecutive blank line collapsing. +- **Zero Dependencies**: Blazing fast and minimal bundle size. + +## Quick Start + +```ts +import { format } from '@fuyeor/markdown-formatter'; + +const markdown = ` +# Title +这是English文本和一个[链接](https://example.com)。 +|Name|Age| +|------|-------| +|Alice|20| +`; + +const formatted = format(markdown); +console.log(formatted); +``` + +### Output: + +````markdown +# Title + +这是 English 文本和一个[链接](https://example.com)。 + +| Name | Age | +| --- | --- | +| Alice | 20 | +```` + +## Options + +`format(content: string, options?: FormatOptions): string` + +| Option | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `maxConsecutiveBlankLines` | `number` | `1` | Maximum allowable consecutive blank lines between blocks. Set to `2` to preserve intentional author whitespace. | + +### Example with Options + +```ts +import { format } from '@fuyeor/markdown-formatter'; + +const source = 'First paragraph\n\n\n\nSecond paragraph'; + +// Retains at most 2 blank lines (3 newlines) +const formatted = format(source, { + maxConsecutiveBlankLines: 2, +}); +``` diff --git a/packages/markdown-formatter/package.json b/packages/markdown-formatter/package.json index 535adfd..3968c2b 100644 --- a/packages/markdown-formatter/package.json +++ b/packages/markdown-formatter/package.json @@ -1,6 +1,6 @@ { "name": "@fuyeor/markdown-formatter", - "version": "0.1.0", + "version": "0.1.1", "description": "Formatter for Fuyeor Flavored Markdown documents.", "license": "MIT", "author": "Fuyeor ", diff --git a/packages/markdown-formatter/src/blocks.ts b/packages/markdown-formatter/src/blocks.ts index 7793bc8..1988edf 100644 --- a/packages/markdown-formatter/src/blocks.ts +++ b/packages/markdown-formatter/src/blocks.ts @@ -1,7 +1,12 @@ // @fuyeor/markdown-formatter/src/blocks.ts import { semanticFenceLanguages } from './constants'; import { formatText } from './text'; -import type { Fence, ListIndentContext, QuoteLine } from './types'; +import type { + Fence, + FormatOptions, + ListIndentContext, + QuoteLine, +} from './types'; /** Normalize line endings before applying deterministic line-based formatting. */ export function normalizeLineEndings(content: string): string { @@ -191,7 +196,8 @@ export function formatSemanticFence( lines: readonly string[], start: number, fence: Fence, - formatFn: (content: string) => string, + formatFn: (content: string, options?: FormatOptions) => string, + options?: FormatOptions, ): { lines: string[]; next: number } | null { if (!fence.language || !semanticFenceLanguages.has(fence.language)) { return null; @@ -204,7 +210,10 @@ export function formatSemanticFence( } if (closingIndex >= lines.length) return null; - const inner = formatFn(lines.slice(start + 1, closingIndex).join('\n')); + const inner = formatFn( + lines.slice(start + 1, closingIndex).join('\n'), + options, + ); return { lines: [ lines[start]!, diff --git a/packages/markdown-formatter/src/constants.ts b/packages/markdown-formatter/src/constants.ts index 5b7abfe..c4ac029 100644 --- a/packages/markdown-formatter/src/constants.ts +++ b/packages/markdown-formatter/src/constants.ts @@ -1,5 +1,7 @@ // @fuyeor/markdown-formatter/src/constants.ts // NOTE: Avoid medieval SCREAMING_SNAKE_CASE; use camelCase for modern readability. +import type { FormatOptions } from './types'; + export const cjkCharacter = '\\p{Script=Han}'; export const latinOrDigit = 'A-Za-z0-9'; export const cjkLatinBoundary = new RegExp( @@ -14,3 +16,8 @@ export const semanticFenceLanguages = new Set([ 'chain', 'accordion', ]); + +/** Default formatting configuration options. */ +export const defaultFormatOptions: Readonly> = { + maxConsecutiveBlankLines: 1, +}; diff --git a/packages/markdown-formatter/src/index.spec.ts b/packages/markdown-formatter/src/index.spec.ts index 689312e..91912d7 100644 --- a/packages/markdown-formatter/src/index.spec.ts +++ b/packages/markdown-formatter/src/index.spec.ts @@ -15,3 +15,15 @@ describe('format fixtures', () => { expect(() => format(null as unknown as string)).toThrow(TypeError); }); }); + +it('respects maxConsecutiveBlankLines configuration', () => { + const source = 'First\n\n\n\nSecond'; + // Default (1) + expect(format(source)).toBe('First\n\nSecond'); + // Allows 2 blank lines + expect(format(source, { maxConsecutiveBlankLines: 2 })).toBe( + 'First\n\n\nSecond', + ); + // Compact (0) + expect(format(source, { maxConsecutiveBlankLines: 0 })).toBe('First\nSecond'); +}); diff --git a/packages/markdown-formatter/src/index.ts b/packages/markdown-formatter/src/index.ts index 6864cd9..0177602 100644 --- a/packages/markdown-formatter/src/index.ts +++ b/packages/markdown-formatter/src/index.ts @@ -9,7 +9,8 @@ import { normalizeLineEndings, trimDocumentBoundary, } from './blocks'; -import type { Fence, ListIndentContext } from './types'; +import { defaultFormatOptions } from './constants'; +import type { Fence, FormatOptions, ListIndentContext } from './types'; export * from './blocks'; export * from './constants'; @@ -17,9 +18,19 @@ export * from './text'; export * from './types'; /** Format a complete FFM document according to the editor's canonical style. */ -export function format(content: string): string { +export function format(content: string, options?: FormatOptions): string { if (typeof content !== 'string') throw new TypeError('content must be a string'); + + const maxBlank = + options?.maxConsecutiveBlankLines ?? + defaultFormatOptions.maxConsecutiveBlankLines; + + if (!Number.isInteger(maxBlank) || maxBlank < 0) + throw new TypeError( + 'maxConsecutiveBlankLines must be a non-negative integer', + ); + const lines = normalizeLineEndings(content).split('\n'); const formatted: string[] = []; const listContext: ListIndentContext = { levels: [0] }; @@ -35,7 +46,16 @@ export function format(content: string): string { } if (line.trim() === '') { - if (formatted.at(-1) !== '') formatted.push(''); + let trailingEmpty = 0; + for ( + let cursor = formatted.length - 1; + cursor >= 0 && formatted[cursor] === ''; + cursor-- + ) { + trailingEmpty++; + } + // Whether to retain blank lines depends on the maximum allowed number of blank lines. + if (trailingEmpty < maxBlank) formatted.push(''); index++; continue; } @@ -47,6 +67,7 @@ export function format(content: string): string { index, openingFence, format, + options, ); if (semanticFence) { formatted.push(...semanticFence.lines); diff --git a/packages/markdown-formatter/src/types.ts b/packages/markdown-formatter/src/types.ts index 89d0f6d..020e910 100644 --- a/packages/markdown-formatter/src/types.ts +++ b/packages/markdown-formatter/src/types.ts @@ -13,3 +13,9 @@ export type QuoteLine = { export type ListIndentContext = { levels: number[]; }; + +/** Configuration options for the Markdown formatter. */ +export type FormatOptions = { + /** Maximum allowable consecutive blank lines between blocks. @default 1 */ + maxConsecutiveBlankLines?: number; +}; From 2b3f7ed1a7e23eec4655d7f8cf4128f460bbbb81 Mon Sep 17 00:00:00 2001 From: Fuyeor Date: Sun, 30 Aug 2026 20:26:34 +0000 Subject: [PATCH 5/5] feat: Add options to html2ffm and fix code style --- packages/html2ffm/README.md | 5 ++ packages/html2ffm/src/color.ts | 4 +- packages/html2ffm/src/constants.ts | 16 +++---- .../html2ffm/src/fixtures/conversions.json | 5 -- packages/html2ffm/src/index.spec.ts | 22 +++++++++ packages/html2ffm/src/index.ts | 6 +-- packages/html2ffm/src/render.ts | 48 +++++++++---------- packages/html2ffm/src/style.ts | 4 +- packages/html2ffm/src/types.ts | 4 ++ packages/html2ffm/test.ts | 10 ++-- 10 files changed, 73 insertions(+), 51 deletions(-) diff --git a/packages/html2ffm/README.md b/packages/html2ffm/README.md index 67edb72..2810896 100644 --- a/packages/html2ffm/README.md +++ b/packages/html2ffm/README.md @@ -21,6 +21,11 @@ console.log(output); // # Hello // // **World** + +// keep blank lines: +const html = '

First

Second

'; +const result = toFFM(html, { maxConsecutiveBlankLines: 4 }); +// First\n\n\n\n\nSecond ``` `toFFM` accepts an HTML fragment and returns a formatted string. It does not fetch external resources, execute scripts, or read stylesheets. diff --git a/packages/html2ffm/src/color.ts b/packages/html2ffm/src/color.ts index 5908246..d8bb0b0 100644 --- a/packages/html2ffm/src/color.ts +++ b/packages/html2ffm/src/color.ts @@ -1,5 +1,5 @@ // @fuyeor/html2ffm/src/color.ts -import { CSS_NAMED_COLORS } from './constants'; +import { cssNamedColors } from './constants'; import type { Rgba } from './types'; // Convert one clamped color channel to a two-digit lowercase hexadecimal value. @@ -143,7 +143,7 @@ export function parseColor(value: string): string | null { const normalized = value.trim().toLowerCase(); if (!normalized || normalized.includes('var(')) return null; - const named = CSS_NAMED_COLORS[normalized]; + const named = cssNamedColors[normalized]; if (named) return named; if (normalized === 'transparent') return null; diff --git a/packages/html2ffm/src/constants.ts b/packages/html2ffm/src/constants.ts index 985f572..c40d15d 100644 --- a/packages/html2ffm/src/constants.ts +++ b/packages/html2ffm/src/constants.ts @@ -1,7 +1,7 @@ // @fuyeor/html2ffm/src/constants.ts import type { Marks } from './types'; -export const BLOCK_TAGS = new Set([ +export const blockElements = new Set([ 'article', 'aside', 'blockquote', @@ -24,27 +24,27 @@ export const BLOCK_TAGS = new Set([ 'ul', ]); -export const DROPPED_TAGS = new Set([ +export const droppedElements = new Set([ 'math', 'script', 'style', 'svg', 'template', ]); -export const HEADING_PATTERN = /^h([1-6])$/u; -export const CSS_LENGTH_PATTERN = +export const headingPattern = /^h([1-6])$/u; +export const cssLengthPattern = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%|pt|pc|in|cm|mm|q|ch|ex|cap|ic|lh|rlh|vw|vh|vmin|vmax|svw|svh|lvw|lvh|dvw|dvh|vi|vb)$/iu; -export const SAFE_SCHEME_PATTERN = /^(?:https?:|mailto:|tel:|ftp:)/iu; -export const DANGEROUS_URL_PATTERN = /^(?:javascript:|data:|vbscript:|file:)/iu; +export const safeSchemePattern = /^(?:https?:|mailto:|tel:|ftp:)/iu; +export const unsafeSchemePattern = /^(?:javascript:|data:|vbscript:|file:)/iu; -export const EMPTY_MARKS: Marks = { +export const emptyMarks: Marks = { bold: false, italic: false, underline: false, strike: false, }; -export const CSS_NAMED_COLORS: Readonly> = { +export const cssNamedColors: Readonly> = { aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', diff --git a/packages/html2ffm/src/fixtures/conversions.json b/packages/html2ffm/src/fixtures/conversions.json index 26ed303..5794e89 100644 --- a/packages/html2ffm/src/fixtures/conversions.json +++ b/packages/html2ffm/src/fixtures/conversions.json @@ -31,11 +31,6 @@ "desc": "long blockquote (3 lines or more)", "text": "

One

Two

Three

", "expect": "```quote\nOne\n\nTwo\n\nThree\n```" - }, - { - "desc": "`

` blockquote", - "text": "

Line1

Line2

Line3

", - "expect": "```quote\nLine1\n\nLine2\n\n\n\nLine3\n```" } ], "paragraphs and headings": [ diff --git a/packages/html2ffm/src/index.spec.ts b/packages/html2ffm/src/index.spec.ts index 00491b6..afe858d 100644 --- a/packages/html2ffm/src/index.spec.ts +++ b/packages/html2ffm/src/index.spec.ts @@ -1,4 +1,5 @@ // @fuyeor/html2ffm/src/index.spec.ts +// pnpm --filter @fuyeor/html2ffm test import { describe, expect, it } from 'vitest'; import { toFFM } from './index'; import fixtures from './fixtures/conversions.json' with { type: 'json' }; @@ -22,3 +23,24 @@ describe('toFFM input validation', () => { ); }); }); + +describe('toFFM options', () => { + it('respects maxConsecutiveBlankLines option for empty paragraphs and blockquotes', () => { + const html = '

First

Second

'; + + // default (keep 1 blank lines) + expect(toFFM(html)).toBe('First\n\nSecond'); + + // keep 4 blank lines + expect(toFFM(html, { maxConsecutiveBlankLines: 4 })).toBe( + 'First\n\n\n\n\nSecond', + ); + + // keep 4 blank lines within blockquote + const quoteHtml = + '

Line1

Line2

Line3

'; + expect(toFFM(quoteHtml, { maxConsecutiveBlankLines: 2 })).toBe( + '```quote\nLine1\n\n\nLine2\n\nLine3\n```', + ); + }); +}); diff --git a/packages/html2ffm/src/index.ts b/packages/html2ffm/src/index.ts index ee5af1a..2633e6e 100644 --- a/packages/html2ffm/src/index.ts +++ b/packages/html2ffm/src/index.ts @@ -2,7 +2,7 @@ import { format } from '@fuyeor/markdown-formatter'; import { parseDocument } from 'htmlparser2'; import { renderFlow } from './render'; -import type { ParsedDocument } from './types'; +import type { ParsedDocument, ToFFMOptions } from './types'; export * from './color'; export * from './constants'; @@ -11,7 +11,7 @@ export * from './style'; export * from './types'; /** Convert an HTML fragment into formatted Fuyeor Flavored Markdown. */ -export function toFFM(input: string): string { +export function toFFM(input: string, options?: ToFFMOptions): string { if (typeof input !== 'string') throw new TypeError('Input must be a string'); let document: ParsedDocument; @@ -26,6 +26,6 @@ export function toFFM(input: string): string { } const rendered = renderFlow(document.children, {}); - const formatted = format(rendered); + const formatted = format(rendered, options); return formatted.replace(/^\n+|\n+$/gu, ''); } diff --git a/packages/html2ffm/src/render.ts b/packages/html2ffm/src/render.ts index 45dbc28..a063b3c 100644 --- a/packages/html2ffm/src/render.ts +++ b/packages/html2ffm/src/render.ts @@ -1,11 +1,11 @@ // @fuyeor/html2ffm/src/render.ts import { - BLOCK_TAGS, - DANGEROUS_URL_PATTERN, - DROPPED_TAGS, - EMPTY_MARKS, - HEADING_PATTERN, - SAFE_SCHEME_PATTERN, + blockElements, + unsafeSchemePattern, + droppedElements, + emptyMarks, + headingPattern, + safeSchemePattern, } from './constants'; import { cloneMarks, @@ -35,11 +35,11 @@ export function isTextNode(node: ChildNode): node is TextNode { } export function isDroppedElement(element: ElementNode): boolean { - return DROPPED_TAGS.has(element.name); + return droppedElements.has(element.name); } export function isBlockElement(element: ElementNode): boolean { - if (BLOCK_TAGS.has(element.name)) return true; + if (blockElements.has(element.name)) return true; return element.children.some( (child) => isElement(child) && isBlockElement(child), ); @@ -195,13 +195,7 @@ export function renderInlineNode( { content: `${fence}${code}${fence}`, style: cloneStyle(nextStyle), - marks: { - ...nextMarks, - bold: false, - italic: false, - underline: false, - strike: false, - }, + marks: { ...nextMarks }, }, ]; } @@ -224,8 +218,8 @@ export function renderInlineNode( const isSafeUrl = normalizedHref !== undefined && normalizedHref !== '' && - !DANGEROUS_URL_PATTERN.test(normalizedHref) && - (!hasScheme || SAFE_SCHEME_PATTERN.test(normalizedHref)); + !unsafeSchemePattern.test(normalizedHref) && + (!hasScheme || safeSchemePattern.test(normalizedHref)); if (isSafeUrl) nextMarks = cloneMarks(nextMarks, { link: href }); } @@ -365,16 +359,13 @@ export function renderBlockElement(element: ElementNode, style: Style): string { return renderList(element, nextStyle, 0); if (element.name === 'table') return renderTable(element, nextStyle); if (element.name === 'blockquote') { - // ✨ 规范化空行(保留最多双换行段落结构) const rawContent = stripBoundaryNewlines( renderFlow(element.children, nextStyle), - ) - .replace(/\n{3,}/gu, '\n\n') - .trim(); + ).trim(); if (!rawContent) return ''; const textLines = rawContent.split(/\n+/u); if (textLines.length >= 3) { - return `\`\`\`quote\n${rawContent}\n\`\`\`\n\n`; // ✨ 完整保留段落原本的换行结构 + return `\`\`\`quote\n${rawContent}\n\`\`\`\n\n`; } const quoted = rawContent .split('\n') @@ -383,12 +374,12 @@ export function renderBlockElement(element: ElementNode, style: Style): string { return `${quoted}\n\n`; } - const headingMatch = element.name.match(HEADING_PATTERN); + const headingMatch = element.name.match(headingPattern); if (headingMatch) { const content = renderInlineContent( element.children, nextStyle, - EMPTY_MARKS, + emptyMarks, ).trim(); return content ? `${'#'.repeat(Number(headingMatch[1]))} ${content}\n\n` @@ -398,6 +389,11 @@ export function renderBlockElement(element: ElementNode, style: Style): string { const content = stripBoundaryNewlines( renderFlow(element.children, nextStyle), ).trim(); + + // When encountering empty paragraphs (such as

or only newline spaces) + // preserve the blank lines and hand them over to the downstream formatter for scheduling + if (element.name === 'p' && !content) return '\n\n'; + return content ? `${content}\n\n` : ''; } @@ -413,7 +409,7 @@ export function renderFlow(nodes: readonly ChildNode[], style: Style): string { for (const node of nodes) { if (isTextNode(node)) { if (/^\s+$/u.test(node.data) && node.data.includes('\n')) continue; - inlinePieces.push(...renderInlineNode(node, style, EMPTY_MARKS)); + inlinePieces.push(...renderInlineNode(node, style, emptyMarks)); continue; } if (!isElement(node) || isDroppedElement(node)) continue; @@ -421,7 +417,7 @@ export function renderFlow(nodes: readonly ChildNode[], style: Style): string { flushInline(); output += renderBlockElement(node, style); } else { - inlinePieces.push(...renderInlineNode(node, style, EMPTY_MARKS)); + inlinePieces.push(...renderInlineNode(node, style, emptyMarks)); } } flushInline(); diff --git a/packages/html2ffm/src/style.ts b/packages/html2ffm/src/style.ts index 85dbee4..c6ee144 100644 --- a/packages/html2ffm/src/style.ts +++ b/packages/html2ffm/src/style.ts @@ -1,11 +1,11 @@ // @fuyeor/html2ffm/src/style.ts import { isTransparentColor, parseColor } from './color'; -import { CSS_LENGTH_PATTERN } from './constants'; +import { cssLengthPattern } from './constants'; import type { Marks, Style } from './types'; export function parseFontSize(value: string): string | null { const normalized = value.trim().replace(/\s*!important\s*$/iu, ''); - return CSS_LENGTH_PATTERN.test(normalized) ? normalized : null; + return cssLengthPattern.test(normalized) ? normalized : null; } // Parse supported inline declarations while preserving CSS last-valid semantics. diff --git a/packages/html2ffm/src/types.ts b/packages/html2ffm/src/types.ts index 2defffc..d265d99 100644 --- a/packages/html2ffm/src/types.ts +++ b/packages/html2ffm/src/types.ts @@ -1,4 +1,5 @@ // @fuyeor/html2ffm/src/types.ts +import type { FormatOptions } from '@fuyeor/markdown-formatter'; import type { parseDocument } from 'htmlparser2'; export type ParsedDocument = ReturnType; @@ -45,3 +46,6 @@ export type TableRow = { cells: ElementNode[]; isHeader: boolean; }; + +/** Options for HTML to FFM conversion. */ +export type ToFFMOptions = FormatOptions; diff --git a/packages/html2ffm/test.ts b/packages/html2ffm/test.ts index 4843702..3310bb8 100644 --- a/packages/html2ffm/test.ts +++ b/packages/html2ffm/test.ts @@ -3,15 +3,15 @@ import { toFFM } from './src/index'; // HTML snippet wanted to test -const inputHtml = `

Hello

`; +const inputHtml = ` +

Hello

+`; console.log('🟥 HTML'); // HTML snippet wanted to test -console.log(` -

Hello

-`); +console.log(inputHtml); console.log('🟪 Fuyeor Flavored Markdown\n'); -console.log(`${toFFM(inputHtml)}\n`); +console.log(`${toFFM(inputHtml, { maxConsecutiveBlankLines: 4 })}\n`);