diff --git a/packages/site_shared/lib/components/common/client/page_header_options.dart b/packages/site_shared/lib/components/common/client/page_header_options.dart index 79115c68104..6fb4826c2de 100644 --- a/packages/site_shared/lib/components/common/client/page_header_options.dart +++ b/packages/site_shared/lib/components/common/client/page_header_options.dart @@ -14,12 +14,14 @@ import '../dropdown.dart'; final class PageHeaderOptions extends StatefulComponent { const PageHeaderOptions({ required this.title, + this.markdownUrl, this.sourceUrl, this.issueUrl, super.key, }); final String title; + final String? markdownUrl; final String? sourceUrl; final String? issueUrl; @@ -91,12 +93,28 @@ final class _PageHeaderOptionsState extends State { ), ], ), + if (component.markdownUrl case final markdownUrl?) ...[ + li( + [ + Button( + icon: 'markdown', + content: 'View as Markdown', + href: markdownUrl, + attributes: const { + 'target': '_blank', + 'rel': 'noopener', + }, + ), + ], + ), + if (component.issueUrl != null) const DropdownDivider(), + ], if (component.sourceUrl case final sourceUrl?) li( [ Button( icon: 'docs', - content: 'View source', + content: 'Open on GitHub', href: sourceUrl, attributes: const { 'target': '_blank', @@ -110,7 +128,7 @@ final class _PageHeaderOptionsState extends State { [ Button( icon: 'bug_report', - content: 'Report issue', + content: 'Report an issue', href: issueUrl, attributes: const { 'target': '_blank', diff --git a/packages/site_shared/lib/components/common/dropdown.dart b/packages/site_shared/lib/components/common/dropdown.dart index e6e62ca5f03..205694dc167 100644 --- a/packages/site_shared/lib/components/common/dropdown.dart +++ b/packages/site_shared/lib/components/common/dropdown.dart @@ -86,3 +86,15 @@ final class DropdownState extends State { ); } } + +/// A separator between groups of dropdown menu items. +final class DropdownDivider extends StatelessComponent { + const DropdownDivider({super.key}); + + @override + Component build(BuildContext _) => const Component.element( + tag: 'li', + classes: 'dropdown-divider', + attributes: {'aria-hidden': 'true', 'role': 'separator'}, + ); +} diff --git a/packages/site_shared/lib/components/layout/site_switcher.dart b/packages/site_shared/lib/components/layout/site_switcher.dart index 3dc00d2e851..f968a421452 100644 --- a/packages/site_shared/lib/components/layout/site_switcher.dart +++ b/packages/site_shared/lib/components/layout/site_switcher.dart @@ -46,11 +46,7 @@ final class SiteSwitcher extends StatelessComponent { subtype: 'Blog', href: 'https://blog.flutter.dev', ), - const Component.element( - tag: 'li', - classes: 'dropdown-divider', - attributes: {'aria-hidden': 'true', 'role': 'separator'}, - ), + const DropdownDivider(), ], _SiteWordMarkListEntry( name: 'Dart', diff --git a/sites/docs/lib/_sass/components/_content.scss b/sites/docs/lib/_sass/components/_content.scss index 94200729256..9e4022c5652 100644 --- a/sites/docs/lib/_sass/components/_content.scss +++ b/sites/docs/lib/_sass/components/_content.scss @@ -96,6 +96,10 @@ main { background-color: var(--site-raised-bgColor); border: var(--site-outline) 1px solid; box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15); + + .dropdown-divider { + background-color: var(--site-outline); + } } } } diff --git a/sites/docs/lib/main.client.options.dart b/sites/docs/lib/main.client.options.dart index c781ee279b5..23c1b571e81 100644 --- a/sites/docs/lib/main.client.options.dart +++ b/sites/docs/lib/main.client.options.dart @@ -147,6 +147,7 @@ ClientOptions get defaultClientOptions => ClientOptions( 'site_shared:page_header_options': ClientLoader( (p) => _page_header_options.PageHeaderOptions( title: p['title'] as String, + markdownUrl: p['markdownUrl'] as String?, sourceUrl: p['sourceUrl'] as String?, issueUrl: p['issueUrl'] as String?, ), diff --git a/sites/docs/lib/main.server.dart b/sites/docs/lib/main.server.dart index 23e8bbff793..2738c1fd439 100644 --- a/sites/docs/lib/main.server.dart +++ b/sites/docs/lib/main.server.dart @@ -38,6 +38,7 @@ import 'src/layouts/toc_layout.dart'; import 'src/layouts/tutorial_layout.dart'; import 'src/loaders/data_processor.dart'; import 'src/pages/custom_pages.dart'; +import 'src/pages/markdown.dart'; import 'src/pages/robots_txt.dart'; import 'src/templating/dash_template_engine.dart'; @@ -82,23 +83,7 @@ Component get _docsFlutterDevSite => ContentApp.custom( theme: const ContentTheme.none(), secondaryOutputs: [ const RobotsTxtOutput(), - MarkdownOutput( - createHeader: (page) { - final header = StringBuffer(); - if (page.data.page['title'] case final String title - when title.isNotEmpty) { - header.writeln('# $title'); - - if (page.data.page['description'] case final String description - when description.isNotEmpty) { - header.writeln(); - header.writeln('> $description'); - } - } - - return header.toString(); - }, - ), + canonicalMarkdownOutput, ], ), ); diff --git a/sites/docs/lib/main.server.options.dart b/sites/docs/lib/main.server.options.dart index 70381901f68..fcbd5e0a090 100644 --- a/sites/docs/lib/main.server.options.dart +++ b/sites/docs/lib/main.server.options.dart @@ -182,7 +182,12 @@ Map __feedbackFeedbackComponent( ) => {'issueUrl': c.issueUrl}; Map __page_header_optionsPageHeaderOptions( _page_header_options.PageHeaderOptions c, -) => {'title': c.title, 'sourceUrl': c.sourceUrl, 'issueUrl': c.issueUrl}; +) => { + 'title': c.title, + 'markdownUrl': c.markdownUrl, + 'sourceUrl': c.sourceUrl, + 'issueUrl': c.issueUrl, +}; Map __simple_tooltipSimpleTooltip( _simple_tooltip.SimpleTooltip c, ) => {'target': c.target.toId(), 'content': c.content.toId()}; diff --git a/sites/docs/lib/src/components/common/page_header.dart b/sites/docs/lib/src/components/common/page_header.dart index 257bc63ff14..e89691e197e 100644 --- a/sites/docs/lib/src/components/common/page_header.dart +++ b/sites/docs/lib/src/components/common/page_header.dart @@ -10,6 +10,7 @@ import 'package:site_shared/components/common/client/page_header_options.dart'; import 'package:site_shared/markdown.dart'; import 'package:site_shared/util.dart'; +import '../../pages/markdown.dart'; import '../../utils/page_source_info.dart'; final class PageHeader extends StatelessComponent { @@ -28,7 +29,8 @@ final class PageHeader extends StatelessComponent { @override Component build(BuildContext context) { - final sourceInfo = context.page.sourceInfo; + final page = context.page; + final sourceInfo = page.sourceInfo; return header( id: 'site-content-title', @@ -47,6 +49,7 @@ final class PageHeader extends StatelessComponent { ), PageHeaderOptions( title: title, + markdownUrl: canonicalMarkdownOutput.routeForPage(page), sourceUrl: sourceInfo.sourceUrl, issueUrl: sourceInfo.issueUrl, ), diff --git a/sites/docs/lib/src/layouts/flutter_layout.dart b/sites/docs/lib/src/layouts/flutter_layout.dart index dea5b96b3cf..989a7b82df2 100644 --- a/sites/docs/lib/src/layouts/flutter_layout.dart +++ b/sites/docs/lib/src/layouts/flutter_layout.dart @@ -14,6 +14,7 @@ import '../components/layout/footer.dart'; import '../components/layout/header.dart'; import '../components/layout/sidenav.dart'; import '../models/sidenav_model.dart'; +import '../pages/markdown.dart'; import '../style_hash.dart'; /// The base Jaspr Content layout for wrapping site content. @@ -57,6 +58,13 @@ abstract class FlutterDocsLayout extends DashLayout { @override Iterable buildExtraHead(Page page) { return [ + if (canonicalMarkdownOutput.routeForPage(page) case final markdownRoute?) + link( + rel: 'alternate', + type: 'text/markdown', + href: markdownRoute, + attributes: const {'title': 'Markdown'}, + ), Builder( builder: (context) { final pageData = page.data.page; diff --git a/sites/docs/lib/src/pages/markdown.dart b/sites/docs/lib/src/pages/markdown.dart new file mode 100644 index 00000000000..61404238908 --- /dev/null +++ b/sites/docs/lib/src/pages/markdown.dart @@ -0,0 +1,47 @@ +// 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_content/jaspr_content.dart'; + +/// The Markdown secondary output shared by routing and page layouts. +final canonicalMarkdownOutput = CanonicalMarkdownOutput( + createHeader: _createHeader, +); + +/// Outputs a secondary Markdown file at the canonical page URL with a `.md` +/// suffix. +final class CanonicalMarkdownOutput extends MarkdownOutput { + CanonicalMarkdownOutput({super.createHeader}); + + /// Returns the secondary Markdown route for [page], if one is generated. + String? routeForPage(Page page) { + if (pattern.matchAsPrefix(page.path) == null) return null; + return createRoute(page.url); + } + + @override + String createRoute(String route) { + // Keep Markdown URLs absolute and avoid a `/.md` suffix. + final absoluteRoute = route.startsWith('/') ? route : '/$route'; + final normalizedRoute = absoluteRoute.endsWith('/') + ? absoluteRoute.substring(0, absoluteRoute.length - 1) + : absoluteRoute; + return normalizedRoute.isEmpty ? '/index.md' : '$normalizedRoute.md'; + } +} + +String _createHeader(Page page) { + final header = StringBuffer(); + if (page.data.page['title'] case final String title when title.isNotEmpty) { + header.writeln('# $title'); + + if (page.data.page['description'] case final String description + when description.isNotEmpty) { + header.writeln(); + header.writeln('> $description'); + } + } + + return header.toString(); +} diff --git a/sites/docs/src/content/tools/devtools/release-notes/release-notes-2.21.1.md b/sites/docs/src/content/tools/devtools/release-notes/release-notes-2.21.1.md index d8cd8828ccf..63f0e25e23c 100644 --- a/sites/docs/src/content/tools/devtools/release-notes/release-notes-2.21.1.md +++ b/sites/docs/src/content/tools/devtools/release-notes/release-notes-2.21.1.md @@ -1,7 +1,7 @@ --- title: DevTools 2.21.1 release notes shortTitle: 2.21.1 release notes -breadcrumb: 2.22.1 +breadcrumb: 2.21.1 description: Release notes for Dart and Flutter DevTools version 2.21.1. showToc: false --- diff --git a/tool/dash_site/lib/src/commands/build.dart b/tool/dash_site/lib/src/commands/build.dart index cd6e86065bb..1f3846bb200 100644 --- a/tool/dash_site/lib/src/commands/build.dart +++ b/tool/dash_site/lib/src/commands/build.dart @@ -56,7 +56,7 @@ Future buildSite(Site site, {required bool productionRelease}) async { '--no-managed-build-options', '--sitemap-domain=${site.baseUrl}', // Exclude secondary Markdown output files from sitemap. - r'--sitemap-exclude=\.html\.md$', + r'--sitemap-exclude=\.md$', '--dart-define=PRODUCTION=$productionRelease', ], workingDirectory: site.directory,