lightnet 4.9.0 → 4.11.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 +26 -0
- package/exports/details-page.ts +1 -0
- package/package.json +9 -7
- package/src/astro-integration/config.ts +5 -0
- package/src/astro-integration/integration.ts +8 -0
- package/src/components/HighlightSection.astro +31 -2
- package/src/layouts/components/header/Header.astro +1 -1
- package/src/layouts/components/header/Menu.astro +1 -1
- package/src/pages/details-page/AudioDetailsPage.astro +6 -13
- package/src/pages/details-page/DefaultDetailsPage.astro +7 -14
- package/src/pages/details-page/VideoDetailsPage.astro +5 -12
- package/src/pages/details-page/api/open-graph.ts +35 -0
- package/src/pages/details-page/components/AudioPlayer.astro +11 -1
- package/src/pages/details-page/components/DetailsHero.astro +50 -0
- package/src/pages/details-page/components/DetailsPage.astro +53 -10
- package/src/pages/details-page/components/MainDetailsSection.astro +3 -3
- package/src/pages/details-page/components/VideoDetailsSection.astro +2 -2
- package/src/pages/details-page/components/main-details/CoverImage.astro +4 -1
- 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 +198 -0
- package/src/pages/details-page/utils/get-cached-open-graph-image.ts +52 -0
- package/src/pages/details-page/utils/get-details-page-json-ld.ts +3 -4
- package/src/pages/details-page/utils/get-media-cover.ts +16 -0
- package/src/utils/get-image-tint.ts +144 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# lightnet
|
|
2
2
|
|
|
3
|
+
## 4.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#479](https://github.com/LightNetDev/LightNet/pull/479) [`d152514`](https://github.com/LightNetDev/LightNet/commit/d152514a2a637cdb6f6fb8ad8f83702a5e7e246a) - Add `disableOpenGraphImages` to skip generating Open Graph images and use media cover images for Open Graph and Twitter previews instead. Generation remains enabled by default.
|
|
8
|
+
|
|
9
|
+
- [#479](https://github.com/LightNetDev/LightNet/pull/479) [`d152514`](https://github.com/LightNetDev/LightNet/commit/d152514a2a637cdb6f6fb8ad8f83702a5e7e246a) - Add cover-colored gradients to the opening section of details pages, with softer cover shadows and clearer primary actions. Share color extraction with Open Graph images, persist calculated colors in Astro's cache directory across builds, and use CSS custom properties to keep gradient styles independent of the number of covers.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#479](https://github.com/LightNetDev/LightNet/pull/479) [`d152514`](https://github.com/LightNetDev/LightNet/commit/d152514a2a637cdb6f6fb8ad8f83702a5e7e246a) - Use color tint for HighlightSection coloring.
|
|
14
|
+
|
|
15
|
+
- [#479](https://github.com/LightNetDev/LightNet/pull/479) [`d152514`](https://github.com/LightNetDev/LightNet/commit/d152514a2a637cdb6f6fb8ad8f83702a5e7e246a) - Add transparent effect to headerbar.
|
|
16
|
+
|
|
17
|
+
- [#479](https://github.com/LightNetDev/LightNet/pull/479) [`d152514`](https://github.com/LightNetDev/LightNet/commit/d152514a2a637cdb6f6fb8ad8f83702a5e7e246a) - Add performance improvements for open-graph image generation.
|
|
18
|
+
|
|
19
|
+
## 4.10.0
|
|
20
|
+
|
|
21
|
+
### Minor Changes
|
|
22
|
+
|
|
23
|
+
- [#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.
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- [#477](https://github.com/LightNetDev/LightNet/pull/477) [`67d9a6e`](https://github.com/LightNetDev/LightNet/commit/67d9a6eab378697f0baa80b70939b69c8fbdf237) - Update dependencies.
|
|
28
|
+
|
|
3
29
|
## 4.9.0
|
|
4
30
|
|
|
5
31
|
### Minor Changes
|
package/exports/details-page.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { default as ContentSection } from "../src/pages/details-page/components/ContentSection.astro"
|
|
6
6
|
export { default as DescriptionSection } from "../src/pages/details-page/components/DescriptionSection.astro"
|
|
7
|
+
export { default as DetailsHero } from "../src/pages/details-page/components/DetailsHero.astro"
|
|
7
8
|
export { default as DetailsPage } from "../src/pages/details-page/components/DetailsPage.astro"
|
|
8
9
|
export { default as OpenButton } from "../src/pages/details-page/components/main-details/OpenButton.astro"
|
|
9
10
|
export { default as ShareButton } from "../src/pages/details-page/components/main-details/ShareButton.astro"
|
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.11.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"
|
|
@@ -114,6 +114,11 @@ export const configSchema = z.object({
|
|
|
114
114
|
* not add any page of this media library to their search results.
|
|
115
115
|
*/
|
|
116
116
|
disallowSearchIndexing: z.boolean().optional(),
|
|
117
|
+
/**
|
|
118
|
+
* Disable Open Graph image generation and use media cover images for
|
|
119
|
+
* Open Graph and Twitter previews instead. Generation is enabled by default.
|
|
120
|
+
*/
|
|
121
|
+
disableOpenGraphImages: z.boolean().default(false),
|
|
117
122
|
/**
|
|
118
123
|
* Link to manifest file within public/ folder
|
|
119
124
|
*/
|
|
@@ -71,6 +71,14 @@ export function lightnet(lightnetConfig: LightnetConfig): AstroIntegration {
|
|
|
71
71
|
prerender: true,
|
|
72
72
|
})
|
|
73
73
|
|
|
74
|
+
if (!config.disableOpenGraphImages) {
|
|
75
|
+
injectRoute({
|
|
76
|
+
pattern: "/[locale]/media/[mediaId]/open-graph.png",
|
|
77
|
+
entrypoint: "lightnet/pages/detail-page/api/open-graph.ts",
|
|
78
|
+
prerender: true,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
74
82
|
injectRoute({
|
|
75
83
|
pattern: "/api/internal/search.json",
|
|
76
84
|
entrypoint: "lightnet/api/internal/search.ts",
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { ImageMetadata } from "astro"
|
|
3
3
|
import { Image } from "astro:assets"
|
|
4
|
+
import { cacheDir } from "astro:config/server"
|
|
4
5
|
import { ArrowRightIcon } from "lucide-react"
|
|
5
6
|
|
|
7
|
+
import { getImageTint } from "../utils/get-image-tint"
|
|
8
|
+
|
|
6
9
|
interface Props {
|
|
7
10
|
image: ImageMetadata
|
|
8
11
|
id?: string
|
|
@@ -16,14 +19,30 @@ interface Props {
|
|
|
16
19
|
|
|
17
20
|
const { image, id, title, text, link, className, titleClass, textClass } =
|
|
18
21
|
Astro.props
|
|
22
|
+
let tint = "#e5e7eb"
|
|
23
|
+
try {
|
|
24
|
+
tint = await getImageTint(image, new URL(cacheDir))
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.warn("[lightnet] Cannot extract highlight image color:", error)
|
|
27
|
+
}
|
|
28
|
+
const tintChannels = tint.startsWith("rgb(")
|
|
29
|
+
? tint.slice(4, -1).split(",").map(Number)
|
|
30
|
+
: [229, 231, 235]
|
|
31
|
+
const darkTint = (factor: number) =>
|
|
32
|
+
`rgb(${tintChannels.map((channel) => Math.round(channel * factor)).join(",")})`
|
|
19
33
|
const iconDirectionClass =
|
|
20
34
|
Astro.locals.i18n.direction === "rtl" ? "scale-x-[-1]" : ""
|
|
21
35
|
---
|
|
22
36
|
|
|
23
37
|
<section
|
|
24
|
-
class="mt-24 w-full bg-gray-
|
|
38
|
+
class="mt-24 w-full bg-[var(--image-tint)] text-gray-900 md:mt-28"
|
|
25
39
|
class:list={[className]}
|
|
26
40
|
id={id}
|
|
41
|
+
style={{
|
|
42
|
+
"--image-tint": tint,
|
|
43
|
+
"--button-tint": darkTint(0.12),
|
|
44
|
+
"--button-hover-tint": darkTint(0.08),
|
|
45
|
+
}}
|
|
27
46
|
>
|
|
28
47
|
<div class="flex flex-col overflow-hidden md:flex-row">
|
|
29
48
|
<Image
|
|
@@ -49,7 +68,7 @@ const iconDirectionClass =
|
|
|
49
68
|
)}
|
|
50
69
|
{link && (
|
|
51
70
|
<a
|
|
52
|
-
class="inline-flex items-center justify-center gap-2 rounded-2xl bg-
|
|
71
|
+
class="inline-flex items-center justify-center gap-2 rounded-2xl bg-[var(--button-tint)] px-6 py-3 text-sm font-bold text-gray-50 shadow-sm transition-colors hover:bg-[var(--button-hover-tint)] hover:text-gray-100"
|
|
53
72
|
href={link.href}
|
|
54
73
|
>
|
|
55
74
|
{link.text}
|
|
@@ -60,3 +79,13 @@ const iconDirectionClass =
|
|
|
60
79
|
</div>
|
|
61
80
|
</div>
|
|
62
81
|
</section>
|
|
82
|
+
|
|
83
|
+
<style>
|
|
84
|
+
section {
|
|
85
|
+
background-image: linear-gradient(
|
|
86
|
+
to bottom,
|
|
87
|
+
rgb(255 255 255 / 25%),
|
|
88
|
+
transparent
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
</style>
|
|
@@ -3,7 +3,7 @@ import PageNavigation from "./PageNavigation.astro"
|
|
|
3
3
|
import PageTitle from "./PageTitle.astro"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
<header class="fixed top-0 z-50 h-14 w-full bg-white
|
|
6
|
+
<header class="fixed top-0 z-50 h-14 w-full border-b border-black/5 before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:bg-white/85 before:backdrop-blur-lg sm:h-20">
|
|
7
7
|
<div class="mx-auto flex h-full max-w-screen-xl justify-between px-4 md:px-8">
|
|
8
8
|
<PageTitle />
|
|
9
9
|
<PageNavigation />
|
|
@@ -21,7 +21,7 @@ const { label, disabled } = Astro.props
|
|
|
21
21
|
data-menu-panel
|
|
22
22
|
aria-hidden="true"
|
|
23
23
|
inert
|
|
24
|
-
class="pointer-events-none absolute end-3 top-full mt-px flex w-48 origin-top scale-y-90 flex-col overflow-hidden rounded-b-md bg-white py-3 opacity-0 shadow-lg transition-all duration-100 ease-out motion-reduce:transform-none motion-reduce:transition-none"
|
|
24
|
+
class="pointer-events-none absolute end-3 top-full mt-px flex w-48 origin-top scale-y-90 flex-col overflow-hidden rounded-b-md bg-white/85 py-3 opacity-0 shadow-lg backdrop-blur-lg transition-all duration-100 ease-out motion-reduce:transform-none motion-reduce:transition-none"
|
|
25
25
|
>
|
|
26
26
|
<slot />
|
|
27
27
|
</ul>
|
|
@@ -2,31 +2,24 @@
|
|
|
2
2
|
import AudioPanel from "./components/AudioPanel.astro"
|
|
3
3
|
import ContentSection from "./components/ContentSection.astro"
|
|
4
4
|
import DescriptionSection from "./components/DescriptionSection.astro"
|
|
5
|
+
import DetailsHero from "./components/DetailsHero.astro"
|
|
5
6
|
import DetailsPage from "./components/DetailsPage.astro"
|
|
6
7
|
import MainDetailsSection from "./components/MainDetailsSection.astro"
|
|
7
8
|
import MediaCollectionsSection from "./components/MediaCollectionsSection.astro"
|
|
8
9
|
import MoreDetailsSection from "./components/MoreDetailsSection.astro"
|
|
9
|
-
import { getDetailsPageJsonLd } from "./utils/get-details-page-json-ld"
|
|
10
10
|
|
|
11
11
|
export type Props = {
|
|
12
12
|
mediaId: string
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const { mediaId } = Astro.props
|
|
16
|
-
const { defaultLocale, tContentField } = Astro.locals.i18n
|
|
17
|
-
|
|
18
|
-
const jsonLd = await getDetailsPageJsonLd({
|
|
19
|
-
mediaId,
|
|
20
|
-
layout: "audio",
|
|
21
|
-
site: Astro.site,
|
|
22
|
-
defaultLocale,
|
|
23
|
-
tContentField,
|
|
24
|
-
})
|
|
25
16
|
---
|
|
26
17
|
|
|
27
|
-
<DetailsPage mediaId={mediaId}
|
|
28
|
-
<
|
|
29
|
-
|
|
18
|
+
<DetailsPage mediaId={mediaId} fallbackSchemaType="AudioObject">
|
|
19
|
+
<DetailsHero mediaId={mediaId}>
|
|
20
|
+
<MainDetailsSection imageSize="md" mediaId={mediaId} />
|
|
21
|
+
<AudioPanel mediaId={mediaId} className="mt-10" />
|
|
22
|
+
</DetailsHero>
|
|
30
23
|
<DescriptionSection mediaId={mediaId} />
|
|
31
24
|
<ContentSection mediaId={mediaId} minimumItems={1} />
|
|
32
25
|
<MediaCollectionsSection mediaId={mediaId} />
|
|
@@ -1,32 +1,25 @@
|
|
|
1
1
|
---
|
|
2
2
|
import ContentSection from "./components/ContentSection.astro"
|
|
3
3
|
import DescriptionSection from "./components/DescriptionSection.astro"
|
|
4
|
+
import DetailsHero from "./components/DetailsHero.astro"
|
|
4
5
|
import DetailsPage from "./components/DetailsPage.astro"
|
|
5
6
|
import OpenButton from "./components/main-details/OpenButton.astro"
|
|
6
7
|
import MainDetailsSection from "./components/MainDetailsSection.astro"
|
|
7
8
|
import MediaCollectionsSection from "./components/MediaCollectionsSection.astro"
|
|
8
9
|
import MoreDetailsSection from "./components/MoreDetailsSection.astro"
|
|
9
|
-
import { getDetailsPageJsonLd } from "./utils/get-details-page-json-ld"
|
|
10
10
|
|
|
11
11
|
export type Props = {
|
|
12
12
|
mediaId: string
|
|
13
13
|
}
|
|
14
14
|
const { mediaId } = Astro.props
|
|
15
|
-
const { defaultLocale, tContentField } = Astro.locals.i18n
|
|
16
|
-
|
|
17
|
-
const jsonLd = await getDetailsPageJsonLd({
|
|
18
|
-
mediaId,
|
|
19
|
-
layout: "default",
|
|
20
|
-
site: Astro.site,
|
|
21
|
-
defaultLocale,
|
|
22
|
-
tContentField,
|
|
23
|
-
})
|
|
24
15
|
---
|
|
25
16
|
|
|
26
|
-
<DetailsPage mediaId={mediaId}
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
|
|
17
|
+
<DetailsPage mediaId={mediaId}>
|
|
18
|
+
<DetailsHero mediaId={mediaId}>
|
|
19
|
+
<MainDetailsSection mediaId={mediaId}>
|
|
20
|
+
<OpenButton className="mt-6" mediaId={mediaId} />
|
|
21
|
+
</MainDetailsSection>
|
|
22
|
+
</DetailsHero>
|
|
30
23
|
<DescriptionSection mediaId={mediaId} />
|
|
31
24
|
<ContentSection mediaId={mediaId} />
|
|
32
25
|
<MediaCollectionsSection mediaId={mediaId} />
|
|
@@ -1,30 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
import ContentSection from "./components/ContentSection.astro"
|
|
3
3
|
import DescriptionSection from "./components/DescriptionSection.astro"
|
|
4
|
+
import DetailsHero from "./components/DetailsHero.astro"
|
|
4
5
|
import DetailsPage from "./components/DetailsPage.astro"
|
|
5
6
|
import MediaCollectionsSection from "./components/MediaCollectionsSection.astro"
|
|
6
7
|
import MoreDetailsSection from "./components/MoreDetailsSection.astro"
|
|
7
8
|
import VideoDetailsSection from "./components/VideoDetailsSection.astro"
|
|
8
|
-
import { getDetailsPageJsonLd } from "./utils/get-details-page-json-ld"
|
|
9
9
|
|
|
10
10
|
export type Props = {
|
|
11
11
|
mediaId: string
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const { mediaId } = Astro.props
|
|
15
|
-
const { defaultLocale, tContentField } = Astro.locals.i18n
|
|
16
|
-
|
|
17
|
-
const jsonLd = await getDetailsPageJsonLd({
|
|
18
|
-
mediaId,
|
|
19
|
-
layout: "video",
|
|
20
|
-
site: Astro.site,
|
|
21
|
-
defaultLocale,
|
|
22
|
-
tContentField,
|
|
23
|
-
})
|
|
24
15
|
---
|
|
25
16
|
|
|
26
|
-
<DetailsPage mediaId={mediaId}
|
|
27
|
-
<
|
|
17
|
+
<DetailsPage mediaId={mediaId} fallbackSchemaType="VideoObject">
|
|
18
|
+
<DetailsHero mediaId={mediaId}>
|
|
19
|
+
<VideoDetailsSection mediaId={mediaId} />
|
|
20
|
+
</DetailsHero>
|
|
28
21
|
<DescriptionSection mediaId={mediaId} />
|
|
29
22
|
<ContentSection mediaId={mediaId} />
|
|
30
23
|
<MediaCollectionsSection mediaId={mediaId} />
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { APIRoute, GetStaticPaths } from "astro"
|
|
2
|
+
import { cacheDir } from "astro:config/server"
|
|
3
|
+
import { getCollection } from "astro:content"
|
|
4
|
+
import config from "virtual:lightnet/config"
|
|
5
|
+
|
|
6
|
+
import { getMediaItem } from "../../../content/get-media-items"
|
|
7
|
+
import { getMediaType } from "../../../content/get-media-types"
|
|
8
|
+
import { getCachedOpenGraphImage } from "../utils/get-cached-open-graph-image"
|
|
9
|
+
import { getMediaCover } from "../utils/get-media-cover"
|
|
10
|
+
|
|
11
|
+
export const getStaticPaths: GetStaticPaths = async () => {
|
|
12
|
+
const items = await getCollection("media")
|
|
13
|
+
return items.map(({ id: mediaId }) => ({
|
|
14
|
+
params: { locale: config.defaultLocale, 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
|
+
const { direction } = locals.i18n
|
|
22
|
+
const png = await getCachedOpenGraphImage(
|
|
23
|
+
{
|
|
24
|
+
cover: await getMediaCover(item.id),
|
|
25
|
+
coverImageStyle: mediaType.data.coverImageStyle,
|
|
26
|
+
title: item.data.title,
|
|
27
|
+
authors: item.data.authors,
|
|
28
|
+
direction,
|
|
29
|
+
},
|
|
30
|
+
new URL(cacheDir),
|
|
31
|
+
)
|
|
32
|
+
return new Response(new Uint8Array(png), {
|
|
33
|
+
headers: { "Content-Type": "image/png" },
|
|
34
|
+
})
|
|
35
|
+
}
|
|
@@ -16,7 +16,12 @@ const isMp3 = audioExtension.endsWith(".mp3")
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
{isMp3 ? (
|
|
19
|
-
<audio
|
|
19
|
+
<audio
|
|
20
|
+
class="overflow-hidden rounded-2xl border border-gray-200"
|
|
21
|
+
class:list={[className]}
|
|
22
|
+
src={src}
|
|
23
|
+
controls
|
|
24
|
+
/>
|
|
20
25
|
) : (
|
|
21
26
|
<a
|
|
22
27
|
class="group flex w-full items-center gap-4 rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm transition hover:border-gray-300 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-900/70"
|
|
@@ -47,6 +52,11 @@ const isMp3 = audioExtension.endsWith(".mp3")
|
|
|
47
52
|
)}
|
|
48
53
|
|
|
49
54
|
<style>
|
|
55
|
+
audio::-webkit-media-controls-enclosure,
|
|
56
|
+
audio::-webkit-media-controls-panel {
|
|
57
|
+
background-color: #f9fafb;
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
audio::-webkit-media-controls-enclosure {
|
|
51
61
|
border-radius: 0;
|
|
52
62
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { cacheDir } from "astro:config/server"
|
|
3
|
+
|
|
4
|
+
import { getImageTint } from "../../../utils/get-image-tint"
|
|
5
|
+
import { getMediaCover } from "../utils/get-media-cover"
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
mediaId: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { direction } = Astro.locals.i18n
|
|
12
|
+
let tint = "#fafafa"
|
|
13
|
+
try {
|
|
14
|
+
tint = await getImageTint(
|
|
15
|
+
await getMediaCover(Astro.props.mediaId),
|
|
16
|
+
new URL(cacheDir),
|
|
17
|
+
)
|
|
18
|
+
} catch (error) {
|
|
19
|
+
console.warn("[lightnet] Cannot extract details page cover color:", error)
|
|
20
|
+
}
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<div
|
|
24
|
+
class="details-hero"
|
|
25
|
+
style={{
|
|
26
|
+
"--cover-tint": tint,
|
|
27
|
+
"--hero-direction":
|
|
28
|
+
direction === "rtl" ? "to bottom left" : "to bottom right",
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
<slot />
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<style>
|
|
35
|
+
.details-hero {
|
|
36
|
+
/* Extend the same gradient behind the fixed header. */
|
|
37
|
+
margin-block-start: -3.5rem;
|
|
38
|
+
padding-block: calc(2.5rem + 3.5rem) 3rem;
|
|
39
|
+
background:
|
|
40
|
+
linear-gradient(to bottom, transparent 40%, #f9fafb),
|
|
41
|
+
linear-gradient(var(--hero-direction), var(--cover-tint), #f9fafb 85%);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@media (min-width: 640px) {
|
|
45
|
+
.details-hero {
|
|
46
|
+
margin-block-start: -5rem;
|
|
47
|
+
padding-block: calc(4rem + 5rem) 4rem;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
</style>
|
|
@@ -1,30 +1,73 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { getImage } from "astro:assets"
|
|
3
|
+
import config from "virtual:lightnet/config"
|
|
4
|
+
|
|
2
5
|
import { getMediaItem } from "../../../content/get-media-items"
|
|
3
6
|
import Page from "../../../layouts/Page.astro"
|
|
4
7
|
import { markdownToText } from "../../../utils/markdown"
|
|
5
8
|
import { detailsPagePath } from "../../../utils/paths"
|
|
9
|
+
import { getDetailsPageJsonLd } from "../utils/get-details-page-json-ld"
|
|
6
10
|
|
|
7
11
|
type Props = {
|
|
8
12
|
mediaId: string
|
|
9
|
-
/**
|
|
10
|
-
|
|
13
|
+
/** Schema.org type used when the media type does not specify one. */
|
|
14
|
+
fallbackSchemaType?: string
|
|
11
15
|
}
|
|
12
16
|
|
|
13
|
-
const { mediaId: id,
|
|
17
|
+
const { mediaId: id, fallbackSchemaType = "CreativeWork" } = Astro.props
|
|
18
|
+
const { defaultLocale, currentLocale, tContentField, tConfigField } =
|
|
19
|
+
Astro.locals.i18n
|
|
14
20
|
const mediaItem = (await getMediaItem(id)).data
|
|
21
|
+
const description = markdownToText(mediaItem.description)
|
|
22
|
+
const shareUrl = new URL(detailsPagePath(currentLocale, { id }), Astro.site)
|
|
23
|
+
const coverImage = config.disableOpenGraphImages
|
|
24
|
+
? await getImage({ src: mediaItem.image, format: "png" })
|
|
25
|
+
: undefined
|
|
26
|
+
const shareImage = coverImage
|
|
27
|
+
? new URL(coverImage.src, Astro.site)
|
|
28
|
+
: new URL(
|
|
29
|
+
`${detailsPagePath(defaultLocale, { id })}open-graph.png`,
|
|
30
|
+
Astro.site,
|
|
31
|
+
)
|
|
32
|
+
const jsonLd = await getDetailsPageJsonLd({
|
|
33
|
+
mediaId: id,
|
|
34
|
+
fallbackSchemaType,
|
|
35
|
+
site: Astro.site,
|
|
36
|
+
defaultLocale,
|
|
37
|
+
tContentField,
|
|
38
|
+
})
|
|
15
39
|
---
|
|
16
40
|
|
|
17
41
|
<Page
|
|
18
42
|
title={mediaItem.title}
|
|
19
|
-
description={
|
|
20
|
-
canonicalUrl={
|
|
21
|
-
new URL(
|
|
22
|
-
detailsPagePath(Astro.locals.i18n.defaultLocale, { id }),
|
|
23
|
-
Astro.site,
|
|
24
|
-
)
|
|
25
|
-
}
|
|
43
|
+
description={description}
|
|
44
|
+
canonicalUrl={new URL(detailsPagePath(defaultLocale, { id }), Astro.site)}
|
|
26
45
|
>
|
|
27
46
|
<Fragment slot="head">
|
|
47
|
+
<meta property="og:type" content="website" />
|
|
48
|
+
<meta property="og:title" content={mediaItem.title} />
|
|
49
|
+
{description && <meta property="og:description" content={description} />}
|
|
50
|
+
<meta
|
|
51
|
+
property="og:site_name"
|
|
52
|
+
content={tConfigField(config.title, config)}
|
|
53
|
+
/>
|
|
54
|
+
<meta property="og:url" content={shareUrl.href} />
|
|
55
|
+
<meta property="og:image" content={shareImage.href} />
|
|
56
|
+
<meta property="og:image:type" content="image/png" />
|
|
57
|
+
<meta
|
|
58
|
+
property="og:image:width"
|
|
59
|
+
content={String(coverImage ? mediaItem.image.width : 1200)}
|
|
60
|
+
/>
|
|
61
|
+
<meta
|
|
62
|
+
property="og:image:height"
|
|
63
|
+
content={String(coverImage ? mediaItem.image.height : 630)}
|
|
64
|
+
/>
|
|
65
|
+
<meta property="og:image:alt" content={mediaItem.title} />
|
|
66
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
67
|
+
<meta name="twitter:title" content={mediaItem.title} />
|
|
68
|
+
{description && <meta name="twitter:description" content={description} />}
|
|
69
|
+
<meta name="twitter:image" content={shareImage.href} />
|
|
70
|
+
<meta name="twitter:image:alt" content={mediaItem.title} />
|
|
28
71
|
{jsonLd && (
|
|
29
72
|
<script
|
|
30
73
|
type="application/ld+json"
|
|
@@ -13,15 +13,15 @@ export type Props = {
|
|
|
13
13
|
const { mediaId, imageSize } = Astro.props
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
<div class="mx-auto
|
|
16
|
+
<div class="mx-auto flex max-w-screen-md flex-col items-center gap-8 px-4 sm:flex-row sm:gap-14 md:px-8">
|
|
17
17
|
<CoverImage size={imageSize} mediaId={mediaId} />
|
|
18
18
|
<div class="flex w-full grow flex-col items-center sm:items-start">
|
|
19
19
|
<Title className="text-center sm:text-start" mediaId={mediaId} />
|
|
20
20
|
<Authors className="mt-2" mediaId={mediaId} />
|
|
21
|
-
<
|
|
21
|
+
<slot />
|
|
22
|
+
<div class="mt-5 flex gap-6 text-sm text-gray-600">
|
|
22
23
|
<ShareButton />
|
|
23
24
|
<EditButton mediaId={mediaId} />
|
|
24
25
|
</div>
|
|
25
|
-
<slot />
|
|
26
26
|
</div>
|
|
27
27
|
</div>
|
|
@@ -15,7 +15,7 @@ const { mediaId } = Astro.props
|
|
|
15
15
|
const item = await getMediaItem(mediaId)
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
-
<div class="mx-auto max-w-screen-md
|
|
18
|
+
<div class="mx-auto max-w-screen-md px-4 md:px-8">
|
|
19
19
|
<VideoPlayer
|
|
20
20
|
url={item.data.content[0].url!}
|
|
21
21
|
title={item.data.title}
|
|
@@ -25,7 +25,7 @@ const item = await getMediaItem(mediaId)
|
|
|
25
25
|
<div class="mx-auto mt-6 flex max-w-screen-md flex-col items-start px-4 md:mt-10 md:px-8">
|
|
26
26
|
<Title mediaId={mediaId} />
|
|
27
27
|
<Authors mediaId={mediaId} />
|
|
28
|
-
<div class="mt-
|
|
28
|
+
<div class="mt-5 flex gap-6 text-sm text-gray-600">
|
|
29
29
|
<ShareButton />
|
|
30
30
|
<EditButton mediaId={mediaId} />
|
|
31
31
|
</div>
|
|
@@ -27,7 +27,10 @@ const imageSizes = {
|
|
|
27
27
|
}
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
<CoverImageDecorator
|
|
30
|
+
<CoverImageDecorator
|
|
31
|
+
className="shrink-0 shadow-xl shadow-gray-900/10"
|
|
32
|
+
style={type.data.coverImageStyle}
|
|
33
|
+
>
|
|
31
34
|
<Image
|
|
32
35
|
class:list={imageSizes[size].css}
|
|
33
36
|
alt=""
|
|
@@ -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,198 @@
|
|
|
1
|
+
import sharp, { type OverlayOptions } from "sharp"
|
|
2
|
+
|
|
3
|
+
import type { MediaTypeEntry } from "../../../content/schema/media-type"
|
|
4
|
+
import { getImageTint } from "../../../utils/get-image-tint"
|
|
5
|
+
|
|
6
|
+
export const OPEN_GRAPH_WIDTH = 1200
|
|
7
|
+
export const OPEN_GRAPH_HEIGHT = 630
|
|
8
|
+
|
|
9
|
+
// Text is Pango markup. Escape user content before passing it to the renderer.
|
|
10
|
+
function escapeMarkup(text: string) {
|
|
11
|
+
return text.replace(
|
|
12
|
+
/[&<>"']/g,
|
|
13
|
+
(character) =>
|
|
14
|
+
({
|
|
15
|
+
"&": "&",
|
|
16
|
+
"<": "<",
|
|
17
|
+
">": ">",
|
|
18
|
+
'"': """,
|
|
19
|
+
"'": "'",
|
|
20
|
+
})[character]!,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function renderText(
|
|
25
|
+
value: string,
|
|
26
|
+
{
|
|
27
|
+
width,
|
|
28
|
+
height,
|
|
29
|
+
maxSize,
|
|
30
|
+
color,
|
|
31
|
+
bold = false,
|
|
32
|
+
rtl = false,
|
|
33
|
+
}: {
|
|
34
|
+
width: number
|
|
35
|
+
height: number
|
|
36
|
+
maxSize: number
|
|
37
|
+
color: string
|
|
38
|
+
bold?: boolean
|
|
39
|
+
rtl?: boolean
|
|
40
|
+
},
|
|
41
|
+
) {
|
|
42
|
+
// Estimate the occupied area using average glyph width (0.55 em) and
|
|
43
|
+
// line height (1.2 em). Count Unicode code points rather than UTF-16 units.
|
|
44
|
+
const size = Math.max(
|
|
45
|
+
1,
|
|
46
|
+
Math.min(
|
|
47
|
+
maxSize,
|
|
48
|
+
Math.floor(
|
|
49
|
+
Math.sqrt((width * height) / ([...value].length * 0.55 * 1.2)),
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
const render = (fit = false) =>
|
|
54
|
+
sharp({
|
|
55
|
+
text: {
|
|
56
|
+
text: `<span foreground="${color}">${escapeMarkup(value)}</span>`,
|
|
57
|
+
font: `sans ${bold ? "bold " : ""}${size}`,
|
|
58
|
+
width,
|
|
59
|
+
...(fit ? { height } : {}),
|
|
60
|
+
wrap: "word-char",
|
|
61
|
+
align: rtl ? "right" : "left",
|
|
62
|
+
rgba: true,
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
.raw()
|
|
66
|
+
.toBuffer({ resolveWithObject: true })
|
|
67
|
+
const result = await render()
|
|
68
|
+
// Wide glyphs, shaping, and explicit line breaks can exceed the estimate.
|
|
69
|
+
return result.info.height <= height ? result : render(true)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function createOpenGraphImage(
|
|
73
|
+
{
|
|
74
|
+
cover,
|
|
75
|
+
title,
|
|
76
|
+
authors = [],
|
|
77
|
+
direction = "ltr",
|
|
78
|
+
coverImageStyle = "default",
|
|
79
|
+
}: {
|
|
80
|
+
cover: Buffer
|
|
81
|
+
title: string
|
|
82
|
+
authors?: string[] | null
|
|
83
|
+
direction?: "ltr" | "rtl"
|
|
84
|
+
coverImageStyle?: MediaTypeEntry["data"]["coverImageStyle"]
|
|
85
|
+
},
|
|
86
|
+
cacheDir?: URL,
|
|
87
|
+
) {
|
|
88
|
+
const rtl = direction === "rtl"
|
|
89
|
+
const textStart = rtl ? 64 : 560
|
|
90
|
+
const coverImage = await sharp(cover)
|
|
91
|
+
.autoOrient()
|
|
92
|
+
.resize(432, 502, { fit: "inside" })
|
|
93
|
+
.ensureAlpha()
|
|
94
|
+
.raw()
|
|
95
|
+
.toBuffer({ resolveWithObject: true })
|
|
96
|
+
const tint = await getImageTint(cover, cacheDir)
|
|
97
|
+
const { width: coverWidth, height: coverHeight } = coverImage.info
|
|
98
|
+
const cornerRadius = coverImageStyle === "book" ? 2 : 6
|
|
99
|
+
const decorations: OverlayOptions[] = []
|
|
100
|
+
if (coverImageStyle === "book") {
|
|
101
|
+
decorations.push({
|
|
102
|
+
input: Buffer.from(`<svg width="${coverWidth}" height="${coverHeight}">
|
|
103
|
+
<defs>
|
|
104
|
+
<linearGradient id="spine" x1="${rtl ? 1 : 0}" x2="${rtl ? 0 : 1}">
|
|
105
|
+
<stop stop-color="#6b7280" stop-opacity="0.2"/>
|
|
106
|
+
<stop offset="1" stop-color="#6b7280" stop-opacity="0"/>
|
|
107
|
+
</linearGradient>
|
|
108
|
+
</defs>
|
|
109
|
+
<rect x="${coverWidth * (rtl ? 0.97 : 0.01)}" width="${coverWidth * 0.02}" height="100%" fill="url(#spine)"/>
|
|
110
|
+
</svg>`),
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
decorations.push({
|
|
114
|
+
input: Buffer.from(`<svg width="${coverWidth}" height="${coverHeight}">
|
|
115
|
+
<rect width="100%" height="100%" rx="${cornerRadius}" fill="white"/>
|
|
116
|
+
</svg>`),
|
|
117
|
+
blend: "dest-in",
|
|
118
|
+
})
|
|
119
|
+
const decoratedCover = await sharp(coverImage.data, { raw: coverImage.info })
|
|
120
|
+
.composite(decorations)
|
|
121
|
+
.raw()
|
|
122
|
+
.toBuffer({ resolveWithObject: true })
|
|
123
|
+
const coverLeft =
|
|
124
|
+
(rtl ? 704 : 64) + Math.floor((432 - coverImage.info.width) / 2)
|
|
125
|
+
const coverTop = 64 + Math.floor((502 - coverImage.info.height) / 2)
|
|
126
|
+
const layers: OverlayOptions[] = [
|
|
127
|
+
{
|
|
128
|
+
input: decoratedCover.data,
|
|
129
|
+
raw: decoratedCover.info,
|
|
130
|
+
left: coverLeft,
|
|
131
|
+
top: coverTop,
|
|
132
|
+
},
|
|
133
|
+
]
|
|
134
|
+
const textLayers: OverlayOptions[] = []
|
|
135
|
+
const addText = async (
|
|
136
|
+
value: string,
|
|
137
|
+
top: number,
|
|
138
|
+
height: number,
|
|
139
|
+
maxSize: number,
|
|
140
|
+
color: string,
|
|
141
|
+
bold = false,
|
|
142
|
+
) => {
|
|
143
|
+
if (!value.trim()) return 0
|
|
144
|
+
const text = await renderText(value, {
|
|
145
|
+
width: 576,
|
|
146
|
+
height,
|
|
147
|
+
maxSize,
|
|
148
|
+
color,
|
|
149
|
+
bold,
|
|
150
|
+
rtl,
|
|
151
|
+
})
|
|
152
|
+
textLayers.push({
|
|
153
|
+
input: text.data,
|
|
154
|
+
raw: text.info,
|
|
155
|
+
left: textStart + (rtl ? 576 - text.info.width : 0),
|
|
156
|
+
top,
|
|
157
|
+
})
|
|
158
|
+
return text.info.height
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const titleHeight = await addText(title, 0, 280, 64, "#111827", true)
|
|
162
|
+
const authorsHeight = await addText(
|
|
163
|
+
authors?.join(", ") ?? "",
|
|
164
|
+
titleHeight + 24,
|
|
165
|
+
84,
|
|
166
|
+
32,
|
|
167
|
+
"#4b5563",
|
|
168
|
+
)
|
|
169
|
+
const contentHeight = titleHeight + (authorsHeight ? 24 + authorsHeight : 0)
|
|
170
|
+
const textTop = Math.round((OPEN_GRAPH_HEIGHT - contentHeight) / 2)
|
|
171
|
+
layers.push(
|
|
172
|
+
...textLayers.map((layer) => ({
|
|
173
|
+
...layer,
|
|
174
|
+
top: textTop + (layer.top ?? 0),
|
|
175
|
+
})),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return sharp(
|
|
179
|
+
Buffer.from(`<svg width="${OPEN_GRAPH_WIDTH}" height="${OPEN_GRAPH_HEIGHT}">
|
|
180
|
+
<defs>
|
|
181
|
+
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
|
|
182
|
+
<stop stop-color="${rtl ? "#fcfcfd" : tint}"/>
|
|
183
|
+
<stop offset="1" stop-color="${rtl ? tint : "#fcfcfd"}"/>
|
|
184
|
+
</linearGradient>
|
|
185
|
+
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
186
|
+
<feGaussianBlur stdDeviation="16"/>
|
|
187
|
+
</filter>
|
|
188
|
+
</defs>
|
|
189
|
+
<rect width="100%" height="100%" fill="url(#background)"/>
|
|
190
|
+
<rect x="${coverLeft}" y="${coverTop + 12}" width="${coverImage.info.width}" height="${coverImage.info.height}" rx="${cornerRadius}"
|
|
191
|
+
fill="#0f172a" opacity="0.18" filter="url(#shadow)"/>
|
|
192
|
+
</svg>`),
|
|
193
|
+
)
|
|
194
|
+
.flatten({ background: "#fafafa" })
|
|
195
|
+
.composite(layers)
|
|
196
|
+
.png()
|
|
197
|
+
.toBuffer()
|
|
198
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
|
|
4
|
+
import sharp from "sharp"
|
|
5
|
+
|
|
6
|
+
import { createOpenGraphImage } from "./create-open-graph-image"
|
|
7
|
+
|
|
8
|
+
// Bump when changing the renderer, layout, or fonts to invalidate old images.
|
|
9
|
+
const RENDERER_VERSION = 345
|
|
10
|
+
|
|
11
|
+
export async function getCachedOpenGraphImage(
|
|
12
|
+
input: Parameters<typeof createOpenGraphImage>[0],
|
|
13
|
+
cacheDir: URL,
|
|
14
|
+
) {
|
|
15
|
+
const key = createHash("sha256")
|
|
16
|
+
.update(
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
version: RENDERER_VERSION,
|
|
19
|
+
dependencies: sharp.versions,
|
|
20
|
+
title: input.title,
|
|
21
|
+
authors: input.authors ?? [],
|
|
22
|
+
direction: input.direction ?? "ltr",
|
|
23
|
+
coverImageStyle: input.coverImageStyle ?? "default",
|
|
24
|
+
}),
|
|
25
|
+
)
|
|
26
|
+
.update(input.cover)
|
|
27
|
+
.digest("hex")
|
|
28
|
+
const directory = new URL("lightnet/open-graph/", cacheDir)
|
|
29
|
+
const imagePath = new URL(`${key}.png`, directory)
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return await readFile(imagePath)
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
35
|
+
console.warn("[lightnet] Cannot read Open Graph image cache:", error)
|
|
36
|
+
return createOpenGraphImage(input, cacheDir)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const png = await createOpenGraphImage(input, cacheDir)
|
|
41
|
+
const temporaryPath = new URL(`${key}.${randomUUID()}.tmp`, directory)
|
|
42
|
+
try {
|
|
43
|
+
await mkdir(directory, { recursive: true })
|
|
44
|
+
await writeFile(temporaryPath, png, { flag: "wx" })
|
|
45
|
+
await rename(temporaryPath, imagePath)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn("[lightnet] Cannot write Open Graph image cache:", error)
|
|
48
|
+
} finally {
|
|
49
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined)
|
|
50
|
+
}
|
|
51
|
+
return png
|
|
52
|
+
}
|
|
@@ -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,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
|
|
3
|
+
import { getEntry } from "astro:content"
|
|
4
|
+
|
|
5
|
+
export async function getMediaCover(mediaId: string) {
|
|
6
|
+
// Schema validation strips fsPath; optimized assets are not available yet
|
|
7
|
+
// during prerendering. Read the original Astro image metadata instead.
|
|
8
|
+
const entry = await getEntry("media", mediaId)
|
|
9
|
+
const imagePath: unknown = entry && Reflect.get(entry.data.image, "fsPath")
|
|
10
|
+
if (typeof imagePath !== "string") {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`Cannot resolve the cover image for media item "${mediaId}"`,
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
return readFile(imagePath)
|
|
16
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
|
|
4
|
+
import type { ImageMetadata } from "astro"
|
|
5
|
+
import sharp from "sharp"
|
|
6
|
+
|
|
7
|
+
// Bound the cache for long-running dev/SSR servers. Locales share the same tint.
|
|
8
|
+
const tints = new Map<string, Promise<string>>()
|
|
9
|
+
// Bump when changing the extraction or tint formula.
|
|
10
|
+
const TINT_VERSION = 2
|
|
11
|
+
|
|
12
|
+
function getChromaticColor(pixels: Buffer): number[] | undefined {
|
|
13
|
+
// Group similar RGB colors into 16 levels per channel.
|
|
14
|
+
const weights = new Float64Array(4096)
|
|
15
|
+
const reds = new Float64Array(4096)
|
|
16
|
+
const greens = new Float64Array(4096)
|
|
17
|
+
const blues = new Float64Array(4096)
|
|
18
|
+
let visibleWeight = 0
|
|
19
|
+
let colorfulWeight = 0
|
|
20
|
+
let largestBucket = 0
|
|
21
|
+
|
|
22
|
+
for (let offset = 0; offset < pixels.length; offset += 4) {
|
|
23
|
+
const r = pixels[offset]
|
|
24
|
+
const g = pixels[offset + 1]
|
|
25
|
+
const b = pixels[offset + 2]
|
|
26
|
+
const weight = pixels[offset + 3] / 255
|
|
27
|
+
visibleWeight += weight
|
|
28
|
+
|
|
29
|
+
const max = Math.max(r, g, b)
|
|
30
|
+
const min = Math.min(r, g, b)
|
|
31
|
+
if (
|
|
32
|
+
weight === 0 ||
|
|
33
|
+
max < 64 ||
|
|
34
|
+
min > 230 ||
|
|
35
|
+
max - min < 40 ||
|
|
36
|
+
(max - min) / max < 0.15
|
|
37
|
+
) {
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const bucket = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4)
|
|
42
|
+
weights[bucket] += weight
|
|
43
|
+
reds[bucket] += r * weight
|
|
44
|
+
greens[bucket] += g * weight
|
|
45
|
+
blues[bucket] += b * weight
|
|
46
|
+
colorfulWeight += weight
|
|
47
|
+
if (weights[bucket] > weights[largestBucket]) largestBucket = bucket
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Tiny colorful accents should not determine an otherwise neutral image.
|
|
51
|
+
if (colorfulWeight === 0 || colorfulWeight < visibleWeight * 0.05) {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
return [reds[largestBucket], greens[largestBucket], blues[largestBucket]].map(
|
|
55
|
+
(sum) => sum / weights[largestBucket],
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function getImageTint(
|
|
60
|
+
image: Buffer | ImageMetadata,
|
|
61
|
+
cacheDir?: URL,
|
|
62
|
+
): Promise<string> {
|
|
63
|
+
if (!Buffer.isBuffer(image)) {
|
|
64
|
+
// Read the original asset; optimized image URLs may not exist during prerendering.
|
|
65
|
+
const imagePath: unknown = Reflect.get(image, "fsPath")
|
|
66
|
+
if (typeof imagePath !== "string") {
|
|
67
|
+
throw new Error(`Cannot resolve image "${image.src}"`)
|
|
68
|
+
}
|
|
69
|
+
image = await readFile(imagePath)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const key = createHash("sha256")
|
|
73
|
+
.update(
|
|
74
|
+
JSON.stringify({ version: TINT_VERSION, dependencies: sharp.versions }),
|
|
75
|
+
)
|
|
76
|
+
.update(image)
|
|
77
|
+
.digest("hex")
|
|
78
|
+
const directory = cacheDir && new URL("lightnet/image-tints/", cacheDir)
|
|
79
|
+
const tintPath = directory && new URL(`${key}.txt`, directory)
|
|
80
|
+
const memoryKey = `${cacheDir?.href ?? ""}:${key}`
|
|
81
|
+
const cached = tints.get(memoryKey)
|
|
82
|
+
if (cached) return cached
|
|
83
|
+
|
|
84
|
+
const tint = (async () => {
|
|
85
|
+
if (tintPath) {
|
|
86
|
+
try {
|
|
87
|
+
const value = await readFile(tintPath, "utf8")
|
|
88
|
+
const channels = /^rgb\((\d+),(\d+),(\d+)\)$/.exec(value)
|
|
89
|
+
if (
|
|
90
|
+
channels &&
|
|
91
|
+
channels.slice(1).every((channel) => Number(channel) <= 255)
|
|
92
|
+
) {
|
|
93
|
+
return value
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
97
|
+
console.warn("[lightnet] Cannot read image tint cache:", error)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const decoded = await sharp(image)
|
|
102
|
+
.autoOrient()
|
|
103
|
+
.resize(432, 502, { fit: "inside" })
|
|
104
|
+
.toColourspace("srgb")
|
|
105
|
+
.ensureAlpha()
|
|
106
|
+
.raw()
|
|
107
|
+
.toBuffer({ resolveWithObject: true })
|
|
108
|
+
let color = getChromaticColor(decoded.data)
|
|
109
|
+
if (!color) {
|
|
110
|
+
// Materialize flattening before stats(), which reads input pixels directly.
|
|
111
|
+
const opaque = await sharp(decoded.data, { raw: decoded.info })
|
|
112
|
+
.flatten({ background: "#fafafa" })
|
|
113
|
+
.raw()
|
|
114
|
+
.toBuffer({ resolveWithObject: true })
|
|
115
|
+
const { dominant } = await sharp(opaque.data, {
|
|
116
|
+
raw: opaque.info,
|
|
117
|
+
}).stats()
|
|
118
|
+
color = [dominant.r, dominant.g, dominant.b]
|
|
119
|
+
}
|
|
120
|
+
// Keep every channel at least 170 for readable dark text on any image tint.
|
|
121
|
+
const value = `rgb(${color
|
|
122
|
+
.map((channel) => Math.round(250 * 0.68 + channel * 0.32))
|
|
123
|
+
.join(",")})`
|
|
124
|
+
if (directory && tintPath) {
|
|
125
|
+
const temporaryPath = new URL(`${key}.${randomUUID()}.tmp`, directory)
|
|
126
|
+
try {
|
|
127
|
+
await mkdir(directory, { recursive: true })
|
|
128
|
+
await writeFile(temporaryPath, value, { flag: "wx" })
|
|
129
|
+
await rename(temporaryPath, tintPath)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.warn("[lightnet] Cannot write image tint cache:", error)
|
|
132
|
+
} finally {
|
|
133
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return value
|
|
137
|
+
})()
|
|
138
|
+
if (tints.size >= 128) tints.delete(tints.keys().next().value!)
|
|
139
|
+
tints.set(memoryKey, tint)
|
|
140
|
+
void tint.catch(() => {
|
|
141
|
+
if (tints.get(memoryKey) === tint) tints.delete(memoryKey)
|
|
142
|
+
})
|
|
143
|
+
return tint
|
|
144
|
+
}
|