diff --git a/packages/site_shared/lib/components/common/breadcrumbs.dart b/packages/site_shared/lib/components/common/breadcrumbs.dart index 9a0ef2790b0..69739ca0d55 100644 --- a/packages/site_shared/lib/components/common/breadcrumbs.dart +++ b/packages/site_shared/lib/components/common/breadcrumbs.dart @@ -10,12 +10,9 @@ import 'package:jaspr_content/jaspr_content.dart'; import '../../util.dart'; import 'material_icon.dart'; -/// Breadcrumbs navigation component that -/// follows ARIA guidelines and includes RDFa markup. +/// Breadcrumbs navigation component that follows ARIA guidelines. /// /// References: -/// - https://developers.google.com/search/docs/data-types/breadcrumb -/// - https://schema.org/BreadcrumbList /// - https://www.w3.org/TR/wai-aria-practices/examples/breadcrumb/index.html class PageBreadcrumbs extends StatelessComponent { const PageBreadcrumbs({this.crumbs, super.key}); @@ -25,7 +22,7 @@ class PageBreadcrumbs extends StatelessComponent { @override Component build(BuildContext context) { final crumbs = - this.crumbs ?? _breadcrumbsForPage(context.pages, context.page); + this.crumbs ?? breadcrumbsForPage(context.pages, context.page); if (crumbs == null || crumbs.isEmpty) { return const Component.empty(); } @@ -36,15 +33,10 @@ class PageBreadcrumbs extends StatelessComponent { [ ol( classes: 'breadcrumb-list', - attributes: { - 'vocab': 'https://schema.org/', - 'typeof': 'BreadcrumbList', - }, [ for (var i = 0; i < crumbs.length; i++) _BreadcrumbItemComponent( crumb: crumbs[i], - index: i, isLast: i == crumbs.length - 1, ), ], @@ -52,69 +44,69 @@ class PageBreadcrumbs extends StatelessComponent { ], ); } +} - /// Extract breadcrumbs from page data. - /// - /// Uses page metadata to generate breadcrumb titles with fallbacks: - /// `breadcrumb` > `shortTitle` > `title`. - List? _breadcrumbsForPage(List pages, Page page) { - final pageUrl = page.url; +/// Extract breadcrumbs from page data. +/// +/// Uses page metadata to generate breadcrumb titles with fallbacks: +/// `breadcrumb` > `shortTitle` > `title`. +List? breadcrumbsForPage(List pages, Page page) { + final pageUrl = page.url; - // Only show breadcrumbs if the URL isn't empty. - if (pageUrl.isEmpty || pageUrl == '/') return null; + // Only show breadcrumbs if the URL isn't empty. + if (pageUrl.isEmpty || pageUrl == '/') return null; - final pageBreadcrumb = page.breadcrumb; - if (pageBreadcrumb == null) { - return null; - } + final pageBreadcrumb = page.breadcrumb; + if (pageBreadcrumb == null) { + return null; + } - final segments = pageUrl - .split('/') - .where((segment) => segment.isNotEmpty) - .toList(growable: false); - if (segments.isEmpty) return null; + final segments = pageUrl + .split('/') + .where((segment) => segment.isNotEmpty) + .toList(growable: false); + if (segments.isEmpty) return null; - final breadcrumbs = []; - var currentPath = ''; + final breadcrumbs = []; + var currentPath = ''; - // Build breadcrumbs for each segment except the current page. - for (var i = 0; i < segments.length - 1; i++) { - currentPath += '/${segments[i]}'; + // Build breadcrumbs for each segment except the current page. + for (var i = 0; i < segments.length - 1; i++) { + currentPath += '/${segments[i]}'; - // Try to find the index page for this directory. - final indexPage = pages.firstWhereOrNull( - (page) => page.url == currentPath, - ); + // Try to find the index page for this directory. + final indexPage = pages.firstWhereOrNull( + (page) => page.url == currentPath, + ); - // Skip if no index page found. - if (indexPage == null) continue; - - if (indexPage.breadcrumb case final indexBreadcrumb?) { - breadcrumbs.add( - BreadcrumbItem( - title: indexBreadcrumb, - url: indexPage.url, - ), - ); - } - } + // Skip if no index page found. + if (indexPage == null) continue; - // If there are no parent breadcrumbs and this isn't a top-level doc, - // don't render the single one. - if (breadcrumbs.isEmpty && segments.length > 1) { - return null; + if (indexPage.breadcrumb case final indexBreadcrumb?) { + breadcrumbs.add( + BreadcrumbItem( + title: indexBreadcrumb, + url: indexPage.url, + ), + ); } + } - // Add the current page as the final breadcrumb. - breadcrumbs.add( - BreadcrumbItem( - title: pageBreadcrumb, - url: pageUrl, - ), - ); - - return breadcrumbs; + // If there are no parent breadcrumbs and this isn't a top-level doc, + // don't render the single one. + if (breadcrumbs.isEmpty && segments.length > 1) { + return null; } + + // Add the current page as the final breadcrumb. + breadcrumbs.add( + BreadcrumbItem( + title: pageBreadcrumb, + url: pageUrl, + ), + ); + + return breadcrumbs; } extension on Page { @@ -141,12 +133,10 @@ final class BreadcrumbItem { final class _BreadcrumbItemComponent extends StatelessComponent { const _BreadcrumbItemComponent({ required this.crumb, - required this.index, required this.isLast, }); final BreadcrumbItem crumb; - final int index; final bool isLast; @override @@ -156,19 +146,10 @@ final class _BreadcrumbItemComponent extends StatelessComponent { if (isLast) 'active', ].toClasses, attributes: { - 'property': 'itemListElement', - 'typeof': 'ListItem', if (isLast) 'aria-current': 'page', }, [ - a( - href: crumb.url, - attributes: {'property': 'item', 'typeof': 'WebPage'}, - [ - span(attributes: {'property': 'name'}, [.text(crumb.title)]), - ], - ), - meta(attributes: {'property': 'position', 'content': index.toString()}), + a(href: crumb.url, [.text(crumb.title)]), if (!isLast) const MaterialIcon('chevron_right'), ], ); diff --git a/packages/site_shared/lib/src/markdown/markdown_parser.dart b/packages/site_shared/lib/src/markdown/markdown_parser.dart index 05d44b53b51..14168908a91 100644 --- a/packages/site_shared/lib/src/markdown/markdown_parser.dart +++ b/packages/site_shared/lib/src/markdown/markdown_parser.dart @@ -91,6 +91,12 @@ String parseMarkdownToHtml(String markdownString, {bool inline = false}) { return renderer.render(nodes); } +/// Parses inline Markdown and returns only its rendered text content. +String parseInlineMarkdownToText(String markdownString) { + final rendered = parseMarkdownToHtml(markdownString, inline: true); + return html.parseFragment(rendered).text ?? ''; +} + final RegExp _markdownFilePattern = RegExp(r'.*\.md$'); class DashMarkdownParser implements PageParser { diff --git a/packages/site_shared/lib/src/utils/structured_data.dart b/packages/site_shared/lib/src/utils/structured_data.dart new file mode 100644 index 00000000000..6342e36231c --- /dev/null +++ b/packages/site_shared/lib/src/utils/structured_data.dart @@ -0,0 +1,78 @@ +// 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 '../../components/common/breadcrumbs.dart'; +import '../markdown/markdown_parser.dart'; + +/// Builds a Schema.org data graph for a site page. +Map buildPageStructuredData({ + required String siteUrl, + required String siteName, + required String siteDescription, + required String pageUrl, + required String title, + String? pageDescription, + DateTime? dateModified, + List? breadcrumbs, +}) { + final siteUri = Uri.parse(siteUrl.endsWith('/') ? siteUrl : '$siteUrl/'); + final siteId = siteUri.replace(fragment: 'website').toString(); + final absolutePageUrl = siteUri.resolve(pageUrl); + final pageId = absolutePageUrl.replace(fragment: 'webpage').toString(); + final normalizedTitle = parseInlineMarkdownToText(title).trim(); + final normalizedDescription = pageDescription?.trim(); + final validBreadcrumbs = breadcrumbs != null && breadcrumbs.length >= 2 + ? breadcrumbs + : null; + final breadcrumbId = absolutePageUrl + .replace(fragment: 'breadcrumb') + .toString(); + + return { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'WebSite', + '@id': siteId, + 'url': siteUri.toString(), + 'name': siteName.trim(), + 'description': siteDescription.trim(), + 'inLanguage': 'en', + }, + { + '@type': 'WebPage', + '@id': pageId, + 'url': absolutePageUrl.toString(), + 'name': normalizedTitle, + if (normalizedDescription != null && normalizedDescription.isNotEmpty) + 'description': normalizedDescription, + 'inLanguage': 'en', + if (dateModified != null) + 'dateModified': dateModified.toUtc().toIso8601String(), + 'isPartOf': {'@id': siteId}, + if (validBreadcrumbs != null) 'breadcrumb': {'@id': breadcrumbId}, + }, + if (validBreadcrumbs != null) + { + '@type': 'BreadcrumbList', + '@id': breadcrumbId, + 'itemListElement': [ + for (final (index, breadcrumb) in validBreadcrumbs.indexed) + { + '@type': 'ListItem', + 'position': index + 1, + 'name': parseInlineMarkdownToText(breadcrumb.title).trim(), + 'item': siteUri.resolve(breadcrumb.url).toString(), + }, + ], + }, + ], + }; +} + +/// Encodes a JSON-LD graph so it can be safely embedded in a script element. +String encodeJsonLdForHtml(Map data) => + jsonEncode(data).replaceAll('<', r'\u003c'); diff --git a/packages/site_shared/lib/structured_data.dart b/packages/site_shared/lib/structured_data.dart new file mode 100644 index 00000000000..78966fbcf64 --- /dev/null +++ b/packages/site_shared/lib/structured_data.dart @@ -0,0 +1,5 @@ +// 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. + +export 'src/utils/structured_data.dart'; diff --git a/sites/docs/lib/src/layouts/doc_layout.dart b/sites/docs/lib/src/layouts/doc_layout.dart index 15d1821e9fd..37e20f7bc3e 100644 --- a/sites/docs/lib/src/layouts/doc_layout.dart +++ b/sites/docs/lib/src/layouts/doc_layout.dart @@ -21,8 +21,6 @@ class DocLayout extends FlutterDocsLayout { @override String get name => 'docs'; - bool get allowBreadcrumbs => true; - @override ({Set prerender, Set prefetch}) speculationUrls(Page page) { // On the homepage, prefetch pages commonly navigated to, @@ -82,9 +80,7 @@ class DocLayout extends FlutterDocsLayout { PageHeader( title: pageTitle, description: pageDescription, - showBreadcrumbs: - allowBreadcrumbs && - (pageData['showBreadcrumbs'] as bool? ?? true), + showBreadcrumbs: showBreadcrumbsFor(page), ), child, diff --git a/sites/docs/lib/src/layouts/flutter_layout.dart b/sites/docs/lib/src/layouts/flutter_layout.dart index f20c80e836d..dea5b96b3cf 100644 --- a/sites/docs/lib/src/layouts/flutter_layout.dart +++ b/sites/docs/lib/src/layouts/flutter_layout.dart @@ -5,7 +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/breadcrumbs.dart'; import 'package:site_shared/layouts.dart'; +import 'package:site_shared/structured_data.dart'; import 'package:site_shared/util.dart'; import '../components/layout/footer.dart'; @@ -44,11 +46,42 @@ abstract class FlutterDocsLayout extends DashLayout { String get defaultSidenav => 'default'; + bool get allowBreadcrumbs => true; + + /// Whether breadcrumbs should be shown for [page], + /// honoring both the layout's [allowBreadcrumbs] and + /// the page's `showBreadcrumbs` metadata. + bool showBreadcrumbsFor(Page page) => + allowBreadcrumbs && (page.data.page['showBreadcrumbs'] as bool? ?? true); + @override Iterable buildExtraHead(Page page) { - return const [ + return [ + Builder( + builder: (context) { + final pageData = page.data.page; + final breadcrumbs = showBreadcrumbsFor(page) + ? breadcrumbsForPage(context.pages, page) + : null; + final structuredData = buildPageStructuredData( + siteUrl: page.data.site['url'] as String, + siteName: page.data.site['name'] as String, + siteDescription: page.data.site['description'] as String, + pageUrl: page.url, + title: pageData['title'] as String, + pageDescription: pageData['description'] as String?, + dateModified: pageData['dateModified'] as DateTime?, + breadcrumbs: breadcrumbs, + ); + + return script( + attributes: const {'type': 'application/ld+json'}, + content: encodeJsonLdForHtml(structuredData), + ); + }, + ), if (productionBuild) - meta( + const meta( name: 'google-site-verification', content: 'HFqxhSbf9YA_0rBglNLzDiWnrHiK_w4cqDh2YD2GEY4', ), diff --git a/sites/docs/lib/src/loaders/data_processor.dart b/sites/docs/lib/src/loaders/data_processor.dart index 4c3e3299db9..57d61f25924 100644 --- a/sites/docs/lib/src/loaders/data_processor.dart +++ b/sites/docs/lib/src/loaders/data_processor.dart @@ -19,27 +19,37 @@ final class DataProcessor implements DataLoader { /// Adds data about the last modified date of the page. static void _loadLastModified(Page page) { final pageLoader = page.loader; - if (pageLoader is! FilesystemLoader) return; + final repositoryRoot = _repositoryRoot; + String? inputPath; + String? projectInputPath; + if (repositoryRoot != null && pageLoader is FilesystemLoader) { + final sourcePath = path.canonicalize( + path.absolute(path.join(pageLoader.directory, page.path)), + ); + + // Use native separators for filesystem paths, + // then POSIX separators for Git paths and source URLs. + String toPosixRelative(String from) => + path.posix.joinAll(path.split(path.relative(sourcePath, from: from))); + inputPath = toPosixRelative(repositoryRoot); + projectInputPath = toPosixRelative(_projectRoot); + } - final sourcePath = path.canonicalize( - path.absolute(path.join(pageLoader.directory, page.path)), - ); + final modifiedSources = { + ?projectInputPath, + if (page.data.page['dateModifiedSources'] + case final List sources) + ...sources.whereType(), + }; + if (modifiedSources.isEmpty) return; - final repositoryRoot = _repositoryRoot; - // Use native separators for filesystem paths, - // then POSIX separators for Git paths and source URLs. - final inputPath = repositoryRoot != null - ? path.posix.joinAll( - path.split(path.relative(sourcePath, from: repositoryRoot)), - ) - : null; - final lastModifiedDate = inputPath != null - ? _lastModifiedDateForPath(inputPath) - : null; + final lastModified = latestModifiedDateForPaths(modifiedSources); + final lastModifiedDate = lastModified?.formatted; page.apply( data: { 'page': { 'date': ?lastModifiedDate, + 'dateModified': ?lastModified, 'inputPath': ?inputPath, if (page.data.page['sitemap'] == null) 'sitemap': { @@ -70,22 +80,37 @@ final String? _repositoryRoot = () { return path.canonicalize((result.stdout as String).trim()); }(); -/// Determines the last modified date for a given path -/// in the form `yyyy-mm-dd`. +/// The project directory from which the docs site is built. +final String _projectRoot = path.canonicalize(path.current); + +/// Determines the newest Git modification time for [inputPaths]. /// -/// Uses `git log` to get the last modified date from the git history. -/// Returns `null` if no date can be determined. -String? _lastModifiedDateForPath(String inputPath) => - _lastModifiedPerPath[inputPath]?.formatted; +/// Input paths are relative to the docs project root. +/// Uses dates collected from Git history. +/// Returns `null` if no date can be determined for any input path. +DateTime? latestModifiedDateForPaths(Iterable inputPaths) { + DateTime? latestDate; + for (final inputPath in inputPaths) { + final modifiedDate = _lastModifiedPerPath[inputPath]; + if (modifiedDate != null && + (latestDate == null || modifiedDate.isAfter(latestDate))) { + latestDate = modifiedDate; + } + } + return latestDate; +} -/// The most recent Git commit date for each content file path. +/// The most recent Git commit date for each docs project file path. /// -/// The paths are relative to the repository root. +/// The paths are relative to the docs project root. /// If Git metadata isn't available, the map is empty. final Map _lastModifiedPerPath = () { final fileLastModified = {}; final repositoryRoot = _repositoryRoot; if (repositoryRoot == null) return fileLastModified; + final projectPath = path.posix.joinAll( + path.split(path.relative(_projectRoot, from: repositoryRoot)), + ); final ProcessResult result; try { @@ -96,7 +121,7 @@ final Map _lastModifiedPerPath = () { '--name-only', '--format=commit-date:%cI', '--', - path.posix.join('sites', 'docs', 'src', 'content'), + projectPath, ], workingDirectory: repositoryRoot, ); @@ -123,10 +148,14 @@ final Map _lastModifiedPerPath = () { } else if (line.isNotEmpty) { // If it's a non-empty line and a date is set, it's a file path. if (currentCommitDate case final lastModifiedTime?) { + final projectRelativePath = path.posix.relative( + line, + from: projectPath, + ); // Only set the last modified time for this path // if we haven't already stored a later modified time. fileLastModified.putIfAbsent( - line, + projectRelativePath, () => lastModifiedTime, ); } diff --git a/sites/docs/lib/src/pages/custom_pages.dart b/sites/docs/lib/src/pages/custom_pages.dart index c95e3981463..82c171241a2 100644 --- a/sites/docs/lib/src/pages/custom_pages.dart +++ b/sites/docs/lib/src/pages/custom_pages.dart @@ -29,6 +29,7 @@ MemoryPage get _glossaryPage => MemoryPage.builder( 'description': 'A glossary reference for terminology ' 'used across docs.flutter.dev.', + 'dateModifiedSources': ['src/data/glossary.yml'], 'showToc': false, 'bodyClass': 'glossary-page', }, diff --git a/sites/docs/lib/src/pages/widget_catalog.dart b/sites/docs/lib/src/pages/widget_catalog.dart index 7b11f23c006..316efd73498 100644 --- a/sites/docs/lib/src/pages/widget_catalog.dart +++ b/sites/docs/lib/src/pages/widget_catalog.dart @@ -37,6 +37,10 @@ List get widgetCatalogPages { 'description': 'A catalog of Flutter\'s ${category.title.unCapitalize()}. ' '${category.description}', + 'dateModifiedSources': [ + 'src/data/catalog/index.yml', + 'src/data/catalog/widgets.yml', + ], }, }, builder: (context) { diff --git a/sites/docs/src/data/site.yml b/sites/docs/src/data/site.yml index 789a994ccf0..b4144c7c9ba 100644 --- a/sites/docs/src/data/site.yml +++ b/sites/docs/src/data/site.yml @@ -1,5 +1,9 @@ title: Flutter +name: Flutter docs url: https://docs.flutter.dev +description: >- + Official documentation for learning Flutter and using it to + build multiplatform apps, with guides, tutorials, examples, and references. main-url: https://flutter.dev email: flutter-dev@googlegroups.com