Repository: teleporthq/teleport-code-generators
Pull request #439 (2020-03-23 13:57:11)

feat(link) adding basic support for links on any nodes and resolver functionality
Adding support for the link sub-structure for each element node, which is transformed at resolver time into a link/navlink element wrapping the corresponding node

codecov[bot]: # [Codecov](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/439?src=pr&el=h1) Report
> Merging [#439](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/439?src=pr&el=desc) into [master](https://codecov.io/gh/teleporthq/teleport-code-generators/commit/88c0dc0974fa1a2b3034b02fcbc49a95999553d1&el=desc) will **decrease** coverage by `0.35%`.
> The diff coverage is `30.00%`.

[![Impacted file tree graph](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/439/graphs/tree.svg?width=650&height=150&src=pr&token=LI2WPEFm1R)](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/439?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #439      +/-   ##
==========================================
- Coverage   77.16%   76.81%   -0.36%     
==========================================
  Files         138      138              
  Lines        4691     4705      +14     
  Branches      937      944       +7     
==========================================
- Hits         3620     3614       -6     
- Misses       1028     1048      +20     
  Partials       43       43              
```


| [Impacted Files](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/439?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [...ckages/teleport-uidl-validator/src/parser/index.ts](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/439/diff?src=pr&el=tree#diff-cGFja2FnZXMvdGVsZXBvcnQtdWlkbC12YWxpZGF0b3Ivc3JjL3BhcnNlci9pbmRleC50cw==) | `60.00% <25.00%> (-2.75%)` | :arrow_down: |
| [packages/teleport-uidl-resolver/src/utils.ts](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/439/diff?src=pr&el=tree#diff-cGFja2FnZXMvdGVsZXBvcnQtdWlkbC1yZXNvbHZlci9zcmMvdXRpbHMudHM=) | `50.75% <29.03%> (-4.90%)` | :arrow_down: |
| [packages/teleport-uidl-resolver/src/resolver.ts](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/439/diff?src=pr&el=tree#diff-cGFja2FnZXMvdGVsZXBvcnQtdWlkbC1yZXNvbHZlci9zcmMvcmVzb2x2ZXIudHM=) | `96.87% <100.00%> (+0.10%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/439?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/439?src=pr&el=footer). Last update [88c0dc0...2dca22b](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/439?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/examples/uidl-samples/project.json b/examples/uidl-samples/project.json
index 9c7d949b..479c2052 100644
--- a/examples/uidl-samples/project.json
+++ b/examples/uidl-samples/project.json
@@ -13,7 +13,7 @@
       },
       {
         "type": "style",
-        "content": "body{font-family: 'Roboto', sans-serif; color: #2c3e50; -webkit-font-smoothing: antialiased; font-size: 16px;}"
+        "content": "body{font-family: 'Roboto', sans-serif; color: #2c3e50; -webkit-font-smoothing: antialiased; font-size: 16px;} a{color:inherit; text-decoration: none}"
       },
       {
         "type": "icon",
@@ -78,7 +78,7 @@
           {
             "value": "contact-us",
             "pageOptions": {
-              "navLink": "/about-us",
+              "navLink": "/about-us-custom-link",
               "componentName": "ContactPage"
             },
             "seo": {
@@ -159,7 +159,7 @@
                               {
                                 "type": "element",
                                 "content": {
-                                  "elementType": "link",
+                                  "elementType": "text",
                                   "style": {
                                     "color": "white",
                                     "backgroundColor": "#822cec",
@@ -168,8 +168,13 @@
                                     "margin": "auto",
                                     "text-decoration": "none"
                                   },
-                                  "attrs": {
-                                    "url": "https://docs.teleporthq.io/guides/getting-started.html"
+                                  "abilities": {
+                                    "link": {
+                                      "type": "section",
+                                      "content": {
+                                        "id": "test"
+                                      }
+                                    }
                                   },
                                   "children": ["Get Started →"]
                                 }
@@ -406,9 +411,14 @@
                       "children": [{
                         "type": "element",
                         "content": {
-                          "elementType": "navlink",
-                          "attrs": {
-                            "transitionTo": "home"
+                          "elementType": "text",
+                          "abilities": {
+                            "link": {
+                              "type": "navlink",
+                              "content": {
+                                "routeName": "home"
+                              }
+                            }
                           },
                           "children": ["Home"]
                         }
@@ -440,9 +450,14 @@
                       "children": [{
                         "type": "element",
                         "content": {
-                          "elementType": "navlink",
-                          "attrs": {
-                            "transitionTo": "team"
+                          "elementType": "text",
+                          "abilities": {
+                            "link": {
+                              "type": "navlink",
+                              "content": {
+                                "routeName": "team"
+                              }
+                            }
                           },
                           "children": ["Team"]
                         }
@@ -474,9 +489,14 @@
                       "children": [{
                         "type": "element",
                         "content": {
-                          "elementType": "navlink",
-                          "attrs": {
-                            "transitionTo": "contact-us"
+                          "elementType": "text",
+                          "abilities": {
+                            "link": {
+                              "type": "navlink",
+                              "content": {
+                                "routeName": "contact-us"
+                              }
+                            }
                           },
                           "children": ["Contact Us"]
                         }
@@ -497,10 +517,6 @@
           "type": "string",
           "defaultValue": "John Doe"
         },
-        "url": {
-          "type": "string",
-          "defaultValue": "http://lorempixel.com/150/150/"
-        },
         "flag": {
           "type": "string",
           "defaultValue": "🇷🇴"
@@ -526,15 +542,22 @@
             "width": "300px",
             "border": "1px solid #ccc",
             "padding": "10px",
-            "margin": "5px"
+            "margin": "5px",
+            "color": "inherit"
           },
           "children": [
             {
               "type": "element",
               "content": {
                 "elementType": "image",
-                "attrs": {
-                  "url": "$props.url"
+                "abilities": {
+                  "link": {
+                    "type": "url",
+                    "content": {
+                      "url": "$props.link",
+                      "newTab": true
+                    }
+                  }
                 },
                 "style": {
                   "display": "block",
@@ -597,17 +620,17 @@
                   {
                     "type": "element",
                     "content": {
-                      "elementType": "link",
+                      "elementType": "text",
                       "style": {
                         "text-decoration": "none",
                         "color": "#822cec"
                       },
-                      "attrs": {
-                        "url": {
-                          "type": "dynamic",
+                      "abilities": {
+                        "link": {
+                          "type": "mail",
                           "content": {
-                            "referenceType": "prop",
-                            "id": "link"
+                            "mail": "mail@teleporthq.io",
+                            "body": "Hello! is it me you're looking for?"
                           }
                         }
                       },
diff --git a/packages/teleport-component-generator-reactnative/src/react-native-mapping.json b/packages/teleport-component-generator-reactnative/src/react-native-mapping.json
index 1763e774..6f8fce3e 100644
--- a/packages/teleport-component-generator-reactnative/src/react-native-mapping.json
+++ b/packages/teleport-component-generator-reactnative/src/react-native-mapping.json
@@ -111,8 +111,14 @@
       }
     },
     "link": {
-      "elementType": "null",
-      "ignore": true
+      "elementType": "Text",
+      "dependency": {
+        "type": "library",
+        "path": "react-native",
+        "meta": {
+          "namedImport": true
+        }
+      }
     },
     "video": {
       "elementType": "null",
diff --git a/packages/teleport-project-generator-angular/src/project-template.ts b/packages/teleport-project-generator-angular/src/project-template.ts
index 9a136e17..1af20262 100644
--- a/packages/teleport-project-generator-angular/src/project-template.ts
+++ b/packages/teleport-project-generator-angular/src/project-template.ts
@@ -18,28 +18,28 @@ export default {
   },
   "private": true,
   "dependencies": {
-    "@angular/common": "~8.2.3",
-    "@angular/compiler": "~8.2.3",
-    "@angular/core": "~8.2.3",
-    "@angular/forms": "~8.2.3",
-    "@angular/platform-browser": "~8.2.3",
-    "@angular/platform-browser-dynamic": "~8.2.3",
-    "@angular/router": "~8.2.3",
-    "rxjs": "~6.4.0",
+    "@angular/common": "~9.0.7",
+    "@angular/compiler": "~9.0.7",
+    "@angular/core": "~9.0.7",
+    "@angular/forms": "~9.0.7",
+    "@angular/platform-browser": "~9.0.7",
+    "@angular/platform-browser-dynamic": "~9.0.7",
+    "@angular/router": "~9.0.7",
+    "rxjs": "~6.5.4",
     "tslib": "^1.10.0",
-    "zone.js": "~0.9.1",
-    "core-js": "^3.0.1"
+    "core-js": "~3.0.1",
+    "zone.js": "~0.10.2"
   },
   "devDependencies": {
-    "@angular-devkit/build-angular": "~0.803.0",
-    "@angular/cli": "~8.3.0",
-    "@angular/compiler-cli": "~8.2.3",
-    "@angular/language-service": "~8.2.3",
-    "@types/node": "~8.9.4",
-    "codelyzer": "^5.0.0",
-    "ts-node": "~7.0.0",
-    "tslint": "~5.15.0",
-    "typescript": "^3.7.5"
+    "@angular-devkit/build-angular": "~0.900.7",
+    "@angular/cli": "~9.0.7",
+    "@angular/compiler-cli": "~9.0.7",
+    "@angular/language-service": "~9.0.7",
+    "@types/node": "^12.11.1",
+    "codelyzer": "^5.1.2",
+    "ts-node": "~8.3.0",
+    "tslint": "~5.18.0",
+    "typescript": "~3.7.5"
   }
 }`,
       fileType: 'json',
@@ -401,7 +401,7 @@ Thumbs.db`,
         {
           name: 'main',
           content: `
-import './polyfills'
+import './polyfills';
 import { enableProdMode } from '@angular/core';
 import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
 
diff --git a/packages/teleport-project-generator-next/src/next-mapping.json b/packages/teleport-project-generator-next/src/next-mapping.json
index e570fa13..92d88dff 100644
--- a/packages/teleport-project-generator-next/src/next-mapping.json
+++ b/packages/teleport-project-generator-next/src/next-mapping.json
@@ -3,8 +3,8 @@
     "navlink": {
       "elementType": "Link",
       "dependency": {
-          "type": "library",
-          "path": "next/link"
+        "type": "library",
+        "path": "next/link"
       },
       "attrs": {
         "href": { "type": "dynamic", "content": { "referenceType": "attr", "id": "transitionTo" } }
diff --git a/packages/teleport-shared/src/utils/uidl-utils.ts b/packages/teleport-shared/src/utils/uidl-utils.ts
index d9395e46..60829e98 100644
--- a/packages/teleport-shared/src/utils/uidl-utils.ts
+++ b/packages/teleport-shared/src/utils/uidl-utils.ts
@@ -140,7 +140,7 @@ export const traverseNodes = (
 
   switch (node.type) {
     case 'element':
-      const { attrs, children, style } = node.content
+      const { attrs, children, style, abilities } = node.content
       if (attrs) {
         Object.keys(attrs).forEach((attrKey) => {
           traverseNodes(attrs[attrKey], fn, node)
@@ -151,6 +151,10 @@ export const traverseNodes = (
         traverseStyleObject(style, fn, node)
       }
 
+      if (abilities?.link?.type === 'url') {
+        traverseNodes(abilities?.link?.content?.url, fn, node)
+      }
+
       if (children) {
         children.forEach((child) => {
           traverseNodes(child, fn, node)
@@ -566,8 +570,13 @@ export const removeChildNodes = (
   switch (node.type) {
     case 'element':
       if (node.content.children) {
+        // filter this level children
         node.content.children = node.content.children.filter((child) => !criteria(child))
+
+        // call function recursively for remaining children
+        node.content.children.forEach((child) => removeChildNodes(child, criteria))
       }
+
       break
 
     case 'repeat':
@@ -586,6 +595,7 @@ export const removeChildNodes = (
 
     case 'static':
     case 'dynamic':
+    case 'raw':
       break
 
     default:
diff --git a/packages/teleport-types/src/uidl.ts b/packages/teleport-types/src/uidl.ts
index 91bf3dd7..87d608b0 100755
--- a/packages/teleport-types/src/uidl.ts
+++ b/packages/teleport-types/src/uidl.ts
@@ -163,13 +163,17 @@ export interface UIDLElement {
   elementType: string
   name?: string
   key?: string // internal usage
-  selfClosing?: boolean
-  ignore?: boolean
   dependency?: UIDLDependency
   style?: UIDLStyleDefinitions
   attrs?: Record<string, UIDLAttributeValue>
   events?: UIDLEventDefinitions
+  abilities?: {
+    link?: UIDLLinkNode
+    // In the future more element abilities can be added here
+  }
   children?: UIDLNode[]
+  selfClosing?: boolean
+  ignore?: boolean
 }
 
 export type UIDLNode =
@@ -189,6 +193,43 @@ export type UIDLStyleDefinitions = Record<string, UIDLStyleValue>
 
 export type UIDLEventDefinitions = Record<string, UIDLEventHandlerStatement[]>
 
+export interface UIDLURLLinkNode {
+  type: 'url'
+  content: {
+    url: UIDLAttributeValue
+    newTab: boolean
+  }
+}
+
+export interface UIDLSectionLinkNode {
+  type: 'section'
+  content: { id: string }
+}
+
+export interface UIDLNavLinkNode {
+  type: 'navlink'
+  content: { routeName: string }
+}
+export interface UIDLMailLinkNode {
+  type: 'mail'
+  content: {
+    mail: string
+    subject?: string
+    body?: string
+  }
+}
+export interface UIDLPhoneLinkNode {
+  type: 'phone'
+  content: { phone: string }
+}
+
+export type UIDLLinkNode =
+  | UIDLURLLinkNode
+  | UIDLSectionLinkNode
+  | UIDLNavLinkNode
+  | UIDLMailLinkNode
+  | UIDLPhoneLinkNode
+
 export interface UIDLEventHandlerStatement {
   type: string
   modifies?: string
diff --git a/packages/teleport-uidl-resolver/src/html-mapping.json b/packages/teleport-uidl-resolver/src/html-mapping.json
index cb02fc55..bb3adc8b 100644
--- a/packages/teleport-uidl-resolver/src/html-mapping.json
+++ b/packages/teleport-uidl-resolver/src/html-mapping.json
@@ -111,10 +111,6 @@
         "href": {
           "type": "dynamic",
           "content": { "referenceType": "attr", "id": "url" }
-        },
-        "rel": {
-          "type": "static",
-          "content": "noreferrer noopener"
         }
       }
     },
diff --git a/packages/teleport-uidl-resolver/src/resolver.ts b/packages/teleport-uidl-resolver/src/resolver.ts
index 8179c84a..83e9f033 100644
--- a/packages/teleport-uidl-resolver/src/resolver.ts
+++ b/packages/teleport-uidl-resolver/src/resolver.ts
@@ -1,6 +1,7 @@
 import * as utils from './utils'
 import { UIDLUtils } from '@teleporthq/teleport-shared'
 import { ComponentUIDL, UIDLElement, Mapping, GeneratorOptions } from '@teleporthq/teleport-types'
+import { resolveAbilities } from './resolvers/abilities'
 
 /**
  * The resolver takes the input UIDL and converts all the abstract node types into
@@ -40,8 +41,13 @@ export default class Resolver {
 
     utils.checkForIllegalNames(uidl, mapping)
 
+    resolveAbilities(uidl, newOptions)
+
+    // TODO: Rename into apply mappings
     utils.resolveNode(uidl.node, newOptions)
 
+    utils.removeIgnoredNodes(uidl.node)
+
     const nodesLookup = {}
     utils.createNodesLookup(uidl.node, nodesLookup)
     utils.generateUniqueKeys(uidl.node, nodesLookup)
@@ -49,7 +55,7 @@ export default class Resolver {
     utils.ensureDataSourceUniqueness(uidl.node)
 
     // There might be urls that need to be prefixed in the metaTags of the component
-    utils.resolveMetaTags(uidl, options)
+    utils.resolveMetaTags(uidl, newOptions)
 
     return uidl
   }
diff --git a/packages/teleport-uidl-resolver/src/resolvers/abilities/index.ts b/packages/teleport-uidl-resolver/src/resolvers/abilities/index.ts
new file mode 100644
index 00000000..f83c116f
--- /dev/null
+++ b/packages/teleport-uidl-resolver/src/resolvers/abilities/index.ts
@@ -0,0 +1,6 @@
+import { ComponentUIDL, GeneratorOptions } from '@teleporthq/teleport-types'
+import { insertLinks } from './utils'
+
+export const resolveAbilities = (uidl: ComponentUIDL, options: GeneratorOptions) => {
+  uidl.node = insertLinks(uidl.node, options)
+}
diff --git a/packages/teleport-uidl-resolver/src/resolvers/abilities/utils.ts b/packages/teleport-uidl-resolver/src/resolvers/abilities/utils.ts
new file mode 100644
index 00000000..488b67e5
--- /dev/null
+++ b/packages/teleport-uidl-resolver/src/resolvers/abilities/utils.ts
@@ -0,0 +1,169 @@
+import { StringUtils } from '@teleporthq/teleport-shared'
+import { GeneratorOptions, UIDLLinkNode, UIDLElementNode } from '@teleporthq/teleport-types'
+
+export const insertLinks = (
+  node: UIDLElementNode,
+  options: GeneratorOptions,
+  linkInParent: boolean = false
+): UIDLElementNode => {
+  const { abilities, children } = node.content
+  const linkInNode = linkInParent || !!abilities?.link
+
+  // TODO: think of a way to reuse the traversal that modifies the tree
+  node.content.children = children?.map((child) => {
+    if (child.type === 'element') {
+      return insertLinks(child, options, linkInNode)
+    }
+
+    if (child.type === 'repeat') {
+      child.content.node = insertLinks(child.content.node, options, linkInNode)
+    }
+
+    if (child.type === 'conditional' && child.content.node.type === 'element') {
+      child.content.node = insertLinks(child.content.node, options, linkInNode)
+    }
+
+    if (child.type === 'slot' && child.content.fallback?.type === 'element') {
+      child.content.fallback = insertLinks(child.content.fallback, options, linkInNode)
+    }
+
+    return child
+  })
+
+  if (abilities?.link) {
+    if (linkInParent) {
+      console.warn('parent node has a link capability, nesting links is illegal')
+      return node
+    }
+
+    const linkNode = createLinkNode(abilities.link, options)
+    // console.log('link node inserted', JSON.stringify(linkNode, null, 2))
+
+    linkNode.content.children.push(node)
+    return linkNode
+  }
+
+  return node
+}
+
+export const createLinkNode = (link: UIDLLinkNode, options: GeneratorOptions): UIDLElementNode => {
+  switch (link.type) {
+    case 'url': {
+      return {
+        type: 'element',
+        content: {
+          elementType: 'link',
+          attrs: {
+            url: link.content.url,
+            ...(link.content.newTab
+              ? {
+                  target: {
+                    type: 'static',
+                    content: '_blank',
+                  },
+                  rel: {
+                    type: 'static',
+                    content: 'noreferrer noopener',
+                  },
+                }
+              : {}),
+          },
+          children: [],
+        },
+      }
+    }
+
+    case 'section': {
+      return {
+        type: 'element',
+        content: {
+          elementType: 'link',
+          attrs: {
+            url: {
+              type: 'static',
+              content: `#${link.content.id}`,
+            },
+          },
+          children: [],
+        },
+      }
+    }
+
+    case 'navlink': {
+      return {
+        type: 'element',
+        content: {
+          elementType: 'navlink',
+          attrs: {
+            transitionTo: {
+              type: 'static',
+              content: resolveNavlink(link.content.routeName, options),
+            },
+          },
+          children: [],
+        },
+      }
+    }
+
+    case 'mail': {
+      let mailUrl = `mailto:${link.content.mail}?subject=${link.content.subject ?? ''}`
+      if (link.content.body) {
+        mailUrl = mailUrl + `&body=${link.content.body}`
+      }
+
+      return {
+        type: 'element',
+        content: {
+          elementType: 'link',
+          attrs: {
+            url: { type: 'static', content: mailUrl },
+          },
+          children: [],
+        },
+      }
+    }
+
+    case 'phone': {
+      return {
+        type: 'element',
+        content: {
+          elementType: 'link',
+          attrs: {
+            url: { type: 'static', content: `tel:${link.content.phone}` },
+          },
+          children: [],
+        },
+      }
+    }
+
+    default:
+      throw new Error(
+        `createLinkNode called with invalid link type '${(link as UIDLLinkNode).type}'`
+      )
+  }
+}
+
+const resolveNavlink = (routeName: string, options: GeneratorOptions) => {
+  if (options.skipNavlinkResolver) {
+    return routeName
+  }
+
+  if (routeName.startsWith('/')) {
+    // attribute was explicitly set as a custom navlink
+    return routeName
+  }
+
+  const friendlyURL = StringUtils.camelCaseToDashCase(
+    StringUtils.removeIllegalCharacters(routeName)
+  )
+
+  const transitionRoute = options.projectRouteDefinition
+    ? options.projectRouteDefinition.values.find((route) => route.value === routeName)
+    : null
+
+  if (!transitionRoute) {
+    return `/${friendlyURL}`
+  }
+
+  return transitionRoute?.pageOptions?.navLink ?? `/${friendlyURL}`
+}
diff --git a/packages/teleport-uidl-resolver/src/utils.ts b/packages/teleport-uidl-resolver/src/utils.ts
index f624bbdf..be65588e 100644
--- a/packages/teleport-uidl-resolver/src/utils.ts
+++ b/packages/teleport-uidl-resolver/src/utils.ts
@@ -8,10 +8,8 @@ import {
   UIDLRepeatContent,
   UIDLAttributeValue,
   Mapping,
-  UIDLStateDefinition,
   GeneratorOptions,
   ComponentUIDL,
-  UIDLElementNode,
 } from '@teleporthq/teleport-types'
 import deepmerge from 'deepmerge'
 
@@ -47,62 +45,6 @@ export const mergeMappings = (
   }
 }
 
-// Finds all the navlink elements and converts the content of the transitionTo attribute
-// to the actual route value that is defined in the project UIDL, in the routing definition
-export const resolveNavlinks = (uidlNode: UIDLNode, routesDefinition: UIDLStateDefinition) => {
-  UIDLUtils.traverseElements(uidlNode, (element) => {
-    if (element.elementType === 'navlink') {
-      const transitionAttribute = element.attrs.transitionTo
-      if (!transitionAttribute) {
-        // Fallback for when transitionTo is not present
-        console.warn(
-          `transitionTo was missing from element: '${element.elementType}'. Falling back to navlink: '/'`
-        )
-        element.attrs.transitionTo = {
-          type: 'static',
-          content: '/',
-        }
-
-        return
-      }
-
-      if (transitionAttribute.type !== 'static') {
-        throw new Error(
-          `Navlink does not support dynamic 'transitionTo' attributes\n ${JSON.stringify(
-            transitionAttribute,
-            null,
-            2
-          )}`
-        )
-      }
-
-      const transitionState = transitionAttribute.content.toString()
-      if (transitionState.startsWith('/')) {
-        // attribute was explicitly set as a custom navlink
-        return
-      }
-
-      const transitionRoute = routesDefinition.values.find(
-        (route) => route.value === transitionState
-      )
-
-      if (!transitionRoute) {
-        transitionAttribute.content = '/'
-        return
-      }
-
-      if (transitionRoute.pageOptions && transitionRoute.pageOptions.navLink) {
-        transitionAttribute.content = transitionRoute.pageOptions.navLink
-      } else {
-        transitionAttribute.content = `/${transitionState}`
-        console.warn(
-          `No navlink was defined for router state: '${transitionState}'. Falling back to '${transitionAttribute.content}'`
-        )
-      }
-    }
-  })
-}
-
 export const resolveMetaTags = (uidl: ComponentUIDL, options: GeneratorOptions) => {
   if (!uidl.seo || !uidl.seo.metaTags || !options.assetsPrefix) {
     return
@@ -115,6 +57,17 @@ export const resolveMetaTags = (uidl: ComponentUIDL, options: GeneratorOptions)
   })
 }
 
+export const removeIgnoredNodes = (uidlNode: UIDLNode) => {
+  // For now this is only used by react-native that adds some ignore flags in the mapping for certain elements.
+  UIDLUtils.removeChildNodes(uidlNode, (node) => {
+    if (node.type === 'element' && node.content.ignore) {
+      return true // elements mapped with ignore will be removed
+    }
+
+    return false
+  })
+}
+
 export const resolveNode = (uidlNode: UIDLNode, options: GeneratorOptions) => {
   UIDLUtils.traverseNodes(uidlNode, (node, parentNode) => {
     if (node.type === 'element') {
@@ -125,15 +78,6 @@ export const resolveNode = (uidlNode: UIDLNode, options: GeneratorOptions) => {
       resolveRepeat(node.content, parentNode)
     }
   })
-
-  // For now this is only used by react-native that adds some ignore flags in the mapping for certain elements.
-  UIDLUtils.removeChildNodes(uidlNode, (node) => {
-    if (node.type === 'element' && node.content.ignore) {
-      return true // elements mapped with ignore will be removed
-    }
-
-    return false
-  })
 }
 
 export const resolveElement = (element: UIDLElement, options: GeneratorOptions) => {
@@ -144,7 +88,6 @@ export const resolveElement = (element: UIDLElement, options: GeneratorOptions)
     attributes: attributesMapping,
   } = mapping
   const originalElement = element
-  const originalElementType = originalElement.elementType
   const mappedElement = elementsMapping[originalElement.elementType] || {
     elementType: originalElement.elementType, // identity mapping
   }
@@ -155,10 +98,6 @@ export const resolveElement = (element: UIDLElement, options: GeneratorOptions)
   // Mapping the type according to the elements mapping
   originalElement.elementType = mappedElement.elementType
 
-  if (originalElementType === 'navlink' && !options.skipNavlinkResolver) {
-    resolveNavlink(originalElement, options)
-  }
-
   if (mappedElement.ignore) {
     originalElement.ignore = mappedElement.ignore
   }
@@ -217,68 +156,7 @@ export const resolveElement = (element: UIDLElement, options: GeneratorOptions)
 
   if (mappedElement.children) {
     originalElement.children = resolveChildren(mappedElement.children, originalElement.children)
-
-    // Solves an edge case for next.js by passing the styles from the <Link> tag to the <a> tag
-    const anchorChild = originalElement.children.find(
-      (child) => child.type === 'element' && child.content.elementType === 'a'
-    ) as UIDLElementNode
-
-    // only do it if there's a child <a> tag and the original element is a navlink
-    const shouldPassStylesToAnchor =
-      originalElement.style && originalElementType === 'navlink' && anchorChild
-    if (shouldPassStylesToAnchor) {
-      anchorChild.content.style = UIDLUtils.cloneObject(originalElement.style)
-      originalElement.style = {}
-    }
-  }
-}
-
-const resolveNavlink = (element: UIDLElement, options: GeneratorOptions) => {
-  const transitionAttribute = element.attrs && element.attrs.transitionTo
-  if (!transitionAttribute) {
-    // Fallback for when transitionTo is not present
-    element.attrs = element.attrs || {}
-    element.attrs.transitionTo = {
-      type: 'static',
-      content: '/',
-    }
-
-    return
-  }
-
-  if (transitionAttribute.type !== 'static') {
-    throw new Error(
-      `Navlink does not support dynamic 'transitionTo' attributes\n ${JSON.stringify(
-        transitionAttribute,
-        null,
-        2
-      )}`
-    )
   }
-
-  const transitionState = transitionAttribute.content.toString()
-  if (transitionState.startsWith('/')) {
-    // attribute was explicitly set as a custom navlink
-    return
-  }
-
-  const friendlyURL = StringUtils.camelCaseToDashCase(
-    StringUtils.removeIllegalCharacters(transitionState)
-  )
-
-  const transitionRoute = options.projectRouteDefinition
-    ? options.projectRouteDefinition.values.find((route) => route.value === transitionState)
-    : null
-
-  if (!transitionRoute) {
-    transitionAttribute.content = `/${friendlyURL}`
-    return
-  }
-
-  transitionAttribute.content =
-    transitionRoute.pageOptions && transitionRoute.pageOptions.navLink
-      ? transitionRoute.pageOptions.navLink
-      : `/${friendlyURL}`
 }
 
 export const resolveChildren = (mappedChildren: UIDLNode[], originalChildren: UIDLNode[] = []) => {
diff --git a/packages/teleport-uidl-validator/src/parser/index.ts b/packages/teleport-uidl-validator/src/parser/index.ts
index 6da8fabe..94f73146 100644
--- a/packages/teleport-uidl-validator/src/parser/index.ts
+++ b/packages/teleport-uidl-validator/src/parser/index.ts
@@ -52,10 +52,9 @@ export const parseProjectJSON = (
   if (result.components) {
     result.components = Object.keys(result.components).reduce(
       (parsedComponnets: Record<string, ComponentUIDL>, key) => {
-        parsedComponnets[key] = parseComponentJSON((result.components[key] as unknown) as Record<
-          string,
-          unknown
-        >)
+        parsedComponnets[key] = parseComponentJSON(
+          (result.components[key] as unknown) as Record<string, unknown>
+        )
         return parsedComponnets
       },
       {}
@@ -82,6 +81,15 @@ const parseComponentNode = (node: Record<string, unknown>): UIDLNode => {
         )
       }
 
+      // @ts-ignore
+      if (elementContent.abilities?.link) {
+        // @ts-ignore
+        const { content, type } = elementContent.abilities?.link
+        if (type === 'url' && typeof content.url === 'string') {
+          content.url = UIDLUtils.transformStringAssignmentToJson(content.url)
+        }
+      }
+
       if (Array.isArray(elementContent.children)) {
         elementContent.children = elementContent.children.map((child) => {
           if (typeof child === 'string') {
@@ -98,8 +106,9 @@ const parseComponentNode = (node: Record<string, unknown>): UIDLNode => {
       const conditionalNode = (node as unknown) as UIDLConditionalNode
       const { reference } = conditionalNode.content
 
-      conditionalNode.content.node = parseComponentNode((conditionalNode.content
-        .node as unknown) as Record<string, unknown>)
+      conditionalNode.content.node = parseComponentNode(
+        (conditionalNode.content.node as unknown) as Record<string, unknown>
+      )
 
       if (typeof reference === 'string') {
         conditionalNode.content.reference = UIDLUtils.transformStringAssignmentToJson(
@@ -113,10 +122,9 @@ const parseComponentNode = (node: Record<string, unknown>): UIDLNode => {
       const repeatNode = (node as unknown) as UIDLRepeatNode
       const { dataSource } = repeatNode.content
 
-      repeatNode.content.node = parseComponentNode((repeatNode.content.node as unknown) as Record<
-        string,
-        unknown
-      >) as UIDLElementNode
+      repeatNode.content.node = parseComponentNode(
+        (repeatNode.content.node as unknown) as Record<string, unknown>
+      ) as UIDLElementNode
 
       if (typeof dataSource === 'string') {
         repeatNode.content.dataSource = UIDLUtils.transformStringAssignmentToJson(dataSource)
@@ -128,11 +136,9 @@ const parseComponentNode = (node: Record<string, unknown>): UIDLNode => {
       const slotNode = (node as unknown) as UIDLSlotNode
 
       if (slotNode.content.fallback) {
-        slotNode.content.fallback = parseComponentNode((slotNode.content
-          .fallback as unknown) as Record<string, unknown>) as
-          | UIDLElementNode
-          | UIDLStaticValue
-          | UIDLDynamicReference
+        slotNode.content.fallback = parseComponentNode(
+          (slotNode.content.fallback as unknown) as Record<string, unknown>
+        ) as UIDLElementNode | UIDLStaticValue | UIDLDynamicReference
       }
 
       return slotNode
diff --git a/packages/teleport-uidl-validator/src/uidl-schemas/component.json b/packages/teleport-uidl-validator/src/uidl-schemas/component.json
index bcddb627..f16e0edf 100644
--- a/packages/teleport-uidl-validator/src/uidl-schemas/component.json
+++ b/packages/teleport-uidl-validator/src/uidl-schemas/component.json
@@ -299,6 +299,12 @@
         "key": {"type": "string"},
         "dependency": {"$ref": "#/definitions/componentDependency"},
         "style": {"$ref": "#/definitions/styleDefinitions"},
+        "abilities": {
+          "type": "object",
+          "properties": {
+            "link": {"$ref": "#/definitions/linkDefinition"}
+          }
+        },
         "attrs": {
           "type": "object",
            "patternProperties": {
@@ -365,6 +371,13 @@
         "content":{"$ref": "#/definitions/styleDefinitions"}
       }
     },
+    "linkDefinition": {
+      "type": "object",
+      "properties": {
+        "type": { "type": "string", "enum": ["url", "section", "navlink", "mail", "phone"] },
+        "content": { "type": "object" }
+      }
+    },
     "eventDefinitions":{
       "type": "object",
       "patternProperties": {
diff --git a/yarn.lock b/yarn.lock
index bfa72170..524c8382 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2784,6 +2784,12 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
     safe-buffer "^5.0.1"
     sha.js "^2.4.8"
 
+cross-env@^7.0.2:
+  version "7.0.2"
+  resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.2.tgz#bd5ed31339a93a3418ac4f3ca9ca3403082ae5f9"
+  dependencies:
+    cross-spawn "^7.0.1"
+
 cross-fetch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.4.tgz#7bef7020207e684a7638ef5f2f698e24d9eb283c"
@@ -2809,7 +2815,7 @@ cross-spawn@^5.0.1:
     shebang-command "^1.2.0"
     which "^1.2.9"
 
-cross-spawn@^7.0.0:
+cross-spawn@^7.0.0, cross-spawn@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.1.tgz#0ab56286e0f7c24e153d04cc2aa027e43a9a5d14"
   dependencies:


Test patch:
diff --git a/packages/teleport-test/package.json b/packages/teleport-test/package.json
index 471d5fd9..2061221b 100644
--- a/packages/teleport-test/package.json
+++ b/packages/teleport-test/package.json
@@ -19,8 +19,8 @@
     "access": "public"
   },
   "scripts": {
-    "packer": "TS_NODE_FILES=true ts-node --project tsconfig.json ./src/packer.ts",
-    "standalone": "TS_NODE_FILES=true ts-node --project tsconfig.json ./src/standalone.ts",
+    "packer": "cross-env TS_NODE_FILES=true ts-node --project tsconfig.json ./src/packer.ts",
+    "standalone": "cross-env TS_NODE_FILES=true ts-node --project tsconfig.json ./src/standalone.ts",
     "bundle": "webpack --config webpack.config.js"
   },
   "dependencies": {
@@ -41,6 +41,7 @@
     "@teleporthq/teleport-types": "^0.10.7"
   },
   "devDependencies": {
+    "cross-env": "^7.0.2",
     "ts-node": "^8.6.2",
     "typescript": "^3.7.5",
     "webpack": "^4.40.2",
diff --git a/packages/teleport-uidl-resolver/__tests__/abilities/mocks.ts b/packages/teleport-uidl-resolver/__tests__/abilities/mocks.ts
new file mode 100644
index 00000000..57ab38c0
--- /dev/null
+++ b/packages/teleport-uidl-resolver/__tests__/abilities/mocks.ts
@@ -0,0 +1,46 @@
+import {
+  UIDLNavLinkNode,
+  UIDLURLLinkNode,
+  UIDLMailLinkNode,
+  UIDLPhoneLinkNode,
+  UIDLSectionLinkNode,
+} from '@teleporthq/teleport-types'
+import { staticNode } from '@teleporthq/teleport-uidl-builders'
+
+export const urlMockedDefinition = (openInNewTab: boolean = false): UIDLURLLinkNode => ({
+  type: 'url',
+  content: {
+    url: staticNode('https://teleporthq.io'),
+    newTab: openInNewTab,
+  },
+})
+
+export const navlinkMockedDefinition = (): UIDLNavLinkNode => ({
+  type: 'navlink',
+  content: {
+    routeName: 'home',
+  },
+})
+
+export const phoneMockedDefinition = (): UIDLPhoneLinkNode => ({
+  type: 'phone',
+  content: {
+    phone: '091837864834',
+  },
+})
+
+export const sectionMockedDefinition = (): UIDLSectionLinkNode => ({
+  type: 'section',
+  content: {
+    id: 'contact',
+  },
+})
+
+export const mailMockedDefinition = (): UIDLMailLinkNode => ({
+  type: 'mail',
+  content: {
+    mail: 'test@teleporthq.io',
+    subject: 'Hello',
+    body: "Is it me you're looking for?",
+  },
+})
diff --git a/packages/teleport-uidl-resolver/__tests__/abilities/utils.ts b/packages/teleport-uidl-resolver/__tests__/abilities/utils.ts
new file mode 100644
index 00000000..bbe17771
--- /dev/null
+++ b/packages/teleport-uidl-resolver/__tests__/abilities/utils.ts
@@ -0,0 +1,126 @@
+import { insertLinks, createLinkNode } from '../../src/resolvers/abilities/utils'
+import { elementNode } from '@teleporthq/teleport-uidl-builders'
+import {
+  urlMockedDefinition,
+  navlinkMockedDefinition,
+  phoneMockedDefinition,
+  mailMockedDefinition,
+  sectionMockedDefinition,
+} from './mocks'
+import { UIDLElementNode, UIDLURLLinkNode } from '@teleporthq/teleport-types'
+
+describe('insertLink', () => {
+  it('wraps a simple element', () => {
+    const node = elementNode('container')
+    const link = urlMockedDefinition() as UIDLURLLinkNode
+    node.content.abilities = { link }
+
+    const result = insertLinks(node, {}, false)
+    expect(result.content.elementType).toBe('link')
+    expect(result.content.attrs.url.content).toBe(link.content.url.content)
+  })
+
+  it('replaces a child', () => {
+    const node = elementNode('container', {}, [
+      elementNode('container'),
+      elementNode('container'),
+      elementNode('container'),
+    ])
+
+    const secondChild = node.content.children[1] as UIDLElementNode
+
+    const link = urlMockedDefinition() as UIDLURLLinkNode
+    secondChild.content.abilities = { link }
+
+    const result = insertLinks(node, {}, false)
+    const secondChildAfterInsertLinks = result.content.children[1] as UIDLElementNode
+
+    expect(secondChildAfterInsertLinks.content.elementType).toBe('link')
+    expect(secondChildAfterInsertLinks.content.attrs.url.content).toBe(link.content.url.content)
+  })
+
+  it('works with a navlink without page settings', () => {
+    const node = elementNode('container', {}, [
+      elementNode('container'),
+      elementNode('container'),
+      elementNode('container'),
+    ])
+
+    const secondChild = node.content.children[1] as UIDLElementNode
+
+    const navlink = navlinkMockedDefinition()
+    secondChild.content.abilities = { link: navlink }
+
+    const result = insertLinks(node, {}, false)
+    const secondChildAfterInsertLinks = result.content.children[1] as UIDLElementNode
+
+    expect(secondChildAfterInsertLinks.content.elementType).toBe('navlink')
+    expect(secondChildAfterInsertLinks.content.attrs.transitionTo.content).toBe(
+      `/${navlink.content.routeName}`
+    )
+  })
+
+  it('works with a navlink with page settings', () => {
+    const node = elementNode('container', {}, [
+      elementNode('container'),
+      elementNode('container'),
+      elementNode('container'),
+    ])
+
+    const secondChild = node.content.children[1] as UIDLElementNode
+
+    const navlink = navlinkMockedDefinition()
+    secondChild.content.abilities = { link: navlink }
+
+    const result = insertLinks(
+      node,
+      {
+        projectRouteDefinition: {
+          type: 'route',
+          defaultValue: 'home',
+          values: [
+            {
+              value: 'home',
+              pageOptions: {
+                navLink: '/main-page',
+              },
+            },
+          ],
+        },
+      },
+      false
+    )
+    const secondChildAfterInsertLinks = result.content.children[1] as UIDLElementNode
+
+    expect(secondChildAfterInsertLinks.content.elementType).toBe('navlink')
+    expect(secondChildAfterInsertLinks.content.attrs.transitionTo.content).toBe(`/main-page`)
+  })
+})
+
+describe('createLink', () => {
+  it('creates a phone link', () => {
+    const link = phoneMockedDefinition()
+    const result = createLinkNode(link, {})
+
+    expect(result.content.elementType).toBe('link')
+    expect(result.content.attrs.url.content).toBe(`tel:${link.content.phone}`)
+  })
+
+  it('creates a mail link', () => {
+    const link = mailMockedDefinition()
+    const result = createLinkNode(link, {})
+
+    expect(result.content.elementType).toBe('link')
+    expect(result.content.attrs.url.content).toBe(
+      `mailto:${link.content.mail}?subject=${link.content.subject}&body=${link.content.body}`
+    )
+  })
+
+  it('creates a section link', () => {
+    const link = sectionMockedDefinition()
+    const result = createLinkNode(link, {})
+
+    expect(result.content.elementType).toBe('link')
+    expect(result.content.attrs.url.content).toBe(`#${link.content.id}`)
+  })
+})
diff --git a/packages/teleport-uidl-resolver/__tests__/utils.ts b/packages/teleport-uidl-resolver/__tests__/utils.ts
index df14254c..25143b4f 100644
--- a/packages/teleport-uidl-resolver/__tests__/utils.ts
+++ b/packages/teleport-uidl-resolver/__tests__/utils.ts
@@ -10,18 +10,11 @@ import {
   generateUniqueKeys,
   createNodesLookup,
   resolveChildren,
-  resolveNavlinks,
   ensureDataSourceUniqueness,
   mergeMappings,
   checkForIllegalNames,
 } from '../src/utils'
-import {
-  UIDLElement,
-  UIDLNode,
-  UIDLStateDefinition,
-  UIDLRepeatNode,
-  Mapping,
-} from '@teleporthq/teleport-types'
+import { UIDLElement, UIDLNode, UIDLRepeatNode, Mapping } from '@teleporthq/teleport-types'
 import mapping from './mapping.json'
 
 describe('generateUniqueKeys', () => {
@@ -235,62 +228,6 @@ describe('resolveChildren', () => {
   })
 })
 
-describe('resolveNavlinks', () => {
-  const routeDef: UIDLStateDefinition = {
-    type: 'string',
-    defaultValue: 'home-link',
-    values: [
-      {
-        value: 'home-link',
-        pageOptions: {
-          navLink: '/home',
-        },
-      },
-    ],
-  }
-
-  it('replaces the transitionTo attribute content', () => {
-    const navlink = elementNode('navlink', {
-      transitionTo: staticNode('home-link'),
-    })
-
-    const uidlNode = elementNode('container', {}, [elementNode('div', {}, [navlink])])
-
-    resolveNavlinks(uidlNode, routeDef)
-    expect(navlink.content.attrs.transitionTo.content).toBe('/home')
-  })
-
-  it('does not change an attribute which starts with /', () => {
-    const navlink = elementNode('navlink', {
-      transitionTo: staticNode('/home-link'),
-    })
-
-    const uidlNode = elementNode('container', {}, [elementNode('div', {}, [navlink])])
-
-    resolveNavlinks(uidlNode, routeDef)
-    expect(navlink.content.attrs.transitionTo.content).toBe('/home-link')
-  })
-
-  it('throws an error for dynamic attributes', () => {
-    const navlink = elementNode('navlink', {
-      transitionTo: dynamicNode('prop', 'path'),
-    })
-
-    expect(() => resolveNavlinks(navlink, routeDef)).toThrow(
-      "Navlink does not support dynamic 'transitionTo' attributes"
-    )
-  })
-
-  it('throws an error if no route is present', () => {
-    const navlink = elementNode('navlink', {
-      transitionTo: staticNode('non-existing-state'),
-    })
-
-    resolveNavlinks(navlink, routeDef)
-    expect(navlink.content.attrs.transitionTo.content).toBe('/')
-  })
-})
-
 describe('mergeMappings', () => {
   const oldMapping: Mapping = {
     elements: {