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 +16 -0
- package/package.json +9 -7
- package/src/astro-integration/integration.ts +6 -0
- package/src/content/schema/media-type.ts +4 -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 +46 -46
- 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,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.
|
|
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",
|
|
@@ -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}
|
|
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,
|
|
@@ -61,13 +60,8 @@ export function createMediaItemJsonLd({
|
|
|
61
60
|
}
|
|
62
61
|
|
|
63
62
|
const media = item.content.map(({ url }, index) => {
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
schemaType === "ImageObject"
|
|
90
|
+
resolvedSchemaType === "SoftwareApplication" ||
|
|
91
|
+
resolvedSchemaType === "WebPage"
|
|
99
92
|
) {
|
|
100
|
-
const
|
|
101
|
-
|
|
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(
|
|
118
|
+
encoding: encodings.filter(
|
|
119
|
+
(entry) => entry["@type"] !== resolvedSchemaType,
|
|
120
|
+
),
|
|
106
121
|
}
|
|
107
122
|
}
|
|
108
|
-
const primary =
|
|
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
|
-
...(
|
|
129
|
+
...(resolvedSchemaType === "VideoObject"
|
|
130
|
+
? { thumbnailUrl: imageUrl }
|
|
131
|
+
: {}),
|
|
114
132
|
encoding: alternatives.length ? alternatives : undefined,
|
|
115
133
|
}
|
|
116
134
|
}
|
|
117
135
|
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
"&": "&",
|
|
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,
|