@penpair/core 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.d.ts +16 -1
- package/dist/adapters.js +8 -12
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +99 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +28 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +177 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +28 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +114 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +52 -3
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +132 -52
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +35 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +770 -139
- package/dist/client.d.ts +26 -5
- package/dist/client.js +46 -20
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +179 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +122 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +61 -0
- package/dist/deep-check.js +9 -5
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +135 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +48 -4
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +42 -7
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +32 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +40 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +28 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +344 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +76 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +102 -11
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +53 -12
- package/dist/instance/connection.d.ts +14 -0
- package/dist/instance/connection.js +13 -2
- package/dist/instance/init.js +1 -1
- package/dist/instance/setup.js +1 -1
- package/dist/instance/tty.js +3 -1
- package/dist/journal.d.ts +77 -0
- package/dist/journal.js +260 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +83 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +309 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +90 -0
- package/dist/layout.d.ts +15 -0
- package/dist/layout.js +79 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +14 -0
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +102 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +42 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +15 -0
- package/dist/operation.d.ts +20 -1
- package/dist/operation.js +113 -13
- package/dist/png.d.ts +5 -0
- package/dist/png.js +7 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +134 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +19 -3
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +51 -0
- package/dist/project-snippets.js +12 -7
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +79 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +17 -3
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +100 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +90 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +75 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +59 -32
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +12 -8
- package/dist/snippets/archive.d.ts +9 -0
- package/dist/snippets/archive.js +10 -4
- package/dist/snippets/read.d.ts +1 -1
- package/dist/snippets/read.js +71 -40
- package/dist/structure-snapshot.d.ts +73 -0
- package/dist/structure-snapshot.js +171 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +197 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +89 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +12 -40
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +56 -0
- package/skills/penpair-design/references/project-facts.md +1 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +14 -0
- package/skills/penpair-handoff/references/handoff-flow.md +11 -0
- package/skills/penpair-recipes/SKILL.md +16 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +6 -2
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveProjectOutput } from "./project-input.js";
|
|
4
|
+
import { atomicWriteFile } from "./atomic-output.js";
|
|
5
|
+
const start = "<!-- penpair:skills:start -->", end = "<!-- penpair:skills:end -->";
|
|
6
|
+
async function exists(file) {
|
|
7
|
+
try {
|
|
8
|
+
await lstat(file);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error.code === "ENOENT")
|
|
13
|
+
return false;
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function planSkillInstructions(root, rows, destination) {
|
|
18
|
+
let selected = destination;
|
|
19
|
+
if (!selected) {
|
|
20
|
+
const candidates = [];
|
|
21
|
+
for (const name of ["AGENTS.md", "CLAUDE.md"])
|
|
22
|
+
if (await exists(path.join(root, name)))
|
|
23
|
+
candidates.push(name);
|
|
24
|
+
if (candidates.length !== 1)
|
|
25
|
+
return { report: { path: null, status: candidates.length ? "choice-required" : "not-selected", diff: "" } };
|
|
26
|
+
selected = candidates[0];
|
|
27
|
+
}
|
|
28
|
+
const canonicalRoot = await realpath(root);
|
|
29
|
+
const file = await resolveProjectOutput(canonicalRoot, selected);
|
|
30
|
+
let before;
|
|
31
|
+
try {
|
|
32
|
+
before = await readFile(file, "utf8");
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code !== "ENOENT")
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
const text = before ?? "";
|
|
39
|
+
const starts = text.split(start).length - 1, ends = text.split(end).length - 1;
|
|
40
|
+
if (starts !== ends || starts > 1 || starts === 1 && text.indexOf(start) > text.indexOf(end))
|
|
41
|
+
throw new Error("Malformed penpair skills markers; repair them before installation (no instruction text was changed)");
|
|
42
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
43
|
+
const relativeRows = rows.map((row) => row.replace(/\]\(([^)]+)\)/g, (_match, target) => {
|
|
44
|
+
const relative = path.relative(path.dirname(file), path.resolve(canonicalRoot, decodeURIComponent(target))).split(path.sep).join("/");
|
|
45
|
+
const link = relative.split("/").map((part) => encodeURIComponent(part).replace(/[()]/g, (character) => `%${character.charCodeAt(0).toString(16)}`)).join("/");
|
|
46
|
+
return `](${link})`;
|
|
47
|
+
}));
|
|
48
|
+
const block = [start, "| Skill | Purpose |", "| --- | --- |", ...relativeRows, end].join(newline);
|
|
49
|
+
let after;
|
|
50
|
+
if (starts === 1)
|
|
51
|
+
after = text.slice(0, text.indexOf(start)) + block + text.slice(text.indexOf(end) + end.length);
|
|
52
|
+
else
|
|
53
|
+
after = text + (text ? text.endsWith(newline) ? newline : newline + newline : "") + "## Penpair skills" + newline + newline + block + newline;
|
|
54
|
+
const changed = text !== after;
|
|
55
|
+
// Diff only the owned region, never repeat unrelated instruction content in reports.
|
|
56
|
+
const oldBlock = starts ? text.slice(text.indexOf(start), text.indexOf(end) + end.length) : "";
|
|
57
|
+
const diff = changed ? [`--- ${file}`, `+++ ${file}`, "@@ penpair-owned skills block @@", ...oldBlock.split(/\r?\n/).filter(Boolean).map((line) => `-${line}`), ...(starts ? block : "## Penpair skills" + newline + newline + block).split(/\r?\n/).map((line) => `+${line}`)].join("\n") : "";
|
|
58
|
+
return { before, after, mode: before === undefined ? 0o644 : (await lstat(file)).mode & 0o777, report: { path: file, status: changed ? "preview" : "unchanged", diff } };
|
|
59
|
+
}
|
|
60
|
+
export async function applySkillInstructions(plan) {
|
|
61
|
+
if (plan.report.status !== "preview" || !plan.report.path || plan.after === undefined)
|
|
62
|
+
return plan.report;
|
|
63
|
+
let current;
|
|
64
|
+
try {
|
|
65
|
+
current = await readFile(plan.report.path, "utf8");
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code !== "ENOENT")
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
if (current !== plan.before)
|
|
72
|
+
throw new Error("Instruction file changed after planning; nothing was overwritten");
|
|
73
|
+
await atomicWriteFile(plan.report.path, plan.after, { replace: plan.before !== undefined, mode: plan.mode });
|
|
74
|
+
return { ...plan.report, status: "written" };
|
|
75
|
+
}
|
package/dist/skills.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type InstructionReport } from "./skill-instructions.js";
|
|
1
2
|
/** The skills shipped inside the package (`skills/` next to `dist/`). */
|
|
2
3
|
export declare const bundledSkillsDirectory: () => string;
|
|
3
4
|
export declare function listBundledSkills(directory?: string): Promise<string[]>;
|
|
@@ -7,6 +8,8 @@ export declare function describeSkill(skillDirectory: string): Promise<{
|
|
|
7
8
|
summary: string;
|
|
8
9
|
}>;
|
|
9
10
|
export interface InstallOptions {
|
|
11
|
+
agents?: string;
|
|
12
|
+
dryRun?: boolean;
|
|
10
13
|
/** The project's root. */
|
|
11
14
|
root: string;
|
|
12
15
|
/** Where the skills go, relative to the root. */
|
|
@@ -23,6 +26,10 @@ export interface InstallOptions {
|
|
|
23
26
|
/** Skills that changed name. Installing the new one leaves the old folder behind, which `installSkills` then reports. */
|
|
24
27
|
export declare const renamedSkills: Record<string, string>;
|
|
25
28
|
export interface InstallReport {
|
|
29
|
+
instructions?: InstructionReport;
|
|
30
|
+
dryRun?: boolean;
|
|
31
|
+
wouldInstall?: string[];
|
|
32
|
+
wouldLink?: string[];
|
|
26
33
|
/** The folder the skills went to, relative to the root. */
|
|
27
34
|
to: string;
|
|
28
35
|
installed: string[];
|
package/dist/skills.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { atomicWriteFile } from "./atomic-output.js";
|
|
2
|
+
import { resolveProjectOutput } from "./project-input.js";
|
|
3
|
+
import { planSkillInstructions, applySkillInstructions } from "./skill-instructions.js";
|
|
4
|
+
import { lstat, mkdir, readdir, readFile, symlink } from "node:fs/promises";
|
|
2
5
|
import path from "node:path";
|
|
3
6
|
import { fileURLToPath } from "node:url";
|
|
4
7
|
/** The skills shipped inside the package (`skills/` next to `dist/`). */
|
|
@@ -24,8 +27,10 @@ async function readIfExists(file) {
|
|
|
24
27
|
try {
|
|
25
28
|
return await readFile(file, "utf8");
|
|
26
29
|
}
|
|
27
|
-
catch {
|
|
28
|
-
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === "ENOENT")
|
|
32
|
+
return undefined;
|
|
33
|
+
throw error;
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
async function exists(file) {
|
|
@@ -33,8 +38,10 @@ async function exists(file) {
|
|
|
33
38
|
await lstat(file);
|
|
34
39
|
return true;
|
|
35
40
|
}
|
|
36
|
-
catch {
|
|
37
|
-
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error.code === "ENOENT")
|
|
43
|
+
return false;
|
|
44
|
+
throw error;
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
/** The skill's name and the first sentence of its description, from the frontmatter, for a row in a project's agent guide. */
|
|
@@ -60,58 +67,78 @@ export async function existingSkillsFolder(root) {
|
|
|
60
67
|
export async function installSkills(options) {
|
|
61
68
|
const source = options.source ?? bundledSkillsDirectory();
|
|
62
69
|
const to = options.to ?? (await existingSkillsFolder(options.root)) ?? ".skills";
|
|
70
|
+
await resolveProjectOutput(options.root, to);
|
|
63
71
|
const names = await listBundledSkills(source);
|
|
64
|
-
const chosen = options.only
|
|
72
|
+
const chosen = options.only ?? names;
|
|
65
73
|
for (const name of chosen)
|
|
66
74
|
if (!names.includes(name))
|
|
67
75
|
throw new Error(`no skill named ${name}; shipped: ${names.join(", ")}`);
|
|
68
|
-
const
|
|
76
|
+
const rows = await Promise.all(chosen.map(async (name) => {
|
|
77
|
+
const { summary } = await describeSkill(path.join(source, name));
|
|
78
|
+
const link = path.posix.join(to, name, "SKILL.md").split("/").map((part) => encodeURIComponent(part).replace(/[()]/g, (character) => `%${character.charCodeAt(0).toString(16)}`)).join("/");
|
|
79
|
+
return `| [${name}](${link}) | ${summary.replace(/\|/g, "\\|")} |`;
|
|
80
|
+
}));
|
|
81
|
+
const instructionPlan = await planSkillInstructions(options.root, rows, options.agents);
|
|
82
|
+
const report = { to, installed: [], unchanged: [], kept: [], linked: [], agentsRows: rows, legacy: [], instructions: instructionPlan.report, ...(options.dryRun ? { dryRun: true, wouldInstall: [], wouldLink: [] } : {}) };
|
|
83
|
+
const writes = new Map();
|
|
84
|
+
const links = [];
|
|
69
85
|
for (const name of chosen) {
|
|
70
86
|
const from = path.join(source, name);
|
|
71
87
|
for (const file of await filesUnder(from)) {
|
|
72
|
-
const target =
|
|
88
|
+
const target = await resolveProjectOutput(options.root, path.join(to, name, file));
|
|
73
89
|
const shipped = await readFile(path.join(from, file), "utf8");
|
|
74
90
|
const present = await readIfExists(target);
|
|
75
91
|
const label = path.posix.join(to, name, file);
|
|
76
|
-
if (present ===
|
|
77
|
-
await mkdir(path.dirname(target), { recursive: true });
|
|
78
|
-
await writeFile(target, shipped);
|
|
79
|
-
report.installed.push(label);
|
|
80
|
-
}
|
|
81
|
-
else if (present === shipped)
|
|
92
|
+
if (present === shipped)
|
|
82
93
|
report.unchanged.push(label);
|
|
83
|
-
else if (options.force && !projectOwned.has(file))
|
|
84
|
-
|
|
85
|
-
report.installed.push(label);
|
|
86
|
-
}
|
|
94
|
+
else if (present === undefined || options.force && !projectOwned.has(file))
|
|
95
|
+
writes.set(target, { label, before: present, content: shipped });
|
|
87
96
|
else
|
|
88
97
|
report.kept.push(label);
|
|
89
98
|
}
|
|
90
|
-
if (options.links !== false)
|
|
99
|
+
if (options.links !== false)
|
|
91
100
|
for (const hub of [".agents/skills", ".claude/skills"]) {
|
|
92
101
|
const link = path.join(options.root, hub, name);
|
|
93
|
-
if (await exists(link))
|
|
102
|
+
if (path.resolve(link) === path.resolve(options.root, to, name) || await exists(link))
|
|
94
103
|
continue;
|
|
95
|
-
await
|
|
96
|
-
await
|
|
97
|
-
|
|
104
|
+
const safe = await resolveProjectOutput(options.root, path.join(hub, name));
|
|
105
|
+
const target = await resolveProjectOutput(options.root, path.join(to, name));
|
|
106
|
+
if (safe === target)
|
|
107
|
+
continue;
|
|
108
|
+
links.push({ file: safe, target: path.relative(path.dirname(safe), target), label: path.posix.join(hub, name) });
|
|
98
109
|
}
|
|
99
|
-
}
|
|
100
|
-
const { summary } = await describeSkill(from);
|
|
101
|
-
report.agentsRows.push(`| [${name}](${path.posix.join(to, name, "SKILL.md")}) | ${summary} |`);
|
|
102
110
|
}
|
|
103
111
|
for (const [oldName, newName] of Object.entries(renamedSkills)) {
|
|
104
112
|
if (!chosen.includes(newName) || !(await exists(path.join(options.root, to, oldName))))
|
|
105
113
|
continue;
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
const newFactsFile = path.join(options.root, to, newName, "references/project-facts.md");
|
|
114
|
+
const oldFacts = await readIfExists(await resolveProjectOutput(options.root, path.join(to, oldName, "references/project-facts.md")));
|
|
115
|
+
const newFactsFile = await resolveProjectOutput(options.root, path.join(to, newName, "references/project-facts.md"));
|
|
109
116
|
const template = await readIfExists(path.join(source, newName, "references/project-facts.md"));
|
|
110
|
-
const current = await readIfExists(newFactsFile);
|
|
117
|
+
const current = writes.get(newFactsFile)?.content ?? await readIfExists(newFactsFile);
|
|
111
118
|
const carriedFacts = oldFacts !== undefined && oldFacts !== template && current === template;
|
|
112
119
|
if (carriedFacts)
|
|
113
|
-
await
|
|
114
|
-
report.legacy.push({ folder: path.posix.join(to, oldName), renamedTo: newName, carriedFacts });
|
|
120
|
+
writes.set(newFactsFile, { label: path.posix.join(to, newName, "references/project-facts.md"), before: await readIfExists(newFactsFile), content: oldFacts });
|
|
121
|
+
report.legacy.push({ folder: path.posix.join(to, oldName), renamedTo: newName, carriedFacts: carriedFacts && !options.dryRun });
|
|
122
|
+
}
|
|
123
|
+
if (options.dryRun) {
|
|
124
|
+
report.wouldInstall = [...writes.values()].map((write) => write.label);
|
|
125
|
+
report.wouldLink = links.map((link) => link.label);
|
|
126
|
+
return report;
|
|
127
|
+
}
|
|
128
|
+
for (const [file, write] of writes) {
|
|
129
|
+
if (await readIfExists(file) !== write.before)
|
|
130
|
+
throw new Error("A skill file changed after planning; nothing overwrote that file");
|
|
131
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
132
|
+
await atomicWriteFile(file, write.content, { replace: write.before !== undefined, mode: write.before === undefined ? 0o644 : (await lstat(file)).mode & 0o777 });
|
|
133
|
+
report.installed.push(write.label);
|
|
134
|
+
}
|
|
135
|
+
for (const link of links) {
|
|
136
|
+
await mkdir(path.dirname(link.file), { recursive: true });
|
|
137
|
+
await symlink(link.target, link.file);
|
|
138
|
+
report.linked.push(link.label);
|
|
115
139
|
}
|
|
140
|
+
if (instructionPlan.report.path)
|
|
141
|
+
await mkdir(path.dirname(instructionPlan.report.path), { recursive: true });
|
|
142
|
+
report.instructions = await applySkillInstructions(instructionPlan);
|
|
116
143
|
return report;
|
|
117
144
|
}
|
package/dist/snippet.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ExecuteOptions, type PenpotToolClient } from "./client.js";
|
|
2
2
|
/** Plugin source ready to send, with the parameters already injected. */
|
|
3
3
|
export interface Snippet {
|
|
4
|
+
effect?: ExecuteOptions["effect"];
|
|
4
5
|
name: string;
|
|
5
6
|
code: string;
|
|
6
7
|
}
|
|
@@ -10,8 +11,8 @@ export interface Snippet {
|
|
|
10
11
|
*/
|
|
11
12
|
export declare function wrap(body: string, params?: unknown): string;
|
|
12
13
|
/** Plugin code for snippets that work on one page: they say so instead of acting on whatever page is open. */
|
|
13
|
-
export declare const requirePage = "if (CFG.page && penpot.currentPage.name !== CFG.page) return { ok: false, wrongPage: true, current: penpot.currentPage.name, message: \"the editor shows \" + penpot.currentPage.name + \", not \" + CFG.page };";
|
|
14
|
-
export declare function snippet(name: string, body: string, params?: unknown): Snippet;
|
|
14
|
+
export declare const requirePage = "if (CFG.page && penpot.currentFile?.pages?.filter((p) => p.name === CFG.page).length > 1) return { ok:false, message:\"page name is ambiguous; rename pages before selecting by name\" };\nif (CFG.page && penpot.currentPage.name !== CFG.page) return { ok: false, wrongPage: true, current: penpot.currentPage.name, message: \"the editor shows \" + penpot.currentPage.name + \", not \" + CFG.page };";
|
|
15
|
+
export declare function snippet(name: string, body: string, params?: unknown, effect?: ExecuteOptions["effect"]): Snippet;
|
|
15
16
|
/** Whether the plugin source is valid JavaScript for an async function. Catches typos without a Penpot connection. */
|
|
16
17
|
export declare function compiles(code: string): string | undefined;
|
|
17
18
|
export interface SnippetResult {
|
package/dist/snippet.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
-
import { executeCode, maxSnippetBytes } from "./client.js";
|
|
2
|
+
import { executeCode, maxSnippetBytes, UncertainExecutionError } from "./client.js";
|
|
3
3
|
import { PenpairError } from "./errors.js";
|
|
4
4
|
/**
|
|
5
5
|
* Wraps snippet body text for the Penpot editor: the parameters arrive as `CFG`, and any error comes back as
|
|
@@ -9,12 +9,13 @@ export function wrap(body, params = {}) {
|
|
|
9
9
|
return `const CFG = ${JSON.stringify(params)};\ntry {\n${body}\n} catch (error) { return { ok: false, message: String(error && error.message || error) }; }`;
|
|
10
10
|
}
|
|
11
11
|
/** Plugin code for snippets that work on one page: they say so instead of acting on whatever page is open. */
|
|
12
|
-
export const requirePage = `if (CFG.page && penpot.
|
|
13
|
-
|
|
12
|
+
export const requirePage = `if (CFG.page && penpot.currentFile?.pages?.filter((p) => p.name === CFG.page).length > 1) return { ok:false, message:"page name is ambiguous; rename pages before selecting by name" };
|
|
13
|
+
if (CFG.page && penpot.currentPage.name !== CFG.page) return { ok: false, wrongPage: true, current: penpot.currentPage.name, message: "the editor shows " + penpot.currentPage.name + ", not " + CFG.page };`;
|
|
14
|
+
export function snippet(name, body, params = {}, effect = "unknown") {
|
|
14
15
|
const code = wrap(body, params);
|
|
15
16
|
if (Buffer.byteLength(code, "utf8") > maxSnippetBytes)
|
|
16
17
|
throw new PenpairError("snippet-too-large", `Snippet ${name} is ${Buffer.byteLength(code, "utf8")} bytes`);
|
|
17
|
-
return { name, code };
|
|
18
|
+
return { name, code, effect };
|
|
18
19
|
}
|
|
19
20
|
/** Whether the plugin source is valid JavaScript for an async function. Catches typos without a Penpot connection. */
|
|
20
21
|
export function compiles(code) {
|
|
@@ -29,9 +30,12 @@ export function compiles(code) {
|
|
|
29
30
|
}
|
|
30
31
|
/** Runs a snippet and returns its result record. A snippet that reports `ok: false` throws, except a wrong page, which the caller handles. */
|
|
31
32
|
export async function runSnippet(client, s, options) {
|
|
32
|
-
const { result } = await executeCode(client, s.code, options);
|
|
33
|
-
const record = typeof result === "object" && result !== null ? result : {
|
|
34
|
-
if (record.ok === false && !record.wrongPage)
|
|
33
|
+
const { result, runId, metrics } = await executeCode(client, s.code, { effect: s.effect, ...options });
|
|
34
|
+
const record = typeof result === "object" && result !== null && !Array.isArray(result) ? result : { value: result };
|
|
35
|
+
if (record.ok === false && !record.wrongPage) {
|
|
36
|
+
if ((options?.effect ?? s.effect) !== "read")
|
|
37
|
+
throw new UncertainExecutionError(runId, `${s.name}: ${record.message ?? "failed"}`);
|
|
35
38
|
throw new PenpairError("tool-failed", `${s.name}: ${record.message ?? "failed"}`);
|
|
36
|
-
|
|
39
|
+
}
|
|
40
|
+
return { ...record, execution: { runId, outcome: "ran", metrics } };
|
|
37
41
|
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { type Snippet } from "../snippet.js";
|
|
2
2
|
export interface ArchiveParams {
|
|
3
|
+
expectedPlan?: {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
w: number;
|
|
9
|
+
h: number;
|
|
10
|
+
}[];
|
|
11
|
+
expectedArchiveId?: string | null;
|
|
3
12
|
page: string;
|
|
4
13
|
/** Name of the board the archived frames are nested into. */
|
|
5
14
|
archiveName: string;
|
package/dist/snippets/archive.js
CHANGED
|
@@ -4,17 +4,21 @@ const select = `${requirePage}
|
|
|
4
4
|
const top = penpot.currentPage.root.children;
|
|
5
5
|
const isPart = (n) => CFG.partPrefixes.some((p) => n.startsWith(p));
|
|
6
6
|
const isKept = (n) => CFG.keepPrefixes.some((p) => n.startsWith(p));
|
|
7
|
-
const
|
|
7
|
+
const archives = top.filter((c) => c.name === CFG.archiveName && c.type === "board");
|
|
8
|
+
if (archives.length > 1) return { ok: false, message: "archive name is ambiguous" };
|
|
9
|
+
const existing = archives[0];
|
|
8
10
|
const candidates = top.filter((c) => c.type === "board" && c !== existing && !isPart(c.name) && !isKept(c.name));
|
|
9
11
|
const plan = candidates.map((c) => ({ id: c.id, name: c.name, x: Math.round(c.x), y: Math.round(c.y), w: Math.round(c.width), h: Math.round(c.height) }));`;
|
|
10
12
|
/** Read-only: what would move and what stays. */
|
|
11
13
|
export function archiveInspect(params) {
|
|
12
14
|
return snippet("archive-inspect", `${select}
|
|
13
|
-
return { ok: true, readOnly: true, archiveExists: !!existing, willMove: plan.length, stays: top.filter((c) => c !== existing && !candidates.includes(c)).length, plan };`, params);
|
|
15
|
+
return { ok: true, readOnly: true, archiveExists: !!existing, archiveId: existing ? existing.id : null, insideIds: existing ? existing.children.map((c) => c.id) : [], willMove: plan.length, stays: top.filter((c) => c !== existing && !candidates.includes(c)).length, plan };`, params);
|
|
14
16
|
}
|
|
15
17
|
/** One write: nests the frames in the archive board and moves the board aside. Safe to run again; frames already inside are not touched. */
|
|
16
18
|
export function archiveApply(params) {
|
|
17
19
|
return snippet("archive-apply", `${select}
|
|
20
|
+
if (CFG.expectedPlan && JSON.stringify(plan) !== JSON.stringify(CFG.expectedPlan)) return { ok: false, message: "Archive source changed since inspection; no frames were moved" };
|
|
21
|
+
if (Object.prototype.hasOwnProperty.call(CFG, "expectedArchiveId") && CFG.expectedArchiveId !== (existing ? existing.id : null)) return { ok: false, message: "Archive target changed since inspection; no frames were moved" };
|
|
18
22
|
if (plan.length === 0) return { ok: true, moved: 0, archiveId: existing ? existing.id : null };
|
|
19
23
|
const minX = Math.min(...plan.map((p) => p.x)), minY = Math.min(...plan.map((p) => p.y));
|
|
20
24
|
const maxX = Math.max(...plan.map((p) => p.x + p.w)), maxY = Math.max(...plan.map((p) => p.y + p.h));
|
|
@@ -32,10 +36,12 @@ return { ok: true, submitted: true, moved: plan.length, archiveId: archive.id };
|
|
|
32
36
|
export function archiveReadback(params) {
|
|
33
37
|
return snippet("archive-readback", `${requirePage}
|
|
34
38
|
const top = penpot.currentPage.root.children;
|
|
35
|
-
const
|
|
39
|
+
const archives = top.filter((c) => c.name === CFG.archiveName && c.type === "board");
|
|
40
|
+
if (archives.length > 1) return { ok: false, message: "archive name is ambiguous" };
|
|
41
|
+
const archive = archives[0];
|
|
36
42
|
const inside = archive ? archive.children.map((c) => c.id) : [];
|
|
37
43
|
const isPart = (n) => CFG.partPrefixes.some((p) => n.startsWith(p));
|
|
38
44
|
const isKept = (n) => CFG.keepPrefixes.some((p) => n.startsWith(p));
|
|
39
45
|
const loose = top.filter((c) => c !== archive && c.type === "board" && !isPart(c.name) && !isKept(c.name)).map((c) => c.name);
|
|
40
|
-
return { ok: true, readOnly: true, archive: archive ? { id: archive.id, x: Math.round(archive.x), y: Math.round(archive.y), children: inside.length } : null, loose };`, params);
|
|
46
|
+
return { ok: true, readOnly: true, archive: archive ? { id: archive.id, x: Math.round(archive.x), y: Math.round(archive.y), children: inside.length } : null, insideIds: inside, loose };`, params);
|
|
41
47
|
}
|
package/dist/snippets/read.d.ts
CHANGED
|
@@ -28,5 +28,5 @@ export interface AuditParams {
|
|
|
28
28
|
}
|
|
29
29
|
/** Blank text, colours and strokes not bound to a token, children sticking out of their parent, gaps off the allowed steps. */
|
|
30
30
|
export declare function audit(params: AuditParams): Snippet;
|
|
31
|
-
/**
|
|
31
|
+
/** Bounded typed preview. Use captureInspection(kind: tokens) for chunked complete evidence. */
|
|
32
32
|
export declare function tokensDump(): Snippet;
|
package/dist/snippets/read.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { requirePage, snippet } from "../snippet.js";
|
|
2
|
+
const readSnippet = (name, body, params = {}) => snippet(name, body, params, "read");
|
|
2
3
|
export function pages() {
|
|
3
|
-
return
|
|
4
|
+
return readSnippet("pages", `return { ok: true, readOnly: true, file: penpot.currentFile.name, revn: penpot.currentFile.revn, current: penpot.currentPage.name,
|
|
4
5
|
pages: penpot.currentFile.pages.map((p) => ({ id: p.id, name: p.name, count: p.root.children.length })) };`);
|
|
5
6
|
}
|
|
6
7
|
/** The editor can only change page between snippets: it switches, and the next snippet sees the new page. */
|
|
@@ -13,53 +14,83 @@ return { ok: true, switched: true };`, { name });
|
|
|
13
14
|
}
|
|
14
15
|
/** The top-level frames of a page: id, name and size, so they can be found by name and exported. */
|
|
15
16
|
export function frames(params) {
|
|
16
|
-
return
|
|
17
|
-
return { ok: true, readOnly: true, file: penpot.currentFile.name, revn: penpot.currentFile.revn, page: penpot.currentPage.name,
|
|
17
|
+
return readSnippet("frames", `${requirePage}
|
|
18
|
+
return { ok: true, readOnly: true, file: penpot.currentFile.name, fileId: penpot.currentFile.id, revn: penpot.currentFile.revn, page: penpot.currentPage.name, pageId: penpot.currentPage.id,
|
|
18
19
|
frames: penpot.currentPage.root.children.filter((c) => c.type === "board").map((c) => ({ id: c.id, name: c.name, width: Math.round(c.width), height: Math.round(c.height) })) };`, params);
|
|
19
20
|
}
|
|
20
21
|
/** One line per shape: type, name, size, position on the page, layout and sizing, the text it carries. */
|
|
21
22
|
export function tree(params) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
if (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < 1 || params.limit > 1000) || params.depth !== undefined && (!Number.isInteger(params.depth) || params.depth < 0 || params.depth > 100))
|
|
24
|
+
throw new Error("Readable tree budgets require limit 1..1000 and depth 0..100; use inspect for larger structured scopes");
|
|
25
|
+
return readSnippet("tree", `${requirePage}
|
|
26
|
+
const depth=CFG.depth===undefined?3:CFG.depth,limit=CFG.limit||400,lines=[];
|
|
27
|
+
let depthLimited=false,textLimited=false,labelLimited=false;
|
|
28
|
+
const stack=[],roots=penpot.currentPage.root.children.filter(c=>!CFG.namePrefix||c.name.startsWith(CFG.namePrefix));
|
|
29
|
+
for(let i=Math.min(roots.length,20000)-1;i>=0;i--)stack.push({shape:roots[i],depth:0});
|
|
30
|
+
const fx=s=>s.flex?" flex:"+s.flex.dir+"/"+s.flex.horizontalSizing+"-"+s.flex.verticalSizing:"";
|
|
31
|
+
const lc=s=>s.layoutChild?" lc:"+(s.layoutChild.horizontalSizing||"")+"-"+(s.layoutChild.verticalSizing||"")+(s.layoutChild.absolute?" ABS":""):"";
|
|
32
|
+
while(stack.length&&lines.length<limit){
|
|
33
|
+
const item=stack.pop(),s=item.shape,name=String(s.name||""),text=String(s.characters||"");
|
|
34
|
+
if(name.length>200)labelLimited=true;if(s.type==="text"&&text.length>24)textLimited=true;
|
|
35
|
+
lines.push(" ".repeat(item.depth)+(s.type||"?")[0]+" "+s.id+" "+JSON.stringify(name.slice(0,200))+" "+Math.round(s.width)+"x"+Math.round(s.height)+"@"+Math.round(s.x)+","+Math.round(s.y)+fx(s)+lc(s)+(s.type==="text"?" «"+JSON.stringify(text.slice(0,24))+"»":"")+(s.hidden?" HIDDEN":""));
|
|
36
|
+
const children=s.children||[];
|
|
37
|
+
if(item.depth>=depth){if(children.length)depthLimited=true;continue;}
|
|
38
|
+
const take=Math.min(children.length,20000-stack.length);
|
|
39
|
+
if(take<children.length)depthLimited=true;
|
|
40
|
+
for(let i=take-1;i>=0;i--)stack.push({shape:children[i],depth:item.depth+1});
|
|
41
|
+
}
|
|
42
|
+
const truncated=stack.length>0||roots.length>20000||depthLimited||textLimited||labelLimited;
|
|
43
|
+
return {ok:true,readOnly:true,page:penpot.currentPage.name,fileId:penpot.currentFile?.id??null,pageId:penpot.currentPage.id??null,revision:penpot.currentFile?.revn??null,lines,returnedLines:lines.length,depthLimit:depth,lineLimit:limit,truncated,complete:!truncated,limits:{depthLimited,textLimited,labelLimited,pendingBranches:stack.length}};`, params);
|
|
33
44
|
}
|
|
34
45
|
/** Blank text, colours and strokes not bound to a token, children sticking out of their parent, gaps off the allowed steps. */
|
|
35
46
|
export function audit(params) {
|
|
36
|
-
return
|
|
37
|
-
const
|
|
38
|
-
const skip
|
|
39
|
-
const
|
|
40
|
-
for
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if
|
|
51
|
-
(s.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
return readSnippet("audit", `${requirePage}
|
|
48
|
+
const candidates=penpot.currentPage.root.children.filter(c=>c.name.startsWith(CFG.framePrefix)),frames=candidates.slice(0,20),out=Object.create(null);
|
|
49
|
+
const skip=n=>(CFG.ignoreUnbound||[]).some(prefix=>n.startsWith(prefix));
|
|
50
|
+
let visited=0,exampleBytes=0,partial=candidates.length>20;const deadline=Date.now()+5000;
|
|
51
|
+
const size=text=>{let n=0;for(const c of text){const p=c.codePointAt(0);n+=p<128?1:p<2048?2:p<65536?3:4;}return n;};
|
|
52
|
+
for(const fr of frames){
|
|
53
|
+
const f={id:fr.id,name:fr.name.slice(0,200),nodes:0,texts:0,blankText:0,unboundFills:[],unboundStrokes:[],badSpacing:[],overflow:[],totals:{unboundFills:0,unboundStrokes:0,badSpacing:0,overflow:0},truncated:fr.name.length>200,scanComplete:true};
|
|
54
|
+
const issue=(category,s,label)=>{f.totals[category]++;const example={id:s.id,label:String(label).slice(0,80)},bytes=size(JSON.stringify(example));if(f[category].length<8&&exampleBytes+bytes<=65536){f[category].push(example);exampleBytes+=bytes;}else f.truncated=true;if(String(label).length>80)f.truncated=true;};
|
|
55
|
+
const stack=[{shape:fr,parent:null}];
|
|
56
|
+
while(stack.length){
|
|
57
|
+
if(visited>=20000||Date.now()>deadline){f.scanComplete=false;f.truncated=true;partial=true;break;}
|
|
58
|
+
const {shape:s,parent}=stack.pop();visited++;f.nodes++;
|
|
59
|
+
const tk=Object.keys(s.tokens||{});
|
|
60
|
+
if(s.type==="text"){if(!(s.characters||"").trim())f.blankText++;else f.texts++;}
|
|
61
|
+
else if(!skip(s.name)&&s.fills&&s.fills.some(fill=>fill.fillColor)&&!tk.some(key=>key.startsWith("fill")))issue("unboundFills",s,s.name+":"+s.fills[0].fillColor);
|
|
62
|
+
if(s.type!=="text"&&!skip(s.name)&&s.strokes&&s.strokes.length&&!tk.some(key=>key.startsWith("stroke")))issue("unboundStrokes",s,s.name);
|
|
63
|
+
if(s.flex&&CFG.spacingSteps)for(const value of [s.flex.rowGap,s.flex.columnGap,s.flex.topPadding,s.flex.rightPadding,s.flex.bottomPadding,s.flex.leftPadding])if(value!==undefined&&value!==null&&!CFG.spacingSteps.includes(Math.round(value)))issue("badSpacing",s,s.name+":"+value);
|
|
64
|
+
if(parent&&parent.type==="board"&&!(s.layoutChild&&s.layoutChild.absolute)&&(s.x+s.width>parent.x+parent.width+1||s.x<parent.x-1))issue("overflow",s,s.name+" in "+parent.name);
|
|
65
|
+
const children=(s.children||[]).filter(child=>!child.hidden),take=Math.min(children.length,Math.max(0,20000-visited-stack.length));
|
|
66
|
+
if(take<children.length){f.scanComplete=false;f.truncated=true;partial=true;}
|
|
67
|
+
for(let i=take-1;i>=0;i--)stack.push({shape:children[i],parent:s});
|
|
68
|
+
}
|
|
69
|
+
f.totalNodes=f.scanComplete?f.nodes:null;out[fr.id]=f;
|
|
56
70
|
}
|
|
57
|
-
|
|
71
|
+
const truncated=partial||Object.values(out).some(frame=>frame.truncated);
|
|
72
|
+
return {ok:true,readOnly:true,schemaVersion:1,kind:"audit-preview",scope:{framePrefix:CFG.framePrefix,includeHiddenDescendants:false},frames:out,frameCount:candidates.length,returnedFrames:frames.length,visitedNodes:visited,countsComplete:!partial,truncated,complete:!truncated,budgets:{frames:20,nodes:20000,timeMs:5000,examplesPerCategory:8,exampleBytes:65536}};`, params);
|
|
58
73
|
}
|
|
59
|
-
/**
|
|
74
|
+
/** Bounded typed preview. Use captureInspection(kind: tokens) for chunked complete evidence. */
|
|
60
75
|
export function tokensDump() {
|
|
61
|
-
return
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
return readSnippet("tokens-dump", `const tokens=penpot.library.local.tokens;
|
|
77
|
+
const sets=[],themes=[];let records=0,bytes=1024,returnedTokens=0,complete=true;
|
|
78
|
+
const size=(value)=>{let n=0;for(const c of JSON.stringify(value)){const p=c.codePointAt(0);n+=p<128?1:p<2048?2:p<65536?3:4;}return n;};
|
|
79
|
+
outer:for(const [order,set] of tokens.sets.entries()){
|
|
80
|
+
const value={order,name:set.name,active:!!set.active,tokens:[]};
|
|
81
|
+
const count=size(value);
|
|
82
|
+
if(records>=5000||bytes+count>262144){complete=false;break;}
|
|
83
|
+
sets.push(value);records++;bytes+=count+1;
|
|
84
|
+
for(const t of set.tokens){
|
|
85
|
+
const token={name:t.name,value:t.value===undefined?null:t.value,type:t.type??null,description:t.description||"",unknowns:[...(t.type===undefined?["type"]:[]),...(t.value===undefined?["value"]:[])]},count=size(token);
|
|
86
|
+
if(records>=5000||bytes+count>262144){complete=false;break outer;}
|
|
87
|
+
value.tokens.push(token);records++;returnedTokens++;bytes+=count+1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for(const theme of tokens.themes){
|
|
91
|
+
const value={group:theme.group,name:theme.name,active:!!theme.active,sets:(theme.activeSets||[]).map(s=>typeof s==="string"?s:s.name)},count=size(value);
|
|
92
|
+
if(records>=5000||bytes+count>262144){complete=false;break;}
|
|
93
|
+
themes.push(value);records++;bytes+=count+1;
|
|
94
|
+
}
|
|
95
|
+
return {ok:true,readOnly:true,schemaVersion:1,kind:"token-preview",fileId:penpot.currentFile.id,revision:penpot.currentFile.revn,complete,truncated:!complete,sets,themes,counts:{returnedTokens,totalTokens:complete?returnedTokens:null,setsTotal:tokens.sets.length,themesTotal:tokens.themes.length},previewBudget:{entities:5000,bytes:262144}};`);
|
|
65
96
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type InspectionDocument } from "./inspection.js";
|
|
2
|
+
import type { ExecuteOptions, PenpotToolClient } from "./client.js";
|
|
3
|
+
export interface StructuralPage {
|
|
4
|
+
schemaVersion: 1;
|
|
5
|
+
kind: "page-structure";
|
|
6
|
+
fileId: string;
|
|
7
|
+
pageId: string;
|
|
8
|
+
revision: number;
|
|
9
|
+
name: string;
|
|
10
|
+
nodes: Record<string, unknown>[];
|
|
11
|
+
}
|
|
12
|
+
export interface StructuralTokens {
|
|
13
|
+
schemaVersion: 1;
|
|
14
|
+
kind: "token-structure";
|
|
15
|
+
fileId: string;
|
|
16
|
+
revision: number;
|
|
17
|
+
sets: Record<string, unknown>[];
|
|
18
|
+
themes: Record<string, unknown>[];
|
|
19
|
+
}
|
|
20
|
+
export interface StructuralSnapshot {
|
|
21
|
+
schemaVersion: 1;
|
|
22
|
+
kind: "structural-snapshot";
|
|
23
|
+
fileId: string;
|
|
24
|
+
revision: number;
|
|
25
|
+
pages: StructuralPage[];
|
|
26
|
+
tokens: StructuralTokens;
|
|
27
|
+
fidelity: "review-only";
|
|
28
|
+
}
|
|
29
|
+
export interface StructuralManifest {
|
|
30
|
+
schemaVersion: 1;
|
|
31
|
+
kind: "structural-snapshot-manifest";
|
|
32
|
+
fileId: string;
|
|
33
|
+
revision: number;
|
|
34
|
+
producer: string;
|
|
35
|
+
fidelity: "review-only";
|
|
36
|
+
pages: Array<{
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
file: string;
|
|
40
|
+
hash: string;
|
|
41
|
+
}>;
|
|
42
|
+
tokens: {
|
|
43
|
+
file: "tokens.json";
|
|
44
|
+
hash: string;
|
|
45
|
+
};
|
|
46
|
+
hash: string;
|
|
47
|
+
}
|
|
48
|
+
export declare const structuralJson: (value: unknown) => string;
|
|
49
|
+
export declare function structuralPage(raw: InspectionDocument, name: string): StructuralPage;
|
|
50
|
+
export declare function assembleStructuralSnapshot(pages: StructuralPage[], rawTokens: InspectionDocument): StructuralSnapshot;
|
|
51
|
+
export declare function captureStructuralSnapshot(client: PenpotToolClient, options?: ExecuteOptions & {
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
}): Promise<StructuralSnapshot>;
|
|
54
|
+
export declare function writeStructuralSnapshot(root: string, out: string, snapshot: StructuralSnapshot, redact?: (text: string) => string): Promise<StructuralManifest>;
|
|
55
|
+
export declare function readStructuralSnapshot(root: string, folder: string): Promise<StructuralSnapshot>;
|
|
56
|
+
export declare function diffStructuralSnapshots(before: StructuralSnapshot, after: StructuralSnapshot, limit?: number): {
|
|
57
|
+
schemaVersion: number;
|
|
58
|
+
kind: string;
|
|
59
|
+
fileId: string;
|
|
60
|
+
beforeRevision: number;
|
|
61
|
+
afterRevision: number;
|
|
62
|
+
totalChanges: number;
|
|
63
|
+
complete: boolean;
|
|
64
|
+
changes: {
|
|
65
|
+
kind: string;
|
|
66
|
+
pageId?: string;
|
|
67
|
+
nodeId?: string;
|
|
68
|
+
before: unknown;
|
|
69
|
+
after: unknown;
|
|
70
|
+
}[];
|
|
71
|
+
fidelity: string;
|
|
72
|
+
};
|
|
73
|
+
export declare const renderStructuralDiff: (diff: ReturnType<typeof diffStructuralSnapshots>) => string;
|