feat: update SEO metadata and enhance Open Graph support across the web application. Add sitemap.xml for improved search engine indexing, introduce a script for compressing the Open Graph image, and refine user session handling in hooks. Update package.json for new compression script and bump Bun version.
Browse files- apps/web/index.html +16 -9
- apps/web/public/og_image.png +2 -2
- apps/web/public/sitemap.xml +28 -0
- apps/web/src/hooks/use-generation-jobs.ts +2 -2
- apps/web/src/lib/site-seo.ts +44 -0
- apps/web/src/routes/__root.tsx +14 -17
- apps/web/src/routes/index.tsx +8 -11
- apps/web/src/utils/trpc.ts +8 -1
- package.json +2 -1
- scripts/compress-og-image.ts +58 -0
apps/web/index.html
CHANGED
|
@@ -3,19 +3,26 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<meta name="description" content="
|
| 7 |
<meta name="theme-color" content="#0c0c0c" />
|
| 8 |
-
<meta property="og:title" content="Labas — AI Exam Practice" />
|
| 9 |
-
<meta property="og:description" content="AI-powered
|
| 10 |
-
<meta property="og:url" content="https://labas.rogasper.com" />
|
| 11 |
-
<meta property="og:image" content="https://labas.rogasper.com/og_image.png" />
|
| 12 |
<meta property="og:type" content="website" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
<meta name="twitter:card" content="summary_large_image" />
|
| 14 |
-
<meta name="twitter:title" content="Labas — AI Exam Practice" />
|
| 15 |
-
<meta name="twitter:description" content="AI-powered
|
| 16 |
<meta name="twitter:image" content="https://labas.rogasper.com/og_image.png" />
|
| 17 |
-
<
|
| 18 |
-
<
|
|
|
|
|
|
|
| 19 |
</head>
|
| 20 |
|
| 21 |
<body>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<meta name="description" content="Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." />
|
| 7 |
<meta name="theme-color" content="#0c0c0c" />
|
| 8 |
+
<meta property="og:title" content="Labas — AI-Powered Exam Practice Platform" />
|
| 9 |
+
<meta property="og:description" content="Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." />
|
| 10 |
+
<meta property="og:url" content="https://labas.rogasper.com/" />
|
|
|
|
| 11 |
<meta property="og:type" content="website" />
|
| 12 |
+
<meta property="og:image" content="https://labas.rogasper.com/og_image.png" />
|
| 13 |
+
<meta property="og:image:secure_url" content="https://labas.rogasper.com/og_image.png" />
|
| 14 |
+
<meta property="og:image:type" content="image/png" />
|
| 15 |
+
<meta property="og:image:width" content="1200" />
|
| 16 |
+
<meta property="og:image:height" content="630" />
|
| 17 |
+
<meta property="og:image:alt" content="Labas — platform latihan ujian bahasa dengan AI Generator dan simulasi tes" />
|
| 18 |
<meta name="twitter:card" content="summary_large_image" />
|
| 19 |
+
<meta name="twitter:title" content="Labas — AI-Powered Exam Practice Platform" />
|
| 20 |
+
<meta name="twitter:description" content="Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." />
|
| 21 |
<meta name="twitter:image" content="https://labas.rogasper.com/og_image.png" />
|
| 22 |
+
<meta name="twitter:image:alt" content="Labas — platform latihan ujian bahasa dengan AI Generator dan simulasi tes" />
|
| 23 |
+
<meta name="robots" content="index, follow" />
|
| 24 |
+
<link rel="canonical" href="https://labas.rogasper.com/" />
|
| 25 |
+
<title>Labas — AI-Powered Exam Practice Platform</title>
|
| 26 |
</head>
|
| 27 |
|
| 28 |
<body>
|
apps/web/public/og_image.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
apps/web/public/sitemap.xml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
| 3 |
+
<url>
|
| 4 |
+
<loc>https://labas.rogasper.com/</loc>
|
| 5 |
+
<changefreq>weekly</changefreq>
|
| 6 |
+
<priority>1.0</priority>
|
| 7 |
+
</url>
|
| 8 |
+
<url>
|
| 9 |
+
<loc>https://labas.rogasper.com/login</loc>
|
| 10 |
+
<changefreq>monthly</changefreq>
|
| 11 |
+
<priority>0.5</priority>
|
| 12 |
+
</url>
|
| 13 |
+
<url>
|
| 14 |
+
<loc>https://labas.rogasper.com/bank</loc>
|
| 15 |
+
<changefreq>weekly</changefreq>
|
| 16 |
+
<priority>0.8</priority>
|
| 17 |
+
</url>
|
| 18 |
+
<url>
|
| 19 |
+
<loc>https://labas.rogasper.com/packages</loc>
|
| 20 |
+
<changefreq>weekly</changefreq>
|
| 21 |
+
<priority>0.8</priority>
|
| 22 |
+
</url>
|
| 23 |
+
<url>
|
| 24 |
+
<loc>https://labas.rogasper.com/leaderboard</loc>
|
| 25 |
+
<changefreq>daily</changefreq>
|
| 26 |
+
<priority>0.6</priority>
|
| 27 |
+
</url>
|
| 28 |
+
</urlset>
|
apps/web/src/hooks/use-generation-jobs.ts
CHANGED
|
@@ -10,8 +10,8 @@ import type { GenerationResult } from "@labas/ai";
|
|
| 10 |
export type { ActiveJob, CompletedResult } from "./use-job-shared";
|
| 11 |
|
| 12 |
export function useGenerationJobs() {
|
| 13 |
-
const { data: session } = authClient.useSession();
|
| 14 |
-
const isAuthenticated =
|
| 15 |
|
| 16 |
const {
|
| 17 |
jobIds,
|
|
|
|
| 10 |
export type { ActiveJob, CompletedResult } from "./use-job-shared";
|
| 11 |
|
| 12 |
export function useGenerationJobs() {
|
| 13 |
+
const { data: session, isPending: isSessionPending } = authClient.useSession();
|
| 14 |
+
const isAuthenticated = Boolean(session?.user?.id) && !isSessionPending;
|
| 15 |
|
| 16 |
const {
|
| 17 |
jobIds,
|
apps/web/src/lib/site-seo.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const SITE_URL = "https://labas.rogasper.com";
|
| 2 |
+
|
| 3 |
+
export const OG_IMAGE_PATH = "/og_image.png";
|
| 4 |
+
export const OG_IMAGE_URL = `${SITE_URL}${OG_IMAGE_PATH}`;
|
| 5 |
+
export const OG_IMAGE_WIDTH = 1200;
|
| 6 |
+
export const OG_IMAGE_HEIGHT = 630;
|
| 7 |
+
export const OG_IMAGE_ALT =
|
| 8 |
+
"Labas — platform latihan ujian bahasa dengan AI Generator dan simulasi tes";
|
| 9 |
+
|
| 10 |
+
export const DEFAULT_SITE_TITLE = "Labas — AI-Powered Exam Practice Platform";
|
| 11 |
+
export const DEFAULT_SITE_DESCRIPTION =
|
| 12 |
+
"Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation.";
|
| 13 |
+
|
| 14 |
+
type SiteHeadInput = {
|
| 15 |
+
title?: string;
|
| 16 |
+
description?: string;
|
| 17 |
+
url?: string;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
/** Shared Open Graph + Twitter tags for TanStack Router `head()`. */
|
| 21 |
+
export function buildSocialMeta(input: SiteHeadInput = {}) {
|
| 22 |
+
const title = input.title ?? DEFAULT_SITE_TITLE;
|
| 23 |
+
const description = input.description ?? DEFAULT_SITE_DESCRIPTION;
|
| 24 |
+
const url = input.url ?? `${SITE_URL}/`;
|
| 25 |
+
|
| 26 |
+
return [
|
| 27 |
+
{ name: "description", content: description },
|
| 28 |
+
{ property: "og:title", content: title },
|
| 29 |
+
{ property: "og:description", content: description },
|
| 30 |
+
{ property: "og:url", content: url },
|
| 31 |
+
{ property: "og:type", content: "website" },
|
| 32 |
+
{ property: "og:image", content: OG_IMAGE_URL },
|
| 33 |
+
{ property: "og:image:secure_url", content: OG_IMAGE_URL },
|
| 34 |
+
{ property: "og:image:type", content: "image/png" },
|
| 35 |
+
{ property: "og:image:width", content: String(OG_IMAGE_WIDTH) },
|
| 36 |
+
{ property: "og:image:height", content: String(OG_IMAGE_HEIGHT) },
|
| 37 |
+
{ property: "og:image:alt", content: OG_IMAGE_ALT },
|
| 38 |
+
{ name: "twitter:card", content: "summary_large_image" },
|
| 39 |
+
{ name: "twitter:title", content: title },
|
| 40 |
+
{ name: "twitter:description", content: description },
|
| 41 |
+
{ name: "twitter:image", content: OG_IMAGE_URL },
|
| 42 |
+
{ name: "twitter:image:alt", content: OG_IMAGE_ALT },
|
| 43 |
+
];
|
| 44 |
+
}
|
apps/web/src/routes/__root.tsx
CHANGED
|
@@ -10,6 +10,12 @@ import { useSidebar } from "@/hooks/use-sidebar";
|
|
| 10 |
import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
|
| 11 |
import { ErrorFallback } from "@/components/ErrorFallback";
|
| 12 |
import type { RouteShell } from "@/lib/route-shell";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
import type { trpc } from "@/utils/trpc";
|
| 14 |
|
| 15 |
function SkipLink() {
|
|
@@ -42,25 +48,16 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
|
|
| 42 |
component: RootComponent,
|
| 43 |
errorComponent: ({ error, reset }) => <ErrorFallback error={error} reset={reset} />,
|
| 44 |
head: () => {
|
| 45 |
-
const siteUrl = "https://labas.rogasper.com";
|
| 46 |
return {
|
| 47 |
meta: [
|
| 48 |
-
{ title:
|
| 49 |
-
{ name: "description", content:
|
| 50 |
-
|
| 51 |
-
{ property: "og:description", content: "AI-powered multi-language test practice platform" },
|
| 52 |
-
{ property: "og:url", content: siteUrl },
|
| 53 |
-
{ property: "og:image", content: `${siteUrl}/og_image.png` },
|
| 54 |
-
{ property: "og:type", content: "website" },
|
| 55 |
-
{ name: "twitter:card", content: "summary_large_image" },
|
| 56 |
-
{ name: "twitter:title", content: "Labas — AI Exam Practice" },
|
| 57 |
-
{ name: "twitter:description", content: "AI-powered multi-language test practice platform" },
|
| 58 |
-
{ name: "twitter:image", content: `${siteUrl}/og_image.png` },
|
| 59 |
{ name: "robots", content: "index, follow" },
|
| 60 |
],
|
| 61 |
links: [
|
| 62 |
{ rel: "icon", href: "/labas_icon.png" },
|
| 63 |
-
{ rel: "canonical", href:
|
| 64 |
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
| 65 |
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" as const },
|
| 66 |
{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap" },
|
|
@@ -75,11 +72,11 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
|
|
| 75 |
"@context": "https://schema.org",
|
| 76 |
"@type": "WebSite",
|
| 77 |
name: "Labas",
|
| 78 |
-
url:
|
| 79 |
-
description:
|
| 80 |
potentialAction: {
|
| 81 |
"@type": "SearchAction",
|
| 82 |
-
target: `${
|
| 83 |
"query-input": "required name=search_term_string",
|
| 84 |
},
|
| 85 |
}),
|
|
@@ -123,7 +120,7 @@ function RootComponent() {
|
|
| 123 |
</main>
|
| 124 |
</div>
|
| 125 |
)}
|
| 126 |
-
<GlobalGenerationProgress />
|
| 127 |
<Toaster richColors />
|
| 128 |
</ThemeProvider>
|
| 129 |
{import.meta.env.DEV && (
|
|
|
|
| 10 |
import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
|
| 11 |
import { ErrorFallback } from "@/components/ErrorFallback";
|
| 12 |
import type { RouteShell } from "@/lib/route-shell";
|
| 13 |
+
import {
|
| 14 |
+
buildSocialMeta,
|
| 15 |
+
DEFAULT_SITE_DESCRIPTION,
|
| 16 |
+
DEFAULT_SITE_TITLE,
|
| 17 |
+
SITE_URL,
|
| 18 |
+
} from "@/lib/site-seo";
|
| 19 |
import type { trpc } from "@/utils/trpc";
|
| 20 |
|
| 21 |
function SkipLink() {
|
|
|
|
| 48 |
component: RootComponent,
|
| 49 |
errorComponent: ({ error, reset }) => <ErrorFallback error={error} reset={reset} />,
|
| 50 |
head: () => {
|
|
|
|
| 51 |
return {
|
| 52 |
meta: [
|
| 53 |
+
{ title: DEFAULT_SITE_TITLE },
|
| 54 |
+
{ name: "description", content: DEFAULT_SITE_DESCRIPTION },
|
| 55 |
+
...buildSocialMeta(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
{ name: "robots", content: "index, follow" },
|
| 57 |
],
|
| 58 |
links: [
|
| 59 |
{ rel: "icon", href: "/labas_icon.png" },
|
| 60 |
+
{ rel: "canonical", href: `${SITE_URL}/` },
|
| 61 |
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
| 62 |
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" as const },
|
| 63 |
{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap" },
|
|
|
|
| 72 |
"@context": "https://schema.org",
|
| 73 |
"@type": "WebSite",
|
| 74 |
name: "Labas",
|
| 75 |
+
url: SITE_URL,
|
| 76 |
+
description: DEFAULT_SITE_DESCRIPTION,
|
| 77 |
potentialAction: {
|
| 78 |
"@type": "SearchAction",
|
| 79 |
+
target: `${SITE_URL}/bank?search={search_term_string}`,
|
| 80 |
"query-input": "required name=search_term_string",
|
| 81 |
},
|
| 82 |
}),
|
|
|
|
| 120 |
</main>
|
| 121 |
</div>
|
| 122 |
)}
|
| 123 |
+
{shell !== "public" && <GlobalGenerationProgress />}
|
| 124 |
<Toaster richColors />
|
| 125 |
</ThemeProvider>
|
| 126 |
{import.meta.env.DEV && (
|
apps/web/src/routes/index.tsx
CHANGED
|
@@ -1,29 +1,26 @@
|
|
| 1 |
-
import { createFileRoute, redirect } from "@tanstack/react-router";
|
| 2 |
import { authClient } from "@/lib/auth-client";
|
| 3 |
import { routeShell } from "@/lib/route-shell";
|
|
|
|
| 4 |
|
| 5 |
export const Route = createFileRoute("/")({
|
| 6 |
staticData: routeShell.public,
|
| 7 |
head: () => ({
|
| 8 |
meta: [
|
| 9 |
-
{ title:
|
| 10 |
-
{
|
| 11 |
-
{ property: "og:title", content: "Labas — AI-Powered Exam Practice Platform" },
|
| 12 |
-
{ property: "og:description", content: "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." },
|
| 13 |
-
{ property: "og:url", content: "https://labas.rogasper.com/" },
|
| 14 |
-
],
|
| 15 |
-
links: [
|
| 16 |
-
{ rel: "canonical", href: "https://labas.rogasper.com/" },
|
| 17 |
],
|
|
|
|
| 18 |
}),
|
| 19 |
beforeLoad: async () => {
|
| 20 |
try {
|
| 21 |
const session = await authClient.getSession();
|
| 22 |
-
if (session
|
| 23 |
throw redirect({ to: "/dashboard", replace: true });
|
| 24 |
}
|
| 25 |
} catch (e) {
|
| 26 |
-
if (e
|
|
|
|
| 27 |
}
|
| 28 |
},
|
| 29 |
});
|
|
|
|
| 1 |
+
import { createFileRoute, isRedirect, redirect } from "@tanstack/react-router";
|
| 2 |
import { authClient } from "@/lib/auth-client";
|
| 3 |
import { routeShell } from "@/lib/route-shell";
|
| 4 |
+
import { buildSocialMeta, DEFAULT_SITE_TITLE, SITE_URL } from "@/lib/site-seo";
|
| 5 |
|
| 6 |
export const Route = createFileRoute("/")({
|
| 7 |
staticData: routeShell.public,
|
| 8 |
head: () => ({
|
| 9 |
meta: [
|
| 10 |
+
{ title: DEFAULT_SITE_TITLE },
|
| 11 |
+
...buildSocialMeta({ url: `${SITE_URL}/` }),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
],
|
| 13 |
+
links: [{ rel: "canonical", href: `${SITE_URL}/` }],
|
| 14 |
}),
|
| 15 |
beforeLoad: async () => {
|
| 16 |
try {
|
| 17 |
const session = await authClient.getSession();
|
| 18 |
+
if (session.data?.user) {
|
| 19 |
throw redirect({ to: "/dashboard", replace: true });
|
| 20 |
}
|
| 21 |
} catch (e) {
|
| 22 |
+
if (isRedirect(e)) throw e;
|
| 23 |
+
// Offline / auth endpoint unavailable — still show public landing.
|
| 24 |
}
|
| 25 |
},
|
| 26 |
});
|
apps/web/src/utils/trpc.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
| 1 |
import type { AppRouter } from "@labas/api/routers/index";
|
| 2 |
import { env } from "@labas/env/web";
|
| 3 |
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
| 4 |
-
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
| 5 |
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
| 6 |
import { toast } from "sonner";
|
| 7 |
|
| 8 |
export const queryClient = new QueryClient({
|
| 9 |
queryCache: new QueryCache({
|
| 10 |
onError: (error, query) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
toast.error(error.message, {
|
| 12 |
action: {
|
| 13 |
label: "retry",
|
|
|
|
| 1 |
import type { AppRouter } from "@labas/api/routers/index";
|
| 2 |
import { env } from "@labas/env/web";
|
| 3 |
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
| 4 |
+
import { createTRPCClient, httpBatchLink, TRPCClientError } from "@trpc/client";
|
| 5 |
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
| 6 |
import { toast } from "sonner";
|
| 7 |
|
| 8 |
export const queryClient = new QueryClient({
|
| 9 |
queryCache: new QueryCache({
|
| 10 |
onError: (error, query) => {
|
| 11 |
+
if (
|
| 12 |
+
error instanceof TRPCClientError &&
|
| 13 |
+
(error.data?.code === "UNAUTHORIZED" ||
|
| 14 |
+
error.message.toLowerCase().includes("authentication required"))
|
| 15 |
+
) {
|
| 16 |
+
return;
|
| 17 |
+
}
|
| 18 |
toast.error(error.message, {
|
| 19 |
action: {
|
| 20 |
label: "retry",
|
package.json
CHANGED
|
@@ -26,6 +26,7 @@
|
|
| 26 |
"build": "turbo build",
|
| 27 |
"test": "turbo test",
|
| 28 |
"check-types": "turbo check-types",
|
|
|
|
| 29 |
"dev:web": "turbo -F web dev",
|
| 30 |
"dev:server": "turbo -F server dev",
|
| 31 |
"db:push": "turbo -F @labas/db db:push",
|
|
@@ -50,6 +51,6 @@
|
|
| 50 |
"turbo": "^2.8.12",
|
| 51 |
"typescript": "catalog:"
|
| 52 |
},
|
| 53 |
-
"packageManager": "bun@1.3.
|
| 54 |
"license": "AGPL-3.0"
|
| 55 |
}
|
|
|
|
| 26 |
"build": "turbo build",
|
| 27 |
"test": "turbo test",
|
| 28 |
"check-types": "turbo check-types",
|
| 29 |
+
"compress:og": "bun scripts/compress-og-image.ts",
|
| 30 |
"dev:web": "turbo -F web dev",
|
| 31 |
"dev:server": "turbo -F server dev",
|
| 32 |
"db:push": "turbo -F @labas/db db:push",
|
|
|
|
| 51 |
"turbo": "^2.8.12",
|
| 52 |
"typescript": "catalog:"
|
| 53 |
},
|
| 54 |
+
"packageManager": "bun@1.3.14",
|
| 55 |
"license": "AGPL-3.0"
|
| 56 |
}
|
scripts/compress-og-image.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Compress apps/web/public/og_image.png for social previews.
|
| 3 |
+
* Requires Bun >= 1.3.14 (Bun.Image API).
|
| 4 |
+
*
|
| 5 |
+
* Usage: bun scripts/compress-og-image.ts
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { join } from "node:path";
|
| 9 |
+
|
| 10 |
+
const ROOT = join(import.meta.dir, "..");
|
| 11 |
+
const TARGET = join(ROOT, "apps/web/public/og_image.png");
|
| 12 |
+
const WIDTH = 1200;
|
| 13 |
+
const HEIGHT = 630;
|
| 14 |
+
|
| 15 |
+
function requireBunImage() {
|
| 16 |
+
const file = Bun.file(TARGET);
|
| 17 |
+
if (typeof file.image !== "function") {
|
| 18 |
+
console.error(
|
| 19 |
+
"Bun.Image is not available. Upgrade Bun to >= 1.3.14:\n" +
|
| 20 |
+
" bun upgrade\n" +
|
| 21 |
+
" # or: curl -fsSL https://bun.com/install | bash",
|
| 22 |
+
);
|
| 23 |
+
process.exit(1);
|
| 24 |
+
}
|
| 25 |
+
return file.image();
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function formatKb(bytes: number) {
|
| 29 |
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
async function main() {
|
| 33 |
+
const before = (await Bun.file(TARGET).arrayBuffer()).byteLength;
|
| 34 |
+
console.log(`Source: ${TARGET}`);
|
| 35 |
+
console.log(`Before: ${formatKb(before)} (${WIDTH}×${HEIGHT} target)`);
|
| 36 |
+
|
| 37 |
+
const pipeline = requireBunImage()
|
| 38 |
+
.resize(WIDTH, HEIGHT, { fit: "fill", filter: "lanczos3" })
|
| 39 |
+
.png({
|
| 40 |
+
compressionLevel: 9,
|
| 41 |
+
palette: true,
|
| 42 |
+
colors: 256,
|
| 43 |
+
dither: true,
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
await pipeline.write(TARGET);
|
| 47 |
+
|
| 48 |
+
const after = (await Bun.file(TARGET).arrayBuffer()).byteLength;
|
| 49 |
+
const meta = await Bun.file(TARGET).image().metadata();
|
| 50 |
+
|
| 51 |
+
console.log(`After: ${formatKb(after)} (${meta.width}×${meta.height}, ${meta.format})`);
|
| 52 |
+
console.log(`Saved: ${formatKb(before - after)} (${(((before - after) / before) * 100).toFixed(1)}%)`);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
main().catch((err) => {
|
| 56 |
+
console.error(err);
|
| 57 |
+
process.exit(1);
|
| 58 |
+
});
|