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(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) } }