import { el, emit, fmt, icon, jsonAttr, labelsFor, plural, uiLocaleOf, type Labels } from './labels' import { DESCRIPTION_FONT, DESCRIPTION_MAX_PX, DESCRIPTION_MOBILE_MAX_PX, TITLE_FONT, TITLE_MAX_PX, truncate } from './measure' export const SERP_TAG = 'golem15-seo-serp' /** One locale of the seo_snippet context answer. */ export interface ContextLocale { locale: string url: string | null h1: string | null siteName: string titleTemplate: string defaultTitle: string defaultDescription: string | null descriptionSource: string bodyWords: number | null } interface ContextData { op: 'context' host: string locales: ContextLocale[] } interface DuplicateItem { label: string url: string } interface DuplicatesData { op: 'duplicates' locale: string title: DuplicateItem[] description: DuplicateItem[] } type Payload = { op: 'context' } | { op: 'duplicates'; locale: string; title: string; description: string } const DUPLICATE_DELAY_MS = 800 const MAX_LINKS = 3 const LENGTH_WORDS = 300 /** Fills a title template the way classes/templates.go does. */ export function renderTemplate(tpl: string, vars: { title: string; description?: string; site: string }): string { if (tpl.trim() === '') { return '' } const out = tpl .split('%title%').join(vars.title) .split('%description%').join(vars.description ?? '') .split('%site%').join(vars.site) return out.split(/\s+/).filter(Boolean).join(' ').replace(/^[\s|\-–—·:,]+|[\s|\-–—·:,]+$/gu, '') } /** Lowercases and strips diacritics (ł becomes l) for keyphrase checks. */ export function normalizeText(s: string): string { return s .toLowerCase() .replace(/ł/g, 'l') .normalize('NFD') .replace(/\p{M}/gu, '') } /** The words of a text after normalizeText. */ export function words(s: string): string[] { return normalizeText(s).split(/[^\p{L}\p{N}]+/u).filter(Boolean) } /** * Q-4 stem rule: two words match when equal, or when both have at least 5 * characters and share a prefix of at least max(5, shorter length − 3) * characters ("grzyby" matches "grzybów"). */ export function wordsMatch(a: string, b: string): boolean { if (a === b) { return true } const ca = Array.from(a) const cb = Array.from(b) if (ca.length < 5 || cb.length < 5) { return false } const need = Math.max(5, Math.min(ca.length, cb.length) - 3) let shared = 0 while (shared < ca.length && shared < cb.length && ca[shared] === cb[shared]) { shared++ } return shared >= need } /** Whether the phrase's words appear in text in order (C6 matching). */ export function phraseIn(phrase: string, text: string): boolean { const p = words(phrase) if (p.length === 0) { return false } const t = words(text) for (let start = 0; start + p.length <= t.length; start++) { let ok = true for (let i = 0; i < p.length; i++) { if (!wordsMatch(p[i], t[start + i])) { ok = false break } } if (ok) { return true } } return false } /** A locale map or a plain value read for one locale. */ function valueIn(fill: Record, key: string, locale: string): string { const v = fill[key] if (typeof v === 'string') { return v } if (v && typeof v === 'object') { const text = (v as Record)[locale] return typeof text === 'string' ? text : '' } return '' } /** * E3 golem15-seo-serp (UI-SPEC C6): the live search-result preview widget * of the SEO tab and the URL rule form, talking to the seo_snippet widget * action through summer-action only. */ export class SeoSerpElement extends HTMLElement { static observedAttributes = ['fill-values', 'busy', 'state', 'record-id', 'ui-locale', 'locale', 'disabled'] private context: ContextData | null = null private contextFailed = false private device: 'desktop' | 'mobile' = 'desktop' private shownLocale = '' private inflight: Payload | null = null private queued: Payload | null = null private duplicates = new Map() private checking = false private timer: ReturnType | undefined private lastChecked = '' private connected = false private readonly onResult = (ev: Event): void => { const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined const data = detail?.data as ContextData | DuplicatesData | undefined this.inflight = null if (data && data.op === 'context') { this.context = data this.contextFailed = false this.render() this.scheduleDuplicates(true) } else if (data && data.op === 'duplicates') { this.duplicates.set(data.locale, data) this.checking = false this.render() } this.flush() } private readonly onLocale = (ev: Event): void => { const locale = (ev as CustomEvent).detail if (typeof locale === 'string' && locale !== '' && locale !== this.shownLocale) { this.shownLocale = locale this.render() this.scheduleDuplicates(false) } } connectedCallback(): void { this.connected = true this.addEventListener('summer-result', this.onResult) window.addEventListener('summer:ml-locale', this.onLocale) this.render() this.request({ op: 'context' }) } disconnectedCallback(): void { this.connected = false this.removeEventListener('summer-result', this.onResult) window.removeEventListener('summer:ml-locale', this.onLocale) clearTimeout(this.timer) } attributeChangedCallback(name: string, _old: string | null, value: string | null): void { if (!this.connected) { return } if (name === 'busy' && value === null) { this.flush() return } if (name === 'state' && value === 'error') { if (this.inflight?.op === 'context') { this.contextFailed = true } this.checking = false this.inflight = null this.render() return } if (name === 'fill-values') { this.render() this.scheduleDuplicates(false) return } this.render() } private labels(): Labels { return labelsFor(uiLocaleOf(this)) } /** Sends payload, or queues it (one at most) while a request runs. */ private request(payload: Payload): void { if (this.hasAttribute('busy') || this.inflight) { this.queued = payload return } this.inflight = payload emit(this, 'summer-action', { payload }) } private flush(): void { if (this.hasAttribute('busy') || this.inflight || !this.queued) { return } const next = this.queued this.queued = null this.request(next) } private fill(): Record { return jsonAttr>(this, 'fill-values', {}) } private locale(): ContextLocale | null { const locales = this.context?.locales ?? [] if (locales.length === 0) { return null } return locales.find((l) => l.locale === this.shownLocale) ?? locales[0] } private scheduleDuplicates(now: boolean): void { const loc = this.locale() if (!loc) { return } const fill = this.fill() const title = valueIn(fill, 'seo_title', loc.locale) || valueIn(fill, 'title', loc.locale) const description = valueIn(fill, 'seo_description', loc.locale) || valueIn(fill, 'description', loc.locale) const key = JSON.stringify([loc.locale, title, description]) if (key === this.lastChecked) { return } clearTimeout(this.timer) const send = (): void => { this.lastChecked = key this.checking = true this.render() this.request({ op: 'duplicates', locale: loc.locale, title, description }) } if (now) { send() } else { this.timer = setTimeout(send, DUPLICATE_DELAY_MS) } } private render(): void { const labels = this.labels() const ui = uiLocaleOf(this) if (!this.context && !this.contextFailed) { this.setAttribute('aria-busy', 'true') this.replaceChildren(el('div', 'seo-serp__skeleton')) return } this.removeAttribute('aria-busy') const loc = this.locale() const localeCode = loc?.locale ?? this.shownLocale const fill = this.fill() const seoTitle = (valueIn(fill, 'seo_title', localeCode) || valueIn(fill, 'title', localeCode)).trim() const seoDescription = (valueIn(fill, 'seo_description', localeCode) || valueIn(fill, 'description', localeCode)).trim() const keyphrase = valueIn(fill, 'seo_keyphrase', localeCode).trim() const siteName = loc?.siteName ?? '' const title = seoTitle || renderTemplate(loc?.titleTemplate ?? '', { title: loc?.defaultTitle ?? '', site: siteName }) || loc?.defaultTitle || '' const description = seoDescription || loc?.defaultDescription || '' const header = el('div', 'seo-serp__header') const track = el('div', 'seo-serp__track') track.setAttribute('role', 'radiogroup') track.setAttribute('aria-label', labels.device) for (const device of ['desktop', 'mobile'] as const) { const seg = el('button', 'seo-serp__segment', labels[device]) seg.type = 'button' seg.setAttribute('role', 'radio') seg.setAttribute('aria-checked', this.device === device ? 'true' : 'false') seg.addEventListener('click', () => { this.device = device this.render() }) track.append(seg) } header.append(track) if (localeCode) { header.append(el('span', 'seo-serp__language', fmt(labels.language, { locale: localeCode.toUpperCase() }))) } const card = el('div', 'serp-card' + (this.device === 'mobile' ? ' serp-card--mobile' : '')) const top = el('div', 'serp-card__site') top.append(el('span', 'serp-card__favicon', Array.from(siteName || this.context?.host || '?')[0]?.toUpperCase() ?? '?')) const names = el('div', 'serp-card__names') names.append(el('span', 'serp-card__sitename', siteName || this.context?.host || '')) names.append(el('span', 'serp-card__crumbs', breadcrumb(this.context?.host ?? '', loc?.url ?? null))) top.append(names) card.append(top) card.append(el('div', 'serp-card__title', truncate(title, TITLE_FONT, TITLE_MAX_PX))) if (description) { const max = this.device === 'mobile' ? DESCRIPTION_MOBILE_MAX_PX : DESCRIPTION_MAX_PX card.append(el('div', 'serp-card__description', truncate(description, DESCRIPTION_FONT, max))) } else { card.append(el('div', 'serp-card__description serp-card__description--missing', labels.noDescription)) } const notes = el('div', 'seo-serp__notes') if (this.contextFailed) { const fail = el('div', 'seo-serp__error') fail.setAttribute('role', 'status') fail.append(icon('circle-alert'), el('span', '', labels.contextFailed)) notes.append(fail) } if (!seoTitle && loc?.titleTemplate) { notes.append(el('div', '', fmt(labels.fromTemplate, { template: loc.titleTemplate }))) } if (!seoDescription && loc?.defaultDescription && loc.descriptionSource === 'content') { notes.append(el('div', '', labels.fromContent)) } if (loc && loc.url === null) { notes.append(el('div', '', labels.noUrl)) } if ((this.getAttribute('record-id') ?? '') !== '' && loc) { notes.append(el('div', '', labels.savedNote)) } const checks = el('div', 'seo-serp__checks') checks.setAttribute('role', 'status') if (this.checking) { checks.append(el('div', 'seo-serp__checking', labels.checking)) } else { const dup = this.duplicates.get(localeCode) if (dup) { for (const [items, forms] of [[dup.title, labels.dupTitle], [dup.description, labels.dupDescription]] as const) { if (items.length > 0) { checks.append(duplicateLine(items, forms, labels, ui)) } } } } const parts: HTMLElement[] = [header, card, notes, checks] if (keyphrase && loc) { parts.push(keyphraseChecks(keyphrase, { title, description, h1: loc.h1, url: loc.url, bodyWords: loc.bodyWords }, labels, ui)) } this.replaceChildren(...parts) } } /** host › segment › segment of a path. */ function breadcrumb(host: string, url: string | null): string { const segments = (url ?? '').split('?')[0].split('/').filter(Boolean).map((s) => { try { return decodeURIComponent(s) } catch { return s } }) return [host, ...segments].filter(Boolean).join(' › ') } function duplicateLine(items: DuplicateItem[], forms: Labels['dupTitle'], labels: Labels, ui: string): HTMLElement { const line = el('div', 'seo-serp__check seo-serp__check--warn') line.append(icon('triangle-alert')) const text = el('span') const marker = '\u0000list\u0000' const sentence = plural(forms, items.length, ui, { list: marker }) const [before, after] = sentence.split(marker) text.append(document.createTextNode(before ?? '')) items.slice(0, MAX_LINKS).forEach((item, i) => { if (i > 0) { text.append(document.createTextNode(', ')) } if (item.url.startsWith('/') && !item.url.startsWith('//')) { const a = el('a', 'seo-serp__link', item.label) a.href = item.url a.target = '_blank' a.rel = 'noopener' text.append(a) } else { text.append(document.createTextNode(item.label)) } }) if (items.length > MAX_LINKS) { text.append(document.createTextNode(' ' + fmt(labels.andMore, { count: items.length - MAX_LINKS }))) } text.append(document.createTextNode(after ?? '')) line.append(text) return line } function keyphraseChecks(phrase: string, page: { title: string; description: string; h1: string | null; url: string | null; bodyWords: number | null }, labels: Labels, ui: string): HTMLElement { const box = el('div', 'seo-serp__keyphrase') box.append(el('div', 'seo-serp__keyphrase-title', fmt(labels.keyphrase, { phrase }))) const line = (ok: boolean, yes: string, no: string): void => { const row = el('div', 'seo-serp__check ' + (ok ? 'seo-serp__check--ok' : 'seo-serp__check--warn')) row.append(icon(ok ? 'circle-check' : 'triangle-alert'), el('span', '', ok ? yes : no)) box.append(row) } line(phraseIn(phrase, page.title), labels.kpTitle, labels.kpTitleNo) if (page.h1 !== null) { line(phraseIn(phrase, page.h1), labels.kpH1, labels.kpH1No) } line(phraseIn(phrase, page.description), labels.kpDesc, labels.kpDescNo) if (page.url !== null) { line(phraseIn(phrase, page.url.replace(/[-_/]+/g, ' ')), labels.kpUrl, labels.kpUrlNo) } if (page.bodyWords !== null) { const count = new Intl.NumberFormat(ui).format(page.bodyWords) line(page.bodyWords >= LENGTH_WORDS, fmt(labels.kpLength, { count }), fmt(labels.kpLengthNo, { count })) } return box }