Skip to content

Commit 751a52e

Browse files
committed
Template polishing
1 parent 89dea81 commit 751a52e

9 files changed

Lines changed: 358 additions & 33 deletions

File tree

.vitepress/config.mts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import { defineConfig, HeadConfig } from 'vitepress'
2-
import { generateRss } from './rss'
2+
import { generateRss, rssPlugin } from './rss'
3+
import { isBlogPath } from './locales'
34

45
const baseUrl = 'https://php-testo.github.io'
56

67
export default defineConfig({
78
title: 'Testo',
89
description: 'Modern PHP Testing Framework',
910

10-
lastUpdated: true,
11+
lastUpdated: false,
1112
cleanUrls: true,
1213
srcExclude: ['CLAUDE.md', 'README.md'],
1314
ignoreDeadLinks: [/feed\.xml$/],
1415

16+
vite: {
17+
plugins: [rssPlugin()],
18+
},
19+
1520
head: [
1621
['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }],
1722
],
@@ -127,6 +132,15 @@ export default defineConfig({
127132
},
128133
},
129134

135+
transformPageData(pageData) {
136+
// Disable lastUpdated and editLink for blog posts
137+
const pagePath = '/' + pageData.relativePath.replace(/\.md$/, '')
138+
if (isBlogPath(pagePath) || isBlogPath(pagePath + '/')) {
139+
pageData.frontmatter.lastUpdated = false
140+
pageData.frontmatter.editLink = false
141+
}
142+
},
143+
130144
transformHead({ pageData }) {
131145
const head: HeadConfig[] = []
132146

.vitepress/locales.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export interface LocaleConfig {
2+
code: string // 'en', 'ru', 'de', etc.
3+
prefix: string // '' for root, 'ru', 'de', etc.
4+
blogTitle: string
5+
blogDescription: string
6+
}
7+
8+
export const locales: LocaleConfig[] = [
9+
{
10+
code: 'en',
11+
prefix: '',
12+
blogTitle: 'Testo Blog',
13+
blogDescription: 'Updates from Testo - Modern PHP Testing Framework',
14+
},
15+
{
16+
code: 'ru',
17+
prefix: 'ru',
18+
blogTitle: 'Блог Testo',
19+
blogDescription: 'Новости Testo - современного PHP фреймворка для тестирования',
20+
},
21+
]
22+
23+
// Helper functions
24+
export function getBlogFolder(locale: LocaleConfig): string {
25+
return locale.prefix ? `${locale.prefix}/blog` : 'blog'
26+
}
27+
28+
export function getBlogUrl(locale: LocaleConfig): string {
29+
return locale.prefix ? `/${locale.prefix}/blog/` : '/blog/'
30+
}
31+
32+
export function getFeedFilename(locale: LocaleConfig): string {
33+
return locale.prefix ? `${locale.prefix}/feed.xml` : 'feed.xml'
34+
}
35+
36+
export function isBlogPath(path: string): boolean {
37+
return locales.some(locale => {
38+
const blogUrl = getBlogUrl(locale)
39+
return path.startsWith(blogUrl) && path !== blogUrl
40+
})
41+
}
42+
43+
export function isBlogIndexPath(path: string): boolean {
44+
return locales.some(locale => path === getBlogUrl(locale))
45+
}
46+
47+
// Generate glob patterns for all blog folders
48+
export function getBlogGlobPatterns(): string[] {
49+
return locales.map(locale => `${getBlogFolder(locale)}/*.md`)
50+
}
51+
52+
// Get all blog index URLs (for filtering)
53+
export function getBlogIndexUrls(): string[] {
54+
return locales.map(locale => getBlogUrl(locale))
55+
}

.vitepress/rss.ts

Lines changed: 135 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,35 @@
1-
import { writeFileSync, mkdirSync } from 'fs'
1+
import { writeFileSync, mkdirSync, readdirSync, readFileSync } from 'fs'
22
import path from 'path'
33
import { Feed } from 'feed'
44
import { createContentLoader, SiteConfig } from 'vitepress'
5+
import type { Plugin } from 'vite'
6+
// @ts-ignore
7+
import matter from 'gray-matter'
8+
import { locales, getBlogFolder, getBlogUrl, getFeedFilename, getBlogGlobPatterns } from './locales'
59

610
const baseUrl = 'https://php-testo.github.io'
711

812
interface FeedConfig {
913
title: string
1014
description: string
1115
filename: string
12-
filter: (url: string) => boolean
16+
blogFolder: string
17+
blogUrl: string
1318
lang: string
1419
}
1520

16-
const feeds: FeedConfig[] = [
17-
{
18-
title: 'Testo Blog',
19-
description: 'Updates from Testo - Modern PHP Testing Framework',
20-
filename: 'feed.xml',
21-
filter: (url) => url.startsWith('/blog/') && url !== '/blog/',
22-
lang: 'en',
23-
},
24-
{
25-
title: 'Блог Testo',
26-
description: 'Новости Testo - современного PHP фреймворка для тестирования',
27-
filename: 'ru/feed.xml',
28-
filter: (url) => url.startsWith('/ru/blog/') && url !== '/ru/blog/',
29-
lang: 'ru',
30-
},
31-
]
21+
// Generate feed configs from locales
22+
const feeds: FeedConfig[] = locales.map(locale => ({
23+
title: locale.blogTitle,
24+
description: locale.blogDescription,
25+
filename: getFeedFilename(locale),
26+
blogFolder: getBlogFolder(locale),
27+
blogUrl: getBlogUrl(locale),
28+
lang: locale.code,
29+
}))
3230

3331
export async function generateRss(config: SiteConfig) {
34-
const posts = await createContentLoader(['blog/*.md', 'ru/blog/*.md'], {
32+
const posts = await createContentLoader(getBlogGlobPatterns(), {
3533
excerpt: false,
3634
render: false,
3735
}).load()
@@ -51,7 +49,7 @@ export async function generateRss(config: SiteConfig) {
5149
})
5250

5351
const filteredPosts = posts
54-
.filter((post) => feedConfig.filter(post.url))
52+
.filter((post) => post.url.startsWith(feedConfig.blogUrl) && post.url !== feedConfig.blogUrl)
5553
.sort((a, b) => {
5654
const dateA = new Date(a.frontmatter.date || 0).getTime()
5755
const dateB = new Date(b.frontmatter.date || 0).getTime()
@@ -107,3 +105,120 @@ export async function generateRss(config: SiteConfig) {
107105
console.log(`✓ RSS generated: ${feedConfig.filename} (${filteredPosts.length} posts)`)
108106
}
109107
}
108+
109+
// Generate RSS content for dev server (without createContentLoader)
110+
function generateRssContent(feedConfig: FeedConfig, docsRoot: string): string {
111+
const feed = new Feed({
112+
title: feedConfig.title,
113+
description: feedConfig.description,
114+
id: baseUrl,
115+
link: baseUrl,
116+
language: feedConfig.lang,
117+
copyright: `Copyright © ${new Date().getFullYear()} Testo`,
118+
generator: 'VitePress + feed',
119+
feedLinks: {
120+
rss: `${baseUrl}/${feedConfig.filename}`,
121+
},
122+
})
123+
124+
const blogPath = path.join(docsRoot, feedConfig.blogFolder)
125+
126+
// Read markdown files directly
127+
const posts: Array<{
128+
url: string
129+
frontmatter: Record<string, any>
130+
}> = []
131+
132+
try {
133+
const files = readdirSync(blogPath).filter((f: string) => f.endsWith('.md') && f !== 'index.md')
134+
135+
for (const file of files) {
136+
const filePath = path.join(blogPath, file)
137+
const content = readFileSync(filePath, 'utf-8')
138+
const { data: frontmatter } = matter(content)
139+
const slug = file.replace(/\.md$/, '')
140+
const url = `${feedConfig.blogUrl}${slug}`
141+
142+
posts.push({ url, frontmatter })
143+
}
144+
} catch (e) {
145+
// Blog folder might not exist
146+
}
147+
148+
// Sort by date
149+
posts.sort((a, b) => {
150+
const dateA = new Date(a.frontmatter.date || 0).getTime()
151+
const dateB = new Date(b.frontmatter.date || 0).getTime()
152+
return dateB - dateA
153+
})
154+
155+
for (const post of posts) {
156+
const url = `${baseUrl}${post.url}`
157+
const imageUrl = post.frontmatter.image ? `${baseUrl}${post.frontmatter.image}` : undefined
158+
159+
feed.addItem({
160+
title: post.frontmatter.title || 'Untitled',
161+
id: url,
162+
link: url,
163+
description: post.frontmatter.description || '',
164+
date: new Date(post.frontmatter.date || Date.now()),
165+
author: post.frontmatter.author
166+
? [{ name: post.frontmatter.author }]
167+
: [{ name: 'Testo Team' }],
168+
image: imageUrl,
169+
})
170+
}
171+
172+
// Generate RSS
173+
let rssContent = feed.rss2()
174+
175+
// Add dc namespace
176+
if (!rssContent.includes('xmlns:dc=')) {
177+
rssContent = rssContent.replace(
178+
'<rss version="2.0"',
179+
'<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"'
180+
)
181+
}
182+
183+
// Add dc:creator for each post
184+
for (const post of posts) {
185+
const author = post.frontmatter.author || 'Testo Team'
186+
const url = `${baseUrl}${post.url}`
187+
rssContent = rssContent.replace(
188+
new RegExp(`(<guid isPermaLink="false">${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</guid>)`),
189+
`$1\n <dc:creator><![CDATA[${author}]]></dc:creator>`
190+
)
191+
}
192+
193+
return rssContent
194+
}
195+
196+
// Vite plugin for dev server RSS
197+
export function rssPlugin(): Plugin {
198+
let docsRoot: string
199+
200+
return {
201+
name: 'vitepress-rss-dev',
202+
configResolved(config) {
203+
// Get docs root from VitePress config
204+
docsRoot = config.root
205+
},
206+
configureServer(server) {
207+
server.middlewares.use((req, res, next) => {
208+
const url = req.url || ''
209+
210+
// Check if requesting RSS feed
211+
const feedConfig = feeds.find(f => url === `/${f.filename}`)
212+
213+
if (feedConfig) {
214+
const rssContent = generateRssContent(feedConfig, docsRoot)
215+
res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8')
216+
res.end(rssContent)
217+
return
218+
}
219+
220+
next()
221+
})
222+
},
223+
}
224+
}

.vitepress/theme/BlogPosts.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const filteredPosts = computed(() => {
3636
display: flex;
3737
flex-direction: column;
3838
gap: 2rem;
39+
margin-top: 1.5rem;
3940
}
4041
4142
.post-card {

.vitepress/theme/BlogSponsor.vue

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script setup>
2-
import { useRoute } from 'vitepress'
2+
import { useRoute, useData } from 'vitepress'
33
44
const route = useRoute()
5+
const { lang } = useData()
56
const isBlog = () => route.path.includes('/blog/')
67
</script>
78

@@ -11,13 +12,32 @@ const isBlog = () => route.path.includes('/blog/')
1112
<span class="sponsor-icon">❤️</span>
1213
<div class="sponsor-content">
1314
<p>
14-
<template v-if="route.path.startsWith('/ru/')">
15-
Вы можете стать спонсором <a href="https://github.com/php-testo" target="_blank">Testo</a>
16-
или поддержать автора на <a href="https://boosty.to/roxblnfk" target="_blank">boosty.to/roxblnfk</a>
15+
<template v-if="lang === 'ru'">
16+
Хотите поддержать проект? <a href="/ru/sponsor">Станьте спонсором</a>.
17+
</template>
18+
<template v-else-if="lang === 'es'">
19+
¿Quieres apoyar el proyecto? <a href="/es/sponsor">Conviértete en patrocinador</a>.
20+
</template>
21+
<template v-else-if="lang === 'zh' || lang === 'zh-CN'">
22+
想支持这个项目吗?<a href="/zh/sponsor">成为赞助商</a>。
23+
</template>
24+
<template v-else-if="lang === 'de'">
25+
Möchten Sie das Projekt unterstützen? <a href="/de/sponsor">Werden Sie Sponsor</a>.
26+
</template>
27+
<template v-else-if="lang === 'fr'">
28+
Vous voulez soutenir le projet ? <a href="/fr/sponsor">Devenez sponsor</a>.
29+
</template>
30+
<template v-else-if="lang === 'pt'">
31+
Quer apoiar o projeto? <a href="/pt/sponsor">Torne-se um patrocinador</a>.
32+
</template>
33+
<template v-else-if="lang === 'ja'">
34+
プロジェクトを支援しませんか?<a href="/ja/sponsor">スポンサーになる</a>。
35+
</template>
36+
<template v-else-if="lang === 'ko'">
37+
프로젝트를 지원하시겠습니까? <a href="/ko/sponsor">스폰서가 되세요</a>.
1738
</template>
1839
<template v-else>
19-
You can become a sponsor of <a href="https://github.com/php-testo" target="_blank">Testo</a>
20-
or support the author at <a href="https://boosty.to/roxblnfk" target="_blank">boosty.to/roxblnfk</a>
40+
Want to support the project? <a href="/sponsor">Become a sponsor</a>.
2141
</template>
2242
</p>
2343
</div>

.vitepress/theme/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ import CodeTabs from './CodeTabs.vue'
88
import JetBrainsPluginButton from './JetBrainsPluginButton.vue'
99
import BlogPosts from './BlogPosts.vue'
1010
import BlogPostHeader from './BlogPostHeader.vue'
11+
import { isBlogPath } from '../locales'
1112
import './style.css'
1213

1314
function isBlogPost() {
1415
const route = useRoute()
15-
const path = route.path
16-
return (path.startsWith('/blog/') || path.startsWith('/ru/blog/')) &&
17-
path !== '/blog/' && path !== '/ru/blog/'
16+
return isBlogPath(route.path)
1817
}
1918

2019
export default {

.vitepress/theme/posts.data.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createContentLoader } from 'vitepress'
2+
import { getBlogGlobPatterns, isBlogIndexPath } from '../locales'
23

34
export interface Post {
45
title: string
@@ -12,10 +13,10 @@ export interface Post {
1213
declare const data: Post[]
1314
export { data }
1415

15-
export default createContentLoader(['blog/*.md', 'ru/blog/*.md'], {
16+
export default createContentLoader(getBlogGlobPatterns(), {
1617
transform(raw): Post[] {
1718
return raw
18-
.filter((page) => page.url !== '/blog/' && page.url !== '/ru/blog/')
19+
.filter((page) => !isBlogIndexPath(page.url))
1920
.map((page) => ({
2021
title: page.frontmatter.title,
2122
url: page.url,

0 commit comments

Comments
 (0)