From 60cedb6590f013fc0ce0e5b02e92ad56c353dc55 Mon Sep 17 00:00:00 2001 From: Manuel Santos Souza Date: Fri, 11 Sep 2026 16:29:19 -0300 Subject: [PATCH 1/6] Add auto-generated table of contents to blog posts - Add BlogTableOfContents, built on jaspr_content's built-in TableOfContentsExtension, showing a collapsible "On this page" nav for posts with at least 2 top-level headings. - Fix scroll-margin-top for heading anchors on www, which was silently a no-op because --site-header-height/--site-subheader-height were never defined for this site. --- .../lib/components/blog/blog_toc.dart | 56 +++++++++++++++++++ sites/www/lib/main.server.dart | 1 + sites/www/lib/src/layouts/blog_layout.dart | 2 + sites/www/lib/styles/core/_vars.scss | 6 ++ sites/www/lib/styles/pages/_blog_page.scss | 55 ++++++++++++++++++ 5 files changed, 120 insertions(+) create mode 100644 packages/site_shared/lib/components/blog/blog_toc.dart diff --git a/packages/site_shared/lib/components/blog/blog_toc.dart b/packages/site_shared/lib/components/blog/blog_toc.dart new file mode 100644 index 0000000000..5d4635f36c --- /dev/null +++ b/packages/site_shared/lib/components/blog/blog_toc.dart @@ -0,0 +1,56 @@ +// Copyright (c) 2026, the Dart project authors. All rights reserved. +// Copyright 2026, the Flutter authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; + +import '../common/material_icon.dart'; + +/// The minimum number of top-level entries an article needs to have +/// before a table of contents is worth showing. +const _minEntriesForToc = 2; + +/// Displays an automatically generated table of contents for the current +/// page, derived from the `h2` and `h3` headings found in its rendered +/// content. +/// +/// Requires [TableOfContentsExtension] to be applied to the page so that +/// its headings are available as a [TableOfContents] under the page's `toc` +/// data. +/// +/// Renders nothing if the page doesn't contain enough headings to warrant +/// a table of contents. +final class BlogTableOfContents extends StatelessComponent { + const BlogTableOfContents({super.key}); + + @override + Component build(BuildContext context) { + final toc = context.page.data['toc'] as TableOfContents?; + if (toc == null || toc.entries.length < _minEntriesForToc) { + return const Component.empty(); + } + + return nav( + classes: 'toc', + attributes: {'aria-label': 'Table of contents'}, + [ + Component.element( + tag: 'details', + children: [ + const Component.element( + tag: 'summary', + children: [ + MaterialIcon('chevron_right'), + Component.text('On this page'), + ], + ), + div(classes: 'toc-list', [toc.build()]), + ], + ), + ], + ); + } +} diff --git a/sites/www/lib/main.server.dart b/sites/www/lib/main.server.dart index ad3623362a..9e59710697 100644 --- a/sites/www/lib/main.server.dart +++ b/sites/www/lib/main.server.dart @@ -97,6 +97,7 @@ void main() { ], extensions: [ ShowcaseStoryExtension(), + const TableOfContentsExtension(), const TableWrapperExtension(), const MermaidProcessor(), const CodeBlockProcessor(defaultTitle: 'Runnable Flutter example'), diff --git a/sites/www/lib/src/layouts/blog_layout.dart b/sites/www/lib/src/layouts/blog_layout.dart index 394a613907..706da0cbb6 100644 --- a/sites/www/lib/src/layouts/blog_layout.dart +++ b/sites/www/lib/src/layouts/blog_layout.dart @@ -9,6 +9,7 @@ import 'package:jaspr/jaspr.dart'; import 'package:jaspr_content/jaspr_content.dart'; import 'package:site_shared/blog.dart'; import 'package:site_shared/components/blog/blog_next_posts.dart'; +import 'package:site_shared/components/blog/blog_toc.dart'; import 'package:site_shared/components/blog/post_info.dart'; import 'package:site_shared/components/common/breadcrumbs.dart'; import 'package:site_shared/components/common/client/back_to_top_button.dart'; @@ -96,6 +97,7 @@ class BlogLayout extends DefaultLayout { ]), ]), if (post != null) PostInfo(post: post, url: page.url), + if (post != null) const BlogTableOfContents(), child, if (isPost) BlogNextPosts(currentPage: page, category: pageCategory), diff --git a/sites/www/lib/styles/core/_vars.scss b/sites/www/lib/styles/core/_vars.scss index d9160e9f5f..857ecbfdf6 100644 --- a/sites/www/lib/styles/core/_vars.scss +++ b/sites/www/lib/styles/core/_vars.scss @@ -45,6 +45,12 @@ --ui-header-height: calc(var(--ui-header-navigation-height) + var(--ui-header-event-banner-height)); --ui-logo-width: 126px; + // Used by shared content styles (such as the heading `scroll-margin-top` + // rules in `_content.scss`) to keep anchor-linked headings clear of the + // fixed header. This site has no subheader, unlike docs.flutter.dev. + --site-header-height: var(--ui-header-height); + --site-subheader-height: 0px; + --font-size-heading-1: 46px; --font-size-heading-2: 34px; --font-size-heading-3: 24px; diff --git a/sites/www/lib/styles/pages/_blog_page.scss b/sites/www/lib/styles/pages/_blog_page.scss index 019b6124c4..60f7bdead8 100644 --- a/sites/www/lib/styles/pages/_blog_page.scss +++ b/sites/www/lib/styles/pages/_blog_page.scss @@ -33,6 +33,61 @@ color: var(--site-base-fgColor); } + .toc { + margin: 1.5rem 0; + background: var(--site-inset-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--ui-border-radius-sm); + + details { + padding: 1rem 1.25rem; + } + + summary { + display: flex; + align-items: center; + gap: 0.25rem; + font-weight: var(--site-fontWeight-bold); + cursor: pointer; + list-style: none; + + &::-webkit-details-marker { + display: none; + } + + .material-symbols { + transition: transform 0.15s var(--ui-anim-func); + } + } + + details[open] summary .material-symbols { + transform: rotate(90deg); + } + + .toc-list { + margin: 0.75rem 0 0; + + ul { + margin: 0; + padding-left: 1rem; + list-style: none; + border-left: 1px solid var(--site-outline-variant); + } + + li { + margin: 0.5rem 0; + } + + a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + } + @media (min-width: 576px) { > .content { padding: 2rem; From 121f218ecf242cca4b59c57b64868e3a41f7299b Mon Sep 17 00:00:00 2001 From: Manuel Santos Souza <89389164+manuelzzz@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:55:36 -0300 Subject: [PATCH 2/6] Update packages/site_shared/lib/components/blog/blog_toc.dart Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/site_shared/lib/components/blog/blog_toc.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/site_shared/lib/components/blog/blog_toc.dart b/packages/site_shared/lib/components/blog/blog_toc.dart index 5d4635f36c..72cfc61dac 100644 --- a/packages/site_shared/lib/components/blog/blog_toc.dart +++ b/packages/site_shared/lib/components/blog/blog_toc.dart @@ -28,8 +28,8 @@ final class BlogTableOfContents extends StatelessComponent { @override Component build(BuildContext context) { - final toc = context.page.data['toc'] as TableOfContents?; - if (toc == null || toc.entries.length < _minEntriesForToc) { + final toc = context.page.data['toc']; + if (toc is! TableOfContents || toc.entries.length < _minEntriesForToc) { return const Component.empty(); } From 28a758fbc9a6b86e6b9a0a7e1e621becce61b151 Mon Sep 17 00:00:00 2001 From: Manuel Santos Souza <89389164+manuelzzz@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:33:46 -0300 Subject: [PATCH 3/6] Update packages/site_shared/lib/components/blog/blog_toc.dart Co-authored-by: Parker Lougheed --- packages/site_shared/lib/components/blog/blog_toc.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/site_shared/lib/components/blog/blog_toc.dart b/packages/site_shared/lib/components/blog/blog_toc.dart index 72cfc61dac..19e9d3917b 100644 --- a/packages/site_shared/lib/components/blog/blog_toc.dart +++ b/packages/site_shared/lib/components/blog/blog_toc.dart @@ -30,7 +30,7 @@ final class BlogTableOfContents extends StatelessComponent { Component build(BuildContext context) { final toc = context.page.data['toc']; if (toc is! TableOfContents || toc.entries.length < _minEntriesForToc) { - return const Component.empty(); + return const .empty(); } return nav( From c7468807d8baf5add0cf2a4381ef45fac2d85c31 Mon Sep 17 00:00:00 2001 From: Manuel Santos Souza <89389164+manuelzzz@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:34:04 -0300 Subject: [PATCH 4/6] Update packages/site_shared/lib/components/blog/blog_toc.dart Co-authored-by: Parker Lougheed --- .../site_shared/lib/components/blog/blog_toc.dart | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/site_shared/lib/components/blog/blog_toc.dart b/packages/site_shared/lib/components/blog/blog_toc.dart index 19e9d3917b..e48e64b1e1 100644 --- a/packages/site_shared/lib/components/blog/blog_toc.dart +++ b/packages/site_shared/lib/components/blog/blog_toc.dart @@ -37,14 +37,12 @@ final class BlogTableOfContents extends StatelessComponent { classes: 'toc', attributes: {'aria-label': 'Table of contents'}, [ - Component.element( - tag: 'details', - children: [ - const Component.element( - tag: 'summary', - children: [ + details( + [ + const summary( + [ MaterialIcon('chevron_right'), - Component.text('On this page'), + .text('On this page'), ], ), div(classes: 'toc-list', [toc.build()]), From 634af7e8ea4f24496d890f62150674660b844675 Mon Sep 17 00:00:00 2001 From: Manuel Santos Souza <89389164+manuelzzz@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:45:10 -0300 Subject: [PATCH 5/6] Update sites/www/lib/styles/pages/_blog_page.scss Co-authored-by: Parker Lougheed --- sites/www/lib/styles/pages/_blog_page.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/sites/www/lib/styles/pages/_blog_page.scss b/sites/www/lib/styles/pages/_blog_page.scss index 60f7bdead8..27b5483cc5 100644 --- a/sites/www/lib/styles/pages/_blog_page.scss +++ b/sites/www/lib/styles/pages/_blog_page.scss @@ -50,6 +50,7 @@ font-weight: var(--site-fontWeight-bold); cursor: pointer; list-style: none; + user-select: none; &::-webkit-details-marker { display: none; From cef0b2f34e970faf24458d75e78b26447a15e652 Mon Sep 17 00:00:00 2001 From: Manuel Santos Souza Date: Fri, 18 Sep 2026 15:56:54 -0300 Subject: [PATCH 6/6] Fix blog-toc title --- packages/site_shared/lib/components/blog/blog_toc.dart | 2 +- sites/www/lib/styles/pages/_blog_page.scss | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/site_shared/lib/components/blog/blog_toc.dart b/packages/site_shared/lib/components/blog/blog_toc.dart index e48e64b1e1..8dcdc73751 100644 --- a/packages/site_shared/lib/components/blog/blog_toc.dart +++ b/packages/site_shared/lib/components/blog/blog_toc.dart @@ -42,7 +42,7 @@ final class BlogTableOfContents extends StatelessComponent { const summary( [ MaterialIcon('chevron_right'), - .text('On this page'), + .text('In this article'), ], ), div(classes: 'toc-list', [toc.build()]), diff --git a/sites/www/lib/styles/pages/_blog_page.scss b/sites/www/lib/styles/pages/_blog_page.scss index 27b5483cc5..8d4875cfbc 100644 --- a/sites/www/lib/styles/pages/_blog_page.scss +++ b/sites/www/lib/styles/pages/_blog_page.scss @@ -1,4 +1,4 @@ -@use 'package:site_shared/_sass/base/mixins'; +@use "package:site_shared/_sass/base/mixins"; .blog main { display: flex; @@ -27,7 +27,6 @@ } } - > .content { padding: 1.5rem; color: var(--site-base-fgColor); @@ -47,6 +46,7 @@ display: flex; align-items: center; gap: 0.25rem; + font-family: var(--site-ui-fontFamily); font-weight: var(--site-fontWeight-bold); cursor: pointer; list-style: none; @@ -129,7 +129,7 @@ color: var(--site-base-fgColor-alt); user-select: none; - >span { + > span { font-size: 1.75rem; } @@ -182,7 +182,9 @@ cursor: pointer; opacity: 0; transform: translateY(0.5rem); - transition: opacity 0.2s var(--ui-anim-func), transform 0.2s var(--ui-anim-func); + transition: + opacity 0.2s var(--ui-anim-func), + transform 0.2s var(--ui-anim-func); pointer-events: none; visibility: hidden;