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:
@@ -8867,3 +8867,23 @@ First run: auto-detect each tool ONCE — probe the login-shell PATH + well-know
|
||||
Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
|
||||
|
||||
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.', NULL, '2026-06-28 21:08:40', '2026-06-28 21:08:40.178', '2026-06-28 21:08:40.178', NULL, 'f155f8e589cd19c3cc52409495d24f50', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'description', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name → absolute path), consulted FIRST.
|
||||
|
||||
Resolution order: (1) explicit settings path → use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH.
|
||||
|
||||
First run: auto-detect each tool ONCE — probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) — and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action.
|
||||
|
||||
Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
|
||||
|
||||
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name → absolute path), consulted FIRST.
|
||||
|
||||
Resolution order: (1) explicit settings path → use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH.
|
||||
|
||||
First run: auto-detect each tool ONCE — probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) — and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action.
|
||||
|
||||
Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
|
||||
|
||||
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.
|
||||
|
||||
DONE (2026-06-29): full D-104 surface landed + fast suite green. UI complete — Tools settings category (per-tool path field app.tools.<name> + Re-detect action tools.detect, live resolver rebuild on edit, en+nl catalogs) in new builtin.tools-settings; Problems panel flags a stale/broken pinned path. Storage is per-tool keys + a first-run marker. Commits dab0b319/0adccc5c/62418b40/bd4a8332/3be063d6/eac9612a/67edf732.', NULL, '2026-06-29 07:32:43', '2026-06-29 07:32:43.640', '2026-06-29 07:32:43.640', NULL, '0dfb6eeb1bcde2f386379f029fa4d0f6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'status', 'in_progress', 'done', NULL, '2026-06-29 07:32:43', '2026-06-29 07:32:43.682', '2026-06-29 07:32:43.682', NULL, '45aeefee25353af62e4aa9f7b1f3265c', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -10656,3 +10656,25 @@ First run: auto-detect each tool ONCE — probe the login-shell PATH + well-know
|
||||
Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
|
||||
|
||||
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.', 'in_progress', 'medium', NULL, NULL, 'D-104', '2026-06-28 17:50:02.366', '2026-06-28 21:08:40.177', NULL, '7e1f7077abd788309d38a51f8a3b1348', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'task', NULL, 'Supporter-binary path resolution: explicit user-scope overrides + first-run auto-detect', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name → absolute path), consulted FIRST.
|
||||
|
||||
Resolution order: (1) explicit settings path → use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH.
|
||||
|
||||
First run: auto-detect each tool ONCE — probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) — and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action.
|
||||
|
||||
Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
|
||||
|
||||
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.
|
||||
|
||||
DONE (2026-06-29): full D-104 surface landed + fast suite green. UI complete — Tools settings category (per-tool path field app.tools.<name> + Re-detect action tools.detect, live resolver rebuild on edit, en+nl catalogs) in new builtin.tools-settings; Problems panel flags a stale/broken pinned path. Storage is per-tool keys + a first-run marker. Commits dab0b319/0adccc5c/62418b40/bd4a8332/3be063d6/eac9612a/67edf732.', 'in_progress', 'medium', NULL, NULL, 'D-104', '2026-06-28 17:50:02.366', '2026-06-29 07:32:43.640', NULL, '5047cde2263e4df79fef6f4718f948fa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYPJ6FTEPP4JK3D7D01ZSMM', 'task', NULL, 'Supporter-binary path resolution: explicit user-scope overrides + first-run auto-detect', 'Implement D-104. clide resolves external supporter binaries (claude, d2, future supporter tools) via an explicit user-scope ''tools:'' map (name → absolute path), consulted FIRST.
|
||||
|
||||
Resolution order: (1) explicit settings path → use it; honest error + fallback-with-warning if missing/non-exec. (2) bundled/pinned (pql D-58, git D-59). (3) login-shell PATH (T-439). (4) bare PATH.
|
||||
|
||||
First run: auto-detect each tool ONCE — probe the login-shell PATH + well-known dirs (~/.local/bin, /usr/local/bin, /opt/homebrew, /home/linuxbrew/.linuxbrew/bin) — and WRITE the discovered absolute paths into the map (pinned, not re-probed each launch). Provide a re-detect action.
|
||||
|
||||
Storage: user-scope SettingsStore keyed by machine (D-93) — never committed. Surface in the Config tab (T-414) + flag unresolved tools in the Problems panel (echoes D-58''s soft floor). Generalizes D-58''s CLIDE_PQL_BIN override to all supporter tools. First consumer: the d2 template (T-494).
|
||||
|
||||
RESOLUTION CORE DONE (2026-06-28): functional path resolution built + wired + tested (14 dart-test cases). SupporterBinaries resolver (lib/src/env/supporter_binaries.dart): explicit override -> login-shell/process PATH -> well-known dirs INCLUDING Homebrew-on-Linux (the D-104 gap). loadSupporterBinaries: first-run auto-detect + persist (pinned, not re-probed); isStalePin flags a moved tool. Boot wiring in main.dart exposes the process-wide activeSupporterBinaries. This UNBLOCKS T-494 — the d2 template resolves d2 via activeSupporterBinaries.resolve(''d2''). REMAINING (UI follow-ons, not gating T-494): Config-tab override editor (view/edit/clear paths), a re-detect action, and Problems-panel surfacing of unresolved tools.
|
||||
|
||||
DONE (2026-06-29): full D-104 surface landed + fast suite green. UI complete — Tools settings category (per-tool path field app.tools.<name> + Re-detect action tools.detect, live resolver rebuild on edit, en+nl catalogs) in new builtin.tools-settings; Problems panel flags a stale/broken pinned path. Storage is per-tool keys + a first-run marker. Commits dab0b319/0adccc5c/62418b40/bd4a8332/3be063d6/eac9612a/67edf732.', 'done', 'medium', NULL, NULL, 'D-104', '2026-06-28 17:50:02.366', '2026-06-29 07:32:43.682', NULL, '70724bd4ac9c1da06f8d1c252492a4b0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- **D2 diagram cards.** `clide draw --file diagram.d2` compiles a d2 diagram to
|
||||
SVG (via the `d2` binary) and renders it in the conversation; `template:"d2"`
|
||||
with inline source works too, and a `.svg` file renders directly. Honest error
|
||||
if d2 isn't installed or the source doesn't compile. (T-494, D-103)
|
||||
- **Tool path resolution + settings.** clide resolves supporter binaries (claude,
|
||||
d2) via an explicit per-tool path, else PATH and the common install dirs — now
|
||||
including Homebrew-on-Linux. A Tools settings category edits the paths and
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ void main() {
|
||||
|
||||
test('template doc lowers via the registry before publishing', () async {
|
||||
wire();
|
||||
registry.register('d2', (doc) async => '<svg data-d2="${doc.fields['source']}"/>');
|
||||
registry.register('d2', (doc) async => DrawOk('<svg data-d2="${doc.fields['source']}"/>'));
|
||||
files['c.json'] = '{"template":"d2","source":"a -> b"}';
|
||||
final r = await draw('c.json');
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
@@ -56,6 +56,24 @@ void main() {
|
||||
expect(r.data['template'], 'd2');
|
||||
});
|
||||
|
||||
test('a .d2 file is wrapped as a d2 template and lowered (T-494)', () async {
|
||||
wire();
|
||||
registry.register('d2', (doc) async => DrawOk('<svg data-d2="${doc.fields['source']}"/>'));
|
||||
files['pipeline.d2'] = 'a -> b';
|
||||
final r = await draw('pipeline.d2');
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(published.single.data['svg'], '<svg data-d2="a -> b"/>');
|
||||
expect(r.data['template'], 'd2');
|
||||
});
|
||||
|
||||
test('a .svg file renders as a primitive card (T-494)', () async {
|
||||
wire();
|
||||
files['logo.svg'] = '<svg id="raw"/>';
|
||||
final r = await draw('logo.svg');
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(published.single.data['svg'], '<svg id="raw"/>');
|
||||
});
|
||||
|
||||
test('a missing file → notFound, nothing published', () async {
|
||||
wire();
|
||||
final r = await draw('nope.json');
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:clide/src/draw/d2_template.dart';
|
||||
import 'package:clide/src/draw/draw_dispatch.dart';
|
||||
import 'package:clide/src/draw/draw_doc.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
DrawingCardDoc d2doc(Object? source) => DrawingCardDoc(template: 'd2', fields: {'template': 'd2', if (source != null) 'source': source});
|
||||
|
||||
group('d2TemplateHandler', () {
|
||||
test('compiles the source field via the injected compiler', () async {
|
||||
final h = d2TemplateHandler(compile: (s) async => DrawOk('<svg data-s="$s"/>'));
|
||||
final r = await h(d2doc('a -> b'));
|
||||
expect((r as DrawOk).svg, '<svg data-s="a -> b"/>');
|
||||
});
|
||||
|
||||
test('a missing source is an honest error', () async {
|
||||
final h = d2TemplateHandler(compile: (_) async => const DrawOk('x'));
|
||||
expect(await h(d2doc(null)), isA<DrawErr>());
|
||||
});
|
||||
|
||||
test('a blank source is an honest error', () async {
|
||||
final h = d2TemplateHandler(compile: (_) async => const DrawOk('x'));
|
||||
expect(await h(d2doc(' ')), isA<DrawErr>());
|
||||
});
|
||||
|
||||
test('propagates a compiler error message', () async {
|
||||
final h = d2TemplateHandler(compile: (_) async => const DrawErr('boom'));
|
||||
expect((await h(d2doc('a -> b')) as DrawErr).message, 'boom');
|
||||
});
|
||||
});
|
||||
|
||||
group('d2CompileViaBinary', () {
|
||||
Future<DrawResult> compile({String? bin = '/x/d2', D2Run? run}) =>
|
||||
d2CompileViaBinary('a -> b', resolveD2: () => bin, run: run ?? (exe, src) async => (code: 0, out: '<svg/>', err: 'success:'));
|
||||
|
||||
test('unresolved d2 hints how to install', () async {
|
||||
expect((await compile(bin: null) as DrawErr).message, contains('d2 not found'));
|
||||
});
|
||||
|
||||
test('a clean compile returns the SVG (stderr ignored)', () async {
|
||||
expect((await compile() as DrawOk).svg, '<svg/>');
|
||||
});
|
||||
|
||||
test('a non-zero exit is a compile error carrying stderr', () async {
|
||||
final r = await compile(run: (exe, src) async => (code: 1, out: '', err: 'line 2: syntax error'));
|
||||
expect((r as DrawErr).message, contains('syntax error'));
|
||||
});
|
||||
|
||||
test('empty output is an error', () async {
|
||||
final r = await compile(run: (exe, src) async => (code: 0, out: ' ', err: ''));
|
||||
expect(r, isA<DrawErr>());
|
||||
});
|
||||
|
||||
test('a spawn failure is reported honestly', () async {
|
||||
final r = await compile(run: (exe, src) async => throw 'ENOENT');
|
||||
expect((r as DrawErr).message, contains('could not run d2'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -37,15 +37,15 @@ void main() {
|
||||
});
|
||||
|
||||
test('template: a registered handler lowers the doc to SVG', () async {
|
||||
final reg = DrawingRegistry()..register('d2', (doc) async => '<svg data-src="${doc.fields['source']}"/>');
|
||||
final reg = DrawingRegistry()..register('d2', (doc) async => DrawOk('<svg data-src="${doc.fields['source']}"/>'));
|
||||
final r = await resolve(parseDrawingCardDoc({'template': 'd2', 'source': 'a -> b'})!, reg);
|
||||
expect((r as DrawOk).svg, '<svg data-src="a -> b"/>');
|
||||
});
|
||||
|
||||
test('template: a handler that returns null is an error', () async {
|
||||
final reg = DrawingRegistry()..register('d2', (_) async => null);
|
||||
test('template: a handler error propagates with its message', () async {
|
||||
final reg = DrawingRegistry()..register('d2', (_) async => const DrawErr('d2 not found'));
|
||||
final r = await resolve(parseDrawingCardDoc({'template': 'd2'})!, reg);
|
||||
expect(r, isA<DrawErr>());
|
||||
expect((r as DrawErr).message, contains('d2 not found'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user