diff --git a/AUTHORS b/AUTHORS
index 2129de85e..1c028a92c 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -17,3 +17,4 @@ Matej Bačo (Meldiron) - Contributor
Simon Binder (simolus3) - Contributor
Mahdi Khodadadifard (xclud) - Contributor
Jamiu Okanlawon (developerjamiu) - Contributor
+Jan Bittner (tenhobi) - Contributor
diff --git a/docs.json b/docs.json
index eac2f0423..778398bd9 100644
--- a/docs.json
+++ b/docs.json
@@ -425,6 +425,19 @@
{
"title": "Secondary Outputs",
"href": "/content/concepts/secondary_outputs"
+ },
+ {
+ "group": "Pages Aggregator",
+ "pages": [
+ {
+ "title": "Overview",
+ "href": "/content/pages_aggregator"
+ },
+ {
+ "title": "Taxonomy",
+ "href": "/content/pages_aggregator/taxonomy"
+ }
+ ]
}
]
},
diff --git a/docs/content/concepts/route_loading.mdx b/docs/content/concepts/route_loading.mdx
index 260c03803..beda8ed14 100644
--- a/docs/content/concepts/route_loading.mdx
+++ b/docs/content/concepts/route_loading.mdx
@@ -127,6 +127,22 @@ ContentApp.custom(
);
```
+### TaxonomyLoader
+
+The `TaxonomyLoader` generates pages from taxonomy terms (like tags or categories) extracted from content frontmatter.
+
+- **Source**: Frontmatter fields in content files.
+- **Behavior**: Scans a directory for content files, extracts unique values from a specified frontmatter field, and generates a page for each term.
+- **Use Cases**:
+ - Tag or category archive pages for a blog.
+ - Any classification-based page generation.
+- **Features**:
+ - Automatic term extraction and normalization.
+ - Optional taxonomy index page.
+ - Context extensions for querying taxonomy data.
+
+[Learn more about Taxonomy](/content/pages_aggregator/taxonomy).
+
## Creating Custom Route Loaders
If the built-in loaders don't fit your needs, you can create a custom `RouteLoader` implementation. At minimum, you'll need to:
diff --git a/docs/content/pages_aggregator/index.mdx b/docs/content/pages_aggregator/index.mdx
new file mode 100644
index 000000000..b7594c5f7
--- /dev/null
+++ b/docs/content/pages_aggregator/index.mdx
@@ -0,0 +1,87 @@
+---
+title: Pages Aggregator
+description: Generate additional routes by analyzing all loaded pages.
+---
+
+---
+
+A `PagesAggregator` generates additional routes after all pages are loaded. Unlike route loaders, aggregators don't load pages from a source — they analyze the already-loaded pages and produce routes based on their content or metadata.
+
+This is useful for any case where the set of routes depends on the content of your pages, such as tag pages, category archives, or any other derived view.
+
+---
+
+## How It Works
+
+Aggregators run after all `RouteLoader`s have finished loading content pages. Each aggregator receives the full list of loaded content pages and returns a list of `Route`s to add to the router.
+
+```dart
+ContentApp.custom(
+ eagerlyLoadAllPages: true,
+ loaders: [FilesystemLoader('content')],
+ pagesAggregators: [
+ MyAggregator(),
+ ],
+ configResolver: PageConfig.all(/* ... */),
+);
+```
+
+
+**Eager loading** must be enabled (`eagerlyLoadAllPages: true`) when using aggregators, as they depend on all pages being loaded before generating routes.
+
+
+## Route Info
+
+Alongside routes, aggregators can produce `AggregatedRouteInfo` objects that carry metadata for each generated route. These are accessible from within a route builder via `context.currentRouteInfo()`:
+
+```dart
+final info = context.currentRouteInfo();
+```
+
+This lets you pass structured data from the aggregator to the route builder without relying on closures or global state.
+
+## Built-in Aggregator: TaxonomyAggregator
+
+`jaspr_content` ships with a built-in aggregator for taxonomy use cases — `TaxonomyAggregator`. It scans page frontmatter for a given field (e.g., `tags`) and generates routes for each unique value (term):
+
+```dart
+TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (context, taxonomy, term) => TagPage(term: term),
+ taxonomyPageBuilder: (context, taxonomy) => TagsPage(),
+)
+```
+
+See the [Taxonomy](/content/pages_aggregator/taxonomy) page for details.
+
+## Custom Aggregators
+
+To create a custom aggregator, extend `PagesAggregator` and implement `aggregatePages`:
+
+```dart
+class ArchiveAggregator extends PagesAggregator {
+ @override
+ Future> aggregatePages(
+ List pages,
+ List routeInfos,
+ ) async {
+ // Group pages by year based on a 'date' frontmatter field.
+ final byYear = >{};
+ for (final page in pages) {
+ final date = page.data.page['date'];
+ if (date is String) {
+ final year = DateTime.parse(date).year;
+ byYear.putIfAbsent(year, () => []).add(page);
+ }
+ }
+
+ return [
+ for (final entry in byYear.entries)
+ Route(
+ path: '/archive/${entry.key}',
+ builder: (context, _) => ArchivePage(year: entry.key, pages: entry.value),
+ ),
+ ];
+ }
+}
+```
diff --git a/docs/content/pages_aggregator/taxonomy.mdx b/docs/content/pages_aggregator/taxonomy.mdx
new file mode 100644
index 000000000..f5686bf70
--- /dev/null
+++ b/docs/content/pages_aggregator/taxonomy.mdx
@@ -0,0 +1,272 @@
+---
+title: Taxonomy
+description: Classify content with taxonomies like tags and categories.
+---
+
+---
+
+Taxonomies let you classify content using frontmatter fields like **tags** or **categories**. A taxonomy is a classification system where each unique value is called a **term**. For example, a "tags" taxonomy might have the terms "dart", "flutter", and "jaspr".
+
+`TaxonomyAggregator` is a built-in `PagesAggregator` that scans loaded content pages, extracts all unique terms from a frontmatter field, and generates routes for each one. See [Pages Aggregator](/content/pages_aggregator) for the general aggregation concept.
+
+---
+
+## Setup
+
+Add a `TaxonomyAggregator` to `ContentApp.custom()` via the `pagesAggregators` parameter:
+
+```dart
+ContentApp.custom(
+ eagerlyLoadAllPages: true,
+ loaders: [FilesystemLoader('content')],
+ pagesAggregators: [
+ TaxonomyAggregator(
+ taxonomy: 'tags',
+ taxonomyPageBuilder: (context, taxonomy) => TagsPage(),
+ termPageBuilder: (context, taxonomy, term) => TagPage(term: term),
+ ),
+ ],
+ configResolver: PageConfig.all(
+ // ... your config
+ ),
+);
+```
+
+
+**Eager loading** must be enabled (`eagerlyLoadAllPages: true`) when using aggregators, as they depend on all content pages being loaded before generating routes.
+
+
+## Term Normalization
+
+All terms are normalized before use. The normalization converts values to lowercase and replaces spaces with dashes:
+
+- `"Dart"` → `dart`
+- `"My Tag"` → `my-tag`
+- `"Hello World"` → `hello-world`
+
+This means `tags: [Dart]` and `tags: [dart]` in different files refer to the same term.
+
+## Querying Taxonomy Data
+
+The `TaxonomyContext` extension on `BuildContext` provides methods to query taxonomy data from within component builders.
+
+
+These methods are **server-only**. They throw an `UnsupportedError` when called on the client. Wrap calls with a `!kIsWeb` check or use them only in server-rendered components.
+
+
+### taxonomyTermRefs
+
+Returns all term refs for a given taxonomy. The taxonomy index ref is excluded.
+
+```dart
+final termRefs = context.taxonomyTermRefs('tags');
+// Returns refs for /tags/dart, /tags/flutter, etc.
+```
+
+### taxonomyTermRef
+
+Returns the term ref for a specific taxonomy and term, or `null` if no such ref exists. The term is normalized before matching, so both `'Dart'` and `'dart'` will find the same ref.
+
+```dart
+final dartRef = context.taxonomyTermRef('tags', 'Dart');
+
+if (dartRef != null) {
+ return a(href: dartRef.url, [text('Dart')]);
+}
+```
+
+### taxonomyIndexRef
+
+Returns the index ref for a given taxonomy, or `null` if no index route was generated.
+
+```dart
+final tagsIndexRef = context.taxonomyIndexRef('tags');
+```
+
+### pagesForTerm
+
+Returns all content pages that have the given term in their frontmatter taxonomy field.
+
+```dart
+final dartPosts = context.pagesForTerm('tags', 'dart');
+
+return ul([
+ for (final post in dartPosts)
+ li([a(href: post.url, [text(post.data.page['title'] as String)])]),
+]);
+```
+
+### taxonomyTermRefsWithCount
+
+Returns a map of term refs to their content page counts.
+
+```dart
+final tagCloud = context.taxonomyTermRefsWithCount('tags');
+for (final MapEntry(:key, :value) in tagCloud.entries) {
+ // key.term == 'dart', 'flutter', etc.
+ // key.url == '/tags/dart', '/tags/flutter', etc.
+ // value == number of content pages with this tag
+}
+```
+
+## Accessing Route Info
+
+Inside a route builder, use `context.currentRouteInfo()` to access the `TaxonomyTermRouteInfo` or `TaxonomyRouteInfo` for the current route:
+
+```dart
+termPageBuilder: (context, taxonomy, term) {
+ final info = context.currentRouteInfo();
+ // info.term == normalized term string
+ // info.url == route URL
+ // info.data == data from initialTermDataBuilder
+ return TagPage(term: term);
+},
+```
+
+## Properties
+
+
+
+ The frontmatter key to extract terms from (e.g., `'tags'`, `'categories'`).
+
+
+ ---
+
+
+ Builder function for individual term routes. Receives the `BuildContext`, the taxonomy name, and the normalized term string.
+
+ ```dart
+ termPageBuilder: (context, taxonomy, term) => TagPage(term: term),
+ ```
+
+
+ ---
+
+
+ Optional builder for the taxonomy index route. Receives the `BuildContext` and the taxonomy name. If not provided, no index route is generated.
+
+ ```dart
+ taxonomyPageBuilder: (context, taxonomy) => TagsPage(),
+ ```
+
+
+ ---
+
+
+ The URL slug for the taxonomy index route. Defaults to the taxonomy name.
+ For example, with `taxonomy: 'tags'` and `taxonomySlug: 'labels'`, the index route is generated at `/labels` instead of `/tags`.
+
+
+ ---
+
+
+ The URL slug for individual term routes. Defaults to the taxonomy name.
+ For example, with `taxonomy: 'tags'` and `termSlug: 'tag'`, term routes are generated at `/tag/dart` instead of `/tags/dart`.
+
+
+ ---
+
+
+ Optional function to provide additional data for each term route. Receives the taxonomy name and the normalized term string. The returned map is stored in `TaxonomyTermRouteInfo.data` and accessible via `context.currentRouteInfo()`.
+
+ ```dart
+ initialTermDataBuilder: (taxonomy, term) => {'title': 'Posts tagged: $term'},
+ ```
+
+
+ ---
+
+
+ Optional function to provide additional data for the taxonomy index route. Receives the taxonomy name. The returned map is stored in `TaxonomyRouteInfo.data` and accessible via `context.currentRouteInfo()`.
+
+ ```dart
+ taxonomyInitialDataBuilder: (taxonomy) => {'title': 'All $taxonomy'},
+ ```
+
+
+ ---
+
+
+ File extensions to scan for frontmatter. Defaults to `['.md', '.html']`.
+
+
+
+## URL Customization
+
+By default, generated routes use the taxonomy name as the URL prefix. Use `taxonomySlug` and `termSlug` to customize the URL structure:
+
+```dart
+TaxonomyAggregator(
+ taxonomy: 'tags',
+ taxonomySlug: 'labels', // Index route at /labels instead of /tags
+ termSlug: 'label', // Term routes at /label/dart instead of /tags/dart
+ // ...
+)
+```
+
+## Multiple Taxonomies
+
+You can use multiple `TaxonomyAggregator`s for different classification systems:
+
+```dart
+ContentApp.custom(
+ eagerlyLoadAllPages: true,
+ loaders: [FilesystemLoader('content')],
+ pagesAggregators: [
+ TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (context, taxonomy, term) { /* ... */ },
+ ),
+ TaxonomyAggregator(
+ taxonomy: 'categories',
+ termPageBuilder: (context, taxonomy, term) { /* ... */ },
+ ),
+ ],
+ // ...
+);
+```
+
+Each taxonomy operates independently — terms from one taxonomy do not interfere with another.
+
+## Full Example
+
+### TagsPage
+
+The `TagsPage` component renders the taxonomy index page at `/tags`. It uses `taxonomyTermRefsWithCount()` to display all tags as a tag cloud.
+
+```dart
+class TagsPage extends StatelessComponent {
+ @override
+ Component build(BuildContext context) {
+ final tagCloud = context.taxonomyTermRefsWithCount('tags');
+ return div(classes: 'tag-cloud', [
+ for (final MapEntry(:key, :value) in tagCloud.entries)
+ a(href: key.url, [text('${key.term} ($value)')]),
+ ]);
+ }
+}
+```
+
+### TagPage
+
+The `TagPage` component renders an individual term page at `/tags/{term}`. It uses `pagesForTerm()` to find all content pages with that tag.
+
+```dart
+class TagPage extends StatelessComponent {
+ const TagPage({required this.term});
+
+ final String term;
+
+ @override
+ Component build(BuildContext context) {
+ final posts = context.pagesForTerm('tags', term);
+ return div([
+ h1([text('Posts tagged: $term')]),
+ ul([
+ for (final post in posts)
+ li([a(href: post.url, [text(post.data.page['title'] as String)])]),
+ ]),
+ ]);
+ }
+}
+```
diff --git a/packages/jaspr_content/CHANGELOG.md b/packages/jaspr_content/CHANGELOG.md
index fdfeee1c3..133d0a2c3 100644
--- a/packages/jaspr_content/CHANGELOG.md
+++ b/packages/jaspr_content/CHANGELOG.md
@@ -1,3 +1,11 @@
+## Unreleased minor
+
+- Added `PagesAggregator` base class for generating additional routes from loaded pages.
+- Added `AggregatedRouteInfo` base class for route metadata produced by aggregators, accessible via `context.currentRouteInfo()` inside aggregator route builders.
+- Added `TaxonomyAggregator` — a `PagesAggregator` that generates taxonomy index and term routes from page frontmatter.
+- Added `TaxonomyRouteInfo` and `TaxonomyTermRouteInfo` data classes for taxonomy route metadata.
+- Added `TaxonomyContext` extension on `BuildContext` with helpers: `taxonomyTermRefs`, `taxonomyTermRef`, `taxonomyIndexRef`, `pagesForTerm`, and `taxonomyTermRefsWithCount`.
+
## 0.5.0
- New `AssetManager` class for handling content assets. Assets like images, videos, etc. can be co-located with the content and referenced by their relative path from a page.
diff --git a/packages/jaspr_content/README.md b/packages/jaspr_content/README.md
index be546975e..39cbe1163 100644
--- a/packages/jaspr_content/README.md
+++ b/packages/jaspr_content/README.md
@@ -33,6 +33,8 @@ It provides **out-of-the-box tools** for loading, parsing, and rendering content
- **🎨 Theming**: Use the built-in [theming system](https://docs.jaspr.site/content/concepts/theming) to customize the look and feel of your site. With light and dark mode out of the box.
+- **🗂️ Aggregation**: Use `PagesAggregator` to generate additional routes based on loaded pages. The built-in `TaxonomyAggregator` generates tag or category routes from page frontmatter.
+
> `jaspr_content` is fully compatible with "normal" Jaspr. You can use components and pages you already have in your app and mix them with content from `jaspr_content`.
## Documentation
diff --git a/packages/jaspr_content/lib/jaspr_content.dart b/packages/jaspr_content/lib/jaspr_content.dart
index 60f5a7bcf..13ad447d6 100644
--- a/packages/jaspr_content/lib/jaspr_content.dart
+++ b/packages/jaspr_content/lib/jaspr_content.dart
@@ -1,6 +1,7 @@
/// A package for building content-driven sites with Jaspr.
library;
+export 'src/aggregated_route.dart' hide InheritedAggregatedContext, InheritedAggregatedRoute;
export 'src/asset_manager.dart';
export 'src/content.dart';
export 'src/content_app.dart';
@@ -18,6 +19,8 @@ export 'src/page_extension/table_of_contents_extension.dart';
export 'src/page_parser/html_parser.dart';
export 'src/page_parser/markdown_parser.dart';
export 'src/page_parser/page_parser.dart';
+export 'src/pages_aggregator/pages_aggregator.dart';
+export 'src/pages_aggregator/taxonomy_aggregator.dart';
export 'src/route_loader/filesystem_loader.dart';
export 'src/route_loader/github_loader.dart';
export 'src/route_loader/memory_loader.dart';
@@ -25,6 +28,7 @@ export 'src/route_loader/route_loader.dart';
export 'src/secondary_output/markdown_output.dart';
export 'src/secondary_output/rss_output.dart';
export 'src/secondary_output/secondary_output.dart';
+export 'src/taxonomy.dart';
export 'src/template_engine/liquid_template_engine.dart';
export 'src/template_engine/mustache_template_engine.dart';
export 'src/template_engine/template_engine.dart';
diff --git a/packages/jaspr_content/lib/src/aggregated_route.dart b/packages/jaspr_content/lib/src/aggregated_route.dart
new file mode 100644
index 000000000..9bd25c1e5
--- /dev/null
+++ b/packages/jaspr_content/lib/src/aggregated_route.dart
@@ -0,0 +1,86 @@
+/// @docImport 'content_app.dart';
+/// @docImport 'pages_aggregator/pages_aggregator.dart';
+library;
+
+import 'package:jaspr/jaspr.dart';
+import 'package:meta/meta.dart';
+
+import 'page.dart';
+
+/// Base class for route information produced by a [PagesAggregator].
+///
+/// Aggregators produce [AggregatedRouteInfo] objects alongside [Route]s during
+/// [PagesAggregator.aggregatePages]. These are made available via [BuildContext]
+/// through [InheritedAggregatedContext] injected by [ContentApp].
+abstract class AggregatedRouteInfo {
+ const AggregatedRouteInfo({required this.url, this.data = const {}});
+
+ /// The URL of the aggregated route, e.g. `/tags/dart`.
+ final String url;
+
+ /// Additional metadata for this route, available to builders via context.
+ final Map data;
+}
+
+/// Inherited component injected by [ContentApp] into the whole component tree.
+///
+/// Provides global access to all content pages and all aggregated route infos.
+/// Use the [TaxonomyContext] extensions on [BuildContext] to access taxonomy data.
+@internal
+class InheritedAggregatedContext extends InheritedComponent {
+ const InheritedAggregatedContext({
+ required this.contentPages,
+ required this.routeInfos,
+ required super.child,
+ });
+
+ /// All content pages loaded by route loaders.
+ final List contentPages;
+
+ /// All aggregated route infos produced by all aggregators.
+ final List routeInfos;
+
+ static InheritedAggregatedContext? of(BuildContext context) =>
+ context.dependOnInheritedComponentOfExactType();
+
+ @override
+ bool updateShouldNotify(InheritedAggregatedContext oldComponent) =>
+ oldComponent.contentPages != contentPages || oldComponent.routeInfos != routeInfos;
+}
+
+/// Inherited component injected by [PagesAggregator] route builders.
+///
+/// Provides the current aggregated route's info within an individual route.
+/// Access via [BuildContext.currentRouteInfo].
+@internal
+class InheritedAggregatedRoute extends InheritedComponent {
+ const InheritedAggregatedRoute({
+ required this.routeInfo,
+ required super.child,
+ });
+
+ /// The route info for the current aggregated route.
+ final AggregatedRouteInfo routeInfo;
+
+ static InheritedAggregatedRoute? of(BuildContext context) =>
+ context.dependOnInheritedComponentOfExactType();
+
+ @override
+ bool updateShouldNotify(InheritedAggregatedRoute oldComponent) =>
+ oldComponent.routeInfo != routeInfo;
+}
+
+/// Context extensions for accessing aggregated route data.
+extension AggregatedRouteContext on BuildContext {
+ /// Returns the [AggregatedRouteInfo] for the current aggregated route cast to [T], or null.
+ ///
+ /// Available inside route builders created by [PagesAggregator]s.
+ ///
+ /// ```dart
+ /// final termRef = context.currentRouteInfo();
+ /// ```
+ T? currentRouteInfo() {
+ final info = InheritedAggregatedRoute.of(this)?.routeInfo;
+ return info is T ? info : null;
+ }
+}
diff --git a/packages/jaspr_content/lib/src/content_app.dart b/packages/jaspr_content/lib/src/content_app.dart
index 75910d1d8..3e187e8d6 100644
--- a/packages/jaspr_content/lib/src/content_app.dart
+++ b/packages/jaspr_content/lib/src/content_app.dart
@@ -7,11 +7,13 @@ import 'dart:async';
import 'package:jaspr/server.dart';
import 'package:jaspr_router/jaspr_router.dart' hide RouteLoader;
+import 'aggregated_route.dart';
import 'data_loader/filesystem_data_loader.dart';
import 'layouts/page_layout.dart';
import 'page.dart';
import 'page_extension/page_extension.dart';
import 'page_parser/page_parser.dart';
+import 'pages_aggregator/pages_aggregator.dart';
import 'route_loader/filesystem_loader.dart';
import 'route_loader/route_loader.dart';
import 'template_engine/template_engine.dart';
@@ -88,6 +90,14 @@ class ContentApp extends AsyncStatelessComponent {
/// The [ContentTheme] to use for the pages.
ContentTheme? theme,
+
+ /// A list of [PagesAggregator]s to use for generating additional routes based on the loaded pages.
+ ///
+ /// Aggregators run after all pages are loaded and receive the full list of loaded pages.
+ /// Requires [eagerlyLoadAllPages] to be `true`.
+ this.pagesAggregators = const [],
+
+ /// Whether to print debug information about the loaded routes and pages.
bool debugPrint = false,
}) : loaders = [FilesystemLoader(directory, debugPrint: debugPrint)],
configResolver = PageConfig.all(
@@ -102,6 +112,10 @@ class ContentApp extends AsyncStatelessComponent {
),
routerBuilder = _defaultRouterBuilder {
_overrideGlobalOptions();
+ assert(
+ pagesAggregators.isEmpty || eagerlyLoadAllPages,
+ 'eagerlyLoadAllPages must be true when using pagesAggregators.',
+ );
}
/// Creates a [ContentApp].
@@ -117,12 +131,22 @@ class ContentApp extends AsyncStatelessComponent {
/// Use [PageConfig.all] to resolve the same config for all pages.
this.configResolver = _defaultConfigResolver,
+ /// A list of [PagesAggregator]s to use for generating additional routes based on the loaded pages.
+ ///
+ /// Aggregators run after all pages are loaded and receive the full list of loaded pages.
+ /// Requires [eagerlyLoadAllPages] to be `true`.
+ this.pagesAggregators = const [],
+
/// A custom builder function to use for building the main [Router] component.
///
/// This can be used to customize the [Router] component like add additional routes or inserting components above the router.
this.routerBuilder = _defaultRouterBuilder,
}) {
_overrideGlobalOptions();
+ assert(
+ pagesAggregators.isEmpty || eagerlyLoadAllPages,
+ 'eagerlyLoadAllPages must be true when using pagesAggregators.',
+ );
}
void _overrideGlobalOptions() {
@@ -136,15 +160,43 @@ class ContentApp extends AsyncStatelessComponent {
final List loaders;
final bool eagerlyLoadAllPages;
final ConfigResolver configResolver;
+
+ /// Aggregators that produce additional routes based on loaded pages.
+ ///
+ /// Aggregators run after all [RouteLoader]s have loaded pages.
+ /// They receive the full list of loaded pages and return [Route]s.
+ final List pagesAggregators;
+
final Component Function(List> routes) routerBuilder;
@override
Future build(BuildContext context) async {
- final routes = await [
- for (final loader in loaders) loader.loadRoutes(configResolver, eagerlyLoadAllPages),
- ].wait;
- _ensureAllowedSuffixes(routes);
- return routerBuilder(routes);
+ // 1. Load all content routes from loaders in parallel.
+ final allRoutes = [
+ ...await [
+ for (final loader in loaders) loader.loadRoutes(configResolver, eagerlyLoadAllPages),
+ ].wait,
+ ];
+
+ // 2. Run aggregators — each receives the same pages from loaders and appends to the shared routeInfos list.
+ // Route builders capture this list via closure; since builders are invoked lazily (on navigation),
+ // the list is fully populated by the time any builder runs.
+ final pages = RouteLoader.loadedPages;
+ final allRouteInfos = [];
+ for (final aggregator in pagesAggregators) {
+ allRoutes.add(await aggregator.aggregatePages(pages, allRouteInfos));
+ }
+
+ // 3. Ensure all path suffixes used in the routes are allowed by Jaspr, and build the router.
+ _ensureAllowedSuffixes(allRoutes);
+
+ // 4. Build the router, wrapped with aggregated context so all routes
+ // can access content pages and route infos.
+ return InheritedAggregatedContext(
+ contentPages: pages,
+ routeInfos: allRouteInfos,
+ child: routerBuilder(allRoutes),
+ );
}
void _ensureAllowedSuffixes(List> routes) {
diff --git a/packages/jaspr_content/lib/src/page.dart b/packages/jaspr_content/lib/src/page.dart
index 2a2b6c3f7..ef26a44de 100644
--- a/packages/jaspr_content/lib/src/page.dart
+++ b/packages/jaspr_content/lib/src/page.dart
@@ -5,6 +5,7 @@ import 'package:fbh_front_matter/fbh_front_matter.dart' as fm;
import 'package:jaspr/server.dart';
import 'package:path/path.dart' as p;
+import 'aggregated_route.dart';
import 'content.dart';
import 'data_loader/data_loader.dart';
import 'layouts/page_layout.dart';
@@ -240,6 +241,7 @@ class PageConfig {
layouts: layouts,
theme: theme,
);
+
return (_) => config;
}
@@ -399,11 +401,13 @@ extension PageContext on BuildContext {
);
}
- final comp = dependOnInheritedComponentOfExactType<_InheritedPage>();
- if (comp == null) {
- throw StateError('No Page objects found in context. Make sure you are inside a page rendered by ContentApp.');
- }
- return comp.pages;
+ final pageComp = dependOnInheritedComponentOfExactType<_InheritedPage>();
+ if (pageComp != null) return pageComp.pages;
+
+ final aggComp = dependOnInheritedComponentOfExactType();
+ if (aggComp != null) return aggComp.contentPages;
+
+ throw StateError('No Page objects found in context. Make sure you are inside a page rendered by ContentApp.');
}
}
diff --git a/packages/jaspr_content/lib/src/pages_aggregator/pages_aggregator.dart b/packages/jaspr_content/lib/src/pages_aggregator/pages_aggregator.dart
new file mode 100644
index 000000000..203700eb4
--- /dev/null
+++ b/packages/jaspr_content/lib/src/pages_aggregator/pages_aggregator.dart
@@ -0,0 +1,35 @@
+/// Defines the interface for content aggregators.
+///
+/// Aggregators are called after all pages are loaded. They can analyze all pages
+/// (including their frontmatter) and produce additional routes.
+library;
+
+import 'package:jaspr_router/jaspr_router.dart' hide RouteLoader;
+
+import '../aggregated_route.dart';
+import '../page.dart';
+
+/// Base class for pages aggregators.
+///
+/// Aggregators analyze loaded pages and produce additional [Route]s.
+/// They run after all route loaders have loaded their pages.
+///
+/// Implement [aggregatePages] to return routes based on the loaded pages.
+/// The [routeInfos] list is shared across all aggregators — append your
+/// produced [AggregatedRouteInfo]s to it so route builders can capture
+/// the fully-populated list via closure.
+///
+/// [ContentApp] passes the same [routeInfos] list to all aggregators
+/// and finishes populating it before any route builder is invoked.
+abstract class PagesAggregator {
+ /// Produces routes based on the given [pages].
+ ///
+ /// Called by [ContentApp] after all pages are loaded.
+ /// The [pages] list is a read-only view of all loaded pages
+ /// from all route loaders.
+ ///
+ /// Append any produced [AggregatedRouteInfo]s to [routeInfos].
+ /// The same list is passed to every aggregator and is fully populated
+ /// before any route builder closure is invoked.
+ Future> aggregatePages(List pages, List routeInfos);
+}
diff --git a/packages/jaspr_content/lib/src/pages_aggregator/taxonomy_aggregator.dart b/packages/jaspr_content/lib/src/pages_aggregator/taxonomy_aggregator.dart
new file mode 100644
index 000000000..3c776f932
--- /dev/null
+++ b/packages/jaspr_content/lib/src/pages_aggregator/taxonomy_aggregator.dart
@@ -0,0 +1,160 @@
+import 'package:jaspr/jaspr.dart';
+import 'package:jaspr_router/jaspr_router.dart' hide RouteLoader;
+
+import '../aggregated_route.dart';
+import '../page.dart';
+import '../taxonomy.dart';
+import 'pages_aggregator.dart';
+
+/// Builder for an individual term route.
+///
+/// Receives the [BuildContext],
+/// the [taxonomy] name (e.g., `'tags'`),
+/// and the normalized [term] string (e.g., `'dart'`).
+typedef TaxonomyTermPageBuilder = Component Function(BuildContext context, String taxonomy, String term);
+
+/// Provides initial data for each term route.
+///
+/// Receives the [taxonomy] name and the normalized [term] string.
+/// The returned map is available via [TaxonomyTermRouteInfo.data].
+typedef TaxonomyTermInitialDataBuilder = Map Function(String taxonomy, String term);
+
+/// Builder for the taxonomy index route.
+///
+/// Receives the [BuildContext] and the [taxonomy] name.
+typedef TaxonomyPageBuilder = Component Function(BuildContext context, String taxonomy);
+
+/// Provides initial data for the taxonomy index route.
+///
+/// Receives the [taxonomy] name.
+/// The returned map is available via [TaxonomyRouteInfo.data].
+typedef TaxonomyInitialDataBuilder = Map Function(String taxonomy);
+
+/// A [PagesAggregator] that generates taxonomy and term routes from page frontmatter.
+///
+/// For each unique term found in loaded pages under the [taxonomy] frontmatter key,
+/// a route is generated using [termPageBuilder].
+/// Optionally, a taxonomy index route is generated using [taxonomyPageBuilder].
+///
+/// The generated routes and their metadata are accessible via [TaxonomyContext]
+/// extensions on [BuildContext].
+class TaxonomyAggregator extends PagesAggregator {
+ /// The frontmatter key to extract terms from
+ /// (e.g., `'tags'`, `'categories'`).
+ final String taxonomy;
+
+ /// The URL slug for the taxonomy index route.
+ ///
+ /// Defaults to [taxonomy].
+ /// For example, with `taxonomy: 'tags'` and `taxonomySlug: 'labels'`,
+ /// the index route is generated at `/labels` instead of `/tags`.
+ final String? taxonomySlug;
+
+ /// Builder for the taxonomy index route listing all terms.
+ final TaxonomyPageBuilder? taxonomyPageBuilder;
+
+ /// Provides initial data for the taxonomy index route.
+ final TaxonomyInitialDataBuilder? taxonomyInitialDataBuilder;
+
+ /// The URL slug for individual term routes.
+ ///
+ /// Defaults to [taxonomy].
+ /// For example, with `taxonomy: 'tags'` and `termSlug: 'tag'`,
+ /// term routes are generated at `/tag/dart` instead of `/tags/dart`.
+ final String? termSlug;
+
+ /// Builder for individual term routes.
+ final TaxonomyTermPageBuilder termPageBuilder;
+
+ /// Provides initial data for each term route.
+ final TaxonomyTermInitialDataBuilder? initialTermDataBuilder;
+
+ /// File extensions to scan for frontmatter.
+ ///
+ /// Defaults to `['.md', '.html']`.
+ final List supportedExtensions;
+ static const List _defaultSupportedExtensions = ['.md', '.html'];
+
+ TaxonomyAggregator({
+ required this.taxonomy,
+ required this.termPageBuilder,
+ this.taxonomySlug,
+ this.taxonomyPageBuilder,
+ this.taxonomyInitialDataBuilder,
+ this.termSlug,
+ this.initialTermDataBuilder,
+ this.supportedExtensions = _defaultSupportedExtensions,
+ });
+
+ @override
+ Future> aggregatePages(List pages, List routeInfos) async {
+ final allTerms = extractTaxonomyTerms(pages: pages, taxonomy: taxonomy);
+
+ final routes = [];
+
+ // Taxonomy index route (e.g., /tags)
+ if (taxonomyPageBuilder != null) {
+ final slug = TaxonomyUtils.normalize(taxonomySlug ?? taxonomy);
+ final url = '/$slug';
+ final data = taxonomyInitialDataBuilder?.call(taxonomy) ?? const {};
+ final ref = TaxonomyRouteInfo(taxonomy: taxonomy, url: url, data: data);
+ routeInfos.add(ref);
+ routes.add(Route(
+ path: url,
+ builder: (context, _) => InheritedAggregatedRoute(
+ routeInfo: ref,
+ child: taxonomyPageBuilder!(context, taxonomy),
+ ),
+ ));
+ }
+
+ // Individual term routes (e.g., /tags/dart)
+ for (final term in allTerms) {
+ final slug = TaxonomyUtils.normalize(termSlug ?? taxonomy);
+ final url = '/$slug/$term';
+ final data = initialTermDataBuilder?.call(taxonomy, term) ?? const {};
+ final ref = TaxonomyTermRouteInfo(taxonomy: taxonomy, term: term, url: url, data: data);
+ routeInfos.add(ref);
+ routes.add(Route(
+ path: url,
+ builder: (context, _) => InheritedAggregatedRoute(
+ routeInfo: ref,
+ child: termPageBuilder(context, taxonomy, term),
+ ),
+ ));
+ }
+
+ return routes;
+ }
+
+ /// Extracts all unique taxonomy terms from the provided [pages].
+ ///
+ /// Iterates through each page's data,
+ /// and collects all values from the [taxonomy] field.
+ /// Returns a set of normalized term strings.
+ @visibleForTesting
+ Set extractTaxonomyTerms({required List pages, required String taxonomy}) {
+ final terms = {};
+
+ for (final page in pages) {
+ final pageSuffix = page.path.split('.').last;
+
+ // Skip pages that don't match supported extensions.
+ if (!supportedExtensions.contains('.$pageSuffix')) {
+ continue;
+ }
+
+ final pageData = page.data.page;
+ final taxonomyList = pageData[taxonomy];
+
+ // If the taxonomy field is a list, extract terms.
+ if (taxonomyList is List) {
+ for (final term in taxonomyList) {
+ terms.add(TaxonomyUtils.normalize(term.toString()));
+ }
+ }
+ }
+
+ return terms;
+ }
+}
diff --git a/packages/jaspr_content/lib/src/route_loader/route_loader.dart b/packages/jaspr_content/lib/src/route_loader/route_loader.dart
index 674149342..8ac163deb 100644
--- a/packages/jaspr_content/lib/src/route_loader/route_loader.dart
+++ b/packages/jaspr_content/lib/src/route_loader/route_loader.dart
@@ -8,6 +8,7 @@ import 'dart:collection';
import 'package:jaspr/server.dart';
import 'package:jaspr_router/jaspr_router.dart';
+import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import '../page.dart';
@@ -40,7 +41,22 @@ abstract class RouteLoader {
/// - immediately in eager mode.
void invalidatePage(Page page);
+ /// All loaded pages across all loaders.
static final List _pages = [];
+
+ static final StreamController _pagesChanged = StreamController.broadcast();
+
+ /// Returns all currently loaded pages across all loaders.
+ ///
+ /// For internal use by [ContentApp] to pass pages to aggregators.
+ @internal
+ static List get loadedPages => UnmodifiableListView(_pages);
+
+ /// Stream that fires whenever pages are added or removed.
+ ///
+ /// For internal use by [PagesAggregator] to detect page changes.
+ @internal
+ static Stream get onPagesChanged => _pagesChanged.stream;
}
/// A base class for [RouteLoader] implementations.
@@ -102,6 +118,7 @@ abstract class RouteLoaderBase implements RouteLoader {
final sources = _sources ??= await loadPageSources();
final List routes = [];
+ final List> loadFutures = [];
for (final source in sources) {
if (source.path.isEmpty || source.private) {
continue;
@@ -111,7 +128,7 @@ abstract class RouteLoaderBase implements RouteLoader {
source.config = config;
if (_eager) {
- source.load();
+ loadFutures.add(source.load());
}
final pageBuilder = AsyncBuilder(builder: (_) => source.load());
@@ -129,6 +146,11 @@ abstract class RouteLoaderBase implements RouteLoader {
}
}
+ // In eager mode, wait for all pages to load so aggregators can access them.
+ if (_eager && loadFutures.isNotEmpty) {
+ await Future.wait(loadFutures);
+ }
+
if (debugPrint) {
_printRoutes(routes);
}
@@ -223,6 +245,7 @@ abstract class PageSource {
_page = newPage;
RouteLoader._pages.add(newPage);
+ RouteLoader._pagesChanged.add(null);
// Preserve original data to reapply
// after first specifying our provided data.
@@ -250,6 +273,7 @@ abstract class PageSource {
void invalidate({bool rebuild = true}) {
if (_page != null) {
RouteLoader._pages.remove(_page);
+ RouteLoader._pagesChanged.add(null);
_page = null;
}
_future = null;
diff --git a/packages/jaspr_content/lib/src/taxonomy.dart b/packages/jaspr_content/lib/src/taxonomy.dart
new file mode 100644
index 000000000..e71e382a8
--- /dev/null
+++ b/packages/jaspr_content/lib/src/taxonomy.dart
@@ -0,0 +1,195 @@
+import 'package:jaspr/server.dart';
+
+import 'aggregated_route.dart';
+import 'page.dart';
+
+/// A reference to a taxonomy index route generated by [TaxonomyAggregator].
+///
+/// Represents the index page for a taxonomy (e.g., `/tags`).
+/// For individual term routes, see [TaxonomyTermRouteInfo].
+class TaxonomyRouteInfo extends AggregatedRouteInfo {
+ const TaxonomyRouteInfo({
+ required this.taxonomy,
+ required super.url,
+ super.data = const {},
+ });
+
+ /// The taxonomy name, e.g. `'tags'`.
+ final String taxonomy;
+}
+
+/// A reference to a taxonomy term route generated by [TaxonomyAggregator].
+///
+/// Represents a page for a specific term (e.g., `/tags/dart`).
+///
+/// ```dart
+/// final tagRefs = context.taxonomyTermRefs('tags');
+/// for (final ref in tagRefs) {
+/// print('${ref.term}: ${ref.url}');
+/// // Output: dart: /tags/dart, flutter: /tags/flutter, ...
+/// }
+/// ```
+class TaxonomyTermRouteInfo extends TaxonomyRouteInfo {
+ const TaxonomyTermRouteInfo({
+ required super.taxonomy,
+ required this.term,
+ required super.url,
+ super.data = const {},
+ });
+
+ /// The normalized term name, e.g. `'dart'`.
+ final String term;
+}
+
+/// Context extensions to access taxonomy data
+/// in a component's build method.
+///
+/// These extensions require eager loading to be enabled
+/// and a [TaxonomyAggregator] to be configured in [ContentApp].
+///
+/// ```dart
+/// // Get all tag term refs.
+/// final tagRefs = context.taxonomyTermRefs('tags');
+///
+/// // Get a specific term ref.
+/// final dartRef = context.taxonomyTermRef('tags', 'dart');
+///
+/// // Get blog posts tagged "dart".
+/// final dartPosts = context.pagesForTerm('tags', 'dart');
+///
+/// // Build a tag cloud with counts.
+/// final tagCloud = context.taxonomyTermRefsWithCount('tags');
+/// for (final MapEntry(:key, :value) in tagCloud.entries) {
+/// print('${key.term}: $value posts');
+/// }
+/// ```
+extension TaxonomyContext on BuildContext {
+ List get _routeInfos => InheritedAggregatedContext.of(this)?.routeInfos ?? [];
+
+ /// Returns all term refs for the given [taxonomy].
+ ///
+ /// Each returned ref corresponds to a term route
+ /// created by a [TaxonomyAggregator].
+ /// The taxonomy index ref is excluded.
+ List taxonomyTermRefs(String taxonomy) {
+ if (kIsWeb) {
+ throw UnsupportedError(
+ 'context.taxonomyTermRefs() is not supported on the client and only allowed to be called on the server.\n'
+ 'Wrap the call with a !kIsWeb check or move it out of any client component.',
+ );
+ }
+
+ return _routeInfos.whereType().where((r) => r.taxonomy == taxonomy).toList();
+ }
+
+ /// Returns the term ref for the given [taxonomy] and [term],
+ /// or `null` if no such ref exists.
+ ///
+ /// The [term] is normalized before matching,
+ /// so both `'Dart'` and `'dart'` will find the same ref.
+ TaxonomyTermRouteInfo? taxonomyTermRef(String taxonomy, String term) {
+ if (kIsWeb) {
+ throw UnsupportedError(
+ 'context.taxonomyTermRef() is not supported on the client and only allowed to be called on the server.\n'
+ 'Wrap the call with a !kIsWeb check or move it out of any client component.',
+ );
+ }
+
+ final normalized = TaxonomyUtils.normalize(term);
+ return _routeInfos.whereType()
+ .where((r) => r.taxonomy == taxonomy && r.term == normalized)
+ .firstOrNull;
+ }
+
+ /// Returns the index ref for the given [taxonomy], or null.
+ TaxonomyRouteInfo? taxonomyIndexRef(String taxonomy) {
+ if (kIsWeb) {
+ throw UnsupportedError(
+ 'context.taxonomyIndexRef() is not supported on the client and only allowed to be called on the server.\n'
+ 'Wrap the call with a !kIsWeb check or move it out of any client component.',
+ );
+ }
+
+ return _routeInfos.whereType()
+ .where((r) => r.taxonomy == taxonomy && r is! TaxonomyTermRouteInfo)
+ .firstOrNull;
+ }
+
+ /// Returns all content pages that have the given [term]
+ /// in their frontmatter [taxonomy] field.
+ ///
+ /// Scans all pages for a list field matching [taxonomy]
+ /// that contains [term] (normalized before matching).
+ /// Taxonomy routes themselves are not included.
+ ///
+ /// For example, with frontmatter `tags: [Dart, Flutter]`,
+ /// calling `pagesForTerm('tags', 'dart')` would match this page.
+ List pagesForTerm(String taxonomy, String term) {
+ if (kIsWeb) {
+ throw UnsupportedError(
+ 'context.pagesForTerm() is not supported on the client and only allowed to be called on the server.\n'
+ 'Wrap the call with a !kIsWeb check or move it out of any client component.',
+ );
+ }
+
+ final normalizedTerm = TaxonomyUtils.normalize(term);
+
+ return pages.where((p) {
+ final terms = p.data.page[taxonomy];
+ if (terms is List) {
+ return terms.any((t) => TaxonomyUtils.normalize(t.toString()) == normalizedTerm);
+ }
+ return false;
+ }).toList();
+ }
+
+ /// Returns a map of term refs to their content page counts
+ /// for the given [taxonomy].
+ ///
+ /// Each key is a [TaxonomyRouteInfo] created by a [TaxonomyAggregator],
+ /// and each value is the number of content pages
+ /// that have that term in their frontmatter.
+ ///
+ /// ```dart
+ /// final tagCloud = context.taxonomyTermRefsWithCount('tags');
+ /// for (final MapEntry(:key, :value) in tagCloud.entries) {
+ /// // key.term is the term name (e.g., 'dart')
+ /// // key.url is the term route URL (e.g., '/tags/dart')
+ /// // value is the count of content pages with that term
+ /// }
+ /// ```
+ Map taxonomyTermRefsWithCount(String taxonomy) {
+ if (kIsWeb) {
+ throw UnsupportedError(
+ 'context.taxonomyTermRefsWithCount() is not supported on the client and only allowed to be called on the server.\n'
+ 'Wrap the call with a !kIsWeb check or move it out of any client component.',
+ );
+ }
+
+ final refs = taxonomyTermRefs(taxonomy);
+
+ final counts = {};
+ for (final p in pages) {
+ final terms = p.data.page[taxonomy];
+ if (terms is List) {
+ for (final t in terms) {
+ final normalized = TaxonomyUtils.normalize(t.toString());
+ counts[normalized] = (counts[normalized] ?? 0) + 1;
+ }
+ }
+ }
+
+ return {for (final ref in refs) ref: counts[ref.term] ?? 0};
+ }
+}
+
+/// Utility functions for taxonomy metadata.
+///
+/// Terms are normalized to lowercase with spaces replaced by dashes
+/// (e.g., `'My Tag'` becomes `'my-tag'`).
+abstract class TaxonomyUtils {
+ /// Normalizes a taxonomy term value to lowercase with dashes.
+ static String normalize(String value) {
+ return value.toLowerCase().replaceAll(' ', '-');
+ }
+}
diff --git a/packages/jaspr_content/pubspec.yaml b/packages/jaspr_content/pubspec.yaml
index 7e58c707b..29d46ba4c 100644
--- a/packages/jaspr_content/pubspec.yaml
+++ b/packages/jaspr_content/pubspec.yaml
@@ -30,6 +30,7 @@ dependencies:
jaspr_router: ^0.8.0
liquify: ^1.0.0
markdown: ^7.3.0
+ meta: ^1.15.0
mustache_template: ^2.0.2
path: ^1.9.1
shelf: ^1.4.2
@@ -40,6 +41,7 @@ dependencies:
yaml: ^3.1.3
dev_dependencies:
+ jaspr_test: ^0.22.0
mocktail: ^1.0.4
test: ^1.24.0
diff --git a/packages/jaspr_content/test/core/taxonomy_test.dart b/packages/jaspr_content/test/core/taxonomy_test.dart
new file mode 100644
index 000000000..68ee01721
--- /dev/null
+++ b/packages/jaspr_content/test/core/taxonomy_test.dart
@@ -0,0 +1,380 @@
+import 'package:jaspr/server.dart';
+import 'package:jaspr_content/jaspr_content.dart';
+import 'package:jaspr_content/src/aggregated_route.dart' show InheritedAggregatedContext;
+import 'package:jaspr_test/jaspr_test.dart';
+
+import '../utils.dart';
+
+Page _makePage({
+ String path = 'test.md',
+ String url = '/test',
+ Map initialData = const {},
+}) {
+ return Page(
+ path: path,
+ url: url,
+ content: '',
+ initialData: initialData,
+ config: PageConfig(),
+ loader: MockRouteLoader(),
+ );
+}
+
+void main() {
+ group('TaxonomyUtils', () {
+ group('normalize()', () {
+ test('lowercases the value', () {
+ expect(TaxonomyUtils.normalize('Dart'), equals('dart'));
+ expect(TaxonomyUtils.normalize('FLUTTER'), equals('flutter'));
+ });
+
+ test('replaces spaces with dashes', () {
+ expect(TaxonomyUtils.normalize('my tag'), equals('my-tag'));
+ expect(TaxonomyUtils.normalize('hello world foo'), equals('hello-world-foo'));
+ });
+
+ test('lowercases and replaces spaces', () {
+ expect(TaxonomyUtils.normalize('My Tag'), equals('my-tag'));
+ expect(TaxonomyUtils.normalize('Hello World'), equals('hello-world'));
+ });
+
+ test('returns already-normalized values unchanged', () {
+ expect(TaxonomyUtils.normalize('dart'), equals('dart'));
+ expect(TaxonomyUtils.normalize('my-tag'), equals('my-tag'));
+ });
+
+ test('handles empty string', () {
+ expect(TaxonomyUtils.normalize(''), equals(''));
+ });
+ });
+ });
+
+ group('InheritedAggregatedContext routeInfos', () {
+ late List routeInfos;
+
+ setUp(() {
+ routeInfos = [
+ const TaxonomyTermRouteInfo(taxonomy: 'tags', term: 'dart', url: '/tags/dart'),
+ const TaxonomyTermRouteInfo(taxonomy: 'tags', term: 'flutter', url: '/tags/flutter'),
+ const TaxonomyRouteInfo(taxonomy: 'tags', url: '/tags'),
+ const TaxonomyTermRouteInfo(taxonomy: 'categories', term: 'tutorials', url: '/categories/tutorials'),
+ ];
+ });
+
+ test('whereType filters to TaxonomyRouteInfo (including terms)', () {
+ final refs = routeInfos.whereType().toList();
+ expect(refs, hasLength(4));
+ });
+
+ test('whereType can be filtered by taxonomy', () {
+ final refs = routeInfos.whereType().where((r) => r.taxonomy == 'tags').toList();
+ expect(refs, hasLength(3));
+ });
+
+ test('whereType returns only term refs', () {
+ final refs = routeInfos.whereType().where((r) => r.taxonomy == 'tags').toList();
+ expect(refs, hasLength(2));
+ expect(refs.map((r) => r.url), unorderedEquals(['/tags/dart', '/tags/flutter']));
+ });
+ });
+
+ group('TaxonomyContext', () {
+ late List allPages;
+ late Page currentPage;
+ late List refs;
+
+ setUp(() {
+ allPages = [
+ _makePage(path: 'posts/hello.md', url: '/posts/hello', initialData: {
+ 'page': {
+ 'title': 'Hello',
+ 'tags': ['Dart', 'Flutter'],
+ },
+ }),
+ _makePage(path: 'posts/world.md', url: '/posts/world', initialData: {
+ 'page': {
+ 'title': 'World',
+ 'tags': ['Dart', 'Jaspr'],
+ },
+ }),
+ _makePage(path: 'about.md', url: '/about', initialData: {
+ 'page': {'title': 'About'},
+ }),
+ ];
+ currentPage = allPages.first;
+ refs = [
+ const TaxonomyTermRouteInfo(taxonomy: 'tags', term: 'dart', url: '/tags/dart'),
+ const TaxonomyTermRouteInfo(taxonomy: 'tags', term: 'flutter', url: '/tags/flutter'),
+ const TaxonomyRouteInfo(taxonomy: 'tags', url: '/tags'),
+ const TaxonomyTermRouteInfo(taxonomy: 'categories', term: 'tutorials', url: '/categories/tutorials'),
+ ];
+ });
+
+ group('taxonomyTermRefs()', () {
+ testComponents('returns only term refs for the given taxonomy', (tester) async {
+ late List result;
+ tester.pumpComponent(
+ InheritedAggregatedContext(
+ contentPages: allPages,
+ routeInfos: refs,
+ child: Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.taxonomyTermRefs('tags');
+ return Component.text('');
+ })),
+ ),
+ );
+
+ expect(result, hasLength(2));
+ expect(result.map((r) => r.url), unorderedEquals(['/tags/dart', '/tags/flutter']));
+ }, isClient: false);
+
+ testComponents('returns empty list when no context is present', (tester) async {
+ late List result;
+ tester.pumpComponent(
+ Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.taxonomyTermRefs('tags');
+ return Component.text('');
+ })),
+ );
+
+ expect(result, isEmpty);
+ }, isClient: false);
+
+ testComponents('returns empty list for non-existent taxonomy', (tester) async {
+ late List result;
+ tester.pumpComponent(
+ InheritedAggregatedContext(
+ contentPages: allPages,
+ routeInfos: refs,
+ child: Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.taxonomyTermRefs('nonexistent');
+ return Component.text('');
+ })),
+ ),
+ );
+
+ expect(result, isEmpty);
+ }, isClient: false);
+ });
+
+ group('taxonomyTermRef()', () {
+ testComponents('returns the matching term ref', (tester) async {
+ late TaxonomyRouteInfo? result;
+ tester.pumpComponent(
+ InheritedAggregatedContext(
+ contentPages: allPages,
+ routeInfos: refs,
+ child: Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.taxonomyTermRef('tags', 'dart');
+ return Component.text('');
+ })),
+ ),
+ );
+
+ expect(result, isNotNull);
+ expect(result!.url, equals('/tags/dart'));
+ }, isClient: false);
+
+ testComponents('normalizes the term for matching', (tester) async {
+ late TaxonomyRouteInfo? result;
+ tester.pumpComponent(
+ InheritedAggregatedContext(
+ contentPages: allPages,
+ routeInfos: refs,
+ child: Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.taxonomyTermRef('tags', 'Dart');
+ return Component.text('');
+ })),
+ ),
+ );
+
+ expect(result, isNotNull);
+ expect(result!.url, equals('/tags/dart'));
+ }, isClient: false);
+
+ testComponents('returns null for non-existent term', (tester) async {
+ late TaxonomyRouteInfo? result;
+ tester.pumpComponent(
+ InheritedAggregatedContext(
+ contentPages: allPages,
+ routeInfos: refs,
+ child: Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.taxonomyTermRef('tags', 'nonexistent');
+ return Component.text('');
+ })),
+ ),
+ );
+
+ expect(result, isNull);
+ }, isClient: false);
+ });
+
+ group('taxonomyIndexRef()', () {
+ testComponents('returns the index ref', (tester) async {
+ late TaxonomyRouteInfo? result;
+ tester.pumpComponent(
+ InheritedAggregatedContext(
+ contentPages: allPages,
+ routeInfos: refs,
+ child: Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.taxonomyIndexRef('tags');
+ return Component.text('');
+ })),
+ ),
+ );
+
+ expect(result, isNotNull);
+ expect(result!.url, equals('/tags'));
+ }, isClient: false);
+
+ testComponents('returns null when no index ref exists', (tester) async {
+ late TaxonomyRouteInfo? result;
+ tester.pumpComponent(
+ InheritedAggregatedContext(
+ contentPages: allPages,
+ routeInfos: refs,
+ child: Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.taxonomyIndexRef('categories');
+ return Component.text('');
+ })),
+ ),
+ );
+
+ expect(result, isNull);
+ }, isClient: false);
+ });
+
+ group('pagesForTerm()', () {
+ testComponents('returns content pages with the given term', (tester) async {
+ late List result;
+ tester.pumpComponent(
+ Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.pagesForTerm('tags', 'dart');
+ return Component.text('');
+ })),
+ );
+
+ expect(result, hasLength(2));
+ expect(result.map((p) => p.url), unorderedEquals(['/posts/hello', '/posts/world']));
+ }, isClient: false);
+
+ testComponents('normalizes the term for matching', (tester) async {
+ late List result;
+ tester.pumpComponent(
+ Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.pagesForTerm('tags', 'Dart');
+ return Component.text('');
+ })),
+ );
+
+ expect(result, hasLength(2));
+ }, isClient: false);
+
+ testComponents('returns only pages with the specific term', (tester) async {
+ late List result;
+ tester.pumpComponent(
+ Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.pagesForTerm('tags', 'flutter');
+ return Component.text('');
+ })),
+ );
+
+ expect(result, hasLength(1));
+ expect(result.first.url, equals('/posts/hello'));
+ }, isClient: false);
+
+ testComponents('returns empty list for non-existent term', (tester) async {
+ late List result;
+ tester.pumpComponent(
+ Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.pagesForTerm('tags', 'nonexistent');
+ return Component.text('');
+ })),
+ );
+
+ expect(result, isEmpty);
+ }, isClient: false);
+
+ testComponents('ignores pages where taxonomy field is not a list', (tester) async {
+ final pagesWithStringTag = [
+ ...allPages,
+ _makePage(path: 'posts/string-tag.md', url: '/posts/string-tag', initialData: {
+ 'page': {'title': 'String Tag', 'tags': 'dart'},
+ }),
+ ];
+
+ late List result;
+ tester.pumpComponent(
+ Page.wrap(currentPage, pagesWithStringTag, Builder(builder: (context) {
+ result = context.pagesForTerm('tags', 'dart');
+ return Component.text('');
+ })),
+ );
+
+ // Only the 2 original content pages with list tags, not the string one.
+ expect(result, hasLength(2));
+ expect(result.any((p) => p.url == '/posts/string-tag'), isFalse);
+ }, isClient: false);
+ });
+
+ group('taxonomyTermRefsWithCount()', () {
+ testComponents('returns term refs as keys with content page counts', (tester) async {
+ late Map result;
+ tester.pumpComponent(
+ InheritedAggregatedContext(
+ contentPages: allPages,
+ routeInfos: refs,
+ child: Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.taxonomyTermRefsWithCount('tags');
+ return Component.text('');
+ })),
+ ),
+ );
+
+ expect(result, hasLength(2));
+ final byUrl = {for (final e in result.entries) e.key.url: e.value};
+ expect(byUrl, equals({'/tags/dart': 2, '/tags/flutter': 1}));
+ }, isClient: false);
+
+ testComponents('returns 0 count for term with no matching content pages', (tester) async {
+ final extendedRefs = [
+ ...refs,
+ const TaxonomyTermRouteInfo(taxonomy: 'tags', term: 'rust', url: '/tags/rust'),
+ ];
+
+ late Map result;
+ tester.pumpComponent(
+ InheritedAggregatedContext(
+ contentPages: allPages,
+ routeInfos: extendedRefs,
+ child: Page.wrap(currentPage, allPages, Builder(builder: (context) {
+ result = context.taxonomyTermRefsWithCount('tags');
+ return Component.text('');
+ })),
+ ),
+ );
+
+ final rustEntry = result.entries.firstWhere((e) => e.key.url == '/tags/rust');
+ expect(rustEntry.value, equals(0));
+ }, isClient: false);
+ });
+
+ group('context.pages in aggregated routes', () {
+ testComponents('returns contentPages via InheritedAggregatedContext', (tester) async {
+ late List result;
+ tester.pumpComponent(
+ InheritedAggregatedContext(
+ contentPages: allPages,
+ routeInfos: refs,
+ child: Builder(builder: (context) {
+ result = context.pages;
+ return Component.text('');
+ }),
+ ),
+ );
+
+ expect(result, equals(allPages));
+ }, isClient: false);
+ });
+ });
+}
diff --git a/packages/jaspr_content/test/route_loaders/taxonomy_loader_test.dart b/packages/jaspr_content/test/route_loaders/taxonomy_loader_test.dart
new file mode 100644
index 000000000..1c8c79c33
--- /dev/null
+++ b/packages/jaspr_content/test/route_loaders/taxonomy_loader_test.dart
@@ -0,0 +1,241 @@
+import 'package:jaspr/jaspr.dart';
+import 'package:jaspr_content/jaspr_content.dart';
+import 'package:test/test.dart';
+
+import '../utils.dart';
+
+Page _makePageWithTags(List tags, {String? path}) {
+ return Page(
+ path: path ?? 'posts/test.md',
+ url: '/posts/test',
+ content: '',
+ initialData: {
+ 'page': {'tags': tags},
+ },
+ config: PageConfig(),
+ loader: MockRouteLoader(),
+ );
+}
+
+Page _makePage({String path = 'test.md', Map initialData = const {}}) {
+ return Page(
+ path: path,
+ url: '/test',
+ content: '',
+ initialData: initialData,
+ config: PageConfig(),
+ loader: MockRouteLoader(),
+ );
+}
+
+void main() {
+ group('TaxonomyAggregator', () {
+ group('extractTaxonomyTerms()', () {
+ test('extracts terms from loaded pages', () {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ );
+
+ final pages = [
+ _makePage(initialData: {
+ 'page': {'tags': ['dart', 'flutter']},
+ }),
+ _makePage(path: 'test2.md', initialData: {
+ 'page': {'tags': ['dart', 'jaspr']},
+ }),
+ ];
+
+ final terms = aggregator.extractTaxonomyTerms(pages: pages, taxonomy: 'tags');
+ expect(terms, unorderedEquals({'dart', 'flutter', 'jaspr'}));
+ });
+
+ test('normalizes terms to lowercase with dashes', () {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ );
+
+ final pages = [
+ _makePage(initialData: {
+ 'page': {'tags': ['My Tag', 'Hello World']},
+ }),
+ ];
+
+ final terms = aggregator.extractTaxonomyTerms(pages: pages, taxonomy: 'tags');
+ expect(terms, unorderedEquals({'my-tag', 'hello-world'}));
+ });
+
+ test('returns empty set when no pages have the taxonomy field', () {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ );
+
+ final pages = [
+ _makePage(initialData: {'page': {'title': 'No Tags'}}),
+ ];
+
+ final terms = aggregator.extractTaxonomyTerms(pages: pages, taxonomy: 'tags');
+ expect(terms, isEmpty);
+ });
+
+ test('deduplicates terms across pages', () {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ );
+
+ final pages = [
+ _makePage(initialData: {'page': {'tags': ['dart']}}),
+ _makePage(path: 'test2.md', initialData: {'page': {'tags': ['dart']}}),
+ ];
+
+ final terms = aggregator.extractTaxonomyTerms(pages: pages, taxonomy: 'tags');
+ expect(terms, equals({'dart'}));
+ });
+
+ test('ignores non-list taxonomy field values', () {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ );
+
+ final pages = [
+ _makePage(initialData: {'page': {'tags': 'just-a-string'}}),
+ ];
+
+ final terms = aggregator.extractTaxonomyTerms(pages: pages, taxonomy: 'tags');
+ expect(terms, isEmpty);
+ });
+
+ test('respects custom supportedExtensions', () {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ supportedExtensions: ['.md'],
+ );
+
+ final pages = [
+ _makePage(path: 'test.md', initialData: {'page': {'tags': ['from-md']}}),
+ _makePage(path: 'test.html', initialData: {'page': {'tags': ['from-html']}}),
+ ];
+
+ final terms = aggregator.extractTaxonomyTerms(pages: pages, taxonomy: 'tags');
+ expect(terms, contains('from-md'));
+ expect(terms, isNot(contains('from-html')));
+ });
+ });
+
+ group('aggregatePages()', () {
+ test('returns a Route for each term', () async {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ );
+
+ final pages = [_makePageWithTags(['dart', 'flutter'])];
+ final routes = await aggregator.aggregatePages(pages, []);
+
+ expect(routes, hasLength(2));
+ expect(routes.map((r) => r.path), unorderedEquals(['/tags/dart', '/tags/flutter']));
+ });
+
+ test('creates taxonomy index route when taxonomyPageBuilder is provided', () async {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ taxonomyPageBuilder: (_, _) => Component.text(''),
+ );
+
+ final pages = [_makePageWithTags(['dart'])];
+ final routes = await aggregator.aggregatePages(pages, []);
+
+ // 1 index + 1 term
+ expect(routes, hasLength(2));
+ expect(routes.any((r) => r.path == '/tags'), isTrue);
+ });
+
+ test('does not create index route when taxonomyPageBuilder is null', () async {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ );
+
+ final pages = [_makePageWithTags(['dart'])];
+ final routes = await aggregator.aggregatePages(pages, []);
+
+ expect(routes.any((r) => r.path == '/tags'), isFalse);
+ });
+
+ test('uses custom taxonomySlug for index route path', () async {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ taxonomySlug: 'labels',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ taxonomyPageBuilder: (_, _) => Component.text(''),
+ );
+
+ final pages = [_makePageWithTags(['dart'])];
+ final routes = await aggregator.aggregatePages(pages, []);
+
+ expect(routes.any((r) => r.path == '/labels'), isTrue);
+ expect(routes.any((r) => r.path == '/tags'), isFalse);
+ });
+
+ test('uses custom termSlug for term route paths', () async {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termSlug: 'label',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ );
+
+ final pages = [_makePageWithTags(['dart'])];
+ final routes = await aggregator.aggregatePages(pages, []);
+
+ expect(routes.any((r) => r.path == '/label/dart'), isTrue);
+ expect(routes.any((r) => r.path.startsWith('/tags')), isFalse);
+ });
+
+ test('creates no routes when no terms are found', () async {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ );
+
+ final routes = await aggregator.aggregatePages([], []);
+ expect(routes, isEmpty);
+ });
+
+ test('populates routeInfos after aggregatePages', () async {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ );
+
+ final pages = [_makePageWithTags(['dart', 'flutter'])];
+ final routeInfos = [];
+ await aggregator.aggregatePages(pages, routeInfos);
+
+ final infos = routeInfos.cast();
+ expect(infos, hasLength(2));
+ expect(infos.map((r) => r.term), unorderedEquals(['dart', 'flutter']));
+ });
+
+ test('includes custom data in routeInfos', () async {
+ final aggregator = TaxonomyAggregator(
+ taxonomy: 'tags',
+ termPageBuilder: (_, _, _) => Component.text(''),
+ initialTermDataBuilder: (_, term) => {'title': 'Tag: $term'},
+ );
+
+ final pages = [_makePageWithTags(['dart'])];
+ final routeInfos = [];
+ await aggregator.aggregatePages(pages, routeInfos);
+
+ final info = routeInfos.cast().firstWhere((r) => r.term == 'dart');
+ expect(info.data['title'], equals('Tag: dart'));
+ });
+ });
+ });
+}
diff --git a/pubspec.lock b/pubspec.lock
index c9b6ee97b..13b3546c0 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -237,10 +237,10 @@ packages:
dependency: transitive
description:
name: characters
- sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+ sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
- version: "1.4.1"
+ version: "1.4.0"
charcode:
dependency: transitive
description:
@@ -1107,18 +1107,18 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
+ sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
- version: "0.12.18"
+ version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+ sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
- version: "0.13.0"
+ version: "0.11.1"
melos:
dependency: "direct dev"
description:
@@ -1768,26 +1768,26 @@ packages:
dependency: transitive
description:
name: test
- sha256: "77cc98ea27006c84e71a7356cf3daf9ddbde2d91d84f77dbfe64cf0e4d9611ae"
+ sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
- version: "1.28.0"
+ version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
- sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
+ sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
- version: "0.7.8"
+ version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
- sha256: f1072617a6657e5fc09662e721307f7fb009b4ed89b19f47175d11d5254a62d4
+ sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
- version: "0.6.14"
+ version: "0.6.12"
timezone:
dependency: transitive
description: