📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
|
||||
const repositoryName = process.env.GITHUB_REPOSITORY?.split("/")[1];
|
||||
const isGitHubPages = Boolean(process.env.GITHUB_ACTIONS && repositoryName);
|
||||
|
||||
export default defineConfig({
|
||||
site: process.env.SITE_URL ?? "http://localhost:4321",
|
||||
base: isGitHubPages ? `/${repositoryName}` : "/",
|
||||
output: "static",
|
||||
trailingSlash: "never",
|
||||
});
|
||||
Generated
+5575
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "cangjie-skill-website",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "npm run validate:registry && astro build",
|
||||
"preview": "astro preview",
|
||||
"check": "astro check",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"validate:registry": "node scripts/validate-registry.mjs",
|
||||
"verify": "npm run validate:registry && npm test && npm run check && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "0.9.9",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"astro": "7.2.9",
|
||||
"fuse.js": "7.4.2",
|
||||
"js-yaml": "5.4.1",
|
||||
"jszip": "3.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "24.10.1",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# 仓颉 Skill 安装规范
|
||||
|
||||
这份文档供 Agent 读取。用户会同时提供 Skill 名称、来源仓库,以及可能存在的仓库内路径。
|
||||
|
||||
## 目标
|
||||
|
||||
根据当前 Agent 和工作环境,安全地把目标 Skill 安装到能够被识别的 Skills 目录,并向用户报告安装结果。不要要求用户自己判断目录或手动复制文件。
|
||||
|
||||
## 一、判断安装位置
|
||||
|
||||
先识别当前正在运行的 Agent,再选择对应目录:
|
||||
|
||||
- Codex:全局 `~/.codex/skills/`;项目级 `<project>/.agents/skills/`
|
||||
- Claude Code:`~/.claude/skills/`
|
||||
- Cursor:`~/.cursor/skills/`
|
||||
- Windsurf:`~/.codeium/windsurf/skills/` 或项目级 `.windsurf/skills/`
|
||||
- Gemini CLI:`~/.gemini/skills/`
|
||||
- QoderWork:`~/.qoderwork/skills/`
|
||||
- WorkBuddy:`~/.workbuddy/skills/`
|
||||
- 其他 Agent:检查其官方配置或当前环境中的 Skills 目录
|
||||
|
||||
用户明确要求安装到当前项目时,优先使用项目级目录;否则使用当前 Agent 的全局目录。已经能从当前环境确认时,不要反问用户。
|
||||
|
||||
## 二、检查来源
|
||||
|
||||
1. 确认来源 URL 可访问,并记录仓库和当前版本或 commit。
|
||||
2. 在临时目录下载或克隆来源,不要直接覆盖已有 Skill。
|
||||
3. 阅读 README、SKILL.md 和安装说明。
|
||||
4. 检查可疑脚本、外部下载、密钥要求和同名目录冲突。
|
||||
5. 遇到需要执行未审查脚本、覆盖本地修改、索取凭证或提升权限时,先向用户说明风险并请求确认。
|
||||
|
||||
## 三、识别并安装
|
||||
|
||||
1. 在指定来源或仓库内路径中查找所有 `SKILL.md`。
|
||||
2. 每个包含 `SKILL.md` 的目录视为一个可安装 Skill;复制该目录及其引用的脚本、模板和资源,保持相对路径不变。
|
||||
3. 不复制 `.git/`、构建缓存、原始大型素材、`.env`、密钥和无关文件。
|
||||
4. 将 Skill 安装到第一节确定的目录。若同名 Skill 已存在:
|
||||
- 内容和版本一致:跳过并说明;
|
||||
- 可以安全升级:保留必要的本地配置后更新;
|
||||
- 存在本地修改或不确定:停止覆盖并询问用户。
|
||||
|
||||
## 四、验证
|
||||
|
||||
安装后完成以下检查:
|
||||
|
||||
1. 确认目标目录内存在可读取的 `SKILL.md`。
|
||||
2. 检查 Skill 的名称、描述和触发条件是否能被当前 Agent 识别。
|
||||
3. 如果当前 Agent 需要重新扫描,提示用户开启新会话或执行必要的重新加载操作。
|
||||
4. 最终报告:来源、安装目录、安装或更新的 Skill 列表、跳过项、风险提示和验证结果。
|
||||
|
||||
安装成功后,不要立刻执行 Skill 所描述的业务任务,除非用户同时提出了该任务。
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import addFormats from "ajv-formats";
|
||||
import { load as loadYaml } from "js-yaml";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(here, "../..");
|
||||
const registryDir = join(root, "registry");
|
||||
const schemasDir = join(root, "schemas");
|
||||
|
||||
const loadSchema = async (name) => JSON.parse(await readFile(join(schemasDir, name), "utf8"));
|
||||
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
addFormats(ajv);
|
||||
// dispatcher 通过 $id 引用 v1/v2,必须先注册子 schema(ADR-004)
|
||||
ajv.addSchema(await loadSchema("registry-entry-v1.schema.json"));
|
||||
ajv.addSchema(await loadSchema("registry-entry-v2.schema.json"));
|
||||
const validate = ajv.compile(await loadSchema("registry-entry.schema.json"));
|
||||
|
||||
const folders = (await readdir(registryDir, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
const errors = [];
|
||||
const slugs = new Set();
|
||||
let legacyPacks = 0;
|
||||
let legacySkills = 0;
|
||||
let v2Packs = 0;
|
||||
let v2Entrypoints = 0;
|
||||
let v2Capabilities = 0;
|
||||
|
||||
for (const folder of folders) {
|
||||
const entryPath = join(registryDir, folder, "entry.yaml");
|
||||
let entry;
|
||||
|
||||
try {
|
||||
entry = loadYaml(await readFile(entryPath, "utf8"));
|
||||
} catch (error) {
|
||||
errors.push(`${folder}: cannot read entry.yaml (${error.message})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validate(entry)) {
|
||||
for (const issue of validate.errors ?? []) {
|
||||
errors.push(`${folder}${issue.instancePath || "/"}: ${issue.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry?.slug !== folder) {
|
||||
errors.push(`${folder}: folder name must equal slug "${entry?.slug ?? "missing"}"`);
|
||||
}
|
||||
if (slugs.has(entry?.slug)) {
|
||||
errors.push(`${folder}: duplicate slug "${entry.slug}"`);
|
||||
}
|
||||
slugs.add(entry?.slug);
|
||||
|
||||
if (entry?.schema_version === 2) {
|
||||
// v2 不变量:skill_count 是兼容别名,必须等于 entrypoint_count
|
||||
if (entry.skill_count !== entry.entrypoint_count) {
|
||||
errors.push(`${folder}: v2 requires skill_count (${entry.skill_count}) === entrypoint_count (${entry.entrypoint_count})`);
|
||||
}
|
||||
if (entry.capability_count < entry.entrypoint_count && entry.output_mode !== "legacy-pack") {
|
||||
errors.push(`${folder}: capability_count (${entry.capability_count}) must be >= entrypoint_count (${entry.entrypoint_count})`);
|
||||
}
|
||||
v2Packs += 1;
|
||||
v2Entrypoints += Number(entry.entrypoint_count ?? 0);
|
||||
v2Capabilities += Number(entry.capability_count ?? 0);
|
||||
} else {
|
||||
legacyPacks += 1;
|
||||
legacySkills += Number(entry?.skill_count ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (folders.length === 0) errors.push("registry: no entries found");
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Registry validation failed with ${errors.length} error(s):`);
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Registry valid: ${folders.length} packs — legacy ${legacyPacks} packs / ${legacySkills} skills; ` +
|
||||
`v2 ${v2Packs} packs / ${v2Entrypoints} entrypoints / ${v2Capabilities} capabilities.`,
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
import type { RegistryEntry } from "@/lib/catalog";
|
||||
import { getEntrypointCount, getOutputMode, searchableText } from "@/lib/catalog";
|
||||
import { withBase } from "@/lib/urls";
|
||||
|
||||
interface Props {
|
||||
entry: RegistryEntry;
|
||||
filterable?: boolean;
|
||||
}
|
||||
|
||||
const { entry, filterable = false } = Astro.props;
|
||||
const qualityLabel = {
|
||||
verified: "已验证",
|
||||
community: "社区收录",
|
||||
experimental: "实验性",
|
||||
}[entry.quality];
|
||||
const outputMode = getOutputMode(entry);
|
||||
const entrypointCount = getEntrypointCount(entry);
|
||||
const countLabel =
|
||||
outputMode === "single"
|
||||
? `1 入口 · ${entry.capability_count ?? "?"} 能力`
|
||||
: outputMode === "pack"
|
||||
? `${entrypointCount} 入口 · ${entry.capability_count ?? "?"} 能力`
|
||||
: `${entry.skill_count} Skills`;
|
||||
---
|
||||
|
||||
<article
|
||||
class="skill-card"
|
||||
data-skill-card={filterable ? "" : undefined}
|
||||
data-search={filterable ? searchableText(entry) : undefined}
|
||||
data-domains={filterable ? entry.domains.join("|") : undefined}
|
||||
data-quality={filterable ? entry.quality : undefined}
|
||||
data-source={filterable ? entry.source_type : undefined}
|
||||
>
|
||||
<div class="card-topline">
|
||||
<span class:list={["quality-badge", `quality-${entry.quality}`]}>{qualityLabel}</span>
|
||||
<span class="skill-count">{countLabel}</span>
|
||||
</div>
|
||||
<h3><a href={withBase(`/skills/${entry.slug}`)}>{entry.name}</a></h3>
|
||||
<p>{entry.summary}</p>
|
||||
<div class="tag-list" aria-label="领域">
|
||||
{entry.domains.map((domain) => <span>{domain}</span>)}
|
||||
</div>
|
||||
<a class="card-link" href={withBase(`/skills/${entry.slug}`)} aria-label={`查看 ${entry.name}`}>查看详情 <span>→</span></a>
|
||||
</article>
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
import { withBase } from "@/lib/urls";
|
||||
import "@/styles/global.css";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
const currentPath = Astro.url.pathname.replace(import.meta.env.BASE_URL.replace(/\/$/, ""), "") || "/";
|
||||
const navigation = [
|
||||
{ href: "/learn", label: "使用教程" },
|
||||
{ href: "/skills", label: "Skills" },
|
||||
{ href: "/submit", label: "提交 Skill" },
|
||||
];
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<link rel="icon" href={`data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2220%22 fill=%22%23111210%22/><text y=%22.78em%22 x=%22.18em%22 font-size=%2272%22 fill=%22white%22>仓</text></svg>`} />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">跳到正文</a>
|
||||
<header class="site-header">
|
||||
<div class="shell header-inner">
|
||||
<a class="brand" href={withBase("/")} aria-label="仓颉 Skill 首页">
|
||||
<span class="brand-mark" aria-hidden="true">仓</span>
|
||||
<span>仓颉 Skill</span>
|
||||
</a>
|
||||
<nav class="site-nav" aria-label="主导航">
|
||||
{navigation.map((item) => (
|
||||
<a
|
||||
href={withBase(item.href)}
|
||||
aria-current={currentPath.startsWith(item.href) ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
<a class="github-link" href="https://github.com/kangarooking/cangjie-skill" target="_blank" rel="noreferrer">
|
||||
GitHub ↗
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="shell footer-inner">
|
||||
<div>
|
||||
<a class="brand footer-brand" href={withBase("/")}><span class="brand-mark">仓</span><span>仓颉 Skill</span></a>
|
||||
<p>把知识蒸馏成 AI 可以直接执行的方法。</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href={withBase("/learn")}>使用教程</a>
|
||||
<a href={withBase("/skills")}>Skill 目录</a>
|
||||
<a href={withBase("/submit")}>参与共建</a>
|
||||
<a href="https://github.com/kangarooking/cangjie-skill" target="_blank" rel="noreferrer">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterCatalog, getCatalogStats, loadCatalog } from "./catalog";
|
||||
|
||||
describe("registry catalog", () => {
|
||||
it("loads all seeded packs and totals", async () => {
|
||||
const entries = await loadCatalog();
|
||||
expect(entries).toHaveLength(22);
|
||||
expect(getCatalogStats(entries)).toMatchObject({ packs: 22, skills: 300 });
|
||||
});
|
||||
|
||||
it("has unique slugs and valid GitHub sources", async () => {
|
||||
const entries = await loadCatalog();
|
||||
expect(new Set(entries.map((entry) => entry.slug)).size).toBe(entries.length);
|
||||
expect(entries.every((entry) => entry.source_url.startsWith("https://github.com/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("filters across Chinese metadata, domains and quality", async () => {
|
||||
const entries = await loadCatalog();
|
||||
expect(filterCatalog(entries, { query: "巴菲特" }).length).toBe(2);
|
||||
expect(filterCatalog(entries, { domain: "数学" })).toHaveLength(1);
|
||||
expect(filterCatalog(entries, { quality: "community" })).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { load as loadYaml } from "js-yaml";
|
||||
|
||||
export type SourceType = "github" | "bundled";
|
||||
export type Quality = "verified" | "community" | "experimental";
|
||||
export type RegistryStatus = "active" | "experimental" | "archived";
|
||||
export type OutputMode = "single" | "pack" | "legacy-pack";
|
||||
|
||||
export interface RegistryEntry {
|
||||
schema_version: 1 | 2;
|
||||
slug: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
source_type: SourceType;
|
||||
source_url: string;
|
||||
skill_path?: string;
|
||||
skill_count: number;
|
||||
/** v2 only:宿主可发现入口数;single 恒为 1 */
|
||||
entrypoint_count?: number;
|
||||
/** v2 only:经验证的能力总数,允许大于入口数 */
|
||||
capability_count?: number;
|
||||
/** v2 only:single | pack;v1 条目由 getOutputMode 规范化为 legacy-pack,不回写 */
|
||||
output_mode?: OutputMode;
|
||||
/** v2 pack only:来源路由入口 slug */
|
||||
router_entrypoint?: string;
|
||||
domains: string[];
|
||||
language: string[];
|
||||
status: RegistryStatus;
|
||||
quality: Quality;
|
||||
featured?: boolean;
|
||||
use_cases: string[];
|
||||
install?: {
|
||||
clone?: string;
|
||||
copy?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** v1 条目在读取层规范化为 legacy-pack(ADR-004:不回写文件)。 */
|
||||
export function getOutputMode(entry: RegistryEntry): OutputMode {
|
||||
return entry.schema_version === 2 ? (entry.output_mode ?? "legacy-pack") : "legacy-pack";
|
||||
}
|
||||
|
||||
/** 安装后新增的可发现 Skill 入口数 —— 用户做安装决策时真正需要的数字。 */
|
||||
export function getEntrypointCount(entry: RegistryEntry): number {
|
||||
return entry.schema_version === 2 ? (entry.entrypoint_count ?? entry.skill_count) : entry.skill_count;
|
||||
}
|
||||
|
||||
export interface CatalogStats {
|
||||
packs: number;
|
||||
/** legacy 条目的可发现 Skill 总数(迁移期保持原叙事口径) */
|
||||
skills: number;
|
||||
domains: number;
|
||||
contributors: number;
|
||||
/** v2 条目聚合:入口数 / 能力数 / 覆盖条目数 */
|
||||
v2Packs: number;
|
||||
v2Entrypoints: number;
|
||||
v2Capabilities: number;
|
||||
}
|
||||
|
||||
const registryDir = resolve(process.cwd(), "../registry");
|
||||
|
||||
let catalogPromise: Promise<RegistryEntry[]> | undefined;
|
||||
|
||||
export function loadCatalog(): Promise<RegistryEntry[]> {
|
||||
catalogPromise ??= readCatalog();
|
||||
return catalogPromise;
|
||||
}
|
||||
|
||||
async function readCatalog(): Promise<RegistryEntry[]> {
|
||||
const folders = (await readdir(registryDir, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
const entries = await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
const raw = await readFile(join(registryDir, folder, "entry.yaml"), "utf8");
|
||||
return loadYaml(raw) as RegistryEntry;
|
||||
}),
|
||||
);
|
||||
|
||||
return entries.sort((a, b) => {
|
||||
if (Boolean(a.featured) !== Boolean(b.featured)) return a.featured ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, "zh-CN");
|
||||
});
|
||||
}
|
||||
|
||||
export function getCatalogStats(entries: RegistryEntry[]): CatalogStats {
|
||||
const domains = new Set(entries.flatMap((entry) => entry.domains));
|
||||
const contributors = new Set(
|
||||
entries.map((entry) => new URL(entry.source_url).pathname.split("/").filter(Boolean)[0]),
|
||||
);
|
||||
|
||||
const legacy = entries.filter((entry) => entry.schema_version !== 2);
|
||||
const v2 = entries.filter((entry) => entry.schema_version === 2);
|
||||
|
||||
return {
|
||||
packs: entries.length,
|
||||
skills: legacy.reduce((sum, entry) => sum + entry.skill_count, 0),
|
||||
domains: domains.size,
|
||||
contributors: contributors.size,
|
||||
v2Packs: v2.length,
|
||||
v2Entrypoints: v2.reduce((sum, entry) => sum + (entry.entrypoint_count ?? entry.skill_count), 0),
|
||||
v2Capabilities: v2.reduce((sum, entry) => sum + (entry.capability_count ?? entry.skill_count), 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** 列表页按输出模式筛选(偏好“少而整”的用户可过滤 single)。 */
|
||||
export function filterByOutputMode(entries: RegistryEntry[], mode?: OutputMode): RegistryEntry[] {
|
||||
if (!mode) return entries;
|
||||
return entries.filter((entry) => getOutputMode(entry) === mode);
|
||||
}
|
||||
|
||||
export function searchableText(entry: RegistryEntry): string {
|
||||
return [
|
||||
entry.name,
|
||||
entry.slug,
|
||||
entry.summary,
|
||||
...entry.domains,
|
||||
...entry.use_cases,
|
||||
]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("zh-CN");
|
||||
}
|
||||
|
||||
export function filterCatalog(
|
||||
entries: RegistryEntry[],
|
||||
options: { query?: string; domain?: string; quality?: string; source?: string },
|
||||
): RegistryEntry[] {
|
||||
const query = options.query?.trim().toLocaleLowerCase("zh-CN") ?? "";
|
||||
|
||||
return entries.filter((entry) => {
|
||||
if (query && !searchableText(entry).includes(query)) return false;
|
||||
if (options.domain && !entry.domains.includes(options.domain)) return false;
|
||||
if (options.quality && entry.quality !== options.quality) return false;
|
||||
if (options.source && entry.source_type !== options.source) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RegistryEntry } from "./catalog";
|
||||
import { getAgentInstallPrompt, INSTALL_GUIDE_URL } from "./install";
|
||||
|
||||
const baseEntry: RegistryEntry = {
|
||||
schema_version: 1,
|
||||
slug: "example-skill",
|
||||
name: "Example Skill",
|
||||
summary: "A sufficiently descriptive example skill summary.",
|
||||
source_type: "github",
|
||||
source_url: "https://github.com/example/example-skill",
|
||||
skill_count: 1,
|
||||
domains: ["测试"],
|
||||
language: ["zh-CN"],
|
||||
status: "active",
|
||||
quality: "community",
|
||||
use_cases: ["完成一个测试任务"],
|
||||
};
|
||||
|
||||
describe("Agent install prompt", () => {
|
||||
it("generates a copy-ready prompt for GitHub skills", () => {
|
||||
expect(getAgentInstallPrompt(baseEntry)).toBe(
|
||||
`请根据 ${INSTALL_GUIDE_URL},从 https://github.com/example/example-skill 安装 example-skill。安装后新增 1 个 Skill。`,
|
||||
);
|
||||
});
|
||||
|
||||
it("mentions the single entrypoint and capability cards for v2 single packs", () => {
|
||||
const prompt = getAgentInstallPrompt({
|
||||
...baseEntry,
|
||||
schema_version: 2,
|
||||
output_mode: "single",
|
||||
entrypoint_count: 1,
|
||||
capability_count: 19,
|
||||
});
|
||||
expect(prompt).toContain("single 模式");
|
||||
expect(prompt).toContain("19 张");
|
||||
});
|
||||
|
||||
it("mentions the router entrypoint for v2 compact packs", () => {
|
||||
const prompt = getAgentInstallPrompt({
|
||||
...baseEntry,
|
||||
schema_version: 2,
|
||||
output_mode: "pack",
|
||||
skill_count: 7,
|
||||
entrypoint_count: 7,
|
||||
capability_count: 19,
|
||||
router_entrypoint: "naval-almanack",
|
||||
});
|
||||
expect(prompt).toContain("compact pack");
|
||||
expect(prompt).toContain("7 个 Skill 入口");
|
||||
expect(prompt).toContain("naval-almanack");
|
||||
});
|
||||
|
||||
it("includes the exact bundled path when the skill lives in this repository", () => {
|
||||
expect(getAgentInstallPrompt({
|
||||
...baseEntry,
|
||||
source_type: "bundled",
|
||||
source_url: "https://github.com/kangarooking/cangjie-skill",
|
||||
skill_path: "registry/example-skill/skill",
|
||||
})).toContain("registry/example-skill/skill");
|
||||
});
|
||||
|
||||
it("can use the local guide while the site is in development", () => {
|
||||
expect(getAgentInstallPrompt(baseEntry, "http://localhost:4321/install/cangjie-skill.md"))
|
||||
.toContain("http://localhost:4321/install/cangjie-skill.md");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RegistryEntry } from "./catalog";
|
||||
import { getEntrypointCount, getOutputMode } from "./catalog";
|
||||
|
||||
export const INSTALL_GUIDE_URL = "https://kangarooking.github.io/cangjie-skill/install/cangjie-skill.md";
|
||||
|
||||
/** 按输出模式生成安装提示词(方案 §11.5:install 按 output_mode 分支)。 */
|
||||
export function getAgentInstallPrompt(entry: RegistryEntry, guideUrl = INSTALL_GUIDE_URL): string {
|
||||
const source = entry.source_type === "bundled" && entry.skill_path
|
||||
? `${entry.source_url} 中的 ${entry.skill_path}`
|
||||
: entry.source_url;
|
||||
|
||||
const mode = getOutputMode(entry);
|
||||
const entrypoints = getEntrypointCount(entry);
|
||||
|
||||
let hint = "";
|
||||
if (mode === "single") {
|
||||
hint = `该 pack 为 single 模式:安装后只新增 1 个 Skill 入口(内含 ${entry.capability_count ?? "多"} 张按需加载的能力卡),整目录复制即可。`;
|
||||
} else if (mode === "pack") {
|
||||
hint = `该 pack 为 compact pack 模式:安装后新增 ${entrypoints} 个 Skill 入口(含 1 个来源路由入口 ${entry.router_entrypoint ?? ""}),各入口目录需分别复制。`.replace(" ", " ");
|
||||
} else {
|
||||
hint = `安装后新增 ${entrypoints} 个 Skill。`;
|
||||
}
|
||||
|
||||
return `请根据 ${guideUrl},从 ${source} 安装 ${entry.slug}。${hint}`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRegistryEntry, serializeRegistryEntry, slugify, validateSkillFolder } from "./submission";
|
||||
|
||||
describe("submission helpers", () => {
|
||||
it("creates a schema-shaped GitHub entry", () => {
|
||||
const entry = createRegistryEntry({
|
||||
mode: "github",
|
||||
name: "Decision Skill",
|
||||
slug: "decision-skill",
|
||||
summary: "一套用于检查复杂决策假设的工作方法。",
|
||||
repositoryUrl: "https://github.com/example/decision-skill.git",
|
||||
skillCount: 3,
|
||||
domains: "决策,管理",
|
||||
languages: ["zh-CN"],
|
||||
useCases: "检查决策假设\n复盘行动结果",
|
||||
});
|
||||
expect(entry.source_url).toBe("https://github.com/example/decision-skill");
|
||||
expect(entry.domains).toEqual(["决策", "管理"]);
|
||||
expect(serializeRegistryEntry(entry)).toContain("schema_version: 1");
|
||||
});
|
||||
|
||||
it("validates local folders and counts SKILL.md files", () => {
|
||||
const result = validateSkillFolder([
|
||||
{ name: "README.md", size: 100, webkitRelativePath: "my-pack/README.md" },
|
||||
{ name: "SKILL.md", size: 200, webkitRelativePath: "my-pack/a/SKILL.md" },
|
||||
{ name: "SKILL.md", size: 200, webkitRelativePath: "my-pack/b/SKILL.md" },
|
||||
{ name: ".DS_Store", size: 10, webkitRelativePath: "my-pack/.DS_Store" },
|
||||
]);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.skillCount).toBe(2);
|
||||
expect(result.acceptedFiles).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("rejects likely secrets and invalid slugs", () => {
|
||||
expect(validateSkillFolder([{ name: ".env", size: 10, webkitRelativePath: "pack/.env" }]).valid).toBe(false);
|
||||
expect(slugify("My Great Skill")).toBe("my-great-skill");
|
||||
expect(() => createRegistryEntry({
|
||||
mode: "bundled",
|
||||
name: "测试",
|
||||
slug: "不合法",
|
||||
summary: "这是一个足够长的测试描述文本。",
|
||||
skillCount: 1,
|
||||
domains: "测试",
|
||||
languages: ["zh-CN"],
|
||||
useCases: "完成一个测试任务",
|
||||
})).toThrow(/Slug/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { dump as dumpYaml } from "js-yaml";
|
||||
import type { RegistryEntry, SourceType } from "./catalog";
|
||||
|
||||
export interface SubmissionDraft {
|
||||
mode: SourceType;
|
||||
name: string;
|
||||
slug: string;
|
||||
summary: string;
|
||||
repositoryUrl?: string;
|
||||
skillCount: number;
|
||||
domains: string;
|
||||
languages: string[];
|
||||
useCases: string;
|
||||
}
|
||||
|
||||
export interface FileLike {
|
||||
name: string;
|
||||
size: number;
|
||||
webkitRelativePath?: string;
|
||||
}
|
||||
|
||||
export interface FolderValidation {
|
||||
valid: boolean;
|
||||
skillCount: number;
|
||||
acceptedFiles: FileLike[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const canonicalRepository = "https://github.com/kangarooking/cangjie-skill";
|
||||
const forbiddenNames = /(^|\/)(\.env(?:\..*)?|id_rsa|id_ed25519|credentials\.json|.*\.(?:pem|key|p12))$/i;
|
||||
const ignoredNames = /(^|\/)(\.DS_Store|Thumbs\.db|node_modules)(\/|$)/i;
|
||||
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
export function createRegistryEntry(draft: SubmissionDraft): RegistryEntry {
|
||||
const slug = slugify(draft.slug);
|
||||
if (!slug || slug !== draft.slug) {
|
||||
throw new Error("Slug 只能包含小写英文字母、数字和连字符,且不能以连字符开头或结尾。");
|
||||
}
|
||||
if (draft.mode === "github" && !isGitHubRepository(draft.repositoryUrl ?? "")) {
|
||||
throw new Error("请输入公开的 GitHub 仓库地址,例如 https://github.com/owner/repo。");
|
||||
}
|
||||
|
||||
const domains = splitList(draft.domains, /[,,\n]/);
|
||||
const useCases = splitList(draft.useCases, /\n/);
|
||||
if (domains.length === 0) throw new Error("请至少填写一个领域标签。");
|
||||
if (useCases.length === 0) throw new Error("请至少填写一个使用场景。");
|
||||
|
||||
const entry: RegistryEntry = {
|
||||
schema_version: 1,
|
||||
slug,
|
||||
name: draft.name.trim(),
|
||||
summary: draft.summary.trim(),
|
||||
source_type: draft.mode,
|
||||
source_url: draft.mode === "github" ? normalizeRepositoryUrl(draft.repositoryUrl ?? "") : canonicalRepository,
|
||||
skill_count: Math.max(1, Math.floor(draft.skillCount)),
|
||||
domains,
|
||||
language: draft.languages.length > 0 ? draft.languages : ["zh-CN"],
|
||||
status: "active",
|
||||
quality: "community",
|
||||
featured: false,
|
||||
use_cases: useCases.slice(0, 6),
|
||||
};
|
||||
|
||||
if (draft.mode === "bundled") entry.skill_path = `registry/${slug}/skill`;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function serializeRegistryEntry(entry: RegistryEntry): string {
|
||||
return dumpYaml(entry, {
|
||||
noRefs: true,
|
||||
lineWidth: 100,
|
||||
sortKeys: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateSkillFolder(files: FileLike[]): FolderValidation {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const acceptedFiles = files.filter((file) => !ignoredNames.test(file.webkitRelativePath || file.name));
|
||||
const paths = acceptedFiles.map((file) => file.webkitRelativePath || file.name);
|
||||
|
||||
const secretFiles = paths.filter((path) => forbiddenNames.test(path));
|
||||
if (secretFiles.length > 0) errors.push(`检测到可能包含密钥的文件:${secretFiles.join("、")}`);
|
||||
|
||||
const totalBytes = acceptedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
if (totalBytes > 20 * 1024 * 1024) errors.push("文件夹超过 20 MB,请移除大型原始资料或二进制文件。");
|
||||
|
||||
const skillFiles = paths.filter((path) => /(^|\/)SKILL\.md$/i.test(path));
|
||||
if (skillFiles.length === 0) errors.push("没有找到 SKILL.md。每个 Skill 至少需要一个 SKILL.md 文件。");
|
||||
if (!paths.some((path) => /(^|\/)README\.md$/i.test(path))) warnings.push("建议添加 README.md,向使用者说明这个 Skill Pack 的范围和安装方式。");
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
skillCount: skillFiles.length,
|
||||
acceptedFiles,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGitHubCreateUrl(entry: RegistryEntry, yaml: string): string {
|
||||
const url = new URL(`${canonicalRepository}/new/main`);
|
||||
url.searchParams.set("filename", `registry/${entry.slug}/entry.yaml`);
|
||||
url.searchParams.set("value", yaml);
|
||||
url.searchParams.set("message", `registry: add ${entry.slug}`);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function splitList(value: string, separator: RegExp): string[] {
|
||||
return [...new Set(value.split(separator).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function isGitHubRepository(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" && url.hostname === "github.com" && url.pathname.split("/").filter(Boolean).length >= 2;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRepositoryUrl(value: string): string {
|
||||
return value.trim().replace(/\.git$/, "").replace(/\/$/, "");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function withBase(path = "/"): string {
|
||||
const base = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}${normalized}` || "/";
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
import SkillCard from "@/components/SkillCard.astro";
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
import { getCatalogStats, loadCatalog } from "@/lib/catalog";
|
||||
import { withBase } from "@/lib/urls";
|
||||
|
||||
const entries = await loadCatalog();
|
||||
const stats = getCatalogStats(entries);
|
||||
const featured = entries.filter((entry) => entry.featured).slice(0, 6);
|
||||
---
|
||||
|
||||
<BaseLayout title="仓颉 Skill — 把知识变成 AI 可执行的方法" description="学习、发现并共建经过蒸馏的 Agent Skills。">
|
||||
<section class="home-hero">
|
||||
<div class="shell hero-grid">
|
||||
<div>
|
||||
<p class="eyebrow">Cangjie Skill Registry</p>
|
||||
<h1>把知识,变成 AI<br />可以执行的方法</h1>
|
||||
<p class="hero-copy">仓颉 Skill 是一套知识蒸馏方法,也是一个开放的 Skill 目录。你可以直接使用已有成果,也可以提交自己的 Skill Pack。</p>
|
||||
<div class="button-row">
|
||||
<a class="button accent" href={withBase("/skills")}>浏览 Skills</a>
|
||||
<a class="button secondary" href={withBase("/learn")}>从零开始使用</a>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="hero-panel" aria-label="仓颉 Skill 数据概览">
|
||||
<div class="hero-panel-label">Registry / v1</div>
|
||||
<dl class="hero-stats">
|
||||
<div><dt>{stats.packs}</dt><dd>Skill Packs</dd></div>
|
||||
<div><dt>{stats.skills}</dt><dd>Legacy Atomic Skills</dd></div>
|
||||
{stats.v2Packs > 0 && <div><dt>{stats.v2Entrypoints} / {stats.v2Capabilities}</dt><dd>v2 入口 / 能力</dd></div>}
|
||||
<div><dt>{stats.domains}</dt><dd>Knowledge Domains</dd></div>
|
||||
<div><dt>{stats.contributors}</dt><dd>Contributors</dd></div>
|
||||
</dl>
|
||||
<div class="registry-line"><span class="status-dot"></span> Registry schema valid</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="shell">
|
||||
<div class="section-header">
|
||||
<div><p class="eyebrow">How it works</p><h2>三步开始使用</h2></div>
|
||||
<a class="text-link" href={withBase("/learn")}>查看完整教程 →</a>
|
||||
</div>
|
||||
<ol class="steps-grid">
|
||||
<li><span>01</span><h3>选择 Skill Pack</h3><p>按主题、领域或使用场景,找到适合当前任务的一组 Skill。</p></li>
|
||||
<li><span>02</span><h3>交给 Agent 安装</h3><p>复制详情页的安装提示词,Agent 会自动判断目录并完成安装。</p></li>
|
||||
<li><span>03</span><h3>描述真实任务</h3><p>自然地提出任务,Agent 会读取 SKILL.md 并按其中的方法执行。</p></li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="shell">
|
||||
<div class="section-header">
|
||||
<div><p class="eyebrow">Featured</p><h2>精选 Skill Packs</h2><p>从投资、认知、写作到 AI 提示词,先从经过验证的代表性成果开始。</p></div>
|
||||
<a class="text-link" href={withBase("/skills")}>查看全部 {stats.packs} 个 →</a>
|
||||
</div>
|
||||
<div class="skill-grid">
|
||||
{featured.map((entry) => <SkillCard entry={entry} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section contribution-section">
|
||||
<div class="shell contribution-grid">
|
||||
<div><p class="eyebrow">Open Registry</p><h2>好方法,值得被更多 Agent 调用</h2></div>
|
||||
<div><p>不需要注册账号,也不需要等待后台系统。填写信息后生成标准 Registry 文件,通过 GitHub Pull Request 完成公开审核和收录。</p><a class="button" href={withBase("/submit")}>提交一个 Skill</a></div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.home-hero { padding: 92px 0 76px; background-image: linear-gradient(rgba(24,25,22,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(24,25,22,.035) 1px, transparent 1px); background-size: 32px 32px; border-bottom: 1px solid var(--line); }
|
||||
.hero-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(300px, .75fr); align-items: end; gap: 70px; }
|
||||
h1 { margin: 0; font-size: clamp(52px, 7.4vw, 88px); line-height: 1.01; letter-spacing: -.06em; }
|
||||
.hero-copy { max-width: 670px; margin: 26px 0 0; color: var(--muted); font-size: 18px; }
|
||||
.hero-panel { padding: 24px; border: 1px solid var(--ink); border-radius: var(--radius); background: rgba(255,254,249,.94); box-shadow: 10px 10px 0 var(--ink); }
|
||||
.hero-panel-label { padding-bottom: 14px; border-bottom: 1px solid var(--line); color: var(--muted); font: 12px "SFMono-Regular", monospace; text-transform: uppercase; }
|
||||
.hero-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 0; margin: 0; }
|
||||
.hero-stats div { padding: 20px 8px 16px 0; border-bottom: 1px solid var(--line); }
|
||||
.hero-stats div:nth-child(odd) { border-right: 1px solid var(--line); }
|
||||
.hero-stats div:nth-child(even) { padding-left: 18px; }
|
||||
.hero-stats dt { font-size: 31px; font-weight: 750; letter-spacing: -.04em; }
|
||||
.hero-stats dd { margin: 3px 0 0; color: var(--muted); font-size: 11px; }
|
||||
.registry-line { padding-top: 16px; color: var(--success); font-size: 12px; }
|
||||
.status-dot { display: inline-block; width: 7px; height: 7px; margin-right: 7px; border-radius: 50%; background: #27a05c; box-shadow: 0 0 0 4px #dff3e8; }
|
||||
.steps-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--ink); }
|
||||
.steps-grid li { min-height: 220px; padding: 26px 26px 24px 0; border-right: 1px solid var(--line); }
|
||||
.steps-grid li + li { padding-left: 26px; }
|
||||
.steps-grid li:last-child { border-right: 0; }
|
||||
.steps-grid span { color: var(--accent); font: 12px "SFMono-Regular", monospace; }
|
||||
.steps-grid h3 { margin: 42px 0 8px; font-size: 20px; }
|
||||
.steps-grid p { margin: 0; color: var(--muted); font-size: 14px; }
|
||||
.contribution-section { background: #eceee7; }
|
||||
.contribution-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 80px; align-items: end; }
|
||||
.contribution-grid h2 { max-width: 540px; margin: 0; font-size: clamp(34px, 5vw, 54px); line-height: 1.1; letter-spacing: -.045em; }
|
||||
.contribution-grid p { margin: 0 0 24px; color: var(--muted); }
|
||||
@media (max-width: 800px) { .hero-grid, .contribution-grid { grid-template-columns: 1fr; gap: 46px; } .hero-panel { max-width: 460px; } }
|
||||
@media (max-width: 600px) { .home-hero { padding: 56px 0 48px; } h1 { font-size: 48px; } .steps-grid { grid-template-columns: 1fr; } .steps-grid li, .steps-grid li + li { min-height: auto; padding: 24px 0; border-right: 0; border-bottom: 1px solid var(--line); } .steps-grid h3 { margin-top: 18px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
import { INSTALL_GUIDE_URL } from "@/lib/install";
|
||||
import { withBase } from "@/lib/urls";
|
||||
|
||||
const exampleInstallPrompt = "请根据 " + INSTALL_GUIDE_URL + ",从 https://github.com/kangarooking/buffett-letters-skill 安装 buffett-letters-skill。";
|
||||
---
|
||||
|
||||
<BaseLayout title="使用教程 — 仓颉 Skill" description="从选择、安装到调用,快速学会在主流 AI 编程 Agent 中使用仓颉 Skill。">
|
||||
<section class="page-hero"><div class="shell"><p class="eyebrow">Get started</p><h1>十分钟,让 Agent<br />用上专业方法</h1><p class="lede">Skill 不是一段需要复制粘贴的提示词,而是一套放在本地、可被 Agent 自动读取的工作方法。</p></div></section>
|
||||
|
||||
<div class="learn-layout shell">
|
||||
<aside class="toc" aria-label="页面目录">
|
||||
<strong>本页内容</strong>
|
||||
<a href="#understand">1. 理解目录</a>
|
||||
<a href="#choose">2. 选择 Skill</a>
|
||||
<a href="#install">3. 交给 Agent 安装</a>
|
||||
<a href="#use">4. 开始调用</a>
|
||||
<a href="#verify">5. 验证是否生效</a>
|
||||
<a href="#troubleshoot">常见问题</a>
|
||||
</aside>
|
||||
|
||||
<article class="prose learn-prose">
|
||||
<section id="understand"><p class="step-number">STEP 01</p><h2>先理解一个 Skill Pack</h2><p>一个 Skill Pack 是一个文件夹,里面可以包含多个面向具体场景的原子 Skill。每个原子 Skill 至少有一个 <code>SKILL.md</code>,用于告诉 Agent:什么时候使用、需要哪些输入、按什么步骤执行、最终交付什么。</p><div class="file-tree"><span>example-skill-pack/</span><span>├── README.md</span><span>├── research-analysis/</span><span>│ └── SKILL.md</span><span>└── decision-checklist/</span><span> └── SKILL.md</span></div></section>
|
||||
|
||||
<section id="choose"><p class="step-number">STEP 02</p><h2>选择适合任务的 Pack</h2><p>进入 <a href={withBase("/skills")}>Skills 目录</a>,按领域或场景搜索。初次使用建议选一个和手头真实任务直接相关的 Pack,而不是一次安装全部内容。</p><div class="callout">例如:准备分析一家企业时选择投资类 Skill;要重写产品页面时选择文案类 Skill。</div></section>
|
||||
|
||||
<section id="install"><p class="step-number">STEP 03</p><h2>把安装提示词交给 Agent</h2><p>进入任意 Skill 详情页,点击「复制安装提示词」,然后直接发送给你正在使用的 Agent。无需自己判断 Codex、Claude Code、Cursor 或其他工具的目录。</p><div class="agent-install-demo"><span>安装提示词</span><p>{exampleInstallPrompt}</p></div><h3>Agent 会自动完成</h3><ul class="agent-checklist"><li>识别当前 Agent 和可用的 Skills 目录</li><li>读取来源仓库,检查 SKILL.md、风险与同名冲突</li><li>安装完整文件及其脚本、模板和资源</li><li>验证是否能被识别,并报告安装位置与结果</li></ul><div class="callout">只有遇到覆盖本地修改、执行未审查脚本、需要凭证或权限提升时,Agent 才应该暂停并询问你。</div></section>
|
||||
|
||||
<section id="use"><p class="step-number">STEP 04</p><h2>直接描述你的真实任务</h2><p>无需背诵特殊口令。说明目标、背景和希望得到的结果即可;如果希望明确指定,也可以直接说出 Skill 名称。</p><div class="prompt-example"><span>示例任务</span><p>请使用巴菲特股东信中的企业质量检查方法,分析这家公司过去五年的年报,并列出我还需要验证的关键假设。</p></div></section>
|
||||
|
||||
<section id="verify"><p class="step-number">STEP 05</p><h2>验证 Skill 是否生效</h2><p>让 Agent 说明它读取了哪个 Skill、当前采用了哪些步骤。一个正常生效的 Skill 应该让输出更有结构,而且包含该方法特有的检查项,而不是泛泛回答。</p><pre><code>开始前,请告诉我你将使用哪个 Skill,
|
||||
并用 3 点概括它要求你遵循的工作流程。</code></pre></section>
|
||||
|
||||
<section id="troubleshoot"><p class="step-number">TROUBLESHOOTING</p><h2>常见问题</h2><details><summary>Agent 没有识别到 Skill</summary><p>先检查目录层级:目标文件应为 <code>.../skill-name/SKILL.md</code>,而不是多套了一层压缩包目录。然后开启新会话再试。</p></details><details><summary>一次安装很多 Skill 会怎样?</summary><p>可以安装,但名称和触发描述过于相似时可能产生冲突。推荐围绕当前工作保留一组高频 Skill。</p></details><details><summary>Skill 会把资料上传到哪里?</summary><p>仓颉 Skill 本身只是本地文本和脚本。实际数据如何处理取决于你使用的 Agent、模型服务和 Skill 内部是否调用外部服务,安装前应检查仓库内容。</p></details></section>
|
||||
</article>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.learn-layout { display: grid; grid-template-columns: 190px minmax(0, 720px); justify-content: center; gap: 70px; padding-top: 70px; }
|
||||
.toc { position: sticky; top: 100px; align-self: start; display: grid; gap: 10px; font-size: 13px; }
|
||||
.toc strong { margin-bottom: 7px; color: var(--ink); }
|
||||
.toc a { color: var(--muted); }
|
||||
.toc a:hover { color: var(--accent); }
|
||||
.learn-prose section { scroll-margin-top: 90px; padding-bottom: 28px; }
|
||||
.learn-prose section + section { border-top: 1px solid var(--line); }
|
||||
.step-number { margin-top: 54px; color: var(--accent) !important; font: 12px "SFMono-Regular", monospace; letter-spacing: .08em; }
|
||||
code { padding: 2px 5px; border-radius: 4px; background: var(--soft); color: var(--ink); font: 13px "SFMono-Regular", monospace; }
|
||||
pre code { padding: 0; background: transparent; color: inherit; }
|
||||
.file-tree { display: grid; margin-top: 22px; padding: 20px; border: 1px solid var(--line); border-radius: 8px; background: var(--soft); font: 13px/1.8 "SFMono-Regular", monospace; }
|
||||
.agent-install-demo { margin: 24px 0; padding: 20px; border: 1px solid var(--ink); border-radius: 8px; background: var(--soft); box-shadow: 5px 5px 0 var(--ink); }
|
||||
.agent-install-demo span { color: var(--accent); font-size: 11px; font-weight: 750; text-transform: uppercase; }
|
||||
.agent-install-demo p { margin: 9px 0 0; color: var(--ink); font-size: 14px; word-break: break-word; }
|
||||
.agent-checklist { display: grid; gap: 9px; padding: 0; list-style: none; }
|
||||
.agent-checklist li::before { content: "✓"; margin-right: 10px; color: var(--success); font-weight: 750; }
|
||||
.prompt-example { margin-top: 22px; padding: 20px; border: 1px solid var(--accent); border-radius: 8px; background: var(--accent-soft); }
|
||||
.prompt-example span { color: var(--accent); font-size: 12px; font-weight: 700; }
|
||||
.prompt-example p { margin-bottom: 0; color: #263653; }
|
||||
details { padding: 18px 0; border-top: 1px solid var(--line); }
|
||||
details:last-child { border-bottom: 1px solid var(--line); }
|
||||
summary { cursor: pointer; font-weight: 650; }
|
||||
@media (max-width: 760px) { .learn-layout { grid-template-columns: 1fr; gap: 0; } .toc { display: none; } }
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
import { getEntrypointCount, getOutputMode, loadCatalog, type RegistryEntry } from "@/lib/catalog";
|
||||
import { getAgentInstallPrompt, INSTALL_GUIDE_URL } from "@/lib/install";
|
||||
import { withBase } from "@/lib/urls";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const entries = await loadCatalog();
|
||||
return entries.map((entry) => ({ params: { slug: entry.slug }, props: { entry } }));
|
||||
}
|
||||
|
||||
interface Props { entry: RegistryEntry }
|
||||
const { entry } = Astro.props;
|
||||
const repositoryPath = new URL(entry.source_url).pathname.replace(/^\//, "");
|
||||
const installPrompt = getAgentInstallPrompt(entry);
|
||||
const qualityLabel = {
|
||||
verified: "已验证",
|
||||
community: "社区收录",
|
||||
experimental: "实验性",
|
||||
}[entry.quality];
|
||||
---
|
||||
|
||||
<BaseLayout title={entry.name + " — 仓颉 Skill"} description={entry.summary}>
|
||||
<section class="detail-hero">
|
||||
<div class="shell">
|
||||
<a class="back-link" href={withBase("/skills")}>← Skills 目录 / {entry.slug}</a>
|
||||
<div class="detail-heading">
|
||||
<div class="skill-symbol" aria-hidden="true">技</div>
|
||||
<div>
|
||||
<div class="detail-badges">
|
||||
<span class:list={["quality-badge", "quality-" + entry.quality]}>{qualityLabel}</span>
|
||||
<span>安装后新增 {getEntrypointCount(entry)} 个可发现 Skill</span>
|
||||
{getOutputMode(entry) !== "legacy-pack" && <span>{getOutputMode(entry) === "single" ? "单入口模式" : "Compact Pack"} · {entry.capability_count} 项能力</span>}
|
||||
<span>{entry.domains.join(" · ")}</span>
|
||||
</div>
|
||||
<h1>{entry.name}</h1>
|
||||
<p>{entry.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="detail-tabs" aria-label="详情页导航">
|
||||
<div class="shell">
|
||||
<a href="#overview">概览</a>
|
||||
<a href="#use-cases">使用场景</a>
|
||||
<a href="#how-to-use">如何使用</a>
|
||||
<a href="#source">来源信息</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="shell detail-grid">
|
||||
<article class="detail-content">
|
||||
<section id="overview">
|
||||
<p class="eyebrow">Overview</p>
|
||||
<h2>这个 Skill Pack 能做什么</h2>
|
||||
<p class="section-lede">{entry.summary}</p>
|
||||
<dl class="overview-stats">
|
||||
<div><dt>{getEntrypointCount(entry)}</dt><dd>可发现入口</dd></div>
|
||||
{getOutputMode(entry) !== "legacy-pack" && <div><dt>{entry.capability_count}</dt><dd>经验证能力</dd></div>}
|
||||
<div><dt>{entry.domains.length}</dt><dd>覆盖领域</dd></div>
|
||||
<div><dt>{entry.language.length}</dt><dd>支持语言</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section id="use-cases">
|
||||
<p class="eyebrow">Use cases</p>
|
||||
<h2>适合解决的问题</h2>
|
||||
<ul class="use-case-list">
|
||||
{entry.use_cases.map((useCase, index) => (
|
||||
<li><span>{String(index + 1).padStart(2, "0")}</span><strong>{useCase}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="how-to-use">
|
||||
<p class="eyebrow">How to use</p>
|
||||
<h2>安装后直接描述任务</h2>
|
||||
<ol class="usage-steps">
|
||||
<li><span>1</span><div><strong>把右侧提示词发给 Agent</strong><p>Agent 会自动识别自己的 Skills 目录,检查来源并完成安装。</p></div></li>
|
||||
<li><span>2</span><div><strong>说清楚你的真实任务</strong><p>提供目标、背景材料和期望结果,不需要记忆特殊命令。</p></div></li>
|
||||
<li><span>3</span><div><strong>确认调用了正确方法</strong><p>让 Agent 说明选用了哪个原子 Skill,以及将遵循哪些步骤。</p></div></li>
|
||||
</ol>
|
||||
<div class="task-prompt">
|
||||
<span>调用示例</span>
|
||||
<p>请使用「{entry.name}」中最适合的方法处理我的任务。开始前,先说明你选择了哪个原子 Skill,以及它要求遵循的工作流程。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="source">
|
||||
<p class="eyebrow">Source</p>
|
||||
<h2>公开来源与审核</h2>
|
||||
<p class="section-lede">本站只负责索引和公开审核,Skill 源内容由对应仓库维护。安装前,Agent 会先读取来源、检查目录结构与潜在风险。</p>
|
||||
<a class="source-row" href={entry.source_url} target="_blank" rel="noreferrer">
|
||||
<span><small>GitHub Repository</small><strong>{repositoryPath}</strong></span><b>打开源仓库 ↗</b>
|
||||
</a>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<aside class="detail-sidebar">
|
||||
<section class="install-card">
|
||||
<div class="agent-row" aria-hidden="true">
|
||||
<span>C</span><span>Cl</span><span>Cu</span><span>W</span><span>+</span>
|
||||
</div>
|
||||
<p class="install-kicker">Agent Install</p>
|
||||
<h2>把提示词发给你的 Agent</h2>
|
||||
<p class="install-copy">无需选择平台或手动复制目录,Agent 会读取安装规范并适配当前环境。</p>
|
||||
<div class="install-prompt"><p>{installPrompt}</p></div>
|
||||
<button class="copy-prompt" type="button" data-copy={installPrompt}>复制安装提示词</button>
|
||||
<div class="install-links">
|
||||
<a href={INSTALL_GUIDE_URL} target="_blank" rel="noreferrer">查看安装规范</a>
|
||||
<a href={entry.source_url} target="_blank" rel="noreferrer">查看源代码 ↗</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metadata-panel">
|
||||
<h2>Registry 信息</h2>
|
||||
<dl>
|
||||
<div><dt>Slug</dt><dd><code>{entry.slug}</code></dd></div>
|
||||
<div><dt>质量</dt><dd>{qualityLabel}</dd></div>
|
||||
<div><dt>领域</dt><dd>{entry.domains.join("、")}</dd></div>
|
||||
<div><dt>语言</dt><dd>{entry.language.join(" / ")}</dd></div>
|
||||
<div><dt>来源</dt><dd>{entry.source_type === "github" ? "GitHub 仓库" : "仓库内置"}</dd></div>
|
||||
<div><dt>状态</dt><dd><span class="active-dot"></span>{entry.status}</dd></div>
|
||||
</dl>
|
||||
<p>信息有误?通过 Pull Request 修改 <code>registry/{entry.slug}/entry.yaml</code>。</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-copy]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(button.dataset.copy ?? "");
|
||||
button.textContent = "已复制,可以发给 Agent";
|
||||
button.classList.add("copied");
|
||||
setTimeout(() => {
|
||||
button.textContent = "复制安装提示词";
|
||||
button.classList.remove("copied");
|
||||
}, 1800);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.detail-hero { padding: 38px 0 52px; border-bottom: 1px solid var(--line); background: var(--paper); }
|
||||
.back-link { color: var(--muted); font-size: 13px; }
|
||||
.back-link:hover { color: var(--accent); }
|
||||
.detail-heading { display: grid; grid-template-columns: 70px minmax(0, 850px); gap: 24px; align-items: start; margin-top: 42px; }
|
||||
.skill-symbol { display: grid; place-items: center; width: 70px; height: 70px; border-radius: 15px; background: var(--ink); color: white; font: 34px "Songti SC", serif; }
|
||||
.detail-heading h1 { margin: 15px 0 10px; font-size: clamp(38px, 5vw, 58px); line-height: 1.08; letter-spacing: -.045em; }
|
||||
.detail-heading p { max-width: 820px; margin: 0; color: var(--muted); font-size: 17px; }
|
||||
.detail-badges { display: flex; flex-wrap: wrap; align-items: center; gap: 11px; color: var(--muted); font-size: 12px; }
|
||||
.detail-tabs { position: sticky; top: 68px; z-index: 8; border-bottom: 1px solid var(--line); background: rgba(255, 254, 249, .95); backdrop-filter: blur(12px); }
|
||||
.detail-tabs .shell { display: flex; gap: 36px; height: 56px; align-items: center; }
|
||||
.detail-tabs a { height: 56px; padding-top: 17px; color: var(--muted); font-size: 14px; }
|
||||
.detail-tabs a:first-child { border-bottom: 2px solid var(--ink); color: var(--ink); font-weight: 700; }
|
||||
.detail-tabs a:hover { color: var(--ink); }
|
||||
.detail-grid { display: grid; grid-template-columns: minmax(0, 730px) 360px; justify-content: space-between; gap: 56px; padding-top: 22px; }
|
||||
.detail-content section { scroll-margin-top: 138px; padding: 48px 0 58px; }
|
||||
.detail-content section + section { border-top: 1px solid var(--line); }
|
||||
.detail-content h2 { margin: 0 0 14px; font-size: 30px; line-height: 1.2; letter-spacing: -.03em; }
|
||||
.section-lede { margin: 0; color: var(--muted); font-size: 16px; line-height: 1.75; }
|
||||
.overview-stats { display: grid; grid-template-columns: repeat(3, 1fr); margin: 34px 0 0; border-top: 1px solid var(--ink); }
|
||||
.overview-stats div { padding: 20px 0; border-bottom: 1px solid var(--line); }
|
||||
.overview-stats div + div { padding-left: 22px; border-left: 1px solid var(--line); }
|
||||
.overview-stats dt { font-size: 28px; font-weight: 750; letter-spacing: -.04em; }
|
||||
.overview-stats dd { margin: 3px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.use-case-list { display: grid; gap: 0; margin: 30px 0 0; padding: 0; border-top: 1px solid var(--ink); list-style: none; }
|
||||
.use-case-list li { display: grid; grid-template-columns: 52px 1fr; align-items: center; min-height: 72px; border-bottom: 1px solid var(--line); }
|
||||
.use-case-list span { color: var(--accent); font: 12px "SFMono-Regular", monospace; }
|
||||
.use-case-list strong { font-size: 16px; }
|
||||
.usage-steps { display: grid; gap: 18px; margin: 30px 0; padding: 0; list-style: none; }
|
||||
.usage-steps li { display: grid; grid-template-columns: 34px 1fr; gap: 14px; }
|
||||
.usage-steps li > span { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--ink); color: white; font-size: 12px; }
|
||||
.usage-steps strong { font-size: 15px; }
|
||||
.usage-steps p { margin: 3px 0 0; color: var(--muted); font-size: 14px; }
|
||||
.task-prompt { padding: 20px; border: 1px solid var(--line); border-radius: 8px; background: var(--soft); }
|
||||
.task-prompt span { color: var(--accent); font-size: 11px; font-weight: 750; text-transform: uppercase; }
|
||||
.task-prompt p { margin: 9px 0 0; color: #41443e; }
|
||||
.source-row { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-top: 26px; padding: 19px 20px; border: 1px solid var(--line); border-radius: 8px; }
|
||||
.source-row:hover { border-color: var(--ink); }
|
||||
.source-row span { display: grid; }
|
||||
.source-row small { color: var(--muted); font-size: 11px; }
|
||||
.source-row strong { overflow-wrap: anywhere; }
|
||||
.source-row b { color: var(--accent); font-size: 13px; white-space: nowrap; }
|
||||
.detail-sidebar { position: sticky; top: 146px; align-self: start; display: grid; gap: 18px; padding-top: 26px; }
|
||||
.install-card { padding: 24px; border: 1px solid var(--ink); border-radius: 12px; background: white; box-shadow: 8px 8px 0 var(--ink); }
|
||||
.agent-row { display: flex; gap: 7px; margin-bottom: 24px; }
|
||||
.agent-row span { display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--line); border-radius: 50%; background: var(--soft); color: var(--muted); font-size: 10px; font-weight: 750; }
|
||||
.agent-row span:first-child { border-color: var(--ink); background: var(--ink); color: white; }
|
||||
.install-kicker { margin: 0 0 4px; color: var(--accent); font: 11px "SFMono-Regular", monospace; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.install-card h2 { margin: 0; font-size: 22px; line-height: 1.3; letter-spacing: -.025em; }
|
||||
.install-copy { margin: 9px 0 18px; color: var(--muted); font-size: 13px; }
|
||||
.install-prompt { padding: 15px; border: 1px solid var(--line); border-radius: 7px; background: var(--soft); }
|
||||
.install-prompt p { margin: 0; color: #4b4e47; font-size: 12px; line-height: 1.65; word-break: break-word; }
|
||||
.copy-prompt { width: 100%; min-height: 46px; margin-top: 13px; border: 1px solid var(--accent); border-radius: 8px; background: var(--accent); color: white; font-weight: 700; }
|
||||
.copy-prompt:hover { filter: brightness(.94); }
|
||||
.copy-prompt.copied { border-color: var(--success); background: var(--success); }
|
||||
.install-links { display: flex; justify-content: space-between; margin-top: 14px; }
|
||||
.install-links a { color: var(--muted); font-size: 11px; }
|
||||
.install-links a:hover { color: var(--accent); }
|
||||
.metadata-panel { padding: 22px; border: 1px solid var(--line); border-radius: 10px; background: white; }
|
||||
.metadata-panel h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.metadata-panel dl { margin: 0; }
|
||||
.metadata-panel dl div { display: grid; grid-template-columns: 72px 1fr; padding: 11px 0; border-top: 1px solid var(--line); }
|
||||
.metadata-panel dt { color: var(--muted); font-size: 11px; }
|
||||
.metadata-panel dd { margin: 0; overflow-wrap: anywhere; font-size: 12px; text-align: right; }
|
||||
.metadata-panel > p { margin: 18px 0 0; color: var(--muted); font-size: 11px; }
|
||||
.active-dot { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: #27a05c; }
|
||||
@media (max-width: 900px) {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.detail-sidebar { position: static; order: -1; grid-template-columns: minmax(0, 1.15fr) minmax(260px, .85fr); }
|
||||
.install-card { box-shadow: 5px 5px 0 var(--ink); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.detail-heading { grid-template-columns: 50px 1fr; gap: 15px; margin-top: 30px; }
|
||||
.skill-symbol { width: 50px; height: 50px; border-radius: 11px; font-size: 24px; }
|
||||
.detail-heading h1 { font-size: 36px; }
|
||||
.detail-tabs { top: 60px; overflow-x: auto; }
|
||||
.detail-tabs .shell { width: max-content; min-width: 100%; gap: 26px; }
|
||||
.detail-sidebar { grid-template-columns: 1fr; }
|
||||
.overview-stats { grid-template-columns: 1fr; }
|
||||
.overview-stats div + div { padding-left: 0; border-left: 0; }
|
||||
.source-row { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
import SkillCard from "@/components/SkillCard.astro";
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
import { getCatalogStats, loadCatalog } from "@/lib/catalog";
|
||||
|
||||
const entries = await loadCatalog();
|
||||
const stats = getCatalogStats(entries);
|
||||
const domains = [...new Set(entries.flatMap((entry) => entry.domains))].sort((a, b) => a.localeCompare(b, "zh-CN"));
|
||||
---
|
||||
|
||||
<BaseLayout title="Skills 目录 — 仓颉 Skill" description={`浏览 ${stats.packs} 个 Skill Pack 和 ${stats.skills} 个经过蒸馏的原子 Skill。`}>
|
||||
<section class="page-hero compact"><div class="shell"><p class="eyebrow">Registry</p><h1>Skills 目录</h1><p class="lede">当前收录 <strong>{stats.packs}</strong> 个 Skill Pack,共 <strong>{stats.skills}</strong> 个原子 Skill。搜索名称、领域或使用场景,快速找到合适的方法。</p></div></section>
|
||||
|
||||
<section class="catalog-section shell">
|
||||
<div class="filters" role="search" aria-label="筛选 Skills">
|
||||
<label class="search-field"><span>搜索</span><input id="skill-search" type="search" placeholder="例如:投资、文案、AI…" autocomplete="off" /></label>
|
||||
<label><span>领域</span><select id="domain-filter"><option value="">全部领域</option>{domains.map((domain) => <option value={domain}>{domain}</option>)}</select></label>
|
||||
<label><span>质量</span><select id="quality-filter"><option value="">全部质量</option><option value="verified">已验证</option><option value="community">社区收录</option><option value="experimental">实验性</option></select></label>
|
||||
<label><span>来源</span><select id="source-filter"><option value="">全部来源</option><option value="github">GitHub 仓库</option><option value="bundled">仓库内置</option></select></label>
|
||||
</div>
|
||||
|
||||
<div class="results-bar"><p id="results-count" aria-live="polite">显示全部 {entries.length} 个 Skill Pack</p><button id="reset-filters" type="button">重置筛选</button></div>
|
||||
<div id="skills-grid" class="skill-grid">
|
||||
{entries.map((entry) => <SkillCard entry={entry} filterable />)}
|
||||
</div>
|
||||
<div id="empty-state" class="empty-state" hidden><h2>没有找到匹配的 Skill</h2><p>换一个关键词,或清空筛选条件再试。</p><button class="button secondary" type="button" data-reset>清空筛选</button></div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const search = document.querySelector<HTMLInputElement>("#skill-search");
|
||||
const domain = document.querySelector<HTMLSelectElement>("#domain-filter");
|
||||
const quality = document.querySelector<HTMLSelectElement>("#quality-filter");
|
||||
const source = document.querySelector<HTMLSelectElement>("#source-filter");
|
||||
const cards = [...document.querySelectorAll<HTMLElement>("[data-skill-card]")];
|
||||
const count = document.querySelector<HTMLElement>("#results-count");
|
||||
const empty = document.querySelector<HTMLElement>("#empty-state");
|
||||
|
||||
function applyFilters() {
|
||||
const query = search?.value.trim().toLocaleLowerCase("zh-CN") ?? "";
|
||||
let visible = 0;
|
||||
for (const card of cards) {
|
||||
const matches = (!query || card.dataset.search?.includes(query))
|
||||
&& (!domain?.value || card.dataset.domains?.split("|").includes(domain.value))
|
||||
&& (!quality?.value || card.dataset.quality === quality.value)
|
||||
&& (!source?.value || card.dataset.source === source.value);
|
||||
card.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
}
|
||||
if (count) count.textContent = visible === cards.length ? `显示全部 ${visible} 个 Skill Pack` : `找到 ${visible} 个 Skill Pack`;
|
||||
if (empty) empty.hidden = visible !== 0;
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set("q", query);
|
||||
if (domain?.value) params.set("domain", domain.value);
|
||||
if (quality?.value) params.set("quality", quality.value);
|
||||
history.replaceState(null, "", `${location.pathname}${params.size ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
if (search) search.value = "";
|
||||
if (domain) domain.value = "";
|
||||
if (quality) quality.value = "";
|
||||
if (source) source.value = "";
|
||||
applyFilters();
|
||||
search?.focus();
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (search) search.value = params.get("q") ?? "";
|
||||
if (domain) domain.value = params.get("domain") ?? "";
|
||||
if (quality) quality.value = params.get("quality") ?? "";
|
||||
applyFilters();
|
||||
|
||||
[search, domain, quality, source].forEach((control) => control?.addEventListener("input", applyFilters));
|
||||
document.querySelectorAll("#reset-filters, [data-reset]").forEach((button) => button.addEventListener("click", resetFilters));
|
||||
</script>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.page-hero.compact { padding-bottom: 42px; }
|
||||
.page-hero.compact h1 { font-size: clamp(42px, 6vw, 64px); }
|
||||
.catalog-section { padding-top: 38px; }
|
||||
.filters { display: grid; grid-template-columns: minmax(260px, 1.6fr) repeat(3, 1fr); gap: 10px; padding: 15px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--soft); }
|
||||
.filters label { display: grid; gap: 6px; }
|
||||
.filters label > span { color: var(--muted); font-size: 11px; font-weight: 700; }
|
||||
.filters input, .filters select { width: 100%; height: 43px; padding: 0 12px; border: 1px solid var(--line); border-radius: 6px; background: white; color: var(--ink); outline: none; }
|
||||
.filters input:focus, .filters select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.results-bar { display: flex; justify-content: space-between; align-items: center; min-height: 67px; }
|
||||
.results-bar p { color: var(--muted); font-size: 13px; }
|
||||
.results-bar button { border: 0; background: transparent; color: var(--accent); font-size: 13px; }
|
||||
.empty-state { padding: 80px 20px; text-align: center; }
|
||||
.empty-state h2 { margin-bottom: 4px; }
|
||||
.empty-state p { margin-top: 0; color: var(--muted); }
|
||||
@media (max-width: 820px) { .filters { grid-template-columns: 1fr 1fr; } .search-field { grid-column: 1 / -1; } }
|
||||
@media (max-width: 520px) { .filters { grid-template-columns: 1fr; } .search-field { grid-column: auto; } }
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
---
|
||||
import BaseLayout from "@/layouts/BaseLayout.astro";
|
||||
---
|
||||
|
||||
<BaseLayout title="提交 Skill — 仓颉 Skill" description="生成标准 Registry 条目,通过 GitHub Pull Request 提交你的 Skill Pack。">
|
||||
<section class="page-hero compact"><div class="shell"><p class="eyebrow">Contribute</p><h1>提交一个 Skill</h1><p class="lede">没有后台账号,也没有黑箱审核。页面只在浏览器本地整理你的信息,最终通过 GitHub Pull Request 公开提交和审核。</p></div></section>
|
||||
|
||||
<section class="submit-section shell">
|
||||
<div class="review-flow" aria-label="审核流程"><div><span>1</span><strong>填写信息</strong><small>本地生成标准文件</small></div><i>→</i><div><span>2</span><strong>发起 PR</strong><small>GitHub 公开提交</small></div><i>→</i><div><span>3</span><strong>自动检查</strong><small>Schema 与目录校验</small></div><i>→</i><div><span>4</span><strong>人工审核</strong><small>合并后自动上线</small></div></div>
|
||||
|
||||
<div class="submit-grid">
|
||||
<form id="submission-form" class="submission-form">
|
||||
<fieldset class="mode-picker"><legend>你的 Skill 放在哪里?</legend><label><input type="radio" name="mode" value="github" checked /><span><strong>已经开源到 GitHub</strong><small>推荐。只收录仓库链接,不复制你的文件。</small></span></label><label><input type="radio" name="mode" value="bundled" /><span><strong>还没有公开仓库</strong><small>选择本地文件夹,打包后随 PR 放进本仓库。</small></span></label></fieldset>
|
||||
|
||||
<div class="form-section"><div class="form-section-title"><span>01</span><div><h2>基本信息</h2><p>这些内容会展示在 Skills 目录和详情页。</p></div></div><div class="field-grid"><label><span>Skill Pack 名称 *</span><input name="name" required maxlength="80" placeholder="例如:产品决策 Skill" /></label><label><span>Slug *</span><input name="slug" required pattern="[a-z0-9]+(?:-[a-z0-9]+)*" maxlength="64" placeholder="product-decision-skill" /><small>仅小写英文、数字和连字符</small></label></div><label><span>一句话简介 *</span><textarea name="summary" required minlength="10" maxlength="240" rows="3" placeholder="说明它来自什么内容、能帮助用户完成什么任务。"></textarea><small><output id="summary-count">0</output> / 240</small></label></div>
|
||||
|
||||
<div class="form-section"><div class="form-section-title"><span>02</span><div><h2>来源与内容</h2><p>我们不会在这个页面上传任何文件。</p></div></div><div data-mode-panel="github"><label><span>GitHub 仓库地址 *</span><input name="repositoryUrl" type="url" placeholder="https://github.com/owner/repository" /></label></div><div data-mode-panel="bundled" hidden><label class="folder-drop"><span>选择 Skill Pack 文件夹 *</span><input id="skill-folder" name="skillFolder" type="file" multiple /><strong>选择本地文件夹</strong><small>只在浏览器中检查;不会自动上传。上限 20 MB。</small></label><div id="folder-result" class="folder-result" aria-live="polite"></div></div><div class="field-grid"><label><span>原子 Skill 数量 *</span><input name="skillCount" type="number" required min="1" value="1" /></label><label><span>语言 *</span><select name="languages" multiple size="3"><option value="zh-CN" selected>简体中文</option><option value="en">English</option><option value="ja">日本語</option></select><small>按住 Command / Ctrl 可多选</small></label></div></div>
|
||||
|
||||
<div class="form-section"><div class="form-section-title"><span>03</span><div><h2>发现信息</h2><p>帮助其他人通过搜索和筛选找到它。</p></div></div><label><span>领域标签 *</span><input name="domains" required placeholder="例如:产品,决策,管理" /><small>用逗号分隔,建议 2–4 个</small></label><label><span>使用场景 *</span><textarea name="useCases" required rows="5" placeholder="评估一个产品方向 检查重要决策的隐含假设 复盘项目失败原因"></textarea><small>每行一个,最多 6 个</small></label></div>
|
||||
|
||||
<label class="agreement"><input name="agreement" type="checkbox" required /><span>我确认提交内容有权公开,并理解它将通过 GitHub PR 公开审核;合并不代表对内容准确性的背书。</span></label>
|
||||
<div id="form-error" class="form-error" role="alert" hidden></div>
|
||||
<button class="button accent generate-button" type="submit">生成投稿文件</button>
|
||||
<p class="privacy-note">草稿信息仅保存在当前浏览器的 localStorage;所选文件夹不会被保存或上传。</p>
|
||||
</form>
|
||||
|
||||
<aside class="preview-panel"><div class="preview-heading"><div><p class="eyebrow">Preview</p><h2>Registry 条目</h2></div><button id="copy-yaml" type="button" disabled>复制 YAML</button></div><pre id="yaml-preview"><code># 填写表单后,这里会生成
|
||||
# registry/<slug>/entry.yaml</code></pre><div id="next-actions" class="next-actions" hidden><h3>文件已准备好</h3><p id="next-description"></p><div class="action-stack"><a id="github-action" class="button accent" href="#" target="_blank" rel="noreferrer">在 GitHub 创建文件 ↗</a><button id="download-action" class="button secondary" type="button">下载投稿文件</button></div><ol><li>在 GitHub 页面检查文件内容并提交 commit</li><li>按提示创建 Pull Request</li><li>等待自动检查和维护者审核</li></ol></div></aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import JSZip from "jszip";
|
||||
import { buildGitHubCreateUrl, createRegistryEntry, serializeRegistryEntry, slugify, validateSkillFolder } from "@/lib/submission";
|
||||
import type { RegistryEntry, SourceType } from "@/lib/catalog";
|
||||
|
||||
const form = document.querySelector<HTMLFormElement>("#submission-form")!;
|
||||
const slugInput = form.elements.namedItem("slug") as HTMLInputElement;
|
||||
const nameInput = form.elements.namedItem("name") as HTMLInputElement;
|
||||
const summaryInput = form.elements.namedItem("summary") as HTMLTextAreaElement;
|
||||
const repoInput = form.elements.namedItem("repositoryUrl") as HTMLInputElement;
|
||||
const countInput = form.elements.namedItem("skillCount") as HTMLInputElement;
|
||||
const folderInput = document.querySelector<HTMLInputElement>("#skill-folder")!;
|
||||
folderInput.setAttribute("webkitdirectory", "");
|
||||
const folderResult = document.querySelector<HTMLElement>("#folder-result")!;
|
||||
const preview = document.querySelector<HTMLElement>("#yaml-preview code")!;
|
||||
const copyButton = document.querySelector<HTMLButtonElement>("#copy-yaml")!;
|
||||
const nextActions = document.querySelector<HTMLElement>("#next-actions")!;
|
||||
const githubAction = document.querySelector<HTMLAnchorElement>("#github-action")!;
|
||||
const downloadAction = document.querySelector<HTMLButtonElement>("#download-action")!;
|
||||
const errorBox = document.querySelector<HTMLElement>("#form-error")!;
|
||||
let generated: { entry: RegistryEntry; yaml: string } | undefined;
|
||||
let slugWasEdited = false;
|
||||
|
||||
function currentMode(): SourceType { return new FormData(form).get("mode") as SourceType; }
|
||||
function updateMode() {
|
||||
const mode = currentMode();
|
||||
document.querySelectorAll<HTMLElement>("[data-mode-panel]").forEach((panel) => { panel.hidden = panel.dataset.modePanel !== mode; });
|
||||
repoInput.required = mode === "github";
|
||||
folderInput.required = mode === "bundled";
|
||||
nextActions.hidden = true;
|
||||
}
|
||||
function saveDraft() {
|
||||
const values = Object.fromEntries([...new FormData(form).entries()].filter(([, value]) => typeof value === "string"));
|
||||
localStorage.setItem("cangjie-skill-submission-draft", JSON.stringify(values));
|
||||
}
|
||||
function restoreDraft() {
|
||||
try {
|
||||
const values = JSON.parse(localStorage.getItem("cangjie-skill-submission-draft") ?? "null");
|
||||
if (!values) return;
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const controls = form.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(`[name="${name}"]`);
|
||||
controls.forEach((control) => {
|
||||
if (control instanceof HTMLInputElement && control.type === "radio") control.checked = control.value === value;
|
||||
else if (!(control instanceof HTMLInputElement && ["file", "checkbox"].includes(control.type))) control.value = String(value);
|
||||
});
|
||||
}
|
||||
slugWasEdited = Boolean(slugInput.value);
|
||||
} catch { localStorage.removeItem("cangjie-skill-submission-draft"); }
|
||||
}
|
||||
|
||||
nameInput.addEventListener("input", () => { if (!slugWasEdited) slugInput.value = slugify(nameInput.value); });
|
||||
slugInput.addEventListener("input", () => { slugWasEdited = true; });
|
||||
summaryInput.addEventListener("input", () => { document.querySelector("#summary-count")!.textContent = String(summaryInput.value.length); });
|
||||
form.querySelectorAll<HTMLInputElement>("[name=mode]").forEach((radio) => radio.addEventListener("change", updateMode));
|
||||
form.addEventListener("input", saveDraft);
|
||||
|
||||
folderInput.addEventListener("change", () => {
|
||||
const result = validateSkillFolder([...(folderInput.files ?? [])]);
|
||||
folderResult.className = `folder-result ${result.valid ? "valid" : "invalid"}`;
|
||||
folderResult.replaceChildren();
|
||||
for (const message of [...result.errors, ...result.warnings, result.valid ? `✓ 找到 ${result.skillCount} 个 SKILL.md,共 ${result.acceptedFiles.length} 个文件。` : ""].filter(Boolean)) {
|
||||
const paragraph = document.createElement("p");
|
||||
paragraph.textContent = message;
|
||||
folderResult.append(paragraph);
|
||||
}
|
||||
if (result.valid) countInput.value = String(result.skillCount);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
errorBox.hidden = true;
|
||||
try {
|
||||
if (currentMode() === "bundled") {
|
||||
const result = validateSkillFolder([...(folderInput.files ?? [])]);
|
||||
if (!result.valid) throw new Error(result.errors.join(" "));
|
||||
}
|
||||
const data = new FormData(form);
|
||||
const languages = [...form.querySelector<HTMLSelectElement>("[name=languages]")!.selectedOptions].map((option) => option.value);
|
||||
const entry = createRegistryEntry({
|
||||
mode: currentMode(),
|
||||
name: String(data.get("name") ?? ""),
|
||||
slug: String(data.get("slug") ?? ""),
|
||||
summary: String(data.get("summary") ?? ""),
|
||||
repositoryUrl: String(data.get("repositoryUrl") ?? ""),
|
||||
skillCount: Number(data.get("skillCount") ?? 1),
|
||||
domains: String(data.get("domains") ?? ""),
|
||||
languages,
|
||||
useCases: String(data.get("useCases") ?? ""),
|
||||
});
|
||||
const yaml = serializeRegistryEntry(entry);
|
||||
generated = { entry, yaml };
|
||||
preview.textContent = yaml;
|
||||
copyButton.disabled = false;
|
||||
nextActions.hidden = false;
|
||||
githubAction.hidden = entry.source_type !== "github";
|
||||
githubAction.href = buildGitHubCreateUrl(entry, yaml);
|
||||
downloadAction.textContent = entry.source_type === "github" ? "下载 entry.yaml" : "下载完整投稿 ZIP";
|
||||
document.querySelector("#next-description")!.textContent = entry.source_type === "github" ? "点击下方按钮会打开 GitHub 文件编辑页;GitHub 会引导你 fork 并创建 PR。" : "ZIP 中包含 Registry 条目和所选 Skill 文件夹。解压到 fork 后提交 Pull Request。";
|
||||
nextActions.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
} catch (error) {
|
||||
errorBox.textContent = error instanceof Error ? error.message : "生成失败,请检查表单。";
|
||||
errorBox.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
copyButton.addEventListener("click", async () => {
|
||||
if (!generated) return;
|
||||
await navigator.clipboard.writeText(generated.yaml);
|
||||
copyButton.textContent = "已复制";
|
||||
setTimeout(() => { copyButton.textContent = "复制 YAML"; }, 1400);
|
||||
});
|
||||
|
||||
downloadAction.addEventListener("click", async () => {
|
||||
if (!generated) return;
|
||||
if (generated.entry.source_type === "github") {
|
||||
downloadBlob(new Blob([generated.yaml], { type: "text/yaml;charset=utf-8" }), "entry.yaml");
|
||||
return;
|
||||
}
|
||||
const zip = new JSZip();
|
||||
zip.file(`registry/${generated.entry.slug}/entry.yaml`, generated.yaml);
|
||||
const validation = validateSkillFolder([...(folderInput.files ?? [])]);
|
||||
for (const fileLike of validation.acceptedFiles) {
|
||||
const file = fileLike as File;
|
||||
const relative = file.webkitRelativePath.split("/").slice(1).join("/") || file.name;
|
||||
zip.file(`registry/${generated.entry.slug}/skill/${relative}`, await file.arrayBuffer());
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
|
||||
downloadBlob(blob, `${generated.entry.slug}-submission.zip`);
|
||||
});
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = Object.assign(document.createElement("a"), { href: url, download: filename });
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
restoreDraft();
|
||||
updateMode();
|
||||
document.querySelector("#summary-count")!.textContent = String(summaryInput.value.length);
|
||||
</script>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.page-hero.compact { padding-bottom: 42px; }
|
||||
.page-hero.compact h1 { font-size: clamp(42px, 6vw, 64px); }
|
||||
.submit-section { padding-top: 38px; }
|
||||
.review-flow { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 38px; padding: 19px 24px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--soft); }
|
||||
.review-flow div { display: grid; grid-template-columns: 28px 1fr; align-items: center; column-gap: 9px; }
|
||||
.review-flow span { grid-row: 1 / 3; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--ink); color: white; font-size: 12px; }
|
||||
.review-flow strong { font-size: 13px; }
|
||||
.review-flow small { color: var(--muted); font-size: 11px; }
|
||||
.review-flow i { color: #a7aaa1; font-style: normal; }
|
||||
.submit-grid { display: grid; grid-template-columns: minmax(0, 680px) minmax(310px, 1fr); gap: 36px; align-items: start; }
|
||||
.submission-form { display: grid; gap: 18px; }
|
||||
fieldset { margin: 0; }
|
||||
.mode-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0; border: 0; }
|
||||
.mode-picker legend { margin-bottom: 10px; font-size: 14px; font-weight: 700; }
|
||||
.mode-picker label { position: relative; display: flex; gap: 11px; min-height: 100px; padding: 17px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; }
|
||||
.mode-picker label:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.mode-picker input { margin-top: 4px; accent-color: var(--accent); }
|
||||
.mode-picker span { display: grid; align-content: start; gap: 5px; }
|
||||
.mode-picker small { color: var(--muted); line-height: 1.45; }
|
||||
.form-section { display: grid; gap: 17px; padding: 26px; border: 1px solid var(--line); border-radius: var(--radius); background: white; }
|
||||
.form-section-title { display: flex; gap: 12px; padding-bottom: 17px; border-bottom: 1px solid var(--line); }
|
||||
.form-section-title > span { color: var(--accent); font: 12px "SFMono-Regular", monospace; }
|
||||
.form-section-title h2 { margin: 0; font-size: 18px; }
|
||||
.form-section-title p { margin: 2px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.form-section label:not(.folder-drop) { display: grid; gap: 6px; }
|
||||
.form-section label > span { font-size: 13px; font-weight: 650; }
|
||||
.form-section input, .form-section textarea, .form-section select { width: 100%; padding: 10px 11px; border: 1px solid #cfd1ca; border-radius: 6px; background: white; color: var(--ink); outline: none; }
|
||||
.form-section input:focus, .form-section textarea:focus, .form-section select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.form-section small { color: var(--muted); font-size: 11px; }
|
||||
.folder-drop { display: grid; place-items: center; min-height: 150px; padding: 22px; border: 1px dashed #aeb1a7; border-radius: 8px; text-align: center; cursor: pointer; }
|
||||
.folder-drop input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||
.folder-drop span { color: var(--muted); font-size: 12px; }
|
||||
.folder-drop strong { color: var(--accent); }
|
||||
.folder-result { font-size: 12px; }
|
||||
.folder-result p { margin: 5px 0; }
|
||||
.folder-result.valid { color: var(--success); }
|
||||
.folder-result.invalid { color: #a32b2b; }
|
||||
.agreement { display: flex; gap: 10px; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.agreement input { margin-top: 3px; accent-color: var(--accent); }
|
||||
.form-error { padding: 12px 14px; border: 1px solid #e1a4a4; border-radius: 6px; background: #fff1f1; color: #8c2020; font-size: 13px; }
|
||||
.generate-button { width: 100%; }
|
||||
.privacy-note { margin: -8px 0 0; text-align: center; color: var(--muted); font-size: 11px; }
|
||||
.preview-panel { position: sticky; top: 92px; overflow: hidden; border: 1px solid var(--ink); border-radius: var(--radius); background: white; }
|
||||
.preview-heading { display: flex; justify-content: space-between; align-items: center; padding: 20px; }
|
||||
.preview-heading .eyebrow { margin-bottom: 2px; }
|
||||
.preview-heading h2 { margin: 0; font-size: 18px; }
|
||||
.preview-heading button { border: 0; background: transparent; color: var(--accent); font-size: 12px; }
|
||||
.preview-heading button:disabled { color: #aaaca5; }
|
||||
#yaml-preview { min-height: 390px; max-height: 560px; margin: 0; border: 0; border-radius: 0; font-size: 11px; }
|
||||
.next-actions { padding: 20px; border-top: 1px solid var(--line); }
|
||||
.next-actions h3 { margin: 0; font-size: 16px; }
|
||||
.next-actions p, .next-actions li { color: var(--muted); font-size: 12px; }
|
||||
.next-actions ol { margin: 18px 0 0; padding-left: 20px; }
|
||||
.action-stack { display: grid; gap: 8px; margin-top: 16px; }
|
||||
@media (max-width: 900px) { .submit-grid { grid-template-columns: 1fr; } .preview-panel { position: static; } .review-flow small { display: none; } }
|
||||
@media (max-width: 620px) { .review-flow { align-items: flex-start; flex-direction: column; } .review-flow i { display: none; } .mode-picker, .field-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
:root {
|
||||
--ink: #151613;
|
||||
--muted: #686b63;
|
||||
--line: #dedfd9;
|
||||
--soft: #f5f6f1;
|
||||
--paper: #fffef9;
|
||||
--accent: #2563eb;
|
||||
--accent-soft: #eaf0ff;
|
||||
--success: #17633a;
|
||||
--warning: #8a5a00;
|
||||
--radius: 10px;
|
||||
--shadow: 0 12px 34px rgba(32, 35, 29, 0.08);
|
||||
font-family: "Avenir Next", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; min-width: 320px; background: var(--paper); line-height: 1.65; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
img { max-width: 100%; }
|
||||
.shell { width: min(1120px, calc(100% - 40px)); margin-inline: auto; }
|
||||
.narrow { width: min(760px, calc(100% - 40px)); margin-inline: auto; }
|
||||
.skip-link { position: fixed; left: 12px; top: -60px; z-index: 20; padding: 10px 14px; background: var(--ink); color: white; }
|
||||
.skip-link:focus { top: 12px; }
|
||||
.site-header { position: sticky; top: 0; z-index: 10; border-bottom: 1px solid rgba(222, 223, 217, 0.85); background: rgba(255, 254, 249, 0.92); backdrop-filter: blur(14px); }
|
||||
.header-inner { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||
.brand { display: inline-flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.brand-mark { display: inline-grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; background: var(--ink); color: white; font-family: "Songti SC", serif; }
|
||||
.site-nav { display: flex; align-items: center; gap: 26px; font-size: 14px; }
|
||||
.site-nav a { color: #4f524c; }
|
||||
.site-nav a:hover, .site-nav a[aria-current="page"] { color: var(--ink); }
|
||||
.site-nav a[aria-current="page"] { font-weight: 650; }
|
||||
.github-link { padding-left: 26px; border-left: 1px solid var(--line); }
|
||||
.site-footer { margin-top: 96px; padding: 42px 0; border-top: 1px solid var(--line); background: var(--soft); color: var(--muted); }
|
||||
.footer-inner { display: flex; justify-content: space-between; gap: 32px; }
|
||||
.footer-brand { color: var(--ink); }
|
||||
.footer-inner p { margin: 12px 0 0; font-size: 14px; }
|
||||
.footer-links { display: grid; grid-template-columns: repeat(2, max-content); gap: 9px 32px; font-size: 14px; }
|
||||
.footer-links a:hover { color: var(--ink); }
|
||||
|
||||
.eyebrow { margin: 0 0 14px; color: var(--accent); font-size: 13px; font-weight: 750; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.page-hero { padding: 76px 0 52px; border-bottom: 1px solid var(--line); }
|
||||
.page-hero h1 { margin: 0; max-width: 830px; font-size: clamp(40px, 6vw, 66px); line-height: 1.08; letter-spacing: -.045em; }
|
||||
.page-hero .lede { max-width: 720px; margin: 22px 0 0; color: var(--muted); font-size: 18px; }
|
||||
.section { padding: 74px 0; }
|
||||
.section + .section { border-top: 1px solid var(--line); }
|
||||
.section-header { display: flex; align-items: end; justify-content: space-between; gap: 28px; margin-bottom: 28px; }
|
||||
.section-header h2 { margin: 0; font-size: clamp(28px, 4vw, 40px); line-height: 1.15; letter-spacing: -.035em; }
|
||||
.section-header p { max-width: 560px; margin: 8px 0 0; color: var(--muted); }
|
||||
.text-link { color: var(--accent); font-weight: 650; white-space: nowrap; }
|
||||
.text-link:hover { text-decoration: underline; text-underline-offset: 4px; }
|
||||
.button-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 30px; }
|
||||
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 18px; border: 1px solid var(--ink); border-radius: 8px; background: var(--ink); color: white; font-weight: 650; }
|
||||
.button:hover { transform: translateY(-1px); box-shadow: var(--shadow); }
|
||||
.button.secondary { background: transparent; color: var(--ink); }
|
||||
.button.accent { border-color: var(--accent); background: var(--accent); }
|
||||
|
||||
.skill-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.skill-card { display: flex; flex-direction: column; min-height: 305px; padding: 23px; border: 1px solid var(--line); border-radius: var(--radius); background: white; transition: border-color .18s ease, transform .18s ease, box-shadow .18s ease; }
|
||||
.skill-card:hover { transform: translateY(-3px); border-color: #bdc0b7; box-shadow: var(--shadow); }
|
||||
.skill-card[hidden] { display: none; }
|
||||
.card-topline { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.quality-badge { padding: 3px 8px; border-radius: 999px; background: var(--soft); color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||
.quality-verified { background: #e9f7ef; color: var(--success); }
|
||||
.quality-experimental { background: #fff3d6; color: var(--warning); }
|
||||
.skill-count { color: var(--muted); font-size: 12px; }
|
||||
.skill-card h3 { margin: 22px 0 8px; font-size: 20px; line-height: 1.3; letter-spacing: -.02em; }
|
||||
.skill-card h3 a:hover { color: var(--accent); }
|
||||
.skill-card > p { margin: 0; color: var(--muted); font-size: 14px; }
|
||||
.tag-list { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 18px; }
|
||||
.tag-list span { padding: 3px 8px; border: 1px solid var(--line); border-radius: 5px; color: #575a53; font-size: 12px; }
|
||||
.card-link { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 22px; color: var(--accent); font-size: 14px; font-weight: 650; }
|
||||
|
||||
.prose h2 { margin: 58px 0 14px; font-size: 28px; letter-spacing: -.025em; }
|
||||
.prose h3 { margin: 34px 0 10px; font-size: 20px; }
|
||||
.prose p, .prose li { color: #4f524c; }
|
||||
.prose a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.prose pre, .code-block { overflow-x: auto; padding: 18px; border: 1px solid var(--line); border-radius: 8px; background: #20211e; color: #f5f6f1; font: 13px/1.65 "SFMono-Regular", Consolas, monospace; }
|
||||
.callout { margin: 24px 0; padding: 18px 20px; border-left: 3px solid var(--accent); background: var(--accent-soft); color: #24345d; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.skill-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.site-nav { gap: 16px; }
|
||||
.github-link { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell, .narrow { width: min(100% - 28px, 1120px); }
|
||||
.header-inner { min-height: 60px; }
|
||||
.brand > span:last-child { display: none; }
|
||||
.site-nav { gap: 14px; font-size: 13px; }
|
||||
.page-hero { padding: 52px 0 38px; }
|
||||
.page-hero h1 { font-size: 40px; }
|
||||
.section { padding: 52px 0; }
|
||||
.section-header, .footer-inner { align-items: flex-start; flex-direction: column; }
|
||||
.skill-grid { grid-template-columns: 1fr; }
|
||||
.skill-card { min-height: 280px; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user