@jxsuite/schema 4.0.0 → 5.0.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/defs/children-value.schema.ts +2 -2
- package/defs/class-def.schema.ts +1 -1
- package/defs/expression-node.schema.ts +2 -2
- package/defs/ref-object.schema.ts +3 -3
- package/package.json +4 -3
- package/src/component-meta.ts +2 -2
- package/src/doc-ops.ts +234 -7
- package/src/validate-project.ts +24 -17
- package/src/which.ts +75 -0
|
@@ -15,7 +15,7 @@ export const arrayNamespaceSchema = {
|
|
|
15
15
|
"removals; without a key, rows are keyed by index. $map/index is not a key — an index " +
|
|
16
16
|
"names a position, not a row.",
|
|
17
17
|
properties: {
|
|
18
|
-
$ref: { pattern:
|
|
18
|
+
$ref: { pattern: String.raw`^\$map/item(/.+)?$`, type: "string" },
|
|
19
19
|
},
|
|
20
20
|
required: ["$ref"],
|
|
21
21
|
type: "object",
|
|
@@ -60,7 +60,7 @@ export const childrenValueSchema = {
|
|
|
60
60
|
"Computed children — a ${…} template that resolves at build time to an array of child " +
|
|
61
61
|
'definitions (e.g. "${state.entry.$children}" for parsed content). A bare plain-text ' +
|
|
62
62
|
"string is NOT a valid children value; text children must be array items.",
|
|
63
|
-
pattern:
|
|
63
|
+
pattern: String.raw`\$\{`,
|
|
64
64
|
type: "string",
|
|
65
65
|
},
|
|
66
66
|
],
|
package/defs/class-def.schema.ts
CHANGED
|
@@ -167,7 +167,7 @@ export const formatDefSchema = {
|
|
|
167
167
|
},
|
|
168
168
|
extensions: {
|
|
169
169
|
description: 'File extensions this format claims, with leading dot (e.g. [".md"]).',
|
|
170
|
-
items: { pattern:
|
|
170
|
+
items: { pattern: String.raw`^\.`, type: "string" },
|
|
171
171
|
minItems: 1,
|
|
172
172
|
type: "array",
|
|
173
173
|
},
|
|
@@ -6,7 +6,7 @@ export const expressionPointerSchema = {
|
|
|
6
6
|
"A JSON Pointer $ref operand within an expression node. $args/<name> resolves a named formula's parameter (callable entries only).",
|
|
7
7
|
properties: {
|
|
8
8
|
$ref: {
|
|
9
|
-
pattern:
|
|
9
|
+
pattern: String.raw`^(\$map/|\$reduce/|\$args/|event#/|#/|parent#/|window#/|document#/)`,
|
|
10
10
|
type: "string",
|
|
11
11
|
},
|
|
12
12
|
},
|
|
@@ -16,7 +16,7 @@ export const expressionPointerSchema = {
|
|
|
16
16
|
|
|
17
17
|
export const expressionLiteralSchema = {
|
|
18
18
|
anyOf: [
|
|
19
|
-
{ not: { pattern:
|
|
19
|
+
{ not: { pattern: String.raw`\$\{` }, type: "string" },
|
|
20
20
|
{ type: "number" },
|
|
21
21
|
{ type: "boolean" },
|
|
22
22
|
{ type: "null" },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const internalRefSchema = {
|
|
2
2
|
description: "Reference to a $defs type definition in the current component.",
|
|
3
3
|
examples: ["#/$defs/Count", "#/$defs/TodoItem", "#/$defs/Status"],
|
|
4
|
-
pattern:
|
|
4
|
+
pattern: String.raw`^#/\$defs/`,
|
|
5
5
|
type: "string",
|
|
6
6
|
} as const;
|
|
7
7
|
|
|
@@ -15,7 +15,7 @@ export const stateRefSchema = {
|
|
|
15
15
|
export const externalRefSchema = {
|
|
16
16
|
description: "Reference to an external Jx component file.",
|
|
17
17
|
examples: ["./card.json", "https://cdn.example.com/button.json"],
|
|
18
|
-
pattern:
|
|
18
|
+
pattern: String.raw`^(\./|\.\./).*\.json$|^https?://`,
|
|
19
19
|
type: "string",
|
|
20
20
|
} as const;
|
|
21
21
|
|
|
@@ -36,7 +36,7 @@ export const parentRefSchema = {
|
|
|
36
36
|
export const mapRefSchema = {
|
|
37
37
|
description: "Reference to the current Array map iteration context.",
|
|
38
38
|
examples: ["$map/item", "$map/index", "$map/item/text", "$map/item/done"],
|
|
39
|
-
pattern:
|
|
39
|
+
pattern: String.raw`^\$map/(item|index)(/.*)?$`,
|
|
40
40
|
type: "string",
|
|
41
41
|
} as const;
|
|
42
42
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"dependencies": {
|
|
3
|
-
"@webref/css": "^8.7.
|
|
3
|
+
"@webref/css": "^8.7.8",
|
|
4
4
|
"@webref/elements": "^2.9.2",
|
|
5
|
-
"@webref/idl": "^3.
|
|
5
|
+
"@webref/idl": "^3.86.0",
|
|
6
6
|
"ajv": "^8.20.0",
|
|
7
7
|
"ajv-formats": "^3.0.1"
|
|
8
8
|
},
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./locale": "./src/locale.ts",
|
|
21
21
|
"./intl": "./src/intl.ts",
|
|
22
22
|
"./media-type": "./src/media-type.ts",
|
|
23
|
+
"./which": "./src/which.ts",
|
|
23
24
|
"./overlays": "./src/overlays.ts",
|
|
24
25
|
"./dialogs": "./src/dialogs.ts",
|
|
25
26
|
"./a11y": "./src/a11y.ts",
|
|
@@ -60,7 +61,7 @@
|
|
|
60
61
|
"upgrade": "bun update --latest && bun install"
|
|
61
62
|
},
|
|
62
63
|
"type": "module",
|
|
63
|
-
"version": "
|
|
64
|
+
"version": "5.0.0",
|
|
64
65
|
"devDependencies": {
|
|
65
66
|
"json-schema-to-ts": "^3.1.1"
|
|
66
67
|
}
|
package/src/component-meta.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* It lives here because THREE backends need the same answer and two of them had already written it
|
|
10
10
|
* twice: `packages/server/src/studio-api.ts` (the dev server's `/__studio/components`) and
|
|
11
|
-
* `packages/desktop/src/project-session.ts`. A third copy in the cloud adapter would have
|
|
12
|
-
* "which entries in `state` count as props" a question with three answers.
|
|
11
|
+
* `packages/desktop/src/backend/project-session.ts`. A third copy in the cloud adapter would have
|
|
12
|
+
* made "which entries in `state` count as props" a question with three answers.
|
|
13
13
|
*
|
|
14
14
|
* @license MIT
|
|
15
15
|
*/
|
package/src/doc-ops.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* @module @jxsuite/schema/doc-ops
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { isNonNullObject, isNumber } from "./guards";
|
|
19
|
-
import type { JxDocumentValue, JxMutableNode, JxPath } from "../types.ts";
|
|
18
|
+
import { isNonNullObject, isNumber, isString } from "./guards";
|
|
19
|
+
import type { JsonValue, JxDocumentValue, JxMutableNode, JxPath } from "../types.ts";
|
|
20
20
|
|
|
21
21
|
/** Value-carrying document mutation, replayable in either direction. */
|
|
22
22
|
export type JxDocOp =
|
|
@@ -161,8 +161,15 @@ function childArrayAt(doc: JxMutableNode, parentPath: JxPath): (JxMutableNode |
|
|
|
161
161
|
* insertion (delta +1, inclusive) shifts them up. Returns `path` unchanged when the splice does not
|
|
162
162
|
* affect it — including when `path` IS `parentPath`, since a splice moves a node's children, never
|
|
163
163
|
* the node.
|
|
164
|
+
*
|
|
165
|
+
* @param {JxPath} parentPath - The node whose children were spliced
|
|
166
|
+
* @param {JxPath} path - The path to translate
|
|
167
|
+
* @param {number} spliceIndex - Where the splice happened
|
|
168
|
+
* @param {number} delta - -1 for a removal, +1 for an insertion
|
|
169
|
+
* @param {boolean} inclusive - Whether a path AT the index shifts (an insertion's rule)
|
|
170
|
+
* @returns {JxPath}
|
|
164
171
|
*/
|
|
165
|
-
function
|
|
172
|
+
export function shiftPrefixedIndex(
|
|
166
173
|
parentPath: JxPath,
|
|
167
174
|
path: JxPath,
|
|
168
175
|
spliceIndex: number,
|
|
@@ -201,7 +208,7 @@ function assertNotIntoOwnSubtree(op: Extract<JxDocOp, { op: "move-child" }>): vo
|
|
|
201
208
|
* counts from the end and an index past the end appends. The applier inserts with `splice`, so an
|
|
202
209
|
* inverse must name the slot the node really landed in, not the one the op asked for.
|
|
203
210
|
*/
|
|
204
|
-
function insertionSlot(index: number, length: number): number {
|
|
211
|
+
export function insertionSlot(index: number, length: number): number {
|
|
205
212
|
return index < 0 ? Math.max(length + index, 0) : Math.min(index, length);
|
|
206
213
|
}
|
|
207
214
|
|
|
@@ -236,12 +243,18 @@ function insertionSlot(index: number, length: number): number {
|
|
|
236
243
|
export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
|
|
237
244
|
// Read before the switch: an op from outside may carry a kind the union does not name.
|
|
238
245
|
const { op: kind } = op;
|
|
246
|
+
assertIntegerIndexes(op);
|
|
239
247
|
switch (op.op) {
|
|
240
248
|
case "set-key": {
|
|
241
249
|
const node = getNodeAtPath(doc, op.path);
|
|
242
|
-
if (
|
|
250
|
+
if (node === undefined) {
|
|
243
251
|
throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
|
|
244
252
|
}
|
|
253
|
+
/* A key set on a text child or a children array lands nowhere a renderer reads, so the edit
|
|
254
|
+
would succeed without effect; on `null` it would throw mid-transaction. */
|
|
255
|
+
if (!isNonNullObject(node) || Array.isArray(node)) {
|
|
256
|
+
throw new TypeError(`doc-op-not-a-node:${op.path.join("/")}`);
|
|
257
|
+
}
|
|
245
258
|
const before = cloneValue(node[op.key]);
|
|
246
259
|
return before === undefined
|
|
247
260
|
? { key: op.key, op: "set-key", path: op.path }
|
|
@@ -273,7 +286,7 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
|
|
|
273
286
|
return {
|
|
274
287
|
fromIndex: toIndex,
|
|
275
288
|
// The removal shifted paths under the source parent; the insertion, under the target.
|
|
276
|
-
fromParentPath:
|
|
289
|
+
fromParentPath: shiftPrefixedIndex(
|
|
277
290
|
op.fromParentPath,
|
|
278
291
|
op.toParentPath,
|
|
279
292
|
op.fromIndex,
|
|
@@ -282,7 +295,7 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
|
|
|
282
295
|
),
|
|
283
296
|
op: "move-child",
|
|
284
297
|
toIndex: op.fromIndex,
|
|
285
|
-
toParentPath:
|
|
298
|
+
toParentPath: shiftPrefixedIndex(op.toParentPath, op.fromParentPath, toIndex, 1, true),
|
|
286
299
|
};
|
|
287
300
|
}
|
|
288
301
|
default: {
|
|
@@ -292,6 +305,26 @@ export function inverseOf(doc: JxMutableNode, op: JxDocOp): JxDocOp {
|
|
|
292
305
|
throw new Error(`unknown-doc-op:${kind}`);
|
|
293
306
|
}
|
|
294
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Throw `doc-op-index:<path>` unless every index the op carries is an integer. A numeric string, a
|
|
310
|
+
* fraction or `NaN` would reach `splice`, which coerces or truncates it, so the op would land in a
|
|
311
|
+
* slot its recorded inverse does not name. Producers coerce canonical numeric strings before they
|
|
312
|
+
* build an op; this is the defence behind them.
|
|
313
|
+
*/
|
|
314
|
+
function assertIntegerIndexes(op: JxDocOp): void {
|
|
315
|
+
const indexes: [JxPath, unknown][] = [];
|
|
316
|
+
if (op.op === "move-child") {
|
|
317
|
+
indexes.push([op.fromParentPath, op.fromIndex], [op.toParentPath, op.toIndex]);
|
|
318
|
+
} else if (op.op === "insert-child" || op.op === "remove-child" || op.op === "set-child") {
|
|
319
|
+
indexes.push([op.parentPath, op.index]);
|
|
320
|
+
}
|
|
321
|
+
for (const [parentPath, index] of indexes) {
|
|
322
|
+
if (!Number.isInteger(index)) {
|
|
323
|
+
throw new TypeError(`doc-op-index:${[...parentPath, "children", String(index)].join("/")}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
295
328
|
/** The node's existing children array, or null when it has none (or is not a node). */
|
|
296
329
|
function childrenAt(doc: JxMutableNode, parentPath: JxPath): (JxMutableNode | string)[] | null {
|
|
297
330
|
const parent = getNodeAtPath(doc, parentPath);
|
|
@@ -370,3 +403,197 @@ export function applyDocOpsWithInverse(doc: JxMutableNode, ops: readonly JxDocOp
|
|
|
370
403
|
}
|
|
371
404
|
return pairs;
|
|
372
405
|
}
|
|
406
|
+
|
|
407
|
+
// ─── Paths ───────────────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Whether `path` is an ancestor of (or equal to) `descendant`.
|
|
411
|
+
*
|
|
412
|
+
* @param {JxPath} path
|
|
413
|
+
* @param {JxPath} descendant
|
|
414
|
+
* @returns {boolean}
|
|
415
|
+
*/
|
|
416
|
+
export function isAncestor(path: JxPath, descendant: JxPath): boolean {
|
|
417
|
+
if (path.length > descendant.length) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
return path.every((v, i) => v === descendant[i]);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Order two paths the way the document reads: parents before children, siblings by index.
|
|
425
|
+
*
|
|
426
|
+
* Segments are compared pairwise; numbers numerically (so `children/10` follows `children/9`), and
|
|
427
|
+
* anything else by string order. A prefix sorts before what extends it.
|
|
428
|
+
*
|
|
429
|
+
* @param {JxPath} a
|
|
430
|
+
* @param {JxPath} b
|
|
431
|
+
* @returns {number}
|
|
432
|
+
*/
|
|
433
|
+
export function comparePaths(a: JxPath, b: JxPath): number {
|
|
434
|
+
const len = Math.min(a.length, b.length);
|
|
435
|
+
for (let i = 0; i < len; i++) {
|
|
436
|
+
const x = a[i]!;
|
|
437
|
+
const y = b[i]!;
|
|
438
|
+
if (x === y) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (isNumber(x) && isNumber(y)) {
|
|
442
|
+
return x - y;
|
|
443
|
+
}
|
|
444
|
+
return String(x) < String(y) ? -1 : 1;
|
|
445
|
+
}
|
|
446
|
+
return a.length - b.length;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Drop any path whose ancestor is also in the list.
|
|
451
|
+
*
|
|
452
|
+
* Deleting a `<section>` and one of its own paragraphs is one deletion, not two: the paragraph goes
|
|
453
|
+
* with its parent, and a second splice into coordinates that no longer exist is how a batch
|
|
454
|
+
* corrupts a document.
|
|
455
|
+
*
|
|
456
|
+
* @param {readonly JxPath[]} paths
|
|
457
|
+
* @returns {JxPath[]}
|
|
458
|
+
*/
|
|
459
|
+
export function topLevelSelection(paths: readonly JxPath[]): JxPath[] {
|
|
460
|
+
const top: JxPath[] = [];
|
|
461
|
+
for (const path of paths) {
|
|
462
|
+
if (!paths.some((q) => !samePath(q, path) && isAncestor(q, path))) {
|
|
463
|
+
top.push([...path]);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return top;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function samePath(a: JxPath, b: JxPath): boolean {
|
|
470
|
+
return a.length === b.length && isAncestor(a, b);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Where a structural verb splices: the parent element's path and the index in its children. */
|
|
474
|
+
export interface SpliceCoordinate {
|
|
475
|
+
parentPath: JxPath;
|
|
476
|
+
index: number;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The splice coordinate `path` names, or `null` for no path and for every path
|
|
481
|
+
* {@link isSpliceablePath} refuses.
|
|
482
|
+
*
|
|
483
|
+
* @param {JxPath | null | undefined} path
|
|
484
|
+
* @returns {SpliceCoordinate | null}
|
|
485
|
+
*/
|
|
486
|
+
export function spliceCoordinate(path: JxPath | null | undefined): SpliceCoordinate | null {
|
|
487
|
+
const index = path?.at(-1);
|
|
488
|
+
return path && path.at(-2) === "children" && isNumber(index)
|
|
489
|
+
? { index, parentPath: path.slice(0, -2) }
|
|
490
|
+
: null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Whether `path` names a position a structural edit can splice: a tail of `"children"` + index.
|
|
495
|
+
*
|
|
496
|
+
* Every structural edit reaches its parent by dropping the last two segments and its position by
|
|
497
|
+
* taking the last. That produces a splice coordinate for exactly one path shape, and a document
|
|
498
|
+
* holds three others a caller can name: the document element (`[]`), a repeater's map template
|
|
499
|
+
* (`[…, "children", "map"]`) and a `$switch` case (`[…, "cases", "warn"]`). For the template, the
|
|
500
|
+
* parent resolves to a children ARRAY, so the splice throws mid-edit; for a case it splices at
|
|
501
|
+
* `NaN` and takes the wrong child.
|
|
502
|
+
*
|
|
503
|
+
* @param {JxPath} path
|
|
504
|
+
* @returns {boolean}
|
|
505
|
+
*/
|
|
506
|
+
export function isSpliceablePath(path: JxPath): boolean {
|
|
507
|
+
return spliceCoordinate(path) !== null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The order a structural batch must run in: contained paths dropped, unspliceable paths dropped,
|
|
512
|
+
* then LAST node first.
|
|
513
|
+
*
|
|
514
|
+
* Descending document order is what makes a loop of splices safe. Removing or duplicating at index
|
|
515
|
+
* `n` renumbers every sibling after `n`, so a batch that starts at the end never touches a
|
|
516
|
+
* coordinate a later step still needs.
|
|
517
|
+
*
|
|
518
|
+
* Containment is decided BEFORE spliceability, on the paths as given: a repeater template still
|
|
519
|
+
* shadows its own descendants, so naming the template and something inside it yields nothing rather
|
|
520
|
+
* than quietly deleting the child nobody pointed at.
|
|
521
|
+
*
|
|
522
|
+
* @param {readonly JxPath[]} paths
|
|
523
|
+
* @returns {JxPath[]} Deepest/last first, every entry a splice coordinate.
|
|
524
|
+
*/
|
|
525
|
+
export function structuralBatch(paths: readonly JxPath[]): JxPath[] {
|
|
526
|
+
return topLevelSelection(paths)
|
|
527
|
+
.filter((path) => isSpliceablePath(path))
|
|
528
|
+
.toSorted((a, b) => comparePaths(b, a));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* An index written as JSON a model or a form produced, as the integer it names: an integer passes,
|
|
533
|
+
* a canonical decimal string (`"0"`, `"12"`, never `"01"`, `"1.0"` or `" 2"`) becomes its number,
|
|
534
|
+
* and anything else is `null`. Negative integers pass; whether one is meaningful is the caller's.
|
|
535
|
+
*
|
|
536
|
+
* @param {JsonValue | undefined} value - A tool argument or form field, as parsed JSON
|
|
537
|
+
* @returns {number | null}
|
|
538
|
+
*/
|
|
539
|
+
export function coerceIndex(value: JsonValue | undefined): number | null {
|
|
540
|
+
if (isNumber(value) && Number.isInteger(value)) {
|
|
541
|
+
return value;
|
|
542
|
+
}
|
|
543
|
+
return isString(value) && /^(?:0|[1-9]\d*)$/.test(value) ? Number(value) : null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** A move's forward and inverse ops, both `move-child`. */
|
|
547
|
+
export interface PlannedMove {
|
|
548
|
+
forward: Extract<JxDocOp, { op: "move-child" }>;
|
|
549
|
+
inverse: Extract<JxDocOp, { op: "move-child" }>;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The ops that move the node at `fromPath` to `toIndex` of `toParentPath`'s children, without
|
|
554
|
+
* moving anything; null when the move cannot happen (the source is no child in a static children
|
|
555
|
+
* array, or the target is no node).
|
|
556
|
+
*
|
|
557
|
+
* `toIndex` is read as the slot in the target's children BEFORE the move, the way a drop indicator
|
|
558
|
+
* names it, so a move later in the same parent lands one earlier once the source is gone. A slot
|
|
559
|
+
* past the end is clamped to the last one, so the forward names where the node really lands. Throws
|
|
560
|
+
* `doc-op-move-into-self` when the target is inside the moved node.
|
|
561
|
+
*
|
|
562
|
+
* @param {JxMutableNode} doc - The document the move applies to
|
|
563
|
+
* @param {JxPath} fromPath
|
|
564
|
+
* @param {JxPath} toParentPath
|
|
565
|
+
* @param {number} toIndex
|
|
566
|
+
* @returns {PlannedMove | null}
|
|
567
|
+
*/
|
|
568
|
+
export function planMove(
|
|
569
|
+
doc: JxMutableNode,
|
|
570
|
+
fromPath: JxPath,
|
|
571
|
+
toParentPath: JxPath,
|
|
572
|
+
toIndex: number,
|
|
573
|
+
): PlannedMove | null {
|
|
574
|
+
const at = spliceCoordinate(fromPath);
|
|
575
|
+
const fromParent = at && getNodeAtPath(doc, at.parentPath);
|
|
576
|
+
const toParent = getNodeAtPath(doc, toParentPath);
|
|
577
|
+
if (!at || !fromParent || !Array.isArray(fromParent.children) || !toParent) {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
if (fromParent.children[at.index] === undefined) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
const sameParent = fromParent === toParent;
|
|
584
|
+
const adjusted = sameParent && at.index < toIndex ? toIndex - 1 : toIndex;
|
|
585
|
+
const targetLength = Array.isArray(toParent.children) ? toParent.children.length : 0;
|
|
586
|
+
const forward: PlannedMove["forward"] = {
|
|
587
|
+
fromIndex: at.index,
|
|
588
|
+
fromParentPath: at.parentPath,
|
|
589
|
+
op: "move-child",
|
|
590
|
+
toIndex: insertionSlot(adjusted, targetLength - (sameParent ? 1 : 0)),
|
|
591
|
+
toParentPath,
|
|
592
|
+
};
|
|
593
|
+
/* Computed against the document the move applies to. The inverse runs against the post-move
|
|
594
|
+
document, so `inverseOf` writes both of its parent paths in post-move coordinates: the removal
|
|
595
|
+
shifted paths under the source parent, the insertion paths under the target. */
|
|
596
|
+
const inverse = inverseOf(doc, forward);
|
|
597
|
+
/* SAFETY: `inverseOf` answers a `move-child` for a `move-child`. */
|
|
598
|
+
return { forward, inverse: inverse as PlannedMove["inverse"] };
|
|
599
|
+
}
|
package/src/validate-project.ts
CHANGED
|
@@ -111,9 +111,11 @@ export async function validateProjectFile(projectRoot: string): Promise<ProjectV
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
|
-
* Restricted schema loader: only `file:` URLs inside the project root are served.
|
|
115
|
-
* under `<root>/node_modules/`
|
|
116
|
-
* (in-repo starters have no local node_modules
|
|
114
|
+
* Restricted schema loader: only `file:` URLs inside the project root are served. A missing file
|
|
115
|
+
* under `<root>/node_modules/` resolves its bare specifier through the project's own resolution
|
|
116
|
+
* (in-repo starters have no local node_modules and walk up to the workspace's). Only this package's
|
|
117
|
+
* own core schemas fall back to the host: an extension's fragments, first-party ones included, are
|
|
118
|
+
* the project's to supply (specs/extensions.md §5.4).
|
|
117
119
|
*
|
|
118
120
|
* @param {string} uri - The `$ref` target ajv could not resolve from already-loaded resources
|
|
119
121
|
* @param {string} root - Absolute project root
|
|
@@ -137,8 +139,8 @@ async function loadProjectSchemaRef(uri: string, root: string): Promise<JsonValu
|
|
|
137
139
|
return readJson(filePath);
|
|
138
140
|
}
|
|
139
141
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
+
// <root>/node_modules/<specifier> is absent — resolve the specifier through the project's own
|
|
143
|
+
// Resolution (workspace symlinks resolve transparently), and through the host only for the core.
|
|
142
144
|
const posixRel = rel.split("\\").join("/");
|
|
143
145
|
const marker = "node_modules/";
|
|
144
146
|
const markerIndex = posixRel.lastIndexOf(marker);
|
|
@@ -147,32 +149,37 @@ async function loadProjectSchemaRef(uri: string, root: string): Promise<JsonValu
|
|
|
147
149
|
}
|
|
148
150
|
const specifier = posixRel.slice(markerIndex + marker.length);
|
|
149
151
|
|
|
152
|
+
const ownPrefix = "@jxsuite/schema/";
|
|
150
153
|
let resolved: string;
|
|
151
154
|
try {
|
|
152
155
|
const projRequire = createRequire(resolve(root, "package.json"));
|
|
153
156
|
resolved = projRequire.resolve(specifier);
|
|
154
|
-
} catch {
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (!specifier.startsWith(ownPrefix)) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Schema ref "${uri}" does not exist and "${specifier}" is not resolvable from the ` +
|
|
161
|
+
`project — is the package installed?`,
|
|
162
|
+
{ cause: error },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
155
165
|
try {
|
|
156
166
|
const selfRequire = createRequire(import.meta.url);
|
|
157
167
|
resolved = selfRequire.resolve(specifier);
|
|
158
|
-
} catch (
|
|
168
|
+
} catch (hostError) {
|
|
159
169
|
// Exports-map subpath resolution is brittle against version-skewed shadowing installs (a
|
|
160
170
|
// Stale local copy terminates Node's walk-up with ERR_PACKAGE_PATH_NOT_EXPORTED). Our own
|
|
161
171
|
// Schema files are pure data and the running validator's copy is guaranteed to match, so
|
|
162
172
|
// Read them straight off this package's disk as the last resort.
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
);
|
|
168
|
-
if (existsSync(ownFile)) {
|
|
169
|
-
return readJson(ownFile);
|
|
170
|
-
}
|
|
173
|
+
const ownFile = fileURLToPath(
|
|
174
|
+
new URL(`../${specifier.slice(ownPrefix.length)}`, import.meta.url),
|
|
175
|
+
);
|
|
176
|
+
if (existsSync(ownFile)) {
|
|
177
|
+
return readJson(ownFile);
|
|
171
178
|
}
|
|
172
179
|
throw new Error(
|
|
173
180
|
`Schema ref "${uri}" does not exist and "${specifier}" is not resolvable from the ` +
|
|
174
|
-
`project or
|
|
175
|
-
{ cause:
|
|
181
|
+
`project or this host's core`,
|
|
182
|
+
{ cause: hostError },
|
|
176
183
|
);
|
|
177
184
|
}
|
|
178
185
|
}
|
package/src/which.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find an executable the way `Bun.which` does, on `node:fs` alone.
|
|
3
|
+
*
|
|
4
|
+
* The desktop backend runs on Electron's Node (specs/desktop.md §7.1), so the code it reaches
|
|
5
|
+
* cannot call `Bun.which`. The answer is held to Bun's by `tests/which.test.ts`: a name containing
|
|
6
|
+
* a path separator is resolved against the working directory and returned when it is an executable
|
|
7
|
+
* regular file; any other name is looked up in each non-empty `PATH` entry in order. On Windows,
|
|
8
|
+
* where every file passes an `X_OK` check, only a launchable name counts: one that already ends in
|
|
9
|
+
* `.exe`, `.cmd`, `.bat` or `.com` is tried as written, and any other gets each of those appended
|
|
10
|
+
* in that order. Like Bun, it ignores `PATHEXT`, whose `.js` and `.vbs` entries name files a
|
|
11
|
+
* process cannot spawn. The path returned is the one that was tried, so an appended extension is
|
|
12
|
+
* lower case whatever the file's own case, and never its realpath.
|
|
13
|
+
*
|
|
14
|
+
* Node-only: it reads the filesystem, unlike the rest of this package's browser-safe modules.
|
|
15
|
+
*/
|
|
16
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
17
|
+
import { delimiter, join, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** True when `path` is a regular file this process may execute. */
|
|
20
|
+
function isExecutable(path: string): boolean {
|
|
21
|
+
try {
|
|
22
|
+
if (!statSync(path).isFile()) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
accessSync(path, constants.X_OK);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The extensions Bun's `which` tries on Windows, in its order. */
|
|
33
|
+
const WINDOWS_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The names to try for `name`: itself elsewhere; on Windows, only names with a launchable
|
|
37
|
+
* extension.
|
|
38
|
+
*/
|
|
39
|
+
function candidates(name: string, platform: NodeJS.Platform): string[] {
|
|
40
|
+
if (platform !== "win32") {
|
|
41
|
+
return [name];
|
|
42
|
+
}
|
|
43
|
+
const lower = name.toLowerCase();
|
|
44
|
+
if (WINDOWS_EXTENSIONS.some((extension) => lower.endsWith(extension))) {
|
|
45
|
+
return [name];
|
|
46
|
+
}
|
|
47
|
+
return WINDOWS_EXTENSIONS.map((extension) => `${name}${extension}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The absolute path of the executable `name`, or null when there is none.
|
|
52
|
+
*
|
|
53
|
+
* @param name A command name, or a path (absolute, or relative to the working directory)
|
|
54
|
+
* @param options `PATH` overrides the environment's search path
|
|
55
|
+
*/
|
|
56
|
+
export function which(
|
|
57
|
+
name: string,
|
|
58
|
+
options: { PATH?: string; platform?: NodeJS.Platform } = {},
|
|
59
|
+
): string | null {
|
|
60
|
+
if (name === "") {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const platform = options.platform ?? process.platform;
|
|
64
|
+
if (name.includes("/") || (platform === "win32" && name.includes("\\"))) {
|
|
65
|
+
return candidates(resolve(name), platform).find((path) => isExecutable(path)) ?? null;
|
|
66
|
+
}
|
|
67
|
+
const dirs = (options.PATH ?? process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
68
|
+
for (const dir of dirs) {
|
|
69
|
+
const found = candidates(join(dir, name), platform).find((path) => isExecutable(path));
|
|
70
|
+
if (found) {
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|