@acid-cat/catalog 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.
- package/LICENSE +21 -0
- package/README.md +26 -2
- package/dist/canonical.d.ts +17 -0
- package/dist/canonical.js +84 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +126 -0
- package/package.json +34 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hebilicious
|
|
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,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @acid-cat/catalog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The AcidCat catalog library for TypeScript. npm package: `@acid-cat/catalog`.
|
|
4
|
+
|
|
5
|
+
Write a catalog in TypeScript, and write it in canonical form. The library has no dependencies. Use it at build time to write `acidcat.catalog.json`.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add -D @acid-cat/catalog
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { Catalog } from "@acid-cat/catalog";
|
|
13
|
+
|
|
14
|
+
const catalog = new Catalog({ title: "Living-room air conditioner" });
|
|
15
|
+
catalog.target("cli", { bindings: ["cli"] });
|
|
16
|
+
const offline = catalog.type("Offline", { type: "object" });
|
|
17
|
+
catalog.capability("aircon.power.set", {
|
|
18
|
+
description: "Turn the unit on or off.",
|
|
19
|
+
audience: ["cli"],
|
|
20
|
+
input: { type: "object", required: ["on"], properties: { on: { type: "boolean" } } },
|
|
21
|
+
failures: { Offline: offline },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await writeFile("acidcat.catalog.json", catalog.render());
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`validate()` lists the rules a catalog breaks, and `render()` refuses to render one that breaks any. The full example is [`examples/aircon.ts`](examples/aircon.ts). Test with `moon run catalog-typescript:test`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical form of a catalog (spec/catalog.md#canonical-form): the one way to write a catalog as text, so that
|
|
3
|
+
* every tool and every catalog library produces the same bytes for the same catalog.
|
|
4
|
+
*/
|
|
5
|
+
type Json = null | boolean | number | string | readonly Json[] | {
|
|
6
|
+
readonly [key: string]: Json;
|
|
7
|
+
};
|
|
8
|
+
type JsonObject = {
|
|
9
|
+
readonly [key: string]: Json;
|
|
10
|
+
};
|
|
11
|
+
/** A catalog in canonical form, as text. */
|
|
12
|
+
export declare function canonicalText(catalog: unknown): string;
|
|
13
|
+
/** Put a catalog's members in canonical order. */
|
|
14
|
+
export declare function canonicalCatalog(catalog: JsonObject): JsonObject;
|
|
15
|
+
/** Order strings by Unicode code point, as Python, Go and Rust compare them. */
|
|
16
|
+
export declare function byCodePoint(left: string, right: string): number;
|
|
17
|
+
export {};
|
|
@@ -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.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write an AcidCat catalog in TypeScript, and render it to the JSON document the toolchain checks.
|
|
3
|
+
*
|
|
4
|
+
* This is an authoring library, not a runtime: a program uses it to produce `acidcat.catalog.json`, and nothing
|
|
5
|
+
* reads the catalog through it at runtime. See spec/catalog.md.
|
|
6
|
+
*/
|
|
7
|
+
export { canonicalText } from "./canonical.js";
|
|
8
|
+
/** A JSON Schema 2020-12 schema, written as a plain object. */
|
|
9
|
+
export type Schema = {
|
|
10
|
+
readonly [keyword: string]: unknown;
|
|
11
|
+
};
|
|
12
|
+
export interface TargetOptions {
|
|
13
|
+
/** The bindings that serve the target, such as `"typescript"` and `"webmcp"`. */
|
|
14
|
+
readonly bindings: readonly string[];
|
|
15
|
+
readonly description?: string;
|
|
16
|
+
/** Settings for the bindings, in a form each binding defines. */
|
|
17
|
+
readonly options?: Schema;
|
|
18
|
+
}
|
|
19
|
+
export interface CapabilityOptions {
|
|
20
|
+
/** What the capability does for its caller, written for people and agents. */
|
|
21
|
+
readonly description: string;
|
|
22
|
+
/** The targets the capability is offered to. Empty means no target. */
|
|
23
|
+
readonly audience?: readonly string[];
|
|
24
|
+
/** The input schema. It must describe an object. Absent means an empty object. */
|
|
25
|
+
readonly input?: Schema;
|
|
26
|
+
/** The schema of a successful call's value. Absent means `null`. */
|
|
27
|
+
readonly output?: Schema;
|
|
28
|
+
/** The failures, keyed by tag. */
|
|
29
|
+
readonly failures?: Readonly<Record<string, Schema>>;
|
|
30
|
+
/** `true` when a call changes nothing anyone can observe. A hint for clients. */
|
|
31
|
+
readonly readOnly?: boolean;
|
|
32
|
+
/** The schema of a value that clients can observe. */
|
|
33
|
+
readonly state?: Schema;
|
|
34
|
+
}
|
|
35
|
+
export declare class Catalog {
|
|
36
|
+
#private;
|
|
37
|
+
constructor(options?: {
|
|
38
|
+
readonly title?: string;
|
|
39
|
+
readonly description?: string;
|
|
40
|
+
});
|
|
41
|
+
/** Declare a target. */
|
|
42
|
+
target(name: string, options: TargetOptions): this;
|
|
43
|
+
/** Declare a shared type, and get the schema that references it. */
|
|
44
|
+
type(name: string, schema: Schema): Schema;
|
|
45
|
+
/** Declare a capability. */
|
|
46
|
+
capability(id: string, options: CapabilityOptions): this;
|
|
47
|
+
/** The catalog rules this catalog breaks. Empty when it conforms. */
|
|
48
|
+
validate(): string[];
|
|
49
|
+
/** The catalog as a JSON value. Throws when the catalog breaks a rule. */
|
|
50
|
+
toJSON(): Schema;
|
|
51
|
+
/** The catalog as JSON text in canonical form, ready to write to `acidcat.catalog.json`. */
|
|
52
|
+
render(): string;
|
|
53
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
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_]*)$/;
|
|
13
|
+
var Catalog = class {
|
|
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
|
+
}
|
|
114
|
+
};
|
|
115
|
+
function compact(value) {
|
|
116
|
+
return Object.fromEntries(Object.entries(value).filter(([, member]) => member !== void 0));
|
|
117
|
+
}
|
|
118
|
+
function visit(value, onObject) {
|
|
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
|
+
}
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
export { Catalog, canonicalText };
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acid-cat/catalog",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Write an AcidCat catalog in TypeScript and render it in canonical form.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"acidcat",
|
|
7
|
+
"catalog"
|
|
8
|
+
],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Hebilicious/acidcat.git",
|
|
13
|
+
"directory": "packages/catalog/typescript"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/Hebilicious/acidcat/tree/main/packages/catalog/typescript#readme",
|
|
16
|
+
"bugs": "https://github.com/Hebilicious/acidcat/issues",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=24"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "26.6.5",
|
|
35
|
+
"typescript": "7.0.2"
|
|
36
|
+
}
|
|
6
37
|
}
|