diff --git a/app/lib/mews/parser.dart b/app/lib/mews/parser.dart index 6d288b7..9863dcc 100644 --- a/app/lib/mews/parser.dart +++ b/app/lib/mews/parser.dart @@ -155,23 +155,7 @@ void _appendBlocks(XmlElement element, String pageUrl, List out) { out.add(ParagraphBlock(_inline(element, pageUrl, embedded))); out.addAll(embedded); case 'blockquote': - final children = []; - final embedded = []; - // Bare inline content (quotes are often written as plain text) becomes - // a paragraph, like in list items. - final inline = []; - 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)); + out.add(QuoteBlock(_mixedBlocks(element, pageUrl))); case 'pre': out.add(PreBlock(element.innerText)); case 'address': @@ -212,27 +196,42 @@ const _inlineElements = { 'cite', 'abbr', 'dfn', 'img', }; +/// Builds blocks from mixed content: consecutive inline runs (text, links, +/// images) become paragraphs, block-level children append as blocks. Runs +/// that are nothing but markup whitespace never produce a paragraph, so +/// pretty-printed quotes and list items start without a blank line. +List _mixedBlocks(XmlElement element, String pageUrl) { + final blocks = []; + final embedded = []; + final inline = []; + + 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 || + (node is XmlElement && _inlineElements.contains(node.name.qualified)); + if (isInlineNode) { + _appendInline(node, pageUrl, inline, embedded); + } else if (node is XmlElement) { + flush(); + _appendBlocks(node, pageUrl, blocks); + } + } + flush(); + blocks.addAll(embedded); + return blocks; +} + ListBlock _listBlock(XmlElement element, String pageUrl) { final items = []; for (final li in element.findElements('li')) { - final blocks = []; - final embedded = []; - // 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 = []; - for (final node in li.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, blocks); - } - } - if (inline.isNotEmpty) blocks.insert(0, ParagraphBlock(inline)); - blocks.addAll(embedded); - items.add(MewsListItem(blocks)); + items.add(MewsListItem(_mixedBlocks(li, pageUrl))); } return ListBlock( ordered: element.name.qualified == 'ol', @@ -395,18 +394,54 @@ MewsFormField _inputField(XmlElement element, String? label) { // 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 _inline(XmlElement element, String pageUrl, [List? embedded]) { final out = []; for (final child in element.children) { _appendInline(child, pageUrl, out, embedded); } + _dropEdgeWhitespace(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 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 out, [List? embedded]) { if (node is XmlText) { - final text = node.value; + final text = node.value.replaceAll(_whitespaceRun, ' '); if (text.isNotEmpty) out.add(MewsText(text)); return; } diff --git a/app/test/parser_test.dart b/app/test/parser_test.dart index 4e6039c..630ab8e 100644 --- a/app/test/parser_test.dart +++ b/app/test/parser_test.dart @@ -165,6 +165,47 @@ void main() { expect(text, contains('Visible & intact.')); }); + test('pretty-printed markup leaves no phantom blank content', () { + final source = ''' + + + Indent + +
+

No blank line above this.

+

And none between paragraphs.

+
+ +

+ A paragraph with + wrapped lines. +

+ + +'''; + 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()); + + // 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', () { final source = '''