From 5e5988589ab7cd8591a0e3b43de3ea54edc10d4d Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Wed, 16 Sep 2026 15:06:45 +0800 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20=E6=9E=84=E5=BB=BA=E9=93=BE?= =?UTF-8?q?=E6=96=B9=E6=A1=88=E2=80=94=E2=80=94esbuild=20=E9=80=89?= =?UTF-8?q?=E5=9E=8B=E3=80=81=E9=A1=B5=E9=9D=A2=E5=8D=B3=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=8D=95=E5=85=83=E3=80=81=E5=AD=97=E8=8A=82=E7=A0=81=E7=94=B1?= =?UTF-8?q?=20CLI=20=E7=BB=8F=20qjsc-kmp=20=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/README.md | 8 ++++ docs/build-chain.md | 97 +++++++++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 4 +- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 docs/build-chain.md diff --git a/docs/README.md b/docs/README.md index 569d359..2076f58 100644 --- a/docs/README.md +++ b/docs/README.md @@ -92,6 +92,14 @@ tinyui/ 约定:顶层只放这八个目录;新 npm 包进 `packages/`,新 Kotlin 模块进 `compose/` 作为子模块,不在顶层增生。ADR 文本中的"宿主"仍指 Kotlin 侧这一运行时角色,与目录名 `compose/` 不冲突。 +## 实现期文档 + +不需要 ADR 但实现前必须定的方案,一项一个文件: + +| 文件 | 主题 | 状态 | +|---|---|---| +| [build-chain.md](./build-chain.md) | 构建链:TSX → ESM 模块字节码,esbuild 选型,字节码由 CLI 经 `qjsc-kmp` 生成 | 方案已定(2026-09-16),实施中 | + ## 下一阶段 四份 ADR 遗留项的汇总、分组与建议顺序见 [roadmap.md](./roadmap.md),状态在那里更新。 diff --git a/docs/build-chain.md b/docs/build-chain.md new file mode 100644 index 0000000..9acabf8 --- /dev/null +++ b/docs/build-chain.md @@ -0,0 +1,97 @@ +# 构建链:TSX → ESM 模块字节码 + +- 状态:方案已定(2026-09-16),实施中 +- 来源:[ADR-005](./adr-005-engine.md) §4 "语言目标 / 模块" 两行的展开;roadmap A 组"构建链打通" +- 范围:`@tiny-ui/cli` 的 `tinyui build`、`compose/` 的 K0 加载序列、sample 的接线与 CI。自动 thunk(roadmap C 组)、字节码行号回映射、`qjsc-kmp` 二进制分发、页面级配置文件不在本期 + +## 1. 目标与验收 + +`tinyui build` 把每个页面的 TSX 编成一个 ESM 模块字节码,`@tiny-ui/core` / `@tiny-ui/native` 各编成一个运行时模块字节码,App 内置这些字节码,每页一个 Runtime 按 ADR-002 K0 的顺序加载。 + +验收:sample 的 `src/pages/home.tsx` 经 CLI 产出 `pages/home.bin`,Android / iOS 上 App 启动后显示页面 `default` 导出的返回值,其中含 `import.meta.url` 的值 `tinyui:pages/home`——模块名、external、注册顺序三件事一次验完。CI 的 gradle 与 pnpm 两条 job 都经过这条链。 + +## 2. 管线 + +``` +src/pages/home.tsx ──esbuild──▶ dist/pages/home.js + .map ──qjsc-kmp -m -n pages/home──▶ dist/pages/home.bin +@tiny-ui/core 包入口 ──esbuild──▶ dist/runtime/core.js ──qjsc-kmp -m -n @tiny-ui/core──▶ dist/runtime/core.bin +@tiny-ui/native 同上(对它而言 @tiny-ui/core 是 external) +``` + +| 步骤 | 做什么 | 不做什么 | +|---|---|---| +| esbuild | TS 类型擦除;JSX → `h()`(`jsxFactory: "h"`,`jsxInject` 自动注入 import,页面不手写);业务内部相对 import 合并进页面模块;`external: ["@tiny-ui/*"]` 保持裸说明符;`format: "esm"`,`target: "esnext"`;输出 source map | 任何语法降级;对 `@tiny-ui/*` 的解析 | +| qjsc-kmp | 源码模块 → 字节码,`-n` 给定模块名,`--strip-source` 保留行号去源码 | 校验模块图(引擎加载时查表) | + +**页面名即模块名**:`src/pages/home.tsx` → `pages/home`,子目录保留路径,无后缀无前缀(ADR-005 §4)。运行时模块名固定 `@tiny-ui/core`、`@tiny-ui/native`。 + +## 3. 页面即构建单元 + +一个页面 = 一个路由单元 = 一个 Runtime(ADR-002)= 一份模块字节码 = 将来的一个分发单元。构建产物因此按页自包含:页面 import 的业务内部代码(工具函数、业务组件)全部合并进该页模块,两个页面共用的代码在两份字节码里各有一份。 + +这样定的理由:每页一个 Runtime 且模块缓存挂在 JSContext 上(quickjs-kmp `docs/decisions.md`"Runtime / Context"条),跨页共享代码在引擎层本来就不存在——即使拆出共享 chunk,每页仍要各注册、各编译一遍,运行时的时间与内存一分不省。重复只花安装包体积,而字节码模块的加载路径(注册 → import 命中表)对页面数是线性的,不需要 chunk 清单这层东西。 + +实现上"一页一次 build()"是对产物形态的描述,不是对 esbuild 调用次数的要求:所有页面作为 `entryPoints` 放进一次 `build()`、`splitting: false`,每个入口的输出仍各自自包含,且共享解析缓存。 + +扩展出口:业务共享代码大到安装包体积成问题时,把它编成 App 级共享模块(名字进 external 列表,与 `@tiny-ui/*` 一样注册进每个引擎的模块表),页面对它保持裸 import。这只是 external 列表与模块表多一项,管线形态不变;触发条件出现前不做。 + +## 4. 工具选型:esbuild + +### 4.1 硬要求 + +1. TS 擦除,不做任何降级:顶层 `await`、`import.meta.url` 原样保留 +2. JSX → `h()`,工厂名可指定 +3. 打包:合并业务内部 import;`@tiny-ui/*` 不解析 +4. ESM 单文件输出 + source map +5. 以库形式被 CLI 调用 + +### 4.2 候选 + +| 候选 | 擦 TS | JSX → `h()` | 打包 + external | 结论 | +|---|---|---|---|---| +| tsc 单独用 | 是 | 是 | 否,不合并模块图 | 仍需打包器 | +| Babel + Rollup | 插件 | 插件 | 是 | 三四个包加配置层,JS 实现,慢 | +| SWC / oxc 单独用 | 是 | 是 | 否,只是转译器 | 同 tsc | +| Vite | 内部 esbuild + Rollup / rolldown | 是 | 是 | 面向 dev server 与浏览器,对库式逐页调用多一层 | +| rolldown | 是(内置 oxc) | 是 | 是 | 能力对等,Rollup 同形 API;仍在向 1.0 收敛,插件生态迁移期 | +| Bun bundler | 是 | 是 | 是 | 绑定 Bun 运行时,CLI 是 Node 生态的 npm 包 | +| **esbuild** | 是 | 是 | 是,`external` 支持通配 | 单一依赖、Go 二进制、API 长期稳定 | + +### 4.3 选 esbuild 的理由 + +- 需求全在内置能力里:`bundle` / `external` / `format` / `target` / `jsxFactory` / `sourcemap` 六个选项即全部配置,零插件 +- 不降级是默认行为:它的定位就是擦除与打包,`esnext` 下不碰语法,与 ADR-005 "CLI 只做 TS 擦除与 JSX 变换"一致;以转换为核心的工具反而要花精力确认它没改写语法 +- 一个依赖、一个二进制:CLI 是业务 App 要安装的包,依赖树越小越好 +- 速度:单页毫秒级,watch 与几十页全量构建不需要额外优化 + +### 4.4 局限与出口 + +esbuild 的 JSX 变换是内置固定逻辑、不可编程。C 组的自动 thunk 要对 JSX 属性表达式判断后包成 getter,esbuild 做不到。出口:`onLoad` 插件拦截 `.tsx`,先跑自有 JSX 变换(TypeScript transformer API 或 oxc),把已是 `h()` 调用的 JS 交回 esbuild 合并——esbuild 退化为"擦除 + 合并",变换逻辑在自己手里。 + +rolldown 是真正的备选(Rollup 同形 API、oxc 转译)。CLI 只包一次 `build()` 调用,切换成本在一个文件内,不需要现在锁定。 + +## 5. 字节码由 CLI 生成,宿主工具从 quickjs-kmp 来 + +字节码编译放 CLI 而不放 Gradle:业务 App 不会有本仓的 build-logic,`tinyui build` 必须自己出最终产物。`qjsc-kmp` 二进制查找顺序:`--qjsc` 参数 → `TINYUI_QJSC` 环境变量 → PATH。 + +字节码绑定引擎构建(文件头记上游 commit,quickjs-kmp `docs/native-build.md`"字节码与宿主工具"),CLI 用的 `qjsc-kmp` 必须与 App 链接的 quickjs-kmp 同一上游 commit。 + +| 场景 | 来源 | +|---|---| +| 本地 | `local.properties` 有 `quickjs-kmp.dir` 时,Gradle 把 `/library/build/native/host-tools/bin/qjsc-kmp` 传给 CLI,零配置 | +| CI(本期) | clone quickjs-kmp 的 catalog 对应 tag,`./gradlew :library:buildHostTools` 现编,多花一两分钟 | +| 长期 | quickjs-kmp 在 tag 发布时把 macOS / Linux 宿主二进制挂到 GitHub Release,CLI 按版本下载(esbuild 的平台包模式);属 quickjs-kmp 仓改动,记 roadmap | + +## 6. 各处职责 + +| 位置 | 改动 | +|---|---| +| `packages/cli` | `bin` 入口 `tinyui`,子命令 `build`,参数 `--root` / `--out` / `--qjsc`;依赖 esbuild。测试:夹具 TSX 打包后断言 `h(` 调用、裸说明符保留、相对 import 已合并、`.map` 存在;qjsc 步骤在 `TINYUI_QJSC` 缺失时跳过 | +| `packages/core` / `native` | 不动运行时 API,只保证入口能打成单模块,各导出 `VERSION` 供 sample 显示 | +| `compose/` | ADR-002 K0 最小版:注册 core → 注册 native → 运行页面模块 → 返回 namespace。命名待 M1 定,本期只保证序列在库里而不在 sample 里。commonTest 用 `JsBytecode.compile` 现场编字节码,不依赖宿主工具 | +| `sample/js` | 新 pnpm workspace 成员,`src/pages/home.tsx`。本期页面用纯 TS 只 import `VERSION`:`h` 的签名是 C 组的事,不在 core 放临时实现;JSX 变换的正确性由 CLI 单元测试覆盖 | +| `sample/shared` | Gradle Exec 任务调 CLI,产物挂成 Compose resources 自定义目录,`Res.readBytes` 读入后走 K0 | +| `settings.gradle.kts` | 有 `quickjs-kmp.dir` 时把 `qjsc-kmp` 路径传给 Exec 任务 | +| CI | gradle job 加 setup-node + pnpm,clone quickjs-kmp 编宿主工具;ios.yml 同样 | + +source map 随 `.js` 输出到 `.map`;行列号回映射到 TSX 归 E1 / E2 错误上报那一期。 diff --git a/docs/roadmap.md b/docs/roadmap.md index fcadf3b..7bc3c6e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,7 +10,7 @@ | 仓库结构 | 已定单仓,目录见 README"仓库结构"一节 | 已定 | | 构建链骨架 | Gradle(build-logic / compose / sample 含 iOS 壳)+ pnpm workspace(core / native / cli)+ CI(PR 门禁 Android / host / apiCheck,iOS 全量在 ios.yml tag / 手动);quickjs-kmp 本地 composite、CI 走 Central | 已完成(PR #1,2026-09-16) | | **quickjs-kmp** | 新建 SDK 接入 QuickJS(ADR-005),复制 mquickjs-kmp 骨架,API 同形;M1 shim + 三端构建,M2 句柄表 + Runtime + 微任务 + **ESM 模块表**,M3 字节码 + 宿主编译工具 + 发布。TinyUI 的 `compose/` 依赖它 | 已完成(M1~M3,`wang.harlon:quickjs-kmp:0.1.0` 2026-09-16 发 Central;桥形验收见 `bench/results/2026-09-16-quickjs-kmp.md`) | -| 构建链打通 | CLI:TSX → `h()`(ES2025)→ 每页一个 ESM 模块字节码,`external: ["@tiny-ui/*"]`;运行时两个模块字节码内置 | 待开 | +| 构建链打通 | CLI:TSX → `h()`(ES2025)→ 每页一个 ESM 模块字节码,`external: ["@tiny-ui/*"]`;运行时两个模块字节码内置 | 方案已定(2026-09-16,[build-chain.md](./build-chain.md)),实施中 | ## B. 验证实验(数据驱动,互相独立,可并行) @@ -41,6 +41,8 @@ |---|---|---| | `createStore`(Solid 式 Proxy 深层响应式) | ADR-005 | **v1.1 计划内**:M2 列表页跑通后做 | | 热下发(页面包分发与版本兼容;引擎侧 Kotlin 回调式 module loader 已在 quickjs-kmp 完成,`JsEngineConfig.moduleLoader`) | ADR-005 | 内核稳定后另立 ADR | +| `qjsc-kmp` 宿主二进制随 quickjs-kmp tag 发 GitHub Release,CLI 按版本下载 | build-chain.md §5 | CI 现编耗时或业务方接入成为问题;属 quickjs-kmp 仓改动 | +| App 级共享模块(业务共享代码进 external 列表与模块表) | build-chain.md §3 | 页面间重复代码让安装包体积成问题 | | 向 bellard/mquickjs 上报 S4 段错误 | ADR-005 §3.2 | 用户决定 | | `ErrorBoundary` 分支级兜底 | ADR-002 | 整页失败的比例成为问题 | | 二进制编码路径 | ADR-002 | 真机 profiling 证明 Kotlin 解析占主导 | From 4e10650376a8297435f86576ce19c579a938ca44 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Wed, 16 Sep 2026 15:19:29 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=E5=BA=94=E7=94=A8=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E2=80=94=E2=80=94=E8=B7=AF=E7=94=B1=E3=80=81=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F=E3=80=81=E8=B7=A8=E9=A1=B5=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=BD=92=20Kotlin=EF=BC=8C=E9=A1=B5=E9=9D=A2=E4=B9=8B?= =?UTF-8?q?=E9=97=B4=E7=BB=8F=20Kotlin=20=E4=B8=AD=E8=BD=AC=E7=9A=84?= =?UTF-8?q?=E5=9B=9B=E7=A7=8D=E9=80=9A=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/README.md | 1 + docs/app-model.md | 50 +++++++++++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 2 ++ 3 files changed, 53 insertions(+) create mode 100644 docs/app-model.md diff --git a/docs/README.md b/docs/README.md index 2076f58..3408e5d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -99,6 +99,7 @@ tinyui/ | 文件 | 主题 | 状态 | |---|---|---| | [build-chain.md](./build-chain.md) | 构建链:TSX → ESM 模块字节码,esbuild 选型,字节码由 CLI 经 `qjsc-kmp` 生成 | 方案已定(2026-09-16),实施中 | +| [app-model.md](./app-model.md) | 应用模型:路由、生命周期、跨页状态在 Kotlin;页面之间经 Kotlin 中转的四种通信 | 已对齐(2026-09-16),API 面归 C 组 | ## 下一阶段 diff --git a/docs/app-model.md b/docs/app-model.md new file mode 100644 index 0000000..034d26b --- /dev/null +++ b/docs/app-model.md @@ -0,0 +1,50 @@ +# 应用模型:页面是 JS 的世界,应用是 Kotlin 的世界 + +- 状态:已对齐(2026-09-16);API 面(`@tiny-ui/native` 的 `navigation` / `store` / `events`)归 roadmap C 组与 J2 白名单一起定 +- 来源:ADR-002 "跨页共享状态走 Kotlin(K5 推送)"、"多页共享引擎被否:JS 全局正是隔离问题的来源"、`navigation.push` 为 J4;ADR-003 "组件注册表 App 级、不可变" + +## 1. 前提 + +JS 侧不存在长生命周期的"应用对象":没有 `app.tsx`、没有 `onLaunch`、没有 `globalData`。每个页面模块是一个孤岛,出口只有 `@tiny-ui/native` 的宿主能力,入口只有 K1 生命周期、K2 事件、K3 回送、K5 推送。页面对另一个页面的存在一无所知。 + +## 2. 四类应用级事务的归属 + +| 事务 | 归属 | 机制 | +|---|---|---| +| 路由 | Kotlin 导航栈 | 页面名 = 模块名 = 路由键(`pages/detail`)。JS `navigation.push(name, params)` 经 J4 → Kotlin 建页面作用域 → K0 加载 → K1 `__mount(props)`,params 即 props。返回 = pop = `__unmount` = 关引擎。深链由宿主解析成页面名 + 参数走同一条 push | +| 应用生命周期 | 宿主事件 | 前后台、内存警告、主题、语言、登录态经 K5 `__emit(topic)` 投给每个订阅了的活页面;栈顶页额外收 K1 `__visible`。页面只有挂载、卸载、可见性三个钩子 | +| 跨页状态 | Kotlin 内存 store | 见第 3 节 | +| 应用级 JS 逻辑 | Kotlin,或无状态工具代码打进各页模块 | 常驻 JS 的出口是"应用级服务 Runtime":一个不挂 UI、随 App 生命周期的引擎,页面经 Kotlin 以 JSON 与它通信,形态同 J3 / K3。v1 不做,触发条件见 roadmap D 组 | + +**TinyUI 页从宿主看只是一个 Composable**,导航栈里可以混放原生页与 TinyUI 页。v1 不内置路由器:库提供页面 Composable 与 `Navigator` 接口,宿主用自己的导航框架实现,JS 的 `navigation.*` 委托给它;sample 给最简实现。 + +## 3. 页面之间怎么通信 + +页面之间没有直接通道,全部经 Kotlin 中转,载荷只有 JSON。两个引擎本来就不能共享对象,这是设计而不是限制。 + +| 场景 | JS 侧 | 过桥 | 到达形态 | +|---|---|---|---| +| 向前传参 | `navigation.push("pages/detail", {id})` | J4 | 新页 K1 `__mount(props)` | +| 向后回传 | `navigation.pop(result)` | J4 | 上一页收 K5 固定 topic(如 `navigation.result`) | +| 共享状态 | `store.get(key)` / `store.set(key, v)` | J2 读,J4 写 | 真值在 Kotlin,变更经 K5 推给订阅了该 key 的页面 | +| 事件广播 | `events.emit(topic, payload)` / `events.on(topic, fn)` | J4 | 所有订阅了该 topic 的活页面各收一次 K5 | +| 直接调用另一页 | 无 | 无 | 不支持;需要它说明那个状态该进 store | + +- **向前传参**:props 是 JSON,函数不过桥。"完成后回调我"只能靠回传或事件 +- **向后回传**:原语是 K5 topic,结果投给"处在那个栈位置的页面"而不是某个闭包,页面被回收重建后照样收到。`push` 返回 Promise 的写法(J3 + cbId)与栈深回收冲突——被回收页的闭包已不存在;可之后作为糖加在 `@tiny-ui/native`,文档写明回收语义 +- **共享状态**:真值在 Kotlin,原生页与 TinyUI 页共用同一份;内存 key-value 所以 J2 同步读合法;JS 侧包成 signal,K5 到达时更新 signal,绑定了它的 prop 自动重算,与页内响应式同一套。持久化由宿主决定 +- **事件广播**:与宿主事件同一条总线,网络状态、主题是 Kotlin 往总线发,业务事件是 JS 往总线发,消费方不区分来源。订阅登记到 Kotlin,只推给感兴趣的页面(每次 K5 = 一次线程切换 + 一个事务);订阅随引擎关闭消失 + +## 4. 投递语义 + +- **状态会留下,事件会错过**:store 是状态,页面回收重建时从 store 重读;事件是瞬时的,页面不在时发生的事件不补发。业务判断"错过了要不要紧",要紧就写进 store +- **顺序只在单页内保证**:每个引擎串行,同一页收到的 K5 严格有序;跨页无顺序承诺 +- **同名页面多实例**:detail 跳 detail 是两个引擎,Kotlin 以页面实例 id 寻址而不是页面名;回传目标、订阅登记都挂在实例上 + +## 5. 返回栈回收 + +ADR-002 遗留的"栈深页引擎回收策略"归 Kotlin 侧路由器:栈深超过 N 关引擎,返回时用保存的 props 重新 `__mount`。推论是页面必须能从 props + store 重建,页内临时状态丢失由业务经 store 自救。N 等 roadmap B 组真机内存数据出来后定。 + +## 6. 对构建链的影响 + +每页一份字节码正好是路由键到产物的一一映射,`tinyui build` 输出页面清单(`manifest.json`),Kotlin 侧路由表以它为来源,不两边手写。见 [build-chain.md](./build-chain.md)。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 7bc3c6e..273daa6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -32,6 +32,7 @@ | 公共布局 prop 清单与 `Modifier` 合成顺序 | ADR-003 | 待开 | | 内置组件集首批(`Column` / `Row` / `Box` / `Text` / `Image` / `Button` / `TextField` / `LazyColumn` / `Spacer`)及各自 prop / 事件 / 命令清单 | ADR-003 / 004 | 待开 | | J2 白名单清单与注册方式 | ADR-002 | 待开 | +| `@tiny-ui/native` 的 `navigation` / `store` / `events` API 面与 `Navigator` 接口([app-model.md](./app-model.md));`push` 的 Promise 糖做不做 | app-model.md | 待开 | | E3 错误 code 表、K 入口超时阈值、连续事件节流阈值 | ADR-002 / 004 | 待开 | | `Placeholder` 在 release 的表现 | ADR-003 | 待开 | @@ -49,6 +50,7 @@ | 命令回执(`cmd()` 返回 Promise) | ADR-004 | 出现必须知道结果的命令 | | 窗口化 `For` | ADR-003 | 出现万行级列表需求 | | 返回栈深页引擎回收策略 | ADR-002 | 真机内存数据出来后定,可能提前 | +| 应用级服务 Runtime(不挂 UI、随 App 生命周期的引擎) | app-model.md | 业务出现不属于任何页面的常驻 JS 逻辑 | | 手势组合内置组件 | ADR-004 | 业务需求出现 | ## 建议顺序与里程碑 From 7768376f70fa414b9d3ffd85d1809760a0e11119 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Wed, 16 Sep 2026 15:40:03 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E6=9E=84=E5=BB=BA=E9=93=BE?= =?UTF-8?q?=E6=89=93=E9=80=9A=E2=80=94=E2=80=94tinyui=20build=20=E5=87=BA?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=AD=97=E8=8A=82=E7=A0=81=EF=BC=8CPageEngin?= =?UTF-8?q?e=20=E5=8A=A0=E8=BD=BD=EF=BC=8Csample=20=E5=8F=8C=E7=AB=AF?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E9=A1=B5=E9=9D=A2=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @tiny-ui/cli:esbuild 打包(TS 擦除、JSX → h()、相对 import 合并、@tiny-ui/* 外部化)→ qjsc-kmp 出字节码 → manifest.json;bin 入口 tinyui build - compose:PageEngine 实现 ADR-002 K0(注册 core / native → 运行页面模块),host 测试用 JsBytecode 现场编字节码 - sample:sample/js 页面源码,Gradle 调 CLI 后把 .bin 打成 Compose 资源,App 加载并显示 default 导出 - CI:clone quickjs-kmp 编 qjsc-kmp 与宿主 JNI 库;pnpm job 加 typecheck Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build.yml | 14 + .github/workflows/ios.yml | 13 + README.md | 14 +- compose/api/compose.klib.api | 25 ++ compose/build.gradle.kts | 10 + .../kotlin/wang/harlon/tinyui/PageEngine.kt | 54 +++ .../wang/harlon/tinyui/PageEngineTest.kt | 56 ++++ docs/README.md | 3 +- docs/build-chain.md | 12 +- docs/roadmap.md | 2 +- packages/cli/package.json | 30 +- packages/cli/src/bin.ts | 47 +++ packages/cli/src/build.ts | 146 +++++++++ packages/cli/src/index.ts | 5 +- packages/cli/src/qjsc.ts | 48 +++ packages/cli/test/build.test.ts | 83 +++++ packages/cli/test/fixtures/app/package.json | 1 + .../cli/test/fixtures/app/src/lib/format.ts | 3 + .../cli/test/fixtures/app/src/pages/home.tsx | 9 + .../fixtures/app/src/pages/nested/detail.ts | 4 + packages/cli/tsconfig.json | 6 +- packages/core/package.json | 18 +- packages/core/src/index.ts | 8 + packages/native/package.json | 18 +- pnpm-lock.yaml | 309 +++++++++++++++++- pnpm-workspace.yaml | 4 + sample/js/package.json | 15 + sample/js/src/lib/greeting.ts | 3 + sample/js/src/pages/home.tsx | 6 + sample/js/tsconfig.json | 10 + sample/shared/build.gradle.kts | 47 +++ .../kotlin/wang/harlon/tinyui/sample/App.kt | 32 +- settings.gradle.kts | 2 + tsconfig.base.json | 2 + 34 files changed, 1030 insertions(+), 29 deletions(-) create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/PageEngine.kt create mode 100644 compose/src/commonTest/kotlin/wang/harlon/tinyui/PageEngineTest.kt create mode 100644 packages/cli/src/bin.ts create mode 100644 packages/cli/src/build.ts create mode 100644 packages/cli/src/qjsc.ts create mode 100644 packages/cli/test/build.test.ts create mode 100644 packages/cli/test/fixtures/app/package.json create mode 100644 packages/cli/test/fixtures/app/src/lib/format.ts create mode 100644 packages/cli/test/fixtures/app/src/pages/home.tsx create mode 100644 packages/cli/test/fixtures/app/src/pages/nested/detail.ts create mode 100644 sample/js/package.json create mode 100644 sample/js/src/lib/greeting.ts create mode 100644 sample/js/src/pages/home.tsx create mode 100644 sample/js/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f312463..7023c70 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,6 +16,19 @@ jobs: distribution: temurin java-version: 25 - uses: gradle/actions/setup-gradle@v5 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: quickjs-kmp host tools (qjsc-kmp for bytecode, host JNI for Android host tests) + run: | + version=$(sed -n 's/^quickjsKmp = "\(.*\)"/\1/p' gradle/libs.versions.toml) + git clone --depth 1 --branch "$version" https://github.com/HarlonWang/quickjs-kmp.git "$RUNNER_TEMP/quickjs-kmp" + (cd "$RUNNER_TEMP/quickjs-kmp" && ./gradlew :library:buildHostTools :library:buildNativeHostJni --no-daemon) + echo "TINYUI_QJSC=$RUNNER_TEMP/quickjs-kmp/library/build/native/host-tools/bin/qjsc-kmp" >> "$GITHUB_ENV" + echo "TINYUI_QUICKJS_HOST_JNI=$RUNNER_TEMP/quickjs-kmp/library/build/native/host-jni/lib" >> "$GITHUB_ENV" - run: ./gradlew :compose:testAndroidHostTest :compose:apiCheck :sample:androidApp:assembleDebug --no-daemon pnpm: @@ -29,4 +42,5 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build + - run: pnpm typecheck - run: pnpm test diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index a9f06ef..4c2f11e 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -15,4 +15,17 @@ jobs: distribution: temurin java-version: 25 - uses: gradle/actions/setup-gradle@v5 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: quickjs-kmp host tools + run: | + version=$(sed -n 's/^quickjsKmp = "\(.*\)"/\1/p' gradle/libs.versions.toml) + git clone --depth 1 --branch "$version" https://github.com/HarlonWang/quickjs-kmp.git "$RUNNER_TEMP/quickjs-kmp" + (cd "$RUNNER_TEMP/quickjs-kmp" && ./gradlew :library:buildHostTools :library:buildNativeHostJni --no-daemon) + echo "TINYUI_QJSC=$RUNNER_TEMP/quickjs-kmp/library/build/native/host-tools/bin/qjsc-kmp" >> "$GITHUB_ENV" + echo "TINYUI_QUICKJS_HOST_JNI=$RUNNER_TEMP/quickjs-kmp/library/build/native/host-jni/lib" >> "$GITHUB_ENV" - run: ./gradlew build --no-daemon diff --git a/README.md b/README.md index b2ee3c4..4432ee5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ JS 写声明式组件、Compose Multiplatform 渲染的 UI 框架,引擎为 QuickJS(经 quickjs-kmp 接入)。Android / iOS 双端。 -> 设计阶段。关键选型已定,代码尚未开始;进度见 [docs/roadmap.md](./docs/roadmap.md)。 +> 早期阶段。关键选型已定,构建链已打通(TSX → 模块字节码 → 双端加载);进度见 [docs/roadmap.md](./docs/roadmap.md)。 - 设计文档与决策记录:[docs/](./docs/README.md) - 响应式模型与引擎 benchmark:[bench/](./bench/README.md) @@ -15,7 +15,7 @@ docs/ 设计文档、ADR、roadmap packages/ npm 包:@tiny-ui/core · @tiny-ui/native · @tiny-ui/cli compose/ KMP 库 wang.harlon:tinyui(Compose Multiplatform 侧) schema/ 内置组件 schema,两侧契约的唯一真值 -sample/ 示例 App +sample/ 示例 App(sample/js 是页面源码,Gradle 构建时经 CLI 编成字节码打进资源) bench/ 响应式模型与引擎 benchmark build-logic/ Gradle convention plugins ``` @@ -25,11 +25,15 @@ build-logic/ Gradle convention plugins ## 构建 ```sh -./gradlew build # compose 库 + 测试(Android host / iOS simulator)+ sample APK + iOS framework;需要 JDK 25、Android SDK、Xcode -pnpm install && pnpm build && pnpm test +pnpm install # Node 22+,pnpm 10 +./gradlew build # compose 库 + 测试(Android host / iOS simulator)+ sample APK + iOS framework;需要 JDK 25、Android SDK、Xcode +pnpm build && pnpm test # 三个 npm 包 ``` -本地联调 quickjs-kmp:`local.properties` 加 `quickjs-kmp.dir=`,即从源码构建(composite build)。 +sample 的 Gradle 构建会调 `tinyui build` 把 `sample/js` 编成字节码,需要 quickjs-kmp 的宿主工具 `qjsc-kmp`,Android host 测试还需要宿主 JNI 库。两种来源: + +- 本地联调:`local.properties` 加 `quickjs-kmp.dir=`,SDK 从源码构建(composite build),两样宿主产物由该仓的任务自动编出,零配置 +- 否则:`TINYUI_QJSC` 指向 `qjsc-kmp`,`TINYUI_QUICKJS_HOST_JNI` 指向含 `libquickjs_kmp` 的目录(CI 的做法见 `.github/workflows/build.yml`) ## License diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index af822a6..3843e4c 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -6,11 +6,36 @@ // - Show declarations: true // Library unique name: +final class wang.harlon.tinyui/PageEngine : kotlin/AutoCloseable { // wang.harlon.tinyui/PageEngine|null[0] + final fun callDefault(kotlin/Array...): wang.harlon.quickjs/JsValue // wang.harlon.tinyui/PageEngine.callDefault|callDefault(kotlin.Array...){}[0] + final fun close() // wang.harlon.tinyui/PageEngine.close|close(){}[0] + + final object Companion { // wang.harlon.tinyui/PageEngine.Companion|null[0] + final const val MODULE_SCHEME // wang.harlon.tinyui/PageEngine.Companion.MODULE_SCHEME|{}MODULE_SCHEME[0] + final fun (): kotlin/String // wang.harlon.tinyui/PageEngine.Companion.MODULE_SCHEME.|(){}[0] + + final fun load(wang.harlon.tinyui/RuntimeBundle, kotlin/ByteArray, wang.harlon.quickjs/JsEngineConfig = ...): wang.harlon.tinyui/PageEngine // wang.harlon.tinyui/PageEngine.Companion.load|load(wang.harlon.tinyui.RuntimeBundle;kotlin.ByteArray;wang.harlon.quickjs.JsEngineConfig){}[0] + } +} + +final class wang.harlon.tinyui/RuntimeBundle { // wang.harlon.tinyui/RuntimeBundle|null[0] + constructor (kotlin/ByteArray, kotlin/ByteArray) // wang.harlon.tinyui/RuntimeBundle.|(kotlin.ByteArray;kotlin.ByteArray){}[0] + + final val core // wang.harlon.tinyui/RuntimeBundle.core|{}core[0] + final fun (): kotlin/ByteArray // wang.harlon.tinyui/RuntimeBundle.core.|(){}[0] + final val native // wang.harlon.tinyui/RuntimeBundle.native|{}native[0] + final fun (): kotlin/ByteArray // wang.harlon.tinyui/RuntimeBundle.native.|(){}[0] +} + final object wang.harlon.tinyui/TinyUI { // wang.harlon.tinyui/TinyUI|null[0] final val engineVersion // wang.harlon.tinyui/TinyUI.engineVersion|{}engineVersion[0] final fun (): kotlin/String // wang.harlon.tinyui/TinyUI.engineVersion.|(){}[0] } +final val wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop|#static{}wang_harlon_tinyui_PageEngine$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop|#static{}wang_harlon_tinyui_RuntimeBundle$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop|#static{}wang_harlon_tinyui_TinyUI$stableprop[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop_getter|wang_harlon_tinyui_PageEngine$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop_getter|wang_harlon_tinyui_RuntimeBundle$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop_getter|wang_harlon_tinyui_TinyUI$stableprop_getter(){}[0] diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index a3d1dd4..abdfcda 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -62,3 +62,13 @@ mavenPublishing { apiValidation { klib { enabled = true } } + +// Android host test 要加载宿主编译的 libquickjs_kmp:composite 时取 quickjs-kmp 的 buildNativeHostJni 产物, +// 否则读 TINYUI_QUICKJS_HOST_JNI(CI 从 quickjs-kmp 源码现编,见 .github/workflows/build.yml) +val quickjsKmpDir = (gradle as ExtensionAware).extra.properties["quickjs-kmp.dir"] as File? +val hostJniDir: Provider = providers.environmentVariable("TINYUI_QUICKJS_HOST_JNI") + .orElse(providers.provider { quickjsKmpDir?.resolve("library/build/native/host-jni/lib")?.absolutePath }) +tasks.withType().matching { it.name == "testAndroidHostTest" }.configureEach { + if (quickjsKmpDir != null) dependsOn(gradle.includedBuild("quickjs-kmp").task(":library:buildNativeHostJni")) + hostJniDir.orNull?.let { systemProperty("java.library.path", it) } +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageEngine.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageEngine.kt new file mode 100644 index 0000000..3c50c2d --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageEngine.kt @@ -0,0 +1,54 @@ +package wang.harlon.tinyui + +import wang.harlon.quickjs.JsEngine +import wang.harlon.quickjs.JsEngineConfig +import wang.harlon.quickjs.JsRef +import wang.harlon.quickjs.JsValue +import wang.harlon.quickjs.ObjectTransport + +/** Bytecode of the runtime modules every page imports, as `tinyui build` writes them under `runtime/`. */ +class RuntimeBundle(val core: ByteArray, val native: ByteArray) + +/** + * One page's engine: the runtime modules registered, the page module evaluated, its namespace held + * (K0 of docs/adr-002-bridge-communication.md). Closing it unloads the page. + */ +class PageEngine private constructor( + private val engine: JsEngine, + private val namespace: JsRef, +) : AutoCloseable { + /** Calls the page module's `default` export with [args]; objects come back as JSON. */ + fun callDefault(vararg args: JsValue): JsValue = + (namespace.get("default", ObjectTransport.REF) as JsRef).use { it.call(*args) } + + override fun close() { + namespace.close() + engine.close() + } + + companion object { + /** `import.meta.url` of a page module is `tinyui:` (docs/adr-005-engine.md). */ + const val MODULE_SCHEME = "tinyui" + + fun load( + runtime: RuntimeBundle, + page: ByteArray, + config: JsEngineConfig = JsEngineConfig(moduleScheme = MODULE_SCHEME), + ): PageEngine { + val engine = JsEngine(config) + try { + engine.registerModule(runtime.core) + engine.registerModule(runtime.native) + val namespace = engine.runBytecode(page, ObjectTransport.REF) as JsRef + if (namespace.isPromise) { + namespace.close() + error("page module is still pending after microtasks were drained; top-level await may only wait on synchronous work") + } + return PageEngine(engine, namespace) + } catch (e: Throwable) { + engine.close() + throw e + } + } + } +} diff --git a/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageEngineTest.kt b/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageEngineTest.kt new file mode 100644 index 0000000..10f4241 --- /dev/null +++ b/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageEngineTest.kt @@ -0,0 +1,56 @@ +package wang.harlon.tinyui + +import wang.harlon.quickjs.JsBytecode +import wang.harlon.quickjs.JsException +import wang.harlon.quickjs.JsValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PageEngineTest { + private val runtime = RuntimeBundle( + core = module("@tiny-ui/core", "export const VERSION = '0.0.0'; export function h(type, props) { return { type, props }; }"), + native = module("@tiny-ui/native", "import { VERSION } from '@tiny-ui/core'; export const NATIVE = 'native ' + VERSION;"), + ) + + @Test + fun loadsRuntimeModulesThenPage() { + val page = module( + "pages/home", + """ + import { VERSION, h } from "@tiny-ui/core"; + import { NATIVE } from "@tiny-ui/native"; + export default (who) => `${'$'}{who}: core ${'$'}{VERSION}, ${'$'}{NATIVE}, at ${'$'}{import.meta.url}`; + """, + ) + PageEngine.load(runtime, page).use { engine -> + val text = engine.callDefault(JsValue.Str("hello")) as JsValue.Str + assertEquals("hello: core 0.0.0, native 0.0.0, at tinyui:pages/home", text.value) + } + } + + @Test + fun topLevelAwaitOnSynchronousWorkResolvesBeforeLoadReturns() { + val page = module("pages/async", "export const ready = await Promise.resolve(42); export default () => ready;") + PageEngine.load(runtime, page).use { engine -> + assertEquals(JsValue.Num(42.0), engine.callDefault()) + } + } + + @Test + fun pendingPageModuleIsRejected() { + val page = module("pages/stuck", "await new Promise(() => {}); export default () => 0;") + val e = assertFailsWith { PageEngine.load(runtime, page) } + assertTrue("pending" in e.message!!) + } + + @Test + fun unknownImportFailsAtLoad() { + val page = module("pages/bad", "import { x } from 'pages/other'; export default () => x;") + assertFailsWith { PageEngine.load(runtime, page) } + } + + private fun module(name: String, source: String): ByteArray = + JsBytecode.compile(source.trimIndent(), name, module = true, strip = JsBytecode.Strip.SOURCE) +} diff --git a/docs/README.md b/docs/README.md index 3408e5d..c6d456e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -73,6 +73,7 @@ tinyui/ ├── compose/ wang.harlon:tinyui KMP 库(Compose Multiplatform 侧):节点表、注册表、桥、内置组件;依赖 quickjs-kmp ├── schema/ 内置组件 schema 的唯一真值 → 生成 packages/core 的 .d.ts 与 compose/ 的注册代码 ├── sample/ 示例 App,M1 Counter / M2 列表页在这里跑 +│ ├── js/ 页面源码(pnpm workspace 成员),shared 的 Gradle 任务调 CLI 编成字节码打进 Compose 资源 │ ├── shared/ KMP 模块:App() 与 iOS 入口,出静态 framework(AGP 9 不允许 application 与 KMP 插件同模块) │ ├── androidApp/ Android 壳(纯 com.android.application) │ └── iosApp/ Xcode 壳 @@ -98,7 +99,7 @@ tinyui/ | 文件 | 主题 | 状态 | |---|---|---| -| [build-chain.md](./build-chain.md) | 构建链:TSX → ESM 模块字节码,esbuild 选型,字节码由 CLI 经 `qjsc-kmp` 生成 | 方案已定(2026-09-16),实施中 | +| [build-chain.md](./build-chain.md) | 构建链:TSX → ESM 模块字节码,esbuild 选型,字节码由 CLI 经 `qjsc-kmp` 生成 | 已完成(2026-09-16) | | [app-model.md](./app-model.md) | 应用模型:路由、生命周期、跨页状态在 Kotlin;页面之间经 Kotlin 中转的四种通信 | 已对齐(2026-09-16),API 面归 C 组 | ## 下一阶段 diff --git a/docs/build-chain.md b/docs/build-chain.md index 9acabf8..4ca6be9 100644 --- a/docs/build-chain.md +++ b/docs/build-chain.md @@ -1,6 +1,6 @@ # 构建链:TSX → ESM 模块字节码 -- 状态:方案已定(2026-09-16),实施中 +- 状态:已完成(2026-09-16,PR #2);sample 在 Android 模拟器与 iOS 模拟器上显示 `tinyui:pages/home` - 来源:[ADR-005](./adr-005-engine.md) §4 "语言目标 / 模块" 两行的展开;roadmap A 组"构建链打通" - 范围:`@tiny-ui/cli` 的 `tinyui build`、`compose/` 的 K0 加载序列、sample 的接线与 CI。自动 thunk(roadmap C 组)、字节码行号回映射、`qjsc-kmp` 二进制分发、页面级配置文件不在本期 @@ -20,10 +20,12 @@ src/pages/home.tsx ──esbuild──▶ dist/pages/home.js + .map ──qjsc-k | 步骤 | 做什么 | 不做什么 | |---|---|---| -| esbuild | TS 类型擦除;JSX → `h()`(`jsxFactory: "h"`,`jsxInject` 自动注入 import,页面不手写);业务内部相对 import 合并进页面模块;`external: ["@tiny-ui/*"]` 保持裸说明符;`format: "esm"`,`target: "esnext"`;输出 source map | 任何语法降级;对 `@tiny-ui/*` 的解析 | +| esbuild | TS 类型擦除;JSX → `h()`(`jsxFactory: "h"`,`inject` 一个内存模块自动引入 `h` / `Fragment`,页面不手写);业务内部相对 import 合并进页面模块;`@tiny-ui/*` 在插件 `onResolve` 里标 `external` + `sideEffects: false`,保持裸说明符且未用到时整条 import 被去掉;`format: "esm"`,`target: "esnext"`;输出 source map | 任何语法降级;对 `@tiny-ui/*` 的解析 | | qjsc-kmp | 源码模块 → 字节码,`-n` 给定模块名,`--strip-source` 保留行号去源码 | 校验模块图(引擎加载时查表) | -**页面名即模块名**:`src/pages/home.tsx` → `pages/home`,子目录保留路径,无后缀无前缀(ADR-005 §4)。运行时模块名固定 `@tiny-ui/core`、`@tiny-ui/native`。 +**页面名即模块名**:`src/pages/home.tsx` → `pages/home`,子目录保留路径,无后缀无前缀(ADR-005 §4)。运行时模块名固定 `@tiny-ui/core`、`@tiny-ui/native`。产物目录 `runtime/*.bin`、`pages/**/*.bin`、`manifest.json`(页面与运行时模块清单,Kotlin 侧路由表的来源,见 [app-model.md](./app-model.md))。 + +`inject` 的 import 不能只写 `external` 配置项:esbuild 对 `external` 列表里的模块一律保留 import(当作有副作用),无 JSX 的页面也会 `import { h } from "@tiny-ui/core"`,引擎链接模块时因 core 没有该导出而报 `SyntaxError`(2026-09-16 Android 模拟器实测)。走插件 `onResolve` 返回 `sideEffects: false` 才会被摇掉。 ## 3. 页面即构建单元 @@ -90,8 +92,8 @@ rolldown 是真正的备选(Rollup 同形 API、oxc 转译)。CLI 只包一 | `packages/core` / `native` | 不动运行时 API,只保证入口能打成单模块,各导出 `VERSION` 供 sample 显示 | | `compose/` | ADR-002 K0 最小版:注册 core → 注册 native → 运行页面模块 → 返回 namespace。命名待 M1 定,本期只保证序列在库里而不在 sample 里。commonTest 用 `JsBytecode.compile` 现场编字节码,不依赖宿主工具 | | `sample/js` | 新 pnpm workspace 成员,`src/pages/home.tsx`。本期页面用纯 TS 只 import `VERSION`:`h` 的签名是 C 组的事,不在 core 放临时实现;JSX 变换的正确性由 CLI 单元测试覆盖 | -| `sample/shared` | Gradle Exec 任务调 CLI,产物挂成 Compose resources 自定义目录,`Res.readBytes` 读入后走 K0 | +| `sample/shared` | Gradle:`pnpm run build` → `tinyui build` → `Sync` 只取 `.bin` 与清单 → `compose.resources.customDirectory`,`Res.readBytes("files/tinyui/…")` 读入后走 K0。AGP 9 的 KMP 库插件默认不处理 assets,Compose resources 在 Android 走 assets,必须 `androidResources { enable = true }`,否则 APK 里没有资源且构建不报错(2026-09-16 实测) | | `settings.gradle.kts` | 有 `quickjs-kmp.dir` 时把 `qjsc-kmp` 路径传给 Exec 任务 | -| CI | gradle job 加 setup-node + pnpm,clone quickjs-kmp 编宿主工具;ios.yml 同样 | +| CI | gradle job 加 setup-node + pnpm,clone quickjs-kmp 的 catalog 版本 tag 编 `buildHostTools`(qjsc-kmp)与 `buildNativeHostJni`(Android host 测试要加载宿主 JNI 库,Maven 包里没有),经 `TINYUI_QJSC` / `TINYUI_QUICKJS_HOST_JNI` 传入;ios.yml 同样 | source map 随 `.js` 输出到 `.map`;行列号回映射到 TSX 归 E1 / E2 错误上报那一期。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 273daa6..854d62b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,7 +10,7 @@ | 仓库结构 | 已定单仓,目录见 README"仓库结构"一节 | 已定 | | 构建链骨架 | Gradle(build-logic / compose / sample 含 iOS 壳)+ pnpm workspace(core / native / cli)+ CI(PR 门禁 Android / host / apiCheck,iOS 全量在 ios.yml tag / 手动);quickjs-kmp 本地 composite、CI 走 Central | 已完成(PR #1,2026-09-16) | | **quickjs-kmp** | 新建 SDK 接入 QuickJS(ADR-005),复制 mquickjs-kmp 骨架,API 同形;M1 shim + 三端构建,M2 句柄表 + Runtime + 微任务 + **ESM 模块表**,M3 字节码 + 宿主编译工具 + 发布。TinyUI 的 `compose/` 依赖它 | 已完成(M1~M3,`wang.harlon:quickjs-kmp:0.1.0` 2026-09-16 发 Central;桥形验收见 `bench/results/2026-09-16-quickjs-kmp.md`) | -| 构建链打通 | CLI:TSX → `h()`(ES2025)→ 每页一个 ESM 模块字节码,`external: ["@tiny-ui/*"]`;运行时两个模块字节码内置 | 方案已定(2026-09-16,[build-chain.md](./build-chain.md)),实施中 | +| 构建链打通 | CLI:TSX → `h()`(ES2025)→ 每页一个 ESM 模块字节码,`external: ["@tiny-ui/*"]`;运行时两个模块字节码内置 | 已完成(PR #2,2026-09-16;方案与踩坑见 [build-chain.md](./build-chain.md)) | ## B. 验证实验(数据驱动,互相独立,可并行) diff --git a/packages/cli/package.json b/packages/cli/package.json index b1d84c9..ca8cc97 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,12 +4,34 @@ "description": "TinyUI build tool: TSX -> h() (ES2025) -> ESM module bytecode.", "license": "MIT", "type": "module", - "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, - "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --test --experimental-strip-types 'test/**/*.test.ts'" + "test": "node --test 'test/**/*.test.ts'" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/HarlonWang/tinyui.git", + "directory": "packages/cli" + }, + "bin": { + "tinyui": "./dist/bin.js" + }, + "dependencies": { + "esbuild": "0.28.2" }, - "repository": { "type": "git", "url": "git+https://github.com/HarlonWang/tinyui.git", "directory": "packages/cli" } + "devDependencies": { + "@tiny-ui/core": "workspace:*", + "@tiny-ui/native": "workspace:*", + "@types/node": "latest" + } } diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts new file mode 100644 index 0000000..e37a56a --- /dev/null +++ b/packages/cli/src/bin.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import { parseArgs } from "node:util"; +import { build } from "./build.ts"; + +const USAGE = `usage: tinyui build [--root ] [--out ] [--qjsc ] [--js-only] + + --root project root, pages under /src/pages (default: cwd) + --out output directory (default: /dist) + --qjsc path to qjsc-kmp (default: $TINYUI_QJSC, then PATH) + --js-only emit ESM sources and source maps only, skip bytecode +`; + +async function main(argv: string[]): Promise { + const { values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + root: { type: "string" }, + out: { type: "string" }, + qjsc: { type: "string" }, + "js-only": { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + }); + if (values.help || positionals[0] !== "build") { + process.stdout.write(USAGE); + return values.help ? 0 : 2; + } + const result = await build({ + root: values.root ?? process.cwd(), + ...(values.out !== undefined && { out: values.out }), + ...(values.qjsc !== undefined && { qjsc: values.qjsc }), + jsOnly: values["js-only"], + }); + for (const m of [...result.runtime, ...result.pages]) { + process.stdout.write(`${m.name} -> ${m.bin ?? m.js}\n`); + } + return 0; +} + +main(process.argv.slice(2)).then( + (code) => process.exit(code), + (e: unknown) => { + process.stderr.write(`tinyui: ${e instanceof Error ? e.message : String(e)}\n`); + process.exit(1); + }, +); diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts new file mode 100644 index 0000000..63771f0 --- /dev/null +++ b/packages/cli/src/build.ts @@ -0,0 +1,146 @@ +import { build as esbuild, type Plugin } from "esbuild"; +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { compileModule, findQjsc } from "./qjsc.ts"; + +export const RUNTIME_MODULES = ["@tiny-ui/core", "@tiny-ui/native"] as const; + +export interface BuildOptions { + /** Project root: pages are discovered under `/src/pages`, packages resolved from `/node_modules`. */ + root: string; + /** Output directory; defaults to `/dist`. */ + out?: string; + /** Path to `qjsc-kmp`; falls back to `TINYUI_QJSC` and then `PATH`. */ + qjsc?: string; + /** Emit only the ESM sources and skip bytecode; for debugging the transform without an engine build. */ + jsOnly?: boolean; +} + +export interface BuiltModule { + /** Module name as the engine sees it: `pages/home`, `@tiny-ui/core`. */ + name: string; + js: string; + map: string; + bin?: string; +} + +export interface BuildResult { + runtime: BuiltModule[]; + pages: BuiltModule[]; + manifest: string; +} + +export interface Manifest { + runtime: string[]; + pages: string[]; +} + +export async function build(options: BuildOptions): Promise { + const root = resolve(options.root); + const out = resolve(options.out ?? join(root, "dist")); + const pagesDir = join(root, "src", "pages"); + const pageNames = await discoverPages(pagesDir); + if (pageNames.size === 0) throw new Error(`no pages found under ${pagesDir}`); + + const qjsc = options.jsOnly ? undefined : await findQjsc(options.qjsc); + if (!options.jsOnly && !qjsc) { + throw new Error("qjsc-kmp not found: pass --qjsc, set TINYUI_QJSC, or put it on PATH (or use --js-only)"); + } + + const runtime = await bundleRuntime(root, out); + const pages = await bundlePages(root, out, pageNames); + if (qjsc) { + for (const m of [...runtime, ...pages]) { + m.bin = m.js.replace(/\.js$/, ".bin"); + await compileModule({ qjsc, input: m.js, output: m.bin, name: m.name }); + } + } + + const manifest = join(out, "manifest.json"); + const content: Manifest = { runtime: runtime.map((m) => m.name), pages: pages.map((m) => m.name) }; + await writeFile(manifest, JSON.stringify(content, null, 2) + "\n"); + return { runtime, pages, manifest }; +} + +async function discoverPages(pagesDir: string): Promise> { + const found = new Map(); + let entries; + try { + entries = await readdir(pagesDir, { recursive: true, withFileTypes: true }); + } catch { + return found; + } + for (const e of entries) { + if (!e.isFile() || !/\.tsx?$/.test(e.name) || e.name.endsWith(".d.ts")) continue; + const file = join(e.parentPath, e.name); + const name = "pages/" + relative(pagesDir, file).replace(/\.tsx?$/, "").split(sep).join("/"); + found.set(name, file); + } + return new Map([...found].sort(([a], [b]) => (a < b ? -1 : 1))); +} + +async function bundleRuntime(root: string, out: string): Promise { + const built: BuiltModule[] = []; + for (const name of RUNTIME_MODULES) { + const outfile = join(out, "runtime", name.replace("@tiny-ui/", "") + ".js"); + await mkdir(dirname(outfile), { recursive: true }); + await esbuild({ + ...common(root), + entryPoints: [name], + outfile, + external: RUNTIME_MODULES.filter((m) => m !== name), + }); + built.push({ name, js: outfile, map: outfile + ".map" }); + } + return built; +} + +async function bundlePages(root: string, out: string, pages: Map): Promise { + const outdir = join(out, "pages"); + await esbuild({ + ...common(root), + entryPoints: [...pages].map(([name, file]) => ({ in: file, out: name.slice("pages/".length) })), + outdir, + outbase: join(root, "src", "pages"), + jsx: "transform", + jsxFactory: "h", + jsxFragment: "Fragment", + inject: [JSX_SHIM], + plugins: [pagePlugin], + }); + return [...pages.keys()].map((name) => { + const js = join(outdir, name.slice("pages/".length) + ".js"); + return { name, js, map: js + ".map" }; + }); +} + +const JSX_SHIM = "tinyui:jsx-shim"; + +/** `inject` wants a module; this serves one in memory so pages get `h` / `Fragment` from the runtime module. */ +const pagePlugin: Plugin = { + name: "tinyui-pages", + setup(api) { + api.onResolve({ filter: /^tinyui:jsx-shim$/ }, (args) => ({ path: args.path, namespace: "tinyui" })); + // Runtime modules stay bare specifiers and are pure, so a page that never uses JSX keeps no import of h + api.onResolve({ filter: /^@tiny-ui\// }, (args) => ({ path: args.path, external: true, sideEffects: false })); + api.onLoad({ filter: /.*/, namespace: "tinyui" }, () => ({ + contents: 'export { h, Fragment } from "@tiny-ui/core";', + loader: "js", + })); + }, +}; + +function common(root: string) { + return { + absWorkingDir: root, + bundle: true, + format: "esm" as const, + platform: "neutral" as const, + target: "esnext", + sourcemap: true as const, + logLevel: "silent" as const, + splitting: false, + treeShaking: true, + legalComments: "none" as const, + }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2e47a88..770f091 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1 +1,4 @@ -export const VERSION = "0.0.0"; +export { build, RUNTIME_MODULES } from "./build.ts"; +export type { BuildOptions, BuildResult, BuiltModule, Manifest } from "./build.ts"; +export { compileModule, findQjsc } from "./qjsc.ts"; +export type { CompileOptions } from "./qjsc.ts"; diff --git a/packages/cli/src/qjsc.ts b/packages/cli/src/qjsc.ts new file mode 100644 index 0000000..5176f71 --- /dev/null +++ b/packages/cli/src/qjsc.ts @@ -0,0 +1,48 @@ +import { execFile } from "node:child_process"; +import { access, constants } from "node:fs/promises"; +import { delimiter, join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const EXECUTABLE = process.platform === "win32" ? "qjsc-kmp.exe" : "qjsc-kmp"; + +/** Locates the quickjs-kmp host compiler: explicit path, then `TINYUI_QJSC`, then `PATH`. */ +export async function findQjsc(explicit?: string): Promise { + const candidates = [explicit, process.env["TINYUI_QJSC"]].filter((p): p is string => !!p); + for (const p of candidates) { + if (await isExecutable(p)) return p; + throw new Error(`qjsc-kmp not found at ${p}`); + } + for (const dir of (process.env["PATH"] ?? "").split(delimiter)) { + if (dir && (await isExecutable(join(dir, EXECUTABLE)))) return join(dir, EXECUTABLE); + } + return undefined; +} + +export interface CompileOptions { + qjsc: string; + input: string; + output: string; + /** Module name recorded in the bytecode; the engine registers and imports it under this exact name. */ + name: string; +} + +/** Compiles one ES module source file to engine bytecode with source text stripped (line numbers kept). */ +export async function compileModule({ qjsc, input, output, name }: CompileOptions): Promise { + try { + await execFileAsync(qjsc, ["-m", "-n", name, "--strip-source", "-o", output, input]); + } catch (e) { + const stderr = (e as { stderr?: string }).stderr?.trim(); + throw new Error(`qjsc-kmp failed for ${name}${stderr ? `:\n${stderr}` : ""}`, { cause: e }); + } +} + +async function isExecutable(path: string): Promise { + try { + await access(path, constants.X_OK); + return true; + } catch { + return false; + } +} diff --git a/packages/cli/test/build.test.ts b/packages/cli/test/build.test.ts new file mode 100644 index 0000000..8e7a3a7 --- /dev/null +++ b/packages/cli/test/build.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; +import { build, type BuildResult } from "../src/build.ts"; +import { findQjsc } from "../src/qjsc.ts"; + +const root = join(import.meta.dirname, "fixtures", "app"); + +describe("tinyui build", () => { + let out: string; + let result: BuildResult; + let qjsc: string | undefined; + + before(async () => { + out = await mkdtemp(join(tmpdir(), "tinyui-build-")); + qjsc = await findQjsc(); + result = await build({ root, out, jsOnly: !qjsc }); + }); + after(() => rm(out, { recursive: true, force: true })); + + it("names pages after their path under src/pages", () => { + assert.deepEqual(result.pages.map((m) => m.name), ["pages/home", "pages/nested/detail"]); + assert.deepEqual(result.runtime.map((m) => m.name), ["@tiny-ui/core", "@tiny-ui/native"]); + }); + + it("turns JSX into h() calls with the factory imported from @tiny-ui/core", async () => { + const js = await readFile(join(out, "pages", "home.js"), "utf8"); + assert.match(js, /import \{ h, Fragment \} from "@tiny-ui\/core"/); + assert.match(js, /h\(Column, null, .*h\(Text, \{ text: label \}\)/s); + assert.doesNotMatch(js, /interface Props|: Props/, "types are erased"); + }); + + it("inlines relative imports and keeps @tiny-ui/* as bare specifiers", async () => { + const js = await readFile(join(out, "pages", "home.js"), "utf8"); + assert.match(js, /function title\(name\)/, "../lib/format.ts is bundled in"); + assert.doesNotMatch(js, /from "\.\.?\//, "no relative imports survive"); + assert.match(js, /from "@tiny-ui\/core"/); + }); + + it("imports the JSX factory only from pages that use JSX", async () => { + const js = await readFile(join(out, "pages", "nested", "detail.js"), "utf8"); + assert.doesNotMatch(js, /@tiny-ui\/core/, "no JSX, no factory import: the runtime module does not export h yet"); + }); + + it("keeps ES2025 syntax as is", async () => { + const js = await readFile(join(out, "pages", "nested", "detail.js"), "utf8"); + assert.match(js, /await Promise\.resolve\(true\)/, "top-level await is not rewritten"); + assert.match(js, /import\.meta\.url/); + }); + + it("writes a source map next to every module", async () => { + for (const m of [...result.runtime, ...result.pages]) { + const map = JSON.parse(await readFile(m.map, "utf8")) as { sources: string[] }; + assert.ok(map.sources.length > 0, `${m.name} has sources`); + } + const home = JSON.parse(await readFile(join(out, "pages", "home.js.map"), "utf8")) as { sources: string[] }; + assert.ok(home.sources.some((s) => s.endsWith("src/pages/home.tsx"))); + }); + + it("bundles each runtime module on its own", async () => { + const core = await readFile(join(out, "runtime", "core.js"), "utf8"); + assert.match(core, /export \{/); + assert.doesNotMatch(core, /from "@tiny-ui\//); + }); + + it("lists everything in manifest.json", async () => { + const manifest = JSON.parse(await readFile(result.manifest, "utf8")); + assert.deepEqual(manifest, { + runtime: ["@tiny-ui/core", "@tiny-ui/native"], + pages: ["pages/home", "pages/nested/detail"], + }); + }); + + it("compiles every module to bytecode when qjsc-kmp is available", { skip: !process.env["TINYUI_QJSC"] && "TINYUI_QJSC not set" }, async () => { + for (const m of [...result.runtime, ...result.pages]) { + assert.ok(m.bin, `${m.name} has bytecode`); + const bytes = await readFile(m.bin!); + assert.equal(bytes.subarray(0, 4).toString("latin1"), "QJKB", `${m.name} bytecode header`); + } + }); +}); diff --git a/packages/cli/test/fixtures/app/package.json b/packages/cli/test/fixtures/app/package.json new file mode 100644 index 0000000..4757a54 --- /dev/null +++ b/packages/cli/test/fixtures/app/package.json @@ -0,0 +1 @@ +{ "name": "fixture-app", "private": true, "type": "module" } diff --git a/packages/cli/test/fixtures/app/src/lib/format.ts b/packages/cli/test/fixtures/app/src/lib/format.ts new file mode 100644 index 0000000..449bad5 --- /dev/null +++ b/packages/cli/test/fixtures/app/src/lib/format.ts @@ -0,0 +1,3 @@ +export function title(name: string): string { + return `Page ${name}`; +} diff --git a/packages/cli/test/fixtures/app/src/pages/home.tsx b/packages/cli/test/fixtures/app/src/pages/home.tsx new file mode 100644 index 0000000..292ef66 --- /dev/null +++ b/packages/cli/test/fixtures/app/src/pages/home.tsx @@ -0,0 +1,9 @@ +import { VERSION } from "@tiny-ui/core"; +import { title } from "../lib/format.ts"; + +interface Props { name: string } + +export default function Home({ name }: Props) { + const label = title(name); + return ; +} diff --git a/packages/cli/test/fixtures/app/src/pages/nested/detail.ts b/packages/cli/test/fixtures/app/src/pages/nested/detail.ts new file mode 100644 index 0000000..a0e9105 --- /dev/null +++ b/packages/cli/test/fixtures/app/src/pages/nested/detail.ts @@ -0,0 +1,4 @@ +import { title } from "../../lib/format.ts"; + +export const ready = await Promise.resolve(true); +export default () => `${title("detail")} @ ${import.meta.url}`; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 6fcd57f..f4ed242 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist" }, - "include": ["src"] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "types": ["node"] }, + "include": ["src"] } diff --git a/packages/core/package.json b/packages/core/package.json index 4bcc9e8..93723b0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,12 +4,24 @@ "description": "TinyUI runtime: signals, ownership, h/For/Show, patch emission.", "license": "MIT", "type": "module", - "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, - "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "node --test --experimental-strip-types 'test/**/*.test.ts'" }, - "repository": { "type": "git", "url": "git+https://github.com/HarlonWang/tinyui.git", "directory": "packages/core" } + "repository": { + "type": "git", + "url": "git+https://github.com/HarlonWang/tinyui.git", + "directory": "packages/core" + }, + "sideEffects": false } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2e47a88..e487cda 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,9 @@ export const VERSION = "0.0.0"; + +// Globals the engine provides to page code; there is no DOM lib to declare them (docs/adr-005-engine.md). +declare global { + interface ImportMeta { + /** `tinyui:`, e.g. `tinyui:pages/home`. */ + readonly url: string; + } +} diff --git a/packages/native/package.json b/packages/native/package.json index 29f2412..6e318e7 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -4,12 +4,24 @@ "description": "TinyUI host capability API for business code: http, storage, toast, navigation, i18n.", "license": "MIT", "type": "module", - "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, - "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "node --test --experimental-strip-types 'test/**/*.test.ts'" }, - "repository": { "type": "git", "url": "git+https://github.com/HarlonWang/tinyui.git", "directory": "packages/native" } + "repository": { + "type": "git", + "url": "git+https://github.com/HarlonWang/tinyui.git", + "directory": "packages/native" + }, + "sideEffects": false } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6565863..7a6ac72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,14 +12,200 @@ importers: specifier: 7.0.2 version: 7.0.2 - packages/cli: {} + packages/cli: + dependencies: + esbuild: + specifier: 0.28.2 + version: 0.28.2 + devDependencies: + '@tiny-ui/core': + specifier: workspace:* + version: link:../core + '@tiny-ui/native': + specifier: workspace:* + version: link:../native + '@types/node': + specifier: latest + version: 22.20.3 packages/core: {} packages/native: {} + sample/js: + dependencies: + '@tiny-ui/core': + specifier: workspace:* + version: link:../../packages/core + '@tiny-ui/native': + specifier: workspace:* + version: link:../../packages/native + devDependencies: + '@tiny-ui/cli': + specifier: workspace:* + version: link:../../packages/cli + packages: + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/node@22.20.3': + resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -140,13 +326,103 @@ packages: cpu: [x64] os: [win32] + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + snapshots: + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@types/node@22.20.3': + dependencies: + undici-types: 6.21.0 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -207,6 +483,35 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -229,3 +534,5 @@ snapshots: '@typescript/typescript-sunos-x64': 7.0.2 '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@6.21.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 924b55f..aa1b561 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,6 @@ packages: - packages/* + - sample/js + +onlyBuiltDependencies: + - esbuild diff --git a/sample/js/package.json b/sample/js/package.json new file mode 100644 index 0000000..5323a0e --- /dev/null +++ b/sample/js/package.json @@ -0,0 +1,15 @@ +{ + "name": "tinyui-sample", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@tiny-ui/core": "workspace:*", + "@tiny-ui/native": "workspace:*" + }, + "devDependencies": { + "@tiny-ui/cli": "workspace:*" + } +} diff --git a/sample/js/src/lib/greeting.ts b/sample/js/src/lib/greeting.ts new file mode 100644 index 0000000..e73e365 --- /dev/null +++ b/sample/js/src/lib/greeting.ts @@ -0,0 +1,3 @@ +export function greeting(runtime: string, from: string): string { + return `TinyUI page loaded: ${runtime} (${from})`; +} diff --git a/sample/js/src/pages/home.tsx b/sample/js/src/pages/home.tsx new file mode 100644 index 0000000..54b1293 --- /dev/null +++ b/sample/js/src/pages/home.tsx @@ -0,0 +1,6 @@ +import { VERSION } from "@tiny-ui/core"; +import { greeting } from "../lib/greeting.ts"; + +export default function home(): string { + return greeting(`core ${VERSION}`, import.meta.url); +} diff --git a/sample/js/tsconfig.json b/sample/js/tsconfig.json new file mode 100644 index 0000000..ba5ff5f --- /dev/null +++ b/sample/js/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "jsx": "react", + "jsxFactory": "h", + "jsxFragmentFactory": "Fragment" + }, + "include": ["src"] +} diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 2efc87a..fa775df 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -7,6 +7,8 @@ plugins { kotlin { android { namespace = "wang.harlon.tinyui.sample.shared" + // AGP 的 KMP 库插件默认不处理 res / assets,Compose resources 在 Android 走 assets,必须打开 + androidResources { enable = true } } listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> @@ -23,6 +25,51 @@ kotlin { implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) + implementation(compose.components.resources) + implementation(libs.kotlinx.coroutines.core) } } } + +// JS 侧:pnpm 编三个包 → tinyui build 把 sample/js 的页面与运行时模块编成字节码 → 只把 .bin 与清单打成 Compose 资源 +val jsRoot = rootDir.resolve("sample/js") +val cliOut = layout.buildDirectory.dir("tinyui-cli") +val quickjsKmpDir = (gradle as ExtensionAware).extra.properties["quickjs-kmp.dir"] as File? +val qjsc: Provider = providers.gradleProperty("tinyui.qjsc") + .orElse(providers.environmentVariable("TINYUI_QJSC")) + .orElse(providers.provider { quickjsKmpDir?.resolve("library/build/native/host-tools/bin/qjsc-kmp")?.absolutePath }) + +val buildJsPackages by tasks.registering(Exec::class) { + group = "build" + description = "pnpm build for packages/*" + workingDir = rootDir + commandLine("pnpm", "run", "build") + inputs.files(fileTree(rootDir.resolve("packages")) { include("*/src/**", "*/package.json", "*/tsconfig.json") }) + inputs.file(rootDir.resolve("tsconfig.base.json")) + outputs.dirs(rootDir.resolve("packages/core/dist"), rootDir.resolve("packages/native/dist"), rootDir.resolve("packages/cli/dist")) +} + +val buildTinyUIPages by tasks.registering(Exec::class) { + group = "build" + description = "tinyui build for sample/js" + dependsOn(buildJsPackages) + if (quickjsKmpDir != null) dependsOn(gradle.includedBuild("quickjs-kmp").task(":library:buildHostTools")) + workingDir = jsRoot + inputs.dir(jsRoot.resolve("src")) + inputs.files(buildJsPackages.map { it.outputs.files }) + inputs.property("qjsc", qjsc.orElse("")) + outputs.dir(cliOut) + val args = mutableListOf("node", rootDir.resolve("packages/cli/dist/bin.js").path, "build", "--root", jsRoot.path, "--out", cliOut.get().asFile.path) + qjsc.orNull?.let { args += listOf("--qjsc", it) } + commandLine(args) +} + +val collectTinyUIResources by tasks.registering(Sync::class) { + from(buildTinyUIPages) { include("**/*.bin", "manifest.json") } + into(layout.buildDirectory.dir("tinyui-resources/files/tinyui")) +} + +compose.resources { + packageOfResClass = "wang.harlon.tinyui.sample.res" + customDirectory("commonMain", layout.dir(collectTinyUIResources.map { it.destinationDir.parentFile.parentFile })) +} diff --git a/sample/shared/src/commonMain/kotlin/wang/harlon/tinyui/sample/App.kt b/sample/shared/src/commonMain/kotlin/wang/harlon/tinyui/sample/App.kt index cfa6148..d48dbb6 100644 --- a/sample/shared/src/commonMain/kotlin/wang/harlon/tinyui/sample/App.kt +++ b/sample/shared/src/commonMain/kotlin/wang/harlon/tinyui/sample/App.kt @@ -2,18 +2,46 @@ package wang.harlon.tinyui.sample import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.ExperimentalResourceApi +import wang.harlon.quickjs.JsValue +import wang.harlon.tinyui.PageEngine +import wang.harlon.tinyui.RuntimeBundle import wang.harlon.tinyui.TinyUI +import wang.harlon.tinyui.sample.res.Res +@OptIn(ExperimentalResourceApi::class) @Composable fun App() { + var text by remember { mutableStateOf("loading pages/home …") } + LaunchedEffect(Unit) { + val runtime = RuntimeBundle( + core = Res.readBytes("files/tinyui/runtime/core.bin"), + native = Res.readBytes("files/tinyui/runtime/native.bin"), + ) + val page = Res.readBytes("files/tinyui/pages/home.bin") + text = withContext(Dispatchers.Default) { + runCatching { + PageEngine.load(runtime, page).use { (it.callDefault() as JsValue.Str).value } + }.getOrElse { "failed: $it" } + } + } MaterialTheme { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("TinyUI sample · quickjs-kmp ${TinyUI.engineVersion}") + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text("$text\n\nquickjs-kmp ${TinyUI.engineVersion}") } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9700581..52771ba 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,6 +30,8 @@ val localProperties = file("local.properties").takeIf { it.exists() }?.let { f - localProperties?.getProperty("quickjs-kmp.dir")?.let { dir -> val sdkDir = file(dir) require(sdkDir.isDirectory) { "quickjs-kmp.dir 不存在: $sdkDir" } + // 构建脚本据此取该仓的宿主产物(qjsc-kmp、host JNI 库) + gradle.extra["quickjs-kmp.dir"] = sdkDir includeBuild(sdkDir) { dependencySubstitution { sdkDir.resolve("gradle/composite-substitutions").readLines() diff --git a/tsconfig.base.json b/tsconfig.base.json index 2f58d9f..a66ed5a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,6 +8,8 @@ "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, "isolatedModules": true, "declaration": true, "sourceMap": true, From e1ea02da4875263647f5d8d1d79bf152e61c25dd Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Wed, 16 Sep 2026 15:48:46 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(cli):=20=E5=90=8C=E5=90=8D=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=9C=89=E4=B8=A4=E4=BB=BD=E6=BA=90=E7=A0=81=E6=97=B6?= =?UTF-8?q?=E6=8A=A5=E9=94=99=E8=80=8C=E4=B8=8D=E6=98=AF=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- packages/cli/src/build.ts | 2 ++ packages/cli/test/build.test.ts | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 63771f0..fb961d7 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -74,6 +74,8 @@ async function discoverPages(pagesDir: string): Promise> { if (!e.isFile() || !/\.tsx?$/.test(e.name) || e.name.endsWith(".d.ts")) continue; const file = join(e.parentPath, e.name); const name = "pages/" + relative(pagesDir, file).replace(/\.tsx?$/, "").split(sep).join("/"); + const clash = found.get(name); + if (clash) throw new Error(`page ${name} has two sources: ${clash} and ${file}`); found.set(name, file); } return new Map([...found].sort(([a], [b]) => (a < b ? -1 : 1))); diff --git a/packages/cli/test/build.test.ts b/packages/cli/test/build.test.ts index 8e7a3a7..77f155e 100644 --- a/packages/cli/test/build.test.ts +++ b/packages/cli/test/build.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, before, describe, it } from "node:test"; @@ -65,6 +65,18 @@ describe("tinyui build", () => { assert.doesNotMatch(core, /from "@tiny-ui\//); }); + it("rejects two sources for one page name", async () => { + const clashRoot = await mkdtemp(join(tmpdir(), "tinyui-clash-")); + try { + await mkdir(join(clashRoot, "src", "pages"), { recursive: true }); + await writeFile(join(clashRoot, "src", "pages", "home.ts"), "export default () => 1;"); + await writeFile(join(clashRoot, "src", "pages", "home.tsx"), "export default () => 2;"); + await assert.rejects(build({ root: clashRoot, jsOnly: true }), /page pages\/home has two sources/); + } finally { + await rm(clashRoot, { recursive: true, force: true }); + } + }); + it("lists everything in manifest.json", async () => { const manifest = JSON.parse(await readFile(result.manifest, "utf8")); assert.deepEqual(manifest, {