lightnet 4.10.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 +16 -0
- package/exports/details-page.ts +1 -0
- package/package.json +1 -1
- package/src/astro-integration/config.ts +5 -0
- package/src/astro-integration/integration.ts +7 -5
- 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 +5 -2
- package/src/pages/details-page/DefaultDetailsPage.astro +6 -3
- package/src/pages/details-page/VideoDetailsPage.astro +4 -1
- package/src/pages/details-page/api/open-graph.ts +17 -23
- 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 +18 -3
- 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-open-graph-image.ts +49 -37
- package/src/pages/details-page/utils/get-cached-open-graph-image.ts +52 -0
- 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,21 @@
|
|
|
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
|
+
|
|
3
19
|
## 4.10.0
|
|
4
20
|
|
|
5
21
|
### 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
|
@@ -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,11 +71,13 @@ export function lightnet(lightnetConfig: LightnetConfig): AstroIntegration {
|
|
|
71
71
|
prerender: true,
|
|
72
72
|
})
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
}
|
|
79
81
|
|
|
80
82
|
injectRoute({
|
|
81
83
|
pattern: "/api/internal/search.json",
|
|
@@ -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,6 +2,7 @@
|
|
|
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"
|
|
@@ -15,8 +16,10 @@ const { mediaId } = Astro.props
|
|
|
15
16
|
---
|
|
16
17
|
|
|
17
18
|
<DetailsPage mediaId={mediaId} fallbackSchemaType="AudioObject">
|
|
18
|
-
<
|
|
19
|
-
|
|
19
|
+
<DetailsHero mediaId={mediaId}>
|
|
20
|
+
<MainDetailsSection imageSize="md" mediaId={mediaId} />
|
|
21
|
+
<AudioPanel mediaId={mediaId} className="mt-10" />
|
|
22
|
+
</DetailsHero>
|
|
20
23
|
<DescriptionSection mediaId={mediaId} />
|
|
21
24
|
<ContentSection mediaId={mediaId} minimumItems={1} />
|
|
22
25
|
<MediaCollectionsSection mediaId={mediaId} />
|
|
@@ -1,6 +1,7 @@
|
|
|
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"
|
|
@@ -14,9 +15,11 @@ const { mediaId } = Astro.props
|
|
|
14
15
|
---
|
|
15
16
|
|
|
16
17
|
<DetailsPage mediaId={mediaId}>
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
|
|
18
|
+
<DetailsHero mediaId={mediaId}>
|
|
19
|
+
<MainDetailsSection mediaId={mediaId}>
|
|
20
|
+
<OpenButton className="mt-6" mediaId={mediaId} />
|
|
21
|
+
</MainDetailsSection>
|
|
22
|
+
</DetailsHero>
|
|
20
23
|
<DescriptionSection mediaId={mediaId} />
|
|
21
24
|
<ContentSection mediaId={mediaId} />
|
|
22
25
|
<MediaCollectionsSection mediaId={mediaId} />
|
|
@@ -1,6 +1,7 @@
|
|
|
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"
|
|
@@ -14,7 +15,9 @@ const { mediaId } = Astro.props
|
|
|
14
15
|
---
|
|
15
16
|
|
|
16
17
|
<DetailsPage mediaId={mediaId} fallbackSchemaType="VideoObject">
|
|
17
|
-
<
|
|
18
|
+
<DetailsHero mediaId={mediaId}>
|
|
19
|
+
<VideoDetailsSection mediaId={mediaId} />
|
|
20
|
+
</DetailsHero>
|
|
18
21
|
<DescriptionSection mediaId={mediaId} />
|
|
19
22
|
<ContentSection mediaId={mediaId} />
|
|
20
23
|
<MediaCollectionsSection mediaId={mediaId} />
|
|
@@ -1,40 +1,34 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises"
|
|
2
|
-
|
|
3
1
|
import type { APIRoute, GetStaticPaths } from "astro"
|
|
4
|
-
import {
|
|
2
|
+
import { cacheDir } from "astro:config/server"
|
|
3
|
+
import { getCollection } from "astro:content"
|
|
5
4
|
import config from "virtual:lightnet/config"
|
|
6
5
|
|
|
7
6
|
import { getMediaItem } from "../../../content/get-media-items"
|
|
8
7
|
import { getMediaType } from "../../../content/get-media-types"
|
|
9
|
-
import {
|
|
8
|
+
import { getCachedOpenGraphImage } from "../utils/get-cached-open-graph-image"
|
|
9
|
+
import { getMediaCover } from "../utils/get-media-cover"
|
|
10
10
|
|
|
11
11
|
export const getStaticPaths: GetStaticPaths = async () => {
|
|
12
12
|
const items = await getCollection("media")
|
|
13
|
-
return
|
|
14
|
-
|
|
15
|
-
)
|
|
13
|
+
return items.map(({ id: mediaId }) => ({
|
|
14
|
+
params: { locale: config.defaultLocale, mediaId },
|
|
15
|
+
}))
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export const GET: APIRoute = async ({ params, locals }) => {
|
|
19
19
|
const item = await getMediaItem(params.mediaId!)
|
|
20
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
21
|
const { direction } = locals.i18n
|
|
31
|
-
const png = await
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
)
|
|
38
32
|
return new Response(new Uint8Array(png), {
|
|
39
33
|
headers: { "Content-Type": "image/png" },
|
|
40
34
|
})
|
|
@@ -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,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { getImage } from "astro:assets"
|
|
2
3
|
import config from "virtual:lightnet/config"
|
|
3
4
|
|
|
4
5
|
import { getMediaItem } from "../../../content/get-media-items"
|
|
@@ -19,7 +20,15 @@ const { defaultLocale, currentLocale, tContentField, tConfigField } =
|
|
|
19
20
|
const mediaItem = (await getMediaItem(id)).data
|
|
20
21
|
const description = markdownToText(mediaItem.description)
|
|
21
22
|
const shareUrl = new URL(detailsPagePath(currentLocale, { id }), Astro.site)
|
|
22
|
-
const
|
|
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
|
+
)
|
|
23
32
|
const jsonLd = await getDetailsPageJsonLd({
|
|
24
33
|
mediaId: id,
|
|
25
34
|
fallbackSchemaType,
|
|
@@ -45,8 +54,14 @@ const jsonLd = await getDetailsPageJsonLd({
|
|
|
45
54
|
<meta property="og:url" content={shareUrl.href} />
|
|
46
55
|
<meta property="og:image" content={shareImage.href} />
|
|
47
56
|
<meta property="og:image:type" content="image/png" />
|
|
48
|
-
<meta
|
|
49
|
-
|
|
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
|
+
/>
|
|
50
65
|
<meta property="og:image:alt" content={mediaItem.title} />
|
|
51
66
|
<meta name="twitter:card" content="summary_large_image" />
|
|
52
67
|
<meta name="twitter:title" content={mediaItem.title} />
|
|
@@ -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=""
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import sharp, { type OverlayOptions } from "sharp"
|
|
2
2
|
|
|
3
3
|
import type { MediaTypeEntry } from "../../../content/schema/media-type"
|
|
4
|
+
import { getImageTint } from "../../../utils/get-image-tint"
|
|
4
5
|
|
|
5
6
|
export const OPEN_GRAPH_WIDTH = 1200
|
|
6
7
|
export const OPEN_GRAPH_HEIGHT = 630
|
|
@@ -25,61 +26,74 @@ async function renderText(
|
|
|
25
26
|
{
|
|
26
27
|
width,
|
|
27
28
|
height,
|
|
28
|
-
|
|
29
|
+
maxSize,
|
|
29
30
|
color,
|
|
30
31
|
bold = false,
|
|
31
32
|
rtl = false,
|
|
32
33
|
}: {
|
|
33
34
|
width: number
|
|
34
35
|
height: number
|
|
35
|
-
|
|
36
|
+
maxSize: number
|
|
36
37
|
color: string
|
|
37
38
|
bold?: boolean
|
|
38
39
|
rtl?: boolean
|
|
39
40
|
},
|
|
40
41
|
) {
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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({
|
|
46
55
|
text: {
|
|
47
56
|
text: `<span foreground="${color}">${escapeMarkup(value)}</span>`,
|
|
48
|
-
font: `sans ${bold ? "bold " : ""}${
|
|
57
|
+
font: `sans ${bold ? "bold " : ""}${size}`,
|
|
49
58
|
width,
|
|
59
|
+
...(fit ? { height } : {}),
|
|
50
60
|
wrap: "word-char",
|
|
51
61
|
align: rtl ? "right" : "left",
|
|
52
62
|
rgba: true,
|
|
53
63
|
},
|
|
54
64
|
})
|
|
55
|
-
.
|
|
65
|
+
.raw()
|
|
56
66
|
.toBuffer({ resolveWithObject: true })
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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)
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
export async function createOpenGraphImage(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
) {
|
|
75
88
|
const rtl = direction === "rtl"
|
|
76
89
|
const textStart = rtl ? 64 : 560
|
|
77
90
|
const coverImage = await sharp(cover)
|
|
78
91
|
.autoOrient()
|
|
79
92
|
.resize(432, 502, { fit: "inside" })
|
|
80
|
-
.
|
|
93
|
+
.ensureAlpha()
|
|
94
|
+
.raw()
|
|
81
95
|
.toBuffer({ resolveWithObject: true })
|
|
82
|
-
const
|
|
96
|
+
const tint = await getImageTint(cover, cacheDir)
|
|
83
97
|
const { width: coverWidth, height: coverHeight } = coverImage.info
|
|
84
98
|
const cornerRadius = coverImageStyle === "book" ? 2 : 6
|
|
85
99
|
const decorations: OverlayOptions[] = []
|
|
@@ -102,20 +116,17 @@ export async function createOpenGraphImage({
|
|
|
102
116
|
</svg>`),
|
|
103
117
|
blend: "dest-in",
|
|
104
118
|
})
|
|
105
|
-
const decoratedCover = await sharp(coverImage.data)
|
|
106
|
-
.ensureAlpha()
|
|
119
|
+
const decoratedCover = await sharp(coverImage.data, { raw: coverImage.info })
|
|
107
120
|
.composite(decorations)
|
|
108
|
-
.
|
|
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(",")})`
|
|
121
|
+
.raw()
|
|
122
|
+
.toBuffer({ resolveWithObject: true })
|
|
113
123
|
const coverLeft =
|
|
114
124
|
(rtl ? 704 : 64) + Math.floor((432 - coverImage.info.width) / 2)
|
|
115
125
|
const coverTop = 64 + Math.floor((502 - coverImage.info.height) / 2)
|
|
116
126
|
const layers: OverlayOptions[] = [
|
|
117
127
|
{
|
|
118
|
-
input: decoratedCover,
|
|
128
|
+
input: decoratedCover.data,
|
|
129
|
+
raw: decoratedCover.info,
|
|
119
130
|
left: coverLeft,
|
|
120
131
|
top: coverTop,
|
|
121
132
|
},
|
|
@@ -125,7 +136,7 @@ export async function createOpenGraphImage({
|
|
|
125
136
|
value: string,
|
|
126
137
|
top: number,
|
|
127
138
|
height: number,
|
|
128
|
-
|
|
139
|
+
maxSize: number,
|
|
129
140
|
color: string,
|
|
130
141
|
bold = false,
|
|
131
142
|
) => {
|
|
@@ -133,13 +144,14 @@ export async function createOpenGraphImage({
|
|
|
133
144
|
const text = await renderText(value, {
|
|
134
145
|
width: 576,
|
|
135
146
|
height,
|
|
136
|
-
|
|
147
|
+
maxSize,
|
|
137
148
|
color,
|
|
138
149
|
bold,
|
|
139
150
|
rtl,
|
|
140
151
|
})
|
|
141
152
|
textLayers.push({
|
|
142
153
|
input: text.data,
|
|
154
|
+
raw: text.info,
|
|
143
155
|
left: textStart + (rtl ? 576 - text.info.width : 0),
|
|
144
156
|
top,
|
|
145
157
|
})
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|