@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.
- package/LICENSE +21 -0
- package/README.md +33 -2
- package/dist/a2ui/from-a2ui.d.ts +35 -0
- package/dist/a2ui/from-a2ui.js +180 -0
- package/dist/a2ui/index.d.ts +11 -0
- package/dist/a2ui/index.js +11 -0
- package/dist/a2ui/props.d.ts +10 -0
- package/dist/a2ui/props.js +94 -0
- package/dist/a2ui/schema.d.ts +26 -0
- package/dist/a2ui/schema.js +193 -0
- package/dist/a2ui/to-a2ui.d.ts +30 -0
- package/dist/a2ui/to-a2ui.js +119 -0
- package/dist/a2ui/types.d.ts +71 -0
- package/dist/a2ui/types.js +11 -0
- package/dist/cli-add/add.d.ts +21 -0
- package/dist/cli-add/add.js +241 -0
- package/dist/cli-add/cli.d.ts +25 -0
- package/dist/cli-add/cli.js +157 -0
- package/dist/cli-add/index.d.ts +12 -0
- package/dist/cli-add/index.js +11 -0
- package/dist/cli-add/paths.d.ts +4 -0
- package/dist/cli-add/paths.js +4 -0
- package/dist/cli-add/provenance.d.ts +32 -0
- package/dist/cli-add/provenance.js +78 -0
- package/dist/cli-add/registry.d.ts +19 -0
- package/dist/cli-add/registry.js +50 -0
- package/dist/cli-add/types.d.ts +71 -0
- package/dist/cli-add/types.js +4 -0
- package/dist/cli-init/cli.d.ts +25 -0
- package/dist/cli-init/cli.js +212 -0
- package/dist/cli-init/config.d.ts +10 -0
- package/dist/cli-init/config.js +62 -0
- package/dist/cli-init/detect.d.ts +13 -0
- package/dist/cli-init/detect.js +42 -0
- package/dist/cli-init/files.d.ts +18 -0
- package/dist/cli-init/files.js +59 -0
- package/dist/cli-init/index.d.ts +14 -0
- package/dist/cli-init/index.js +13 -0
- package/dist/cli-init/init.d.ts +14 -0
- package/dist/cli-init/init.js +157 -0
- package/dist/cli-init/safe-path.d.ts +2 -0
- package/dist/cli-init/safe-path.js +26 -0
- package/dist/cli-init/theme-apply.d.ts +2 -0
- package/dist/cli-init/theme-apply.js +69 -0
- package/dist/cli-init/themes.d.ts +37 -0
- package/dist/cli-init/themes.js +83 -0
- package/dist/cli-init/types.d.ts +50 -0
- package/dist/cli-init/types.js +1 -0
- package/dist/cli-init/wire.d.ts +20 -0
- package/dist/cli-init/wire.js +90 -0
- package/dist/cli-update/cli.d.ts +26 -0
- package/dist/cli-update/cli.js +161 -0
- package/dist/cli-update/index.d.ts +11 -0
- package/dist/cli-update/index.js +10 -0
- package/dist/cli-update/lines.d.ts +38 -0
- package/dist/cli-update/lines.js +189 -0
- package/dist/cli-update/provenance.d.ts +14 -0
- package/dist/cli-update/provenance.js +51 -0
- package/dist/cli-update/types.d.ts +83 -0
- package/dist/cli-update/types.js +1 -0
- package/dist/cli-update/update.d.ts +12 -0
- package/dist/cli-update/update.js +261 -0
- package/dist/elements/content.d.ts +15 -0
- package/dist/elements/content.js +53 -0
- package/dist/elements/define.d.ts +49 -0
- package/dist/elements/define.js +281 -0
- package/dist/elements/index.d.ts +6 -0
- package/dist/elements/index.js +6 -0
- package/dist/golden/axe.d.ts +32 -0
- package/dist/golden/axe.js +22 -0
- package/dist/golden/catalog.d.ts +24 -0
- package/dist/golden/catalog.js +85 -0
- package/dist/golden/cli.d.ts +9 -0
- package/dist/golden/cli.js +45 -0
- package/dist/golden/guidance.d.ts +12 -0
- package/dist/golden/guidance.js +171 -0
- package/dist/golden/index.d.ts +17 -0
- package/dist/golden/index.js +17 -0
- package/dist/golden/outline.d.ts +21 -0
- package/dist/golden/outline.js +68 -0
- package/dist/golden/paint.d.ts +21 -0
- package/dist/golden/paint.js +103 -0
- package/dist/golden/report.d.ts +28 -0
- package/dist/golden/report.js +64 -0
- package/dist/golden/score.d.ts +16 -0
- package/dist/golden/score.js +116 -0
- package/dist/golden/sinks.d.ts +8 -0
- package/dist/golden/sinks.js +11 -0
- package/dist/golden/tasks.d.ts +4 -0
- package/dist/golden/tasks.js +686 -0
- package/dist/golden/types.d.ts +54 -0
- package/dist/golden/types.js +9 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/internal/paths.d.ts +56 -0
- package/dist/internal/paths.js +186 -0
- package/dist/llms/bundle.d.ts +6 -0
- package/dist/llms/bundle.js +47 -0
- package/dist/llms/cli.d.ts +24 -0
- package/dist/llms/cli.js +104 -0
- package/dist/llms/format.d.ts +35 -0
- package/dist/llms/format.js +166 -0
- package/dist/llms/index.d.ts +9 -0
- package/dist/llms/index.js +8 -0
- package/dist/llms/types.d.ts +51 -0
- package/dist/llms/types.js +1 -0
- package/dist/llms/write.d.ts +14 -0
- package/dist/llms/write.js +49 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/server.d.ts +20 -0
- package/dist/mcp/server.js +326 -0
- package/dist/mcp/stdio.d.ts +3 -0
- package/dist/mcp/stdio.js +34 -0
- package/package.json +63 -4
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `texuli diff` and `texuli update`: three-way merge of a copied component
|
|
3
|
+
* (the consumer's files) against the source it was copied from (the base,
|
|
4
|
+
* at the installed version) and the new source. Local filesystem only.
|
|
5
|
+
* Reads and writes use the same containment as `texuli add`.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, lstatSync } from 'node:fs';
|
|
8
|
+
import { mkdir, readdir, readFile, realpath, unlink, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
10
|
+
import { hasProvenance, provenanceHeader, provenanceSource } from "../cli-add/provenance.js";
|
|
11
|
+
import { getAddable, isPrimitivesPath, listAddableIds } from "../cli-add/registry.js";
|
|
12
|
+
import { assertNoSymlinkOnPath, assertSourceFileInside, resolveContainedOutDir, resolveContainedSourceDir, validateRegistryEntry, } from "../internal/paths.js";
|
|
13
|
+
import { merge3, unifiedDiff } from "./lines.js";
|
|
14
|
+
import { parseProvenance } from "./provenance.js";
|
|
15
|
+
const DEFAULT_VERSION = '0.0.0';
|
|
16
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']);
|
|
17
|
+
async function walk(dir, root = dir) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
20
|
+
const full = join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory()) {
|
|
22
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
23
|
+
out.push(...(await walk(full, root)));
|
|
24
|
+
}
|
|
25
|
+
else if (entry.isFile()) {
|
|
26
|
+
out.push(full);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
const toRel = (root, full) => relative(root, full).split(sep).join('/');
|
|
32
|
+
/** Read one component's source tree, contained like `texuli add`. */
|
|
33
|
+
async function readSource(root, entry, label) {
|
|
34
|
+
if (!existsSync(root))
|
|
35
|
+
return `${label} does not exist: ${root}`;
|
|
36
|
+
if (isPrimitivesPath(root))
|
|
37
|
+
return `${label} must not be the primitives package.`;
|
|
38
|
+
const resolved = resolveContainedSourceDir(root, entry);
|
|
39
|
+
if (!resolved.ok)
|
|
40
|
+
return resolved.error;
|
|
41
|
+
const dir = resolved.sourceDir;
|
|
42
|
+
if (isPrimitivesPath(dir))
|
|
43
|
+
return `Refusing to read primitives source for "${entry.id}".`;
|
|
44
|
+
const files = new Map();
|
|
45
|
+
if (!existsSync(dir))
|
|
46
|
+
return { dir, relDir: '', files };
|
|
47
|
+
for (const file of await walk(dir)) {
|
|
48
|
+
const inside = assertSourceFileInside(dir, file);
|
|
49
|
+
if (!inside.ok)
|
|
50
|
+
return inside.error;
|
|
51
|
+
files.set(toRel(dir, file), await readFile(inside.realFile, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
// Relative to the real root, so a symlinked root never stamps a parent path.
|
|
54
|
+
const relDir = toRel(await realpath(root), await realpath(dir));
|
|
55
|
+
for (const path of files.keys()) {
|
|
56
|
+
if (provenanceSource(relDir, path) === null) {
|
|
57
|
+
return `Provenance source would not be a plain relative path: ${relDir}/${path}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { dir, relDir, files };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Plan (and with `apply`, perform) an update. `diff` is this with
|
|
64
|
+
* `apply: false`.
|
|
65
|
+
*/
|
|
66
|
+
async function run(options, apply) {
|
|
67
|
+
const id = options.id.trim().toLowerCase();
|
|
68
|
+
const version = options.version ?? DEFAULT_VERSION;
|
|
69
|
+
const dryRun = !apply || Boolean(options.dryRun);
|
|
70
|
+
const mode = options.conflicts ?? 'refuse';
|
|
71
|
+
const result = {
|
|
72
|
+
ok: false,
|
|
73
|
+
id,
|
|
74
|
+
package: '',
|
|
75
|
+
outDir: resolve(options.cwd),
|
|
76
|
+
installedVersion: null,
|
|
77
|
+
version,
|
|
78
|
+
files: [],
|
|
79
|
+
conflicts: 0,
|
|
80
|
+
errors: [],
|
|
81
|
+
dryRun,
|
|
82
|
+
};
|
|
83
|
+
const fail = (error) => ({ ...result, errors: [...result.errors, error] });
|
|
84
|
+
if (!id)
|
|
85
|
+
return fail('Component id is required.');
|
|
86
|
+
const entry = getAddable(id, options.registry);
|
|
87
|
+
if (!entry) {
|
|
88
|
+
return fail(`Unknown or non-addable component "${id}". Known: ${listAddableIds(options.registry).join(', ') || '(none)'}.`);
|
|
89
|
+
}
|
|
90
|
+
const entryCheck = validateRegistryEntry(entry);
|
|
91
|
+
if (!entryCheck.ok)
|
|
92
|
+
return fail(entryCheck.error);
|
|
93
|
+
if (entry.package === '@texuli/primitives' || entry.package === 'primitives') {
|
|
94
|
+
return fail(`Refusing to update primitives package component "${id}".`);
|
|
95
|
+
}
|
|
96
|
+
result.package = entry.package;
|
|
97
|
+
const out = resolveContainedOutDir(options.cwd, options.outDir ?? join('src', 'components', 'texuli', id));
|
|
98
|
+
if (!out.ok)
|
|
99
|
+
return fail(out.error);
|
|
100
|
+
const outDir = out.outDir;
|
|
101
|
+
result.outDir = outDir;
|
|
102
|
+
const cwdReal = existsSync(options.cwd) ? await realpath(options.cwd) : resolve(options.cwd);
|
|
103
|
+
const upstream = await readSource(options.sourceRoot, entry, 'Source root');
|
|
104
|
+
if (typeof upstream === 'string')
|
|
105
|
+
return fail(upstream);
|
|
106
|
+
if (upstream.files.size === 0)
|
|
107
|
+
return fail(`No files found under ${upstream.dir}`);
|
|
108
|
+
let base = null;
|
|
109
|
+
if (options.baseRoot !== undefined) {
|
|
110
|
+
const read = await readSource(options.baseRoot, entry, 'Base root');
|
|
111
|
+
if (typeof read === 'string')
|
|
112
|
+
return fail(read);
|
|
113
|
+
base = read;
|
|
114
|
+
}
|
|
115
|
+
// Local files: never through a symlink; tracked ones carry this component's header.
|
|
116
|
+
const local = new Map();
|
|
117
|
+
if (existsSync(outDir)) {
|
|
118
|
+
const link = assertNoSymlinkOnPath(outDir, cwdReal);
|
|
119
|
+
if (!link.ok)
|
|
120
|
+
return fail(link.error);
|
|
121
|
+
for (const full of await walk(outDir)) {
|
|
122
|
+
if (lstatSync(full).isSymbolicLink())
|
|
123
|
+
return fail(`Refusing symlink in component: ${full}`);
|
|
124
|
+
const content = await readFile(full, 'utf8');
|
|
125
|
+
const parsed = parseProvenance(content);
|
|
126
|
+
const tracked = parsed !== null && parsed.meta.id === entry.id;
|
|
127
|
+
local.set(toRel(outDir, full), {
|
|
128
|
+
full: content,
|
|
129
|
+
body: tracked ? parsed.body : content,
|
|
130
|
+
version: tracked ? parsed.meta.version : '',
|
|
131
|
+
tracked,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const versions = new Set([...local.values()].filter((f) => f.tracked).map((f) => f.version));
|
|
136
|
+
if (versions.size > 1) {
|
|
137
|
+
return fail(`Local files carry different versions (${[...versions].sort().join(', ')}).`);
|
|
138
|
+
}
|
|
139
|
+
result.installedVersion = versions.size === 1 ? [...versions][0] : null;
|
|
140
|
+
if (options.baseVersion !== undefined &&
|
|
141
|
+
result.installedVersion !== null &&
|
|
142
|
+
result.installedVersion !== options.baseVersion) {
|
|
143
|
+
return fail(`Installed version is ${result.installedVersion}, but the base root is ${options.baseVersion}.`);
|
|
144
|
+
}
|
|
145
|
+
const labels = {
|
|
146
|
+
local: 'local',
|
|
147
|
+
base: `base ${result.installedVersion ?? '(unknown)'}`,
|
|
148
|
+
upstream: `${entry.package} ${version}`,
|
|
149
|
+
};
|
|
150
|
+
const stamp = (path, body) => {
|
|
151
|
+
if (hasProvenance(body))
|
|
152
|
+
return body;
|
|
153
|
+
const meta = {
|
|
154
|
+
id: entry.id,
|
|
155
|
+
package: entry.package,
|
|
156
|
+
version,
|
|
157
|
+
// Checked in readSource for every upstream path.
|
|
158
|
+
source: provenanceSource(upstream.relDir, path) ?? '',
|
|
159
|
+
};
|
|
160
|
+
return provenanceHeader(meta, path) + body;
|
|
161
|
+
};
|
|
162
|
+
const paths = new Set([
|
|
163
|
+
...upstream.files.keys(),
|
|
164
|
+
...(base ? base.files.keys() : []),
|
|
165
|
+
...local.keys(),
|
|
166
|
+
]);
|
|
167
|
+
const planned = [];
|
|
168
|
+
const plan = (path, status, content, conflicts = 0) => {
|
|
169
|
+
const to = resolve(outDir, path);
|
|
170
|
+
const before = local.get(path)?.full ?? '';
|
|
171
|
+
let action = 'none';
|
|
172
|
+
if (content === null && status === 'removed' && options.prune)
|
|
173
|
+
action = 'delete';
|
|
174
|
+
else if (content !== null && content !== before)
|
|
175
|
+
action = 'write';
|
|
176
|
+
if (dryRun && action !== 'none')
|
|
177
|
+
action = action === 'write' ? 'would-write' : 'would-delete';
|
|
178
|
+
const after = action === 'delete' || action === 'would-delete' ? '' : (content ?? before);
|
|
179
|
+
const diff = unifiedDiff(before, after, { from: `a/${path}`, to: `b/${path}` });
|
|
180
|
+
planned.push({ path, status, action, diff, conflicts, to, content });
|
|
181
|
+
};
|
|
182
|
+
for (const path of [...paths].sort()) {
|
|
183
|
+
const mine = local.get(path);
|
|
184
|
+
const theirs = upstream.files.get(path);
|
|
185
|
+
const origin = base?.files.get(path);
|
|
186
|
+
if (mine && !mine.tracked) {
|
|
187
|
+
plan(path, 'untracked', null);
|
|
188
|
+
}
|
|
189
|
+
else if (theirs !== undefined && !mine) {
|
|
190
|
+
if (origin !== undefined && result.installedVersion !== null)
|
|
191
|
+
plan(path, 'deleted-locally', null);
|
|
192
|
+
else
|
|
193
|
+
plan(path, 'added', stamp(path, theirs));
|
|
194
|
+
}
|
|
195
|
+
else if (theirs !== undefined && mine) {
|
|
196
|
+
if (mine.body === theirs)
|
|
197
|
+
plan(path, 'unchanged', stamp(path, theirs));
|
|
198
|
+
else if (!base)
|
|
199
|
+
plan(path, 'needs-base', null);
|
|
200
|
+
else if (origin === mine.body)
|
|
201
|
+
plan(path, 'fast-forward', stamp(path, theirs));
|
|
202
|
+
else if (origin === theirs)
|
|
203
|
+
plan(path, 'kept-local', stamp(path, mine.body));
|
|
204
|
+
else {
|
|
205
|
+
const merged = merge3(origin ?? '', mine.body, theirs, labels);
|
|
206
|
+
plan(path, merged.conflicts > 0 ? 'conflict' : 'merged', stamp(path, merged.text), merged.conflicts);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else if (mine) {
|
|
210
|
+
const untouched = base !== null && origin === mine.body;
|
|
211
|
+
plan(path, untouched ? 'removed' : 'removed-modified', null);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
result.files = planned.map(({ to: _to, content: _content, ...file }) => file);
|
|
215
|
+
result.conflicts = planned.reduce((sum, file) => sum + file.conflicts, 0);
|
|
216
|
+
const blocked = planned.filter((f) => f.status === 'needs-base').map((f) => f.path);
|
|
217
|
+
if (apply && blocked.length > 0) {
|
|
218
|
+
result.errors.push(`Local edits cannot be merged without the installed source (--base-root): ${blocked.join(', ')}`);
|
|
219
|
+
}
|
|
220
|
+
if (apply && result.conflicts > 0 && mode === 'refuse') {
|
|
221
|
+
result.errors.push(`${result.conflicts} conflict(s); nothing was written. Resolve them, or pass --conflicts markers.`);
|
|
222
|
+
}
|
|
223
|
+
result.ok = result.errors.length === 0;
|
|
224
|
+
if (!result.ok || dryRun) {
|
|
225
|
+
if (!result.ok)
|
|
226
|
+
result.files = result.files.map((f) => ({ ...f, action: 'none' }));
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
for (const file of planned) {
|
|
230
|
+
if (file.action === 'none')
|
|
231
|
+
continue;
|
|
232
|
+
const link = assertNoSymlinkOnPath(file.to, cwdReal);
|
|
233
|
+
if (!link.ok)
|
|
234
|
+
return { ...result, ok: false, errors: [link.error] };
|
|
235
|
+
if (file.action === 'delete') {
|
|
236
|
+
await unlink(file.to);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
await mkdir(dirname(file.to), { recursive: true });
|
|
240
|
+
const after = assertNoSymlinkOnPath(file.to, cwdReal);
|
|
241
|
+
if (!after.ok)
|
|
242
|
+
return { ...result, ok: false, errors: [after.error] };
|
|
243
|
+
await writeFile(file.to, file.content ?? '', 'utf8');
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Show what `texuli update` would do: each file's status and a unified diff
|
|
249
|
+
* from the local file to its result. Writes nothing.
|
|
250
|
+
*/
|
|
251
|
+
export function diffComponent(options) {
|
|
252
|
+
return run(options, false);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Update a copied component to the new source with a three-way merge.
|
|
256
|
+
* Writes nothing when any file conflicts (unless `conflicts: 'markers'`) or
|
|
257
|
+
* needs a base that was not given.
|
|
258
|
+
*/
|
|
259
|
+
export function updateComponent(options) {
|
|
260
|
+
return run(options, true);
|
|
261
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type RenderCatalog, type RenderComponentSpec } from '@texuli/catalog/render/renderer';
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
/**
|
|
4
|
+
* The content preset (T-tool-elements): one lazy loader per catalog content
|
|
5
|
+
* component (blocks, charts, media), and the render catalog of their
|
|
6
|
+
* manifests. Each loader is its own dynamic import, so a page pays only for
|
|
7
|
+
* the components its trees use.
|
|
8
|
+
*/
|
|
9
|
+
export type ElementComponent = Component<any>;
|
|
10
|
+
export type ElementComponentLoader = () => Promise<ElementComponent>;
|
|
11
|
+
export declare const CONTENT_ELEMENT_LOADERS: Readonly<Record<string, ElementComponentLoader>>;
|
|
12
|
+
/** Render specs of the content manifests (labels, prop schemas, emit tiers). */
|
|
13
|
+
export declare function contentRenderSpecs(): RenderComponentSpec[];
|
|
14
|
+
/** The render catalog the content elements validate trees against. */
|
|
15
|
+
export declare function contentRenderCatalog(): RenderCatalog;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { CONTENT_MANIFESTS } from '@texuli/catalog/manifests/content';
|
|
2
|
+
import { renderCatalogFromSpecs, } from '@texuli/catalog/render/renderer';
|
|
3
|
+
export const CONTENT_ELEMENT_LOADERS = Object.freeze({
|
|
4
|
+
'blocks-text': async () => (await (await import('@texuli/blocks/text')).loadTextFamily()).default,
|
|
5
|
+
'blocks-data': async () => (await (await import('@texuli/blocks/data')).loadDataFamily()).default,
|
|
6
|
+
'blocks-diagram': async () => (await (await import('@texuli/blocks/diagram')).loadDiagramFamily()).default,
|
|
7
|
+
'blocks-media': async () => (await (await import('@texuli/blocks/media')).loadMediaFamily()).default,
|
|
8
|
+
'blocks-embed': async () => (await (await import('@texuli/blocks/embed')).loadEmbedFamily()).default,
|
|
9
|
+
'blocks-structure': async () => (await (await import('@texuli/blocks/structure')).loadStructureFamily()).default,
|
|
10
|
+
'blocks-code': async () => (await (await import('@texuli/blocks/code')).loadCodeFamily()).default,
|
|
11
|
+
'blocks-interactive': async () => (await (await import('@texuli/blocks/interactive')).loadInteractiveFamily()).default,
|
|
12
|
+
'blocks-map': async () => (await (await import('@texuli/blocks/map')).loadMapFamily()).default,
|
|
13
|
+
'charts-area': async () => (await import('@texuli/charts/cartesian')).AreaChart,
|
|
14
|
+
'charts-bar': async () => (await import('@texuli/charts/cartesian')).BarChart,
|
|
15
|
+
'charts-cartesian': async () => (await import('@texuli/charts/cartesian')).CartesianChart,
|
|
16
|
+
'charts-combo': async () => (await import('@texuli/charts/cartesian')).ComboChart,
|
|
17
|
+
'charts-line': async () => (await import('@texuli/charts/cartesian')).LineChart,
|
|
18
|
+
'charts-stacked-bar': async () => (await import('@texuli/charts/cartesian')).StackedBarChart,
|
|
19
|
+
'charts-bullet': async () => (await import('@texuli/charts/compact')).BulletChart,
|
|
20
|
+
'charts-gauge': async () => (await import('@texuli/charts/compact')).Gauge,
|
|
21
|
+
'charts-progress-ring': async () => (await import('@texuli/charts/compact')).ProgressRing,
|
|
22
|
+
'charts-sparkline': async () => (await import('@texuli/charts/compact')).Sparkline,
|
|
23
|
+
'charts-canvas-renderer': async () => (await import('@texuli/charts/canvas')).CanvasRenderer,
|
|
24
|
+
'charts-funnel': async () => (await import('@texuli/charts/flow')).FunnelChart,
|
|
25
|
+
'charts-network': async () => (await import('@texuli/charts/flow')).NetworkGraph,
|
|
26
|
+
'charts-sankey': async () => (await import('@texuli/charts/flow')).SankeyChart,
|
|
27
|
+
'charts-geo-map': async () => (await import('@texuli/charts/geo')).GeoMap,
|
|
28
|
+
'charts-geo-point-map': async () => (await import('@texuli/charts/geo')).GeoPointMap,
|
|
29
|
+
'charts-bubble': async () => (await import('@texuli/charts/matrix')).BubbleChart,
|
|
30
|
+
'charts-heatmap': async () => (await import('@texuli/charts/matrix')).HeatmapChart,
|
|
31
|
+
'charts-scatter': async () => (await import('@texuli/charts/matrix')).ScatterChart,
|
|
32
|
+
'charts-donut': async () => (await import('@texuli/charts/part-whole')).DonutChart,
|
|
33
|
+
'charts-pie': async () => (await import('@texuli/charts/part-whole')).PieChart,
|
|
34
|
+
'charts-sunburst': async () => (await import('@texuli/charts/part-whole')).SunburstChart,
|
|
35
|
+
'charts-treemap': async () => (await import('@texuli/charts/part-whole')).TreemapChart,
|
|
36
|
+
'media-image': async () => (await import('@texuli/media/image')).Image,
|
|
37
|
+
'media-composition-svg': async () => (await import('@texuli/media/composition-svg')).CompositionSvg,
|
|
38
|
+
'media-composition-canvas': async () => (await import('@texuli/media/composition-canvas')).CompositionCanvas,
|
|
39
|
+
});
|
|
40
|
+
/** Render specs of the content manifests (labels, prop schemas, emit tiers). */
|
|
41
|
+
export function contentRenderSpecs() {
|
|
42
|
+
return CONTENT_MANIFESTS.map((manifest) => ({
|
|
43
|
+
id: manifest.id,
|
|
44
|
+
label: manifest.label,
|
|
45
|
+
props: manifest.props,
|
|
46
|
+
agentEmittable: manifest.agentEmittable === true,
|
|
47
|
+
emit: manifest.agentEmittable === true ? ['developer', 'untrusted'] : ['developer'],
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
/** The render catalog the content elements validate trees against. */
|
|
51
|
+
export function contentRenderCatalog() {
|
|
52
|
+
return renderCatalogFromSpecs(contentRenderSpecs());
|
|
53
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type RenderCatalog, type RenderTier } from '@texuli/catalog/render/renderer';
|
|
2
|
+
import { type ElementComponentLoader } from './content.js';
|
|
3
|
+
/**
|
|
4
|
+
* `@texuli/tools/elements` (T-tool-elements): light-DOM custom elements so a
|
|
5
|
+
* non-Svelte host (plain HTML, React, Vue, a server template) can render
|
|
6
|
+
* catalog trees with the real Texuli content components.
|
|
7
|
+
*
|
|
8
|
+
* - `<tx-catalog>` takes an untrusted catalog tree (the `tree` property, a
|
|
9
|
+
* `tree` JSON attribute, or a child `<script type="application/json">`),
|
|
10
|
+
* checks it against the tree limits and the sink-free renderer, and builds
|
|
11
|
+
* one `<tx-{id}>` element per component, or a labelled placeholder.
|
|
12
|
+
* - `<tx-{id}>` (one per content component) mounts the Svelte component in
|
|
13
|
+
* its own light DOM, so page styles and the Texuli CSS layers apply.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here writes markup from data: elements are created with
|
|
16
|
+
* `createElement`, text with `textContent`, and every props bag passes the
|
|
17
|
+
* catalog renderer (unknown ids, forbidden props and HTML strings never
|
|
18
|
+
* reach a component). The render tier is fixed when the elements are
|
|
19
|
+
* defined; markup cannot raise it.
|
|
20
|
+
*/
|
|
21
|
+
export declare const ELEMENT_ID_PATTERN: RegExp;
|
|
22
|
+
export interface DefineElementsOptions {
|
|
23
|
+
/** id → lazy Svelte component. Default: the content preset. */
|
|
24
|
+
loaders?: Readonly<Record<string, ElementComponentLoader>>;
|
|
25
|
+
/** Render catalog the trees and props are checked against. Default: content manifests. */
|
|
26
|
+
catalog?: RenderCatalog;
|
|
27
|
+
/** Who authors the trees. Default `untrusted` (agent content). */
|
|
28
|
+
tier?: RenderTier;
|
|
29
|
+
/** Tag prefix: `tx` gives `<tx-catalog>` and `<tx-blocks-text>`. */
|
|
30
|
+
prefix?: string;
|
|
31
|
+
/** Registry to define into (a window's `customElements`). */
|
|
32
|
+
registry?: CustomElementRegistry;
|
|
33
|
+
/** The window whose `HTMLElement` the classes extend. */
|
|
34
|
+
window?: Window & typeof globalThis;
|
|
35
|
+
}
|
|
36
|
+
export interface DefinedElements {
|
|
37
|
+
catalogTag: string;
|
|
38
|
+
/** id → tag of every component element. */
|
|
39
|
+
componentTags: Readonly<Record<string, string>>;
|
|
40
|
+
}
|
|
41
|
+
/** The custom element tag for a catalog id, or `null` for an invalid id. */
|
|
42
|
+
export declare function elementTagFor(id: string, prefix?: string): string | null;
|
|
43
|
+
/** Parses a JSON attribute or script body; anything invalid is `undefined`. */
|
|
44
|
+
export declare function parseJsonData(text: string | null | undefined): unknown;
|
|
45
|
+
/**
|
|
46
|
+
* Defines `<{prefix}-catalog>` and one `<{prefix}-{id}>` per loader. Safe to
|
|
47
|
+
* call again: tags already defined are kept as they are.
|
|
48
|
+
*/
|
|
49
|
+
export declare function defineTexuliElements(options?: DefineElementsOptions): DefinedElements;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { renderCatalogTreeWithLimits } from '@texuli/catalog/render/limits';
|
|
2
|
+
import { renderCatalogTree, } from '@texuli/catalog/render/renderer';
|
|
3
|
+
import { mount, unmount } from 'svelte';
|
|
4
|
+
import { CONTENT_ELEMENT_LOADERS, contentRenderCatalog, } from "./content.js";
|
|
5
|
+
/**
|
|
6
|
+
* `@texuli/tools/elements` (T-tool-elements): light-DOM custom elements so a
|
|
7
|
+
* non-Svelte host (plain HTML, React, Vue, a server template) can render
|
|
8
|
+
* catalog trees with the real Texuli content components.
|
|
9
|
+
*
|
|
10
|
+
* - `<tx-catalog>` takes an untrusted catalog tree (the `tree` property, a
|
|
11
|
+
* `tree` JSON attribute, or a child `<script type="application/json">`),
|
|
12
|
+
* checks it against the tree limits and the sink-free renderer, and builds
|
|
13
|
+
* one `<tx-{id}>` element per component, or a labelled placeholder.
|
|
14
|
+
* - `<tx-{id}>` (one per content component) mounts the Svelte component in
|
|
15
|
+
* its own light DOM, so page styles and the Texuli CSS layers apply.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here writes markup from data: elements are created with
|
|
18
|
+
* `createElement`, text with `textContent`, and every props bag passes the
|
|
19
|
+
* catalog renderer (unknown ids, forbidden props and HTML strings never
|
|
20
|
+
* reach a component). The render tier is fixed when the elements are
|
|
21
|
+
* defined; markup cannot raise it.
|
|
22
|
+
*/
|
|
23
|
+
export const ELEMENT_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
24
|
+
const PREFIX_PATTERN = /^[a-z][a-z0-9]*$/;
|
|
25
|
+
/** The custom element tag for a catalog id, or `null` for an invalid id. */
|
|
26
|
+
export function elementTagFor(id, prefix = 'tx') {
|
|
27
|
+
if (!PREFIX_PATTERN.test(prefix) || !ELEMENT_ID_PATTERN.test(id))
|
|
28
|
+
return null;
|
|
29
|
+
return `${prefix}-${id}`;
|
|
30
|
+
}
|
|
31
|
+
/** Parses a JSON attribute or script body; anything invalid is `undefined`. */
|
|
32
|
+
export function parseJsonData(text) {
|
|
33
|
+
if (typeof text !== 'string' || text.trim() === '')
|
|
34
|
+
return undefined;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(text);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function placeholder(doc, label, reason) {
|
|
43
|
+
const note = doc.createElement('p');
|
|
44
|
+
note.className = 'tx-element-placeholder';
|
|
45
|
+
note.setAttribute('role', 'note');
|
|
46
|
+
note.dataset.reason = reason;
|
|
47
|
+
note.textContent = label;
|
|
48
|
+
return note;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Defines `<{prefix}-catalog>` and one `<{prefix}-{id}>` per loader. Safe to
|
|
52
|
+
* call again: tags already defined are kept as they are.
|
|
53
|
+
*/
|
|
54
|
+
export function defineTexuliElements(options = {}) {
|
|
55
|
+
const win = options.window ?? globalThis;
|
|
56
|
+
const registry = options.registry ?? win.customElements;
|
|
57
|
+
const prefix = options.prefix ?? 'tx';
|
|
58
|
+
if (!PREFIX_PATTERN.test(prefix))
|
|
59
|
+
throw new TypeError(`Invalid element prefix: ${prefix}`);
|
|
60
|
+
if (!registry || typeof win.HTMLElement !== 'function') {
|
|
61
|
+
throw new TypeError('Custom elements are not available in this environment.');
|
|
62
|
+
}
|
|
63
|
+
const loaders = options.loaders ?? CONTENT_ELEMENT_LOADERS;
|
|
64
|
+
const catalog = options.catalog ?? contentRenderCatalog();
|
|
65
|
+
const tier = options.tier === 'developer' ? 'developer' : 'untrusted';
|
|
66
|
+
const componentTags = {};
|
|
67
|
+
for (const id of Object.keys(loaders)) {
|
|
68
|
+
const tag = elementTagFor(id, prefix);
|
|
69
|
+
if (!tag)
|
|
70
|
+
continue;
|
|
71
|
+
componentTags[id] = tag;
|
|
72
|
+
}
|
|
73
|
+
const cache = new Map();
|
|
74
|
+
const load = (id) => {
|
|
75
|
+
let pending = cache.get(id);
|
|
76
|
+
if (!pending) {
|
|
77
|
+
const loader = loaders[id];
|
|
78
|
+
pending = loader ? loader() : Promise.reject(new Error(`No loader for ${id}`));
|
|
79
|
+
// A failed load may be retried on the next render.
|
|
80
|
+
pending.catch(() => cache.delete(id));
|
|
81
|
+
cache.set(id, pending);
|
|
82
|
+
}
|
|
83
|
+
return pending;
|
|
84
|
+
};
|
|
85
|
+
class ComponentElement extends win.HTMLElement {
|
|
86
|
+
static observedAttributes = ['props'];
|
|
87
|
+
/** The catalog id; set by the subclass per tag. */
|
|
88
|
+
static componentId = '';
|
|
89
|
+
#props = undefined;
|
|
90
|
+
#instance;
|
|
91
|
+
#renderToken = 0;
|
|
92
|
+
#diagnostics = [];
|
|
93
|
+
get componentId() {
|
|
94
|
+
return this.constructor.componentId;
|
|
95
|
+
}
|
|
96
|
+
/** The component's props: plain data, validated against its manifest. */
|
|
97
|
+
get props() {
|
|
98
|
+
return this.#props;
|
|
99
|
+
}
|
|
100
|
+
set props(value) {
|
|
101
|
+
this.#props = value;
|
|
102
|
+
if (this.isConnected)
|
|
103
|
+
void this.#render();
|
|
104
|
+
}
|
|
105
|
+
/** Why the last props were refused or changed (empty when they were fine). */
|
|
106
|
+
get diagnostics() {
|
|
107
|
+
return this.#diagnostics;
|
|
108
|
+
}
|
|
109
|
+
attributeChangedCallback(name, _old, value) {
|
|
110
|
+
if (name === 'props')
|
|
111
|
+
this.props = parseJsonData(value);
|
|
112
|
+
}
|
|
113
|
+
connectedCallback() {
|
|
114
|
+
void this.#render();
|
|
115
|
+
}
|
|
116
|
+
disconnectedCallback() {
|
|
117
|
+
this.#renderToken += 1;
|
|
118
|
+
this.#teardown();
|
|
119
|
+
}
|
|
120
|
+
#teardown() {
|
|
121
|
+
if (this.#instance) {
|
|
122
|
+
void unmount(this.#instance);
|
|
123
|
+
this.#instance = undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async #render() {
|
|
127
|
+
const token = ++this.#renderToken;
|
|
128
|
+
const id = this.componentId;
|
|
129
|
+
const result = renderCatalogTree({ id, props: this.#props ?? {} }, { catalog, tier });
|
|
130
|
+
this.#diagnostics = result.diagnostics;
|
|
131
|
+
const node = result.roots[0];
|
|
132
|
+
this.#teardown();
|
|
133
|
+
if (node?.kind !== 'component') {
|
|
134
|
+
const reason = node?.kind === 'placeholder' ? node.reason : 'invalid-props';
|
|
135
|
+
const label = node?.kind === 'placeholder' ? node.label : (catalog.get(id)?.label ?? id);
|
|
136
|
+
this.dataset.state = 'invalid';
|
|
137
|
+
this.replaceChildren(placeholder(this.ownerDocument, label, reason));
|
|
138
|
+
this.dispatchEvent(new CustomEvent('tx-invalid', { detail: result.diagnostics }));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.dataset.state = 'loading';
|
|
142
|
+
this.setAttribute('aria-busy', 'true');
|
|
143
|
+
let Component;
|
|
144
|
+
try {
|
|
145
|
+
Component = await load(id);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
if (token !== this.#renderToken)
|
|
149
|
+
return;
|
|
150
|
+
this.removeAttribute('aria-busy');
|
|
151
|
+
this.dataset.state = 'error';
|
|
152
|
+
this.replaceChildren(placeholder(this.ownerDocument, catalog.get(id)?.label ?? id, 'load-error'));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (token !== this.#renderToken || !this.isConnected)
|
|
156
|
+
return;
|
|
157
|
+
const target = this.ownerDocument.createElement('div');
|
|
158
|
+
target.dataset.txElementRoot = '';
|
|
159
|
+
this.replaceChildren(target);
|
|
160
|
+
const props = { ...node.props };
|
|
161
|
+
// Validation callbacks become DOM events: trees carry data, never functions.
|
|
162
|
+
if (catalog.get(id)?.props.some((prop) => prop.name === 'oninvalid')) {
|
|
163
|
+
props.oninvalid = (issues) => this.dispatchEvent(new CustomEvent('tx-invalid', { detail: issues }));
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
this.#instance = mount(Component, { target, props });
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
// A component that refuses its props (a chart with an invalid series)
|
|
170
|
+
// must not break the page: show its placeholder and report why.
|
|
171
|
+
this.removeAttribute('aria-busy');
|
|
172
|
+
this.dataset.state = 'invalid';
|
|
173
|
+
this.replaceChildren(placeholder(this.ownerDocument, catalog.get(id)?.label ?? id, 'render-error'));
|
|
174
|
+
this.dispatchEvent(new CustomEvent('tx-invalid', {
|
|
175
|
+
detail: [
|
|
176
|
+
{
|
|
177
|
+
code: 'render-error',
|
|
178
|
+
message: error instanceof Error ? error.message : String(error),
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
}));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.removeAttribute('aria-busy');
|
|
185
|
+
this.dataset.state = 'ready';
|
|
186
|
+
this.dispatchEvent(new CustomEvent('tx-ready'));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
class CatalogElement extends win.HTMLElement {
|
|
190
|
+
static observedAttributes = ['tree'];
|
|
191
|
+
#tree = undefined;
|
|
192
|
+
#diagnostics = [];
|
|
193
|
+
/** The catalog tree (one node or a list of nodes): untrusted data. */
|
|
194
|
+
get tree() {
|
|
195
|
+
return this.#tree;
|
|
196
|
+
}
|
|
197
|
+
set tree(value) {
|
|
198
|
+
this.#tree = value;
|
|
199
|
+
if (this.isConnected)
|
|
200
|
+
this.#render();
|
|
201
|
+
}
|
|
202
|
+
/** Why nodes were dropped, replaced or refused in the last render. */
|
|
203
|
+
get diagnostics() {
|
|
204
|
+
return this.#diagnostics;
|
|
205
|
+
}
|
|
206
|
+
attributeChangedCallback(name, _old, value) {
|
|
207
|
+
if (name === 'tree')
|
|
208
|
+
this.tree = parseJsonData(value);
|
|
209
|
+
}
|
|
210
|
+
connectedCallback() {
|
|
211
|
+
if (this.#tree === undefined) {
|
|
212
|
+
const script = this.querySelector(':scope > script[type="application/json"]');
|
|
213
|
+
if (script)
|
|
214
|
+
this.#tree = parseJsonData(script.textContent);
|
|
215
|
+
}
|
|
216
|
+
this.#render();
|
|
217
|
+
}
|
|
218
|
+
#render() {
|
|
219
|
+
const doc = this.ownerDocument;
|
|
220
|
+
const input = this.#tree;
|
|
221
|
+
const nodes = Array.isArray(input)
|
|
222
|
+
? input
|
|
223
|
+
: input === undefined
|
|
224
|
+
? []
|
|
225
|
+
: [input];
|
|
226
|
+
const roots = [];
|
|
227
|
+
const diagnostics = [];
|
|
228
|
+
let limited = false;
|
|
229
|
+
nodes.forEach((node, index) => {
|
|
230
|
+
const result = renderCatalogTreeWithLimits(node, {
|
|
231
|
+
catalog,
|
|
232
|
+
tier,
|
|
233
|
+
enforceRenderTime: false,
|
|
234
|
+
});
|
|
235
|
+
if (!result.withinLimits)
|
|
236
|
+
limited = true;
|
|
237
|
+
roots.push(...result.roots);
|
|
238
|
+
diagnostics.push(...result.diagnostics.map((d) => ({ ...d, path: `${index}.${d.path}` })));
|
|
239
|
+
});
|
|
240
|
+
this.#diagnostics = diagnostics;
|
|
241
|
+
const fragment = doc.createDocumentFragment();
|
|
242
|
+
for (const node of roots)
|
|
243
|
+
fragment.append(this.#build(node));
|
|
244
|
+
this.replaceChildren(fragment);
|
|
245
|
+
this.dataset.state = limited ? 'over-limits' : 'ready';
|
|
246
|
+
this.dispatchEvent(new CustomEvent('tx-render', { detail: { diagnostics, rendered: roots.length } }));
|
|
247
|
+
}
|
|
248
|
+
#build(node) {
|
|
249
|
+
const doc = this.ownerDocument;
|
|
250
|
+
if (node.kind === 'placeholder')
|
|
251
|
+
return placeholder(doc, node.label, node.reason);
|
|
252
|
+
const tag = componentTags[node.id];
|
|
253
|
+
if (!tag)
|
|
254
|
+
return placeholder(doc, catalog.get(node.id)?.label ?? node.id, 'no-element');
|
|
255
|
+
const element = doc.createElement(tag);
|
|
256
|
+
element.props = node.props;
|
|
257
|
+
if (node.children.length === 0)
|
|
258
|
+
return element;
|
|
259
|
+
// Content components take no DOM children; nested nodes follow in order.
|
|
260
|
+
const group = doc.createElement('div');
|
|
261
|
+
group.dataset.txElementGroup = node.id;
|
|
262
|
+
const children = doc.createElement('div');
|
|
263
|
+
children.dataset.txElementChildren = '';
|
|
264
|
+
for (const child of node.children)
|
|
265
|
+
children.append(this.#build(child));
|
|
266
|
+
group.append(element, children);
|
|
267
|
+
return group;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const catalogTag = `${prefix}-catalog`;
|
|
271
|
+
if (!registry.get(catalogTag))
|
|
272
|
+
registry.define(catalogTag, CatalogElement);
|
|
273
|
+
for (const [id, tag] of Object.entries(componentTags)) {
|
|
274
|
+
if (registry.get(tag))
|
|
275
|
+
continue;
|
|
276
|
+
registry.define(tag, class extends ComponentElement {
|
|
277
|
+
static componentId = id;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return { catalogTag, componentTags: Object.freeze({ ...componentTags }) };
|
|
281
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@texuli/tools/elements` — light-DOM custom elements for the catalog
|
|
3
|
+
* content components (T-tool-elements). See `docs/elements.md`.
|
|
4
|
+
*/
|
|
5
|
+
export { CONTENT_ELEMENT_LOADERS, contentRenderCatalog, contentRenderSpecs, type ElementComponent, type ElementComponentLoader, } from './content.js';
|
|
6
|
+
export { type DefinedElements, type DefineElementsOptions, defineTexuliElements, ELEMENT_ID_PATTERN, elementTagFor, parseJsonData, } from './define.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@texuli/tools/elements` — light-DOM custom elements for the catalog
|
|
3
|
+
* content components (T-tool-elements). See `docs/elements.md`.
|
|
4
|
+
*/
|
|
5
|
+
export { CONTENT_ELEMENT_LOADERS, contentRenderCatalog, contentRenderSpecs, } from "./content.js";
|
|
6
|
+
export { defineTexuliElements, ELEMENT_ID_PATTERN, elementTagFor, parseJsonData, } from "./define.js";
|