Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
18ecf7c89This file is pinned to the selected release. Download the original to open it in your design tool.
export const SUPPORTED_LOCALES = Object.freeze(['ja', 'en', 'zh-CN'])
export const DEFAULT_LOCALE = 'ja'
export const LOCALE_STORAGE_KEY = 'stackchan.locale'
let activeLocale = DEFAULT_LOCALE
let japaneseCatalog = Object.freeze({})
let activeCatalog = Object.freeze({})
let templateEntries = []
let observer
const canonicalText = new WeakMap()
const canonicalAttributes = new WeakMap()
function hasBrowserStorage() {
return typeof globalThis.localStorage !== 'undefined'
}
function storedLocale() {
if (!hasBrowserStorage()) return null
try {
return globalThis.localStorage.getItem(LOCALE_STORAGE_KEY)
} catch {
return null
}
}
export function normalizeLocale(locale) {
if (typeof locale !== 'string') return null
const normalized = locale.trim().replaceAll('_', '-').toLowerCase()
if (normalized === 'ja' || normalized.startsWith('ja-')) return 'ja'
if (normalized === 'en' || normalized.startsWith('en-')) return 'en'
if (normalized === 'zh' || normalized.startsWith('zh-')) return 'zh-CN'
return null
}
export function resolveLocale({ stored = storedLocale(), browser = globalThis.navigator?.languages ?? [] } = {}) {
const explicit = normalizeLocale(stored)
if (explicit) return explicit
for (const candidate of browser) {
const matched = normalizeLocale(candidate)
if (matched) return matched
}
return DEFAULT_LOCALE
}
async function defaultCatalogLoader(locale) {
const response = await fetch(new URL(`./locales/${locale}.json`, import.meta.url))
if (!response.ok) throw new Error(`Unable to load locale ${locale}: HTTP ${response.status}`)
return response.json()
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function rebuildTemplateEntries() {
templateEntries = Object.keys(japaneseCatalog)
.filter((key) => /\{[A-Za-z][A-Za-z0-9_]*\}/.test(key))
.map((key) => {
const names = []
let cursor = 0
let pattern = '^'
for (const match of key.matchAll(/\{([A-Za-z][A-Za-z0-9_]*)\}/g)) {
pattern += escapeRegExp(key.slice(cursor, match.index)) + '([\\s\\S]+?)'
names.push(match[1])
cursor = match.index + match[0].length
}
pattern += `${escapeRegExp(key.slice(cursor))}$`
return { key, names, pattern: new RegExp(pattern) }
})
}
function interpolate(value, params) {
return String(value).replace(/\{([A-Za-z][A-Za-z0-9_]*)\}/g, (placeholder, name) =>
Object.hasOwn(params, name) ? String(params[name]) : placeholder
)
}
function translateRenderedString(source) {
for (const entry of templateEntries) {
const match = entry.pattern.exec(source)
if (!match) continue
const params = Object.fromEntries(entry.names.map((name, index) => [name, match[index + 1]]))
return interpolate(activeCatalog[entry.key] ?? japaneseCatalog[entry.key] ?? entry.key, params)
}
return source
}
export function t(source, params = {}) {
const translated = activeCatalog[source] ?? japaneseCatalog[source]
if (translated !== undefined) return interpolate(translated, params)
if (Object.keys(params).length > 0) return interpolate(source, params)
return translateRenderedString(source)
}
export function getLocale() {
return activeLocale
}
const SKIPPED_TEXT_PARENTS = new Set(['CODE', 'PRE', 'SCRIPT', 'STYLE'])
const TRANSLATED_ATTRIBUTES = ['aria-label', 'placeholder', 'title']
function textSource(node, refreshSource) {
if (refreshSource || !canonicalText.has(node)) canonicalText.set(node, node.nodeValue ?? '')
return canonicalText.get(node)
}
function attributeSource(element, attribute, refreshSource) {
const current = element.getAttribute(attribute)
const sources = canonicalAttributes.get(element)
if (current === null) {
if (refreshSource) sources?.delete(attribute)
return null
}
if (sources && !refreshSource && sources.has(attribute)) return sources.get(attribute)
const nextSources = sources ?? new Map()
nextSources.set(attribute, current)
if (!sources) canonicalAttributes.set(element, nextSources)
return current
}
function translateTextNode(node, refreshSource = false) {
if (
!node.parentElement ||
SKIPPED_TEXT_PARENTS.has(node.parentElement.tagName) ||
node.parentElement.closest('[translate="no"]')
)
return
const source = textSource(node, refreshSource)
const trimmed = source.trim()
if (!trimmed) return
const translated = t(trimmed)
const start = source.indexOf(trimmed)
const rendered = `${source.slice(0, start)}${translated}${source.slice(start + trimmed.length)}`
if (node.nodeValue !== rendered) node.nodeValue = rendered
}
function translateElementAttributes(element, refreshedAttributes) {
if (element.closest('[translate="no"]')) return
for (const attribute of TRANSLATED_ATTRIBUTES) {
const source = attributeSource(element, attribute, refreshedAttributes?.has(attribute) ?? false)
if (source === null) continue
const translated = t(source)
if (element.getAttribute(attribute) !== translated) element.setAttribute(attribute, translated)
}
}
export function translateDocument(root = globalThis.document) {
if (!root || typeof NodeFilter === 'undefined') return
if (root.nodeType === Node.TEXT_NODE) {
translateTextNode(root)
return
}
if (root instanceof Element) translateElementAttributes(root)
const textWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
while (textWalker.nextNode()) translateTextNode(textWalker.currentNode)
const elementWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
while (elementWalker.nextNode()) translateElementAttributes(elementWalker.currentNode)
}
const observerOptions = {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: TRANSLATED_ATTRIBUTES,
}
function observeDocument() {
if (observer && document.documentElement) observer.observe(document.documentElement, observerOptions)
}
function startObserver() {
if (observer || typeof MutationObserver === 'undefined' || !document.documentElement) return
observer = new MutationObserver((records) => {
observer.disconnect()
try {
const changedText = new Set()
const changedAttributes = new Map()
const addedNodes = []
for (const record of records) {
if (record.type === 'characterData') changedText.add(record.target)
else if (record.type === 'attributes' && record.attributeName) {
const attributes = changedAttributes.get(record.target) ?? new Set()
attributes.add(record.attributeName)
changedAttributes.set(record.target, attributes)
} else if (record.type === 'childList') addedNodes.push(...record.addedNodes)
}
for (const node of changedText) translateTextNode(node, true)
for (const [element, attributes] of changedAttributes) translateElementAttributes(element, attributes)
for (const node of addedNodes) translateDocument(node)
} finally {
observeDocument()
}
})
observeDocument()
}
export async function initializeI18n({ locale = resolveLocale(), loader = defaultCatalogLoader } = {}) {
const normalized = normalizeLocale(locale) ?? DEFAULT_LOCALE
try {
japaneseCatalog = Object.freeze(await loader(DEFAULT_LOCALE))
} catch (error) {
console.warn('[i18n] Japanese catalog is unavailable', error)
japaneseCatalog = Object.freeze({})
}
if (normalized === DEFAULT_LOCALE) {
activeLocale = DEFAULT_LOCALE
activeCatalog = japaneseCatalog
} else {
try {
activeCatalog = Object.freeze(await loader(normalized))
activeLocale = normalized
} catch (error) {
console.warn(`[i18n] ${normalized} catalog is unavailable; using Japanese`, error)
activeLocale = DEFAULT_LOCALE
activeCatalog = japaneseCatalog
}
}
rebuildTemplateEntries()
if (typeof document !== 'undefined') {
document.documentElement.lang = activeLocale
const reconnectObserver = Boolean(observer)
observer?.disconnect()
try {
translateDocument(document)
} finally {
if (reconnectObserver) observeDocument()
}
startObserver()
document.dispatchEvent(new CustomEvent('stackchan:localechange', { detail: { locale: activeLocale } }))
}
return activeLocale
}
export async function setLocale(locale, options = {}) {
const normalized = normalizeLocale(locale) ?? DEFAULT_LOCALE
if (hasBrowserStorage()) {
try {
globalThis.localStorage.setItem(LOCALE_STORAGE_KEY, normalized)
} catch {}
}
return initializeI18n({ ...options, locale: normalized })
}
export const i18nReady = typeof document === 'undefined' ? Promise.resolve(DEFAULT_LOCALE) : initializeI18n()