- E8 golem15-seo-schema: the type per content kind, the Service grid with price and currency checks, custom JSON-LD with a 300 ms parse status - E9 golem15-seo-graph: nodes and findings from the seo_graph action, the JSON, the Rich Results Test link, 800 ms re-check on fill changes, the ML locale switch, one queued request, the failure line - golem15-seo-parent: the primary category select over the record's parents; the seoparent Decode accepts the loaded value sent back - the rebuilt element bundle and vitest smoke tests
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import { el, emit, fmt, icon, jsonAttr, labelsFor, plural, uid, uiLocaleOf, type Labels } from './labels'
|
|
|
|
export const SCHEMA_TAG = 'golem15-seo-schema'
|
|
|
|
/** The C17 cap of custom JSON-LD, in bytes. */
|
|
export const MAX_CUSTOM_BYTES = 16 * 1024
|
|
|
|
/** The editor's Service fields. */
|
|
export interface ServiceFields {
|
|
serviceType?: string
|
|
areaServed?: string
|
|
price?: string
|
|
currency?: string
|
|
}
|
|
|
|
/** The seoschema value, stored as a JSON string. */
|
|
export interface SchemaValue {
|
|
type: string
|
|
fields: ServiceFields
|
|
custom: string
|
|
}
|
|
|
|
/** The types an editor may choose per kind (UI-SPEC C17), the default first. */
|
|
export function typesFor(kind: string): string[] {
|
|
switch (kind) {
|
|
case 'article':
|
|
return ['BlogPosting', 'Article', 'NewsArticle']
|
|
case 'collection':
|
|
return ['CollectionPage']
|
|
case 'profile':
|
|
return ['ProfilePage']
|
|
}
|
|
return ['WebPage', 'AboutPage', 'ContactPage', 'Service']
|
|
}
|
|
|
|
/** Reads a stored value; anything else is the empty value. */
|
|
export function parseValue(raw: unknown): SchemaValue {
|
|
const out: SchemaValue = { type: '', fields: {}, custom: '' }
|
|
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
return out
|
|
}
|
|
try {
|
|
const v = JSON.parse(raw) as Partial<SchemaValue>
|
|
out.type = typeof v.type === 'string' ? v.type : ''
|
|
out.fields = v.fields && typeof v.fields === 'object' ? { ...v.fields } : {}
|
|
out.custom = typeof v.custom === 'string' ? v.custom : ''
|
|
} catch {
|
|
// An unreadable value starts empty.
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Writes a value as its JSON string; the empty value is "". */
|
|
export function serializeValue(v: SchemaValue): string {
|
|
const fields: ServiceFields = {}
|
|
for (const key of ['serviceType', 'areaServed', 'price', 'currency'] as const) {
|
|
const text = (v.fields[key] ?? '').trim()
|
|
if (text !== '') {
|
|
fields[key] = text
|
|
}
|
|
}
|
|
const custom = v.custom.trim()
|
|
if (v.type === '' && custom === '' && Object.keys(fields).length === 0) {
|
|
return ''
|
|
}
|
|
return JSON.stringify({ type: v.type, fields, custom })
|
|
}
|
|
|
|
/** A price such as 150, 149.99 or 149,99. */
|
|
export function validPrice(s: string): boolean {
|
|
return s.trim() === '' || /^\d+([.,]\d{1,2})?$/.test(s.trim())
|
|
}
|
|
|
|
/** A three-letter currency code. */
|
|
export function validCurrency(s: string): boolean {
|
|
return s.trim() === '' || /^[A-Za-z]{3}$/.test(s.trim())
|
|
}
|
|
|
|
/** The status of custom JSON-LD text. */
|
|
export type JsonStatus =
|
|
| { kind: 'empty' }
|
|
| { kind: 'valid'; nodes: number }
|
|
| { kind: 'error'; line: number; column: number; message: string }
|
|
| { kind: 'notObject' }
|
|
| { kind: 'tooLarge' }
|
|
|
|
/** Line and column of a character offset. */
|
|
function position(text: string, offset: number): { line: number; column: number } {
|
|
const before = text.slice(0, Math.max(0, Math.min(offset, text.length)))
|
|
const lines = before.split('\n')
|
|
return { line: lines.length, column: lines[lines.length - 1].length + 1 }
|
|
}
|
|
|
|
/** Checks custom JSON-LD the way the server will (UI-SPEC C17). */
|
|
export function checkCustom(text: string): JsonStatus {
|
|
if (text.trim() === '') {
|
|
return { kind: 'empty' }
|
|
}
|
|
if (new TextEncoder().encode(text.trim()).length > MAX_CUSTOM_BYTES) {
|
|
return { kind: 'tooLarge' }
|
|
}
|
|
let v: unknown
|
|
try {
|
|
v = JSON.parse(text)
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
const lc = /line (\d+) column (\d+)/i.exec(message)
|
|
if (lc) {
|
|
return { kind: 'error', line: Number(lc[1]), column: Number(lc[2]), message }
|
|
}
|
|
const pos = /position (\d+)/i.exec(message)
|
|
const at = pos ? position(text, Number(pos[1])) : position(text, text.length)
|
|
return { kind: 'error', line: at.line, column: at.column, message }
|
|
}
|
|
const isObject = (x: unknown): boolean => x !== null && typeof x === 'object' && !Array.isArray(x)
|
|
if (isObject(v)) {
|
|
return { kind: 'valid', nodes: 1 }
|
|
}
|
|
if (Array.isArray(v) && v.length > 0 && v.every(isObject)) {
|
|
return { kind: 'valid', nodes: v.length }
|
|
}
|
|
return { kind: 'notObject' }
|
|
}
|
|
|
|
const typeLabel = (labels: Labels, type: string): string => (labels as unknown as Record<string, string>)['t' + type] ?? type
|
|
|
|
const noteFor = (labels: Labels, type: string): string => {
|
|
switch (type) {
|
|
case 'BlogPosting':
|
|
case 'Article':
|
|
case 'NewsArticle':
|
|
return labels.noteArticle
|
|
case 'ContactPage':
|
|
return labels.noteContact
|
|
case 'Service':
|
|
return labels.noteService
|
|
case 'CollectionPage':
|
|
return labels.noteCollection
|
|
case 'ProfilePage':
|
|
return labels.noteProfile
|
|
}
|
|
return labels.notePage
|
|
}
|
|
|
|
/**
|
|
* E8 golem15-seo-schema (UI-SPEC C17, D-44): the page's schema type per
|
|
* content kind (option kind), the Service fields and custom JSON-LD with a
|
|
* live parse status. Its value is a JSON string, so E9 reads it through
|
|
* fill; the server checks it again on save.
|
|
*/
|
|
export class SeoSchemaElement extends HTMLElement {
|
|
static observedAttributes = ['options', 'disabled', 'invalid', 'ui-locale']
|
|
|
|
private current: SchemaValue = { type: '', fields: {}, custom: '' }
|
|
private open = false
|
|
private status: JsonStatus = { kind: 'empty' }
|
|
private timer: ReturnType<typeof setTimeout> | undefined
|
|
private fieldErrors: Record<string, string> = {}
|
|
private connected = false
|
|
|
|
get value(): string {
|
|
return serializeValue(this.current)
|
|
}
|
|
|
|
set value(v: unknown) {
|
|
this.current = parseValue(v)
|
|
this.status = checkCustom(this.current.custom)
|
|
this.open = this.current.custom.trim() !== ''
|
|
this.render()
|
|
}
|
|
|
|
connectedCallback(): void {
|
|
this.connected = true
|
|
this.render()
|
|
}
|
|
|
|
disconnectedCallback(): void {
|
|
this.connected = false
|
|
clearTimeout(this.timer)
|
|
}
|
|
|
|
attributeChangedCallback(): void {
|
|
if (this.connected) {
|
|
this.render()
|
|
}
|
|
}
|
|
|
|
private kind(): string {
|
|
const options = jsonAttr<Record<string, unknown>>(this, 'options', {})
|
|
return typeof options.kind === 'string' ? options.kind : ''
|
|
}
|
|
|
|
private labels(): Labels {
|
|
return labelsFor(uiLocaleOf(this))
|
|
}
|
|
|
|
private changed(): void {
|
|
emit(this, 'summer-change', { value: this.value })
|
|
}
|
|
|
|
private effectiveType(): string {
|
|
const types = typesFor(this.kind())
|
|
return types.includes(this.current.type) ? this.current.type : types[0]
|
|
}
|
|
|
|
private render(): void {
|
|
if (!this.connected) {
|
|
return
|
|
}
|
|
const labels = this.labels()
|
|
const disabled = this.hasAttribute('disabled')
|
|
const types = typesFor(this.kind())
|
|
const sections: HTMLElement[] = []
|
|
|
|
// 1. The type row.
|
|
const typeRow = el('div', 'seo-schema__section')
|
|
const selectId = uid('seo-schema-type')
|
|
const label = el('label', 'seo-schema__label', labels.schemaType)
|
|
label.htmlFor = selectId
|
|
if (types.length > 1) {
|
|
const select = el('select', 'seo-schema__select')
|
|
select.id = selectId
|
|
select.disabled = disabled
|
|
const def = el('option', '', fmt(labels.typeDefault, { type: types[0] }))
|
|
def.value = ''
|
|
select.append(def)
|
|
for (const t of types) {
|
|
const opt = el('option', '', typeLabel(labels, t))
|
|
opt.value = t
|
|
select.append(opt)
|
|
}
|
|
select.value = types.includes(this.current.type) ? this.current.type : ''
|
|
select.addEventListener('change', () => {
|
|
this.current.type = select.value
|
|
this.changed()
|
|
this.render()
|
|
})
|
|
typeRow.append(label, select)
|
|
} else {
|
|
const fixed = el('div', 'seo-schema__fixed', typeLabel(labels, types[0]))
|
|
fixed.id = selectId
|
|
typeRow.append(label, fixed)
|
|
}
|
|
typeRow.append(el('p', 'seo-schema__note', noteFor(labels, this.effectiveType())))
|
|
sections.push(typeRow)
|
|
|
|
// 2. The Service fields.
|
|
if (this.effectiveType() === 'Service') {
|
|
const grid = el('div', 'seo-schema__grid')
|
|
const field = (key: keyof ServiceFields, text: string, hint: string, placeholder = '', mode = ''): void => {
|
|
const wrap = el('div', 'seo-schema__cell')
|
|
const id = uid('seo-schema-' + key)
|
|
const l = el('label', 'seo-schema__label', text)
|
|
l.htmlFor = id
|
|
const input = el('input', 'seo-schema__input')
|
|
input.id = id
|
|
input.type = 'text'
|
|
input.disabled = disabled
|
|
input.value = this.current.fields[key] ?? ''
|
|
if (placeholder) {
|
|
input.placeholder = placeholder
|
|
}
|
|
if (mode) {
|
|
input.inputMode = mode
|
|
}
|
|
if (key === 'currency') {
|
|
input.maxLength = 3
|
|
}
|
|
const error = el('p', 'seo-schema__error', this.fieldErrors[key] ?? '')
|
|
error.setAttribute('role', 'status')
|
|
input.addEventListener('input', () => {
|
|
const v = input.value
|
|
const ok = key === 'price' ? validPrice(v) : key === 'currency' ? validCurrency(v) : true
|
|
if (ok) {
|
|
this.current.fields[key] = v
|
|
delete this.fieldErrors[key]
|
|
error.textContent = ''
|
|
this.changed()
|
|
} else {
|
|
this.fieldErrors[key] = key === 'price' ? labels.priceInvalid : labels.currencyInvalid
|
|
error.textContent = this.fieldErrors[key]
|
|
}
|
|
})
|
|
if (key === 'currency') {
|
|
input.addEventListener('blur', () => {
|
|
input.value = input.value.toUpperCase()
|
|
if (validCurrency(input.value)) {
|
|
this.current.fields.currency = input.value
|
|
this.changed()
|
|
}
|
|
})
|
|
}
|
|
wrap.append(l, input)
|
|
if (hint) {
|
|
wrap.append(el('p', 'seo-schema__note', hint))
|
|
}
|
|
wrap.append(error)
|
|
grid.append(wrap)
|
|
}
|
|
field('serviceType', labels.serviceType, '', labels.serviceTypePh)
|
|
field('areaServed', labels.areaServed, labels.areaServedHint)
|
|
field('price', labels.price, labels.priceHint, '', 'decimal')
|
|
field('currency', labels.currency, labels.currencyHint)
|
|
sections.push(grid)
|
|
}
|
|
|
|
// 3. Custom JSON-LD.
|
|
const customSection = el('div', 'seo-schema__section')
|
|
const toggle = el('button', 'seo-schema__disclosure')
|
|
toggle.type = 'button'
|
|
toggle.setAttribute('aria-expanded', String(this.open))
|
|
const panelId = uid('seo-schema-custom')
|
|
toggle.setAttribute('aria-controls', panelId)
|
|
toggle.append(el('span', '', labels.custom), icon('chevron-down'))
|
|
toggle.addEventListener('click', () => {
|
|
this.open = !this.open
|
|
this.render()
|
|
})
|
|
const head = el('div', 'seo-schema__disclosure-row')
|
|
head.append(toggle)
|
|
if (this.current.custom.trim() !== '') {
|
|
head.append(el('span', 'seo-schema__note', labels.customSet))
|
|
}
|
|
customSection.append(head)
|
|
if (this.open) {
|
|
const panel = el('div', 'seo-schema__panel')
|
|
panel.id = panelId
|
|
const areaId = uid('seo-schema-json')
|
|
const hint = el('p', 'seo-schema__note', labels.customHint)
|
|
hint.id = areaId + '-hint'
|
|
const area = el('textarea', 'seo-schema__json')
|
|
area.id = areaId
|
|
area.spellcheck = false
|
|
area.disabled = disabled
|
|
area.value = this.current.custom
|
|
area.setAttribute('aria-label', labels.custom)
|
|
area.setAttribute('aria-describedby', hint.id)
|
|
const line = el('p', 'seo-schema__status')
|
|
line.setAttribute('role', 'status')
|
|
line.setAttribute('aria-live', 'polite')
|
|
this.statusLine(line, labels)
|
|
area.addEventListener('input', () => {
|
|
this.current.custom = area.value
|
|
this.changed()
|
|
clearTimeout(this.timer)
|
|
this.timer = setTimeout(() => {
|
|
this.status = checkCustom(area.value)
|
|
this.statusLine(line, labels)
|
|
}, 300)
|
|
})
|
|
panel.append(hint, area, line)
|
|
customSection.append(panel)
|
|
}
|
|
sections.push(customSection)
|
|
|
|
this.classList.toggle('seo-schema--invalid', this.hasAttribute('invalid'))
|
|
this.replaceChildren(...sections)
|
|
}
|
|
|
|
private statusLine(line: HTMLElement, labels: Labels): void {
|
|
const s = this.status
|
|
line.className = 'seo-schema__status'
|
|
switch (s.kind) {
|
|
case 'empty':
|
|
line.replaceChildren()
|
|
return
|
|
case 'valid':
|
|
line.classList.add('seo-schema__status--ok')
|
|
line.replaceChildren(icon('circle-check'), el('span', '', plural(labels.jsonValid, s.nodes, uiLocaleOf(this))))
|
|
return
|
|
case 'error':
|
|
line.classList.add('seo-schema__status--error')
|
|
line.replaceChildren(icon('circle-alert'), el('span', '', fmt(labels.jsonError, { line: s.line, column: s.column, message: s.message })))
|
|
return
|
|
case 'notObject':
|
|
line.classList.add('seo-schema__status--error')
|
|
line.replaceChildren(icon('circle-alert'), el('span', '', labels.jsonNotObject))
|
|
return
|
|
case 'tooLarge':
|
|
line.classList.add('seo-schema__status--error')
|
|
line.replaceChildren(icon('circle-alert'), el('span', '', labels.jsonTooLarge))
|
|
}
|
|
}
|
|
}
|