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..fe2ae9b
--- /dev/null
+++ b/lib/src/dartdoc/dartdoc_generator.dart
@@ -0,0 +1,158 @@
+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}');
+ }
+
+ if (await fileSystem.directoryExists(outputDir)) {
+ await fileSystem.deleteDirectory(outputDir, recursive: true);
+ }
+ 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;
+ 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);
+ }
+ }
+
+ /// 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 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, 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 '';
+ }
+
+ 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/rnd/ROADMAP.md b/rnd/ROADMAP.md
index 4c4b19e..5412178 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 **theme-aware** Stardust top-bar (logo + primary-color badge, light/dark synced to the site both ways); the content column is 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,11 @@
**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.
+>
+> A "is it actually correct/UI-good?" review then hardened phase 1: (1) fixed a search-noise bug — tag `#dartdoc-main-content`, not dartdoc's `` (which wraps the nav sidebars); (2) the top-bar is now **theme-aware** (logo + primary-color badge, static so it never collides with dartdoc's `z-index:2000` fixed-on-scroll header) with a **two-way theme sync** (site `localStorage['theme']` ↔ dartdoc's `colorTheme`); (3) subpath-aware home link (`basePath`, not the absolute `url`); (4) the output dir is cleared before writing. Demo'd end-to-end on a splash-landing + sidebar-guides + `/api/` site with unified search. 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..9fff9b3
--- /dev/null
+++ b/test/dartdoc/dartdoc_generator_test.dart
@@ -0,0 +1,191 @@
+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/exceptions.dart';
+import 'package:stardust/src/utils/logger.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('DartdocGenerator.buildTopBar', () {
+ 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'));
+ 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;}