dotframe 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5
4
+
5
+ - Native builds follow the entry's import graph instead of copying the game folder: relative imports that leave the game root and workspace packages that resolve to source are staged with their relative layout and rewritten to relative paths, so ports inside monorepos compile.
6
+
3
7
  ## 0.1.4
4
8
 
5
9
  Fixes from rounds 2 and 3 of the Craft Ones dogfood.
package/cli/native.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, relative, resolve } from "node:path";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { CliError, type Ctx, exec, home, print, type RunResult, which } from "./lib";
5
5
 
6
6
  // The engine this CLI belongs to: a git checkout or node_modules/dotframe.
@@ -44,42 +44,104 @@ export function vendorFix(platform: NativePlatform): string {
44
44
  return `dotframe vendor ${platform}`;
45
45
  }
46
46
 
47
- const SKIP = new Set(["node_modules", ".git", ".dotframe", "dist", "build", ".vercel"]);
48
-
49
- function copyTs(from: string, to: string): number {
50
- let n = 0;
51
- for (const name of readdirSync(from)) {
52
- if (SKIP.has(name)) continue;
53
- const src = join(from, name);
54
- if (statSync(src).isDirectory()) n += copyTs(src, join(to, name));
55
- else if (name.endsWith(".ts") || name.endsWith(".json")) {
56
- mkdirSync(to, { recursive: true });
57
- cpSync(src, join(to, name));
58
- n += 1;
47
+ const SPECIFIER = /(?:from\s+|import\s*\(\s*|import\s+)(["'])([^"']+)\1/g;
48
+ const EXTENSIONS = ["", ".ts", ".tsx", ".json", "/index.ts", "/index.tsx"];
49
+
50
+ function resolveFile(base: string): string | null {
51
+ const candidates = [base.replace(/\.js$/, ".ts"), base];
52
+ for (const c of candidates) for (const ext of EXTENSIONS) if (existsSync(c + ext) && statSync(c + ext).isFile()) return realpathSync(c + ext);
53
+ return null;
54
+ }
55
+
56
+ // A bare specifier that resolves, through node_modules, to TypeScript source outside node_modules: a workspace
57
+ // package (bun and npm link those). Published packages stay packages.
58
+ function resolveWorkspace(specifier: string, fromDir: string): string | null {
59
+ const parts = specifier.split("/");
60
+ const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
61
+ const sub = parts.slice(name.split("/").length).join("/");
62
+ for (let dir = fromDir; ; dir = dirname(dir)) {
63
+ const pkgDir = join(dir, "node_modules", name);
64
+ if (existsSync(pkgDir)) {
65
+ const real = realpathSync(pkgDir);
66
+ if (real.split(sep).includes("node_modules")) return null;
67
+ if (sub) return resolveFile(join(real, sub));
68
+ const pkg = JSON.parse(readFileSync(join(real, "package.json"), "utf8")) as Record<string, unknown>;
69
+ const exp = pkg.exports as Record<string, unknown> | string | undefined;
70
+ const dot = typeof exp === "string" ? exp : (exp?.["."] as Record<string, string> | string | undefined);
71
+ const entry = typeof dot === "string" ? dot : (dot?.import ?? dot?.default ?? dot?.types ?? (pkg.module as string) ?? (pkg.main as string) ?? "index.ts");
72
+ return resolveFile(join(real, entry));
59
73
  }
74
+ if (dirname(dir) === dir) return null;
60
75
  }
61
- return n;
62
76
  }
63
77
 
64
- // scriptc's static build takes relative imports only, and treats anything under node_modules as package code
65
- // for its dynamic engine. Staging copies the engine and the game side by side and rewrites "dotframe/..."
66
- // imports to relative paths.
67
- function rewriteImports(dir: string, engineRoot: string): void {
68
- for (const name of readdirSync(dir)) {
69
- const path = join(dir, name);
70
- if (statSync(path).isDirectory()) {
71
- rewriteImports(path, engineRoot);
72
- continue;
78
+ interface Graph {
79
+ files: string[];
80
+ // Per file, the specifiers to rewrite and the absolute file each one points at.
81
+ links: Map<string, Map<string, string>>;
82
+ }
83
+
84
+ // Every source file the entry reaches: relative imports (including ones that leave the game root) and workspace
85
+ // packages. "dotframe/..." is the engine, staged separately.
86
+ function importGraph(entry: string): Graph {
87
+ const files: string[] = [];
88
+ const links = new Map<string, Map<string, string>>();
89
+ const queue = [realpathSync(entry)];
90
+ const seen = new Set(queue);
91
+ while (queue.length > 0) {
92
+ const file = queue.shift() as string;
93
+ files.push(file);
94
+ if (!/\.tsx?$/.test(file)) continue;
95
+ const map = new Map<string, string>();
96
+ for (const m of readFileSync(file, "utf8").matchAll(SPECIFIER)) {
97
+ const spec = m[2];
98
+ if (spec.startsWith("dotframe/") || spec.startsWith("node:")) continue;
99
+ const target = spec.startsWith(".") ? resolveFile(resolve(dirname(file), spec)) : resolveWorkspace(spec, dirname(file));
100
+ if (!target) continue;
101
+ if (!spec.startsWith(".")) map.set(spec, target);
102
+ if (!seen.has(target)) {
103
+ seen.add(target);
104
+ queue.push(target);
105
+ }
106
+ }
107
+ links.set(file, map);
108
+ }
109
+ return { files, links };
110
+ }
111
+
112
+ function commonDir(paths: string[]): string {
113
+ let base = dirname(paths[0]);
114
+ for (const p of paths) while (!(p + sep).startsWith(base === sep ? sep : base + sep)) base = dirname(base);
115
+ return base;
116
+ }
117
+
118
+ function rel(fromFile: string, to: string): string {
119
+ const r = relative(dirname(fromFile), to);
120
+ return r.startsWith(".") ? r : `./${r}`;
121
+ }
122
+
123
+ // scriptc's static build takes relative imports only, and treats anything under node_modules as package code for
124
+ // its dynamic engine. Staging copies the engine and every reached game file side by side, keeping their relative
125
+ // layout, and rewrites "dotframe/..." and workspace imports to relative paths.
126
+ function stageGame(root: string, entry: string, tree: string, engineStage: string): string {
127
+ const graph = importGraph(join(root, entry));
128
+ const base = commonDir([realpathSync(root) + sep + "x", ...graph.files]);
129
+ const staged = (file: string): string => join(tree, relative(base, file));
130
+ for (const file of graph.files) {
131
+ const to = staged(file);
132
+ mkdirSync(dirname(to), { recursive: true });
133
+ let text = readFileSync(file, "utf8");
134
+ if (/\.tsx?$/.test(file)) {
135
+ const map = graph.links.get(file) ?? new Map<string, string>();
136
+ text = text.replace(SPECIFIER, (whole: string, q: string, spec: string): string => {
137
+ if (spec.startsWith("dotframe/")) return whole.replace(`${q}${spec}${q}`, `${q}${rel(to, join(engineStage, spec.slice("dotframe/".length)))}${q}`);
138
+ const target = map.get(spec);
139
+ return target ? whole.replace(`${q}${spec}${q}`, `${q}${rel(to, staged(target)).replace(/\.tsx?$/, "")}${q}`) : whole;
140
+ });
73
141
  }
74
- if (!name.endsWith(".ts")) continue;
75
- const text = readFileSync(path, "utf8");
76
- const next = text.replace(/(from\s+|import\s*\(\s*)(["'])dotframe\/([^"']+)\2/g, (_m: string, head: string, q: string, rest: string): string => {
77
- let rel = relative(dirname(path), join(engineRoot, rest));
78
- if (!rel.startsWith(".")) rel = `./${rel}`;
79
- return `${head}${q}${rel}${q}`;
80
- });
81
- if (next !== text) writeFileSync(path, next);
142
+ writeFileSync(to, text);
82
143
  }
144
+ return join(tree, relative(base, realpathSync(root)));
83
145
  }
84
146
 
85
147
  export async function buildNative(ctx: Ctx, root: string, gameName: string, targetName: string, t: NativeTarget): Promise<{ binary: string; log: string; steps: RunResult[] }> {
@@ -95,6 +157,7 @@ export async function buildNative(ctx: Ctx, root: string, gameName: string, targ
95
157
  const log = join(logDir, `build-${targetName}.log`);
96
158
  const out = join(root, "dist", targetName);
97
159
  const binary = join(out, `${t.name ?? gameName}${platform === "windows" ? ".exe" : ""}`);
160
+ if (!existsSync(join(root, t.entry))) throw new CliError("ENTRY_MISSING", `native entry ${t.entry} does not exist`, `add ${t.entry} (see the macos skill) or fix targets.${targetName}.native.entry`, skill);
98
161
  if (ctx.dryRun) {
99
162
  print(ctx, { dryRun: true, platform, stage, vendor: vendor.dir, binary }, (): string => `would stage the engine and game in ${stage} and build ${binary}`);
100
163
  return { binary, log, steps: [] };
@@ -104,9 +167,7 @@ export async function buildNative(ctx: Ctx, root: string, gameName: string, targ
104
167
  mkdirSync(out, { recursive: true });
105
168
  const engineStage = join(stage, "dotframe");
106
169
  cpSync(join(ENGINE, "src"), join(engineStage, "src"), { recursive: true });
107
- const gameStage = join(stage, "game");
108
- copyTs(root, gameStage);
109
- rewriteImports(gameStage, engineStage);
170
+ const gameStage = stageGame(root, t.entry, join(stage, "game"), engineStage);
110
171
 
111
172
  const inc = [`-I${join(vendor.dir, vendor.sdl ?? "", "include")}`, `-I${join(vendor.dir, "wgpu", platform, "include")}`];
112
173
  const lib = join(stage, "lib");
@@ -120,8 +181,6 @@ export async function buildNative(ctx: Ctx, root: string, gameName: string, targ
120
181
  const ffi = JSON.parse(readFileSync(join(ENGINE, "native", `ffi.${platform}.json`), "utf8")) as { libraries: string[] };
121
182
  ffi.libraries = [join(lib, "libdf_native.a"), join(vendor.dir, "build", `sdl-${platform}`, "libSDL3.a"), join(vendor.dir, "wgpu", platform, "lib", "libwgpu_native.a")];
122
183
  writeFileSync(join(stage, "ffi.json"), JSON.stringify(ffi, null, 2));
123
- const entry = join(gameStage, t.entry);
124
- if (!existsSync(entry)) throw new CliError("ENTRY_MISSING", `native entry ${t.entry} does not exist`, `add ${t.entry} (see the macos skill) or fix targets.${targetName}.native.entry`, skill);
125
184
  let env: Record<string, string> = {};
126
185
  if (platform === "windows") {
127
186
  // A devDependency of dotframe, so an npm install of dotframe does not bring it; the game can.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotframe",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "TS-first game engine and agent-first CLI: one codebase for web, Discord, iOS and native binaries",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,7 @@ A target with `"native"` is built by the CLI itself:
20
20
  "windows": { "native": { "platform": "windows", "entry": "main.native.ts" } }
21
21
  ```
22
22
 
23
- The CLI copies the engine (`src/`) and the game's `.ts` and `.json` files into `.dotframe/native/<target>/`, rewrites `dotframe/...` imports to relative paths, compiles the C shim against the vendored SDL3 and wgpu-native, and runs scriptc. This is why it works from `node_modules`: scriptc treats code under `node_modules` as package code for its dynamic engine and takes only relative imports in a static build, so building in place fails.
23
+ The CLI copies the engine (`src/`) and every file the entry reaches into `.dotframe/native/<target>/`: relative imports (including ones that leave the game folder, as in a monorepo port importing `../../packages/shared/src`) and workspace packages that resolve to source (`@my/shared` linked by bun or npm workspaces). It keeps their relative layout, rewrites `dotframe/...` and workspace imports to relative paths, compiles the C shim against the vendored SDL3 and wgpu-native, and runs scriptc. This is why it works from `node_modules`: scriptc treats code under `node_modules` as package code for its dynamic engine and takes only relative imports in a static build, so building in place fails.
24
24
 
25
25
  - Vendor lives in `DOTFRAME_VENDOR`, else a dotframe git checkout's `vendor/` when populated, else `~/.dotframe/vendor`. It survives reinstalling dotframe and is shared by every game.
26
26
  - Output goes to the game's `dist/<target>/`, never into `node_modules`.