diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 3320744735e..3e98939fc38 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -205,8 +205,43 @@ EdgeOne 构建阶段会按仓库中的 `.nvmrc` 切换 Node 版本。若控制
2. **自建仓库**:在 EdgeOne 控制台选择与 `.nvmrc` **完全一致**的 Node 版本;仍失败时检查构建日志是否仍在读取旧的 `.nvmrc`(需推送后再构建)。
3. `package.json` 中 `engines.node` 为 `>=20 <25`,在 Node 20 系列内均可构建;关键是 **构建环境实际安装的版本**能解析 `.nvmrc`。
+**兼容尝试(按顺序,任选其一即可,不必全做):**
+
+| 尝试 | 做法 |
+|------|------|
+| A. 对齐补丁号 | 打开 EdgeOne **项目设置 → Node.js 版本**,选择与仓库根目录 **`.nvmrc` 完全一致**的一项(例如均为 `20.18.0`),保存后 **重新部署**。 |
+| B. 改自建 fork | 若平台下拉列表**没有**当前 `.nvmrc` 里的补丁号:把 fork 里的 `.nvmrc` 改成平台**已有**的某一档(仍为 Node 20 即可),推送后再构建。 |
+| C. 清缓存 / 换分支 | 确认构建日志里 **Switching node** 读到的版本已更新;关闭「使用构建缓存」或触发一次无缓存构建,排除旧 `.nvmrc` 缓存。 |
+| D. `engines` 报错时 | 若日志是 **Yarn does not satisfy engine** 而非 **Switching node**:在构建环境设 `YARN_IGNORE_ENGINES=1`(仅当确为 engines 校验问题时使用)。 |
+| E. 不要用「只写 major」** | 少数平台不支持 `.nvmrc` 仅写 `20`;若遇解析错误,改为 **`20.18.0`** 这类完整补丁号。 |
+
+说明:Next.js 14 与当前依赖不要求锁在某一补丁版本;**兼容的核心是「平台实际能装上的 Node」与「`.nvmrc` / 控制台选择」一致**。
+
构建命令与静态导出等与其它平台相同,按需配置环境变量(至少 `NOTION_PAGE_ID`)。
+### `yarn install` 报 `ENOSPC: no space left on device`
+
+与 **Node 版本** 无关(日志里已出现 `Now, we're on node version v20.18.0` 即表示切换成功)。错误 **`ENOSPC`** 表示构建机 **磁盘或 `/dev/shm`(内存盘)空间不足**,在安装依赖从 cache 拷贝到 `node_modules` 时写满。
+
+**可尝试:**
+
+1. **向 EdgeOne / 腾讯云工单反馈**:说明构建任务在 `yarn install` 阶段 `ENOSPC`,申请更大构建盘或确认是否为平台侧临时配额。
+2. **减少单次写入体积**(若控制台支持自定义安装命令):
+ - 使用 `yarn install --frozen-lockfile --prefer-offline` 且**开启依赖缓存**(命中缓存时少下载);或
+ - 在构建前增加 `yarn cache clean`(会多下载,仅当怀疑缓存损坏时尝试,**不一定**缓解 ENOSPC)。
+3. **自定义 Yarn 缓存目录**(若平台文档支持挂载更大分区):设置环境变量 **`YARN_CACHE_FOLDER`** 到有足够剩余空间的路径(具体以 EdgeOne 构建环境说明为准)。
+4. **换构建方案**:在磁盘更大的 CI(如 GitHub Actions)完成 `yarn build` / `next build`,将产物同步到 EdgeOne(仅静态托管),绕过 Pages 内置构建机容量限制。
+
+**备选:怀疑「构建/文件缓存」把空间占满时**
+
+| 做法 | 适用阶段 | 说明 |
+|------|----------|------|
+| **关闭 EdgeOne「依赖/构建缓存」再构建** | `yarn install` 前后 | 若平台在恢复缓存后又解压一份 `node_modules`,可能出现「缓存 + 工作区」双份占用 **`/dev/shm`**。在控制台**关闭缓存恢复**后重试(构建会变慢,但有时能腾出空间;视平台实现而定)。 |
+| **`YARN_CACHE_FOLDER` 指到大磁盘路径** | `yarn install` | 避免 Yarn 默认缓存与仓库同落在小容量分区;路径以 **EdgeOne 官方文档** 为准。 |
+| **构建环境变量 `ENABLE_CACHE=false`** | **`next build` 阶段** | 关闭 Notion 数据**读**盘缓存为主,会**增加 Notion 请求与耗时**;**锁文件/会话目录仍可能写入**。对日志里 **`yarn install` 拷贝 `node_modules` 即失败**的 ENOSPC **通常无效**,因尚未执行到 Next 构建。 |
+
+仓库本身无法通过改 `package.json` 消除平台磁盘上限;根本解决依赖 **构建环境配额** 或 **外置构建**。
+
## Docker 部署
### Dockerfile
diff --git a/conf/themeSwitch.manifest.js b/conf/themeSwitch.manifest.js
index dedd68c5ee1..99239c6a79a 100644
--- a/conf/themeSwitch.manifest.js
+++ b/conf/themeSwitch.manifest.js
@@ -106,6 +106,10 @@ export const THEME_SWITCH_MANIFEST = {
claude: {
name: 'Claude',
summary: '类 Claude Docs 的文档与终端氛围。'
+ },
+ thoughtlite: {
+ name: 'ThoughtLite',
+ summary: '轻阅读向时间线与 Latest 卡片,单列列表与文章卡片排版。'
}
}
diff --git a/docs/themes/README.en.md b/docs/themes/README.en.md
index fd6ccd040f0..fd0ea2291a0 100644
--- a/docs/themes/README.en.md
+++ b/docs/themes/README.en.md
@@ -11,6 +11,7 @@ This directory centralizes documentation for all themes, including design intent
| Claude | [CLAUDE.en.md](./CLAUDE.en.md) | [CLAUDE.md](./CLAUDE.md) |
| Endspace | [ENDSPACE.en.md](./ENDSPACE.en.md) | [ENDSPACE.md](./ENDSPACE.md) |
| Fuwari | [FUWARI.md](./FUWARI.md) | [FUWARI.md](./FUWARI.md) |
+| ThoughtLite (WIP) | [THOUGHTLITE.en.md](./THOUGHTLITE.en.md) | [THOUGHTLITE.md](./THOUGHTLITE.md) · [plan (zh)](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md) |
## Maintenance Rules
diff --git a/docs/themes/README.md b/docs/themes/README.md
index e8cb18d9dd5..d99e7cd8145 100644
--- a/docs/themes/README.md
+++ b/docs/themes/README.md
@@ -11,6 +11,7 @@
| Claude | [CLAUDE.md](./CLAUDE.md) | [CLAUDE.en.md](./CLAUDE.en.md) |
| Endspace | [ENDSPACE.md](./ENDSPACE.md) | [ENDSPACE.en.md](./ENDSPACE.en.md) |
| Fuwari | [FUWARI.md](./FUWARI.md) | [FUWARI.md](./FUWARI.md) |
+| ThoughtLite(移植中) | [THOUGHTLITE.md](./THOUGHTLITE.md) | [THOUGHTLITE.en.md](./THOUGHTLITE.en.md) · [迁移计划](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md) |
## 维护约定
diff --git a/docs/themes/THOUGHTLITE.en.md b/docs/themes/THOUGHTLITE.en.md
new file mode 100644
index 00000000000..ec5f7d562c4
--- /dev/null
+++ b/docs/themes/THOUGHTLITE.en.md
@@ -0,0 +1,104 @@
+# ThoughtLite theme (NotionNext)
+
+[中文](./THOUGHTLITE.md) | Task plan (Chinese): [THOUGHTLITE_MIGRATION_PLAN.zh-CN.md](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md)
+
+This document is for **developers who maintain the ThoughtLite port** in NotionNext: why it exists, upstream provenance, original author repository, compliance notes, and day-to-day maintenance.
+
+---
+
+## 1. Why it was added
+
+- **Community request**: [Issue #3987](https://github.com/notionnext-org/NotionNext/issues/3987) proposed a new theme inspired by the open-source Astro theme **ThoughtLite** (timeline-style home, reading-first layout).
+- **Goal**: Provide an optional React skin that **stays compatible with NotionNext data contracts** (`posts`, `post`, `customNav`, comments, plugins, etc.) while **approximating ThoughtLite’s look and information architecture**.
+
+---
+
+## 2. Upstream, author, and repositories
+
+| Item | Link |
+|------|------|
+| **Upstream theme** | ThoughtLite (Astro) |
+| **Author** | [tuyuritio](https://github.com/tuyuritio) |
+| **Upstream source** | [tuyuritio/astro-theme-thought-lite](https://github.com/tuyuritio/astro-theme-thought-lite) |
+| **Live demo** | [thought-lite.ttio.workers.dev](https://thought-lite.ttio.workers.dev/) |
+| **Upstream stack** | Astro, Svelte, Tailwind CSS (see upstream README) |
+| **Upstream license** | **GPL-3.0** |
+
+NotionNext is primarily **MIT**. This theme is implemented as a **design-informed React rewrite**, **without copying** upstream `.astro` / `.svelte` source wholesale. If someone later wants to vendor GPL code paths, maintainers must assess **GPL/MIT compatibility** (see [migration plan §0](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md)).
+
+---
+
+## 3. Mapping in this repo
+
+| Item | Location |
+|------|----------|
+| **Theme folder** | `themes/thoughtlite/` |
+| **Root scope** | `#theme-thoughtlite` |
+| **Config keys** | `THOUGHTLITE_*` in `themes/thoughtlite/config.js` via `siteConfig(..., CONFIG)` |
+| **Baseline** | Started from `themes/example`, then restyled toward ThoughtLite |
+| **Shared features** | `@/components/*` (Notion body, comments, search, etc.) |
+
+---
+
+## 4. Features (maintenance map)
+
+- **Header**: title, horizontal nav (incl. custom menu), search, theme toggle (`Header.js`, `MenuList.js`, `MenuItemDrop.js`).
+- **Home**: optional **Latest** card; **timeline** grouped by `publishDay` (`HomeTimeline.js`, `BlogItem` `timeline` variant); respects global `POST_LIST_STYLE` (page vs scroll).
+- **Post**: card header, `PostMeta`, `NotionPage`, `ShareBar`, comments (`dynamic` without SSR), sidebar TOC (`LayoutSlug` in `index.js`).
+- **Archive / category / tag / search**: `TlPageHero`; archive uses the same rail style; taxonomy uses `tl-chip` (`style.js`).
+- **Footer**: copyright, beian, `PoweredBy`, plus **ThoughtLite name + upstream repo and author links** (`Footer.js`).
+- **Theme switcher**: `conf/themeSwitch.manifest.js` entry `thoughtlite`; previews under `public/images/themes-preview/thoughtlite.{png,webp}` (replace placeholders before release; optionally run `yarn perf:compress-theme-previews`).
+
+---
+
+## 5. Config keys (`themes/thoughtlite/config.js`)
+
+| Key | Purpose |
+|-----|---------|
+| `THOUGHTLITE_MENU_*` | Show/hide nav entries (category, tag, archive, search) |
+| `THOUGHTLITE_HOME_TIMELINE` | Timeline on bare home routes |
+| `THOUGHTLITE_HOME_LATEST_CARD` | Latest summary card on home |
+| `THOUGHTLITE_SIDEBAR_ONLY_ON_POST` | Sidebar only on post detail |
+| `THOUGHTLITE_POST_LIST_COVER` | Cover thumbnails in list mode |
+| `THOUGHTLITE_TITLE_IMAGE` | Hero background image on non-post title bar |
+| `THOUGHTLITE_HOME_MINIMAL_HEADER` | Minimal home title area |
+| `THOUGHTLITE_ARTICLE_LAYOUT_VERTICAL` | Stack main + sidebar on posts |
+| `THOUGHTLITE_ARTICLE_HIDDEN_NOTIFICATION` | Hide announcement on post |
+
+Global **`LAYOUT_SIDEBAR_REVERSE`** still applies at site level.
+
+---
+
+## 6. Enable
+
+```bash
+NEXT_PUBLIC_THEME=thoughtlite
+```
+
+Use `yarn dev` locally; `?theme=thoughtlite` may work where the app exposes theme switching.
+
+---
+
+## 7. Maintenance checklist
+
+1. Prefer editing **`#theme-thoughtlite`** tokens and classes in `themes/thoughtlite/style.js`.
+2. Layout entry points: `themes/thoughtlite/index.js` (`Layout*`); presentational pieces under `themes/thoughtlite/components/`.
+3. When **matching upstream visuals**, reference the [public demo](https://thought-lite.ttio.workers.dev/)—avoid pasting **GPL source files**; document any exception in the PR.
+4. Timeline relies on **`publishDay` / `publishDate`** from Notion mapping (`lib/db/notion/getPageProperties.js`).
+5. Cross-theme conventions: see [FUWARI.md](./FUWARI.md) and [THEME_MIGRATION_GUIDE](../THEME_MIGRATION_GUIDE.md).
+6. Refresh theme previews under `public/images/themes-preview/` when the UI changes materially.
+7. Smoke test: `yarn lint --dir themes/thoughtlite` and click through main routes with `NEXT_PUBLIC_THEME=thoughtlite`.
+
+---
+
+## 8. Links
+
+- Issue: [notionnext-org/NotionNext#3987](https://github.com/notionnext-org/NotionNext/issues/3987)
+- Upstream: [tuyuritio/astro-theme-thought-lite](https://github.com/tuyuritio/astro-theme-thought-lite)
+- Demo: [thought-lite.ttio.workers.dev](https://thought-lite.ttio.workers.dev/)
+
+---
+
+## 9. Status
+
+This is an **initial, merge-ready port**: core flows work; **further polish** (tokens, spacing, motion, a11y, plugin edge cases) is expected in follow-up PRs.
diff --git a/docs/themes/THOUGHTLITE.md b/docs/themes/THOUGHTLITE.md
new file mode 100644
index 00000000000..ccee6d3bd62
--- /dev/null
+++ b/docs/themes/THOUGHTLITE.md
@@ -0,0 +1,107 @@
+# ThoughtLite 主题(NotionNext)
+
+[English](./THOUGHTLITE.en.md) | 任务计划:[THOUGHTLITE_MIGRATION_PLAN.zh-CN.md](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md)
+
+本文面向**后续维护本主题的开发者**,说明主题加入原因、上游来源、原作者仓库、合规注意与日常维护要点。
+
+---
+
+## 1. 加入背景与原因
+
+- **社区提议**:用户在 [Issue #3987](https://github.com/notionnext-org/NotionNext/issues/3987) 中建议新增主题,参考对象为开源 Astro 主题 **ThoughtLite**,认为其阅读向、时间线式首页与轻量导航适合作为 NotionNext 的可选皮肤之一。
+- **产品目标**:在 **不破坏 NotionNext 既有数据契约**(`posts` / `post` / `customNav` / 评论与插件等)的前提下,提供一套**视觉与信息架构上贴近 ThoughtLite** 的 React 主题,便于站点一键切换试用。
+
+---
+
+## 2. 来源、原作者与仓库
+
+| 说明 | 链接 |
+|------|------|
+| **上游主题名** | ThoughtLite(Astro 生态) |
+| **原作者 GitHub** | [tuyuritio](https://github.com/tuyuritio) |
+| **上游源码仓库** | [tuyuritio/astro-theme-thought-lite](https://github.com/tuyuritio/astro-theme-thought-lite) |
+| **上游在线演示** | [thought-lite.ttio.workers.dev](https://thought-lite.ttio.workers.dev/) |
+| **上游技术栈** | Astro、Svelte、Tailwind CSS 等(见上游 README) |
+| **上游许可证** | **GPL-3.0** |
+
+NotionNext 本仓库主体为 **MIT**。本主题目录下的实现为 **基于上游「设计参考」在 Next.js + React 中重写**,**不直接复制**上游 `.astro` / `.svelte` 等 GPL 源码文本;若未来有人希望合入上游组件源码,须由维护者单独评估 **GPL 与 MIT 的兼容性**(详见 [迁移计划 §0](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md))。
+
+---
+
+## 3. 与本仓库的对应关系
+
+| 维度 | 说明 |
+|------|------|
+| **主题目录** | `themes/thoughtlite/` |
+| **根节点** | `#theme-thoughtlite`(样式与全局隔离) |
+| **配置前缀** | `THOUGHTLITE_*`,集中在 `themes/thoughtlite/config.js`,通过 `siteConfig('KEY', default, CONFIG)` 读取 |
+| **起点** | 初期由 `themes/example` 骨架复制并改名,再逐步替换为 ThoughtLite 取向的 UI |
+| **通用逻辑** | 评论、Notion 正文、搜索等复用 `@/components/*`,与其它主题一致 |
+
+---
+
+## 4. 功能概览(维护时对照)
+
+- **顶栏**:站点标题、横向导航(含自定义菜单)、搜索入口、深浅色切换(`components/Header.js`、`MenuList.js`、`MenuItemDrop.js`)。
+- **首页**:可选 **Latest** 卡片、按 **`publishDay`** 分组的时间线列表(`HomeTimeline.js`、`BlogItem` 的 `timeline` 变体);支持分页 / 滚动列表两种全局 `POST_LIST_STYLE`。
+- **文章页**:卡片式标题区、`PostMeta`、`NotionPage`、`ShareBar`、评论区(`dynamic` 关闭 SSR)、侧栏目录等(`index.js` 中 `LayoutSlug`)。
+- **归档 / 分类 / 标签 / 搜索**:统一 `TlPageHero` 页头;归档为时间线侧轨;分类与标签为 `tl-chip`(`style.js`)。
+- **页脚**:版权、备案、`PoweredBy`,以及 **ThoughtLite 主题名 + 上游仓库与原作者链接**(`components/Footer.js`)。
+- **主题切换**:`conf/themeSwitch.manifest.js` 中 `thoughtlite` 条目;预览图 `public/images/themes-preview/thoughtlite.{png,webp}`(当前可为占位图,发布前建议替换真实截图并执行 `yarn perf:compress-theme-previews`)。
+
+---
+
+## 5. 配置项一览(`themes/thoughtlite/config.js`)
+
+| 键 | 含义 |
+|----|------|
+| `THOUGHTLITE_MENU_CATEGORY` / `TAG` / `ARCHIVE` / `SEARCH` | 顶栏是否展示对应菜单项 |
+| `THOUGHTLITE_HOME_TIMELINE` | 纯首页是否使用按日时间线 |
+| `THOUGHTLITE_HOME_LATEST_CARD` | 首页是否展示 Latest 摘要块 |
+| `THOUGHTLITE_SIDEBAR_ONLY_ON_POST` | 是否仅在文章详情页显示侧栏 |
+| `THOUGHTLITE_POST_LIST_COVER` | 列表是否显示封面(时间线场景建议 `false`) |
+| `THOUGHTLITE_TITLE_IMAGE` | 非文章页的 TitleBar 是否使用背景大图 |
+| `THOUGHTLITE_HOME_MINIMAL_HEADER` | 首页是否隐藏大块 TitleBar |
+| `THOUGHTLITE_ARTICLE_LAYOUT_VERTICAL` | 文章页主区与侧栏是否改为上下堆叠 |
+| `THOUGHTLITE_ARTICLE_HIDDEN_NOTIFICATION` | 文章页是否隐藏公告 |
+
+全局项 **`LAYOUT_SIDEBAR_REVERSE`** 仍由站点级配置控制,与本主题 `LayoutBase` 共同作用。
+
+---
+
+## 6. 启用方式
+
+```bash
+# .env.local 或部署环境
+NEXT_PUBLIC_THEME=thoughtlite
+```
+
+本地开发:`yarn dev` 后访问站点;也可用 URL 参数 `?theme=thoughtlite` 在支持主题切换的场景下试用(以实际路由与配置为准)。
+
+---
+
+## 7. 迁移与维护事项(给后续开发者)
+
+1. **改样式优先** `themes/thoughtlite/style.js` 中的 **`#theme-thoughtlite`** 下 CSS 变量与类名,避免污染其它主题。
+2. **改布局与路由** 以 `themes/thoughtlite/index.js` 中各 `Layout*` 为入口;列表子组件在 `themes/thoughtlite/components/`。
+3. **对齐上游视觉** 时建议只对照 [上游演示站](https://thought-lite.ttio.workers.dev/) 与公开 UI 行为,**避免整文件拷贝**上游 GPL 源码;大段引用需在 PR 中说明合规路径。
+4. **数据字段**:时间线依赖 `post.publishDay`、`post.publishDate`(由 Notion 属性映射,见 `lib/db/notion/getPageProperties.js`);勿写死 Notion 内部 ID。
+5. **与其它主题对齐行为** 时,可参考 [FUWARI.md](./FUWARI.md) 与 [主题迁移指南(中文)](../THEME_MIGRATION_GUIDE.zh-CN.md) 中的菜单、评论、TOC、插件位等约定。
+6. **更新主题切换预览** 时替换 `public/images/themes-preview/thoughtlite.png`,并运行 `yarn perf:compress-theme-previews` 更新 webp(若仓库保留该脚本)。
+7. **合并前自检**:`yarn lint --dir themes/thoughtlite`、`NEXT_PUBLIC_THEME=thoughtlite` 下手动点一遍主要路由(首页、文章、归档、分类、标签、搜索、404、加密文)。
+
+更细的分阶段清单见 [THOUGHTLITE_MIGRATION_PLAN.zh-CN.md](./THOUGHTLITE_MIGRATION_PLAN.zh-CN.md)。
+
+---
+
+## 8. 相关链接汇总
+
+- 需求:[notionnext-org/NotionNext#3987](https://github.com/notionnext-org/NotionNext/issues/3987)
+- 上游仓库:[tuyuritio/astro-theme-thought-lite](https://github.com/tuyuritio/astro-theme-thought-lite)
+- 上游演示:[thought-lite.ttio.workers.dev](https://thought-lite.ttio.workers.dev/)
+
+---
+
+## 9. 当前阶段说明(给审阅者与贡献者)
+
+本主题为 **初步合并可用的实现**:核心列表与文章流已接通,视觉与交互仍会**按迭代优化**(色板、间距、动效、无障碍、与其它插件的边角兼容等)。合并 PR 不代表 Issue 中所有「像素级对齐上游」诉求已全部完成;后续可在同一主题目录内持续提交小 PR 演进。
diff --git a/docs/themes/THOUGHTLITE_MIGRATION_PLAN.zh-CN.md b/docs/themes/THOUGHTLITE_MIGRATION_PLAN.zh-CN.md
new file mode 100644
index 00000000000..ab4380a0710
--- /dev/null
+++ b/docs/themes/THOUGHTLITE_MIGRATION_PLAN.zh-CN.md
@@ -0,0 +1,103 @@
+# ThoughtLite 主题迁移任务计划(NotionNext)
+
+关联需求:[Issue #3987 · 新主题 ThoughtLite](https://github.com/notionnext-org/NotionNext/issues/3987)
+
+| 项目 | 链接 |
+|------|------|
+| 上游源码(Astro + Svelte + Tailwind) | [tuyuritio/astro-theme-thought-lite](https://github.com/tuyuritio/astro-theme-thought-lite) |
+| 在线预览 | [thought-lite.ttio.workers.dev](https://thought-lite.ttio.workers.dev/) |
+| 本仓库工作分支 | `feat/theme-thoughtlite` |
+| 当前骨架目录 | `themes/thoughtlite/`(由 `themes/example` 复制并重命名配置前缀,**可运行、非最终视觉**) |
+
+---
+
+## 0. 许可证与合规(必须先确认)
+
+上游仓库为 **GPL-3.0**。NotionNext 主仓库为 **MIT**。
+
+- **直接复制**上游 `.astro` / `.svelte` 等源码进本仓库,可能触发 **GPL 与 MIT 的兼容性**问题,需在合并前由维护者确认策略(例如:主题单独以 GPL 子目录说明、或仅「参考设计」用 React 重写而不复制 GPL 文本等)。
+- 请在 **PR 描述与 `docs/themes/THOUGHTLITE.md`** 中写明许可证结论后再合入 `main`。
+
+---
+
+## 1. 与 Fuwari(Astro → Next)迁移的相同注意点
+
+对照 [`docs/themes/FUWARI.md`](./FUWARI.md) 与 [主题迁移指南(中文)](../THEME_MIGRATION_GUIDE.zh-CN.md):
+
+1. **技术栈**:上游为 **Astro + Svelte**,NotionNext 主题为 **Next.js Pages Router + React**;不能「直接挂载」Astro 工程,只能 **移植视觉与信息架构**。
+2. **数据契约**:仅使用 NotionNext 传入的 `props`(`siteInfo`、`posts`、`post`、`customNav`、`customMenu`、`notice`、`tagOptions` 等),见迁移指南 §3。
+3. **必接能力**:菜单(含 `CUSTOM_MENU`)、公告 `NotionPage`、深浅色 `useGlobal`、文章 TOC/评论/分享/版权/相邻篇、侧栏挂件与 `rightAreaSlot`、联系邮箱 `handleEmailClick` / `resolveContactEmail` 约定(迁移指南 §7)。
+4. **配置**:使用 `siteConfig('THOUGHTLITE_*', default, CONFIG)`,集中在 `themes/thoughtlite/config.js`,避免魔法常量。
+5. **目录隔离**:不引用其他主题目录组件;通用能力用 `@/components/*`。
+
+---
+
+## 2. 分阶段任务(建议顺序)
+
+### Phase A — 骨架与联调(当前分支目标)
+
+- [x] 新建分支 `feat/theme-thoughtlite`。
+- [x] 建立 `themes/thoughtlite`(example 骨架 + `THOUGHTLITE_*` 配置前缀 + `id='theme-thoughtlite'`)。
+- [x] 顶栏 / 页脚 / 首页时间线与 Latest 卡片 / 文章卡片化初版(React 自研样式,非复制上游 Astro 源码)。
+- [ ] 本地 `NEXT_PUBLIC_THEME=thoughtlite`(或 URL `?theme=thoughtlite`)跑通:首页、文章、归档、分类、标签、搜索、404。
+- [ ] `yarn lint` / `yarn type-check` 针对本主题目录无新增错误。
+
+### Phase B — 版式对齐 ThoughtLite
+
+- [ ] 阅读上游 `src/layouts`、`src/pages` 与首页「时间线 / Latest」结构,画出与 NotionNext 路由的映射表(`/`、`/[prefix]/[slug]` 等)。
+- [x] 替换顶栏导航:站点名 + 横向菜单 + 搜索入口 + 深浅色切换。
+- [x] 首页:按 `publishDay` 分组的时间线列表(滚动/分页均支持)。
+- [x] 归档页:按月分组列表与首页一致的时间线视觉。
+- [x] 分类 / 标签索引:`tl-chip` 导航块。
+- [x] 搜索页:`TlPageHero` + 搜索框卡片化;关键词高亮 `useEffect` 依赖补全。
+- [ ] `style.js`:继续对齐上游色板、间距与字体(当前为首版 token)。
+
+### Phase C — 文章与侧栏
+
+- [ ] 文章页:正文 `NotionPage`、封面、元信息密度对齐上游文章页。
+- [ ] 侧栏/页脚:按需保留或简化;接入统计、广告、插件位(与 Fuwari 侧栏模块对齐思路)。
+- [ ] 可选:上游 **Swup** 类全页过渡在 Next 中成本高,可用 `framer-motion` 或弱化动效替代。
+
+### Phase D — 上架主仓库清单
+
+- [x] `docs/themes/THOUGHTLITE.md` 与 **`docs/themes/THOUGHTLITE.en.md`**:功能、配置、上游致谢与许可证说明。
+- [x] `docs/themes/README.md` / `README.en.md` 导航表更新。
+- [x] 按 [主题迁移指南 §8](../THEME_MIGRATION_GUIDE.zh-CN.md) 提交 `public/images/themes-preview/thoughtlite.png` / `.webp`(当前为占位图,可后续替换为真实截图)与 `conf/themeSwitch.manifest.js` 条目。
+- [ ] 合并 PR 时由维护者决定是否 **`Closes #3987`**(若仍有多阶段需求可保留 Issue 子任务)。
+
+---
+
+## 3. 风险与依赖
+
+| 风险 | 缓解 |
+|------|------|
+| GPL / MIT | 见 §0,PR 前书面结论。 |
+| 上游用 **pnpm + Svelte**,与本仓库 **yarn + React** 不一致 | 仅借鉴 UI,不混用包管理器。 |
+| i18n | 与全局 `next.config` `locales` 一致;上游多语言文案迁到 `lib/lang` 或主题内字典。 |
+| 构建体积 | 勿引入过大依赖;图标优先已有 Iconify / Heroicons 体系。 |
+
+---
+
+## 4. 验证命令(开发自检)
+
+```bash
+yarn dev
+# 浏览器访问: http://localhost:3000/?theme=thoughtlite
+
+yarn build
+yarn type-check
+```
+
+---
+
+维护者可按 Phase 勾选推进;每阶段结束建议 squash 或清晰 commit message 便于审阅。
+
+---
+
+## 5. 维护文档索引
+
+| 文档 | 用途 |
+|------|------|
+| [THOUGHTLITE.md](./THOUGHTLITE.md) | 加入原因、上游来源、原作者仓库、配置与维护清单(**主入口**) |
+| [THOUGHTLITE.en.md](./THOUGHTLITE.en.md) | 上述内容的英文版,便于国际贡献者 |
+| 本文(迁移计划) | 分阶段任务、风险、验证命令;与主文档交叉引用 |
diff --git a/lib/cache/build_session.js b/lib/cache/build_session.js
index 7d0593e1b1d..838c4b7cad0 100644
--- a/lib/cache/build_session.js
+++ b/lib/cache/build_session.js
@@ -1,21 +1,66 @@
import fs from 'fs'
+import os from 'os'
import path from 'path'
-const NOTION_CACHE_ROOT = path.join(process.cwd(), '.next', 'cache', 'notion')
-const BUILD_SESSION_FILE = path.join(NOTION_CACHE_ROOT, 'build-session.json')
+/** 惰性解析:构建/本地可写 `.next/cache/notion`;Serverless(如 Netlify)只读盘则落到 `os.tmpdir()` */
+let cachedNotionCacheRoot = null
+
+function resolveNotionCacheRoot() {
+ if (cachedNotionCacheRoot) {
+ return cachedNotionCacheRoot
+ }
+
+ const fromEnv = process.env.NOTION_NEXT_NOTION_CACHE_DIR
+ if (fromEnv) {
+ const dir = path.resolve(fromEnv)
+ fs.mkdirSync(dir, { recursive: true })
+ cachedNotionCacheRoot = dir
+ return cachedNotionCacheRoot
+ }
+
+ const primary = path.join(process.cwd(), '.next', 'cache', 'notion')
+ try {
+ fs.mkdirSync(primary, { recursive: true })
+ cachedNotionCacheRoot = primary
+ return cachedNotionCacheRoot
+ } catch (err) {
+ const code = err && err.code
+ if (
+ code === 'ENOENT' ||
+ code === 'EROFS' ||
+ code === 'EACCES' ||
+ code === 'EPERM'
+ ) {
+ const fallback = path.join(os.tmpdir(), 'notionnext-notion-cache')
+ fs.mkdirSync(fallback, { recursive: true })
+ cachedNotionCacheRoot = fallback
+ if (process.env.NODE_ENV !== 'test') {
+ console.warn(
+ '[NotionNext] Notion file cache root (read-only deploy, using tmpdir):',
+ fallback
+ )
+ }
+ return cachedNotionCacheRoot
+ }
+ throw err
+ }
+}
function sanitizeSessionId(sessionId) {
return String(sessionId || 'default').replace(/[^a-z0-9_-]/gi, '_')
}
export function getNotionCacheRoot() {
- fs.mkdirSync(NOTION_CACHE_ROOT, { recursive: true })
- return NOTION_CACHE_ROOT
+ return resolveNotionCacheRoot()
}
export function getBuildSessionId() {
+ const buildSessionFile = path.join(
+ getNotionCacheRoot(),
+ 'build-session.json'
+ )
try {
- const raw = fs.readFileSync(BUILD_SESSION_FILE, 'utf8')
+ const raw = fs.readFileSync(buildSessionFile, 'utf8')
const parsed = JSON.parse(raw)
if (parsed?.sessionId) {
return sanitizeSessionId(parsed.sessionId)
@@ -26,5 +71,10 @@ export function getBuildSessionId() {
}
export function getBuildSessionPath(...parts) {
- return path.join(getNotionCacheRoot(), 'sessions', getBuildSessionId(), ...parts)
+ return path.join(
+ getNotionCacheRoot(),
+ 'sessions',
+ getBuildSessionId(),
+ ...parts
+ )
}
diff --git a/public/images/themes-preview/thoughtlite.png b/public/images/themes-preview/thoughtlite.png
new file mode 100644
index 00000000000..b826d48e0d2
Binary files /dev/null and b/public/images/themes-preview/thoughtlite.png differ
diff --git a/public/images/themes-preview/thoughtlite.webp b/public/images/themes-preview/thoughtlite.webp
new file mode 100644
index 00000000000..dff9568eaa3
Binary files /dev/null and b/public/images/themes-preview/thoughtlite.webp differ
diff --git a/themes/thoughtlite/components/Announcement.js b/themes/thoughtlite/components/Announcement.js
new file mode 100644
index 00000000000..f6786ce597d
--- /dev/null
+++ b/themes/thoughtlite/components/Announcement.js
@@ -0,0 +1,30 @@
+import { useGlobal } from '@/lib/global'
+import dynamic from 'next/dynamic'
+
+const NotionPage = dynamic(() => import('@/components/NotionPage'))
+
+/**
+ * 公告模块(单篇 Notion 页)
+ */
+const Announcement = ({ post, className }) => {
+ const { locale } = useGlobal()
+ if (!post || Object.keys(post).length === 0) {
+ return <>>
+ }
+ return (
+
+ )
+}
+export default Announcement
diff --git a/themes/thoughtlite/components/BlogItem.js b/themes/thoughtlite/components/BlogItem.js
new file mode 100644
index 00000000000..02334db937c
--- /dev/null
+++ b/themes/thoughtlite/components/BlogItem.js
@@ -0,0 +1,119 @@
+import LazyImage from '@/components/LazyImage'
+import NotionIcon from '@/components/NotionIcon'
+import TwikooCommentCount from '@/components/TwikooCommentCount'
+import { siteConfig } from '@/lib/config'
+import SmartLink from '@/components/SmartLink'
+import CONFIG from '../config'
+
+/**
+ * 博客列表的单个卡片
+ * @param {{ post: object, variant?: 'default' | 'timeline' }} props
+ */
+const BlogItem = ({ post, variant = 'default' }) => {
+ const showPageCover =
+ variant === 'default' &&
+ siteConfig('THOUGHTLITE_POST_LIST_COVER', null, CONFIG) &&
+ post?.pageCoverThumbnail
+
+ if (variant === 'timeline') {
+ return (
+
+
+
+ {siteConfig('POST_TITLE_ICON') && (
+
+
+
+ )}
+ {post?.title}
+
+ {post?.type !== 'Page' && post?.category && (
+
+ #{post.category}
+
+ )}
+
+ {post.summary && !post.results ? (
+
+ {post.summary}
+
+ ) : null}
+ {post.results ? (
+
+ {post.results.map((r, index) => (
+ {r}
+ ))}
+
+ ) : null}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ {siteConfig('POST_TITLE_ICON') && (
+
+ )}
+ {post?.title}
+
+
+
+
+ by{' '}
+ {siteConfig('AUTHOR')}
+ {' '}
+ on {post.date?.start_date || post.createdTime}
+
+ {post.category && (
+ <>
+ |
+
+ {post.category}
+
+ >
+ )}
+
+
+ {!post.results && (
+
+ {post.summary}
+
+ )}
+ {post.results && (
+
+ {post.results.map((r, index) => (
+ {r}
+ ))}
+
+ )}
+
+ {showPageCover && (
+
+
+
+
+
+ )}
+
+ )
+}
+
+export default BlogItem
diff --git a/themes/thoughtlite/components/BlogListArchive.js b/themes/thoughtlite/components/BlogListArchive.js
new file mode 100644
index 00000000000..278d1d0c6c2
--- /dev/null
+++ b/themes/thoughtlite/components/BlogListArchive.js
@@ -0,0 +1,30 @@
+import SmartLink from '@/components/SmartLink'
+
+/**
+ * 归档:按月(或站点约定)分组的文章列表
+ */
+export default function BlogListArchive({ archiveTitle, archivePosts }) {
+ const posts = archivePosts[archiveTitle] || []
+ const sectionId = `archive-${String(archiveTitle).replace(/\s+/g, '-')}`
+ return (
+
+
+ {archiveTitle}
+
+
+ {posts.map(post => (
+ -
+
+ {post?.publishDay}
+
+
+ {post.title}
+
+
+ ))}
+
+
+ )
+}
diff --git a/themes/thoughtlite/components/BlogListPage.js b/themes/thoughtlite/components/BlogListPage.js
new file mode 100644
index 00000000000..eb831362846
--- /dev/null
+++ b/themes/thoughtlite/components/BlogListPage.js
@@ -0,0 +1,82 @@
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import SmartLink from '@/components/SmartLink'
+import { useRouter } from 'next/router'
+import CONFIG from '../config'
+import BlogItem from './BlogItem'
+import HomeTimeline from './HomeTimeline'
+
+/**
+ * 使用分页插件的博客列表
+ * @param {*} props
+ * @returns
+ */
+export const BlogListPage = props => {
+ const {
+ page = 1,
+ posts,
+ postCount,
+ useTimeline: useTimelineProp
+ } = props
+ const { locale, NOTION_CONFIG } = useGlobal()
+ const router = useRouter()
+ const totalPage = Math.ceil(
+ postCount / siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG)
+ )
+ const currentPage = +page
+
+ const showPrev = currentPage > 1
+ const showNext = page < totalPage
+ const pagePrefix = router.asPath
+ .split('?')[0]
+ .replace(/\/page\/[1-9]\d*/, '')
+ .replace(/\/$/, '')
+ .replace('.html', '')
+
+ const showPageCover = siteConfig('THOUGHTLITE_POST_LIST_COVER', null, CONFIG)
+
+ const useTimeline =
+ useTimelineProp ??
+ (siteConfig('THOUGHTLITE_HOME_TIMELINE', true, CONFIG) &&
+ !props.category &&
+ !props.tag &&
+ !props.keyword &&
+ !router?.query?.s &&
+ (router.pathname === '/' || router.pathname === '/page/[page]'))
+
+ return (
+
+ {useTimeline ? (
+
+ ) : (
+
+ {posts?.map(post => (
+
+ ))}
+
+ )}
+
+
+
+ {locale.PAGINATION.PREV}
+
+
+ {locale.PAGINATION.NEXT}
+
+
+
+ )
+}
diff --git a/themes/thoughtlite/components/BlogListScroll.js b/themes/thoughtlite/components/BlogListScroll.js
new file mode 100644
index 00000000000..28c0eac4ead
--- /dev/null
+++ b/themes/thoughtlite/components/BlogListScroll.js
@@ -0,0 +1,91 @@
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import throttle from 'lodash.throttle'
+import { useRouter } from 'next/router'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import CONFIG from '../config'
+import BlogItem from './BlogItem'
+import HomeTimeline from './HomeTimeline'
+
+/**
+ * 使用滚动无限加载的博客列表
+ * @param {*} props
+ * @returns
+ */
+export const BlogListScroll = props => {
+ const { posts, useTimeline: useTimelineProp } = props
+ const router = useRouter()
+ const { locale, NOTION_CONFIG } = useGlobal()
+ const [page, updatePage] = useState(1)
+ const POSTS_PER_PAGE = siteConfig('POSTS_PER_PAGE', null, NOTION_CONFIG)
+
+ const postsToShow = posts
+ ? Object.assign([], posts).slice(0, POSTS_PER_PAGE * page)
+ : []
+
+ const hasMore = Boolean(posts?.length) && page * POSTS_PER_PAGE < posts.length
+
+ const handleGetMore = useCallback(() => {
+ updatePage(p => {
+ const total = posts?.length ?? 0
+ if (p * POSTS_PER_PAGE >= total) return p
+ return p + 1
+ })
+ }, [posts, POSTS_PER_PAGE])
+
+ const targetRef = useRef(null)
+
+ const scrollTrigger = useMemo(
+ () =>
+ throttle(() => {
+ const scrollS = window.scrollY + window.outerHeight
+ const clientHeight = targetRef.current?.clientHeight ?? 0
+ if (scrollS > clientHeight + 100) {
+ handleGetMore()
+ }
+ }, 500),
+ [handleGetMore]
+ )
+
+ useEffect(() => {
+ window.addEventListener('scroll', scrollTrigger)
+ return () => {
+ scrollTrigger.cancel()
+ window.removeEventListener('scroll', scrollTrigger)
+ }
+ }, [scrollTrigger])
+
+ const showPageCover = siteConfig('THOUGHTLITE_POST_LIST_COVER', null, CONFIG)
+
+ const useTimeline =
+ useTimelineProp ??
+ (siteConfig('THOUGHTLITE_HOME_TIMELINE', true, CONFIG) &&
+ !props.category &&
+ !props.tag &&
+ !props.keyword &&
+ !router?.query?.s &&
+ (router.pathname === '/' || router.pathname === '/page/[page]'))
+
+ return (
+
+ {useTimeline ? (
+
+ ) : (
+
+ {postsToShow?.map(post => (
+
+ ))}
+
+ )}
+
+
+ {' '}
+ {hasMore ? locale.COMMON.MORE : `${locale.COMMON.NO_MORE} 😰`}{' '}
+
+
+ )
+}
diff --git a/themes/thoughtlite/components/Catalog.js b/themes/thoughtlite/components/Catalog.js
new file mode 100644
index 00000000000..c6589b1f6b1
--- /dev/null
+++ b/themes/thoughtlite/components/Catalog.js
@@ -0,0 +1,98 @@
+import throttle from 'lodash.throttle'
+import { uuidToId } from 'notion-utils'
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+/**
+ * 目录导航组件
+ * @param toc
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const Catalog = ({ toc }) => {
+ // 监听滚动事件
+ useEffect(() => {
+ window.addEventListener('scroll', actionSectionScrollSpy)
+ actionSectionScrollSpy()
+ return () => {
+ window.removeEventListener('scroll', actionSectionScrollSpy)
+ }
+ }, [])
+
+ // 目录自动滚动
+ const tRef = useRef(null)
+ const tocIds = []
+
+ // 同步选中目录事件
+ const [activeSection, setActiveSection] = useState(null)
+ const throttleMs = 200
+ const actionSectionScrollSpy = useCallback(
+ throttle(() => {
+ const sections = document.getElementsByClassName('notion-h')
+ let prevBBox = null
+ let currentSectionId = activeSection
+ for (let i = 0; i < sections.length; ++i) {
+ const section = sections[i]
+ if (!section || !(section instanceof Element)) continue
+ if (!currentSectionId) {
+ currentSectionId = section.getAttribute('data-id')
+ }
+ const bbox = section.getBoundingClientRect()
+ const prevHeight = prevBBox ? bbox.top - prevBBox.bottom : 0
+ const offset = Math.max(150, prevHeight / 4)
+ // GetBoundingClientRect returns values relative to viewport
+ if (bbox.top - offset < 0) {
+ currentSectionId = section.getAttribute('data-id')
+ prevBBox = bbox
+ continue
+ }
+ // No need to continue loop, if last element has been detected
+ break
+ }
+ setActiveSection(currentSectionId)
+ const index = tocIds.indexOf(currentSectionId) || 0
+ tRef?.current?.scrollTo({ top: 28 * index, behavior: 'smooth' })
+ }, throttleMs)
+ )
+
+ // 无目录就直接返回空
+ if (!toc || toc.length < 1) {
+ return <>>
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+export default Catalog
diff --git a/themes/thoughtlite/components/Footer.js b/themes/thoughtlite/components/Footer.js
new file mode 100644
index 00000000000..7f685032730
--- /dev/null
+++ b/themes/thoughtlite/components/Footer.js
@@ -0,0 +1,44 @@
+import { BeiAnGongAn } from '@/components/BeiAnGongAn'
+import BeiAnSite from '@/components/BeiAnSite'
+import CopyRightDate from '@/components/CopyRightDate'
+import PoweredBy from '@/components/PoweredBy'
+
+const UPSTREAM_REPO = 'https://github.com/tuyuritio/astro-theme-thought-lite'
+const UPSTREAM_AUTHOR = 'https://github.com/tuyuritio'
+
+export const Footer = () => {
+ return (
+
+ )
+}
diff --git a/themes/thoughtlite/components/Header.js b/themes/thoughtlite/components/Header.js
new file mode 100644
index 00000000000..2ef3bd5e2db
--- /dev/null
+++ b/themes/thoughtlite/components/Header.js
@@ -0,0 +1,49 @@
+import SmartLink from '@/components/SmartLink'
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import CONFIG from '../config'
+import { MenuList } from './MenuList'
+
+/**
+ * ThoughtLite 风格顶栏:站点名 + 横向菜单 + 搜索 / 深浅色
+ */
+export const Header = props => {
+ const { isDarkMode, toggleDarkMode } = useGlobal()
+
+ const openSearch = () => {
+ window.location.href = '/search'
+ }
+
+ return (
+
+ )
+}
diff --git a/themes/thoughtlite/components/HomeTimeline.js b/themes/thoughtlite/components/HomeTimeline.js
new file mode 100644
index 00000000000..561acb58862
--- /dev/null
+++ b/themes/thoughtlite/components/HomeTimeline.js
@@ -0,0 +1,49 @@
+import { useMemo } from 'react'
+import BlogItem from './BlogItem'
+
+/**
+ * 按「发布日」分组的时间线列表(对齐 ThoughtLite 首页观感)。
+ * @param {{ posts: object[] }} props
+ */
+export default function HomeTimeline({ posts }) {
+ const groups = useMemo(() => {
+ if (!posts?.length) return []
+ const sorted = [...posts].sort(
+ (a, b) => (b.publishDate || 0) - (a.publishDate || 0)
+ )
+ const dayOrder = []
+ const seen = new Set()
+ for (const p of sorted) {
+ const day = p.publishDay || p.date?.start_date || ''
+ if (!seen.has(day)) {
+ seen.add(day)
+ dayOrder.push(day)
+ }
+ }
+ return dayOrder.map(day => ({
+ day,
+ posts: sorted.filter(
+ p => (p.publishDay || p.date?.start_date || '') === day
+ )
+ }))
+ }, [posts])
+
+ if (!groups.length) return null
+
+ return (
+
+ {groups.map(({ day, posts: dayPosts }) => (
+
+ {day || '—'}
+
+ {dayPosts.map(post => (
+ -
+
+
+ ))}
+
+
+ ))}
+
+ )
+}
diff --git a/themes/thoughtlite/components/LatestCard.js b/themes/thoughtlite/components/LatestCard.js
new file mode 100644
index 00000000000..e956318b051
--- /dev/null
+++ b/themes/thoughtlite/components/LatestCard.js
@@ -0,0 +1,28 @@
+import SmartLink from '@/components/SmartLink'
+import { siteConfig } from '@/lib/config'
+import CONFIG from '../config'
+
+/**
+ * 首页「Latest」摘要块(对齐 ThoughtLite 演示站顶部引用样式)。
+ */
+export default function LatestCard({ post }) {
+ if (!post?.href) return null
+ const enabled = siteConfig('THOUGHTLITE_HOME_LATEST_CARD', true, CONFIG)
+ if (!enabled) return null
+
+ return (
+
+ )
+}
diff --git a/themes/thoughtlite/components/MenuItemDrop.js b/themes/thoughtlite/components/MenuItemDrop.js
new file mode 100644
index 00000000000..9b079b744e3
--- /dev/null
+++ b/themes/thoughtlite/components/MenuItemDrop.js
@@ -0,0 +1,74 @@
+import SmartLink from '@/components/SmartLink'
+import { useState } from 'react'
+
+/**
+ * 支持下拉二级的菜单
+ * @param {*} param0
+ * @returns
+ */
+export const MenuItemDrop = ({ link, variant = 'default' }) => {
+ const [show, changeShow] = useState(false)
+ const hasSubMenu = link?.subMenus?.length > 0
+ const isInline = variant === 'inline'
+
+ const itemShell = isInline
+ ? 'relative flex-shrink-0'
+ : 'cursor-pointer'
+
+ const linkBox = isInline
+ ? 'rounded-md px-2 py-1.5 tl-nav-link no-underline flex items-center gap-1.5 whitespace-nowrap'
+ : 'rounded px-2 md:pl-0 md:mr-3 my-4 md:pr-3 text-gray-700 dark:text-gray-200 no-underline md:border-r border-gray-light'
+
+ return (
+ changeShow(true)}
+ onMouseOut={() => changeShow(false)}>
+ {!hasSubMenu && (
+
+
+ {link?.icon && } {link?.name}
+ {hasSubMenu && }
+
+
+ )}
+
+ {hasSubMenu && (
+
+ {link?.icon && } {link?.name}
+
+
+ )}
+
+ {/* 子菜单 */}
+ {hasSubMenu && (
+
+ {link.subMenus.map((sLink, index) => {
+ return (
+ -
+
+
+ {link?.icon && }
+ {sLink.title}
+
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/themes/thoughtlite/components/MenuList.js b/themes/thoughtlite/components/MenuList.js
new file mode 100644
index 00000000000..dcc39f7c80b
--- /dev/null
+++ b/themes/thoughtlite/components/MenuList.js
@@ -0,0 +1,86 @@
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import CONFIG from '../config'
+import { MenuItemDrop } from './MenuItemDrop'
+
+/**
+ * 导航菜单列表
+ * @param {*} props
+ * @param {'stack'|'header'} [props.variant] stack:旧版通栏导航;header:顶栏内横向滚动
+ * @returns
+ */
+export const MenuList = props => {
+ const { customNav, customMenu, variant = 'stack' } = props
+ const { locale } = useGlobal()
+
+ let links = [
+ {
+ id: 1,
+ icon: 'fas fa-search',
+ name: locale.NAV.SEARCH,
+ href: '/search',
+ show: siteConfig('THOUGHTLITE_MENU_SEARCH', null, CONFIG)
+ },
+ {
+ id: 2,
+ icon: 'fas fa-archive',
+ name: locale.NAV.ARCHIVE,
+ href: '/archive',
+ show: siteConfig('THOUGHTLITE_MENU_ARCHIVE', null, CONFIG)
+ },
+ {
+ id: 3,
+ icon: 'fas fa-folder',
+ name: locale.COMMON.CATEGORY,
+ href: '/category',
+ show: siteConfig('THOUGHTLITE_MENU_CATEGORY', null, CONFIG)
+ },
+ {
+ id: 4,
+ icon: 'fas fa-tag',
+ name: locale.COMMON.TAGS,
+ href: '/tag',
+ show: siteConfig('THOUGHTLITE_MENU_TAG', null, CONFIG)
+ }
+ ]
+
+ if (customNav) {
+ links = links.concat(customNav)
+ }
+
+ // 如果 开启自定义菜单,则不再使用 Page生成菜单。
+ if (siteConfig('CUSTOM_MENU')) {
+ links = customMenu
+ }
+
+ if (!links || links.length === 0) {
+ return null
+ }
+
+ if (variant === 'header') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/themes/thoughtlite/components/PostLock.js b/themes/thoughtlite/components/PostLock.js
new file mode 100644
index 00000000000..bd42b97317d
--- /dev/null
+++ b/themes/thoughtlite/components/PostLock.js
@@ -0,0 +1,56 @@
+import { useGlobal } from '@/lib/global'
+import { useEffect, useRef } from 'react'
+
+/**
+ * 文章锁;通过此组件校验密码访问文章
+ */
+export const PostLock = props => {
+ const { validPassword } = props
+ const { locale } = useGlobal()
+
+ const submitPassword = () => {
+ const p = document.getElementById('password')
+ if (!validPassword(p?.value)) {
+ const tips = document.getElementById('tips')
+ if (tips) {
+ tips.innerHTML = ''
+ tips.innerHTML = `${locale.COMMON.PASSWORD_ERROR}
`
+ }
+ }
+ }
+ const passwordInputRef = useRef(null)
+ useEffect(() => {
+ passwordInputRef.current?.focus()
+ }, [])
+
+ return (
+
+
+
+ {locale.COMMON.ARTICLE_LOCK_TIPS}
+
+
+ {
+ if (e.key === 'Enter') {
+ submitPassword()
+ }
+ }}
+ ref={passwordInputRef}
+ className='min-w-0 flex-1 border-0 bg-transparent py-3 pl-4 text-sm text-[var(--tl-text)] outline-none'
+ />
+
+
+
+
+
+ )
+}
diff --git a/themes/thoughtlite/components/PostMeta.js b/themes/thoughtlite/components/PostMeta.js
new file mode 100644
index 00000000000..8f38f0d985d
--- /dev/null
+++ b/themes/thoughtlite/components/PostMeta.js
@@ -0,0 +1,50 @@
+import { useGlobal } from '@/lib/global'
+import { formatDateFmt } from '@/lib/utils/formatDate'
+import SmartLink from '@/components/SmartLink'
+
+/**
+ * 文章详情的元信息
+ */
+export const PostMeta = props => {
+ const { post } = props
+ const { locale } = useGlobal()
+
+ return (
+
+
+ {post?.type !== 'Page' && (
+ <>
+
+
+ {post?.category}
+
+ |
+ >
+ )}
+
+ {post?.type !== 'Page' && (
+ <>
+
+ {post?.publishDay}
+
+ |
+
+ {locale.COMMON.LAST_EDITED_TIME}: {post?.lastEditedDay}
+
+ |
+
+
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/themes/thoughtlite/components/RecentCommentListForExample.js b/themes/thoughtlite/components/RecentCommentListForExample.js
new file mode 100644
index 00000000000..0fbd075efc2
--- /dev/null
+++ b/themes/thoughtlite/components/RecentCommentListForExample.js
@@ -0,0 +1,62 @@
+import { siteConfig } from '@/lib/config'
+import { RecentComments } from '@waline/client'
+import SmartLink from '@/components/SmartLink'
+import { useEffect, useState } from 'react'
+
+/**
+ * 最近评论列表
+ * 基于Waline实现
+ * @see https://waline.js.org/guide/get-started.html
+ * @param {*} props
+ * @returns
+ */
+const RecentCommentListForExample = props => {
+ const [comments, updateComments] = useState([])
+ const [onLoading, changeLoading] = useState(true)
+ useEffect(() => {
+ RecentComments({
+ serverURL: siteConfig('COMMENT_WALINE_SERVER_URL'),
+ count: 5
+ }).then(({ comments }) => {
+ changeLoading(false)
+ updateComments(comments)
+ })
+ }, [])
+
+ return (
+ <>
+ {onLoading && (
+
+ Loading...
+
+
+ )}
+ {!onLoading && comments && comments.length === 0 && (
+ No Comments
+ )}
+ {!onLoading &&
+ comments &&
+ comments.length > 0 &&
+ comments.map(comment => (
+
+
+
+
+ --{comment.nick}
+
+
+
+ ))}
+ >
+ )
+}
+
+export default RecentCommentListForExample
diff --git a/themes/thoughtlite/components/SearchInput.js b/themes/thoughtlite/components/SearchInput.js
new file mode 100644
index 00000000000..b09891e2d9f
--- /dev/null
+++ b/themes/thoughtlite/components/SearchInput.js
@@ -0,0 +1,100 @@
+import { useGlobal } from '@/lib/global'
+import { useRouter } from 'next/router'
+import { useImperativeHandle, useRef, useState } from 'react'
+
+let lock = false
+
+/**
+ * 搜索输入框
+ */
+const SearchInput = ({ currentTag, keyword, cRef }) => {
+ const { locale } = useGlobal()
+ const router = useRouter()
+ const searchInputRef = useRef(null)
+ useImperativeHandle(cRef, () => {
+ return {
+ focus: () => {
+ searchInputRef?.current?.focus()
+ }
+ }
+ })
+ const handleSearch = () => {
+ const key = searchInputRef.current.value
+ if (key && key !== '') {
+ router.push({ pathname: '/search/' + key }).then(() => {})
+ } else {
+ router.push({ pathname: '/' }).then(() => {})
+ }
+ }
+ const handleKeyUp = e => {
+ if (e.keyCode === 13) {
+ handleSearch()
+ } else if (e.keyCode === 27) {
+ cleanSearch()
+ }
+ }
+ const cleanSearch = () => {
+ searchInputRef.current.value = ''
+ setShowClean(false)
+ }
+ function lockSearchInput() {
+ lock = true
+ }
+
+ function unLockSearchInput() {
+ lock = false
+ }
+ const [showClean, setShowClean] = useState(false)
+ const updateSearchKey = val => {
+ if (lock) {
+ return
+ }
+ searchInputRef.current.value = val
+ if (val) {
+ setShowClean(true)
+ } else {
+ setShowClean(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+export default SearchInput
diff --git a/themes/thoughtlite/components/SideBar.js b/themes/thoughtlite/components/SideBar.js
new file mode 100644
index 00000000000..d7c15214359
--- /dev/null
+++ b/themes/thoughtlite/components/SideBar.js
@@ -0,0 +1,125 @@
+import Live2D from '@/components/Live2D'
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import dynamic from 'next/dynamic'
+import SmartLink from '@/components/SmartLink'
+import CONFIG from '../config'
+import Announcement from './Announcement'
+import Catalog from './Catalog'
+const ExampleRecentComments = dynamic(
+ () => import('./RecentCommentListForExample')
+)
+
+/**
+ * 侧边栏
+ */
+export const SideBar = props => {
+ const { locale } = useGlobal()
+ const { latestPosts, categoryOptions, notice, post } = props
+ // 评论相关
+ const COMMENT_WALINE_SERVER_URL = siteConfig(
+ 'COMMENT_WALINE_SERVER_URL',
+ false
+ )
+ const COMMENT_WALINE_RECENT = siteConfig('COMMENT_WALINE_RECENT', false)
+
+ // 文章详情页特殊布局
+ const HIDDEN_NOTIFICATION =
+ post && siteConfig('THOUGHTLITE_ARTICLE_HIDDEN_NOTIFICATION', false, CONFIG)
+
+ // 文章详情页左右布局改为上下布局
+ const LAYOUT_VERTICAL =
+ post && siteConfig('THOUGHTLITE_ARTICLE_LAYOUT_VERTICAL', false, CONFIG)
+
+ return (
+ <>
+ {/* 目录 */}
+ {post?.toc && post?.toc.length > 2 && (
+
+ )}
+
+ {/* 分类 */}
+
+
+ {/* 最新文章 */}
+
+
+ {/* 公告 */}
+ {!HIDDEN_NOTIFICATION && }
+
+ {/* 最近评论 */}
+ {COMMENT_WALINE_SERVER_URL && COMMENT_WALINE_RECENT && (
+
+ )}
+
+ {/* 宠物挂件 */}
+
+ >
+ )
+}
diff --git a/themes/thoughtlite/components/TitleBar.js b/themes/thoughtlite/components/TitleBar.js
new file mode 100644
index 00000000000..f43066d0618
--- /dev/null
+++ b/themes/thoughtlite/components/TitleBar.js
@@ -0,0 +1,58 @@
+import NotionIcon from '@/components/NotionIcon'
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import { useRouter } from 'next/router'
+import CONFIG from '../config'
+
+/**
+ * 列表/归档等页顶栏下的弱标题区;文章页标题在 LayoutSlug 内展示。
+ */
+export default function TitleBar(props) {
+ const { post } = props
+ const { fullWidth, siteInfo } = useGlobal()
+ const router = useRouter()
+
+ if (post) {
+ return null
+ }
+
+ const skipOnHome =
+ siteConfig('THOUGHTLITE_HOME_MINIMAL_HEADER', true, CONFIG) &&
+ (router.pathname === '/' || router.pathname === '/page/[page]')
+
+ if (skipOnHome) {
+ return null
+ }
+
+ const title = siteConfig('TITLE')
+ const description = siteConfig('AUTHOR')
+ const headerImage = siteInfo?.pageCover
+
+ const TITLE_BG = siteConfig('THOUGHTLITE_TITLE_IMAGE', false, CONFIG)
+
+ if (fullWidth) {
+ return null
+ }
+
+ return (
+
+
+
+ {siteConfig('POST_TITLE_ICON') && }
+ {title}
+
+
{description}
+
+ {TITLE_BG && headerImage ? (
+ <>
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+ >
+ ) : null}
+
+ )
+}
diff --git a/themes/thoughtlite/components/TlPageHero.js b/themes/thoughtlite/components/TlPageHero.js
new file mode 100644
index 00000000000..d73c2d06e26
--- /dev/null
+++ b/themes/thoughtlite/components/TlPageHero.js
@@ -0,0 +1,19 @@
+/**
+ * 列表类页面统一页头(归档 / 分类 / 标签 / 搜索等)
+ */
+export default function TlPageHero({ eyebrow, title, description }) {
+ if (!title) return null
+ return (
+
+ {eyebrow ? (
+
+ {eyebrow}
+
+ ) : null}
+ {title}
+ {description ? (
+ {description}
+ ) : null}
+
+ )
+}
diff --git a/themes/thoughtlite/config.js b/themes/thoughtlite/config.js
new file mode 100644
index 00000000000..0f072087061
--- /dev/null
+++ b/themes/thoughtlite/config.js
@@ -0,0 +1,28 @@
+/**
+ * 主题配置文件
+ */
+const CONFIG = {
+ // 菜单配置
+ THOUGHTLITE_MENU_CATEGORY: true, // 显示分类
+ THOUGHTLITE_MENU_TAG: true, // 显示标签
+ THOUGHTLITE_MENU_ARCHIVE: true, // 显示归档
+ THOUGHTLITE_MENU_SEARCH: true, // 显示搜索
+
+ /** 首页(无分类/标签/搜索关键词)使用按日分组时间线 */
+ THOUGHTLITE_HOME_TIMELINE: true,
+ /** 首页顶部「Latest」摘要卡片(取最新一篇) */
+ THOUGHTLITE_HOME_LATEST_CARD: true,
+ /** 仅文章页显示侧栏(目录、公告等);列表/归档等不显示 */
+ THOUGHTLITE_SIDEBAR_ONLY_ON_POST: true,
+
+ THOUGHTLITE_POST_LIST_COVER: false, // 时间线模式建议关闭封面以保清爽
+
+ THOUGHTLITE_TITLE_IMAGE: false, // 标题栏,是否背景图片
+ /** 首页隐藏大块 TitleBar,仅保留顶栏 */
+ THOUGHTLITE_HOME_MINIMAL_HEADER: true,
+
+ // 文章页面布局
+ THOUGHTLITE_ARTICLE_LAYOUT_VERTICAL: false, // 文章详情,左右布局改为上下布局
+ THOUGHTLITE_ARTICLE_HIDDEN_NOTIFICATION: false // 文章详情隐藏公告
+}
+export default CONFIG
diff --git a/themes/thoughtlite/index.js b/themes/thoughtlite/index.js
new file mode 100644
index 00000000000..8a6e0da4fdf
--- /dev/null
+++ b/themes/thoughtlite/index.js
@@ -0,0 +1,422 @@
+'use client'
+
+/**
+ * ThoughtLite(进行中)
+ *
+ * 当前为基于 `themes/example` 的可运行骨架,便于在 `NEXT_PUBLIC_THEME=thoughtlite` 下联调数据契约。
+ * 视觉与交互需按 `docs/themes/THOUGHTLITE_MIGRATION_PLAN.zh-CN.md` 对照上游 [ThoughtLite](https://github.com/tuyuritio/astro-theme-thought-lite) 逐步替换。
+ *
+ * 关联:[#3987](https://github.com/notionnext-org/NotionNext/issues/3987)
+ */
+
+import replaceSearchResult from '@/components/Mark'
+import NotionIcon from '@/components/NotionIcon'
+import NotionPage from '@/components/NotionPage'
+import ShareBar from '@/components/ShareBar'
+import { siteConfig } from '@/lib/config'
+import { useGlobal } from '@/lib/global'
+import { isBrowser } from '@/lib/utils'
+import { Transition } from '@headlessui/react'
+import SmartLink from '@/components/SmartLink'
+import dynamic from 'next/dynamic'
+import { useRouter } from 'next/router'
+import { useEffect } from 'react'
+import BlogListArchive from './components/BlogListArchive'
+import { BlogListPage } from './components/BlogListPage'
+import { BlogListScroll } from './components/BlogListScroll'
+import { Footer } from './components/Footer'
+import { Header } from './components/Header'
+import LatestCard from './components/LatestCard'
+import { PostLock } from './components/PostLock'
+import { PostMeta } from './components/PostMeta'
+import SearchInput from './components/SearchInput'
+import { SideBar } from './components/SideBar'
+import TitleBar from './components/TitleBar'
+import TlPageHero from './components/TlPageHero'
+import CONFIG from './config'
+import { Style } from './style'
+
+const Comment = dynamic(() => import('@/components/Comment'), { ssr: false })
+
+/**
+ * 基础布局框架
+ * 1.其它页面都嵌入在LayoutBase中
+ * 2.采用左右两侧布局,移动端使用顶部导航栏
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const LayoutBase = props => {
+ const { children, post } = props
+ const { onLoading, fullWidth, locale } = useGlobal()
+
+ const LAYOUT_VERTICAL =
+ post && siteConfig('THOUGHTLITE_ARTICLE_LAYOUT_VERTICAL', false, CONFIG)
+
+ const LAYOUT_SIDEBAR_REVERSE = siteConfig('LAYOUT_SIDEBAR_REVERSE', false)
+
+ const sidebarOnlyPost = siteConfig(
+ 'THOUGHTLITE_SIDEBAR_ONLY_ON_POST',
+ true,
+ CONFIG
+ )
+ const showSidebar =
+ !fullWidth && (!sidebarOnlyPost || Boolean(post)) && !LAYOUT_VERTICAL
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {props.slotTop}
+ {children}
+
+
+
+ {showSidebar && (
+
+
+
+ )}
+
+ {LAYOUT_VERTICAL && !fullWidth && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * 首页
+ * @param {*} props
+ * @returns 此主题首页就是列表
+ */
+const LayoutIndex = props => {
+ return
+}
+
+/**
+ * 文章列表
+ * @param {*} props
+ * @returns
+ */
+const LayoutPostList = props => {
+ const { category, tag, keyword, latestPosts, posts } = props
+ const router = useRouter()
+ const useTimeline =
+ siteConfig('THOUGHTLITE_HOME_TIMELINE', true, CONFIG) &&
+ !category &&
+ !tag &&
+ !keyword &&
+ !router?.query?.s &&
+ (router.pathname === '/' || router.pathname === '/page/[page]')
+
+ const latestCardPost = latestPosts?.[0] || posts?.[0]
+
+ return (
+ <>
+ {category && (
+
+
+ {category}
+
+ )}
+ {tag && (
+ #{tag}
+ )}
+
+ {useTimeline &&
+ siteConfig('THOUGHTLITE_HOME_LATEST_CARD', true, CONFIG) && (
+
+ )}
+
+ {siteConfig('POST_LIST_STYLE') === 'page' ? (
+
+ ) : (
+
+ )}
+ >
+ )
+}
+
+/**
+ * 文章详情页
+ * @param {*} props
+ * @returns
+ */
+const LayoutSlug = props => {
+ const { post, lock, validPassword } = props
+ const router = useRouter()
+ const { locale } = useGlobal()
+ const waiting404 = siteConfig('POST_WAITING_TIME_FOR_404') * 1000
+ useEffect(() => {
+ // 404
+ if (!post) {
+ setTimeout(
+ () => {
+ if (isBrowser) {
+ const article = document.querySelector('#article-wrapper #notion-article')
+ if (!article) {
+ router.push('/404').then(() => {
+ console.warn('找不到页面', router.asPath)
+ })
+ }
+ }
+ },
+ waiting404
+ )
+ }
+ }, [post])
+ return (
+ <>
+ {lock ? (
+
+ ) : (
+ post && (
+
+
+
+ {siteConfig('POST_TITLE_ICON') && (
+
+ )}
+ {post.title}
+
+
+
+
+
+
+
+
+
+
+ {locale?.COMMON?.COMMENTS || 'Comments'}
+
+
+
+
+ )
+ )}
+ >
+ )
+}
+
+/**
+ * 404页
+ * @param {*} props
+ * @returns
+ */
+const Layout404 = props => {
+ const router = useRouter()
+ useEffect(() => {
+ // 延时3秒如果加载失败就返回首页
+ setTimeout(() => {
+ const article = isBrowser && document.getElementById('article-wrapper')
+ if (!article) {
+ router.push('/').then(() => {
+ // console.log('找不到页面', router.asPath)
+ })
+ }
+ }, 3000)
+ }, [])
+
+ return <>
+
+
+
404
+
+
页面无法加载,即将返回首页
+
+
+
+ >
+}
+
+/**
+ * 搜索页
+ * @param {*} props
+ * @returns
+ */
+const LayoutSearch = props => {
+ const { keyword } = props
+ const router = useRouter()
+ const { locale } = useGlobal()
+ useEffect(() => {
+ if (isBrowser) {
+ const container = document.getElementById('posts-wrapper')
+ if (keyword && container) {
+ replaceSearchResult({
+ doms: container,
+ search: keyword,
+ target: {
+ element: 'span',
+ className: 'text-red-500 border-b border-dashed'
+ }
+ })
+ }
+ }
+ }, [router, keyword])
+
+ return (
+ <>
+
+
+
+
+
+ >
+ )
+}
+
+/**
+ * 归档列表
+ * @param {*} props
+ * @returns 按照日期将文章分组排序
+ */
+const LayoutArchive = props => {
+ const { archivePosts } = props
+ const { locale } = useGlobal()
+ const keys = Object.keys(archivePosts || {}).sort((a, b) =>
+ String(b).localeCompare(String(a), undefined, {
+ sensitivity: 'base',
+ numeric: true
+ })
+ )
+ return (
+ <>
+
+
+ {keys.map(archiveTitle => (
+
+ ))}
+
+ >
+ )
+}
+
+/**
+ * 分类列表
+ * @param {*} props
+ * @returns
+ */
+const LayoutCategoryIndex = props => {
+ const { categoryOptions } = props
+ const { locale } = useGlobal()
+ return (
+ <>
+
+
+ {categoryOptions?.map(category => (
+
+
+ {category.name}
+ ({category.count})
+
+ ))}
+
+ >
+ )
+}
+
+/**
+ * 标签列表
+ * @param {*} props
+ * @returns
+ */
+const LayoutTagIndex = props => {
+ const { tagOptions } = props
+ const { locale } = useGlobal()
+ return (
+ <>
+
+
+ {tagOptions.map(tag => (
+
+
+ {tag.name}
+ {tag.count ? (
+ ({tag.count})
+ ) : null}
+
+ ))}
+
+ >
+ )
+}
+
+export {
+ Layout404,
+ LayoutArchive,
+ LayoutBase,
+ LayoutCategoryIndex,
+ LayoutIndex,
+ LayoutPostList,
+ LayoutSearch,
+ LayoutSlug,
+ LayoutTagIndex,
+ CONFIG as THEME_CONFIG
+}
diff --git a/themes/thoughtlite/style.js b/themes/thoughtlite/style.js
new file mode 100644
index 00000000000..bdb9d2aa91e
--- /dev/null
+++ b/themes/thoughtlite/style.js
@@ -0,0 +1,259 @@
+/* eslint-disable react/no-unknown-property */
+/**
+ * ThoughtLite 风格:仅作用于 #theme-thoughtlite
+ * 设计为 CSS 变量 + 少量全局子选择器,便于与 Notion 正文协同。
+ */
+const Style = () => {
+ return (
+
+ )
+}
+
+export { Style }