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;
});