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:
+2
-1
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user