Repository: teleporthq/teleport-code-generators (ts)

Issue:
Use `Image` from next/image for when a specific node inside uidl respects the requirements.
NextJS recently released NextJS 10 and a new Image-component to handle images. But it has some specific requirements. So, any node that matches the specific requirements. Please change it’s tag name to `Image` Instead of `img`.

https://nextjs.org/docs/api-reference/next/image

This can be achieved by passing a new plugin for `teleport-project-generator-next`. Since we don’t need to add framework specific business logics inside react-base component. A inspiration for this plugin could be `InlineStyles` Which we can check here https://github.com/teleporthq/teleport-code-generators/blob/development/packages/teleport-plugin-jsx-inline-styles/src/index.ts

But before this switch make sure the next templates are upgraded to Next-10. At the moment we are using Next-9.3 for generating projects.

Pull request description:
feat(next-image): a JSX plugin to use next/image for loading images efficiently
fixes #574 

- Adding the `next/Image` if the image has `width` and `height` alone and of same css unit.
- Upgrading to Next@10 for the project-generation 

Patch:
diff --git a/packages/teleport-plugin-jsx-next-image/package.json b/packages/teleport-plugin-jsx-next-image/package.json
new file mode 100644
index 00000000..0d1b238b
--- /dev/null
+++ b/packages/teleport-plugin-jsx-next-image/package.json
@@ -0,0 +1,34 @@
+{
+    "name": "@teleporthq/teleport-plugin-jsx-next-image",
+    "version": "0.16.3",
+    "description": "A Next-JS plugin for using next/image for images with local assets and dimensions.",
+    "author": "teleportHQ",
+    "license": "MIT",
+    "homepage": "https://teleporthq.io/",
+    "main": "dist/cjs/index.js",
+    "types": "dist/cjs/index.d.ts",
+    "module": "dist/esm/index.js",
+    "sideEffects": false,
+    "repository": {
+      "type": "git",
+      "url": "git+ssh://git@github.com/teleporthq/teleport-code-generators.git"
+    },
+    "bugs": {
+      "url": "https://github.com/teleporthq/teleport-code-generators/issues"
+    },
+    "publishConfig": {
+      "access": "public"
+    },
+    "scripts": {
+      "clean": "rimraf dist",
+      "build": "npm run build:cjs & npm run build:esm",
+      "build:cjs": "tsc -p tsconfig-cjs.json",
+      "build:esm": "tsc -p tsconfig-esm.json"
+    },
+    "dependencies": {
+      "@teleporthq/teleport-types": "^0.16.3",
+      "@teleporthq/teleport-shared": "^0.16.3",
+      "@babel/types": "^7.5.5"
+    }
+}
+  
\ No newline at end of file
diff --git a/packages/teleport-plugin-jsx-next-image/src/index.ts b/packages/teleport-plugin-jsx-next-image/src/index.ts
new file mode 100644
index 00000000..9c2fadba
--- /dev/null
+++ b/packages/teleport-plugin-jsx-next-image/src/index.ts
@@ -0,0 +1,92 @@
+import { ComponentPluginFactory, ComponentPlugin, UIDLDependency } from '@teleporthq/teleport-types'
+import { UIDLUtils } from '@teleporthq/teleport-shared'
+import * as types from '@babel/types'
+
+interface NextImagePluginConfig {
+  componentChunkName: string
+  localAssetFolder: string
+}
+
+/*
+  At the moment, the plugin is very restricted wo work only in the following cases.
+  - When both the units specified for the img match. 
+    Eg - both height and width to have same 'px' identifier
+
+-   When the `img` tag has only width and height specified.
+    https://github.com/vercel/next.js/discussions/18312
+*/
+
+const CSS_REGEX = /^([+-]?(?:\d+|\d*\.\d+))([a-z]*|%)$/
+const NEXT_HEAD_DEPENDENCY: UIDLDependency = {
+  type: 'library',
+  path: 'next/image',
+  version: '10.2.0',
+}
+
+export const createNextImagePlugin: ComponentPluginFactory<NextImagePluginConfig> = (config) => {
+  const { componentChunkName = 'jsx-component', localAssetFolder = 'playground_assets' } =
+    config || {}
+  const nextImagePlugin: ComponentPlugin = async (structure) => {
+    const { chunks, uidl, dependencies } = structure
+    const componentChunk = chunks.find((chunk) => chunk.name === componentChunkName)
+    if (!componentChunk) {
+      return
+    }
+
+    UIDLUtils.traverseElements(uidl.node, (element) => {
+      const { elementType, attrs = {}, style = {}, key } = element
+
+      if (key && elementType === 'img' && Object.keys(style).length === 2) {
+        const imageSource = attrs?.src?.content.toString()
+        if (!imageSource?.startsWith(`/${localAssetFolder}`)) {
+          return
+        }
+
+        if (!(style.hasOwnProperty('width') && style.hasOwnProperty('height'))) {
+          return
+        }
+
+        const { height, width } = style
+        const heightUnit = String(height.content).match(CSS_REGEX)[2]
+        const widthUnit = String(width.content).match(CSS_REGEX)[2]
+
+        if (heightUnit.length === 0 || heightUnit !== widthUnit) {
+          return
+        }
+
+        const jsxTag = ((componentChunk.meta.nodesLookup as unknown) as Record<
+          string,
+          types.JSXElement
+        >)[key]
+
+        if (!jsxTag) {
+          return
+        }
+
+        ;(jsxTag.openingElement.name as types.JSXIdentifier).name = 'Image'
+        ;(jsxTag.closingElement.name as types.JSXIdentifier).name = 'Image'
+        jsxTag.openingElement.attributes = [
+          ...jsxTag.openingElement.attributes,
+          types.jsxAttribute(
+            types.jsxIdentifier('width'),
+            types.jsxExpressionContainer(types.numericLiteral(parseInt(String(width.content), 10)))
+          ),
+          types.jsxAttribute(
+            types.jsxIdentifier('height'),
+            types.jsxExpressionContainer(types.numericLiteral(parseInt(String(height.content), 10)))
+          ),
+        ]
+
+        if (!dependencies.Image) {
+          dependencies.Image = NEXT_HEAD_DEPENDENCY
+        }
+      }
+    })
+
+    return structure
+  }
+
+  return nextImagePlugin
+}
+
+export default createNextImagePlugin()
diff --git a/packages/teleport-plugin-jsx-next-image/tsconfig-cjs.json b/packages/teleport-plugin-jsx-next-image/tsconfig-cjs.json
new file mode 100755
index 00000000..c66b615a
--- /dev/null
+++ b/packages/teleport-plugin-jsx-next-image/tsconfig-cjs.json
@@ -0,0 +1,11 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "dist/cjs",
+    "module": "commonjs",
+    "target": "es5"
+  },
+  "include": [
+    "./src"
+  ]
+}
\ No newline at end of file
diff --git a/packages/teleport-plugin-jsx-next-image/tsconfig-esm.json b/packages/teleport-plugin-jsx-next-image/tsconfig-esm.json
new file mode 100755
index 00000000..5d65f088
--- /dev/null
+++ b/packages/teleport-plugin-jsx-next-image/tsconfig-esm.json
@@ -0,0 +1,12 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "dist/esm",
+    "module": "esnext",
+    "target": "es2019",
+    "moduleResolution": "node"
+  },
+  "include": [
+    "./src"
+  ]
+}
\ No newline at end of file
diff --git a/packages/teleport-project-generator-next/package.json b/packages/teleport-project-generator-next/package.json
index c960ee24..5210a399 100644
--- a/packages/teleport-project-generator-next/package.json
+++ b/packages/teleport-project-generator-next/package.json
@@ -35,7 +35,8 @@
     "@teleporthq/teleport-postprocessor-prettier-js": "^0.16.3",
     "@teleporthq/teleport-project-generator": "^0.16.3",
     "@teleporthq/teleport-shared": "^0.16.3",
-    "@teleporthq/teleport-types": "^0.16.3"
+    "@teleporthq/teleport-types": "^0.16.3",
+    "@teleporthq/teleport-plugin-jsx-next-image": "^0.16.3"
   },
   "gitHead": "b185c3fdb7dc94ff8c7eed63f7edba055fffa8d0"
 }
diff --git a/packages/teleport-project-generator-next/src/index.ts b/packages/teleport-project-generator-next/src/index.ts
index 01374301..e61ca877 100644
--- a/packages/teleport-project-generator-next/src/index.ts
+++ b/packages/teleport-project-generator-next/src/index.ts
@@ -6,6 +6,7 @@ import prettierJS from '@teleporthq/teleport-postprocessor-prettier-js'
 import { Mapping, ReactStyleVariation, FileType } from '@teleporthq/teleport-types'
 import { createStyleSheetPlugin } from '@teleporthq/teleport-plugin-css'
 import importStatementsPlugin from '@teleporthq/teleport-plugin-import-statements'
+import nextImagePlugin from '@teleporthq/teleport-plugin-jsx-next-image'
 
 import { createDocumentFileChunks, configContentGenerator } from './utils'
 import NextMapping from './next-mapping.json'
@@ -26,13 +27,14 @@ const createNextProjectGenerator = () => {
     style: ReactStyleVariation.StyledJSX,
     components: {
       generator: createReactComponentGenerator,
+      plugins: [nextImagePlugin],
       mappings: [NextMapping as Mapping],
       path: ['components'],
     },
     pages: {
       generator: createReactComponentGenerator,
       path: ['pages'],
-      plugins: [headConfigPlugin],
+      plugins: [nextImagePlugin, headConfigPlugin],
       mappings: [NextMapping as Mapping],
       options: {
         useFileNameForNavigation: true,
diff --git a/packages/teleport-project-generator-next/src/project-template.ts b/packages/teleport-project-generator-next/src/project-template.ts
index 9f109953..2d808f17 100644
--- a/packages/teleport-project-generator-next/src/project-template.ts
+++ b/packages/teleport-project-generator-next/src/project-template.ts
@@ -18,9 +18,9 @@ export default {
   "author": "TeleportHQ",
   "license": "MIT",
   "dependencies": {
-    "next": "^9.3.1",
-    "react": "^16.13.0",
-    "react-dom": "^16.13.0"
+    "next": "^10.2.0",
+    "react": "^17.0.2",
+    "react-dom": "^17.0.2"
   }
 }`,
       fileType: 'json',
diff --git a/packages/teleport-project-generator-next/src/utils.ts b/packages/teleport-project-generator-next/src/utils.ts
index e521d627..bde80b1a 100644
--- a/packages/teleport-project-generator-next/src/utils.ts
+++ b/packages/teleport-project-generator-next/src/utils.ts
@@ -11,7 +11,6 @@ import {
   ChunkType,
   FrameWorkConfigOptions,
 } from '@teleporthq/teleport-types'
-// import MagicString from 'magic-string'
 
 export const createDocumentFileChunks = (uidl: ProjectUIDL, options: EntryFileOptions) => {
   const { settings, meta, assets, manifest, customCode } = uidl.globals
diff --git a/packages/teleport-types/src/generators.ts b/packages/teleport-types/src/generators.ts
index eea33a37..e7a4db17 100644
--- a/packages/teleport-types/src/generators.ts
+++ b/packages/teleport-types/src/generators.ts
@@ -36,7 +36,7 @@ export interface ChunkDefinition {
   type: ChunkType
   name: string
   fileType: FileType
-  meta?: Record<string, unknown>
+  meta?: Record<string, unknown> // TODO: updates types here
   content: ChunkContent
   linkAfter: string[]
 }


Test patch:
diff --git a/packages/teleport-plugin-jsx-next-image/Readme.md b/packages/teleport-plugin-jsx-next-image/Readme.md
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/teleport-plugin-jsx-next-image/__tests__/index.ts b/packages/teleport-plugin-jsx-next-image/__tests__/index.ts
new file mode 100644
index 00000000..e53fd839
--- /dev/null
+++ b/packages/teleport-plugin-jsx-next-image/__tests__/index.ts
@@ -0,0 +1,157 @@
+import { component, elementNode, staticNode } from '@teleporthq/teleport-uidl-builders'
+import { createNextImagePlugin } from '../src'
+import {
+  ChunkDefinition,
+  ChunkType,
+  FileType,
+  ComponentStructure,
+} from '@teleporthq/teleport-types'
+import * as types from '@babel/types'
+
+describe('plugin-jsx-next-image', () => {
+  const plugin = createNextImagePlugin()
+  const element = elementNode(
+    'img',
+    {
+      src: staticNode('/playground_assets/image.png'),
+      alt: staticNode('Demo Picture'),
+    },
+    [],
+    null,
+    {
+      width: staticNode('100px'),
+      height: staticNode('100px'),
+    },
+    {}
+  )
+  element.content.key = 'container'
+
+  it(`When a local asset is having only width and height, 
+    the jsx-next-image converts it to use Image Component`, async () => {
+    const componentChunk: ChunkDefinition = {
+      name: 'jsx-component',
+      meta: {
+        nodesLookup: {
+          container: {
+            openingElement: {
+              name: {
+                name: 'img',
+              },
+              attributes: [],
+            },
+            closingElement: {
+              name: {
+                name: 'img',
+              },
+            },
+          },
+        },
+      },
+      type: ChunkType.AST,
+      fileType: FileType.JS,
+      linkAfter: ['import-local'],
+      content: {},
+    }
+    const structureMock: ComponentStructure = {
+      uidl: component('App', element),
+      options: {},
+      chunks: [componentChunk],
+      dependencies: {},
+    }
+
+    const { chunks, dependencies } = await plugin(structureMock)
+    const nodeChunk = (chunks[0].meta.nodesLookup as Record<string, types.JSXElement>)?.container
+
+    expect(nodeChunk).toBeDefined()
+    expect((nodeChunk.openingElement.name as types.JSXIdentifier).name).toBe('Image')
+    expect(Object.keys(dependencies).length).toBe(1)
+    expect(Object.keys(dependencies).includes('Image')).toBe(true)
+  })
+
+  it('Does not convert images with remote souce to use Next Image component', async () => {
+    const componentChunk: ChunkDefinition = {
+      name: 'jsx-component',
+      meta: {
+        nodesLookup: {
+          container: {
+            openingElement: {
+              name: {
+                name: 'img',
+              },
+              attributes: [],
+            },
+            closingElement: {
+              name: {
+                name: 'img',
+              },
+            },
+          },
+        },
+      },
+      type: ChunkType.AST,
+      fileType: FileType.JS,
+      linkAfter: ['import-local'],
+      content: {},
+    }
+    element.content.attrs.src.content = `https://via.placeholder.com/150`
+    const uidl = component('App', element)
+    const structure = {
+      uidl,
+      options: {},
+      chunks: [componentChunk],
+      dependencies: {},
+    }
+
+    const { chunks, dependencies } = await plugin(structure)
+    const nodeChunk = (chunks[0].meta.nodesLookup as Record<string, types.JSXElement>)?.container
+
+    expect(nodeChunk).toBeDefined()
+    expect((nodeChunk.openingElement.name as types.JSXIdentifier).name).toBe('img')
+    expect(Object.keys(dependencies).length).toBe(0)
+    expect(Object.keys(dependencies).includes('Image')).toBe(false)
+  })
+
+  it('Does not convert images with different css unit identifiers', async () => {
+    const componentChunk: ChunkDefinition = {
+      name: 'jsx-component',
+      meta: {
+        nodesLookup: {
+          container: {
+            openingElement: {
+              name: {
+                name: 'img',
+              },
+              attributes: [],
+            },
+            closingElement: {
+              name: {
+                name: 'img',
+              },
+            },
+          },
+        },
+      },
+      type: ChunkType.AST,
+      fileType: FileType.JS,
+      linkAfter: ['import-local'],
+      content: {},
+    }
+    element.content.attrs.src.content = '/playground_assets/image.png'
+    element.content.style.width.content = '100%'
+    const uidl = component('App', element)
+    const structure = {
+      uidl,
+      options: {},
+      chunks: [componentChunk],
+      dependencies: {},
+    }
+
+    const { chunks, dependencies } = await plugin(structure)
+    const nodeChunk = (chunks[0].meta.nodesLookup as Record<string, types.JSXElement>)?.container
+
+    expect(nodeChunk).toBeDefined()
+    expect((nodeChunk.openingElement.name as types.JSXIdentifier).name).toBe('img')
+    expect(Object.keys(dependencies).length).toBe(0)
+    expect(Object.keys(dependencies).includes('Image')).toBe(false)
+  })
+})