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
27 changes: 26 additions & 1 deletion .github/workflows/website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,24 @@ jobs:
website:
runs-on: ubuntu-24.04
timeout-minutes: 25
env:
# 学习站的代码在私有仓库,用只读的部署密钥检出;别人提的 PR 拿不到密钥,只构建笔记部分
HAS_LEARN: ${{ secrets.LEARN_DEPLOY_KEY != '' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
ref: ${{ github.event_name == 'pull_request' && github.ref || 'master' }}
persist-credentials: false
- name: 检出学习站
if: env.HAS_LEARN == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: CyC2018/CS-Notes-learn
path: learn
fetch-depth: 1
ssh-key: ${{ secrets.LEARN_DEPLOY_KEY }}
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.12'
Expand All @@ -60,11 +72,24 @@ jobs:
python website/deploy/test_package_site.py
node website/builder/test_search.cjs
npm test --prefix website/deploy
- name: 检查学习站
if: hashFiles('learn/build.py') != ''
run: |
node learn/tests/core.test.cjs website/dist/learn/data.json
python learn/tests/test_content.py
- name: 检查学习站的内容对照
# 只提醒不阻塞:路线改了但没有运行 learn/maintain.py --write 时,读者的记录仍然保留,只是更新记录没有日期
if: hashFiles('learn/build.py') != ''
continue-on-error: true
run: python learn/maintain.py
- name: 发布网页和图片并校验正式域名
if: github.repository == 'CyC2018/CS-Notes' && github.ref == 'refs/heads/master' && github.event_name != 'pull_request'
env:
EDGEONE_API_TOKEN: ${{ secrets.EDGEONE_API_TOKEN }}
run: node website/deploy/deploy.mjs
run: |
# 发布会覆盖整站:没有带上学习站时停止,避免 /learn/ 被撤下
test -f website/dist/learn/index.html || { echo '::error::没有检出学习站(检查 LEARN_DEPLOY_KEY),停止发布'; exit 1; }
node website/deploy/deploy.mjs
- name: 保留小型版本报告
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
Expand Down
2 changes: 2 additions & 0 deletions website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ verify_updates.py 在临时副本中修改文章、替换配图、新增文章

页面模板保留手机分类导航、折叠目录、搜索和旧文章入口兼容;固定版本的浏览器依赖及许可证保存在 templates/vendor 中。

学习站(定级、每日计划、间隔复习)的代码不在本仓库,网站工作流从私有仓库检出到 learn/;learn/ 存在时,build.py 调用 learn/build.py 生成到 dist/learn/,随网站一起发布,并在首页、侧边栏和三条学习路线的文章开头加入口;不存在时只构建笔记,没有入口。

## 自动更新与发布

`.github/workflows/website.yml` 在 master 提交后、北京时间每天 09:17 和 21:17,以及手动 Run workflow 时运行。PR 仅验证合并后的源码,不读取部署凭据。CI 使用 `refresh.py --from-checkout` 从已检出的 Git 提交生成,源码中未提交的本机文件不会进入产物。
Expand Down
34 changes: 30 additions & 4 deletions website/builder/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
import hashlib
import html
import json
import os
import posixpath
import re
import shutil
import subprocess
import sys
import tempfile
import unicodedata
import urllib.parse
Expand All @@ -28,6 +31,9 @@
IMAGE_BY_NAME = {}
ANCHORS = {}
SOURCE_IMAGE_HOST = 'cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com'
# 学习站与笔记网站一起构建,放在 /learn/ 下;题目链接指向同一域名下的笔记
LEARN_BUILD = BASE.parent / 'learn' / 'build.py'
SITE_URL = 'https://cyc2018.xyz/'
IMAGE = re.compile(r'(<img\b[^>]*?\bsrc=["\'])([^"\']+)(["\'])', re.I)
MD_IMAGE = re.compile(r'(!\[[^\]]*\]\()((?:[^()\s]|\([^()\n]*\))*)(\))')
MD_LINK = re.compile(r'(?<!!)\[((?:[^\[\]\n]|\[[^\[\]\n]*\])+)\]\(([^\s)]+)\)')
Expand Down Expand Up @@ -205,9 +211,14 @@ def build(optimize=True):
for label, symbol in topics) + '</nav>'
homepage = re.sub(r'^\| (?:学习路线|算法)[^\n]*\n\|[^\n]*\n\|[^\n]*', cards, homepage, count=1, flags=re.M)
homepage = homepage.replace('width="200px"', 'class="site-logo" width="200px"', 1)
if LEARN_BUILD.is_file():
banner = ('<a class="learn-banner" href="/learn/"><span class="learn-banner-text"><b>按计划学习</b>'
'<span>先用面试真题自评定级,跳过已经掌握的内容,按天学、按间隔复习,记到面试那天</span></span>'
'<span class="learn-banner-go">开始</span></a>')
homepage = homepage.replace(cards, banner + cards, 1)
(dist / 'README.md').write_text('# CS-Notes\n\n' + homepage)
note_paths = ['/' + str(p.relative_to(ROOT).with_suffix('')) for p in sorted((ROOT / 'notes').glob('*.md'))]
sidebar = ['- [首页](/)', '']
sidebar = ['- [首页](/)', *(["- [学习站:定级与每日计划](/learn/ ':ignore')"] if LEARN_BUILD.is_file() else []), '']
for line in homepage.splitlines():
if line.startswith('## '):
label = re.sub(r':[a-z0-9_]+:', '', line[3:]).strip()
Expand All @@ -220,6 +231,20 @@ def build(optimize=True):
(dist / '_404.md').write_text('# 页面暂未找到\n\n请从目录或搜索查找笔记。\n\n[返回首页](/)\n')
(dist / '.nojekyll').touch()
shutil.copyfile(ROOT / 'docs/_media/LogoMakr_1J56bI.png', dist / 'favicon.png')
learn_report = None
if LEARN_BUILD.is_file():
# 学习站的问题(链接到的小节改名、题目和要点对不上)只给警告,不挡住笔记的发布;警告写进构建报告
result = subprocess.run([sys.executable, str(LEARN_BUILD), '--source', str(ROOT), '--output', str(dist / 'learn'),
'--notes-base', '/#/notes/', '--site-url', SITE_URL + 'learn/', '--lenient'],
check=True, capture_output=True, text=True)
learn_report = json.loads(result.stdout.strip().splitlines()[-1])
# 在 GitHub Actions 中输出为警告注释,显示在运行结果的摘要里
prefix = '::warning::' if os.environ.get('GITHUB_ACTIONS') else ''
for warning in learn_report['warnings']:
print(prefix + '学习站:' + warning, file=sys.stderr)
if learn_report['pending']:
print(prefix + '学习站:内容与 learn/content.lock.json 不一致,请在本机运行 python3 learn/maintain.py 查看并写入:'
+ json.dumps(learn_report['pending'], ensure_ascii=False), file=sys.stderr)
copy_referenced_assets(dist, ROOT)
image_report = optimize_images(dist, CACHE) if optimize else {'converted': 0}
index_name, search_report = write_search_index(dist, sorted((dist / 'notes').glob('*.md')))
Expand All @@ -231,9 +256,10 @@ def build(optimize=True):
'window.CS_NOTES_SEARCH_INDEX = "/' + index_name + '";\n'
'window.CS_NOTES_ANIMATIONS = ' + json.dumps(animations, ensure_ascii=False, separators=(',', ':')) + ';\n')
generator = hashlib.sha256()
for resource in sorted([*Path(__file__).parent.glob('*.py'), *SITE.rglob('*')]):
learn = [*LEARN_BUILD.parent.glob('*.py'), *(LEARN_BUILD.parent / 'src').rglob('*')] if LEARN_BUILD.is_file() else []
for resource in sorted([*Path(__file__).parent.glob('*.py'), *SITE.rglob('*'), *learn]):
if resource.is_file():
generator.update(resource.relative_to(BASE).as_posix().encode())
generator.update(resource.relative_to(BASE.parent).as_posix().encode())
generator.update(resource.read_bytes())
build_info = {'sourceCommit': SOURCE_COMMIT, 'generatorHash': generator.hexdigest(), 'notes': len(note_paths),
'searchIndex': index_name, 'legacyRoutes': len(legacy)}
Expand All @@ -247,7 +273,7 @@ def build(optimize=True):
files = [p for p in dist.rglob('*') if p.is_file()]
report = {'notes': len(note_paths), 'files': len(files), 'bytes': sum(p.stat().st_size for p in files),
'oversized': [str(p.relative_to(dist)) for p in files if p.stat().st_size > 25*1024*1024],
'repositoryImagesUsedForOldUrls': len(reused), 'externalImages': len(unchanged_external), 'search': search_report, 'images': image_report, 'legacyRoutes': len(legacy)}
'learn': learn_report, 'repositoryImagesUsedForOldUrls': len(reused), 'externalImages': len(unchanged_external), 'search': search_report, 'images': image_report, 'legacyRoutes': len(legacy)}
if report['oversized'] or len(files) > 20000:
raise ValueError('Static hosting quota exceeded')
validation = check(ROOT, dist)
Expand Down
2 changes: 1 addition & 1 deletion website/builder/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def check(source, output):
if not name:
continue
references += 1
if name not in files and name + '.md' not in files:
if name not in files and name + '.md' not in files and name.rstrip('/') + '/index.html' not in files:
errors.append('缺失引用:' + name)
for name in re.findall(r'(?:src|href)="(/[^"#]+)"', (output / 'index.html').read_text()):
if name.split('?')[0].lstrip('/') not in files:
Expand Down
3 changes: 2 additions & 1 deletion website/builder/site_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ def copy_referenced_assets(dist, root):
references.update(referenced_paths(doc.read_text()))
for relative in sorted(references):
destination = dist / relative
if destination.is_file() or Path(str(destination) + '.md').is_file() or destination.suffix == '.md':
# 目录链接(例如 /learn/)由目录下的 index.html 提供
if destination.is_file() or (destination / 'index.html').is_file() or Path(str(destination) + '.md').is_file() or destination.suffix == '.md':
continue
source = root / relative
if not source.resolve().is_relative_to(root.resolve()) or not source.is_file():
Expand Down
13 changes: 13 additions & 0 deletions website/deploy/deploy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ function verificationPaths(manifest) {
for (const path of paths) {
if (!records.has(path)) throw new Error(`构建缺少关键文件:${path}`);
}
// 学习站(/learn/):入口、数据和离线缓存脚本都要是这次构建的版本
if (records.has('learn/index.html')) {
for (const path of ['learn/index.html', 'learn/data.json', 'learn/app.js', 'learn/sw.js']) {
if (!records.has(path)) throw new Error(`构建缺少关键文件:${path}`);
paths.push(path);
}
}
return [...new Set(paths)];
}

Expand All @@ -62,6 +69,12 @@ export async function verifyPublished(manifestBytes, { fetcher = fetch, origin =
if (sha256(remote) !== revision) throw new Error('正式域名的版本清单尚未与构建一致。');
const records = new Map(manifest.files.map(file => [file.path, file]));
// Check actual bytes as well as the manifest; an SPA fallback can also return HTTP 200.
// 目录地址 /learn/ 要返回学习站的入口页,而不是网站首页或 404
if (records.has('learn/index.html')) {
const entry = records.get('learn/index.html');
const bytes = await download(origin, 'learn/', revision, fetcher);
if (sha256(bytes) !== entry.sha256) throw new Error('正式域名的 /learn/ 没有返回学习站入口页');
}
await Promise.all(paths.map(async path => {
const record = records.get(path);
const bytes = await download(origin, path, revision, fetcher);
Expand Down
20 changes: 18 additions & 2 deletions website/deploy/deploy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ async function saveManifest(directory, files, sourceCommit = 'source') {
}

async function fixture(t, { notes = ['notes/MySQL.md', 'notes/大模型应用.md',
'notes/Java 基础.md', 'notes/66. 构建乘积数组.md'], images = true } = {}) {
'notes/Java 基础.md', 'notes/66. 构建乘积数组.md'], images = true, learn = false } = {}) {
const root = await mkdtemp(join(tmpdir(), 'cs-notes-deploy-'));
const directory = join(root, 'dist');
t.after(() => rm(root, { recursive: true, force: true }));
const files = new Map(['index.html', 'build-info.json', 'README.md', 'site.css', 'site.js',
'search.js', 'search-paths.js', 'search-index-example.json', 'image-variants.json', ...notes,
...(images ? ['notes/pics/example.png', 'optimized/example.webp'] : [])]
...(images ? ['notes/pics/example.png', 'optimized/example.webp'] : []),
...(learn ? ['learn/index.html', 'learn/data.json', 'learn/app.js', 'learn/sw.js'] : [])]
.map(path => [path, Buffer.from(`validated contents: ${path}`)]));
for (const [path, data] of files) {
await mkdir(resolve(directory, path, '..'), { recursive: true });
Expand Down Expand Up @@ -185,3 +186,18 @@ test('已有旧发布包时,上传的 ZIP 必须包含当前清单', async t =
});
assert.equal(report.result, 'deployed');
});

test('学习站:校验入口、数据和离线缓存脚本,目录地址 /learn/ 要返回入口页', async t => {
const { directory, files } = await fixture(t, { learn: true });
const serve = (learnDirectory) => async url => {
const path = decodeURIComponent(url.pathname.slice(1));
if (path === 'learn/') return new Response(learnDirectory);
return new Response(files.get(path));
};
const report = await deploySite({ directory, attempts: 1, getClient: () => assert.fail('无变化时不应读取发布凭据'),
fetcher: serve(files.get('learn/index.html')) });
assert.equal(report.verifiedFiles, 19);
// 托管服务把 /learn/ 回退成网站首页时,校验要失败
await assert.rejects(deploySite({ directory, attempts: 1, sleep: async () => {}, getClient: () => assert.fail('无变化时不应读取发布凭据'),
fetcher: serve(files.get('index.html')) }), /learn/);
});
7 changes: 7 additions & 0 deletions website/templates/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ body { color: #34495e; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI
.article-toc { border: 1px solid #dfe3da; border-radius: 7px; padding: 12px 18px; margin: 20px 0 28px; background: #fafbf8; }
.article-toc summary { color: #369b72; font-weight: 600; cursor: pointer; min-height: 24px; }
.article-toc > ul { margin-bottom: 0; }
.learn-banner, .learn-callout { display: flex; align-items: center; gap: 16px; margin: 18px 0; padding: 16px 18px; border: 1px solid #e3e6e4; border-left: 4px solid var(--theme-color); border-radius: 10px; background: #fafbfa; color: inherit; text-decoration: none !important; }
.learn-banner:hover, .learn-banner:focus-visible, .learn-callout:hover, .learn-callout:focus-visible { border-color: var(--theme-color); background: #eef7f2; }
.learn-banner-text { flex: 1; min-width: 0; }
.learn-banner b, .learn-callout b { display: block; color: var(--theme-color); font-size: 16px; }
.learn-banner-text span, .learn-callout span { display: block; margin-top: 2px; font-size: 14px; line-height: 1.6; opacity: .85; }
.learn-banner-go { flex: none; padding: 7px 18px; border-radius: 8px; background: var(--theme-color); color: #fff; font-weight: 600; font-size: 15px; }
.learn-callout { display: block; }
@media (min-width: 769px) { .content { padding-top: 12px; } .article-toc { display: none; } }
.page-toc-block { margin: 0 0 18px; padding: 0 0 14px 18px; border-bottom: 1px solid #e2e3dc; }
.page-toc-title { margin: 0 0 6px; font-size: 14px; font-weight: 600; color: #34495e; }
Expand Down
13 changes: 13 additions & 0 deletions website/templates/site.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,18 @@
}
preserveLegacyLinks();
window.addEventListener('hashchange', preserveLegacyLinks);
// 学习路线的文章开头提示可以在学习站中按这条路线定级、排计划
var LEARN_ROUTES = { 'Java 后端': 'java', '大模型应用开发': 'llm', '面试冲刺': 'sprint' };
function learnCallout(vm, section) {
var match = decode(vm.route.path).match(/^\/notes\/学习路线 - (.+)$/), id = match && LEARN_ROUTES[match[1]];
if (!section || !id || section.querySelector('.learn-callout')) return;
var box = document.createElement('a');
box.className = 'learn-callout';
box.href = '/learn/#/start/' + id;
box.innerHTML = '<b>在学习站中按这条路线学</b><span>先做一轮自评定级,跳过已经掌握的天,按每天的时间和面试日期排出计划,自评过的题按间隔复习。</span>';
var heading = section.querySelector('h1');
(heading || section.firstChild).insertAdjacentElement(heading ? 'afterend' : 'beforebegin', box);
}
window.$docsify = {
name: 'CS-Notes',
repo: 'https://github.com/CyC2018/CS-Notes',
Expand Down Expand Up @@ -226,6 +238,7 @@
configureImages(document.querySelector('.markdown-section'), vm.route.path === '/');
stepAnimations(document.querySelector('.markdown-section'));
articleToc(vm);
learnCallout(vm, document.querySelector('.markdown-section'));
document.querySelectorAll('.article-toc').forEach(function (toc) {
toc.open = window.matchMedia('(min-width: 769px)').matches;
});
Expand Down