wiki multilang support
ci/woodpecker/push/deploy Pipeline was successful

This commit is contained in:
2026-08-28 20:52:49 +02:00
parent 653894a876
commit 9ffbc7a2ca
25 changed files with 351 additions and 52 deletions
@@ -0,0 +1,35 @@
import { describe, it, expect, afterEach } from 'vitest'
import { i18n } from '../../i18n'
import { WIKI_BASE, wikiUrl } from '../wiki.api'
afterEach(() => {
i18n.global.locale.value = 'en'
})
describe('wikiUrl', () => {
it('leaves English unprefixed', () => {
expect(wikiUrl('development/godot', 'en')).toBe(`${WIKI_BASE}/development/godot`)
})
it('prefixes Hungarian with /hu', () => {
expect(wikiUrl('development/godot', 'hu')).toBe(`${WIKI_BASE}/hu/development/godot`)
})
it('accepts a route that already starts with a slash', () => {
expect(wikiUrl('/development/godot', 'hu')).toBe(`${WIKI_BASE}/hu/development/godot`)
})
it('returns the wiki root when no page is given', () => {
expect(wikiUrl('', 'hu')).toBe(`${WIKI_BASE}/hu`)
expect(wikiUrl()).toBe(WIKI_BASE)
})
it('falls back to English for an unsupported language', () => {
expect(wikiUrl('development/godot', 'de')).toBe(`${WIKI_BASE}/development/godot`)
})
it('follows the active interface language when none is passed', () => {
i18n.global.locale.value = 'hu'
expect(wikiUrl('development/godot')).toBe(`${WIKI_BASE}/hu/development/godot`)
})
})
+29 -11
View File
@@ -1,9 +1,27 @@
import type { WikiPage, WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface'
import { CONFIG } from '../lib/config'
import { i18n } from '../i18n'
const WIKI_BASE = CONFIG.wikiBase
const WIKI_LOCALES = ['en', 'hu'] as const
const DEFAULT_WIKI_LOCALE = 'en'
function resolveLocale(locale?: string): string {
const value = (locale ?? String(i18n.global.locale.value)).toLowerCase()
return (WIKI_LOCALES as readonly string[]).includes(value) ? value : DEFAULT_WIKI_LOCALE
}
// Grav serves English unprefixed and Hungarian under /hu, so a page link is the
// base plus the prefix plus the language-neutral route.
function wikiUrl(path = '', locale?: string): string {
const lang = resolveLocale(locale)
const prefix = lang === DEFAULT_WIKI_LOCALE ? '' : `/${lang}`
const route = path ? `/${path.replace(/^\/+/, '')}` : ''
return `${WIKI_BASE}${prefix}${route}`
}
interface RawWikiPage {
id: number
path: string
@@ -22,9 +40,9 @@ const HIGHLIGHTED_TAG = 'highlighted'
async function fetchPages(
tag: string,
opts: { body?: boolean; limit?: number } = {},
opts: { body?: boolean; limit?: number; locale?: string } = {},
): Promise<RawWikiPage[]> {
const params = new URLSearchParams({ tag })
const params = new URLSearchParams({ tag, lang: resolveLocale(opts.locale) })
if (opts.body) params.set('body', '1')
if (opts.limit) params.set('limit', String(opts.limit))
@@ -34,8 +52,8 @@ async function fetchPages(
return json?.pages ?? []
}
const listBlogPages = async (): Promise<WikiPageWithContent[]> => {
const pages = await fetchPages('blog', { body: true })
const listBlogPages = async (locale?: string): Promise<WikiPageWithContent[]> => {
const pages = await fetchPages('blog', { body: true, locale })
return pages.map((p): WikiPageWithContent => ({
id: p.id,
path: p.path,
@@ -48,8 +66,8 @@ const listBlogPages = async (): Promise<WikiPageWithContent[]> => {
}))
}
const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
const pages = await fetchPages('blog', { body: true })
const getBlogPage = async (slug: string, locale?: string): Promise<WikiPageContent | null> => {
const pages = await fetchPages('blog', { body: true, locale })
const matched = pages.find((p) => {
const pageSlug = p.path.startsWith('blog/') ? p.path.replace('blog/', '') : p.path
@@ -70,8 +88,8 @@ const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
}
}
const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
const pages = await fetchPages('engine', { body: true })
const listEnginePages = async (locale?: string): Promise<WikiPageWithContent[]> => {
const pages = await fetchPages('engine', { body: true, locale })
return pages
.filter((p) => (p.tags ?? []).includes(HIGHLIGHTED_TAG))
.map((p): WikiPageWithContent => ({
@@ -87,8 +105,8 @@ const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
}))
}
const listHowtoPages = async (): Promise<WikiPage[]> => {
const pages = await fetchPages('howto', { limit: 30 })
const listHowtoPages = async (locale?: string): Promise<WikiPage[]> => {
const pages = await fetchPages('howto', { limit: 30, locale })
return pages.map((p): WikiPage => ({
id: p.id,
path: p.path,
@@ -100,5 +118,5 @@ const listHowtoPages = async (): Promise<WikiPage[]> => {
}))
}
export { WIKI_BASE }
export { WIKI_BASE, WIKI_LOCALES, DEFAULT_WIKI_LOCALE, wikiUrl }
export default { listBlogPages, getBlogPage, listHowtoPages, listEnginePages }
@@ -0,0 +1,13 @@
import { watch } from 'vue'
import { i18n } from '../i18n'
// Wiki content is fetched per language, so anything cached from the wiki has to
// be dropped and re-read when the visitor switches EN/HU.
export function useLocaleReload(reload: () => void | Promise<void>) {
watch(
() => i18n.global.locale.value,
() => {
void reload()
},
)
}
+2 -2
View File
@@ -53,7 +53,7 @@
</nav>
<div class="footer-meta">
<a :href="WIKI_BASE" target="_blank" rel="noopener noreferrer" class="footer-link">{{ t('footer.wiki') }}</a>
<a :href="wikiUrl()" target="_blank" rel="noopener noreferrer" class="footer-link">{{ t('footer.wiki') }}</a>
<a :href="GIT_BASE" target="_blank" rel="noopener noreferrer" class="footer-link">{{ t('footer.git') }}</a>
<span class="footer-rss">
<Rss v-bind="ICON" aria-hidden="true" />
@@ -89,7 +89,7 @@ import { Menu, Moon, Rss, Sun, X } from 'lucide-vue-next'
import { useUiStore } from '../stores/ui.store'
import { getCookie } from '../lib/cookie'
import { ICON, ICON_CONTROL } from '../components/icons'
import { WIKI_BASE } from '../api/wiki.api'
import { wikiUrl } from '../api/wiki.api'
const GIT_BASE = 'https://git.teletypegames.org'
@@ -48,7 +48,7 @@
<a :href="exploreUrl(page)" target="_blank" rel="noopener" class="btn-accent">
{{ t('engines.explore') }}
</a>
<a :href="`${WIKI_BASE}/${page.path}`" target="_blank" rel="noopener" class="btn-ghost">
<a :href="wikiUrl(page.path)" target="_blank" rel="noopener" class="btn-ghost">
{{ t('engines.openWiki') }}
</a>
</div>
@@ -63,7 +63,7 @@
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { WIKI_BASE } from '../../api/wiki.api'
import { wikiUrl } from '../../api/wiki.api'
import { useEnginesStore, getEngineDigest } from '../../stores/engines.store'
import type { WikiPageWithContent } from '../../lib/interfaces/wiki.interface'
import SkeletonCard from '../../components/SkeletonCard.vue'
@@ -79,7 +79,7 @@ const cards = computed(() =>
)
const exploreUrl = (page: WikiPageWithContent): string =>
page.repo || `${WIKI_BASE}/${page.path}`
page.repo || wikiUrl(page.path)
onMounted(() => store.fetch())
</script>
@@ -6,7 +6,7 @@
<h1 class="hero-title">{{ t('howtos.title') }}</h1>
<p class="hero-subtitle">{{ t('howtos.subtitle') }}</p>
<div class="hero-actions">
<a :href="WIKI_BASE" target="_blank" rel="noopener noreferrer" class="btn-accent">
<a :href="wikiUrl()" target="_blank" rel="noopener noreferrer" class="btn-accent">
{{ t('howtos.knowledgeBase') }}
</a>
</div>
@@ -30,7 +30,7 @@
<a
v-for="page in recentPages"
:key="page.id"
:href="`${WIKI_BASE}/${page.path}`"
:href="wikiUrl(page.path)"
target="_blank"
rel="noopener noreferrer"
class="howto-card"
@@ -50,7 +50,7 @@
</div>
<div v-if="!error && recentPages.length > 0" class="focus-row">
<a :href="WIKI_BASE" target="_blank" rel="noopener noreferrer" class="link text-sm">
<a :href="wikiUrl()" target="_blank" rel="noopener noreferrer" class="link text-sm">
{{ t('howtos.browseWiki') }}
</a>
</div>
@@ -63,7 +63,7 @@ import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '../../lib/dateFormat'
import { WIKI_BASE } from '../../api/wiki.api'
import { wikiUrl } from '../../api/wiki.api'
import { useHowtosStore } from '../../stores/howtos.store'
import SkeletonCard from '../../components/SkeletonCard.vue'
@@ -30,7 +30,7 @@
<a :href="client.releasesUrl" target="_blank" rel="noopener noreferrer" class="btn-accent">
{{ t('stores.clientDownload') }}
</a>
<a :href="client.wikiUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
<a :href="wikiUrl(client.wikiPath)" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.docs') }}
</a>
<a :href="client.repoUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
@@ -82,7 +82,7 @@
</div>
<div class="st-links st-links-after">
<a :href="current.wikiUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
<a :href="wikiUrl(current.wikiPath)" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.docs') }}
</a>
<a :href="current.repoUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
@@ -116,7 +116,7 @@
import { computed, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { CONFIG } from '../../lib/config'
import { wikiUrl } from '../../api/wiki.api'
import CommandBlock from './CommandBlock.vue'
import { BrandIcon } from '../../components/icons'
import clientScreenshot from '../../assets/warpengine-client.webp'
@@ -130,7 +130,7 @@ const FORGE = 'https://git.teletypegames.org/stores'
const CLIENT = {
repoUrl: `${FORGE}/warp-engine-client`,
releasesUrl: `${FORGE}/warp-engine-client/releases`,
wikiUrl: `${CONFIG.wikiBase}/stores/warp-engine-client`,
wikiPath: 'stores/warp-engine-client',
}
type DeviceId = 'batocera' | 'retroarch'
@@ -150,7 +150,7 @@ const devices = [
id: 'batocera' as DeviceId,
projectUrl: 'https://batocera.org',
repoUrl: `${FORGE}/ttg-batocera-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-batocera-store`,
wikiPath: 'stores/ttg-batocera-store',
platforms: [
...CARTRIDGE_PLATFORMS,
{ platform: 'ebitengine', label: 'Ebitengine', ext: '.zip', note: 'x86_64 · ARM64' },
@@ -172,7 +172,7 @@ const devices = [
id: 'retroarch' as DeviceId,
projectUrl: 'https://www.retroarch.com',
repoUrl: `${FORGE}/ttg-retroarch-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-retroarch-store`,
wikiPath: 'stores/ttg-retroarch-store',
platforms: CARTRIDGE_PLATFORMS,
installCmd: `curl -fsSL ${FORGE}/ttg-retroarch-store/raw/branch/master/install.sh | sh`,
afterInstallCmd: '',
@@ -74,3 +74,31 @@ describe('getEngineDigest', () => {
expect(getEngineDigest('')).toEqual({ intro: '', highlightsTitle: '', highlights: [] })
})
})
const SAMPLE_MARKDOWN_HU = `
> A példamotor egy **minta** keretrendszer, ami markdownból landing-kártyát csinál.
# Amit kapsz
- **Első funkció**: rövid magyarázattal.
- Sima felsorolás, félkövér nyitás nélkül.
# Későbbi szakasz
- **Nem kerül be**: egy lista egy későbbi cím alatt.
`
describe('getEngineDigest, Hungarian page', () => {
it('recognises the Hungarian highlights heading', () => {
const digest = getEngineDigest(SAMPLE_MARKDOWN_HU)
expect(digest.highlightsTitle).toBe('Amit kapsz')
expect(digest.highlights).toHaveLength(2)
expect(digest.highlights[0]).toEqual({ title: 'Első funkció', text: 'rövid magyarázattal.' })
})
it('still stops at the next heading', () => {
expect(getEngineDigest(SAMPLE_MARKDOWN_HU).highlights.map((h) => h.title)).not.toContain(
'Nem kerül be',
)
})
})
+10
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import wikiApi from '../api/wiki.api'
import { isNew } from '../lib/softwareUtils'
import { useLoadable } from '../composables/useLoadable'
import { useLocaleReload } from '../composables/useLocaleReload'
import type { WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface'
export const useBlogStore = defineStore('blog', () => {
@@ -18,7 +19,10 @@ export const useBlogStore = defineStore('blog', () => {
})
}
const currentSlug = ref<string | null>(null)
async function fetchPage(slug: string) {
currentSlug.value = slug
currentPage.value = null
await withPageCache(async () => {
const result = await wikiApi.getBlogPage(slug)
@@ -37,5 +41,11 @@ export const useBlogStore = defineStore('blog', () => {
return content.replace(/[#*`_[\]()]/g, '').trim().slice(0, 300) + '...'
}
useLocaleReload(async () => {
invalidate()
await fetch()
if (currentSlug.value) await fetchPage(currentSlug.value)
})
return { pages, loading, error, currentPage, pageLoading, pageError, fetch, fetchPage, isNew, getPermalink, getCleanPreview, invalidate }
})
+8 -2
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import wikiApi from '../api/wiki.api'
import { useLoadable } from '../composables/useLoadable'
import { useLocaleReload } from '../composables/useLocaleReload'
import type { WikiPageWithContent } from '../lib/interfaces/wiki.interface'
export interface EngineHighlight {
@@ -15,7 +16,7 @@ export interface EngineDigest {
highlights: EngineHighlight[]
}
const HIGHLIGHTS_HEADING = 'what you get'
const HIGHLIGHTS_HEADINGS = ['what you get', 'amit kapsz']
const MAX_HIGHLIGHTS = 6
function stripInline(md: string): string {
@@ -39,7 +40,7 @@ export function getEngineDigest(content: string): EngineDigest {
if (heading) {
if (inHighlights) break
const title = stripInline(heading[1])
if (title.toLowerCase() !== HIGHLIGHTS_HEADING) break
if (!HIGHLIGHTS_HEADINGS.includes(title.toLowerCase())) break
inHighlights = true
digest.highlightsTitle = title
continue
@@ -81,5 +82,10 @@ export const useEnginesStore = defineStore('engines', () => {
return content.replace(/[#*`_[\]()>|-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 260) + '...'
}
useLocaleReload(async () => {
invalidate()
await fetch()
})
return { pages, loading, error, fetch, getCleanPreview, getEngineDigest, invalidate }
})
+6
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import wikiApi from '../api/wiki.api'
import { isNew } from '../lib/softwareUtils'
import { useLoadable } from '../composables/useLoadable'
import { useLocaleReload } from '../composables/useLocaleReload'
import type { WikiPage } from '../lib/interfaces/wiki.interface'
export const useHowtosStore = defineStore('howtos', () => {
@@ -15,5 +16,10 @@ export const useHowtosStore = defineStore('howtos', () => {
})
}
useLocaleReload(async () => {
invalidate()
await fetch()
})
return { pages, loading, error, fetch, isNew, invalidate }
})