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.
This commit is contained in:
2026-10-27 15:43:32 +01:00
parent 4ca26e9a89
commit 923d0f9c3a
7 changed files with 121 additions and 10 deletions

2
assets/dist/seo.css vendored

File diff suppressed because one or more lines are too long

2
assets/dist/seo.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -44,6 +44,8 @@ export interface Labels {
noimageindex: string
unavailableAfter: string
invalidNumber: string
robotsUnparsed: string
robotsReset: string
// E3
desktop: string
mobile: string
@@ -166,6 +168,8 @@ const en: Labels = {
noimageindex: 'Do not index images (noimageindex)',
unavailableAfter: 'Remove from results after',
invalidNumber: 'Enter a whole number, -1 or more.',
robotsUnparsed: 'The stored value “:value” is not one this editor can show. It stays as it is until you reset it.',
robotsReset: 'Reset',
desktop: 'Desktop',
mobile: 'Mobile',
device: 'Preview device',
@@ -283,6 +287,8 @@ const pl: Labels = {
noimageindex: 'Nie indeksuj obrazów (noimageindex)',
unavailableAfter: 'Usuń z wyników po dniu',
invalidNumber: 'Wpisz liczbę całkowitą, -1 lub większą.',
robotsUnparsed: 'Zapisanej wartości „:value” ten edytor nie potrafi pokazać. Zostaje bez zmian, dopóki jej nie wyzerujesz.',
robotsReset: 'Wyzeruj',
desktop: 'Komputer',
mobile: 'Telefon',
device: 'Urządzenie podglądu',

View File

@@ -1,4 +1,4 @@
import { el, emit, icon, jsonAttr, labelsFor, plural, uid, uiLocaleOf, type Labels } from './labels'
import { el, emit, fmt, icon, jsonAttr, labelsFor, plural, uid, uiLocaleOf, type Labels } from './labels'
export const ROBOTS_TAG = 'golem15-seo-robots'
@@ -35,6 +35,26 @@ export function emptyRobots(): Robots {
}
}
/**
* 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()
@@ -75,7 +95,8 @@ export function parseRobots(value: string): Robots | null {
break
case 'max-snippet':
case 'max-video-preview': {
if (arg === null || !/^-?\d+$/.test(arg)) return null
// 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
@@ -87,7 +108,7 @@ export function parseRobots(value: string): Robots | null {
r.maxImagePreview = arg
break
case 'unavailable_after':
if (arg === null || !/^\d{4}-\d{2}-\d{2}$/.test(arg) || Number.isNaN(Date.parse(arg + 'T00:00:00Z'))) return null
if (arg === null || !validDate(arg)) return null
r.unavailableAfter = arg
break
default:
@@ -128,6 +149,9 @@ export class SeoRobotsElement extends HTMLElement {
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
@@ -139,7 +163,9 @@ export class SeoRobotsElement extends HTMLElement {
return
}
this.current = next
this.model = parseRobots(next) ?? emptyRobots()
const parsed = parseRobots(next)
this.unparsed = parsed === null
this.model = parsed ?? emptyRobots()
this.open = this.open || advancedCount(this.model) > 0
this.render()
}
@@ -160,6 +186,9 @@ export class SeoRobotsElement extends HTMLElement {
}
private commit(): void {
if (this.unparsed) {
return
}
this.current = serializeRobots(this.model)
emit(this, 'summer-change', { value: this.current })
this.render()
@@ -167,7 +196,7 @@ export class SeoRobotsElement extends HTMLElement {
private render(): void {
const labels = this.labels()
const disabled = this.hasAttribute('disabled')
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'))
@@ -231,7 +260,25 @@ export class SeoRobotsElement extends HTMLElement {
}
panel.append(checks)
this.replaceChildren(groups, row, panel)
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()
}
@@ -348,7 +395,7 @@ export class SeoRobotsElement extends HTMLElement {
input.dataset.focusKey = 'unavailableAfter'
input.value = this.model.unavailableAfter
input.addEventListener('change', () => {
this.model.unavailableAfter = /^\d{4}-\d{2}-\d{2}$/.test(input.value) ? input.value : ''
this.model.unavailableAfter = validDate(input.value) ? input.value : ''
this.commit()
})
wrap.append(input)

View File

@@ -242,6 +242,27 @@ golem15-seo-robots .seo-robots__check input {
accent-color: var(--c-primary);
}
golem15-seo-robots .seo-robots__unparsed {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: var(--c-danger);
font-size: 13px;
}
golem15-seo-robots .seo-robots__reset {
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--c-border);
border-radius: 8px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
golem15-seo-robots.seo-robots--invalid .seo-robots__track {
border-color: var(--c-danger);
}

View File

@@ -108,6 +108,17 @@ func TestRobotsValueMalformed(t *testing.T) {
}
}
// TestRobotsValueSigned: a signed number is accepted and stored without
// its plus sign, as the element's grammar reads it (WR-24).
func TestRobotsValueSigned(t *testing.T) {
if got, err := NormalizeRobots("max-snippet:+5"); err != nil || got != "max-snippet:5" {
t.Fatalf("NormalizeRobots(max-snippet:+5) = %q, %v", got, err)
}
if _, err := NormalizeRobots("unavailable_after:2028-02-29"); err != nil {
t.Fatalf("leap day: %v", err)
}
}
func TestRobotsValueInherit(t *testing.T) {
parse := func(v string) Robots {
t.Helper()

View File

@@ -73,11 +73,37 @@ describe('E2 value grammar', () => {
})
it('malformed values are refused like the Go grammar', () => {
for (const bad of [',', 'index,', 'index,noindex', 'follow,nofollow', 'noarchive,noarchive', 'index:1', 'noarchive:yes', 'max-snippet', 'max-snippet:abc', 'max-snippet:-2', 'max-snippet:1.5', 'max-image-preview:huge', 'unavailable_after:2027-13-01', 'unavailable_after:27-01-01', 'none', 'noodp']) {
for (const bad of [',', 'index,', 'index,noindex', 'follow,nofollow', 'noarchive,noarchive', 'index:1', 'noarchive:yes', 'max-snippet', 'max-snippet:abc', 'max-snippet:-2', 'max-snippet:1.5', 'max-image-preview:huge', 'unavailable_after:2027-13-01', 'unavailable_after:27-01-01', 'unavailable_after:2027-02-30', 'unavailable_after:2027-02-29', 'unavailable_after:2027-04-31', 'unavailable_after:2027-01-00', 'max-snippet:+-1', 'none', 'noodp']) {
expect(parseRobots(bad), bad).toBeNull()
}
})
it('accepts what the Go grammar accepts (WR-24): a leap day and a signed number', () => {
expect(serializeRobots(parseRobots('unavailable_after:2028-02-29') as Robots)).toBe('unavailable_after:2028-02-29')
expect(serializeRobots(parseRobots('max-snippet:+5') as Robots)).toBe('max-snippet:5')
expect(serializeRobots(parseRobots('max-video-preview:-1') as Robots)).toBe('max-video-preview:-1')
})
it('a stored value it cannot parse is kept, shown and replaced only by Reset (WR-24)', () => {
const node = document.createElement('golem15-seo-robots') as HTMLElement & { value: string }
node.setAttribute('ui-locale', 'en')
document.body.append(node)
node.value = 'noindex,unavailable_after:2027-02-30'
const seen: string[] = []
node.addEventListener('summer-change', (ev) => seen.push((ev as CustomEvent).detail.value))
expect(node.querySelector('.seo-robots__unparsed')?.textContent).toContain('noindex,unavailable_after:2027-02-30')
const segment = node.querySelector('[data-focus-key="follow-2"]') as HTMLButtonElement
expect(segment.disabled).toBe(true)
segment.click()
expect(seen).toEqual([])
expect(node.value).toBe('noindex,unavailable_after:2027-02-30')
;(node.querySelector('.seo-robots__reset') as HTMLButtonElement).click()
expect(seen).toEqual([''])
expect(node.querySelector('.seo-robots__unparsed')).toBeNull()
;(node.querySelector('[data-focus-key="index-2"]') as HTMLButtonElement).click()
expect(seen).toEqual(['', 'noindex'])
})
it('the element emits the fixed-order value and "" when both groups inherit', () => {
const node = document.createElement('golem15-seo-robots') as HTMLElement & { value: string }
document.body.append(node)