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 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
@@ -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.9.0",
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.24",
63
+ "@tanstack/react-virtual": "^3.14.25",
62
64
  "autoprefixer": "^10.6.2",
63
65
  "embla-carousel": "^8.6.0",
64
66
  "embla-carousel-wheel-gestures": "^8.1.0",
65
67
  "fuse.js": "^7.5.0",
66
- "i18next": "^26.6.0",
67
- "lucide-react": "^1.66.0",
68
- "marked": "^18.1.2",
68
+ "i18next": "^26.7.1",
69
+ "lucide-react": "^1.67.0",
70
+ "marked": "^18.1.3",
69
71
  "postcss": "^8.5.32",
70
72
  "postcss-load-config": "^6.0.1",
71
73
  "yaml": "^2.9.1"
@@ -76,7 +78,7 @@
76
78
  "@types/react": "^19.3.0",
77
79
  "astro": "^6.4.8",
78
80
  "typescript": "^6.0.3",
79
- "vitest": "^5.0.5"
81
+ "vitest": "^5.1.0"
80
82
  },
81
83
  "engines": {
82
84
  "node": ">=22"
@@ -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-200 md:mt-28"
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-primary px-6 py-3 text-sm font-bold text-gray-50 shadow-sm hover:bg-primary/85 hover:text-gray-100"
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 shadow-md sm:h-20">
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} jsonLd={jsonLd}>
28
- <MainDetailsSection imageSize="md" mediaId={mediaId} />
29
- <AudioPanel mediaId={mediaId} className="mt-10" />
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} jsonLd={jsonLd}>
27
- <MainDetailsSection mediaId={mediaId}>
28
- <OpenButton className="mt-8" mediaId={mediaId} />
29
- </MainDetailsSection>
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} jsonLd={jsonLd}>
27
- <VideoDetailsSection mediaId={mediaId} />
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 class="rounded-2xl" class:list={[className]} src={src} controls />
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
- /** Structured data to include in the page head as JSON-LD. */
10
- jsonLd?: Record<string, unknown>
13
+ /** Schema.org type used when the media type does not specify one. */
14
+ fallbackSchemaType?: string
11
15
  }
12
16
 
13
- const { mediaId: id, jsonLd } = Astro.props
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={markdownToText(mediaItem.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 mt-10 flex max-w-screen-md flex-col items-center gap-8 px-4 sm:mt-20 sm:flex-row sm:items-start sm:gap-14 md:px-8">
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
- <div class="mt-4 flex gap-6">
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 md:mt-8 md:px-8">
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-4 flex gap-6">
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 className="shrink-0" style={type.data.coverImageStyle}>
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
- layout,
28
+ fallbackSchemaType = "CreativeWork",
31
29
  schemaType,
32
30
  canonicalUrl,
33
31
  imageUrl,
@@ -35,16 +33,17 @@ export function createMediaItemJsonLd({
35
33
  contentLabels = [],
36
34
  }: {
37
35
  item: MediaItemEntry["data"]
38
- layout: DetailsPageLayout
36
+ fallbackSchemaType?: string
39
37
  schemaType?: MediaTypeEntry["data"]["schemaType"]
40
38
  canonicalUrl: URL
41
39
  imageUrl: string
42
40
  categoryNames?: string[]
43
41
  contentLabels?: (string | undefined)[]
44
42
  }): Record<string, unknown> {
43
+ const resolvedSchemaType = schemaType ?? fallbackSchemaType
45
44
  const base = {
46
45
  "@context": "https://schema.org",
47
- "@type": schemaType ?? "CreativeWork",
46
+ "@type": resolvedSchemaType,
48
47
  "@id": `${canonicalUrl.href}#work`,
49
48
  url: canonicalUrl.href,
50
49
  name: item.title,
@@ -87,7 +86,10 @@ export function createMediaItemJsonLd({
87
86
  })
88
87
 
89
88
  const encodings = media.filter((entry) => entry !== undefined)
90
- if (schemaType === "SoftwareApplication" || schemaType === "WebPage") {
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
- schemaType === "AudioObject" ||
106
- schemaType === "VideoObject" ||
107
- schemaType === "ImageObject"
107
+ resolvedSchemaType === "AudioObject" ||
108
+ resolvedSchemaType === "VideoObject" ||
109
+ resolvedSchemaType === "ImageObject"
108
110
  ) {
109
- const matching = encodings.filter((entry) => entry["@type"] === schemaType)
110
- if (schemaType === "AudioObject" && matching.length > 1) {
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((entry) => entry["@type"] !== schemaType),
118
+ encoding: encodings.filter(
119
+ (entry) => entry["@type"] !== resolvedSchemaType,
120
+ ),
115
121
  }
116
122
  }
117
- const primary = media[0]?.["@type"] === schemaType ? media[0] : undefined
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
- ...(schemaType === "VideoObject" ? { thumbnailUrl: imageUrl } : {}),
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
+ "&": "&amp;",
16
+ "<": "&lt;",
17
+ ">": "&gt;",
18
+ '"': "&quot;",
19
+ "'": "&apos;",
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
- layout,
12
+ fallbackSchemaType,
14
13
  site,
15
14
  defaultLocale,
16
15
  tContentField,
17
16
  }: {
18
17
  mediaId: string
19
- layout: DetailsPageLayout
18
+ fallbackSchemaType: string
20
19
  site: URL | undefined
21
20
  defaultLocale: string
22
21
  tContentField: TranslateContentFieldFn
@@ -36,7 +35,7 @@ export async function getDetailsPageJsonLd({
36
35
 
37
36
  return createMediaItemJsonLd({
38
37
  item: item.data,
39
- layout,
38
+ fallbackSchemaType,
40
39
  schemaType: mediaType.data.schemaType,
41
40
  canonicalUrl,
42
41
  imageUrl: new URL(image.src, site).href,
@@ -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
+ }