@democraft/compiler 0.1.0-beta.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 +8 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +547 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Democraft contributors
|
|
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
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# @democraft/compiler
|
|
2
|
+
|
|
3
|
+
Captures authored demos into normalized JSON-compatible IR, validates them, and renders readable inspection text.
|
|
4
|
+
|
|
5
|
+
`compileDemo` remains the compatibility API and returns the IR, compiled config,
|
|
6
|
+
and diagnostics. New library integrations can use `compileDemoResult`, which
|
|
7
|
+
returns `OperationResult<CompiledDemo>` and omits `value` when error diagnostics
|
|
8
|
+
are present. Neither entry point exits the process.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { DemoConfig, DemoDefinition } from '@democraft/core';
|
|
2
|
+
import { OperationResult, DemoIR, Diagnostic } from '@democraft/schema';
|
|
3
|
+
|
|
4
|
+
type CompilationResult = {
|
|
5
|
+
ir: DemoIR;
|
|
6
|
+
config: Pick<DemoConfig, "fps">;
|
|
7
|
+
diagnostics: Diagnostic[];
|
|
8
|
+
};
|
|
9
|
+
type CompiledDemo = Omit<CompilationResult, "diagnostics">;
|
|
10
|
+
type CompilationOperationResult = OperationResult<CompiledDemo>;
|
|
11
|
+
|
|
12
|
+
declare function compileDemo(definition: DemoDefinition): Promise<CompilationResult>;
|
|
13
|
+
declare function compileDemoResult(definition: DemoDefinition): Promise<CompilationOperationResult>;
|
|
14
|
+
|
|
15
|
+
declare function validateIR(ir: DemoIR): Diagnostic[];
|
|
16
|
+
|
|
17
|
+
declare function inspectIR(ir: DemoIR): string;
|
|
18
|
+
|
|
19
|
+
declare function parseDurationMs(duration: string): number | null;
|
|
20
|
+
|
|
21
|
+
declare const DEFINITION_HASH_PREFIX = "definition-v1:sha256:";
|
|
22
|
+
declare const CAPTURE_HASH_PREFIX = "capture-v1:sha256:";
|
|
23
|
+
/**
|
|
24
|
+
* Returns the deterministic JSON payload used by the definition hash.
|
|
25
|
+
*
|
|
26
|
+
* The human id, schema version, and an existing hash are intentionally
|
|
27
|
+
* excluded. Object keys are sorted recursively while array order is retained.
|
|
28
|
+
*/
|
|
29
|
+
declare function canonicalizeDefinition(ir: Pick<DemoIR, "title" | "source" | "targets" | "scenes" | "visuals">): string;
|
|
30
|
+
declare function createDefinitionHash(ir: Pick<DemoIR, "title" | "source" | "targets" | "scenes" | "visuals">): string;
|
|
31
|
+
/**
|
|
32
|
+
* Canonical projection of fields that can affect Playwright actions,
|
|
33
|
+
* screenshots, target snapshots, timings, or capture diagnostics.
|
|
34
|
+
*
|
|
35
|
+
* Only fields proven to be presentation-only are excluded. The versioned
|
|
36
|
+
* prefix must change whenever capture runtime semantics change this boundary.
|
|
37
|
+
*/
|
|
38
|
+
declare function canonicalizeCaptureDefinition(ir: Pick<DemoIR, "source" | "targets" | "scenes">): string;
|
|
39
|
+
declare function createCaptureHash(ir: Pick<DemoIR, "source" | "targets" | "scenes">): string;
|
|
40
|
+
|
|
41
|
+
export { CAPTURE_HASH_PREFIX, type CompilationOperationResult, type CompilationResult, type CompiledDemo, DEFINITION_HASH_PREFIX, canonicalizeCaptureDefinition, canonicalizeDefinition, compileDemo, compileDemoResult, createCaptureHash, createDefinitionHash, inspectIR, parseDurationMs, validateIR };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
// src/compile.ts
|
|
2
|
+
import {
|
|
3
|
+
diagnosticDocsUrl as diagnosticDocsUrl3,
|
|
4
|
+
schemaVersion
|
|
5
|
+
} from "@democraft/schema";
|
|
6
|
+
|
|
7
|
+
// src/capture.ts
|
|
8
|
+
function createSceneCapture(steps) {
|
|
9
|
+
const push = (step) => {
|
|
10
|
+
steps.push(step);
|
|
11
|
+
return Promise.resolve();
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
goto: (path, options) => push({ kind: "browser.goto", id: options?.id, path }),
|
|
15
|
+
click: (target, options) => push({ kind: "browser.click", id: options?.id, target }),
|
|
16
|
+
fill: (target, value, options) => push({ kind: "browser.fill", id: options?.id, target, value }),
|
|
17
|
+
select: (target, value, options) => push({ kind: "browser.select", id: options?.id, target, value }),
|
|
18
|
+
expectVisible: (target, options) => push({ kind: "assert.visible", id: options?.id, target }),
|
|
19
|
+
expectText: (target, text, options) => push({ kind: "assert.text", id: options?.id, target, text }),
|
|
20
|
+
expectUrl: (path, options) => push({ kind: "assert.url", id: options?.id, path }),
|
|
21
|
+
establish: (target, options) => push({ kind: "camera.establish", id: options?.id, target }),
|
|
22
|
+
focus: (target, options) => push({
|
|
23
|
+
kind: "camera.focus",
|
|
24
|
+
id: options?.id,
|
|
25
|
+
target,
|
|
26
|
+
padding: options?.padding
|
|
27
|
+
}),
|
|
28
|
+
hold: (duration, options) => push({ kind: "timeline.hold", id: options?.id, duration }),
|
|
29
|
+
transition: (options) => push({
|
|
30
|
+
kind: "timeline.transition",
|
|
31
|
+
id: options?.id,
|
|
32
|
+
transition: options?.type,
|
|
33
|
+
duration: options?.duration
|
|
34
|
+
}),
|
|
35
|
+
caption: (text, options) => push({
|
|
36
|
+
kind: "overlay.caption",
|
|
37
|
+
id: options?.id,
|
|
38
|
+
text,
|
|
39
|
+
renderer: options?.renderer
|
|
40
|
+
}),
|
|
41
|
+
callout: (target, options) => push({
|
|
42
|
+
kind: "overlay.callout",
|
|
43
|
+
id: options.id,
|
|
44
|
+
target,
|
|
45
|
+
title: options.title,
|
|
46
|
+
description: options.description,
|
|
47
|
+
renderer: options.renderer
|
|
48
|
+
}),
|
|
49
|
+
visual: (visual, props, options) => push({
|
|
50
|
+
kind: "overlay.visual",
|
|
51
|
+
id: options?.id,
|
|
52
|
+
visual,
|
|
53
|
+
props,
|
|
54
|
+
duration: options?.duration
|
|
55
|
+
}),
|
|
56
|
+
cue: (name, options) => push({ kind: "cue", id: options?.id, name })
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/normalize.ts
|
|
61
|
+
import {
|
|
62
|
+
diagnosticDocsUrl
|
|
63
|
+
} from "@democraft/schema";
|
|
64
|
+
|
|
65
|
+
// src/duration.ts
|
|
66
|
+
function parseDurationMs(duration) {
|
|
67
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(duration);
|
|
68
|
+
if (!match) return null;
|
|
69
|
+
const value = Number(match[1]);
|
|
70
|
+
if (!Number.isFinite(value) || value < 0) return null;
|
|
71
|
+
return match[2] === "s" ? Math.round(value * 1e3) : Math.round(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/normalize.ts
|
|
75
|
+
function normalizeScene(demoId, scene, diagnostics) {
|
|
76
|
+
return {
|
|
77
|
+
id: scene.id,
|
|
78
|
+
purpose: scene.purpose,
|
|
79
|
+
pacing: scene.pacing ?? "normal",
|
|
80
|
+
importance: scene.importance ?? "primary",
|
|
81
|
+
steps: scene.steps.map(
|
|
82
|
+
(step, index) => normalizeStep(demoId, scene.id, step, index, diagnostics)
|
|
83
|
+
)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function normalizeStep(demoId, sceneId, step, index, diagnostics) {
|
|
87
|
+
const id = step.id ?? `${sceneId}.${slugStep(step)}.${index + 1}`;
|
|
88
|
+
switch (step.kind) {
|
|
89
|
+
case "timeline.hold": {
|
|
90
|
+
const durationMs = parseDurationMs(step.duration);
|
|
91
|
+
if (durationMs === null) {
|
|
92
|
+
diagnostics.push(invalidDuration(demoId, sceneId, id, step.duration));
|
|
93
|
+
}
|
|
94
|
+
return { kind: step.kind, id, durationMs: durationMs ?? 0 };
|
|
95
|
+
}
|
|
96
|
+
case "timeline.transition": {
|
|
97
|
+
const durationMs = step.duration ? parseDurationMs(step.duration) : void 0;
|
|
98
|
+
if (step.duration && durationMs === null) {
|
|
99
|
+
diagnostics.push(invalidDuration(demoId, sceneId, id, step.duration));
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
kind: step.kind,
|
|
103
|
+
id,
|
|
104
|
+
transition: step.transition ?? "cut",
|
|
105
|
+
durationMs: durationMs ?? void 0
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
case "overlay.visual": {
|
|
109
|
+
const durationMs = step.duration ? parseDurationMs(step.duration) : void 0;
|
|
110
|
+
if (step.duration && durationMs === null) {
|
|
111
|
+
diagnostics.push(invalidDuration(demoId, sceneId, id, step.duration));
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
kind: step.kind,
|
|
115
|
+
id,
|
|
116
|
+
visual: step.visual,
|
|
117
|
+
props: step.props && typeof step.props === "object" && !Array.isArray(step.props) ? step.props : { value: step.props },
|
|
118
|
+
durationMs: durationMs ?? void 0
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
default:
|
|
122
|
+
return { ...step, id };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function invalidDuration(demoId, sceneId, stepId, duration) {
|
|
126
|
+
return {
|
|
127
|
+
code: "DC102",
|
|
128
|
+
severity: "error",
|
|
129
|
+
message: `Invalid duration "${duration}". Use positive values like "250ms", "1s", or "1.5s".`,
|
|
130
|
+
path: `scenes.${sceneId}.steps.${stepId}.duration`,
|
|
131
|
+
suggestion: 'Use a duration such as "250ms", "1s", or "1.5s".',
|
|
132
|
+
docsUrl: diagnosticDocsUrl("DC102"),
|
|
133
|
+
demoId,
|
|
134
|
+
sceneId,
|
|
135
|
+
stepId,
|
|
136
|
+
details: { duration }
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function slugStep(step) {
|
|
140
|
+
const targetOrPath = "target" in step ? step.target : "path" in step ? step.path : "name" in step ? step.name : "";
|
|
141
|
+
return `${step.kind.replace(".", "-")}-${targetOrPath}`.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/validation.ts
|
|
145
|
+
import {
|
|
146
|
+
diagnosticDocsUrl as diagnosticDocsUrl2
|
|
147
|
+
} from "@democraft/schema";
|
|
148
|
+
function validateIR(ir) {
|
|
149
|
+
const diagnostics = [];
|
|
150
|
+
const sceneIds = /* @__PURE__ */ new Set();
|
|
151
|
+
const visualIds = new Set(ir.visuals ?? []);
|
|
152
|
+
if (!ir.id || !ir.title || !ir.source.baseUrl) {
|
|
153
|
+
diagnostics.push({
|
|
154
|
+
code: "DC001",
|
|
155
|
+
severity: "error",
|
|
156
|
+
message: "Demo id, title, and source.baseUrl are required.",
|
|
157
|
+
path: "demo",
|
|
158
|
+
suggestion: "Provide non-empty id, title, and source.baseUrl fields.",
|
|
159
|
+
demoId: ir.id
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
for (const [targetKey, target] of Object.entries(ir.targets)) {
|
|
163
|
+
if (!targetKey || !target.id) {
|
|
164
|
+
diagnostics.push({
|
|
165
|
+
code: "DC106",
|
|
166
|
+
severity: "error",
|
|
167
|
+
message: "Target ids must be non-empty.",
|
|
168
|
+
path: `targets.${targetKey || "<empty>"}`,
|
|
169
|
+
suggestion: "Use a stable non-empty target key.",
|
|
170
|
+
demoId: ir.id,
|
|
171
|
+
targetId: target.id || targetKey
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (!Array.isArray(target.locators) || target.locators.length === 0) {
|
|
175
|
+
diagnostics.push({
|
|
176
|
+
code: "DC106",
|
|
177
|
+
severity: "error",
|
|
178
|
+
message: `Target "${target.id || targetKey}" must define at least one locator.`,
|
|
179
|
+
path: `targets.${targetKey}.locators`,
|
|
180
|
+
suggestion: "Add at least one locator such as byRole() or byTestId().",
|
|
181
|
+
demoId: ir.id,
|
|
182
|
+
targetId: target.id || targetKey
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const scene of ir.scenes) {
|
|
187
|
+
if (!scene.id) {
|
|
188
|
+
diagnostics.push({
|
|
189
|
+
code: "DC103",
|
|
190
|
+
severity: "error",
|
|
191
|
+
message: "Scene ids must be non-empty.",
|
|
192
|
+
path: "scenes",
|
|
193
|
+
suggestion: "Give every scene a stable non-empty id.",
|
|
194
|
+
demoId: ir.id
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (sceneIds.has(scene.id)) {
|
|
198
|
+
diagnostics.push({
|
|
199
|
+
code: "DC002",
|
|
200
|
+
severity: "error",
|
|
201
|
+
message: `Duplicate scene id "${scene.id}".`,
|
|
202
|
+
path: `scenes.${scene.id}`,
|
|
203
|
+
suggestion: "Rename one of the scenes so every scene id is unique.",
|
|
204
|
+
demoId: ir.id,
|
|
205
|
+
sceneId: scene.id
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
sceneIds.add(scene.id);
|
|
209
|
+
const stepIds = /* @__PURE__ */ new Set();
|
|
210
|
+
for (const step of scene.steps) {
|
|
211
|
+
if (!step.id) {
|
|
212
|
+
diagnostics.push({
|
|
213
|
+
code: "DC104",
|
|
214
|
+
severity: "error",
|
|
215
|
+
message: "Step ids must be non-empty.",
|
|
216
|
+
path: `scenes.${scene.id}.steps`,
|
|
217
|
+
suggestion: "Remove the empty id to generate one or provide a stable id.",
|
|
218
|
+
demoId: ir.id,
|
|
219
|
+
sceneId: scene.id
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (stepIds.has(step.id)) {
|
|
223
|
+
diagnostics.push({
|
|
224
|
+
code: "DC002",
|
|
225
|
+
severity: "error",
|
|
226
|
+
message: `Duplicate step id "${step.id}".`,
|
|
227
|
+
path: `scenes.${scene.id}.steps.${step.id}`,
|
|
228
|
+
suggestion: "Rename one of the steps so every step id is unique.",
|
|
229
|
+
demoId: ir.id,
|
|
230
|
+
sceneId: scene.id,
|
|
231
|
+
stepId: step.id
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
stepIds.add(step.id);
|
|
235
|
+
const hasTarget = "target" in step;
|
|
236
|
+
const target = hasTarget ? step.target : void 0;
|
|
237
|
+
if (hasTarget && !target) {
|
|
238
|
+
diagnostics.push({
|
|
239
|
+
code: "DC106",
|
|
240
|
+
severity: "error",
|
|
241
|
+
message: "Step target ids must be non-empty.",
|
|
242
|
+
path: `scenes.${scene.id}.steps.${step.id}.target`,
|
|
243
|
+
suggestion: "Use one of the target ids declared in targets.",
|
|
244
|
+
demoId: ir.id,
|
|
245
|
+
sceneId: scene.id,
|
|
246
|
+
stepId: step.id
|
|
247
|
+
});
|
|
248
|
+
} else if (target && !ir.targets[target]) {
|
|
249
|
+
diagnostics.push({
|
|
250
|
+
code: "DC101",
|
|
251
|
+
severity: "error",
|
|
252
|
+
message: `Unknown target "${target}".`,
|
|
253
|
+
path: `scenes.${scene.id}.steps.${step.id}.target`,
|
|
254
|
+
suggestion: unknownTargetSuggestion(target, Object.keys(ir.targets)),
|
|
255
|
+
demoId: ir.id,
|
|
256
|
+
sceneId: scene.id,
|
|
257
|
+
stepId: step.id,
|
|
258
|
+
targetId: target
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (step.kind === "overlay.visual") {
|
|
262
|
+
if (!visualIds.has(step.visual)) {
|
|
263
|
+
diagnostics.push({
|
|
264
|
+
code: "DC107",
|
|
265
|
+
severity: "error",
|
|
266
|
+
message: `Unknown visual "${step.visual}".`,
|
|
267
|
+
path: `scenes.${scene.id}.steps.${step.id}.visual`,
|
|
268
|
+
suggestion: visualIds.size === 0 ? `Declare "${step.visual}" in the demo visuals map.` : `Use one of the declared visuals: ${[...visualIds].join(", ")}.`,
|
|
269
|
+
demoId: ir.id,
|
|
270
|
+
sceneId: scene.id,
|
|
271
|
+
stepId: step.id
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (!isJsonValue(step.props)) {
|
|
275
|
+
diagnostics.push({
|
|
276
|
+
code: "DC108",
|
|
277
|
+
severity: "error",
|
|
278
|
+
message: `Visual "${step.visual}" props must be JSON-serializable.`,
|
|
279
|
+
path: `scenes.${scene.id}.steps.${step.id}.props`,
|
|
280
|
+
suggestion: "Use only strings, numbers, booleans, null, arrays, and plain objects as visual props.",
|
|
281
|
+
demoId: ir.id,
|
|
282
|
+
sceneId: scene.id,
|
|
283
|
+
stepId: step.id
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return diagnostics.map((diagnostic) => ({
|
|
290
|
+
...diagnostic,
|
|
291
|
+
docsUrl: diagnostic.docsUrl ?? diagnosticDocsUrl2(diagnostic.code)
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
function isJsonValue(value, seen = /* @__PURE__ */ new Set()) {
|
|
295
|
+
if (value === null) return true;
|
|
296
|
+
if (["string", "boolean"].includes(typeof value)) return true;
|
|
297
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
298
|
+
if (typeof value !== "object") return false;
|
|
299
|
+
if (seen.has(value)) return false;
|
|
300
|
+
seen.add(value);
|
|
301
|
+
if (Array.isArray(value)) {
|
|
302
|
+
const valid2 = value.every((item) => isJsonValue(item, seen));
|
|
303
|
+
seen.delete(value);
|
|
304
|
+
return valid2;
|
|
305
|
+
}
|
|
306
|
+
const prototype = Object.getPrototypeOf(value);
|
|
307
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
308
|
+
const valid = Object.values(value).every((item) => isJsonValue(item, seen));
|
|
309
|
+
seen.delete(value);
|
|
310
|
+
return valid;
|
|
311
|
+
}
|
|
312
|
+
function unknownTargetSuggestion(target, targetIds) {
|
|
313
|
+
if (targetIds.length === 0) {
|
|
314
|
+
return `Declare "${target}" in targets.`;
|
|
315
|
+
}
|
|
316
|
+
return `Declare "${target}" in targets or use one of: ${targetIds.join(", ")}.`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/definition-hash.ts
|
|
320
|
+
import { createHash } from "crypto";
|
|
321
|
+
var DEFINITION_HASH_PREFIX = "definition-v1:sha256:";
|
|
322
|
+
var CAPTURE_HASH_PREFIX = "capture-v1:sha256:";
|
|
323
|
+
function canonicalizeDefinition(ir) {
|
|
324
|
+
return canonicalJson({
|
|
325
|
+
title: ir.title,
|
|
326
|
+
source: ir.source,
|
|
327
|
+
targets: ir.targets,
|
|
328
|
+
visuals: ir.visuals,
|
|
329
|
+
scenes: ir.scenes
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function createDefinitionHash(ir) {
|
|
333
|
+
return `${DEFINITION_HASH_PREFIX}${sha256(canonicalizeDefinition(ir))}`;
|
|
334
|
+
}
|
|
335
|
+
function canonicalizeCaptureDefinition(ir) {
|
|
336
|
+
return canonicalJson({
|
|
337
|
+
source: { baseUrl: ir.source.baseUrl },
|
|
338
|
+
targets: Object.fromEntries(
|
|
339
|
+
Object.entries(ir.targets).map(([id, target]) => [
|
|
340
|
+
id,
|
|
341
|
+
{ id: target.id, locators: target.locators }
|
|
342
|
+
])
|
|
343
|
+
),
|
|
344
|
+
scenes: ir.scenes.map((scene) => ({
|
|
345
|
+
id: scene.id,
|
|
346
|
+
steps: scene.steps.map(captureStepProjection)
|
|
347
|
+
}))
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
function createCaptureHash(ir) {
|
|
351
|
+
return `${CAPTURE_HASH_PREFIX}${sha256(canonicalizeCaptureDefinition(ir))}`;
|
|
352
|
+
}
|
|
353
|
+
function captureStepProjection(step) {
|
|
354
|
+
const identity = { id: step.id, kind: step.kind };
|
|
355
|
+
switch (step.kind) {
|
|
356
|
+
case "browser.goto":
|
|
357
|
+
case "assert.url":
|
|
358
|
+
return { ...identity, path: step.path };
|
|
359
|
+
case "browser.click":
|
|
360
|
+
case "assert.visible":
|
|
361
|
+
case "camera.establish":
|
|
362
|
+
return { ...identity, target: step.target };
|
|
363
|
+
case "browser.fill":
|
|
364
|
+
case "browser.select":
|
|
365
|
+
return { ...identity, target: step.target, value: step.value };
|
|
366
|
+
case "assert.text":
|
|
367
|
+
return { ...identity, target: step.target, text: step.text };
|
|
368
|
+
case "camera.focus":
|
|
369
|
+
return { ...identity, target: step.target };
|
|
370
|
+
case "timeline.hold":
|
|
371
|
+
case "timeline.transition":
|
|
372
|
+
return { ...identity, durationMs: step.durationMs };
|
|
373
|
+
case "overlay.caption":
|
|
374
|
+
return { ...identity, text: step.text };
|
|
375
|
+
case "overlay.callout":
|
|
376
|
+
return {
|
|
377
|
+
...identity,
|
|
378
|
+
target: step.target,
|
|
379
|
+
title: step.title,
|
|
380
|
+
description: step.description
|
|
381
|
+
};
|
|
382
|
+
case "overlay.visual":
|
|
383
|
+
return identity;
|
|
384
|
+
case "cue":
|
|
385
|
+
return identity;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function sha256(value) {
|
|
389
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
390
|
+
}
|
|
391
|
+
function canonicalJson(value) {
|
|
392
|
+
if (Array.isArray(value)) {
|
|
393
|
+
return `[${value.map((item) => item === void 0 ? "null" : canonicalJson(item)).join(",")}]`;
|
|
394
|
+
}
|
|
395
|
+
if (value && typeof value === "object") {
|
|
396
|
+
const entries = Object.entries(value).filter(([, item]) => item !== void 0).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
|
397
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
|
|
398
|
+
}
|
|
399
|
+
const serialized = JSON.stringify(value);
|
|
400
|
+
if (serialized === void 0) {
|
|
401
|
+
throw new TypeError("Definition contains a non-JSON value.");
|
|
402
|
+
}
|
|
403
|
+
return serialized;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/compile.ts
|
|
407
|
+
async function compileDemo(definition) {
|
|
408
|
+
const diagnostics = [];
|
|
409
|
+
const capturedScenes = [];
|
|
410
|
+
const fps = definition.config?.fps;
|
|
411
|
+
const hasValidFps = fps === void 0 || Number.isFinite(fps) && fps > 0;
|
|
412
|
+
if (!hasValidFps) {
|
|
413
|
+
diagnostics.push({
|
|
414
|
+
code: "DC001",
|
|
415
|
+
severity: "error",
|
|
416
|
+
message: "Config fps must be a finite number greater than 0.",
|
|
417
|
+
path: "config.fps",
|
|
418
|
+
suggestion: "Use a positive FPS such as 30 or 60.",
|
|
419
|
+
docsUrl: diagnosticDocsUrl3("DC001"),
|
|
420
|
+
demoId: definition.id
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
const demo = {
|
|
424
|
+
async scene(id, metadataOrRun, maybeRun) {
|
|
425
|
+
const metadata = typeof metadataOrRun === "function" ? {} : metadataOrRun;
|
|
426
|
+
const run = typeof metadataOrRun === "function" ? metadataOrRun : maybeRun;
|
|
427
|
+
const steps = [];
|
|
428
|
+
if (!run) {
|
|
429
|
+
diagnostics.push({
|
|
430
|
+
code: "DC103",
|
|
431
|
+
severity: "error",
|
|
432
|
+
message: `Scene "${id}" is missing a run callback.`,
|
|
433
|
+
path: `scenes.${id}`,
|
|
434
|
+
suggestion: "Pass a scene callback as the final argument.",
|
|
435
|
+
docsUrl: diagnosticDocsUrl3("DC103"),
|
|
436
|
+
demoId: definition.id,
|
|
437
|
+
sceneId: id
|
|
438
|
+
});
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
await run(createSceneCapture(steps));
|
|
442
|
+
capturedScenes.push({ id, ...metadata, steps });
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
try {
|
|
446
|
+
await definition.run({ demo });
|
|
447
|
+
} catch (error) {
|
|
448
|
+
diagnostics.push({
|
|
449
|
+
code: "DC003",
|
|
450
|
+
severity: "error",
|
|
451
|
+
message: error instanceof Error ? error.message : "Demo capture failed.",
|
|
452
|
+
path: "run",
|
|
453
|
+
suggestion: "Fix the exception thrown by the demo run callback.",
|
|
454
|
+
docsUrl: diagnosticDocsUrl3("DC003"),
|
|
455
|
+
demoId: definition.id
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
const ir = {
|
|
459
|
+
schemaVersion,
|
|
460
|
+
id: definition.id,
|
|
461
|
+
title: definition.title,
|
|
462
|
+
source: definition.source,
|
|
463
|
+
targets: definition.targets,
|
|
464
|
+
visuals: Object.keys(definition.visuals ?? {}),
|
|
465
|
+
scenes: capturedScenes.map(
|
|
466
|
+
(scene) => normalizeScene(definition.id, scene, diagnostics)
|
|
467
|
+
)
|
|
468
|
+
};
|
|
469
|
+
const validationDiagnostics = validateIR(ir);
|
|
470
|
+
diagnostics.push(...validationDiagnostics);
|
|
471
|
+
if (!validationDiagnostics.some((diagnostic) => diagnostic.code === "DC108")) {
|
|
472
|
+
ir.definitionHash = createDefinitionHash(ir);
|
|
473
|
+
}
|
|
474
|
+
ir.captureHash = createCaptureHash(ir);
|
|
475
|
+
return {
|
|
476
|
+
ir,
|
|
477
|
+
config: fps === void 0 || !hasValidFps ? {} : { fps },
|
|
478
|
+
diagnostics
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
async function compileDemoResult(definition) {
|
|
482
|
+
const { ir, config, diagnostics } = await compileDemo(definition);
|
|
483
|
+
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
484
|
+
return { ok: false, diagnostics };
|
|
485
|
+
}
|
|
486
|
+
return { ok: true, value: { ir, config }, diagnostics };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/inspect.ts
|
|
490
|
+
function inspectIR(ir) {
|
|
491
|
+
const lines = [ir.title.toUpperCase(), ""];
|
|
492
|
+
for (const scene of ir.scenes) {
|
|
493
|
+
lines.push(`Scene: ${scene.id}`, "");
|
|
494
|
+
scene.steps.forEach((step, index) => {
|
|
495
|
+
lines.push(`${index + 1}. ${describeStep(step)}`);
|
|
496
|
+
});
|
|
497
|
+
lines.push("");
|
|
498
|
+
}
|
|
499
|
+
return lines.join("\n").trimEnd();
|
|
500
|
+
}
|
|
501
|
+
function describeStep(step) {
|
|
502
|
+
switch (step.kind) {
|
|
503
|
+
case "browser.goto":
|
|
504
|
+
return `Go to "${step.path}"`;
|
|
505
|
+
case "browser.click":
|
|
506
|
+
return `Click target "${step.target}"`;
|
|
507
|
+
case "browser.fill":
|
|
508
|
+
return `Fill target "${step.target}" with "${step.value}"`;
|
|
509
|
+
case "browser.select":
|
|
510
|
+
return `Select "${step.value}" in target "${step.target}"`;
|
|
511
|
+
case "assert.visible":
|
|
512
|
+
return `Expect target "${step.target}" to be visible`;
|
|
513
|
+
case "assert.text":
|
|
514
|
+
return `Expect target "${step.target}" to contain "${step.text}"`;
|
|
515
|
+
case "assert.url":
|
|
516
|
+
return `Expect URL "${step.path}"`;
|
|
517
|
+
case "camera.establish":
|
|
518
|
+
return step.target ? `Establish camera on "${step.target}"` : "Establish camera";
|
|
519
|
+
case "camera.focus":
|
|
520
|
+
return `Focus camera on "${step.target}"`;
|
|
521
|
+
case "timeline.hold":
|
|
522
|
+
return `Hold for ${step.durationMs}ms`;
|
|
523
|
+
case "timeline.transition":
|
|
524
|
+
return step.durationMs ? `Transition with ${step.transition} for ${step.durationMs}ms` : `Transition with ${step.transition}`;
|
|
525
|
+
case "overlay.caption":
|
|
526
|
+
return `Show caption "${step.text}"`;
|
|
527
|
+
case "overlay.callout":
|
|
528
|
+
return `Show callout "${step.title}" on "${step.target}"`;
|
|
529
|
+
case "overlay.visual":
|
|
530
|
+
return `Show visual "${step.visual}"`;
|
|
531
|
+
case "cue":
|
|
532
|
+
return `Create cue "${step.name}"`;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
export {
|
|
536
|
+
CAPTURE_HASH_PREFIX,
|
|
537
|
+
DEFINITION_HASH_PREFIX,
|
|
538
|
+
canonicalizeCaptureDefinition,
|
|
539
|
+
canonicalizeDefinition,
|
|
540
|
+
compileDemo,
|
|
541
|
+
compileDemoResult,
|
|
542
|
+
createCaptureHash,
|
|
543
|
+
createDefinitionHash,
|
|
544
|
+
inspectIR,
|
|
545
|
+
parseDurationMs,
|
|
546
|
+
validateIR
|
|
547
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@democraft/compiler",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Compiler and validation pipeline for Democraft definitions.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/felipersas/democraft.git",
|
|
9
|
+
"directory": "packages/compiler"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/felipersas/democraft#readme",
|
|
12
|
+
"bugs": "https://github.com/felipersas/democraft/issues",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org/"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@democraft/core": "0.1.0-beta.0",
|
|
35
|
+
"@democraft/schema": "0.1.0-beta.0"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "vitest run"
|
|
41
|
+
}
|
|
42
|
+
}
|