fix: collapse markup whitespace so blockquotes render without blank lines

This commit is contained in:
randogoth 2026-10-11 10:43:12 +03:00
parent 204aee899a
commit 4e07c9a305
2 changed files with 112 additions and 36 deletions

View file

@ -155,23 +155,7 @@ void _appendBlocks(XmlElement element, String pageUrl, List<MewsBlock> out) {
out.add(ParagraphBlock(_inline(element, pageUrl, embedded))); out.add(ParagraphBlock(_inline(element, pageUrl, embedded)));
out.addAll(embedded); out.addAll(embedded);
case 'blockquote': case 'blockquote':
final children = <MewsBlock>[]; out.add(QuoteBlock(_mixedBlocks(element, pageUrl)));
final embedded = <MewsBlock>[];
// Bare inline content (quotes are often written as plain text) becomes
// a paragraph, like in list items.
final inline = <MewsInline>[];
for (final node in element.children) {
final isInlineNode = node is XmlText ||
(node is XmlElement && _inlineElements.contains(node.name.qualified));
if (isInlineNode) {
_appendInline(node, pageUrl, inline, embedded);
} else if (node is XmlElement) {
_appendBlocks(node, pageUrl, children);
}
}
if (inline.isNotEmpty) children.insert(0, ParagraphBlock(inline));
children.addAll(embedded);
out.add(QuoteBlock(children));
case 'pre': case 'pre':
out.add(PreBlock(element.innerText)); out.add(PreBlock(element.innerText));
case 'address': case 'address':
@ -212,27 +196,42 @@ const _inlineElements = {
'cite', 'abbr', 'dfn', 'img', 'cite', 'abbr', 'dfn', 'img',
}; };
ListBlock _listBlock(XmlElement element, String pageUrl) { /// Builds blocks from mixed content: consecutive inline runs (text, links,
final items = <MewsListItem>[]; /// images) become paragraphs, block-level children append as blocks. Runs
for (final li in element.findElements('li')) { /// that are nothing but markup whitespace never produce a paragraph, so
/// pretty-printed quotes and list items start without a blank line.
List<MewsBlock> _mixedBlocks(XmlElement element, String pageUrl) {
final blocks = <MewsBlock>[]; final blocks = <MewsBlock>[];
final embedded = <MewsBlock>[]; final embedded = <MewsBlock>[];
// List items commonly hold bare inline content (the dated-link entry
// pattern in spec 6.1), so direct text and inline elements form a
// paragraph while block-level children append as blocks.
final inline = <MewsInline>[]; final inline = <MewsInline>[];
for (final node in li.children) {
void flush() {
_dropEdgeWhitespace(inline);
if (inline.isNotEmpty) {
blocks.add(ParagraphBlock(List.of(inline)));
inline.clear();
}
}
for (final node in element.children) {
final isInlineNode = node is XmlText || final isInlineNode = node is XmlText ||
(node is XmlElement && _inlineElements.contains(node.name.qualified)); (node is XmlElement && _inlineElements.contains(node.name.qualified));
if (isInlineNode) { if (isInlineNode) {
_appendInline(node, pageUrl, inline, embedded); _appendInline(node, pageUrl, inline, embedded);
} else if (node is XmlElement) { } else if (node is XmlElement) {
flush();
_appendBlocks(node, pageUrl, blocks); _appendBlocks(node, pageUrl, blocks);
} }
} }
if (inline.isNotEmpty) blocks.insert(0, ParagraphBlock(inline)); flush();
blocks.addAll(embedded); blocks.addAll(embedded);
items.add(MewsListItem(blocks)); return blocks;
}
ListBlock _listBlock(XmlElement element, String pageUrl) {
final items = <MewsListItem>[];
for (final li in element.findElements('li')) {
items.add(MewsListItem(_mixedBlocks(li, pageUrl)));
} }
return ListBlock( return ListBlock(
ordered: element.name.qualified == 'ol', ordered: element.name.qualified == 'ol',
@ -395,18 +394,54 @@ MewsFormField _inputField(XmlElement element, String? label) {
// Inline --------------------------------------------------------------------- // Inline ---------------------------------------------------------------------
/// Pretty-printed markup leaves whitespace runs between tags; browsers treat
/// them as a single space, and whitespace-only runs at the edges of inline
/// content as nothing at all.
final _whitespaceRun = RegExp(r'\s+');
List<MewsInline> _inline(XmlElement element, String pageUrl, [List<MewsBlock>? embedded]) { List<MewsInline> _inline(XmlElement element, String pageUrl, [List<MewsBlock>? embedded]) {
final out = <MewsInline>[]; final out = <MewsInline>[];
for (final child in element.children) { for (final child in element.children) {
_appendInline(child, pageUrl, out, embedded); _appendInline(child, pageUrl, out, embedded);
} }
_dropEdgeWhitespace(out);
return out; return out;
} }
/// Trims markup whitespace from the edges of a run: whitespace-only nodes are
/// dropped, and the first/last text node loses its leading/trailing spaces,
/// so indented markup renders no blank first or last lines. Interior
/// whitespace stays: it separates inline elements the author typed apart.
void _dropEdgeWhitespace(List<MewsInline> out) {
bool blank(MewsInline node) => node is MewsText && node.text.trim().isEmpty;
while (out.isNotEmpty && blank(out.first)) {
out.removeAt(0);
}
while (out.isNotEmpty && blank(out.last)) {
out.removeLast();
}
if (out.isNotEmpty && out.first is MewsText) {
final text = (out.first as MewsText).text.trimLeft();
if (text.isEmpty) {
out.removeAt(0);
} else {
out[0] = MewsText(text);
}
}
if (out.isNotEmpty && out.last is MewsText) {
final text = (out.last as MewsText).text.trimRight();
if (text.isEmpty) {
out.removeLast();
} else {
out[out.length - 1] = MewsText(text);
}
}
}
void _appendInline(XmlNode node, String pageUrl, List<MewsInline> out, void _appendInline(XmlNode node, String pageUrl, List<MewsInline> out,
[List<MewsBlock>? embedded]) { [List<MewsBlock>? embedded]) {
if (node is XmlText) { if (node is XmlText) {
final text = node.value; final text = node.value.replaceAll(_whitespaceRun, ' ');
if (text.isNotEmpty) out.add(MewsText(text)); if (text.isNotEmpty) out.add(MewsText(text));
return; return;
} }

View file

@ -165,6 +165,47 @@ void main() {
expect(text, contains('Visible & intact.')); expect(text, contains('Visible & intact.'));
}); });
test('pretty-printed markup leaves no phantom blank content', () {
final source = '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>Indent</title><meta name="mews-profile" content="0.1" /></head>
<body>
<blockquote>
<p>No blank line above this.</p>
<p>And none between paragraphs.</p>
</blockquote>
<ul>
<li>
<a href="2026-10-08-post.html">2026-10-08 A dated link</a>
</li>
</ul>
<p>
A paragraph with
wrapped lines.
</p>
</body>
</html>
''';
final page = parseMewsPage(source: source, url: 'https://example.com/indent.html');
// The quote holds its two paragraphs and nothing else.
final quote = page.body[0] as QuoteBlock;
expect(quote.children, hasLength(2));
expect((quote.children.first as ParagraphBlock).inline.single,
isA<MewsText>());
// The list item starts directly with the link, no blank run before it.
final list = page.body[1] as ListBlock;
final item = (list.items.single.blocks.single as ParagraphBlock).inline;
final link = item.single as MewsLink;
expect((link.children.single as MewsText).text, '2026-10-08 A dated link');
// Interior line breaks collapse to single spaces, like in browsers.
final paragraph = (page.body[2] as ParagraphBlock).inline;
expect((paragraph.single as MewsText).text, 'A paragraph with wrapped lines.');
});
test('keeps bare inline content in list items and quotes', () { test('keeps bare inline content in list items and quotes', () {
final source = ''' final source = '''
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>