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.
- package/CHANGELOG.md +20 -0
- package/__e2e__/admin.spec.ts +113 -0
- package/__e2e__/fixtures/basics/astro.config.mjs +6 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
- package/__e2e__/fixtures/basics/package.json +2 -2
- package/__e2e__/fixtures/basics/src/content/media/skate-sounds--en.json +15 -0
- package/__e2e__/fixtures/basics/src/content/media-types/audio.json +7 -0
- package/__e2e__/fixtures/basics/src/translations/de.yml +1 -0
- package/__e2e__/fixtures/basics/src/translations/en.yml +1 -0
- package/__e2e__/homepage.spec.ts +21 -0
- package/package.json +13 -6
- package/src/admin/api/fs/writeText.ts +50 -0
- package/src/admin/components/form/FieldErrors.tsx +22 -0
- package/src/admin/components/form/SubmitButton.tsx +79 -0
- package/src/admin/components/form/TextField.tsx +24 -0
- package/src/admin/components/form/form-context.ts +4 -0
- package/src/admin/components/form/index.ts +16 -0
- package/src/admin/i18n/translations/en.yml +11 -0
- package/src/admin/i18n/translations.ts +5 -0
- package/src/admin/pages/AdminRoute.astro +16 -0
- package/src/admin/pages/media/EditForm.tsx +73 -0
- package/src/admin/pages/media/EditRoute.astro +42 -0
- package/src/admin/pages/media/file-system.ts +37 -0
- package/src/admin/pages/media/media-item-store.ts +11 -0
- package/src/admin/types/media-item.ts +10 -0
- package/src/api/media/[mediaId].ts +16 -0
- package/src/{pages/api → api}/versions.ts +1 -1
- package/src/astro-integration/config.ts +15 -0
- package/src/astro-integration/integration.ts +44 -6
- package/src/components/CategoriesSection.astro +1 -1
- package/src/components/MediaGallerySection.astro +1 -1
- package/src/components/Toast.tsx +55 -0
- package/src/components/showToast.ts +61 -0
- package/src/content/astro-image.ts +1 -14
- package/src/content/content-schema.ts +10 -3
- package/src/content/get-media-items.ts +46 -1
- package/src/i18n/locals.d.ts +35 -28
- package/src/i18n/locals.ts +2 -1
- package/src/i18n/react/i18n-context.ts +32 -0
- package/src/i18n/react/prepare-i18n-config.ts +31 -0
- package/src/i18n/react/useI18n.ts +15 -0
- package/src/i18n/translate.ts +15 -3
- package/src/i18n/translations.ts +20 -7
- package/src/layouts/Page.astro +1 -1
- package/src/pages/details-page/components/MainDetailsSection.astro +5 -1
- package/src/pages/details-page/components/VideoDetailsSection.astro +5 -1
- package/src/pages/details-page/components/main-details/EditButton.astro +30 -0
- package/src/pages/details-page/components/main-details/ShareButton.astro +9 -13
- package/src/pages/{api → search-page/api}/search.ts +3 -3
- package/src/pages/search-page/components/LoadingSkeleton.tsx +3 -1
- package/src/pages/search-page/components/SearchFilter.astro +12 -3
- package/src/pages/search-page/components/SearchFilter.tsx +4 -7
- package/src/pages/search-page/components/SearchList.astro +7 -6
- package/src/pages/search-page/components/SearchList.tsx +12 -15
- package/src/pages/search-page/components/SearchListItem.tsx +3 -5
- package/src/pages/search-page/hooks/use-search.ts +3 -3
- package/tailwind.config.ts +1 -0
- package/src/pages/search-page/utils/search-filter-translations.ts +0 -20
- package/src/pages/search-page/utils/search-translations.ts +0 -11
- /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 "
|
|
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: "/
|
|
50
|
-
entrypoint: "lightnet/pages/
|
|
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/
|
|
56
|
-
entrypoint: "lightnet/
|
|
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: "/
|
|
62
|
-
entrypoint: "lightnet/
|
|
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
|
|
|
@@ -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 {
|
|
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 {
|
|
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:
|
|
175
|
+
image: image(),
|
|
176
176
|
})
|
|
177
177
|
|
|
178
178
|
export const createCategorySchema = ({ image }: SchemaContext) =>
|
|
179
179
|
categorySchema.extend({
|
|
180
|
-
image:
|
|
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
|
+
}
|
package/src/i18n/locals.d.ts
CHANGED
|
@@ -3,36 +3,43 @@ declare namespace App {
|
|
|
3
3
|
/**
|
|
4
4
|
* Provides internationalization helpers.
|
|
5
5
|
*/
|
|
6
|
-
i18n:
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
* @param TranslationKey to be translated.
|
|
11
|
-
*/
|
|
12
|
-
t: import("./translate").TranslateFn
|
|
6
|
+
i18n: I18n
|
|
7
|
+
}
|
|
8
|
+
}
|
|
13
9
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The current text direction. Left-to-right or right-to-left.
|
|
28
|
+
*/
|
|
29
|
+
direction: "ltr" | "rtl"
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
}
|
package/src/i18n/locals.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/i18n/translate.ts
CHANGED
|
@@ -22,18 +22,30 @@ const languageCodes = [
|
|
|
22
22
|
]
|
|
23
23
|
const defaultLocale = resolveDefaultLocale(config)
|
|
24
24
|
|
|
25
|
-
await
|
|
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:
|
|
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 =
|
|
48
|
+
const t = i18n.getFixedT<TranslationKey>(resolvedLocale)
|
|
37
49
|
const fallbackLng = [
|
|
38
50
|
...resolveLanguage(resolvedLocale).fallbackLanguages,
|
|
39
51
|
defaultLocale,
|