Files
sm-seo-plugin/assets/src/elements/serp.ts
Jakub Zych 3ecbd1f711 fix: the result preview and the robots.txt tester survive a dropped first action (CR-04)
Both elements sent their first summer-action from connectedCallback and
kept it in flight forever when the host dropped it (the v0.5.1 widget host
appends the element before it keeps a reference). A shared ActionQueue no
longer trusts that an emitted action was taken: one the host did not mark
busy synchronously is retried once on the next task, a second drop shows
an error, and a disconnect forgets the request in flight. The first
request is sent on a microtask. Element tests drive a fake host that
mirrors WidgetField's contract, including the append-first ordering, and
the robots.txt tester gets its own tests.
2026-10-27 15:24:33 +01:00

446 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ActionQueue } from './actions'
import { el, 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<string, unknown>, 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<string, unknown>)[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 readonly actions = new ActionQueue<Payload>(this, (payload) => this.failed(payload))
private duplicates = new Map<string, DuplicatesData>()
private checking = false
private timer: ReturnType<typeof setTimeout> | 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.actions.settle()
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()
}
}
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()
// Deferred: the host may wire the element only after connecting it.
queueMicrotask(() => {
if (!this.connected) {
return
}
if (this.context) {
this.scheduleDuplicates(true)
} else {
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)
// A result that arrives while disconnected is lost; start over on reconnect.
this.actions.reset()
this.checking = false
this.lastChecked = ''
}
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
if (!this.connected) {
return
}
if (name === 'busy' && value === null) {
this.actions.flush()
return
}
if (name === 'state' && value === 'error') {
const failed = this.actions.settle()
if (failed) {
this.failed(failed)
} else {
this.checking = false
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 {
this.actions.request(payload)
}
/** A request the host refused (state=error) or never took. */
private failed(payload: Payload): void {
if (payload.op === 'context') {
this.contextFailed = true
} else {
this.lastChecked = ''
}
this.checking = false
this.render()
}
private fill(): Record<string, unknown> {
return jsonAttr<Record<string, unknown>>(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
}