2026-10-11 13:05:07 +03:00
|
|
|
/// The shared vocabulary every internal page is built from. Internal pages are
|
|
|
|
|
/// ordinary Mews documents, so a page has only headings, lists, paragraphs and
|
|
|
|
|
/// links to work with; these builders keep the result consistent.
|
|
|
|
|
library;
|
|
|
|
|
|
|
|
|
|
import '../../l10n/strings.dart';
|
|
|
|
|
import '../../mews/document.dart';
|
|
|
|
|
import 'mews_urls.dart';
|
|
|
|
|
|
|
|
|
|
/// Separates independent labels on one line. A separator is layout, so it is a
|
|
|
|
|
/// constant here rather than a message.
|
2026-10-11 14:03:44 +03:00
|
|
|
const actionSeparator = ' · ';
|
2026-10-11 13:05:07 +03:00
|
|
|
|
2026-10-11 13:20:00 +03:00
|
|
|
/// A link with its label: a choice in a group, or an action on a row. For an
|
|
|
|
|
/// action [current] is always false.
|
2026-10-11 13:05:07 +03:00
|
|
|
typedef Choice = ({String href, String label, bool current});
|
|
|
|
|
|
2026-10-11 13:20:00 +03:00
|
|
|
/// An action link on a row.
|
|
|
|
|
Choice action(String href, String label) =>
|
|
|
|
|
(href: href, label: label, current: false);
|
|
|
|
|
|
2026-10-11 13:05:07 +03:00
|
|
|
/// One entry in the strip of internal pages.
|
|
|
|
|
typedef NavItem = ({String url, String labelId});
|
|
|
|
|
|
|
|
|
|
/// The strip carried by every internal page, in a fixed order so its position
|
|
|
|
|
/// never shifts under the reader.
|
|
|
|
|
const navItems = <NavItem>[
|
|
|
|
|
(url: MewsUrls.hub, labelId: 'nav-hub'),
|
2026-10-11 13:20:00 +03:00
|
|
|
(url: MewsUrls.posts, labelId: 'nav-posts'),
|
2026-10-11 13:05:07 +03:00
|
|
|
(url: MewsUrls.bookmarks, labelId: 'nav-bookmarks'),
|
|
|
|
|
(url: MewsUrls.subscriptions, labelId: 'nav-subscriptions'),
|
|
|
|
|
(url: MewsUrls.history, labelId: 'nav-history'),
|
2026-10-11 13:36:44 +03:00
|
|
|
(url: MewsUrls.tags, labelId: 'nav-tags'),
|
2026-10-11 13:05:07 +03:00
|
|
|
(url: MewsUrls.settings, labelId: 'nav-settings'),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/// The title and the navigation strip that open every internal page. The page
|
|
|
|
|
/// the reader is on is plain text, matching how a current value is marked.
|
|
|
|
|
List<MewsBlock> chrome({
|
|
|
|
|
required String title,
|
|
|
|
|
required Uri current,
|
|
|
|
|
required Strings strings,
|
|
|
|
|
}) {
|
|
|
|
|
final inline = <MewsInline>[];
|
|
|
|
|
for (final item in navItems) {
|
|
|
|
|
if (inline.isNotEmpty) inline.add(const MewsText(actionSeparator));
|
|
|
|
|
final label = strings.t(item.labelId);
|
|
|
|
|
// Compare paths so a filtered or sorted page still marks its own entry.
|
|
|
|
|
if (Uri.parse(item.url).path == current.path) {
|
|
|
|
|
inline.add(MewsEmphasis(EmphasisStyle.strong, [MewsText(label)]));
|
|
|
|
|
} else {
|
|
|
|
|
inline.add(MewsLink(item.url, [MewsText(label)]));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [
|
|
|
|
|
HeadingBlock(1, [MewsText(title)]),
|
|
|
|
|
ParagraphBlock(inline),
|
|
|
|
|
const RuleBlock(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 14:03:44 +03:00
|
|
|
/// One row in a list: the item as a link, then its note and date on a second,
|
|
|
|
|
/// quieter line, then its chips and actions. One paragraph per row keeps the
|
|
|
|
|
/// lines together, since each paragraph carries its own bottom padding.
|
2026-10-11 13:05:07 +03:00
|
|
|
MewsListItem itemRow({
|
|
|
|
|
required String href,
|
|
|
|
|
required String label,
|
|
|
|
|
String? date,
|
|
|
|
|
String? note,
|
2026-10-11 13:20:00 +03:00
|
|
|
bool strong = false,
|
2026-10-11 13:32:00 +03:00
|
|
|
List<Choice> chips = const [],
|
2026-10-11 13:20:00 +03:00
|
|
|
List<Choice> actions = const [],
|
2026-10-11 13:05:07 +03:00
|
|
|
}) {
|
2026-10-11 14:03:44 +03:00
|
|
|
final meta = [?note, ?date];
|
2026-10-11 13:05:07 +03:00
|
|
|
return MewsListItem([
|
|
|
|
|
ParagraphBlock([
|
2026-10-11 13:20:00 +03:00
|
|
|
MewsLink(href, [
|
|
|
|
|
if (strong)
|
2026-10-11 14:03:44 +03:00
|
|
|
MewsEmphasis(EmphasisStyle.strong, [MewsText(label)])
|
2026-10-11 13:20:00 +03:00
|
|
|
else
|
2026-10-11 14:03:44 +03:00
|
|
|
MewsText(label),
|
2026-10-11 13:20:00 +03:00
|
|
|
]),
|
2026-10-11 14:03:44 +03:00
|
|
|
if (meta.isNotEmpty) ...[
|
2026-10-11 13:05:07 +03:00
|
|
|
const MewsLineBreak(),
|
2026-10-11 14:03:44 +03:00
|
|
|
MewsEmphasis(
|
|
|
|
|
EmphasisStyle.emphasis, [MewsText(meta.join(actionSeparator))]),
|
2026-10-11 13:05:07 +03:00
|
|
|
],
|
2026-10-11 13:32:00 +03:00
|
|
|
if (chips.isNotEmpty) ...[
|
|
|
|
|
const MewsLineBreak(),
|
2026-10-11 14:03:44 +03:00
|
|
|
// A leading hash tells a tag apart from the actions beside it, which
|
|
|
|
|
// links alone give no way to distinguish.
|
|
|
|
|
..._separated([
|
|
|
|
|
for (final chip in chips)
|
|
|
|
|
MewsLink(chip.href, [MewsText('#${chip.label}')]),
|
|
|
|
|
]),
|
2026-10-11 13:32:00 +03:00
|
|
|
],
|
2026-10-11 13:20:00 +03:00
|
|
|
if (actions.isNotEmpty) ...[
|
|
|
|
|
const MewsLineBreak(),
|
|
|
|
|
..._separated(
|
|
|
|
|
[for (final action in actions) MewsLink(action.href, [MewsText(action.label)])]),
|
|
|
|
|
],
|
2026-10-11 13:05:07 +03:00
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:20:00 +03:00
|
|
|
/// Joins runs with the separator between them, never around them.
|
|
|
|
|
List<MewsInline> _separated(List<MewsInline> runs) {
|
|
|
|
|
final out = <MewsInline>[];
|
|
|
|
|
for (final run in runs) {
|
|
|
|
|
if (out.isNotEmpty) out.add(const MewsText(actionSeparator));
|
|
|
|
|
out.add(run);
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A group of mutually exclusive values on one line, for a short group that
|
|
|
|
|
/// would waste a list. The chosen value is plain text, the rest are links.
|
|
|
|
|
List<MewsBlock> choiceLine({required String label, required List<Choice> options}) {
|
|
|
|
|
return [
|
|
|
|
|
ParagraphBlock([
|
|
|
|
|
MewsEmphasis(EmphasisStyle.emphasis, [MewsText(label)]),
|
|
|
|
|
const MewsText(actionSeparator),
|
|
|
|
|
..._separated([
|
|
|
|
|
for (final option in options)
|
|
|
|
|
if (option.current)
|
|
|
|
|
MewsEmphasis(EmphasisStyle.strong, [MewsText(option.label)])
|
|
|
|
|
else
|
|
|
|
|
MewsLink(option.href, [MewsText(option.label)]),
|
|
|
|
|
]),
|
|
|
|
|
]),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:05:07 +03:00
|
|
|
/// A group of mutually exclusive values: the current one plain, the rest links.
|
|
|
|
|
ListBlock choiceRow(List<Choice> options) {
|
|
|
|
|
return ListBlock(
|
|
|
|
|
ordered: false,
|
|
|
|
|
start: 1,
|
|
|
|
|
items: [
|
|
|
|
|
for (final option in options)
|
|
|
|
|
MewsListItem([
|
|
|
|
|
ParagraphBlock([
|
|
|
|
|
if (option.current)
|
|
|
|
|
MewsEmphasis(EmphasisStyle.strong, [MewsText(option.label)])
|
|
|
|
|
else
|
|
|
|
|
MewsLink(option.href, [MewsText(option.label)]),
|
|
|
|
|
]),
|
|
|
|
|
]),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:32:00 +03:00
|
|
|
/// A one-field form that submits back into the mews scheme, turning what the
|
|
|
|
|
/// reader typed into one query parameter named [field]. [keep] carries the
|
|
|
|
|
/// parameters the submit must not drop as hidden fields, the way an HTML form
|
|
|
|
|
/// does.
|
2026-10-11 13:25:57 +03:00
|
|
|
///
|
|
|
|
|
/// Keep this at a fixed position in a page's blocks: the renderer matches form
|
|
|
|
|
/// state by position, so a block appearing above it would reset what the
|
|
|
|
|
/// reader typed.
|
2026-10-11 13:32:00 +03:00
|
|
|
FormBlock textForm({
|
2026-10-11 13:25:57 +03:00
|
|
|
required String action,
|
2026-10-11 13:32:00 +03:00
|
|
|
required String field,
|
2026-10-11 13:25:57 +03:00
|
|
|
required String label,
|
|
|
|
|
required String submitLabel,
|
2026-10-11 13:32:00 +03:00
|
|
|
String value = '',
|
2026-10-11 13:25:57 +03:00
|
|
|
Map<String, String> keep = const {},
|
|
|
|
|
}) {
|
|
|
|
|
return FormBlock(
|
|
|
|
|
action: action,
|
|
|
|
|
method: 'get',
|
|
|
|
|
submitLabel: submitLabel,
|
|
|
|
|
fields: [
|
2026-10-11 13:32:00 +03:00
|
|
|
MewsTextField(name: field, label: label, password: false, value: value),
|
2026-10-11 13:25:57 +03:00
|
|
|
for (final kept in keep.entries)
|
|
|
|
|
MewsHiddenField(name: kept.key, value: kept.value),
|
|
|
|
|
MewsSubmitField(reset: false, label: submitLabel),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 14:03:44 +03:00
|
|
|
/// A line of links with the separator between them, never trailing. Returns
|
|
|
|
|
/// nothing when there is no link to show, so a page never holds an empty line.
|
|
|
|
|
List<MewsBlock> linkLine(List<Choice> links) {
|
|
|
|
|
if (links.isEmpty) return const [];
|
|
|
|
|
return [
|
|
|
|
|
ParagraphBlock(_separated(
|
|
|
|
|
[for (final link in links) MewsLink(link.href, [MewsText(link.label)])])),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:05:07 +03:00
|
|
|
/// A plain list of links, used for menus and library lists alike.
|
|
|
|
|
ListBlock itemList(List<MewsListItem> items) =>
|
|
|
|
|
ListBlock(ordered: false, start: 1, items: items);
|
|
|
|
|
|
|
|
|
|
/// Wraps blocks as an internal document. The marker makes the renderer treat
|
|
|
|
|
/// it as a Mews page rather than prepending the "not a Mews page" notice, and
|
|
|
|
|
/// no entries are offered so an internal page is never subscribable.
|
|
|
|
|
MewsPage internalPage({
|
|
|
|
|
required Uri url,
|
|
|
|
|
required String title,
|
|
|
|
|
required List<MewsBlock> body,
|
|
|
|
|
}) {
|
|
|
|
|
return MewsPage(
|
|
|
|
|
url: url.toString(),
|
|
|
|
|
specVersion: 'internal',
|
|
|
|
|
title: title,
|
|
|
|
|
body: body,
|
|
|
|
|
lang: 'en',
|
|
|
|
|
direction: TextDirection.ltr,
|
|
|
|
|
variant: MewsVariant.plain,
|
|
|
|
|
entries: const [],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Picks the message for [count]. Plurals are separate messages, so the
|
|
|
|
|
/// choice happens here and Fluent formats the number.
|
|
|
|
|
String countText(
|
|
|
|
|
Strings strings,
|
|
|
|
|
int count, {
|
|
|
|
|
required String none,
|
|
|
|
|
required String one,
|
|
|
|
|
required String many,
|
|
|
|
|
}) {
|
|
|
|
|
return switch (count) {
|
|
|
|
|
0 => strings.t(none),
|
|
|
|
|
1 => strings.t(one),
|
|
|
|
|
_ => strings.t(many, {'count': count}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// YYYY-MM-DD, the shape a Mews entry link uses (spec 6.1).
|
|
|
|
|
String dayText(DateTime date) =>
|
|
|
|
|
'${date.year.toString().padLeft(4, '0')}-${_two(date.month)}-${_two(date.day)}';
|
|
|
|
|
|
|
|
|
|
String clockText(DateTime date) => '${_two(date.hour)}:${_two(date.minute)}';
|
|
|
|
|
|
|
|
|
|
/// The day and the time of day, for history rows.
|
|
|
|
|
String dayTimeText(DateTime date) => '${dayText(date)} ${clockText(date)}';
|
|
|
|
|
|
|
|
|
|
String _two(int value) => value.toString().padLeft(2, '0');
|