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 '
' + '' + '${_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/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;}