unframer 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.
@@ -0,0 +1,572 @@
1
+ import { Plugin, build, transform } from 'esbuild'
2
+ import dprint from 'dprint-node'
3
+ import tmp from 'tmp'
4
+
5
+ import pico from 'picocolors'
6
+
7
+ import { polyfillNode } from 'esbuild-plugin-polyfill-node'
8
+
9
+ import {
10
+ ControlDescription,
11
+ ControlType,
12
+ PropertyControls,
13
+ } from '../framer-fixed/dist/framer.js'
14
+ import { fetch as _fetch } from 'native-fetch'
15
+ import fs from 'fs'
16
+ import path from 'path'
17
+ import { execSync } from 'child_process'
18
+
19
+ const __dirname = path.dirname(new URL(import.meta.url).pathname)
20
+ const prefix = '[unframer]'
21
+ export const logger = {
22
+ log(...args) {
23
+ console.log(prefix, ...args)
24
+ },
25
+ error(...args) {
26
+ console.error([prefix, ...args].map((x) => pico.red(x)).join(' '))
27
+ },
28
+ }
29
+
30
+ const fetchWithRetry = retryTwice(_fetch) as typeof fetch
31
+
32
+ function validateUrl(url: string) {
33
+ try {
34
+ const u = new URL(url)
35
+ } catch (e) {
36
+ throw new Error(`Invalid URL: ${url}`)
37
+ }
38
+ }
39
+
40
+ export async function bundle({
41
+ cwd: out = '',
42
+ components = {} as Record<string, string>,
43
+ signal = new AbortController().signal,
44
+ }) {
45
+ out ||= path.resolve(process.cwd(), 'example')
46
+ out = path.resolve(out)
47
+ try {
48
+ fs.rmSync(out, { recursive: true, force: true })
49
+ } catch (e) {}
50
+ fs.mkdirSync(path.resolve(out), { recursive: true })
51
+
52
+ const result = await build({
53
+ // entryPoints: {
54
+ // index: url,
55
+ // },
56
+
57
+ entryPoints: Object.keys(components).map((name) => {
58
+ const url = components[name]
59
+ validateUrl(url)
60
+
61
+ return {
62
+ in: `virtual:${name}`,
63
+ out: name,
64
+ }
65
+ }),
66
+ jsx: 'automatic',
67
+
68
+ bundle: true,
69
+ platform: 'browser',
70
+ metafile: true,
71
+ format: 'esm',
72
+ minify: false,
73
+ treeShaking: true,
74
+ splitting: true,
75
+ // splitting: true,
76
+ logLevel: 'error',
77
+
78
+ pure: ['addPropertyControls'],
79
+ external: whitelist,
80
+ plugins: [
81
+ esbuildPluginBundleDependencies({
82
+ signal,
83
+ }),
84
+ polyfillNode({}),
85
+ {
86
+ name: 'virtual loader',
87
+ setup(build) {
88
+ build.onResolve({ filter: /^virtual:.*/ }, (args) => {
89
+ return {
90
+ path: args.path.replace(/^virtual:/, ''),
91
+ namespace: 'virtual',
92
+ }
93
+ })
94
+ build.onLoad(
95
+ { filter: /.*/, namespace: 'virtual' },
96
+ async (args) => {
97
+ const name = args.path
98
+ const url = components[name]
99
+
100
+ return {
101
+ contents: /** js */ `'use client'
102
+ import Component from '${url}'
103
+ import { WithFramerBreakpoints } from 'unframer/dist/react'
104
+ Component.Responsive = (props) => {
105
+ return <WithFramerBreakpoints Component={Component} {...props} />
106
+ }
107
+ export default Component
108
+ `,
109
+ loader: 'jsx',
110
+ }
111
+ },
112
+ )
113
+ },
114
+ },
115
+ ],
116
+ write: true,
117
+
118
+ // outfile: 'dist/example.js',
119
+ outdir: out,
120
+ // outfile: path.resolve(cwd, sourcefile),
121
+ })
122
+
123
+ fs.writeFileSync(
124
+ path.resolve(out, 'meta.json'),
125
+ JSON.stringify(result.metafile, null, 2),
126
+ 'utf-8',
127
+ )
128
+
129
+ if (signal.aborted) {
130
+ throw new Error('aborted')
131
+ }
132
+ // logger.log('result', result)
133
+
134
+ const files = fs.readdirSync(out)
135
+ for (let file of files) {
136
+ if (!file.endsWith('.js')) {
137
+ continue
138
+ }
139
+ const resultFile = path.resolve(out, file)
140
+ const output = fs.readFileSync(resultFile, 'utf-8')
141
+ logger.log(`formatting`, file)
142
+ const code = dprint.format(resultFile, output, {
143
+ lineWidth: 140,
144
+ quoteStyle: 'alwaysSingle',
145
+ trailingCommas: 'always',
146
+ semiColons: 'always',
147
+ })
148
+ fs.writeFileSync(resultFile, code, 'utf-8')
149
+ const name = file.replace(/\.js$/, '')
150
+ if (components[name]) {
151
+ logger.log(`extracting types for ${name}`)
152
+ const propControls = await extractPropControlsUnsafe(
153
+ resultFile,
154
+ name,
155
+ )
156
+ if (!propControls) {
157
+ logger.log(`no property controls found for ${name}`)
158
+ }
159
+ const types = propControlsToType(propControls)
160
+ // name = 'framer-' + name
161
+ // logger.log('name', name)
162
+
163
+ fs.writeFileSync(path.resolve(out, `${name}.d.ts`), types)
164
+ }
165
+ }
166
+
167
+ // TODO this is a vulnerability, i need to sandbox this somehow
168
+
169
+ // https://framer.com/m/Mega-Menu-2wT3.js@W0zNsrcZ2WAwVuzt0BCl
170
+ // let name = u.pathname
171
+ // .split('/')
172
+ // .slice(-1)[0]
173
+ // // https://regex101.com/r/8prywY/1
174
+ // // .replace(/-[\w\d]{4}\.js/i, '')
175
+ // .replace(/\.js/i, '')
176
+ // .replace(/@.*/, '')
177
+ // .toLowerCase()
178
+ }
179
+
180
+ function decapitalize(str: string) {
181
+ return str.charAt(0).toLowerCase() + str.slice(1)
182
+ }
183
+
184
+ export async function extractPropControlsSafe(text, name) {
185
+ try {
186
+ const propControlsCode = await parsePropertyControls(text)
187
+ // console.log('propControlsCode', propControlsCode)
188
+ const propControls: PropertyControls | undefined =
189
+ await Promise.resolve().then(async () => {
190
+ if (!propControlsCode) return
191
+ const ivm = require('isolated-vm')
192
+ const vm = new ivm.Isolate({ memoryLimit: 128 })
193
+
194
+ const context = vm.createContextSync()
195
+
196
+ const jail = context.global
197
+
198
+ let result = undefined
199
+ context.global.setSync('__return', (x) => {
200
+ result = x
201
+ })
202
+
203
+ const mod = vm.compileModuleSync(`${text}`)
204
+ await mod.instantiateSync(context, (spec, mod) => {
205
+ // TODO instantiate framer, react, framer-motion etc
206
+ return
207
+ })
208
+ await mod.evaluate({})
209
+ return result
210
+ })
211
+ if (!propControls) {
212
+ logger.error(`no property controls found for component ${name}`)
213
+ return
214
+ }
215
+ return propControls
216
+ } catch (e: any) {
217
+ console.error(`Cannot get property controls for ${name}`, e.stack)
218
+ }
219
+ }
220
+
221
+ export async function extractPropControlsUnsafe(filename, name) {
222
+ const packageJson = path.resolve(path.dirname(filename), 'package.json')
223
+ try {
224
+ fs.writeFileSync(
225
+ packageJson,
226
+ JSON.stringify({ type: 'module' }),
227
+ 'utf-8',
228
+ )
229
+ const delimiter = '__delimiter__'
230
+ let propCode = `JSON.stringify(x.default?.propertyControls || null, null, 2)`
231
+ // propCode = `x.default`
232
+ const code = `import(${JSON.stringify(
233
+ filename,
234
+ )}).then(x => { console.log(${JSON.stringify(
235
+ delimiter,
236
+ )}); console.log(${propCode})
237
+ })`
238
+ const res = execSync(`node --input-type=module -e '${code}'`)
239
+ let stdout = res.toString()
240
+ stdout = stdout.split(delimiter)[1]
241
+ // console.log(stdout)
242
+ return safeJsonParse(stdout)
243
+ } catch (e: any) {
244
+ console.error(`Cannot get property controls for ${name}`, e.stack)
245
+ } finally {
246
+ fs.rmSync(packageJson)
247
+ }
248
+ }
249
+
250
+ function safeJsonParse(text) {
251
+ try {
252
+ return JSON.parse(text)
253
+ } catch (e) {
254
+ logger.error('cannot parse json', text.slice(0, 100))
255
+ return null
256
+ }
257
+ }
258
+
259
+ export function propControlsToType(controls?: PropertyControls) {
260
+ try {
261
+ const types = Object.entries(controls || ({} as PropertyControls))
262
+ .map(([key, value]) => {
263
+ if (!value) {
264
+ return
265
+ }
266
+
267
+ const typescriptType = (value: ControlDescription<any>) => {
268
+ value.type
269
+ switch (value.type) {
270
+ case ControlType.Color:
271
+ return 'string'
272
+ case ControlType.Boolean:
273
+ return 'boolean'
274
+ case ControlType.Number:
275
+ return 'number'
276
+ case ControlType.String:
277
+ return 'string'
278
+ case ControlType.Enum: {
279
+ // @ts-expect-error
280
+ const options = value.optionTitles || value.options
281
+ return options.map((x) => `'${x}'`).join(' | ')
282
+ }
283
+ case ControlType.File:
284
+ return 'string'
285
+ case ControlType.Image:
286
+ return 'string'
287
+ case ControlType.ComponentInstance:
288
+ return 'React.ReactNode'
289
+ case ControlType.Array:
290
+ // @ts-expect-error
291
+ return `${typescriptType(value.control)}[]`
292
+ case ControlType.Object:
293
+ // @ts-expect-error
294
+ return `{${Object.entries(value.controls)
295
+ .map(([k, v]) => {
296
+ // @ts-expect-error
297
+ return `${k}: ${typescriptType(v)}`
298
+ })
299
+ .join(', ')}`
300
+ case ControlType.Date:
301
+ return 'string | Date'
302
+ case ControlType.Link:
303
+ return 'string'
304
+ case ControlType.ResponsiveImage:
305
+ return `{src: string, srcSet?: string, alt?: string}`
306
+ case ControlType.FusedNumber:
307
+ return 'number'
308
+ case ControlType.Transition:
309
+ return 'any'
310
+ case ControlType.EventHandler:
311
+ return 'Function'
312
+ }
313
+ }
314
+ let name = decapitalize(value.title || key || '')
315
+ if (!name) {
316
+ return ''
317
+ }
318
+ return ` ${JSON.stringify(name)}?: ${typescriptType(value)}`
319
+ })
320
+ .filter(Boolean)
321
+ .join('\n')
322
+
323
+ const defaultPropsTypes = ` children?: React.ReactNode\n style?: React.CSSProperties\n className?: string\n id?: string\n width?: any\n height?: any\n layoutId?: string\n`
324
+ let t = ''
325
+ t += 'import * as React from "react"\n'
326
+ t += `export interface Props {\n${defaultPropsTypes}${types}\n}\n`
327
+ t += `const Component = (props: Props) => any\n`
328
+ t += `export default Component\n`
329
+ t += `type Breakpoint = 'Desktop' | 'Tablet' | 'Mobile'\n`
330
+ t += `Component.Responsive = (props: Omit<Props, 'variant'> & {variants: Record<Breakpoint, Props['variant']>}) => any\n`
331
+
332
+ return t
333
+ } catch (e: any) {
334
+ logger.error('cannot generate types', e.stack)
335
+ return ''
336
+ }
337
+ }
338
+
339
+ export function parsePropertyControls(code: string) {
340
+ const start = code.indexOf('addPropertyControls(')
341
+ if (start === -1) {
342
+ logger.error('no addPropertyControls call found')
343
+ return null
344
+ }
345
+ // count all parentheses to find when the addPropertyControls ends
346
+ let openParentheses = 0
347
+ let closedParentheses = 0
348
+ let current = start
349
+ // parses using parentheses
350
+ while (current < code.length) {
351
+ const newP = code.indexOf('(', current)
352
+ const newC = code.indexOf(')', current)
353
+ if (newP === -1 && newC === -1) {
354
+ break
355
+ }
356
+ if (newP !== -1 && newP < newC) {
357
+ openParentheses++
358
+ current = newP + 1
359
+ }
360
+ if (newC !== -1 && newC < newP) {
361
+ closedParentheses++
362
+ current = newC + 1
363
+ }
364
+ if (openParentheses === closedParentheses) {
365
+ break
366
+ }
367
+ }
368
+
369
+ const end = current
370
+ const propControls = code.substring(start, end)
371
+ const realStart = propControls.indexOf(',')
372
+ if (realStart === -1) {
373
+ return ''
374
+ }
375
+ return propControls.slice(realStart + 1, -1)
376
+ }
377
+
378
+ const whitelist = [
379
+ 'react',
380
+ 'react-dom',
381
+ 'framer',
382
+ 'unframer',
383
+ 'framer-motion', //
384
+ ]
385
+
386
+ export function esbuildPluginBundleDependencies({
387
+ signal = undefined as AbortSignal | undefined,
388
+ }) {
389
+ const codeCache = new Map()
390
+ let redirectCache = new Map<string, Promise<string>>()
391
+ const plugin: Plugin = {
392
+ name: 'esbuild-plugin',
393
+ setup(build) {
394
+ build.onResolve({ filter: /^https?:\/\// }, (args) => {
395
+ const url = new URL(args.path)
396
+ return {
397
+ path: args.path,
398
+ external: false,
399
+ // sideEffects: false,
400
+ namespace: 'https',
401
+ }
402
+ })
403
+ const resolveDep = (args) => {
404
+ if (signal?.aborted) {
405
+ throw new Error('aborted')
406
+ }
407
+ if (args.path.startsWith('https://')) {
408
+ return {
409
+ path: args.path,
410
+ external: false,
411
+ // sideEffects: false,
412
+ namespace: 'https',
413
+ }
414
+ }
415
+ if (args.path === 'framer') {
416
+ return {
417
+ path: 'unframer/dist/framer',
418
+ external: true,
419
+ }
420
+ }
421
+ if (
422
+ whitelist.some(
423
+ (x) => x === args.path || args.path.startsWith(x + '/'),
424
+ )
425
+ ) {
426
+ return {
427
+ path: args.path,
428
+ external: true,
429
+ }
430
+ }
431
+
432
+ // console.log('resolve', args.path)
433
+ if (args.path.startsWith('.') || args.path.startsWith('/')) {
434
+ const u = new URL(args.path, args.importer).toString()
435
+ // logger.log('resolve', u)
436
+ return {
437
+ path: u,
438
+ namespace: 'https',
439
+ }
440
+ }
441
+
442
+ const url = `https://esm.sh/${args.path}`
443
+
444
+ return {
445
+ path: url,
446
+ namespace: 'https',
447
+ external: false,
448
+ }
449
+ }
450
+ // build.onResolve({ filter: /^\w/ }, resolveDep)
451
+ build.onResolve({ filter: /.*/, namespace: 'https' }, resolveDep)
452
+ build.onLoad({ filter: /.*/, namespace: 'https' }, async (args) => {
453
+ if (signal?.aborted) {
454
+ throw new Error('aborted')
455
+ }
456
+ const url = args.path
457
+ const u = new URL(url)
458
+ const resolved = await resolveRedirect(url, redirectCache)
459
+ if (codeCache.has(url)) {
460
+ const code = await codeCache.get(url)
461
+ return {
462
+ contents: code,
463
+ loader: 'js',
464
+ }
465
+ }
466
+ let loader = 'jsx' as any
467
+ const promise = Promise.resolve().then(async () => {
468
+ logger.log('fetching', url)
469
+ const res = await fetchWithRetry(resolved, { signal })
470
+ if (!res.ok) {
471
+ throw new Error(
472
+ `Cannot fetch ${resolved}: ${res.status} ${res.statusText}`,
473
+ )
474
+ }
475
+ // console.log('type', res.headers.get('content-type'))
476
+ if (
477
+ res.headers
478
+ .get('content-type')
479
+ ?.startsWith('application/json')
480
+ ) {
481
+ loader = 'json'
482
+ return await res.text()
483
+ }
484
+ const text = await res.text()
485
+
486
+ const transformed = await transform(text, {
487
+ define: {
488
+ 'import.meta.url': JSON.stringify(resolved),
489
+ },
490
+ minify: false,
491
+ format: 'esm',
492
+ jsx: 'transform',
493
+ logLevel: 'error',
494
+ loader,
495
+ platform: 'browser',
496
+ })
497
+ // console.log('transformed', resolved)
498
+ return transformed.code
499
+ })
500
+
501
+ codeCache.set(url, promise)
502
+ const code = await promise
503
+
504
+ return {
505
+ contents: code,
506
+
507
+ loader,
508
+ }
509
+ })
510
+ },
511
+ }
512
+ return plugin
513
+ }
514
+
515
+ export async function resolveRedirect(url?: string, redirectCache?: any) {
516
+ if (!url) {
517
+ return ''
518
+ }
519
+ url = url.toString()
520
+ if (redirectCache.has(url)) {
521
+ return await redirectCache.get(url)
522
+ }
523
+
524
+ const p = recursiveResolveRedirect(url)
525
+ redirectCache.set(url, p)
526
+ return await p
527
+ }
528
+
529
+ export async function recursiveResolveRedirect(url?: string) {
530
+ if (!url) {
531
+ return
532
+ }
533
+
534
+ let res = await fetchWithRetry(url, { redirect: 'manual', method: 'HEAD' })
535
+ const loc = res.headers.get('location')
536
+ if (res.status < 400 && res.status >= 300 && loc) {
537
+ // logger.log('redirect', loc)
538
+ return recursiveResolveRedirect(res.headers.get('location') || '')
539
+ }
540
+
541
+ return url
542
+ }
543
+
544
+ function addExtension(p) {
545
+ const ext = path.extname(p)
546
+ logger.log('addExtension', ext)
547
+ if (!ext) {
548
+ return p + '.js'
549
+ }
550
+ if (ext.includes('@')) {
551
+ return p + '.js'
552
+ }
553
+ // if (!p.endsWith('.js')) {
554
+ // return p + '.js'
555
+ // }
556
+ return p
557
+ }
558
+
559
+ function retryTwice<F extends Function>(fn: Function): Function {
560
+ return async (...args) => {
561
+ try {
562
+ return await fn(...args)
563
+ } catch (e: any) {
564
+ // ignore abort errors
565
+ if (e.name === 'AbortError') {
566
+ return
567
+ }
568
+ logger.error('retrying', e.message)
569
+ return await fn(...args)
570
+ }
571
+ }
572
+ }
package/src/framer.ts ADDED
@@ -0,0 +1 @@
1
+ export * from '../framer-fixed/dist/framer.js'