lightnet 2.15.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 +428 -0
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/__e2e__/detailPage.spec.ts +0 -0
- package/__e2e__/fixtures/basics/astro.config.mjs +38 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/astro +17 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/astro-check +17 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/tailwind +17 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/tailwindcss +17 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/tsc +17 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/tsserver +17 -0
- package/__e2e__/fixtures/basics/package.json +19 -0
- package/__e2e__/fixtures/basics/public/favicon.svg +1 -0
- package/__e2e__/fixtures/basics/public/files/example.pdf +0 -0
- package/__e2e__/fixtures/basics/src/assets/logo.png +0 -0
- package/__e2e__/fixtures/basics/src/content/categories/christian-living.json +3 -0
- package/__e2e__/fixtures/basics/src/content/categories/teens.json +3 -0
- package/__e2e__/fixtures/basics/src/content/categories/theology.json +3 -0
- package/__e2e__/fixtures/basics/src/content/media/faithful-freestyle--en.json +13 -0
- package/__e2e__/fixtures/basics/src/content/media/how-to-kickflip--de.json +12 -0
- package/__e2e__/fixtures/basics/src/content/media/images/cover.jpg +0 -0
- package/__e2e__/fixtures/basics/src/content/media/images/how-to-kickflip--en.webp +0 -0
- package/__e2e__/fixtures/basics/src/content/media-collections/how-to-articles.json +3 -0
- package/__e2e__/fixtures/basics/src/content/media-types/book.json +9 -0
- package/__e2e__/fixtures/basics/src/content/media-types/video.json +7 -0
- package/__e2e__/fixtures/basics/src/content.config.ts +3 -0
- package/__e2e__/fixtures/basics/src/pages/[locale]/index.astro +14 -0
- package/__e2e__/fixtures/basics/src/translations/de.json +10 -0
- package/__e2e__/fixtures/basics/src/translations/en.json +10 -0
- package/__e2e__/fixtures/basics/tailwind.config.mjs +8 -0
- package/__e2e__/homepage.spec.ts +108 -0
- package/__e2e__/search.spec.ts +16 -0
- package/__e2e__/test-utils.ts +80 -0
- package/__tests__/pages/details-page/create-content-metadata.spec.ts +104 -0
- package/__tests__/utils/markdown.spec.ts +33 -0
- package/exports/components.ts +9 -0
- package/exports/content.ts +8 -0
- package/exports/details-page.ts +1 -0
- package/exports/i18n.ts +2 -0
- package/exports/index.ts +6 -0
- package/exports/utils.ts +2 -0
- package/package.json +54 -0
- package/playwright.config.ts +30 -0
- package/src/astro-integration/config.ts +185 -0
- package/src/astro-integration/integration.ts +74 -0
- package/src/astro-integration/project-context.ts +5 -0
- package/src/astro-integration/virtual.d.ts +14 -0
- package/src/astro-integration/vite-plugin-lightnet-config.ts +55 -0
- package/src/components/CategoriesOverview.astro +37 -0
- package/src/components/Gallery.astro +121 -0
- package/src/components/Hero.astro +82 -0
- package/src/components/HighlightSection.astro +71 -0
- package/src/components/Icon.tsx +27 -0
- package/src/components/MediaItemList.astro +84 -0
- package/src/components/Section.astro +49 -0
- package/src/content/astro-image.ts +14 -0
- package/src/content/content-schema-internal.ts +52 -0
- package/src/content/content-schema.ts +263 -0
- package/src/content/external-api.ts +7 -0
- package/src/content/get-categories.ts +15 -0
- package/src/content/get-languages.ts +14 -0
- package/src/content/get-media-items.ts +27 -0
- package/src/content/get-media-types.ts +23 -0
- package/src/content/query-media-items.ts +89 -0
- package/src/content/resolve-category-label.ts +20 -0
- package/src/i18n/get-locale-paths.ts +8 -0
- package/src/i18n/languages.ts +10 -0
- package/src/i18n/locals.d.ts +38 -0
- package/src/i18n/locals.ts +28 -0
- package/src/i18n/resolve-default-locale.ts +30 -0
- package/src/i18n/resolve-language.ts +25 -0
- package/src/i18n/resolve-locales.ts +5 -0
- package/src/i18n/translate.ts +64 -0
- package/src/i18n/translations/de.json +25 -0
- package/src/i18n/translations/en.json +25 -0
- package/src/layouts/MarkdownPage.astro +11 -0
- package/src/layouts/Page.astro +54 -0
- package/src/layouts/components/Favicon.astro +32 -0
- package/src/layouts/components/LanguagePicker.astro +38 -0
- package/src/layouts/components/Menu.astro +28 -0
- package/src/layouts/components/MenuItem.astro +21 -0
- package/src/layouts/components/PageNavigation.astro +65 -0
- package/src/layouts/components/PageTitle.astro +44 -0
- package/src/layouts/components/PreloadReact.tsx +3 -0
- package/src/pages/404.astro +14 -0
- package/src/pages/RedirectToDefaultLocale.astro +3 -0
- package/src/pages/api/search-response.ts +14 -0
- package/src/pages/api/search.ts +47 -0
- package/src/pages/details-page/DefaultDetails.astro +44 -0
- package/src/pages/details-page/DetailsPage.astro +53 -0
- package/src/pages/details-page/VideoDetails.astro +43 -0
- package/src/pages/details-page/components/Authors.astro +19 -0
- package/src/pages/details-page/components/Content.astro +73 -0
- package/src/pages/details-page/components/Cover.astro +35 -0
- package/src/pages/details-page/components/Description.astro +26 -0
- package/src/pages/details-page/components/MediaCollection.astro +39 -0
- package/src/pages/details-page/components/MediaCollections.astro +21 -0
- package/src/pages/details-page/components/OpenButton.astro +34 -0
- package/src/pages/details-page/components/SectionTitle.astro +8 -0
- package/src/pages/details-page/components/ShareButton.astro +58 -0
- package/src/pages/details-page/components/Title.astro +18 -0
- package/src/pages/details-page/components/VideoPlayer.astro +78 -0
- package/src/pages/details-page/components/details/Categories.astro +31 -0
- package/src/pages/details-page/components/details/Details.astro +17 -0
- package/src/pages/details-page/components/details/Label.astro +3 -0
- package/src/pages/details-page/components/details/Languages.astro +46 -0
- package/src/pages/details-page/utils/create-content-metadata.ts +78 -0
- package/src/pages/search-page/Search.tsx +71 -0
- package/src/pages/search-page/SearchPage.astro +51 -0
- package/src/pages/search-page/components/ResultList.tsx +135 -0
- package/src/pages/search-page/components/SearchFilter.tsx +189 -0
- package/src/pages/search-page/hooks/use-debounce.ts +17 -0
- package/src/pages/search-page/hooks/use-search.ts +95 -0
- package/src/pages/search-page/types.ts +9 -0
- package/src/pages/search-page/utils/search-translations.ts +22 -0
- package/src/pages/search-page/utils/use-provided-translations.ts +5 -0
- package/src/utils/markdown.ts +41 -0
- package/src/utils/paths.ts +45 -0
- package/src/utils/urls.ts +29 -0
- package/src/utils/verify-schema.ts +38 -0
- package/tailwind.config.ts +56 -0
- package/vitest.config.js +19 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Image } from "astro:assets"
|
|
3
|
+
|
|
4
|
+
import type { MediaItemEntry } from "../content/content-schema-internal"
|
|
5
|
+
import { getMediaTypes } from "../content/get-media-types"
|
|
6
|
+
import { detailsPagePath } from "../utils/paths"
|
|
7
|
+
import Icon from "./Icon"
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
items: (MediaItemEntry & { disabled?: boolean })[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { items } = Astro.props
|
|
14
|
+
|
|
15
|
+
const { t, direction } = Astro.locals.i18n
|
|
16
|
+
|
|
17
|
+
const mediaTypes = Object.fromEntries(
|
|
18
|
+
(await getMediaTypes()).map((type) => [
|
|
19
|
+
type.id,
|
|
20
|
+
{
|
|
21
|
+
id: type.id,
|
|
22
|
+
name: t(type.data.label, { allowFixedStrings: true }),
|
|
23
|
+
icon: type.data.icon,
|
|
24
|
+
},
|
|
25
|
+
]),
|
|
26
|
+
)
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<ol class="divide-y divide-gray-200">
|
|
30
|
+
{
|
|
31
|
+
items.map((item) => (
|
|
32
|
+
<li lang={item.data.language}>
|
|
33
|
+
<a
|
|
34
|
+
href={
|
|
35
|
+
item.disabled
|
|
36
|
+
? "javascript:void(0)"
|
|
37
|
+
: detailsPagePath(Astro.currentLocale, {
|
|
38
|
+
id: item.id,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
class="group flex overflow-hidden py-4 transition-colors ease-in-out md:rounded-sm md:py-8 md:hover:bg-gray-100"
|
|
42
|
+
class:list={
|
|
43
|
+
item.disabled ? "pointer-events-none cursor-default opacity-50" : ""
|
|
44
|
+
}
|
|
45
|
+
tabindex={item.disabled ? -1 : 0}
|
|
46
|
+
>
|
|
47
|
+
<div class="flex h-32 w-32 shrink-0 flex-col items-start justify-center">
|
|
48
|
+
<Image
|
|
49
|
+
class="max-h-32 w-auto max-w-32 rounded-sm object-contain shadow-md"
|
|
50
|
+
src={item.data.image}
|
|
51
|
+
alt=""
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div
|
|
56
|
+
class="ms-5 flex grow flex-col justify-center sm:ms-8"
|
|
57
|
+
lang={item.data.language}
|
|
58
|
+
>
|
|
59
|
+
<p class="mb-1 line-clamp-3 font-bold text-gray-700 md:mb-3">
|
|
60
|
+
<Icon
|
|
61
|
+
className={`${mediaTypes[item.data.type.id].icon} me-2 align-bottom text-2xl text-gray-700`}
|
|
62
|
+
ariaLabel={mediaTypes[item.data.type.id].name}
|
|
63
|
+
/>
|
|
64
|
+
<span>{item.data.title}</span>
|
|
65
|
+
</p>
|
|
66
|
+
<div class="mb-3 flex flex-col flex-wrap items-start gap-2 md:flex-row md:items-center md:gap-3">
|
|
67
|
+
{!!item.data.authors?.length && (
|
|
68
|
+
<p class="mb-1 md:mb-0 md:text-base">
|
|
69
|
+
{item.data.authors.join(", ")}
|
|
70
|
+
</p>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<Icon
|
|
76
|
+
className="mdi--chevron-right hidden sm:block md:group-hover:text-primary my-auto ms-2 me-4 shrink-0 text-2xl text-gray-300"
|
|
77
|
+
flipIcon={direction === "rtl"}
|
|
78
|
+
ariaLabel=""
|
|
79
|
+
/>
|
|
80
|
+
</a>
|
|
81
|
+
</li>
|
|
82
|
+
))
|
|
83
|
+
}
|
|
84
|
+
</ol>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
/**
|
|
4
|
+
* Id to set to the section element.
|
|
5
|
+
* For example this can be used to reference it inside anchors.
|
|
6
|
+
*/
|
|
7
|
+
id?: string
|
|
8
|
+
/**
|
|
9
|
+
* Maximum width of the section container.
|
|
10
|
+
*
|
|
11
|
+
* - "full" takes the full available width of the LightNet page. This is not equal to the screen width.
|
|
12
|
+
* For example the full width typically is used on the homepage.
|
|
13
|
+
* - "prose" width ensures text is well readable. This is the width used on the details page.
|
|
14
|
+
*
|
|
15
|
+
* @default "full"
|
|
16
|
+
*/
|
|
17
|
+
maxWidth?: "full" | "prose"
|
|
18
|
+
/**
|
|
19
|
+
* Title on top of the section.
|
|
20
|
+
*/
|
|
21
|
+
title?: string
|
|
22
|
+
/**
|
|
23
|
+
* Css classes to set to the section element.
|
|
24
|
+
*/
|
|
25
|
+
className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { id, maxWidth = "full", title, className } = Astro.props
|
|
29
|
+
|
|
30
|
+
const maxWidths = {
|
|
31
|
+
full: "max-w-screen-xl",
|
|
32
|
+
prose: "max-w-screen-md",
|
|
33
|
+
}
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
<section
|
|
37
|
+
class="mx-auto mt-24 px-4 md:mt-28 md:px-8"
|
|
38
|
+
class:list={[maxWidths[maxWidth], className]}
|
|
39
|
+
id={id}
|
|
40
|
+
>
|
|
41
|
+
{
|
|
42
|
+
title && (
|
|
43
|
+
<h2 class="mb-10 text-2xl font-bold text-gray-700 sm:mb-12 sm:text-3xl">
|
|
44
|
+
{title}
|
|
45
|
+
</h2>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
<slot />
|
|
49
|
+
</section>
|
|
@@ -0,0 +1,14 @@
|
|
|
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())
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* We use this internal content schema to make content schema easier to
|
|
3
|
+
* comprehend for external users.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "astro/zod"
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
mediaItemSchema as externalMediaItemSchema,
|
|
9
|
+
mediaTypeSchema,
|
|
10
|
+
} from "./content-schema"
|
|
11
|
+
|
|
12
|
+
export type MediaItem = z.infer<typeof mediaItemSchema>
|
|
13
|
+
export type MediaItemEntry = {
|
|
14
|
+
id: string
|
|
15
|
+
data: MediaItem
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Media Item
|
|
20
|
+
*/
|
|
21
|
+
const mediaItemSchema = externalMediaItemSchema.extend({
|
|
22
|
+
image: z.object({
|
|
23
|
+
src: z.string(),
|
|
24
|
+
width: z.number(),
|
|
25
|
+
height: z.number(),
|
|
26
|
+
format: z.enum([
|
|
27
|
+
"png",
|
|
28
|
+
"jpg",
|
|
29
|
+
"jpeg",
|
|
30
|
+
"tiff",
|
|
31
|
+
"webp",
|
|
32
|
+
"gif",
|
|
33
|
+
"svg",
|
|
34
|
+
"avif",
|
|
35
|
+
]),
|
|
36
|
+
}),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const mediaItemEntrySchema = z.object({
|
|
40
|
+
id: z.string(),
|
|
41
|
+
data: mediaItemSchema,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Media Type
|
|
46
|
+
*/
|
|
47
|
+
export const mediaTypeEntrySchema = z.object({
|
|
48
|
+
id: z.string(),
|
|
49
|
+
data: mediaTypeSchema,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export type MediaType = z.infer<typeof mediaTypeSchema>
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { glob } from "astro/loaders"
|
|
2
|
+
import { z } from "astro/zod"
|
|
3
|
+
import type { SchemaContext } from "astro:content"
|
|
4
|
+
import { defineCollection, reference } from "astro:content"
|
|
5
|
+
|
|
6
|
+
import { astroImage } from "./astro-image"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Category Schema
|
|
10
|
+
*/
|
|
11
|
+
export const categorySchema = z.object({
|
|
12
|
+
/**
|
|
13
|
+
* Name of the category.
|
|
14
|
+
*
|
|
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"
|
|
19
|
+
*/
|
|
20
|
+
label: z.string(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Media Collection Schema
|
|
25
|
+
*/
|
|
26
|
+
export const mediaCollectionSchema = z.object({
|
|
27
|
+
/**
|
|
28
|
+
* Name of the collection.
|
|
29
|
+
*
|
|
30
|
+
* This can either be a translation key or a string that will be displayed as is.
|
|
31
|
+
* LightNet will try to use it as translation key first if no translation is found it will use the string as is.
|
|
32
|
+
*/
|
|
33
|
+
label: z.string(),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Media Item Schema
|
|
38
|
+
*/
|
|
39
|
+
export const mediaItemSchema = z.object({
|
|
40
|
+
/**
|
|
41
|
+
* Identifier of this media item. If other media items
|
|
42
|
+
* share the same commonId they will show up as translations.
|
|
43
|
+
* The common id will show up in the media item's url combined with it's language.
|
|
44
|
+
*
|
|
45
|
+
* We suggest you use the english name of the media item, all lower case, words separated with hyphens.
|
|
46
|
+
*
|
|
47
|
+
* @example "a-book-about-love"
|
|
48
|
+
*/
|
|
49
|
+
commonId: z.string(),
|
|
50
|
+
/**
|
|
51
|
+
* Title of this media item.
|
|
52
|
+
* This is expected to be in the language that is defined by the 'language' property.
|
|
53
|
+
*
|
|
54
|
+
* @example "A book about love"
|
|
55
|
+
*/
|
|
56
|
+
title: z.string(),
|
|
57
|
+
/**
|
|
58
|
+
* References one media-type by its filename without .json suffix.
|
|
59
|
+
*
|
|
60
|
+
* @example "book"
|
|
61
|
+
*/
|
|
62
|
+
type: reference("media-types"),
|
|
63
|
+
/**
|
|
64
|
+
*Describes this media item. You can use markdown syntax to add formatting.
|
|
65
|
+
* This is expected to be in the language that is defined by the 'language' property.
|
|
66
|
+
*
|
|
67
|
+
* @example "This is a book about **love**..."
|
|
68
|
+
*/
|
|
69
|
+
description: z.string().optional(),
|
|
70
|
+
/**
|
|
71
|
+
* List of authors of this media item.
|
|
72
|
+
*
|
|
73
|
+
* @example ["George Miller", "Timothy Meier"]
|
|
74
|
+
*/
|
|
75
|
+
authors: z.array(z.string()).optional(),
|
|
76
|
+
/**
|
|
77
|
+
* Date this media item has been created on this lightnet instance.
|
|
78
|
+
* Format is YYYY-MM-DD
|
|
79
|
+
*
|
|
80
|
+
* @example 2024-09-10
|
|
81
|
+
*/
|
|
82
|
+
dateCreated: z.string().date(),
|
|
83
|
+
/**
|
|
84
|
+
* List of categories of this media item.
|
|
85
|
+
*
|
|
86
|
+
* @example ["family"]
|
|
87
|
+
*/
|
|
88
|
+
categories: z.array(reference("categories")).optional(),
|
|
89
|
+
/**
|
|
90
|
+
* List of media collections this media item is included.
|
|
91
|
+
* Collections can be used to group media items into series, playlists...
|
|
92
|
+
*
|
|
93
|
+
* @example [{collection:"my-series"}]
|
|
94
|
+
*/
|
|
95
|
+
collections: z
|
|
96
|
+
.array(
|
|
97
|
+
z.object({
|
|
98
|
+
/**
|
|
99
|
+
* Id of the collection.
|
|
100
|
+
*/
|
|
101
|
+
collection: reference("media-collections"),
|
|
102
|
+
/**
|
|
103
|
+
* Position of the item inside the collection.
|
|
104
|
+
*/
|
|
105
|
+
index: z.number().optional(),
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
.optional(),
|
|
109
|
+
/**
|
|
110
|
+
* BCP-47 name of the language this media item is in.
|
|
111
|
+
*
|
|
112
|
+
* @example "en"
|
|
113
|
+
*/
|
|
114
|
+
language: z.string(),
|
|
115
|
+
/**
|
|
116
|
+
* Relative path to the image of this media item. Eg. a book cover or video thumbnail.
|
|
117
|
+
*
|
|
118
|
+
* The image is expected to be inside the `images` folder next to the media item definition json.
|
|
119
|
+
* This image will be used for previews and on the media item detail page.
|
|
120
|
+
* It can have on of this file types: png, jpg, tiff, webp, gif, svg, avif.
|
|
121
|
+
* We suggest to give it a size of at least 1000px for it's longer side.
|
|
122
|
+
*
|
|
123
|
+
* @example "./images/a-book-about-love--en.jpg"
|
|
124
|
+
*/
|
|
125
|
+
image: z.string(),
|
|
126
|
+
/**
|
|
127
|
+
* List of objects defining the content of this media item.
|
|
128
|
+
*/
|
|
129
|
+
content: z
|
|
130
|
+
.array(
|
|
131
|
+
z.object({
|
|
132
|
+
/**
|
|
133
|
+
* Urls might be:
|
|
134
|
+
* - links to youtube videos
|
|
135
|
+
* - links to vimeo videos
|
|
136
|
+
* - links to .mp4 video files
|
|
137
|
+
* - links to external websites
|
|
138
|
+
* - links to pdfs (might be hosted inside the public/files/ folder)
|
|
139
|
+
* - links to epubs (might be hosted inside the public/files/ folder)
|
|
140
|
+
*
|
|
141
|
+
* @example "/files/a-book-about-love.pdf"
|
|
142
|
+
*/
|
|
143
|
+
url: z.string(),
|
|
144
|
+
/**
|
|
145
|
+
* The name of the content. If this is not set. The file name
|
|
146
|
+
* from URL will be used.
|
|
147
|
+
*/
|
|
148
|
+
name: z.string().optional(),
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
.min(1),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* MediaItemSchema above defines the shape of a media item.
|
|
156
|
+
* We need this function to accept the astro content's image function that
|
|
157
|
+
* is available inside defineCollection.
|
|
158
|
+
*
|
|
159
|
+
* @param schemaContext that is passed by astro's defineCollection schema.
|
|
160
|
+
* @returns schema with image mixed in.
|
|
161
|
+
*/
|
|
162
|
+
export const mediaSchema = ({ image }: SchemaContext) =>
|
|
163
|
+
mediaItemSchema.extend({
|
|
164
|
+
image: astroImage(image),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Media Type Schema
|
|
169
|
+
*/
|
|
170
|
+
export const mediaTypeSchema = z.object({
|
|
171
|
+
/**
|
|
172
|
+
* Name of this media type that will be shown on the pages.
|
|
173
|
+
*
|
|
174
|
+
* This can either be a fixed string or a translation key.
|
|
175
|
+
*
|
|
176
|
+
* @example "media-type.book"
|
|
177
|
+
*/
|
|
178
|
+
label: z.string(),
|
|
179
|
+
/**
|
|
180
|
+
* What media item details page to use for media items with this type.
|
|
181
|
+
*
|
|
182
|
+
*/
|
|
183
|
+
detailsPage: z
|
|
184
|
+
.discriminatedUnion("layout", [
|
|
185
|
+
z.object({
|
|
186
|
+
/**
|
|
187
|
+
* Details page for all media types.
|
|
188
|
+
*/
|
|
189
|
+
layout: z.literal("default"),
|
|
190
|
+
/**
|
|
191
|
+
* Label for the open action button. Use this if you want to change the text
|
|
192
|
+
* of the "Open" button to be more matching to your media item.
|
|
193
|
+
* For example you could change the text to be "Read" for a book media type.
|
|
194
|
+
*
|
|
195
|
+
* The label is a translation key.
|
|
196
|
+
*
|
|
197
|
+
* @example "ln.details.open"
|
|
198
|
+
*/
|
|
199
|
+
openActionLabel: z.string().optional(),
|
|
200
|
+
/**
|
|
201
|
+
* What style to use for the cover image.
|
|
202
|
+
*
|
|
203
|
+
* @example "book"
|
|
204
|
+
*/
|
|
205
|
+
coverStyle: z.enum(["default", "book"]).default("default"),
|
|
206
|
+
}),
|
|
207
|
+
z.object({
|
|
208
|
+
/**
|
|
209
|
+
* Custom details page.
|
|
210
|
+
*/
|
|
211
|
+
layout: z.literal("custom"),
|
|
212
|
+
/**
|
|
213
|
+
* This references a custom component name to be used for the
|
|
214
|
+
* details page. The custom component has be located at src/details-pages/
|
|
215
|
+
*
|
|
216
|
+
* @example "MyArticleDetails.astro"
|
|
217
|
+
*/
|
|
218
|
+
customComponent: z.string(),
|
|
219
|
+
}),
|
|
220
|
+
z.object({
|
|
221
|
+
/**
|
|
222
|
+
* Detail page for videos.
|
|
223
|
+
*/
|
|
224
|
+
layout: z.literal("video"),
|
|
225
|
+
}),
|
|
226
|
+
])
|
|
227
|
+
.optional(),
|
|
228
|
+
/**
|
|
229
|
+
* Pick the media type's icon from https://pictogrammers.com/library/mdi/
|
|
230
|
+
* Prefix it's name with "mdi--"
|
|
231
|
+
*
|
|
232
|
+
* @example "mdi--ab-testing"
|
|
233
|
+
*/
|
|
234
|
+
icon: z.string(),
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
export const LIGHTNET_COLLECTIONS = {
|
|
238
|
+
categories: defineCollection({
|
|
239
|
+
loader: glob({ pattern: "*.json", base: "./src/content/categories" }),
|
|
240
|
+
schema: categorySchema,
|
|
241
|
+
}),
|
|
242
|
+
"media-collections": defineCollection({
|
|
243
|
+
loader: glob({
|
|
244
|
+
pattern: "*.json",
|
|
245
|
+
base: "./src/content/media-collections",
|
|
246
|
+
}),
|
|
247
|
+
schema: mediaCollectionSchema,
|
|
248
|
+
}),
|
|
249
|
+
media: defineCollection({
|
|
250
|
+
loader: glob({
|
|
251
|
+
pattern: "*.json",
|
|
252
|
+
base: "./src/content/media",
|
|
253
|
+
}),
|
|
254
|
+
schema: mediaSchema,
|
|
255
|
+
}),
|
|
256
|
+
"media-types": defineCollection({
|
|
257
|
+
loader: glob({
|
|
258
|
+
pattern: "*.json",
|
|
259
|
+
base: "./src/content/media-types",
|
|
260
|
+
}),
|
|
261
|
+
schema: mediaTypeSchema,
|
|
262
|
+
}),
|
|
263
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type CollectionEntry, getCollection } from "astro:content"
|
|
2
|
+
|
|
3
|
+
import { type MediaItemQuery, queryMediaItems } from "./query-media-items"
|
|
4
|
+
|
|
5
|
+
export const getMediaItems = (
|
|
6
|
+
query?: MediaItemQuery<CollectionEntry<"media">>,
|
|
7
|
+
) => queryMediaItems(getCollection("media"), query ?? {})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type TranslateFn } from "../i18n/translate"
|
|
2
|
+
import { getMediaItems } from "./get-media-items"
|
|
3
|
+
import { resolveCategoryLabel } from "./resolve-category-label"
|
|
4
|
+
|
|
5
|
+
const contentCategories = new Set<string>(
|
|
6
|
+
(await getMediaItems())
|
|
7
|
+
.flatMap((item) => item.data.categories ?? [])
|
|
8
|
+
.map((c) => c.id),
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export async function getCategories(currentLocale: string, t: TranslateFn) {
|
|
12
|
+
return [...contentCategories.values()]
|
|
13
|
+
.map((id) => ({ id, name: resolveCategoryLabel(t, id) }))
|
|
14
|
+
.sort((a, b) => a.name.localeCompare(b.name, currentLocale))
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { resolveLanguage } from "../i18n/resolve-language"
|
|
2
|
+
import { getMediaItems } from "./get-media-items"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Array of distinct content languages.
|
|
6
|
+
*/
|
|
7
|
+
export const contentLanguages = Object.values(
|
|
8
|
+
Object.fromEntries(
|
|
9
|
+
(await getMediaItems()).map(({ data: { language } }) => [
|
|
10
|
+
language,
|
|
11
|
+
resolveLanguage(language),
|
|
12
|
+
]),
|
|
13
|
+
),
|
|
14
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getCollection, getEntry } from "astro:content"
|
|
2
|
+
|
|
3
|
+
import { verifySchemaAsync } from "../utils/verify-schema"
|
|
4
|
+
import { mediaItemEntrySchema } from "./content-schema-internal"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Internal API to get media items. Since this package is a Astro integration
|
|
8
|
+
* we cannot rely on Astro's getCollection typings. They are configured outside this package.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const getMediaItem = async (id: string) => {
|
|
12
|
+
const item = await getEntry("media", id)
|
|
13
|
+
return prepareItem(item)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const getMediaItems = async () => {
|
|
17
|
+
const items: unknown[] = await getCollection("media")
|
|
18
|
+
return Promise.all(items.map(prepareItem))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const prepareItem = async (item: unknown) => {
|
|
22
|
+
return await verifySchemaAsync(
|
|
23
|
+
mediaItemEntrySchema,
|
|
24
|
+
item,
|
|
25
|
+
(id) => `Invalid media item: ${id}`,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getCollection, getEntry } from "astro:content"
|
|
2
|
+
|
|
3
|
+
import { verifySchema } from "../utils/verify-schema"
|
|
4
|
+
import { mediaTypeEntrySchema } from "./content-schema-internal"
|
|
5
|
+
|
|
6
|
+
export const getMediaType = async (id: string) => {
|
|
7
|
+
return verifySchema(
|
|
8
|
+
mediaTypeEntrySchema,
|
|
9
|
+
await getEntry("media-types", id),
|
|
10
|
+
`Received invalid media type ${id}.`,
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const getMediaTypes = async () => {
|
|
15
|
+
const mediaTypes: unknown[] = await getCollection("media-types")
|
|
16
|
+
return mediaTypes.map((type: unknown) =>
|
|
17
|
+
verifySchema(
|
|
18
|
+
mediaTypeEntrySchema,
|
|
19
|
+
type,
|
|
20
|
+
(id) => `Received invalid media type '${id}'.`,
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { MediaItemEntry } from "./content-schema-internal"
|
|
2
|
+
|
|
3
|
+
export type MediaItemQuery<TMediaItem extends MediaItemEntry> = {
|
|
4
|
+
/**
|
|
5
|
+
* A filter for multiple fields will be logically combined using AND (&&).
|
|
6
|
+
*/
|
|
7
|
+
where?: {
|
|
8
|
+
type?: TMediaItem["data"]["type"]["id"]
|
|
9
|
+
language?: string
|
|
10
|
+
category?: NonNullable<TMediaItem["data"]["categories"]>[number]["id"]
|
|
11
|
+
collection?: NonNullable<
|
|
12
|
+
TMediaItem["data"]["collections"]
|
|
13
|
+
>[number]["collection"]["id"]
|
|
14
|
+
}
|
|
15
|
+
orderBy?: "dateCreated" | "title"
|
|
16
|
+
limit?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const queryMediaItems = async <TMediaItem extends MediaItemEntry>(
|
|
20
|
+
allItems: Promise<TMediaItem[]>,
|
|
21
|
+
query: MediaItemQuery<TMediaItem>,
|
|
22
|
+
) => {
|
|
23
|
+
const { where = {}, orderBy, limit } = query
|
|
24
|
+
const filters: { (item: TMediaItem): boolean }[] = []
|
|
25
|
+
|
|
26
|
+
if (where.type) {
|
|
27
|
+
filters.push((item) => item.data.type.id === where.type)
|
|
28
|
+
}
|
|
29
|
+
if (where.language) {
|
|
30
|
+
filters.push((item) => item.data.language === where.language)
|
|
31
|
+
}
|
|
32
|
+
if (where.category) {
|
|
33
|
+
filters.push(
|
|
34
|
+
(item) => !!item.data.categories?.find(({ id }) => id === where.category),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
if (where.collection) {
|
|
38
|
+
filters.push(
|
|
39
|
+
(item) =>
|
|
40
|
+
!!item.data.collections?.find(
|
|
41
|
+
({ collection }) => collection.id === where.collection,
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
const combinedFilter = (item: TMediaItem) =>
|
|
46
|
+
filters.every((filter) => filter(item))
|
|
47
|
+
|
|
48
|
+
const items = (await allItems).filter(combinedFilter)
|
|
49
|
+
|
|
50
|
+
if (orderBy === "dateCreated") {
|
|
51
|
+
items.sort((item1, item2) =>
|
|
52
|
+
item2.data.dateCreated.localeCompare(item1.data.dateCreated),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
if (orderBy === "title") {
|
|
56
|
+
items.sort((item1, item2) =>
|
|
57
|
+
item1.data.title.localeCompare(item2.data.title),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
const { collection } = where
|
|
61
|
+
if (!orderBy && collection) {
|
|
62
|
+
items.sort((a, b) => compareCollectionItems(a, b, collection))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return items.slice(0, limit)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function compareCollectionItems(
|
|
69
|
+
item1: MediaItemEntry,
|
|
70
|
+
item2: MediaItemEntry,
|
|
71
|
+
collectionId: string,
|
|
72
|
+
) {
|
|
73
|
+
const getIndex = (item: MediaItemEntry) =>
|
|
74
|
+
item.data.collections?.find(
|
|
75
|
+
({ collection }) => collection.id === collectionId,
|
|
76
|
+
)?.index
|
|
77
|
+
const index1 = getIndex(item1)
|
|
78
|
+
const index2 = getIndex(item2)
|
|
79
|
+
if (index1 === index2) {
|
|
80
|
+
return item1.id.localeCompare(item2.id)
|
|
81
|
+
}
|
|
82
|
+
if (index1 === undefined && index2 !== undefined) {
|
|
83
|
+
return 1
|
|
84
|
+
}
|
|
85
|
+
if (index1 !== undefined && index2 === undefined) {
|
|
86
|
+
return -1
|
|
87
|
+
}
|
|
88
|
+
return index1! - index2!
|
|
89
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AstroError } from "astro/errors"
|
|
2
|
+
import { getCollection } from "astro:content"
|
|
3
|
+
|
|
4
|
+
import type { TranslateFn } from "../i18n/translate"
|
|
5
|
+
|
|
6
|
+
const categories = await getCollection("categories")
|
|
7
|
+
|
|
8
|
+
export const resolveCategoryLabel = (
|
|
9
|
+
translate: TranslateFn,
|
|
10
|
+
categoryId: string,
|
|
11
|
+
) => {
|
|
12
|
+
const category = categories.find((c) => c.id === categoryId)
|
|
13
|
+
if (!category) {
|
|
14
|
+
throw new AstroError(
|
|
15
|
+
`Unknown category: ${categoryId}`,
|
|
16
|
+
"Make sure you add the category to the categories content collection.",
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
return translate(category.data.label, { allowFixedStrings: true })
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GetStaticPaths } from "astro"
|
|
2
|
+
import config from "virtual:lightnet/config"
|
|
3
|
+
|
|
4
|
+
import { resolveLocales } from "./resolve-locales"
|
|
5
|
+
|
|
6
|
+
export const getLocalePaths = (() => {
|
|
7
|
+
return resolveLocales(config).map((locale) => ({ params: { locale } }))
|
|
8
|
+
}) satisfies GetStaticPaths
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import config from "virtual:lightnet/config"
|
|
2
|
+
|
|
3
|
+
export const BUNDLED_LANGUAGES = [
|
|
4
|
+
{ code: "de", name: "Deutsch", direction: "ltr" },
|
|
5
|
+
{ code: "en", name: "English", direction: "ltr" },
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
export const ALL_LANGUAGES = config.languages ?? BUNDLED_LANGUAGES
|
|
9
|
+
|
|
10
|
+
export type Language = (typeof ALL_LANGUAGES)[number]
|