@@ -49,6 +49,33 @@ const releaseNotesExcerpt = computed(() => {
4949 .slice (0 , 3 )
5050})
5151
52+ const SITE_NAME = ' BPM Sniffer'
53+ const FALLBACK_SITE_URL = ' https://coderDJing.github.io/bpm-sniffer/'
54+ const envSiteUrlRaw = (import .meta .env as Record <string , string | undefined >).VITE_SITE_URL
55+ const envSiteUrl = envSiteUrlRaw ? envSiteUrlRaw .trim () : ' '
56+ const ensureTrailingSlash = (value : string ) => {
57+ if (! value ) return ' /'
58+ return value .endsWith (' /' ) ? value : ` ${value }/ `
59+ }
60+ const resolveRuntimeUrl = () => {
61+ if (envSiteUrl ) return envSiteUrl
62+ if (typeof window !== ' undefined' ) {
63+ const { origin, pathname } = window .location
64+ let normalizedPath = pathname
65+ if (normalizedPath .endsWith (' index.html' )) {
66+ normalizedPath = normalizedPath .slice (0 , - ' index.html' .length )
67+ }
68+ if (! normalizedPath .endsWith (' /' )) {
69+ normalizedPath = ` ${normalizedPath }/ `
70+ }
71+ return ` ${origin }${normalizedPath } `
72+ }
73+ return FALLBACK_SITE_URL
74+ }
75+ const canonicalUrl = ensureTrailingSlash (resolveRuntimeUrl () || FALLBACK_SITE_URL )
76+ const canonicalBase = canonicalUrl .replace (/ \/ $ / , ' ' )
77+ const socialCardUrl = canonicalBase ? ` ${canonicalBase }/social-card.png ` : ` ${canonicalUrl }social-card.png `
78+
5279type SiteLang = ' zh' | ' en'
5380type FeatureEntry = { title: string ; detail: string }
5481type StepEntry = { label: string ; title: string ; detail: string }
@@ -63,6 +90,13 @@ type DemoEntry = {
6390 floatingOff: string
6491}
6592
93+ type SeoEntry = {
94+ title: string
95+ description: string
96+ keywords: string []
97+ locale: string
98+ }
99+
66100type TranslationEntry = {
67101 eyebrow: string
68102 heroTitle: string
@@ -82,6 +116,7 @@ type TranslationEntry = {
82116 steps: StepEntry []
83117 langToggleLabel: string
84118 demo: DemoEntry
119+ seo: SeoEntry
85120}
86121
87122const translations: Record <SiteLang , TranslationEntry > = {
@@ -122,6 +157,13 @@ const translations: Record<SiteLang, TranslationEntry> = {
122157 pinOff: ' 置顶' ,
123158 floatingOn: ' 悬浮中' ,
124159 floatingOff: ' 悬浮球'
160+ },
161+ seo: {
162+ title: ' BPM Sniffer · 系统音频实时 BPM 侦测工具' ,
163+ description:
164+ ' BPM Sniffer 是一款面向 Windows 10+ 的系统音频 BPM 检测工具,安装即用、零驱动依赖,提供稳定数值、可视化与 OTA 更新。' ,
165+ keywords: [' BPM Sniffer' , ' BPM 检测' , ' 节拍侦测' , ' 系统音频' , ' DJ 工具' ],
166+ locale: ' zh_CN'
125167 }
126168 },
127169 en: {
@@ -170,6 +212,13 @@ const translations: Record<SiteLang, TranslationEntry> = {
170212 pinOff: ' Pin window' ,
171213 floatingOn: ' Floating' ,
172214 floatingOff: ' Floating widget'
215+ },
216+ seo: {
217+ title: ' BPM Sniffer · Real-time system audio BPM detector' ,
218+ description:
219+ ' BPM Sniffer is a lightweight Windows BPM detector that listens to any system audio, keeps the BPM steady with visuals, and updates itself over the air.' ,
220+ keywords: [' BPM Sniffer' , ' BPM detector' , ' beat detection' , ' system audio' , ' DJ tool' ],
221+ locale: ' en_US'
173222 }
174223 }
175224}
@@ -216,11 +265,61 @@ const releaseDateText = computed(() => {
216265 }
217266})
218267const demoI18n = computed (() => localized .value .demo )
268+ const seoMeta = computed (() => localized .value .seo )
219269
220270function toggleLang() {
221271 lang .value = lang .value === ' zh' ? ' en' : ' zh'
222272}
223273
274+ function upsertMeta(attribute : ' name' | ' property' , key : string , value : string ) {
275+ if (typeof document === ' undefined' ) return
276+ const head = document .head || document .querySelector (' head' )
277+ if (! head ) return
278+ let element = head .querySelector <HTMLMetaElement >(` meta[${attribute }="${key }"] ` )
279+ if (! element ) {
280+ element = document .createElement (' meta' )
281+ element .setAttribute (attribute , key )
282+ head .appendChild (element )
283+ }
284+ element .setAttribute (' content' , value )
285+ }
286+
287+ function upsertLink(rel : string , href : string ) {
288+ if (typeof document === ' undefined' ) return
289+ const head = document .head || document .querySelector (' head' )
290+ if (! head ) return
291+ let link = head .querySelector <HTMLLinkElement >(` link[rel="${rel }"] ` )
292+ if (! link ) {
293+ link = document .createElement (' link' )
294+ link .setAttribute (' rel' , rel )
295+ head .appendChild (link )
296+ }
297+ link .setAttribute (' href' , href )
298+ }
299+
300+ function applySeoMeta() {
301+ if (typeof document === ' undefined' ) return
302+ const meta = seoMeta .value
303+ const keywords = (meta .keywords || []).join (' , ' )
304+ document .title = meta .title
305+ document .documentElement .lang = lang .value
306+ upsertMeta (' name' , ' description' , meta .description )
307+ if (keywords ) {
308+ upsertMeta (' name' , ' keywords' , keywords )
309+ }
310+ upsertMeta (' property' , ' og:title' , meta .title )
311+ upsertMeta (' property' , ' og:description' , meta .description )
312+ upsertMeta (' property' , ' og:locale' , meta .locale )
313+ upsertMeta (' property' , ' og:url' , canonicalUrl )
314+ upsertMeta (' property' , ' og:image' , socialCardUrl )
315+ upsertMeta (' property' , ' og:site_name' , SITE_NAME )
316+ upsertMeta (' name' , ' twitter:card' , ' summary_large_image' )
317+ upsertMeta (' name' , ' twitter:title' , meta .title )
318+ upsertMeta (' name' , ' twitter:description' , meta .description )
319+ upsertMeta (' name' , ' twitter:image' , socialCardUrl )
320+ upsertLink (' canonical' , canonicalUrl )
321+ }
322+
224323watch (lang , (val ) => {
225324 if (typeof window !== ' undefined' ) {
226325 try {
@@ -231,6 +330,14 @@ watch(lang, (val) => {
231330 }
232331})
233332
333+ watch (
334+ lang ,
335+ () => {
336+ applySeoMeta ()
337+ },
338+ { immediate: true }
339+ )
340+
234341onMounted (() => {
235342 fetchLatestRelease ()
236343})
0 commit comments