import type { ContentTypeRow } from '../db/schema.js'; import type { ContentItem, SeoData } from './items.js'; /** * SEO resolution: what a page actually claims to be, once fallbacks are applied. * * The point of putting this in core rather than in the template is that the admin's preview and * the public page must agree. A preview that resolves its own fallbacks is a preview of something * nobody will ever see — the failure mode is silent and only shows up in a social-media share * weeks later. Both callers go through `resolveSeo`. */ /** * Lengths at which the previews warn. * * These are guidance, not limits, and the UI must say so. Google truncates by *pixel width*, not * character count, and the width depends on the glyphs — so no character count is ever exactly * right, and enforcing one with `maxLength` would block a legitimate title that happens to be * narrow. Nothing here is validated server-side for the same reason; over-length content is a * quality warning, not an error worth refusing a save over. */ export declare const SEO_GUIDANCE: { /** Roughly where a search result title starts being cut off. */ readonly titleChars: 60; /** Roughly where a search result description starts being cut off. */ readonly descriptionChars: 160; }; export interface ResolvedSeo { /** What goes in `` and the search-result heading. Never empty — falls back to the title. */ title: string; /** Meta description, or null when the editor has written none. */ description: string | null; /** Media id for the social card, after the item → content type fallback. */ ogImageId: string | null; /** Where that image came from, so the editor can be told it is inheriting rather than unset. */ ogImageSource: 'item' | 'contentType' | 'site' | 'none'; /** Whether the page asks not to be indexed. */ noIndex: boolean; } /** * The site-wide end of the fallback chain. * * Optional so every existing caller keeps working and resolves exactly as it did — a deployment that * has never opened the site settings screen has an empty last link. */ export interface SeoSiteDefaults { name?: string | null; titleTemplate?: string | null; seoDescription?: string | null; seoOgImageId?: string | null; seoNoIndex?: boolean; } export declare function resolveSeo(item: { title: string; seo: SeoData; }, contentType?: Pick<ContentTypeRow, 'default_og_image_id'> | null, site?: SeoSiteDefaults | null): ResolvedSeo; /** * Trim text the way a search result does, at a word boundary with an ellipsis. * * Used only by the preview. Nothing truncates what is stored — the editor's words are kept whole * and the preview shows what a search engine is likely to do with them. */ export declare function truncateForPreview(text: string, limit: number): string; /** * The absolute URL a page will be shared and indexed under. * * Takes the site origin as an argument rather than reading configuration: core has no idea what * host it is deployed behind, and a canonical URL guessed from a request header is how staging * environments end up claiming to be production. */ export declare function canonicalUrl(origin: string, path: string): string; /** Convenience for the common case of resolving straight off a hydrated item. */ export declare function resolveItemSeo(item: ContentItem, contentType?: Pick<ContentTypeRow, 'default_og_image_id'> | null): ResolvedSeo;