@shlomoa/openui-spec 0.1.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.
- package/LICENSE +201 -0
- package/README.md +45 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +159 -0
- package/dist/src/index.d.ts +51 -0
- package/dist/src/index.js +260 -0
- package/package.json +41 -0
- package/spec/openui.json +1008 -0
- package/spec/openui.schema.json +54 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.OpenUiJson = exports.OpenUiValidationError = exports.OpenUiJsonError = void 0;
|
|
7
|
+
const node_fs_1 = require("node:fs");
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const _2020_1 = __importDefault(require("ajv/dist/2020"));
|
|
10
|
+
class OpenUiJsonError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "OpenUiJsonError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.OpenUiJsonError = OpenUiJsonError;
|
|
17
|
+
class OpenUiValidationError extends OpenUiJsonError {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "OpenUiValidationError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
exports.OpenUiValidationError = OpenUiValidationError;
|
|
24
|
+
class OpenUiJson {
|
|
25
|
+
document;
|
|
26
|
+
options;
|
|
27
|
+
constructor(document = null, options = {}) {
|
|
28
|
+
this.document = document;
|
|
29
|
+
this.options = options;
|
|
30
|
+
}
|
|
31
|
+
static load(path, options = {}) {
|
|
32
|
+
let document;
|
|
33
|
+
try {
|
|
34
|
+
document = JSON.parse((0, node_fs_1.readFileSync)(path, "utf8"));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw new OpenUiJsonError(`cannot load JSON document ${path}: ${errorMessage(error)}`);
|
|
38
|
+
}
|
|
39
|
+
if (!isJsonObject(document)) {
|
|
40
|
+
throw new OpenUiJsonError("an OpenUI JSON document must be an object");
|
|
41
|
+
}
|
|
42
|
+
return new OpenUiJson(document, options);
|
|
43
|
+
}
|
|
44
|
+
static parse(content, options = {}) {
|
|
45
|
+
let document;
|
|
46
|
+
try {
|
|
47
|
+
document = JSON.parse(content);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new OpenUiJsonError(`cannot parse JSON document: ${errorMessage(error)}`);
|
|
51
|
+
}
|
|
52
|
+
if (!isJsonObject(document)) {
|
|
53
|
+
throw new OpenUiJsonError("an OpenUI JSON document must be an object");
|
|
54
|
+
}
|
|
55
|
+
return new OpenUiJson(document, options);
|
|
56
|
+
}
|
|
57
|
+
save(filePath) {
|
|
58
|
+
if (this.document === null) {
|
|
59
|
+
throw new OpenUiJsonError("cannot save a removed root object");
|
|
60
|
+
}
|
|
61
|
+
(0, node_fs_1.mkdirSync)(node_path_1.default.dirname(filePath), { recursive: true });
|
|
62
|
+
(0, node_fs_1.writeFileSync)(filePath, this.serialize(), "utf8");
|
|
63
|
+
}
|
|
64
|
+
serialize(indent = 2) {
|
|
65
|
+
if (this.document === null) {
|
|
66
|
+
throw new OpenUiJsonError("cannot serialize a removed root object");
|
|
67
|
+
}
|
|
68
|
+
return escapeNonAscii(JSON.stringify(this.document, null, indent)) + "\n";
|
|
69
|
+
}
|
|
70
|
+
validate() {
|
|
71
|
+
if (this.document === null) {
|
|
72
|
+
throw new OpenUiValidationError("the root object has been removed");
|
|
73
|
+
}
|
|
74
|
+
const schema = this.loadJson(this.options.schema, this.options.schemaPath, "schema");
|
|
75
|
+
const catalog = this.loadJson(this.options.catalog, this.options.catalogPath, "catalog");
|
|
76
|
+
const validator = this.createValidator(schema, OpenUiValidationError);
|
|
77
|
+
if (!validator(this.document)) {
|
|
78
|
+
throw new OpenUiValidationError(formatValidationErrors(validator.errors));
|
|
79
|
+
}
|
|
80
|
+
const supportedTypes = new Set([...this.walk(catalog)].map((node) => node.type));
|
|
81
|
+
const seenIds = new Set();
|
|
82
|
+
for (const node of this.walk(this.document)) {
|
|
83
|
+
if (seenIds.has(node.id)) {
|
|
84
|
+
throw new OpenUiValidationError(`duplicate object id: ${node.id}`);
|
|
85
|
+
}
|
|
86
|
+
seenIds.add(node.id);
|
|
87
|
+
if (!supportedTypes.has(node.type)) {
|
|
88
|
+
throw new OpenUiValidationError(`unsupported object type: ${node.type}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
add(parentId, child) {
|
|
93
|
+
const parent = this.find(parentId);
|
|
94
|
+
if (parent === undefined) {
|
|
95
|
+
throw new OpenUiJsonError(`parent object not found: ${parentId}`);
|
|
96
|
+
}
|
|
97
|
+
const candidate = structuredClone(child);
|
|
98
|
+
this.validateChild(candidate);
|
|
99
|
+
const existingIds = new Set([...this.walk(this.document)].map((node) => node.id));
|
|
100
|
+
const duplicateId = [...this.walk(candidate)].map((node) => node.id).find((id) => existingIds.has(id));
|
|
101
|
+
if (duplicateId !== undefined) {
|
|
102
|
+
throw new OpenUiJsonError(`object id already exists: ${duplicateId}`);
|
|
103
|
+
}
|
|
104
|
+
(parent.children ??= []).push(candidate);
|
|
105
|
+
}
|
|
106
|
+
remove(objectId, options = {}) {
|
|
107
|
+
if (this.document === null) {
|
|
108
|
+
throw new OpenUiJsonError("the root object has already been removed");
|
|
109
|
+
}
|
|
110
|
+
if (this.document.id === objectId) {
|
|
111
|
+
if (options.parentId !== undefined) {
|
|
112
|
+
throw new OpenUiJsonError("the root object has no parent");
|
|
113
|
+
}
|
|
114
|
+
this.document = null;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const parent = this.findParent(objectId);
|
|
118
|
+
if (parent === undefined) {
|
|
119
|
+
throw new OpenUiJsonError(`object not found: ${objectId}`);
|
|
120
|
+
}
|
|
121
|
+
if (options.parentId !== undefined && parent.node.id !== options.parentId) {
|
|
122
|
+
throw new OpenUiJsonError(`object ${objectId} does not belong to parent ${options.parentId}`);
|
|
123
|
+
}
|
|
124
|
+
parent.node.children?.splice(parent.index, 1);
|
|
125
|
+
}
|
|
126
|
+
replace(objectId, replacement, options = {}) {
|
|
127
|
+
const candidate = structuredClone(replacement);
|
|
128
|
+
this.validateChild(candidate);
|
|
129
|
+
if (candidate.id !== objectId) {
|
|
130
|
+
throw new OpenUiJsonError("replacement object id must match the replaced object id");
|
|
131
|
+
}
|
|
132
|
+
if (this.document !== null && this.document.id === objectId) {
|
|
133
|
+
if (options.parentId !== undefined) {
|
|
134
|
+
throw new OpenUiJsonError("the root object has no parent");
|
|
135
|
+
}
|
|
136
|
+
this.document = candidate;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const parent = this.findParent(objectId);
|
|
140
|
+
if (parent === undefined) {
|
|
141
|
+
throw new OpenUiJsonError(`object not found: ${objectId}`);
|
|
142
|
+
}
|
|
143
|
+
if (options.parentId !== undefined && parent.node.id !== options.parentId) {
|
|
144
|
+
throw new OpenUiJsonError(`object ${objectId} does not belong to parent ${options.parentId}`);
|
|
145
|
+
}
|
|
146
|
+
parent.node.children[parent.index] = candidate;
|
|
147
|
+
}
|
|
148
|
+
updateAttributes(objectId, attributes) {
|
|
149
|
+
const node = this.find(objectId);
|
|
150
|
+
if (node === undefined) {
|
|
151
|
+
throw new OpenUiJsonError(`object not found: ${objectId}`);
|
|
152
|
+
}
|
|
153
|
+
if (!Object.entries(attributes).every(([key, value]) => typeof key === "string" && (value === null || typeof value === "string"))) {
|
|
154
|
+
throw new OpenUiJsonError("attribute changes must map strings to strings or null");
|
|
155
|
+
}
|
|
156
|
+
const updated = structuredClone(node);
|
|
157
|
+
if (!Object.hasOwn(updated, "attrs")) {
|
|
158
|
+
updated.attrs = {};
|
|
159
|
+
}
|
|
160
|
+
Object.assign(updated.attrs, attributes);
|
|
161
|
+
this.validateNode(updated, node === this.document);
|
|
162
|
+
node.attrs = updated.attrs;
|
|
163
|
+
}
|
|
164
|
+
validateChild(child) {
|
|
165
|
+
this.validateNode(child, false);
|
|
166
|
+
const catalog = this.loadJson(this.options.catalog, this.options.catalogPath, "catalog");
|
|
167
|
+
const supportedTypes = new Set([...this.walk(catalog)].map((node) => node.type));
|
|
168
|
+
const seenIds = new Set();
|
|
169
|
+
for (const node of this.walk(child)) {
|
|
170
|
+
if (seenIds.has(node.id)) {
|
|
171
|
+
throw new OpenUiJsonError(`duplicate object id: ${node.id}`);
|
|
172
|
+
}
|
|
173
|
+
seenIds.add(node.id);
|
|
174
|
+
if (!supportedTypes.has(node.type)) {
|
|
175
|
+
throw new OpenUiJsonError(`unsupported object type: ${node.type}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
validateNode(node, isRoot) {
|
|
180
|
+
const schema = this.loadJson(this.options.schema, this.options.schemaPath, "schema");
|
|
181
|
+
const definition = isRoot ? schema : { $ref: "#/$defs/element", $defs: schema.$defs };
|
|
182
|
+
const validator = this.createValidator(definition, OpenUiJsonError);
|
|
183
|
+
if (!validator(node)) {
|
|
184
|
+
throw new OpenUiJsonError(formatValidationErrors(validator.errors));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
find(objectId) {
|
|
188
|
+
if (this.document === null) {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
return [...this.walk(this.document)].find((node) => node.id === objectId);
|
|
192
|
+
}
|
|
193
|
+
findParent(objectId) {
|
|
194
|
+
if (this.document === null) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
for (const node of this.walk(this.document)) {
|
|
198
|
+
const index = node.children?.findIndex((child) => child.id === objectId) ?? -1;
|
|
199
|
+
if (index >= 0) {
|
|
200
|
+
return { node, index };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
*walk(node) {
|
|
206
|
+
yield node;
|
|
207
|
+
for (const child of node.children ?? []) {
|
|
208
|
+
yield* this.walk(child);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
loadJson(value, filePath, name) {
|
|
212
|
+
if (value !== undefined) {
|
|
213
|
+
if (!isJsonObject(value)) {
|
|
214
|
+
throw new OpenUiValidationError(`OpenUI ${name} must be an object`);
|
|
215
|
+
}
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
218
|
+
const resolvedPath = filePath ?? node_path_1.default.resolve(__dirname, "..", "..", "spec", `openui${name === "schema" ? ".schema" : ""}.json`);
|
|
219
|
+
try {
|
|
220
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(resolvedPath, "utf8"));
|
|
221
|
+
if (!isJsonObject(parsed)) {
|
|
222
|
+
throw new OpenUiValidationError(`OpenUI ${name} must be an object`);
|
|
223
|
+
}
|
|
224
|
+
return parsed;
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error instanceof OpenUiValidationError) {
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
throw new OpenUiValidationError(`cannot load ${name} ${resolvedPath}: ${errorMessage(error)}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
createValidator(schema, ErrorType) {
|
|
234
|
+
try {
|
|
235
|
+
return new _2020_1.default({ allErrors: true, strict: false }).compile(schema);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
throw new ErrorType(`invalid OpenUI schema: ${errorMessage(error)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
exports.OpenUiJson = OpenUiJson;
|
|
243
|
+
function isJsonObject(value) {
|
|
244
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
245
|
+
}
|
|
246
|
+
function errorMessage(error) {
|
|
247
|
+
return error instanceof Error ? error.message : String(error);
|
|
248
|
+
}
|
|
249
|
+
function formatValidationErrors(errors) {
|
|
250
|
+
return [...(errors ?? [])]
|
|
251
|
+
.sort((left, right) => left.instancePath.localeCompare(right.instancePath))
|
|
252
|
+
.map((error) => `${jsonPath(error.instancePath)}: ${error.message}`)
|
|
253
|
+
.join("\n");
|
|
254
|
+
}
|
|
255
|
+
function jsonPath(instancePath) {
|
|
256
|
+
return instancePath === "" ? "$" : `$.${instancePath.split("/").filter(Boolean).join(".")}`;
|
|
257
|
+
}
|
|
258
|
+
function escapeNonAscii(value) {
|
|
259
|
+
return value.replace(/[\u007f-\uffff]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
260
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shlomoa/openui-spec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Load, validate, and edit OpenUI JSON documents.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"main": "./dist/src/index.js",
|
|
7
|
+
"types": "./dist/src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/src/index.d.ts",
|
|
11
|
+
"import": "./dist/src/index.js",
|
|
12
|
+
"require": "./dist/src/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"ng-openui-spec": "./dist/src/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist/src",
|
|
20
|
+
"spec/openui.json",
|
|
21
|
+
"spec/openui.schema.json"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json",
|
|
25
|
+
"test": "npm run build && node --test dist/tests/*.test.js"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/shlomoa/openui-spec.git"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"ajv": "8.20.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "26.4.0",
|
|
39
|
+
"typescript": "7.0.2"
|
|
40
|
+
}
|
|
41
|
+
}
|