Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ website/sitemap.xml

# Python bytecode cache (ui-ux-pro-max skill scripts)
__pycache__/
# 从 CHANGELOG.md 生成(scripts/site-assets.mjs),手写的那份下次发版就过期了
website/changelog.html
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,22 @@ SEO 与 GEO(生成式引擎优化)都落在 `<head>` 与 `website/` 根下

改文案时记得回头看这几处:`<title>`、`meta description`、OG/Twitter 三件套、JSON-LD 里的 `description` 与 `featureList`、以及 `llms.txt`。它们不在一个文件里,最容易只改了一处。

### 内容页与顶栏一致性

除首页外还有 7 个页面:`download.html`、`faq.html`、`changelog.html`(生成)、`guide/`(索引 + 三篇:行情条 / 分组 / 老板键)。它们共用首页的 token 与顶栏页脚,版式换成阅读型 —— `.page-head`(标题区,自己让出 fixed 顶栏的高度)、`.prose`(**680px** 正文列,中文每行 35–45 字最省眼,比首页那 1072px 通栏窄得多)、`.card-grid`(下载卡与指南索引)、`.release*`(更新日志)。面包屑只在 `guide/*` 出现,那是全站唯一的三级路径。

**顶栏、页脚、回到顶部这三段在每页各有一份**,好让每页都是「打开就能看」的独立文件。这违反本仓库平时拒绝的「同一个东西写两遍」,让它可接受的是 `site-assets.mjs` 里的校验:这三段各用 `<!-- chrome:nav:start -->` 之类的标记包起来,脚本提取后**逐字节比对**(比对前剥掉 `../`,因为子目录页面的深度不是漂移),不一致就让 CI 失败。`404.html` 与 `privacy.html` 用 `CHROME_SKIP` 排除 —— 前者要为任意深度的失效路径服务,链接只能是站点绝对路径;后者是从 `docs/` 复制来的、商店登记在册的隐私政策。

于是**当前页高亮不能写进顶栏**(写了就不一致了),改成挂在 `<body data-page="…">` 上加 CSS。选择器用 `href$=` 后缀匹配,这样 `guide/*` 里的 `../` 前缀不影响命中;状态收进一个 `--current` 变量,颜色与指示器都从它派生,于是「哪个 data-page 对应哪个导航项」那份清单只写一遍(拆成两块迟早对不上,而症状只是某一页的指示器没出现)。加新页面时给它一个 `data-page` 值并在那份清单里登记一行;`download` 没有条目是故意的 —— 它的入口是顶栏那颗常驻 CTA 按钮,不是导航项,硬凑一条只会是死代码。

**指示器的形状是有意的,不要改回「淡色底块 + 通栏下划线」。** 那是模板与生成式工具的默认答案;两个信号还互相打架(底块已经说了「就是这里」,再压一条粗线是重复),合起来会被读成一颗带下边框的按钮。现在只留一个信号:标签提亮 + 一条与文字等宽、两端渐隐、带辉光的强调色短横。三个细节各有原因:左右各 12px 正是 `.nav__link` 的内边距,于是线宽正好等于文字宽(按元素框算会宽出一截);两端渐隐,与顶栏滚动后那条 `.nav.is-scrolled::after` 发丝线同源,全站只有这两处用渐变线;辉光用 `drop-shadow` 而非 `box-shadow` —— 前者跟着渐变渲染后的 alpha 走,后者会按元素框发一圈方形的光。手机菜单里同一枚指示器改成**左侧竖条**:竖排列表用竖条、横排用横线,方向跟着布局走。

**`changelog.html` 由 `site-assets.mjs` 从 `CHANGELOG.md` 生成**,不是手抄的:手抄的那份下一次发版就过期,而且过期是静默的。生成器只认 `CHANGELOG.md` 实际用到的那几种写法(`##` 版本、`###` 小节、`-` 条目、行内 `**粗体**` 与 `` `代码` ``),遇到别的原样当段落输出、不抛错。它的顶栏是从 `index.html` 里**提取**出来的,所以顶栏仍然只有一份来源,而生成出来的页面同样受上面那条一致性校验约束(`readChrome` 返回的是**含标记**的整块,只返回内部内容的话生成页会因为「缺标记」而校验失败 —— 踩过)。

站点里另外两处「按实际内容生成」的地方:`sitemap.xml` 扫描 `website/**/*.html`(**递归**,只扫顶层会让 `guide/*` 静默漏掉:不校验、不进 sitemap,而这两件事都不会有人注意到),`lastmod` 取自 `git log`;引用校验按**页面自己所在的目录**解析相对路径 —— 按站点根解析会把 `guide/` 下的 `../styles.css` 解到仓库外面去,而且会报出一堆假缺失。

改动这些页面之后,`npm run site:assets` 会把四件事一次做完:资源复制、引用校验、顶栏一致性校验、sitemap 生成。

注意 **`ticker-dark.png` / `ticker-light.png` 只有 344×59**,必须按原尺寸展示(`.ticker-runway` 就是为此存在的)—— 其余截图是 2000px 级,可自由缩放。

Pages 现在已经跑在 GitHub Actions 上(2026-10 从 `master:/docs` 迁过来的,见下)。工作流的 `paths` 过滤器里带着 `screenshots/**` 与 `docs/privacy.html`:换一张截图、改一次隐私政策,同样应该重新发布,否则站点上还是旧的,而「只改了 screenshots/」看起来与官网无关。
Expand Down
305 changes: 302 additions & 3 deletions scripts/site-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,22 +106,49 @@ function isLocalRef(ref) {
return !/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(ref);
}

// 递归收集**所有**页面,含子目录。只扫顶层的话 guide/* 会被静默漏掉 ——
// 不校验、不进 sitemap,而这两件事都不会有人注意到。
async function publishedPages() {
return (await readdir(siteDir)).filter((f) => f.endsWith('.html')).sort();
const found = [];

async function walk(dir, prefix) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
if (entry.isDirectory()) {
if (entry.name === 'assets') continue; // 产物,不是页面
await walk(join(dir, entry.name), `${prefix}${entry.name}/`);
} else if (entry.name.endsWith('.html')) {
found.push(prefix + entry.name);
}
}
}

await walk(siteDir, '');
return found.sort();
}

async function verifyReferences(pages) {
const broken = [];

for (const page of pages) {
const html = await readFile(resolve(siteDir, page), 'utf8');
// 相对引用要按**页面自己所在的目录**解析,不是站点根 —— guide/ 下的
// ../styles.css 指的是 website/styles.css,按站点根解会解到仓库外面去。
const baseDir = dirname(resolve(siteDir, page));
const seen = new Set();

const check = (ref) => {
const clean = ref.split(/[?#]/)[0];
if (!clean || !isLocalRef(clean) || seen.has(clean)) return;
seen.add(clean);
if (!existsSync(resolve(siteDir, clean))) broken.push(`${page} → ${clean}`);

const target = resolve(baseDir, clean);
if (!target.startsWith(siteDir)) {
// 跳出站点目录的引用在本地能跑(file:// 尚可),发布后一定 404
broken.push(`${page} → ${clean}(跳出了站点目录)`);
return;
}
if (!existsSync(target)) broken.push(`${page} → ${clean}`);
};

for (const [, ref] of html.matchAll(/(?:src|href)="([^"]+)"/g)) check(ref);
Expand Down Expand Up @@ -161,7 +188,9 @@ function lastModified(page) {
async function writeSitemap(pages) {
const listed = pages.filter((p) => !SITEMAP_SKIP.has(p));
const urls = listed.map((page) => {
const loc = SITE_URL + (page === ENTRY ? '' : page);
// 目录索引的规范地址是目录本身:guide/index.html → /guide/
const slug = page === ENTRY ? '' : page.replace(/index\.html$/, '');
const loc = SITE_URL + slug;
const stamp = lastModified(page);
return ` <url>\n <loc>${loc}</loc>${stamp ? `\n <lastmod>${stamp}</lastmod>` : ''}\n </url>`;
});
Expand All @@ -177,6 +206,260 @@ async function writeSitemap(pages) {
return listed;
}

// ── 顶栏 / 页脚 / 回到顶部的一致性 ──────────────────────────────
//
// 这三段被复制进每一个页面,好让每页都是「打开就能看」的独立文件(站点没有
// 构建步骤是它的一条底线)。这种复制正是本仓库平时拒绝的东西 —— 让它可以
// 接受的是下面这个校验:所有页面里这三段必须逐字节相同,不一致就让 CI 失败。
//
// 比对前剥掉 `../`:子目录页面确实需要它,而目录深度不是漂移。
const CHROME_BLOCKS = ['nav', 'footer', 'totop'];

// Two pages are deliberately standalone and carry no chrome:
// 404.html —— 项目站会对任意深度的失效路径返回它,所有链接只能是
// 站点绝对路径,套上顶栏反而会把相对链接带坏
// privacy.html —— 从 docs/ 复制来的隐私政策,商店登记在册,保持原样
const CHROME_SKIP = new Set(['404.html', 'privacy.html']);

const normalizeChrome = (block) => block.replace(/\.\.\//g, '').trim();

async function verifyChrome(pages) {
const problems = [];
let checked = 0;

for (const name of CHROME_BLOCKS) {
const re = new RegExp(
`<!-- chrome:${name}:start -->([\\s\\S]*?)<!-- chrome:${name}:end -->`
);
let reference = null;
let referencePage = null;

for (const page of pages) {
if (CHROME_SKIP.has(page)) continue;
const html = await readFile(resolve(siteDir, page), 'utf8');
const m = re.exec(html);
if (!m) {
problems.push(`${page}: 缺少 chrome:${name} 块`);
continue;
}
checked += 1;
const block = normalizeChrome(m[1]);
if (reference === null) {
reference = block;
referencePage = page;
} else if (block !== reference) {
problems.push(`chrome:${name}:${page} 与 ${referencePage} 不一致`);
}
}
}

return { problems, checked };
}

// 返回整块(含标记本身)—— 生成出来的页面必须连标记一起带上,
// 否则它自己就通不过上面的一致性校验。
async function readChrome(name) {
const html = await readFile(resolve(siteDir, ENTRY), 'utf8');
const m = new RegExp(
`<!-- chrome:${name}:start -->[\\s\\S]*?<!-- chrome:${name}:end -->`
).exec(html);
if (!m) throw new Error(`chrome:${name} block not found in ${ENTRY}`);
return m[0];
}

// ── 更新日志页 ──────────────────────────────────────────────────
//
// 从 CHANGELOG.md 生成而不是手抄一份:手抄的那份在下一次发版就过期了,而且
// 过期是静默的 —— 只有人主动去比对才会发现。这是 site-assets 里唯一「凭内容
// 造页面」的部分,所以范围压到最小:只认 CHANGELOG.md 实际用到的那几种写法
// (`##` 版本、`###` 小节、`-` 条目、行内 **粗体** 与 `代码`),遇到别的一律
// 原样当段落输出,不抛错。
const escapeHtml = (s) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

const inline = (s) =>
escapeHtml(s)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');

function renderChangelog(md) {
const out = [];
let list = null;
let inRelease = false;
let inSection = false;

const closeList = () => {
if (!list) return;
out.push(`<ul>${list.join('')}</ul>`);
list = null;
};
const closeSection = () => {
if (!inSection) return;
out.push(' </div>');
inSection = false;
};
const closeRelease = () => {
closeSection();
if (!inRelease) return;
out.push('</section>');
inRelease = false;
};

for (const raw of md.split(/\r?\n/)) {
const line = raw.trimEnd();

// 文件顶部那个 `# Changelog`,页面自己有 h1,跳过
if (/^#\s+/.test(line)) continue;

const head = /^##\s+(.+)$/.exec(line);
if (head) {
closeList();
closeRelease();
// "v1.6.1 (2026-10-09)" → 版本与日期分开排,日期用等宽数字
const m = /^(v?[\d.]+)\s*\((\d{4}-\d{2}-\d{2})\)$/.exec(head[1].trim());
const version = m ? m[1] : head[1].trim();
const date = m ? m[2] : '';
out.push(
'<section class="release">',
' <div class="release__head">',
` <h2 class="release__version">${escapeHtml(version)}</h2>`,
...(date ? [` <span class="release__date">${date}</span>`] : []),
' </div>'
);
inRelease = true;
continue;
}

const kind = /^###\s+(.+)$/.exec(line);
if (kind) {
closeList();
closeSection();
out.push(
' <div class="release__section">',
` <h3 class="release__kind">${escapeHtml(kind[1].trim())}</h3>`
);
inSection = true;
continue;
}

const item = /^\s*[-*]\s+(.+)$/.exec(line);
if (item) {
list = list || [];
list.push(`<li>${inline(item[1])}</li>`);
continue;
}

if (!line.trim()) {
closeList();
continue;
}

// 兜底:认不出的行原样当段落,不猜也不抛错
closeList();
out.push(` <p>${inline(line)}</p>`);
}

closeList();
closeRelease();
return out.join('\n');
}

function pageShell({ title, description, path, bodyAttrs, chrome, content }) {
const url = SITE_URL + path;
return `<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
<meta name="description" content="${description}" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<meta name="theme-color" content="#0d1117" />
<link rel="canonical" href="${url}" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="favicon-180.png" />

<meta property="og:type" content="website" />
<meta property="og:site_name" content="QuantDesktop" />
<meta property="og:locale" content="zh_CN" />
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${description}" />
<meta property="og:url" content="${url}" />
<meta property="og:image" content="${SITE_URL}og-cover.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="QuantDesktop —— 免费的 A 股桌面看盘工具,安装包不到 5 MB" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${title}" />
<meta name="twitter:description" content="${description}" />
<meta name="twitter:image" content="${SITE_URL}og-cover.png" />

<link rel="stylesheet" href="styles.css" />
<script>
// 与首页同一段:首帧前定好主题,否则深色访问者会先看到一帧白闪。
(function () {
var root = document.documentElement;
root.classList.add('js');
var saved = null;
try { saved = localStorage.getItem('qd-theme'); } catch (e) { /* private mode */ }
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
root.setAttribute('data-theme', saved || (prefersLight ? 'light' : 'dark'));
})();
</script>
</head>

<body ${bodyAttrs}>

${chrome.nav}

<main id="top">
${content}
</main>

${chrome.footer}

${chrome.totop}

<script src="app.js" defer></script>
</body>
</html>
`;
}

async function writeChangelog(chrome) {
const md = await readFile(resolve(root, 'CHANGELOG.md'), 'utf8');
const releases = renderChangelog(md);

const content = ` <header class="page-head container">
<h1 class="page-head__title">更新日志</h1>
<p class="page-head__lede">
每个版本改了什么。完整历史也在
<a href="https://github.com/Leaderxin/quant-desktop/blob/master/CHANGELOG.md">GitHub</a> 上。
</p>
</header>

<div class="container" style="padding-bottom: clamp(56px, 7vw, 88px)">
<div class="prose" style="max-width: 760px">
${releases}
</div>
</div>
`;

await writeFile(
resolve(siteDir, 'changelog.html'),
pageShell({
title: 'QuantDesktop 更新日志 —— 每个版本改了什么',
description:
'QuantDesktop 各版本的更新记录:新增功能、修复的问题与行为变更。全部版本均免费,可从 GitHub Releases 或 Microsoft Store 升级。',
path: 'changelog.html',
bodyAttrs: 'data-page="changelog"',
chrome,
content,
})
);
}

async function main() {
const missing = [];
const copied = await assemble(missing);
Expand All @@ -186,6 +469,14 @@ async function main() {
process.exit(1);
}

// 生成的页面要先落盘,后面的校验与 sitemap 才会把它算进去。
const chrome = {
nav: await readChrome('nav'),
footer: await readChrome('footer'),
totop: await readChrome('totop'),
};
await writeChangelog(chrome);

const pages = await publishedPages();

const broken = await verifyReferences(pages);
Expand All @@ -195,10 +486,18 @@ async function main() {
process.exit(1);
}

const { problems, checked } = await verifyChrome(pages);
if (problems.length > 0) {
console.error('[site] chrome blocks differ between pages:');
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}

const listed = await writeSitemap(pages);

console.log(`[site] Copied ${copied} files into website/assets/`);
console.log(`[site] ${pages.length} page(s): every local reference resolves.`);
console.log(`[site] ${checked} chrome blocks match across pages.`);
console.log(`[site] sitemap.xml lists ${listed.length} URL(s).`);
console.log('[site] Open website/index.html in a browser to preview.');
}
Expand Down
Loading
Loading