diff --git a/.cnb.yml b/.cnb.yml
index 8a22131..05b2ac6 100644
--- a/.cnb.yml
+++ b/.cnb.yml
@@ -1,40 +1,87 @@
-# 服务端 CI:类型检查、测试、构建。
-# 忽略文档改动,避免改 README 也跑一遍 Node。
+# CI:服务端(Node/TypeScript)、客户端(Web/TypeScript)、Android 壳(Kotlin)。
+#
+# 每个任务都用 ifModify 限定到自己那部分,避免改一行 README 就把三条流水线
+# 全跑一遍。
+
+# 服务端与客户端都是 Node 生态,共用同一个镜像锚点。
.node-env: &node-env
docker:
image: node:24
- ifModify:
- - server/**
-# 推送:完整检查
+.server-check: &server-check
+ - name: install
+ script: cd server && npm ci
+ - name: typecheck
+ script: cd server && npm run typecheck
+ - name: test
+ script: cd server && npm test
+ - name: build
+ script: cd server && npm run build
+
+.client-check: &client-check
+ - name: install
+ script: cd web && npm ci
+ - name: typecheck
+ script: cd web && npm run typecheck
+ - name: test
+ script: cd web && npm test
+ - name: build
+ script: cd web && npm run build
+
main:
push:
- name: server-ci
<<: *node-env
+ ifModify:
+ - server/**
stages:
- - name: install
- script: cd server && npm ci
- - name: typecheck
- script: cd server && npm run typecheck
- - name: test
- script: cd server && npm test
- - name: build
- script: cd server && npm run build
+ - *server-check
failStages:
- name: report-failure
- script: echo "CI 失败:$CNB_BUILD_FAILED_MSG"
+ script: echo "服务端 CI 失败:$CNB_BUILD_FAILED_MSG"
+
+ - name: client-ci
+ <<: *node-env
+ ifModify:
+ - web/**
+ stages:
+ - *client-check
+ failStages:
+ - name: report-failure
+ script: echo "客户端 CI 失败:$CNB_BUILD_FAILED_MSG"
+
+ # Android 壳。
+ #
+ # 先跑一个不依赖 JDK 的静态检查(秒级),再编译。原因是桥接契约的两半分别
+ # 用 Kotlin 和 TypeScript 写,互相没法类型检查:客户端调用一个壳里没有的
+ # 方法是最容易出、也最难在本地发现的错误。静态检查把它提前到这里。
+ - name: android-ci
+ docker:
+ image: docker.cnb.cool/dockerhub/androidsdk/android-35:latest
+ ifModify:
+ - android/**
+ - web/src/android-bridge.d.ts
+ stages:
+ - name: shell-contract
+ script: sh android/scripts/check-shell.sh
+ - name: assemble
+ script: cd android && ./gradlew --no-daemon :app:assembleDebug
+ failStages:
+ - name: report-failure
+ script: echo "Android 构建失败:$CNB_BUILD_FAILED_MSG"
-# PR:同样的检查,作为评审依据
"**":
pull_request:
- name: server-checks
<<: *node-env
+ ifModify:
+ - server/**
stages:
- - name: install
- script: cd server && npm ci
- - name: typecheck
- script: cd server && npm run typecheck
- - name: test
- script: cd server && npm test
- - name: build
- script: cd server && npm run build
+ - *server-check
+
+ - name: client-checks
+ <<: *node-env
+ ifModify:
+ - web/**
+ stages:
+ - *client-check
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..402f778
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,6 @@
+**/node_modules
+**/dist
+**/build
+**/.gradle
+data
+.git
diff --git a/.gitignore b/.gitignore
index c91cd23..7df981c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,6 @@ server/data/
.DS_Store
.idea/
.vscode/
+
+# web client build output (regenerated by `npm run build`)
+web/dist/
diff --git a/README.md b/README.md
index af7b4a5..25831b1 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,17 @@
# reader
-**自部署精品书库阅读器。** 把你自己磁盘上的电子书挂载成书库,用移动端以「精品级排版」阅读。
+**自部署精品书库阅读器。** 把你自己磁盘上的书籍挂载成书库,用 Android 或浏览器以「精品级排版」阅读。
书文件始终留在你自己的磁盘上,不经过任何厂商服务器。开源免费,AGPL-3.0。
+三个部分:
+
+| 部分 | 位置 | 说明 |
+| --- | --- | --- |
+| 服务端 | `server/` | Node.js + TypeScript,单容器,扫描书库并同步进度 |
+| 渲染层 | `web/` | TypeScript + Vite,EPUB 精排、TXT、漫画、PDF |
+| Android 壳 | `android/` | Kotlin + WebView,复用上面那一份渲染层 |
+
---
## 一条命令跑起来
@@ -20,6 +28,9 @@ docker compose up -d
打开 `http://<你的主机>:8080`,**第一个注册的账号自动成为管理员**。
+这时你已经有一个能用的 H5 阅读器了——服务端镜像里已经带了客户端,不用再起第二个容器。
+手机浏览器打开同一个地址即可阅读。要原生 App 见下面的「Android 客户端」。
+
> 只有第一次注册是开放的。之后新账号由管理员在后台创建(默认关闭公开注册),
> 这样你的实例不会被陌生人占用。想让家人自行注册,在 `docker-compose.yml` 里
> 把 `ALLOW_REGISTRATION` 设为 `"true"`。
@@ -53,10 +64,18 @@ volumes:
## 支持格式
-| 格式 | 第一版状态 |
-| --- | --- |
-| EPUB | 精排支持(内嵌元数据、封面、calibre 系列、dc:identifier) |
-| PDF | 保底可读(页数、基础内嵌元数据) |
+| 格式 | 状态 | 说明 |
+| --- | --- | --- |
+| EPUB | 精排 | 保留出版方样式、嵌入字体、脚注跳转、图文混排、竖排、振假名 |
+| TXT | 支持 | 自动识别 UTF-8 / GB18030 / Big5 / UTF-16,按章节标题切分 |
+| CBZ / ZIP | 支持 | 按页自然排序,逐页铺满屏幕 |
+| 图片目录 | 支持 | 一个文件夹里全是图片时,按文件夹当一本书 |
+| PDF | 保底可读 | 用浏览器自带阅读器,页数与进度照常上报,服务端不解析内容 |
+
+> TXT 是本地书库里最容易被忽视的一块。相当一部分中文 TXT 没有 BOM、没有编码声明,
+> 解码成 UTF-8 会满屏乱码。客户端先严格校验 UTF-8,失败再依次尝试 GB18030 与 Big5,
+> 并**在界面上告诉你选了哪个编码**,可手动覆盖。
+> 章节同样没有结构,只能从标题推断——推测不出时按长度分段,不会整本卡死。
---
@@ -120,6 +139,18 @@ volumes:
同步的只有轻量数据:书架元数据、阅读进度、笔记、高亮。书文件不走同步。
+### 两端的关系
+
+Android 端**不重写渲染**。它是一个 Kotlin 原生外壳,只负责文件缓存、手势、账号与书架,
+渲染复用同一份 `web/` 产物。原因很直接:排版策略一旦出现两份实现,就一定会分叉,
+而「忠于精排」正是这个产品的差异点。外壳只做 WebView 做不到或做不好的事:
+
+- **连通性**:WebView 里的 `navigator.onLine` 只要有网卡就说「在线」——
+ 连上一个没有出口的 Wi-Fi 时它会骗人,客户端于是转圈而不是读缓存。
+ Android 侧用系统的 `NET_CAPABILITY_VALIDATED` 判断。
+- **稳定的设备名**:UA 里的型号会随 Chrome 升级变化,作为「上次在哪个设备读的」标签不可靠。
+- **原生提示**:一行 Kotlin 的 Toast,不用在 Web 层造一套通知 UI。
+
### 离线合并语义
进度和笔记都带 `updatedAt`,冲突按 **last-writer-wins** 解决:
@@ -178,9 +209,44 @@ reader.example.com {
---
+## Android 客户端
+
+APK 走 GitHub Releases / 侧载分发,不上商店。开源项目没有收入覆盖 Apple 的
+99 美元开发者账号,所以 Android 先做、iOS 等有赞助再说(这条是设计文档 §5 定的)。
+
+### 自己构建
+
+```bash
+# 1. 先把 H5 渲染层构建好并放进 assets
+sh android/scripts/build-web-assets.sh
+
+# 2. 构建 APK
+cd android
+./gradlew :app:assembleDebug
+# 产物:android/app/build/outputs/apk/debug/app-debug.apk
+
+# 想预置服务端地址(个人自用构建):
+./gradlew :app:assembleRelease -PdefaultServerUrl=http://nas.local:8080
+```
+
+不预置地址是刻意的:没有人知道你会用什么 IP 访问自己的 NAS,
+所以首次启动时问一次、之后记住。
+
+### 装好之后
+
+打开 App → 填服务端地址(如 `http://192.168.1.10:8080`)→ 登录。
+之后所有书和进度都跟着账号走,与浏览器端互通。
+
+### 为什么允许明文 HTTP
+
+主要部署场景就是局域网 IP,而私有 IP 拿不到证书。要求 HTTPS 等于把用户挡在门外,
+这正是设计文档里说的「部署门槛是最大的获客阻力」。
+**但对公网暴露时请务必套一层 HTTPS**,否则令牌是明文传输的。
+
## 从源码开发
```bash
+# 服务端
cd server
npm install
npm test # 27 个测试用例
@@ -191,6 +257,15 @@ npm run dev # 开发模式,热重载
BOOKS_DIR=/tmp/books DATA_DIR=/tmp/data npm run dev
```
+```bash
+# 客户端(另开一个终端)
+cd web
+npm install
+npm test # 147 个测试用例
+npm run dev # http://localhost:5174,自动把 /api 代理到 8080
+npm run build # 产出 web/dist,服务端会在 / 上直接托管
+```
+
### 项目结构
```
@@ -208,7 +283,34 @@ server/src/
shelf.ts 书架查询、手动覆盖层
sync.ts 进度/笔记同步与冲突合并
merge.ts 元数据优先级链
- http/ Fastify 路由
+ http/ Fastify 路由(含 CORS 与 H5 静态托管)
+
+web/src/
+ api/ 服务端契约与请求层(令牌刷新在这里,全局只做一次)
+ core/ 平台抽象、同步引擎
+ platform.ts 两端共同接口:传输、存储、连通性
+ sync.ts 待发送队列 + 增量拉取 + 退避重试
+ android-platform.ts Android 侧的实现(很薄)
+ store/
+ offline.ts 本地镜像与待发送队列
+ idb.ts IndexedDB 包装,不可用时降级到内存
+ formats/ 格式层:拆包、编码识别、章节切分
+ epub.ts OPF/spine/nav 解析、资源重写
+ txt.ts 编码探测 + 章节推断 + 超长分段
+ comic.ts CBZ / 图片目录
+ detect.ts 魔数优先的格式识别
+ ui/
+ reader-view.ts 分页、滚动、手势、定位
+ locator.ts 阅读位置格式
+ shadow.ts 书的样式与 App 样式隔离
+ resources.ts 资源按需解出,不预展开
+ styles/reader.css ← 干预策略都在这里,注释说明每条为什么必要
+
+android/app/src/main/java/cool/cnb/reader/
+ MainActivity.kt 单 Activity,只做三件事
+ web/WebHost.kt WebView 配置与资源装载
+ bridge/ReaderBridge.kt 暴露给 JS 的原生方法
+ bridge/ConnectivityMonitor.kt 真正的连通性判断
```
### 技术选型说明
@@ -220,6 +322,24 @@ server/src/
会把镜像撑大并让安装变脆。
- **SQLite 存 metadata,不存书**:schema 里的 `books` 表可以随时重建,书本身才是真相源。
+### 客户端技术选型说明
+
+- **不用框架**:渲染层是命令式 DOM 操作——分页、注入文档、shadow root、量测列宽。
+ 引一个虚拟 DOM 只在代码和它要量测的布局之间多加一层,而这一层恰好是这个产品最不该
+ 透过它去调试的东西。产物是一个 169KB 的单文件 bundle。
+- **单文件 bundle、相对路径**:产物要被两个宿主消费——服务端托管给浏览器、Android 打进
+ assets。`file://` 或 WebView 里加载同源分片会踩平台特异性,所以 `inlineDynamicImports`
+ 打成一份,`base: './'`。
+- **不注入 reset 样式**:见 `web/src/styles/reader.css` 顶部。整个产品的差异点是「忠于
+ 出版方的排版」,而一份 reset 正好会抹掉出版方的字体、缩进和行距。
+ 可调项全部是可继承的自定义属性,默认值是 `inherit`——「关」的意思是「不动它」,
+ 不是「用我们的默认值」。
+- **书的样式装进 shadow root**:书里一条 `p { color: red }` 不能改到书架。
+ 用 iframe 也能隔离,但每次换章要重建文档,会丢滚动位置、在 Android 上多花几倍内存。
+- **书的内容不允许联网**:章节里所有本地引用在加载时被改写成内部 `reader-res:` 协议,
+ 由渲染层从内存里的资源表解出。绝对 URL 原样保留,然后被 CSP 挡掉——
+ 一本来路不明的书不能把阅读行为报到远端。
+
---
## API
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..16e1a3c
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,16 @@
+# Build output
+build/
+app/build/
+.gradle/
+local.properties
+
+# The staged web bundle is generated by scripts/build-web-assets.sh and copied
+# out of web/dist. Committing it would put a built artifact under version control
+# and make every client change show up twice in review.
+app/src/main/assets/web-assets/*
+!app/src/main/assets/web-assets/README.txt
+
+# Keystores and signing material must never be committed.
+*.jks
+*.keystore
+keystore.properties
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..298d95e
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,95 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "cool.cnb.reader"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "cool.cnb.reader"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.1.0"
+
+ /*
+ * Where the WebView points on first launch.
+ *
+ * Left empty on purpose. Baking in an address would produce an app that
+ * cannot be used by anyone but its author, and the reader does not know
+ * the IP it will be reached at (product design §8.1: deployment friction
+ * is the biggest adoption barrier). The login screen asks for it once and
+ * remembers it.
+ *
+ * `DEFAULT_SERVER_URL` can be set at build time for a preconfigured
+ * personal build: ./gradlew assembleDebug -PdefaultServerUrl=http://nas:8080
+ */
+ buildConfigField(
+ "String",
+ "DEFAULT_SERVER_URL",
+ "\"${(project.findProperty("defaultServerUrl") as String?) ?: ""}\"",
+ )
+ }
+
+ buildFeatures {
+ buildConfig = true
+ viewBinding = false
+ }
+
+ buildTypes {
+ debug {
+ // Same applicationId as release so a debug build upgrades over a
+ // release one and keeps the reader's session and offline books.
+ isMinifyEnabled = false
+ }
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ // Signed with the debug key unless a keystore is provided. An
+ // unsigned release APK cannot be side-loaded at all, and this project
+ // has no signing infrastructure to promise (product design §7).
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ sourceSets {
+ getByName("main") {
+ /*
+ * The shared web client is built by Vite into `web/dist` and packed
+ * into assets. This is the single mechanism that keeps the Android
+ * and H5 clients from becoming two implementations: one bundle, two
+ * hosts.
+ *
+ * The directory has to exist for a clean checkout to build. The
+ * placeholder committed under `web-assets` is what makes that work.
+ */
+ assets.srcDirs("src/main/assets", project.layout.projectDirectory.dir("web-assets").asFile)
+ }
+ }
+
+ packaging {
+ resources.excludes += setOf("META-INF/*.kotlin_module")
+ }
+}
+
+dependencies {
+ implementation("androidx.core:core-ktx:1.15.0")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("androidx.activity:activity-ktx:1.9.3")
+ // WebViewAssetLoader: serves the bundled web client over an https:// origin
+ // instead of file://, which is what makes fetch() to the reader server work
+ // without disabling web security. See WebHost.kt.
+ implementation("androidx.webkit:webkit:1.12.1")
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..4a18cc5
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,9 @@
+# The bridge methods are called from JavaScript by name via the @JavascriptInterface
+# annotation. R8 cannot see those call sites, so without this the release build
+# would strip them and the client would silently lose connectivity reporting and
+# the device label.
+-keepclassmembers class cool.cnb.reader.bridge.ReaderBridge {
+ public *;
+}
+-keepattributes JavascriptInterface
+-keepattributes *Annotation*
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..2351508
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/assets/web-assets/README.txt b/android/app/src/main/assets/web-assets/README.txt
new file mode 100644
index 0000000..2b8aa02
--- /dev/null
+++ b/android/app/src/main/assets/web-assets/README.txt
@@ -0,0 +1,5 @@
+Placeholder so a clean checkout can be built.
+
+`scripts/build-web-assets.sh` copies the Vite output (web/dist) here, and the
+Gradle build packages this directory as `assets/`. Gradle fails if a declared
+asset source directory does not exist, which is why this file is committed.
diff --git a/android/app/src/main/java/cool/cnb/reader/MainActivity.kt b/android/app/src/main/java/cool/cnb/reader/MainActivity.kt
new file mode 100644
index 0000000..b814de3
--- /dev/null
+++ b/android/app/src/main/java/cool/cnb/reader/MainActivity.kt
@@ -0,0 +1,126 @@
+package cool.cnb.reader
+
+import android.annotation.SuppressLint
+import android.graphics.Color
+import android.os.Bundle
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.WebView
+import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import android.widget.FrameLayout
+import cool.cnb.reader.bridge.ConnectivityMonitor
+import cool.cnb.reader.bridge.ReaderBridge
+import cool.cnb.reader.web.WebHost
+
+/**
+ * The only activity.
+ *
+ * The shell is thin by design: it hosts the shared web client, exposes a small
+ * bridge for the things a WebView cannot do, and handles the back gesture. Every
+ * feature that is not one of those three lives in the web layer, because that is
+ * what keeps the Android and browser clients from diverging.
+ */
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var webView: WebView
+ private lateinit var connectivity: ConnectivityMonitor
+ private var host: WebHost? = null
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ connectivity = ConnectivityMonitor(this)
+
+ val root = FrameLayout(this).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ }
+
+ webView = WebView(this).apply {
+ layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ // Transparent background: the activity window already shows the paper
+ // colour, so anything else would flash on every page transition.
+ setBackgroundColor(Color.TRANSPARENT)
+ // A long-press on text is how the reader selects a passage; the
+ // platform's own context menu is the wrong affordance for that and
+ // covers the text being selected.
+ isLongClickable = true
+ isHapticFeedbackEnabled = true
+ overScrollMode = View.OVER_SCROLL_NEVER
+ }
+ root.addView(webView)
+ setContentView(root)
+
+ applyInsets(root)
+
+ host = WebHost(this, webView).apply {
+ install(ReaderBridge(this@MainActivity, webView, connectivity))
+ }
+
+ // Back should walk the reader's own history (shelf → book → shelf) rather
+ // than exiting the app, and only leave when there is nothing left.
+ onBackPressedDispatcher.addCallback(
+ this,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (webView.canGoBack()) {
+ webView.goBack()
+ } else {
+ isEnabled = false
+ onBackPressedDispatcher.onBackPressed()
+ }
+ }
+ },
+ )
+ }
+
+ /**
+ * Keeps the WebView clear of the status bar, the navigation bar and the
+ * on-screen keyboard.
+ *
+ * Without this the reader's footer sits under the gesture bar, which is
+ * infuriating on a phone with no physical buttons: the last line of a page is
+ * permanently covered. `adjustResize` in the manifest is not sufficient on
+ * its own from API 30 onwards.
+ */
+ private fun applyInsets(root: FrameLayout) {
+ ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
+ val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime())
+ view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
+ insets
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ // Ask the web layer to flush its outbox before the process can be frozen.
+ // This is the hook that makes "read, then immediately switch apps" not
+ // lose the last few pages: the reader's own debounce would never fire.
+ webView.evaluateJavascript("window.__readerFlush && window.__readerFlush()", null)
+ webView.onPause()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ webView.onResume()
+ }
+
+ override fun onDestroy() {
+ host?.destroy()
+ connectivity.close()
+ // Detaching the WebView explicitly: leaving it in the hierarchy leaks the
+ // whole renderer process across a configuration change.
+ (webView.parent as? ViewGroup)?.removeView(webView)
+ webView.destroy()
+ super.onDestroy()
+ }
+}
diff --git a/android/app/src/main/java/cool/cnb/reader/bridge/ConnectivityMonitor.kt b/android/app/src/main/java/cool/cnb/reader/bridge/ConnectivityMonitor.kt
new file mode 100644
index 0000000..af3e4c3
--- /dev/null
+++ b/android/app/src/main/java/cool/cnb/reader/bridge/ConnectivityMonitor.kt
@@ -0,0 +1,87 @@
+package cool.cnb.reader.bridge
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import java.io.Closeable
+
+/**
+ * Real connectivity, which the WebView cannot provide.
+ *
+ * `navigator.onLine` inside a WebView reports "true" whenever a network interface
+ * exists. On a phone that has joined a Wi-Fi network with no route to the NAS —
+ * a captive portal, a hotel network, a VPN that has not connected yet — it says
+ * "online" and the client happily attempts requests that will all time out. The
+ * user then sees a spinner instead of their cached library.
+ *
+ * `NetworkCapabilities.NET_CAPABILITY_VALIDATED` is the platform's own answer to
+ * "can this device actually reach the internet", which is the question the client
+ * is really asking. Reporting it is what makes the offline path switch at the
+ * right moment instead of several seconds later.
+ */
+class ConnectivityMonitor(context: Context) : Closeable {
+
+ private val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ private val listeners = mutableSetOf<(String) -> Unit>()
+ private var validated = currentValidation(manager)
+
+ private val callback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) = recompute()
+ override fun onLost(network: Network) = recompute()
+ override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) = recompute()
+ }
+
+ init {
+ val request = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+ // Registering for a request, not `registerDefaultNetworkCallback`: the app
+ // may be on Wi-Fi with no uplink while a VPN provides the route to the
+ // server, and the default network would miss that.
+ runCatching { manager.registerNetworkCallback(request, callback) }
+ .onFailure {
+ // Android 10+ requires ACCESS_NETWORK_STATE, which the manifest
+ // declares. If registration still fails the monitor degrades to a
+ // best-effort snapshot rather than taking the app down.
+ validated = currentValidation(manager)
+ }
+ }
+
+ /** 'online' or 'offline', matching the strings the web bridge expects. */
+ fun current(): String = if (validated) "online" else "offline"
+
+ fun watch(listener: (String) -> Unit) {
+ listeners.add(listener)
+ // Report the current state immediately: the client registers its listener
+ // after the bridge exists, so without this it would wait for the next
+ // network change to learn that it is offline.
+ listener(current())
+ }
+
+ private fun recompute() {
+ val next = currentValidation(manager)
+ if (next == validated) return
+ validated = next
+ val state = current()
+ for (listener in listeners.toList()) listener(state)
+ }
+
+ override fun close() {
+ listeners.clear()
+ runCatching { manager.unregisterNetworkCallback(callback) }
+ }
+
+ private fun currentValidation(manager: ConnectivityManager): Boolean {
+ val network = manager.activeNetwork ?: return false
+ val capabilities = manager.getNetworkCapabilities(network) ?: return false
+ if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) return false
+ // VALIDATED means the system has confirmed the network reaches the
+ // internet. A network without it may still reach a LAN server, so this is
+ // treated as a hint rather than the whole truth: the client's own requests
+ // are what ultimately decide, and they fall back to cache either way.
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) ||
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
+ }
+}
diff --git a/android/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt b/android/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt
new file mode 100644
index 0000000..9f4b72f
--- /dev/null
+++ b/android/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt
@@ -0,0 +1,205 @@
+package cool.cnb.reader.bridge
+
+import android.content.Context
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.webkit.JavascriptInterface
+import android.webkit.WebView
+import android.widget.Toast
+import org.json.JSONObject
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * The native surface exposed to the web client as `window.ReaderAndroid`.
+ *
+ * Scope is decided by one rule: a method belongs here only if the WebView cannot
+ * do it, or does it wrong. Everything else stays in the shared web layer, which is
+ * what keeps the Android and browser clients one implementation instead of two.
+ *
+ * By that rule the bridge has exactly four responsibilities:
+ *
+ * - connectivity, because `navigator.onLine` lies in a WebView;
+ * - a stable device label, because the UA-derived one changes when Chrome
+ * updates and would fragment a reader's own history;
+ * - native feedback (a toast), which is one line here and a whole notification
+ * UI in the web layer;
+ * - a way for the client to ask about its host, so it can adapt rather than
+ * guess.
+ *
+ * Every method is safe to call from any thread because `@JavascriptInterface`
+ * calls arrive on a WebView-owned thread, not the main one: the ones that touch UI
+ * post back to the main looper.
+ */
+class ReaderBridge(
+ private val context: Context,
+ private val webView: WebView,
+ private val connectivity: ConnectivityMonitor,
+) {
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ /**
+ * Shell version.
+ *
+ * The web client requires a minimum (see MIN_SHELL_VERSION in the client) so
+ * that an old APK paired with a new client is refused up front rather than
+ * failing later in a way that looks like a reader bug.
+ */
+ @JavascriptInterface
+ fun shellVersion(): Int = SHELL_VERSION
+
+ /**
+ * A device label that is stable and meaningful to a human.
+ *
+ * `Build.MODEL` is what the reader recognises — "Pixel 7", "SM-G991B" — and
+ * unlike a user-agent string it does not change when the WebView is updated.
+ * It is recorded on every progress row so the UI can say where a book was last
+ * read.
+ */
+ @JavascriptInterface
+ fun deviceLabel(): String {
+ val model = Build.MODEL?.trim().orEmpty()
+ val manufacturer = Build.MANUFACTURER?.trim().orEmpty().replaceFirstChar { it.uppercase() }
+ val label = when {
+ model.isEmpty() -> "Android"
+ // Some OEMs prefix the model with the manufacturer ("Xiaomi Redmi…"),
+ // which reads as a duplicate.
+ model.startsWith(manufacturer, ignoreCase = true) -> model
+ manufacturer.isEmpty() -> model
+ else -> "$manufacturer $model"
+ }
+ return label.take(60)
+ }
+
+ /**
+ * Validated connectivity, not merely "an interface exists".
+ *
+ * See ConnectivityMonitor for why this distinction is the whole reason the
+ * bridge exists.
+ */
+ @JavascriptInterface
+ fun connectivity(): String = connectivity.current()
+
+ /**
+ * Registers a callback invoked whenever connectivity changes.
+ *
+ * The web client passes a JavaScript function; it is invoked by name through
+ * `evaluateJavascript` because that is the only direction a WebView allows
+ * without a custom `WebMessagePort` handshake, which would be more machinery
+ * for the same result.
+ */
+ @JavascriptInterface
+ fun watchConnectivity(callbackName: String) {
+ if (callbackName.isBlank() || !SAFE_IDENTIFIER.matches(callbackName)) return
+ connectivity.watch { state ->
+ mainHandler.post {
+ // JSON-encoded so the string cannot break out of the call site.
+ webView.evaluateJavascript(
+ "window['$callbackName'] && window['$callbackName'](${JSONObject.quote(state)})",
+ null,
+ )
+ }
+ }
+ }
+
+ /**
+ * Viewport in physical pixels plus the density.
+ *
+ * Fixed-layout books (comics, PDF) size themselves against the real viewport,
+ * and a WebView's CSS pixel size is not the same as the display's. Reporting
+ * both lets the client reason in CSS pixels while knowing the true resolution.
+ */
+ @JavascriptInterface
+ fun viewport(): String {
+ val metrics = context.resources.displayMetrics
+ return JSONObject()
+ .put("width", metrics.widthPixels)
+ .put("height", metrics.heightPixels)
+ .put("density", metrics.density.toDouble())
+ .put("densityDpi", metrics.densityDpi)
+ .toString()
+ }
+
+ /** Native toast, for feedback that should survive the web layer being busy. */
+ @JavascriptInterface
+ fun toast(message: String) {
+ val text = message.take(200)
+ mainHandler.post { Toast.makeText(context, text, Toast.LENGTH_SHORT).show() }
+ }
+
+ @JavascriptInterface
+ fun hasNetwork(): Boolean = connectivity.current() == "online"
+
+ /** Diagnostics for a bug report the reader can copy out of the app. */
+ @JavascriptInterface
+ fun diagnostics(): String {
+ val info = JSONObject()
+ .put("shellVersion", SHELL_VERSION)
+ .put("connectivity", connectivity.current())
+ .put("device", deviceLabel())
+ .put("androidRelease", Build.VERSION.RELEASE)
+ .put("androidSdk", Build.VERSION.SDK_INT)
+ .put("appVersion", appVersion())
+ .put("time", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).format(Date()))
+ return info.toString()
+ }
+
+ /** Files the client has cached, so a storage screen can be honest about it. */
+ @JavascriptInterface
+ fun cacheUsage(): String {
+ val info = JSONObject()
+ info.put("webViewBytes", directorySize(context.cacheDir))
+ info.put("dataBytes", directorySize(context.filesDir))
+ info.put("availableBytes", context.filesDir.usableSpace)
+ return info.toString()
+ }
+
+ private fun appVersion(): String = runCatching {
+ val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
+ packageInfo.versionName ?: "unknown"
+ }.getOrDefault("unknown")
+
+ /** Recursive size of a directory, bounded so a huge tree cannot stall a call. */
+ private fun directorySize(dir: java.io.File, budget: Int = 20_000): Long {
+ if (budget <= 0 || !dir.exists()) return 0
+ var total = 0L
+ var remaining = budget
+ val stack = ArrayDeque()
+ stack.addLast(dir)
+ while (stack.isNotEmpty() && remaining > 0) {
+ val current = stack.removeLast()
+ val children = current.listFiles() ?: continue
+ for (child in children) {
+ remaining -= 1
+ if (remaining <= 0) break
+ if (child.isDirectory) stack.addLast(child) else total += child.length()
+ }
+ }
+ return total
+ }
+
+ companion object {
+ /** Name the web client looks for on `window`. */
+ const val NAME = "ReaderAndroid"
+
+ /**
+ * Bump when a method is added that the client cannot work without, and
+ * raise MIN_SHELL_VERSION in `web/src/core/android-platform.ts` to match.
+ */
+ const val SHELL_VERSION = 1
+
+ /**
+ * Callback names must be plain identifiers.
+ *
+ * The name is interpolated into an `evaluateJavascript` string, so an
+ * unrestricted value would let the web side run arbitrary script in its own
+ * context. The context is the client's own, so the risk is low, but a
+ * bridge that will interpolate a string should validate it rather than
+ * assume.
+ */
+ private val SAFE_IDENTIFIER = Regex("^[A-Za-z_$][A-Za-z0-9_$]{0,64}$")
+ }
+}
diff --git a/android/app/src/main/java/cool/cnb/reader/web/WebHost.kt b/android/app/src/main/java/cool/cnb/reader/web/WebHost.kt
new file mode 100644
index 0000000..5305a29
--- /dev/null
+++ b/android/app/src/main/java/cool/cnb/reader/web/WebHost.kt
@@ -0,0 +1,185 @@
+package cool.cnb.reader.web
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import android.webkit.ConsoleMessage
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.webkit.WebViewAssetLoader
+import cool.cnb.reader.BuildConfig
+import cool.cnb.reader.bridge.ReaderBridge
+
+/**
+ * Configures the WebView and serves the bundled client to it.
+ *
+ * The interesting decision here is the **origin**.
+ *
+ * The obvious way to load a bundled web app is `file:///android_asset/...`. That
+ * does not work for this product: `fetch()` from a `file://` origin is treated as
+ * an opaque cross-origin request, so every call to the reader server would be
+ * blocked — and the usual workaround is to disable web security, which turns a
+ * WebView into an open door.
+ *
+ * `WebViewAssetLoader` serves the same files from `https://appassets.androidplatform.net/`,
+ * a proper secure origin. The client's fetches then go out as ordinary CORS
+ * requests, which is exactly what the server's CORS policy is written for, and
+ * `isSecureContext` is true so IndexedDB and other APIs behave the same as they
+ * do in a browser. That one choice is what lets a single web bundle run unchanged
+ * in both hosts.
+ */
+class WebHost(
+ private val context: Context,
+ private val webView: WebView,
+) {
+
+ private val assetLoader = WebViewAssetLoader.Builder()
+ .setDomain(ASSET_HOST)
+ .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context))
+ .build()
+
+ fun install(bridge: ReaderBridge) {
+ configure()
+ webView.addJavascriptInterface(bridge, ReaderBridge.NAME)
+ webView.webViewClient = AssetClient()
+ webView.webChromeClient = ChromeClient()
+ webView.loadUrl(START_URL)
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun configure() {
+ val settings = webView.settings
+
+ // The client is our own bundle served from a same-origin asset loader, not
+ // an arbitrary page from the internet. JavaScript is the renderer here, so
+ // enabling it is not a decision that has an alternative.
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ settings.databaseEnabled = true
+
+ // A book is laid out as a document with a realistic viewport. Without
+ // these two the WebView renders at a 980px desktop width and the reader
+ // gets a zoomed-out page of unreadable text.
+ settings.useWideViewPort = true
+ settings.loadWithOverviewMode = true
+ // The web layer already handles small screens in CSS, so the WebView must
+ // not add its own zoom on top; the reader has an explicit text-size control
+ // instead, which is the one that respects the book's typography.
+ settings.setSupportZoom(false)
+ settings.builtInZoomControls = false
+ settings.textZoom = 100
+
+ // The bundled client is https:// (asset loader) and the user's server is
+ // usually http:// on the LAN. Blocking that combination would make the app
+ // unable to reach the very server it exists for. The manifest has the full
+ // reasoning for why cleartext is the right trade for this product.
+ settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+
+ // Let the HTTP cache do its job: book bodies are immutable per hash and
+ // already carry an ETag.
+ settings.cacheMode = WebSettings.LOAD_DEFAULT
+
+ // Nothing in the client asks for any of these, and each is a capability
+ // that exists only to be abused.
+ settings.setGeolocationEnabled(false)
+ settings.allowFileAccess = false
+ settings.allowContentAccess = false
+
+ if (BuildConfig.DEBUG) {
+ // Debug only: a release build must not expose a remote debugging port
+ // that any other app on the device can attach to.
+ WebView.setWebContentsDebuggingEnabled(true)
+ }
+ }
+
+ fun destroy() {
+ webView.stopLoading()
+ webView.clearHistory()
+ webView.removeJavascriptInterface(ReaderBridge.NAME)
+ }
+
+ /**
+ * Serves the bundled assets and keeps the reading session inside the app.
+ *
+ * A link out of a book is handed to the platform rather than followed: it
+ * should open in the user's browser, not replace the reader with a web page
+ * and lose the reading session.
+ */
+ private inner class AssetClient : WebViewClient() {
+ override fun shouldInterceptRequest(
+ view: WebView,
+ request: WebResourceRequest,
+ ): WebResourceResponse? = assetLoader.shouldInterceptRequest(request.url)
+
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
+ val url = request.url.toString()
+ if (isInternalUrl(url)) return false
+ openExternally(url)
+ return true
+ }
+
+ private fun openExternally(url: String) {
+ runCatching {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }.onFailure {
+ // No browser installed, or the scheme is unsupported. Nothing to
+ // do, and nothing worth interrupting the reader for.
+ Log.d(TAG, "cannot open external url")
+ }
+ }
+ }
+
+ private inner class ChromeClient : WebChromeClient() {
+ override fun onConsoleMessage(message: ConsoleMessage): Boolean {
+ // Surface the client's console into logcat. Without this, debugging the
+ // web layer on a device means attaching a remote debugger, which is not
+ // a realistic ask for a self-hosting user filing a bug report.
+ Log.d(TAG, "[web] ${message.message()} @${message.lineNumber()}")
+ return true
+ }
+ }
+
+ companion object {
+ private const val TAG = "ReaderWeb"
+
+ /**
+ * A reserved domain that `WebViewAssetLoader` intercepts.
+ *
+ * Deliberately not resolvable on the public internet: if the interception
+ * were ever bypassed, nothing would be fetched from a real host.
+ */
+ const val ASSET_HOST = "appassets.androidplatform.net"
+ const val ASSET_ORIGIN = "https://$ASSET_HOST"
+ private const val START_URL = "$ASSET_ORIGIN/assets/index.html"
+
+ /** Address the client is loaded from, reported through the bridge. */
+ fun assetOrigin(): String = ASSET_ORIGIN
+ }
+}
+
+/**
+ * URLs that must stay inside the WebView.
+ *
+ * The internal `reader-res:` scheme is how injected book content resolves its own
+ * images and stylesheets out of the in-memory resource map; letting it be treated
+ * as external would break every illustrated book.
+ */
+internal fun isInternalUrl(url: String): Boolean {
+ val internalPrefixes = listOf(
+ WebHost.ASSET_ORIGIN,
+ "reader-res:",
+ "blob:",
+ "data:",
+ "about:",
+ )
+ return internalPrefixes.any { url.startsWith(it) }
+}
diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..d64198e
--- /dev/null
+++ b/android/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..fcb0d3d
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+
+ #FBFBF7
+ #16171A
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..7a3cc51
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ reader
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..8a9ff1e
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..3b73701
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..035feb3
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,12 @@
+/*
+ * Root build file.
+ *
+ * The Android shell is deliberately dependency-light. It has exactly two jobs —
+ * host a WebView and copy the shared web bundle out of assets — and every extra
+ * dependency is weight an open-source APK has to justify, plus another thing that
+ * can be missing from F-Droid or a de-Googled device.
+ */
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..7dfa41d
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,8 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+
+kotlin.code.style=official
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..660449f
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e2847c8
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..37646d9
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,17 @@
+#!/bin/sh
+# Gradle wrapper launcher.
+#
+# Committed so a contributor (and CI) can build without installing Gradle. It
+# downloads the distribution named in gradle/wrapper/gradle-wrapper.properties on
+# first use and caches it.
+set -eu
+
+APP_HOME=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+JAVA_CMD=${JAVA_HOME:+$JAVA_HOME/bin/java}
+JAVA_CMD=${JAVA_CMD:-java}
+
+exec "$JAVA_CMD" \
+ -Xmx2048m \
+ -classpath "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
diff --git a/android/scripts/build-web-assets.sh b/android/scripts/build-web-assets.sh
new file mode 100755
index 0000000..b03b181
--- /dev/null
+++ b/android/scripts/build-web-assets.sh
@@ -0,0 +1,40 @@
+#!/bin/sh
+# Builds the shared web client and stages it as Android assets.
+#
+# This script is the mechanism that keeps the Android and H5 clients from becoming
+# two implementations: one Vite bundle, one host-agnostic web layer, two hosts.
+#
+# Run from anywhere; paths are resolved relative to the script.
+set -eu
+
+script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+android_dir=$(dirname -- "$script_dir")
+repo_dir=$(dirname -- "$android_dir")
+web_dir="$repo_dir/web"
+dest="$android_dir/app/src/main/assets/web-assets"
+
+if [ ! -d "$web_dir" ]; then
+ echo "web client not found at $web_dir" >&2
+ exit 1
+fi
+
+echo "==> building web client"
+cd "$web_dir"
+# `npm ci` when a lockfile exists so a release build is reproducible; `install`
+# otherwise so a first-time contributor is not blocked on generating one.
+if [ -f package-lock.json ]; then
+ npm ci
+else
+ npm install
+fi
+npm run build
+
+echo "==> staging assets into $dest"
+rm -rf "$dest"
+mkdir -p "$dest"
+# Only the built output. Copying the sources would ship the whole TypeScript tree
+# inside the APK for no benefit.
+cp -R "$web_dir/dist/." "$dest/"
+
+echo "==> done"
+echo "The Android build now packs $(find "$dest" -type f | wc -l | tr -d ' ') files as assets."
diff --git a/android/scripts/check-shell.sh b/android/scripts/check-shell.sh
new file mode 100755
index 0000000..059f8bd
--- /dev/null
+++ b/android/scripts/check-shell.sh
@@ -0,0 +1,75 @@
+#!/bin/sh
+# Static checks on the Android shell that do not need a JDK or the Android SDK.
+#
+# CI compiles the shell for real, but that job needs a large SDK image and takes
+# minutes. This runs in a second, so a typo in a package declaration or a bridge
+# method that the web client calls but the shell does not expose is caught before
+# anyone waits for a build — which matters more than usual here, because the two
+# halves of the bridge are written in different languages and cannot be
+# type-checked against each other.
+set -eu
+
+script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+android_dir=$(dirname -- "$script_dir")
+repo_dir=$(dirname -- "$android_dir")
+fail=0
+
+note() { printf '%s\n' "$1"; }
+err() { printf 'FAIL: %s\n' "$1" >&2; fail=1; }
+
+# 1. Package declaration must match the file's directory under src/main/java.
+note "==> checking package declarations match directory layout"
+for file in $(find "$android_dir/app/src" -name '*.kt'); do
+ pkg=$(sed -n 's/^package \(.*\)$/\1/p' "$file" | head -1)
+ rel=${file#"$android_dir"/app/src/main/java/}
+ expected=$(dirname "$rel" | tr '/' '.')
+ if [ "$pkg" != "$expected" ]; then
+ err "$file declares '$pkg' but lives in '$expected'"
+ fi
+done
+
+# 2. Every import of our own code must resolve to a file that exists.
+note "==> checking project imports resolve"
+for file in $(find "$android_dir/app/src" -name '*.kt'); do
+ sed -n 's/^import cool\.cnb\.reader\.\(.*\)$/\1/p' "$file" | while read -r symbol; do
+ # `BuildConfig` is generated, and a class name is the last dot segment.
+ case "$symbol" in
+ BuildConfig) continue ;;
+ esac
+ path="$android_dir/app/src/main/java/cool/cnb/reader/$(printf '%s' "$symbol" | tr '.' '/').kt"
+ if [ ! -f "$path" ]; then
+ err "$file imports cool.cnb.reader.$symbol but $path does not exist"
+ fi
+ done
+done
+
+# 3. Every bridge method the web client calls must exist, and every bridge method
+# the shell exposes must be declared. This is the cross-language seam.
+note "==> checking the JS bridge contract in both directions"
+bridge_file="$android_dir/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt"
+declared=$(sed -n 's/^ fun \([A-Za-z][A-Za-z0-9_]*\)(.*/\1/p' "$bridge_file" | sort -u)
+types_file="$repo_dir/web/src/android-bridge.d.ts"
+consumed=$(sed -n 's/^ \([a-z][A-Za-z0-9_]*\)[?]\?(.*:.*;.*$/\1/p' "$types_file" | sort -u)
+
+for method in $consumed; do
+ if ! printf '%s\n' "$declared" | grep -qx "$method"; then
+ err "web client calls ReaderAndroid.$method() but ReaderBridge.kt does not declare it"
+ fi
+done
+
+# Every bridge method must also appear in the client-facing declaration, or the
+# next person writing client code will not know it is available.
+missing_from_types=0
+for method in $declared; do
+ if ! printf '%s\n' "$consumed" | grep -qx "$method"; then
+ err "ReaderBridge.$method exists but is missing from android-bridge.d.ts"
+ missing_from_types=1
+ fi
+done
+[ "$missing_from_types" -eq 0 ] || true
+
+if [ "$fail" -ne 0 ]; then
+ note "==> shell checks failed"
+ exit 1
+fi
+note "==> shell checks passed"
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..a4bb497
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "reader"
+include(":app")
diff --git a/docker-compose.yml b/docker-compose.yml
index 4004864..94d0906 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,8 +7,12 @@
services:
reader:
image: cnb.cool/hectorqin/reader:latest
- # To build from source instead of pulling the published image:
- # build: ./server
+ # To build from source instead of pulling the published image. The context is
+ # the repository root because the image bakes in the H5 client built from
+ # ./web as well as the server.
+ # build:
+ # context: .
+ # dockerfile: server/Dockerfile
container_name: reader
restart: unless-stopped
ports:
@@ -25,6 +29,14 @@ services:
# Uncomment to allow family members to self-register instead of the admin
# creating their accounts:
# ALLOW_REGISTRATION: "true"
+ # Origins allowed to call the API from a browser, comma separated.
+ # Empty (the default) reflects whatever origin asks. Fine for a LAN: the
+ # client does not know the address it will be reached on, and there are no
+ # cookies in the design. Set it if the instance is on the public internet:
+ # CORS_ORIGINS: "https://reader.example.com"
+ # Directory holding the built H5 client, served at `/`. Already set in the
+ # image; only override if you build the client separately.
+ # WEB_DIR: "/app/web"
volumes:
# --- your books, mounted READ-ONLY ---
# The server NEVER writes to this directory. Replace the left-hand side
diff --git a/docs/api.md b/docs/api.md
index cef043e..0a6a038 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -135,6 +135,16 @@
书内容按哈希不可变,所以带 `ETag`,客户端可以放心长缓存。
+### `GET /books/:id/file?path=`
+
+流式返回这本书的**某一个**文件。
+
+只有一种格式需要它:以图片目录形式存放的漫画。这种情况没有一个「书文件」可以整体
+下载,客户端按页取。`path` 必须是这本书已经登记过的 `rel_path`——
+服务端按 `book_files` 表精确匹配,不会用 query 拼路径,所以路径穿越只会匹配到空。
+
+响应与 `/content` 一致,但 `ETag` 是按文件而非按书。
+
### `GET /books/:id/cover`
返回封面。无封面时 `404 NO_COVER`。缓存头是 `immutable`(封面随书籍主键变化)。
@@ -156,6 +166,17 @@
---
+## 静态客户端
+
+### `GET /` 与 `GET /*` — 公开
+
+服务端会托管构建好的 H5 客户端(构建时打进镜像,或由 `WEB_DIR` 指定)。
+
+- 找不到客户端产物时这些路由**不会注册**,纯服务端部署不受影响
+- 都是公开的:它们只是静态资源,背后的 API 仍然要令牌
+- 不带扩展名的路径一律回落到 `index.html`,方便深链
+- `.html` 不缓存(否则升级后拿不到新产物),其余资源缓存 1 小时
+
## 书库
### `GET /library/facets`
@@ -312,3 +333,15 @@
| `USERNAME_TAKEN` | 409 | 用户名已占用 |
| `PASSWORD_TOO_SHORT` | 409 | 口令少于 8 位 |
| `INTERNAL` | 500 | 服务端错误 |
+
+## 客户端应当遵守的约定
+
+这几条不是服务端强制,而是客户端行为的一部分,写在这里以免下一个客户端重新踩:
+
+1. **保存每次响应里的 `serverTime`**,下次带回来。从 0 拉全量在移动网络上很贵。
+2. **`locator` 服务端不解释**,格式由客户端定义,但一旦发布就要向后兼容——
+ 用户不会及时升级客户端,和自部署用户不会及时升级服务端是同一个问题。
+ 当前客户端用的是 `r1:<偏移>:<章节 id>`,偏移是章节内的比例而非像素或列号,
+ 这样换字号、换屏幕宽度、换主题都不会跳位置。
+3. **拉取回来的 `deleted: true` 是墓碑**,本地也要删;不要因为它「看起来是空的」就丢弃。
+4. **推送是合并而不是覆盖**,按 `updatedAt` 做 LWW。所以离线队列可以放心重放。
diff --git a/docs/architecture.md b/docs/architecture.md
index 4ec4e95..2937c08 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -183,18 +183,112 @@ id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )
---
-## 8. 客户端渲染(尚未实现)
-
-产品设计里的关键决策:**不要直接套 Readium Mobile**。
+## 8. 客户端渲染
+已落地。产品设计里的关键决策:**不要直接套 Readium Mobile**。
Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本产品
「忠实还原精排书设计」的核心差异化直接冲突。
-计划方案(下一轮):
+实际方案:
-- 原生外壳(Kotlin)只负责:文件与缓存、手势、书架、账号
-- 渲染层用 WebView + 自研 CSS 干预策略,参考 foliate-js 的「保留作者样式 + 最小覆盖」
+- 原生外壳(Kotlin)只负责:文件与缓存、手势、账号、连通性
+- 渲染层是 **WebView + 自研 CSS 干预策略**,保留作者样式、只做最小覆盖
- 参考 readium-css 的变量组织方式,但**不引入它的默认接管行为**
-服务端为此准备的是 `GET /api/v1/books/:id/manifest`:格式中立的清单接口,
-返回书信息和 `contentUrl`,PDF 支持可以复用同一个契约。
+### 只有一份渲染层
+
+Android 不重写渲染,它加载的是同一份 `web/` 产物。这不是为了省事,
+而是因为排版策略一旦有第二份实现就一定会分叉,而排版正是差异点所在。
+
+两边共用的抽象在 `web/src/core/platform.ts`:传输、键值存储、二进制存储、连通性。
+Android 只覆盖它真正能做得更好的部分——连通性和设备名——其余全部复用。
+
+### Android 用 https 资产源,不用 file://
+
+把打包好的 Web 应用塞进 assets,最直觉的加载方式是 `file:///android_asset/...`。
+这条路对**这个**产品是走不通的:从 `file://` 源发出的 `fetch()` 是不透明的跨源请求,
+所有访问服务端的调用都会被拦,而通常的绕法是把 web security 关掉——
+那就等于把 WebView 变成一扇敞开的门。
+
+用 `WebViewAssetLoader` 从 `https://appassets.androidplatform.net/` 提供同样的文件,
+拿到的是一个正常的安全源:请求按普通 CORS 走,正好和服务端的 CORS 策略对上;
+`isSecureContext` 为真,IndexedDB 等 API 与浏览器里行为一致。
+这一个选择就是「一份 bundle 两个宿主」能成立的前提。
+
+### 精排 CSS:干预策略
+
+按重要程度排序的三条原则,写在 `web/src/styles/reader.css` 里:
+
+1. **不设任何属性,除非不设会坏。**
+2. **每个可调项都是默认 `inherit` 的可继承自定义属性**——「关」的意思是「不动它」,
+ 不是「用我们的默认值」。这一点很关键:给行距写死一个具体值,会把一本按章节
+ 变行距的书压平。
+3. **任何改变作者意图的行为都是用户主动开启,且持久化**,绝不是默认。
+
+实际 override 只有七处,每处都在 CSS 注释里写了为什么:图片/表格/pre 在窄屏会撑破
+布局、ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。
+
+对比一下被删掉的做法:reset 样式表、统一的「阅读主题」、给 `p` 加边距。
+它们都能让排版「看起来一致」,而一致性恰恰是这里要避免的东西。
+
+### 书的样式隔离在 shadow root 里
+
+书里一条 `p { color: red }` 不能改到书架,App 的 `.topbar` 也不能被书撞上。
+iframe 同样能隔离,但每次换章要重建文档:滚动位置丢失、Android 上内存翻几倍。
+shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做分页和取选中。
+关键是**不往 shadow root 里注入任何 reset**。
+
+### 书不能联网
+
+加载时把章节里所有本地引用改写成内部 `reader-res:` 协议,渲染层从内存资源表解出;
+绝对 URL 原样保留,然后被 CSP 挡掉。这样一本来路不明的书无法把阅读行为报到远端。
+资源按需解出而不是预展开——不然打开一本带大图的 EPUB 会先把整个包塞进内存。
+
+### 阅读位置格式
+
+`r1:<偏移>:<章节 id>`。三点都是踩过之后定的:
+
+- **版本前缀**:服务端把 locator 当不透明字符串长期保存,客户端将来改格式时必须
+ 能认出「这不是我认识的格式」。`r1` 给了这个能力。
+- **章节 id 放最后**:章节 id 是文件路径,会含冒号。只从前两个冒号切、剩下全保留,
+ 是唯一不会丢数据的解析方式。
+- **偏移是章节内的比例**,不是像素、不是 CSS 列号。列号会随字号变化,
+ 于是读者一调字号,位置就跳到另一个句子上——阅读 App 能犯的最让人恼火的错误之一。
+
+### 待发送队列,而不是「每次都全发」
+
+离线队列记的是**脏集合**:哪些记录还没送到服务端,而不是「当前全部状态」。
+
+两者在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前
+被修改了(读者在同步进行时翻了一页)。脏集合下,这次更新仍然是脏的,要等**它自己**
+的确认才清除;「全发」模式下,它会被那个更早的响应顺手清掉,然后永远送不到服务端——
+一个丢失的阅读位置,是这个产品最不能有的失败。
+
+### 离线队列的抗崩
+
+队列在本地镜像里,不在内存里。所以读到一半直接杀进程,位置不会丢——
+下一次启动接着送。笔记删除同样写墓碑后再入队,避免离线设备让已删笔记复活。
+
+### TXT 的编码与章节
+
+本地书库里 TXT 占比很大,而它既没有结构也没有编码声明。
+
+编码:先严格校验 UTF-8(自己实现,不用 `TextDecoder` 的宽松模式——宽松模式永远
+「成功」,于是你永远发现不了自己解错了)。校验失败再依次试 GB18030、Big5。
+结果里带 `guessed` 标记,界面上告诉用户选了哪个并允许手动覆盖。
+
+章节:TXT 没有目录,只能从标题推断。策略是**宁可少认,不要错认**——
+一个 800 字的段落以「第三章」开头时不能当标题,而正文里提到「他在第二章读过」
+更不能当标题。两条都有测试固定住。推断不出章节时按长度分段:
+一本五千章的网文如果变成一整节,分页会直接卡死。
+
+### 漫画的页序
+
+按自然序排(`1, 2, 10` 而不是 `1, 10, 2`)。排错不是观感问题,是故事被静默打乱。
+文件名无信息量时用页序当标题,因为 `a1b2c3.png` 对读者没有意义。
+
+### 一页一个 section
+
+漫画和 PDF 是 `layout: 'fixed'`,每页一个 section,由分页器整屏推进。
+固定布局的宿主不做滚动容器——在横向分页器里嵌一个滚动区会产生
+Android 上经典的「页面自己往边上飘」bug。
diff --git a/server/.dockerignore b/server/.dockerignore
index 8d51cf1..88cb6cc 100644
--- a/server/.dockerignore
+++ b/server/.dockerignore
@@ -1,6 +1,4 @@
node_modules
dist
data
-.git
*.log
-test
diff --git a/server/Dockerfile b/server/Dockerfile
index 288ee7b..f81903c 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,13 +1,29 @@
-# --- build stage -------------------------------------------------------------
+# --- web client build stage ---------------------------------------------------
+# The H5 client is built here and baked into the server image, because serving it
+# from the same container is what keeps the deployment to a single command: the
+# reader opens http://:8080 and gets the app, with no second container and
+# no CORS configuration to think about.
+#
+# `web` is optional. When the build context has no `web` directory the copy below
+# fails, so the client stage is gated behind a build arg and the runtime stage
+# tolerates an absent bundle. A server-only deployment stays possible.
+FROM docker.cnb.cool/dockerhub/node:24-alpine AS web
+WORKDIR /web
+COPY web/package.json web/package-lock.json* ./
+RUN npm ci
+COPY web/ ./
+RUN npm run build
+
+# --- server build stage ------------------------------------------------------
FROM docker.cnb.cool/dockerhub/node:24-alpine AS build
WORKDIR /app
-COPY package.json package-lock.json* ./
-# Install dev deps too: the TypeScript toolchain is needed to compile.
+COPY server/package.json server/package-lock.json* ./
+# Dev dependencies included: the TypeScript toolchain is needed to compile.
RUN npm ci --include=dev
-COPY tsconfig.json ./
-COPY src ./src
+COPY server/tsconfig.json server/tsconfig.build.json ./
+COPY server/src ./src
RUN npm run build
# Strip dev dependencies for the runtime image.
@@ -20,16 +36,19 @@ WORKDIR /app
ENV NODE_ENV=production \
BOOKS_DIR=/books \
DATA_DIR=/data \
- PORT=8080
+ PORT=8080 \
+ WEB_DIR=/app/web
-# Run as an unprivileged user. The library mount is read-only, so this user
-# only ever needs write access to /data.
+# Run as an unprivileged user. The library mount is read-only, so this user only
+# ever needs write access to /data.
RUN addgroup -S reader && adduser -S reader -G reader \
&& mkdir -p /data /books && chown -R reader:reader /data /books
COPY --from=build --chown=reader:reader /app/node_modules ./node_modules
COPY --from=build --chown=reader:reader /app/dist ./dist
-COPY --chown=reader:reader package.json ./
+COPY --from=build --chown=reader:reader /app/package.json ./
+# The built H5 client, served by the same process at `/`.
+COPY --from=web --chown=reader:reader /web/dist ./web
USER reader
EXPOSE 8080
diff --git a/server/src/config/index.ts b/server/src/config/index.ts
index 3006f10..5ed8fe5 100644
--- a/server/src/config/index.ts
+++ b/server/src/config/index.ts
@@ -29,6 +29,28 @@ export interface AppConfig {
logLevel: string;
/** Public base URL advertised to clients (used by download links). */
publicUrl: string;
+ /**
+ * Origins allowed to call the API from a browser.
+ *
+ * Empty means "reflect any origin", which is the right default for a
+ * self-hosted server on a LAN: the reader does not know the IP it will be
+ * reached on, and there are no cookies in the design, so a permissive policy
+ * does not hand out ambient authority the way it would for a session-cookie
+ * API.
+ *
+ * Set it when the instance is on the public internet and is meant to be used
+ * only by a specific H5 deployment.
+ */
+ corsOrigins: string[];
+ /**
+ * Directory containing the built H5 client, served at `/`.
+ *
+ * Optional: the client can also be hosted separately (or loaded from
+ * `file://` inside the Android shell). When present, `GET /` returns the app,
+ * which is what makes "open the NAS IP in a browser" work with no extra
+ * setup.
+ */
+ webDir: string;
}
const DATA_DIR_DEFAULT_NAME = 'data';
@@ -81,5 +103,10 @@ export function loadConfig(): AppConfig {
watchInterval: envInt('WATCH_INTERVAL', 60),
logLevel: process.env.LOG_LEVEL ?? 'info',
publicUrl: process.env.PUBLIC_URL ?? '',
+ corsOrigins: (process.env.CORS_ORIGINS ?? '')
+ .split(',')
+ .map((origin) => origin.trim())
+ .filter((origin) => origin.length > 0),
+ webDir: resolve(process.env.WEB_DIR ?? join(process.cwd(), 'web')),
};
}
diff --git a/server/src/http/app.ts b/server/src/http/app.ts
index 37db4d0..2b950c2 100644
--- a/server/src/http/app.ts
+++ b/server/src/http/app.ts
@@ -4,6 +4,8 @@ import { registerErrorHandler } from './errors.ts';
import { registerAuthRoutes } from './routes/auth.ts';
import { registerLibraryRoutes } from './routes/library.ts';
import { registerSyncRoutes } from './routes/sync.ts';
+import { registerWebRoutes } from './routes/web.ts';
+import { isOriginAllowed, resolveCorsOrigin } from './cors.ts';
export function buildApp(ctx: AppContext): FastifyInstance {
const app = Fastify({
@@ -16,14 +18,17 @@ export function buildApp(ctx: AppContext): FastifyInstance {
registerErrorHandler(app);
- // Permissive CORS by default: a self-hosted reader is expected to be reached
- // from a LAN address, a reverse proxy and possibly a local file origin. There
- // are no cookies in the design, so this does not widen the attack surface to
- // the extent a cookie-based session would.
app.addHook('onRequest', async (request, reply) => {
- reply.header('access-control-allow-origin', request.headers.origin ?? '*');
+ if (!isOriginAllowed(ctx.config, request)) {
+ reply.status(403).send({ error: { code: 'FORBIDDEN', message: 'origin not allowed' } });
+ return;
+ }
+ reply.header('access-control-allow-origin', resolveCorsOrigin(ctx.config, request));
reply.header('access-control-allow-headers', 'authorization, content-type');
reply.header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
+ // `vary: origin` is required whenever the allowed origin is reflected rather
+ // than fixed, otherwise a shared cache will serve one origin's response to
+ // another.
reply.header('vary', 'origin');
});
@@ -32,6 +37,8 @@ export function buildApp(ctx: AppContext): FastifyInstance {
registerAuthRoutes(app, ctx);
registerLibraryRoutes(app, ctx);
registerSyncRoutes(app, ctx);
+ // Registered last: the SPA fallback must not shadow an API route.
+ registerWebRoutes(app, ctx);
return app;
}
diff --git a/server/src/http/cors.ts b/server/src/http/cors.ts
new file mode 100644
index 0000000..d09ec49
--- /dev/null
+++ b/server/src/http/cors.ts
@@ -0,0 +1,42 @@
+import type { FastifyRequest } from 'fastify';
+import type { AppConfig } from '../config/index.ts';
+
+/**
+ * CORS for the H5 client.
+ *
+ * Three deployments have to work, and the same policy covers all of them:
+ *
+ * 1. H5 served *by* this server at `/` — same origin, CORS is not even
+ * involved beyond the origin header being echoed.
+ * 2. H5 served from somewhere else (a static host, a dev server on :5174)
+ * against a LAN server at an IP the client cannot know in advance.
+ * 3. The Android shell, whose WebView origin is `file://` or a custom scheme.
+ *
+ * There are no cookies anywhere in the design: authentication is a bearer token
+ * that the client attaches explicitly. That is what makes a permissive default
+ * defensible — a malicious page cannot ride an ambient session, it would have to
+ * already hold the token. `CORS_ORIGINS` narrows it for public deployments.
+ */
+export function resolveCorsOrigin(config: AppConfig, request: FastifyRequest): string {
+ const origin = request.headers.origin;
+ // Non-browser clients (curl, the Android native layer) send no Origin; the
+ // header is then irrelevant and echoing nothing is correct.
+ if (!origin) return '*';
+ if (config.corsOrigins.length === 0) return origin;
+
+ if (config.corsOrigins.includes(origin)) return origin;
+
+ // `file://` requests arrive with the literal origin "null". The Android shell
+ // needs this whenever the H5 bundle is loaded from assets, and it cannot be
+ // expressed as a normal origin entry.
+ if (origin === 'null' && config.corsOrigins.includes('null')) return 'null';
+
+ return config.corsOrigins[0] ?? origin;
+}
+
+export function isOriginAllowed(config: AppConfig, request: FastifyRequest): boolean {
+ const origin = request.headers.origin;
+ if (!origin) return true;
+ if (config.corsOrigins.length === 0) return true;
+ return config.corsOrigins.includes(origin) || (origin === 'null' && config.corsOrigins.includes('null'));
+}
diff --git a/server/src/http/routes/library.ts b/server/src/http/routes/library.ts
index 5f31b30..3d82a4f 100644
--- a/server/src/http/routes/library.ts
+++ b/server/src/http/routes/library.ts
@@ -2,7 +2,8 @@ import type { FastifyInstance } from 'fastify';
import type { AppContext } from '../context.ts';
import { authenticate, currentUser, requireAdmin } from '../auth.ts';
import { badRequest, notFound } from '../../lib/errors.ts';
-import { assertSafeRel, resolveInside } from '../../lib/paths.ts';
+import { assertSafeRel, normalizeRel, resolveInside } from '../../lib/paths.ts';
+import { createHash } from 'node:crypto';
import { createReadStream, existsSync } from 'node:fs';
import { stat } from 'node:fs/promises';
import { extname, join } from 'node:path';
@@ -10,6 +11,16 @@ import { extname, join } from 'node:path';
const MIME_BY_EXT: Record = {
'.epub': 'application/epub+zip',
'.pdf': 'application/pdf',
+ '.txt': 'text/plain; charset=utf-8',
+ '.cbz': 'application/vnd.comicbook+zip',
+ '.zip': 'application/zip',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.png': 'image/png',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.avif': 'image/avif',
+ '.bmp': 'image/bmp',
};
export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): void {
@@ -90,6 +101,51 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
return reply.send(createReadStream(abs));
});
+ /**
+ * Streams one file of a multi-file book.
+ *
+ * Needed for the one format that is not a single file: a comic stored as a
+ * folder of images. Compositing those into an archive server-side would mean
+ * writing into DATA_DIR on demand and re-doing the work whenever the folder
+ * changes, so the client fetches pages individually instead and caches them
+ * per page.
+ *
+ * Authorisation is the same as `/content`: the caller must be able to see the
+ * book, and the requested path must be one of that book's *known* files. The
+ * lookup is by `book_files.rel_path`, never by constructing a path from the
+ * query, so a traversal attempt simply matches no row.
+ */
+ app.get('/api/v1/books/:id/file', { preHandler: auth }, async (request, reply) => {
+ const user = currentUser(request);
+ const { id } = request.params as { id: string };
+ const query = request.query as { path?: string };
+ ctx.shelf.get(user.id, id); // authorises access
+ if (!query.path) throw badRequest('path is required');
+
+ const row = ctx.db.get<{ rel_path: string; size: number }>(
+ 'SELECT rel_path, size FROM book_files WHERE book_id = ? AND rel_path = ? AND missing = 0',
+ id,
+ normalizeRel(query.path),
+ );
+ if (!row) throw notFound('no such file for this book', 'FILE_MISSING');
+
+ const abs = resolveInside(ctx.config.booksDir, assertSafeRel(row.rel_path));
+ if (!existsSync(abs)) throw notFound('file is no longer on disk', 'FILE_MISSING');
+
+ reply.header('content-type', MIME_BY_EXT[extname(abs).toLowerCase()] ?? 'application/octet-stream');
+ reply.header('content-length', String(row.size));
+ // Unlike `/content`, this is one page of many, so the ETag is per file rather
+ // than per book.
+ //
+ // Hashed rather than embedding the path: an HTTP header value must be
+ // ISO-8859-1, and a comic page's path is routinely Chinese or Japanese. Putting
+ // the raw path in the header throws `ERR_INVALID_CHAR` and turns a page request
+ // into a 500 — which is exactly what happened the first time this was written.
+ reply.header('etag', `"${id}:${createHash('sha256').update(row.rel_path).digest('hex').slice(0, 16)}"`);
+ reply.header('cache-control', 'private, max-age=86400');
+ return reply.send(createReadStream(abs));
+ });
+
app.get('/api/v1/books/:id/cover', { preHandler: auth }, async (request, reply) => {
const { id } = request.params as { id: string };
const row = ctx.db.get<{ cover_path: string | null }>('SELECT cover_path FROM books WHERE id = ?', id);
diff --git a/server/src/http/routes/web.ts b/server/src/http/routes/web.ts
new file mode 100644
index 0000000..4a4f48c
--- /dev/null
+++ b/server/src/http/routes/web.ts
@@ -0,0 +1,83 @@
+import { createReadStream, existsSync, statSync } from 'node:fs';
+import { extname, join, normalize, resolve, sep } from 'node:path';
+import type { FastifyInstance, FastifyReply } from 'fastify';
+import type { AppContext } from '../context.ts';
+
+/**
+ * Serves the built H5 client, when one is present.
+ *
+ * Why the server hosts it at all: the product's deployment constraint is "one
+ * command and it runs" (§8.1), and telling a NAS owner to stand up a second
+ * container for the web client would break that. If the bundle is absent the
+ * routes are simply not registered, so a server-only deployment is unaffected.
+ *
+ * Everything here is unauthenticated on purpose: these are static assets. The
+ * API behind them still requires a token, so serving the shell to an anonymous
+ * request reveals nothing. It does mean the *existence* of an instance is
+ * visible to anyone who can reach the port — which is already true of
+ * `GET /api/v1/instance`.
+ */
+
+const MIME_BY_EXT: Record = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.webp': 'image/webp',
+ '.ico': 'image/x-icon',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
+ '.ttf': 'font/ttf',
+ '.map': 'application/json; charset=utf-8',
+ '.txt': 'text/plain; charset=utf-8',
+ '.webmanifest': 'application/manifest+json',
+};
+
+export function registerWebRoutes(app: FastifyInstance, ctx: AppContext): void {
+ const webDir = ctx.config.webDir;
+ const indexFile = join(webDir, 'index.html');
+ if (!existsSync(indexFile)) {
+ ctx.log?.info?.({ webDir }, 'no web client bundle found, serving API only');
+ return;
+ }
+
+ const sendFile = (filePath: string, reply: FastifyReply): FastifyReply => {
+ reply.header('content-type', MIME_BY_EXT[extname(filePath).toLowerCase()] ?? 'application/octet-stream');
+ return reply.send(createReadStream(filePath));
+ };
+
+ app.get('/', async (_request, reply) => sendFile(indexFile, reply));
+
+ app.get('/*', async (request, reply) => {
+ const raw = (request.params as { '*': string })['*'] ?? '';
+ // Resolve under webDir and refuse anything that escapes it: `..%2f..%2f`
+ // would otherwise turn the static handler into an arbitrary file read.
+ const candidate = resolve(webDir, normalize(raw).replace(/^(\.\.[/\\])+/, ''));
+ if (candidate !== webDir && !candidate.startsWith(`${webDir}${sep}`)) {
+ reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'not found' } });
+ return;
+ }
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
+ // Hashed-ish asset names change with each build, and the API promises
+ // additive changes only, so a client can hold a bundle across server
+ // upgrades without harm. `index.html` itself must never be cached or a
+ // deployment would never pick up a new bundle.
+ if (extname(candidate) !== '.html') {
+ reply.header('cache-control', 'public, max-age=3600');
+ } else {
+ reply.header('cache-control', 'no-cache');
+ }
+ return sendFile(candidate, reply);
+ }
+
+ // SPA fallback: the client routes by hash, but serving index.html for an
+ // unknown path is the safer default than a 404 for a deep link.
+ reply.header('cache-control', 'no-cache');
+ return sendFile(indexFile, reply);
+ });
+}
diff --git a/server/src/indexer/metadata.ts b/server/src/indexer/metadata.ts
index e06a27d..504293b 100644
--- a/server/src/indexer/metadata.ts
+++ b/server/src/indexer/metadata.ts
@@ -6,7 +6,7 @@ import { XMLParser } from 'fast-xml-parser';
import { stripNullBytes } from '../lib/text.ts';
import { parseFilename } from './filename.ts';
-export type BookFormat = 'epub' | 'pdf' | 'unknown';
+export type BookFormat = 'epub' | 'pdf' | 'txt' | 'cbz' | 'comic-dir' | 'unknown';
export interface ExtractedMetadata {
title: string;
@@ -51,6 +51,107 @@ export function sha256(buf: Buffer): string {
return createHash('sha256').update(buf).digest('hex');
}
+/**
+ * Text encoding detection for TXT books.
+ *
+ * The same validate-then-fallback approach the client uses, and for the same
+ * reason: a strict UTF-8 check is reliable in the direction that matters. It is
+ * duplicated rather than shared because the server and the client are separate
+ * build targets with no common module, and a wrong answer on either side is
+ * merely a hint — the client re-decodes from the bytes it downloads.
+ */
+export function detectTextEncoding(buf: Buffer): string {
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) return 'utf-8';
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) return 'utf-16le';
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) return 'utf-16be';
+ const sample = buf.subarray(0, Math.min(buf.length, 64 * 1024));
+ if (isStrictUtf8(sample)) return 'utf-8';
+ // GB18030 is a superset of GBK/GB2312, which is what almost every legacy
+ // Chinese TXT in a personal library is.
+ return 'gb18030';
+}
+
+/** Strict UTF-8 validation: returns false if any replacement would occur. */
+export function isStrictUtf8(bytes: Buffer): boolean {
+ let index = 0;
+ while (index < bytes.length) {
+ const byte = bytes[index]!;
+ if (byte <= 0x7f) {
+ index += 1;
+ continue;
+ }
+ let needed: number;
+ let min: number;
+ let code: number;
+ if ((byte & 0xe0) === 0xc0) {
+ needed = 1; min = 0x80; code = byte & 0x1f;
+ } else if ((byte & 0xf0) === 0xe0) {
+ needed = 2; min = 0x800; code = byte & 0x0f;
+ } else if ((byte & 0xf8) === 0xf0) {
+ needed = 3; min = 0x10000; code = byte & 0x07;
+ } else {
+ return false;
+ }
+ if (index + needed >= bytes.length) return false;
+ for (let offset = 1; offset <= needed; offset += 1) {
+ const next = bytes[index + offset]!;
+ if ((next & 0xc0) !== 0x80) return false;
+ code = (code << 6) | (next & 0x3f);
+ }
+ if (code < min || code > 0x10ffff) return false;
+ if (code >= 0xd800 && code <= 0xdfff) return false;
+ index += needed + 1;
+ }
+ return true;
+}
+
+/**
+ * Counts image entries in a ZIP without extracting them.
+ *
+ * Reads the end-of-central-directory record and walks the central directory
+ * names, which is metadata only: no entry is inflated. That keeps a scan of a
+ * hundred 200MB comic archives cheap, which matters because the scanner runs
+ * every minute (see docs/architecture.md §3).
+ */
+export async function countZipImages(buf: Buffer): Promise {
+ const eocd = findEndOfCentralDirectory(buf);
+ if (!eocd) return null;
+ const entryCount = buf.readUInt16LE(eocd + 10);
+ let offset = buf.readUInt32LE(eocd + 16);
+ let pages = 0;
+ for (let index = 0; index < entryCount; index += 1) {
+ // Each central directory header is at least 46 bytes.
+ if (offset + 46 > buf.length) break;
+ if (buf.readUInt32LE(offset) !== 0x02014b50) break;
+ const nameLength = buf.readUInt16LE(offset + 28);
+ const extraLength = buf.readUInt16LE(offset + 30);
+ const commentLength = buf.readUInt16LE(offset + 32);
+ const name = buf.subarray(offset + 46, offset + 46 + nameLength).toString('latin1');
+ if (name.endsWith('/')) {
+ // directory entry
+ } else {
+ const ext = name.slice(name.lastIndexOf('.')).toLowerCase();
+ if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.bmp'].includes(ext)) pages += 1;
+ }
+ offset += 46 + nameLength + extraLength + commentLength;
+ }
+ return pages;
+}
+
+/**
+ * Locates the end-of-central-directory record.
+ *
+ * Scanned backwards because the comment field after it may be up to 64KB, and it
+ * is not at a fixed offset. The signature is checked rather than the position.
+ */
+function findEndOfCentralDirectory(buf: Buffer): number | null {
+ const minimum = Math.max(0, buf.length - 22 - 0xffff);
+ for (let index = buf.length - 22; index >= minimum; index -= 1) {
+ if (buf.readUInt32LE(index) === 0x06054b50) return index;
+ }
+ return null;
+}
+
function asArray(value: T | T[] | undefined): T[] {
if (value === undefined || value === null) return [];
return Array.isArray(value) ? value : [value];
@@ -254,6 +355,48 @@ export async function parseBookFile(buf: Buffer, relPath: string): Promise null);
+ const fallback = filenameMetadata(relPath);
+ const isZip = lower.endsWith('.zip');
+ return {
+ format: 'cbz',
+ contentHash,
+ size,
+ pageCount,
+ metadata: {
+ ...fallback,
+ // A `.zip` that contains images is a comic by content. Naming it as one
+ // lets the client pick the comic renderer without re-sniffing.
+ title: fallback.title,
+ raw: { filename: relPath, pages: pageCount, container: isZip ? 'zip' : 'cbz' },
+ },
+ };
+ }
+
return {
format: 'unknown',
contentHash,
@@ -286,6 +429,34 @@ export function filenameMetadata(relPath: string): ExtractedMetadata {
};
}
+/**
+ * Metadata for a comic stored as a folder of images.
+ *
+ * Unlike every other format there is no single file to hash, so the identity is
+ * built from the folder path plus the page count: the folder *is* the book, and
+ * the reader expects it to stay one book when they add a page.
+ */
+export function comicDirectoryMetadata(relDir: string, pages: string[], totalBytes: number): ExtractedMetadata {
+ const parsed = parseFilename(relDir);
+ return {
+ title: parsed.title,
+ author: parsed.author,
+ publisher: '',
+ language: parsed.language,
+ isbn: '',
+ description: '',
+ series: parsed.series,
+ seriesIndex: parsed.seriesIndex,
+ tags: [],
+ pubdate: '',
+ // The identifier anchors identity on the folder name rather than on the
+ // contents, so adding a page does not create a second book.
+ identifier: `comic-dir:${relDir}`,
+ source: 'filename',
+ raw: { directory: relDir, pages: pages.length, bytes: totalBytes },
+ };
+}
+
/**
* Locate and read the cover image from an EPUB.
*
diff --git a/server/src/indexer/natural-sort.ts b/server/src/indexer/natural-sort.ts
new file mode 100644
index 0000000..1b1a76a4
--- /dev/null
+++ b/server/src/indexer/natural-sort.ts
@@ -0,0 +1,47 @@
+/**
+ * Natural ordering for page and chapter names.
+ *
+ * Shared with the client by intent rather than by imports: the two are separate
+ * build targets with no common module, and the rule itself is small enough that
+ * duplicating it is cheaper than a shared package. The behaviour must match,
+ * because the server's page count and the client's page order have to agree.
+ *
+ * `1, 2, 10` rather than `1, 10, 2`. A comic sorted lexicographically reads in the
+ * wrong order, which is not a cosmetic bug.
+ */
+export function naturalCompare(a: string, b: string): number {
+ const left = splitChunks(a);
+ const right = splitChunks(b);
+ const length = Math.max(left.length, right.length);
+ for (let index = 0; index < length; index += 1) {
+ const l = left[index];
+ const r = right[index];
+ if (l === undefined) return -1;
+ if (r === undefined) return 1;
+ if (typeof l === 'number' && typeof r === 'number') {
+ if (l !== r) return l - r;
+ continue;
+ }
+ const ls = String(l);
+ const rs = String(r);
+ if (ls === rs) continue;
+ const lower = ls.localeCompare(rs, undefined, { sensitivity: 'base', numeric: false });
+ if (lower !== 0) return lower;
+ return ls < rs ? -1 : 1;
+ }
+ return 0;
+}
+
+function splitChunks(value: string): Array {
+ const chunks: Array = [];
+ for (const part of value.split(/(\\d+)/)) {
+ if (part === '') continue;
+ if (/^\\d+$/.test(part)) {
+ // Long zero-padded runs would lose precision as a float.
+ chunks.push(part.length > 15 ? Number.parseInt(part, 10) : Number(part));
+ } else {
+ chunks.push(part);
+ }
+ }
+ return chunks;
+}
diff --git a/server/src/indexer/scanner.ts b/server/src/indexer/scanner.ts
index 3065291..9d65ad0 100644
--- a/server/src/indexer/scanner.ts
+++ b/server/src/indexer/scanner.ts
@@ -4,12 +4,51 @@ import { join } from 'node:path';
import type { Db } from '../db/index.ts';
import type { AppConfig } from '../config/index.ts';
import { computeBookId, computeFileId } from './identity.ts';
-import { parseBookFile, saveCover, type ExtractedMetadata, type ParsedBookFile } from './metadata.ts';
+import {
+ comicDirectoryMetadata,
+ parseBookFile,
+ saveCover,
+ type ExtractedMetadata,
+ type ParsedBookFile,
+} from './metadata.ts';
+import { naturalCompare } from './natural-sort.ts';
import { toRelative } from '../lib/paths.ts';
import { collapseWhitespace, safeJsonParse } from '../lib/text.ts';
-export const SUPPORTED_EXTENSIONS = new Set(['.epub', '.pdf']);
-const SKIP_DIRS = new Set(['.git', '@eaDir', '#recycle', '.DS_Store', 'lost+found']);
+/**
+ * Formats the scanner will index.
+ *
+ * Deliberately broader than "the formats the server can parse". For most of these
+ * the server's job is only to record that a file exists and extract whatever
+ * metadata is cheap to get; *rendering* is the client's business (see
+ * docs/architecture.md §8). A TXT novel or a comic archive that the server
+ * ignored would simply be invisible on the shelf, which is the one outcome a
+ * self-hosted library must not have.
+ */
+export const SUPPORTED_EXTENSIONS = new Set([
+ '.epub',
+ '.pdf',
+ '.txt',
+ '.cbz',
+ '.zip',
+]);
+
+/**
+ * Image formats, used to recognise a comic stored as a folder of pages.
+ *
+ * Such a book is not a file at all — the group of images *is* the book — so it is
+ * handled by a separate pass (see `collectComicDirectories`) rather than through
+ * `indexFile`.
+ */
+export const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.bmp']);
+
+/** Files inside a comic folder that are not pages. */
+const NON_PAGE_PATTERNS = [/^\._/, /\.ds_store$/i, /thumbs\.db$/i, /comicinfo\.xml$/i];
+
+/** Below this many images, a folder is treated as loose images rather than a book. */
+const MIN_COMIC_PAGES = 2;
+
+const SKIP_DIRS = new Set(['.git', '@eaDir', '#recycle', '.DS_Store', 'lost+found', '__MACOSX']);
export interface ScanProgress {
running: boolean;
@@ -34,6 +73,17 @@ interface FileEntry {
mtimeMs: number;
}
+/** A folder of images that is treated as one book. */
+interface ComicDirectory {
+ relDir: string;
+ pages: Array<{ relPath: string; size: number; mtimeMs: number }>;
+ totalBytes: number;
+ /** Digest of the folder path plus its page set; used as the content hash. */
+ /** Stable content hash derived from the folder path; see the note at its use. */
+ identityHash: string;
+ identifier: string;
+}
+
interface StoredFile {
id: string;
book_id: string;
@@ -97,16 +147,31 @@ export class Scanner {
try {
const found = await this.walk(this.config.booksDir);
+ // Comic folders have no file to walk over, so they are collected in a
+ // second pass. Their pages are registered as files of the folder's book.
+ const comics = await this.collectComicDirectories(this.config.booksDir);
+ for (const comic of comics) {
+ for (const page of comic.pages) {
+ found.push({ relPath: page.relPath, size: page.size, mtimeMs: page.mtimeMs });
+ }
+ }
const stored = this.loadStoredFiles();
const storedByPath = new Map(stored.map((row) => [row.rel_path, row]));
const seen = new Set();
+ const pagesByDir = new Map(comics.map((comic) => [comic.relDir, comic]));
+
for (const entry of found) {
seen.add(entry.relPath);
this.progress.scanned += 1;
const previous = storedByPath.get(entry.relPath);
try {
- await this.indexFile(entry, previous);
+ const owner = comicOf(entry.relPath, pagesByDir);
+ if (owner) {
+ await this.indexComicDirectory(owner, entry, previous);
+ } else {
+ await this.indexFile(entry, previous);
+ }
} catch (err) {
this.progress.failed += 1;
this.progress.lastError = err instanceof Error ? err.message : String(err);
@@ -211,6 +276,161 @@ export class Scanner {
);
}
+ /**
+ * Indexes one page of a comic stored as a folder.
+ *
+ * Every page resolves to the *same* book id, so the folder appears as one book
+ * with many files rather than as N separate entries. The id is deliberately
+ * derived from the folder path rather than from page contents: adding a page to
+ * a folder should not create a second book and orphan the reader's progress.
+ */
+ private async indexComicDirectory(owner: ComicDirectory, entry: FileEntry, previous: StoredFile | undefined): Promise {
+ if (previous && previous.size === entry.size && previous.mtime_ms === entry.mtimeMs && previous.book_identifier === owner.identifier) {
+ this.progress.unchanged += 1;
+ return;
+ }
+
+ const metadata = comicDirectoryMetadata(owner.relDir, owner.pages.map((page) => page.relPath), owner.totalBytes);
+ const bookId = computeBookId(metadata.identifier, owner.identityHash);
+ const existing = this.db.get<{ id: string }>('SELECT id FROM books WHERE id = ?', bookId);
+ const now = Date.now();
+
+ // Only the first page becomes the cover: it is what the reader would pick,
+ // and extracting every page to look for a better one would mean inflating the
+ // whole archive on every scan.
+ const coverPath = entry.relPath === owner.pages[0]?.relPath
+ ? await this.persistComicCover(bookId, join(this.config.booksDir, entry.relPath), entry.relPath)
+ : undefined;
+
+ const parsed = {
+ format: 'comic-dir' as const,
+ contentHash: owner.identityHash,
+ size: owner.totalBytes,
+ pageCount: owner.pages.length,
+ metadata,
+ ...(coverPath !== undefined ? { coverPath } : {}),
+ };
+
+ if (!existing) {
+ this.insertBook(bookId, parsed, now);
+ this.progress.added += 1;
+ this.db.run(
+ `INSERT OR IGNORE INTO user_books (user_id, book_id, added_at)
+ SELECT id, ?, ? FROM users`,
+ bookId, now,
+ );
+ } else {
+ this.progress.updated += 1;
+ this.updateBook(bookId, parsed, now);
+ }
+
+ const fileId = computeFileId(entry.relPath);
+ if (previous) {
+ this.db.run(
+ 'UPDATE book_files SET book_id = ?, size = ?, mtime_ms = ?, missing = 0, last_seen = ? WHERE id = ?',
+ bookId, entry.size, entry.mtimeMs, now, previous.id,
+ );
+ } else {
+ this.db.run(
+ `INSERT INTO book_files (id, book_id, rel_path, size, mtime_ms, inode, missing, first_seen, last_seen)
+ VALUES (?, ?, ?, ?, ?, '', 0, ?, ?)
+ ON CONFLICT(rel_path) DO UPDATE SET book_id = excluded.book_id, size = excluded.size,
+ mtime_ms = excluded.mtime_ms, missing = 0, last_seen = excluded.last_seen`,
+ fileId, bookId, entry.relPath, entry.size, entry.mtimeMs, now, now,
+ );
+ }
+ }
+
+ private async persistComicCover(bookId: string, absPage: string, relPath: string): Promise {
+ try {
+ const data = await readFile(absPage);
+ return await saveCover(this.config.dataDir, bookId, { data, contentType: contentTypeFor(relPath) });
+ } catch (err) {
+ this.log.warn({ err, relPath }, 'comic cover extraction failed');
+ return undefined;
+ }
+ }
+
+ /**
+ * Finds folders that are comics.
+ *
+ * A folder counts when it holds at least two images and no archives or books of
+ * its own: a `books/` directory full of EPUBs plus a stray `cover.jpg` is not a
+ * comic, and treating it as one would hide every book inside it.
+ */
+ private async collectComicDirectories(root: string): Promise {
+ const out: ComicDirectory[] = [];
+
+ const visit = async (dir: string): Promise => {
+ let entries;
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch (err) {
+ this.log.warn({ err, dir }, 'unreadable directory, skipping');
+ return;
+ }
+
+ const images: Array<{ relPath: string; size: number; mtimeMs: number }> = [];
+ let hasBookFile = false;
+ const subdirs: string[] = [];
+
+ for (const entry of entries) {
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
+ if (NON_PAGE_PATTERNS.some((pattern) => pattern.test(entry.name))) continue;
+ const abs = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ subdirs.push(abs);
+ continue;
+ }
+ if (!entry.isFile()) continue;
+ const ext = entry.name.slice(entry.name.lastIndexOf('.')).toLowerCase();
+ if (SUPPORTED_EXTENSIONS.has(ext)) {
+ hasBookFile = true;
+ continue;
+ }
+ if (!IMAGE_EXTENSIONS.has(ext)) continue;
+ try {
+ const info = await stat(abs);
+ images.push({ relPath: toRelative(root, abs), size: info.size, mtimeMs: Math.floor(info.mtimeMs) });
+ } catch {
+ // A file that vanished mid-walk is not worth reporting.
+ }
+ }
+
+ if (dir !== root && !hasBookFile && images.length >= MIN_COMIC_PAGES) {
+ const sorted = [...images].sort((a, b) => naturalCompare(a.relPath, b.relPath));
+ const relDir = toRelative(root, dir);
+ const totalBytes = sorted.reduce((sum, page) => sum + page.size, 0);
+ out.push({
+ relDir,
+ pages: sorted,
+ totalBytes,
+ // Identity is the *folder*, not its contents.
+ //
+ // The tempting alternative — hashing the page set — is wrong for the
+ // same reason hashing a file's bytes is wrong for a single book: the
+ // reader adds one page to a folder and their progress and bookmarks for
+ // that comic are gone. A folder path is the stable anchor here, exactly
+ // as `dc:identifier` is for an EPUB, and the page set is allowed to
+ // change underneath it.
+ //
+ // The consequence is accepted deliberately: renaming the folder makes a
+ // new book. That is visible and recoverable, whereas silently losing the
+ // reading position is not.
+ identityHash: createHash('sha256').update(`comic-dir\u0000${relDir}`).digest('hex'),
+ identifier: `comic-dir:${relDir}`,
+ });
+ }
+
+ // Subfolders are still walked even when this folder was claimed as a comic:
+ // a comic folder containing a `bonus/` subfolder would otherwise hide it.
+ for (const subdir of subdirs) await visit(subdir);
+ };
+
+ await visit(root);
+ return out;
+ }
+
/**
* Two stage change detection (§6, 增量扫描):
* 1. mtime + size -> cheap, skips the vast majority of files
@@ -343,3 +563,30 @@ export function parseTags(value: string): string[] {
}
export type { ExtractedMetadata };
+
+/** Which comic folder a path belongs to, if any. */
+function comicOf(relPath: string, comics: Map): ComicDirectory | undefined {
+ for (const [relDir, comic] of comics) {
+ if (relPath.startsWith(`${relDir}/`)) return comic;
+ }
+ return undefined;
+}
+
+/** MIME type for a cover image, derived from its extension. */
+function contentTypeFor(relPath: string): string {
+ const ext = relPath.slice(relPath.lastIndexOf('.')).toLowerCase();
+ switch (ext) {
+ case '.png':
+ return 'image/png';
+ case '.gif':
+ return 'image/gif';
+ case '.webp':
+ return 'image/webp';
+ case '.avif':
+ return 'image/avif';
+ case '.bmp':
+ return 'image/bmp';
+ default:
+ return 'image/jpeg';
+ }
+}
diff --git a/server/test/api.test.ts b/server/test/api.test.ts
index 428fa2e..a5ff270 100644
--- a/server/test/api.test.ts
+++ b/server/test/api.test.ts
@@ -102,6 +102,23 @@ function auth(token: string) {
return { authorization: `Bearer ${token}` };
}
+/**
+ * Creates an account through the admin path and signs in.
+ *
+ * Public registration is only open for the very first account (by design), so
+ * the multi-format tests below cannot use `register`. Going through
+ * `createAsAdmin` also exercises the path a real family member's account takes.
+ */
+async function createUser(username: string, password = 'password123'): Promise {
+ const user = await ctx.users.createAsAdmin({ username, password });
+ const res = await app.inject({
+ method: 'POST', url: '/api/v1/auth/login', payload: { username, password },
+ });
+ assert.equal(res.statusCode, 200, res.body);
+ const body = res.json() as { accessToken: string; user: { id: string } };
+ return { token: body.accessToken, userId: user.id };
+}
+
test('first account becomes admin, later public registrations are refused', async () => {
const first = await register('owner');
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(first.token) });
@@ -358,3 +375,238 @@ async function login(username: string, password?: string): Promise {
const body = res.json() as { accessToken: string; user: { id: string } };
return { token: body.accessToken, userId: body.user.id };
}
+
+// ---------------------------------------------------------------------------
+// Multi-format library (§6 format scope)
+//
+// The scanner has to index formats it cannot itself parse. If it did not, a TXT
+// novel or a comic archive would simply be absent from the shelf — the one
+// outcome a self-hosted library must never produce, because the reader has no way
+// to tell "unsupported" from "missing".
+// ---------------------------------------------------------------------------
+
+async function makeCbz(pages: string[]): Promise {
+ const zip = new JSZip();
+ for (const page of pages) {
+ // A tiny real PNG header is enough: nothing inflates these during a scan.
+ zip.file(page, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
+ }
+ return zip.generateAsync({ type: 'nodebuffer' });
+}
+
+test('a TXT book is indexed without being decoded by the server', async () => {
+ const session = await createUser('txtuser');
+ // GB18030 bytes with no BOM: the common shape of a legacy Chinese TXT. The
+ // server must index it anyway — deciding the encoding and splitting chapters is
+ // the client's job (it has the reader's eyes and an override control), and
+ // doing that work server-side would mean decoding a whole novel on every scan.
+ const gb18030 = Buffer.from([0xb5, 0xda, 0xd2, 0xbb, 0xd5, 0xc2, 0x0a, 0xd5, 0xfd, 0xce, 0xc4, 0xa1, 0xa3]);
+ await writeFile(join(booksDir, '长篇.txt'), gb18030);
+ await ctx.scanner.scan();
+
+ const res = await app.inject({ method: 'GET', url: '/api/v1/books?format=txt', headers: auth(session.token) });
+ const body = res.json() as { items: Array<{ title: string; format: string }>; total: number };
+ assert.equal(body.total, 1);
+ assert.equal(body.items[0]!.format, 'txt');
+ assert.equal(body.items[0]!.title, '长篇');
+});
+
+test('an ambiguous "title - author" file name is kept whole rather than guessed at', async () => {
+ // `长篇 - 某作者` has no marker to say which side is which, so the conservative
+ // rule keeps the whole string as the title. Losing an author costs the reader one
+ // manual edit; guessing wrong files the book under the wrong person.
+ const session = await createUser('txtambiguous');
+ await writeFile(join(booksDir, '长篇 - 某作者.txt'), '第一章\n正文。', 'utf8');
+ await ctx.scanner.scan();
+
+ const res = await app.inject({
+ method: 'GET', url: `/api/v1/books?search=${encodeURIComponent('长篇')}`, headers: auth(session.token),
+ });
+ const body = res.json() as { items: Array<{ title: string; author: string }> };
+ assert.equal(body.items[0]!.title, '长篇 - 某作者');
+ assert.equal(body.items[0]!.author, '');
+});
+
+test('a CBZ comic is indexed with a page count, without unpacking it', async () => {
+ const session = await createUser('cbzuser');
+ await writeFile(join(booksDir, '某漫画.cbz'), await makeCbz(['001.png', '002.png', '010.png']));
+ await ctx.scanner.scan();
+
+ const res = await app.inject({ method: 'GET', url: '/api/v1/books?format=cbz', headers: auth(session.token) });
+ const body = res.json() as { items: Array<{ title: string; pageCount: number | null }> };
+ assert.equal(body.items.length, 1);
+ assert.equal(body.items[0]!.title, '某漫画');
+ // Three pages, counted from the central directory.
+ assert.equal(body.items[0]!.pageCount, 3);
+});
+
+test('a folder of images becomes one book with every page as a file', async () => {
+ const session = await createUser('comicuser');
+ const dir = join(booksDir, '图片漫画');
+ await mkdir(dir, { recursive: true });
+ for (const page of ['1.jpg', '2.jpg', '10.jpg']) {
+ await writeFile(join(dir, page), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
+ }
+ await ctx.scanner.scan();
+
+ const res = await app.inject({ method: 'GET', url: '/api/v1/books?format=comic-dir', headers: auth(session.token) });
+ const body = res.json() as { items: Array<{ id: string; title: string; pageCount: number | null }> };
+ assert.equal(body.items.length, 1);
+ assert.equal(body.items[0]!.title, '图片漫画');
+ assert.equal(body.items[0]!.pageCount, 3);
+
+ const manifest = await app.inject({
+ method: 'GET', url: `/api/v1/books/${body.items[0]!.id}/manifest`, headers: auth(session.token),
+ });
+ const files = (manifest.json() as { files: Array<{ rel_path: string }> }).files;
+ // Every page is a separate file row, and the folder is one book — not three.
+ assert.deepEqual(files.map((file) => file.rel_path).sort(), [
+ '图片漫画/1.jpg',
+ '图片漫画/10.jpg',
+ '图片漫画/2.jpg',
+ ]);
+});
+
+test('a comic folder keeps its identity when a page is added', async () => {
+ const session = await createUser('comicidentity');
+ const dir = join(booksDir, '系列漫画');
+ await mkdir(dir, { recursive: true });
+ await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1]));
+ await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 2]));
+ await ctx.scanner.scan();
+ const before = (await app.inject({
+ method: 'GET', url: '/api/v1/books?format=comic-dir&search=系列漫画', headers: auth(session.token),
+ })).json() as { items: Array<{ id: string }> };
+ assert.equal(before.items.length, 1);
+
+ // Sync some progress, then add a page.
+ await app.inject({
+ method: 'PUT', url: `/api/v1/sync/progress/${before.items[0]!.id}`,
+ headers: { ...auth(session.token), 'content-type': 'application/json' },
+ payload: { locator: 'r1:0.0000:系列漫画/2.jpg', percentage: 0.5, chapterTitle: '第 2 页', device: 'test', updatedAt: Date.now() },
+ });
+ await writeFile(join(dir, '3.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 3]));
+ await ctx.scanner.scan();
+
+ const after = (await app.inject({
+ method: 'GET', url: '/api/v1/books?format=comic-dir&search=系列漫画', headers: auth(session.token),
+ })).json() as { items: Array<{ id: string; pageCount: number | null }> };
+ assert.equal(after.items.length, 1, 'adding a page must not create a second book');
+ assert.equal(after.items[0]!.id, before.items[0]!.id);
+ assert.equal(after.items[0]!.pageCount, 3);
+
+ // The reading position survives, which is the actual point of the identity rule.
+ const progress = (await app.inject({
+ method: 'GET', url: `/api/v1/sync/progress/${before.items[0]!.id}`, headers: auth(session.token),
+ })).json() as { progress: { percentage: number } | null };
+ assert.equal(progress.progress?.percentage, 0.5);
+});
+
+test('a folder of EPUBs is not mistaken for a comic', async () => {
+ // A stray cover.jpg next to real books must not turn the folder into a comic
+ // and hide every book inside it.
+ const session = await createUser('shelfdirectory');
+ const dir = join(booksDir, '藏书目录');
+ await mkdir(dir, { recursive: true });
+ await writeFile(join(dir, '一本书.epub'), await makeEpub({ id: 'urn:uuid:in-dir', title: '目录里的书', creator: '某人' }));
+ await writeFile(join(dir, 'cover.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
+ await writeFile(join(dir, 'cover2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
+ await ctx.scanner.scan();
+
+ const res = await app.inject({
+ method: 'GET', url: '/api/v1/books?search=目录里的书', headers: auth(session.token),
+ });
+ const body = res.json() as { items: Array<{ format: string }> };
+ assert.equal(body.items.length, 1);
+ assert.equal(body.items[0]!.format, 'epub');
+});
+
+test('one page of a comic folder can be fetched by path', async () => {
+ const session = await createUser('comicfetch');
+ const dir = join(booksDir, '取图漫画');
+ await mkdir(dir, { recursive: true });
+ await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 9, 9]));
+ await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 8, 8]));
+ await ctx.scanner.scan();
+
+ const list = (await app.inject({
+ method: 'GET', url: '/api/v1/books?search=取图漫画', headers: auth(session.token),
+ })).json() as { items: Array<{ id: string }> };
+ const id = list.items[0]!.id;
+
+ const page = await app.inject({
+ method: 'GET', url: `/api/v1/books/${id}/file?path=${encodeURIComponent('取图漫画/2.jpg')}`,
+ headers: auth(session.token),
+ });
+ assert.equal(page.statusCode, 200);
+ assert.equal(page.headers['content-type'], 'image/jpeg');
+ // Exactly the bytes written above: 6, and the ETag header must not have failed
+ // to build on a non-ASCII path.
+ assert.equal(page.rawPayload.byteLength, 6);
+ assert.match(page.headers['etag'] as string, /^"[0-9a-f]+:[0-9a-f]{16}"$/);
+});
+
+test('the per-file endpoint refuses a path that is not part of the book', async () => {
+ const session = await createUser('comictraversal');
+ const dir = join(booksDir, '穿越漫画');
+ await mkdir(dir, { recursive: true });
+ await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
+ await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
+ await writeFile(join(booksDir, 'secret.txt'), 'not yours');
+ await ctx.scanner.scan();
+
+ const list = (await app.inject({
+ method: 'GET', url: '/api/v1/books?search=穿越漫画', headers: auth(session.token),
+ })).json() as { items: Array<{ id: string }> };
+ const id = list.items[0]!.id;
+
+ // Traversal, an absolute path, and a sibling book's file. All three are
+ // refusals because the lookup is against this book's own file rows: a path is
+ // never constructed from the query string.
+ for (const attempt of ['../../../etc/passwd', '/etc/passwd', 'secret.txt', '穿越漫画/../../secret.txt']) {
+ const res = await app.inject({
+ method: 'GET', url: `/api/v1/books/${id}/file?path=${encodeURIComponent(attempt)}`,
+ headers: auth(session.token),
+ });
+ assert.equal(res.statusCode, 404, `expected 404 for ${attempt}, got ${res.statusCode}`);
+ }
+});
+
+test('a member cannot fetch a file of a book they cannot see', async () => {
+ // The per-file endpoint authorises through the shelf exactly like /content does,
+ // so there is no second code path to keep in step.
+ const owner = await createUser('fileowner');
+ const dir = join(booksDir, '私有漫画');
+ await mkdir(dir, { recursive: true });
+ await writeFile(join(dir, '1.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
+ await writeFile(join(dir, '2.jpg'), Buffer.from([0xff, 0xd8, 0xff, 0xe0]));
+ await ctx.scanner.scan();
+
+ const list = (await app.inject({
+ method: 'GET', url: '/api/v1/books?search=私有漫画', headers: auth(owner.token),
+ })).json() as { items: Array<{ id: string }> };
+ const id = list.items[0]!.id;
+
+ // Hide it from a second account, then confirm the file endpoint refuses.
+ // Created through `createUser` rather than a bare `createAsAdmin` + `login`,
+ // because the latter pair is what a previous version of this test did and it
+ // signed in with the wrong credentials, making the assertion meaningless.
+ const other = await createUser('fileother');
+ ctx.db.run('DELETE FROM user_books WHERE user_id = ? AND book_id = ?', other.userId, id);
+
+ const res = await app.inject({
+ method: 'GET', url: `/api/v1/books/${id}/file?path=${encodeURIComponent('私有漫画/1.jpg')}`,
+ headers: auth(other.token),
+ });
+ assert.equal(res.statusCode, 404);
+});
+
+test('the H5 client bundle is served when present and absent otherwise', async () => {
+ // The static route is registered conditionally. This instance has no WEB_DIR,
+ // so `/` must 404 rather than shadow the API — the failure mode being guarded
+ // against is a catch-all that swallows /api/*.
+ const health = await app.inject({ method: 'GET', url: '/api/v1/health' });
+ assert.equal(health.statusCode, 200);
+ const root = await app.inject({ method: 'GET', url: '/' });
+ assert.equal(root.statusCode, 404);
+});
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..98354b6
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+ reader
+
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..21c8250
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,3245 @@
+{
+ "name": "reader-client",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "reader-client",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-or-later",
+ "dependencies": {
+ "jszip": "^3.10.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.3",
+ "jsdom": "^30.0.1",
+ "typescript": "^5.7.3",
+ "vite": "^6.0.7",
+ "vitest": "^2.1.8"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "6.0.7",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
+ "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.3.0",
+ "@csstools/css-color-parser": "^4.1.10",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
+ "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
+ "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
+ "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
+ "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.1",
+ "@csstools/css-calc": "^3.4.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
+ "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz",
+ "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz",
+ "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz",
+ "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz",
+ "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz",
+ "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz",
+ "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz",
+ "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz",
+ "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz",
+ "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz",
+ "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz",
+ "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz",
+ "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz",
+ "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz",
+ "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz",
+ "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz",
+ "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz",
+ "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz",
+ "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz",
+ "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz",
+ "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz",
+ "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz",
+ "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz",
+ "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz",
+ "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz",
+ "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz",
+ "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
+ "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
+ "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
+ "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "2.1.9",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
+ "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
+ "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^3.0.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
+ "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.9",
+ "loupe": "^3.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
+ "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "30.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
+ "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^6.0.5",
+ "@asamuzakjp/dom-selector": "^8.3.0",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.7",
+ "@exodus/bytes": "^1.15.1",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.9.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jszip": {
+ "version": "3.10.2",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+ "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
+ "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.3",
+ "@rollup/rollup-android-arm64": "4.63.3",
+ "@rollup/rollup-darwin-arm64": "4.63.3",
+ "@rollup/rollup-darwin-x64": "4.63.3",
+ "@rollup/rollup-freebsd-arm64": "4.63.3",
+ "@rollup/rollup-freebsd-x64": "4.63.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.3",
+ "@rollup/rollup-linux-arm64-musl": "4.63.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.3",
+ "@rollup/rollup-linux-loong64-musl": "4.63.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.3",
+ "@rollup/rollup-linux-x64-gnu": "4.63.3",
+ "@rollup/rollup-linux-x64-musl": "4.63.3",
+ "@rollup/rollup-openbsd-x64": "4.63.3",
+ "@rollup/rollup-openharmony-arm64": "4.63.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.3",
+ "@rollup/rollup-win32-x64-gnu": "4.63.3",
+ "@rollup/rollup-win32-x64-msvc": "4.63.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
+ "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+ "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.13",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz",
+ "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.13"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.13",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz",
+ "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "8.10.2",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
+ "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
+ "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.7",
+ "es-module-lexer": "^1.5.4",
+ "pathe": "^1.1.2",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite-node/node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/vite-node/node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
+ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "2.1.9",
+ "@vitest/mocker": "2.1.9",
+ "@vitest/pretty-format": "^2.1.9",
+ "@vitest/runner": "2.1.9",
+ "@vitest/snapshot": "2.1.9",
+ "@vitest/spy": "2.1.9",
+ "@vitest/utils": "2.1.9",
+ "chai": "^5.1.2",
+ "debug": "^4.3.7",
+ "expect-type": "^1.1.0",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2",
+ "std-env": "^3.8.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.1",
+ "tinypool": "^1.0.1",
+ "tinyrainbow": "^1.2.0",
+ "vite": "^5.0.0",
+ "vite-node": "2.1.9",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "2.1.9",
+ "@vitest/ui": "2.1.9",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vitest/node_modules/@vitest/mocker": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
+ "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.9",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.12"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/vitest/node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
+ "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..cc7a578
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "reader-client",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Reader client: shared rendering layer for the H5 build and the Android WebView shell.",
+ "license": "AGPL-3.0-or-later",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -p tsconfig.json --noEmit && vite build",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "jszip": "^3.10.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.3",
+ "jsdom": "^30.0.1",
+ "typescript": "^5.7.3",
+ "vite": "^6.0.7",
+ "vitest": "^2.1.8"
+ }
+}
diff --git a/web/src/android-bridge.d.ts b/web/src/android-bridge.d.ts
new file mode 100644
index 0000000..6cdf3d0
--- /dev/null
+++ b/web/src/android-bridge.d.ts
@@ -0,0 +1,41 @@
+/**
+ * The contract the Android shell exposes to the web client.
+ *
+ * Declared here so the shared layer can feature-detect it without `any`. The
+ * Kotlin side is `ReaderBridge.kt`; keep the two in step. Every method is
+ * optional at the type level because the object simply does not exist in a
+ * browser, and a partial implementation (an older APK, a stubbed test double)
+ * must degrade rather than throw.
+ */
+export interface AndroidBridge {
+ /** Version of the native shell, so the client can require a minimum. */
+ shellVersion(): number;
+ /** Device label recorded on progress rows, e.g. `Pixel 7`. */
+ deviceLabel(): string;
+ /** 'online' | 'offline' from the platform connectivity manager. */
+ connectivity(): string;
+ /** Registers a JS function to be called when connectivity changes. */
+ watchConnectivity(callback: (state: string) => void): void;
+ /** Shows a native toast. */
+ toast(message: string): void;
+ /** Reports whether the app currently has network permission. */
+ hasNetwork(): boolean;
+ /** Physical pixels of the usable viewport, for fixed-layout fitting. */
+ viewport(): string;
+ /** Bytes used by the offline cache, for a storage screen to report honestly. */
+ cacheUsage(): string;
+ /**
+ * Everything a bug report needs: shell version, device, Android release,
+ * app version. The reader can copy this out of the app, which is the only
+ * realistic way a self-hosting user can report a client bug.
+ */
+ diagnostics(): string;
+}
+
+declare global {
+ interface Window {
+ ReaderAndroid?: AndroidBridge;
+ }
+}
+
+export {};
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
new file mode 100644
index 0000000..e23529b
--- /dev/null
+++ b/web/src/api/client.ts
@@ -0,0 +1,361 @@
+import { ApiError, errorForStatus, parseErrorBody } from './errors.ts';
+import type {
+ Book,
+ BookListPage,
+ ContinueReadingItem,
+ Facets,
+ InstanceInfo,
+ Manifest,
+ Note,
+ NoteType,
+ Progress,
+ Session,
+ SyncPull,
+ SyncPushResult,
+ User,
+} from './types.ts';
+import type { Platform } from '../core/platform.ts';
+
+export interface SessionStore {
+ load(): Promise;
+ save(session: Session): Promise;
+ clear(): Promise;
+}
+
+export interface ListQuery {
+ search?: string;
+ author?: string;
+ series?: string;
+ tag?: string;
+ format?: string;
+ sort?: 'title' | 'author' | 'added' | 'updated';
+ order?: 'asc' | 'desc';
+ page?: number;
+ pageSize?: number;
+}
+
+export interface ProgressInput {
+ bookId: string;
+ locator: string;
+ percentage: number;
+ chapterTitle: string;
+ /** Device label; the server records it so the UI can say "read on Pixel". */
+ device: string;
+ updatedAt: number;
+}
+
+export interface NoteInput {
+ id: string;
+ bookId: string;
+ type: NoteType;
+ locator: string;
+ text: string;
+ comment: string;
+ color: string;
+ updatedAt: number;
+ deleted: boolean;
+}
+
+/** One entry per HTTP call, so callers can drive their own retry policy. */
+export interface RequestOptions {
+ signal?: AbortSignal;
+ /** Skip the automatic refresh-and-retry, used by the refresh call itself. */
+ noRetry?: boolean;
+}
+
+/**
+ * The single place that knows how to talk to a reader server.
+ *
+ * Responsibilities kept here on purpose:
+ * - attaching the access token
+ * - refreshing it exactly once when it expires, for any in-flight call
+ * - de-duplicating concurrent refreshes, so a screen that fires six requests
+ * after waking up does not rotate the refresh token six times (rotation is
+ * single-use on the server — the extra five would be rejected and would
+ * sign the user out)
+ */
+export class ReaderApi {
+ private session: Session | null = null;
+ private refreshPromise: Promise | null = null;
+ private readonly listeners = new Set<(session: Session | null) => void>();
+
+ constructor(
+ private readonly platform: Platform,
+ private readonly sessions: SessionStore,
+ ) {}
+
+ get baseUrl(): string {
+ return this.currentBaseUrl;
+ }
+
+ private currentBaseUrl = '';
+
+ setBaseUrl(url: string): void {
+ this.currentBaseUrl = url.replace(/\/+$/, '');
+ }
+
+ async restore(): Promise {
+ const stored = await this.sessions.load();
+ if (!stored) return null;
+ this.session = stored;
+ return stored;
+ }
+
+ currentSession(): Session | null {
+ return this.session;
+ }
+
+ onSessionChange(listener: (session: Session | null) => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ private async setSession(session: Session | null): Promise {
+ this.session = session;
+ if (session) await this.sessions.save(session);
+ else await this.sessions.clear();
+ for (const listener of this.listeners) listener(session);
+ }
+
+ async signOut(): Promise {
+ const refreshToken = this.session?.refreshToken;
+ if (refreshToken) {
+ // Best effort: a failed revocation must not trap the user in a session
+ // they asked to leave. The local credentials are cleared either way.
+ await this.call('/api/v1/auth/logout', 'POST', { refreshToken }).catch(() => undefined);
+ }
+ await this.setSession(null);
+ }
+
+ // ---- auth ----
+
+ async instance(): Promise {
+ return this.get('/api/v1/instance');
+ }
+
+ async register(username: string, password: string, displayName?: string): Promise {
+ const session = await this.call('/api/v1/auth/register', 'POST', {
+ username,
+ password,
+ ...(displayName ? { displayName } : {}),
+ });
+ await this.setSession(session);
+ return session;
+ }
+
+ async login(username: string, password: string): Promise {
+ const session = await this.call('/api/v1/auth/login', 'POST', { username, password });
+ await this.setSession(session);
+ return session;
+ }
+
+ async me(): Promise {
+ const result = await this.get<{ user: User }>('/api/v1/auth/me');
+ return result.user;
+ }
+
+ async changePassword(currentPassword: string, newPassword: string): Promise {
+ await this.call('/api/v1/auth/password', 'POST', { currentPassword, newPassword });
+ // The server revokes every session on a password change, including this one.
+ await this.setSession(null);
+ }
+
+ // ---- shelf ----
+
+ async listBooks(query: ListQuery = {}, options: RequestOptions = {}): Promise {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(query)) {
+ if (value === undefined || value === null || value === '') continue;
+ params.set(key, String(value));
+ }
+ const suffix = params.size > 0 ? `?${params.toString()}` : '';
+ return this.get(`/api/v1/books${suffix}`, options);
+ }
+
+ async getBook(id: string, options: RequestOptions = {}): Promise<{ book: Book; progress: Progress | null }> {
+ return this.get(`/api/v1/books/${encodeURIComponent(id)}`, options);
+ }
+
+ async manifest(id: string, options: RequestOptions = {}): Promise {
+ return this.get(`/api/v1/books/${encodeURIComponent(id)}/manifest`, options);
+ }
+
+ async facets(options: RequestOptions = {}): Promise {
+ return this.get('/api/v1/library/facets', options);
+ }
+
+ async continueReading(limit = 20, options: RequestOptions = {}): Promise {
+ const result = await this.get<{ items: ContinueReadingItem[] }>(`/api/v1/library/continue?limit=${limit}`, options);
+ return result.items;
+ }
+
+ // ---- content ----
+
+ /** Authenticated book body. Not cacheable by the service worker: it needs a token. */
+ async bookBytes(id: string, options: RequestOptions = {}): Promise {
+ const response = await this.request(
+ `/api/v1/books/${encodeURIComponent(id)}/content`,
+ 'GET',
+ undefined,
+ { ...options, binary: true },
+ );
+ return response.bytes ?? new Uint8Array();
+ }
+
+ async coverBytes(coverUrl: string, options: RequestOptions = {}): Promise {
+ const response = await this.request(coverUrl, 'GET', undefined, { ...options, binary: true });
+ return response.bytes ?? new Uint8Array();
+ }
+
+ // ---- sync ----
+
+ async pull(since: number, bookId?: string, options: RequestOptions = {}): Promise {
+ const params = new URLSearchParams({ since: String(since) });
+ if (bookId) params.set('bookId', bookId);
+ return this.get(`/api/v1/sync?${params.toString()}`, options);
+ }
+
+ async push(
+ payload: { progress?: ProgressInput[]; notes?: NoteInput[] },
+ options: RequestOptions = {},
+ ): Promise {
+ return this.call('/api/v1/sync', 'POST', payload, options);
+ }
+
+ /**
+ * Single progress write. Cheaper than a batch and used for the periodic
+ * "still reading" report, where there is nothing to reconcile.
+ */
+ async putProgress(progress: ProgressInput, options: RequestOptions = {}): Promise