diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 989657d7..a2c50a84 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -26,35 +26,34 @@ concurrency: jobs: website: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 25 steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 ref: ${{ github.event_name == 'pull_request' && github.ref || 'master' }} persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: '3.12' cache: pip cache-dependency-path: website/requirements.txt - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '22' + package-manager-cache: false - name: 安装固定版本依赖 run: | python -m pip install -r website/requirements.txt npm ci --prefix website/deploy --ignore-scripts - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: website/.cache/site-assets - key: site-webp-v1-${{ runner.os }}-${{ hashFiles('website/requirements.txt', 'website/builder/site_assets.py', 'notes/pics/**', 'assets/**') }} - restore-keys: site-webp-v1-${{ runner.os }}- + key: site-webp-${{ runner.os }}-${{ hashFiles('website/requirements.txt') }}-${{ hashFiles('website/builder/site_assets.py', 'notes/pics/**', 'assets/**', 'docs/_media/**') }} + restore-keys: site-webp-${{ runner.os }}-${{ hashFiles('website/requirements.txt') }}- - name: 生成并校验网站与配图 - run: | - python website/refresh.py --from-checkout - python website/deploy/package_site.py + run: python website/refresh.py --from-checkout - name: 检查搜索和发布保护 run: | python website/builder/test_site_assets.py @@ -68,7 +67,7 @@ jobs: run: node website/deploy/deploy.mjs - name: 保留小型版本报告 if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: website-report-${{ github.run_id }}-${{ github.run_attempt }} path: | diff --git a/website/README.md b/website/README.md index 774bfb81..2eac2769 100644 --- a/website/README.md +++ b/website/README.md @@ -16,13 +16,13 @@ python3 website/builder/serve.py refresh.py 使用独立浅层 Git 缓存读取最新 master,并生成源码快照。缓存的推送地址已停用。临时目录内完成生成和校验后才替换 website/dist;缺图或站内链接校验失败时保留上一版产物。 -源码缓存、upstream、dist、图片优化缓存、版本记录和验证报告均已忽略,不加入提交。 +源码缓存、upstream、dist、图片优化缓存、版本记录和验证报告均已忽略,不加入提交。图片优化缓存按 Pillow 版本隔离,云端恢复缓存时也要求依赖版本匹配。 ## 图片来源与发布 旧 COS 图片 URL 如果能在当前源码中唯一匹配原文件名,构建时就改用对应仓库文件;同名不唯一或没有匹配时保留原外链。当前内容的 246 个旧图床配图均能匹配,不复制另一套图片到源码中。 -生成时只收集页面实际引用的文件。无损 WebP 经逐像素校验,只有能节省至少 10% 才使用;原图仍保留在生成目录,仓库原文件不改动。搜索索引在构建时生成,访客首次搜索只加载一个索引文件。 +生成时只收集页面实际引用的文件。无损 WebP 经逐像素校验,只有能节省至少 10% 才使用;原图仍保留在生成目录,仓库原文件不改动。首页标识图片优先加载,其余正文图片默认延迟加载,保留作者显式指定的 loading 属性。搜索索引在构建时生成,访客首次搜索只加载一个索引文件;标准化文本缓存供后续搜索复用,不改变原索引。 发布时应把整个 website/dist(或内容位于 ZIP 根目录的压缩包)上传到静态托管,网页和图片一起上传。访客从网站域名读取图片,无需另建 COS/TOS 图床或替换一套对象存储链接。 @@ -47,9 +47,11 @@ verify_updates.py 在临时副本中修改文章、替换配图、新增文章 `.github/workflows/website.yml` 在 master 提交后、北京时间每天 09:17 和 21:17,以及手动 Run workflow 时运行。PR 仅验证合并后的源码,不读取部署凭据。CI 使用 `refresh.py --from-checkout` 从已检出的 Git 提交生成,源码中未提交的本机文件不会进入产物。 -通过图片、引用和搜索检查后,使用 Python 标准库生成可重复的 UTF-8 发布包,再由腾讯云官方 Makers SDK 原样上传到现有 cs-notes 项目,包含整个 website/dist 的网页和图片。固定项目 ID 和中国站,上传前核对项目名称。清单与正式站一致时检查正式文件并跳过重复发布;有变化时等待腾讯云部署 Success,再核对正式域名的版本清单及 15 个关键页面/资源的实际 SHA-256,覆盖中文、空格文件名、图片清单、原图和优化配图。 +通过图片、引用和搜索检查后,发布脚本先校验本地检查清单,再比较正式站版本。无变化时核对正式文件,跳过打包、凭据读取和上传。有变化时从当前产物重新生成可重复的 UTF-8 发布包,逐文件检查字节和哈希,再由腾讯云官方 Makers SDK 原样上传到现有 cs-notes 项目,避免误用旧 ZIP。固定项目 ID 和中国站,上传前核对项目名称。 -发布包由 `python3 website/deploy/package_site.py` 在构建后生成到忽略的 `.cache/site-release.zip`,仅包含已校验清单中的文件;打包阶段不传入令牌。SDK 的目录打包在当前平台解包后会将中文名转成 `#U…`,因此使用 SDK 的 archive 参数上传标准 ZIP,保留原有中文网址。 +等待腾讯云部署 Success 后,再校验正式域名的清单及关键页面/资源的实际 SHA-256。文章样本按当前清单选取,覆盖 ASCII、中文和空格;配图存在时检查原图和优化图片。不绑定特定文章名称,也不要求不存在的优化图片。首页等必要文件在本地缺失时即失败,先于凭据读取和上传。 + +自动发布在确认有变化后调用 `package_site.py`,生成忽略的 `.cache/site-release.zip`;打包子进程不接收发布令牌。需要手工生成 ZIP 时也可在构建后运行 `python3 website/deploy/package_site.py`,内容仅含已校验清单中的文件。SDK 的目录打包在当前平台解包后会将中文名转成 `#U…`,因此使用 SDK 的 archive 参数上传标准 ZIP,保留原有中文网址。 本机检查发布保护(不需要令牌、不会上传): diff --git a/website/builder/site_assets.py b/website/builder/site_assets.py index c6239120..b68af64c 100644 --- a/website/builder/site_assets.py +++ b/website/builder/site_assets.py @@ -109,7 +109,8 @@ def copy_referenced_assets(dist, root): def optimize_images(dist, cache): """Add pixel-identical WebP alternatives; retain each referenced original URL.""" - from PIL import Image + from PIL import Image, __version__ as pillow_version + cache = cache / pillow_version cache.mkdir(parents=True, exist_ok=True) candidates = [p for p in dist.rglob('*') if p.suffix.lower() in {'.png', '.jpg', '.jpeg'} and p.stat().st_size >= 4096] diff --git a/website/builder/test_search.cjs b/website/builder/test_search.cjs index d1bee82a..c8e6ee85 100644 --- a/website/builder/test_search.cjs +++ b/website/builder/test_search.cjs @@ -9,6 +9,18 @@ assert(search(index, '拥塞控制').some(r => r.path.includes('计算机网络 assert(search(index, '检索增强生成').some(r => r.path === '/notes/大模型应用' && r.id === '三、检索增强生成')); assert(search(index, 'Token 分词').some(r => r.path === '/notes/大模型应用' && r.id === 'token-与分词')); assert.equal(search(index, '').length, 0); +const sample = { documents: [{ title: '数据库', path: '/notes/示例', sections: [ + { title: '事务', id: '事务', text: '一致性 '.repeat(20) }, + { title: '一致性', id: '一致性', text: 'SQL 与数据库' }, +] }] }; +const serialized = JSON.stringify(sample); +assert.equal(search(sample, '一致性')[0].id, '一致性', '章节标题应优先于正文中重复的关键词'); +assert.equal(search(sample, 'SQL')[0].id, '一致性', '全角输入仍能搜索'); +assert.equal(JSON.stringify(sample), serialized, '搜索缓存不能改动原索引'); +const nextIndex = JSON.parse(serialized); +nextIndex.documents[0].sections.push({ title: '新增内容', id: '新章节', text: '新笔记检索词' }); +assert.equal(search(nextIndex, '新笔记检索词')[0].id, '新章节', '新索引应使用新内容'); +assert.equal(search(sample, '新笔记检索词').length, 0, '不同索引的缓存不能串用'); if (process.argv.includes('--updated')) { assert(search(index, '本地同步验证示例词').some(r => r.id === '本地验证更新')); } diff --git a/website/deploy/deploy.mjs b/website/deploy/deploy.mjs index f4b79148..6eb6b282 100644 --- a/website/deploy/deploy.mjs +++ b/website/deploy/deploy.mjs @@ -1,9 +1,12 @@ // Publish only validated dist/ to the existing Tencent Cloud project. import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; import { readFile, mkdir, writeFile, appendFile } from 'node:fs/promises'; import { resolve } from 'node:path'; +import { promisify } from 'node:util'; import { fileURLToPath, pathToFileURL } from 'node:url'; const WEBSITE = fileURLToPath(new URL('../', import.meta.url)); +const executeFile = promisify(execFile); export const PROJECT = { projectId: 'makers-s7dxi8u5vidy', name: 'cs-notes' }; export const ORIGIN = 'https://cyc2018.xyz'; @@ -17,24 +20,50 @@ async function download(origin, path, revision, fetcher) { return Buffer.from(await response.arrayBuffer()); } +function verificationPaths(manifest) { + const paths = ['index.html', 'build-info.json', 'README.md', 'site.css', 'site.js', + 'search.js', 'search-paths.js', manifest.searchIndex, 'image-variants.json']; + const records = new Map(manifest.files.map(file => [file.path, file])); + const notes = [...records.keys()].filter(path => path.startsWith('notes/') && path.endsWith('.md')).sort(); + // Exercise ASCII, Unicode and spaces without depending on specific article titles. + for (const predicate of [path => /^[\x00-\x7F]+$/.test(path), + path => /[^\x00-\x7F]/.test(path) && !path.includes(' '), path => path.includes(' ')]) { + const note = notes.find(predicate); + if (note) paths.push(note); + } + for (const prefix of ['notes/pics/', 'optimized/']) { + const image = manifest.files.find(file => file.path.startsWith(prefix)); + if (image) paths.push(image.path); + } + for (const path of paths) { + if (!records.has(path)) throw new Error(`构建缺少关键文件:${path}`); + } + return [...new Set(paths)]; +} + +async function packageRelease(directory, archive) { + const environment = { ...process.env }; + delete environment.EDGEONE_API_TOKEN; + try { + await executeFile('python3', [resolve(WEBSITE, 'deploy/package_site.py'), + '--source', resolve(directory), '--output', resolve(archive)], { env: environment }); + } catch (error) { + // The packaging subprocess receives no publishing token; don't log its command or environment. + const reason = error.stderr?.trim().split('\n').at(-1) || '请重新生成并校验网站。'; + throw new Error(`发布包生成失败,未上传:${reason}`); + } +} + export async function verifyPublished(manifestBytes, { fetcher = fetch, origin = ORIGIN } = {}) { const manifest = JSON.parse(manifestBytes); + const paths = verificationPaths(manifest); const revision = sha256(manifestBytes); const remote = await download(origin, 'asset-manifest.json', revision, fetcher); if (sha256(remote) !== revision) throw new Error('正式域名的版本清单尚未与构建一致。'); - const paths = ['index.html', 'build-info.json', 'README.md', 'site.css', 'site.js', - 'search.js', 'search-paths.js', manifest.searchIndex, 'image-variants.json', 'notes/MySQL.md', - 'notes/大模型应用.md', 'notes/Java 基础.md', 'notes/66. 构建乘积数组.md']; const records = new Map(manifest.files.map(file => [file.path, file])); - for (const prefix of ['notes/pics/', 'optimized/']) { - const image = manifest.files.find(file => file.path.startsWith(prefix)); - if (!image) throw new Error(`构建缺少配图:${prefix}`); - paths.push(image.path); - } // Check actual bytes as well as the manifest; an SPA fallback can also return HTTP 200. await Promise.all(paths.map(async path => { const record = records.get(path); - if (!record) throw new Error(`构建缺少关键文件:${path}`); const bytes = await download(origin, path, revision, fetcher); if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) { throw new Error(`正式域名文件校验不一致:${path}`); @@ -47,6 +76,7 @@ export async function deploySite({ directory, archive = resolve(directory, '../. sleep = ms => new Promise(done => setTimeout(done, ms)), attempts = 12 }) { const manifestBytes = await readFile(resolve(directory, 'asset-manifest.json')); const manifest = JSON.parse(manifestBytes); + verificationPaths(manifest); // Reject local defects before any credential or production mutation. let unchanged = false; try { const remote = await download(ORIGIN, 'asset-manifest.json', sha256(manifestBytes), fetcher); @@ -54,6 +84,8 @@ export async function deploySite({ directory, archive = resolve(directory, '../. } catch { /* A missing manifest should trigger a new deployment, never a false skip. */ } let deployment; if (!unchanged) { + // Always bind the upload to this validated dist; a cached ZIP may belong to an older build. + await packageRelease(directory, archive); const makers = await getClient(); const project = await makers.projects.get({ projectId: PROJECT.projectId }); if (project.projectId !== PROJECT.projectId || project.name !== PROJECT.name) { diff --git a/website/deploy/deploy.test.mjs b/website/deploy/deploy.test.mjs index 0e346d9d..6f35555d 100644 --- a/website/deploy/deploy.test.mjs +++ b/website/deploy/deploy.test.mjs @@ -1,25 +1,39 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; -import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { access, mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; import { deploySite, PROJECT } from './deploy.mjs'; +const executeFile = promisify(execFile); -async function fixture(t) { - const directory = await mkdtemp(join(tmpdir(), 'cs-notes-deploy-')); - t.after(() => rm(directory, { 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', 'notes/MySQL.md', - 'notes/pics/example.png', 'optimized/example.webp', 'image-variants.json', - 'notes/大模型应用.md', 'notes/Java 基础.md', 'notes/66. 构建乘积数组.md'] - .map(path => [path, Buffer.from(`validated contents: ${path}`)])); - const manifest = Buffer.from(JSON.stringify({ sourceCommit: 'source', generatorHash: 'generator', - searchIndex: 'search-index-example.json', files: [...files].map(([path, data]) => ({ +async function saveManifest(directory, files, sourceCommit = 'source') { + const manifest = Buffer.from(JSON.stringify({ sourceCommit, generatorHash: 'generator', + searchIndex: 'search-index-example.json', files: [...files].filter(([path]) => path !== 'asset-manifest.json').map(([path, data]) => ({ path, bytes: data.length, sha256: createHash('sha256').update(data).digest('hex'), })) })); files.set('asset-manifest.json', manifest); await writeFile(join(directory, 'asset-manifest.json'), manifest); + return manifest; +} + +async function fixture(t, { notes = ['notes/MySQL.md', 'notes/大模型应用.md', + 'notes/Java 基础.md', 'notes/66. 构建乘积数组.md'], images = true } = {}) { + 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'] : [])] + .map(path => [path, Buffer.from(`validated contents: ${path}`)])); + for (const [path, data] of files) { + await mkdir(resolve(directory, path, '..'), { recursive: true }); + await writeFile(join(directory, path), data); + } + const manifest = await saveManifest(directory, files); return { directory, files, manifest }; } @@ -35,10 +49,11 @@ test('每日检查:清单一致时不请求凭据、不重复发布,同时 }, }); assert.equal(report.result, 'unchanged'); - assert.equal(report.verifiedFiles, 16); + assert.equal(report.verifiedFiles, 15); assert.ok(requests.includes('notes/MySQL.md')); assert.ok(requests.includes('notes/pics/example.png')); assert.ok(requests.includes('optimized/example.webp')); + await assert.rejects(access(resolve(directory, '../.cache/site-release.zip')), { code: 'ENOENT' }); }); test('新版本只向固定项目发布 dist,等候 Success 后重试正式域名校验', async t => { @@ -113,3 +128,60 @@ test('中文或含空格文章返回首页时不能宣告发布成功', async t ? 'SPA fallback' : files.get(decodeURIComponent(url.pathname.slice(1)))), }), /文件校验不一致:notes\/大模型应用.md/); }); + +test('文章改名和没有优化配图时仍按清单核验当前有效内容', async t => { + const { directory, files } = await fixture(t, { notes: ['notes/new-note.md', 'notes/新笔记.md', 'notes/专题 新内容.md'], images: false }); + const requests = []; + const report = await deploySite({ directory, attempts: 1, + getClient: () => assert.fail('无变化时不读取凭据'), + fetcher: async url => { + const path = decodeURIComponent(url.pathname.slice(1)); + requests.push(path); + return new Response(files.get(path)); + }, + }); + assert.equal(report.result, 'unchanged'); + assert.ok(requests.includes('notes/新笔记.md')); + assert.ok(requests.includes('notes/专题 新内容.md')); +}); + +test('本地清单缺少关键页面时,在读取凭据和上传之前失败', async t => { + const { directory, files } = await fixture(t); + files.delete('index.html'); + await saveManifest(directory, files); + await assert.rejects(deploySite({ directory, attempts: 1, + fetcher: async () => new Response('{}'), + getClient: () => assert.fail('不完整的本地产物不能读取发布凭据'), + }), /构建缺少关键文件:index.html/); +}); + +test('文件与清单不一致时,在读取凭据和上传之前失败', async t => { + const { directory } = await fixture(t); + await writeFile(join(directory, 'README.md'), 'changed after validation'); + await assert.rejects(deploySite({ directory, attempts: 1, + fetcher: async () => new Response('{}'), + getClient: () => assert.fail('内容不一致时不能读取发布凭据'), + }), /打包前文件校验不一致/); +}); + +test('已有旧发布包时,上传的 ZIP 必须包含当前清单', async t => { + const { directory, files } = await fixture(t); + const archive = resolve(directory, '../.cache/site-release.zip'); + const script = fileURLToPath(new URL('./package_site.py', import.meta.url)); + await executeFile('python3', [script, '--source', directory, '--output', archive]); + const manifest = await saveManifest(directory, files, 'new-source'); + let published = false; + const report = await deploySite({ directory, attempts: 1, + fetcher: async url => new Response(published ? files.get(decodeURIComponent(url.pathname.slice(1))) : '{}'), + getClient: async () => ({ projects: { get: async () => PROJECT }, + deployments: { deploy: async input => { + const { stdout } = await executeFile('python3', ['-c', + 'import sys, zipfile; print(zipfile.ZipFile(sys.argv[1]).read("asset-manifest.json").decode())', input.artifact.archive]); + assert.deepEqual(JSON.parse(stdout), JSON.parse(manifest)); + published = true; + return { status: 'Success', deploymentId: 'new-release' }; + } }, + }), + }); + assert.equal(report.result, 'deployed'); +}); diff --git a/website/requirements.txt b/website/requirements.txt index 9673b412..c9a490a4 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -1 +1 @@ -Pillow==11.3.0 +Pillow==13.0.0 diff --git a/website/templates/search.js b/website/templates/search.js index bed8b03d..c4306c3e 100644 --- a/website/templates/search.js +++ b/website/templates/search.js @@ -5,26 +5,49 @@ return String(value).normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim(); } - function search(index, query, limit) { - var terms = normalize(query).slice(0, 120).split(' ').filter(Boolean); - if (!terms.length) return []; - var results = []; + var preparedIndexes = new WeakMap(); + function prepareIndex(index) { + var prepared = preparedIndexes.get(index); + if (prepared) return prepared; + prepared = []; index.documents.forEach(function (doc) { var page = normalize(doc.title); doc.sections.forEach(function (section) { var heading = normalize(section.title); var body = normalize(section.text); - var text = heading + ' ' + page + ' ' + body; - if (!terms.every(function (term) { return text.includes(term); })) return; - var score = terms.reduce(function (sum, term) { - return sum + (heading.includes(term) ? 40 : 0) + (page.includes(term) ? 15 : 0) + - (body.includes(term) ? Math.min(body.split(term).length - 1, 6) : 0); - }, 0); - if (heading === normalize(query)) score += 100; - results.push({ page: doc.title, path: doc.path, title: section.title || doc.title, - id: section.id, text: section.text, score: score }); + prepared.push({ page: page, heading: heading, body: body, text: heading + ' ' + page + ' ' + body, + pageTitle: doc.title, path: doc.path, section: section }); }); }); + preparedIndexes.set(index, prepared); + return prepared; + } + + function occurrences(text, term) { + var count = 0, offset = 0, found; + while (count < 6 && (found = text.indexOf(term, offset)) !== -1) { + count++; + offset = found + term.length; + } + return count; + } + + function search(index, query, limit) { + var normalizedQuery = normalize(query); + var terms = normalizedQuery.slice(0, 120).split(' ').filter(Boolean); + if (!terms.length) return []; + var results = []; + prepareIndex(index).forEach(function (entry) { + if (!terms.every(function (term) { return entry.text.includes(term); })) return; + var score = terms.reduce(function (sum, term) { + return sum + (entry.heading.includes(term) ? 40 : 0) + (entry.page.includes(term) ? 15 : 0) + + occurrences(entry.body, term); + }, 0); + if (entry.heading === normalizedQuery) score += 100; + var section = entry.section; + results.push({ page: entry.pageTitle, path: entry.path, title: section.title || entry.pageTitle, + id: section.id, text: section.text, score: score }); + }); return results.sort(function (a, b) { return b.score - a.score; }).slice(0, limit || 20); } @@ -41,6 +64,7 @@ return response.json(); }).then(function (index) { if (index.version !== 1 || !Array.isArray(index.documents)) throw new Error('Invalid search index'); + prepareIndex(index); return index; }).catch(function (error) { indexPromise = null; throw error; }); } @@ -99,6 +123,7 @@ input.addEventListener('focus', function () { loadIndex().catch(function () {}); }); input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(update, 140); }); input.addEventListener('keydown', function (event) { + if (event.isComposing || event.keyCode === 229) return; if (event.key === 'Escape') { input.value = ''; update(); } var first = panel.querySelector('a'); if (first && event.key === 'ArrowDown') { event.preventDefault(); first.focus(); } diff --git a/website/templates/site.js b/website/templates/site.js index 77fb14df..090e90cc 100644 --- a/website/templates/site.js +++ b/website/templates/site.js @@ -3,6 +3,18 @@ try { return decodeURIComponent(value); } catch (error) { return value; } } function encodedRoute(path) { return path.split('/').map(encodeURIComponent).join('/'); } + function configureImages(root, homepage) { + if (!root) return; + root.querySelectorAll('img').forEach(function (img) { + if (homepage && img.classList.contains('site-logo')) { + img.loading = 'eager'; + img.setAttribute('fetchpriority', 'high'); + } else if (!img.hasAttribute('loading')) { + img.loading = 'lazy'; + } + img.decoding = 'async'; + }); + } function preserveLegacyLinks() { var hash = location.hash; var legacy = (window.CS_NOTES_LEGACY_ROUTES || {})[decode(location.pathname)]; @@ -31,12 +43,8 @@ copyCode: { buttonText: '复制', errorText: '复制失败', successText: '已复制' }, plugins: [function (hook, vm) { hook.afterEach(function (content) { - content = content.replace(/]*>/gi, function (tag) { - if (!/\bloading\s*=/i.test(tag)) tag = tag.replace(/ 5 && @@ -52,10 +60,7 @@ hook.doneEach(function () { var heading = document.querySelector('.markdown-section h1, .markdown-section h2'); document.title = (vm.route.path === '/' || !heading ? 'CS-Notes' : heading.textContent.trim() + ' · CS-Notes') + ' · CyC2018'; - document.querySelectorAll('.markdown-section img').forEach(function (img) { - img.loading = 'lazy'; - img.decoding = 'async'; - }); + configureImages(document.querySelector('.markdown-section'), vm.route.path === '/'); document.querySelectorAll('.article-toc').forEach(function (toc) { toc.open = window.matchMedia('(min-width: 769px)').matches; });