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 -}}