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:
2
assets/dist/seo.css
vendored
2
assets/dist/seo.css
vendored
File diff suppressed because one or more lines are too long
2
assets/dist/seo.js
vendored
2
assets/dist/seo.js
vendored
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user