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