feat(i18n): add japanese translation with better fallback

This commit is contained in:
Shintaro Jokagi 2025-05-29 11:53:26 +12:00
parent 8d942e0942
commit 9f9872376e
No known key found for this signature in database
GPG key ID: 0DDF8FA44C9A0DA8
30 changed files with 1420 additions and 630 deletions

View file

@ -9,7 +9,7 @@ export default defineConfig({
site: 'https://zen-browser.app', site: 'https://zen-browser.app',
i18n: { i18n: {
defaultLocale: 'en', defaultLocale: 'en',
locales: ['en'], locales: ['en', 'ja'],
routing: { routing: {
fallbackType: 'rewrite', fallbackType: 'rewrite',
prefixDefaultLocale: false, prefixDefaultLocale: false,

View file

@ -16,7 +16,7 @@
"prepare": "husky", "prepare": "husky",
"test": "npx vitest run", "test": "npx vitest run",
"test:coverage": "npx vitest --coverage", "test:coverage": "npx vitest --coverage",
"test:playwright": "npx playwright test" "test:playwright": "npx playwright test --reporter=list"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.4",

View file

@ -9,9 +9,15 @@ const {
mods: { slug }, mods: { slug },
}, },
} = getUI(locale) } = getUI(locale)
const { href, ...props } = Astro.props
if (!href) {
console.error('BackButton: href is required')
}
--- ---
<button type="button" onclick="window.history.back()" class="mb-8 flex w-min items-center gap-2"> <a href={href} class="mb-8 flex w-fit items-center gap-2" {...props} data-testid="back-button">
<ArrowLeftIcon class="size-4" /> <ArrowLeftIcon class="size-4" />
{slug.back} {slug.back}
</button> </a>

View file

@ -3,7 +3,17 @@ import { getLocale, getPath } from '~/utils/i18n'
const locale = getLocale(Astro) const locale = getLocale(Astro)
const getLocalePath = getPath(locale) const getLocalePath = getPath(locale)
const { class: className, isPrimary, isAlert, isBordered, href, id, extra } = Astro.props const {
class: className,
isPrimary,
isAlert,
isBordered,
href,
id,
extra,
localePath = true,
...props
} = Astro.props
--- ---
{ {
@ -11,7 +21,7 @@ const { class: className, isPrimary, isAlert, isBordered, href, id, extra } = As
<a <a
id={id} id={id}
{...extra} {...extra}
href={getLocalePath(href)} href={localePath ? getLocalePath(href) : href}
class:list={[ class:list={[
'transition-bg flex items-center justify-center gap-2 rounded-xl px-6 py-4 transition-transform duration-150 hover:scale-[1.02] active:scale-[0.98]', 'transition-bg flex items-center justify-center gap-2 rounded-xl px-6 py-4 transition-transform duration-150 hover:scale-[1.02] active:scale-[0.98]',
className, className,
@ -23,6 +33,7 @@ const { class: className, isPrimary, isAlert, isBordered, href, id, extra } = As
? 'bg-subtle' ? 'bg-subtle'
: '!transition-bg border-2 border-dark hover:bg-dark hover:text-paper hover:shadow-sm', : '!transition-bg border-2 border-dark hover:bg-dark hover:text-paper hover:shadow-sm',
]} ]}
{...props}
> >
<slot /> <slot />
</a> </a>
@ -41,6 +52,7 @@ const { class: className, isPrimary, isAlert, isBordered, href, id, extra } = As
? '' ? ''
: '!transition-bg border-2 border-dark hover:bg-dark hover:text-paper hover:shadow-sm', : '!transition-bg border-2 border-dark hover:bg-dark hover:text-paper hover:shadow-sm',
]} ]}
{...props}
> >
<slot /> <slot />
</button> </button>

View file

@ -23,15 +23,17 @@ const {
class="relative flex w-full flex-col items-center gap-6 py-12 text-start md:text-center lg:py-36" class="relative flex w-full flex-col items-center gap-6 py-12 text-start md:text-center lg:py-36"
> >
<Description class="mb-2 text-6xl font-bold"> <Description class="mb-2 text-6xl font-bold">
<motion.span client:load {...getTitleAnimation(0.2)}> {
{community.title[0]} community.title.map((title, index) =>
</motion.span> title !== '\n' ? (
<motion.span client:load {...getTitleAnimation(0.4)}> <motion.span client:load {...getTitleAnimation(0.2 + index * 0.2)}>
{community.title[1]} {title}
</motion.span> </motion.span>
<motion.span client:load {...getTitleAnimation(0.6)}> ) : (
{community.title[2]} <br class="hidden md:block" />
</motion.span> )
)
}
</Description> </Description>
<motion.p client:load {...getTitleAnimation(0.6)} className="lg:w-1/2 lg:px-0"> <motion.p client:load {...getTitleAnimation(0.6)} className="lg:w-1/2 lg:px-0">
{community.description} {community.description}

View file

@ -19,22 +19,28 @@ const {
}, },
} = getUI(locale) } = getUI(locale)
const { title1 = features.title1, title2 = features.title2, title3 = features.title3 } = Astro.props interface Props {
titles?: string[]
}
const { titles } = Astro.props
const descriptions = Object.values(features.featureTabs).map(tab => tab.description) const descriptions = Object.values(features.featureTabs).map(tab => tab.description)
--- ---
<section id="Features" class="relative flex w-full flex-col py-12 text-start lg:py-36"> <section id="Features" class="relative flex w-full flex-col py-12 text-start lg:py-36">
<Description class="mb-2 text-4xl font-bold sm:text-6xl"> <Description class="mb-2 text-4xl font-bold sm:text-6xl">
<motion.span client:load {...getTitleAnimation(0.2)}> {
{title1} (titles || features.titles).map((title, index) =>
</motion.span> title !== '\n' ? (
<motion.span client:load {...getTitleAnimation(0.4)}> <motion.span client:load {...getTitleAnimation(0.2 + index * 0.2)}>
{title2} {title}
</motion.span> </motion.span>
<motion.span client:load {...getTitleAnimation(0.6)}> ) : (
{title3} <br class="hidden md:block" />
</motion.span> )
)
}
</Description> </Description>
<motion.p client:load {...getTitleAnimation(0.6)} className="lg:w-1/2"> <motion.p client:load {...getTitleAnimation(0.6)} className="lg:w-1/2">
{features.description} {features.description}

View file

@ -37,26 +37,25 @@ const {
> >
<div class="flex h-full flex-col items-center justify-center"> <div class="flex h-full flex-col items-center justify-center">
<Title class="relative px-12 text-center font-normal leading-8 md:text-7xl lg:px-0 lg:text-9xl"> <Title class="relative px-12 text-center font-normal leading-8 md:text-7xl lg:px-0 lg:text-9xl">
<motion.span client:load {...getHeroTitleAnimation()}> {
{hero.title[0]} hero.title.map(title =>
</motion.span> title.text !== '\n' ? (
<motion.span client:load {...getHeroTitleAnimation()}> <motion.span
{hero.title[1]} client:load
</motion.span> {...getHeroTitleAnimation()}
<br class="hidden md:block" /> className={title.highlight ? 'italic text-coral' : ''}
<motion.span client:load {...getHeroTitleAnimation()}> >
{hero.title[2]} {title.text}
</motion.span> </motion.span>
<motion.span client:load {...getHeroTitleAnimation()} className="italic text-coral"> ) : (
{hero.title[3]} <br class="hidden md:block" />
</motion.span> )
<motion.span client:load {...getHeroTitleAnimation()}> )
{hero.title[4]} }
</motion.span>
</Title> </Title>
<motion.span client:load {...getHeroTitleAnimation()}> <motion.span client:load {...getHeroTitleAnimation()}>
<Description class="px-12 text-center lg:px-0"> <Description class="px-12 text-center lg:px-0">
{hero.description[0]}. {hero.description[0]}
<br class="hidden sm:inline" /> <br class="hidden sm:inline" />
{hero.description[1]}</Description {hero.description[1]}</Description
> >

View file

@ -0,0 +1,587 @@
---
import { icon, library } from '@fortawesome/fontawesome-svg-core'
import { faSort, faSortDown, faSortUp } from '@fortawesome/free-solid-svg-icons'
import { type ZenTheme } from '~/mods'
import { getPath, type Locale } from '~/utils/i18n'
// Add icons to the library
library.add(faSort, faSortUp, faSortDown)
// Create icon objects
const defaultSortIcon = icon({ prefix: 'fas', iconName: 'sort' })
const ascSortIcon = icon({ prefix: 'fas', iconName: 'sort-up' })
const descSortIcon = icon({ prefix: 'fas', iconName: 'sort-down' })
interface ModsListProps {
allMods: ZenTheme[]
locale: Locale
// eslint-disable-next-line @typescript-eslint/no-explicit-any
translations: any
}
const { allMods, locale, translations } = Astro.props as ModsListProps
const getLocalePath = getPath(locale)
// Server-side rendering setup
const defaultLimit = 12
const defaultPage = 1
const initialMods = allMods.slice(0, defaultLimit)
const totalPages = Math.ceil(allMods.length / defaultLimit)
---
<div id="mods-list-container" class="flex flex-1 flex-col">
<div class="flex flex-col items-center gap-4">
<div class="flex w-full flex-col items-center justify-center gap-6">
<input
class="w-full rounded-full border-2 border-dark bg-transparent px-6 py-2 text-lg outline-none"
id="search"
placeholder={translations.search}
type="text"
/>
</div>
<div class="grid w-full grid-cols-2 place-items-center gap-4 sm:grid-cols-3">
<div class="flex flex-col items-start gap-2">
<button
class="text-md flex items-center gap-2 px-4 py-2 font-semibold"
id="created-sort"
type="button"
>
{translations.sort.lastCreated}
<span class="relative">
<span id="created-sort-default" class="" set:html={defaultSortIcon.html[0]} />
<span id="created-sort-asc" class="hidden" set:html={ascSortIcon.html[0]} />
<span id="created-sort-desc" class="hidden" set:html={descSortIcon.html[0]} />
</span>
</button>
</div>
<div class="flex flex-col items-center gap-2">
<button
class="text-md flex items-center gap-2 px-4 py-2 font-semibold"
id="updated-sort"
type="button"
>
{translations.sort.lastUpdated}
<span class="relative">
<span id="updated-sort-default" class="" set:html={defaultSortIcon.html[0]} />
<span id="updated-sort-asc" class="hidden" set:html={ascSortIcon.html[0]} />
<span id="updated-sort-desc" class="hidden" set:html={descSortIcon.html[0]} />
</span>
</button>
</div>
<div class="flex items-center gap-2 px-4 py-2">
<label class="text-md font-semibold" for="limit">
{translations.sort.perPage}
</label>
<select class="rounded border border-dark px-2 py-1 text-sm dark:bg-paper" id="limit">
<option value="12">12</option>
<option value="24">24</option>
<option value="48">48</option>
<option value="96">96</option>
</select>
</div>
</div>
</div>
<div
id="mods-grid"
class="grid w-full grid-cols-1 place-items-start gap-12 py-6 md:grid-cols-2 xl:grid-cols-3"
>
{
initialMods.map(mod => (
<a
class="mod-card flex w-full flex-col gap-4 border-transparent transition-colors duration-100 hover:opacity-90"
href={getLocalePath(`/mods/${mod.id}`)}
>
<div class="relative mb-0 block aspect-[1.85/1] h-48 overflow-hidden rounded-md border-2 border-dark object-cover shadow-md">
<img
alt={mod.name}
class="h-full w-full object-cover transition-transform duration-100 hover:scale-105"
loading="lazy"
src={mod.image}
/>
</div>
<div>
<h2 class="text-lg font-bold">
{mod.name} <span class="ml-1 text-sm font-normal">by @{mod.author}</span>
</h2>
<p class="line-clamp-2 text-sm font-thin">{mod.description}</p>
</div>
</a>
))
}
</div>
<div
id="no-results"
class="hidden h-full flex-col justify-center gap-4 place-self-center text-center"
>
<h2 class="text-lg font-bold">{translations.noResults}</h2>
<p class="text-sm font-thin">{translations.noResultsDescription}</p>
</div>
<div id="pagination" class="mx-auto mb-12 hidden items-center justify-center gap-4 px-8">
{
totalPages > 1 && (
<>
<span class="pointer-events-none px-3 py-2 text-gray-400">&lt;</span>
<form class="flex items-center gap-2" id="page-form">
<input
id="page-input"
aria-label="Page number"
class="w-16 rounded border border-dark bg-transparent px-2 py-1 text-center text-sm"
type="text"
value="1"
/>
<span class="text-sm">
{translations.pagination.pagination
.replace('{totalPages}', totalPages.toString())
.replace('{totalItems}', allMods.length.toString())
.replace('{input}', '')}
</span>
</form>
<a
class="px-3 py-2 text-dark hover:text-gray-600"
href={getLocalePath('/mods?page=2')}
data-page="2"
>
&gt;
</a>
</>
)
}
</div>
</div>
<script define:vars={{ allMods, translations, locale }} is:inline>
class ModsSearch {
constructor() {
this.allMods = allMods
this.translations = translations
this.locale = locale // State
this.state = {
search: '',
createdSort: 'default',
updatedSort: 'default',
page: 1,
limit: 12,
}
// Track if content has been dynamically modified
this.hasBeenModified = false
// Performance optimizations
this.searchTimeout = null
this.isRendering = false
this.lastFilteredMods = null
this.lastRenderState = null
this.initializeFromURL()
this.bindEvents()
this.renderMods()
}
getLocalePath(path) {
if (this.locale && this.locale !== 'en' && !path.startsWith(`/${this.locale}`)) {
return `/${this.locale}${path.startsWith('/') ? '' : '/'}${path}`
}
return path
} // Validation helpers - similar to Zod
validateSortOrder(value, defaultValue = 'default') {
const validSortOrders = ['default', 'asc', 'desc']
return validSortOrders.includes(value) ? value : defaultValue
}
validateLimit(value, defaultValue = 12) {
const validLimits = [12, 24, 48, 96]
const parsed = parseInt(value, 10)
return validLimits.includes(parsed) ? parsed : defaultValue
}
validatePage(value, defaultValue = 1) {
const parsed = parseInt(value, 10)
return !isNaN(parsed) && parsed >= 1 ? parsed : defaultValue
}
initializeFromURL() {
const params = new URLSearchParams(window.location.search)
// Validate and sanitize URL parameters with fallbacks
const rawCreatedSort = params.get('created')
const rawUpdatedSort = params.get('updated')
const rawPage = params.get('page')
const rawLimit = params.get('limit')
this.state = {
search: params.get('q') || '',
createdSort: this.validateSortOrder(rawCreatedSort, 'default'),
updatedSort: this.validateSortOrder(rawUpdatedSort, 'default'),
page: this.validatePage(rawPage, 1),
limit: this.validateLimit(rawLimit, 12),
}
// Set form values
document.getElementById('search').value = this.state.search
document.getElementById('limit').value = this.state.limit.toString()
}
bindEvents() {
// Search input with debouncing
document.getElementById('search').addEventListener('input', e => {
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout)
}
// Set new timeout for debounced search
this.searchTimeout = setTimeout(() => {
this.setState({ search: e.target.value, page: 1 })
}, 300) // 300ms debounce
})
// Sort buttons
document.getElementById('created-sort').addEventListener('click', () => {
this.toggleCreatedSort()
})
document.getElementById('updated-sort').addEventListener('click', () => {
this.toggleUpdatedSort()
}) // Limit select
document.getElementById('limit').addEventListener('change', e => {
const newLimit = this.validateLimit(e.target.value, this.state.limit)
this.setState({ limit: newLimit, page: 1 })
})
}
setState(newState) {
// Prevent multiple renders
if (this.isRendering) return
// Validate new state values before setting
const validatedState = { ...newState }
if ('createdSort' in validatedState) {
validatedState.createdSort = this.validateSortOrder(
validatedState.createdSort,
this.state.createdSort
)
}
if ('updatedSort' in validatedState) {
validatedState.updatedSort = this.validateSortOrder(
validatedState.updatedSort,
this.state.updatedSort
)
}
if ('page' in validatedState) {
validatedState.page = this.validatePage(validatedState.page, this.state.page)
}
if ('limit' in validatedState) {
validatedState.limit = this.validateLimit(validatedState.limit, this.state.limit)
}
// Determine if this is a search/filter operation (should replace URL)
// vs pagination navigation (should push to history)
const isSearchOrFilter =
'search' in validatedState ||
'createdSort' in validatedState ||
'updatedSort' in validatedState ||
'limit' in validatedState ||
('page' in validatedState && validatedState.page === 1)
this.state = { ...this.state, ...validatedState }
this.updateURL(isSearchOrFilter)
this.updateSortIcons()
this.renderMods()
}
toggleCreatedSort() {
const newSort =
this.state.createdSort === 'default'
? 'asc'
: this.state.createdSort === 'asc'
? 'desc'
: 'default'
this.setState({ createdSort: newSort, updatedSort: 'default', page: 1 })
}
toggleUpdatedSort() {
const newSort =
this.state.updatedSort === 'default'
? 'asc'
: this.state.updatedSort === 'asc'
? 'desc'
: 'default'
this.setState({ updatedSort: newSort, createdSort: 'default', page: 1 })
}
updateSortIcons() {
// Update created sort icons
document
.getElementById('created-sort-default')
.classList.toggle('hidden', this.state.createdSort !== 'default')
document
.getElementById('created-sort-asc')
.classList.toggle('hidden', this.state.createdSort !== 'asc')
document
.getElementById('created-sort-desc')
.classList.toggle('hidden', this.state.createdSort !== 'desc')
// Update updated sort icons
document
.getElementById('updated-sort-default')
.classList.toggle('hidden', this.state.updatedSort !== 'default')
document
.getElementById('updated-sort-asc')
.classList.toggle('hidden', this.state.updatedSort !== 'asc')
document
.getElementById('updated-sort-desc')
.classList.toggle('hidden', this.state.updatedSort !== 'desc')
}
updateURL(isSearchOrFilter = false) {
const params = new URLSearchParams()
if (this.state.search) params.set('q', this.state.search)
if (this.state.createdSort !== 'default') params.set('created', this.state.createdSort)
if (this.state.updatedSort !== 'default') params.set('updated', this.state.updatedSort)
if (this.state.page > 1) params.set('page', this.state.page.toString())
if (this.state.limit !== 12) params.set('limit', this.state.limit.toString())
const newUrl = `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ''}`
// Only push to history for pagination navigation, replace for search/filtering
if (!isSearchOrFilter && this.state.page > 1) {
window.history.pushState({}, '', newUrl)
} else {
window.history.replaceState({}, '', newUrl)
}
}
getFilteredMods() {
let filtered = [...this.allMods]
// Filter by search
const searchTerm = this.state.search.toLowerCase()
if (searchTerm) {
filtered = filtered.filter(
mod =>
mod.name.toLowerCase().includes(searchTerm) ||
mod.description.toLowerCase().includes(searchTerm) ||
mod.author.toLowerCase().includes(searchTerm) ||
(mod.tags?.some(tag => tag.toLowerCase().includes(searchTerm)) ?? false)
)
}
// Sort by createdAt if chosen
if (this.state.createdSort !== 'default') {
filtered.sort((a, b) => {
const diff = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
return this.state.createdSort === 'asc' ? diff : -diff
})
}
// Sort by updatedAt if chosen
if (this.state.updatedSort !== 'default') {
filtered.sort((a, b) => {
const diff = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
return this.state.updatedSort === 'asc' ? diff : -diff
})
}
return filtered
}
renderMods() {
// Prevent concurrent renders
if (this.isRendering) return
this.isRendering = true
// Use requestAnimationFrame for smoother rendering
requestAnimationFrame(() => {
try {
const filteredMods = this.getFilteredMods()
const totalPages = Math.ceil(filteredMods.length / this.state.limit)
const startIndex = (this.state.page - 1) * this.state.limit
const endIndex = startIndex + this.state.limit
const paginatedMods = filteredMods.slice(startIndex, endIndex)
const modsGrid = document.getElementById('mods-grid')
const noResults = document.getElementById('no-results')
if (paginatedMods.length > 0) {
noResults.classList.add('hidden')
modsGrid.classList.remove('hidden')
// Check if we're in the default state
const isDefaultState =
this.state.search === '' &&
this.state.createdSort === 'default' &&
this.state.updatedSort === 'default' &&
this.state.page === 1 &&
this.state.limit === 12
// Re-render if: not in default state, OR we've been modified and need to restore default
if (!isDefaultState || this.hasBeenModified) {
// Create document fragment for better performance
const fragment = document.createDocumentFragment()
const tempDiv = document.createElement('div')
tempDiv.innerHTML = paginatedMods
.map(
mod => `
<a
class="mod-card flex w-full flex-col gap-4 border-transparent transition-colors duration-100 hover:opacity-90"
href="${this.getLocalePath(`/mods/${mod.id}${this.buildSearchParams()}`)}"
>
<div class="relative mb-0 block aspect-[1.85/1] h-48 overflow-hidden rounded-md border-2 border-dark object-cover shadow-md">
<img
alt="${mod.name}"
class="h-full w-full object-cover transition-transform duration-100 hover:scale-105"
loading="lazy"
src="${mod.image}"
/>
</div>
<div>
<h2 class="text-lg font-bold">
${mod.name} <span class="ml-1 text-sm font-normal">by @${mod.author}</span>
</h2>
<p class="text-sm font-thin line-clamp-2">${mod.description}</p>
</div>
</a>
`
)
.join('')
// Move all children to fragment
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild)
}
// Clear and append fragment (single reflow)
modsGrid.innerHTML = ''
modsGrid.appendChild(fragment)
// Track that we've modified the content
this.hasBeenModified = !isDefaultState
}
} else {
modsGrid.classList.add('hidden')
noResults.classList.replace('hidden', 'flex')
this.hasBeenModified = true
}
this.renderPagination(totalPages, filteredMods.length)
} finally {
this.isRendering = false
}
})
}
buildSearchParams() {
const params = new URLSearchParams()
if (this.state.search) params.set('q', this.state.search)
if (this.state.createdSort !== 'default') params.set('created', this.state.createdSort)
if (this.state.updatedSort !== 'default') params.set('updated', this.state.updatedSort)
if (this.state.page > 1) params.set('page', this.state.page.toString())
if (this.state.limit !== 12) params.set('limit', this.state.limit.toString())
return params.toString() ? `?${params.toString()}` : ''
}
buildPaginationUrl(targetPage) {
const params = new URLSearchParams(this.buildSearchParams())
if (targetPage > 1) {
params.set('page', targetPage.toString())
} else {
params.delete('page')
}
return `/mods?${params.toString()}`
}
navigatePage(pageNum) {
// Validate page number
const validatedPage = this.validatePage(pageNum, this.state.page)
// Update state without going through setState to handle URL properly
this.state = { ...this.state, page: validatedPage }
// For pagination navigation, push to history if page > 1, otherwise replace
this.updateURL(false) // false = this is navigation, not search/filter
this.updateSortIcons()
this.renderMods()
window.scrollTo(0, 0)
}
renderPagination(totalPages, totalItems) {
const pagination = document.getElementById('pagination')
if (totalPages <= 1) {
pagination.innerHTML = ''
pagination.classList.add('hidden')
return
}
const prevButton =
this.state.page > 1
? `<a
class="px-3 py-2 text-dark hover:text-gray-600"
href="${this.getLocalePath(this.buildPaginationUrl(this.state.page - 1))}"
data-page="${this.state.page - 1}"
>
&lt;
</a>`
: `<span class="pointer-events-none px-3 py-2 text-gray-400">&lt;</span>`
const nextButton =
this.state.page < totalPages
? `<a
class="px-3 py-2 text-dark hover:text-gray-600"
href="${this.getLocalePath(this.buildPaginationUrl(this.state.page + 1))}"
data-page="${this.state.page + 1}"
>
&gt;
</a>`
: `<span class="pointer-events-none px-3 py-2 text-gray-400">&gt;</span>`
const paginationText = this.translations.pagination.pagination
.replace('{totalPages}', totalPages.toString())
.replace('{totalItems}', totalItems.toString())
pagination.innerHTML = `
${prevButton}
<form class="flex items-center gap-2" id="page-form">
<input
id="page-input"
aria-label="Page number"
class="w-16 rounded border border-dark bg-transparent px-2 py-1 text-center text-sm"
type="text"
value="${this.state.page}"
/>
<span class="text-sm">${paginationText.replace('{input}', '')}</span>
</form>
${nextButton}
` // Bind pagination events
pagination.classList.replace('hidden', 'flex')
const pageForm = document.getElementById('page-form')
const pageInput = document.getElementById('page-input')
pageForm.addEventListener('submit', e => {
e.preventDefault()
const inputValue = pageInput.value
const newPage = this.validatePage(inputValue, this.state.page)
// Additional validation for page range
if (newPage >= 1 && newPage <= totalPages) {
this.navigatePage(newPage)
} else {
// Reset to current page if out of range
pageInput.value = this.state.page.toString()
}
})
// Bind navigation links
pagination.addEventListener('click', e => {
if (e.target.tagName === 'A' && e.target.dataset.page) {
e.preventDefault()
this.navigatePage(parseInt(e.target.dataset.page, 10))
}
})
}
}
// Initialize the mods search
new ModsSearch()
</script>

View file

@ -1,236 +0,0 @@
import { icon, library } from '@fortawesome/fontawesome-svg-core'
import { faSort, faSortDown, faSortUp } from '@fortawesome/free-solid-svg-icons'
import { useEffect, useState, type FormEvent } from 'react'
import { useModsSearch } from '~/hooks/useModsSearch'
import { type ZenTheme } from '~/mods'
import { getUI, type Locale } from '~/utils/i18n'
// Add icons to the library
library.add(faSort, faSortUp, faSortDown)
// Create icon objects
const defaultSortIcon = icon({ prefix: 'fas', iconName: 'sort' })
const ascSortIcon = icon({ prefix: 'fas', iconName: 'sort-up' })
const descSortIcon = icon({ prefix: 'fas', iconName: 'sort-down' })
type ModsListProps = {
allMods: ZenTheme[]
locale: Locale
}
const ModsList = ({ allMods, locale }: ModsListProps) => {
const {
search,
createdSort,
updatedSort,
page,
limit,
totalPages,
totalItems,
setSearch,
toggleCreatedSort,
toggleUpdatedSort,
setPage,
setLimit,
mods: paginatedMods,
// searchParams,
} = useModsSearch(allMods)
const [pageInput, setPageInput] = useState(page.toString())
// Keep page input in sync with actual page
useEffect(() => {
setPageInput(page.toString())
}, [page])
function getSortIcon(state: 'default' | 'asc' | 'desc') {
if (state === 'asc') return ascSortIcon
if (state === 'desc') return descSortIcon
return defaultSortIcon
}
function handleSearch(e: FormEvent<HTMLInputElement>) {
const target = e.target as HTMLInputElement
setSearch(target.value)
}
function handleLimitChange(e: FormEvent<HTMLSelectElement>) {
const target = e.target as HTMLSelectElement
setLimit(Number.parseInt(target.value, 10))
}
function handlePageSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
const newPage = Number.parseInt(pageInput, 10)
if (!Number.isNaN(newPage) && newPage >= 1 && newPage <= totalPages) {
setPage(newPage)
window.scrollTo(0, 0)
} else {
setPageInput(page.toString())
}
}
function handlePageInputChange(e: FormEvent<HTMLInputElement>) {
const target = e.target as HTMLInputElement
setPageInput(target.value)
}
function navigatePage(pageNum: number) {
setPage(pageNum)
window.scrollTo(0, 0)
}
const {
routes: { mods },
} = getUI(locale)
function renderPagination() {
if (totalPages <= 1) return null
return (
<div className="mx-auto mb-12 flex items-center justify-center gap-4 px-8">
<button
className={`px-3 py-2 ${page === 1 ? 'pointer-events-none text-gray-400' : 'text-dark hover:text-gray-600'}`}
onClick={() => navigatePage(page - 1)}
type="button"
>
&lt;
</button>
<form className="flex items-center gap-2" onSubmit={handlePageSubmit}>
{mods.pagination.pagination.split('{input}').map((value, index) => {
if (index === 0) {
return (
<input
key={index}
aria-label="Page number"
className="w-16 rounded border border-dark bg-transparent px-2 py-1 text-center text-sm"
onInput={handlePageInputChange}
type="text"
value={pageInput}
/>
)
}
return (
<span className="text-sm" key={value}>
{value
.replace('{totalPages}', totalPages.toString())
.replace('{totalItems}', totalItems.toString())}
</span>
)
})}
</form>
<button
className={`px-3 py-2 ${page === totalPages ? 'pointer-events-none text-gray-400' : 'text-dark hover:text-gray-600'}`}
onClick={() => navigatePage(page + 1)}
type="button"
>
&gt;
</button>
</div>
)
}
return (
<div>
<div className="flex flex-col items-center gap-4">
<div className="flex w-full flex-col items-center justify-center gap-6">
<input
className="w-full rounded-full border-2 border-dark bg-transparent px-6 py-2 text-lg outline-none"
id="search"
onInput={handleSearch}
placeholder={mods.search}
type="text"
value={search}
/>
</div>
<div className="grid w-full grid-cols-2 place-items-center gap-4 sm:grid-cols-3">
<div className="flex flex-col items-start gap-2">
<button
className="text-md flex items-center gap-2 px-4 py-2 font-semibold"
onClick={toggleCreatedSort}
type="button"
>
{mods.sort.lastCreated}
<span
// biome-ignore lint/security/noDangerouslySetInnerHtml: Icons are safe
dangerouslySetInnerHTML={{
__html: getSortIcon(createdSort).html[0],
}}
/>
</button>
</div>
<div className="flex flex-col items-center gap-2">
<button
className="text-md flex items-center gap-2 px-4 py-2 font-semibold"
onClick={toggleUpdatedSort}
type="button"
>
{mods.sort.lastUpdated}
<span
// biome-ignore lint/security/noDangerouslySetInnerHtml: Icons are safe
dangerouslySetInnerHTML={{
__html: getSortIcon(updatedSort).html[0],
}}
/>
</button>
</div>
<div className="flex items-center gap-2 px-4 py-2">
<label className="text-md font-semibold" htmlFor="limit">
{mods.sort.perPage}
</label>
<select
className="rounded border border-dark bg-transparent px-2 py-1 text-sm [&>option]:text-black"
id="limit"
onInput={handleLimitChange}
value={limit}
>
<option value="12">12</option>
<option value="24">24</option>
<option value="48">48</option>
<option value="96">96</option>
</select>
</div>
</div>
</div>
<div className="grid w-full grid-cols-1 place-items-start gap-12 py-6 md:grid-cols-2 xl:grid-cols-3">
{paginatedMods.length > 0 ? (
paginatedMods.map(mod => (
<a
className="mod-card flex w-full flex-col gap-4 border-transparent transition-colors duration-100 hover:opacity-90"
href={`/mods/${mod.id}`}
key={mod.id}
>
<div className="relative mb-0 block aspect-[1.85/1] h-48 overflow-hidden rounded-md border-2 border-dark object-cover shadow-md">
<img
alt={mod.name}
className="h-full w-full object-cover transition-transform duration-100 hover:scale-105"
loading="lazy"
src={mod.image}
/>
</div>
<div>
<h2 className="text-lg font-bold">
{mod.name} <span className="ml-1 text-sm font-normal">by @{mod.author}</span>
</h2>
<p className="text-sm font-thin">{mod.description}</p>
</div>
</a>
))
) : (
<div className="col-span-4 grid place-items-center gap-4 place-self-center px-8 text-center">
<h2 className="text-lg font-bold">{mods.noResults}</h2>
<p className="text-sm font-thin">{mods.noResultsDescription}</p>
</div>
)}
</div>
{renderPagination()}
</div>
)
}
export default ModsList

View file

@ -1,6 +1,7 @@
--- ---
import InfoIcon from '~/icons/InfoIcon.astro' import InfoIcon from '~/icons/InfoIcon.astro'
import { getIntlLocale } from '~/constants/i18n'
import { releaseNotes as releaseNotesData } from '~/release-notes' import { releaseNotes as releaseNotesData } from '~/release-notes'
import { getLocale, getPath, getUI } from '~/utils/i18n' import { getLocale, getPath, getUI } from '~/utils/i18n'
import { type ReleaseNote, getReleaseNoteFirefoxVersion } from '../release-notes' import { type ReleaseNote, getReleaseNoteFirefoxVersion } from '../release-notes'
@ -184,7 +185,7 @@ generateItems(props.knownIssues, 'known')
} }
</div> </div>
<div class="text-xs font-bold opacity-80"> <div class="text-xs font-bold opacity-80">
{date && date.toLocaleDateString('en-US', { dateStyle: 'long' })} {date && date.toLocaleDateString(getIntlLocale(locale), { dateStyle: 'long' })}
</div> </div>
</div> </div>
{ {

View file

@ -21,7 +21,7 @@ const {
<section id="sponsors" class:list={['py-12', !showSponsors && 'hidden']}> <section id="sponsors" class:list={['py-12', !showSponsors && 'hidden']}>
<div class="mx-auto flex flex-col text-center"> <div class="mx-auto flex flex-col text-center">
<motion.span client:load {...getTitleAnimation(0.2)}> <motion.span client:load {...getTitleAnimation(0.2)}>
<Description class="mb-2 text-6xl font-bold">Our Sponsors</Description> <Description class="mb-2 text-6xl font-bold">{sponsors.title}</Description>
</motion.span> </motion.span>
<motion.span client:load {...getTitleAnimation(0.4)}> <motion.span client:load {...getTitleAnimation(0.4)}>
<Description set:html={sponsors.description} /> <Description set:html={sponsors.description} />

View file

@ -1,4 +1,13 @@
--- ---
import { getLocale, getUI } from '~/utils/i18n'
const locale = getLocale(Astro)
const {
routes: {
download: { buttonCard },
},
} = getUI(locale)
interface Props { interface Props {
label: string label: string
href: string href: string
@ -42,7 +51,7 @@ const { label, href, checksum } = Astro.props
</svg> </svg>
</button> </button>
<span class="absolute -top-10 left-1/2 z-50 hidden min-w-[120px] -translate-x-1/2 select-none whitespace-nowrap rounded-md border border-subtle bg-[rgba(255,255,255,0.98)] px-3 py-2 text-xs text-gray-700 opacity-100 shadow transition-opacity duration-150 group-focus-within/checksum:hidden group-hover/checksum:flex group-focus-within/checksum:group-hover/checksum:hidden dark:bg-[rgba(24,24,27,0.98)] dark:text-gray-100"> <span class="absolute -top-10 left-1/2 z-50 hidden min-w-[120px] -translate-x-1/2 select-none whitespace-nowrap rounded-md border border-subtle bg-[rgba(255,255,255,0.98)] px-3 py-2 text-xs text-gray-700 opacity-100 shadow transition-opacity duration-150 group-focus-within/checksum:hidden group-hover/checksum:flex group-focus-within/checksum:group-hover/checksum:hidden dark:bg-[rgba(24,24,27,0.98)] dark:text-gray-100">
Show SHA-256 {buttonCard.showChecksum}
</span> </span>
<span class="checksum-tooltip popover absolute -left-14 -top-12 z-50 hidden min-w-[220px] items-center gap-2 whitespace-nowrap rounded-md border border-subtle bg-[rgba(255,255,255,0.98)] px-3 py-2 text-xs text-gray-700 opacity-100 shadow transition-opacity duration-150 group-focus-within/checksum:flex dark:bg-[rgba(24,24,27,0.98)] dark:text-gray-100"> <span class="checksum-tooltip popover absolute -left-14 -top-12 z-50 hidden min-w-[220px] items-center gap-2 whitespace-nowrap rounded-md border border-subtle bg-[rgba(255,255,255,0.98)] px-3 py-2 text-xs text-gray-700 opacity-100 shadow transition-opacity duration-150 group-focus-within/checksum:flex dark:bg-[rgba(24,24,27,0.98)] dark:text-gray-100">
<span class="font-mono flex-1 truncate text-xs">{checksum}</span> <span class="font-mono flex-1 truncate text-xs">{checksum}</span>
@ -50,7 +59,7 @@ const { label, href, checksum } = Astro.props
type="button" type="button"
class="copy-btn rounded bg-coral px-2 py-1 text-xs text-white hover:bg-coral/80 data-[twilight='true']:bg-zen-blue data-[twilight='true']:hover:bg-zen-blue/80" class="copy-btn rounded bg-coral px-2 py-1 text-xs text-white hover:bg-coral/80 data-[twilight='true']:bg-zen-blue data-[twilight='true']:hover:bg-zen-blue/80"
> >
Copy {buttonCard.copy}
</button> </button>
</span> </span>
</span> </span>
@ -62,7 +71,7 @@ const { label, href, checksum } = Astro.props
<span <span
class="release-type-tag rounded-full bg-coral/10 px-2 py-1 text-xs font-medium text-coral transition-colors duration-200 group-hover:bg-coral/20 data-[twilight='true']:bg-zen-blue/10 data-[twilight='true']:text-zen-blue data-[twilight='true']:group-hover:bg-zen-blue/20" class="release-type-tag rounded-full bg-coral/10 px-2 py-1 text-xs font-medium text-coral transition-colors duration-200 group-hover:bg-coral/20 data-[twilight='true']:bg-zen-blue/10 data-[twilight='true']:text-zen-blue data-[twilight='true']:group-hover:bg-zen-blue/20"
> >
Beta {buttonCard.beta}
</span> </span>
<div <div
class="download-arrow-icon text-muted-foreground rounded-xl border border-subtle p-2 transition-colors duration-200 group-hover:border-coral group-hover:text-coral data-[twilight='true']:group-hover:border-zen-blue data-[twilight='true']:group-hover:text-zen-blue" class="download-arrow-icon text-muted-foreground rounded-xl border border-subtle p-2 transition-colors duration-200 group-hover:border-coral group-hover:text-coral data-[twilight='true']:group-hover:border-zen-blue data-[twilight='true']:group-hover:text-zen-blue"

View file

@ -1,49 +1,61 @@
import { getUI } from '~/utils/i18n'
/** /**
* Returns the releases object, injecting checksums dynamically. * Returns the releases object, injecting checksums dynamically.
* @param locale The locale to use for labels
* @param checksums Record<string, string> mapping filenames to SHA-256 hashes * @param checksums Record<string, string> mapping filenames to SHA-256 hashes
*/ */
export function getReleasesWithChecksums(checksums: Record<string, string>) { export function getReleasesWithChecksums(locale: string) {
return { const {
macos: { routes: {
universal: { download: {
link: 'https://github.com/zen-browser/desktop/releases/latest/download/zen.macos-universal.dmg', links: { macos, windows, linux },
label: 'Universal',
checksum: checksums['zen.macos-universal.dmg'],
}, },
}, },
windows: { } = getUI(locale)
x86_64: { return (checksums: Record<string, string>) => {
link: 'https://github.com/zen-browser/desktop/releases/latest/download/zen.installer.exe', return {
label: '64-bit (Recommended)', macos: {
checksum: checksums['zen.installer.exe'], universal: {
}, link: 'https://github.com/zen-browser/desktop/releases/latest/download/zen.macos-universal.dmg',
arm64: { label: macos.universal,
link: 'https://github.com/zen-browser/desktop/releases/latest/download/zen.installer-arm64.exe', checksum: checksums['zen.macos-universal.dmg'],
label: 'ARM64',
checksum: checksums['zen.installer-arm64.exe'],
},
},
linux: {
x86_64: {
tarball: {
link: 'https://github.com/zen-browser/desktop/releases/latest/download/zen.linux-x86_64.tar.xz',
label: 'Tarball',
checksum: checksums['zen.linux-x86_64.tar.xz'],
}, },
}, },
aarch64: { windows: {
tarball: { x86_64: {
link: 'https://github.com/zen-browser/desktop/releases/latest/download/zen.linux-aarch64.tar.xz', link: 'https://github.com/zen-browser/desktop/releases/latest/download/zen.installer.exe',
label: 'Tarball', label: windows['64bit'],
checksum: checksums['zen.linux-aarch64.tar.xz'], checksum: checksums['zen.installer.exe'],
},
arm64: {
link: 'https://github.com/zen-browser/desktop/releases/latest/download/zen.installer-arm64.exe',
label: windows.ARM64,
checksum: checksums['zen.installer-arm64.exe'],
}, },
}, },
flathub: { linux: {
all: { x86_64: {
link: 'https://flathub.org/apps/app.zen_browser.zen', tarball: {
label: 'Flathub', link: 'https://github.com/zen-browser/desktop/releases/latest/download/zen.linux-x86_64.tar.xz',
label: linux.x86_64,
checksum: checksums['zen.linux-x86_64.tar.xz'],
},
},
aarch64: {
tarball: {
link: 'https://github.com/zen-browser/desktop/releases/latest/download/zen.linux-aarch64.tar.xz',
label: linux.aarch64,
checksum: checksums['zen.linux-aarch64.tar.xz'],
},
},
flathub: {
all: {
link: 'https://flathub.org/apps/app.zen_browser.zen',
label: linux.flathub,
},
}, },
}, },
}, }
} }
} }

View file

@ -1,4 +1,20 @@
export const I18N = { const UI_EN = (await import('~/i18n/en/translation.json', { with: { type: 'json' } })).default
const UI_JA = (await import('~/i18n/ja/translation.json', { with: { type: 'json' } })).default
export const i18n = {
DEFAULT_LOCALE: 'en', DEFAULT_LOCALE: 'en',
LOCALES: [{ label: 'English', value: 'en' }], LOCALES: [
} as const { label: 'English', value: 'en', ui: UI_EN, intl: 'en-US' },
{ label: '日本語', value: 'ja', ui: UI_JA, intl: 'ja-JP' },
],
}
/**
* Type definition for UI translations based on the English translation
* @typedef {Object} UIProps
*/
export type UIProps = typeof UI_EN | typeof UI_JA
export const getIntlLocale = (locale: string) => {
return i18n.LOCALES.find(l => l.value === locale)?.intl
}

View file

@ -1,5 +1,5 @@
import { CHECKSUMS } from './checksum' import { CHECKSUMS } from './checksum'
import { I18N } from './i18n' import { i18n as I18N } from './i18n'
export const CONSTANT = { export const CONSTANT = {
I18N, I18N,

View file

@ -1,172 +0,0 @@
import { useEffect, useState } from 'react'
import { type ZenTheme } from '../mods'
type SortOrder = 'default' | 'asc' | 'desc'
type ModsSearchState = {
search: string
createdSort: SortOrder
updatedSort: SortOrder
page: number
limit: number
}
const DEFAULT_LIMIT = 12
export function useModsSearch(mods: ZenTheme[]) {
const [searchParams, setSearchParams] = useState<URLSearchParams>()
const [state, setState] = useState<ModsSearchState>({
search: '',
createdSort: 'desc',
updatedSort: 'default',
page: 1,
limit: DEFAULT_LIMIT,
})
// Initialize search params
useEffect(() => {
const params = new URLSearchParams(window.location.search)
setSearchParams(params)
setState({
search: params.get('q') || '',
createdSort: (params.get('created') as SortOrder) || 'desc',
updatedSort: (params.get('updated') as SortOrder) || 'default',
page: Number.parseInt(params.get('page') || '1', 10),
limit: Number.parseInt(params.get('limit') || String(DEFAULT_LIMIT), 10),
})
}, [])
// Update URL when state changes
useEffect(() => {
if (!searchParams) return
if (state.search) {
searchParams.set('q', state.search)
} else {
searchParams.delete('q')
}
if (state.createdSort !== 'default') {
searchParams.set('created', state.createdSort)
} else {
searchParams.delete('created')
}
if (state.updatedSort !== 'default') {
searchParams.set('updated', state.updatedSort)
} else {
searchParams.delete('updated')
}
if (state.page > 1) {
searchParams.set('page', state.page.toString())
} else {
searchParams.delete('page')
}
if (state.limit !== DEFAULT_LIMIT) {
searchParams.set('limit', state.limit.toString())
} else {
searchParams.delete('limit')
}
const newUrl = `${window.location.pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
if (state.page > 1) {
window.history.pushState({}, '', newUrl)
} else {
window.history.replaceState({}, '', newUrl)
}
}, [state, searchParams])
const filteredMods = (() => {
let filtered = [...mods]
// Filter by search
const searchTerm = state.search.toLowerCase()
if (searchTerm) {
filtered = filtered.filter(
mod =>
mod.name.toLowerCase().includes(searchTerm) ||
mod.description.toLowerCase().includes(searchTerm) ||
mod.author.toLowerCase().includes(searchTerm) ||
(mod.tags?.some(tag => tag.toLowerCase().includes(searchTerm)) ?? false)
)
}
// Sort by createdAt if chosen
if (state.createdSort !== 'default') {
filtered.sort((a, b) => {
const diff = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
return state.createdSort === 'asc' ? diff : -diff
})
}
// Sort by updatedAt if chosen
if (state.updatedSort !== 'default') {
filtered.sort((a, b) => {
const diff = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
return state.updatedSort === 'asc' ? diff : -diff
})
}
return filtered
})()
// Calculate pagination
const totalPages = Math.ceil(filteredMods.length / state.limit)
const startIndex = (state.page - 1) * state.limit
const endIndex = startIndex + state.limit
const paginatedMods = filteredMods.slice(startIndex, endIndex)
const setSearch = (search: string) => {
setState(prev => ({ ...prev, search, page: 1 })) // Reset page when search changes
}
const toggleCreatedSort = () => {
setState(prev => ({
...prev,
createdSort:
prev.createdSort === 'default' ? 'asc' : prev.createdSort === 'asc' ? 'desc' : 'default',
page: 1, // Reset page when sort changes
}))
}
const toggleUpdatedSort = () => {
setState(prev => ({
...prev,
updatedSort:
prev.updatedSort === 'default' ? 'asc' : prev.updatedSort === 'asc' ? 'desc' : 'default',
page: 1, // Reset page when sort changes
}))
}
const setPage = (page: number) => {
setState(prev => ({
...prev,
page: Math.max(1, Math.min(page, totalPages)),
}))
}
const setLimit = (limit: number) => {
setState(prev => ({ ...prev, limit, page: 1 })) // Reset page when limit changes
}
return {
search: state.search,
createdSort: state.createdSort,
updatedSort: state.updatedSort,
page: state.page,
limit: state.limit,
totalPages,
totalItems: filteredMods.length,
setSearch,
toggleCreatedSort,
toggleUpdatedSort,
setPage,
setLimit,
mods: paginatedMods,
searchParams,
}
}

View file

@ -3,7 +3,14 @@
"index": { "index": {
"title": "Zen Browser", "title": "Zen Browser",
"hero": { "hero": {
"title": ["welcome", "to", "a", "calmer", "internet"], "title": [
{ "text": "welcome ", "highlight": false },
{ "text": "to ", "highlight": false },
{ "text": "\n", "highlight": false },
{ "text": "a ", "highlight": false },
{ "text": "calmer ", "highlight": true },
{ "text": "internet", "highlight": false }
],
"description": [ "description": [
"Beautifully designed, privacy-focused, and packed with features.", "Beautifully designed, privacy-focused, and packed with features.",
"We care about your experience, not your data." "We care about your experience, not your data."
@ -14,10 +21,8 @@
} }
}, },
"features": { "features": {
"title1": "Productivity", "titles": ["Productivity ", "at ", "its best"],
"title2": "at", "description": "Zen is packed with features that help you stay productive and focused. Browsers should be tools that help you get things done, not distractions that keep you from your work.",
"title3": "its best",
"description": "Zen Browser is packed with features that help you stay productive and focused. Browsers should be tools that help you get things done, not distractions that keep you from your work.",
"featureTabs": { "featureTabs": {
"workspaces": { "workspaces": {
"title": "Workspaces", "title": "Workspaces",
@ -48,7 +53,7 @@
} }
}, },
"community": { "community": {
"title": ["Our", "Core", "Values"], "title": ["Our ", "Core ", "Values"],
"description": "We make it not only a priority, but a necessity to ensure that Zen always strikes the right balance between beauty, performance, and privacy. We are committed to making Zen the most beautiful, productive, and privacy-respecting browser out there — without compromising on your experience.", "description": "We make it not only a priority, but a necessity to ensure that Zen always strikes the right balance between beauty, performance, and privacy. We are committed to making Zen the most beautiful, productive, and privacy-respecting browser out there — without compromising on your experience.",
"lists": { "lists": {
"freeAndOpenSource": { "freeAndOpenSource": {
@ -102,9 +107,8 @@
} }
}, },
"releaseNotes": { "releaseNotes": {
"title": "Release notes - Zen Browser",
"topSection": { "topSection": {
"title": "Release Notes", "title": "Changelog",
"description": "Stay up to date with the latest changes to Zen! Since the <a class=\"zen-link\" href=\"#1.0.0-a.1\">first release</a> till <a class=\"zen-link\" href=\"#{latestVersion}\">{latestVersion}</a>, we've been working hard to make Zen Browser the best it can be. Thanks everyone for your feedback! ❤️" "description": "Stay up to date with the latest changes to Zen! Since the <a class=\"zen-link\" href=\"#1.0.0-a.1\">first release</a> till <a class=\"zen-link\" href=\"#{latestVersion}\">{latestVersion}</a>, we've been working hard to make Zen Browser the best it can be. Thanks everyone for your feedback! ❤️"
}, },
"list": { "list": {
@ -256,25 +260,25 @@
} }
}, },
"download": { "download": {
"title": "Download - Zen Browser", "title": "Download Zen",
"description": "Download Zen Browser for your platform and experience a more mindful internet browsing experience. All downloads include SHA256 checksums for verification.", "description": "Download Zen for your platform and experience a more mindful internet browsing experience. All downloads include SHA256 checksums for verification.",
"twilightInfo": "You're currently in Twilight mode, this means you're downloading the latest experimental features and updates.", "twilightInfo": "You're currently in Twilight mode, this means you're downloading the latest experimental features and updates.",
"alertInfo": { "alertInfo": {
"description": "<strong class='font-medium text-zen-blue'>Twilight Mode:</strong> You're currently in Twilight mode, this means you're downloading the latest experimental features and updates." "description": "<strong class='font-medium text-zen-blue'>Twilight Mode:</strong> You're currently in Twilight mode, this means you're downloading the latest experimental features and updates."
}, },
"platformSelector": { "platformSelector": {
"title": "Platform Selector", "title": "Platform Selector",
"description": "Select your platform to download Zen Browser." "description": "Select your platform to download Zen."
}, },
"additionalResources": { "additionalResources": {
"title": "Additional Resources", "title": "Additional Resources",
"sourceCode": { "sourceCode": {
"title": "Source Code", "title": "Source Code",
"description": "Explore Zen Browser's source code on GitHub. Contribute to the project or build your own version." "description": "Explore Zen's source code on GitHub. Contribute to the project or build your own version."
}, },
"documentation": { "documentation": {
"title": "Documentation", "title": "Documentation",
"description": "Access comprehensive documentation, guides, and tutorials for Zen Browser." "description": "Access comprehensive documentation, guides, and tutorials for Zen."
} }
}, },
"securityNotice": { "securityNotice": {
@ -293,6 +297,20 @@
"mac": "Works on both new Apple (M-Series) and older Intel Macs.<br />Requires macOS 11.0 or later.", "mac": "Works on both new Apple (M-Series) and older Intel Macs.<br />Requires macOS 11.0 or later.",
"windows": "Works on Windows 10 and Windows 11.<br />Not sure which version to get? Most people should choose the 64-bit installer.", "windows": "Works on Windows 10 and Windows 11.<br />Not sure which version to get? Most people should choose the 64-bit installer.",
"linux": "Works with many Linux versions.<br />Pick the download that matches your system." "linux": "Works with many Linux versions.<br />Pick the download that matches your system."
},
"links": {
"macos": { "universal": "Universal" },
"windows": { "64bit": "64-bit (Recommended)", "ARM64": "ARM64" },
"linux": {
"flathub": "Flathub",
"x86_64": "Tarball",
"aarch64": "Tarball"
}
},
"buttonCard": {
"copy": "Copy",
"showChecksum": "Show SHA-256",
"beta": "Beta"
} }
}, },
"privacyPolicy": { "privacyPolicy": {
@ -301,97 +319,97 @@
"sections": { "sections": {
"introduction": { "introduction": {
"title": "Introduction", "title": "Introduction",
"body": "Welcome to Zen Browser! Your privacy is our priority. This Privacy Policy outlines the types of personal information we collect, how we use it, and the steps we take to protect your data when you use Zen Browser.", "body": "Welcome to Zen! Your privacy is our priority. This Privacy Policy outlines the types of personal information we collect, how we use it, and the steps we take to protect your data when you use Zen.",
"summary": "We don't sell data - We don't collect data - We don't track you" "summary": "We don't sell data - We don't collect data - We don't track you"
}, },
"noCollect": { "noCollect": {
"title": "1. Information We Do Not Collect", "title": "1. Information We Do Not Collect",
"body": "Zen Browser is designed with privacy in mind. We do not collect, store, or share any of your personal data. Here's what that means:" "body": "Zen is designed with privacy in mind. We do not collect, store, or share any of your personal data. Here's what that means:"
}, },
"noTelemetry": { "noTelemetry": {
"title": "1.1. No Telemetry", "title": "1.1. No Telemetry",
"body": "We do not collect any telemetry data or crash reports.", "body": "We do not collect any telemetry data or crash reports.",
"body2": "Zen Browser has stripped out telemetry built into Mozilla Firefox. We have removed all telemetry data collection and crash reports." "body2": "Zen has stripped out telemetry built into Mozilla Firefox. We have removed all telemetry data collection and crash reports."
}, },
"noPersonalData": { "noPersonalData": {
"title": "1.2. No Personal Data Collection", "title": "1.2. No Personal Data Collection",
"body": "Zen Browser does not collect any personal information such as your IP address, browsing history, search queries, or form data." "body": "Zen does not collect any personal information such as your IP address, browsing history, search queries, or form data."
}, },
"noThirdParty": { "noThirdParty": {
"title": "1.3. No Third-Party Tracking", "title": "1.3. No Third-Party Tracking",
"body": "We do not allow third-party trackers or analytics tools to operate within Zen Browser. Your browsing activity remains entirely private and is not shared with any third party. Mozilla is not considered a third party as it is the base of Zen Browser." "body": "We do not allow third-party trackers or analytics tools to operate within Zen. Your browsing activity remains entirely private and is not shared with any third party. Mozilla is not considered a third party as it is the base of Zen."
}, },
"externalConnections": { "externalConnections": {
"title": "1.4. External connections made at startup", "title": "1.4. External connections made at startup",
"body": "Zen Browser may make external connections at startup to check for updates and ensure the browser is up to date on plugins, addons, check for connectivity and Geolocation/push notifications services in order to comply with web standards. We, at Zen, do not collect any data from these connections, but they may be logged by third-party services or websites you visit. These connections are necessary for the proper functioning of the browser and are not used for tracking or profiling purposes. They can be disabled through the browser flags (about:config)." "body": "Zen may make external connections at startup to check for updates and ensure the browser is up to date on plugins, addons, check for connectivity and Geolocation/push notifications services in order to comply with web standards. We, at Zen, do not collect any data from these connections, but they may be logged by third-party services or websites you visit. These connections are necessary for the proper functioning of the browser and are not used for tracking or profiling purposes. They can be disabled through the browser flags (about:config)."
}, },
"localStorage": { "localStorage": {
"title": "2. Information Stored Locally on Your Device" "title": "2. Information Stored Locally on Your Device"
}, },
"browsingData": { "browsingData": {
"title": "2.1. Browsing Data", "title": "2.1. Browsing Data",
"body": "Zen Browser stores certain data locally on your device to enhance your browsing experience. This includes:" "body": "Zen stores certain data locally on your device to enhance your browsing experience. This includes:"
}, },
"cookies": { "cookies": {
"title": "Cookies", "title": "Cookies",
"body": "Cookies are stored locally on your device and are not shared with Zen Browser or any third party. You have full control over the management of cookies through the browser's settings." "body": "Cookies are stored locally on your device and are not shared with Zen or any third party. You have full control over the management of cookies through the browser's settings."
}, },
"cache": { "cache": {
"title": "Cache and Temporary Files", "title": "Cache and Temporary Files",
"body": "Zen Browser may store cache files and other temporary data locally to improve performance. These files can be cleared at any time through the browser's settings." "body": "Zen may store cache files and other temporary data locally to improve performance. These files can be cleared at any time through the browser's settings."
}, },
"settings": { "settings": {
"title": "2.2. Settings and Preferences", "title": "2.2. Settings and Preferences",
"body": "Any customizations, settings, and preferences you make within Zen Browser are stored locally on your device. We do not have access to or control over this data." "body": "Any customizations, settings, and preferences you make within Zen are stored locally on your device. We do not have access to or control over this data."
}, },
"sync": { "sync": {
"title": "3. Sync Feature", "title": "3. Sync Feature",
"body": "Zen Browser offers a \"Sync\" feature, which is implemented using Mozilla Firefox's Sync feature. This feature allows you to synchronize your bookmarks, history, passwords, and other data across multiple devices. For this feature to work, your data is encrypted and stored on Mozilla's servers and is treated in accordance with their Privacy Policy. We, at Zen, cannot view any of this data.", "body": "Zen offers a \"Sync\" feature, which is implemented using Mozilla Firefox's Sync feature. This feature allows you to synchronize your bookmarks, history, passwords, and other data across multiple devices. For this feature to work, your data is encrypted and stored on Mozilla's servers and is treated in accordance with their Privacy Policy. We, at Zen, cannot view any of this data.",
"link1": "Mozilla Firefox Sync", "link1": "Mozilla Firefox Sync",
"link2": "This is how we store your passwords" "link2": "This is how we store your passwords"
}, },
"addons": { "addons": {
"title": "4. Add-ons and \"Mods\"", "title": "4. Add-ons and \"Mods\"",
"body": "You can install Add-ons from addons.mozilla.org. Zen Browser periodically checks for updates to these Add-ons.\nYou can also install \"Mods\" from zen-browser.app/mods. These Mods are hosted by our services and follow the same privacy policy our website. We do not collect any data from these Mods, they are purely static content that is downloaded to your device." "body": "You can install Add-ons from addons.mozilla.org. Zen periodically checks for updates to these Add-ons.\nYou can also install \"Mods\" from zen-browser.app/mods. These Mods are hosted by our services and follow the same privacy policy our website. We do not collect any data from these Mods, they are purely static content that is downloaded to your device."
}, },
"security": { "security": {
"title": "5. Data Security", "title": "5. Data Security",
"body": "Although Zen Browser does not collect your data, we are committed to protecting the information that is stored locally on your device and, if you use the Sync feature, the encrypted data stored on Mozilla's servers. We recommend that you use secure passwords, enable device encryption, and regularly update your software to ensure your data remains safe.", "body": "Although Zen does not collect your data, we are committed to protecting the information that is stored locally on your device and, if you use the Sync feature, the encrypted data stored on Mozilla's servers. We recommend that you use secure passwords, enable device encryption, and regularly update your software to ensure your data remains safe.",
"note": "Note that most of the security measures are taken care by Mozilla Firefox." "note": "Note that most of the security measures are taken care by Mozilla Firefox."
}, },
"control": { "control": {
"title": "6. Your Control", "title": "6. Your Control",
"deletionTitle": "6.1. Data Deletion", "deletionTitle": "6.1. Data Deletion",
"deletionBody": "You have full control over all data stored locally on your device by Zen Browser. You can clear your browsing data, cookies, and cache at any time using the browser's settings." "deletionBody": "You have full control over all data stored locally on your device by Zen. You can clear your browsing data, cookies, and cache at any time using the browser's settings."
}, },
"website": { "website": {
"title": "7. Our Website and Services", "title": "7. Our Website and Services",
"body": "Zen Browser's website and services do not use any third-party analytics, tracking, or CDN services. We do not collect any personal information from users visiting our website. The website is hosted on Cloudflare but with analytics and tracking disabled, Cloudflare may collect some analytics data from HTTP requests in order to provide security and performance improvements. However, this data is not linked to any personal information and is not used for tracking purposes.", "body": "Zen's website and services do not use any third-party analytics, tracking, or CDN services. We do not collect any personal information from users visiting our website. The website is hosted on Cloudflare but with analytics and tracking disabled, Cloudflare may collect some analytics data from HTTP requests in order to provide security and performance improvements. However, this data is not linked to any personal information and is not used for tracking purposes.",
"externalLinksTitle": "7.1. External links", "externalLinksTitle": "7.1. External links",
"externalLinksBody": "Zen Browser may contain links to external websites or services that are not owned or operated by us. We are not responsible for the content or privacy practices of these sites. We recommend that you review the privacy policies of these sites before providing them with any personal information." "externalLinksBody": "Zen may contain links to external websites or services that are not owned or operated by us. We are not responsible for the content or privacy practices of these sites. We recommend that you review the privacy policies of these sites before providing them with any personal information."
}, },
"changes": { "changes": {
"title": "8. Changes to This Privacy Policy", "title": "8. Changes to This Privacy Policy",
"body": "We may update this Privacy Policy from time to time to reflect changes in our practices or legal requirements. We will notify you of any significant changes by updating the effective date at the top of this policy. Continued use of Zen Browser after such changes constitutes your acceptance of the new terms." "body": "We may update this Privacy Policy from time to time to reflect changes in our practices or legal requirements. We will notify you of any significant changes by updating the effective date at the top of this policy. Continued use of Zen after such changes constitutes your acceptance of the new terms."
}, },
"otherTelemetry": { "otherTelemetry": {
"title": "9. Other telemetry done by Mozilla Firefox", "title": "9. Other telemetry done by Mozilla Firefox",
"body": "We try to disable all telemetry data collection in Zen Browser. But, we may have missed some. Check the below links for more information.", "body": "We try to disable all telemetry data collection in Zen. But, we may have missed some. Check the below links for more information.",
"firefoxPrivacyNotice": "Firefox Privacy Notice", "firefoxPrivacyNotice": "Firefox Privacy Notice",
"forMoreInformation": "for more information." "forMoreInformation": "for more information."
}, },
"contact": { "contact": {
"title": "10. Contact Us", "title": "10. Contact Us",
"body": "If you have any questions or concerns about this Privacy Policy or Zen Browser, please contact us at:", "body": "If you have any questions or concerns about this Privacy Policy or Zen, please contact us at:",
"discord": "Discord: ", "discord": "Discord: ",
"discordLink": "Zen Browser's Discord", "discordLink": "Zen's Discord",
"github": "GitHub: ", "github": "GitHub: ",
"githubLink": "Organization" "githubLink": "Organization"
} }
} }
}, },
"welcome": { "welcome": {
"title": ["Welcome", "to", "Zen!"] "title": ["Welcome ", "to ", "Zen!"]
}, },
"whatsNew": { "whatsNew": {
"title": "What's New in {latestVersion.version}!", "title": "What's New in {latestVersion.version}!",
@ -412,11 +430,11 @@
}, },
"mods": { "mods": {
"title": "Zen Mods", "title": "Zen Mods",
"description": "Browse our diverse collection of Zen Mods, community-made plugins and themes for Zen Browser. Discover a theme to match every mood, and a plugin to fulfill every requirement. Start customizing your browser experience today!" "description": "Browse our diverse collection of Zen Mods, community-made plugins and themes for Zen. Discover a theme to match every mood, and a plugin to fulfill every requirement. Start customizing your browser experience today!"
}, },
"releaseNotes": { "releaseNotes": {
"title": "Release notes - Zen", "title": "Release notes - Zen",
"description": "Stay up to date with the latest changes to Zen Browser! Since the first release till {latestVersion}, we've been working hard to make Zen Browser the best it can be. Thanks everyone for your feedback! ❤️" "description": "Stay up to date with the latest changes to Zen! Since the first release till {latestVersion}, we've been working hard to make Zen the best it can be. Thanks everyone for your feedback! ❤️"
}, },
"about": { "about": {
"title": "About Zen", "title": "About Zen",
@ -428,11 +446,11 @@
}, },
"download": { "download": {
"title": "Download - Zen", "title": "Download - Zen",
"description": "Download Zen Browser for your platform and experience a more mindful internet browsing experience. All downloads include SHA256 checksums for verification." "description": "Download Zen for your platform and experience a more mindful internet browsing experience. All downloads include SHA256 checksums for verification."
}, },
"privacyPolicy": { "privacyPolicy": {
"title": "Privacy Policy - Zen", "title": "Privacy Policy - Zen",
"description": "Your privacy is our priority. This Privacy Policy outlines the types of personal information we collect, how we use it, and the steps we take to protect your data when you use Zen Browser." "description": "Your privacy is our priority. This Privacy Policy outlines the types of personal information we collect, how we use it, and the steps we take to protect your data when you use Zen."
}, },
"welcome": { "welcome": {
"title": "Welcome!", "title": "Welcome!",
@ -477,13 +495,13 @@
"releaseNotesDesc": "Stay up to date with the latest features and improvements.", "releaseNotesDesc": "Stay up to date with the latest features and improvements.",
"discordDesc": "Join our community on Discord to chat with other Zen users!", "discordDesc": "Join our community on Discord to chat with other Zen users!",
"donate": "Donate ❤️", "donate": "Donate ❤️",
"donateDesc": "Support the development of Zen Browser with a donation.", "donateDesc": "Support the development of Zen with a donation.",
"aboutUs": "About Us 🌟", "aboutUs": "About Us 🌟",
"aboutUsDesc": "Learn more about the team behind Zen Browser.", "aboutUsDesc": "Learn more about the team behind Zen.",
"documentation": "Documentation", "documentation": "Documentation",
"documentationDesc": "Learn how to use Zen Browser with our documentation.", "documentationDesc": "Learn how to use Zen with our documentation.",
"github": "GitHub", "github": "GitHub",
"githubDesc": "Contribute to the development of Zen Browser on GitHub.", "githubDesc": "Contribute to the development of Zen on GitHub.",
"menu": "Menu" "menu": "Menu"
} }
} }

View file

@ -0,0 +1,514 @@
{
"routes": {
"index": {
"title": "Zenブラウザー",
"hero": {
"title": [
{ "text": "ようこそ", "highlight": false },
{ "text": "\n", "highlight": false },
{ "text": "静かな", "highlight": true },
{ "text": "\n", "highlight": false },
{ "text": "インターネット", "highlight": false },
{ "text": "へ", "highlight": false }
],
"description": [
"美しいデザイン、プライバシー重視、機能満載。",
"私たちはあなたの体験を大切にし、データには関心がありません。"
],
"buttons": {
"beta": "ベータ版が利用可能です!",
"support": "サポートする ❤️"
}
},
"features": {
"titles": ["生産性", "の", "極み"],
"description": "Zenは、生産性と集中力を高める機能が満載です。ブラウザーは作業の妨げではなく、作業を助けるツールであるべきです。",
"featureTabs": {
"workspaces": {
"title": "ワークスペース",
"description": "タブをワークスペースごとに整理し、プロジェクトごとに分けて管理。簡単に切り替え可能です。"
},
"compactMode": {
"title": "コンパクトモード",
"description": "Zenのコンパクトモードは、必要ないときにタブバーを隠し、必要なときに表示して画面を広く使えます。"
},
"glance": {
"title": "覗き見",
"description": "覗き見機能で、よく使うタブを素早く切り替え。履歴をスクロールする必要はありません。"
},
"splitView": {
"title": "画面分割",
"description": "画面分割機能で、2つのタブを並べて表示。比較や切り替えが簡単です。"
}
}
},
"sponsors": {
"title": "スポンサー",
"description": "ご支援いただいているスポンサーの皆様に感謝します。<br />あなたも<a href=\"/donate\" class=\"zen-link\">直接寄付</a>でこの旅に参加できます!",
"sponsors": {
"tuta": {
"name": "Tuta",
"url": "https://tuta.com/"
}
}
},
"community": {
"title": ["私たちの", "コア", "バリュー"],
"description": "Zenは美しさ、パフォーマンス、プライバシーのバランスを最優先にしています。最高の体験を妥協せずに提供します。",
"lists": {
"freeAndOpenSource": {
"title": "無料・オープンソース",
"description": "Zenは無料でオープンソース。誰でも自由に使え、カスタマイズできます。"
},
"simpleYetPowerful": {
"title": "シンプルでパワフル",
"description": "Zenは使いやすく、日常の作業も十分にこなせます。"
},
"privateAndAlwaysUpToDate": {
"title": "プライバシー重視・常に最新",
"description": "Zenはプライバシーを守り、常に最新。無料で使え、カスタマイズも可能です。"
}
},
"images": {
"community": {
"alt": "コミュニティ"
}
}
}
},
"mods": {
"title": "Zen Mods",
"description": "Zen Browser用の多彩なModプラグイン・テーマを探そう。気分やニーズに合ったテーマやプラグインで、ブラウザー体験をカスタマイズ",
"pagination": {
"pagination": "{input} / {totalPages}(全{totalItems}件)"
},
"search": "検索ワードを入力...",
"sort": {
"lastCreated": "新着順",
"lastUpdated": "更新順",
"perPage": "表示件数"
},
"noResults": "結果が見つかりません",
"noResultsDescription": "別のキーワードで検索するか、後でもう一度お試しください。",
"slug": {
"title": "{name} - Zen Mods",
"description": "{name} Modの詳細Zen用",
"alert": {
"description": "このテーマをインストールするにはZenが必要です。",
"button": "今すぐダウンロード!"
},
"createdBy": "作成者:<a href={link} class=\"zen-link font-bold\">{author}</a> • <span class=\"font-bold\">v{version}</span>",
"creationDate": "作成日 • <b>{createdAt}</b>",
"latestUpdate": "最終更新 • <b>{updatedAt}</b>",
"visitModHomepage": "Modのホームページへ",
"installMod": "Modをインストール 🎉",
"uninstallMod": "Modをアンインストール",
"back": "戻る"
}
},
"releaseNotes": {
"topSection": {
"title": "変更履歴",
"description": "Zenの最新情報はこちら<a class=\"zen-link\" href=\"#1.0.0-a.1\">最初のリリース</a>から<a class=\"zen-link\" href=\"#{latestVersion}\">{latestVersion}</a>まで、最高のブラウザーを目指して努力しています。ご意見ありがとうございます!❤️"
},
"list": {
"support": "応援してください!",
"navigateToVersion": "バージョンへ移動..."
},
"itemType": {
"fix": "修正",
"feature": "追加",
"known": "既知",
"break": "重大",
"theme": "テーマ",
"security": "セキュリティ"
},
"backToTop": "トップへ戻る",
"chooseVersion": "バージョンを選択",
"components": {
"releaseNoteItem": {
"twilight": "Twilight",
"twilightChanges": "Twilightの変更点",
"releaseChanges": "v{version}",
"firefoxVersion": "Firefox {version}",
"githubRelease": "GitHubリリース",
"workflowRun": "ワークフロー実行",
"compareChanges": "変更を比較",
"twilightWarning": "TwilightはZen Browserのプレリリース版です。不具合や未完成の機能が含まれる場合があります。",
"reportIssues": " 問題が発生した場合は、<a rel=\"noopener noreferrer\" target=\"_blank\" href=\"https://github.com/zen-browser/desktop/issues/\" class=\"zen-link\">issueページ</a>でご報告ください。",
"learnMore": "詳細はこちら",
"viewIssue": "GitHubのIssue番号{issue}を見る"
}
},
"slug": {
"title": "リリースノート",
"redirect": "バージョン{version}のリリースノートにリダイレクト中..."
}
},
"about": {
"title": "Zenについて",
"description": "私たちは、ウェブ体験を大切にする開発者とデザイナーの集まりです。インターネットは、データ収集を心配せずに探索・学習・交流できる場所であるべきだと信じています。",
"littleHelp": "応援しませんか?",
"mainTeam": {
"title": "メインチーム",
"description": "最高のブラウジング体験を提供するために努力しているメンバーです。",
"subTitle": {
"browser": "ブラウザー",
"website": "ウェブサイト・ブランディング"
},
"members": {
"browser": {
"mauro": {
"name": "Mauro B.",
"description": "クリエイター・メイン開発者",
"link": "https://cheff.dev/"
},
"jan": {
"name": "Jan Heres",
"description": "MacOSビルド担当・貢献者",
"link": "https://janheres.eu/"
},
"bryan": {
"name": "Bryan Galdámez",
"description": "テーマ機能の大貢献者",
"link": "https://josuegalre.netlify.app/"
},
"oscar": {
"name": "Oscar Gonzalez",
"description": "SRE・コード署名担当",
"link": false
},
"daniel": {
"name": "Daniel García",
"description": "MacOS証明書・公証管理",
"link": false
},
"brhm": {
"name": "BrhmDev",
"description": "大きな貢献をしている開発者",
"link": "https://github.com/BrhmDev"
},
"kristijanribaric": {
"name": "Kristijan Ribaric",
"description": "スプリットビュー・ワークスペース担当",
"link": "https://github.com/kristijanribaric"
},
"larvey": {
"name": "Larvey",
"description": "AUR管理者",
"link": "https://github.com/LarveyOfficial/"
},
"studio": {
"name": "Studio Movie Girl",
"description": "グラデーションジェネレーターの貢献者",
"link": "https://github.com/neurokitti"
}
},
"website": {
"taroj1205": {
"name": "Shintaro Jokagi",
"description": "コアウェブサイトアーキテクト、リファクタリング・技術強化担当",
"link": "https://github.com/taroj1205"
},
"jace": {
"name": "Jace",
"description": "ウェブサイトデザイン・ブランディング担当",
"link": "https://x.com/JaceThings"
},
"canoa": {
"name": "Canoa",
"description": "活発な貢献者・ウェブサイト管理",
"link": "https://thatcanoa.org/"
},
"adam": {
"name": "Adam",
"description": "ブランディング・デザイン",
"link": "https://cybrneon.xyz/"
},
"n7itro": {
"name": "n7itro",
"description": "リリースノート執筆・貢献者",
"link": "https://github.com/n7itro"
},
"jafeth": {
"name": "Jafeth Garro",
"description": "ドキュメント執筆",
"link": "https://iamjafeth.com/"
}
}
}
},
"contributors": {
"title": "コントリビューター",
"description": "Zenの発展に貢献してくださった皆様です。",
"browser": "ブラウザー",
"website": "ウェブサイト"
}
},
"donate": {
"title": "寄付",
"description": "私たちは少人数の開発チームです。ご支援いただけると幸いです。",
"patreon": {
"title": "Patreon",
"description": "Patreonで毎月のご支援が可能です。ご自身に合った支援レベルをお選びください。",
"button": "Patreonへ"
},
"koFi": {
"title": "Ko-fi",
"description": "Ko-fiで一度きり、または毎月のご支援が可能です。ご希望の金額をお選びください。",
"button": "Ko-fiへ"
}
},
"download": {
"title": "Zenをダウンロードする",
"description": "お使いのプラットフォーム向けにZenをダウンロード。すべてのダウンロードにはSHA256チェックサムが付属しています。",
"twilightInfo": "現在Twilightモードです。最新の実験的機能とアップデートをダウンロードしています。",
"alertInfo": {
"description": "<strong class='font-medium text-zen-blue'>Twilightモード:</strong> 現在Twilightモードで、最新の実験的機能とアップデートをダウンロードしています。"
},
"platformSelector": {
"title": "プラットフォーム選択",
"description": "お使いのプラットフォームを選択してZenをダウンロード。"
},
"additionalResources": {
"title": "追加リソース",
"sourceCode": {
"title": "ソースコード",
"description": "GitHubでZenのソースコードを閲覧・貢献・ビルドできます。"
},
"documentation": {
"title": "ドキュメント",
"description": "Zenの包括的なドキュメント・ガイド・チュートリアル。"
}
},
"securityNotice": {
"title": "検証済み・安全なダウンロード",
"description": "すべてのZenダウンロードは署名・検証済みです。公式サイトまたはGitHubからのダウンロードを推奨します。ダウンロードに問題がある場合やウイルス対策で警告が出た場合は、<a href='https://github.com/zen-browser/desktop/issues/new/choose' class='zen-link ml-1'>ご報告ください</a>。"
},
"platformNames": {
"mac": "macOS",
"windows": "Windows",
"linux": "Linux",
"macDownload": "MacOSダウンロード",
"windowsDownload": "Windowsダウンロード",
"linuxDownload": "Linuxダウンロード"
},
"platformDescriptions": {
"mac": "AppleMシリーズ・Intel両対応。<br />macOS 11.0以降が必要です。",
"windows": "Windows 10・11対応。<br />どちらを選ぶか迷った場合は64ビット版を推奨します。",
"linux": "多くのLinuxディストリビューションで動作。<br />お使いのシステムに合ったものを選択してください。"
},
"links": {
"macos": {
"universal": "ユニバーサル"
},
"windows": {
"64bit": "64-ビット(推奨)",
"ARM64": "ARM64"
},
"linux": {
"flathub": "Flathub",
"x86_64": "Tarball",
"aarch64": "Tarball"
}
},
"buttonCard": {
"copy": "コピー",
"showChecksum": "SHA-256を表示",
"beta": "ベータ"
}
},
"privacyPolicy": {
"title": "プライバシーポリシー",
"lastUpdated": "最終更新: 2025-02-5",
"sections": {
"introduction": {
"title": "はじめに",
"body": "Zenへようこそあなたのプライバシーは最優先です。本ポリシーでは、収集する情報の種類、利用方法、保護手段について説明します。",
"summary": "データ販売なし - データ収集なし - トラッキングなし"
},
"noCollect": {
"title": "1. 収集しない情報",
"body": "Zenはプライバシー重視で設計されています。個人データを収集・保存・共有しません。"
},
"noTelemetry": {
"title": "1.1. テレメトリーなし",
"body": "テレメトリーデータやクラッシュレポートは収集しません。",
"body2": "Mozilla Firefoxに組み込まれているテレメトリーも削除しています。"
},
"noPersonalData": {
"title": "1.2. 個人データの収集なし",
"body": "IPアドレス、閲覧履歴、検索クエリ、フォームデータなどの個人情報は一切収集しません。"
},
"noThirdParty": {
"title": "1.3. サードパーティトラッキングなし",
"body": "サードパーティのトラッカーや解析ツールは一切許可していません。Mozillaはベースであり、サードパーティではありません。"
},
"externalConnections": {
"title": "1.4. 起動時の外部接続",
"body": "Zenは起動時にアップデート確認や接続性・ジオロケーション/プッシュ通知サービスのため外部接続を行う場合があります。これらの接続は機能上必要で、トラッキングやプロファイリング目的ではありません。about:configで無効化可能です。"
},
"localStorage": {
"title": "2. デバイスに保存される情報"
},
"browsingData": {
"title": "2.1. 閲覧データ",
"body": "Zenは体験向上のため、いくつかのデータをローカルに保存します。"
},
"cookies": {
"title": "クッキー",
"body": "クッキーはローカルに保存され、Zenや第三者と共有されません。管理はブラウザー設定から可能です。"
},
"cache": {
"title": "キャッシュ・一時ファイル",
"body": "パフォーマンス向上のためキャッシュや一時データを保存します。設定からいつでも削除可能です。"
},
"settings": {
"title": "2.2. 設定・プリファレンス",
"body": "カスタマイズや設定はすべてローカルに保存され、私たちがアクセスすることはありません。"
},
"sync": {
"title": "3. 同期機能",
"body": "ZenはMozilla FirefoxのSync機能を利用しています。データは暗号化されMozillaのサーバーに保存されます。私たちは内容を閲覧できません。",
"link1": "Mozilla Firefox Sync",
"link2": "パスワードの保存方法"
},
"addons": {
"title": "4. アドオン・Mod",
"body": "MozillaのアドオンやZen Modsをインストール可能です。Zen Modsは当社サービスでホストされ、データ収集はありません。"
},
"security": {
"title": "5. データセキュリティ",
"body": "Zenはデータを収集しませんが、ローカルやMozillaサーバー上のデータ保護に努めています。安全なパスワードやデバイス暗号化、ソフトウェアの定期更新を推奨します。",
"note": "多くのセキュリティ対策はMozilla Firefoxによって提供されています。"
},
"control": {
"title": "6. コントロール",
"deletionTitle": "6.1. データ削除",
"deletionBody": "Zenが保存するすべてのローカルデータは、設定からいつでも削除できます。"
},
"website": {
"title": "7. ウェブサイト・サービス",
"body": "Zenのウェブサイト・サービスはサードパーティの解析やCDNを使用しません。Cloudflareでホストされていますが、解析・トラッキングは無効化されています。",
"externalLinksTitle": "7.1. 外部リンク",
"externalLinksBody": "Zenには外部サイトへのリンクが含まれる場合があります。内容やプライバシーについては各サイトのポリシーをご確認ください。"
},
"changes": {
"title": "8. ポリシーの変更",
"body": "本ポリシーは必要に応じて更新されます。重要な変更時は日付を更新し、継続利用で同意したものとみなします。"
},
"otherTelemetry": {
"title": "9. Mozilla Firefoxによるその他のテレメトリー",
"body": "すべてのテレメトリー無効化に努めていますが、見落としがある場合もあります。詳細は下記リンクをご参照ください。",
"firefoxPrivacyNotice": "Firefoxプライバシー通知",
"forMoreInformation": "詳細はこちら。"
},
"contact": {
"title": "10. お問い合わせ",
"body": "本ポリシーやZenに関するご質問は下記までご連絡ください",
"discord": "Discord: ",
"discordLink": "ZenのDiscord",
"github": "GitHub: ",
"githubLink": "Organization"
}
}
},
"welcome": {
"title": ["ようこそ", "Zenへ", "!"]
},
"whatsNew": {
"title": "{latestVersion.version}の新機能!",
"reportIssue": "問題を報告する",
"joinDiscord": "Discordに参加",
"readFullReleaseNotes": "リリースノート全文を読む"
},
"notFound": {
"title": "ページが見つかりません",
"description": "お探しのページは存在しないか、移動されました。",
"button": "ホームへ戻る"
}
},
"layout": {
"index": {
"title": "Zenブラウザー",
"description": "美しいデザイン、プライバシー重視、機能満載。"
},
"mods": {
"title": "Zen Mods",
"description": "Zen用の多彩なModプラグイン・テーマを探そう。気分やニーズに合ったテーマやプラグインで、ブラウザ体験をカスタマイズ"
},
"releaseNotes": {
"title": "リリースノート - Zen",
"description": "Zenの最新情報はこちら最初のリリースから{latestVersion}まで、最高のブラウザを目指して努力しています。ご意見ありがとうございます!❤️"
},
"about": {
"title": "Zenについて",
"description": "私たちは、ウェブ体験を大切にする開発者とデザイナーの集まりです。インターネットは、データ収集を心配せずに探索・学習・交流できる場所であるべきだと信じています。"
},
"donate": {
"title": "寄付 - Zen",
"description": "私たちは少人数の開発チームです。ご支援いただけると幸いです。"
},
"download": {
"title": "Zenをダウンロードする",
"description": "お使いのプラットフォーム向けにZenをダウンロード。すべてのダウンロードにはSHA256チェックサムが付属しています。"
},
"privacyPolicy": {
"title": "プライバシーポリシー - Zen",
"description": "あなたのプライバシーは最優先です。本ポリシーでは、収集する情報の種類、利用方法、保護手段について説明します。"
},
"welcome": {
"title": "ようこそ!",
"description": "Zenへようこそ"
},
"whatsNew": {
"title": "{latestVersion.version}の新機能!"
}
},
"components": {
"footer": {
"title": "Zenブラウザー",
"description": "美しいデザイン、プライバシー重視、機能満載。私たちはあなたのデータではなく、体験を大切にします。",
"download": "ダウンロード",
"followUs": "フォローする",
"aboutUs": "私たちについて",
"teamAndContributors": "チーム・コントリビューター",
"privacyPolicy": "プライバシーポリシー",
"getStarted": "はじめに",
"documentation": "ドキュメント",
"zenMods": "Zen Mods",
"releaseNotes": "リリースノート",
"getHelp": "ヘルプ",
"discord": "Discord",
"uptimeStatus": "稼働状況",
"reportAnIssue": "問題を報告",
"twilight": "Twilight",
"madeWith": "<span aria-label='love'>❤️</span>と共に<a href='{link}' class='zen-link inline-block font-bold'>Zenチーム</a>が作りました"
},
"nav": {
"brand": "Zenブラウザー",
"menu": {
"gettingStarted": "はじめに",
"usefulLinks": "便利なリンク",
"mods": "Mods",
"download": "ダウンロード",
"discord": "Discord",
"releaseNotes": "リリースノート",
"zenMods": "Zen Mods",
"tryZenMods": "Zen Modsを試す",
"zenModsDesc": "Zen Modsでブラウザ体験をカスタマイズ。",
"releaseNotesDesc": "最新機能・改善情報はこちら。",
"discordDesc": "Discordコミュニティで他のZenユーザーと交流",
"donate": "寄付 ❤️",
"donateDesc": "Zen開発を寄付で応援。",
"aboutUs": "私たちについて 🌟",
"aboutUsDesc": "Zenのチームについて知る。",
"documentation": "ドキュメント",
"documentationDesc": "ドキュメントでZenの使い方を学ぶ。",
"github": "GitHub",
"githubDesc": "GitHubでZen開発に貢献。",
"menu": "メニュー"
}
}
}
}

View file

@ -97,7 +97,9 @@ const locale = getLocale(Astro)
) )
</script> </script>
</head> </head>
<body class="overflow-x-hidden text-balance bg-paper font-['bricolage-grotesque'] text-dark"> <body
class="min-h-[max(100dvh, 64rem)] grid grid-rows-[auto,1fr,auto] overflow-x-hidden text-balance bg-paper font-['bricolage-grotesque'] text-dark"
>
<NavBar /> <NavBar />
<slot /> <slot />
<Footer /> <Footer />

View file

@ -7,6 +7,7 @@ import { getLocale, getPath, getUI } from '~/utils/i18n'
export { getStaticPaths } from '~/utils/i18n' export { getStaticPaths } from '~/utils/i18n'
const locale = getLocale(Astro) const locale = getLocale(Astro)
console.log(Astro.currentLocale)
const getLocalePath = getPath(locale) const getLocalePath = getPath(locale)
const { const {
routes: { notFound }, routes: { notFound },

View file

@ -27,7 +27,7 @@ const appleIcon = icon({ prefix: 'fab', iconName: 'apple' })
const githubIcon = icon({ prefix: 'fab', iconName: 'github' }) const githubIcon = icon({ prefix: 'fab', iconName: 'github' })
const checksums = await getChecksums() const checksums = await getChecksums()
const releases = getReleasesWithChecksums(checksums) const releases = getReleasesWithChecksums(locale)(checksums)
const platformNames = download.platformNames const platformNames = download.platformNames
const platformDescriptions = download.platformDescriptions const platformDescriptions = download.platformDescriptions

View file

@ -6,7 +6,7 @@ import ArrowRightIcon from '~/icons/ArrowRightIcon.astro'
import InfoIcon from '~/icons/InfoIcon.astro' import InfoIcon from '~/icons/InfoIcon.astro'
import Layout from '~/layouts/Layout.astro' import Layout from '~/layouts/Layout.astro'
import { getAllMods, getAuthorLink, getLocalizedDate } from '~/mods' import { getAllMods, getAuthorLink, getLocalizedDate } from '~/mods'
import { getUI } from '~/utils/i18n' import { getPath, getUI } from '~/utils/i18n'
import { getLocale, getOtherLocales } from '~/utils/i18n' import { getLocale, getOtherLocales } from '~/utils/i18n'
export async function getStaticPaths() { export async function getStaticPaths() {
@ -44,7 +44,9 @@ const dates = {
updatedAt: getLocalizedDate(mod.updatedAt), updatedAt: getLocalizedDate(mod.updatedAt),
} }
const locale = getLocale(Astro as { params: { locale?: string } }) const locale = getLocale(Astro)
const getLocalePath = getPath(locale)
const { const {
routes: { routes: {
@ -58,8 +60,8 @@ const {
description={slug.description.replace('{name}', mod.name)} description={slug.description.replace('{name}', mod.name)}
ogImage={mod.image} ogImage={mod.image}
> >
<main class="mt-6 2xl:mt-0"> <main class="container mt-6 2xl:mt-0">
<div class="mx-auto mb-24 mt-12 flex flex-col gap-6 px-8 lg:mt-32 lg:w-1/2"> <div class="mx-auto mb-24 mt-12 flex flex-col gap-6 lg:mt-32">
<div <div
id="install-theme-error" id="install-theme-error"
class="flex flex-col items-center justify-center gap-2 rounded-xl bg-red-200 p-2 pl-4 md:flex-row md:justify-between dark:bg-red-700" class="flex flex-col items-center justify-center gap-2 rounded-xl bg-red-200 p-2 pl-4 md:flex-row md:justify-between dark:bg-red-700"
@ -79,7 +81,7 @@ const {
<ArrowRightIcon class="size-4" /> <ArrowRightIcon class="size-4" />
</Button> </Button>
</div> </div>
<BackButton /> <BackButton id="back-button" href={getLocalePath('/mods')} />
<div> <div>
<Description class="text-6xl font-bold">{mod.name}</Description> <Description class="text-6xl font-bold">{mod.name}</Description>
<Description>{mod.description}</Description> <Description>{mod.description}</Description>
@ -132,3 +134,13 @@ const {
<div></div> <div></div>
</main></Layout </main></Layout
> >
<script>
const backButton = document.getElementById('back-button') as HTMLAnchorElement
const search = window.location.search
if (search.length > 0) {
const searchParams = new URLSearchParams(search)
const backLink = `${backButton.href}?${searchParams.toString()}`
backButton.href = backLink
}
</script>

View file

@ -1,6 +1,6 @@
--- ---
import Description from '~/components/Description.astro' import Description from '~/components/Description.astro'
import ModsList from '~/components/ModsList' import ModsList from '~/components/ModsList.astro'
import { CONSTANT } from '~/constants' import { CONSTANT } from '~/constants'
import Layout from '~/layouts/Layout.astro' import Layout from '~/layouts/Layout.astro'
import { getAllMods } from '~/mods' import { getAllMods } from '~/mods'
@ -27,6 +27,10 @@ const allMods = (await getAllMods()) || []
</header> </header>
<!-- Importing ModList component --> <!-- Importing ModList component -->
<ModsList allMods={allMods} locale={locale ?? CONSTANT.I18N.DEFAULT_LOCALE} client:load /> <ModsList
allMods={allMods}
locale={locale ?? CONSTANT.I18N.DEFAULT_LOCALE}
translations={mods}
/>
</main> </main>
</Layout> </Layout>

View file

@ -22,7 +22,7 @@ const {
class="container flex h-full min-h-[1000px] flex-1 flex-col items-center justify-center py-4" class="container flex h-full min-h-[1000px] flex-1 flex-col items-center justify-center py-4"
> >
<div id="release-notes" class="py-42 flex min-h-screen w-full flex-col justify-center gap-8"> <div id="release-notes" class="py-42 flex min-h-screen w-full flex-col justify-center gap-8">
<Description class="mt-48 text-6xl font-bold">Changelog</Description> <Description class="mt-48 text-6xl font-bold">{releaseNotes.topSection.title}</Description>
<p <p
class="text-base opacity-55" class="text-base opacity-55"
set:html={releaseNotes.topSection.description.replaceAll( set:html={releaseNotes.topSection.description.replaceAll(
@ -34,7 +34,7 @@ const {
<Button class="flex" isPrimary href="/donate"> <Button class="flex" isPrimary href="/donate">
{releaseNotes.list.support} {releaseNotes.list.support}
</Button> </Button>
<Button id="navigate-to-version" href="#" class="flex"> <Button id="navigate-to-version" href="#" class="flex" localePath={false}>
{releaseNotes.list.navigateToVersion} {releaseNotes.list.navigateToVersion}
</Button> </Button>
</div> </div>
@ -46,7 +46,7 @@ const {
{releaseNotesData.map(notes => <ReleaseNoteItem {...notes} />)} {releaseNotesData.map(notes => <ReleaseNoteItem {...notes} />)}
</div> </div>
</main> </main>
<Button href="#" id="scroll-top" isPrimary class="fixed bottom-8 right-8"> <Button id="scroll-top" isPrimary class="fixed bottom-8 right-8" onclick="window.scrollTo(0, 0)">
<p class="hidden items-center gap-2 sm:flex"> <p class="hidden items-center gap-2 sm:flex">
{releaseNotes.backToTop} {releaseNotes.backToTop}
<ArrowUpIcon aria-hidden="true" class="size-4" /> <ArrowUpIcon aria-hidden="true" class="size-4" />

View file

@ -14,6 +14,6 @@ const {
<Layout title={layout.welcome.title} description={layout.welcome.description}> <Layout title={layout.welcome.title} description={layout.welcome.description}>
<main class="container"> <main class="container">
<Features title1={welcome.title[0]} title2={welcome.title[1]} title3={welcome.title[2]} /> <Features titles={welcome.title} />
</main> </main>
</Layout> </Layout>

View file

@ -11,7 +11,7 @@ describe('getReleasesWithChecksums', () => {
'zen.linux-x86_64.tar.xz': 'linx86sum', 'zen.linux-x86_64.tar.xz': 'linx86sum',
'zen.linux-aarch64.tar.xz': 'linaarchsum', 'zen.linux-aarch64.tar.xz': 'linaarchsum',
} }
const releases = getReleasesWithChecksums(checksums) const releases = getReleasesWithChecksums('en')(checksums)
expect(releases.macos.universal.checksum).toBe('macsum') expect(releases.macos.universal.checksum).toBe('macsum')
expect(releases.windows.x86_64.checksum).toBe('winsum') expect(releases.windows.x86_64.checksum).toBe('winsum')
expect(releases.windows.arm64.checksum).toBe('winarmsum') expect(releases.windows.arm64.checksum).toBe('winarmsum')

View file

@ -79,9 +79,10 @@ test.describe('Download page platform detection and tab switching', () => {
}) })
test.describe('Download page download links', () => { test.describe('Download page download links', () => {
const releases = getReleasesWithChecksums(CONSTANT.CHECKSUMS) const releases = getReleasesWithChecksums('en')(CONSTANT.CHECKSUMS)
function getPlatformLinks(releases: ReturnType<typeof getReleasesWithChecksums>) { type Releases = ReturnType<ReturnType<typeof getReleasesWithChecksums>>
function getPlatformLinks(releases: Releases) {
return { return {
mac: [releases.macos.universal], mac: [releases.macos.universal],
windows: [releases.windows.x86_64, releases.windows.arm64], windows: [releases.windows.x86_64, releases.windows.arm64],

View file

@ -5,7 +5,8 @@ test('clicking back button navigates to previous page', async ({ page }) => {
const currentUrl = page.url() const currentUrl = page.url()
const modCards = await page.locator('.mod-card').all() const modCards = await page.locator('.mod-card').all()
await modCards[0].click() await modCards[0].click()
await page.getByRole('button', { name: 'Back' }).click() await page.waitForURL('/mods/*')
await page.getByTestId('back-button').click()
await page.waitForURL(currentUrl) await page.waitForURL(currentUrl)
expect(page.url()).toStrictEqual(currentUrl) expect(page.url()).toStrictEqual(currentUrl)
}) })

View file

@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'
test('all routes do not return 404', async ({ page }) => { test('all routes do not return 404', async ({ page }) => {
const routes = ['/', '/welcome', '/about', '/privacy-policy', '/download', '/donate', '/whatsnew'] const routes = ['/', '/welcome', '/about', '/privacy-policy', '/download', '/donate', '/whatsnew']
for (const route of routes) { for (const route of routes) {
const response = await page.goto(route) const response = await page.goto(route, { waitUntil: 'domcontentloaded' })
expect(response?.status()).not.toBe(404) expect(response?.status()).not.toBe(404)
} }
}) })

View file

@ -1,7 +1,7 @@
import { type GetStaticPaths } from 'astro' import { type AstroGlobal, type GetStaticPaths } from 'astro'
import { CONSTANT } from '~/constants' import { CONSTANT } from '~/constants'
import UI_EN from '~/i18n/en/translation.json' import { type UIProps } from '~/constants/i18n'
/** /**
* Represents the available locales in the application * Represents the available locales in the application
@ -22,17 +22,14 @@ export const getPath = (locale?: Locale) => (path: string) => {
} }
/** /**
* Extracts the current locale from Astro's params, defaulting to the default locale * Retrieves the current locale from the Astro object.
* @param {Object} Astro - Astro's context object *
* @param {Object} [Astro.params] - Routing parameters * @param Astro - The Astro object containing the current locale information
* @param {string} [Astro.params.locale] - The current locale parameter * @param Astro.currentLocale - The current locale string from Astro
* @returns {Locale} The determined locale * @returns The current locale cast as a Locale type
*/ */
export const getLocale = (Astro: { params?: { locale?: string } }) => { export const getLocale = (Astro: AstroGlobal): Locale => {
if (Astro.params?.locale) { return Astro.currentLocale as Locale
return Astro.params.locale as Locale
}
return CONSTANT.I18N.DEFAULT_LOCALE as Locale
} }
/** /**
@ -47,7 +44,7 @@ export const locales = CONSTANT.I18N.LOCALES.map(({ value }) => value)
*/ */
const otherLocales = CONSTANT.I18N.LOCALES.filter( const otherLocales = CONSTANT.I18N.LOCALES.filter(
({ value }) => value !== CONSTANT.I18N.DEFAULT_LOCALE ({ value }) => value !== CONSTANT.I18N.DEFAULT_LOCALE
) ).map(({ value }) => value)
/** /**
* Retrieves locales other than the default locale * Retrieves locales other than the default locale
@ -55,81 +52,79 @@ const otherLocales = CONSTANT.I18N.LOCALES.filter(
*/ */
export const getOtherLocales = () => otherLocales export const getOtherLocales = () => otherLocales
/**
* Type definition for UI translations based on the English translation
* @typedef {Object} UI
*/
export type UI = typeof UI_EN
/**
* Mapping of locales to their UI translation objects
* @type {Object.<Locale, UI>}
*/
export const ui = { en: UI_EN }
/** /**
* Retrieves UI translations for a given locale, merging with default translations * Retrieves UI translations for a given locale, merging with default translations
* @param {Locale} [locale] - The target locale for translations * @param {Locale} [locale] - The target locale for translations
* @returns {UI} Merged UI translations * @returns {UI} Merged UI translations
*/ */
export const getUI = (locale?: Locale | string): UI => { export const getUI = (locale?: Locale | string): UIProps => {
const validLocale = locales.includes(locale as Locale) ? locale : CONSTANT.I18N.DEFAULT_LOCALE const validLocale = locales.includes(locale as Locale) ? locale : CONSTANT.I18N.DEFAULT_LOCALE
const defaultUI = ui[CONSTANT.I18N.DEFAULT_LOCALE] const defaultUI = CONSTANT.I18N.LOCALES.find(
const localeUI = ui[validLocale as Locale] ({ value }) => value === CONSTANT.I18N.DEFAULT_LOCALE
)?.ui
const localeUI = CONSTANT.I18N.LOCALES.find(({ value }) => value === validLocale)?.ui
/** // Helper to recursively check for missing keys
* Recursively merges two objects, with the override object taking precedence function checkMismatch(
* @template T defaultObj: UIProps,
* @param {T} defaultObj - The default object to merge from localeObj: Partial<UIProps> = {},
* @param {Partial<T>} overrideObj - The object to merge over the default path: string[] = []
* @returns {T} The deeply merged object ): void {
*/ if (typeof defaultObj !== 'object' || defaultObj === null) return
function deepMerge<T extends object>(defaultObj: T, overrideObj: Partial<T>): T { for (const key of Object.keys(defaultObj) as (keyof UIProps)[]) {
// Handle non-object cases if (!(key in localeObj)) {
if (typeof defaultObj !== 'object' || defaultObj === null) { console.error(
return (overrideObj ?? defaultObj) as T `[i18n] Missing translation key: ${[...path, key as string].join('.')} in locale '\x1b[1m${validLocale}\x1b[0m'. See src/i18n/${validLocale}/translation.json`
}
if (typeof overrideObj !== 'object' || overrideObj === null) {
return (overrideObj ?? defaultObj) as T
}
// Create a new object or array based on the default object's type
const result = Array.isArray(defaultObj) ? [...defaultObj] : { ...defaultObj }
// Merge properties from the default object
for (const key of Object.keys(defaultObj) as (keyof T)[]) {
const defaultValue = defaultObj[key]
const overrideValue = overrideObj[key]
// Recursively merge nested objects
if (
defaultValue !== null &&
overrideValue !== null &&
typeof defaultValue === 'object' &&
typeof overrideValue === 'object'
) {
// Type assertion to handle nested merging
;(result as Record<keyof T, unknown>)[key] = deepMerge(
defaultValue as object,
overrideValue as Partial<object>
) )
} else if (overrideValue !== undefined) { } else if (
// Override with the new value if it exists typeof defaultObj[key] === 'object' &&
;(result as Record<keyof T, unknown>)[key] = overrideValue defaultObj[key] !== null &&
typeof localeObj[key] === 'object' &&
localeObj[key] !== null
) {
// @ts-expect-error: recursive structure
checkMismatch(defaultObj[key], localeObj[key], [...path, key as string])
} }
} }
// Add any new properties from overrideObj
for (const key of Object.keys(overrideObj) as (keyof T)[]) {
if (!(key in defaultObj)) {
;(result as Record<keyof T, unknown>)[key] = overrideObj[key]
}
}
return result as T
} }
return deepMerge(defaultUI, localeUI) // Deep merge: localeUI overrides defaultUI, fallback to defaultUI for missing keys
function deepMerge(defaultObj: UIProps, localeObj: Partial<UIProps> = {}): UIProps {
if (typeof defaultObj !== 'object' || defaultObj === null) return defaultObj
if (typeof localeObj !== 'object' || localeObj === null) return defaultObj
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = Array.isArray(defaultObj) ? [...defaultObj] : { ...defaultObj }
for (const key of Object.keys(defaultObj) as (keyof UIProps)[]) {
if (key in localeObj) {
if (
typeof defaultObj[key] === 'object' &&
defaultObj[key] !== null &&
typeof localeObj[key] === 'object' &&
localeObj[key] !== null
) {
// @ts-expect-error: recursive structure
result[key] = deepMerge(defaultObj[key], localeObj[key])
} else {
result[key] = localeObj[key]
}
} else {
result[key] = defaultObj[key]
}
}
return result
}
if (!defaultUI) {
throw new Error('Default UI translation is missing!')
}
if (localeUI && validLocale !== CONSTANT.I18N.DEFAULT_LOCALE) {
checkMismatch(defaultUI, localeUI)
return deepMerge(defaultUI, localeUI) as UIProps
}
// If localeUI is undefined or locale is default, just return defaultUI
return defaultUI
} }
/** /**