diff --git a/sites/docs/lib/src/components/layout/banner.dart b/sites/docs/lib/src/components/layout/banner.dart index 7bce79cae1f..671ad7dc6bd 100644 --- a/sites/docs/lib/src/components/layout/banner.dart +++ b/sites/docs/lib/src/components/layout/banner.dart @@ -5,15 +5,104 @@ import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; -/// The site-wide banner. -class DashBanner extends StatelessComponent { - const DashBanner(this.inlineHtmlContent, {super.key}); +/// The information to display in the site banner, +/// as configured in `src/data/banner.yml`. +@immutable +final class BannerContent { + /// The ordered content parts to render in the banner. + final List parts; + + /// Creates banner content from the specified [parts]. + const BannerContent({required this.parts}); + + /// Creates banner content from the parsed [bannerData]. + /// + /// The [bannerData] list is expected to contain + /// `text`, `link`, or `newLine` entries from `src/data/banner.yml`. + /// + /// Throws if any entry has an unsupported structure. + factory BannerContent.fromList(List bannerData) => BannerContent( + parts: [ + for (final item in bannerData) + switch (item) { + {'text': final String text} => .text(text), + {'link': final Map link} => .link( + text: link['text'] as String, + url: link['url'] as String, + newTab: link['newTab'] as bool? ?? false, + ), + {'newLine': _} => const .newLine(), + _ => throw FormatException('Invalid banner item: $item'), + }, + ], + ); +} + +/// A single renderable piece of banner content. +@immutable +sealed class BannerPart { + /// Creates a banner content part. + const BannerPart(); + + /// Creates a text part with the specified [text]. + const factory BannerPart.text(String text) = _BannerText; + + /// Creates a link part with the specified [text] and [url]. + /// + /// Unless [newTab] is `true`, the link opens in the same tab. + const factory BannerPart.link({ + required String text, + required String url, + bool newTab, + }) = _BannerLink; + + /// Creates a new line part that renders a line break. + const factory BannerPart.newLine() = _BannerNewLine; +} + +/// Plain text within a site banner. +final class _BannerText extends BannerPart { + /// Creates a text banner part with the specified [text]. + const _BannerText(this.text); + + /// The text to render in the banner. + final String text; +} - /// The raw, inline HTML content to render in the banner. +/// A link within a site banner. +final class _BannerLink extends BannerPart { + /// Creates a link banner part with the specified [text] and [url]. /// - /// This should only be sourced from managed content, - /// such as our checked-in data files. - final String inlineHtmlContent; + /// Unless [newTab] is `true`, the link opens in the same tab. + const _BannerLink({ + required this.text, + required this.url, + this.newTab = false, + }); + + /// The link label to render in the banner. + final String text; + + /// The destination URL for this link. + final String url; + + /// Whether this link opens in a new browser tab. + final bool newTab; +} + +/// A line break within a site banner. +final class _BannerNewLine extends BannerPart { + /// Creates a line break banner part. + const _BannerNewLine(); +} + +/// A site-wide banner rendered from structured content. +class DashBanner extends StatelessComponent { + /// Creates a site banner that displays the specified [content]. + const DashBanner(this.content, {super.key}); + + /// The structured content to render in this banner. + final BannerContent content; @override Component build(BuildContext context) => div( @@ -21,7 +110,17 @@ class DashBanner extends StatelessComponent { attributes: {'role': 'alert'}, [ p([ - RawText(inlineHtmlContent), + for (final part in content.parts) + switch (part) { + _BannerText(:final text) => .text(text), + _BannerLink(:final text, :final url, :final newTab) => a( + href: url, + target: newTab ? Target.blank : null, + attributes: newTab ? const {'rel': 'noopener'} : null, + [.text(text)], + ), + _BannerNewLine() => const br(), + }, ]), ], ); diff --git a/sites/docs/lib/src/layouts/dash_layout.dart b/sites/docs/lib/src/layouts/dash_layout.dart index 5c41a6b8cd4..d1e5d6a676a 100644 --- a/sites/docs/lib/src/layouts/dash_layout.dart +++ b/sites/docs/lib/src/layouts/dash_layout.dart @@ -9,6 +9,7 @@ import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/jaspr_content.dart'; import '../components/common/client/cookie_notice.dart'; +import '../components/layout/banner.dart'; import '../components/layout/footer.dart'; import '../components/layout/header.dart'; import '../components/layout/sidenav.dart'; @@ -270,6 +271,21 @@ if (sidenav) { ); } + /// Builds the banner component for the given [page]. + Component? buildBanner(Page page) { + final showBanner = + (page.data.page['showBanner'] as bool?) ?? + (page.data.site['showBanner'] as bool?) ?? + false; + if (showBanner) { + if (page.data['banner'] case final List bannerData) { + return DashBanner(BannerContent.fromList(bannerData)); + } + } + + return null; + } + /// Builds the speculation rules `