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 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)['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 | undefined private fieldErrors: Record = {} 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>(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)) } } }