Skip to content

Commit 2e7b810

Browse files
authored
feat: URL-level trilingual site (en default + zh-CN + zh-TW) (#89)
feat: URL-level trilingual site (en default + zh-CN + zh-TW)
2 parents f75a5a2 + 2d99a9b commit 2e7b810

41 files changed

Lines changed: 1353 additions & 1050 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/ossheroes/scripts/verify-seo.mjs

Lines changed: 97 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#!/usr/bin/env node
22
/**
3-
* 校验规范页面只暴露 /heroes/ 与 /hero/ URL,并校验 sitemap / llms.txt。
3+
* 校验规范页面(URL 级三语言:en 无前缀 + /zh-CN/ + /zh-TW/)只暴露
4+
* /heroes/ 与 /hero/ URL,校验 canonical / hreflang / og:locale / <html lang>,
5+
* 并校验 sitemap / llms.txt。
46
*
57
* 用法:node scripts/verify-seo.mjs [ossheroes-dist] [www-dist]
68
*/
@@ -14,6 +16,9 @@ const DEFAULT_WWW_DIST = join(__dirname, '..', '..', 'www', 'dist');
1416
const DIST = process.argv[2] ? resolve(process.argv[2]) : DEFAULT_DIST;
1517
const WWW_DIST = process.argv[3] ? resolve(process.argv[3]) : DEFAULT_WWW_DIST;
1618
const SITE = 'https://opensource.win';
19+
/** 全站语言:en 为默认语言,URL 无前缀 */
20+
const LOCALES = ['en', 'zh-CN', 'zh-TW'];
21+
const OG_LOCALE = { en: 'en_US', 'zh-CN': 'zh_CN', 'zh-TW': 'zh_TW' };
1722
const errors = [];
1823

1924
function check(condition, message) {
@@ -40,6 +45,22 @@ function getCanonical(html) {
4045
return html.match(/<link\s+rel=["']canonical["']\s+href=["']([^"']+)["']/i)?.[1] ?? null;
4146
}
4247

48+
/** hreflang → href 映射(<link rel="alternate" hreflang="..." href="...">) */
49+
function getHreflangs(html) {
50+
const map = {};
51+
const tags = html.match(/<link\b[^>]*rel=["']alternate["'][^>]*>/gi) || [];
52+
for (const tag of tags) {
53+
const hreflang = tag.match(/hreflang=["']([^"']+)["']/i)?.[1];
54+
const href = tag.match(/href=["']([^"']+)["']/i)?.[1];
55+
if (hreflang && href) map[hreflang] = href;
56+
}
57+
return map;
58+
}
59+
60+
function getHtmlLang(html) {
61+
return html.match(/<html\s+lang=["']([^"']+)["']/i)?.[1] ?? null;
62+
}
63+
4364
function getJsonLd(html) {
4465
const matches = [...html.matchAll(/<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)];
4566
return matches.flatMap((match) => {
@@ -51,15 +72,34 @@ function getJsonLd(html) {
5172
});
5273
}
5374

54-
function assertCanonicalPage(path, canonicalPath, label) {
75+
/** locale 中立路径 → 该语言的 URL 路径(en 无前缀) */
76+
function localePath(locale, path) {
77+
return locale === 'en' ? path : `/${locale}${path}`;
78+
}
79+
80+
/**
81+
* 校验某个语言版本的规范页面:canonical / og:url 自指当前语言 URL、
82+
* 4 条 hreflang alternate(x-default → en)、<html lang> 与 og:locale。
83+
*/
84+
function assertCanonicalPage(path, canonicalPath, locale, label) {
5585
if (!existsSync(path)) {
5686
check(false, `${label}: 页面存在`);
5787
return '';
5888
}
5989
const html = read(path);
60-
const canonical = `${SITE}${canonicalPath}`;
61-
check(getCanonical(html) === canonical, `${label}: canonical 为 ${canonicalPath}`);
62-
check(getMeta(html, 'property', 'og:url') === canonical, `${label}: og:url 为 ${canonicalPath}`);
90+
const canonical = `${SITE}${localePath(locale, canonicalPath)}`;
91+
check(getCanonical(html) === canonical, `${label}: canonical 为 ${localePath(locale, canonicalPath)}`);
92+
check(getMeta(html, 'property', 'og:url') === canonical, `${label}: og:url 为 ${localePath(locale, canonicalPath)}`);
93+
check(getHtmlLang(html) === locale, `${label}: <html lang="${locale}">`);
94+
check(getMeta(html, 'property', 'og:locale') === OG_LOCALE[locale], `${label}: og:locale 为 ${OG_LOCALE[locale]}`);
95+
const hreflangs = getHreflangs(html);
96+
for (const l of LOCALES) {
97+
check(
98+
hreflangs[l] === `${SITE}${localePath(l, canonicalPath)}`,
99+
`${label}: hreflang ${l} 指向 ${localePath(l, canonicalPath)}`,
100+
);
101+
}
102+
check(hreflangs['x-default'] === `${SITE}${localePath('en', canonicalPath)}`, `${label}: hreflang x-default 指向 en URL`);
63103
check(!html.includes(`${SITE}/ossheroes/`), `${label}: 不暴露旧规范 URL`);
64104
return html;
65105
}
@@ -71,14 +111,14 @@ if (!isDir(DIST)) {
71111

72112
const heroesDir = join(DIST, 'heroes');
73113
const heroDir = join(DIST, 'hero');
74-
assertCanonicalPage(join(heroesDir, 'index.html'), '/heroes/', '/heroes/ 首页');
114+
assertCanonicalPage(join(heroesDir, 'index.html'), '/heroes/', 'en', '/heroes/ 首页(en)');
75115

76116
const rankingYears = isDir(heroesDir)
77117
? readdirSync(heroesDir).map((entry) => entry.match(/^ranking-(\d{4})$/)?.[1]).filter(Boolean).sort()
78118
: [];
79119
check(rankingYears.length > 0, '检测到年度榜单页面');
80120
for (const year of rankingYears) {
81-
assertCanonicalPage(join(heroesDir, `ranking-${year}`, 'index.html'), `/heroes/ranking-${year}/`, `/heroes/ranking-${year}/`);
121+
assertCanonicalPage(join(heroesDir, `ranking-${year}`, 'index.html'), `/heroes/ranking-${year}/`, 'en', `/heroes/ranking-${year}/(en)`);
82122
}
83123

84124
const logins = isDir(heroDir)
@@ -87,7 +127,7 @@ const logins = isDir(heroDir)
87127
check(logins.length >= 3, `开发者详情页数 >= 3(实际 ${logins.length})`);
88128
for (const login of logins.slice(0, 3)) {
89129
const canonicalPath = `/hero/${login}/`;
90-
const html = assertCanonicalPage(join(heroDir, login, 'index.html'), canonicalPath, `/hero/${login}/`);
130+
const html = assertCanonicalPage(join(heroDir, login, 'index.html'), canonicalPath, 'en', `/hero/${login}/(en)`);
91131
const jsonLd = getJsonLd(html);
92132
check(jsonLd.length > 0, `/hero/${login}/: JSON-LD 可解析`);
93133
const profile = jsonLd.find((item) => item['@type'] === 'ProfilePage');
@@ -96,12 +136,42 @@ for (const login of logins.slice(0, 3)) {
96136
check(!!getMeta(html, 'property', 'og:description'), `/hero/${login}/: og:description`);
97137
check(!!getMeta(html, 'property', 'og:image'), `/hero/${login}/: og:image`);
98138
check(!!getMeta(html, 'name', 'twitter:image'), `/hero/${login}/: twitter:image`);
99-
check(profile?.url === `${SITE}${canonicalPath}`, `/hero/${login}/: JSON-LD url 为规范 URL`);
100-
check(profile?.['@id'] === `${SITE}${canonicalPath}`, `/hero/${login}/: JSON-LD @id 为规范 URL`);
101-
check(profile?.mainEntity?.url === `${SITE}${canonicalPath}`, `/hero/${login}/: Person url 为规范 URL`);
102-
check(profile?.mainEntity?.['@id'] === `${SITE}${canonicalPath}#person`, `/hero/${login}/: Person @id 为规范 URL`);
139+
const enUrl = `${SITE}${canonicalPath}`;
140+
check(profile?.url === enUrl, `/hero/${login}/: JSON-LD url 为规范 URL`);
141+
check(profile?.['@id'] === enUrl, `/hero/${login}/: JSON-LD @id 为规范 URL`);
142+
check(profile?.mainEntity?.url === enUrl, `/hero/${login}/: Person url 为规范 URL`);
143+
check(profile?.mainEntity?.['@id'] === `${enUrl}#person`, `/hero/${login}/: Person @id 为规范 URL`);
144+
}
145+
146+
/* ---- 语言版本:/zh-CN/ 与 /zh-TW/ 的首页 + 各年榜单 + 抽样详情页 ---- */
147+
for (const locale of LOCALES.filter((l) => l !== 'en')) {
148+
const localeRoot = join(DIST, locale);
149+
assertCanonicalPage(join(localeRoot, 'heroes', 'index.html'), '/heroes/', locale, `/${locale}/heroes/ 首页`);
150+
for (const year of rankingYears) {
151+
assertCanonicalPage(
152+
join(localeRoot, 'heroes', `ranking-${year}`, 'index.html'),
153+
`/heroes/ranking-${year}/`,
154+
locale,
155+
`/${locale}/heroes/ranking-${year}/`,
156+
);
157+
}
158+
for (const login of logins.slice(0, 3)) {
159+
assertCanonicalPage(join(localeRoot, 'hero', login, 'index.html'), `/hero/${login}/`, locale, `/${locale}/hero/${login}/`);
160+
}
103161
}
104162

163+
/* zh-TW 正文应为繁体(OpenCC s2tw 构建期转换) */
164+
const zhTwHome = existsSync(join(DIST, 'zh-TW', 'heroes', 'index.html'))
165+
? read(join(DIST, 'zh-TW', 'heroes', 'index.html'))
166+
: '';
167+
check(zhTwHome.includes('開源') && zhTwHome.includes('開發者'), 'zh-TW 首页正文为繁体(開源 / 開發者)');
168+
169+
/* 首次访问重定向脚本只存在于 en 页面 */
170+
const enHome = read(join(heroesDir, 'index.html'));
171+
check(enHome.includes('osw-language') && enHome.includes('location.replace'), 'en 首页内联首访重定向脚本');
172+
const zhCnHome = read(join(DIST, 'zh-CN', 'heroes', 'index.html'));
173+
check(!zhCnHome.includes('location.replace'), 'zh-CN 首页不含重定向脚本');
174+
105175
const sitemap = join(WWW_DIST, 'sitemap.xml');
106176
const llms = join(WWW_DIST, 'llms.txt');
107177
const hasWwwBuild = isDir(WWW_DIST);
@@ -116,6 +186,21 @@ if (existsSync(sitemap)) {
116186
check(xml.includes(`${SITE}/heroes/ranking-`), 'sitemap 包含 /heroes/ 年度榜单');
117187
check(xml.includes(`${SITE}/hero/`), 'sitemap 包含 /hero/ 开发者详情');
118188
check(!xml.includes('/ossheroes/'), 'sitemap 不含旧 /ossheroes/ URL');
189+
check(
190+
xml.includes('xmlns:xhtml="http://www.w3.org/1999/xhtml"'),
191+
'sitemap 声明 xmlns:xhtml 命名空间',
192+
);
193+
for (const l of LOCALES) {
194+
check(
195+
xml.includes(`hreflang="${l}" href="${SITE}${localePath(l, '/heroes/')}"`),
196+
`sitemap 含 /heroes/ 的 ${l} alternate`,
197+
);
198+
}
199+
check(
200+
xml.includes(`hreflang="x-default" href="${SITE}/heroes/"`),
201+
'sitemap 含 x-default → en alternate',
202+
);
203+
check(xml.includes(`${SITE}/zh-CN/hero/`) && xml.includes(`${SITE}/zh-TW/hero/`), 'sitemap 含 zh-CN / zh-TW 开发者详情 URL');
119204
}
120205
check(existsSync(llms), 'llms.txt 已生成');
121206
if (existsSync(llms)) {

apps/ossheroes/scripts/verify-urls.mjs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env node
22
/**
3-
* 校验码力榜新规范 URL 与 GitHub Pages 静态兼容跳转页。
3+
* 校验码力榜新规范 URL(URL 级三语言:en 无前缀 + /zh-CN/ + /zh-TW/)
4+
* 与 GitHub Pages 静态兼容跳转页。
45
*
56
* 用法:node scripts/verify-urls.mjs [dist-dir]
67
* 默认读取 apps/ossheroes/dist。
@@ -13,6 +14,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
1314
const DEFAULT_DIST = join(__dirname, '..', 'dist');
1415
const DIST = process.argv[2] ? resolve(process.argv[2]) : DEFAULT_DIST;
1516
const SITE = 'https://opensource.win';
17+
/** 带前缀的语言版本(en 为默认语言,无前缀) */
18+
const PREFIXED_LOCALES = ['zh-CN', 'zh-TW'];
1619
const errors = [];
1720

1821
function check(condition, message) {
@@ -110,6 +113,25 @@ for (const login of sample(heroLogins, 10)) {
110113
checkRedirect(join(legacyDir, login, 'index.html'), `/hero/${login}/`, `/ossheroes/${login}/`);
111114
}
112115

116+
/* ---- 三语言结构:/zh-CN/ 与 /zh-TW/ 前缀下产出同构页面 ---- */
117+
for (const locale of PREFIXED_LOCALES) {
118+
const localeRoot = join(DIST, locale);
119+
check(existsSync(join(localeRoot, 'heroes', 'index.html')), `/${locale}/heroes/ 语言版首页存在`);
120+
for (const year of rankingYears) {
121+
check(
122+
existsSync(join(localeRoot, 'heroes', `ranking-${year}`, 'index.html')),
123+
`/${locale}/heroes/ranking-${year}/ 语言版榜单页存在`,
124+
);
125+
}
126+
const localeHeroDir = join(localeRoot, 'hero');
127+
for (const login of sample(heroLogins, 10)) {
128+
check(
129+
existsSync(join(localeHeroDir, login, 'index.html')),
130+
`/${locale}/hero/${login}/ 语言版详情页存在`,
131+
);
132+
}
133+
}
134+
113135
if (errors.length) {
114136
console.error(`\n❌ verify-urls 失败:${errors.length} 项未通过`);
115137
process.exit(1);
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
---
2+
import { t, type Locale } from '@opensource-win/ui';
3+
24
interface Props {
5+
locale: Locale;
36
login: string;
47
}
58
6-
const { login } = Astro.props;
9+
const { locale, login } = Astro.props;
710
---
811

9-
<!-- 终端风页面头部:纯 HTML/CSS 装饰(grid 背景 + 辉光 + 扫描线),零图片素材 -->
12+
{/* 终端风页面头部:纯 HTML/CSS 装饰(grid 背景 + 辉光 + 扫描线),零图片素材 */}
1013
<div class="detail-hero">
1114
<div class="detail-hero-scan" aria-hidden="true"></div>
1215
<div class="detail-hero-inner container">
1316
<div class="detail-hero-cmd"><span class="detail-term-prompt" aria-hidden="true">$</span> ./whois {login}</div>
1417
<div class="detail-hero-title" aria-hidden="true">&gt; DEVELOPER PROFILE<span class="detail-hero-cursor"></span></div>
1518
<div class="detail-hero-meta">
1619
<span class="hero-dot" aria-hidden="true"><span class="hero-dot-ping"></span><span class="hero-dot-core"></span></span>
17-
<span data-i18n-zh="码力榜开发者档案" data-i18n-en="HeroRank Developer Profile">码力榜开发者档案</span>
20+
<span>{t(locale, '码力榜开发者档案', 'HeroRank Developer Profile')}</span>
1821
</div>
1922
</div>
2023
</div>

apps/ossheroes/src/components/Footer.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ const footerYear = new Date().getFullYear();
44
---
55

66
<footer class="page-footer">
7-
<div data-i18n-zh={`Copyright © 2022 - ${footerYear} OpenSource.Win`} data-i18n-en={`Copyright © 2022 - ${footerYear} OpenSource.Win`}>Copyright © 2022 - {footerYear} OpenSource.Win</div>
7+
<div>Copyright © 2022 - {footerYear} OpenSource.Win</div>
88
<FooterCredit class="page-footer-credit" />
99
</footer>
Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
11
---
2+
import { t, type Locale } from '@opensource-win/ui';
23
import { getLatestRanking } from '../lib/ranking';
34
45
interface Props {
6+
locale: Locale;
57
/** 往年榜单页传入对应年份;缺省取最新一年 */
68
year?: number;
79
}
810
9-
const { year } = Astro.props;
11+
const { locale, year } = Astro.props;
1012
const heroYear = year ?? getLatestRanking().year;
1113
---
1214

13-
<!-- 终端风 hero:纯 HTML/CSS,不引入图片素材 -->
15+
{/* 终端风 hero:纯 HTML/CSS,不引入图片素材 */}
1416
<div class="hero">
1517
<div class="hero-inner container">
1618
<div class="hero-badge">
1719
<span class="hero-dot" aria-hidden="true"><span class="hero-dot-ping"></span><span class="hero-dot-core"></span></span>
18-
<span data-i18n-zh="系统广播" data-i18n-en="System Broadcast">系统广播</span>
20+
<span>{t(locale, '系统广播', 'System Broadcast')}</span>
1921
</div>
2022
<h1 class="hero-title">
2123
<span class="hero-cmd">./ranking --year {heroYear}</span>
22-
<span class="hero-hash" aria-hidden="true">#</span> <span data-i18n-zh="中国开源码力榜" data-i18n-en="China Open Source HeroRank">中国开源码力榜</span>
24+
<span class="hero-hash" aria-hidden="true">#</span> <span>{t(locale, '中国开源码力榜', 'China Open Source HeroRank')}</span>
2325
</h1>
24-
<p class="hero-subtitle" data-i18n-zh="由 OpenSource.Win、开源社、X-lab 开放实验室联合发起,基于 OpenRank 开源贡献度算法,甄选年度最具影响力的中国开源开发者。" data-i18n-en="Co-founded by OpenSource.Win, KAIYUANSHE and X-lab — an annual ranking of China's most impactful open source developers, powered by the OpenRank algorithm.">由 OpenSource.Win、开源社、X-lab 开放实验室联合发起,基于 OpenRank 开源贡献度算法,甄选年度最具影响力的中国开源开发者。</p>
26+
<p class="hero-subtitle">{t(locale, '由 OpenSource.Win、开源社、X-lab 开放实验室联合发起,基于 OpenRank 开源贡献度算法,甄选年度最具影响力的中国开源开发者。', "Co-founded by OpenSource.Win, KAIYUANSHE and X-lab — an annual ranking of China's most impactful open source developers, powered by the OpenRank algorithm.")}</p>
2527
</div>
2628
</div>

apps/ossheroes/src/components/HeroCard.astro

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
---
22
import Avatar from './Avatar.astro';
3+
import { localePath, type Locale } from '@opensource-win/ui';
34
import { HERO_BASE } from '../lib/site';
45
import { normalizeMeta } from '../lib/meta';
56
67
interface Props {
8+
locale: Locale;
79
login: string;
810
name?: string | null;
911
description?: string | null;
@@ -13,13 +15,13 @@ interface Props {
1315
hasPage: boolean;
1416
}
1517
16-
const { login, name, description, avatar, githubAvatar, hasPage } = Astro.props;
18+
const { locale, login, name, description, avatar, githubAvatar, hasPage } = Astro.props;
1719
const displayName = normalizeMeta(name) || login;
1820
const desc = normalizeMeta(description);
1921
---
2022

2123
{hasPage ? (
22-
<a class="user-item" href={`${HERO_BASE}/${login}/`}>
24+
<a class="user-item" href={localePath(locale, `${HERO_BASE}/${login}/`)}>
2325
<Avatar login={login} size={64} className="avatar" displayName={displayName} avatar={avatar} githubAvatar={githubAvatar} />
2426
<div class="nickname mt-2">{displayName}</div>
2527
<div class="description mt-1">{desc}</div>
@@ -30,4 +32,4 @@ const desc = normalizeMeta(description);
3032
<div class="nickname mt-2">{displayName}</div>
3133
<div class="description mt-1">{desc}</div>
3234
</div>
33-
)}
35+
)}

0 commit comments

Comments
 (0)