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.
405 lines
14 KiB
TypeScript
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
|
|
}
|
|
}
|