From 00e42c925c4ab427fa8f5bf604e9144779bdfb4d Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 12 Oct 2026 11:13:34 +0300 Subject: [PATCH] feat: offer a non-Mews web page as a simplified view or in the browser --- SPEC.md | 2 + app/android/app/src/main/AndroidManifest.xml | 6 + app/lib/application/actors/fetch_actor.dart | 42 ++- .../controllers/browser_controller.dart | 14 +- app/lib/mews/html.dart | 128 +++++++++ app/lib/mews/parser.dart | 72 +++-- .../presentation/reader/mews_page_view.dart | 131 ++++++--- .../presentation/reader/reader_screen.dart | 75 +++-- app/locales/de/main.ftl | 7 +- app/locales/en-US/main.ftl | 7 +- app/locales/es/main.ftl | 7 +- app/locales/fr/main.ftl | 7 +- app/locales/he/main.ftl | 7 +- app/locales/ja/main.ftl | 7 +- app/locales/pt/main.ftl | 7 +- app/locales/ru/main.ftl | 7 +- app/pubspec.lock | 16 ++ app/pubspec.yaml | 1 + app/test/app_widget_test.dart | 67 ++++- app/test/fake_fetch.dart | 5 +- app/test/fetch_actor_test.dart | 28 +- app/test/html_test.dart | 265 ++++++++++++++++++ app/test/parser_test.dart | 6 - app/test/reader_widget_test.dart | 101 +++++++ 24 files changed, 863 insertions(+), 152 deletions(-) create mode 100644 app/lib/mews/html.dart create mode 100644 app/test/html_test.dart diff --git a/SPEC.md b/SPEC.md index b69d668..841753a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -79,6 +79,8 @@ The v1 client presents one reader screen: - An address field pinned at the top, below the status bar inset on phones; on touch devices it hides when the reader scrolls down and returns when they scroll up or reach the top, since a page that fits the viewport leaves nothing to scroll for it. A short tap on the page shows a hidden bar and hides a shown one, unless the tap opened something on the page — a link, a form, a control — for those leave the bar where it was. On pointer hosts it stays on screen, since the keyboard reaches it whenever it is needed. Its leading icons open the hub and, on pointer platforms, go back and forward. Its trailing icons reload the page, subscribe to the site, and bookmark the page; subscribing is offered only on a page that carries dated links or declares an Atom feed. - A web page that cannot be loaded offers to open in the reader's system web browser, since the web can be shown outside; a page over gemini or spartan has nowhere else to go, so its failures offer only trying again. +- A web page that is not a Mews page is not a broken page. The client reads pages as strict XML (spec 7.1) and the web is not well-formed XML, so such a page is named as the web page it is and offered two ways on: a simplified view, or the reader's own browser. The simplified view is the same markup read as far as the Mews subset reaches — elements Mews supports are shown, the rest are unwrapped and keep their content, and markup that is not content is left out. A form is the one exception to that: Mews has forms, but a web page's form is left out of the view, since the reader cannot usefully answer a search box or a sign-in here and submitting one would only land them on another page this client cannot show as Mews. Only the form goes; content it wrapped is kept. It renders through the same reader presentation as any page, so its links are followed in the app and fail back the same way, its text can be kept as a quotation, and it says it is simplified. Asking for it belongs to the address it was asked for, so a link from it that also fails explains itself rather than simplifying unasked. A page that carries the conformance marker and still will not parse is reported as the markup error it is: the marker, not the MIME type, is what claims a page as Mews, since Mews pages are served as `text/html`. +- A table cannot hold another (spec 4.2), so a nested one — the old web's way of laying out a page — is shown as a table of its own, after the table whose cell held it, rather than flattened into that cell's text. A row belongs only to the table that owns it, never to one it is nested inside. Rows of unequal length are filled out to the widest, since a table with holes cannot be drawn; a cell spanning several columns sits in the first of them. - On pointer hosts the keyboard drives the reader: Ctrl or Cmd+L focuses the address field with its text selected, Ctrl or Cmd+R reloads the page, and the walk goes back and forward with Alt+Left and Alt+Right, or on a Mac with Cmd+Left, Cmd+Right, Cmd+[ and Cmd+]. Touch devices navigate with gestures instead, and none of these shortcuts are offered there. The links on a page are reachable by keyboard as well: the page is one stop in the focus order, and once it holds the focus, Tab highlights its links in reading order and Enter opens the highlighted one; the page scrolls to keep the highlighted link in view, Shift+Tab walks back, Escape puts the highlight away, and a Tab past either end hands the focus back to the chrome. - A swipe right goes back and a swipe left goes forward through the history stack. On a pointer host the whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text, and the address bar's buttons work as well. On a touch device, where the column leaves no margin, the page surface itself accepts the swipe: a finger that crosses the slop sideways, before the long press that starts a text selection completes, is swiping, while a finger that held still that long keeps its drag for the selection. - The client's own pages live under the `mews:` scheme. `mews:hub` names them and says what each one holds; `mews:posts`, `mews:quotations`, `mews:bookmarks`, `mews:subscriptions`, `mews:history` and `mews:settings` each carry a strip linking to the others. Bare `mews:` is the hub, and the hub is where a session with no history to return to starts. Each page is built as an ordinary Mews document and rendered by the same renderer as a fetched page, so it joins the history stack and the swipe transitions, and none of them is recorded in history or offered for subscription. diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 8237944..79449ff 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -44,5 +44,11 @@ + + + + + diff --git a/app/lib/application/actors/fetch_actor.dart b/app/lib/application/actors/fetch_actor.dart index d36e508..7fdabd3 100644 --- a/app/lib/application/actors/fetch_actor.dart +++ b/app/lib/application/actors/fetch_actor.dart @@ -8,6 +8,7 @@ import 'package:http/http.dart' as http; import '../../mews/atom.dart'; import '../../mews/document.dart'; import '../../mews/gemtext.dart'; +import '../../mews/html.dart'; import '../../mews/parser.dart'; import '../../protocols/gemini.dart' as gemini; import '../../protocols/spartan.dart' as spartan; @@ -153,12 +154,18 @@ class FetchHttpStatusError extends FetchResult { } class FetchParseError extends FetchResult { - const FetchParseError(this.message, this.source); + const FetchParseError(this.message, {this.simplified, this.declaresMews = false}); final String message; - /// The markup that failed to parse, for the "show page text" option. - final String source; + /// The page as the app can show it, for the simplified view, or null when + /// nothing legible came out of the markup. + final MewsPage? simplified; + + /// Whether the markup declared the Mews conformance marker. A page that + /// claims to be Mews and still will not parse has a real markup error; one + /// that never claimed it is just an ordinary web page. + final bool declaresMews; } /// A Gemini or Spartan status carrying a message for the reader: a temporary @@ -304,7 +311,8 @@ class FetchHandler with Handler { final newEtag = response.headers['etag']; final newLastModified = response.headers['last-modified']; - return _parse(response.body, url, etag: newEtag, lastModified: newLastModified); + return _parse(response.body, url, + bytes: response.bodyBytes, etag: newEtag, lastModified: newLastModified); } /// The page could not be revalidated; use the cached copy when there is one. @@ -315,14 +323,32 @@ class FetchHandler with Handler { etag: etag, lastModified: lastModified, notModified: true); } + /// [bytes] are the undecoded body when there is one. The HTML conversion + /// reads the document's own charset from them, which an already-decoded + /// string has lost. FetchResult _parse(String source, String url, - {String? etag, String? lastModified, bool notModified = false}) { + {List? bytes, String? etag, String? lastModified, bool notModified = false}) { try { final page = parseMewsPage(source: source, url: url); return FetchPageOk( page: page, source: source, etag: etag, lastModified: lastModified, notModified: notModified); } on MewsParseException catch (e) { - return FetchParseError(e.message, source); + return _parseFailure(e, source, bytes, url); + } + } + + /// Markup the strict parse refused is converted for the simplified view, so + /// the reader is offered the structure Mews supports rather than nothing. + /// Converting here keeps the work off the main isolate. + FetchResult _parseFailure( + MewsParseException error, String source, List? bytes, String url) { + try { + final converted = convertHtmlPage(url: url, source: source, bytes: bytes); + return FetchParseError(error.message, + simplified: converted.page, declaresMews: converted.declaresMews); + } on Exception { + // Nothing legible in it; the error page stands on its own. + return FetchParseError(error.message); } } @@ -497,7 +523,7 @@ class FetchHandler with Handler { entries: parseAtomEntries(source: response.body, feedUrl: url), ); } on MewsParseException catch (e) { - return FetchParseError(e.message, response.body); + return FetchParseError(e.message); } } @@ -532,7 +558,7 @@ class FetchHandler with Handler { return const FetchNetworkError(); } if (response.statusCode != 200) return FetchHttpStatusError(response.statusCode); - return _parse(response.body, url); + return _parse(response.body, url, bytes: response.bodyBytes); } @override diff --git a/app/lib/application/controllers/browser_controller.dart b/app/lib/application/controllers/browser_controller.dart index 447f84a..cd6b4c5 100644 --- a/app/lib/application/controllers/browser_controller.dart +++ b/app/lib/application/controllers/browser_controller.dart @@ -23,11 +23,15 @@ class LoadFailed extends BrowseError { const LoadFailed(); } +/// A page the strict Mews parse refused. [simplified] is the same markup read +/// as far as the Mews subset reaches, for the reader who asks to see it, and is +/// null when nothing legible came out. [declaresMews] separates a page with a +/// real markup error from an ordinary web page. class BrokenPage extends BrowseError { - const BrokenPage(this.source); + const BrokenPage({this.simplified, this.declaresMews = false}); - /// The markup that failed to parse; "show page text" recovers its content. - final String source; + final MewsPage? simplified; + final bool declaresMews; } /// A protocol status with the site's own message for the reader: a temporary @@ -435,9 +439,9 @@ class BrowserController { )); await _db.recordVisit(url, page.title); } - case FetchParseError(:final source): + case FetchParseError(:final simplified, :final declaresMews): currentPage.value = null; - error.value = BrokenPage(source); + error.value = BrokenPage(simplified: simplified, declaresMews: declaresMews); case FetchProtocolError(:final message): currentPage.value = null; error.value = ProtocolFailure(message); diff --git a/app/lib/mews/html.dart b/app/lib/mews/html.dart new file mode 100644 index 0000000..cdc445e --- /dev/null +++ b/app/lib/mews/html.dart @@ -0,0 +1,128 @@ +/// Converts an ordinary web page into the Mews document model, so a page the +/// app cannot show as Mews can still be read in the structure Mews supports. +/// +/// Mews is a strict subset of XHTML Mobile Profile 1.2 (mews-spec section 4), +/// so a web page's elements already are Mews elements plus extras. The work is +/// therefore only to parse tolerantly — real HTML is not well-formed XML, which +/// is what [parseMewsPage] requires (spec 7.1) — and hand the result to the +/// same walk. Elements outside the subset are unwrapped and keep their content; +/// markup that is not content at all is dropped by the walk itself. +library; + +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; +import 'package:xml/xml.dart'; + +import 'document.dart'; +import 'parser.dart'; + +/// Stands in for the conformance marker a converted page has none of, so the +/// renderer applies reader presentation and can tell a simplified view apart. +/// Mirrors how gemtext.dart marks a converted capsule. +const htmlSpecVersion = 'html'; + +/// The whole page is built at once by the renderer rather than lazily, so a +/// very long web page is cut off instead of stalling the first frame. +const _maxBlocks = 2000; + +/// Valid XML names, minus the colon: real pages carry attributes like +/// `@click` and `v-bind:href` that [XmlElement] will not accept. +final _xmlName = RegExp(r'^[A-Za-z_][A-Za-z0-9_.\-]*$'); + +/// A form on the web is not reading. The reader cannot usefully answer a +/// search box or a sign-in here, and submitting one would land them on another +/// page this client cannot show as Mews. The form itself is unwrapped rather +/// than dropped, so any real content inside it survives. +const _unwrapped = {'form'}; + +/// A form's controls, which have nothing to say once the form is gone. +const _dropped = { + 'input', 'select', 'textarea', 'label', 'fieldset', 'optgroup', 'option', +}; + +/// Converts [bytes], or [source] when there are none, into a page plus whether +/// the markup declared itself a Mews page. A page carrying the conformance +/// marker that still failed to parse has a real markup error; a page without it +/// is simply not Mews, and the two deserve different words. +/// +/// Prefer [bytes]: the HTML parser reads the document's own `` +/// from them, while an already-decoded string has been through whatever charset +/// the HTTP header claimed. +({MewsPage page, bool declaresMews}) convertHtmlPage({ + required String url, + String? source, + List? bytes, +}) { + final document = html_parser.parse(bytes ?? source ?? ''); + final declaresMews = + document.querySelector('meta[name="mews-profile"]') != null; + + final root = document.documentElement; + final copied = root == null ? null : _copy(root).whereType().firstOrNull; + if (copied == null) { + throw const MewsParseException('The markup has no root element.'); + } + + final page = parseMewsDocument( + XmlDocument([copied]), + url: url, + specVersionOverride: htmlSpecVersion, + ); + return (page: _capped(page), declaresMews: declaresMews); +} + +/// Copies an HTML element into the XML tree the Mews walk reads, as the nodes +/// that stand in for it: itself, or just its children where the element is in +/// the way, or nothing at all. Comments and the doctype are left behind, and +/// an element XML cannot name keeps its content the way an unknown wrapper +/// does. Beyond that the walk stays the place that decides what is shown. +List _copy(dom.Element element) { + final name = element.localName; + if (name != null && _dropped.contains(name)) return const []; + + final children = []; + for (final node in element.nodes) { + if (node is dom.Element) { + children.addAll(_copy(node)); + } else if (node is dom.Text) { + children.add(XmlText(node.text)); + } + } + + if (name == null || !_xmlName.hasMatch(name) || _unwrapped.contains(name)) { + return children; + } + + final attributes = {}; + for (final entry in element.attributes.entries) { + final key = entry.key; + final attribute = key is dom.AttributeName ? key.name : key.toString(); + // Last one wins, as a browser does; duplicates would be rejected here. + if (_xmlName.hasMatch(attribute)) attributes[attribute] = entry.value; + } + + return [ + XmlElement( + XmlName.qualified(name), + [for (final a in attributes.entries) XmlAttribute(XmlName.qualified(a.key), a.value)], + children, + ) + ]; +} + +MewsPage _capped(MewsPage page) { + if (page.body.length <= _maxBlocks) return page; + return MewsPage( + url: page.url, + body: page.body.sublist(0, _maxBlocks), + lang: page.lang, + direction: page.direction, + variant: page.variant, + entries: page.entries, + specVersion: page.specVersion, + title: page.title, + description: page.description, + author: page.author, + atomFeedHref: page.atomFeedHref, + ); +} diff --git a/app/lib/mews/parser.dart b/app/lib/mews/parser.dart index 496ac26..19cb184 100644 --- a/app/lib/mews/parser.dart +++ b/app/lib/mews/parser.dart @@ -26,7 +26,16 @@ MewsPage parseMewsPage({required String source, required String url}) { } on XmlException catch (e) { throw MewsParseException(e.toString()); } + return parseMewsDocument(doc, url: url); +} +/// Builds the page from an already-parsed document. A tolerant HTML parse +/// arrives here too (see html.dart), so the mapping below stays the one place +/// that decides what the Mews subset shows, whatever parsed the markup. +/// [specVersionOverride] stands in for the conformance marker a converted +/// document carries none of. +MewsPage parseMewsDocument(XmlDocument doc, + {required String url, String? specVersionOverride}) { final html = doc.rootElement; if (html.name.qualified != 'html') { throw const MewsParseException('The root element is not html.'); @@ -47,7 +56,7 @@ MewsPage parseMewsPage({required String source, required String url}) { final dir = _dirOf(html) ?? _dirOf(bodyElement); return MewsPage( url: url, - specVersion: headData.specVersion, + specVersion: specVersionOverride ?? headData.specVersion, title: headData.title, description: headData.description, author: headData.author, @@ -139,7 +148,12 @@ List _entriesOf(XmlElement body, String pageUrl) { // Blocks -------------------------------------------------------------------- /// Elements whose whole subtree is not content and must never reach the model. -const _droppedElements = {'script', 'style', 'object', 'meta', 'link', 'head'}; +const _droppedElements = { + 'script', 'style', 'object', 'meta', 'link', 'head', + // Not content on the web either: markup the renderer has nothing to draw + // for, and controls that are interface rather than text. + 'svg', 'iframe', 'noscript', 'template', 'canvas', 'audio', 'video', 'button', +}; void _appendBlocks(XmlElement element, String pageUrl, List out) { switch (element.name.qualified) { @@ -170,7 +184,9 @@ void _appendBlocks(XmlElement element, String pageUrl, List out) { case 'dl': out.add(_definitionBlock(element, pageUrl)); case 'table': - out.add(_tableBlock(element, pageUrl)); + final nested = []; + out.add(_tableBlock(element, pageUrl, nested)); + out.addAll(nested); case 'img': out.add(_imageBlock(element, pageUrl)); case 'form': @@ -180,11 +196,11 @@ void _appendBlocks(XmlElement element, String pageUrl, List out) { _appendBlocks(child, pageUrl, out); } default: - // Unknown or dropped wrappers: keep the readable content, drop the rest. + // Unknown wrappers: keep the readable content, drop the rest. Mixed + // content has to be handled here, not just element children, because + // text sits directly inside a div on most of the web. if (!_droppedElements.contains(element.name.qualified)) { - for (final child in element.children.whereType()) { - _appendBlocks(child, pageUrl, out); - } + out.addAll(_mixedBlocks(element, pageUrl)); } } } @@ -255,18 +271,31 @@ DefinitionListBlock _definitionBlock(XmlElement element, String pageUrl) { return DefinitionListBlock(items); } -TableBlock _tableBlock(XmlElement element, String pageUrl) { +/// The nearest table a node sits in, so a row can say which table owns it. +XmlElement? _enclosingTable(XmlNode node) => node.ancestors + .whereType() + .where((e) => e.name.qualified == 'table') + .firstOrNull; + +/// Builds the table, adding to [embedded] any table nested in one of its +/// cells. Tables cannot nest in the model (spec 4.2), so a nested one — old +/// web layout markup — becomes a table of its own rather than being flattened +/// into the cell that held it. +TableBlock _tableBlock(XmlElement element, String pageUrl, + [List? embedded]) { final rows = []; - for (final row in element.descendants - .whereType() - .where((e) => e.name.qualified == 'tr')) { + // Rows are found among the descendants because thead, tbody and tfoot sit + // between, and an HTML parse inserts a tbody even where the markup has + // none. A row owned by a nested table belongs to that table, not this one. + for (final row in element.descendants.whereType().where( + (e) => e.name.qualified == 'tr' && _enclosingTable(e) == element)) { final cells = []; for (final cell in row.children.whereType()) { final isHeader = cell.name.qualified == 'th'; if (!isHeader && cell.name.qualified != 'td') continue; cells.add(MewsTableCell( header: isHeader, - inline: _inline(cell, pageUrl), + inline: _inline(cell, pageUrl, embedded), colspan: int.tryParse(cell.getAttribute('colspan') ?? '') ?? 1, rowspan: int.tryParse(cell.getAttribute('rowspan') ?? '') ?? 1, )); @@ -489,6 +518,11 @@ void _appendInline(XmlNode node, String pageUrl, List out, // Every image is shown in its own block (spec 4.2), so an inline image // is lifted out of the paragraph that contains it. embedded?.add(_imageBlock(node, pageUrl)); + case 'table': + // Likewise a table, which cannot sit inside another (spec 4.2). Without + // this it would fall to the default below and its cells would run + // together as text. + embedded?.add(_tableBlock(node, pageUrl, embedded)); default: if (!_droppedElements.contains(node.name.qualified)) { for (final child in node.children) { @@ -497,17 +531,3 @@ void _appendInline(XmlNode node, String pageUrl, List out, } } } - -/// Recovers readable text from a page that failed XML parsing, for the "show -/// its text" escape hatch (spec 8.1). Best effort by design. -String mewsPlainText(String source) { - final withoutBlocks = source.replaceAll(RegExp(r'<(script|style)[\s\S]*?', caseSensitive: false), ''); - final stripped = withoutBlocks.replaceAll(RegExp(r'<[^>]+>'), ' '); - return stripped - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); -} diff --git a/app/lib/presentation/reader/mews_page_view.dart b/app/lib/presentation/reader/mews_page_view.dart index 05ef168..c33d914 100644 --- a/app/lib/presentation/reader/mews_page_view.dart +++ b/app/lib/presentation/reader/mews_page_view.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import '../../l10n/strings.dart'; import '../../application/fonts.dart'; import '../../mews/document.dart'; +import '../../mews/html.dart' show htmlSpecVersion; import 'palettes.dart'; /// How long the reader has to stop typing before a search box narrows its @@ -84,6 +85,11 @@ class _MewsPageViewState extends State { int _focusedLink = -1; int _linkCount = 0; int _linkCounter = 0; + + /// Forms of the page, counted in reading order as it is built. A web page + /// can carry the same form twice — a search box in both a desktop and a + /// mobile header — so the action alone does not identify one. + int _formCounter = 0; final _linkHrefs = []; final _linkSpans = []; final _region = FocusNode(); @@ -157,8 +163,13 @@ class _MewsPageViewState extends State { _linkHrefs.clear(); _linkSpans.clear(); _linkCounter = 0; + _formCounter = 0; final blocks = [ - if (!widget.page.isMews) + // A converted web page says so: what it shows is only the part of it + // the Mews subset reaches. + if (widget.page.specVersion == htmlSpecVersion) + _notice(strings.t('error-simplified-view'), base, palette) + else if (!widget.page.isMews) _notice(strings.t('error-non-mews'), base, palette), for (final block in widget.page.body) _block(context, block, base), ]; @@ -242,6 +253,72 @@ class _MewsPageViewState extends State { ); } + /// A table, every row padded out to the width of the widest. Flutter's + /// Table requires each row to hold the same number of cells, and a web + /// page's rows are routinely ragged. A colspan is not honored, so a cell + /// that spans several columns simply sits in the first of them. + Widget _table( + List rows, + String? caption, + double base, + TextStyle foreground, + ) { + final palette = widget.palette; + var width = 0; + for (final row in rows) { + if (row.cells.length > width) width = row.cells.length; + } + // Nothing to draw, and a Table needs at least one column. + if (width == 0) return const SizedBox.shrink(); + + return Padding( + padding: EdgeInsets.only(bottom: base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (caption != null) + Text( + caption, + style: foreground.copyWith(fontWeight: FontWeight.bold), + ), + Table( + border: TableBorder.all(color: palette.border), + defaultVerticalAlignment: TableCellVerticalAlignment.top, + children: [ + for (final row in rows) + TableRow( + children: [ + for (var i = 0; i < width; i++) + TableCell( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: base * 0.6, + vertical: base * 0.3, + ), + child: Text.rich( + TextSpan( + children: i < row.cells.length + ? _inline(row.cells[i].inline) + : const [], + ), + style: foreground.copyWith( + fontWeight: + i < row.cells.length && row.cells[i].header + ? FontWeight.bold + : null, + ), + ), + ), + ), + ], + ), + ], + ), + ], + ), + ); + } + Widget _notice(String text, double base, MewsPalette palette) => Padding( padding: EdgeInsets.only(bottom: base), child: Text( @@ -417,57 +494,19 @@ class _MewsPageViewState extends State { ], ), ), - TableBlock(:final rows, :final caption) => Padding( - padding: EdgeInsets.only(bottom: base), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (caption != null) - Text( - caption, - style: foreground.copyWith(fontWeight: FontWeight.bold), - ), - Table( - border: TableBorder.all(color: palette.border), - defaultVerticalAlignment: TableCellVerticalAlignment.top, - children: [ - for (final row in rows) - TableRow( - children: [ - for (final cell in row.cells) - TableCell( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: base * 0.6, - vertical: base * 0.3, - ), - child: Text.rich( - TextSpan(children: _inline(cell.inline)), - style: foreground.copyWith( - fontWeight: cell.header - ? FontWeight.bold - : null, - ), - ), - ), - ), - ], - ), - ], - ), - ], - ), - ), + TableBlock(:final rows, :final caption) => + _table(rows, caption, base, foreground), ImageBlock(:final src, :final alt, :final allowed) => Padding( padding: EdgeInsets.only(bottom: base), child: allowed ? _image(src, alt, base) : _imageFallback(alt, base), ), FormBlock() => Padding( padding: EdgeInsets.only(bottom: base), - // Keyed by what it submits to: a page rebuilt as the reader types in - // this box is matched up with the box they are typing in, so the text - // and the cursor stay where they are. - key: ValueKey('form:${block.action}'), + // Keyed by where it sits and what it submits to: a page rebuilt as + // the reader types in this box is matched up with the box they are + // typing in, so the text and the cursor stay where they are. The + // position keeps two forms with one action apart. + key: ValueKey('form:${_formCounter++}:${block.action}'), child: _MewsForm( form: block, foreground: foreground, diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index f34a812..efdd9f7 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -22,7 +22,6 @@ import '../../application/controllers/browser_controller.dart' import '../../database/database.dart' show ClientCertificate; import '../../application/pages/mews_urls.dart'; import '../../mews/document.dart'; -import '../../mews/parser.dart' show mewsPlainText; import '../app.dart'; import 'address_bar.dart'; import 'mews_page_view.dart'; @@ -46,7 +45,11 @@ class _ReaderScreenState extends State { /// scrolls down, back on any upward scroll. A pointer host with a keyboard /// has room to spare and keys to reach the bar, so it never hides. bool _chromeVisible = true; - bool _showBrokenText = false; + + /// The address whose simplified view the reader asked for. Keyed by address + /// so the choice applies to that page alone: a link from it that also fails + /// explains itself rather than silently simplifying too. + String? _simplifiedFor; final _checkedSubscriptions = {}; // The finger behind a possible tap on the page, tracked outside the @@ -274,7 +277,7 @@ class _ReaderScreenState extends State { return SignalBuilder(builder: (context) { final error = deps.browser.error.value; return switch (error) { - BrokenPage(:final source) => _errorPage(context, source), + final BrokenPage broken => _errorPage(context, broken), LoadFailed() => _errorPage(context, null), // A protocol status carries the site's own words for what went // wrong. @@ -300,7 +303,15 @@ class _ReaderScreenState extends State { } _checkSubscription(context, page); - _showBrokenText = false; + + return _pageView(context, page); + } + + /// A page as the reader sees it. Both the fetched page and the simplified + /// view of a web page come through here, so they share the palette, the + /// fonts, the link handling and the choice to keep a quotation. + Widget _pageView(BuildContext context, MewsPage page) { + final deps = AppScope.of(context).deps; return SignalBuilder(builder: (context) { final settings = deps.settings; @@ -536,20 +547,28 @@ class _ReaderScreenState extends State { } } - Widget _errorPage(BuildContext context, String? brokenSource) { + /// The page that stands in for one the reader cannot have: [broken] is a + /// page the Mews parse refused, and null is a page that would not load. + Widget _errorPage(BuildContext context, BrokenPage? broken) { final strings = AppScope.of(context).strings; final deps = AppScope.of(context).deps; // A web address can be shown by the system's browser, so a page that - // will not load here can leave; a protocol page has nowhere else to go, - // so only trying again is offered there. + // will not load here, or that this client cannot read, can leave; a + // protocol page has nowhere else to go, so only trying again is offered + // there. final failed = deps.browser.currentAddress; final failedScheme = Uri.tryParse(failed)?.scheme; - final canOpenOutside = - brokenSource == null && (failedScheme == 'http' || failedScheme == 'https'); + final canOpenOutside = failedScheme == 'http' || failedScheme == 'https'; + + // The simplified view is a page, not a notice, so it fills the screen + // rather than sitting in the centered column below. + if (broken?.simplified != null && _simplifiedFor == failed) { + return _pageView(context, broken!.simplified!); + } Widget content; - if (brokenSource == null) { + if (broken == null) { content = Column( mainAxisSize: MainAxisSize.min, children: [ @@ -568,25 +587,33 @@ class _ReaderScreenState extends State { ], ], ); - } else if (_showBrokenText) { - content = Column( - children: [ - Text(strings.t('error-broken-page-text-title'), - style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Expanded(child: SingleChildScrollView(child: Text(mewsPlainText(brokenSource)))), - ], - ); } else { + // A page claiming the Mews marker that still will not parse has a real + // markup error. One that never claimed it is an ordinary web page, and + // saying so is the honest answer; the MIME type cannot tell them apart, + // since Mews pages are served as text/html (spec 7.1). content = Column( mainAxisSize: MainAxisSize.min, children: [ - Text(strings.t('error-broken-page'), textAlign: TextAlign.center), - const SizedBox(height: 16), - FilledButton( - onPressed: _press(() => setState(() => _showBrokenText = true)), - child: Text(strings.t('error-broken-page-show-text')), + Text( + strings.t(broken.declaresMews + ? 'error-broken-page' + : 'error-not-mews-page'), + textAlign: TextAlign.center, ), + const SizedBox(height: 16), + if (broken.simplified != null) + FilledButton( + onPressed: _press(() => setState(() => _simplifiedFor = failed)), + child: Text(strings.t('error-show-simplified')), + ), + if (canOpenOutside) ...[ + const SizedBox(height: 8), + OutlinedButton( + onPressed: _press(() => _openOutside(failed)), + child: Text(strings.t('error-open-in-browser')), + ), + ], ], ); } diff --git a/app/locales/de/main.ftl b/app/locales/de/main.ftl index dd3d20a..c54187c 100644 --- a/app/locales/de/main.ftl +++ b/app/locales/de/main.ftl @@ -211,9 +211,10 @@ form-reset-default = Zurücksetzen error-load-failed = Die Seite konnte nicht geladen werden. Prüfe die Adresse und deine Verbindung und versuche es erneut. error-try-again = Erneut versuchen error-open-in-browser = In einem Webbrowser öffnen -error-broken-page = Diese Seite hat einen Auszeichnungsfehler und kann deshalb nicht angezeigt werden. -error-broken-page-show-text = Seitentext anzeigen -error-broken-page-text-title = Seitentext +error-broken-page = Diese Mews-Seite hat einen Auszeichnungsfehler und kann deshalb nicht als solche angezeigt werden. +error-not-mews-page = Dies ist eine Webseite, keine Mews-Seite. Du kannst eine vereinfachte Fassung lesen oder sie im Browser öffnen. +error-show-simplified = Vereinfachte Fassung anzeigen +error-simplified-view = Dies ist eine vereinfachte Fassung und zeigt nur, was Mews darstellen kann. error-non-mews = Dies ist keine Mews-Seite und wird deshalb schlicht angezeigt. error-image-blocked = Dieses Bild liegt auf einer anderen Website und wird nicht angezeigt. error-image-unavailable = Dieses Bild konnte nicht geladen werden. diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index 14cadf4..f06be15 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -214,9 +214,10 @@ form-reset-default = Reset error-load-failed = The page could not be loaded. Check the address and your connection, then try again. error-try-again = Try again error-open-in-browser = Open in a web browser -error-broken-page = This page has a markup error, so it cannot be shown. -error-broken-page-show-text = Show page text -error-broken-page-text-title = Page text +error-broken-page = This Mews page has a markup error, so it can't be shown as one. +error-not-mews-page = This is a web page, not a Mews page. You can read a simplified version or open it in your browser. +error-show-simplified = Show a simplified version +error-simplified-view = This is a simplified version, showing only what Mews can display. error-non-mews = This page is not a Mews page, so it is shown plainly. error-image-blocked = This image is on another site and is not shown. error-image-unavailable = This image could not be loaded. diff --git a/app/locales/es/main.ftl b/app/locales/es/main.ftl index 5f2c4a5..4d706ac 100644 --- a/app/locales/es/main.ftl +++ b/app/locales/es/main.ftl @@ -211,9 +211,10 @@ form-reset-default = Restablecer error-load-failed = No se ha podido cargar la página. Comprueba la dirección y tu conexión, y vuelve a intentarlo. error-try-again = Volver a intentarlo error-open-in-browser = Abrir en un navegador web -error-broken-page = Esta página tiene un error de marcado, así que no se puede mostrar. -error-broken-page-show-text = Mostrar el texto de la página -error-broken-page-text-title = Texto de la página +error-broken-page = Esta página de Mews tiene un error de marcado, así que no se puede mostrar como tal. +error-not-mews-page = Esta es una página web, no una página de Mews. Puedes leer una versión simplificada o abrirla en tu navegador. +error-show-simplified = Mostrar una versión simplificada +error-simplified-view = Esta es una versión simplificada y solo muestra lo que Mews puede presentar. error-non-mews = Esta página no es una página de Mews, así que se muestra de forma sencilla. error-image-blocked = Esta imagen está en otro sitio y no se muestra. error-image-unavailable = No se ha podido cargar esta imagen. diff --git a/app/locales/fr/main.ftl b/app/locales/fr/main.ftl index 51bdfd8..f9aa34e 100644 --- a/app/locales/fr/main.ftl +++ b/app/locales/fr/main.ftl @@ -211,9 +211,10 @@ form-reset-default = Réinitialiser error-load-failed = La page n'a pas pu être chargée. Vérifie l'adresse et ta connexion, puis réessaie. error-try-again = Réessayer error-open-in-browser = Ouvrir dans un navigateur web -error-broken-page = Cette page comporte une erreur de balisage et ne peut donc pas être affichée. -error-broken-page-show-text = Afficher le texte de la page -error-broken-page-text-title = Texte de la page +error-broken-page = Cette page Mews comporte une erreur de balisage et ne peut donc pas être affichée comme telle. +error-not-mews-page = Ceci est une page web, pas une page Mews. Tu peux en lire une version simplifiée ou l'ouvrir dans ton navigateur. +error-show-simplified = Afficher une version simplifiée +error-simplified-view = Ceci est une version simplifiée et n'affiche que ce que Mews peut présenter. error-non-mews = Cette page n'est pas une page Mews, elle est donc affichée simplement. error-image-blocked = Cette image se trouve sur un autre site et n'est pas affichée. error-image-unavailable = Cette image n'a pas pu être chargée. diff --git a/app/locales/he/main.ftl b/app/locales/he/main.ftl index 7e3ee91..b0f9899 100644 --- a/app/locales/he/main.ftl +++ b/app/locales/he/main.ftl @@ -217,9 +217,10 @@ form-reset-default = איפוס error-load-failed = לא ניתן לטעון את הדף. בדקו את הכתובת ואת החיבור ונסו שוב. error-try-again = ניסיון חוזר error-open-in-browser = פתיחה בדפדפן אינטרנט -error-broken-page = בדף הזה יש שגיאת סימון, ולכן אי אפשר להציג אותו. -error-broken-page-show-text = הצגת הטקסט של הדף -error-broken-page-text-title = הטקסט של הדף +error-broken-page = בדף Mews הזה יש שגיאת סימון, ולכן אי אפשר להציג אותו כדף Mews. +error-not-mews-page = זה דף אינטרנט, לא דף Mews. אפשר לקרוא גרסה מפושטת או לפתוח אותו בדפדפן. +error-show-simplified = הצגת גרסה מפושטת +error-simplified-view = זו גרסה מפושטת, שמציגה רק את מה ש-Mews יכול להציג. error-non-mews = הדף הזה אינו דף Mews, ולכן הוא מוצג בפשטות. error-image-blocked = התמונה הזו נמצאת באתר אחר ואינה מוצגת. error-image-unavailable = לא ניתן לטעון את התמונה הזו. diff --git a/app/locales/ja/main.ftl b/app/locales/ja/main.ftl index b911a4b..23eca6e 100644 --- a/app/locales/ja/main.ftl +++ b/app/locales/ja/main.ftl @@ -211,9 +211,10 @@ form-reset-default = リセット error-load-failed = ページを読み込めませんでした。アドレスと接続を確認して、もう一度お試しください。 error-try-again = もう一度試す error-open-in-browser = ウェブブラウザで開く -error-broken-page = このページにはマークアップの誤りがあるため、表示できません。 -error-broken-page-show-text = ページのテキストを表示 -error-broken-page-text-title = ページのテキスト +error-broken-page = この Mews ページにはマークアップの誤りがあるため、Mews ページとして表示できません。 +error-not-mews-page = これは Mews のページではなくウェブページです。簡易表示で読むか、ブラウザで開くことができます。 +error-show-simplified = 簡易表示で見る +error-simplified-view = これは簡易表示で、Mews が表示できる範囲のみを示しています。 error-non-mews = これは Mews のページではないため、簡素に表示しています。 error-image-blocked = この画像は別のサイトにあるため表示しません。 error-image-unavailable = この画像を読み込めませんでした。 diff --git a/app/locales/pt/main.ftl b/app/locales/pt/main.ftl index 2dfcc29..9986551 100644 --- a/app/locales/pt/main.ftl +++ b/app/locales/pt/main.ftl @@ -211,9 +211,10 @@ form-reset-default = Repor error-load-failed = Não foi possível carregar a página. Verifica o endereço e a tua ligação e tenta de novo. error-try-again = Tentar de novo error-open-in-browser = Abrir num navegador web -error-broken-page = Esta página tem um erro de marcação, por isso não pode ser mostrada. -error-broken-page-show-text = Mostrar o texto da página -error-broken-page-text-title = Texto da página +error-broken-page = Esta página Mews tem um erro de marcação, por isso não pode ser mostrada como tal. +error-not-mews-page = Esta é uma página web, não uma página Mews. Podes ler uma versão simplificada ou abri-la no teu navegador. +error-show-simplified = Mostrar uma versão simplificada +error-simplified-view = Esta é uma versão simplificada e mostra apenas o que o Mews consegue apresentar. error-non-mews = Esta não é uma página Mews, por isso é mostrada de forma simples. error-image-blocked = Esta imagem está noutro site e não é mostrada. error-image-unavailable = Não foi possível carregar esta imagem. diff --git a/app/locales/ru/main.ftl b/app/locales/ru/main.ftl index fe6e73e..6b0ae92 100644 --- a/app/locales/ru/main.ftl +++ b/app/locales/ru/main.ftl @@ -217,9 +217,10 @@ form-reset-default = Сбросить error-load-failed = Не удалось загрузить страницу. Проверьте адрес и подключение, затем повторите попытку. error-try-again = Повторить попытку error-open-in-browser = Открыть в веб-браузере -error-broken-page = На этой странице ошибка разметки, поэтому показать её нельзя. -error-broken-page-show-text = Показать текст страницы -error-broken-page-text-title = Текст страницы +error-broken-page = На этой странице Mews ошибка разметки, поэтому показать её как страницу Mews нельзя. +error-not-mews-page = Это веб-страница, а не страница Mews. Можно прочитать упрощённую версию или открыть её в браузере. +error-show-simplified = Показать упрощённую версию +error-simplified-view = Это упрощённая версия: показано только то, что Mews может отобразить. error-non-mews = Это не страница Mews, поэтому она показана простым текстом. error-image-blocked = Это изображение находится на другом сайте и не показывается. error-image-unavailable = Не удалось загрузить изображение. diff --git a/app/pubspec.lock b/app/pubspec.lock index 8cee3ae..75fda47 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -185,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" dart_style: dependency: transitive description: @@ -373,6 +381,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + html: + dependency: "direct main" + description: + name: html + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" + url: "https://pub.dev" + source: hosted + version: "0.15.7" http: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index ca73855..e37292f 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: file_picker: ^13.1.0 url_launcher: ^6.3.3 share_plus: ^13.3.1 + html: ^0.15.7 dev_dependencies: flutter_test: diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index a998929..bbc5272 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -8,7 +8,7 @@ import 'package:flutter/rendering.dart' show RenderParagraph; import 'package:flutter_test/flutter_test.dart'; import 'package:mews/application/controllers/browser_controller.dart' - show BrokenPage, LoadFailed; + show LoadFailed; import 'package:mews/database/database.dart'; import 'package:mews/l10n/strings.dart'; import 'package:mews/mews/document.dart'; @@ -116,16 +116,67 @@ void main() { expect(find.textContaining('Sign guestbook'), findsOneWidget); }); - testWidgets('shows the broken-page error and its text view', (tester) async { - await pumpApp(tester); - deps.browser.currentPage.value = null; - deps.browser.error.value = const BrokenPage('

Half a page & more'); + testWidgets('offers a web page simplified or in the browser', (tester) async { + // Ordinary HTML: unclosed tags, text loose in a div, a script to ignore. + // Nothing here is a markup error, it is simply not a Mews page. + const webPage = ''' + +A Journal + + +

Loose text in a div. +

A heading

+
  • First
  • Second
+

Never closed + +'''; + final stubDeps = AppDependencies( + database: MewsDatabase.test(NativeDatabase.memory()), + fetch: FakeFetch(const {'https://example.com/journal': webPage}), + strings: strings, + ); + await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings)); + await tester.pumpAndSettle(); + + await stubDeps.browser.open('https://example.com/journal'); + await tester.pumpAndSettle(); + + expect(find.textContaining('not a Mews page'), findsOneWidget); + expect(find.textContaining('markup error'), findsNothing); + expect(find.textContaining('Open in a web browser'), findsOneWidget); + + await tester.tap(find.textContaining('Show a simplified version')); + await tester.pumpAndSettle(); + + // The structure survives, and what Mews cannot show is left out. + expect(find.textContaining('A heading'), findsOneWidget); + expect(find.textContaining('Loose text in a div'), findsOneWidget); + expect(find.textContaining('Never closed'), findsOneWidget); + expect(find.textContaining('Second'), findsWidgets); + expect(find.textContaining('ignore.me'), findsNothing); + expect(find.textContaining('simplified version'), findsWidgets); + }); + + testWidgets('calls a page that claims Mews and will not parse broken', + (tester) async { + const claimed = ''' +Claimed + +

Never closed +'''; + final stubDeps = AppDependencies( + database: MewsDatabase.test(NativeDatabase.memory()), + fetch: FakeFetch(const {'https://example.com/claimed': claimed}), + strings: strings, + ); + await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings)); + await tester.pumpAndSettle(); + + await stubDeps.browser.open('https://example.com/claimed'); await tester.pumpAndSettle(); expect(find.textContaining('markup error'), findsOneWidget); - await tester.tap(find.textContaining('Show page text')); - await tester.pumpAndSettle(); - expect(find.textContaining('Half a page & more'), findsOneWidget); + expect(find.textContaining('not a Mews page'), findsNothing); }); testWidgets('shows the load-failed error page', (tester) async { diff --git a/app/test/fake_fetch.dart b/app/test/fake_fetch.dart index ae14eff..5d5e041 100644 --- a/app/test/fake_fetch.dart +++ b/app/test/fake_fetch.dart @@ -5,6 +5,7 @@ import 'package:actors/actors.dart'; import 'package:mews/application/actors/fetch_actor.dart'; import 'package:mews/mews/atom.dart'; import 'package:mews/mews/gemtext.dart'; +import 'package:mews/mews/html.dart'; import 'package:mews/mews/parser.dart'; import 'package:mews/protocols/tofu.dart'; @@ -103,7 +104,9 @@ class FakeFetch implements Actor { try { return FetchPageOk(page: parseMewsPage(source: source, url: url), source: source); } on MewsParseException catch (e) { - return FetchParseError(e.message, source); + final converted = convertHtmlPage(url: url, source: source); + return FetchParseError(e.message, + simplified: converted.page, declaresMews: converted.declaresMews); } } diff --git a/app/test/fetch_actor_test.dart b/app/test/fetch_actor_test.dart index 0558150..adbd0f4 100644 --- a/app/test/fetch_actor_test.dart +++ b/app/test/fetch_actor_test.dart @@ -35,15 +35,35 @@ void main() { expect(ok.notModified, isFalse); }); - test('ParseSource reports broken markup with its source', () async { + test('ParseSource offers a web page as a simplified page', () async { final actor = Actor.create(FetchHandler.new); addTearDown(actor.close); - final result = await actor.send( - const ParseSource(url: 'https://example.com/broken.html', source: '

nope')); + final result = await actor.send(const ParseSource( + url: 'https://example.com/broken.html', + source: '

Loose

Never closed')); expect(result, isA()); - expect((result as FetchParseError).source, '

nope'); + final error = result as FetchParseError; + // No conformance marker, so this is an ordinary web page rather than a + // Mews page with a markup error. + expect(error.declaresMews, isFalse); + expect(error.simplified, isNotNull); + expect(error.simplified!.body.whereType(), isNotEmpty); + }); + + test('ParseSource reports a markup error on a page claiming Mews', () async { + final actor = Actor.create(FetchHandler.new); + addTearDown(actor.close); + + final result = await actor.send(const ParseSource( + url: 'https://example.com/broken.html', + source: 'T' + '' + '

Never closed')); + + expect(result, isA()); + expect((result as FetchParseError).declaresMews, isTrue); }); // The protocol transports are exercised through the same handler a fetch diff --git a/app/test/html_test.dart b/app/test/html_test.dart new file mode 100644 index 0000000..9d54413 --- /dev/null +++ b/app/test/html_test.dart @@ -0,0 +1,265 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mews/mews/document.dart'; +import 'package:mews/mews/html.dart'; + +/// Ordinary web markup: a doctype, unclosed tags, text loose inside wrappers, +/// and plenty the Mews subset has nothing to draw for. +const webPage = ''' + + + + A Journal + + + + +

+
+ Loose text in a div. +

On old phones

+

Notes on old phones and the about page. +

    +
  • First +
  • Second +
+
Worth keeping.
+
  kept as is
+
ModelPrice
3310Ten
+
+ + + +
+ + +'''; + +/// Every bit of readable text in a page, so a test can say what survived the +/// conversion and what did not. +String textOf(List blocks) { + final parts = []; + + void addInline(List runs) { + for (final run in runs) { + switch (run) { + case MewsText(:final text): + parts.add(text); + case MewsEmphasis(:final children): + addInline(children); + case MewsLink(:final children): + addInline(children); + case MewsLineBreak(): + break; + } + } + } + + void addBlocks(List blocks) { + for (final block in blocks) { + switch (block) { + case HeadingBlock(:final inline): + addInline(inline); + case ParagraphBlock(:final inline): + addInline(inline); + case AddressBlock(:final inline): + addInline(inline); + case QuoteBlock(:final children): + addBlocks(children); + case PreBlock(:final text): + parts.add(text); + case ListBlock(:final items): + for (final item in items) { + addBlocks(item.blocks); + } + case DefinitionListBlock(:final items): + for (final item in items) { + addInline(item.term); + addInline(item.definition); + } + case TableBlock(:final rows): + for (final row in rows) { + for (final cell in row.cells) { + addInline(cell.inline); + } + } + case ImageBlock(:final alt): + parts.add(alt); + case RuleBlock(): + break; + case FormBlock(): + break; + } + } + } + + addBlocks(blocks); + return parts.join(' '); +} + +({MewsPage page, bool declaresMews}) convert(String source, + {String url = 'https://example.com/journal'}) => + convertHtmlPage(url: url, source: source); + +void main() { + test('converts a web page into the blocks Mews supports', () { + final page = convert(webPage).page; + final kinds = page.body.map((b) => b.runtimeType.toString()).toList(); + + expect(page.title, 'A Journal'); + expect(page.lang, 'en'); + expect(kinds, contains('HeadingBlock')); + expect(kinds, contains('ParagraphBlock')); + expect(kinds, contains('ListBlock')); + expect(kinds, contains('QuoteBlock')); + expect(kinds, contains('PreBlock')); + expect(kinds, contains('TableBlock')); + expect(kinds, contains('RuleBlock')); + }); + + test('a converted page is marked as a simplified view', () { + expect(convert(webPage).page.specVersion, htmlSpecVersion); + // The marker is what makes a page Mews, and this one never claimed it. + expect(convert(webPage).declaresMews, isFalse); + }); + + test('a page claiming the Mews marker is told apart', () { + const claimed = 'T' + '' + '

Never closed'; + expect(convert(claimed).declaresMews, isTrue); + }); + + test('keeps text sitting directly inside an unknown wrapper', () { + final page = convert(webPage).page; + final text = page.body + .whereType() + .expand((b) => b.inline.whereType()) + .map((t) => t.text) + .join(' '); + expect(text, contains('Loose text in a div.')); + }); + + test('closes tags the page left open', () { + final page = convert('

One

Two

  • A
  • B
').page; + expect(page.body.whereType().length, 2); + expect(page.body.whereType().single.items.length, 2); + }); + + test('leaves out markup that is not content', () { + final all = textOf(convert(webPage).page.body); + expect(all, isNot(contains('ignore.me'))); + expect(all, isNot(contains('color: red'))); + expect(all, isNot(contains('Press me'))); + expect(all, isNot(contains('circle'))); + // A nav is a list of real links, so it is kept rather than stripped. + expect(all, contains('Home')); + // The table's cells come through. + expect(all, contains('3310')); + }); + + test('resolves a relative link against the page address', () { + final page = convert(webPage).page; + final links = page.body + .whereType() + .expand((b) => b.inline.whereType()) + .map((l) => l.href); + expect(links, contains('https://example.com/about.html')); + }); + + test('an image on another site falls back to its alt text', () { + const source = 'A chart' + 'Ours'; + final images = convert(source).page.body.whereType().toList(); + expect(images.first.allowed, isFalse); + expect(images.first.alt, 'A chart'); + expect(images.last.allowed, isTrue); + }); + + test('an attribute no XML name allows does not stop the page', () { + // The wrapper in webPage carries @click and v-bind:href. + expect(() => convert(webPage), returnsNormally); + expect(convert(webPage).page.body, isNotEmpty); + }); + + test('reads the charset the document declares, not the one assumed', () { + const html = 'T' + '

Beschädigte Seite

'; + final page = convertHtmlPage( + url: 'https://example.com/', bytes: utf8.encode(html)) + .page; + final text = page.body + .whereType() + .expand((b) => b.inline.whereType()) + .map((t) => t.text) + .join(); + expect(text, contains('Beschädigte Seite')); + }); + + test('a nested table becomes a table of its own', () { + // Old web layout markup: a table inside a cell of another. + const source = '' + '' + '' + '
Outer headOuter cell
Holds one' + '
Inner aInner b
' + '
Beside it
'; + final tables = convert(source).page.body.whereType().toList(); + expect(tables.length, 2); + + String cells(TableBlock t) => t.rows + .map((r) => + r.cells.map((c) => textOf([ParagraphBlock(c.inline)])).join('|')) + .join(' / '); + + // The outer table keeps its own two rows; the inner row is not hoisted + // into it, and the inner cells do not run together in the cell's text. + expect(tables.first.rows.length, 2); + expect(cells(tables.first), contains('Outer head')); + expect(cells(tables.first), contains('Holds one')); + expect(cells(tables.first), contains('Beside it')); + expect(cells(tables.first), isNot(contains('Inner a'))); + + // The inner table stands on its own, with its content intact. + expect(tables.last.rows.length, 1); + expect(cells(tables.last), contains('Inner a')); + expect(cells(tables.last), contains('Inner b')); + }); + + test('leaves a form out but keeps what it wrapped', () { + const source = '' + '
' + '' + '' + '' + '' + '' + '
' + '

Wrapped heading

' + '

Real content inside a form.

' + ''; + final page = convert(source).page; + final text = textOf(page.body); + + // No form survives, so nothing in the view invites an answer the reader + // cannot send. + expect(page.body.whereType(), isEmpty); + expect(text, isNot(contains('Search'))); + expect(text, isNot(contains('Titles'))); + expect(text, isNot(contains('Draft'))); + expect(text, isNot(contains('Go'))); + + // The form's wrapper goes, its content stays. + expect(page.body.whereType(), isNotEmpty); + expect(text, contains('Wrapped heading')); + expect(text, contains('Real content inside a form.')); + }); + + test('markup with nothing in it still yields a page', () { + final page = convert('').page; + expect(page.url, 'https://example.com/journal'); + expect(page.body, isEmpty); + }); +} diff --git a/app/test/parser_test.dart b/app/test/parser_test.dart index 0d3bc8c..c0b05c0 100644 --- a/app/test/parser_test.dart +++ b/app/test/parser_test.dart @@ -159,12 +159,6 @@ void main() { expect(submit.label, 'Sign guestbook'); }); - test('mewsPlainText recovers text from broken markup', () { - const broken = 'Half

Visible & intact.

'; - final text = mewsPlainText(broken); - expect(text, contains('Visible & intact.')); - }); - test('pretty-printed markup leaves no phantom blank content', () { final source = ''' diff --git a/app/test/reader_widget_test.dart b/app/test/reader_widget_test.dart index 8f264db..4a08f91 100644 --- a/app/test/reader_widget_test.dart +++ b/app/test/reader_widget_test.dart @@ -82,6 +82,107 @@ void main() { expect(opened, 'https://example.com/next.html'); }); + testWidgets('two forms with one action stay apart', (tester) async { + // A web page often carries its search box twice, once per header, both + // submitting to the same address. + const search = FormBlock( + action: 'https://example.com/w/index.php', + method: 'get', + fields: [MewsTextField(name: 'search', password: false, label: 'Search')], + submitLabel: 'Go', + ); + final page = MewsPage( + url: 'https://example.com/article', + lang: 'en', + direction: TextDirection.ltr, + variant: MewsVariant.plain, + specVersion: '0.1', + entries: const [], + body: const [search, ParagraphBlock([MewsText('Between them.')]), search], + ); + + await tester.pumpWidget( + MaterialApp( + // A form's fields are Material widgets and need a sheet to sit on. + home: Scaffold( + body: StringsScope( + strings: await _strings(), + child: MewsPageView( + page: page, + palette: paletteFor( + pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), + fontFamily: 'Atkinson Hyperlegible', + fontScale: 1.0, + visitedUrls: const {}, + onLinkTap: (_) {}, + onFormSubmit: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(TextField), findsNWidgets(2)); + }); + + testWidgets('a table with ragged rows renders without holes', (tester) async { + // A web page's rows rarely match: here a header row of two, a row of + // three, and one spanning cell. Flutter's Table would assert on these. + final page = MewsPage( + url: 'https://example.com/table.html', + lang: 'en', + direction: TextDirection.ltr, + variant: MewsVariant.plain, + specVersion: '0.1', + entries: const [], + body: const [ + TableBlock( + rows: [ + MewsTableRow([ + MewsTableCell(header: true, inline: [MewsText('Model')]), + MewsTableCell(header: true, inline: [MewsText('Price')]), + ]), + MewsTableRow([ + MewsTableCell(header: false, inline: [MewsText('3310')]), + MewsTableCell(header: false, inline: [MewsText('Ten')]), + MewsTableCell(header: false, inline: [MewsText('Spare')]), + ]), + MewsTableRow([ + MewsTableCell(header: false, inline: [MewsText('Only one')]), + ]), + MewsTableRow([]), + ], + ), + ], + ); + + await tester.pumpWidget( + MaterialApp( + home: StringsScope( + strings: await _strings(), + child: MewsPageView( + page: page, + palette: paletteFor( + pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), + fontFamily: 'Atkinson Hyperlegible', + fontScale: 1.0, + visitedUrls: const {}, + onLinkTap: (_) {}, + onFormSubmit: (_, _) {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Model'), findsOneWidget); + expect(find.text('Spare'), findsOneWidget); + expect(find.text('Only one'), findsOneWidget); + }); + testWidgets('Tab walks the links on a page', (tester) async { final page = MewsPage( url: 'https://example.com/index.html',