fix: collapse markup whitespace so blockquotes render without blank lines
This commit is contained in:
parent
204aee899a
commit
4e07c9a305
2 changed files with 112 additions and 36 deletions
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// 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<MewsBlock> _mixedBlocks(XmlElement element, String pageUrl) {
|
||||||
|
final blocks = <MewsBlock>[];
|
||||||
|
final embedded = <MewsBlock>[];
|
||||||
|
final inline = <MewsInline>[];
|
||||||
|
|
||||||
|
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) {
|
ListBlock _listBlock(XmlElement element, String pageUrl) {
|
||||||
final items = <MewsListItem>[];
|
final items = <MewsListItem>[];
|
||||||
for (final li in element.findElements('li')) {
|
for (final li in element.findElements('li')) {
|
||||||
final blocks = <MewsBlock>[];
|
items.add(MewsListItem(_mixedBlocks(li, pageUrl)));
|
||||||
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>[];
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"?>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue