justskill-design 0.0.1 → 3.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/README.en.md +235 -0
- package/README.md +184 -11
- package/SKILL.md +435 -0
- package/canvas/app.mjs +2870 -0
- package/canvas/groups.mjs +204 -0
- package/canvas/index.html +141 -0
- package/canvas/styles.css +1032 -0
- package/canvas/tree.mjs +249 -0
- package/cordis.patch.yml +8 -0
- package/design.mjs +172 -0
- package/docs/dsh-workbench.png +0 -0
- package/dsh/client.js +864 -0
- package/dsh/index.js +354 -0
- package/dsh/scan.js +139 -0
- package/dsh/skill.js +41 -0
- package/lib/base-css.mjs +92 -0
- package/lib/detect.mjs +123 -0
- package/lib/diagnostics.mjs +382 -0
- package/lib/export-html.mjs +309 -0
- package/lib/export-svg.mjs +111 -0
- package/lib/fragment.mjs +702 -0
- package/lib/geometry.mjs +827 -0
- package/lib/icons.mjs +106 -0
- package/lib/levenshtein.mjs +133 -0
- package/lib/node/cli.mjs +387 -0
- package/lib/node/export-svg.mjs +75 -0
- package/lib/node/fs.mjs +309 -0
- package/lib/node/op-json.mjs +75 -0
- package/lib/node/raster-run.mjs +115 -0
- package/lib/node/server.mjs +1264 -0
- package/lib/node/static.mjs +206 -0
- package/lib/node/verbs/add.mjs +220 -0
- package/lib/node/verbs/export.mjs +161 -0
- package/lib/node/verbs/format.mjs +106 -0
- package/lib/node/verbs/import.mjs +126 -0
- package/lib/node/verbs/ls.mjs +98 -0
- package/lib/node/verbs/migrate.mjs +236 -0
- package/lib/node/verbs/move.mjs +76 -0
- package/lib/node/verbs/new.mjs +284 -0
- package/lib/node/verbs/remove.mjs +41 -0
- package/lib/node/verbs/rename.mjs +87 -0
- package/lib/node/verbs/serve.mjs +164 -0
- package/lib/node/verbs/set.mjs +150 -0
- package/lib/node/verbs/show.mjs +122 -0
- package/lib/node/verbs/tokens.mjs +167 -0
- package/lib/node/verbs/validate.mjs +130 -0
- package/lib/node/write-verbs.mjs +215 -0
- package/lib/normalize.mjs +288 -0
- package/lib/ops.mjs +2063 -0
- package/lib/parse.mjs +997 -0
- package/lib/raster.mjs +186 -0
- package/lib/readers/pen.mjs +1684 -0
- package/lib/receipt.mjs +203 -0
- package/lib/render.mjs +503 -0
- package/lib/serialize.mjs +441 -0
- package/lib/spans.mjs +177 -0
- package/lib/svg-path.mjs +483 -0
- package/lib/table.mjs +1047 -0
- package/lib/tokens.mjs +295 -0
- package/lib/validate.mjs +929 -0
- package/package.json +68 -6
- package/references/cookbook.md +156 -0
- package/references/images.md +171 -0
- package/references/product-flow.md +408 -0
- package/references/schema.md +90 -0
- package/references/styling.md +181 -0
- package/scripts/install.mjs +95 -0
package/lib/detect.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which reader opens this file?
|
|
3
|
+
*
|
|
4
|
+
* The extension decides first, because a caller who named a file `.html` means
|
|
5
|
+
* it. Content sniffing is the fallback for a file with no useful extension —
|
|
6
|
+
* `design` from stdin, or a fixture in a temp directory.
|
|
7
|
+
*
|
|
8
|
+
* PURE ESM — no `node:` imports, no filesystem. The caller reads the text; this
|
|
9
|
+
* module only decides what it is and which reader to load.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class DetectError extends Error {
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} code `detect/unknown-format`
|
|
15
|
+
* @param {string} message
|
|
16
|
+
*/
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "DetectError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Format name -> a lazy loader for its reader.
|
|
26
|
+
*
|
|
27
|
+
* Lazy so that `design.mjs --help` and an HTML-only session never load the pen
|
|
28
|
+
* reader, and so that a missing reader is an error at use, not at import.
|
|
29
|
+
*/
|
|
30
|
+
export const READERS = Object.freeze({
|
|
31
|
+
html: () => import("./parse.mjs"),
|
|
32
|
+
pen: () => import("./readers/pen.mjs"),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The loader for `format`.
|
|
37
|
+
*
|
|
38
|
+
* @param {"html"|"pen"|string} format
|
|
39
|
+
* @returns {() => Promise<object>}
|
|
40
|
+
* @throws {DetectError} `detect/unknown-format`
|
|
41
|
+
*/
|
|
42
|
+
export function readerFor(format) {
|
|
43
|
+
const load = READERS[format];
|
|
44
|
+
if (typeof load !== "function") {
|
|
45
|
+
const known = Object.keys(READERS).join(", ");
|
|
46
|
+
throw new DetectError(
|
|
47
|
+
"detect/unknown-format",
|
|
48
|
+
`No reader for format ${JSON.stringify(String(format))}: expected one of ${known}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return load;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The lower-case extension of a path, `.html` style, or `""`. */
|
|
55
|
+
export function extensionOf(file) {
|
|
56
|
+
if (typeof file !== "string" || file === "") return "";
|
|
57
|
+
const base = file.slice(file.lastIndexOf("/") + 1);
|
|
58
|
+
const dot = base.lastIndexOf(".");
|
|
59
|
+
if (dot <= 0) return "";
|
|
60
|
+
return base.slice(dot).toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const PEN_EXTENSIONS = new Set([".pen"]);
|
|
64
|
+
const HTML_EXTENSIONS = new Set([".html", ".htm"]);
|
|
65
|
+
|
|
66
|
+
/** True when the text is JSON with both a `version` and a `children` field. */
|
|
67
|
+
export function looksLikePen(text) {
|
|
68
|
+
if (typeof text !== "string") return false;
|
|
69
|
+
const trimmed = text.trimStart();
|
|
70
|
+
if (!trimmed.startsWith("{")) return false;
|
|
71
|
+
let value;
|
|
72
|
+
try {
|
|
73
|
+
value = JSON.parse(trimmed);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return (
|
|
78
|
+
value !== null &&
|
|
79
|
+
typeof value === "object" &&
|
|
80
|
+
!Array.isArray(value) &&
|
|
81
|
+
value.version !== undefined &&
|
|
82
|
+
value.children !== undefined
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when a `<` appears before the first `{`. */
|
|
87
|
+
export function looksLikeHtml(text) {
|
|
88
|
+
if (typeof text !== "string") return false;
|
|
89
|
+
const angle = text.indexOf("<");
|
|
90
|
+
if (angle === -1) return false;
|
|
91
|
+
const brace = text.indexOf("{");
|
|
92
|
+
return brace === -1 || angle < brace;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `"html"`, `"pen"` or `"unknown"`.
|
|
97
|
+
*
|
|
98
|
+
* Rules, in order:
|
|
99
|
+
* 1. `.pen` extension -> pen.
|
|
100
|
+
* 2. `.html` / `.htm` extension -> html.
|
|
101
|
+
* 3. text that starts with `{` and parses as JSON with a `version` and a
|
|
102
|
+
* `children` field -> pen.
|
|
103
|
+
* 4. a `<` before the first `{` -> html.
|
|
104
|
+
* 5. otherwise unknown.
|
|
105
|
+
*
|
|
106
|
+
* The extension beats the content in both directions, which is the rule a
|
|
107
|
+
* renamed file depends on: `page.html` that happens to hold pen JSON is still
|
|
108
|
+
* read as HTML, and a `.pen` file with markup is still read as pen.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} path
|
|
111
|
+
* @param {string} text
|
|
112
|
+
* @returns {"html"|"pen"|"unknown"}
|
|
113
|
+
*/
|
|
114
|
+
export function detectFormat(path, text) {
|
|
115
|
+
const extension = extensionOf(path);
|
|
116
|
+
if (PEN_EXTENSIONS.has(extension)) return "pen";
|
|
117
|
+
if (HTML_EXTENSIONS.has(extension)) return "html";
|
|
118
|
+
|
|
119
|
+
const source = typeof text === "string" ? text : "";
|
|
120
|
+
if (looksLikePen(source)) return "pen";
|
|
121
|
+
if (looksLikeHtml(source)) return "html";
|
|
122
|
+
return "unknown";
|
|
123
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostics: grouping them, filtering them, sorting them, and finding the
|
|
3
|
+
* element one is about.
|
|
4
|
+
*
|
|
5
|
+
* PURE ESM — no `node:` imports, no DOM. Everything a report decides is decided
|
|
6
|
+
* here, so a test can reach it.
|
|
7
|
+
*
|
|
8
|
+
* A v3 diagnostic looks like this (docs/format-v3.md §6):
|
|
9
|
+
*
|
|
10
|
+
* { severity: "error", code: "structure/duplicate-id", target: "home__title",
|
|
11
|
+
* path: "body > section > div > p", offset: 1234, line: 42, column: 7,
|
|
12
|
+
* message: "…", hint: "…", fix: "…" }
|
|
13
|
+
*
|
|
14
|
+
* `target` is a `data-id`; `offset` is a character offset into the source. A
|
|
15
|
+
* finding is located by either, and a finding that names no element inside a
|
|
16
|
+
* page is answered by that page's root.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SEVERITY_RANK = { error: 0, warning: 1 };
|
|
20
|
+
|
|
21
|
+
/** Fold a severity to the two values the format defines. */
|
|
22
|
+
export function normalizeSeverity(severity) {
|
|
23
|
+
return severity === "error" ? "error" : "warning";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rankOf(diagnostic) {
|
|
27
|
+
return SEVERITY_RANK[normalizeSeverity(diagnostic && diagnostic.severity)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function offsetOf(diagnostic) {
|
|
31
|
+
const value = diagnostic && diagnostic.offset;
|
|
32
|
+
return Number.isFinite(value) ? value : Infinity;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ------------------------------------------------------------------ *
|
|
36
|
+
* Reading the document model
|
|
37
|
+
* ------------------------------------------------------------------ */
|
|
38
|
+
|
|
39
|
+
function asElement(node) {
|
|
40
|
+
if (!node || typeof node !== "object") return false;
|
|
41
|
+
if (node.kind === "element") return true;
|
|
42
|
+
// Tolerant of a plain `{ tag, attrs }` object: the element contract needs both.
|
|
43
|
+
return typeof node.tag === "string" && Array.isArray(node.attrs);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The value of one attribute, or null.
|
|
48
|
+
*
|
|
49
|
+
* `attrs` is an array of `{ kind: "attr", name, rawName, value, … }` in document
|
|
50
|
+
* order. A Map or a plain object is accepted too, so an older tree still reads.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} node
|
|
53
|
+
* @param {string} name
|
|
54
|
+
* @returns {string|null}
|
|
55
|
+
*/
|
|
56
|
+
export function attrValue(node, name) {
|
|
57
|
+
if (!node || typeof node !== "object") return null;
|
|
58
|
+
const attrs = node.attrs;
|
|
59
|
+
if (!attrs) return null;
|
|
60
|
+
if (Array.isArray(attrs)) {
|
|
61
|
+
for (const attr of attrs) {
|
|
62
|
+
if (!attr) continue;
|
|
63
|
+
const key = typeof attr.name === "string" ? attr.name : attr.rawName;
|
|
64
|
+
if (key !== name) continue;
|
|
65
|
+
if (attr.value === undefined || attr.value === null) return "";
|
|
66
|
+
return typeof attr.value === "string" ? attr.value : String(attr.value);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (typeof attrs.get === "function") {
|
|
71
|
+
const value = attrs.get(name);
|
|
72
|
+
return value === undefined || value === null ? null : String(value);
|
|
73
|
+
}
|
|
74
|
+
if (typeof attrs === "object" && Object.prototype.hasOwnProperty.call(attrs, name)) {
|
|
75
|
+
const value = attrs[name];
|
|
76
|
+
return value === undefined || value === null ? null : String(value);
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The `data-id` of a node, or null. */
|
|
82
|
+
export function dataIdOf(node) {
|
|
83
|
+
const value = attrValue(node, "data-id");
|
|
84
|
+
return value === null || value === "" ? null : value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The `data-type` of a node, or null. */
|
|
88
|
+
export function dataTypeOf(node) {
|
|
89
|
+
const value = attrValue(node, "data-type");
|
|
90
|
+
return value === null || value === "" ? null : value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True for `<section data-type="page">`. */
|
|
94
|
+
export function isPageElement(node) {
|
|
95
|
+
return asElement(node) && dataTypeOf(node) === "page";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rootsOf(doc) {
|
|
99
|
+
if (!doc || typeof doc !== "object") return [];
|
|
100
|
+
if (Array.isArray(doc.children) && doc.children.length > 0) return doc.children;
|
|
101
|
+
const roots = [];
|
|
102
|
+
if (doc.html) roots.push(doc.html);
|
|
103
|
+
else {
|
|
104
|
+
if (doc.head) roots.push(doc.head);
|
|
105
|
+
if (doc.body) roots.push(doc.body);
|
|
106
|
+
}
|
|
107
|
+
return roots;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every element of a document, in document order, plus the lookups a report
|
|
112
|
+
* needs: parent links, and `data-id` -> element (first wins, so a duplicate id
|
|
113
|
+
* resolves to the element that owns it first).
|
|
114
|
+
*
|
|
115
|
+
* @param {object} doc
|
|
116
|
+
* @returns {{ elements: object[], parents: Map<object, object|null>, byId: Map<string, object> }}
|
|
117
|
+
*/
|
|
118
|
+
export function collectElements(doc) {
|
|
119
|
+
const elements = [];
|
|
120
|
+
const parents = new Map();
|
|
121
|
+
const byId = new Map();
|
|
122
|
+
|
|
123
|
+
const visit = (node, parent) => {
|
|
124
|
+
if (!node || typeof node !== "object") return;
|
|
125
|
+
if (Array.isArray(node)) {
|
|
126
|
+
for (const child of node) visit(child, parent);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (asElement(node)) {
|
|
130
|
+
elements.push(node);
|
|
131
|
+
const declared = node.parent && typeof node.parent === "object" ? node.parent : null;
|
|
132
|
+
parents.set(node, declared || parent || null);
|
|
133
|
+
const id = dataIdOf(node);
|
|
134
|
+
if (id !== null && !byId.has(id)) byId.set(id, node);
|
|
135
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
136
|
+
for (const child of children) visit(child, node);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
140
|
+
for (const child of children) visit(child, parent);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
for (const root of rootsOf(doc)) visit(root, null);
|
|
144
|
+
return { elements, parents, byId };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Every page of a document, in document order. */
|
|
148
|
+
export function pagesOf(doc, index) {
|
|
149
|
+
const elements = index ? index.elements : collectElements(doc).elements;
|
|
150
|
+
return elements.filter(isPageElement);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function enclosingPage(element, parents, fallback) {
|
|
154
|
+
let current = element;
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
while (current && typeof current === "object" && !seen.has(current)) {
|
|
157
|
+
seen.add(current);
|
|
158
|
+
if (isPageElement(current)) return current;
|
|
159
|
+
current = parents.get(current) || null;
|
|
160
|
+
}
|
|
161
|
+
return fallback || null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function deepestElementAt(elements, offset) {
|
|
165
|
+
let best = null;
|
|
166
|
+
for (const element of elements) {
|
|
167
|
+
const start = Number.isFinite(element.start) ? element.start : null;
|
|
168
|
+
const end = Number.isFinite(element.end) ? element.end : null;
|
|
169
|
+
if (start === null || end === null) continue;
|
|
170
|
+
if (offset < start || offset >= end) continue;
|
|
171
|
+
if (best === null || start >= best.start) best = element;
|
|
172
|
+
}
|
|
173
|
+
return best;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ------------------------------------------------------------------ *
|
|
177
|
+
* Sorting, grouping, filtering
|
|
178
|
+
* ------------------------------------------------------------------ */
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Errors before warnings, then by source offset, then by the order the
|
|
182
|
+
* validator produced. A finding with no offset sorts last inside its severity.
|
|
183
|
+
*
|
|
184
|
+
* @param {object[]} list
|
|
185
|
+
* @returns {object[]} a new array
|
|
186
|
+
*/
|
|
187
|
+
export function sortDiagnostics(list) {
|
|
188
|
+
const source = Array.isArray(list) ? list : [];
|
|
189
|
+
return source
|
|
190
|
+
.map((diagnostic, index) => ({ diagnostic, index }))
|
|
191
|
+
.sort((a, b) => {
|
|
192
|
+
const bySeverity = rankOf(a.diagnostic) - rankOf(b.diagnostic);
|
|
193
|
+
if (bySeverity !== 0) return bySeverity;
|
|
194
|
+
const byOffset = offsetOf(a.diagnostic) - offsetOf(b.diagnostic);
|
|
195
|
+
if (byOffset !== 0) return byOffset;
|
|
196
|
+
return a.index - b.index;
|
|
197
|
+
})
|
|
198
|
+
.map((entry) => entry.diagnostic);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* One row per `code`: the worst severity in the group, how many findings it has,
|
|
203
|
+
* the distinct `data-id`s it points at, and the message of its first finding.
|
|
204
|
+
*
|
|
205
|
+
* Sorted by severity, then by count descending, then by code, so the order never
|
|
206
|
+
* depends on the order findings arrived in.
|
|
207
|
+
*
|
|
208
|
+
* @param {object[]} list
|
|
209
|
+
* @returns {{code: string, severity: string, count: number, targets: string[], message: string}[]}
|
|
210
|
+
*/
|
|
211
|
+
export function groupDiagnostics(list) {
|
|
212
|
+
const groups = new Map();
|
|
213
|
+
for (const diagnostic of Array.isArray(list) ? list : []) {
|
|
214
|
+
if (!diagnostic) continue;
|
|
215
|
+
const code = typeof diagnostic.code === "string" && diagnostic.code !== "" ? diagnostic.code : "(no code)";
|
|
216
|
+
const severity = normalizeSeverity(diagnostic.severity);
|
|
217
|
+
let group = groups.get(code);
|
|
218
|
+
if (!group) {
|
|
219
|
+
group = {
|
|
220
|
+
code,
|
|
221
|
+
severity,
|
|
222
|
+
count: 0,
|
|
223
|
+
targets: [],
|
|
224
|
+
message: typeof diagnostic.message === "string" ? diagnostic.message : "",
|
|
225
|
+
};
|
|
226
|
+
groups.set(code, group);
|
|
227
|
+
}
|
|
228
|
+
// A code normally has one severity; if it ever has two, the group is as bad
|
|
229
|
+
// as its worst finding rather than as good as its first.
|
|
230
|
+
if (SEVERITY_RANK[severity] < SEVERITY_RANK[group.severity]) group.severity = severity;
|
|
231
|
+
group.count += 1;
|
|
232
|
+
const target = typeof diagnostic.target === "string" && diagnostic.target !== "" ? diagnostic.target : null;
|
|
233
|
+
if (target !== null && !group.targets.includes(target)) group.targets.push(target);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return [...groups.values()].sort((a, b) => {
|
|
237
|
+
const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
|
238
|
+
if (bySeverity !== 0) return bySeverity;
|
|
239
|
+
if (a.count !== b.count) return b.count - a.count;
|
|
240
|
+
return a.code < b.code ? -1 : a.code > b.code ? 1 : 0;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Findings of one severity and/or one code.
|
|
246
|
+
*
|
|
247
|
+
* @param {object[]} list
|
|
248
|
+
* @param {{severity?: string, code?: string|string[]}|string} [options]
|
|
249
|
+
* A string is read as `severity`, which is the v1 signature.
|
|
250
|
+
* @returns {object[]} a copy, so a caller cannot mutate the document's list
|
|
251
|
+
*/
|
|
252
|
+
export function filterDiagnostics(list, options = {}) {
|
|
253
|
+
const source = Array.isArray(list) ? list : [];
|
|
254
|
+
const settings = typeof options === "string" ? { severity: options } : options || {};
|
|
255
|
+
|
|
256
|
+
const severity = settings.severity === undefined || settings.severity === "all" ? null : String(settings.severity);
|
|
257
|
+
const rawCode = settings.code;
|
|
258
|
+
const codes =
|
|
259
|
+
rawCode === undefined || rawCode === null || rawCode === "all"
|
|
260
|
+
? null
|
|
261
|
+
: new Set((Array.isArray(rawCode) ? rawCode : [rawCode]).map(String));
|
|
262
|
+
|
|
263
|
+
return source.filter((diagnostic) => {
|
|
264
|
+
if (!diagnostic) return false;
|
|
265
|
+
if (severity !== null && normalizeSeverity(diagnostic.severity) !== severity) return false;
|
|
266
|
+
if (codes !== null && !codes.has(String(diagnostic.code))) return false;
|
|
267
|
+
return true;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `{ errors, warnings, total }` for a list of diagnostics. */
|
|
272
|
+
export function summarize(list) {
|
|
273
|
+
const source = Array.isArray(list) ? list : [];
|
|
274
|
+
let errors = 0;
|
|
275
|
+
let warnings = 0;
|
|
276
|
+
for (const diagnostic of source) {
|
|
277
|
+
if (normalizeSeverity(diagnostic && diagnostic.severity) === "error") errors += 1;
|
|
278
|
+
else warnings += 1;
|
|
279
|
+
}
|
|
280
|
+
return { errors, warnings, total: source.length, ok: errors === 0 };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The identity of a diagnostic — docs/format-v3.md §6. Used to refuse only on a
|
|
285
|
+
* finding that was not there before.
|
|
286
|
+
*
|
|
287
|
+
* Four fields, and `target` earns its place: a diagnostic with no `offset` is
|
|
288
|
+
* common, and without `target` two errors that differ only in which element they
|
|
289
|
+
* are about collapse into one key. The gate would still refuse the edit, and the
|
|
290
|
+
* report would then show one row where there were two — a silent drop, which is
|
|
291
|
+
* the one thing this format does not do.
|
|
292
|
+
*
|
|
293
|
+
* This is the only implementation. `lib/validate.mjs` imports and re-exports it
|
|
294
|
+
* so the gate, the report and the documented contract cannot drift apart again.
|
|
295
|
+
*
|
|
296
|
+
* @param {object} diagnostic
|
|
297
|
+
* @returns {string}
|
|
298
|
+
*/
|
|
299
|
+
export function diagnosticKey(diagnostic) {
|
|
300
|
+
if (!diagnostic) return "|||";
|
|
301
|
+
const code = typeof diagnostic.code === "string" ? diagnostic.code : "";
|
|
302
|
+
const offset = Number.isFinite(diagnostic.offset) ? diagnostic.offset : "";
|
|
303
|
+
const target = typeof diagnostic.target === "string" ? diagnostic.target : "";
|
|
304
|
+
const message = typeof diagnostic.message === "string" ? diagnostic.message : "";
|
|
305
|
+
return `${code}|${offset}|${target}|${message}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Drop duplicates that share a `diagnosticKey`, keeping the first. */
|
|
309
|
+
export function dedupeDiagnostics(list) {
|
|
310
|
+
const seen = new Set();
|
|
311
|
+
const out = [];
|
|
312
|
+
for (const diagnostic of Array.isArray(list) ? list : []) {
|
|
313
|
+
if (!diagnostic) continue;
|
|
314
|
+
const key = diagnosticKey(diagnostic);
|
|
315
|
+
if (seen.has(key)) continue;
|
|
316
|
+
seen.add(key);
|
|
317
|
+
out.push(diagnostic);
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* ------------------------------------------------------------------ *
|
|
323
|
+
* Locating a finding
|
|
324
|
+
* ------------------------------------------------------------------ */
|
|
325
|
+
|
|
326
|
+
function looksLikeDocument(value) {
|
|
327
|
+
if (!value || typeof value !== "object") return false;
|
|
328
|
+
if (value.kind === "document") return true;
|
|
329
|
+
return Array.isArray(value.children) && (value.body !== undefined || value.html !== undefined || value.children.length > 0);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Where on the canvas a diagnostic lives.
|
|
334
|
+
*
|
|
335
|
+
* The walk starts at the element named by `diagnostic.target` (a `data-id`), or
|
|
336
|
+
* failing that at the deepest element whose `[start, end)` contains
|
|
337
|
+
* `diagnostic.offset`. From there it walks up to the enclosing page. When the
|
|
338
|
+
* start element carries no `data-id` — an unrecognised tag, a text finding — the
|
|
339
|
+
* answer is the page root, which is the node that stands for the page.
|
|
340
|
+
*
|
|
341
|
+
* When the finding names no element and the file has exactly one page, that page
|
|
342
|
+
* is the answer; with several pages and no location there is nothing honest to
|
|
343
|
+
* point at, and the result is null.
|
|
344
|
+
*
|
|
345
|
+
* @param {object} diagnostic
|
|
346
|
+
* @param {object} doc
|
|
347
|
+
* @returns {{pageId: string|null, nodeId: string|null}|null}
|
|
348
|
+
*/
|
|
349
|
+
export function resolveDiagnosticTarget(diagnostic, doc) {
|
|
350
|
+
// The contract is (diagnostic, doc); a caller that swapped them is understood
|
|
351
|
+
// rather than answered with a wrong location.
|
|
352
|
+
if (looksLikeDocument(diagnostic) && !looksLikeDocument(doc)) {
|
|
353
|
+
const swap = diagnostic;
|
|
354
|
+
diagnostic = doc;
|
|
355
|
+
doc = swap;
|
|
356
|
+
}
|
|
357
|
+
if (!diagnostic || !doc) return null;
|
|
358
|
+
const index = collectElements(doc);
|
|
359
|
+
const pages = index.elements.filter(isPageElement);
|
|
360
|
+
|
|
361
|
+
let element = null;
|
|
362
|
+
if (typeof diagnostic.target === "string" && diagnostic.target !== "") {
|
|
363
|
+
element = index.byId.get(diagnostic.target) || null;
|
|
364
|
+
}
|
|
365
|
+
if (!element && Number.isFinite(diagnostic.offset)) {
|
|
366
|
+
element = deepestElementAt(index.elements, diagnostic.offset);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (element) {
|
|
370
|
+
const page = enclosingPage(element, index.parents, pages.length === 1 ? pages[0] : null);
|
|
371
|
+
if (!page) return null;
|
|
372
|
+
const pageId = dataIdOf(page);
|
|
373
|
+
const nodeId = dataIdOf(element) || pageId;
|
|
374
|
+
return { pageId, nodeId };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (pages.length === 1) {
|
|
378
|
+
const pageId = dataIdOf(pages[0]);
|
|
379
|
+
return { pageId, nodeId: pageId };
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|