/// 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. const actionSeparator = ' ยท '; /// A link with its label: a choice in a group, or an action on a row. For an /// action [current] is always false. typedef Choice = ({String href, String label, bool current}); /// An action link on a row. Choice action(String href, String label) => (href: href, label: label, current: false); /// 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 = [ (url: MewsUrls.hub, labelId: 'nav-hub'), (url: MewsUrls.posts, labelId: 'nav-posts'), (url: MewsUrls.bookmarks, labelId: 'nav-bookmarks'), (url: MewsUrls.subscriptions, labelId: 'nav-subscriptions'), (url: MewsUrls.history, labelId: 'nav-history'), (url: MewsUrls.tags, labelId: 'nav-tags'), (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 chrome({ required String title, required Uri current, required Strings strings, }) { final inline = []; 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(), ]; } /// 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. MewsListItem itemRow({ required String href, required String label, String? date, String? note, bool strong = false, List chips = const [], List actions = const [], }) { final meta = [?note, ?date]; return MewsListItem([ ParagraphBlock([ MewsLink(href, [ if (strong) MewsEmphasis(EmphasisStyle.strong, [MewsText(label)]) else MewsText(label), ]), if (meta.isNotEmpty) ...[ const MewsLineBreak(), MewsEmphasis( EmphasisStyle.emphasis, [MewsText(meta.join(actionSeparator))]), ], if (chips.isNotEmpty) ...[ const MewsLineBreak(), // 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}')]), ]), ], if (actions.isNotEmpty) ...[ const MewsLineBreak(), ..._separated( [for (final action in actions) MewsLink(action.href, [MewsText(action.label)])]), ], ]), ]); } /// Joins runs with the separator between them, never around them. List _separated(List runs) { final out = []; 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 choiceLine({required String label, required List 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)]), ]), ]), ]; } /// A group of mutually exclusive values: the current one plain, the rest links. ListBlock choiceRow(List 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)]), ]), ]), ], ); } /// 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. /// /// 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. FormBlock textForm({ required String action, required String field, required String label, required String submitLabel, String value = '', Map keep = const {}, }) { return FormBlock( action: action, method: 'get', submitLabel: submitLabel, fields: [ MewsTextField(name: field, label: label, password: false, value: value), for (final kept in keep.entries) MewsHiddenField(name: kept.key, value: kept.value), MewsSubmitField(reset: false, label: submitLabel), ], ); } /// 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 linkLine(List links) { if (links.isEmpty) return const []; return [ ParagraphBlock(_separated( [for (final link in links) MewsLink(link.href, [MewsText(link.label)])])), ]; } /// A plain list of links, used for menus and library lists alike. ListBlock itemList(List 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. /// /// Unlike a fetched page, which declares its own language, one of ours is /// written in whichever language the reader set, so it takes its direction /// from that and mirrors when the interface does. MewsPage internalPage({ required Uri url, required String title, required List body, required Strings strings, }) { return MewsPage( url: url.toString(), specVersion: 'internal', title: title, body: body, lang: strings.tag, direction: strings.isRtl ? TextDirection.rtl : TextDirection.ltr, variant: MewsVariant.plain, entries: const [], ); } /// Picks the message for [count]. Plurals are separate messages rather than a /// rule inside one, so the choice happens here. /// /// A language needing a form English does not have, such as Russian's "few" /// or Hebrew's "two", names it with the same id and that suffix. Those are /// derived from [many] rather than passed at every call, and a language /// without them falls through to [many]. String countText( Strings strings, int count, { required String none, required String one, required String many, }) { if (count == 0) return strings.t(none); final suffix = strings.pluralSuffixFor(count); if (suffix == 'one') return strings.t(one, {'count': count}); if (suffix != 'many') { final stem = many.substring(0, many.lastIndexOf('-')); final text = strings.lookup('$stem-$suffix', {'count': count}); if (text != null) return text; } return 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');