framer-code-link 0.2.0 → 0.2.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.
@@ -1,530 +0,0 @@
1
- /**
2
- * Type installer helper using @typescript/ata
3
- */
4
-
5
- import { setupTypeAcquisition } from "@typescript/ata"
6
- import ts from "typescript"
7
- import path from "path"
8
- import fs from "fs/promises"
9
- import { extractImports } from "../utils/imports.js"
10
- import { debug, warn } from "../utils/logging.js"
11
-
12
- export interface InstallerConfig {
13
- projectDir: string
14
- }
15
-
16
- const FETCH_TIMEOUT_MS = 60_000
17
- const MAX_FETCH_RETRIES = 3
18
- const REACT_TYPES_VERSION = "18.3.12"
19
- const REACT_DOM_TYPES_VERSION = "18.3.1"
20
- const CORE_LIBRARIES = ["framer-motion", "framer"]
21
- const JSON_EXTENSION_REGEX = /\.json$/i
22
-
23
- /**
24
- * Installer class for managing automatic type acquisition.
25
- */
26
- export class Installer {
27
- private projectDir: string
28
- private ata: ReturnType<typeof setupTypeAcquisition>
29
- private processedImports = new Set<string>()
30
- private initializationPromise: Promise<void> | null = null
31
-
32
- constructor(config: InstallerConfig) {
33
- this.projectDir = config.projectDir
34
-
35
- const seenPackages = new Set<string>()
36
-
37
- this.ata = setupTypeAcquisition({
38
- projectName: "framer-code-link",
39
- typescript: ts,
40
- logger: console,
41
- fetcher: fetchWithRetry,
42
- delegate: {
43
- started: () => {
44
- seenPackages.clear()
45
- debug("ATA: fetching type definitions...")
46
- },
47
- progress: () => {
48
- // intentionally noop – progress noise is not helpful in CLI output
49
- },
50
- finished: (files) => {
51
- if (files && files.size > 0) {
52
- debug("ATA: type acquisition complete")
53
- }
54
- },
55
- errorMessage: (message: string, error: Error) => {
56
- warn(`ATA warning: ${message}`, error)
57
- },
58
- receivedFile: async (code: string, receivedPath: string) => {
59
- const normalized = receivedPath.replace(/^\//, "")
60
- const destination = path.join(this.projectDir, normalized)
61
-
62
- const pkgMatch = receivedPath.match(
63
- /\/node_modules\/(@?[^\/]+(?:\/[^\/]+)?)\//
64
- )
65
-
66
- // Check if file already exists with same content
67
- let isFromCache = false
68
- try {
69
- const existing = await fs.readFile(destination, "utf-8")
70
- if (existing === code) {
71
- isFromCache = true
72
- if (pkgMatch && !seenPackages.has(pkgMatch[1])) {
73
- seenPackages.add(pkgMatch[1])
74
- debug(`📦 Types: ${pkgMatch[1]} (from disk cache)`)
75
- }
76
- return // Skip write if identical
77
- }
78
- } catch {
79
- // File doesn't exist or can't be read, proceed with write
80
- }
81
-
82
- if (pkgMatch && !seenPackages.has(pkgMatch[1])) {
83
- seenPackages.add(pkgMatch[1])
84
- debug(`📦 Types: ${pkgMatch[1]}`)
85
- }
86
-
87
- await this.writeTypeFile(receivedPath, code)
88
- },
89
- },
90
- })
91
-
92
- debug("Type installer initialized")
93
- }
94
-
95
- /**
96
- * Ensure the project scaffolding exists (tsconfig, declarations, etc.)
97
- */
98
- async initialize(): Promise<void> {
99
- if (this.initializationPromise) {
100
- return this.initializationPromise
101
- }
102
-
103
- this.initializationPromise = this.initializeProject()
104
- .then(() => {
105
- debug("Type installer initialization complete")
106
- })
107
- .catch((err) => {
108
- this.initializationPromise = null
109
- throw err
110
- })
111
-
112
- return this.initializationPromise
113
- }
114
-
115
- /**
116
- * Fire-and-forget processing of a component file to fetch missing types.
117
- * JSON files are ignored.
118
- */
119
- process(fileName: string, content: string): void {
120
- if (!content || JSON_EXTENSION_REGEX.test(fileName)) {
121
- return
122
- }
123
-
124
- Promise.resolve()
125
- .then(async () => {
126
- await this.processImports(fileName, content)
127
- })
128
- .catch((err) => {
129
- debug(`Type installer failed for ${fileName}`, err)
130
- })
131
- }
132
-
133
- // ---------------------------------------------------------------------------
134
- // Internal helpers
135
- // ---------------------------------------------------------------------------
136
-
137
- private async initializeProject(): Promise<void> {
138
- await Promise.all([
139
- this.ensureTsConfig(),
140
- this.ensurePrettierConfig(),
141
- this.ensureFramerDeclarations(),
142
- this.ensurePackageJson(),
143
- ])
144
-
145
- // Fire-and-forget type installation - don't block initialization
146
- Promise.resolve()
147
- .then(async () => {
148
- await this.ensureReact18Types()
149
-
150
- const coreImports = CORE_LIBRARIES.map(
151
- (lib) => `import "${lib}";`
152
- ).join("\n")
153
- await this.ata(coreImports)
154
- })
155
- .catch((err) => {
156
- debug("Type installation failed", err)
157
- })
158
- }
159
-
160
- private async processImports(
161
- fileName: string,
162
- content: string
163
- ): Promise<void> {
164
- const imports = extractImports(content).filter((imp) => imp.type === "npm")
165
-
166
- if (imports.length === 0) {
167
- return
168
- }
169
-
170
- const hash = imports
171
- .map((imp) => imp.name)
172
- .sort()
173
- .join(",")
174
-
175
- if (this.processedImports.has(hash)) {
176
- return
177
- }
178
-
179
- this.processedImports.add(hash)
180
- debug(`Processing imports for ${fileName} (${imports.length} packages)`)
181
-
182
- try {
183
- await this.ata(content)
184
- } catch (err) {
185
- warn(`ATA failed for ${fileName}`, err as Error)
186
- }
187
- }
188
-
189
- private async writeTypeFile(
190
- receivedPath: string,
191
- code: string
192
- ): Promise<void> {
193
- const normalized = receivedPath.replace(/^\//, "")
194
- const destination = path.join(this.projectDir, normalized)
195
-
196
- try {
197
- await fs.mkdir(path.dirname(destination), { recursive: true })
198
- await fs.writeFile(destination, code, "utf-8")
199
- } catch (err) {
200
- warn(`Failed to write type file ${destination}`, err)
201
- return
202
- }
203
-
204
- if (normalized.match(/node_modules\/@types\/[^\/]+\/index\.d\.ts$/)) {
205
- await this.ensureTypesPackageJson(normalized)
206
- }
207
-
208
- if (normalized.includes("node_modules/@types/react/index.d.ts")) {
209
- await this.patchReactTypes(destination)
210
- }
211
- }
212
-
213
- private async ensureTypesPackageJson(normalizedPath: string): Promise<void> {
214
- const pkgMatch = normalizedPath.match(/node_modules\/(@types\/[^\/]+)\//)
215
- if (!pkgMatch) return
216
-
217
- const pkgName = pkgMatch[1]
218
- const pkgDir = path.join(this.projectDir, "node_modules", pkgName)
219
- const pkgJsonPath = path.join(pkgDir, "package.json")
220
-
221
- try {
222
- const response = await fetch(`https://registry.npmjs.org/${pkgName}`)
223
- if (!response.ok) return
224
-
225
- const npmData = await response.json()
226
- const version = npmData["dist-tags"]?.latest
227
- if (!version || !npmData.versions?.[version]) return
228
-
229
- const pkg = npmData.versions[version]
230
-
231
- if (pkg.exports && typeof pkg.exports === "object") {
232
- const fixExport = (value: any): any => {
233
- if (typeof value === "string") {
234
- const tsPath = value
235
- .replace(/\.js$/, ".d.ts")
236
- .replace(/\.cjs$/, ".d.cts")
237
- return { types: tsPath }
238
- }
239
-
240
- if (value && typeof value === "object") {
241
- if ((value.import || value.require) && !value.types) {
242
- const base = value.import || value.require
243
- value.types = base
244
- .replace(/\.js$/, ".d.ts")
245
- .replace(/\.cjs$/, ".d.cts")
246
- }
247
- }
248
-
249
- return value
250
- }
251
-
252
- for (const key of Object.keys(pkg.exports)) {
253
- pkg.exports[key] = fixExport(pkg.exports[key])
254
- }
255
- }
256
-
257
- await fs.mkdir(pkgDir, { recursive: true })
258
- await fs.writeFile(pkgJsonPath, JSON.stringify(pkg, null, 2))
259
- } catch {
260
- // best-effort
261
- }
262
- }
263
-
264
- private async patchReactTypes(destination: string): Promise<void> {
265
- try {
266
- let content = await fs.readFile(destination, "utf-8")
267
- if (content.includes("function useRef<T = undefined>()")) {
268
- return
269
- }
270
-
271
- const overloadPattern =
272
- /function useRef<T>\(initialValue: T \| undefined\): RefObject<T \| undefined>;/
273
-
274
- if (!overloadPattern.test(content)) {
275
- return
276
- }
277
-
278
- content = content.replace(
279
- overloadPattern,
280
- `function useRef<T>(initialValue: T | undefined): RefObject<T | undefined>;
281
- function useRef<T = undefined>(): MutableRefObject<T | undefined>;`
282
- )
283
-
284
- await fs.writeFile(destination, content, "utf-8")
285
- } catch {
286
- // ignore patch failures
287
- }
288
- }
289
-
290
- private async ensureTsConfig(): Promise<void> {
291
- const tsconfigPath = path.join(this.projectDir, "tsconfig.json")
292
- try {
293
- await fs.access(tsconfigPath)
294
- debug("tsconfig.json already exists")
295
- } catch {
296
- const config = {
297
- compilerOptions: {
298
- noEmit: true,
299
- target: "ES2021",
300
- lib: ["ES2021", "DOM", "DOM.Iterable"],
301
- module: "ESNext",
302
- moduleResolution: "bundler",
303
- customConditions: ["source"],
304
- jsx: "react-jsx",
305
- allowJs: true,
306
- allowSyntheticDefaultImports: true,
307
- strict: false,
308
- allowImportingTsExtensions: true,
309
- resolveJsonModule: true,
310
- esModuleInterop: true,
311
- skipLibCheck: true,
312
- typeRoots: ["./node_modules/@types"],
313
- },
314
- include: ["files/**/*", "framer-modules.d.ts"],
315
- }
316
- await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2))
317
- debug("Created tsconfig.json")
318
- }
319
- }
320
-
321
- private async ensurePrettierConfig(): Promise<void> {
322
- const prettierPath = path.join(this.projectDir, ".prettierrc")
323
- try {
324
- await fs.access(prettierPath)
325
- debug(".prettierrc already exists")
326
- } catch {
327
- const config = {
328
- tabWidth: 4,
329
- semi: false,
330
- trailingComma: "es5",
331
- }
332
- await fs.writeFile(prettierPath, JSON.stringify(config, null, 2))
333
- debug("Created .prettierrc")
334
- }
335
- }
336
-
337
- private async ensureFramerDeclarations(): Promise<void> {
338
- const declarationsPath = path.join(this.projectDir, "framer-modules.d.ts")
339
- try {
340
- await fs.access(declarationsPath)
341
- debug("framer-modules.d.ts already exists")
342
- } catch {
343
- const declarations = `// Type declarations for Framer URL imports
344
- declare module "https://framer.com/m/*"
345
-
346
- declare module "https://framerusercontent.com/*"
347
-
348
- declare module "*.json"
349
- `
350
- await fs.writeFile(declarationsPath, declarations)
351
- debug("Created framer-modules.d.ts")
352
- }
353
- }
354
-
355
- private async ensurePackageJson(): Promise<void> {
356
- const packagePath = path.join(this.projectDir, "package.json")
357
- try {
358
- await fs.access(packagePath)
359
- debug("package.json already exists")
360
- } catch {
361
- const pkg = {
362
- name: path.basename(this.projectDir),
363
- version: "1.0.0",
364
- private: true,
365
- description: "Framer files synced with framer-code-link",
366
- }
367
- await fs.writeFile(packagePath, JSON.stringify(pkg, null, 2))
368
- debug("Created package.json")
369
- }
370
- }
371
-
372
- private async ensureReact18Types(): Promise<void> {
373
- const reactTypesDir = path.join(
374
- this.projectDir,
375
- "node_modules/@types/react"
376
- )
377
-
378
- const reactFiles = [
379
- "package.json",
380
- "index.d.ts",
381
- "global.d.ts",
382
- "jsx-runtime.d.ts",
383
- "jsx-dev-runtime.d.ts",
384
- ]
385
-
386
- if (
387
- await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles)
388
- ) {
389
- debug("📦 React types (from cache)")
390
- } else {
391
- debug("Downloading React 18 types...")
392
- await this.downloadTypePackage(
393
- "@types/react",
394
- REACT_TYPES_VERSION,
395
- reactTypesDir,
396
- reactFiles
397
- )
398
- }
399
-
400
- const reactDomDir = path.join(
401
- this.projectDir,
402
- "node_modules/@types/react-dom"
403
- )
404
-
405
- const reactDomFiles = ["package.json", "index.d.ts", "client.d.ts"]
406
-
407
- if (
408
- await this.hasTypePackage(
409
- reactDomDir,
410
- REACT_DOM_TYPES_VERSION,
411
- reactDomFiles
412
- )
413
- ) {
414
- debug("📦 React DOM types (from cache)")
415
- } else {
416
- await this.downloadTypePackage(
417
- "@types/react-dom",
418
- REACT_DOM_TYPES_VERSION,
419
- reactDomDir,
420
- reactDomFiles
421
- )
422
- }
423
- }
424
-
425
- private async hasTypePackage(
426
- destinationDir: string,
427
- version: string,
428
- files: string[]
429
- ): Promise<boolean> {
430
- try {
431
- const pkgJsonPath = path.join(destinationDir, "package.json")
432
- const pkgJson = await fs.readFile(pkgJsonPath, "utf-8")
433
- const parsed = JSON.parse(pkgJson)
434
-
435
- if (parsed.version !== version) {
436
- return false
437
- }
438
-
439
- for (const file of files) {
440
- if (file === "package.json") continue
441
- await fs.access(path.join(destinationDir, file))
442
- }
443
-
444
- return true
445
- } catch {
446
- return false
447
- }
448
- }
449
-
450
- private async downloadTypePackage(
451
- pkgName: string,
452
- version: string,
453
- destinationDir: string,
454
- files: string[]
455
- ): Promise<void> {
456
- const baseUrl = `https://unpkg.com/${pkgName}@${version}`
457
- await fs.mkdir(destinationDir, { recursive: true })
458
-
459
- await Promise.all(
460
- files.map(async (file) => {
461
- const destination = path.join(destinationDir, file)
462
-
463
- // Check if file already exists
464
- try {
465
- await fs.access(destination)
466
- return // Skip if exists
467
- } catch {
468
- // File doesn't exist, download it
469
- }
470
-
471
- try {
472
- const response = await fetch(`${baseUrl}/${file}`)
473
- if (!response.ok) return
474
- const content = await response.text()
475
- await fs.writeFile(destination, content)
476
- } catch {
477
- // ignore per-file failures
478
- }
479
- })
480
- )
481
- }
482
- }
483
-
484
- // -----------------------------------------------------------------------------
485
- // Fetch helpers
486
- // -----------------------------------------------------------------------------
487
-
488
- async function fetchWithRetry(
489
- url: string | URL | Request,
490
- init?: RequestInit,
491
- retries = MAX_FETCH_RETRIES
492
- ): Promise<Response> {
493
- const urlString = typeof url === "string" ? url : url.toString()
494
-
495
- for (let attempt = 1; attempt <= retries; attempt++) {
496
- const controller = new AbortController()
497
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
498
-
499
- try {
500
- const response = await fetch(url, {
501
- ...init,
502
- signal: controller.signal,
503
- })
504
- clearTimeout(timeout)
505
- return response
506
- } catch (error: any) {
507
- clearTimeout(timeout)
508
-
509
- const isRetryable =
510
- error?.cause?.code === "ECONNRESET" ||
511
- error?.cause?.code === "ETIMEDOUT" ||
512
- error?.cause?.code === "UND_ERR_CONNECT_TIMEOUT" ||
513
- error?.message?.includes("timeout")
514
-
515
- if (attempt < retries && isRetryable) {
516
- const delay = attempt * 1_000
517
- warn(
518
- `Fetch failed (${error?.cause?.code || error?.message}) for ${urlString}, retrying in ${delay}ms...`
519
- )
520
- await new Promise((resolve) => setTimeout(resolve, delay))
521
- continue
522
- }
523
-
524
- warn(`Fetch failed for ${urlString}`, error)
525
- throw error
526
- }
527
- }
528
-
529
- throw new Error(`Max retries exceeded for ${urlString}`)
530
- }
@@ -1,87 +0,0 @@
1
- /**
2
- * Sync Validation Helper
3
- *
4
- * Pure functions for validating incoming changes during live sync.
5
- * Determines if a change should be applied, queued, or rejected.
6
- */
7
-
8
- import type { FileInfo } from "../types.js"
9
- import { hashFileContent } from "../utils/state-persistence.js"
10
- import type { FileSyncMetadata } from "../utils/file-metadata-cache.js"
11
-
12
- /**
13
- * Result of validating an incoming file change
14
- */
15
- export type ChangeValidation =
16
- | { action: "apply"; reason: "new-file" | "safe-update" }
17
- | { action: "queue"; reason: "snapshot-in-progress" }
18
- | { action: "reject"; reason: "stale-base" | "unknown-file" }
19
-
20
- /**
21
- * Validates whether an incoming REMOTE file change should be applied
22
- *
23
- * During watching mode, we trust remote changes and apply them immediately.
24
- * During snapshot_processing, we queue them for later (to avoid race conditions).
25
- *
26
- * Note: This is for INCOMING changes from remote. Local changes (from watcher)
27
- * are handled separately and always sent during watching mode.
28
- */
29
- export function validateIncomingChange(
30
- file: FileInfo,
31
- fileMeta: FileSyncMetadata | undefined,
32
- currentMode: string
33
- ): ChangeValidation {
34
- // Queue changes that arrive during snapshot processing
35
- if (currentMode === "snapshot_processing" || currentMode === "handshaking") {
36
- return { action: "queue", reason: "snapshot-in-progress" }
37
- }
38
-
39
- // During watching, apply changes immediately
40
- if (currentMode === "watching") {
41
- if (!fileMeta) {
42
- // New file from remote
43
- return { action: "apply", reason: "new-file" }
44
- }
45
-
46
- // Existing file - trust the remote (we're in steady state)
47
- return { action: "apply", reason: "safe-update" }
48
- }
49
-
50
- // During conflict resolution, queue for now (could be enhanced later)
51
- if (currentMode === "conflict_resolution") {
52
- return { action: "queue", reason: "snapshot-in-progress" }
53
- }
54
-
55
- // Shouldn't receive changes while disconnected
56
- return { action: "reject", reason: "unknown-file" }
57
- }
58
-
59
- /**
60
- * Validates whether an outgoing LOCAL change should be sent to remote
61
- *
62
- * Checks if the local file has actually changed since last sync
63
- * to avoid sending duplicate updates.
64
- *
65
- * Note: This will be used when WATCHER_EVENT is migrated to the state machine.
66
- * Currently, the legacy watcher path always sends changes (with echo prevention).
67
- */
68
- export function validateOutgoingChange(
69
- fileName: string,
70
- content: string,
71
- fileMeta: FileSyncMetadata | undefined
72
- ): { shouldSend: boolean; reason: string } {
73
- const currentHash = hashFileContent(content)
74
-
75
- if (!fileMeta) {
76
- // New local file
77
- return { shouldSend: true, reason: "new-file" }
78
- }
79
-
80
- if (fileMeta.localHash === currentHash) {
81
- // No change since we last saw this file
82
- return { shouldSend: false, reason: "no-change" }
83
- }
84
-
85
- // File has changed
86
- return { shouldSend: true, reason: "changed" }
87
- }