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
@@ -1,34 +1,34 @@
1
1
  import { AstroError } from "astro/errors"
2
2
  import { getCollection } from "astro:content"
3
3
 
4
- import type { TranslateMapFn } from "../i18n/translate-map"
4
+ import type { TranslateContentFieldFn } from "../i18n/translate-map"
5
5
  import { lazy } from "../utils/lazy"
6
6
  import { verifySchemaAsync } from "../utils/verify-schema"
7
- import { categoryEntrySchema } from "./content-schema"
8
7
  import { getMediaItems } from "./get-media-items"
8
+ import { categoryEntrySchema } from "./schema/category"
9
9
 
10
10
  const categoriesById = lazy(async () =>
11
11
  Object.fromEntries(
12
- (await loadCategories()).map(({ id, data }) => [id, data]),
12
+ (await loadCategories()).map((category) => [category.id, category]),
13
13
  ),
14
14
  )
15
15
 
16
- const contentCategories = lazy(async () => {
16
+ const contentCategoryIds = lazy(async () => {
17
17
  const categories = await categoriesById.get()
18
- return Object.fromEntries(
18
+ return new Set<string>(
19
19
  (await getMediaItems())
20
20
  .flatMap((item) => item.data.categories ?? [])
21
- .map((c) => {
22
- const category = categories[c.id]
21
+ .map(({ id }) => {
22
+ const category = categories[id]
23
23
  if (!category) {
24
24
  throw new AstroError(
25
25
  "Missing Category",
26
- `A media item references a non-existent category: "${c.id}".\n` +
26
+ `A media item references a non-existent category: "${id}".\n` +
27
27
  `To fix this, create a category file at:\n` +
28
- `src/content/categories/${c.id}.json`,
28
+ `src/content/categories/${id}.json`,
29
29
  )
30
30
  }
31
- return [c.id, category]
31
+ return id
32
32
  }),
33
33
  )
34
34
  })
@@ -42,18 +42,13 @@ const contentCategories = lazy(async () => {
42
42
  */
43
43
  export async function getUsedCategories(
44
44
  currentLocale: string,
45
- tMap: TranslateMapFn,
45
+ tContentField: TranslateContentFieldFn,
46
46
  ) {
47
- const categories = await contentCategories.get()
48
- return [...Object.entries(categories)]
49
- .map(([id, data]) => ({
50
- id,
51
- ...data,
52
- labelText: tMap(data.label, {
53
- path: ["categories", id, "label"],
54
- }),
55
- }))
56
- .sort((a, b) => a.labelText.localeCompare(b.labelText, currentLocale))
47
+ const usedIds = await contentCategoryIds.get()
48
+ // we intentionally translate all categories because we want
49
+ // to record translations also for unreferenced categories
50
+ const categories = await getCategories(currentLocale, tContentField)
51
+ return categories.filter(({ id }) => usedIds.has(id))
57
52
  }
58
53
 
59
54
  /**
@@ -66,21 +61,22 @@ export async function getUsedCategories(
66
61
  */
67
62
  export async function getCategories(
68
63
  currentLocale: string,
69
- tMap: TranslateMapFn,
64
+ tContentField: TranslateContentFieldFn,
70
65
  ) {
71
66
  const categories = await categoriesById.get()
72
67
  return [...Object.entries(categories)]
73
- .map(([id, data]) => ({
68
+ .map(([id, category]) => ({
74
69
  id,
75
- ...data,
76
- labelText: tMap(data.label, {
77
- path: ["categories", id, "label"],
78
- }),
70
+ ...category.data,
71
+ labelText: tContentField(category.data.label, category),
79
72
  }))
80
73
  .sort((a, b) => a.labelText.localeCompare(b.labelText, currentLocale))
81
74
  }
82
75
 
83
- export async function getCategory(id: string) {
76
+ export async function getCategory(
77
+ id: string,
78
+ tContentField: TranslateContentFieldFn,
79
+ ) {
84
80
  const category = (await categoriesById.get())[id]
85
81
  if (!category) {
86
82
  throw new AstroError(
@@ -90,7 +86,8 @@ export async function getCategory(id: string) {
90
86
  }
91
87
  return {
92
88
  id,
93
- ...category,
89
+ ...category.data,
90
+ labelText: tContentField(category.data.label, category),
94
91
  }
95
92
  }
96
93
 
@@ -1,35 +1,22 @@
1
- import { resolveLanguage } from "../i18n/resolve-language"
2
- import type { TranslateMapFn } from "../i18n/translate-map"
1
+ import { getTranslatedLanguages } from "../i18n/resolve-language"
2
+ import type { TranslateConfigFieldFn } from "../i18n/translate-map"
3
3
  import { lazy } from "../utils/lazy"
4
4
  import { getMediaItems } from "./get-media-items"
5
5
 
6
- type ResolvedLanguage = Awaited<ReturnType<typeof resolveLanguage>>
7
-
8
6
  /**
9
- * Array of distinct content languages.
7
+ * Distinct language codes referenced by media items.
10
8
  */
11
- const contentLanguages = lazy(async () => {
12
- const languagesByCode = Object.fromEntries(
13
- await Promise.all(
14
- (await getMediaItems()).map(async ({ data: { language } }) => [
15
- language,
16
- resolveLanguage(language),
17
- ]),
18
- ),
19
- )
20
- return Object.values(languagesByCode) as ResolvedLanguage[]
21
- })
9
+ const contentLanguagesLoader = lazy(
10
+ async () =>
11
+ new Set((await getMediaItems()).map(({ data: { language } }) => language)),
12
+ )
22
13
 
23
- export const getUsedLanguages = async (
14
+ export const getContentLanguages = async (
24
15
  currentLocale: string,
25
- tMap: TranslateMapFn,
16
+ tConfigField: TranslateConfigFieldFn,
26
17
  ) => {
27
- return (await contentLanguages.get())
28
- .map((language) => ({
29
- ...language,
30
- labelText: tMap(language.label, {
31
- path: ["languages", language.code, "label"],
32
- }),
33
- }))
34
- .sort((a, b) => a.labelText.localeCompare(b.labelText, currentLocale))
18
+ const contentLanguages = await contentLanguagesLoader.get()
19
+ return (await getTranslatedLanguages(currentLocale, tConfigField)).filter(
20
+ ({ code }) => contentLanguages.has(code),
21
+ )
35
22
  }
@@ -2,7 +2,7 @@ import { getCollection } from "astro:content"
2
2
 
3
3
  import { lazy } from "../utils/lazy"
4
4
  import { verifySchemaAsync } from "../utils/verify-schema"
5
- import { mediaCollectionEntrySchema } from "./content-schema"
5
+ import { mediaCollectionEntrySchema } from "./schema/media-collection"
6
6
 
7
7
  const collectionsByMediaItemIds = lazy(async () =>
8
8
  (await loadMediaCollections())
@@ -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 { mediaItemEntrySchema } from "./schema/media-item"
5
5
 
6
6
  /**
7
7
  * Internal API to get media items. Since this package is a Astro integration
@@ -1,17 +1,17 @@
1
1
  import { AstroError } from "astro/errors"
2
2
  import { getCollection } from "astro:content"
3
3
 
4
- import type { TranslateMapFn } from "../i18n/translate-map"
4
+ import type { TranslateContentFieldFn } from "../i18n/translate-map"
5
5
  import { lazy } from "../utils/lazy"
6
6
  import { verifySchema } from "../utils/verify-schema"
7
- import { mediaTypeEntrySchema } from "./content-schema"
8
7
  import { getMediaItems } from "./get-media-items"
8
+ import { mediaTypeEntrySchema } from "./schema/media-type"
9
9
 
10
10
  const typesById = lazy(async () =>
11
11
  Object.fromEntries((await loadTypes()).map((type) => [type.id, type])),
12
12
  )
13
13
 
14
- const contentTypes = lazy(
14
+ const contentTypesLoader = lazy(
15
15
  async () => new Set((await getMediaItems()).map((item) => item.data.type.id)),
16
16
  )
17
17
 
@@ -26,18 +26,25 @@ export const getMediaType = async (id: string) => {
26
26
  return type
27
27
  }
28
28
 
29
- export const getUsedMediaTypes = async (
29
+ export async function getUsedMediaTypes(
30
30
  currentLocale: string,
31
- tMap: TranslateMapFn,
32
- ) => {
33
- const byId = await typesById.get()
34
- return Array.from(await contentTypes.get(), (typeId) => byId[typeId])
35
- .map(({ id, data }) => ({
36
- id,
37
- ...data,
38
- labelText: tMap(data.label, {
39
- path: ["media-types", id, "label"],
40
- }),
31
+ tContentField: TranslateContentFieldFn,
32
+ ) {
33
+ const contentTypes = await contentTypesLoader.get()
34
+ const mediaTypes = await getTranslatedMediaTypes(currentLocale, tContentField)
35
+ return mediaTypes.filter(({ id }) => contentTypes.has(id))
36
+ }
37
+
38
+ export async function getTranslatedMediaTypes(
39
+ currentLocale: string,
40
+ tContentField: TranslateContentFieldFn,
41
+ ) {
42
+ const mediaTypes = Object.values(await typesById.get())
43
+ return mediaTypes
44
+ .map((mediaType) => ({
45
+ id: mediaType.id,
46
+ ...mediaType.data,
47
+ labelText: tContentField(mediaType.data.label, mediaType),
41
48
  }))
42
49
  .sort((a, b) => a.labelText.localeCompare(b.labelText, currentLocale))
43
50
  }
@@ -1,4 +1,5 @@
1
- import type { MediaCollectionEntry, MediaItemEntry } from "./content-schema"
1
+ import type { MediaCollectionEntry } from "./schema/media-collection"
2
+ import type { MediaItemEntry } from "./schema/media-item"
2
3
 
3
4
  export type MediaItemQuery<TMediaItem extends MediaItemEntry> = {
4
5
  /**
@@ -0,0 +1,40 @@
1
+ import { z } from "astro/zod"
2
+ import type { SchemaContext } from "astro:content"
3
+
4
+ import { translationMapSchema } from "../../i18n/translation-map-schema"
5
+ import { imageSchema } from "../astro-image"
6
+
7
+ /**
8
+ * Category Schema
9
+ */
10
+ export const categorySchema = z.object({
11
+ /**
12
+ * Name of the category.
13
+ *
14
+ * Label translated for the default locale. Other configured site locales are optional.
15
+ */
16
+ label: translationMapSchema,
17
+
18
+ /* Relative path to the thumbnail image of this category.
19
+ *
20
+ * The image is expected to be inside the `images` folder next to category definition json.
21
+ * It can have one of these file types: png, jpg, tiff, webp, gif, svg, avif.
22
+ * We suggest to give it a size of at least 1000px for it's longer side.
23
+ *
24
+ * @example "./images/devotionals.jpg"
25
+ */
26
+ image: imageSchema.optional(),
27
+ })
28
+
29
+ export const createCategorySchema = ({ image }: SchemaContext) =>
30
+ categorySchema.extend({
31
+ image: image().optional(),
32
+ })
33
+
34
+ export const categoryEntrySchema = z.object({
35
+ id: z.string(),
36
+ collection: z.literal("categories"),
37
+ data: categorySchema,
38
+ })
39
+
40
+ export type CategoryEntry = z.infer<typeof categoryEntrySchema>
@@ -0,0 +1,31 @@
1
+ import { z } from "astro/zod"
2
+ import { reference } from "astro:content"
3
+
4
+ import { translationMapSchema } from "../../i18n/translation-map-schema"
5
+
6
+ /**
7
+ * Media Collection Schema
8
+ */
9
+ export const mediaCollectionSchema = z.object({
10
+ /**
11
+ * Name of the collection.
12
+ *
13
+ * Label translated for the default locale. Other configured site locales are optional.
14
+ */
15
+ label: translationMapSchema,
16
+ /**
17
+ * Ordered list of media items included in this collection.
18
+ * The array order defines how items are shown when querying by collection.
19
+ *
20
+ * @example ["my-book--en", "my-video--en"]
21
+ */
22
+ mediaItems: z.array(reference("media")),
23
+ })
24
+
25
+ export const mediaCollectionEntrySchema = z.object({
26
+ id: z.string(),
27
+ collection: z.literal("media-collections"),
28
+ data: mediaCollectionSchema,
29
+ })
30
+
31
+ export type MediaCollectionEntry = z.infer<typeof mediaCollectionEntrySchema>
@@ -0,0 +1,137 @@
1
+ import { z } from "astro/zod"
2
+ import type { SchemaContext } from "astro:content"
3
+ import { reference } from "astro:content"
4
+
5
+ import { translationMapSchema } from "../../i18n/translation-map-schema"
6
+ import { imageSchema } from "../astro-image"
7
+
8
+ /**
9
+ * Media Item Schema
10
+ */
11
+ export const mediaItemSchema = z.object({
12
+ /**
13
+ * Optional identifier used to link translated variants of a media item.
14
+ * If other media items share the same commonId they will show up as translations.
15
+ * If omitted, the media item is treated as standalone and has no translations.
16
+ * The common id will show up in the media item's url combined with it's language.
17
+ *
18
+ * We suggest you use the english name of the media item, all lower case, words separated with hyphens.
19
+ *
20
+ * @example "a-book-about-love"
21
+ */
22
+ commonId: z.string().optional(),
23
+ /**
24
+ * Title of this media item.
25
+ * This is expected to be in the language that is defined by the 'language' property.
26
+ *
27
+ * @example "A book about love"
28
+ */
29
+ title: z.string(),
30
+ /**
31
+ * References one media-type by its filename without .json suffix.
32
+ *
33
+ * @example "book"
34
+ */
35
+ type: reference("media-types"),
36
+ /**
37
+ *Describes this media item. You can use markdown syntax to add formatting.
38
+ * This is expected to be in the language that is defined by the 'language' property.
39
+ *
40
+ * @example "This is a book about **love**..."
41
+ */
42
+ description: z.string().optional(),
43
+ /**
44
+ * List of authors of this media item.
45
+ *
46
+ * @example ["George Miller", "Timothy Meier"]
47
+ */
48
+ authors: z.array(z.string()).nullish(),
49
+ /**
50
+ * Date this media item has been created on this lightnet instance.
51
+ * Format is YYYY-MM-DD
52
+ *
53
+ * @example 2024-09-10
54
+ */
55
+ dateCreated: z.iso.date(),
56
+ /**
57
+ * List of categories of this media item.
58
+ *
59
+ * @example ["family"]
60
+ */
61
+ categories: z.array(reference("categories")).nullish(),
62
+ /**
63
+ * BCP-47 language code of this media item.
64
+ *
65
+ * @example "en"
66
+ */
67
+ language: z.string().nonempty(),
68
+ /**
69
+ * Relative path to the image of this media item. Eg. a book cover or video thumbnail.
70
+ *
71
+ * The image is expected to be inside the `images` folder next to the media item definition json.
72
+ * This image will be used for previews and on the media item detail page.
73
+ * It can have one of these file types: png, jpg, tiff, webp, gif, svg, avif.
74
+ * We suggest to give it a size of at least 1000px for it's longer side.
75
+ *
76
+ * @example "./images/a-book-about-love--en.jpg"
77
+ */
78
+ image: imageSchema,
79
+ /**
80
+ * List of objects defining the content of this media item.
81
+ */
82
+ content: z
83
+ .array(
84
+ z.object({
85
+ /**
86
+ * Storage kind for this content item.
87
+ *
88
+ * - `"upload"`: a file managed by this LightNet site (typically a relative path like `/files/...`)
89
+ * - `"link"`: an external URL (typically `https://...`)
90
+ *
91
+ * @example "upload"
92
+ */
93
+ type: z.enum(["upload", "link"]),
94
+ /**
95
+ * Urls might be:
96
+ * - links to youtube videos
97
+ * - links to vimeo videos
98
+ * - links to .mp4 video files
99
+ * - links to .mp3 audio files
100
+ * - links to external websites
101
+ * - links to pdfs (might be hosted inside the public/files/ folder)
102
+ * - links to epubs (might be hosted inside the public/files/ folder)
103
+ *
104
+ * @example "/files/a-book-about-love.pdf"
105
+ */
106
+ url: z.string(),
107
+ /**
108
+ * The name of the content translated for the default locale.
109
+ * Other configured site locales are optional.
110
+ * If this is not set, the file name from URL will be used.
111
+ */
112
+ label: translationMapSchema.optional(),
113
+ }),
114
+ )
115
+ .min(1),
116
+ })
117
+
118
+ /**
119
+ * MediaItemSchema above defines the shape of a media item.
120
+ * We need this function to accept the astro content's image function that
121
+ * is available inside defineCollection.
122
+ *
123
+ * @param schemaContext that is passed by astro's defineCollection schema.
124
+ * @returns schema with image mixed in.
125
+ */
126
+ export const createMediaItemSchema = ({ image }: SchemaContext) =>
127
+ mediaItemSchema.extend({
128
+ image: image(),
129
+ })
130
+
131
+ export const mediaItemEntrySchema = z.object({
132
+ id: z.string(),
133
+ collection: z.literal("media"),
134
+ data: mediaItemSchema,
135
+ })
136
+
137
+ export type MediaItemEntry = z.infer<typeof mediaItemEntrySchema>
@@ -0,0 +1,90 @@
1
+ import { z } from "astro/zod"
2
+
3
+ import { translationMapSchema } from "../../i18n/translation-map-schema"
4
+
5
+ /**
6
+ * Media Type Schema
7
+ */
8
+ export const mediaTypeSchema = z.object({
9
+ /**
10
+ * Name of this media type that will be shown on the pages.
11
+ *
12
+ * Label translated for the default locale. Other configured site locales are optional.
13
+ */
14
+ label: translationMapSchema,
15
+ /**
16
+ * Defines how the cover image for a media item of this type is rendered.
17
+ *
18
+ * Options:
19
+ * - `"default"` — Renders the media item image with no modifications.
20
+ * - `"book"` — Adds a book fold effect and sharper edges, styled like a book cover.
21
+ * - `"video"` — Constrains the image to a 16:9 aspect ratio with a black background.
22
+ *
23
+ * @default "default"
24
+ */
25
+ coverImageStyle: z.enum(["default", "book", "video"]).default("default"),
26
+ /**
27
+ * What media item details page to use for media items with this type.
28
+ *
29
+ */
30
+ detailsPage: z
31
+ .discriminatedUnion("layout", [
32
+ z.object({
33
+ /**
34
+ * Details page for all media types.
35
+ */
36
+ layout: z.literal("default"),
37
+ /**
38
+ * Label for the open action button. Use this if you want to change the text
39
+ * of the "Open" button to be more matching to your media item.
40
+ * For example you could change the text to be "Read" for a book media type.
41
+ *
42
+ * Label translated for the default locale. Other configured site locales are optional.
43
+ */
44
+ openActionLabel: translationMapSchema.optional(),
45
+ }),
46
+ z.object({
47
+ /**
48
+ * Custom details page.
49
+ */
50
+ layout: z.literal("custom"),
51
+ /**
52
+ * This references a custom component name to be used for the
53
+ * details page. The custom component has be located at src/details-pages/
54
+ *
55
+ * @example "MyArticleDetails.astro"
56
+ */
57
+ customComponent: z.string(),
58
+ }),
59
+ z.object({
60
+ /**
61
+ * Detail page for videos.
62
+ */
63
+ layout: z.literal("video"),
64
+ }),
65
+ z.object({
66
+ /**
67
+ * Detail page for audio files.
68
+ *
69
+ * This only supports mp3 files.
70
+ */
71
+ layout: z.literal("audio"),
72
+ }),
73
+ ])
74
+ .optional(),
75
+ /**
76
+ * Pick the media type's icon from https://lucide.dev/icons/
77
+ * Prefix it's name with "lucide--"
78
+ *
79
+ * @example "lucide--book-open"
80
+ */
81
+ icon: z.string(),
82
+ })
83
+
84
+ export const mediaTypeEntrySchema = z.object({
85
+ id: z.string(),
86
+ collection: z.literal("media-types"),
87
+ data: mediaTypeSchema,
88
+ })
89
+
90
+ export type MediaTypeEntry = z.infer<typeof mediaTypeEntrySchema>
@@ -33,6 +33,28 @@ type I18n = {
33
33
  */
34
34
  tMap: import("./translate-map").TranslateMapFn
35
35
 
36
+ /**
37
+ * Resolve an inline translation map that belongs to LightNet config.
38
+ *
39
+ * Use this for translated values read from the resolved runtime config,
40
+ * such as the site title or configured language labels.
41
+ *
42
+ * @param translationMap Localized values keyed by locale code.
43
+ * @param config The resolved LightNet config object that owns the field.
44
+ */
45
+ tConfigField: import("./translate-map").TranslateConfigFieldFn
46
+
47
+ /**
48
+ * Resolve an inline translation map that belongs to a content entry.
49
+ *
50
+ * Use this for translated values stored inside content collections,
51
+ * such as category labels, media type labels, or content item labels.
52
+ *
53
+ * @param translationMap Localized values keyed by locale code.
54
+ * @param contentEntry The content entry that owns the translated field.
55
+ */
56
+ tContentField: import("./translate-map").TranslateContentFieldFn
57
+
36
58
  /**
37
59
  * The current locale resolved by LightNet from the URL pathname.
38
60
  *
@@ -20,11 +20,13 @@ export const onRequest: MiddlewareHandler = async ({ locals, url }, next) => {
20
20
  useTranslate(currentLocale),
21
21
  getTranslationKeys(),
22
22
  ])
23
- const tMap = useTranslateMap(currentLocale)
23
+ const { tMap, tConfigField, tContentField } = useTranslateMap(currentLocale)
24
24
  const { direction } = resolveLanguage(currentLocale)
25
25
  locals.i18n = {
26
26
  t,
27
27
  tMap,
28
+ tConfigField,
29
+ tContentField,
28
30
  currentLocale,
29
31
  defaultLocale,
30
32
  direction,
@@ -0,0 +1,74 @@
1
+ import { createWriteStream } from "node:fs"
2
+ import { mkdir, unlink, writeFile } from "node:fs/promises"
3
+ import { resolve } from "node:path"
4
+ import process from "node:process"
5
+
6
+ import { root } from "astro:config/server"
7
+ import config from "virtual:lightnet/config"
8
+
9
+ import { lazy } from "../utils/lazy"
10
+
11
+ type Translation = {
12
+ type: "map" | "user" | "lightnet"
13
+ key: string
14
+ values: Record<string, string | undefined>
15
+ }
16
+
17
+ const lightnetCachePath = resolve(
18
+ root.pathname,
19
+ "node_modules",
20
+ ".cache",
21
+ "lightnet",
22
+ )
23
+
24
+ const recordedTranslations = new Set<string>()
25
+
26
+ const translationStore = lazy(() => createTranslationStore())
27
+
28
+ const writeLanguagesManifest = async () => {
29
+ const manifestPath = resolve(lightnetCachePath, "languages.json")
30
+ const { defaultLocale, locales } = config
31
+ const manifest = {
32
+ defaultLocale,
33
+ locales,
34
+ }
35
+ await writeFile(manifestPath, JSON.stringify(manifest), "utf-8")
36
+ }
37
+
38
+ const createTranslationStore = async () => {
39
+ const translationStorePath = resolve(lightnetCachePath, "translations.jsonl")
40
+
41
+ await mkdir(lightnetCachePath, { recursive: true })
42
+ try {
43
+ await unlink(translationStorePath)
44
+ } catch {
45
+ // catch error if file has not been existing
46
+ }
47
+
48
+ const store = createWriteStream(translationStorePath, {
49
+ flags: "a",
50
+ encoding: "utf8",
51
+ })
52
+
53
+ await writeLanguagesManifest()
54
+
55
+ process.on("exit", () => store?.end())
56
+ process.on("SIGINT", () => store?.end())
57
+ return store
58
+ }
59
+
60
+ export async function recordTranslation(translation: Translation) {
61
+ if (!import.meta.env.PROD) {
62
+ // only record during build
63
+ return
64
+ }
65
+
66
+ const key = translation.type + translation.key
67
+ if (recordedTranslations.has(key)) {
68
+ return
69
+ }
70
+ recordedTranslations.add(key)
71
+
72
+ const store = await translationStore.get()
73
+ store.write(JSON.stringify(translation) + "\n")
74
+ }