From 087c2f25fbf5d36e2b8832e985d652fae2ff15df Mon Sep 17 00:00:00 2001 From: Raj Nandan Sharma Date: Wed, 18 Mar 2026 10:56:39 +0530 Subject: [PATCH] implement site and page-level SEO meta tags with social preview image support --- .../server/controllers/dashboardController.ts | 22 +++ .../server/controllers/layoutController.ts | 4 + .../server/controllers/siteDataController.ts | 2 + src/lib/server/controllers/siteDataKeys.ts | 10 ++ src/lib/server/types/db.ts | 3 + src/routes/(docs)/docs.json | 4 + .../docs/content/v4/changelogs/v4.0.19.md | 36 +++++ src/routes/(docs)/docs/content/v4/pages.md | 17 ++ .../content/v4/setup/site-configuration.md | 36 +++-- src/routes/(kener)/+layout.svelte | 6 - src/routes/(kener)/+page.svelte | 20 ++- src/routes/(kener)/[page_path]/+page.svelte | 21 ++- .../events/[MMMM]-[YYYY]/+page.svelte | 4 + .../(kener)/events/[MMMM]-[YYYY]/+page.svelte | 4 + .../incidents/[incident_id]/+page.svelte | 4 + .../[maintenance_id]/+page.svelte | 4 + .../monitors/[monitor_tag]/+page.svelte | 4 + .../manage/app/pages/[page_id]/+page.svelte | 152 ++++++++++++++++++ .../app/site-configurations/+page.svelte | 43 ++++- 19 files changed, 372 insertions(+), 24 deletions(-) create mode 100644 src/routes/(docs)/docs/content/v4/changelogs/v4.0.19.md diff --git a/src/lib/server/controllers/dashboardController.ts b/src/lib/server/controllers/dashboardController.ts index 31ed7c26..c0c876c7 100644 --- a/src/lib/server/controllers/dashboardController.ts +++ b/src/lib/server/controllers/dashboardController.ts @@ -162,6 +162,9 @@ export interface PageDashboardData { monitorTags: string[]; monitorGroupMembersByTag: Record; 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, }; }; diff --git a/src/lib/server/controllers/layoutController.ts b/src/lib/server/controllers/layoutController.ts index 1896f4c2..35e8273b 100644 --- a/src/lib/server/controllers/layoutController.ts +++ b/src/lib/server/controllers/layoutController.ts @@ -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 { @@ -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, }; } diff --git a/src/lib/server/controllers/siteDataController.ts b/src/lib/server/controllers/siteDataController.ts index 0cf80a26..d98aa13c 100644 --- a/src/lib/server/controllers/siteDataController.ts +++ b/src/lib/server/controllers/siteDataController.ts @@ -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 { diff --git a/src/lib/server/controllers/siteDataKeys.ts b/src/lib/server/controllers/siteDataKeys.ts index f58c62de..c28efc84 100644 --- a/src/lib/server/controllers/siteDataKeys.ts +++ b/src/lib/server/controllers/siteDataKeys.ts @@ -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", + }, ]; diff --git a/src/lib/server/types/db.ts b/src/lib/server/types/db.ts index 308a704c..d4841f10 100644 --- a/src/lib/server/types/db.ts +++ b/src/lib/server/types/db.ts @@ -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 { diff --git a/src/routes/(docs)/docs.json b/src/routes/(docs)/docs.json index b915de82..e1408148 100644 --- a/src/routes/(docs)/docs.json +++ b/src/routes/(docs)/docs.json @@ -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" diff --git a/src/routes/(docs)/docs/content/v4/changelogs/v4.0.19.md b/src/routes/(docs)/docs/content/v4/changelogs/v4.0.19.md new file mode 100644 index 00000000..af6a47d1 --- /dev/null +++ b/src/routes/(docs)/docs/content/v4/changelogs/v4.0.19.md @@ -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 `` 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 | diff --git a/src/routes/(docs)/docs/content/v4/pages.md b/src/routes/(docs)/docs/content/v4/pages.md index 910a2c0f..4f3fae06 100644 --- a/src/routes/(docs)/docs/content/v4/pages.md +++ b/src/routes/(docs)/docs/content/v4/pages.md @@ -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**. diff --git a/src/routes/(docs)/docs/content/v4/setup/site-configuration.md b/src/routes/(docs)/docs/content/v4/setup/site-configuration.md index f4f7425e..00ffd6bf 100644 --- a/src/routes/(docs)/docs/content/v4/setup/site-configuration.md +++ b/src/routes/(docs)/docs/content/v4/setup/site-configuration.md @@ -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. diff --git a/src/routes/(kener)/+layout.svelte b/src/routes/(kener)/+layout.svelte index c6236dfb..236d9b50 100644 --- a/src/routes/(kener)/+layout.svelte +++ b/src/routes/(kener)/+layout.svelte @@ -17,7 +17,6 @@ <Toaster /> <svelte:head> - <title>Kener Status {#if data.font?.cssSrc} @@ -44,11 +43,6 @@ ${data.customCSS || ""} `} - - {#if data.socialPreviewImage} - - - {/if}
diff --git a/src/routes/(kener)/+page.svelte b/src/routes/(kener)/+page.svelte index 8d3f6e13..a9508da5 100644 --- a/src/routes/(kener)/+page.svelte +++ b/src/routes/(kener)/+page.svelte @@ -113,7 +113,25 @@ - {(data.pageDetails?.page_title || "Status Page") + " - " + data.siteName} + {data.metaPageTitle || (data.pageDetails?.page_title || "Status Page") + " - " + data.siteName} + {#if data.metaPageTitle} + {data.metaPageTitle} + {:else if data.pageDetails?.page_title} + {data.pageDetails.page_title} - {data.siteName} + {:else} + {data.siteName} - Status Page + {/if} + {#if data.metaPageDescription} + + {:else if data.pageDetails?.page_header} + + {:else} + + {/if} + {#if data.socialPagePreviewImage} + + + {/if} diff --git a/src/routes/(kener)/[page_path]/+page.svelte b/src/routes/(kener)/[page_path]/+page.svelte index 53afc8dd..a9508da5 100644 --- a/src/routes/(kener)/[page_path]/+page.svelte +++ b/src/routes/(kener)/[page_path]/+page.svelte @@ -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 @@ - {(data.pageDetails?.page_title || "Status Page") + " - " + data.siteName} + {data.metaPageTitle || (data.pageDetails?.page_title || "Status Page") + " - " + data.siteName} + {#if data.metaPageTitle} + {data.metaPageTitle} + {:else if data.pageDetails?.page_title} + {data.pageDetails.page_title} - {data.siteName} + {:else} + {data.siteName} - Status Page + {/if} + {#if data.metaPageDescription} + + {:else if data.pageDetails?.page_header} + + {:else} + + {/if} + {#if data.socialPagePreviewImage} + + + {/if} diff --git a/src/routes/(kener)/[page_path]/events/[MMMM]-[YYYY]/+page.svelte b/src/routes/(kener)/[page_path]/events/[MMMM]-[YYYY]/+page.svelte index b50aa487..99670b00 100644 --- a/src/routes/(kener)/[page_path]/events/[MMMM]-[YYYY]/+page.svelte +++ b/src/routes/(kener)/[page_path]/events/[MMMM]-[YYYY]/+page.svelte @@ -159,6 +159,10 @@ {currentMonth} - Maintenances & Incidents - {data.siteName} + {#if data.socialPreviewImage} + + + {/if}
diff --git a/src/routes/(kener)/events/[MMMM]-[YYYY]/+page.svelte b/src/routes/(kener)/events/[MMMM]-[YYYY]/+page.svelte index d1146c10..a04f7151 100644 --- a/src/routes/(kener)/events/[MMMM]-[YYYY]/+page.svelte +++ b/src/routes/(kener)/events/[MMMM]-[YYYY]/+page.svelte @@ -159,6 +159,10 @@ {currentMonth} - Maintenances & Incidents - {data.siteName} + {#if data.socialPreviewImage} + + + {/if}
diff --git a/src/routes/(kener)/incidents/[incident_id]/+page.svelte b/src/routes/(kener)/incidents/[incident_id]/+page.svelte index da3af99f..67ec1fa4 100644 --- a/src/routes/(kener)/incidents/[incident_id]/+page.svelte +++ b/src/routes/(kener)/incidents/[incident_id]/+page.svelte @@ -24,6 +24,10 @@ {#if data.comments.length > 0} {/if} + {#if data.socialPreviewImage} + + + {/if}
diff --git a/src/routes/(kener)/maintenances/[maintenance_id]/+page.svelte b/src/routes/(kener)/maintenances/[maintenance_id]/+page.svelte index fa460bad..813f3686 100644 --- a/src/routes/(kener)/maintenances/[maintenance_id]/+page.svelte +++ b/src/routes/(kener)/maintenances/[maintenance_id]/+page.svelte @@ -69,6 +69,10 @@ {#if data.maintenance.description} {/if} + {#if data.socialPreviewImage} + + + {/if}
diff --git a/src/routes/(kener)/monitors/[monitor_tag]/+page.svelte b/src/routes/(kener)/monitors/[monitor_tag]/+page.svelte index 8636cb93..cd1c61fc 100644 --- a/src/routes/(kener)/monitors/[monitor_tag]/+page.svelte +++ b/src/routes/(kener)/monitors/[monitor_tag]/+page.svelte @@ -33,6 +33,10 @@ {#if data.monitorDescription} {/if} + {#if data.socialPreviewImage} + + + {/if}
diff --git a/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte b/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte index 5fa564c5..d71afca4 100644 --- a/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte +++ b/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte @@ -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(null); @@ -411,6 +413,60 @@ formData.page_logo = ""; } + function clearSocialPreview() { + pageSettings.socialPagePreviewImage = ""; + } + + async function handleSocialPreviewUpload(event: Event): Promise { + 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 @@ + + + + Social Preview & SEO + Optional social preview image and meta tags for this page. Leave empty to use site defaults. + + +
+ +
+ {#if pageSettings.socialPagePreviewImage} + Social preview + {:else} + + {/if} +
+ + +
+
+ + + {#if pageSettings.socialPagePreviewImage} + + {/if} +
+ {#if pageSettings.socialPagePreviewImage} +

{pageSettings.socialPagePreviewImage}

+ {:else} +

Optional. Leave empty to use site default.

+ {/if} +
+
+ +
+ + +

Overrides the default page title in search results

+
+
+ +