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.
446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
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
|
||
}
|