Compare commits

...

2 Commits
v0.1.0 ... main

Author SHA1 Message Date
Kinneyzhang
aebdfaf926 release v0.1.2 2026-10-02 13:54:52 +08:00
Kinneyzhang
b900340a42 release v0.1.1 2026-10-02 09:30:47 +08:00
10 changed files with 241 additions and 76 deletions

View File

@ -4,13 +4,13 @@
"summary": "技能目录站:把对外发布的技能生成一个静态站(能干什么 / 先装什么 / 要配什么 / 依赖谁 / 一条安装命令),生成物不手改页面。", "summary": "技能目录站:把对外发布的技能生成一个静态站(能干什么 / 先装什么 / 要配什么 / 依赖谁 / 一条安装命令),生成物不手改页面。",
"tier": 1, "tier": 1,
"status": "stable", "status": "stable",
"version": "5da1a0f", "version": "5067fdf",
"source": { "source": {
"path": "skills/skill-catalog", "path": "skills/skill-catalog",
"commit": "5da1a0f", "commit": "5067fdf",
"describe": "5da1a0f", "describe": "5067fdf",
"dirty": false, "dirty": false,
"packed_at": "2026-10-02T01:27:14.099Z" "packed_at": "2026-10-02T05:54:51.818Z"
}, },
"platforms": [ "platforms": [
"darwin", "darwin",
@ -163,8 +163,8 @@
"files": [ "files": [
{ {
"path": "REFERENCE.md", "path": "REFERENCE.md",
"bytes": 7578, "bytes": 8622,
"sha256": "759ac4d91bed48ae" "sha256": "1f02f50d78756cb2"
}, },
{ {
"path": "SKILL.md", "path": "SKILL.md",
@ -174,17 +174,22 @@
{ {
"path": "VERSION", "path": "VERSION",
"bytes": 6, "bytes": 6,
"sha256": "e9dd8507f4bf0c6f" "sha256": "297b50f08386d352"
}, },
{ {
"path": "assets/app.js", "path": "assets/app.js",
"bytes": 3114, "bytes": 5134,
"sha256": "bfb8b03ff9d4b971" "sha256": "585048deddd24fa1"
},
{
"path": "assets/favicon.svg",
"bytes": 235,
"sha256": "dc255fa0fe62baca"
}, },
{ {
"path": "assets/style.css", "path": "assets/style.css",
"bytes": 16162, "bytes": 17877,
"sha256": "16217b36cb3db39d" "sha256": "d2a02fd09ea7ffa7"
}, },
{ {
"path": "contract.lock.json", "path": "contract.lock.json",
@ -193,8 +198,8 @@
}, },
{ {
"path": "interface.json", "path": "interface.json",
"bytes": 2783, "bytes": 2813,
"sha256": "d413b130d1140cfd" "sha256": "85140b7a2154ada6"
}, },
{ {
"path": "scripts/setup-hosting.sh", "path": "scripts/setup-hosting.sh",
@ -203,8 +208,8 @@
}, },
{ {
"path": "scripts/skill-catalog.mjs", "path": "scripts/skill-catalog.mjs",
"bytes": 36074, "bytes": 38433,
"sha256": "d97ba74c71008450" "sha256": "6faf465b64cc520a"
} }
], ],
"leak_scan": { "leak_scan": {

View File

@ -8,7 +8,7 @@
## 安装 ## 安装
```sh ```sh
pi install git:gitea.vhkd.top/geekinney/skill-catalog.git@v0.1.0 pi install git:gitea.vhkd.top/pi-hub/skill-catalog.git@v0.1.2
``` ```
装完 `pi-skill list` 能看到 `skill-catalog`,`pi-skill skill-catalog check` 会告诉还缺什么。 装完 `pi-skill list` 能看到 `skill-catalog`,`pi-skill skill-catalog check` 会告诉还缺什么。

View File

@ -1,5 +1,5 @@
{ {
"name": "skill-catalog", "name": "skill-catalog",
"version": "0.1.0", "version": "0.1.2",
"description": "技能目录站:把对外发布的技能生成一个静态站(能干什么 / 先装什么 / 要配什么 / 依赖谁 / 一条安装命令),生成物不手改页面。" "description": "技能目录站:把对外发布的技能生成一个静态站(能干什么 / 先装什么 / 要配什么 / 依赖谁 / 一条安装命令),生成物不手改页面。"
} }

View File

@ -109,6 +109,19 @@ DNS 加一条 `pi.vhkd.top A 47.243.111.1`(或先不加 DNS,用 IP/路径访
- **双主题**:明暗各一套变量;`<head>` 里的引导脚本按「上次选择 → 系统偏好」在首帧前设好 `data-theme`, - **双主题**:明暗各一套变量;`<head>` 里的引导脚本按「上次选择 → 系统偏好」在首帧前设好 `data-theme`,
导航右侧按钮切换并记住(`localStorage: pi-hub-theme`)。新组件必须两套主题下都看过。 导航右侧按钮切换并记住(`localStorage: pi-hub-theme`)。新组件必须两套主题下都看过。
## 4.3 产品级检查单(每次改页面前过一遍)
- **没有点不动的按钮**:静态信息(依赖名、平台、层级说明)用 `.meta-chip`(无边框、不悬停),
可点的才用边框/底色;不确定能不能点的,就别做成按钮的形状。
- **首屏动作要有意义**:安装示例固定取底座里最基础的一个(不是数组第一条),并明确标注「安装示例」。
- **键盘与小屏**:`/` 聚焦搜索、`Esc` 清空;筛选写进 URL(`#core,tool`)可分享可回退;
移动端只收起文字链接,**主题开关与搜索必须留**。
- **站点基础设施**:favicon、`theme-color`(明/暗两版)、OG/Twitter、`robots.txt`、`sitemap.xml`、canonical
—— 都由 `build` 生成,别手写。
- **生成物不进版本库**:`dist/` 已 .gitignore;要发布就跑 `build` + `deploy`。
- **无障碍最低线**:交互元素有 `aria-label`/`role`,状态用 `aria-pressed`;`focus-visible` 可见;
`prefers-reduced-motion` 关动效。
## 5. 想加东西往哪加 ## 5. 想加东西往哪加
- **新页面/新字段**:数据来自技能声明,先想清楚它是不是「声明里已经有的事实」——是就渲染, - **新页面/新字段**:数据来自技能声明,先想清楚它是不是「声明里已经有的事实」——是就渲染,

View File

@ -1 +1 @@
0.1.0 0.1.2

View File

@ -28,6 +28,8 @@
} }
if (count) count.textContent = String(shown); if (count) count.textContent = String(shown);
if (empty) empty.hidden = shown !== 0; if (empty) empty.hidden = shown !== 0;
const clear = document.getElementById('clear-filters');
if (clear) clear.hidden = activeLayers.size + activeRequires.size === 0;
}; };
search?.addEventListener('input', apply); search?.addEventListener('input', apply);
@ -77,5 +79,58 @@
} }
}); });
// 键盘:/ 聚焦搜索,Esc 清空并退出;筛选状态写进 URL(可分享/可回退)。
document.addEventListener('keydown', event => {
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName ?? '');
if (event.key === '/' && !typing) {
event.preventDefault();
search?.focus();
search?.select();
} else if (event.key === 'Escape' && (typing || search?.value)) {
if (search) {
search.value = '';
apply();
if (typing) search.blur();
}
}
});
const syncHash = () => {
const hash = activeLayers.size ? `#${[...activeLayers].sort().join(',')}` : '';
if (location.hash !== hash) history.replaceState(null, '', `${location.pathname}${location.search}${hash}`);
};
const readHash = () => {
const wanted = location.hash.replace(/^#/, '').split(',').map(s => s.trim()).filter(Boolean);
for (const chip of layerFilters) {
const layer = chip.dataset.layerFilter;
if (wanted.includes(layer)) {
activeLayers.add(layer);
chip.classList.add('active');
chip.setAttribute('aria-pressed', 'true');
}
}
};
for (const chip of layerFilters) {
chip.addEventListener('click', () => {
chip.setAttribute('aria-pressed', String(activeLayers.has(chip.dataset.layerFilter)));
syncHash();
});
}
window.addEventListener('hashchange', () => location.reload());
readHash();
if (activeLayers.size) apply();
document.getElementById('clear-filters')?.addEventListener('click', () => {
activeLayers.clear();
activeRequires.clear();
for (const chip of [...layerFilters, ...reqFilters]) {
chip.classList.remove('active');
chip.setAttribute('aria-pressed', 'false');
}
if (search) search.value = '';
history.replaceState(null, '', location.pathname);
apply();
});
apply(); apply();
})(); })();

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="6" fill="#1b1e24"/>
<text x="32" y="45" text-anchor="middle" font-family="Georgia, serif" font-size="40" fill="#fbfbf9">π</text>
</svg>

After

Width:  |  Height:  |  Size: 235 B

View File

@ -12,7 +12,7 @@
--ink: #1b1e24; --ink: #1b1e24;
--ink-2: #454b56; --ink-2: #454b56;
--muted: #767c88; --muted: #767c88;
--faint: #a3a8b2; --faint: #6f7683; /* 正文小字也要过 AA 对比度 */
--line: #e6e6e2; --line: #e6e6e2;
--line-2: #d2d2cd; --line-2: #d2d2cd;
--accent: #2f5bd7; --accent: #2f5bd7;
@ -57,7 +57,7 @@
--ink: #eceef2; --ink: #eceef2;
--ink-2: #c2c7d0; --ink-2: #c2c7d0;
--muted: #8b929e; --muted: #8b929e;
--faint: #6b7280; --faint: #8a919e;
--line: #26292e; --line: #26292e;
--line-2: #383c43; --line-2: #383c43;
--accent: #93aaff; --accent: #93aaff;
@ -98,13 +98,21 @@ h1 { font-size: clamp(26px, 3.2vw, var(--t-2xl)); line-height: 1.1; letter-spaci
/* 标签 / 导航 / 元信息:等宽小字,字距拉开,全大写。 */ /* 标签 / 导航 / 元信息:等宽小字,字距拉开,全大写。 */
.label, .nav a, .nav-brand, .ver, .stat, .count, .panel-head, .badge, .rel, .eyebrow, .label, .nav a, .nav-brand, .ver, .stat, .count, .panel-head, .badge, .rel, .eyebrow,
.card-foot, .foot h4, .foot-line, .cmd-step { .foot h4, .foot-line, .cmd-step {
font-family: var(--mono); font-family: var(--mono);
font-size: 11.5px; font-size: var(--t-xs);
letter-spacing: 0.12em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
} }
/* 卡片脚注:等宽小字,但保持原样大小写(命令名不该被大写化) */
.card-foot, .used {
font-family: var(--mono);
font-size: var(--t-xs);
letter-spacing: 0.04em;
text-transform: none;
}
a { color: inherit; text-decoration: none; } a { color: inherit; text-decoration: none; }
a:hover { color: var(--accent); } a:hover { color: var(--accent); }
code { font-family: var(--mono); font-size: 0.86em; letter-spacing: 0; } code { font-family: var(--mono); font-size: 0.86em; letter-spacing: 0; }
@ -117,7 +125,7 @@ code { font-family: var(--mono); font-size: 0.86em; letter-spacing: 0; }
.nav { .nav {
position: sticky; top: 0; z-index: 10; position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between; gap: var(--s-5); display: flex; align-items: center; justify-content: space-between; gap: var(--s-5);
padding: var(--s-4) 0; margin-bottom: var(--s-8); padding: var(--s-3) 0; margin-bottom: var(--s-6);
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg) 88%, transparent); background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
@ -145,8 +153,8 @@ code { font-family: var(--mono); font-size: 0.86em; letter-spacing: 0; }
[data-theme="dark"] .icon-sun { display: none; } [data-theme="dark"] .icon-sun { display: none; }
/* ── 主视觉 ───────────────────────────────────────── */ /* ── 主视觉 ───────────────────────────────────────── */
.masthead { margin-bottom: var(--s-7); } .masthead { margin-bottom: var(--s-6); }
.eyebrow { color: var(--faint); margin: 0 0 20px; } .eyebrow { color: var(--faint); margin: 0 0 var(--s-4); }
.lede { color: var(--ink-2); font-size: var(--t-md); margin: 0 0 var(--s-5); max-width: var(--measure-tight); } .lede { color: var(--ink-2); font-size: var(--t-md); margin: 0 0 var(--s-5); max-width: var(--measure-tight); }
.cta { .cta {
@ -210,33 +218,41 @@ button.chip:hover { color: var(--ink); border-color: var(--line-2); }
button.chip.active { color: var(--accent); border-color: currentColor; } button.chip.active { color: var(--accent); border-color: currentColor; }
.chip-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); } .chip-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
/* ── 分区面板 ─────────────────────────────────────── */ /* ── 分区与卡片 ───────────────────────────────────── */
.panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); margin-bottom: var(--s-5); overflow: hidden; } .layer-section { margin: 0 0 var(--s-7); }
.panel-head { .layer-head {
display: flex; align-items: center; gap: 10px; display: flex; align-items: baseline; gap: var(--s-2);
padding: 11px 16px; border-bottom: 1px solid var(--line); padding-bottom: var(--s-2); margin-bottom: var(--s-4);
color: var(--ink-2); background: var(--surface-2); border-bottom: 1px solid var(--line);
} }
.panel-count { color: var(--faint); } .layer-section .label { color: var(--ink-2); }
.panel-hint { margin-left: auto; color: var(--faint); letter-spacing: .04em; text-transform: none; font-family: var(--kai); font-size: 12.5px; } .layer-count { color: var(--faint); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(292px, 1fr)); } .layer-why {
margin-left: auto; color: var(--faint); font-size: var(--t-sm);
font-family: var(--kai); letter-spacing: 0; text-transform: none;
}
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--s-4); }
.card { .card {
display: flex; flex-direction: column; gap: var(--s-2); display: flex; flex-direction: column; gap: var(--s-2);
padding: var(--s-4) var(--s-5) var(--s-4); padding: var(--s-4) var(--s-5) var(--s-4);
border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); border: 1px solid var(--line); border-radius: var(--radius);
transition: background .15s; background: var(--surface);
transition: border-color .15s, background .15s, transform .15s;
} }
.card:hover { background: var(--surface-2); } .card:hover { border-color: var(--line-2); background: var(--surface-2); transform: translateY(-1px); }
.card-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } .card-top { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s-2); }
.card h3 { margin: 0; font-family: var(--mono); font-size: 15px; letter-spacing: 0; } .card h3 { margin: 0; font-family: var(--mono); font-size: 15px; letter-spacing: 0; }
.card .ver { color: var(--faint); } .card .ver { color: var(--faint); }
.card .summary { .card .summary {
margin: 0; color: var(--ink-2); font-size: var(--t-sm); line-height: 1.8; margin: 0; color: var(--ink-2); font-size: var(--t-sm); line-height: 1.8;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
} }
.card-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; color: var(--faint); margin-top: auto; padding-top: 4px; } .card-foot {
display: flex; align-items: center; gap: var(--s-2); flex-wrap: wrap;
color: var(--faint); margin-top: auto; padding-top: var(--s-2);
}
.card-layer { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); letter-spacing: .08em; } .card-layer { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); letter-spacing: .08em; }
.card-foot .chip { padding: 1px 8px; font-size: 11px; letter-spacing: .04em; text-transform: none; }
.used { margin-left: auto; } .used { margin-left: auto; }
/* ── 详情页 ───────────────────────────────────────── */ /* ── 详情页 ───────────────────────────────────────── */
@ -334,3 +350,38 @@ button.chip.active { color: var(--accent); border-color: currentColor; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { transition: none !important; } * { transition: none !important; }
} }
/* ── 产品细节:静态信息 vs 可点控件要有区分 ───────────── */
.meta-chip {
font-family: var(--mono); font-size: 11px; letter-spacing: .04em;
color: var(--faint); white-space: nowrap;
}
.meta-chip-warn { color: var(--warn); }
.card-foot .meta-chip { margin-right: 2px; }
.cta-label {
font-family: var(--mono); font-size: var(--t-xs); letter-spacing: .12em;
text-transform: uppercase; color: var(--faint); margin: 0 0 var(--s-2);
}
.cta-more { font-size: var(--t-sm); color: var(--muted); border-bottom: 1px solid var(--line-2); padding-bottom: 1px; }
.cta-more:hover { color: var(--accent); border-color: currentColor; }
/* 移动端:只收起文字链接,主题开关必须留着 */
@media (max-width: 720px) {
.nav-links { gap: 0; }
.nav-links a { display: none; }
}
/* 筛选行:先说清是什么,再给「清除」出口 */
.chipbar { align-items: center; }
.chip-label {
font-family: var(--mono); font-size: var(--t-xs); letter-spacing: .12em;
text-transform: uppercase; color: var(--faint); margin-right: var(--s-1);
}
.chip.clear { color: var(--muted); border-style: dashed; }
.chip.clear:hover { color: var(--ink); border-style: solid; }
/* 详情页:什么时候用(收集了就必须展示) */
.use-when { margin: 0 0 var(--s-4); color: var(--muted); font-size: var(--t-sm); max-width: var(--measure); }
.use-when .label { color: var(--faint); margin-right: var(--s-2); }

View File

@ -101,6 +101,7 @@
} }
], ],
"publish": { "publish": {
"package_repo": "https://gitea.vhkd.top/geekinney/skill-catalog.git" "package_repo": "https://gitea.vhkd.top/pi-hub/skill-catalog.git"
} },
"license": "GPL-3.0-or-later"
} }

View File

@ -30,7 +30,7 @@ import { resolveConfig, agentDirOf } from '../../skill-interface/scripts/skill-c
import { scanLeaks } from '../../skill-interface/scripts/skill-pack.mjs'; import { scanLeaks } from '../../skill-interface/scripts/skill-pack.mjs';
const SKILL = 'skill-catalog'; const SKILL = 'skill-catalog';
const BRAND = { name: 'Pi Hub', tagline: '为 Pi 装上你要的能力', scope: '技能 · 扩展 · 主题 · 提示模板' }; const BRAND = { name: 'Pi Hub', headline: 'Pi 能力目录', scope: '技能 · 扩展 · 主题 · 提示模板' };
const SCOPE_HTML = '<span class="nowrap">技能 · 扩展 · 主题 · 提示模板</span>'; const SCOPE_HTML = '<span class="nowrap">技能 · 扩展 · 主题 · 提示模板</span>';
// 字体三套(都 OFL,可自托管):正文楷(读者喜欢的)+ 标题衬线(中文用思源宋)+ // 字体三套(都 OFL,可自托管):正文楷(读者喜欢的)+ 标题衬线(中文用思源宋)+
// 拉丁/等宽 PragmataPro(本机 local(),商用字体不随站分发)。 // 拉丁/等宽 PragmataPro(本机 local(),商用字体不随站分发)。
@ -122,6 +122,7 @@ export function collectCatalog(repo = repoRoot()) {
return { return {
name: skill.name, name: skill.name,
status: declared.status ?? 'stable', status: declared.status ?? 'stable',
license: declared.license ?? '',
declaredLayer: declared.catalog?.layer ?? null, declaredLayer: declared.catalog?.layer ?? null,
summary: declared.summary ?? '', summary: declared.summary ?? '',
useWhen: declared.useWhen ?? '', useWhen: declared.useWhen ?? '',
@ -206,7 +207,7 @@ function siteNav(depth = 0) {
<a class="nav-brand" href="${prefix}index.html"><span class="brand-mark">π</span><span><strong>${BRAND.name}</strong><em>${BRAND.scope}</em></span></a> <a class="nav-brand" href="${prefix}index.html"><span class="brand-mark">π</span><span><strong>${BRAND.name}</strong><em>${BRAND.scope}</em></span></a>
<div class="nav-links"> <div class="nav-links">
<a href="${prefix}index.html">目录</a> <a href="${prefix}index.html">目录</a>
<a href="https://gitea.vhkd.top/geekinney" target="_blank" rel="noreferrer">仓库</a> <a href="https://gitea.vhkd.top/pi-hub" target="_blank" rel="noreferrer">仓库</a>
<a href="https://pi.dev/packages" target="_blank" rel="noreferrer">官方画廊</a> <a href="https://pi.dev/packages" target="_blank" rel="noreferrer">官方画廊</a>
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="切换明暗主题" title="切换明暗主题"> <button class="theme-toggle" id="theme-toggle" type="button" aria-label="切换明暗主题" title="切换明暗主题">
<svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><g stroke-linecap="round"><path d="M12 2.6v2.2M12 19.2v2.2M2.6 12h2.2M19.2 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M18.7 5.3l-1.6 1.6M6.9 17.1l-1.6 1.6"/></g></svg> <svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><g stroke-linecap="round"><path d="M12 2.6v2.2M12 19.2v2.2M2.6 12h2.2M19.2 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M18.7 5.3l-1.6 1.6M6.9 17.1l-1.6 1.6"/></g></svg>
@ -223,7 +224,7 @@ function siteFoot(depth = 0) {
<div><h4>关于</h4><p>这里收录的条目都从技能自己的声明生成,和仓库同步;页面不手改。</p></div> <div><h4>关于</h4><p>这里收录的条目都从技能自己的声明生成,和仓库同步;页面不手改。</p></div>
<div><h4>资源</h4><ul> <div><h4>资源</h4><ul>
<li><a href="https://pi.dev/packages" target="_blank" rel="noreferrer">官方画廊 pi.dev/packages</a></li> <li><a href="https://pi.dev/packages" target="_blank" rel="noreferrer">官方画廊 pi.dev/packages</a></li>
<li><a href="https://gitea.vhkd.top/geekinney" target="_blank" rel="noreferrer">Gitea 仓库</a></li> <li><a href="https://gitea.vhkd.top/pi-hub" target="_blank" rel="noreferrer">Gitea 仓库</a></li>
<li><a href="${prefix}catalog.json">catalog.json(机器可读)</a></li> <li><a href="${prefix}catalog.json">catalog.json(机器可读)</a></li>
</ul></div> </ul></div>
<div><h4>字体</h4><ul> <div><h4>字体</h4><ul>
@ -231,7 +232,7 @@ function siteFoot(depth = 0) {
<li>拉丁 Pragmata Pro(读者本机)</li> <li>拉丁 Pragmata Pro(读者本机)</li>
</ul></div> </ul></div>
</div> </div>
<p class="foot-line">${BRAND.name} · 由 skill-catalog 生成 · 条目与技能声明一致</p> <p class="foot-line">${BRAND.name} · 更新于 ${escapeHtml(new Date().toISOString().slice(0, 10))} · 由 skill-catalog 从技能声明生成</p>
</footer>`; </footer>`;
} }
@ -253,6 +254,14 @@ function pageShell({ title, description, body, siteUrl, depth = 0, fontsCss = nu
<title>${escapeHtml(title)}</title> <title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}"> <meta name="description" content="${escapeHtml(description)}">
${fontsCss ? fontsCss.map(href => `<link rel="stylesheet" href="${prefix}assets/${href}">`).join('\n') : ''} ${fontsCss ? fontsCss.map(href => `<link rel="stylesheet" href="${prefix}assets/${href}">`).join('\n') : ''}
<link rel="icon" href="${prefix}assets/favicon.svg" type="image/svg+xml">
<meta name="theme-color" content="#fbfbf9" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#101114" media="(prefers-color-scheme: dark)">
<meta property="og:type" content="website">
<meta property="og:title" content="${escapeHtml(title)}">
<meta property="og:description" content="${escapeHtml(description)}">
${siteUrl ? `<meta property="og:url" content="${escapeHtml(siteUrl)}">` : ''}
<meta name="twitter:card" content="summary">
<link rel="stylesheet" href="${prefix}assets/style.css"> <link rel="stylesheet" href="${prefix}assets/style.css">
${siteUrl ? `<link rel="canonical" href="${escapeHtml(siteUrl)}">` : ''} ${siteUrl ? `<link rel="canonical" href="${escapeHtml(siteUrl)}">` : ''}
</head> </head>
@ -277,12 +286,14 @@ const LAYER_HINT = {
function cardOf(entry) { function cardOf(entry) {
const facts = [ const facts = [
...entry.requires.slice(0, 3).map(item => `<span class="chip">${escapeHtml(item.name)}</span>`), ...entry.requires.slice(0, 3).map(item => `<span class="meta-chip">${escapeHtml(item.name)}</span>`),
...(entry.installable ? [] : ['<span class="chip chip-warn">依赖未公开</span>']), ...(entry.installable ? [] : ['<span class="meta-chip meta-chip-warn">依赖未公开</span>']),
].join(''); ].join('');
const used = entry.usedByCount ? `<span class="used">被 ${entry.usedByCount} 个技能使用</span>` : ''; const used = entry.usedByCount ? `<span class="used">被 ${entry.usedByCount} 个技能使用</span>` : '';
return `<a class="card" href="skills/${escapeHtml(entry.name)}.html" data-layer="${entry.layer}" return `<a class="card" href="skills/${escapeHtml(entry.name)}.html" data-layer="${entry.layer}"
data-search="${escapeHtml(`${entry.name} ${entry.summary} ${entry.useWhen} ${entry.layer} ${entry.commands.map(c => c.name).join(' ')}`)}"> data-search="${escapeHtml(
`${entry.name} ${entry.summary} ${entry.useWhen} ${entry.layer} ${entry.commands.map(c => c.name).join(' ')} ${entry.requires.map(r => r.name).join(' ')}`,
)}">
<div class="card-top"><h3>${escapeHtml(entry.name)}</h3><span class="ver">v${escapeHtml(entry.version)}</span></div> <div class="card-top"><h3>${escapeHtml(entry.name)}</h3><span class="ver">v${escapeHtml(entry.version)}</span></div>
<p class="summary">${escapeHtml(entry.summary)}</p> <p class="summary">${escapeHtml(entry.summary)}</p>
<div class="card-foot"> <div class="card-foot">
@ -377,14 +388,15 @@ ${entry.usesPublic.length || entry.usedBy.length ? '' : '<li class="muted">独
<a class="back" href="../index.html">← 全部技能</a> <a class="back" href="../index.html">← 全部技能</a>
<div class="title-row"> <div class="title-row">
<h1>${escapeHtml(entry.name)}</h1> <h1>${escapeHtml(entry.name)}</h1>
<span class="ver">v${escapeHtml(entry.version)}</span> ${entry.version ? `<span class="ver">v${escapeHtml(entry.version)}</span>` : '<span class="ver">未标版本</span>'}
</div> </div>
<p class="lede">${escapeHtml(entry.summary)}</p> <p class="lede">${escapeHtml(entry.summary)}</p>
${entry.useWhen ? `<p class="use-when"><span class="label">什么时候用</span>${escapeHtml(entry.useWhen)}</p>` : ''}
<div class="chips"> <div class="chips">
<span class="chip layer-${entry.layer}">${LAYER_LABEL[entry.layer]}</span> <span class="chip layer-${entry.layer}">${LAYER_LABEL[entry.layer]}</span>
<span class="chip">${LAYER_HINT[entry.layer]}</span> <span class="meta-chip">${LAYER_HINT[entry.layer]}</span>
${entry.status !== 'stable' ? `<span class="chip chip-warn">${entry.status === 'experimental' ? '实验性' : '将退役'}</span>` : ''} ${entry.status !== 'stable' ? `<span class="meta-chip meta-chip-warn">${entry.status === 'experimental' ? '实验性' : '将退役'}</span>` : ''}
${entry.platforms.map(platform => `<span class="chip">${escapeHtml(platform)}</span>`).join('')} ${entry.platforms.map(platform => `<span class="meta-chip">${escapeHtml(platform)}</span>`).join('')}
</div> </div>
</header> </header>
<div class="detail-grid"> <div class="detail-grid">
@ -396,10 +408,13 @@ ${entry.usesPublic.length || entry.usedBy.length ? '' : '<li class="muted">独
</article> </article>
<aside> <aside>
<div class="panel side"><div class="panel-head"><span class="label">关系</span></div><div class="panel-body">${relations}</div></div> <div class="panel side"><div class="panel-head"><span class="label">关系</span></div><div class="panel-body">${relations}</div></div>
<div class="panel side"><div class="panel-head"><span class="label">许可</span></div><div class="panel-body">
<p class="muted small">${entry.license ? escapeHtml(entry.license) : '未声明许可(以仓库为准)'}</p>
</div></div>
<div class="panel side"><div class="panel-head"><span class="label">仓库</span></div><div class="panel-body"> <div class="panel side"><div class="panel-head"><span class="label">仓库</span></div><div class="panel-body">
<ul class="repos"> <ul class="repos">
${entry.repos.package ? `<li><span class="rel">Pi 包</span><a href="${escapeHtml(entry.repos.package)}">${escapeHtml(entry.repos.package.replace(/^https?:\/\//, ''))}</a></li>` : ''} ${entry.repos.package ? `<li><span class="rel">Pi 包</span><a target="_blank" rel="noreferrer" href="${escapeHtml(entry.repos.package)}">${escapeHtml(entry.repos.package.replace(/^https?:\/\//, ''))}</a></li>` : ''}
${entry.repos.cli ? `<li><span class="rel">CLI</span><a href="${escapeHtml(entry.repos.cli)}">${escapeHtml(entry.repos.cli.replace(/^https?:\/\//, ''))}</a></li>` : ''} ${entry.repos.cli ? `<li><span class="rel">CLI</span><a target="_blank" rel="noreferrer" href="${escapeHtml(entry.repos.cli)}">${escapeHtml(entry.repos.cli.replace(/^https?:\/\//, ''))}</a></li>` : ''}
</ul> </ul>
</div></div> </div></div>
</aside> </aside>
@ -422,33 +437,34 @@ function indexPage(entries, siteUrl, fontsCss) {
.map(layer => { .map(layer => {
const items = entries.filter(entry => entry.layer === layer); const items = entries.filter(entry => entry.layer === layer);
if (!items.length) return ''; if (!items.length) return '';
return `<section class="panel layer-section" data-layer="${layer}"> return `<section class="layer-section" data-layer="${layer}">
<div class="panel-head"> <div class="layer-head">
<span class="dot"></span><span class="label">${LAYER_LABEL[layer]}</span> <span class="dot"></span><span class="label">${LAYER_LABEL[layer]}</span>
<span class="panel-count">${items.length}</span> <span class="layer-count">${items.length}</span>
<span class="panel-hint">${LAYER_HINT[layer]}</span> <span class="layer-why">${LAYER_HINT[layer]}</span>
</div> </div>
<div class="grid">${items.map(cardOf).join('\n')}</div> <div class="grid">${items.map(cardOf).join('\n')}</div>
</section>`; </section>`;
}) })
.join('\n'); .join('\n');
const latest = entries[0]; // 安装示例取最底层的一个:稳定、有意义,不是随便挑第一条。
const kinds = entries.length; const sample = entries.find(entry => entry.layer === 'core') ?? entries[0];
const body = `<header class="masthead"> const body = `<header class="masthead">
<p class="eyebrow">${BRAND.name} · 自托管目录</p> <p class="eyebrow">${BRAND.name} · 自托管目录</p>
<h1>${BRAND.tagline}</h1> <h1>${BRAND.headline}</h1>
<p class="lede">${SCOPE_HTML}的集中目录。每条都写清依赖、前置条件与安装命令,装完就能用。</p> <p class="lede">集中收录${SCOPE_HTML}。每条都写清依赖、前置条件与安装方式,装完即用。</p>
${ ${
latest sample
? `<div class="cta"> ? `<p class="cta-label">安装示例</p>
<code class="cta-cmd">${escapeHtml(latest.install.pi)}</code> <div class="cta">
<button class="copy primary" data-copy="${escapeHtml(latest.install.pi)}">复制命令</button> <code class="cta-cmd">${escapeHtml(sample.install.pi)}</code>
</div> <button class="copy primary" data-copy="${escapeHtml(sample.install.pi)}">复制命令</button>
<p class="cta-hint">先从 <a href="skills/${escapeHtml(latest.name)}.html">${escapeHtml(latest.name)}</a> 试起</p>` <a class="cta-more" href="skills/${escapeHtml(sample.name)}.html">看它怎么用 →</a>
</div>`
: '' : ''
} }
<div class="meta"> <div class="meta">
<span class="stat">${kinds} 个条目</span> <span class="stat">${entries.length} 个条目</span>
<span class="legend"> <span class="legend">
<span class="legend-item core"><span class="dot"></span>底座</span> <span class="legend-item core"><span class="dot"></span>底座</span>
<span class="legend-item tool"><span class="dot"></span>工具</span> <span class="legend-item tool"><span class="dot"></span>工具</span>
@ -458,29 +474,38 @@ function indexPage(entries, siteUrl, fontsCss) {
</div> </div>
</header> </header>
<div class="toolbar"> <div class="toolbar">
<input id="search" type="search" placeholder="搜索技能、命令或关键词" autocomplete="off"> <input id="search" type="search" placeholder="搜索技能、命令或关键词…" aria-label="搜索技能" autocomplete="off">
<div class="segmented" id="layers"> <div class="segmented" id="layers" role="group" aria-label="按层筛选">
${layers ${layers
.filter(layer => entries.some(entry => entry.layer === layer)) .filter(layer => entries.some(entry => entry.layer === layer))
.map(layer => `<button class="seg" data-layer-filter="${layer}">${LAYER_LABEL[layer]}</button>`) .map(layer => `<button class="seg" data-layer-filter="${layer}" aria-pressed="false">${LAYER_LABEL[layer]}</button>`)
.join('')} .join('')}
</div> </div>
<span class="count" id="count">${entries.length}</span> <span class="count" id="count">${entries.length}</span>
</div> </div>
${requires.length ? `<div class="chipbar" id="requires">${requires.map(name => `<button class="chip filter" data-requires="${escapeHtml(name)}">${escapeHtml(name)}</button>`).join('')}</div>` : ''} ${
requires.length
? `<div class="chipbar" id="requires">
<span class="chip-label">按依赖筛选</span>
${requires.map(name => `<button class="chip filter" data-requires="${escapeHtml(name)}" aria-pressed="false">${escapeHtml(name)}</button>`).join('')}
<button class="chip clear" id="clear-filters" type="button" hidden>清除筛选</button>
</div>`
: ''
}
<main id="sections"> <main id="sections">
${sections} ${sections}
</main> </main>
<p class="empty muted" id="empty" hidden>没有匹配的技能。</p>`; <p class="empty muted" id="empty" hidden>没有匹配的技能。</p>`;
return pageShell({ return pageShell({
title: 'Pi 能力目录', title: `${BRAND.headline} · ${BRAND.name}`,
description: 'Pi 能力目录:技能与扩展,写清依赖与前置条件,一条命令装好就能用。', description: '集中收录 Pi 的技能、扩展、主题与提示模板:写清依赖、前置条件与安装方式,一条命令装好即用。',
body, body,
fontsCss, fontsCss,
siteUrl: siteUrl ?? '', siteUrl: siteUrl ?? '',
}); });
} }
/** 取(或首次下载)一套网络字体到 local 缓存;失败返回 { dir: null, error }。 */ /** 取(或首次下载)一套网络字体到 local 缓存;失败返回 { dir: null, error }。 */
export function ensureFont(repo, font) { export function ensureFont(repo, font) {
// 字体缓存是机器级的(与构建哪个仓库无关),跟着 agent 目录走。 // 字体缓存是机器级的(与构建哪个仓库无关),跟着 agent 目录走。
@ -535,6 +560,17 @@ export function buildCatalog({ repo = repoRoot(), out, siteUrl = '' } = {}) {
fs.mkdirSync(path.join(outDir, 'assets'), { recursive: true }); fs.mkdirSync(path.join(outDir, 'assets'), { recursive: true });
fs.cpSync(path.join(SKILL_DIR, 'assets', 'style.css'), path.join(outDir, 'assets', 'style.css')); fs.cpSync(path.join(SKILL_DIR, 'assets', 'style.css'), path.join(outDir, 'assets', 'style.css'));
fs.cpSync(path.join(SKILL_DIR, 'assets', 'app.js'), path.join(outDir, 'assets', 'app.js')); fs.cpSync(path.join(SKILL_DIR, 'assets', 'app.js'), path.join(outDir, 'assets', 'app.js'));
fs.cpSync(path.join(SKILL_DIR, 'assets', 'favicon.svg'), path.join(outDir, 'assets', 'favicon.svg'));
const siteRoot = (siteUrl || '').replace(/\/$/, '');
const urls = [`${siteRoot}/`, ...entries.map(entry => `${siteRoot}/skills/${entry.name}.html`)];
fs.writeFileSync(path.join(outDir, 'robots.txt'), `User-agent: *\nAllow: /\n${siteRoot ? `Sitemap: ${siteRoot}/sitemap.xml\n` : ''}`);
fs.writeFileSync(
path.join(outDir, 'sitemap.xml'),
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls
.filter(url => url.startsWith('http'))
.map(url => ` <url><loc>${url}</loc></url>`)
.join('\n')}\n</urlset>\n`,
);
// 中文正文字体(霞鹜文楷 Screen):首次构建下载到 local 缓存,之后直接用; // 中文正文字体(霞鹜文楷 Screen):首次构建下载到 local 缓存,之后直接用;
// 拿不到就退回系统字体并 warn(页面仍可用)。 // 拿不到就退回系统字体并 warn(页面仍可用)。
const fontLinks = []; const fontLinks = [];