feat(draw): d2 diagram template — compile d2 source to SVG (T-494)

The d2 drawing template compiles a diagram's source to SVG through the d2
binary (resolved via the D-104 path layer), then paints it with the same
renderer the svg card uses. `clide draw --file x.d2` infers the type from
the extension; `.svg` files render directly. Template handlers now return
a DrawResult so a compile failure or an unresolved d2 surface as an honest
userError with an install hint, not a generic "no SVG". Real d2 0.7.1
verified end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 11:19:48 +02:00
co-authored by Claude Opus 4.8
parent 67edf732f1
commit 64d77ec5dc
10 changed files with 234 additions and 19 deletions
+2 -1
View File
@@ -41,6 +41,7 @@ import 'package:clide/clide.dart' show clideVersion;
import 'package:clide/src/daemon/claude_account_commands.dart';
import 'package:clide/src/daemon/dispatcher.dart';
import 'package:clide/src/daemon/draw_commands.dart';
import 'package:clide/src/draw/d2_template.dart' show d2TemplateHandler;
import 'package:clide/src/daemon/editor_commands.dart';
import 'package:clide/src/daemon/files_commands.dart';
import 'package:clide/src/daemon/git_commands.dart';
@@ -376,7 +377,7 @@ Future<void> main() async {
registerDrawCommands(
dispatcher,
() => kernelMessages?.publish,
registry: DrawingRegistry(),
registry: DrawingRegistry()..register('d2', d2TemplateHandler()),
readFile: (path) async {
final file = File(path.startsWith('/') ? path : '${workRoot.path}/$path');
try {
+19 -9
View File
@@ -18,7 +18,7 @@ import 'dart:convert';
import '../draw/draw_dispatch.dart';
import '../draw/draw_doc.dart';
export '../draw/draw_dispatch.dart' show DrawingFileReader, DrawingRegistry, DrawingTemplateHandler;
export '../draw/draw_dispatch.dart' show DrawErr, DrawOk, DrawResult, DrawingFileReader, DrawingRegistry, DrawingTemplateHandler;
import '../ipc/command_schema.dart';
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
@@ -66,16 +66,26 @@ Future<IpcResponse> _draw(IpcRequest req, MessagePublisher? Function() publisher
);
}
Object? decoded;
try {
decoded = jsonDecode(raw);
} on FormatException catch (e) {
return _userErr(req.id, 'invalid JSON in $file: ${e.message}');
// Type inference from the extension (T-494): a `.d2`/`.svg` file is the raw
// source, not a JSON envelope — wrap it in the matching doc. Everything else
// is a drawing-card JSON document.
final lower = file.toLowerCase();
final DrawingCardDoc? doc;
if (lower.endsWith('.d2')) {
doc = DrawingCardDoc(template: 'd2', fields: {'template': 'd2', 'source': raw});
} else if (lower.endsWith('.svg')) {
doc = DrawingCardDoc(svg: raw);
} else {
final Object? decoded;
try {
decoded = jsonDecode(raw);
} on FormatException catch (e) {
return _userErr(req.id, 'invalid JSON in $file: ${e.message}');
}
doc = parseDrawingCardDoc(decoded);
if (doc == null) return _userErr(req.id, 'a drawing-card document must be a JSON object');
}
final doc = parseDrawingCardDoc(decoded);
if (doc == null) return _userErr(req.id, 'a drawing-card document must be a JSON object');
final result = await resolveDrawingSvg(doc, registry, readFile: readFile);
if (result is DrawErr) return _userErr(req.id, result.message);
final svg = (result as DrawOk).svg;
+80
View File
@@ -0,0 +1,80 @@
/// The `d2` drawing-card template (T-494 / D-91 / D-103).
///
/// A d2 diagram is just an SVG card with a compile step in front: the doc's
/// `source` (d2 diagram text) is compiled to SVG, then painted by the SAME
/// renderer the `svg` card uses (T-320). The compile shells out to the `d2`
/// binary — the supporter-tool pattern (peer of pql/git, D-3/D-5), resolved via
/// the D-104 path layer (T-495). No second core language, no vendored Go.
///
/// Honest failures (D-103): a missing source, an unresolved `d2`, or a compile
/// error each return a [DrawErr] with a user-facing message + hint, which the
/// command layer turns into an IpcError userError — never a throw.
///
/// Flutter-free: pure Dart (dart:io), runs under `dart test`. The process spawn
/// is injectable ([D2Compiler]) so the handler is tested without a real binary.
library;
import 'dart:convert';
import 'dart:io';
import '../env/supporter_binaries.dart';
import 'draw_dispatch.dart';
/// Compiles d2 [source] to an SVG [DrawResult]. Injected into
/// [d2TemplateHandler] so it is testable; the default is [d2CompileViaBinary].
typedef D2Compiler = Future<DrawResult> Function(String source);
/// Handler for `template: "d2"` — reads the doc's `source` field (the diagram
/// text) and compiles it. Register this in the [DrawingRegistry].
DrawingTemplateHandler d2TemplateHandler({D2Compiler compile = d2CompileViaBinary}) {
return (doc) async {
final source = doc.fields['source'];
if (source is! String || source.trim().isEmpty) {
return const DrawErr('the d2 template needs a non-empty "source" field (the d2 diagram text)');
}
return compile(source);
};
}
/// One run of the d2 binary: its exit code, stdout (SVG) and stderr.
typedef D2RunResult = ({int code, String out, String err});
/// Runs the d2 [exe] over [source]. Injected so [d2CompileViaBinary] is tested
/// without a real binary; the default is [_spawnD2].
typedef D2Run = Future<D2RunResult> Function(String exe, String source);
/// Resolve the `d2` binary (D-104) and compile [source] through it. Failure keys
/// off the exit code — d2 logs `success:` to stderr on a clean compile, so a
/// non-empty stderr is not itself an error. [resolveD2] and [run] are injectable
/// for testing; the defaults use [activeSupporterBinaries] and a real spawn.
Future<DrawResult> d2CompileViaBinary(String source, {String? Function()? resolveD2, D2Run run = _spawnD2}) async {
final d2 = (resolveD2 ?? _defaultResolveD2)();
if (d2 == null) {
return const DrawErr('d2 not found — install it from https://d2lang.com, or set its path in Settings → Tools');
}
final D2RunResult r;
try {
r = await run(d2, source);
} catch (e) {
return DrawErr('could not run d2 ($d2): $e');
}
if (r.code != 0) {
return DrawErr('d2 compile failed: ${r.err.trim().isEmpty ? 'exit ${r.code}' : r.err.trim()}');
}
if (r.out.trim().isEmpty) return const DrawErr('d2 produced no SVG');
return DrawOk(r.out);
}
String? _defaultResolveD2() => (activeSupporterBinaries ?? SupporterBinaries()).resolve('d2');
/// `d2 - -` — read source on stdin, write SVG to stdout. Drains stdout/stderr
/// concurrently with the stdin write to avoid a pipe deadlock on a big diagram.
Future<D2RunResult> _spawnD2(String exe, String source) async {
final proc = await Process.start(exe, const ['-', '-']);
final outF = proc.stdout.transform(utf8.decoder).join();
final errF = proc.stderr.transform(utf8.decoder).join();
proc.stdin.write(source);
await proc.stdin.close();
final code = await proc.exitCode;
return (code: code, out: await outF, err: await errF);
}
+5 -4
View File
@@ -17,8 +17,10 @@ library;
import 'draw_doc.dart';
/// Produces an SVG string for a template-mode doc, or `null` on failure.
typedef DrawingTemplateHandler = Future<String?> Function(DrawingCardDoc doc);
/// Lowers a template-mode doc to SVG, as a [DrawResult] — [DrawOk] with the SVG
/// or [DrawErr] carrying an honest, user-facing message (e.g. a compile failure
/// or an unresolved tool, with a hint).
typedef DrawingTemplateHandler = Future<DrawResult> Function(DrawingCardDoc doc);
/// Reads a file's contents, or `null` if unreadable. Injected for testability.
typedef DrawingFileReader = Future<String?> Function(String path);
@@ -65,6 +67,5 @@ Future<DrawResult> resolveDrawingSvg(DrawingCardDoc doc, DrawingRegistry registr
final handler = registry.handlerFor(doc.template!);
if (handler == null) return DrawErr('unknown drawing template: ${doc.template}');
final svg = await handler(doc);
return svg == null ? DrawErr('template ${doc.template} produced no SVG') : DrawOk(svg);
return handler(doc);
}