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 +10 -0
- package/package.json +9 -7
- package/src/astro-integration/integration.ts +6 -0
- package/src/pages/details-page/AudioDetailsPage.astro +1 -11
- package/src/pages/details-page/DefaultDetailsPage.astro +1 -11
- package/src/pages/details-page/VideoDetailsPage.astro +1 -11
- package/src/pages/details-page/api/open-graph.ts +41 -0
- package/src/pages/details-page/components/DetailsPage.astro +38 -10
- package/src/pages/details-page/utils/create-media-item-json-ld.ts +23 -41
- package/src/pages/details-page/utils/create-open-graph-image.ts +186 -0
- package/src/pages/details-page/utils/get-details-page-json-ld.ts +3 -4
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.
|
|
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.
|
|
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.
|
|
67
|
-
"lucide-react": "^1.
|
|
68
|
-
"marked": "^18.1.
|
|
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
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
-
/**
|
|
10
|
-
|
|
12
|
+
/** Schema.org type used when the media type does not specify one. */
|
|
13
|
+
fallbackSchemaType?: string
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
const { mediaId: id,
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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 (
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
resolvedSchemaType === "AudioObject" ||
|
|
108
|
+
resolvedSchemaType === "VideoObject" ||
|
|
109
|
+
resolvedSchemaType === "ImageObject"
|
|
108
110
|
) {
|
|
109
|
-
const matching = encodings.filter(
|
|
110
|
-
|
|
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(
|
|
118
|
+
encoding: encodings.filter(
|
|
119
|
+
(entry) => entry["@type"] !== resolvedSchemaType,
|
|
120
|
+
),
|
|
115
121
|
}
|
|
116
122
|
}
|
|
117
|
-
const primary =
|
|
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
|
-
...(
|
|
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
|
+
"&": "&",
|
|
15
|
+
"<": "<",
|
|
16
|
+
">": ">",
|
|
17
|
+
'"': """,
|
|
18
|
+
"'": "'",
|
|
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
|
-
|
|
12
|
+
fallbackSchemaType,
|
|
14
13
|
site,
|
|
15
14
|
defaultLocale,
|
|
16
15
|
tContentField,
|
|
17
16
|
}: {
|
|
18
17
|
mediaId: string
|
|
19
|
-
|
|
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
|
-
|
|
38
|
+
fallbackSchemaType,
|
|
40
39
|
schemaType: mediaType.data.schemaType,
|
|
41
40
|
canonicalUrl,
|
|
42
41
|
imageUrl: new URL(image.src, site).href,
|