implement site and page-level SEO meta tags with social preview image support

This commit is contained in:
Raj Nandan Sharma
2026-03-18 10:56:39 +05:30
parent 36f2ae1f69
commit 087c2f25fb
19 changed files with 372 additions and 24 deletions
@@ -162,6 +162,9 @@ export interface PageDashboardData {
monitorTags: string[];
monitorGroupMembersByTag: Record<string, string[]>;
pageDetails: PageRecordTyped;
socialPagePreviewImage?: string;
metaPageTitle?: string;
metaPageDescription?: string;
}
const BuildPageStatus = (latestData: Array<{ status?: string | null; latency?: number | null }>, nowTs: number) => {
@@ -377,6 +380,22 @@ export const GetPageDashboardData = async (
monitorGroupMembersByTag[monitor.tag] = groupData.monitors.map((member) => member.tag);
}
let socialPagePreviewImage: string | undefined = layoutData.socialPreviewImage;
let metaPageTitle: string | undefined = layoutData.metaSiteTitle;
let metaPageDescription: string | undefined = layoutData.metaSiteDescription;
if (!!pageDetails.page_settings_json) {
try {
const pageSettings = JSON.parse(pageDetails.page_settings_json);
if (pageSettings) {
socialPagePreviewImage = pageSettings.socialPreviewImage || layoutData.socialPreviewImage;
metaPageTitle = pageSettings.metaTitle || layoutData.metaSiteTitle;
metaPageDescription = pageSettings.metaDescription || layoutData.metaSiteDescription;
}
} catch (e) {
// Ignore JSON parsing errors and fallback to layout data or defaults
}
}
return {
pageStatus,
ongoingIncidents,
@@ -384,5 +403,8 @@ export const GetPageDashboardData = async (
monitorTags,
monitorGroupMembersByTag,
pageDetails: pageDetailsTyped,
socialPagePreviewImage,
metaPageTitle,
metaPageDescription,
};
};
@@ -71,6 +71,8 @@ export interface LayoutServerData {
customCSS?: string;
globalPageVisibilitySettings: GlobalPageVisibilitySettings;
dateAndTimeFormat: SiteDateTimeFormat;
metaSiteTitle?: string;
metaSiteDescription?: string;
}
export async function GetLayoutServerData(cookies: Cookies, request: Request): Promise<LayoutServerData> {
@@ -139,5 +141,7 @@ export async function GetLayoutServerData(cookies: Cookies, request: Request): P
customCSS: siteData.customCSS,
globalPageVisibilitySettings: siteData.globalPageVisibilitySettings || seedSiteData.globalPageVisibilitySettings,
dateAndTimeFormat: siteData.dateAndTimeFormat || seedSiteData.dateAndTimeFormat,
metaSiteTitle: siteData.metaSiteTitle,
metaSiteDescription: siteData.metaSiteDescription,
};
}
@@ -62,6 +62,8 @@ export interface SiteDataTransformed {
globalPageVisibilitySettings?: GlobalPageVisibilitySettings;
pageOrderingSettings?: PageOrderingSettings;
dateAndTimeFormat?: SiteDateTimeFormat;
metaSiteTitle?: string;
metaSiteDescription?: string;
}
export function InsertKeyValue(key: string, value: string): Promise<number[]> {
@@ -276,4 +276,14 @@ export const siteDataKeys: SiteDataKey[] = [
isValid: IsValidJSONString,
data_type: "object",
},
{
key: "metaSiteTitle",
isValid: (value) => typeof value === "string",
data_type: "string",
},
{
key: "metaSiteDescription",
isValid: (value) => typeof value === "string",
data_type: "string",
},
];
+3
View File
@@ -444,6 +444,9 @@ export interface PageSettingsType {
mobile: number;
};
monitor_layout_style: "default-list" | "default-grid" | "compact-list" | "compact-grid";
metaPageTitle?: string;
metaPageDescription?: string;
socialPagePreviewImage?: string;
}
export interface PageRecordTyped {
+4
View File
@@ -295,6 +295,10 @@
"group": "v4.x",
"collapsible": false,
"pages": [
{
"title": "v4.0.19",
"content": "v4/changelogs/v4.0.19"
},
{
"title": "v4.0.18",
"content": "v4/changelogs/v4.0.18"
@@ -0,0 +1,36 @@
---
title: v4.0.19 Changelog
description: See what's new in Kener v4.0.19, including new features, improvements, and bug fixes
---
## New features {#new-features}
### Site-level SEO meta tags {#site-level-seo}
Admins can now set a custom **meta title** and **meta description** for the entire status page from **Manage → Site Configurations → Social Preview & SEO**. These values are used as defaults for all pages and control how the site appears in search engine results and social link previews.
Both fields are optional — leave them empty to use the existing page title and header as defaults.
| Field | Effect |
| :----------------- | :---------------------------------------------------- |
| `Meta Title` | Sets the `<title>` tag shown in search results |
| `Meta Description` | Sets the `<meta name="description">` tag for snippets |
The **social preview image** upload has been merged into this same card. A single Save button persists the image, title, and description together.
### Page-level SEO meta tags {#page-level-seo}
Each page now supports its own **meta title**, **meta description**, and **social preview image** via **Manage → Pages → [page] → Social Preview & SEO**. These override the site-level defaults for that specific page.
The override hierarchy is:
1. **Page-level** setting from `page_settings_json` (if set)
2. **Site-level** setting from Site Configurations (if set)
3. **Auto-generated** defaults (page title + site name, page header)
| Meta tag | Page-level key | Site-level fallback | Auto fallback |
| :--------------------- | :------------------- | :-------------------- | :-------------------------- |
| `<title>` | `metaTitle` | `metaSiteTitle` | `{page_title} - {siteName}` |
| `<meta description>` | `metaDescription` | `metaSiteDescription` | Page header text |
| `<meta og:image>` | `socialPreviewImage` | `socialPreviewImage` | None |
| `<meta twitter:image>` | `socialPreviewImage` | `socialPreviewImage` | None |
@@ -64,6 +64,23 @@ Choose one layout:
Click **Save Preferences** after changes.
## Social preview and SEO {#social-preview-and-seo}
Each page can override the site-level SEO defaults set in [Site Configuration](/docs/v4/setup/site-configuration#social-preview-and-seo).
| Field | Description |
| ---------------------- | ------------------------------------------------------------- |
| `Meta Title` | Custom `<title>` and `og:title` for this page. |
| `Meta Description` | Custom `<meta name="description">` and `og:description`. |
| `Social Preview Image` | Custom `og:image` and `twitter:image` shown in link previews. |
When a field is left empty, the site-level value is used. When the site-level value is also empty, the page title and header are used as automatic fallbacks.
**Override hierarchy**: Page setting → Site setting → Auto-generated from page title/header.
> [!TIP]
> Upload a 1200×630 image for best results across social platforms.
## Delete a page {#delete-a-page}
Non-home pages can be deleted from **Danger Zone**.
@@ -12,18 +12,20 @@ Use **Manage → Site Configurations** to control identity, navigation, monitor
3. Configure **Navigation Menu**.
4. Set **Monitor Sub Menu Options**.
5. Configure **Global Page Visibility Settings**.
6. Configure **Data Retention Policy**.
7. Configure **Event Display Settings**.
6. Configure **Social Preview & SEO**.
7. Configure **Data Retention Policy**.
8. Configure **Event Display Settings**.
## Runtime impact map {#runtime-impact-map}
| Setting area | Stored key | Runtime impact |
| ---------------------------- | ------------------------------------ | ------------------------------------------------------------------- |
| Site name / URL / logo / nav | `siteName`, `siteURL`, `logo`, `nav` | Rendered in top navbar branding and nav links |
| Favicon | `favicon` | Used in `<head>` as page icon |
| Monitor sub menu options | `subMenuOptions` | Gates monitor share actions (badges/embed) on public monitor pages |
| Global page visibility | `globalPageVisibilitySettings` | Controls page switcher visibility and page-scoped navigation/events |
| Data retention policy | `dataRetentionPolicy` | Controls daily cleanup of old `monitoring_data` |
| Setting area | Stored key | Runtime impact |
| ---------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------- |
| Site name / URL / logo / nav | `siteName`, `siteURL`, `logo`, `nav` | Rendered in top navbar branding and nav links |
| Favicon | `favicon` | Used in `<head>` as page icon |
| Monitor sub menu options | `subMenuOptions` | Gates monitor share actions (badges/embed) on public monitor pages |
| Global page visibility | `globalPageVisibilitySettings` | Controls page switcher visibility and page-scoped navigation/events |
| Data retention policy | `dataRetentionPolicy` | Controls daily cleanup of old `monitoring_data` |
| Social preview & SEO | `metaSiteTitle`, `metaSiteDescription`, `socialPreviewImage` | Default `og:title`, `og:description`, `og:image` for all pages |
## Monitor sub menu options {#monitor-sub-menu-options}
@@ -80,6 +82,21 @@ This affects:
- event sections on status pages
- notifications payload API used by the UI
## Social preview and SEO {#social-preview-and-seo}
The **Social Preview & SEO** card sets site-wide defaults for meta tags used in search engines and link previews.
| Field | Stored key | Meta tags affected |
| ---------------------- | --------------------- | --------------------------------------------- |
| `Meta Title` | `metaSiteTitle` | `<title>`, `og:title` |
| `Meta Description` | `metaSiteDescription` | `<meta name="description">`, `og:description` |
| `Social Preview Image` | `socialPreviewImage` | `og:image`, `twitter:image` |
These values are used as defaults for every page. Individual pages can override them — see [Pages → Social Preview & SEO](/docs/v4/pages#social-preview-and-seo).
> [!TIP]
> Upload a 1200×630 image for best results across social platforms.
## Verify changes {#verify-changes}
- Update site name/logo/nav and refresh home page.
@@ -90,3 +107,4 @@ This affects:
- notifications calendar opens page-scoped events for the current month.
- Change event display settings and verify incident/maintenance visibility.
- Set retention policy and confirm scheduler logs in server output.
- Set meta title/description and social preview image, then check `<meta>` tags in page source.
-6
View File
@@ -17,7 +17,6 @@
<Toaster />
<svelte:head>
<title>Kener Status</title>
<link rel="icon" href={data.favicon} />
{#if data.font?.cssSrc}
<link rel="stylesheet" href={data.font.cssSrc} />
@@ -44,11 +43,6 @@
${data.customCSS || ""}
</style>`}
<script src={clientResolver(resolve, "/capture.js")}></script>
<!-- social preview meta tags -->
{#if data.socialPreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
{/if}
</svelte:head>
<main class="kener-public">
<!-- Nav -->
+19 -1
View File
@@ -113,7 +113,25 @@
</script>
<svelte:head>
<title>{(data.pageDetails?.page_title || "Status Page") + " - " + data.siteName}</title>
<title>{data.metaPageTitle || (data.pageDetails?.page_title || "Status Page") + " - " + data.siteName}</title>
{#if data.metaPageTitle}
<title>{data.metaPageTitle}</title>
{:else if data.pageDetails?.page_title}
<title>{data.pageDetails.page_title} - {data.siteName}</title>
{:else}
<title>{data.siteName} - Status Page</title>
{/if}
{#if data.metaPageDescription}
<meta name="description" content={data.metaPageDescription} />
{:else if data.pageDetails?.page_header}
<meta name="description" content={data.pageDetails.page_header} />
{:else}
<meta name="description" content={data.pageDetails.page_title + " - Status Page"} />
{/if}
{#if data.socialPagePreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
{/if}
</svelte:head>
<!-- page title -->
+19 -2
View File
@@ -16,7 +16,6 @@
import { SveltePurify } from "@humanspeak/svelte-purify";
let { data } = $props();
let pageSettings = $derived(data.pageDetails.page_settings);
let barCount = $derived.by(() =>
data.isMobile
@@ -114,7 +113,25 @@
</script>
<svelte:head>
<title>{(data.pageDetails?.page_title || "Status Page") + " - " + data.siteName}</title>
<title>{data.metaPageTitle || (data.pageDetails?.page_title || "Status Page") + " - " + data.siteName}</title>
{#if data.metaPageTitle}
<title>{data.metaPageTitle}</title>
{:else if data.pageDetails?.page_title}
<title>{data.pageDetails.page_title} - {data.siteName}</title>
{:else}
<title>{data.siteName} - Status Page</title>
{/if}
{#if data.metaPageDescription}
<meta name="description" content={data.metaPageDescription} />
{:else if data.pageDetails?.page_header}
<meta name="description" content={data.pageDetails.page_header} />
{:else}
<meta name="description" content={data.pageDetails.page_title + " - Status Page"} />
{/if}
{#if data.socialPagePreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPagePreviewImage)} />
{/if}
</svelte:head>
<!-- page title -->
@@ -159,6 +159,10 @@
<svelte:head>
<title>{currentMonth} - Maintenances & Incidents - {data.siteName}</title>
{#if data.socialPreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
{/if}
</svelte:head>
<div class="flex flex-col gap-3">
@@ -159,6 +159,10 @@
<svelte:head>
<title>{currentMonth} - Maintenances & Incidents - {data.siteName}</title>
{#if data.socialPreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
{/if}
</svelte:head>
<div class="flex flex-col gap-3">
@@ -24,6 +24,10 @@
{#if data.comments.length > 0}
<meta name="description" content={data.comments[0].comment} />
{/if}
{#if data.socialPreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
{/if}
</svelte:head>
<div class="flex flex-col gap-3">
@@ -69,6 +69,10 @@
{#if data.maintenance.description}
<meta name="description" content={data.maintenance.description} />
{/if}
{#if data.socialPreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
{/if}
</svelte:head>
<div class="flex flex-col gap-3">
@@ -33,6 +33,10 @@
{#if data.monitorDescription}
<meta name="description" content={data.monitorDescription} />
{/if}
{#if data.socialPreviewImage}
<meta property="og:image" content={clientResolver(resolve, data.socialPreviewImage)} />
<meta name="twitter:image" content={clientResolver(resolve, data.socialPreviewImage)} />
{/if}
</svelte:head>
<div class="flex flex-col gap-3">
<ThemePlus monitor_tags={[data.monitorTag]} embedMonitorTag={data.monitorTag} />
@@ -9,6 +9,7 @@
import { Label } from "$lib/components/ui/label/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { Switch } from "$lib/components/ui/switch/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { toast } from "svelte-sonner";
import Loader from "@lucide/svelte/icons/loader";
import SaveIcon from "@lucide/svelte/icons/save";
@@ -51,6 +52,7 @@
let saving = $state(false);
let savingMonitors = $state(false);
let uploadingLogo = $state(false);
let uploadingSocialPreview = $state(false);
// Page data
let currentPage = $state<PageWithMonitors | null>(null);
@@ -411,6 +413,60 @@
formData.page_logo = "";
}
function clearSocialPreview() {
pageSettings.socialPagePreviewImage = "";
}
async function handleSocialPreviewUpload(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
if (!allowedTypes.includes(file.type)) {
toast.error("Invalid file type. Allowed: PNG, JPG, WebP");
return;
}
if (file.size > GC.MAX_UPLOAD_BYTES) {
toast.error(`File too large. Maximum size is ${GC.MAX_UPLOAD_BYTES / (1024 * 1024)}MB`);
return;
}
uploadingSocialPreview = true;
try {
const base64 = await fileToBase64(file);
const response = await fetch(clientResolver(resolve, "/manage/api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "uploadImage",
data: {
base64,
mimeType: file.type,
fileName: file.name,
maxWidth: 1200,
maxHeight: 630,
prefix: "page_social_"
}
})
});
const result = await response.json();
if (result.error) {
toast.error(result.error);
} else {
pageSettings.socialPagePreviewImage = result.url;
toast.success("Social preview image uploaded");
}
} catch (e) {
toast.error("Failed to upload social preview image");
} finally {
uploadingSocialPreview = false;
input.value = "";
}
}
async function savePageSettings() {
if (!currentPage) return;
@@ -786,6 +842,102 @@
</Card.Footer>
</Card.Root>
<!-- Social Preview & SEO Card -->
<Card.Root>
<Card.Header>
<Card.Title>Social Preview & SEO</Card.Title>
<Card.Description
>Optional social preview image and meta tags for this page. Leave empty to use site defaults.</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex items-start gap-4">
<!-- Preview -->
<div class="bg-muted flex h-32 w-64 items-center justify-center rounded-lg border">
{#if pageSettings.socialPagePreviewImage}
<img
src={clientResolver(resolve, pageSettings.socialPagePreviewImage)}
alt="Social preview"
class="h-full w-full rounded-lg object-cover"
/>
{:else}
<ImageIcon class="text-muted-foreground h-8 w-8" />
{/if}
</div>
<!-- Upload Controls -->
<div class="flex flex-1 flex-col gap-2">
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={uploadingSocialPreview}
onclick={() => document.getElementById("page-social-preview-input")?.click()}
>
{#if uploadingSocialPreview}
<Loader class="h-4 w-4 animate-spin" />
Uploading...
{:else}
<UploadIcon class="h-4 w-4" />
Upload Social Preview
{/if}
</Button>
<input
id="page-social-preview-input"
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp"
class="hidden"
onchange={handleSocialPreviewUpload}
disabled={uploadingSocialPreview}
/>
{#if pageSettings.socialPagePreviewImage}
<Button variant="ghost" size="sm" onclick={clearSocialPreview}>
<XIcon class="h-4 w-4" />
</Button>
{/if}
</div>
{#if pageSettings.socialPagePreviewImage}
<p class="text-muted-foreground truncate text-xs">{pageSettings.socialPagePreviewImage}</p>
{:else}
<p class="text-muted-foreground text-xs">Optional. Leave empty to use site default.</p>
{/if}
</div>
</div>
<div class="space-y-2">
<Label for="page-metaPageTitle">Meta Title</Label>
<Input
id="page-metaPageTitle"
type="text"
bind:value={pageSettings.metaPageTitle}
placeholder="Custom page title for search engines"
/>
<p class="text-muted-foreground text-xs">Overrides the default page title in search results</p>
</div>
<div class="space-y-2">
<Label for="page-metaPageDescription">Meta Description</Label>
<Textarea
id="page-metaPageDescription"
bind:value={pageSettings.metaPageDescription}
placeholder="Custom description for search engines"
rows={3}
/>
<p class="text-muted-foreground text-xs">Shown as the snippet text in search engine results</p>
</div>
</Card.Content>
<Card.Footer class="flex justify-end">
<Button onclick={savePageSettings} disabled={savingSettings}>
{#if savingSettings}
<Loader class="h-4 w-4 animate-spin" />
Saving...
{:else}
<SaveIcon class="h-4 w-4" />
Save
{/if}
</Button>
</Card.Footer>
</Card.Root>
<!-- Danger Zone Card (only for non-home pages) -->
{#if currentPage.page_path !== ""}
<Card.Root class="border-destructive">
@@ -2,6 +2,7 @@
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { Switch } from "$lib/components/ui/switch/index.js";
import * as Card from "$lib/components/ui/card/index.js";
@@ -97,6 +98,8 @@
});
let eventDisplaySettings = $state<EventDisplaySettings>(structuredClone(defaultEventDisplaySettings));
let metaSiteTitle = $state("");
let metaSiteDescription = $state("");
let currentOrigin = $state("");
function onForceExclusivityChange(checked: boolean | "indeterminate") {
@@ -204,6 +207,9 @@
} else {
eventDisplaySettings = structuredClone(defaultEventDisplaySettings);
}
metaSiteTitle = data.metaSiteTitle || "";
metaSiteDescription = data.metaSiteDescription || "";
}
} catch (e) {
toast.error("Failed to load site data");
@@ -300,7 +306,11 @@
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "storeSiteData",
data: { socialPreviewImage: siteData.socialPreviewImage }
data: {
socialPreviewImage: siteData.socialPreviewImage,
metaSiteTitle: metaSiteTitle,
metaSiteDescription: metaSiteDescription
}
})
});
@@ -308,10 +318,10 @@
if (result.error) {
toast.error(result.error);
} else {
toast.success("Social preview image saved successfully");
toast.success("Social preview & SEO settings saved successfully");
}
} catch (e) {
toast.error("Failed to save social preview image");
toast.error("Failed to save social preview & SEO settings");
} finally {
savingSocialPreviewImage = false;
}
@@ -805,11 +815,11 @@
</Card.Footer>
</Card.Root>
<!-- Social Preview Image Card -->
<!-- Social Preview & SEO Card -->
<Card.Root>
<Card.Header>
<Card.Title>Social Preview Image</Card.Title>
<Card.Description>Upload an optional social preview image at least 640x320px or similar</Card.Description>
<Card.Title>Social Preview & SEO</Card.Title>
<Card.Description>Configure social preview image and meta tags for search engines</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="flex items-start gap-4">
@@ -863,6 +873,27 @@
{/if}
</div>
</div>
<div class="space-y-2">
<Label for="metaSiteTitle">Meta Title</Label>
<Input
id="metaSiteTitle"
type="text"
bind:value={metaSiteTitle}
placeholder="Custom page title for search engines"
/>
<p class="text-muted-foreground text-xs">Overrides the default page title in search results</p>
</div>
<div class="space-y-2">
<Label for="metaSiteDescription">Meta Description</Label>
<Textarea
id="metaSiteDescription"
bind:value={metaSiteDescription}
placeholder="Custom description for search engines"
rows={3}
/>
<p class="text-muted-foreground text-xs">Shown as the snippet text in search engine results</p>
</div>
</Card.Content>
<Card.Footer class="flex justify-end">
<Button onclick={saveSocialPreviewImage} disabled={savingSocialPreviewImage} class="cursor-pointer">