lightnet 4.9.0 → 4.10.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,15 @@
1
1
  # lightnet
2
2
 
3
+ ## 4.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#477](https://github.com/LightNetDev/LightNet/pull/477) [`67d9a6e`](https://github.com/LightNetDev/LightNet/commit/67d9a6eab378697f0baa80b70939b69c8fbdf237) - Add Open Graph and Twitter metadata to detail pages, with generated share images that preserve the full cover alongside the title and authors. Cards use cover-inspired backgrounds and the media type’s cover decoration. Sharp is now a peer dependency.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#477](https://github.com/LightNetDev/LightNet/pull/477) [`67d9a6e`](https://github.com/LightNetDev/LightNet/commit/67d9a6eab378697f0baa80b70939b69c8fbdf237) - Update dependencies.
12
+
3
13
  ## 4.9.0
4
14
 
5
15
  ### Minor 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.9.0",
6
+ "version": "4.10.0",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/LightNetDev/lightnet",
@@ -43,12 +43,14 @@
43
43
  "./pages/DetailsPageRoute.astro": "./src/pages/details-page/DetailsPageRoute.astro",
44
44
  "./api/internal/search.ts": "./src/pages/search-page/api/search.ts",
45
45
  "./api/versions.ts": "./src/api/versions.ts",
46
- "./api/robots.ts": "./src/api/robots.ts"
46
+ "./api/robots.ts": "./src/api/robots.ts",
47
+ "./pages/detail-page/api/open-graph.ts": "./src/pages/details-page/api/open-graph.ts"
47
48
  },
48
49
  "peerDependencies": {
49
50
  "astro": "^6.0.0",
50
51
  "react": "^19.0.0",
51
52
  "react-dom": "^19.0.0",
53
+ "sharp": "^0.34.5",
52
54
  "tailwindcss": ">=3.4.0 <4.0.0"
53
55
  },
54
56
  "dependencies": {
@@ -58,14 +60,14 @@
58
60
  "@iconify-json/mdi": "^1.2.3",
59
61
  "@iconify/tailwind": "^1.2.0",
60
62
  "@tailwindcss/typography": "^0.5.20",
61
- "@tanstack/react-virtual": "^3.14.24",
63
+ "@tanstack/react-virtual": "^3.14.25",
62
64
  "autoprefixer": "^10.6.2",
63
65
  "embla-carousel": "^8.6.0",
64
66
  "embla-carousel-wheel-gestures": "^8.1.0",
65
67
  "fuse.js": "^7.5.0",
66
- "i18next": "^26.6.0",
67
- "lucide-react": "^1.66.0",
68
- "marked": "^18.1.2",
68
+ "i18next": "^26.7.1",
69
+ "lucide-react": "^1.67.0",
70
+ "marked": "^18.1.3",
69
71
  "postcss": "^8.5.32",
70
72
  "postcss-load-config": "^6.0.1",
71
73
  "yaml": "^2.9.1"
@@ -76,7 +78,7 @@
76
78
  "@types/react": "^19.3.0",
77
79
  "astro": "^6.4.8",
78
80
  "typescript": "^6.0.3",
79
- "vitest": "^5.0.5"
81
+ "vitest": "^5.1.0"
80
82
  },
81
83
  "engines": {
82
84
  "node": ">=22"
@@ -71,6 +71,12 @@ export function lightnet(lightnetConfig: LightnetConfig): AstroIntegration {
71
71
  prerender: true,
72
72
  })
73
73
 
74
+ injectRoute({
75
+ pattern: "/[locale]/media/[mediaId]/open-graph.png",
76
+ entrypoint: "lightnet/pages/detail-page/api/open-graph.ts",
77
+ prerender: true,
78
+ })
79
+
74
80
  injectRoute({
75
81
  pattern: "/api/internal/search.json",
76
82
  entrypoint: "lightnet/api/internal/search.ts",
@@ -6,25 +6,15 @@ 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"
10
9
 
11
10
  export type Props = {
12
11
  mediaId: string
13
12
  }
14
13
 
15
14
  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
- })
25
15
  ---
26
16
 
27
- <DetailsPage mediaId={mediaId} jsonLd={jsonLd}>
17
+ <DetailsPage mediaId={mediaId} fallbackSchemaType="AudioObject">
28
18
  <MainDetailsSection imageSize="md" mediaId={mediaId} />
29
19
  <AudioPanel mediaId={mediaId} className="mt-10" />
30
20
  <DescriptionSection mediaId={mediaId} />
@@ -6,24 +6,14 @@ 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"
10
9
 
11
10
  export type Props = {
12
11
  mediaId: string
13
12
  }
14
13
  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
- })
24
14
  ---
25
15
 
26
- <DetailsPage mediaId={mediaId} jsonLd={jsonLd}>
16
+ <DetailsPage mediaId={mediaId}>
27
17
  <MainDetailsSection mediaId={mediaId}>
28
18
  <OpenButton className="mt-8" mediaId={mediaId} />
29
19
  </MainDetailsSection>
@@ -5,25 +5,15 @@ 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"
9
8
 
10
9
  export type Props = {
11
10
  mediaId: string
12
11
  }
13
12
 
14
13
  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
- })
24
14
  ---
25
15
 
26
- <DetailsPage mediaId={mediaId} jsonLd={jsonLd}>
16
+ <DetailsPage mediaId={mediaId} fallbackSchemaType="VideoObject">
27
17
  <VideoDetailsSection mediaId={mediaId} />
28
18
  <DescriptionSection mediaId={mediaId} />
29
19
  <ContentSection mediaId={mediaId} />
@@ -0,0 +1,41 @@
1
+ import { readFile } from "node:fs/promises"
2
+
3
+ import type { APIRoute, GetStaticPaths } from "astro"
4
+ import { getCollection, getEntry } from "astro:content"
5
+ import config from "virtual:lightnet/config"
6
+
7
+ import { getMediaItem } from "../../../content/get-media-items"
8
+ import { getMediaType } from "../../../content/get-media-types"
9
+ import { createOpenGraphImage } from "../utils/create-open-graph-image"
10
+
11
+ export const getStaticPaths: GetStaticPaths = async () => {
12
+ const items = await getCollection("media")
13
+ return config.locales.flatMap((locale) =>
14
+ items.map(({ id: mediaId }) => ({ params: { locale, mediaId } })),
15
+ )
16
+ }
17
+
18
+ export const GET: APIRoute = async ({ params, locals }) => {
19
+ const item = await getMediaItem(params.mediaId!)
20
+ const mediaType = await getMediaType(item.data.type.id)
21
+ // Read the original Astro image metadata: schema validation strips its
22
+ // non-enumerable fsPath, and optimized assets are generated after prerendering.
23
+ const entry = await getEntry("media", params.mediaId!)
24
+ const imagePath: unknown = Reflect.get(entry!.data.image, "fsPath")
25
+ if (typeof imagePath !== "string") {
26
+ throw new Error(
27
+ `Cannot resolve the cover image for media item "${item.id}"`,
28
+ )
29
+ }
30
+ const { direction } = locals.i18n
31
+ const png = await createOpenGraphImage({
32
+ cover: await readFile(imagePath),
33
+ coverImageStyle: mediaType.data.coverImageStyle,
34
+ title: item.data.title,
35
+ authors: item.data.authors,
36
+ direction,
37
+ })
38
+ return new Response(new Uint8Array(png), {
39
+ headers: { "Content-Type": "image/png" },
40
+ })
41
+ }
@@ -1,30 +1,58 @@
1
1
  ---
2
+ import config from "virtual:lightnet/config"
3
+
2
4
  import { getMediaItem } from "../../../content/get-media-items"
3
5
  import Page from "../../../layouts/Page.astro"
4
6
  import { markdownToText } from "../../../utils/markdown"
5
7
  import { detailsPagePath } from "../../../utils/paths"
8
+ import { getDetailsPageJsonLd } from "../utils/get-details-page-json-ld"
6
9
 
7
10
  type Props = {
8
11
  mediaId: string
9
- /** Structured data to include in the page head as JSON-LD. */
10
- jsonLd?: Record<string, unknown>
12
+ /** Schema.org type used when the media type does not specify one. */
13
+ fallbackSchemaType?: string
11
14
  }
12
15
 
13
- const { mediaId: id, jsonLd } = Astro.props
16
+ const { mediaId: id, fallbackSchemaType = "CreativeWork" } = Astro.props
17
+ const { defaultLocale, currentLocale, tContentField, tConfigField } =
18
+ Astro.locals.i18n
14
19
  const mediaItem = (await getMediaItem(id)).data
20
+ const description = markdownToText(mediaItem.description)
21
+ const shareUrl = new URL(detailsPagePath(currentLocale, { id }), Astro.site)
22
+ const shareImage = new URL("open-graph.png", shareUrl)
23
+ const jsonLd = await getDetailsPageJsonLd({
24
+ mediaId: id,
25
+ fallbackSchemaType,
26
+ site: Astro.site,
27
+ defaultLocale,
28
+ tContentField,
29
+ })
15
30
  ---
16
31
 
17
32
  <Page
18
33
  title={mediaItem.title}
19
- description={markdownToText(mediaItem.description)}
20
- canonicalUrl={
21
- new URL(
22
- detailsPagePath(Astro.locals.i18n.defaultLocale, { id }),
23
- Astro.site,
24
- )
25
- }
34
+ description={description}
35
+ canonicalUrl={new URL(detailsPagePath(defaultLocale, { id }), Astro.site)}
26
36
  >
27
37
  <Fragment slot="head">
38
+ <meta property="og:type" content="website" />
39
+ <meta property="og:title" content={mediaItem.title} />
40
+ {description && <meta property="og:description" content={description} />}
41
+ <meta
42
+ property="og:site_name"
43
+ content={tConfigField(config.title, config)}
44
+ />
45
+ <meta property="og:url" content={shareUrl.href} />
46
+ <meta property="og:image" content={shareImage.href} />
47
+ <meta property="og:image:type" content="image/png" />
48
+ <meta property="og:image:width" content="1200" />
49
+ <meta property="og:image:height" content="630" />
50
+ <meta property="og:image:alt" content={mediaItem.title} />
51
+ <meta name="twitter:card" content="summary_large_image" />
52
+ <meta name="twitter:title" content={mediaItem.title} />
53
+ {description && <meta name="twitter:description" content={description} />}
54
+ <meta name="twitter:image" content={shareImage.href} />
55
+ <meta name="twitter:image:alt" content={mediaItem.title} />
28
56
  {jsonLd && (
29
57
  <script
30
58
  type="application/ld+json"
@@ -23,11 +23,9 @@ const FILE_FORMATS: Record<string, { type: string; mime: string }> = {
23
23
  txt: { type: "MediaObject", mime: "text/plain" },
24
24
  }
25
25
 
26
- export type DetailsPageLayout = "default" | "audio" | "video"
27
-
28
26
  export function createMediaItemJsonLd({
29
27
  item,
30
- layout,
28
+ fallbackSchemaType = "CreativeWork",
31
29
  schemaType,
32
30
  canonicalUrl,
33
31
  imageUrl,
@@ -35,16 +33,17 @@ export function createMediaItemJsonLd({
35
33
  contentLabels = [],
36
34
  }: {
37
35
  item: MediaItemEntry["data"]
38
- layout: DetailsPageLayout
36
+ fallbackSchemaType?: string
39
37
  schemaType?: MediaTypeEntry["data"]["schemaType"]
40
38
  canonicalUrl: URL
41
39
  imageUrl: string
42
40
  categoryNames?: string[]
43
41
  contentLabels?: (string | undefined)[]
44
42
  }): Record<string, unknown> {
43
+ const resolvedSchemaType = schemaType ?? fallbackSchemaType
45
44
  const base = {
46
45
  "@context": "https://schema.org",
47
- "@type": schemaType ?? "CreativeWork",
46
+ "@type": resolvedSchemaType,
48
47
  "@id": `${canonicalUrl.href}#work`,
49
48
  url: canonicalUrl.href,
50
49
  name: item.title,
@@ -87,7 +86,10 @@ export function createMediaItemJsonLd({
87
86
  })
88
87
 
89
88
  const encodings = media.filter((entry) => entry !== undefined)
90
- if (schemaType === "SoftwareApplication" || schemaType === "WebPage") {
89
+ if (
90
+ resolvedSchemaType === "SoftwareApplication" ||
91
+ resolvedSchemaType === "WebPage"
92
+ ) {
91
93
  const primary = item.content[0]
92
94
  const resourceUrl =
93
95
  primary?.type === "link"
@@ -102,55 +104,35 @@ export function createMediaItemJsonLd({
102
104
  }
103
105
 
104
106
  if (
105
- schemaType === "AudioObject" ||
106
- schemaType === "VideoObject" ||
107
- schemaType === "ImageObject"
107
+ resolvedSchemaType === "AudioObject" ||
108
+ resolvedSchemaType === "VideoObject" ||
109
+ resolvedSchemaType === "ImageObject"
108
110
  ) {
109
- const matching = encodings.filter((entry) => entry["@type"] === schemaType)
110
- if (schemaType === "AudioObject" && matching.length > 1) {
111
+ const matching = encodings.filter(
112
+ (entry) => entry["@type"] === resolvedSchemaType,
113
+ )
114
+ if (resolvedSchemaType === "AudioObject" && matching.length > 1) {
111
115
  return {
112
116
  ...base,
113
117
  hasPart: matching,
114
- encoding: encodings.filter((entry) => entry["@type"] !== schemaType),
118
+ encoding: encodings.filter(
119
+ (entry) => entry["@type"] !== resolvedSchemaType,
120
+ ),
115
121
  }
116
122
  }
117
- const primary = media[0]?.["@type"] === schemaType ? media[0] : undefined
123
+ const primary =
124
+ media[0]?.["@type"] === resolvedSchemaType ? media[0] : undefined
118
125
  const alternatives = encodings.filter((entry) => entry !== primary)
119
126
  return {
120
127
  ...primary,
121
128
  ...base,
122
- ...(schemaType === "VideoObject" ? { thumbnailUrl: imageUrl } : {}),
129
+ ...(resolvedSchemaType === "VideoObject"
130
+ ? { thumbnailUrl: imageUrl }
131
+ : {}),
123
132
  encoding: alternatives.length ? alternatives : undefined,
124
133
  }
125
134
  }
126
135
 
127
- if (
128
- !schemaType &&
129
- layout === "video" &&
130
- media[0]?.["@type"] === "VideoObject"
131
- ) {
132
- return {
133
- ...media[0],
134
- ...base,
135
- "@type": "VideoObject",
136
- thumbnailUrl: imageUrl,
137
- }
138
- }
139
-
140
- if (layout === "audio") {
141
- const tracks = encodings.filter((entry) => entry["@type"] === "AudioObject")
142
- if (!schemaType && tracks.length === 1 && item.content.length === 1) {
143
- return { ...tracks[0], ...base, "@type": "AudioObject" }
144
- }
145
- if (tracks.length && item.content.length > 1) {
146
- return {
147
- ...base,
148
- hasPart: tracks,
149
- encoding: encodings.filter((entry) => entry["@type"] !== "AudioObject"),
150
- }
151
- }
152
- }
153
-
154
136
  return { ...base, encoding: encodings.length ? encodings : undefined }
155
137
  }
156
138
 
@@ -0,0 +1,186 @@
1
+ import sharp, { type OverlayOptions } from "sharp"
2
+
3
+ import type { MediaTypeEntry } from "../../../content/schema/media-type"
4
+
5
+ export const OPEN_GRAPH_WIDTH = 1200
6
+ export const OPEN_GRAPH_HEIGHT = 630
7
+
8
+ // Text is Pango markup. Escape user content before passing it to the renderer.
9
+ function escapeMarkup(text: string) {
10
+ return text.replace(
11
+ /[&<>"']/g,
12
+ (character) =>
13
+ ({
14
+ "&": "&amp;",
15
+ "<": "&lt;",
16
+ ">": "&gt;",
17
+ '"': "&quot;",
18
+ "'": "&apos;",
19
+ })[character]!,
20
+ )
21
+ }
22
+
23
+ async function renderText(
24
+ value: string,
25
+ {
26
+ width,
27
+ height,
28
+ size,
29
+ color,
30
+ bold = false,
31
+ rtl = false,
32
+ }: {
33
+ width: number
34
+ height: number
35
+ size: number
36
+ color: string
37
+ bold?: boolean
38
+ rtl?: boolean
39
+ },
40
+ ) {
41
+ let fontSize = size
42
+ // Measure the rendered text and reduce the font until every line fits.
43
+ // Pango handles Unicode shaping, bidi text, and wrapping long words.
44
+ while (true) {
45
+ const result = await sharp({
46
+ text: {
47
+ text: `<span foreground="${color}">${escapeMarkup(value)}</span>`,
48
+ font: `sans ${bold ? "bold " : ""}${fontSize}`,
49
+ width,
50
+ wrap: "word-char",
51
+ align: rtl ? "right" : "left",
52
+ rgba: true,
53
+ },
54
+ })
55
+ .png()
56
+ .toBuffer({ resolveWithObject: true })
57
+ if (result.info.height <= height) return result
58
+ fontSize *= 0.9
59
+ }
60
+ }
61
+
62
+ export async function createOpenGraphImage({
63
+ cover,
64
+ title,
65
+ authors = [],
66
+ direction = "ltr",
67
+ coverImageStyle = "default",
68
+ }: {
69
+ cover: Buffer
70
+ title: string
71
+ authors?: string[] | null
72
+ direction?: "ltr" | "rtl"
73
+ coverImageStyle?: MediaTypeEntry["data"]["coverImageStyle"]
74
+ }) {
75
+ const rtl = direction === "rtl"
76
+ const textStart = rtl ? 64 : 560
77
+ const coverImage = await sharp(cover)
78
+ .autoOrient()
79
+ .resize(432, 502, { fit: "inside" })
80
+ .png()
81
+ .toBuffer({ resolveWithObject: true })
82
+ const { dominant } = await sharp(coverImage.data).stats()
83
+ const { width: coverWidth, height: coverHeight } = coverImage.info
84
+ const cornerRadius = coverImageStyle === "book" ? 2 : 6
85
+ const decorations: OverlayOptions[] = []
86
+ if (coverImageStyle === "book") {
87
+ decorations.push({
88
+ input: Buffer.from(`<svg width="${coverWidth}" height="${coverHeight}">
89
+ <defs>
90
+ <linearGradient id="spine" x1="${rtl ? 1 : 0}" x2="${rtl ? 0 : 1}">
91
+ <stop stop-color="#6b7280" stop-opacity="0.2"/>
92
+ <stop offset="1" stop-color="#6b7280" stop-opacity="0"/>
93
+ </linearGradient>
94
+ </defs>
95
+ <rect x="${coverWidth * (rtl ? 0.97 : 0.01)}" width="${coverWidth * 0.02}" height="100%" fill="url(#spine)"/>
96
+ </svg>`),
97
+ })
98
+ }
99
+ decorations.push({
100
+ input: Buffer.from(`<svg width="${coverWidth}" height="${coverHeight}">
101
+ <rect width="100%" height="100%" rx="${cornerRadius}" fill="white"/>
102
+ </svg>`),
103
+ blend: "dest-in",
104
+ })
105
+ const decoratedCover = await sharp(coverImage.data)
106
+ .ensureAlpha()
107
+ .composite(decorations)
108
+ .png()
109
+ .toBuffer()
110
+ const tint = `rgb(${[dominant.r, dominant.g, dominant.b]
111
+ .map((channel) => Math.round(250 * 0.68 + channel * 0.32))
112
+ .join(",")})`
113
+ const coverLeft =
114
+ (rtl ? 704 : 64) + Math.floor((432 - coverImage.info.width) / 2)
115
+ const coverTop = 64 + Math.floor((502 - coverImage.info.height) / 2)
116
+ const layers: OverlayOptions[] = [
117
+ {
118
+ input: decoratedCover,
119
+ left: coverLeft,
120
+ top: coverTop,
121
+ },
122
+ ]
123
+ const textLayers: OverlayOptions[] = []
124
+ const addText = async (
125
+ value: string,
126
+ top: number,
127
+ height: number,
128
+ size: number,
129
+ color: string,
130
+ bold = false,
131
+ ) => {
132
+ if (!value.trim()) return 0
133
+ const text = await renderText(value, {
134
+ width: 576,
135
+ height,
136
+ size,
137
+ color,
138
+ bold,
139
+ rtl,
140
+ })
141
+ textLayers.push({
142
+ input: text.data,
143
+ left: textStart + (rtl ? 576 - text.info.width : 0),
144
+ top,
145
+ })
146
+ return text.info.height
147
+ }
148
+
149
+ const titleHeight = await addText(title, 0, 280, 64, "#111827", true)
150
+ const authorsHeight = await addText(
151
+ authors?.join(", ") ?? "",
152
+ titleHeight + 24,
153
+ 84,
154
+ 32,
155
+ "#4b5563",
156
+ )
157
+ const contentHeight = titleHeight + (authorsHeight ? 24 + authorsHeight : 0)
158
+ const textTop = Math.round((OPEN_GRAPH_HEIGHT - contentHeight) / 2)
159
+ layers.push(
160
+ ...textLayers.map((layer) => ({
161
+ ...layer,
162
+ top: textTop + (layer.top ?? 0),
163
+ })),
164
+ )
165
+
166
+ return sharp(
167
+ Buffer.from(`<svg width="${OPEN_GRAPH_WIDTH}" height="${OPEN_GRAPH_HEIGHT}">
168
+ <defs>
169
+ <linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
170
+ <stop stop-color="${rtl ? "#fcfcfd" : tint}"/>
171
+ <stop offset="1" stop-color="${rtl ? tint : "#fcfcfd"}"/>
172
+ </linearGradient>
173
+ <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
174
+ <feGaussianBlur stdDeviation="16"/>
175
+ </filter>
176
+ </defs>
177
+ <rect width="100%" height="100%" fill="url(#background)"/>
178
+ <rect x="${coverLeft}" y="${coverTop + 12}" width="${coverImage.info.width}" height="${coverImage.info.height}" rx="${cornerRadius}"
179
+ fill="#0f172a" opacity="0.18" filter="url(#shadow)"/>
180
+ </svg>`),
181
+ )
182
+ .flatten({ background: "#fafafa" })
183
+ .composite(layers)
184
+ .png()
185
+ .toBuffer()
186
+ }
@@ -5,18 +5,17 @@ import { getMediaItem } from "../../../content/get-media-items"
5
5
  import { getMediaType } from "../../../content/get-media-types"
6
6
  import type { TranslateContentFieldFn } from "../../../i18n/translate-map"
7
7
  import { detailsPagePath } from "../../../utils/paths"
8
- import type { DetailsPageLayout } from "./create-media-item-json-ld"
9
8
  import { createMediaItemJsonLd } from "./create-media-item-json-ld"
10
9
 
11
10
  export async function getDetailsPageJsonLd({
12
11
  mediaId,
13
- layout,
12
+ fallbackSchemaType,
14
13
  site,
15
14
  defaultLocale,
16
15
  tContentField,
17
16
  }: {
18
17
  mediaId: string
19
- layout: DetailsPageLayout
18
+ fallbackSchemaType: string
20
19
  site: URL | undefined
21
20
  defaultLocale: string
22
21
  tContentField: TranslateContentFieldFn
@@ -36,7 +35,7 @@ export async function getDetailsPageJsonLd({
36
35
 
37
36
  return createMediaItemJsonLd({
38
37
  item: item.data,
39
- layout,
38
+ fallbackSchemaType,
40
39
  schemaType: mediaType.data.schemaType,
41
40
  canonicalUrl,
42
41
  imageUrl: new URL(image.src, site).href,