Files
sm-seo-plugin/assets/src/elements/schema.ts
Jakub Zych 10ff628f6a feat: structured data, graph preview and primary category in the SEO tab
- 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
2026-11-04 14:37:49 +01:00

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))
}
}
}