-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: create initial tuono-html-tags logic
- Loading branch information
1 parent
b0bfaf6
commit e764322
Showing
16 changed files
with
368 additions
and
46 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import MetaTagsServer from './meta-tags-server' | ||
|
||
export default MetaTagsServer |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}), | ||
) |
Oops, something went wrong.