@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.
Files changed (90) hide show
  1. package/README.md +119 -0
  2. package/dist/authoring/collectorInfo.d.ts +17 -0
  3. package/dist/authoring/collectorInfo.js +62 -0
  4. package/dist/authoring/index.d.ts +2 -0
  5. package/dist/authoring/index.js +2 -0
  6. package/dist/authoring/layoutDefaults.d.ts +20 -2
  7. package/dist/authoring/layoutDefaults.js +48 -13
  8. package/dist/authoring/opacityStops.d.ts +8 -0
  9. package/dist/authoring/opacityStops.js +17 -0
  10. package/dist/cli/index.d.ts +1 -0
  11. package/dist/cli/index.js +1 -0
  12. package/dist/cli/loadFrame.js +1 -1
  13. package/dist/cli/main.js +88 -0
  14. package/dist/cli/sourceWatcher.d.ts +40 -0
  15. package/dist/cli/sourceWatcher.js +100 -0
  16. package/dist/cli/studio/boxSets.d.ts +33 -0
  17. package/dist/cli/studio/boxSets.js +90 -0
  18. package/dist/cli/studio/edit/applyEdits.d.ts +71 -0
  19. package/dist/cli/studio/edit/applyEdits.js +177 -0
  20. package/dist/cli/studio/edit/impact.d.ts +53 -0
  21. package/dist/cli/studio/edit/impact.js +87 -0
  22. package/dist/cli/studio/edit/resolveModule.d.ts +10 -0
  23. package/dist/cli/studio/edit/resolveModule.js +55 -0
  24. package/dist/cli/studio/edit/resolvePath.d.ts +53 -0
  25. package/dist/cli/studio/edit/resolvePath.js +606 -0
  26. package/dist/cli/studio/edit/sourceIndex.d.ts +29 -0
  27. package/dist/cli/studio/edit/sourceIndex.js +73 -0
  28. package/dist/cli/studio/edit/ts.d.ts +14 -0
  29. package/dist/cli/studio/edit/ts.js +34 -0
  30. package/dist/cli/studio/serve.d.ts +23 -0
  31. package/dist/cli/studio/serve.js +516 -0
  32. package/dist/cli/studio/session.d.ts +73 -0
  33. package/dist/cli/studio/session.js +154 -0
  34. package/dist/cli/trim.d.ts +62 -0
  35. package/dist/cli/trim.js +251 -0
  36. package/dist/cli/watch.d.ts +3 -0
  37. package/dist/cli/watch.js +20 -89
  38. package/dist/layout/assetPlacement.d.ts +71 -0
  39. package/dist/layout/assetPlacement.js +104 -0
  40. package/dist/layout/border.d.ts +27 -0
  41. package/dist/layout/border.js +31 -0
  42. package/dist/layout/boxOverrides.d.ts +28 -0
  43. package/dist/layout/boxOverrides.js +95 -0
  44. package/dist/layout/color.d.ts +23 -0
  45. package/dist/layout/color.js +53 -0
  46. package/dist/layout/cutouts.d.ts +28 -0
  47. package/dist/layout/cutouts.js +29 -0
  48. package/dist/layout/decorations.d.ts +73 -0
  49. package/dist/layout/decorations.js +104 -0
  50. package/dist/layout/frameLayers.d.ts +163 -0
  51. package/dist/layout/frameLayers.js +433 -0
  52. package/dist/layout/index.d.ts +27 -0
  53. package/dist/layout/index.js +27 -0
  54. package/dist/layout/paint.d.ts +36 -0
  55. package/dist/layout/paint.js +148 -0
  56. package/dist/layout/powerToughnessRow.d.ts +26 -0
  57. package/dist/layout/powerToughnessRow.js +13 -0
  58. package/dist/layout/resolveFrameAsset.d.ts +37 -0
  59. package/dist/layout/resolveFrameAsset.js +66 -0
  60. package/dist/layout/rulesSlide.d.ts +13 -0
  61. package/dist/layout/rulesSlide.js +33 -0
  62. package/dist/layout/settings.d.ts +26 -0
  63. package/dist/layout/settings.js +62 -0
  64. package/dist/layout/textStyle.d.ts +29 -0
  65. package/dist/layout/textStyle.js +28 -0
  66. package/dist/layout/turn.d.ts +54 -0
  67. package/dist/layout/turn.js +107 -0
  68. package/dist/manifest/contract.d.ts +6103 -9063
  69. package/dist/manifest/contract.js +48 -1
  70. package/dist/manifest/testFrame.js +9 -0
  71. package/dist/schema/collectorInfo.d.ts +95 -0
  72. package/dist/schema/collectorInfo.js +80 -0
  73. package/dist/schema/condition.d.ts +67 -0
  74. package/dist/schema/condition.js +47 -0
  75. package/dist/schema/frame.d.ts +17213 -24450
  76. package/dist/schema/frame.js +418 -14
  77. package/dist/schema/frameAssets.d.ts +56 -34
  78. package/dist/schema/frameAssets.js +96 -2
  79. package/dist/schema/index.d.ts +3 -0
  80. package/dist/schema/index.js +3 -0
  81. package/dist/schema/layout.d.ts +29 -4
  82. package/dist/schema/layout.js +27 -2
  83. package/dist/schema/paint.d.ts +202 -0
  84. package/dist/schema/paint.js +109 -0
  85. package/dist/studio/assets/PlantinMTProRgIt-D0FB51kb.woff2 +0 -0
  86. package/dist/studio/assets/beleren-b-B3lFvodX.ttf +0 -0
  87. package/dist/studio/assets/beleren-bsc-yvL6mU7X.ttf +0 -0
  88. package/dist/studio/assets/index-BnhNwm_r.js +74 -0
  89. package/dist/studio/index.html +12 -0
  90. 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
+ }