Skip to content

Commit 9bb5364

Browse files
authored
Add homepage latest release badge (#277)
* feat(release): add xmake release data generation and display in homepage * style(release): render hero badge as superscript
1 parent fd784cc commit 9bb5364

8 files changed

Lines changed: 292 additions & 4 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const xmakeRelease = {
2+
"tagName": "",
3+
"name": "",
4+
"url": "https://github.com/xmake-io/xmake/releases/latest",
5+
"publishedAt": "",
6+
"fetchedAt": ""
7+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue'
3+
import { useData } from 'vitepress'
4+
import { xmakeRelease } from '../../data/xmake-release.js'
5+
6+
type HeroConfig = {
7+
name?: string
8+
text?: string
9+
tagline?: string
10+
}
11+
12+
const { frontmatter, lang, page } = useData()
13+
14+
const hero = computed<HeroConfig>(() => {
15+
const heroConfig = frontmatter.value.hero
16+
if (!heroConfig || typeof heroConfig !== 'object') {
17+
return {}
18+
}
19+
return heroConfig as HeroConfig
20+
})
21+
22+
const isZh = computed(() => {
23+
return lang.value === 'zh' || lang.value === 'zh-CN' || page.value.relativePath.startsWith('zh/')
24+
})
25+
26+
const releaseTag = computed(() => xmakeRelease.tagName.trim())
27+
const releaseUrl = computed(() => xmakeRelease.url || 'https://github.com/xmake-io/xmake/releases/latest')
28+
const badgeTitle = computed(() => {
29+
if (!releaseTag.value) {
30+
return ''
31+
}
32+
return isZh.value ? `查看 ${releaseTag.value} 发布说明` : `View ${releaseTag.value} release notes`
33+
})
34+
</script>
35+
36+
<template>
37+
<h1 class="heading">
38+
<span v-if="hero.name" class="xmake-hero-name">
39+
<span v-html="hero.name" class="name clip"></span>
40+
<a
41+
v-if="releaseTag"
42+
class="xmake-release-badge"
43+
:href="releaseUrl"
44+
target="_blank"
45+
rel="noreferrer"
46+
:title="badgeTitle"
47+
:aria-label="badgeTitle"
48+
>
49+
{{ releaseTag }}
50+
</a>
51+
</span>
52+
<span v-if="hero.text" v-html="hero.text" class="text"></span>
53+
</h1>
54+
<p v-if="hero.tagline" v-html="hero.tagline" class="tagline"></p>
55+
</template>
56+
57+
<style scoped>
58+
.heading {
59+
display: flex;
60+
flex-direction: column;
61+
}
62+
63+
.xmake-hero-name {
64+
display: inline-block;
65+
width: fit-content;
66+
max-width: 392px;
67+
letter-spacing: -0.4px;
68+
line-height: 40px;
69+
font-size: 32px;
70+
font-weight: 700;
71+
white-space: pre-wrap;
72+
}
73+
74+
.name {
75+
color: var(--vp-home-hero-name-color);
76+
}
77+
78+
.text {
79+
width: fit-content;
80+
max-width: 392px;
81+
letter-spacing: -0.4px;
82+
line-height: 40px;
83+
font-size: 32px;
84+
font-weight: 700;
85+
white-space: pre-wrap;
86+
}
87+
88+
.name:lang(ja),
89+
.text:lang(ja) {
90+
font-feature-settings: 'palt';
91+
word-break: auto-phrase;
92+
}
93+
94+
.clip {
95+
background: var(--vp-home-hero-name-background);
96+
-webkit-background-clip: text;
97+
background-clip: text;
98+
-webkit-text-fill-color: var(--vp-home-hero-name-color);
99+
}
100+
101+
.tagline {
102+
padding-top: 8px;
103+
max-width: 392px;
104+
line-height: 28px;
105+
font-size: 18px;
106+
font-weight: 500;
107+
white-space: pre-wrap;
108+
color: var(--vp-c-text-2);
109+
}
110+
111+
.xmake-release-badge {
112+
position: relative;
113+
top: -1.9em;
114+
margin-left: 0.04em;
115+
padding: 0.05em 0.16em;
116+
border-radius: 0.45em;
117+
background: transparent;
118+
color: var(--vp-c-brand-1);
119+
text-decoration: underline;
120+
text-decoration-color: transparent;
121+
text-underline-offset: 0.16em;
122+
white-space: nowrap;
123+
font-size: 0.26em;
124+
line-height: 1;
125+
font-weight: 700;
126+
letter-spacing: 0.01em;
127+
font-variant-numeric: tabular-nums;
128+
transition: background-color 0.2s ease, color 0.2s ease, text-decoration-color 0.2s ease;
129+
}
130+
131+
.xmake-release-badge:hover {
132+
background: rgba(66, 211, 146, 0.12);
133+
color: var(--vp-c-brand-2);
134+
text-decoration-color: currentColor;
135+
}
136+
137+
@media (min-width: 640px) {
138+
.xmake-hero-name,
139+
.text {
140+
max-width: 576px;
141+
line-height: 56px;
142+
font-size: 48px;
143+
}
144+
145+
.tagline {
146+
padding-top: 12px;
147+
max-width: 576px;
148+
line-height: 32px;
149+
font-size: 20px;
150+
}
151+
}
152+
153+
@media (min-width: 960px) {
154+
.xmake-hero-name,
155+
.text {
156+
line-height: 64px;
157+
font-size: 56px;
158+
}
159+
160+
.tagline {
161+
line-height: 36px;
162+
font-size: 24px;
163+
}
164+
}
165+
</style>

docs/.vitepress/theme/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import WWAds from './components/WWAds.vue'
66
import VPCarbonAds from './components/VPCarbonAds.vue'
77
import SponsorButton from './components/SponsorButton.vue'
88
import FileExplorer from './components/FileExplorer.vue'
9+
import HomeHeroInfo from './components/HomeHeroInfo.vue'
910
import { useData } from 'vitepress'
1011
import { data as codes } from '../data/codes-data.js'
1112
import './styles.css'
@@ -37,6 +38,9 @@ export default {
3738
page.value.relativePath.startsWith('zh/') ||
3839
page.value.filePath.includes('/zh/')
3940
})
41+
const isHomePage = computed(() => {
42+
return page.value.relativePath === 'index.md' || page.value.relativePath === 'zh/index.md'
43+
})
4044
const isPost = computed(() => {
4145
return page.value.filePath.includes('posts/') || page.value.filePath.includes('blog/')
4246
})
@@ -51,15 +55,21 @@ export default {
5155
}, { immediate: true })
5256
}
5357

54-
return h(Theme.Layout, { class: { 'is-post': isPost.value } }, {
58+
const slots: Record<string, () => ReturnType<typeof h> | null> = {
5559
'doc-content-before': () => h(VPDoc),
5660
'doc-after': () => {
5761
if (isZh.value) {
5862
return h('div', { class: 'mobile-only-ads' }, h(WWAds))
5963
}
6064
return null
6165
}
62-
})
66+
}
67+
68+
if (isHomePage.value) {
69+
slots['home-hero-info'] = () => h(HomeHeroInfo)
70+
}
71+
72+
return h(Theme.Layout, { class: { 'is-post': isPost.value } }, slots)
6373
},
6474
enhanceApp({ app, router }) {
6575
// Register global components

docs/.vitepress/theme/styles.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
}
2323
}
2424

25+
.VPHomeHero .xmake-release-badge:focus-visible {
26+
outline: 2px solid var(--vp-c-brand-1);
27+
outline-offset: 3px;
28+
}
29+
2530
/* button */
2631
:root {
2732
--vp-button-brand-border: transparent;

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
{
22
"type": "module",
33
"scripts": {
4-
"docs:dev": "node scripts/generate-blog-data.js && node scripts/generate-codes-data.js && vitepress dev docs",
5-
"docs:build": "node scripts/generate-blog-data.js && node scripts/generate-codes-data.js && vitepress build docs && node scripts/fix-llms-txt.cjs",
4+
"docs:dev": "node scripts/generate-release-data.js && node scripts/generate-blog-data.js && node scripts/generate-codes-data.js && vitepress dev docs",
5+
"docs:build": "node scripts/generate-release-data.js && node scripts/generate-blog-data.js && node scripts/generate-codes-data.js && vitepress build docs && node scripts/fix-llms-txt.cjs",
66
"docs:preview": "vitepress preview docs",
7+
"generate-release": "node scripts/generate-release-data.js",
78
"generate-blog": "node scripts/generate-blog-data.js",
89
"generate-codes": "node scripts/generate-codes-data.js"
910
},

scripts/build.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ npm add @types/node -D
99
npm add shiki -D
1010
npm add jszip
1111
npm install
12+
npm run generate-release
1213
npm run generate-blog
1314
npm run generate-codes
1415
npx vitepress build docs

scripts/generate-release-data.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env node
2+
3+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4+
import path from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
7+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
8+
const outputDir = path.resolve(__dirname, '../docs/.vitepress/data')
9+
const outputFile = path.join(outputDir, 'xmake-release.js')
10+
const releaseApiUrl = 'https://api.github.com/repos/xmake-io/xmake/releases/latest'
11+
const releasePageUrl = 'https://github.com/xmake-io/xmake/releases/latest'
12+
const outputPrefix = 'export const xmakeRelease = '
13+
14+
const emptyRelease = {
15+
tagName: '',
16+
name: '',
17+
url: releasePageUrl,
18+
publishedAt: '',
19+
fetchedAt: ''
20+
}
21+
22+
function ensureOutputDir() {
23+
if (!existsSync(outputDir)) {
24+
mkdirSync(outputDir, { recursive: true })
25+
}
26+
}
27+
28+
function serializeRelease(release) {
29+
return `${outputPrefix}${JSON.stringify(release, null, 2)}\n`
30+
}
31+
32+
function readCachedRelease() {
33+
if (!existsSync(outputFile)) {
34+
return { ...emptyRelease }
35+
}
36+
37+
try {
38+
const content = readFileSync(outputFile, 'utf8').trim()
39+
if (!content.startsWith(outputPrefix)) {
40+
return { ...emptyRelease }
41+
}
42+
43+
const cachedRelease = JSON.parse(content.slice(outputPrefix.length))
44+
return { ...emptyRelease, ...cachedRelease }
45+
} catch {
46+
return { ...emptyRelease }
47+
}
48+
}
49+
50+
async function fetchLatestRelease() {
51+
const response = await fetch(releaseApiUrl, {
52+
headers: {
53+
Accept: 'application/vnd.github+json',
54+
'User-Agent': 'xmake-docs-release-badge'
55+
},
56+
signal: AbortSignal.timeout(10000)
57+
})
58+
59+
if (!response.ok) {
60+
throw new Error(`GitHub API returned ${response.status}`)
61+
}
62+
63+
const payload = await response.json()
64+
65+
return {
66+
tagName: typeof payload.tag_name === 'string' ? payload.tag_name.trim() : '',
67+
name: typeof payload.name === 'string' ? payload.name.trim() : '',
68+
url: typeof payload.html_url === 'string' ? payload.html_url : releasePageUrl,
69+
publishedAt: typeof payload.published_at === 'string' ? payload.published_at : '',
70+
fetchedAt: new Date().toISOString()
71+
}
72+
}
73+
74+
async function main() {
75+
ensureOutputDir()
76+
77+
const cachedRelease = readCachedRelease()
78+
79+
try {
80+
const latestRelease = await fetchLatestRelease()
81+
writeFileSync(outputFile, serializeRelease(latestRelease))
82+
console.log(`Generated xmake release data: ${latestRelease.tagName || 'unknown'}`)
83+
} catch (error) {
84+
const fallbackRelease = {
85+
...emptyRelease,
86+
...cachedRelease,
87+
fetchedAt: cachedRelease.fetchedAt || new Date().toISOString()
88+
}
89+
90+
writeFileSync(outputFile, serializeRelease(fallbackRelease))
91+
console.warn(`Failed to fetch latest xmake release, using cached data instead: ${error instanceof Error ? error.message : String(error)}`)
92+
}
93+
}
94+
95+
main().catch((error) => {
96+
console.error('Unable to generate xmake release data:', error)
97+
process.exitCode = 1
98+
})

scripts/run.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
# npm add vitepress@2.0.0-alpha.6 -D --save-dev
33
# npm add vitepress-plugin-group-icons -D
44
# npm add vitepress-plugin-llms -D
5+
npm run generate-release
56
npx vitepress dev docs

0 commit comments

Comments
 (0)