Skip to content

Commit f79c8e1

Browse files
author
闫茂源
committed
feat: seo
1 parent 65817bd commit f79c8e1

10 files changed

Lines changed: 393 additions & 8 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ jobs:
5757
id: mirror
5858
run: bash ci/mirror-static-sites.sh
5959

60+
- name: Merge static site sitemaps
61+
run: node ci/scripts/merge-sitemaps.mjs
62+
6063
- name: Save recipe-book-modern static site cache
6164
if: steps.mirror.outputs.recipe_book_modern_downloaded == 'true'
6265
uses: actions/cache/save@v5

.vitepress/config.mts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { withSidebar, type VitePressSidebarOptions } from 'vitepress-sidebar'
33
import { readFileSync } from 'node:fs'
44
import { fileURLToPath } from 'node:url'
55
import { dirname, resolve } from 'node:path'
6+
import { buildVitePressBootstrapScript } from '../ci/lib/tfg-theme.mjs'
67
import { assertUiLocales, buildSearchOptions, buildThemeConfig, loadUiLocales } from './i18n/index.ts'
8+
import {
9+
buildPageSeoHead,
10+
buildWebSiteJsonLd,
11+
transformWikiSitemapItems,
12+
} from './seo.mts'
713

814
const __dirname = dirname(fileURLToPath(import.meta.url))
915

@@ -12,6 +18,10 @@ const GITHUB_REPO = `${GITHUB_ORG}/Wiki`
1218
const NAMESPACE = 'modern'
1319
const SITE_DOMAIN = readFileSync(resolve(__dirname, '..', 'public', 'CNAME'), 'utf8').trim()
1420
const SITE_URL = `https://${SITE_DOMAIN}`
21+
const OG_IMAGE = `${SITE_URL}/logo.png`
22+
const SITE_TITLE = 'TerraFirmaGreg Wiki'
23+
const SITE_DESCRIPTION =
24+
'Official TerraFirmaGreg wiki — modpack info, upgrade guides, field guide, recipe book, and developer references.'
1525

1626
const LOCALES = ['en_us', 'zh_cn', 'pt_br'] as const
1727
type Locale = (typeof LOCALES)[number]
@@ -59,25 +69,40 @@ export default defineConfig(
5969
vite: {
6070
publicDir: resolve(__dirname, '..', 'public'),
6171
},
62-
title: 'TerraFirmaGreg Wiki',
63-
description:
64-
'Official TerraFirmaGreg wiki — modpack info, upgrade guides, and developer references.',
72+
title: SITE_TITLE,
73+
description: SITE_DESCRIPTION,
6574
lang: rootEntry.lang,
6675
base: '/',
6776
cleanUrls: true,
6877
lastUpdated: true,
6978
ignoreDeadLinks: 'localhostLinks',
79+
appearance: {
80+
storageKey: 'tfg-theme',
81+
},
7082

7183
head: [
84+
['script', {}, buildVitePressBootstrapScript()],
7285
['link', { rel: 'icon', type: 'image/png', href: '/favicon.png' }],
7386
['meta', { name: 'theme-color', content: '#ff0e0b' }],
87+
['meta', { name: 'robots', content: 'index, follow' }],
7488
['meta', { property: 'og:type', content: 'website' }],
75-
['meta', { property: 'og:site_name', content: 'TerraFirmaGreg Wiki' }],
76-
['meta', { property: 'og:image', content: `${SITE_URL}favicon.png` }],
89+
['meta', { property: 'og:site_name', content: SITE_TITLE }],
90+
['meta', { property: 'og:description', content: SITE_DESCRIPTION }],
91+
['meta', { property: 'og:image', content: OG_IMAGE }],
92+
['meta', { name: 'twitter:card', content: 'summary' }],
93+
['meta', { name: 'twitter:site', content: '@TerraFirmaGreg' }],
94+
['script', { type: 'application/ld+json' }, buildWebSiteJsonLd(SITE_URL)],
7795
],
7896

97+
transformHead({ page, title, description }) {
98+
return buildPageSeoHead(SITE_URL, page, title, description, OG_IMAGE)
99+
},
100+
79101
sitemap: {
80102
hostname: SITE_URL,
103+
transformItems(items) {
104+
return transformWikiSitemapItems(SITE_URL, items)
105+
},
81106
},
82107

83108
themeConfig: {

.vitepress/seo.mts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import type { HeadConfig } from 'vitepress'
2+
3+
export const LOCALES = ['en_us', 'zh_cn', 'pt_br'] as const
4+
export type WikiLocale = (typeof LOCALES)[number]
5+
6+
type SitemapLink = {
7+
lang: string
8+
hreflang?: string
9+
url: string
10+
}
11+
12+
type WikiSitemapItem = {
13+
url: string
14+
links?: SitemapLink[]
15+
lastmod?: string | number | Date
16+
}
17+
18+
const HREFLANG: Record<WikiLocale, string> = {
19+
en_us: 'en',
20+
zh_cn: 'zh-CN',
21+
pt_br: 'pt-BR',
22+
}
23+
24+
const LOCALE_INDEX_RE = /^modern\/(en_us|zh_cn|pt_br)\/index\.md$/
25+
26+
/** Map a docs-relative path to the public URL (cleanUrls). */
27+
export function pageToCanonical(siteUrl: string, page: string): string {
28+
const base = siteUrl.replace(/\/$/, '')
29+
if (page === 'index.md') {
30+
return `${base}/modern/en_us/`
31+
}
32+
const indexMatch = page.match(/^modern\/(en_us|zh_cn|pt_br)\/index\.md$/)
33+
if (indexMatch) {
34+
return `${base}/modern/${indexMatch[1]}/`
35+
}
36+
const path = page.replace(/\.md$/, '')
37+
return `${base}/${path}`
38+
}
39+
40+
function localePageSuffix(page: string): string | null {
41+
const match = page.match(/^modern\/(en_us|zh_cn|pt_br)\/(.+\.md)$/)
42+
if (!match || match[2] === 'index.md') {
43+
return null
44+
}
45+
return match[2].replace(/\.md$/, '').replace(/\/index$/, '')
46+
}
47+
48+
export function buildHreflangHead(siteUrl: string, page: string): HeadConfig[] {
49+
const base = siteUrl.replace(/\/$/, '')
50+
const suffix = localePageSuffix(page)
51+
if (!suffix) {
52+
if (LOCALE_INDEX_RE.test(page) || page === 'index.md') {
53+
return hreflangLinks(base, LOCALES.map((locale) => `${base}/modern/${locale}/`))
54+
}
55+
return []
56+
}
57+
const urls = LOCALES.map((locale) => `${base}/modern/${locale}/${suffix}`)
58+
return hreflangLinks(base, urls)
59+
}
60+
61+
function hreflangLinks(_base: string, urls: string[]): HeadConfig[] {
62+
const links: HeadConfig[] = LOCALES.map((locale, index) => [
63+
'link',
64+
{ rel: 'alternate', hreflang: HREFLANG[locale], href: urls[index] },
65+
])
66+
links.push(['link', { rel: 'alternate', hreflang: 'x-default', href: urls[0] }])
67+
return links
68+
}
69+
70+
export function buildPageSeoHead(
71+
siteUrl: string,
72+
page: string,
73+
title: string,
74+
description: string,
75+
ogImage: string,
76+
): HeadConfig[] {
77+
const canonical = pageToCanonical(siteUrl, page)
78+
return [
79+
['link', { rel: 'canonical', href: canonical }],
80+
['meta', { property: 'og:url', content: canonical }],
81+
['meta', { property: 'og:title', content: title }],
82+
['meta', { property: 'og:description', content: description }],
83+
['meta', { name: 'twitter:card', content: 'summary' }],
84+
['meta', { name: 'twitter:title', content: title }],
85+
['meta', { name: 'twitter:description', content: description }],
86+
['meta', { name: 'twitter:image', content: ogImage }],
87+
...buildHreflangHead(siteUrl, page),
88+
]
89+
}
90+
91+
export function buildWebSiteJsonLd(siteUrl: string): string {
92+
const base = siteUrl.replace(/\/$/, '')
93+
return JSON.stringify({
94+
'@context': 'https://schema.org',
95+
'@type': 'WebSite',
96+
name: 'TerraFirmaGreg Wiki',
97+
url: base,
98+
description:
99+
'Official TerraFirmaGreg wiki — modpack info, upgrade guides, and developer references.',
100+
publisher: {
101+
'@type': 'Organization',
102+
name: 'TerraFirmaGreg Team',
103+
url: 'https://terrafirmagreg.team',
104+
},
105+
})
106+
}
107+
108+
export function transformWikiSitemapItems(_siteUrl: string, items: WikiSitemapItem[]): WikiSitemapItem[] {
109+
return items
110+
.filter((item) => {
111+
const path = normalizeSitemapPath(item.url)
112+
return path !== '/'
113+
})
114+
.map((item) => {
115+
const links = sitemapHreflangLinks(item.url)
116+
return links ? { ...item, links } : item
117+
})
118+
}
119+
120+
function normalizeSitemapPath(url: string): string {
121+
const path = url.startsWith('/') ? url : `/${url}`
122+
return path.replace(/\/$/, '') || '/'
123+
}
124+
125+
function sitemapHreflangLinks(url: string): WikiSitemapItem['links'] | undefined {
126+
const path = normalizeSitemapPath(url)
127+
128+
const indexMatch = path.match(/^\/modern\/(en_us|zh_cn|pt_br)$/)
129+
if (indexMatch) {
130+
return hreflangSitemapLinks((locale) => `/modern/${locale}/`)
131+
}
132+
133+
const pageMatch = path.match(/^\/modern\/(en_us|zh_cn|pt_br)\/(.+)$/)
134+
if (pageMatch) {
135+
const suffix = pageMatch[2]!
136+
return hreflangSitemapLinks((locale) => `/modern/${locale}/${suffix}`)
137+
}
138+
139+
return undefined
140+
}
141+
142+
function hreflangSitemapLinks(urlForLocale: (locale: WikiLocale) => string): WikiSitemapItem['links'] {
143+
const links = LOCALES.map((locale) => ({
144+
lang: HREFLANG[locale],
145+
hreflang: HREFLANG[locale],
146+
url: urlForLocale(locale),
147+
}))
148+
links.push({ lang: 'x-default', hreflang: 'x-default', url: urlForLocale('en_us') })
149+
return links
150+
}
151+
152+
export function parseSitemapLocs(xml: string): string[] {
153+
const locs: string[] = []
154+
const re = /<loc>([^<]+)<\/loc>/g
155+
let match: RegExpExecArray | null
156+
while ((match = re.exec(xml)) !== null) {
157+
locs.push(match[1]!)
158+
}
159+
return locs
160+
}
161+
162+
export function buildMergedSitemap(urls: Iterable<string>): string {
163+
const unique = [...new Set([...urls].filter(Boolean))]
164+
const body = unique.map((url) => ` <url><loc>${escapeXml(url)}</loc></url>`).join('\n')
165+
return [
166+
'<?xml version="1.0" encoding="UTF-8"?>',
167+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
168+
body,
169+
'</urlset>',
170+
'',
171+
].join('\n')
172+
}
173+
174+
function escapeXml(value: string): string {
175+
return value
176+
.replace(/&/g, '&amp;')
177+
.replace(/</g, '&lt;')
178+
.replace(/>/g, '&gt;')
179+
.replace(/"/g, '&quot;')
180+
.replace(/'/g, '&apos;')
181+
}

ci/lib/tfg-theme.mjs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/** @typedef {'light' | 'dark' | 'auto'} TfgThemePreference */
2+
3+
export const TFG_THEME_KEY = 'tfg-theme';
4+
5+
/**
6+
* @param {string | null | undefined} value
7+
* @returns {TfgThemePreference | null}
8+
*/
9+
export function normalizeTfgThemePreference(value) {
10+
return value === 'light' || value === 'dark' || value === 'auto' ? value : null;
11+
}
12+
13+
/**
14+
* @param {Pick<Storage, 'getItem'>} storage
15+
* @returns {TfgThemePreference}
16+
*/
17+
export function readTfgThemePreference(storage) {
18+
return normalizeTfgThemePreference(storage.getItem(TFG_THEME_KEY)) ?? 'auto';
19+
}
20+
21+
/**
22+
* @param {TfgThemePreference} preference
23+
* @param {(query: string) => { matches: boolean }} [matchMedia]
24+
* @returns {'light' | 'dark'}
25+
*/
26+
export function resolveTfgTheme(preference, matchMedia = globalThis.matchMedia?.bind(globalThis)) {
27+
if (preference === 'light' || preference === 'dark') {
28+
return preference;
29+
}
30+
31+
const media = matchMedia?.('(prefers-color-scheme: dark)');
32+
if (media && typeof media.matches === 'boolean') {
33+
return media.matches ? 'dark' : 'light';
34+
}
35+
36+
return 'dark';
37+
}
38+
39+
/**
40+
* @param {Pick<Storage, 'setItem'>} storage
41+
* @param {TfgThemePreference} preference
42+
*/
43+
export function writeTfgThemePreference(storage, preference) {
44+
const normalized = normalizeTfgThemePreference(preference);
45+
if (!normalized) {
46+
return;
47+
}
48+
storage.setItem(TFG_THEME_KEY, normalized);
49+
}
50+
51+
/**
52+
* Inline script for VitePress <head> to avoid a flash of the wrong theme.
53+
*/
54+
export function buildVitePressBootstrapScript() {
55+
return `(()=>{const K='${TFG_THEME_KEY}';function n(v){return v==='light'||v==='dark'||v==='auto'?v:null}function r(p){if(p==='light'||p==='dark')return p;return matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}const d=r(n(localStorage.getItem(K))||'auto');if(d==='dark')document.documentElement.classList.add('dark');document.documentElement.style.colorScheme=d})();`;
56+
}

ci/scripts/merge-sitemaps.mjs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env node
2+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3+
import { dirname, join } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
import { buildMergedSitemap, parseSitemapLocs } from '../../.vitepress/seo.mts';
7+
import { buildStaticSiteUrl, loadStaticSitesConfig } from '../lib/static-site.mjs';
8+
9+
const __dirname = dirname(fileURLToPath(import.meta.url));
10+
const root = join(__dirname, '../..');
11+
const distDir = process.argv[2] ?? join(root, '.vitepress/dist');
12+
const siteUrl = `https://${readFileSync(join(root, 'public/CNAME'), 'utf8').trim()}`;
13+
const WIKI_LOCALES = ['en_us', 'zh_cn', 'pt_br'];
14+
15+
const wikiSitemap = join(distDir, 'sitemap.xml');
16+
if (!existsSync(wikiSitemap)) {
17+
console.error(`::error::Missing wiki sitemap: ${wikiSitemap}`);
18+
process.exit(1);
19+
}
20+
21+
/** @type {string[]} */
22+
let urls = parseSitemapLocs(readFileSync(wikiSitemap, 'utf8')).filter(
23+
(url) => url !== `${siteUrl}/` && url !== siteUrl,
24+
);
25+
26+
for (const site of loadStaticSitesConfig()) {
27+
const siteSitemap = join(distDir, site.id, 'sitemap.xml');
28+
if (existsSync(siteSitemap)) {
29+
const locs = parseSitemapLocs(readFileSync(siteSitemap, 'utf8'));
30+
console.log(`Merging ${locs.length} URL(s) from ${site.id}`);
31+
urls.push(...locs);
32+
continue;
33+
}
34+
35+
const siteRoot = join(distDir, site.id);
36+
if (!existsSync(siteRoot)) {
37+
console.warn(`Skipping ${site.id}: not installed under ${siteRoot}`);
38+
continue;
39+
}
40+
41+
for (const locale of WIKI_LOCALES) {
42+
urls.push(buildStaticSiteUrl(site, siteUrl, locale));
43+
}
44+
console.warn(`Merged ${WIKI_LOCALES.length} landing URL(s) for ${site.id} (no sitemap.xml)`);
45+
}
46+
47+
const merged = buildMergedSitemap(urls);
48+
writeFileSync(wikiSitemap, merged, 'utf8');
49+
console.log(`Wrote merged sitemap (${new Set(urls).size} unique URLs) → ${wikiSitemap}`);

ci/scripts/merge-sitemaps.test.mjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'node:test';
3+
4+
import { buildMergedSitemap, pageToCanonical, parseSitemapLocs } from '../../.vitepress/seo.mts';
5+
6+
test('pageToCanonical uses trailing slash for locale index', () => {
7+
assert.equal(
8+
pageToCanonical('https://wiki.test', 'modern/en_us/index.md'),
9+
'https://wiki.test/modern/en_us/',
10+
);
11+
});
12+
13+
test('parseSitemapLocs extracts loc tags', () => {
14+
const xml = '<?xml version="1.0"?><urlset><url><loc>https://a.test/one</loc></url></urlset>';
15+
assert.deepEqual(parseSitemapLocs(xml), ['https://a.test/one']);
16+
});
17+
18+
test('buildMergedSitemap deduplicates urls', () => {
19+
const xml = buildMergedSitemap(['https://a.test/one', 'https://a.test/one', 'https://a.test/two']);
20+
assert.match(xml, /<loc>https:\/\/a\.test\/one<\/loc>/);
21+
assert.match(xml, /<loc>https:\/\/a\.test\/two<\/loc>/);
22+
assert.equal(parseSitemapLocs(xml).length, 2);
23+
});

0 commit comments

Comments
 (0)