mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-11-01 19:53:43 +00:00
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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 提交生成,源码中未提交的本机文件不会进入产物。
|
||||
|
||||
@@ -6,9 +6,12 @@ import argparse
|
||||
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
|
||||
@@ -28,6 +31,9 @@ SOURCE_COMMIT = json.loads(VERSION_FILE.read_text())['commit'] if VERSION_FILE.i
|
||||
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)]+)\)')
|
||||
@@ -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()
|
||||
@@ -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')))
|
||||
@@ -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)}
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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',
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user