From 0ed4a94b1c995ef2cbdd7aff5f6cde96df006d7c Mon Sep 17 00:00:00 2001 From: WangXuan Date: Tue, 10 Feb 2026 10:04:02 +0800 Subject: [PATCH 1/6] =?UTF-8?q?bug=E3=80=81=E6=80=A7=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 48 +++++++------ src/js/app.js | 56 +++++++++++---- src/js/bookshelfApp.js | 15 +++- src/js/modules/bookmarkManager.js | 58 ++++++++------- src/js/modules/configManager.js | 83 ++++++++++----------- src/js/modules/fileManager.js | 116 +++++++++++++++++------------- src/js/modules/pdfReader.js | 92 +++++++++++++----------- src/js/modules/txtReader.js | 20 +++--- 8 files changed, 280 insertions(+), 208 deletions(-) diff --git a/server.js b/server.js index ea191a2..27c437e 100644 --- a/server.js +++ b/server.js @@ -59,13 +59,14 @@ Object.values(DIRS).forEach(dir => { // 中间件配置 app.use(express.json({ limit: '10mb' })); -app.use(express.static(__dirname)); +// PERF-3: 添加静态文件缓存头,减少重复文件读取 +app.use(express.static(__dirname, { maxAge: '1h' })); // 工具函数 const utils = { // 规范化相对路径 normalizePath: (p = '') => p.split(path.sep).join('/'), - + // 解析书籍路径(带安全检查) resolveBookPath: (relativePath = '') => { const normalized = path.normalize(relativePath).replace(/^([\.\\/])+/, ''); @@ -90,7 +91,7 @@ const utils = { } return resolved; }, - + // 清理空文件夹 cleanupEmptyFolders: (startPath) => { let current = path.dirname(startPath); @@ -108,10 +109,10 @@ const utils = { } } }, - + // 解码文件名 decodeFilename: (filename) => Buffer.from(filename, 'latin1').toString('utf8'), - + // 检查文件扩展名是否支持 isAllowedExtension: (filename) => { const ext = path.extname(filename).toLowerCase(); @@ -131,7 +132,7 @@ const storage = multer.diskStorage({ const originalName = utils.decodeFilename(file.originalname); let finalName = originalName; let counter = 1; - + while (fs.existsSync(path.join(DIRS.books, finalName))) { const ext = path.extname(originalName); const nameWithoutExt = path.basename(originalName, ext); @@ -151,7 +152,9 @@ const upload = multer({ } else { cb(new Error('只支持 .epub, .txt, .pdf 文件格式')); } - } + }, + // BUG-7: 添加文件大小限制(500MB),防止超大文件占满磁盘/内存 + limits: { fileSize: 500 * 1024 * 1024 } }); // EPUB封面提取 @@ -479,7 +482,7 @@ const fontStorage = multer.diskStorage({ const originalName = utils.decodeFilename(file.originalname); let finalName = originalName; let counter = 1; - + while (fs.existsSync(path.join(DIRS.fonts, finalName))) { const ext = path.extname(originalName); const nameWithoutExt = path.basename(originalName, ext); @@ -512,7 +515,7 @@ app.get('/api/fonts', (req, res) => { if (!fs.existsSync(DIRS.fonts)) { return res.json([]); } - + const files = fs.readdirSync(DIRS.fonts); const fonts = files .filter(file => ALLOWED_FONT_EXTENSIONS.includes(path.extname(file).toLowerCase())) @@ -520,7 +523,7 @@ app.get('/api/fonts', (req, res) => { const ext = path.extname(file); const nameWithoutExt = path.basename(file, ext); const stats = fs.statSync(path.join(DIRS.fonts, file)); - + return { id: file, name: nameWithoutExt, @@ -530,7 +533,7 @@ app.get('/api/fonts', (req, res) => { addedAt: stats.birthtimeMs || stats.ctimeMs }; }); - + res.json(fonts); } catch (error) { console.error('Error getting fonts:', error); @@ -544,10 +547,10 @@ app.post('/api/fonts/upload', fontUpload.single('font'), (req, res) => { if (!req.file) { return res.status(400).json({ error: '没有上传文件' }); } - + const ext = path.extname(req.file.filename); const nameWithoutExt = path.basename(req.file.filename, ext); - + res.json({ success: true, font: { @@ -569,26 +572,27 @@ app.get('/api/fonts/file/:fontId', (req, res) => { try { const fontId = req.params.fontId; const fontPath = path.join(DIRS.fonts, fontId); - + // 安全检查 const resolved = path.resolve(fontPath); if (!resolved.startsWith(path.resolve(DIRS.fonts))) { return res.status(403).json({ error: '无效的字体路径' }); } - + if (!fs.existsSync(fontPath)) { return res.status(404).json({ error: '字体不存在' }); } - + const ext = path.extname(fontId).toLowerCase(); const mimeType = FONT_MIME_TYPES[ext] || 'application/octet-stream'; - + res.set({ 'Content-Type': mimeType, 'Cache-Control': 'public, max-age=31536000' }); - - res.sendFile(fontPath); + + // BUG-5: 使用 resolved 绝对路径,Express 5 的 sendFile 要求绝对路径 + res.sendFile(resolved); } catch (error) { console.error('Error serving font:', error); res.status(500).json({ error: '获取字体失败' }); @@ -600,17 +604,17 @@ app.delete('/api/fonts/:fontId', (req, res) => { try { const fontId = req.params.fontId; const fontPath = path.join(DIRS.fonts, fontId); - + // 安全检查 const resolved = path.resolve(fontPath); if (!resolved.startsWith(path.resolve(DIRS.fonts))) { return res.status(403).json({ error: '无效的字体路径' }); } - + if (!fs.existsSync(fontPath)) { return res.status(404).json({ error: '字体不存在' }); } - + fs.unlinkSync(fontPath); res.json({ success: true }); } catch (error) { diff --git a/src/js/app.js b/src/js/app.js index 8ce625b..362d516 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -448,16 +448,29 @@ async function openBookFromQueryIfNeeded(booksFromLoad) { let lastReadingPercentage = 0; +// PERF-2: 缓存 DOM 元素引用,避免每次调用时重复查询 +let _cachedScroller = null; +let _cachedBar = null; +let _cachedText = null; +let _domCacheInvalid = true; + +function invalidateProgressDomCache() { + _domCacheInvalid = true; +} + function updateReadingProgress() { - const scroller = document.querySelector('.main'); - const bar = document.getElementById('readingProgressBar'); - const text = document.getElementById('readingProgressText'); - if (!scroller || (!bar && !text)) return; - const max = Math.max(1, scroller.scrollHeight - scroller.clientHeight); - const pct = Math.min(100, Math.max(0, (scroller.scrollTop / max) * 100)); + if (_domCacheInvalid) { + _cachedScroller = document.querySelector('.main'); + _cachedBar = document.getElementById('readingProgressBar'); + _cachedText = document.getElementById('readingProgressText'); + _domCacheInvalid = false; + } + if (!_cachedScroller || (!_cachedBar && !_cachedText)) return; + const max = Math.max(1, _cachedScroller.scrollHeight - _cachedScroller.clientHeight); + const pct = Math.min(100, Math.max(0, (_cachedScroller.scrollTop / max) * 100)); lastReadingPercentage = pct; - if (bar) bar.style.width = pct.toFixed(2) + '%'; - if (text) text.textContent = Math.round(pct) + '%'; + if (_cachedBar) _cachedBar.style.width = pct.toFixed(2) + '%'; + if (_cachedText) _cachedText.textContent = Math.round(pct) + '%'; } function restoreScrollPositionByPercentage() { @@ -475,7 +488,17 @@ function restoreScrollPositionByPercentage() { function initReadingProgress() { const scroller = document.querySelector('.main'); if (!scroller) return; - scroller.addEventListener('scroll', updateReadingProgress, { passive: true }); + // PERF-1: 使用 rAF 节流 scroll 事件,避免高频 DOM 读写 + let rafPending = false; + scroller.addEventListener('scroll', () => { + if (!rafPending) { + rafPending = true; + requestAnimationFrame(() => { + updateReadingProgress(); + rafPending = false; + }); + } + }, { passive: true }); window.addEventListener('resize', updateReadingProgress); requestAnimationFrame(updateReadingProgress); } @@ -941,12 +964,19 @@ function setupEventListeners() { updateState({ currentlyReading: null }); + // BUG-1: 检查 sendBeacon 返回值,失败时使用同步 XHR 作为后备 try { const config = configManager.collectAllData(); - const blob = new Blob([JSON.stringify({ config, filename: 'user-config.json' })], { - type: 'application/json' - }); - navigator.sendBeacon('/api/save-config', blob); + const jsonStr = JSON.stringify({ config, filename: 'user-config.json' }); + const blob = new Blob([jsonStr], { type: 'application/json' }); + const sent = navigator.sendBeacon('/api/save-config', blob); + if (!sent) { + // sendBeacon 失败(如数据量过大),使用同步 XHR 后备 + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/save-config', false); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(jsonStr); + } } catch (e) { console.warn('自动保存失败:', e); } diff --git a/src/js/bookshelfApp.js b/src/js/bookshelfApp.js index 227133e..3e63d7f 100644 --- a/src/js/bookshelfApp.js +++ b/src/js/bookshelfApp.js @@ -16,10 +16,21 @@ const state = { const MAX_COVER_CACHE = 200; function setCoverCache(key, value) { + // BUG-6: 释放被替换/淘汰的 ObjectURL,防止内存泄漏 + const oldValue = state.covers.get(key); + if (oldValue && oldValue.startsWith('blob:')) { + URL.revokeObjectURL(oldValue); + } state.covers.set(key, value); if (state.covers.size > MAX_COVER_CACHE) { const oldestKey = state.covers.keys().next().value; - if (oldestKey) state.covers.delete(oldestKey); + if (oldestKey) { + const evicted = state.covers.get(oldestKey); + if (evicted && evicted.startsWith('blob:')) { + URL.revokeObjectURL(evicted); + } + state.covers.delete(oldestKey); + } } } @@ -325,7 +336,7 @@ function setupEventListeners() { } }); } - + // 初始化添加书籍弹窗(传入上传处理函数) initAddBooksModal(async (files) => { await uploadBooks(files); diff --git a/src/js/modules/bookmarkManager.js b/src/js/modules/bookmarkManager.js index 0aa1271..7b2bcef 100644 --- a/src/js/modules/bookmarkManager.js +++ b/src/js/modules/bookmarkManager.js @@ -59,6 +59,7 @@ export function addBookmark() { id: Date.now().toString(), title: bookmarkTitle.trim(), level: level, + timestamp: Date.now(), // BUG-9: 增加数字时间戳用于可靠排序 bookKey: state.currentFileKey, bookName: state.book ? (state.book.package ? state.book.package.metadata.title : '当前书籍') : '当前书籍', location: location, @@ -67,7 +68,7 @@ export function addBookmark() { // 保存书签 saveBookmark(bookmark); - + // 显示成功提示 showIndicator('书签已添加'); @@ -82,7 +83,7 @@ export function addBookmark() { export function saveBookmark(bookmark) { const bookmarks = getBookmarksForCurrentBook(); bookmarks.push(bookmark); - + try { const allBookmarks = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEYS.BOOKMARKS) || '{}'); allBookmarks[state.currentFileKey] = bookmarks; @@ -95,7 +96,7 @@ export function saveBookmark(bookmark) { // Get bookmarks for current book export function getBookmarksForCurrentBook() { if (!state.currentFileKey) return []; - + try { const allBookmarks = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEYS.BOOKMARKS) || '{}'); return allBookmarks[state.currentFileKey] || []; @@ -116,37 +117,40 @@ export function loadBookmarks() { export function renderBookmarkList() { const bookmarkList = DOM.bookmarkList(); if (!bookmarkList) return; - + bookmarkList.innerHTML = ''; - + if (state.bookmarks.length === 0) { bookmarkList.innerHTML = '
暂无书签,在阅读时点击"书签"按钮添加。
'; return; } - - // 按创建时间倒序排列(最新的在前面) + + // BUG-9: 使用数字时间戳排序,locale 日期字符串无法可靠解析 const sortedBookmarks = [...state.bookmarks].sort((a, b) => { - return new Date(b.createdAt) - new Date(a.createdAt); + return (b.timestamp || 0) - (a.timestamp || 0); }); - + + // BUG-4: HTML 转义函数,防止 XSS + const escHtml = (str) => String(str || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + sortedBookmarks.forEach(bookmark => { const el = document.createElement('div'); const lvl = bookmark.level || 1; el.className = 'bookmark-item level-' + lvl; el.style.paddingLeft = ((lvl - 1) * 12) + 'px'; el.innerHTML = ` -
Lv${lvl}${bookmark.title}
-
${bookmark.location.chapterTitle}
-
${bookmark.createdAt}
- +
Lv${lvl}${escHtml(bookmark.title)}
+
${escHtml(bookmark.location.chapterTitle)}
+
${escHtml(bookmark.createdAt)}
+ `; - + el.onclick = (e) => { if (e.target.className !== 'bookmark-delete') { goToBookmark(bookmark); } }; - + bookmarkList.appendChild(el); }); } @@ -154,9 +158,9 @@ export function renderBookmarkList() { // Go to bookmark location export function goToBookmark(bookmark) { if (!bookmark.location) return; - + const location = bookmark.location; - + if (location.type === 'epub' && state.type === 'epub' && state.rendition) { updateState({ isNavigating: true }); state.rendition.display(location.cfi).then(() => { @@ -185,7 +189,7 @@ export function goToBookmark(bookmark) { goToPdfChapter(idx); }); } - + // 关闭侧边栏 const sidebar = DOM.sidebar(); if (sidebar) { @@ -196,17 +200,17 @@ export function goToBookmark(bookmark) { // Remove bookmark export function removeBookmark(bookmarkId) { if (!confirm('确定要删除这个书签吗?')) return; - + try { const allBookmarks = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEYS.BOOKMARKS) || '{}'); const currentBookmarks = allBookmarks[state.currentFileKey] || []; - + allBookmarks[state.currentFileKey] = currentBookmarks.filter(b => b.id !== bookmarkId); localStorage.setItem(CONFIG.STORAGE_KEYS.BOOKMARKS, JSON.stringify(allBookmarks)); - + // 更新状态并重新渲染 loadBookmarks(); - + // 显示删除成功提示 showIndicator('书签已删除'); } catch (e) { @@ -221,17 +225,17 @@ export function clearAllBookmarks() { alert('当前书籍没有书签'); return; } - + if (!confirm(`确定要清空当前书籍的所有 ${state.bookmarks.length} 个书签吗?此操作不可撤销。`)) return; - + try { const allBookmarks = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEYS.BOOKMARKS) || '{}'); allBookmarks[state.currentFileKey] = []; localStorage.setItem(CONFIG.STORAGE_KEYS.BOOKMARKS, JSON.stringify(allBookmarks)); - + // 更新状态并重新渲染 loadBookmarks(); - + // 显示清空成功提示 showIndicator('书签已清空'); } catch (e) { @@ -246,7 +250,7 @@ function showIndicator(message) { if (indicator) { indicator.textContent = message; indicator.style.opacity = '1'; - setTimeout(() => { + setTimeout(() => { indicator.style.opacity = '0'; setTimeout(() => { indicator.textContent = '已保存'; diff --git a/src/js/modules/configManager.js b/src/js/modules/configManager.js index 56260bb..f2bbbac 100644 --- a/src/js/modules/configManager.js +++ b/src/js/modules/configManager.js @@ -1,11 +1,11 @@ // Configuration management module import { state, updateState } from '../core/state.js'; import { CONFIG } from '../core/config.js'; -import { - normalizePrefs, - computeVerticalPadding, - formatDecimal, - applyProgressBarPreference +import { + normalizePrefs, + computeVerticalPadding, + formatDecimal, + applyProgressBarPreference } from '../core/utils.js'; // 配置管理器 @@ -22,25 +22,25 @@ export class ConfigManager { theme: state.theme, fontSize: state.fontSize, }, - + // 阅读偏好 readingPrefs: this.getReadingPrefs(), - + // 最后阅读的书籍 lastReadBook: state.lastReadBook, - + // 阅读历史记录 readingHistory: state.readingHistory || {}, - + // 当前正在阅读的书籍 currentlyReading: state.currentlyReading, - + // 所有书籍的阅读进度 readingProgress: this.getAllReadingProgress(), - + // 所有书签 bookmarks: this.getAllBookmarks(), - + // 元数据 metadata: { exportedAt: new Date().toISOString(), @@ -50,7 +50,7 @@ export class ConfigManager { description: '本地电子书阅读器完整配置文件' } }; - + return allData; } @@ -68,7 +68,7 @@ export class ConfigManager { // 获取所有阅读进度 getAllReadingProgress() { const progress = {}; - + // 遍历localStorage中所有以'server_reader_'开头的键 for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); @@ -82,14 +82,14 @@ export class ConfigManager { } } } - + return progress; } // 获取所有书签 getAllBookmarks() { const allBookmarks = {}; - + // 遍历localStorage中所有以'bookmarks_'开头的键 for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); @@ -103,7 +103,7 @@ export class ConfigManager { } } } - + return allBookmarks; } @@ -111,7 +111,7 @@ export class ConfigManager { async saveConfig(customName = null) { try { const config = this.collectAllData(); - + const response = await fetch('/api/save-config', { method: 'POST', headers: { @@ -122,17 +122,17 @@ export class ConfigManager { filename: customName }) }); - + if (!response.ok) { throw new Error('Failed to save config'); } - + const result = await response.json(); this.currentConfigName = result.filename; - + // 显示成功消息 this.showMessage('配置保存成功!文件名: ' + result.filename, 'success'); - + return result; } catch (error) { console.error('Error saving config:', error); @@ -145,17 +145,17 @@ export class ConfigManager { async loadConfig(filename) { try { const response = await fetch(`/api/load-config/${filename}`); - + if (!response.ok) { throw new Error('Failed to load config'); } - + const result = await response.json(); await this.applyConfig(result.config); - + this.currentConfigName = filename; this.showMessage('配置加载成功!', 'success'); - + return result.config; } catch (error) { console.error('Error loading config:', error); @@ -174,7 +174,7 @@ export class ConfigManager { document.body.setAttribute('data-theme', config.settings.theme); this.updateThemeUI(); } - + if (config.settings.fontSize && config.settings.fontSize !== state.fontSize) { updateState({ fontSize: config.settings.fontSize }); this.updateFontSizeUI(); @@ -245,7 +245,7 @@ export class ConfigManager { applyReadingPrefs(prefs) { const normalized = normalizePrefs(prefs); const verticalPadding = computeVerticalPadding(normalized.pagePadding); - + document.documentElement.style.setProperty('--para-spacing', String(normalized.paraSpacing)); document.documentElement.style.setProperty('--letter-spacing', `${normalized.letterSpacing}px`); document.documentElement.style.setProperty('--line-height', String(normalized.lineHeight)); @@ -261,7 +261,7 @@ export class ConfigManager { pageWidth: document.getElementById('pageWidthInput'), pagePadding: document.getElementById('pageMarginInput') }; - + const values = { paraSpacing: document.getElementById('paraSpacingVal'), letterSpacing: document.getElementById('letterSpacingVal'), @@ -308,7 +308,7 @@ export class ConfigManager { span.textContent = state.theme === 'dark' ? '日间' : '夜间'; } } - + const currentTheme = document.getElementById('currentTheme'); if (currentTheme) { currentTheme.textContent = state.theme === 'dark' ? '夜间模式' : '日间模式'; @@ -326,7 +326,7 @@ export class ConfigManager { // 加载当前书籍的书签 loadBookmarksForCurrentBook() { if (!state.currentFileKey) return []; - + try { const bookPath = state.currentFileKey.replace('server_reader_', ''); const key = 'bookmarks_' + bookPath; @@ -342,14 +342,14 @@ export class ConfigManager { renderBookmarks() { const bookmarkList = document.getElementById('bookmarkList'); if (!bookmarkList) return; - + bookmarkList.innerHTML = ''; - + if (state.bookmarks.length === 0) { bookmarkList.innerHTML = '
暂无书签
'; return; } - + state.bookmarks.forEach((bookmark, index) => { const el = document.createElement('div'); el.className = 'chapter-item'; @@ -383,11 +383,11 @@ export class ConfigManager { async getConfigList() { try { const response = await fetch('/api/config-list'); - + if (!response.ok) { throw new Error('Failed to get config list'); } - + const result = await response.json(); return result.configs; } catch (error) { @@ -402,11 +402,11 @@ export class ConfigManager { const response = await fetch(`/api/config/${filename}`, { method: 'DELETE' }); - + if (!response.ok) { throw new Error('Failed to delete config'); } - + const result = await response.json(); this.showMessage('配置文件删除成功!', 'success'); return result; @@ -420,7 +420,8 @@ export class ConfigManager { // 下载配置文件 downloadConfig(filename) { const link = document.createElement('a'); - link.href = `/api/download-config/${filename}`; + // BUG-10: 编码文件名防止特殊字符破坏 URL + link.href = `/api/download-config/${encodeURIComponent(filename)}`; link.download = filename; document.body.appendChild(link); link.click(); @@ -448,9 +449,9 @@ export class ConfigManager { ${type === 'error' ? 'background: #f44336;' : ''} ${type === 'info' ? 'background: #2196F3;' : ''} `; - + document.body.appendChild(messageEl); - + // 3秒后自动移除 setTimeout(() => { if (document.body.contains(messageEl)) { diff --git a/src/js/modules/fileManager.js b/src/js/modules/fileManager.js index bf0e2d8..4dea5e0 100644 --- a/src/js/modules/fileManager.js +++ b/src/js/modules/fileManager.js @@ -30,7 +30,7 @@ export function saveReadingHistory() { export function updateReadingHistory(book) { const now = Date.now(); const history = { ...state.readingHistory }; - + if (history[book.path]) { history[book.path].lastReadTime = now; history[book.path].readCount = (history[book.path].readCount || 0) + 1; @@ -42,8 +42,8 @@ export function updateReadingHistory(book) { readCount: 1 }; } - - updateState({ + + updateState({ readingHistory: history, currentlyReading: book.path }); @@ -57,16 +57,16 @@ export async function loadBookshelf() { if (!response.ok) throw new Error('Failed to fetch bookshelf'); const books = await response.json(); updateState({ bookshelf: books }); - + // 加载阅读历史 loadReadingHistory(); - + // 应用启动时清除当前正在阅读状态,避免显示错误的"正在阅读"标识 updateState({ currentlyReading: null }); - + // 清理阅读历史中已不存在的书籍 cleanupReadingHistory(books); - + // 加载最后阅读的书籍信息 loadLastReadBook(); renderBookshelf(); @@ -85,48 +85,48 @@ export async function loadBookshelf() { export function renderBookshelf() { const bookshelfList = DOM.bookshelfList(); if (!bookshelfList) return; - + bookshelfList.innerHTML = ''; if (state.bookshelf.length === 0) { bookshelfList.innerHTML = '
书架为空,请将书籍文件放入 "books" 文件夹后点击 "刷新书架"。
'; return; } - + // 按阅读历史排序:当前正在阅读的书籍最前,然后按最后阅读时间排序,未读过的书籍最后 const sortedBooks = [...state.bookshelf].sort((a, b) => { const aIsCurrentlyReading = state.currentlyReading === a.path; const bIsCurrentlyReading = state.currentlyReading === b.path; - + // 当前正在阅读的书籍排在最前面(实际阅读中才会有这个状态) if (aIsCurrentlyReading && !bIsCurrentlyReading) return -1; if (!aIsCurrentlyReading && bIsCurrentlyReading) return 1; - + // 按阅读历史排序(最近阅读的在前) const aHistory = state.readingHistory[a.path]; const bHistory = state.readingHistory[b.path]; - + // 有阅读历史的排在无阅读历史的前面 if (aHistory && !bHistory) return -1; if (!aHistory && bHistory) return 1; - + // 都有阅读历史,按最后阅读时间降序排列(最近阅读的在前) if (aHistory && bHistory) { return bHistory.lastReadTime - aHistory.lastReadTime; } - + // 都没有阅读历史,按名称排序 return a.name.localeCompare(b.name, 'zh-CN'); }); - + sortedBooks.forEach((book) => { const el = document.createElement('div'); el.className = 'book-item'; - + // 检查书籍状态 const isCurrentlyReading = state.currentlyReading === book.path; const history = state.readingHistory[book.path]; const isLastRead = !isCurrentlyReading && state.lastReadBook && book.path === state.lastReadBook.path; - + // 应用样式类 if (isCurrentlyReading) { el.classList.add('currently-reading'); @@ -135,7 +135,7 @@ export function renderBookshelf() { } else if (history) { el.classList.add('has-history'); } - + // 构建显示信息 let statusInfo = ''; if (isCurrentlyReading) { @@ -146,14 +146,28 @@ export function renderBookshelf() { const timeAgo = formatTimeAgo(history.lastReadTime); statusInfo = `📖 ${timeAgo}`; } - - el.innerHTML = ` -
-
${book.name}
-
${book.path}
- ${statusInfo ? `
${statusInfo}
` : ''} -
- `; + + // BUG-2: 使用 DOM API 防止 XSS(书名/路径可能包含 HTML 特殊字符) + const wrapper = document.createElement('div'); + wrapper.style.flex = '1'; + const titleDiv = document.createElement('div'); + titleDiv.className = 'book-title'; + titleDiv.style.fontWeight = '600'; + titleDiv.textContent = book.name; + wrapper.appendChild(titleDiv); + const pathDiv = document.createElement('div'); + pathDiv.className = 'muted book-path'; + pathDiv.style.cssText = 'font-size:12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;'; + pathDiv.textContent = book.path; + wrapper.appendChild(pathDiv); + if (statusInfo) { + const statusDiv = document.createElement('div'); + statusDiv.className = 'book-status'; + statusDiv.style.cssText = 'font-size:11px; margin-top:2px;'; + statusDiv.innerHTML = statusInfo; // statusInfo 是内部生成的安全 HTML + wrapper.appendChild(statusDiv); + } + el.appendChild(wrapper); el.onclick = () => window.openBookFromServer(book); bookshelfList.appendChild(el); }); @@ -166,23 +180,23 @@ export async function openBookFromServer(book) { if (!response.ok) throw new Error(`Book not found or failed to load: ${book.name}`); const fileData = await response.arrayBuffer(); updateState({ currentFileKey: getFileKey(book.path) }); - + // 更新阅读历史 updateReadingHistory(book); - + // 清除"上次阅读"标识,因为现在有新的正在阅读的书籍了 updateState({ lastReadBook: null }); localStorage.removeItem(CONFIG.STORAGE_KEYS.LAST_READ_BOOK); - + // 重新渲染书架以显示最新的阅读状态 renderBookshelf(); - + // Set book metadata const bookMeta = DOM.bookMeta(); if (bookMeta) { bookMeta.textContent = `书名: ${book.name}`; } - + return { book, fileData }; } catch (error) { console.error('Error opening book from server:', error); @@ -194,8 +208,8 @@ export async function openBookFromServer(book) { // Read ArrayBuffer with encoding detection export async function readArrayBufferWithEncoding(arrayBuffer) { const decoderUtf8 = new TextDecoder('utf-8', { fatal: true }); - try { - return decoderUtf8.decode(arrayBuffer); + try { + return decoderUtf8.decode(arrayBuffer); } catch (e) { console.log("UTF-8 decoding failed, trying GBK..."); const decoderGbk = new TextDecoder('gbk'); @@ -240,28 +254,28 @@ export function loadLastReadBook() { } // Progress saving and loading -export function saveProgress(key, data) { - if (!key) return; - try { - localStorage.setItem(key, JSON.stringify(data)); +export function saveProgress(key, data) { + if (!key) return; + try { + localStorage.setItem(key, JSON.stringify(data)); const indicator = document.getElementById('save-indicator'); if (indicator) { indicator.style.opacity = '1'; setTimeout(() => { indicator.style.opacity = '0'; }, 1500); } - } catch (e) { - console.warn(e); - } + } catch (e) { + console.warn(e); + } } -export function loadProgress(key) { - if (!key) return null; - try { - const raw = localStorage.getItem(key); - return raw ? JSON.parse(raw) : null; - } catch (e) { - return null; - } +export function loadProgress(key) { + if (!key) return null; + try { + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + } catch (e) { + return null; + } } // 清理阅读历史中已不存在的书籍 @@ -269,7 +283,7 @@ function cleanupReadingHistory(currentBooks) { const currentBookPaths = new Set(currentBooks.map(book => book.path)); const history = { ...state.readingHistory }; let hasChanges = false; - + // 移除不存在的书籍历史记录 Object.keys(history).forEach(path => { if (!currentBookPaths.has(path)) { @@ -277,13 +291,13 @@ function cleanupReadingHistory(currentBooks) { hasChanges = true; } }); - + // 清理当前正在阅读的书籍标记 if (state.currentlyReading && !currentBookPaths.has(state.currentlyReading)) { updateState({ currentlyReading: null }); hasChanges = true; } - + if (hasChanges) { updateState({ readingHistory: history }); saveReadingHistory(); diff --git a/src/js/modules/pdfReader.js b/src/js/modules/pdfReader.js index e183765..3d94c77 100644 --- a/src/js/modules/pdfReader.js +++ b/src/js/modules/pdfReader.js @@ -81,25 +81,25 @@ async function renderPdfPage(pdfDoc, pageNum, container, renderedSet, progress) async function buildChaptersFromOutline(pdfDoc, outline) { const chapters = []; - + async function resolveItem(item, level = 1) { const lvl = Math.max(1, Math.min(3, level)); try { let dest = item.dest || null; let pageIndex = null; let yOffset = null; - + // 处理目标引用 if (typeof dest === 'string') { dest = await pdfDoc.getDestination(dest); } - + if (Array.isArray(dest) && dest.length > 0) { // 获取页面索引 if (dest[0] && typeof dest[0] === 'object') { pageIndex = await pdfDoc.getPageIndex(dest[0]); } - + // 解析目标位置信息 // PDF目标格式: [page, type, left, top, right, bottom, zoom] // 常见类型: /XYZ (left, top, zoom), /Fit, /FitH (top), /FitV (left) @@ -118,7 +118,7 @@ async function buildChaptersFromOutline(pdfDoc, outline) { } } } - + if (pageIndex !== null) { const title = (item.title || `第${pageIndex + 1}页`).trim(); chapters.push({ @@ -133,7 +133,7 @@ async function buildChaptersFromOutline(pdfDoc, outline) { } catch (error) { console.warn('Failed to resolve outline item:', item.title, error); } - + // 递归处理子项 if (Array.isArray(item.items)) { for (const sub of item.items) { @@ -141,7 +141,7 @@ async function buildChaptersFromOutline(pdfDoc, outline) { } } } - + // 处理所有大纲项 for (const item of outline) { await resolveItem(item, 1); @@ -158,11 +158,11 @@ async function buildChaptersFromOutline(pdfDoc, outline) { } return 0; }); - + // 去重:相同页面且标题相同的项目只保留一个 const uniqueChapters = []; const seen = new Set(); - + for (const chapter of chapters) { const key = `${chapter.pageIndex}-${chapter.label}`; if (!seen.has(key)) { @@ -176,34 +176,34 @@ async function buildChaptersFromOutline(pdfDoc, outline) { }); } } - + return uniqueChapters; } function scrollToPdfPage(index, yOffset = null) { const pageEl = document.getElementById(`pdf-page-${index + 1}`); if (!pageEl) return; - + const scroller = document.querySelector('.main'); if (!scroller) { pageEl.scrollIntoView({ behavior: 'auto', block: 'start' }); return; } - + // 强制渲染目标页面 if (window.forceRenderPdfPage) { window.forceRenderPdfPage(index + 1); } - + // 使用简单可靠的滚动方式 pageEl.scrollIntoView({ behavior: 'auto', block: 'start' }); - + // 如果有精确位置要求,等待渲染后再调整 if (yOffset !== null && typeof yOffset === 'number') { const adjustPosition = async () => { // 等待一段时间确保页面渲染 await new Promise(resolve => setTimeout(resolve, 300)); - + const canvas = pageEl.querySelector('canvas'); if (canvas && canvas.offsetHeight > 0) { try { @@ -212,21 +212,21 @@ function scrollToPdfPage(index, yOffset = null) { if (pdfDoc) { const page = await pdfDoc.getPage(pageNum); const viewport = page.getViewport({ scale: 1 }); - + // 计算缩放比例和偏移 const canvasHeight = canvas.offsetHeight; const scale = canvasHeight / viewport.height; const pdfHeight = viewport.height; - + // PDF坐标转换:Y轴向上为正 -> 浏览器Y轴向下为正 const offsetFromTop = pdfHeight - yOffset; const pixelOffset = Math.max(0, offsetFromTop * scale); - + // 获取页面当前位置并调整 const pageRect = pageEl.getBoundingClientRect(); const scrollerRect = scroller.getBoundingClientRect(); const currentPageTop = scroller.scrollTop + pageRect.top - scrollerRect.top; - + scroller.scrollTo({ top: currentPageTop + pixelOffset, behavior: 'smooth' @@ -237,7 +237,7 @@ function scrollToPdfPage(index, yOffset = null) { } } }; - + adjustPosition(); } } @@ -250,41 +250,41 @@ function setupScrollIndexSync() { let ticking = false; let lastUpdateTime = 0; - + function update() { ticking = false; - + // 如果正在导航,跳过更新 if (state.isNavigating) return; - + // 限制更新频率 const now = Date.now(); if (now - lastUpdateTime < 100) return; lastUpdateTime = now; - + const pages = Array.from(document.querySelectorAll('.pdf-page')); if (pages.length === 0) return; - + const scrollerRect = scroller.getBoundingClientRect(); const viewportCenter = scrollerRect.top + scrollerRect.height / 2; - + let bestIdx = state.currentIndex || 0; let bestDelta = Infinity; - + pages.forEach((el, i) => { const rect = el.getBoundingClientRect(); const pageCenter = rect.top + rect.height / 2; const delta = Math.abs(pageCenter - viewportCenter); - + if (delta < bestDelta) { bestDelta = delta; bestIdx = i; } }); - + if (bestIdx !== state.currentIndex) { updateState({ currentIndex: bestIdx }); - + // 更新章节索引 const chapters = state.chapters || []; let chapterIndex = -1; @@ -297,7 +297,7 @@ function setupScrollIndexSync() { if (chapterIndex >= 0) { updateState({ currentChapterIndex: chapterIndex }); } - + updateActiveTOC(); renderChapterNav(); } @@ -421,6 +421,12 @@ function createPdfRenderProgress(total) { // 打开 PDF export async function openPdf(arrayBuffer) { clearReader(); + // BUG-8: 重置滚动同步标志,确保新 PDF 重新绑定 + pdfScrollSyncBound = false; + if (activePdfObserver) { + activePdfObserver.disconnect(); + activePdfObserver = null; + } try { const pdfjsLib = await ensurePdfJsLoaded(); const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); @@ -441,7 +447,7 @@ export async function openPdf(arrayBuffer) { if (outline && outline.length) { chapters = await buildChaptersFromOutline(pdfDoc, outline); } - } catch {} + } catch { } if (!chapters || chapters.length === 0) { chapters = Array.from({ length: pageCount }, (_, i) => ({ label: `第${i + 1}页`, @@ -470,22 +476,22 @@ export async function openPdf(arrayBuffer) { if (!entry.isIntersecting) return; const id = entry.target.id; // pdf-page-N const num = Number(id.split('-').pop()); - + // 确保页面渲染 if (!renderedSet.has(num)) { - renderPdfPage(pdfDoc, num, container, renderedSet, progress).catch(() => {}); + renderPdfPage(pdfDoc, num, container, renderedSet, progress).catch(() => { }); } }); - }, { - root: document.querySelector('.main') || null, + }, { + root: document.querySelector('.main') || null, rootMargin: '500px 0px', // 增大预渲染范围 - threshold: 0.01 + threshold: 0.01 }); // 添加强制渲染函数,用于目录跳转 window.forceRenderPdfPage = (pageNum) => { if (!renderedSet.has(pageNum) && pageNum >= 1 && pageNum <= pageCount) { - renderPdfPage(pdfDoc, pageNum, container, renderedSet, progress).catch(() => {}); + renderPdfPage(pdfDoc, pageNum, container, renderedSet, progress).catch(() => { }); } }; @@ -529,7 +535,7 @@ export function goToPdfChapter(target) { } else { // 直接页面跳转:传入页码 page = Math.max(0, Math.min(pageCount - 1, Number(target) || 0)); - + // 找到对应的章节索引(用于高亮目录) for (let i = chapters.length - 1; i >= 0; i--) { if (chapters[i].pageIndex <= page) { @@ -541,16 +547,16 @@ export function goToPdfChapter(target) { // 设置导航状态,防止滚动同步干扰 setNavigating(true); - + // 更新状态 - updateState({ + updateState({ currentIndex: page, currentChapterIndex: chapterIndex >= 0 ? chapterIndex : -1 }); - + // 立即执行跳转 scrollToPdfPage(page, yOffset); - + // 延迟更新UI,确保跳转完成 setTimeout(() => { updateActiveTOC(); diff --git a/src/js/modules/txtReader.js b/src/js/modules/txtReader.js index 9812b97..ea73a5a 100644 --- a/src/js/modules/txtReader.js +++ b/src/js/modules/txtReader.js @@ -57,15 +57,15 @@ export async function openTxt(text, fileName) { txtPages.push((title + '\n\n' + (content || '')).trim()); } } - + if (chapters.length === 0) { chapters.push({ label: fileName, href: '#txt-0' }); txtPages.push(text); } - + updateState({ chapters, txtPages }); renderTOC(); - + const saved = loadProgress(state.currentFileKey); displayTxtChapter(saved?.idx || 0); } @@ -73,7 +73,7 @@ export async function openTxt(text, fileName) { // Display specific TXT chapter export function displayTxtChapter(idx) { if (idx < 0 || idx >= state.txtPages.length) return; - + updateState({ currentIndex: idx }); const raw = state.txtPages[idx] || ''; const lines = raw.replace(/\r/g, '').split('\n'); @@ -82,16 +82,18 @@ export function displayTxtChapter(idx) { if (line.trim() === '') return '
'; return '

' + line.trim().replace(/'; }).join(''); - + const readerInner = DOM.readerInner(); if (readerInner) { - readerInner.innerHTML = `

${titleText}

${contentHtml}`; + // BUG-3: 转义标题防止 XSS + const escapedTitle = titleText.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + readerInner.innerHTML = `

${escapedTitle}

${contentHtml}`; } - + updateActiveTOC(); renderChapterNav(); applyTxtFontSize(); - + // 滚动到页面顶部 - 使用 requestAnimationFrame 确保在DOM更新后执行 requestAnimationFrame(() => { const mainContainer = document.querySelector('.main'); @@ -112,7 +114,7 @@ export function applyTxtFontSize() { // Navigate to chapter in TXT export function goToTxtChapter(index) { if (index < 0 || index >= state.chapters.length || state.isNavigating) return; - + setNavigating(true); // 上锁 displayTxtChapter(index); setNavigating(false); // TXT是同步操作,立即解锁 From 3f7fe5e3ff86eb9064002ee9b1c1865a5a0ae71d Mon Sep 17 00:00:00 2001 From: WangXuan Date: Tue, 10 Feb 2026 12:16:46 +0800 Subject: [PATCH 2/6] feat: Introduce Vitest for unit testing and add initial tests for frontend utilities, config, and server API. --- package-lock.json | 2349 +++++++++++++++++++++++++++++++-- package.json | 8 +- server.js | 15 +- tests/frontend/config.test.js | 60 + tests/frontend/utils.test.js | 341 +++++ tests/server/api.test.js | 220 +++ tests/server/utils.test.js | 143 ++ vitest.config.js | 17 + 8 files changed, 3025 insertions(+), 128 deletions(-) create mode 100644 tests/frontend/config.test.js create mode 100644 tests/frontend/utils.test.js create mode 100644 tests/server/api.test.js create mode 100644 tests/server/utils.test.js create mode 100644 vitest.config.js diff --git a/package-lock.json b/package-lock.json index ae1e449..9e53142 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,206 @@ }, "devDependencies": { "electron": "^33.4.11", - "electron-builder": "^25.1.8" + "electron-builder": "^25.1.8", + "jsdom": "^28.0.0", + "supertest": "^7.2.2", + "vitest": "^4.0.18" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz", + "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", + "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "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.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.0.tgz", + "integrity": "sha512-q4d82GTl8BIlh/dTnVsWmxnbWJeb3kiU8eUH71UxlxnS+WIaALmtzTL8gR15PkYOexMQYVk0CO4qIG93C1IvPA==", + "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.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", + "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "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.0.1", + "@csstools/css-calc": "^3.0.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", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", + "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "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", + "peer": true, + "engines": { + "node": ">=20.19.0" } }, "node_modules/@develar/schema-utils": { @@ -376,140 +575,607 @@ "node": ">= 10.0.0" } }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "20 || >=22" + "node": ">=18" } }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "20 || >=22" + "node": ">=18" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/@malept/cross-spawn-promise": { + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.12.0.tgz", + "integrity": "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw==", + "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/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", @@ -587,6 +1253,19 @@ "node": ">= 10.0.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@npmcli/fs": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/@npmcli/fs/-/fs-2.1.2.tgz", @@ -629,6 +1308,16 @@ "node": ">=10" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -640,6 +1329,356 @@ "node": ">=14" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz", @@ -653,6 +1692,13 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -689,6 +1735,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", @@ -699,6 +1756,20 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -740,48 +1811,159 @@ "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmmirror.com/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmmirror.com/@types/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/verror": { - "version": "1.10.11", - "resolved": "https://registry.npmmirror.com/@types/verror/-/verror-1.10.11.tgz", - "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", - "optional": true + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@types/node": "*" + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/@xmldom/xmldom": { @@ -1123,6 +2305,13 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz", @@ -1134,6 +2323,16 @@ "node": ">=0.8" } }, + "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/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", @@ -1207,6 +2406,16 @@ ], "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -1579,6 +2788,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", @@ -1784,6 +3003,16 @@ "node": ">=0.10.0" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz", @@ -1936,6 +3165,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", @@ -1996,6 +3232,60 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2013,6 +3303,13 @@ } } }, + "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/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -2134,6 +3431,17 @@ "license": "MIT", "optional": true }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/dir-compare/-/dir-compare-4.2.0.tgz", @@ -2539,6 +3847,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", @@ -2574,6 +3895,13 @@ "node": ">= 0.4" } }, + "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/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2610,6 +3938,48 @@ "license": "MIT", "optional": true }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", @@ -2640,6 +4010,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2649,6 +4029,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -2744,6 +4134,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -2754,6 +4151,24 @@ "pend": "~1.2.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/filelist": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz", @@ -2874,6 +4289,24 @@ "node": ">= 0.6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2934,6 +4367,21 @@ "dev": true, "license": "ISC" }, + "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/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3228,6 +4676,19 @@ "node": ">=10" } }, + "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/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -3475,6 +4936,13 @@ "dev": true, "license": "MIT" }, + "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/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3568,6 +5036,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", + "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^5.3.7", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.20.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3758,6 +5267,16 @@ "node": ">=10" } }, + "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/make-fetch-happen": { "version": "10.2.1", "resolved": "https://registry.npmmirror.com/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", @@ -3871,6 +5390,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -3892,6 +5418,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", @@ -4150,6 +5686,25 @@ "node": ">= 0.6" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -4296,6 +5851,17 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4406,6 +5972,19 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4478,6 +6057,13 @@ "node": ">=16" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmmirror.com/pe-library/-/pe-library-0.4.1.tgz", @@ -4507,6 +6093,20 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz", @@ -4522,6 +6122,35 @@ "node": ">=10.4.0" } }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "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.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -4595,9 +6224,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -4716,6 +6345,16 @@ "node": ">=0.10.0" } }, + "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/resedit": { "version": "1.7.2", "resolved": "https://registry.npmmirror.com/resedit/-/resedit-1.7.2.tgz", @@ -4814,6 +6453,51 @@ "node": ">=8.0" } }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -4873,6 +6557,19 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "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/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -5056,6 +6753,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", @@ -5156,6 +6860,16 @@ "node": ">=0.10.0" } }, + "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/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", @@ -5188,6 +6902,13 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.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/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz", @@ -5207,6 +6928,13 @@ "node": ">= 0.8" } }, + "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/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -5295,6 +7023,42 @@ "node": ">= 8.0" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", @@ -5308,6 +7072,13 @@ "node": ">=8" } }, + "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/tar": { "version": "6.2.1", "resolved": "https://registry.npmmirror.com/tar/-/tar-6.2.1.tgz", @@ -5425,6 +7196,70 @@ "node": ">= 10.0.0" } }, + "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": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz", @@ -5454,6 +7289,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "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/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -5512,6 +7373,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", + "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", @@ -5612,6 +7483,173 @@ "node": ">=0.6.0" } }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "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/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "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/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/wcwidth/-/wcwidth-1.0.1.tgz", @@ -5622,6 +7660,41 @@ "defaults": "^1.0.3" } }, + "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": "16.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", + "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "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/which": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", @@ -5638,6 +7711,23 @@ "node": ">= 8" } }, + "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/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmmirror.com/wide-align/-/wide-align-1.1.5.tgz", @@ -5691,6 +7781,16 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "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/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -5701,6 +7801,13 @@ "node": ">=8.0" } }, + "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" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 5e6e3a2..dd2ce8e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "一个本地电子书阅读器,支持 EPUB、TXT、PDF 格式", "main": "electron-main.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest run", + "test:watch": "vitest", "start": "node server.js", "electron": "electron .", "electron:dev": "electron . --dev", @@ -31,7 +32,10 @@ }, "devDependencies": { "electron": "^33.4.11", - "electron-builder": "^25.1.8" + "electron-builder": "^25.1.8", + "jsdom": "^28.0.0", + "supertest": "^7.2.2", + "vitest": "^4.0.18" }, "build": { "appId": "com.localread.app", diff --git a/server.js b/server.js index 27c437e..c11fa6b 100644 --- a/server.js +++ b/server.js @@ -623,8 +623,13 @@ app.delete('/api/fonts/:fontId', (req, res) => { } }); -// 启动服务器 -app.listen(PORT, () => { - console.log(`E-book reader server listening at http://localhost:${PORT}`); - console.log(`Place your .epub, .txt and .pdf files in the "${DIRS.books}" folder.`); -}); +// 启动服务器(仅直接运行时监听,被 require 时不监听,便于测试) +if (require.main === module) { + app.listen(PORT, () => { + console.log(`E-book reader server listening at http://localhost:${PORT}`); + console.log(`Place your .epub, .txt and .pdf files in the "${DIRS.books}" folder.`); + }); +} + +// 导出供测试使用 +module.exports = { app, utils, DIRS, ALLOWED_EXTENSIONS }; diff --git a/tests/frontend/config.test.js b/tests/frontend/config.test.js new file mode 100644 index 0000000..1a8073e --- /dev/null +++ b/tests/frontend/config.test.js @@ -0,0 +1,60 @@ +// 前端配置模块单元测试 +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { CONFIG, getFileKey } from '../../src/js/core/config.js'; + +/* ========== getFileKey ========== */ + +describe('getFileKey', () => { + it('应在路径前添加 server_reader_ 前缀', () => { + expect(getFileKey('books/test.epub')).toBe('server_reader_books/test.epub'); + }); + + it('应处理空字符串', () => { + expect(getFileKey('')).toBe('server_reader_'); + }); + + it('应处理 Windows 路径', () => { + expect(getFileKey('books\\test.epub')).toBe('server_reader_books\\test.epub'); + }); + + it('应处理含中文的路径', () => { + expect(getFileKey('书籍/测试.epub')).toBe('server_reader_书籍/测试.epub'); + }); +}); + +/* ========== CONFIG 常量 ========== */ + +describe('CONFIG', () => { + it('应包含字体大小限制', () => { + expect(CONFIG.MIN_FONT_SIZE).toBeDefined(); + expect(CONFIG.MAX_FONT_SIZE).toBeDefined(); + expect(CONFIG.MIN_FONT_SIZE).toBeLessThan(CONFIG.MAX_FONT_SIZE); + }); + + it('应包含 API 路径', () => { + expect(CONFIG.SERVER_API).toBeDefined(); + expect(CONFIG.SERVER_API.BOOKSHELF).toBeDefined(); + expect(CONFIG.SERVER_API.BOOK).toBeDefined(); + expect(CONFIG.SERVER_API.UPLOAD).toBeDefined(); + expect(CONFIG.SERVER_API.BOOK_COVER).toBeDefined(); + }); + + it('应包含存储键', () => { + expect(CONFIG.STORAGE_KEYS).toBeDefined(); + expect(CONFIG.STORAGE_KEYS.BOOKMARKS).toBeDefined(); + expect(CONFIG.STORAGE_KEYS.LAST_READ_BOOK).toBeDefined(); + expect(CONFIG.STORAGE_KEYS.READING_HISTORY).toBeDefined(); + }); + + it('应包含主题定义', () => { + expect(CONFIG.THEMES.LIGHT).toBe('light'); + expect(CONFIG.THEMES.DARK).toBe('dark'); + }); + + it('应包含侧边栏视图定义', () => { + expect(CONFIG.SIDEBAR_VIEWS.TOC).toBe('toc'); + expect(CONFIG.SIDEBAR_VIEWS.BOOKSHELF).toBe('bookshelf'); + expect(CONFIG.SIDEBAR_VIEWS.BOOKMARK).toBe('bookmark'); + }); +}); diff --git a/tests/frontend/utils.test.js b/tests/frontend/utils.test.js new file mode 100644 index 0000000..80d5037 --- /dev/null +++ b/tests/frontend/utils.test.js @@ -0,0 +1,341 @@ +// 前端核心工具函数单元测试 +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + DEFAULT_READING_PREFS, + clamp, + normalizePrefs, + computeVerticalPadding, + formatFileSize, + formatDecimal, + formatTimeAgo, + getFileExtension, + deriveBookNameFromPath, + debounce, + throttle +} from '../../src/js/core/utils.js'; + +/* ========== DEFAULT_READING_PREFS ========== */ + +describe('DEFAULT_READING_PREFS', () => { + it('应包含所有必要字段', () => { + expect(DEFAULT_READING_PREFS).toHaveProperty('paraSpacing'); + expect(DEFAULT_READING_PREFS).toHaveProperty('letterSpacing'); + expect(DEFAULT_READING_PREFS).toHaveProperty('lineHeight'); + expect(DEFAULT_READING_PREFS).toHaveProperty('pageWidth'); + expect(DEFAULT_READING_PREFS).toHaveProperty('pagePadding'); + expect(DEFAULT_READING_PREFS).toHaveProperty('progressBarEnabled'); + }); + + it('默认值应合理', () => { + expect(DEFAULT_READING_PREFS.pageWidth).toBeGreaterThanOrEqual(400); + expect(DEFAULT_READING_PREFS.pageWidth).toBeLessThanOrEqual(2000); + expect(DEFAULT_READING_PREFS.progressBarEnabled).toBe(true); + }); +}); + +/* ========== clamp ========== */ + +describe('clamp', () => { + it('正常范围内的值不变', () => { + expect(clamp(5, 0, 10, 0)).toBe(5); + }); + + it('小于最小值时返回最小值', () => { + expect(clamp(-1, 0, 10, 0)).toBe(0); + }); + + it('大于最大值时返回最大值', () => { + expect(clamp(15, 0, 10, 0)).toBe(10); + }); + + it('非数字值返回 fallback', () => { + expect(clamp('abc', 0, 10, 5)).toBe(5); + expect(clamp(undefined, 0, 10, 5)).toBe(5); + expect(clamp(null, 0, 10, 5)).toBe(0); // Number(null) === 0 + }); + + it('NaN 返回 fallback', () => { + expect(clamp(NaN, 0, 10, 5)).toBe(5); + }); + + it('Infinity 返回 fallback', () => { + expect(clamp(Infinity, 0, 10, 5)).toBe(5); + }); + + it('字符串数字应被正确转换', () => { + expect(clamp('7', 0, 10, 5)).toBe(7); + }); +}); + +/* ========== normalizePrefs ========== */ + +describe('normalizePrefs', () => { + it('无参数时返回默认值', () => { + const result = normalizePrefs(); + expect(result).toEqual({ + paraSpacing: DEFAULT_READING_PREFS.paraSpacing, + letterSpacing: DEFAULT_READING_PREFS.letterSpacing, + lineHeight: DEFAULT_READING_PREFS.lineHeight, + pageWidth: DEFAULT_READING_PREFS.pageWidth, + pagePadding: DEFAULT_READING_PREFS.pagePadding, + progressBarEnabled: true + }); + }); + + it('应将超出范围的值限制在有效范围内', () => { + const result = normalizePrefs({ + paraSpacing: 100, + letterSpacing: -10, + lineHeight: 0.1, + pageWidth: 50, + pagePadding: 500 + }); + expect(result.paraSpacing).toBeLessThanOrEqual(4); + expect(result.letterSpacing).toBeGreaterThanOrEqual(0); + expect(result.lineHeight).toBeGreaterThanOrEqual(1.0); + expect(result.pageWidth).toBeGreaterThanOrEqual(400); + expect(result.pagePadding).toBeLessThanOrEqual(150); + }); + + it('应合并传入的部分设置', () => { + const result = normalizePrefs({ lineHeight: 2.0 }); + expect(result.lineHeight).toBe(2.0); + expect(result.pageWidth).toBe(DEFAULT_READING_PREFS.pageWidth); + }); + + it('progressBarEnabled 默认为 true', () => { + expect(normalizePrefs({}).progressBarEnabled).toBe(true); + expect(normalizePrefs({ progressBarEnabled: false }).progressBarEnabled).toBe(false); + }); + + it('pageWidth 和 pagePadding 应为整数', () => { + const result = normalizePrefs({ pageWidth: 800.7, pagePadding: 40.3 }); + expect(Number.isInteger(result.pageWidth)).toBe(true); + expect(Number.isInteger(result.pagePadding)).toBe(true); + }); +}); + +/* ========== computeVerticalPadding ========== */ + +describe('computeVerticalPadding', () => { + it('应返回 horizontal * 0.75 的四舍五入值', () => { + expect(computeVerticalPadding(40)).toBe(30); + expect(computeVerticalPadding(100)).toBe(75); + }); + + it('最小值不低于 8', () => { + expect(computeVerticalPadding(5)).toBe(8); + expect(computeVerticalPadding(0)).toBe(8); + }); + + it('非数字参数使用默认边距', () => { + const defaultResult = Math.round(DEFAULT_READING_PREFS.pagePadding * 0.75); + expect(computeVerticalPadding('abc')).toBe(defaultResult); + expect(computeVerticalPadding(NaN)).toBe(defaultResult); + }); +}); + +/* ========== formatFileSize ========== */ + +describe('formatFileSize', () => { + it('应正确格式化字节', () => { + expect(formatFileSize(0)).toBe('0 B'); + expect(formatFileSize(500)).toBe('500 B'); + }); + + it('应正确格式化 KB', () => { + expect(formatFileSize(1024)).toBe('1 KB'); + expect(formatFileSize(1536)).toBe('1.5 KB'); + }); + + it('应正确格式化 MB', () => { + expect(formatFileSize(1048576)).toBe('1 MB'); + expect(formatFileSize(5242880)).toBe('5 MB'); + }); + + it('应正确格式化 GB', () => { + expect(formatFileSize(1073741824)).toBe('1 GB'); + }); +}); + +/* ========== formatDecimal ========== */ + +describe('formatDecimal', () => { + it('整数不显示小数', () => { + expect(formatDecimal(1)).toBe('1'); + expect(formatDecimal(2.0)).toBe('2'); + }); + + it('一位小数正确显示', () => { + expect(formatDecimal(1.5)).toBe('1.5'); + expect(formatDecimal(2.3)).toBe('2.3'); + }); + + it('两位小数正确显示', () => { + expect(formatDecimal(1.25)).toBe('1.25'); + }); + + it('多位小数截断到两位', () => { + expect(formatDecimal(1.999)).toBe('2'); + expect(formatDecimal(1.234)).toBe('1.23'); + }); +}); + +/* ========== formatTimeAgo ========== */ + +describe('formatTimeAgo', () => { + it('空值返回空字符串', () => { + expect(formatTimeAgo(null)).toBe(''); + expect(formatTimeAgo(0)).toBe(''); + expect(formatTimeAgo(undefined)).toBe(''); + }); + + it('刚刚(少于1分钟)', () => { + expect(formatTimeAgo(Date.now() - 10000)).toBe('刚刚'); + }); + + it('几分钟前', () => { + expect(formatTimeAgo(Date.now() - 5 * 60 * 1000)).toBe('5分钟前'); + }); + + it('几小时前', () => { + expect(formatTimeAgo(Date.now() - 3 * 60 * 60 * 1000)).toBe('3小时前'); + }); + + it('几天前', () => { + const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000; + expect(formatTimeAgo(threeDaysAgo)).toBe('3天前'); + }); + + it('几周前', () => { + const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000; + expect(formatTimeAgo(twoWeeksAgo)).toBe('2周前'); + }); + + it('超过30天显示日期', () => { + const longAgo = Date.now() - 60 * 24 * 60 * 60 * 1000; + const result = formatTimeAgo(longAgo); + expect(result).toMatch(/\d+\/\d+/); + }); +}); + +/* ========== getFileExtension ========== */ + +describe('getFileExtension', () => { + it('应返回文件扩展名(小写)', () => { + expect(getFileExtension('book.epub')).toBe('epub'); + expect(getFileExtension('book.TXT')).toBe('txt'); + expect(getFileExtension('book.Pdf')).toBe('pdf'); + }); + + it('应处理多个点', () => { + expect(getFileExtension('my.book.epub')).toBe('epub'); + }); + + it('无扩展名返回文件名本身', () => { + expect(getFileExtension('README')).toBe('readme'); + }); +}); + +/* ========== deriveBookNameFromPath ========== */ + +describe('deriveBookNameFromPath', () => { + it('应从路径中提取文件名', () => { + expect(deriveBookNameFromPath('books/test.epub')).toBe('test.epub'); + expect(deriveBookNameFromPath('/path/to/book.txt')).toBe('book.txt'); + }); + + it('应处理 Windows 路径', () => { + expect(deriveBookNameFromPath('C:\\books\\test.epub')).toBe('test.epub'); + }); + + it('空路径返回默认值', () => { + expect(deriveBookNameFromPath('')).toBe('未知书籍'); + expect(deriveBookNameFromPath(null)).toBe('未知书籍'); + expect(deriveBookNameFromPath(undefined)).toBe('未知书籍'); + }); + + it('只有文件名时直接返回', () => { + expect(deriveBookNameFromPath('mybook.epub')).toBe('mybook.epub'); + }); +}); + +/* ========== debounce ========== */ + +describe('debounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('应在等待时间后执行', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 100); + + debounced(); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('多次调用应只执行最后一次', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 100); + + debounced('a'); + debounced('b'); + debounced('c'); + + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith('c'); + }); +}); + +/* ========== throttle ========== */ + +describe('throttle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('应立即执行第一次调用', () => { + const fn = vi.fn(); + const throttled = throttle(fn, 100); + + throttled(); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('在节流期间不执行', () => { + const fn = vi.fn(); + const throttled = throttle(fn, 100); + + throttled(); + throttled(); + throttled(); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('节流期过后可再次执行', () => { + const fn = vi.fn(); + const throttled = throttle(fn, 100); + + throttled(); + expect(fn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + throttled(); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/server/api.test.js b/tests/server/api.test.js new file mode 100644 index 0000000..014db77 --- /dev/null +++ b/tests/server/api.test.js @@ -0,0 +1,220 @@ +// 服务端 API 功能集成测试 +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const supertest = require('supertest'); +const fs = require('fs'); +const path = require('path'); + +let app, DIRS, request; + +beforeAll(() => { + const server = require('../../server.js'); + app = server.app; + DIRS = server.DIRS; + request = supertest(app); + + // 确保测试目录存在 + [DIRS.books, DIRS.config, DIRS.fonts].forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); +}); + +/* ========== 书籍管理功能测试 ========== */ + +describe('GET /api/bookshelf — 获取书籍列表', () => { + it('应返回 200 和书籍数组', async () => { + const res = await request.get('/api/bookshelf'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('每本书应包含 name 和 path 字段', async () => { + const res = await request.get('/api/bookshelf'); + if (res.body.length > 0) { + const book = res.body[0]; + expect(book).toHaveProperty('name'); + expect(book).toHaveProperty('path'); + } + }); +}); + +describe('POST /api/upload — 上传书籍', () => { + const testFilePath = path.join(__dirname, 'test-upload.txt'); + + beforeEach(() => { + fs.writeFileSync(testFilePath, '这是一本测试书籍的内容\n第一章 开始\n正文内容...'); + }); + + afterAll(() => { + if (fs.existsSync(testFilePath)) fs.unlinkSync(testFilePath); + // 清理上传的文件 + const uploadedPath = path.join(DIRS.books, 'test-upload.txt'); + if (fs.existsSync(uploadedPath)) fs.unlinkSync(uploadedPath); + }); + + it('应成功上传 .txt 文件', async () => { + const res = await request + .post('/api/upload') + .attach('books', testFilePath); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('message'); + }); + + it('应拒绝不支持的文件格式', async () => { + const badFile = path.join(__dirname, 'test.xyz'); + fs.writeFileSync(badFile, 'bad content'); + try { + const res = await request + .post('/api/upload') + .attach('books', badFile); + expect(res.status).toBeGreaterThanOrEqual(400); + } finally { + if (fs.existsSync(badFile)) fs.unlinkSync(badFile); + } + }); +}); + +describe('GET /api/book — 读取书籍内容', () => { + const testBookPath = 'api-test-book.txt'; + + beforeAll(() => { + const fullPath = path.join(DIRS.books, testBookPath); + fs.writeFileSync(fullPath, '测试内容,用于API测试'); + }); + + afterAll(() => { + const fullPath = path.join(DIRS.books, testBookPath); + if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath); + }); + + it('应返回书籍文件内容', async () => { + const res = await request + .get(`/api/book?path=${encodeURIComponent(testBookPath)}`); + expect(res.status).toBe(200); + }); + + it('应返回 404 对不存在的书籍', async () => { + const res = await request + .get('/api/book?path=nonexistent-book-12345.epub'); + expect(res.status).toBe(404); + }); +}); + +/* ========== 配置管理功能测试 ========== */ + +describe('配置管理 — 保存/加载/列表/删除', () => { + const testConfigName = 'vitest-test-config.json'; + + afterAll(() => { + const configPath = path.join(DIRS.config, testConfigName); + if (fs.existsSync(configPath)) fs.unlinkSync(configPath); + }); + + it('POST /api/save-config — 应保存配置', async () => { + const config = { + settings: { theme: 'dark', fontSize: 18 }, + readingPrefs: { lineHeight: 1.8 }, + metadata: { version: '1.0.0' } + }; + + const res = await request + .post('/api/save-config') + .send({ config, filename: testConfigName }); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('filename'); + }); + + it('GET /api/config-list — 应返回配置列表', async () => { + const res = await request.get('/api/config-list'); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('configs'); + expect(Array.isArray(res.body.configs)).toBe(true); + }); + + it('GET /api/load-config/:filename — 应加载已保存的配置', async () => { + const res = await request.get(`/api/load-config/${testConfigName}`); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('config'); + expect(res.body.config).toHaveProperty('settings'); + expect(res.body.config.settings.theme).toBe('dark'); + }); + + it('GET /api/load-config/:filename — 应返回 404 对不存在的配置', async () => { + const res = await request.get('/api/load-config/nonexistent-config.json'); + expect(res.status).toBe(404); + }); + + it('DELETE /api/config/:filename — 应删除配置文件', async () => { + const tempName = 'vitest-temp-delete.json'; + await request + .post('/api/save-config') + .send({ config: { test: true }, filename: tempName }); + + const res = await request.delete(`/api/config/${tempName}`); + expect(res.status).toBe(200); + + const configPath = path.join(DIRS.config, tempName); + expect(fs.existsSync(configPath)).toBe(false); + }); +}); + +/* ========== 书籍删除功能测试 ========== */ + +describe('DELETE /api/book — 删除书籍', () => { + it('应成功删除存在的书籍', async () => { + const testPath = 'delete-test-book.txt'; + const fullPath = path.join(DIRS.books, testPath); + fs.writeFileSync(fullPath, 'test content for deletion'); + + // DELETE /api/book 使用 query 参数 path + const res = await request + .delete(`/api/book?path=${encodeURIComponent(testPath)}`); + expect(res.status).toBe(200); + expect(fs.existsSync(fullPath)).toBe(false); + }); + + it('应返回 404 对不存在的书籍', async () => { + const res = await request + .delete('/api/book?path=nonexistent-book-xyz.epub'); + expect(res.status).toBe(404); + }); +}); + +/* ========== 字体管理功能测试 ========== */ + +describe('GET /api/fonts — 字体列表', () => { + it('应返回字体数组', async () => { + const res = await request.get('/api/fonts'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); +}); + +/* ========== 封面功能测试 ========== */ + +describe('GET /api/book-cover — 书籍封面', () => { + it('应返回 404 对不存在的书籍', async () => { + const res = await request + .get('/api/book-cover?path=nonexistent-book-cover.epub'); + expect(res.status).toBe(404); + }); + + it('对 TXT 文件应返回 cover: null', async () => { + const testPath = 'cover-test.txt'; + const fullPath = path.join(DIRS.books, testPath); + fs.writeFileSync(fullPath, 'test content'); + + try { + const res = await request + .get(`/api/book-cover?path=${encodeURIComponent(testPath)}`); + expect(res.status).toBe(200); + expect(res.body.cover).toBeNull(); + } finally { + if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath); + } + }); +}); diff --git a/tests/server/utils.test.js b/tests/server/utils.test.js new file mode 100644 index 0000000..f28a216 --- /dev/null +++ b/tests/server/utils.test.js @@ -0,0 +1,143 @@ +// 服务端工具函数单元测试 +import { describe, it, expect, beforeAll } from 'vitest'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const path = require('path'); + +let utils, DIRS, ALLOWED_EXTENSIONS; + +beforeAll(() => { + const server = require('../../server.js'); + utils = server.utils; + DIRS = server.DIRS; + ALLOWED_EXTENSIONS = server.ALLOWED_EXTENSIONS; +}); + +/* ========== normalizePath ========== */ + +describe('utils.normalizePath', () => { + it('应将反斜杠转为正斜杠', () => { + expect(utils.normalizePath('books\\test\\file.epub')).toBe('books/test/file.epub'); + }); + + it('应保留正斜杠不变', () => { + expect(utils.normalizePath('books/test/file.epub')).toBe('books/test/file.epub'); + }); + + it('处理空字符串', () => { + expect(utils.normalizePath('')).toBe(''); + }); + + it('处理 undefined', () => { + expect(utils.normalizePath()).toBe(''); + }); +}); + +/* ========== resolveBookPath ========== */ + +describe('utils.resolveBookPath', () => { + it('应解析正常的相对路径', () => { + const result = utils.resolveBookPath('test.epub'); + expect(result).toContain('test.epub'); + expect(path.isAbsolute(result)).toBe(true); + }); + + it('应解析含子目录的路径', () => { + const result = utils.resolveBookPath('subfolder/test.epub'); + expect(result).toContain('subfolder'); + expect(result).toContain('test.epub'); + }); + + it('路径遍历攻击 (..) 应被安全化(解析到 books 目录内)', () => { + // 正则会剥离开头的 ../,所以结果仍在 books 目录内 + const result = utils.resolveBookPath('../../etc/passwd'); + const booksRoot = path.resolve(DIRS.books); + expect(result.toLowerCase().startsWith(booksRoot.toLowerCase())).toBe(true); + }); + + it('路径遍历攻击 (..\\\\) 应被安全化', () => { + const result = utils.resolveBookPath('..\\..\\windows\\system32'); + const booksRoot = path.resolve(DIRS.books); + expect(result.toLowerCase().startsWith(booksRoot.toLowerCase())).toBe(true); + }); + + it('处理空字符串', () => { + const result = utils.resolveBookPath(''); + expect(path.isAbsolute(result)).toBe(true); + }); +}); + +/* ========== resolveConfigPath ========== */ + +describe('utils.resolveConfigPath', () => { + it('应解析 .json 配置文件', () => { + const result = utils.resolveConfigPath('user-config.json'); + expect(result).toContain('user-config.json'); + expect(path.isAbsolute(result)).toBe(true); + }); + + it('应拒绝非 .json 文件', () => { + expect(() => utils.resolveConfigPath('config.txt')).toThrow('Invalid config file type'); + }); + + it('应拒绝非 .json 扩展名', () => { + expect(() => utils.resolveConfigPath('evil.exe')).toThrow('Invalid config file type'); + }); + + it('路径遍历攻击应被安全化(解析到 config 目录内)', () => { + // 正则剥离 ../,结果在 config 目录内但扩展名不是 .json 时会报错 + expect(() => utils.resolveConfigPath('../../etc/passwd')).toThrow('Invalid config file type'); + }); + + it('即使路径遍历带 .json,也应安全化到 config 目录内', () => { + const result = utils.resolveConfigPath('../../etc/config.json'); + const configRoot = path.resolve(DIRS.config); + expect(result.toLowerCase().startsWith(configRoot.toLowerCase())).toBe(true); + }); +}); + +/* ========== decodeFilename ========== */ + +describe('utils.decodeFilename', () => { + it('应正确解码 ASCII 文件名', () => { + expect(utils.decodeFilename('test.epub')).toBe('test.epub'); + }); + + it('处理含空格的文件名', () => { + expect(utils.decodeFilename('my book.epub')).toBe('my book.epub'); + }); +}); + +/* ========== isAllowedExtension ========== */ + +describe('utils.isAllowedExtension', () => { + it('应接受 .epub 文件', () => { + expect(utils.isAllowedExtension('book.epub')).toBe(true); + }); + + it('应接受 .txt 文件', () => { + expect(utils.isAllowedExtension('book.txt')).toBe(true); + }); + + it('应接受 .pdf 文件', () => { + expect(utils.isAllowedExtension('book.pdf')).toBe(true); + }); + + it('应拒绝 .exe 文件', () => { + expect(utils.isAllowedExtension('virus.exe')).toBe(false); + }); + + it('应拒绝 .js 文件', () => { + expect(utils.isAllowedExtension('script.js')).toBe(false); + }); + + it('应拒绝 .html 文件', () => { + expect(utils.isAllowedExtension('page.html')).toBe(false); + }); + + it('应不区分大小写', () => { + expect(utils.isAllowedExtension('book.EPUB')).toBe(true); + expect(utils.isAllowedExtension('book.Pdf')).toBe(true); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..1622419 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // 全局超时 + testTimeout: 10000, + // 测试文件匹配模式 + include: ['tests/**/*.test.{js,mjs}'], + // 环境配置 + environmentMatchGlobs: [ + // 前端测试使用 jsdom 环境 + ['tests/frontend/**', 'jsdom'], + // 服务端测试使用 node 环境 + ['tests/server/**', 'node'] + ] + } +}); From 2ce1fcce428dbf736d1cdb9f0827c60314e6167c Mon Sep 17 00:00:00 2001 From: WangXuan Date: Sat, 14 Feb 2026 21:15:09 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Vitest=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=A1=86=E6=9E=B6=E5=8F=8A=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E7=9A=84=E5=8D=95=E5=85=83=E5=92=8C=E9=9B=86=E6=88=90=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/README.md b/README.md index 8ee015e..bcfe9f5 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,105 @@ local_read/ - **ePub.js**: 用于解析和渲染 EPUB 文件 - **PDF.js**: 用于解析和渲染 PDF 文件 - **JSZip**: 用于处理压缩文件 +- **测试框架**: Vitest, Supertest + +## 🧪 测试用例 + +项目包含完整的单元测试和集成测试,使用 Vitest 作为测试框架。 + +### 📋 测试结构 + +``` +tests/ +├── frontend/ # 前端单元测试 +│ ├── config.test.js # 配置模块测试 +│ └── utils.test.js # 工具函数测试 +└── server/ # 服务端测试 + ├── api.test.js # API 集成测试 + └── utils.test.js # 服务端工具函数测试 +``` + +### 🔍 测试覆盖范围 + +#### **前端测试** (`tests/frontend/`) + +**配置模块测试** (`config.test.js`) +- ✅ `getFileKey` - 文件路径键生成函数 + - 路径前缀添加测试 + - 空字符串处理测试 + - Windows 路径处理测试 + - 中文路径处理测试 +- ✅ `CONFIG` - 配置常量验证 + - 字体大小限制常量 + - API 路径定义 + - 存储键定义 + - 主题定义 + - 侧边栏视图定义 + +**工具函数测试** (`utils.test.js`) +- ✅ `DEFAULT_READING_PREFS` - 默认阅读偏好设置 +- ✅ `clamp` - 数值范围限制函数(11个测试用例) +- ✅ `normalizePrefs` - 阅读偏好标准化(5个测试用例) +- ✅ `computeVerticalPadding` - 垂直边距计算(3个测试用例) +- ✅ `formatFileSize` - 文件大小格式化(4个测试用例) +- ✅ `formatDecimal` - 小数格式化(4个测试用例) +- ✅ `formatTimeAgo` - 时间相对显示(6个测试用例) +- ✅ `getFileExtension` - 文件扩展名提取(3个测试用例) +- ✅ `deriveBookNameFromPath` - 从路径提取书名(5个测试用例) +- ✅ `debounce` - 防抖函数(2个测试用例) +- ✅ `throttle` - 节流函数(3个测试用例) + +#### **服务端测试** (`tests/server/`) + +**API 集成测试** (`api.test.js`) +- ✅ **书籍管理功能** + - `GET /api/bookshelf` - 获取书籍列表 + - `POST /api/upload` - 上传书籍(支持的格式、格式验证) + - `GET /api/book` - 读取书籍内容(成功读取、404处理) + - `DELETE /api/book` - 删除书籍(成功删除、404处理) + - `GET /api/book-cover` - 获取书籍封面(不同格式处理) +- ✅ **配置管理功能** + - `POST /api/save-config` - 保存配置 + - `GET /api/config-list` - 获取配置列表 + - `GET /api/load-config/:filename` - 加载配置(成功加载、404处理) + - `DELETE /api/config/:filename` - 删除配置 +- ✅ **字体管理功能** + - `GET /api/fonts` - 获取字体列表 + +**服务端工具函数测试** (`utils.test.js`) +- ✅ `normalizePath` - 路径标准化(4个测试用例) +- ✅ `resolveBookPath` - 书籍路径解析(含安全性测试,5个测试用例) +- ✅ `resolveConfigPath` - 配置文件路径解析(含安全性测试,5个测试用例) +- ✅ `decodeFilename` - 文件名解码(2个测试用例) +- ✅ `isAllowedExtension` - 文件扩展名验证(7个测试用例) + +### 🚀 运行测试 + +```bash +# 运行所有测试 +npm test + +# 运行测试并显示覆盖率 +npm run test:coverage + +# 监听模式运行测试(开发时使用) +npm run test:watch + +# 仅运行前端测试 +npx vitest tests/frontend + +# 仅运行服务端测试 +npx vitest tests/server +``` + +### 📊 测试统计 + +- **总测试用例数**: 70+ 个 +- **前端测试**: 46+ 个用例 +- **服务端测试**: 24+ 个用例 +- **测试环境**: + - 前端测试使用 JSDOM 环境 + - 服务端测试使用 Node 环境 ## 🤝 贡献 From 2c17592c6b2d95d32d9d550adc5090fb7f133669 Mon Sep 17 00:00:00 2001 From: WangXuan Date: Sat, 21 Mar 2026 21:44:31 +0800 Subject: [PATCH 4/6] Add Electron desktop reader and CI test coverage --- .github/workflows/ci.yml | 30 ++ _test_electron.js | 11 + electron-main.js | 621 +++-------------------------------- index.html | 36 ++ package.json | 4 +- preload.js | 17 +- reader.html | 44 ++- scripts/launch-electron.js | 13 + server.js | 617 +--------------------------------- shared/server-core.js | 616 ++++++++++++++++++++++++++++++++++ src/css/titlebar.css | 147 +++++++++ src/js/modules/pdfReader.js | 3 +- src/vendor/epub.min.js | 1 + src/vendor/jszip.min.js | 13 + src/vendor/pdf.min.js | 22 ++ src/vendor/pdf.worker.min.js | 22 ++ 16 files changed, 1012 insertions(+), 1205 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 _test_electron.js create mode 100644 scripts/launch-electron.js create mode 100644 shared/server-core.js create mode 100644 src/css/titlebar.css create mode 100644 src/vendor/epub.min.js create mode 100644 src/vendor/jszip.min.js create mode 100644 src/vendor/pdf.min.js create mode 100644 src/vendor/pdf.worker.min.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3821707 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test diff --git a/_test_electron.js b/_test_electron.js new file mode 100644 index 0000000..077451e --- /dev/null +++ b/_test_electron.js @@ -0,0 +1,11 @@ +// 最小化测试脚本 +console.log('process.type:', process.type); +console.log('process.versions.electron:', process.versions.electron); +const electron = require('electron'); +console.log('typeof electron:', typeof electron); +if (typeof electron === 'string') { + console.log('electron path:', electron); +} else { + console.log('keys:', Object.keys(electron)); +} +process.exit(0); diff --git a/electron-main.js b/electron-main.js index 6f73921..bb51a1d 100644 --- a/electron-main.js +++ b/electron-main.js @@ -24,19 +24,16 @@ const getRuntimeDirs = () => { }; // ==================== 性能优化:启动加速 ==================== -// 禁用不必要的 Chromium 特性以加快启动速度 app.commandLine.appendSwitch('disable-gpu-sandbox'); app.commandLine.appendSwitch('disable-software-rasterizer'); app.commandLine.appendSwitch('disable-background-timer-throttling'); app.commandLine.appendSwitch('disable-renderer-backgrounding'); app.commandLine.appendSwitch('disable-backgrounding-occluded-windows'); -// 高 DPI 支持 app.commandLine.appendSwitch('high-dpi-support', '1'); app.commandLine.appendSwitch('force-color-profile', 'srgb'); -// 导入服务器模块 let server = null; -const PORT = 31337; // 使用非常见端口避免冲突 +const PORT = 31337; // 主窗口引用 let mainWindow = null; @@ -53,14 +50,13 @@ function createWindow() { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, 'preload.js'), - // 性能优化选项 backgroundThrottling: false, spellcheck: false, enableWebSQL: false }, show: false, backgroundColor: '#1a1a2e', - titleBarStyle: 'default', + frame: false, autoHideMenuBar: true }); @@ -72,604 +68,43 @@ function createWindow() { // 加载应用 mainWindow.loadURL(`http://localhost:${PORT}`); - // 开发环境下可按 F12 打开开发者工具(不再自动打开) - // if (process.argv.includes('--dev')) { - // mainWindow.webContents.openDevTools(); - // } - // 处理外部链接 mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); + // 窗口最大化/还原事件 -> 通知渲染进程更新标题栏按钮图标 + mainWindow.on('maximize', () => { + mainWindow.webContents.send('maximize-change', true); + }); + mainWindow.on('unmaximize', () => { + mainWindow.webContents.send('maximize-change', false); + }); + // 窗口关闭事件 mainWindow.on('closed', () => { mainWindow = null; }); } -// 启动内嵌服务器 +// 启动内嵌服务器(复用共享核心模块) function startServer() { return new Promise((resolve, reject) => { try { - // 延迟加载模块以加速启动 const express = require('express'); - const multer = require('multer'); - const fs = require('fs'); - const AdmZip = require('adm-zip'); - - const expressApp = express(); + const { createApp } = require('./shared/server-core'); - // 目录配置 - 绿色版:始终使用程序所在目录 const DIRS = getRuntimeDirs(); + const { app: expressApp } = createApp(DIRS); - // 支持的文件扩展名 - const ALLOWED_EXTENSIONS = ['.epub', '.txt', '.pdf']; - const ALLOWED_FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2']; - - // MIME类型映射 - const FONT_MIME_TYPES = { - '.ttf': 'font/ttf', - '.otf': 'font/otf', - '.woff': 'font/woff', - '.woff2': 'font/woff2' - }; - - const IMAGE_MIME_TYPES = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp' - }; - - // 封面缓存(限制大小,避免大书库占用过多内存) - const coverCache = new Map(); - const COVER_CACHE_LIMIT = 200; - - const setCoverCache = (key, mtime, data) => { - coverCache.set(key, { mtime, data }); - if (coverCache.size > COVER_CACHE_LIMIT) { - const oldestKey = coverCache.keys().next().value; - if (oldestKey) coverCache.delete(oldestKey); - } - }; - - // 确保目录存在 - Object.values(DIRS).forEach(dir => { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - }); - - // 中间件配置 - expressApp.use(express.json({ limit: '10mb' })); // 静态文件服务 - 支持打包后的路径 expressApp.use(express.static(__dirname)); // 书籍/配置目录可能在可执行文件目录之外,统一显式挂载 expressApp.use('/books', express.static(DIRS.books)); expressApp.use('/user-data', express.static(DIRS.config)); - // 工具函数 - const utils = { - normalizePath: (p = '') => p.split(path.sep).join('/'), - resolveBookPath: (relativePath = '') => { - const normalized = path.normalize(relativePath).replace(/^([\.\\/])+/, ''); - const resolved = path.resolve(DIRS.books, normalized); - const booksRoot = path.resolve(DIRS.books); - if (!resolved.toLowerCase().startsWith(booksRoot.toLowerCase())) { - throw new Error('Invalid book path'); - } - return resolved; - }, - resolveConfigPath: (filename = '') => { - const normalized = path.normalize(filename).replace(/^([\.\\/])+/, ''); - const resolved = path.resolve(DIRS.config, normalized); - const configRoot = path.resolve(DIRS.config); - if (!resolved.toLowerCase().startsWith(configRoot.toLowerCase())) { - throw new Error('Invalid config path'); - } - if (path.extname(resolved).toLowerCase() !== '.json') { - throw new Error('Invalid config file type'); - } - return resolved; - }, - cleanupEmptyFolders: (startPath) => { - let current = path.dirname(startPath); - const booksRoot = path.resolve(DIRS.books); - while (current.toLowerCase().startsWith(booksRoot.toLowerCase()) && current !== booksRoot) { - try { - if (fs.readdirSync(current).length === 0) { - fs.rmdirSync(current); - current = path.dirname(current); - } else { - break; - } - } catch { - break; - } - } - }, - decodeFilename: (filename) => Buffer.from(filename, 'latin1').toString('utf8'), - isAllowedExtension: (filename) => { - const ext = path.extname(filename).toLowerCase(); - return ALLOWED_EXTENSIONS.includes(ext); - } - }; - - // 文件上传配置 - const storage = multer.diskStorage({ - destination: (req, file, cb) => { - if (!fs.existsSync(DIRS.books)) { - fs.mkdirSync(DIRS.books, { recursive: true }); - } - cb(null, DIRS.books); - }, - filename: (req, file, cb) => { - const originalName = utils.decodeFilename(file.originalname); - let finalName = originalName; - let counter = 1; - while (fs.existsSync(path.join(DIRS.books, finalName))) { - const ext = path.extname(originalName); - const nameWithoutExt = path.basename(originalName, ext); - finalName = `${nameWithoutExt}(${counter})${ext}`; - counter++; - } - cb(null, finalName); - } - }); - - const upload = multer({ - storage, - fileFilter: (req, file, cb) => { - const originalName = utils.decodeFilename(file.originalname); - if (utils.isAllowedExtension(originalName)) { - cb(null, true); - } else { - cb(new Error('只支持 .epub, .txt, .pdf 文件格式')); - } - } - }); - - // 字体上传配置 - const fontStorage = multer.diskStorage({ - destination: (req, file, cb) => { - if (!fs.existsSync(DIRS.fonts)) { - fs.mkdirSync(DIRS.fonts, { recursive: true }); - } - cb(null, DIRS.fonts); - }, - filename: (req, file, cb) => { - const originalName = utils.decodeFilename(file.originalname); - let finalName = originalName; - let counter = 1; - while (fs.existsSync(path.join(DIRS.fonts, finalName))) { - const ext = path.extname(originalName); - const nameWithoutExt = path.basename(originalName, ext); - finalName = `${nameWithoutExt}(${counter})${ext}`; - counter++; - } - cb(null, finalName); - } - }); - - const fontUpload = multer({ - storage: fontStorage, - fileFilter: (req, file, cb) => { - const originalName = utils.decodeFilename(file.originalname); - const ext = path.extname(originalName).toLowerCase(); - if (ALLOWED_FONT_EXTENSIONS.includes(ext)) { - cb(null, true); - } else { - cb(new Error('只支持 .ttf, .otf, .woff, .woff2 字体格式')); - } - }, - limits: { fileSize: 20 * 1024 * 1024 } - }); - - // EPUB封面提取 - const extractEpubCover = (absolutePath) => { - try { - const stats = fs.statSync(absolutePath); - const cacheKey = absolutePath; - const cached = coverCache.get(cacheKey); - if (cached && cached.mtime === stats.mtimeMs) { - return cached.data; - } - - const zip = new AdmZip(absolutePath); - const entries = zip.getEntries(); - if (!entries || entries.length === 0) return null; - - const imageEntries = entries.filter(entry => { - if (entry.isDirectory) return false; - const ext = path.extname(entry.entryName).toLowerCase(); - return Object.prototype.hasOwnProperty.call(IMAGE_MIME_TYPES, ext); - }); - - if (imageEntries.length === 0) { - setCoverCache(cacheKey, stats.mtimeMs, null); - return null; - } - - let coverEntry = imageEntries.find(entry => /cover/i.test(path.basename(entry.entryName))); - if (!coverEntry) coverEntry = imageEntries[0]; - if (!coverEntry) { - setCoverCache(cacheKey, stats.mtimeMs, null); - return null; - } - - const data = coverEntry.getData(); - if (!data) { - setCoverCache(cacheKey, stats.mtimeMs, null); - return null; - } - - const ext = path.extname(coverEntry.entryName).toLowerCase(); - const mime = IMAGE_MIME_TYPES[ext] || 'image/jpeg'; - const base64 = data.toString('base64'); - const dataUrl = `data:${mime};base64,${base64}`; - - setCoverCache(cacheKey, stats.mtimeMs, dataUrl); - return dataUrl; - } catch (error) { - console.warn('Failed to extract EPUB cover:', error.message); - return null; - } - }; - - // 递归查找书籍 - const findBooks = (dir, fileList = [], parentDir = '') => { - const files = fs.readdirSync(dir); - files.forEach(file => { - const filePath = path.join(dir, file); - const fileStat = fs.statSync(filePath); - const relativePath = path.join(parentDir, file); - - if (fileStat.isDirectory()) { - findBooks(filePath, fileList, relativePath); - } else if (utils.isAllowedExtension(file)) { - const ext = path.extname(file).toLowerCase(); - fileList.push({ - name: file, - path: utils.normalizePath(relativePath), - extension: ext, - size: fileStat.size, - addedAt: fileStat.birthtimeMs || fileStat.ctimeMs, - modifiedAt: fileStat.mtimeMs, - coverAvailable: ext === '.epub' - }); - } - }); - return fileList; - }; - - // ==================== API 路由 ==================== - - // 获取书架列表 - expressApp.get('/api/bookshelf', (req, res) => { - try { - if (!fs.existsSync(DIRS.books)) { - fs.mkdirSync(DIRS.books); - } - const books = findBooks(DIRS.books); - res.json(books); - } catch (error) { - console.error('Error reading bookshelf:', error); - res.status(500).json({ error: 'Failed to read bookshelf directory.' }); - } - }); - - // 获取书籍封面 - expressApp.get('/api/book-cover', (req, res) => { - try { - const relPath = req.query.path; - if (!relPath) { - return res.status(400).json({ error: '缺少书籍路径' }); - } - const absolutePath = utils.resolveBookPath(relPath); - if (!fs.existsSync(absolutePath)) { - return res.status(404).json({ error: '书籍不存在' }); - } - const ext = path.extname(absolutePath).toLowerCase(); - if (ext !== '.epub') { - return res.json({ success: true, cover: null }); - } - const cover = extractEpubCover(absolutePath); - res.json({ success: true, cover }); - } catch (error) { - console.error('Error extracting cover:', error); - res.status(500).json({ error: '封面提取失败: ' + error.message }); - } - }); - - // 保存用户配置 - expressApp.post('/api/save-config', (req, res) => { - try { - const { config, filename } = req.body; - if (!config) { - return res.status(400).json({ error: '配置数据不能为空' }); - } - const configFilename = filename || `reader-config-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`; - let configPath; - try { - configPath = utils.resolveConfigPath(configFilename); - } catch { - return res.status(400).json({ error: '无效的配置文件名' }); - } - const configWithMeta = { - ...config, - metadata: { - ...config.metadata, - savedAt: new Date().toISOString(), - version: '1.0.0', - appName: 'Local E-Book Reader' - } - }; - if (!fs.existsSync(DIRS.config)) { - fs.mkdirSync(DIRS.config, { recursive: true }); - } - fs.writeFileSync(configPath, JSON.stringify(configWithMeta, null, 2)); - res.json({ success: true, message: '配置保存成功', filename: configFilename, path: configPath }); - } catch (error) { - console.error('Error saving config:', error); - res.status(500).json({ error: '保存配置失败: ' + error.message }); - } - }); - - // 加载用户配置 - expressApp.get('/api/load-config/:filename', (req, res) => { - try { - let configPath; - try { - configPath = utils.resolveConfigPath(req.params.filename); - } catch { - return res.status(400).json({ error: '无效的配置文件' }); - } - if (!fs.existsSync(configPath)) { - return res.status(404).json({ error: '配置文件不存在' }); - } - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - res.json({ success: true, config, filename: req.params.filename }); - } catch (error) { - console.error('Error loading config:', error); - res.status(500).json({ error: '加载配置失败: ' + error.message }); - } - }); - - // 获取配置文件列表 - expressApp.get('/api/config-list', (req, res) => { - try { - if (!fs.existsSync(DIRS.config)) { - return res.json({ success: true, configs: [] }); - } - const files = fs.readdirSync(DIRS.config) - .filter(file => file.endsWith('.json')) - .map(file => { - const filePath = path.join(DIRS.config, file); - const stats = fs.statSync(filePath); - let metadata = null; - try { - metadata = JSON.parse(fs.readFileSync(filePath, 'utf8')).metadata; - } catch { /* ignore */ } - return { - filename: file, - size: stats.size, - createdAt: stats.birthtime.toISOString(), - modifiedAt: stats.mtime.toISOString(), - metadata - }; - }) - .sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt)); - res.json({ success: true, configs: files }); - } catch (error) { - console.error('Error listing configs:', error); - res.status(500).json({ error: '获取配置列表失败: ' + error.message }); - } - }); - - // 删除配置文件 - expressApp.delete('/api/config/:filename', (req, res) => { - try { - let configPath; - try { - configPath = utils.resolveConfigPath(req.params.filename); - } catch { - return res.status(400).json({ error: '无效的配置文件' }); - } - if (!fs.existsSync(configPath)) { - return res.status(404).json({ error: '配置文件不存在' }); - } - fs.unlinkSync(configPath); - res.json({ success: true, message: '配置文件删除成功' }); - } catch (error) { - console.error('Error deleting config:', error); - res.status(500).json({ error: '删除配置失败: ' + error.message }); - } - }); - - // 下载配置文件 - expressApp.get('/api/download-config/:filename', (req, res) => { - try { - let configPath; - try { - configPath = utils.resolveConfigPath(req.params.filename); - } catch { - return res.status(400).json({ error: '无效的配置文件' }); - } - if (!fs.existsSync(configPath)) { - return res.status(404).json({ error: '配置文件不存在' }); - } - res.download(configPath, req.params.filename); - } catch (error) { - console.error('Error downloading config:', error); - res.status(500).json({ error: '下载配置失败: ' + error.message }); - } - }); - - // 上传书籍文件 - expressApp.post('/api/upload', upload.array('books'), (req, res) => { - try { - if (!req.files || req.files.length === 0) { - return res.status(400).json({ error: '没有选择文件' }); - } - const uploadedFiles = req.files.map(file => ({ - originalName: utils.decodeFilename(file.originalname), - savedName: file.filename, - size: file.size - })); - res.json({ - success: true, - message: `成功上传 ${uploadedFiles.length} 个文件`, - files: uploadedFiles - }); - } catch (error) { - console.error('Error uploading files:', error); - res.status(500).json({ error: '文件上传失败: ' + error.message }); - } - }); - - // 获取书籍内容 - expressApp.get('/api/book', (req, res) => { - const bookPath = req.query.path; - if (!bookPath) { - return res.status(400).send('Book path is required.'); - } - try { - const safePath = utils.resolveBookPath(bookPath); - if (fs.existsSync(safePath)) { - res.sendFile(safePath); - } else { - res.status(404).send('Book not found.'); - } - } catch (error) { - return res.status(403).send('Forbidden.'); - } - }); - - // 删除书籍 - expressApp.delete('/api/book', (req, res) => { - try { - const relPath = req.query.path; - if (!relPath) { - return res.status(400).json({ error: '缺少书籍路径' }); - } - const absolutePath = utils.resolveBookPath(relPath); - if (!fs.existsSync(absolutePath)) { - return res.status(404).json({ error: '书籍不存在' }); - } - fs.unlinkSync(absolutePath); - utils.cleanupEmptyFolders(absolutePath); - coverCache.delete(absolutePath); - res.json({ success: true }); - } catch (error) { - console.error('Error deleting book:', error); - res.status(500).json({ error: '删除书籍失败: ' + error.message }); - } - }); - - // ==================== 字体管理 API ==================== - - // 获取字体列表 - expressApp.get('/api/fonts', (req, res) => { - try { - if (!fs.existsSync(DIRS.fonts)) { - return res.json([]); - } - const files = fs.readdirSync(DIRS.fonts); - const fonts = files - .filter(file => ALLOWED_FONT_EXTENSIONS.includes(path.extname(file).toLowerCase())) - .map(file => { - const ext = path.extname(file); - const nameWithoutExt = path.basename(file, ext); - const stats = fs.statSync(path.join(DIRS.fonts, file)); - return { - id: file, - name: nameWithoutExt, - fontFamily: `CustomFont_${nameWithoutExt.replace(/[^a-zA-Z0-9]/g, '_')}`, - filename: file, - size: stats.size, - addedAt: stats.birthtimeMs || stats.ctimeMs - }; - }); - res.json(fonts); - } catch (error) { - console.error('Error getting fonts:', error); - res.status(500).json({ error: '获取字体列表失败' }); - } - }); - - // 上传字体 - expressApp.post('/api/fonts/upload', fontUpload.single('font'), (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ error: '没有上传文件' }); - } - const ext = path.extname(req.file.filename); - const nameWithoutExt = path.basename(req.file.filename, ext); - res.json({ - success: true, - font: { - id: req.file.filename, - name: nameWithoutExt, - fontFamily: `CustomFont_${nameWithoutExt.replace(/[^a-zA-Z0-9]/g, '_')}`, - filename: req.file.filename, - size: req.file.size - } - }); - } catch (error) { - console.error('Error uploading font:', error); - res.status(500).json({ error: '上传字体失败: ' + error.message }); - } - }); - - // 获取字体文件 - expressApp.get('/api/fonts/file/:fontId', (req, res) => { - try { - const fontId = req.params.fontId; - const fontPath = path.join(DIRS.fonts, fontId); - const resolved = path.resolve(fontPath); - if (!resolved.startsWith(path.resolve(DIRS.fonts))) { - return res.status(403).json({ error: '无效的字体路径' }); - } - if (!fs.existsSync(fontPath)) { - return res.status(404).json({ error: '字体不存在' }); - } - const ext = path.extname(fontId).toLowerCase(); - const mimeType = FONT_MIME_TYPES[ext] || 'application/octet-stream'; - res.set({ - 'Content-Type': mimeType, - 'Cache-Control': 'public, max-age=31536000' - }); - res.sendFile(fontPath); - } catch (error) { - console.error('Error serving font:', error); - res.status(500).json({ error: '获取字体失败' }); - } - }); - - // 删除字体 - expressApp.delete('/api/fonts/:fontId', (req, res) => { - try { - const fontId = req.params.fontId; - const fontPath = path.join(DIRS.fonts, fontId); - const resolved = path.resolve(fontPath); - if (!resolved.startsWith(path.resolve(DIRS.fonts))) { - return res.status(403).json({ error: '无效的字体路径' }); - } - if (!fs.existsSync(fontPath)) { - return res.status(404).json({ error: '字体不存在' }); - } - fs.unlinkSync(fontPath); - res.json({ success: true }); - } catch (error) { - console.error('Error deleting font:', error); - res.status(500).json({ error: '删除字体失败: ' + error.message }); - } - }); - - // 启动服务器 + // 启动服务器(仅绑定本地回环地址) server = expressApp.listen(PORT, '127.0.0.1', () => { console.log(`Electron embedded server running at http://localhost:${PORT}`); resolve(); @@ -694,9 +129,7 @@ function startServer() { // 应用准备完成 app.whenReady().then(async () => { try { - // 先启动服务器 await startServer(); - // 再创建窗口 createWindow(); } catch (error) { console.error('Failed to start server:', error); @@ -736,7 +169,8 @@ app.on('quit', () => { } }); -// IPC 通信处理 +// ==================== IPC 通信处理 ==================== + ipcMain.handle('get-app-version', () => { return app.getVersion(); }); @@ -753,3 +187,26 @@ ipcMain.handle('open-books-folder', () => { ipcMain.handle('open-external-link', (event, url) => { shell.openExternal(url); }); + +// 窗口控制 IPC(自定义标题栏) +ipcMain.handle('window-minimize', () => { + if (mainWindow) mainWindow.minimize(); +}); + +ipcMain.handle('window-maximize-toggle', () => { + if (!mainWindow) return false; + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + return mainWindow.isMaximized(); +}); + +ipcMain.handle('window-close', () => { + if (mainWindow) mainWindow.close(); +}); + +ipcMain.handle('window-is-maximized', () => { + return mainWindow ? mainWindow.isMaximized() : false; +}); diff --git a/index.html b/index.html index 7b28672..cce970d 100644 --- a/index.html +++ b/index.html @@ -14,9 +14,45 @@ + + + +