Files
sm-seo-plugin/assets/src/elements/robots.ts
Jakub Zych 923d0f9c3a fix: the robots element follows the Go grammar and keeps a value it cannot parse (WR-24)
parseRobots accepted 2027-02-30 (Date.parse rolls it over) and refused
max-snippet:+5, both unlike classes/robots_value.go. A value it could not
parse showed every group as Inherit and the first click serialised the
empty model, dropping the stored directives. Dates are now checked as
calendar dates, a sign is accepted, and an unparsed value is kept with a
note, the controls off and an explicit Reset.
2026-10-27 15:43:32 +01:00

405 lines
14 KiB
TypeScript

import { el, emit, fmt, icon, jsonAttr, labelsFor, plural, uid, uiLocaleOf, type Labels } from './labels'
export const ROBOTS_TAG = 'golem15-seo-robots'
/**
* A robots value (UI-SPEC C5): comma-separated directives in the fixed
* order index|noindex, follow|nofollow, noarchive, nosnippet, noimageindex,
* max-snippet:N, max-image-preview:none|standard|large,
* max-video-preview:N, unavailable_after:YYYY-MM-DD. The grammar is the
* same as classes/robots_value.go; empty inherits everything.
*/
export interface Robots {
index: '' | 'index' | 'noindex'
follow: '' | 'follow' | 'nofollow'
noarchive: boolean
nosnippet: boolean
noimageindex: boolean
maxSnippet: number | null
maxImagePreview: '' | 'none' | 'standard' | 'large'
maxVideoPreview: number | null
unavailableAfter: string
}
export function emptyRobots(): Robots {
return {
index: '',
follow: '',
noarchive: false,
nosnippet: false,
noimageindex: false,
maxSnippet: null,
maxImagePreview: '',
maxVideoPreview: null,
unavailableAfter: '',
}
}
/**
* Whether s is a real calendar date YYYY-MM-DD, as Go's time.Parse checks
* it (2027-02-30 is not; Date.parse would roll it over into March).
*/
export function validDate(s: string): boolean {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
if (!m) {
return false
}
const year = Number(m[1])
const month = Number(m[2])
const day = Number(m[3])
if (month < 1 || month > 12 || day < 1) {
return false
}
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
return day <= days
}
/** Parses a robots value; null when it is malformed. */
export function parseRobots(value: string): Robots | null {
const r = emptyRobots()
const trimmed = value.trim()
if (trimmed === '') {
return r
}
const seen = new Set<string>()
for (const part of trimmed.split(',')) {
const d = part.trim().toLowerCase()
if (d === '') {
return null
}
const colon = d.indexOf(':')
const name = (colon < 0 ? d : d.slice(0, colon)).trim()
const arg = colon < 0 ? null : d.slice(colon + 1).trim()
const group = name === 'noindex' ? 'index' : name === 'nofollow' ? 'follow' : name
if (seen.has(group)) {
return null
}
seen.add(group)
switch (name) {
case 'index':
case 'noindex':
if (arg !== null) return null
r.index = name
break
case 'follow':
case 'nofollow':
if (arg !== null) return null
r.follow = name
break
case 'noarchive':
case 'nosnippet':
case 'noimageindex':
if (arg !== null) return null
r[name] = true
break
case 'max-snippet':
case 'max-video-preview': {
// An optional sign, like Go's strconv.Atoi.
if (arg === null || !/^[+-]?\d+$/.test(arg)) return null
const n = Number.parseInt(arg, 10)
if (n < -1) return null
if (name === 'max-snippet') r.maxSnippet = n
else r.maxVideoPreview = n
break
}
case 'max-image-preview':
if (arg !== 'none' && arg !== 'standard' && arg !== 'large') return null
r.maxImagePreview = arg
break
case 'unavailable_after':
if (arg === null || !validDate(arg)) return null
r.unavailableAfter = arg
break
default:
return null
}
}
return r
}
/** Serialises a robots value in the fixed order, joined by ",". */
export function serializeRobots(r: Robots): string {
const out: string[] = []
if (r.index) out.push(r.index)
if (r.follow) out.push(r.follow)
if (r.noarchive) out.push('noarchive')
if (r.nosnippet) out.push('nosnippet')
if (r.noimageindex) out.push('noimageindex')
if (r.maxSnippet !== null) out.push('max-snippet:' + r.maxSnippet)
if (r.maxImagePreview) out.push('max-image-preview:' + r.maxImagePreview)
if (r.maxVideoPreview !== null) out.push('max-video-preview:' + r.maxVideoPreview)
if (r.unavailableAfter) out.push('unavailable_after:' + r.unavailableAfter)
return out.join(',')
}
/** The number of advanced directives set. */
export function advancedCount(r: Robots): number {
return [r.noarchive, r.nosnippet, r.noimageindex, r.maxSnippet !== null, r.maxImagePreview !== '', r.maxVideoPreview !== null, r.unavailableAfter !== ''].filter(Boolean).length
}
/**
* E2 golem15-seo-robots (UI-SPEC C5): two segmented radio groups
* (indexing, links) and an advanced disclosure; the value is the robots
* string, emitted with summer-change on every edit.
*/
export class SeoRobotsElement extends HTMLElement {
static observedAttributes = ['options', 'disabled', 'invalid', 'ui-locale', 'locale']
private current = ''
private model: Robots = emptyRobots()
private open = false
// The stored value does not parse: it is kept as it is, the controls are
// off and only Reset replaces it (a click must never drop it silently).
private unparsed = false
get value(): string {
return this.current
}
set value(v: unknown) {
const next = typeof v === 'string' ? v : v == null ? '' : String(v)
if (next === this.current && this.childElementCount > 0) {
return
}
this.current = next
const parsed = parseRobots(next)
this.unparsed = parsed === null
this.model = parsed ?? emptyRobots()
this.open = this.open || advancedCount(this.model) > 0
this.render()
}
connectedCallback(): void {
this.open = advancedCount(this.model) > 0
this.render()
}
attributeChangedCallback(): void {
if (this.isConnected) {
this.render()
}
}
private labels(): Labels {
return labelsFor(uiLocaleOf(this))
}
private commit(): void {
if (this.unparsed) {
return
}
this.current = serializeRobots(this.model)
emit(this, 'summer-change', { value: this.current })
this.render()
}
private render(): void {
const labels = this.labels()
const disabled = this.hasAttribute('disabled') || this.unparsed
const options = jsonAttr<Record<string, unknown>>(this, 'options', {})
const inheritLabel = options.scope === 'type' ? labels.siteDefault : labels.inherit
this.classList.toggle('seo-robots--invalid', this.hasAttribute('invalid'))
const focused = document.activeElement && this.contains(document.activeElement) ? (document.activeElement as HTMLElement).dataset.focusKey : undefined
const groups = el('div', 'seo-robots__groups')
groups.append(
this.radioGroup(labels.indexing, 'index', [
['', inheritLabel, 'inherit'],
['index', labels.index, 'ok'],
['noindex', labels.noindex, 'danger'],
], disabled),
this.radioGroup(labels.links, 'follow', [
['', inheritLabel, 'inherit'],
['follow', labels.follow, 'ok'],
['nofollow', labels.nofollow, 'danger'],
], disabled),
)
const panelId = uid('seo-robots-advanced')
const toggle = el('button', 'seo-robots__toggle')
toggle.type = 'button'
toggle.setAttribute('aria-expanded', this.open ? 'true' : 'false')
toggle.setAttribute('aria-controls', panelId)
toggle.dataset.focusKey = 'toggle'
toggle.append(el('span', '', labels.advanced), icon('chevron-down'))
toggle.addEventListener('click', () => {
this.open = !this.open
this.render()
})
const row = el('div', 'seo-robots__advanced-row')
row.append(toggle)
const count = advancedCount(this.model)
if (count > 0) {
row.append(el('span', 'seo-robots__summary', plural(labels.advancedSet, count, uiLocaleOf(this))))
}
const panel = el('div', 'seo-robots__panel')
panel.id = panelId
panel.hidden = !this.open
panel.append(
this.numberField(labels.maxSnippet, labels.maxSnippetHint, 'maxSnippet', disabled, labels),
this.selectField(labels, disabled),
this.numberField(labels.maxVideoPreview, '', 'maxVideoPreview', disabled, labels),
this.dateField(labels, disabled),
)
const checks = el('div', 'seo-robots__checks')
for (const key of ['noarchive', 'nosnippet', 'noimageindex'] as const) {
const label = el('label', 'seo-robots__check')
const box = el('input')
box.type = 'checkbox'
box.checked = this.model[key]
box.disabled = disabled
box.dataset.focusKey = key
box.addEventListener('change', () => {
this.model[key] = box.checked
this.commit()
})
label.append(box, el('span', '', labels[key]))
checks.append(label)
}
panel.append(checks)
const parts: HTMLElement[] = [groups, row, panel]
if (this.unparsed) {
const note = el('div', 'seo-robots__unparsed')
note.setAttribute('role', 'status')
note.append(icon('triangle-alert'), el('span', '', fmt(labels.robotsUnparsed, { value: this.current })))
if (!this.hasAttribute('disabled')) {
const reset = el('button', 'seo-robots__reset', labels.robotsReset)
reset.type = 'button'
reset.dataset.focusKey = 'reset'
reset.addEventListener('click', () => {
this.unparsed = false
this.model = emptyRobots()
this.commit()
})
note.append(reset)
}
parts.unshift(note)
}
this.replaceChildren(...parts)
if (focused) {
this.querySelector<HTMLElement>(`[data-focus-key="${focused}"]`)?.focus()
}
}
private radioGroup(caption: string, key: 'index' | 'follow', items: [string, string, string][], disabled: boolean): HTMLElement {
const wrap = el('div', 'seo-robots__group')
const captionId = uid('seo-robots-caption')
const cap = el('span', 'seo-robots__caption', caption)
cap.id = captionId
const track = el('div', 'seo-robots__track')
track.setAttribute('role', 'radiogroup')
track.setAttribute('aria-labelledby', captionId)
const current = this.model[key]
items.forEach(([value, text, tone], i) => {
const seg = el('button', 'seo-robots__segment seo-robots__segment--' + tone, text)
seg.type = 'button'
seg.setAttribute('role', 'radio')
const checked = current === value
seg.setAttribute('aria-checked', checked ? 'true' : 'false')
seg.tabIndex = checked ? 0 : -1
seg.disabled = disabled
seg.dataset.focusKey = key + '-' + i
seg.addEventListener('click', () => this.choose(key, value))
seg.addEventListener('keydown', (ev) => {
const delta = ev.key === 'ArrowRight' || ev.key === 'ArrowDown' ? 1 : ev.key === 'ArrowLeft' || ev.key === 'ArrowUp' ? -1 : 0
if (delta === 0 || disabled) {
return
}
ev.preventDefault()
const next = (i + delta + items.length) % items.length
this.choose(key, items[next][0])
this.querySelector<HTMLElement>(`[data-focus-key="${key}-${next}"]`)?.focus()
})
track.append(seg)
})
wrap.append(cap, track)
return wrap
}
private choose(key: 'index' | 'follow', value: string): void {
if (this.hasAttribute('disabled')) {
return
}
if (key === 'index') {
this.model.index = value as Robots['index']
} else {
this.model.follow = value as Robots['follow']
}
this.commit()
}
private numberField(caption: string, hint: string, key: 'maxSnippet' | 'maxVideoPreview', disabled: boolean, labels: Labels): HTMLElement {
const wrap = el('label', 'seo-robots__field')
wrap.append(el('span', 'seo-robots__label', caption))
const input = el('input', 'seo-robots__input')
input.type = 'number'
input.min = '-1'
input.step = '1'
input.disabled = disabled
input.dataset.focusKey = key
const v = this.model[key]
input.value = v === null ? '' : String(v)
const error = el('span', 'seo-robots__error')
error.setAttribute('role', 'status')
input.addEventListener('change', () => {
const raw = input.value.trim()
if (raw === '') {
this.model[key] = null
this.commit()
return
}
if (!/^-?\d+$/.test(raw) || Number.parseInt(raw, 10) < -1) {
error.textContent = labels.invalidNumber
return
}
this.model[key] = Number.parseInt(raw, 10)
this.commit()
})
wrap.append(input)
if (hint) {
wrap.append(el('span', 'seo-robots__hint', hint))
}
wrap.append(error)
return wrap
}
private selectField(labels: Labels, disabled: boolean): HTMLElement {
const wrap = el('label', 'seo-robots__field')
wrap.append(el('span', 'seo-robots__label', labels.maxImagePreview))
const select = el('select', 'seo-robots__input')
select.disabled = disabled
select.dataset.focusKey = 'maxImagePreview'
for (const [value, text] of [['', labels.notSet], ['none', labels.none], ['standard', labels.standard], ['large', labels.large]] as const) {
const option = el('option', '', text)
option.value = value
option.selected = this.model.maxImagePreview === value
select.append(option)
}
select.addEventListener('change', () => {
this.model.maxImagePreview = select.value as Robots['maxImagePreview']
this.commit()
})
wrap.append(select)
return wrap
}
private dateField(labels: Labels, disabled: boolean): HTMLElement {
const wrap = el('label', 'seo-robots__field')
wrap.append(el('span', 'seo-robots__label', labels.unavailableAfter))
const input = el('input', 'seo-robots__input')
input.type = 'date'
input.disabled = disabled
input.dataset.focusKey = 'unavailableAfter'
input.value = this.model.unavailableAfter
input.addEventListener('change', () => {
this.model.unavailableAfter = validDate(input.value) ? input.value : ''
this.commit()
})
wrap.append(input)
return wrap
}
}