From bc3e9c23f0284ba1777fd767712317ceacfb748d Mon Sep 17 00:00:00 2001 From: SecPan Date: Thu, 16 Jul 2026 04:52:03 -0700 Subject: [PATCH 1/4] Port upstream 4844: keep soft line breaks on export via CSS --- .../muya/src/assets/styles/exportStyle.css | 16 ++++++++ .../__tests__/softBreakExportHtml.spec.ts | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts diff --git a/third_party/muya/src/assets/styles/exportStyle.css b/third_party/muya/src/assets/styles/exportStyle.css index e362724..333faba 100644 --- a/third_party/muya/src/assets/styles/exportStyle.css +++ b/third_party/muya/src/assets/styles/exportStyle.css @@ -89,6 +89,22 @@ white-space: pre-wrap; } +/* Render soft line breaks (Shift+Enter → a bare `\n` inside a block) the way + the editor does (`.mu-content` is pre-wrap) instead of emitting a + non-standard `
`, so the exported HTML stays CommonMark-conformant (the + `\n` is a plain line ending) yet still shows the break (marktext#3676). + + `li:not(:has(> p))` targets only tight list items, whose soft break is a + bare `\n` directly inside the `
  • `. Loose items wrap their content in + `

    ` (handled by the `p` rule); giving them `li` pre-wrap would expose + marked's pretty-printing newline between `

    ` and `
  • ` as a stray blank + line, so they are deliberately excluded. Specificity stays level with the + earlier `.toc-container ul li` rule (no-descending-specificity). */ +.markdown-body p, +.markdown-body li:not(:has(> p)) { + white-space: pre-wrap; +} + .markdown-body table { display: table; } diff --git a/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts b/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts new file mode 100644 index 0000000..81fc69f --- /dev/null +++ b/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { getHighlightHtml } from '../../utils/marked'; + +// marktext#3676 — a soft line break (Shift+Enter, serialized as a bare `\n` +// inside a block) shows as a line break in the editor (`.mu-content` is +// pre-wrap) but was lost on export because marked renders a soft break as a +// space. Rather than emit a non-standard `
    ` (which CommonMark reserves for +// hard breaks), the export keeps the conformant `\n` and renders it with +// `white-space: pre-wrap` on `.markdown-body p` and `li:not(:has(> p))` +// (tight items only). +// +// These assert the HTML stays conformant — the soft break is a preserved +// newline, never a `
    `, and hard breaks are untouched. The pre-wrap +// rendering itself (and the `:has()` exclusion of loose items) is a CSS +// concern verified in a real browser against the export stylesheet. + +const OPTS = { math: false, superSubScript: false, footnote: false, frontMatter: false }; + +describe('#3676 — soft line breaks survive export as a conformant newline', () => { + it('keeps a paragraph soft break as a newline, never a
    ', () => { + const html = getHighlightHtml('line one\nline two', OPTS); + expect(html).toContain('

    line one\nline two

    '); + expect(html).not.toMatch(//); + }); + + it('keeps a soft break inside a tight list item, never a
    ', () => { + const html = getHighlightHtml('- line A\n line B', OPTS); + expect(html).toMatch(/
  • line A\nline B<\/li>/); + expect(html).not.toMatch(//); + }); + + it('leaves a real hard break (two trailing spaces) as
    ', () => { + // Sanity: the change only touches soft breaks; hard breaks are untouched. + const html = getHighlightHtml('line one \nline two', OPTS); + expect(html).toMatch(//); + }); +}); From 8ebbff110de254a0b5081d653a9407a88e46388a Mon Sep 17 00:00:00 2001 From: SecPan Date: Thu, 16 Jul 2026 16:43:54 -0700 Subject: [PATCH 2/4] Address review: strip marked's formatting whitespace in pre-wrap list contexts --- tests/e2e/export-soft-break-layout.spec.ts | 74 +++++++++++++++++++ .../__tests__/softBreakExportHtml.spec.ts | 51 ++++++++++++- third_party/muya/src/state/markdownToHtml.ts | 41 ++++++++++ 3 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/export-soft-break-layout.spec.ts diff --git a/tests/e2e/export-soft-break-layout.spec.ts b/tests/e2e/export-soft-break-layout.spec.ts new file mode 100644 index 0000000..28954ea --- /dev/null +++ b/tests/e2e/export-soft-break-layout.spec.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs' +import { expect, test, type Page } from '@playwright/test' + +// Browser layout coverage for the #3676 export soft-break fix (PR review): +// the export stylesheet renders tight list items `white-space: pre-wrap`, and +// `white-space` inherits — so marked's formatting-only newlines around nested +// block children would each render as a visible empty line box. The export +// pipeline strips that serializer whitespace (markdownToHtml, +// `_stripListFormattingWhitespace`; shape pinned by +// third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts). This +// spec closes the loop in a real Chromium: the stylesheet plus the pipeline's +// output shape lay out without phantom blank rows, while authored soft +// breaks still render as real line breaks. + +// Playwright runs from the repo root; the config's testDir keeps that cwd. +const exportCss = readFileSync('third_party/muya/src/assets/styles/exportStyle.css', 'utf8') + +async function measureListItemHeight(page: Page, listInner: string, extraCss = '') { + await page.setContent( + `` + + `
      ${listInner}
    `, + ) + return page.evaluate( + () => document.querySelector('.markdown-body > ul > li')!.getBoundingClientRect().height, + ) +} + +test('an authored soft break in a tight item renders as a real second line', async ({ page }) => { + const single = await measureListItemHeight(page, '
  • line A
  • ') + const soft = await measureListItemHeight(page, '
  • line A\nline B
  • ') + + expect(soft).toBeGreaterThan(single * 1.8) + expect(soft).toBeLessThan(single * 2.6) +}) + +test('the cleaned nested-list shape adds no phantom blank rows', async ({ page }) => { + // Exactly what the export pipeline emits for `- line A\n - child`. + const cleaned = '
  • line A
    • child
  • ' + const withPreWrap = await measureListItemHeight(page, cleaned) + const control = await measureListItemHeight( + page, + cleaned, + '.markdown-body li { white-space: normal !important; }', + ) + + expect(withPreWrap).toBe(control) +}) + +test("marked's uncleaned serializer newlines DO create blank rows (premise)", async ({ page }) => { + // marked's raw serialization for the same markdown — the shape the + // pipeline strip exists to prevent. If this stops failing tall, the strip + // (and this suite) can be reconsidered. + const raw = '
  • line A\n
      \n
    • child
    • \n
    \n
  • ' + const withPreWrap = await measureListItemHeight(page, raw) + const control = await measureListItemHeight( + page, + raw, + '.markdown-body li { white-space: normal !important; }', + ) + + expect(withPreWrap).toBeGreaterThan(control) +}) + +test('the cleaned nested-blockquote shape adds no phantom blank rows', async ({ page }) => { + const cleaned = '
  • line A

    quoted

  • ' + const withPreWrap = await measureListItemHeight(page, cleaned) + const control = await measureListItemHeight( + page, + cleaned, + '.markdown-body li { white-space: normal !important; }', + ) + + expect(withPreWrap).toBe(control) +}) diff --git a/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts b/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts index 81fc69f..1817e7f 100644 --- a/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts +++ b/third_party/muya/src/state/__tests__/softBreakExportHtml.spec.ts @@ -1,4 +1,7 @@ +// @vitest-environment happy-dom + import { describe, expect, it } from 'vitest'; +import { MarkdownToHtml } from '../markdownToHtml'; import { getHighlightHtml } from '../../utils/marked'; // marktext#3676 — a soft line break (Shift+Enter, serialized as a bare `\n` @@ -9,10 +12,13 @@ import { getHighlightHtml } from '../../utils/marked'; // `white-space: pre-wrap` on `.markdown-body p` and `li:not(:has(> p))` // (tight items only). // -// These assert the HTML stays conformant — the soft break is a preserved -// newline, never a `
    `, and hard breaks are untouched. The pre-wrap -// rendering itself (and the `:has()` exclusion of loose items) is a CSS -// concern verified in a real browser against the export stylesheet. +// Because `white-space` inherits, marked's pretty-printing newlines around +// nested block children (`line A\n