@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
@@ -0,0 +1,283 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js';
2
+ import { deviceMetadataSchema, widgetMetadataSchema } from './schema.js';
3
+ /** Thrown by {@link validateMetadata} when a metadata document is rejected. */
4
+ export class MetadataValidationError extends Error {
5
+ kind;
6
+ issues;
7
+ metadataId;
8
+ constructor(kind, issues, metadataId) {
9
+ super(formatMessage(kind, issues, metadataId));
10
+ this.name = 'MetadataValidationError';
11
+ this.kind = kind;
12
+ this.issues = issues;
13
+ this.metadataId = metadataId;
14
+ }
15
+ }
16
+ const ajv = new Ajv2020({
17
+ allErrors: true,
18
+ strict: true,
19
+ allowUnionTypes: true,
20
+ validateFormats: false,
21
+ });
22
+ const validators = {
23
+ device: ajv.compile(deviceMetadataSchema),
24
+ widget: ajv.compile(widgetMetadataSchema),
25
+ };
26
+ /** Keywords whose failure only restates a problem reported by their subschemas. */
27
+ const containerKeywords = new Set(['if', 'allOf', 'anyOf', 'oneOf']);
28
+ /**
29
+ * Validates one device or widget metadata document.
30
+ *
31
+ * @param input Untrusted JSON value of the metadata file.
32
+ * @param kind Metadata kind that selects the contract.
33
+ * @param expectedId Id the caller expects; a mismatch is reported at `id`.
34
+ * @throws {MetadataValidationError} with the path of every rejected value.
35
+ */
36
+ export function validateMetadata(input, kind, expectedId) {
37
+ if (kind !== 'device' && kind !== 'widget') {
38
+ throw new TypeError(`Unknown metadata kind "${String(kind)}"; expected "device" or "widget".`);
39
+ }
40
+ const metadataId = isRecord(input) && typeof input.id === 'string' ? input.id : undefined;
41
+ const fatal = findNonFiniteNumbers(input);
42
+ if (fatal.length > 0)
43
+ throw new MetadataValidationError(kind, fatal, metadataId);
44
+ const validate = validators[kind];
45
+ const issues = [];
46
+ if (!validate(input))
47
+ issues.push(...toIssues(validate.errors ?? [], input));
48
+ issues.push(...semanticIssues(input));
49
+ if (expectedId !== undefined && metadataId !== undefined && metadataId !== expectedId) {
50
+ issues.push({
51
+ path: 'id',
52
+ keyword: 'expectedId',
53
+ message: `must equal the expected metadata id ${quote(expectedId)}`,
54
+ });
55
+ }
56
+ if (issues.length === 0)
57
+ return;
58
+ throw new MetadataValidationError(kind, dedupeAndSort(issues), metadataId);
59
+ }
60
+ function toIssues(errors, input) {
61
+ const issues = [];
62
+ for (const error of errors) {
63
+ if (containerKeywords.has(error.keyword))
64
+ continue;
65
+ const params = error.params;
66
+ const basePath = pointerToPath(error.instancePath);
67
+ let path = basePath;
68
+ if (error.keyword === 'required')
69
+ path = joinPath(basePath, String(params.missingProperty));
70
+ else if (error.keyword === 'additionalProperties') {
71
+ path = joinPath(basePath, String(params.additionalProperty));
72
+ }
73
+ issues.push({ path, message: describeError(error, input), keyword: error.keyword });
74
+ }
75
+ if (issues.length > 0 || errors.length === 0)
76
+ return issues;
77
+ return errors.map((error) => ({
78
+ path: pointerToPath(error.instancePath),
79
+ message: error.message ?? 'is invalid',
80
+ keyword: error.keyword,
81
+ }));
82
+ }
83
+ function describeError(error, input) {
84
+ const params = error.params;
85
+ switch (error.keyword) {
86
+ case 'required': {
87
+ const missing = String(params.missingProperty);
88
+ const owner = resolvePointer(input, error.instancePath);
89
+ if (missing === 'nullable' && isRecord(owner) && owner.default === null) {
90
+ return 'must be true when the default value is null';
91
+ }
92
+ return `missing required field ${quote(missing)}`;
93
+ }
94
+ case 'additionalProperties':
95
+ return `unknown field ${quote(String(params.additionalProperty))}`;
96
+ case 'enum': {
97
+ const allowed = Array.isArray(params.allowedValues) ? params.allowedValues : [];
98
+ return `must be one of ${allowed.map((value) => quote(String(value))).join(', ')}`;
99
+ }
100
+ case 'const': {
101
+ const { parent, key } = resolveParent(input, error.instancePath);
102
+ if (key === 'nullable' && isRecord(parent) && parent.default === null) {
103
+ return 'must be true when the default value is null';
104
+ }
105
+ return `must be ${quote(String(params.allowedValue))}`;
106
+ }
107
+ case 'type':
108
+ return `must be ${describeTypes(params.type)}`;
109
+ case 'pattern':
110
+ return `must match the pattern ${String(params.pattern)}`;
111
+ case 'minLength':
112
+ return params.limit === 1
113
+ ? 'must not be empty'
114
+ : `must have at least ${describeLimit(params)} characters`;
115
+ case 'maxLength':
116
+ return `must have at most ${describeLimit(params)} characters`;
117
+ case 'minimum':
118
+ return `must be greater than or equal to ${describeLimit(params)}`;
119
+ case 'maximum':
120
+ return `must be less than or equal to ${describeLimit(params)}`;
121
+ case 'exclusiveMinimum':
122
+ return `must be greater than ${describeLimit(params)}`;
123
+ case 'exclusiveMaximum':
124
+ return `must be less than ${describeLimit(params)}`;
125
+ case 'uniqueItems':
126
+ return 'must not contain duplicate values';
127
+ case 'minItems':
128
+ return `must contain at least ${describeLimit(params)} items`;
129
+ case 'maxItems':
130
+ return `must contain at most ${describeLimit(params)} items`;
131
+ case 'false schema':
132
+ return describeForbiddenField(input, error.instancePath);
133
+ case 'not':
134
+ return `must not be ${formatValue(resolvePointer(input, error.instancePath))}`;
135
+ default:
136
+ return error.message ?? 'is invalid';
137
+ }
138
+ }
139
+ function describeForbiddenField(input, pointer) {
140
+ const { parent, key } = resolveParent(input, pointer);
141
+ if (key === 'default' && isRecord(parent)) {
142
+ if (parent.type === 'bytes') {
143
+ return 'must not be declared: bytes properties cannot declare defaults';
144
+ }
145
+ if (parent.required === true)
146
+ return 'must not be declared when "required" is true';
147
+ }
148
+ if ((key === 'enumName' || key === 'values') && isRecord(parent) && parent.type !== 'enum') {
149
+ return 'must not be declared unless "type" is "enum"';
150
+ }
151
+ return 'must not be declared';
152
+ }
153
+ function describeTypes(type) {
154
+ const names = (Array.isArray(type) ? type : [type]).map((name) => String(name));
155
+ if (names.length <= 1)
156
+ return names[0] ?? 'of the expected type';
157
+ return `${names.slice(0, -1).join(', ')} or ${names[names.length - 1]}`;
158
+ }
159
+ function formatValue(value) {
160
+ if (value === undefined)
161
+ return 'the forbidden value';
162
+ return JSON.stringify(value) ?? String(value);
163
+ }
164
+ function describeLimit(params) {
165
+ return JSON.stringify(params.limit);
166
+ }
167
+ /** Checks that JSON Schema cannot express without duplicating every enum. */
168
+ function semanticIssues(input) {
169
+ if (!isRecord(input))
170
+ return [];
171
+ const issues = [];
172
+ const properties = input.properties;
173
+ if (!isRecord(properties))
174
+ return issues;
175
+ for (const [key, entry] of Object.entries(properties)) {
176
+ if (!isRecord(entry))
177
+ continue;
178
+ const path = `properties.${key}`;
179
+ if (entry.type === 'enum' && Array.isArray(entry.values) && 'default' in entry) {
180
+ const value = entry.default;
181
+ const values = entry.values.filter((item) => typeof item === 'string');
182
+ if (typeof value === 'string' && !values.includes(value)) {
183
+ issues.push({
184
+ path: `${path}.default`,
185
+ keyword: 'default',
186
+ message: `must be one of the declared "values" (${values.map(quote).join(', ')})`,
187
+ });
188
+ }
189
+ }
190
+ const editor = entry.editor;
191
+ if (isRecord(editor) && editor.kind === 'number') {
192
+ const { min, max } = editor;
193
+ if (typeof min === 'number' && typeof max === 'number' && min > max) {
194
+ issues.push({
195
+ path: `${path}.editor`,
196
+ keyword: 'editor',
197
+ message: '"min" must be less than or equal to "max"',
198
+ });
199
+ }
200
+ }
201
+ }
202
+ return issues;
203
+ }
204
+ /** NaN and Infinity are not JSON numbers, so they are rejected before schema validation. */
205
+ function findNonFiniteNumbers(value, path = '', found = []) {
206
+ if (typeof value === 'number') {
207
+ if (!Number.isFinite(value)) {
208
+ found.push({ path, keyword: 'number', message: 'must be a finite JSON number' });
209
+ }
210
+ return found;
211
+ }
212
+ if (Array.isArray(value)) {
213
+ value.forEach((item, index) => findNonFiniteNumbers(item, joinPath(path, String(index)), found));
214
+ return found;
215
+ }
216
+ if (isRecord(value)) {
217
+ for (const [key, item] of Object.entries(value)) {
218
+ findNonFiniteNumbers(item, joinPath(path, key), found);
219
+ }
220
+ }
221
+ return found;
222
+ }
223
+ function dedupeAndSort(issues) {
224
+ const unique = new Map();
225
+ for (const issue of issues) {
226
+ const key = `${issue.path}\u0000${issue.message}`;
227
+ if (!unique.has(key))
228
+ unique.set(key, issue);
229
+ }
230
+ return [...unique.values()].sort((left, right) => left.path === right.path
231
+ ? left.message.localeCompare(right.message)
232
+ : left.path.localeCompare(right.path));
233
+ }
234
+ function formatMessage(kind, issues, metadataId) {
235
+ const subject = metadataId === undefined ? `${kind} metadata` : `${kind} metadata ${quote(metadataId)}`;
236
+ const count = `${issues.length} ${issues.length === 1 ? 'problem' : 'problems'}`;
237
+ const lines = issues.map((issue) => ` - ${issue.path === '' ? '<root>' : issue.path}: ${issue.message}`);
238
+ return [`Invalid ${subject} (${count}):`, ...lines].join('\n');
239
+ }
240
+ function pointerToPath(pointer) {
241
+ if (pointer === '')
242
+ return '';
243
+ return pointer.slice(1).split('/').map(decodeSegment).join('.');
244
+ }
245
+ function decodeSegment(segment) {
246
+ return segment.replace(/~1/g, '/').replace(/~0/g, '~');
247
+ }
248
+ function joinPath(base, key) {
249
+ return base === '' ? key : `${base}.${key}`;
250
+ }
251
+ function resolvePointer(input, pointer) {
252
+ if (pointer === '')
253
+ return input;
254
+ let current = input;
255
+ for (const segment of pointer.slice(1).split('/').map(decodeSegment)) {
256
+ if (Array.isArray(current)) {
257
+ const index = Number(segment);
258
+ current = Number.isInteger(index) ? current[index] : undefined;
259
+ }
260
+ else if (isRecord(current)) {
261
+ current = current[segment];
262
+ }
263
+ else {
264
+ return undefined;
265
+ }
266
+ }
267
+ return current;
268
+ }
269
+ function resolveParent(input, pointer) {
270
+ const index = pointer.lastIndexOf('/');
271
+ if (index < 0)
272
+ return { parent: input, key: '' };
273
+ return {
274
+ parent: resolvePointer(input, pointer.slice(0, index)),
275
+ key: decodeSegment(pointer.slice(index + 1)),
276
+ };
277
+ }
278
+ function isRecord(value) {
279
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
280
+ }
281
+ function quote(value) {
282
+ return JSON.stringify(value);
283
+ }
@@ -0,0 +1 @@
1
+ export * from '@mica-js/device-model';
package/dist/model.js ADDED
@@ -0,0 +1 @@
1
+ export * from '@mica-js/device-model';
@@ -0,0 +1 @@
1
+ export type { WidgetFactory, WidgetImplementation, WidgetInstanceApi, WidgetInputEvent, WidgetDeliveryMeta, WidgetPortListener, WidgetAssetApi, WidgetAssetRef, WidgetAudioApi, WidgetAudioTone, WidgetAudioToneOptions, WidgetAudioStream, WidgetAudioStreamOptions, WidgetAudioVoice, WidgetAudioVoiceSettings, WidgetAudioFormat, WidgetAudioState, WidgetAudioEnded, WidgetAudioEndReason, WidgetAudioRampOptions, WidgetAudioStopOptions, WidgetAudioStreamState, WidgetAudioStreamStatus, WidgetOscillatorType, Disposable, Event, } from '@mica-js/widget/author';
package/dist/widget.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "@mica-js/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Device and widget author APIs with CLI-managed content builds",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "bin": {
9
+ "mica": "./dist/build/cli.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "templates"
14
+ ],
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ },
20
+ "./device": {
21
+ "types": "./dist/device.d.ts",
22
+ "import": "./dist/device.js"
23
+ },
24
+ "./widget": {
25
+ "types": "./dist/widget.d.ts",
26
+ "import": "./dist/widget.js"
27
+ },
28
+ "./model": {
29
+ "types": "./dist/model.d.ts",
30
+ "import": "./dist/model.js"
31
+ },
32
+ "./ddl": {
33
+ "types": "./dist/ddl.d.ts",
34
+ "import": "./dist/ddl.js"
35
+ },
36
+ "./metadata": {
37
+ "types": "./dist/metadata/index.d.ts",
38
+ "import": "./dist/metadata/index.js"
39
+ },
40
+ "./config": {
41
+ "types": "./dist/build/config.d.ts",
42
+ "import": "./dist/build/config.js"
43
+ },
44
+ "./prepare": {
45
+ "types": "./dist/build/prepare.d.ts",
46
+ "import": "./dist/build/prepare.js"
47
+ }
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "dependencies": {
53
+ "@mica-js/core": "0.6.0",
54
+ "@mica-js/ddl": "0.2.0",
55
+ "@mica-js/device-model": "0.5.0",
56
+ "@mica-js/simulator": "2.1.0",
57
+ "@mica-js/widget": "0.3.0",
58
+ "ajv": "^8.17.1",
59
+ "rolldown": "^1.2.14",
60
+ "vite": "^8.3.4"
61
+ },
62
+ "devDependencies": {
63
+ "@types/node": "^24.19.2",
64
+ "typescript": "^6.0.3",
65
+ "vitest": "^5.0.5"
66
+ },
67
+ "scripts": {
68
+ "build": "tsc -p tsconfig.build.json",
69
+ "typecheck": "tsc --noEmit",
70
+ "test": "vitest run"
71
+ }
6
72
  }
@@ -0,0 +1,3 @@
1
+ import { defineConfig } from '@mica-js/sdk/config';
2
+
3
+ export default defineConfig({});
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "mica-components",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "prepare": "mica prepare",
7
+ "build": "mica build",
8
+ "typecheck": "mica prepare && tsc --noEmit"
9
+ },
10
+ "devDependencies": {
11
+ "@mica-js/sdk": "^0.1.0",
12
+ "typescript": "^6.0.3"
13
+ }
14
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "kind": "device",
3
+ "id": "resistor",
4
+ "version": "1.0.0",
5
+ "displayName": "Resistor",
6
+ "designatorPrefix": "R",
7
+ "properties": {
8
+ "resistance": {
9
+ "type": "number",
10
+ "required": false,
11
+ "default": 10000,
12
+ "unit": "ohm",
13
+ "editor": { "kind": "number", "min": 0.001 }
14
+ }
15
+ },
16
+ "pins": { "a": { "label": "A" }, "b": { "label": "B" } },
17
+ "symbol": {
18
+ "bounds": { "x": -40, "y": -20, "width": 80, "height": 40 },
19
+ "graphics": [
20
+ { "type": "line", "from": [-40, 0], "to": [-24, 0] },
21
+ { "type": "rect", "x": -24, "y": -10, "width": 48, "height": 20, "fill": "background" },
22
+ { "type": "line", "from": [24, 0], "to": [40, 0] }
23
+ ],
24
+ "pins": {
25
+ "a": { "position": [-40, 0], "direction": "left", "label": { "visible": false } },
26
+ "b": { "position": [40, 0], "direction": "right", "label": { "visible": false } }
27
+ },
28
+ "annotations": { "designator": { "position": [0, -16], "align": "center" } }
29
+ }
30
+ }
@@ -0,0 +1,8 @@
1
+ import type { Factory } from './model.ddl';
2
+
3
+ const create: Factory = () => ({
4
+ sync() {},
5
+ dispose() {},
6
+ });
7
+
8
+ export default create;
@@ -0,0 +1,6 @@
1
+ device Resistor(resistance: number default 10000) {
2
+ assert resistance >= 0.001 && resistance <= 1_000_000_000,
3
+ "resistance must be within the supported range";
4
+ pin a = [resistor(to: b, resistance: resistance)];
5
+ pin b;
6
+ }
@@ -0,0 +1,22 @@
1
+ import type { WidgetFactory } from '@mica-js/sdk/widget';
2
+
3
+ const create: WidgetFactory = (api) => {
4
+ let brightness = 0;
5
+ const unsubscribe = api.subscribe('brightness', (value) => {
6
+ if (typeof value !== 'number' || !Number.isFinite(value)) return;
7
+ brightness = Math.max(0, Math.min(1, value));
8
+ api.invalidate();
9
+ });
10
+ return {
11
+ render(ctx) {
12
+ ctx.fillStyle = '#22aa55';
13
+ ctx.globalAlpha = brightness;
14
+ ctx.fillRect(0, 0, api.width, api.height);
15
+ },
16
+ dispose() {
17
+ unsubscribe();
18
+ },
19
+ };
20
+ };
21
+
22
+ export default create;
@@ -0,0 +1,9 @@
1
+ {
2
+ "kind": "widget",
3
+ "id": "lamp",
4
+ "version": "1.0.0",
5
+ "displayName": "Lamp",
6
+ "ports": {
7
+ "brightness": { "direction": "input", "type": "number", "delivery": "level" }
8
+ }
9
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2023", "DOM"],
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true,
10
+ "allowArbitraryExtensions": true,
11
+ "verbatimModuleSyntax": true
12
+ },
13
+ "include": ["src"]
14
+ }