feat: add Gemini and Spartan protocol support
This commit is contained in:
parent
a4a49f41d7
commit
fa7aa5557c
48 changed files with 4992 additions and 161 deletions
154
app/lib/mews/gemtext.dart
Normal file
154
app/lib/mews/gemtext.dart
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/// 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';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue