@cardanvil/frame-kit 0.4.0 → 0.6.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 +45 -0
- package/dist/cli/main.js +16 -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 +87 -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 +513 -0
- package/dist/cli/studio/session.d.ts +73 -0
- package/dist/cli/studio/session.js +154 -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/decorations.d.ts +78 -0
- package/dist/layout/decorations.js +108 -0
- package/dist/layout/frameLayers.d.ts +136 -0
- package/dist/layout/frameLayers.js +291 -0
- package/dist/layout/index.d.ts +16 -0
- package/dist/layout/index.js +16 -0
- package/dist/layout/resolveFrameAsset.d.ts +31 -0
- package/dist/layout/resolveFrameAsset.js +58 -0
- package/dist/manifest/contract.d.ts +671 -1
- package/dist/manifest/contract.js +7 -1
- package/dist/schema/frame.d.ts +1692 -0
- package/dist/schema/frame.js +27 -0
- package/dist/schema/frameAssets.d.ts +3 -1
- package/dist/schema/frameAssets.js +23 -1
- 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-CNzyV22G.js +76 -0
- package/dist/studio/index.html +12 -0
- package/package.json +12 -3
package/README.md
CHANGED
|
@@ -129,6 +129,51 @@ Packaging needs Vite — it is what turns `import w from "./w.png"` into a file
|
|
|
129
129
|
so it is an **optional** peer dependency, loaded only when packaging runs. The
|
|
130
130
|
schemas above work under plain Node and in a browser without it.
|
|
131
131
|
|
|
132
|
+
### `frame-kit studio`
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
frame-kit studio [--root <dir>] [--port <n>] [--frame <slug>] [--no-write]
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
A visual box editor, served from `dist/studio` and opened at
|
|
139
|
+
<http://localhost:4620>. It loads frames through the same Vite SSR loader
|
|
140
|
+
`validate` uses, so what it draws is the validated frame, and it reloads when a
|
|
141
|
+
source file changes.
|
|
142
|
+
|
|
143
|
+
The card name and type line are previewed in Beleren at the authored size and
|
|
144
|
+
flagged when they overflow — one line each, with no wrapping, symbols or
|
|
145
|
+
auto-shrink, so the app remains the authority on a full render.
|
|
146
|
+
|
|
147
|
+
Working on the studio's own UI: `--dev` serves it from source with hot
|
|
148
|
+
reloading, on the same port as the API, so one command covers both. It needs
|
|
149
|
+
the UI's source, which only exists inside the frames workspace.
|
|
150
|
+
|
|
151
|
+
Dragging a box rewrites the literal it came from, in place — nothing is
|
|
152
|
+
reprinted, so comments and formatting survive. Editing needs `typescript`
|
|
153
|
+
installed (an optional peer); without it the studio still runs read-only. Values
|
|
154
|
+
that are not written as literals are reported as such rather than edited.
|
|
155
|
+
|
|
156
|
+
### `@cardanvil/frame-kit/layout`
|
|
157
|
+
|
|
158
|
+
How a frame's art is chosen and placed, as pure functions:
|
|
159
|
+
|
|
160
|
+
- `selectFrameLayers(details, options)` — the base frame and section overlays a
|
|
161
|
+
set of `FrameDetails` selects. Split out of Card Anvil's `getFrameLayers`, so
|
|
162
|
+
the studio and the renderer cannot drift on two-colour splits, hybrid halves
|
|
163
|
+
or the coloured-artifact fallback.
|
|
164
|
+
- `frameDetailsFor(shape)` — those details from a plain description of a card
|
|
165
|
+
(colours plus artifact / vehicle / land / enchantment / hybrid / devoid),
|
|
166
|
+
for callers with no Scryfall card to parse.
|
|
167
|
+
- `placeAsset(path, config)` — where a piece of art is drawn. Most frame art is
|
|
168
|
+
full-sheet and centres, but crowns, nicknames and PT plates come from the
|
|
169
|
+
layout's own render config; centring those puts a legendary crown in the
|
|
170
|
+
middle of the card.
|
|
171
|
+
- `resolveFrameAsset` / `resolveBaseFrame` — exact per-family lookup, and the
|
|
172
|
+
base-frame fallback chain.
|
|
173
|
+
|
|
174
|
+
Card Anvil still owns the other half — reading a Scryfall card into
|
|
175
|
+
`FrameDetails` — because that is where the Scryfall types live.
|
|
176
|
+
|
|
132
177
|
## Type it, then validate it
|
|
133
178
|
|
|
134
179
|
Typing a config as `Frame` is weaker than the schema a loading app enforces. The clearest example:
|
package/dist/cli/main.js
CHANGED
|
@@ -9,6 +9,7 @@ import { PackagingError } from "./errors.js";
|
|
|
9
9
|
import { Reporter, inGitHubActions } from "./report.js";
|
|
10
10
|
import { SCHEMA_NAMES, toJsonSchema } from "./schema.js";
|
|
11
11
|
import { stableStringify } from "./stableJson.js";
|
|
12
|
+
import { runStudio } from "./studio/serve.js";
|
|
12
13
|
import { watchFrames } from "./watch.js";
|
|
13
14
|
const USAGE = `frame-kit — package Card Anvil frames
|
|
14
15
|
|
|
@@ -17,6 +18,7 @@ Usage:
|
|
|
17
18
|
frame-kit build --out <dir> [options] check every frame, then pack each one
|
|
18
19
|
frame-kit schema <name> [--out <file>] print a format as JSON Schema
|
|
19
20
|
(${SCHEMA_NAMES.join(" | ")})
|
|
21
|
+
frame-kit studio [options] open the visual box editor
|
|
20
22
|
frame-kit --help
|
|
21
23
|
frame-kit --version
|
|
22
24
|
|
|
@@ -35,6 +37,17 @@ build only:
|
|
|
35
37
|
in the index
|
|
36
38
|
--watch rebuild a frame whenever its source changes
|
|
37
39
|
|
|
40
|
+
studio only:
|
|
41
|
+
--port <n> port to serve on (default: 4620)
|
|
42
|
+
--host <h> interface to bind (default: 127.0.0.1)
|
|
43
|
+
--open open the studio in your browser
|
|
44
|
+
--dev serve the studio's own UI from source, with hot
|
|
45
|
+
reloading (only inside the frames workspace)
|
|
46
|
+
--no-write serve read-only; refuse every edit
|
|
47
|
+
|
|
48
|
+
The studio edits the literals in your source when you drag a box, so run it on
|
|
49
|
+
a clean working tree. It needs TypeScript installed to find them.
|
|
50
|
+
|
|
38
51
|
A frame is any directory containing a frame.meta.json. One with "private": true
|
|
39
52
|
is checked by validate but never built.
|
|
40
53
|
`;
|
|
@@ -208,6 +221,9 @@ async function main(argv) {
|
|
|
208
221
|
if (command === "schema") {
|
|
209
222
|
return await runSchema(argv.slice(1));
|
|
210
223
|
}
|
|
224
|
+
if (command === "studio") {
|
|
225
|
+
return await runStudio(argv.slice(1));
|
|
226
|
+
}
|
|
211
227
|
if (command !== "validate" && command !== "build") {
|
|
212
228
|
console.error(`Unknown command "${command}".\n\n${USAGE}`);
|
|
213
229
|
return EXIT.usage;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long to wait for changes to stop before reacting.
|
|
3
|
+
*
|
|
4
|
+
* One save can produce several events — a write, a rename, an attribute
|
|
5
|
+
* change — and a tool upstream may rewrite a whole directory.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEBOUNCE_MS = 200;
|
|
8
|
+
/** Whether a path is build output, editor debris, or inside a skipped dir. */
|
|
9
|
+
export declare function isIgnored(file: string): boolean;
|
|
10
|
+
export interface SourceWatcherOptions {
|
|
11
|
+
/** Absolute path the watch is rooted at. Only used for error messages. */
|
|
12
|
+
root: string;
|
|
13
|
+
/** Directories to watch recursively. Overlapping entries are fine. */
|
|
14
|
+
dirs: readonly string[];
|
|
15
|
+
/**
|
|
16
|
+
* Called with each batch of changed paths, debounced and never overlapping.
|
|
17
|
+
*
|
|
18
|
+
* Anything that arrives while a call is in flight is queued and delivered in
|
|
19
|
+
* the next batch, so a slow handler cannot drop a change.
|
|
20
|
+
*/
|
|
21
|
+
onChange: (changed: readonly string[]) => void | Promise<void>;
|
|
22
|
+
/** Ignored on top of the built-in list — a build's own output directory, say. */
|
|
23
|
+
ignore?: (file: string) => boolean;
|
|
24
|
+
/** Defaults to `DEBOUNCE_MS`. */
|
|
25
|
+
debounceMs?: number;
|
|
26
|
+
/** Where an unwatchable directory or a throwing handler is reported. */
|
|
27
|
+
onError?: (message: string) => void;
|
|
28
|
+
}
|
|
29
|
+
export interface SourceWatcher {
|
|
30
|
+
close: () => void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Watches directories and delivers debounced, non-overlapping change batches.
|
|
34
|
+
*
|
|
35
|
+
* Shared by `frame-kit build --watch` and `frame-kit studio`, which need the
|
|
36
|
+
* same three things and get them wrong in the same three ways otherwise:
|
|
37
|
+
* coalescing a burst of events from one save, never running two handlers at
|
|
38
|
+
* once, and surviving a handler that throws.
|
|
39
|
+
*/
|
|
40
|
+
export declare function startSourceWatcher(options: SourceWatcherOptions): SourceWatcher;
|
|
@@ -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,87 @@
|
|
|
1
|
+
import { CardBoxesSchema, LayoutConfigSchema } 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
|
+
[FrameAssetsSchema, "assets"],
|
|
27
|
+
[LayoutMasksSchema, "masks"],
|
|
28
|
+
]);
|
|
29
|
+
/**
|
|
30
|
+
* Whether a schema is a plain object of numbers — a layout's render knobs
|
|
31
|
+
* (`planeswalkerConfig`, `sagaConfig`, `crownConfig`, `nicknameConfig`).
|
|
32
|
+
*
|
|
33
|
+
* Recognised by shape rather than by name, so a knob group added to the
|
|
34
|
+
* contract is editable in the studio without a change here. Records and enums
|
|
35
|
+
* do not qualify, which is what keeps `templateSettings`, `colorArtOverrides`
|
|
36
|
+
* and `hybridTitleMask` out.
|
|
37
|
+
*/
|
|
38
|
+
function isNumericObject(schema) {
|
|
39
|
+
const node = schema.def;
|
|
40
|
+
if (node.type !== "object" || !node.shape) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const fields = Object.values(node.shape);
|
|
44
|
+
return (fields.length > 0 &&
|
|
45
|
+
fields.every((field) => {
|
|
46
|
+
const inner = unwrap(field).inner.def;
|
|
47
|
+
return inner.type === "number" || inner.type === "boolean";
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Every box / asset / mask / knob slot the frame contract defines, read off
|
|
52
|
+
* the schema rather than listed by hand.
|
|
53
|
+
*
|
|
54
|
+
* Identity is the test: a slot counts as boxes because its schema *is*
|
|
55
|
+
* `CardBoxesSchema`, not because its name ends in "Boxes". So a slot added to
|
|
56
|
+
* `LayoutConfigSchema` shows up here — and in the studio — with no change,
|
|
57
|
+
* and `boxSets.test.ts` fails if one is added that this cannot classify.
|
|
58
|
+
*/
|
|
59
|
+
export function layoutSlots() {
|
|
60
|
+
const shape = LayoutConfigSchema.def
|
|
61
|
+
.shape;
|
|
62
|
+
const slots = [];
|
|
63
|
+
for (const [key, schema] of Object.entries(shape)) {
|
|
64
|
+
const { inner, wrapped } = unwrap(schema);
|
|
65
|
+
const kind = KIND_BY_SCHEMA.get(inner) ??
|
|
66
|
+
(isNumericObject(inner) ? "knobs" : undefined);
|
|
67
|
+
if (kind) {
|
|
68
|
+
slots.push({ key, kind, required: !wrapped });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return slots;
|
|
72
|
+
}
|
|
73
|
+
/** Just the slots holding `CardBoxes` — what the studio can edit. */
|
|
74
|
+
export function boxSlots() {
|
|
75
|
+
return layoutSlots().filter((slot) => slot.kind === "boxes");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Names of every member a `CardBoxes` can hold, in contract order.
|
|
79
|
+
*
|
|
80
|
+
* Used to enumerate a box set without assuming which optional members a given
|
|
81
|
+
* frame happens to define.
|
|
82
|
+
*/
|
|
83
|
+
export function cardBoxKeys() {
|
|
84
|
+
const shape = CardBoxesSchema.def
|
|
85
|
+
.shape;
|
|
86
|
+
return Object.keys(shape);
|
|
87
|
+
}
|
|
@@ -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
|
+
};
|