Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/features/dartdoc.md
Original file line number Diff line number Diff line change
@@ -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.

<Note>
`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`.
</Note>

## 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.
2 changes: 2 additions & 0 deletions lib/src/cli/cli_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +29,7 @@ class StardustCliRunner extends CommandRunner<int> {
addCommand(NewCommand());
addCommand(CleanCommand());
addCommand(OpenApiCommand());
addCommand(DartdocCommand());

argParser.addFlag(
'version',
Expand Down
72 changes: 72 additions & 0 deletions lib/src/cli/commands/dartdoc_command.dart
Original file line number Diff line number Diff line change
@@ -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<int> {
@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<int> 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;
}
}
}
158 changes: 158 additions & 0 deletions lib/src/dartdoc/dartdoc_generator.dart
Original file line number Diff line number Diff line change
@@ -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
/// `<main>` 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<int> 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 `<body>`, 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 `<main>` 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('<body[^>]*>'), (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 = '<script>(function(){'
'function d(){try{var t=localStorage.getItem("theme");'
'if(t==="dark")return true;if(t==="light")return false;}catch(e){}'
'return matchMedia("(prefers-color-scheme: dark)").matches;}'
'var k=d();try{localStorage.setItem("colorTheme",k?"true":"false");}catch(e){}'
'var b=document.body;b.classList.remove("light-theme","dark-theme");'
'b.classList.add(k?"dark-theme":"light-theme");'
'try{new MutationObserver(function(){var x=b.classList.contains("dark-theme");'
'try{localStorage.setItem("theme",x?"dark":"light");}catch(e){}})'
'.observe(b,{attributes:true,attributeFilter:["class"]});}catch(e){}'
'})();</script>';

/// 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 '<div class="sd-apibar">'
'<style>'
'.sd-apibar{display:flex;align-items:center;gap:.55rem;padding:.5rem 1rem;'
'font:600 14px/1.2 system-ui,-apple-system,BlinkMacSystemFont,sans-serif;'
'background:#fff;color:#1a1a1a;border-bottom:1px solid rgba(0,0,0,.08)}'
'.dark-theme .sd-apibar{background:#0d1117;color:#e6edf3;border-bottom-color:rgba(255,255,255,.1)}'
'.sd-apibar__brand{display:flex;align-items:center;gap:.5rem;color:inherit;text-decoration:none;font-weight:700}'
'.sd-apibar__brand img{height:22px;width:auto;display:block}'
'.sd-apibar__badge{padding:.1rem .4rem;border-radius:5px;font-size:11px;font-weight:700;'
'letter-spacing:.03em;background:$accent;color:#fff}'
'.sd-apibar__home{margin-left:auto;color:inherit;text-decoration:none;font-weight:500;opacity:.7}'
'.sd-apibar__home:hover{opacity:1;color:$accent}'
'.sd-logo-dark{display:none}.dark-theme .sd-logo-light{display:none}.dark-theme .sd-logo-dark{display:block}'
'</style>'
'<a class="sd-apibar__brand" href="$home">${_logoImages(logoLight, logoDark)}'
'<span>${encodeHtml(name)}</span><span class="sd-apibar__badge">API</span></a>'
'<a class="sd-apibar__home" href="$home">← Back to docs</a>'
'</div>';
}

static String _logoImages(String? light, String? dark) {
if (light != null && dark != null && light != dark) {
return '<img class="sd-logo-light" src="${encodeHtmlAttribute(light)}" alt="">'
'<img class="sd-logo-dark" src="${encodeHtmlAttribute(dark)}" alt="">';
}
if (light != null) return '<img src="${encodeHtmlAttribute(light)}" alt="">';
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;
}
}
7 changes: 6 additions & 1 deletion rnd/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | `<DartPad id="…">` 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 |
Expand Down Expand Up @@ -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**) — `<DartPad>` 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**) — `<DartPad>` 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 `<main>` (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.
Expand Down
2 changes: 2 additions & 0 deletions stardust.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading