/// Converts gemtext, the format Gemini and Spartan pages are written in, into /// the document model (document.dart), so protocol pages render through the /// same reader presentation as Mews pages. Gemtext declares neither structure /// nor metadata: there is no inline markup, no head, no title, so the first /// level-1 heading stands in for a title and the reader presentation decides /// everything else. /// /// A link line whose label begins with a date is a dated entry (mews-spec /// 6.1), which keeps subscriptions working on protocol sites. library; import 'dart:convert'; import 'document.dart'; /// The conformance marker gemtext pages carry in place of the Mews one, so the /// renderer applies the reader presentation instead of falling back to plain. const gemtextSpecVersion = 'gemtext'; final _entryDatePattern = RegExp(r'^(\d{4}-\d{2}-\d{2}) (.+)$'); MewsPage parseGemtextPage({required String source, required String url}) { final blocks = []; final entries = []; String? title; var inPre = false; final pre = StringBuffer(); final quote = StringBuffer(); final linkItems = []; void closePre() { // One trailing newline rides with the last line; the fence is not content. blocks.add(PreBlock(pre.toString().replaceFirst(RegExp(r'\n$'), ''))); pre.clear(); } // Consecutive lines of one kind are gathered until a line of another kind // ends them. void closeQuote() { if (quote.isEmpty) return; final lines = quote.toString().split('\n'); blocks.add(QuoteBlock([ for (final line in lines) if (line.trim().isNotEmpty) ParagraphBlock([MewsText(line.trim())]), ])); quote.clear(); } void closeLinks() { if (linkItems.isEmpty) return; blocks.add(ListBlock(ordered: false, start: 1, items: List.of(linkItems))); linkItems.clear(); } for (final line in const LineSplitter().convert(source)) { if (inPre) { if (line.startsWith('```')) { closePre(); inPre = false; } else { pre.writeln(line); } continue; } if (line.startsWith('```')) { closeQuote(); closeLinks(); inPre = true; continue; } if (line.startsWith('=>')) { closeQuote(); final parts = line.substring(2).trim().split(RegExp(r'\s+')); if (parts.isEmpty || parts.first.isEmpty) continue; final href = Uri.parse(url).resolve(parts.first).toString(); final label = parts.skip(1).join(' ').trim(); final text = label.isEmpty ? parts.first : label; final match = _entryDatePattern.firstMatch(text); if (match != null) { final date = DateTime.tryParse(match.group(1)!); if (date != null) { entries.add(MewsEntry(url: href, date: date, title: match.group(2)!.trim())); } } linkItems.add(MewsListItem([ParagraphBlock([MewsLink(href, [MewsText(text)])])])); continue; } closeLinks(); if (line.startsWith('###') && !line.startsWith('####')) { closeQuote(); final text = line.substring(3).trim(); blocks.add(HeadingBlock(3, [MewsText(text)])); } else if (line.startsWith('##') && !line.startsWith('###')) { closeQuote(); final text = line.substring(2).trim(); blocks.add(HeadingBlock(2, [MewsText(text)])); } else if (line.startsWith('#') && !line.startsWith('##')) { closeQuote(); final text = line.substring(1).trim(); final heading = HeadingBlock(1, [MewsText(text)]); title ??= text; blocks.add(heading); } else if (line.startsWith('* ')) { closeQuote(); _listOf(blocks).items.add(MewsListItem([ParagraphBlock([MewsText(line.substring(2).trim())])])); } else if (line.startsWith('>')) { quote.writeln(line.substring(1).trim()); } else if (line.trim().isNotEmpty) { closeQuote(); blocks.add(ParagraphBlock([MewsText(line.trim())])); } else { closeQuote(); } } if (inPre) closePre(); closeQuote(); closeLinks(); entries.sort((a, b) => b.date.compareTo(a.date)); final text = source; return MewsPage( url: url, specVersion: gemtextSpecVersion, title: title, lang: _langOf(text), direction: _isRtl(text) ? TextDirection.rtl : TextDirection.ltr, variant: MewsVariant.plain, entries: entries, body: blocks, ); } /// Appends a list block to build on, reusing the last one while consecutive /// items keep coming. ListBlock _listOf(List blocks) { if (blocks.isNotEmpty && blocks.last is ListBlock && !(blocks.last as ListBlock).ordered) { return blocks.last as ListBlock; } final fresh = ListBlock(ordered: false, start: 1, items: []); blocks.add(fresh); return fresh; } final _rtlPattern = RegExp(r'[\u0590-\u08FF]'); bool _isRtl(String text) => _rtlPattern.hasMatch(text); String _langOf(String text) { if (RegExp(r'[\u0590-\u05FF]').hasMatch(text)) return 'he'; if (RegExp(r'[\u0600-\u06FF]').hasMatch(text)) return 'ar'; return 'en'; }