lightnet 4.8.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,21 @@
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
+
13
+ ## 4.9.0
14
+
15
+ ### Minor Changes
16
+
17
+ - [#475](https://github.com/LightNetDev/LightNet/pull/475) [`24a5813`](https://github.com/LightNetDev/LightNet/commit/24a58138e83824c7f740b77c2373f152c6d02eb7) - Support `SoftwareApplication` and `WebPage` as media type structured data classifications in JSON definitions and the admin interface. For these types, use the first content entry's HTTP(S) link as the resource URL and identify the LightNet detail page with `mainEntityOfPage`. Retain the detail URL when the first entry is an upload or has no valid HTTP(S) URL.
18
+
3
19
  ## 4.8.0
4
20
 
5
21
  ### 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.8.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",
@@ -16,6 +16,8 @@ export const mediaTypeSchema = z.object({
16
16
  * Schema.org type of the work described on media detail pages.
17
17
  * Independent of the page layout and downloadable file formats.
18
18
  * If omitted, structured data is inferred from the layout and content.
19
+ * WebPage and SoftwareApplication use the first content entry's HTTP(S)
20
+ * link as the work URL, with the LightNet detail page as mainEntityOfPage.
19
21
  */
20
22
  schemaType: z
21
23
  .enum([
@@ -26,6 +28,8 @@ export const mediaTypeSchema = z.object({
26
28
  "AudioObject",
27
29
  "VideoObject",
28
30
  "ImageObject",
31
+ "SoftwareApplication",
32
+ "WebPage",
29
33
  ])
30
34
  .optional(),
31
35
  /**
@@ -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,
@@ -61,13 +60,8 @@ export function createMediaItemJsonLd({
61
60
  }
62
61
 
63
62
  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)) {
63
+ const resolvedUrl = resolveHttpUrl(url, canonicalUrl)
64
+ if (!resolvedUrl) {
71
65
  return undefined
72
66
  }
73
67
 
@@ -93,56 +87,62 @@ export function createMediaItemJsonLd({
93
87
 
94
88
  const encodings = media.filter((entry) => entry !== undefined)
95
89
  if (
96
- schemaType === "AudioObject" ||
97
- schemaType === "VideoObject" ||
98
- schemaType === "ImageObject"
90
+ resolvedSchemaType === "SoftwareApplication" ||
91
+ resolvedSchemaType === "WebPage"
99
92
  ) {
100
- const matching = encodings.filter((entry) => entry["@type"] === schemaType)
101
- if (schemaType === "AudioObject" && matching.length > 1) {
93
+ const primary = item.content[0]
94
+ const resourceUrl =
95
+ primary?.type === "link"
96
+ ? resolveHttpUrl(primary.url, canonicalUrl)
97
+ : undefined
98
+ return {
99
+ ...base,
100
+ url: resourceUrl?.href ?? canonicalUrl.href,
101
+ mainEntityOfPage: canonicalUrl.href,
102
+ encoding: encodings.length ? encodings : undefined,
103
+ }
104
+ }
105
+
106
+ if (
107
+ resolvedSchemaType === "AudioObject" ||
108
+ resolvedSchemaType === "VideoObject" ||
109
+ resolvedSchemaType === "ImageObject"
110
+ ) {
111
+ const matching = encodings.filter(
112
+ (entry) => entry["@type"] === resolvedSchemaType,
113
+ )
114
+ if (resolvedSchemaType === "AudioObject" && matching.length > 1) {
102
115
  return {
103
116
  ...base,
104
117
  hasPart: matching,
105
- encoding: encodings.filter((entry) => entry["@type"] !== schemaType),
118
+ encoding: encodings.filter(
119
+ (entry) => entry["@type"] !== resolvedSchemaType,
120
+ ),
106
121
  }
107
122
  }
108
- const primary = media[0]?.["@type"] === schemaType ? media[0] : undefined
123
+ const primary =
124
+ media[0]?.["@type"] === resolvedSchemaType ? media[0] : undefined
109
125
  const alternatives = encodings.filter((entry) => entry !== primary)
110
126
  return {
111
127
  ...primary,
112
128
  ...base,
113
- ...(schemaType === "VideoObject" ? { thumbnailUrl: imageUrl } : {}),
129
+ ...(resolvedSchemaType === "VideoObject"
130
+ ? { thumbnailUrl: imageUrl }
131
+ : {}),
114
132
  encoding: alternatives.length ? alternatives : undefined,
115
133
  }
116
134
  }
117
135
 
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
- }
136
+ return { ...base, encoding: encodings.length ? encodings : undefined }
137
+ }
130
138
 
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
- }
139
+ function resolveHttpUrl(value: string, base: URL) {
140
+ try {
141
+ const url = new URL(value, base)
142
+ return ["http:", "https:"].includes(url.protocol) ? url : undefined
143
+ } catch {
144
+ return undefined
143
145
  }
144
-
145
- return { ...base, encoding: encodings.length ? encodings : undefined }
146
146
  }
147
147
 
148
148
  function getVideoEmbedUrl(url: URL) {
@@ -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,