@cardanvil/frame-kit 0.5.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.
Files changed (46) hide show
  1. package/README.md +45 -0
  2. package/dist/cli/main.js +16 -0
  3. package/dist/cli/sourceWatcher.d.ts +40 -0
  4. package/dist/cli/sourceWatcher.js +100 -0
  5. package/dist/cli/studio/boxSets.d.ts +33 -0
  6. package/dist/cli/studio/boxSets.js +87 -0
  7. package/dist/cli/studio/edit/applyEdits.d.ts +71 -0
  8. package/dist/cli/studio/edit/applyEdits.js +177 -0
  9. package/dist/cli/studio/edit/impact.d.ts +53 -0
  10. package/dist/cli/studio/edit/impact.js +87 -0
  11. package/dist/cli/studio/edit/resolveModule.d.ts +10 -0
  12. package/dist/cli/studio/edit/resolveModule.js +55 -0
  13. package/dist/cli/studio/edit/resolvePath.d.ts +53 -0
  14. package/dist/cli/studio/edit/resolvePath.js +606 -0
  15. package/dist/cli/studio/edit/sourceIndex.d.ts +29 -0
  16. package/dist/cli/studio/edit/sourceIndex.js +73 -0
  17. package/dist/cli/studio/edit/ts.d.ts +14 -0
  18. package/dist/cli/studio/edit/ts.js +34 -0
  19. package/dist/cli/studio/serve.d.ts +23 -0
  20. package/dist/cli/studio/serve.js +513 -0
  21. package/dist/cli/studio/session.d.ts +73 -0
  22. package/dist/cli/studio/session.js +154 -0
  23. package/dist/cli/watch.d.ts +3 -0
  24. package/dist/cli/watch.js +20 -89
  25. package/dist/layout/assetPlacement.d.ts +71 -0
  26. package/dist/layout/assetPlacement.js +104 -0
  27. package/dist/layout/decorations.d.ts +78 -0
  28. package/dist/layout/decorations.js +108 -0
  29. package/dist/layout/frameLayers.d.ts +136 -0
  30. package/dist/layout/frameLayers.js +291 -0
  31. package/dist/layout/index.d.ts +16 -0
  32. package/dist/layout/index.js +16 -0
  33. package/dist/layout/resolveFrameAsset.d.ts +31 -0
  34. package/dist/layout/resolveFrameAsset.js +58 -0
  35. package/dist/manifest/contract.d.ts +359 -1
  36. package/dist/manifest/contract.js +3 -1
  37. package/dist/schema/frame.d.ts +907 -0
  38. package/dist/schema/frame.js +16 -0
  39. package/dist/schema/frameAssets.d.ts +3 -1
  40. package/dist/schema/frameAssets.js +23 -1
  41. package/dist/studio/assets/PlantinMTProRgIt-D0FB51kb.woff2 +0 -0
  42. package/dist/studio/assets/beleren-b-B3lFvodX.ttf +0 -0
  43. package/dist/studio/assets/beleren-bsc-yvL6mU7X.ttf +0 -0
  44. package/dist/studio/assets/index-CNzyV22G.js +76 -0
  45. package/dist/studio/index.html +12 -0
  46. 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
+ };