feat: addTranslateZx to allow component library

This commit is contained in:
Nurul Huda (Apon) 2026-06-15 12:06:12 +06:00
parent d732718bc0
commit 48e29b9d2f
No known key found for this signature in database
GPG Key ID: 5D3F1DE2855A2F79
17 changed files with 467 additions and 145 deletions

View File

@ -311,51 +311,24 @@ pub fn build(b: *std.Build) !void {
}
}
pub fn createModule(
b: *std.Build,
opts: std.Build.Module.CreateOptions,
) *std.Build.Module {
const root_src_file = opts.root_source_file orelse unreachable;
pub const TranslatedZx = @import("src/build/TranslatedZx.zig");
/// Transpile a `.zx` component file into a `TranslatedZx`, mirroring
/// `std.Build.addTranslateC`. Call `.createModule()` / `.addModule(name)` on the
/// result to get a module, then bind `zx` afterwards typically via the
/// `ZiexBuild` returned by `init`, which owns the app's canonical zx module:
///
/// const tzx = ziex.addTranslateZx(b, .{ .root_source_file = b.path("icons.zx") });
/// const icons = tzx.createModule();
/// icons.addImport("zx", zx.module); // `zx` from `ziex.init`
///
/// The host transpiler CLI is resolved with the host target so it can execute
/// during the build; it never enters the runtime module graph.
pub fn addTranslateZx(b: *std.Build, opts: TranslatedZx.Options) *TranslatedZx {
const zx_host_dep = b.dependencyFromBuildZig(@This(), .{
// .optimize = options.cli.optimize, // Always in release mode for faster transpilation
// No target = host target, so zx CLI can execute during build
.@"exclude-lsp" = true, // Skip LSP for faster build-time transpilation
});
const transpiler_exe = zx_host_dep.artifact("zx");
const transpile_cmd = b.addRunArtifact(transpiler_exe);
transpile_cmd.setName("zx transpile");
transpile_cmd.addArg("transpile");
transpile_cmd.addDirectoryArg(root_src_file);
transpile_cmd.addArg("--outdir");
const transpiler_mod_path = transpile_cmd.addOutputDirectoryArg("components_gen");
// transpile_cmd.addArg("--rootdir");
// transpile_cmd.addDirectoryArg(static_lazypath);
transpile_cmd.addArg("--dep-file");
_ = transpile_cmd.addDepFileOutputArg("transpile.d");
// if (opts.copy_embedded_sources) {
// transpile_cmd.addArg("--copy-embedded-sources");
// }
// if (opts.base_path) |bp| {
// transpile_cmd.addArgs(&.{ "--base-path", bp });
// }
// Always generate inlined sourcemaps so dev mode can remap errors to .zx files
transpile_cmd.addArgs(&.{ "--map", "inline" });
const cache_path_arg = b.pathJoin(&.{ b.cache_root.path orelse ".zig-cache", "zx_transpile" });
transpile_cmd.addArgs(&.{ "--cache-dir", cache_path_arg });
transpile_cmd.expectExitCode(0);
// _ = try transpile_cmd.step.addDirectoryWatchInput(opts.site_path);
var transpiled_mod_opts = opts;
const root_src_basename = root_src_file.basename(b, &transpile_cmd.step);
const root_src_ext = std.fs.path.extension(root_src_basename);
const transpiled_src_name = b.fmt("{s}.zig", .{root_src_basename[0 .. root_src_basename.len - root_src_ext.len]});
transpiled_mod_opts.root_source_file = transpiler_mod_path.path(b, transpiled_src_name);
return b.createModule(transpiled_mod_opts);
return TranslatedZx.create(b, zx_host_dep.artifact("zx"), opts);
}
pub const info = .{

10
pkg/ui/build.zig Normal file
View File

@ -0,0 +1,10 @@
const std = @import("std");
const ziex = @import("ziex");
pub fn build(b: *std.Build) void {
const tzx = ziex.addTranslateZx(b, .{
.root_source_file = b.path("component/root.zx"),
});
const ui_mod = tzx.addModule("ui");
_ = ui_mod;
}

84
pkg/ui/build.zig.zon Normal file
View File

@ -0,0 +1,84 @@
.{
// This is the default name used by packages depending on this one. For
// example, when a user runs `zig fetch --save <url>`, this field is used
// as the key in the `dependencies` table. Although the user can choose a
// different name, most users will stick with this provided value.
//
// It is redundant to include "zig" in this name because it is already
// within the Zig package namespace.
.name = .ui,
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
.version = "0.0.0",
// Together with name, this represents a globally unique package
// identifier. This field is generated by the Zig toolchain when the
// package is first created, and then *never changes*. This allows
// unambiguous detection of one package being an updated version of
// another.
//
// When forking a Zig project, this id should be regenerated (delete the
// field and run `zig build`) if the upstream project is still maintained.
// Otherwise, the fork is *hostile*, attempting to take control over the
// original project's identity. Thus it is recommended to leave the comment
// on the following line intact, so that it shows up in code reviews that
// modify the field.
.fingerprint = 0x27ff46b0392d653b, // Changing this has security and trust implications.
// Tracks the earliest Zig version that the package considers to be a
// supported use case.
.minimum_zig_version = "0.16.0",
// This field is optional.
// Each dependency must either provide a `url` and `hash`, or a `path`.
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.dependencies = .{
.ziex = .{
.path = "../../",
},
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding
// // `hash`, otherwise you are communicating that you expect to find the old hash at
// // the new URL. If the contents of a URL change this will result in a hash mismatch
// // which will prevent zig from using it.
// .url = "https://example.com/foo.tar.gz",
//
// // This is computed from the file contents of the directory of files that is
// // obtained after fetching `url` and applying the inclusion rules given by
// // `paths`.
// //
// // This field is the source of truth; packages do not come from a `url`; they
// // come from a `hash`. `url` is just one of many possible mirrors for how to
// // obtain a package matching this `hash`.
// //
// // Uses the [multihash](https://multiformats.io/multihash/) format.
// .hash = "...",
//
// // When this is provided, the package is found in a directory relative to the
// // build root. In this case the package's hash is irrelevant and therefore not
// // computed. This field and `url` are mutually exclusive.
// .path = "foo",
//
// // When this is set to `true`, a package is declared to be lazily
// // fetched. This makes the dependency only get fetched if it is
// // actually used.
// .lazy = false,
//},
},
// Specifies the set of files and directories that are included in this package.
// Only files and directories listed here are included in the `hash` that
// is computed for this package. Only files listed here will remain on disk
// when using the zig package manager. As a rule of thumb, one should list
// files required for compilation plus any license(s).
// Paths are relative to the build root. Use the empty string (`""`) to refer to
// the build root itself.
// A directory listed here means that all files within, recursively, are included.
.paths = .{
"build.zig",
"build.zig.zon",
"src",
// For example...
//"LICENSE",
//"README.md",
},
}

9
pkg/ui/component/icon.zx Normal file
View File

@ -0,0 +1,9 @@
pub fn GitHub(allocator: zx.Allocator) zx.Component {
return (
<svg @{allocator} width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
</svg>
);
}
const zx = @import("zx");

1
pkg/ui/component/root.zx Normal file
View File

@ -0,0 +1 @@
pub const icon = @import("icon.zx");

View File

@ -1 +0,0 @@
pub const Icons = @import("pages/components/icons.zx");

View File

@ -1,3 +1,7 @@
const component = @import("component");
const icon = @import("icon");
const ui = @import("ui");
pub fn Layout(ctx: zx.LayoutContext, children: zx.Component) zx.Component {
const path = ctx.request.pathname;
const description = "A full-stack web framework for Zig. Write declarative UI components using familiar JSX patterns, transpiled to efficient Zig code.";
@ -80,6 +84,12 @@ pub fn Layout(ctx: zx.LayoutContext, children: zx.Component) zx.Component {
</style>
</head>
<body>
<div style="display:none">
<component.icon.GitHub />
<ui.icon.GitHub />
<icon.GitHub />
</div>
<GtmBody />
{children}
</body>

View File

@ -114,11 +114,11 @@ pub fn build(b: *std.Build) !void {
app_exe.step.dependOn(&install_pg.step); // Playground disabled
// --- ZX setup: wires dependencies and adds `zx`/`dev` build steps --- //
_ = try ziex.init(b, app_exe, .{
var zx = try ziex.init(b, app_exe, .{
.app = .{
// .path = b.path("app"),
// .base_path = "/test",
.copy_embedded_sources = true,
// .copy_embedded_sources = true,
.features = .{
.sqlite = .enabled,
},
@ -130,10 +130,17 @@ pub fn build(b: *std.Build) !void {
.cli = .{ .optimize = optimize },
});
const component_mod = ziex.createModule(b, .{
.root_source_file = b.path("app/components.zx"),
});
app_exe.root_module.addImport("components", component_mod);
// --- ZX Components --- //
// Single File
const icons_mod = zx.addComponent(.{ .root_source_file = b.path("component/icon.zx") });
zx.app.module.addImport("icon", icons_mod);
// multifile
zx.addComponentImport("component", .{ .root_source_file = b.path("component/main.zx") });
// from deps
const ui_dep = b.dependency("ui", .{});
const ui_mod = ui_dep.module("ui");
ui_mod.addImport("zx", zx.zx_module);
zx.addImport("ui", ui_mod);
const tailwindcss_b = tailwindcss.addBuild(b, .{
.config = .{

View File

@ -12,6 +12,7 @@
.bunjs = .{ .path = "../pkg/plugin-bun" },
.esbuild = .{ .path = "../pkg/plugin-esbuild" },
.tailwindcss = .{ .path = "../pkg/plugin-tailwindcss" },
.ui = .{ .path = "../pkg/ui" },
},
.paths = .{
"build.zig",

9
site/component/icon.zx Normal file
View File

@ -0,0 +1,9 @@
pub fn GitHub(allocator: zx.Allocator) zx.Component {
return (
<svg @{allocator} width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
</svg>
);
}
const zx = @import("zx");

1
site/component/main.zx Normal file
View File

@ -0,0 +1 @@
pub const icon = @import("icon.zx");

View File

@ -0,0 +1,70 @@
const TranslatedZx = @This();
const std = @import("std");
owner: *std.Build,
run: *std.Build.Step.Run,
output_dir: std.Build.LazyPath,
out_basename: []const u8,
mod_opts: std.Build.Module.CreateOptions,
pub const Options = struct {
/// The `.zx` source file to transpile.
root_source_file: std.Build.LazyPath,
target: ?std.Build.ResolvedTarget = null,
optimize: ?std.builtin.OptimizeMode = null,
};
/// Create a `TranslatedZx` by running the host `zx transpile` CLI over
/// `opts.root_source_file`.
pub fn create(
b: *std.Build,
transpiler_exe: *std.Build.Step.Compile,
opts: Options,
) *TranslatedZx {
const self = b.allocator.create(TranslatedZx) catch @panic("OOM");
const run = b.addRunArtifact(transpiler_exe);
run.addArg("transpile");
run.addDirectoryArg(opts.root_source_file);
run.addArg("--outdir");
const output_dir = run.addOutputDirectoryArg("components_gen");
run.addArg("--dep-file");
_ = run.addDepFileOutputArg("transpile.d");
run.addArgs(&.{ "--map", "inline" });
const basename = opts.root_source_file.basename(b, &run.step);
const ext = std.fs.path.extension(basename);
const out_basename = b.fmt("{s}.zig", .{basename[0 .. basename.len - ext.len]});
run.setName(b.fmt("translate-zx {s}", .{basename}));
self.* = .{
.owner = b,
.run = run,
.output_dir = output_dir,
.out_basename = out_basename,
.mod_opts = .{
.root_source_file = output_dir.path(b, out_basename),
.target = opts.target,
.optimize = opts.optimize,
},
};
return self;
}
/// The translated root `.zig` source file.
pub fn getOutput(self: *TranslatedZx) std.Build.LazyPath {
return self.output_dir.path(self.owner, self.out_basename);
}
/// Create a private module from the translated source. The module has no `zx`
/// import add it afterwards, e.g. `mod.addImport("zx", zx_module)`.
pub fn createModule(self: *TranslatedZx) *std.Build.Module {
return self.owner.createModule(self.mod_opts);
}
/// Like `createModule`, but exposes the module to dependent packages under `name`.
pub fn addModule(self: *TranslatedZx, name: []const u8) *std.Build.Module {
return self.owner.addModule(name, self.mod_opts);
}

View File

@ -1,15 +1,14 @@
const std = @import("std");
const html_util = @import("../util/html.zig");
const build_zig = @import("../../build.zig");
pub const InitOptions = @import("init/InitOptions.zig");
const LazyPath = std.Build.LazyPath;
const AddElementOptions = @import("../Build.zig").AddElementOptions;
pub const InitOptions = @import("init/InitOptions.zig");
pub fn init(b: *std.Build, exe: *std.Build.Step.Compile, options: InitOptions) !Build {
const target = exe.root_module.resolved_target;
const optimize = exe.root_module.optimize;
const build_zig = @import("../../build.zig");
const wasm_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding, .abi = .none });
const zx_dep = b.dependencyFromBuildZig(build_zig, .{
@ -48,7 +47,6 @@ pub fn init(b: *std.Build, exe: *std.Build.Step.Compile, options: InitOptions) !
.cli_path = null,
.site_outdir = null,
.steps = .default,
.copy_embedded_sources = false,
.client = options.client,
.static_path = options.static_path,
.ziex_js_dep = ziex_js_dep,
@ -58,7 +56,6 @@ pub fn init(b: *std.Build, exe: *std.Build.Step.Compile, options: InitOptions) !
if (options.app) |site_opts| {
opts.site_path = site_opts.path orelse opts.site_path;
opts.copy_embedded_sources = site_opts.copy_embedded_sources;
opts.base_path = site_opts.base_path;
opts.features = site_opts.features;
}
@ -88,7 +85,6 @@ const InitInnerOptions = struct {
cli_path: ?LazyPath,
site_outdir: ?LazyPath,
steps: InitOptions.CliOptions.Steps,
copy_embedded_sources: bool,
features: InitOptions.AppOptions.FeatureOptions = .default,
client: InitOptions.ClientOptions,
static_path: ?LazyPath,
@ -303,9 +299,6 @@ pub fn initInner(
transpile_cmd.addDirectoryArg(static_lazypath);
transpile_cmd.addArg("--dep-file");
_ = transpile_cmd.addDepFileOutputArg("transpile.d");
if (opts.copy_embedded_sources) {
transpile_cmd.addArg("--copy-embedded-sources");
}
if (opts.base_path) |bp| {
transpile_cmd.addArgs(&.{ "--base-path", bp });
}
@ -623,6 +616,8 @@ pub fn initInner(
return .{
.build = b,
.zx_module = zx_module,
.app = .{ .exe = exe, .module = app_module },
.cmd = .{
.transpile = transpile_cmd,
},
@ -661,6 +656,11 @@ pub const Build = struct {
transpile: *std.Build.Step.Run,
};
pub const App = struct {
exe: *std.Build.Step.Compile,
module: *std.Build.Module,
};
/// Output transformer: injects elements into the generated output
pub const Transformer = struct {
b: *std.Build,
@ -674,6 +674,13 @@ pub const Build = struct {
build: *std.Build,
/// The app's canonical `zx` module. Component modules created via
/// `addComponent` are bound to this so the whole app shares one zx graph.
zx_module: *std.Build.Module,
/// The app's root executable and generated `app` module.
app: App,
cmd: BuildCommand,
outdir: LazyPath,
@ -682,6 +689,32 @@ pub const Build = struct {
cli: BuildZiex,
transformer: Transformer,
/// Transpile a `.zx` component file and return a Zig module wired to this
/// app's canonical `zx` module.
pub fn addComponent(self: Build, opts: build_zig.TranslatedZx.Options) *std.Build.Module {
var component_opts = opts;
// Inherit the app's target/optimize unless the caller overrode them.
if (component_opts.target == null) component_opts.target = self.zx_module.resolved_target;
if (component_opts.optimize == null) component_opts.optimize = self.zx_module.optimize;
const mod = build_zig.addTranslateZx(self.build, component_opts).createModule();
mod.addImport("zx", self.zx_module);
return mod;
}
/// Like `addComponent`, but also imports the resulting module onto the app's
/// root module and generated `app` module under `name`.
pub fn addComponentImport(self: Build, name: []const u8, opts: build_zig.TranslatedZx.Options) void {
const mod = self.addComponent(opts);
self.addImport(name, mod);
}
/// Add an import to app's root module and generated `app` module under `name`.
pub fn addImport(self: Build, name: []const u8, mod: *std.Build.Module) void {
self.app.exe.root_module.addImport(name, mod);
self.app.module.addImport(name, mod);
}
};
const ServerOnlyStubMode = enum {

View File

@ -79,18 +79,6 @@ pub const AppOptions = struct {
/// If `null`, defaults to root path ("/").
base_path: ?[]const u8 = null,
/// Copy embedded `.zx` source files to the transpile output directory.
///
/// When enabled, any `.zx` files referenced via `@embedFile` in your templates
/// will be copied to the output directory alongside the generated `.zig` files,
/// and the `@embedFile` paths will be updated to reference the local copies.
///
/// This is useful when you want to display source code examples in your app
/// and need the files accessible within the package boundary.
///
/// Default: `false`
copy_embedded_sources: bool = false,
/// Features that can be optionally enabled
features: FeatureOptions = .default,
};

View File

@ -68,7 +68,7 @@ var g_dev_shutting_down: bool = false;
fn onDevShutdown() void {
if (g_dev_shutting_down) return;
g_dev_shutting_down = true;
std.debug.print("\n{s}Stopping dev server...{s}\n", .{ Colors.gray, Colors.reset });
std.debug.print("\n{s}Stopping dev server...{s}", .{ Colors.gray, Colors.reset });
if (comptime builtin.os.tag != .windows) {
if (runner) |r| if (r.id) |pid| std.posix.kill(pid, std.posix.SIG.KILL) catch {};
if (builder) |b| if (b.id) |pid| std.posix.kill(pid, std.posix.SIG.KILL) catch {};

View File

@ -32,13 +32,6 @@ const map_flag = zli.Flag{
.default_value = .{ .String = "none" },
};
const copy_embedded_sources_flag = zli.Flag{
.name = "copy-embedded-sources",
.description = "Copy embedded .zx source files to output directory and rewrite paths",
.type = .Bool,
.default_value = .{ .Bool = false },
};
const rootdir_flag = zli.Flag{
.name = "rootdir",
.description = "Root directory for the generated meta file (e.g., static dir)",
@ -77,7 +70,6 @@ pub fn register(writer: *std.Io.Writer, reader: *std.Io.Reader, allocator: std.m
try cmd.addFlag(copy_only_flag);
try cmd.addFlag(flags.verbose_flag);
try cmd.addFlag(map_flag);
try cmd.addFlag(copy_embedded_sources_flag);
try cmd.addFlag(rootdir_flag);
try cmd.addFlag(depfile_flag);
try cmd.addFlag(cachedir_flag);
@ -96,7 +88,6 @@ fn transpile(ctx: zli.CommandContext) !void {
const copy_only = ctx.flag("copy-only", bool);
const verbose = ctx.flag("verbose", bool);
const sourcemap_str = ctx.flag("map", []const u8);
const copy_embedded_sources = ctx.flag("copy-embedded-sources", bool);
const rootdir_str = ctx.flag("rootdir", []const u8);
const rootdir: ?[]const u8 = if (rootdir_str.len > 0) rootdir_str else null;
const depfile_str = ctx.flag("dep-file", []const u8);
@ -145,7 +136,6 @@ fn transpile(ctx: zli.CommandContext) !void {
.outdir = outdir,
.verbose = verbose,
.map = map,
.copy_embedded_sources = copy_embedded_sources,
.rootdir = rootdir,
.dep_file = dep_file,
.cache_dir = cache_dir,
@ -230,7 +220,6 @@ fn transpile(ctx: zli.CommandContext) !void {
.outdir = outdir,
.verbose = verbose,
.map = map,
.copy_embedded_sources = copy_embedded_sources,
.rootdir = rootdir,
.dep_file = dep_file,
.cache_dir = cache_dir,
@ -269,46 +258,184 @@ fn writeDepFile(allocator: std.mem.Allocator, path: []const u8, target: []const
try f.writePositionalAll(io, buf.items, 0);
}
/// Scan source for `@embedFile("...")` references and add resolved paths as dependencies.
// TODO: collect @import(...), only add .zig files to deps that are actually imported
// TODO: also we should be able to collect the .zx dependancy tree and transpile the ones that is only used,
// however that will make multi threading hard but we may do this in the future just to save transpilation time
// currently transpilation takes ms for files in hundreds, but for larger project we may need to optimize this by only transpiling the used files
fn collectEmbedFileDeps(
/// Walk the transpiled Zig AST and, for each `@embedFile("...")` whose target
/// exists on disk (relative to `source_dir`), copy it into the output dir at the
/// same relative spelling (so the emitted `@embedFile` resolves at compile time)
/// and record its absolute source path in `input_files` for the dep file.
fn collectEmbedFiles(
allocator: std.mem.Allocator,
input_files: *std.array_list.Managed([]const u8),
source: []const u8,
ast: *const std.zig.Ast,
source_dir: []const u8,
out_dir: []const u8,
) !void {
const io = std.Io.Threaded.global_single_threaded.io();
for (0..ast.nodes.len) |i| {
const node: std.zig.Ast.Node.Index = @enumFromInt(i);
switch (ast.nodeTag(node)) {
.builtin_call_two, .builtin_call_two_comma => {},
else => continue,
}
if (!std.mem.eql(u8, ast.tokenSlice(ast.nodeMainToken(node)), "@embedFile")) continue;
const arg = ast.nodeData(node).opt_node_and_opt_node[0].unwrap() orelse continue;
if (ast.nodeTag(arg) != .string_literal) continue;
const raw = ast.tokenSlice(ast.nodeMainToken(arg));
const embed_path = std.zig.string_literal.parseAlloc(allocator, raw) catch continue;
defer allocator.free(embed_path);
const src = std.fs.path.join(allocator, &.{ source_dir, embed_path }) catch continue;
defer allocator.free(src);
// Only act on embeds that exist as real source files on disk.
if (std.Io.Dir.cwd().statFile(io, src, .{})) |_| {} else |_| continue;
// Copy into the output dir at the embed's relative spelling.
const dst = std.fs.path.join(allocator, &.{ out_dir, embed_path }) catch continue;
defer allocator.free(dst);
if (std.fs.path.dirname(dst)) |parent| {
std.Io.Dir.cwd().createDirPath(io, parent) catch {};
}
std.Io.Dir.cwd().copyFile(src, std.Io.Dir.cwd(), dst, io, .{}) catch |err| {
std.debug.print("Warning: Could not copy embedded file {s}: {}\n", .{ src, err });
};
const abs = std.fs.path.resolve(allocator, &.{src}) catch continue;
input_files.append(abs) catch allocator.free(abs);
}
}
/// Re-run the `@embedFile` copy for a cached `.zx`/`.mdzx` whose transpilation was
/// skipped. On a cache hit `transpileFile` (and thus `collectEmbedFiles`) never runs
fn copyEmbedsForCached(
allocator: std.mem.Allocator,
input_files: *std.array_list.Managed([]const u8),
zig_path: []const u8,
source_dir: []const u8,
out_dir: []const u8,
) !void {
const io = std.Io.Threaded.global_single_threaded.io();
const zig_src = try std.Io.Dir.cwd().readFileAlloc(
io,
zig_path,
allocator,
std.Io.Limit.limited(std.math.maxInt(usize)),
);
defer allocator.free(zig_src);
const zig_src_z = try allocator.dupeSentinel(u8, zig_src, 0);
defer allocator.free(zig_src_z);
var ast = try std.zig.Ast.parse(allocator, zig_src_z, .zig);
defer ast.deinit(allocator);
try collectEmbedFiles(allocator, input_files, &ast, source_dir, out_dir);
}
/// Walk the transpiled Zig AST (`ast`, already produced by `core_lang.Ast.parse`)
/// and append each `@import("...")` target that resolves to an existing
/// `.zx`/`.mdzx` source (relative to `source_dir`) to `out`.
fn collectZxImports(
allocator: std.mem.Allocator,
out: *std.array_list.Managed([]const u8),
ast: *const std.zig.Ast,
source_dir: []const u8,
) !void {
const needle = "@embedFile(\"";
var pos: usize = 0;
while (std.mem.indexOfPos(u8, source, pos, needle)) |start| {
const path_start = start + needle.len;
const path_end = std.mem.indexOfPos(u8, source, path_start, "\"") orelse break;
const embed_path = source[path_start..path_end];
pos = path_end + 1;
const io = std.Io.Threaded.global_single_threaded.io();
const resolved = std.fs.path.join(allocator, &.{ source_dir, embed_path }) catch continue;
defer allocator.free(resolved);
for (0..ast.nodes.len) |i| {
const node: std.zig.Ast.Node.Index = @enumFromInt(i);
switch (ast.nodeTag(node)) {
.builtin_call_two, .builtin_call_two_comma => {},
else => continue,
}
const abs_path = std.fs.path.resolve(allocator, &.{resolved}) catch continue;
const main_tok = ast.nodeMainToken(node);
if (!std.mem.eql(u8, ast.tokenSlice(main_tok), "@import")) continue;
// Only record embeds that actually exist on disk. .zx files often
// @embedFile() the transpiled .zig output of a sibling .zx that
// file doesn't exist as a source and listing it in the dep file
// makes the build harness fail with FileNotFound.
const io_local = std.Io.Threaded.global_single_threaded.io();
if (std.Io.Dir.cwd().statFile(io_local, abs_path, .{})) |_| {
input_files.append(abs_path) catch {
allocator.free(abs_path);
// First argument: the import path string literal.
const arg = ast.nodeData(node).opt_node_and_opt_node[0].unwrap() orelse continue;
if (ast.nodeTag(arg) != .string_literal) continue;
const raw = ast.tokenSlice(ast.nodeMainToken(arg));
const import_path = std.zig.string_literal.parseAlloc(allocator, raw) catch continue;
defer allocator.free(import_path);
// Map the rewritten `.zig` target back to its `.zx`/`.mdzx` source. Plain
// `@import("std")` / module imports have no extension and are skipped.
const stem = if (std.mem.endsWith(u8, import_path, ".zig"))
import_path[0 .. import_path.len - ".zig".len]
else if (std.mem.endsWith(u8, import_path, ".zx"))
import_path[0 .. import_path.len - ".zx".len]
else if (std.mem.endsWith(u8, import_path, ".mdzx"))
import_path[0 .. import_path.len - ".mdzx".len]
else
continue;
for ([_][]const u8{ ".zx", ".mdzx" }) |ext| {
const cand = std.fmt.allocPrint(allocator, "{s}{s}", .{ stem, ext }) catch continue;
const resolved = std.fs.path.join(allocator, &.{ source_dir, cand }) catch {
allocator.free(cand);
continue;
};
} else |_| {
allocator.free(abs_path);
defer allocator.free(resolved);
if (std.Io.Dir.cwd().statFile(io, resolved, .{})) |_| {
// Return the import spelling relative to source_dir (e.g. `icon.zx`).
out.append(cand) catch allocator.free(cand);
break;
} else |_| allocator.free(cand);
}
}
}
/// Transpile `source_path` into `output_path`, then recursively transpile every
/// `.zx`/`.mdzx` file it `@import`s (resolved relative to the importing file)
/// into the same `outdir`.
fn transpileFileRecursive(
allocator: std.mem.Allocator,
global_components: *std.array_list.Managed(ClientComponentSerializable),
opts: TranspileOptions,
source_path: []const u8,
output_path: []const u8,
visited: *std.StringHashMap(void),
input_files: *std.array_list.Managed([]const u8),
) !void {
const abs_source = std.fs.path.resolve(allocator, &.{source_path}) catch
try allocator.dupe(u8, source_path);
if (visited.contains(abs_source)) {
allocator.free(abs_source);
return;
}
// `visited` owns abs_source; also record it as a dep file input.
try visited.put(abs_source, {});
try input_files.append(try allocator.dupe(u8, abs_source));
// Transpile this file, collecting its `.zx` imports from the parsed AST.
var imports = std.array_list.Managed([]const u8).init(allocator);
defer {
for (imports.items) |p| allocator.free(p);
imports.deinit();
}
try transpileFile(allocator, global_components, opts, source_path, output_path, &imports, input_files);
const source_dir = std.fs.path.dirname(source_path) orelse ".";
const out_dir = std.fs.path.dirname(output_path) orelse opts.outdir;
for (imports.items) |rel| {
const dep_source = try std.fs.path.join(allocator, &.{ source_dir, rel });
defer allocator.free(dep_source);
const ext_len: usize = if (std.mem.endsWith(u8, rel, ".mdzx")) ".mdzx".len else ".zx".len;
const out_rel = try std.mem.concat(allocator, u8, &.{ rel[0 .. rel.len - ext_len], ".zig" });
defer allocator.free(out_rel);
const dep_output = try std.fs.path.join(allocator, &.{ out_dir, out_rel });
defer allocator.free(dep_output);
try transpileFileRecursive(allocator, global_components, opts, dep_source, dep_output, visited, input_files);
}
}
// ---- Component Cache (per-file incremental) ---- //
fn writeComponentCache(
@ -1292,6 +1419,8 @@ fn transpileFile(
opts: TranspileOptions,
source_path: []const u8,
output_path: []const u8,
imports_out: ?*std.array_list.Managed([]const u8),
input_files: *std.array_list.Managed([]const u8),
) !void {
const io = std.Io.Threaded.global_single_threaded.io();
const source = try std.Io.Dir.cwd().readFileAlloc(
@ -1326,6 +1455,16 @@ fn transpileFile(
});
defer result.deinit(allocator);
const ast_source_dir = std.fs.path.dirname(source_path) orelse ".";
if (imports_out) |out| {
try collectZxImports(allocator, out, &result.zig_ast, ast_source_dir);
}
// Auto-copy any files this component `@embedFile`s into the output dir.
{
const ast_out_dir = std.fs.path.dirname(output_path) orelse opts.outdir;
try collectEmbedFiles(allocator, input_files, &result.zig_ast, ast_source_dir, ast_out_dir);
}
// Extract route from source path
const component_route = try extractRouteFromPath(allocator, relative_source_path);
defer allocator.free(component_route);
@ -1525,18 +1664,12 @@ fn transpileDirectory(
const output_path = try std.fs.path.join(allocator, &.{ opts.outdir, output_rel_path });
defer allocator.free(output_path);
// Track this input for the dep file (absolute path for Make format)
// Track this input for the dep file (absolute path for Make format).
// Embedded-file deps are collected by `transpileFile` via the AST.
const abs_input = std.fs.path.resolve(allocator, &.{input_path}) catch
try allocator.dupe(u8, input_path);
try input_files.append(abs_input);
// Scan for @embedFile references and track them as dependencies
const source_dir = std.fs.path.dirname(input_path) orelse ".";
if (std.Io.Dir.cwd().readFileAlloc(io, input_path, allocator, std.Io.Limit.limited(4 * 1024 * 1024))) |source| {
defer allocator.free(source);
try collectEmbedFileDeps(allocator, input_files, source, source_dir);
} else |_| {}
const cache_base = opts.cache_dir orelse opts.outdir;
const cache_out_path = try std.fs.path.join(allocator, &.{ cache_base, output_rel_path });
defer allocator.free(cache_out_path);
@ -1565,10 +1698,19 @@ fn transpileDirectory(
std.debug.print("Warning: Failed to copy cached file {s} to {s}: {}\n", .{ cache_out_path, output_path, err });
};
}
// Transpilation was skipped, so `collectEmbedFiles` never ran for
// this file. Replay the `@embedFile` copy from the cached `.zig`
{
const embed_src_dir = std.fs.path.dirname(input_path) orelse ".";
const embed_out_dir = std.fs.path.dirname(output_path) orelse opts.outdir;
copyEmbedsForCached(allocator, input_files, cache_out_path, embed_src_dir, embed_out_dir) catch |err| {
std.debug.print("Warning: Failed to copy embedded files for cached {s}: {}\n", .{ input_path, err });
};
}
if (opts.verbose) std.debug.print("Skipped (up-to-date): {s}\n", .{input_path});
} else {
const components_before = global_components.items.len;
transpileFile(allocator, global_components, opts, input_path, output_path) catch |err| {
transpileFile(allocator, global_components, opts, input_path, output_path, null, input_files) catch |err| {
global_components.items.len = components_before;
std.debug.print("Error transpiling {s}: {}\n", .{ input_path, err });
continue;
@ -1587,28 +1729,6 @@ fn transpileDirectory(
std.debug.print("Warning: Failed to write component cache for {s}: {}\n", .{ input_path, err });
};
}
// Also copy the original .zx file if copy_embedded_sources is enabled
if (opts.copy_embedded_sources) {
const zx_output_path = try std.fs.path.join(allocator, &.{ opts.outdir, entry.path });
defer allocator.free(zx_output_path);
if (std.fs.path.dirname(zx_output_path)) |parent| {
std.Io.Dir.cwd().createDirPath(io, parent) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => {
std.debug.print("Error creating directory {s}: {}\n", .{ parent, err });
continue;
},
};
}
std.Io.Dir.cwd().copyFile(input_path, std.Io.Dir.cwd(), zx_output_path, io, .{}) catch |err| {
std.debug.print("Warning: Could not copy .zx source {s}: {}\n", .{ input_path, err });
continue;
};
if (opts.verbose) std.debug.print("Copied source: {s} -> {s}\n", .{ input_path, zx_output_path });
}
} else {
// Copy all non-.zx files from the site directory, excluding reserved files
const basename = getBasename(entry.path);
@ -1666,7 +1786,6 @@ const TranspileOptions = struct {
outdir: []const u8,
verbose: bool,
map: core_lang.Ast.ParseOptions.MapMode = .none,
copy_embedded_sources: bool = false,
rootdir: ?[]const u8 = null,
dep_file: ?[]const u8 = null,
cache_dir: ?[]const u8 = null,
@ -1738,7 +1857,13 @@ fn transpileCommand(allocator: std.mem.Allocator, opts: TranspileOptions) !void
const outpath = try std.fs.path.join(allocator, &.{ opts.outdir, output_rel_path });
defer allocator.free(outpath);
try transpileFile(allocator, &all_client_cmps, opts, opts.path, outpath);
var visited = std.StringHashMap(void).init(allocator);
defer {
var it = visited.keyIterator();
while (it.next()) |k| allocator.free(k.*);
visited.deinit();
}
try transpileFileRecursive(allocator, &all_client_cmps, opts, opts.path, outpath, &visited, &input_files);
task.completeOne();
},
else => {
@ -1755,8 +1880,9 @@ fn transpileCommand(allocator: std.mem.Allocator, opts: TranspileOptions) !void
}
// Generate routes
genRoutes(allocator, opts.outdir, opts.rootdir, opts.base_path, opts.verbose) catch |err| {
std.debug.print("Warning: Failed to generate meta.zig: {}\n", .{err});
genRoutes(allocator, opts.outdir, opts.rootdir, opts.base_path, opts.verbose) catch |err| switch (err) {
error.NoPagesOrRoutes => {}, // No routes to generate is not an error
else => std.debug.print("Warning: Failed to generate meta.zig: {}\n", .{err}),
};
// --- @rendering -> Client Side Rendering Related Files Generation --- //

View File

@ -63,7 +63,8 @@ pub fn parse(gpa: std.mem.Allocator, zx_source: [:0]const u8, options: ParseOpti
errdefer diagnostics.deinit();
const render_result = try parse_result.renderAlloc(arena, .{ .mode = .zig, .sourcemap = options.map.enabled(), .path = options.path });
var zig_ast = try std.zig.Ast.parse(gpa, try arena.dupeSentinel(u8, render_result.source, 0), .zig);
const zx_sourcez = try gpa.dupeSentinel(u8, render_result.source, 0);
var zig_ast = try std.zig.Ast.parse(gpa, zx_sourcez, .zig);
const zig_sourcez = try arena.dupeSentinel(u8, if (zig_ast.errors.len == 0) try zig_ast.renderAlloc(arena) else render_result.source, 0);
var components = std.ArrayList(ClientComponentMetadata).empty;
@ -86,7 +87,7 @@ pub fn parse(gpa: std.mem.Allocator, zx_source: [:0]const u8, options: ParseOpti
return ParseResult{
.zig_ast = zig_ast,
.zx_source = try gpa.dupeSentinel(u8, render_result.source, 0),
.zx_source = zx_sourcez,
.zig_source = try gpa.dupeSentinel(u8, zig_sourcez, 0),
.client_components = components,
.sourcemap = result_sourcemap,