From 14a965f9c19ec49ed88f4d422bb1d6dc1e385e42 Mon Sep 17 00:00:00 2001 From: Cell <1024@lruihao.cn> Date: Thu, 28 May 2026 12:43:42 +0800 Subject: [PATCH] refactor(assets): migrate JavaScript to TypeScript service architecture (#766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: add TypeScript support and refactor utility functions * docs: add CLAUDE.md for Claude Code guidance Co-Authored-By: Claude Opus 4.7 * refactor(assets): replace factory-function pattern with service classes and DI container Introduce typed service container, event bus, and service interfaces to replace the shared FixItContext factory-function pattern. Each module is now a standalone class implementing a typed interface, with explicit constructor-injected dependencies. The window.fixit backward-compatibility facade is built via a publicAPI() helper that extracts class methods. Also removes unnecessary setTimeout/afterPaint deferred execution wrappers from init sequences, and improves module header comments. Co-Authored-By: Claude Opus 4.7 * refactor: replace private properties with class fields in modules Co-Authored-By: Claude Opus 4.7 * refactor(assets): rewrite link page script in TypeScript Rename pages/link.js to link.ts with proper type annotations and update the Hugo template reference accordingly. Co-Authored-By: Claude Opus 4.7 * docs: rewrite CLAUDE.md and copilot-instructions.md in English Update both files to reflect the new service-class architecture with DI container, typed event bus, and ES6 # private fields. Co-Authored-By: Claude Opus 4.7 * fix: update event handling to use CustomEvent for better type safety * refactor: enhance theme switching by adding isChanged flag to event details * refactor(assets): refactor Mermaid module structure and event wiring * refactor(assets): align lib integrations with TypedEventBus and add module overviews * chore(assets): remove vite-specific import ignore comments * refactor(assets): remove deprecated forEach utility and use native forEach Replace custom forEach wrapper with native .forEach() on NodeList, HTMLCollection (via querySelectorAll), and arrays. The utility was already marked @deprecated and all 30+ call sites use synchronous handlers, making the wrapper unnecessary. Also replace getElementsByClassName/getElementsByTagName with querySelectorAll for consistency. - Delete utils/array.ts (no longer needed) - Remove forEach export from utils/index.ts - Refactor 11 module files to use native .forEach() - Switch getElementsByClassName to querySelectorAll in theme.ts and charts.ts * refactor(assets): split ChartsModule into independent lib files Move ECharts, Mapbox GL, and TypeIt integrations from the ServiceContainer-managed ChartsModule into three standalone lib files (echarts.ts, mapbox.ts, typeit.ts). Each lib: - Instantiates its own TypedEventBus - Reads config from window.config.* - Self-initializes on DOMContentLoaded - Handles decrypt/re-init events independently - Uses implicit idempotency via DOM element queries Key improvements: - Reduces ServiceContainer complexity - Makes chart libs independently loadable - Fixes partial decryption handling in echarts (only re-inits decrypted portion) - Maintains consistency with aplayer/file-tree lib pattern * refactor(assets): extract third-party libraries to standalone lib files Extract twemoji, pangu, cookieconsent, watermark, lightgallery, and json-viewer from modules into separate lib files for better separation of concerns. Each lib is loaded conditionally via assets.html and handles its own initialization and event lifecycle independently. Additionally, unify fixit event handling by replacing document.addEventListener calls with TypedEventBus.on() in EncryptionModule and MiscModule for consistent event architecture. Remove unused MiscService dependency from ContentModule. * refactor(assets): unify js.Build pipeline and defaults - extract shared helper partial function/js-build.html for js.Build + minify - make plugin script renderer and Mermaid use the same js.Build logic - default Build.minify to hugo.IsProduction when unset - default Build.sourceMap to cond hugo.IsDevelopment "linked" "none" - remove duplicated sourceMap branching in layout templates - update js targetPath naming to .js (no explicit .min.js suffix) * refactor(assets): extract comment systems to independent lib files Extract comment system initialization logic from CommentModule into 7 independent lib files (artalk, gitalk, valine, waline, utterances, twikoo, giscus). Each lib: - Conditionally loads based on window.config.comment configuration - Handles backend-specific initialization via DOMContentLoaded - Manages its own theme synchronization and event listeners - Is loaded conditionally in assets.html only when enabled Simplify CommentModule to handle only: - Comment section UI display and visibility toggle - Shared lightGallery initialization utility (used by Artalk and Twikoo) - Comment expiration handling This reduces module complexity and enables per-backend lazy loading. * refactor(assets): unify js.Build pipeline and consolidate PWA config Enhance js-build.html to accept Build as bool/dict, auto-derive targetPath from resource name (.ts → .js), and skip js.Build when Build is false/omit. Simplify assets.html by removing explicit targetPath dicts for all .ts lib builds. Consolidate enablePWA and serviceWorkerURL into a PWA config object. * refactor(assets): decouple toc from content module and add JSDoc headers - Remove TocService dependency from ContentModule, toc now self-manages - Rename initContent() → setup(), init() → setup() for toc - Make initFootnotes private (#initFootnotes) - Add multi-line JSDoc headers with Responsibilities to all modules, main.ts, pages/link.ts, and head/color-scheme.ts * refactor(assets): split types.ts into types/ directory module * refactor(assets): simplify JS module architecture and expose typed window.fixit API - Remove DI container (core/container.ts), use direct constructor calls - Export shared eventBus singleton from core/event-bus.ts, remove 16 redundant instances - Merge thin modules: SvgModule/CommentModule/LinkGuardModule into ContentModule/MiscModule - Trim service interfaces to externally-used methods only - Replace publicAPI() reflection with minimal typed window.fixit facade - Expose public APIs: setThemeMode, eventBus, initContent, mask overlay, scroll state - Extract setThemeMode from initSwitchTheme closure as public method - Rename fixit:reset to fixit:re-encrypt, remove dead events - Replace custom.js.example with custom.ts.example - Update CLAUDE.md and copilot-instructions.md * style(i18n): use semantic names for comment dividers instead of file paths Replace file-path-based comment dividers (e.g. partials/base/header.html) with semantic names (e.g. Header) across all 16 i18n files for consistency. * chore(workflow): update cron schedule for library updates --------- Co-authored-by: Claude Opus 4.7 --- .github/copilot-instructions.md | 327 +-- .github/workflows/librarybot.yml | 5 +- .gitignore | 3 + CLAUDE.md | 141 ++ apps/test/content/posts/aplayer-test/index.md | 6 + .../content/posts/encryption-test/index.md | 12 +- apps/test/content/posts/link-test.md | 2 +- apps/test/content/posts/mathjax-test.md | 6 + apps/test/hugo.toml | 12 + assets/js/core/event-bus.ts | 42 + assets/js/core/tokens.ts | 81 + assets/js/custom.js.example | 35 - assets/js/custom.ts.example | 31 + assets/js/fixit-decryptor.js | 260 --- assets/js/head/color-scheme.js | 16 - assets/js/head/color-scheme.ts | 21 + assets/js/lib/aplayer.js | 13 - assets/js/lib/aplayer.ts | 29 + assets/js/lib/artalk.ts | 45 + assets/js/lib/cookieconsent.ts | 11 + assets/js/lib/echarts.ts | 89 + assets/js/lib/file-tree.js | 60 - assets/js/lib/file-tree.ts | 103 + assets/js/lib/fixit-decryptor.ts | 207 ++ assets/js/lib/giscus.ts | 39 + assets/js/lib/gitalk.ts | 17 + assets/js/lib/json-viewer.ts | 28 + assets/js/lib/lightgallery.ts | 41 + assets/js/lib/mapbox.ts | 88 + assets/js/lib/mathjax.js | 44 - assets/js/lib/mathjax.ts | 77 + assets/js/lib/mermaid.js | 698 ------ assets/js/lib/mermaid.ts | 904 +++++++ assets/js/lib/pagefind-search.js | 142 -- assets/js/lib/pangu.ts | 39 + assets/js/lib/twemoji.ts | 23 + assets/js/lib/twikoo.ts | 39 + assets/js/lib/typeit.ts | 71 + assets/js/lib/utterances.ts | 41 + assets/js/lib/valine.ts | 13 + assets/js/lib/waline.ts | 35 + assets/js/lib/watermark.ts | 11 + assets/js/main.ts | 85 + assets/js/modules/code.ts | 418 ++++ assets/js/modules/content.ts | 195 ++ assets/js/modules/core.ts | 93 + assets/js/modules/encryption.ts | 45 + assets/js/modules/events.ts | 172 ++ assets/js/modules/menu.ts | 57 + assets/js/modules/misc.ts | 143 ++ assets/js/modules/pagefind.ts | 216 ++ assets/js/modules/search.ts | 319 +++ assets/js/modules/theme.ts | 79 + assets/js/modules/toc.ts | 281 +++ assets/js/pages/link.js | 44 - assets/js/pages/link.ts | 45 + assets/js/service-worker.js | 5 +- assets/js/theme.js | 2074 ----------------- assets/js/types/config.ts | 160 ++ assets/js/types/index.ts | 3 + assets/js/types/third-party.ts | 48 + assets/js/types/ui.ts | 79 + assets/js/utils/animation.ts | 15 + assets/js/utils/clipboard.ts | 22 + assets/js/utils/comment.ts | 29 + assets/js/utils/common.js | 185 -- assets/js/utils/dom.ts | 51 + assets/js/utils/file.ts | 16 + assets/js/utils/index.ts | 10 + assets/js/utils/media.ts | 15 + assets/js/utils/string.ts | 32 + assets/js/utils/theme.ts | 19 + assets/js/utils/tooltip.ts | 29 + assets/js/utils/validate.ts | 21 + assets/scss/pages/single/_base.scss | 11 +- assets/scss/pages/single/_code.scss | 3 +- .../pages/single/shortcodes/_mermaid.scss | 4 +- eslint.config.js | 5 +- i18n/de.toml | 68 +- i18n/en.toml | 68 +- i18n/es.toml | 68 +- i18n/fr.toml | 68 +- i18n/hi.toml | 68 +- i18n/it.toml | 68 +- i18n/ja.toml | 68 +- i18n/ko.toml | 68 +- i18n/pl.toml | 68 +- i18n/pt-BR.toml | 68 +- i18n/ro.toml | 68 +- i18n/ru.toml | 68 +- i18n/sr.toml | 68 +- i18n/vi.toml | 68 +- i18n/zh-CN.toml | 68 +- i18n/zh-TW.toml | 68 +- layouts/_partials/base/assets.html | 147 +- layouts/_partials/base/comment.html | 20 +- layouts/_partials/base/head/index.html | 7 +- layouts/_partials/function/js-build.html | 32 + .../_partials/init/detection-deprecated.html | 2 +- layouts/_partials/plugin/link.html | 3 + .../_partials/plugin/mermaid-bootstrap.html | 32 + layouts/_partials/plugin/script.html | 16 +- layouts/_shortcodes/aplayer.html | 2 +- layouts/home.link.html | 7 +- package.json | 3 +- tsconfig.json | 16 + 106 files changed, 5810 insertions(+), 4500 deletions(-) create mode 100644 CLAUDE.md create mode 100644 assets/js/core/event-bus.ts create mode 100644 assets/js/core/tokens.ts delete mode 100644 assets/js/custom.js.example create mode 100644 assets/js/custom.ts.example delete mode 100644 assets/js/fixit-decryptor.js delete mode 100644 assets/js/head/color-scheme.js create mode 100644 assets/js/head/color-scheme.ts delete mode 100644 assets/js/lib/aplayer.js create mode 100644 assets/js/lib/aplayer.ts create mode 100644 assets/js/lib/artalk.ts create mode 100644 assets/js/lib/cookieconsent.ts create mode 100644 assets/js/lib/echarts.ts delete mode 100644 assets/js/lib/file-tree.js create mode 100644 assets/js/lib/file-tree.ts create mode 100644 assets/js/lib/fixit-decryptor.ts create mode 100644 assets/js/lib/giscus.ts create mode 100644 assets/js/lib/gitalk.ts create mode 100644 assets/js/lib/json-viewer.ts create mode 100644 assets/js/lib/lightgallery.ts create mode 100644 assets/js/lib/mapbox.ts delete mode 100644 assets/js/lib/mathjax.js create mode 100644 assets/js/lib/mathjax.ts delete mode 100644 assets/js/lib/mermaid.js create mode 100644 assets/js/lib/mermaid.ts delete mode 100644 assets/js/lib/pagefind-search.js create mode 100644 assets/js/lib/pangu.ts create mode 100644 assets/js/lib/twemoji.ts create mode 100644 assets/js/lib/twikoo.ts create mode 100644 assets/js/lib/typeit.ts create mode 100644 assets/js/lib/utterances.ts create mode 100644 assets/js/lib/valine.ts create mode 100644 assets/js/lib/waline.ts create mode 100644 assets/js/lib/watermark.ts create mode 100644 assets/js/main.ts create mode 100644 assets/js/modules/code.ts create mode 100644 assets/js/modules/content.ts create mode 100644 assets/js/modules/core.ts create mode 100644 assets/js/modules/encryption.ts create mode 100644 assets/js/modules/events.ts create mode 100644 assets/js/modules/menu.ts create mode 100644 assets/js/modules/misc.ts create mode 100644 assets/js/modules/pagefind.ts create mode 100644 assets/js/modules/search.ts create mode 100644 assets/js/modules/theme.ts create mode 100644 assets/js/modules/toc.ts delete mode 100644 assets/js/pages/link.js create mode 100644 assets/js/pages/link.ts delete mode 100644 assets/js/theme.js create mode 100644 assets/js/types/config.ts create mode 100644 assets/js/types/index.ts create mode 100644 assets/js/types/third-party.ts create mode 100644 assets/js/types/ui.ts create mode 100644 assets/js/utils/animation.ts create mode 100644 assets/js/utils/clipboard.ts create mode 100644 assets/js/utils/comment.ts delete mode 100644 assets/js/utils/common.js create mode 100644 assets/js/utils/dom.ts create mode 100644 assets/js/utils/file.ts create mode 100644 assets/js/utils/index.ts create mode 100644 assets/js/utils/media.ts create mode 100644 assets/js/utils/string.ts create mode 100644 assets/js/utils/theme.ts create mode 100644 assets/js/utils/tooltip.ts create mode 100644 assets/js/utils/validate.ts create mode 100644 layouts/_partials/function/js-build.html create mode 100644 layouts/_partials/plugin/mermaid-bootstrap.html create mode 100644 tsconfig.json diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4d5da897..653e74d3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,307 +1,94 @@ -# FixIt 编码标准和指导原则 +# FixIt Coding Standards and Guidelines -本文档定义了 FixIt 主题项目的编码标准、最佳实践和开发指导原则。所有贡献者和 AI 助手在参与项目开发时都应遵循这些规范。 +This document defines the detailed coding standards for the FixIt theme project. For project overview, architecture, and development commands, see [CLAUDE.md](../../CLAUDE.md). -## 项目概述 +## SCSS Coding Standards -FixIt 是一个面向 Hugo 静态网站生成器的现代化、响应式主题。项目基于以下技术栈: +### Naming -- **Hugo**: 静态网站生成器(≥ 0.158.0) -- **SCSS**: CSS 预处理器,用于样式开发 -- **JavaScript**: ES6+ 标准,用于前端交互功能 -- **Go Templates**: Hugo 模板引擎 -- **Node.js**: 开发环境和构建工具 -- **pnpm**: 包管理器 +- **CSS classes**: BEM or semantic naming (`header-desktop`, `menu-item`, `post-tag`) +- **SCSS variables**: hyphen-separated, semantic (`$global-font-family`, `$code-background-color`) +- **CSS custom properties**: prefixed with `fi-` / `--fi-` -## 目录结构约定 +### Guidelines -``` -FixIt/ -├── apps/ # 最小化站点 -│ ├── demo/ # 演示站点 -│ └── test/ # 测试站点 -├── archetypes/ # 内容模板 -├── assets/ # 主题资源文件 -│ ├── css/ # SCSS 样式文件 -│ ├── js/ # JavaScript 文件 -│ ├── images/ # 图像资源 -│ └── lib/ # 第三方库 -├── i18n/ # 国际化翻译文件 -├── layouts/ # Hugo 模板文件 -│ ├── _markup/ # Hugo 渲染钩子 -│ ├── _partials/ # 可复用模板组件 -│ └── _shortcodes/ # 自定义短代码 -├── packages/ # 主题相关包 -├── static/ # 静态文件 -├── hugo.toml # 主题默认配置 -└── package.json # npm 脚本和依赖 -``` +- Use 2-space indentation +- Use CSS variables for theme switching support +- Prefer relative units (rem, em, %) over absolute units +- Use SCSS variables for colors — no hardcoded values +- Keep selector nesting shallow -## SCSS/CSS 编码规范 +## TypeScript Coding Standards -### 文件组织结构 +### Architecture -1. **主入口文件**: `assets/scss/main.scss` -2. **变量文件**: `assets/scss/_variables.scss` - 全局变量定义 -3. **覆写文件**: `assets/scss/_override.scss` - 用户自定义覆写 +Service-class architecture with direct constructor calls: -### 命名约定 +- **`TypedEventBus`** (`core/event-bus.ts`) — Module-level singleton (`eventBus`) wrapping DOM `CustomEvents` with typed event map. +- **Service interfaces** (`core/tokens.ts`) — Typed contracts for each module (`CoreService`, `ThemeService`, `CodeService`, etc.). +- **Module classes** (`modules/*.ts`) — Each module implements its service interface. Dependencies are constructor-injected. -1. **CSS 类名**: 使用 BEM 方法论或语义化命名 +### Module Pattern - ```scss - .header-desktop {} - .menu-item {} - .single-title {} - .post-tag {} - ``` +```typescript +export class ExampleModule implements ExampleService { + #privateState: any // ES6 # private fields, not _ prefix -2. **SCSS 变量**: 使用连字符分隔,语义化命名 + constructor(private readonly core: CoreService) {} - ```scss - $global-font-family: system-ui, sans-serif; - $code-background-color: #f4f6f8; - $header-height: 3.5rem; - ``` - -3. **CSS 自定义属性**: 使用前缀约定 - - ```scss - $prefix: fi-; - $rootPrefix: --fi-; - ``` - -### 样式组织原则 - -1. **主题切换支持**: 使用 CSS 变量实现主题切换 - - ```scss - .element { - color: fi-var(global-font-color); - } - ``` - -2. **Mixin 使用**: 提高代码复用性 - - ```scss - @include border-radius; - ``` - -### 代码质量要求 - -1. **缩进**: 使用 2 个空格 -2. **注释**: 为复杂逻辑添加注释 -3. **颜色**: 使用变量而非硬编码颜色值 -4. **单位**: 优先使用相对单位(rem、em、%) - -## JavaScript 编码规范 - -### 代码风格 - -1. **ES6+ 标准**: 使用现代 JavaScript 语法 -2. **模块化**: 使用 ES6 模块系统 - -### 文件组织 - -1. **主题核心**: `assets/js/theme.js` - 主题核心逻辑 -2. **工具函数**: `assets/js/utils/` - 通用工具函数 -3. **特定功能**: 按功能划分独立模块 - -### 编码实例 - -```javascript -// 使用 ES6 类 -export default class Util { - static copyText(text) { - // ... - } -} - -// 主题类组织 -class FixIt { - constructor() { - this.config = window.config - this.scrollTop = Util.getScrollTop() - } - - init() { - this.initTheme() - this.initComponents() - } + publicMethod(): void { /* ... */ } + #privateHelper(): void { /* ... */ } } ``` -### 最佳实践 +### Key Rules -1. **错误处理**: 使用 try-catch 处理异常 -2. **异步操作**: 优先使用 async/await -3. **事件处理**: 合理使用事件委托 -4. **性能优化**: 避免不必要的 DOM 操作 +- Use ES6 `#` private fields — not TypeScript `private` with `_` prefix +- Keep modules focused: one module per file, one service interface per module +- Import the shared `eventBus` singleton from `core/event-bus` for cross-module communication +- Constructor injection for dependencies — no global state access +- `window.fixit` exposes a typed public API (`FixItPublicAPI`) for user custom scripts -## Hugo 模板规范 +### Utilities -### 模板组织 +- Pure functions only in `utils/` — no side effects, no DOM state +- Re-export everything through `utils/index.ts` -1. **布局模板**: `layouts/` 目录下的主要模板 -2. **局部模板**: `layouts/_partials/` 下的可复用组件 -3. **短代码**: `layouts/_shortcodes/` 下的内容短代码 +## Hugo Template Standards -### 编码约定 +### Conventions -1. **变量命名**: 使用驼峰命名法 +- **Variable naming**: camelCase (`$footerConfig`, `$fingerprint`) +- **Comments**: Hugo syntax `{{- /* comment */ -}}` +- **Translation**: Use `T` function for i18n (`{{ T "header.switchTheme" }}`) +- **Whitespace**: Use `{{- -}}` trim markers to control whitespace output - ```go-html-template - {{- $footerConfig := .Site.Params.footer -}} - {{- $fingerprint := .Site.Store.Get "fingerprint" -}} - ``` +### Guidelines -2. **注释规范**: 使用 Hugo 注释语法 +- Use `partialCached` for expensive partials that don't change per page +- Use `.Site.Store` for shared computed values (e.g. fingerprint) +- Check `hugo.IsProduction` before adding analytics or minification - ```go-html-template - {{- /* 这是模板注释 */ -}} - {{- /* - 多行注释 - 可以跨越多行 - */ -}} - ``` +## Git Workflow -3. **条件判断**: 清晰的条件结构 +### Commit Convention - ```go-html-template - {{- if ne $config.enable false -}} - - {{- end -}} - ``` - -4. **循环遍历**: 合理使用 range - - ```go-html-template - {{- range $index, $value := .Pages -}} - - {{- end -}} - ``` - -### 国际化处理 - -1. **翻译函数**: 使用 `T` 函数 - - ```go-html-template - {{ T "header.switchTheme" }} - ``` - -2. **多语言支持**: 考虑多语言环境 - - ```go-html-template - {{- if hugo.IsMultilingual -}} - - {{- end -}} - ``` - -## 开发工作流程 - -### 环境设置 - -1. **前置要求**: - - Node.js (≥ 20.0.0) - - Hugo Extended (≥ 0.158.0) - - pnpm (包管理器) - -2. **开发命令**: - - ```bash - pnpm dev:demo # 启动 demo 站点开发服务器 - pnpm dev:test # 启动 test 站点开发服务器 - pnpm dev:docs # 启动文档开发服务器(需有 fixit-docs 作为同级目录) - pnpm build:demo # 构建 demo 站点 - pnpm build:test # 构建 test 站点 - pnpm build # 一键构建所有站点 - pnpm preview # 预览构建后的站点(需先构建) - ``` - -### 代码质量 - -1. **代码审查**: 提交前进行自我审查 -2. **测试验证**: 在多个环境中测试功能 -3. **文档更新**: 必要时更新相关文档 -4. **向后兼容**: 确保更改不破坏现有功能 - -### Git 提交规范 - -遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范: +Follows [Conventional Commits](https://www.conventionalcommits.org/): ``` -feat: 新增功能 -fix: 修复 bug -docs: 文档更新 -style: 代码格式调整 -refactor: 代码重构 -test: 测试相关 -chore: 构建过程或工具相关 +(): ``` -## 性能优化指导 +Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `perf`, `style`, `test`, `ci`, `build` -### CSS 性能 +Scopes: `workflow`, `archetypes`, `assets`, `i18n`, `layouts`, `config`, or specific directories. -1. **选择器优化**: 避免过深的嵌套 -2. **媒体查询**: 合理组织响应式断点 -3. **动画优化**: 使用 transform 和 opacity -4. **资源压缩**: 生产环境启用压缩 +### Pre-commit Hooks -### JavaScript 性能 +Pre-commit runs: versioning (dev mode), typecheck, lint-staged (eslint --fix on staged files). -1. **延迟加载**: 非关键脚本使用 defer -2. **事件优化**: 合理使用防抖和节流 -3. **内存管理**: 及时清理事件监听器 -4. **模块化**: 按需加载功能模块 +## Browser Compatibility -### Hugo 模板性能 - -1. **缓存策略**: 合理使用 Hugo 缓存机制 -2. **资源处理**: 优化图片和静态资源 -3. **构建优化**: 减少不必要的模板处理 - -## 可访问性要求 - -1. **语义化 HTML**: 使用正确的 HTML 标签 -2. **ARIA 属性**: 为复杂组件添加 ARIA 支持 -3. **键盘导航**: 确保键盘可访问性 -4. **颜色对比**: 满足 WCAG 对比度要求 -5. **屏幕阅读器**: 提供适当的文本替代 - -## 浏览器兼容性 - -1. **目标浏览器**: 支持现代浏览器 -2. **渐进增强**: 基础功能向下兼容 -3. **特性检测**: 使用特性检测而非浏览器检测 -4. **Polyfill**: 必要时提供 polyfill 支持 - -## 安全考虑 - -1. **XSS 防护**: 正确处理用户输入 -2. **CSRF 保护**: 表单提交安全 -3. **内容安全**: 合理设置 CSP 策略 -4. **依赖安全**: 定期更新依赖包 - -## 文档要求 - -1. **代码注释**: 复杂逻辑必须注释 -2. **API 文档**: 公共方法需要文档 -3. **使用示例**: 提供清晰的使用示例 -4. **更新日志**: 重要更改记录在 CHANGELOG - -## 第三方库管理 - -1. **依赖选择**: 优先选择轻量、维护活跃的库 -2. **版本管理**: 及时更新安全补丁 -3. **许可证**: 确保许可证兼容性 -4. **定制化**: 必要时进行本地化修改 - -## 贡献指导 - -1. **讨论优先**: 重大更改前先讨论 -2. **小步迭代**: 避免大规模重构 -3. **测试覆盖**: 新功能需要充分测试 -4. **文档同步**: 功能和文档同时更新 - -遵循这些编码标准将有助于维护代码质量,提高开发效率,并确保项目的长期可维护性。 +- Target modern browsers +- Use progressive enhancement for advanced features +- Prefer feature detection over browser detection diff --git a/.github/workflows/librarybot.yml b/.github/workflows/librarybot.yml index 63b8a96d..f26d68cc 100644 --- a/.github/workflows/librarybot.yml +++ b/.github/workflows/librarybot.yml @@ -1,7 +1,8 @@ name: Update libraries from npm on: schedule: - - cron: '0 0 * * 0' + # Run at 00:00, on day 1 of the month + - cron: '0 0 1 * *' workflow_dispatch: permissions: contents: write @@ -47,3 +48,5 @@ jobs: base: main labels: dependencies reviewers: Lruihao + author: Cell[bot] + committer: Cell[bot] diff --git a/.gitignore b/.gitignore index d3c4660e..b50b1983 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ node_modules/ # Trae .trae/ +# Claude +.claude/ + # OS Files ## Windows Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3bc72b23 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +FixIt is a modern, responsive theme for the Hugo static site generator. Built with Hugo templates, SCSS, and TypeScript. + +## Development Commands + +### Prerequisites + +- Node.js >= 20 +- Hugo Extended >= 0.158.0 (Dart Sass required) +- pnpm + +### Development + +```bash +pnpm install # Install dependencies +pnpm dev:demo # Start demo site dev server +pnpm dev:test # Start test site dev server +pnpm dev:docs # Start docs dev server (requires fixit-docs as sibling directory) +``` + +### Build + +```bash +pnpm build:demo # Build demo site +pnpm build:test # Build test site +pnpm build # Build all sites (demo + test, merged into public/) +pnpm preview # Preview built site (requires build first) +``` + +### Code Quality + +```bash +pnpm lint # Run ESLint +pnpm typecheck # Run TypeScript type checking +``` + +There are no unit tests. Verify changes by building `pnpm build:demo` or `pnpm build:test` and inspecting the output. + +## Architecture + +### Monorepo Structure + +Root `package.json` is `@hugo-fixit/core`. pnpm workspaces include `apps/*` and `packages/*`: + +- `apps/demo/` — Demo site (deployed to `demo.fixit.lruihao.cn`) +- `apps/test/` — Test site for exercising theme features +- `packages/shared` — Shared utilities (exports `workspaceRoot`) +- `packages/versioning` — Version management (auto-updates version in `layouts/_partials/init/index.html` during pre-commit) +- `packages/integration` — Post-build: merges demo/test output into `public/` + +### JavaScript Module System (`assets/js/`) + +Service-class architecture with direct constructor calls: + +- **`main.ts`** — Entry point. Instantiates all modules with direct constructor calls, builds the typed `window.fixit` public API facade, and runs the init sequence on `DOMContentLoaded`. +- **`types/`** — TypeScript type definitions: + - `config.ts` — `FixItConfig` and all config sub-types. + - `ui.ts` — `FixItPublicAPI`, global `window` augmentation for third-party libs. + - `third-party.ts` — Types for vendored libraries. +- **`core/`** — Infrastructure layer: + - `event-bus.ts` — Typed event bus singleton wrapping DOM `CustomEvents`. Exports `eventBus` (module-level singleton) and `FixItEventMap` type. + - `tokens.ts` — Service interfaces (`CoreService`, `ThemeService`, `CodeService`, etc.) used for module constructor typing. +- **`modules/`** — Feature modules. Each is a class implementing its service interface. Dependencies are constructor-injected. Private state uses ES6 `#` fields. Modules: charts, code, content, core, encryption, events, menu, misc, search, theme, toc. `pagefind.ts` is a standalone factory consumed by `SearchModule`. +- **`utils/`** — Pure utility functions (no side effects, no DOM state). Re-exported from `utils/index.ts`. +- **`lib/`** — Third-party library wrappers (aplayer, echarts, file-tree, fixit-decryptor, lightgallery, mapbox, mathjax, mermaid, etc.). All import the shared `eventBus` singleton. +- **`head/`** — `color-scheme.ts` runs synchronously in `` before body render to prevent flash of wrong theme. +- **`pages/`** — Page-specific scripts (e.g. `link.ts` for the link guard redirection page). + +Cross-module communication uses the shared `eventBus` singleton, not direct module imports. The `window.fixit` facade exposes a typed public API for user custom scripts (`custom.ts`): theme control, scroll state, mask overlay management, content re-initialization, and the event bus. + +### Hugo Templates (`layouts/`) + +- **`_partials/`** — Reusable template components (organized into `init/`, `base/`, `function/`, `plugin/`, `single/`, `store/`) +- **`_shortcodes/`** — 31 custom shortcodes (admonition, aplayer, echarts, file-tree, mermaid, tabs, timeline, etc.) +- **`_markup/`** — 15 render hooks (code blocks, headings, images, links, tables, blockquote alerts, passthrough for math) + +### Asset Pipeline + +Hugo Pipes processes all assets. The key orchestration is in `_partials/base/assets.html`: + +- **CSS**: `scss/config.template.scss` generates runtime CSS custom properties from Hugo config. `scss/main.scss` is the entry point importing `core/`, `pages/`, `widgets/`, `custom`. +- **JS**: `_partials/function/js-build.html` wraps `js.Build` with minify-in-production defaults. Hugo's `@params` injection passes config values into TypeScript at build time. +- **Third-party libraries**: Stored in `assets/lib/` (vendored, not npm-managed). Tracked by `librarybot.yml` and updated weekly by the `hugo-fixit/librarybot` GitHub Action. Can be overridden via CDN config in `assets/data/cdn/jsdelivr.yml` or `unpkg.yml`. + +### Theme Configuration + +- **`hugo.toml`** — Default theme configuration (1700+ lines). Uses `_merge = "shallow"` to let user configs override without deep merging. +- **`theme.toml`** — Theme metadata + +## Coding Standards + +### SCSS (`assets/scss/`) + +- CSS classes: BEM or semantic naming (`header-desktop`, `menu-item`) +- SCSS variables: hyphen-separated, semantic (`$global-font-family`) +- CSS custom properties: prefixed with `fi-` / `--fi-` +- Use CSS variables for theme switching; SCSS variables for colors (no hardcoded values) +- Prefer relative units (rem, em, %) over absolute units +- Keep selector nesting shallow + +### TypeScript + +- Use ES6 `#` private fields — not TypeScript `private` with `_` prefix +- One module per file, one service interface per module +- Constructor injection for dependencies — no global state access +- Import the shared `eventBus` singleton from `core/event-bus` — do not create new instances +- Pure functions only in `utils/` — no side effects, no DOM state + +### Hugo Templates + +- Variable naming: camelCase (`$footerConfig`, `$fingerprint`) +- Translation: use `T` function (`{{ T "header.switchTheme" }}`) +- Whitespace: use `{{- -}}` trim markers +- Use `partialCached` for expensive partials that don't change per page +- Use `.Site.Store` for shared computed values (e.g. fingerprint) +- Check `hugo.IsProduction` before adding analytics or minification + +## Commit Convention + +Follows [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): +``` + +Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `perf`, `style`, `test`, `ci`, `build` + +Scopes: `workflow`, `archetypes`, `assets`, `i18n`, `layouts`, `config`, or specific directories. + +## Pre-commit Hooks + +Pre-commit runs: versioning (dev mode), typecheck, lint-staged (eslint --fix on staged files). + +## ESLint + +Uses `@antfu/eslint-config` with TypeScript support. Config at `eslint.config.js`. diff --git a/apps/test/content/posts/aplayer-test/index.md b/apps/test/content/posts/aplayer-test/index.md index f5c1384a..106e3e89 100644 --- a/apps/test/content/posts/aplayer-test/index.md +++ b/apps/test/content/posts/aplayer-test/index.md @@ -26,3 +26,9 @@ Testing APlayer shortcode in FixIt theme. [00:08.02]amazing {{< /audio >}} {{< /aplayer >}} + +{{% fixit-encryptor "1212" %}} +{{< aplayer fixed=false mini=false autoplay=false theme="#b7daff" loop="all" order="list" preload="auto" volume=0.7 mutex=true lrcType=3 listFolded=false listMaxHeight="" storageName="aplayer-setting" >}} + {{< audio name="Wavelength" artist="oldmanyoung" url="Wavelength.mp3" cover="Wavelength.webp" lrc="Wavelength.lrc" />}} +{{< /aplayer >}} +{{% /fixit-encryptor %}} diff --git a/apps/test/content/posts/encryption-test/index.md b/apps/test/content/posts/encryption-test/index.md index e6233ed9..ad01a605 100644 --- a/apps/test/content/posts/encryption-test/index.md +++ b/apps/test/content/posts/encryption-test/index.md @@ -37,10 +37,8 @@ This is a **right-aligned** paragraph. {{< script >}} console.log('before decrypting'); -document.addEventListener('DOMContentLoaded', () => { - fixit.decryptor.addEventListener('decrypted', function() { - console.log('after decrypting') - }) +document.addEventListener('fixit:decrypted', () => { + console.log('after decrypting') }); {{< /script >}} @@ -157,10 +155,8 @@ series: {{< typeit code=javascript >}} console.log('before decrypting'); -document.addEventListener('DOMContentLoaded', () => { - fixit.decryptor.addEventListener('decrypted', function() { - console.log('after decrypting') - }) +document.addEventListener('fixit:decrypted', () => { + console.log('after decrypting') }); {{< /typeit >}} diff --git a/apps/test/content/posts/link-test.md b/apps/test/content/posts/link-test.md index 143ef8a9..0e10ca04 100644 --- a/apps/test/content/posts/link-test.md +++ b/apps/test/content/posts/link-test.md @@ -64,7 +64,7 @@ These links are rendered by `layouts/_markup/render-link.html` and should always ### Card with custom image icon -{{< link href="https://gohugo.io" content="Hugo Official Site" card=true card-icon="/test/images/hugo.min.svg" >}} +{{< link href="https://gohugo.io" content="Hugo Official Site" card=true card-icon="/images/hugo.svg" >}} ### Card with download state diff --git a/apps/test/content/posts/mathjax-test.md b/apps/test/content/posts/mathjax-test.md index d9f31d1a..ec185da6 100644 --- a/apps/test/content/posts/mathjax-test.md +++ b/apps/test/content/posts/mathjax-test.md @@ -142,3 +142,9 @@ $$ \bbox[border: solid .4pt magenta, pink]{x^2=4} $$ {{< /auto-dark >}} + +### In Encrypted Content + +{{% fixit-encryptor "1212" %}} +$c = \pm\sqrt{a^2 + b^2}$ and \(f(x)=\int_{-\infty}^{\infty} \hat{f}(\xi) e^{2 \pi i \xi x} d \xi\) +{{% /fixit-encryptor %}} diff --git a/apps/test/hugo.toml b/apps/test/hugo.toml index 513eb716..5f9f9ac7 100644 --- a/apps/test/hugo.toml +++ b/apps/test/hugo.toml @@ -6,6 +6,18 @@ title = "FixIt TEST" baseURL = "https://demo.fixit.lruihao.cn/test/" ignoreLogs = [ "warning-file-tree" ] +defaultContentLanguage = "en" + +# test for multilingual support +# [languages] + +# [languages.en] +# languageCode = "en" +# languageName = "English" + +# [languages.zh-cn] +# languageCode = "zh-CN" +# languageName = "简体中文" [permalinks] diff --git a/assets/js/core/event-bus.ts b/assets/js/core/event-bus.ts new file mode 100644 index 00000000..f8bc57ee --- /dev/null +++ b/assets/js/core/event-bus.ts @@ -0,0 +1,42 @@ +/** Event map: every FixIt event and its payload shape. */ +export interface FixItEventMap { + 'fixit:switch-theme': { isDark: boolean, mode: string, isChanged: boolean } + 'fixit:scroll': void + 'fixit:resize': void + 'fixit:decrypted': void + 'fixit:partial-decrypted': { target: Element } + 'fixit:re-encrypt': void + 'fixit:code-tab-sync': { lang: string, source: HTMLElement } +} + +type Handler = T extends void + ? (() => void) | ((event: CustomEvent) => void) + : (event: CustomEvent) => void + +/** Typed event bus — wraps DOM CustomEvents with type-safe emit/on/off. */ +export class TypedEventBus { + private target = document + + on(event: K, handler: Handler): void { + this.target.addEventListener(event as string, handler as EventListener) + } + + off(event: K, handler: Handler): void { + this.target.removeEventListener(event as string, handler as EventListener) + } + + emit( + event: K, + ...args: FixItEventMap[K] extends void ? [] : [FixItEventMap[K]] + ): void { + const detail = args[0] + this.target.dispatchEvent( + detail !== undefined + ? new CustomEvent(event as string, { detail }) + : new CustomEvent(event as string), + ) + } +} + +/** Shared event bus singleton for all modules and libs. */ +export const eventBus = new TypedEventBus() diff --git a/assets/js/core/tokens.ts b/assets/js/core/tokens.ts new file mode 100644 index 00000000..64e9d63e --- /dev/null +++ b/assets/js/core/tokens.ts @@ -0,0 +1,81 @@ +/** Service interfaces for all FixIt modules. */ +import type { FixItConfig, MaskOverlayHandler } from '../types' + +// ─── CoreService ─── +export interface CoreService { + readonly config: FixItConfig + isDark: boolean + themeMode: string + disableScrollEvent: boolean + newScrollTop: number + oldScrollTop: number + registerMaskOverlay: (name: string, handlers: MaskOverlayHandler) => void + openMaskOverlay: (name: string) => void + closeMaskOverlay: (name: string, skipSync?: boolean) => void + toggleMaskOverlay: (name: string) => void + closeActiveMaskOverlay: () => void + syncMaskState: () => void +} + +// ─── ThemeService ─── +export interface ThemeService { + setThemeMode: (mode: string, persist?: boolean) => void + initThemeColor: () => void + initSwitchTheme: () => void +} + +// ─── MenuService ─── +export interface MenuService { + initMenu: () => void +} + +// ─── SearchService ─── +export interface SearchService { + initSearch: () => void +} + +// ─── CodeService ─── +export interface CodeService { + initCodeWrapper: () => void + initCodeTabs: () => void + initDiagramCopyBtn: () => void +} + +// ─── TocService ─── +export interface TocService { + syncTocHeight: () => void + syncTocActiveState: () => void + initToc: () => void + setup: () => void +} + +// ─── EncryptionService ─── +export interface EncryptionService { + initFixItDecryptor: () => void +} + +// ─── ContentService ─── +export interface ContentService { + initSVGIcon: () => void + initLinkGuardDialog: (target?: Element | Document) => void + initContent: (target?: Element | Document) => void + setup: () => void +} + +// ─── MiscService ─── +export interface MiscService { + initSiteTime: () => void + initServiceWorker: () => void + initAutoMark: () => void + initReward: () => void + initPostChatUser: () => void + initComment: () => void +} + +// ─── EventsService ─── +export interface EventsService { + onScroll: () => void + onResize: () => void + onClickMask: () => void + initPrint: () => void +} diff --git a/assets/js/custom.js.example b/assets/js/custom.js.example deleted file mode 100644 index cb62f5b6..00000000 --- a/assets/js/custom.js.example +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Custom JavaScript for FixIt blog site. - * @author @Lruihao https://lruihao.cn - */ -class FixItBlog { - /** - * say hello - * you can define your own functions below - * @returns {FixItBlog} - */ - hello() { - console.log('custom.js: Hello FixIt!'); - return this; - } - - /** - * initialize - * @returns {FixItBlog} - */ - init() { - this.hello(); - return this; - } -} - -/** - * immediate execution - */ -(() => { - window.fixitBlog = new FixItBlog(); - // it will be executed when the DOM tree is built - document.addEventListener('DOMContentLoaded', () => { - window.fixitBlog.init(); - }); -})(); diff --git a/assets/js/custom.ts.example b/assets/js/custom.ts.example new file mode 100644 index 00000000..8d888f23 --- /dev/null +++ b/assets/js/custom.ts.example @@ -0,0 +1,31 @@ +/** + * Custom TypeScript for FixIt site. + * + * Rename this file to custom.ts to activate it. + * Access the FixIt public API via `window.fixit` (type: FixItPublicAPI). + * @see https://fixit.lruihao.cn + */ + +import type { FixItPublicAPI } from './types/ui' + +declare const fixit: FixItPublicAPI + +class CustomScript { + constructor() { + this.init() + } + + init() { + console.log('hello from custom.ts!') + document.addEventListener('fixit:switch-theme', ({ detail }) => { + console.log('Theme switched:', detail.mode, 'isDark:', detail.isDark, 'isChanged:', detail.isChanged) + }) + console.log('FixIt API:', fixit) + console.log('FixIt config:', fixit.config) + return this + } +} + +document.addEventListener('DOMContentLoaded', () => { + void new CustomScript() +}) diff --git a/assets/js/fixit-decryptor.js b/assets/js/fixit-decryptor.js deleted file mode 100644 index 1bf80584..00000000 --- a/assets/js/fixit-decryptor.js +++ /dev/null @@ -1,260 +0,0 @@ -class FixItDecryptor { - /** - * FixIt decryptor for encrypted pages and fixit-encryptor shortcode - * @param {Object} options - * @param {Function} [options.decrypted] [Lifecycle Hooks] handler after decrypting - * @param {Function} [options.reset] [Lifecycle Hooks] handler after encrypting again - * @param {Number} [options.duration=86400] number of seconds to cache decryption statistics. unit: s - */ - constructor(options = {}) { - this.options = options || {}; - this.options.duration = this.options.duration || 24 * 60 * 60; // default cache one day - this.decryptedEventSet = new Set(); - this.partialDecryptedEventSet = new Set(); - this.resetEventSet = new Set(); - customElements.get('fixit-encryptor') || customElements.define('fixit-encryptor', class extends HTMLElement {}); - customElements.get('cipher-text') || customElements.define('cipher-text', class extends HTMLElement {}); - } - - /** - * decrypt content - * @param {Element} $cipherText cipher text element - * @param {Element} $target target content element - * @param {String} salt salt string - */ - #decryptContent($cipherText, $target, salt) { - try { - $target.innerHTML = CryptoJS.enc.Base64 - .parse($cipherText.innerText.replace(salt, '')) - .toString(CryptoJS.enc.Utf8); - $cipherText.parentElement.classList.add('decrypted'); - } catch (err) { - return console.error(err); - } - // decrypted hook - const eventSet = $target.id === 'content' ? this.decryptedEventSet : this.partialDecryptedEventSet; - for (const event of eventSet) { - event($target); - } - } - - /** - * validate password - * @param {Element} $encryptor fixit-encryptor element - * @param {Function} callback callback function after password validation - * @returns - */ - async #validatePassword($encryptor, callback) { - const $cipherText = $encryptor.querySelector('cipher-text'); - const password = $cipherText.dataset.password; - const inputEl = $encryptor.querySelector('.fixit-decryptor-input'); - const input = inputEl.value.trim(); - // Warning: insufficient-password-hash Weak hashing algorithms for passwords poses security risks. - const { h64ToString } = await xxhash(); - const inputHash = h64ToString(input); - const inputSha256 = CryptoJS.SHA256(input).toString(); - const saltLen = input.length % 2 ? input.length : input.length + 1; - - inputEl.value = ''; - inputEl.blur(); - if (!input) { - alert('Please enter the correct password!'); - return console.warn('Please enter the correct password!'); - } - if (inputHash !== password) { - alert(`Password error: ${input} not the correct password!`); - return console.warn(`Password error: ${input} not the correct password!`); - } - callback($cipherText, inputHash, inputSha256.slice(saltLen)); - } - - /** - * initialize FixIt decryptor - * @param {Object} options - * @param {Boolean} options.all whether to decrypt all content - * @param {String} options.shortcode whether to decrypt fixit-encryptor shortcode - */ - init({ all, shortcode }) { - this.addEventListener('decrypted', this.options?.decrypted); - this.addEventListener('partial-decrypted', this.options?.partialDecrypted); - this.addEventListener('reset', this.options?.reset); - const $content = document.querySelector('#content'); - if (shortcode) { - this.addEventListener('decrypted', () => { - this.initShortcodes($content); - }); - this.addEventListener('partial-decrypted', ($parent) => { - this.initShortcodes($parent); - }); - } - if (all) { - this.initPage(); - } else if (shortcode) { - this.initShortcodes($content); - } - } - - /** - * initialize FixIt decryptor for the encrypted pages - */ - initPage() { - this.validateCache(); - const $encryptor = document.querySelector('article > fixit-encryptor'); - const $content = document.querySelector('#content'); - - const decryptorHandler = () => { - this.#validatePassword($encryptor, ($cipherText, passwordHash, salt) => { - // cache decryption statistics - window.localStorage?.setItem( - `fixit-decryptor/#${location.pathname}`, - JSON.stringify({ - expiration: Math.ceil(Date.now() / 1000) + this.options.duration, - password: passwordHash, - salt, - }) - ); - this.#decryptContent($cipherText, $content, salt); - }); - }; - - // bind decryptor input enter keydown event - $encryptor.querySelector('.fixit-decryptor-input')?.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - decryptorHandler(); - } - }); - - // bind decryptor button click event - $encryptor.querySelector('.fixit-decryptor-btn')?.addEventListener('click', (e) => { - e.preventDefault(); - decryptorHandler(); - }); - - // bind encryptor button click event - $encryptor.querySelector('.fixit-encryptor-btn')?.addEventListener('click', (e) => { - e.preventDefault(); - $encryptor.classList.remove('decrypted'); - $content.innerHTML = ''; - window.localStorage?.removeItem(`fixit-decryptor/#${location.pathname}`); - // reset hook - for (const event of this.resetEventSet) { - event(); - } - }); - - $encryptor.classList.add('initialized'); - } - - /** - * initialize FixIt decryptor for fixit-encryptor shortcodes - * @param {Element} $parent parent element - */ - initShortcodes($parent) { - const $shortcodes = $parent.querySelectorAll('fixit-encryptor:not(.initialized)'); - - $shortcodes.forEach($shortcode => { - const decryptorHandler = () => { - const $content = $shortcode.querySelector('.decryptor-content'); - this.#validatePassword($shortcode, ($cipherText, passwordHash, salt) => { - this.#decryptContent($cipherText, $content, salt); - }); - }; - - // bind decryptor input enter keydown event - $shortcode.querySelector('.fixit-decryptor-input')?.addEventListener('keydown', function (e) { - if (e.key === 'Enter') { - e.preventDefault(); - decryptorHandler(); - } - }); - - // bind decryptor button click event - $shortcode.querySelector('.fixit-decryptor-btn')?.addEventListener('click', function (e) { - e.preventDefault(); - decryptorHandler(); - }); - - $shortcode.classList.add('initialized'); - }); - } - - /** - * validate the cached decryption statistics in localStorage - * @returns {FixItDecryptor} - */ - validateCache() { - const $content = document.querySelector('#content'); - const $encryptor = document.querySelector('article > fixit-encryptor'); - const $cipherText = $encryptor.querySelector('cipher-text'); - const password = $cipherText.dataset.password; - const cachedStat = JSON.parse(window.localStorage?.getItem(`fixit-decryptor/#${location.pathname}`)); - - if (!cachedStat || cachedStat?.password !== password || Number(cachedStat?.expiration) < Math.ceil(Date.now() / 1000)) { - if (cachedStat) { - window.localStorage?.removeItem(`fixit-decryptor/#${location.pathname}`); - console.warn('The password has expired, please re-enter!'); - } - return this; - } - this.#decryptContent($cipherText, $content, cachedStat.salt); - return this; - } - - /** - * add event listener for FixIt Decryptor - * @param {String} event event name - * @param {Function} listener event handler - * @returns {FixItDecryptor} - */ - addEventListener(event, listener) { - if (typeof listener !== 'function') { - return this; - } - switch (event) { - case 'decrypted': - this.decryptedEventSet.add(listener); - break; - case 'partial-decrypted': - this.partialDecryptedEventSet.add(listener); - break; - case 'reset': - this.resetEventSet.add(listener); - break; - default: - console.warn(`Event ${event} not found in FixIt Decryptor!`); - break; - } - return this; - } - - /** - * remove event listener for FixIt Decryptor - * @param {String} event event name - * @param {Function} listener event handler - * @returns {FixItDecryptor} - */ - removeEventListener(event, listener) { - if (typeof listener !== 'function') { - return this; - } - switch (event) { - case 'decrypted': - this.decryptedEventSet.delete(listener); - break; - case 'partial-decrypted': - this.partialDecryptedEventSet.delete(listener); - break; - case 'reset': - this.resetEventSet.delete(listener); - break; - default: - console.warn(`Event ${event} not found in FixIt Decryptor!`); - break; - } - return this; - } -} - -window.FixItDecryptor = FixItDecryptor; - diff --git a/assets/js/head/color-scheme.js b/assets/js/head/color-scheme.js deleted file mode 100644 index 85478971..00000000 --- a/assets/js/head/color-scheme.js +++ /dev/null @@ -1,16 +0,0 @@ -import params from '@params'; - -/** - * Initialize theme mode before body rendering. - * Modes: auto | light | dark - */ -(function () { - const localStorage = window.localStorage; - const storedMode = localStorage?.getItem('theme-mode'); - const themeMode = storedMode || - (params.defaultTheme === 'light' || params.defaultTheme === 'dark' - ? params.defaultTheme - : 'auto'); - - document.documentElement.dataset.themeMode = themeMode; -})(); diff --git a/assets/js/head/color-scheme.ts b/assets/js/head/color-scheme.ts new file mode 100644 index 00000000..3d154cff --- /dev/null +++ b/assets/js/head/color-scheme.ts @@ -0,0 +1,21 @@ +// @ts-expect-error — Hugo js.Build virtual module +import params from '@params'; + +/** + * Color scheme initialization — runs synchronously in `` before body rendering. + * + * Responsibilities: + * - Read stored theme mode from localStorage, falling back to site default. + * - Set `data-theme-mode` on `` to prevent flash of wrong theme. + */ +(function () { + const localStorage = window.localStorage + const storedMode = localStorage?.getItem('theme-mode') + const themeMode = storedMode || ( + params.defaultTheme === 'light' || params.defaultTheme === 'dark' + ? params.defaultTheme + : 'auto' + ) + + document.documentElement.dataset.themeMode = themeMode +})() diff --git a/assets/js/lib/aplayer.js b/assets/js/lib/aplayer.js deleted file mode 100644 index 05e9d45b..00000000 --- a/assets/js/lib/aplayer.js +++ /dev/null @@ -1,13 +0,0 @@ -window.FixItAPlayer = { - init: () => { - Array.from(document.getElementsByClassName("aplayer-shortcode")).forEach((aplayer) => { - if (aplayer.dataset.processed) return - const audio = JSON.parse(aplayer.dataset.audio) - const options = JSON.parse(aplayer.dataset.options) - options.audio = audio - options.container = aplayer - new APlayer(options) - aplayer.dataset.processed = true - }) - }, -} diff --git a/assets/js/lib/aplayer.ts b/assets/js/lib/aplayer.ts new file mode 100644 index 00000000..ee16fe61 --- /dev/null +++ b/assets/js/lib/aplayer.ts @@ -0,0 +1,29 @@ +/** + * APlayer integration for FixIt shortcode blocks. + * + * Responsibilities: + * - Discover `.shortcode-aplayer` nodes and initialize APlayer instances once. + * - Re-run initialization after decrypted or partially decrypted content is revealed. + * - Keep behavior idempotent through `data-processed` markers. + */ +import { eventBus } from '../core/event-bus' + +function initAPlayer() { + const aplayers = document.querySelectorAll('.shortcode-aplayer') + aplayers.forEach((el) => { + if (el.dataset.processed) + return + const audio = JSON.parse(el.dataset.audio!) + const options = JSON.parse(el.dataset.options!) + options.audio = audio + options.container = el + void new window.APlayer!(options) + el.dataset.processed = 'true' + }) +} + +document.addEventListener('DOMContentLoaded', () => { + initAPlayer() + eventBus.on('fixit:decrypted', initAPlayer) + eventBus.on('fixit:partial-decrypted', initAPlayer) +}, false) diff --git a/assets/js/lib/artalk.ts b/assets/js/lib/artalk.ts new file mode 100644 index 00000000..772070f9 --- /dev/null +++ b/assets/js/lib/artalk.ts @@ -0,0 +1,45 @@ +/** + * Artalk comment system integration for FixIt. + * + * Responsibilities: + * - Initialize Artalk with configured settings + * - Sync dark mode based on theme preference + * - Handle theme switching + * - Setup lightGallery for comment images when enabled + */ +import { eventBus } from '../core/event-bus' +import { isDarkMode } from '../utils' +import { initCommentLightGallery } from '../utils/comment' + +document.addEventListener('DOMContentLoaded', () => { + if (!window.config.comment?.artalk || !window.Artalk) + return + + const artalkConfig = window.config.comment.artalk + + // Count-only mode for expired comments + if (window.config.comment?.expired) { + window.Artalk.LoadCountWidget({ + server: artalkConfig.server, + site: artalkConfig.site, + pvEl: artalkConfig.pvEl, + countEl: artalkConfig.countEl, + }) + return + } + + const artalk = window.Artalk.init(artalkConfig) + artalk.setDarkMode(isDarkMode()) + + eventBus.on('fixit:switch-theme', ({ detail }) => { + if (!detail.isChanged) + return + artalk.setDarkMode(detail.isDark) + }) + + // Init lightGallery for comment images when enabled + artalk.on('comments-loaded', () => { + if (window.config.comment?.artalk?.lightgallery) + initCommentLightGallery('.atk-comment .atk-content', 'img:not([atk-emoticon])') + }) +}, false) diff --git a/assets/js/lib/cookieconsent.ts b/assets/js/lib/cookieconsent.ts new file mode 100644 index 00000000..b8abd867 --- /dev/null +++ b/assets/js/lib/cookieconsent.ts @@ -0,0 +1,11 @@ +/** + * Cookie Consent integration for FixIt. + * + * Responsibilities: + * - Initialize the cookie consent banner with configured settings. + */ + +document.addEventListener('DOMContentLoaded', () => { + if (window.config.cookieconsent && window.cookieconsent) + window.cookieconsent.initialise(window.config.cookieconsent) +}, false) diff --git a/assets/js/lib/echarts.ts b/assets/js/lib/echarts.ts new file mode 100644 index 00000000..5039c814 --- /dev/null +++ b/assets/js/lib/echarts.ts @@ -0,0 +1,89 @@ +/** + * ECharts integration for FixIt shortcode blocks. + * + * Responsibilities: + * - Initialize ECharts instances with light/dark theme support. + * - Re-render all charts on theme switch. + * - Resize charts on window resize events. + * - Re-run initialization after decrypted content is revealed. + */ +import { eventBus } from '../core/event-bus' +import { getStagingDOM, isDarkMode, isObjectLiteral } from '../utils' + +let echartsArr: any[] = [] + +function initEchartsInTarget(target: Element | Document = document) { + const echarts = window.echarts + const config = window.config.echarts + if (!echarts || !config) + return + const isDark = isDarkMode() + const stagingDOM = getStagingDOM() + target.querySelectorAll('.echarts').forEach(($echarts: Element) => { + const $dataEl = $echarts.nextElementSibling as HTMLElement + if ($dataEl.tagName !== 'TEMPLATE') + return + const chart = echarts.init($echarts as HTMLElement, isDark ? 'dark' : 'light', { renderer: 'svg' }) + chart.showLoading() + stagingDOM.stage(($dataEl as HTMLTemplateElement).content.cloneNode(true)) + const _setOption = (option: any) => { + if (!option) { + chart.hideLoading() + console.warn('ECharts option is missing or invalid. Chart disposed.', { + element: $echarts, + option: $dataEl, + }) + chart.dispose() + ;($echarts as HTMLElement).removeAttribute('style') + return + } + chart.hideLoading() + chart.setOption(option) + echartsArr.push(chart) + } + if ($dataEl.dataset.fmt === 'js') { + try { + const jsCodes = stagingDOM.contentAsText() + // eslint-disable-next-line no-new-func + const _getOption = new Function('fixit', 'chart', isObjectLiteral(jsCodes) ? `return ${jsCodes}` : jsCodes) + if ($dataEl.dataset.async === 'true') { + return Promise.resolve(_getOption(window.fixit, chart)).then((option: any) => { + _setOption(option) + }) + } + return _setOption(_getOption(window.fixit, chart)) + } + catch (err) { + return console.error(err) + } + } + _setOption(stagingDOM.contentAsJson()) + }) + stagingDOM.destroy() +} + +function applyEchartsTheme() { + const echarts = window.echarts + const config = window.config.echarts + if (!echarts || !config) + return + echarts.registerTheme('light', config.lightTheme!) + echarts.registerTheme('dark', config.darkTheme!) + echartsArr.forEach(chart => chart.dispose()) + echartsArr = [] + initEchartsInTarget() +} + +document.addEventListener('DOMContentLoaded', () => { + applyEchartsTheme() + eventBus.on('fixit:switch-theme', ({ detail }) => { + if (!detail.isChanged) + return + applyEchartsTheme() + }) + eventBus.on('fixit:resize', () => { + echartsArr.forEach(chart => chart.resize()) + }) + eventBus.on('fixit:decrypted', applyEchartsTheme) + eventBus.on('fixit:partial-decrypted', ({ detail }) => initEchartsInTarget(detail.target)) +}, false) diff --git a/assets/js/lib/file-tree.js b/assets/js/lib/file-tree.js deleted file mode 100644 index 8d315973..00000000 --- a/assets/js/lib/file-tree.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * FileTree class to handle file tree interactions - */ -export default class FileTree { - static init(target = document) { - target.querySelectorAll('.file-tree-toggle:not([data-init])').forEach((label) => { - label.addEventListener('click', (e) => { - e.stopPropagation() - const item = label.closest('.file-tree-folder') - const isCollapsed = item.classList.contains('is-collapsed') - item && item.classList.toggle('is-collapsed', !isCollapsed) - - const wrapper = label.closest('.file-tree-wrapper') - this.updateLineHeight(wrapper) - }) - label.dataset.init = 'true' - }) - this.updateLineHeight(target) - } - - static updateLineHeight(target = document) { - const uls = target.querySelectorAll('.file-tree .file-tree') - uls.forEach((ul) => { - const parentItem = ul.closest('.file-tree-item.is-collapsed') - if (parentItem) { - ul.style.removeProperty('--fi-file-tree-line-height') - return - } - const items = Array.from(ul.children).filter((el) => el.classList?.contains('file-tree-item')) - if (!items.length) { - ul.style.removeProperty('--fi-file-tree-line-height') - return - } - - const firstLabel = items[0].querySelector('.file-tree-label') - const lastLabel = items[items.length - 1].querySelector('.file-tree-label') - if (!firstLabel || !lastLabel) return - - const firstRect = firstLabel.getBoundingClientRect() - const lastRect = lastLabel.getBoundingClientRect() - - const firstCenterY = firstRect.top + firstRect.height / 2 - const lastCenterY = lastRect.top + lastRect.height / 2 - const offsetY = firstRect.height / 2 + 1/2 + 4 - const height = Math.max(0, lastCenterY - firstCenterY + offsetY) - - ul.style.setProperty('--fi-file-tree-line-height', `${height}px`) - }) - } - - static expandAll(target = document) { - target.querySelectorAll('.file-tree-folder').forEach(folder => folder.classList.remove('is-collapsed')) - this.updateLineHeight(target) - } - - static collapseAll(target = document) { - target.querySelectorAll('.file-tree-folder').forEach(folder => folder.classList.add('is-collapsed')) - this.updateLineHeight(target) - } -} diff --git a/assets/js/lib/file-tree.ts b/assets/js/lib/file-tree.ts new file mode 100644 index 00000000..59e8a096 --- /dev/null +++ b/assets/js/lib/file-tree.ts @@ -0,0 +1,103 @@ +/** + * File tree behavior module for FixIt content blocks. + * + * Responsibilities: + * - Initialize folder expand/collapse interactions for `.file-tree` blocks. + * - Recalculate connector line heights when tree visibility/layout changes. + * - Sync tree state across tab switches, print preparation, and decrypted content updates. + */ +import type { TabContainerChangedEvent } from '../types' +import { eventBus } from '../core/event-bus' + +/** + * Initialize file tree toggle handlers under the given root. + * @param target - The root element or document to search within. + */ + +function initFileTree(target: Element | Document = document) { + target.querySelectorAll('.file-tree-toggle:not([data-init])').forEach((label) => { + label.addEventListener('click', (e) => { + e.stopPropagation() + const item = label.closest('.file-tree-folder') + const isCollapsed = item!.classList.contains('is-collapsed') + item && item.classList.toggle('is-collapsed', !isCollapsed) + + const wrapper = label.closest('.file-tree-wrapper') + updateLineHeight(wrapper as Element) + }) + label.dataset.init = 'true' + }) + updateLineHeight(target) +} + +/** + * Recalculate and apply the vertical connector line height for nested file trees. + * @param target - The root element or document to search within. + */ +function updateLineHeight(target: Element | Document = document) { + const uls = target.querySelectorAll('.file-tree .file-tree') + uls.forEach((ul) => { + const parentItem = ul.closest('.file-tree-item.is-collapsed') + if (parentItem) { + ul.style.removeProperty('--fi-file-tree-line-height') + return + } + const items = Array.from(ul.children).filter(el => el.classList?.contains('file-tree-item')) + if (!items.length) { + ul.style.removeProperty('--fi-file-tree-line-height') + return + } + + const firstLabel = items[0].querySelector('.file-tree-label') + const lastLabel = items[items.length - 1].querySelector('.file-tree-label') + if (!firstLabel || !lastLabel) + return + + const firstRect = firstLabel.getBoundingClientRect() + const lastRect = lastLabel.getBoundingClientRect() + + const firstCenterY = firstRect.top + firstRect.height / 2 + const lastCenterY = lastRect.top + lastRect.height / 2 + const offsetY = firstRect.height / 2 + 1 / 2 + 4 + const height = Math.max(0, lastCenterY - firstCenterY + offsetY) + + ul.style.setProperty('--fi-file-tree-line-height', `${height}px`) + }) +} + +/** + * Expand all collapsed file tree folders. + * @param target - The root element or document to search within. + */ +function expandAll(target: Element | Document = document) { + target.querySelectorAll('.file-tree-folder').forEach(folder => folder.classList.remove('is-collapsed')) + updateLineHeight(target) +} + +/** Bind global events for file tree self-management. */ +function bindEvents() { + document.addEventListener('tab-container-changed', (e: TabContainerChangedEvent) => { + const panel = e.panel || e.detail?.relatedTarget + if (panel) + updateLineHeight(panel) + }, false) + + window.addEventListener('beforeprint', () => { + if (window.config.print?.expandFileTree) { + expandAll(document.getElementById('content')!) + } + }, false) + + eventBus.on('fixit:decrypted', () => { + initFileTree() + }) + + eventBus.on('fixit:partial-decrypted', ({ detail }) => { + initFileTree(detail.target) + }) +} + +document.addEventListener('DOMContentLoaded', () => { + initFileTree() + bindEvents() +}, false) diff --git a/assets/js/lib/fixit-decryptor.ts b/assets/js/lib/fixit-decryptor.ts new file mode 100644 index 00000000..faebf0f4 --- /dev/null +++ b/assets/js/lib/fixit-decryptor.ts @@ -0,0 +1,207 @@ +/** + * Encrypted content decryptor for FixIt pages and shortcodes. + * + * Responsibilities: + * - Validate password input and decrypt Base64 payloads into target containers. + * - Support both full-page and shortcode-scoped encrypted blocks. + * - Persist and validate page-level decrypt cache with expiration. + * - Emit and react to FixIt events for decrypted/partial-decrypted/reset flows. + */ +import { eventBus } from '../core/event-bus' +import { flashTooltip } from '../utils' + +declare const CryptoJS: any + +interface DecryptorOptions { + duration?: number +} + +interface CachedStat { + expiration: number + password: string + salt: string +} + +class FixItDecryptor { + options: Required + + /** + * Create a decryptor instance and register custom elements. + * @param options - Configuration options. + * @param options.duration - Cache duration in seconds for decrypted content (default: 24 hours). + */ + constructor(options: DecryptorOptions = {}) { + this.options = { duration: options.duration || 24 * 60 * 60 } + customElements.get('fixit-encryptor') || customElements.define('fixit-encryptor', class extends HTMLElement {}) + customElements.get('cipher-text') || customElements.define('cipher-text', class extends HTMLElement {}) + } + + /** + * Decode Base64 cipher text and inject the decrypted HTML into the target. + * @param $cipherText - The `` element containing the encrypted content. + * @param $target - The DOM element to inject decrypted HTML into. + * @param salt - The salt string derived from the password. + */ + #decryptContent($cipherText: HTMLElement, $target: HTMLElement, salt: string) { + try { + $target.innerHTML = CryptoJS!.enc.Base64 + .parse($cipherText.textContent!.replace(salt, '')) + .toString(CryptoJS!.enc.Utf8) + $cipherText.parentElement!.classList.add('decrypted') + } + catch (err) { + return console.error(err) + } + if ($target.id === 'content') + eventBus.emit('fixit:decrypted') + else + eventBus.emit('fixit:partial-decrypted', { target: $target }) + } + + /** + * Validate user input against the stored password hash; invoke callback on success. + * @param $encryptor - The `` element containing the input field. + * @param callback - Invoked with `(cipherText, passwordHash, salt)` on success. + */ + async #validatePassword($encryptor: Element, callback: ($cipherText: HTMLElement, passwordHash: string, salt: string) => void) { + const $cipherText = $encryptor.querySelector('cipher-text')! + const password = $cipherText.dataset.password + const inputEl = $encryptor.querySelector('.fixit-decryptor-input')! + const input = inputEl.value.trim() + const { h64ToString } = await window.xxhash!() + const inputHash = h64ToString(input) + const inputSha256 = CryptoJS!.SHA256(input).toString() + const saltLen = input.length % 2 ? input.length : input.length + 1 + + inputEl.value = '' + inputEl.blur() + if (!input) { + flashTooltip(inputEl, 'Please enter the correct password!') + return console.warn('Please enter the correct password!') + } + if (inputHash !== password) { + flashTooltip(inputEl, `Password error: ${input} not the correct password!`) + return console.warn(`Password error: ${input} not the correct password!`) + } + callback($cipherText, inputHash, inputSha256.slice(saltLen)) + } + + /** + * Initialize page-level and/or shortcode-level decryption based on flags. + * @param options - `{ all?, shortcode? }` controlling which modes to activate. + * @param options.all - Enable whole-page decryption. + * @param options.shortcode - Enable shortcode-level decryption. + */ + init({ all, shortcode }: { all?: boolean, shortcode?: boolean }) { + const $content = document.querySelector('#content') + if (shortcode) { + eventBus.on('fixit:decrypted', () => { + this.initShortcodes($content!) + }) + eventBus.on('fixit:partial-decrypted', ({ detail }) => { + this.initShortcodes(detail.target) + }) + } + if (all) { + this.initPage() + } + else if (shortcode) { + this.initShortcodes($content!) + } + } + + /** Initialize whole-page decryption with cache validation and encrypt/re-encrypt buttons. */ + initPage() { + this.validateCache() + const $encryptor = document.querySelector('article > fixit-encryptor')! + const $content = document.querySelector('#content')! + + const decryptorHandler = () => { + this.#validatePassword($encryptor, ($cipherText, passwordHash, salt) => { + window.localStorage?.setItem( + `fixit-decryptor/#${location.pathname}`, + JSON.stringify({ + expiration: Math.ceil(Date.now() / 1000) + this.options.duration, + password: passwordHash, + salt, + }), + ) + this.#decryptContent($cipherText, $content, salt) + }) + } + + $encryptor.querySelector('.fixit-decryptor-input')?.addEventListener('keydown', (e) => { + if ((e as KeyboardEvent).key === 'Enter') { + e.preventDefault() + decryptorHandler() + } + }) + + $encryptor.querySelector('.fixit-decryptor-btn')?.addEventListener('click', (e) => { + e.preventDefault() + decryptorHandler() + }) + + $encryptor.querySelector('.fixit-encryptor-btn')?.addEventListener('click', (e) => { + e.preventDefault() + $encryptor.classList.remove('decrypted') + $content.innerHTML = '' + window.localStorage?.removeItem(`fixit-decryptor/#${location.pathname}`) + eventBus.emit('fixit:re-encrypt') + }) + + $encryptor.classList.add('initialized') + } + + /** + * Initialize decryption for all unprocessed `fixit-encryptor` shortcodes under a parent. + * @param $parent - The parent element to search for shortcodes. + */ + initShortcodes($parent: Element) { + const $shortcodes = $parent.querySelectorAll('fixit-encryptor:not(.initialized)') + + $shortcodes.forEach(($shortcode) => { + const decryptorHandler = () => { + const $content = $shortcode.querySelector('.decryptor-content')! + this.#validatePassword($shortcode, ($cipherText, passwordHash, salt) => { + this.#decryptContent($cipherText, $content, salt) + }) + } + + $shortcode.querySelector('.fixit-decryptor-input')?.addEventListener('keydown', (e) => { + if ((e as KeyboardEvent).key === 'Enter') { + e.preventDefault() + decryptorHandler() + } + }) + + $shortcode.querySelector('.fixit-decryptor-btn')?.addEventListener('click', (e) => { + e.preventDefault() + decryptorHandler() + }) + + $shortcode.classList.add('initialized') + }) + } + + /** Restore decrypted content from localStorage cache if the password has not expired. */ + validateCache() { + const $content = document.querySelector('#content')! + const $encryptor = document.querySelector('article > fixit-encryptor')! + const $cipherText = $encryptor.querySelector('cipher-text')! + const password = $cipherText.dataset.password + const cachedStat: CachedStat | null = JSON.parse(window.localStorage?.getItem(`fixit-decryptor/#${location.pathname}`) || 'null') + + if (!cachedStat || cachedStat.password !== password || cachedStat.expiration < Math.ceil(Date.now() / 1000)) { + if (cachedStat) { + window.localStorage?.removeItem(`fixit-decryptor/#${location.pathname}`) + console.warn('The password has expired, please re-enter!') + } + return this + } + this.#decryptContent($cipherText, $content, cachedStat.salt) + return this + } +} + +window.FixItDecryptor = FixItDecryptor diff --git a/assets/js/lib/giscus.ts b/assets/js/lib/giscus.ts new file mode 100644 index 00000000..ef6761e6 --- /dev/null +++ b/assets/js/lib/giscus.ts @@ -0,0 +1,39 @@ +/** + * Giscus comment system integration for FixIt. + * + * Responsibilities: + * - Setup theme synchronization via postMessage + * - Handle giscus initialization messages + */ +import { eventBus } from '../core/event-bus' +import { isDarkMode } from '../utils' + +document.addEventListener('DOMContentLoaded', () => { + if (!window.config.comment?.giscus) + return + + const giscusConfig = window.config.comment.giscus + + const applyGiscusTheme = (isDark: boolean) => { + const message = { setConfig: { theme: isDark ? giscusConfig.darkTheme : giscusConfig.lightTheme } } + document.querySelector('.giscus-frame')?.contentWindow?.postMessage({ giscus: message }, giscusConfig.origin!) + } + + eventBus.on('fixit:switch-theme', ({ detail }) => { + if (!detail.isChanged) + return + applyGiscusTheme(detail.isDark) + }) + + const messageListener = (event: MessageEvent) => { + if (event.origin !== giscusConfig.origin) + return + const $script = document.querySelector('#giscus>script') + if ($script) { + $script.remove() + } + applyGiscusTheme(isDarkMode()) + window.removeEventListener('message', messageListener, false) + } + window.addEventListener('message', messageListener, false) +}, false) diff --git a/assets/js/lib/gitalk.ts b/assets/js/lib/gitalk.ts new file mode 100644 index 00000000..a171ef2a --- /dev/null +++ b/assets/js/lib/gitalk.ts @@ -0,0 +1,17 @@ +/** + * Gitalk comment system integration for FixIt. + * + * Responsibilities: + * - Initialize Gitalk with configured settings + * - Render Gitalk container + */ + +document.addEventListener('DOMContentLoaded', () => { + if (!window.config.comment?.gitalk || !window.Gitalk) + return + + const gitalkConfig = window.config.comment.gitalk + gitalkConfig.body = decodeURI(window.location.href) + const gitalk = new window.Gitalk(gitalkConfig) + gitalk.render('gitalk') +}, false) diff --git a/assets/js/lib/json-viewer.ts b/assets/js/lib/json-viewer.ts new file mode 100644 index 00000000..032ec009 --- /dev/null +++ b/assets/js/lib/json-viewer.ts @@ -0,0 +1,28 @@ +/** + * JSON Viewer integration for FixIt. + * + * Responsibilities: + * - Apply theme-aware styling to json-viewer custom elements. + * - Sync theme when it changes. + */ +import { eventBus } from '../core/event-bus' +import { isDarkMode } from '../utils' + +function applyJsonViewerTheme(isDark: boolean) { + document.querySelectorAll('json-viewer').forEach(($el: Element) => { + $el.setAttribute('theme', isDark ? 'dark' : 'light') + }) +} + +document.addEventListener('DOMContentLoaded', () => { + if (!window.JsonViewerElement) + return + + applyJsonViewerTheme(isDarkMode()) + + eventBus.on('fixit:switch-theme', ({ detail }) => { + if (!detail.isChanged) + return + applyJsonViewerTheme(detail.isDark) + }) +}, false) diff --git a/assets/js/lib/lightgallery.ts b/assets/js/lib/lightgallery.ts new file mode 100644 index 00000000..1c43eb30 --- /dev/null +++ b/assets/js/lib/lightgallery.ts @@ -0,0 +1,41 @@ +/** + * LightGallery integration for FixIt. + * + * Responsibilities: + * - Initialize lightGallery for page image zoom and thumbnails. + * - Re-initialize on decrypted or partially decrypted content. + */ +import { eventBus } from '../core/event-bus' + +let lg: { destroy: (removeSubModules?: boolean) => void } | undefined + +function initLightGallery() { + if (!window.config.lightgallery || !window.lightGallery) + return + + lg?.destroy(true) + const contentEl = document.getElementById('content') + if (!contentEl) + return + + lg = window.lightGallery(contentEl, { + plugins: [window.lgThumbnail, window.lgZoom], + selector: '.lightgallery', + speed: 400, + hideBarsDelay: 2000, + allowMediaOverlap: true, + exThumbImage: 'data-thumbnail', + toggleThumb: true, + thumbWidth: 80, + thumbHeight: '60px', + actualSize: false, + showZoomInOutIcons: true, + licenseKey: 'none', + }) +} + +document.addEventListener('DOMContentLoaded', () => { + initLightGallery() + eventBus.on('fixit:decrypted', initLightGallery) + eventBus.on('fixit:partial-decrypted', initLightGallery) +}, false) diff --git a/assets/js/lib/mapbox.ts b/assets/js/lib/mapbox.ts new file mode 100644 index 00000000..e7cef9b7 --- /dev/null +++ b/assets/js/lib/mapbox.ts @@ -0,0 +1,88 @@ +/** + * Mapbox GL integration for FixIt shortcode blocks. + * + * Responsibilities: + * - Initialize Mapbox GL maps with controls and optional markers. + * - Apply light/dark style on theme switch. + * - Re-run initialization after decrypted content is revealed. + */ +import { eventBus } from '../core/event-bus' +import { isDarkMode } from '../utils' + +const mapboxArr: any[] = [] + +function initMapbox(target: Element | Document = document) { + const mapboxgl = window.mapboxgl + const MapboxLanguage = window.MapboxLanguage + const config = window.config.mapbox + if (!mapboxgl || !config) + return + if (!mapboxgl.accessToken) { + mapboxgl.accessToken = config.accessToken! + mapboxgl.setRTLTextPlugin(config.RTLTextPlugin!) + } + const isDark = isDarkMode() + target.querySelectorAll('.mapbox:empty').forEach(($mapbox) => { + const { lng, lat, zoom, lightStyle, darkStyle, marked, markers, navigation, geolocate, scale, fullscreen } = JSON.parse($mapbox.dataset.options!) + const mapbox = new mapboxgl.Map({ + container: $mapbox, + center: [lng, lat], + zoom, + minZoom: 0.2, + style: isDark ? darkStyle : lightStyle, + attributionControl: false, + }) + if (marked) { + new mapboxgl.Marker().setLngLat([lng, lat]).addTo(mapbox) + } + const markerArray = typeof markers === 'string' ? JSON.parse(markers) : markers + if (Array.isArray(markerArray) && markerArray.length > 0) { + markerArray.forEach((marker: any) => { + const { lng: markerLng, lat: markerLat, description } = marker + const popup = new mapboxgl.Popup({ offset: 25 }).setText(description) + new mapboxgl.Marker() + .setLngLat([markerLng, markerLat]) + .setPopup(popup) + .addTo(mapbox) + }) + } + if (navigation) { + mapbox.addControl(new mapboxgl.NavigationControl(), 'bottom-right') + } + if (geolocate) { + mapbox.addControl( + new mapboxgl.GeolocateControl({ + positionOptions: { enableHighAccuracy: true }, + showUserLocation: true, + trackUserLocation: true, + }), + 'bottom-right', + ) + } + if (scale) { + mapbox.addControl(new mapboxgl.ScaleControl()) + } + if (fullscreen) { + mapbox.addControl(new mapboxgl.FullscreenControl()) + } + mapbox.addControl(new MapboxLanguage()) + mapboxArr.push(mapbox) + }) +} + +document.addEventListener('DOMContentLoaded', () => { + initMapbox() + eventBus.on('fixit:switch-theme', ({ detail }) => { + if (!detail.isChanged) + return + const isDark = detail.isDark + mapboxArr.forEach((mapbox: any) => { + const $mapbox = mapbox.getContainer() + const { lightStyle, darkStyle } = JSON.parse($mapbox.dataset.options) + mapbox.setStyle(isDark ? darkStyle : lightStyle) + mapbox.addControl(new window.MapboxLanguage()) + }) + }) + eventBus.on('fixit:decrypted', () => initMapbox()) + eventBus.on('fixit:partial-decrypted', ({ detail }) => initMapbox(detail.target)) +}, false) diff --git a/assets/js/lib/mathjax.js b/assets/js/lib/mathjax.js deleted file mode 100644 index ef93387a..00000000 --- a/assets/js/lib/mathjax.js +++ /dev/null @@ -1,44 +0,0 @@ -const params = window.config.mathjax || {} - -/** - * Load MathJax script dynamically - */ -function loadMathJax() { - const script = document.createElement('script') - script.src = params.cdn || 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js' - script.async = true - document.head.appendChild(script) -} - -/** - * Configuring MathJax - * https://docs.mathjax.org/en/latest/options/index.html - */ -window.MathJax = { - tex: { - displayMath: [['\\[', '\\]'], ['$$', '$$']], - inlineMath: [['\\(', '\\)'], ['$', '$']], - packages: { - ...params.packages, - }, - // custom macros - macros: { - // make \KaTeX command work in MathJax - KaTeX: '{K\\kern-.325em\\raise.21em{\\scriptstyle{A}}\\kern-.17em\\TeX}', - ...params.macros, - }, - ...params.tex, - }, - loader: { - ...params.loader, - failed: function (error) { - console.error(`MathJax(${error.package || '?'}): ${error.message}`); - }, - }, - options: { - processHtmlClass: 'content', - ...params.options, - } -} - -loadMathJax() diff --git a/assets/js/lib/mathjax.ts b/assets/js/lib/mathjax.ts new file mode 100644 index 00000000..372f7699 --- /dev/null +++ b/assets/js/lib/mathjax.ts @@ -0,0 +1,77 @@ +/** + * MathJax integration for FixIt. + * + * Responsibilities: + * - Build MathJax global configuration from theme runtime options. + * - Register default delimiters/macros and allow user-level overrides. + * - Load the MathJax runtime script dynamically from configured CDN. + */ +import { eventBus } from '../core/event-bus' + +/** + * Bootstrap MathJax by setting up the global configuration and loading the script. + * @see https://docs.mathjax.org/en/latest/options/index.html + */ +function bootstrapMathJax() { + const params = window.config.mathjax || {} + + const loadMathJax = () => { + const script = document.createElement('script') + script.src = params.cdn || 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js' + script.async = true + document.head.appendChild(script) + } + + const configureMathJax = () => { + window.MathJax = { + tex: { + displayMath: [['\\[', '\\]'], ['$$', '$$']], + inlineMath: [['\\(', '\\)'], ['$', '$']], + packages: { + ...params.packages, + }, + macros: { + // make \KaTeX command work in MathJax + KaTeX: '{K\\kern-.325em\\raise.21em{\\scriptstyle{A}}\\kern-.17em\\TeX}', + ...params.macros, + }, + ...params.tex, + }, + loader: { + ...params.loader, + failed(error: { package?: string, message: string }) { + console.error(`MathJax(${error.package || '?'}): ${error.message}`) + }, + }, + options: { + processHtmlClass: 'content', + ...params.options, + }, + } + } + + loadMathJax() + configureMathJax() +} + +/** + * Trigger MathJax re-typesetting if MathJax is loaded. + */ +function initMathJax(el?: Element) { + const elements = el ? [el] : undefined + if (window.MathJax?.typesetPromise) { + window.MathJax.typesetPromise(elements).then(() => { + // Do something else after typesetting is complete + }).catch((err: Error) => console.warn(err.message)) + } +} + +document.addEventListener('DOMContentLoaded', () => { + bootstrapMathJax() + eventBus.on('fixit:decrypted', () => { + initMathJax() + }) + eventBus.on('fixit:partial-decrypted', ({ detail }) => { + initMathJax(detail.target) + }) +}) diff --git a/assets/js/lib/mermaid.js b/assets/js/lib/mermaid.js deleted file mode 100644 index ce0ad8a3..00000000 --- a/assets/js/lib/mermaid.js +++ /dev/null @@ -1,698 +0,0 @@ -{{- /* Page level params is not supported */ -}} -{{- $mermaid := .Site.Params.mermaid -}} -{{- $mermaidCDN := $mermaid.cdn | default "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs" -}} - -import mermaid from "{{ $mermaidCDN }}" -{{- with $mermaid.zenuml }} -import zenuml from "{{ . }}" -{{- end }} -{{- $loadersArr := slice }} -{{- range $i, $loaders := $mermaid.layoutloaders }} -import loaders{{ $i }} from "{{ $loaders }}" -{{- $loadersArr = $loadersArr | append (printf "loaders%d" $i) }} -{{- end }} -{{- if $mermaid.zenuml }} -await mermaid.registerExternalDiagrams([zenuml]) -{{- end }} -const loaders = [] -for(const item of {{ $loadersArr }}) { - if(Array.isArray(item)) { - loaders.push(...item) - } else { - loaders.push(item) - } -} -mermaid.registerLayoutLoaders(loaders) -mermaid.startOnLoad = false; -const config = {{ $mermaid | jsonify }} -let tabContainerEventBound = false -let mermaidThemeRerenderBound = false -let mermaidContainerObserver = null -let mermaidRenderLock = Promise.resolve() -let mermaidConfigKey = '' -let mermaidIdSeq = 0 -// Panzoom states -let panzoomInstances = null -let panzoomWheelHosts = null -let panzoomEventBound = false -let mermaidThemeSyncBound = false -let mermaidPanzoomGroups = null - -/** - * Waits for the next animation frame. - * @returns {Promise} - */ -function nextFrame() { - return new Promise((resolve) => requestAnimationFrame(resolve)) -} - -/** - * Gets (and initializes) the panzoom shared state for a Mermaid diagram container. - * - * The group is used to keep zoom/pan transform in sync across theme layers - * (e.g. `.mermaid` vs `.mermaid-dark`) within the same `.diagram-container`. - * - * @param {SVGElement} svg - * @returns {Object} - */ -function getMermaidPanzoomGroup(svg) { - if (!svg?.closest?.('.diagram-view')) return null - const container = svg?.closest?.('.diagram-container') - if (!container) return null - if (!mermaidPanzoomGroups) mermaidPanzoomGroups = new WeakMap() - let group = mermaidPanzoomGroups.get(container) - if (!group) { - group = { container, transform: null, svgs: new Set(), lastSvg: null } - mermaidPanzoomGroups.set(container, group) - } - const svgs = container.querySelectorAll('.mermaid svg, .mermaid-dark svg') - svgs.forEach((s) => group.svgs.add(s)) - return group -} - -/** - * Compares two pan/zoom transforms with a small tolerance. - * Used to avoid redundant sync work (e.g. repeated theme switches / re-renders). - * - * @param {Object} a - PanzoomTransform object. - * @param {Object} b - PanzoomTransform object. - * @returns {boolean} - */ -function isSamePanzoomTransform(a, b) { - if (!a || !b) return false - const eps = 1e-3 - return ( - Math.abs(a.x - b.x) <= eps && - Math.abs(a.y - b.y) <= eps && - Math.abs(a.scale - b.scale) <= eps - ) -} - -/** - * Applies a pan/zoom transform to a Panzoom instance. - * - * Reads the current state via `getPan()` and `getScale()` and skips if it is already - * effectively equal to `transform` (see `isSamePanzoomTransform`). - * - * Note: the pan step is deferred to the next macrotask so the zoom can be applied first. - * - * @param {Object} instance - Panzoom instance with zoom/pan/getPan/getScale methods. - * @param {PanzoomTransform} transform - * @returns {void} - */ -function applyPanzoomTransform(instance, transform) { - if (!instance || !transform) return - const currentTransform = { - scale: instance.getScale(), - ...instance.getPan(), - } - if (isSamePanzoomTransform(currentTransform, transform)) return - instance.zoom(transform.scale, { animate: false, force: true }) - setTimeout(() => instance.pan(transform.x, transform.y, { animate: false, force: true })) -} - -/** - * Schedule task at idle time to avoid blocking critical rendering. - * @param {Function} task - * @param {number} [timeout=0] - Timeout in milliseconds for the task to run. - */ -function runWhenIdle(task, timeout = 0) { - if (typeof window.requestIdleCallback === 'function') { - window.requestIdleCallback(task, { timeout }) - return - } - window.setTimeout(task, 80) -} - -/** - * Checks whether an element is in the viewport (vertically). - * @param {Element} node - * @returns {boolean} - */ -function isNodeInViewport(node) { - const rect = node.getBoundingClientRect() - return rect.top < window.innerHeight && rect.bottom > 0 -} - -/** - * Checks whether a node is currently visible and should be rendered first. - * @param {Element} node - * @returns {boolean} - */ -function isNodeVisuallyActive(node) { - if (!isRenderContextActive(node)) return false - const nodeStyle = getComputedStyle(node) - if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden') return false - const rect = node.getBoundingClientRect() - return rect.width > 0 && rect.height > 0 -} - -/** - * Checks whether an element is inside an active renderable context. - * - * This is stricter than "is in the DOM": it excludes content hidden by - * tab panels, diagram/code toggles, or common hidden containers. - * - * @param {Element} el - * @returns {boolean} - */ -function isRenderContextActive(el) { - if (!el?.isConnected) return false - if (el.closest?.('[hidden], .d-none, [aria-hidden="true"]')) return false - const diagramView = el.closest?.('.diagram-view') - if (diagramView?.closest?.('.diagram-tabs') && !diagramView.classList.contains('active')) return false - const panel = el.closest?.('.tab-panel') - if (panel?.hidden) return false - return true -} - -/** - * Check whether the current effective theme is dark. - * In auto mode, this follows the system color scheme preference. - * @returns {boolean} Whether dark mode is currently active. - */ -function isDarkMode() { - const themeMode = document.documentElement.dataset.themeMode || 'auto'; - return themeMode === 'auto' - ? window.matchMedia('(prefers-color-scheme: dark)').matches - : themeMode === 'dark'; -} - -/** - * Gets Mermaid theme name for the current effective color scheme. - * `.mermaid` uses themes[0] (default), `.mermaid-dark` uses themes[1] (dark). - * @returns {string} - */ -function getTheme() { - return isDarkMode() ? config.themes[1] : config.themes[0] -} - -/** - * Mermaid uses global runtime config; `initialize()` changes subsequent `render()` behavior. - * This helper serializes all render work to avoid concurrent initialize/render cross-talk. - * @param {Function} task - * @returns {Promise} - */ -function withMermaidLock(task) { - mermaidRenderLock = mermaidRenderLock.then(task, task) - return mermaidRenderLock -} - -/** - * Initializes Mermaid only when (theme, darkMode) changes to reduce work and keep it stable. - * @param {string} theme - * @param {boolean} darkMode - */ -function ensureMermaidInitialized(theme, darkMode) { - const key = `${String(theme)}|${darkMode ? '1' : '0'}` - if (key === mermaidConfigKey) return - mermaidConfigKey = key - mermaid.initialize({ - startOnLoad: false, - darkMode, - theme, - securityLevel: config.securitylevel, - look: config.look, - layout: config.layout, - fontFamily: config.fontfamily, - altFontFamily: config.fontfamily - }) -} - -/** - * Renders a single Mermaid host element (`.mermaid`, `.mermaid-dark`, `.mermaid-neutral`). - * - * Notes: - * - Source uses `textContent` to avoid HTML entities (e.g. `>`) breaking Mermaid parsing. - * - Uses `data-processing` / `data-processed` as state flags. - * - Emits `fixit:mermaid-rendered` to hook Panzoom and other post-render behaviors. - * - * @param {Element} el - * @param {Object} options - * @param {string} options.theme - * @param {boolean} options.darkMode - */ -async function renderMermaidElement(el, { theme, darkMode } = {}) { - if (!el || el.hasAttribute('data-processed') || el.hasAttribute('data-processing')) return - if (!isRenderContextActive(el)) return - const source = el.textContent.trim() - if (!source) { - el.setAttribute('data-processed', '') - return - } - - await withMermaidLock(async () => { - if (!el || el.hasAttribute('data-processed') || !isRenderContextActive(el)) return - if (!el.hasAttribute('data-processing')) el.setAttribute('data-processing', '') - ensureMermaidInitialized(theme, darkMode) - const id = `fixit-mermaid-${++mermaidIdSeq}` - try { - const result = await mermaid.render(id, source) - const svg = result?.svg || '' - el.innerHTML = svg - if (typeof result?.bindFunctions === 'function') { - try { result.bindFunctions(el) } catch {} - } - const svgEl = el.querySelector('svg') - if (svgEl && !svgEl.id) svgEl.id = id - el.removeAttribute('data-processing') - el.setAttribute('data-processed', '') - const svgId = svgEl?.id || id - if (svgId) { - document.dispatchEvent(new CustomEvent('fixit:mermaid-rendered', { detail: { svgId } })) - } - } catch (e) { - const errDiv = document.getElementById(`d${id}`) - if (errDiv) { - el.innerHTML = errDiv.innerHTML - errDiv.remove() - } - el.removeAttribute('data-processing') - el.setAttribute('data-processed', '') - console.warn('FixIt Mermaid render failed:', e) - } - }) -} - -function collectMermaidContainers(root = document) { - return Array.from(root.querySelectorAll('.diagram-container')) - .filter((container) => container && container.querySelector('.mermaid, .mermaid-dark, .mermaid-neutral')) -} - -/** - * Core rule: render only when the active layer is visible and not rendered yet. - * - "Active layer" means `.mermaid` in light mode or `.mermaid-dark` in dark mode. - * - We treat presence of a rendered `` as "already rendered" even if flags are missing. - */ -async function renderActiveLayerInContainer(container) { - if (!container) return - const darkMode = isDarkMode() - const el = container.querySelector(darkMode ? '.mermaid-dark' : '.mermaid') - if (!el) return - if (el.hasAttribute('data-processed') || el.querySelector('svg')) return - if (!isNodeVisuallyActive(el)) return - await renderMermaidElement(el, { theme: getTheme(), darkMode }) -} - -/** - * Neutral theme is not part of the live light/dark switching UI. - * Render it in idle time for print / fallback scenarios. - */ -function scheduleNeutralRender() { - if (window.matchMedia('only screen and (max-width: 960px)').matches) return - runWhenIdle(() => { - const neutralNodes = Array.from(document.querySelectorAll('.mermaid-neutral')) - .filter((el) => el && !el.hasAttribute('data-processed')) - .filter(isRenderContextActive) - neutralNodes.forEach((el) => { - renderMermaidElement(el, { theme: 'neutral', darkMode: false }) - }) - }) -} - -/** - * Lazy rendering via IntersectionObserver. - * When a `.diagram-container` gets close to viewport, attempt to render its active layer. - */ -function bindMermaidIntersectionObserver() { - if (mermaidContainerObserver) return - mermaidContainerObserver = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return - const container = entry.target - if (!container || !isRenderContextActive(container)) return - renderActiveLayerInContainer(container) - }) - }, { - root: null, - // 200px margin to allow partial visibility - rootMargin: '200px 0px', - threshold: 0.01, - }) -} - -/** - * Registers all Mermaid containers under root into the observer. - * Idempotent via `data-mermaid-observed`. - */ -function observeMermaidContainers(root = document, refresh = false) { - bindMermaidIntersectionObserver() - const containers = collectMermaidContainers(root) - containers.forEach((container) => { - if (!container || container.hasAttribute('data-mermaid-observed')) return - container.setAttribute('data-mermaid-observed', '') - mermaidContainerObserver.observe(container) - }) - if (!refresh) return - containers.forEach((container) => { - if (!container) return - mermaidContainerObserver.unobserve(container) - mermaidContainerObserver.observe(container) - }) -} - -/** - * Gets the currently visible SVG for a diagram block that contains multiple Mermaid layers. - * @param {Element} root - * @returns {SVGElement|null} - */ -function getActiveMermaidSvg(root) { - const nodes = root?.querySelectorAll?.('.mermaid, .mermaid-dark, .mermaid-neutral') - if (!nodes?.length) return null - const visible = Array.from(nodes).find((el) => { - const style = getComputedStyle(el) - if (style.display === 'none' || style.visibility === 'hidden') return false - const rect = el.getBoundingClientRect() - return rect.width > 0 && rect.height > 0 - }) - return visible?.querySelector?.('svg') || null -} - -/** - * Enables pan/zoom for a Mermaid SVG inside `.diagram-view`. - * Requires `window.Panzoom`. - * - * @param {SVGElement} svg - * @returns {void} - */ -function bindMermaidPanzoom(svg) { - if (!svg) return - if (!svg.closest?.('.diagram-view')) return - if (!svg.closest?.('.mermaid, .mermaid-dark')) return - if (!panzoomInstances) panzoomInstances = new WeakMap() - if (!panzoomWheelHosts) panzoomWheelHosts = new WeakSet() - if (panzoomInstances.has(svg)) return - if (typeof window.Panzoom !== 'function') return - - const group = getMermaidPanzoomGroup(svg) - - const panzoom = window.Panzoom(svg, { - maxScale: 6, - minScale: 0.2, - step: 0.1, - }) - panzoomInstances.set(svg, panzoom) - - if (group?.transform) { - applyPanzoomTransform(panzoom, group.transform) - } - - const host = svg.closest('.mermaid, .mermaid-dark') - if (!host) return - if (panzoomWheelHosts.has(host)) return - panzoomWheelHosts.add(host) - - host.addEventListener('pointerdown', () => { - const currentSvg = host.querySelector('svg') - if (group && currentSvg) group.lastSvg = currentSvg - }) - - host.addEventListener('wheel', (event) => { - if (!event.ctrlKey) return - const currentSvg = host.querySelector('svg') - const instance = currentSvg && panzoomInstances.get(currentSvg) - if (!instance) return - if (group && currentSvg) group.lastSvg = currentSvg - event.preventDefault() - instance.zoomWithWheel(event) - }, { passive: false }) -} - -/** - * Initializes diagram tabs (`.diagram-tabs[data-diagram="mermaid"]`) controls: - * - Diagram/Code tab switching - * - Zoom/reset/download buttons wiring - * - Ensures Mermaid renders when the diagram tab is active - */ -function initDiagramControls() { - document.querySelectorAll('.diagram-tabs[data-diagram="mermaid"]:not([data-diagram-init])').forEach((tabs) => { - const actions = tabs.querySelector('.tabs-actions') - const diagramTabBtn = tabs.querySelector('.tab-item[data-tab="diagram"]') - const codeTabBtn = tabs.querySelector('.tab-item[data-tab="code"]') - const diagramBlock = tabs.querySelector('.diagram-view') - const codeBlock = Array.from(tabs.querySelectorAll('.tabs-content > .code-block')) - .find((block) => !block.classList.contains('diagram-view')) - if (!actions || !diagramTabBtn || !codeTabBtn || !diagramBlock || !codeBlock) return - - const zoomInBtn = tabs.querySelector('.diagram-zoom-in-btn') - const zoomOutBtn = tabs.querySelector('.diagram-zoom-out-btn') - const resetBtn = tabs.querySelector('.diagram-reset-btn') - const downloadBtn = tabs.querySelector('.diagram-download-btn') - tabs.dataset.diagramInit = 'true' - - const diagramActionButtons = [zoomOutBtn, zoomInBtn, resetBtn, downloadBtn].filter(Boolean) - - const movedActionRestore = new WeakMap() - let codeActionCache = [] - - const moveToActions = (elements) => { - elements.forEach((el) => { - if (!el || el.parentElement === actions) return - movedActionRestore.set(el, { parent: el.parentElement, next: el.nextSibling }) - if (pinnedFullscreenBtn && pinnedFullscreenBtn.parentElement === actions) { - actions.insertBefore(el, pinnedFullscreenBtn) - } else { - actions.appendChild(el) - } - }) - } - - const restoreMoved = (elements) => { - elements.forEach((el) => { - const restore = movedActionRestore.get(el) - if (!restore?.parent) return - if (restore.next && restore.next.parentNode === restore.parent) { - restore.parent.insertBefore(el, restore.next) - } else { - restore.parent.appendChild(el) - } - movedActionRestore.delete(el) - }) - } - - const pinnedFullscreenBtn = codeBlock.querySelector('.code-header .fullscreen-btn') - if (pinnedFullscreenBtn && pinnedFullscreenBtn.parentElement !== actions) { - actions.appendChild(pinnedFullscreenBtn) - } - - const collectCodeActions = () => { - const codeHeader = codeBlock.querySelector('.code-header') - if (codeHeader) { - return Array.from(codeHeader.querySelectorAll('.action-btn')) - .filter((btn) => btn !== pinnedFullscreenBtn) - } - return Array.from(codeBlock.querySelectorAll('.copy-icon-btn')) - } - - const getCodeActions = () => { - if (codeActionCache.length && codeActionCache.some((el) => el.isConnected)) return codeActionCache - codeActionCache = collectCodeActions() - return codeActionCache - } - - const setDiagramActionsVisible = (visible) => { - diagramActionButtons.forEach((btn) => { - btn.style.display = visible ? '' : 'none' - }) - } - - const switchTo = (target) => { - const showDiagram = target === 'diagram' - diagramTabBtn.classList.toggle('active', showDiagram) - codeTabBtn.classList.toggle('active', !showDiagram) - diagramBlock.classList.toggle('active', showDiagram) - codeBlock.classList.toggle('active', !showDiagram) - const codeActions = getCodeActions() - if (showDiagram) { - setDiagramActionsVisible(true) - restoreMoved(codeActions) - // observeMermaidContainers(tabs, true) - } else { - setDiagramActionsVisible(false) - moveToActions(codeActions) - } - } - - const resolvePanzoom = () => { - const svg = getActiveMermaidSvg(diagramBlock) - return svg && panzoomInstances ? panzoomInstances.get(svg) : null - } - - diagramTabBtn.addEventListener('click', () => switchTo('diagram')) - codeTabBtn.addEventListener('click', () => switchTo('code')) - - zoomInBtn?.addEventListener('click', () => { - const panzoom = resolvePanzoom() - if (!panzoom?.zoomIn) return - try { panzoom.zoomIn({ animate: true }) } catch { panzoom.zoomIn() } - }) - zoomOutBtn?.addEventListener('click', () => { - const panzoom = resolvePanzoom() - if (!panzoom?.zoomOut) return - try { panzoom.zoomOut({ animate: true }) } catch { panzoom.zoomOut() } - }) - resetBtn?.addEventListener('click', () => { - const panzoom = resolvePanzoom() - if (!panzoom?.reset) return - try { panzoom.reset({ animate: true }) } catch { panzoom.reset() } - }) - downloadBtn?.addEventListener('click', () => { - const svg = getActiveMermaidSvg(diagramBlock) - if (!svg) return - const clonedSvg = svg.cloneNode(true) - // remove style attribute (transform etc.) that may interfere with proper rendering in external viewers - clonedSvg.removeAttribute('style') - const xml = new XMLSerializer().serializeToString(clonedSvg) - const blob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = `${diagramBlock.dataset.filename.split('.')[0]}.svg` - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) - }) - }) -} - -/** - * Binds a listener to attach Panzoom after Mermaid renders an SVG. - * @returns {void} - */ -function bindRenderedPanzoom() { - if (panzoomEventBound) return - panzoomEventBound = true - document.addEventListener('fixit:mermaid-rendered', (event) => { - const svgId = event?.detail?.svgId - if (!svgId) return - bindMermaidPanzoom(document.getElementById(svgId)) - }) -} - -/** - * Binds a listener so Mermaid diagrams inside a tab panel render after tab switches. - * @returns {void} - */ -function bindTabContainerChanged() { - if (tabContainerEventBound) return - tabContainerEventBound = true - document.addEventListener('tab-container-changed', async (event) => { - const panel = event?.panel || event?.detail?.relatedTarget - if (!panel) return - // Wait for layout/visibility to settle after tab switch before measuring visibility. - await nextFrame() - await nextFrame() - observeMermaidContainers(panel, true) - }) -} - -/** - * Binds theme switch synchronization for Panzoom transforms across Mermaid layers. - * @returns {void} - */ -function bindThemeSync() { - if (mermaidThemeSyncBound) return - mermaidThemeSyncBound = true - let lastEffectiveDark = isDarkMode() - - const getActivePanzoomSvg = (container) => { - const nodes = container?.querySelectorAll?.('.mermaid, .mermaid-dark') - if (!nodes?.length) return null - const visible = Array.from(nodes).find((el) => { - const style = getComputedStyle(el) - if (style.display === 'none' || style.visibility === 'hidden') return false - const rect = el.getBoundingClientRect() - return rect.width > 0 && rect.height > 0 - }) - return visible?.querySelector?.('svg') || null - } - - const safeGetTransformFromSvg = (svg) => { - const instance = svg && panzoomInstances?.get?.(svg) - if (instance?.getPan && instance?.getScale) { - try { - const pan = instance.getPan() - const scale = instance.getScale() - if (pan && typeof scale === 'number') return { x: pan.x, y: pan.y, scale } - } catch {} - } - return null - } - - const sync = (nowDark) => { - if (!mermaidPanzoomGroups) return - document.querySelectorAll('.diagram-view .diagram-container').forEach((container) => { - const currentActive = getActivePanzoomSvg(container) - const fallbackSource = nowDark - ? container.querySelector('.mermaid svg') - : container.querySelector('.mermaid-dark svg') - - const groupSeed = currentActive || fallbackSource - if (!groupSeed) return - const group = getMermaidPanzoomGroup(groupSeed) - if (!group) return - - const last = group?.lastSvg - const sourceSvg = last?.isConnected ? last : (fallbackSource || currentActive) - const sourceTransform = safeGetTransformFromSvg(sourceSvg) - if (sourceTransform) group.transform = sourceTransform - - if (!group.transform) return - - if (currentActive) { - const instance = panzoomInstances?.get?.(currentActive) - applyPanzoomTransform(instance, group.transform) - } - }) - } - - if (window.fixit?.switchThemeEventSet?.add) { - window.fixit.switchThemeEventSet.add(async (isDark) => { - if (isDark === lastEffectiveDark) return - lastEffectiveDark = isDark - await nextFrame() - await nextFrame() - sync(isDark) - }) - } -} - -/** - * Initializes Mermaid rendering, events, lazy loading and diagram controls. - * @returns {Promise} - */ -async function init() { - const mermaidElements = document.querySelectorAll('.mermaid, .mermaid-dark, .mermaid-neutral') - if (!mermaidElements.length) return - console.log( - `%c💫 FixIt Mermaid`, - 'color: #FF3670; font-weight: bold; font-size: 16px; text-shadow: 1px 1px 2px rgba(0,0,0,0.1);', - ) - - initDiagramControls() - bindRenderedPanzoom() - bindThemeSync() - bindTabContainerChanged() - - // Core: observe containers and render only when they become visible. - observeMermaidContainers() - // Neutral is non-critical, defer to idle time. - scheduleNeutralRender() - - if (!mermaidThemeRerenderBound && window.fixit?.switchThemeEventSet?.add) { - mermaidThemeRerenderBound = true - window.fixit.switchThemeEventSet.add(async () => { - await nextFrame() - await nextFrame() - // Theme switch doesn't change container intersection, so refresh IO to trigger callbacks. - observeMermaidContainers(document, true) - }) - } -} - -window.FixItMermaid = { - config, - init, -} -window.mermaid = mermaid diff --git a/assets/js/lib/mermaid.ts b/assets/js/lib/mermaid.ts new file mode 100644 index 00000000..c438f7a3 --- /dev/null +++ b/assets/js/lib/mermaid.ts @@ -0,0 +1,904 @@ +/** + * Mermaid runtime integration for FixIt. + * + * This module is responsible for rendering and maintaining Mermaid diagrams after + * bootstrapMermaid() loads Mermaid and optional extensions dynamically. + * + * Main responsibilities: + * - Lazy render Mermaid blocks via IntersectionObserver to reduce initial cost. + * - Render only the currently active diagram layer (light/dark), plus idle-time + * neutral rendering for fallback/print scenarios. + * - Keep Mermaid global initialize() calls serialized to avoid concurrent + * initialize/render cross-talk. + * - Bind pan/zoom behavior and synchronize transforms across light/dark layers + * during theme switches. + * - Wire diagram tab controls (diagram/code switch, zoom/reset/download actions). + * - React to FixIt events (theme switch, decrypted content, partial decrypted content) + * and re-observe/re-render affected containers when context changes. + * + * Public entrypoints: + * - initMermaidRuntime(runtime): register loaded runtime dependencies and bind listeners. + * - bootstrapMermaid(options): dynamic-import bootstrap entry used by template script. + */ +import type { + MermaidConfig, + MermaidRuntime, + MermaidRuntimeModule, + PanzoomInstance, + PanzoomTransform, + TabContainerChangedEvent, +} from '../types' +import { eventBus } from '../core/event-bus' +import { isDarkMode } from '../utils' + +interface MermaidPanzoomGroup { + container: Element + transform: PanzoomTransform | null + svgs: Set + lastSvg: SVGElement | null +} + +interface RenderOptions { + theme: string + darkMode: boolean +} + +interface MermaidBootstrapOptions { + mermaidSource: string + zenumlSource?: string + layoutLoaderSources?: string[] + config: MermaidConfig +} + +let mermaid: MermaidRuntimeModule | null = null +let config: MermaidConfig = {} +let hasBoundGlobalEvents = false +let mermaidContainerObserver: IntersectionObserver | null = null +let mermaidRenderLock: Promise = Promise.resolve() +let mermaidConfigKey = '' +let mermaidIdSeq = 0 + +let panzoomInstances: WeakMap | null = null +let panzoomWheelHosts: WeakSet | null = null +let mermaidPanzoomGroups: WeakMap | null = null + +/** + * Gets (and initializes) the pan/zoom shared state for a Mermaid diagram container. + * The group keeps transforms in sync across `.mermaid` and `.mermaid-dark` layers. + * @param svg Mermaid SVG element inside a diagram container. + * @returns Shared pan/zoom group state, or null when element is out of supported context. + */ +function getMermaidPanzoomGroup(svg: SVGElement): MermaidPanzoomGroup | null { + if (!svg.closest('.diagram-view')) + return null + const container = svg.closest('.diagram-container') + if (!container) + return null + + if (!mermaidPanzoomGroups) + mermaidPanzoomGroups = new WeakMap() + + let group = mermaidPanzoomGroups.get(container) + if (!group) { + group = { container, transform: null, svgs: new Set(), lastSvg: null } + mermaidPanzoomGroups.set(container, group) + } + + const svgs = container.querySelectorAll('.mermaid svg, .mermaid-dark svg') + svgs.forEach((item) => { + group?.svgs.add(item) + }) + + return group +} + +/** + * Compares pan/zoom transforms with a small tolerance. + * Used to avoid redundant sync work during repeated theme switches / re-renders. + * @param a Source transform. + * @param b Target transform. + * @returns Whether two transforms are effectively the same. + */ +function isSamePanzoomTransform(a: PanzoomTransform | null, b: PanzoomTransform | null): boolean { + if (!a || !b) + return false + + const eps = 1e-3 + return ( + Math.abs(a.x - b.x) <= eps + && Math.abs(a.y - b.y) <= eps + && Math.abs(a.scale - b.scale) <= eps + ) +} + +/** + * Applies a pan/zoom transform to an instance. + * The pan step is deferred to the next macrotask so zoom can settle first. + * @param instance Panzoom instance. + * @param transform Target transform. + */ +function applyPanzoomTransform(instance: PanzoomInstance | null | undefined, transform: PanzoomTransform | null): void { + if (!instance || !transform) + return + + const currentTransform: PanzoomTransform = { + scale: instance.getScale(), + ...instance.getPan(), + } + if (isSamePanzoomTransform(currentTransform, transform)) + return + + instance.zoom(transform.scale, { animate: false, force: true }) + setTimeout(() => instance.pan(transform.x, transform.y, { animate: false, force: true })) +} + +/** + * Schedule low-priority work without blocking critical rendering. + * @param task Deferred task callback. + * @param timeout Timeout in milliseconds when browser supports requestIdleCallback. + */ +function runWhenIdle(task: () => void, timeout = 0): void { + if (typeof window.requestIdleCallback === 'function') { + window.requestIdleCallback(task, { timeout }) + return + } + window.setTimeout(task, 80) +} + +/** + * Checks whether a node is currently visible and should be rendered first. + * @param node Candidate Mermaid host element. + * @returns Whether node is currently visible and renderable. + */ +function isNodeVisuallyActive(node: Element): boolean { + if (!isRenderContextActive(node)) + return false + + const nodeStyle = getComputedStyle(node) + if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden') + return false + + const rect = node.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 +} + +/** + * Checks whether an element is inside an active renderable context. + * This excludes hidden tab panels / diagram layers and common hidden containers. + * @param el Candidate element. + * @returns Whether element is active in current render context. + */ +function isRenderContextActive(el: Element): boolean { + if (!el.isConnected) + return false + if (el.closest('[hidden], .d-none, [aria-hidden="true"]')) + return false + + const diagramView = el.closest('.diagram-view') + if (diagramView?.closest('.diagram-tabs') && !diagramView.classList.contains('active')) + return false + + const panel = el.closest('.tab-panel') + if (panel instanceof HTMLElement && panel.hidden) + return false + + return true +} + +/** + * Gets Mermaid theme name for the current effective color scheme. + * @returns Current Mermaid theme. + */ +function getTheme(): string { + const themes = config.themes ?? ['default', 'dark'] + const lightTheme = themes[0] ?? 'default' + const darkTheme = themes[1] ?? lightTheme + return isDarkMode() ? darkTheme : lightTheme +} + +/** + * Mermaid uses global runtime config; initialize() affects subsequent render(). + * This lock serializes all render work to avoid concurrent initialize/render cross-talk. + * @param task Async render task. + * @returns Task result. + */ +function withMermaidLock(task: () => Promise): Promise { + const run = mermaidRenderLock.then(task, task) + // Keep the chain alive even after errors so subsequent renders are not blocked. + mermaidRenderLock = run.then( + () => undefined, + () => undefined, + ) + return run +} + +/** + * Initializes Mermaid only when (theme, darkMode) changes. + * @param theme Mermaid theme name. + * @param darkMode Whether dark mode is currently active. + */ +function ensureMermaidInitialized(theme: string, darkMode: boolean): void { + if (!mermaid) + return + + const key = `${String(theme)}|${darkMode ? '1' : '0'}` + if (key === mermaidConfigKey) + return + + // Mermaid initialize() mutates global runtime behavior for following render() calls. + mermaidConfigKey = key + mermaid.initialize({ + startOnLoad: false, + darkMode, + theme, + securityLevel: config.securitylevel, + look: config.look, + layout: config.layout, + fontFamily: config.fontfamily, + altFontFamily: config.fontfamily, + }) +} + +/** + * Renders a single Mermaid host element (`.mermaid`, `.mermaid-dark`, `.mermaid-neutral`). + * Source uses textContent to avoid HTML entities breaking Mermaid parsing. + * @param el Mermaid host element. + * @param options Render options with theme and dark mode. + */ +async function renderMermaidElement(el: Element | null, options: RenderOptions): Promise { + if (!el || !mermaid || el.hasAttribute('data-processed') || el.hasAttribute('data-processing')) + return + if (!isRenderContextActive(el)) + return + + const source = el.textContent?.trim() ?? '' + if (!source) { + el.setAttribute('data-processed', '') + return + } + + await withMermaidLock(async () => { + if (!el || !mermaid || el.hasAttribute('data-processed') || !isRenderContextActive(el)) + return + + // Mark processing to prevent duplicate renders from intersect/theme/tab events. + if (!el.hasAttribute('data-processing')) + el.setAttribute('data-processing', '') + + ensureMermaidInitialized(options.theme, options.darkMode) + + const id = `fixit-mermaid-${++mermaidIdSeq}` + try { + const result = await mermaid.render(id, source) + const svg = result?.svg ?? '' + el.innerHTML = svg + if (typeof result?.bindFunctions === 'function') { + try { + result.bindFunctions(el) + } + catch { + // Ignore bind errors from third-party diagrams. + } + } + + const svgEl = el.querySelector('svg') + if (svgEl && !svgEl.id) + svgEl.id = id + + el.removeAttribute('data-processing') + el.setAttribute('data-processed', '') + + const svgId = svgEl?.id ?? id + if (svgId) { + const renderedSvg = document.getElementById(svgId) + if (renderedSvg instanceof SVGElement) + bindMermaidPanzoom(renderedSvg) + } + } + catch (error) { + const errDiv = document.getElementById(`d${id}`) + if (errDiv) { + el.innerHTML = errDiv.innerHTML + errDiv.remove() + } + el.removeAttribute('data-processing') + el.setAttribute('data-processed', '') + console.warn('FixIt Mermaid render failed:', error) + } + }) +} + +/** + * Collect all diagram containers that contain Mermaid elements. + * @param root Search root. + * @returns Diagram containers that contain Mermaid blocks. + */ +function collectMermaidContainers(root: ParentNode = document): HTMLElement[] { + return Array.from(root.querySelectorAll('.diagram-container')) + .filter(container => container.querySelector('.mermaid, .mermaid-dark, .mermaid-neutral')) +} + +/** + * Core rule: render only when the active layer is visible and not rendered yet. + * Active layer is `.mermaid` in light mode or `.mermaid-dark` in dark mode. + * @param container Diagram container. + */ +async function renderActiveLayerInContainer(container: Element | null): Promise { + if (!container) + return + + const darkMode = isDarkMode() + const el = container.querySelector(darkMode ? '.mermaid-dark' : '.mermaid') + if (!el) + return + if (el.hasAttribute('data-processed') || el.querySelector('svg')) + return + if (!isNodeVisuallyActive(el)) + return + + await renderMermaidElement(el, { theme: getTheme(), darkMode }) +} + +/** + * Neutral theme is not part of live light/dark switching UI. + * Render it in idle time for print / fallback scenarios. + */ +function scheduleNeutralRender(): void { + if (window.matchMedia('only screen and (max-width: 960px)').matches) + return + + runWhenIdle(() => { + const neutralNodes = Array.from(document.querySelectorAll('.mermaid-neutral')) + .filter(el => !el.hasAttribute('data-processed')) + .filter(isRenderContextActive) + + neutralNodes.forEach((el) => { + void renderMermaidElement(el, { theme: 'neutral', darkMode: false }) + }) + }) +} + +/** Lazy rendering via IntersectionObserver. */ +function bindMermaidIntersectionObserver(): void { + if (mermaidContainerObserver) + return + + mermaidContainerObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) + return + const container = entry.target + if (!container || !isRenderContextActive(container)) + return + void renderActiveLayerInContainer(container) + }) + }, { + root: null, + // 200px preloading margin lets diagrams render slightly before entering viewport. + rootMargin: '200px 0px', + threshold: 0.01, + }) +} + +/** + * Registers Mermaid containers under root into the observer. + * @param root Search root. + * @param refresh Whether to re-observe matched containers. + */ +function observeMermaidContainers(root: ParentNode = document, refresh = false): void { + bindMermaidIntersectionObserver() + if (!mermaidContainerObserver) + return + + const containers = collectMermaidContainers(root) + containers.forEach((container) => { + if (container.hasAttribute('data-mermaid-observed')) + return + container.setAttribute('data-mermaid-observed', '') + mermaidContainerObserver?.observe(container) + }) + + if (!refresh) + return + + // Force observer callback refresh after context switches (tab/theme/decrypt events). + containers.forEach((container) => { + mermaidContainerObserver?.unobserve(container) + mermaidContainerObserver?.observe(container) + }) +} + +/** + * Gets the currently visible SVG for a diagram block with multiple Mermaid layers. + * @param root Diagram block root element. + * @returns Visible Mermaid SVG, or null. + */ +function getActiveMermaidSvg(root: Element | null): SVGElement | null { + const nodes = root?.querySelectorAll('.mermaid, .mermaid-dark, .mermaid-neutral') + if (!nodes?.length) + return null + + const visible = Array.from(nodes).find((el) => { + const style = getComputedStyle(el) + if (style.display === 'none' || style.visibility === 'hidden') + return false + const rect = el.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + }) + + return visible?.querySelector('svg') ?? null +} + +/** + * Enables pan/zoom for a Mermaid SVG inside `.diagram-view`. + * @param svg Mermaid SVG element. + */ +function bindMermaidPanzoom(svg: SVGElement): void { + if (!svg.closest('.diagram-view')) + return + if (!svg.closest('.mermaid, .mermaid-dark')) + return + + if (!panzoomInstances) + panzoomInstances = new WeakMap() + if (!panzoomWheelHosts) + panzoomWheelHosts = new WeakSet() + + if (panzoomInstances.has(svg)) + return + if (typeof window.Panzoom !== 'function') + return + + const group = getMermaidPanzoomGroup(svg) + const panzoom = window.Panzoom(svg, { + maxScale: 6, + minScale: 0.2, + step: 0.1, + }) + panzoomInstances.set(svg, panzoom) + + if (group?.transform) + applyPanzoomTransform(panzoom, group.transform) + + const host = svg.closest('.mermaid, .mermaid-dark') + if (!host) + return + if (panzoomWheelHosts.has(host)) + return + + panzoomWheelHosts.add(host) + + host.addEventListener('pointerdown', () => { + const currentSvg = host.querySelector('svg') + if (group && currentSvg) + group.lastSvg = currentSvg + }, false) + + host.addEventListener('wheel', (event: WheelEvent) => { + if (!event.ctrlKey) + return + + const currentSvg = host.querySelector('svg') + const instance = currentSvg ? panzoomInstances?.get(currentSvg) : null + if (!instance) + return + + if (group && currentSvg) + group.lastSvg = currentSvg + + event.preventDefault() + instance.zoomWithWheel(event) + }, { passive: false }) +} + +/** + * Initializes diagram tabs controls: + * - Diagram/Code tab switching + * - Zoom/reset/download buttons wiring + * @param tabs Diagram tabs root element. + */ +async function initSingleDiagramTabs(tabs: HTMLElement): Promise { + const actions = tabs.querySelector('.tabs-actions') + const diagramTabBtn = tabs.querySelector('.tab-item[data-tab="diagram"]') + const codeTabBtn = tabs.querySelector('.tab-item[data-tab="code"]') + const diagramBlock = tabs.querySelector('.diagram-view') + const codeBlock = Array.from(tabs.querySelectorAll('.tabs-content > .code-block')) + .find(block => !block.classList.contains('diagram-view')) + + if (!actions || !diagramTabBtn || !codeTabBtn || !diagramBlock || !codeBlock) + return + + const zoomInBtn = tabs.querySelector('.diagram-zoom-in-btn') + const zoomOutBtn = tabs.querySelector('.diagram-zoom-out-btn') + const resetBtn = tabs.querySelector('.diagram-reset-btn') + const downloadBtn = tabs.querySelector('.diagram-download-btn') + tabs.dataset.diagramInit = 'true' + + const diagramActionButtons = [zoomOutBtn, zoomInBtn, resetBtn, downloadBtn].filter(Boolean) as HTMLElement[] + const movedActionRestore = new WeakMap() + let codeActionCache: HTMLElement[] = [] + + const pinnedFullscreenBtn = codeBlock.querySelector('.code-header .fullscreen-btn') + if (pinnedFullscreenBtn && pinnedFullscreenBtn.parentElement !== actions) { + actions.appendChild(pinnedFullscreenBtn) + } + + const collectCodeActions = (): HTMLElement[] => { + const codeHeader = codeBlock.querySelector('.code-header') + if (codeHeader) { + return Array.from(codeHeader.querySelectorAll('.action-btn')) + .filter(btn => btn !== pinnedFullscreenBtn) + } + return Array.from(codeBlock.querySelectorAll('.copy-icon-btn')) + } + + const getCodeActions = (): HTMLElement[] => { + if (codeActionCache.length && codeActionCache.some(el => el.isConnected)) + return codeActionCache + + codeActionCache = collectCodeActions() + return codeActionCache + } + + const setDiagramActionsVisible = (visible: boolean): void => { + diagramActionButtons.forEach((btn) => { + btn.style.display = visible ? '' : 'none' + }) + } + + const moveToActions = (elements: HTMLElement[]): void => { + elements.forEach((el) => { + if (!el || el.parentElement === actions) + return + + movedActionRestore.set(el, { parent: el.parentElement as HTMLElement, next: el.nextSibling }) + if (pinnedFullscreenBtn && pinnedFullscreenBtn.parentElement === actions) { + actions.insertBefore(el, pinnedFullscreenBtn) + } + else { + actions.appendChild(el) + } + }) + } + + const restoreMoved = (elements: HTMLElement[]): void => { + elements.forEach((el) => { + const restore = movedActionRestore.get(el) + if (!restore?.parent) + return + + if (restore.next && restore.next.parentNode === restore.parent) { + restore.parent.insertBefore(el, restore.next) + } + else { + restore.parent.appendChild(el) + } + movedActionRestore.delete(el) + }) + } + + const switchTo = (target: 'diagram' | 'code'): void => { + const showDiagram = target === 'diagram' + diagramTabBtn.classList.toggle('active', showDiagram) + codeTabBtn.classList.toggle('active', !showDiagram) + diagramBlock.classList.toggle('active', showDiagram) + codeBlock.classList.toggle('active', !showDiagram) + + const codeActions = getCodeActions() + if (showDiagram) { + setDiagramActionsVisible(true) + // Restore code actions back to code header in diagram mode. + restoreMoved(codeActions) + } + else { + setDiagramActionsVisible(false) + // Move code actions into tab action area while code tab is active. + moveToActions(codeActions) + } + } + + const resolvePanzoom = (): PanzoomInstance | null => { + const svg = getActiveMermaidSvg(diagramBlock) + return svg && panzoomInstances ? (panzoomInstances.get(svg) ?? null) : null + } + + diagramTabBtn.addEventListener('click', () => switchTo('diagram'), false) + codeTabBtn.addEventListener('click', () => switchTo('code'), false) + + zoomInBtn?.addEventListener('click', () => { + const panzoom = resolvePanzoom() + if (!panzoom?.zoomIn) + return + try { + panzoom.zoomIn({ animate: true }) + } + catch { + panzoom.zoomIn() + } + }, false) + + zoomOutBtn?.addEventListener('click', () => { + const panzoom = resolvePanzoom() + if (!panzoom?.zoomOut) + return + try { + panzoom.zoomOut({ animate: true }) + } + catch { + panzoom.zoomOut() + } + }, false) + + resetBtn?.addEventListener('click', () => { + const panzoom = resolvePanzoom() + if (!panzoom?.reset) + return + try { + panzoom.reset({ animate: true }) + } + catch { + panzoom.reset() + } + }, false) + + downloadBtn?.addEventListener('click', () => { + const svg = getActiveMermaidSvg(diagramBlock) + if (!svg) + return + + const clonedSvg = svg.cloneNode(true) + if (!(clonedSvg instanceof SVGElement)) + return + + // Remove style attribute (transform etc.) that may interfere with proper rendering in external viewers + clonedSvg.removeAttribute('style') + const xml = new XMLSerializer().serializeToString(clonedSvg) + const blob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + const filename = diagramBlock.dataset.filename ?? 'mermaid.mmd' + + link.href = url + link.download = `${filename.split('.')[0]}.svg` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + }, false) +} + +/** Initializes Mermaid diagram tab controls for newly discovered blocks. */ +function initDiagramControls(): void { + const tabsList = document.querySelectorAll('.diagram-tabs[data-diagram="mermaid"]:not([data-diagram-init])') + tabsList.forEach((tabs) => { + void initSingleDiagramTabs(tabs) + }) +} + +/** Binds tab switch listener so Mermaid in tab panels renders after tab changes. */ +function bindTabContainerChanged(): void { + document.addEventListener('tab-container-changed', (event: TabContainerChangedEvent) => { + const panel = event.panel ?? event.detail?.relatedTarget + if (!panel) + return + observeMermaidContainers(panel, true) + }, false) +} + +/** Binds theme switch sync for pan/zoom transforms across Mermaid layers. */ +function bindThemeSync(): void { + const getActivePanzoomSvg = (container: Element): SVGElement | null => { + const nodes = container.querySelectorAll('.mermaid, .mermaid-dark') + if (!nodes.length) + return null + + const visible = Array.from(nodes).find((el) => { + const style = getComputedStyle(el) + if (style.display === 'none' || style.visibility === 'hidden') + return false + const rect = el.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + }) + + return visible?.querySelector('svg') ?? null + } + + const safeGetTransformFromSvg = (svg: SVGElement | null): PanzoomTransform | null => { + const instance = svg ? panzoomInstances?.get(svg) : null + if (!instance?.getPan || !instance?.getScale) + return null + + try { + const pan = instance.getPan() + const scale = instance.getScale() + if (pan && typeof scale === 'number') + return { x: pan.x, y: pan.y, scale } + } + catch { + return null + } + + return null + } + + const sync = (nowDark: boolean): void => { + if (!mermaidPanzoomGroups) + return + + document.querySelectorAll('.diagram-view .diagram-container').forEach((container) => { + const currentActive = getActivePanzoomSvg(container) + // Read transform from the layer that was visible before theme flip. + const fallbackSource = nowDark + ? container.querySelector('.mermaid svg') + : container.querySelector('.mermaid-dark svg') + + const groupSeed = currentActive ?? fallbackSource + if (!groupSeed) + return + + const group = getMermaidPanzoomGroup(groupSeed) + if (!group) + return + + const last = group.lastSvg + const sourceSvg = last?.isConnected ? last : (fallbackSource ?? currentActive) + const sourceTransform = safeGetTransformFromSvg(sourceSvg) + if (sourceTransform) + group.transform = sourceTransform + + if (!group.transform) + return + + if (currentActive) { + const instance = panzoomInstances?.get(currentActive) + applyPanzoomTransform(instance, group.transform) + } + }) + } + + eventBus.on('fixit:switch-theme', ({ detail }) => { + if (!detail?.isChanged) + return + + sync(detail.isDark) + observeMermaidContainers(document, true) + }) +} + +/** Initializes Mermaid rendering, events, lazy loading and diagram controls. */ +function initMermaid(): void { + const mermaidElements = document.querySelectorAll('.mermaid, .mermaid-dark, .mermaid-neutral') + if (!mermaidElements.length) + return + + initDiagramControls() + observeMermaidContainers() + scheduleNeutralRender() +} + +/** Binds Mermaid global listeners once per page lifecycle. */ +function bindGlobalEventsOnce(): void { + if (hasBoundGlobalEvents) + return + + hasBoundGlobalEvents = true + // eslint-disable-next-line no-console + console.log( + '%c💫 FixIt Mermaid', + 'color: #FF3670; font-weight: bold; font-size: 16px; text-shadow: 1px 1px 2px rgba(0,0,0,0.1);', + ) + bindThemeSync() + bindTabContainerChanged() + + initMermaid() + eventBus.on('fixit:decrypted', initMermaid) + eventBus.on('fixit:partial-decrypted', initMermaid) +} + +/** + * Initializes Mermaid runtime with externally loaded dependencies. + * Accepts the core Mermaid module + optional zenuml/layout loaders. + * @param runtime Runtime object with Mermaid module, config and optional extensions. + */ +export async function initMermaidRuntime(runtime: MermaidRuntime): Promise { + mermaid = runtime.mermaid + config = runtime.config + + const externalDiagrams: unknown[] = [] + if (runtime.zenuml) + externalDiagrams.push(runtime.zenuml) + + if (externalDiagrams.length && typeof mermaid.registerExternalDiagrams === 'function') { + try { + await mermaid.registerExternalDiagrams(externalDiagrams) + } + catch (error) { + console.warn('FixIt Mermaid registerExternalDiagrams failed:', error) + } + } + + const loaders: unknown[] = [] + runtime.loaders.forEach((item) => { + if (Array.isArray(item)) + loaders.push(...item) + else + loaders.push(item) + }) + + if (loaders.length && typeof mermaid.registerLayoutLoaders === 'function') { + try { + mermaid.registerLayoutLoaders(loaders) + } + catch (error) { + console.warn('FixIt Mermaid registerLayoutLoaders failed:', error) + } + } + + mermaid.startOnLoad = false + window.mermaid = mermaid + + document.addEventListener('DOMContentLoaded', bindGlobalEventsOnce, { once: true }) + if (document.readyState !== 'loading') + bindGlobalEventsOnce() +} + +/** + * Unwraps ESM default export shape used by third-party CDN modules. + * @param mod Imported module object. + * @returns Default export when present, otherwise module itself. + */ +function unwrapModule(mod: { default?: T } | T): T { + if (mod && typeof mod === 'object' && 'default' in mod) { + return (mod as { default?: T }).default as T + } + return mod as T +} + +/** + * Bootstrap entry used by template-injected script. + * Loads Mermaid and optional extensions via dynamic import and hands runtime to initMermaidRuntime. + * @param options Bootstrap options provided by template-injected script. + */ +export async function bootstrapMermaid(options: MermaidBootstrapOptions): Promise { + const { mermaidSource, zenumlSource = '', layoutLoaderSources = [], config } = options + + try { + // Mermaid core module is required; optional modules degrade gracefully. + const mermaidMod = await import(mermaidSource) + const mermaid = unwrapModule(mermaidMod) + + let zenuml: unknown + if (zenumlSource) { + try { + const zenumlMod = await import(zenumlSource) + zenuml = unwrapModule(zenumlMod) + } + catch (error) { + console.warn('FixIt Mermaid zenuml load failed:', error) + } + } + + const loaded = await Promise.all(layoutLoaderSources.map(async (source) => { + try { + const loaderMod = await import(source) + return unwrapModule(loaderMod) + } + catch (error) { + console.warn('FixIt Mermaid layout loader load failed:', source, error) + return null + } + })) + + const runtime: MermaidRuntime = { + mermaid, + config, + zenuml, + // Keep only successfully loaded optional loaders. + loaders: loaded.filter(item => item !== null), + } + await initMermaidRuntime(runtime) + } + catch (error) { + console.warn('FixIt Mermaid bootstrap failed:', error) + } +} diff --git a/assets/js/lib/pagefind-search.js b/assets/js/lib/pagefind-search.js deleted file mode 100644 index f678c1d4..00000000 --- a/assets/js/lib/pagefind-search.js +++ /dev/null @@ -1,142 +0,0 @@ -const ABSOLUTE_URL_RE = /^(?:[a-z]+:)?\/\//i; - -const normalizeBundlePath = (path, baseURL) => { - let bundlePath = typeof path === 'string' && path.length > 0 ? path : 'pagefind/'; - if (!bundlePath.endsWith('/')) { - bundlePath = `${bundlePath}/`; - } - if (ABSOLUTE_URL_RE.test(bundlePath)) { - return bundlePath; - } - return new URL(bundlePath, baseURL || document.baseURI).toString(); -}; - -const toObject = (value) => (value && typeof value === 'object' ? value : {}); - -const normalizeSortOrder = (value) => ( - String(value).toLowerCase() === 'asc' - ? 'asc' - : 'desc' -); - -const replaceExcerptHighlightTag = (excerpt, highlightTag) => { - if (!excerpt || !highlightTag || highlightTag === 'mark') { - return excerpt || ''; - } - - return excerpt - .replaceAll('', `<${highlightTag}>`) - .replaceAll('', ``); -}; - -export function createPagefindSearch(searchConfig) { - const pagefindConfig = toObject(searchConfig.pagefind); - const bundlePath = normalizeBundlePath(pagefindConfig.bundlePath, pagefindConfig.baseURL); - const rawDebounceTimeout = Number(pagefindConfig.debounceTimeoutMs ?? 300); - const debounceTimeout = Number.isFinite(rawDebounceTimeout) ? Math.max(0, rawDebounceTimeout) : 300; - const builtInFiltersEnabled = pagefindConfig.useBuiltInFilters !== false; - const sortBy = typeof pagefindConfig.sortBy === 'string' ? pagefindConfig.sortBy.trim() : ''; - const sortOrder = normalizeSortOrder(pagefindConfig.sortOrder); - const highlightTag = searchConfig.highlightTag ?? 'em'; - const excerptLength = Number(searchConfig.snippetLength ?? 30); - - const state = { - loading: null, - initialized: false, - availableFilters: null, - }; - - const ensurePagefind = async () => { - if (!state.loading) { - state.loading = import(`${bundlePath}pagefind.js`) - .then(async (mod) => { - if (!state.initialized) { - const options = {}; - if (Number.isFinite(excerptLength) && excerptLength >= 0) { - options.excerptLength = excerptLength; - } - if (Object.keys(options).length && typeof mod.options === 'function') { - await mod.options(options); - } - await mod.init(); - state.initialized = true; - } - return mod; - }) - .catch((error) => { - state.loading = null; - throw error; - }); - } - return state.loading; - }; - - const getAvailableFilters = async () => { - if (state.availableFilters) return state.availableFilters; - - const pagefind = await ensurePagefind(); - if (typeof pagefind.filters !== 'function') { - state.availableFilters = {}; - return state.availableFilters; - } - - try { - state.availableFilters = toObject(await pagefind.filters()); - } catch (error) { - console.warn('[FixIt] failed to read Pagefind filters:', error); - state.availableFilters = {}; - } - return state.availableFilters; - }; - - return { - preload() { - return ensurePagefind(); - }, - async search(query, maxResultLength) { - if (!query || !query.trim()) return []; - - const pagefind = await ensurePagefind(); - const searchOptions = {}; - - if (builtInFiltersEnabled) { - const availableFilters = await getAvailableFilters(); - const filters = {}; - if (Object.prototype.hasOwnProperty.call(availableFilters, 'hidden')) { - filters.hidden = 'false'; - } - if (Object.prototype.hasOwnProperty.call(availableFilters, 'encrypted')) { - filters.encrypted = 'false'; - } - if (Object.keys(filters).length) { - searchOptions.filters = filters; - } - } - - if (sortBy) { - searchOptions.sort = { [sortBy]: sortOrder }; - } - - const resultLimit = Number.isFinite(maxResultLength) - ? Math.max(0, Math.floor(maxResultLength)) - : 10; - - const searched = debounceTimeout > 0 && typeof pagefind.debouncedSearch === 'function' - ? await pagefind.debouncedSearch(query, searchOptions, debounceTimeout) - : await pagefind.search(query, searchOptions); - - if (searched === null) return null; - - const records = await Promise.all( - (searched.results || []).slice(0, resultLimit).map((entry) => entry.data()), - ); - - return records.map((item) => ({ - uri: item.url || '#', - title: item.meta?.title || item.url || '', - date: item.meta?.date || '', - context: replaceExcerptHighlightTag(item.excerpt || '', highlightTag), - })); - }, - }; -} diff --git a/assets/js/lib/pangu.ts b/assets/js/lib/pangu.ts new file mode 100644 index 00000000..b7fcf5ae --- /dev/null +++ b/assets/js/lib/pangu.ts @@ -0,0 +1,39 @@ +/** + * Pangu.js integration for FixIt. + * + * Responsibilities: + * - Automatically add spacing between CJK (Chinese, Japanese, Korean) and ASCII characters. + * - Support both full-page spacing and selector-based spacing. + * - Re-run spacing after decrypted or partially decrypted content is revealed. + */ +import { eventBus } from '../core/event-bus' + +function initPangu(target?: Element) { + if (!window.config.pangu?.enable || !window.pangu) + return + + window.pangu.ignoredTags = /^(script|code|pre|textarea|sup|sub)$/i + + if (target) { + window.pangu.spacingNode(target) + return + } + + if (window.config.pangu.selector) { + document.querySelectorAll(window.config.pangu.selector).forEach((el) => { + window.pangu.spacingNode(el) + }) + return + } + window.pangu.autoSpacingPage() +} + +document.addEventListener('DOMContentLoaded', () => { + initPangu() + eventBus.on('fixit:decrypted', () => { + initPangu() + }) + eventBus.on('fixit:partial-decrypted', ({ detail }) => { + initPangu(detail.target) + }) +}, false) diff --git a/assets/js/lib/twemoji.ts b/assets/js/lib/twemoji.ts new file mode 100644 index 00000000..006ce6bc --- /dev/null +++ b/assets/js/lib/twemoji.ts @@ -0,0 +1,23 @@ +/** + * Twemoji integration for FixIt. + * + * Responsibilities: + * - Parse emoji shortcodes into Twemoji images when enabled. + * - Re-run parsing after decrypted or partially decrypted content is revealed. + */ +import { eventBus } from '../core/event-bus' + +function initTwemoji(target: Element | Document = document) { + if (window.config.twemoji && window.twemoji) + window.twemoji.parse(target) +} + +document.addEventListener('DOMContentLoaded', () => { + initTwemoji() + eventBus.on('fixit:decrypted', () => { + initTwemoji() + }) + eventBus.on('fixit:partial-decrypted', ({ detail }) => { + initTwemoji(detail.target) + }) +}, false) diff --git a/assets/js/lib/twikoo.ts b/assets/js/lib/twikoo.ts new file mode 100644 index 00000000..5763336b --- /dev/null +++ b/assets/js/lib/twikoo.ts @@ -0,0 +1,39 @@ +/** + * Twikoo comment system integration for FixIt. + * + * Responsibilities: + * - Initialize Twikoo with configured settings + * - Fetch and display comment counts + * - Setup lightGallery for comment images when enabled + */ +import { initCommentLightGallery } from '../utils/comment' + +document.addEventListener('DOMContentLoaded', () => { + if (!window.config.comment?.twikoo || !window.twikoo) + return + + const twikooConfig = window.config.comment.twikoo as any + + if (twikooConfig.lightgallery) { + twikooConfig.onCommentLoaded = () => { + initCommentLightGallery('.tk-comments .tk-content', 'img:not(.tk-owo-emotion)') + } + } + + window.twikoo.init(twikooConfig) + + if (twikooConfig.commentCount) { + window.twikoo + .getCommentsCount({ + envId: twikooConfig.envId, + region: twikooConfig.region, + urls: [window.location.pathname], + includeReply: false, + }) + .then((response: Array<{ count: number }>) => { + const twikooCommentCount = document.getElementById('twikoo-comment-count') + if (twikooCommentCount) + twikooCommentCount.innerHTML = String(response[0].count) + }) + } +}, false) diff --git a/assets/js/lib/typeit.ts b/assets/js/lib/typeit.ts new file mode 100644 index 00000000..7c4382ae --- /dev/null +++ b/assets/js/lib/typeit.ts @@ -0,0 +1,71 @@ +/** + * TypeIt integration for FixIt shortcode blocks. + * + * Responsibilities: + * - Initialize TypeIt typewriter instances, grouped and chained by data attributes. + * - Re-run initialization after decrypted content is revealed. + */ +import { eventBus } from '../core/event-bus' +import { getStagingDOM } from '../utils' + +function initTypeit(target: Element | Document = document) { + const TypeIt = window.TypeIt + const config = window.config.typeit + if (!TypeIt || !config) + return + const speed = config.speed || 100 + const cursorSpeed = config.cursorSpeed || 1000 + const cursorChar = config.cursorChar || '|' + const loop = config.loop ?? false + const typeitElements = target.querySelectorAll('.typeit') + const groupMap = Array.from(typeitElements).reduce>((acc, ele) => { + const group = ele.dataset.group || ele.id || Math.random().toString(36).substring(2) + acc[group] = acc[group] || [] + acc[group].push(ele) + return acc + }, {}) + const stagingDOM = getStagingDOM() + + Object.values(groupMap).forEach((group) => { + const typeone = (i: number) => { + const typeitElement = group[i] + const singleData = typeitElement.dataset + stagingDOM.stage((typeitElement.querySelector('template') as HTMLTemplateElement).content.cloneNode(true)) + let targetEle = typeitElement.firstElementChild as HTMLElement + if (typeitElement.firstElementChild!.tagName === 'TEMPLATE') { + typeitElement.innerHTML = '' + targetEle = typeitElement + } + const instance = new TypeIt(targetEle, { + strings: stagingDOM.$el.querySelector('pre')?.innerHTML || stagingDOM.contentAsHtml(), + speed: Number(singleData.speed) >= 0 ? Number(singleData.speed) : speed, + lifeLike: true, + cursorSpeed: Number(singleData.cursorSpeed) >= 0 ? Number(singleData.cursorSpeed) : cursorSpeed, + cursorChar: singleData.cursorChar || cursorChar, + waitUntilVisible: true, + loop: singleData.loop ? singleData.loop === 'true' : loop, + afterComplete: () => { + const duration = Number(singleData.duration ?? config.duration) + if (i === group.length - 1) { + if (duration >= 0) { + window.setTimeout(() => { + instance.destroy() + }, duration) + } + return + } + instance.destroy() + typeone(i + 1) + }, + }).go() + } + typeone(0) + }) + stagingDOM.destroy() +} + +document.addEventListener('DOMContentLoaded', () => { + initTypeit() + eventBus.on('fixit:decrypted', () => initTypeit()) + eventBus.on('fixit:partial-decrypted', ({ detail }) => initTypeit(detail.target)) +}, false) diff --git a/assets/js/lib/utterances.ts b/assets/js/lib/utterances.ts new file mode 100644 index 00000000..0a12aa00 --- /dev/null +++ b/assets/js/lib/utterances.ts @@ -0,0 +1,41 @@ +/** + * Utterances comment system integration for FixIt. + * + * Responsibilities: + * - Dynamically inject Utterances script + * - Handle theme synchronization via postMessage + */ +import { eventBus } from '../core/event-bus' +import { isDarkMode } from '../utils' + +document.addEventListener('DOMContentLoaded', () => { + if (!window.config.comment?.utterances) + return + + const utterancesConfig = window.config.comment.utterances + + const script = document.createElement('script') + script.src = 'https://utteranc.es/client.js' + script.setAttribute('repo', utterancesConfig.repo!) + script.setAttribute('issue-term', utterancesConfig.issueTerm!) + if (utterancesConfig.label) + script.setAttribute('label', utterancesConfig.label) + script.setAttribute('theme', isDarkMode() ? utterancesConfig.darkTheme! : utterancesConfig.lightTheme!) + script.crossOrigin = 'anonymous' + script.async = true + document.getElementById('utterances')!.appendChild(script) + + const applyUtterancesTheme = (isDark: boolean) => { + const message = { + type: 'set-theme', + theme: isDark ? utterancesConfig.darkTheme : utterancesConfig.lightTheme, + } + document.querySelector('.utterances-frame')?.contentWindow?.postMessage(message, 'https://utteranc.es') + } + + eventBus.on('fixit:switch-theme', ({ detail }) => { + if (!detail.isChanged) + return + applyUtterancesTheme(detail.isDark) + }) +}, false) diff --git a/assets/js/lib/valine.ts b/assets/js/lib/valine.ts new file mode 100644 index 00000000..a5f46487 --- /dev/null +++ b/assets/js/lib/valine.ts @@ -0,0 +1,13 @@ +/** + * Valine comment system integration for FixIt. + * + * Responsibilities: + * - Initialize Valine with configured settings + */ + +document.addEventListener('DOMContentLoaded', () => { + if (!window.config.comment?.valine || !window.Valine) + return + + void new window.Valine(window.config.comment.valine) +}, false) diff --git a/assets/js/lib/waline.ts b/assets/js/lib/waline.ts new file mode 100644 index 00000000..1bf0fc71 --- /dev/null +++ b/assets/js/lib/waline.ts @@ -0,0 +1,35 @@ +/** + * Waline comment system integration for FixIt. + * + * Responsibilities: + * - Initialize Waline with configured settings + * - Handle pageview counting for expired comments + */ +import { eventBus } from '../core/event-bus' +import { getThemeMode } from '../utils' + +document.addEventListener('DOMContentLoaded', () => { + if (!window.config.comment?.waline || !window.Waline) + return + + const DARK_MODE = 'html[data-theme-mode="dark"]' + const walineConfig = window.config.comment.waline + walineConfig.dark = getThemeMode() === 'auto' ? 'auto' : DARK_MODE + + // Count-only mode for expired comments + if (window.config.comment?.expired) { + if (walineConfig.pageview) { + window.Waline.pageviewCount({ + serverURL: walineConfig.serverURL, + path: window.location.pathname, + }) + } + return + } + + const walineInstance = window.Waline.init(walineConfig) + + eventBus.on('fixit:switch-theme', ({ detail }) => { + walineInstance.update({ dark: detail.mode === 'auto' ? 'auto' : DARK_MODE }) + }) +}, false) diff --git a/assets/js/lib/watermark.ts b/assets/js/lib/watermark.ts new file mode 100644 index 00000000..cd7d5af6 --- /dev/null +++ b/assets/js/lib/watermark.ts @@ -0,0 +1,11 @@ +/** + * Watermark integration for FixIt. + * + * Responsibilities: + * - Initialize watermark overlay when configured. + */ + +document.addEventListener('DOMContentLoaded', () => { + if (window.config.watermark?.enable && window.Watermark) + void new window.Watermark!(window.config.watermark) +}, false) diff --git a/assets/js/main.ts b/assets/js/main.ts new file mode 100644 index 00000000..5012b25a --- /dev/null +++ b/assets/js/main.ts @@ -0,0 +1,85 @@ +import { eventBus } from './core/event-bus' +import { CodeModule } from './modules/code' +import { ContentModule } from './modules/content' +import { CoreModule } from './modules/core' +import { EncryptionModule } from './modules/encryption' +import { EventsModule } from './modules/events' +import { MenuModule } from './modules/menu' +import { MiscModule } from './modules/misc' +import { SearchModule } from './modules/search' +import { ThemeModule } from './modules/theme' +import { TocModule } from './modules/toc' + +/** + * FixIt theme entry point — initializes all modules and the window.fixit facade. + * + * Responsibilities: + * - Instantiate all service modules with direct constructor calls. + * - Build the `window.fixit` facade. + * - Run the init sequence on `DOMContentLoaded` (content, theme, menu, search, etc.). + */ +function bootstrap(): void { + const core = new CoreModule() + const theme = new ThemeModule(core) + const code = new CodeModule() + const toc = new TocModule() + const menu = new MenuModule(core) + const search = new SearchModule(core) + const enc = new EncryptionModule(core) + const misc = new MiscModule(core) + const content = new ContentModule(core, code) + const events = new EventsModule(core, toc, search, code) + + // Build window.fixit facade + window.fixit = { + get config() { return core.config }, + get themeMode() { return core.themeMode }, + get isDark() { return core.isDark }, + get newScrollTop() { return core.newScrollTop }, + get oldScrollTop() { return core.oldScrollTop }, + setThemeMode: (mode, persist) => theme.setThemeMode(mode, persist), + registerMaskOverlay: (name, handlers) => core.registerMaskOverlay(name, handlers), + toggleMaskOverlay: name => core.toggleMaskOverlay(name), + closeMaskOverlay: (name, skipSync) => core.closeMaskOverlay(name, skipSync), + initContent: target => content.initContent(target), + eventBus, + } + + function init() { + try { + toc.setup() + content.setup() + enc.initFixItDecryptor() + theme.initThemeColor() + content.initSVGIcon() + menu.initMenu() + theme.initSwitchTheme() + search.initSearch() + misc.initSiteTime() + misc.initServiceWorker() + misc.initAutoMark() + misc.initReward() + misc.initPostChatUser() + misc.initComment() + events.onScroll() + events.onResize() + events.onClickMask() + events.initPrint() + } + catch (err) { + console.error(err) + } + const fixitColor = '#FF735A' + // eslint-disable-next-line no-console + console.log( + `%c FixIt ${core.config.version} %c https://github.com/hugo-fixit %c`, + `background: ${fixitColor};border:1px solid ${fixitColor}; padding: 1px; border-radius: 2px 0 0 2px; color: #fff;`, + `border:1px solid ${fixitColor}; padding: 1px; border-radius: 0 2px 2px 0; color: ${fixitColor};`, + 'background:transparent;', + ) + } + + document.addEventListener('DOMContentLoaded', init, false) +} + +bootstrap() diff --git a/assets/js/modules/code.ts b/assets/js/modules/code.ts new file mode 100644 index 00000000..ed587ea9 --- /dev/null +++ b/assets/js/modules/code.ts @@ -0,0 +1,418 @@ +import type { CodeService } from '../core/tokens' +import { eventBus } from '../core/event-bus' +import { animateCSS, createCopyText, downloadAsFile, flashCopiedTooltip, getStagingDOM } from '../utils' + +/** + * Code module — code block interactions: copy, download, fullscreen, tabs, and line numbers. + * + * Responsibilities: + * - Wrap code blocks with action buttons (copy, download, fullscreen, line numbers). + * - Initialize code tab groups and sync selection across related blocks. + * - Copy diagram source (Mermaid, ECharts) from code blocks. + */ +const CellTooltip = window.CellTooltip +const copyText = createCopyText() + +export class CodeModule implements CodeService { + #fullscreenAbort: AbortController | undefined + + constructor() {} + + /** + * Attach copy-to-clipboard behaviour to a code block. + * @param codeBlock - The `.code-block` container element. + * @param codePreEl - The `
` element containing the code text.
+   */
+  initCopyCode(codeBlock: HTMLElement, codePreEl: HTMLElement) {
+    const copyBtn = codeBlock.dataset.mode === 'classic'
+      ? codeBlock.querySelector('.code-header .copy-btn')
+      : codeBlock.querySelector('.copy-icon-btn')
+    if (codeBlock.dataset.copyable !== 'true' || !copyBtn)
+      return
+    copyBtn.addEventListener('click', () => {
+      const iswWrap = codeBlock.classList.contains('line-wrapping')
+      const highlightLines = codeBlock.querySelectorAll('.hl')
+      iswWrap && codeBlock.classList.toggle('line-wrapping')
+      highlightLines.forEach(($hl) => {
+        $hl.classList.toggle('hl')
+      })
+      copyText(codePreEl.textContent!.trim()).then(() => {
+        animateCSS(codePreEl, 'animate__flash')
+        iswWrap && codeBlock.classList.toggle('line-wrapping')
+        highlightLines.forEach(($hl) => {
+          $hl.classList.toggle('hl')
+        })
+        flashCopiedTooltip(copyBtn)
+      }, () => {
+        console.error('Clipboard write failed!', 'Your browser does not support clipboard API!')
+      })
+    }, false)
+  }
+
+  /**
+   * Attach toggle behaviour to the code expand/collapse button.
+   * @param codeBlock - The `.code-block` container element.
+   */
+  initCodeExpandBtn(codeBlock: HTMLElement) {
+    codeBlock.querySelector('.code-expand-btn')?.addEventListener('click', () => {
+      codeBlock.classList.toggle('is-expanded')
+    }, false)
+  }
+
+  /**
+   * Attach download behaviour to a code block's download button.
+   * @param codeBlock - The `.code-block` container element.
+   * @param codePreEl - The `
` element containing the code text.
+   */
+  initDownloadCode(codeBlock: HTMLElement, codePreEl: HTMLElement) {
+    const downloadBtn = codeBlock.querySelector('.code-header .download-btn')
+    if (!downloadBtn)
+      return
+    downloadBtn.addEventListener('click', () => {
+      const $codeHeader = codeBlock.querySelector('.code-header')
+      const name = codeBlock.dataset.name?.trim()
+      const language = Array.from($codeHeader?.classList || []).find(className => className.startsWith('language-'))?.replace('language-', '')
+      const ext = language && language !== 'fallback' ? language : 'txt'
+      const fallbackName = name
+        ? (name.includes('.') ? name : `${name}.${ext}`)
+        : `code.${ext}`
+      const fileName = codeBlock.getAttribute('filename')?.trim()
+      downloadAsFile(codePreEl.textContent!, fileName || fallbackName)
+      downloadBtn.toggleAttribute('data-downloaded', true)
+      downloadBtn.classList.toggle('fa-spin', true)
+      setTimeout(() => {
+        downloadBtn.toggleAttribute('data-downloaded', false)
+        downloadBtn.classList.toggle('fa-spin', false)
+      }, 300)
+    }, false)
+  }
+
+  /**
+   * Get the fullscreen target element (parent `.code-tabs` or the block itself).
+   * @param codeBlock - The `.code-block` element.
+   * @returns The element to apply fullscreen to.
+   */
+  #getCodeFullscreenTarget(codeBlock: HTMLElement): HTMLElement {
+    return (codeBlock.closest('.code-tabs') as HTMLElement) || codeBlock
+  }
+
+  /**
+   * Toggle fullscreen state and update button tooltips.
+   * @param codeBlock - The `.code-block` element.
+   * @param show - `true` to enter fullscreen, `false` to exit.
+   */
+  #setCodeFullscreenState(codeBlock: HTMLElement, show: boolean) {
+    const target = this.#getCodeFullscreenTarget(codeBlock)
+    const expandBtn = codeBlock.querySelector('.code-expand-btn')
+
+    if (show && expandBtn) {
+      codeBlock.dataset.fullscreenExpanded = codeBlock.classList.contains('is-expanded') ? 'true' : 'false'
+      codeBlock.classList.add('is-expanded')
+    }
+
+    if (!show && target.classList.contains('is-fullscreen')) {
+      target.classList.add('instant-height')
+      window.requestAnimationFrame(() => target.classList.remove('instant-height'))
+
+      if (expandBtn && codeBlock.dataset.fullscreenExpanded === 'false') {
+        codeBlock.classList.remove('is-expanded')
+      }
+      delete codeBlock.dataset.fullscreenExpanded
+    }
+
+    // update button tooltip
+    target.classList.toggle('is-fullscreen', show)
+    const btn = target.querySelector('.tabs-actions .fullscreen-btn')
+      || codeBlock.querySelector('.code-header .fullscreen-btn')
+    if (!btn)
+      return
+    const exitTitle = btn.dataset.exitTitle || btn.getAttribute('data-exit-title') || btn.title
+    const originalTitle = btn.dataset.ctOriginalTitle || btn.dataset.ctTitle || btn.title
+    btn.dataset.ctOriginalTitle = originalTitle
+    btn.dataset.ctTitle = show ? exitTitle : originalTitle
+    const instance = CellTooltip.getOrCreateInstance(btn)
+    instance.hide()
+  }
+
+  /** Exit fullscreen on the currently active code block. */
+  closeCodeFullscreen() {
+    const $activeTabs = document.querySelector('.code-tabs.is-fullscreen')
+    if ($activeTabs) {
+      const $activeBlock = $activeTabs.querySelector('.code-block.active') || $activeTabs.querySelector('.code-block')
+      if ($activeBlock)
+        this.#setCodeFullscreenState($activeBlock, false)
+      return
+    }
+    const $activeBlock = document.querySelector('.code-block.highlight.is-fullscreen')
+    if ($activeBlock)
+      this.#setCodeFullscreenState($activeBlock, false)
+  }
+
+  /**
+   * Attach fullscreen toggle and Escape-key handler to a code block.
+   * @param codeBlock - The `.code-block` container element.
+   */
+  initFullscreenCode(codeBlock: HTMLElement) {
+    const fullscreenBtn = codeBlock.querySelector('.code-header .fullscreen-btn')
+    if (!fullscreenBtn)
+      return
+    fullscreenBtn.addEventListener('click', () => {
+      const target = this.#getCodeFullscreenTarget(codeBlock)
+      const show = !target.classList.contains('is-fullscreen')
+      if (show) {
+        this.closeCodeFullscreen()
+        codeBlock.classList.remove('is-collapsed')
+      }
+      this.#setCodeFullscreenState(codeBlock, show)
+    }, false)
+    if (!this.#fullscreenAbort) {
+      this.#fullscreenAbort = new AbortController()
+      document.addEventListener('keydown', (event) => {
+        if (event.key === 'Escape')
+          this.closeCodeFullscreen()
+      }, { signal: this.#fullscreenAbort.signal })
+    }
+  }
+
+  /** Initialize all un-initialized code blocks on the page. */
+  initCodeWrapper() {
+    const $codeBlocks = document.querySelectorAll('.code-block.highlight:not([data-init])')
+    $codeBlocks.forEach(($codeBlock) => {
+      const $preElements = $codeBlock.querySelectorAll('pre.chroma')
+      if (!$preElements.length)
+        return
+      const $codePreEl = $preElements[$preElements.length - 1]
+      $codeBlock.dataset.init = 'true'
+
+      this.initCopyCode($codeBlock, $codePreEl)
+      this.initCodeExpandBtn($codeBlock)
+
+      // classic mode code block interactions
+      if ($codeBlock.dataset.mode === 'classic') {
+        const $codeHeader = $codeBlock.querySelector('.code-header')
+        if (!$codeHeader)
+          return
+        this.initDownloadCode($codeBlock, $codePreEl)
+        this.initFullscreenCode($codeBlock)
+        // code title
+        $codeHeader.querySelector('.code-title')!.addEventListener('click', () => {
+          if ($codeBlock.classList.contains('is-fullscreen'))
+            return
+          $codeBlock.classList.toggle('is-collapsed')
+        }, false)
+        // ellipses icon
+        $codeHeader.querySelector('.ellipses-btn')!.addEventListener('click', () => {
+          $codeBlock.classList.remove('is-collapsed')
+        }, false)
+        // line numbers toggle button
+        $codeHeader.querySelector('.line-nos-btn')?.addEventListener('click', () => {
+          $codeBlock.classList.toggle('line-nos-hidden')
+        }, false)
+        // line wrapping toggle button
+        $codeHeader.querySelector('.line-wrap-btn')?.addEventListener('click', () => {
+          if ($codeBlock.querySelector('[contenteditable="true"]'))
+            return
+          $codeBlock.classList.toggle('line-wrapping')
+        }, false)
+        // edit button toggle button
+        if ($codeBlock.dataset.editable === 'true') {
+          $codeHeader.querySelector('.edit-btn')?.addEventListener('click', () => {
+            const isEditable = $codePreEl.getAttribute('contenteditable') === 'true'
+            if (isEditable) {
+              $codePreEl.setAttribute('contenteditable', 'false')
+              $codePreEl.blur()
+            }
+            else {
+              $codeBlock.querySelectorAll('.hl').forEach(($hl: Element) => {
+                $hl.classList.remove('hl')
+              })
+              $codeBlock.classList.add('is-expanded')
+              $codeBlock.classList.remove('line-wrapping')
+              $codePreEl.setAttribute('contenteditable', 'true')
+              $codePreEl.focus()
+            }
+          }, false)
+        }
+      }
+    })
+  }
+
+  /** Group consecutive code blocks into tabbed containers with language sync. */
+  initCodeTabs() {
+    const $codeBlocks = document.querySelectorAll('.code-block[group]:not([data-tab-init])')
+    const processed = new Set()
+    const normalizeTabTitle = (title = '') => title.toLowerCase()
+
+    $codeBlocks.forEach(($block) => {
+      if (processed.has($block))
+        return
+
+      const groupName = $block.getAttribute('group')!
+      const $tabs: HTMLElement[] = []
+      let $curr: HTMLElement | null = $block
+
+      // collect consecutive blocks with same group
+      while ($curr && $curr.classList?.contains('code-block') && $curr.getAttribute('group') === groupName) {
+        $tabs.push($curr)
+        processed.add($curr)
+        $curr = $curr.nextElementSibling as HTMLElement
+      }
+
+      if ($tabs.length < 2)
+        return
+
+      // create DOM structure
+      const $container = document.createElement('div')
+      $container.className = 'code-tabs'
+
+      const $header = document.createElement('div')
+      $header.className = 'tabs-header'
+
+      const $items = document.createElement('div')
+      $items.className = 'tabs-items'
+
+      const $actions = document.createElement('div')
+      $actions.className = 'tabs-actions'
+
+      $header.appendChild($items)
+      $header.appendChild($actions)
+
+      const $content = document.createElement('div')
+      $content.className = 'tabs-content'
+
+      // insert container before the first block
+      const $firstBlock = $tabs[0]
+      $firstBlock.parentNode!.insertBefore($container, $firstBlock)
+
+      const activeTabIndex = $tabs.findIndex(tab => tab.classList.contains('active'))
+      const langPref = window.localStorage.getItem('config_lang_perf')
+      const hasCodeToggle = $tabs.some(tab => tab.dataset.codeToggle === 'true')
+      const langPrefIndex = (langPref && hasCodeToggle) ? $tabs.findIndex(tab => tab.dataset.tabTitle!.toLowerCase() === langPref) : -1
+      const resolvedIndex = langPrefIndex !== -1 ? langPrefIndex : activeTabIndex
+      const beforeTabs = $tabs[0]?.getAttribute('before_tabs')
+      if (beforeTabs) {
+        const $before = document.createElement('span')
+        $before.className = 'before-tabs'
+        $before.textContent = beforeTabs
+        $items.appendChild($before)
+      }
+
+      const tabButtons: HTMLElement[] = []
+      const toggleLangToIndex = new Map()
+
+      const switchToTab = (index: number) => {
+        const $nextTab = $tabs[index]
+        const $nextBtn = tabButtons[index]
+        if (!$nextTab || !$nextBtn)
+          return
+
+        // 1. restore buttons to the currently active tab
+        const $activeTab = $tabs.find(t => t.classList.contains('active'))
+        if ($activeTab) {
+          const $activeHeader = $activeTab.querySelector('.code-header')
+          if ($activeHeader) {
+            Array.from($actions.children).forEach(btn => $activeHeader.appendChild(btn))
+          }
+        }
+
+        // 2. switch active tab UI
+        tabButtons.forEach(b => b.classList.remove('active'))
+        $nextBtn.classList.add('active')
+
+        // 3. switch content
+        $tabs.forEach(b => b.classList.remove('active'))
+        $nextTab.classList.add('active')
+
+        // 4. sync shadow mode data attribute
+        const shadowMode = $nextTab?.dataset.shadow
+        if (shadowMode) {
+          $container.dataset.shadow = shadowMode
+        }
+        else {
+          delete $container.dataset.shadow
+        }
+
+        // 5. move new buttons to actions
+        const $codeHeader = $nextTab.querySelector('.code-header')
+        if ($codeHeader) {
+          $codeHeader.querySelectorAll('.action-btn').forEach(btn => $actions.appendChild(btn))
+        }
+      }
+
+      eventBus.on('fixit:code-tab-sync', ({ detail }) => {
+        if (!detail.lang || detail.source === $container)
+          return
+        const index = toggleLangToIndex.get(detail.lang)
+        index !== undefined && switchToTab(index)
+      })
+
+      $tabs.forEach(($tab, index) => {
+        const title = $tab.dataset.tabTitle || 'Code'
+        const defaultActiveTab = resolvedIndex === -1 && index === 0
+
+        // tab button
+        const $btn = document.createElement('span')
+        $btn.className = 'tab-item'
+        if (defaultActiveTab)
+          $btn.classList.add('active')
+        $btn.textContent = title
+        $btn.dataset.index = String(index)
+        $btn.title = title
+        tabButtons.push($btn)
+
+        const normalizedTitle = normalizeTabTitle(title)
+        if (!toggleLangToIndex.has(normalizedTitle)) {
+          toggleLangToIndex.set(normalizedTitle, index)
+        }
+
+        $btn.addEventListener('click', () => {
+          if ($tab.dataset.codeToggle === 'true') {
+            window.localStorage.setItem('config_lang_perf', normalizedTitle)
+            eventBus.emit('fixit:code-tab-sync', { lang: normalizedTitle, source: $container })
+          }
+          switchToTab(index)
+        })
+        $items.appendChild($btn)
+
+        // move block to content
+        $tab.classList.toggle('active', resolvedIndex === index || defaultActiveTab)
+        $tab.classList.remove('is-collapsed')
+        $tab.classList.remove('d-none')
+        $tab.dataset.tabInit = 'true'
+        $content.appendChild($tab)
+      })
+
+      $container.appendChild($header)
+      $container.appendChild($content)
+
+      // initialize actions for the active tab
+      if (resolvedIndex !== -1) {
+        switchToTab(resolvedIndex)
+      }
+      else {
+        switchToTab(0)
+      }
+    })
+  }
+
+  /** Attach copy behaviour to diagram container copy buttons. */
+  initDiagramCopyBtn() {
+    const stagingDOM = getStagingDOM()
+    document.querySelectorAll('.diagram-container > .copy-icon-btn').forEach(($btn) => {
+      $btn.addEventListener('click', () => {
+        stagingDOM.stage($btn.parentElement!.querySelector('template')!.content.cloneNode(true))
+        let code = stagingDOM.contentAsText()
+        try {
+          code = JSON.stringify(JSON.parse(code), null, 2)
+        }
+        catch { /* ignore */ }
+        copyText(code).then(() => {
+          flashCopiedTooltip($btn as HTMLElement)
+        }, () => {
+          console.error('Clipboard write failed!', 'Your browser does not support clipboard API!')
+        })
+      }, false)
+    })
+    stagingDOM.destroy()
+  }
+}
diff --git a/assets/js/modules/content.ts b/assets/js/modules/content.ts
new file mode 100644
index 00000000..5172bce1
--- /dev/null
+++ b/assets/js/modules/content.ts
@@ -0,0 +1,195 @@
+import type { CodeService, ContentService, CoreService } from '../core/tokens'
+import { eventBus } from '../core/event-bus'
+import { createCopyText, flashCopiedTooltip } from '../utils'
+
+const CellTooltip = window.CellTooltip
+const copyText = createCopyText()
+
+/**
+ * Content module — details toggle, tooltips, footnotes, SVG icons, and link guard.
+ *
+ * Responsibilities:
+ * - Attach toggle behaviour to `
` elements. + * - Initialize CellTooltip on action buttons, copy buttons, and footnotes. + * - Fetch and inline SVG icons from `data-svg-src` attributes. + * - Set up link guard dialog for external link confirmation. + * - Re-initialize components after encrypted content is decrypted. + */ +export class ContentModule implements ContentService { + constructor( + private readonly core: CoreService, + private readonly code: CodeService, + ) {} + + /** Fetch and inline SVG icons referenced by `data-svg-src` attributes. */ + initSVGIcon() { + document.querySelectorAll('[data-svg-src]').forEach(($icon) => { + fetch($icon.dataset.svgSrc!) + .then(response => response.text()) + .then((svg) => { + const $temp = document.createElement('div') + $temp.insertAdjacentHTML('afterbegin', svg) + const $svg = $temp.firstChild as SVGElement + $svg.dataset.svgSrc = $icon.dataset.svgSrc + $svg.classList.add('icon') + const $titleElements = $svg.getElementsByTagName('title') + $titleElements.length && $svg.removeChild($titleElements[0]) + $icon.parentElement!.replaceChild($svg, $icon) + }) + .catch((err) => { + console.error(err) + }) + }) + } + + /** + * Initialize the link-guard dialog and bind click handlers on guarded links. + * @param target - The root element to search for guarded links. + */ + initLinkGuardDialog(target: Element | Document = document) { + const dialog = document.getElementById('link-guard-dialog') as HTMLDialogElement + if (!dialog) + return + + const $target = dialog.querySelector('.target') + const $copy = dialog.querySelector('.copy-icon-btn') + const $confirm = dialog.querySelector('.confirm-btn') + const $cancel = dialog.querySelector('.cancel-btn') + + const _closeDialog = () => { + if (dialog.open) + dialog.close() + ;(dialog as any)._target = null + if ($target) { + $target.textContent = '-' + } + } + + if (!dialog.dataset.init) { + dialog.dataset.init = 'true' + + $confirm!.addEventListener('click', () => { + if ((dialog as any)._target) { + window.open((dialog as any)._target, '_blank', 'noopener,noreferrer') + } + _closeDialog() + }) + + $cancel!.addEventListener('click', _closeDialog) + + $copy!.addEventListener('click', () => { + const textToCopy = (dialog as any)._target || '' + if (!textToCopy) + return + copyText(textToCopy).then(() => { + flashCopiedTooltip($copy!) + }) + }) + } + + target.querySelectorAll('a[target="_blank"][data-guard="modal"]:not([data-init])').forEach(($link) => { + $link.dataset.init = 'true' + $link.addEventListener('click', (e) => { + e.preventDefault() + let targetUrl = $link.href + try { + const guardUrl = new URL($link.href) + targetUrl = guardUrl.searchParams.get('target') || targetUrl + } + catch { + // Ignore malformed URLs and fall back to the original href. + } + + ;(dialog as any)._target = targetUrl + if ($target) { + $target.textContent = targetUrl + } + dialog.showModal() + ;(document.activeElement as HTMLElement)?.blur() + }, false) + }) + } + + /** + * Attach toggle behaviour to `
` elements. + * @param target - The root element to search within. + */ + initDetails(target: Element | Document = document) { + target.querySelectorAll('.details:not(.disabled)').forEach(($details) => { + const $summary = $details.querySelector('.details-summary')! + $summary.addEventListener('click', () => { + $details.classList.toggle('open') + }, false) + }) + } + + /** Convert footnote refs into tooltip-enabled elements. */ + #initFootnotes() { + const $footnoteRefs = document.querySelectorAll('#content sup[id^="fnref:"]') + const $footnotes = document.querySelector('.footnotes[role="doc-endnotes"]') + if (!$footnoteRefs.length || !$footnotes) + return + const footnoteMap = new Map() + $footnoteRefs.forEach(($ref) => { + if (this.core.config.tooltip) { + const $link = $ref.querySelector('a.footnote-ref') + if ($link) { + $link.addEventListener('click', (e) => { + e.preventDefault() + }, false) + } + } + const id = $ref.id.replace('fnref:', '') + const $footnoteContent = $footnotes.querySelector(`[id="fn:${id}"]`) + if ($footnoteContent) { + const $clonedContent = $footnoteContent.cloneNode(true) as HTMLElement + const $backref = $clonedContent.querySelector('.footnote-backref') + if ($backref) + $backref.remove() + footnoteMap.set($ref, $clonedContent) + } + }) + footnoteMap.forEach(($content, $ref) => { + if ($ref.hasAttribute('title')) + return + $ref.setAttribute('title', $content.textContent!.trim()) + if (this.core.config.tooltip) { + CellTooltip.getOrCreateInstance($ref) + } + }) + } + + /** Initialize CellTooltip on action buttons, copy buttons, and footnotes. */ + initTooltip() { + if (!this.core.config.tooltip) + return + CellTooltip.initAll('li[data-task] > span[title]', { placement: 'right' }) + CellTooltip.initAll('.action-btn[title]', { placement: 'bottom' }) + CellTooltip.initAll('.copy-icon-btn[title]', { placement: 'top' }) + this.#initFootnotes() + } + + /** + * Re-initialize content components within a target element. + * Useful after AJAX/pjax loads or dynamic content injection. + * @param target - The root element to initialize components within. + */ + initContent(target: Element | Document = document) { + this.initDetails(target) + this.code.initCodeWrapper() + this.code.initCodeTabs() + this.code.initDiagramCopyBtn() + this.initTooltip() + this.initLinkGuardDialog(target) + } + + setup() { + this.initContent() + eventBus.on('fixit:decrypted', () => { + this.initContent() + }) + eventBus.on('fixit:partial-decrypted', ({ detail }) => { + this.initContent(detail.target) + }) + } +} diff --git a/assets/js/modules/core.ts b/assets/js/modules/core.ts new file mode 100644 index 00000000..36ac735e --- /dev/null +++ b/assets/js/modules/core.ts @@ -0,0 +1,93 @@ +import type { CoreService } from '../core/tokens' +import type { FixItConfig, MaskOverlayHandler } from '../types' +import { getScrollTop, getThemeMode, isDarkMode } from '../utils' + +/** + * Core module — shared state initialization and mask overlay management. + * + * Responsibilities: + * - Load and expose page/site configuration from `window.config`. + * - Track theme mode (light/dark) and provide `isDark` / `themeMode` accessors. + * - Manage mask overlay visibility for search and menu drawers. + */ +export class CoreModule implements CoreService { + readonly config: FixItConfig + themeMode: string + isDark: boolean + newScrollTop: number + oldScrollTop: number + disableScrollEvent: boolean + + private activeMaskOverlay: string | null = null + private readonly maskOverlays = new Map() + + constructor() { + this.config = window.config + this.themeMode = getThemeMode() + this.isDark = isDarkMode() + this.newScrollTop = getScrollTop() + this.oldScrollTop = this.newScrollTop + this.disableScrollEvent = false + + window.objectFitImages?.() + } + + /** Register a named mask overlay with open/close/isActive handlers. */ + registerMaskOverlay(name: string, handlers: MaskOverlayHandler) { + this.maskOverlays.set(name, handlers) + } + + /** Toggle the mask element's blur class based on active overlay state. */ + syncMaskState() { + document.getElementById('mask')?.classList.toggle('blur', Boolean(this.activeMaskOverlay)) + } + + /** Open a named mask overlay, closing any previously active one. */ + openMaskOverlay(name: string) { + this.disableScrollEvent = true + const overlay = this.maskOverlays.get(name) + if (!overlay) + return + if (this.activeMaskOverlay && this.activeMaskOverlay !== name) { + this.closeMaskOverlay(this.activeMaskOverlay, true) + } + overlay.onOpen?.() + this.activeMaskOverlay = name + this.syncMaskState() + } + + /** Close a named mask overlay and optionally skip mask state sync. */ + closeMaskOverlay(name: string, skipSync = false) { + this.disableScrollEvent = false + const overlay = this.maskOverlays.get(name) + if (!overlay) + return + overlay.onClose?.() + if (this.activeMaskOverlay === name) { + this.activeMaskOverlay = null + } + !skipSync && this.syncMaskState() + } + + /** Toggle a named mask overlay open/closed. */ + toggleMaskOverlay(name: string) { + const overlay = this.maskOverlays.get(name) + if (!overlay) + return + const isActive = overlay.isActive?.() ?? this.activeMaskOverlay === name + if (this.activeMaskOverlay === name && isActive) { + this.closeMaskOverlay(name) + return + } + this.openMaskOverlay(name) + } + + /** Close whichever mask overlay is currently active. */ + closeActiveMaskOverlay() { + if (!this.activeMaskOverlay) { + this.syncMaskState() + return + } + this.closeMaskOverlay(this.activeMaskOverlay) + } +} diff --git a/assets/js/modules/encryption.ts b/assets/js/modules/encryption.ts new file mode 100644 index 00000000..12ecd54c --- /dev/null +++ b/assets/js/modules/encryption.ts @@ -0,0 +1,45 @@ +import type { CoreService, EncryptionService } from '../core/tokens' +import { eventBus } from '../core/event-bus' + +/** + * Encryption module — page decryption via FixItDecryptor and encrypted content toggling. + * + * Responsibilities: + * - Initialize FixItDecryptor for full-page and shortcode-scoped decryption. + * - Toggle visibility of encrypted content sections. + */ +export class EncryptionModule implements EncryptionService { + constructor(private readonly core: CoreService) {} + + /** + * Toggle between encrypted-hidden and decrypted-shown classes. + * @param container - The root element containing encrypted elements. + * @param show - `true` to show decrypted content, `false` to hide. + */ + #toggleEncryptedClass(container: Element | Document, show: boolean) { + const fromClass = show ? 'encrypted-hidden' : 'decrypted-shown' + const toClass = show ? 'decrypted-shown' : 'encrypted-hidden' + container.querySelectorAll(`.${fromClass}`).forEach(($element: Element) => { + $element.classList.replace(fromClass, toClass) + }) + } + + /** Initialize the FixItDecryptor and wire up decryption/re-encryption events. */ + initFixItDecryptor() { + if (!this.core.config.encryption) + return + const decryptor = new window.FixItDecryptor() + + eventBus.on('fixit:decrypted', () => { + this.#toggleEncryptedClass(document, true) + }) + eventBus.on('fixit:partial-decrypted', ({ detail }) => { + this.#toggleEncryptedClass(detail.target, true) + }) + eventBus.on('fixit:re-encrypt', () => { + this.#toggleEncryptedClass(document, false) + }) + + decryptor.init(this.core.config.encryption) + } +} diff --git a/assets/js/modules/events.ts b/assets/js/modules/events.ts new file mode 100644 index 00000000..80631c00 --- /dev/null +++ b/assets/js/modules/events.ts @@ -0,0 +1,172 @@ +import type { CodeService, CoreService, EventsService, SearchService, TocService } from '../core/tokens' +import { eventBus } from '../core/event-bus' +import { animateCSS, getScrollTop, isMobile, scrollIntoView } from '../utils' + +/** + * Events module — scroll, resize, mask click, and print event handling. + * + * Responsibilities: + * - Throttled scroll handler for back-to-top button, header show/hide, and TOC active state sync. + * - Resize handler for TOC height and table wrapping recalculation. + * - Mask click handler to close search and menu drawers. + * - Print preparation handler. + */ +export class EventsModule implements EventsService { + #resizeTimeout: number | null = null + + constructor( + private readonly core: CoreService, + private readonly toc: TocService, + private readonly search: SearchService, + private readonly code: CodeService, + ) {} + + /** Bind scroll listener: auto-hide headers, reading progress, back-to-top, and TOC sync. */ + onScroll() { + const ACCURACY = 20 + const $autoHeaders: HTMLElement[] = [] + const $backToTop = document.querySelector('.back-to-top') + const $readingProgressBar = document.querySelector('.reading-progress-bar') + if (document.body.dataset.headerDesktop === 'auto') { + $autoHeaders.push(document.getElementById('header-desktop')!) + } + if (document.body.dataset.headerMobile === 'auto') { + $autoHeaders.push(document.getElementById('header-mobile')!) + } + $backToTop?.addEventListener('click', () => { + scrollIntoView('body') + }) + window.addEventListener('scroll', (event) => { + if (this.core.disableScrollEvent) { + event.preventDefault() + return + } + this.core.newScrollTop = getScrollTop() + const scroll = this.core.newScrollTop - this.core.oldScrollTop + if (Math.abs(scroll) > ACCURACY) { + this.core.closeActiveMaskOverlay() + const isScrollingDown = scroll > 0 + $autoHeaders.forEach(($header) => { + if (isScrollingDown) { + $header.classList.remove('header__fadeInDown') + animateCSS($header, ['header__fadeOutUp'], true) + } + else { + $header.classList.remove('header__fadeOutUp') + animateCSS($header, ['header__fadeInDown'], true) + } + }) + } + else if (this.core.newScrollTop <= 0) { + $autoHeaders.forEach(($header) => { + $header.classList.remove('header__fadeOutUp') + animateCSS($header, ['header__fadeInDown'], true) + }) + } + const contentHeight = document.body.scrollHeight - window.innerHeight + const scrollPercent = Math.max(Math.min(100 * Math.max(this.core.newScrollTop, 0) / contentHeight, 100), 0) + if ($readingProgressBar) { + $readingProgressBar.style.setProperty('--fi-progress', `${scrollPercent.toFixed(2)}%`) + } + if ($backToTop) { + if (scrollPercent > 1) { + $backToTop.classList.remove('d-none', 'animate__fadeOut') + animateCSS($backToTop, ['animate__fadeIn'], true) + } + else { + $backToTop.classList.remove('animate__fadeIn') + animateCSS($backToTop, ['animate__fadeOut'], true, () => { + $backToTop.classList.contains('animate__fadeOut') && $backToTop.classList.add('d-none') + }) + } + $backToTop.style.setProperty('--fi-b2t-progress', scrollPercent.toFixed(2)) + if (navigator.userAgent.toLowerCase().includes('firefox')) { + const dashoffset = 2 * Math.PI * 50 * (1 - scrollPercent / 100) + $backToTop.querySelector('circle.progress')!.style.strokeDashoffset = String(dashoffset.toFixed(2)) + } + } + eventBus.emit('fixit:scroll') + this.toc.syncTocHeight() + this.toc.syncTocActiveState() + this.core.oldScrollTop = this.core.newScrollTop + }, false) + } + + /** Bind resize listener with debounce: re-init TOC, search, and sync state. */ + onResize() { + let resizeBefore = isMobile() + window.addEventListener('resize', () => { + if (!this.#resizeTimeout) { + this.#resizeTimeout = window.setTimeout(() => { + this.#resizeTimeout = null + eventBus.emit('fixit:resize') + this.toc.initToc() + this.search.initSearch() + this.toc.syncTocHeight() + this.toc.syncTocActiveState() + + const _isMobile = isMobile() + if (_isMobile !== resizeBefore) { + this.core.closeActiveMaskOverlay() + resizeBefore = _isMobile + } + }, 100) + } + }, false) + } + + /** Bind mask click to close the active overlay. */ + onClickMask() { + document.getElementById('mask')!.addEventListener('click', (e) => { + if (!(e.target as HTMLElement).classList.contains('blur')) + return + this.core.closeActiveMaskOverlay() + }, false) + } + + /** Bind beforeprint/afterprint to expand admonitions, code blocks, details, and file trees. */ + initPrint() { + window.addEventListener('beforeprint', () => { + const $content = document.getElementById('content')! + const printConfig = this.core.config.print || {} + + if (printConfig.expandAdmonition) { + $content.querySelectorAll('.admonition').forEach(($el: Element) => $el.classList.add('open')) + } + if (printConfig.expandCode) { + $content.querySelectorAll('.code-tabs').forEach(($codeTabs) => { + if ($codeTabs.dataset.diagram) + return + const $actions = $codeTabs.querySelector('.tabs-actions') + const $activeBlock = $codeTabs.querySelector('.code-block.active') + if ($actions && $activeBlock) { + const $codeHeader = $activeBlock.querySelector('.code-header') + if ($codeHeader) { + Array.from($actions.children).forEach(btn => $codeHeader.appendChild(btn)) + } + } + const $codeBlocks = $codeTabs.querySelectorAll('.code-block') + $codeBlocks.forEach(($codeBlock) => { + delete $codeBlock.dataset.tabInit + $codeTabs.parentElement!.insertBefore($codeBlock, $codeTabs) + }) + $codeTabs.parentElement!.removeChild($codeTabs) + }) + $content.querySelectorAll('.code-block').forEach(($el) => { + $el.classList.add('line-wrapping') + $el.classList.remove('is-collapsed') + if ($el.querySelector('.code-expand-btn')) { + $el.classList.add('is-expanded') + } + }) + } + if (printConfig.expandDetails) { + $content.querySelectorAll('details').forEach(($el: Element) => $el.setAttribute('open', '')) + } + }, false) + + window.addEventListener('afterprint', () => { + this.code.initCodeTabs() + }, false) + } +} diff --git a/assets/js/modules/menu.ts b/assets/js/modules/menu.ts new file mode 100644 index 00000000..df5f7d88 --- /dev/null +++ b/assets/js/modules/menu.ts @@ -0,0 +1,57 @@ +import type { CoreService, MenuService } from '../core/tokens' + +/** + * Menu module — desktop dropdown and mobile drawer navigation. + * + * Responsibilities: + * - Initialize desktop header dropdown menu interactions. + * - Initialize mobile header drawer menu open/close/toggle. + * - Sync menu state with mask overlay. + */ +export class MenuModule implements MenuService { + constructor(private readonly core: CoreService) {} + + /** Initialize both desktop and mobile menus. */ + initMenu() { + this.initMenuDesktop() + this.initMenuMobile() + } + + /** Set min-width on desktop sub-menus to match parent item width. */ + initMenuDesktop() { + document.querySelectorAll('.has-children').forEach(($item) => { + $item.querySelector('.sub-menu')!.style.minWidth = `${$item.offsetWidth - 8}px` + }) + } + + /** Initialize mobile drawer menu with mask overlay and nested toggles. */ + initMenuMobile() { + const $menuToggleMobile = document.getElementById('menu-toggle-mobile') + const $menuMobile = document.getElementById('menu-mobile') + if (!$menuToggleMobile || !$menuMobile) + return + this.core.registerMaskOverlay('menu-mobile', { + isActive: () => $menuMobile.classList.contains('active'), + onOpen: () => { + $menuToggleMobile.classList.add('active') + $menuMobile.classList.add('active') + $menuToggleMobile.setAttribute('aria-expanded', 'true') + }, + onClose: () => { + $menuToggleMobile.classList.remove('active') + $menuMobile.classList.remove('active') + $menuToggleMobile.setAttribute('aria-expanded', 'false') + }, + }) + $menuToggleMobile.addEventListener('click', () => { + this.core.toggleMaskOverlay('menu-mobile') + }, false) + // add nested menu toggler + document.querySelectorAll('.menu-item>.nested-item').forEach(($nestedItem) => { + $nestedItem.addEventListener('click', function (this: HTMLElement) { + (this.parentNode as HTMLElement).querySelector('.sub-menu')!.classList.toggle('open') + this.querySelector('.dropdown-icon')!.classList.toggle('open') + }) + }) + } +} diff --git a/assets/js/modules/misc.ts b/assets/js/modules/misc.ts new file mode 100644 index 00000000..4ab4866b --- /dev/null +++ b/assets/js/modules/misc.ts @@ -0,0 +1,143 @@ +import type { CoreService, MiscService } from '../core/tokens' +import { eventBus } from '../core/event-bus' +import { getScrollTop, isMobile, isValidDate, scrollIntoView } from '../utils' + +/** + * Miscellaneous module — site time, PWA, bookmarks, rewards, comments, and PostChat. + * + * Responsibilities: + * - Display site running time with animated counters. + * - Register service worker for PWA support. + * - Auto-bookmark scroll position for page restoration. + * - Initialize reward QR codes and PostChat AI user info. + * - Initialize comment section UI and scroll-into-view. + */ +export class MiscModule implements MiscService { + private siteTime: ReturnType | undefined + + constructor(private readonly core: CoreService) {} + + /** Calculate and display the elapsed time since site launch. */ + getSiteTime() { + const now = new Date() + const run = new Date(this.core.config.siteTime!) + const $runTimes = document.querySelector('.run-times') + if (!isValidDate(run) || !$runTimes) { + clearInterval(this.siteTime) + $runTimes && $runTimes.parentNode!.removeChild($runTimes) + return + } + const totalSeconds = Math.floor((now.getTime() - run.getTime()) / 1000) + const days = Math.floor(totalSeconds / 86400) + const hours = Math.floor((totalSeconds % 86400) / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + $runTimes.innerHTML = `${days}, ${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` + document.querySelector('.site-time .d-none')?.classList.remove('d-none') + } + + /** Start the site-time counter with visibility-change pausing. */ + initSiteTime() { + if (this.core.config.siteTime) { + this.siteTime = setInterval(() => this.getSiteTime(), 500) + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + return clearInterval(this.siteTime) + } + this.siteTime = setInterval(() => this.getSiteTime(), 500) + }, false) + } + } + + /** Register the service worker for PWA support. */ + initServiceWorker() { + if (this.core.config.PWA?.enable && 'serviceWorker' in navigator) { + navigator.serviceWorker + .register(this.core.config.PWA.serviceWorkerURL, { scope: '/' }) + .then((_registration) => { + // console.log('Service Worker Registered'); + }) + .catch((error) => { + console.error('error: ', error) + }) + navigator.serviceWorker + .ready + .then((_registration) => { + // console.log('Service Worker Ready'); + }) + } + } + + /** Save and restore scroll position as an automatic bookmark. */ + initAutoMark() { + if (!this.core.config.autoBookmark) + return + window.addEventListener('beforeunload', () => { + window.sessionStorage?.setItem(`fixit-bookmark/#${location.pathname}`, String(getScrollTop())) + }) + const scrollTop = Number(window.sessionStorage?.getItem(`fixit-bookmark/#${location.pathname}`)) + if (scrollTop && location.hash === '') { + window.scrollTo({ top: scrollTop, behavior: 'smooth' }) + } + } + + /** Initialize reward/donation button exclusive-toggle behaviour. */ + initReward() { + const $rewards = document.querySelectorAll('.post-reward [data-mode="fixed"]') + if (!$rewards.length) + return + if (isMobile()) { + $rewards.forEach($reward => $reward.removeAttribute('data-mode')) + return + } + const _closeRewardExclude = (id?: string | null) => { + $rewards.forEach(($reward) => { + const $rewardInput = $reward.parentElement!.querySelector('.reward-input') + if ($rewardInput && $rewardInput.id !== id) { + $rewardInput.checked = false + } + }) + } + $rewards.forEach(($reward) => { + $reward.previousElementSibling!.addEventListener('click', function (this: HTMLElement) { + _closeRewardExclude(this.getAttribute('for')) + }, false) + }) + eventBus.on('fixit:scroll', () => _closeRewardExclude()) + } + + /** Initialize the comment section UI. */ + initComment() { + if (!this.core.config.comment?.enable) + return + + if (document.querySelector('#comments')) { + const $viewCommentsBtn = document.querySelector('.view-comments')! + $viewCommentsBtn.classList.remove('d-none') + $viewCommentsBtn.addEventListener('click', () => { + scrollIntoView('#comments') + }, false) + } + + if (this.core.config.comment.expired) + document.querySelector('#comments')!.remove() + } + + /** Initialize PostChat theme sync if configured. */ + initPostChatUser() { + if (!window.postChatUser || !window.postChatConfig || window.postChatConfig.userMode === 'magic') + return + window.postChat_theme = this.core.isDark ? 'dark' : 'light' + eventBus.on('fixit:switch-theme', ({ detail }) => { + if (!detail.isChanged) + return + const targetFrame = document.getElementById('postChat_iframeContainer') + if (targetFrame) { + window.postChatUser.setPostChatTheme(detail.isDark ? 'dark' : 'light') + } + else { + window.postChat_theme = detail.isDark ? 'dark' : 'light' + } + }) + } +} diff --git a/assets/js/modules/pagefind.ts b/assets/js/modules/pagefind.ts new file mode 100644 index 00000000..cd1f396a --- /dev/null +++ b/assets/js/modules/pagefind.ts @@ -0,0 +1,216 @@ +/** + * Pagefind search engine integration with lazy loading, filters, and debounced search. + * + * Responsibilities: + * - Lazy load the Pagefind UI and index on first search interaction. + * - Apply configured filters, sorting, and debounce settings. + * - Manage search dialog open/close lifecycle. + */ + +/** Matches absolute URLs (e.g. "https://..." or "//...") */ +const ABSOLUTE_URL_RE = /^(?:[a-z]+:)?\/\//i + +/** + * Normalize a Pagefind bundle path to a full URL. + * Relative paths are resolved against the given baseURL or document.baseURI. + * @param path - The raw bundle path from config. + * @param baseURL - Optional base URL for resolving relative paths. + * @returns The fully resolved bundle URL. + */ +function normalizeBundlePath(path: string, baseURL?: string): string { + let bundlePath = typeof path === 'string' && path.length > 0 ? path : 'pagefind/' + if (!bundlePath.endsWith('/')) { + bundlePath = `${bundlePath}/` + } + if (ABSOLUTE_URL_RE.test(bundlePath)) { + return bundlePath + } + return new URL(bundlePath, baseURL || document.baseURI).toString() +} + +/** + * Safely cast a value to a plain object; returns `{}` for non-objects. + * @param value - The value to cast. + * @returns A plain object, or `{}` if the value is not an object. + */ +const toObject = (value: unknown): Record => (value && typeof value === 'object' ? value as Record : {}) + +/** + * Normalize sort order to 'asc' or 'desc', defaulting to 'desc'. + * @param value - The raw sort order value. + * @returns `'asc'` or `'desc'`. + */ +function normalizeSortOrder(value: unknown): 'asc' | 'desc' { + return String(value).toLowerCase() === 'asc' + ? 'asc' + : 'desc' +} + +/** + * Replace `` tags in Pagefind excerpts with the configured highlight tag. + * @param excerpt - The excerpt string from Pagefind. + * @param highlightTag - The target HTML tag name. + * @returns The excerpt with replaced highlight tags. + */ +function replaceExcerptHighlightTag(excerpt: string, highlightTag: string): string { + if (!excerpt || !highlightTag || highlightTag === 'mark') { + return excerpt || '' + } + return excerpt + .replaceAll('', `<${highlightTag}>`) + .replaceAll('', ``) +} + +/** + * Create a Pagefind search instance with lazy-loading and built-in filters. + * + * @param searchConfig - The search configuration object from FixIt theme config. + * Expects `searchConfig.pagefind` to contain Pagefind-specific options: + * - `bundlePath` - Path to the Pagefind bundle directory (default: 'pagefind/') + * - `baseURL` - Base URL for resolving relative bundle paths + * - `debounceTimeoutMs` - Debounce timeout in ms for search queries (default: 300) + * - `useBuiltInFilters` - Whether to apply built-in filters for hidden/encrypted pages (default: true) + * - `sortBy` - Field name to sort results by + * - `sortOrder` - Sort direction: 'asc' or 'desc' (default: 'desc') + * @returns An object with `preload()` and `search(query, maxResultLength?)` methods. + */ +export function createPagefindSearch(searchConfig: Record) { + const pagefindConfig = toObject(searchConfig.pagefind) + const bundlePath = normalizeBundlePath(pagefindConfig.bundlePath as string, pagefindConfig.baseURL as string) + const rawDebounceTimeout = Number(pagefindConfig.debounceTimeoutMs ?? 300) + const debounceTimeout = Number.isFinite(rawDebounceTimeout) ? Math.max(0, rawDebounceTimeout) : 300 + const builtInFiltersEnabled = pagefindConfig.useBuiltInFilters !== false + const sortBy = typeof pagefindConfig.sortBy === 'string' ? (pagefindConfig.sortBy as string).trim() : '' + const sortOrder = normalizeSortOrder(pagefindConfig.sortOrder) + const highlightTag = searchConfig.highlightTag ?? 'em' + const excerptLength = Number(searchConfig.snippetLength ?? 30) + + /** Internal state for lazy-loading the Pagefind library. */ + const state: { + loading: Promise | null + initialized: boolean + availableFilters: Record | null + } = { + loading: null, + initialized: false, + availableFilters: null, + } + + /** + * Lazy-load and initialize the Pagefind library. + * The module is imported on first call; subsequent calls return the cached promise. + */ + const ensurePagefind = async () => { + if (!state.loading) { + state.loading = import(/* @vite-ignore */ `${bundlePath}pagefind.js`) + .then(async (mod: any) => { + if (!state.initialized) { + const options: Record = {} + if (Number.isFinite(excerptLength) && excerptLength >= 0) { + options.excerptLength = excerptLength + } + if (Object.keys(options).length && typeof mod.options === 'function') { + await mod.options(options) + } + await mod.init() + state.initialized = true + } + return mod + }) + .catch((error: Error) => { + state.loading = null + throw error + }) + } + return state.loading + } + + /** + * Retrieve available Pagefind filters (e.g. "hidden", "encrypted"). + * Results are cached after the first successful fetch. + */ + const getAvailableFilters = async () => { + if (state.availableFilters) + return state.availableFilters + + const pagefind = await ensurePagefind() + if (typeof pagefind.filters !== 'function') { + state.availableFilters = {} + return state.availableFilters + } + + try { + state.availableFilters = toObject(await pagefind.filters()) + } + catch (error) { + console.warn('[FixIt] failed to read Pagefind filters:', error) + state.availableFilters = {} + } + return state.availableFilters + } + + return { + /** Preload the Pagefind library so the first search is faster. */ + preload() { + return ensurePagefind() + }, + + /** + * Search for a query string using Pagefind. + * + * @param query - The search query. + * @param maxResultLength - Maximum number of results to return (default: 10). + * @returns A list of search results with `uri`, `title`, `date`, and `context` fields, + * or `null` if the search was aborted by Pagefind (e.g. superseded by a newer query). + */ + async search(query: string, maxResultLength?: number) { + if (!query || !query.trim()) + return [] + + const pagefind = await ensurePagefind() + const searchOptions: Record = {} + + // Apply built-in filters to exclude hidden and encrypted pages + if (builtInFiltersEnabled) { + const availableFilters = await getAvailableFilters() + const filters: Record = {} + if (Object.hasOwn(availableFilters, 'hidden')) { + filters.hidden = 'false' + } + if (Object.hasOwn(availableFilters, 'encrypted')) { + filters.encrypted = 'false' + } + if (Object.keys(filters).length) { + searchOptions.filters = filters + } + } + + if (sortBy) { + searchOptions.sort = { [sortBy]: sortOrder } + } + + const resultLimit = Number.isFinite(maxResultLength) + ? Math.max(0, Math.floor(maxResultLength!)) + : 10 + + // Use debounced search when available to avoid rapid-fire queries + const searched = debounceTimeout > 0 && typeof pagefind.debouncedSearch === 'function' + ? await pagefind.debouncedSearch(query, searchOptions, debounceTimeout) + : await pagefind.search(query, searchOptions) + + if (searched === null) + return null + + const records = await Promise.all( + (searched.results || []).slice(0, resultLimit).map((entry: any) => entry.data()), + ) + + return records.map((item: any) => ({ + uri: item.url || '#', + title: item.meta?.title || item.url || '', + date: item.meta?.date || '', + context: replaceExcerptHighlightTag(item.excerpt || '', highlightTag), + })) + }, + } +} diff --git a/assets/js/modules/search.ts b/assets/js/modules/search.ts new file mode 100644 index 00000000..c7da1d40 --- /dev/null +++ b/assets/js/modules/search.ts @@ -0,0 +1,319 @@ +import type { CoreService, SearchService } from '../core/tokens' +import { applyHighlightToText, HTMLEscape, isMobile } from '../utils' +import { createPagefindSearch } from './pagefind' + +const SEARCH_META: Record = { + algolia: { label: 'algolia', icon: '', href: 'https://www.algolia.com/' }, + fuse: { label: 'Fuse.js', icon: '', href: 'https://fusejs.io/' }, + cse: { label: 'Google CSE', icon: '', href: 'https://programmablesearchengine.google.com/' }, + pagefind: { label: 'Pagefind', icon: '', href: 'https://pagefind.app/' }, +} + +/** + * Search module — Algolia, Fuse.js, CSE, and Pagefind search engine integration. + * + * Responsibilities: + * - Initialize the configured search engine backend. + * - Manage search dialog open/close and keyboard shortcuts. + * - Handle autosearch, result rendering, and UI state reset. + */ +export class SearchModule implements SearchService { + #searchMobileOnce: boolean | undefined + #searchDesktopOnce: boolean | undefined + #searchMobile: any + #searchDesktop: any + #algoliaIndex: any + #pagefindSearch: ReturnType | undefined + + constructor(private readonly core: CoreService) {} + + /** + * Reset search UI: close header, hide loading/clear, clear input value. + * @param $header - The header element containing the search. + * @param $searchLoading - The loading indicator element. + * @param $searchClear - The clear button element. + * @param searchInstance - The autocomplete instance to clear. + */ + #resetSearchUI($header: HTMLElement, $searchLoading: HTMLElement, $searchClear: HTMLElement, searchInstance: any) { + $header.classList.remove('open') + $searchLoading.style.display = 'none' + $searchClear.style.display = 'none' + searchInstance && searchInstance.autocomplete.setVal('') + document.getElementById(`search-toggle-${$header.id.replace('header-', '')}`)?.setAttribute('aria-expanded', 'false') + } + + /** Initialize the search overlay, autocomplete, and engine-specific logic. */ + initSearch() { + const searchConfig = this.core.config.search + const _isMobile = isMobile() + if ( + !searchConfig + || (_isMobile && this.#searchMobileOnce) + || (!_isMobile && this.#searchDesktopOnce) + ) { + return + } + const { + maxResultLength = 10, + snippetLength = 50, + highlightTag = 'em', + fuseIndexURL, + } = searchConfig + const suffix = _isMobile ? 'mobile' : 'desktop' + const $header = document.getElementById(`header-${suffix}`)! + const $searchInput = document.getElementById(`search-input-${suffix}`) as HTMLInputElement + const $searchToggle = document.getElementById(`search-toggle-${suffix}`) + const $searchLoading = document.getElementById(`search-loading-${suffix}`) as HTMLElement + const $searchClear = document.getElementById(`search-clear-${suffix}`) as HTMLElement + const $searchCancel = document.getElementById('search-cancel-mobile') + const $menuToggleMobile = document.getElementById('menu-toggle-mobile') + const $menuMobile = document.getElementById('menu-mobile') + if (!$header || !$searchInput || !$searchToggle || !$searchLoading || !$searchClear) + return + const setSearchExpanded = (expanded: boolean) => { + $searchToggle?.setAttribute('aria-expanded', expanded ? 'true' : 'false') + } + const overlayName = `search-${suffix}` + const openSearch = () => { + if (_isMobile && $menuToggleMobile && $menuMobile) { + this.core.disableScrollEvent = true + $menuToggleMobile.classList.add('active') + $menuMobile.classList.add('active') + $menuToggleMobile.setAttribute('aria-expanded', 'true') + } + $header.classList.add('open') + setSearchExpanded(true) + !_isMobile && $searchInput.focus() + } + const closeSearch = () => { + if (_isMobile && $menuToggleMobile && $menuMobile) { + this.core.disableScrollEvent = false + $menuToggleMobile.classList.remove('active') + $menuMobile.classList.remove('active') + $menuToggleMobile.setAttribute('aria-expanded', 'false') + } + this.#resetSearchUI($header, $searchLoading, $searchClear, _isMobile ? this.#searchMobile : this.#searchDesktop) + } + + // goto the PostChat panel rather than search results + if (searchConfig.type === 'post-chat' && window.postChatUser) { + if (_isMobile) { + $searchInput.addEventListener('focus', () => { + window.postChatUser.setSearchInput('') + }, false) + } + else { + $searchToggle.addEventListener('click', () => { + window.postChatUser.setSearchInput('') + }, false) + } + return + } + + if (_isMobile) { + this.#searchMobileOnce = true + this.core.registerMaskOverlay(overlayName, { + isActive: () => $header.classList.contains('open'), + onOpen: openSearch, + onClose: closeSearch, + }) + $searchInput.addEventListener('focus', () => { + this.core.openMaskOverlay(overlayName) + }, false) + $searchCancel?.addEventListener('click', () => { + this.core.closeMaskOverlay(overlayName) + }, false) + $searchClear.addEventListener('click', () => { + this.core.disableScrollEvent = false + $searchClear.style.display = 'none' + this.#searchMobile && this.#searchMobile.autocomplete.setVal('') + }, false) + } + else { + this.#searchDesktopOnce = true + this.core.registerMaskOverlay(overlayName, { + isActive: () => $header.classList.contains('open'), + onOpen: openSearch, + onClose: closeSearch, + }) + $searchToggle.addEventListener('click', () => { + this.core.toggleMaskOverlay(overlayName) + }, false) + $searchClear.addEventListener('click', () => { + $searchClear.style.display = 'none' + this.#searchDesktop && this.#searchDesktop.autocomplete.setVal('') + }, false) + } + $searchInput.addEventListener('input', () => { + if ($searchInput.value === '') + $searchClear.style.display = 'none' + else $searchClear.style.display = 'inline' + }, false) + if (searchConfig.type === 'pagefind') { + this.#pagefindSearch = this.#pagefindSearch || createPagefindSearch(searchConfig) + $searchInput.addEventListener('focus', () => { + this.#pagefindSearch!.preload().catch((error: Error) => { + console.error(error) + }) + }, { once: true }) + } + + const initAutosearch = () => { + const autosearch = window.autocomplete!(`#search-input-${suffix}`, { + hint: false, + autoselect: true, + dropdownMenuContainer: `#search-dropdown-${suffix}`, + clearOnSelected: true, + cssClasses: { noPrefix: true }, + debug: false, + }, { + name: 'search', + source: (query: string, callback: (results: any[]) => void) => { + $searchLoading.style.display = 'inline' + $searchClear.style.display = 'none' + const finish = (results: any[]) => { + $searchLoading.style.display = 'none' + $searchClear.style.display = 'inline' + callback(results) + } + if (searchConfig.type === 'algolia') { + this.#algoliaIndex + = this.#algoliaIndex + || window.algoliasearch!( + searchConfig.algoliaAppID, + searchConfig.algoliaSearchKey, + ).initIndex(searchConfig.algoliaIndex) + this.#algoliaIndex + .search(query, { + offset: 0, + length: maxResultLength * 8, + attributesToHighlight: ['title'], + attributesToRetrieve: ['*'], + attributesToSnippet: [`content:${snippetLength}`], + highlightPreTag: `<${highlightTag}>`, + highlightPostTag: ``, + }) + .then(({ hits }: { hits: any[] }) => { + const results: Record = {} + hits.forEach(({ uri, date, _highlightResult: { title }, _snippetResult: { content } }: any) => { + if (results[uri] && results[uri].context.length > content.value) + return + results[uri] = { + uri, + title: title.value, + date, + context: content.value, + } + }) + finish(Object.values(results).slice(0, maxResultLength)) + }) + .catch((err: Error) => { + console.error(err) + finish([]) + }) + } + else if (searchConfig.type === 'fuse') { + const search = () => { + const results: Record = {} + window._fuseIndex.search(query).forEach(({ item, matches }: any) => { + let title = item.title + let content = item.content + matches.forEach(({ indices, key }: any) => { + if (key === 'content') { + content = applyHighlightToText(content, indices, highlightTag) + } + else if (key === 'title') { + title = applyHighlightToText(title, indices, highlightTag) + } + }) + results[item.uri] = { + uri: item.uri, + title, + date: item.date, + context: content, + } + }) + return Object.values(results).slice(0, maxResultLength) + } + if (!window._fuseIndex) { + fetch(fuseIndexURL!) + .then(response => response.json()) + .then((data) => { + window._fuseIndex = new window.Fuse!(data, { + isCaseSensitive: searchConfig.isCaseSensitive ?? false, + findAllMatches: searchConfig.findAllMatches ?? false, + minMatchCharLength: searchConfig.minMatchCharLength ?? 1, + location: searchConfig.location ?? 0, + threshold: searchConfig.threshold ?? 0.3, + distance: searchConfig.distance ?? 100, + ignoreLocation: searchConfig.ignoreLocation ?? false, + useExtendedSearch: searchConfig.useExtendedSearch ?? false, + ignoreFieldNorm: searchConfig.ignoreFieldNorm ?? false, + includeScore: false, + shouldSort: true, + includeMatches: true, + keys: ['content', 'title'], + }) + finish(search()) + }) + .catch((err: Error) => { + console.error(err) + finish([]) + }) + } + else { + finish(search()) + } + } + else if (searchConfig.type === 'cse') { + const cseConfig = this.core.config.cse + if (cseConfig?.engine === 'google' && cseConfig.cx) { + finish([{ + uri: `${cseConfig.resultsPage}#gsc.tab=0&gsc.q=${encodeURIComponent(query)}`, + title: cseConfig.searchIn, + date: '', + context: cseConfig.gotoResultsPage, + }]) + } + } + else if (searchConfig.type === 'pagefind') { + this.#pagefindSearch! + .search(query, maxResultLength) + .then((results: any[] | null) => { + finish(results || []) + }) + .catch((err: Error) => { + console.error(err) + finish([]) + }) + } + else { + finish([]) + } + }, + templates: { + suggestion: ({ title, uri, date, context }: any) => + `
${title}${date}
${context}
`, + empty: ({ query }: any) => `
${searchConfig.noResultsFound}: "${HTMLEscape(query)}"
`, + footer: () => { + const meta = SEARCH_META[searchConfig.type!] + if (!meta) + return '' + return `` + }, + }, + }) + autosearch.on('autocomplete:selected', (_event: any, suggestion: any, _dataset: any, _context: any) => { + this.core.closeMaskOverlay(overlayName) + window.location.assign(suggestion.uri) + }) + if (_isMobile) { + this.#searchMobile = autosearch + } + else { + this.#searchDesktop = autosearch + } + } + initAutosearch() + } +} diff --git a/assets/js/modules/theme.ts b/assets/js/modules/theme.ts new file mode 100644 index 00000000..520a37b4 --- /dev/null +++ b/assets/js/modules/theme.ts @@ -0,0 +1,79 @@ +import type { CoreService, ThemeService } from '../core/tokens' +import { eventBus } from '../core/event-bus' + +/** + * Theme module — color scheme switching and theme-color meta tag management. + * + * Responsibilities: + * - Toggle between light, dark, and auto color schemes. + * - Update `` based on current scheme. + * - Persist user preference to localStorage. + */ +export class ThemeModule implements ThemeService { + private readonly mql = window.matchMedia('(prefers-color-scheme: dark)') + + constructor(private readonly core: CoreService) {} + + /** + * Apply a theme mode and emit the `fixit:switch-theme` event. + * @param mode - `'auto'`, `'light'`, or `'dark'`. + * @param persist - Whether to save the choice to localStorage (default: `true`). + */ + setThemeMode(mode: string, persist = true) { + const prevIsDark = this.core.isDark + this.core.themeMode = mode + document.documentElement.dataset.themeMode = mode + this.core.isDark = mode === 'auto' ? this.mql.matches : mode === 'dark' + + if (persist) { + window.localStorage?.setItem('theme-mode', mode) + } + + eventBus.emit('fixit:switch-theme', { + isDark: this.core.isDark, + mode, + isChanged: prevIsDark !== this.core.isDark, + }) + } + + /** Sync the `` tag with the current color scheme. */ + initThemeColor() { + const $meta = document.querySelector('[name="theme-color"]') + if (!$meta) + return + const applyThemeColor = (isDark: boolean) => { + $meta.content = isDark ? $meta.dataset.dark! : $meta.dataset.light! + } + eventBus.on('fixit:switch-theme', ({ detail }) => { + if (!detail.isChanged) + return + applyThemeColor(detail.isDark) + }) + applyThemeColor(this.core.isDark) + } + + /** Initialize the theme switch button cycle and system preference listener. */ + initSwitchTheme() { + const modes = ['auto', 'light', 'dark'] as const + + document.querySelectorAll('.theme-switch').forEach(($themeSwitch: Element) => { + $themeSwitch.addEventListener('click', () => { + const currentIndex = modes.indexOf(this.core.themeMode as typeof modes[number]) + const nextMode = modes[(currentIndex + 1) % modes.length] + this.setThemeMode(nextMode) + }, false) + }) + + this.mql.addEventListener('change', (e: MediaQueryListEvent) => { + if (this.core.themeMode !== 'auto') + return + const prevIsDark = this.core.isDark + this.core.isDark = e.matches + eventBus.emit('fixit:switch-theme', { + isDark: this.core.isDark, + mode: 'auto', + isChanged: prevIsDark !== this.core.isDark, + }) + }) + } +} diff --git a/assets/js/modules/toc.ts b/assets/js/modules/toc.ts new file mode 100644 index 00000000..c6dfbbda --- /dev/null +++ b/assets/js/modules/toc.ts @@ -0,0 +1,281 @@ +import type { TocService } from '../core/tokens' +import { animateCSS, isTocStatic } from '../utils' + +/** + * Table of Contents module — TOC scroll tracking, active state sync, and dialog. + * + * Responsibilities: + * - Move TOC node to the correct container (static, auto, or drawer) on init. + * - Track scroll position and highlight the active heading in all TOC containers. + * - Initialize mobile TOC drawer dialog and its open/close handlers. + * - Clone TOC nodes to detach APlayer event listeners. + */ +export class TocModule implements TocService { + private activeTocId: string | null = null + + /** Get the pixel height of the currently visible sticky header. */ + getVisibleHeaderOffset(): number { + const $desktopHeader = document.getElementById('header-desktop') + const $mobileHeader = document.getElementById('header-mobile') + const $header = [$desktopHeader, $mobileHeader].find($el => $el && window.getComputedStyle($el).display !== 'none') + if (!$header) + return 0 + const isDesktop = $header.id === 'header-desktop' + const headerMode = isDesktop ? document.body.dataset.headerDesktop : document.body.dataset.headerMobile + if (!['sticky', 'auto'].includes(headerMode!)) + return 0 + if (headerMode === 'auto' && $header.classList.contains('header__fadeOutUp')) + return 0 + return $header.offsetHeight + } + + /** Get the pixel height of the breadcrumb container. */ + getBreadcrumbHeight(): number { + return document.querySelector('.breadcrumb-container')?.offsetHeight || 0 + } + + /** Get the combined vertical offset used to determine the active TOC heading. */ + getTocIndexOffset(): number { + return 20 + this.getVisibleHeaderOffset() + this.getBreadcrumbHeight() + } + + /** Get all heading elements that have an `id` attribute. */ + getTocHeadingElements(): HTMLElement[] { + return Array.from(document.querySelectorAll('.heading-element[id]')) + } + + /** + * Determine which heading is currently active based on scroll position. + * @param $headingElements - Array of heading elements with `id` attributes. + * @param indexOffset - Vertical offset from the top for the active threshold. + * @returns The active heading element, or `null` if none found. + */ + getActiveTocHeading($headingElements: HTMLElement[], indexOffset = this.getTocIndexOffset()): HTMLElement | null { + if (!$headingElements.length) + return null + const threshold = window.scrollY + indexOffset + 1 + let $activeHeading = $headingElements[0] + for (const $heading of $headingElements) { + const headingTop = window.scrollY + $heading.getBoundingClientRect().top + if (headingTop <= threshold) { + $activeHeading = $heading + } + else { + break + } + } + return $activeHeading + } + + /** Get all TOC root containers (static, auto, and drawer). */ + getTocRoots(): HTMLElement[] { + return [ + document.getElementById('TableOfContents'), + document.querySelector('#toc-content-static > nav'), + document.querySelector('#toc-content-drawer > nav'), + ].filter(Boolean) as HTMLElement[] + } + + /** + * Find the TOC link that points to the given heading id. + * @param $tocRoot - The TOC root container element. + * @param id - The heading id (without `#`). + * @returns The matching anchor element, or `null`. + */ + getTocLinkById($tocRoot: HTMLElement, id: string): HTMLAnchorElement | null { + if (!$tocRoot || !id) + return null + const targetHash = `#${id}` + return Array.from($tocRoot.querySelectorAll('a[href^="#"]')).find($link => $link.getAttribute('href') === targetHash) || null + } + + /** + * Highlight the active TOC item and its parent chain. + * @param $tocRoot - The TOC root container element. + * @param activeId - The id of the currently active heading. + */ + applyTocActiveState($tocRoot: HTMLElement, activeId: string) { + if (!$tocRoot) + return + $tocRoot.querySelectorAll('a[href^="#"]').forEach(($tocLink: Element) => { + $tocLink.classList.remove('active') + }) + $tocRoot.querySelectorAll('li').forEach(($tocLi: Element) => { + $tocLi.classList.remove('has-active') + }) + const $activeLink = this.getTocLinkById($tocRoot, activeId) + if (!$activeLink) + return + $activeLink.classList.add('active') + let $parent = $activeLink.closest('li') + while ($parent) { + $parent.classList.add('has-active') + $parent = $parent.parentElement?.closest('li') || null + } + } + + /** + * Scroll the active TOC link into the visible area of its container. + * @param $tocRoot - The TOC root container element. + * @param activeId - The id of the currently active heading. + * @param $scrollContainer - The scrollable container (defaults to `$tocRoot`). + */ + scrollActiveTocLinkIntoView($tocRoot: HTMLElement, activeId: string, $scrollContainer: HTMLElement = $tocRoot) { + const $activeLink = this.getTocLinkById($tocRoot, activeId) + if (!$activeLink || !$scrollContainer) + return + const containerRect = $scrollContainer.getBoundingClientRect() + const linkRect = $activeLink.getBoundingClientRect() + const offsetTop = linkRect.top - containerRect.top + const offsetBottom = linkRect.bottom - containerRect.bottom + if (offsetTop < 0) { + $scrollContainer.scrollTop += offsetTop + } + else if (offsetBottom > 0) { + $scrollContainer.scrollTop += offsetBottom + } + } + + /** Update the TOC container's max-height CSS variable to fit the viewport. */ + syncTocHeight() { + const $toc = document.getElementById('toc-auto') + const $tocContentAuto = document.getElementById('toc-content-auto') + if ($toc && $tocContentAuto) { + const maxHeight = Math.max(window.innerHeight - $tocContentAuto.getBoundingClientRect().top - 16) + $tocContentAuto.style.setProperty('--fi-toc-content-max-height', `${Math.floor(maxHeight)}px`) + } + } + + /** Sync the active heading highlight across all TOC containers. */ + syncTocActiveState() { + const $headingElements = this.getTocHeadingElements() + const $activeHeading = this.getActiveTocHeading($headingElements) + if (!$activeHeading?.id) + return + const activeId = $activeHeading.id + const $tocRoots = this.getTocRoots() + $tocRoots.forEach(($tocRoot) => { + this.applyTocActiveState($tocRoot, activeId) + }) + if (this.activeTocId !== activeId) { + this.activeTocId = activeId + if (!isTocStatic()) { + const $autoTocRoot = document.getElementById('TableOfContents') + const $autoTocContainer = document.getElementById('toc-content-auto') + if ($autoTocRoot && $autoTocContainer) { + this.scrollActiveTocLinkIntoView($autoTocRoot, activeId, $autoTocContainer) + } + } + if ((document.getElementById('toc-dialog') as HTMLDialogElement)?.open) { + const $dialogTocRoot = document.querySelector('#toc-content-drawer > nav')! + this.scrollActiveTocLinkIntoView($dialogTocRoot, activeId, $dialogTocRoot) + } + } + } + + /** Initialize TOC layout: move the TOC node to the correct container and sync state. */ + initToc() { + const $tocCore = document.getElementById('TableOfContents') + if ($tocCore === null) + return + // TOC Drawer Button Visibility + const openButton = document.querySelector('#toc-drawer-button') + if (openButton) { + openButton.classList.toggle('d-none', !isTocStatic()) + } + this.activeTocId = null + // TOC Static and TOC Dialog + if (isTocStatic()) { + const $tocContentStatic = document.getElementById('toc-content-static')! + if ($tocCore.parentElement !== $tocContentStatic) { + $tocCore.parentElement!.removeChild($tocCore) + $tocContentStatic.appendChild($tocCore) + } + this.syncTocHeight() + this.syncTocActiveState() + return + } + + // TOC Auto + const $tocContentAuto = document.getElementById('toc-content-auto')! + if ($tocCore.parentElement !== $tocContentAuto) { + $tocCore.parentElement!.removeChild($tocCore) + $tocContentAuto.appendChild($tocCore) + } + const $toc = document.getElementById('toc-auto')! + $toc.style.visibility = 'visible' + animateCSS($toc, ['animate__fadeIn', 'animate__faster'], true) + this.syncTocHeight() + this.syncTocActiveState() + } + + /** Bind the TOC title click handler for show/hide toggle. */ + initTocListener() { + const $toc = document.getElementById('toc-auto')! + const $tocContentAuto = document.getElementById('toc-content-auto')! + document.querySelector('#toc-auto>.toc-title')?.addEventListener('click', () => { + const animation = ['animate__faster'] + const tocHidden = $toc.classList.contains('toc-hidden') + animation.push(tocHidden ? 'animate__fadeIn' : 'animate__fadeOut') + if (tocHidden) { + $tocContentAuto.classList.remove('d-none', 'animate__fadeOut') + } + else { + $tocContentAuto.classList.remove('animate__fadeIn') + } + animateCSS($tocContentAuto, animation, true, () => { + $tocContentAuto.classList.contains('animate__fadeOut') && $tocContentAuto.classList.add('d-none') + }) + $toc.classList.toggle('toc-hidden') + }, false) + } + + /** Initialize the mobile TOC drawer dialog and its open/close handlers. */ + initTocDialog() { + const dialog = document.querySelector('#toc-dialog') + const openButton = document.querySelector('#toc-drawer-button') + if (!dialog || !openButton) + return + const closeButton = dialog.querySelector('.toc-close-btn') + closeButton?.addEventListener('click', () => dialog.close()) + openButton.addEventListener('click', () => { + dialog.showModal() + openButton.setAttribute('aria-expanded', 'true') + this.syncTocHeight() + this.syncTocActiveState() + const $dialogTocRoot = document.querySelector('#toc-content-drawer > nav')! + this.scrollActiveTocLinkIntoView($dialogTocRoot, this.activeTocId!, $dialogTocRoot) + ;(document.activeElement as HTMLElement)?.blur() + }) + document.querySelectorAll('#toc-content-drawer a[href^="#"]').forEach(($link) => { + $link.addEventListener('click', () => dialog.close()) + }) + dialog.addEventListener('close', () => { + openButton.setAttribute('aria-expanded', 'false') + }) + } + + /** Clone TOC and heading-mark nodes to detach APlayer event listeners. */ + fixTocScroll() { + if (typeof window.APlayer === 'function') { + let $tocCore = document.getElementById('TableOfContents') + if ($tocCore) { + const $newTocCore = $tocCore.cloneNode(true) as HTMLElement + $tocCore.parentElement!.replaceChild($newTocCore, $tocCore) + $tocCore = $newTocCore + } + document.querySelectorAll('.heading-mark').forEach(($headingMark: Element) => { + const $newHeadingMark = $headingMark.cloneNode(true) + $headingMark.parentElement!.replaceChild($newHeadingMark, $headingMark) + }) + } + } + + /** Initialize all TOC components and register event listeners. */ + setup() { + this.fixTocScroll() + this.initToc() + this.initTocListener() + this.initTocDialog() + } +} diff --git a/assets/js/pages/link.js b/assets/js/pages/link.js deleted file mode 100644 index cb746b0f..00000000 --- a/assets/js/pages/link.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * for link redirection page - */ - -import { createCopyText } from '../utils/common'; - -const copyText = createCopyText(); - -function initLinkGuard() { - const params = new URLSearchParams(window.location.search); - const target = params.get('target'); - const targetElement = document.querySelector('.target'); - const copyBtn = document.querySelector('.copy-icon-btn'); - const confirmBtn = document.querySelector('.confirm-btn'); - - if (target) { - targetElement.textContent = target; - copyBtn.disabled = false; - confirmBtn.disabled = false; - - copyBtn.addEventListener('click', () =>{ - copyText(target).then(() => { - copyBtn.toggleAttribute('data-copied', true); - setTimeout(() => { - copyBtn.toggleAttribute('data-copied', false); - }, 2000); - }, () => { - console.error('Clipboard write failed!', 'Your browser does not support clipboard API!'); - }); - }); - - confirmBtn.addEventListener('click', () => { - window.location.href = target; - }); - } else { - targetElement.textContent = 'Invalid target URL'; - } -} - -if (document.readyState !== 'loading') { - initLinkGuard(); -} else { - document.addEventListener('DOMContentLoaded', initLinkGuard, false); -} diff --git a/assets/js/pages/link.ts b/assets/js/pages/link.ts new file mode 100644 index 00000000..452fad18 --- /dev/null +++ b/assets/js/pages/link.ts @@ -0,0 +1,45 @@ +/** + * Link guard redirection page — displays target URL with copy and confirm actions. + * + * Responsibilities: + * - Parse the `target` query parameter and display the destination URL. + * - Allow users to copy the target URL to clipboard. + * - Confirm and navigate to the target URL on user action. + */ +import { createCopyText } from '../utils' + +const copyText = createCopyText() + +function initLinkGuard(): void { + const params = new URLSearchParams(window.location.search) + const target = params.get('target') + const $target = document.querySelector('.target')! + const $copy = document.querySelector('.copy-icon-btn')! + const $confirm = document.querySelector('.confirm-btn')! + + if (!target) { + $target.textContent = 'Invalid target URL' + return + } + + $target.textContent = target + $copy.disabled = false + $confirm.disabled = false + + $copy.addEventListener('click', () => { + copyText(target).then(() => { + $copy.toggleAttribute('data-copied', true) + window.setTimeout(() => { + $copy.toggleAttribute('data-copied', false) + }, 2000) + }, () => { + console.error('Clipboard write failed!', 'Your browser does not support clipboard API!') + }) + }) + + $confirm.addEventListener('click', () => { + window.location.href = target + }) +} + +document.addEventListener('DOMContentLoaded', initLinkGuard, false) diff --git a/assets/js/service-worker.js b/assets/js/service-worker.js index 287e36b8..d3d41688 100644 --- a/assets/js/service-worker.js +++ b/assets/js/service-worker.js @@ -1,6 +1,7 @@ /** * Service Worker * imported from https://github.com/HEIGE-PCloud/DoIt/blob/v0.2.11/src/js/sw.js + * [todo] rewrite with TypeScript and fixes issue #298 */ const CACHE_VERSION = 1; @@ -167,7 +168,7 @@ self.addEventListener('activate', (event) => { self.clients.claim(), self.skipWaiting() ]).catch((err) => { - console.log(err); + console.warn(err); self.skipWaiting(); }) ); @@ -209,7 +210,7 @@ self.addEventListener('fetch', (event) => { resolve(response); }); }).catch((err) => { - console.log(err); + console.warn(err); return response; }); } else { diff --git a/assets/js/theme.js b/assets/js/theme.js deleted file mode 100644 index 9f5ccc2c..00000000 --- a/assets/js/theme.js +++ /dev/null @@ -1,2074 +0,0 @@ -// TODO use ESLint to check the code style -import { - forEach, - getScrollTop, - isMobile, - getThemeMode, - isDarkMode, - isTocStatic, - animateCSS, - isValidDate, - scrollIntoView, - getStagingDOM, - createCopyText, - isObjectLiteral, - HTMLEscape, -} from './utils/common'; -import FileTree from './lib/file-tree.js' -import { createPagefindSearch } from './lib/pagefind-search.js' - -const copyText = createCopyText(); - -class FixIt { - constructor() { - this.config = window.config; - this.themeMode = getThemeMode(); - this.isDark = isDarkMode(); - this.newScrollTop = getScrollTop(); - this.oldScrollTop = this.newScrollTop; - this.maskOverlays = new Map(); - this.activeMaskOverlay = null; - this.activeTocId = null; - this.scrollEventSet = new Set(); - this.resizeEventSet = new Set(); - this.switchThemeEventSet = new Set(); - this.beforeprintEventSet = new Set(); - this.afterprintEventSet = new Set(); - window.objectFitImages && objectFitImages(); - } - - initThemeColor() { - const $meta = document.querySelector('[name="theme-color"]'); - if (!$meta) return; - this._themeColorOnSwitchTheme = this._themeColorOnSwitchTheme || (() => { - $meta.content = this.isDark ? $meta.dataset.dark : $meta.dataset.light; - }); - this.switchThemeEventSet.add(this._themeColorOnSwitchTheme); - this._themeColorOnSwitchTheme(); - } - - initSVGIcon() { - forEach(document.querySelectorAll('[data-svg-src]'), ($icon) => { - fetch($icon.dataset.svgSrc) - .then((response) => response.text()) - .then((svg) => { - const $temp = document.createElement('div'); - $temp.insertAdjacentHTML('afterbegin', svg); - const $svg = $temp.firstChild; - $svg.dataset.svgSrc = $icon.dataset.svgSrc - $svg.classList.add('icon'); - const $titleElements = $svg.getElementsByTagName('title'); - $titleElements.length && $svg.removeChild($titleElements[0]); - $icon.parentElement.replaceChild($svg, $icon); - }) - .catch((err) => { - console.error(err); - }); - }); - } - - initTwemoji(target = document) { - this.config.twemoji && twemoji.parse(target); - } - - registerMaskOverlay(name, handlers) { - this.maskOverlays.set(name, handlers); - } - - syncMaskState() { - document.getElementById('mask')?.classList.toggle('blur', Boolean(this.activeMaskOverlay)); - } - - openMaskOverlay(name) { - this.disableScrollEvent = true; - const overlay = this.maskOverlays.get(name); - if (!overlay) return; - if (this.activeMaskOverlay && this.activeMaskOverlay !== name) { - this.closeMaskOverlay(this.activeMaskOverlay, true); - } - overlay.onOpen?.(); - this.activeMaskOverlay = name; - this.syncMaskState(); - } - - closeMaskOverlay(name, skipSync = false) { - this.disableScrollEvent = false; - const overlay = this.maskOverlays.get(name); - if (!overlay) return; - overlay.onClose?.(); - if (this.activeMaskOverlay === name) { - this.activeMaskOverlay = null; - } - !skipSync && this.syncMaskState(); - } - - toggleMaskOverlay(name) { - const overlay = this.maskOverlays.get(name); - if (!overlay) return; - const isActive = overlay.isActive?.() ?? this.activeMaskOverlay === name; - if (this.activeMaskOverlay === name && isActive) { - this.closeMaskOverlay(name); - return; - } - this.openMaskOverlay(name); - } - - closeActiveMaskOverlay() { - if (!this.activeMaskOverlay) { - this.syncMaskState(); - return; - } - this.closeMaskOverlay(this.activeMaskOverlay); - } - - initMenu() { - this.initMenuDesktop(); - this.initMenuMobile(); - } - - initMenuDesktop() { - forEach(document.querySelectorAll('.has-children'), ($item) => { - $item.querySelector('.sub-menu').style.minWidth = `${$item.offsetWidth - 8}px`; - }); - } - - initMenuMobile() { - const $menuToggleMobile = document.getElementById('menu-toggle-mobile'); - const $menuMobile = document.getElementById('menu-mobile'); - if (!$menuToggleMobile || !$menuMobile) return; - this.registerMaskOverlay('menu-mobile', { - isActive: () => $menuMobile.classList.contains('active'), - onOpen: () => { - $menuToggleMobile.classList.add('active'); - $menuMobile.classList.add('active'); - $menuToggleMobile.setAttribute('aria-expanded', 'true'); - }, - onClose: () => { - $menuToggleMobile.classList.remove('active'); - $menuMobile.classList.remove('active'); - $menuToggleMobile.setAttribute('aria-expanded', 'false'); - }, - }); - $menuToggleMobile.addEventListener('click', () => { - this.toggleMaskOverlay('menu-mobile'); - }, false); - this._menuMobileOnClickMask = this._menuMobileOnClickMask || (() => { - $menuToggleMobile.classList.remove('active'); - $menuMobile.classList.remove('active'); - $menuToggleMobile.setAttribute('aria-expanded', 'false'); - }); - // add nested menu toggler - forEach(document.querySelectorAll('.menu-item>.nested-item'), ($nestedItem) => { - $nestedItem.addEventListener('click', function () { - this.parentNode.querySelector('.sub-menu').classList.toggle('open'); - this.querySelector('.dropdown-icon').classList.toggle('open'); - }); - }); - } - - initSwitchTheme() { - const mql = window.matchMedia('(prefers-color-scheme: dark)'); - const modes = ['auto', 'light', 'dark']; - const applyThemeMode = (mode, persist = true) => { - this.themeMode = mode; - document.documentElement.dataset.themeMode = mode; - this.isDark = mode === 'auto' ? mql.matches : mode === 'dark'; - - if (persist) { - window.localStorage?.setItem('theme-mode', mode); - } - - for (let event of this.switchThemeEventSet) { - event(this.isDark); - } - }; - - forEach(document.getElementsByClassName('theme-switch'), ($themeSwitch) => { - $themeSwitch.addEventListener('click', () => { - const currentIndex = modes.indexOf(this.themeMode); - const nextMode = modes[(currentIndex + 1) % modes.length]; - applyThemeMode(nextMode); - }, false); - }); - - mql.addEventListener('change', (e) => { - if (this.themeMode !== 'auto') return; - this.isDark = e.matches; - for (let event of this.switchThemeEventSet) { - event(this.isDark); - } - }); - } - - /** - * Helper method to apply highlight tags to text based on match indices - * @param {String} text - The text to highlight - * @param {Array} indices - Array of match indices - * @param {String} highlightTag - The HTML tag to use for highlighting - * @returns {String} The highlighted text - */ - _applyHighlightToText(text, indices, highlightTag) { - let offset = 0; - for (let i = 0; i < indices.length; i++) { - const substr = text.substring(indices[i][0] + offset, indices[i][1] + 1 + offset); - const tag = `<${highlightTag}>` + substr + ``; - text = text.substring(0, indices[i][0] + offset) + tag + text.substring(indices[i][1] + 1 + offset, text.length); - offset += highlightTag.length * 2 + 5; - } - return text; - } - - /** - * Helper method to reset search UI elements - * @param {Element} $header - The header element - * @param {Element} $searchLoading - The loading indicator element - * @param {Element} $searchClear - The clear button element - * @param {Object} searchInstance - The search autocomplete instance - */ - _resetSearchUI($header, $searchLoading, $searchClear, searchInstance) { - $header.classList.remove('open'); - $searchLoading.style.display = 'none'; - $searchClear.style.display = 'none'; - searchInstance && searchInstance.autocomplete.setVal(''); - document.getElementById(`search-toggle-${$header.id.replace('header-', '')}`)?.setAttribute('aria-expanded', 'false'); - } - - initSearch() { - const searchConfig = this.config.search; - const _isMobile = isMobile(); - if ( - !searchConfig || - (_isMobile && this._searchMobileOnce) || - (!_isMobile && this._searchDesktopOnce) - ) - return; - // Initialize default search config - const maxResultLength = searchConfig.maxResultLength ?? 10; - const snippetLength = searchConfig.snippetLength ?? 50; - const highlightTag = searchConfig.highlightTag ?? 'em'; - const isCaseSensitive = searchConfig.isCaseSensitive ?? false; - const minMatchCharLength = searchConfig.minMatchCharLength ?? 1; - const findAllMatches = searchConfig.findAllMatches ?? false; - const location = searchConfig.location ?? 0; - const threshold = searchConfig.threshold ?? 0.3; - const distance = searchConfig.distance ?? 100; - const ignoreLocation = searchConfig.ignoreLocation ?? false; - const useExtendedSearch = searchConfig.useExtendedSearch ?? false; - const ignoreFieldNorm = searchConfig.ignoreFieldNorm ?? false; - const suffix = _isMobile ? 'mobile' : 'desktop'; - const $header = document.getElementById(`header-${suffix}`); - const $searchInput = document.getElementById(`search-input-${suffix}`); - const $searchToggle = document.getElementById(`search-toggle-${suffix}`); - const $searchLoading = document.getElementById(`search-loading-${suffix}`); - const $searchClear = document.getElementById(`search-clear-${suffix}`); - const $searchCancel = document.getElementById('search-cancel-mobile'); - const $menuToggleMobile = document.getElementById('menu-toggle-mobile'); - const $menuMobile = document.getElementById('menu-mobile'); - if (!$header || !$searchInput || !$searchToggle || !$searchLoading || !$searchClear) return; - const setSearchExpanded = (expanded) => { - $searchToggle?.setAttribute('aria-expanded', expanded ? 'true' : 'false'); - }; - const overlayName = `search-${suffix}`; - const openSearch = () => { - if (_isMobile && $menuToggleMobile && $menuMobile) { - this.disableScrollEvent = true; - $menuToggleMobile.classList.add('active'); - $menuMobile.classList.add('active'); - $menuToggleMobile.setAttribute('aria-expanded', 'true'); - } - $header.classList.add('open'); - setSearchExpanded(true); - !_isMobile && $searchInput.focus(); - }; - const closeSearch = () => { - if (_isMobile && $menuToggleMobile && $menuMobile) { - this.disableScrollEvent = false; - $menuToggleMobile.classList.remove('active'); - $menuMobile.classList.remove('active'); - $menuToggleMobile.setAttribute('aria-expanded', 'false'); - } - this._resetSearchUI($header, $searchLoading, $searchClear, _isMobile ? this._searchMobile : this._searchDesktop); - }; - - // goto the PostChat panel rather than search results - if (searchConfig.type === 'post-chat' && window.postChatUser) { - if (_isMobile) { - $searchInput.addEventListener('focus', () => { - window.postChatUser.setSearchInput(''); - }, false); - } else { - $searchToggle.addEventListener('click', () => { - window.postChatUser.setSearchInput(''); - }, false); - } - return; - } - - if (_isMobile) { - this._searchMobileOnce = true; - this.registerMaskOverlay(overlayName, { - isActive: () => $header.classList.contains('open'), - onOpen: openSearch, - onClose: closeSearch, - }); - $searchInput.addEventListener('focus', () => { - this.openMaskOverlay(overlayName); - }, false); - $searchCancel.addEventListener('click', () => { - this.closeMaskOverlay(overlayName); - }, false); - $searchClear.addEventListener('click', () => { - this.disableScrollEvent = false; - $searchClear.style.display = 'none'; - this._searchMobile && this._searchMobile.autocomplete.setVal(''); - }, false); - } else { - this._searchDesktopOnce = true; - this.registerMaskOverlay(overlayName, { - isActive: () => $header.classList.contains('open'), - onOpen: openSearch, - onClose: closeSearch, - }); - $searchToggle.addEventListener('click', () => { - this.toggleMaskOverlay(overlayName); - }, false); - $searchClear.addEventListener('click', () => { - $searchClear.style.display = 'none'; - this._searchDesktop && this._searchDesktop.autocomplete.setVal(''); - }, false); - } - $searchInput.addEventListener('input', () => { - if ($searchInput.value === '') $searchClear.style.display = 'none'; - else $searchClear.style.display = 'inline'; - }, false); - if (searchConfig.type === 'pagefind') { - this._pagefindSearch = this._pagefindSearch || createPagefindSearch(searchConfig); - $searchInput.addEventListener('focus', () => { - this._pagefindSearch.preload().catch((error) => { - console.error(error); - }); - }, { once: true }); - } - - const initAutosearch = () => { - const autosearch = autocomplete(`#search-input-${suffix}`, - { - hint: false, - autoselect: true, - dropdownMenuContainer: `#search-dropdown-${suffix}`, - clearOnSelected: true, - cssClasses: { noPrefix: true }, - debug: false - }, - { - name: 'search', - source: (query, callback) => { - $searchLoading.style.display = 'inline'; - $searchClear.style.display = 'none'; - const finish = (results) => { - $searchLoading.style.display = 'none'; - $searchClear.style.display = 'inline'; - callback(results); - }; - if (searchConfig.type === 'algolia') { - this._algoliaIndex = - this._algoliaIndex || - algoliasearch( - searchConfig.algoliaAppID, - searchConfig.algoliaSearchKey - ).initIndex(searchConfig.algoliaIndex); - this._algoliaIndex - .search(query, { - offset: 0, - length: maxResultLength * 8, - attributesToHighlight: ['title'], - attributesToRetrieve: ['*'], - attributesToSnippet: [`content:${snippetLength}`], - highlightPreTag: `<${highlightTag}>`, - highlightPostTag: `` - }) - .then(({ hits }) => { - const results = {}; - hits.forEach(({ uri, date, _highlightResult: { title }, _snippetResult: { content } }) => { - if (results[uri] && results[uri].context.length > content.value) return; - results[uri] = { - uri: uri, - title: title.value, - date: date, - context: content.value - }; - }); - finish(Object.values(results).slice(0, maxResultLength)); - }) - .catch((err) => { - console.error(err); - finish([]); - }); - } else if (searchConfig.type === 'fuse') { - const search = () => { - const results = {}; - window._index.search(query).forEach(({ item, refIndex, matches }) => { - let title = item.title; - let content = item.content; - matches.forEach(({ indices, value, key }) => { - if (key === 'content') { - content = this._applyHighlightToText(content, indices, highlightTag); - } else if (key === 'title') { - title = this._applyHighlightToText(title, indices, highlightTag); - } - }); - results[item.uri] = { - uri: item.uri, - title: title, - date: item.date, - context: content - }; - }); - return Object.values(results).slice(0, maxResultLength); - }; - if (!window._index) { - fetch(searchConfig.fuseIndexURL) - .then((response) => response.json()) - .then((data) => { - const options = { - isCaseSensitive: isCaseSensitive, - findAllMatches: findAllMatches, - minMatchCharLength: minMatchCharLength, - location: location, - threshold: threshold, - distance: distance, - ignoreLocation: ignoreLocation, - useExtendedSearch: useExtendedSearch, - ignoreFieldNorm: ignoreFieldNorm, - includeScore: false, - shouldSort: true, - includeMatches: true, - keys: ['content', 'title'] - }; - window._index = new Fuse(data, options); - finish(search()); - }) - .catch((err) => { - console.error(err); - finish([]); - }); - } else finish(search()); - } else if (searchConfig.type === 'cse') { - const cseConfig = this.config.cse; - if (cseConfig.engine === 'google' && cseConfig.cx) { - finish([{ - uri: `${cseConfig.resultsPage}#gsc.tab=0&gsc.q=${encodeURIComponent(query)}`, - title: cseConfig.searchIn, - date: '', - context: cseConfig.gotoResultsPage - }]); - } - } else if (searchConfig.type === 'pagefind') { - this._pagefindSearch - .search(query, maxResultLength) - .then((results) => { - finish(results || []); - }) - .catch((err) => { - console.error(err); - finish([]); - }); - } else { - finish([]); - } - }, - templates: { - suggestion: ({ title, uri, date, context }) => - `
${title}${date}
${context}
`, - empty: ({ query }) => `
${searchConfig.noResultsFound}: "${HTMLEscape(query)}"
`, - footer: ({ }) => { - let searchType, icon, href; - switch (searchConfig.type) { - case 'algolia': - searchType = 'algolia'; - icon = ''; - href = 'https://www.algolia.com/'; - break; - case 'fuse': - searchType = 'Fuse.js'; - icon = ''; - href = 'https://fusejs.io/'; - break; - case 'cse': - if (this.config.cse.engine === 'google') { - searchType = 'Google CSE'; - icon = ''; - href = 'https://programmablesearchengine.google.com/'; - } - break; - case 'pagefind': - searchType = 'Pagefind'; - icon = ''; - href = 'https://pagefind.app/'; - break; - default: - searchType = ''; - icon = ''; - href = ''; - } - return ``; - } - } - } - ); - autosearch.on('autocomplete:selected', (_event, suggestion, _dataset, _context) => { - this.closeMaskOverlay(overlayName); - window.location.assign(suggestion.uri); - }); - if (_isMobile) { - this._searchMobile = autosearch; - } else { - this._searchDesktop = autosearch; - } - }; - initAutosearch(); - } - - initDetails(target = document) { - forEach(target.querySelectorAll('.details:not(.disabled)'), ($details) => { - const $summary = $details.querySelector('.details-summary'); - $summary.addEventListener('click', () => { - $details.classList.toggle('open'); - }, false); - }); - } - - initLightGallery() { - if (this.config.lightgallery) { - this.lg && this.lg.destroy(true); - this.lg = lightGallery(document.getElementById('content'), { - plugins: [lgThumbnail, lgZoom], - selector: '.lightgallery', - speed: 400, - hideBarsDelay: 2000, - allowMediaOverlap: true, - exThumbImage: 'data-thumbnail', - toggleThumb: true, - thumbWidth: 80, - thumbHeight: '60px', - actualSize: false, - showZoomInOutIcons: true, - licenseKey: 'none' - }); - } - } - - /** - * init copy code button for code blocks in all modes (classic and non-classic) - * @param {HTMLElement} codeBlock code block wrapper element - * @param {HTMLElement} codePreEl single code block pre element - */ - initCopyCode(codeBlock, codePreEl) { - const copyBtn = codeBlock.dataset.mode === 'classic' - ? codeBlock.querySelector('.code-header .copy-btn') - : codeBlock.querySelector('.copy-icon-btn'); - if (codeBlock.dataset.copyable !== 'true' || !copyBtn) return; - copyBtn.addEventListener('click', () => { - const iswWrap = codeBlock.classList.contains('line-wrapping'); - const highlightLines = codeBlock.querySelectorAll('.hl'); - iswWrap && codeBlock.classList.toggle('line-wrapping'); - forEach(highlightLines, $hl => $hl.classList.toggle('hl')); - copyText(codePreEl.innerText.trim()).then(() => { - animateCSS(codePreEl, 'animate__flash'); - iswWrap && codeBlock.classList.toggle('line-wrapping'); - forEach(highlightLines, $hl => $hl.classList.toggle('hl')); - const copiedText = copyBtn.dataset.copiedText; - const originalTitle = copyBtn.dataset.ctOriginalTitle; - copyBtn.toggleAttribute('data-copied', true); - copyBtn.dataset.ctTitle = copiedText; - const instance = window.CellTooltip.getOrCreateInstance(copyBtn); - instance.refresh(); - setTimeout(() => { - copyBtn.toggleAttribute('data-copied', false); - copyBtn.dataset.ctTitle = originalTitle; - instance.hide(); - }, 2000); - }, () => { - console.error('Clipboard write failed!', 'Your browser does not support clipboard API!'); - }); - }, false); - } - - initCodeExpandBtn(codeBlock) { - codeBlock.querySelector('.code-expand-btn')?.addEventListener('click', () => { - codeBlock.classList.toggle('is-expanded'); - }, false); - } - - initDownloadCode(codeBlock, codePreEl) { - const downloadBtn = codeBlock.querySelector('.code-header .download-btn'); - if (!downloadBtn) return; - downloadBtn.addEventListener('click', () => { - const $codeHeader = codeBlock.querySelector('.code-header'); - const name = codeBlock.dataset.name?.trim(); - const language = Array.from($codeHeader?.classList || []).find((className) => className.startsWith('language-'))?.replace('language-', ''); - const ext = language && language !== 'fallback' ? language : 'txt'; - const fallbackName = name - ? (name.includes('.') ? name : `${name}.${ext}`) - : `code.${ext}`; - const fileName = codeBlock.getAttribute('filename')?.trim(); - const blob = new Blob([codePreEl.innerText], { type: 'text/plain;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = (fileName || fallbackName).replace(/[\\/:*?"<>|\r\n]+/g, '-'); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - downloadBtn.toggleAttribute('data-downloaded', true); - downloadBtn.classList.toggle('fa-spin', true); - setTimeout(() => { - downloadBtn.toggleAttribute('data-downloaded', false); - downloadBtn.classList.toggle('fa-spin', false); - }, 300); - }, false); - } - - _getCodeFullscreenTarget(codeBlock) { - return codeBlock.closest('.code-tabs') || codeBlock; - } - - _setCodeFullscreenState(codeBlock, show) { - const target = this._getCodeFullscreenTarget(codeBlock); - const expandBtn = codeBlock.querySelector('.code-expand-btn'); - - if (show && expandBtn) { - codeBlock.dataset.fullscreenExpanded = codeBlock.classList.contains('is-expanded') ? 'true' : 'false'; - codeBlock.classList.add('is-expanded'); - } - - if (!show && target.classList.contains('is-fullscreen')) { - target.classList.add('instant-height'); - window.requestAnimationFrame(() => target.classList.remove('instant-height')); - - if (expandBtn && codeBlock.dataset.fullscreenExpanded === 'false') { - codeBlock.classList.remove('is-expanded'); - } - delete codeBlock.dataset.fullscreenExpanded; - } - - // update button tooltip - target.classList.toggle('is-fullscreen', show); - const btn = target.querySelector('.tabs-actions .fullscreen-btn') - || codeBlock.querySelector('.code-header .fullscreen-btn'); - if (!btn) return; - const exitTitle = btn.dataset.exitTitle || btn.getAttribute('data-exit-title') || btn.title; - const originalTitle = btn.dataset.ctOriginalTitle || btn.dataset.ctTitle || btn.title; - btn.dataset.ctOriginalTitle = originalTitle; - btn.dataset.ctTitle = show ? exitTitle : originalTitle; - const instance = window.CellTooltip.getOrCreateInstance(btn); - instance.hide(); - } - - closeCodeFullscreen() { - const $activeTabs = document.querySelector('.code-tabs.is-fullscreen'); - if ($activeTabs) { - const $activeBlock = $activeTabs.querySelector('.code-block.active') || $activeTabs.querySelector('.code-block'); - if ($activeBlock) this._setCodeFullscreenState($activeBlock, false); - return; - } - const $activeBlock = document.querySelector('.code-block.highlight.is-fullscreen'); - if ($activeBlock) this._setCodeFullscreenState($activeBlock, false); - } - - initFullscreenCode(codeBlock) { - const fullscreenBtn = codeBlock.querySelector('.code-header .fullscreen-btn'); - if (!fullscreenBtn) return; - fullscreenBtn.addEventListener('click', () => { - const target = this._getCodeFullscreenTarget(codeBlock); - const show = !target.classList.contains('is-fullscreen'); - if (show) { - this.closeCodeFullscreen(); - codeBlock.classList.remove('is-collapsed'); - } - this._setCodeFullscreenState(codeBlock, show); - }, false); - if (!this._codeFullscreenOnEsc) { - this._codeFullscreenOnEsc = (event) => { - if (event.key === 'Escape') { - this.closeCodeFullscreen(); - } - }; - document.addEventListener('keydown', this._codeFullscreenOnEsc, false); - } - } - - /** - * init code wrapper - */ - initCodeWrapper() { - const $codeBlocks = document.querySelectorAll('.code-block.highlight:not([data-init])'); - forEach($codeBlocks, ($codeBlock) => { - const $preElements = $codeBlock.querySelectorAll('pre.chroma'); - if (!$preElements.length) return; - const $codePreEl = $preElements[$preElements.length - 1]; - $codeBlock.dataset.init = 'true'; - - this.initCopyCode($codeBlock, $codePreEl); - this.initCodeExpandBtn($codeBlock); - - // classic mode code block interactions - if ($codeBlock.dataset.mode === 'classic') { - const $codeHeader = $codeBlock.querySelector('.code-header'); - if (!$codeHeader) return; - this.initDownloadCode($codeBlock, $codePreEl); - this.initFullscreenCode($codeBlock); - // code title - $codeHeader.querySelector('.code-title').addEventListener('click', () => { - if ($codeBlock.classList.contains('is-fullscreen')) return; - $codeBlock.classList.toggle('is-collapsed'); - }, false); - // ellipses icon - $codeHeader.querySelector('.ellipses-btn').addEventListener('click', () => { - $codeBlock.classList.remove('is-collapsed'); - }, false); - // line numbers toggle button - $codeHeader.querySelector('.line-nos-btn')?.addEventListener('click', () => { - $codeBlock.classList.toggle('line-nos-hidden'); - }, false); - // line wrapping toggle button - $codeHeader.querySelector('.line-wrap-btn')?.addEventListener('click', () => { - if ($codeBlock.querySelector('[contenteditable="true"]')) return; - $codeBlock.classList.toggle('line-wrapping'); - }, false); - // edit button toggle button - if ($codeBlock.dataset.editable === 'true') { - $codeHeader.querySelector('.edit-btn')?.addEventListener('click', () => { - const isEditable = $codePreEl.getAttribute('contenteditable') === 'true' - if (isEditable) { - $codePreEl.setAttribute('contenteditable', false); - $codePreEl.blur(); - } else { - forEach($codeBlock.querySelectorAll('.hl'), ($hl) => { - $hl.classList.remove('hl'); - }); - $codeBlock.classList.add('is-expanded'); - $codeBlock.classList.remove('line-wrapping'); - $codePreEl.setAttribute('contenteditable', true); - $codePreEl.focus(); - } - }, false); - } - } - }); - } - - /** - * init code tabs - */ - initCodeTabs() { - const $codeBlocks = document.querySelectorAll('.code-block[group]:not([data-tab-init])'); - const processed = new Set(); - const normalizeTabTitle = (title = '') => title.toLowerCase(); - - this._codeTabToggleRegistry = this._codeTabToggleRegistry || new Map(); - - forEach($codeBlocks, ($block) => { - if (processed.has($block)) return; - - const groupName = $block.getAttribute('group'); - const $tabs = []; - let $curr = $block; - - // collect consecutive blocks with same group - while ($curr && $curr.classList?.contains('code-block') && $curr.getAttribute('group') === groupName) { - $tabs.push($curr); - processed.add($curr); - $curr = $curr.nextElementSibling; - } - - if ($tabs.length < 2) return; - - // create DOM structure - const $container = document.createElement('div'); - $container.className = 'code-tabs'; - - const $header = document.createElement('div'); - $header.className = 'tabs-header'; - - const $items = document.createElement('div'); - $items.className = 'tabs-items'; - - const $actions = document.createElement('div'); - $actions.className = 'tabs-actions'; - - $header.appendChild($items); - $header.appendChild($actions); - - const $content = document.createElement('div'); - $content.className = 'tabs-content'; - - // insert container before the first block - const $firstBlock = $tabs[0]; - $firstBlock.parentNode.insertBefore($container, $firstBlock); - - const activeTabIndex = $tabs.findIndex(tab => tab.classList.contains('active')); - const langPref = window.localStorage.getItem('config_lang_perf'); - const hasCodeToggle = $tabs.some(tab => tab.dataset.codeToggle === 'true'); - const langPrefIndex = (langPref && hasCodeToggle) ? $tabs.findIndex(tab => tab.dataset.tabTitle.toLowerCase() === langPref) : -1; - const resolvedIndex = langPrefIndex !== -1 ? langPrefIndex : activeTabIndex; - const beforeTabs = $tabs[0]?.getAttribute('before_tabs'); - if (beforeTabs) { - const $before = document.createElement('span'); - $before.className = 'before-tabs'; - $before.textContent = beforeTabs; - $items.appendChild($before); - } - - const tabButtons = []; - const toggleLangToIndex = new Map(); - - const switchToTab = (index, { sync = true } = {}) => { - const $nextTab = $tabs[index]; - const $nextBtn = tabButtons[index]; - if (!$nextTab || !$nextBtn) return; - - // 1. restore buttons to the currently active tab - const $activeTab = $tabs.find(t => t.classList.contains('active')); - if ($activeTab) { - const $activeHeader = $activeTab.querySelector('.code-header'); - if ($activeHeader) { - Array.from($actions.children).forEach(btn => $activeHeader.appendChild(btn)); - } - } - - // 2. switch active tab UI - tabButtons.forEach(b => b.classList.remove('active')); - $nextBtn.classList.add('active'); - - // 3. switch content - $tabs.forEach(b => b.classList.remove('active')); - $nextTab.classList.add('active'); - - // 4. sync shadow mode data attribute - const shadowMode = $nextTab?.dataset.shadow; - if (shadowMode) { - $container.dataset.shadow = shadowMode; - } else { - delete $container.dataset.shadow; - } - - // 5. move new buttons to actions - const $codeHeader = $nextTab.querySelector('.code-header'); - if ($codeHeader) { - $codeHeader.querySelectorAll('.action-btn').forEach(btn => $actions.appendChild(btn)); - } - - if (!sync || $nextTab.dataset.codeToggle !== 'true') return; - - const tabTitle = $nextTab.dataset.tabTitle; - if (!tabTitle) return; - const normalizedTitle = normalizeTabTitle(tabTitle); - window.localStorage.setItem('config_lang_perf', normalizedTitle); - - const syncHandlers = this._codeTabToggleRegistry.get(normalizedTitle); - if (!syncHandlers?.size) return; - syncHandlers.forEach((handler) => { - if (handler !== switchByLang) { - handler(normalizedTitle, { sync: false }); - } - }); - }; - - const switchByLang = (lang, options = {}) => { - const index = toggleLangToIndex.get(normalizeTabTitle(lang)); - if (index === undefined) return; - switchToTab(index, options); - }; - - $tabs.forEach(($tab, index) => { - const title = $tab.dataset.tabTitle || 'Code'; - const defaultActiveTab = resolvedIndex === -1 && index === 0; - - // tab button - const $btn = document.createElement('span'); - $btn.className = 'tab-item'; - if (defaultActiveTab) $btn.classList.add('active'); - $btn.textContent = title; - $btn.dataset.index = index; - $btn.title = title; - tabButtons.push($btn); - - const normalizedTitle = normalizeTabTitle(title); - if (!toggleLangToIndex.has(normalizedTitle)) { - toggleLangToIndex.set(normalizedTitle, index); - } - - $btn.addEventListener('click', () => { - switchToTab(index); - }); - $items.appendChild($btn); - - // move block to content - $tab.classList.toggle('active', resolvedIndex === index || defaultActiveTab); - $tab.classList.remove('is-collapsed'); - $tab.classList.remove('d-none'); - $tab.dataset.tabInit = 'true'; - $content.appendChild($tab); - }); - - $container.appendChild($header); - $container.appendChild($content); - - toggleLangToIndex.forEach((_index, lang) => { - const handlers = this._codeTabToggleRegistry.get(lang) || new Set(); - handlers.add(switchByLang); - this._codeTabToggleRegistry.set(lang, handlers); - }); - - // initialize actions for the active tab - if (resolvedIndex !== -1) { - switchToTab(resolvedIndex, { sync: false }); - } else { - switchToTab(0, { sync: false }); - } - }); - } - - /** - * init diagram copy button - */ - initDiagramCopyBtn() { - const stagingDOM = getStagingDOM() - forEach(document.querySelectorAll('.diagram-container > .copy-icon-btn'), ($btn) => { - $btn.addEventListener('click', () => { - stagingDOM.stage($btn.parentElement.querySelector('template').content.cloneNode(true)) - let code = stagingDOM.contentAsText(); - try { - code = JSON.stringify(JSON.parse(code), null, 2); - } catch { } - copyText(code).then(() => { - const copiedText = $btn.dataset.copiedText; - const originalTitle = $btn.dataset.ctOriginalTitle; - $btn.toggleAttribute('data-copied', true); - $btn.dataset.ctTitle = copiedText; - const instance = window.CellTooltip.getOrCreateInstance($btn); - instance.refresh(); - setTimeout(() => { - $btn.toggleAttribute('data-copied', false); - $btn.dataset.ctTitle = originalTitle; - instance.hide(); - }, 2000); - $btn.toggleAttribute('data-copied', true); - }, () => { - console.error('Clipboard write failed!', 'Your browser does not support clipboard API!'); - }); - }, false); - }); - stagingDOM.destroy(); - } - - getVisibleHeaderOffset() { - const $desktopHeader = document.getElementById('header-desktop'); - const $mobileHeader = document.getElementById('header-mobile'); - const $header = [$desktopHeader, $mobileHeader].find(($el) => $el && window.getComputedStyle($el).display !== 'none'); - if (!$header) return 0; - const isDesktop = $header.id === 'header-desktop'; - const headerMode = isDesktop ? document.body.dataset.headerDesktop : document.body.dataset.headerMobile; - if (!['sticky', 'auto'].includes(headerMode)) return 0; - if (headerMode === 'auto' && $header.classList.contains('header__fadeOutUp')) return 0; - return $header.offsetHeight; - } - - getBreadcrumbHeight() { - return document.querySelector('.breadcrumb-container')?.offsetHeight || 0; - } - - getTocIndexOffset() { - return 20 + this.getVisibleHeaderOffset() + this.getBreadcrumbHeight(); - } - - getTocHeadingElements() { - return Array.from(document.getElementsByClassName('heading-element')).filter(($heading) => $heading.id); - } - - getActiveTocHeading($headingElements, indexOffset = this.getTocIndexOffset()) { - if (!$headingElements.length) return null; - const threshold = window.scrollY + indexOffset + 1; - let $activeHeading = $headingElements[0]; - for (const $heading of $headingElements) { - const headingTop = window.scrollY + $heading.getBoundingClientRect().top; - if (headingTop <= threshold) { - $activeHeading = $heading; - } else { - break; - } - } - return $activeHeading; - } - - getTocRoots() { - return [ - document.getElementById('TableOfContents'), - document.querySelector('#toc-content-static > nav'), - document.querySelector('#toc-content-drawer > nav'), - ].filter(Boolean); - } - - getTocLinkById($tocRoot, id) { - if (!$tocRoot || !id) return null; - const targetHash = `#${id}`; - return Array.from($tocRoot.querySelectorAll('a[href^="#"]')).find(($link) => $link.getAttribute('href') === targetHash) || null; - } - - applyTocActiveState($tocRoot, activeId) { - if (!$tocRoot) return; - forEach($tocRoot.querySelectorAll('a[href^="#"]'), ($tocLink) => { - $tocLink.classList.remove('active'); - }); - forEach($tocRoot.querySelectorAll('li'), ($tocLi) => { - $tocLi.classList.remove('has-active'); - }); - const $activeLink = this.getTocLinkById($tocRoot, activeId); - if (!$activeLink) return; - $activeLink.classList.add('active'); - let $parent = $activeLink.closest('li'); - while ($parent) { - $parent.classList.add('has-active'); - $parent = $parent.parentElement?.closest('li') || null; - } - } - - scrollActiveTocLinkIntoView($tocRoot, activeId, $scrollContainer = $tocRoot) { - const $activeLink = this.getTocLinkById($tocRoot, activeId); - if (!$activeLink || !$scrollContainer) return; - const containerRect = $scrollContainer.getBoundingClientRect(); - const linkRect = $activeLink.getBoundingClientRect(); - const offsetTop = linkRect.top - containerRect.top; - const offsetBottom = linkRect.bottom - containerRect.bottom; - if (offsetTop < 0) { - $scrollContainer.scrollTop += offsetTop; - } else if (offsetBottom > 0) { - $scrollContainer.scrollTop += offsetBottom; - } - } - - syncTocHeight() { - const $toc = document.getElementById('toc-auto'); - const $tocContentAuto = document.getElementById('toc-content-auto'); - if ($toc && $tocContentAuto) { - const maxHeight = Math.max(window.innerHeight - $tocContentAuto.getBoundingClientRect().top - 16); - $tocContentAuto.style.setProperty('--fi-toc-content-max-height', `${Math.floor(maxHeight)}px`); - } - } - - syncTocActiveState() { - const $headingElements = this.getTocHeadingElements(); - const $activeHeading = this.getActiveTocHeading($headingElements); - if (!$activeHeading?.id) return; - const activeId = $activeHeading.id; - const $tocRoots = this.getTocRoots(); - forEach($tocRoots, ($tocRoot) => { - this.applyTocActiveState($tocRoot, activeId); - }); - if (this.activeTocId !== activeId) { - this.activeTocId = activeId; - if (!isTocStatic()) { - const $autoTocRoot = document.getElementById('TableOfContents'); - const $autoTocContainer = document.getElementById('toc-content-auto'); - if ($autoTocRoot && $autoTocContainer) { - this.scrollActiveTocLinkIntoView($autoTocRoot, activeId, $autoTocContainer); - } - } - if (document.getElementById('toc-dialog')?.open) { - const $dialogTocRoot = document.querySelector('#toc-content-drawer > nav'); - this.scrollActiveTocLinkIntoView($dialogTocRoot, activeId, $dialogTocRoot); - } - } - } - - /** - * init table of contents - */ - initToc() { - const $tocCore = document.getElementById('TableOfContents'); - if ($tocCore === null) return; - // TOC Drawer Button Visibility - const openButton = document.querySelector("#toc-drawer-button"); - if (openButton) { - openButton.classList.toggle('d-none', !isTocStatic()); - } - this.activeTocId = null; - // TOC Static and TOC Dialog - if (isTocStatic()) { - const $tocContentStatic = document.getElementById('toc-content-static'); - if ($tocCore.parentElement !== $tocContentStatic) { - $tocCore.parentElement.removeChild($tocCore); - $tocContentStatic.appendChild($tocCore); - } - this.syncTocHeight(); - this.syncTocActiveState(); - return; - } - - // TOC Auto - const $tocContentAuto = document.getElementById('toc-content-auto'); - if ($tocCore.parentElement !== $tocContentAuto) { - $tocCore.parentElement.removeChild($tocCore); - $tocContentAuto.appendChild($tocCore); - } - const $toc = document.getElementById('toc-auto'); - $toc.style.visibility = 'visible'; - animateCSS($toc, ['animate__fadeIn', 'animate__faster'], true); - this.syncTocHeight(); - this.syncTocActiveState(); - } - - // TODO refactor use allow-discrete display property - initTocListener() { - const $toc = document.getElementById('toc-auto'); - const $tocContentAuto = document.getElementById('toc-content-auto'); - document.querySelector('#toc-auto>.toc-title')?.addEventListener('click', () => { - const animation = ['animate__faster']; - const tocHidden = $toc.classList.contains('toc-hidden'); - animation.push(tocHidden ? 'animate__fadeIn' : 'animate__fadeOut'); - if (tocHidden) { - $tocContentAuto.classList.remove('d-none', 'animate__fadeOut'); - } else { - $tocContentAuto.classList.remove('animate__fadeIn'); - } - animateCSS($tocContentAuto, animation, true, () => { - $tocContentAuto.classList.contains('animate__fadeOut') && $tocContentAuto.classList.add('d-none'); - }); - $toc.classList.toggle('toc-hidden'); - }, false); - } - - initTocDialog() { - // HTMLDialogElement - const dialog = document.querySelector("#toc-dialog"); - const openButton = document.querySelector("#toc-drawer-button"); - if (!dialog || !openButton) return; - const closeButton = dialog.querySelector(".toc-close-btn"); - closeButton?.addEventListener("click", () => dialog.close()); - openButton.addEventListener("click", () => { - dialog.showModal(); - openButton.setAttribute('aria-expanded', 'true'); - this.syncTocHeight(); - this.syncTocActiveState(); - const $dialogTocRoot = document.querySelector('#toc-content-drawer > nav'); - this.scrollActiveTocLinkIntoView($dialogTocRoot, this.activeTocId, $dialogTocRoot); - document.activeElement?.blur(); - }); - forEach(document.querySelectorAll('#toc-content-drawer a[href^="#"]'), ($link) => { - $link.addEventListener("click", () => dialog.close()); - }); - dialog.addEventListener("close", () => { - openButton.setAttribute('aria-expanded', 'false'); - }); - } - - /** - * It's a dirty hack to fix the bug of APlayer and smoothScroll. - * see https://github.com/hugo-fixit/FixIt/issues/292 - */ - fixTocScroll() { - if (typeof APlayer === 'function') { - // remove APlayer click event listener of the toc link - let $tocCore = document.getElementById('TableOfContents'); - if ($tocCore) { - const $newTocCore = $tocCore.cloneNode(true); - $tocCore.parentElement.replaceChild($newTocCore, $tocCore); - $tocCore = $newTocCore; - } - // remove APlayer click event listener of the heading mark - forEach(document.querySelectorAll('.heading-mark'), ($headingMark) => { - const $newHeadingMark = $headingMark.cloneNode(true); - $headingMark.parentElement.replaceChild($newHeadingMark, $headingMark); - }); - } - } - - initEcharts() { - if (!this.config.echarts) return; - echarts.registerTheme('light', this.config.echarts.lightTheme); - echarts.registerTheme('dark', this.config.echarts.darkTheme); - this._echartsOnSwitchTheme = this._echartsOnSwitchTheme || (() => { - this._echartsArr = this._echartsArr || []; - for (let i = 0; i < this._echartsArr.length; i++) { - this._echartsArr[i].dispose(); - } - this._echartsArr = []; - const stagingDOM = getStagingDOM() - forEach(document.getElementsByClassName('echarts'), ($echarts) => { - const $dataEl = $echarts.nextElementSibling; - if ($dataEl.tagName !== 'TEMPLATE') return; - const chart = echarts.init($echarts, this.isDark ? 'dark' : 'light', { renderer: 'svg' }); - chart.showLoading(); - stagingDOM.stage($dataEl.content.cloneNode(true)); - const _setOption = (option) => { - if (!option) { - chart.hideLoading(); - console.warn('ECharts option is missing or invalid. Chart disposed.', { - element: $echarts, - option: $dataEl, - }); - chart.dispose(); - $echarts.removeAttribute('style'); - return; - } - chart.hideLoading(); - chart.setOption(option); - this._echartsArr.push(chart); - }; - // support JS object literal or JS code - if ($dataEl.dataset.fmt === 'js') { - try { - const jsCodes = stagingDOM.contentAsText(); - /** - * Get ECharts option - * @param {Object} fixit FixIt instance - * @param {Object} chart ECharts instance - * @returns {Object|Promise} ECharts option or Promise - */ - const _getOption = new Function('fixit', 'chart', - isObjectLiteral(jsCodes) ? `return ${jsCodes}` : jsCodes - ); - if ($dataEl.dataset.async === 'true') { - return Promise.resolve(_getOption(this, chart)).then(option => { - _setOption(option); - }); - } - return _setOption(_getOption(this, chart)); - } catch (err) { - return console.error(err); - } - } - // support JSON - _setOption(stagingDOM.contentAsJson()); - }); - stagingDOM.destroy(); - }); - this.switchThemeEventSet.add(this._echartsOnSwitchTheme); - this._echartsOnSwitchTheme(); - this._echartsOnResize = this._echartsOnResize || (() => { - for (let i = 0; i < this._echartsArr.length; i++) { - this._echartsArr[i].resize(); - } - }); - this.resizeEventSet.add(this._echartsOnResize); - } - - initMapbox() { - if (this.config.mapbox) { - if (!mapboxgl.accessToken) { - mapboxgl.accessToken = this.config.mapbox.accessToken; - mapboxgl.setRTLTextPlugin(this.config.mapbox.RTLTextPlugin); - this._mapboxArr = this._mapboxArr || []; - } - forEach(document.querySelectorAll('.mapbox:empty'), ($mapbox) => { - const { lng, lat, zoom, lightStyle, darkStyle, marked, markers, navigation, geolocate, scale, fullscreen } = JSON.parse($mapbox.dataset.options); - const mapbox = new mapboxgl.Map({ - container: $mapbox, - center: [lng, lat], - zoom: zoom, - minZoom: 0.2, - style: this.isDark ? darkStyle : lightStyle, - attributionControl: false - }); - if (marked) { - new mapboxgl.Marker().setLngLat([lng, lat]).addTo(mapbox); - } - const markerArray = typeof markers === 'string' ? JSON.parse(markers) : markers; - if (Array.isArray(markerArray) && markerArray.length > 0) { - markerArray.forEach(marker => { - const { lng: markerLng, lat: markerLat, description } = marker; - const popup = new mapboxgl.Popup({ offset: 25 }).setText(description); - new mapboxgl.Marker() - .setLngLat([markerLng, markerLat]) - .setPopup(popup) - .addTo(mapbox); - }); - } - if (navigation) { - mapbox.addControl(new mapboxgl.NavigationControl(), 'bottom-right'); - } - if (geolocate) { - mapbox.addControl( - new mapboxgl.GeolocateControl({ - positionOptions: { - enableHighAccuracy: true - }, - showUserLocation: true, - trackUserLocation: true - }), - 'bottom-right' - ); - } - if (scale) { - mapbox.addControl(new mapboxgl.ScaleControl()); - } - if (fullscreen) { - mapbox.addControl(new mapboxgl.FullscreenControl()); - } - mapbox.addControl(new MapboxLanguage()); - this._mapboxArr.push(mapbox); - }); - this._mapboxOnSwitchTheme = this._mapboxOnSwitchTheme || (() => { - forEach(this._mapboxArr, (mapbox) => { - const $mapbox = mapbox.getContainer(); - const { lightStyle, darkStyle } = JSON.parse($mapbox.dataset.options); - mapbox.setStyle(this.isDark ? darkStyle : lightStyle); - mapbox.addControl(new MapboxLanguage()); - }); - }); - this.switchThemeEventSet.add(this._mapboxOnSwitchTheme); - } - } - - initTypeit(target = document) { - if (this.config.typeit) { - const typeitConfig = this.config.typeit; - const speed = typeitConfig.speed || 100; - const cursorSpeed = typeitConfig.cursorSpeed || 1000; - const cursorChar = typeitConfig.cursorChar || '|'; - const loop = typeitConfig.loop ?? false; - // divide them into different groups according to the data-group attribute value of the element - // results in an object like {group1: [ele1, ele2], group2: [ele3, ele4]} - const typeitElements = target.querySelectorAll('.typeit') - const groupMap = Array.from(typeitElements).reduce((acc, ele) => { - const group = ele.dataset.group || ele.id || Math.random().toString(36).substring(2); - acc[group] = acc[group] || []; - acc[group].push(ele); - return acc; - }, {}); - const stagingDOM = getStagingDOM() - - Object.values(groupMap).forEach((group) => { - const typeone = (i) => { - const typeitElement = group[i]; - const singleData = typeitElement.dataset; - stagingDOM.stage(typeitElement.querySelector('template').content.cloneNode(true)); - // for shortcodes usage - let targetEle = typeitElement.firstElementChild - // for system elements usage - if (typeitElement.firstElementChild.tagName === 'TEMPLATE') { - typeitElement.innerHTML = ''; - targetEle = typeitElement - } - // create a new instance of TypeIt for each element - const instance = new TypeIt(targetEle, { - strings: stagingDOM.$el.querySelector('pre')?.innerHTML || stagingDOM.contentAsHtml(), - speed: Number(singleData.speed) >= 0 ? Number(singleData.speed) : speed, - lifeLike: true, - cursorSpeed: Number(singleData.cursorSpeed) >= 0 ? Number(singleData.cursorSpeed) : cursorSpeed, - cursorChar: singleData.cursorChar || cursorChar, - waitUntilVisible: true, - loop: singleData.loop ? singleData.loop === 'true' : loop, - afterComplete: () => { - const duration = Number(singleData.duration ?? typeitConfig.duration); - if (i === group.length - 1) { - if (duration >= 0) { - window.setTimeout(() => { - instance.destroy(); - }, duration); - } - return; - } - instance.destroy(); - typeone(i + 1); - } - }).go(); - }; - typeone(0); - }); - stagingDOM.destroy(); - } - } - - initCommentLightGallery(comments, images) { - document.querySelectorAll(comments).forEach(($content) => { - const $imgs = $content.querySelectorAll(images + ':not([lightgallery-loaded])'); - $imgs.forEach(($img) => { - $img.setAttribute('lightgallery-loaded', ''); - const $link = document.createElement('a'); - $link.setAttribute('class', 'comment-lightgallery'); - $link.setAttribute('href', $img.src); - $link.append($img.cloneNode()); - $img.replaceWith($link); - }); - if ($imgs.length) { - lightGallery($content, { - selector: '.comment-lightgallery', - actualSize: false, - hideBarsDelay: 2000, - speed: 400 - }); - } - }); - } - - initComment() { - if (!this.config.comment?.enable) return; - // whether to show the view comments button - if (document.querySelector('#comments')) { - const $viewCommentsBtn = document.querySelector('.view-comments'); - $viewCommentsBtn.classList.remove('d-none'); - // view comments button click event - $viewCommentsBtn.addEventListener('click', () => { - scrollIntoView('#comments'); - }, false); - } - this.config.comment.expired && document.querySelector('#comments').remove(); - if (this.config.comment.artalk) { - if (this.config.comment.expired) { - return Artalk.LoadCountWidget({ - server: this.config.comment.artalk.server, - site: this.config.comment.artalk.site, - pvEl: this.config.comment.artalk.pvEl, - countEl: this.config.comment.artalk.countEl - }) - } - const artalk = Artalk.init(this.config.comment.artalk); - artalk.setDarkMode(this.isDark); - this.switchThemeEventSet.add(() => { - artalk.setDarkMode(this.isDark); - }); - artalk.on('comments-loaded', () => { - this.config.comment.artalk.lightgallery && this.initCommentLightGallery('.atk-comment .atk-content', 'img:not([atk-emoticon])'); - }); - return artalk; - } - if (this.config.comment.gitalk) { - this.config.comment.gitalk.body = decodeURI(window.location.href); - const gitalk = new Gitalk(this.config.comment.gitalk); - gitalk.render('gitalk'); - return gitalk; - } - if (this.config.comment.valine) { - return new Valine(this.config.comment.valine); - } - if (this.config.comment.waline) { - if (this.config.comment.expired) { - this.config.comment.waline.pageview && Waline.pageviewCount({ - serverURL: this.config.comment.waline.serverURL, - path: window.location.pathname - }); - return; - } - return Waline.init(this.config.comment.waline); - } - if (this.config.comment.utterances) { - const utterancesConfig = this.config.comment.utterances; - const script = document.createElement('script'); - script.src = 'https://utteranc.es/client.js'; - script.setAttribute('repo', utterancesConfig.repo); - script.setAttribute('issue-term', utterancesConfig.issueTerm); - if (utterancesConfig.label) script.setAttribute('label', utterancesConfig.label); - script.setAttribute('theme', this.isDark ? utterancesConfig.darkTheme : utterancesConfig.lightTheme); - script.crossOrigin = 'anonymous'; - script.async = true; - document.getElementById('utterances').appendChild(script); - this._utterancesOnSwitchTheme = this._utterancesOnSwitchTheme || (() => { - const message = { - type: 'set-theme', - theme: this.isDark ? utterancesConfig.darkTheme : utterancesConfig.lightTheme - }; - document.querySelector('.utterances-frame')?.contentWindow.postMessage(message, 'https://utteranc.es'); - }); - this.switchThemeEventSet.add(this._utterancesOnSwitchTheme); - return; - } - if (this.config.comment.twikoo) { - const twikooConfig = this.config.comment.twikoo; - if (twikooConfig.lightgallery) { - twikooConfig.onCommentLoaded = () => { - this.initCommentLightGallery('.tk-comments .tk-content', 'img:not(.tk-owo-emotion)'); - }; - } - twikoo.init(twikooConfig); - if (twikooConfig.commentCount) { - // https://twikoo.js.org/api.html#get-comments-count - twikoo - .getCommentsCount({ - envId: twikooConfig.envId, - region: twikooConfig.region, - urls: [window.location.pathname], - includeReply: false - }) - .then(function (response) { - const twikooCommentCount = document.getElementById('twikoo-comment-count'); - if (twikooCommentCount) twikooCommentCount.innerHTML = response[0].count; - }); - } - return; - } - if (this.config.comment.giscus) { - const giscusConfig = this.config.comment.giscus; - this._giscusOnSwitchTheme = this._giscusOnSwitchTheme || (() => { - const message = { setConfig: { theme: this.isDark ? giscusConfig.darkTheme : giscusConfig.lightTheme } }; - document.querySelector('.giscus-frame')?.contentWindow.postMessage({ giscus: message }, giscusConfig.origin); - }); - this.switchThemeEventSet.add(this._giscusOnSwitchTheme); - // gicuss to parent message - this._messageListener = (event) => { - if (event.origin !== giscusConfig.origin) return; - const $script = document.querySelector('#giscus>script'); - if ($script) { - $script.parentElement.removeChild($script); - } - this._giscusOnSwitchTheme() - window.removeEventListener('message', this._messageListener, false); - }; - window.addEventListener('message', this._messageListener, false); - return; - } - } - - initCookieconsent() { - this.config.cookieconsent && window.cookieconsent?.initialise(this.config.cookieconsent); - } - - getSiteTime = () => { - let now = new Date(); - let run = new Date(this.config.siteTime); - let $runTimes = document.querySelector('.run-times'); - if (!isValidDate(run) || !$runTimes) { - clearInterval(this.siteTime); - $runTimes && $runTimes.parentNode.remove(); - return; - } - let runTime = (now - run) / 1000, - days = Math.floor(runTime / 60 / 60 / 24), - hours = Math.floor(runTime / 60 / 60 - 24 * days), - minutes = Math.floor(runTime / 60 - 24 * 60 * days - 60 * hours), - seconds = Math.floor((now - run) / 1000 - 24 * 60 * 60 * days - 60 * 60 * hours - 60 * minutes); - $runTimes.innerHTML = `${days}, ${String(hours).padStart(2, 0)}:${String(minutes).padStart(2, 0)}:${String(seconds).padStart(2, 0)}`; - document.querySelector('.site-time .d-none')?.classList.remove('d-none'); - }; - - initSiteTime() { - if (this.config.siteTime) { - this.siteTime = setInterval(this.getSiteTime, 500); - document.addEventListener('visibilitychange', () => { - if (document.hidden) { - return clearInterval(this.siteTime); - } - this.siteTime = setInterval(this.getSiteTime, 500); - }, false); - } - } - - initServiceWorker() { - if (this.config.enablePWA && 'serviceWorker' in navigator) { - navigator.serviceWorker - .register('/service-worker.min.js', { scope: '/' }) - .then(function (registration) { - // console.log('Service Worker Registered'); - }) - .catch(function (error) { - console.error('error: ', error); - }); - navigator.serviceWorker - .ready - .then(function (registration) { - // console.log('Service Worker Ready'); - }); - } - } - - initWatermark() { - if (!this.config.watermark?.enable) return; - new Watermark(this.config.watermark); - } - - initPangu() { - if (!this.config.pangu?.enable) return; - // to avoid extra spaces for extended Markdown syntax fraction in Chinese - pangu.ignoredTags = /^(script|code|pre|textarea|sup|sub)$/i; - const selector = this.config.pangu.selector; - if (selector) { - document.querySelectorAll(selector).forEach((el) => pangu.spacingNode(el)); - return; - } - pangu.autoSpacingPage(); - } - - initMathJax() { - if (window.MathJax?.typesetPromise) { - window.MathJax.typesetPromise().then(() => { - // Do something else after typesetting is complete - }).catch((err) => console.log(err.message)); - } - } - - initJsonViewer() { - if (!window.JsonViewerElement) return; - this._jsonViewerOnSwitchTheme = this._jsonViewerOnSwitchTheme || (() => { - forEach(document.getElementsByTagName('json-viewer'), ($el) => { - $el.setAttribute('theme', this.isDark ? 'dark' : 'light'); - }); - }); - this.switchThemeEventSet.add(this._jsonViewerOnSwitchTheme); - this._jsonViewerOnSwitchTheme(); - } - - initTabEvents(target = document) { - target.addEventListener('tab-container-changed', () => { - FileTree.updateLineHeight(target); - }, false); - } - - initFootnotes() { - const $footnoteRefs = document.querySelectorAll('#content sup[id^="fnref:"]'); - const $footnotes = document.querySelector('.footnotes[role="doc-endnotes"]'); - if (!$footnoteRefs.length || !$footnotes) return; - const footnoteMap = new Map(); - $footnoteRefs.forEach(($ref) => { - if (this.config.tooltip) { - const $link = $ref.querySelector('a.footnote-ref'); - if ($link) { - $link.addEventListener('click', (e) => { - e.preventDefault(); - }, false); - } - } - const id = $ref.id.replace('fnref:', ''); - const $footnoteContent = $footnotes.querySelector(`[id="fn:${id}"]`); - if ($footnoteContent) { - const $clonedContent = $footnoteContent.cloneNode(true); - const $backref = $clonedContent.querySelector('.footnote-backref'); - if ($backref) { - $backref.remove(); - } - footnoteMap.set($ref, $clonedContent); - } - }); - footnoteMap.forEach(($content, $ref) => { - if ($ref.hasAttribute('title')) return; - $ref.setAttribute('title', $content.textContent.trim()); - if (this.config.tooltip) { - window.CellTooltip.getOrCreateInstance($ref); - } - }); - } - - initTooltip() { - if (!this.config.tooltip) return; - // task list items tooltip - window.CellTooltip.initAll('li[data-task] > span[title]', { - placement: 'right', - }); - // code block action buttons tooltip - window.CellTooltip.initAll('.action-btn[title]', { - placement: 'bottom', - }); - // copy icon button tooltip - window.CellTooltip.initAll('.copy-icon-btn[title]', { - placement: 'top', - }); - // footnote refs tooltip - this.initFootnotes(); - } - - /** - * Initialize link guard dialog for links with target="_blank" and data-guard="modal" - * @param {Element} target - The target element to initialize within (optional, defaults to document) - */ - initLinkGuardDialog(target = document) { - const dialog = document.getElementById('link-guard-dialog'); - if (!dialog) return; - - const $target = dialog.querySelector('.target'); - const $copy = dialog.querySelector('.copy-icon-btn'); - const $confirm = dialog.querySelector('.confirm-btn'); - const $cancel = dialog.querySelector('.cancel-btn'); - - const _closeDialog = () => { - if (dialog.open) dialog.close(); - dialog._target = null; - if ($target) { - $target.textContent = '-'; - } - }; - - if (!dialog.dataset.init) { - dialog.dataset.init = 'true'; - - $confirm.addEventListener('click', () => { - if (dialog._target) { - window.open(dialog._target, '_blank', 'noopener,noreferrer'); - } - _closeDialog(); - }); - - $cancel.addEventListener('click', _closeDialog); - - $copy.addEventListener('click', () => { - const textToCopy = dialog._target || ''; - if (!textToCopy) return; - copyText(textToCopy).then(() => { - $copy.toggleAttribute('data-copied', true); - window.setTimeout(() => { - $copy.toggleAttribute('data-copied', false); - }, 2000); - }); - }); - } - - forEach(target.querySelectorAll('a[target="_blank"][data-guard="modal"]:not([data-init])'), ($link) => { - $link.dataset.init = 'true'; - $link.addEventListener('click', (e) => { - e.preventDefault(); - let target = $link.href; - try { - const guardUrl = new URL($link.href); - target = guardUrl.searchParams.get('target') || target; - } catch (err) { - // Ignore malformed URLs and fall back to the original href. - } - - dialog._target = target; - if ($target) { - $target.textContent = target; - } - dialog.showModal(); - document.activeElement?.blur(); - }, false); - }); - } - - /** - * Helper method to initialize content components - * @param {Element} target - The target element (optional, defaults to document) - * @param {Boolean} includeToc - Whether to initialize TOC-related components - */ - _initContentComponents(target = document, includeToc = false) { - this.initTwemoji(target); - this.initDetails(target); - this.initLightGallery(); - this.initCodeWrapper(); - this.initCodeTabs(); - this.initDiagramCopyBtn(); - this.initEcharts(); - this.initTypeit(target); - this.initMapbox(); - this.initTooltip(); - if (includeToc) { - window.setTimeout(() => { - this.fixTocScroll(); - this.initToc(); - this.initTocListener(); - this.initTocDialog(); - }, 100); - } - this.initPangu(); - this.initMathJax(); - this.initJsonViewer(); - this.initTabEvents(target); - this.initLinkGuardDialog(target); - FileTree.init(target); - window.FixItMermaid?.init?.(); - window.FixItAPlayer?.init?.(); - } - - /** - * Helper method to toggle encrypted content visibility - * @param {Element} container - The container element - * @param {Boolean} show - true to show decrypted content, false to hide - */ - _toggleEncryptedClass(container, show) { - const fromClass = show ? 'encrypted-hidden' : 'decrypted-shown'; - const toClass = show ? 'decrypted-shown' : 'encrypted-hidden'; - forEach(container.querySelectorAll(`.${fromClass}`), ($element) => { - $element.classList.replace(fromClass, toClass); - }); - } - - initFixItDecryptor() { - this.decryptor = new FixItDecryptor({ - decrypted: () => { - this._initContentComponents(document, true); - this._toggleEncryptedClass(document, true); - }, - partialDecrypted: ($content) => { - this._initContentComponents($content, false); - this._toggleEncryptedClass($content, true); - }, - reset: () => { - this._toggleEncryptedClass(document, false); - } - }); - this.decryptor.init(this.config.encryption); - } - - initAutoMark() { - if (!this.config.autoBookmark) return; - window.addEventListener('beforeunload', () => { - window.sessionStorage?.setItem(`fixit-bookmark/#${location.pathname}`, getScrollTop()); - }); - const scrollTop = Number(window.sessionStorage?.getItem(`fixit-bookmark/#${location.pathname}`)); - // If the page opens with a specific hash, just jump out - if (scrollTop && location.hash === '') { - window.scrollTo({ - top: scrollTop, - behavior: 'smooth' - }); - } - } - - initReward() { - const $rewards = document.querySelectorAll('.post-reward [data-mode="fixed"]'); - if (!$rewards.length) return; - // `fixed` mode only supports desktop - if (isMobile()) { - forEach($rewards, ($reward) => { - $reward.removeAttribute('data-mode'); - }); - return; - } - // Close post reward images exclude special id - const _closeRewardExclude = (id) => { - forEach($rewards, ($reward) => { - const $rewardInput = $reward.parentElement.querySelector('.reward-input'); - if ($rewardInput.id !== id) { - $rewardInput.checked = false; - } - }); - }; - // Add additional click event to reward buttons - forEach($rewards, ($reward) => { - $reward.previousElementSibling.addEventListener('click', function () { - _closeRewardExclude(this.getAttribute('for')); - }, false) - }); - this.scrollEventSet.add(_closeRewardExclude); - } - - initPostChatUser() { - if (!window.postChatUser || !postChatConfig || postChatConfig.userMode === 'magic') return; - postChat_theme = this.isDark ? 'dark' : 'light'; - this.switchThemeEventSet.add((isDark) => { - const targetFrame = document.getElementById("postChat_iframeContainer") - if (targetFrame) { - window.postChatUser.setPostChatTheme(isDark ? 'dark' : 'light'); - } else { - postChat_theme = isDark ? 'dark' : 'light'; - } - }); - } - - onScroll() { - const ACCURACY = 20; - const $autoHeaders = []; - const $backToTop = document.querySelector('.back-to-top'); - const $readingProgressBar = document.querySelector('.reading-progress-bar'); - if (document.body.dataset.headerDesktop === 'auto') { - $autoHeaders.push(document.getElementById('header-desktop')); - } - if (document.body.dataset.headerMobile === 'auto') { - $autoHeaders.push(document.getElementById('header-mobile')); - } - $backToTop?.addEventListener('click', () => { - scrollIntoView('body'); - }); - window.addEventListener('scroll', (event) => { - if (this.disableScrollEvent) { - event.preventDefault(); - return; - } - this.newScrollTop = getScrollTop(); - const scroll = this.newScrollTop - this.oldScrollTop; - if (Math.abs(scroll) > ACCURACY) { - this.closeActiveMaskOverlay(); - const isScrollingDown = scroll > 0; - forEach($autoHeaders, ($header) => { - if (isScrollingDown) { - $header.classList.remove('header__fadeInDown'); - animateCSS($header, ['header__fadeOutUp'], true); - } else { - $header.classList.remove('header__fadeOutUp'); - animateCSS($header, ['header__fadeInDown'], true); - } - }); - } else if (this.newScrollTop <= 0) { - forEach($autoHeaders, ($header) => { - $header.classList.remove('header__fadeOutUp'); - animateCSS($header, ['header__fadeInDown'], true); - }); - } - const contentHeight = document.body.scrollHeight - window.innerHeight; - const scrollPercent = Math.max(Math.min(100 * Math.max(this.newScrollTop, 0) / contentHeight, 100), 0); - if ($readingProgressBar) { - $readingProgressBar.style.setProperty('--fi-progress', `${scrollPercent.toFixed(2)}%`); - } - // whether to show back to top button - if ($backToTop) { - if (scrollPercent > 1) { - $backToTop.classList.remove('d-none', 'animate__fadeOut'); - animateCSS($backToTop, ['animate__fadeIn'], true); - } else { - $backToTop.classList.remove('animate__fadeIn'); - animateCSS($backToTop, ['animate__fadeOut'], true, () => { - $backToTop.classList.contains('animate__fadeOut') && $backToTop.classList.add('d-none'); - }); - } - // Set progress as 0-100 value for CSS calculation - $backToTop.style.setProperty('--fi-b2t-progress', scrollPercent.toFixed(2)); - // Calculate stroke-dashoffset for Firefox compatibility - if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) { - const dashoffset = 2 * Math.PI * 50 * (1 - scrollPercent / 100); - $backToTop.querySelector('circle.progress').style.strokeDashoffset = dashoffset.toFixed(2); - } - } - for (let event of this.scrollEventSet) { - event(); - } - this.syncTocHeight(); - this.syncTocActiveState(); - this.oldScrollTop = this.newScrollTop; - }, false); - } - - onResize() { - let resizeBefore = isMobile(); - window.addEventListener('resize', () => { - if (!this._resizeTimeout) { - this._resizeTimeout = window.setTimeout(() => { - this._resizeTimeout = null; - for (let event of this.resizeEventSet) { - event(); - } - this.initToc(); - this.initSearch(); - this.syncTocHeight(); - this.syncTocActiveState(); - - const _isMobile = isMobile(); - if (_isMobile !== resizeBefore) { - this.closeActiveMaskOverlay(); - resizeBefore = _isMobile; - } - }, 100); - } - }, false); - } - - onClickMask() { - document.getElementById('mask').addEventListener('click', (e) => { - if (!e.target.classList.contains('blur')) return; - this.closeActiveMaskOverlay(); - }, false); - } - - initPrint() { - window.addEventListener('beforeprint', () => { - const $content = document.getElementById('content'); - const printConfig = this.config.print || {}; - - if (printConfig.expandAdmonition) { - forEach($content.querySelectorAll('.admonition'), ($el) => $el.classList.add('open')); - } - if (printConfig.expandCode) { - // revert code tabs to code blocks for better printing support - forEach($content.querySelectorAll('.code-tabs'), ($codeTabs) => { - // skip diagrams - if ($codeTabs.dataset.diagram) return; - // restore action buttons to the active tab's code-header before reverting - const $actions = $codeTabs.querySelector('.tabs-actions'); - const $activeBlock = $codeTabs.querySelector('.code-block.active'); - if ($actions && $activeBlock) { - const $codeHeader = $activeBlock.querySelector('.code-header'); - if ($codeHeader) { - Array.from($actions.children).forEach(btn => $codeHeader.appendChild(btn)); - } - } - const $codeBlocks = $codeTabs.querySelectorAll('.code-block'); - $codeBlocks.forEach(($codeBlock) => { - delete $codeBlock.dataset.tabInit; - $codeTabs.parentElement.insertBefore($codeBlock, $codeTabs); - }); - $codeTabs.parentElement.removeChild($codeTabs); - }); - forEach($content.querySelectorAll('.code-block'), ($el) => { - // line wrapping - $el.classList.add('line-wrapping'); - // expand all code blocks - $el.classList.remove('is-collapsed'); - // expand code preview - if ($el.querySelector('.code-expand-btn')) { - $el.classList.add('is-expanded'); - } - }); - } - if (printConfig.expandDetails) { - forEach($content.querySelectorAll('details'), ($el) => $el.setAttribute('open', '')); - } - for (let event of this.beforeprintEventSet) { - event(); - } - if (printConfig.expandFileTree) { - FileTree.expandAll($content); - } - }, false); - - window.addEventListener('afterprint', () => { - this.initCodeTabs(); - for (let event of this.afterprintEventSet) { - event(); - } - }, false); - } - - init() { - try { - if (this.config.encryption) { - this.initFixItDecryptor(); - } - if (!this.config.encryption?.all) { - this._initContentComponents(document, false); - } - this.initThemeColor(); - this.initSVGIcon(); - this.initMenu(); - this.initSwitchTheme(); - this.initSearch(); - this.initCookieconsent(); - this.initSiteTime(); - this.initServiceWorker(); - this.initWatermark(); - this.initAutoMark(); - this.initReward(); - this.initPostChatUser(); - - window.setTimeout(() => { - this.initComment(); - if (!this.config.encryption?.all) { - this.fixTocScroll(); - this.initToc(); - this.initTocListener(); - this.initTocDialog(); - } - this.onScroll(); - this.onResize(); - this.onClickMask(); - this.initPrint(); - }, 100); - } catch (err) { - console.error(err); - } - console.log( - `%c FixIt ${this.config.version} %c https://github.com/hugo-fixit %c`, - `background: #FF735A;border:1px solid #FF735A; padding: 1px; border-radius: 2px 0 0 2px; color: #fff;`, - `border:1px solid #FF735A; padding: 1px; border-radius: 0 2px 2px 0; color: #FF735A;`, - 'background:transparent;' - ); - } -} - -const themeInit = () => { - window.fixit = new FixIt(); - window.fixit.init(); -}; - -if (document.readyState !== 'loading') { - themeInit(); -} else { - document.addEventListener('DOMContentLoaded', themeInit, false); -} diff --git a/assets/js/types/config.ts b/assets/js/types/config.ts new file mode 100644 index 00000000..b3345d38 --- /dev/null +++ b/assets/js/types/config.ts @@ -0,0 +1,160 @@ +import type { MermaidConfig } from './third-party' + +/** Mask overlay handler */ +export interface MaskOverlayHandler { + isActive?: () => boolean + onOpen?: () => void + onClose?: () => void +} + +/** FixIt theme configuration (typed version of window.config) */ +export interface FixItConfig { + version?: string + themeMode?: string + twemoji?: boolean + search?: SearchConfig + cse?: CSEConfig + echarts?: EchartsConfig + mapbox?: MapboxConfig + typeit?: TypeItConfig + comment?: CommentConfig + cookieconsent?: object + siteTime?: string + PWA?: PWAConfig + watermark?: WatermarkConfig + pangu?: PanguConfig + mathjax?: MathJaxConfig + mermaid?: MermaidConfig + lightgallery?: boolean + tooltip?: boolean + autoBookmark?: boolean + encryption?: EncryptionConfig + print?: PrintConfig +} + +export interface SearchConfig { + type?: string + maxResultLength?: number + snippetLength?: number + highlightTag?: string + isCaseSensitive?: boolean + minMatchCharLength?: number + findAllMatches?: boolean + location?: number + threshold?: number + distance?: number + ignoreLocation?: boolean + useExtendedSearch?: boolean + ignoreFieldNorm?: boolean + fuseIndexURL?: string + algoliaAppID?: string + algoliaSearchKey?: string + algoliaIndex?: string + noResultsFound?: string + pagefind?: PagefindConfig +} + +export interface PagefindConfig { + bundlePath?: string + baseURL?: string + debounceTimeoutMs?: number + useBuiltInFilters?: boolean + sortBy?: string + sortOrder?: string +} + +export interface CSEConfig { + engine?: string + cx?: string + resultsPage?: string + searchIn?: string + gotoResultsPage?: string +} + +export interface EchartsConfig { + lightTheme?: object + darkTheme?: object +} + +export interface MapboxConfig { + accessToken?: string + RTLTextPlugin?: string +} + +export interface TypeItConfig { + speed?: number + cursorSpeed?: number + cursorChar?: string + loop?: boolean + duration?: number +} + +export interface CommentConfig { + enable?: boolean + expired?: boolean + lightgallery?: boolean + artalk?: Record + gitalk?: Record + valine?: Record + waline?: Record + utterances?: UtterancesConfig + twikoo?: TwikooConfig + giscus?: GiscusConfig +} + +export interface UtterancesConfig { + repo?: string + issueTerm?: string + label?: string + lightTheme?: string + darkTheme?: string +} + +export interface TwikooConfig extends Record { + lightgallery?: boolean + commentCount?: boolean + envId?: string + region?: string +} + +export interface GiscusConfig { + origin?: string + lightTheme?: string + darkTheme?: string +} + +export interface PWAConfig { + enable?: boolean + serviceWorkerURL: string +} + +export interface WatermarkConfig { + enable?: boolean + [key: string]: any +} + +export interface PanguConfig { + enable?: boolean + selector?: string +} + +export interface MathJaxConfig { + cdn?: string + packages?: Record + macros?: Record + tex?: Record + loader?: Record + options?: Record +} + +export interface EncryptionConfig { + all?: boolean + shortcode?: boolean +} + +export interface PrintConfig { + expandAdmonition?: boolean + expandCode?: boolean + expandDetails?: boolean + expandFileTree?: boolean +} diff --git a/assets/js/types/index.ts b/assets/js/types/index.ts new file mode 100644 index 00000000..949057ed --- /dev/null +++ b/assets/js/types/index.ts @@ -0,0 +1,3 @@ +export type * from './config' +export type * from './third-party' +export type * from './ui' diff --git a/assets/js/types/third-party.ts b/assets/js/types/third-party.ts new file mode 100644 index 00000000..93f3402d --- /dev/null +++ b/assets/js/types/third-party.ts @@ -0,0 +1,48 @@ +export interface MermaidConfig { + wrapper?: boolean + cdn?: string + zenuml?: string + themes?: string[] + securitylevel?: string + look?: string + fontfamily?: string + layoutloaders?: string[] + layout?: string +} + +export interface MermaidRenderResult { + svg?: string + bindFunctions?: (element: Element) => void +} + +export interface MermaidRuntimeModule { + startOnLoad: boolean + initialize: (config: Record) => void + render: (id: string, source: string) => Promise + registerExternalDiagrams?: (diagrams: unknown[]) => Promise + registerLayoutLoaders?: (loaders: unknown[]) => void +} + +export interface MermaidRuntime { + mermaid: MermaidRuntimeModule + config: MermaidConfig + zenuml?: unknown + loaders: unknown[] +} + +export interface PanzoomTransform { + x: number + y: number + scale: number +} + +export interface PanzoomInstance { + getPan: () => { x: number, y: number } + getScale: () => number + pan: (x: number, y: number, options?: { animate?: boolean, force?: boolean }) => void + zoom: (scale: number, options?: { animate?: boolean, force?: boolean }) => void + zoomIn: (options?: { animate?: boolean }) => void + zoomOut: (options?: { animate?: boolean }) => void + zoomWithWheel: (event: WheelEvent) => void + reset: (options?: { animate?: boolean }) => void +} diff --git a/assets/js/types/ui.ts b/assets/js/types/ui.ts new file mode 100644 index 00000000..8d003db1 --- /dev/null +++ b/assets/js/types/ui.ts @@ -0,0 +1,79 @@ +import type { FixItEventMap, TypedEventBus } from '../core/event-bus' +import type { FixItConfig, MaskOverlayHandler } from './config' +import type { MermaidRuntimeModule, PanzoomInstance } from './third-party' + +export interface TabContainerChangedDetail { + relatedTarget?: Element | null +} + +export type TabContainerChangedEvent = CustomEvent & { + panel: Element | null +} + +type FixItDocumentEventMap = { + [K in keyof FixItEventMap]: CustomEvent +} + +/** Public API exposed on window.fixit. */ +export interface FixItPublicAPI { + readonly config: FixItConfig + readonly themeMode: string + readonly isDark: boolean + readonly newScrollTop: number + readonly oldScrollTop: number + setThemeMode: (mode: string, persist?: boolean) => void + registerMaskOverlay: (name: string, handlers: MaskOverlayHandler) => void + toggleMaskOverlay: (name: string) => void + closeMaskOverlay: (name: string, skipSync?: boolean) => void + initContent: (target?: Element | Document) => void + eventBus: TypedEventBus +} + +declare global { + interface DocumentEventMap extends FixItDocumentEventMap { + 'tab-container-changed': TabContainerChangedEvent + } + + interface Window { + // Third-party libraries + autocomplete?: any + algoliasearch?: any + Artalk?: any + APlayer?: any + CellTooltip?: any + cookieconsent?: any + CryptoJS?: any + echarts?: any + Fuse?: any + Gitalk?: any + JsonViewerElement?: any + lgThumbnail?: any + lgZoom?: any + lightGallery?: any + mapboxgl?: any + MapboxLanguage?: any + MathJax?: any + objectFitImages?: () => void + pangu?: any + postChatUser?: any + postChatConfig?: any + postChat_theme?: string + twemoji?: any + twikoo?: any + TypeIt?: any + Valine?: any + Waline?: any + Watermark?: any + xxhash?: any + Panzoom?: (element: SVGElement, options?: Record) => PanzoomInstance + + // FixIt theme + fixit: FixItPublicAPI + config: FixItConfig + mermaid?: MermaidRuntimeModule + _fuseIndex?: any + _searchMobile?: any + _searchDesktop?: any + FixItDecryptor?: any + } +} diff --git a/assets/js/utils/animation.ts b/assets/js/utils/animation.ts new file mode 100644 index 00000000..e7833774 --- /dev/null +++ b/assets/js/utils/animation.ts @@ -0,0 +1,15 @@ +/** + * Add one or more Animate.css classes to an element. + * @param element - The DOM element to animate. + * @param animation - One or more Animate.css class names. + * @param reserved - If `true`, keep animation classes after completion. + * @param callback - Optional callback invoked when the animation ends. + */ +export function animateCSS(element: Element, animation: string | string[], reserved?: boolean, callback?: () => void) { + const animations = Array.isArray(animation) ? animation : [animation] + element.classList.add('animate__animated', ...animations) + element.addEventListener('animationend', () => { + !reserved && element.classList.remove('animate__animated', ...animations) + typeof callback === 'function' && callback() + }, { once: true }) +} diff --git a/assets/js/utils/clipboard.ts b/assets/js/utils/clipboard.ts new file mode 100644 index 00000000..b9702c12 --- /dev/null +++ b/assets/js/utils/clipboard.ts @@ -0,0 +1,22 @@ +/** + * Create a text-copy helper with clipboard API fallback. + * @returns A function that copies text to the clipboard. + */ +export function createCopyText(): (text: string) => Promise { + if (navigator.clipboard) { + return (text: string) => navigator.clipboard.writeText(text) + } + return (text: string) => new Promise((resolve, reject) => { + const input = document.createElement('input') + input.value = text + document.body.appendChild(input) + input.select() + if (document.execCommand('copy')) { + document.body.removeChild(input) + resolve() + } + else { + reject(new Error('Copy failed')) + } + }) +} diff --git a/assets/js/utils/comment.ts b/assets/js/utils/comment.ts new file mode 100644 index 00000000..ed7e33cf --- /dev/null +++ b/assets/js/utils/comment.ts @@ -0,0 +1,29 @@ +/** + * Enable lightGallery on images inside comment containers. + * @param comments - CSS selector for comment content containers. + * @param images - CSS selector for images within those containers. + */ +export function initCommentLightGallery(comments: string, images: string) { + if (!window.lightGallery) + return + + document.querySelectorAll(comments).forEach(($content) => { + const $imgs = $content.querySelectorAll(`${images}:not([lightgallery-loaded])`) + $imgs.forEach(($img) => { + $img.setAttribute('lightgallery-loaded', '') + const $link = document.createElement('a') + $link.setAttribute('class', 'comment-lightgallery') + $link.setAttribute('href', $img.src) + $link.append($img.cloneNode()) + $img.replaceWith($link) + }) + if ($imgs.length) { + window.lightGallery($content, { + selector: '.comment-lightgallery', + actualSize: false, + hideBarsDelay: 2000, + speed: 400, + }) + } + }) +} diff --git a/assets/js/utils/common.js b/assets/js/utils/common.js deleted file mode 100644 index b2f19f66..00000000 --- a/assets/js/utils/common.js +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Iterate over an array-like collection. - * If any handler call returns a Promise, all Promises are collected and returned. - * @param {ArrayLike<*>|Array<*>} elements collection to iterate - * @param {Function} handler callback for each item - * @returns {Promise>} resolved results for async handlers - */ -export function forEach(elements, handler) { - elements = elements || []; - const promises = []; - for (let i = 0; i < elements.length; i++) { - const result = handler(elements[i], i); - if (result instanceof Promise) { - promises.push(result); - } - } - return Promise.all(promises); -} - -/** - * Get the current vertical scroll position. - * @returns {number} current scroll top - */ -export function getScrollTop() { - return (document.documentElement ?? document.body).scrollTop; -} - -/** - * Check whether the current viewport matches the mobile breakpoint. - * @returns {Boolean} whether the viewport is mobile-sized - */ -export function isMobile() { - return window.matchMedia('only screen and (max-width: 680px)').matches; -} - -/** - * Check whether the table of contents should use the static layout. - * @returns {Boolean} whether the TOC should be rendered as static - */ -export function isTocStatic() { - return document.getElementById('toc-static')?.dataset?.kept === 'true' || window.matchMedia('only screen and (max-width: 960px)').matches; -} - -/** - * Get the current theme mode from the root element. - * @returns {String} one of auto, light, or dark - */ -export function getThemeMode() { - return document.documentElement.dataset.themeMode || 'auto'; -} - -/** - * Check whether the current effective theme is dark. - * In auto mode, this follows the system color scheme preference. - * @returns {Boolean} whether dark mode is currently active - */ -export function isDarkMode() { - const themeMode = getThemeMode(); - return themeMode === 'auto' - ? window.matchMedia('(prefers-color-scheme: dark)').matches - : themeMode === 'dark'; -} - -/** - * Add one or more Animate.css classes to an element. - * @param {Element} element target element - * @param {String|Array} animation animation name or names - * @param {Boolean} reserved whether to keep animation classes after completion - * @param {Function} callback callback invoked after animation ends - */ -export function animateCSS(element, animation, reserved, callback) { - !Array.isArray(animation) && (animation = [animation]); - element.classList.add('animate__animated', ...animation); - element.addEventListener('animationend', () => { - !reserved && element.classList.remove('animate__animated', ...animation); - typeof callback === 'function' && callback(); - }, { once: true }); -} - -/** - * Validate whether a value is a valid Date instance. - * @param {*} date value to validate - * @returns {Boolean} whether the value is a valid date - */ -export function isValidDate(date) { - return date instanceof Date && !isNaN(date.getTime()); -} - -/** - * Scroll an element into view smoothly. - * @param {String} selector selector or id reference beginning with # - */ -export function scrollIntoView(selector) { - const element = selector.startsWith('#') - ? document.getElementById(selector.slice(1)) - : document.querySelector(selector); - element?.scrollIntoView({ - behavior: 'smooth' - }); -} - -/** - * Create a hidden staging element for temporary DOM operations. - * @returns {Object} staging helpers and the staging element itself - */ -export function getStagingDOM() { - const stagingElement = document.createElement('div') - stagingElement.style.display = 'none'; - stagingElement.dataset.stagingId = Math.random().toString(36).slice(2); - document.body.appendChild(stagingElement); - - return { - $el: stagingElement, - stage(dom) { - stagingElement.innerHTML = ''; - stagingElement.appendChild(dom); - }, - contentAsHtml() { - return stagingElement.innerHTML; - }, - contentAsText() { - return stagingElement.innerText; - }, - contentAsJson() { - return JSON.parse(stagingElement.innerHTML); - }, - destroy() { - document.body.removeChild(stagingElement); - } - } -} - -/** - * Create a text-copy helper with clipboard API fallback. - * @returns {Function} function that copies text and returns a Promise - */ -export function createCopyText() { - if (navigator.clipboard) { - return (text) => navigator.clipboard.writeText(text); - } - return (text) => new Promise((resolve, reject) => { - const input = document.createElement('input'); - input.value = text; - document.body.appendChild(input); - input.select(); - if (document.execCommand('copy')) { - document.body.removeChild(input); - resolve(); - } else { - reject(); - } - }); -} - -/** - * Check whether a string looks like a JavaScript object literal. - * @example isObjectLiteral("{a:1,b:2}") // true - * @param {String} str string to check - * @returns {Boolean} whether the string is an object literal - */ -export function isObjectLiteral(str) { - if (typeof str !== 'string') { - return false; - } - str = str.replace(/\s+/g, ' ').trim().replace(/;$/, '') - if (str.startsWith('{') && str.endsWith('}')) { - return true; - } - return false; -} - -/** - * Escape a string for safe HTML text output. - * @param {String} str string to escape - * @returns {String} escaped HTML string - */ -export function HTMLEscape(str) { - return str.replace(/[&<>"']/g, char => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - })[char]); -} diff --git a/assets/js/utils/dom.ts b/assets/js/utils/dom.ts new file mode 100644 index 00000000..41a7c7b9 --- /dev/null +++ b/assets/js/utils/dom.ts @@ -0,0 +1,51 @@ +/** + * Get the current vertical scroll position. + * @returns The scroll offset in pixels. + */ +export function getScrollTop(): number { + return (document.documentElement ?? document.body).scrollTop +} + +/** + * Scroll an element into view smoothly. + * @param selector - A CSS selector or `#id` string targeting the element. + */ +export function scrollIntoView(selector: string) { + const element = selector.startsWith('#') + ? document.getElementById(selector.slice(1)) + : document.querySelector(selector) + element?.scrollIntoView({ + behavior: 'smooth', + }) +} + +/** + * Create a hidden staging element for temporary DOM operations. + * @returns A staging object with `stage`, `contentAsHtml`, `contentAsText`, `contentAsJson`, and `destroy` methods. + */ +export function getStagingDOM() { + const stagingElement = document.createElement('div') + stagingElement.style.display = 'none' + stagingElement.dataset.stagingId = Math.random().toString(36).slice(2) + document.body.appendChild(stagingElement) + + return { + $el: stagingElement, + stage(dom: Node) { + stagingElement.innerHTML = '' + stagingElement.appendChild(dom) + }, + contentAsHtml(): string { + return stagingElement.innerHTML + }, + contentAsText(): string { + return stagingElement.textContent ?? '' + }, + contentAsJson(): any { + return JSON.parse(stagingElement.innerHTML) + }, + destroy() { + document.body.removeChild(stagingElement) + }, + } +} diff --git a/assets/js/utils/file.ts b/assets/js/utils/file.ts new file mode 100644 index 00000000..0cbfa8a2 --- /dev/null +++ b/assets/js/utils/file.ts @@ -0,0 +1,16 @@ +/** + * Download a text string as a file. + * @param content - The text content to download. + * @param filename - The desired file name. + */ +export function downloadAsFile(content: string, filename: string) { + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename.replace(/[\\/:*?"<>|\r\n]+/g, '-') + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} diff --git a/assets/js/utils/index.ts b/assets/js/utils/index.ts new file mode 100644 index 00000000..028b0fc0 --- /dev/null +++ b/assets/js/utils/index.ts @@ -0,0 +1,10 @@ +export * from './animation' +export * from './clipboard' +export * from './comment' +export * from './dom' +export * from './file' +export * from './media' +export * from './string' +export * from './theme' +export * from './tooltip' +export * from './validate' diff --git a/assets/js/utils/media.ts b/assets/js/utils/media.ts new file mode 100644 index 00000000..f90031ea --- /dev/null +++ b/assets/js/utils/media.ts @@ -0,0 +1,15 @@ +/** + * Check whether the current viewport matches the mobile breakpoint. + * @returns `true` if the viewport width is 680px or less. + */ +export function isMobile(): boolean { + return window.matchMedia('only screen and (max-width: 680px)').matches +} + +/** + * Check whether the table of contents should use the static layout. + * @returns `true` if TOC should be rendered statically. + */ +export function isTocStatic(): boolean { + return (document.getElementById('toc-static') as HTMLElement)?.dataset?.kept === 'true' || window.matchMedia('only screen and (max-width: 960px)').matches +} diff --git a/assets/js/utils/string.ts b/assets/js/utils/string.ts new file mode 100644 index 00000000..fe2ec246 --- /dev/null +++ b/assets/js/utils/string.ts @@ -0,0 +1,32 @@ +/** + * Escape a string for safe HTML text output. + * @param str - The string to escape. + * @returns The escaped string. + */ +export function HTMLEscape(str: string): string { + return str.replace(/[&<>"']/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''', + }[char]!)) +} + +/** + * Apply highlight tags to text at the given character index ranges. + * @param text - The source text to highlight. + * @param indices - Array of `[start, end]` character index pairs. + * @param highlightTag - The HTML tag name to wrap highlights with. + * @returns The text with highlight tags inserted. + */ +export function applyHighlightToText(text: string, indices: number[][], highlightTag: string): string { + let offset = 0 + for (let i = 0; i < indices.length; i++) { + const substr = text.substring(indices[i][0] + offset, indices[i][1] + 1 + offset) + const tag = `<${highlightTag}>${substr}` + text = text.substring(0, indices[i][0] + offset) + tag + text.substring(indices[i][1] + 1 + offset, text.length) + offset += highlightTag.length * 2 + 5 + } + return text +} diff --git a/assets/js/utils/theme.ts b/assets/js/utils/theme.ts new file mode 100644 index 00000000..3fb756cf --- /dev/null +++ b/assets/js/utils/theme.ts @@ -0,0 +1,19 @@ +/** + * Get the current theme mode from the root element. + * @returns The theme mode string: `'auto'`, `'light'`, or `'dark'`. + */ +export function getThemeMode(): string { + return document.documentElement.dataset.themeMode || 'auto' +} + +/** + * Check whether the current effective theme is dark. + * In auto mode, this follows the system color scheme preference. + * @returns `true` if dark mode is active. + */ +export function isDarkMode(): boolean { + const themeMode = getThemeMode() + return themeMode === 'auto' + ? window.matchMedia('(prefers-color-scheme: dark)').matches + : themeMode === 'dark' +} diff --git a/assets/js/utils/tooltip.ts b/assets/js/utils/tooltip.ts new file mode 100644 index 00000000..c5c3b309 --- /dev/null +++ b/assets/js/utils/tooltip.ts @@ -0,0 +1,29 @@ +/** + * Flash a temporary tooltip message on an element. + * @param el - The target element to show the tooltip on. + * @param message - The tooltip message text. + * @param duration - How long to display the tooltip in milliseconds. + */ +export function flashTooltip(el: HTMLElement, message: string, duration = 3000) { + const CellTooltip = window.CellTooltip + const originalTitle = el.dataset.ctTitle + el.dataset.ctTitle = message + const instance = CellTooltip.getOrCreateInstance(el) + instance.refresh() + instance.show() + setTimeout(() => { + el.dataset.ctTitle = originalTitle ?? '' + instance.hide() + }, duration) +} + +/** + * Flash a "copied" tooltip on a button, resetting after a delay. + * @param btn - The button element to show the tooltip on. + * @param duration - How long to display the tooltip in milliseconds. + */ +export function flashCopiedTooltip(btn: HTMLElement, duration = 2000) { + btn.toggleAttribute('data-copied', true) + flashTooltip(btn, btn.dataset.copiedText ?? '', duration) + setTimeout(() => btn.toggleAttribute('data-copied', false), duration) +} diff --git a/assets/js/utils/validate.ts b/assets/js/utils/validate.ts new file mode 100644 index 00000000..dc60bc2c --- /dev/null +++ b/assets/js/utils/validate.ts @@ -0,0 +1,21 @@ +/** + * Validate whether a value is a valid Date instance. + * @param date - The value to check. + * @returns `true` if the value is a valid Date. + */ +export function isValidDate(date: unknown): date is Date { + return date instanceof Date && !Number.isNaN(date.getTime()) +} + +/** + * Check whether a string looks like a JavaScript object literal. + * @param str - The value to check. + * @returns `true` if the string resembles `{...}`. + */ +export function isObjectLiteral(str: unknown): str is string { + if (typeof str !== 'string') { + return false + } + const trimmed = str.replace(/\s+/g, ' ').trim().replace(/;$/, '') + return trimmed.startsWith('{') && trimmed.endsWith('}') +} diff --git a/assets/scss/pages/single/_base.scss b/assets/scss/pages/single/_base.scss index f0ab97bb..81b0ff54 100644 --- a/assets/scss/pages/single/_base.scss +++ b/assets/scss/pages/single/_base.scss @@ -510,8 +510,15 @@ } } - json-viewer[boxed] + json-viewer[boxed] { - margin-top: 0.5rem; + json-viewer { + &[boxed] + json-viewer[boxed] { + margin-top: 0.5rem; + } + + @include dark-mode { + --jv-bg-color: #{fi-var(code-block-background-color)}; + --jv-border-color: #{fi-var(global-border-color)}; + } } } } diff --git a/assets/scss/pages/single/_code.scss b/assets/scss/pages/single/_code.scss index 5065b567..c65c93bd 100644 --- a/assets/scss/pages/single/_code.scss +++ b/assets/scss/pages/single/_code.scss @@ -623,10 +623,9 @@ background-color: fi-var(global-border-color-weight); } - // hide dividers on both sides of the active tab + // lighten dividers on both sides of the active tab &.active::after, &:has(+ .tab-item.active)::after { - // display: none; background-color: fi-var(global-border-color); } diff --git a/assets/scss/pages/single/shortcodes/_mermaid.scss b/assets/scss/pages/single/shortcodes/_mermaid.scss index 73bc5091..451ae73c 100644 --- a/assets/scss/pages/single/shortcodes/_mermaid.scss +++ b/assets/scss/pages/single/shortcodes/_mermaid.scss @@ -4,7 +4,7 @@ @mixin mermaid-base { position: relative; box-sizing: border-box; - margin: 0; + margin: 0 !important; overflow: hidden !important; &[data-processed] { @@ -70,7 +70,7 @@ } .diagram-container { - margin-block: 0; + margin-block: 0 !important; } &.is-fullscreen { diff --git a/eslint.config.js b/eslint.config.js index fb0f64f3..8c276afc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,12 +5,11 @@ export default antfu({ ignores: [ 'node_modules/**', 'assets/lib/**', - 'assets/js/lib/mermaid.js', 'public/**', 'layouts/**/*.json', 'layouts/**/*.xml', 'layouts/**/*.md', - // 临时忽略 - 'assets/js/**', + // ignore temporarily + 'assets/js/service-worker.js', ], }) diff --git a/i18n/de.toml b/i18n/de.toml index cadbff01..e49ab06f 100644 --- a/i18n/de.toml +++ b/i18n/de.toml @@ -2,7 +2,7 @@ # Übersetzung auf Deutsch # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Hugo-Version ist zu niedrig.\n\nAktuelle Hugo-Version ist {{ .Current }}, die minimal unterstützte Version für FixIt ist {{ .Minimal }}.\n\nWenn Sie Hugo auf Ihrem eigenen Computer ausführen, schauen Sie sich https://gohugo.io/getting-started/installing/#upgrade-hugo für eine Upgrade-Anleitung an.\n\nWenn Sie auf einer Drittanbieterplattform bereitstellen, konfigurieren Sie bitte die Hugo-Version entsprechend." hugoExtendedWarn = "Die Hugo Extended-Version ist für die Unterstützung von SCSS erforderlich." @@ -11,14 +11,14 @@ compatibilityError = "Kompatibilitätsfehler ({{ .From }} -> {{ .To }}):\nSie ha devVersionWarn = "Sie verwenden eine Entwicklungsversion von FixIt. Bitte erwägen Sie die Verwendung einer stabilen Version.\nSiehe https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "Die aktuelle Umgebung ist \"Entwicklung\". Das \"Kommentarsystem\", \"PWA\", \"CDN\", \"Fingerprint\" und \"Analytics\" werden deaktiviert." quicklyUpgrade = "Schnelles Upgrade verwenden Sie den Befehl: " -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "Nach oben" viewComments = "Kommentare anzeigen" noscript = "Diese Website funktioniert am besten mit aktiviertem JavaScript." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -67,29 +67,29 @@ other = "Insgesamt {{ .Count }} Artikel" more = "Mehr" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "Sprache wählen" noMoretTranslations = "Keine weiteren Übersetzungen" switchTheme = "Darstellung ändern" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "Realisiert durch {{ .Hugo }} | Thema - {{ .Theme }}" siteUV = "Besucher insgesamt" sitePV = "Besuche insgesamt" siteRunning = "Website läuft ..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "en" # Deutsch unterstützt Valine nicht valinePlaceholder = "Ihr Kommentar ..." facebookLanguageCode = "de_DE" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "Suche" searchPlaceholder = "Suche nach Titel und Inhalt..." @@ -111,14 +111,14 @@ exitFullscreen = "Vollbild verlassen" cookieconsentMessage = "Diese Website verwendet Cookies, um Ihre Erfahrung zu verbessern." cookieconsentDismiss = "Zustimmen" cookieconsentLink = "Erfahren Sie mehr" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "Teilen auf" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "Inhalt" pin = "Oben anheften" @@ -166,41 +166,41 @@ wechatpay = "WeChat Pay" alipay = "Alipay" paypal = "PayPal" bitcoin = "Bitcoin" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "Seite nicht gefunden" [pageNotFoundText] other = "Leider konnte die von Ihnen angeforderte Seite nicht aufgerufen werden." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "Offline" [offlineText] other = "Sie sind nicht mit dem Internet verbunden, es stehen nur zwischengespeicherte Seiten zur Verfügung." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "Hinweis zur Weiterleitung" message = "Sie sind dabei, {{ .Title }} zu verlassen. Bitte achten Sie auf die Sicherheit Ihres Kontos und Vermögens." confirm = "Weiter besuchen" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "Hinweis" tip = "Tipp" important = "Wichtig" warning = "Warnung" caution = "Vorsicht" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "Überprüft" " " = "Nicht überprüft" @@ -211,9 +211,9 @@ x = "Überprüft" "!" = "Wichtig" "!x" = "Wichtig Erledigt" "?" = "Frage" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "Notiz" abstract = "Kurzfassung" @@ -228,26 +228,26 @@ danger = "Vorsicht" bug = "Bug" example = "Beispiel" quote = "Zitat" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "NEU" changed = "GEÄNDERT" deleted = "GELÖSCHT" deprecated = "VERALTET" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "Vergrößern" zoomOut = "Verkleinern" reset = "Zurücksetzen" download = "SVG herunterladen" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "Diagramm" code = "Code" -# === tabs related === +# === Tabs === diff --git a/i18n/en.toml b/i18n/en.toml index 4cfc8463..0bf10f79 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -1,7 +1,7 @@ # Translations for English # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Hugo version is too low.\n\nCurrent Hugo version is {{ .Current }}, the minimum supported version for FixIt is {{ .Minimal }}.\n\nIf you are running Hugo on your own computer, check out https://gohugo.io/getting-started/installing/#upgrade-hugo for upgrading guide.\n\nIf you are deploying on a third-party platform, please configure Hugo version accordingly." hugoExtendedWarn = "The Hugo Extended version is necessary for SCSS support." @@ -10,14 +10,14 @@ compatibilityError = "Compatibility Error ({{ .From }} -> {{ .To }}):\nYou have devVersionWarn = "You are using a development version of FixIt. Please consider using a stable version.\nSee https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "Current environment is \"development\". The \"comment system\", \"PWA\", \"CDN\", \"fingerprint\" and \"analytics\" will be disabled." quicklyUpgrade = "Quickly upgrade use command: " -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "Back to Top" viewComments = "View Comments" noscript = "This website works best with JavaScript enabled." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -67,29 +67,29 @@ other = "{{ .Count }} articles in total" more = "More" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "Select Language" noMoretTranslations = "No more translations" switchTheme = "Switch Theme" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "Powered by {{ .Hugo }} | Theme - {{ .Theme }}" siteUV = "Total visitors" sitePV = "Total visits" siteRunning = "Website running ..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "en" valinePlaceholder = "Your comment ..." facebookLanguageCode = "en_US" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "Search" searchPlaceholder = "Search titles or contents ..." @@ -111,14 +111,14 @@ exitFullscreen = "Exit fullscreen" cookieconsentMessage = "This website uses Cookies to improve your experience." cookieconsentDismiss = "Got it!" cookieconsentLink = "Learn more" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "Share on" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "Contents" pin = "Pin to top" @@ -166,41 +166,41 @@ wechatpay = "WeChat Pay" alipay = "Alipay" paypal = "PayPal" bitcoin = "Bitcoin" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "Page not found" [pageNotFoundText] other = "The page you're looking for doesn't exist. Sorry." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "Offline" [offlineText] other = "You are not connected to the Internet, only cached pages will be available." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "Redirection Notice" message = "You are about to leave {{ .Title }}, please be aware of your account and property security." confirm = "Continue visiting" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "Note" tip = "Tip" important = "Important" warning = "Warning" caution = "Caution" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "Checked" " " = "Unchecked" @@ -211,9 +211,9 @@ x = "Checked" "!" = "Important" "!x" = "Important Checked" "?" = "Question" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "Note" abstract = "Abstract" @@ -228,26 +228,26 @@ danger = "Danger" bug = "Bug" example = "Example" quote = "Quote" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "NEW" changed = "CHANGED" deleted = "DELETED" deprecated = "DEPRECATED" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "Zoom in" zoomOut = "Zoom out" reset = "Reset" download = "Download SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "Diagram" code = "Code" -# === tabs related === +# === Tabs === diff --git a/i18n/es.toml b/i18n/es.toml index 1949ecb7..9f2d3fcd 100644 --- a/i18n/es.toml +++ b/i18n/es.toml @@ -2,7 +2,7 @@ # Traducciones para español # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Hugo versión es demasiado baja.\n\nLa versión actual de Hugo es {{ .Current }}, la versión mínima compatible para FixIt es {{ .Minimal }}.\n\nSi estás ejecutando Hugo en tu propia computadora, consulta https://gohugo.io/getting-started/installing/#upgrade-hugo para obtener una guía de actualización.\n\nSi estás desplegando en una plataforma de terceros, por favor configura la versión de Hugo en consecuencia." hugoExtendedWarn = "La versión extendida de Hugo es necesaria para el soporte de SCSS." @@ -11,14 +11,14 @@ compatibilityError = "Error de compatibilidad ({{ .From }} -> {{ .To }}):\nTiene devVersionWarn = "Está utilizando una versión de desarrollo de FixIt. Considere usar una versión estable.\nConsulte https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "El entorno actual es \"desarrollo\". El \"sistema de comentarios\", \"PWA\", \"CDN\", \"huella digital\" y \"análisis\" estarán deshabilitados." quicklyUpgrade = "Actualización rápida use el comando: " -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "Volver arriba" viewComments = "Ver comentarios" noscript = "Este sitio web funciona mejor con JavaScript habilitado." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -67,29 +67,29 @@ other = "Total {{ .Count }} artículos" more = "Más" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "Selecciona el lenguage" noMoretTranslations = "No hay más traducciones" switchTheme = "Cambia el tema" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "Provisto por {{ .Hugo }} | Tema - {{ .Theme }}" siteUV = "Total de visitantes" sitePV = "Total de visitas" siteRunning = "El sitio se está ejecutando ..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "en" # Español no soportado por Valine valinePlaceholder = "Tu comentario ..." facebookLanguageCode = "es_MX" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "Buscar" searchPlaceholder = "Busca títulos o contenido..." @@ -111,14 +111,14 @@ exitFullscreen = "Salir de pantalla completa" cookieconsentMessage = "Este sitio web utiliza Cookies para mejorar su experiencia." cookieconsentDismiss = "De acuerdo" cookieconsentLink = "Aprende más" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "Compartir en" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "Contenido" pin = "Fijar en la parte superior" @@ -166,41 +166,41 @@ wechatpay = "WeChat Pay" alipay = "Alipay" paypal = "PayPal" bitcoin = "Bitcoin" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "Página no encontrada" [pageNotFoundText] other = "La página que estás buscando no existe. Lo siento." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "desconectado" [offlineText] other = "No está conectado a Internet, solo estarán disponibles las páginas almacenadas en caché." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "Aviso de redirección" message = "Está a punto de salir de {{ .Title }}. Proteja la seguridad de su cuenta y sus bienes." confirm = "Continuar visitando" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "Nota" tip = "Consejo" important = "Importante" warning = "Advertencia" caution = "Precaución" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "Verificado" " " = "No verificado" @@ -211,9 +211,9 @@ x = "Verificado" "!" = "Importante" "!x" = "Importante Verificado" "?" = "Pregunta" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "Nota" abstract = "Resumen" @@ -228,26 +228,26 @@ danger = "Peligro" bug = "Error" example = "Ejemplo" quote = "Cita" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "NUEVO" changed = "MODIFICADO" deleted = "ELIMINADO" deprecated = "OBSOLETO" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "Acercar" zoomOut = "Alejar" reset = "Restablecer" download = "Descargar SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "Diagrama" code = "Código" -# === tabs related === +# === Tabs === diff --git a/i18n/fr.toml b/i18n/fr.toml index d63a691c..cf862785 100644 --- a/i18n/fr.toml +++ b/i18n/fr.toml @@ -2,7 +2,7 @@ # Traductions pour le français # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "La version de Hugo est trop ancienne.\n\nLa version actuelle de Hugo est {{ .Current }}, la version minimale requise pour FixIt est {{ .Minimal }}.\n\nSi vous utilisez Hugo sur votre ordinateur, consultez le guide de mise à niveau à l'adresse https://gohugo.io/getting-started/installing/#upgrade-hugo.\n\nSi vous déployez sur une plateforme tierce, veuillez configurer la version de Hugo en conséquence." hugoExtendedWarn = "La version étendue de Hugo est nécessaire pour la prise en charge de SCSS." @@ -11,14 +11,14 @@ compatibilityError = "Erreur de compatibilité ({{ .From }} -> {{ .To }}):\nVous devVersionWarn = "Vous utilisez une version de développement de FixIt. Veuillez considérer l'utilisation d'une version stable.\nVoir https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "L'environnement actuel est \"développement\". Le \"système de commentaires\", le \"PWA\", le \"CDN\", le \"prise d'empreinte\" et la \"analyses\" seront désactivés." quicklyUpgrade = "La mise à jour rapide utilise la commande: " -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "Retour en Haut" viewComments = "Afficher les Commentaires" noscript = "Ce site Web fonctionne mieux quand JavaScript est activé." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -67,29 +67,29 @@ other = "{{ .Count }} articles au total" more = "Plus" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "Choisir la langue" noMoretTranslations = "Plus de traductions" switchTheme = "Changer de Thème" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "Propulsé par {{ .Hugo }} | Thème - {{ .Theme }}" siteUV = "Total de visiteurs" sitePV = "Total de visites" siteRunning = "Site en cours d'exécution ..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "en" # French not supported by Valine valinePlaceholder = "Votre commentaire ..." facebookLanguageCode = "fr" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "Chercher" searchPlaceholder = "Rechercher des titres, des contenus..." @@ -111,14 +111,14 @@ exitFullscreen = "Quitter le plein écran" cookieconsentMessage = "Ce site Web utilise des Cookies pour améliorer votre expérience." cookieconsentDismiss = "J'ai compris !" cookieconsentLink = "En apprendre plus" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "Partager via" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "Contenus" pin = "Épingler en haut" @@ -166,41 +166,41 @@ wechatpay = "WeChat Pay" alipay = "Alipay" paypal = "PayPal" bitcoin = "Bitcoin" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "Page introuvable" [pageNotFoundText] other = "Désolé, la page recherchée n'existe pas." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "Hors ligne" [offlineText] other = "Vous n'êtes pas connecté à Internet, seules les pages mises en cache seront disponibles." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "Avis de redirection" message = "Vous êtes sur le point de quitter {{ .Title }}. Veuillez protéger la sécurité de votre compte et de vos biens." confirm = "Continuer la visite" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "Remarque" tip = "Astuce" important = "Important" warning = "Avertissement" caution = "Attention" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "Vérifié" " " = "Non vérifié" @@ -211,9 +211,9 @@ x = "Vérifié" "!" = "Important" "!x" = "Important Vérifié" "?" = "Question" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "Remarque" abstract = "Résumé" @@ -228,26 +228,26 @@ danger = "Danger" bug = "Bug" example = "Exemple" quote = "Citation" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "NOUVEAU" changed = "MODIFIÉ" deleted = "SUPPRIMÉ" deprecated = "OBSOLÈTE" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "Zoom avant" zoomOut = "Zoom arrière" reset = "Réinitialiser" download = "Télécharger le SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "Diagramme" code = "Code" -# === tabs related === +# === Tabs === diff --git a/i18n/hi.toml b/i18n/hi.toml index 70d1cf48..85b4c065 100644 --- a/i18n/hi.toml +++ b/i18n/hi.toml @@ -2,7 +2,7 @@ # हिंदी के लिए अनुवाद # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Hugo संस्करण बहुत पुराना है।\n\nवर्तमान में उपयोग किया जा रहा Hugo संस्करण {{ .Current }} है, जबकि FixIt के लिए न्यूनतम आवश्यक संस्करण {{ .Minimal }} है।\n\nयदि आप अपने कंप्यूटर पर Hugo का उपयोग कर रहे हैं, तो कृपया अपग्रेड गाइड देखें: https://gohugo.io/getting-started/installing/#upgrade-hugo\n\nयदि आप किसी तृतीय-पक्ष प्लेटफ़ॉर्म पर तैनात कर रहे हैं, तो कृपया Hugo संस्करण को तदनुसार कॉन्फ़िगर करें। " hugoExtendedWarn = "SCSS समर्थन के लिए Hugo Extended संस्करण आवश्यक है।" @@ -11,14 +11,14 @@ compatibilityError = "संगतता त्रुटि ({{ .From }} -> {{ . devVersionWarn = "आप फिक्सइट के डिवेलपर संस्करण का उपयोग कर रहे हैं। कृपया एक स्थिर संस्करण का उपयोग करने पर विचार करें।\nयह देखें: https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "वर्तमान परिवेश \"डिवेलप्मेंट\" का है। \"टिप्पणी प्रणाली\", \"पीडब्ल्यूए\", \"सीडीएन\", \"फ़िंगरप्रिंट\" और \"विश्लेषण\" अक्षम कर दिए जाएंगे।" quicklyUpgrade = "शीघ्रता से अपग्रेड यह कमांड का उपयोग करें: " -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "वापस शीर्ष पर" viewComments = "टिप्पणियाँ देखें" noscript = "यह वेबसाइट जेएस को चालू करके बेहतर काम करती है।" -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -68,29 +68,29 @@ other = "कुल {{ .Count }} लेख" more = "और देखें" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "भाषा चुने" noMoretTranslations = "कोई और अनुवाद नहीं" switchTheme = "थीम बदलें" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "{{ .Hugo }} के द्वारा संचालित | थीम - {{ .Theme }}" siteUV = "कुल विज़िटर" sitePV = "कुल विज़िट" siteRunning = "वेबसाइट चल रही है ..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "hi" valinePlaceholder = "आपकी टिप्पणी ..." facebookLanguageCode = "hi" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "खोज" searchPlaceholder = "शीर्षक या सामग्री खोजें ..." @@ -112,14 +112,14 @@ exitFullscreen = "पूर्ण स्क्रीन से बाहर न cookieconsentMessage = "यह वेबसाइट आपके अनुभव को बेहतर बनाने के लिए कुकीज़ का उपयोग करती है।" cookieconsentDismiss = "समझ गया!" cookieconsentLink = "और अधिक जानें" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = " शेयर करें" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "सामग्री" pin = "शीर्ष पर पिन करें" @@ -167,41 +167,41 @@ wechatpay = "वीचैट पे" alipay = "अली पे" paypal = "पेपैल" bitcoin = "बिटकॉइन" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "पृष्ठ नहीं मिला" [pageNotFoundText] other = "आप जिस पृष्ठ को खोज रहे हैं वह मौजूद नहीं है। क्षमा मांगना।" -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "ऑफलाइन" [offlineText] other = "आप इंटरनेट से कनेक्ट नहीं हैं, केवल कैश्ड पेज ही उपलब्ध होंगे।" -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "रीडायरेक्शन सूचना" message = "आप {{ .Title }} छोड़ने वाले हैं, कृपया अपने खाते और संपत्ति की सुरक्षा पर ध्यान दें।" confirm = "देखना जारी रखें" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "ध्यान दें" tip = "टिप" important = "महत्वपूर्ण" warning = "चेतावनी" caution = "सावधान" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "जांच की गई" " " = "जांच नहीं की गई" @@ -212,9 +212,9 @@ x = "जांच की गई" "!" = "महत्वपूर्ण" "!x" = "महत्वपूर्ण सत्यापित" "?" = "सवाल" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "ध्यान दें" abstract = "सारांश" @@ -229,26 +229,26 @@ danger = "खतरा" bug = "बग" example = "उदाहरण" quote = "उद्धरण" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "नया" changed = "बदला हुआ" deleted = "हटाए गए" deprecated = "अप्रचलित" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "ज़ूम इन" zoomOut = "ज़ूम आउट" reset = "रीसेट" download = "SVG डाउनलोड करें" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "आरेख" code = "कोड" -# === tabs related === +# === Tabs === diff --git a/i18n/it.toml b/i18n/it.toml index 119d9faa..6b4030ac 100644 --- a/i18n/it.toml +++ b/i18n/it.toml @@ -2,7 +2,7 @@ # Traduzioni per l'italiano # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "La versione di Hugo è troppo bassa.\n\nLa versione attuale di Hugo è {{ .Current }}, la versione minima supportata per FixIt è {{ .Minimal }}.\n\nSe stai eseguendo Hugo sul tuo computer, consulta https://gohugo.io/getting-started/installing/#upgrade-hugo per la guida all'aggiornamento.\n\nSe stai distribuendo su una piattaforma di terze parti, configura la versione di Hugo di conseguenza." hugoExtendedWarn = "La versione Hugo Extended è necessaria per il supporto SCSS." @@ -11,14 +11,14 @@ compatibilityError = "Errore di compatibilità ({{ .From }} -> {{ .To }}):\nHai devVersionWarn = "Stai utilizzando una versione di sviluppo di FixIt. Si prega di considerare l'utilizzo di una versione stabile.\nVedi https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "L'ambiente attuale è \"sviluppo\". Il \"sistema di commenti\", \"PWA\", \"CDN\", \"impronta digitale\" e \"analisi\" saranno disabilitati." quicklyUpgrade = "Aggiornamento rapido usa il comando: " -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "Torna all'inizio" viewComments = "Vedi commenti" noscript = "Questo sito funziona meglio con JavaScript abilitato." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -67,29 +67,29 @@ other = "Totale {{ .Count }} articoli" more = "Più" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "Scegliere la lingua" noMoretTranslations = "Non ci sono altre traduzioni" switchTheme = "Cambiare il tema" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "Realizzato da {{ .Hugo }} | Tema - {{ .Theme }}" siteUV = "Visitatori totali" sitePV = "Visite totali" siteRunning = "Sito in esecuzione ..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "en" # Valine non supporta l'italiano valinePlaceholder = "Il tuo commento ..." facebookLanguageCode = "it" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "Cerca" searchPlaceholder = "Cerca il titolo o il contenuto dell'articolo ..." @@ -111,14 +111,14 @@ exitFullscreen = "Esci da schermo intero" cookieconsentMessage = "Questo sito Web utilizza i Cookies per migliorare la tua esperienza." cookieconsentDismiss = "Essere d'accordo" cookieconsentLink = "Per saperne di più" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "Condividi su" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "Contenuti" pin = "Fissa in alto" @@ -166,41 +166,41 @@ wechatpay = "WeChat Pay" alipay = "Alipay" paypal = "PayPal" bitcoin = "Bitcoin" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "Pagina non trovata" [pageNotFoundText] other = "Mi spiace, la pagina cercata non esiste." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "disconnesso" [offlineText] other = "Non sei connesso a Internet, saranno disponibili solo le pagine memorizzate nella cache." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "Avviso di reindirizzamento" message = "Stai per lasciare {{ .Title }}. Presta attenzione alla sicurezza del tuo account e dei tuoi beni." confirm = "Continua a visitare" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "Nota" tip = "Suggerimento" important = "Importante" warning = "Avvertimento" caution = "Attenzione" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "Verificato" " " = "Non verificato" @@ -211,9 +211,9 @@ x = "Verificato" "!" = "Importante" "!x" = "Importante Verificato" "?" = "Domanda" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "Note" abstract = "Sommario" @@ -228,26 +228,26 @@ danger = "Pericolo" bug = "Bug" example = "Esempio" quote = "Citazione" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "NUOVO" changed = "CAMBIATO" deleted = "CANCELLATO" deprecated = "DEPRECATO" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "Zoom avanti" zoomOut = "Zoom indietro" reset = "Reimposta" download = "Scarica SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "Diagramma" code = "Codice" -# === tabs related === +# === Tabs === diff --git a/i18n/ja.toml b/i18n/ja.toml index ba30cabc..5ed1fd99 100644 --- a/i18n/ja.toml +++ b/i18n/ja.toml @@ -2,7 +2,7 @@ # 日本語翻訳 # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Hugo のバージョンが低すぎます。\n\n現在の Hugo バージョンは {{ .Current }} ですが、FixIt がサポートする最小バージョンは {{ .Minimal }} です。\n\n自分のコンピューターで Hugo を実行している場合は、https://gohugo.io/getting-started/installing/#upgrade-hugo でアップグレードガイドを参照してください。\n\nサードパーティプラットフォームにデプロイしている場合は、Hugo バージョンを適切に設定してください。" hugoExtendedWarn = "SCSS サポートには Hugo Extended バージョンが必要です。" @@ -11,14 +11,14 @@ compatibilityError = "互換性エラー ({{ .From }} -> {{ .To }}):\n互換性 devVersionWarn = "開発版の FixIt を使用しています。安定版の使用を検討してください。\n https://github.com/hugo-fixit/FixIt/releases を参照" devEnvWarn = "現在の実行環境は「development」です。「コメントシステム」、「PWA」、「CDN」、「フィンガープリント」、「統計」は有効になりません。" quicklyUpgrade = "コマンドを使用して迅速にアップグレード:" -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "トップに戻る" viewComments = "コメントを見る" noscript = "このサイトは JavaScript を有効にした状態で最適に表示されます。" -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -65,29 +65,29 @@ other = "合計 {{ .Count }} 件の記事" more = "もっと見る" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "言語を選択" noMoretTranslations = "これ以上の翻訳はありません" switchTheme = "テーマを切り替え" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "{{ .Hugo }} によって強力に駆動されています | テーマ - {{ .Theme }}" siteUV = "総訪問者数" sitePV = "総訪問数" siteRunning = "サイトが実行中……" -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "zh-cn" valinePlaceholder = "あなたのコメント……" facebookLanguageCode = "zh_CN" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "検索" searchPlaceholder = "タイトルまたは内容を検索..." @@ -109,14 +109,14 @@ exitFullscreen = "全画面を終了" cookieconsentMessage = "このウェブサイトはクッキーを使用して体験を向上させます。" cookieconsentDismiss = "同意する" cookieconsentLink = "詳細はこちら" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "共有先" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "目次" pin = "ピン留め" @@ -162,41 +162,41 @@ wechatpay = "WeChat" alipay = "アリペイ" paypal = "ペイパル" bitcoin = "ビットコイン" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "ページが見つかりません" [pageNotFoundText] other = "申し訳ありませんが、お探しのページは存在しません。" -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "オフライン" [offlineText] other = "インターネットに接続されていません。キャッシュされたページのみが利用可能です。" -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "リダイレクト通知" message = "{{ .Title }} から離れようとしています。アカウントと資産の安全にご注意ください。" confirm = "閲覧を続ける" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "注意" tip = "ヒント" important = "重要" warning = "警告" caution = "慎重" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "完了" " " = "未完了" @@ -207,9 +207,9 @@ x = "完了" "!" = "重要" "!x" = "重要・確認済み" "?" = "問題" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "注意" abstract = "要約" @@ -224,26 +224,26 @@ danger = "危険" bug = "バグ" example = "例" quote = "引用" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "新規" changed = "変更" deleted = "削除" deprecated = "非推奨" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "拡大" zoomOut = "縮小" reset = "リセット" download = "SVG をダウンロード" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "図" code = "コード" -# === tabs related === +# === Tabs === diff --git a/i18n/ko.toml b/i18n/ko.toml index 0aeb3cbd..7c1c2801 100644 --- a/i18n/ko.toml +++ b/i18n/ko.toml @@ -2,7 +2,7 @@ # 한국어 번역 # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Hugo 버전이 너무 낮습니다.\n\n현재 Hugo 버전은 {{ .Current }}이며, FixIt의 최소 지원 버전은 {{ .Minimal }}입니다.\n\n자신의 컴퓨터에서 Hugo를 실행 중이라면 https://gohugo.io/getting-started/installing/#upgrade-hugo에서 업그레이드 가이드를 확인하세요.\n\n타사 플랫폼에 배포 중이라면 Hugo 버전을 적절히 설정해 주세요." hugoExtendedWarn = "SCSS 지원을 위해 Hugo Extended 버전이 필요합니다." @@ -11,14 +11,14 @@ compatibilityError = "호환성 오류 ({{ .From }} -> {{ .To }}):\n비호환 devVersionWarn = "현재 개발 버전의 FixIt을 사용 중입니다. 안정적인 버전을 사용하는 것이 좋습니다.\n자세한 내용은 https://github.com/hugo-fixit/FixIt/releases를 참조하세요." devEnvWarn = "현재 실행 환경이 'development'입니다. '댓글 시스템', 'PWA', 'CDN', '지문 인식' 및 '통계'가 활성화되지 않습니다." quicklyUpgrade = "명령어를 사용하여 빠르게 업그레이드하세요:" -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "맨 위로" viewComments = "댓글 보기" noscript = "이 사이트는 JavaScript가 활성화된 상태에서 최상의 성능을 발휘합니다." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -65,29 +65,29 @@ other = "총 {{ .Count }} 개의 게시물" more = "더 보기" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "언어 선택" noMoretTranslations = "더 이상 번역이 없습니다" switchTheme = "테마 전환" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "{{ .Hugo }}로 구동 | 테마 - {{ .Theme }}" siteUV = "총 방문자 수" sitePV = "총 페이지 뷰 수" siteRunning = "사이트 운영 중……" -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "ko" valinePlaceholder = "당신의 댓글……" facebookLanguageCode = "ko_KR" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "검색" searchPlaceholder = "제목 또는 내용을 검색하세요..." @@ -109,14 +109,14 @@ exitFullscreen = "전체 화면 종료" cookieconsentMessage = "이 웹사이트는 쿠키를 사용하여 경험을 향상시킵니다." cookieconsentDismiss = "동의" cookieconsentLink = "자세히 알아보기" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "공유하기" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "목차" pin = "고정" @@ -162,41 +162,41 @@ wechatpay = "위챗페이" alipay = "알리페이" paypal = "페이팔" bitcoin = "비트코인" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "페이지를 찾을 수 없습니다" [pageNotFoundText] other = "죄송합니다. 찾고 있는 페이지가 존재하지 않습니다." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "오프라인" [offlineText] other = "인터넷에 연결되어 있지 않으며, 캐시된 페이지만 사용할 수 있습니다." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "리디렉션 안내" message = "{{ .Title }}에서 벗어나려고 합니다. 계정과 자산 보안에 유의해 주세요." confirm = "계속 방문" -# === link redirection === -# === GitHub Alert === +# === Link Redirection === +# === Alert === [alert] note = "노트" tip = "팁" important = "중요" warning = "경고" caution = "주의" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "완료" " " = "미완료" @@ -207,9 +207,9 @@ x = "완료" "!" = "중요" "!x" = "중요 확인됨" "?" = "문제" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "노트" abstract = "초록" @@ -224,26 +224,26 @@ danger = "위험" bug = "버그" example = "예시" quote = "인용" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "추가됨" changed = "변경됨" deleted = "삭제됨" deprecated = "더 이상 사용되지 않음" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "확대" zoomOut = "축소" reset = "재설정" download = "SVG 다운로드" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "다이어그램" code = "코드" -# === tabs related === +# === Tabs === diff --git a/i18n/pl.toml b/i18n/pl.toml index 1706d5c2..178d5709 100644 --- a/i18n/pl.toml +++ b/i18n/pl.toml @@ -2,7 +2,7 @@ # Tłumaczenie na język polski # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Wersja Hugo jest zbyt niska.\n\nAktualna wersja Hugo to {{ .Current }}, minimalna obsługiwana wersja dla FixIt to {{ .Minimal }}.\n\nJeśli uruchamiasz Hugo na własnym komputerze, zapoznaj się z przewodnikiem aktualizacji na stronie https://gohugo.io/getting-started/installing/#upgrade-hugo.\n\nJeśli wdrażasz na platformie zewnętrznej, skonfiguruj odpowiednio wersję Hugo." hugoExtendedWarn = "Wersja Hugo Extended jest wymagana do obsługi SCSS." @@ -11,14 +11,14 @@ compatibilityError = "Błąd zgodności ({{ .From }} -> {{ .To }}):\nMasz niekom devVersionWarn = "Używasz wersji deweloperskiej FixIt. Proszę rozważyć użycie stabilnej wersji.\nZobacz https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "Obecne środowisko to \"rozwój\". \"System komentarzy\", \"PWA\", \"CDN\", \"odcisk palca\" i \"analiza\" będą wyłączone." quicklyUpgrade = "Szybka aktualizacja użyj polecenia: " -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "Powrót do góry" viewComments = "Zobacz komentarze" noscript = "Ta strona działa najlepiej z włączonym JavaScriptem." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -67,29 +67,29 @@ other = "Łącznie {{ .Count }} artykułów" more = "Więcej" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "Wybierz język" noMoretTranslations = "Brak dalszych tłumaczeń" switchTheme = "Przełącz schemat" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "Napędzany przez {{ .Hugo }} | Szablon - {{ .Theme }}" siteUV = "Całkowita odwiedzających" sitePV = "Łączna wizyt" siteRunning = "Website running ..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "en" # Valine nie obsługuje języka polskiego valinePlaceholder = "Twój komentarz ..." facebookLanguageCode = "pl" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "Szukaj" searchPlaceholder = "Wyszukaj tytuł lub treść artykułu ..." @@ -111,14 +111,14 @@ exitFullscreen = "Wyjdź z pełnego ekranu" cookieconsentMessage = "Ta strona korzysta z plików Cookies, aby poprawić komfort użytkowania." cookieconsentDismiss = "Zgodzić się" cookieconsentLink = "Ucz się więcej" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "Udostępnij na" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "Spis treści" pin = "Przypnij na górze" @@ -166,41 +166,41 @@ wechatpay = "WeChat Pay" alipay = "Alipay" paypal = "PayPal" bitcoin = "Bitcoin" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "Nie znaleziono strony" [pageNotFoundText] other = "Wybacz, chyba coś namieszaliśmy." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "Offline" [offlineText] other = "Nie masz połączenia z Internetem, dostępne będą tylko strony z pamięci podręcznej." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "Powiadomienie o przekierowaniu" message = "Za chwilę opuścisz {{ .Title }}. Zadbaj o bezpieczeństwo konta i swoich środków." confirm = "Kontynuuj odwiedzanie" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "Notatka" tip = "Wskazówka" important = "Ważne" warning = "Ostrzeżenie" caution = "Ostrożność" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "Zweryfikowane" " " = "Niezweryfikowane" @@ -211,9 +211,9 @@ x = "Zweryfikowane" "!" = "Ważne" "!x" = "Ważne Sprawdzone" "?" = "Pytanie" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "Notka" abstract = "Streszczenie" @@ -228,26 +228,26 @@ danger = "Niebezpieczeństwo" bug = "Problem" example = "Przykład" quote = "Cytat" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "Dodano" changed = "Zmieniono" deleted = "Usunięte" deprecated = "Przestarzałe" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "Powiększ" zoomOut = "Pomniejsz" reset = "Resetuj" download = "Pobierz SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "Diagram" code = "Kod" -# === tabs related === +# === Tabs === diff --git a/i18n/pt-BR.toml b/i18n/pt-BR.toml index 7b0258b5..b4884481 100644 --- a/i18n/pt-BR.toml +++ b/i18n/pt-BR.toml @@ -2,7 +2,7 @@ # Tradução para português do Brasil # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "A versão do Hugo é muito baixa.\n\nA versão atual do Hugo é {{ .Current }}, a versão mínima suportada pelo FixIt é {{ .Minimal }}.\n\nSe você está executando o Hugo em seu computador, consulte https://gohugo.io/getting-started/installing/#upgrade-hugo para obter um guia de atualização.\n\nSe você está implantando em uma plataforma de terceiros, configure a versão do Hugo adequadamente." hugoExtendedWarn = "A versão Hugo Extended é necessária para o suporte a SCSS." @@ -11,14 +11,14 @@ compatibilityError = "Erro de compatibilidade ({{ .From }} -> {{ .To }}):\nVocê devVersionWarn = "Você está usando a versão de desenvolvedor do FixIt. Por favor, use uma versão estável. \nConsulte https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "O ambiente atual é \"development\". O \"comment system\", \"PWA\", \"CDN\", \"fingerprint\" e \"analytics\" serão desativados." quicklyUpgrade = "Utilize o comando para atualizar rapidamente:" -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "Voltar ao topo" viewComments = "Ver comentários" noscript = "Este site funciona melhor com o JavaScript ativado." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -68,29 +68,29 @@ other = "{{ .Count }} artigos no total" more = "Mais" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "Selecione o idioma" noMoretTranslations = "Não há mais traduções" switchTheme = "Trocar tema" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "Criado com {{ .Hugo }} | Tema - {{ .Theme }}" siteUV = "Total de visitantes" sitePV = "Total de visitas" siteRunning = "Site no ar..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "en" # Valine não suporta português valinePlaceholder = "O seu comentário..." facebookLanguageCode = "pt_BR" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "Pesquisa" searchPlaceholder = "Pesquisar títulos ou conteúdos..." @@ -112,14 +112,14 @@ exitFullscreen = "Sair da tela cheia" cookieconsentMessage = "Este site usa Cookies para melhorar sua experiência." cookieconsentDismiss = "Aceitar" cookieconsentLink = "Saber mais" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "Compartilhar em" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "Conteúdos" pin = "Fixar no topo" @@ -167,41 +167,41 @@ wechatpay = "Pague pelo WeChat" alipay = "Alipay" paypal = "PayPal" bitcoin = "Bitcoin" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "Página não encontrada" [pageNotFoundText] other = "A página que você procura não existe. Desculpe." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "Offline" [offlineText] other = "Você não está conectado à Internet, apenas as páginas em cache estarão disponíveis." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "Aviso de redirecionamento" message = "Você está prestes a sair de {{ .Title }}. Fique atento à segurança da sua conta e dos seus bens." confirm = "Continuar visitando" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "Nota" tip = "Sugestão" important = "Importante" warning = "Aviso" caution = "Cuidado" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "Verificado" " " = "Não verificado" @@ -212,9 +212,9 @@ x = "Verificado" "!" = "Importante" "!x" = "Importante Verificado" "?" = "Dúvida" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "Nota" abstract = "Resumo" @@ -229,26 +229,26 @@ danger = "Perigo" bug = "Bug" example = "Exemplo" quote = "Citação" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "NOVO" changed = "ALTERADO" deleted = "EXCLUÍDO" deprecated = "OBSOLETO" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "Ampliar" zoomOut = "Reduzir" reset = "Redefinir" download = "Baixar SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "Diagrama" code = "Código" -# === tabs related === +# === Tabs === diff --git a/i18n/ro.toml b/i18n/ro.toml index 19bea2bb..bba864d1 100644 --- a/i18n/ro.toml +++ b/i18n/ro.toml @@ -2,7 +2,7 @@ # Traduceri pentru limba română # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Versiunea Hugo este prea veche.\n\nVersiunea curentă Hugo este {{ .Current }}, versiunea minimă compatibilă cu FixIt este {{ .Minimal }}.\n\nDacă rulați Hugo pe propriul computer, consultați https://gohugo.io/getting-started/installing/#upgrade-hugo pentru ghidul de actualizare.\n\nDacă implementați pe o platformă terță, configurați versiunea Hugo corespunzător." hugoExtendedWarn = "Versiunea Hugo Extended este necesară pentru suportul SCSS." @@ -11,14 +11,14 @@ compatibilityError = "Eroare de compatibilitate ({{ .From }} -> {{ .To }}):\nAve devVersionWarn = "Utilizați o versiune de dezvoltare a FixIt. Vă rugăm să luați în considerare utilizarea unei versiuni stabile.\nConsultați https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "Mediul curent este \"dezvoltare\". \"Sistemul de comentarii\", \"PWA\", \"CDN\", \"amprentă\" și \"analize\" vor fi dezactivate." quicklyUpgrade = "Actualizare rapidă utilizați comanda: " -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "Înapoi Sus" viewComments = "Vizualizare Comentarii" noscript = "Acest site funcționează cel mai bine cu JavaScript activat." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -67,29 +67,29 @@ other = "{{ .Count }} articole în total" more = "Mai mult" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "Selectare Limbă" noMoretTranslations = "Nu mai sunt traduceri disponibile" switchTheme = "Schimbare Temă" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "Realizat de către {{ .Hugo }} | Temă - {{ .Theme }}" siteUV = "Total vizitatori" sitePV = "Total vizite" siteRunning = "Site-ul rulează ..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "en" # Valine nu suporta romana valinePlaceholder = "Comentariul dvs ..." facebookLanguageCode = "ro_RO" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "Căutare" searchPlaceholder = "Căutarea titlului sau conținutului articolului ..." @@ -111,14 +111,14 @@ exitFullscreen = "Ieși din ecran complet" cookieconsentMessage = "Acest site web utilizează Cookies pentru a vă îmbunătăți experiența." cookieconsentDismiss = "De acord" cookieconsentLink = "Aflați mai multe" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "Distribuie pe" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "Cuprins" pin = "Fixează în partea de sus" @@ -166,41 +166,41 @@ wechatpay = "WeChat Pay" alipay = "Alipay" paypal = "PayPal" bitcoin = "Bitcoin" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "Pagina nu a fost găsită" [pageNotFoundText] other = "Pagina pe care o căutați nu există. Ne cerem scuze." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "Deconectat" [offlineText] other = "Nu sunteți conectat la Internet, vor fi disponibile doar paginile stocate în cache." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "Notificare de redirecționare" message = "Urmează să părăsiți {{ .Title }}. Vă rugăm să acordați atenție securității contului și bunurilor dvs." confirm = "Continuă vizitarea" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "Notă" tip = "Sfat" important = "Important" warning = "Avertisment" caution = "Atenție" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "Controlat" " " = "Necontrolat" @@ -211,9 +211,9 @@ x = "Controlat" "!" = "Important" "!x" = "Important Verificat" "?" = "Întrebare" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "Notă" abstract = "Rezumat" @@ -228,26 +228,26 @@ danger = "Pericol" bug = "Bug" example = "Exemplu" quote = "Citat" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "NOU" changed = "SCHIMBAT" deleted = "ȘTERS" deprecated = "DEPRECIAȚI" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "Mărește" zoomOut = "Micșorează" reset = "Resetează" download = "Descarcă SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "Diagramă" code = "Cod" -# === tabs related === +# === Tabs === diff --git a/i18n/ru.toml b/i18n/ru.toml index 98d27c1a..01e789af 100644 --- a/i18n/ru.toml +++ b/i18n/ru.toml @@ -2,7 +2,7 @@ # Переводы на русский # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Версия Hugo слишком низкая.\n\nТекущая версия Hugo — {{ .Current }}, минимальная поддерживаемая версия для FixIt — {{ .Minimal }}.\n\nЕсли вы запускаете Hugo на своём компьютере, ознакомьтесь с руководством по обновлению по адресу https://gohugo.io/getting-started/installing/#upgrade-hugo.\n\nЕсли вы развёртываете на сторонней платформе, настройте версию Hugo соответствующим образом." hugoExtendedWarn = "Для поддержки SCSS необходима версия Hugo Extended." @@ -11,14 +11,14 @@ compatibilityError = "Ошибка совместимости ({{ .From }} -> {{ devVersionWarn = "Вы используете версию разработки FixIt. Пожалуйста, рассмотрите возможность использования стабильной версии.\nСм. https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "Текущая среда - \"разработка\". \"Система комментариев\", \"PWA\", \"CDN\", \"отпечаток\" и \"аналитика\" будут отключены." quicklyUpgrade = "Быстрое обновление используйте команду: " -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "Наверх" viewComments = "Посмотреть комментарии" noscript = "Этот сайт работает лучше с включенным JavaScript." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -67,29 +67,29 @@ other = "Всего {{ .Count }} статей" more = "Больше" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "Выбор Языка" noMoretTranslations = "Нет больше переводов" switchTheme = "Сменить Тему" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "Сделано {{ .Hugo }} | Тема - {{ .Theme }}" siteUV = "Всего посетителей" sitePV = "Всего посещений" siteRunning = "Website running ..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "en" # Valine не поддерживает русский valinePlaceholder = "Ваш комментарий ..." facebookLanguageCode = "ru_RU" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "Поиск" searchPlaceholder = "Поиск заголовков или содержимого ..." @@ -111,14 +111,14 @@ exitFullscreen = "Выйти из полного экрана" cookieconsentMessage = "Этот сайт использует Cookies для улучшения вашего опыта." cookieconsentDismiss = "Согласен" cookieconsentLink = "Учить больше" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "Поделиться в" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "Содержание" pin = "Закрепить наверху" @@ -166,41 +166,41 @@ wechatpay = "" alipay = "" paypal = "" bitcoin = "" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "Страница не найдена" [pageNotFoundText] other = "Страница, которую вы ищете, не существует. Приносим извинения." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "Не в сети" [offlineText] other = "Вы не подключены к интернету, будут доступны только кешированные страницы." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "Уведомление о перенаправлении" message = "Вы собираетесь покинуть {{ .Title }}. Пожалуйста, обратите внимание на безопасность вашего аккаунта и имущества." confirm = "Продолжить посещение" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "Примечание" tip = "Совет" important = "Важно" warning = "Предупреждение" caution = "Осторожно" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "Проверено" " " = "Не проверено" @@ -211,9 +211,9 @@ x = "Проверено" "!" = "Важно" "!x" = "Важно Проверено" "?" = "Вопрос" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "Замечание" abstract = "Краткое описание" @@ -228,26 +228,26 @@ danger = "Опасность" bug = "Ошибка" example = "Пример" quote = "Цитата" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "НОВЫЙ" changed = "ИЗМЕНЕН" deleted = "УДАЛЕН" deprecated = "УСТАРЕЛ" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "Увеличить" zoomOut = "Уменьшить" reset = "Сбросить" download = "Скачать SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "Диаграмма" code = "Код" -# === tabs related === +# === Tabs === diff --git a/i18n/sr.toml b/i18n/sr.toml index ac6427e8..b8fdf2de 100644 --- a/i18n/sr.toml +++ b/i18n/sr.toml @@ -2,7 +2,7 @@ # Превод на Српски # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Верзија Hugo-а је прениска.\n\nТренутна верзија Hugo-а је {{ .Current }}, а минимална подржана верзија за FixIt је {{ .Minimal }}.\n\nАко покрећете Hugo на свом рачунару, погледајте https://gohugo.io/getting-started/installing/#upgrade-hugo за водич за надоградњу.\n\nАко постављате на платформу треће стране, конфигуришите верзију Hugo-а у складу с тим." hugoExtendedWarn = "Верзија Hugo Extended је неопходна за подршку SCSS-а." @@ -11,14 +11,14 @@ compatibilityError = "Грешка у компатибилности ({{ .From } devVersionWarn = "Користите развојну верзију FixIt. Размотрите коришћење стабилне верзије.\nПогледајте https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "Тренутно окружење је \"развојно\". \"Систем коментара\", \"PWA\", \"CDN\", \"отисак прста\" и \"аналитика\" ће бити онемогућени." quicklyUpgrade = "Брзо ажурирање користите команду: " -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "Назад на Врх" viewComments = "Погледај Коментаре" noscript = "Овај сајт најбоље ради са омогућеним JavaScript-ом." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -67,29 +67,29 @@ other = "{{ .Count }} чланака укупно" more = "Више" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "Изабери Језик" noMoretTranslations = "Нема више превода" switchTheme = "Промени Тему" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "Покреће {{ .Hugo }} | Тема - {{ .Theme }}" siteUV = "Укупно посетилаца" sitePV = "Укупно посета" siteRunning = "Website running ..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "sr" # Valine не подржава српски valinePlaceholder = "Ваш коментар ..." facebookLanguageCode = "sr_RS" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "Претрага" searchPlaceholder = "Претражи наслове или садржај..." @@ -111,14 +111,14 @@ exitFullscreen = "Изађи из целог екрана" cookieconsentMessage = "Ова веб локација користи Cookies да би побољшала ваше искуство." cookieconsentDismiss = "Договорити се" cookieconsentLink = "Сазнајте више" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "Подели на" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "Садржаји" pin = "Закачи" @@ -166,41 +166,41 @@ wechatpay = "WeChat Pay" alipay = "Alipay" paypal = "PayPal" bitcoin = "Bitcoin" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "Страница није пронађена" [pageNotFoundText] other = "Страница коју тражите не постоји. Жао нам је." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "Оффлине" [offlineText] other = "Нисте повезани на Интернет, биће доступне само кеширане странице." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "Обавештење о преусмеравању" message = "Управо ћете напустити {{ .Title }}. Обратите пажњу на безбедност свог налога и имовине." confirm = "Настави посету" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "Напомена" tip = "Савет" important = "Важно" warning = "Упозорење" caution = "Опрез" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "Проверено" " " = "Непроверено" @@ -211,9 +211,9 @@ x = "Проверено" "!" = "Важно" "!x" = "Важно Проверено" "?" = "Питање" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "Напомена" abstract = "Сажетак" @@ -228,26 +228,26 @@ danger = "Опасност" bug = "Грешка" example = "Пример" quote = "Цитат" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "НОВО" changed = "ПРОМЕЊЕНО" deleted = "ОБРИСАНО" deprecated = "ЗАСТАРЕЛО" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "Увећај" zoomOut = "Умањи" reset = "Ресетуј" download = "Преузми SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "Дијаграм" code = "Код" -# === tabs related === +# === Tabs === diff --git a/i18n/vi.toml b/i18n/vi.toml index 2a19accc..38a57fa9 100644 --- a/i18n/vi.toml +++ b/i18n/vi.toml @@ -1,7 +1,7 @@ # Translations for Vietnamese # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Phiên bản Hugo quá thấp.\n\nPhiên bản Hugo hiện tại là {{ .Current }}, phiên bản tối thiểu được hỗ trợ cho FixIt là {{ .Minimal }}.\n\nNếu bạn đang chạy Hugo trên máy tính của mình, hãy xem https://gohugo.io/getting-started/installing/#upgrade-hugo để biết hướng dẫn nâng cấp.\n\nNếu bạn đang triển khai trên nền tảng của bên thứ ba, vui lòng cấu hình phiên bản Hugo cho phù hợp." hugoExtendedWarn = "Phiên bản Hugo Extended là cần thiết để hỗ trợ SCSS." @@ -10,14 +10,14 @@ compatibilityError = "Lỗi tương thích ({{ .From }} -> {{ .To }}):\nBạn c devVersionWarn = "Bạn đang sử dụng một phiên bản phát triển của FixIt. Vui lòng xem xét sử dụng một phiên bản ổn định.\nXem https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "Môi trường hiện tại là \"development\". \"Hệ thống bình luận\", \"PWA\", \"CDN\", \"vân tay\" và \"thống kê\" sẽ bị vô hiệu hóa." quicklyUpgrade = "Nâng cấp nhanh bằng lệnh: " -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "Lên trên" viewComments = "Xem bình luận" noscript = "Trang web này hoạt động tốt nhất khi JavaScript được kích hoạt." -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -66,29 +66,29 @@ other = "{{ .Count }} bài viết" more = "Thêm" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "Chọn Ngôn ngữ" noMoretTranslations = "Không có nữa" switchTheme = "Đổi chủ đề" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "Cung cấp bởi {{ .Hugo }} | Chủ đề - {{ .Theme }}" siteUV = "Tổng khách truy cập" sitePV = "Tổng lượt truy cập" siteRunning = "Trang web đang chạy ..." -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "en" # Valine không hỗ trợ tiếng Việt valinePlaceholder = "Bình luận của bạn ..." facebookLanguageCode = "vi" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "Tìm kiếm" searchPlaceholder = "Tìm tiêu đề hoặc nội dung..." @@ -110,14 +110,14 @@ exitFullscreen = "Thoát toàn màn hình" cookieconsentMessage = "Trang web này sử dụng Cookies để cải thiện trải nghiệm của bạn." cookieconsentDismiss = "Đã hiểu!" cookieconsentLink = "Tìm hiểu thêm" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "Chia sẻ trên" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "Nội dung" pin = "Ghim ở đầu trang" @@ -165,41 +165,41 @@ wechatpay = "WeChat Pay" alipay = "Alipay" paypal = "PayPal" bitcoin = "Bitcoin" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "Không tìm thấy trang" [pageNotFoundText] other = "Trang bạn đang tìm kiếm không tồn tại. Xin lỗi." -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "ngoại tuyến" [offlineText] other = "Bạn chưa kết nối với Internet, chỉ các trang được lưu trong bộ nhớ cache sẽ khả dụng." -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "Thông báo chuyển hướng" message = "Bạn sắp rời khỏi {{ .Title }}, vui lòng chú ý an toàn cho tài khoản và tài sản của mình." confirm = "Tiếp tục truy cập" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "Ghi chú" tip = "Mẹo" important = "Quan trọng" warning = "Cảnh báo" caution = "Chú ý" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "Đã kiểm tra" " " = "Chưa kiểm tra" @@ -210,9 +210,9 @@ x = "Đã kiểm tra" "!" = "Quan trọng" "!x" = "Quan trọng Đã kiểm tra" "?" = "Câu hỏi" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "Ghi chú" abstract = "Tóm tắt" @@ -227,26 +227,26 @@ danger = "Nguy hiểm" bug = "Lỗi" example = "Ví dụ" quote = "Trích dẫn" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "MỚI" changed = "THAY ĐỔI" deleted = "XOÁ" deprecated = "KHÔNG CÒN SỬ DỤNG" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "Phóng to" zoomOut = "Thu nhỏ" reset = "Đặt lại" download = "Tải SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "Sơ đồ" code = "Mã" -# === tabs related === +# === Tabs === diff --git a/i18n/zh-CN.toml b/i18n/zh-CN.toml index 26861632..188b0bac 100644 --- a/i18n/zh-CN.toml +++ b/i18n/zh-CN.toml @@ -2,7 +2,7 @@ # 简体中文的翻译 # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Hugo 版本过低。\n\n目前使用的 Hugo 版本为 {{ .Current }},FixIt 支持的最低的 Hugo 版本为 {{ .Minimal }}。\n\n如果你正在自己的计算机上运行 Hugo,请访问 https://gohugo.io/getting-started/installing/#upgrade-hugo 以查阅升级指南。\n\n如果你正在第三方平台上部署,请按照相应文档配置 Hugo 版本。" hugoExtendedWarn = "需要使用 Hugo Extended 版本来获得 SCSS 支持。" @@ -11,14 +11,14 @@ compatibilityError = "兼容性错误 ({{ .From }} -> {{ .To }}):\n你进行了 devVersionWarn = "你正在使用开发版的 FixIt,请考虑使用稳定版。\n见 https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "当前运行环境是“development”。“评论系统”、“PWA”、“CDN”、“fingerprint”和“统计”不会启用。" quicklyUpgrade = "使用命令快速升级:" -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "回到顶部" viewComments = "查看评论" noscript = "该网站在启用 JavaScript 的情况下效果最佳。" -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -65,29 +65,29 @@ other = "共计 {{ .Count }} 篇文章" more = "更多" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "选择语言" noMoretTranslations = "没有更多翻译" switchTheme = "切换主题" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "由 {{ .Hugo }} 强力驱动 | 主题 - {{ .Theme }}" siteUV = "总访客数" sitePV = "总访问量" siteRunning = "网站运行中……" -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "zh-cn" valinePlaceholder = "你的评论……" facebookLanguageCode = "zh_CN" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "搜索" searchPlaceholder = "搜索文章标题或内容……" @@ -109,14 +109,14 @@ exitFullscreen = "退出全屏" cookieconsentMessage = "本网站使用 Cookies 来改善你的浏览体验。" cookieconsentDismiss = "同意" cookieconsentLink = "了解更多" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "分享到" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "目录" pin = "置顶" @@ -162,41 +162,41 @@ wechatpay = "微信" alipay = "支付宝" paypal = "贝宝" bitcoin = "比特币" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "页面没找到" [pageNotFoundText] other = "抱歉,你要查找的页面不存在。" -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "离线" [offlineText] other = "你没有连接到 Internet,只有缓存的页面可用。" -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "跳转提示" message = "即将离开{{ .Title }},请注意账号财产安全。" confirm = "继续访问" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "注意" tip = "提示" important = "重要" warning = "警告" caution = "小心" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "已完成" " " = "未完成" @@ -207,9 +207,9 @@ x = "已完成" "!" = "重要" "!x" = "重要已核验" "?" = "问题" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "注意" abstract = "摘要" @@ -224,26 +224,26 @@ danger = "危险" bug = "Bug" example = "示例" quote = "引用" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "新增" changed = "更改" deleted = "删除" deprecated = "弃用" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "放大" zoomOut = "缩小" reset = "重置" download = "下载 SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "图表" code = "代码" -# === tabs related === +# === Tabs === diff --git a/i18n/zh-TW.toml b/i18n/zh-TW.toml index 6298431d..35494c16 100644 --- a/i18n/zh-TW.toml +++ b/i18n/zh-TW.toml @@ -2,7 +2,7 @@ # 繁體中文的翻譯 # https://gohugo.io/content-management/multilingual/#translation-of-strings -# === init === +# === Init === [init] hugoVersionError = "Hugo 版本過低。\n\n目前使用的 Hugo 版本為 {{ .Current }},FixIt 支持的最老的 Hugo 版本為 {{ .Minimal }}。\n\n如果你正在自己的計算機上運行 Hugo,請訪問 https://gohugo.io/getting-started/installing/#upgrade-hugo 以查閱升級指南。\n\n如果你正在第三方平台上部署,請按照相應文檔配置 Hugo 版本。" hugoExtendedWarn = "需要使用 Hugo Extended 版本來獲得 SCSS 支持。" @@ -11,14 +11,14 @@ compatibilityError = "兼容性錯誤 ({{ .From }} -> {{ .To }}):\n你進行了 devVersionWarn = "你正在使用開發版的 FixIt,請考慮使用穩定版。\n見 https://github.com/hugo-fixit/FixIt/releases" devEnvWarn = "當前運行環境是“development”。“評論系統”、“PWA”、“CDN”、“fingerprint”和“統計”不會啟用。" quicklyUpgrade = "使用命令快速升級:" -# === init === +# === Init === -# === baseof === +# === Base Layout === [baseof] backToTop = "回到頂部" viewComments = "查看評論" noscript = "該網站在啟用 JavaScript 的情況下效果最佳。" -# === baseof === +# === Base Layout === # === Taxonomy === [archives] @@ -65,29 +65,29 @@ other = "共計 {{ .Count }} 篇文章" more = "更多" # === Pagination === -# === partials/header.html === +# === Header === [header] selectLanguage = "選擇語言" noMoretTranslations = "沒有更多翻譯" switchTheme = "切換主題" -# === partials/header.html === +# === Header === -# === partials/footer.html === +# === Footer === [footer] poweredBySome = "由 {{ .Hugo }} 強力驅動 | 主題 - {{ .Theme }}" siteUV = "總訪客數" sitePV = "總訪問量" siteRunning = "網站運行中……" -# === partials/footer.html === +# === Footer === -# === partials/comment.html === +# === Comment === [comment] valineLang = "zh-TW" valinePlaceholder = "你的評論……" facebookLanguageCode = "zh-TW" -# === partials/comment.html === +# === Comment === -# === partials/assets.html === +# === Assets === [assets] search = "搜尋" searchPlaceholder = "搜尋文章標題或內容……" @@ -109,14 +109,14 @@ exitFullscreen = "退出全螢幕" cookieconsentMessage = "本網站使用 Cookies 來改善你的流覽體驗。" cookieconsentDismiss = "同意" cookieconsentLink = "瞭解更多" -# === partials/assets.html === +# === Assets === -# === partials/plugin/share.html === +# === Share === [shareOn] other = "分享到" -# === partials/plugin/share.html === +# === Share === -# === posts/single.html === +# === Single Post === [single] contents = "目錄" pin = "置頂" @@ -162,41 +162,41 @@ wechatpay = "微信" alipay = "支付寶" paypal = "PayPal" bitcoin = "比特幣" -# === posts/single.html === +# === Single Post === -# === 404.html === +# === Error Pages === [pageNotFound] other = "找不到網頁" [pageNotFoundText] other = "抱歉,你要查找的頁面不存在。" -# === 404.html === +# === Error Pages === -# === offline === +# === Offline === [offlineTitle] other = "離線" [offlineText] other = "你沒有連接到 Internet,只有緩存的頁面可用。" -# === offline === +# === Offline === -# === link redirection === +# === Link Redirection === [linkRedirection] title = "跳轉提示" message = "即將離開{{ .Title }},請注意帳號財產安全。" confirm = "繼續訪問" -# === link redirection === +# === Link Redirection === -# === GitHub Alert === +# === Alert === [alert] note = "注意" tip = "提示" important = "重要" warning = "警告" caution = "小心" -# === GitHub Alert === +# === Alert === -# === Task lists === +# === Task List === [taskList] x = "已完成" " " = "未完成" @@ -207,9 +207,9 @@ x = "已完成" "!" = "重要" "!x" = "重要已核驗" "?" = "問題" -# === Task lists === +# === Task List === -# === shortcodes/admonition.html === +# === Admonition === [admonition] note = "注意" abstract = "摘要" @@ -224,26 +224,26 @@ danger = "危險" bug = "Bug" example = "範例" quote = "引用" -# === shortcodes/admonition.html === +# === Admonition === -# === shortcodes/version.html === +# === Version === [version] new = "新增" changed = "更改" deleted = "刪除" deprecated = "棄用" -# === shortcodes/version.html === +# === Version === -# === diagram actions === +# === Diagram === [diagram] zoomIn = "放大" zoomOut = "縮小" reset = "重置" download = "下載 SVG" -# === diagram actions === +# === Diagram === -# === tabs related === +# === Tabs === [tabs] diagram = "圖表" code = "程式碼" -# === tabs related === +# === Tabs === diff --git a/layouts/_partials/base/assets.html b/layouts/_partials/base/assets.html index a4c52e6e..7f0fb011 100644 --- a/layouts/_partials/base/assets.html +++ b/layouts/_partials/base/assets.html @@ -1,15 +1,16 @@ {{- /* -FixIt theme assets partial -=============================================== -- Third-party libraries and plugins -- Theme assets and plugins - - Config script - - MathJax plugin - - Mermaid plugin - - Theme script - - Custom Assets block - - Custom script -=============================================== + Assets Partial - Asset orchestration for page scripts and styles + + This partial collects feature flags and page/site params, then registers + required CSS/JS resources through store/style.html and store/script.html. + + Key responsibilities: + - Resolve optional third-party libraries and plugin integrations + - Build per-page runtime config and emit window.config + - Build and register core bundles (file-tree, mermaid, main, custom) + - Append page-injected style/script arrays and analytics hooks + + Called from: layouts/baseof.html */ -}} {{- $noop := .WordCount -}} @@ -82,6 +83,7 @@ FixIt theme assets partial {{- if .Store.Get "hasJsonViewer" | and (not $isArchivesOrOffline) -}} {{- $source := $cdn.jsonViewerElementJS | default "lib/json-viewer-element/json-viewer-element.umd.js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} + {{- dict "Source" (resources.Get "js/lib/json-viewer.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} {{- /* twemoji */ -}} @@ -89,6 +91,7 @@ FixIt theme assets partial {{- $source := $cdn.twemojiJS | default "lib/twemoji/twemoji.min.js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $config = dict "twemoji" true | merge $config -}} + {{- dict "Source" (resources.Get "js/lib/twemoji.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} {{- /* lightgallery */ -}} @@ -102,6 +105,7 @@ FixIt theme assets partial {{- $source := $cdn.lightgalleryZoomJS | default "lib/lightgallery/plugins/zoom/lg-zoom.min.js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $config = dict "lightgallery" true | merge $config -}} + {{- dict "Source" (resources.Get "js/lib/lightgallery.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} {{- /* Sharer.js */ -}} @@ -116,6 +120,7 @@ FixIt theme assets partial {{- $source := $cdn.typeitJS | default "lib/typeit/index.umd.js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $config = dict "speed" $typeit.speed "cursorSpeed" $typeit.cursorSpeed "cursorChar" $typeit.cursorChar "duration" $typeit.duration "loop" $typeit.loop | dict "typeit" | merge $config -}} + {{- dict "Source" (resources.Get "js/lib/typeit.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} {{- /* KaTeX */ -}} @@ -132,6 +137,7 @@ FixIt theme assets partial {{- /* MathJax */ -}} {{- if .Store.Get "hasMathJax" | and (not $isArchivesOrOffline) -}} + {{- dict "Source" (resources.Get "js/lib/mathjax.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $mathjax := $math.mathjax -}} {{- $mjoc := $mathjax.options | default dict -}} {{- $mjo := dict -}} @@ -166,34 +172,32 @@ FixIt theme assets partial {{- $lightTheme := resources.Get "lib/echarts/theme/light.yml" | transform.Unmarshal -}} {{- $darkTheme := resources.Get "lib/echarts/theme/dark.yml" | transform.Unmarshal -}} {{- $config = dict "lightTheme" $lightTheme "darkTheme" $darkTheme | dict "echarts" | merge $config -}} + {{- dict "Source" (resources.Get "js/lib/echarts.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} {{- /* Mapbox GL */ -}} {{- if .Store.Get "hasMapbox" -}} {{- $source := $cdn.mapboxGLCSS | default "lib/mapbox-gl/mapbox-gl.css" -}} - {{- dict "Source" $source "Minify" true "Fingerprint" $fingerprint "Preload" true | dict "Page" . "Data" | partial "store/style.html" -}} + {{- dict "Source" $source "Minify" hugo.IsProduction "Fingerprint" $fingerprint "Preload" true | dict "Page" . "Data" | partial "store/style.html" -}} {{- $source = $cdn.mapboxGLJS | default "lib/mapbox-gl/mapbox-gl.js" -}} - {{- dict "Source" $source "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} - {{- dict "Source" "lib/mapbox-gl/mapbox-gl-language.js" "Minify" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} + {{- dict "Source" $source "Minify" hugo.IsProduction "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} + {{- dict "Source" "lib/mapbox-gl/mapbox-gl-language.js" "Minify" hugo.IsProduction "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $config = dict "accessToken" $params.mapbox.accessToken "RTLTextPlugin" "https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.2.0/mapbox-gl-rtl-text.js" | dict "mapbox" | merge $config -}} + {{- dict "Source" (resources.Get "js/lib/mapbox.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} -{{- /* Music */ -}} +{{- /* Music (APlayer / MetingJS) */ -}} {{- if (.Store.Get "hasAplayer") | or (.Store.Get "hasMusic") -}} {{- /* APlayer */ -}} {{- $source := $cdn.aplayerCSS | default "lib/aplayer/APlayer.min.css" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Preload" true | dict "Page" . "Data" | partial "store/style.html" -}} - {{- $options := dict "targetPath" "lib/aplayer/dark.min.css" "enableSourceMap" true -}} + {{- $options := dict "targetPath" "lib/aplayer/dark.min.css" -}} {{- dict "Source" "lib/aplayer/dark.scss" "ToCSS" $options "Fingerprint" $fingerprint | dict "Page" . "Data" | partial "store/style.html" -}} {{- $source := $cdn.aplayerJS | default "lib/aplayer/APlayer.min.js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- if .Store.Get "hasAplayer" -}} - {{- $options := dict "targetPath" "js/lib/aplayer.min.js" "minify" hugo.IsProduction -}} - {{- if not hugo.IsProduction -}} - {{- $options = dict "sourceMap" "external" | merge $options -}} - {{- end -}} - {{- dict "Source" (resources.Get "js/lib/aplayer.js") "Build" $options "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} + {{- dict "Source" (resources.Get "js/lib/aplayer.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} {{- if .Store.Get "hasMusic" -}} {{- /* MetingJS */ -}} @@ -202,7 +206,7 @@ FixIt theme assets partial {{- end -}} {{- end -}} -{{- /* Tabs and tab */ -}} +{{- /* Tabs */ -}} {{- if .Store.Get "hasTabs" | and (not $isArchivesOrOffline) -}} {{- $source := $cdn.tabContainerElementJS | default "lib/tab-container-element/bundle.min.js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Attr" `type="module"` "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} @@ -218,6 +222,7 @@ FixIt theme assets partial {{- $cookieconsentConfig = .Site.Params.cookieconsent | merge $cookieconsentConfig -}} {{- $cookieconsentConfig = dict "message" ($cookieconsentConfig.content.message | default (T "assets.cookieconsentMessage")) "dismiss" ($cookieconsentConfig.content.dismiss | default (T "assets.cookieconsentDismiss")) "link" ($cookieconsentConfig.content.link | default (T "assets.cookieconsentLink")) | dict "content" | merge $cookieconsentConfig -}} {{- $config = $cookieconsentConfig | dict "cookieconsent" | merge $config -}} + {{- dict "Source" (resources.Get "js/lib/cookieconsent.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} {{- /* PanguJS */ -}} @@ -225,11 +230,12 @@ FixIt theme assets partial {{- $source := $cdn.panguJS | default "lib/pangu/pangu.umd.js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $config = dict "pangu" .Site.Params.pangu | merge $config -}} + {{- dict "Source" (resources.Get "js/lib/pangu.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} {{- /* Cell Tooltip */ -}} {{- if eq .Site.Params.tooltip true -}} - {{- /* [todo] 临时可行性验证,需要寻找一个更稳定的替代品(Floating UI) */ -}} + {{- /* [TODO] Temporary feasibility check; replace with a more stable alternative (Floating UI). */ -}} {{- $source := $cdn.cellTooltipJS | default "lib/cell-tooltip/cell-tooltip.umd.js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $config = dict "tooltip" true | merge $config -}} @@ -238,7 +244,7 @@ FixIt theme assets partial {{- /* Watermark */ -}} {{- if eq .Site.Params.watermark.enable true -}} {{- $source := $cdn.cellWatermarkJS | default "lib/cell-watermark/watermark.js" -}} - {{- dict "Source" $source "Minify" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} + {{- dict "Source" $source "Minify" hugo.IsProduction "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $watermarkConfig := .Site.Params.watermark | default dict -}} {{- $watermarkConfig = dict "appendTo" ".widgets" @@ -250,6 +256,7 @@ FixIt theme assets partial | merge $watermarkConfig -}} {{- $config = dict "watermark" $watermarkConfig | merge $config -}} + {{- dict "Source" (resources.Get "js/lib/watermark.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} {{- /* Content Decryption */ -}} @@ -259,20 +266,16 @@ FixIt theme assets partial {{- $cryptoEncBase64JS := $cdn.cryptoEncBase64JS | default "lib/crypto-js/enc-base64.js" -}} {{- $cryptoSha256JS := $cdn.cryptoSha256JS | default "lib/crypto-js/sha256.js" -}} {{- $xxhashWasmJS := $cdn.xxhashWasmJS | default "lib/xxhash-wasm/xxhash-wasm.js" -}} - {{- dict "Source" $cryptoCoreJS "Minify" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} - {{- dict "Source" $cryptoEncBase64JS "Minify" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} - {{- dict "Source" $cryptoSha256JS "Minify" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} - {{- dict "Source" $xxhashWasmJS "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} + {{- dict "Source" $cryptoCoreJS "Minify" hugo.IsProduction "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} + {{- dict "Source" $cryptoEncBase64JS "Minify" hugo.IsProduction "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} + {{- dict "Source" $cryptoSha256JS "Minify" hugo.IsProduction "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} + {{- dict "Source" $xxhashWasmJS "Minify" hugo.IsProduction "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- /* Decryption script */ -}} - {{- $options := dict "targetPath" "js/fixit-decryptor.min.js" "minify" hugo.IsProduction -}} - {{- if not hugo.IsProduction -}} - {{- $options = dict "sourceMap" "external" | merge $options -}} - {{- end -}} - {{- dict "Source" (resources.Get "js/fixit-decryptor.js") "Build" $options "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} + {{- dict "Source" (resources.Get "js/lib/fixit-decryptor.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $config = dict "all" (isset $params "password") "shortcode" ($encryptPartial | default false) | dict "encryption" | merge $config -}} {{- end -}} -{{- /* 不蒜子 */ -}} +{{- /* Busuanzi visitor counter */ -}} {{- if .Site.Params.busuanzi.enable | and hugo.IsProduction -}} {{- $source := .Site.Params.busuanzi.source | default "https://vercount.one/js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Async" true "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} @@ -288,10 +291,12 @@ FixIt theme assets partial {{- /* PWA */ -}} {{- if not hugo.IsServer | and .Site.Params.enablePWA | and hugo.IsProduction -}} - {{- $options := dict "targetPath" "service-worker.min.js" "minify" true -}} - {{- $source := resources.Get "js/service-worker.js" | js.Build $options -}} - {{- $_ := $source.RelPermalink -}} - {{- $config = dict "enablePWA" .Site.Params.enablePWA | merge $config -}} + {{- $serviceWorker := dict "Resource" (resources.Get "js/service-worker.js") "Build" true | partial "function/js-build.html" -}} + {{- with $fingerprint -}} + {{- $serviceWorker = $serviceWorker | fingerprint . -}} + {{- end -}} + {{- $serviceWorkerURL := $serviceWorker.RelPermalink -}} + {{- $config = dict "PWA" (dict "enable" .Site.Params.enablePWA "serviceWorkerURL" $serviceWorkerURL) | merge $config -}} {{- end -}} {{- /* Auto Bookmark */ -}} @@ -335,7 +340,7 @@ FixIt theme assets partial {{- dict "Source" . "Fingerprint" $fingerprint "Defer" true | dict "Page" $ "Data" | partial "store/script.html" -}} {{- end -}} -{{- /* Config scripts */ -}} +{{- /* Runtime config script (window.config) */ -}} {{- $configJS := $config | jsonify | printf "window.config=%s;" -}} {{- if hugo.IsServer -}} {{- $configJS = add $configJS "console.log('Page config:', window.config);" -}} @@ -344,20 +349,10 @@ FixIt theme assets partial {{- if $uniqueFileId | and (not .IsHome) -}} {{- $targetPath = printf "%s/js/config/%s.js" $languagePrefix $uniqueFileId -}} {{- end -}} -{{- dict "Content" $configJS "Path" $targetPath "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} +{{- dict "Content" $configJS "Path" $targetPath "Minify" hugo.IsProduction "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} -{{- /* MathJax plugin */ -}} -{{- if .Store.Get "hasMathJax" | and (not $isArchivesOrOffline) -}} - {{- $targetPath := "" -}} - {{- if $uniqueFileId | and (not .IsHome) -}} - {{- $targetPath = printf "%s/js/lib/mathjax/%s.min.js" $languagePrefix $uniqueFileId -}} - {{- end -}} - {{- $options := dict "targetPath" $targetPath "minify" hugo.IsProduction -}} - {{- if not hugo.IsProduction -}} - {{- $options = dict "sourceMap" "external" | merge $options -}} - {{- end -}} - {{- dict "Source" (resources.Get "js/lib/mathjax.js") "Build" $options "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} -{{- end -}} +{{- /* File-tree plugin */ -}} +{{- dict "Source" (resources.Get "js/lib/file-tree.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- /* Mermaid plugin */ -}} {{- if .Store.Get "hasMermaid" | and (not $isArchivesOrOffline) -}} @@ -365,28 +360,40 @@ FixIt theme assets partial {{- $source := $cdn.panzoomJS | default "lib/panzoom/panzoom.min.js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Defer" true | dict "Page" $ "Data" | partial "store/script.html" -}} {{- end -}} - {{- $options := dict "Source" "js/lib/mermaid.js" "Template" "js/lib/mermaid.js" -}} - {{- $options = dict "Context" . "Minify" hugo.IsProduction "Fingerprint" $fingerprint "Attr" `type="module"` | merge $options -}} - {{- $options | dict "Page" $ "Data" | partial "store/script.html" -}} + + {{- $mermaid := .Site.Params.mermaid -}} + {{- $mermaidCDN := $mermaid.cdn | default "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs" -}} + {{- $zenumlCDN := $mermaid.zenuml | default "" -}} + {{- $layoutLoaders := $mermaid.layoutloaders | default slice -}} + + {{- $options := dict "format" "esm" -}} + {{- $mermaidModule := dict "Resource" (resources.Get "js/lib/mermaid.ts") "Build" $options | partial "function/js-build.html" -}} + {{- with $fingerprint -}} + {{- $mermaidModule = $mermaidModule | fingerprint . -}} + {{- end -}} + {{- $mermaidModuleURL := $mermaidModule.RelPermalink -}} + + {{- $bootstrapJS := dict + "MermaidModuleURL" $mermaidModuleURL + "MermaidCDN" $mermaidCDN + "ZenumlCDN" $zenumlCDN + "LayoutLoaders" $layoutLoaders + "Mermaid" $mermaid + | partial "plugin/mermaid-bootstrap.html" + | htmlUnescape + -}} + {{- dict "Content" $bootstrapJS "Path" "js/lib/mermaid-bootstrap.js" "Minify" hugo.IsProduction "Attr" `type="module"` "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- end -}} -{{- /* Theme script */ -}} -{{- $options := dict "targetPath" "js/theme.min.js" "minify" hugo.IsProduction -}} -{{- if not hugo.IsProduction -}} - {{- $options = dict "sourceMap" "external" | merge $options -}} -{{- end -}} -{{- dict "Source" (resources.Get "js/theme.js") "Build" $options "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} +{{- /* Theme main bundle */ -}} +{{- dict "Source" (resources.Get "js/main.ts") "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} -{{- /* Custom Assets */ -}} +{{- /* Custom assets block */ -}} {{- block "custom-assets" . }}{{ end -}} -{{- /* Custom script */ -}} -{{- with resources.Get "js/custom.js" -}} - {{- $options := dict "targetPath" "js/custom.min.js" "minify" hugo.IsProduction -}} - {{- if not hugo.IsProduction -}} - {{- $options = dict "sourceMap" "external" | merge $options -}} - {{- end -}} - {{- dict "Source" . "Build" $options "Fingerprint" $fingerprint "Defer" true | dict "Page" $ "Data" | partial "store/script.html" -}} +{{- /* Custom script (custom.ts takes priority over custom.js) */ -}} +{{- with resources.Get "js/custom.ts" | default (resources.Get "js/custom.js") -}} + {{- dict "Source" . "Build" true "Fingerprint" $fingerprint "Defer" true | dict "Page" $ "Data" | partial "store/script.html" -}} {{- end -}} {{- with .Store.Get "styleArr" -}} @@ -399,7 +406,7 @@ FixIt theme assets partial {{- with .Store.Get "scriptArr" -}} {{- $content := delimit . "\n" -}} {{- $targetPath := printf "%s/js/pages/%s.js" $languagePrefix $uniqueFileId -}} - {{- dict "Content" $content "Path" $targetPath "Defer" true | dict "Page" $ "Data" | partial "store/script.html" -}} + {{- dict "Content" $content "Path" $targetPath "Minify" hugo.IsProduction "Defer" true | dict "Page" $ "Data" | partial "store/script.html" -}} {{- end -}} {{- range (.Store.Get "this").style -}} diff --git a/layouts/_partials/base/comment.html b/layouts/_partials/base/comment.html index 9ac30b52..55820fbe 100644 --- a/layouts/_partials/base/comment.html +++ b/layouts/_partials/base/comment.html @@ -19,6 +19,8 @@ {{- dict "Source" $source "Minify" true "Fingerprint" $fingerprint | dict "Page" . "Data" | partial "store/style.html" -}} {{- $source := $cdn.artalkJS | default (add $artalk.server "/dist/Artalk.js") -}} {{- dict "Source" $source "Fingerprint" $fingerprint | dict "Page" . "Data" | partial "store/script.html" -}} + {{- $options := dict "targetPath" "js/lib/artalk.js" -}} + {{- dict "Source" (resources.Get "js/lib/artalk.ts") "Build" $options "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $commentConfig = dict "el" "#artalk" "pageKey" .Permalink "pageTitle" .Title "pvEl" "artalk-visitor-count" "countEl" "artalk-comment-count" | dict "artalk" | merge $commentConfig -}} {{- if (eq $artalk.locale "") | and (eq $.Site.Language.Locale "en") -}} {{- $artalk = dict "locale" "en-US" | merge $artalk -}} @@ -107,6 +109,8 @@ {{- dict "Source" $source "Minify" true "Fingerprint" $fingerprint | dict "Page" . "Data" | partial "store/style.html" -}} {{- $source := $cdn.gitalkJS | default "lib/gitalk/gitalk.min.js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint | dict "Page" . "Data" | partial "store/script.html" -}} + {{- $options := dict "targetPath" "js/lib/gitalk.js" -}} + {{- dict "Source" (resources.Get "js/lib/gitalk.ts") "Build" $options "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $commentConfig = dict "id" .Date "title" .Title "clientID" $gitalk.clientId "clientSecret" $gitalk.clientSecret "repo" $gitalk.repo "owner" $gitalk.owner "admin" (slice $gitalk.owner) | dict "gitalk" | merge $commentConfig -}}