@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.
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 +671 -1
  36. package/dist/manifest/contract.js +7 -1
  37. package/dist/schema/frame.d.ts +1692 -0
  38. package/dist/schema/frame.js +27 -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
@@ -0,0 +1,606 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { formatPath } from "../../../manifest/walk.js";
4
+ import { resolveModuleFile } from "./resolveModule.js";
5
+ import { isUnderRoot, normalizeFile } from "./sourceIndex.js";
6
+ /** Box sets `withCollectorInfoDefaults` fills a `collectorInfo` into. */
7
+ const COLLECTOR_BOX_SETS = new Set([
8
+ "boxes",
9
+ "tallBoxes",
10
+ "backBoxes",
11
+ "creatureBoxes",
12
+ "backCreatureBoxes",
13
+ "flipsidePtBoxes",
14
+ "sagaFrontBoxes",
15
+ "sagaFrontCreatureBoxes",
16
+ "sagaBackBoxes",
17
+ "sagaBackCreatureBoxes",
18
+ ]);
19
+ const KIT_HELPERS = new Set([
20
+ "withCollectorInfoDefaults",
21
+ "withSettingsDefaults",
22
+ "omit",
23
+ ]);
24
+ /** Guards against an import cycle turning into a stack overflow. */
25
+ const MAX_DEPTH = 96;
26
+ const expr = (file, node, scope) => ({ kind: "expr", file, node, scope });
27
+ const fail = (reason, detail, source) => ({
28
+ ok: false,
29
+ problem: source
30
+ ? { editable: false, reason, detail, source }
31
+ : { editable: false, reason, detail },
32
+ });
33
+ function lookup(scope, name) {
34
+ for (let current = scope; current; current = current.parent) {
35
+ const found = current.bindings.get(name);
36
+ if (found) {
37
+ return found;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ function locationOf(api, sourceFile, node) {
43
+ const { line, character } = api.getLineAndCharacterOfPosition(sourceFile, node.getStart(sourceFile));
44
+ return {
45
+ file: normalizeFile(sourceFile.fileName),
46
+ line: line + 1,
47
+ column: character + 1,
48
+ };
49
+ }
50
+ function targetLocation(ctx, target) {
51
+ switch (target.kind) {
52
+ case "helperConfig":
53
+ return targetLocation(ctx, target.config);
54
+ case "helperBoxSet":
55
+ return targetLocation(ctx, target.base);
56
+ case "omitResult":
57
+ return targetLocation(ctx, target.source);
58
+ default: {
59
+ const sourceFile = ctx.index.get(target.file);
60
+ return sourceFile
61
+ ? locationOf(ctx.api, sourceFile, target.node)
62
+ : undefined;
63
+ }
64
+ }
65
+ }
66
+ /** Package that owns `file`, for identifying frame-kit's own helpers. */
67
+ const packageNames = new Map();
68
+ function packageNameFor(file) {
69
+ let dir = path.dirname(file);
70
+ for (;;) {
71
+ if (packageNames.has(dir)) {
72
+ return packageNames.get(dir);
73
+ }
74
+ try {
75
+ const { name } = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
76
+ packageNames.set(dir, name);
77
+ return name;
78
+ }
79
+ catch {
80
+ /* keep walking up */
81
+ }
82
+ const parent = path.dirname(dir);
83
+ if (parent === dir) {
84
+ return undefined;
85
+ }
86
+ dir = parent;
87
+ }
88
+ }
89
+ function propertyName(api, name) {
90
+ if (api.isIdentifier(name) ||
91
+ api.isStringLiteral(name) ||
92
+ api.isNumericLiteral(name)) {
93
+ return name.text;
94
+ }
95
+ return undefined;
96
+ }
97
+ /** Strips `as const`, `satisfies`, parentheses and `!`. */
98
+ function unwrapExpr(api, node) {
99
+ let current = node;
100
+ for (;;) {
101
+ if (api.isParenthesizedExpression(current) ||
102
+ api.isAsExpression(current) ||
103
+ api.isSatisfiesExpression(current) ||
104
+ api.isNonNullExpression(current)) {
105
+ current = current.expression;
106
+ continue;
107
+ }
108
+ return current;
109
+ }
110
+ }
111
+ /**
112
+ * The expression an exported name is bound to, following re-exports.
113
+ *
114
+ * `export * from` is deliberately not followed: it would mean scanning a whole
115
+ * barrel chain to find which module declares the name, and no frame's own
116
+ * value is reached that way.
117
+ */
118
+ function exportTarget(ctx, file, exportName, depth) {
119
+ const sourceFile = ctx.index.get(file);
120
+ if (!sourceFile) {
121
+ return fail("unreadable", `Could not read ${file}.`);
122
+ }
123
+ const { api } = ctx;
124
+ for (const statement of sourceFile.statements) {
125
+ const isExported = api
126
+ .getModifiers(statement)
127
+ ?.some((modifier) => modifier.kind === api.SyntaxKind.ExportKeyword);
128
+ if (api.isVariableStatement(statement) && isExported) {
129
+ for (const declaration of statement.declarationList.declarations) {
130
+ if (api.isIdentifier(declaration.name) &&
131
+ declaration.name.text === exportName &&
132
+ declaration.initializer) {
133
+ return {
134
+ ok: true,
135
+ target: expr(file, declaration.initializer, undefined),
136
+ };
137
+ }
138
+ }
139
+ }
140
+ if (api.isFunctionDeclaration(statement) &&
141
+ isExported &&
142
+ statement.name?.text === exportName) {
143
+ return { ok: true, target: expr(file, statement, undefined) };
144
+ }
145
+ if (api.isExportAssignment(statement) && exportName === "default") {
146
+ return { ok: true, target: expr(file, statement.expression, undefined) };
147
+ }
148
+ // `export { a as b }` and `export { a } from "./x"`.
149
+ if (api.isExportDeclaration(statement) &&
150
+ statement.exportClause &&
151
+ api.isNamedExports(statement.exportClause)) {
152
+ for (const element of statement.exportClause.elements) {
153
+ if (element.name.text !== exportName) {
154
+ continue;
155
+ }
156
+ const local = element.propertyName?.text ?? element.name.text;
157
+ if (statement.moduleSpecifier &&
158
+ api.isStringLiteral(statement.moduleSpecifier)) {
159
+ const next = resolveModuleFile(statement.moduleSpecifier.text, file);
160
+ return next
161
+ ? exportTarget(ctx, next, local, depth + 1)
162
+ : fail("unsupported-expression", `Could not resolve "${statement.moduleSpecifier.text}" from ${path.basename(file)}.`);
163
+ }
164
+ return resolveIdentifier(ctx, file, local, depth + 1, undefined);
165
+ }
166
+ }
167
+ }
168
+ return fail("no-export", `${path.basename(file)} has no export named "${exportName}".`);
169
+ }
170
+ /** What an identifier refers to: a scope binding, a file-level declaration, or an import. */
171
+ function resolveIdentifier(ctx, file, name, depth, scope) {
172
+ if (depth > MAX_DEPTH) {
173
+ return fail("unsupported-expression", `Gave up following "${name}" — the references may be cyclic.`);
174
+ }
175
+ const bound = lookup(scope, name);
176
+ if (bound) {
177
+ return { ok: true, target: bound };
178
+ }
179
+ const sourceFile = ctx.index.get(file);
180
+ if (!sourceFile) {
181
+ return fail("unreadable", `Could not read ${file}.`);
182
+ }
183
+ const { api } = ctx;
184
+ for (const statement of sourceFile.statements) {
185
+ if (api.isVariableStatement(statement)) {
186
+ for (const declaration of statement.declarationList.declarations) {
187
+ if (api.isIdentifier(declaration.name) &&
188
+ declaration.name.text === name &&
189
+ declaration.initializer) {
190
+ return {
191
+ ok: true,
192
+ target: expr(file, declaration.initializer, undefined),
193
+ };
194
+ }
195
+ }
196
+ }
197
+ if (api.isFunctionDeclaration(statement) && statement.name?.text === name) {
198
+ return { ok: true, target: expr(file, statement, undefined) };
199
+ }
200
+ if (!api.isImportDeclaration(statement) ||
201
+ !statement.importClause ||
202
+ !api.isStringLiteral(statement.moduleSpecifier)) {
203
+ continue;
204
+ }
205
+ const { importClause } = statement;
206
+ const specifier = statement.moduleSpecifier.text;
207
+ const named = importClause.namedBindings;
208
+ if (named && api.isNamespaceImport(named) && named.name.text === name) {
209
+ return fail("not-a-literal", `"${name}" is a whole module (\`import * as ${name}\`) — asset barrels are not editable values.`);
210
+ }
211
+ if (named && api.isNamedImports(named)) {
212
+ for (const element of named.elements) {
213
+ if (element.name.text === name) {
214
+ const original = element.propertyName?.text ?? element.name.text;
215
+ const next = resolveModuleFile(specifier, file);
216
+ return next
217
+ ? exportTarget(ctx, next, original, depth + 1)
218
+ : fail("unsupported-expression", `Could not resolve "${specifier}" from ${path.basename(file)}.`);
219
+ }
220
+ }
221
+ }
222
+ if (importClause.name?.text === name) {
223
+ const next = resolveModuleFile(specifier, file);
224
+ return next
225
+ ? exportTarget(ctx, next, "default", depth + 1)
226
+ : fail("unsupported-expression", `Could not resolve "${specifier}" from ${path.basename(file)}.`);
227
+ }
228
+ }
229
+ return fail("unsupported-expression", `Could not find where "${name}" is declared.`);
230
+ }
231
+ /** The file an imported name comes from, without resolving what it binds to. */
232
+ function importSourceFor(ctx, sourceFile, name, importer) {
233
+ const { api } = ctx;
234
+ for (const statement of sourceFile.statements) {
235
+ if (!api.isImportDeclaration(statement) ||
236
+ !statement.importClause ||
237
+ !api.isStringLiteral(statement.moduleSpecifier)) {
238
+ continue;
239
+ }
240
+ const named = statement.importClause.namedBindings;
241
+ if (named && api.isNamedImports(named)) {
242
+ for (const element of named.elements) {
243
+ if (element.name.text === name) {
244
+ return resolveModuleFile(statement.moduleSpecifier.text, importer);
245
+ }
246
+ }
247
+ }
248
+ }
249
+ return undefined;
250
+ }
251
+ /** Which frame-kit authoring helper a call is, if any. Identity, not name. */
252
+ function kitHelper(ctx, file, call, scope, depth) {
253
+ const { api } = ctx;
254
+ if (!api.isIdentifier(call.expression)) {
255
+ return undefined;
256
+ }
257
+ const name = call.expression.text;
258
+ if (!KIT_HELPERS.has(name)) {
259
+ return undefined;
260
+ }
261
+ const declaration = resolveIdentifier(ctx, file, name, depth + 1, scope);
262
+ const declaredIn = declaration.ok && declaration.target.kind === "expr"
263
+ ? declaration.target.file
264
+ : undefined;
265
+ const sourceFile = ctx.index.get(file);
266
+ const importedFrom = sourceFile
267
+ ? importSourceFor(ctx, sourceFile, name, file)
268
+ : undefined;
269
+ if (packageNameFor(declaredIn ?? importedFrom ?? file) !==
270
+ "@cardanvil/frame-kit") {
271
+ return undefined;
272
+ }
273
+ if (name === "omit") {
274
+ return "omit";
275
+ }
276
+ return name === "withCollectorInfoDefaults" ? "collectorInfo" : "settings";
277
+ }
278
+ /** A callable's parameters and body, whether written as a declaration or an arrow. */
279
+ function callableOf(api, node) {
280
+ if (api.isFunctionDeclaration(node) ||
281
+ api.isFunctionExpression(node) ||
282
+ api.isArrowFunction(node)) {
283
+ return { parameters: node.parameters, body: node.body };
284
+ }
285
+ return undefined;
286
+ }
287
+ /**
288
+ * Evaluates a call to a function written in the author's own source.
289
+ *
290
+ * Frames shape box sets with small pure helpers, so refusing every call would
291
+ * make most of a frame like `frame-borderless` read-only. Only the shapes
292
+ * those helpers actually use are understood: an expression-bodied arrow, or a
293
+ * block whose statements are `const` bindings followed by a single `return`.
294
+ * Anything else — a branch, a loop, a reassignment — falls through to the
295
+ * `helper-call` refusal below, and the value-verification gate in
296
+ * `applyEdits` is the backstop if this is ever wrong.
297
+ */
298
+ function inlineCall(ctx, target, depth) {
299
+ const { api } = ctx;
300
+ const callee = deref(ctx, expr(target.file, target.node.expression, target.scope), depth + 1);
301
+ if (!callee.ok || callee.target.kind !== "expr") {
302
+ return undefined;
303
+ }
304
+ const callable = callableOf(api, callee.target.node);
305
+ if (!callable?.body) {
306
+ return undefined;
307
+ }
308
+ const calleeFile = callee.target.file;
309
+ const bindings = new Map();
310
+ callable.parameters.forEach((parameter, i) => {
311
+ if (!api.isIdentifier(parameter.name)) {
312
+ return;
313
+ }
314
+ const argument = target.node.arguments[i];
315
+ const bound = argument
316
+ ? expr(target.file, argument, target.scope)
317
+ : parameter.initializer
318
+ ? expr(calleeFile, parameter.initializer, undefined)
319
+ : undefined;
320
+ if (bound) {
321
+ bindings.set(parameter.name.text, bound);
322
+ }
323
+ });
324
+ let scope = { bindings, parent: undefined };
325
+ const { body } = callable;
326
+ if (!api.isBlock(body)) {
327
+ return deref(ctx, expr(calleeFile, body, scope), depth + 1);
328
+ }
329
+ for (const statement of body.statements) {
330
+ if (api.isVariableStatement(statement)) {
331
+ const locals = new Map();
332
+ for (const declaration of statement.declarationList.declarations) {
333
+ if (api.isIdentifier(declaration.name) && declaration.initializer) {
334
+ locals.set(declaration.name.text, expr(calleeFile, declaration.initializer, scope));
335
+ }
336
+ }
337
+ scope = { bindings: locals, parent: scope };
338
+ continue;
339
+ }
340
+ if (api.isReturnStatement(statement)) {
341
+ return statement.expression
342
+ ? deref(ctx, expr(calleeFile, statement.expression, scope), depth + 1)
343
+ : undefined;
344
+ }
345
+ return undefined; // a branch, a loop, something with control flow
346
+ }
347
+ return undefined;
348
+ }
349
+ /**
350
+ * Reduces a target to the thing it actually is: follows identifiers, property
351
+ * accesses and re-exports, recognises frame-kit's helpers, and inlines calls
352
+ * to the author's own shaping functions.
353
+ */
354
+ function deref(ctx, target, depth) {
355
+ if (depth > MAX_DEPTH) {
356
+ return fail("unsupported-expression", "Gave up resolving — the references may be cyclic.");
357
+ }
358
+ if (target.kind !== "expr") {
359
+ return { ok: true, target };
360
+ }
361
+ const { api } = ctx;
362
+ const node = unwrapExpr(api, target.node);
363
+ const { file, scope } = target;
364
+ if (api.isIdentifier(node)) {
365
+ const next = resolveIdentifier(ctx, file, node.text, depth + 1, scope);
366
+ return next.ok ? deref(ctx, next.target, depth + 1) : next;
367
+ }
368
+ if (api.isPropertyAccessExpression(node) ||
369
+ api.isElementAccessExpression(node)) {
370
+ const key = api.isPropertyAccessExpression(node)
371
+ ? node.name.text
372
+ : api.isStringLiteral(node.argumentExpression) ||
373
+ api.isNumericLiteral(node.argumentExpression)
374
+ ? node.argumentExpression.text
375
+ : undefined;
376
+ if (key === undefined) {
377
+ return fail("computed", "This value is read with a computed key.");
378
+ }
379
+ const object = deref(ctx, expr(file, node.expression, scope), depth + 1);
380
+ if (!object.ok) {
381
+ return object;
382
+ }
383
+ const inner = descend(ctx, object.target, key, depth + 1);
384
+ return inner.ok ? deref(ctx, inner.target, depth + 1) : inner;
385
+ }
386
+ if (api.isCallExpression(node)) {
387
+ const helper = kitHelper(ctx, file, node, scope, depth);
388
+ const [first, second] = node.arguments;
389
+ if (helper === "omit" && first && second) {
390
+ const list = unwrapExpr(api, second);
391
+ if (api.isArrayLiteralExpression(list)) {
392
+ const removed = new Set();
393
+ for (const element of list.elements) {
394
+ const key = unwrapExpr(api, element);
395
+ if (api.isStringLiteral(key) ||
396
+ api.isNoSubstitutionTemplateLiteral(key)) {
397
+ removed.add(key.text);
398
+ }
399
+ }
400
+ return {
401
+ ok: true,
402
+ target: {
403
+ kind: "omitResult",
404
+ source: expr(file, first, scope),
405
+ removed,
406
+ },
407
+ };
408
+ }
409
+ }
410
+ if (helper && helper !== "omit" && first && second) {
411
+ return {
412
+ ok: true,
413
+ target: {
414
+ kind: "helperConfig",
415
+ helper,
416
+ config: expr(file, first, scope),
417
+ defaults: expr(file, second, scope),
418
+ },
419
+ };
420
+ }
421
+ const inlined = inlineCall(ctx, { file, node, scope }, depth);
422
+ if (inlined) {
423
+ return inlined;
424
+ }
425
+ const name = api.isIdentifier(node.expression)
426
+ ? node.expression.text
427
+ : api.isPropertyAccessExpression(node.expression)
428
+ ? node.expression.name.text
429
+ : "a function";
430
+ const sourceFile = ctx.index.get(file);
431
+ return fail("helper-call", `This value is produced by \`${name}(…)\`, which the studio cannot follow.`, sourceFile ? locationOf(api, sourceFile, node) : undefined);
432
+ }
433
+ const isNegativeNumber = api.isPrefixUnaryExpression(node) &&
434
+ node.operator === api.SyntaxKind.MinusToken &&
435
+ api.isNumericLiteral(node.operand);
436
+ if (api.isConditionalExpression(node) ||
437
+ api.isBinaryExpression(node) ||
438
+ api.isTemplateExpression(node) ||
439
+ (api.isPrefixUnaryExpression(node) && !isNegativeNumber)) {
440
+ const sourceFile = ctx.index.get(file);
441
+ return fail("computed", "This value is computed from other values; edit it in the source.", sourceFile ? locationOf(api, sourceFile, node) : undefined);
442
+ }
443
+ return { ok: true, target: expr(file, node, scope) };
444
+ }
445
+ /** One path segment: the value of `key` inside `target`. */
446
+ function descend(ctx, target, key, depth) {
447
+ if (depth > MAX_DEPTH) {
448
+ return fail("unsupported-expression", "Gave up resolving — the structure may be cyclic.");
449
+ }
450
+ const resolved = deref(ctx, target, depth);
451
+ if (!resolved.ok) {
452
+ return resolved;
453
+ }
454
+ const current = resolved.target;
455
+ const { api } = ctx;
456
+ if (current.kind === "helperConfig") {
457
+ if (current.helper === "collectorInfo" && COLLECTOR_BOX_SETS.has(key)) {
458
+ const base = descend(ctx, current.config, key, depth + 1);
459
+ if (!base.ok) {
460
+ return base; // the box set itself is absent; the helper leaves it so
461
+ }
462
+ return {
463
+ ok: true,
464
+ target: {
465
+ kind: "helperBoxSet",
466
+ base: base.target,
467
+ defaults: current.defaults,
468
+ },
469
+ };
470
+ }
471
+ if (current.helper === "settings" && key === "templateSettings") {
472
+ return fail("computed", "`templateSettings` here merges the layout's own settings with the defaults.", targetLocation(ctx, current.defaults));
473
+ }
474
+ return descend(ctx, current.config, key, depth + 1);
475
+ }
476
+ if (current.kind === "omitResult") {
477
+ if (current.removed.has(key)) {
478
+ return fail("missing-property", `"${key}" is removed here by \`omit(…)\`.`, targetLocation(ctx, current.source));
479
+ }
480
+ return descend(ctx, current.source, key, depth + 1);
481
+ }
482
+ if (current.kind === "helperBoxSet") {
483
+ const own = descend(ctx, current.base, key, depth + 1);
484
+ if (own.ok || key !== "collectorInfo") {
485
+ return own;
486
+ }
487
+ // The box set has no collectorInfo of its own, so the helper's default is
488
+ // the value — and every box set that omits it reads the same literal.
489
+ return { ok: true, target: current.defaults, shared: true };
490
+ }
491
+ const node = unwrapExpr(api, current.node);
492
+ if (!api.isObjectLiteralExpression(node)) {
493
+ const sourceFile = ctx.index.get(current.file);
494
+ return fail("not-a-literal", `Expected an object to read "${key}" from, but found ${api.SyntaxKind[node.kind]}.`, sourceFile ? locationOf(api, sourceFile, node) : undefined);
495
+ }
496
+ // Source order decides: a later spread overrides an earlier own property,
497
+ // and an own property overrides an earlier spread.
498
+ let winner;
499
+ for (const property of node.properties) {
500
+ if (api.isPropertyAssignment(property) &&
501
+ propertyName(api, property.name) === key) {
502
+ winner = {
503
+ ok: true,
504
+ target: expr(current.file, property.initializer, current.scope),
505
+ };
506
+ }
507
+ else if (api.isShorthandPropertyAssignment(property) &&
508
+ property.name.text === key) {
509
+ winner = {
510
+ ok: true,
511
+ target: expr(current.file, property.name, current.scope),
512
+ };
513
+ }
514
+ else if (api.isSpreadAssignment(property)) {
515
+ const inner = descend(ctx, expr(current.file, property.expression, current.scope), key, depth + 1);
516
+ if (inner.ok) {
517
+ winner = inner;
518
+ }
519
+ }
520
+ }
521
+ if (!winner) {
522
+ const sourceFile = ctx.index.get(current.file);
523
+ return fail("missing-property", `"${key}" is not written here.`, sourceFile ? locationOf(api, sourceFile, node) : undefined);
524
+ }
525
+ return winner;
526
+ }
527
+ /**
528
+ * Where the value at `framePath` is written in source, if it is written as a
529
+ * literal at all.
530
+ *
531
+ * Returns the exact text range so a caller can splice a new value in without
532
+ * reprinting anything — comments, formatting and import order survive because
533
+ * nothing else is touched.
534
+ */
535
+ export function resolveFramePath(ctx, entryFile, exportName, framePath) {
536
+ let step = exportTarget(ctx, normalizeFile(entryFile), exportName, 0);
537
+ let shared = false;
538
+ for (const segment of framePath) {
539
+ if (!step.ok) {
540
+ return step.problem;
541
+ }
542
+ step = descend(ctx, step.target, String(segment), 0);
543
+ shared ||= step.ok && step.shared === true;
544
+ }
545
+ if (!step.ok) {
546
+ return step.problem;
547
+ }
548
+ const final = deref(ctx, step.target, 0);
549
+ if (!final.ok) {
550
+ return final.problem;
551
+ }
552
+ if (final.target.kind !== "expr") {
553
+ return {
554
+ editable: false,
555
+ reason: "not-a-literal",
556
+ detail: `${formatPath(framePath)} resolves to a value a helper builds, not a literal.`,
557
+ };
558
+ }
559
+ const { api } = ctx;
560
+ const sourceFile = ctx.index.get(final.target.file);
561
+ if (!sourceFile) {
562
+ return {
563
+ editable: false,
564
+ reason: "unreadable",
565
+ detail: `Could not read ${final.target.file}.`,
566
+ };
567
+ }
568
+ const node = unwrapExpr(api, final.target.node);
569
+ const isNegativeNumber = api.isPrefixUnaryExpression(node) &&
570
+ node.operator === api.SyntaxKind.MinusToken &&
571
+ api.isNumericLiteral(node.operand);
572
+ const isLiteral = api.isNumericLiteral(node) ||
573
+ api.isStringLiteral(node) ||
574
+ api.isNoSubstitutionTemplateLiteral(node) ||
575
+ node.kind === api.SyntaxKind.TrueKeyword ||
576
+ node.kind === api.SyntaxKind.FalseKeyword ||
577
+ isNegativeNumber;
578
+ const location = locationOf(api, sourceFile, node);
579
+ if (!isLiteral) {
580
+ return {
581
+ editable: false,
582
+ reason: "not-a-literal",
583
+ detail: `${formatPath(framePath)} is written as ${api.SyntaxKind[node.kind]}, not a literal.`,
584
+ source: location,
585
+ };
586
+ }
587
+ if (!isUnderRoot(final.target.file, ctx.root)) {
588
+ return {
589
+ editable: false,
590
+ reason: "outside-root",
591
+ detail: `This value lives in ${path.basename(final.target.file)}, outside the project the studio was pointed at.`,
592
+ source: location,
593
+ };
594
+ }
595
+ const start = node.getStart(sourceFile);
596
+ const end = node.getEnd();
597
+ return {
598
+ editable: true,
599
+ file: normalizeFile(final.target.file),
600
+ start,
601
+ end,
602
+ text: sourceFile.text.slice(start, end),
603
+ location,
604
+ sharedDefault: shared,
605
+ };
606
+ }
@@ -0,0 +1,29 @@
1
+ import type ts from "typescript";
2
+ import type { TypeScriptApi } from "./ts.js";
3
+ /**
4
+ * Path in the one spelling everything else compares against.
5
+ *
6
+ * Windows hands the same file back with either separator and either
7
+ * drive-letter case depending on who produced the path — Vite's module graph,
8
+ * `path.resolve`, or an author's `frame.meta.json`. Every map key, root
9
+ * confinement check and self-write lookup goes through here.
10
+ */
11
+ export declare function normalizeFile(file: string): string;
12
+ /** Whether `file` is inside `root` — not merely prefixed by its name. */
13
+ export declare function isUnderRoot(file: string, root: string): boolean;
14
+ export interface SourceIndex {
15
+ /** Parsed file, or `undefined` if it cannot be read. */
16
+ get: (file: string) => ts.SourceFile | undefined;
17
+ /** Current text of `file`, from the same cached read as `get`. */
18
+ text: (file: string) => string | undefined;
19
+ /** Drops a file so the next `get` re-reads it. */
20
+ invalidate: (file: string) => void;
21
+ }
22
+ /**
23
+ * Parses source files on demand and keeps them until they change on disk.
24
+ *
25
+ * Keyed on `(mtimeMs, size)` rather than a manual flush, so the studio's own
26
+ * writes invalidate themselves and an edit made in the author's editor is
27
+ * picked up without coordination.
28
+ */
29
+ export declare function createSourceIndex(api: TypeScriptApi): SourceIndex;