@texuli/tools 0.0.0-stage → 0.1.0-next.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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -2
  3. package/dist/a2ui/from-a2ui.d.ts +35 -0
  4. package/dist/a2ui/from-a2ui.js +180 -0
  5. package/dist/a2ui/index.d.ts +11 -0
  6. package/dist/a2ui/index.js +11 -0
  7. package/dist/a2ui/props.d.ts +10 -0
  8. package/dist/a2ui/props.js +94 -0
  9. package/dist/a2ui/schema.d.ts +26 -0
  10. package/dist/a2ui/schema.js +193 -0
  11. package/dist/a2ui/to-a2ui.d.ts +30 -0
  12. package/dist/a2ui/to-a2ui.js +119 -0
  13. package/dist/a2ui/types.d.ts +71 -0
  14. package/dist/a2ui/types.js +11 -0
  15. package/dist/cli-add/add.d.ts +21 -0
  16. package/dist/cli-add/add.js +241 -0
  17. package/dist/cli-add/cli.d.ts +25 -0
  18. package/dist/cli-add/cli.js +157 -0
  19. package/dist/cli-add/index.d.ts +12 -0
  20. package/dist/cli-add/index.js +11 -0
  21. package/dist/cli-add/paths.d.ts +4 -0
  22. package/dist/cli-add/paths.js +4 -0
  23. package/dist/cli-add/provenance.d.ts +32 -0
  24. package/dist/cli-add/provenance.js +78 -0
  25. package/dist/cli-add/registry.d.ts +19 -0
  26. package/dist/cli-add/registry.js +50 -0
  27. package/dist/cli-add/types.d.ts +71 -0
  28. package/dist/cli-add/types.js +4 -0
  29. package/dist/cli-init/cli.d.ts +25 -0
  30. package/dist/cli-init/cli.js +212 -0
  31. package/dist/cli-init/config.d.ts +10 -0
  32. package/dist/cli-init/config.js +62 -0
  33. package/dist/cli-init/detect.d.ts +13 -0
  34. package/dist/cli-init/detect.js +42 -0
  35. package/dist/cli-init/files.d.ts +18 -0
  36. package/dist/cli-init/files.js +59 -0
  37. package/dist/cli-init/index.d.ts +14 -0
  38. package/dist/cli-init/index.js +13 -0
  39. package/dist/cli-init/init.d.ts +14 -0
  40. package/dist/cli-init/init.js +157 -0
  41. package/dist/cli-init/safe-path.d.ts +2 -0
  42. package/dist/cli-init/safe-path.js +26 -0
  43. package/dist/cli-init/theme-apply.d.ts +2 -0
  44. package/dist/cli-init/theme-apply.js +69 -0
  45. package/dist/cli-init/themes.d.ts +37 -0
  46. package/dist/cli-init/themes.js +83 -0
  47. package/dist/cli-init/types.d.ts +50 -0
  48. package/dist/cli-init/types.js +1 -0
  49. package/dist/cli-init/wire.d.ts +20 -0
  50. package/dist/cli-init/wire.js +90 -0
  51. package/dist/cli-update/cli.d.ts +26 -0
  52. package/dist/cli-update/cli.js +161 -0
  53. package/dist/cli-update/index.d.ts +11 -0
  54. package/dist/cli-update/index.js +10 -0
  55. package/dist/cli-update/lines.d.ts +38 -0
  56. package/dist/cli-update/lines.js +189 -0
  57. package/dist/cli-update/provenance.d.ts +14 -0
  58. package/dist/cli-update/provenance.js +51 -0
  59. package/dist/cli-update/types.d.ts +83 -0
  60. package/dist/cli-update/types.js +1 -0
  61. package/dist/cli-update/update.d.ts +12 -0
  62. package/dist/cli-update/update.js +261 -0
  63. package/dist/elements/content.d.ts +15 -0
  64. package/dist/elements/content.js +53 -0
  65. package/dist/elements/define.d.ts +49 -0
  66. package/dist/elements/define.js +281 -0
  67. package/dist/elements/index.d.ts +6 -0
  68. package/dist/elements/index.js +6 -0
  69. package/dist/golden/axe.d.ts +32 -0
  70. package/dist/golden/axe.js +22 -0
  71. package/dist/golden/catalog.d.ts +24 -0
  72. package/dist/golden/catalog.js +85 -0
  73. package/dist/golden/cli.d.ts +9 -0
  74. package/dist/golden/cli.js +45 -0
  75. package/dist/golden/guidance.d.ts +12 -0
  76. package/dist/golden/guidance.js +171 -0
  77. package/dist/golden/index.d.ts +17 -0
  78. package/dist/golden/index.js +17 -0
  79. package/dist/golden/outline.d.ts +21 -0
  80. package/dist/golden/outline.js +68 -0
  81. package/dist/golden/paint.d.ts +21 -0
  82. package/dist/golden/paint.js +103 -0
  83. package/dist/golden/report.d.ts +28 -0
  84. package/dist/golden/report.js +64 -0
  85. package/dist/golden/score.d.ts +16 -0
  86. package/dist/golden/score.js +116 -0
  87. package/dist/golden/sinks.d.ts +8 -0
  88. package/dist/golden/sinks.js +11 -0
  89. package/dist/golden/tasks.d.ts +4 -0
  90. package/dist/golden/tasks.js +686 -0
  91. package/dist/golden/types.d.ts +54 -0
  92. package/dist/golden/types.js +9 -0
  93. package/dist/index.d.ts +8 -0
  94. package/dist/index.js +8 -0
  95. package/dist/internal/paths.d.ts +56 -0
  96. package/dist/internal/paths.js +186 -0
  97. package/dist/llms/bundle.d.ts +6 -0
  98. package/dist/llms/bundle.js +47 -0
  99. package/dist/llms/cli.d.ts +24 -0
  100. package/dist/llms/cli.js +104 -0
  101. package/dist/llms/format.d.ts +35 -0
  102. package/dist/llms/format.js +166 -0
  103. package/dist/llms/index.d.ts +9 -0
  104. package/dist/llms/index.js +8 -0
  105. package/dist/llms/types.d.ts +51 -0
  106. package/dist/llms/types.js +1 -0
  107. package/dist/llms/write.d.ts +14 -0
  108. package/dist/llms/write.js +49 -0
  109. package/dist/mcp/index.d.ts +2 -0
  110. package/dist/mcp/index.js +2 -0
  111. package/dist/mcp/server.d.ts +20 -0
  112. package/dist/mcp/server.js +326 -0
  113. package/dist/mcp/stdio.d.ts +3 -0
  114. package/dist/mcp/stdio.js +34 -0
  115. package/package.json +63 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dailybot, Inc.
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,34 @@
1
- # Temporary Holding Version
1
+ # @texuli/tools
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
+ **Layer L7: tools.** Agent and developer tooling over the library: the
4
+ copy-in CLI, the agent docs bundle, the A2UI adapter, custom elements, the
5
+ golden set and the read-only MCP server. Private workspace package
6
+ (`"private": true`), not published: every bin below runs inside this
7
+ repository only.
8
+
9
+ ## What exists today
10
+
11
+ Every area has source in `src/` and a unit test in `tests/`. All areas
12
+ except the MCP server also have a doc in [`docs/`](docs/) and an exported
13
+ subpath in `package.json`.
14
+
15
+ | Area | Source | Subpath | Bin | What it does |
16
+ | --- | --- | --- | --- | --- |
17
+ | Copy-in CLI | `src/cli-add/` | `@texuli/tools/cli-add` | `texuli-add` | Copies styled component source into a consumer app with a provenance header. Never copies `@texuli/primitives`; local paths only. [Docs](docs/cli-add.md) |
18
+ | Init and themes | `src/cli-init/` | `@texuli/tools/cli-init` | `texuli-init` | Wires tokens and CSS into a Vite, Astro or SvelteKit project, then lists, previews and applies themes from the theme library (210 themes). Local files only. [Docs](docs/cli-init.md) |
19
+ | Update | `src/cli-update/` | `@texuli/tools/cli-update` | `texuli-update` | Updates a component copied with `texuli add` to new source by three-way merge: base from the provenance headers, the consumer's edits, the new upstream. [Docs](docs/cli-update.md) |
20
+ | Agent docs bundle | `src/llms/` | `@texuli/tools/llms` | `texuli-llms` | Generates the llms.txt bundle and agent-facing docs from catalog manifests. [Docs](docs/llms.md) |
21
+ | A2UI adapter (experimental) | `src/a2ui/` | `@texuli/tools/a2ui` | — | Converts between catalog trees and A2UI v0.9 server-to-client messages, on plain data only. [Docs](docs/a2ui.md) |
22
+ | Custom elements | `src/elements/` | `@texuli/tools/elements` | — | Light-DOM custom elements that render catalog trees with the real content components, for hosts that do not use Svelte. [Docs](docs/elements.md) |
23
+ | Golden set | `src/golden/` | `@texuli/tools/golden` | CLI source only: `src/golden/cli.ts`, not a registered bin | Thirty pre-registered interface tasks for agents, each with a reference tree, plus an offline scorer. [Docs](docs/golden.md) |
24
+ | MCP server | `src/mcp/` | — | — | The read-only local stdio MCP server over the catalog and themes (`catalog`, `component`, `validate`, `example`, `theme` tools). Source and tests only: no export, no published binary. |
25
+
26
+ The root entry (`@texuli/tools`) re-exports the A2UI, copy-in CLI, update
27
+ and llms helpers.
28
+
29
+ ## Status
30
+
31
+ Implemented and gated in this repository: unit tests per area, including
32
+ `tests/mcp.test.ts`. Nothing is delivered outside it yet — no npm release
33
+ and no published MCP binary
34
+ ([ARCHITECTURE](../../docs/ARCHITECTURE.md) tracks the delivery plan).
@@ -0,0 +1,35 @@
1
+ import type { CatalogTreeInput } from '@texuli/catalog/render/renderer';
2
+ import { type A2uiDiagnostic, type A2uiLimits } from './types.js';
3
+ export interface FromA2uiOptions extends A2uiLimits {
4
+ /** The surface to read (default: the first one the messages name). */
5
+ surfaceId?: string;
6
+ /** When set, a `createSurface` naming another catalog fails the conversion. */
7
+ catalogId?: string;
8
+ }
9
+ export type FromA2uiResult = {
10
+ ok: true;
11
+ surfaceId: string;
12
+ /** From `createSurface`, when the messages include one. */
13
+ catalogId?: string;
14
+ /** The catalog tree's top-level nodes (empty after `deleteSurface`). */
15
+ roots: CatalogTreeInput[];
16
+ /** Components no path from `root` reaches; they are left out. */
17
+ unused: string[];
18
+ } | {
19
+ ok: false;
20
+ diagnostics: A2uiDiagnostic[];
21
+ };
22
+ /**
23
+ * Convert untrusted A2UI v0.9 messages (one message or a list, already
24
+ * parsed from JSON) back into a catalog tree.
25
+ *
26
+ * The messages are checked against {@link A2UI_MESSAGE_SCHEMA} first. For
27
+ * the chosen surface, `updateComponents` messages upsert components by id;
28
+ * `deleteSurface` clears them; `updateDataModel` is ignored (catalog trees
29
+ * carry no data model). The tree is rebuilt from `root`. Missing children,
30
+ * cycles, a component used twice, data-bound child templates, bindings,
31
+ * prototype keys and size limits fail the conversion. Messages for other
32
+ * surfaces are ignored. Pass the result to `renderCatalogTree`, which applies
33
+ * the catalog's ids, tiers and prop schemas.
34
+ */
35
+ export declare function a2uiToCatalogTree(input: unknown, options?: FromA2uiOptions): FromA2uiResult;
@@ -0,0 +1,180 @@
1
+ import { copyProps, isPlainRecord } from "./props.js";
2
+ import { validateA2uiMessages } from "./schema.js";
3
+ import { A2UI_RESERVED_KEYS, A2UI_ROOT_ID, A2UI_ROOTS_COMPONENT, } from "./types.js";
4
+ function tooManyComponents(message, max) {
5
+ if (!isPlainRecord(message) || !isPlainRecord(message.updateComponents))
6
+ return false;
7
+ const { components } = message.updateComponents;
8
+ if (!Array.isArray(components) || components.length > max)
9
+ return Array.isArray(components);
10
+ return components.some((c) => isPlainRecord(c) && Array.isArray(c.children) && c.children.length > max);
11
+ }
12
+ function surfaceOf(message) {
13
+ if ('createSurface' in message)
14
+ return message.createSurface.surfaceId;
15
+ if ('updateComponents' in message)
16
+ return message.updateComponents.surfaceId;
17
+ if ('updateDataModel' in message)
18
+ return message.updateDataModel.surfaceId;
19
+ return message.deleteSurface.surfaceId;
20
+ }
21
+ /**
22
+ * Convert untrusted A2UI v0.9 messages (one message or a list, already
23
+ * parsed from JSON) back into a catalog tree.
24
+ *
25
+ * The messages are checked against {@link A2UI_MESSAGE_SCHEMA} first. For
26
+ * the chosen surface, `updateComponents` messages upsert components by id;
27
+ * `deleteSurface` clears them; `updateDataModel` is ignored (catalog trees
28
+ * carry no data model). The tree is rebuilt from `root`. Missing children,
29
+ * cycles, a component used twice, data-bound child templates, bindings,
30
+ * prototype keys and size limits fail the conversion. Messages for other
31
+ * surfaces are ignored. Pass the result to `renderCatalogTree`, which applies
32
+ * the catalog's ids, tiers and prop schemas.
33
+ */
34
+ export function a2uiToCatalogTree(input, options = {}) {
35
+ const maxComponents = options.maxComponents ?? 1000;
36
+ const maxDepth = options.maxDepth ?? 64;
37
+ const list = Array.isArray(input) ? input : [input];
38
+ // Bound the work before the schema walks every item.
39
+ const oversized = list.length > maxComponents || list.some((m) => tooManyComponents(m, maxComponents));
40
+ if (oversized) {
41
+ return {
42
+ ok: false,
43
+ diagnostics: [
44
+ { code: 'limit', path: '$', message: `More than ${maxComponents} components or messages.` },
45
+ ],
46
+ };
47
+ }
48
+ const invalid = validateA2uiMessages(list);
49
+ if (invalid.length > 0)
50
+ return { ok: false, diagnostics: invalid };
51
+ const messages = list;
52
+ const surfaceId = options.surfaceId ?? (messages[0] ? surfaceOf(messages[0]) : undefined);
53
+ if (surfaceId === undefined) {
54
+ return { ok: false, diagnostics: [{ code: 'surface', path: '$', message: 'No messages.' }] };
55
+ }
56
+ const diagnostics = [];
57
+ const components = new Map();
58
+ let created = false;
59
+ let catalogId;
60
+ messages.forEach((message, i) => {
61
+ if (surfaceOf(message) !== surfaceId)
62
+ return;
63
+ if ('createSurface' in message) {
64
+ if (created) {
65
+ diagnostics.push({
66
+ code: 'surface',
67
+ path: `$[${i}]`,
68
+ message: `Surface "${surfaceId}" is created twice without a deleteSurface between.`,
69
+ });
70
+ }
71
+ if (options.catalogId !== undefined &&
72
+ message.createSurface.catalogId !== options.catalogId) {
73
+ diagnostics.push({
74
+ code: 'surface',
75
+ path: `$[${i}].createSurface.catalogId`,
76
+ message: `Expected catalog "${options.catalogId}".`,
77
+ });
78
+ }
79
+ created = true;
80
+ catalogId = message.createSurface.catalogId;
81
+ }
82
+ else if ('deleteSurface' in message) {
83
+ created = false;
84
+ components.clear();
85
+ }
86
+ else if ('updateComponents' in message) {
87
+ message.updateComponents.components.forEach((value, j) => {
88
+ components.set(value.id, { value, path: `$[${i}].updateComponents.components[${j}]` });
89
+ });
90
+ if (components.size > maxComponents) {
91
+ diagnostics.push({
92
+ code: 'limit',
93
+ path: `$[${i}]`,
94
+ message: `More than ${maxComponents} components.`,
95
+ });
96
+ }
97
+ }
98
+ });
99
+ if (diagnostics.length > 0)
100
+ return { ok: false, diagnostics };
101
+ const base = { ok: true, surfaceId, ...(catalogId === undefined ? {} : { catalogId }) };
102
+ if (components.size === 0)
103
+ return { ...base, roots: [], unused: [] };
104
+ const used = new Set();
105
+ const onPath = new Set();
106
+ const build = (id, from, depth) => {
107
+ if (diagnostics.length >= 50)
108
+ return null;
109
+ const entry = components.get(id);
110
+ if (!entry) {
111
+ diagnostics.push(id === A2UI_ROOT_ID
112
+ ? { code: 'missing-root', path: from, message: 'No component has the id "root".' }
113
+ : { code: 'missing-child', path: from, message: `No component has the id "${id}".` });
114
+ return null;
115
+ }
116
+ if (onPath.has(id)) {
117
+ diagnostics.push({ code: 'cycle', path: from, message: `"${id}" contains itself.` });
118
+ return null;
119
+ }
120
+ if (used.has(id)) {
121
+ diagnostics.push({
122
+ code: 'shared-child',
123
+ path: from,
124
+ message: `"${id}" already has a parent; a tree uses each component once.`,
125
+ });
126
+ return null;
127
+ }
128
+ if (depth > maxDepth) {
129
+ diagnostics.push({
130
+ code: 'limit',
131
+ path: entry.path,
132
+ message: `Nesting deeper than ${maxDepth}.`,
133
+ });
134
+ return null;
135
+ }
136
+ const { value, path } = entry;
137
+ used.add(id);
138
+ if (value.component === A2UI_ROOTS_COMPONENT && depth > 1) {
139
+ diagnostics.push({
140
+ code: 'invalid-node',
141
+ path: `${path}.component`,
142
+ message: `"${A2UI_ROOTS_COMPONENT}" may only be the root.`,
143
+ });
144
+ return null;
145
+ }
146
+ if (value.children !== undefined && value.child !== undefined) {
147
+ diagnostics.push({
148
+ code: 'invalid-node',
149
+ path,
150
+ message: 'A component has "children" or "child", not both.',
151
+ });
152
+ return null;
153
+ }
154
+ if (value.children !== undefined && !Array.isArray(value.children)) {
155
+ diagnostics.push({
156
+ code: 'template-children',
157
+ path: `${path}.children`,
158
+ message: 'Data-bound child templates are not supported; list child ids.',
159
+ });
160
+ return null;
161
+ }
162
+ const props = copyProps(value, A2UI_RESERVED_KEYS, path, null, diagnostics);
163
+ const childIds = value.child !== undefined ? [value.child] : value.children;
164
+ onPath.add(id);
165
+ const children = childIds?.map((childId, k) => build(childId, value.child !== undefined ? `${path}.child` : `${path}.children[${k}]`, depth + 1));
166
+ onPath.delete(id);
167
+ const node = {
168
+ id: value.component,
169
+ ...(Object.keys(props).length > 0 ? { props } : {}),
170
+ ...(children ? { children: children.filter((c) => c !== null) } : {}),
171
+ };
172
+ return node;
173
+ };
174
+ const root = build(A2UI_ROOT_ID, '$', 1);
175
+ if (diagnostics.length > 0 || root === null)
176
+ return { ok: false, diagnostics };
177
+ const unused = [...components.keys()].filter((id) => !used.has(id)).sort();
178
+ const roots = root.id === A2UI_ROOTS_COMPONENT ? [...(root.children ?? [])] : [root];
179
+ return { ...base, roots, unused };
180
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@texuli/tools/a2ui` — experimental A2UI v0.9 adapter.
3
+ *
4
+ * Converts catalog trees to A2UI-shaped server-to-client messages and back,
5
+ * checked against a Texuli subset of the v0.9 message schema. Plain data
6
+ * only: no DOM, no network.
7
+ */
8
+ export { a2uiToCatalogTree, type FromA2uiOptions, type FromA2uiResult } from './from-a2ui.js';
9
+ export { A2UI_MESSAGE_SCHEMA, checkJsonSchema, type JsonSchema, validateA2uiMessages, } from './schema.js';
10
+ export { catalogTreeToA2ui, type ToA2uiOptions, type ToA2uiResult } from './to-a2ui.js';
11
+ export { A2UI_RESERVED_KEYS, A2UI_ROOT_ID, A2UI_ROOTS_COMPONENT, A2UI_VERSION, type A2uiChildList, type A2uiComponent, type A2uiCreateSurfaceMessage, type A2uiDeleteSurfaceMessage, type A2uiDiagnostic, type A2uiIssueCode, type A2uiLimits, type A2uiMessage, type A2uiUpdateComponentsMessage, type A2uiUpdateDataModelMessage, } from './types.js';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@texuli/tools/a2ui` — experimental A2UI v0.9 adapter.
3
+ *
4
+ * Converts catalog trees to A2UI-shaped server-to-client messages and back,
5
+ * checked against a Texuli subset of the v0.9 message schema. Plain data
6
+ * only: no DOM, no network.
7
+ */
8
+ export { a2uiToCatalogTree } from "./from-a2ui.js";
9
+ export { A2UI_MESSAGE_SCHEMA, checkJsonSchema, validateA2uiMessages, } from "./schema.js";
10
+ export { catalogTreeToA2ui } from "./to-a2ui.js";
11
+ export { A2UI_RESERVED_KEYS, A2UI_ROOT_ID, A2UI_ROOTS_COMPONENT, A2UI_VERSION, } from "./types.js";
@@ -0,0 +1,10 @@
1
+ import { type A2uiDiagnostic } from './types.js';
2
+ /** A Texuli or A2UI component id: kebab-case, like the catalog renderer's. */
3
+ export declare const COMPONENT_ID: RegExp;
4
+ export declare function isPlainRecord(value: unknown): value is Record<string, unknown>;
5
+ /**
6
+ * Copy the props of one node into a fresh object, or report why they cannot
7
+ * travel: reserved structure keys, prototype keys, non-JSON values, and
8
+ * bindings or function calls (this adapter carries literals only).
9
+ */
10
+ export declare function copyProps(source: Readonly<Record<string, unknown>>, skip: readonly string[], path: string, reservedCode: 'reserved-prop' | null, out: A2uiDiagnostic[]): Record<string, unknown>;
@@ -0,0 +1,94 @@
1
+ import { A2UI_RESERVED_KEYS } from "./types.js";
2
+ /** Keys that could reach an object's prototype when copied. */
3
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
4
+ /** A Texuli or A2UI component id: kebab-case, like the catalog renderer's. */
5
+ export const COMPONENT_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
6
+ export function isPlainRecord(value) {
7
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
8
+ return false;
9
+ const proto = Object.getPrototypeOf(value);
10
+ return proto === Object.prototype || proto === null;
11
+ }
12
+ /**
13
+ * Whether a prop value would mean something other than a literal to an A2UI
14
+ * client: a data binding (`{ path }`) or a function call (`{ call }`).
15
+ */
16
+ function isDynamic(value) {
17
+ if (!isPlainRecord(value))
18
+ return false;
19
+ const keys = Object.keys(value);
20
+ return ((keys.length === 1 && typeof value.path === 'string') ||
21
+ (typeof value.call === 'string' &&
22
+ keys.every((k) => ['call', 'args', 'returnType'].includes(k))));
23
+ }
24
+ /** Check that `value` is plain JSON data with no prototype-reaching keys. */
25
+ function checkJson(value, path, out) {
26
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
27
+ return;
28
+ if (typeof value === 'number') {
29
+ if (!Number.isFinite(value))
30
+ out.push({ code: 'not-json', path, message: 'Numbers must be finite.' });
31
+ return;
32
+ }
33
+ if (Array.isArray(value)) {
34
+ value.forEach((item, i) => {
35
+ checkJson(item, `${path}[${i}]`, out);
36
+ });
37
+ return;
38
+ }
39
+ if (!isPlainRecord(value)) {
40
+ out.push({ code: 'not-json', path, message: `A ${typeof value} is not JSON data.` });
41
+ return;
42
+ }
43
+ for (const key of Object.keys(value)) {
44
+ if (UNSAFE_KEYS.has(key)) {
45
+ out.push({
46
+ code: 'unsafe-key',
47
+ path: `${path}.${key}`,
48
+ message: `"${key}" is never copied.`,
49
+ });
50
+ }
51
+ else {
52
+ checkJson(value[key], `${path}.${key}`, out);
53
+ }
54
+ }
55
+ }
56
+ /**
57
+ * Copy the props of one node into a fresh object, or report why they cannot
58
+ * travel: reserved structure keys, prototype keys, non-JSON values, and
59
+ * bindings or function calls (this adapter carries literals only).
60
+ */
61
+ export function copyProps(source, skip, path, reservedCode, out) {
62
+ const props = {};
63
+ for (const key of Object.keys(source)) {
64
+ if (skip.includes(key))
65
+ continue;
66
+ const at = `${path}.${key}`;
67
+ if (UNSAFE_KEYS.has(key)) {
68
+ out.push({ code: 'unsafe-key', path: at, message: `"${key}" is never copied.` });
69
+ continue;
70
+ }
71
+ if (reservedCode && A2UI_RESERVED_KEYS.includes(key)) {
72
+ out.push({
73
+ code: reservedCode,
74
+ path: at,
75
+ message: `"${key}" is A2UI structure, so a prop cannot use that name.`,
76
+ });
77
+ continue;
78
+ }
79
+ const value = source[key];
80
+ if (isDynamic(value)) {
81
+ out.push({
82
+ code: 'binding',
83
+ path: at,
84
+ message: 'Data bindings and function calls are not supported; pass a literal value.',
85
+ });
86
+ continue;
87
+ }
88
+ const before = out.length;
89
+ checkJson(value, at, out);
90
+ if (out.length === before)
91
+ props[key] = structuredClone(value);
92
+ }
93
+ return props;
94
+ }
@@ -0,0 +1,26 @@
1
+ import { type A2uiDiagnostic } from './types.js';
2
+ /** A JSON Schema document (the subset of keywords {@link checkJsonSchema} reads). */
3
+ export type JsonSchema = {
4
+ readonly [keyword: string]: unknown;
5
+ };
6
+ /**
7
+ * The A2UI v0.9 server-to-client message schema, as Texuli checks it.
8
+ *
9
+ * A hand-written subset of the upstream `server_to_client.json` and
10
+ * `common_types.json` (`https://a2ui.org/specification/v0_9/`): the same four
11
+ * messages, required fields, `additionalProperties: false` and `ChildList`.
12
+ * Upstream leaves the component shape to the catalog; here a component is
13
+ * `id` plus a Texuli catalog id in `component`, with props beside them.
14
+ */
15
+ export declare const A2UI_MESSAGE_SCHEMA: JsonSchema;
16
+ /**
17
+ * Validate `value` against `schema`, resolving `#/$defs/*` refs in `root`.
18
+ *
19
+ * Reads only the keywords Texuli's schemas use: `$ref`, `type`, `const`,
20
+ * `enum`, `required`, `properties`, `additionalProperties`, `items`,
21
+ * `minItems`, `minLength`, `pattern`, `oneOf` and `anyOf`. Any other keyword
22
+ * is ignored, so this is not a general JSON Schema validator.
23
+ */
24
+ export declare function checkJsonSchema(schema: JsonSchema, value: unknown, path?: string, root?: JsonSchema): A2uiDiagnostic[];
25
+ /** Validate a list of A2UI messages against {@link A2UI_MESSAGE_SCHEMA}. */
26
+ export declare function validateA2uiMessages(messages: unknown): A2uiDiagnostic[];
@@ -0,0 +1,193 @@
1
+ import { A2UI_VERSION } from "./types.js";
2
+ const KEBAB_ID = '^[a-z][a-z0-9]*(-[a-z0-9]+)*$';
3
+ const ref = (name) => ({ $ref: `#/$defs/${name}` });
4
+ const message = (key, body) => ({
5
+ type: 'object',
6
+ required: ['version', key],
7
+ additionalProperties: false,
8
+ properties: { version: { const: A2UI_VERSION }, [key]: body },
9
+ });
10
+ /**
11
+ * The A2UI v0.9 server-to-client message schema, as Texuli checks it.
12
+ *
13
+ * A hand-written subset of the upstream `server_to_client.json` and
14
+ * `common_types.json` (`https://a2ui.org/specification/v0_9/`): the same four
15
+ * messages, required fields, `additionalProperties: false` and `ChildList`.
16
+ * Upstream leaves the component shape to the catalog; here a component is
17
+ * `id` plus a Texuli catalog id in `component`, with props beside them.
18
+ */
19
+ export const A2UI_MESSAGE_SCHEMA = {
20
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
21
+ $id: 'texuli:a2ui/v0.9/server-to-client',
22
+ title: 'A2UI v0.9 server-to-client message (Texuli catalog)',
23
+ oneOf: [
24
+ ref('CreateSurfaceMessage'),
25
+ ref('UpdateComponentsMessage'),
26
+ ref('UpdateDataModelMessage'),
27
+ ref('DeleteSurfaceMessage'),
28
+ ],
29
+ $defs: {
30
+ CreateSurfaceMessage: message('createSurface', {
31
+ type: 'object',
32
+ required: ['surfaceId', 'catalogId'],
33
+ additionalProperties: false,
34
+ properties: {
35
+ surfaceId: ref('SurfaceId'),
36
+ catalogId: { type: 'string', minLength: 1 },
37
+ theme: { type: 'object' },
38
+ sendDataModel: { type: 'boolean' },
39
+ },
40
+ }),
41
+ UpdateComponentsMessage: message('updateComponents', {
42
+ type: 'object',
43
+ required: ['surfaceId', 'components'],
44
+ additionalProperties: false,
45
+ properties: {
46
+ surfaceId: ref('SurfaceId'),
47
+ components: { type: 'array', minItems: 1, items: ref('AnyComponent') },
48
+ },
49
+ }),
50
+ UpdateDataModelMessage: message('updateDataModel', {
51
+ type: 'object',
52
+ required: ['surfaceId'],
53
+ additionalProperties: false,
54
+ properties: { surfaceId: ref('SurfaceId'), path: { type: 'string' }, value: {} },
55
+ }),
56
+ DeleteSurfaceMessage: message('deleteSurface', {
57
+ type: 'object',
58
+ required: ['surfaceId'],
59
+ additionalProperties: false,
60
+ properties: { surfaceId: ref('SurfaceId') },
61
+ }),
62
+ SurfaceId: { type: 'string', minLength: 1 },
63
+ ComponentId: { type: 'string', minLength: 1 },
64
+ ChildList: {
65
+ oneOf: [
66
+ { type: 'array', items: ref('ComponentId') },
67
+ {
68
+ type: 'object',
69
+ required: ['componentId', 'path'],
70
+ additionalProperties: false,
71
+ properties: { componentId: ref('ComponentId'), path: { type: 'string' } },
72
+ },
73
+ ],
74
+ },
75
+ AnyComponent: {
76
+ type: 'object',
77
+ required: ['id', 'component'],
78
+ properties: {
79
+ id: ref('ComponentId'),
80
+ component: { type: 'string', pattern: KEBAB_ID },
81
+ children: ref('ChildList'),
82
+ child: ref('ComponentId'),
83
+ },
84
+ },
85
+ },
86
+ };
87
+ function typeOf(value) {
88
+ if (value === null)
89
+ return 'null';
90
+ if (Array.isArray(value))
91
+ return 'array';
92
+ if (typeof value === 'number')
93
+ return Number.isInteger(value) ? 'integer' : 'number';
94
+ return typeof value;
95
+ }
96
+ function typeMatches(actual, expected) {
97
+ const list = Array.isArray(expected) ? expected : [expected];
98
+ return list.some((t) => t === actual || (t === 'number' && actual === 'integer'));
99
+ }
100
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
101
+ const own = (record, key) => Object.hasOwn(record, key);
102
+ /**
103
+ * Validate `value` against `schema`, resolving `#/$defs/*` refs in `root`.
104
+ *
105
+ * Reads only the keywords Texuli's schemas use: `$ref`, `type`, `const`,
106
+ * `enum`, `required`, `properties`, `additionalProperties`, `items`,
107
+ * `minItems`, `minLength`, `pattern`, `oneOf` and `anyOf`. Any other keyword
108
+ * is ignored, so this is not a general JSON Schema validator.
109
+ */
110
+ export function checkJsonSchema(schema, value, path = '$', root = schema) {
111
+ const errors = [];
112
+ const fail = (at, text) => errors.push({ code: 'schema', path: at, message: text });
113
+ if (typeof schema.$ref === 'string') {
114
+ const name = schema.$ref.replace(/^#\/\$defs\//, '');
115
+ const defs = root.$defs;
116
+ const target = isRecord(defs) && own(defs, name) ? defs[name] : undefined;
117
+ if (!isRecord(target))
118
+ return [{ code: 'schema', path, message: `Unknown $ref ${schema.$ref}` }];
119
+ return checkJsonSchema(target, value, path, root);
120
+ }
121
+ if (schema.type !== undefined && !typeMatches(typeOf(value), schema.type)) {
122
+ fail(path, `Expected ${String(schema.type)}, got ${typeOf(value)}.`);
123
+ return errors;
124
+ }
125
+ if (own(schema, 'const') && value !== schema.const) {
126
+ fail(path, `Expected ${JSON.stringify(schema.const)}.`);
127
+ }
128
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
129
+ fail(path, `Expected one of ${schema.enum.map((v) => JSON.stringify(v)).join(', ')}.`);
130
+ }
131
+ if (typeof value === 'string') {
132
+ if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
133
+ fail(path, `Expected at least ${schema.minLength} characters.`);
134
+ }
135
+ if (typeof schema.pattern === 'string' && !new RegExp(schema.pattern, 'u').test(value)) {
136
+ fail(path, `Does not match ${schema.pattern}.`);
137
+ }
138
+ }
139
+ if (Array.isArray(value)) {
140
+ if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
141
+ fail(path, `Expected at least ${schema.minItems} items.`);
142
+ }
143
+ if (isRecord(schema.items)) {
144
+ const items = schema.items;
145
+ value.forEach((item, i) => {
146
+ errors.push(...checkJsonSchema(items, item, `${path}[${i}]`, root));
147
+ });
148
+ }
149
+ }
150
+ if (isRecord(value)) {
151
+ const properties = isRecord(schema.properties) ? schema.properties : {};
152
+ if (Array.isArray(schema.required)) {
153
+ for (const key of schema.required) {
154
+ if (typeof key === 'string' && !own(value, key))
155
+ fail(path, `Missing required "${key}".`);
156
+ }
157
+ }
158
+ for (const key of Object.keys(value)) {
159
+ const at = `${path}.${key}`;
160
+ const sub = own(properties, key) ? properties[key] : undefined;
161
+ if (isRecord(sub))
162
+ errors.push(...checkJsonSchema(sub, value[key], at, root));
163
+ else if (schema.additionalProperties === false)
164
+ fail(at, `Unexpected property "${key}".`);
165
+ else if (isRecord(schema.additionalProperties)) {
166
+ errors.push(...checkJsonSchema(schema.additionalProperties, value[key], at, root));
167
+ }
168
+ }
169
+ }
170
+ for (const keyword of ['oneOf', 'anyOf']) {
171
+ const branches = schema[keyword];
172
+ if (!Array.isArray(branches))
173
+ continue;
174
+ const results = branches.map((b) => (isRecord(b) ? checkJsonSchema(b, value, path, root) : []));
175
+ const passing = results.filter((r) => r.length === 0).length;
176
+ if (keyword === 'anyOf' ? passing === 0 : passing !== 1) {
177
+ // Report the closest branch so the message names the real problem.
178
+ const closest = results.reduce((best, r) => (r.length < best.length ? r : best));
179
+ if (passing > 1)
180
+ fail(path, 'Matches more than one oneOf branch.');
181
+ else
182
+ errors.push(...closest);
183
+ }
184
+ }
185
+ return errors;
186
+ }
187
+ /** Validate a list of A2UI messages against {@link A2UI_MESSAGE_SCHEMA}. */
188
+ export function validateA2uiMessages(messages) {
189
+ if (!Array.isArray(messages)) {
190
+ return [{ code: 'schema', path: '$', message: 'Expected an array of A2UI messages.' }];
191
+ }
192
+ return messages.flatMap((m, i) => checkJsonSchema(A2UI_MESSAGE_SCHEMA, m, `$[${i}]`));
193
+ }
@@ -0,0 +1,30 @@
1
+ import type { CatalogTreeInput } from '@texuli/catalog/render/renderer';
2
+ import { type A2uiDiagnostic, type A2uiLimits, type A2uiMessage } from './types.js';
3
+ export interface ToA2uiOptions extends A2uiLimits {
4
+ /** The A2UI surface the components belong to. */
5
+ surfaceId: string;
6
+ /** The catalog the client resolves `component` ids in, e.g. `catalog-<sha256>.json`. */
7
+ catalogId: string;
8
+ /** Start with a `createSurface` message (default true). */
9
+ createSurface?: boolean;
10
+ }
11
+ export type ToA2uiResult = {
12
+ ok: true;
13
+ messages: A2uiMessage[];
14
+ } | {
15
+ ok: false;
16
+ diagnostics: A2uiDiagnostic[];
17
+ };
18
+ /**
19
+ * Convert a catalog tree (one node or a list of roots) into A2UI v0.9
20
+ * messages: an optional `createSurface`, then one `updateComponents` whose
21
+ * flat component list references children by id.
22
+ *
23
+ * Ids are deterministic: the root is `root` and the rest are `n1`, `n2`… in
24
+ * the order they are visited. Zero or several roots are wrapped in a
25
+ * `texuli-roots` component. Props must be literal JSON; reserved names
26
+ * (`id`, `component`, `children`, `child`), prototype keys, bindings and
27
+ * function calls fail the conversion. The output is checked against
28
+ * {@link A2UI_MESSAGE_SCHEMA} before it is returned.
29
+ */
30
+ export declare function catalogTreeToA2ui(tree: CatalogTreeInput | readonly CatalogTreeInput[], options: ToA2uiOptions): ToA2uiResult;