lightnet 3.12.2 → 4.0.1

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 (111) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/exports/content.ts +7 -2
  3. package/exports/i18n.ts +0 -1
  4. package/exports/index.ts +1 -5
  5. package/exports/utils.ts +0 -1
  6. package/package.json +26 -12
  7. package/src/astro-integration/config.ts +60 -49
  8. package/src/astro-integration/integration.ts +13 -24
  9. package/src/astro-integration/tailwind.ts +86 -0
  10. package/src/astro-integration/validators/validate-inline-translations.ts +51 -0
  11. package/src/astro-integration/validators/validate-languages.ts +39 -0
  12. package/src/astro-integration/virtual.d.ts +8 -6
  13. package/src/astro-integration/vite-plugin-lightnet-config.ts +29 -9
  14. package/src/components/CarouselSection.astro +7 -11
  15. package/src/components/CategoriesSection.astro +2 -2
  16. package/src/components/HighlightSection.astro +4 -7
  17. package/src/components/Icon.tsx +2 -2
  18. package/src/components/MediaGallerySection.astro +88 -68
  19. package/src/components/MediaList.astro +9 -7
  20. package/src/components/SearchInput.astro +7 -4
  21. package/src/components/Section.astro +7 -5
  22. package/src/components/VideoPlayer.astro +2 -3
  23. package/src/content/content-schema.ts +129 -142
  24. package/src/content/get-categories.ts +52 -28
  25. package/src/content/get-languages.ts +29 -8
  26. package/src/content/get-media-collections.ts +43 -0
  27. package/src/content/get-media-types.ts +41 -7
  28. package/src/content/query-media-items.ts +23 -13
  29. package/src/i18n/bcp-47.ts +8 -0
  30. package/src/i18n/get-locale-paths.ts +1 -3
  31. package/src/i18n/locals.d.ts +21 -3
  32. package/src/i18n/locals.ts +18 -11
  33. package/src/i18n/resolve-current-locale.ts +18 -0
  34. package/src/i18n/resolve-language.ts +10 -5
  35. package/src/i18n/translate-map.ts +70 -0
  36. package/src/i18n/translate.ts +68 -47
  37. package/src/layouts/Page.astro +5 -3
  38. package/src/layouts/components/LanguagePicker.astro +22 -17
  39. package/src/layouts/components/Menu.astro +2 -5
  40. package/src/layouts/components/MenuItem.astro +1 -1
  41. package/src/layouts/components/PageNavigation.astro +29 -29
  42. package/src/layouts/components/PageTitle.astro +23 -7
  43. package/src/pages/404Route.astro +2 -1
  44. package/src/pages/RootRoute.astro +6 -1
  45. package/src/pages/details-page/DefaultDetailsPage.astro +9 -2
  46. package/src/pages/details-page/DetailsPageRoute.astro +1 -2
  47. package/src/pages/details-page/components/AudioPanel.astro +7 -3
  48. package/src/pages/details-page/components/AudioPlayer.astro +2 -2
  49. package/src/pages/details-page/components/ContentSection.astro +67 -44
  50. package/src/pages/details-page/components/MediaCollection.astro +8 -4
  51. package/src/pages/details-page/components/MediaCollectionsSection.astro +3 -6
  52. package/src/pages/details-page/components/main-details/EditButton.astro +22 -10
  53. package/src/pages/details-page/components/main-details/OpenButton.astro +17 -12
  54. package/src/pages/details-page/components/main-details/ShareButton.astro +3 -2
  55. package/src/pages/details-page/components/more-details/Categories.astro +5 -3
  56. package/src/pages/details-page/components/more-details/Languages.astro +12 -7
  57. package/src/pages/details-page/utils/create-content-metadata.ts +24 -9
  58. package/src/pages/details-page/utils/get-translations.ts +6 -0
  59. package/src/pages/search-page/components/LoadingSkeleton.tsx +6 -5
  60. package/src/pages/search-page/components/SearchFilter.astro +10 -21
  61. package/src/pages/search-page/components/SearchFilter.tsx +2 -2
  62. package/src/pages/search-page/components/SearchList.astro +10 -7
  63. package/src/pages/search-page/components/SearchListItem.tsx +5 -4
  64. package/src/pages/search-page/hooks/use-search.ts +5 -2
  65. package/src/utils/lazy.ts +20 -0
  66. package/src/utils/paths.ts +40 -3
  67. package/src/utils/urls.ts +1 -2
  68. package/src/utils/verify-schema.ts +12 -10
  69. package/tailwind.config.ts +1 -25
  70. package/__e2e__/admin.spec.ts +0 -20
  71. package/__e2e__/basics-fixture.ts +0 -77
  72. package/__e2e__/fixtures/basics/astro.config.mjs +0 -40
  73. package/__e2e__/fixtures/basics/node_modules/.bin/astro +0 -21
  74. package/__e2e__/fixtures/basics/node_modules/.bin/tailwind +0 -21
  75. package/__e2e__/fixtures/basics/node_modules/.bin/tailwindcss +0 -21
  76. package/__e2e__/fixtures/basics/node_modules/.bin/tsc +0 -21
  77. package/__e2e__/fixtures/basics/node_modules/.bin/tsserver +0 -21
  78. package/__e2e__/fixtures/basics/package.json +0 -21
  79. package/__e2e__/fixtures/basics/public/favicon.svg +0 -1
  80. package/__e2e__/fixtures/basics/public/files/example.pdf +0 -0
  81. package/__e2e__/fixtures/basics/src/assets/logo.png +0 -0
  82. package/__e2e__/fixtures/basics/src/content/categories/christian-living.json +0 -3
  83. package/__e2e__/fixtures/basics/src/content/categories/teens.json +0 -3
  84. package/__e2e__/fixtures/basics/src/content/categories/theology.json +0 -3
  85. package/__e2e__/fixtures/basics/src/content/media/faithful-freestyle--en.json +0 -13
  86. package/__e2e__/fixtures/basics/src/content/media/how-to-kickflip--de.json +0 -12
  87. package/__e2e__/fixtures/basics/src/content/media/images/cover.jpg +0 -0
  88. package/__e2e__/fixtures/basics/src/content/media/images/how-to-kickflip--en.webp +0 -0
  89. package/__e2e__/fixtures/basics/src/content/media/skate-sounds--en.json +0 -15
  90. package/__e2e__/fixtures/basics/src/content/media-collections/how-to-articles.json +0 -3
  91. package/__e2e__/fixtures/basics/src/content/media-types/audio.json +0 -7
  92. package/__e2e__/fixtures/basics/src/content/media-types/book.json +0 -9
  93. package/__e2e__/fixtures/basics/src/content/media-types/video.json +0 -7
  94. package/__e2e__/fixtures/basics/src/content.config.ts +0 -3
  95. package/__e2e__/fixtures/basics/src/pages/[locale]/index.astro +0 -16
  96. package/__e2e__/fixtures/basics/src/translations/de.yml +0 -9
  97. package/__e2e__/fixtures/basics/src/translations/en.yml +0 -9
  98. package/__e2e__/fixtures/basics/tailwind.config.mjs +0 -8
  99. package/__e2e__/global.teardown.ts +0 -5
  100. package/__e2e__/homepage.spec.ts +0 -123
  101. package/__e2e__/search.spec.ts +0 -14
  102. package/__tests__/pages/details-page/create-content-metadata.spec.ts +0 -135
  103. package/__tests__/utils/markdown.spec.ts +0 -74
  104. package/__tests__/utils/urls.spec.ts +0 -27
  105. package/playwright.config.ts +0 -31
  106. package/src/astro-integration/project-context.ts +0 -5
  107. package/src/content/compare-media-collection-items.ts +0 -24
  108. package/src/i18n/resolve-default-locale.ts +0 -19
  109. package/src/i18n/resolve-locales.ts +0 -5
  110. package/src/pages/details-page/utils/get-collection-items.ts +0 -29
  111. package/vitest.config.js +0 -20
@@ -5,6 +5,22 @@ import { defineCollection, reference } from "astro:content"
5
5
 
6
6
  import { imageSchema } from "./astro-image"
7
7
 
8
+ /**
9
+ * Translations by BCP-47 tag
10
+ * Must contain at least one localized value.
11
+ *
12
+ * @example
13
+ * {
14
+ * de: "Hallo",
15
+ * en: "Hello"
16
+ * }
17
+ */
18
+ export const inlineTranslationSchema = z
19
+ .record(z.string(), z.string())
20
+ .refine((value) => Object.keys(value).length > 0, {
21
+ message: "Inline translations must contain at least one entry",
22
+ })
23
+
8
24
  /**
9
25
  * Category Schema
10
26
  */
@@ -12,12 +28,9 @@ export const categorySchema = z.object({
12
28
  /**
13
29
  * Name of the category.
14
30
  *
15
- * This can either be a translation key or a string that will be displayed as is.
16
- * LightNet will try to use it as translation key first if no translation is found it will use the string as is.
17
- *
18
- * @example "category.biography"
31
+ * Label translated for the default locale. Other configured site locales are optional.
19
32
  */
20
- label: z.string(),
33
+ label: inlineTranslationSchema,
21
34
 
22
35
  /* Relative path to the thumbnail image of this category.
23
36
  *
@@ -37,10 +50,16 @@ export const mediaCollectionSchema = z.object({
37
50
  /**
38
51
  * Name of the collection.
39
52
  *
40
- * This can either be a translation key or a string that will be displayed as is.
41
- * LightNet will try to use it as translation key first if no translation is found it will use the string as is.
53
+ * Label translated for the default locale. Other configured site locales are optional.
54
+ */
55
+ label: inlineTranslationSchema,
56
+ /**
57
+ * Ordered list of media items included in this collection.
58
+ * The array order defines how items are shown when querying by collection.
59
+ *
60
+ * @example ["my-book--en", "my-video--en"]
42
61
  */
43
- label: z.string(),
62
+ mediaItems: z.array(reference("media")),
44
63
  })
45
64
 
46
65
  /**
@@ -48,15 +67,16 @@ export const mediaCollectionSchema = z.object({
48
67
  */
49
68
  export const mediaItemSchema = z.object({
50
69
  /**
51
- * Identifier of this media item. If other media items
52
- * share the same commonId they will show up as translations.
70
+ * Optional identifier used to link translated variants of a media item.
71
+ * If other media items share the same commonId they will show up as translations.
72
+ * If omitted, the media item is treated as standalone and has no translations.
53
73
  * The common id will show up in the media item's url combined with it's language.
54
74
  *
55
75
  * We suggest you use the english name of the media item, all lower case, words separated with hyphens.
56
76
  *
57
77
  * @example "a-book-about-love"
58
78
  */
59
- commonId: z.string(),
79
+ commonId: z.string().optional(),
60
80
  /**
61
81
  * Title of this media item.
62
82
  * This is expected to be in the language that is defined by the 'language' property.
@@ -89,7 +109,7 @@ export const mediaItemSchema = z.object({
89
109
  *
90
110
  * @example 2024-09-10
91
111
  */
92
- dateCreated: z.string().date(),
112
+ dateCreated: z.iso.date(),
93
113
  /**
94
114
  * List of categories of this media item.
95
115
  *
@@ -97,31 +117,11 @@ export const mediaItemSchema = z.object({
97
117
  */
98
118
  categories: z.array(reference("categories")).nullish(),
99
119
  /**
100
- * List of media collections this media item is included.
101
- * Collections can be used to group media items into series, playlists...
102
- *
103
- * @example [{collection:"my-series"}]
104
- */
105
- collections: z
106
- .array(
107
- z.object({
108
- /**
109
- * Id of the collection.
110
- */
111
- collection: reference("media-collections"),
112
- /**
113
- * Position of the item inside the collection.
114
- */
115
- index: z.number().optional(),
116
- }),
117
- )
118
- .nullish(),
119
- /**
120
- * BCP-47 name of the language this media item is in.
120
+ * BCP-47 language code of this media item.
121
121
  *
122
122
  * @example "en"
123
123
  */
124
- language: z.string(),
124
+ language: z.string().nonempty(),
125
125
  /**
126
126
  * Relative path to the image of this media item. Eg. a book cover or video thumbnail.
127
127
  *
@@ -139,6 +139,15 @@ export const mediaItemSchema = z.object({
139
139
  content: z
140
140
  .array(
141
141
  z.object({
142
+ /**
143
+ * Storage kind for this content item.
144
+ *
145
+ * - `"upload"`: a file managed by this LightNet site (typically a relative path like `/files/...`)
146
+ * - `"link"`: an external URL (typically `https://...`)
147
+ *
148
+ * @example "upload"
149
+ */
150
+ type: z.enum(["upload", "link"]),
142
151
  /**
143
152
  * Urls might be:
144
153
  * - links to youtube videos
@@ -153,10 +162,11 @@ export const mediaItemSchema = z.object({
153
162
  */
154
163
  url: z.string(),
155
164
  /**
156
- * The name of the content. If this is not set. The file name
157
- * from URL will be used. This can either be a fixed string or a translation key.
165
+ * The name of the content translated for the default locale.
166
+ * Other configured site locales are optional.
167
+ * If this is not set, the file name from URL will be used.
158
168
  */
159
- label: z.string().optional(),
169
+ label: inlineTranslationSchema.optional(),
160
170
  }),
161
171
  )
162
172
  .min(1),
@@ -183,111 +193,81 @@ export const createCategorySchema = ({ image }: SchemaContext) =>
183
193
  /**
184
194
  * Media Type Schema
185
195
  */
186
- export const mediaTypeSchema = z
187
- .object({
188
- /**
189
- * Name of this media type that will be shown on the pages.
190
- *
191
- * This can either be a fixed string or a translation key.
192
- *
193
- * @example "media-type.book"
194
- */
195
- label: z.string(),
196
- /**
197
- * Defines how the cover image for a media item of this type is rendered.
198
- *
199
- * Options:
200
- * - `"default"` — Renders the media item image with no modifications.
201
- * - `"book"` — Adds a book fold effect and sharper edges, styled like a book cover.
202
- * - `"video"` — Constrains the image to a 16:9 aspect ratio with a black background.
203
- *
204
- * @default "default"
205
- */
206
- coverImageStyle: z.enum(["default", "book", "video"]).default("default"),
207
- /**
208
- * What media item details page to use for media items with this type.
209
- *
210
- */
211
- detailsPage: z
212
- .discriminatedUnion("layout", [
213
- z.object({
214
- /**
215
- * Details page for all media types.
216
- */
217
- layout: z.literal("default"),
218
- /**
219
- * Label for the open action button. Use this if you want to change the text
220
- * of the "Open" button to be more matching to your media item.
221
- * For example you could change the text to be "Read" for a book media type.
222
- *
223
- * The label is a translation key.
224
- *
225
- * @example "ln.details.open"
226
- */
227
- openActionLabel: z.string().optional(),
228
- /**
229
- * (Deprecated) Specifies the style of the cover image.
230
- *
231
- * Use `coverImageStyle` instead. This option will be removed in a future major release.
232
- *
233
- * Supported values:
234
- * - `"default"` — unmodified media item image
235
- * - `"book"` — styled as a book cover (book fold, sharper edges)
236
- *
237
- * @example "book"
238
- * @deprecated Use `coverImageStyle` instead
239
- */
240
- coverStyle: z.enum(["default", "book"]).default("default"),
241
- }),
242
- z.object({
243
- /**
244
- * Custom details page.
245
- */
246
- layout: z.literal("custom"),
247
- /**
248
- * This references a custom component name to be used for the
249
- * details page. The custom component has be located at src/details-pages/
250
- *
251
- * @example "MyArticleDetails.astro"
252
- */
253
- customComponent: z.string(),
254
- }),
255
- z.object({
256
- /**
257
- * Detail page for videos.
258
- */
259
- layout: z.literal("video"),
260
- }),
261
- z.object({
262
- /**
263
- * Detail page for audio files.
264
- *
265
- * This only supports mp3 files.
266
- */
267
- layout: z.literal("audio"),
268
- }),
269
- ])
270
- .optional(),
271
- /**
272
- * Pick the media type's icon from https://pictogrammers.com/library/mdi/
273
- * Prefix it's name with "mdi--"
274
- *
275
- * @example "mdi--ab-testing"
276
- */
277
- icon: z.string(),
278
- })
279
- .transform((mediaType) => {
280
- // migrate old cover images style to new property
281
- const hasDeprecatedBookCover =
282
- mediaType.detailsPage?.layout === "default" &&
283
- mediaType.detailsPage.coverStyle === "book"
284
- return {
285
- ...mediaType,
286
- coverImageStyle: hasDeprecatedBookCover
287
- ? "book"
288
- : mediaType.coverImageStyle,
289
- }
290
- })
196
+ export const mediaTypeSchema = z.object({
197
+ /**
198
+ * Name of this media type that will be shown on the pages.
199
+ *
200
+ * Label translated for the default locale. Other configured site locales are optional.
201
+ */
202
+ label: inlineTranslationSchema,
203
+ /**
204
+ * Defines how the cover image for a media item of this type is rendered.
205
+ *
206
+ * Options:
207
+ * - `"default"` Renders the media item image with no modifications.
208
+ * - `"book"` — Adds a book fold effect and sharper edges, styled like a book cover.
209
+ * - `"video"` — Constrains the image to a 16:9 aspect ratio with a black background.
210
+ *
211
+ * @default "default"
212
+ */
213
+ coverImageStyle: z.enum(["default", "book", "video"]).default("default"),
214
+ /**
215
+ * What media item details page to use for media items with this type.
216
+ *
217
+ */
218
+ detailsPage: z
219
+ .discriminatedUnion("layout", [
220
+ z.object({
221
+ /**
222
+ * Details page for all media types.
223
+ */
224
+ layout: z.literal("default"),
225
+ /**
226
+ * Label for the open action button. Use this if you want to change the text
227
+ * of the "Open" button to be more matching to your media item.
228
+ * For example you could change the text to be "Read" for a book media type.
229
+ *
230
+ * Label translated for the default locale. Other configured site locales are optional.
231
+ */
232
+ openActionLabel: inlineTranslationSchema.optional(),
233
+ }),
234
+ z.object({
235
+ /**
236
+ * Custom details page.
237
+ */
238
+ layout: z.literal("custom"),
239
+ /**
240
+ * This references a custom component name to be used for the
241
+ * details page. The custom component has be located at src/details-pages/
242
+ *
243
+ * @example "MyArticleDetails.astro"
244
+ */
245
+ customComponent: z.string(),
246
+ }),
247
+ z.object({
248
+ /**
249
+ * Detail page for videos.
250
+ */
251
+ layout: z.literal("video"),
252
+ }),
253
+ z.object({
254
+ /**
255
+ * Detail page for audio files.
256
+ *
257
+ * This only supports mp3 files.
258
+ */
259
+ layout: z.literal("audio"),
260
+ }),
261
+ ])
262
+ .optional(),
263
+ /**
264
+ * Pick the media type's icon from https://lucide.dev/icons/
265
+ * Prefix it's name with "lucide--"
266
+ *
267
+ * @example "lucide--book-open"
268
+ */
269
+ icon: z.string(),
270
+ })
291
271
 
292
272
  export const LIGHTNET_COLLECTIONS = {
293
273
  categories: defineCollection({
@@ -333,3 +313,10 @@ export const categoryEntrySchema = z.object({
333
313
  id: z.string(),
334
314
  data: categorySchema,
335
315
  })
316
+
317
+ export const mediaCollectionEntrySchema = z.object({
318
+ id: z.string(),
319
+ data: mediaCollectionSchema,
320
+ })
321
+
322
+ export type MediaCollectionEntry = z.infer<typeof mediaCollectionEntrySchema>
@@ -1,43 +1,58 @@
1
1
  import { AstroError } from "astro/errors"
2
2
  import { getCollection } from "astro:content"
3
3
 
4
- import { type TranslateFn } from "../i18n/translate"
4
+ import type { TranslateMapFn } from "../i18n/translate-map"
5
+ import { lazy } from "../utils/lazy"
5
6
  import { verifySchemaAsync } from "../utils/verify-schema"
6
7
  import { categoryEntrySchema } from "./content-schema"
7
8
  import { getMediaItems } from "./get-media-items"
8
9
 
9
- const categoriesById = Object.fromEntries(
10
- (await loadCategories()).map(({ id, data }) => [id, data]),
10
+ const categoriesById = lazy(async () =>
11
+ Object.fromEntries(
12
+ (await loadCategories()).map(({ id, data }) => [id, data]),
13
+ ),
11
14
  )
12
15
 
13
- const contentCategories = Object.fromEntries(
14
- (await getMediaItems())
15
- .flatMap((item) => item.data.categories ?? [])
16
- .map((c) => {
17
- const category = categoriesById[c.id]
18
- if (!category) {
19
- throw new AstroError(
20
- "Missing Category",
21
- `A media item references a non-existent category: "${c.id}".\n` +
22
- `To fix this, create a category file at:\n` +
23
- `src/content/categories/${c.id}.json`,
24
- )
25
- }
26
- return [c.id, category]
27
- }),
28
- )
16
+ const contentCategories = lazy(async () => {
17
+ const categories = await categoriesById.get()
18
+ return Object.fromEntries(
19
+ (await getMediaItems())
20
+ .flatMap((item) => item.data.categories ?? [])
21
+ .map((c) => {
22
+ const category = categories[c.id]
23
+ if (!category) {
24
+ throw new AstroError(
25
+ "Missing Category",
26
+ `A media item references a non-existent category: "${c.id}".\n` +
27
+ `To fix this, create a category file at:\n` +
28
+ `src/content/categories/${c.id}.json`,
29
+ )
30
+ }
31
+ return [c.id, category]
32
+ }),
33
+ )
34
+ })
29
35
 
30
36
  /**
31
37
  * Get categories that are referenced from media items.
32
38
  * Adds the translated name of the category and sorts by this name.
33
39
  *
34
40
  * @param currentLocale current locale
35
- * @param t translate function
36
41
  * @returns categories sorted by labelText
37
42
  */
38
- export async function getUsedCategories(currentLocale: string, t: TranslateFn) {
39
- return [...Object.entries(contentCategories)]
40
- .map(([id, data]) => ({ id, ...data, labelText: t(data.label) }))
43
+ export async function getUsedCategories(
44
+ currentLocale: string,
45
+ tMap: TranslateMapFn,
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
+ }))
41
56
  .sort((a, b) => a.labelText.localeCompare(b.labelText, currentLocale))
42
57
  }
43
58
 
@@ -47,17 +62,26 @@ export async function getUsedCategories(currentLocale: string, t: TranslateFn) {
47
62
  * by media items use `getUsedCategories`.
48
63
  *
49
64
  * @param currentLocale current locale
50
- * @param t translate function
51
65
  * @returns categories sorted by labelText
52
66
  */
53
- export async function getCategories(currentLocale: string, t: TranslateFn) {
54
- return [...Object.entries(categoriesById)]
55
- .map(([id, data]) => ({ id, ...data, labelText: t(data.label) }))
67
+ export async function getCategories(
68
+ currentLocale: string,
69
+ tMap: TranslateMapFn,
70
+ ) {
71
+ const categories = await categoriesById.get()
72
+ return [...Object.entries(categories)]
73
+ .map(([id, data]) => ({
74
+ id,
75
+ ...data,
76
+ labelText: tMap(data.label, {
77
+ path: ["categories", id, "label"],
78
+ }),
79
+ }))
56
80
  .sort((a, b) => a.labelText.localeCompare(b.labelText, currentLocale))
57
81
  }
58
82
 
59
83
  export async function getCategory(id: string) {
60
- const category = categoriesById[id]
84
+ const category = (await categoriesById.get())[id]
61
85
  if (!category) {
62
86
  throw new AstroError(
63
87
  `Missing category "${id}"`,
@@ -1,14 +1,35 @@
1
1
  import { resolveLanguage } from "../i18n/resolve-language"
2
+ import type { TranslateMapFn } from "../i18n/translate-map"
3
+ import { lazy } from "../utils/lazy"
2
4
  import { getMediaItems } from "./get-media-items"
3
5
 
6
+ type ResolvedLanguage = Awaited<ReturnType<typeof resolveLanguage>>
7
+
4
8
  /**
5
9
  * Array of distinct content languages.
6
10
  */
7
- export const contentLanguages = Object.values(
8
- Object.fromEntries(
9
- (await getMediaItems()).map(({ data: { language } }) => [
10
- language,
11
- resolveLanguage(language),
12
- ]),
13
- ),
14
- )
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
+ })
22
+
23
+ export const getUsedLanguages = async (
24
+ currentLocale: string,
25
+ tMap: TranslateMapFn,
26
+ ) => {
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))
35
+ }
@@ -0,0 +1,43 @@
1
+ import { getCollection } from "astro:content"
2
+
3
+ import { lazy } from "../utils/lazy"
4
+ import { verifySchemaAsync } from "../utils/verify-schema"
5
+ import { mediaCollectionEntrySchema } from "./content-schema"
6
+
7
+ const collectionsByMediaItemIds = lazy(async () =>
8
+ (await loadMediaCollections())
9
+ .flatMap((collection) =>
10
+ collection.data.mediaItems.map(({ id }) => [id, collection.id]),
11
+ )
12
+ .reduce(
13
+ (collected, [mediaId, collectionId]) => {
14
+ const collectionIds = collected[mediaId] ?? []
15
+
16
+ if (!collectionIds.includes(collectionId)) {
17
+ collected[mediaId] = [...collectionIds, collectionId]
18
+ }
19
+
20
+ return collected
21
+ },
22
+ {} as Record<string, string[]>,
23
+ ),
24
+ )
25
+
26
+ export const getCollectionsForMediaItem = async (mediaId: string) => {
27
+ return (await collectionsByMediaItemIds.get())[mediaId] ?? []
28
+ }
29
+
30
+ async function loadMediaCollections() {
31
+ const mediaCollections: unknown[] = await getCollection("media-collections")
32
+ return await Promise.all(mediaCollections.map(parseMediaCollection))
33
+ }
34
+
35
+ async function parseMediaCollection(item: unknown) {
36
+ return await verifySchemaAsync(
37
+ mediaCollectionEntrySchema,
38
+ item,
39
+ (id) => `Invalid media-collection: ${id}`,
40
+ (id) =>
41
+ `Fix these issues inside "src/content/media-collections/${id}.json":`,
42
+ )
43
+ }
@@ -1,18 +1,52 @@
1
- import { getCollection, getEntry } from "astro:content"
1
+ import { AstroError } from "astro/errors"
2
+ import { getCollection } from "astro:content"
2
3
 
4
+ import type { TranslateMapFn } from "../i18n/translate-map"
5
+ import { lazy } from "../utils/lazy"
3
6
  import { verifySchema } from "../utils/verify-schema"
4
7
  import { mediaTypeEntrySchema } from "./content-schema"
8
+ import { getMediaItems } from "./get-media-items"
9
+
10
+ const typesById = lazy(async () =>
11
+ Object.fromEntries((await loadTypes()).map((type) => [type.id, type])),
12
+ )
13
+
14
+ const contentTypes = lazy(
15
+ async () => new Set((await getMediaItems()).map((item) => item.data.type.id)),
16
+ )
5
17
 
6
18
  export const getMediaType = async (id: string) => {
7
- return verifySchema(
8
- mediaTypeEntrySchema,
9
- await getEntry("media-types", id),
10
- (id) => `Invalid media type: "${id}"`,
11
- (id) => `Fix these issues inside "src/content/media-types/${id}.json":`,
12
- )
19
+ const type = (await typesById.get())[id]
20
+ if (!type) {
21
+ throw new AstroError(
22
+ `No media type found for id ${id}`,
23
+ `Fix this by adding a media type definition at "src/content/media-types/${id}.json"`,
24
+ )
25
+ }
26
+ return type
27
+ }
28
+
29
+ export const getUsedMediaTypes = async (
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
+ }),
41
+ }))
42
+ .sort((a, b) => a.labelText.localeCompare(b.labelText, currentLocale))
13
43
  }
14
44
 
15
45
  export const getMediaTypes = async () => {
46
+ return Object.values(await typesById.get())
47
+ }
48
+
49
+ async function loadTypes() {
16
50
  const mediaTypes: unknown[] = await getCollection("media-types")
17
51
  return mediaTypes.map((type: unknown) =>
18
52
  verifySchema(