| 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, '&') |
| 21 | .replace(/</g, '<') |
| 22 | .replace(/>/g, '>') |
| 23 | .replace(/"/g, '"') |
| 24 | .replace(/'/g, ''') |
| 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 |