the-mews/app/lib/mews/gemtext.dart

155 lines
4.9 KiB
Dart
Raw Normal View History

/// 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 = <MewsBlock>[];
final entries = <MewsEntry>[];
String? title;
var inPre = false;
final pre = StringBuffer();
final quote = StringBuffer();
final linkItems = <MewsListItem>[];
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<MewsBlock> 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';
}