@jxsuite/schema 5.3.0 → 5.5.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/package.json +1 -1
- package/src/guards.ts +114 -1
- package/src/jxn/index.ts +2 -1
- package/src/jxn/lint.ts +7 -1
- package/src/jxn/parse.ts +30 -13
- package/src/jxn/project.ts +248 -0
- package/src/jxn/serialize.ts +7 -7
- package/src/project-file-node.ts +83 -9
- package/src/project-file.ts +123 -20
- package/src/validate-project.ts +71 -10
package/package.json
CHANGED
package/src/guards.ts
CHANGED
|
@@ -242,7 +242,120 @@ export function isTemplateString(value?: unknown): value is string {
|
|
|
242
242
|
* because a newline after `return` is an ASI bare return.
|
|
243
243
|
*/
|
|
244
244
|
export function bodyReturnsValue(body: string): boolean {
|
|
245
|
-
return /\breturn\b[^\S\n]*(?![;}\s]|$)/.test(body);
|
|
245
|
+
return /\breturn\b[^\S\n]*(?![;}\s]|$)/.test(maskCode(body));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A function body with its comments and the contents of its strings blanked, so a pattern reads
|
|
250
|
+
* only code (`spec.md` §23.1). The result is the same length, every newline kept, so an offset in
|
|
251
|
+
* it is an offset in the body. A string keeps its quotes, so `return "x"` still returns a value,
|
|
252
|
+
* and a template literal's `${…}` keeps its contents, which are code.
|
|
253
|
+
*
|
|
254
|
+
* A scanner and not a parser, because the interpreter must answer the same way and cannot carry
|
|
255
|
+
* one. Its known limit: a regular expression literal is read as code, so a quote inside one can
|
|
256
|
+
* blank what follows it.
|
|
257
|
+
*
|
|
258
|
+
* @param body The body, already JavaScript.
|
|
259
|
+
*/
|
|
260
|
+
export function maskCode(body: string): string {
|
|
261
|
+
// Nothing can open a comment or a string: the body is all code, and most handlers are.
|
|
262
|
+
if (!/[/'"`]/u.test(body)) {
|
|
263
|
+
return body;
|
|
264
|
+
}
|
|
265
|
+
const known = masked.get(body);
|
|
266
|
+
if (known !== undefined) {
|
|
267
|
+
return known;
|
|
268
|
+
}
|
|
269
|
+
const result = scanCode(body);
|
|
270
|
+
if (masked.size >= MASKED_LIMIT) {
|
|
271
|
+
masked.clear();
|
|
272
|
+
}
|
|
273
|
+
masked.set(body, result);
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Bodies already masked. Every pattern a compile runs over a body reads it masked, and a dev server
|
|
279
|
+
* compiles the same bodies again on every change, so the scan is done once per text. Bounded,
|
|
280
|
+
* because a long session sees many texts.
|
|
281
|
+
*/
|
|
282
|
+
const masked = new Map<string, string>();
|
|
283
|
+
const MASKED_LIMIT = 2048;
|
|
284
|
+
|
|
285
|
+
/** The scan {@link maskCode} caches. */
|
|
286
|
+
function scanCode(body: string): string {
|
|
287
|
+
/* UTF-16 units, as `at` counts them. Code points (`[...body]`) made an astral character in a
|
|
288
|
+
string one element for two offsets, so the result came back short and every offset after it,
|
|
289
|
+
which is what a caller splices the body at, was off by one. */
|
|
290
|
+
const out = body.split("");
|
|
291
|
+
const blank = (at: number): void => {
|
|
292
|
+
if (out[at] !== undefined && out[at] !== "\n") {
|
|
293
|
+
out[at] = " ";
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
/** The brace depth of each template substitution still open, innermost last. */
|
|
297
|
+
const substitutions: number[] = [];
|
|
298
|
+
let inTemplate = false;
|
|
299
|
+
let at = 0;
|
|
300
|
+
while (at < body.length) {
|
|
301
|
+
const char = body.charAt(at);
|
|
302
|
+
if (inTemplate) {
|
|
303
|
+
if (char === "\\") {
|
|
304
|
+
blank(at);
|
|
305
|
+
blank(at + 1);
|
|
306
|
+
at += 2;
|
|
307
|
+
} else if (char === "`") {
|
|
308
|
+
inTemplate = false;
|
|
309
|
+
at += 1;
|
|
310
|
+
} else if (char === "$" && body.charAt(at + 1) === "{") {
|
|
311
|
+
substitutions.push(0);
|
|
312
|
+
inTemplate = false;
|
|
313
|
+
at += 2;
|
|
314
|
+
} else {
|
|
315
|
+
blank(at);
|
|
316
|
+
at += 1;
|
|
317
|
+
}
|
|
318
|
+
} else if (char === "/" && body.charAt(at + 1) === "/") {
|
|
319
|
+
while (at < body.length && body.charAt(at) !== "\n") {
|
|
320
|
+
blank(at);
|
|
321
|
+
at += 1;
|
|
322
|
+
}
|
|
323
|
+
} else if (char === "/" && body.charAt(at + 1) === "*") {
|
|
324
|
+
const end = body.indexOf("*/", at + 2);
|
|
325
|
+
const stop = end === -1 ? body.length : end + 2;
|
|
326
|
+
for (; at < stop; at += 1) {
|
|
327
|
+
blank(at);
|
|
328
|
+
}
|
|
329
|
+
} else if (char === "'" || char === '"') {
|
|
330
|
+
at += 1;
|
|
331
|
+
while (at < body.length && body.charAt(at) !== char && body.charAt(at) !== "\n") {
|
|
332
|
+
if (body.charAt(at) === "\\") {
|
|
333
|
+
blank(at);
|
|
334
|
+
at += 1;
|
|
335
|
+
}
|
|
336
|
+
blank(at);
|
|
337
|
+
at += 1;
|
|
338
|
+
}
|
|
339
|
+
at += 1;
|
|
340
|
+
} else if (char === "`") {
|
|
341
|
+
inTemplate = true;
|
|
342
|
+
at += 1;
|
|
343
|
+
} else {
|
|
344
|
+
const open = substitutions.length - 1;
|
|
345
|
+
if (open >= 0 && char === "{") {
|
|
346
|
+
substitutions[open] = (substitutions[open] ?? 0) + 1;
|
|
347
|
+
} else if (open >= 0 && char === "}") {
|
|
348
|
+
if (substitutions[open] === 0) {
|
|
349
|
+
substitutions.pop();
|
|
350
|
+
inTemplate = true;
|
|
351
|
+
} else {
|
|
352
|
+
substitutions[open] = (substitutions[open] ?? 1) - 1;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
at += 1;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return out.join("");
|
|
246
359
|
}
|
|
247
360
|
|
|
248
361
|
// ─── Style guards & accessors ───────────────────────────────────────────────────
|
package/src/jxn/index.ts
CHANGED
|
@@ -12,7 +12,8 @@ export { JxnError } from "./errors.ts";
|
|
|
12
12
|
export type { JxnSpan } from "./errors.ts";
|
|
13
13
|
export { countJxnComments, lintJxn } from "./lint.ts";
|
|
14
14
|
export type { JxnWarning } from "./lint.ts";
|
|
15
|
-
export { parseJxn, parseJxnWithSpans } from "./parse.ts";
|
|
15
|
+
export { kdlProblem, parseJxn, parseJxnWithSpans } from "./parse.ts";
|
|
16
16
|
export { reconcileJxn } from "./reconcile.ts";
|
|
17
17
|
export type { JxnReader, JxnReading } from "./reconcile.ts";
|
|
18
18
|
export { serializeJxn } from "./serialize.ts";
|
|
19
|
+
export { parseProject, parseProjectWithSpans, serializeProject } from "./project.ts";
|
package/src/jxn/lint.ts
CHANGED
|
@@ -34,10 +34,16 @@ interface JxnScan {
|
|
|
34
34
|
escapes: number[];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The `/- kdl-version 2` line a file may open with. It is a slashdash by spelling, but it says
|
|
39
|
+
* which KDL the file is written in and holds nothing `.json` would lose, so it is not counted.
|
|
40
|
+
*/
|
|
41
|
+
const VERSION_MARKER = /^\uFEFF?\s*\/-[^\S\n]*kdl-version[^\S\n]+\d+[^\S\n]*(?:\n|$)/u;
|
|
42
|
+
|
|
37
43
|
/** Walk a file's text, knowing what is a string and what is a comment. */
|
|
38
44
|
function scanJxn(text: string): JxnScan {
|
|
39
45
|
const scan: JxnScan = { comments: 0, escapes: [] };
|
|
40
|
-
let index = 0;
|
|
46
|
+
let index = VERSION_MARKER.exec(text)?.[0].length ?? 0;
|
|
41
47
|
while (index < text.length) {
|
|
42
48
|
if (text.startsWith("//", index)) {
|
|
43
49
|
scan.comments += 1;
|
package/src/jxn/parse.ts
CHANGED
|
@@ -28,14 +28,14 @@ const ANNOTATIONS = new Set(["array", "fn", "object", "on", "ref"]);
|
|
|
28
28
|
const RESERVED_CHILDREN = new Set(["attributes", "script", "state", "style"]);
|
|
29
29
|
|
|
30
30
|
/** What one read of a file is building. */
|
|
31
|
-
interface Reader {
|
|
31
|
+
export interface Reader {
|
|
32
32
|
/** Where each thing came from, by JSON Pointer, when that was asked for. */
|
|
33
33
|
spans: Map<string, JxnSpan> | null;
|
|
34
34
|
text: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** Set a key, refusing a repeat and a key that would be a prototype rather than data. */
|
|
38
|
-
function setKey(
|
|
38
|
+
export function setKey(
|
|
39
39
|
target: JsonObject,
|
|
40
40
|
key: string,
|
|
41
41
|
value: JsonValue,
|
|
@@ -53,7 +53,7 @@ function setKey(
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/** An error at a node or entry's place in the file. */
|
|
56
|
-
function failure(reader: Reader, at: Entry | Node, message: string): JxnError {
|
|
56
|
+
export function failure(reader: Reader, at: Entry | Node, message: string): JxnError {
|
|
57
57
|
const start = spanOf(at)?.start;
|
|
58
58
|
return new JxnError(message, start === undefined ? null : locate(reader.text, start));
|
|
59
59
|
}
|
|
@@ -65,7 +65,7 @@ function spanOf(at: Entry | Node): JxnSpan | null {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Record where a pointer's value came from. */
|
|
68
|
-
function mark(reader: Reader, pointer: string, at: Entry | Node): void {
|
|
68
|
+
export function mark(reader: Reader, pointer: string, at: Entry | Node): void {
|
|
69
69
|
const span = reader.spans ? spanOf(at) : null;
|
|
70
70
|
if (span) {
|
|
71
71
|
reader.spans?.set(pointer, span);
|
|
@@ -73,12 +73,12 @@ function mark(reader: Reader, pointer: string, at: Entry | Node): void {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/** A JSON Pointer token. */
|
|
76
|
-
function token(key: string): string {
|
|
76
|
+
export function token(key: string): string {
|
|
77
77
|
return key.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** A node's annotation, checked. */
|
|
81
|
-
function annotationOf(reader: Reader, node: Node): string | null {
|
|
81
|
+
export function annotationOf(reader: Reader, node: Node): string | null {
|
|
82
82
|
const tag = node.getTag();
|
|
83
83
|
if (tag !== null && !ANNOTATIONS.has(tag)) {
|
|
84
84
|
throw failure(reader, node, `(${tag}) is not an annotation Jx Notation has`);
|
|
@@ -114,17 +114,17 @@ function valueOf(reader: Reader, entry: Entry): JsonValue {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/** A node's positional values, in order. */
|
|
117
|
-
function positionals(reader: Reader, node: Node): JsonValue[] {
|
|
117
|
+
export function positionals(reader: Reader, node: Node): JsonValue[] {
|
|
118
118
|
return node.entries.filter((entry) => !entry.name).map((entry) => valueOf(reader, entry));
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/** A node's named entries. */
|
|
122
|
-
function properties(node: Node): Entry[] {
|
|
122
|
+
export function properties(node: Node): Entry[] {
|
|
123
123
|
return node.entries.filter((entry) => entry.name);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/** The node's children, or none. */
|
|
127
|
-
function childrenOf(node: Node): readonly Node[] {
|
|
127
|
+
export function childrenOf(node: Node): readonly Node[] {
|
|
128
128
|
return node.children?.nodes ?? [];
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -169,7 +169,7 @@ function readItems(reader: Reader, kids: readonly Node[], pointer: string): Json
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/** A JSON block: JSON-in-KDL, plus `(ref)` and `(fn)`. */
|
|
172
|
-
function readValue(reader: Reader, node: Node, pointer: string): JsonValue {
|
|
172
|
+
export function readValue(reader: Reader, node: Node, pointer: string): JsonValue {
|
|
173
173
|
const tag = annotationOf(reader, node);
|
|
174
174
|
mark(reader, pointer, node);
|
|
175
175
|
if (tag === "fn") {
|
|
@@ -217,7 +217,7 @@ function readValue(reader: Reader, node: Node, pointer: string): JsonValue {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
/** A `style` block: scalars, and nested blocks, with source order significant. */
|
|
220
|
-
function readStyle(reader: Reader, node: Node, pointer: string): JsonObject {
|
|
220
|
+
export function readStyle(reader: Reader, node: Node, pointer: string): JsonObject {
|
|
221
221
|
const style: JsonObject = {};
|
|
222
222
|
mark(reader, pointer, node);
|
|
223
223
|
if (positionals(reader, node).length > 0) {
|
|
@@ -480,7 +480,7 @@ function readElementChild(
|
|
|
480
480
|
* @param reader The read.
|
|
481
481
|
* @param node The `$head` node.
|
|
482
482
|
*/
|
|
483
|
-
function readHead(reader: Reader, node: Node): JsonValue {
|
|
483
|
+
export function readHead(reader: Reader, node: Node): JsonValue {
|
|
484
484
|
return childrenOf(node).map((kid, index) => {
|
|
485
485
|
const pointer = `/$head/${index}`;
|
|
486
486
|
return kid.getName() === "-"
|
|
@@ -489,8 +489,25 @@ function readHead(reader: Reader, node: Node): JsonValue {
|
|
|
489
489
|
});
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Why a file is not KDL at all, or null when it is: the test for a file that is not Jx Notation
|
|
494
|
+
* before anything asks what it means (desktop.md §7.8). A file that parses here may still not be a
|
|
495
|
+
* valid document; that is `parseJxn`'s answer.
|
|
496
|
+
*
|
|
497
|
+
* @param text The file.
|
|
498
|
+
* @returns The reason with its line and column, or null.
|
|
499
|
+
*/
|
|
500
|
+
export function kdlProblem(text: string): string | null {
|
|
501
|
+
try {
|
|
502
|
+
parseSource(text);
|
|
503
|
+
return null;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
return error instanceof Error ? error.message : String(error);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
492
509
|
/** Parse KDL, and turn its error into ours. */
|
|
493
|
-
function parseSource(text: string): Document {
|
|
510
|
+
export function parseSource(text: string): Document {
|
|
494
511
|
const control = RAW_CONTROL.exec(text);
|
|
495
512
|
if (control) {
|
|
496
513
|
throw new JxnError(
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project file in Jx Notation: `project.jxn` (`site-architecture.md` §2.3).
|
|
3
|
+
*
|
|
4
|
+
* The grammar is `spec.md` §22's, with a different top level. There is no root element, and every
|
|
5
|
+
* node is a member of the configuration. `$head` has element rows, `style` and `$media` have style
|
|
6
|
+
* rows, and everything else is a JSON block. So the `$ref`, duplicate-key, NFC and refusal rules
|
|
7
|
+
* are the document grammar's own, and a person who can write one can write the other.
|
|
8
|
+
*
|
|
9
|
+
* The file declares itself with a `project` node, the first after any `$` metadata, whose one
|
|
10
|
+
* optional value is the project's `name`. `classifyJxn` reads that node without parsing the file.
|
|
11
|
+
*
|
|
12
|
+
* @docs framework/concepts/jxn-files
|
|
13
|
+
*/
|
|
14
|
+
import type { Node } from "@bgotink/kdl";
|
|
15
|
+
import type { JsonObject, JsonValue, ProjectConfig } from "../../types.ts";
|
|
16
|
+
import { isJsonObject } from "../guards.ts";
|
|
17
|
+
import { normalizeObject } from "../parse.ts";
|
|
18
|
+
import { classifyJxn } from "./classify.ts";
|
|
19
|
+
import { JxnError } from "./errors.ts";
|
|
20
|
+
import type { JxnSpan } from "./errors.ts";
|
|
21
|
+
import {
|
|
22
|
+
annotationOf,
|
|
23
|
+
childrenOf,
|
|
24
|
+
failure,
|
|
25
|
+
mark,
|
|
26
|
+
parseSource,
|
|
27
|
+
positionals,
|
|
28
|
+
properties,
|
|
29
|
+
readHead,
|
|
30
|
+
readStyle,
|
|
31
|
+
readValue,
|
|
32
|
+
setKey,
|
|
33
|
+
token,
|
|
34
|
+
} from "./parse.ts";
|
|
35
|
+
import type { Reader } from "./parse.ts";
|
|
36
|
+
import { reconcileJxn } from "./reconcile.ts";
|
|
37
|
+
import type { JxnReading } from "./reconcile.ts";
|
|
38
|
+
import { INDENT, down, isRef, scalar, writeHeadBlock, writeName, writeValue } from "./serialize.ts";
|
|
39
|
+
|
|
40
|
+
/** The node that makes a `.jxn` file a project's. */
|
|
41
|
+
const PROJECT_NODE = "project";
|
|
42
|
+
|
|
43
|
+
/** The members whose rows are style declarations, in source order. */
|
|
44
|
+
const STYLE_MEMBERS = new Set(["$media", "style"]);
|
|
45
|
+
|
|
46
|
+
/** Read the `project` node: the file's kind, and the project's name. */
|
|
47
|
+
function readProjectNode(reader: Reader, node: Node, config: JsonObject): void {
|
|
48
|
+
if (annotationOf(reader, node) !== null) {
|
|
49
|
+
throw failure(reader, node, `"project" names the file's kind, and takes no annotation`);
|
|
50
|
+
}
|
|
51
|
+
const values = positionals(reader, node);
|
|
52
|
+
if (values.length > 1 || properties(node).length > 0 || childrenOf(node).length > 0) {
|
|
53
|
+
throw failure(reader, node, `"project" holds one value, the project's name, and nothing else`);
|
|
54
|
+
}
|
|
55
|
+
if (values[0] !== undefined) {
|
|
56
|
+
setKey(config, "name", values[0], reader, node);
|
|
57
|
+
mark(reader, "/name", node);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read one top-level member. */
|
|
62
|
+
function readMember(reader: Reader, node: Node, config: JsonObject): void {
|
|
63
|
+
const name = node.getName();
|
|
64
|
+
const pointer = `/${token(name)}`;
|
|
65
|
+
const isPlain = node.getTag() === null;
|
|
66
|
+
let value: JsonValue;
|
|
67
|
+
if (name === "$head" && isPlain) {
|
|
68
|
+
value = readHead(reader, node);
|
|
69
|
+
mark(reader, pointer, node);
|
|
70
|
+
} else if (STYLE_MEMBERS.has(name) && isPlain) {
|
|
71
|
+
value = readStyle(reader, node, pointer);
|
|
72
|
+
} else {
|
|
73
|
+
value = readValue(reader, node, pointer);
|
|
74
|
+
}
|
|
75
|
+
setKey(config, name, value, reader, node);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read a whole project file. */
|
|
79
|
+
function readProject(reader: Reader): ProjectConfig {
|
|
80
|
+
if (classifyJxn(reader.text) === "document") {
|
|
81
|
+
throw new JxnError(
|
|
82
|
+
`A project file declares itself: its first node, after any $ metadata, is project "<name>"`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const source = parseSource(reader.text);
|
|
86
|
+
const config: JsonObject = {};
|
|
87
|
+
let declared = false;
|
|
88
|
+
for (const node of source.nodes) {
|
|
89
|
+
const name = node.getName();
|
|
90
|
+
if (name === PROJECT_NODE) {
|
|
91
|
+
if (declared) {
|
|
92
|
+
throw failure(reader, node, `"project" is written twice, and a file declares one project`);
|
|
93
|
+
}
|
|
94
|
+
readProjectNode(reader, node, config);
|
|
95
|
+
declared = true;
|
|
96
|
+
} else {
|
|
97
|
+
// `classifyJxn` has already said that nothing but `$` metadata comes before the declaration.
|
|
98
|
+
readMember(reader, node, config);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/* SAFETY: every ProjectConfig member is optional, so any object is a structurally admissible
|
|
102
|
+
config; member-level checking is `validateProject`'s job, as it is for `project.json`. */
|
|
103
|
+
return normalizeObject(config) as ProjectConfig;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read a `project.jxn` file.
|
|
108
|
+
*
|
|
109
|
+
* @param text The file.
|
|
110
|
+
* @param path Its path, when an error should name it.
|
|
111
|
+
* @returns The same `ProjectConfig` its `project.json` spelling reads to.
|
|
112
|
+
* @throws {JxnError} When it is not one, naming the line and column where it can.
|
|
113
|
+
*/
|
|
114
|
+
export function parseProject(text: string, path?: string): ProjectConfig {
|
|
115
|
+
try {
|
|
116
|
+
return readProject({ spans: null, text });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw path !== undefined && error instanceof JxnError ? error.inFile(path) : error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read a `project.jxn` file, and where in the file each part of it came from.
|
|
124
|
+
*
|
|
125
|
+
* @param text The file.
|
|
126
|
+
* @throws {JxnError} When it is not one.
|
|
127
|
+
*/
|
|
128
|
+
export function parseProjectWithSpans(text: string) {
|
|
129
|
+
const spans = new Map<string, JxnSpan>();
|
|
130
|
+
return { config: readProject({ spans, text }), spans };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Style rows, one declaration per line, because `--="1280px"` on the node's own line reads badly
|
|
135
|
+
* and a project's tokens are what people scan.
|
|
136
|
+
*/
|
|
137
|
+
function writeStyleRows(key: string, style: JsonValue, indent: string, where: string): string[] {
|
|
138
|
+
if (!isJsonObject(style) || isRef(style)) {
|
|
139
|
+
throw new JxnError(`${where} is a set of declarations, and this is not one`);
|
|
140
|
+
}
|
|
141
|
+
const entries = Object.entries(style);
|
|
142
|
+
const head = indent + writeName(key, where);
|
|
143
|
+
if (entries.length === 0) {
|
|
144
|
+
return [`${head} {}`];
|
|
145
|
+
}
|
|
146
|
+
const inner = indent + INDENT;
|
|
147
|
+
const rows = entries.flatMap(([entry, value]) => {
|
|
148
|
+
const at = down(where, entry);
|
|
149
|
+
const spelled = scalar(value, inner, at);
|
|
150
|
+
if (spelled !== null) {
|
|
151
|
+
return [`${inner}${writeName(entry, at)} ${spelled}`];
|
|
152
|
+
}
|
|
153
|
+
return isJsonObject(value)
|
|
154
|
+
? writeStyleRows(entry, value, inner, at)
|
|
155
|
+
: // A list, such as the several `@font-face` a stylesheet can hold, is the one other thing.
|
|
156
|
+
writeValue(entry, value, inner, at);
|
|
157
|
+
});
|
|
158
|
+
return [`${head} {`, ...rows, `${indent}}`];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One top-level member, in its canonical form. */
|
|
162
|
+
function writeMember(key: string, value: JsonValue): string[] {
|
|
163
|
+
const where = down("", key);
|
|
164
|
+
if (key === "$head") {
|
|
165
|
+
return Array.isArray(value)
|
|
166
|
+
? writeHeadBlock(value, INDENT)
|
|
167
|
+
: // Not a list, so not rows. The annotation is what stops it being read as them.
|
|
168
|
+
[`(object)${writeValue(key, value, "", where).join("\n")}`];
|
|
169
|
+
}
|
|
170
|
+
return STYLE_MEMBERS.has(key)
|
|
171
|
+
? writeStyleRows(key, value, "", where)
|
|
172
|
+
: writeValue(key, value, "", where);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A project file in its canonical form. */
|
|
176
|
+
function writeCanonical(config: ProjectConfig): string {
|
|
177
|
+
if (!isJsonObject(config)) {
|
|
178
|
+
throw new JxnError("A project's configuration is an object");
|
|
179
|
+
}
|
|
180
|
+
const source: JsonObject = config;
|
|
181
|
+
const header = ["/- kdl-version 2"];
|
|
182
|
+
const sections: string[][] = [];
|
|
183
|
+
const name = scalar(source.name, "", "/name");
|
|
184
|
+
for (const [key, value] of Object.entries(source)) {
|
|
185
|
+
if (key === "name" && name !== null) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const isRows = key === "$head" || STYLE_MEMBERS.has(key);
|
|
189
|
+
const spelled = key.startsWith("$") && !isRows ? scalar(value, "", down("", key)) : null;
|
|
190
|
+
// Short metadata such as `$schema` stays above the declaration, where a document has it.
|
|
191
|
+
if (spelled === null || spelled.includes("\n")) {
|
|
192
|
+
sections.push(writeMember(key, value));
|
|
193
|
+
} else {
|
|
194
|
+
header.push(`${writeName(key, down("", key))} ${spelled}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
header.push(name === null ? PROJECT_NODE : `${PROJECT_NODE} ${name}`);
|
|
198
|
+
return `${[header, ...sections].map((lines) => lines.join("\n")).join("\n\n")}\n`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Read a file the way {@link reconcileJxn} wants to be told about it. */
|
|
202
|
+
function readForReconcile(text: string): JxnReading {
|
|
203
|
+
const { config, spans } = parseProjectWithSpans(text);
|
|
204
|
+
return { document: isJsonObject(config) ? config : null, spans };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Write a project's configuration as a `project.jxn` file.
|
|
209
|
+
*
|
|
210
|
+
* @param config The configuration.
|
|
211
|
+
* @param previous The file it is replacing. Everything that still means the same keeps its exact
|
|
212
|
+
* text, comments and blank lines included. Without one, the whole file is canonical.
|
|
213
|
+
* @throws {JxnError} When it holds what Jx Notation cannot spell, naming the JSON Pointer.
|
|
214
|
+
*/
|
|
215
|
+
export function serializeProject(config: ProjectConfig, previous?: string): string {
|
|
216
|
+
if (previous === undefined) {
|
|
217
|
+
return writeCanonical(config);
|
|
218
|
+
}
|
|
219
|
+
const canonical = writeCanonical(inOrderOf(config, previous));
|
|
220
|
+
return reconcileJxn(previous, canonical, readForReconcile);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The configuration with its members in the order the previous file has them.
|
|
225
|
+
*
|
|
226
|
+
* Order means nothing among a project's members, so a writer whose object happens to hold them in
|
|
227
|
+
* another order must not move a member, which would cost the comment above it. A member the file
|
|
228
|
+
* did not have goes after the ones it did.
|
|
229
|
+
*/
|
|
230
|
+
function inOrderOf(config: ProjectConfig, previous: string): ProjectConfig {
|
|
231
|
+
let before: string[];
|
|
232
|
+
try {
|
|
233
|
+
before = Object.keys(parseProject(previous));
|
|
234
|
+
} catch {
|
|
235
|
+
return config;
|
|
236
|
+
}
|
|
237
|
+
if (!isJsonObject(config)) {
|
|
238
|
+
return config;
|
|
239
|
+
}
|
|
240
|
+
const source = config;
|
|
241
|
+
const keys = Object.keys(source);
|
|
242
|
+
const order = [
|
|
243
|
+
...before.filter((key) => keys.includes(key)),
|
|
244
|
+
...keys.filter((key) => !before.includes(key)),
|
|
245
|
+
];
|
|
246
|
+
// SAFETY: the same members, only in another order.
|
|
247
|
+
return Object.fromEntries(order.map((key) => [key, source[key]])) as ProjectConfig;
|
|
248
|
+
}
|
package/src/jxn/serialize.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { parseJxnWithSpans } from "./parse.ts";
|
|
|
12
12
|
import { reconcileJxn } from "./reconcile.ts";
|
|
13
13
|
import type { JxnReading } from "./reconcile.ts";
|
|
14
14
|
|
|
15
|
-
const INDENT = " ";
|
|
15
|
+
export const INDENT = " ";
|
|
16
16
|
|
|
17
17
|
/** Past this a line of names is worse than one name per line. */
|
|
18
18
|
const WIDTH = 100;
|
|
@@ -35,7 +35,7 @@ const RESERVED_TAGS = new Set(["-", "attributes", "script", "state", "style"]);
|
|
|
35
35
|
|
|
36
36
|
const isObject = (value: JsonValue | undefined): value is JsonObject => isJsonObject(value);
|
|
37
37
|
|
|
38
|
-
const isRef = (value: JsonValue | undefined): value is { $ref: string } =>
|
|
38
|
+
export const isRef = (value: JsonValue | undefined): value is { $ref: string } =>
|
|
39
39
|
isObject(value) && isString(value.$ref) && Object.keys(value).length === 1;
|
|
40
40
|
|
|
41
41
|
/** Whether a string has no lone surrogate. A `u` regular expression sees one as a code point. */
|
|
@@ -129,7 +129,7 @@ function writeString(value: string, indent: string, where: string): string {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
/** A key, as a node or property name. Never multi-line: a name is one line. */
|
|
132
|
-
function writeName(key: string, where: string): string {
|
|
132
|
+
export function writeName(key: string, where: string): string {
|
|
133
133
|
if (!isWellFormed(key)) {
|
|
134
134
|
throw new JxnError(`${where} is a key with a lone surrogate, which Jx Notation cannot spell`);
|
|
135
135
|
}
|
|
@@ -137,7 +137,7 @@ function writeName(key: string, where: string): string {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/** A value that fits in one token, or null if it needs a block. */
|
|
140
|
-
function scalar(value: JsonValue | undefined, indent: string, where: string): string | null {
|
|
140
|
+
export function scalar(value: JsonValue | undefined, indent: string, where: string): string | null {
|
|
141
141
|
if (value === null) {
|
|
142
142
|
return "#null";
|
|
143
143
|
}
|
|
@@ -157,7 +157,7 @@ function scalar(value: JsonValue | undefined, indent: string, where: string): st
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/** A pointer one step on. */
|
|
160
|
-
const down = (where: string, key: string | number): string =>
|
|
160
|
+
export const down = (where: string, key: string | number): string =>
|
|
161
161
|
`${where}/${String(key).replaceAll("~", "~0").replaceAll("/", "~1")}`;
|
|
162
162
|
|
|
163
163
|
/** Whether a function has a spelling as a `(fn)` node, rather than as a plain block. */
|
|
@@ -205,7 +205,7 @@ function writeFunction(key: string, fn: JsonObject, indent: string, where: strin
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
/** A JSON block. */
|
|
208
|
-
function writeValue(key: string, value: JsonValue, indent: string, where: string): string[] {
|
|
208
|
+
export function writeValue(key: string, value: JsonValue, indent: string, where: string): string[] {
|
|
209
209
|
const name = writeName(key, where);
|
|
210
210
|
const head = indent + name;
|
|
211
211
|
const single = scalar(value, indent, where);
|
|
@@ -480,7 +480,7 @@ function pushMember(
|
|
|
480
480
|
}
|
|
481
481
|
|
|
482
482
|
/** A file's `$head`, whose rows are elements. */
|
|
483
|
-
function writeHeadBlock(head: JsonValue[], indent: string): string[] {
|
|
483
|
+
export function writeHeadBlock(head: JsonValue[], indent: string): string[] {
|
|
484
484
|
return [
|
|
485
485
|
"$head {",
|
|
486
486
|
...head.flatMap((row, index) =>
|
package/src/project-file-node.ts
CHANGED
|
@@ -16,10 +16,22 @@ import {
|
|
|
16
16
|
lstatSync,
|
|
17
17
|
openSync,
|
|
18
18
|
readFileSync,
|
|
19
|
+
statSync,
|
|
19
20
|
} from "node:fs";
|
|
20
21
|
import { dirname, join } from "node:path";
|
|
21
|
-
import {
|
|
22
|
-
|
|
22
|
+
import {
|
|
23
|
+
NEW_PROJECT_FILE_NAME,
|
|
24
|
+
ProjectFileConflictError,
|
|
25
|
+
findEnclosingProject,
|
|
26
|
+
parseProjectFile,
|
|
27
|
+
pickProjectFileName,
|
|
28
|
+
} from "./project-file.ts";
|
|
29
|
+
import type {
|
|
30
|
+
EnclosingProject,
|
|
31
|
+
ProjectFileName,
|
|
32
|
+
ProjectFileRead,
|
|
33
|
+
ProjectWalkIo,
|
|
34
|
+
} from "./project-file.ts";
|
|
23
35
|
|
|
24
36
|
/** Larger than any real project file, and small enough to read before anyone has answered. */
|
|
25
37
|
const MAX_PROJECT_FILE_BYTES = 4 * 1024 * 1024;
|
|
@@ -83,24 +95,80 @@ function readCandidate(path: string): string | null {
|
|
|
83
95
|
export const NODE_PROJECT_WALK_IO: ProjectWalkIo = { dirname, join, readText: readCandidate };
|
|
84
96
|
|
|
85
97
|
/**
|
|
86
|
-
*
|
|
98
|
+
* Which project file a folder holds.
|
|
99
|
+
*
|
|
100
|
+
* @param root The folder.
|
|
101
|
+
* @returns Its name; "conflict" when the folder holds both; null when it holds neither.
|
|
102
|
+
*/
|
|
103
|
+
export function projectFileNameIn(root: string): ProjectFileName | "conflict" | null {
|
|
104
|
+
return pickProjectFileName((name) => existsSync(join(root, name)));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The project file's path in a folder: whichever of the two names it holds, or a new project's name
|
|
109
|
+
* when it holds neither, which is where a project file would be written.
|
|
87
110
|
*
|
|
88
111
|
* @param root The folder.
|
|
112
|
+
* @throws {ProjectFileConflictError} When the folder holds both, which no host may choose between.
|
|
89
113
|
*/
|
|
90
114
|
export function projectFilePath(root: string): string {
|
|
91
|
-
|
|
115
|
+
const name = projectFileNameIn(root);
|
|
116
|
+
if (name === "conflict") {
|
|
117
|
+
throw new ProjectFileConflictError(root);
|
|
118
|
+
}
|
|
119
|
+
return join(root, name ?? NEW_PROJECT_FILE_NAME);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Which project file a folder holds, and when it last changed: what a cache of it keys on. */
|
|
123
|
+
export interface ProjectFileStamp {
|
|
124
|
+
mtime: number;
|
|
125
|
+
path: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The project file a folder holds and when it last changed.
|
|
130
|
+
*
|
|
131
|
+
* A cache compares both, because converting a project changes which file it is and need not change
|
|
132
|
+
* the time.
|
|
133
|
+
*
|
|
134
|
+
* @param root The folder.
|
|
135
|
+
* @returns Null when the folder holds no project file, or holds both, which is no project to serve
|
|
136
|
+
* until the person editing it chooses.
|
|
137
|
+
*/
|
|
138
|
+
export function projectFileStamp(root: string): ProjectFileStamp | null {
|
|
139
|
+
const name = projectFileNameIn(root);
|
|
140
|
+
if (name === null || name === "conflict") {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const path = join(root, name);
|
|
144
|
+
const mtime = statSync(path, { throwIfNoEntry: false })?.mtimeMs;
|
|
145
|
+
return mtime === undefined ? null : { mtime, path };
|
|
92
146
|
}
|
|
93
147
|
|
|
94
148
|
/**
|
|
95
|
-
* Whether a
|
|
149
|
+
* Whether a cached stamp still describes the file.
|
|
150
|
+
*
|
|
151
|
+
* @param cached What the cache holds.
|
|
152
|
+
* @param stamp What the folder holds now.
|
|
153
|
+
*/
|
|
154
|
+
export function isSameStamp(
|
|
155
|
+
cached: ProjectFileStamp | undefined,
|
|
156
|
+
stamp: ProjectFileStamp,
|
|
157
|
+
): boolean {
|
|
158
|
+
return cached !== undefined && cached.mtime === stamp.mtime && cached.path === stamp.path;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether a folder named explicitly holds a project file, of either name.
|
|
96
163
|
*
|
|
97
164
|
* By name alone, because the caller has already said this is the project. A search, which could
|
|
98
|
-
* land on a page by that name, uses {@link findEnclosingProjectOnDisk}.
|
|
165
|
+
* land on a page by that name, uses {@link findEnclosingProjectOnDisk}. A folder holding both files
|
|
166
|
+
* holds one, and reading it reports the conflict.
|
|
99
167
|
*
|
|
100
168
|
* @param root The folder.
|
|
101
169
|
*/
|
|
102
170
|
export function hasProjectFile(root: string): boolean {
|
|
103
|
-
return
|
|
171
|
+
return projectFileNameIn(root) !== null;
|
|
104
172
|
}
|
|
105
173
|
|
|
106
174
|
/**
|
|
@@ -120,10 +188,16 @@ export function findEnclosingProjectOnDisk(
|
|
|
120
188
|
* Read a folder's project file.
|
|
121
189
|
*
|
|
122
190
|
* @param root The folder.
|
|
123
|
-
* @returns What it holds or what is wrong with it,
|
|
191
|
+
* @returns What it holds or what is wrong with it, a folder holding both files included, or null
|
|
192
|
+
* when it has none.
|
|
124
193
|
*/
|
|
125
194
|
export function readProjectFile(root: string): ProjectFileRead | null {
|
|
126
|
-
|
|
195
|
+
let path: string;
|
|
196
|
+
try {
|
|
197
|
+
path = projectFilePath(root);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return { error: error instanceof Error ? error.message : String(error), path: root, text: "" };
|
|
200
|
+
}
|
|
127
201
|
try {
|
|
128
202
|
return parseProjectFile(path, readFileSync(path, "utf8"));
|
|
129
203
|
} catch {
|
package/src/project-file.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What makes a file named `project.json` a project's configuration, and which
|
|
3
|
-
* path.
|
|
2
|
+
* What makes a file named `project.jxn` or `project.json` a project's configuration, and which
|
|
3
|
+
* project encloses a path (`site-architecture.md` §2.3).
|
|
4
4
|
*
|
|
5
5
|
* A name is not enough. `pages/project.json` is a legal page with the route `/project`, and Nx
|
|
6
6
|
* keeps its own `project.json` in every package of a workspace, so a walker that believes the name
|
|
@@ -18,11 +18,35 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { isNonNullObject } from "./guards.ts";
|
|
20
20
|
import { rewriteJson } from "./json-layout.ts";
|
|
21
|
+
import { classifyJxn } from "./jxn/classify.ts";
|
|
22
|
+
import { isJxnPath } from "./jxn/document.ts";
|
|
23
|
+
import { parseProject, serializeProject } from "./jxn/project.ts";
|
|
21
24
|
import { parseProjectConfig } from "./parse.ts";
|
|
22
25
|
import type { ProjectConfig } from "../types.ts";
|
|
23
26
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The names a project file may have. Neither is preferred: a folder holding both is refused,
|
|
29
|
+
* because silently reading one would leave the other looking live and not being so.
|
|
30
|
+
*/
|
|
31
|
+
export const PROJECT_FILE_NAMES = ["project.jxn", "project.json"] as const;
|
|
32
|
+
|
|
33
|
+
/** One of {@link PROJECT_FILE_NAMES}. */
|
|
34
|
+
export type ProjectFileName = (typeof PROJECT_FILE_NAMES)[number];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a new project's file is called (`site-architecture.md` §2.4). Never what an existing
|
|
38
|
+
* project's is: that is whichever of {@link PROJECT_FILE_NAMES} its folder holds.
|
|
39
|
+
*/
|
|
40
|
+
export const NEW_PROJECT_FILE_NAME: ProjectFileName = "project.jxn";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether a file name is one a project file may have.
|
|
44
|
+
*
|
|
45
|
+
* @param name A bare file name, with no folder.
|
|
46
|
+
*/
|
|
47
|
+
export function isProjectFileName(name: string | null | undefined): name is ProjectFileName {
|
|
48
|
+
return name === "project.jxn" || name === "project.json";
|
|
49
|
+
}
|
|
26
50
|
|
|
27
51
|
/**
|
|
28
52
|
* Whether a project-relative path is the project's own file.
|
|
@@ -33,7 +57,50 @@ export const PROJECT_FILE_NAME = "project.json";
|
|
|
33
57
|
* @param path A path relative to the project's root.
|
|
34
58
|
*/
|
|
35
59
|
export function isProjectConfigPath(path: string | null | undefined): boolean {
|
|
36
|
-
return path
|
|
60
|
+
return isProjectFileName(path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The file name at the end of a path, in either separator.
|
|
65
|
+
*
|
|
66
|
+
* @param path A path.
|
|
67
|
+
*/
|
|
68
|
+
export function baseName(path: string): string {
|
|
69
|
+
return path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What a folder holding both project files is told.
|
|
74
|
+
*
|
|
75
|
+
* @param root The folder, as the person will recognise it.
|
|
76
|
+
*/
|
|
77
|
+
export function projectFileConflict(root: string): string {
|
|
78
|
+
return `${root} holds both project.jxn and project.json, and a project has one. Keep the one you edit and delete the other`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A folder holding both project files. */
|
|
82
|
+
export class ProjectFileConflictError extends Error {
|
|
83
|
+
/** @param root The folder. */
|
|
84
|
+
constructor(root: string) {
|
|
85
|
+
super(projectFileConflict(root));
|
|
86
|
+
this.name = "ProjectFileConflictError";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which project file a folder holds, from the names in it.
|
|
92
|
+
*
|
|
93
|
+
* @param has Whether the folder holds a file by this name.
|
|
94
|
+
* @returns The name; "conflict" when it holds both; null when neither.
|
|
95
|
+
*/
|
|
96
|
+
export function pickProjectFileName(
|
|
97
|
+
has: (name: ProjectFileName) => boolean,
|
|
98
|
+
): ProjectFileName | "conflict" | null {
|
|
99
|
+
const found = PROJECT_FILE_NAMES.filter((name) => has(name));
|
|
100
|
+
if (found.length > 1) {
|
|
101
|
+
return "conflict";
|
|
102
|
+
}
|
|
103
|
+
return found[0] ?? null;
|
|
37
104
|
}
|
|
38
105
|
|
|
39
106
|
/**
|
|
@@ -55,21 +122,30 @@ const DOCUMENT_KEYS = [
|
|
|
55
122
|
const NX_KEYS = ["projectType", "sourceRoot", "targets"] as const;
|
|
56
123
|
|
|
57
124
|
/**
|
|
58
|
-
* What a file named `project.json` turned out to be.
|
|
125
|
+
* What a file named `project.jxn` or `project.json` turned out to be.
|
|
59
126
|
*
|
|
60
127
|
* - `project`: a project's configuration.
|
|
61
|
-
* - `other`:
|
|
62
|
-
* - `unreadable`:
|
|
128
|
+
* - `other`: something else, such as a page by that name, so the search goes on past it.
|
|
129
|
+
* - `unreadable`: neither. Believed, and reported, because this is most likely a project with a typo
|
|
63
130
|
* in it, and the person who made the typo needs to hear about it.
|
|
64
131
|
*/
|
|
65
132
|
export type ProjectFileVerdict = "other" | "project" | "unreadable";
|
|
66
133
|
|
|
67
134
|
/**
|
|
68
|
-
* Classify the text of a file
|
|
135
|
+
* Classify the text of a project file candidate.
|
|
136
|
+
*
|
|
137
|
+
* A `project.jxn` says what it is with its first node (`spec.md` §22.5), which is read without
|
|
138
|
+
* parsing the file, so a typo further down still leaves its folder a project. A `project.json` is
|
|
139
|
+
* judged by its members, because JSON has no way to say.
|
|
69
140
|
*
|
|
70
141
|
* @param text The file's text.
|
|
142
|
+
* @param path The file's name or path, which decides the format.
|
|
71
143
|
*/
|
|
72
|
-
export function classifyProjectFile(text: string): ProjectFileVerdict {
|
|
144
|
+
export function classifyProjectFile(text: string, path = "project.json"): ProjectFileVerdict {
|
|
145
|
+
if (isJxnPath(path)) {
|
|
146
|
+
const kind = classifyJxn(text);
|
|
147
|
+
return kind === "document" ? "other" : kind;
|
|
148
|
+
}
|
|
73
149
|
let parsed: unknown;
|
|
74
150
|
try {
|
|
75
151
|
parsed = JSON.parse(text);
|
|
@@ -97,11 +173,12 @@ export interface ProjectWalkIo {
|
|
|
97
173
|
|
|
98
174
|
/** The project a walk found. */
|
|
99
175
|
export interface EnclosingProject {
|
|
100
|
-
/** The project file's path. */
|
|
176
|
+
/** The project file's path; with a conflict, the `project.jxn` of the two. */
|
|
101
177
|
projectFile: string;
|
|
102
178
|
/** The folder holding it. */
|
|
103
179
|
root: string;
|
|
104
|
-
|
|
180
|
+
/** What it is: a project, a project that cannot be read, or a folder holding both files. */
|
|
181
|
+
verdict: Exclude<ProjectFileVerdict, "other"> | "conflict";
|
|
105
182
|
}
|
|
106
183
|
|
|
107
184
|
/**
|
|
@@ -117,11 +194,15 @@ export function findEnclosingProject(
|
|
|
117
194
|
io: ProjectWalkIo,
|
|
118
195
|
stopAt?: string,
|
|
119
196
|
): EnclosingProject | null {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return { projectFile, root: startDirectory, verdict };
|
|
197
|
+
const found = PROJECT_FILE_NAMES.flatMap((name) => {
|
|
198
|
+
const projectFile = io.join(startDirectory, name);
|
|
199
|
+
const text = io.readText(projectFile);
|
|
200
|
+
const verdict = text === null ? "other" : classifyProjectFile(text, name);
|
|
201
|
+
return verdict === "other" ? [] : [{ projectFile, root: startDirectory, verdict }];
|
|
202
|
+
});
|
|
203
|
+
const [first] = found;
|
|
204
|
+
if (first) {
|
|
205
|
+
return found.length > 1 ? { ...first, verdict: "conflict" } : first;
|
|
125
206
|
}
|
|
126
207
|
const parent = io.dirname(startDirectory);
|
|
127
208
|
return startDirectory === stopAt || parent === startDirectory
|
|
@@ -146,18 +227,40 @@ export type ProjectFileRead =
|
|
|
146
227
|
*/
|
|
147
228
|
export function parseProjectFile(path: string, text: string): ProjectFileRead {
|
|
148
229
|
try {
|
|
149
|
-
return { config:
|
|
230
|
+
return { config: parseProjectSource(text, path), path, text };
|
|
150
231
|
} catch (error) {
|
|
151
232
|
return { error: error instanceof Error ? error.message : String(error), path, text };
|
|
152
233
|
}
|
|
153
234
|
}
|
|
154
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Parse a project file's text in the format its name says.
|
|
238
|
+
*
|
|
239
|
+
* @param text The file's text.
|
|
240
|
+
* @param path The file's path, which decides the format and names it in an error.
|
|
241
|
+
* @throws {Error} When it does not read: a `JxnError`, with a line and column, for `project.jxn`.
|
|
242
|
+
*/
|
|
243
|
+
export function parseProjectSource(text: string, path: string): ProjectConfig {
|
|
244
|
+
return isJxnPath(path) ? parseProject(text, path) : parseProjectConfig(text, path);
|
|
245
|
+
}
|
|
246
|
+
|
|
155
247
|
/**
|
|
156
248
|
* Write a project's configuration, keeping as much of the text it replaces as can be kept.
|
|
157
249
|
*
|
|
250
|
+
* A `project.jxn` keeps its comments through the merge `spec.md` §22.4 describes, and a
|
|
251
|
+
* `project.json` keeps its layout.
|
|
252
|
+
*
|
|
158
253
|
* @param config The configuration.
|
|
159
254
|
* @param previous The file's current text, or null for a new one.
|
|
255
|
+
* @param path The file's path, which decides the format. A new project's name without one.
|
|
256
|
+
* @throws {Error} When a `project.jxn` holds what Jx Notation cannot spell, naming the pointer.
|
|
160
257
|
*/
|
|
161
|
-
export function serializeProjectFile(
|
|
162
|
-
|
|
258
|
+
export function serializeProjectFile(
|
|
259
|
+
config: ProjectConfig,
|
|
260
|
+
previous: string | null,
|
|
261
|
+
path: string = NEW_PROJECT_FILE_NAME,
|
|
262
|
+
): string {
|
|
263
|
+
return isJxnPath(path)
|
|
264
|
+
? serializeProject(config, previous ?? undefined)
|
|
265
|
+
: rewriteJson(config, previous);
|
|
163
266
|
}
|
package/src/validate-project.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Validate-project — project
|
|
2
|
+
* Validate-project — project file validation against the generated per-project entry schema
|
|
3
3
|
* (specs/extensions.md §5.4).
|
|
4
4
|
*
|
|
5
5
|
* Node-only by design (mirrors the dynamic-ajv-import pattern of `validateDocument`): reads
|
|
6
|
-
* `<root>/project.
|
|
7
|
-
* ajv-2020, and resolves its relative `$ref`s through a file loader restricted
|
|
8
|
-
* and its node_modules. The entry file's URL is injected as `$id` at load time
|
|
9
|
-
* machine-path-free relative refs resolve.
|
|
6
|
+
* `<root>/project.jxn` or `<root>/project.json`, and `<root>/project.schema.json`, compiles the
|
|
7
|
+
* entry document with ajv-2020, and resolves its relative `$ref`s through a file loader restricted
|
|
8
|
+
* to the project root and its node_modules. The entry file's URL is injected as `$id` at load time
|
|
9
|
+
* so the committed, machine-path-free relative refs resolve.
|
|
10
10
|
*
|
|
11
11
|
* When a `./node_modules/...` ref does not exist on disk (workspace hoisting leaves in-repo
|
|
12
12
|
* starters without their own node_modules), the loader falls back to host resolution of the bare
|
|
@@ -15,15 +15,21 @@
|
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync } from "node:fs";
|
|
17
17
|
import { createRequire } from "node:module";
|
|
18
|
-
import { isAbsolute, relative, resolve } from "node:path";
|
|
18
|
+
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
19
19
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
20
|
|
|
21
21
|
import type { JsonObject, JsonValue } from "../types";
|
|
22
|
+
import { isJsonObject, isNonNullObject, isString } from "./guards.ts";
|
|
23
|
+
import { isJxnPath } from "./jxn/document.ts";
|
|
24
|
+
import { scalar } from "./jxn/serialize.ts";
|
|
25
|
+
import { parseProjectSource } from "./project-file.ts";
|
|
22
26
|
import { projectFilePath } from "./project-file-node.ts";
|
|
23
27
|
|
|
24
28
|
export interface ProjectValidationResult {
|
|
25
29
|
valid: boolean;
|
|
26
30
|
errors: unknown[] | null;
|
|
31
|
+
/** The project file's name, `project.jxn` or `project.json`, which a report names. */
|
|
32
|
+
file: string;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
// Minimal structural types for the optional `ajv` peer dependency (no direct dependency, and it
|
|
@@ -67,16 +73,18 @@ export async function validateProjectFile(projectRoot: string): Promise<ProjectV
|
|
|
67
73
|
const schemaPath = resolve(root, "project.schema.json");
|
|
68
74
|
|
|
69
75
|
if (!existsSync(projectPath)) {
|
|
70
|
-
throw new Error(`project.
|
|
76
|
+
throw new Error(`No project.jxn or project.json in ${root}`);
|
|
71
77
|
}
|
|
72
78
|
if (!existsSync(schemaPath)) {
|
|
73
79
|
throw new Error(
|
|
74
80
|
`project.schema.json not found in ${root} — run \`jx schema\` to generate it from ` +
|
|
75
|
-
`project
|
|
81
|
+
`the project file's extensions`,
|
|
76
82
|
);
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
|
|
85
|
+
/* SAFETY: both readers answer an object: `parseProjectConfig` refuses anything else, and the
|
|
86
|
+
project grammar has no top level but the configuration's members. */
|
|
87
|
+
const project = parseProjectSource(readFileSync(projectPath, "utf8"), projectPath) as JsonObject;
|
|
80
88
|
/*
|
|
81
89
|
* SAFETY: project.schema.json is written by `jx schema` (emitProjectSchema), which always emits a
|
|
82
90
|
* JSON object. A hand-damaged file that is not one fails loudly either way: the `$id` write below
|
|
@@ -108,7 +116,60 @@ export async function validateProjectFile(projectRoot: string): Promise<ProjectV
|
|
|
108
116
|
|
|
109
117
|
const validate = await ajv.compileAsync(entrySchema);
|
|
110
118
|
const valid = validate(project);
|
|
111
|
-
|
|
119
|
+
const errors = validate.errors ?? null;
|
|
120
|
+
return {
|
|
121
|
+
errors: errors && isJxnPath(projectPath) ? explainJxnLists(errors, project) : errors,
|
|
122
|
+
file: basename(projectPath),
|
|
123
|
+
valid,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The value at a JSON Pointer. */
|
|
128
|
+
function valueAt(document: JsonValue, pointer: string): JsonValue | undefined {
|
|
129
|
+
let current: JsonValue | undefined = document;
|
|
130
|
+
for (const part of pointer.split("/").slice(1)) {
|
|
131
|
+
const key = part.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
132
|
+
current = Array.isArray(current)
|
|
133
|
+
? current[Number(key)]
|
|
134
|
+
: isJsonObject(current)
|
|
135
|
+
? current[key]
|
|
136
|
+
: undefined;
|
|
137
|
+
}
|
|
138
|
+
return current;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Say how to write a list of one in Jx Notation, where it is the mistake people make by hand.
|
|
143
|
+
*
|
|
144
|
+
* `extensions "@jxsuite/parser"` reads as a string, so the schema's "must be array" is true and of
|
|
145
|
+
* no help. Coercing from the schema instead would make the reader need the schema.
|
|
146
|
+
*
|
|
147
|
+
* @param errors Ajv's errors.
|
|
148
|
+
* @param project What the file read to.
|
|
149
|
+
*/
|
|
150
|
+
export function explainJxnLists(errors: unknown[], project: JsonValue): unknown[] {
|
|
151
|
+
return errors.map((error) => {
|
|
152
|
+
if (
|
|
153
|
+
!isNonNullObject(error) ||
|
|
154
|
+
!("keyword" in error) ||
|
|
155
|
+
error.keyword !== "type" ||
|
|
156
|
+
!("params" in error) ||
|
|
157
|
+
!isNonNullObject(error.params) ||
|
|
158
|
+
!("type" in error.params) ||
|
|
159
|
+
error.params.type !== "array" ||
|
|
160
|
+
!("instancePath" in error) ||
|
|
161
|
+
!isString(error.instancePath)
|
|
162
|
+
) {
|
|
163
|
+
return error;
|
|
164
|
+
}
|
|
165
|
+
const value = valueAt(project, error.instancePath);
|
|
166
|
+
const spelled = scalar(value, "", error.instancePath);
|
|
167
|
+
const key = error.instancePath.split("/").at(-1) ?? "";
|
|
168
|
+
if (spelled === null || spelled.includes("\n") || key === "") {
|
|
169
|
+
return error;
|
|
170
|
+
}
|
|
171
|
+
return { ...error, message: `must be a list, written as ${key} { - ${spelled} }` };
|
|
172
|
+
});
|
|
112
173
|
}
|
|
113
174
|
|
|
114
175
|
/**
|