Files
sm-seo-plugin/assets/src/elements/serp.ts

435 lines
14 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 { 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<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 inflight: Payload | null = null
private queued: Payload | null = null
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.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<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
}