diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index a2c50a84..9779380e 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -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 diff --git a/website/README.md b/website/README.md index 2eac2769..89a2e582 100644 --- a/website/README.md +++ b/website/README.md @@ -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 提交生成,源码中未提交的本机文件不会进入产物。 diff --git a/website/builder/build.py b/website/builder/build.py index 40c3d41e..7d2268f6 100644 --- a/website/builder/build.py +++ b/website/builder/build.py @@ -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'(]*?\bsrc=["\'])([^"\']+)(["\'])', re.I) MD_IMAGE = re.compile(r'(!\[[^\]]*\]\()((?:[^()\s]|\([^()\n]*\))*)(\))') MD_LINK = re.compile(r'(?' 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 = ('按计划学习' + '先用面试真题自评定级,跳过已经掌握的内容,按天学、按间隔复习,记到面试那天' + '开始') + 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) diff --git a/website/builder/check.py b/website/builder/check.py index 12246beb..b1cfc45d 100644 --- a/website/builder/check.py +++ b/website/builder/check.py @@ -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: diff --git a/website/builder/site_assets.py b/website/builder/site_assets.py index 17ed5a05..e0ca012d 100644 --- a/website/builder/site_assets.py +++ b/website/builder/site_assets.py @@ -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(): diff --git a/website/deploy/deploy.mjs b/website/deploy/deploy.mjs index 6eb6b282..370e4388 100644 --- a/website/deploy/deploy.mjs +++ b/website/deploy/deploy.mjs @@ -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); diff --git a/website/deploy/deploy.test.mjs b/website/deploy/deploy.test.mjs index 6f35555d..2bed8dcd 100644 --- a/website/deploy/deploy.test.mjs +++ b/website/deploy/deploy.test.mjs @@ -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/); +}); diff --git a/website/templates/site.css b/website/templates/site.css index 133518da..0b2b0b63 100644 --- a/website/templates/site.css +++ b/website/templates/site.css @@ -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; } diff --git a/website/templates/site.js b/website/templates/site.js index d639c7fb..c4c14f11 100644 --- a/website/templates/site.js +++ b/website/templates/site.js @@ -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 = '在学习站中按这条路线学先做一轮自评定级,跳过已经掌握的天,按每天的时间和面试日期排出计划,自评过的题按间隔复习。'; + 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; });