Files
FixIt/assets/js/modules/content.ts
T
Cell 17d7bc1781 refactor(assets): SCSS variables with hugo:vars and module setup standardization (#788)
* feat(assets): refactor SCSS variables with Hugo config via hugo:vars

Use Hugo's hugo:vars mechanism (v0.161.0+) to configure SCSS variables
via [params.appearance] in hugo.toml, replacing the override.scss file.

- Add [params.appearance] config section in hugo.toml
- Create scss-vars.html partial for SCSS default values
- Pass vars to toCSS via head/index.html
- Rewrite _variables.scss to use @use "hugo:vars" as h with !default
- Update all 20 consumer files from @use "override" to @use "variables"
- Delete override.scss
- Add admonition mixin in core/mixins/_admonition.scss
- Update deprecation detection to use fileExists

* refactor(assets): standardize module setup() and simplify main.ts init

- Add setup() method to all modules (menu, theme, search, enc, misc, events)
- Rename initMenu to setup, initMenuDesktop/initMenuMobile to initDesktop/initMobile
- Rename initSearch, initFixItDecryptor to setup
- Standardize setup() position at end of each module class and interface
- Simplify main.ts init sequence to uniform setup() calls

* refactor(assets): replace config.template.scss with hugo:vars/internal

- Delete config.template.scss and its ExecuteAsTemplate pipeline
- Pass internal config (base URL, logo, loading) via hugo:vars/internal namespace
- Move CSS custom properties to _root.scss using set-fi-vars mixin
- Simplify _variables.scss by removing config namespace dependency
- Update CLAUDE.md to reflect new SCSS architecture

* style(i18n): fix spacing in Korean translation strings

* fix(assets): resolve dead SCSS config keys and deduplicate defaults

- Wire up 5 config keys with v.$ references: menu-active-color,
  tag-cloud-start, tag-cloud-end, tag-cloud-end-dark,
  pagination-link-color-dark (were reading from SCSS vars, ignoring
  hugo:vars overrides)
- Remove dead !default flags from all v.$ references (hugo:vars
  always provides values, making !default unreachable)
- Compute derived defaults in scss-vars.html from base values instead
  of hardcoding duplicates
- Extract admonition mixin set-fi-vars map to shared $vars variable
2026-06-22 02:43:35 +08:00

197 lines
6.6 KiB
TypeScript

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 `.details` 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<HTMLElement>('[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<HTMLElement>('.target')
const $copy = dialog.querySelector<HTMLElement>('.copy-icon-btn')
const $confirm = dialog.querySelector<HTMLElement>('.confirm-btn')
const $cancel = dialog.querySelector<HTMLElement>('.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<HTMLAnchorElement>('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 `.details` elements.
* @param target - The root element to search within.
*/
initDetails(target: Element | Document = document) {
target.querySelectorAll<HTMLElement>('.details:not(.disabled)').forEach(($details) => {
const $summary = $details.querySelector<HTMLElement>('.details-summary')!
$summary.addEventListener('click', () => {
$details.classList.toggle('open')
}, false)
})
}
/** Convert footnote refs into tooltip-enabled elements. */
#initFootnotes() {
const $footnoteRefs = document.querySelectorAll<HTMLElement>('#content sup[id^="fnref:"]')
const $footnotes = document.querySelector<HTMLElement>('.footnotes[role="doc-endnotes"]')
if (!$footnoteRefs.length || !$footnotes)
return
const footnoteMap = new Map<HTMLElement, HTMLElement>()
$footnoteRefs.forEach(($ref) => {
if (this.core.config.tooltip) {
const $link = $ref.querySelector<HTMLAnchorElement>('a.footnote-ref')
if ($link) {
$link.addEventListener('click', (e) => {
e.preventDefault()
}, false)
}
}
const id = $ref.id.replace('fnref:', '')
const $footnoteContent = $footnotes.querySelector<HTMLElement>(`[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()
this.initSVGIcon()
eventBus.on('fixit:decrypted', () => {
this.initContent()
})
eventBus.on('fixit:partial-decrypted', ({ detail }) => {
this.initContent(detail.target)
})
}
}