Skip to content

Commit 7e8f086

Browse files
committed
feat: migrate HeroRank URLs to heroes namespace
1 parent 5ebb033 commit 7e8f086

71 files changed

Lines changed: 416 additions & 275 deletions

Some content is hidden

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

‎.github/workflows/gh-pages.yml‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,16 @@ jobs:
3737
- name: Build ossheroes site
3838
run: pnpm --filter ossheroes build
3939

40+
- name: Verify ossheroes URLs and SEO
41+
run: |
42+
pnpm --filter ossheroes verify:urls
43+
pnpm --filter ossheroes verify:seo
44+
4045
- name: Merge outputs for GitHub Pages
4146
run: |
4247
rm -rf dist
43-
mkdir -p dist/ossheroes
4448
cp -r apps/www/dist/. dist/
45-
cp -r apps/ossheroes/dist/ossheroes/. dist/ossheroes/
49+
cp -r apps/ossheroes/dist/. dist/
4650
4751
# sanity checks
4852
for f in index.html sitemap.xml robots.txt llms.txt; do
@@ -51,8 +55,14 @@ jobs:
5155
exit 1
5256
fi
5357
done
54-
if [ ! -f dist/ossheroes/index.html ]; then
55-
echo "Error: dist/ossheroes/index.html not found"
58+
for f in heroes/index.html ossheroes/index.html; do
59+
if [ ! -f "dist/${f}" ]; then
60+
echo "Error: dist/${f} not found"
61+
exit 1
62+
fi
63+
done
64+
if ! find dist/hero -mindepth 2 -maxdepth 2 -name index.html -print -quit | grep -q .; then
65+
echo "Error: no developer profile was built under dist/hero"
5666
exit 1
5767
fi
5868

‎apps/ossheroes/DEVELOP.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
### 项目开发
22

33
本站基于 Astro 构建:开发者档案为 Content Collection(`src/content/heroes/<login>/index.md`),
4-
年度榜单由动态路由 `src/pages/ranking-[year].astro` 根据 `src/data/rankingList.json` 自动生成。
4+
年度榜单由动态路由 `src/pages/heroes/ranking-[year].astro` 根据 `src/data/rankingList.json` 自动生成。
55

66
> 兼容说明:仓库根的 `source` 是指向 `src/content/heroes` 的符号链接,仅供
77
> apps/www 的 SEO 脚本(generate-seo-assets.mjs)按旧路径读取开发者列表,请保留。
@@ -68,7 +68,7 @@
6868
### 往年榜单
6969

7070
榜单页由 Astro 动态路由 `src/pages/ranking-[year].astro` 根据 `src/data/rankingList.json`
71-
中存在的年份自动生成(`/ossheroes/ranking-<year>/`),无需手动创建页面文件。
71+
中存在的年份自动生成(`/heroes/ranking-<year>/`),无需手动创建页面文件。
7272

7373
`src/content/heroes/opensource-ranking/` 下的 `<year>.md` 是兼容占位文件:apps/www 的
7474
SEO 脚本通过它们枚举榜单年份生成根 sitemap。sync_xlab.js 将新年份写入 rankingList.json 后,

‎apps/ossheroes/astro.config.mjs‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
// @ts-check
22
import { defineConfig } from 'astro/config';
33
import react from '@astrojs/react';
4-
import sitemap from '@astrojs/sitemap';
54
import tailwindcss from '@tailwindcss/vite';
65

76
// https://astro.build/config
87
export default defineConfig({
98
site: 'https://opensource.win',
10-
base: '/ossheroes',
11-
// 产物输出至 dist/ossheroes;CI (gh-pages.yml) 会把该目录拷入 apps/www/dist/ossheroes。
12-
outDir: './dist/ossheroes',
9+
// 站点同时输出 /heroes/、/hero/<login>/ 与旧 /ossheroes/ 跳转页,不能再使用单一 base。
10+
base: '/',
11+
// CI 会把该目录的各个路由目录合并至 GitHub Pages 根目录。
12+
outDir: './dist',
1313
// 静态资源源目录用 static(assets 配置见下),与 outDir 分离避免混淆。
1414
publicDir: './static',
1515
build: {
@@ -19,8 +19,6 @@ export default defineConfig({
1919
},
2020
integrations: [
2121
react(),
22-
// 旧 .html 跳转页不进 sitemap
23-
sitemap({ filter: (page) => !page.includes('.html') }),
2422
],
2523
vite: {
2624
plugins: [tailwindcss()],

‎apps/ossheroes/package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
"start": "astro dev",
88
"build": "astro build",
99
"preview": "astro preview",
10-
"astro": "astro"
10+
"astro": "astro",
11+
"verify:urls": "node scripts/verify-urls.mjs",
12+
"verify:seo": "node scripts/verify-seo.mjs"
1113
},
1214
"dependencies": {
1315
"@astrojs/react": "^4.4.2",

‎apps/ossheroes/scripts/verify-seo.mjs‎

Lines changed: 93 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,131 @@
11
#!/usr/bin/env node
22
/**
3-
* verify-seo.mjs — 校验 ossheroes 开发者详情页的 SEO 结构化数据。
3+
* 校验规范页面只暴露 /heroes/ 与 /hero/ URL,并校验 sitemap / llms.txt。
44
*
5-
* 随机抽取 3 个开发者详情页 HTML,验证:
6-
* 1. 含 <script type="application/ld+json"> 且解析为 ProfilePage / Person
7-
* 2. 含 og:title、og:description、og:image、twitter:image 等标签
8-
*
9-
* 用法:node scripts/verify-seo.mjs [dist-dir]
10-
* 默认 dist-dir 为脚本上级目录的 dist/ossheroes。成功 exit 0,失败 exit 1。
5+
* 用法:node scripts/verify-seo.mjs [ossheroes-dist] [www-dist]
116
*/
127
import { readdirSync, existsSync, readFileSync, statSync } from 'node:fs';
138
import { join, dirname, resolve } from 'node:path';
149
import { fileURLToPath } from 'node:url';
1510

1611
const __dirname = dirname(fileURLToPath(import.meta.url));
17-
const DEFAULT_DIST = join(__dirname, '..', 'dist', 'ossheroes');
12+
const DEFAULT_DIST = join(__dirname, '..', 'dist');
13+
const DEFAULT_WWW_DIST = join(__dirname, '..', '..', 'www', 'dist');
1814
const DIST = process.argv[2] ? resolve(process.argv[2]) : DEFAULT_DIST;
19-
15+
const WWW_DIST = process.argv[3] ? resolve(process.argv[3]) : DEFAULT_WWW_DIST;
16+
const SITE = 'https://opensource.win';
2017
const errors = [];
21-
function check(cond, msg) {
22-
console.log(`${cond ? '✓' : '✗'} ${msg}`);
23-
if (!cond) errors.push(msg);
24-
}
2518

26-
/** 可复现的伪随机采样:固定种子的 mulberry32,CI 稳定但仍具随机性。 */
27-
function mulberry32(seed) {
28-
return function () {
29-
seed |= 0;
30-
seed = (seed + 0x6d2b79f5) | 0;
31-
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
32-
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
33-
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
34-
};
19+
function check(condition, message) {
20+
console.log(`${condition ? '✓' : '✗'} ${message}`);
21+
if (!condition) errors.push(message);
3522
}
36-
function sample(arr, n, seed = 99007711) {
37-
const rng = mulberry32(seed);
38-
const pool = [...arr];
39-
const out = [];
40-
while (out.length < n && pool.length) {
41-
out.push(pool.splice(Math.floor(rng() * pool.length), 1)[0]);
42-
}
43-
return out;
23+
24+
function isDir(path) {
25+
return existsSync(path) && statSync(path).isDirectory();
4426
}
4527

46-
function isDir(p) {
47-
return existsSync(p) && statSync(p).isDirectory();
28+
function read(path) {
29+
return readFileSync(path, 'utf8');
4830
}
4931

50-
/** 在所有 <meta> 标签中查找含 `attr="value"` 者,返回其 content(属性顺序无关)。 */
5132
function getMeta(html, attr, value) {
52-
const valueEsc = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53-
const attrRe = new RegExp(`${attr}=["']${valueEsc}["']`);
54-
const tag = (html.match(/<meta\b[^>]*>/gi) || []).find((t) => attrRe.test(t));
55-
if (!tag) return null;
56-
const cm = tag.match(/content=["']([^"']*)["']/i);
57-
return cm ? cm[1] : null;
33+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
34+
const attrRe = new RegExp(`${attr}=["']${escaped}["']`);
35+
const tag = (html.match(/<meta\b[^>]*>/gi) || []).find((item) => attrRe.test(item));
36+
return tag?.match(/content=["']([^"']*)["']/i)?.[1] ?? null;
37+
}
38+
39+
function getCanonical(html) {
40+
return html.match(/<link\s+rel=["']canonical["']\s+href=["']([^"']+)["']/i)?.[1] ?? null;
41+
}
42+
43+
function getJsonLd(html) {
44+
const matches = [...html.matchAll(/<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)];
45+
return matches.flatMap((match) => {
46+
try {
47+
return [JSON.parse(match[1])];
48+
} catch {
49+
return [];
50+
}
51+
});
5852
}
5953

60-
/** 递归收集 JSON-LD 对象中的所有 @type 值(含 mainEntity 等)。 */
61-
function collectTypes(obj, into) {
62-
if (!obj || typeof obj !== 'object') return;
63-
const t = obj['@type'];
64-
if (t) (Array.isArray(t) ? t : [t]).forEach((x) => into.add(x));
65-
for (const k of Object.keys(obj)) {
66-
if (k === '@type') continue;
67-
const v = obj[k];
68-
if (v && typeof v === 'object') collectTypes(v, into);
54+
function assertCanonicalPage(path, canonicalPath, label) {
55+
if (!existsSync(path)) {
56+
check(false, `${label}: 页面存在`);
57+
return '';
6958
}
59+
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}`);
63+
check(!html.includes(`${SITE}/ossheroes/`), `${label}: 不暴露旧规范 URL`);
64+
return html;
7065
}
7166

7267
if (!isDir(DIST)) {
7368
console.error(`✗ 产物目录不存在: ${DIST}(请先运行 pnpm --filter ossheroes build)`);
7469
process.exit(1);
7570
}
7671

77-
const entries = readdirSync(DIST);
78-
const candidateDirs = entries
79-
.filter((e) => !e.startsWith('.') && e !== 'assets' && !/^ranking-/.test(e))
80-
.filter((e) => isDir(join(DIST, e)))
81-
.sort();
82-
check(candidateDirs.length >= 3, `开发者目录数 >= 3(实际 ${candidateDirs.length})`);
72+
const heroesDir = join(DIST, 'heroes');
73+
const heroDir = join(DIST, 'hero');
74+
assertCanonicalPage(join(heroesDir, 'index.html'), '/heroes/', '/heroes/ 首页');
8375

84-
const picks = sample(candidateDirs, 3);
85-
for (const d of picks) {
86-
const file = join(DIST, d, 'index.html');
87-
if (!existsSync(file)) {
88-
check(false, `${d}: index.html 不存在`);
89-
continue;
90-
}
91-
const html = readFileSync(file, 'utf8');
76+
const rankingYears = isDir(heroesDir)
77+
? readdirSync(heroesDir).map((entry) => entry.match(/^ranking-(\d{4})$/)?.[1]).filter(Boolean).sort()
78+
: [];
79+
check(rankingYears.length > 0, '检测到年度榜单页面');
80+
for (const year of rankingYears) {
81+
assertCanonicalPage(join(heroesDir, `ranking-${year}`, 'index.html'), `/heroes/ranking-${year}/`, `/heroes/ranking-${year}/`);
82+
}
9283

93-
// 1. JSON-LD
94-
const ldRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
95-
const blocks = [...html.matchAll(ldRe)].map((m) => m[1]);
96-
check(blocks.length > 0, `${d}: 含 <script type="application/ld+json">`);
97-
const types = new Set();
98-
let parsed = 0;
99-
for (const b of blocks) {
100-
try {
101-
collectTypes(JSON.parse(b), types);
102-
parsed++;
103-
} catch {
104-
/* 忽略畸形块 */
105-
}
106-
}
107-
check(parsed > 0, `${d}: JSON-LD 可解析(${parsed}/${blocks.length})`);
108-
const seo = [...types];
109-
check(
110-
seo.includes('ProfilePage') || seo.includes('Person'),
111-
`${d}: JSON-LD 类型为 ProfilePage/Person(${seo.join('/') || '无'})`,
112-
);
84+
const logins = isDir(heroDir)
85+
? readdirSync(heroDir).filter((login) => existsSync(join(heroDir, login, 'index.html'))).sort()
86+
: [];
87+
check(logins.length >= 3, `开发者详情页数 >= 3(实际 ${logins.length})`);
88+
for (const login of logins.slice(0, 3)) {
89+
const canonicalPath = `/hero/${login}/`;
90+
const html = assertCanonicalPage(join(heroDir, login, 'index.html'), canonicalPath, `/hero/${login}/`);
91+
const jsonLd = getJsonLd(html);
92+
check(jsonLd.length > 0, `/hero/${login}/: JSON-LD 可解析`);
93+
const profile = jsonLd.find((item) => item['@type'] === 'ProfilePage');
94+
check(!!profile, `/hero/${login}/: JSON-LD 包含 ProfilePage`);
95+
check(!!getMeta(html, 'property', 'og:title'), `/hero/${login}/: og:title`);
96+
check(!!getMeta(html, 'property', 'og:description'), `/hero/${login}/: og:description`);
97+
check(!!getMeta(html, 'property', 'og:image'), `/hero/${login}/: og:image`);
98+
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`);
103+
}
113104

114-
// 2. OG + twitter 标签
115-
check(!!getMeta(html, 'property', 'og:title'), `${d}: og:title`);
116-
check(!!getMeta(html, 'property', 'og:description'), `${d}: og:description`);
117-
check(!!getMeta(html, 'property', 'og:image'), `${d}: og:image`);
118-
check(!!getMeta(html, 'name', 'twitter:image'), `${d}: twitter:image`);
105+
const sitemap = join(WWW_DIST, 'sitemap.xml');
106+
const llms = join(WWW_DIST, 'llms.txt');
107+
const hasWwwBuild = isDir(WWW_DIST);
108+
check(
109+
hasWwwBuild,
110+
`官网产物目录存在: ${WWW_DIST}(请先运行 pnpm --filter www build)`,
111+
);
112+
check(existsSync(sitemap), '根 sitemap.xml 已生成');
113+
if (existsSync(sitemap)) {
114+
const xml = read(sitemap);
115+
check(xml.includes(`${SITE}/heroes/`), 'sitemap 包含 /heroes/');
116+
check(xml.includes(`${SITE}/heroes/ranking-`), 'sitemap 包含 /heroes/ 年度榜单');
117+
check(xml.includes(`${SITE}/hero/`), 'sitemap 包含 /hero/ 开发者详情');
118+
check(!xml.includes('/ossheroes/'), 'sitemap 不含旧 /ossheroes/ URL');
119+
}
120+
check(existsSync(llms), 'llms.txt 已生成');
121+
if (existsSync(llms)) {
122+
const text = read(llms);
123+
check(text.includes(`${SITE}/heroes/`) && text.includes(`${SITE}/hero/`), 'llms.txt 使用新 URL');
124+
check(!text.includes('/ossheroes/'), 'llms.txt 不含旧 /ossheroes/ URL');
119125
}
120126

121127
if (errors.length) {
122128
console.error(`\n❌ verify-seo 失败:${errors.length} 项未通过`);
123129
process.exit(1);
124130
}
125131
console.log('\n✅ verify-seo 通过');
126-
process.exit(0);

0 commit comments

Comments
 (0)