From eb6c867348a99929612abeb080902e57a2b575a6 Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Sun, 1 Nov 2026 13:04:24 +0800 Subject: [PATCH 1/5] Update build.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 入口页的脚本合成本站脚本和依赖库两个文件、样式合成一个,文件名带内容哈希;生成 sw.js,写入构建号和入口文件列表;复制 edgeone.json。 --- website/builder/build.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/website/builder/build.py b/website/builder/build.py index e1c77d4b..dc732d6b 100644 --- a/website/builder/build.py +++ b/website/builder/build.py @@ -189,7 +189,7 @@ def build(optimize=True): dist = Path(temporary) / 'dist' dist.mkdir() shutil.copytree(SITE / 'vendor', dist / 'vendor') - for name in ('index.html', 'site.css', 'site.js', 'search.js'): + for name in ('index.html', 'site.css', 'site.js', 'search.js', 'edgeone.json'): shutil.copyfile(SITE / name, dist / name) for path in sources(): target = dist / path.relative_to(ROOT) @@ -282,10 +282,11 @@ def build(optimize=True): 'searchIndex': index_name, 'legacyRoutes': len(legacy)} (dist / 'build-info.json').write_text(json.dumps(build_info, indent=2) + '\n') entry = (dist / 'index.html').read_text() - for name in ('site.css', 'site.js', 'search.js', 'search-paths.js'): - digest = hashlib.sha256((dist / name).read_bytes()).hexdigest()[:12] - entry = entry.replace('/' + name + '"', '/' + name + '?v=' + digest + '"') + entry = bundle_entry(dist, entry) (dist / 'index.html').write_text(entry) + shell = ['/', '/_sidebar.md', '/README.md'] + re.findall(r'(?:src|href)="(/(?:app|vendor|style)-[0-9a-f]{16}\.(?:js|css))"', entry) + worker = (SITE / 'sw.js').read_text().replace('__SHELL__', json.dumps(shell)) + (dist / 'sw.js').write_text(worker.replace('__BUILD__', hashlib.sha256(json.dumps(shell).encode()).hexdigest()[:12])) write_manifest(dist, build_info) 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), @@ -310,6 +311,32 @@ def build(optimize=True): print(json.dumps({k: v for k, v in report.items() if k not in ('imageReplacements', 'externalImageUrls')}, ensure_ascii=False, indent=2)) +SCRIPT_TAG = re.compile(r'^ \n', re.M) +STYLE_TAG = re.compile(r'^ \n', re.M) + + +def bundle_entry(dist, entry): + """入口页的脚本按原顺序合成本站脚本和依赖库两个文件,样式合成一个;文件名带内容哈希。""" + def write(names, prefix, suffix, separator): + parts = [re.sub(r'^//# sourceMappingURL=.*$', '', (dist / name).read_text(), flags=re.M).rstrip() for name in names] + data = separator.join(parts) + '\n' + name = f'{prefix}-{hashlib.sha256(data.encode()).hexdigest()[:16]}{suffix}' + (dist / name).write_text(data) + return name + scripts, styles = SCRIPT_TAG.findall(entry), STYLE_TAG.findall(entry) + own = [name for name in scripts if not name.startswith('vendor/')] + vendor = [name for name in scripts if name.startswith('vendor/')] + # 本站脚本要先执行(site.js 定义 $docsify 配置,依赖库中的插件往里面添加) + if scripts != own + vendor: + raise ValueError('入口页的脚本顺序应为本站脚本在前、依赖库在后') + tags = ''.join(f' \n' + for group, prefix in ((own, 'app'), (vendor, 'vendor')) if group) + style = f' \n' + entry = SCRIPT_TAG.sub('', entry) + entry = STYLE_TAG.sub(lambda match: style if match[1] == styles[0] else '', entry) + return entry.replace('', tags + '', 1) + + if __name__ == '__main__': parser = argparse.ArgumentParser(description='仅在本机从最新上游源码生成静态站,不下载图片、不发布。') parser.add_argument('--source', type=Path, default=ROOT) From 8b56ab502b9bcf992e290a8356b45936d9b80fd3 Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Sun, 1 Nov 2026 13:04:24 +0800 Subject: [PATCH 2/5] Add sw.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缓存入口页、脚本样式和读过的文章与配图:文章先走网络,3 秒内没有返回或断网时用缓存,没缓存过的文章给出离线说明;学习站不经过这里。 --- website/templates/sw.js | 69 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 website/templates/sw.js diff --git a/website/templates/sw.js b/website/templates/sw.js new file mode 100644 index 00000000..21136bee --- /dev/null +++ b/website/templates/sw.js @@ -0,0 +1,69 @@ +// 离线与弱网:安装时缓存入口页和本次构建的脚本、样式;读过的文章、侧边栏和配图在访问时缓存。 +// 文章和入口页先走网络,3 秒内没有返回或断网时用缓存;带内容哈希的文件直接用缓存。学习站(/learn/)有自己的缓存,这里不处理。 +// 下面的构建号和文件列表由 build.py 写入。 +var BUILD = '__BUILD__'; +var SHELL = __SHELL__; +// 带哈希的脚本、样式不按构建号分开存:缓存的旧入口页(弱网时先显示)引用的旧文件还要能读到,超过上限时删最早的 +var STATIC = 'cs-notes-static', PAGES = 'cs-notes-pages', IMAGES = 'cs-notes-images'; +var LIMITS = {}; +LIMITS[STATIC] = 40; +LIMITS[PAGES] = 120; +LIMITS[IMAGES] = 300; +self.addEventListener('install', function (event) { + // 构建号 BUILD 写在这个文件里,构建变化时浏览器会重新安装 + event.waitUntil(caches.open(STATIC).then(function (cache) { return cache.addAll(SHELL); }).then(function () { return trim(STATIC); }).then(function () { return self.skipWaiting(); })); +}); +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); +// 缓存条目超过上限时删掉最早放入的 +function trim(name) { + return caches.open(name).then(function (cache) { + return cache.keys().then(function (keys) { + return Promise.all(keys.slice(0, Math.max(0, keys.length - LIMITS[name])).map(function (key) { return cache.delete(key); })); + }); + }); +} +function put(name, request, response) { + if (!response || !response.ok || response.type !== 'basic') return; + var copy = response.clone(); + caches.open(name).then(function (cache) { return cache.put(request, copy); }).then(function () { if (LIMITS[name]) return trim(name); }); +} +function offlinePage(path) { + var title = decodeURIComponent(path.split('/').pop().replace(/\.md$/, '')); + return new Response('# 当前离线\n\n《' + title + '》还没有在这台设备上打开过,联网后再试。已经读过的文章离线时也能打开。\n', + { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-CS-Notes-Offline': '1' } }); +} +// 先走网络,等待超过 wait 毫秒或失败时用缓存(所有缓存中找);都没有时由 fallback 给出内容 +function networkFirst(request, name, wait, fallback) { + return caches.match(request, { ignoreSearch: true }).then(function (cached) { + var network = fetch(request).then(function (response) { put(name, request, response); return response; }); + var settled = network.catch(function () { return null; }); + var timer = new Promise(function (resolve) { setTimeout(resolve, wait); }); + return Promise.race(cached ? [settled, timer] : [settled]).then(function (first) { + if (first) return first; + if (cached) return cached; + return fallback ? fallback() : Response.error(); + }); + }); +} +function cacheFirst(request, name) { + return caches.match(request).then(function (hit) { + return hit || fetch(request).then(function (response) { put(name, request, response); return response; }); + }); +} +self.addEventListener('fetch', function (event) { + var request = event.request, url = new URL(request.url); + if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.indexOf('/learn/') === 0 || url.searchParams.has('release')) return; + var path = url.pathname; + if (request.mode === 'navigate') { + // 网站只有首页一个入口,其它地址(旧文章的 .html 链接等)照常走网络 + if (path === '/' || path === '/index.html') event.respondWith(networkFirst(new Request('/'), STATIC, 3000, null)); + } else if (/^\/(app|vendor|style)-[0-9a-f]{16}\.(js|css)$|^\/search-index-[0-9a-f]+\.json$|^\/optimized\//.test(path)) { + event.respondWith(cacheFirst(request, /^\/optimized\//.test(path) ? IMAGES : STATIC)); + } else if (/\.md$/.test(path)) { + event.respondWith(networkFirst(request, PAGES, 3000, path === '/_sidebar.md' ? null : function () { return offlinePage(path); })); + } else if (/\.(png|jpe?g|gif|svg|webp)$/.test(path)) { + event.respondWith(networkFirst(request, IMAGES, 3000, null)); + } +}); From 3d0075f9fe262513e0ea8ee6661cfe3afb10d42d Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Sun, 1 Nov 2026 13:04:24 +0800 Subject: [PATCH 3/5] Add edgeone.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown 文件按 text/plain 返回,便于压缩传输。 --- website/templates/edgeone.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 website/templates/edgeone.json diff --git a/website/templates/edgeone.json b/website/templates/edgeone.json new file mode 100644 index 00000000..199dd80c --- /dev/null +++ b/website/templates/edgeone.json @@ -0,0 +1,5 @@ +{ + "headers": [ + { "source": "/*.md", "headers": [{ "key": "Content-Type", "value": "text/plain; charset=utf-8" }] } + ] +} From 98731ffdd4194bf6406dc07766e164a2d1f541bd Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Sun, 1 Nov 2026 13:04:24 +0800 Subject: [PATCH 4/5] Update site.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注册 sw.js;离线时在文章上方提示显示的是缓存,离线说明页提供重新加载。 --- website/templates/site.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/website/templates/site.js b/website/templates/site.js index 53a01fb9..eb863e85 100644 --- a/website/templates/site.js +++ b/website/templates/site.js @@ -30,6 +30,27 @@ root.querySelectorAll('.docsify-copy-code-button [aria-hidden="hidden"]').forEach(function (node) { node.setAttribute('aria-hidden', 'true'); }); }); } + // 离线时提示显示的是缓存的版本;sw.js 缓存读过的文章,离线时也能打开 + function offlineNote(section) { + if (!section || section.querySelector('.offline-note')) return; + // sw.js 给没缓存过的文章返回的说明页:Docsify 会把它记在内存里,联网后要刷新页面才能重新下载 + if (/^当前离线/.test((section.querySelector('h1') || {}).textContent || '')) { + var retry = document.createElement('p'); + retry.innerHTML = '重新加载'; + section.appendChild(retry); + window.addEventListener('online', function () { location.reload(); }, { once: true }); + return; + } + if (navigator.onLine !== false) return; + var note = document.createElement('p'); + note.className = 'offline-note'; + note.setAttribute('role', 'status'); + note.textContent = '当前离线,显示的是这台设备上次缓存的内容。'; + section.insertBefore(note, section.firstChild); + } + if ('serviceWorker' in navigator && (location.protocol === 'https:' || location.hostname === '127.0.0.1' || location.hostname === 'localhost')) { + window.addEventListener('load', function () { navigator.serviceWorker.register('/sw.js').catch(function () {}); }); + } function preserveLegacyLinks() { var hash = location.hash; var legacy = (window.CS_NOTES_LEGACY_ROUTES || {})[decode(location.pathname)]; @@ -337,6 +358,7 @@ return document.body.innerHTML + ''; }); hook.doneEach(function () { + offlineNote(document.querySelector('.markdown-section')); var heading = document.querySelector('.markdown-section h1, .markdown-section h2'); document.title = (vm.route.path === '/' || !heading ? 'CS-Notes' : heading.textContent.trim() + ' · CS-Notes') + ' · CyC2018'; configureImages(document.querySelector('.markdown-section'), vm.route.path === '/'); From a67cdbe52664fd60c8f661aeee22221990f95d71 Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Sun, 1 Nov 2026 13:04:24 +0800 Subject: [PATCH 5/5] Update site.css MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 离线提示的样式。 --- website/templates/site.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/website/templates/site.css b/website/templates/site.css index 49ea5866..31f5fa81 100644 --- a/website/templates/site.css +++ b/website/templates/site.css @@ -114,3 +114,6 @@ body { color: #34495e; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI /* 首页贡献者头像:链接按图片大小计算点击区域 */ .markdown-section > a:has(> img) { display: inline-block; margin: 2px; } .markdown-section > a > img[src*="avatars"] { aspect-ratio: 1; } + +/* 离线提示 */ +.markdown-section .offline-note { margin: 0 0 18px; padding: 8px 12px; border-radius: 6px; background: #eaf3ee; color: #34495e; font-size: 14px; }