Skip to content

Commit

Permalink
feat: create initial tuono-html-tags logic
Browse files Browse the repository at this point in the history
  • Loading branch information
Valerioageno committed Oct 20, 2024
1 parent b0bfaf6 commit e764322
Show file tree
Hide file tree
Showing 16 changed files with 368 additions and 46 deletions.
2 changes: 1 addition & 1 deletion packages/html-tags/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ Check [tuono](https://tuono.dev) for more.

## Credits

This package is a fork of [s-yadav/react-meta-tags](https://github.com/s-yadav/react-meta-tags).
This package is heavily inspired by [s-yadav/react-meta-tags](https://github.com/s-yadav/react-meta-tags).
24 changes: 22 additions & 2 deletions packages/html-tags/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
"name": "tuono-html-tags",
"version": "0.10.4",
"description": "Tuono package that handles the HTML tags",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
"format": "prettier -u --write --ignore-unknown '**/*'",
"format:check": "prettier --check --ignore-unknown '**/*'",
"types": "tsc --noEmit",
"test:watch": "vitest",
"test": "vitest run"
},
"keywords": [],
"author": "Valerio Ageno",
Expand All @@ -19,6 +25,16 @@
"README.md"
],
"exports": {
"./server": {
"import": {
"types": "./dist/esm/server/index.d.ts",
"default": "./dist/esm/server/index.js"
},
"require": {
"types": "./dist/cjs/server/index.d.ts",
"default": "./dist/cjs/server/index.js"
}
},
".": {
"import": {
"types": "./dist/esm/index.d.ts",
Expand All @@ -37,6 +53,10 @@
},
"devDependencies": {
"@tanstack/config": "^0.7.11",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"vitest": "^2.0.0"
}
}
5 changes: 5 additions & 0 deletions packages/html-tags/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import MetaTagsContext from './meta-tags-context'
import MetaTags from './meta-tags'

export default MetaTags
export { MetaTags, MetaTagsContext }
33 changes: 33 additions & 0 deletions packages/html-tags/src/meta-tags-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { createContext, useContext } from 'react'
import type { ReactNode } from 'react'

type ExtractFn = (elements: ReactNode) => void

interface MetaTagsContextValues {
extract?: ExtractFn
}

const MetaContext = createContext<MetaTagsContextValues>({})

interface MetaTagsContextProps {
children: ReactNode
extract?: ExtractFn
}

export default function MetaContextProvider({
extract,
children,
}: MetaTagsContextProps): ReactNode {
return (
<MetaContext.Provider
value={{
extract,
}}
>
{children}
</MetaContext.Provider>
)
}

export const useMetaTagsContext = (): MetaTagsContextValues =>
useContext(MetaContext)
25 changes: 25 additions & 0 deletions packages/html-tags/src/meta-tags.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as React from 'react'
import { afterEach, describe, expect, test } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import '@testing-library/jest-dom'
import MetaContextProvider from './meta-tags-context'
import MetaTags from './meta-tags'

describe('Should correctly render the head tags', () => {
afterEach(() => {
cleanup()
})

test('It should correctly render the head element', async () => {
await React.act(async () => {
render(
<MetaContextProvider>
<MetaTags>
<title>Tuono</title>
</MetaTags>
</MetaContextProvider>,
)
})
expect(document.title).toEqual('Tuono')
})
})
40 changes: 40 additions & 0 deletions packages/html-tags/src/meta-tags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { useMetaTagsContext } from './meta-tags-context'
import { appendChild } from './utils'

interface MetaTagsProps {
children: ReactNode
}

export default function MetaTags({ children }: MetaTagsProps): ReactNode {
const [lastChildren, setLastChildren] = useState<ReactNode>(children)
const elementRef = useRef<HTMLDivElement>(null)
const { extract } = useMetaTagsContext()
const [isClient, setIsClient] = useState<boolean>(false)

const handleChildrens = (): void => {
if (extract || !children) return
if (children === lastChildren) return

setLastChildren(children)

const childNodes = Array.prototype.slice.call(elementRef.current?.children)

appendChild(document.head, childNodes)
}

useEffect(() => {
handleChildrens()
}, [handleChildrens, isClient])

useEffect(() => {
setIsClient(true)
}, [setIsClient])

if (!isClient && extract) {
extract(children)
}

return <div ref={elementRef}>{isClient ? children : <></>}</div>
}
3 changes: 3 additions & 0 deletions packages/html-tags/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import MetaTagsServer from './meta-tags-server'

export default MetaTagsServer
26 changes: 26 additions & 0 deletions packages/html-tags/src/server/meta-tags-server.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as React from 'react'
import { afterEach, describe, expect, test } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import '@testing-library/jest-dom'
import MetaContextProvider from '../meta-tags-context'
import MetaTagsServer from './meta-tags-server'
import MetaTags from '../meta-tags'

describe('Should correctly render the head tags', () => {
afterEach(() => {
cleanup()
})

test('It should correctly render the html elements', () => {
const metaTagsServer = MetaTagsServer()
render(
<MetaContextProvider extract={metaTagsServer.extract}>
<MetaTags>
<title>Tuono</title>
</MetaTags>
</MetaContextProvider>,
)

expect(metaTagsServer.renderToString()).toBe('<title>Tuono</title>')
})
})
26 changes: 26 additions & 0 deletions packages/html-tags/src/server/meta-tags-server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'

interface MetaServerTagsOut {
extract: (el: ReactNode) => void
renderToString: () => string
}

/*
* Why not using a class?
* Developing this fn as class resulted in a broken server side rendering. It might
* be because a rust v8 engine issue but I'm not sure about that.
*
*/
export default function MetaTagsServer(): MetaServerTagsOut {
let headElms: ReactNode

return {
extract(elms: ReactNode): void {
headElms = elms
},
renderToString(): string {
return renderToStaticMarkup(headElms)
},
}
}
130 changes: 130 additions & 0 deletions packages/html-tags/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { ReactNode } from 'react'

const camelCaseProps = ['itemProp']
const uniqueIdentifiersI = ['property', 'name', 'itemprop']
const uniqueIdentifiers = uniqueIdentifiersI.concat(camelCaseProps) //case sensitive props is defined in case anyone defined the lowercase prop
const uniqueIdentifiersAll = uniqueIdentifiers.concat(['id'])

/**
Note:
1. In server side we will add meta tags and title at last after fitering
2. In client we will match and replace meta tagString
3. For now we will not support link and other tags properly, they can be added but we will not check for uniqueness and will not decide placement
**/

function filterOutMetaWithId(metas) {
metas = Array.prototype.slice.call(metas || [])
return metas.filter((meta) => !meta.id)
}

export function filterAndArrangeTags(headElms: ReactNode[]): ReactNode[] {
let title = null
let canonicalLink = null
const metas = []
const rest = []

headElms.forEach((elm) => {
const { type, props } = elm
if (type === 'title') {
title = elm
} else if (type === 'link' && props.rel === 'canonical') {
canonicalLink = elm
} else if (type === 'meta') {
metas.push(elm)
} else {
rest.push(elm)
}
})

return [title, ...removeDuplicateMetas(metas), canonicalLink, ...rest]
}

function removeDuplicateMetas(metas) {
const addedMeta = {}

//initialize all the identifiers with empty array
uniqueIdentifiersAll.forEach((identifier) => {
addedMeta[identifier] = []
})

const filteredMetas = []
for (let i = metas.length - 1; i >= 0; i--) {
const meta = metas[i]

const { id } = meta.props
let addMeta = false

//if has id and element with id is not present than always add meta
if (id) {
addMeta = !addedMeta.id[id]

//for any other unique identifier check if meta already available with same identifier which doesn't have id
} else {
addMeta =
uniqueIdentifiers.filter((identifier) => {
const identifierValue = meta.props[identifier]
const existing = addedMeta[identifier][identifierValue]
return existing && !existing.props.id
}).length === 0
}

if (addMeta) {
filteredMetas.unshift(meta)

//add meta as added
uniqueIdentifiersAll.forEach((identifier) => {
const identifierValue = meta.props[identifier]
if (identifierValue) addedMeta[identifier][identifierValue] = meta
})
}
}

return filteredMetas
}

export function getDuplicateTitle() {
return document.head.querySelectorAll('title')
}

export function getDuplicateCanonical() {
return document.head.querySelectorAll('link[rel="canonical"]')
}

export function getDuplicateElementById({ id }) {
return id && document.head.querySelector(`#${id}`)
}

export function getDuplicateMeta(meta) {
const head = document.head

//for any other unique identifier check if metas already available with same identifier which doesn't have id
return uniqueIdentifiersI.reduce((duplicates, identifier) => {
const identifierValue = meta.getAttribute(identifier)
return identifierValue
? duplicates.concat(
filterOutMetaWithId(
head.querySelectorAll(`[${identifier} = "${identifierValue}"]`),
),
)
: duplicates
}, [])
}

//function to append childrens on a parent
export function appendChild(parent: HTMLElement, childrens: ReactNode[]): void {
const docFrag = document.createDocumentFragment()

//we used for loop instead of forEach because childrens can be array like object
for (let i = 0, ln = childrens.length; i < ln; i++) {
docFrag.appendChild(childrens[i])
}

parent.appendChild(docFrag)
}

export function removeChild(parent, childrens) {
if (childrens.length === undefined) childrens = [childrens]
for (let i = 0, ln = childrens.length; i < ln; i++) {
parent.removeChild(childrens[i])
}
}
7 changes: 7 additions & 0 deletions packages/html-tags/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react"
},
"include": ["src", "tests", "vite.config.ts"]
}
21 changes: 15 additions & 6 deletions packages/html-tags/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackBuildConfig } from '@tanstack/config/build'
import react from '@vitejs/plugin-react'

const config = defineConfig({})
const config = defineConfig({
plugins: [react()],
test: {
name: 'tuono-html-tags',
watch: true,
environment: 'jsdom',
globals: true,
},
})

export default mergeConfig(
config,
tanstackBuildConfig({
entry: './src/index.ts',
srcDir: './src',
}),
config,
tanstackBuildConfig({
entry: ['./src/index.ts', './src/server/index.ts'],
srcDir: './src',
}),
)
Loading

0 comments on commit e764322

Please sign in to comment.