@cardanvil/frame-kit 0.5.0 → 0.7.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.md +119 -0
- package/dist/authoring/collectorInfo.d.ts +17 -0
- package/dist/authoring/collectorInfo.js +62 -0
- package/dist/authoring/index.d.ts +2 -0
- package/dist/authoring/index.js +2 -0
- package/dist/authoring/layoutDefaults.d.ts +20 -2
- package/dist/authoring/layoutDefaults.js +48 -13
- package/dist/authoring/opacityStops.d.ts +8 -0
- package/dist/authoring/opacityStops.js +17 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/loadFrame.js +1 -1
- package/dist/cli/main.js +88 -0
- package/dist/cli/sourceWatcher.d.ts +40 -0
- package/dist/cli/sourceWatcher.js +100 -0
- package/dist/cli/studio/boxSets.d.ts +33 -0
- package/dist/cli/studio/boxSets.js +90 -0
- package/dist/cli/studio/edit/applyEdits.d.ts +71 -0
- package/dist/cli/studio/edit/applyEdits.js +177 -0
- package/dist/cli/studio/edit/impact.d.ts +53 -0
- package/dist/cli/studio/edit/impact.js +87 -0
- package/dist/cli/studio/edit/resolveModule.d.ts +10 -0
- package/dist/cli/studio/edit/resolveModule.js +55 -0
- package/dist/cli/studio/edit/resolvePath.d.ts +53 -0
- package/dist/cli/studio/edit/resolvePath.js +606 -0
- package/dist/cli/studio/edit/sourceIndex.d.ts +29 -0
- package/dist/cli/studio/edit/sourceIndex.js +73 -0
- package/dist/cli/studio/edit/ts.d.ts +14 -0
- package/dist/cli/studio/edit/ts.js +34 -0
- package/dist/cli/studio/serve.d.ts +23 -0
- package/dist/cli/studio/serve.js +516 -0
- package/dist/cli/studio/session.d.ts +73 -0
- package/dist/cli/studio/session.js +154 -0
- package/dist/cli/trim.d.ts +62 -0
- package/dist/cli/trim.js +251 -0
- package/dist/cli/watch.d.ts +3 -0
- package/dist/cli/watch.js +20 -89
- package/dist/layout/assetPlacement.d.ts +71 -0
- package/dist/layout/assetPlacement.js +104 -0
- package/dist/layout/border.d.ts +27 -0
- package/dist/layout/border.js +31 -0
- package/dist/layout/boxOverrides.d.ts +28 -0
- package/dist/layout/boxOverrides.js +95 -0
- package/dist/layout/color.d.ts +23 -0
- package/dist/layout/color.js +53 -0
- package/dist/layout/cutouts.d.ts +28 -0
- package/dist/layout/cutouts.js +29 -0
- package/dist/layout/decorations.d.ts +73 -0
- package/dist/layout/decorations.js +104 -0
- package/dist/layout/frameLayers.d.ts +163 -0
- package/dist/layout/frameLayers.js +433 -0
- package/dist/layout/index.d.ts +27 -0
- package/dist/layout/index.js +27 -0
- package/dist/layout/paint.d.ts +36 -0
- package/dist/layout/paint.js +148 -0
- package/dist/layout/powerToughnessRow.d.ts +26 -0
- package/dist/layout/powerToughnessRow.js +13 -0
- package/dist/layout/resolveFrameAsset.d.ts +37 -0
- package/dist/layout/resolveFrameAsset.js +66 -0
- package/dist/layout/rulesSlide.d.ts +13 -0
- package/dist/layout/rulesSlide.js +33 -0
- package/dist/layout/settings.d.ts +26 -0
- package/dist/layout/settings.js +62 -0
- package/dist/layout/textStyle.d.ts +29 -0
- package/dist/layout/textStyle.js +28 -0
- package/dist/layout/turn.d.ts +54 -0
- package/dist/layout/turn.js +107 -0
- package/dist/manifest/contract.d.ts +6103 -9063
- package/dist/manifest/contract.js +48 -1
- package/dist/manifest/testFrame.js +9 -0
- package/dist/schema/collectorInfo.d.ts +95 -0
- package/dist/schema/collectorInfo.js +80 -0
- package/dist/schema/condition.d.ts +67 -0
- package/dist/schema/condition.js +47 -0
- package/dist/schema/frame.d.ts +17213 -24450
- package/dist/schema/frame.js +418 -14
- package/dist/schema/frameAssets.d.ts +56 -34
- package/dist/schema/frameAssets.js +96 -2
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.js +3 -0
- package/dist/schema/layout.d.ts +29 -4
- package/dist/schema/layout.js +27 -2
- package/dist/schema/paint.d.ts +202 -0
- package/dist/schema/paint.js +109 -0
- package/dist/studio/assets/PlantinMTProRgIt-D0FB51kb.woff2 +0 -0
- package/dist/studio/assets/beleren-b-B3lFvodX.ttf +0 -0
- package/dist/studio/assets/beleren-bsc-yvL6mU7X.ttf +0 -0
- package/dist/studio/assets/index-BnhNwm_r.js +74 -0
- package/dist/studio/index.html +12 -0
- package/package.json +16 -5
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { relativePosix } from "./discover.js";
|
|
4
|
+
/** Directories never worth watching. Mirrors discovery's own skip list. */
|
|
5
|
+
const SKIP_DIRS = new Set([
|
|
6
|
+
"node_modules",
|
|
7
|
+
"dist",
|
|
8
|
+
".git",
|
|
9
|
+
".pnpm-store",
|
|
10
|
+
"__fixtures__",
|
|
11
|
+
]);
|
|
12
|
+
/** Files a rebuild produces, or an editor leaves lying around. */
|
|
13
|
+
const IGNORED_FILE = /(\.cardframe|frame-index\.json|\.preview\.[a-z]+|~|\.swp|\.tmp)$/i;
|
|
14
|
+
/**
|
|
15
|
+
* How long to wait for changes to stop before reacting.
|
|
16
|
+
*
|
|
17
|
+
* One save can produce several events — a write, a rename, an attribute
|
|
18
|
+
* change — and a tool upstream may rewrite a whole directory.
|
|
19
|
+
*/
|
|
20
|
+
export const DEBOUNCE_MS = 200;
|
|
21
|
+
/** Whether a path is build output, editor debris, or inside a skipped dir. */
|
|
22
|
+
export function isIgnored(file) {
|
|
23
|
+
const segments = file.split(path.sep);
|
|
24
|
+
return (segments.some((segment) => SKIP_DIRS.has(segment) || segment.startsWith(".")) || IGNORED_FILE.test(file));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Watches directories and delivers debounced, non-overlapping change batches.
|
|
28
|
+
*
|
|
29
|
+
* Shared by `frame-kit build --watch` and `frame-kit studio`, which need the
|
|
30
|
+
* same three things and get them wrong in the same three ways otherwise:
|
|
31
|
+
* coalescing a burst of events from one save, never running two handlers at
|
|
32
|
+
* once, and surviving a handler that throws.
|
|
33
|
+
*/
|
|
34
|
+
export function startSourceWatcher(options) {
|
|
35
|
+
const { root, dirs, onChange, ignore, debounceMs = DEBOUNCE_MS, onError = (message) => {
|
|
36
|
+
console.error(message);
|
|
37
|
+
}, } = options;
|
|
38
|
+
const watchers = [];
|
|
39
|
+
const pending = new Set();
|
|
40
|
+
let timer;
|
|
41
|
+
let running = false;
|
|
42
|
+
async function drain() {
|
|
43
|
+
if (running) {
|
|
44
|
+
// Whatever arrived is already in `pending`, and the loop below picks it
|
|
45
|
+
// up when the in-flight handler finishes.
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
running = true;
|
|
49
|
+
try {
|
|
50
|
+
// Keyed off `pending` rather than a flag: anything that arrived during a
|
|
51
|
+
// handler is still queued when it finishes.
|
|
52
|
+
while (pending.size > 0) {
|
|
53
|
+
const changed = [...pending];
|
|
54
|
+
pending.clear();
|
|
55
|
+
await onChange(changed);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (cause) {
|
|
59
|
+
// A failed handler must not end the session: the author is mid-edit and
|
|
60
|
+
// the next save is probably the fix.
|
|
61
|
+
onError(cause instanceof Error ? cause.message : String(cause));
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
running = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function onFileEvent(file) {
|
|
68
|
+
if (isIgnored(file) || ignore?.(file) === true) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
pending.add(file);
|
|
72
|
+
if (timer) {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
}
|
|
75
|
+
timer = setTimeout(() => void drain(), debounceMs);
|
|
76
|
+
}
|
|
77
|
+
for (const dir of dirs) {
|
|
78
|
+
try {
|
|
79
|
+
watchers.push(watch(dir, { recursive: true }, (_event, name) => {
|
|
80
|
+
if (name !== null) {
|
|
81
|
+
onFileEvent(path.join(dir, name));
|
|
82
|
+
}
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
onError(`Could not watch ${relativePosix(root, dir)}. Recursive watching is ` +
|
|
87
|
+
`not supported on this filesystem.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
close() {
|
|
92
|
+
if (timer) {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
for (const watcher of watchers) {
|
|
96
|
+
watcher.close();
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a `LayoutConfig` slot holds.
|
|
3
|
+
*
|
|
4
|
+
* `boxes` is the only kind the studio can edit geometry in; the other two are
|
|
5
|
+
* listed so the layer panel can offer them without hard-coding field names.
|
|
6
|
+
*/
|
|
7
|
+
export type SlotKind = "boxes" | "assets" | "masks" | "knobs";
|
|
8
|
+
export interface LayoutSlot {
|
|
9
|
+
/** The `LayoutConfig` property, e.g. `sagaBackCreatureBoxes`. */
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly kind: SlotKind;
|
|
12
|
+
/** False for every slot the contract marks optional. */
|
|
13
|
+
readonly required: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Every box / asset / mask / knob slot the frame contract defines, read off
|
|
17
|
+
* the schema rather than listed by hand.
|
|
18
|
+
*
|
|
19
|
+
* Identity is the test: a slot counts as boxes because its schema *is*
|
|
20
|
+
* `CardBoxesSchema`, not because its name ends in "Boxes". So a slot added to
|
|
21
|
+
* `LayoutConfigSchema` shows up here — and in the studio — with no change,
|
|
22
|
+
* and `boxSets.test.ts` fails if one is added that this cannot classify.
|
|
23
|
+
*/
|
|
24
|
+
export declare function layoutSlots(): LayoutSlot[];
|
|
25
|
+
/** Just the slots holding `CardBoxes` — what the studio can edit. */
|
|
26
|
+
export declare function boxSlots(): LayoutSlot[];
|
|
27
|
+
/**
|
|
28
|
+
* Names of every member a `CardBoxes` can hold, in contract order.
|
|
29
|
+
*
|
|
30
|
+
* Used to enumerate a box set without assuming which optional members a given
|
|
31
|
+
* frame happens to define.
|
|
32
|
+
*/
|
|
33
|
+
export declare function cardBoxKeys(): string[];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { CardBoxesSchema, LayoutConfigSchema, SecondFaceBoxesSchema, } from "../../schema/frame.js";
|
|
2
|
+
import { FrameAssetsSchema, LayoutMasksSchema, } from "../../schema/frameAssets.js";
|
|
3
|
+
/** Strips `.optional()` and friends to reach the schema that carries identity. */
|
|
4
|
+
function unwrap(schema) {
|
|
5
|
+
let current = schema;
|
|
6
|
+
let wrapped = false;
|
|
7
|
+
for (;;) {
|
|
8
|
+
const node = current.def;
|
|
9
|
+
if (node.innerType === undefined ||
|
|
10
|
+
![
|
|
11
|
+
"optional",
|
|
12
|
+
"nullable",
|
|
13
|
+
"default",
|
|
14
|
+
"prefault",
|
|
15
|
+
"readonly",
|
|
16
|
+
"nonoptional",
|
|
17
|
+
].includes(node.type)) {
|
|
18
|
+
return { inner: current, wrapped };
|
|
19
|
+
}
|
|
20
|
+
current = node.innerType;
|
|
21
|
+
wrapped = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const KIND_BY_SCHEMA = new Map([
|
|
25
|
+
[CardBoxesSchema, "boxes"],
|
|
26
|
+
// A second face's set is boxes like any other, only fewer of them.
|
|
27
|
+
[SecondFaceBoxesSchema, "boxes"],
|
|
28
|
+
[FrameAssetsSchema, "assets"],
|
|
29
|
+
[LayoutMasksSchema, "masks"],
|
|
30
|
+
]);
|
|
31
|
+
/**
|
|
32
|
+
* Whether a schema is an object of numbers — a layout's render knobs
|
|
33
|
+
* (`planeswalkerConfig`, `sagaConfig`, `sectionConfig`, `crownConfig`,
|
|
34
|
+
* `nicknameConfig`, `powerToughnessRow`). A group may also hold objects, such
|
|
35
|
+
* as `planeswalkerConfig.abilityDivider`; the studio edits only its numbers.
|
|
36
|
+
*
|
|
37
|
+
* Recognised by shape rather than by name, so a knob group added to the
|
|
38
|
+
* contract is editable in the studio without a change here. Records and enums
|
|
39
|
+
* do not qualify, which is what keeps `templateSettings`, `colorArtOverrides`
|
|
40
|
+
* and `hybridTitleMask` out.
|
|
41
|
+
*/
|
|
42
|
+
function isNumericObject(schema) {
|
|
43
|
+
const node = schema.def;
|
|
44
|
+
if (node.type !== "object" || !node.shape) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const fields = Object.values(node.shape);
|
|
48
|
+
const types = fields.map((field) => unwrap(field).inner.def.type);
|
|
49
|
+
const isKnob = (type) => type === "number" || type === "boolean";
|
|
50
|
+
return (types.some(isKnob) &&
|
|
51
|
+
types.every((type) => isKnob(type) || type === "object"));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Every box / asset / mask / knob slot the frame contract defines, read off
|
|
55
|
+
* the schema rather than listed by hand.
|
|
56
|
+
*
|
|
57
|
+
* Identity is the test: a slot counts as boxes because its schema *is*
|
|
58
|
+
* `CardBoxesSchema`, not because its name ends in "Boxes". So a slot added to
|
|
59
|
+
* `LayoutConfigSchema` shows up here — and in the studio — with no change,
|
|
60
|
+
* and `boxSets.test.ts` fails if one is added that this cannot classify.
|
|
61
|
+
*/
|
|
62
|
+
export function layoutSlots() {
|
|
63
|
+
const shape = LayoutConfigSchema.def
|
|
64
|
+
.shape;
|
|
65
|
+
const slots = [];
|
|
66
|
+
for (const [key, schema] of Object.entries(shape)) {
|
|
67
|
+
const { inner, wrapped } = unwrap(schema);
|
|
68
|
+
const kind = KIND_BY_SCHEMA.get(inner) ??
|
|
69
|
+
(isNumericObject(inner) ? "knobs" : undefined);
|
|
70
|
+
if (kind) {
|
|
71
|
+
slots.push({ key, kind, required: !wrapped });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return slots;
|
|
75
|
+
}
|
|
76
|
+
/** Just the slots holding `CardBoxes` — what the studio can edit. */
|
|
77
|
+
export function boxSlots() {
|
|
78
|
+
return layoutSlots().filter((slot) => slot.kind === "boxes");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Names of every member a `CardBoxes` can hold, in contract order.
|
|
82
|
+
*
|
|
83
|
+
* Used to enumerate a box set without assuming which optional members a given
|
|
84
|
+
* frame happens to define.
|
|
85
|
+
*/
|
|
86
|
+
export function cardBoxKeys() {
|
|
87
|
+
const shape = CardBoxesSchema.def
|
|
88
|
+
.shape;
|
|
89
|
+
return Object.keys(shape);
|
|
90
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { FramePath } from "../../../manifest/walk.js";
|
|
2
|
+
import { type ResolveContext, type SourceLocation, type UnsupportedReason } from "./resolvePath.js";
|
|
3
|
+
/** A value a literal may be replaced with. The contract has no others. */
|
|
4
|
+
export type EditableValue = number | string | boolean;
|
|
5
|
+
export interface Edit {
|
|
6
|
+
readonly path: FramePath;
|
|
7
|
+
readonly value: EditableValue;
|
|
8
|
+
}
|
|
9
|
+
export interface EditRejection {
|
|
10
|
+
readonly path: FramePath;
|
|
11
|
+
readonly reason: UnsupportedReason | "value-mismatch" | "stale" | "overlap";
|
|
12
|
+
readonly detail: string;
|
|
13
|
+
readonly source?: SourceLocation;
|
|
14
|
+
}
|
|
15
|
+
export interface AppliedEdit {
|
|
16
|
+
readonly path: FramePath;
|
|
17
|
+
readonly file: string;
|
|
18
|
+
readonly from: string;
|
|
19
|
+
readonly to: string;
|
|
20
|
+
/** True when the literal is a helper default other box sets also read. */
|
|
21
|
+
readonly sharedDefault: boolean;
|
|
22
|
+
}
|
|
23
|
+
export type ApplyResult = {
|
|
24
|
+
readonly ok: true;
|
|
25
|
+
readonly applied: readonly AppliedEdit[];
|
|
26
|
+
/** New sha256 of every file written, for the caller's self-write map. */
|
|
27
|
+
readonly files: ReadonlyMap<string, string>;
|
|
28
|
+
} | {
|
|
29
|
+
readonly ok: false;
|
|
30
|
+
readonly rejected: readonly EditRejection[];
|
|
31
|
+
};
|
|
32
|
+
export interface ApplyOptions {
|
|
33
|
+
readonly ctx: ResolveContext;
|
|
34
|
+
readonly entryFile: string;
|
|
35
|
+
readonly exportName: string;
|
|
36
|
+
/**
|
|
37
|
+
* The frame as most recently loaded, used to verify that each literal we are
|
|
38
|
+
* about to overwrite really is the one that produced the current value.
|
|
39
|
+
*/
|
|
40
|
+
readonly frame: unknown;
|
|
41
|
+
readonly edits: readonly Edit[];
|
|
42
|
+
/**
|
|
43
|
+
* sha256 each file is expected to still have. A file that has changed since
|
|
44
|
+
* the studio read it is refused rather than overwritten.
|
|
45
|
+
*/
|
|
46
|
+
readonly expect?: ReadonlyMap<string, string>;
|
|
47
|
+
}
|
|
48
|
+
/** Whether a literal's source text really denotes `value`. */
|
|
49
|
+
export declare function literalDenotes(text: string, value: unknown): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Renders a new literal in the style of the one it replaces.
|
|
52
|
+
*
|
|
53
|
+
* Numbers are written plainly — every real frame uses integers, and a drag
|
|
54
|
+
* produces fractions nobody wants in source. Strings reuse the quote
|
|
55
|
+
* character already there so the file still satisfies Prettier.
|
|
56
|
+
*/
|
|
57
|
+
export declare function renderLiteral(value: EditableValue, previous: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* Rewrites literals in place, or refuses and writes nothing.
|
|
60
|
+
*
|
|
61
|
+
* Every edit is resolved to a source range, checked against the value the
|
|
62
|
+
* frame actually loaded with, and only then spliced. The check is the reason
|
|
63
|
+
* this is safe to run against someone's working tree: if the resolver is ever
|
|
64
|
+
* wrong — a mis-ordered spread, a followed import that was the wrong one — the
|
|
65
|
+
* literal will not match the value, and the whole batch is refused. The
|
|
66
|
+
* failure mode is "refuses to edit", never "edited the wrong number".
|
|
67
|
+
*
|
|
68
|
+
* Only the literal's own characters change, so comments, formatting and import
|
|
69
|
+
* order survive by construction; nothing is reprinted.
|
|
70
|
+
*/
|
|
71
|
+
export declare function applyEdits(options: ApplyOptions): Promise<ApplyResult>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { formatPath, getAtPath } from "../../../manifest/walk.js";
|
|
3
|
+
import { writeFileAtomic } from "../../atomic.js";
|
|
4
|
+
import { resolveFramePath, } from "./resolvePath.js";
|
|
5
|
+
import { normalizeFile } from "./sourceIndex.js";
|
|
6
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
7
|
+
/** Whether a literal's source text really denotes `value`. */
|
|
8
|
+
export function literalDenotes(text, value) {
|
|
9
|
+
if (typeof value === "number") {
|
|
10
|
+
return Number(text) === value;
|
|
11
|
+
}
|
|
12
|
+
if (typeof value === "boolean") {
|
|
13
|
+
return text === String(value);
|
|
14
|
+
}
|
|
15
|
+
if (typeof value === "string") {
|
|
16
|
+
const quote = text[0];
|
|
17
|
+
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return text.slice(1, -1) === value;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Renders a new literal in the style of the one it replaces.
|
|
26
|
+
*
|
|
27
|
+
* Numbers are written plainly — every real frame uses integers, and a drag
|
|
28
|
+
* produces fractions nobody wants in source. Strings reuse the quote
|
|
29
|
+
* character already there so the file still satisfies Prettier.
|
|
30
|
+
*/
|
|
31
|
+
export function renderLiteral(value, previous) {
|
|
32
|
+
if (typeof value === "number") {
|
|
33
|
+
return Number.isInteger(value) ? String(value) : String(Math.round(value));
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === "boolean") {
|
|
36
|
+
return String(value);
|
|
37
|
+
}
|
|
38
|
+
const quote = previous.startsWith("'")
|
|
39
|
+
? "'"
|
|
40
|
+
: previous.startsWith("`")
|
|
41
|
+
? "`"
|
|
42
|
+
: '"';
|
|
43
|
+
const escaped = value
|
|
44
|
+
.replaceAll("\\", "\\\\")
|
|
45
|
+
.replaceAll(quote, `\\${quote}`);
|
|
46
|
+
return `${quote}${escaped}${quote}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Rewrites literals in place, or refuses and writes nothing.
|
|
50
|
+
*
|
|
51
|
+
* Every edit is resolved to a source range, checked against the value the
|
|
52
|
+
* frame actually loaded with, and only then spliced. The check is the reason
|
|
53
|
+
* this is safe to run against someone's working tree: if the resolver is ever
|
|
54
|
+
* wrong — a mis-ordered spread, a followed import that was the wrong one — the
|
|
55
|
+
* literal will not match the value, and the whole batch is refused. The
|
|
56
|
+
* failure mode is "refuses to edit", never "edited the wrong number".
|
|
57
|
+
*
|
|
58
|
+
* Only the literal's own characters change, so comments, formatting and import
|
|
59
|
+
* order survive by construction; nothing is reprinted.
|
|
60
|
+
*/
|
|
61
|
+
export async function applyEdits(options) {
|
|
62
|
+
const { ctx, entryFile, exportName, frame, edits, expect } = options;
|
|
63
|
+
const rejected = [];
|
|
64
|
+
const splices = [];
|
|
65
|
+
for (const edit of edits) {
|
|
66
|
+
const resolution = resolveFramePath(ctx, entryFile, exportName, edit.path);
|
|
67
|
+
if (!resolution.editable) {
|
|
68
|
+
rejected.push({
|
|
69
|
+
path: edit.path,
|
|
70
|
+
reason: resolution.reason,
|
|
71
|
+
detail: resolution.detail,
|
|
72
|
+
...(resolution.source ? { source: resolution.source } : {}),
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// The gate. `getAtPath` reads the value the frame really loaded with; if
|
|
77
|
+
// the literal we found does not denote it, we found the wrong literal.
|
|
78
|
+
const current = getAtPath(frame, edit.path);
|
|
79
|
+
if (!literalDenotes(resolution.text, current)) {
|
|
80
|
+
rejected.push({
|
|
81
|
+
path: edit.path,
|
|
82
|
+
reason: "value-mismatch",
|
|
83
|
+
detail: `${formatPath(edit.path)} is ${JSON.stringify(current)} in the loaded frame, but ` +
|
|
84
|
+
`the literal found at ${resolution.location.file}:${String(resolution.location.line)} is ` +
|
|
85
|
+
`${resolution.text}. Refusing to edit a value the studio cannot account for.`,
|
|
86
|
+
source: resolution.location,
|
|
87
|
+
});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
splices.push({
|
|
91
|
+
file: resolution.file,
|
|
92
|
+
start: resolution.start,
|
|
93
|
+
end: resolution.end,
|
|
94
|
+
text: renderLiteral(edit.value, resolution.text),
|
|
95
|
+
applied: {
|
|
96
|
+
path: edit.path,
|
|
97
|
+
file: resolution.file,
|
|
98
|
+
from: resolution.text,
|
|
99
|
+
to: renderLiteral(edit.value, resolution.text),
|
|
100
|
+
sharedDefault: resolution.sharedDefault,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (rejected.length > 0) {
|
|
105
|
+
return { ok: false, rejected };
|
|
106
|
+
}
|
|
107
|
+
// Group by file, then reject a batch whose ranges collide — two edits to one
|
|
108
|
+
// literal have no defined outcome, and silently picking one is worse.
|
|
109
|
+
const byFile = new Map();
|
|
110
|
+
for (const splice of splices) {
|
|
111
|
+
const key = normalizeFile(splice.file);
|
|
112
|
+
byFile.set(key, [...(byFile.get(key) ?? []), splice]);
|
|
113
|
+
}
|
|
114
|
+
for (const [file, group] of byFile) {
|
|
115
|
+
const sorted = [...group].sort((a, b) => a.start - b.start);
|
|
116
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
117
|
+
const previous = sorted[i - 1];
|
|
118
|
+
const current = sorted[i];
|
|
119
|
+
if (previous && current && current.start < previous.end) {
|
|
120
|
+
rejected.push({
|
|
121
|
+
path: current.applied.path,
|
|
122
|
+
reason: "overlap",
|
|
123
|
+
detail: `Two edits target overlapping text in ${file}.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (rejected.length > 0) {
|
|
129
|
+
return { ok: false, rejected };
|
|
130
|
+
}
|
|
131
|
+
const written = new Map();
|
|
132
|
+
const pending = [];
|
|
133
|
+
for (const [file, group] of byFile) {
|
|
134
|
+
const text = ctx.index.text(file);
|
|
135
|
+
if (text === undefined) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
rejected: [
|
|
139
|
+
{
|
|
140
|
+
path: group[0]?.applied.path ?? [],
|
|
141
|
+
reason: "unreadable",
|
|
142
|
+
detail: `Could not read ${file}.`,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const wanted = expect?.get(file);
|
|
148
|
+
if (wanted !== undefined && sha256(text) !== wanted) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
rejected: [
|
|
152
|
+
{
|
|
153
|
+
path: group[0]?.applied.path ?? [],
|
|
154
|
+
reason: "stale",
|
|
155
|
+
detail: `${file} changed on disk since the studio last read it.`,
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// Descending, so an earlier replacement never shifts a later offset.
|
|
161
|
+
let next = text;
|
|
162
|
+
for (const splice of [...group].sort((a, b) => b.start - a.start)) {
|
|
163
|
+
next = next.slice(0, splice.start) + splice.text + next.slice(splice.end);
|
|
164
|
+
}
|
|
165
|
+
pending.push({ file, text: next });
|
|
166
|
+
written.set(file, sha256(next));
|
|
167
|
+
}
|
|
168
|
+
for (const { file, text } of pending) {
|
|
169
|
+
await writeFileAtomic(file, text);
|
|
170
|
+
ctx.index.invalidate(file);
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
applied: splices.map((splice) => splice.applied),
|
|
175
|
+
files: written,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FramePath } from "../../../manifest/walk.js";
|
|
2
|
+
import { type ResolveContext } from "./resolvePath.js";
|
|
3
|
+
/** A box set the studio can address, as `session.ts` enumerates them. */
|
|
4
|
+
export interface BoxSetRef {
|
|
5
|
+
readonly variant: string | null;
|
|
6
|
+
readonly layout: string;
|
|
7
|
+
readonly key: string;
|
|
8
|
+
readonly path: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/** One place a literal is read from, besides the one being edited. */
|
|
11
|
+
export interface Impact {
|
|
12
|
+
readonly variant: string | null;
|
|
13
|
+
readonly layout: string;
|
|
14
|
+
readonly boxSet: string;
|
|
15
|
+
readonly path: FramePath;
|
|
16
|
+
}
|
|
17
|
+
export interface ImpactOptions {
|
|
18
|
+
readonly ctx: ResolveContext;
|
|
19
|
+
readonly entryFile: string;
|
|
20
|
+
readonly exportName: string;
|
|
21
|
+
/** Every `kind: "boxes"` slot in the frame. */
|
|
22
|
+
readonly boxSets: readonly BoxSetRef[];
|
|
23
|
+
/** The full logical path of the value being edited. */
|
|
24
|
+
readonly target: FramePath;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Every box set that reads the same literal as `target`.
|
|
28
|
+
*
|
|
29
|
+
* Frames share geometry heavily — `frame-borderless` spreads
|
|
30
|
+
* `borderlessNormalBoxes` into most of its layouts, and
|
|
31
|
+
* `withCollectorInfoDefaults` hands one `collectorInfo` to all ten box sets of
|
|
32
|
+
* every layout. Moving such a box moves it everywhere, so the studio has to be
|
|
33
|
+
* able to say so *before* it writes, rather than surprising the author after.
|
|
34
|
+
*
|
|
35
|
+
* Identity is the exact source range: two paths are the same value only when
|
|
36
|
+
* they resolve to the same characters in the same file.
|
|
37
|
+
*
|
|
38
|
+
* The scan is per-frame. A literal exported through `/extend` and read by a
|
|
39
|
+
* different frame package is not counted here; `resolveFramePath` reports that
|
|
40
|
+
* separately by way of the file it landed in.
|
|
41
|
+
*/
|
|
42
|
+
export declare function impactOf(options: ImpactOptions): Impact[];
|
|
43
|
+
/**
|
|
44
|
+
* Memoises impact scans for one loaded revision of a frame.
|
|
45
|
+
*
|
|
46
|
+
* The scan resolves the same tail against every box set, which for a frame
|
|
47
|
+
* like `frame-borderless` is ~100 resolutions; the inspector asks for one per
|
|
48
|
+
* field. Parses are already cached by `sourceIndex`, so this only has to stop
|
|
49
|
+
* the walk itself from repeating.
|
|
50
|
+
*/
|
|
51
|
+
export declare function createImpactCache(): {
|
|
52
|
+
get: (revision: number, options: ImpactOptions) => Impact[];
|
|
53
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { resolveFramePath } from "./resolvePath.js";
|
|
2
|
+
/** Splits a leaf path into its box-set prefix and the tail inside that set. */
|
|
3
|
+
function splitTarget(target, boxSets) {
|
|
4
|
+
for (const set of boxSets) {
|
|
5
|
+
if (target.length > set.path.length &&
|
|
6
|
+
set.path.every((segment, i) => target[i] === segment)) {
|
|
7
|
+
return { set, tail: target.slice(set.path.length) };
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Every box set that reads the same literal as `target`.
|
|
14
|
+
*
|
|
15
|
+
* Frames share geometry heavily — `frame-borderless` spreads
|
|
16
|
+
* `borderlessNormalBoxes` into most of its layouts, and
|
|
17
|
+
* `withCollectorInfoDefaults` hands one `collectorInfo` to all ten box sets of
|
|
18
|
+
* every layout. Moving such a box moves it everywhere, so the studio has to be
|
|
19
|
+
* able to say so *before* it writes, rather than surprising the author after.
|
|
20
|
+
*
|
|
21
|
+
* Identity is the exact source range: two paths are the same value only when
|
|
22
|
+
* they resolve to the same characters in the same file.
|
|
23
|
+
*
|
|
24
|
+
* The scan is per-frame. A literal exported through `/extend` and read by a
|
|
25
|
+
* different frame package is not counted here; `resolveFramePath` reports that
|
|
26
|
+
* separately by way of the file it landed in.
|
|
27
|
+
*/
|
|
28
|
+
export function impactOf(options) {
|
|
29
|
+
const { ctx, entryFile, exportName, boxSets, target } = options;
|
|
30
|
+
const split = splitTarget(target, boxSets);
|
|
31
|
+
if (!split) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const anchor = resolveFramePath(ctx, entryFile, exportName, target);
|
|
35
|
+
if (!anchor.editable) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const shared = [];
|
|
39
|
+
for (const set of boxSets) {
|
|
40
|
+
const path = [...set.path, ...split.tail];
|
|
41
|
+
if (path.length === target.length &&
|
|
42
|
+
path.every((s, i) => s === target[i])) {
|
|
43
|
+
continue; // the value being edited
|
|
44
|
+
}
|
|
45
|
+
const resolution = resolveFramePath(ctx, entryFile, exportName, path);
|
|
46
|
+
if (resolution.editable &&
|
|
47
|
+
resolution.file === anchor.file &&
|
|
48
|
+
resolution.start === anchor.start &&
|
|
49
|
+
resolution.end === anchor.end) {
|
|
50
|
+
shared.push({
|
|
51
|
+
variant: set.variant,
|
|
52
|
+
layout: set.layout,
|
|
53
|
+
boxSet: set.key,
|
|
54
|
+
path,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return shared;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Memoises impact scans for one loaded revision of a frame.
|
|
62
|
+
*
|
|
63
|
+
* The scan resolves the same tail against every box set, which for a frame
|
|
64
|
+
* like `frame-borderless` is ~100 resolutions; the inspector asks for one per
|
|
65
|
+
* field. Parses are already cached by `sourceIndex`, so this only has to stop
|
|
66
|
+
* the walk itself from repeating.
|
|
67
|
+
*/
|
|
68
|
+
export function createImpactCache() {
|
|
69
|
+
let cachedRevision = -1;
|
|
70
|
+
let entries = new Map();
|
|
71
|
+
return {
|
|
72
|
+
get(revision, options) {
|
|
73
|
+
if (revision !== cachedRevision) {
|
|
74
|
+
cachedRevision = revision;
|
|
75
|
+
entries = new Map();
|
|
76
|
+
}
|
|
77
|
+
const key = options.target.join(".");
|
|
78
|
+
const hit = entries.get(key);
|
|
79
|
+
if (hit) {
|
|
80
|
+
return hit;
|
|
81
|
+
}
|
|
82
|
+
const computed = impactOf(options);
|
|
83
|
+
entries.set(key, computed);
|
|
84
|
+
return computed;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Absolute path a specifier resolves to from `importer`, or `undefined`.
|
|
3
|
+
*
|
|
4
|
+
* Relative specifiers are probed on disk; bare ones go through Node's own
|
|
5
|
+
* resolver, which honours `exports` maps and pnpm's workspace links. That
|
|
6
|
+
* matters because frame packages point their exports straight at TypeScript
|
|
7
|
+
* source (`"./extend": "./src/extend.ts"`), so `@cardanvil/frame-m15/extend`
|
|
8
|
+
* resolves to a file this can actually parse.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveModuleFile(specifier: string, importer: string): string | undefined;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { normalizeFile } from "./sourceIndex.js";
|
|
5
|
+
/**
|
|
6
|
+
* Extensions tried for a relative import, in order.
|
|
7
|
+
*
|
|
8
|
+
* Frame packages import without extensions by convention (`./boxes`,
|
|
9
|
+
* `./base`), so a specifier names either a sibling module or a directory with
|
|
10
|
+
* a barrel. `.js` is mapped back to `.ts` for authors who write NodeNext-style
|
|
11
|
+
* specifiers.
|
|
12
|
+
*/
|
|
13
|
+
const CANDIDATES = [
|
|
14
|
+
(base) => `${base}.ts`,
|
|
15
|
+
(base) => `${base}.tsx`,
|
|
16
|
+
(base) => path.join(base, "index.ts"),
|
|
17
|
+
(base) => path.join(base, "index.tsx"),
|
|
18
|
+
(base) => base,
|
|
19
|
+
];
|
|
20
|
+
function firstExisting(base) {
|
|
21
|
+
for (const candidate of CANDIDATES) {
|
|
22
|
+
const file = candidate(base);
|
|
23
|
+
try {
|
|
24
|
+
if (statSync(file).isFile()) {
|
|
25
|
+
return normalizeFile(file);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* not there; try the next candidate */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Absolute path a specifier resolves to from `importer`, or `undefined`.
|
|
36
|
+
*
|
|
37
|
+
* Relative specifiers are probed on disk; bare ones go through Node's own
|
|
38
|
+
* resolver, which honours `exports` maps and pnpm's workspace links. That
|
|
39
|
+
* matters because frame packages point their exports straight at TypeScript
|
|
40
|
+
* source (`"./extend": "./src/extend.ts"`), so `@cardanvil/frame-m15/extend`
|
|
41
|
+
* resolves to a file this can actually parse.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveModuleFile(specifier, importer) {
|
|
44
|
+
if (specifier.startsWith(".")) {
|
|
45
|
+
const base = path.resolve(path.dirname(importer), specifier);
|
|
46
|
+
const stripped = base.replace(/\.js$/, "");
|
|
47
|
+
return firstExisting(base) ?? firstExisting(stripped);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return normalizeFile(createRequire(importer).resolve(specifier));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|