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.
246 lines
7.9 KiB
TypeScript
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)
|
|
}
|
|
}
|