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: