lightnet 4.0.7 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/exports/content.ts +5 -7
  4. package/package.json +13 -13
  5. package/src/api/versions.ts +1 -1
  6. package/src/astro-integration/config.ts +15 -25
  7. package/src/components/CategoriesSection.astro +2 -2
  8. package/src/components/MediaGallerySection.astro +4 -9
  9. package/src/components/MediaList.astro +13 -13
  10. package/src/content/content-schema.ts +5 -292
  11. package/src/content/get-categories.ts +26 -29
  12. package/src/content/get-languages.ts +13 -26
  13. package/src/content/get-media-collections.ts +1 -1
  14. package/src/content/get-media-items.ts +1 -1
  15. package/src/content/get-media-types.ts +21 -14
  16. package/src/content/query-media-items.ts +2 -1
  17. package/src/content/schema/category.ts +40 -0
  18. package/src/content/schema/media-collection.ts +31 -0
  19. package/src/content/schema/media-item.ts +137 -0
  20. package/src/content/schema/media-type.ts +90 -0
  21. package/src/i18n/locals.d.ts +22 -0
  22. package/src/i18n/locals.ts +3 -1
  23. package/src/i18n/record-translation.ts +74 -0
  24. package/src/i18n/resolve-language.ts +12 -5
  25. package/src/i18n/translate-map.ts +129 -19
  26. package/src/i18n/translate.ts +38 -0
  27. package/src/i18n/translation-map-schema.ts +17 -0
  28. package/src/i18n/translations/TRANSLATION-STATUS.md +13 -41
  29. package/src/i18n/translations/ar.yml +1 -0
  30. package/src/i18n/translations/bn.yml +1 -0
  31. package/src/i18n/translations/de.yml +1 -0
  32. package/src/i18n/translations/es.yml +1 -0
  33. package/src/i18n/translations/fi.yml +1 -0
  34. package/src/i18n/translations/fr.yml +1 -0
  35. package/src/i18n/translations/hi.yml +1 -0
  36. package/src/i18n/translations/kk.yml +3 -0
  37. package/src/i18n/translations/pt.yml +1 -0
  38. package/src/i18n/translations/ru.yml +1 -0
  39. package/src/i18n/translations/uk.yml +1 -0
  40. package/src/i18n/translations/ur.yml +1 -0
  41. package/src/i18n/translations/zh.yml +1 -0
  42. package/src/i18n/translations.ts +5 -2
  43. package/src/layouts/Page.astro +3 -4
  44. package/src/layouts/components/Footer.astro +72 -10
  45. package/src/layouts/components/LanguagePicker.astro +2 -2
  46. package/src/layouts/components/PageNavigation.astro +17 -21
  47. package/src/layouts/components/PageTitle.astro +4 -13
  48. package/src/pages/details-page/DefaultDetailsPage.astro +2 -15
  49. package/src/pages/details-page/components/AudioPanel.astro +5 -4
  50. package/src/pages/details-page/components/ContentSection.astro +5 -4
  51. package/src/pages/details-page/components/MediaCollection.astro +2 -6
  52. package/src/pages/details-page/components/main-details/OpenButton.astro +20 -18
  53. package/src/pages/details-page/components/more-details/Categories.astro +3 -5
  54. package/src/pages/details-page/components/more-details/Languages.astro +7 -3
  55. package/src/pages/details-page/utils/create-content-metadata.ts +8 -22
  56. package/src/pages/search-page/api/search.ts +1 -1
  57. package/src/pages/search-page/components/SearchFilter.astro +5 -5
  58. package/src/pages/search-page/components/SearchList.astro +22 -19
  59. package/src/astro-integration/validators/validate-inline-translations.ts +0 -51
@@ -2,7 +2,7 @@ import { AstroError } from "astro/errors"
2
2
  import i18next from "i18next"
3
3
  import config from "virtual:lightnet/config"
4
4
 
5
- import type { TranslateMapFn } from "./translate-map"
5
+ import type { TranslateConfigFieldFn } from "./translate-map"
6
6
 
7
7
  const languages = Object.fromEntries(
8
8
  config.languages.map((language) => [language.code, language]),
@@ -25,13 +25,20 @@ export const resolveLanguage = (bcp47: string) => {
25
25
 
26
26
  export const resolveTranslatedLanguage = (
27
27
  bcp47: string,
28
- tMap: TranslateMapFn,
28
+ tConfigField: TranslateConfigFieldFn,
29
29
  ) => {
30
30
  const language = resolveLanguage(bcp47)
31
31
  return {
32
32
  ...language,
33
- labelText: tMap(language.label, {
34
- path: ["languages", bcp47, "label"],
35
- }),
33
+ labelText: tConfigField(language.label, config),
36
34
  }
37
35
  }
36
+
37
+ export async function getTranslatedLanguages(
38
+ currentLocale: string,
39
+ tConfigField: TranslateConfigFieldFn,
40
+ ) {
41
+ return config.languages
42
+ .map(({ code }) => resolveTranslatedLanguage(code, tConfigField))
43
+ .sort((a, b) => a.labelText.localeCompare(b.labelText, currentLocale))
44
+ }
@@ -1,6 +1,10 @@
1
1
  import { AstroError } from "astro/errors"
2
+ import type { CollectionKey } from "astro:content"
2
3
  import config from "virtual:lightnet/config"
3
4
 
5
+ import type { ExtendedLightnetConfig } from "../astro-integration/config"
6
+ import { recordTranslation } from "./record-translation"
7
+
4
8
  /**
5
9
  * A map of translated values keyed by locale code.
6
10
  *
@@ -12,14 +16,6 @@ import config from "virtual:lightnet/config"
12
16
  */
13
17
  export type TranslationMap = Record<string, string | undefined>
14
18
 
15
- /**
16
- * Describes where a translation map comes from so missing-value
17
- * errors can point to the exact field in config or content.
18
- */
19
- export type TranslationMapContext = {
20
- path: (string | number)[]
21
- }
22
-
23
19
  /**
24
20
  * Resolve a translation map for the current locale, falling back
25
21
  * to configured default locale when needed.
@@ -32,7 +28,37 @@ export type TranslationMapContext = {
32
28
  */
33
29
  export type TranslateMapFn = (
34
30
  translationMap: TranslationMap,
35
- context: TranslationMapContext,
31
+ /**
32
+ * Describes where a translation map comes from so translation logs
33
+ * can point to the exact field in config or content.
34
+ */
35
+ context: { path: (string | number)[] },
36
+ ) => string
37
+
38
+ /**
39
+ * Resolve an inline translation map that belongs to a content entry.
40
+ *
41
+ * The entry is only used to provide a stable fallback path when translation-map
42
+ * metadata is missing during the current refactor.
43
+ */
44
+ export type TranslateContentFieldFn = (
45
+ translationMap: TranslationMap,
46
+ contentEntry: {
47
+ id: string
48
+ collection: CollectionKey
49
+ data: Record<PropertyKey, unknown>
50
+ },
51
+ ) => string
52
+
53
+ /**
54
+ * Resolve an inline translation map that belongs to the LightNet config.
55
+ *
56
+ * The config object is only used to keep the API explicit and to provide a
57
+ * stable fallback path when translation-map metadata is missing.
58
+ */
59
+ export type TranslateConfigFieldFn = (
60
+ translationMap: TranslationMap,
61
+ config: ExtendedLightnetConfig,
36
62
  ) => string
37
63
 
38
64
  /**
@@ -45,26 +71,110 @@ export type TranslateMapFn = (
45
71
  * @param currentLocale The locale that should be resolved first before
46
72
  * falling back to LightNet's configured default locale.
47
73
  */
48
- export function useTranslateMap(currentLocale: string): TranslateMapFn {
49
- return (translationMap: TranslationMap, context: TranslationMapContext) => {
50
- const currentLocaleValue = translationMap[currentLocale]
51
- if (currentLocaleValue) {
52
- return currentLocaleValue
74
+ export function useTranslateMap(currentLocale: string) {
75
+ const tMap: TranslateMapFn = (translationMap, context) => {
76
+ const getKey = () => {
77
+ const keyCacheSymbol = Symbol.for("ln.key-cache")
78
+ if (hasOwnProperty(translationMap, keyCacheSymbol)) {
79
+ return translationMap[keyCacheSymbol] as string
80
+ }
81
+ const value = context.path.join(".")
82
+ Object.defineProperty(translationMap, keyCacheSymbol, { value })
83
+ return value
84
+ }
85
+ const key = getKey()
86
+ recordTranslation({ key, values: translationMap, type: "map" })
87
+
88
+ const currentLocaleTranslation = translationMap[currentLocale]
89
+ if (currentLocaleTranslation) {
90
+ return currentLocaleTranslation
53
91
  }
54
92
 
55
- const defaultLocaleValue = translationMap[config.defaultLocale]
56
- if (defaultLocaleValue) {
57
- return defaultLocaleValue
93
+ const defaultLocaleTranslation = translationMap[config.defaultLocale]
94
+ if (defaultLocaleTranslation) {
95
+ return defaultLocaleTranslation
58
96
  }
59
97
 
60
- const availableLocales = Object.keys(translationMap)
98
+ const availableLocales = Object.keys(translationMap).filter(
99
+ (key) => key !== "path",
100
+ )
61
101
  const availableLocalesText = availableLocales.length
62
102
  ? availableLocales.map((locale) => `"${locale}"`).join(", ")
63
103
  : "none"
64
104
 
65
105
  throw new AstroError(
66
- `Missing translation map value for "${context.path.join(".")}" in locales "${currentLocale}" and "${config.defaultLocale}"`,
106
+ `Missing translation map value for "${key}" in locales "${currentLocale}" and "${config.defaultLocale}"`,
67
107
  `Available locales: ${availableLocalesText}. Add a value for "${currentLocale}" or "${config.defaultLocale}" to this inline translation map.`,
68
108
  )
69
109
  }
110
+
111
+ const tContentField: TranslateContentFieldFn = (
112
+ translationMap,
113
+ contentEntry,
114
+ ) => {
115
+ return tMap(translationMap, {
116
+ path: getMapPath(translationMap, contentEntry.data, [
117
+ "content",
118
+ contentEntry.collection,
119
+ contentEntry.id,
120
+ ]),
121
+ })
122
+ }
123
+
124
+ const tConfigField: TranslateConfigFieldFn = (translationMap, _config) => {
125
+ return tMap(translationMap, {
126
+ path: getMapPath(translationMap, _config, ["config"]),
127
+ })
128
+ }
129
+
130
+ return { tMap, tContentField, tConfigField }
131
+ }
132
+
133
+ function getMapPath(
134
+ translationMap: TranslationMap,
135
+ data: unknown,
136
+ path: string[],
137
+ ) {
138
+ const pathCacheSymbol = Symbol.for("ln.path-cache")
139
+
140
+ if (hasOwnProperty(translationMap, pathCacheSymbol)) {
141
+ return translationMap[pathCacheSymbol] as string[]
142
+ }
143
+
144
+ const findPath: (_data: unknown, _path: string[]) => string[] | undefined = (
145
+ _data,
146
+ _path,
147
+ ) => {
148
+ if (!_data || typeof _data !== "object") {
149
+ return
150
+ }
151
+ if (_data === translationMap) {
152
+ return _path
153
+ }
154
+ for (const [key, value] of Object.entries(_data)) {
155
+ const p = findPath(value, [..._path, key])
156
+ if (p) {
157
+ return p as string[]
158
+ }
159
+ }
160
+ }
161
+
162
+ const resolvedPath = findPath(data, path)
163
+ if (!resolvedPath) {
164
+ throw new AstroError(
165
+ `Invalid map context provided ${path} for could not find path for object ${JSON.stringify(translationMap)}`,
166
+ `Provided object ${JSON.stringify(data)}`,
167
+ )
168
+ }
169
+ Object.defineProperty(translationMap, pathCacheSymbol, {
170
+ value: resolvedPath,
171
+ })
172
+ return resolvedPath
173
+ }
174
+
175
+ function hasOwnProperty<K extends PropertyKey>(
176
+ obj: unknown,
177
+ key: K,
178
+ ): obj is Record<K, unknown> {
179
+ return !!obj && typeof obj === "object" && Object.hasOwn(obj, key)
70
180
  }
@@ -3,6 +3,7 @@ import i18next, { type TOptions } from "i18next"
3
3
  import config from "virtual:lightnet/config"
4
4
 
5
5
  import { lazy } from "../utils/lazy"
6
+ import { recordTranslation } from "./record-translation"
6
7
  import { type LightNetTranslationKey, loadTranslations } from "./translations"
7
8
 
8
9
  // We add (string & NonNullable<unknown>) to preserve typescript autocompletion for known keys
@@ -68,6 +69,7 @@ export async function useTranslate(
68
69
  const resolvedLocale = bcp47 ?? config.defaultLocale
69
70
  const translations = await i18nextTranslations.get()
70
71
  const availableTranslationKeys = new Set(await translationKeys.get())
72
+ recordAllTranslations()
71
73
 
72
74
  const i18n = i18next.createInstance()
73
75
 
@@ -100,3 +102,39 @@ export async function useTranslate(
100
102
  return value
101
103
  }
102
104
  }
105
+
106
+ async function recordAllTranslations() {
107
+ if (!import.meta.env.PROD) {
108
+ // only record translations during build time
109
+ return
110
+ }
111
+
112
+ const { locales } = config
113
+ const localesIncludingEnglish = [...locales.filter((l) => l !== "en"), "en"]
114
+ const keys = await translationKeys.get()
115
+ const translations = await i18nextTranslations.get()
116
+
117
+ const collectValues = (key: string, locales: string[]) => {
118
+ const values = {} as Record<string, string | undefined>
119
+ for (const locale of locales) {
120
+ values[locale] = translations[locale]?.translation[key]
121
+ }
122
+ return values
123
+ }
124
+
125
+ for (const key of keys) {
126
+ if (key.startsWith("ln.")) {
127
+ recordTranslation({
128
+ key,
129
+ values: collectValues(key, localesIncludingEnglish),
130
+ type: "lightnet",
131
+ })
132
+ } else {
133
+ recordTranslation({
134
+ key,
135
+ values: collectValues(key, locales),
136
+ type: "user",
137
+ })
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from "astro/zod"
2
+
3
+ /**
4
+ * Translations by BCP-47 tag
5
+ * Must contain at least one localized value.
6
+ *
7
+ * @example
8
+ * {
9
+ * de: "Hallo",
10
+ * en: "Hello"
11
+ * }
12
+ */
13
+ export const translationMapSchema = z
14
+ .record(z.string(), z.string().nonempty())
15
+ .refine((value) => Object.keys(value).length > 0, {
16
+ message: "Inline translations must contain at least one entry",
17
+ })
@@ -4,21 +4,15 @@ This autogenerated report shows all built-in languages and the current status of
4
4
 
5
5
  ## **AR** ([ar.yml](./ar.yml))
6
6
 
7
- Missing keys:
8
-
9
- - ln.details.edit
7
+ All keys have been translated. ✅
10
8
 
11
9
  ## **BN** ([bn.yml](./bn.yml))
12
10
 
13
- Missing keys:
14
-
15
- - ln.details.edit
11
+ All keys have been translated. ✅
16
12
 
17
13
  ## **DE** ([de.yml](./de.yml))
18
14
 
19
- Missing keys:
20
-
21
- - ln.details.edit
15
+ All keys have been translated. ✅
22
16
 
23
17
  ## **EN** ([en.yml](./en.yml))
24
18
 
@@ -26,62 +20,40 @@ All keys have been translated. ✅
26
20
 
27
21
  ## **ES** ([es.yml](./es.yml))
28
22
 
29
- Missing keys:
30
-
31
- - ln.details.edit
23
+ All keys have been translated. ✅
32
24
 
33
25
  ## **FI** ([fi.yml](./fi.yml))
34
26
 
35
- Missing keys:
36
-
37
- - ln.details.edit
27
+ All keys have been translated. ✅
38
28
 
39
29
  ## **FR** ([fr.yml](./fr.yml))
40
30
 
41
- Missing keys:
42
-
43
- - ln.details.edit
31
+ All keys have been translated. ✅
44
32
 
45
33
  ## **HI** ([hi.yml](./hi.yml))
46
34
 
47
- Missing keys:
48
-
49
- - ln.details.edit
35
+ All keys have been translated. ✅
50
36
 
51
37
  ## **KK** ([kk.yml](./kk.yml))
52
38
 
53
- Missing keys:
54
-
55
- - ln.previous
56
- - ln.next
57
- - ln.details.edit
39
+ All keys have been translated. ✅
58
40
 
59
41
  ## **PT** ([pt.yml](./pt.yml))
60
42
 
61
- Missing keys:
62
-
63
- - ln.details.edit
43
+ All keys have been translated. ✅
64
44
 
65
45
  ## **RU** ([ru.yml](./ru.yml))
66
46
 
67
- Missing keys:
68
-
69
- - ln.details.edit
47
+ All keys have been translated. ✅
70
48
 
71
49
  ## **UK** ([uk.yml](./uk.yml))
72
50
 
73
- Missing keys:
74
-
75
- - ln.details.edit
51
+ All keys have been translated. ✅
76
52
 
77
53
  ## **UR** ([ur.yml](./ur.yml))
78
54
 
79
- Missing keys:
80
-
81
- - ln.details.edit
55
+ All keys have been translated. ✅
82
56
 
83
57
  ## **ZH** ([zh.yml](./zh.yml))
84
58
 
85
- Missing keys:
86
-
87
- - ln.details.edit
59
+ All keys have been translated. ✅
@@ -17,6 +17,7 @@ ln.search.all-categories: جميع الفئات
17
17
  ln.search.no-results: لا توجد نتائج
18
18
  ln.details.open: فتح
19
19
  ln.details.share: مشاركة
20
+ ln.details.edit: تحرير
20
21
  ln.details.part-of-collection: جزء من مجموعة
21
22
  ln.details.download: تنزيل
22
23
  ln.share.url-copied-to-clipboard: تم نسخ الرابط إلى الحافظة
@@ -17,6 +17,7 @@ ln.search.all-categories: সকল বিভাগ
17
17
  ln.search.no-results: কোনো ফলাফল নেই
18
18
  ln.details.open: খুলুন
19
19
  ln.details.share: শেয়ার করুন
20
+ ln.details.edit: সম্পাদনা করুন
20
21
  ln.details.part-of-collection: সংগ্রহের অংশ
21
22
  ln.details.download: ডাউনলোড করুন
22
23
  ln.share.url-copied-to-clipboard: লিঙ্ক ক্লিপবোর্ডে কপি হয়েছে
@@ -11,6 +11,7 @@ ln.external-link: Externer Link
11
11
  ln.details.open: Öffnen
12
12
  ln.details.part-of-collection: Teil der Sammlung
13
13
  ln.details.download: Download
14
+ ln.details.edit: Bearbeiten
14
15
  ln.details.share: Teilen
15
16
  ln.header.open-main-menu: Öffne Hauptmenü
16
17
  ln.header.select-language: Sprache auswählen
@@ -17,6 +17,7 @@ ln.search.all-categories: Todas las categorías
17
17
  ln.search.no-results: Sin resultados
18
18
  ln.details.open: Abrir
19
19
  ln.details.share: Compartir
20
+ ln.details.edit: Editar
20
21
  ln.details.part-of-collection: Parte de la colección
21
22
  ln.details.download: Descargar
22
23
  ln.share.url-copied-to-clipboard: Enlace copiado al portapapeles
@@ -17,6 +17,7 @@ ln.search.all-categories: Kaikki kategoriat
17
17
  ln.search.no-results: Ei tuloksia
18
18
  ln.details.open: Avaa
19
19
  ln.details.share: Jaa
20
+ ln.details.edit: Muokkaa
20
21
  ln.details.part-of-collection: Osa kokoelmaa
21
22
  ln.details.download: Lataa
22
23
  ln.share.url-copied-to-clipboard: Linkki kopioitu leikepöydälle
@@ -17,6 +17,7 @@ ln.search.all-categories: Toutes les catégories
17
17
  ln.search.no-results: Aucun résultat
18
18
  ln.details.open: Ouvrir
19
19
  ln.details.share: Partager
20
+ ln.details.edit: Modifier
20
21
  ln.details.part-of-collection: Fait partie de la collection
21
22
  ln.details.download: Télécharger
22
23
  ln.share.url-copied-to-clipboard: Lien copié dans le presse-papiers
@@ -17,6 +17,7 @@ ln.search.all-categories: सभी श्रेणियाँ
17
17
  ln.search.no-results: कोई परिणाम नहीं मिला
18
18
  ln.details.open: खोलें
19
19
  ln.details.share: साझा करें
20
+ ln.details.edit: संपादित करें
20
21
  ln.details.part-of-collection: संग्रह का भाग
21
22
  ln.details.download: डाउनलोड करें
22
23
  ln.share.url-copied-to-clipboard: लिंक क्लिपबोर्ड पर कॉपी हो गया है
@@ -6,6 +6,8 @@ ln.categories: Барлық санаттар
6
6
  ln.language: Тіл
7
7
  ln.languages: Тілдер
8
8
  ln.type: Түрі
9
+ ln.previous: Алдыңғы
10
+ ln.next: Келесі
9
11
  ln.external-link: Сыртқы сілтеме
10
12
  ln.search.title: Іздеу
11
13
  ln.search.placeholder: Медиа іздеу
@@ -15,6 +17,7 @@ ln.search.all-categories: Барлық санаттар
15
17
  ln.search.no-results: Нәтижие жоқ
16
18
  ln.details.open: Ашу
17
19
  ln.details.share: Бөлісу
20
+ ln.details.edit: Өңдеу
18
21
  ln.details.part-of-collection: Жинақ бөлімі
19
22
  ln.details.download: Жүктеу
20
23
  ln.share.url-copied-to-clipboard: Жүктеме көшірілді
@@ -17,6 +17,7 @@ ln.search.all-categories: Todas as categorias
17
17
  ln.search.no-results: Nenhum resultado
18
18
  ln.details.open: Abrir
19
19
  ln.details.share: Partilhar
20
+ ln.details.edit: Editar
20
21
  ln.details.part-of-collection: Parte da coleção
21
22
  ln.details.download: Transferir
22
23
  ln.share.url-copied-to-clipboard: Ligação copiada para a área de transferência
@@ -17,6 +17,7 @@ ln.search.all-categories: Все категории
17
17
  ln.search.no-results: Нет результатов
18
18
  ln.details.open: Открыть
19
19
  ln.details.share: Поделиться
20
+ ln.details.edit: Редактировать
20
21
  ln.details.part-of-collection: Часть коллекции
21
22
  ln.details.download: Скачать
22
23
  ln.share.url-copied-to-clipboard: Ссылка скопированa в буфер
@@ -17,6 +17,7 @@ ln.search.all-categories: Усі категорії
17
17
  ln.search.no-results: Немає результатів
18
18
  ln.details.open: Відкрити
19
19
  ln.details.share: Поділитися
20
+ ln.details.edit: Редагувати
20
21
  ln.details.part-of-collection: Частина колекції
21
22
  ln.details.download: Завантажити
22
23
  ln.share.url-copied-to-clipboard: Посилання скопійовано до буферу обміну
@@ -17,6 +17,7 @@ ln.search.all-categories: تمام زمرے
17
17
  ln.search.no-results: کوئی نتیجہ نہیں ملا
18
18
  ln.details.open: کھولیں
19
19
  ln.details.share: شیئر کریں
20
+ ln.details.edit: ترمیم کریں
20
21
  ln.details.part-of-collection: مجموعے کا حصہ
21
22
  ln.details.download: ڈاؤن لوڈ کریں
22
23
  ln.share.url-copied-to-clipboard: لنک کاپی ہو گیا
@@ -17,6 +17,7 @@ ln.search.all-categories: 所有分类
17
17
  ln.search.no-results: 没有结果
18
18
  ln.details.open: 打开
19
19
  ln.details.share: 分享
20
+ ln.details.edit: 编辑
20
21
  ln.details.part-of-collection: 属于一个合集
21
22
  ln.details.download: 下载
22
23
  ln.share.url-copied-to-clipboard: 链接已复制到剪贴板
@@ -1,3 +1,4 @@
1
+ import { z } from "astro/zod"
1
2
  import YAML from "yaml"
2
3
 
3
4
  const builtInTranslations = {
@@ -19,6 +20,8 @@ const builtInTranslations = {
19
20
 
20
21
  type BuiltInLanguage = keyof typeof builtInTranslations
21
22
 
23
+ const translationFileSchema = z.record(z.string(), z.string())
24
+
22
25
  const userTranslations = Object.fromEntries(
23
26
  Object.entries(
24
27
  import.meta.glob(["/src/translations/*.(yml|yaml)"], {
@@ -52,7 +55,7 @@ const loadBuiltInTranslations = async (
52
55
  return {}
53
56
  }
54
57
  const yml = (await translationMap[bcp47]()).default
55
- return YAML.parse(yml)
58
+ return translationFileSchema.parse(YAML.parse(yml))
56
59
  }
57
60
 
58
61
  const loadUserTranslations = async (bcp47: string) => {
@@ -60,7 +63,7 @@ const loadUserTranslations = async (bcp47: string) => {
60
63
  return {}
61
64
  }
62
65
  const yml = (await userTranslations[bcp47]()) as string
63
- return YAML.parse(yml)
66
+ return translationFileSchema.parse(YAML.parse(yml))
64
67
  }
65
68
 
66
69
  export type LightNetTranslationKey =
@@ -20,10 +20,9 @@ interface Props {
20
20
  }
21
21
 
22
22
  const { title, description, mainClass, locale } = Astro.props
23
- const currentLocale = locale ?? Astro.locals.i18n.currentLocale
24
- const configTitle = Astro.locals.i18n.tMap(config.title, {
25
- path: ["config", "title"],
26
- })
23
+ const { currentLocale: pathCurrentLocale, tConfigField } = Astro.locals.i18n
24
+ const currentLocale = locale ?? pathCurrentLocale
25
+ const configTitle = tConfigField(config.title, config)
27
26
  const { direction } = resolveLanguage(currentLocale)
28
27
  ---
29
28
 
@@ -1,24 +1,86 @@
1
1
  ---
2
2
  import config from "virtual:lightnet/config"
3
3
 
4
+ import { localizePath } from "../../utils/paths"
5
+ import { isExternalUrl } from "../../utils/urls"
4
6
  import LightNetLogo from "./LightNetLogo.svg"
5
7
 
6
- if (!config.credits) {
8
+ const { t, tConfigField, currentLocale } = Astro.locals.i18n
9
+
10
+ // Resolve optional footer text for the current locale.
11
+ const footerText = config.footerText
12
+ ? tConfigField(config.footerText, config)
13
+ : undefined
14
+
15
+ // Prepare footer links using the same locale rules as the main menu.
16
+ const footerLinks = (config.footerLinks ?? []).map(
17
+ ({ href, label, requiresLocale }) => {
18
+ const isExternal = isExternalUrl(href)
19
+
20
+ return {
21
+ href:
22
+ isExternal || !requiresLocale
23
+ ? href
24
+ : localizePath(currentLocale, href),
25
+ label: tConfigField(label, config),
26
+ isExternal,
27
+ }
28
+ },
29
+ )
30
+
31
+ // Hide the footer entirely when there is nothing to show.
32
+ const shouldRenderFooter =
33
+ config.credits || Boolean(footerText) || footerLinks.length > 0
34
+
35
+ if (!shouldRenderFooter) {
7
36
  return
8
37
  }
9
38
  ---
10
39
 
11
- <footer class="w-full border-t border-gray-300">
40
+ <footer class="w-full border-t border-gray-200 bg-white">
12
41
  <div
13
- class="mx-auto flex w-full max-w-screen-xl justify-end px-4 py-4 md:px-8"
42
+ class="mx-auto flex w-full max-w-screen-xl flex-col items-center justify-between gap-4 px-4 py-6 md:flex-row md:px-8"
14
43
  >
15
- <a
16
- class="flex items-center gap-2 py-2 text-sm text-gray-800 hover:underline"
17
- href="https://lightnet.community"
18
- target="_blank"
44
+ <div
45
+ class="flex flex-wrap items-center justify-center gap-2 text-sm text-gray-700 md:justify-start"
19
46
  >
20
- <img src={LightNetLogo.src} alt="" class="h-5 w-auto" loading="lazy" />
21
- {Astro.locals.i18n.t("ln.footer.powered-by-lightnet")}
22
- </a>
47
+ {footerText && <span>{footerText}</span>}
48
+
49
+ {
50
+ footerLinks.map((link, index) => (
51
+ <Fragment>
52
+ {(footerText || index > 0) && <span class="text-gray-400">·</span>}
53
+ <a
54
+ href={link.href}
55
+ target={link.isExternal ? "_blank" : undefined}
56
+ rel={link.isExternal ? "noopener noreferrer" : undefined}
57
+ class="underline-offset-4 hover:underline"
58
+ >
59
+ {link.label}
60
+ </a>
61
+ </Fragment>
62
+ ))
63
+ }
64
+ </div>
65
+
66
+ {
67
+ config.credits && (
68
+ <div class="flex items-center text-sm">
69
+ <a
70
+ class="flex items-center gap-2 text-gray-800 underline-offset-4 hover:underline"
71
+ href="https://lightnet.community"
72
+ target="_blank"
73
+ >
74
+ <img
75
+ src={LightNetLogo.src}
76
+ alt=""
77
+ class="h-5 w-auto"
78
+ loading="lazy"
79
+ />
80
+ <span>{t("ln.footer.powered-by-lightnet")}</span>
81
+ </a>
82
+ </div>
83
+ )
84
+ }
23
85
  </div>
24
86
  </footer>
@@ -6,7 +6,7 @@ import { localizePath, pathWithoutBase } from "../../utils/paths"
6
6
  import Menu from "./Menu.astro"
7
7
  import MenuItem from "./MenuItem.astro"
8
8
 
9
- const { locales, currentLocale, tMap } = Astro.locals.i18n
9
+ const { locales, currentLocale, tConfigField } = Astro.locals.i18n
10
10
 
11
11
  const currentPath = pathWithoutBase(Astro.url.pathname)
12
12
  const hasLocale =
@@ -18,7 +18,7 @@ const translations = (
18
18
  await Promise.all(
19
19
  locales.map(async (locale) => ({
20
20
  locale,
21
- label: resolveTranslatedLanguage(locale, tMap).labelText,
21
+ label: resolveTranslatedLanguage(locale, tConfigField).labelText,
22
22
  active: locale === currentLocale,
23
23
  href: currentPathWithLocale(locale),
24
24
  })),