diff --git a/packages/site_shared/README.md b/packages/site_shared/README.md new file mode 100644 index 00000000000..b73ccada457 --- /dev/null +++ b/packages/site_shared/README.md @@ -0,0 +1,24 @@ +# site_shared + +This package is the core library containing shared logic, UI components, and the design system for 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 features to build documentation websites using Jaspr: + +- **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, cards, chips, tooltips, tabs, dropdowns, embedded YouTube videos, search bars, and wrapped code blocks. + - **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 & Processors** (`lib/extensions`): Custom processors that hook into the Dart markdown parser to extend its default syntax and behavior, such as `attribute_processor.dart`, `code_block_processor.dart`, `header_processor.dart`, and `table_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 primary web documentation. +2. Prevent code duplication between the `dart.dev` and `docs.flutter.dev` repositories. +3. 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/site/lib/_sass/base/_mixins.scss b/packages/site_shared/lib/_sass/base/_mixins.scss similarity index 100% rename from site/lib/_sass/base/_mixins.scss rename to packages/site_shared/lib/_sass/base/_mixins.scss diff --git a/site/lib/_sass/base/_reset.scss b/packages/site_shared/lib/_sass/base/_reset.scss similarity index 100% rename from site/lib/_sass/base/_reset.scss rename to packages/site_shared/lib/_sass/base/_reset.scss diff --git a/site/lib/_sass/components/_alert.scss b/packages/site_shared/lib/_sass/components/_alert.scss similarity index 100% rename from site/lib/_sass/components/_alert.scss rename to packages/site_shared/lib/_sass/components/_alert.scss diff --git a/site/lib/_sass/components/_banner.scss b/packages/site_shared/lib/_sass/components/_banner.scss similarity index 100% rename from site/lib/_sass/components/_banner.scss rename to packages/site_shared/lib/_sass/components/_banner.scss diff --git a/site/lib/_sass/components/_breadcrumbs.scss b/packages/site_shared/lib/_sass/components/_breadcrumbs.scss similarity index 100% rename from site/lib/_sass/components/_breadcrumbs.scss rename to packages/site_shared/lib/_sass/components/_breadcrumbs.scss diff --git a/site/lib/_sass/components/_button.scss b/packages/site_shared/lib/_sass/components/_button.scss similarity index 98% rename from site/lib/_sass/components/_button.scss rename to packages/site_shared/lib/_sass/components/_button.scss index e4df9379615..079bbc2fe87 100644 --- a/site/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/site/lib/_sass/components/_card.scss b/packages/site_shared/lib/_sass/components/_card.scss similarity index 99% rename from site/lib/_sass/components/_card.scss rename to packages/site_shared/lib/_sass/components/_card.scss index 7c9a31b48a8..baa367abe62 100644 --- a/site/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/site/lib/_sass/components/_code.scss b/packages/site_shared/lib/_sass/components/_code.scss similarity index 100% rename from site/lib/_sass/components/_code.scss rename to packages/site_shared/lib/_sass/components/_code.scss diff --git a/site/lib/_sass/components/_cookie-notice.scss b/packages/site_shared/lib/_sass/components/_cookie-notice.scss similarity index 100% rename from site/lib/_sass/components/_cookie-notice.scss rename to packages/site_shared/lib/_sass/components/_cookie-notice.scss diff --git a/site/lib/_sass/components/_dropdown.scss b/packages/site_shared/lib/_sass/components/_dropdown.scss similarity index 99% rename from site/lib/_sass/components/_dropdown.scss rename to packages/site_shared/lib/_sass/components/_dropdown.scss index d121e252617..0822040569d 100644 --- a/site/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/site/lib/_sass/components/_quiz.scss b/packages/site_shared/lib/_sass/components/_quiz.scss similarity index 100% rename from site/lib/_sass/components/_quiz.scss rename to packages/site_shared/lib/_sass/components/_quiz.scss diff --git a/site/lib/_sass/components/_site-switcher.scss b/packages/site_shared/lib/_sass/components/_site-switcher.scss similarity index 97% rename from site/lib/_sass/components/_site-switcher.scss rename to packages/site_shared/lib/_sass/components/_site-switcher.scss index 58b3fb0e331..07b6fad5300 100644 --- a/site/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/site/lib/_sass/components/_stepper.scss b/packages/site_shared/lib/_sass/components/_stepper.scss similarity index 100% rename from site/lib/_sass/components/_stepper.scss rename to packages/site_shared/lib/_sass/components/_stepper.scss diff --git a/site/lib/_sass/components/_summary-card.scss b/packages/site_shared/lib/_sass/components/_summary-card.scss similarity index 100% rename from site/lib/_sass/components/_summary-card.scss rename to packages/site_shared/lib/_sass/components/_summary-card.scss diff --git a/site/lib/_sass/components/_tabs.scss b/packages/site_shared/lib/_sass/components/_tabs.scss similarity index 99% rename from site/lib/_sass/components/_tabs.scss rename to packages/site_shared/lib/_sass/components/_tabs.scss index 17381350e7c..05a54bd83d9 100644 --- a/site/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/site/lib/_sass/components/_theming.scss b/packages/site_shared/lib/_sass/components/_theming.scss similarity index 100% rename from site/lib/_sass/components/_theming.scss rename to packages/site_shared/lib/_sass/components/_theming.scss diff --git a/site/lib/_sass/components/_tooltip.scss b/packages/site_shared/lib/_sass/components/_tooltip.scss similarity index 100% rename from site/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/site/lib/src/components/common/breadcrumbs.dart b/packages/site_shared/lib/components/common/breadcrumbs.dart similarity index 90% rename from site/lib/src/components/common/breadcrumbs.dart rename to packages/site_shared/lib/components/common/breadcrumbs.dart index 66d9bb4894f..9a0ef2790b0 100644 --- a/site/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/site/lib/src/components/common/button.dart b/packages/site_shared/lib/components/common/button.dart similarity index 85% rename from site/lib/src/components/common/button.dart rename to packages/site_shared/lib/components/common/button.dart index 740af24cf37..49dd3727c4b 100644 --- a/site/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/site/lib/src/components/common/card.dart b/packages/site_shared/lib/components/common/card.dart similarity index 100% rename from site/lib/src/components/common/card.dart rename to packages/site_shared/lib/components/common/card.dart diff --git a/site/lib/src/components/common/chip.dart b/packages/site_shared/lib/components/common/chip.dart similarity index 99% rename from site/lib/src/components/common/chip.dart rename to packages/site_shared/lib/components/common/chip.dart index 41415e59693..080ec351b95 100644 --- a/site/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/site/lib/src/components/common/client/collapse_button.dart b/packages/site_shared/lib/components/common/client/collapse_button.dart similarity index 100% rename from site/lib/src/components/common/client/collapse_button.dart rename to packages/site_shared/lib/components/common/client/collapse_button.dart diff --git a/site/lib/src/components/common/client/cookie_notice.dart b/packages/site_shared/lib/components/common/client/cookie_notice.dart similarity index 88% rename from site/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/site/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/site/lib/src/components/common/client/copy_button.dart b/packages/site_shared/lib/components/common/client/copy_button.dart similarity index 80% rename from site/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/site/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/site/lib/src/components/common/client/download_button.dart b/packages/site_shared/lib/components/common/client/download_button.dart similarity index 100% rename from site/lib/src/components/common/client/download_button.dart rename to packages/site_shared/lib/components/common/client/download_button.dart diff --git a/site/lib/src/components/common/client/feedback.dart b/packages/site_shared/lib/components/common/client/feedback.dart similarity index 98% rename from site/lib/src/components/common/client/feedback.dart rename to packages/site_shared/lib/components/common/client/feedback.dart index 721bee0d308..7baaafd0b49 100644 --- a/site/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/site/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 site/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/site/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 site/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/site/lib/src/components/common/client/simple_tooltip.dart b/packages/site_shared/lib/components/common/client/simple_tooltip.dart similarity index 83% rename from site/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/site/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/site/lib/src/components/common/dropdown.dart b/packages/site_shared/lib/components/common/dropdown.dart similarity index 98% rename from site/lib/src/components/common/dropdown.dart rename to packages/site_shared/lib/components/common/dropdown.dart index bf177204590..3ec74b55727 100644 --- a/site/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/site/lib/src/components/common/fragment_target.dart b/packages/site_shared/lib/components/common/fragment_target.dart similarity index 100% rename from site/lib/src/components/common/fragment_target.dart rename to packages/site_shared/lib/components/common/fragment_target.dart diff --git a/site/lib/src/components/common/material_icon.dart b/packages/site_shared/lib/components/common/material_icon.dart similarity index 100% rename from site/lib/src/components/common/material_icon.dart rename to packages/site_shared/lib/components/common/material_icon.dart diff --git a/site/lib/src/components/common/search.dart b/packages/site_shared/lib/components/common/search.dart similarity index 100% rename from site/lib/src/components/common/search.dart rename to packages/site_shared/lib/components/common/search.dart diff --git a/site/lib/src/components/common/tabs.dart b/packages/site_shared/lib/components/common/tabs.dart similarity index 100% rename from site/lib/src/components/common/tabs.dart rename to packages/site_shared/lib/components/common/tabs.dart diff --git a/site/lib/src/components/common/tags.dart b/packages/site_shared/lib/components/common/tags.dart similarity index 100% rename from site/lib/src/components/common/tags.dart rename to packages/site_shared/lib/components/common/tags.dart diff --git a/site/lib/src/components/common/tooltip.dart b/packages/site_shared/lib/components/common/tooltip.dart similarity index 98% rename from site/lib/src/components/common/tooltip.dart rename to packages/site_shared/lib/components/common/tooltip.dart index 932f174ff24..3cdb63782bf 100644 --- a/site/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/site/lib/src/components/common/wrapped_code_block.dart b/packages/site_shared/lib/components/common/wrapped_code_block.dart similarity index 99% rename from site/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/site/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/site/lib/src/components/common/youtube_embed.dart b/packages/site_shared/lib/components/common/youtube_embed.dart similarity index 100% rename from site/lib/src/components/common/youtube_embed.dart rename to packages/site_shared/lib/components/common/youtube_embed.dart diff --git a/site/lib/src/components/dartpad/dartpad_injector.dart b/packages/site_shared/lib/components/dartpad/dartpad_injector.dart similarity index 90% rename from site/lib/src/components/dartpad/dartpad_injector.dart rename to packages/site_shared/lib/components/dartpad/dartpad_injector.dart index 95c0981d579..69bb328a4f1 100644 --- a/site/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/site/lib/src/components/dartpad/embedded_dartpad.dart b/packages/site_shared/lib/components/dartpad/embedded_dartpad.dart similarity index 100% rename from site/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..6e88237b8e8 --- /dev/null +++ b/packages/site_shared/lib/components/layout/banner.dart @@ -0,0 +1,62 @@ +// 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 the `src/data/banner.yml` file. +@immutable +final class BannerContent { + final String text; + final String linkText; + final String linkUri; + final bool newTab; + + const BannerContent({ + required this.text, + required this.linkText, + required this.linkUri, + this.newTab = false, + }); + + factory BannerContent.fromMap(Map bannerData) { + final text = bannerData['text'] as String; + final link = bannerData['link'] as Map; + final linkText = link['text'] as String; + final linkUri = link['url'] as String; + final newTab = link['newTab'] as bool? ?? false; + + return BannerContent( + text: text, + linkText: linkText, + linkUri: linkUri, + newTab: 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([ + .text(content.text), + const .text(' '), + a( + href: content.linkUri, + target: content.newTab ? Target.blank : null, + [.text(content.linkText)], + ), + ]), + ], + ); +} diff --git a/site/lib/src/components/layout/menu_toggle.dart b/packages/site_shared/lib/components/layout/menu_toggle.dart similarity index 100% rename from site/lib/src/components/layout/menu_toggle.dart rename to packages/site_shared/lib/components/layout/menu_toggle.dart diff --git a/site/lib/src/components/layout/site_switcher.dart b/packages/site_shared/lib/components/layout/site_switcher.dart similarity index 73% rename from site/lib/src/components/layout/site_switcher.dart rename to packages/site_shared/lib/components/layout/site_switcher.dart index 811a308957e..3dc00d2e851 100644 --- a/site/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/site/lib/src/components/layout/theme_switcher.dart b/packages/site_shared/lib/components/layout/theme_switcher.dart similarity index 99% rename from site/lib/src/components/layout/theme_switcher.dart rename to packages/site_shared/lib/components/layout/theme_switcher.dart index a06e647c477..5674bc86d33 100644 --- a/site/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/site/lib/src/components/tutorial/client/progress_ring.dart b/packages/site_shared/lib/components/tutorial/client/progress_ring.dart similarity index 100% rename from site/lib/src/components/tutorial/client/progress_ring.dart rename to packages/site_shared/lib/components/tutorial/client/progress_ring.dart diff --git a/site/lib/src/components/tutorial/client/quiz.dart b/packages/site_shared/lib/components/tutorial/client/quiz.dart similarity index 95% rename from site/lib/src/components/tutorial/client/quiz.dart rename to packages/site_shared/lib/components/tutorial/client/quiz.dart index f04b044568f..98200df9b00 100644 --- a/site/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/site/lib/src/components/tutorial/downloadable_snippet.dart b/packages/site_shared/lib/components/tutorial/downloadable_snippet.dart similarity index 92% rename from site/lib/src/components/tutorial/downloadable_snippet.dart rename to packages/site_shared/lib/components/tutorial/downloadable_snippet.dart index b11391a25b3..3561ab0aad6 100644 --- a/site/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/site/lib/src/models/quiz_model.dart b/packages/site_shared/lib/components/tutorial/models/quiz_model.dart similarity index 100% rename from site/lib/src/models/quiz_model.dart rename to packages/site_shared/lib/components/tutorial/models/quiz_model.dart diff --git a/site/lib/src/models/summary_card_model.dart b/packages/site_shared/lib/components/tutorial/models/summary_card_model.dart similarity index 100% rename from site/lib/src/models/summary_card_model.dart rename to packages/site_shared/lib/components/tutorial/models/summary_card_model.dart diff --git a/site/lib/src/models/tutorial_model.dart b/packages/site_shared/lib/components/tutorial/models/tutorial_model.dart similarity index 100% rename from site/lib/src/models/tutorial_model.dart rename to packages/site_shared/lib/components/tutorial/models/tutorial_model.dart diff --git a/site/lib/src/components/tutorial/progress_ring.dart b/packages/site_shared/lib/components/tutorial/progress_ring.dart similarity index 100% rename from site/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/site/lib/src/components/tutorial/stepper.dart b/packages/site_shared/lib/components/tutorial/stepper.dart similarity index 99% rename from site/lib/src/components/tutorial/stepper.dart rename to packages/site_shared/lib/components/tutorial/stepper.dart index e9ccfdf43e3..1f7424abd4c 100644 --- a/site/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/site/lib/src/components/tutorial/summary_card.dart b/packages/site_shared/lib/components/tutorial/summary_card.dart similarity index 97% rename from site/lib/src/components/tutorial/summary_card.dart rename to packages/site_shared/lib/components/tutorial/summary_card.dart index 76b8a2e7dd1..298745d318d 100644 --- a/site/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/site/lib/src/components/tutorial/tutorial_outline.dart b/packages/site_shared/lib/components/tutorial/tutorial_outline.dart similarity index 58% rename from site/lib/src/components/tutorial/tutorial_outline.dart rename to packages/site_shared/lib/components/tutorial/tutorial_outline.dart index 672680636f8..c4297b1a656 100644 --- a/site/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/site/lib/src/components/util/global_event_listener.dart b/packages/site_shared/lib/components/utils/global_event_listener.dart similarity index 100% rename from site/lib/src/components/util/global_event_listener.dart rename to packages/site_shared/lib/components/utils/global_event_listener.dart diff --git a/site/lib/src/extensions/attribute_processor.dart b/packages/site_shared/lib/extensions/attribute_processor.dart similarity index 98% rename from site/lib/src/extensions/attribute_processor.dart rename to packages/site_shared/lib/extensions/attribute_processor.dart index 2b5b4419112..c529a386261 100644 --- a/site/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/site/lib/src/extensions/code_block_processor.dart b/packages/site_shared/lib/extensions/code_block_processor.dart similarity index 98% rename from site/lib/src/extensions/code_block_processor.dart rename to packages/site_shared/lib/extensions/code_block_processor.dart index 64aa04e2074..c028285152e 100644 --- a/site/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/site/lib/src/extensions/header_extractor.dart b/packages/site_shared/lib/extensions/header_extractor.dart similarity index 100% rename from site/lib/src/extensions/header_extractor.dart rename to packages/site_shared/lib/extensions/header_extractor.dart diff --git a/site/lib/src/extensions/header_processor.dart b/packages/site_shared/lib/extensions/header_processor.dart similarity index 100% rename from site/lib/src/extensions/header_processor.dart rename to packages/site_shared/lib/extensions/header_processor.dart diff --git a/site/lib/src/extensions/table_processor.dart b/packages/site_shared/lib/extensions/table_processor.dart similarity index 100% rename from site/lib/src/extensions/table_processor.dart rename to packages/site_shared/lib/extensions/table_processor.dart diff --git a/packages/site_shared/lib/layouts/dash_layout.dart b/packages/site_shared/lib/layouts/dash_layout.dart new file mode 100644 index 00000000000..afef129779d --- /dev/null +++ b/packages/site_shared/lib/layouts/dash_layout.dart @@ -0,0 +1,337 @@ +// 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/server.dart'; +import 'package:jaspr_content/jaspr_content.dart'; + +import '../components/common/client/cookie_notice.dart'; +import '../components/layout/banner.dart'; +import '../util.dart'; + +/// The base Jaspr Content layout for all sites. +abstract class DashLayout implements PageLayout { + const DashLayout(); + + @override + String get name; + + List get defaultBodyClasses => []; + + 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. + /// + /// Override in subclasses to provide page-specific URLs for + /// eager prerendering and prefetching. + ({Set prerender, Set prefetch}) speculationUrls(Page page) => + const (prerender: {}, prefetch: {}); + + 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 _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 pageImage = pageData['image'] as String?; + + final windowTitle = titleBase != null + ? '$pageTitle | $titleBase' + : pageTitle; + + final canonicalUrl = pageData['canonical'] as String?; + + return [ + Component.element(tag: 'title', children: [Component.text(windowTitle)]), + meta(name: 'description', content: pageDescription), + + // URL + if (pageData['noindex'] case final noIndex? + when noIndex == true || noIndex == 'true') + const meta(name: 'robots', content: 'noindex'), + if (canonicalUrl case final canonicalUrl? when canonicalUrl.isNotEmpty) + link(rel: 'canonical', href: canonicalUrl), + if (pageData['redirectTo'] case final String redirectTo + when redirectTo.isNotEmpty) + RawText(''), + + // 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: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:url', + 'content': canonicalUrl ?? page.path, + }, + ), + meta( + attributes: { + 'property': 'og:image', + 'content': pageImage ?? twitterDefaultImageUrl, + }, + ), + + // Fonts + const link(rel: 'preconnect', href: 'https://fonts.googleapis.com'), + const link( + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + attributes: {'crossorigin': ''}, + ), + for (final font in fonts) link(rel: 'stylesheet', href: font), + + // Styles + link( + rel: 'stylesheet', + href: '/assets/css/main.css?hash=${htmlEscape.convert(stylesHash)}', + ), + + const script( + src: + 'https://cdn.jsdelivr.net/npm/@justinribeiro/lite-youtube@1.8.2/lite-youtube.js', + attributes: { + 'type': 'module', + 'integrity': 'sha256-Jy0j0fUMJ2T3WxSEs2WjHLrS+3DlO7S9DItQtP55FII=', + 'crossorigin': 'anonymous', + 'referrerpolicy': 'no-referrer', + }, + ), + + // Tag Manager and Analytics + if (productionBuild) ...[ + const script(content: 'window.dataLayer = window.dataLayer || [];'), + script( + content: + ''' +(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': +new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], +j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= +'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); +})(window,document,'script','dataLayer','$tagManagerId'); +''', + ), + script( + content: + ''' +(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ +(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), +m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) +})(window,document,'script','//www.google-analytics.com/analytics.js','ga'); + +ga('create', '$analyticsId', 'auto'); +ga('send', 'pageview'); +''', + ), + ], + + // 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 buildLayout(Page page, Component child) { + final pageData = page.data.page; + final bodyClass = pageData['bodyClass'] as String?; + + 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), + ], + ), + Component.element( + tag: 'body', + classes: [?bodyClass, ...defaultBodyClasses].toClasses, + children: [ + // The theme setting logic should remain before other scripts to + // avoid a flash of the initial theme on load. + const script( + content: ''' +try { + const storedTheme = window.localStorage.getItem('theme') ?? 'light-mode'; + if (storedTheme === 'auto-mode') { + const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)'); + document.body.classList.add( + 'auto-mode', + prefersDarkMode.matches ? 'dark-mode' : 'light-mode', + ); + } else { + document.body.classList.add(storedTheme); + } +} catch (e) { + // localStorage is not available, do nothing and fall back to default. +} + ''', + ), + if (productionBuild) + RawText( + '', + ), + const a( + id: 'skip-to-main', + classes: 'filled-button', + href: '#site-content-title', + attributes: {'tabindex': '1'}, + [.text('Skip to main content')], + ), + 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 Map bannerData) { + return DashBanner(BannerContent.fromMap(bannerData)); + } + } + + return null; + } + + /// Builds the speculation rules `'), + // Fall back to prefetch link tags for browsers without + // Speculation Rules API support. + for (final url in {...prerender, ...prefetch}) + link(rel: 'prefetch', href: url), + ]; + } +} diff --git a/packages/site_shared/lib/markdown.dart b/packages/site_shared/lib/markdown.dart new file mode 100644 index 00000000000..b2bd8d42b61 --- /dev/null +++ b/packages/site_shared/lib/markdown.dart @@ -0,0 +1 @@ +export 'src/markdown/markdown_parser.dart'; diff --git a/packages/site_shared/lib/src/analytics/analytics_server.dart b/packages/site_shared/lib/src/analytics/analytics_server.dart new file mode 100644 index 00000000000..42072ca7a68 --- /dev/null +++ b/packages/site_shared/lib/src/analytics/analytics_server.dart @@ -0,0 +1,18 @@ +// 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 '../../analytics.dart'; + +/// Server implementation of [Analytics]. +/// +/// Don't use directly. Access through [analytics] instead. +@internal +final class AnalyticsImplementation extends Analytics { + @override + void sendEvent(String eventName, Map parameters) { + // Ignore on the server. + } +} diff --git a/packages/site_shared/lib/src/analytics/analytics_web.dart b/packages/site_shared/lib/src/analytics/analytics_web.dart new file mode 100644 index 00000000000..a68ff944c7b --- /dev/null +++ b/packages/site_shared/lib/src/analytics/analytics_web.dart @@ -0,0 +1,32 @@ +// 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 'package:universal_web/js_interop.dart'; +import 'package:universal_web/web.dart' as web; + +import '../../analytics.dart'; +import '../../util.dart'; + +/// Web implementation of [Analytics]. +/// +/// Don't use directly. Access through [analytics] instead. +@internal +final class AnalyticsImplementation extends Analytics { + @override + void sendEvent(String eventName, Map parameters) { + if (!productionBuild) { + return; + } + final dataLayer = web.window['dataLayer']; + if (dataLayer.isA()) { + (dataLayer as JSArray).toDart.add( + { + 'event': eventName, + ...parameters, + }.jsify(), + ); + } + } +} diff --git a/site/lib/src/builders/styles_hash_builder.dart b/packages/site_shared/lib/src/builders/styles_hash_builder.dart similarity index 89% rename from site/lib/src/builders/styles_hash_builder.dart rename to packages/site_shared/lib/src/builders/styles_hash_builder.dart index 1d1c0bfbf07..a71332fd03d 100644 --- a/site/lib/src/builders/styles_hash_builder.dart +++ b/packages/site_shared/lib/src/builders/styles_hash_builder.dart @@ -6,6 +6,8 @@ import 'dart:convert'; import 'package:build/build.dart'; import 'package:crypto/crypto.dart'; +Builder stylesHashBuilder(BuilderOptions options) => StylesHashBuilder(options); + class StylesHashBuilder implements Builder { const StylesHashBuilder(this.options); @@ -31,7 +33,7 @@ class StylesHashBuilder implements Builder { final outputContent = """ -// Generated by docs_flutter_dev_site|stylesHashBuilder. Do not edit. +// Generated by site_shared|stylesHashBuilder. Do not edit. // dart format off /// The generated hash of the `main.css` file. diff --git a/site/lib/src/highlight/theme/dark.dart b/packages/site_shared/lib/src/highlight/theme/dark.dart similarity index 100% rename from site/lib/src/highlight/theme/dark.dart rename to packages/site_shared/lib/src/highlight/theme/dark.dart diff --git a/site/lib/src/highlight/theme/light.dart b/packages/site_shared/lib/src/highlight/theme/light.dart similarity index 100% rename from site/lib/src/highlight/theme/light.dart rename to packages/site_shared/lib/src/highlight/theme/light.dart diff --git a/site/lib/src/highlight/token_renderer.dart b/packages/site_shared/lib/src/highlight/token_renderer.dart similarity index 99% rename from site/lib/src/highlight/token_renderer.dart rename to packages/site_shared/lib/src/highlight/token_renderer.dart index e158c57e928..175e06e2fcb 100644 --- a/site/lib/src/highlight/token_renderer.dart +++ b/packages/site_shared/lib/src/highlight/token_renderer.dart @@ -152,7 +152,8 @@ enum FontWeight { w600(600), w700(700), w800(800), - w900(900); + w900(900) + ; final int weight; diff --git a/site/lib/src/markdown/alert_syntax.dart b/packages/site_shared/lib/src/markdown/alert_syntax.dart similarity index 99% rename from site/lib/src/markdown/alert_syntax.dart rename to packages/site_shared/lib/src/markdown/alert_syntax.dart index 5c1473209cb..93e2dd4d257 100644 --- a/site/lib/src/markdown/alert_syntax.dart +++ b/packages/site_shared/lib/src/markdown/alert_syntax.dart @@ -157,7 +157,8 @@ enum _AlertType { ), secondary( cssClass: 'alert-secondary', - ); + ) + ; /// The CSS class to add to `aside` element final String cssClass; diff --git a/site/lib/src/markdown/attribute_syntax.dart b/packages/site_shared/lib/src/markdown/attribute_syntax.dart similarity index 98% rename from site/lib/src/markdown/attribute_syntax.dart rename to packages/site_shared/lib/src/markdown/attribute_syntax.dart index 9a213317fac..b54a8b35622 100644 --- a/site/lib/src/markdown/attribute_syntax.dart +++ b/packages/site_shared/lib/src/markdown/attribute_syntax.dart @@ -4,7 +4,7 @@ import 'package:markdown/markdown.dart' as md; -import '../util.dart'; +import '../../util.dart'; /// A `package:markdown` extension that adds support for /// attribute syntax as a standalone block. diff --git a/site/lib/src/markdown/fenced_code_block_syntax.dart b/packages/site_shared/lib/src/markdown/fenced_code_block_syntax.dart similarity index 100% rename from site/lib/src/markdown/fenced_code_block_syntax.dart rename to packages/site_shared/lib/src/markdown/fenced_code_block_syntax.dart diff --git a/site/lib/src/markdown/header_syntax.dart b/packages/site_shared/lib/src/markdown/header_syntax.dart similarity index 98% rename from site/lib/src/markdown/header_syntax.dart rename to packages/site_shared/lib/src/markdown/header_syntax.dart index e4be28e7e11..6da8f2d7986 100644 --- a/site/lib/src/markdown/header_syntax.dart +++ b/packages/site_shared/lib/src/markdown/header_syntax.dart @@ -4,7 +4,7 @@ import 'package:markdown/markdown.dart' as md; -import '../util.dart'; +import '../../util.dart'; /// A custom header syntax that extends the default header syntax to support: /// diff --git a/site/lib/src/markdown/markdown_parser.dart b/packages/site_shared/lib/src/markdown/markdown_parser.dart similarity index 98% rename from site/lib/src/markdown/markdown_parser.dart rename to packages/site_shared/lib/src/markdown/markdown_parser.dart index ea8bead4e26..05d44b53b51 100644 --- a/site/lib/src/markdown/markdown_parser.dart +++ b/packages/site_shared/lib/src/markdown/markdown_parser.dart @@ -14,7 +14,6 @@ import 'package:jaspr_content/jaspr_content.dart'; import 'package:markdown/markdown.dart' as md; import 'package:markdown_description_list/markdown_description_list.dart'; -import '../extensions/registry.dart'; import 'alert_syntax.dart'; import 'attribute_syntax.dart'; import 'fenced_code_block_syntax.dart'; @@ -65,7 +64,7 @@ class DashMarkdown extends AsyncStatelessComponent { ? _defaultMarkdownDocument.parseInline(content) : _defaultMarkdownDocument.parse(content); var nodes = DashMarkdownParser.buildNodes(markdownNodes); - for (final extension in allNodeProcessingExtensions) { + for (final extension in currentPage.config.extensions) { nodes = await extension.apply(currentPage, nodes); } diff --git a/site/lib/src/components/util/retake_element.dart b/packages/site_shared/lib/src/utils/retake_element.dart similarity index 100% rename from site/lib/src/components/util/retake_element.dart rename to packages/site_shared/lib/src/utils/retake_element.dart diff --git a/packages/site_shared/lib/src/utils/retake_element_vm.dart b/packages/site_shared/lib/src/utils/retake_element_vm.dart new file mode 100644 index 00000000000..d98584f0505 --- /dev/null +++ b/packages/site_shared/lib/src/utils/retake_element_vm.dart @@ -0,0 +1,7 @@ +import 'package:jaspr/jaspr.dart'; + +Component retakeRef(BuildContext context, String id) { + throw UnimplementedError(); +} + +String extractContent(Element element) => ''; diff --git a/site/lib/src/components/util/retake_element_web.dart b/packages/site_shared/lib/src/utils/retake_element_web.dart similarity index 59% rename from site/lib/src/components/util/retake_element_web.dart rename to packages/site_shared/lib/src/utils/retake_element_web.dart index 36a1694baac..5631e5ac73f 100644 --- a/site/lib/src/components/util/retake_element_web.dart +++ b/packages/site_shared/lib/src/utils/retake_element_web.dart @@ -5,23 +5,54 @@ import 'package:jaspr/client.dart'; // ignore: implementation_imports import 'package:jaspr/src/dom/type_checks.dart'; +import 'package:universal_web/js_interop.dart'; import 'package:universal_web/web.dart' as web; -/// Retakes the element matching [predicate] during hydration. -web.Element? retakeElement( - BuildContext context, - bool Function(web.Element element) predicate, -) { +/// Retakes the element with the specified [id] ref during hydration. +Component retakeRef(BuildContext context, String id) { final r = (context as Element).parentRenderObjectElement?.renderObject; - if (r == null) return null; - final node = (r as DomRenderObject).retakeNode((node) { - return node.isElement && predicate(node as web.Element); + if (r == null) return const .empty(); + + var node = (r as DomRenderObject).retakeNode((node) { + return node.isComment && (node as web.Comment).data.startsWith('ref:$id'); }); - return node as web.Element?; + + if (node == null) return const .empty(); + + final nodes = [node]; + + node = node.nextSibling; + while (node != null) { + r.retakeNode((n) => n == node); + nodes.add(node); + + if (node.isComment && (node as web.Comment).data.startsWith('/ref:$id')) { + break; + } + + node = node.nextSibling; + } + + return .fragment([ + for (final node in nodes) RawNode(node), + ]); } -Component wrapNode(web.Node node) { - return RawNode(node); +/// Extracts the content of a
 block inside the given
+/// [element] during hydration.
+String extractContent(Element element) {
+  final r = element.parentRenderObjectElement?.renderObject as DomRenderObject?;
+  if (r == null) return '';
+
+  final code = r.retakeNode((node) {
+    return node.instanceOfString('Element') &&
+        (node as web.Element).tagName.toLowerCase() == 'pre';
+  });
+
+  if (code == null) return '';
+
+  code.parentNode?.removeChild(code);
+  return (code as web.Element).textContent ?? '';
 }
 
 class RawNode extends Component {
diff --git a/packages/site_shared/lib/util.dart b/packages/site_shared/lib/util.dart
new file mode 100644
index 00000000000..8687871a194
--- /dev/null
+++ b/packages/site_shared/lib/util.dart
@@ -0,0 +1,156 @@
+// 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:universal_web/web.dart' as web;
+
+/// Whether this build of the site will be deployed to production.
+const productionBuild = bool.fromEnvironment('PRODUCTION');
+
+/// Converts the specified [text] into a standardized URL slug
+/// that can be used as the ID for headers and other anchors in HTML.
+String slugify(String text) => text
+    .toLowerCase()
+    .trim()
+    .replaceAll(_slugifyPunctuationToReplace, '-')
+    .replaceAll(_slugifyUnsupportedToRemove, '')
+    .replaceAll(_slugifyCharsToCombine, '-')
+    .replaceAll(_slugifyHyphenTrim, '');
+
+final RegExp _slugifyPunctuationToReplace = RegExp(r'[:._]');
+final RegExp _slugifyUnsupportedToRemove = RegExp(
+  r'[^\p{L}\p{N}\s:.-]',
+  unicode: true,
+);
+final RegExp _slugifyCharsToCombine = RegExp(r'[\s-]+');
+final RegExp _slugifyHyphenTrim = RegExp(r'^-+|-+$');
+
+final RegExp _attributePattern = RegExp(r'(\w+)="([^"]*)"');
+final RegExp _whitespacePattern = RegExp(r'\s+');
+final RegExp _wordPattern = RegExp(r'\S+(?:\s*|$)');
+final RegExp _trailingMarkdownLinkPattern = RegExp(r'(\[.+\]:\s*\S+\s*)+$');
+
+Map parseAttributes(String attributeString) {
+  final attributes = {};
+  final classes = [];
+
+  // Extract all key="value" pairs.
+  final keyValueMatches = _attributePattern.allMatches(attributeString);
+  for (final match in keyValueMatches) {
+    final key = match.group(1)!;
+    final value = match.group(2)!;
+    attributes[key] = value;
+  }
+
+  // Remove all key="value" pairs to process remaining tokens.
+  final remaining = attributeString.replaceAll(_attributePattern, '').trim();
+
+  // Split remaining content by whitespace to find IDs and classes.
+  final parts = remaining.split(_whitespacePattern);
+
+  for (final part in parts) {
+    if (part.isEmpty) continue;
+
+    if (part.startsWith('#')) {
+      attributes['id'] = part.substring(1);
+    } else if (part.startsWith('.')) {
+      classes.add(part.substring(1));
+    }
+  }
+
+  if (classes.isNotEmpty) {
+    attributes['class'] = classes.join(' ');
+  }
+
+  return attributes;
+}
+
+String truncateWords(String text, int maxWords) {
+  if (maxWords <= 0) {
+    return '';
+  }
+
+  final words = text.trim().split(_whitespacePattern);
+  if (words.length <= maxWords) {
+    return text;
+  }
+
+  final truncated = words.take(maxWords).join(' ');
+  return '$truncated...';
+}
+
+/// Truncates the given [text] to the specified number of words [maxWords],
+/// preserving all whitespace and line breaks, as well as any trailing Markdown
+/// link definitions at the end of the text.
+String truncateWordsMarkdown(String text, int maxWords) {
+  if (maxWords <= 0) {
+    return '';
+  }
+
+  final trailingLinks = _trailingMarkdownLinkPattern.firstMatch(text);
+  var endContent = '';
+
+  if (trailingLinks != null) {
+    text = text.substring(0, trailingLinks.start);
+    endContent = '\n${trailingLinks.group(0)!}';
+  }
+
+  final matches = _wordPattern.allMatches(text);
+  if (matches.length <= maxWords) {
+    return text + endContent;
+  }
+
+  final truncated = matches.map((m) => m.group(0)!).take(maxWords).join('');
+  return '$truncated...\n$endContent';
+}
+
+extension StringUnCapitalize on String {
+  String unCapitalize() =>
+      isEmpty ? this : substring(0, 1).toLowerCase() + substring(1);
+}
+
+extension ListToClasses on List {
+  /// Convert a list of classes into a single class string
+  /// that can be added to an HTML element.
+  String get toClasses => join(' ');
+}
+
+enum OperatingSystem {
+  windows('Windows'),
+  macos('macOS'),
+  linux('Linux'),
+  chromeos('ChromeOS')
+  ;
+
+  const OperatingSystem(this.label);
+  final String label;
+}
+
+/// Get the user's current operating system, or
+/// `null` if not of one "macos", "windows", "linux", or "chromeos".
+OperatingSystem? getOS() {
+  final userAgent = web.window.navigator.userAgent;
+  if (userAgent.contains('Mac')) {
+    // macOS or iPhone
+    return OperatingSystem.macos;
+  }
+
+  if (userAgent.contains('Win')) {
+    // Windows
+    return OperatingSystem.windows;
+  }
+
+  if ((userAgent.contains('Linux') || userAgent.contains('X11')) &&
+      !userAgent.contains('Android')) {
+    // Linux, but not Android
+    return OperatingSystem.linux;
+  }
+
+  if (userAgent.contains('CrOS')) {
+    // ChromeOS
+    return OperatingSystem.chromeos;
+  }
+
+  // Anything else
+  return null;
+}
diff --git a/packages/site_shared/pubspec.yaml b/packages/site_shared/pubspec.yaml
new file mode 100644
index 00000000000..787e1f93a30
--- /dev/null
+++ b/packages/site_shared/pubspec.yaml
@@ -0,0 +1,25 @@
+name: site_shared
+publish_to: none
+
+resolution: workspace
+environment:
+  sdk: ^3.11.0
+
+dependencies:
+  build: ^4.0.5
+  collection: ^1.19.1
+  crypto: ^3.0.7
+  html: ^0.15.6
+  jaspr: ^0.23.0
+  jaspr_content: ^0.5.2
+  markdown: ^7.3.1
+  markdown_description_list: ^0.1.1
+  meta: ^1.18.2
+  nanoid2: ^2.0.1
+  opal: ^0.2.2
+  path: ^1.9.0
+  universal_web: ^1.1.1+1
+  yaml: ^3.1.3
+
+dev_dependencies:
+  analysis_defaults: any
diff --git a/pubspec.yaml b/pubspec.yaml
index 23981e17e37..2fd12250298 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -7,5 +7,6 @@ environment:
 workspace:
   - packages/analysis_defaults
   - packages/excerpter
+  - packages/site_shared
   - site
   - tool/dash_site
diff --git a/site/build.yaml b/site/build.yaml
index 4787d5a2661..b30a155a0fa 100644
--- a/site/build.yaml
+++ b/site/build.yaml
@@ -1,13 +1,3 @@
-builders:
-  stylesHashBuilder:
-    import: 'package:docs_flutter_dev_site/builders.dart'
-    builder_factories: [ 'stylesHashBuilder' ]
-    build_extensions:
-      "web/assets/css/main.css":
-        - "lib/src/style_hash.dart"
-    auto_apply: root_package
-    build_to: source
-
 targets:
   $default:
     builders:
@@ -27,6 +17,3 @@ targets:
             # Disable for now.
             # dart2wasm:
             #   args: [-O2, -Djaspr.flags.release=true]
-      docs_flutter_dev_site:stylesHashBuilder:
-        dev_options:
-          fixed_hash: true
diff --git a/site/lib/_sass/_site.scss b/site/lib/_sass/_site.scss
index c400cf86d58..b1702b46b77 100644
--- a/site/lib/_sass/_site.scss
+++ b/site/lib/_sass/_site.scss
@@ -1,6 +1,6 @@
 // Must be imported first to ensure that
 // the reset is applied before any other styles are applied.
-@use 'base/reset';
+@use 'package:site_shared/_sass/base/reset';
 
 // Must be imported second to ensure that our defined CSS variables are defined
 // before any other styles are applied and they are used.
@@ -11,17 +11,9 @@
 @use 'base/utils';
 
 // Styles for individual components or content types, alphabetically ordered.
-@use 'components/alert';
-@use 'components/banner';
 @use 'components/books';
-@use 'components/breadcrumbs';
-@use 'components/button';
-@use 'components/card';
-@use 'components/code';
 @use 'components/content';
 @use 'components/collapsible';
-@use 'components/cookie-notice';
-@use 'components/dropdown';
 @use 'components/expansion-list';
 @use 'components/filter-search';
 @use 'components/footer';
@@ -33,18 +25,29 @@
 @use 'components/pagenav';
 @use 'components/platform-cards';
 @use 'components/pill';
-@use 'components/quiz';
 @use 'components/sidebar';
 @use 'components/side-menu';
-@use 'components/site-switcher';
-@use 'components/summary-card';
-@use 'components/stepper';
-@use 'components/tabs';
-@use 'components/theming';
-@use 'components/tooltip';
 @use 'components/trailing';
 @use 'components/tutorial_pages';
 
+// Shared styles from site_shared package, alphabetically ordered.
+@use 'package:site_shared/_sass/components/alert';
+@use 'package:site_shared/_sass/components/banner';
+@use 'package:site_shared/_sass/components/breadcrumbs';
+@use 'package:site_shared/_sass/components/button';
+@use 'package:site_shared/_sass/components/card';
+@use 'package:site_shared/_sass/components/code';
+@use 'package:site_shared/_sass/components/cookie-notice';
+@use 'package:site_shared/_sass/components/dropdown';
+@use 'package:site_shared/_sass/components/menu-toggle';
+@use 'package:site_shared/_sass/components/quiz';
+@use 'package:site_shared/_sass/components/site-switcher';
+@use 'package:site_shared/_sass/components/stepper';
+@use 'package:site_shared/_sass/components/summary-card';
+@use 'package:site_shared/_sass/components/tabs';
+@use 'package:site_shared/_sass/components/theming';
+@use 'package:site_shared/_sass/components/tooltip';
+
 // Styles for specific pages, alphabetically ordered.
 @use 'pages/glossary';
 @use 'pages/learning-resources-index';
@@ -53,4 +56,4 @@
 
 // Must be imported last to ensure that
 // the print overrides take priority over earlier defined styles.
-@use 'base/print-overrides';
+@use 'base/print-overrides';
\ No newline at end of file
diff --git a/site/lib/_sass/components/_header.scss b/site/lib/_sass/components/_header.scss
index ad24c34f596..2370c1e21e3 100644
--- a/site/lib/_sass/components/_header.scss
+++ b/site/lib/_sass/components/_header.scss
@@ -136,32 +136,6 @@
   }
 }
 
-body:not(.sidenav-closed) #menu-toggle {
-  @media (min-width: 1024px) {
-    display: none;
-  }
-}
-
-// Toggle between menu and close buttons if sidenav is open or not.
-#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;
-  }
-}
 
 #site-primary-logo {
   text-decoration: none;
diff --git a/site/lib/_sass/components/_next-prev-nav.scss b/site/lib/_sass/components/_next-prev-nav.scss
index ce2bde478b8..1897e453f13 100644
--- a/site/lib/_sass/components/_next-prev-nav.scss
+++ b/site/lib/_sass/components/_next-prev-nav.scss
@@ -1,4 +1,4 @@
-@use '../base/mixins';
+@use 'package:site_shared/_sass/base/mixins';
 
 #site-prev-next {
   display: flex;
diff --git a/site/lib/_sass/components/_sidebar.scss b/site/lib/_sass/components/_sidebar.scss
index db57724e097..ffa324a545c 100644
--- a/site/lib/_sass/components/_sidebar.scss
+++ b/site/lib/_sass/components/_sidebar.scss
@@ -1,4 +1,4 @@
-@use '../base/mixins';
+@use 'package:site_shared/_sass/base/mixins';
 
 #sidenav {
   margin: 0;
diff --git a/site/lib/_sass/pages/_glossary.scss b/site/lib/_sass/pages/_glossary.scss
index 74fbe7ad353..d7ab79e0672 100644
--- a/site/lib/_sass/pages/_glossary.scss
+++ b/site/lib/_sass/pages/_glossary.scss
@@ -1,4 +1,4 @@
-@use '../base/mixins';
+@use 'package:site_shared/_sass/base/mixins';
 
 body.glossary-page main {
   .glossary-card {
diff --git a/site/lib/_sass/pages/_learning-resources-index.scss b/site/lib/_sass/pages/_learning-resources-index.scss
index d19ecc8a80f..3527fa4ca80 100644
--- a/site/lib/_sass/pages/_learning-resources-index.scss
+++ b/site/lib/_sass/pages/_learning-resources-index.scss
@@ -1,4 +1,4 @@
-@use '../base/mixins';
+@use 'package:site_shared/_sass/base/mixins';
 
 #resource-filter-group-wrapper {
   border: 1px solid var(--site-inset-borderColor);
diff --git a/site/lib/_sass/pages/_search.scss b/site/lib/_sass/pages/_search.scss
index 30fa71479cc..2575342018d 100644
--- a/site/lib/_sass/pages/_search.scss
+++ b/site/lib/_sass/pages/_search.scss
@@ -1,4 +1,4 @@
-@use '../base/mixins';
+@use 'package:site_shared/_sass/base/mixins';
 
 #search-body {
   margin-block-start: 1.5rem;
diff --git a/site/lib/builders.dart b/site/lib/builders.dart
deleted file mode 100644
index e5605d1cbae..00000000000
--- a/site/lib/builders.dart
+++ /dev/null
@@ -1,9 +0,0 @@
-// 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:build/build.dart';
-
-import 'src/builders/styles_hash_builder.dart' show StylesHashBuilder;
-
-Builder stylesHashBuilder(BuilderOptions options) => StylesHashBuilder(options);
diff --git a/site/lib/main.client.options.dart b/site/lib/main.client.options.dart
index 6ebbe20949f..c781ee279b5 100644
--- a/site/lib/main.client.options.dart
+++ b/site/lib/main.client.options.dart
@@ -6,36 +6,12 @@
 
 import 'package:jaspr/client.dart';
 
-import 'package:docs_flutter_dev_site/src/components/common/client/collapse_button.dart'
-    deferred as _collapse_button;
-import 'package:docs_flutter_dev_site/src/components/common/client/cookie_notice.dart'
-    deferred as _cookie_notice;
-import 'package:docs_flutter_dev_site/src/components/common/client/copy_button.dart'
-    deferred as _copy_button;
-import 'package:docs_flutter_dev_site/src/components/common/client/download_button.dart'
-    deferred as _download_button;
 import 'package:docs_flutter_dev_site/src/components/common/client/download_latest_button.dart'
     deferred as _download_latest_button;
-import 'package:docs_flutter_dev_site/src/components/common/client/feedback.dart'
-    deferred as _feedback;
-import 'package:docs_flutter_dev_site/src/components/common/client/on_this_page_button.dart'
-    deferred as _on_this_page_button;
 import 'package:docs_flutter_dev_site/src/components/common/client/os_selector.dart'
     deferred as _os_selector;
-import 'package:docs_flutter_dev_site/src/components/common/client/page_header_options.dart'
-    deferred as _page_header_options;
-import 'package:docs_flutter_dev_site/src/components/common/client/simple_tooltip.dart'
-    deferred as _simple_tooltip;
-import 'package:docs_flutter_dev_site/src/components/dartpad/dartpad_injector.dart'
-    deferred as _dartpad_injector;
 import 'package:docs_flutter_dev_site/src/components/layout/client/pagenav.dart'
     deferred as _pagenav;
-import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart'
-    deferred as _menu_toggle;
-import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart'
-    deferred as _site_switcher;
-import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart'
-    deferred as _theme_switcher;
 import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart'
     deferred as _archive_table;
 import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart'
@@ -44,12 +20,36 @@ import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_fil
     deferred as _learning_resource_filters;
 import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart'
     deferred as _learning_resource_filters_sidebar;
-import 'package:docs_flutter_dev_site/src/components/tutorial/client/quiz.dart'
+import 'package:site_shared/components/common/client/collapse_button.dart'
+    deferred as _collapse_button;
+import 'package:site_shared/components/common/client/cookie_notice.dart'
+    deferred as _cookie_notice;
+import 'package:site_shared/components/common/client/copy_button.dart'
+    deferred as _copy_button;
+import 'package:site_shared/components/common/client/download_button.dart'
+    deferred as _download_button;
+import 'package:site_shared/components/common/client/feedback.dart'
+    deferred as _feedback;
+import 'package:site_shared/components/common/client/on_this_page_button.dart'
+    deferred as _on_this_page_button;
+import 'package:site_shared/components/common/client/page_header_options.dart'
+    deferred as _page_header_options;
+import 'package:site_shared/components/common/client/simple_tooltip.dart'
+    deferred as _simple_tooltip;
+import 'package:site_shared/components/dartpad/dartpad_injector.dart'
+    deferred as _dartpad_injector;
+import 'package:site_shared/components/layout/menu_toggle.dart'
+    deferred as _menu_toggle;
+import 'package:site_shared/components/layout/site_switcher.dart'
+    deferred as _site_switcher;
+import 'package:site_shared/components/layout/theme_switcher.dart'
+    deferred as _theme_switcher;
+import 'package:site_shared/components/tutorial/client/quiz.dart'
     deferred as _quiz;
-import 'package:docs_flutter_dev_site/src/components/util/component_ref.dart'
-    as _component_ref;
-import 'package:docs_flutter_dev_site/src/models/quiz_model.dart'
+import 'package:site_shared/components/tutorial/models/quiz_model.dart'
     as _quiz_model;
+import 'package:site_shared/components/utils/component_ref.dart'
+    as _component_ref;
 
 /// Default [ClientOptions] for use with your Jaspr project.
 ///
@@ -69,49 +69,82 @@ import 'package:docs_flutter_dev_site/src/models/quiz_model.dart'
 /// ```
 ClientOptions get defaultClientOptions => ClientOptions(
   clients: {
-    'collapse_button': ClientLoader(
+    'download_latest_button': ClientLoader(
+      (p) => _download_latest_button.DownloadLatestButton(
+        os: p['os'] as String,
+        arch: p['arch'] as String?,
+      ),
+      loader: _download_latest_button.loadLibrary,
+    ),
+    'os_selector': ClientLoader(
+      (p) => _os_selector.OsSelector(),
+      loader: _os_selector.loadLibrary,
+    ),
+    'pagenav': ClientLoader(
+      (p) => _pagenav.PageNav(
+        breadcrumbs: (p['breadcrumbs'] as List).cast(),
+        pageNumber: p['pageNumber'] as int?,
+        initialHeading: p['initialHeading'] as String,
+        content: _component_ref.ComponentRef.fromId(p['content'] as String),
+      ),
+      loader: _pagenav.loadLibrary,
+    ),
+    'archive_table': ClientLoader(
+      (p) => _archive_table.ArchiveTable(
+        os: p['os'] as String,
+        channel: p['channel'] as String,
+      ),
+      loader: _archive_table.loadLibrary,
+    ),
+    'glossary_search_section': ClientLoader(
+      (p) => _glossary_search_section.GlossarySearchSection(),
+      loader: _glossary_search_section.loadLibrary,
+    ),
+    'learning_resource_filters': ClientLoader(
+      (p) => _learning_resource_filters.LearningResourceFilters(),
+      loader: _learning_resource_filters.loadLibrary,
+    ),
+    'learning_resource_filters_sidebar': ClientLoader(
+      (p) =>
+          _learning_resource_filters_sidebar.LearningResourceFiltersSidebar(),
+      loader: _learning_resource_filters_sidebar.loadLibrary,
+    ),
+    'site_shared:collapse_button': ClientLoader(
       (p) => _collapse_button.CollapseButton(
         classes: (p['classes'] as List).cast(),
         title: p['title'] as String?,
       ),
       loader: _collapse_button.loadLibrary,
     ),
-    'cookie_notice': ClientLoader(
-      (p) => _cookie_notice.CookieNotice(),
+    'site_shared:cookie_notice': ClientLoader(
+      (p) => _cookie_notice.CookieNotice(
+        host: p['host'] as String,
+        alwaysDarkMode: p['alwaysDarkMode'] as bool,
+      ),
       loader: _cookie_notice.loadLibrary,
     ),
-    'copy_button': ClientLoader(
+    'site_shared:copy_button': ClientLoader(
       (p) => _copy_button.CopyButton(
         buttonText: p['buttonText'] as String?,
+        toCopy: p['toCopy'] as String?,
         classes: (p['classes'] as List).cast(),
         title: p['title'] as String?,
       ),
       loader: _copy_button.loadLibrary,
     ),
-    'download_button': ClientLoader(
+    'site_shared:download_button': ClientLoader(
       (p) => _download_button.DownloadButton(name: p['name'] as String),
       loader: _download_button.loadLibrary,
     ),
-    'download_latest_button': ClientLoader(
-      (p) => _download_latest_button.DownloadLatestButton(
-        os: p['os'] as String,
-        arch: p['arch'] as String?,
-      ),
-      loader: _download_latest_button.loadLibrary,
-    ),
-    'feedback': ClientLoader(
+    'site_shared:feedback': ClientLoader(
       (p) => _feedback.FeedbackComponent(issueUrl: p['issueUrl'] as String),
       loader: _feedback.loadLibrary,
     ),
-    'on_this_page_button': ClientLoader(
+    'site_shared:on_this_page_button': ClientLoader(
       (p) => _on_this_page_button.OnThisPageButton(),
       loader: _on_this_page_button.loadLibrary,
     ),
-    'os_selector': ClientLoader(
-      (p) => _os_selector.OsSelector(),
-      loader: _os_selector.loadLibrary,
-    ),
-    'page_header_options': ClientLoader(
+    'site_shared:page_header_options': ClientLoader(
       (p) => _page_header_options.PageHeaderOptions(
         title: p['title'] as String,
         sourceUrl: p['sourceUrl'] as String?,
@@ -119,14 +152,14 @@ ClientOptions get defaultClientOptions => ClientOptions(
       ),
       loader: _page_header_options.loadLibrary,
     ),
-    'simple_tooltip': ClientLoader(
+    'site_shared:simple_tooltip': ClientLoader(
       (p) => _simple_tooltip.SimpleTooltip(
         target: _component_ref.ComponentRef.fromId(p['target'] as String),
         content: _component_ref.ComponentRef.fromId(p['content'] as String),
       ),
       loader: _simple_tooltip.loadLibrary,
     ),
-    'dartpad_injector': ClientLoader(
+    'site_shared:dartpad_injector': ClientLoader(
       (p) => _dartpad_injector.DartPadInjector(
         title: p['title'] as String,
         theme: p['theme'] as String?,
@@ -135,48 +168,19 @@ ClientOptions get defaultClientOptions => ClientOptions(
       ),
       loader: _dartpad_injector.loadLibrary,
     ),
-    'pagenav': ClientLoader(
-      (p) => _pagenav.PageNav(
-        breadcrumbs: (p['breadcrumbs'] as List).cast(),
-        pageNumber: p['pageNumber'] as int?,
-        initialHeading: p['initialHeading'] as String,
-        content: _component_ref.ComponentRef.fromId(p['content'] as String),
-      ),
-      loader: _pagenav.loadLibrary,
-    ),
-    'menu_toggle': ClientLoader(
+    'site_shared:menu_toggle': ClientLoader(
       (p) => _menu_toggle.MenuToggle(),
       loader: _menu_toggle.loadLibrary,
     ),
-    'site_switcher': ClientLoader(
-      (p) => _site_switcher.SiteSwitcher(),
+    'site_shared:site_switcher': ClientLoader(
+      (p) => _site_switcher.SiteSwitcher(isFlutter: p['isFlutter'] as bool),
       loader: _site_switcher.loadLibrary,
     ),
-    'theme_switcher': ClientLoader(
+    'site_shared:theme_switcher': ClientLoader(
       (p) => _theme_switcher.ThemeSwitcher(),
       loader: _theme_switcher.loadLibrary,
     ),
-    'archive_table': ClientLoader(
-      (p) => _archive_table.ArchiveTable(
-        os: p['os'] as String,
-        channel: p['channel'] as String,
-      ),
-      loader: _archive_table.loadLibrary,
-    ),
-    'glossary_search_section': ClientLoader(
-      (p) => _glossary_search_section.GlossarySearchSection(),
-      loader: _glossary_search_section.loadLibrary,
-    ),
-    'learning_resource_filters': ClientLoader(
-      (p) => _learning_resource_filters.LearningResourceFilters(),
-      loader: _learning_resource_filters.loadLibrary,
-    ),
-    'learning_resource_filters_sidebar': ClientLoader(
-      (p) =>
-          _learning_resource_filters_sidebar.LearningResourceFiltersSidebar(),
-      loader: _learning_resource_filters_sidebar.loadLibrary,
-    ),
-    'quiz': ClientLoader(
+    'site_shared:quiz': ClientLoader(
       (p) => _quiz.InteractiveQuiz(
         title: p['title'] as String?,
         questions: (p['questions'] as List)
diff --git a/site/lib/main.server.dart b/site/lib/main.server.dart
index 2c897e0b8d0..1e5f4dd4aed 100644
--- a/site/lib/main.server.dart
+++ b/site/lib/main.server.dart
@@ -7,16 +7,24 @@ import 'package:jaspr_content/components/file_tree.dart';
 import 'package:jaspr_content/jaspr_content.dart';
 import 'package:jaspr_content/theme.dart';
 import 'package:path/path.dart' as path;
+import 'package:site_shared/components/common/card.dart';
+import 'package:site_shared/components/common/material_icon.dart';
+import 'package:site_shared/components/common/tabs.dart';
+import 'package:site_shared/components/common/youtube_embed.dart';
+import 'package:site_shared/components/tutorial/downloadable_snippet.dart';
+import 'package:site_shared/components/tutorial/progress_ring.dart';
+import 'package:site_shared/components/tutorial/quiz.dart';
+import 'package:site_shared/components/tutorial/stepper.dart';
+import 'package:site_shared/components/tutorial/summary_card.dart';
+import 'package:site_shared/components/tutorial/tutorial_outline.dart';
+import 'package:site_shared/components/utils/define_component.dart';
+import 'package:site_shared/markdown.dart';
 
 import 'main.server.options.dart'; // Generated. Do not remove or edit.
-import 'src/components/common/card.dart';
 import 'src/components/common/client/download_latest_button.dart';
 import 'src/components/common/client/os_selector.dart';
 import 'src/components/common/code_preview.dart';
 import 'src/components/common/dash_image.dart';
-import 'src/components/common/material_icon.dart';
-import 'src/components/common/tabs.dart';
-import 'src/components/common/youtube_embed.dart';
 import 'src/components/pages/architecture_recommendations.dart';
 import 'src/components/pages/archive_table.dart';
 import 'src/components/pages/devtools_release_notes_index.dart';
@@ -24,19 +32,11 @@ import 'src/components/pages/expansion_list.dart';
 import 'src/components/pages/learning_resource_index.dart';
 import 'src/components/pages/platforms_grid.dart';
 import 'src/components/pages/widget_catalog.dart';
-import 'src/components/tutorial/downloadable_snippet.dart';
-import 'src/components/tutorial/progress_ring.dart';
-import 'src/components/tutorial/quiz.dart';
-import 'src/components/tutorial/stepper.dart';
-import 'src/components/tutorial/summary_card.dart';
-import 'src/components/tutorial/tutorial_outline.dart';
-import 'src/components/util/component_ref.dart';
 import 'src/extensions/registry.dart';
 import 'src/layouts/doc_layout.dart';
 import 'src/layouts/toc_layout.dart';
 import 'src/layouts/tutorial_layout.dart';
 import 'src/loaders/data_processor.dart';
-import 'src/markdown/markdown_parser.dart';
 import 'src/pages/custom_pages.dart';
 import 'src/pages/robots_txt.dart';
 import 'src/templating/dash_template_engine.dart';
@@ -46,7 +46,7 @@ void main() {
   // Initializes the server environment with the generated default options.
   Jaspr.initializeApp(options: defaultServerOptions);
 
-  runApp(ComponentRefScope(child: _docsFlutterDevSite));
+  runApp(_docsFlutterDevSite);
 }
 
 Component get _docsFlutterDevSite => ContentApp.custom(
@@ -116,7 +116,9 @@ List get _embeddableComponents => [
   const Quiz(),
   const ProgressRing(),
   const SummaryCard(),
-  const DownloadableSnippet(),
+  DownloadableSnippet(
+    snippetsDirectoryPath: path.join(siteSrcDirectoryPath, '_snippets'),
+  ),
   const Stepper(),
   const WidgetCatalogCategories(),
   const TutorialOutline(),
@@ -124,36 +126,18 @@ List get _embeddableComponents => [
   const ArchitectureRecommendations(),
   const PlatformsGrid(),
   const PlatformCard(),
-  CustomComponent(
-    pattern: RegExp('Icon', caseSensitive: false),
-    builder: (_, attrs, _) => MaterialIcon.fromAttributes(attrs),
-  ),
-  CustomComponent(
-    pattern: RegExp('OSSelector', caseSensitive: false),
-    builder: (_, _, _) => const OsSelector(),
-  ),
-  CustomComponent(
-    pattern: RegExp('Card', caseSensitive: false),
-    builder: (_, attrs, child) => Card.fromAttributes(attrs, child),
-  ),
-  CustomComponent(
-    pattern: RegExp('LearningResourceIndex', caseSensitive: false),
-    builder: (_, _, _) => LearningResourceIndex(),
-  ),
-  CustomComponent(
-    pattern: RegExp('ArchiveTable'),
-    builder: (_, attrs, _) => ArchiveTable.fromAttributes(attrs),
-  ),
-  CustomComponent(
-    pattern: RegExp('DownloadLatestButton', caseSensitive: false),
-    builder: (_, attrs, _) => DownloadLatestButton.fromAttributes(attrs),
-  ),
-  CustomComponent(
-    pattern: RegExp('ExpansionList', caseSensitive: false),
-    builder: (_, attrs, _) => ExpansionList.fromAttributes(attrs),
+  defineComponentWithAttrs('Icon', MaterialIcon.fromAttributes),
+  defineComponent('OSSelector', const OsSelector()),
+  defineComponentWithChild('Card', Card.fromAttributes),
+  defineComponent('LearningResourceIndex', const LearningResourceIndex()),
+  defineComponentWithAttrs('ArchiveTable', ArchiveTable.fromAttributes),
+  defineComponentWithAttrs(
+    'DownloadLatestButton',
+    DownloadLatestButton.fromAttributes,
   ),
-  CustomComponent(
-    pattern: RegExp('DevToolsReleaseNotesIndex', caseSensitive: false),
-    builder: (_, _, _) => const DevToolsReleaseNotesIndex(),
+  defineComponentWithAttrs('ExpansionList', ExpansionList.fromAttributes),
+  defineComponent(
+    'DevToolsReleaseNotesIndex',
+    const DevToolsReleaseNotesIndex(),
   ),
 ];
diff --git a/site/lib/main.server.options.dart b/site/lib/main.server.options.dart
index 6262d37d117..70381901f68 100644
--- a/site/lib/main.server.options.dart
+++ b/site/lib/main.server.options.dart
@@ -5,36 +5,12 @@
 // Generated with jaspr_builder
 
 import 'package:jaspr/server.dart';
-import 'package:docs_flutter_dev_site/src/components/common/client/collapse_button.dart'
-    as _collapse_button;
-import 'package:docs_flutter_dev_site/src/components/common/client/cookie_notice.dart'
-    as _cookie_notice;
-import 'package:docs_flutter_dev_site/src/components/common/client/copy_button.dart'
-    as _copy_button;
-import 'package:docs_flutter_dev_site/src/components/common/client/download_button.dart'
-    as _download_button;
 import 'package:docs_flutter_dev_site/src/components/common/client/download_latest_button.dart'
     as _download_latest_button;
-import 'package:docs_flutter_dev_site/src/components/common/client/feedback.dart'
-    as _feedback;
-import 'package:docs_flutter_dev_site/src/components/common/client/on_this_page_button.dart'
-    as _on_this_page_button;
 import 'package:docs_flutter_dev_site/src/components/common/client/os_selector.dart'
     as _os_selector;
-import 'package:docs_flutter_dev_site/src/components/common/client/page_header_options.dart'
-    as _page_header_options;
-import 'package:docs_flutter_dev_site/src/components/common/client/simple_tooltip.dart'
-    as _simple_tooltip;
-import 'package:docs_flutter_dev_site/src/components/dartpad/dartpad_injector.dart'
-    as _dartpad_injector;
 import 'package:docs_flutter_dev_site/src/components/layout/client/pagenav.dart'
     as _pagenav;
-import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart'
-    as _menu_toggle;
-import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart'
-    as _site_switcher;
-import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart'
-    as _theme_switcher;
 import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart'
     as _archive_table;
 import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart'
@@ -43,9 +19,31 @@ import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_fil
     as _learning_resource_filters;
 import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart'
     as _learning_resource_filters_sidebar;
-import 'package:docs_flutter_dev_site/src/components/tutorial/client/quiz.dart'
-    as _quiz;
 import 'package:jaspr_content/components/file_tree.dart' as _file_tree;
+import 'package:site_shared/components/common/client/collapse_button.dart'
+    as _collapse_button;
+import 'package:site_shared/components/common/client/cookie_notice.dart'
+    as _cookie_notice;
+import 'package:site_shared/components/common/client/copy_button.dart'
+    as _copy_button;
+import 'package:site_shared/components/common/client/download_button.dart'
+    as _download_button;
+import 'package:site_shared/components/common/client/feedback.dart'
+    as _feedback;
+import 'package:site_shared/components/common/client/on_this_page_button.dart'
+    as _on_this_page_button;
+import 'package:site_shared/components/common/client/page_header_options.dart'
+    as _page_header_options;
+import 'package:site_shared/components/common/client/simple_tooltip.dart'
+    as _simple_tooltip;
+import 'package:site_shared/components/dartpad/dartpad_injector.dart'
+    as _dartpad_injector;
+import 'package:site_shared/components/layout/menu_toggle.dart' as _menu_toggle;
+import 'package:site_shared/components/layout/site_switcher.dart'
+    as _site_switcher;
+import 'package:site_shared/components/layout/theme_switcher.dart'
+    as _theme_switcher;
+import 'package:site_shared/components/tutorial/client/quiz.dart' as _quiz;
 
 /// Default [ServerOptions] for use with your Jaspr project.
 ///
@@ -66,104 +64,119 @@ import 'package:jaspr_content/components/file_tree.dart' as _file_tree;
 ServerOptions get defaultServerOptions => ServerOptions(
   clientId: 'main.client.dart.js',
   clients: {
+    _download_latest_button.DownloadLatestButton:
+        ClientTarget<_download_latest_button.DownloadLatestButton>(
+          'download_latest_button',
+          params: __download_latest_buttonDownloadLatestButton,
+        ),
+    _os_selector.OsSelector: ClientTarget<_os_selector.OsSelector>(
+      'os_selector',
+    ),
+    _pagenav.PageNav: ClientTarget<_pagenav.PageNav>(
+      'pagenav',
+      params: __pagenavPageNav,
+    ),
+    _archive_table.ArchiveTable: ClientTarget<_archive_table.ArchiveTable>(
+      'archive_table',
+      params: __archive_tableArchiveTable,
+    ),
+    _glossary_search_section.GlossarySearchSection:
+        ClientTarget<_glossary_search_section.GlossarySearchSection>(
+          'glossary_search_section',
+        ),
+    _learning_resource_filters.LearningResourceFilters:
+        ClientTarget<_learning_resource_filters.LearningResourceFilters>(
+          'learning_resource_filters',
+        ),
+    _learning_resource_filters_sidebar.LearningResourceFiltersSidebar:
+        ClientTarget<
+          _learning_resource_filters_sidebar.LearningResourceFiltersSidebar
+        >('learning_resource_filters_sidebar'),
     _collapse_button.CollapseButton:
         ClientTarget<_collapse_button.CollapseButton>(
-          'collapse_button',
+          'site_shared:collapse_button',
           params: __collapse_buttonCollapseButton,
         ),
     _cookie_notice.CookieNotice: ClientTarget<_cookie_notice.CookieNotice>(
-      'cookie_notice',
+      'site_shared:cookie_notice',
+      params: __cookie_noticeCookieNotice,
     ),
     _copy_button.CopyButton: ClientTarget<_copy_button.CopyButton>(
-      'copy_button',
+      'site_shared:copy_button',
       params: __copy_buttonCopyButton,
     ),
     _download_button.DownloadButton:
         ClientTarget<_download_button.DownloadButton>(
-          'download_button',
+          'site_shared:download_button',
           params: __download_buttonDownloadButton,
         ),
-    _download_latest_button.DownloadLatestButton:
-        ClientTarget<_download_latest_button.DownloadLatestButton>(
-          'download_latest_button',
-          params: __download_latest_buttonDownloadLatestButton,
-        ),
     _feedback.FeedbackComponent: ClientTarget<_feedback.FeedbackComponent>(
-      'feedback',
+      'site_shared:feedback',
       params: __feedbackFeedbackComponent,
     ),
     _on_this_page_button.OnThisPageButton:
         ClientTarget<_on_this_page_button.OnThisPageButton>(
-          'on_this_page_button',
+          'site_shared:on_this_page_button',
         ),
-    _os_selector.OsSelector: ClientTarget<_os_selector.OsSelector>(
-      'os_selector',
-    ),
     _page_header_options.PageHeaderOptions:
         ClientTarget<_page_header_options.PageHeaderOptions>(
-          'page_header_options',
+          'site_shared:page_header_options',
           params: __page_header_optionsPageHeaderOptions,
         ),
     _simple_tooltip.SimpleTooltip: ClientTarget<_simple_tooltip.SimpleTooltip>(
-      'simple_tooltip',
+      'site_shared:simple_tooltip',
       params: __simple_tooltipSimpleTooltip,
     ),
     _dartpad_injector.DartPadInjector:
         ClientTarget<_dartpad_injector.DartPadInjector>(
-          'dartpad_injector',
+          'site_shared:dartpad_injector',
           params: __dartpad_injectorDartPadInjector,
         ),
-    _pagenav.PageNav: ClientTarget<_pagenav.PageNav>(
-      'pagenav',
-      params: __pagenavPageNav,
-    ),
     _menu_toggle.MenuToggle: ClientTarget<_menu_toggle.MenuToggle>(
-      'menu_toggle',
+      'site_shared:menu_toggle',
     ),
     _site_switcher.SiteSwitcher: ClientTarget<_site_switcher.SiteSwitcher>(
-      'site_switcher',
+      'site_shared:site_switcher',
+      params: __site_switcherSiteSwitcher,
     ),
     _theme_switcher.ThemeSwitcher: ClientTarget<_theme_switcher.ThemeSwitcher>(
-      'theme_switcher',
-    ),
-    _archive_table.ArchiveTable: ClientTarget<_archive_table.ArchiveTable>(
-      'archive_table',
-      params: __archive_tableArchiveTable,
+      'site_shared:theme_switcher',
     ),
-    _glossary_search_section.GlossarySearchSection:
-        ClientTarget<_glossary_search_section.GlossarySearchSection>(
-          'glossary_search_section',
-        ),
-    _learning_resource_filters.LearningResourceFilters:
-        ClientTarget<_learning_resource_filters.LearningResourceFilters>(
-          'learning_resource_filters',
-        ),
-    _learning_resource_filters_sidebar.LearningResourceFiltersSidebar:
-        ClientTarget<
-          _learning_resource_filters_sidebar.LearningResourceFiltersSidebar
-        >('learning_resource_filters_sidebar'),
     _quiz.InteractiveQuiz: ClientTarget<_quiz.InteractiveQuiz>(
-      'quiz',
+      'site_shared:quiz',
       params: __quizInteractiveQuiz,
     ),
   },
   styles: () => [..._file_tree.FileTree.styles],
 );
 
+Map __download_latest_buttonDownloadLatestButton(
+  _download_latest_button.DownloadLatestButton c,
+) => {'os': c.os, 'arch': c.arch};
+Map __pagenavPageNav(_pagenav.PageNav c) => {
+  'breadcrumbs': c.breadcrumbs,
+  'pageNumber': c.pageNumber,
+  'initialHeading': c.initialHeading,
+  'content': c.content.toId(),
+};
+Map __archive_tableArchiveTable(
+  _archive_table.ArchiveTable c,
+) => {'os': c.os, 'channel': c.channel};
 Map __collapse_buttonCollapseButton(
   _collapse_button.CollapseButton c,
 ) => {'classes': c.classes, 'title': c.title};
+Map __cookie_noticeCookieNotice(
+  _cookie_notice.CookieNotice c,
+) => {'host': c.host, 'alwaysDarkMode': c.alwaysDarkMode};
 Map __copy_buttonCopyButton(_copy_button.CopyButton c) => {
   'buttonText': c.buttonText,
+  'toCopy': c.toCopy,
   'classes': c.classes,
   'title': c.title,
 };
 Map __download_buttonDownloadButton(
   _download_button.DownloadButton c,
 ) => {'name': c.name};
-Map __download_latest_buttonDownloadLatestButton(
-  _download_latest_button.DownloadLatestButton c,
-) => {'os': c.os, 'arch': c.arch};
 Map __feedbackFeedbackComponent(
   _feedback.FeedbackComponent c,
 ) => {'issueUrl': c.issueUrl};
@@ -181,15 +194,9 @@ Map __dartpad_injectorDartPadInjector(
   'height': c.height,
   'runAutomatically': c.runAutomatically,
 };
-Map __pagenavPageNav(_pagenav.PageNav c) => {
-  'breadcrumbs': c.breadcrumbs,
-  'pageNumber': c.pageNumber,
-  'initialHeading': c.initialHeading,
-  'content': c.content.toId(),
-};
-Map __archive_tableArchiveTable(
-  _archive_table.ArchiveTable c,
-) => {'os': c.os, 'channel': c.channel};
+Map __site_switcherSiteSwitcher(
+  _site_switcher.SiteSwitcher c,
+) => {'isFlutter': c.isFlutter};
 Map __quizInteractiveQuiz(_quiz.InteractiveQuiz c) => {
   'title': c.title,
   'questions': c.questions.map((i) => i.toJson()).toList(),
diff --git a/site/lib/src/analytics/analytics_web.dart b/site/lib/src/analytics/analytics_web.dart
index 568e56e3c9c..81993694d24 100644
--- a/site/lib/src/analytics/analytics_web.dart
+++ b/site/lib/src/analytics/analytics_web.dart
@@ -3,10 +3,10 @@
 // found in the LICENSE file.
 
 import 'package:meta/meta.dart';
+import 'package:site_shared/util.dart';
 import 'package:universal_web/js_interop.dart';
 import 'package:universal_web/web.dart' as web;
 
-import '../util.dart';
 import 'analytics.dart';
 
 /// Web implementation of [Analytics].
diff --git a/site/lib/src/client/global_scripts.dart b/site/lib/src/client/global_scripts.dart
index fe15df697be..ca987770066 100644
--- a/site/lib/src/client/global_scripts.dart
+++ b/site/lib/src/client/global_scripts.dart
@@ -3,11 +3,10 @@
 // found in the LICENSE file.
 
 import 'package:jaspr/jaspr.dart';
+import 'package:site_shared/util.dart';
 import 'package:universal_web/js_interop.dart';
 import 'package:universal_web/web.dart' as web;
 
-import '../util.dart';
-
 /// Global scripts converted from JS.
 ///
 /// These are temporary until they can be integrated with their
diff --git a/site/lib/src/components/common/client/os_selector.dart b/site/lib/src/components/common/client/os_selector.dart
index e5e6fb8c1d1..1c5420a6130 100644
--- a/site/lib/src/components/common/client/os_selector.dart
+++ b/site/lib/src/components/common/client/os_selector.dart
@@ -4,10 +4,9 @@
 
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
+import 'package:site_shared/util.dart';
 import 'package:universal_web/web.dart' as web;
 
-import '../../../util.dart';
-
 @client
 class OsSelector extends StatefulComponent {
   const OsSelector({super.key});
diff --git a/site/lib/src/components/common/code_preview.dart b/site/lib/src/components/common/code_preview.dart
index c4adcda0328..e3cdd26570a 100644
--- a/site/lib/src/components/common/code_preview.dart
+++ b/site/lib/src/components/common/code_preview.dart
@@ -5,9 +5,8 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
-
-import '../../util.dart';
-import 'wrapped_code_block.dart';
+import 'package:site_shared/components/common/wrapped_code_block.dart';
+import 'package:site_shared/util.dart';
 
 /// A component that displays a preview area alongside a code block.
 ///
diff --git a/site/lib/src/components/common/dash_image.dart b/site/lib/src/components/common/dash_image.dart
index f39aa586326..8dde006c617 100644
--- a/site/lib/src/components/common/dash_image.dart
+++ b/site/lib/src/components/common/dash_image.dart
@@ -5,8 +5,7 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
-
-import '../../markdown/markdown_parser.dart';
+import 'package:site_shared/markdown.dart';
 
 class DashImage with CustomComponentBase {
   const DashImage();
diff --git a/site/lib/src/components/common/page_header.dart b/site/lib/src/components/common/page_header.dart
index 74178855586..257bc63ff14 100644
--- a/site/lib/src/components/common/page_header.dart
+++ b/site/lib/src/components/common/page_header.dart
@@ -5,12 +5,12 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/components/common/breadcrumbs.dart';
+import 'package:site_shared/components/common/client/page_header_options.dart';
+import 'package:site_shared/markdown.dart';
+import 'package:site_shared/util.dart';
 
-import '../../markdown/markdown_parser.dart';
-import '../../util.dart';
 import '../../utils/page_source_info.dart';
-import 'breadcrumbs.dart';
-import 'client/page_header_options.dart';
 
 final class PageHeader extends StatelessComponent {
   const PageHeader({
diff --git a/site/lib/src/components/common/prev_next.dart b/site/lib/src/components/common/prev_next.dart
index 5750e48a7d0..7104c7ca418 100644
--- a/site/lib/src/components/common/prev_next.dart
+++ b/site/lib/src/components/common/prev_next.dart
@@ -4,10 +4,10 @@
 
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
+import 'package:site_shared/components/common/material_icon.dart';
+import 'package:site_shared/markdown.dart';
 
-import '../../markdown/markdown_parser.dart';
 import '../../models/page_navigation_model.dart';
-import 'material_icon.dart';
 
 /// Previous and next page buttons to display at the end of a page
 /// in a connected series of pages, such as the language docs.
diff --git a/site/lib/src/components/layout/banner.dart b/site/lib/src/components/layout/banner.dart
deleted file mode 100644
index 7bce79cae1f..00000000000
--- a/site/lib/src/components/layout/banner.dart
+++ /dev/null
@@ -1,28 +0,0 @@
-// 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 site-wide banner.
-class DashBanner extends StatelessComponent {
-  const DashBanner(this.inlineHtmlContent, {super.key});
-
-  /// The raw, inline HTML content to render in the banner.
-  ///
-  /// This should only be sourced from managed content,
-  /// such as our checked-in data files.
-  final String inlineHtmlContent;
-
-  @override
-  Component build(BuildContext context) => div(
-    id: 'site-banner',
-    attributes: {'role': 'alert'},
-    [
-      p([
-        RawText(inlineHtmlContent),
-      ]),
-    ],
-  );
-}
diff --git a/site/lib/src/components/layout/client/pagenav.dart b/site/lib/src/components/layout/client/pagenav.dart
index 49ce5b72307..5e770106a13 100644
--- a/site/lib/src/components/layout/client/pagenav.dart
+++ b/site/lib/src/components/layout/client/pagenav.dart
@@ -4,14 +4,14 @@
 
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
+import 'package:site_shared/components/common/dropdown.dart';
+import 'package:site_shared/components/common/material_icon.dart';
+import 'package:site_shared/components/utils/component_ref.dart';
+import 'package:site_shared/util.dart';
 import 'package:universal_web/js_interop.dart';
 import 'package:universal_web/web.dart' as web;
 
 import '../../../client/global_scripts.dart';
-import '../../../util.dart';
-import '../../common/dropdown.dart';
-import '../../common/material_icon.dart';
-import '../../util/component_ref.dart';
 
 @client
 class PageNav extends StatefulComponent {
@@ -121,7 +121,7 @@ class _PageNavState extends State {
           ]),
         ],
       ),
-      content: component.content.component,
+      content: component.content,
     );
   }
 
diff --git a/site/lib/src/components/layout/header.dart b/site/lib/src/components/layout/header.dart
index 82bb08d26cf..dbe464d28e5 100644
--- a/site/lib/src/components/layout/header.dart
+++ b/site/lib/src/components/layout/header.dart
@@ -5,14 +5,14 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/components/common/button.dart';
+import 'package:site_shared/components/common/material_icon.dart';
+import 'package:site_shared/components/layout/menu_toggle.dart';
+import 'package:site_shared/components/layout/site_switcher.dart';
+import 'package:site_shared/components/layout/theme_switcher.dart';
+import 'package:site_shared/util.dart';
 
-import '../../util.dart';
 import '../../utils/active_nav.dart';
-import '../common/button.dart';
-import '../common/material_icon.dart';
-import 'menu_toggle.dart';
-import 'site_switcher.dart';
-import 'theme_switcher.dart';
 
 /// The site-wide top navigation bar.
 class DashHeader extends StatelessComponent {
diff --git a/site/lib/src/components/layout/sidenav.dart b/site/lib/src/components/layout/sidenav.dart
index 6edad3663cd..a9098a51d84 100644
--- a/site/lib/src/components/layout/sidenav.dart
+++ b/site/lib/src/components/layout/sidenav.dart
@@ -5,11 +5,11 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/components/common/material_icon.dart';
+import 'package:site_shared/util.dart';
 
 import '../../models/sidenav_model.dart';
-import '../../util.dart';
 import '../../utils/active_nav.dart';
-import '../common/material_icon.dart';
 
 /// The site-wide side navigation menu,
 /// with entries loaded from the `src/data/sidenav.yml` file.
diff --git a/site/lib/src/components/layout/toc.dart b/site/lib/src/components/layout/toc.dart
index 07362c1b6c6..0cd4075f9d7 100644
--- a/site/lib/src/components/layout/toc.dart
+++ b/site/lib/src/components/layout/toc.dart
@@ -5,13 +5,13 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/components/common/client/on_this_page_button.dart';
+import 'package:site_shared/components/common/material_icon.dart';
+import 'package:site_shared/components/utils/component_ref.dart';
+import 'package:site_shared/markdown.dart';
+import 'package:site_shared/util.dart';
 
-import '../../markdown/markdown_parser.dart';
 import '../../models/page_navigation_model.dart';
-import '../../util.dart';
-import '../common/client/on_this_page_button.dart';
-import '../common/material_icon.dart';
-import '../util/component_ref.dart';
 import 'client/pagenav.dart';
 
 final class DashTableOfContents extends StatelessComponent {
@@ -68,7 +68,7 @@ final class PageNavBar extends StatelessComponent {
       ],
       pageNumber: linkedPageTitle != null ? currentLinkedPageNumber : null,
       initialHeading: currentTitle,
-      content: context.ref(
+      content: ref(
         div([
           if (data.pageEntries.isEmpty) ...[
             a(
diff --git a/site/lib/src/components/layout/trailing_content.dart b/site/lib/src/components/layout/trailing_content.dart
index 39ec4ac558b..2efcfc836a8 100644
--- a/site/lib/src/components/layout/trailing_content.dart
+++ b/site/lib/src/components/layout/trailing_content.dart
@@ -5,9 +5,9 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/components/common/client/feedback.dart';
 
 import '../../utils/page_source_info.dart';
-import '../common/client/feedback.dart';
 
 /// The trailing content of a content documentation page, such as
 /// its last updated information, report an issue links, and similar.
diff --git a/site/lib/src/components/pages/architecture_recommendations.dart b/site/lib/src/components/pages/architecture_recommendations.dart
index 5477bbc4a11..7ea371b7176 100644
--- a/site/lib/src/components/pages/architecture_recommendations.dart
+++ b/site/lib/src/components/pages/architecture_recommendations.dart
@@ -5,8 +5,7 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
-
-import '../../markdown/markdown_parser.dart';
+import 'package:site_shared/markdown.dart';
 
 class ArchitectureRecommendations extends CustomComponentBase {
   const ArchitectureRecommendations();
diff --git a/site/lib/src/components/pages/expansion_list.dart b/site/lib/src/components/pages/expansion_list.dart
index 25aed291081..e46870ebaf7 100644
--- a/site/lib/src/components/pages/expansion_list.dart
+++ b/site/lib/src/components/pages/expansion_list.dart
@@ -7,9 +7,8 @@ import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
 import 'package:path/path.dart' as path;
-
-import '../../markdown/markdown_parser.dart';
-import '../../util.dart';
+import 'package:site_shared/markdown.dart';
+import 'package:site_shared/util.dart';
 
 class ExpansionListItem {
   ExpansionListItem({
diff --git a/site/lib/src/components/pages/glossary_search_section.dart b/site/lib/src/components/pages/glossary_search_section.dart
index 440746f834e..07efcee01cf 100644
--- a/site/lib/src/components/pages/glossary_search_section.dart
+++ b/site/lib/src/components/pages/glossary_search_section.dart
@@ -4,10 +4,9 @@
 
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
+import 'package:site_shared/components/common/search.dart';
 import 'package:universal_web/web.dart' as web;
 
-import '../common/search.dart';
-
 @client
 class GlossarySearchSection extends StatefulComponent {
   const GlossarySearchSection({super.key});
diff --git a/site/lib/src/components/pages/learning_resource_filters.dart b/site/lib/src/components/pages/learning_resource_filters.dart
index 870780c44b6..76d1268aeda 100644
--- a/site/lib/src/components/pages/learning_resource_filters.dart
+++ b/site/lib/src/components/pages/learning_resource_filters.dart
@@ -6,14 +6,14 @@ import 'dart:math';
 
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
+import 'package:site_shared/components/common/material_icon.dart';
+import 'package:site_shared/components/common/search.dart';
+import 'package:site_shared/components/utils/global_event_listener.dart';
 import 'package:universal_web/js_interop.dart';
 import 'package:universal_web/web.dart' as web;
 
 import '../../analytics/analytics.dart';
 import '../../models/learning_resource_model.dart';
-import '../common/material_icon.dart';
-import '../common/search.dart';
-import '../util/global_event_listener.dart';
 import 'learning_resource_filters_sidebar.dart';
 
 @client
diff --git a/site/lib/src/components/pages/learning_resource_filters_sidebar.dart b/site/lib/src/components/pages/learning_resource_filters_sidebar.dart
index c918d05692a..ea58b477711 100644
--- a/site/lib/src/components/pages/learning_resource_filters_sidebar.dart
+++ b/site/lib/src/components/pages/learning_resource_filters_sidebar.dart
@@ -4,11 +4,11 @@
 
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
+import 'package:site_shared/components/common/material_icon.dart';
+import 'package:site_shared/util.dart';
 
 import '../../analytics/analytics.dart';
 import '../../models/learning_resource_model.dart';
-import '../../util.dart';
-import '../common/material_icon.dart';
 import 'learning_resource_filters.dart';
 
 @client
diff --git a/site/lib/src/components/pages/learning_resource_index.dart b/site/lib/src/components/pages/learning_resource_index.dart
index 139d075f92e..9293ad60f63 100644
--- a/site/lib/src/components/pages/learning_resource_index.dart
+++ b/site/lib/src/components/pages/learning_resource_index.dart
@@ -5,14 +5,14 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/util.dart';
 
 import '../../models/learning_resource_model.dart';
-import '../../util.dart';
 import 'learning_resource_filters.dart';
 import 'learning_resource_filters_sidebar.dart';
 
 final class LearningResourceIndex extends StatelessComponent {
-  LearningResourceIndex({super.key});
+  const LearningResourceIndex({super.key});
 
   @override
   Component build(BuildContext context) {
diff --git a/site/lib/src/components/pages/platforms_grid.dart b/site/lib/src/components/pages/platforms_grid.dart
index 66793e7e81f..d41f065bd38 100644
--- a/site/lib/src/components/pages/platforms_grid.dart
+++ b/site/lib/src/components/pages/platforms_grid.dart
@@ -5,10 +5,9 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
-
-import '../../markdown/markdown_parser.dart';
-import '../common/button.dart';
-import '../common/material_icon.dart';
+import 'package:site_shared/components/common/button.dart';
+import 'package:site_shared/components/common/material_icon.dart';
+import 'package:site_shared/markdown.dart';
 
 class PlatformsGrid extends CustomComponentBase {
   const PlatformsGrid();
diff --git a/site/lib/src/components/pages/widget_catalog.dart b/site/lib/src/components/pages/widget_catalog.dart
index 61e968225b3..fb744f58401 100644
--- a/site/lib/src/components/pages/widget_catalog.dart
+++ b/site/lib/src/components/pages/widget_catalog.dart
@@ -6,10 +6,10 @@ import 'package:collection/collection.dart';
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/markdown.dart';
+import 'package:site_shared/util.dart';
 
-import '../../markdown/markdown_parser.dart';
 import '../../models/widget_catalog_model.dart';
-import '../../util.dart';
 
 class WidgetCatalogCategories extends CustomComponentBase {
   const WidgetCatalogCategories();
diff --git a/site/lib/src/components/tutorial/quiz.dart b/site/lib/src/components/tutorial/quiz.dart
deleted file mode 100644
index d552df4f8c3..00000000000
--- a/site/lib/src/components/tutorial/quiz.dart
+++ /dev/null
@@ -1,38 +0,0 @@
-// 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 '../../models/quiz_model.dart';
-import 'client/quiz.dart';
-
-class Quiz extends CustomComponent {
-  const Quiz() : super.base();
-
-  @override
-  Component? create(Node node, NodesBuilder builder) {
-    if (node is ElementNode && node.tag.toLowerCase() == 'quiz') {
-      if (node.children?.whereType().isNotEmpty ?? false) {
-        throw Exception(
-          'Invalid Quiz content. Remove any leading empty lines to '
-          'avoid parsing as markdown.',
-        );
-      }
-
-      final title = node.attributes['title'];
-
-      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);
-    }
-    return null;
-  }
-}
diff --git a/site/lib/src/components/util/component_ref.dart b/site/lib/src/components/util/component_ref.dart
deleted file mode 100644
index f4a0c499d03..00000000000
--- a/site/lib/src/components/util/component_ref.dart
+++ /dev/null
@@ -1,102 +0,0 @@
-// 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:nanoid2/nanoid2.dart';
-
-import '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 {
-  const ComponentRef._(this.id);
-
-  final String id;
-
-  Component get component {
-    return Builder(
-      builder: (context) {
-        if (!kIsWeb) {
-          final scope =
-              context
-                      .getElementForInheritedComponentOfExactType<
-                        ComponentRefScope
-                      >()
-                  as _ComponentRefScopeElement?;
-          return Component.wrapElement(
-            id: id,
-            child: scope!.getComponentById(id),
-          );
-        } else {
-          final elem = retakeElement(context, (elem) => elem.id == id);
-          assert(elem != null, 'Element with id "$id" not found');
-          return wrapNode(elem!);
-        }
-      },
-    );
-  }
-
-  @decoder
-  factory ComponentRef.fromId(String id) {
-    return ComponentRef._(id);
-  }
-
-  @encoder
-  String toId() => id;
-}
-
-extension ComponentRefExtension on BuildContext {
-  /// Wraps a [Component] in a [ComponentRef] for use in @client components.
-  ComponentRef ref(Component child) {
-    final scope =
-        getElementForInheritedComponentOfExactType()
-            as _ComponentRefScopeElement?;
-    assert(scope != null, 'No ComponentRefScope found in context');
-    final ref = scope!.register(child);
-    return ref;
-  }
-}
-
-/// A scope for registering and retrieving component references.
-///
-/// This should wrap your entire app, typically in `main.dart`.
-class ComponentRefScope extends InheritedComponent {
-  const ComponentRefScope({
-    required super.child,
-  });
-
-  @override
-  bool updateShouldNotify(ComponentRefScope oldComponent) {
-    return false;
-  }
-
-  @override
-  InheritedElement createElement() => _ComponentRefScopeElement(this);
-}
-
-class _ComponentRefScopeElement extends InheritedElement {
-  _ComponentRefScopeElement(ComponentRefScope super.component);
-
-  final Map _registeredComponents = {};
-
-  Component getComponentById(String id) {
-    final component = _registeredComponents[id];
-    assert(component != null, 'No component registered with id "$id"');
-    return component!;
-  }
-
-  ComponentRef register(Component child) {
-    final id = 'ref-${nanoid(length: 8)}';
-    _registeredComponents[id] = child;
-    return ComponentRef._(id);
-  }
-}
diff --git a/site/lib/src/components/util/retake_element_vm.dart b/site/lib/src/components/util/retake_element_vm.dart
deleted file mode 100644
index 775262eedd1..00000000000
--- a/site/lib/src/components/util/retake_element_vm.dart
+++ /dev/null
@@ -1,17 +0,0 @@
-// 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:universal_web/web.dart' as web;
-
-web.Element? retakeElement(
-  BuildContext context,
-  bool Function(web.Element element) predicate,
-) {
-  throw UnimplementedError();
-}
-
-Component wrapNode(web.Node node) {
-  throw UnimplementedError();
-}
diff --git a/site/lib/src/extensions/glossary_link_processor.dart b/site/lib/src/extensions/glossary_link_processor.dart
index f1602547136..af86e036278 100644
--- a/site/lib/src/extensions/glossary_link_processor.dart
+++ b/site/lib/src/extensions/glossary_link_processor.dart
@@ -5,9 +5,9 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/components/common/client/simple_tooltip.dart';
+import 'package:site_shared/components/utils/component_ref.dart';
 
-import '../components/common/client/simple_tooltip.dart';
-import '../components/util/component_ref.dart';
 import '../pages/glossary.dart';
 
 /// A node-processing, page extension for Jaspr Content that looks for links to
@@ -52,8 +52,8 @@ class GlossaryLinkProcessor implements PageExtension {
             Builder(
               builder: (context) {
                 return SimpleTooltip(
-                  target: context.ref(target),
-                  content: context.ref(content),
+                  target: ref(target),
+                  content: ref(content),
                 );
               },
             ),
diff --git a/site/lib/src/extensions/registry.dart b/site/lib/src/extensions/registry.dart
index a2690a6a188..87644847e9c 100644
--- a/site/lib/src/extensions/registry.dart
+++ b/site/lib/src/extensions/registry.dart
@@ -3,13 +3,13 @@
 // found in the LICENSE file.
 
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/extensions/attribute_processor.dart';
+import 'package:site_shared/extensions/code_block_processor.dart';
+import 'package:site_shared/extensions/header_extractor.dart';
+import 'package:site_shared/extensions/header_processor.dart';
+import 'package:site_shared/extensions/table_processor.dart';
 
-import 'attribute_processor.dart';
-import 'code_block_processor.dart';
 import 'glossary_link_processor.dart';
-import 'header_extractor.dart';
-import 'header_processor.dart';
-import 'table_processor.dart';
 import 'tutorial_navigation.dart';
 import 'tutorial_structure_processor.dart';
 
@@ -20,7 +20,7 @@ const List allNodeProcessingExtensions = [
   HeaderExtractorExtension(),
   HeaderWrapperExtension(),
   TableWrapperExtension(),
-  CodeBlockProcessor(),
+  CodeBlockProcessor(defaultTitle: 'Runnable Flutter example'),
   GlossaryLinkProcessor(),
   TutorialNavigationExtension(),
   TutorialStructureExtension(),
diff --git a/site/lib/src/extensions/tutorial_navigation.dart b/site/lib/src/extensions/tutorial_navigation.dart
index ed5aed6d3aa..0d47175269e 100644
--- a/site/lib/src/extensions/tutorial_navigation.dart
+++ b/site/lib/src/extensions/tutorial_navigation.dart
@@ -3,8 +3,7 @@
 // found in the LICENSE file.
 
 import 'package:jaspr_content/jaspr_content.dart';
-
-import '../models/tutorial_model.dart';
+import 'package:site_shared/components/tutorial/models/tutorial_model.dart';
 
 /// A page extension for Jaspr Content that adds
 /// page navigation to the current tutorial page.
diff --git a/site/lib/src/layouts/dash_layout.dart b/site/lib/src/layouts/dash_layout.dart
deleted file mode 100644
index 56ee4244ad5..00000000000
--- a/site/lib/src/layouts/dash_layout.dart
+++ /dev/null
@@ -1,341 +0,0 @@
-// 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 'dart:convert';
-
-import 'package:jaspr/dom.dart';
-import 'package:jaspr/jaspr.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 '../util.dart';
-
-/// The base Jaspr Content layout for wrapping site content.
-abstract class FlutterDocsLayout extends PageLayoutBase {
-  const FlutterDocsLayout();
-
-  @override
-  String get name;
-
-  List get defaultBodyClasses => [];
-
-  String get defaultSidenav => 'default';
-
-  /// Returns page-specific URLs to eagerly speculate on, in addition to
-  /// the document-level rules that match all internal links.
-  ///
-  /// Override in subclasses to provide page-specific URLs for
-  /// eager prerendering and prefetching.
-  ({Set prerender, Set prefetch}) speculationUrls(Page page) =>
-      const (prerender: {}, prefetch: {});
-
-  @override
-  @mustCallSuper
-  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? ?? '';
-
-    return [
-      ...super.buildHead(page),
-      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)
-        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),
-      meta(
-        name: 'twitter:description',
-        content: pageDescription,
-      ),
-
-      meta(attributes: {'property': 'og:title', 'content': pageTitle}),
-      meta(
-        attributes: {
-          'property': 'og:description',
-          'content': pageDescription,
-        },
-      ),
-      meta(attributes: {'property': 'og:url', 'content': page.path}),
-      const meta(
-        attributes: {
-          'property': 'og:image',
-          'content': '/assets/images/flutter-logo-sharing.png',
-        },
-      ),
-
-      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(
-        rel: 'stylesheet',
-        href: '/assets/css/main.css?hash=$generatedStylesHash',
-      ),
-
-      if (pageData['js'] case final List jsList)
-        for (final js in jsList)
-          if (js case {'url': final String jsUrl, 'defer': final Object? defer})
-            script(
-              src: jsUrl,
-              attributes: {if (defer == 'true' || defer == true) 'defer': ''},
-            ),
-      const script(
-        src:
-            'https://cdn.jsdelivr.net/npm/@justinribeiro/lite-youtube@1.8.2/lite-youtube.js',
-        attributes: {
-          'type': 'module',
-          'integrity': 'sha256-Jy0j0fUMJ2T3WxSEs2WjHLrS+3DlO7S9DItQtP55FII=',
-          'crossorigin': 'anonymous',
-          'referrerpolicy': 'no-referrer',
-        },
-      ),
-
-      // 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),
-    ];
-  }
-
-  @override
-  Component buildBody(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 pageSidenavRaw = pageData['sidenav'];
-    final pageSidenav = pageSidenavRaw is String
-        ? pageSidenavRaw
-        : defaultSidenav;
-    final sideNavEntries = switch (page.data['sidenav']) {
-      _ when pageSidenav == 'ai' => switch (page.data['ai']) {
-        final List sidenavData => navEntriesFromData(sidenavData),
-        _ => null,
-      },
-      final List sidenavData => navEntriesFromData(sidenavData),
-      _ => null,
-    };
-    final obsolete = pageData['obsolete'] == true;
-
-    return .fragment(
-      [
-        const Document.html(
-          attributes: {
-            'lang': 'en',
-            'dir': 'ltr',
-          },
-        ),
-        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', [
-            if (sideNavEntries != null)
-              DashSideNav(
-                navEntries: sideNavEntries,
-                currentPageUrl: pageUrl,
-              ),
-            main_(
-              id: 'page-content',
-              classes: [
-                if (pageData['focusedLayout'] == true) 'focused',
-              ].toClasses,
-              [child],
-            ),
-            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('''
-
-      '''),
-      ],
-    );
-  }
-
-  /// Builds the speculation rules `'),
-      // Fall back to prefetch link tags for browsers without
-      // Speculation Rules API support.
-      for (final url in {...prerender, ...prefetch})
-        link(rel: 'prefetch', href: url),
-    ];
-  }
-}
diff --git a/site/lib/src/layouts/doc_layout.dart b/site/lib/src/layouts/doc_layout.dart
index 35974839360..15d1821e9fd 100644
--- a/site/lib/src/layouts/doc_layout.dart
+++ b/site/lib/src/layouts/doc_layout.dart
@@ -8,11 +8,10 @@ import 'package:jaspr_content/jaspr_content.dart';
 
 import '../components/common/page_header.dart';
 import '../components/common/prev_next.dart';
-import '../components/layout/banner.dart';
 import '../components/layout/toc.dart';
 import '../components/layout/trailing_content.dart';
 import '../models/page_navigation_model.dart';
-import 'dash_layout.dart';
+import 'flutter_layout.dart';
 
 /// The Jaspr Content layout to use for normal docs pages,
 /// adding elements such as breadcrumbs, TOC, and prev/next cards.
@@ -48,14 +47,9 @@ class DocLayout extends FlutterDocsLayout {
   @override
   Component buildBody(Page page, Component child) {
     final pageData = page.data.page;
-    final siteData = page.data.site;
 
     final pageTitle = pageData['title'] as String;
     final pageDescription = (pageData['description'] as String?)?.trim();
-    final showBanner =
-        (pageData['showBanner'] as bool?) ??
-        (siteData['showBanner'] as bool?) ??
-        false;
     final navigationData = page.navigationData;
 
     return super.buildBody(
@@ -75,10 +69,7 @@ class DocLayout extends FlutterDocsLayout {
                 PageNavBar(navigationData),
               ],
             ),
-          if (showBanner)
-            if (siteData['bannerHtml'] case final String bannerHtml
-                when bannerHtml.trim().isNotEmpty)
-              DashBanner(bannerHtml),
+          ?buildBanner(page),
           div(classes: 'after-leading-content', [
             if (navigationData case PageNavigationData(
               toc: final toc?,
@@ -97,7 +88,6 @@ class DocLayout extends FlutterDocsLayout {
               ),
 
               child,
-
               PrevNext(
                 previousPage: PageNavigationEntry.fromData(pageData['prev']),
                 nextPage: PageNavigationEntry.fromData(pageData['next']),
diff --git a/site/lib/src/layouts/flutter_layout.dart b/site/lib/src/layouts/flutter_layout.dart
new file mode 100644
index 00000000000..c0e2b4d5bc1
--- /dev/null
+++ b/site/lib/src/layouts/flutter_layout.dart
@@ -0,0 +1,119 @@
+// 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:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/layouts/dash_layout.dart';
+import 'package:site_shared/util.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';
+
+/// The base Jaspr Content layout for wrapping site content.
+abstract class FlutterDocsLayout extends DashLayout {
+  const FlutterDocsLayout();
+
+  @override
+  String get name;
+
+  @override
+  String get siteHost => 'docs.flutter.dev';
+  @override
+  String get iconUrl => '/assets/images/branding/flutter/icon/64.png';
+  @override
+  String get iconUrlApple =>
+      '/assets/images/branding/flutter/logo/flutter-logomark-320px.png';
+  @override
+  String get twitterSiteTag => '@flutterdev';
+  @override
+  String get twitterDefaultImageUrl =>
+      '/assets/images/flutter-logo-sharing.png';
+
+  @override
+  String get tagManagerId => 'GTM-ND4LWWZ';
+  @override
+  String get analyticsId => 'UA-67589403-1';
+
+  @override
+  String get stylesHash => generatedStylesHash;
+
+  @override
+  Iterable buildExtraHead(Page page) {
+    return const [
+      if (productionBuild)
+        meta(
+          name: 'google-site-verification',
+          content: 'HFqxhSbf9YA_0rBglNLzDiWnrHiK_w4cqDh2YD2GEY4',
+        ),
+    ];
+  }
+
+  @override
+  Component buildBody(Page page, Component child) {
+    final pageData = page.data.page;
+    final pageUrl = page.url.startsWith('/') ? page.url : '/${page.url}';
+
+    final pageSidenavRaw = pageData['sidenav'];
+    final pageSidenav = pageSidenavRaw is String
+        ? pageSidenavRaw
+        : defaultSidenav;
+    final sideNavEntries = switch (page.data['sidenav']) {
+      _ when pageSidenav == 'ai' => switch (page.data['ai']) {
+        final List sidenavData => navEntriesFromData(sidenavData),
+        _ => null,
+      },
+      final List sidenavData => navEntriesFromData(sidenavData),
+      _ => null,
+    };
+    final obsolete = pageData['obsolete'] == true;
+
+    return .fragment([
+      const DashHeader(),
+      div(id: 'site-below-header', [
+        div(id: 'site-main-row', [
+          if (sideNavEntries != null)
+            DashSideNav(
+              navEntries: sideNavEntries,
+              currentPageUrl: pageUrl,
+            ),
+          main_(
+            id: 'page-content',
+            classes: [
+              if (pageData['focusedLayout'] == true) 'focused',
+            ].toClasses,
+            [child],
+          ),
+          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 script(
+        content: '''
+const sidenav = document.getElementById('sidenav');
+if (sidenav) {
+  const activeEntries = sidenav.querySelectorAll('.nav-link.active');
+  if (activeEntries.length > 0) {
+    const activeEntry = activeEntries[activeEntries.length - 1];
+    
+    sidenav.scrollTo({
+      top: activeEntry.offsetTop - window.innerHeight / 3,
+    });
+  }
+}
+      ''',
+      ),
+    ]);
+  }
+}
diff --git a/site/lib/src/layouts/toc_layout.dart b/site/lib/src/layouts/toc_layout.dart
index 9401e9ffd23..7dd3d864d32 100644
--- a/site/lib/src/layouts/toc_layout.dart
+++ b/site/lib/src/layouts/toc_layout.dart
@@ -5,8 +5,8 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/components/common/card.dart';
 
-import '../components/common/card.dart';
 import 'doc_layout.dart';
 
 class TocLayout extends DocLayout {
diff --git a/site/lib/src/layouts/tutorial_layout.dart b/site/lib/src/layouts/tutorial_layout.dart
index c148b36fb4e..1a314afdddf 100644
--- a/site/lib/src/layouts/tutorial_layout.dart
+++ b/site/lib/src/layouts/tutorial_layout.dart
@@ -4,8 +4,8 @@
 
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/components/tutorial/models/tutorial_model.dart';
 
-import '../models/tutorial_model.dart';
 import 'doc_layout.dart';
 
 class TutorialLayout extends DocLayout {
diff --git a/site/lib/src/models/learning_resource_model.dart b/site/lib/src/models/learning_resource_model.dart
index 12e0224df13..68e248bb11e 100644
--- a/site/lib/src/models/learning_resource_model.dart
+++ b/site/lib/src/models/learning_resource_model.dart
@@ -57,7 +57,8 @@ enum LearningResourceType {
   tutorial('Tutorial', ['codelab', 'tutorial']),
   sampleCode('Sample code', ['quickstart', 'demo', 'sample', 'sample code']),
   workshop('Workshop', ['workshop', 'video']),
-  recipe('Recipe', ['recipe', 'how to', 'cookbook']);
+  recipe('Recipe', ['recipe', 'how to', 'cookbook'])
+  ;
 
   const LearningResourceType(this.label, this.tags);
 
@@ -99,7 +100,8 @@ enum LearningResourceTag {
   ]),
   testing('Testing', ['testing', 'tests', 'test', 'perf', 'performance']),
   web('Web', ['web', 'wasm']),
-  widgets('Widgets', ['widgets', 'layout']);
+  widgets('Widgets', ['widgets', 'layout'])
+  ;
 
   const LearningResourceTag(this.label, this.tags);
 
diff --git a/site/lib/src/models/page_navigation_model.dart b/site/lib/src/models/page_navigation_model.dart
index 671981bf295..06c1e15ea1b 100644
--- a/site/lib/src/models/page_navigation_model.dart
+++ b/site/lib/src/models/page_navigation_model.dart
@@ -3,8 +3,7 @@
 // found in the LICENSE file.
 
 import 'package:jaspr_content/jaspr_content.dart';
-
-import '../extensions/header_extractor.dart';
+import 'package:site_shared/extensions/header_extractor.dart';
 
 extension GetPageNavigationData on Page {
   PageNavigationData? get navigationData {
diff --git a/site/lib/src/pages/glossary.dart b/site/lib/src/pages/glossary.dart
index c3a2590159d..d482c604ee6 100644
--- a/site/lib/src/pages/glossary.dart
+++ b/site/lib/src/pages/glossary.dart
@@ -5,12 +5,12 @@
 import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
+import 'package:site_shared/components/common/button.dart';
+import 'package:site_shared/components/common/card.dart';
+import 'package:site_shared/markdown.dart';
+import 'package:site_shared/util.dart';
 
-import '../components/common/button.dart';
-import '../components/common/card.dart';
 import '../components/pages/glossary_search_section.dart';
-import '../markdown/markdown_parser.dart';
-import '../util.dart';
 
 /// Different types of resources that glossary terms might link to.
 enum ResourceType {
@@ -21,7 +21,8 @@ enum ResourceType {
   video,
   code,
   diagnostic,
-  external;
+  external
+  ;
 
   /// The ID of the material symbol icon associated with each resource type.
   String get icon => switch (this) {
diff --git a/site/lib/src/pages/robots_txt.dart b/site/lib/src/pages/robots_txt.dart
index 8fbfaea15a6..3ee7bbac409 100644
--- a/site/lib/src/pages/robots_txt.dart
+++ b/site/lib/src/pages/robots_txt.dart
@@ -4,8 +4,7 @@
 
 import 'package:jaspr/server.dart';
 import 'package:jaspr_content/jaspr_content.dart';
-
-import '../util.dart';
+import 'package:site_shared/util.dart';
 
 /// The secondary output to configure to create
 /// a `robots.txt` file in the root directory.
diff --git a/site/lib/src/pages/widget_catalog.dart b/site/lib/src/pages/widget_catalog.dart
index 82d83955202..45e181b5aaa 100644
--- a/site/lib/src/pages/widget_catalog.dart
+++ b/site/lib/src/pages/widget_catalog.dart
@@ -5,9 +5,10 @@ import 'package:jaspr/dom.dart';
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
 import 'package:path/path.dart' as path;
+import 'package:site_shared/markdown.dart';
+import 'package:site_shared/util.dart';
 
 import '../components/pages/widget_catalog.dart';
-import '../markdown/markdown_parser.dart';
 import '../models/widget_catalog_model.dart';
 import '../util.dart';
 
diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart
index ce114383cc9..bd8e030c02b 100644
--- a/site/lib/src/style_hash.dart
+++ b/site/lib/src/style_hash.dart
@@ -1,4 +1,4 @@
-// Generated by docs_flutter_dev_site|stylesHashBuilder. Do not edit.
+// Generated by site_shared|stylesHashBuilder. Do not edit.
 // dart format off
 
 /// The generated hash of the `main.css` file.
diff --git a/site/lib/src/templating/dash_template_engine.dart b/site/lib/src/templating/dash_template_engine.dart
index ed2a8e2dbe7..681410e2c41 100644
--- a/site/lib/src/templating/dash_template_engine.dart
+++ b/site/lib/src/templating/dash_template_engine.dart
@@ -6,8 +6,7 @@ import 'package:collection/collection.dart';
 import 'package:jaspr_content/jaspr_content.dart';
 import 'package:liquify/liquify.dart';
 import 'package:path/path.dart' as path;
-
-import '../util.dart';
+import 'package:site_shared/util.dart';
 
 /// A template engine for Jaspr Content that
 /// uses `package:liquify` to parse and render Liquid templates.
diff --git a/site/lib/src/util.dart b/site/lib/src/util.dart
index 5ad8e587991..fde3153ae30 100644
--- a/site/lib/src/util.dart
+++ b/site/lib/src/util.dart
@@ -3,157 +3,6 @@
 // found in the LICENSE file.
 
 import 'package:path/path.dart' as path;
-import 'package:universal_web/web.dart' as web;
-
-/// Whether this build of the site will be deployed to production.
-const productionBuild = bool.fromEnvironment('PRODUCTION');
 
 /// Path to the `/src` directory where site content is located.
 final siteSrcDirectoryPath = path.join('..', 'src');
-
-/// Converts the specified [text] into a standardized URL slug
-/// that can be used as the ID for headers and other anchors in HTML.
-String slugify(String text) => text
-    .toLowerCase()
-    .trim()
-    .replaceAll(_slugifyPunctuationToReplace, '-')
-    .replaceAll(_slugifyUnsupportedToRemove, '')
-    .replaceAll(_slugifyCharsToCombine, '-')
-    .replaceAll(_slugifyHyphenTrim, '');
-
-final RegExp _slugifyPunctuationToReplace = RegExp(r'[:._]');
-final RegExp _slugifyUnsupportedToRemove = RegExp(
-  r'[^\p{L}\p{N}\s:.-]',
-  unicode: true,
-);
-final RegExp _slugifyCharsToCombine = RegExp(r'[\s-]+');
-final RegExp _slugifyHyphenTrim = RegExp(r'^-+|-+$');
-
-final RegExp _attributePattern = RegExp(r'(\w+)="([^"]*)"');
-final RegExp _whitespacePattern = RegExp(r'\s+');
-final RegExp _wordPattern = RegExp(r'\S+(?:\s*|$)');
-final RegExp _trailingMarkdownLinkPattern = RegExp(r'(\[.+\]:\s*\S+\s*)+$');
-
-Map parseAttributes(String attributeString) {
-  final attributes = {};
-  final classes = [];
-
-  // Extract all key="value" pairs.
-  final keyValueMatches = _attributePattern.allMatches(attributeString);
-  for (final match in keyValueMatches) {
-    final key = match.group(1)!;
-    final value = match.group(2)!;
-    attributes[key] = value;
-  }
-
-  // Remove all key="value" pairs to process remaining tokens.
-  final remaining = attributeString.replaceAll(_attributePattern, '').trim();
-
-  // Split remaining content by whitespace to find IDs and classes.
-  final parts = remaining.split(_whitespacePattern);
-
-  for (final part in parts) {
-    if (part.isEmpty) continue;
-
-    if (part.startsWith('#')) {
-      attributes['id'] = part.substring(1);
-    } else if (part.startsWith('.')) {
-      classes.add(part.substring(1));
-    }
-  }
-
-  if (classes.isNotEmpty) {
-    attributes['class'] = classes.join(' ');
-  }
-
-  return attributes;
-}
-
-String truncateWords(String text, int maxWords) {
-  if (maxWords <= 0) {
-    return '';
-  }
-
-  final words = text.trim().split(_whitespacePattern);
-  if (words.length <= maxWords) {
-    return text;
-  }
-
-  final truncated = words.take(maxWords).join(' ');
-  return '$truncated...';
-}
-
-/// Truncates the given [text] to the specified number of words [maxWords],
-/// preserving all whitespace and line breaks, as well as any trailing Markdown
-/// link definitions at the end of the text.
-String truncateWordsMarkdown(String text, int maxWords) {
-  if (maxWords <= 0) {
-    return '';
-  }
-
-  final trailingLinks = _trailingMarkdownLinkPattern.firstMatch(text);
-  var endContent = '';
-
-  if (trailingLinks != null) {
-    text = text.substring(0, trailingLinks.start);
-    endContent = '\n${trailingLinks.group(0)!}';
-  }
-
-  final matches = _wordPattern.allMatches(text);
-  if (matches.length <= maxWords) {
-    return text + endContent;
-  }
-
-  final truncated = matches.map((m) => m.group(0)!).take(maxWords).join('');
-  return '$truncated...\n$endContent';
-}
-
-extension StringUnCapitalize on String {
-  String unCapitalize() =>
-      isEmpty ? this : substring(0, 1).toLowerCase() + substring(1);
-}
-
-extension ListToClasses on List {
-  /// Convert a list of classes into a single class string
-  /// that can be added to an HTML element.
-  String get toClasses => join(' ');
-}
-
-enum OperatingSystem {
-  windows('Windows'),
-  macos('macOS'),
-  linux('Linux'),
-  chromeos('ChromeOS');
-
-  const OperatingSystem(this.label);
-  final String label;
-}
-
-/// Get the user's current operating system, or
-/// `null` if not of one "macos", "windows", "linux", or "chromeos".
-OperatingSystem? getOS() {
-  final userAgent = web.window.navigator.userAgent;
-  if (userAgent.contains('Mac')) {
-    // macOS or iPhone
-    return OperatingSystem.macos;
-  }
-
-  if (userAgent.contains('Win')) {
-    // Windows
-    return OperatingSystem.windows;
-  }
-
-  if ((userAgent.contains('Linux') || userAgent.contains('X11')) &&
-      !userAgent.contains('Android')) {
-    // Linux, but not Android
-    return OperatingSystem.linux;
-  }
-
-  if (userAgent.contains('CrOS')) {
-    // ChromeOS
-    return OperatingSystem.chromeos;
-  }
-
-  // Anything else
-  return null;
-}
diff --git a/site/pubspec.yaml b/site/pubspec.yaml
index a3d7bd7d795..84b051bb6ec 100644
--- a/site/pubspec.yaml
+++ b/site/pubspec.yaml
@@ -7,29 +7,22 @@ environment:
   sdk: ^3.11.0
 
 dependencies:
-  build: ^4.0.5
   collection: ^1.19.1
-  crypto: ^3.0.7
   html: ^0.15.6
   http: ^1.6.0
   jaspr: ^0.23.0
   jaspr_content: ^0.5.2
   # Used as our template engine.
-  liquify: ^1.5.1
-  markdown: ^7.3.0
-  markdown_description_list: ^0.1.1
+  liquify: 1.5.1
   meta: ^1.18.1
-  nanoid2: ^2.0.1
-  # Used for syntax highlighting.
-  opal: ^0.2.2
   path: ^1.9.1
   pub_semver: ^2.2.0
+  site_shared: any
   universal_web: ^1.1.1+1
   yaml: ^3.1.3
 
 dev_dependencies:
-  analysis_defaults:
-    path: ../packages/analysis_defaults
+  analysis_defaults: any
   build_runner: ^2.13.1
   build_web_compilers: ^4.4.17
   jaspr_builder: ^0.23.0
diff --git a/src/data/banner.yml b/src/data/banner.yml
new file mode 100644
index 00000000000..855c64748aa
--- /dev/null
+++ b/src/data/banner.yml
@@ -0,0 +1,6 @@
+text: >-
+  Flutter is back at Google I/O on May 19-20!
+link:
+  text: "Register now"
+  url: "https://io.google/2026/?utm_source=flutter&utm_medium=embedded_marketing&utm_campaign=flutter"
+  newTab: true
diff --git a/src/data/site.yml b/src/data/site.yml
index f0b17a38e6e..bb26d996313 100644
--- a/src/data/site.yml
+++ b/src/data/site.yml
@@ -5,12 +5,6 @@ email: flutter-dev@googlegroups.com
 
 showBanner: true
 
-# The raw, inline HTML to display in the banner.
-# Is automatically wrapped in a paragraph tag.
-bannerHtml: >-
-  Flutter is back at Google I/O on May 19-20!
-  Register now
-
 branch: main
 repo:
   organization: https://github.com/flutter