Skip to content

Commit a472006

Browse files
committedFeb 2, 2025·
feat(ssr): transformHtmlTemplate
1 parent 4e10ee5 commit a472006

File tree

4 files changed

+160
-3
lines changed

4 files changed

+160
-3
lines changed
 

‎packages/unhead/src/server/index.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
export * from './createHead'
2-
export * from './renderSSRHead'
3-
export * from './util'
1+
export { createHead } from './createHead'
2+
export { renderSSRHead } from './renderSSRHead'
3+
export { transformHtmlTemplate } from './transformHtmlTemplate'
4+
export { escapeHtml, propsToString, ssrRenderTags, tagToString } from './util'
45
export type { SSRHeadPayload } from '@unhead/schema'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { RenderSSRHeadOptions, Unhead } from '@unhead/schema'
2+
import { renderSSRHead } from './renderSSRHead'
3+
import { extractTagsFromHtml } from './util/extractTagsFromHtml'
4+
5+
export async function transformHtmlTemplate(head: Unhead<any>, html: string, options?: RenderSSRHeadOptions) {
6+
const { html: parsedHtml, input } = extractTagsFromHtml(html)
7+
head.push(input)
8+
const headHtml = await renderSSRHead(head, options)
9+
return parsedHtml
10+
.replace('<html>', `<html${headHtml.htmlAttrs}>`)
11+
.replace('<body>', `<body>${headHtml.bodyTagsOpen ? `\n${headHtml.bodyTagsOpen}` : ``}`)
12+
.replace('<body>', `<body${headHtml.bodyAttrs}>`)
13+
.replace('</head>', `${headHtml.headTags}</head>`)
14+
.replace('</body>', `${headHtml.bodyTags}</body>`)
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
function extractAttributes(tag: string) {
2+
const attrs = tag.match(/([a-z-]+)="([^"]*)"/g)
3+
return attrs?.reduce((acc, attr) => {
4+
const [key, ...valueParts] = attr.split('=')
5+
// join value parts to support '=' within the quoted values
6+
const val = valueParts.join('=').slice(1, -1)
7+
return { ...acc, [key]: val, tagPriority: 'low' }
8+
}, {})
9+
}
10+
11+
export function extractTagsFromHtml(html: string) {
12+
const input = {}
13+
// i should be able to give it a string of html and it should convert it to input for useHead()
14+
// parse htmlAttrs, bodyAttrs
15+
input.htmlAttrs = extractAttributes(html.match(/<html[^>]*>/)?.[0] || '')
16+
17+
html = html.replace(/<html[^>]*>/, '<html>')
18+
input.bodyAttrs = extractAttributes(html.match(/<body[^>]*>/)?.[0] || '')
19+
html = html.replace(/<body[^>]*>/, '<body>')
20+
// parse headTags, need to split on /> and seperate each tag
21+
const innerHead = html.match(/<head[^>]*>([\s\S]*)<\/head>/)?.[1]
22+
// replace ['meta', 'link', 'base'] tags first because they're unique in that they don't have a closing tag
23+
innerHead?.match(/<meta[^>]*>|<link[^>]*>|<base[^>]*>/g).forEach((s) => {
24+
html = html.replace(s, '')
25+
const tag = s.split(' ')[0].slice(1)
26+
input[tag] = input[tag] || []
27+
input[tag].push(extractAttributes(s))
28+
})
29+
innerHead?.match(/<title[^>]*>[\s\S]*?<\/title>|<script[^>]*>[\s\S]*?<\/script>|<style[^>]*>[\s\S]*?<\/style>/g)
30+
.map(tag => tag.trim())
31+
.filter(Boolean)
32+
.forEach((tag) => {
33+
html = html.replace(tag, '')
34+
const type = tag.match(/<([a-z-]+)/)?.[1]
35+
const res = {
36+
tagPriority: 'low',
37+
[type !== 'script' ? 'textContent' : 'innerHTML']: tag.match(/>([\s\S]*)</)?.[1],
38+
...extractAttributes(tag),
39+
}
40+
if (type === 'title') {
41+
input.title = res
42+
}
43+
else {
44+
input[type] = input[type] || []
45+
input[type].push(res)
46+
}
47+
})
48+
// remove duplicate new lines from html, could be 2, 5 or 20 in a row
49+
html = html.replace(/(\n\s*)+/g, '\n')
50+
// we leave any body tags as the order is out of our control
51+
return { html, input }
52+
}

‎packages/unhead/test/unit/server/ssr.test.ts

+89
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useHead, useSeoMeta } from 'unhead'
22
import { renderSSRHead } from 'unhead/server'
3+
import { transformHtmlTemplate } from 'unhead/server/transformHtmlTemplate'
4+
import { extractTagsFromHtml } from 'unhead/server/util/extractTagsFromHtml'
35
import { describe, it } from 'vitest'
46
import { basicSchema } from '../../fixtures'
57
import { createServerHeadWithContext } from '../../util'
@@ -214,4 +216,91 @@ describe('ssr', () => {
214216
}
215217
`)
216218
})
219+
it('vite template', async () => {
220+
const html = `<!doctype html>
221+
<html lang="en">
222+
<head>
223+
<meta charset="UTF-8" />
224+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
225+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
226+
<title>Vite + Vue + TS</title>
227+
<!--app-head-->
228+
</head>
229+
<body>
230+
<div id="app"><!--app-html--></div>
231+
<script type="module" src="/src/entry-client.ts"></script>
232+
</body>
233+
</html>`
234+
const head = createServerHeadWithContext()
235+
head.push({
236+
title: 'new title',
237+
meta: [
238+
{ charset: 'utf-16' },
239+
],
240+
})
241+
expect(await transformHtmlTemplate(head, html)).toMatchInlineSnapshot(`
242+
"<!doctype html>
243+
<html lang="en">
244+
<head>
245+
<!--app-head-->
246+
<meta charset="utf-16">
247+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
248+
<title>new title</title>
249+
<link rel="icon" type="image/svg+xml" href="/vite.svg"></head>
250+
<body>
251+
<div id="app"><!--app-html--></div>
252+
<script type="module" src="/src/entry-client.ts"></script>
253+
</body>
254+
</html>"
255+
`)
256+
})
257+
it('random template', async () => {
258+
const html = `
259+
<html lang="en">
260+
<head>
261+
<meta charset="UTF-8">
262+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
263+
<title>Document</title>
264+
<link rel="stylesheet" href="style.css">
265+
<script src="script.js" async type="module"></script>
266+
</head>
267+
<body style="accent-color: red;">
268+
<div>hello</div>
269+
<script src="ssr.test.ts"></script>
270+
<script>
271+
console.log('hello')
272+
</script>
273+
</body>
274+
</html>
275+
`
276+
const head = createServerHeadWithContext()
277+
head.push({
278+
title: 'new title',
279+
bodyAttrs: {
280+
style: 'background-color: blue;',
281+
},
282+
meta: [
283+
{ charset: 'utf-16' },
284+
],
285+
})
286+
expect(await transformHtmlTemplate(head, html)).toMatchInlineSnapshot(`
287+
"
288+
<html lang="en">
289+
<head>
290+
<meta charset="utf-16">
291+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
292+
<title>new title</title>
293+
<link rel="stylesheet" href="style.css">
294+
<script src="script.js" type="module"></script></head>
295+
<body style="background-color: blue; accent-color: red">
296+
<div>hello</div>
297+
<script src="ssr.test.ts"></script>
298+
<script>
299+
console.log('hello')
300+
</script>
301+
</body>
302+
</html>
303+
"
304+
`)
305+
})
217306
})

0 commit comments

Comments
 (0)
Please sign in to comment.