返回 slidev
indexHtml.ts
根目录 / packages / slidev / node / setups / indexHtml.ts
1 import type { ResolvedSlidevOptions, SeoMeta } from '@slidev/types'
2 import type { ResolvableLink } from 'unhead/types'
3 import { existsSync } from 'node:fs'
4 import { readFile } from 'node:fs/promises'
5 import { slash } from '@antfu/utils'
6 import { white, yellow } from 'ansis'
7 import { join } from 'pathe'
8 import { parseHtmlForUnheadExtraction } from 'unhead/parser'
9 import { createHead, transformHtmlTemplate } from 'unhead/server'
10 import { version } from '../../package.json'
11 import { getSlideTitle } from '../commands/shared'
12 import { toAtFS } from '../resolver'
13 import { generateCoollabsFontsUrl, generateGoogleFontsUrl } from '../utils'
14
15 const RE_TRAILING_SLASH = /\/$/
16 const RE_BODY_CONTENT = /<body>([\s\S]*?)<\/body>/i
17
18 function escapeHtml(str: string): string {
19 return str
20 .replace(/&/g, '&amp;')
21 .replace(/</g, '&lt;')
22 .replace(/>/g, '&gt;')
23 .replace(/"/g, '&quot;')
24 .replace(/'/g, '&#039;')
25 }
26
27 function toAttrValue(unsafe: unknown) {
28 return JSON.stringify(escapeHtml(String(unsafe)))
29 }
30
31 function collectPreloadImages(data: Omit<ResolvedSlidevOptions, 'utils'>['data'], base?: string): ResolvableLink[] {
32 const config = data.config
33 if (config.preloadImages === false)
34 return []
35
36 const seen = new Set<string>()
37 const links: ResolvableLink[] = []
38 const basePrefix = base ? base.replace(RE_TRAILING_SLASH, '') : ''
39
40 for (const slide of data.slides) {
41 const images = slide.images || slide.source?.images
42 if (!images?.length)
43 continue
44 for (const url of images) {
45 if (seen.has(url))
46 continue
47 seen.add(url)
48 const href = url.startsWith('http') || url.startsWith('//')
49 ? url
50 : `${basePrefix}${url.startsWith('/') ? url : `/${url}`}`
51 links.push({ rel: 'preload', as: 'image', href })
52 }
53 }
54
55 return links
56 }
57
58 export default async function setupIndexHtml({ mode, entry, clientRoot, userRoot, roots, data, base }: Omit<ResolvedSlidevOptions, 'utils'>): Promise<string> {
59 let main = await readFile(join(clientRoot, 'index.html'), 'utf-8')
60 let body = ''
61
62 const inputs: any[] = []
63
64 for (const root of roots) {
65 const path = join(root, 'index.html')
66 if (!existsSync(path))
67 continue
68
69 const html = await readFile(path, 'utf-8')
70
71 if (root === userRoot && html.includes('<!DOCTYPE')) {
72 console.error(yellow(`[Slidev] Ignored provided index.html with doctype declaration. (${white(path)})`))
73 console.error(yellow('This file may be generated by Slidev, please remove it from your project.'))
74 continue
75 }
76
77 inputs.push(parseHtmlForUnheadExtraction(html).input)
78 body += `\n${(html.match(RE_BODY_CONTENT)?.[1] || '').trim()}`
79 }
80
81 if (data.features.tweet) {
82 body += '\n<script async src="https://platform.twitter.com/widgets.js"></script>'
83 }
84
85 if (data.features.bluesky) {
86 body += '\n<script async src="https://embed.bsky.app/static/embed.js" charset="utf-8"></script>'
87 }
88
89 const webFontsLink: ResolvableLink[] = []
90 if (data.config.fonts.webfonts.length) {
91 const { provider } = data.config.fonts
92 if (provider === 'google') {
93 webFontsLink.push({ rel: 'stylesheet', href: generateGoogleFontsUrl(data.config.fonts), type: 'text/css' })
94 }
95 else if (provider === 'coollabs') {
96 webFontsLink.push({ rel: 'stylesheet', href: generateCoollabsFontsUrl(data.config.fonts), type: 'text/css' })
97 }
98 }
99
100 const { info, author, keywords } = data.headmatter
101 const seoMeta = (data.headmatter.seoMeta ?? {}) as SeoMeta
102
103 const ogImage = seoMeta.ogImage === 'auto'
104 ? './og-image.png'
105 : seoMeta.ogImage
106 ? seoMeta.ogImage
107 : existsSync(join(userRoot, 'og-image.png'))
108 ? './og-image.png'
109 : undefined
110
111 const title = getSlideTitle(data)
112 const description = info ? toAttrValue(info) : null
113 const unhead = createHead({
114 init: [
115 {
116 htmlAttrs: data.headmatter.lang ? { lang: data.headmatter.lang as string } : undefined,
117 title,
118 link: [
119 data.config.favicon ? { rel: 'icon', href: data.config.favicon } : null,
120 ...webFontsLink,
121 ...collectPreloadImages(data, base),
122 ].filter(x => x),
123 meta: [
124 { 'http-equiv': 'Content-Type', 'content': 'text/html; charset=UTF-8' },
125 { property: 'slidev:version', content: version },
126 { property: 'slidev:entry', content: mode === 'dev' ? slash(entry) : null },
127 { name: 'description', content: description },
128 { name: 'author', content: author ? toAttrValue(author) : null },
129 { name: 'keywords', content: keywords ? toAttrValue(Array.isArray(keywords) ? keywords.join(', ') : keywords) : null },
130 { property: 'og:title', content: seoMeta.ogTitle || title },
131 { property: 'og:description', content: seoMeta.ogDescription || description },
132 { property: 'og:image', content: ogImage },
133 { property: 'og:url', content: seoMeta.ogUrl },
134 { property: 'twitter:card', content: seoMeta.twitterCard },
135 { property: 'twitter:site', content: seoMeta.twitterSite },
136 { property: 'twitter:title', content: seoMeta.twitterTitle },
137 { property: 'twitter:description', content: seoMeta.twitterDescription },
138 { property: 'twitter:image', content: seoMeta.twitterImage },
139 { property: 'twitter:url', content: seoMeta.twitterUrl },
140 ].filter(x => x.content),
141 },
142 ...inputs,
143 ],
144 })
145
146 const mainUrl = toAtFS(join(clientRoot, 'main.ts'))
147 if (mode === 'build') {
148 main = main.replace('__ENTRY__', mainUrl)
149 }
150 else {
151 const basePrefix = base ? base.slice(0, -1) : ''
152 main = main.replace('__ENTRY__', encodeURI(basePrefix + mainUrl))
153 }
154
155 main = main.replace('<!-- body -->', body)
156
157 const html = transformHtmlTemplate(unhead, main)
158 return html
159 }
160
160 lines TYPESCRIPT