diff --git a/packages/site_shared/README.md b/packages/site_shared/README.md new file mode 100644 index 00000000000..db75ce7d779 --- /dev/null +++ b/packages/site_shared/README.md @@ -0,0 +1,48 @@ +# site_shared + +This package is the core library containing +shared logic, UI components, and the design system for +the Dart and Flutter documentation sites. + +It provides a centralized location for APIs, +user interface elements, and logic intended for use by +both the `dart.dev` and `docs.flutter.dev` websites. +Using a shared package ensures a consistent design language and +feature set across Dart and Flutter web documentation platforms. + +## What's included + +The `site_shared` package provides several key capabilities to +build documentation websites using Dart, Jaspr, and Jaspr Content: + +- **UI components** (`lib/components`): + Reusable, modular components built for use across documentation pages. + - **Common components** (`lib/components/common`): + Everyday UI elements such as breadcrumbs, buttons, code blocks, and more. + - **Layout components** (`lib/components/layout`): + Structural layout elements like theme switchers, + site switchers, banners, and menu toggles. + - **Interactive components**: + Integrations such as Dartpad (`lib/components/dartpad`), + tutorials, and user client-side feedback tools. +- **Markdown extensions and processors** (`lib/extensions`): + Custom processors that hook into the Dart Markdown parser to + extend its default syntax and behavior, such as `attribute_processor.dart`. +- **Core styles** (`lib/_sass`): + The shared base styles and component-specific SCSS styling. + These resources define the unified visual identity used by both websites. +- **Utilities and builders** (`lib/src`): + Reusable logic for code syntax highlighting (`lib/src/highlight`), + analytics integrations (`lib/src/analytics`), + builders (`lib/src/builders`), and various helper utilities. + +## Goals + +The primary aims of this shared package are to: + +1. Streamline styling and standardize UI component implementation + across our various websites. +1. Prevent code duplication between the + `dart-lang/site-www` and `flutter/website` repositories. +1. Establish a robust codebase that can be + updated, maintained, and improved in a unified way. diff --git a/packages/site_shared/analysis_options.yaml b/packages/site_shared/analysis_options.yaml new file mode 100644 index 00000000000..95c3595413a --- /dev/null +++ b/packages/site_shared/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:analysis_defaults/analysis.yaml + +formatter: + trailing_commas: preserve diff --git a/packages/site_shared/build.yaml b/packages/site_shared/build.yaml new file mode 100644 index 00000000000..2f015507221 --- /dev/null +++ b/packages/site_shared/build.yaml @@ -0,0 +1,14 @@ +builders: + stylesHashBuilder: + import: "package:site_shared/src/builders/styles_hash_builder.dart" + builder_factories: ["stylesHashBuilder"] + build_extensions: + "web/assets/css/main.css": + - "lib/src/style_hash.dart" + auto_apply: dependents + build_to: source + required_inputs: + - ".css" + defaults: + dev_options: + fixed_hash: true diff --git a/sites/docs/lib/_sass/base/_mixins.scss b/packages/site_shared/lib/_sass/base/_mixins.scss similarity index 100% rename from sites/docs/lib/_sass/base/_mixins.scss rename to packages/site_shared/lib/_sass/base/_mixins.scss diff --git a/sites/docs/lib/_sass/base/_reset.scss b/packages/site_shared/lib/_sass/base/_reset.scss similarity index 100% rename from sites/docs/lib/_sass/base/_reset.scss rename to packages/site_shared/lib/_sass/base/_reset.scss diff --git a/sites/docs/lib/_sass/components/_alert.scss b/packages/site_shared/lib/_sass/components/_alert.scss similarity index 100% rename from sites/docs/lib/_sass/components/_alert.scss rename to packages/site_shared/lib/_sass/components/_alert.scss diff --git a/sites/docs/lib/_sass/components/_banner.scss b/packages/site_shared/lib/_sass/components/_banner.scss similarity index 100% rename from sites/docs/lib/_sass/components/_banner.scss rename to packages/site_shared/lib/_sass/components/_banner.scss diff --git a/sites/docs/lib/_sass/components/_breadcrumbs.scss b/packages/site_shared/lib/_sass/components/_breadcrumbs.scss similarity index 100% rename from sites/docs/lib/_sass/components/_breadcrumbs.scss rename to packages/site_shared/lib/_sass/components/_breadcrumbs.scss diff --git a/sites/docs/lib/_sass/components/_button.scss b/packages/site_shared/lib/_sass/components/_button.scss similarity index 98% rename from sites/docs/lib/_sass/components/_button.scss rename to packages/site_shared/lib/_sass/components/_button.scss index e4df9379615..079bbc2fe87 100644 --- a/sites/docs/lib/_sass/components/_button.scss +++ b/packages/site_shared/lib/_sass/components/_button.scss @@ -25,7 +25,8 @@ button { cursor: pointer; &.filled-button, - &.text-button, &.outlined-button { + &.text-button, + &.outlined-button { display: flex; align-items: center; width: fit-content; @@ -125,4 +126,4 @@ button { border-bottom-left-radius: 0; } } -} +} \ No newline at end of file diff --git a/sites/docs/lib/_sass/components/_card.scss b/packages/site_shared/lib/_sass/components/_card.scss similarity index 99% rename from sites/docs/lib/_sass/components/_card.scss rename to packages/site_shared/lib/_sass/components/_card.scss index 7c9a31b48a8..baa367abe62 100644 --- a/sites/docs/lib/_sass/components/_card.scss +++ b/packages/site_shared/lib/_sass/components/_card.scss @@ -173,7 +173,7 @@ } } - &.install-card { + &.install-card { gap: 0.25rem; .card-leading { @@ -281,4 +281,4 @@ button.card { width: 100%; max-height: 100%; } -} +} \ No newline at end of file diff --git a/sites/docs/lib/_sass/components/_code.scss b/packages/site_shared/lib/_sass/components/_code.scss similarity index 100% rename from sites/docs/lib/_sass/components/_code.scss rename to packages/site_shared/lib/_sass/components/_code.scss diff --git a/sites/docs/lib/_sass/components/_cookie-notice.scss b/packages/site_shared/lib/_sass/components/_cookie-notice.scss similarity index 100% rename from sites/docs/lib/_sass/components/_cookie-notice.scss rename to packages/site_shared/lib/_sass/components/_cookie-notice.scss diff --git a/sites/docs/lib/_sass/components/_dropdown.scss b/packages/site_shared/lib/_sass/components/_dropdown.scss similarity index 99% rename from sites/docs/lib/_sass/components/_dropdown.scss rename to packages/site_shared/lib/_sass/components/_dropdown.scss index d121e252617..0822040569d 100644 --- a/sites/docs/lib/_sass/components/_dropdown.scss +++ b/packages/site_shared/lib/_sass/components/_dropdown.scss @@ -68,4 +68,4 @@ display: block; } } -} +} \ No newline at end of file diff --git a/packages/site_shared/lib/_sass/components/_menu-toggle.scss b/packages/site_shared/lib/_sass/components/_menu-toggle.scss new file mode 100644 index 00000000000..ea0d9618f75 --- /dev/null +++ b/packages/site_shared/lib/_sass/components/_menu-toggle.scss @@ -0,0 +1,26 @@ +// Toggle between menu and close buttons if sidenav is open or not. +body:not(.sidenav-closed) #menu-toggle { + @media (min-width: 1024px) { + display: none; + } +} + +#menu-toggle span.material-symbols { + &:first-child { + display: inline; + } + + &:last-child { + display: none; + } +} + +body.open_menu #menu-toggle span.material-symbols { + &:first-child { + display: none; + } + + &:last-child { + display: inline; + } +} diff --git a/sites/docs/lib/_sass/components/_quiz.scss b/packages/site_shared/lib/_sass/components/_quiz.scss similarity index 100% rename from sites/docs/lib/_sass/components/_quiz.scss rename to packages/site_shared/lib/_sass/components/_quiz.scss diff --git a/sites/docs/lib/_sass/components/_site-switcher.scss b/packages/site_shared/lib/_sass/components/_site-switcher.scss similarity index 97% rename from sites/docs/lib/_sass/components/_site-switcher.scss rename to packages/site_shared/lib/_sass/components/_site-switcher.scss index 58b3fb0e331..07b6fad5300 100644 --- a/sites/docs/lib/_sass/components/_site-switcher.scss +++ b/packages/site_shared/lib/_sass/components/_site-switcher.scss @@ -1,5 +1,3 @@ -@use '../base/mixins'; - #site-switcher { position: relative; diff --git a/sites/docs/lib/_sass/components/_stepper.scss b/packages/site_shared/lib/_sass/components/_stepper.scss similarity index 100% rename from sites/docs/lib/_sass/components/_stepper.scss rename to packages/site_shared/lib/_sass/components/_stepper.scss diff --git a/sites/docs/lib/_sass/components/_summary-card.scss b/packages/site_shared/lib/_sass/components/_summary-card.scss similarity index 100% rename from sites/docs/lib/_sass/components/_summary-card.scss rename to packages/site_shared/lib/_sass/components/_summary-card.scss diff --git a/sites/docs/lib/_sass/components/_tabs.scss b/packages/site_shared/lib/_sass/components/_tabs.scss similarity index 99% rename from sites/docs/lib/_sass/components/_tabs.scss rename to packages/site_shared/lib/_sass/components/_tabs.scss index 17381350e7c..05a54bd83d9 100644 --- a/sites/docs/lib/_sass/components/_tabs.scss +++ b/packages/site_shared/lib/_sass/components/_tabs.scss @@ -93,4 +93,4 @@ ul.nav-tabs { } } } -} +} \ No newline at end of file diff --git a/sites/docs/lib/_sass/components/_theming.scss b/packages/site_shared/lib/_sass/components/_theming.scss similarity index 100% rename from sites/docs/lib/_sass/components/_theming.scss rename to packages/site_shared/lib/_sass/components/_theming.scss diff --git a/sites/docs/lib/_sass/components/_tooltip.scss b/packages/site_shared/lib/_sass/components/_tooltip.scss similarity index 100% rename from sites/docs/lib/_sass/components/_tooltip.scss rename to packages/site_shared/lib/_sass/components/_tooltip.scss diff --git a/packages/site_shared/lib/analytics.dart b/packages/site_shared/lib/analytics.dart new file mode 100644 index 00000000000..5c8fecb1023 --- /dev/null +++ b/packages/site_shared/lib/analytics.dart @@ -0,0 +1,21 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; + +import 'src/analytics/analytics_server.dart' + if (dart.library.js_interop) 'src/analytics/analytics_web.dart'; + +/// Used to report analytic events. +final analytics = AnalyticsImplementation(); + +/// Contains methods for reporting analytics events. +abstract class Analytics { + @protected + void sendEvent(String eventName, Map parameters); + + void sendFeedback(bool helpful) { + sendEvent('feedback', {'feedback_type': helpful ? 'up' : 'down'}); + } +} diff --git a/sites/docs/lib/src/components/common/breadcrumbs.dart b/packages/site_shared/lib/components/common/breadcrumbs.dart similarity index 90% rename from sites/docs/lib/src/components/common/breadcrumbs.dart rename to packages/site_shared/lib/components/common/breadcrumbs.dart index 66d9bb4894f..9a0ef2790b0 100644 --- a/sites/docs/lib/src/components/common/breadcrumbs.dart +++ b/packages/site_shared/lib/components/common/breadcrumbs.dart @@ -18,11 +18,14 @@ import 'material_icon.dart'; /// - https://schema.org/BreadcrumbList /// - https://www.w3.org/TR/wai-aria-practices/examples/breadcrumb/index.html class PageBreadcrumbs extends StatelessComponent { - const PageBreadcrumbs({super.key}); + const PageBreadcrumbs({this.crumbs, super.key}); + + final List? crumbs; @override Component build(BuildContext context) { - final crumbs = _breadcrumbsForPage(context.pages, context.page); + final crumbs = + this.crumbs ?? _breadcrumbsForPage(context.pages, context.page); if (crumbs == null || crumbs.isEmpty) { return const Component.empty(); } @@ -54,7 +57,7 @@ class PageBreadcrumbs extends StatelessComponent { /// /// Uses page metadata to generate breadcrumb titles with fallbacks: /// `breadcrumb` > `shortTitle` > `title`. - List<_BreadcrumbItem>? _breadcrumbsForPage(List pages, Page page) { + List? _breadcrumbsForPage(List pages, Page page) { final pageUrl = page.url; // Only show breadcrumbs if the URL isn't empty. @@ -71,7 +74,7 @@ class PageBreadcrumbs extends StatelessComponent { .toList(growable: false); if (segments.isEmpty) return null; - final breadcrumbs = <_BreadcrumbItem>[]; + final breadcrumbs = []; var currentPath = ''; // Build breadcrumbs for each segment except the current page. @@ -88,7 +91,7 @@ class PageBreadcrumbs extends StatelessComponent { if (indexPage.breadcrumb case final indexBreadcrumb?) { breadcrumbs.add( - _BreadcrumbItem( + BreadcrumbItem( title: indexBreadcrumb, url: indexPage.url, ), @@ -104,7 +107,7 @@ class PageBreadcrumbs extends StatelessComponent { // Add the current page as the final breadcrumb. breadcrumbs.add( - _BreadcrumbItem( + BreadcrumbItem( title: pageBreadcrumb, url: pageUrl, ), @@ -127,8 +130,8 @@ extension on Page { } } -final class _BreadcrumbItem { - const _BreadcrumbItem({required this.title, required this.url}); +final class BreadcrumbItem { + const BreadcrumbItem({required this.title, required this.url}); final String title; final String url; @@ -142,7 +145,7 @@ final class _BreadcrumbItemComponent extends StatelessComponent { required this.isLast, }); - final _BreadcrumbItem crumb; + final BreadcrumbItem crumb; final int index; final bool isLast; diff --git a/sites/docs/lib/src/components/common/button.dart b/packages/site_shared/lib/components/common/button.dart similarity index 85% rename from sites/docs/lib/src/components/common/button.dart rename to packages/site_shared/lib/components/common/button.dart index 740af24cf37..49dd3727c4b 100644 --- a/sites/docs/lib/src/components/common/button.dart +++ b/packages/site_shared/lib/components/common/button.dart @@ -14,6 +14,7 @@ class Button extends StatelessComponent { const Button({ super.key, this.icon, + this.trailingIcon, this.href, this.content, this.style = ButtonStyle.text, @@ -30,6 +31,7 @@ class Button extends StatelessComponent { final String? title; final ButtonStyle style; final String? icon; + final String? trailingIcon; final String? id; final String? href; final Map attributes; @@ -48,7 +50,8 @@ class Button extends StatelessComponent { final mergedClasses = [ style.cssClass, - if (icon != null && content == null) 'icon-button', + if ((icon != null || trailingIcon != null) && content == null) + 'icon-button', ...?classes, ].toClasses; @@ -56,6 +59,7 @@ class Button extends StatelessComponent { if (icon case final iconId?) MaterialIcon(iconId), if (content case final contentText?) asRaw ? RawText(contentText) : .text(contentText), + if (trailingIcon case final iconId?) MaterialIcon(iconId), ]; if (href case final href?) { @@ -82,7 +86,8 @@ class Button extends StatelessComponent { enum ButtonStyle { filled, outlined, - text; + text + ; String get cssClass => switch (this) { ButtonStyle.filled => 'filled-button', @@ -90,17 +95,3 @@ enum ButtonStyle { ButtonStyle.text => 'text-button', }; } - -class SegmentedButton extends StatelessComponent { - const SegmentedButton({ - super.key, - required this.children, - }); - - final List children; - - @override - Component build(BuildContext context) { - return span(classes: ['segmented-button'].toClasses, children); - } -} diff --git a/sites/docs/lib/src/components/common/card.dart b/packages/site_shared/lib/components/common/card.dart similarity index 100% rename from sites/docs/lib/src/components/common/card.dart rename to packages/site_shared/lib/components/common/card.dart diff --git a/sites/docs/lib/src/components/common/chip.dart b/packages/site_shared/lib/components/common/chip.dart similarity index 99% rename from sites/docs/lib/src/components/common/chip.dart rename to packages/site_shared/lib/components/common/chip.dart index 41415e59693..080ec351b95 100644 --- a/sites/docs/lib/src/components/common/chip.dart +++ b/packages/site_shared/lib/components/common/chip.dart @@ -7,7 +7,7 @@ import 'package:jaspr/jaspr.dart'; import 'package:universal_web/web.dart' as web; import '../../util.dart'; -import '../util/global_event_listener.dart'; +import '../utils/global_event_listener.dart'; import 'material_icon.dart'; /// A set of Material Design-like chips for configuration. diff --git a/sites/docs/lib/src/components/common/client/collapse_button.dart b/packages/site_shared/lib/components/common/client/collapse_button.dart similarity index 100% rename from sites/docs/lib/src/components/common/client/collapse_button.dart rename to packages/site_shared/lib/components/common/client/collapse_button.dart diff --git a/sites/docs/lib/src/components/common/client/cookie_notice.dart b/packages/site_shared/lib/components/common/client/cookie_notice.dart similarity index 88% rename from sites/docs/lib/src/components/common/client/cookie_notice.dart rename to packages/site_shared/lib/components/common/client/cookie_notice.dart index b4362a5aca0..a01abe85135 100644 --- a/sites/docs/lib/src/components/common/client/cookie_notice.dart +++ b/packages/site_shared/lib/components/common/client/cookie_notice.dart @@ -12,7 +12,14 @@ import '../button.dart'; /// The cookie banner to show on a user's first time visiting the site. @client final class CookieNotice extends StatefulComponent { - const CookieNotice({super.key}); + const CookieNotice({ + super.key, + required this.host, + this.alwaysDarkMode = false, + }); + + final String host; + final bool alwaysDarkMode; @override State createState() => _CookieNoticeState(); @@ -60,13 +67,16 @@ final class _CookieNoticeState extends State { Component build(BuildContext context) { return section( id: 'cookie-notice', - classes: [if (showNotice) 'show'].toClasses, + classes: [ + if (showNotice) 'show', + if (component.alwaysDarkMode) 'always-dark-mode', + ].toClasses, attributes: {'data-nosnippet': 'true'}, [ div(classes: 'container', [ - const p([ + p([ .text( - 'docs.flutter.dev uses cookies from Google to deliver and ' + '${component.host} uses cookies from Google to deliver and ' 'enhance the quality of its services and to analyze traffic.', ), ]), diff --git a/sites/docs/lib/src/components/common/client/copy_button.dart b/packages/site_shared/lib/components/common/client/copy_button.dart similarity index 80% rename from sites/docs/lib/src/components/common/client/copy_button.dart rename to packages/site_shared/lib/components/common/client/copy_button.dart index fffc9dc0aba..7270990470c 100644 --- a/sites/docs/lib/src/components/common/client/copy_button.dart +++ b/packages/site_shared/lib/components/common/client/copy_button.dart @@ -11,11 +11,13 @@ import '../button.dart'; class CopyButton extends StatefulComponent { const CopyButton({ this.buttonText, + this.toCopy, this.classes = const [], this.title, }); final String? title; + final String? toCopy; final String? buttonText; final List classes; @@ -32,9 +34,11 @@ class _CopyButtonState extends State { @override void initState() { if (kIsWeb) { - // Extract the code content and unhide the copy button on the client. - context.binding.addPostFrameCallback(() { - setState(() { + if (component.toCopy != null) { + content = component.toCopy; + } else { + // Extract the code content and unhide the copy button on the client. + context.binding.addPostFrameCallback(() { final codeElement = buttonKey.currentNode ?.closest('.code-block-wrapper') ?.querySelector('pre code') @@ -46,6 +50,7 @@ class _CopyButtonState extends State { codeElement, /* NodeFilter.SHOW_ELEMENT */ 1, ); + web.Node? currentNode; while ((currentNode = iterator.nextNode()) != null) { final element = currentNode as web.Element; @@ -55,15 +60,19 @@ class _CopyButtonState extends State { } // Remove zero-width spaces - content = codeElement.textContent?.replaceAll('\u200B', ''); - }); + final extracted = codeElement.textContent?.replaceAll('\u200B', ''); + + assert( + extracted != null, + 'CopyButton: Unable to find code content to copy. ' + 'Is the CopyButton inside a code block?', + ); - assert( - content != null, - 'CopyButton: Unable to find code content to copy. ' - 'Is the CopyButton inside a code block?', - ); - }); + setState(() { + content = extracted; + }); + }); + } } super.initState(); diff --git a/sites/docs/lib/src/components/common/client/download_button.dart b/packages/site_shared/lib/components/common/client/download_button.dart similarity index 100% rename from sites/docs/lib/src/components/common/client/download_button.dart rename to packages/site_shared/lib/components/common/client/download_button.dart diff --git a/sites/docs/lib/src/components/common/client/feedback.dart b/packages/site_shared/lib/components/common/client/feedback.dart similarity index 98% rename from sites/docs/lib/src/components/common/client/feedback.dart rename to packages/site_shared/lib/components/common/client/feedback.dart index 721bee0d308..7baaafd0b49 100644 --- a/sites/docs/lib/src/components/common/client/feedback.dart +++ b/packages/site_shared/lib/components/common/client/feedback.dart @@ -5,7 +5,7 @@ import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; -import '../../../analytics/analytics.dart'; +import '../../../analytics.dart'; import '../button.dart'; /// Provides the user options to provide feedback on the specified page. @@ -83,7 +83,8 @@ enum _FeedbackState { unhelpful( 'Thank you for your feedback! ' 'Please let us know what we can do to improve.', - ); + ) + ; const _FeedbackState(this.introduction); diff --git a/sites/docs/lib/src/components/common/client/on_this_page_button.dart b/packages/site_shared/lib/components/common/client/on_this_page_button.dart similarity index 100% rename from sites/docs/lib/src/components/common/client/on_this_page_button.dart rename to packages/site_shared/lib/components/common/client/on_this_page_button.dart diff --git a/sites/docs/lib/src/components/common/client/page_header_options.dart b/packages/site_shared/lib/components/common/client/page_header_options.dart similarity index 100% rename from sites/docs/lib/src/components/common/client/page_header_options.dart rename to packages/site_shared/lib/components/common/client/page_header_options.dart diff --git a/sites/docs/lib/src/components/common/client/simple_tooltip.dart b/packages/site_shared/lib/components/common/client/simple_tooltip.dart similarity index 83% rename from sites/docs/lib/src/components/common/client/simple_tooltip.dart rename to packages/site_shared/lib/components/common/client/simple_tooltip.dart index 869527f5de5..12e9f1fba84 100644 --- a/sites/docs/lib/src/components/common/client/simple_tooltip.dart +++ b/packages/site_shared/lib/components/common/client/simple_tooltip.dart @@ -4,7 +4,7 @@ import 'package:jaspr/jaspr.dart'; -import '../../util/component_ref.dart'; +import '../../utils/component_ref.dart'; import '../tooltip.dart'; @client @@ -21,8 +21,8 @@ class SimpleTooltip extends StatelessComponent { @override Component build(BuildContext context) { return Tooltip( - target: target.component, - content: content.component, + target: target, + content: content, ); } } diff --git a/sites/docs/lib/src/components/common/dropdown.dart b/packages/site_shared/lib/components/common/dropdown.dart similarity index 98% rename from sites/docs/lib/src/components/common/dropdown.dart rename to packages/site_shared/lib/components/common/dropdown.dart index bf177204590..3ec74b55727 100644 --- a/sites/docs/lib/src/components/common/dropdown.dart +++ b/packages/site_shared/lib/components/common/dropdown.dart @@ -7,7 +7,7 @@ import 'package:jaspr/jaspr.dart'; import 'package:universal_web/web.dart' as web; -import '../util/global_event_listener.dart'; +import '../utils/global_event_listener.dart'; /// A dropdown with a toggle button and expandable content. final class Dropdown extends StatefulComponent { diff --git a/sites/docs/lib/src/components/common/fragment_target.dart b/packages/site_shared/lib/components/common/fragment_target.dart similarity index 100% rename from sites/docs/lib/src/components/common/fragment_target.dart rename to packages/site_shared/lib/components/common/fragment_target.dart diff --git a/sites/docs/lib/src/components/common/material_icon.dart b/packages/site_shared/lib/components/common/material_icon.dart similarity index 100% rename from sites/docs/lib/src/components/common/material_icon.dart rename to packages/site_shared/lib/components/common/material_icon.dart diff --git a/sites/docs/lib/src/components/common/search.dart b/packages/site_shared/lib/components/common/search.dart similarity index 100% rename from sites/docs/lib/src/components/common/search.dart rename to packages/site_shared/lib/components/common/search.dart diff --git a/sites/docs/lib/src/components/common/tabs.dart b/packages/site_shared/lib/components/common/tabs.dart similarity index 100% rename from sites/docs/lib/src/components/common/tabs.dart rename to packages/site_shared/lib/components/common/tabs.dart diff --git a/sites/docs/lib/src/components/common/tags.dart b/packages/site_shared/lib/components/common/tags.dart similarity index 100% rename from sites/docs/lib/src/components/common/tags.dart rename to packages/site_shared/lib/components/common/tags.dart diff --git a/sites/docs/lib/src/components/common/tooltip.dart b/packages/site_shared/lib/components/common/tooltip.dart similarity index 98% rename from sites/docs/lib/src/components/common/tooltip.dart rename to packages/site_shared/lib/components/common/tooltip.dart index 932f174ff24..3cdb63782bf 100644 --- a/sites/docs/lib/src/components/common/tooltip.dart +++ b/packages/site_shared/lib/components/common/tooltip.dart @@ -7,7 +7,7 @@ import 'package:jaspr/jaspr.dart'; import 'package:universal_web/web.dart' as web; import '../../util.dart'; -import '../util/global_event_listener.dart'; +import '../utils/global_event_listener.dart'; class Tooltip extends StatefulComponent { const Tooltip({ diff --git a/sites/docs/lib/src/components/common/wrapped_code_block.dart b/packages/site_shared/lib/components/common/wrapped_code_block.dart similarity index 99% rename from sites/docs/lib/src/components/common/wrapped_code_block.dart rename to packages/site_shared/lib/components/common/wrapped_code_block.dart index 3bc7596c140..155ad4bcb16 100644 --- a/sites/docs/lib/src/components/common/wrapped_code_block.dart +++ b/packages/site_shared/lib/components/common/wrapped_code_block.dart @@ -168,7 +168,8 @@ enum CodeBlockTag { passesStaticAnalysis('static analysis: success', parentClass: 'passes-sa'), failsStaticAnalysis('static analysis: failure', parentClass: 'fails-sa'), runtimeSuccess('runtime: success', parentClass: 'runtime-success'), - runtimeFailure('runtime: failure', parentClass: 'runtime-fail'); + runtimeFailure('runtime: failure', parentClass: 'runtime-fail') + ; const CodeBlockTag(this.spanContent, {required this.parentClass}); diff --git a/sites/docs/lib/src/components/common/youtube_embed.dart b/packages/site_shared/lib/components/common/youtube_embed.dart similarity index 100% rename from sites/docs/lib/src/components/common/youtube_embed.dart rename to packages/site_shared/lib/components/common/youtube_embed.dart diff --git a/sites/docs/lib/src/components/dartpad/dartpad_injector.dart b/packages/site_shared/lib/components/dartpad/dartpad_injector.dart similarity index 90% rename from sites/docs/lib/src/components/dartpad/dartpad_injector.dart rename to packages/site_shared/lib/components/dartpad/dartpad_injector.dart index 95c0981d579..69bb328a4f1 100644 --- a/sites/docs/lib/src/components/dartpad/dartpad_injector.dart +++ b/packages/site_shared/lib/components/dartpad/dartpad_injector.dart @@ -4,7 +4,8 @@ import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; -import '../util/retake_element.dart'; + +import '../../src/utils/retake_element.dart'; import 'embedded_dartpad.dart'; /// Prepares a code block that will be replaced with an embedded @@ -79,16 +80,7 @@ class _DartPadInjectorState extends State { if (kIsWeb) { // During hydration, extract the content from the pre-rendered code block. - final elem = retakeElement(context, (elem) { - return elem.tagName.toLowerCase() == 'pre'; - }); - - if (elem == null) { - content = ''; - } else { - elem.parentNode?.removeChild(elem); - content = elem.textContent ?? ''; - } + content = extractContent(context as Element); } } diff --git a/sites/docs/lib/src/components/dartpad/embedded_dartpad.dart b/packages/site_shared/lib/components/dartpad/embedded_dartpad.dart similarity index 100% rename from sites/docs/lib/src/components/dartpad/embedded_dartpad.dart rename to packages/site_shared/lib/components/dartpad/embedded_dartpad.dart diff --git a/packages/site_shared/lib/components/layout/banner.dart b/packages/site_shared/lib/components/layout/banner.dart new file mode 100644 index 00000000000..828fda5df70 --- /dev/null +++ b/packages/site_shared/lib/components/layout/banner.dart @@ -0,0 +1,80 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; + +/// The information to display in the site banner, +/// as configured in `src/data/banner.yml`. +@immutable +final class BannerContent { + final List parts; + + const BannerContent({required this.parts}); + + factory BannerContent.fromList(List bannerData) => BannerContent( + parts: [ + for (final item in bannerData) + switch (item) { + {'text': final String text} => BannerText(text), + {'link': final Map link} => BannerLink( + text: link['text'] as String, + url: link['url'] as String, + newTab: link['newTab'] as bool? ?? false, + ), + _ => throw FormatException('Invalid banner item: $item'), + }, + ], + ); +} + +@immutable +sealed class BannerPart { + const BannerPart(); +} + +final class BannerText extends BannerPart { + const BannerText(this.text); + + final String text; +} + +final class BannerLink extends BannerPart { + const BannerLink({ + required this.text, + required this.url, + this.newTab = false, + }); + + final String text; + final String url; + final bool newTab; +} + +/// The site-wide banner. +class DashBanner extends StatelessComponent { + const DashBanner(this.content, {super.key}); + + final BannerContent content; + + @override + Component build(BuildContext context) => div( + id: 'site-banner', + attributes: {'role': 'alert'}, + [ + p([ + 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)], + ), + }, + ]), + ], + ); +} diff --git a/sites/docs/lib/src/components/layout/menu_toggle.dart b/packages/site_shared/lib/components/layout/menu_toggle.dart similarity index 100% rename from sites/docs/lib/src/components/layout/menu_toggle.dart rename to packages/site_shared/lib/components/layout/menu_toggle.dart diff --git a/sites/docs/lib/src/components/layout/site_switcher.dart b/packages/site_shared/lib/components/layout/site_switcher.dart similarity index 73% rename from sites/docs/lib/src/components/layout/site_switcher.dart rename to packages/site_shared/lib/components/layout/site_switcher.dart index 811a308957e..3dc00d2e851 100644 --- a/sites/docs/lib/src/components/layout/site_switcher.dart +++ b/packages/site_shared/lib/components/layout/site_switcher.dart @@ -11,20 +11,22 @@ import '../common/dropdown.dart'; @client final class SiteSwitcher extends StatelessComponent { - const SiteSwitcher(); + const SiteSwitcher({this.isFlutter = true, super.key}); + + final bool isFlutter; @override Component build(BuildContext _) { - return const Dropdown( + return Dropdown( id: 'site-switcher', - toggle: Button(icon: 'apps', title: 'Visit related sites.'), + toggle: const Button(icon: 'apps', title: 'Visit related sites.'), content: nav( classes: 'dropdown-menu', attributes: {'role': 'menu'}, [ - ul( - [ - _SiteWordMarkListEntry( + ul([ + if (isFlutter) ...[ + const _SiteWordMarkListEntry( name: 'Flutter', href: 'https://flutter.dev', ), @@ -32,40 +34,48 @@ final class SiteSwitcher extends StatelessComponent { name: 'Flutter', subtype: 'Docs', href: '/', - current: true, + current: isFlutter, ), - _SiteWordMarkListEntry( + const _SiteWordMarkListEntry( name: 'Flutter', subtype: 'API', href: 'https://api.flutter.dev', ), - _SiteWordMarkListEntry( + const _SiteWordMarkListEntry( name: 'Flutter', subtype: 'Blog', href: 'https://blog.flutter.dev', ), - Component.element( + const Component.element( tag: 'li', classes: 'dropdown-divider', attributes: {'aria-hidden': 'true', 'role': 'separator'}, ), - _SiteWordMarkListEntry( + ], + _SiteWordMarkListEntry( + name: 'Dart', + href: 'https://dart.dev', + dart: true, + current: !isFlutter, + ), + if (!isFlutter) + const _SiteWordMarkListEntry( name: 'Dart', - href: 'https://dart.dev', - dart: true, - ), - _SiteWordMarkListEntry( - name: 'DartPad', - href: 'https://dartpad.dev', - dart: true, - ), - _SiteWordMarkListEntry( - name: 'pub.dev', - href: 'https://pub.dev', + subtype: 'API', + href: 'https://api.dart.dev', dart: true, ), - ], - ), + const _SiteWordMarkListEntry( + name: 'DartPad', + href: 'https://dartpad.dev', + dart: true, + ), + const _SiteWordMarkListEntry( + name: 'pub.dev', + href: 'https://pub.dev', + dart: true, + ), + ]), ], ), ); diff --git a/sites/docs/lib/src/components/layout/theme_switcher.dart b/packages/site_shared/lib/components/layout/theme_switcher.dart similarity index 99% rename from sites/docs/lib/src/components/layout/theme_switcher.dart rename to packages/site_shared/lib/components/layout/theme_switcher.dart index a06e647c477..5674bc86d33 100644 --- a/sites/docs/lib/src/components/layout/theme_switcher.dart +++ b/packages/site_shared/lib/components/layout/theme_switcher.dart @@ -21,7 +21,8 @@ final class ThemeSwitcher extends StatefulComponent { enum _Theme { light('Light', 'Switch to the light theme.', 'light_mode'), dark('Dark', 'Switch to the dark theme.', 'dark_mode'), - auto('Automatic', 'Match theme to device theme.', 'night_sight_auto'); + auto('Automatic', 'Match theme to device theme.', 'night_sight_auto') + ; final String label; final String description; diff --git a/sites/docs/lib/src/components/tutorial/client/progress_ring.dart b/packages/site_shared/lib/components/tutorial/client/progress_ring.dart similarity index 100% rename from sites/docs/lib/src/components/tutorial/client/progress_ring.dart rename to packages/site_shared/lib/components/tutorial/client/progress_ring.dart diff --git a/sites/docs/lib/src/components/tutorial/client/quiz.dart b/packages/site_shared/lib/components/tutorial/client/quiz.dart similarity index 95% rename from sites/docs/lib/src/components/tutorial/client/quiz.dart rename to packages/site_shared/lib/components/tutorial/client/quiz.dart index f04b044568f..98200df9b00 100644 --- a/sites/docs/lib/src/components/tutorial/client/quiz.dart +++ b/packages/site_shared/lib/components/tutorial/client/quiz.dart @@ -6,9 +6,9 @@ import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'package:universal_web/web.dart' as web; -import '../../../models/quiz_model.dart'; import '../../../util.dart'; import '../../common/button.dart'; +import '../models/quiz_model.dart'; @client class InteractiveQuiz extends StatefulComponent { @@ -89,7 +89,7 @@ class _InteractiveQuizState extends State { if (question == currentQuestion) 'active', ].toClasses, [ - strong([.text(question.question)]), + strong([RawText(question.question)]), ol([ for (final (index, option) in question.options.indexed) li( @@ -113,14 +113,14 @@ class _InteractiveQuizState extends State { [ div(classes: 'question-wrapper', [ div(classes: 'question', [ - p([.text(option.text)]), + p([RawText(option.text)]), ]), div(classes: 'solution', [ if (option.correct) const p(classes: 'correct', [.text('That\'s right!')]) else - const p(classes: 'incorrect', [.text('Not quite')]), - p([.text(option.explanation)]), + const p(classes: 'incorrect', [.text('Not quite.')]), + p([RawText(option.explanation)]), ]), ]), ], @@ -144,7 +144,7 @@ class _InteractiveQuizState extends State { currentQuestionIndex--; }); }, - content: 'Previous', + content: 'Previous question', ), Button( key: nextButtonKey, diff --git a/sites/docs/lib/src/components/tutorial/downloadable_snippet.dart b/packages/site_shared/lib/components/tutorial/downloadable_snippet.dart similarity index 92% rename from sites/docs/lib/src/components/tutorial/downloadable_snippet.dart rename to packages/site_shared/lib/components/tutorial/downloadable_snippet.dart index b11391a25b3..3561ab0aad6 100644 --- a/sites/docs/lib/src/components/tutorial/downloadable_snippet.dart +++ b/packages/site_shared/lib/components/tutorial/downloadable_snippet.dart @@ -7,13 +7,16 @@ import 'package:jaspr_content/jaspr_content.dart'; import 'package:path/path.dart' as path; import '../../extensions/code_block_processor.dart'; -import '../../util.dart'; import '../common/client/copy_button.dart'; import '../common/client/download_button.dart'; import '../common/wrapped_code_block.dart'; class DownloadableSnippet extends CustomComponentBase { - const DownloadableSnippet(); + const DownloadableSnippet({ + required this.snippetsDirectoryPath, + }); + + final String snippetsDirectoryPath; @override Pattern get pattern => 'DownloadableSnippet'; @@ -33,7 +36,7 @@ class DownloadableSnippet extends CustomComponentBase { builder: (context) { final page = context.page; final snippet = page.loader.readPartialSync( - path.join(siteSrcDirectoryPath, '_snippets', src), + path.join(snippetsDirectoryPath, src), page, ); final language = src.split('.').last; diff --git a/sites/docs/lib/src/models/quiz_model.dart b/packages/site_shared/lib/components/tutorial/models/quiz_model.dart similarity index 100% rename from sites/docs/lib/src/models/quiz_model.dart rename to packages/site_shared/lib/components/tutorial/models/quiz_model.dart diff --git a/sites/docs/lib/src/models/summary_card_model.dart b/packages/site_shared/lib/components/tutorial/models/summary_card_model.dart similarity index 100% rename from sites/docs/lib/src/models/summary_card_model.dart rename to packages/site_shared/lib/components/tutorial/models/summary_card_model.dart diff --git a/sites/docs/lib/src/models/tutorial_model.dart b/packages/site_shared/lib/components/tutorial/models/tutorial_model.dart similarity index 100% rename from sites/docs/lib/src/models/tutorial_model.dart rename to packages/site_shared/lib/components/tutorial/models/tutorial_model.dart diff --git a/sites/docs/lib/src/components/tutorial/progress_ring.dart b/packages/site_shared/lib/components/tutorial/progress_ring.dart similarity index 100% rename from sites/docs/lib/src/components/tutorial/progress_ring.dart rename to packages/site_shared/lib/components/tutorial/progress_ring.dart diff --git a/packages/site_shared/lib/components/tutorial/quiz.dart b/packages/site_shared/lib/components/tutorial/quiz.dart new file mode 100644 index 00000000000..e7b0486f6f6 --- /dev/null +++ b/packages/site_shared/lib/components/tutorial/quiz.dart @@ -0,0 +1,73 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; +import 'package:yaml/yaml.dart'; + +import '../../src/markdown/markdown_parser.dart'; +import 'client/quiz.dart'; +import 'models/quiz_model.dart'; + +class Quiz extends CustomComponent { + const Quiz() : super.base(); + + @override + Component? create(Node node, NodesBuilder builder) { + if (node is! ElementNode || node.tag.toLowerCase() != 'quiz') { + return null; + } + + final title = node.attributes['title']; + + // If the quiz has an ID, load it from the page data. + if (node.attributes['id'] case final String quizId when quizId.isNotEmpty) { + return Builder( + builder: (context) { + final quizzes = context.page.data['quiz'] as Map?; + if (quizzes?[quizId] case final List quizData) { + return InteractiveQuiz( + title: title, + questions: quizData + .map((q) => _parseQuestion(q as Map)) + .toList(growable: false), + ); + } + + throw ArgumentError('Failed to parse quiz with ID: $quizId'); + }, + ); + } + + // If the quiz does not have an ID, parse it from the content. + if (node.children?.whereType().isNotEmpty ?? false) { + throw Exception( + 'Invalid Quiz content. Remove any leading empty lines to ' + 'avoid parsing as markdown.', + ); + } + + final content = node.children?.map((n) => n.innerText).join('\n') ?? ''; + final data = loadYamlNode(content); + assert(data is YamlList, 'Invalid Quiz content. Expected a YAML list.'); + final questions = (data as YamlList).nodes + .map((n) => Question.fromMap(n as YamlMap)) + .toList(); + assert(questions.isNotEmpty, 'Quiz must contain at least one question.'); + return InteractiveQuiz(title: title, questions: questions); + } +} + +Question _parseQuestion(Map map) => Question( + parseMarkdownToHtml(map['question'] as String, inline: true), + (map['options'] as List) + .map((e) => _parseAnswer(e as Map)) + .toList(), +); + +AnswerOption _parseAnswer(Map map) => AnswerOption( + parseMarkdownToHtml(map['text'] as String, inline: true), + map['correct'] as bool? ?? false, + parseMarkdownToHtml(map['explanation'] as String), +); diff --git a/sites/docs/lib/src/components/tutorial/stepper.dart b/packages/site_shared/lib/components/tutorial/stepper.dart similarity index 99% rename from sites/docs/lib/src/components/tutorial/stepper.dart rename to packages/site_shared/lib/components/tutorial/stepper.dart index e9ccfdf43e3..1f7424abd4c 100644 --- a/sites/docs/lib/src/components/tutorial/stepper.dart +++ b/packages/site_shared/lib/components/tutorial/stepper.dart @@ -5,7 +5,6 @@ import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/jaspr_content.dart'; - import '../common/button.dart'; import '../common/material_icon.dart'; diff --git a/sites/docs/lib/src/components/tutorial/summary_card.dart b/packages/site_shared/lib/components/tutorial/summary_card.dart similarity index 97% rename from sites/docs/lib/src/components/tutorial/summary_card.dart rename to packages/site_shared/lib/components/tutorial/summary_card.dart index 76b8a2e7dd1..298745d318d 100644 --- a/sites/docs/lib/src/components/tutorial/summary_card.dart +++ b/packages/site_shared/lib/components/tutorial/summary_card.dart @@ -7,9 +7,9 @@ import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/jaspr_content.dart'; import 'package:yaml/yaml.dart'; -import '../../markdown/markdown_parser.dart'; -import '../../models/summary_card_model.dart'; +import '../../src/markdown/markdown_parser.dart'; import '../common/material_icon.dart'; +import 'models/summary_card_model.dart'; class SummaryCard extends CustomComponent { const SummaryCard() : super.base(); diff --git a/sites/docs/lib/src/components/tutorial/tutorial_outline.dart b/packages/site_shared/lib/components/tutorial/tutorial_outline.dart similarity index 58% rename from sites/docs/lib/src/components/tutorial/tutorial_outline.dart rename to packages/site_shared/lib/components/tutorial/tutorial_outline.dart index 672680636f8..c4297b1a656 100644 --- a/sites/docs/lib/src/components/tutorial/tutorial_outline.dart +++ b/packages/site_shared/lib/components/tutorial/tutorial_outline.dart @@ -6,11 +6,13 @@ import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/jaspr_content.dart'; -import '../../markdown/markdown_parser.dart'; -import '../../models/tutorial_model.dart'; +import '../../src/markdown/markdown_parser.dart'; +import 'models/tutorial_model.dart'; class TutorialOutline extends CustomComponentBase { - const TutorialOutline(); + const TutorialOutline({this.showUnitTitle = true}); + + final bool showUnitTitle; @override Pattern get pattern => 'TutorialOutline'; @@ -30,22 +32,31 @@ class TutorialOutline extends CustomComponentBase { }; return div(classes: 'tutorial-outline', [ - ol([ - for (final unit in model.units) - li([ - .text(unit.title), - ol([ - for (final chapter in unit.chapters) - li([ - a(href: chapter.url, [ - DashMarkdown(content: chapter.title, inline: true), - ]), - ]), - ]), - ]), - ]), + ol([for (final unit in model.units) ..._buildUnit(unit)]), ]); }, ); } + + List _buildUnit(TutorialUnit unit) { + final chapters = [ + for (final chapter in unit.chapters) + li([ + a(href: chapter.url, [ + DashMarkdown(content: chapter.title, inline: true), + ]), + ]), + ]; + + if (showUnitTitle) { + return [ + li([ + .text(unit.title), + ol(chapters), + ]), + ]; + } else { + return chapters; + } + } } diff --git a/packages/site_shared/lib/components/utils/component_ref.dart b/packages/site_shared/lib/components/utils/component_ref.dart new file mode 100644 index 00000000000..e9d35afd013 --- /dev/null +++ b/packages/site_shared/lib/components/utils/component_ref.dart @@ -0,0 +1,51 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:nanoid2/nanoid2.dart'; + +import '../../src/utils/retake_element.dart'; + +/// A wrapper around [Component] to make it usable across server/client boundaries. +/// +/// This is a temporary (and limited) solution until server components have +/// landed in Jaspr. They enable passing components to @client components +/// directly, by creating a unique ID on the server and retaking the dom node +/// on the client. +/// +/// On the server, wrap your component with `context.ref(yourComponent)`, and +/// pass the resulting [ComponentRef] to your @client component. +/// On the client, retrieve the original component by calling `myRef.component`. +class ComponentRef extends StatelessComponent { + const ComponentRef._(this.id, [this._component = const .empty()]); + + final String id; + final Component _component; + + @override + Component build(BuildContext context) { + if (!kIsWeb) { + return Component.fragment([ + RawText(''), + _component, + RawText(''), + ]); + } + + return retakeRef(context, id); + } + + @decoder + factory ComponentRef.fromId(String id) { + return ComponentRef._(id); + } + + @encoder + String toId() => id; +} + +ComponentRef ref(Component child) { + return ComponentRef._(nanoid(length: 8), child); +} diff --git a/packages/site_shared/lib/components/utils/define_component.dart b/packages/site_shared/lib/components/utils/define_component.dart new file mode 100644 index 00000000000..78632c1ce83 --- /dev/null +++ b/packages/site_shared/lib/components/utils/define_component.dart @@ -0,0 +1,33 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; + +CustomComponent defineComponent(String name, Component child) { + return CustomComponent( + pattern: RegExp(name, caseSensitive: false), + builder: (_, _, _) => child, + ); +} + +CustomComponent defineComponentWithAttrs( + String name, + Component Function(Map attributes) factory, +) { + return CustomComponent( + pattern: RegExp(name, caseSensitive: false), + builder: (_, attrs, _) => factory(attrs), + ); +} + +CustomComponent defineComponentWithChild( + String name, + Component Function(Map attributes, Component? child) factory, +) { + return CustomComponent( + pattern: RegExp(name, caseSensitive: false), + builder: (_, attrs, child) => factory(attrs, child), + ); +} diff --git a/sites/docs/lib/src/components/util/global_event_listener.dart b/packages/site_shared/lib/components/utils/global_event_listener.dart similarity index 100% rename from sites/docs/lib/src/components/util/global_event_listener.dart rename to packages/site_shared/lib/components/utils/global_event_listener.dart diff --git a/sites/docs/lib/src/extensions/attribute_processor.dart b/packages/site_shared/lib/extensions/attribute_processor.dart similarity index 98% rename from sites/docs/lib/src/extensions/attribute_processor.dart rename to packages/site_shared/lib/extensions/attribute_processor.dart index 2b5b4419112..c529a386261 100644 --- a/sites/docs/lib/src/extensions/attribute_processor.dart +++ b/packages/site_shared/lib/extensions/attribute_processor.dart @@ -3,8 +3,7 @@ // found in the LICENSE file. import 'package:jaspr_content/jaspr_content.dart'; - -import '../markdown/attribute_syntax.dart'; +import '../src/markdown/attribute_syntax.dart'; /// A node-processing, page extension for Jaspr Content that looks for /// attribute markers from [AttributeBlockSyntax] and [AttributeInlineSyntax], diff --git a/sites/docs/lib/src/extensions/code_block_processor.dart b/packages/site_shared/lib/extensions/code_block_processor.dart similarity index 98% rename from sites/docs/lib/src/extensions/code_block_processor.dart rename to packages/site_shared/lib/extensions/code_block_processor.dart index 64aa04e2074..c028285152e 100644 --- a/sites/docs/lib/src/extensions/code_block_processor.dart +++ b/packages/site_shared/lib/extensions/code_block_processor.dart @@ -13,15 +13,17 @@ import 'package:opal/opal.dart' as opal; import '../components/common/wrapped_code_block.dart'; import '../components/dartpad/dartpad_injector.dart'; -import '../highlight/theme/dark.dart'; -import '../highlight/theme/light.dart'; -import '../highlight/token_renderer.dart' as highlighter; +import '../src/highlight/theme/dark.dart'; +import '../src/highlight/theme/light.dart'; +import '../src/highlight/token_renderer.dart' as highlighter; final class CodeBlockProcessor implements PageExtension { static final opal.LanguageRegistry _languageRegistry = opal.LanguageRegistry.withDefaults(); - const CodeBlockProcessor(); + const CodeBlockProcessor({required this.defaultTitle}); + + final String defaultTitle; @override Future> apply(Page page, List nodes) async { @@ -55,7 +57,7 @@ final class CodeBlockProcessor implements PageExtension { return ComponentNode( DartPadWrapper( content: lines.join('\n'), - title: title ?? 'Runnable Flutter example', + title: title ?? defaultTitle, theme: metadata['theme'], height: metadata['height'], runAutomatically: metadata['run'] == 'true', diff --git a/sites/docs/lib/src/extensions/header_extractor.dart b/packages/site_shared/lib/extensions/header_extractor.dart similarity index 100% rename from sites/docs/lib/src/extensions/header_extractor.dart rename to packages/site_shared/lib/extensions/header_extractor.dart diff --git a/sites/docs/lib/src/extensions/header_processor.dart b/packages/site_shared/lib/extensions/header_processor.dart similarity index 100% rename from sites/docs/lib/src/extensions/header_processor.dart rename to packages/site_shared/lib/extensions/header_processor.dart diff --git a/sites/docs/lib/src/extensions/table_processor.dart b/packages/site_shared/lib/extensions/table_processor.dart similarity index 100% rename from sites/docs/lib/src/extensions/table_processor.dart rename to packages/site_shared/lib/extensions/table_processor.dart diff --git a/sites/docs/lib/src/layouts/dash_layout.dart b/packages/site_shared/lib/layouts/dash_layout.dart similarity index 51% rename from sites/docs/lib/src/layouts/dash_layout.dart rename to packages/site_shared/lib/layouts/dash_layout.dart index 5c41a6b8cd4..13a2cf89683 100644 --- a/sites/docs/lib/src/layouts/dash_layout.dart +++ b/packages/site_shared/lib/layouts/dash_layout.dart @@ -1,24 +1,20 @@ -// Copyright 2025 The Flutter Authors. All rights reserved. +// Copyright 2026 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:convert'; import 'package:jaspr/dom.dart'; -import 'package:jaspr/jaspr.dart'; +import 'package:jaspr/server.dart'; import 'package:jaspr_content/jaspr_content.dart'; import '../components/common/client/cookie_notice.dart'; -import '../components/layout/footer.dart'; -import '../components/layout/header.dart'; -import '../components/layout/sidenav.dart'; -import '../models/sidenav_model.dart'; -import '../style_hash.dart'; +import '../components/layout/banner.dart'; import '../util.dart'; -/// The base Jaspr Content layout for wrapping site content. -abstract class FlutterDocsLayout extends PageLayoutBase { - const FlutterDocsLayout(); +/// The base Jaspr Content layout for all sites. +abstract class DashLayout implements PageLayout { + const DashLayout(); @override String get name; @@ -27,6 +23,21 @@ abstract class FlutterDocsLayout extends PageLayoutBase { String get defaultSidenav => 'default'; + String? get titleBase => null; + String get siteHost; + bool get cookieNoticeDarkMode => false; + + String get iconUrl; + String get iconUrlApple; + String? get iconUrlApple152 => null; + String? get iconUrlApple167 => null; + String? get iconUrlApple180 => null; + String get twitterSiteTag; + String get twitterDefaultImageUrl; + + String get tagManagerId; + String get analyticsId; + /// Returns page-specific URLs to eagerly speculate on, in addition to /// the document-level rules that match all internal links. /// @@ -35,86 +46,110 @@ abstract class FlutterDocsLayout extends PageLayoutBase { ({Set prerender, Set prefetch}) speculationUrls(Page page) => const (prerender: {}, prefetch: {}); - @override - @mustCallSuper - Iterable buildHead(Page page) { + List get fonts => [ + 'https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap', + 'https://fonts.googleapis.com/css2?family=Google+Sans+Mono:wght@400;500;700&display=swap', + 'https://fonts.googleapis.com/css2?family=Google+Sans+Text:wght@400;500;700&display=swap', + 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0', + 'https://fonts.googleapis.com/css2?family=Noto+Serif:ital,wght@0,100..900;1,100..900&display=swap', + ]; + + String get stylesHash; + + Iterable buildExtraHead(Page page) => const []; + + Iterable _buildHead(Page page) { final pageData = page.data.page; final siteData = page.data.site; + final pageTitle = (pageData['title'] ?? siteData['title']) as String; - final pageDescription = pageData['description'] as String? ?? ''; + final pageDescription = pageData['description'] as String?; + final pageImage = pageData['image'] as String?; + + final windowTitle = titleBase != null + ? '$pageTitle | $titleBase' + : pageTitle; + + final canonicalUrl = pageData['canonical'] as String?; return [ - ...super.buildHead(page), + Component.element(tag: 'title', children: [.text(windowTitle)]), + if (pageDescription case final String desc) + meta(name: 'description', content: desc), + + // URL if (pageData['noindex'] case final noIndex? when noIndex == true || noIndex == 'true') const meta(name: 'robots', content: 'noindex'), - if (pageData['canonical'] case final String canonicalUrl - when canonicalUrl.isNotEmpty) + if (canonicalUrl case final canonicalUrl? when canonicalUrl.isNotEmpty) link(rel: 'canonical', href: canonicalUrl), if (pageData['redirectTo'] case final String redirectTo when redirectTo.isNotEmpty) RawText(''), - const link( - rel: 'icon', - href: '/assets/images/branding/flutter/icon/64.png', - attributes: {'sizes': '64x64'}, - ), - const link( - rel: 'apple-touch-icon', - href: '/assets/images/branding/flutter/logo/flutter-logomark-320px.png', - ), - const meta(name: 'twitter:card', content: 'summary'), - const meta(name: 'twitter:site', content: '@flutterdev'), - meta(name: 'twitter:title', content: pageTitle), + + // Icons + link(rel: 'icon', href: iconUrl, attributes: {'sizes': '64x64'}), + link(rel: 'apple-touch-icon', href: iconUrlApple), + if (iconUrlApple152 case final url?) + link( + rel: 'apple-touch-icon', + href: url, + attributes: {'sizes': '152x152'}, + ), + if (iconUrlApple180 case final url?) + link( + rel: 'apple-touch-icon', + href: url, + attributes: {'sizes': '180x180'}, + ), + if (iconUrlApple167 case final url?) + link( + rel: 'apple-touch-icon', + href: url, + attributes: {'sizes': '167x167'}, + ), + + // Social meta( - name: 'twitter:description', - content: pageDescription, + name: 'twitter:card', + content: pageImage != null ? 'summary_large_image' : 'summary', ), + meta(name: 'twitter:site', content: twitterSiteTag), + meta(name: 'twitter:title', content: pageTitle), + if (pageDescription case final String desc) + meta(name: 'twitter:description', content: desc), + if (pageImage case final String img) + meta(name: 'twitter:image', content: img), meta(attributes: {'property': 'og:title', 'content': pageTitle}), + if (pageDescription case final String desc) + meta(attributes: {'property': 'og:description', 'content': desc}), meta( attributes: { - 'property': 'og:description', - 'content': pageDescription, + 'property': 'og:url', + 'content': canonicalUrl ?? page.path, }, ), - meta(attributes: {'property': 'og:url', 'content': page.path}), - const meta( + meta( attributes: { 'property': 'og:image', - 'content': '/assets/images/flutter-logo-sharing.png', + 'content': pageImage ?? twitterDefaultImageUrl, }, ), + // Fonts const link(rel: 'preconnect', href: 'https://fonts.googleapis.com'), const link( rel: 'preconnect', href: 'https://fonts.gstatic.com', attributes: {'crossorigin': ''}, ), - const link( - rel: 'stylesheet', - href: - 'https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap', - ), - const link( - rel: 'stylesheet', - href: - 'https://fonts.googleapis.com/css2?family=Google+Sans+Mono:wght@400;500;700&display=swap', - ), - const link( - rel: 'stylesheet', - href: - 'https://fonts.googleapis.com/css2?family=Google+Sans+Text:wght@400;500;700&display=swap', - ), - const link( - rel: 'stylesheet', - href: - 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0', - ), - const link( + for (final font in fonts) link(rel: 'stylesheet', href: font), + + // Styles + link( rel: 'stylesheet', - href: '/assets/css/main.css?hash=$generatedStylesHash', + href: '/assets/css/main.css?hash=${htmlEscape.convert(stylesHash)}', ), if (pageData['js'] case final List jsList) @@ -124,6 +159,8 @@ abstract class FlutterDocsLayout extends PageLayoutBase { src: jsUrl, attributes: {if (defer == 'true' || defer == true) 'defer': ''}, ), + ...buildExtraHead(page), + const script( src: 'https://cdn.jsdelivr.net/npm/@justinribeiro/lite-youtube@1.8.2/lite-youtube.js', @@ -135,68 +172,69 @@ abstract class FlutterDocsLayout extends PageLayoutBase { }, ), - // Set up tag manager and analytics. - if (productionBuild) - const RawText(''' - - - - - -'''), +''', + ), + ], + // Add speculation rules and prefetch fallback links for // URLs provided by subclass overrides of speculationUrls. ..._buildSpeculationRulesHead(page), ]; } + Component buildBody(Page page, Component child); + @override - Component buildBody(Page page, Component child) { + Component buildLayout(Page page, Component child) { final pageData = page.data.page; final bodyClass = pageData['bodyClass'] as String?; - final pageUrl = page.url.startsWith('/') ? page.url : '/${page.url}'; - - final sidenavs = page.data['sidenav'] as Map; - final pageSidenavKey = pageData['sidenav'] as String? ?? defaultSidenav; - final sideNavEntries = navEntriesFromData( - sidenavs[pageSidenavKey] as List, - ); - final obsolete = pageData['obsolete'] == true; - - return .fragment( - [ - const Document.html( - attributes: { - 'lang': 'en', - 'dir': 'ltr', - }, + return Component.element( + tag: 'html', + attributes: {'lang': 'en', 'dir': 'ltr'}, + children: [ + Component.element( + tag: 'head', + children: [ + const meta(charset: 'utf-8'), + const meta( + name: 'viewport', + content: 'width=device-width, initial-scale=1', + ), + ..._buildHead(page), + ], ), - if ([?bodyClass, ...defaultBodyClasses] case final bodyClasses - when bodyClasses.isNotEmpty) - Document.body( - attributes: { - 'class': bodyClasses.toClasses, - }, - ), - // The theme setting logic should remain before other scripts to - // avoid a flash of the initial theme on load. - const RawText(''' - - '''), - if (productionBuild) - const RawText( - '', - ), - const a( - id: 'skip-to-main', - classes: 'filled-button', - href: '#site-content-title', - [.text('Skip to main content')], - ), - const CookieNotice(), - const DashHeader(), - div(id: 'site-below-header', [ - div(id: 'site-main-row', [ - DashSideNav( - navEntries: sideNavEntries, - currentPageUrl: pageUrl, + ''', ), - main_( - id: 'page-content', - classes: [ - if (pageData['focusedLayout'] == true) 'focused', - ].toClasses, - [child], + if (productionBuild) + RawText( + '', + ), + const a( + id: 'skip-to-main', + classes: 'filled-button', + href: '#site-content-title', + attributes: {'tabindex': '1'}, + [.text('Skip to main content')], ), - if (obsolete) - const div(id: 'obsolete-banner', [ - div(classes: 'text-center', [ - .text( - 'Some content on this page might be out of date.', - ), - ]), - ]), - ]), - const DashFooter(), - ]), - // Scroll the sidenav to the active item before other logic - // to avoid it jumping after page load. - const RawText(''' - - '''), + CookieNotice(host: siteHost, alwaysDarkMode: cookieNoticeDarkMode), + buildBody(page, child), + ], + ), ], ); } + /// 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 `