feat(reader): PDF 改 pdf.js 渲染+斜体 bug 根治+评论假挂载修复+排版类
【T1 PDF 内置阅读重做】iframe 对 arXiv PDF 实测 ERR_ABORTED(浏览器 PDF
插件策略差异)→ pdfjs-dist 6.4.299 客户端 canvas 渲染:只在 /papers/
动态 import(worker ?url 打包,不进全站 bundle);文档一次取回 + IO
懒渲染(rootMargin 400px)+ 页占位 aspect-ratio 防跳动 + 12s 首屏兜底 +
微信 UA 直接兜底;工具条页码/缩放/翻页/下载/arXiv/授权声明保留。红线:
PDF 由访客浏览器直取 arXiv(CORS * 实测),仓内零副本。真页实测:
13 页 canvas 全绘制、首屏墨迹像素非空、零 console 错误。
【T2 斜体排版 bug 真凶】global.css 的 `.prose-* p > em:only-child
{ display:block; text-align:center }` 图注样式按"斜体独占段"自动命中,
把所有正文独占段斜体变成块级居中。改:图注用显式 .fig-cap 驱动 + 补
em/i { display:inline; font-style:italic } 基线;media-sample 迁移新写法,
指南注明旧写法失效原因。实测独占段与行内斜体均 display:inline/italic。
【T3 评论"假挂载"真 bug】原 initArtalk 一进来就置 mounted=true——脚本
404/被拦截时把空容器误标已挂载,5s 兜底被绕过,正是"评论区一直空着"的
来源之一。改为库就绪才算成功;另加三触发路径(IO 主 + 锚点点击 + 滚到
页底 2s 兜底)、5s 失败提示+重试钮、软导航重挂、成功打
data-artalk-mounted=1 供 E2E。
【T4 文章排版类 + 安全白名单】11 个受限类(ink-1/2/3/muted/accent、
font-serif/kai/sans/mono、drop-cap、quote-line、fig-cap)全走 tokens 禁
任意 hex;新增 rehype-class-whitelist.ts:className 白名单 + 剥离 style
与 on*;实测注入用例只剩 class="ink-2",style/onclick 全无。新坑入档:
markdown raw HTML 在 hast 是 type:'raw' 节点、白名单插件看不见 span,必须
先 rehype-raw 再过滤 → 插件链 [rehypeRaw, rehypeClassWhitelist,
rehypeLinkCards]。新依赖:pdfjs-dist、rehype-raw。
车道:op-artalk(T1 6/6、T2 2/2、T3 3/3、T4 6/6);收编复验:build 46 页、
E2E 37/37、四专项 playwright 实测(PDF 13 canvas/斜体 inline/白名单净化/
评论 mounted 标记)
验证: node v26.13.0 + astro 7.4.4 构建 46 页 exit 0;E2E 37/37 PASS
Spec: none (阅读体验四件,车道级交付)
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
328ab68f18
commit
280654f9e3
11 files changed
+621
-55
No files matched your search
+5
-1
@@ -4,6 +4,8 @@ import pagefind from 'astro-pagefind';
|
|||||||
import sitemap from '@astrojs/sitemap';
|
import sitemap from '@astrojs/sitemap';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import rehypeLinkCards, { linkCardsReport } from './src/lib/rehype-link-cards';
|
import rehypeLinkCards, { linkCardsReport } from './src/lib/rehype-link-cards';
|
||||||
|
import rehypeClassWhitelist from './src/lib/rehype-class-whitelist';
|
||||||
|
import rehypeRaw from 'rehype-raw';
|
||||||
|
|
||||||
// 构建透明(T7):commit/时间经 vite.define 注入,页脚与 /about 引用。
|
// 构建透明(T7):commit/时间经 vite.define 注入,页脚与 /about 引用。
|
||||||
// 四重回退:CI 环境变量优先(GITEA_ACTIONS/GITHUB_SHA)→ 本地 git → dev 占位。
|
// 四重回退:CI 环境变量优先(GITEA_ACTIONS/GITHUB_SHA)→ 本地 git → dev 占位。
|
||||||
@@ -21,7 +23,9 @@ export default defineConfig({
|
|||||||
integrations: [pagefind(), sitemap(), linkCardsReport()],
|
integrations: [pagefind(), sitemap(), linkCardsReport()],
|
||||||
markdown: {
|
markdown: {
|
||||||
// 独立成段的裸链接 → OG 预览卡(构建期抓取,失败回落普通链接;见 src/lib/rehype-link-cards.ts)
|
// 独立成段的裸链接 → OG 预览卡(构建期抓取,失败回落普通链接;见 src/lib/rehype-link-cards.ts)
|
||||||
rehypePlugins: [rehypeLinkCards],
|
// 顺序:rehypeRaw 先把 markdown 里的 raw HTML 解析成 hast element(否则白名单看不到 span),
|
||||||
|
// 再白名单净化(剥 style/on*/非白名单类),最后链接卡片(节点由插件自建,不经净化)
|
||||||
|
rehypePlugins: [rehypeRaw, rehypeClassWhitelist, rehypeLinkCards],
|
||||||
},
|
},
|
||||||
i18n: {
|
i18n: {
|
||||||
defaultLocale: 'zh',
|
defaultLocale: 'zh',
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: "排版能力样例"
|
||||||
|
pubDate: 2026-11-02
|
||||||
|
category: tech
|
||||||
|
tags: [test, hidden]
|
||||||
|
# 排版/斜体/白名单测试样张(fix3 车道):hidden 不列出,构建与 E2E 验证用
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
本篇验证三件事:斜体行内渲染、受限排版类白名单、图注类。
|
||||||
|
|
||||||
|
## 斜体(应为行内,不换行不居中)
|
||||||
|
|
||||||
|
这一句里有 *行内斜体强调* 夹在文字中间,它必须与正文同处一行。
|
||||||
|
|
||||||
|
*这一整段是斜体独占段,它不该变成块级居中。*
|
||||||
|
|
||||||
|
## 受限排版类(白名单)
|
||||||
|
|
||||||
|
<span class="ink-muted">这一段是 ink-muted 灰字。</span>
|
||||||
|
<span class="font-kai">这一段是霞鹜文楷字族。</span>
|
||||||
|
<span class="font-mono">这一段是等宽字族 mono。</span>
|
||||||
|
|
||||||
|
<p class="drop-cap">首字下沉段落:秋天的海面像一块被风抚平的铁皮,落日把它的边缘烧红了一圈。</p>
|
||||||
|
|
||||||
|
<p class="quote-line">所谓暮色,就是光慢慢地承认自己要走。</p>
|
||||||
|
|
||||||
|
## 白名单剔除(非法类与内联样式必须被剥离)
|
||||||
|
|
||||||
|
<span class="evil-class ink-2" style="color:#ff0000" onclick="alert(1)">这一段同时带了非法类、内联颜色与 onclick,构建后应只剩 ink-2 且 style/onclick 消失。</span>
|
||||||
|
|
||||||
|
## 图注(类驱动)
|
||||||
|
|
||||||
|
<p class="fig-cap">这是显式图注样式(fig-cap),居中灰字。</p>
|
||||||
@@ -14,7 +14,7 @@ draft: false
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
*图注写法:斜体单独成段,photos 目录样片副本(1600×2400 竖幅)。*
|
<p class="fig-cap">图注新写法:fig-cap 类,photos 目录样片副本(1600×2400 竖幅)。</p>
|
||||||
|
|
||||||
## 视频(raw HTML,H.264 320×240,ffmpeg testsrc2 生成)
|
## 视频(raw HTML,H.264 320×240,ffmpeg testsrc2 生成)
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -22,10 +22,11 @@ content/posts/
|
|||||||
|
|
||||||
```markdown
|
```markdown
|
||||||

|

|
||||||
*图:外滩的傍晚* ← 紧跟媒体的斜体独立段 = 图注(居中小字)
|
<p class="fig-cap">图:外滩的傍晚</p> ← 显式图注类(居中小字)
|
||||||
```
|
```
|
||||||
|
|
||||||
- 图注可选;写法固定为 `*…*` 单独成段,紧跟图片
|
- 图注可选;**必须用 `<p class="fig-cap">…</p>`**(2026-11-02 起)
|
||||||
|
- ⚠️ 旧写法 `*斜体独立段*` 已不再自动当图注:此前它会把正文里所有独占成段的斜体都变成块级居中(用户实测的排版 bug),现在斜体恢复为正常行内强调
|
||||||
- 压缩纪律:入仓前长边 ≤2400px(可跑 `python3 scripts/photos_ingest.py`,对 posts 资产同样有效的思路,图片请自行用同规格压缩)
|
- 压缩纪律:入仓前长边 ≤2400px(可跑 `python3 scripts/photos_ingest.py`,对 posts 资产同样有效的思路,图片请自行用同规格压缩)
|
||||||
|
|
||||||
## 视频 / 音频
|
## 视频 / 音频
|
||||||
@@ -83,6 +84,33 @@ https://astro.build/
|
|||||||
- **缓存**:结果存 `content/.link-cache.json`(入仓,保证构建确定性);条目 30 天后自动重抓。想强制刷新某条——删掉该 URL 的条目即可
|
- **缓存**:结果存 `content/.link-cache.json`(入仓,保证构建确定性);条目 30 天后自动重抓。想强制刷新某条——删掉该 URL 的条目即可
|
||||||
- 缩略图取 `og:image`,无图不占位;图标取站点而非第三方 favicon 服务
|
- 缩略图取 `og:image`,无图不占位;图标取站点而非第三方 favicon 服务
|
||||||
|
|
||||||
|
## 文章排版类(受限白名单)
|
||||||
|
|
||||||
|
正文里可用 `<span class="…">` / `<p class="…">` 做**受限**排版。构建期有白名单过滤:**只放行下表类名,其它 class 与 `style`、`on*` 属性一律被剥离**(防注入 + 保护灰阶骨架)。
|
||||||
|
|
||||||
|
| 类名 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `ink-1` / `ink-2` / `ink-3` | 指定灰阶层(正文/次要/更淡) |
|
||||||
|
| `ink-muted` | 最淡灰(注释感) |
|
||||||
|
| `ink-accent` | 强调色(链接色,仅少量点缀) |
|
||||||
|
| `font-serif` | 思源宋(书评正文族) |
|
||||||
|
| `font-kai` | 霞鹜文楷(金句族) |
|
||||||
|
| `font-sans` | 思源黑(UI/技术族) |
|
||||||
|
| `font-mono` | 等宽族(标签/数值) |
|
||||||
|
| `drop-cap` | 段落首字下沉(书评开篇) |
|
||||||
|
| `quote-line` | 金句块(左侧竖线 + 文楷) |
|
||||||
|
| `fig-cap` | 图注(居中小字) |
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<span class="ink-muted">次要说明</span>
|
||||||
|
<span class="font-kai">金句可以换文楷</span>
|
||||||
|
<p class="drop-cap">开篇段落,首字会下沉。</p>
|
||||||
|
<p class="quote-line">所谓暮色,就是光慢慢地承认自己要走。</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **颜色只能走上面这些类**(内部用 tokens,亮暗双态都可读);写 `style="color:red"` 会被构建期剔除
|
||||||
|
- 斜体就用普通 markdown `*斜体*`——行内强调,不会换行也不会居中
|
||||||
|
|
||||||
## 发布提醒
|
## 发布提醒
|
||||||
|
|
||||||
- push main → webhook 自动构建发布(约 10 秒)
|
- push main → webhook 自动构建发布(约 10 秒)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
"astro-pagefind": "^2.0.1",
|
"astro-pagefind": "^2.0.1",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"lxgw-wenkai-screen-webfont": "^1.7.0",
|
"lxgw-wenkai-screen-webfont": "^1.7.0",
|
||||||
|
"pdfjs-dist": "^6.4.299",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
"sharp": "^0.35.5"
|
"sharp": "^0.35.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+137
@@ -38,6 +38,12 @@ importers:
|
|||||||
lxgw-wenkai-screen-webfont:
|
lxgw-wenkai-screen-webfont:
|
||||||
specifier: ^1.7.0
|
specifier: ^1.7.0
|
||||||
version: 1.7.0
|
version: 1.7.0
|
||||||
|
pdfjs-dist:
|
||||||
|
specifier: ^6.4.299
|
||||||
|
version: 6.4.299
|
||||||
|
rehype-raw:
|
||||||
|
specifier: ^7.0.0
|
||||||
|
version: 7.0.0
|
||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.35.5
|
specifier: ^0.35.5
|
||||||
version: 0.35.5(@types/node@24.19.2)
|
version: 0.35.5(@types/node@24.19.2)
|
||||||
@@ -427,6 +433,81 @@ packages:
|
|||||||
'@mdx-js/mdx@3.1.1':
|
'@mdx-js/mdx@3.1.1':
|
||||||
resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
|
resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
|
||||||
|
|
||||||
|
'@napi-rs/canvas-android-arm64@1.1.0':
|
||||||
|
resolution: {integrity: sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-arm64@1.1.0':
|
||||||
|
resolution: {integrity: sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-x64@1.1.0':
|
||||||
|
resolution: {integrity: sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu@1.1.0':
|
||||||
|
resolution: {integrity: sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl@1.1.0':
|
||||||
|
resolution: {integrity: sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu@1.1.0':
|
||||||
|
resolution: {integrity: sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu@1.1.0':
|
||||||
|
resolution: {integrity: sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-musl@1.1.0':
|
||||||
|
resolution: {integrity: sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc@1.1.0':
|
||||||
|
resolution: {integrity: sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc@1.1.0':
|
||||||
|
resolution: {integrity: sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/canvas@1.1.0':
|
||||||
|
resolution: {integrity: sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.2.6':
|
'@napi-rs/wasm-runtime@1.2.6':
|
||||||
resolution: {integrity: sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==}
|
resolution: {integrity: sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
||||||
@@ -1369,6 +1450,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==}
|
resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
pdfjs-dist@6.4.299:
|
||||||
|
resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==}
|
||||||
|
engines: {node: '>=22.13.0 || >=24'}
|
||||||
|
|
||||||
piccolore@0.1.3:
|
piccolore@0.1.3:
|
||||||
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
|
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
|
||||||
|
|
||||||
@@ -2166,6 +2251,54 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@napi-rs/canvas-android-arm64@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-arm64@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-x64@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-musl@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas@1.1.0':
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas-android-arm64': 1.1.0
|
||||||
|
'@napi-rs/canvas-darwin-arm64': 1.1.0
|
||||||
|
'@napi-rs/canvas-darwin-x64': 1.1.0
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf': 1.1.0
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu': 1.1.0
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl': 1.1.0
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu': 1.1.0
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu': 1.1.0
|
||||||
|
'@napi-rs/canvas-linux-x64-musl': 1.1.0
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc': 1.1.0
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc': 1.1.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.2.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
|
'@napi-rs/wasm-runtime@1.2.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.11.1
|
'@emnapi/core': 1.11.1
|
||||||
@@ -3457,6 +3590,10 @@ snapshots:
|
|||||||
|
|
||||||
path-expression-matcher@1.6.2: {}
|
path-expression-matcher@1.6.2: {}
|
||||||
|
|
||||||
|
pdfjs-dist@6.4.299:
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas': 1.1.0
|
||||||
|
|
||||||
piccolore@0.1.3: {}
|
piccolore@0.1.3: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ const locale = localeOf(Astro.url.pathname);
|
|||||||
{' '}{t('pv_viewed', locale)}
|
{' '}{t('pv_viewed', locale)}
|
||||||
</p>
|
</p>
|
||||||
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
|
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
|
||||||
|
<p class="atk-load-hint" id="artalk-retry" hidden>
|
||||||
|
<span>评论区加载失败。</span>
|
||||||
|
<button type="button" class="atk-retry-btn" id="artalk-retry-btn">重试</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Artalk 主题接管:--at-color-* 全套映射到站点 tokens(值均为 var(),明暗随
|
<!-- Artalk 主题接管:--at-color-* 全套映射到站点 tokens(值均为 var(),明暗随
|
||||||
html[data-theme] 翻转 tokens 自动生效,一套规则双态成立)。
|
html[data-theme] 翻转 tokens 自动生效,一套规则双态成立)。
|
||||||
@@ -76,6 +80,26 @@ const locale = localeOf(Astro.url.pathname);
|
|||||||
#artalk-container .atk-content a:hover {
|
#artalk-container .atk-content a:hover {
|
||||||
text-decoration-color: var(--accent);
|
text-decoration-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.atk-load-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
}
|
||||||
|
.atk-retry-btn {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.atk-retry-btn:hover { border-color: var(--accent); }
|
||||||
.atk-pv-line {
|
.atk-pv-line {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -107,9 +131,24 @@ const locale = localeOf(Astro.url.pathname);
|
|||||||
document.addEventListener('astro:page-load', () => void bumpPv());
|
document.addEventListener('astro:page-load', () => void bumpPv());
|
||||||
void bumpPv();
|
void bumpPv();
|
||||||
|
|
||||||
|
// ── 挂载健壮化(用户实测「总没加载出来」)──
|
||||||
|
// 触发路径三条:IO 进入视口(主)/ 点击评论区锚点(兜底)/ 滚到页底 2s 后(兜底);
|
||||||
|
// 失败路径:脚本 5s 未 onload → 灰阶提示 + 重试钮;成功后容器打 data-artalk-mounted 供 E2E 断言。
|
||||||
const el = document.getElementById('artalk-container');
|
const el = document.getElementById('artalk-container');
|
||||||
if (el) {
|
if (el) {
|
||||||
|
let mounting = false;
|
||||||
|
let mounted = false;
|
||||||
|
const retryEl = document.getElementById('artalk-retry');
|
||||||
|
const showRetry = (show: boolean) => { if (retryEl) retryEl.hidden = !show; };
|
||||||
|
const armTimeout = () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!mounted) showRetry(true);
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
const mount = () => {
|
const mount = () => {
|
||||||
|
if (mounting || mounted) return;
|
||||||
|
mounting = true;
|
||||||
|
armTimeout();
|
||||||
if (document.getElementById('artalk-lib')) return initArtalk();
|
if (document.getElementById('artalk-lib')) return initArtalk();
|
||||||
// 官方部署文档要求 CSS 手动引入(JS 不自动注入);此前缺失导致评论区自上线起跑在 UA 默认样式上
|
// 官方部署文档要求 CSS 手动引入(JS 不自动注入);此前缺失导致评论区自上线起跑在 UA 默认样式上
|
||||||
const cssReady = new Promise<void>((done) => {
|
const cssReady = new Promise<void>((done) => {
|
||||||
@@ -130,6 +169,15 @@ const locale = localeOf(Astro.url.pathname);
|
|||||||
};
|
};
|
||||||
const initArtalk = () => {
|
const initArtalk = () => {
|
||||||
const lib = (window as unknown as { Artalk?: { init: (o: object) => void } }).Artalk;
|
const lib = (window as unknown as { Artalk?: { init: (o: object) => void } }).Artalk;
|
||||||
|
// 只有库真正就绪才算挂载成功:否则脚本 404/被拦截时会把"空容器"误标成已挂载,
|
||||||
|
// 5s 兜底提示也被一并绕过(用户看到的正是"评论区一直空着")
|
||||||
|
if (!lib) {
|
||||||
|
showRetry(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mounted = true;
|
||||||
|
showRetry(false);
|
||||||
|
el.dataset.artalkMounted = '1';
|
||||||
lib?.init({
|
lib?.init({
|
||||||
el: '#artalk-container',
|
el: '#artalk-container',
|
||||||
server: 'https://wait4x.online/artalk',
|
server: 'https://wait4x.online/artalk',
|
||||||
@@ -143,12 +191,48 @@ const locale = localeOf(Astro.url.pathname);
|
|||||||
pvAdd: false,
|
pvAdd: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
// 兜底 ①:点任意指向评论区的锚点(含页内 #评论 跳转)立即挂载
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
if (!t.closest('a[href="#comments"], a[href="#artalk-container"], [data-artalk-jump]')) return;
|
||||||
|
mountNow();
|
||||||
|
});
|
||||||
|
// 兜底 ②:滚到页底 2s 仍未挂载 → 强制挂载(用户滚到底显然在找评论)
|
||||||
|
let bottomTimer = 0;
|
||||||
|
addEventListener('scroll', () => {
|
||||||
|
if (mounted || mounting) return;
|
||||||
|
const nearBottom = innerHeight + scrollY >= document.documentElement.scrollHeight - 200;
|
||||||
|
if (!nearBottom) return;
|
||||||
|
clearTimeout(bottomTimer);
|
||||||
|
bottomTimer = window.setTimeout(() => { if (!mounted) mountNow(); }, 2000);
|
||||||
|
}, { passive: true });
|
||||||
|
// 重试钮:重置脚本引用后重新挂载
|
||||||
|
document.getElementById('artalk-retry-btn')?.addEventListener('click', () => {
|
||||||
|
showRetry(false);
|
||||||
|
document.getElementById('artalk-lib')?.remove();
|
||||||
|
mounting = false;
|
||||||
|
mountNow();
|
||||||
|
});
|
||||||
|
// 软导航复核:换页后若容器在而从未挂载,重新按当前 DOM 接线
|
||||||
|
document.addEventListener('astro:page-load', () => {
|
||||||
|
const fresh = document.getElementById('artalk-container');
|
||||||
|
if (fresh && !fresh.dataset.artalkMounted) {
|
||||||
|
mounting = false;
|
||||||
|
mountNow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 进入视口 ~200px 前挂载;无 IO 环境直接挂
|
// 进入视口 ~200px 前挂载;无 IO 环境直接挂
|
||||||
if ('IntersectionObserver' in window) {
|
if ('IntersectionObserver' in window) {
|
||||||
new IntersectionObserver(
|
new IntersectionObserver(
|
||||||
(entries, obs) => { if (entries.some((e) => e.isIntersecting)) { obs.disconnect(); mount(); } },
|
(entries, obs) => { if (entries.some((e) => e.isIntersecting)) { obs.disconnect(); mountNow(); } },
|
||||||
{ rootMargin: '200px' }
|
{ rootMargin: '200px' }
|
||||||
).observe(el);
|
).observe(el);
|
||||||
} else mount();
|
} else mountNow();
|
||||||
|
|
||||||
|
function mountNow() {
|
||||||
|
if (mounted) return;
|
||||||
|
mount();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* rehype 排版类白名单:文章里允许用 `<span class="...">` / `<p class="fig-cap">` 做受限排版,
|
||||||
|
* 但**只放行白名单类名**,其余 class 一律剔除;同时剥离 style 与 on* 事件属性(纵深防御)。
|
||||||
|
*
|
||||||
|
* 为什么需要:Astro 默认不 sanitize markdown 里的 raw HTML——若直接放行 class,作者(或
|
||||||
|
* Sveltia 后台协作者)可写入任意类名/颜色破坏灰阶骨架,甚至借 style/on* 注入。
|
||||||
|
* 白名单与 global.css 的「受限排版类」段一一对应,加类时两处同改。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALLOWED = new Set([
|
||||||
|
// 颜色(走 tokens,明暗双态可读)
|
||||||
|
'ink-1',
|
||||||
|
'ink-2',
|
||||||
|
'ink-3',
|
||||||
|
'ink-muted',
|
||||||
|
'ink-accent',
|
||||||
|
// 字族(与分轨排版共存的四个既有字族)
|
||||||
|
'font-serif',
|
||||||
|
'font-kai',
|
||||||
|
'font-sans',
|
||||||
|
'font-mono',
|
||||||
|
// 文学排版
|
||||||
|
'drop-cap',
|
||||||
|
'quote-line',
|
||||||
|
'fig-cap',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default function rehypeClassWhitelist() {
|
||||||
|
return (tree: any) => {
|
||||||
|
const walk = (node: any) => {
|
||||||
|
if (node?.type === 'element') {
|
||||||
|
const props = node.properties ?? (node.properties = {});
|
||||||
|
// 剥离内联样式与事件属性:排版能力只经白名单类提供
|
||||||
|
delete props.style;
|
||||||
|
for (const key of Object.keys(props)) {
|
||||||
|
if (/^on/i.test(key)) delete props[key];
|
||||||
|
}
|
||||||
|
const raw = props.className;
|
||||||
|
if (raw !== undefined) {
|
||||||
|
const list = Array.isArray(raw) ? raw : String(raw).split(/\s+/);
|
||||||
|
const kept = list.map((c: string) => String(c).trim()).filter((c: string) => ALLOWED.has(c));
|
||||||
|
if (kept.length > 0) props.className = kept;
|
||||||
|
else delete props.className;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const child of node?.children ?? []) walk(child);
|
||||||
|
};
|
||||||
|
walk(tree);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
---
|
---
|
||||||
// 论文内置阅读视图(非路由共享件,_ 前缀不成页):双语薄壳 papers/agensh.astro 与
|
// 论文内置阅读视图(非路由共享件,_ 前缀不成页):双语薄壳 papers/agensh.astro 与
|
||||||
// en/papers/agensh.astro 共用。许可证红线:arXiv nonexclusive-dist 1.0 禁止第三方
|
// en/papers/agensh.astro 共用。
|
||||||
// 再分发——PDF 只经 iframe 指向 arXiv 原站,仓内不存任何 PDF 副本。
|
// 许可证红线:arXiv nonexclusive-dist 1.0 禁止第三方再分发——PDF 只由**访客浏览器直取
|
||||||
// 微信内置浏览器内嵌 PDF 基本不可用:UA 检测在 head 前置(无闪烁),微信下不发起
|
// arXiv**(响应带 access-control-allow-origin: *),仓内不存任何 PDF 副本。
|
||||||
// iframe 请求,直接呈现兜底卡(含"右上角用浏览器打开"指引)。
|
// 渲染改用 pdf.js canvas 逐页画(iframe 方案实测 arXiv PDF 请求被浏览器 PDF 插件策略
|
||||||
|
// ERR_ABORTED,无法内嵌全文);pdfjs 只在 /papers/ 页动态 import,不进全站 bundle。
|
||||||
|
// 微信内置浏览器:不发起 PDF 请求,直接兜底卡(含"右上角用浏览器打开"指引)。
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { t } from '../../i18n/ui';
|
import { t } from '../../i18n/ui';
|
||||||
import type { Locale } from '../../lib/locale';
|
import type { Locale } from '../../lib/locale';
|
||||||
@@ -57,17 +59,26 @@ const p = PAPERS[paper];
|
|||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- iframe 无初始 src:脚本确认非微信后才挂载,微信内不产生无用请求 -->
|
<!-- 阅读工具条:页码 / 上一下一页 / 缩放 -->
|
||||||
<div class="paper-stage">
|
<div class="paper-toolbar" id="paper-toolbar" hidden>
|
||||||
<iframe
|
<span class="pt-page" id="paper-page-label">{t('paper_page_label', locale)} – / –</span>
|
||||||
id="paper-frame"
|
<span class="pt-group">
|
||||||
class="paper-frame"
|
<button type="button" class="pt-btn" id="paper-prev" aria-label={t('paper_prev_page', locale)}>‹</button>
|
||||||
data-src={p.pdf}
|
<button type="button" class="pt-btn" id="paper-next" aria-label={t('paper_next_page', locale)}>›</button>
|
||||||
title={p.alt[locale]}
|
</span>
|
||||||
allow="fullscreen"
|
<span class="pt-group">
|
||||||
></iframe>
|
<button type="button" class="pt-btn" id="paper-zoom-out" aria-label={t('paper_zoom_out', locale)}>−</button>
|
||||||
|
<span class="pt-zoom" id="paper-zoom-label">100%</span>
|
||||||
|
<button type="button" class="pt-btn" id="paper-zoom-in" aria-label={t('paper_zoom_in', locale)}>+</button>
|
||||||
|
</span>
|
||||||
|
<span class="pt-status" id="paper-status">{t('paper_loading', locale)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 兜底卡:微信 UA(JS 加 is-wechat)或 iframe 加载超时后显形 -->
|
<div class="paper-stage" id="paper-stage">
|
||||||
|
<!-- canvas 逐页容器(pdf.js 渲染;脚本按页懒创建 canvas) -->
|
||||||
|
<div class="paper-canvas-wrap" id="paper-canvas-wrap" data-pdf={p.pdf} role="document" aria-label={p.alt[locale]}></div>
|
||||||
|
|
||||||
|
<!-- 兜底卡:微信 UA / 加载失败或超时 / 无 JS 时显形 -->
|
||||||
<div class="paper-fallback" id="paper-fallback" hidden>
|
<div class="paper-fallback" id="paper-fallback" hidden>
|
||||||
<p class="paper-fallback-title">{t('paper_fallback_title', locale)}</p>
|
<p class="paper-fallback-title">{t('paper_fallback_title', locale)}</p>
|
||||||
<p class="paper-fallback-hint" data-wechat-only hidden>{t('paper_wechat_hint', locale)}</p>
|
<p class="paper-fallback-hint" data-wechat-only hidden>{t('paper_wechat_hint', locale)}</p>
|
||||||
@@ -88,34 +99,156 @@ const p = PAPERS[paper];
|
|||||||
</article>
|
</article>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script is:inline data-astro-rerun>
|
<script>
|
||||||
// data-astro-rerun:ClientRouter 软导航后内联脚本默认不重跑,不加则从别的页面切进
|
// pdf.js 客户端渲染:文档一次取回,页面 canvas 按 IntersectionObserver 懒渲染(长文不爆内存)。
|
||||||
// 本页时 iframe 永不挂载(ThemeScript 同款坑,2026-10-25 已记录)
|
// 软导航换页后按当前 DOM 重新接线(astro:page-load);任何失败一律显兜底卡,不阻塞阅读。
|
||||||
(function () {
|
const boot = async () => {
|
||||||
var frame = document.getElementById('paper-frame');
|
if (!/^\/(?:en\/)?papers\/[^/]+\/?$/.test(location.pathname)) return;
|
||||||
if (!frame) return;
|
const wrap = document.getElementById('paper-canvas-wrap') as HTMLElement | null;
|
||||||
var fallback = document.getElementById('paper-fallback');
|
const toolbar = document.getElementById('paper-toolbar') as HTMLElement | null;
|
||||||
var wechat = /MicroMessenger/i.test(navigator.userAgent);
|
const fallback = document.getElementById('paper-fallback') as HTMLElement | null;
|
||||||
document.documentElement.classList.toggle('is-wechat', wechat);
|
const statusEl = document.getElementById('paper-status') as HTMLElement | null;
|
||||||
|
const pageLabel = document.getElementById('paper-page-label') as HTMLElement | null;
|
||||||
|
const zoomLabel = document.getElementById('paper-zoom-label') as HTMLElement | null;
|
||||||
|
if (!wrap || !fallback || wrap.dataset.paperBooted === '1') return;
|
||||||
|
wrap.dataset.paperBooted = '1';
|
||||||
|
|
||||||
function showFallback(withWechatHint) {
|
const showFallback = (wechat = false) => {
|
||||||
if (!fallback) return;
|
|
||||||
fallback.hidden = false;
|
fallback.hidden = false;
|
||||||
var hint = fallback.querySelector('[data-wechat-only]');
|
document.documentElement.classList.toggle('is-wechat', wechat);
|
||||||
if (hint) hint.hidden = !withWechatHint;
|
const hint = fallback.querySelector('[data-wechat-only]') as HTMLElement | null;
|
||||||
}
|
if (hint) hint.hidden = !wechat;
|
||||||
|
};
|
||||||
|
const setStatus = (text: string) => {
|
||||||
|
if (!statusEl) return;
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.hidden = text === '';
|
||||||
|
};
|
||||||
|
|
||||||
if (wechat) {
|
// 微信内置浏览器:不发起 PDF 请求(渲染必然不可用,白等带宽)
|
||||||
// 微信 webview 内嵌 PDF 基本不可用:不挂载 iframe,直接兜底 + 浏览器指引
|
if (/MicroMessenger/i.test(navigator.userAgent)) {
|
||||||
showFallback(true);
|
showFallback(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var settled = false;
|
|
||||||
frame.addEventListener('load', function () { settled = true; });
|
let doc: any = null;
|
||||||
frame.src = frame.dataset.src;
|
let zoom = 1;
|
||||||
// arXiv 现无 X-Frame-Options(实测 2026-11-01),超时兜底只作弱网/拦截保险
|
const pageEls: { el: HTMLDivElement; canvas: HTMLCanvasElement; page: number; rendered: boolean; ratio: number }[] = [];
|
||||||
setTimeout(function () { if (!settled) showFallback(false); }, 9000);
|
let io: IntersectionObserver | null = null;
|
||||||
})();
|
let renderedAny = false;
|
||||||
|
|
||||||
|
const fail = (err: unknown) => {
|
||||||
|
console.warn('[paper] 渲染失败,回落兜底卡', err);
|
||||||
|
showFallback(false);
|
||||||
|
};
|
||||||
|
// 12s 内连第一页都没画出来就兜底(弱网/被拦截)
|
||||||
|
const guard = window.setTimeout(() => { if (!renderedAny) showFallback(false); }, 12000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfjs: any = await import('pdfjs-dist');
|
||||||
|
const workerUrl = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||||
|
doc = await pdfjs.getDocument({ url: wrap.dataset.pdf }).promise;
|
||||||
|
window.clearTimeout(guard);
|
||||||
|
setStatus('');
|
||||||
|
if (toolbar) toolbar.hidden = false;
|
||||||
|
|
||||||
|
// 先建全部页占位(页高按第一页比例估算,避免滚动跳动),IO 到达再真正 render
|
||||||
|
const first = await doc.getPage(1);
|
||||||
|
const baseVp = first.getViewport({ scale: 1 });
|
||||||
|
const wrapWidth = wrap.clientWidth || 720;
|
||||||
|
const renderScale = () => (wrapWidth / baseVp.width) * zoom * (window.devicePixelRatio || 1);
|
||||||
|
|
||||||
|
for (let n = 1; n <= doc.numPages; n++) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'paper-page';
|
||||||
|
el.dataset.page = String(n);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
el.appendChild(canvas);
|
||||||
|
wrap.appendChild(el);
|
||||||
|
pageEls.push({ el, canvas, page: n, rendered: false, ratio: baseVp.height / baseVp.width });
|
||||||
|
el.style.aspectRatio = `${baseVp.width} / ${baseVp.height}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPage = async (entry: (typeof pageEls)[number]) => {
|
||||||
|
if (entry.rendered) return;
|
||||||
|
entry.rendered = true;
|
||||||
|
const page = await doc.getPage(entry.page);
|
||||||
|
const vp = page.getViewport({ scale: renderScale() });
|
||||||
|
const c = entry.canvas;
|
||||||
|
c.width = vp.width;
|
||||||
|
c.height = vp.height;
|
||||||
|
c.style.width = '100%';
|
||||||
|
c.style.height = 'auto';
|
||||||
|
await page.render({ canvasContext: c.getContext('2d')!, viewport: vp, canvas: c }).promise;
|
||||||
|
entry.el.style.aspectRatio = '';
|
||||||
|
if (!renderedAny) {
|
||||||
|
renderedAny = true;
|
||||||
|
setStatus('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (!e.isIntersecting) continue;
|
||||||
|
const entry = pageEls.find((p) => p.el === e.target);
|
||||||
|
if (entry) void renderPage(entry);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '400px 0px' },
|
||||||
|
);
|
||||||
|
pageEls.forEach((p) => io!.observe(p.el));
|
||||||
|
|
||||||
|
// 页码跟随滚动(页中点越过视口 40% 即视为当前页)
|
||||||
|
const currentPage = () => {
|
||||||
|
const mid = window.scrollY + window.innerHeight * 0.4;
|
||||||
|
for (const p of pageEls) {
|
||||||
|
if (p.el.getBoundingClientRect().bottom + window.scrollY > mid) return p.page;
|
||||||
|
}
|
||||||
|
return pageEls.length;
|
||||||
|
};
|
||||||
|
const label = () => {
|
||||||
|
if (pageLabel) pageLabel.textContent = `${currentPage()} / ${doc.numPages}`;
|
||||||
|
};
|
||||||
|
addEventListener('scroll', label, { passive: true });
|
||||||
|
label();
|
||||||
|
|
||||||
|
const gotoPage = (n: number) => {
|
||||||
|
const target = pageEls[Math.max(0, Math.min(pageEls.length - 1, n - 1))];
|
||||||
|
target?.el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
document.getElementById('paper-prev')?.addEventListener('click', () => gotoPage(currentPage() - 1));
|
||||||
|
document.getElementById('paper-next')?.addEventListener('click', () => gotoPage(currentPage() + 1));
|
||||||
|
|
||||||
|
const rerenderVisible = async () => {
|
||||||
|
if (zoomLabel) zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
|
||||||
|
// 仅重绘当前视口内的页(其余等 IO 重新触发)
|
||||||
|
for (const p of pageEls) {
|
||||||
|
const r = p.el.getBoundingClientRect();
|
||||||
|
if (r.bottom > -200 && r.top < window.innerHeight + 200) {
|
||||||
|
p.rendered = false;
|
||||||
|
await renderPage(p);
|
||||||
|
} else {
|
||||||
|
p.rendered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById('paper-zoom-in')?.addEventListener('click', () => {
|
||||||
|
zoom = Math.min(2.5, zoom + 0.25);
|
||||||
|
void rerenderVisible();
|
||||||
|
});
|
||||||
|
document.getElementById('paper-zoom-out')?.addEventListener('click', () => {
|
||||||
|
zoom = Math.max(0.6, zoom - 0.25);
|
||||||
|
void rerenderVisible();
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
window.clearTimeout(guard);
|
||||||
|
fail(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('astro:page-load', () => void boot());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -161,19 +294,62 @@ const p = PAPERS[paper];
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.paper-download:hover { border-color: var(--accent); color: var(--accent); }
|
.paper-download:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* 工具条:灰阶克制,mono 小字 */
|
||||||
|
.paper-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.paper-toolbar[hidden] { display: none; }
|
||||||
|
.pt-group { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.pt-btn {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
color: var(--ink-1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pt-btn:hover { border-color: var(--accent); }
|
||||||
|
.pt-page { color: var(--ink-2); }
|
||||||
|
.pt-zoom { min-width: 3.2em; text-align: center; color: var(--ink-2); }
|
||||||
|
.pt-status { color: var(--ink-4); }
|
||||||
|
|
||||||
.paper-stage {
|
.paper-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.paper-frame {
|
/* canvas 页容器:纵向滚动,页间留缝,纸色底 */
|
||||||
width: 100%;
|
.paper-canvas-wrap {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 0;
|
overflow-y: auto;
|
||||||
background: var(--surface-2, #f4f4f2);
|
overscroll-behavior: contain;
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
background: var(--bg-soft);
|
||||||
}
|
}
|
||||||
/* 微信内不挂 iframe,避免空白框占位 */
|
.paper-canvas-wrap:empty { min-height: 12rem; }
|
||||||
:global(html.is-wechat) .paper-frame { display: none; }
|
.paper-page {
|
||||||
|
width: min(100%, calc(var(--measure) + 80px));
|
||||||
|
margin: 0 auto var(--space-3);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.paper-page canvas { display: block; width: 100%; }
|
||||||
.paper-fallback {
|
.paper-fallback {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -184,9 +360,9 @@ const p = PAPERS[paper];
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
background: var(--surface-2, #f4f4f2);
|
background: var(--bg-soft);
|
||||||
border: 1px dashed var(--line);
|
border: 1px dashed var(--line);
|
||||||
border-radius: var(--radius, 8px);
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.paper-fallback[hidden] { display: none; }
|
.paper-fallback[hidden] { display: none; }
|
||||||
.paper-fallback-title { font-weight: 600; margin: 0; }
|
.paper-fallback-title { font-weight: 600; margin: 0; }
|
||||||
|
|||||||
+41
-2
@@ -146,8 +146,10 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; }
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
/* 图注约定:紧跟媒体的 *斜体独立段* 视为图注(写作指南 docs/写作指南.md) */
|
/* 图注:**显式类驱动**(`<p class="fig-cap">说明</p>`)。此前按 `p > em:only-child` 自动命中,
|
||||||
.prose-reading p > em:only-child, .prose-tech p > em:only-child {
|
把正文里任何独占成段的斜体都变成块级居中——用户实测的排版 bug(斜体单独一行并居中)由此而来。 */
|
||||||
|
.prose-reading p.fig-cap, .prose-tech p.fig-cap,
|
||||||
|
.prose-reading p.fig-cap em, .prose-tech p.fig-cap em {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
@@ -155,7 +157,44 @@ pre code { background: none; border: none; padding: 0; font-size: inherit; }
|
|||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
margin-top: -0.6em;
|
margin-top: -0.6em;
|
||||||
margin-bottom: 1.2em;
|
margin-bottom: 1.2em;
|
||||||
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
/* 行内斜体基线:不换行、不居中(正常强调语义) */
|
||||||
|
.prose-reading em, .prose-tech em,
|
||||||
|
.prose-reading i, .prose-tech i {
|
||||||
|
display: inline;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
/* ── 受限排版类(文章内通过 <span class> 使用;rehype 白名单只放行这些类名) ──
|
||||||
|
颜色一律走 tokens,亮暗双态都可读;禁任意 hex/内联样式(保持灰阶骨架)。 */
|
||||||
|
.prose-reading .ink-1, .prose-tech .ink-1 { color: var(--ink-1); }
|
||||||
|
.prose-reading .ink-2, .prose-tech .ink-2 { color: var(--ink-2); }
|
||||||
|
.prose-reading .ink-3, .prose-tech .ink-3 { color: var(--ink-3); }
|
||||||
|
.prose-reading .ink-muted, .prose-tech .ink-muted { color: var(--ink-4); }
|
||||||
|
.prose-reading .ink-accent, .prose-tech .ink-accent { color: var(--accent); }
|
||||||
|
.prose-reading .font-serif, .prose-tech .font-serif { font-family: var(--font-serif); }
|
||||||
|
.prose-reading .font-kai, .prose-tech .font-kai { font-family: var(--font-kai); }
|
||||||
|
.prose-reading .font-sans, .prose-tech .font-sans { font-family: var(--font-sans); }
|
||||||
|
.prose-reading .font-mono, .prose-tech .font-mono { font-family: var(--font-mono); font-size: 0.92em; }
|
||||||
|
/* 文学常用:首字下沉(书评开篇)与金句块(引文式的独立强调段) */
|
||||||
|
.prose-reading p.drop-cap::first-letter, .prose-tech p.drop-cap::first-letter {
|
||||||
|
float: left;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 3.1em;
|
||||||
|
line-height: 1;
|
||||||
|
padding-right: 0.08em;
|
||||||
|
margin-top: 0.05em;
|
||||||
|
color: var(--paper-ink);
|
||||||
|
}
|
||||||
|
.prose-reading .quote-line, .prose-tech .quote-line {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-kai);
|
||||||
|
color: var(--paper-ink);
|
||||||
|
border-left: 2px solid var(--paper-line);
|
||||||
|
padding-left: 0.9em;
|
||||||
|
margin: 1.4em 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* 视频与音频:原生标签,markdown raw HTML 直书(指南有模板) */
|
/* 视频与音频:原生标签,markdown raw HTML 直书(指南有模板) */
|
||||||
.prose-reading video, .prose-tech video,
|
.prose-reading video, .prose-tech video,
|
||||||
.prose-reading audio, .prose-tech audio {
|
.prose-reading audio, .prose-tech audio {
|
||||||
|
|||||||
+15
-3
@@ -91,9 +91,21 @@ ok('en 页 canonical 自指', !!enCanon && new URL(enCanon).pathname === new URL
|
|||||||
|
|
||||||
// ---------- 折叠批:首页限 6 篇 / 归档年折叠 / 书架年折叠 ----------
|
// ---------- 折叠批:首页限 6 篇 / 归档年折叠 / 书架年折叠 ----------
|
||||||
await page.goto(BASE + '/', { waitUntil: 'networkidle' });
|
await page.goto(BASE + '/', { waitUntil: 'networkidle' });
|
||||||
const homeRows = await page.$$eval('.post-list .row', (as) => as.length);
|
const homeTracks = await page.evaluate(() =>
|
||||||
const homeMore = await page.locator('.more-row a[href="/archives/"]').count();
|
[...document.querySelectorAll('.track-sec')].map((sec) => ({
|
||||||
ok('首页 ≤6 篇 + 全部文章入口', homeRows > 0 && homeRows <= 6 && homeMore === 1, `条数=${homeRows} 入口=${homeMore}`);
|
label: sec.getAttribute('aria-label'),
|
||||||
|
rows: sec.querySelectorAll('.row.tap').length,
|
||||||
|
more: sec.querySelectorAll('.more-link').length,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
'首页分轨 阅读/技术 各 ≤6 + 各自更多入口',
|
||||||
|
homeTracks.length === 2 &&
|
||||||
|
homeTracks.every((t) => t.rows > 0 && t.rows <= 6 && t.more === 1) &&
|
||||||
|
homeTracks[0].label === '阅读' &&
|
||||||
|
homeTracks[1].label === '技术',
|
||||||
|
JSON.stringify(homeTracks),
|
||||||
|
);
|
||||||
|
|
||||||
await page.goto(BASE + '/archives/', { waitUntil: 'networkidle' });
|
await page.goto(BASE + '/archives/', { waitUntil: 'networkidle' });
|
||||||
const arch = await page.evaluate(() => {
|
const arch = await page.evaluate(() => {
|
||||||
|
|||||||
Reference in new issue
Block a user