lightnet 4.7.1 → 4.8.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # lightnet
2
2
 
3
+ ## 4.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Add an optional `schemaType` field to media types, supporting `CreativeWork`, `Book`, `Article`, `DigitalDocument`, `AudioObject`, `VideoObject`, and `ImageObject`. Explicit types determine the main JSON-LD type independently of the page layout, while audio, video, and document files retain their own types. Omitting the field preserves automatic inference. The field is also available in the admin interface.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Add canonical information to pages.
12
+
13
+ - [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Add a `head` slot to `Page` for custom head content and an optional `jsonLd` prop to `DetailsPage` for safely rendering structured data in the page head.
14
+
15
+ Generate JSON-LD for audio, video, and default media detail pages, including descriptive item metadata, recognized file encodings, video player URLs, and individual audio tracks.
16
+
17
+ - [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Fix carousel accessibility by removing group role from items.
18
+
19
+ - [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Removed behaviour around adding Astro base path.
20
+
21
+ - [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Update dependencies.
22
+
3
23
  ## 4.7.1
4
24
 
5
25
  ### Patch Changes
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "LightNet makes it easy to run your own digital media library.",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "version": "4.7.1",
6
+ "version": "4.8.0",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/LightNetDev/lightnet",
@@ -54,19 +54,19 @@
54
54
  "dependencies": {
55
55
  "@astrojs/react": "^5.0.7",
56
56
  "@astrojs/sitemap": "^3.7.4",
57
- "@iconify-json/lucide": "^1.2.145",
57
+ "@iconify-json/lucide": "^1.2.150",
58
58
  "@iconify-json/mdi": "^1.2.3",
59
59
  "@iconify/tailwind": "^1.2.0",
60
60
  "@tailwindcss/typography": "^0.5.20",
61
- "@tanstack/react-virtual": "^3.14.21",
62
- "autoprefixer": "^10.6.1",
61
+ "@tanstack/react-virtual": "^3.14.24",
62
+ "autoprefixer": "^10.6.2",
63
63
  "embla-carousel": "^8.6.0",
64
64
  "embla-carousel-wheel-gestures": "^8.1.0",
65
65
  "fuse.js": "^7.5.0",
66
- "i18next": "^26.5.2",
67
- "lucide-react": "^1.59.0",
68
- "marked": "^18.1.1",
69
- "postcss": "^8.5.30",
66
+ "i18next": "^26.6.0",
67
+ "lucide-react": "^1.66.0",
68
+ "marked": "^18.1.2",
69
+ "postcss": "^8.5.32",
70
70
  "postcss-load-config": "^6.0.1",
71
71
  "yaml": "^2.9.1"
72
72
  },
@@ -84,7 +84,6 @@ function getImage({ image, id }: Category) {
84
84
  {categories.map((category) => (
85
85
  <li
86
86
  class="carousel-item--narrow"
87
- role="group"
88
87
  aria-roledescription="slide"
89
88
  class:list={[
90
89
  /** Category cards without images should take the full height of other cards */
@@ -145,7 +145,6 @@ const gridLayouts = {
145
145
  return (
146
146
  <li
147
147
  class={`carousel-item--${resolvedItemWidth} h-full`}
148
- role="group"
149
148
  aria-roledescription="slide"
150
149
  >
151
150
  <a
@@ -12,6 +12,22 @@ export const mediaTypeSchema = z.object({
12
12
  * Label translated for the default locale. Other configured site locales are optional.
13
13
  */
14
14
  label: translationMapSchema,
15
+ /**
16
+ * Schema.org type of the work described on media detail pages.
17
+ * Independent of the page layout and downloadable file formats.
18
+ * If omitted, structured data is inferred from the layout and content.
19
+ */
20
+ schemaType: z
21
+ .enum([
22
+ "CreativeWork",
23
+ "Book",
24
+ "Article",
25
+ "DigitalDocument",
26
+ "AudioObject",
27
+ "VideoObject",
28
+ "ImageObject",
29
+ ])
30
+ .optional(),
15
31
  /**
16
32
  * Defines how the cover image for a media item of this type is rendered.
17
33
  *
@@ -12,7 +12,6 @@ export const onRequest: MiddlewareHandler = async ({ locals, url }, next) => {
12
12
  const locales = config.locales
13
13
  const currentLocale = resolveCurrentLocaleFromPathname({
14
14
  pathname: url.pathname,
15
- base: import.meta.env.BASE_URL,
16
15
  locales,
17
16
  defaultLocale,
18
17
  })
@@ -1,18 +1,13 @@
1
- import { pathWithoutBase } from "../utils/paths"
2
-
3
1
  export function resolveCurrentLocaleFromPathname({
4
2
  pathname,
5
- base,
6
3
  locales,
7
4
  defaultLocale,
8
5
  }: {
9
6
  pathname: string
10
- base?: string
11
7
  locales: string[]
12
8
  defaultLocale: string
13
9
  }) {
14
- const pathnameWithoutBase = base ? pathWithoutBase(pathname, base) : pathname
15
- const firstPathSegment = pathnameWithoutBase.split("/")[1]
10
+ const firstPathSegment = pathname.split("/")[1]
16
11
 
17
12
  return locales.includes(firstPathSegment) ? firstPathSegment : defaultLocale
18
13
  }
@@ -6,10 +6,10 @@ import CustomHead from "virtual:lightnet/components/CustomHead"
6
6
  import config from "virtual:lightnet/config"
7
7
 
8
8
  import { resolveLanguage } from "../i18n/resolve-language"
9
- import { pathWithBase } from "../utils/paths"
10
9
  import Footer from "./components/footer/Footer.astro"
11
10
  import Header from "./components/header/Header.astro"
12
11
  import Favicon from "./components/meta/Favicon.astro"
12
+ import SearchEngineMetadata from "./components/meta/SearchEngineMetadata.astro"
13
13
  import ViewTransition from "./components/meta/ViewTransition.astro"
14
14
 
15
15
  export interface Props {
@@ -47,6 +47,17 @@ export interface Props {
47
47
  * default: false
48
48
  */
49
49
  disallowSearchIndexing?: boolean
50
+ /**
51
+ * Absolute URL of the preferred version of this page, used in the
52
+ * `<link rel="canonical">` tag to help search engines consolidate duplicate URLs.
53
+ *
54
+ * Defaults to the current pathname with a trailing slash, resolved against
55
+ * Astro's `site` URL, without query parameters or a fragment.
56
+ * Only included when search indexing is enabled.
57
+ *
58
+ * @example "https://example.com/about/"
59
+ */
60
+ canonicalUrl?: string | URL
50
61
  }
51
62
 
52
63
  const {
@@ -56,6 +67,7 @@ const {
56
67
  locale,
57
68
  disableShouldTrack,
58
69
  disallowSearchIndexing,
70
+ canonicalUrl,
59
71
  } = Astro.props
60
72
  const { currentLocale: pathCurrentLocale, tConfigField } = Astro.locals.i18n
61
73
  const currentLocale = locale ?? pathCurrentLocale
@@ -72,16 +84,20 @@ const { direction } = resolveLanguage(currentLocale)
72
84
  <head>
73
85
  <meta charset="UTF-8" />
74
86
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
75
- {(disallowSearchIndexing ?? config.disallowSearchIndexing) && (
76
- <meta name="robots" content="noindex" />
77
- )}
78
- {CustomHead && <CustomHead />}
79
87
  <title>{title ? `${title} | ${configTitle}` : configTitle}</title>
80
88
  {description && <meta name="description" content={description} />}
81
89
  {config.manifest && <link rel="manifest" href={config.manifest} />}
82
- <link rel="prefetch" href={pathWithBase("/api/internal/search.json")} />
90
+ <link rel="prefetch" href="/api/internal/search.json" />
83
91
  <Favicon />
84
92
  <ViewTransition />
93
+ <SearchEngineMetadata
94
+ disallowSearchIndexing={disallowSearchIndexing}
95
+ canonicalUrl={
96
+ typeof canonicalUrl === "string" ? new URL(canonicalUrl) : canonicalUrl
97
+ }
98
+ />
99
+ {CustomHead && <CustomHead />}
100
+ <slot name="head" />
85
101
  </head>
86
102
  <body class="flex min-h-screen flex-col overflow-y-scroll bg-gray-50 text-gray-900">
87
103
  <Header />
@@ -9,6 +9,13 @@ const { t } = Astro.locals.i18n
9
9
  class="inline-flex items-center gap-2 whitespace-nowrap underline-offset-4 hover:underline"
10
10
  {...getLinkAttributes("https://lightnet.community")}
11
11
  >
12
- <img src={LightNetLogo.src} alt="" class="h-5 w-auto" loading="lazy" />
12
+ <img
13
+ src={LightNetLogo.src}
14
+ width="141"
15
+ height="173"
16
+ alt=""
17
+ class="h-5 w-auto"
18
+ loading="lazy"
19
+ />
13
20
  <span>{t("ln.footer.powered-by-lightnet")}</span>
14
21
  </a>
@@ -2,7 +2,7 @@ import config from "virtual:lightnet/config"
2
2
 
3
3
  import { resolveLanguage } from "../../i18n/resolve-language"
4
4
  import type { TranslateConfigFieldFn } from "../../i18n/translate-map"
5
- import { localizePath, pathWithoutBase } from "../../utils/paths"
5
+ import { localizePath } from "../../utils/paths"
6
6
 
7
7
  export type LanguageLink = {
8
8
  active: boolean
@@ -20,7 +20,7 @@ export function getLanguageSelectionMenuItems({
20
20
  pathname: string
21
21
  tConfigField: TranslateConfigFieldFn
22
22
  }) {
23
- const currentPath = pathWithoutBase(pathname)
23
+ const currentPath = pathname
24
24
  const hasLocale = Boolean(
25
25
  currentLocale &&
26
26
  (currentPath.startsWith(`/${currentLocale}/`) ||
@@ -0,0 +1,28 @@
1
+ ---
2
+ import config from "virtual:lightnet/config"
3
+
4
+ import { ensureTrailingSlash } from "../../../utils/paths"
5
+
6
+ type Props = {
7
+ disallowSearchIndexing?: boolean
8
+ canonicalUrl?: URL
9
+ }
10
+
11
+ const { disallowSearchIndexing, canonicalUrl: canonicalUrlProp } = Astro.props
12
+ const searchIndexing = !(
13
+ disallowSearchIndexing ?? config.disallowSearchIndexing
14
+ )
15
+
16
+ const canonicalUrl =
17
+ canonicalUrlProp ??
18
+ new URL(ensureTrailingSlash(Astro.url.pathname), Astro.site)
19
+ ---
20
+
21
+ {!searchIndexing && <meta name="robots" content="noindex" />}
22
+
23
+ {searchIndexing && (
24
+ <>
25
+ <link rel="sitemap" href="/sitemap-index.xml" />
26
+ <link rel="canonical" href={canonicalUrl.href} />
27
+ </>
28
+ )}
@@ -6,15 +6,25 @@ import DetailsPage from "./components/DetailsPage.astro"
6
6
  import MainDetailsSection from "./components/MainDetailsSection.astro"
7
7
  import MediaCollectionsSection from "./components/MediaCollectionsSection.astro"
8
8
  import MoreDetailsSection from "./components/MoreDetailsSection.astro"
9
+ import { getDetailsPageJsonLd } from "./utils/get-details-page-json-ld"
9
10
 
10
11
  export type Props = {
11
12
  mediaId: string
12
13
  }
13
14
 
14
15
  const { mediaId } = Astro.props
16
+ const { defaultLocale, tContentField } = Astro.locals.i18n
17
+
18
+ const jsonLd = await getDetailsPageJsonLd({
19
+ mediaId,
20
+ layout: "audio",
21
+ site: Astro.site,
22
+ defaultLocale,
23
+ tContentField,
24
+ })
15
25
  ---
16
26
 
17
- <DetailsPage mediaId={mediaId}>
27
+ <DetailsPage mediaId={mediaId} jsonLd={jsonLd}>
18
28
  <MainDetailsSection imageSize="md" mediaId={mediaId} />
19
29
  <AudioPanel mediaId={mediaId} className="mt-10" />
20
30
  <DescriptionSection mediaId={mediaId} />
@@ -6,14 +6,24 @@ import OpenButton from "./components/main-details/OpenButton.astro"
6
6
  import MainDetailsSection from "./components/MainDetailsSection.astro"
7
7
  import MediaCollectionsSection from "./components/MediaCollectionsSection.astro"
8
8
  import MoreDetailsSection from "./components/MoreDetailsSection.astro"
9
+ import { getDetailsPageJsonLd } from "./utils/get-details-page-json-ld"
9
10
 
10
11
  export type Props = {
11
12
  mediaId: string
12
13
  }
13
14
  const { mediaId } = Astro.props
15
+ const { defaultLocale, tContentField } = Astro.locals.i18n
16
+
17
+ const jsonLd = await getDetailsPageJsonLd({
18
+ mediaId,
19
+ layout: "default",
20
+ site: Astro.site,
21
+ defaultLocale,
22
+ tContentField,
23
+ })
14
24
  ---
15
25
 
16
- <DetailsPage mediaId={mediaId}>
26
+ <DetailsPage mediaId={mediaId} jsonLd={jsonLd}>
17
27
  <MainDetailsSection mediaId={mediaId}>
18
28
  <OpenButton className="mt-8" mediaId={mediaId} />
19
29
  </MainDetailsSection>
@@ -5,15 +5,25 @@ import DetailsPage from "./components/DetailsPage.astro"
5
5
  import MediaCollectionsSection from "./components/MediaCollectionsSection.astro"
6
6
  import MoreDetailsSection from "./components/MoreDetailsSection.astro"
7
7
  import VideoDetailsSection from "./components/VideoDetailsSection.astro"
8
+ import { getDetailsPageJsonLd } from "./utils/get-details-page-json-ld"
8
9
 
9
10
  export type Props = {
10
11
  mediaId: string
11
12
  }
12
13
 
13
14
  const { mediaId } = Astro.props
15
+ const { defaultLocale, tContentField } = Astro.locals.i18n
16
+
17
+ const jsonLd = await getDetailsPageJsonLd({
18
+ mediaId,
19
+ layout: "video",
20
+ site: Astro.site,
21
+ defaultLocale,
22
+ tContentField,
23
+ })
14
24
  ---
15
25
 
16
- <DetailsPage mediaId={mediaId}>
26
+ <DetailsPage mediaId={mediaId} jsonLd={jsonLd}>
17
27
  <VideoDetailsSection mediaId={mediaId} />
18
28
  <DescriptionSection mediaId={mediaId} />
19
29
  <ContentSection mediaId={mediaId} />
@@ -2,17 +2,35 @@
2
2
  import { getMediaItem } from "../../../content/get-media-items"
3
3
  import Page from "../../../layouts/Page.astro"
4
4
  import { markdownToText } from "../../../utils/markdown"
5
+ import { detailsPagePath } from "../../../utils/paths"
5
6
 
6
7
  type Props = {
7
8
  mediaId: string
9
+ /** Structured data to include in the page head as JSON-LD. */
10
+ jsonLd?: Record<string, unknown>
8
11
  }
9
12
 
10
- const mediaItem = (await getMediaItem(Astro.props.mediaId)).data
13
+ const { mediaId: id, jsonLd } = Astro.props
14
+ const mediaItem = (await getMediaItem(id)).data
11
15
  ---
12
16
 
13
17
  <Page
14
18
  title={mediaItem.title}
15
19
  description={markdownToText(mediaItem.description)}
20
+ canonicalUrl={
21
+ new URL(
22
+ detailsPagePath(Astro.locals.i18n.defaultLocale, { id }),
23
+ Astro.site,
24
+ )
25
+ }
16
26
  >
27
+ <Fragment slot="head">
28
+ {jsonLd && (
29
+ <script
30
+ type="application/ld+json"
31
+ set:html={JSON.stringify(jsonLd).replace(/</g, "\\u003c")}
32
+ />
33
+ )}
34
+ </Fragment>
17
35
  <slot />
18
36
  </Page>
@@ -0,0 +1,176 @@
1
+ import type { MediaItemEntry } from "../../../content/schema/media-item"
2
+ import type { MediaTypeEntry } from "../../../content/schema/media-type"
3
+ import { markdownToText } from "../../../utils/markdown"
4
+ import { createContentMetadata } from "./create-content-metadata"
5
+
6
+ const FILE_FORMATS: Record<string, { type: string; mime: string }> = {
7
+ mp3: { type: "AudioObject", mime: "audio/mpeg" },
8
+ wav: { type: "AudioObject", mime: "audio/wav" },
9
+ aac: { type: "AudioObject", mime: "audio/aac" },
10
+ ogg: { type: "AudioObject", mime: "audio/ogg" },
11
+ mp4: { type: "VideoObject", mime: "video/mp4" },
12
+ webm: { type: "VideoObject", mime: "video/webm" },
13
+ ogv: { type: "VideoObject", mime: "video/ogg" },
14
+ jpg: { type: "ImageObject", mime: "image/jpeg" },
15
+ jpeg: { type: "ImageObject", mime: "image/jpeg" },
16
+ png: { type: "ImageObject", mime: "image/png" },
17
+ gif: { type: "ImageObject", mime: "image/gif" },
18
+ webp: { type: "ImageObject", mime: "image/webp" },
19
+ avif: { type: "ImageObject", mime: "image/avif" },
20
+ svg: { type: "ImageObject", mime: "image/svg+xml" },
21
+ pdf: { type: "MediaObject", mime: "application/pdf" },
22
+ epub: { type: "MediaObject", mime: "application/epub+zip" },
23
+ txt: { type: "MediaObject", mime: "text/plain" },
24
+ }
25
+
26
+ export type DetailsPageLayout = "default" | "audio" | "video"
27
+
28
+ export function createMediaItemJsonLd({
29
+ item,
30
+ layout,
31
+ schemaType,
32
+ canonicalUrl,
33
+ imageUrl,
34
+ categoryNames = [],
35
+ contentLabels = [],
36
+ }: {
37
+ item: MediaItemEntry["data"]
38
+ layout: DetailsPageLayout
39
+ schemaType?: MediaTypeEntry["data"]["schemaType"]
40
+ canonicalUrl: URL
41
+ imageUrl: string
42
+ categoryNames?: string[]
43
+ contentLabels?: (string | undefined)[]
44
+ }): Record<string, unknown> {
45
+ const base = {
46
+ "@context": "https://schema.org",
47
+ "@type": schemaType ?? "CreativeWork",
48
+ "@id": `${canonicalUrl.href}#work`,
49
+ url: canonicalUrl.href,
50
+ name: item.title,
51
+ description: markdownToText(item.description) || undefined,
52
+ image: imageUrl,
53
+ inLanguage: item.language,
54
+ // Author names can refer to people or organizations; the model does not distinguish them.
55
+ author: item.authors?.length
56
+ ? item.authors.map((name) => ({ name }))
57
+ : undefined,
58
+ about: categoryNames.length
59
+ ? categoryNames.map((name) => ({ "@type": "Thing", name }))
60
+ : undefined,
61
+ }
62
+
63
+ const media = item.content.map(({ url }, index) => {
64
+ let resolvedUrl: URL
65
+ try {
66
+ resolvedUrl = new URL(url, canonicalUrl)
67
+ } catch {
68
+ return undefined
69
+ }
70
+ if (!["http:", "https:"].includes(resolvedUrl.protocol)) {
71
+ return undefined
72
+ }
73
+
74
+ const metadata = createContentMetadata({
75
+ url: resolvedUrl.href,
76
+ labelText: contentLabels[index],
77
+ })
78
+ const format = FILE_FORMATS[metadata.extension]
79
+ const embedUrl = getVideoEmbedUrl(resolvedUrl)
80
+ if (!format && !embedUrl) {
81
+ return undefined
82
+ }
83
+
84
+ return {
85
+ "@type": embedUrl ? "VideoObject" : format.type,
86
+ name: metadata.labelText,
87
+ inLanguage: item.language,
88
+ ...(embedUrl
89
+ ? { embedUrl }
90
+ : { contentUrl: resolvedUrl.href, encodingFormat: format.mime }),
91
+ }
92
+ })
93
+
94
+ const encodings = media.filter((entry) => entry !== undefined)
95
+ if (
96
+ schemaType === "AudioObject" ||
97
+ schemaType === "VideoObject" ||
98
+ schemaType === "ImageObject"
99
+ ) {
100
+ const matching = encodings.filter((entry) => entry["@type"] === schemaType)
101
+ if (schemaType === "AudioObject" && matching.length > 1) {
102
+ return {
103
+ ...base,
104
+ hasPart: matching,
105
+ encoding: encodings.filter((entry) => entry["@type"] !== schemaType),
106
+ }
107
+ }
108
+ const primary = media[0]?.["@type"] === schemaType ? media[0] : undefined
109
+ const alternatives = encodings.filter((entry) => entry !== primary)
110
+ return {
111
+ ...primary,
112
+ ...base,
113
+ ...(schemaType === "VideoObject" ? { thumbnailUrl: imageUrl } : {}),
114
+ encoding: alternatives.length ? alternatives : undefined,
115
+ }
116
+ }
117
+
118
+ if (
119
+ !schemaType &&
120
+ layout === "video" &&
121
+ media[0]?.["@type"] === "VideoObject"
122
+ ) {
123
+ return {
124
+ ...media[0],
125
+ ...base,
126
+ "@type": "VideoObject",
127
+ thumbnailUrl: imageUrl,
128
+ }
129
+ }
130
+
131
+ if (layout === "audio") {
132
+ const tracks = encodings.filter((entry) => entry["@type"] === "AudioObject")
133
+ if (!schemaType && tracks.length === 1 && item.content.length === 1) {
134
+ return { ...tracks[0], ...base, "@type": "AudioObject" }
135
+ }
136
+ if (tracks.length && item.content.length > 1) {
137
+ return {
138
+ ...base,
139
+ hasPart: tracks,
140
+ encoding: encodings.filter((entry) => entry["@type"] !== "AudioObject"),
141
+ }
142
+ }
143
+ }
144
+
145
+ return { ...base, encoding: encodings.length ? encodings : undefined }
146
+ }
147
+
148
+ function getVideoEmbedUrl(url: URL) {
149
+ if (url.hostname === "www.youtube.com" || url.hostname === "youtube.com") {
150
+ const id = url.pathname.startsWith("/embed/")
151
+ ? url.pathname.slice(7)
152
+ : url.pathname === "/watch"
153
+ ? url.searchParams.get("v")
154
+ : undefined
155
+ if (id) return `https://www.youtube-nocookie.com/embed/${id}`
156
+ }
157
+ if (url.hostname === "youtu.be" && url.pathname.slice(1)) {
158
+ return `https://www.youtube-nocookie.com/embed/${url.pathname.slice(1)}`
159
+ }
160
+ if (
161
+ url.hostname === "www.youtube-nocookie.com" &&
162
+ url.pathname.startsWith("/embed/") &&
163
+ url.pathname.slice(7)
164
+ ) {
165
+ return `https://www.youtube-nocookie.com${url.pathname}`
166
+ }
167
+ if (url.hostname === "vimeo.com" && /^\/\d+\/?$/.test(url.pathname)) {
168
+ return `https://player.vimeo.com/video/${url.pathname.split("/")[1]}`
169
+ }
170
+ if (
171
+ url.hostname === "player.vimeo.com" &&
172
+ /^\/video\/\d+\/?$/.test(url.pathname)
173
+ ) {
174
+ return url.href
175
+ }
176
+ }
@@ -0,0 +1,48 @@
1
+ import { getImage } from "astro:assets"
2
+
3
+ import { getCategory } from "../../../content/get-categories"
4
+ import { getMediaItem } from "../../../content/get-media-items"
5
+ import { getMediaType } from "../../../content/get-media-types"
6
+ import type { TranslateContentFieldFn } from "../../../i18n/translate-map"
7
+ import { detailsPagePath } from "../../../utils/paths"
8
+ import type { DetailsPageLayout } from "./create-media-item-json-ld"
9
+ import { createMediaItemJsonLd } from "./create-media-item-json-ld"
10
+
11
+ export async function getDetailsPageJsonLd({
12
+ mediaId,
13
+ layout,
14
+ site,
15
+ defaultLocale,
16
+ tContentField,
17
+ }: {
18
+ mediaId: string
19
+ layout: DetailsPageLayout
20
+ site: URL | undefined
21
+ defaultLocale: string
22
+ tContentField: TranslateContentFieldFn
23
+ }) {
24
+ const item = await getMediaItem(mediaId)
25
+ const mediaType = await getMediaType(item.data.type.id)
26
+ const canonicalUrl = new URL(
27
+ detailsPagePath(defaultLocale, { id: mediaId }),
28
+ site,
29
+ )
30
+ const image = await getImage({ src: item.data.image, format: "webp" })
31
+ const categories = await Promise.all(
32
+ (item.data.categories ?? []).map(({ id }) =>
33
+ getCategory(id, tContentField),
34
+ ),
35
+ )
36
+
37
+ return createMediaItemJsonLd({
38
+ item: item.data,
39
+ layout,
40
+ schemaType: mediaType.data.schemaType,
41
+ canonicalUrl,
42
+ imageUrl: new URL(image.src, site).href,
43
+ categoryNames: categories.map((category) => category.labelText),
44
+ contentLabels: item.data.content.map(({ label }) =>
45
+ label ? tContentField(label, item) : undefined,
46
+ ),
47
+ })
48
+ }
@@ -1,7 +1,6 @@
1
1
  import Fuse from "fuse.js"
2
2
  import { useEffect, useMemo, useRef, useState } from "react"
3
3
 
4
- import { pathWithBase } from "../../../utils/paths"
5
4
  import type { SearchItem, SearchResponse } from "../api/search-response"
6
5
  import { observeSearchQuery, type SearchQuery } from "../utils/search-query"
7
6
 
@@ -17,7 +16,7 @@ type EnrichedSearchItem = SearchItem & {
17
16
  translatedLanguage: string
18
17
  }
19
18
 
20
- const SEARCH_API_PATH = pathWithBase("/api/internal/search.json")
19
+ const SEARCH_API_PATH = "/api/internal/search.json"
21
20
 
22
21
  export function useSearch({ categories, mediaTypes, languages }: Context) {
23
22
  const fuse = useRef<Fuse<EnrichedSearchItem>>(undefined)
@@ -1,40 +1,5 @@
1
1
  import { isAbsoluteUrl } from "./urls"
2
2
 
3
- /**
4
- * Prefix a site-internal path with Astro's configured base path.
5
- *
6
- * This helper trims any trailing slash from `BASE_URL`, ensures the input
7
- * path starts with a leading slash, and concatenates the two values.
8
- * Absolute URLs are out of scope for this helper.
9
- *
10
- * @param path internal path such as "/en/media", "/api/internal/search.json", or "/"
11
- * @returns base-aware internal path
12
- */
13
- export function pathWithBase(path: string) {
14
- const normalizedBase = import.meta.env.BASE_URL.replace(/\/+$/, "")
15
- const normalizedPath = path.startsWith("/") ? path : `/${path}`
16
- return `${normalizedBase}${normalizedPath}`
17
- }
18
-
19
- /**
20
- * Remove Astro's configured base path from a pathname when present.
21
- *
22
- * @param pathname pathname such as "/docs/en/media" or "/en/media"
23
- * @param base Astro base path. Defaults to `import.meta.env.BASE_URL`.
24
- * @returns pathname without the base prefix
25
- */
26
- export function pathWithoutBase(
27
- pathname: string,
28
- base = import.meta.env.BASE_URL,
29
- ) {
30
- const normalizedBase = base !== "/" ? base.replace(/\/+$/, "") : ""
31
-
32
- return normalizedBase &&
33
- (pathname === normalizedBase || pathname.startsWith(`${normalizedBase}/`))
34
- ? pathname.slice(normalizedBase.length) || "/"
35
- : pathname
36
- }
37
-
38
3
  /**
39
4
  * Build path to media item page.
40
5
  *
@@ -46,7 +11,7 @@ export function detailsPagePath(
46
11
  locale: string | undefined,
47
12
  { id }: { id: string },
48
13
  ) {
49
- return pathWithBase(`/${locale}/media/${id}`)
14
+ return `/${locale}/media/${id}/`
50
15
  }
51
16
 
52
17
  /**
@@ -79,7 +44,7 @@ export function searchPagePath(
79
44
  searchParams.append("type", filter.type)
80
45
  }
81
46
  const query = searchParams.size ? `?${searchParams.toString()}` : ""
82
- return pathWithBase(`/${locale}/media${query}`)
47
+ return `/${locale}/media/${query}`
83
48
  }
84
49
 
85
50
  /**
@@ -96,8 +61,22 @@ export function localizePath(locale: string | undefined, path: string) {
96
61
  if (isAbsoluteUrl(path)) {
97
62
  return path
98
63
  }
64
+ const localizedPath = `${locale ? `/${locale}` : ""}/${path.replace(/^\//, "")}`
65
+ return ensureTrailingSlash(localizedPath)
66
+ }
99
67
 
100
- return pathWithBase(
101
- `${locale ? `/${locale}` : ""}/${path.replace(/^\//, "")}`,
102
- )
68
+ /**
69
+ * Ensure an internal path's pathname ends with a slash, before any query or hash.
70
+ * Uses URL parsing to normalize the path and add a leading slash when missing.
71
+ * Empty input resolves to "/". Absolute URL origins are not included in the result.
72
+ *
73
+ * @param path internal path, optionally including a query string or fragment
74
+ * @returns normalized path with a trailing slash, query string, and fragment
75
+ * @example ensureTrailingSlash("about?ref=menu#team") // "/about/?ref=menu#team"
76
+ * @example ensureTrailingSlash("/about/") // "/about/"
77
+ */
78
+ export function ensureTrailingSlash(path: string) {
79
+ const { pathname, search, hash } = new URL(path, "https://localhost:4321")
80
+ const pathWithSlash = pathname.endsWith("/") ? pathname : `${pathname}/`
81
+ return `${pathWithSlash}${search}${hash}`
103
82
  }