From 21c0e60bbad552efbe112aa67e08653696f39004 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 26 Jul 2026 00:01:19 +0100 Subject: [PATCH 1/5] feat: add stardust dartdoc to co-host API reference with unified search --- docs/features/dartdoc.md | 61 ++++++++++++++++ lib/src/cli/cli_runner.dart | 2 + lib/src/cli/commands/dartdoc_command.dart | 72 +++++++++++++++++++ lib/src/dartdoc/dartdoc_generator.dart | 86 ++++++++++++++++++++++ rnd/ROADMAP.md | 5 +- stardust.yaml | 2 + test/dartdoc/dartdoc_generator_test.dart | 87 +++++++++++++++++++++++ 7 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 docs/features/dartdoc.md create mode 100644 lib/src/cli/commands/dartdoc_command.dart create mode 100644 lib/src/dartdoc/dartdoc_generator.dart create mode 100644 test/dartdoc/dartdoc_generator_test.dart diff --git a/docs/features/dartdoc.md b/docs/features/dartdoc.md new file mode 100644 index 0000000..0343bfc --- /dev/null +++ b/docs/features/dartdoc.md @@ -0,0 +1,61 @@ +--- +title: Dart API Docs (dartdoc) +description: Co-host a Dart package's API reference inside your Stardust site — guides and API in one place, with unified search. +--- + +# Dart API Docs + +`stardust dartdoc` runs Dart's official [`dart doc`](https://dart.dev/tools/dart-doc) on a package and folds the generated API reference into your Stardust site: guides and API reference live under one domain, one deploy, and **one search box**. + +Every API page gets a Stardust top-bar linking back to your docs, and its content is indexed by the site's search — so a reader searching your docs finds API symbols alongside your guides. dartdoc renders the API bodies (accurate and always up to date with the language); Stardust owns the surrounding chrome and search. + +## Usage + +Run it from anywhere, pointing at the Dart package (defaults to the current directory): + +```bash +# Resolve the package's dependencies first +dart pub get + +# Generate the API docs into public/api/ +stardust dartdoc . -o public/api +``` + +Then build as usual — the `public/` directory is copied into your site and indexed automatically: + +```bash +stardust build +``` + +Your API reference is now live at `/api/`, searchable from the main search box. + + +`stardust dartdoc` is a generation step you run when your package's API changes (like `stardust openapi`), not on every build — `dart doc` takes a few seconds. Commit the generated `public/api/` or regenerate it in CI before `stardust build`. + + +## Wire up the navigation + +Add a link to your API docs in `stardust.yaml`: + +```yaml +nav: + - label: API + href: /api/ +``` + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `[package-path]` | `.` | Path to the Dart package to document | +| `-o`, `--output` | `public/api` | Where to write the API docs | +| `-c`, `--config` | `stardust.yaml` | Config file (used for the top-bar name + back-link) | + +## Requirements + +- The **Dart SDK** must be installed and on your `PATH` (`stardust dartdoc` shells out to `dart doc`). +- Run `dart pub get` in the package first so its dependencies resolve. + +## What's co-hosted vs. native + +The API page **bodies** keep dartdoc's own styling — Stardust adds the top-bar and unified search around them, rather than re-rendering the API content. This keeps the integration robust and zero-maintenance as the language and dartdoc evolve. Full Stardust-native styling of API bodies is a future enhancement. diff --git a/lib/src/cli/cli_runner.dart b/lib/src/cli/cli_runner.dart index d465405..ade1945 100644 --- a/lib/src/cli/cli_runner.dart +++ b/lib/src/cli/cli_runner.dart @@ -7,6 +7,7 @@ import '../version.dart'; import 'commands/build_command.dart'; import 'commands/check_command.dart'; import 'commands/clean_command.dart'; +import 'commands/dartdoc_command.dart'; import 'commands/dev_command.dart'; import 'commands/init_command.dart'; import 'commands/new_command.dart'; @@ -28,6 +29,7 @@ class StardustCliRunner extends CommandRunner { addCommand(NewCommand()); addCommand(CleanCommand()); addCommand(OpenApiCommand()); + addCommand(DartdocCommand()); argParser.addFlag( 'version', diff --git a/lib/src/cli/commands/dartdoc_command.dart b/lib/src/cli/commands/dartdoc_command.dart new file mode 100644 index 0000000..fa249fc --- /dev/null +++ b/lib/src/cli/commands/dartdoc_command.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart' as p; + +import '../../config/config_loader.dart'; +import '../../dartdoc/dartdoc_generator.dart'; +import '../../utils/logger.dart'; + +/// Import a Dart package's API reference (via `dart doc`) into the Stardust site. +class DartdocCommand extends Command { + @override + final name = 'dartdoc'; + + @override + final description = "Import a Dart package's API docs (via `dart doc`) into your Stardust site"; + + DartdocCommand() { + argParser + ..addOption('output', abbr: 'o', help: 'Output directory for the API docs', defaultsTo: 'public/api') + ..addOption('config', abbr: 'c', help: 'Path to stardust.yaml', defaultsTo: 'stardust.yaml') + ..addFlag('verbose', abbr: 'v', help: 'Verbose output', negatable: false); + } + + @override + Future run() async { + final args = argResults; + if (args == null) return 1; + + final packagePath = args.rest.isNotEmpty ? args.rest.first : '.'; + final outputDir = args['output'] as String; + final configPath = args['config'] as String; + + if (!File(configPath).existsSync()) { + stderr.writeln('❌ Config file not found: $configPath'); + return 1; + } + if (!File(p.join(packagePath, 'pubspec.yaml')).existsSync()) { + stderr.writeln('❌ Not a Dart package (no pubspec.yaml found in "$packagePath")'); + return 1; + } + + final logger = Logger(onLog: stdout.writeln, onError: stderr.writeln); + + try { + final config = await ConfigLoader.load(configPath, logger: logger); + final pages = await DartdocGenerator( + packagePath: packagePath, + outputDir: outputDir, + config: config, + logger: logger, + ).generate(); + + if (pages == 0) { + stderr.writeln('❌ No API pages generated'); + return 1; + } + + stdout.writeln(''); + stdout.writeln('📁 Output: ${p.absolute(outputDir)}'); + stdout.writeln(''); + stdout.writeln('Next steps:'); + stdout.writeln(' 1. Add a nav/sidebar link to your API docs (mounted at /api/ when output is public/api)'); + stdout.writeln(' 2. Run `stardust build` — the pages are copied into the site and indexed for search'); + stdout.writeln(''); + return 0; + } catch (e) { + stderr.writeln('❌ dartdoc import failed: $e'); + return 1; + } + } +} diff --git a/lib/src/dartdoc/dartdoc_generator.dart b/lib/src/dartdoc/dartdoc_generator.dart new file mode 100644 index 0000000..b39657d --- /dev/null +++ b/lib/src/dartdoc/dartdoc_generator.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../config/config.dart'; +import '../core/file_system.dart'; +import '../utils/exceptions.dart'; +import '../utils/html_utils.dart'; +import '../utils/logger.dart'; + +/// Runs `dart doc` on a package and co-hosts the API reference inside a Stardust +/// site: every page gets a self-contained Stardust top-bar, and dartdoc's +/// `
` content is tagged with `data-pagefind-body` so the site's search +/// indexes the API pages (unified search) once the output is copied into the +/// build (default `public/api/`). +class DartdocGenerator { + final String packagePath; + final String outputDir; + final StardustConfig config; + final Logger logger; + final FileSystem fileSystem; + + DartdocGenerator({ + required this.packagePath, + required this.outputDir, + required this.config, + this.logger = const Logger(), + FileSystem? fileSystem, + }) : fileSystem = fileSystem ?? const LocalFileSystem(); + + /// Generates the API docs and returns the number of HTML pages written. + Future generate() async { + final tmp = await Directory.systemTemp.createTemp('stardust-dartdoc-'); + try { + logger.log('📚 Running `dart doc` on $packagePath'); + final ProcessResult result; + try { + result = await Process.run('dart', ['doc', '--output', tmp.path, '.'], workingDirectory: packagePath); + } on ProcessException { + throw const ContentException('`dart doc` is unavailable — is the Dart SDK installed and on your PATH?'); + } + if (result.exitCode != 0) { + throw ContentException('`dart doc` failed. Run `dart pub get` in the package first.\n${result.stderr}'); + } + + final topBar = buildTopBar(config.name, config.url ?? '/'); + var pages = 0; + await for (final entity in fileSystem.listDirectory(tmp.path, recursive: true)) { + if (entity is! File) continue; + final dest = p.join(outputDir, p.relative(entity.path, from: tmp.path)); + if (entity.path.endsWith('.html')) { + await fileSystem.writeFile(dest, injectChrome(await fileSystem.readFile(entity.path), topBar)); + pages++; + } else { + await fileSystem.copyFile(entity.path, dest); + } + } + logger.log(' ✓ Wrote $pages API pages to $outputDir'); + return pages; + } finally { + await tmp.delete(recursive: true); + } + } + + /// Inserts [topBar] right after the `` tag and tags dartdoc's `
` + /// content region with `data-pagefind-body`. Both edits are no-ops when the + /// target isn't present (e.g. dartdoc's search/404 pages). + static String injectChrome(String html, String topBar) { + final withBar = html.replaceFirstMapped(RegExp(']*>'), (m) => '${m[0]}$topBar'); + return withBar.replaceFirst('
', '
'); + } + + /// A self-contained (scoped-CSS) Stardust top-bar linking back to the docs + /// home, safe to inject into dartdoc's independently-styled pages. + static String buildTopBar(String name, String homeUrl) => '
' + '' + '${encodeHtml(name)} · API reference' + '← Back to docs' + '
'; +} diff --git a/rnd/ROADMAP.md b/rnd/ROADMAP.md index 4c4b19e..f7f912a 100644 --- a/rnd/ROADMAP.md +++ b/rnd/ROADMAP.md @@ -35,6 +35,7 @@ | LLM-friendly output | `llms.txt` index + `llms-full.txt` full content + per-page `.md` twin + "Copy page as Markdown" button; `llm: false` frontmatter opts a page out | | Official GitHub Action | Composite action: checksum-verified install, `stardust check` + build, outputs the built dir; PR-preview recipes for Netlify/Vercel/Cloudflare Pages. Ships with the `v0.7.0` release | | DartPad embeds | `` runnable Dart/Flutter snippets from a gist — lazy-loaded `dartpad.dev/?id=…&theme=…` iframe (v0.8 item 3) | +| dartdoc integration | `stardust dartdoc` runs `dart doc` and co-hosts the API reference under `/api/` with a Stardust top-bar; content tagged `data-pagefind-body` so the site search returns API symbols (unified search). Bodies stay dartdoc-styled (v0.8 item 2, phase 1) | ### ⚠️ Have on paper — partial, broken, or documented-but-unimplemented | Feature | Reality | @@ -172,7 +173,9 @@ **Exit criteria**: a Flutter package can replace docs.page + dartdoc with one `stardust` command; a Claude/Cursor user can connect to any Stardust site's docs in two clicks without the site owner running a service. -> **Status (2026-07-25)**: v0.8 opened with item 3 (**DartPad embeds**) — `` added to the existing `EmbedBuilder` alongside Zapp, rendering a lazy-loaded `dartpad.dev/?id=…&theme=…` iframe from a gist, with query-escaped inputs. Uses DartPad's *current* gist-embed URL (the older `embed-*.html` pages are deprecated per the official embedding guide; mode is auto-detected). Items 1, 2, 4–7 remain. +> **Status (2026-07-25)**: v0.8 opened with item 3 (**DartPad embeds**) — `` added to the existing `EmbedBuilder` alongside Zapp, rendering a lazy-loaded `dartpad.dev/?id=…&theme=…` iframe from a gist, with query-escaped inputs. Uses DartPad's *current* gist-embed URL (the older `embed-*.html` pages are deprecated per the official embedding guide; mode is auto-detected). +> +> Then item 2 (**dartdoc integration**, phase 1) — `stardust dartdoc` runs `dart doc`, injects a self-contained Stardust top-bar and `data-pagefind-body` into each page via a single HTML post-pass (no scraping, no `dartdoc_options.yaml` intrusion), and writes to `public/api/`. The normal build copies it to `/api/` and Pagefind indexes it, so the site's search returns API symbols — **unified search verified end-to-end** (9 fragments incl. API pages on a fixture). Bodies stay dartdoc-styled; full native styling + auto-nav are deferred follow-ups. **Approach chosen because dartdoc has no clean machine-readable content export or stable library API** (verified against the official guide) — co-hosting dartdoc's real output is the robust path. Items 1, 4–7 remain. ### v1.0 — "Platform" (commit to stability) · when the above is real - **Compatibility promise**: stable config schema (migrations for breaking changes), stable CSS tokens, stable component syntax. diff --git a/stardust.yaml b/stardust.yaml index 4be7a66..2509de8 100644 --- a/stardust.yaml +++ b/stardust.yaml @@ -84,6 +84,8 @@ sidebar: label: Dark Mode - slug: features/openapi label: OpenAPI Import + - slug: features/dartdoc + label: Dart API Docs - slug: features/llm-output label: LLM-Friendly Output - slug: features/redirects diff --git a/test/dartdoc/dartdoc_generator_test.dart b/test/dartdoc/dartdoc_generator_test.dart new file mode 100644 index 0000000..61360b1 --- /dev/null +++ b/test/dartdoc/dartdoc_generator_test.dart @@ -0,0 +1,87 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:stardust/src/config/config.dart'; +import 'package:stardust/src/dartdoc/dartdoc_generator.dart'; +import 'package:stardust/src/utils/logger.dart'; +import 'package:test/test.dart'; + +void main() { + group('DartdocGenerator.buildTopBar', () { + test('renders the site name and a back-to-docs link', () { + final bar = DartdocGenerator.buildTopBar('My Docs', 'https://example.com'); + + expect(bar, contains('class="sd-apibar"')); + expect(bar, contains('My Docs · API reference')); + expect(bar, contains('href="https://example.com">← Back to docs')); + }); + + test('escapes the name and url', () { + final bar = DartdocGenerator.buildTopBar('A & B ', 'https://e.com/"'); + + expect(bar, contains('A & B <x>')); + expect(bar, isNot(contains('A & B '))); + expect(bar, contains('https://e.com/"')); + }); + }); + + group('DartdocGenerator.injectChrome', () { + const bar = '
BAR
'; + + test('inserts the top-bar right after (with attributes)', () { + final out = DartdocGenerator.injectChrome('X', bar); + + expect(out, contains('
BAR
')); + }); + + test('tags dartdoc
for pagefind', () { + final out = DartdocGenerator.injectChrome('
content
', bar); + + expect(out, contains('
content
')); + }); + + test('no-ops the pagefind tag on pages without
', () { + final out = DartdocGenerator.injectChrome('
no main
', bar); + + expect(out, isNot(contains('data-pagefind-body'))); + expect(out, contains('BAR')); + }); + }); + + group('DartdocGenerator.generate (real dart doc)', () { + test('produces API pages with the Stardust top-bar and pagefind body', () async { + final pkg = await Directory.systemTemp.createTemp('stardust-dd-'); + final out = await Directory.systemTemp.createTemp('stardust-dd-out-'); + Future write(String rel, String content) async { + final file = File(p.join(pkg.path, rel)); + await file.parent.create(recursive: true); + await file.writeAsString(content); + } + + try { + await write('pubspec.yaml', 'name: fixture_pkg\nenvironment:\n sdk: ^3.0.0\n'); + await write( + 'lib/fixture_pkg.dart', '/// A greeter.\nclass Greeter {\n /// Greet.\n String greet() => "hi";\n}\n'); + await Process.run('dart', ['pub', 'get'], workingDirectory: pkg.path); + + const config = StardustConfig(name: 'Fixture', url: 'https://x.dev'); + final pages = await DartdocGenerator( + packagePath: pkg.path, + outputDir: out.path, + config: config, + logger: Logger(onLog: (_) {}), + ).generate(); + + expect(pages, greaterThan(0)); + final classPage = File(p.join(out.path, 'fixture_pkg', 'Greeter-class.html')); + expect(classPage.existsSync(), isTrue); + final html = classPage.readAsStringSync(); + expect(html, contains('class="sd-apibar"')); + expect(html, contains('
')); + } finally { + await pkg.delete(recursive: true); + await out.delete(recursive: true); + } + }, timeout: const Timeout(Duration(minutes: 2))); + }); +} From 7ec5d8c66d50ca4cceac67ad367dc6d0b63c5102 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 26 Jul 2026 09:23:49 +0100 Subject: [PATCH 2/5] fix: search on dart doc pages --- lib/src/dartdoc/dartdoc_generator.dart | 10 ++++++---- test/dartdoc/dartdoc_generator_test.dart | 15 +++++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/lib/src/dartdoc/dartdoc_generator.dart b/lib/src/dartdoc/dartdoc_generator.dart index b39657d..ff5b30e 100644 --- a/lib/src/dartdoc/dartdoc_generator.dart +++ b/lib/src/dartdoc/dartdoc_generator.dart @@ -62,12 +62,14 @@ class DartdocGenerator { } } - /// Inserts [topBar] right after the `` tag and tags dartdoc's `
` - /// content region with `data-pagefind-body`. Both edits are no-ops when the - /// target isn't present (e.g. dartdoc's search/404 pages). + /// Inserts [topBar] right after the `` tag and marks dartdoc's content + /// column (`#dartdoc-main-content`) with `data-pagefind-body` so search + /// indexes the API prose only — dartdoc's `
` also wraps the left/right + /// nav sidebars, which would otherwise pollute every page's search entry. + /// Both edits are no-ops when the target isn't present (search/404 pages). static String injectChrome(String html, String topBar) { final withBar = html.replaceFirstMapped(RegExp(']*>'), (m) => '${m[0]}$topBar'); - return withBar.replaceFirst('
', '
'); + return withBar.replaceFirst('id="dartdoc-main-content"', 'id="dartdoc-main-content" data-pagefind-body'); } /// A self-contained (scoped-CSS) Stardust top-bar linking back to the docs diff --git a/test/dartdoc/dartdoc_generator_test.dart b/test/dartdoc/dartdoc_generator_test.dart index 61360b1..43db56c 100644 --- a/test/dartdoc/dartdoc_generator_test.dart +++ b/test/dartdoc/dartdoc_generator_test.dart @@ -34,14 +34,17 @@ void main() { expect(out, contains('
BAR
')); }); - test('tags dartdoc
for pagefind', () { - final out = DartdocGenerator.injectChrome('
content
', bar); + test('tags dartdoc content column (not the sidebar-wrapping
)', () { + const page = '
nav
' + '
content
'; + final out = DartdocGenerator.injectChrome(page, bar); - expect(out, contains('
content
')); + expect(out, contains('id="dartdoc-main-content" data-pagefind-body')); + expect(out, isNot(contains('
', () { - final out = DartdocGenerator.injectChrome('
no main
', bar); + test('no-ops the pagefind tag on pages without the content column', () { + final out = DartdocGenerator.injectChrome('
search page
', bar); expect(out, isNot(contains('data-pagefind-body'))); expect(out, contains('BAR')); @@ -77,7 +80,7 @@ void main() { expect(classPage.existsSync(), isTrue); final html = classPage.readAsStringSync(); expect(html, contains('class="sd-apibar"')); - expect(html, contains('
')); + expect(html, contains('id="dartdoc-main-content" data-pagefind-body')); } finally { await pkg.delete(recursive: true); await out.delete(recursive: true); From 217f0fc6d5cd553611bfaa5638182ed1a10d1e8f Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 26 Jul 2026 10:30:15 +0100 Subject: [PATCH 3/5] fix: correctness gap on stardust dartdoc --- lib/src/dartdoc/dartdoc_generator.dart | 10 +++++++++- test/dartdoc/dartdoc_generator_test.dart | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/src/dartdoc/dartdoc_generator.dart b/lib/src/dartdoc/dartdoc_generator.dart index ff5b30e..cc8648e 100644 --- a/lib/src/dartdoc/dartdoc_generator.dart +++ b/lib/src/dartdoc/dartdoc_generator.dart @@ -43,7 +43,10 @@ class DartdocGenerator { throw ContentException('`dart doc` failed. Run `dart pub get` in the package first.\n${result.stderr}'); } - final topBar = buildTopBar(config.name, config.url ?? '/'); + if (await fileSystem.directoryExists(outputDir)) { + await fileSystem.deleteDirectory(outputDir, recursive: true); + } + final topBar = buildTopBar(config.name, homeHref(config)); var pages = 0; await for (final entity in fileSystem.listDirectory(tmp.path, recursive: true)) { if (entity is! File) continue; @@ -72,6 +75,11 @@ class DartdocGenerator { return withBar.replaceFirst('id="dartdoc-main-content"', 'id="dartdoc-main-content" data-pagefind-body'); } + /// Root-relative docs home for the "Back to docs" link — respects a subpath + /// deploy (`build.basePath` or a path in `url`) and stays host-relative so it + /// works in local preview and production alike. + static String homeHref(StardustConfig config) => config.basePath.isEmpty ? '/' : '${config.basePath}/'; + /// A self-contained (scoped-CSS) Stardust top-bar linking back to the docs /// home, safe to inject into dartdoc's independently-styled pages. static String buildTopBar(String name, String homeUrl) => '
' diff --git a/test/dartdoc/dartdoc_generator_test.dart b/test/dartdoc/dartdoc_generator_test.dart index 43db56c..f604caa 100644 --- a/test/dartdoc/dartdoc_generator_test.dart +++ b/test/dartdoc/dartdoc_generator_test.dart @@ -25,6 +25,22 @@ void main() { }); }); + group('DartdocGenerator.homeHref', () { + test('is / for a root deploy', () { + expect(DartdocGenerator.homeHref(const StardustConfig(name: 'X')), '/'); + }); + + test('respects a subpath from build.basePath', () { + const config = StardustConfig(name: 'X', build: BuildConfig(basePath: '/docs')); + expect(DartdocGenerator.homeHref(config), '/docs/'); + }); + + test('respects a subpath extracted from url', () { + const config = StardustConfig(name: 'X', url: 'https://acme.github.io/pkg'); + expect(DartdocGenerator.homeHref(config), '/pkg/'); + }); + }); + group('DartdocGenerator.injectChrome', () { const bar = '
BAR
'; @@ -67,6 +83,9 @@ void main() { 'lib/fixture_pkg.dart', '/// A greeter.\nclass Greeter {\n /// Greet.\n String greet() => "hi";\n}\n'); await Process.run('dart', ['pub', 'get'], workingDirectory: pkg.path); + final stale = File(p.join(out.path, 'stale.html')); + await stale.writeAsString('from a previous run'); + const config = StardustConfig(name: 'Fixture', url: 'https://x.dev'); final pages = await DartdocGenerator( packagePath: pkg.path, @@ -76,6 +95,7 @@ void main() { ).generate(); expect(pages, greaterThan(0)); + expect(stale.existsSync(), isFalse, reason: 'output dir should be cleared before writing'); final classPage = File(p.join(out.path, 'fixture_pkg', 'Greeter-class.html')); expect(classPage.existsSync(), isTrue); final html = classPage.readAsStringSync(); From e8e8185c67135e64c84037b7a756d11d0dceadbd Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Sun, 26 Jul 2026 14:59:07 +0100 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20dartdoc=20=E2=80=94=20theme-synced?= =?UTF-8?q?=20top-bar,=20content-only=20search,=20subpath=20home,=20clean?= =?UTF-8?q?=20rebuilds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/src/dartdoc/dartdoc_generator.dart | 104 ++++++++++++++++++----- test/dartdoc/dartdoc_generator_test.dart | 55 ++++++++++-- 2 files changed, 130 insertions(+), 29 deletions(-) diff --git a/lib/src/dartdoc/dartdoc_generator.dart b/lib/src/dartdoc/dartdoc_generator.dart index cc8648e..fe2ae9b 100644 --- a/lib/src/dartdoc/dartdoc_generator.dart +++ b/lib/src/dartdoc/dartdoc_generator.dart @@ -46,7 +46,13 @@ class DartdocGenerator { if (await fileSystem.directoryExists(outputDir)) { await fileSystem.deleteDirectory(outputDir, recursive: true); } - final topBar = buildTopBar(config.name, homeHref(config)); + final topBar = buildTopBar( + name: config.name, + homeUrl: homeHref(config), + primaryColor: config.theme.colors.primary, + logoLight: _prefixAsset(config.logo?.effectiveLight, config.basePath), + logoDark: _prefixAsset(config.logo?.effectiveDark, config.basePath), + ); var pages = 0; await for (final entity in fileSystem.listDirectory(tmp.path, recursive: true)) { if (entity is! File) continue; @@ -65,32 +71,88 @@ class DartdocGenerator { } } - /// Inserts [topBar] right after the `` tag and marks dartdoc's content - /// column (`#dartdoc-main-content`) with `data-pagefind-body` so search - /// indexes the API prose only — dartdoc's `
` also wraps the left/right - /// nav sidebars, which would otherwise pollute every page's search entry. - /// Both edits are no-ops when the target isn't present (search/404 pages). + /// Right after ``, injects a theme-sync script (mirrors the site's saved + /// theme onto dartdoc's, two-way) and the [topBar]; and marks dartdoc's content + /// column (`#dartdoc-main-content`) with `data-pagefind-body` so search indexes + /// the API prose only — dartdoc's `
` also wraps the left/right nav + /// sidebars. Each edit is a no-op when its target is absent (search/404 pages). static String injectChrome(String html, String topBar) { - final withBar = html.replaceFirstMapped(RegExp(']*>'), (m) => '${m[0]}$topBar'); - return withBar.replaceFirst('id="dartdoc-main-content"', 'id="dartdoc-main-content" data-pagefind-body'); + final chrome = '$_themeSync$topBar'; + final withChrome = html.replaceFirstMapped(RegExp(']*>'), (m) => '${m[0]}$chrome'); + return withChrome.replaceFirst('id="dartdoc-main-content"', 'id="dartdoc-main-content" data-pagefind-body'); } + /// Runs before dartdoc's own theme init: reads the site's saved theme + /// (`localStorage['theme']`), applies the matching dartdoc theme immediately + /// (no flash) and stores dartdoc's `colorTheme`, then observes dartdoc's own + /// toggle to write the site key back — so the two themes stay in lockstep. + static const _themeSync = ''; + /// Root-relative docs home for the "Back to docs" link — respects a subpath /// deploy (`build.basePath` or a path in `url`) and stays host-relative so it /// works in local preview and production alike. static String homeHref(StardustConfig config) => config.basePath.isEmpty ? '/' : '${config.basePath}/'; - /// A self-contained (scoped-CSS) Stardust top-bar linking back to the docs - /// home, safe to inject into dartdoc's independently-styled pages. - static String buildTopBar(String name, String homeUrl) => '
' - '' - '${encodeHtml(name)} · API reference' - '← Back to docs' - '
'; + /// A self-contained, theme-aware Stardust top-bar (logo + name + API badge, + /// linking home) safe to inject into dartdoc's independently-styled pages. + /// It is static, not sticky, so it never collides with dartdoc's + /// fixed-on-scroll header, and it adapts to dartdoc's `light-theme`/ + /// `dark-theme` body class. [primaryColor] is sanitized for CSS. + static String buildTopBar({ + required String name, + required String homeUrl, + required String primaryColor, + String? logoLight, + String? logoDark, + }) { + final accent = _safeColor(primaryColor, '#6366f1'); + final home = encodeHtmlAttribute(homeUrl); + return '
' + '' + '${_logoImages(logoLight, logoDark)}' + '${encodeHtml(name)}API' + '← Back to docs' + '
'; + } + + static String _logoImages(String? light, String? dark) { + if (light != null && dark != null && light != dark) { + return '' + ''; + } + if (light != null) return ''; + return ''; + } + + static String? _prefixAsset(String? path, String basePath) => + path == null ? null : (path.startsWith('/') ? '$basePath$path' : path); + + /// Accepts only a hex color or a plain named color, else [fallback] — so a + /// hostile `theme.colors.primary` can't break out of the CSS value. + static String _safeColor(String value, String fallback) { + final v = value.trim(); + return RegExp(r'^#[0-9a-fA-F]{3,8}$').hasMatch(v) || RegExp(r'^[a-zA-Z]+$').hasMatch(v) ? v : fallback; + } } diff --git a/test/dartdoc/dartdoc_generator_test.dart b/test/dartdoc/dartdoc_generator_test.dart index f604caa..8a625d3 100644 --- a/test/dartdoc/dartdoc_generator_test.dart +++ b/test/dartdoc/dartdoc_generator_test.dart @@ -8,20 +8,50 @@ import 'package:test/test.dart'; void main() { group('DartdocGenerator.buildTopBar', () { - test('renders the site name and a back-to-docs link', () { - final bar = DartdocGenerator.buildTopBar('My Docs', 'https://example.com'); + test('renders name, API badge, back-to-docs link, and adapts to dark theme', () { + final bar = DartdocGenerator.buildTopBar(name: 'My Docs', homeUrl: '/docs/', primaryColor: '#6366f1'); expect(bar, contains('class="sd-apibar"')); - expect(bar, contains('My Docs · API reference')); - expect(bar, contains('href="https://example.com">← Back to docs')); + expect(bar, contains('My Docs')); + expect(bar, contains('sd-apibar__badge')); + expect(bar, contains('href="/docs/"')); + expect(bar, contains('← Back to docs')); + expect(bar, contains('.dark-theme .sd-apibar')); // theme-adaptive + }); + + test('uses the site primary color as the accent', () { + final bar = DartdocGenerator.buildTopBar(name: 'X', homeUrl: '/', primaryColor: '#ff3366'); + + expect(bar, contains('background:#ff3366')); + }); + + test('rejects a hostile primary color, falling back to the default accent', () { + final bar = DartdocGenerator.buildTopBar(name: 'X', homeUrl: '/', primaryColor: 'red;}