Skip to content

Commit af4c25a

Browse files
leochiu-aantfu
andauthoredMar 31, 2025··
feat: support open graph protocol (#2096)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 98d80b0 commit af4c25a

File tree

13 files changed

+191
-103
lines changed

13 files changed

+191
-103
lines changed
 

‎demo/starter/slides.md

+3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ drawings:
2020
transition: slide-left
2121
# enable MDC Syntax: https://sli.dev/features/mdc
2222
mdc: true
23+
# open graph
24+
# seoMeta:
25+
# ogImage: https://cover.sli.dev
2326
---
2427

2528
# Welcome to Slidev

‎docs/custom/index.md

+13
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,19 @@ drawings:
108108
htmlAttrs:
109109
dir: ltr
110110
lang: en
111+
112+
# SEO meta tags
113+
seoMeta:
114+
ogTitle: Slidev Starter Template
115+
ogDescription: Presentation slides for developers
116+
ogImage: https://cover.sli.dev
117+
ogUrl: https://example.com
118+
twitterCard: summary_large_image
119+
twitterTitle: Slidev Starter Template
120+
twitterDescription: Presentation slides for developers
121+
twitterImage: https://cover.sli.dev
122+
twitterSite: username
123+
twitterUrl: https://example.com
111124
---
112125
```
113126

‎docs/vite.config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default defineConfig({
2323
llmstxt({
2424
ignoreFiles: [
2525
'index.md',
26-
'README.md'
26+
'README.md',
2727
],
2828
}),
2929
Components({

‎packages/client/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,5 @@ export const HEADMATTER_FIELDS = [
8383
'mdc',
8484
'contextMenu',
8585
'wakeLock',
86+
'seoMeta',
8687
]

‎packages/parser/src/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export function getDefaultConfig(): SlidevConfig {
4444
wakeLock: true,
4545
remote: false,
4646
mdc: false,
47+
seoMeta: {},
4748
}
4849
}
4950

‎packages/slidev/node/cli.ts

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const CONFIG_RESTART_FIELDS: (keyof SlidevConfig)[] = [
3030
'mdc',
3131
'editor',
3232
'theme',
33+
'seoMeta',
3334
]
3435

3536
/**

‎packages/slidev/node/options.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export async function createDataUtils(resolved: Omit<ResolvedSlidevOptions, 'uti
8484
return {
8585
...await setupShiki(resolved.roots),
8686
katexOptions: await setupKatex(resolved.roots),
87-
indexHtml: setupIndexHtml(resolved),
87+
indexHtml: await setupIndexHtml(resolved),
8888
define: getDefine(resolved),
8989
iconsResolvePath: [resolved.clientRoot, ...resolved.roots].reverse(),
9090
isMonacoTypesIgnored: pkg => monacoTypesIgnorePackagesMatches.some(i => i.test(pkg)),
+50-21
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import type { ResolvedSlidevOptions } from '@slidev/types'
1+
import type { ResolvedSlidevOptions, SeoMeta } from '@slidev/types'
2+
import type { ResolvableLink } from 'unhead/types'
23
import { existsSync, readFileSync } from 'node:fs'
34
import { join } from 'node:path'
45
import { slash } from '@antfu/utils'
56
import { white, yellow } from 'ansis'
67
import { escapeHtml } from 'markdown-it/lib/common/utils.mjs'
8+
import { createHead, transformHtmlTemplate } from 'unhead/server'
79
import { version } from '../../package.json'
810
import { getSlideTitle } from '../commands/shared'
911
import { toAtFS } from '../resolver'
@@ -13,22 +15,11 @@ function toAttrValue(unsafe: unknown) {
1315
return JSON.stringify(escapeHtml(String(unsafe)))
1416
}
1517

16-
export default function setupIndexHtml({ mode, entry, clientRoot, userRoot, roots, data, base }: Omit<ResolvedSlidevOptions, 'utils'>): string {
18+
export default async function setupIndexHtml({ mode, entry, clientRoot, userRoot, roots, data, base }: Omit<ResolvedSlidevOptions, 'utils'>): Promise<string> {
1719
let main = readFileSync(join(clientRoot, 'index.html'), 'utf-8')
1820
let head = ''
1921
let body = ''
2022

21-
const { info, author, keywords } = data.headmatter
22-
head += [
23-
`<meta name="slidev:version" content="${version}">`,
24-
mode === 'dev' && `<meta charset="slidev:entry" content="${slash(entry)}">`,
25-
`<link rel="icon" href="${data.config.favicon}">`,
26-
`<title>${getSlideTitle(data)}</title>`,
27-
info && `<meta name="description" content=${toAttrValue(info)}>`,
28-
author && `<meta name="author" content=${toAttrValue(author)}>`,
29-
keywords && `<meta name="keywords" content=${toAttrValue(Array.isArray(keywords) ? keywords.join(', ') : keywords)}>`,
30-
].filter(Boolean).join('\n')
31-
3223
for (const root of roots) {
3324
const path = join(root, 'index.html')
3425
if (!existsSync(path))
@@ -46,25 +37,63 @@ export default function setupIndexHtml({ mode, entry, clientRoot, userRoot, root
4637
body += `\n${(index.match(/<body>([\s\S]*?)<\/body>/i)?.[1] || '').trim()}`
4738
}
4839

49-
if (data.features.tweet)
40+
if (data.features.tweet) {
5041
body += '\n<script async src="https://platform.twitter.com/widgets.js"></script>'
42+
}
5143

44+
const webFontsLink: ResolvableLink[] = []
5245
if (data.config.fonts.webfonts.length) {
5346
const { provider } = data.config.fonts
54-
if (provider === 'google')
55-
head += `\n<link rel="stylesheet" href="${generateGoogleFontsUrl(data.config.fonts)}" type="text/css">`
56-
else if (provider === 'coollabs')
57-
head += `\n<link rel="stylesheet" href="${generateCoollabsFontsUrl(data.config.fonts)}" type="text/css">`
47+
if (provider === 'google') {
48+
webFontsLink.push({ rel: 'stylesheet', href: generateGoogleFontsUrl(data.config.fonts), type: 'text/css' })
49+
}
50+
else if (provider === 'coollabs') {
51+
webFontsLink.push({ rel: 'stylesheet', href: generateCoollabsFontsUrl(data.config.fonts), type: 'text/css' })
52+
}
5853
}
5954

60-
if (data.headmatter.lang)
61-
main = main.replace('<html lang="en">', `<html lang="${data.headmatter.lang}">`)
55+
const { info, author, keywords } = data.headmatter
56+
const seoMeta = (data.headmatter.seoMeta ?? {}) as SeoMeta
57+
58+
const title = getSlideTitle(data)
59+
const description = info ? toAttrValue(info) : null
60+
const unhead = createHead({
61+
init: [
62+
{
63+
htmlAttrs: { lang: (data.headmatter.lang as string | undefined) ?? 'en' },
64+
title,
65+
link: [
66+
{ rel: 'icon', href: data.config.favicon },
67+
...webFontsLink,
68+
],
69+
meta: [
70+
{ property: 'slidev:version', content: version },
71+
{ charset: 'slidev:entry', content: mode === 'dev' && slash(entry) },
72+
{ name: 'description', content: description },
73+
{ name: 'author', content: author ? toAttrValue(author) : null },
74+
{ name: 'keywords', content: keywords ? toAttrValue(Array.isArray(keywords) ? keywords.join(', ') : keywords) : null },
75+
{ property: 'og:title', content: seoMeta.ogTitle || title },
76+
{ property: 'og:description', content: seoMeta.ogDescription || description },
77+
{ property: 'og:image', content: seoMeta.ogImage },
78+
{ property: 'og:url', content: seoMeta.ogUrl },
79+
{ property: 'twitter:card', content: seoMeta.twitterCard },
80+
{ property: 'twitter:site', content: seoMeta.twitterSite },
81+
{ property: 'twitter:title', content: seoMeta.twitterTitle },
82+
{ property: 'twitter:description', content: seoMeta.twitterDescription },
83+
{ property: 'twitter:image', content: seoMeta.twitterImage },
84+
{ property: 'twitter:url', content: seoMeta.twitterUrl },
85+
],
86+
},
87+
],
88+
})
6289

6390
const baseInDev = mode === 'dev' && base ? base.slice(0, -1) : ''
6491
main = main
6592
.replace('__ENTRY__', baseInDev + toAtFS(join(clientRoot, 'main.ts')))
6693
.replace('<!-- head -->', head)
6794
.replace('<!-- body -->', body)
6895

69-
return main
96+
const html = await transformHtmlTemplate(unhead, main)
97+
98+
return html
7099
}

‎packages/slidev/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
"sirv": "catalog:",
103103
"source-map-js": "catalog:",
104104
"typescript": "catalog:",
105+
"unhead": "catalog:",
105106
"unocss": "catalog:",
106107
"unplugin-icons": "catalog:",
107108
"unplugin-vue-components": "catalog:",

‎packages/types/src/frontmatter.ts

+22
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,12 @@ export interface HeadmatterConfig extends TransitionOptions {
255255
* @default []
256256
*/
257257
monacoRunAdditionalDeps?: string[]
258+
/**
259+
* Seo meta tags settings
260+
*
261+
* @default {}
262+
*/
263+
seoMeta?: SeoMeta
258264
}
259265

260266
export interface Frontmatter extends TransitionOptions {
@@ -454,3 +460,19 @@ export interface TransitionGroupProps {
454460
leaveActiveClass?: string
455461
leaveToClass?: string
456462
}
463+
464+
/**
465+
* The following type should map to unhead MataFlat type
466+
*/
467+
export interface SeoMeta {
468+
ogTitle?: string
469+
ogDescription?: string
470+
ogImage?: string
471+
ogUrl?: string
472+
twitterCard?: 'summary' | 'summary_large_image' | 'app' | 'player'
473+
twitterSite?: string
474+
twitterTitle?: string
475+
twitterDescription?: string
476+
twitterImage?: string
477+
twitterUrl?: string
478+
}

‎packages/vscode/schema/headmatter.json

+49
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,12 @@
484484
"markdownDescription": "Additional local modules to load as dependencies of monaco runnable",
485485
"default": []
486486
},
487+
"seoMeta": {
488+
"$ref": "#/definitions/SeoMeta",
489+
"description": "Seo meta tags settings",
490+
"markdownDescription": "Seo meta tags settings",
491+
"default": {}
492+
},
487493
"defaults": {
488494
"$ref": "#/definitions/Frontmatter",
489495
"description": "Default frontmatter options applied to all slides",
@@ -777,6 +783,49 @@
777783
}
778784
}
779785
},
786+
"SeoMeta": {
787+
"type": "object",
788+
"properties": {
789+
"ogTitle": {
790+
"type": "string"
791+
},
792+
"ogDescription": {
793+
"type": "string"
794+
},
795+
"ogImage": {
796+
"type": "string"
797+
},
798+
"ogUrl": {
799+
"type": "string"
800+
},
801+
"twitterCard": {
802+
"type": "string",
803+
"enum": [
804+
"summary",
805+
"summary_large_image",
806+
"app",
807+
"player"
808+
]
809+
},
810+
"twitterSite": {
811+
"type": "string"
812+
},
813+
"twitterTitle": {
814+
"type": "string"
815+
},
816+
"twitterDescription": {
817+
"type": "string"
818+
},
819+
"twitterImage": {
820+
"type": "string"
821+
},
822+
"twitterUrl": {
823+
"type": "string"
824+
}
825+
},
826+
"description": "The following type should map to unhead MataFlat type",
827+
"markdownDescription": "The following type should map to unhead MataFlat type"
828+
},
780829
"Frontmatter": {
781830
"type": "object",
782831
"properties": {

0 commit comments

Comments
 (0)
Please sign in to comment.