diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..609debe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +# 部署由 Cloudflare Pages 自己盯着 main 分支做,这里只负责验证。 +# 关键一项是 pnpm build:docusaurus.config.ts 设了 onBrokenLinks: "throw", +# 但那只在有人真的跑构建时才生效——没有 CI 的话,带坏链的改动会一路合进 +# main,直到部署失败或有人本地构建才被发现。 +on: + push: + branches: [main] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # 先跑最便宜的:图片体积与字体子集覆盖 + - name: 资源护栏 + run: pnpm check:assets + + - name: 类型检查 + run: pnpm typecheck + + - name: 编辑器往返测试 + run: pnpm test + + # 顺带校验站内链接(onBrokenLinks: throw) + - name: 构建 + run: pnpm build diff --git a/.gitignore b/.gitignore index cf59d89..8552bd8 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ yarn-error.log* .reasonix/ .pnpm-store/ + +# 字体子集源(pnpm fonts 自动下载,约 22MB) +.cache-fonts/ diff --git a/README.md b/README.md index 28d7a24..a69271d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # MidSoul-Wiki

- +

MidSoul-Wiki 是 [Heart Fire Project](https://github.com/Heart-Fire-Project) 旗下项目 **午夜灵魂** 的官方文档与维基站点。 @@ -84,6 +84,18 @@ MidSoul-Wiki 是 [Heart Fire Project](https://github.com/Heart-Fire-Project) 旗 - **表格**:支持表头上色、单元格染色、合并单元格、行内换行与列表;长表格发布后自动获得 sticky 表头 - **行内标签/上色/锚点**:编辑器工具栏提供对应按钮,生成的格式全部为发布页所支持 +## 🔤 字体 + +标题使用自托管的 **Noto Serif SC 子集**(`src/css/fonts/`),正文使用系统字体栈。不走 Google Fonts —— 那个域名对国内读者基本不可达,且 `@import` 会阻塞渲染。 + +子集按**全站出现过的汉字**裁剪(当前约 1176 字,两个字重合计约 470KB)。**新增文档若用到了子集之外的字,标题里那几个字会回落系统宋体**,需要重新生成: + +```bash +pnpm fonts +``` + +需要 [uv](https://docs.astral.sh/uv/)(用来跑 fonttools)。源字体会自动下载并缓存到 `.cache-fonts/`(已 gitignore)。CI 会在仓库用字超出 `scripts/font-charset.txt` 时报错提醒。 + ## 🚀 部署 本项目由 Cloudflare Pages 自动托管,推送到 `main` 分支后自动构建部署。 diff --git a/docs/intro.md b/docs/intro.md index ad72929..00e6b95 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -1,6 +1,7 @@ --- sidebar_position: 1 slug: / +description: 午夜灵魂(MidSoul)是一张基于 Minecraft Java 版的不对称竞技地图,由心火计划制作。本 Wiki 收录游戏的全部机制、模式、地图、能力、回响、进度与饰品资料。 --- # MidSoul Wiki diff --git a/docs/intro.tiptap.json b/docs/intro.tiptap.json index 236ab00..bf56ab8 100644 --- a/docs/intro.tiptap.json +++ b/docs/intro.tiptap.json @@ -1,6 +1,6 @@ { "format": "tiptap-v1", - "frontmatter": "---\nsidebar_position: 1\nslug: /\n---\n", + "frontmatter": "---\nsidebar_position: 1\nslug: /\ndescription: 午夜灵魂(MidSoul)是一张基于 Minecraft Java 版的不对称竞技地图,由心火计划制作。本 Wiki 收录游戏的全部机制、模式、地图、能力、回响、进度与饰品资料。\n---\n", "content": { "type": "doc", "content": [ diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/1. \345\205\250\345\261\200\346\234\272\345\210\266.md" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/1. \345\205\250\345\261\200\346\234\272\345\210\266.md" index f889905..3cc6593 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/1. \345\205\250\345\261\200\346\234\272\345\210\266.md" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/1. \345\205\250\345\261\200\346\234\272\345\210\266.md" @@ -1,5 +1,5 @@ --- -sidebar_position: 1 +description: 午夜灵魂的基础系统:灵魂与灵魂守卫者的玩家状态,灵魂碎片、灵魂之灯、灵魂宝物盒与传送门等游戏物件,以及气息探测、玩家等级等全局机制。 --- # 全局机制 diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/1. \345\205\250\345\261\200\346\234\272\345\210\266.tiptap.json" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/1. \345\205\250\345\261\200\346\234\272\345\210\266.tiptap.json" index 86ea7db..7222f3e 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/1. \345\205\250\345\261\200\346\234\272\345\210\266.tiptap.json" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/1. \345\205\250\345\261\200\346\234\272\345\210\266.tiptap.json" @@ -1,6 +1,6 @@ { "format": "tiptap-v1", - "frontmatter": "---\nsidebar_position: 1\n---\n", + "frontmatter": "---\ndescription: 午夜灵魂的基础系统:灵魂与灵魂守卫者的玩家状态,灵魂碎片、灵魂之灯、灵魂宝物盒与传送门等游戏物件,以及气息探测、玩家等级等全局机制。\n---\n", "content": { "type": "doc", "content": [ diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/2. \346\250\241\345\274\217\344\273\213\347\273\215.md" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/2. \346\250\241\345\274\217\344\273\213\347\273\215.md" index 79d8b9f..51344a9 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/2. \346\250\241\345\274\217\344\273\213\347\273\215.md" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/2. \346\250\241\345\274\217\344\273\213\347\273\215.md" @@ -1,5 +1,5 @@ --- -sidebar_position: 1 +description: 午夜灵魂两个游戏模式的完整流程与机制:灵气碎片的灵能反噬、最终锚点、多守卫特殊机制与表现分算法,以及快节奏的针锋奔逃。 --- # 模式介绍 diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/2. \346\250\241\345\274\217\344\273\213\347\273\215.tiptap.json" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/2. \346\250\241\345\274\217\344\273\213\347\273\215.tiptap.json" index 2a604f3..e2d3548 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/2. \346\250\241\345\274\217\344\273\213\347\273\215.tiptap.json" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/2. \346\250\241\345\274\217\344\273\213\347\273\215.tiptap.json" @@ -1,6 +1,6 @@ { "format": "tiptap-v1", - "frontmatter": "---\nsidebar_position: 1\n---\n", + "frontmatter": "---\ndescription: 午夜灵魂两个游戏模式的完整流程与机制:灵气碎片的灵能反噬、最终锚点、多守卫特殊机制与表现分算法,以及快节奏的针锋奔逃。\n---\n", "content": { "type": "doc", "content": [ diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/3. \345\234\260\345\233\276\345\257\274\350\247\210.md" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/3. \345\234\260\345\233\276\345\257\274\350\247\210.md" index f58905f..ab8783b 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/3. \345\234\260\345\233\276\345\257\274\350\247\210.md" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/3. \345\234\260\345\233\276\345\257\274\350\247\210.md" @@ -1,5 +1,5 @@ --- -sidebar_position: 1 +description: 午夜灵魂 6 张内置地图——聚光圣殿、镇灵塔楼、山间湖谷、心火特区、昔居浮屿、苏园夜游——的俯视图、数据与机制,附虚空惩罚与自定义地图说明。 --- # 地图导览 @@ -12,7 +12,7 @@ sidebar_position: 1 > 匿于峡间的小镇,圣殿在此祝福各位 -![](/img/maps/聚光圣殿.PNG) +![](/img/maps/聚光圣殿.webp) 镇压灵魂之所,复生与焚尽,皆在一念之间 -![](/img/maps/镇灵塔楼.PNG) +![](/img/maps/镇灵塔楼.webp) 地图原筑:[Hide & Seek: Panda Village](https://www.minecraftmaps.com/48643-hide-seek-panda-village) by Kapanas & oozner @@ -40,7 +40,7 @@ sidebar_position: 1 > 平静的湖面波澜不起,本该热闹的村庄却也寂静 -![](/img/maps/山间湖谷.png) +![](/img/maps/山间湖谷.webp) 荒境环抱无处可逃,逸散灵魂如影随梢 -![](/img/maps/心火特区.png) +![](/img/maps/心火特区.webp) 地图构建:Mol1Sama feat. Roser [Original] @@ -70,7 +70,7 @@ sidebar_position: 1 > 原浮空城地块,因多起高空事故而封闭 -![](/img/maps/昔居浮屿.PNG) +![](/img/maps/昔居浮屿.webp) 地图构建:Mol1Sama [Original] @@ -87,7 +87,7 @@ sidebar_position: 1 > 有堂有庭,有桥有船。有书有酒,有歌有弦。 -![](/img/maps/苏园夜游.PNG) +![](/img/maps/苏园夜游.webp) 地图原筑:[苍园](https://www.bilibili.com/video/BV1KgBEYYEDh) by 苍蓝之水手 diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/3. \345\234\260\345\233\276\345\257\274\350\247\210.tiptap.json" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/3. \345\234\260\345\233\276\345\257\274\350\247\210.tiptap.json" index 9ee271f..ad1bb98 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/3. \345\234\260\345\233\276\345\257\274\350\247\210.tiptap.json" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/3. \345\234\260\345\233\276\345\257\274\350\247\210.tiptap.json" @@ -1,6 +1,6 @@ { "format": "tiptap-v1", - "frontmatter": "---\nsidebar_position: 1\n---\n", + "frontmatter": "---\ndescription: 午夜灵魂 6 张内置地图——聚光圣殿、镇灵塔楼、山间湖谷、心火特区、昔居浮屿、苏园夜游——的俯视图、数据与机制,附虚空惩罚与自定义地图说明。\n---\n", "content": { "type": "doc", "content": [ @@ -76,7 +76,7 @@ { "type": "image", "attrs": { - "src": "/img/maps/聚光圣殿.PNG", + "src": "/img/maps/聚光圣殿.webp", "alt": "", "title": null, "width": null, @@ -523,7 +523,7 @@ { "type": "image", "attrs": { - "src": "/img/maps/镇灵塔楼.PNG", + "src": "/img/maps/镇灵塔楼.webp", "alt": "", "title": null, "width": null, @@ -1002,7 +1002,7 @@ { "type": "image", "attrs": { - "src": "/img/maps/山间湖谷.png", + "src": "/img/maps/山间湖谷.webp", "alt": null, "title": null, "width": null, @@ -1429,7 +1429,7 @@ { "type": "image", "attrs": { - "src": "/img/maps/心火特区.png", + "src": "/img/maps/心火特区.webp", "alt": null, "title": null, "width": null, @@ -1895,7 +1895,7 @@ { "type": "image", "attrs": { - "src": "/img/maps/昔居浮屿.PNG", + "src": "/img/maps/昔居浮屿.webp", "alt": null, "title": null, "width": null, @@ -2361,7 +2361,7 @@ { "type": "image", "attrs": { - "src": "/img/maps/苏园夜游.PNG", + "src": "/img/maps/苏园夜游.webp", "alt": null, "title": null, "width": null, diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/4. \350\203\275\345\212\233\344\270\200\350\247\210.md" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/4. \350\203\275\345\212\233\344\270\200\350\247\210.md" index c162099..5cbd4f7 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/4. \350\203\275\345\212\233\344\270\200\350\247\210.md" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/4. \350\203\275\345\212\233\344\270\200\350\247\210.md" @@ -1,5 +1,5 @@ --- -sidebar_position: 1 +description: 午夜灵魂全部 10 个技能、14 个天赋与 7 件灵魂宝物的冷却时间、持续时间与效果数据,按灵魂方与灵魂守卫者方分列。 --- # 能力一览 diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/4. \350\203\275\345\212\233\344\270\200\350\247\210.tiptap.json" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/4. \350\203\275\345\212\233\344\270\200\350\247\210.tiptap.json" index cc54b12..08bcad6 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/4. \350\203\275\345\212\233\344\270\200\350\247\210.tiptap.json" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/4. \350\203\275\345\212\233\344\270\200\350\247\210.tiptap.json" @@ -1,6 +1,6 @@ { "format": "tiptap-v1", - "frontmatter": "---\nsidebar_position: 1\n---\n", + "frontmatter": "---\ndescription: 午夜灵魂全部 10 个技能、14 个天赋与 7 件灵魂宝物的冷却时间、持续时间与效果数据,按灵魂方与灵魂守卫者方分列。\n---\n", "content": { "type": "doc", "content": [ diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/5. \345\233\236\345\223\215\350\256\260\345\275\225.md" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/5. \345\233\236\345\223\215\350\256\260\345\275\225.md" index 4e2e781..09d1e97 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/5. \345\233\236\345\223\215\350\256\260\345\275\225.md" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/5. \345\233\236\345\223\215\350\256\260\345\275\225.md" @@ -1,5 +1,5 @@ --- -sidebar_position: 1 +description: 午夜灵魂全部回响的详细信息:强制性引导、灵魂鉴碎片、开盒得技能、天幕降落雷、命运交响曲等,含各自的触发条件与效果改动。 --- # 回响记录 diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/5. \345\233\236\345\223\215\350\256\260\345\275\225.tiptap.json" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/5. \345\233\236\345\223\215\350\256\260\345\275\225.tiptap.json" index d31d4b5..1f3901e 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/5. \345\233\236\345\223\215\350\256\260\345\275\225.tiptap.json" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/5. \345\233\236\345\223\215\350\256\260\345\275\225.tiptap.json" @@ -1,6 +1,6 @@ { "format": "tiptap-v1", - "frontmatter": "---\nsidebar_position: 1\n---\n", + "frontmatter": "---\ndescription: 午夜灵魂全部回响的详细信息:强制性引导、灵魂鉴碎片、开盒得技能、天幕降落雷、命运交响曲等,含各自的触发条件与效果改动。\n---\n", "content": { "type": "doc", "content": [ diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/6. \350\277\233\345\272\246\347\242\221\345\210\273.md" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/6. \350\277\233\345\272\246\347\242\221\345\210\273.md" index d730863..ed638c3 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/6. \350\277\233\345\272\246\347\242\221\345\210\273.md" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/6. \350\277\233\345\272\246\347\242\221\345\210\273.md" @@ -1,5 +1,5 @@ --- -sidebar_position: 1 +description: 午夜灵魂全部进度成就的解锁条件、描述与进度点奖励,涵盖教程、装饰品解锁,以及沉浸、得胜、高光、捡拾、处决五类历程进度。 --- # 进度碑刻 @@ -27,7 +27,7 @@ sidebar_position: 1 ['**五号屠场**', '完成 5 场游戏', '您初出茅庐。', '5', '-'], ['**二十首情诗**', '完成 20 场游戏', '您渐入佳境。', '5', '-'], ['**五十度灰**', '完成 50 场游戏', '您驾轻就熟。', '10', '-'], - ['**s百年孤独**', '完成 100 场游戏', '您持之以恒。', '10', '-'], + ['**百年孤独**', '完成 100 场游戏', '您持之以恒。', '10', '-'], [{t:'**二百周年的人**',bg:'#E5E6E9',op:0.55}, {t:'完成 200 场游戏',bg:'#E5E6E9',op:0.55}, {t:'您历久弥新。',bg:'#E5E6E9',op:0.55}, {t:'20',bg:'#E5E6E9',op:0.55}, {t:'-',bg:'#E5E6E9',op:0.55}], [{t:'**五百罗汉**',bg:'#E5E6E9',op:0.55}, {t:'完成 500 场游戏',bg:'#E5E6E9',op:0.55}, {t:'您已臻化境。',bg:'#E5E6E9',op:0.55}, {t:'20',bg:'#E5E6E9',op:0.55}, {t:'-',bg:'#E5E6E9',op:0.55}], ]}/> diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/6. \350\277\233\345\272\246\347\242\221\345\210\273.tiptap.json" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/6. \350\277\233\345\272\246\347\242\221\345\210\273.tiptap.json" index fe3c4a8..d3da170 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/6. \350\277\233\345\272\246\347\242\221\345\210\273.tiptap.json" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/6. \350\277\233\345\272\246\347\242\221\345\210\273.tiptap.json" @@ -1,6 +1,6 @@ { "format": "tiptap-v1", - "frontmatter": "---\nsidebar_position: 1\n---\n", + "frontmatter": "---\ndescription: 午夜灵魂全部进度成就的解锁条件、描述与进度点奖励,涵盖教程、装饰品解锁,以及沉浸、得胜、高光、捡拾、处决五类历程进度。\n---\n", "content": { "type": "doc", "content": [ @@ -862,7 +862,7 @@ "type": "bold" } ], - "text": "s百年孤独" + "text": "百年孤独" } ] } diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/7. \351\245\260\345\223\201\351\233\206\345\206\214.md" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/7. \351\245\260\345\223\201\351\233\206\345\206\214.md" index 2a3ea1d..5203c25 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/7. \351\245\260\345\223\201\351\233\206\345\206\214.md" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/7. \351\245\260\345\223\201\351\233\206\345\206\214.md" @@ -1,5 +1,5 @@ --- -sidebar_position: 1 +description: 午夜灵魂装饰品总览:粒子效果、武器样式与文本套组的完整列表和获取方法,以及饰品装配规则。 --- # 饰品集册 diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/7. \351\245\260\345\223\201\351\233\206\345\206\214.tiptap.json" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/7. \351\245\260\345\223\201\351\233\206\345\206\214.tiptap.json" index 29cd529..4939110 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/7. \351\245\260\345\223\201\351\233\206\345\206\214.tiptap.json" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/7. \351\245\260\345\223\201\351\233\206\345\206\214.tiptap.json" @@ -1,6 +1,6 @@ { "format": "tiptap-v1", - "frontmatter": "---\nsidebar_position: 1\n---\n", + "frontmatter": "---\ndescription: 午夜灵魂装饰品总览:粒子效果、武器样式与文本套组的完整列表和获取方法,以及饰品装配规则。\n---\n", "content": { "type": "doc", "content": [ diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/8. \351\245\260\345\223\201\351\233\206\345\206\214-\346\226\207\346\234\254\345\245\227\347\273\204\350\257\246\350\247\210.md" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/8. \351\245\260\345\223\201\351\233\206\345\206\214-\346\226\207\346\234\254\345\245\227\347\273\204\350\257\246\350\247\210.md" index dcb0419..2de4e68 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/8. \351\245\260\345\223\201\351\233\206\345\206\214-\346\226\207\346\234\254\345\245\227\347\273\204\350\257\246\350\247\210.md" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/8. \351\245\260\345\223\201\351\233\206\345\206\214-\346\226\207\346\234\254\345\245\227\347\273\204\350\257\246\350\247\210.md" @@ -1,5 +1,5 @@ --- -sidebar_position: 1 +description: 午夜灵魂文本套组四大系列——趋光、溯回、闲嬉、灵兽——的全部文本内容与解锁方式。 --- # 饰品集册/文本套组详览 diff --git "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/8. \351\245\260\345\223\201\351\233\206\345\206\214-\346\226\207\346\234\254\345\245\227\347\273\204\350\257\246\350\247\210.tiptap.json" "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/8. \351\245\260\345\223\201\351\233\206\345\206\214-\346\226\207\346\234\254\345\245\227\347\273\204\350\257\246\350\247\210.tiptap.json" index 928ea30..cf2bb94 100644 --- "a/docs/\345\215\210\345\244\234\347\201\265\351\255\202/8. \351\245\260\345\223\201\351\233\206\345\206\214-\346\226\207\346\234\254\345\245\227\347\273\204\350\257\246\350\247\210.tiptap.json" +++ "b/docs/\345\215\210\345\244\234\347\201\265\351\255\202/8. \351\245\260\345\223\201\351\233\206\345\206\214-\346\226\207\346\234\254\345\245\227\347\273\204\350\257\246\350\247\210.tiptap.json" @@ -1,6 +1,6 @@ { "format": "tiptap-v1", - "frontmatter": "---\nsidebar_position: 1\n---\n", + "frontmatter": "---\ndescription: 午夜灵魂文本套组四大系列——趋光、溯回、闲嬉、灵兽——的全部文本内容与解锁方式。\n---\n", "content": { "type": "doc", "content": [ diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 76a3c3a..a8824b2 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -105,7 +105,6 @@ const config: Config = { language: ["zh"], indexBlog: false, docsRouteBasePath: "/wiki", - searchBarShortcutHint: false, }, ], ], @@ -154,7 +153,8 @@ const config: Config = { minHeadingLevel: 2, maxHeadingLevel: 4, }, - image: "img/MidSoul-Logo.png", + // 分享卡片按 1.91:1 裁切,直接用 3.24:1 的 logo 会被裁掉两头。 + image: "img/og-cover.jpg", colorMode: { defaultMode: "dark", respectPrefersColorScheme: true, @@ -164,11 +164,13 @@ const config: Config = { hideOnScroll: true, logo: { alt: "MidSoul Wiki", - src: "img/MidSoul-Logo-Dark.png", - srcDark: "img/MidSoul-Logo.png", + src: "img/MidSoul-Logo-Dark.webp", + srcDark: "img/MidSoul-Logo.webp", href: "/", - width: 120, - height: 36, + // 原图 2400×741(3.24:1),导航栏按 2rem 高显示;写成 120×36(3.33:1) + // 会让浏览器按错误比例预留占位。这里按真实比例给 104×32。 + width: 104, + height: 32, }, items: [ { diff --git a/package.json b/package.json index a2443af..b9eb580 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", - "typecheck": "tsc" + "typecheck": "tsc", + "fonts": "node scripts/build-font-subset.mjs", + "test": "node src/components/editor/fromMarkdown.test.mjs && node src/components/editor/toMarkdown.test.mjs && node src/components/editor/tableIO.test.mjs", + "check:assets": "node scripts/check-assets.mjs" }, "dependencies": { "@docusaurus/core": "3.10.2", @@ -49,6 +52,7 @@ }, "devDependencies": { "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", "@docusaurus/tsconfig": "3.10.2", "@docusaurus/types": "3.10.2", "@types/mdx": "^2.0.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4a56ac..f61c2cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@docusaurus/module-type-aliases': specifier: 3.10.2 version: 3.10.2(@swc/core@1.15.47)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/plugin-content-docs': + specifier: 3.10.2 + version: 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(postcss@8.5.26))(@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8))(@rspack/core@1.7.12)(@swc/core@1.15.47)(debug@4.4.3)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) '@docusaurus/tsconfig': specifier: 3.10.2 version: 3.10.2 diff --git a/scripts/build-font-subset.mjs b/scripts/build-font-subset.mjs new file mode 100644 index 0000000..9cd6c32 --- /dev/null +++ b/scripts/build-font-subset.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * 生成自托管的 Noto Serif SC 子集。 + * + * 站点原先从 fonts.googleapis.com 加载 Noto Serif SC / Noto Sans SC。中文 + * 受众多半在国内,那个域名基本不可达,且 @import 写在 CSS 首行是阻塞渲染 + * 的。正文已回落系统字体栈(PingFang SC / 微软雅黑),标题衬线体是设计 + * 主特征,改成自托管子集。 + * + * pnpm fonts + * + * 需要 uv(https://docs.astral.sh/uv/)——用它跑 fonttools,不污染系统 + * Python。源字体从 jsDelivr 拉,缓存在 .cache-fonts/(已 gitignore)。 + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { ROOT, CHARSET_MANIFEST, collectCharset } from './charset.mjs'; + +const CACHE = path.join(ROOT, '.cache-fonts'); +// 放在 src/css 下而不是 static/:@font-face 用相对 url() 交给打包器处理, +// 才能自动带上 baseUrl(/midsoul/)并加内容哈希。 +const OUT = path.join(ROOT, 'src', 'css', 'fonts'); +const SOURCE = 'https://cdn.jsdelivr.net/gh/notofonts/noto-cjk@main/Serif/SubsetOTF/SC'; + +// 400:h1/h2/h3、页脚标题、翻页标签、首页大标题。 +// 700:Infima 的 --ifm-heading-font-weight,作用于 h4~h6 与博客标题。 +// 600 全站没有任何规则用到,不生成。 +const WEIGHTS = [ + { weight: 400, source: 'NotoSerifSC-Regular.otf' }, + { weight: 700, source: 'NotoSerifSC-Bold.otf' }, +]; + +function download(name) { + const target = path.join(CACHE, name); + if (fs.existsSync(target)) return target; + fs.mkdirSync(CACHE, { recursive: true }); + console.log(`下载 ${name}(约 11MB,只需一次)…`); + execFileSync('curl', ['-sfL', '--max-time', '300', `${SOURCE}/${name}`, '-o', target]); + return target; +} + +const charset = collectCharset(); +fs.mkdirSync(OUT, { recursive: true }); +fs.mkdirSync(CACHE, { recursive: true }); +// CI 用这份清单比对:仓库里出现了子集外的字就提示重新生成,不必解析字体。 +fs.writeFileSync(CHARSET_MANIFEST, charset); +const charsetFile = path.join(CACHE, 'charset.txt'); +fs.writeFileSync(charsetFile, charset); +console.log(`字符集:${charset.length} 个中日韩字符 + 拉丁基本区`); + +for (const { weight, source } of WEIGHTS) { + const input = download(source); + const output = path.join(OUT, `NotoSerifSC-${weight}.woff2`); + execFileSync('uvx', [ + '--quiet', '--from', 'fonttools[woff]', 'pyftsubset', input, + `--output-file=${output}`, + '--flavor=woff2', + `--text-file=${charsetFile}`, + // 拉丁基本区 + 常用符号:标题里混排英文与数字。 + '--unicodes=U+0020-007E,U+00A0-00FF,U+2010-2027,U+2030-205E', + '--no-hinting', + '--desubroutinize', + '--drop-tables+=DSIG', + ], { stdio: 'inherit' }); + console.log(`✓ NotoSerifSC-${weight}.woff2 ${(fs.statSync(output).size / 1024).toFixed(1)} KB`); +} diff --git a/scripts/charset.mjs b/scripts/charset.mjs new file mode 100644 index 0000000..21d2dc5 --- /dev/null +++ b/scripts/charset.mjs @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const ROOT = path.resolve(import.meta.dirname, '..'); +export const CHARSET_MANIFEST = path.join(import.meta.dirname, 'font-charset.txt'); + +/** 标题可能用到的文字来源:文档与博客正文,以及配置和首页里写死的中文串。 */ +const CONTENT_DIRS = [ + { dir: 'docs', match: /\.mdx?$/ }, + { dir: 'blog', match: /\.mdx?$/ }, + { dir: 'src/pages', match: /\.tsx$/ }, +]; +const CONTENT_FILES = ['docusaurus.config.ts']; + +const CJK = /[ -〿㐀-䶿一-鿿＀-￯]/; + +function walk(dir, match, found = []) { + if (!fs.existsSync(dir)) return found; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, match, found); + else if (match.test(entry.name)) found.push(full); + } + return found; +} + +/** + * 全站出现过的中日韩字符,排序去重。 + * + * 取整站用字而不是只取标题用字:标题是会变的,用整站词汇留余量,否则新 + * 标题里的字一旦落在子集外,就会逐字回落系统宋体,同一行出现两种字形。 + */ +export function collectCharset() { + const files = [ + ...CONTENT_DIRS.flatMap(({ dir, match }) => walk(path.join(ROOT, dir), match)), + ...CONTENT_FILES.map((file) => path.join(ROOT, file)), + ]; + const chars = new Set(); + for (const file of files) { + for (const char of fs.readFileSync(file, 'utf8')) if (CJK.test(char)) chars.add(char); + } + return [...chars].sort().join(''); +} diff --git a/scripts/check-assets.mjs b/scripts/check-assets.mjs new file mode 100644 index 0000000..1230187 --- /dev/null +++ b/scripts/check-assets.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +/** + * 资源护栏,CI 里跑。 + * + * 1. static/img 单文件不得超过 MAX_IMAGE_BYTES。站点曾经因为一张 2400×741 + * 的 logo(860KB,实际只渲染 104×32)和 6 张未压缩的地图 PNG(合计 + * 8.3MB)而首屏极重。压完之后需要一道闸,否则下次有人直接拖张原图进来 + * 就白做了。 + * 2. 仓库用字必须落在 scripts/font-charset.txt 之内。超出的字在标题里会 + * 逐字回落系统宋体,同一行出现两种字形——提示跑 `pnpm fonts` 重生成。 + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { ROOT, CHARSET_MANIFEST, collectCharset } from './charset.mjs'; + +const MAX_IMAGE_BYTES = 500 * 1024; +const IMAGE_DIR = path.join(ROOT, 'static', 'img'); +const IMAGE_FILE = /\.(?:avif|gif|jpe?g|png|svg|webp)$/i; + +const problems = []; + +function checkImages(dir) { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { checkImages(full); continue; } + if (!IMAGE_FILE.test(entry.name)) continue; + const size = fs.statSync(full).size; + if (size > MAX_IMAGE_BYTES) { + problems.push( + `图片过大:${path.relative(ROOT, full)} 为 ${(size / 1024).toFixed(0)}KB,` + + `上限 ${MAX_IMAGE_BYTES / 1024}KB。请压缩后再提交(截图类内容建议 cwebp -q 82)。`, + ); + } + } +} + +function checkCharset() { + if (!fs.existsSync(CHARSET_MANIFEST)) { + problems.push('缺少 scripts/font-charset.txt,请运行 `pnpm fonts` 生成。'); + return; + } + const covered = new Set(fs.readFileSync(CHARSET_MANIFEST, 'utf8')); + const missing = [...collectCharset()].filter((char) => !covered.has(char)); + if (missing.length > 0) { + problems.push( + `字体子集缺少 ${missing.length} 个字:${missing.slice(0, 30).join('')}` + + `${missing.length > 30 ? '…' : ''}\n 这些字出现在标题里时会回落系统宋体。请运行 \`pnpm fonts\` 重新生成子集。`, + ); + } +} + +checkImages(IMAGE_DIR); +checkCharset(); + +if (problems.length > 0) { + console.error('资源检查未通过:\n'); + for (const problem of problems) console.error(` ✗ ${problem}\n`); + process.exit(1); +} +console.log('✓ 资源检查通过'); diff --git a/scripts/font-charset.txt b/scripts/font-charset.txt new file mode 100644 index 0000000..97a3bfc --- /dev/null +++ b/scripts/font-charset.txt @@ -0,0 +1 @@ +、。《》「」一万丈三上下不与且世丘丛东丢两严个中为主久么义之乐乘九也书了予事二于互五些亡交亦产享亮人什仅介仍从仓他仙代令以们件任伏众优会传伤伴伺似但位低住体何余作你佳使例供依侦侧侵保信修俯倍倒候值假偏停偶储傲僻儿允元充先光免入全八公六共关其具兽内册再冒冕写军冠冥冰冲决况冷净准减凝出击刀刃分切划列则初判利别到制刷刺刻削前剑剧剩力功加务动助劫励势勇包化区匿十升午半华协单占卡卫印即却厅历压原厨去参又及友双反发取受变叠句另叩只召可史右叶号吃各合同名后向否吧含听启吸告员呢周味命和咒咔品哈响哦唤唯啊啸喜喵器噬四回因园困围固国图圣在地场均坏块坪垂型垠埋城基堂塑塔境墙增墨士壮声壳处复外多夜大天失头夺奇奋奏奔奖套好如妨始娱嬉子字存孤它守安完定宝实室害家容宿寂寒寝寸对寻导封射将小少尔尖尘尚尝就尺尽尾局层居屈屏展属屠屡山屿岁岸峡巡工左差己已布带帮常幅幕干平年并幻幽庄序庐库应底度庭延建开异弃式引张弥弦弱弹强归当录形彩影彻彼往征径待律得循微心必志快念态怎思急性总恒恢恭息恶恻悉您悬情惊惜惩想愈意感愿慎懈戏成我戒或战截戮戴所扉手才打扣执扫扬扭找承技把抑抗抢护报抱拉拓拖招拥拦拨择拼拾持指按挑振捕损捡换据掉掌排探接控推描提援搭播操擦支收改攻放故效敌敏救教散数整文斗料斩断新方施旁旅无日旧时明昔星春是显晓晚普景晶暂暗暴曲更替最月有服期木未末本机杀杂权材村束条来板极构果枯架某染查标栏样核根格桃桑档桥桶梢梦棍棒楼概模横樱次欢欺歌止正此步武死殊残段殿毁每比毕毛气氤氲水永求汉汝池污汤汲沉没沦沧油治法波注泰活流浆测浏浓浮海浸消涉涵淡淬深添清渊渐渲渴游湎湖源溢溯滞满滴演漫潜澜瀚火灭灯灰灵灼炮炸点炽烁烈烙烛烟热焚焰然照熔熟燃爆爱父片版牌物牵特状狂独猎猫献率玩环现理瑟璀璨甜生用由甲画界留略疗疾痕痛瘪白百的皆盈盏盒盖盘目直相看真眠眼着睡瞥瞩瞬知短石破砸础确碍碎碑示社祈祛祝神福离种科秒秘秦称移程稿空穿窒窟立竞章端竹笔符第笼等筏筑策签简算管篇类粉粒粗粹精系素索紫累繁红约级纪纳线组细终绍绎经结绘给绚络绝统继绩绪续绯维综绽绿编缚缩网罗罚罩置署群翻者而耗聆聊联聚肉背胜胡能脉脑脚脱腾自至致臻舍航般船艘良艰色节芒花芽苍苏若苦英范茅草荒荫获萤营落著蓄蓝蔓薪藏虔虚虫虽蚀血行衡表被裁装西要见观规觅视览觉角解触言誉警计认让议讯记许论设证评诅译试诗诚询该详语误说请读谁调谋谢谨谷象负败质贯资赋赐赛赢走赴赶起超越趋足跃跑距跨路跳踏踪蹈蹑蹲身躯躲转轻载较辅辉辑输辛辰边达迁过迎运近还这进远连迭述迷迹追退送适逃逆选透逐递途通速造逮逸逻遇遏道遮遵避那邦郁部都配酒酷醒释里重量金鉴针钟钮钻铜铤铭银铸铺锁锋锐错锚锤锦键镇长门闪闭问闲间闸闹阅阈阎阑队防阳阴阵阶阻附际降限除陨险陷随隐隔障难雄雅集雨零雷雾需霜露霾静非靠面音韵页顶顷项顺须预题颜额风飘飞食饮饰首驻驾骁验骤骨髓高魂魄魔鱼鳕鸣鸿黄黑默齐!(),:;>?~ \ No newline at end of file diff --git a/src/components/Callout.module.css b/src/components/Callout.module.css index 03eb6ac..2061c87 100644 --- a/src/components/Callout.module.css +++ b/src/components/Callout.module.css @@ -4,13 +4,20 @@ gap: 0.75rem; margin: 1.5rem 0; padding: 0.9rem 1.1rem; - border: 1px solid var(--callout-border); + border: 1px solid var(--callout-border-l); border-radius: 8px; - background: var(--callout-background); - color: var(--callout-ink); + background: var(--callout-bg-l); + color: var(--callout-ink-l); line-height: 1.8; } +/* 暗色值随组件一起发出,切换主题纯靠 CSS,不等 React 重渲染。 */ +:global([data-theme='dark']) .callout { + border-color: var(--callout-border-d); + background: var(--callout-bg-d); + color: var(--callout-ink-d); +} + .emoji { flex: none; font-size: 1.15em; diff --git a/src/components/Callout.tsx b/src/components/Callout.tsx index 34a1084..7d61d0a 100644 --- a/src/components/Callout.tsx +++ b/src/components/Callout.tsx @@ -1,18 +1,20 @@ import React, { type CSSProperties, type ReactNode } from 'react'; -import { useColorMode } from '@docusaurus/theme-common'; import { CALLOUT, INK, type CalloutColor } from './theme'; import styles from './Callout.module.css'; export type CalloutProps = { color?: CalloutColor | string; emoji?: ReactNode; children?: ReactNode }; export default function Callout({ color = 'green', emoji, children }: CalloutProps) { - const { colorMode } = useColorMode(); - const isDark = colorMode === 'dark'; - const palette = (CALLOUT[color as CalloutColor] ?? CALLOUT.green)[isDark ? 'dark' : 'light']; + // 明暗两套都发出去,由 Callout.module.css 挑;在 JS 里二选一会让 SSR + // 按 defaultMode 烤死一种,另一种主题的读者首屏会闪一下。 + const palette = CALLOUT[color as CalloutColor] ?? CALLOUT.green; const style = { - '--callout-border': palette.border, - '--callout-background': palette.bg, - '--callout-ink': isDark ? INK.dark : INK.light, + '--callout-border-l': palette.light.border, + '--callout-border-d': palette.dark.border, + '--callout-bg-l': palette.light.bg, + '--callout-bg-d': palette.dark.bg, + '--callout-ink-l': INK.light, + '--callout-ink-d': INK.dark, } as CSSProperties & Record<`--${string}`, string>; return
{emoji && {emoji}} diff --git a/src/components/ColorTable.tsx b/src/components/ColorTable.tsx index 92170ce..1e3246f 100644 --- a/src/components/ColorTable.tsx +++ b/src/components/ColorTable.tsx @@ -1,5 +1,4 @@ import React, { useId, type ReactNode } from 'react'; -import { useColorMode } from '@docusaurus/theme-common'; import { darkBg } from './colorUtils'; import { INK } from './theme'; @@ -15,15 +14,21 @@ function parse(value: ColorValue): { bg?: string; text?: string } | null { } export default function ColorTable({ header, cols, rows, cells, hideHeader, noFirstCol, children }: ColorTableProps) { - const { colorMode } = useColorMode(); - const isDark = colorMode === 'dark'; const id = useId().replace(/:/g, ''); const selector = `#t${id}`; const rules: string[] = []; + // 明暗两套规则一起写进