@acid-cat/catalog 0.1.0 → 0.3.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/dist/canonical.js +84 -0
- package/dist/index.js +120 -200
- package/package.json +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
//#region src/canonical.ts
|
|
2
|
+
const CATALOG_ORDER = [
|
|
3
|
+
"acidcat",
|
|
4
|
+
"title",
|
|
5
|
+
"description",
|
|
6
|
+
"targets",
|
|
7
|
+
"$defs",
|
|
8
|
+
"capabilities"
|
|
9
|
+
];
|
|
10
|
+
const TARGET_ORDER = [
|
|
11
|
+
"bindings",
|
|
12
|
+
"description",
|
|
13
|
+
"options"
|
|
14
|
+
];
|
|
15
|
+
const CAPABILITY_ORDER = [
|
|
16
|
+
"description",
|
|
17
|
+
"audience",
|
|
18
|
+
"input",
|
|
19
|
+
"output",
|
|
20
|
+
"failures",
|
|
21
|
+
"readOnly",
|
|
22
|
+
"state"
|
|
23
|
+
];
|
|
24
|
+
/** A catalog in canonical form, as text. */
|
|
25
|
+
function canonicalText(catalog) {
|
|
26
|
+
return `${write(canonicalCatalog(catalog), "")}\n`;
|
|
27
|
+
}
|
|
28
|
+
/** Put a catalog's members in canonical order. */
|
|
29
|
+
function canonicalCatalog(catalog) {
|
|
30
|
+
return ordered(catalog, CATALOG_ORDER, (key, value) => {
|
|
31
|
+
if (key === "targets") return byKey(value, (target) => ordered(target, TARGET_ORDER, sortedMember));
|
|
32
|
+
if (key === "$defs") return byKey(value, sorted);
|
|
33
|
+
if (key === "capabilities") return byKey(value, (capability) => ordered(capability, CAPABILITY_ORDER, (member, inner) => member === "failures" ? byKey(inner, sorted) : sortedMember(member, inner)));
|
|
34
|
+
return sortedMember(key, value);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/** The known members in their order, then every other member sorted by key. */
|
|
38
|
+
function ordered(value, order, each) {
|
|
39
|
+
const known = order.filter((key) => key in value);
|
|
40
|
+
const others = Object.keys(value).filter((key) => !order.includes(key)).sort(byCodePoint);
|
|
41
|
+
return Object.fromEntries([...known, ...others].map((key) => [key, each(key, value[key])]));
|
|
42
|
+
}
|
|
43
|
+
/** A map keyed by name or id: its keys sorted, each value shaped by `each`. */
|
|
44
|
+
function byKey(value, each) {
|
|
45
|
+
const object = value;
|
|
46
|
+
return Object.fromEntries(Object.keys(object).sort(byCodePoint).map((key) => [key, each(object[key])]));
|
|
47
|
+
}
|
|
48
|
+
function sortedMember(_key, value) {
|
|
49
|
+
return sorted(value);
|
|
50
|
+
}
|
|
51
|
+
/** Every object's keys sorted, at every depth. Arrays keep their order. */
|
|
52
|
+
function sorted(value) {
|
|
53
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
54
|
+
if (value !== null && typeof value === "object") return byKey(value, sorted);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
/** Order strings by Unicode code point, as Python, Go and Rust compare them. */
|
|
58
|
+
function byCodePoint(left, right) {
|
|
59
|
+
const a = [...left];
|
|
60
|
+
const b = [...right];
|
|
61
|
+
for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
|
|
62
|
+
const difference = a[i].codePointAt(0) - b[i].codePointAt(0);
|
|
63
|
+
if (difference !== 0) return difference;
|
|
64
|
+
}
|
|
65
|
+
return a.length - b.length;
|
|
66
|
+
}
|
|
67
|
+
/** Write a JSON value with two-space indentation, one member or item per line. */
|
|
68
|
+
function write(value, indent) {
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
if (value.length === 0) return "[]";
|
|
71
|
+
const inner = `${indent} `;
|
|
72
|
+
return `[\n${value.map((item) => inner + write(item, inner)).join(",\n")}\n${indent}]`;
|
|
73
|
+
}
|
|
74
|
+
if (value !== null && typeof value === "object") {
|
|
75
|
+
const keys = Object.keys(value);
|
|
76
|
+
if (keys.length === 0) return "{}";
|
|
77
|
+
const inner = `${indent} `;
|
|
78
|
+
return `{\n${keys.map((key) => `${inner}${JSON.stringify(key)}: ${write(value[key], inner)}`).join(",\n")}\n${indent}}`;
|
|
79
|
+
}
|
|
80
|
+
if (typeof value === "number" && !Number.isFinite(value)) throw new TypeError("A catalog holds only finite numbers");
|
|
81
|
+
return JSON.stringify(value);
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { byCodePoint, canonicalCatalog, canonicalText };
|
package/dist/index.js
CHANGED
|
@@ -1,206 +1,126 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (key === "capabilities") {
|
|
14
|
-
return byKey(
|
|
15
|
-
value,
|
|
16
|
-
(capability) => ordered(
|
|
17
|
-
capability,
|
|
18
|
-
CAPABILITY_ORDER,
|
|
19
|
-
(member, inner) => member === "failures" ? byKey(inner, sorted) : sortedMember(member, inner)
|
|
20
|
-
)
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
return sortedMember(key, value);
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
function ordered(value, order, each) {
|
|
27
|
-
const known = order.filter((key) => key in value);
|
|
28
|
-
const others = Object.keys(value).filter((key) => !order.includes(key)).sort(byCodePoint);
|
|
29
|
-
return Object.fromEntries([...known, ...others].map((key) => [key, each(key, value[key])]));
|
|
30
|
-
}
|
|
31
|
-
function byKey(value, each) {
|
|
32
|
-
const object = value;
|
|
33
|
-
return Object.fromEntries(
|
|
34
|
-
Object.keys(object).sort(byCodePoint).map((key) => [key, each(object[key])])
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
function sortedMember(_key, value) {
|
|
38
|
-
return sorted(value);
|
|
39
|
-
}
|
|
40
|
-
function sorted(value) {
|
|
41
|
-
if (Array.isArray(value)) return value.map(sorted);
|
|
42
|
-
if (value !== null && typeof value === "object") return byKey(value, sorted);
|
|
43
|
-
return value;
|
|
44
|
-
}
|
|
45
|
-
function byCodePoint(left, right) {
|
|
46
|
-
const a = [...left];
|
|
47
|
-
const b = [...right];
|
|
48
|
-
for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
|
|
49
|
-
const difference = a[i].codePointAt(0) - b[i].codePointAt(0);
|
|
50
|
-
if (difference !== 0) return difference;
|
|
51
|
-
}
|
|
52
|
-
return a.length - b.length;
|
|
53
|
-
}
|
|
54
|
-
function write(value, indent) {
|
|
55
|
-
if (Array.isArray(value)) {
|
|
56
|
-
if (value.length === 0) return "[]";
|
|
57
|
-
const inner = `${indent} `;
|
|
58
|
-
return `[
|
|
59
|
-
${value.map((item) => inner + write(item, inner)).join(",\n")}
|
|
60
|
-
${indent}]`;
|
|
61
|
-
}
|
|
62
|
-
if (value !== null && typeof value === "object") {
|
|
63
|
-
const keys = Object.keys(value);
|
|
64
|
-
if (keys.length === 0) return "{}";
|
|
65
|
-
const inner = `${indent} `;
|
|
66
|
-
const members = keys.map((key) => `${inner}${JSON.stringify(key)}: ${write(value[key], inner)}`);
|
|
67
|
-
return `{
|
|
68
|
-
${members.join(",\n")}
|
|
69
|
-
${indent}}`;
|
|
70
|
-
}
|
|
71
|
-
if (typeof value === "number" && !Number.isFinite(value)) throw new TypeError("A catalog holds only finite numbers");
|
|
72
|
-
return JSON.stringify(value);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// src/index.ts
|
|
76
|
-
var ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*(\.[a-z][a-z0-9]*(-[a-z0-9]+)*)+$/;
|
|
77
|
-
var NAME = /^[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)?$/;
|
|
78
|
-
var TYPE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
79
|
-
var REFERENCE = /^#\/\$defs\/([A-Za-z][A-Za-z0-9_]*)$/;
|
|
1
|
+
import { canonicalText } from "./canonical.js";
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* Write an AcidCat catalog in TypeScript, and render it to the JSON document the toolchain checks.
|
|
5
|
+
*
|
|
6
|
+
* This is an authoring library, not a runtime: a program uses it to produce `acidcat.catalog.json`, and nothing
|
|
7
|
+
* reads the catalog through it at runtime. See spec/catalog.md.
|
|
8
|
+
*/
|
|
9
|
+
const ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*(\.[a-z][a-z0-9]*(-[a-z0-9]+)*)+$/;
|
|
10
|
+
const NAME = /^[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)?$/;
|
|
11
|
+
const TYPE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
12
|
+
const REFERENCE = /^#\/\$defs\/([A-Za-z][A-Za-z0-9_]*)$/;
|
|
80
13
|
var Catalog = class {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
#referenceProblems(schema) {
|
|
182
|
-
const problems = [];
|
|
183
|
-
visit(schema, (node) => {
|
|
184
|
-
if ("$defs" in node) problems.push("carries its own $defs; declare shared types with type()");
|
|
185
|
-
if (node.$ref === void 0) return;
|
|
186
|
-
const name = typeof node.$ref === "string" ? REFERENCE.exec(node.$ref)?.[1] : void 0;
|
|
187
|
-
if (name === void 0 || !this.#types.has(name)) problems.push(`references ${String(node.$ref)}, which is not a type`);
|
|
188
|
-
});
|
|
189
|
-
return problems;
|
|
190
|
-
}
|
|
14
|
+
#title;
|
|
15
|
+
#description;
|
|
16
|
+
#targets = /* @__PURE__ */ new Map();
|
|
17
|
+
#types = /* @__PURE__ */ new Map();
|
|
18
|
+
#capabilities = /* @__PURE__ */ new Map();
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.#title = options.title;
|
|
21
|
+
this.#description = options.description;
|
|
22
|
+
}
|
|
23
|
+
/** Declare a target. */
|
|
24
|
+
target(name, options) {
|
|
25
|
+
if (this.#targets.has(name)) throw new Error(`The target ${name} is declared twice`);
|
|
26
|
+
this.#targets.set(name, options);
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
/** Declare a shared type, and get the schema that references it. */
|
|
30
|
+
type(name, schema) {
|
|
31
|
+
if (this.#types.has(name)) throw new Error(`The type ${name} is declared twice`);
|
|
32
|
+
this.#types.set(name, schema);
|
|
33
|
+
return { $ref: `#/$defs/${name}` };
|
|
34
|
+
}
|
|
35
|
+
/** Declare a capability. */
|
|
36
|
+
capability(id, options) {
|
|
37
|
+
if (this.#capabilities.has(id)) throw new Error(`The capability ${id} is declared twice`);
|
|
38
|
+
this.#capabilities.set(id, options);
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
/** The catalog rules this catalog breaks. Empty when it conforms. */
|
|
42
|
+
validate() {
|
|
43
|
+
const problems = [];
|
|
44
|
+
for (const [name, target] of this.#targets) {
|
|
45
|
+
if (!NAME.test(name)) problems.push(`the target name ${name} is malformed`);
|
|
46
|
+
if (target.bindings.length === 0) problems.push(`the target ${name} has no bindings`);
|
|
47
|
+
if (new Set(target.bindings).size !== target.bindings.length) problems.push(`the target ${name} repeats a binding`);
|
|
48
|
+
for (const binding of target.bindings) if (!NAME.test(binding)) problems.push(`the target ${name} names the malformed binding ${binding}`);
|
|
49
|
+
}
|
|
50
|
+
for (const [name, schema] of this.#types) {
|
|
51
|
+
if (!TYPE_NAME.test(name)) problems.push(`the type name ${name} is malformed`);
|
|
52
|
+
problems.push(...this.#referenceProblems(schema).map((problem) => `the type ${name} ${problem}`));
|
|
53
|
+
}
|
|
54
|
+
for (const [id, capability] of this.#capabilities) {
|
|
55
|
+
if (id.length > 128 || !ID.test(id)) problems.push(`the id ${id} is malformed`);
|
|
56
|
+
if (capability.description.trim() === "") problems.push(`${id} has no description`);
|
|
57
|
+
const audience = capability.audience ?? [];
|
|
58
|
+
if (new Set(audience).size !== audience.length) problems.push(`${id} repeats a target in its audience`);
|
|
59
|
+
for (const target of audience) if (!this.#targets.has(target)) problems.push(`${id} names the undeclared target ${target}`);
|
|
60
|
+
if (capability.input !== void 0 && this.#resolve(capability.input).type !== "object") problems.push(`${id} input does not describe an object`);
|
|
61
|
+
const schemas = [
|
|
62
|
+
capability.input,
|
|
63
|
+
capability.output,
|
|
64
|
+
capability.state,
|
|
65
|
+
...Object.values(capability.failures ?? {})
|
|
66
|
+
];
|
|
67
|
+
for (const schema of schemas) if (schema !== void 0) problems.push(...this.#referenceProblems(schema).map((problem) => `${id} ${problem}`));
|
|
68
|
+
}
|
|
69
|
+
return problems;
|
|
70
|
+
}
|
|
71
|
+
/** The catalog as a JSON value. Throws when the catalog breaks a rule. */
|
|
72
|
+
toJSON() {
|
|
73
|
+
const problems = this.validate();
|
|
74
|
+
if (problems.length > 0) throw new Error(`The catalog does not conform:\n${problems.join("\n")}`);
|
|
75
|
+
return compact({
|
|
76
|
+
acidcat: "0.1",
|
|
77
|
+
title: this.#title,
|
|
78
|
+
description: this.#description,
|
|
79
|
+
targets: Object.fromEntries([...this.#targets].map(([name, target]) => [name, compact({
|
|
80
|
+
bindings: target.bindings,
|
|
81
|
+
description: target.description,
|
|
82
|
+
options: target.options
|
|
83
|
+
})])),
|
|
84
|
+
$defs: this.#types.size === 0 ? void 0 : Object.fromEntries(this.#types),
|
|
85
|
+
capabilities: Object.fromEntries([...this.#capabilities].map(([id, capability]) => [id, compact({
|
|
86
|
+
description: capability.description,
|
|
87
|
+
audience: capability.audience ?? [],
|
|
88
|
+
input: capability.input,
|
|
89
|
+
output: capability.output,
|
|
90
|
+
failures: Object.keys(capability.failures ?? {}).length === 0 ? void 0 : capability.failures,
|
|
91
|
+
readOnly: capability.readOnly,
|
|
92
|
+
state: capability.state
|
|
93
|
+
})]))
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** The catalog as JSON text in canonical form, ready to write to `acidcat.catalog.json`. */
|
|
97
|
+
render() {
|
|
98
|
+
return canonicalText(this.toJSON());
|
|
99
|
+
}
|
|
100
|
+
#resolve(schema) {
|
|
101
|
+
const name = typeof schema.$ref === "string" ? REFERENCE.exec(schema.$ref)?.[1] : void 0;
|
|
102
|
+
return name === void 0 ? schema : this.#types.get(name) ?? schema;
|
|
103
|
+
}
|
|
104
|
+
#referenceProblems(schema) {
|
|
105
|
+
const problems = [];
|
|
106
|
+
visit(schema, (node) => {
|
|
107
|
+
if ("$defs" in node) problems.push("carries its own $defs; declare shared types with type()");
|
|
108
|
+
if (node.$ref === void 0) return;
|
|
109
|
+
const name = typeof node.$ref === "string" ? REFERENCE.exec(node.$ref)?.[1] : void 0;
|
|
110
|
+
if (name === void 0 || !this.#types.has(name)) problems.push(`references ${String(node.$ref)}, which is not a type`);
|
|
111
|
+
});
|
|
112
|
+
return problems;
|
|
113
|
+
}
|
|
191
114
|
};
|
|
192
115
|
function compact(value) {
|
|
193
|
-
|
|
116
|
+
return Object.fromEntries(Object.entries(value).filter(([, member]) => member !== void 0));
|
|
194
117
|
}
|
|
195
118
|
function visit(value, onObject) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
119
|
+
if (Array.isArray(value)) value.forEach((item) => visit(item, onObject));
|
|
120
|
+
else if (value !== null && typeof value === "object") {
|
|
121
|
+
onObject(value);
|
|
122
|
+
Object.values(value).forEach((item) => visit(item, onObject));
|
|
123
|
+
}
|
|
202
124
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
canonicalText
|
|
206
|
-
};
|
|
125
|
+
//#endregion
|
|
126
|
+
export { Catalog, canonicalText };
|