Files
sm-seo-plugin/assets/src/elements/robotstester.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

246 lines
7.9 KiB
TypeScript

import { ActionQueue } from './actions'
import { el, fmt, icon, jsonAttr, labelsFor, uid, uiLocaleOf, type Labels } from './labels'
export const ROBOTSTESTER_TAG = 'golem15-seo-robotstester'
/** The tester's context answer: the crawler list and the environment. */
interface ContextData {
op: 'context'
agents: string[]
bots: string[]
staging: boolean
}
/** The tester's test answer. */
interface TestData {
op: 'test'
url: string
allowed: boolean
line: number
rule: string
bot: boolean
agent: string
staging: boolean
}
type Payload = { op: 'context' } | { op: 'test'; url: string; agent: string }
/** The crawler list before the context answer arrives. */
const DEFAULT_AGENTS = ['Googlebot', 'Googlebot-Image', 'Bingbot', '*']
/** The result line of a test answer in the element's language. */
export function resultText(labels: Labels, r: TestData): string {
const params = { agent: r.agent, line: r.line, rule: r.rule }
if (r.allowed) {
return r.line > 0 ? fmt(labels.allowedRule, params) : fmt(labels.allowedNone, params)
}
return r.bot ? fmt(labels.blockedBot, params) : fmt(labels.blocked, params)
}
/**
* E5 golem15-seo-robotstester (UI-SPEC C10): an address input, a crawler
* select and the test button (labelled from the action), and the result
* line. It tests the editor's text and the AI bot switches the form holds,
* including unsaved changes (the widget's fill), through the robots_test
* action only.
*/
export class SeoRobotsTesterElement extends HTMLElement {
static observedAttributes = ['busy', 'state', 'ui-locale', 'disabled', 'label', 'busy-label']
private context: ContextData | null = null
private result: TestData | null = null
private error = ''
private readonly actions = new ActionQueue<Payload>(this, (payload) => this.failed(payload))
private connected = false
private input: HTMLInputElement | null = null
private select: HTMLSelectElement | null = null
private button: HTMLButtonElement | null = null
private line: HTMLDivElement | null = null
private notes: HTMLDivElement | null = null
private builtFor = ''
private readonly onResult = (ev: Event): void => {
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
const data = detail?.data as ContextData | TestData | undefined
this.actions.settle()
if (data && data.op === 'context') {
this.context = data
this.build(true)
} else if (data && data.op === 'test') {
this.result = data
this.error = ''
this.renderResult()
}
}
connectedCallback(): void {
this.connected = true
this.addEventListener('summer-result', this.onResult)
this.build(false)
// Deferred: the host may wire the element only after connecting it.
queueMicrotask(() => {
if (this.connected && !this.context) {
this.request({ op: 'context' })
}
})
}
disconnectedCallback(): void {
this.connected = false
this.removeEventListener('summer-result', this.onResult)
// A result that arrives while disconnected is lost; start over on reconnect.
this.actions.reset()
}
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
if (!this.connected) {
return
}
if (name === 'busy' && value === null) {
this.actions.flush()
}
if (name === 'state' && value === 'error') {
this.actions.settle()
}
if (name === 'ui-locale') {
this.build(true)
return
}
this.syncControls()
}
private labels(): Labels {
return labelsFor(uiLocaleOf(this))
}
private request(payload: Payload): void {
this.actions.request(payload)
}
/** A request the host never took: the tester keeps the default crawlers, a test says it failed. */
private failed(payload: Payload): void {
if (payload.op === 'test') {
this.error = this.labels().testFailed
this.result = null
this.renderResult()
}
}
private build(force: boolean): void {
const key = uiLocaleOf(this) + '|' + (this.context?.agents ?? DEFAULT_AGENTS).join(',')
if (!force && this.builtFor === key && this.input) {
this.syncControls()
return
}
this.builtFor = key
const labels = this.labels()
const keepUrl = this.input?.value ?? ''
const keepAgent = this.select?.value ?? 'Googlebot'
this.replaceChildren()
const row = el('div', 'seo-robotstester__row')
const urlId = uid('seo-robotstester-url')
const urlWrap = el('div', 'seo-robotstester__field seo-robotstester__field--url')
const urlLabel = el('label', 'seo-robotstester__label', labels.url)
urlLabel.htmlFor = urlId
const input = el('input', 'seo-robotstester__input')
input.id = urlId
input.type = 'text'
input.placeholder = labels.urlPlaceholder
input.value = keepUrl
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault()
this.test()
}
})
urlWrap.append(urlLabel, input)
const agentId = uid('seo-robotstester-agent')
const agentWrap = el('div', 'seo-robotstester__field seo-robotstester__field--agent')
const agentLabel = el('label', 'seo-robotstester__label', labels.agent)
agentLabel.htmlFor = agentId
const select = el('select', 'seo-robotstester__select')
select.id = agentId
for (const agent of this.context?.agents ?? DEFAULT_AGENTS) {
const option = el('option', '', agent)
option.value = agent
select.append(option)
}
select.value = keepAgent
if (select.value !== keepAgent) {
select.value = 'Googlebot'
}
agentWrap.append(agentLabel, select)
const button = el('button', 'seo-robotstester__button')
button.type = 'button'
button.addEventListener('click', () => this.test())
row.append(urlWrap, agentWrap, button)
const line = el('div', 'seo-robotstester__result')
line.setAttribute('role', 'status')
const notes = el('div', 'seo-robotstester__notes')
this.input = input
this.select = select
this.button = button
this.line = line
this.notes = notes
this.append(row, line, notes)
this.syncControls()
this.renderResult()
}
private syncControls(): void {
if (!this.button || !this.input || !this.select) {
return
}
const disabled = this.hasAttribute('disabled')
const busy = this.hasAttribute('busy')
this.input.readOnly = disabled
this.select.disabled = disabled
this.button.hidden = disabled
this.button.disabled = busy
this.button.textContent = busy ? this.getAttribute('busy-label') || this.getAttribute('label') || '' : this.getAttribute('label') || ''
}
private test(): void {
if (this.hasAttribute('disabled') || !this.input || !this.select) {
return
}
const url = this.input.value.trim()
if (!url.startsWith('/')) {
this.error = this.labels().urlRequired
this.result = null
this.renderResult()
return
}
this.error = ''
this.request({ op: 'test', url, agent: this.select.value })
}
private renderResult(): void {
if (!this.line || !this.notes) {
return
}
const labels = this.labels()
this.line.className = 'seo-robotstester__result'
if (this.error) {
this.line.classList.add('seo-robotstester__result--error')
this.line.replaceChildren(el('span', '', this.error))
} else if (this.result) {
const allowed = this.result.allowed
this.line.classList.add(allowed ? 'seo-robotstester__result--allowed' : 'seo-robotstester__result--blocked')
this.line.replaceChildren(icon(allowed ? 'circle-check' : 'triangle-alert'), el('span', '', resultText(labels, this.result)))
} else {
this.line.replaceChildren()
}
const notes: HTMLElement[] = []
if (this.context?.staging || jsonAttr<{ staging?: boolean }>(this, 'data', {}).staging) {
notes.push(el('p', 'seo-robotstester__note', labels.stagingNote))
}
notes.push(el('p', 'seo-robotstester__note', labels.unsaved))
this.notes.replaceChildren(...notes)
}
}