fix(ts): preserve component import build parity
This commit is contained in:
@@ -126,17 +126,43 @@ test "root TypeScript markup discovery classification and resolver budgets" {
|
||||
test "generated TypeScript runners install the compiled root markup view" {
|
||||
const desktop = @embedFile("src/app_runner/ts_core_main.zig");
|
||||
try std.testing.expect(std.mem.indexOf(u8, desktop, "TsUiAppWithFeatures(core, .{ .runtime_markup = dev })") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, desktop, "if (dev) void else @import(\"app_markup_root\")") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, desktop, "CompiledMarkupImports(core.Model, core.Msg, \"app.native\", &app_markup_sources)") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, desktop, ".view = CompiledAppView.build") != null);
|
||||
|
||||
const mobile = @embedFile("src/app_runner/ts_core_mobile.zig");
|
||||
try std.testing.expect(std.mem.indexOf(u8, mobile, "pub const features: native_sdk.UiAppFeatures = .{ .runtime_markup = false }") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, mobile, "TsUiAppWithFeatures(core, features)") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, mobile, "const app_markup_root = @import(\"app_markup_root\")") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, mobile, "CompiledMarkupImports(core.Model, core.Msg, \"app.native\", &app_markup_sources)") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, mobile, ".view = CompiledAppView.build") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, mobile, ".markup =") == null);
|
||||
}
|
||||
|
||||
test "Debug TypeScript root markup stays outside the staged app module" {
|
||||
const source = @embedFile("build/app.zig");
|
||||
try std.testing.expect(std.mem.indexOf(
|
||||
u8,
|
||||
source,
|
||||
"_ = staged.addCopyFile(b.path(appPath(b, app_root, \"src/app.native\")), \"app.native\");",
|
||||
) == null);
|
||||
try std.testing.expect(std.mem.indexOf(
|
||||
u8,
|
||||
source,
|
||||
"_ = release_markup.addCopyFile(b.path(appPath(b, app_root, \"src/app.native\")), \"app.native\");",
|
||||
) != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, source, "if (optimize != .Debug)") != null);
|
||||
}
|
||||
|
||||
test "native check preserves the app markup root for component files" {
|
||||
const source = @embedFile("tools/native-sdk/main.zig");
|
||||
try std.testing.expect(std.mem.indexOf(
|
||||
u8,
|
||||
source,
|
||||
"checkFiles(allocator, io, markup_files.items, .{ .import_root = \"src\" })",
|
||||
) != null);
|
||||
}
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const host_target = b.graph.host;
|
||||
|
||||
+37
-6
@@ -483,6 +483,10 @@ const TsCoreStage = struct {
|
||||
/// mirror/registry files): the embed static library's `app`
|
||||
/// module roots here on iOS/Android targets.
|
||||
mobile_root: std.Build.LazyPath,
|
||||
/// Release/mobile-only module embedding src/app.native. It lives in a
|
||||
/// separate generated directory so a Debug app-code module never gains
|
||||
/// the root markup as a transitive file input; Debug links markup_c.
|
||||
app_markup_root: std.Build.LazyPath,
|
||||
/// The compiled-core archive: the app module links it (with libc,
|
||||
/// for the toolchain's runtime) beside the staged mirror.
|
||||
archive: std.Build.LazyPath,
|
||||
@@ -1310,12 +1314,12 @@ fn tsCoreStage(
|
||||
\\
|
||||
, .{ service_carrier, service_pool_workers }));
|
||||
_ = staged.addCopyFile(migrations_zig, "migrations.zig");
|
||||
// Release desktop and mobile compile the complete root markup closure at
|
||||
// comptime, so stage the root and every non-window import beside the
|
||||
// generated runner. Debug desktop still reads the root through the small
|
||||
// linked C data object below, preserving link-only rebuilds for ordinary
|
||||
// edits while using these staged imports for the runtime resolver.
|
||||
_ = staged.addCopyFile(b.path(appPath(b, app_root, "src/app.native")), "app.native");
|
||||
// Every imported file is an input to Debug's embedded source resolver and
|
||||
// the release/mobile compiled view, so stage those beside the runner.
|
||||
// Keep the ROOT out of this directory: changing any file in one WriteFiles
|
||||
// output changes its directory identity, and the runner lives here. Root
|
||||
// markup gets a separate release-only module below; Debug keeps the small
|
||||
// linked C data object, preserving its link-only edit path.
|
||||
for (app_markup_sources.files) |source| {
|
||||
_ = staged.addCopyFile(b.path(appPath(b, app_root, source.source_path)), source.staged_path);
|
||||
}
|
||||
@@ -1338,9 +1342,17 @@ fn tsCoreStage(
|
||||
// registry, same carrier constant — only the shell differs (the embed
|
||||
// host's AppDef contract instead of a process `main`).
|
||||
const mobile_root = staged.addCopyFile(dep.path("src/app_runner/ts_core_mobile.zig"), "mobile.zig");
|
||||
const release_markup = b.addWriteFiles();
|
||||
_ = release_markup.addCopyFile(b.path(appPath(b, app_root, "src/app.native")), "app.native");
|
||||
const app_markup_root = release_markup.add("app_markup_root.zig",
|
||||
\\//! Generated release/mobile embedding for the TypeScript root view.
|
||||
\\pub const source = @embedFile("app.native");
|
||||
\\
|
||||
);
|
||||
return .{
|
||||
.main_root = main_root,
|
||||
.mobile_root = mobile_root,
|
||||
.app_markup_root = app_markup_root,
|
||||
.archive = archive,
|
||||
.service_exe = service_exe,
|
||||
.service_archive = service_archive,
|
||||
@@ -1579,6 +1591,8 @@ pub const MobileLibOptions = struct {
|
||||
pub const MobileTsCore = struct {
|
||||
/// The staged mobile wiring (mobile.zig beside the generated mirror).
|
||||
main_root: std.Build.LazyPath,
|
||||
/// Separate root-markup module consumed by the compiled mobile view.
|
||||
app_markup_root: std.Build.LazyPath,
|
||||
/// The compiled-core archive; merged into the embed library.
|
||||
archive: std.Build.LazyPath,
|
||||
/// The in-process service archive, when src/services exists.
|
||||
@@ -1647,6 +1661,11 @@ fn addMobileLibWithTarget(b: *std.Build, dep: *std.Build.Dependency, target: std
|
||||
.optimize = optimize,
|
||||
});
|
||||
mod.addImport("app_manifest_zon", ts.manifest_mod);
|
||||
mod.addImport("app_markup_root", b.createModule(.{
|
||||
.root_source_file = ts.app_markup_root,
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}));
|
||||
break :ts_app mod;
|
||||
} else localModule(b, target, optimize, options.main);
|
||||
app_mod.addImport("native_sdk", native_sdk_mod);
|
||||
@@ -1836,6 +1855,7 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
|
||||
.max_image_pixel_bytes = app_config.max_image_pixel_bytes,
|
||||
.ts_core = if (ts_stage) |stage| .{
|
||||
.main_root = stage.mobile_root,
|
||||
.app_markup_root = stage.app_markup_root,
|
||||
.archive = stage.archive,
|
||||
.service_archive = stage.service_archive,
|
||||
.manifest_mod = appManifestModule(b, app_options.app_root, manifest_name),
|
||||
@@ -2184,6 +2204,17 @@ fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Resolv
|
||||
// toolchain's runtime needs libc.
|
||||
app_mod.link_libc = true;
|
||||
app_mod.addObjectFile(stage.archive);
|
||||
// Debug reads src/app.native only through the separately linked C
|
||||
// data object. Supplying this generated module in Debug would put
|
||||
// the authored root back into the app-code dependency graph even
|
||||
// though the runner's comptime branch never imports it.
|
||||
if (optimize != .Debug) {
|
||||
app_mod.addImport("app_markup_root", b.createModule(.{
|
||||
.root_source_file = stage.app_markup_root,
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}));
|
||||
}
|
||||
// The in-process service archive links beside it (distinct symbol
|
||||
// prefix; its runtime internals are localized).
|
||||
if (stage.service_archive) |service_archive| app_mod.addObjectFile(service_archive);
|
||||
|
||||
@@ -95,8 +95,9 @@ pub fn appMarkup() []const u8 {
|
||||
// Debug keeps root markup in the separately linked data object above, so an
|
||||
// edit is a C-data compile + relink and the runtime interpreter owns imports.
|
||||
// Release embeds the same closure and compiles it to direct view code.
|
||||
const app_markup_sources = [_]native_sdk.canvas.ui_markup.SourceFile{
|
||||
.{ .path = "app.native", .source = @embedFile("app.native") },
|
||||
const app_markup_root = if (dev) void else @import("app_markup_root");
|
||||
const app_markup_sources = if (dev) void else [_]native_sdk.canvas.ui_markup.SourceFile{
|
||||
.{ .path = "app.native", .source = app_markup_root.source },
|
||||
} ++ app_sources.sources;
|
||||
const CompiledAppView = if (dev)
|
||||
void
|
||||
|
||||
@@ -40,6 +40,7 @@ pub const core = @import("core.zig");
|
||||
const services = @import("services.zig");
|
||||
const service_carrier = @import("service_carrier.zig");
|
||||
const app_sources = @import("app_sources.zig");
|
||||
const app_markup_root = @import("app_markup_root");
|
||||
|
||||
/// Shared with the embed host: its UiApp type must use the same feature set
|
||||
/// as this module's TypeScript adapter or their Options types are distinct.
|
||||
@@ -51,9 +52,8 @@ const Adapter = native_sdk.TsUiAppWithFeatures(core, features);
|
||||
pub const Model = core.Model;
|
||||
pub const Msg = core.Msg;
|
||||
|
||||
pub const app_markup = @embedFile("app.native");
|
||||
const app_markup_sources = [_]native_sdk.canvas.ui_markup.SourceFile{
|
||||
.{ .path = "app.native", .source = app_markup },
|
||||
.{ .path = "app.native", .source = app_markup_root.source },
|
||||
} ++ app_sources.sources;
|
||||
const CompiledAppView = native_sdk.canvas.CompiledMarkupImports(core.Model, core.Msg, "app.native", &app_markup_sources);
|
||||
|
||||
|
||||
@@ -3899,10 +3899,27 @@ pub fn resolveImports(
|
||||
root_source: []const u8,
|
||||
loader: ImportLoader,
|
||||
diagnostic: *MarkupErrorInfo,
|
||||
) ResolveError!MarkupDocument {
|
||||
return resolveImportsFromRoot(arena, dirnamePath(root_name), root_name, root_source, loader, diagnostic);
|
||||
}
|
||||
|
||||
/// Resolve an import closure against an explicit markup root. App-wide
|
||||
/// tooling uses this form while checking component files independently:
|
||||
/// every file under `src/` keeps the app root, so a component may import a
|
||||
/// sibling directory exactly as it does when reached from `src/app.native`.
|
||||
/// Direct `native markup check <file>` continues through `resolveImports`
|
||||
/// above, where the checked file's own directory is the root.
|
||||
pub fn resolveImportsFromRoot(
|
||||
arena: std.mem.Allocator,
|
||||
root_dir: []const u8,
|
||||
root_name: []const u8,
|
||||
root_source: []const u8,
|
||||
loader: ImportLoader,
|
||||
diagnostic: *MarkupErrorInfo,
|
||||
) ResolveError!MarkupDocument {
|
||||
var resolver = ImportResolver{
|
||||
.arena = arena,
|
||||
.root_dir = dirnamePath(root_name),
|
||||
.root_dir = root_dir,
|
||||
.loader = loader,
|
||||
.diagnostic = diagnostic,
|
||||
};
|
||||
|
||||
@@ -1450,6 +1450,43 @@ test "an absolute root path resolves imports the same as a relative one" {
|
||||
try testing.expectEqualStrings(markup.import_src_escape_message, escaped.message);
|
||||
}
|
||||
|
||||
test "an explicit app root lets an independently checked component import a sibling directory" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const set = [_]markup.SourceFile{
|
||||
.{
|
||||
.path = "src/components/card.native",
|
||||
.source = "<import src=\"../shared/base.native\"/>\n<template name=\"card\"><use template=\"base\" /></template>",
|
||||
},
|
||||
.{
|
||||
.path = "src/shared/base.native",
|
||||
.source = "<template name=\"base\"><text>shared</text></template>",
|
||||
},
|
||||
};
|
||||
var loader = markup.SourceSetLoader{ .set = &set };
|
||||
var diagnostic: markup.MarkupErrorInfo = .{};
|
||||
const document = try markup.resolveImportsFromRoot(
|
||||
arena,
|
||||
"src",
|
||||
set[0].path,
|
||||
set[0].source,
|
||||
loader.loader(),
|
||||
&diagnostic,
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 2), document.templates.len);
|
||||
try testing.expectEqualStrings("base", document.templates[0].attr("name").?);
|
||||
try testing.expectEqualStrings("card", document.templates[1].attr("name").?);
|
||||
|
||||
// The direct-file resolver deliberately retains its narrower boundary.
|
||||
try testing.expectError(
|
||||
error.MarkupImport,
|
||||
markup.resolveImports(arena, set[0].path, set[0].source, loader.loader(), &diagnostic),
|
||||
);
|
||||
try testing.expectEqualStrings(markup.import_src_escape_message, diagnostic.message);
|
||||
}
|
||||
|
||||
test ".native is the one markup extension" {
|
||||
try testing.expect(markup.hasMarkupExtension("view.native"));
|
||||
try testing.expect(!markup.hasMarkupExtension("view.html"));
|
||||
|
||||
@@ -715,7 +715,7 @@ fn runCheck(allocator: std.mem.Allocator, io: std.Io, env_map: *std.process.Envi
|
||||
try collectMarkupFiles(allocator, io, "src", &markup_files);
|
||||
var outcome = markup_cli.CheckOutcome{};
|
||||
if (markup_files.items.len > 0) {
|
||||
outcome = try markup_cli.checkFiles(allocator, io, markup_files.items);
|
||||
outcome = try markup_cli.checkFiles(allocator, io, markup_files.items, .{ .import_root = "src" });
|
||||
if (outcome.failures > 0) return error.MarkupCheckFailed;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, args: []const []const u8) !
|
||||
return error.MarkupCommandFailed;
|
||||
}
|
||||
|
||||
const outcome = try checkFiles(allocator, io, files.items);
|
||||
const outcome = try checkFiles(allocator, io, files.items, .{});
|
||||
// Exit directly: the diagnostics above are the whole story, and a
|
||||
// returned error would bury them under the CLI's own return trace.
|
||||
if (outcome.failures > 0) std.process.exit(1);
|
||||
@@ -55,13 +55,21 @@ pub const CheckOutcome = struct {
|
||||
contract_checked: bool = false,
|
||||
};
|
||||
|
||||
pub const CheckOptions = struct {
|
||||
/// Shared import boundary for every checked file. `native check` sets
|
||||
/// this to `src`, matching the generated app resolver; the standalone
|
||||
/// markup command leaves it null so each explicitly named file remains
|
||||
/// rooted at its own directory.
|
||||
import_root: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// The `check` body shared by `native markup check` and `native check`:
|
||||
/// structural validation of every file, plus — when the working directory
|
||||
/// is an app with a FRESH model-contract artifact — the model-aware
|
||||
/// contract pass and the dead-state lint. A missing, stale, or unreadable
|
||||
/// artifact degrades to structural checking with a note, never a false
|
||||
/// pass.
|
||||
pub fn checkFiles(allocator: std.mem.Allocator, io: std.Io, files: []const []const u8) !CheckOutcome {
|
||||
pub fn checkFiles(allocator: std.mem.Allocator, io: std.Io, files: []const []const u8, options: CheckOptions) !CheckOutcome {
|
||||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
@@ -104,6 +112,7 @@ pub fn checkFiles(allocator: std.mem.Allocator, io: std.Io, files: []const []con
|
||||
.contract = if (contract_value) |*parsed| parsed else null,
|
||||
.usage = if (usage_state) |*live_usage| live_usage else null,
|
||||
.arena = arena,
|
||||
.import_root = options.import_root,
|
||||
}) catch {
|
||||
outcome.failures += 1;
|
||||
printOrphanHint(arena, io, file_path, &embedded_basenames);
|
||||
@@ -194,6 +203,41 @@ test "orphanNote: Zig track hints only for unembedded files" {
|
||||
try std.testing.expect(std.mem.indexOf(u8, note, "@embedFile") != null);
|
||||
}
|
||||
|
||||
test "app-wide checking preserves src as the component import root" {
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const io = std.testing.io;
|
||||
try tmp.dir.createDirPath(io, "src/components");
|
||||
try tmp.dir.createDirPath(io, "src/shared");
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "src/components/card.native",
|
||||
.data =
|
||||
\\<import src="../shared/base.native"/>
|
||||
\\<template name="card"><column><use template="base" /></column></template>
|
||||
,
|
||||
});
|
||||
try tmp.dir.writeFile(io, .{
|
||||
.sub_path = "src/shared/base.native",
|
||||
.data = "<template name=\"base\"><text>shared</text></template>\n",
|
||||
});
|
||||
|
||||
var root_buffer: [256]u8 = undefined;
|
||||
const root = try std.fmt.bufPrint(&root_buffer, ".zig-cache/tmp/{s}/src", .{tmp.sub_path[0..]});
|
||||
var path_buffer: [320]u8 = undefined;
|
||||
const component_path = try std.fmt.bufPrint(&path_buffer, "{s}/components/card.native", .{root});
|
||||
const checked = try checkFile(std.testing.allocator, io, component_path, .{
|
||||
.arena = std.testing.allocator,
|
||||
.import_root = root,
|
||||
});
|
||||
try std.testing.expect(!checked.had_view);
|
||||
|
||||
// An explicitly named standalone component keeps its own directory as
|
||||
// the root; reaching a sibling remains an escape in that narrower mode.
|
||||
try std.testing.expectError(error.MarkupImport, checkFile(std.testing.allocator, io, component_path, .{
|
||||
.arena = std.testing.allocator,
|
||||
}));
|
||||
}
|
||||
|
||||
/// The basename of every `@embedFile("...")` argument across the .zig
|
||||
/// sources under src/, each file read once.
|
||||
fn collectEmbeddedBasenames(arena: std.mem.Allocator, io: std.Io) ![]const []const u8 {
|
||||
@@ -339,6 +383,7 @@ const FileCheckContext = struct {
|
||||
/// Session arena for contract-check messages, which outlive the
|
||||
/// per-file arena (the dead-state summary prints after all files).
|
||||
arena: std.mem.Allocator,
|
||||
import_root: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
const FileCheckResult = struct {
|
||||
@@ -356,14 +401,16 @@ fn checkFile(allocator: std.mem.Allocator, io: std.Io, file_path: []const u8, co
|
||||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
// Resolve the import closure from disk, rooted at the checked file's
|
||||
// directory (the markup root): checking a view checks its imports, and
|
||||
// a broken import reports at the importing file's position. A file
|
||||
// that is all templates (no view root) is a valid component file —
|
||||
// it checks standalone and as an import target.
|
||||
// Resolve the import closure from disk. Standalone checks root at the
|
||||
// checked file's directory; app-wide `native check` supplies the app's
|
||||
// shared `src/` root so independently checked component files preserve
|
||||
// the same sibling-import boundary as src/app.native.
|
||||
var disk_loader = DiskLoader{ .io = io };
|
||||
var diagnostic: ui_markup.MarkupErrorInfo = .{};
|
||||
const document = ui_markup.resolveImports(arena_state.allocator(), file_path, source, disk_loader.loader(), &diagnostic) catch |err| {
|
||||
const document = (if (context.import_root) |root|
|
||||
ui_markup.resolveImportsFromRoot(arena_state.allocator(), root, file_path, source, disk_loader.loader(), &diagnostic)
|
||||
else
|
||||
ui_markup.resolveImports(arena_state.allocator(), file_path, source, disk_loader.loader(), &diagnostic)) catch |err| {
|
||||
const path = if (diagnostic.path.len > 0) diagnostic.path else file_path;
|
||||
std.debug.print("{s}:{d}:{d}: error: {s}\n", .{ path, diagnostic.line, diagnostic.column, diagnostic.message });
|
||||
printStaleBinaryHint(diagnostic.message);
|
||||
|
||||
Reference in New Issue
Block a user