lightnet 3.10.0 → 3.10.2

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 (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/__e2e__/admin.spec.ts +113 -0
  3. package/__e2e__/fixtures/basics/astro.config.mjs +6 -0
  4. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  5. package/__e2e__/fixtures/basics/package.json +2 -2
  6. package/__e2e__/fixtures/basics/src/content/media/skate-sounds--en.json +15 -0
  7. package/__e2e__/fixtures/basics/src/content/media-types/audio.json +7 -0
  8. package/__e2e__/fixtures/basics/src/translations/de.yml +1 -0
  9. package/__e2e__/fixtures/basics/src/translations/en.yml +1 -0
  10. package/__e2e__/homepage.spec.ts +21 -0
  11. package/package.json +13 -6
  12. package/src/admin/api/fs/writeText.ts +50 -0
  13. package/src/admin/components/form/FieldErrors.tsx +22 -0
  14. package/src/admin/components/form/SubmitButton.tsx +79 -0
  15. package/src/admin/components/form/TextField.tsx +24 -0
  16. package/src/admin/components/form/form-context.ts +4 -0
  17. package/src/admin/components/form/index.ts +16 -0
  18. package/src/admin/i18n/translations/en.yml +11 -0
  19. package/src/admin/i18n/translations.ts +5 -0
  20. package/src/admin/pages/AdminRoute.astro +16 -0
  21. package/src/admin/pages/media/EditForm.tsx +73 -0
  22. package/src/admin/pages/media/EditRoute.astro +42 -0
  23. package/src/admin/pages/media/file-system.ts +37 -0
  24. package/src/admin/pages/media/media-item-store.ts +11 -0
  25. package/src/admin/types/media-item.ts +10 -0
  26. package/src/api/media/[mediaId].ts +16 -0
  27. package/src/{pages/api → api}/versions.ts +1 -1
  28. package/src/astro-integration/config.ts +15 -0
  29. package/src/astro-integration/integration.ts +44 -6
  30. package/src/components/CategoriesSection.astro +1 -1
  31. package/src/components/MediaGallerySection.astro +1 -1
  32. package/src/components/Toast.tsx +55 -0
  33. package/src/components/showToast.ts +61 -0
  34. package/src/content/astro-image.ts +1 -14
  35. package/src/content/content-schema.ts +10 -3
  36. package/src/content/get-media-items.ts +46 -1
  37. package/src/i18n/locals.d.ts +35 -28
  38. package/src/i18n/locals.ts +2 -1
  39. package/src/i18n/react/i18n-context.ts +32 -0
  40. package/src/i18n/react/prepare-i18n-config.ts +31 -0
  41. package/src/i18n/react/useI18n.ts +15 -0
  42. package/src/i18n/translate.ts +15 -3
  43. package/src/i18n/translations.ts +20 -7
  44. package/src/layouts/Page.astro +1 -1
  45. package/src/pages/details-page/components/MainDetailsSection.astro +5 -1
  46. package/src/pages/details-page/components/VideoDetailsSection.astro +5 -1
  47. package/src/pages/details-page/components/main-details/EditButton.astro +30 -0
  48. package/src/pages/details-page/components/main-details/ShareButton.astro +9 -13
  49. package/src/pages/{api → search-page/api}/search.ts +3 -3
  50. package/src/pages/search-page/components/LoadingSkeleton.tsx +3 -1
  51. package/src/pages/search-page/components/SearchFilter.astro +12 -3
  52. package/src/pages/search-page/components/SearchFilter.tsx +4 -7
  53. package/src/pages/search-page/components/SearchList.astro +7 -6
  54. package/src/pages/search-page/components/SearchList.tsx +12 -15
  55. package/src/pages/search-page/components/SearchListItem.tsx +3 -5
  56. package/src/pages/search-page/hooks/use-search.ts +3 -3
  57. package/tailwind.config.ts +1 -0
  58. package/src/pages/search-page/utils/search-filter-translations.ts +0 -20
  59. package/src/pages/search-page/utils/search-translations.ts +0 -11
  60. /package/src/pages/{api → search-page/api}/search-response.ts +0 -0
@@ -0,0 +1,37 @@
1
+ export const writeText = (path: string, body: string) => {
2
+ return fetch(
3
+ `/api/internal/fs/writeText?path=${encodeURIComponent(path.replace(/^\//, ""))}`,
4
+ {
5
+ method: "POST",
6
+ headers: { "Content-Type": resolveContentType(path) },
7
+ body,
8
+ },
9
+ )
10
+ }
11
+
12
+ export const writeJson = async (path: string, object: unknown) => {
13
+ return writeText(path, JSON.stringify(sortObject(object), null, 2))
14
+ }
15
+
16
+ const resolveContentType = (path: string) => {
17
+ const normalizedPath = path.trim().toLowerCase()
18
+ return normalizedPath.endsWith(".json")
19
+ ? "application/json"
20
+ : "text/plain; charset=utf-8"
21
+ }
22
+
23
+ const sortObject = (value: unknown): unknown => {
24
+ if (Array.isArray(value)) {
25
+ return value.map(sortObject)
26
+ }
27
+
28
+ if (value && typeof value === "object") {
29
+ const entries = Object.entries(value as Record<string, unknown>)
30
+ .sort(([a], [b]) => (a > b ? 1 : a < b ? -1 : 0))
31
+ .map(([key, nestedValue]) => [key, sortObject(nestedValue)])
32
+
33
+ return Object.fromEntries(entries)
34
+ }
35
+
36
+ return value
37
+ }
@@ -0,0 +1,11 @@
1
+ import { type MediaItem, mediaItemSchema } from "../../types/media-item"
2
+ import { writeJson } from "./file-system"
3
+
4
+ export const loadMediaItem = (id: string) =>
5
+ fetch(`/api/media/${id}.json`)
6
+ .then((response) => response.json())
7
+ .then((json) => mediaItemSchema.parse(json.content))
8
+
9
+ export const updateMediaItem = async (id: string, item: MediaItem) => {
10
+ return writeJson(`/src/content/media/${id}.json`, item)
11
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from "astro/zod"
2
+
3
+ const NON_EMPTY_STRING = "ln.admin.errors.non-empty-string"
4
+
5
+ export const mediaItemSchema = z.object({
6
+ commonId: z.string().nonempty(NON_EMPTY_STRING),
7
+ title: z.string().nonempty(NON_EMPTY_STRING),
8
+ })
9
+
10
+ export type MediaItem = z.infer<typeof mediaItemSchema>
@@ -0,0 +1,16 @@
1
+ import type { APIRoute, GetStaticPaths } from "astro"
2
+ import { getCollection } from "astro:content"
3
+
4
+ import { getRawMediaItem } from "../../content/get-media-items"
5
+
6
+ export const getStaticPaths = (async () => {
7
+ const mediaItems = await getCollection("media")
8
+ return mediaItems.map(({ id: mediaId }) => ({ params: { mediaId } }))
9
+ }) satisfies GetStaticPaths
10
+
11
+ export const GET: APIRoute = async ({ params: { mediaId } }) => {
12
+ const entry = await getRawMediaItem(mediaId!)
13
+ return new Response(
14
+ JSON.stringify({ id: entry.id, content: entry.data }, null, 2),
15
+ )
16
+ }
@@ -1,6 +1,6 @@
1
1
  import type { APIRoute } from "astro"
2
2
 
3
- import pkg from "../../../package.json" assert { type: "json" }
3
+ import pkg from "../../package.json" assert { type: "json" }
4
4
 
5
5
  export const GET: APIRoute = () => {
6
6
  return new Response(JSON.stringify({ lightnet: pkg.version }))
@@ -217,6 +217,21 @@ export const configSchema = z.object({
217
217
  hideHeaderSearchIcon: z.boolean().default(false),
218
218
  })
219
219
  .optional(),
220
+ /**
221
+ * Experimental features. Subject to change with any release.
222
+ */
223
+ experimental: z
224
+ .object({
225
+ /**
226
+ * Configure administration interface.
227
+ */
228
+ admin: z
229
+ .object({
230
+ enabled: z.boolean().default(false),
231
+ })
232
+ .optional(),
233
+ })
234
+ .optional(),
220
235
  })
221
236
 
222
237
  export type Language = z.input<typeof languageSchema>
@@ -19,6 +19,7 @@ export function lightnet(lightnetConfig: LightnetConfig): AstroIntegration {
19
19
  updateConfig,
20
20
  logger,
21
21
  addMiddleware,
22
+ command,
22
23
  }) => {
23
24
  const config = verifySchema(
24
25
  configSchema,
@@ -46,23 +47,60 @@ export function lightnet(lightnetConfig: LightnetConfig): AstroIntegration {
46
47
  })
47
48
 
48
49
  injectRoute({
49
- pattern: "/api/search.json",
50
- entrypoint: "lightnet/pages/api/search.ts",
50
+ pattern: "/[locale]/media/[mediaId]",
51
+ entrypoint: "lightnet/pages/DetailsPageRoute.astro",
51
52
  prerender: true,
52
53
  })
53
54
 
54
55
  injectRoute({
55
- pattern: "/api/versions.json",
56
- entrypoint: "lightnet/pages/api/versions.ts",
56
+ pattern: "/api/internal/search.json",
57
+ entrypoint: "lightnet/api/internal/search.ts",
57
58
  prerender: true,
58
59
  })
59
60
 
60
61
  injectRoute({
61
- pattern: "/[locale]/media/[mediaId]",
62
- entrypoint: "lightnet/pages/DetailsPageRoute.astro",
62
+ pattern: "/api/versions.json",
63
+ entrypoint: "lightnet/api/versions.ts",
63
64
  prerender: true,
64
65
  })
65
66
 
67
+ if (config.experimental?.admin?.enabled) {
68
+ injectRoute({
69
+ pattern: "/api/media/[mediaId].json",
70
+ entrypoint: "lightnet/api/media/[mediaId].ts",
71
+ prerender: true,
72
+ })
73
+
74
+ injectRoute({
75
+ pattern: "/[locale]/admin",
76
+ entrypoint: "lightnet/admin/pages/AdminRoute.astro",
77
+ prerender: true,
78
+ })
79
+ injectRoute({
80
+ pattern: "/[locale]/admin/media/[mediaId]",
81
+ entrypoint: "lightnet/admin/pages/media/EditRoute.astro",
82
+ prerender: true,
83
+ })
84
+ }
85
+
86
+ // During local development admin ui can use
87
+ // this endpoints to write files.
88
+ if (config.experimental?.admin?.enabled && command === "dev") {
89
+ injectRoute({
90
+ pattern: "/api/internal/fs/writeText",
91
+ entrypoint: "lightnet/api/internal/fs/writeText.ts",
92
+ prerender: false,
93
+ })
94
+ // Add empty adapter to avoid warning
95
+ // about missing adapter.
96
+ // This hack might break in the future :(
97
+ // We could also set the "node" adapter if no
98
+ // adapter has been set by user.
99
+ if (!astroConfig.adapter) {
100
+ updateConfig({ adapter: {} })
101
+ }
102
+ }
103
+
66
104
  addMiddleware({ entrypoint: "lightnet/locals", order: "pre" })
67
105
 
68
106
  astroConfig.integrations.push(tailwind(), react())
@@ -7,7 +7,7 @@ import { searchPagePath } from "../utils/paths"
7
7
  import CarouselSection from "./CarouselSection.astro"
8
8
  import Section, { type Props as SectionProps } from "./Section.astro"
9
9
 
10
- type Props = SectionProps & {
10
+ type Props = Omit<SectionProps, "maxWidth"> & {
11
11
  layout?: "grid" | "carousel"
12
12
  }
13
13
 
@@ -20,7 +20,7 @@ type MediaItem = {
20
20
  }
21
21
  }
22
22
 
23
- type Props = SectionProps & {
23
+ type Props = Omit<SectionProps, "maxWidth"> & {
24
24
  items: (MediaItem | undefined)[]
25
25
  layout: ItemStyle
26
26
  viewLayout?: "grid" | "carousel"
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ export type ToastVariant = "info" | "success" | "warning" | "error"
4
+
5
+ export type ToastProps = {
6
+ id?: string
7
+ children: ReactNode
8
+ className?: string
9
+ variant?: ToastVariant
10
+ }
11
+
12
+ const variantClassName: Record<ToastVariant, string> = {
13
+ info: "border-slate-400 bg-white/95",
14
+ success: "border-emerald-500 bg-emerald-100",
15
+ warning: "border-amber-500 bg-amber-100",
16
+ error: "border-rose-500 bg-rose-100",
17
+ }
18
+
19
+ export default function Toast({
20
+ id,
21
+ children,
22
+ className = "",
23
+ variant = "info",
24
+ }: ToastProps) {
25
+ const alertClasses = variantClassName[variant] ?? variantClassName.info
26
+ const ariaLive = variant === "error" ? "assertive" : "polite"
27
+ const hiddenTransform = "translateY(1.5rem)"
28
+ const overshootTransform = "translateY(-0.25rem)"
29
+ const visibleTransform = "translateY(0)"
30
+
31
+ return (
32
+ <div
33
+ id={id}
34
+ className={`pointer-events-none fixed bottom-4 end-0 flex justify-end px-4 opacity-0 transition duration-300 will-change-transform ${className}`}
35
+ data-toast="true"
36
+ data-variant={variant}
37
+ data-toast-hidden-transform={hiddenTransform}
38
+ data-toast-overshoot-transform={overshootTransform}
39
+ data-toast-visible-transform={visibleTransform}
40
+ role="status"
41
+ aria-live={ariaLive}
42
+ style={{
43
+ transform: hiddenTransform,
44
+ transitionProperty: "opacity, transform",
45
+ transitionTimingFunction: "cubic-bezier(0.34, 1.56, 0.64, 1)",
46
+ }}
47
+ >
48
+ <div
49
+ className={`pointer-events-auto flex max-w-sm flex-col items-start gap-1 rounded-2xl border p-4 text-base shadow-md backdrop-blur-sm ${alertClasses}`}
50
+ >
51
+ {children}
52
+ </div>
53
+ </div>
54
+ )
55
+ }
@@ -0,0 +1,61 @@
1
+ const DEFAULT_DURATION_MS = 3000
2
+ const TIMEOUT_DATA_KEY = "toastHideTimeoutId"
3
+ const DEFAULT_HIDDEN_TRANSFORM = "translateY(1.5rem)"
4
+ const DEFAULT_VISIBLE_TRANSFORM = "translateY(0)"
5
+ const DEFAULT_OVERSHOOT_TRANSFORM = "translateY(-0.25rem)"
6
+
7
+ type ShowToastOptions = {
8
+ duration?: number
9
+ }
10
+
11
+ /**
12
+ * Shows a toast element by toggling its opacity and schedules it to hide again.
13
+ * Works with markup rendered by the Toast component but can target any element.
14
+ */
15
+ export function showToast(
16
+ element: HTMLElement,
17
+ options: ShowToastOptions = {},
18
+ ) {
19
+ const duration = options.duration ?? DEFAULT_DURATION_MS
20
+ const existingTimeoutId = element.dataset[TIMEOUT_DATA_KEY]
21
+ const hiddenTransform =
22
+ element.dataset.toastHiddenTransform ?? DEFAULT_HIDDEN_TRANSFORM
23
+ const overshootTransform =
24
+ element.dataset.toastOvershootTransform ?? DEFAULT_OVERSHOOT_TRANSFORM
25
+ const visibleTransform =
26
+ element.dataset.toastVisibleTransform ?? DEFAULT_VISIBLE_TRANSFORM
27
+
28
+ if (existingTimeoutId) {
29
+ window.clearTimeout(Number(existingTimeoutId))
30
+ }
31
+
32
+ element.style.opacity = "100%"
33
+ element.style.transform = overshootTransform
34
+ element.dataset.toastVisible = "true"
35
+
36
+ const settleIntoPlace = () => {
37
+ element.style.transform = visibleTransform
38
+ }
39
+
40
+ window.requestAnimationFrame(() => {
41
+ window.requestAnimationFrame(settleIntoPlace)
42
+ })
43
+
44
+ const timeoutId = window.setTimeout(() => {
45
+ element.style.opacity = "0%"
46
+ element.style.transform = hiddenTransform
47
+ element.dataset.toastVisible = "false"
48
+ delete element.dataset[TIMEOUT_DATA_KEY]
49
+ }, duration)
50
+
51
+ element.dataset[TIMEOUT_DATA_KEY] = String(timeoutId)
52
+ }
53
+
54
+ export function showToastById(id: string, options?: ShowToastOptions) {
55
+ const element = document.getElementById(id)
56
+ if (!element) {
57
+ return
58
+ }
59
+
60
+ showToast(element as HTMLElement, options)
61
+ }
@@ -1,17 +1,4 @@
1
- import { type ImageFunction, z } from "astro:content"
2
-
3
- /**
4
- * We use this function to make sure decap's relative paths will resolve correctly
5
- * with astro content.
6
- *
7
- * @param image astro content image function
8
- * @returns image property
9
- */
10
- export const astroImage = (image: ImageFunction) =>
11
- z
12
- .string()
13
- .transform((path) => (path.startsWith("./") ? path : `./${path}`))
14
- .pipe(image())
1
+ import { z } from "astro/zod"
15
2
 
16
3
  /**
17
4
  * The Astro image function resolves to this schema.
@@ -3,7 +3,7 @@ import { z } from "astro/zod"
3
3
  import type { SchemaContext } from "astro:content"
4
4
  import { defineCollection, reference } from "astro:content"
5
5
 
6
- import { astroImage, imageSchema } from "./astro-image"
6
+ import { imageSchema } from "./astro-image"
7
7
 
8
8
  /**
9
9
  * Category Schema
@@ -172,12 +172,12 @@ export const mediaItemSchema = z.object({
172
172
  */
173
173
  export const createMediaItemSchema = ({ image }: SchemaContext) =>
174
174
  mediaItemSchema.extend({
175
- image: astroImage(image),
175
+ image: image(),
176
176
  })
177
177
 
178
178
  export const createCategorySchema = ({ image }: SchemaContext) =>
179
179
  categorySchema.extend({
180
- image: astroImage(image).optional(),
180
+ image: image().optional(),
181
181
  })
182
182
 
183
183
  /**
@@ -315,6 +315,13 @@ export const LIGHTNET_COLLECTIONS = {
315
315
  }),
316
316
  schema: mediaTypeSchema,
317
317
  }),
318
+ "internal-media-image-path": defineCollection({
319
+ loader: glob({
320
+ pattern: "*.json",
321
+ base: "./src/content/media",
322
+ }),
323
+ schema: z.object({ image: z.string() }),
324
+ }),
318
325
  }
319
326
 
320
327
  export const mediaItemEntrySchema = z.object({
@@ -1,7 +1,7 @@
1
1
  import { getCollection, getEntry } from "astro:content"
2
2
 
3
3
  import { verifySchemaAsync } from "../utils/verify-schema"
4
- import { mediaItemEntrySchema } from "./content-schema"
4
+ import { type MediaItemEntry, mediaItemEntrySchema } from "./content-schema"
5
5
 
6
6
  /**
7
7
  * Internal API to get media items. Since this package is a Astro integration
@@ -26,3 +26,48 @@ const prepareItem = async (item: unknown) => {
26
26
  (id) => `Fix these issues inside "src/content/media/${id}.json":`,
27
27
  )
28
28
  }
29
+
30
+ /**
31
+ * Revert media items like they it is stored in the
32
+ * content collection folder.
33
+ */
34
+ export const getRawMediaItem = async (id: string) => {
35
+ const item = await getMediaItem(id)
36
+ return revertMediaItemEntry(item)
37
+ }
38
+
39
+ /**
40
+ * Revert media items like they are stored in the
41
+ * content collection folder.
42
+ */
43
+ export const getRawMediaItems = async () => {
44
+ const mediaItems = await getMediaItems()
45
+ return Promise.all(mediaItems.map(revertMediaItemEntry))
46
+ }
47
+
48
+ /**
49
+ * Returns the media item like it is stored in the content collection json.
50
+ * We need to revert Astro's modifications to references and images.
51
+ *
52
+ * @param mediaItem media item parsed by Astro
53
+ * @returns media item like before parsing
54
+ */
55
+ async function revertMediaItemEntry({ id, data: mediaItem }: MediaItemEntry) {
56
+ const type = mediaItem.type.id
57
+ const categories = mediaItem.categories?.map((category) => category.id)
58
+ const collections = mediaItem.collections?.map((collection) => ({
59
+ ...collection,
60
+ collection: collection.collection.id,
61
+ }))
62
+ const image = (await getEntry("internal-media-image-path", id))?.data.image
63
+ return {
64
+ id,
65
+ data: {
66
+ ...mediaItem,
67
+ type,
68
+ categories,
69
+ collections,
70
+ image,
71
+ },
72
+ }
73
+ }
@@ -3,36 +3,43 @@ declare namespace App {
3
3
  /**
4
4
  * Provides internationalization helpers.
5
5
  */
6
- i18n: {
7
- /**
8
- * Translate a key to the language of the current locale.
9
- *
10
- * @param TranslationKey to be translated.
11
- */
12
- t: import("./translate").TranslateFn
6
+ i18n: I18n
7
+ }
8
+ }
13
9
 
14
- /**
15
- * The current locale or the default locale if the current locale is not available.
16
- *
17
- * In comparison to Astro.currentLocale this will always return a locale.
18
- * Use Astro.currentLocale if you want to know the locale that is included in the current path.
19
- */
20
- currentLocale: string
10
+ type I18n = {
11
+ /**
12
+ * Translate a key to the language of the current locale.
13
+ *
14
+ * @param TranslationKey to be translated.
15
+ */
16
+ t: import("./translate").TranslateFn
21
17
 
22
- /**
23
- * The current text direction. Left-to-right or right-to-left.
24
- */
25
- direction: "ltr" | "rtl"
18
+ /**
19
+ * The current locale or the default locale if the current locale is not available.
20
+ *
21
+ * In comparison to Astro.currentLocale this will always return a locale.
22
+ * Use Astro.currentLocale if you want to know the locale that is included in the current path.
23
+ */
24
+ currentLocale: string
26
25
 
27
- /**
28
- * The default locale as defined in the project configuration.
29
- */
30
- defaultLocale: string
26
+ /**
27
+ * The current text direction. Left-to-right or right-to-left.
28
+ */
29
+ direction: "ltr" | "rtl"
31
30
 
32
- /**
33
- * The available locales as defined in the project configuration.
34
- */
35
- locales: string[]
36
- }
37
- }
31
+ /**
32
+ * The default locale as defined in the project configuration.
33
+ */
34
+ defaultLocale: string
35
+
36
+ /**
37
+ * The available locales as defined in the project configuration.
38
+ */
39
+ locales: string[]
40
+
41
+ /**
42
+ * All available translation keys.
43
+ */
44
+ translationKeys: string[]
38
45
  }
@@ -4,7 +4,7 @@ import config from "virtual:lightnet/config"
4
4
  import { resolveDefaultLocale } from "./resolve-default-locale"
5
5
  import { resolveLanguage } from "./resolve-language"
6
6
  import { resolveLocales } from "./resolve-locales"
7
- import { useTranslate } from "./translate"
7
+ import { translationKeys, useTranslate } from "./translate"
8
8
 
9
9
  export const onRequest: MiddlewareHandler = (
10
10
  { locals, currentLocale: astroCurrentLocale },
@@ -22,6 +22,7 @@ export const onRequest: MiddlewareHandler = (
22
22
  defaultLocale,
23
23
  direction,
24
24
  locales,
25
+ translationKeys,
25
26
  }
26
27
  }
27
28
  return next()
@@ -0,0 +1,32 @@
1
+ import { createContext } from "react"
2
+
3
+ export type I18n = {
4
+ t: (key: string) => string
5
+ currentLocale: string
6
+ direction: "rtl" | "ltr"
7
+ }
8
+
9
+ export type I18nConfig = Omit<I18n, "t"> & {
10
+ translations: Record<string, string>
11
+ }
12
+
13
+ export const I18nContext = createContext<I18n | undefined>(undefined)
14
+
15
+ /**
16
+ * Creates the runtime i18n helpers given a prepared configuration.
17
+ * Wraps the raw translation dictionary with a lookup that throws on missing keys.
18
+ */
19
+ export const createI18n = ({
20
+ translations,
21
+ currentLocale,
22
+ direction,
23
+ }: I18nConfig) => {
24
+ const t = (key: string) => {
25
+ const translated = translations[key]
26
+ if (!translated) {
27
+ throw new Error(`Missing translation for key ${key}`)
28
+ }
29
+ return translated
30
+ }
31
+ return { t, currentLocale, direction }
32
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Prepares the configuration object passed from an Astro page to the React i18n context.
3
+ * Resolves every requested translation key (supporting wildcard suffixes like `ln.dashboard.*`)
4
+ * so the React island receives only the strings it needs.
5
+ *
6
+ * @param i18n i18n helpers sourced from `Astro.locals`.
7
+ * @param translationKeys Specific keys (or wildcard groups) required by the React component.
8
+ * @returns A configuration object containing the resolved config.
9
+ */
10
+ export const prepareI18nConfig = (
11
+ { t, translationKeys: allKeys, currentLocale, direction }: I18n,
12
+ translationKeys: string[],
13
+ ) => {
14
+ const resolveTranslations = (key: string) => {
15
+ if (key.endsWith("*")) {
16
+ const keyPrefix = key.slice(0, -1)
17
+ return allKeys
18
+ .filter((k) => k.startsWith(keyPrefix))
19
+ .map((k) => [k, t(k)])
20
+ }
21
+ return [[key, t(key)]]
22
+ }
23
+
24
+ return {
25
+ translations: Object.fromEntries(
26
+ translationKeys.flatMap(resolveTranslations),
27
+ ) as Record<string, string>,
28
+ currentLocale,
29
+ direction,
30
+ }
31
+ }
@@ -0,0 +1,15 @@
1
+ import { useContext } from "react"
2
+
3
+ import { I18nContext } from "./i18n-context"
4
+
5
+ /**
6
+ * Retrieves the current i18n helpers from context.
7
+ * Must be called inside a React tree wrapped with `I18nContext.Provider`, otherwise throws.
8
+ */
9
+ export const useI18n = () => {
10
+ const i18n = useContext(I18nContext)
11
+ if (!i18n) {
12
+ throw new Error("No i18n context has been provided")
13
+ }
14
+ return i18n
15
+ }
@@ -22,18 +22,30 @@ const languageCodes = [
22
22
  ]
23
23
  const defaultLocale = resolveDefaultLocale(config)
24
24
 
25
- await i18next.init({
25
+ const translations = await prepareI18nextTranslations()
26
+ export const translationKeys = [
27
+ ...new Set(
28
+ Object.values(translations)
29
+ .map(({ translation }) => translation)
30
+ .flatMap((oneLanguageTranslations) =>
31
+ Object.keys(oneLanguageTranslations),
32
+ ),
33
+ ),
34
+ ]
35
+
36
+ const i18n = i18next.createInstance()
37
+ await i18n.init({
26
38
  lng: defaultLocale,
27
39
  // don't use name spacing
28
40
  nsSeparator: false,
29
41
  // only use flat keys
30
42
  keySeparator: false,
31
- resources: await prepareI18nextTranslations(),
43
+ resources: translations,
32
44
  })
33
45
 
34
46
  export function useTranslate(bcp47: string | undefined): TranslateFn {
35
47
  const resolvedLocale = bcp47 ?? defaultLocale
36
- const t = i18next.getFixedT<TranslationKey>(resolvedLocale)
48
+ const t = i18n.getFixedT<TranslationKey>(resolvedLocale)
37
49
  const fallbackLng = [
38
50
  ...resolveLanguage(resolvedLocale).fallbackLanguages,
39
51
  defaultLocale,