154 lines
4.9 KiB
Dart
154 lines
4.9 KiB
Dart
/// 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';
|
|
}
|