Skip to content
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ Plugins depend on the **`sdk/` package** (its own `build.zig` + `build.zig.zon`)
Pattern:

- **Plugins** (built-in + third-party): `.fizzy = .{ .path = ".../sdk" }` locally, or the `fizzy-sdk-v*` **release asset** URL from the matching `sdk-v*` tag (not the git archive — that is the monorepo root zon with Velopack). Call `fizzy.plugin.create` / `.install` as before; `b.dependency("fizzy", .{ .plugin_sdk = true })` still works (the option is accepted and ignored — `sdk/` always exports modules). Packing: `scripts/pack-sdk.sh` / `.github/workflows/sdk-tag.yml`.
- **App**: repo-root `zig build` as usual. Velopack stays `.lazy = true` in the root zon; never `@import("velopack_zig")` — the helper surface is vendored in `build/velopack.zig` and resolved only in `build/app.zig` via `lazyDependency`.
- **App**: repo-root `zig build` as usual. The app **consumes `sdk/` as a dependency** (`.fizzy_sdk = .{ .path = "sdk/" }`), so build scripts reach `plugin`/`core_module`/`sdk_version` through `@import("fizzy_sdk")` and never by relative path into `sdk/` — a file may belong to only one module, so a path import claims it for the root build module and breaks the dependency outright. The same applies in reverse: nothing under `src/` may relative-import an `sdk/` file. Velopack stays `.lazy = true` in the root zon; never `@import("velopack_zig")` — the helper surface is vendored in `build/velopack.zig` and resolved only in `build/app.zig` via `lazyDependency`.
- **dvui is pinned in exactly one place — `sdk/build.zig.zon` — and is deliberately absent from the root zon.** The app borrows it via `build/sdk.zig`'s `dvuiDependency` (which forwards backend/target/optimize normally), and build scripts get dvui's build API from `@import("fizzy_sdk").dvui`. Do **not** "fix" the missing root dep by re-adding `.dvui`: two pins that drift make `recorded_sdk_shape_fingerprint` unsatisfiable by *both* the app and plugin-SDK builds at once, and the resulting error tells you to bump `sdk_version`, which cannot help. Bump or swap to a local checkout in `sdk/build.zig.zon` only.
- Shared `core` import wiring lives in `sdk/core_module.zig` and is called from the app build *and* `sdk/plugin_sdk.zig`'s `exportModules` so the import set can't drift. Note the `with_tui = false` on the zf dependency: without it, zf's standalone terminal binary drags `libvaxis` into every plugin build.

Acceptance test after any build-graph change:
Expand Down
6 changes: 5 additions & 1 deletion build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ const std = @import("std");

/// App-side re-export of the plugin build API (lives in `sdk/`). Plugins should depend on
/// the `sdk/` package directly — see CLAUDE.md — not this root package.
pub const plugin = @import("sdk/plugin_sdk.zig");
///
/// Reached through the dependency rather than by path (`sdk/plugin_sdk.zig`): the app consumes
/// `sdk/` as a package so the two can share one dvui pin, and a file may belong to only one module,
/// so claiming these for the root's build module would make that impossible.
pub const plugin = @import("fizzy_sdk").plugin;

pub fn build(b: *std.Build) !void {
const windows_msvc_libc_opt = b.option([]const u8, "windows-msvc-libc", "zig libc manifest for *-windows-msvc when cross-compiling; forwarded by packageall for Windows children") orelse null;
Expand Down
6 changes: 2 additions & 4 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,8 @@
.hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_",
.lazy = true,
},
.dvui = .{
.url = "https://github.com/foxnne/dvui-dev/archive/ed2f1c67f0316184783c8dba7d79ed4c49d26f97.tar.gz",
.hash = "dvui-0.5.0-dev-AQFJmX1d_QA2wHjWCweU26ZxqIrA9LwWeysGFbfVMc7y",
//.path = "../dvui-dev",
.fizzy_sdk = .{
.path = "sdk/",
},
.assetpack = .{
.url = "https://github.com/foxnne/assetpack/archive/ac7592f3f5988857840d0df4610e1e1fad690e2e.tar.gz",
Expand Down
70 changes: 64 additions & 6 deletions build/app.zig
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
const std = @import("std");

const plugin = @import("../sdk/plugin_sdk.zig");
const core_mod = @import("../sdk/core_module.zig");
const dvui = @import("dvui");
// Through the `sdk/` dependency, not by relative path — see `build/sdk.zig`'s `dvuiDependency` for
// why the app consumes the SDK as a package, and `sdk/build.zig` for what it exposes. dvui's build
// API arrives the same way because `sdk/` owns the repo's only dvui pin.
const fizzy_sdk = @import("fizzy_sdk");
const plugin = fizzy_sdk.plugin;
const core_mod = fizzy_sdk.core_module;
const dvui = fizzy_sdk.dvui;
const velopack = @import("velopack.zig");

pub const Options = struct {
Expand Down Expand Up @@ -367,6 +371,10 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil
// below never reaches it (nothing in the graph forces `sdk.manifest`), so it
// needs its own root either way.
.{ "fizzy-sdk-manifest-tests", "src/sdk/manifest.zig" },
// The `[[wikilink]]` tokenizer. std-only on purpose: it's shared verbatim by the
// markdown renderer and by out-of-tree indexers, so it must not depend on dvui or
// anything else the SDK-rooted artifact drags in.
.{ "fizzy-sdk-wikilink-tests", "src/sdk/services/wikilink.zig" },
// The text plugin's headless editing model. Lives under src/plugins/ but is
// deliberately dvui-free (see textcore.zig), so it tests as pure logic from the
// app build. One root covers every file below it — they're relative imports.
Expand All @@ -383,9 +391,16 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil
// Sniffing image bytes stb can't decode (SVG badges), so the preview never re-enters
// stbi for them every frame. std-only, same reasoning as the two above.
.{ "fizzy-md-image-format-tests", "src/plugins/markdown/src/md/image_format.zig" },
// The markdown preview's block height table — placement, height trust, and the
// never-blank visible-range guarantee. std-only by design (see block_heights.zig) so
// the rules the preview's scroll stability rests on are testable without a Window.
.{ "fizzy-md-block-heights-tests", "src/plugins/markdown/src/md/block_heights.zig" },
// Content-swap reveal phase machine. std-only by design (see reveal.zig) — the dvui
// half is the thin wrapper in core/dvui.zig.
.{ "fizzy-reveal-tests", "src/core/reveal.zig" },
// Ring buffering and dot-segment filtering for the folder watcher. std-only so it can
// be tested here; FolderWatcher.zig itself needs a live editor.
.{ "fizzy-folder-events-tests", "src/editor/folder_events.zig" },
}) |entry| {
try unit_test_artifacts.append(b.allocator, b.addTest(.{
.name = entry[0],
Expand Down Expand Up @@ -448,7 +463,7 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil
return;
}

const dvui_testing_dep = b.dependency("dvui", .{
const dvui_testing_dep = sdk.dvuiDependency(b, .{
.target = target,
.optimize = optimize,
.backend = .testing,
Expand Down Expand Up @@ -481,7 +496,12 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil
const icons_test = core_mod.addImports(b, core_module_test, dvui_testing_dep.module("dvui_testing"), target, optimize);
fizzy_test_module.addImport("core", core_module_test);
if (icons_test) |icons| fizzy_test_module.addImport("icons", icons);
if (b.lazyDependency("nightwatch", .{ .target = target, .optimize = optimize })) |dep| {
// See `exe.zig` for why macOS needs the FSEvents backend.
const nightwatch_test_dep = if (target.result.os.tag == .macos)
b.lazyDependency("nightwatch", .{ .target = target, .optimize = optimize, .macos_fsevents = true })
else
b.lazyDependency("nightwatch", .{ .target = target, .optimize = optimize });
if (nightwatch_test_dep) |dep| {
fizzy_test_module.addImport("nightwatch", dep.module("nightwatch"));
}

Expand All @@ -499,7 +519,7 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil
.sdk = sdk_module_test,
.icons = icons_test,
}, fizzy_test_module);
_ = plugins.markdown.addStaticModule(b, target, optimize, .{
const markdown_module_test = plugins.markdown.addStaticModule(b, target, optimize, .{
.dvui = dvui_testing_dep.module("dvui_testing"),
.core = core_module_test,
.sdk = sdk_module_test,
Expand Down Expand Up @@ -536,6 +556,13 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil
// built above rather than rooting a second one at the widget — a file may belong to only
// one module per compilation, and the plugin's own module already owns it.
integration_module.addImport("text", text_module_test);
// Same reasoning for the markdown preview: its block virtualization is a claim about what
// gets *drawn*, which only a real headless frame can check.
integration_module.addImport("markdown", markdown_module_test);
integration_module.addAnonymousImport("markdown_sample", .{ .root_source_file = b.path("docs/PLUGINS.md") });
// The document with the 45KB table — the case table-row culling exists for, and the one it
// could get wrong.
integration_module.addAnonymousImport("markdown_sample_tables", .{ .root_source_file = b.path("docs/PLUGIN_MANIFEST_PLAN.md") });

const integration_tests = b.addTest(.{
.name = "fizzy-integration-tests",
Expand Down Expand Up @@ -596,6 +623,37 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil
bench_step.dependOn(&run_bench.step);
}

// `zig build bench-markdown` — markdown preview frame-cost benchmark. Same rules as
// `bench-text` above: its own step, prints timings instead of asserting, only comparable at
// equal `-Doptimize` (cmark and freetype build at the app's optimize level).
{
const bench_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("tests/bench/bench_markdown.zig"),
});
bench_module.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
bench_module.addImport("markdown", markdown_module_test);
// This repo's own docs, as anonymous imports rather than checked-in fixtures — the same
// reasoning as `bench-text`'s samples. `PLUGINS.md` is the document that prompted the
// benchmark.
bench_module.addAnonymousImport("sample_huge", .{ .root_source_file = b.path("docs/PLUGINS.md") });
bench_module.addAnonymousImport("sample_prose", .{ .root_source_file = b.path("docs/PLUGIN_MANIFEST_PLAN.md") });
bench_module.addAnonymousImport("sample_medium", .{ .root_source_file = b.path("CLAUDE.md") });
bench_module.addAnonymousImport("sample_small", .{ .root_source_file = b.path("docs/MODULARIZATION_RELEASE_NOTES.md") });

const bench_markdown = b.addTest(.{ .name = "fizzy-bench-markdown", .root_module = bench_module });
bench_markdown.root_module.link_libcpp = !target_is_windows_msvc;
if (target.result.os.tag == .windows) {
bench_markdown.root_module.linkSystemLibrary("comctl32", .{});
}

const bench_step = b.step("bench-markdown", "Benchmark the markdown preview's per-frame draw cost (prints timings)");
const run_bench = b.addRunArtifact(bench_markdown);
run_bench.has_side_effects = true;
bench_step.dependOn(&run_bench.step);
}

// Pure-logic tests that nevertheless sit in a file importing `dvui` (or the SDK)
// can't join the unit layer, so they get their own roots here. Rooting at
// `src/sdk/sdk.zig` collects every SDK file reachable from it by relative
Expand Down
2 changes: 1 addition & 1 deletion build/common.zig
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const std = @import("std");

const plugin = @import("../sdk/plugin_sdk.zig");
const plugin = @import("fizzy_sdk").plugin;
const update = @import("../update.zig");
const GitDependency = update.GitDependency;

Expand Down
23 changes: 16 additions & 7 deletions build/exe.zig
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
const std = @import("std");
const dvui = @import("dvui");
// dvui's build API via the SDK package, which owns the repo's only dvui pin.
const dvui = @import("fizzy_sdk").dvui;
// Vendored Velopack glue — see build/velopack.zig header (never `@import("velopack_zig")`).
const velopack = @import("velopack.zig");
const plugin = @import("../sdk/plugin_sdk.zig");
const core_mod = @import("../sdk/core_module.zig");
const plugin = @import("fizzy_sdk").plugin;
const core_mod = @import("fizzy_sdk").core_module;
const common = @import("common.zig");
const plugins = @import("plugins.zig");
const sdk = @import("sdk.zig");
Expand Down Expand Up @@ -79,7 +80,7 @@ pub fn addFizzyExecutableForTarget(
velopack_enabled: bool,
) !FizzyExecutable {
const dvui_dep = if (macos_sdl_paths) |p|
b.dependency("dvui", .{
sdk.dvuiDependency(b, .{
.target = resolved_target,
.optimize = optimize,
.backend = .sdl3,
Expand All @@ -89,9 +90,9 @@ pub fn addFizzyExecutableForTarget(
.library_path = p.lib,
})
else
b.dependency("dvui", .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit });
sdk.dvuiDependency(b, .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit });

const dvui_proxy_dep = b.dependency("dvui", .{
const dvui_proxy_dep = sdk.dvuiDependency(b, .{
.target = resolved_target,
.optimize = optimize,
.backend = .proxy,
Expand Down Expand Up @@ -149,7 +150,15 @@ pub fn addFizzyExecutableForTarget(
});
_ = core_mod.addImports(b, core_proxy_module, dvui_proxy_mod, resolved_target, optimize);

if (b.lazyDependency("nightwatch", .{ .target = resolved_target, .optimize = optimize })) |dep| {
// `macos_fsevents` is load-bearing for `FolderWatcher`: it watches a whole project folder,
// and the kqueue fallback needs a file descriptor per directory *and* per file — exactly the
// shape that exhausts the fd limit on a real repo. FSEvents covers the subtree with one
// stream. The option only exists when nightwatch is built for macOS, hence the split.
const nightwatch_dep = if (resolved_target.result.os.tag == .macos)
b.lazyDependency("nightwatch", .{ .target = resolved_target, .optimize = optimize, .macos_fsevents = true })
else
b.lazyDependency("nightwatch", .{ .target = resolved_target, .optimize = optimize });
if (nightwatch_dep) |dep| {
exe.root_module.addImport("nightwatch", dep.module("nightwatch"));
}

Expand Down
24 changes: 24 additions & 0 deletions build/sdk.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
const std = @import("std");

/// The repo's one dvui, borrowed from the `sdk/` package instead of pinned by the app.
///
/// dvui is not a dependency of the root package at all: `sdk/build.zig.zon` declares the only pin
/// and this reaches through to it, so there is a single place to bump a version or point at a local
/// checkout. `args` is forwarded to dvui's own build untouched (backend, target, optimize, …), so
/// callers keep full control of *how* it is built; only *which* dvui is shared.
///
/// Worth the indirection because the two are not free to disagree. dvui types reachable from the
/// plugin boundary feed `dylib.sdk_shape_fingerprint`, which both the app build and the plugin-SDK
/// build check against the single `recorded_sdk_shape_fingerprint` literal in `src/sdk/version.zig`.
/// When each build compiled a different dvui, they computed different fingerprints from that one
/// literal and no value satisfied both — every fix broke the other side, and the error blamed
/// `sdk_version`, which a bump cannot repair. One pin makes that state unreachable rather than
/// merely discouraged.
///
/// The direction is forced: `sdk/` ships standalone as `fizzy-sdk-v*.tar.gz` for third-party
/// plugins, so it must carry its own pin and can never read anything above its own root. The app
/// can always reach down into it.
pub fn dvuiDependency(b: *std.Build, args: anytype) *std.Build.Dependency {
// Only the SDK package's resolved dependency table is wanted here, not its artifacts, so its
// own target/optimize are left at default; `args` carries the target dvui is really built for.
return b.dependency("fizzy_sdk", .{}).builder.dependency("dvui", args);
}

pub fn addProxyBridgeModule(
b: *std.Build,
target: std.Build.ResolvedTarget,
Expand Down
4 changes: 2 additions & 2 deletions build/web.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const std = @import("std");
const core_mod = @import("../sdk/core_module.zig");
const core_mod = @import("fizzy_sdk").core_module;
const plugins = @import("plugins.zig");
const sdk = @import("sdk.zig");

Expand All @@ -24,7 +24,7 @@ pub fn addSteps(
}),
});

const dvui_web_dep = b.dependency("dvui", .{
const dvui_web_dep = sdk.dvuiDependency(b, .{
.target = web_target,
.optimize = optimize,
.backend = .web,
Expand Down
Loading
Loading