@sun-asterisk/sungen 2.4.6 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -7
- package/dist/cli/commands/add.d.ts.map +1 -1
- package/dist/cli/commands/add.js +109 -9
- package/dist/cli/commands/add.js.map +1 -1
- package/dist/cli/commands/figma.d.ts +11 -0
- package/dist/cli/commands/figma.d.ts.map +1 -0
- package/dist/cli/commands/figma.js +178 -0
- package/dist/cli/commands/figma.js.map +1 -0
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +2 -0
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/index.js +4 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/generators/gherkin-parser/index.d.ts +1 -0
- package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
- package/dist/generators/gherkin-parser/index.js +3 -0
- package/dist/generators/gherkin-parser/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts +29 -1
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +21 -1
- package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js +11 -2
- package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/after-all.hbs +8 -0
- package/dist/generators/test-generator/adapters/playwright/templates/after-each.hbs +8 -0
- package/dist/generators/test-generator/adapters/playwright/templates/before-all.hbs +8 -0
- package/dist/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
- package/dist/generators/test-generator/adapters/playwright/templates/test-file.hbs +24 -0
- package/dist/generators/test-generator/code-generator.d.ts +2 -0
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +109 -12
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/step-mapper.d.ts +1 -0
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +1 -1
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/generators/test-generator/template-engine.d.ts +29 -1
- package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
- package/dist/generators/test-generator/template-engine.js +11 -2
- package/dist/generators/test-generator/template-engine.js.map +1 -1
- package/dist/generators/test-generator/utils/data-resolver.d.ts +11 -2
- package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/data-resolver.js +36 -25
- package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
- package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +7 -0
- package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -0
- package/dist/generators/test-generator/utils/runtime-data-transformer.js +42 -0
- package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -0
- package/dist/generators/types.d.ts +1 -0
- package/dist/generators/types.d.ts.map +1 -1
- package/dist/generators/types.js.map +1 -1
- package/dist/orchestrator/ai-rules-updater.d.ts.map +1 -1
- package/dist/orchestrator/ai-rules-updater.js +2 -0
- package/dist/orchestrator/ai-rules-updater.js.map +1 -1
- package/dist/orchestrator/figma/figma-scaffolder-helpers.d.ts +33 -0
- package/dist/orchestrator/figma/figma-scaffolder-helpers.d.ts.map +1 -0
- package/dist/orchestrator/figma/figma-scaffolder-helpers.js +135 -0
- package/dist/orchestrator/figma/figma-scaffolder-helpers.js.map +1 -0
- package/dist/orchestrator/figma/figma-scaffolder-types.d.ts +25 -0
- package/dist/orchestrator/figma/figma-scaffolder-types.d.ts.map +1 -0
- package/dist/orchestrator/figma/figma-scaffolder-types.js +7 -0
- package/dist/orchestrator/figma/figma-scaffolder-types.js.map +1 -0
- package/dist/orchestrator/figma/figma-scaffolder.d.ts +23 -0
- package/dist/orchestrator/figma/figma-scaffolder.d.ts.map +1 -0
- package/dist/orchestrator/figma/figma-scaffolder.js +212 -0
- package/dist/orchestrator/figma/figma-scaffolder.js.map +1 -0
- package/dist/orchestrator/figma/node-path-collapser.d.ts +16 -0
- package/dist/orchestrator/figma/node-path-collapser.d.ts.map +1 -0
- package/dist/orchestrator/figma/node-path-collapser.js +37 -0
- package/dist/orchestrator/figma/node-path-collapser.js.map +1 -0
- package/dist/orchestrator/figma/spec-figma-renderer.d.ts +44 -0
- package/dist/orchestrator/figma/spec-figma-renderer.d.ts.map +1 -0
- package/dist/orchestrator/figma/spec-figma-renderer.js +45 -0
- package/dist/orchestrator/figma/spec-figma-renderer.js.map +1 -0
- package/dist/orchestrator/figma/spec-figma-section-renderers.d.ts +23 -0
- package/dist/orchestrator/figma/spec-figma-section-renderers.d.ts.map +1 -0
- package/dist/orchestrator/figma/spec-figma-section-renderers.js +47 -0
- package/dist/orchestrator/figma/spec-figma-section-renderers.js.map +1 -0
- package/dist/orchestrator/project-initializer.d.ts +9 -0
- package/dist/orchestrator/project-initializer.d.ts.map +1 -1
- package/dist/orchestrator/project-initializer.js +74 -10
- package/dist/orchestrator/project-initializer.js.map +1 -1
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +56 -11
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +30 -17
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-review.md +4 -3
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +34 -2
- package/dist/orchestrator/templates/ai-instructions/claude-config.md +12 -2
- package/dist/orchestrator/templates/ai-instructions/claude-skill-figma-source.md +151 -0
- package/dist/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +66 -13
- package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +93 -23
- package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-review.md +2 -0
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +53 -9
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +21 -16
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-review.md +4 -3
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +34 -2
- package/dist/orchestrator/templates/ai-instructions/copilot-config.md +12 -2
- package/dist/orchestrator/templates/ai-instructions/copilot-skill-figma-source.md +151 -0
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-figma-source.md +151 -0
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +86 -13
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-selector-fix.md +61 -0
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +105 -28
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-review.md +20 -0
- package/dist/orchestrator/templates/specs-base.d.ts +12 -1
- package/dist/orchestrator/templates/specs-base.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-base.js +47 -5
- package/dist/orchestrator/templates/specs-base.js.map +1 -1
- package/dist/orchestrator/templates/specs-base.ts +65 -7
- package/dist/orchestrator/templates/specs-test-data.d.ts +14 -0
- package/dist/orchestrator/templates/specs-test-data.d.ts.map +1 -0
- package/dist/orchestrator/templates/specs-test-data.js +100 -0
- package/dist/orchestrator/templates/specs-test-data.js.map +1 -0
- package/dist/orchestrator/templates/specs-test-data.ts +66 -0
- package/dist/tools/figma/figma-auth.d.ts +36 -0
- package/dist/tools/figma/figma-auth.d.ts.map +1 -0
- package/dist/tools/figma/figma-auth.js +182 -0
- package/dist/tools/figma/figma-auth.js.map +1 -0
- package/dist/tools/figma/figma-cache.d.ts +45 -0
- package/dist/tools/figma/figma-cache.d.ts.map +1 -0
- package/dist/tools/figma/figma-cache.js +191 -0
- package/dist/tools/figma/figma-cache.js.map +1 -0
- package/dist/tools/figma/figma-client-types.d.ts +112 -0
- package/dist/tools/figma/figma-client-types.d.ts.map +1 -0
- package/dist/tools/figma/figma-client-types.js +7 -0
- package/dist/tools/figma/figma-client-types.js.map +1 -0
- package/dist/tools/figma/figma-errors.d.ts +49 -0
- package/dist/tools/figma/figma-errors.d.ts.map +1 -0
- package/dist/tools/figma/figma-errors.js +105 -0
- package/dist/tools/figma/figma-errors.js.map +1 -0
- package/dist/tools/figma/figma-image-downloader.d.ts +25 -0
- package/dist/tools/figma/figma-image-downloader.d.ts.map +1 -0
- package/dist/tools/figma/figma-image-downloader.js +128 -0
- package/dist/tools/figma/figma-image-downloader.js.map +1 -0
- package/dist/tools/figma/figma-node-filter.d.ts +26 -0
- package/dist/tools/figma/figma-node-filter.d.ts.map +1 -0
- package/dist/tools/figma/figma-node-filter.js +164 -0
- package/dist/tools/figma/figma-node-filter.js.map +1 -0
- package/dist/tools/figma/figma-rest-client.d.ts +24 -0
- package/dist/tools/figma/figma-rest-client.d.ts.map +1 -0
- package/dist/tools/figma/figma-rest-client.js +154 -0
- package/dist/tools/figma/figma-rest-client.js.map +1 -0
- package/dist/tools/figma/figma-url-parser.d.ts +18 -0
- package/dist/tools/figma/figma-url-parser.d.ts.map +1 -0
- package/dist/tools/figma/figma-url-parser.js +51 -0
- package/dist/tools/figma/figma-url-parser.js.map +1 -0
- package/dist/utils/exec-file-no-throw.d.ts +20 -0
- package/dist/utils/exec-file-no-throw.d.ts.map +1 -0
- package/dist/utils/exec-file-no-throw.js +36 -0
- package/dist/utils/exec-file-no-throw.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/commands/add.ts +80 -9
- package/src/cli/commands/figma.ts +162 -0
- package/src/cli/commands/generate.ts +2 -0
- package/src/cli/index.ts +4 -2
- package/src/generators/gherkin-parser/index.ts +4 -0
- package/src/generators/test-generator/adapters/adapter-interface.ts +12 -1
- package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +14 -2
- package/src/generators/test-generator/adapters/playwright/templates/after-all.hbs +8 -0
- package/src/generators/test-generator/adapters/playwright/templates/after-each.hbs +8 -0
- package/src/generators/test-generator/adapters/playwright/templates/before-all.hbs +8 -0
- package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
- package/src/generators/test-generator/adapters/playwright/templates/test-file.hbs +24 -0
- package/src/generators/test-generator/code-generator.ts +122 -13
- package/src/generators/test-generator/step-mapper.ts +2 -2
- package/src/generators/test-generator/template-engine.ts +28 -2
- package/src/generators/test-generator/utils/data-resolver.ts +45 -27
- package/src/generators/test-generator/utils/runtime-data-transformer.ts +51 -0
- package/src/generators/types.ts +1 -0
- package/src/orchestrator/ai-rules-updater.ts +2 -0
- package/src/orchestrator/figma/figma-scaffolder-helpers.ts +126 -0
- package/src/orchestrator/figma/figma-scaffolder-types.ts +26 -0
- package/src/orchestrator/figma/figma-scaffolder.ts +209 -0
- package/src/orchestrator/figma/node-path-collapser.ts +38 -0
- package/src/orchestrator/figma/spec-figma-renderer.ts +80 -0
- package/src/orchestrator/figma/spec-figma-section-renderers.ts +46 -0
- package/src/orchestrator/project-initializer.ts +84 -10
- package/src/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +56 -11
- package/src/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +30 -17
- package/src/orchestrator/templates/ai-instructions/claude-cmd-review.md +4 -3
- package/src/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +34 -2
- package/src/orchestrator/templates/ai-instructions/claude-config.md +12 -2
- package/src/orchestrator/templates/ai-instructions/claude-skill-figma-source.md +151 -0
- package/src/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +66 -13
- package/src/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +93 -23
- package/src/orchestrator/templates/ai-instructions/claude-skill-tc-review.md +2 -0
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +53 -9
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +21 -16
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-review.md +4 -3
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +34 -2
- package/src/orchestrator/templates/ai-instructions/copilot-config.md +12 -2
- package/src/orchestrator/templates/ai-instructions/copilot-skill-figma-source.md +151 -0
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-figma-source.md +151 -0
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +86 -13
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-selector-fix.md +61 -0
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +105 -28
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-review.md +20 -0
- package/src/orchestrator/templates/specs-base.ts +65 -7
- package/src/orchestrator/templates/specs-test-data.ts +66 -0
- package/src/tools/figma/figma-auth.ts +161 -0
- package/src/tools/figma/figma-cache.ts +184 -0
- package/src/tools/figma/figma-client-types.ts +125 -0
- package/src/tools/figma/figma-errors.ts +127 -0
- package/src/tools/figma/figma-image-downloader.ts +112 -0
- package/src/tools/figma/figma-node-filter.ts +198 -0
- package/src/tools/figma/figma-rest-client.ts +183 -0
- package/src/tools/figma/figma-url-parser.ts +55 -0
- package/src/utils/exec-file-no-throw.ts +45 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download a Figma-signed image URL to disk.
|
|
3
|
+
*
|
|
4
|
+
* - Streams response body to a .tmp file then atomically renames to destPath.
|
|
5
|
+
* - Verifies the final file is non-zero bytes before committing.
|
|
6
|
+
* - Enforces a configurable timeout (default 30 s).
|
|
7
|
+
* - Surfaces all failures as FigmaNetworkError.
|
|
8
|
+
*
|
|
9
|
+
* Security: signed S3 URLs are consumed transiently — never logged.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { pipeline } from 'node:stream/promises';
|
|
15
|
+
import { Readable } from 'node:stream';
|
|
16
|
+
import { FigmaNetworkError } from './figma-errors';
|
|
17
|
+
|
|
18
|
+
export interface DownloadOptions {
|
|
19
|
+
/** Request timeout in milliseconds. Default: 30 000. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Download the image at `url` and write it to `destPath`.
|
|
25
|
+
*
|
|
26
|
+
* Uses atomic write: streams to `<destPath>.tmp`, verifies size > 0,
|
|
27
|
+
* then renames to `destPath`. Cleans up .tmp on failure.
|
|
28
|
+
*
|
|
29
|
+
* @throws FigmaNetworkError on any network, timeout, or I/O failure.
|
|
30
|
+
* @throws FigmaNetworkError when the downloaded file has zero bytes.
|
|
31
|
+
*/
|
|
32
|
+
export async function downloadToPath(
|
|
33
|
+
url: string,
|
|
34
|
+
destPath: string,
|
|
35
|
+
options: DownloadOptions = {},
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
38
|
+
const tmpPath = `${destPath}.tmp`;
|
|
39
|
+
|
|
40
|
+
// Ensure destination directory exists
|
|
41
|
+
const dir = path.dirname(destPath);
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
46
|
+
|
|
47
|
+
let response: Response;
|
|
48
|
+
try {
|
|
49
|
+
response = await fetch(url, { signal: controller.signal });
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
cleanupTmp(tmpPath);
|
|
53
|
+
const isTimeout =
|
|
54
|
+
cause instanceof Error && cause.name === 'AbortError';
|
|
55
|
+
throw new FigmaNetworkError(
|
|
56
|
+
isTimeout
|
|
57
|
+
? `Image download timed out after ${timeoutMs}ms.`
|
|
58
|
+
: 'Image download failed due to a network error.',
|
|
59
|
+
cause,
|
|
60
|
+
);
|
|
61
|
+
} finally {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
cleanupTmp(tmpPath);
|
|
67
|
+
throw new FigmaNetworkError(
|
|
68
|
+
`Image download received HTTP ${response.status}.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!response.body) {
|
|
73
|
+
cleanupTmp(tmpPath);
|
|
74
|
+
throw new FigmaNetworkError('Image download response has no body.');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Stream to .tmp file
|
|
78
|
+
const writeStream = fs.createWriteStream(tmpPath);
|
|
79
|
+
try {
|
|
80
|
+
await pipeline(Readable.fromWeb(response.body as import('stream/web').ReadableStream), writeStream);
|
|
81
|
+
} catch (cause) {
|
|
82
|
+
cleanupTmp(tmpPath);
|
|
83
|
+
throw new FigmaNetworkError('Failed to write image to disk.', cause);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Verify non-zero file size
|
|
87
|
+
const stat = fs.statSync(tmpPath);
|
|
88
|
+
if (stat.size === 0) {
|
|
89
|
+
cleanupTmp(tmpPath);
|
|
90
|
+
throw new FigmaNetworkError('Downloaded image file is empty (0 bytes).');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Atomic rename
|
|
94
|
+
try {
|
|
95
|
+
fs.renameSync(tmpPath, destPath);
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
cleanupTmp(tmpPath);
|
|
98
|
+
throw new FigmaNetworkError('Failed to finalize image file on disk.', cause);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Internal helpers
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function cleanupTmp(tmpPath: string): void {
|
|
107
|
+
try {
|
|
108
|
+
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
|
109
|
+
} catch {
|
|
110
|
+
// Best-effort cleanup — ignore errors
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prune raw Figma node trees to a minimal shape for LLM / test-generation use.
|
|
3
|
+
*
|
|
4
|
+
* Drops: vectors, fills, effects, strokes, constraints, style references.
|
|
5
|
+
* Keeps: id, name, type, text content, component/variant metadata, bounding box, children.
|
|
6
|
+
*
|
|
7
|
+
* Role inference: component name suffix → button | textbox | link | image | null.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import type {
|
|
12
|
+
FilteredFigmaNode,
|
|
13
|
+
FigmaNodeRole,
|
|
14
|
+
FigmaTextLabel,
|
|
15
|
+
FigmaVariantDefinition,
|
|
16
|
+
} from './figma-client-types';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Zod schema — loose, unknown fields allowed via passthrough
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
// Zod v4: z.record() requires explicit key schema (z.string())
|
|
23
|
+
const RawNodeSchema: z.ZodType<Record<string, unknown>> = z
|
|
24
|
+
.object({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
name: z.string(),
|
|
27
|
+
type: z.string(),
|
|
28
|
+
characters: z.string().optional(),
|
|
29
|
+
componentPropertyDefinitions: z.record(z.string(), z.unknown()).optional(),
|
|
30
|
+
componentProperties: z
|
|
31
|
+
.record(z.string(), z.object({ value: z.string(), type: z.string() }))
|
|
32
|
+
.optional(),
|
|
33
|
+
absoluteBoundingBox: z
|
|
34
|
+
.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() })
|
|
35
|
+
.optional(),
|
|
36
|
+
children: z.array(z.lazy((): z.ZodType<Record<string, unknown>> => RawNodeSchema)).optional(),
|
|
37
|
+
})
|
|
38
|
+
.passthrough();
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Role inference
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const ROLE_SUFFIX_MAP: Array<[RegExp, FigmaNodeRole]> = [
|
|
45
|
+
[/button$/i, 'button'],
|
|
46
|
+
[/input|textbox|text.?field|text.?input/i, 'textbox'],
|
|
47
|
+
[/link$/i, 'link'],
|
|
48
|
+
[/icon|image|img|illustration/i, 'image'],
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function inferRole(name: string): FigmaNodeRole {
|
|
52
|
+
for (const [pattern, role] of ROLE_SUFFIX_MAP) {
|
|
53
|
+
if (pattern.test(name)) return role;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Node types to skip entirely (leaf-only visual noise)
|
|
59
|
+
const SKIP_TYPES = new Set([
|
|
60
|
+
'VECTOR',
|
|
61
|
+
'STAR',
|
|
62
|
+
'POLYGON',
|
|
63
|
+
'BOOLEAN_OPERATION',
|
|
64
|
+
'ELLIPSE',
|
|
65
|
+
'LINE',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Core filter
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Recursively prune a raw Figma node to the minimal FilteredFigmaNode shape.
|
|
74
|
+
* Returns null when the node type should be dropped entirely.
|
|
75
|
+
*
|
|
76
|
+
* @param raw Validated (but loosely typed) raw Figma node object.
|
|
77
|
+
* @param maxDepth Maximum recursion depth (default 30 — safety guard).
|
|
78
|
+
*/
|
|
79
|
+
function filterNode(
|
|
80
|
+
raw: Record<string, unknown>,
|
|
81
|
+
depth = 0,
|
|
82
|
+
maxDepth = 30,
|
|
83
|
+
): FilteredFigmaNode | null {
|
|
84
|
+
const id = raw['id'] as string;
|
|
85
|
+
const name = raw['name'] as string;
|
|
86
|
+
const type = raw['type'] as string;
|
|
87
|
+
|
|
88
|
+
if (SKIP_TYPES.has(type)) return null;
|
|
89
|
+
if (depth > maxDepth) return null;
|
|
90
|
+
|
|
91
|
+
// Bounding box
|
|
92
|
+
let boundingBox: FilteredFigmaNode['boundingBox'];
|
|
93
|
+
const bb = raw['absoluteBoundingBox'] as
|
|
94
|
+
| { x: number; y: number; width: number; height: number }
|
|
95
|
+
| undefined;
|
|
96
|
+
if (bb) {
|
|
97
|
+
boundingBox = { x: bb.x, y: bb.y, w: bb.width, h: bb.height };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Text content
|
|
101
|
+
const text =
|
|
102
|
+
type === 'TEXT' ? (raw['characters'] as string | undefined) : undefined;
|
|
103
|
+
|
|
104
|
+
// Component / variant metadata
|
|
105
|
+
let componentName: string | undefined;
|
|
106
|
+
let variantProps: Record<string, string> | undefined;
|
|
107
|
+
|
|
108
|
+
if (type === 'COMPONENT' || type === 'COMPONENT_SET' || type === 'INSTANCE') {
|
|
109
|
+
componentName = name;
|
|
110
|
+
const props = raw['componentProperties'] as
|
|
111
|
+
| Record<string, { value: string; type: string }>
|
|
112
|
+
| undefined;
|
|
113
|
+
if (props) {
|
|
114
|
+
variantProps = Object.fromEntries(
|
|
115
|
+
Object.entries(props).map(([k, v]) => [k, v.value]),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Role (only meaningful for component-like nodes)
|
|
121
|
+
const role: FigmaNodeRole =
|
|
122
|
+
componentName != null ? inferRole(name) : null;
|
|
123
|
+
|
|
124
|
+
// Recurse into children
|
|
125
|
+
const rawChildren = (raw['children'] as Record<string, unknown>[] | undefined) ?? [];
|
|
126
|
+
const children: FilteredFigmaNode[] = rawChildren
|
|
127
|
+
.map((child) => filterNode(child, depth + 1, maxDepth))
|
|
128
|
+
.filter((n): n is FilteredFigmaNode => n !== null);
|
|
129
|
+
|
|
130
|
+
const node: FilteredFigmaNode = { id, name, type, children };
|
|
131
|
+
if (text !== undefined) node.text = text;
|
|
132
|
+
if (componentName !== undefined) node.componentName = componentName;
|
|
133
|
+
if (variantProps !== undefined) node.variantProps = variantProps;
|
|
134
|
+
if (role) node.role = role;
|
|
135
|
+
if (boundingBox !== undefined) node.boundingBox = boundingBox;
|
|
136
|
+
|
|
137
|
+
return node;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Public API
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate and filter a raw Figma node tree.
|
|
146
|
+
*
|
|
147
|
+
* @throws ZodError when the input fails basic structural validation.
|
|
148
|
+
*/
|
|
149
|
+
export function filterFigmaNode(rawNode: unknown): FilteredFigmaNode {
|
|
150
|
+
const validated = RawNodeSchema.parse(rawNode);
|
|
151
|
+
const result = filterNode(validated);
|
|
152
|
+
// Root node should never be null (root type is never in SKIP_TYPES)
|
|
153
|
+
if (!result) {
|
|
154
|
+
const id = (rawNode as Record<string, unknown>)['id'] ?? 'unknown';
|
|
155
|
+
throw new Error(`Root node (id=${id}) was filtered out — unexpected type.`);
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Recursively collect all text labels from a filtered node tree.
|
|
162
|
+
* Returns a flat list of { text, nodePath } pairs.
|
|
163
|
+
*/
|
|
164
|
+
export function extractTextLabels(
|
|
165
|
+
node: FilteredFigmaNode,
|
|
166
|
+
_path = '',
|
|
167
|
+
): FigmaTextLabel[] {
|
|
168
|
+
const currentPath = _path ? `${_path} > ${node.name}` : node.name;
|
|
169
|
+
const labels: FigmaTextLabel[] = [];
|
|
170
|
+
|
|
171
|
+
if (node.text) {
|
|
172
|
+
labels.push({ text: node.text, nodePath: currentPath });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const child of node.children) {
|
|
176
|
+
labels.push(...extractTextLabels(child, currentPath));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return labels;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract variant definitions from a COMPONENT_SET node.
|
|
184
|
+
* Each direct child COMPONENT represents one variant combination.
|
|
185
|
+
*/
|
|
186
|
+
export function extractVariants(
|
|
187
|
+
componentSetNode: FilteredFigmaNode,
|
|
188
|
+
): FigmaVariantDefinition[] {
|
|
189
|
+
if (componentSetNode.type !== 'COMPONENT_SET') return [];
|
|
190
|
+
|
|
191
|
+
return componentSetNode.children
|
|
192
|
+
.filter((child) => child.type === 'COMPONENT')
|
|
193
|
+
.map((child) => ({
|
|
194
|
+
nodeId: child.id,
|
|
195
|
+
name: child.name,
|
|
196
|
+
variantProps: child.variantProps ?? {},
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Figma REST client.
|
|
3
|
+
*
|
|
4
|
+
* - Base URL: https://api.figma.com
|
|
5
|
+
* - Auth header: X-Figma-Token (PAT — never logged)
|
|
6
|
+
* - Retry: 429 → honor Retry-After (cap 3 attempts); 5xx → retry once
|
|
7
|
+
* - All HTTP errors mapped to typed FigmaError subclasses
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
FigmaAuthError,
|
|
12
|
+
FigmaAccessError,
|
|
13
|
+
FigmaRateLimitError,
|
|
14
|
+
FigmaNetworkError,
|
|
15
|
+
} from './figma-errors';
|
|
16
|
+
import type {
|
|
17
|
+
FigmaMeResponse,
|
|
18
|
+
FigmaFileNodesResponse,
|
|
19
|
+
FigmaImageUrlsResponse,
|
|
20
|
+
FigmaImageOptions,
|
|
21
|
+
} from './figma-client-types';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Constants
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const BASE_URL = 'https://api.figma.com';
|
|
28
|
+
/**
|
|
29
|
+
* Per-request timeout (ms). Figma can stall on very large nodes; without this,
|
|
30
|
+
* node fetch hangs the CLI indefinitely. Override via SUNGEN_FIGMA_TIMEOUT_MS.
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 90_000;
|
|
33
|
+
function getRequestTimeoutMs(): number {
|
|
34
|
+
const raw = process.env.SUNGEN_FIGMA_TIMEOUT_MS;
|
|
35
|
+
const n = raw ? parseInt(raw, 10) : NaN;
|
|
36
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Internal helpers
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** Build headers with PAT — never expose PAT in logs/errors. */
|
|
44
|
+
function makeHeaders(pat: string): Record<string, string> {
|
|
45
|
+
return { 'X-Figma-Token': pat };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Parse Retry-After header into seconds (default 60 when missing/invalid). */
|
|
49
|
+
function parseRetryAfter(headers: Headers): number {
|
|
50
|
+
const value = headers.get('Retry-After');
|
|
51
|
+
if (!value) return 60;
|
|
52
|
+
const parsed = parseInt(value, 10);
|
|
53
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 60;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Sleep for `ms` milliseconds. */
|
|
57
|
+
function sleep(ms: number): Promise<void> {
|
|
58
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Map a non-2xx HTTP status to a typed error.
|
|
63
|
+
* `url` is included only in a generic way — never includes PAT.
|
|
64
|
+
*/
|
|
65
|
+
function mapHttpError(status: number, url: string): never {
|
|
66
|
+
const safeUrl = url.replace(/\?.*$/, ''); // strip query params (may contain ids)
|
|
67
|
+
if (status === 401) throw new FigmaAuthError();
|
|
68
|
+
if (status === 403) throw new FigmaAccessError(403);
|
|
69
|
+
if (status === 404) throw new FigmaAccessError(404);
|
|
70
|
+
throw new FigmaNetworkError(`Figma API error ${status} at ${safeUrl}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Core fetch wrapper with retry logic.
|
|
75
|
+
*
|
|
76
|
+
* Retry rules:
|
|
77
|
+
* - 429: up to MAX_RATE_LIMIT_RETRIES attempts, sleeping Retry-After seconds between each.
|
|
78
|
+
* - 5xx: retry once after 2 s.
|
|
79
|
+
* - Other errors: throw immediately.
|
|
80
|
+
*/
|
|
81
|
+
async function figmaFetch(pat: string, url: string): Promise<Response> {
|
|
82
|
+
let serverErrorRetried = false;
|
|
83
|
+
|
|
84
|
+
// eslint-disable-next-line no-constant-condition
|
|
85
|
+
while (true) {
|
|
86
|
+
let response: Response;
|
|
87
|
+
const timeoutMs = getRequestTimeoutMs();
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
90
|
+
try {
|
|
91
|
+
response = await fetch(url, { headers: makeHeaders(pat), signal: controller.signal });
|
|
92
|
+
} catch (cause) {
|
|
93
|
+
const aborted = (cause as { name?: string } | undefined)?.name === 'AbortError';
|
|
94
|
+
if (aborted) {
|
|
95
|
+
throw new FigmaNetworkError(
|
|
96
|
+
`Figma API request timed out after ${Math.round(timeoutMs / 1000)}s. ` +
|
|
97
|
+
'The node may be very large. Retry with --refresh, pick a smaller frame, ' +
|
|
98
|
+
'or raise SUNGEN_FIGMA_TIMEOUT_MS.',
|
|
99
|
+
cause,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
throw new FigmaNetworkError('Network request to Figma API failed.', cause);
|
|
103
|
+
} finally {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (response.ok) return response;
|
|
108
|
+
|
|
109
|
+
// 429: fail fast. Figma rate-limit windows are per-minute, so in-CLI
|
|
110
|
+
// retries typically waste time. Surface the Retry-After hint and let
|
|
111
|
+
// the caller re-run after waiting.
|
|
112
|
+
if (response.status === 429) {
|
|
113
|
+
const retryAfter = parseRetryAfter(response.headers);
|
|
114
|
+
const planTier = response.headers.get('x-figma-plan-tier') ?? undefined;
|
|
115
|
+
const bucket = response.headers.get('x-figma-rate-limit-type') ?? undefined;
|
|
116
|
+
const upgradeLink = response.headers.get('x-figma-upgrade-link') ?? undefined;
|
|
117
|
+
throw new FigmaRateLimitError(retryAfter, undefined, { planTier, bucket, upgradeLink });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (response.status >= 500 && !serverErrorRetried) {
|
|
121
|
+
serverErrorRetried = true;
|
|
122
|
+
await sleep(2000);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// For all other non-2xx, delegate to mapHttpError (throws)
|
|
127
|
+
mapHttpError(response.status, url);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Public API
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* GET /v1/me — validate PAT and return user info.
|
|
137
|
+
*/
|
|
138
|
+
export async function getMe(pat: string): Promise<FigmaMeResponse> {
|
|
139
|
+
const url = `${BASE_URL}/v1/me`;
|
|
140
|
+
const response = await figmaFetch(pat, url);
|
|
141
|
+
return response.json() as Promise<FigmaMeResponse>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* GET /v1/files/:key/nodes?ids=<id,id,...>
|
|
146
|
+
* Returns the raw node document and current file version.
|
|
147
|
+
*/
|
|
148
|
+
export async function getFileNodes(
|
|
149
|
+
pat: string,
|
|
150
|
+
fileKey: string,
|
|
151
|
+
nodeIds: string[],
|
|
152
|
+
): Promise<FigmaFileNodesResponse> {
|
|
153
|
+
if (nodeIds.length === 0) {
|
|
154
|
+
throw new Error('getFileNodes: nodeIds must be a non-empty array.');
|
|
155
|
+
}
|
|
156
|
+
const ids = nodeIds.join(',');
|
|
157
|
+
const url = `${BASE_URL}/v1/files/${encodeURIComponent(fileKey)}/nodes?ids=${encodeURIComponent(ids)}`;
|
|
158
|
+
const response = await figmaFetch(pat, url);
|
|
159
|
+
return response.json() as Promise<FigmaFileNodesResponse>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* GET /v1/images/:key?ids=<id,id>&scale=<n>&format=<fmt>
|
|
164
|
+
* Returns signed S3 URLs — consume immediately, do not persist.
|
|
165
|
+
*/
|
|
166
|
+
export async function getImageUrls(
|
|
167
|
+
pat: string,
|
|
168
|
+
fileKey: string,
|
|
169
|
+
nodeIds: string[],
|
|
170
|
+
options: FigmaImageOptions = {},
|
|
171
|
+
): Promise<FigmaImageUrlsResponse> {
|
|
172
|
+
if (nodeIds.length === 0) {
|
|
173
|
+
throw new Error('getImageUrls: nodeIds must be a non-empty array.');
|
|
174
|
+
}
|
|
175
|
+
const scale = options.scale ?? 2;
|
|
176
|
+
const format = options.format ?? 'png';
|
|
177
|
+
const ids = nodeIds.join(',');
|
|
178
|
+
const url =
|
|
179
|
+
`${BASE_URL}/v1/images/${encodeURIComponent(fileKey)}` +
|
|
180
|
+
`?ids=${encodeURIComponent(ids)}&scale=${scale}&format=${format}`;
|
|
181
|
+
const response = await figmaFetch(pat, url);
|
|
182
|
+
return response.json() as Promise<FigmaImageUrlsResponse>;
|
|
183
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline Figma URL parser.
|
|
3
|
+
* Converts Figma share URLs → { fileKey, nodeId } without any network call.
|
|
4
|
+
*
|
|
5
|
+
* Supported URL forms:
|
|
6
|
+
* https://www.figma.com/design/<KEY>/<name>?node-id=1-23
|
|
7
|
+
* https://www.figma.com/file/<KEY>/<name>?node-id=1%3A23 (legacy + percent-encoded)
|
|
8
|
+
* https://www.figma.com/design/<KEY>/<name> (no node selected)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { FigmaNodeRef } from './figma-client-types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Regex captures:
|
|
15
|
+
* group 1 → path type: "design" | "file"
|
|
16
|
+
* group 2 → fileKey (alphanumeric)
|
|
17
|
+
* group 3 → raw node-id query param value (may be absent)
|
|
18
|
+
*/
|
|
19
|
+
const FIGMA_URL_RE =
|
|
20
|
+
/^https?:\/\/(?:www\.)?figma\.com\/(design|file)\/([A-Za-z0-9]+)(?:\/[^?#]*)?(?:[?&].*?node-id=([\w%:.-]+))?/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert URL-form node-id to Figma API form.
|
|
24
|
+
* "1-23" → "1:23"
|
|
25
|
+
* "1%3A23" → "1:23" (percent-encoded colon)
|
|
26
|
+
* "1:23" → "1:23" (already correct)
|
|
27
|
+
*/
|
|
28
|
+
function normalizeNodeId(raw: string): string {
|
|
29
|
+
const decoded = decodeURIComponent(raw);
|
|
30
|
+
// Replace dash separator only between two digit groups (Figma node-id pattern)
|
|
31
|
+
return decoded.replace(/^(\d+)-(\d+)$/, '$1:$2');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a Figma share URL into a structured reference.
|
|
36
|
+
*
|
|
37
|
+
* @param url - Full Figma URL string.
|
|
38
|
+
* @returns `{ fileKey, nodeId? }` on success, `null` if URL is not a valid Figma URL.
|
|
39
|
+
*/
|
|
40
|
+
export function parseFigmaUrl(url: string): FigmaNodeRef | null {
|
|
41
|
+
if (!url || typeof url !== 'string') return null;
|
|
42
|
+
|
|
43
|
+
const match = FIGMA_URL_RE.exec(url.trim());
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
|
|
46
|
+
const fileKey = match[2];
|
|
47
|
+
const rawNodeId = match[3];
|
|
48
|
+
|
|
49
|
+
const result: FigmaNodeRef = { fileKey };
|
|
50
|
+
if (rawNodeId) {
|
|
51
|
+
result.nodeId = normalizeNodeId(rawNodeId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe execFile wrapper — uses execFile (not exec) to prevent shell injection.
|
|
3
|
+
* Handles Windows compatibility and provides structured output.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFile } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
export interface ExecFileResult {
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
/** Process exit code; 0 = success. */
|
|
15
|
+
status: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run a command safely using execFile (no shell expansion).
|
|
20
|
+
* Never throws — all errors are captured in the result.
|
|
21
|
+
*
|
|
22
|
+
* @param cmd - Executable name or path (no shell features).
|
|
23
|
+
* @param args - Arguments as an array (each arg is passed verbatim).
|
|
24
|
+
* @param cwd - Optional working directory.
|
|
25
|
+
*/
|
|
26
|
+
export async function execFileNoThrow(
|
|
27
|
+
cmd: string,
|
|
28
|
+
args: string[],
|
|
29
|
+
cwd?: string,
|
|
30
|
+
): Promise<ExecFileResult> {
|
|
31
|
+
try {
|
|
32
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, {
|
|
33
|
+
cwd,
|
|
34
|
+
// Prevent accidental shell expansion on Windows too
|
|
35
|
+
shell: false,
|
|
36
|
+
});
|
|
37
|
+
return { stdout: stdout ?? '', stderr: stderr ?? '', status: 0 };
|
|
38
|
+
} catch (err: any) {
|
|
39
|
+
return {
|
|
40
|
+
stdout: err.stdout ?? '',
|
|
41
|
+
stderr: err.stderr ?? '',
|
|
42
|
+
status: typeof err.code === 'number' ? err.code : 1,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|