Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 54 additions & 73 deletions packages/site_shared/lib/components/common/breadcrumbs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand All @@ -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();
}
Expand All @@ -36,85 +33,80 @@ 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,
),
],
),
],
);
}
}

/// Extract breadcrumbs from page data.
///
/// Uses page metadata to generate breadcrumb titles with fallbacks:
/// `breadcrumb` > `shortTitle` > `title`.
List<BreadcrumbItem>? _breadcrumbsForPage(List<Page> 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<BreadcrumbItem>? breadcrumbsForPage(List<Page> 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 = <BreadcrumbItem>[];
var currentPath = '';
final breadcrumbs = <BreadcrumbItem>[];
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,
);
Comment thread
parlough marked this conversation as resolved.

// 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 {
Expand All @@ -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
Expand All @@ -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'),
],
);
Expand Down
6 changes: 6 additions & 0 deletions packages/site_shared/lib/src/markdown/markdown_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions packages/site_shared/lib/src/utils/structured_data.dart
Original file line number Diff line number Diff line change
@@ -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<String, Object?> buildPageStructuredData({
required String siteUrl,
required String siteName,
required String siteDescription,
required String pageUrl,
required String title,
String? pageDescription,
DateTime? dateModified,
List<BreadcrumbItem>? 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<String, Object?> data) =>
jsonEncode(data).replaceAll('<', r'\u003c');
5 changes: 5 additions & 0 deletions packages/site_shared/lib/structured_data.dart
Original file line number Diff line number Diff line change
@@ -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';
6 changes: 1 addition & 5 deletions sites/docs/lib/src/layouts/doc_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ class DocLayout extends FlutterDocsLayout {
@override
String get name => 'docs';

bool get allowBreadcrumbs => true;

@override
({Set<String> prerender, Set<String> prefetch}) speculationUrls(Page page) {
// On the homepage, prefetch pages commonly navigated to,
Expand Down Expand Up @@ -82,9 +80,7 @@ class DocLayout extends FlutterDocsLayout {
PageHeader(
title: pageTitle,
description: pageDescription,
showBreadcrumbs:
allowBreadcrumbs &&
(pageData['showBreadcrumbs'] as bool? ?? true),
showBreadcrumbs: showBreadcrumbsFor(page),
),

child,
Expand Down
37 changes: 35 additions & 2 deletions sites/docs/lib/src/layouts/flutter_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Component> 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',
),
Expand Down
Loading
Loading