@toghen/figma 0.0.0-stage → 0.1.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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/cli/bin.mjs +19 -0
  4. package/cli/bridge.ts +243 -0
  5. package/cli/check.ts +62 -0
  6. package/cli/figcraft.ts +764 -0
  7. package/cli/figcraftApply.ts +1058 -0
  8. package/cli/figcraftFile.ts +476 -0
  9. package/cli/figcraftLibrary.ts +147 -0
  10. package/cli/figcraftRecord.ts +121 -0
  11. package/cli/figcraftScene.ts +661 -0
  12. package/cli/main.ts +218 -0
  13. package/cli/members.ts +65 -0
  14. package/cli/run.ts +756 -0
  15. package/component-files.md +665 -0
  16. package/dist/code.js +24147 -0
  17. package/dist/manifest.json +23 -0
  18. package/dist/ui.html +6918 -0
  19. package/guide.md +985 -0
  20. package/main/code.ts +50 -0
  21. package/main/components/apply.ts +704 -0
  22. package/main/components/endOfRun.ts +168 -0
  23. package/main/components/errors.ts +17 -0
  24. package/main/components/fieldWriter.ts +497 -0
  25. package/main/components/file.ts +263 -0
  26. package/main/components/receipt.ts +228 -0
  27. package/main/components/resolve.ts +1193 -0
  28. package/main/components/run.ts +273 -0
  29. package/main/components/snapshot.ts +341 -0
  30. package/main/components/stamp.ts +312 -0
  31. package/main/components/update.ts +465 -0
  32. package/main/dispatcher.ts +33 -0
  33. package/main/engines/batchRunner.ts +200 -0
  34. package/main/engines/ioAdapter.ts +132 -0
  35. package/main/engines/operationExecutor.ts +512 -0
  36. package/main/engines/propertyMapper.ts +221 -0
  37. package/main/engines/queryEngine.ts +45 -0
  38. package/main/engines/styleSyncEngine.ts +444 -0
  39. package/main/engines/variableSyncEngine.ts +561 -0
  40. package/main/handlers/auditSelection.ts +60 -0
  41. package/main/handlers/boundLayers.ts +51 -0
  42. package/main/handlers/cancelOperation.ts +18 -0
  43. package/main/handlers/executeApplyBindings.ts +69 -0
  44. package/main/handlers/executeScaffold.ts +59 -0
  45. package/main/handlers/executeStyleSync.ts +121 -0
  46. package/main/handlers/executeVariableSync.ts +82 -0
  47. package/main/handlers/exportDocument.ts +114 -0
  48. package/main/handlers/getSelection.ts +43 -0
  49. package/main/handlers/index.ts +30 -0
  50. package/main/handlers/inspectCorrespondence.ts +172 -0
  51. package/main/handlers/loadConventions.ts +86 -0
  52. package/main/handlers/loadDocument.ts +253 -0
  53. package/main/handlers/lock.ts +270 -0
  54. package/main/handlers/planApply.ts +157 -0
  55. package/main/handlers/planApplyBindings.ts +79 -0
  56. package/main/handlers/planScaffold.ts +84 -0
  57. package/main/handlers/planStyleSync.ts +49 -0
  58. package/main/handlers/planVariableSync.ts +49 -0
  59. package/main/handlers/readSelectionBindings.ts +160 -0
  60. package/main/handlers/resizeUi.ts +105 -0
  61. package/main/handlers/runComponent.ts +65 -0
  62. package/main/handlers/templates.ts +462 -0
  63. package/main/handlers/tokenGraph.ts +122 -0
  64. package/main/runtimeState.ts +66 -0
  65. package/main/utils/abortController.ts +11 -0
  66. package/main/utils/boundVariables.ts +84 -0
  67. package/main/utils/buildPayloads.ts +85 -0
  68. package/main/utils/correspondence.ts +658 -0
  69. package/main/utils/designSource.ts +122 -0
  70. package/main/utils/figmaCollectionIndex.ts +120 -0
  71. package/main/utils/figmaStyleAdapter.ts +268 -0
  72. package/main/utils/figmaTokenAdapter.ts +255 -0
  73. package/main/utils/flattenDesignSystem.ts +38 -0
  74. package/main/utils/folds.ts +203 -0
  75. package/main/utils/fontStack.ts +57 -0
  76. package/main/utils/lists.ts +4 -0
  77. package/main/utils/lockStore.ts +72 -0
  78. package/main/utils/moves.ts +67 -0
  79. package/main/utils/pageRoots.ts +58 -0
  80. package/main/utils/planSymbols.ts +96 -0
  81. package/main/utils/purpose.ts +351 -0
  82. package/main/utils/recentStore.ts +166 -0
  83. package/main/utils/resolverModes.ts +271 -0
  84. package/main/utils/rootRecord.ts +137 -0
  85. package/main/utils/shardedData.ts +142 -0
  86. package/main/utils/standing.ts +106 -0
  87. package/main/utils/styleTarget.ts +51 -0
  88. package/main/utils/sum.ts +612 -0
  89. package/main/utils/symbolicIdRegistry.ts +26 -0
  90. package/main/utils/syncState.ts +133 -0
  91. package/main/utils/templateStore.ts +73 -0
  92. package/main/utils/tokenPath.ts +28 -0
  93. package/main/utils/tokenStamps.ts +91 -0
  94. package/manifest.json +15 -0
  95. package/package.json +64 -4
  96. package/reference.md +1609 -0
  97. package/shared/components/artboard.ts +126 -0
  98. package/shared/components/axes.ts +420 -0
  99. package/shared/components/bindings.ts +64 -0
  100. package/shared/components/blueprint.ts +248 -0
  101. package/shared/components/check.ts +705 -0
  102. package/shared/components/diff.ts +668 -0
  103. package/shared/components/fields.ts +404 -0
  104. package/shared/components/groups.ts +306 -0
  105. package/shared/components/index.ts +140 -0
  106. package/shared/components/members.ts +280 -0
  107. package/shared/components/model.ts +273 -0
  108. package/shared/components/order.ts +53 -0
  109. package/shared/components/read.ts +480 -0
  110. package/shared/components/stack.ts +124 -0
  111. package/shared/components/steps.ts +186 -0
  112. package/shared/components/validate.ts +548 -0
  113. package/shared/conventions.ts +28 -0
  114. package/shared/inputs.ts +40 -0
  115. package/shared/operations.ts +233 -0
  116. package/shared/pendingNames.ts +39 -0
  117. package/shared/protocol.ts +588 -0
  118. package/shared/tokenTypeMap.ts +21 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * One JSON value kept in the Figma file's shared plugin data, split across
3
+ * numbered shards with an index saying how many there are.
4
+ *
5
+ * ```
6
+ * toghen/<key> {"v":1,"shards":2}
7
+ * toghen/<key>.0 {"first part…
8
+ * toghen/<key>.1 …the rest"}
9
+ * ```
10
+ *
11
+ * Figma caps one entry at 100 kB counting namespace and key, so anything that
12
+ * can grow is sharded. The payload is escaped to pure ASCII first, so its
13
+ * character count is its byte count and no surrogate pair is split across a
14
+ * shard boundary. The index is cleared first, the shards written, then the
15
+ * index: a write interrupted part-way leaves no index, which reads as nothing
16
+ * stored, never as a mixture of old and new shards.
17
+ *
18
+ * The kept templates (`templateStore.ts`) and the lock (`lockStore.ts`) are kept this way. So was the sync
19
+ * record of older builds, which `rootRecord.ts` reads once and deletes.
20
+ */
21
+
22
+ /** Figma requires a namespace of at least three alphanumeric characters. */
23
+ export const NAMESPACE = 'toghen';
24
+
25
+ /** Figma's documented cap on one entry, counting namespace + key + value. */
26
+ const ENTRY_LIMIT_BYTES = 100 * 1024;
27
+ /** Generous allowance for the namespace plus the longest shard key. */
28
+ const KEY_OVERHEAD_BYTES = 64;
29
+ export const SHARD_CAPACITY = ENTRY_LIMIT_BYTES - KEY_OVERHEAD_BYTES;
30
+
31
+ const FORMAT_VERSION = 1;
32
+
33
+ export interface ShardedWrite {
34
+ ok: boolean;
35
+ /** Set when the payload needed more shards than allowed; nothing was kept. */
36
+ tooBig?: boolean;
37
+ /** Set when Figma refused a write. */
38
+ reason?: string;
39
+ bytes: number;
40
+ shards: number;
41
+ }
42
+
43
+ /**
44
+ * The stored payload, or `''` when there is none.
45
+ *
46
+ * Throws only when the index itself is not JSON; a missing index, a future
47
+ * version, or a count that is not a whole number read as nothing stored.
48
+ */
49
+ export function readSharded(key: string): string {
50
+ const index = read(key);
51
+ if (!index) return '';
52
+
53
+ const parsed: unknown = JSON.parse(index);
54
+ if (!isPlainObject(parsed)) return '';
55
+ if (parsed.v !== FORMAT_VERSION) return '';
56
+
57
+ const count = parsed.shards;
58
+ if (typeof count !== 'number' || !Number.isInteger(count) || count < 0) return '';
59
+
60
+ let payload = '';
61
+ for (let i = 0; i < count; i += 1) {
62
+ payload += read(`${key}.${i}`);
63
+ }
64
+ return payload;
65
+ }
66
+
67
+ /**
68
+ * Writes `payload` (already ASCII, see `toAsciiJson`) under `key`. Returns rather
69
+ * than throws: a caller that has just written to Figma must not have that work
70
+ * reported as failed because its record could not be kept.
71
+ */
72
+ export function writeSharded(key: string, payload: string, maxShards: number): ShardedWrite {
73
+ const shards = chunk(payload, SHARD_CAPACITY);
74
+ try {
75
+ // Clearing the index first is the commit protocol: see the note at the top.
76
+ write(key, '');
77
+
78
+ if (shards.length > maxShards) {
79
+ clearShardsFrom(key, 0);
80
+ return { ok: false, tooBig: true, bytes: payload.length, shards: 0 };
81
+ }
82
+
83
+ shards.forEach((shard, index) => {
84
+ write(`${key}.${index}`, shard);
85
+ });
86
+ clearShardsFrom(key, shards.length);
87
+ write(key, toAsciiJson({ v: FORMAT_VERSION, shards: shards.length }));
88
+ return { ok: true, bytes: payload.length, shards: shards.length };
89
+ } catch (error) {
90
+ return { ok: false, bytes: payload.length, shards: 0, reason: error instanceof Error ? error.message : String(error) };
91
+ }
92
+ }
93
+
94
+ /** Removes the value kept under `key`: the index first, then every shard. */
95
+ export function clearSharded(key: string): void {
96
+ write(key, '');
97
+ clearShardsFrom(key, 0);
98
+ }
99
+
100
+ /**
101
+ * Serializes to JSON containing only ASCII. `JSON.stringify` already escapes
102
+ * control characters, so what remains above U+007F is escaped here; the result
103
+ * parses back identically, astral characters included.
104
+ */
105
+ export function toAsciiJson(value: unknown): string {
106
+ return JSON.stringify(value).replace(/[\u0080-￿]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
107
+ }
108
+
109
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
110
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
111
+ }
112
+
113
+ function read(key: string): string {
114
+ return figma.root.getSharedPluginData(NAMESPACE, key);
115
+ }
116
+
117
+ /** Figma removes the key when the value is the empty string. */
118
+ function write(key: string, value: string): void {
119
+ figma.root.setSharedPluginData(NAMESPACE, key, value);
120
+ }
121
+
122
+ /**
123
+ * Removes every shard of `key` from `first` upward. A value that shrinks leaves
124
+ * shards behind; enumerating the namespace rather than trusting the previous
125
+ * index also cleans up after an interrupted write.
126
+ */
127
+ function clearShardsFrom(key: string, first: number): void {
128
+ const prefix = `${key}.`;
129
+ for (const stored of figma.root.getSharedPluginDataKeys(NAMESPACE)) {
130
+ if (!stored.startsWith(prefix)) continue;
131
+ const rest = stored.slice(prefix.length);
132
+ if (/^\d+$/.test(rest) && Number(rest) >= first) write(stored, '');
133
+ }
134
+ }
135
+
136
+ function chunk(text: string, size: number): string[] {
137
+ const chunks: string[] = [];
138
+ for (let i = 0; i < text.length; i += size) {
139
+ chunks.push(text.slice(i, i + size));
140
+ }
141
+ return chunks;
142
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Where each collection of the sum stands in the Figma file.
3
+ *
4
+ * The plan and the Inspector both ask it, so a collection's id, the id of each of
5
+ * its modes and whether it may be written to are decided once.
6
+ *
7
+ * ### Collections built by an earlier sync
8
+ *
9
+ * The earlier sync built the product: one collection named for the document, a
10
+ * mode per combination of every modifier's contexts. So only the base collection,
11
+ * the one with the document's name, can have been built by it: when it holds any
12
+ * mode other than `default` it is reported as built by an earlier sync and left
13
+ * alone, and the run stops before its first write. A collection named after one
14
+ * of the document's modifiers is the sum's own, whatever modes it holds.
15
+ *
16
+ * ### A modifier's collection that no longer matches its modifier
17
+ *
18
+ * - A mode the modifier no longer has (`goneModes`) is a mode to delete, which
19
+ * the plan lists as breaking and deletes only when it is allowed to.
20
+ * - Figma's default mode is the first one and is fixed at creation. When the
21
+ * modifier no longer has that context (`defaultGone`), the collection can
22
+ * never match the document again: it is reported and nothing is written to it.
23
+ *
24
+ * Moving a file from the product to the sum is a migration, designed when a
25
+ * consumer needs one.
26
+ */
27
+
28
+ import type { FigmaCollectionIndex, IndexedCollection, IndexedMode } from './figmaCollectionIndex.js';
29
+ import type { Sum, SumCollection } from './sum.js';
30
+
31
+ export function symbolicCollectionId(collectionName: string): string {
32
+ return `$col:${collectionName}`;
33
+ }
34
+
35
+ export function symbolicModeId(collectionName: string, modeName: string): string {
36
+ return `$mode:${collectionName}/${modeName}`;
37
+ }
38
+
39
+ export interface CollectionStanding {
40
+ spec: SumCollection;
41
+ /** The collection of that name in the file, when there is one. */
42
+ existing?: IndexedCollection;
43
+ /**
44
+ * Base collection only: the modes it holds besides `default`. Set means built
45
+ * by an earlier sync: it is reported and never written to.
46
+ */
47
+ strangeModes: string[];
48
+ /** A modifier's collection: the modes it holds that the modifier no longer has. */
49
+ goneModes: IndexedMode[];
50
+ /** A modifier's collection: Figma's default mode, when the modifier no longer has that context. */
51
+ defaultGone?: string;
52
+ /** The real id, or the symbolic one of the collection this plan creates. */
53
+ id: string;
54
+ /** The real mode id, or the symbolic one of a mode this plan creates. */
55
+ modeId(mode: string): string;
56
+ }
57
+
58
+ export function standingOf(sum: Sum, figmaIndex: FigmaCollectionIndex): Map<string, CollectionStanding> {
59
+ const standing = new Map<string, CollectionStanding>();
60
+ for (const spec of sum.collections) {
61
+ const existing = figmaIndex.byName[spec.name];
62
+ const isBase = spec.modifier === undefined && !spec.axis;
63
+ const unknown = existing ? existing.modes.filter((mode) => !spec.modes.includes(mode.name)) : [];
64
+ const figmaDefault = existing?.modes[0]?.name;
65
+ standing.set(spec.name, {
66
+ spec,
67
+ ...(existing ? { existing } : {}),
68
+ strangeModes: isBase ? unknown.map((mode) => mode.name) : [],
69
+ goneModes: isBase ? [] : unknown,
70
+ ...(!isBase && figmaDefault !== undefined && !spec.modes.includes(figmaDefault) ? { defaultGone: figmaDefault } : {}),
71
+ id: existing?.id ?? symbolicCollectionId(spec.name),
72
+ modeId: (mode) => existing?.modes.find((m) => m.name === mode)?.modeId ?? symbolicModeId(spec.name, mode),
73
+ });
74
+ }
75
+ return standing;
76
+ }
77
+
78
+ /** Whether the base collection exists and was built by an earlier sync. */
79
+ export const builtEarlier = (standing: CollectionStanding): boolean => standing.strangeModes.length > 0;
80
+
81
+ /**
82
+ * Why nothing may be written to the collection, when that is so: the short reason a skipped token
83
+ * carries, and the sentence that says it in full.
84
+ */
85
+ export function closedBecause(standing: CollectionStanding): { skip: string; message: string } | undefined {
86
+ const name = standing.spec.name;
87
+ if (builtEarlier(standing)) {
88
+ return {
89
+ skip: 'built by an earlier sync',
90
+ message:
91
+ `Collection "${name}" was built by an earlier sync: it holds mode(s) ` +
92
+ `${standing.strangeModes.join(', ')} besides "default". The base collection takes that name, so ` +
93
+ `nothing is written to it. Rename or delete the old collection, or sync under another collection name.`,
94
+ };
95
+ }
96
+ if (standing.defaultGone !== undefined) {
97
+ return {
98
+ skip: 'default mode removed',
99
+ message:
100
+ `Collection "${name}" has "${standing.defaultGone}" as its default mode, which the modifier no longer ` +
101
+ `has. Figma fixes a collection's default mode when the collection is created and cannot reassign it, so ` +
102
+ `nothing is written to this collection; recreating it is a migration.`,
103
+ };
104
+ }
105
+ return undefined;
106
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The token a style name is spelled from, when a file names a style through references.
3
+ *
4
+ * `effectStyle="{kbd.boxShadow}"` where `kbd.boxShadow` is only `{elevation.raised}` binds the style the target
5
+ * becomes, as a reference to a token Figma holds is an alias of it. The chain of whole-value references is followed
6
+ * to the first token that is not one, in the default context (styles have no modes). If that token is not a style of
7
+ * the kind asked for, or the path is not in the document, the path asked about is returned unchanged and names what
8
+ * it named before.
9
+ */
10
+
11
+ import type { Resolver } from '@toghen/lib';
12
+ import type { TokenTypeConversionMap } from '../../shared/tokenTypeMap.js';
13
+ import { resolveSumModes, snapshotModes, type SnapshotToken } from './resolverModes.js';
14
+ import { referencePath } from '@toghen/lib';
15
+
16
+ export function styleTargetOf(
17
+ resolver: Resolver | null | undefined,
18
+ conversionMap: TokenTypeConversionMap,
19
+ ): (path: string, styleType: 'TEXT' | 'EFFECT') => string {
20
+ let read: { tokens: Map<string, SnapshotToken>; prefix: string } | null | undefined;
21
+ const tokens = () => {
22
+ if (read === undefined) {
23
+ read = null;
24
+ if (resolver) {
25
+ try {
26
+ const resolution = resolveSumModes(resolver);
27
+ const snapshot = snapshotModes(resolution).find((s) => s.modeName === resolution.defaultMode);
28
+ if (snapshot) read = { tokens: snapshot.pathToToken, prefix: `${resolution.dsCollectionName}.` };
29
+ } catch {
30
+ // a document the sum cannot be built from names no style through a reference
31
+ }
32
+ }
33
+ }
34
+ return read;
35
+ };
36
+
37
+ return (path, styleType) => {
38
+ const found = tokens();
39
+ if (!found) return path;
40
+ const seen = new Set<string>();
41
+ for (let at = path; !seen.has(at); ) {
42
+ seen.add(at);
43
+ const token = found.tokens.get(`${found.prefix}${at}`);
44
+ if (!token) return path;
45
+ if (!token.aliasOf)
46
+ return conversionMap[token.type]?.target === 'style' && conversionMap[token.type]?.styleType === styleType ? at : path;
47
+ at = referencePath(token.aliasOf);
48
+ }
49
+ return path;
50
+ };
51
+ }