framer-code-link 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.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Utilities for parsing import statements and extracting package information.
3
+ */
4
+
5
+ export interface ImportInfo {
6
+ type: "npm" | "url"
7
+ name: string
8
+ raw: string
9
+ }
10
+
11
+ /**
12
+ * Extract npm and URL-based imports from source code.
13
+ */
14
+ export function extractImports(code: string): ImportInfo[] {
15
+ const imports: ImportInfo[] = []
16
+ const seen = new Set<string>()
17
+
18
+ const npmRegex =
19
+ /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]([^.\/][^'"]+)['"]/g
20
+ const urlRegex =
21
+ /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]https?:\/\/[^'"]+['"]/g
22
+
23
+ let match: RegExpExecArray | null
24
+
25
+ while ((match = npmRegex.exec(code)) !== null) {
26
+ const pkgName = match[1]
27
+ const normalized = pkgName.startsWith("@")
28
+ ? pkgName.split("/").slice(0, 2).join("/")
29
+ : pkgName.split("/")[0]
30
+
31
+ if (!seen.has(normalized)) {
32
+ seen.add(normalized)
33
+ imports.push({
34
+ type: "npm",
35
+ name: normalized,
36
+ raw: match[0],
37
+ })
38
+ }
39
+ }
40
+
41
+ while ((match = urlRegex.exec(code)) !== null) {
42
+ const pkgName = extractPackageFromUrl(match[0])
43
+ if (pkgName && !seen.has(pkgName)) {
44
+ seen.add(pkgName)
45
+ imports.push({
46
+ type: "url",
47
+ name: pkgName,
48
+ raw: match[0],
49
+ })
50
+ }
51
+ }
52
+
53
+ return imports
54
+ }
55
+
56
+ /**
57
+ * Attempt to derive an npm-style package specifier from a URL import.
58
+ */
59
+ export function extractPackageFromUrl(url: string): string | null {
60
+ const match = url.match(/\/(@?[^@\/]+(?:\/[^@\/]+)?)/)
61
+ return match?.[1] ?? null
62
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Logging utilities for consistent output
3
+ */
4
+
5
+ export enum LogLevel {
6
+ DEBUG = 0,
7
+ INFO = 1,
8
+ WARN = 2,
9
+ ERROR = 3,
10
+ }
11
+
12
+ let currentLevel = LogLevel.INFO
13
+
14
+ export function setLogLevel(level: LogLevel): void {
15
+ currentLevel = level
16
+ }
17
+
18
+ export function debug(message: string, ...args: unknown[]): void {
19
+ if (currentLevel <= LogLevel.DEBUG) {
20
+ console.debug(`[DEBUG] ${message}`, ...args)
21
+ }
22
+ }
23
+
24
+ export function info(message: string, ...args: unknown[]): void {
25
+ if (currentLevel <= LogLevel.INFO) {
26
+ console.info(`[INFO] ${message}`, ...args)
27
+ }
28
+ }
29
+
30
+ export function warn(message: string, ...args: unknown[]): void {
31
+ if (currentLevel <= LogLevel.WARN) {
32
+ console.warn(`[WARN] ${message}`, ...args)
33
+ }
34
+ }
35
+
36
+ export function error(message: string, ...args: unknown[]): void {
37
+ if (currentLevel <= LogLevel.ERROR) {
38
+ console.error(`[ERROR] ${message}`, ...args)
39
+ }
40
+ }
41
+
42
+ export function success(message: string, ...args: unknown[]): void {
43
+ if (currentLevel <= LogLevel.INFO) {
44
+ console.log(`✓ ${message}`, ...args)
45
+ }
46
+ }
47
+
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Path manipulation utilities
3
+ */
4
+
5
+ import path from "path"
6
+ import { fileURLToPath } from "url"
7
+
8
+ /**
9
+ * Resolves a path relative to the project directory
10
+ */
11
+ export function resolveProjectPath(
12
+ projectDir: string,
13
+ relativePath: string
14
+ ): string {
15
+ return path.resolve(projectDir, relativePath)
16
+ }
17
+
18
+ /**
19
+ * Gets a relative path from the project directory
20
+ */
21
+ export function getRelativePath(
22
+ projectDir: string,
23
+ absolutePath: string
24
+ ): string {
25
+ return path.relative(projectDir, absolutePath)
26
+ }
27
+
28
+ /**
29
+ * Ensures a directory path ends with a separator
30
+ */
31
+ export function ensureTrailingSlash(dirPath: string): string {
32
+ return dirPath.endsWith(path.sep) ? dirPath : dirPath + path.sep
33
+ }
34
+
35
+ /**
36
+ * Gets the directory name from an import.meta.url (ESM)
37
+ */
38
+ export function getDirname(importMetaUrl: string): string {
39
+ return path.dirname(fileURLToPath(importMetaUrl))
40
+ }
41
+
42
+ /**
43
+ * Normalizes a file path by:
44
+ * - Converting backslashes to forward slashes
45
+ * - Resolving . and .. segments
46
+ * - Removing duplicate slashes
47
+ */
48
+ export function normalizePath(filePath: string): string {
49
+ if (!filePath) return ""
50
+
51
+ const isAbsolute = filePath.startsWith("/")
52
+ const segments = filePath.replace(/\\/g, "/").split("/")
53
+ const stack: string[] = []
54
+
55
+ for (const segment of segments) {
56
+ if (!segment || segment === ".") {
57
+ continue
58
+ }
59
+
60
+ if (segment === "..") {
61
+ if (stack.length > 0) {
62
+ stack.pop()
63
+ }
64
+ continue
65
+ }
66
+
67
+ stack.push(segment)
68
+ }
69
+
70
+ const normalized = stack.join("/")
71
+ if (isAbsolute) {
72
+ return `/${normalized}`
73
+ }
74
+
75
+ return normalized
76
+ }
@@ -0,0 +1,94 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+
4
+ interface PackageJson {
5
+ framerProjectId?: string
6
+ framerProjectName?: string
7
+ name?: string
8
+ version?: string
9
+ [key: string]: unknown
10
+ }
11
+
12
+ export function toPackageName(name: string): string {
13
+ return name
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9-]/g, "-")
16
+ .replace(/^-+|-+$/g, "")
17
+ .replace(/-+/g, "-")
18
+ }
19
+
20
+ export async function findOrCreateProjectDir(
21
+ projectHash: string,
22
+ projectName?: string,
23
+ explicitDir?: string
24
+ ): Promise<string> {
25
+ if (explicitDir) {
26
+ const resolved = path.resolve(explicitDir)
27
+ await fs.mkdir(path.join(resolved, "files"), { recursive: true })
28
+ return resolved
29
+ }
30
+
31
+ const cwd = process.cwd()
32
+ const existing = await findExistingProjectDir(cwd, projectHash)
33
+ if (existing) {
34
+ return existing
35
+ }
36
+
37
+ if (!projectName) {
38
+ throw new Error(
39
+ "Project name is required when creating a new workspace. Pass --name <project name>."
40
+ )
41
+ }
42
+
43
+ const dirName = toPackageName(projectName)
44
+ const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6))
45
+
46
+ await fs.mkdir(path.join(projectDir, "files"), { recursive: true })
47
+ const pkg: PackageJson = {
48
+ name: dirName || projectHash,
49
+ version: "1.0.0",
50
+ private: true,
51
+ framerProjectId: projectHash,
52
+ framerProjectName: projectName,
53
+ }
54
+ await fs.writeFile(
55
+ path.join(projectDir, "package.json"),
56
+ JSON.stringify(pkg, null, 2)
57
+ )
58
+
59
+ return projectDir
60
+ }
61
+
62
+ async function findExistingProjectDir(
63
+ baseDir: string,
64
+ projectHash: string
65
+ ): Promise<string | null> {
66
+ const candidate = path.join(baseDir, "package.json")
67
+ if (await matchesProject(candidate, projectHash)) {
68
+ return baseDir
69
+ }
70
+
71
+ const entries = await fs.readdir(baseDir, { withFileTypes: true })
72
+ for (const entry of entries) {
73
+ if (!entry.isDirectory()) continue
74
+ const dir = path.join(baseDir, entry.name)
75
+ if (await matchesProject(path.join(dir, "package.json"), projectHash)) {
76
+ return dir
77
+ }
78
+ }
79
+
80
+ return null
81
+ }
82
+
83
+ async function matchesProject(
84
+ packageJsonPath: string,
85
+ projectHash: string
86
+ ): Promise<boolean> {
87
+ try {
88
+ const content = await fs.readFile(packageJsonPath, "utf-8")
89
+ const pkg = JSON.parse(content) as PackageJson
90
+ return pkg.framerProjectId === projectHash
91
+ } catch {
92
+ return false
93
+ }
94
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * State persistence helper
3
+ *
4
+ * Persists last sync timestamps along with content hashes.
5
+ * We only trust persisted timestamps if the file content hasn't changed
6
+ * (hash matches), because that means the file wasn't edited while CLI was offline.
7
+ */
8
+
9
+ import fs from "fs/promises"
10
+ import path from "path"
11
+ import { createHash } from "crypto"
12
+ import { debug, warn } from "./logging.js"
13
+ import { normalizePath } from "./paths.js"
14
+
15
+ export interface PersistedFileState {
16
+ timestamp: number // Remote modified timestamp from last sync
17
+ contentHash: string // Hash of content when we received the sync confirmation
18
+ }
19
+
20
+ interface PersistedState {
21
+ version: number
22
+ files: Record<string, PersistedFileState>
23
+ }
24
+
25
+ const STATE_FILE_NAME = ".framer-sync-state.json"
26
+ // Version 2: State machine rewrite - only stores hashes/timestamps for local edit detection
27
+ // Breaking change: users must delete old .framer-sync-state.json files
28
+ const CURRENT_VERSION = 2
29
+ const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".json"]
30
+ const DEFAULT_EXTENSION = ".tsx"
31
+
32
+ export function normalizePersistedFileName(fileName: string): string {
33
+ let normalized = normalizePath(fileName.trim())
34
+ if (
35
+ !SUPPORTED_EXTENSIONS.some((ext) => normalized.toLowerCase().endsWith(ext))
36
+ ) {
37
+ normalized = `${normalized}${DEFAULT_EXTENSION}`
38
+ }
39
+ return normalized
40
+ }
41
+
42
+ /**
43
+ * Hash file content to detect changes
44
+ */
45
+ export function hashFileContent(content: string): string {
46
+ return createHash("sha256").update(content, "utf-8").digest("hex")
47
+ }
48
+
49
+ /**
50
+ * Load persisted state from disk
51
+ */
52
+ export async function loadPersistedState(
53
+ projectDir: string
54
+ ): Promise<Map<string, PersistedFileState>> {
55
+ const statePath = path.join(projectDir, STATE_FILE_NAME)
56
+ const result = new Map<string, PersistedFileState>()
57
+
58
+ try {
59
+ const data = await fs.readFile(statePath, "utf-8")
60
+ const parsed: PersistedState = JSON.parse(data)
61
+
62
+ if (parsed.version !== CURRENT_VERSION) {
63
+ warn(
64
+ `State file version mismatch (expected ${CURRENT_VERSION}, got ${parsed.version}). Ignoring persisted state.`
65
+ )
66
+ return result
67
+ }
68
+
69
+ for (const [fileName, state] of Object.entries(parsed.files)) {
70
+ const normalizedName = normalizePersistedFileName(fileName)
71
+ if (normalizedName !== fileName) {
72
+ debug(
73
+ `Normalized persisted key "${fileName}" -> "${normalizedName}" for compatibility`
74
+ )
75
+ }
76
+ result.set(normalizedName, state)
77
+ }
78
+
79
+ debug(`Loaded persisted state for ${result.size} files`)
80
+ return result
81
+ } catch (err) {
82
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
83
+ debug("No persisted state found (first run)")
84
+ return result
85
+ }
86
+ warn("Failed to load persisted state:", err)
87
+ return result
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Save current state to disk
93
+ */
94
+ export async function savePersistedState(
95
+ projectDir: string,
96
+ state: Map<string, PersistedFileState>
97
+ ): Promise<void> {
98
+ const statePath = path.join(projectDir, STATE_FILE_NAME)
99
+
100
+ const persistedState: PersistedState = {
101
+ version: CURRENT_VERSION,
102
+ files: Object.fromEntries(state.entries()),
103
+ }
104
+
105
+ try {
106
+ await fs.writeFile(statePath, JSON.stringify(persistedState, null, 2))
107
+ debug(`Saved persisted state for ${state.size} files`)
108
+ } catch (err) {
109
+ warn("Failed to save persisted state:", err)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Validate persisted timestamp against current file content
115
+ * Returns the timestamp only if the content hash matches (file unchanged)
116
+ */
117
+ export function validatePersistedTimestamp(
118
+ persistedState: PersistedFileState | undefined,
119
+ currentContent: string
120
+ ): number | null {
121
+ if (!persistedState) {
122
+ return null
123
+ }
124
+
125
+ const currentHash = hashFileContent(currentContent)
126
+
127
+ if (currentHash === persistedState.contentHash) {
128
+ debug(
129
+ `Hash matches for persisted state - trusting timestamp ${persistedState.timestamp}`
130
+ )
131
+ return persistedState.timestamp
132
+ }
133
+
134
+ debug(
135
+ "Hash mismatch for persisted state - file was edited while CLI was offline"
136
+ )
137
+ return null
138
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "resolveJsonModule": true,
10
+ "outDir": "./dist"
11
+ },
12
+ "include": ["src/**/*"]
13
+ }
14
+
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config"
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ pool: "threads", // Fork pool trips tinypool on Node 25; threads stay stable
6
+ },
7
+ })
8
+