Repository: teleporthq/teleport-code-generators
Pull request #353 (2019-08-26 10:27:03)

refactor(seo): handling default title and canonical link
Addresses some leftovers of #21 

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

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

```diff
@@            Coverage Diff             @@
##           master     #353      +/-   ##
==========================================
- Coverage   77.85%   77.75%   -0.11%     
==========================================
  Files         112      112              
  Lines        3676     3695      +19     
  Branches      777      783       +6     
==========================================
+ Hits         2862     2873      +11     
- Misses        773      782       +9     
+ Partials       41       40       -1
```


| [Impacted Files](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/353?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/teleport-project-generator/src/utils.ts](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/353/diff?src=pr&el=tree#diff-cGFja2FnZXMvdGVsZXBvcnQtcHJvamVjdC1nZW5lcmF0b3Ivc3JjL3V0aWxzLnRz) | `94.01% <100%> (+0.85%)` | :arrow_up: |
| [packages/teleport-project-generator/src/index.ts](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/353/diff?src=pr&el=tree#diff-cGFja2FnZXMvdGVsZXBvcnQtcHJvamVjdC1nZW5lcmF0b3Ivc3JjL2luZGV4LnRz) | `96.55% <100%> (ø)` | :arrow_up: |
| [...kages/teleport-plugin-vue-head-config/src/index.ts](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/353/diff?src=pr&el=tree#diff-cGFja2FnZXMvdGVsZXBvcnQtcGx1Z2luLXZ1ZS1oZWFkLWNvbmZpZy9zcmMvaW5kZXgudHM=) | `100% <100%> (ø)` | :arrow_up: |
| [...kages/teleport-project-generator-next/src/utils.ts](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/353/diff?src=pr&el=tree#diff-cGFja2FnZXMvdGVsZXBvcnQtcHJvamVjdC1nZW5lcmF0b3ItbmV4dC9zcmMvdXRpbHMudHM=) | `87.67% <20%> (-5.39%)` | :arrow_down: |
| [...es/teleport-project-generator/src/file-handlers.ts](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/353/diff?src=pr&el=tree#diff-cGFja2FnZXMvdGVsZXBvcnQtcHJvamVjdC1nZW5lcmF0b3Ivc3JjL2ZpbGUtaGFuZGxlcnMudHM=) | `79.22% <20%> (-1.99%)` | :arrow_down: |
| [...teleport-component-generator/src/resolver/utils.ts](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/353/diff?src=pr&el=tree#diff-cGFja2FnZXMvdGVsZXBvcnQtY29tcG9uZW50LWdlbmVyYXRvci9zcmMvcmVzb2x2ZXIvdXRpbHMudHM=) | `66.14% <50%> (ø)` | :arrow_up: |
| [...kages/teleport-plugin-jsx-head-config/src/index.ts](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/353/diff?src=pr&el=tree#diff-cGFja2FnZXMvdGVsZXBvcnQtcGx1Z2luLWpzeC1oZWFkLWNvbmZpZy9zcmMvaW5kZXgudHM=) | `97.67% <92.3%> (-2.33%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/353?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/353?src=pr&el=footer). Last update [2026260...09ac900](https://codecov.io/gh/teleporthq/teleport-code-generators/pull/353?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 aff0a81b..81b5fa5b 100644
--- a/examples/uidl-samples/project.json
+++ b/examples/uidl-samples/project.json
@@ -37,7 +37,9 @@
             "value": "home",
             "meta": {
               "path": "/",
-              "componentName": "Home",
+              "componentName": "Home"
+            },
+            "seo": {
               "title": "Home Page"
             }
           },
@@ -45,7 +47,9 @@
             "value": "team",
             "meta": {
               "path": "/team",
-              "componentName": "Team",
+              "componentName": "Team"
+            },
+            "seo": {
               "title": "About Page",
               "metaTags": [
                 { "name": "description", "content": "What is this about?" },
@@ -53,6 +57,9 @@
                 { "name": "viewport", "content": "width=device-width, initial-scale=1.0" },
                 { "property": "og:title", "content": "What is this about?" },
                 { "property": "og:url", "content": "/playground_assets/asdasd.png" }
+              ],
+              "assets": [
+                { "type": "canonical", "path": "https://teleporthq.io" }
               ]
             }
           },
@@ -61,6 +68,11 @@
             "meta": {
               "path": "/contact-us",
               "componentName": "ContactPage"
+            },
+            "seo": {
+              "assets": [
+                { "type": "canonical", "path": "https://teleporthq.io" }
+              ]
             }
           }
         ]
diff --git a/packages/teleport-component-generator/src/resolver/utils.ts b/packages/teleport-component-generator/src/resolver/utils.ts
index b90130c8..181af18f 100644
--- a/packages/teleport-component-generator/src/resolver/utils.ts
+++ b/packages/teleport-component-generator/src/resolver/utils.ts
@@ -73,11 +73,11 @@ export const resolveNavlinks = (uidlNode: UIDLNode, routesDefinition: UIDLStateD
 }
 
 export const resolveMetaTags = (uidl: ComponentUIDL, options: GeneratorOptions) => {
-  if (!uidl.meta || !uidl.meta.metaTags || !options.assetsPrefix) {
+  if (!uidl.seo || !uidl.seo.metaTags || !options.assetsPrefix) {
     return
   }
 
-  uidl.meta.metaTags.forEach((tag) => {
+  uidl.seo.metaTags.forEach((tag) => {
     Object.keys(tag).forEach((key) => {
       tag[key] = prefixPlaygroundAssetsURL(options.assetsPrefix, tag[key])
     })
diff --git a/packages/teleport-plugin-jsx-head-config/src/index.ts b/packages/teleport-plugin-jsx-head-config/src/index.ts
index b16fd8d5..836d9398 100644
--- a/packages/teleport-plugin-jsx-head-config/src/index.ts
+++ b/packages/teleport-plugin-jsx-head-config/src/index.ts
@@ -30,16 +30,20 @@ export const createPlugin: ComponentPluginFactory<JSXHeadPluginConfig> = (config
       )
     }
 
+    if (!uidl.seo) {
+      return structure
+    }
+
     const headASTTags = []
 
-    if (uidl.meta && uidl.meta.title) {
+    if (uidl.seo.title) {
       const titleAST = createJSXTag('title')
-      addChildJSXText(titleAST, uidl.meta.title)
+      addChildJSXText(titleAST, uidl.seo.title)
       headASTTags.push(titleAST)
     }
 
-    if (uidl.meta && uidl.meta.metaTags) {
-      uidl.meta.metaTags.forEach((tag) => {
+    if (uidl.seo.metaTags) {
+      uidl.seo.metaTags.forEach((tag) => {
         const metaAST = createSelfClosingJSXTag('meta')
         Object.keys(tag).forEach((key) => {
           addAttributeToJSXTag(metaAST, key, tag[key])
@@ -48,6 +52,18 @@ export const createPlugin: ComponentPluginFactory<JSXHeadPluginConfig> = (config
       })
     }
 
+    if (uidl.seo.assets) {
+      uidl.seo.assets.forEach((asset) => {
+        // TODO: Handle other asset types when needed
+        if (asset.type === 'canonical') {
+          const canonicalLink = createSelfClosingJSXTag('link')
+          addAttributeToJSXTag(canonicalLink, 'rel', 'canonical')
+          addAttributeToJSXTag(canonicalLink, 'href', asset.path)
+          headASTTags.push(canonicalLink)
+        }
+      })
+    }
+
     if (headASTTags.length > 0) {
       const headConfigTag = createJSXTag(configTagIdentifier, headASTTags)
 
diff --git a/packages/teleport-plugin-vue-head-config/src/index.ts b/packages/teleport-plugin-vue-head-config/src/index.ts
index 2009574e..b70d9e45 100644
--- a/packages/teleport-plugin-vue-head-config/src/index.ts
+++ b/packages/teleport-plugin-vue-head-config/src/index.ts
@@ -21,14 +21,24 @@ export const createPlugin: ComponentPluginFactory<VueMetaPluginConfig> = (config
     const headObject: {
       title?: string
       meta?: Array<Record<string, string>>
+      link?: Array<Record<string, string>>
     } = {}
 
-    if (uidl.meta && uidl.meta.title) {
-      headObject.title = uidl.meta.title
+    if (uidl.seo && uidl.seo.title) {
+      headObject.title = uidl.seo.title
     }
 
-    if (uidl.meta && uidl.meta.metaTags) {
-      headObject.meta = uidl.meta.metaTags
+    if (uidl.seo && uidl.seo.metaTags) {
+      headObject.meta = uidl.seo.metaTags
+    }
+
+    if (uidl.seo.assets) {
+      uidl.seo.assets.forEach((asset) => {
+        // TODO: Handle other asset types when needed
+        if (asset.type === 'canonical') {
+          headObject.link = [{ rel: 'canonical', href: asset.path }]
+        }
+      })
     }
 
     if (Object.keys(headObject).length > 0) {
diff --git a/packages/teleport-project-generator-next/src/utils.ts b/packages/teleport-project-generator-next/src/utils.ts
index f6993d67..5e4d850a 100644
--- a/packages/teleport-project-generator-next/src/utils.ts
+++ b/packages/teleport-project-generator-next/src/utils.ts
@@ -1,7 +1,6 @@
 import {
   addAttributeToJSXTag,
   addChildJSXTag,
-  addChildJSXText,
 } from '@teleporthq/teleport-shared/dist/cjs/utils/ast-jsx-utils'
 import { createJSXTag } from '@teleporthq/teleport-shared/dist/cjs/builders/ast-builders'
 
@@ -30,11 +29,7 @@ export const createDocumentFileChunks = (uidl: ProjectUIDL, options: EntryFileOp
     addAttributeToJSXTag(htmlNode, 'lang', settings.language)
   }
 
-  if (settings.title) {
-    const titleTag = createJSXTag('title')
-    addChildJSXText(titleTag, settings.title)
-    addChildJSXTag(headNode, titleTag)
-  }
+  // NOTE: Title is added in per page, not in the layout file
 
   if (manifest) {
     const linkTag = createJSXTag('link')
@@ -55,6 +50,14 @@ export const createDocumentFileChunks = (uidl: ProjectUIDL, options: EntryFileOp
   assets.forEach((asset) => {
     const assetPath = prefixPlaygroundAssetsURL(options.assetsPrefix, asset.path)
 
+    // link canonical for SEO
+    if (asset.type === 'canonical' && assetPath) {
+      const linkTag = createJSXTag('link')
+      addAttributeToJSXTag(linkTag, 'rel', 'canonical')
+      addAttributeToJSXTag(linkTag, 'href', assetPath)
+      addChildJSXTag(headNode, linkTag)
+    }
+
     // link stylesheet (external css, font)
     if ((asset.type === 'style' || asset.type === 'font') && assetPath) {
       const linkTag = createJSXTag('link')
diff --git a/packages/teleport-project-generator/src/file-handlers.ts b/packages/teleport-project-generator/src/file-handlers.ts
index efda8c00..234c9a7f 100644
--- a/packages/teleport-project-generator/src/file-handlers.ts
+++ b/packages/teleport-project-generator/src/file-handlers.ts
@@ -192,6 +192,14 @@ const createHTMLEntryFileChunks = (uidl: ProjectUIDL, options: EntryFileOptions)
   assets.forEach((asset) => {
     const assetPath = prefixPlaygroundAssetsURL(assetsPrefix, asset.path)
 
+    // link canonical for SEO
+    if (asset.type === 'canonical' && assetPath) {
+      const linkTag = createHTMLNode('link')
+      addAttributeToNode(linkTag, 'rel', 'canonical')
+      addAttributeToNode(linkTag, 'href', assetPath)
+      addChildNode(headNode, linkTag)
+    }
+
     // link stylesheet (external css, font)
     if ((asset.type === 'style' || asset.type === 'font') && assetPath) {
       const linkTag = createHTMLNode('link') // , { selfClosing: true })
diff --git a/packages/teleport-project-generator/src/index.ts b/packages/teleport-project-generator/src/index.ts
index cc7a9242..8126a792 100644
--- a/packages/teleport-project-generator/src/index.ts
+++ b/packages/teleport-project-generator/src/index.ts
@@ -61,7 +61,7 @@ export const createProjectGenerator = (strategy: ProjectStrategy): ProjectGenera
     const { components = {}, root } = uidl
 
     // Based on the routing roles, separate pages into distict UIDLs with their own file names and paths
-    const pageUIDLs = createPageUIDLs(root, strategy)
+    const pageUIDLs = createPageUIDLs(uidl, strategy)
 
     // Set the filename and path for each component based on the strategy
     prepareComponentFilenamesAndPath(components, strategy)
diff --git a/packages/teleport-project-generator/src/utils.ts b/packages/teleport-project-generator/src/utils.ts
index fdbe41e5..7c55e1a8 100644
--- a/packages/teleport-project-generator/src/utils.ts
+++ b/packages/teleport-project-generator/src/utils.ts
@@ -11,33 +11,29 @@ import {
   GeneratedFolder,
   UIDLElement,
   ComponentUIDL,
+  ProjectUIDL,
   UIDLConditionalNode,
-  UIDLStateDefinition,
   ProjectStrategy,
 } from '@teleporthq/teleport-types'
 import { elementNode } from '@teleporthq/teleport-shared/dist/cjs/builders/uidl-builders'
 
-export const createPageUIDLs = (
-  root: ComponentUIDL,
-  strategy: ProjectStrategy
-): ComponentUIDL[] => {
-  const routeNodes = extractRoutes(root)
-  return routeNodes.map((routeNode) =>
-    createPageUIDL(routeNode, root.stateDefinitions.route, strategy)
-  )
+export const createPageUIDLs = (uidl: ProjectUIDL, strategy: ProjectStrategy): ComponentUIDL[] => {
+  const routeNodes = extractRoutes(uidl.root)
+  return routeNodes.map((routeNode) => createPageUIDL(routeNode, uidl, strategy))
 }
 
 const createPageUIDL = (
   routeNode: UIDLConditionalNode,
-  routeDefintion: UIDLStateDefinition,
+  uidl: ProjectUIDL,
   strategy: ProjectStrategy
 ): ComponentUIDL => {
   const { value, node } = routeNode.content
   const pageName = value.toString()
+  const routeDefinition = uidl.root.stateDefinitions.route
   const pagesStrategyOptions = strategy.pages.options || {}
 
   const { componentName, fileName } = extractPageMetadata(
-    routeDefintion,
+    routeDefinition,
     pageName,
     strategy.pages.options
   )
@@ -47,7 +43,7 @@ const createPageUIDL = (
   const createPathInOwnFile =
     !pagesStrategyOptions.usePathAsFileName && pagesStrategyOptions.createFolderForEachComponent
 
-  const fileMeta = createPathInOwnFile
+  const meta = createPathInOwnFile
     ? {
         fileName: pagesStrategyOptions.customComponentFileName || 'index',
         styleFileName: pagesStrategyOptions.customStyleFileName || 'style',
@@ -62,10 +58,13 @@ const createPageUIDL = (
       }
 
   // Looking into the state definition, we take the seo information for the corresponding page
-  const pageDefinition = routeDefintion.values.find((stateDef) => stateDef.value === pageName)
-  const seoMeta = pageDefinition.meta
-    ? { title: pageDefinition.meta.title, metaTags: pageDefinition.meta.metaTags }
-    : null
+  // If no title is provided for the page, the global settings title is passed as a default
+  const pageDefinition = routeDefinition.values.find((stateDef) => stateDef.value === pageName)
+  const title = (pageDefinition.seo && pageDefinition.seo.title) || uidl.globals.settings.title
+  const seo = {
+    ...pageDefinition.seo,
+    title,
+  }
 
   // Because conditional nodes accept any type of UIDLNode as a child
   // we need to ensure that the page is always of type 'element'
@@ -75,7 +74,8 @@ const createPageUIDL = (
   return {
     name: componentName,
     node: pageContent,
-    meta: { ...fileMeta, ...seoMeta },
+    meta,
+    seo,
   }
 }
 
diff --git a/packages/teleport-types/src/uidl.ts b/packages/teleport-types/src/uidl.ts
index f59cd02b..53f1f55c 100755
--- a/packages/teleport-types/src/uidl.ts
+++ b/packages/teleport-types/src/uidl.ts
@@ -16,7 +16,7 @@ export interface ProjectUIDL {
 }
 
 export interface GlobalAsset {
-  type: string
+  type: 'script' | 'style' | 'font' | 'canonical' | 'icon'
   path?: string
   content?: string
   options?: {
@@ -37,13 +37,18 @@ export interface ComponentUIDL {
     styleFileName?: string
     templateFileName?: string
     path?: string[]
-    title?: string
-    metaTags?: Array<Record<string, string>>
   }
+  seo?: UIDLComponentSEO
   propDefinitions?: Record<string, UIDLPropDefinition>
   stateDefinitions?: Record<string, UIDLStateDefinition>
 }
 
+export interface UIDLComponentSEO {
+  title?: string
+  metaTags?: Array<Record<string, string>>
+  assets?: GlobalAsset[]
+}
+
 export interface UIDLPropDefinition {
   type: string
   defaultValue?: string | number | boolean | any[] | object | (() => void)
@@ -60,9 +65,8 @@ export interface UIDLStateDefinition {
       componentName?: string
       path?: string
       fileName?: string
-      title?: string
-      metaTags?: Array<Record<string, string>>
     }
+    seo?: UIDLComponentSEO
     transitions?: any
   }>
   actions?: string[]


Test patch:
diff --git a/packages/teleport-plugin-jsx-head-config/__tests__/index.ts b/packages/teleport-plugin-jsx-head-config/__tests__/index.ts
index 339e4e57..22d687fe 100644
--- a/packages/teleport-plugin-jsx-head-config/__tests__/index.ts
+++ b/packages/teleport-plugin-jsx-head-config/__tests__/index.ts
@@ -50,7 +50,7 @@ describe('plugin-jsx-head-config', () => {
   it('Should set the title in the <Helmet> component', async () => {
     const uidlSample = component('SimpleComponent', elementNode('container'))
     uidlSample.node.content.key = 'container'
-    uidlSample.meta = {
+    uidlSample.seo = {
       title: 'Test Title',
     }
 
@@ -78,7 +78,7 @@ describe('plugin-jsx-head-config', () => {
   it('Should set the meta tags in the <Helmet> component', async () => {
     const uidlSample = component('SimpleComponent', elementNode('container'))
     uidlSample.node.content.key = 'container'
-    uidlSample.meta = {
+    uidlSample.seo = {
       metaTags: [
         {
           name: 'description',
@@ -119,4 +119,39 @@ describe('plugin-jsx-head-config', () => {
     expect((randomKeyAttribute.name as types.JSXIdentifier).name).toBe('randomKey')
     expect((randomKeyAttribute.value as types.StringLiteral).value).toBe('randomValue')
   })
+
+  it('Should set the link tag in the <Helmet> for canonical', async () => {
+    const uidlSample = component('SimpleComponent', elementNode('container'))
+    uidlSample.node.content.key = 'container'
+    uidlSample.seo = {
+      assets: [
+        {
+          type: 'canonical',
+          path: 'https://teleporthq.io',
+        },
+      ],
+    }
+
+    const structure: ComponentStructure = {
+      uidl: uidlSample,
+      options: {},
+      chunks: [jsxChunk],
+      dependencies: {},
+    }
+
+    await plugin(structure)
+
+    const astNode = structure.chunks[0].meta.nodesLookup.container as types.JSXElement
+    const helmetNode = astNode.children[0] as types.JSXElement
+    expect((helmetNode.openingElement.name as types.JSXIdentifier).name).toBe('Helmet')
+
+    const linkNode = helmetNode.children[0] as types.JSXElement
+
+    const relAttribute = linkNode.openingElement.attributes[0] as types.JSXAttribute
+    const hrefAttribute = linkNode.openingElement.attributes[1] as types.JSXAttribute
+    expect((relAttribute.name as types.JSXIdentifier).name).toBe('rel')
+    expect((relAttribute.value as types.StringLiteral).value).toBe('canonical')
+    expect((hrefAttribute.name as types.JSXIdentifier).name).toBe('href')
+    expect((hrefAttribute.value as types.StringLiteral).value).toBe('https://teleporthq.io')
+  })
 })
diff --git a/packages/teleport-plugin-vue-head-config/__tests__/index.ts b/packages/teleport-plugin-vue-head-config/__tests__/index.ts
index 70606658..6bd75aec 100644
--- a/packages/teleport-plugin-vue-head-config/__tests__/index.ts
+++ b/packages/teleport-plugin-vue-head-config/__tests__/index.ts
@@ -25,7 +25,7 @@ describe('plugin-vue-head-config', () => {
   it('Should set the title in head object of the component', async () => {
     const uidlSample = component('SimpleComponent', elementNode('container'))
     uidlSample.node.content.key = 'container'
-    uidlSample.meta = {
+    uidlSample.seo = {
       title: 'Test Title',
     }
 
@@ -57,13 +57,13 @@ describe('plugin-vue-head-config', () => {
     const titleProperty = headObject.properties[0] as types.ObjectProperty
 
     expect(titleProperty.key.value).toBe('title')
-    expect(titleProperty.value.value).toBe('Test Title')
+    expect((titleProperty.value as types.StringLiteral).value).toBe('Test Title')
   })
 
-  it('Should set the meta tags in the <Helmet> component', async () => {
+  it('Sets the meta tags in the component object', async () => {
     const uidlSample = component('SimpleComponent', elementNode('container'))
     uidlSample.node.content.key = 'container'
-    uidlSample.meta = {
+    uidlSample.seo = {
       metaTags: [
         {
           name: 'description',
@@ -104,4 +104,46 @@ describe('plugin-vue-head-config', () => {
 
     expect(metaProperty.key.value).toBe('meta')
   })
+
+  it('Sets the link tag for the canonical in the component object', async () => {
+    const uidlSample = component('SimpleComponent', elementNode('container'))
+    uidlSample.node.content.key = 'container'
+    uidlSample.seo = {
+      assets: [
+        {
+          type: 'canonical',
+          path: 'https://teleporthq.io',
+        },
+      ],
+    }
+
+    const jsChunk = {
+      type: CHUNK_TYPE.AST,
+      fileType: FILE_TYPE.JS,
+      name: 'vue-js-chunk',
+      content: {
+        declaration: {
+          properties: [],
+        },
+      },
+      linkAfter: [],
+    }
+
+    const structure: ComponentStructure = {
+      uidl: uidlSample,
+      options: {},
+      chunks: [jsChunk],
+      dependencies: {},
+    }
+
+    await plugin(structure)
+
+    const headProperty = jsChunk.content.declaration.properties[0] as types.ObjectProperty
+    expect((headProperty.key as types.Identifier).name).toBe('head')
+
+    const headObject = headProperty.value as types.ObjectExpression
+    const linkProperty = headObject.properties[0] as types.ObjectProperty
+
+    expect(linkProperty.key.value).toBe('link')
+  })
 })