mirror of
https://github.com/CyC2018/Interview-Notebook.git
synced 2026-10-28 22:53:47 +00:00
Harden publishing and improve search performance (#1477)
This commit is contained in:
@@ -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: |
|
||||
|
||||
+6
-4
@@ -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,保留原有中文网址。
|
||||
|
||||
本机检查发布保护(不需要令牌、不会上传):
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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 === '本地验证更新'));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
? '<html>SPA fallback</html>' : 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');
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
Pillow==11.3.0
|
||||
Pillow==13.0.0
|
||||
|
||||
+38
-13
@@ -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(); }
|
||||
|
||||
@@ -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(/<img\b[^>]*>/gi, function (tag) {
|
||||
if (!/\bloading\s*=/i.test(tag)) tag = tag.replace(/<img\b/i, '<img loading="lazy"');
|
||||
if (!/\bdecoding\s*=/i.test(tag)) tag = tag.replace(/<img\b/i, '<img decoding="async"');
|
||||
return tag;
|
||||
});
|
||||
var document = new DOMParser().parseFromString(content, 'text/html');
|
||||
configureImages(document, vm.route.path === '/');
|
||||
var heading = document.querySelector('h1');
|
||||
var list = heading && heading.nextElementSibling;
|
||||
if (list && list.tagName === 'UL' && list.querySelectorAll('a').length > 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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user