@mica-js/sdk 0.0.0-stage → 0.2.0

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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -2
  3. package/dist/build/cli.d.ts +22 -0
  4. package/dist/build/cli.js +211 -0
  5. package/dist/build/config.d.ts +16 -0
  6. package/dist/build/config.js +92 -0
  7. package/dist/build/content.d.ts +14 -0
  8. package/dist/build/content.js +159 -0
  9. package/dist/build/prepare.d.ts +51 -0
  10. package/dist/build/prepare.js +306 -0
  11. package/dist/build/project.d.ts +4 -0
  12. package/dist/build/project.js +30 -0
  13. package/dist/build/vite.d.ts +7 -0
  14. package/dist/build/vite.js +60 -0
  15. package/dist/ddl.d.ts +2 -0
  16. package/dist/ddl.js +1 -0
  17. package/dist/device.d.ts +1 -0
  18. package/dist/device.js +1 -0
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +1 -0
  21. package/dist/metadata/index.d.ts +5 -0
  22. package/dist/metadata/index.js +2 -0
  23. package/dist/metadata/schema.d.ts +10 -0
  24. package/dist/metadata/schema.js +365 -0
  25. package/dist/metadata/types.d.ts +185 -0
  26. package/dist/metadata/types.js +7 -0
  27. package/dist/metadata/validate.d.ts +25 -0
  28. package/dist/metadata/validate.js +283 -0
  29. package/dist/model.d.ts +1 -0
  30. package/dist/model.js +1 -0
  31. package/dist/widget.d.ts +1 -0
  32. package/dist/widget.js +1 -0
  33. package/package.json +69 -3
  34. package/templates/basic/mica.config.ts +3 -0
  35. package/templates/basic/package.json +14 -0
  36. package/templates/basic/src/devices/resistor/device.json +30 -0
  37. package/templates/basic/src/devices/resistor/index.ts +8 -0
  38. package/templates/basic/src/devices/resistor/model.ddl +6 -0
  39. package/templates/basic/src/widgets/lamp/index.ts +22 -0
  40. package/templates/basic/src/widgets/lamp/widget.json +9 -0
  41. package/templates/basic/tsconfig.json +14 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DigitalEcho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,115 @@
1
- # Temporary Holding Version
1
+ # @mica-js/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Device / Widget 作者 API、DDL 类型准备及 CLI 双文件构建。每个条目独立发布;一个工程可以维护多个条目。Vite 由 SDK 内部管理,作者使用 `mica build` 与 `mica prepare`。
4
+
5
+ ## 工程与产物
6
+
7
+ 源码使用固定入口:
8
+
9
+ ```text
10
+ src/
11
+ devices/<id>/
12
+ device.json
13
+ model.ddl
14
+ index.ts # 可选,省略时生成最小同步工厂
15
+ widgets/<id>/
16
+ widget.json
17
+ index.ts
18
+ shared/
19
+ ```
20
+
21
+ 构建结果只有以下文件,不生成归档、包清单或独立资源:
22
+
23
+ ```text
24
+ dist/
25
+ devices/<id>/device.json
26
+ devices/<id>/device.js
27
+ widgets/<id>/widget.json
28
+ widgets/<id>/widget.js
29
+ ```
30
+
31
+ 目录名与元数据 `id` 一致,字符范围为字母、数字、中划线。身份与版本由条目 JSON 的 `id`、`version` 表达。Device JS 具名导出 `model`(共享 DeviceIR)与 `create`(同步适配器工厂);Widget JS 默认导出同步实例工厂。每个 JS 独立打包依赖,宿主不解析 npm 导入或共享 chunk。模块装载及执行隔离由宿主负责。
32
+
33
+ ## 项目配置与命令
34
+
35
+ 工程根目录必须包含 `mica.config.ts`,空配置使用全部默认约定:
36
+
37
+ ```typescript
38
+ import { defineConfig } from '@mica-js/sdk/config';
39
+
40
+ export default defineConfig({});
41
+ ```
42
+
43
+ CLI 从当前目录向上查找最近的 `mica.config.ts`,该文件所在目录就是项目根目录;`--root` 改变查找起点,`--config` 指定配置文件(相对于查找起点)。未找到配置时报告错误。IDE 可以仅通过文件是否存在识别项目;配置是可执行 TypeScript,只应在受信任的工程中加载。
44
+
45
+ 可选配置示例:
46
+
47
+ ```typescript
48
+ export default defineConfig({
49
+ sourceDir: 'src',
50
+ outDir: 'dist',
51
+ base: '/components/',
52
+ alias: { '@': './src' },
53
+ });
54
+ ```
55
+
56
+ 源码、输出及别名路径相对于项目根目录解析;输出目录必须与源码目录分离,避免构建清理覆盖源码。配置只接受 SDK 定义的字段,Vite 是内部构建引擎;作者不需要安装 Vite 或维护 `vite.config.ts`。
57
+
58
+ ```shell
59
+ mica build
60
+ mica prepare
61
+ mica prepare --watch
62
+ mica build --config ./mica.config.ts
63
+ ```
64
+
65
+ `mica build` 准备 DDL 类型、校验元数据并独立打包每个条目,将双文件产物写入 `outDir`。预览宿主自行提供这些静态产物;组件源码变更后重新运行构建。
66
+
67
+ 内部打包复用 Vite 的 TS 与资源转换及别名解析。`?raw` 资源成为字符串,`?url` 等标准资源导入在库构建中内联。样式表等会额外输出文件的入口会构建失败;运行期模块导入、`import.meta` 引用同样拒绝。资源如何解码和使用由实现与宿主允许的 API 决定。
68
+
69
+ ## DDL 类型
70
+
71
+ ```typescript
72
+ import type { Factory } from './model.ddl';
73
+
74
+ const create: Factory = (ctx) => ({
75
+ sync(batch) {
76
+ // ctx.props、ctx.inputs、ctx.memory 和 batch.outputs 按该 DDL 精确投影。
77
+ },
78
+ dispose() {},
79
+ });
80
+
81
+ export default create;
82
+ ```
83
+
84
+ TypeScript 配置需要 `allowArbitraryExtensions: true`、`moduleResolution: "Bundler"`(或其他支持该扩展声明的解析模式)。`mica prepare` 在每份 DDL 旁生成 `model.d.ddl.ts`,提供 `Props`、`PropertyInput`、`Inputs`、`Memory`、`Outputs`、`Context`、`Batch`、`Adapter`、`Factory` 别名。声明仅供编译期使用;`import type` 不产生 DDL 运行时导入。建议忽略 `**/*.d.ddl.ts`。
85
+
86
+ ```shell
87
+ mica prepare
88
+ mica prepare --watch
89
+ mica prepare --root . --source-dir src
90
+ ```
91
+
92
+ `mica build` 也运行类型准备。独立 `tsc` 前仍应先运行 `mica prepare`。失败时只清理带 SDK 生成标记的过期声明;作者维护的文件受到保护。所有工厂与适配器钩子遵循新版仿真规范的同步返回、数据所有权和实例生命周期合同。
93
+
94
+ ## JSON 元数据
95
+
96
+ JSON 由作者完整维护,构建器只校验 JSON 自身结构以及 ID 与目录名;不从 DDL 提取属性或引脚,不做 JSON 与 DDL 的一致性检查,也不向发布 JSON 注入默认值。元数据的格式化可能改变空白。
97
+
98
+ `@mica-js/sdk/metadata` 导出 `deviceMetadataSchema`、`widgetMetadataSchema`、`validateMetadata` 以及相应 TypeScript 类型。元数据不含 `formatVersion`、`icon`、`category`。字节属性的 JSON 默认编码尚未定义,因此 `bytes` 属性暂不接受 JSON `default`。
99
+
100
+ `symbol` 描述静态矢量符号、引脚连接点和位号位置。X 向右、Y 向下,角度单位为度,正扫角顺时针。使用逻辑单位,建议 10 单位为默认网格间距;颜色使用主题角色。JSON 校验不证明符号引脚或属性与 DDL 一致。
101
+
102
+ ## 公开入口
103
+
104
+ | 入口 | 内容 |
105
+ | ----------------------- | ------------------------------------------------------------------ |
106
+ | `@mica-js/sdk` | `createDeviceLibrary`、`createBoard`、`SimulationError` 及宿主类型 |
107
+ | `@mica-js/sdk/device` | 器件上下文、工厂、批次、通道、话题与 JIT 类型 |
108
+ | `@mica-js/sdk/widget` | Widget 工厂、上下文、绘制、输入、资源与音频类型 |
109
+ | `@mica-js/sdk/model` | 共享 IR、Builder 与校验 |
110
+ | `@mica-js/sdk/ddl` | DDL 编译与类型投影 |
111
+ | `@mica-js/sdk/metadata` | Device / Widget Schema、类型与校验 |
112
+ | `@mica-js/sdk/config` | `defineConfig()`、`MicaConfig` 及项目配置发现与加载 |
113
+ | `@mica-js/sdk/prepare` | `prepare()`、声明监听工具 |
114
+
115
+ 浏览器作者入口不会引入 Vite 或 Node 构建依赖。最小工程见 [templates/basic](./templates/basic/),使用前将其作为独立工程安装依赖。
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ /** Writable half of `process.stdout` / `process.stderr`, so tests can capture CLI output. */
3
+ export interface CliStreams {
4
+ readonly out: {
5
+ write(chunk: string): unknown;
6
+ };
7
+ readonly err: {
8
+ write(chunk: string): unknown;
9
+ };
10
+ }
11
+ export interface CliArguments {
12
+ readonly command: 'prepare' | 'build';
13
+ readonly help: boolean;
14
+ readonly watch: boolean;
15
+ readonly root: string;
16
+ readonly config?: string;
17
+ readonly sourceDir?: string;
18
+ }
19
+ /** Parses SDK commands without reading or executing project configuration. */
20
+ export declare function parseCliArguments(argv: readonly string[]): CliArguments;
21
+ /** Runs one SDK command and returns its exit code. */
22
+ export declare function runCli(argv: readonly string[], streams?: CliStreams): Promise<number>;
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { loadProjectConfig } from './config.js';
4
+ import { relative, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { prepare, watchDevices } from './prepare.js';
7
+ const USAGE = `Usage: mica <command> [options]
8
+
9
+ Commands:
10
+ build build standalone Device / Widget artifacts
11
+ prepare generate model.d.ddl.ts declarations
12
+
13
+ A mica.config.ts file identifies the project; it is discovered upwards from --root.
14
+
15
+ Options:
16
+ --config <file> explicit mica.config.ts, relative to --root
17
+ --root <dir> discovery directory (default: current directory)
18
+ --source-dir <dir> override the configured source directory
19
+ --watch watch DDL definitions (prepare only)
20
+ -h, --help show this help`;
21
+ /** Parses SDK commands without reading or executing project configuration. */
22
+ export function parseCliArguments(argv) {
23
+ // A bare `mica` only explains the command instead of guessing a package root.
24
+ let help = argv.length === 0;
25
+ let watch = false;
26
+ let root = process.cwd();
27
+ let sourceDir;
28
+ let config;
29
+ let command = 'prepare';
30
+ let index = 0;
31
+ const first = argv[0];
32
+ if (first !== undefined && !first.startsWith('-')) {
33
+ if (first !== 'prepare' && first !== 'build')
34
+ throw new Error(`Unknown command '${first}'.`);
35
+ command = first;
36
+ index = 1;
37
+ }
38
+ for (; index < argv.length; index++) {
39
+ const argument = argv[index];
40
+ const separator = argument.indexOf('=');
41
+ const flag = separator === -1 ? argument : argument.slice(0, separator);
42
+ const inline = separator === -1 ? undefined : argument.slice(separator + 1);
43
+ const flagValue = () => {
44
+ if (inline !== undefined) {
45
+ if (!inline)
46
+ throw new Error(`Missing value for ${flag}.`);
47
+ return inline;
48
+ }
49
+ const next = argv[++index];
50
+ if (next === undefined || next.startsWith('-'))
51
+ throw new Error(`Missing value for ${flag}.`);
52
+ return next;
53
+ };
54
+ const bare = () => {
55
+ if (inline !== undefined)
56
+ throw new Error(`${flag} does not take a value.`);
57
+ };
58
+ switch (flag) {
59
+ case '-h':
60
+ case '--help':
61
+ bare();
62
+ help = true;
63
+ break;
64
+ case '--watch':
65
+ bare();
66
+ watch = true;
67
+ break;
68
+ case '--root':
69
+ root = resolve(flagValue());
70
+ break;
71
+ case '--source-dir':
72
+ sourceDir = flagValue();
73
+ break;
74
+ case '--config':
75
+ config = flagValue();
76
+ break;
77
+ default:
78
+ throw new Error(`Unknown argument '${argument}'.`);
79
+ }
80
+ }
81
+ if (watch && command !== 'prepare')
82
+ throw new Error('--watch is only supported by mica prepare.');
83
+ return {
84
+ command,
85
+ help,
86
+ watch,
87
+ root: resolve(root),
88
+ ...(sourceDir === undefined ? {} : { sourceDir }),
89
+ ...(config === undefined ? {} : { config }),
90
+ };
91
+ }
92
+ /** Runs one SDK command and returns its exit code. */
93
+ export async function runCli(argv, streams = { out: process.stdout, err: process.stderr }) {
94
+ let options;
95
+ try {
96
+ options = parseCliArguments(argv);
97
+ }
98
+ catch (error) {
99
+ streams.err.write(`mica: ${messageOf(error)}\n\n${USAGE}\n`);
100
+ return 1;
101
+ }
102
+ if (options.help) {
103
+ streams.out.write(`${USAGE}\n`);
104
+ return 0;
105
+ }
106
+ try {
107
+ const project = await loadProjectConfig({
108
+ root: options.root,
109
+ ...(options.config === undefined ? {} : { config: options.config }),
110
+ });
111
+ const configured = {
112
+ ...project,
113
+ config: {
114
+ ...project.config,
115
+ ...(options.sourceDir === undefined ? {} : { sourceDir: options.sourceDir }),
116
+ },
117
+ };
118
+ if (options.command === 'build') {
119
+ const { buildProject } = await import('./project.js');
120
+ await buildProject(configured);
121
+ streams.out.write('mica build: complete\n');
122
+ return 0;
123
+ }
124
+ const preparation = { root: project.root, sourceDir: configured.config.sourceDir ?? 'src' };
125
+ return options.watch
126
+ ? await watchLoop(preparation, streams)
127
+ : await prepareOnce(preparation, streams);
128
+ }
129
+ catch (error) {
130
+ streams.err.write(`mica ${options.command}: ${messageOf(error)}\n`);
131
+ return 1;
132
+ }
133
+ }
134
+ async function prepareOnce(options, streams) {
135
+ try {
136
+ report(await prepare({ root: options.root, sourceDir: options.sourceDir }), streams);
137
+ return 0;
138
+ }
139
+ catch (error) {
140
+ streams.err.write(`mica prepare: ${messageOf(error)}\n`);
141
+ return 1;
142
+ }
143
+ }
144
+ function watchLoop(options, streams) {
145
+ return new Promise((settle) => {
146
+ let watcher;
147
+ try {
148
+ watcher = watchDevices({
149
+ root: options.root,
150
+ sourceDir: options.sourceDir,
151
+ onPrepare: (files) => report(files, streams),
152
+ onError: (error) => streams.err.write(`mica prepare: ${messageOf(error)}\n`),
153
+ });
154
+ }
155
+ catch (error) {
156
+ streams.err.write(`mica prepare: ${messageOf(error)}\n`);
157
+ settle(1);
158
+ return;
159
+ }
160
+ let closing = false;
161
+ const stop = () => {
162
+ // A second signal must not wait for a regeneration that may already be stuck.
163
+ if (closing)
164
+ process.exit(0);
165
+ closing = true;
166
+ process.off('SIGINT', stop);
167
+ process.off('SIGTERM', stop);
168
+ void watcher.dispose().then(() => settle(0), (error) => {
169
+ streams.err.write(`mica prepare: ${messageOf(error)}\n`);
170
+ settle(1);
171
+ });
172
+ };
173
+ process.on('SIGINT', stop);
174
+ process.on('SIGTERM', stop);
175
+ void watcher.ready.then(() => {
176
+ if (closing)
177
+ return;
178
+ const sourceRoot = resolve(options.root, options.sourceDir);
179
+ streams.out.write(`mica prepare: watching ${sourceRoot} for changes\n`);
180
+ });
181
+ });
182
+ }
183
+ function report(files, streams) {
184
+ for (const file of files)
185
+ streams.out.write(`${relative(process.cwd(), file)}\n`);
186
+ if (!files.length) {
187
+ streams.out.write('mica prepare: no device definitions found\n');
188
+ return;
189
+ }
190
+ const plural = files.length === 1 ? 'declaration' : 'declarations';
191
+ streams.out.write(`mica prepare: ${files.length} device ${plural} ready\n`);
192
+ }
193
+ function messageOf(error) {
194
+ return error instanceof Error ? error.message : String(error);
195
+ }
196
+ function isMainModule() {
197
+ const entry = process.argv[1];
198
+ if (entry === undefined)
199
+ return false;
200
+ try {
201
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ }
207
+ if (isMainModule()) {
208
+ void runCli(process.argv.slice(2)).then((code) => {
209
+ process.exitCode = code;
210
+ });
211
+ }
@@ -0,0 +1,16 @@
1
+ export interface MicaConfig {
2
+ readonly sourceDir?: string;
3
+ readonly outDir?: string;
4
+ readonly base?: string;
5
+ readonly alias?: Readonly<Record<string, string>>;
6
+ }
7
+ export declare function defineConfig(config: MicaConfig): MicaConfig;
8
+ export declare function findProjectConfig(root?: string, config?: string): Promise<string>;
9
+ export declare function loadProjectConfig(options?: {
10
+ root?: string;
11
+ config?: string;
12
+ }): Promise<{
13
+ root: string;
14
+ configFile: string;
15
+ config: MicaConfig;
16
+ }>;
@@ -0,0 +1,92 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { loadConfigFromFile } from 'vite';
4
+ export function defineConfig(config) {
5
+ return config;
6
+ }
7
+ const CONFIG_FILE = 'mica.config.ts';
8
+ async function isFile(file) {
9
+ try {
10
+ return (await stat(file)).isFile();
11
+ }
12
+ catch (error) {
13
+ const code = error.code;
14
+ if (code === 'ENOENT' || code === 'ENOTDIR')
15
+ return false;
16
+ throw error;
17
+ }
18
+ }
19
+ export async function findProjectConfig(root = process.cwd(), config) {
20
+ const startingRoot = resolve(root);
21
+ if (config !== undefined) {
22
+ const file = resolve(startingRoot, config);
23
+ if (basename(file) !== CONFIG_FILE) {
24
+ throw new Error(`Mica project config must be named ${CONFIG_FILE}: ${file}`);
25
+ }
26
+ if (!(await isFile(file))) {
27
+ throw new Error(`Mica project config is not a file or does not exist: ${file}`);
28
+ }
29
+ return file;
30
+ }
31
+ let directory = startingRoot;
32
+ while (true) {
33
+ const file = join(directory, CONFIG_FILE);
34
+ if (await isFile(file))
35
+ return file;
36
+ const parent = dirname(directory);
37
+ if (parent === directory) {
38
+ throw new Error(`No ${CONFIG_FILE} project marker found from ${startingRoot} upward`);
39
+ }
40
+ directory = parent;
41
+ }
42
+ }
43
+ function invalid(field, expected) {
44
+ throw new Error(`Invalid Mica config ${field}: expected ${expected}`);
45
+ }
46
+ function plainObject(value, field) {
47
+ if (value === null ||
48
+ typeof value !== 'object' ||
49
+ (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) {
50
+ return invalid(field, 'a plain object');
51
+ }
52
+ return value;
53
+ }
54
+ function knownKeys(value, keys, field) {
55
+ for (const key of Reflect.ownKeys(value)) {
56
+ if (typeof key !== 'string' || !keys.includes(key)) {
57
+ invalid(`${field}.${String(key)}`, 'a supported config key');
58
+ }
59
+ }
60
+ }
61
+ function validateConfig(value) {
62
+ const config = plainObject(value, 'default export');
63
+ knownKeys(config, ['sourceDir', 'outDir', 'base', 'alias'], 'default export');
64
+ for (const field of ['sourceDir', 'outDir', 'base']) {
65
+ const entry = config[field];
66
+ if (entry !== undefined && (typeof entry !== 'string' || entry.trim().length === 0)) {
67
+ invalid(field, 'a nonempty string');
68
+ }
69
+ }
70
+ if (config.alias !== undefined) {
71
+ const aliases = plainObject(config.alias, 'alias');
72
+ for (const key of Reflect.ownKeys(aliases)) {
73
+ if (typeof key !== 'string' || typeof aliases[key] !== 'string') {
74
+ invalid(`alias.${String(key)}`, 'a string');
75
+ }
76
+ }
77
+ }
78
+ return {
79
+ ...config,
80
+ sourceDir: config.sourceDir ?? 'src',
81
+ outDir: config.outDir ?? 'dist',
82
+ base: config.base ?? '/',
83
+ };
84
+ }
85
+ export async function loadProjectConfig(options = {}) {
86
+ const configFile = await findProjectConfig(options.root, options.config);
87
+ const root = dirname(configFile);
88
+ const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, configFile, root, 'silent', undefined, 'bundle');
89
+ if (!loaded)
90
+ throw new Error(`Unable to load Mica project config: ${configFile}`);
91
+ return { root, configFile, config: validateConfig(loaded.config) };
92
+ }
@@ -0,0 +1,14 @@
1
+ import { type AliasOptions } from 'vite';
2
+ export interface ContentBuildOptions {
3
+ readonly root: string;
4
+ readonly sourceDir?: string;
5
+ readonly aliases?: AliasOptions;
6
+ readonly minify?: boolean;
7
+ }
8
+ export interface ContentFile {
9
+ readonly fileName: string;
10
+ readonly source: string;
11
+ }
12
+ export declare function modelExpression(value: unknown): string;
13
+ /** Builds each registration separately so no shared chunk or runtime import escapes. */
14
+ export declare function buildContent(options: ContentBuildOptions): Promise<readonly ContentFile[]>;
@@ -0,0 +1,159 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { isBuiltin } from 'node:module';
4
+ import { compileDDL } from '@mica-js/ddl';
5
+ import { build, normalizePath } from 'vite';
6
+ import { parseAst } from 'rolldown/parseAst';
7
+ import { validateMetadata } from '../metadata/index.js';
8
+ function assertSelfContained(code, label) {
9
+ const visit = (node) => {
10
+ if (node === null || typeof node !== 'object')
11
+ return;
12
+ if (Array.isArray(node)) {
13
+ for (const child of node)
14
+ visit(child);
15
+ return;
16
+ }
17
+ const record = node;
18
+ if (record.type === 'ImportDeclaration' ||
19
+ record.type === 'ImportExpression' ||
20
+ ((record.type === 'ExportNamedDeclaration' || record.type === 'ExportAllDeclaration') &&
21
+ record.source) ||
22
+ (record.type === 'MetaProperty' && record.meta.name === 'import'))
23
+ throw new Error(`${label}: implementation contains a runtime module or asset reference`);
24
+ for (const child of Object.values(record))
25
+ visit(child);
26
+ };
27
+ visit(parseAst(code));
28
+ }
29
+ async function exists(path) {
30
+ try {
31
+ return (await stat(path)).isFile();
32
+ }
33
+ catch (error) {
34
+ if (error.code === 'ENOENT')
35
+ return false;
36
+ throw error;
37
+ }
38
+ }
39
+ // JSON alone loses the IR's non-finite numbers and negative zero.
40
+ export function modelExpression(value) {
41
+ if (typeof value === 'number') {
42
+ if (Number.isNaN(value))
43
+ return 'NaN';
44
+ if (value === Infinity)
45
+ return 'Infinity';
46
+ if (value === -Infinity)
47
+ return '-Infinity';
48
+ return Object.is(value, -0) ? '-0' : String(value);
49
+ }
50
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
51
+ return JSON.stringify(value);
52
+ if (value instanceof Uint8Array)
53
+ return `new Uint8Array(${modelExpression(Array.from(value))})`;
54
+ if (Array.isArray(value))
55
+ return `[${value.map(modelExpression).join(',')}]`;
56
+ if (typeof value === 'object')
57
+ return `{${Object.entries(value)
58
+ .map(([key, item]) => `${key === '__proto__' ? '["__proto__"]' : JSON.stringify(key)}:${modelExpression(item)}`)
59
+ .join(',')}}`;
60
+ throw new TypeError(`Cannot serialize model value: ${typeof value}`);
61
+ }
62
+ /** Builds each registration separately so no shared chunk or runtime import escapes. */
63
+ export async function buildContent(options) {
64
+ const files = [];
65
+ for (const kind of ['device', 'widget']) {
66
+ const category = `${kind}s`;
67
+ const directory = resolve(options.root, options.sourceDir ?? 'src', category);
68
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
69
+ if (error.code === 'ENOENT')
70
+ return [];
71
+ throw error;
72
+ });
73
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
74
+ if (!entry.isDirectory())
75
+ continue;
76
+ const id = entry.name;
77
+ const base = resolve(directory, id);
78
+ const jsonPath = resolve(base, `${kind}.json`);
79
+ const metadata = JSON.parse(await readFile(jsonPath, 'utf8'));
80
+ validateMetadata(metadata, kind, id);
81
+ let source;
82
+ const entryId = 'virtual:mica-content-entry';
83
+ if (kind === 'device') {
84
+ const ddlPath = resolve(base, 'model.ddl');
85
+ const model = await compileDDL(await readFile(ddlPath, 'utf8'), {
86
+ uri: `mica:devices/${id}/model.ddl`,
87
+ });
88
+ const adapter = resolve(base, 'index.ts');
89
+ source = `export const model = ${modelExpression(model)};\n`;
90
+ source += (await exists(adapter))
91
+ ? `export { default as create } from ${JSON.stringify(normalizePath(adapter))};\n`
92
+ : 'export function create() { return { sync() {}, dispose() {} }; }\n';
93
+ }
94
+ else {
95
+ source = `export { default } from ${JSON.stringify(normalizePath(resolve(base, 'index.ts')))};\n`;
96
+ }
97
+ const bundled = await build({
98
+ configFile: false,
99
+ root: options.root,
100
+ publicDir: false,
101
+ logLevel: 'silent',
102
+ resolve: {
103
+ ...(options.aliases === undefined ? {} : { alias: options.aliases }),
104
+ conditions: ['source', 'module', 'browser', 'production'],
105
+ },
106
+ plugins: [
107
+ {
108
+ name: 'mica:content-entry',
109
+ enforce: 'pre',
110
+ resolveId(id) {
111
+ if (isBuiltin(id))
112
+ throw new Error(`Node built-ins are unavailable in content modules: ${id}`);
113
+ if (id === entryId)
114
+ return '\0' + entryId;
115
+ },
116
+ load(id) {
117
+ if (id === '\0' + entryId)
118
+ return source;
119
+ },
120
+ },
121
+ ],
122
+ build: {
123
+ write: false,
124
+ target: 'es2022',
125
+ sourcemap: false,
126
+ minify: options.minify ?? false,
127
+ lib: { entry: entryId, formats: ['es'], fileName: () => `${kind}.js`, cssFileName: kind },
128
+ rolldownOptions: {
129
+ input: entryId,
130
+ output: { codeSplitting: false },
131
+ onwarn(warning, warn) {
132
+ if (warning.code === 'UNRESOLVED_IMPORT')
133
+ throw new Error(warning.message);
134
+ warn(warning);
135
+ },
136
+ },
137
+ },
138
+ });
139
+ if ('close' in bundled)
140
+ throw new Error('Watch builds are not supported inside a content build');
141
+ const outputs = (Array.isArray(bundled) ? bundled : [bundled]).flatMap((result) => result.output);
142
+ const chunk = outputs[0];
143
+ if (outputs.length !== 1 ||
144
+ chunk?.type !== 'chunk' ||
145
+ chunk.imports.length ||
146
+ chunk.dynamicImports.some((name) => name !== chunk.fileName))
147
+ throw new Error(`${category}/${id}: implementation must produce exactly one self-contained JavaScript module (${outputs.map((item) => item.fileName).join(', ')}; imports: ${chunk?.type === 'chunk' ? [...chunk.imports, ...chunk.dynamicImports].join(', ') : 'n/a'})`);
148
+ assertSelfContained(chunk.code, `${category}/${id}`);
149
+ const expectedExports = kind === 'device' ? ['create', 'model'] : ['default'];
150
+ if (expectedExports.some((name) => !chunk.exports.includes(name)))
151
+ throw new Error(`${category}/${id}: missing implementation export`);
152
+ files.push({
153
+ fileName: `${category}/${id}/${kind}.json`,
154
+ source: `${JSON.stringify(metadata, null, 2)}\n`,
155
+ }, { fileName: `${category}/${id}/${kind}.js`, source: chunk.code });
156
+ }
157
+ }
158
+ return files;
159
+ }