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
This commit is contained in:
Jakub Zych
2026-11-04 14:37:49 +01:00
parent 17da132b00
commit 10ff628f6a
10 changed files with 1350 additions and 4 deletions

2
assets/dist/seo.css vendored

File diff suppressed because one or more lines are too long

3
assets/dist/seo.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,240 @@
import { ActionQueue } from './actions'
import { el, fmt, icon, jsonAttr, labelsFor, uiLocaleOf, type Labels } from './labels'
export const GRAPH_TAG = 'golem15-seo-graph'
/** One missing property of a node. */
export interface GraphFinding {
level: 'required' | 'recommended'
property: string
}
/** A node of the graph with its findings. */
export interface GraphNode {
id: string
type: string
findings: GraphFinding[]
rules: boolean
}
/** The seo_graph action's answer (UI-SPEC C18). */
export interface GraphData {
locale: string
url: string | null
graph: Record<string, unknown> | null
nodes: GraphNode[]
}
interface Payload {
op: 'graph'
locale: string
}
/** The Rich Results Test address of a page. */
export function richResultsURL(url: string): string {
return 'https://search.google.com/test/rich-results?url=' + encodeURIComponent(url)
}
/** The fragment of an @id (#article), the whole id when it has none. */
function fragment(id: string): string {
const i = id.indexOf('#')
return i >= 0 ? id.slice(i) : id
}
/**
* E9 golem15-seo-graph (UI-SPEC C18, D-46): the effective @graph of the
* saved page with the unsaved seo_schema value, every node with Google's
* required and recommended findings, the JSON and the Rich Results Test
* link. It asks through summer-action only ({op: graph, locale}), on
* connect, 800 ms after the last fill change and on the ML locale switch.
*/
export class SeoGraphElement extends HTMLElement {
static observedAttributes = ['fill-values', 'busy', 'state', 'ui-locale', 'disabled', 'label']
private data: GraphData | null = null
private failed = false
private pending = false
private showJSON = false
private shownLocale = ''
private lastFill = ''
private timer: ReturnType<typeof setTimeout> | undefined
private connected = false
private readonly actions = new ActionQueue<Payload>(this, () => this.fail())
private readonly onResult = (ev: Event): void => {
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
this.actions.settle()
const data = detail?.data as GraphData | undefined
if (data && Array.isArray(data.nodes)) {
this.data = data
this.failed = false
}
this.pending = this.actions.current !== null
this.render()
}
private readonly onLocale = (ev: Event): void => {
const locale = (ev as CustomEvent).detail
if (typeof locale === 'string' && locale !== '' && locale !== this.shownLocale) {
this.shownLocale = locale
this.check()
}
}
connectedCallback(): void {
this.connected = true
this.lastFill = this.getAttribute('fill-values') ?? ''
this.addEventListener('summer-result', this.onResult)
window.addEventListener('summer:ml-locale', this.onLocale)
this.render()
queueMicrotask(() => {
if (this.connected) {
this.check()
}
})
}
disconnectedCallback(): void {
this.connected = false
this.removeEventListener('summer-result', this.onResult)
window.removeEventListener('summer:ml-locale', this.onLocale)
clearTimeout(this.timer)
this.actions.reset()
this.pending = false
}
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
if (!this.connected) {
return
}
if (name === 'busy' && value === null) {
this.actions.flush()
return
}
if (name === 'state' && value === 'error') {
if (this.actions.settle()) {
this.fail()
}
return
}
if (name === 'fill-values') {
const fill = value ?? ''
if (fill !== this.lastFill) {
this.lastFill = fill
clearTimeout(this.timer)
this.timer = setTimeout(() => this.check(), 800)
}
return
}
this.render()
}
private labels(): Labels {
return labelsFor(uiLocaleOf(this))
}
/** Asks for the graph of the shown locale (one queued request at most). */
check(): void {
clearTimeout(this.timer)
this.pending = true
this.actions.request({ op: 'graph', locale: this.shownLocale })
this.render()
}
private fail(): void {
this.failed = true
this.pending = this.actions.current !== null
this.render()
}
private render(): void {
if (!this.connected) {
return
}
const labels = this.labels()
const sections: HTMLElement[] = []
this.setAttribute('aria-busy', String(this.pending))
const data = this.data
const header = el('p', 'seo-graph__meta')
if (data) {
header.textContent = data.url ? fmt(labels.forLocale, { locale: (data.locale || '').toUpperCase(), url: data.url }) : labels.graphNoUrl
}
if (this.pending) {
const line = el('p', 'seo-graph__meta seo-graph__checking', labels.graphChecking)
line.setAttribute('role', 'status')
sections.push(line)
}
if (data) {
sections.push(header)
const list = el('div', 'seo-graph__nodes')
let problems = 0
for (const node of data.nodes) {
const row = el('div', 'seo-graph__node')
const head = el('div', 'seo-graph__node-head')
head.append(el('span', 'seo-graph__pill', node.type || '—'), el('code', 'seo-graph__id', fragment(node.id || '')))
row.append(head)
if (!node.rules) {
row.append(el('p', 'seo-graph__meta', fmt(labels.noRules, { type: node.type })))
}
for (const f of node.findings ?? []) {
problems++
const required = f.level === 'required'
const line = el('p', 'seo-graph__finding seo-graph__finding--' + (required ? 'required' : 'recommended'))
line.append(icon(required ? 'circle-alert' : 'triangle-alert'), el('span', '', fmt(required ? labels.required : labels.recommended, { property: f.property, type: node.type })))
row.append(line)
}
list.append(row)
}
sections.push(list)
if (problems === 0 && data.nodes.length > 0) {
const ok = el('p', 'seo-graph__ok')
ok.append(icon('circle-check'), el('span', '', labels.allGood))
sections.push(ok)
}
if (data.graph) {
const toggle = el('button', 'seo-graph__disclosure')
toggle.type = 'button'
toggle.setAttribute('aria-expanded', String(this.showJSON))
toggle.append(el('span', '', this.showJSON ? labels.hideJson : labels.showJson), icon('chevron-down'))
toggle.addEventListener('click', () => {
this.showJSON = !this.showJSON
this.render()
})
sections.push(toggle)
if (this.showJSON) {
sections.push(el('pre', 'seo-graph__json', JSON.stringify(data.graph, null, 2)))
}
}
const notes = el('div', 'seo-graph__links')
if (data.url) {
const link = el('a', 'seo-graph__link')
link.href = richResultsURL(data.url)
link.target = '_blank'
link.rel = 'noopener'
link.append(el('span', '', labels.richResults), icon('external-link'))
notes.append(link)
}
notes.append(el('p', 'seo-graph__meta', labels.graphUnsaved), el('p', 'seo-graph__meta', labels.layerNodes))
sections.push(notes)
}
if (this.failed) {
const line = el('p', 'seo-graph__error')
line.setAttribute('role', 'status')
line.append(icon('circle-alert'), el('span', '', labels.graphFailed))
sections.push(line)
}
if (!this.hasAttribute('disabled')) {
const button = el('button', 'seo-graph__button', this.getAttribute('label') || labels.graphAction)
button.type = 'button'
button.addEventListener('click', () => this.check())
sections.push(button)
}
this.replaceChildren(...sections)
}
/** The fill snapshot (seo_schema), for tests. */
fill(): Record<string, unknown> {
return jsonAttr<Record<string, unknown>>(this, 'fill-values', {})
}
}

View File

@@ -1,8 +1,11 @@
import './styles.css'
import { GRAPH_TAG, SeoGraphElement } from './graph'
import { IMAGE_TAG, SeoImageElement } from './image'
import { PARENT_TAG, SeoParentElement } from './parent'
import { ROBOTS_TAG, SeoRobotsElement } from './robots'
import { ROBOTSTESTER_TAG, SeoRobotsTesterElement } from './robotstester'
import { ROBOTSTXT_TAG, SeoRobotsTxtElement } from './robotstxt'
import { SCHEMA_TAG, SeoSchemaElement } from './schema'
import { SERP_TAG, SeoSerpElement } from './serp'
import { SeoTextElement, TEXT_TAG } from './text'
import { SeoTransferElement, TRANSFER_TAG } from './transfer'
@@ -18,6 +21,9 @@ const elements: Array<[string, CustomElementConstructor]> = [
[ROBOTSTXT_TAG, SeoRobotsTxtElement],
[ROBOTSTESTER_TAG, SeoRobotsTesterElement],
[TRANSFER_TAG, SeoTransferElement],
['golem15-seo-schema', SeoSchemaElement],
['golem15-seo-graph', SeoGraphElement],
['golem15-seo-parent', SeoParentElement],
]
for (const [tag, ctor] of elements) {
if (!customElements.get(tag)) {
@@ -26,7 +32,13 @@ for (const [tag, ctor] of elements) {
}
export {
GRAPH_TAG,
IMAGE_TAG,
PARENT_TAG,
SCHEMA_TAG,
SeoGraphElement,
SeoParentElement,
SeoSchemaElement,
ROBOTS_TAG,
ROBOTSTESTER_TAG,
ROBOTSTXT_TAG,

View File

@@ -1,5 +1,5 @@
// Element copy of the SEO field elements (UI-SPEC copywriting E1 to E6,
// plus E7 for the social image), chosen by the ui-locale attribute. Plural
// E7 for the social image, E8 and E9 for structured data), chosen by the ui-locale attribute. Plural
// texts are CLDR maps; placeholders use :name.
/** A CLDR plural map: one/other in English, one/few/many/other in Polish. */
@@ -134,6 +134,58 @@ export interface Labels {
readFailed: string
openHistory: string
applyCount: Plural
// E8 golem15-seo-schema
schemaType: string
typeDefault: string
tBlogPosting: string
tArticle: string
tNewsArticle: string
tWebPage: string
tAboutPage: string
tContactPage: string
tService: string
tCollectionPage: string
tProfilePage: string
noteArticle: string
notePage: string
noteContact: string
noteService: string
noteCollection: string
noteProfile: string
serviceType: string
serviceTypePh: string
areaServed: string
areaServedHint: string
price: string
priceHint: string
currency: string
currencyHint: string
priceInvalid: string
currencyInvalid: string
custom: string
customSet: string
customHint: string
jsonValid: Plural
jsonError: string
jsonNotObject: string
jsonTooLarge: string
// E9 golem15-seo-graph
forLocale: string
graphChecking: string
allGood: string
required: string
recommended: string
noRules: string
showJson: string
hideJson: string
richResults: string
graphNoUrl: string
graphUnsaved: string
layerNodes: string
graphAction: string
graphFailed: string
// golem15-seo-parent
primaryDefault: string
}
const en: Labels = {
@@ -253,6 +305,55 @@ const en: Labels = {
readFailed: 'The file could not be read.',
openHistory: 'Open History',
applyCount: { one: 'Apply :count change', other: 'Apply :count changes' },
schemaType: 'Schema type',
typeDefault: 'Default (:type)',
tBlogPosting: 'BlogPosting (blog post)',
tArticle: 'Article (article)',
tNewsArticle: 'NewsArticle (news)',
tWebPage: 'WebPage (page)',
tAboutPage: 'AboutPage (about us)',
tContactPage: 'ContactPage (contact)',
tService: 'Service (service or offer)',
tCollectionPage: 'CollectionPage (listing)',
tProfilePage: 'ProfilePage (profile)',
noteArticle: 'Author, dates, image, word count and section come from the content.',
notePage: 'Name, description and breadcrumbs come from the SEO tab.',
noteContact: 'Address and opening hours come from the SEO settings (Publisher, Local business).',
noteService: 'The provider is the publisher from the SEO settings.',
noteCollection: 'Lists the items shown on the page.',
noteProfile: 'Name, image and profiles come from the author.',
serviceType: 'Kind of service',
serviceTypePh: 'e.g. Mushroom workshops',
areaServed: 'Area served',
areaServedHint: 'Cities or regions, separated by commas.',
price: 'Price',
priceHint: 'Leave empty when the price is set individually.',
currency: 'Currency',
currencyHint: 'ISO code, e.g. PLN.',
priceInvalid: 'Enter a price such as 150 or 149.99.',
currencyInvalid: 'Use a three-letter currency code, e.g. PLN.',
custom: 'Custom JSON-LD (advanced)',
customSet: 'Set',
customHint: 'An object or a list of objects. A node with the @id of a generated node is merged into it, and your values win; other nodes are added to the graph. Paste only the JSON, without a script tag.',
jsonValid: { one: 'Valid JSON: :count node', other: 'Valid JSON: :count nodes' },
jsonError: 'Line :line, column :column: :message',
jsonNotObject: 'Use an object or a list of objects.',
jsonTooLarge: 'Over 16 KB: shorten the custom JSON-LD.',
forLocale: 'JSON-LD for :locale — :url',
graphChecking: 'Building the graph…',
allGood: 'No problems: every node has its required and recommended properties.',
required: 'Missing required property “:property” (:type)',
recommended: 'Missing recommended property “:property” (:type)',
noRules: ':type has no rich-result rules at Google; it is checked as valid schema.org only.',
showJson: 'Show JSON-LD',
hideJson: 'Hide JSON-LD',
richResults: "Check in Google's Rich Results Test",
graphNoUrl: 'The Rich Results Test link appears after the first save, when the page has an address.',
graphUnsaved: 'Shows the saved page with your unsaved structured data changes.',
layerNodes: 'The site may add nodes of its own, such as FAQPage. The site audit checks the rendered result.',
graphAction: 'Check structured data',
graphFailed: 'The structured data could not be built. Try again.',
primaryDefault: 'Default (first category)',
}
const pl: Labels = {
@@ -382,6 +483,55 @@ const pl: Labels = {
readFailed: 'Nie udało się odczytać pliku.',
openHistory: 'Otwórz Historię zmian',
applyCount: { one: 'Zastosuj :count zmianę', few: 'Zastosuj :count zmiany', many: 'Zastosuj :count zmian', other: 'Zastosuj :count zmiany' },
schemaType: 'Typ schema',
typeDefault: 'Domyślny (:type)',
tBlogPosting: 'BlogPosting (wpis na blogu)',
tArticle: 'Article (artykuł)',
tNewsArticle: 'NewsArticle (wiadomość)',
tWebPage: 'WebPage (strona)',
tAboutPage: 'AboutPage (o nas)',
tContactPage: 'ContactPage (kontakt)',
tService: 'Service (usługa lub oferta)',
tCollectionPage: 'CollectionPage (lista)',
tProfilePage: 'ProfilePage (profil)',
noteArticle: 'Autor, daty, obraz, liczba słów i sekcja pochodzą z treści.',
notePage: 'Nazwa, opis i ścieżka nawigacji pochodzą z zakładki SEO.',
noteContact: 'Adres i godziny otwarcia pochodzą z ustawień SEO (Wydawca, Firma lokalna).',
noteService: 'Usługodawcą jest wydawca z ustawień SEO.',
noteCollection: 'Wymienia elementy pokazane na stronie.',
noteProfile: 'Imię i nazwisko, zdjęcie i profile pochodzą od autora.',
serviceType: 'Rodzaj usługi',
serviceTypePh: 'np. warsztaty grzybowe',
areaServed: 'Obszar działania',
areaServedHint: 'Miasta lub regiony oddzielone przecinkami.',
price: 'Cena',
priceHint: 'Zostaw puste, gdy cena jest ustalana indywidualnie.',
currency: 'Waluta',
currencyHint: 'Kod ISO, np. PLN.',
priceInvalid: 'Wpisz cenę, np. 150 lub 149,99.',
currencyInvalid: 'Użyj trzyliterowego kodu waluty, np. PLN.',
custom: 'Własny JSON-LD (zaawansowane)',
customSet: 'Ustawiony',
customHint: 'Obiekt lub lista obiektów. Węzeł z @id wygenerowanego węzła zostaje z nim scalony, a Twoje wartości mają pierwszeństwo; pozostałe węzły są dodawane do grafu. Wklej sam JSON, bez znacznika script.',
jsonValid: { one: 'Poprawny JSON: :count węzeł', few: 'Poprawny JSON: :count węzły', many: 'Poprawny JSON: :count węzłów', other: 'Poprawny JSON: :count węzła' },
jsonError: 'Wiersz :line, kolumna :column: :message',
jsonNotObject: 'Użyj obiektu lub listy obiektów.',
jsonTooLarge: 'Ponad 16 KB: skróć własny JSON-LD.',
forLocale: 'JSON-LD dla :locale — :url',
graphChecking: 'Budowanie grafu…',
allGood: 'Brak problemów: każdy węzeł ma wymagane i zalecane właściwości.',
required: 'Brak wymaganej właściwości „:property” (:type)',
recommended: 'Brak zalecanej właściwości „:property” (:type)',
noRules: ':type nie ma reguł wyników z elementami rozszerzonymi w Google; sprawdzana jest tylko poprawność schema.org.',
showJson: 'Pokaż JSON-LD',
hideJson: 'Ukryj JSON-LD',
richResults: 'Sprawdź w Teście wyników z elementami rozszerzonymi Google',
graphNoUrl: 'Link do testu pojawi się po pierwszym zapisie, gdy strona będzie miała adres.',
graphUnsaved: 'Pokazuje zapisaną stronę z niezapisanymi zmianami danych strukturalnych.',
layerNodes: 'Witryna może dodać własne węzły, np. FAQPage. Audyt witryny sprawdza wynik na stronie.',
graphAction: 'Sprawdź dane strukturalne',
graphFailed: 'Nie udało się zbudować danych strukturalnych. Spróbuj ponownie.',
primaryDefault: 'Domyślna (pierwsza kategoria)',
}
const dictionaries: Record<string, Labels> = { en, pl }
@@ -441,6 +591,7 @@ const ICONS: Record<string, string[]> = {
'circle-check': ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'm9 12 2 2 4-4'],
'circle-alert': ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'M12 8v4', 'M12 16h.01'],
'chevron-down': ['m6 9 6 6 6-6'],
'external-link': ['M15 3h6v6', 'M10 14 21 3', 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'],
}
/** An aria-hidden inline SVG icon of size px. */

View File

@@ -0,0 +1,97 @@
import { el, emit, labelsFor, uid, uiLocaleOf } from './labels'
export const PARENT_TAG = 'golem15-seo-parent'
/** One parent the record can take as its primary category. */
export interface ParentOption {
value: string
label: string
}
/** The loaded value: the stored choice and the record's parents. */
export interface ParentValue {
selected: string
options: ParentOption[]
}
/** Reads the loaded value (an object) or a plain stored choice. */
export function parseParent(v: unknown): ParentValue {
if (typeof v === 'string') {
return { selected: v, options: [] }
}
const out: ParentValue = { selected: '', options: [] }
if (v && typeof v === 'object') {
const raw = v as { selected?: unknown; options?: unknown }
out.selected = typeof raw.selected === 'string' ? raw.selected : ''
if (Array.isArray(raw.options)) {
for (const o of raw.options) {
if (o && typeof o === 'object' && typeof (o as ParentOption).value === 'string') {
const opt = o as ParentOption
out.options.push({ value: opt.value, label: typeof opt.label === 'string' && opt.label ? opt.label : opt.value })
}
}
}
}
return out
}
/**
* golem15-seo-parent (P33-23, D-86): the primary category of a record, a
* 44px select over the record's parents with the "first category" default.
* The value is the chosen <type>:<id>, "" for the default. A stored choice
* that is no longer one of the parents falls back to the default (P1 says
* so).
*/
export class SeoParentElement extends HTMLElement {
static observedAttributes = ['disabled', 'invalid', 'ui-locale']
private current: ParentValue = { selected: '', options: [] }
get value(): string {
return this.current.options.some((o) => o.value === this.current.selected) ? this.current.selected : ''
}
set value(v: unknown) {
const next = parseParent(v)
if (typeof v === 'string' && this.current.options.length > 0) {
next.options = this.current.options
}
this.current = next
this.render()
}
connectedCallback(): void {
this.render()
}
attributeChangedCallback(): void {
if (this.isConnected) {
this.render()
}
}
private render(): void {
if (!this.isConnected) {
return
}
const labels = labelsFor(uiLocaleOf(this))
const select = el('select', 'seo-parent__select')
select.id = uid('seo-parent')
select.disabled = this.hasAttribute('disabled')
const def = el('option', '', labels.primaryDefault)
def.value = ''
select.append(def)
for (const o of this.current.options) {
const opt = el('option', '', o.label)
opt.value = o.value
select.append(opt)
}
select.value = this.value
select.addEventListener('change', () => {
this.current.selected = select.value
emit(this, 'summer-change', { value: select.value })
})
this.classList.toggle('seo-parent--invalid', this.hasAttribute('invalid'))
this.replaceChildren(select)
}
}

View File

@@ -0,0 +1,384 @@
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))
}
}
}

View File

@@ -850,3 +850,279 @@ golem15-seo-transfer .seo-transfer__actions {
transition: none;
}
}
/* E8 golem15-seo-schema (UI-SPEC C17) */
golem15-seo-schema {
display: flex;
flex-direction: column;
gap: 24px;
}
golem15-seo-schema .seo-schema__section {
display: flex;
flex-direction: column;
gap: 6px;
}
golem15-seo-schema .seo-schema__label {
font-size: 14px;
font-weight: 600;
line-height: 1.5;
color: var(--c-text);
}
golem15-seo-schema .seo-schema__select,
golem15-seo-schema .seo-schema__input,
golem15-seo-parent .seo-parent__select {
box-sizing: border-box;
height: 44px;
padding: 0 14px;
border: 1px solid var(--c-border-strong);
border-radius: 10px;
background: var(--c-surface);
color: var(--c-text);
font: inherit;
font-size: 14px;
transition: border-color 150ms;
}
golem15-seo-schema .seo-schema__select,
golem15-seo-parent .seo-parent__select {
min-width: 280px;
max-width: 100%;
}
golem15-seo-schema .seo-schema__input {
width: 100%;
}
golem15-seo-schema .seo-schema__fixed {
font-size: 14px;
color: var(--c-text);
}
golem15-seo-schema .seo-schema__note,
golem15-seo-graph .seo-graph__meta {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--c-muted);
}
golem15-seo-schema .seo-schema__section > .seo-schema__note {
margin-top: 2px;
}
golem15-seo-schema .seo-schema__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 600px) {
golem15-seo-schema .seo-schema__grid {
grid-template-columns: 1fr;
}
}
golem15-seo-schema .seo-schema__cell {
display: flex;
flex-direction: column;
gap: 6px;
}
golem15-seo-schema .seo-schema__error {
margin: 0;
font-size: 13px;
color: var(--c-danger);
}
golem15-seo-schema .seo-schema__error:empty {
display: none;
}
golem15-seo-schema .seo-schema__disclosure-row {
display: flex;
align-items: center;
gap: 8px;
}
golem15-seo-schema .seo-schema__disclosure,
golem15-seo-graph .seo-graph__disclosure {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0;
border: 0;
background: none;
color: var(--c-text);
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
align-self: flex-start;
}
golem15-seo-schema .seo-schema__disclosure[aria-expanded='true'] .seo-icon,
golem15-seo-graph .seo-graph__disclosure[aria-expanded='true'] .seo-icon {
transform: rotate(180deg);
}
golem15-seo-schema .seo-schema__panel {
display: flex;
flex-direction: column;
gap: 8px;
}
golem15-seo-schema .seo-schema__json {
box-sizing: border-box;
width: 100%;
min-height: 160px;
resize: vertical;
padding: 12px 14px;
border: 1px solid var(--c-border-strong);
border-radius: 10px;
background: var(--c-surface);
color: var(--c-text);
font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
line-height: 1.6;
}
golem15-seo-schema .seo-schema__status,
golem15-seo-graph .seo-graph__finding,
golem15-seo-graph .seo-graph__ok,
golem15-seo-graph .seo-graph__error {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 13px;
line-height: 1.5;
}
golem15-seo-schema .seo-schema__status--ok,
golem15-seo-graph .seo-graph__ok {
color: var(--c-ok-text);
}
golem15-seo-schema .seo-schema__status--error,
golem15-seo-graph .seo-graph__error,
golem15-seo-graph .seo-graph__finding--required .seo-icon {
color: var(--c-danger);
}
golem15-seo-graph .seo-graph__finding {
font-size: 14px;
color: var(--c-text);
}
golem15-seo-schema .seo-schema__select:focus-visible,
golem15-seo-schema .seo-schema__input:focus-visible,
golem15-seo-schema .seo-schema__json:focus-visible,
golem15-seo-schema .seo-schema__disclosure:focus-visible,
golem15-seo-graph .seo-graph__disclosure:focus-visible,
golem15-seo-graph .seo-graph__button:focus-visible,
golem15-seo-graph .seo-graph__link:focus-visible,
golem15-seo-parent .seo-parent__select:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--c-ring);
}
golem15-seo-schema.seo-schema--invalid .seo-schema__json,
golem15-seo-parent.seo-parent--invalid .seo-parent__select {
border-color: var(--c-danger);
}
/* E9 golem15-seo-graph (UI-SPEC C18) */
golem15-seo-graph {
display: flex;
flex-direction: column;
gap: 24px;
}
golem15-seo-graph .seo-graph__nodes {
display: flex;
flex-direction: column;
gap: 16px;
}
golem15-seo-graph .seo-graph__node {
display: flex;
flex-direction: column;
gap: 8px;
}
golem15-seo-graph .seo-graph__node-head {
display: flex;
align-items: center;
gap: 8px;
}
golem15-seo-graph .seo-graph__pill {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 999px;
background: var(--c-subtle);
color: var(--c-muted);
font-size: 12px;
font-weight: 600;
}
golem15-seo-graph .seo-graph__id {
font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
color: var(--c-muted);
}
golem15-seo-graph .seo-graph__json {
box-sizing: border-box;
max-height: 360px;
overflow: auto;
margin: 0;
padding: 12px 14px;
border: 1px solid var(--c-border);
border-radius: 10px;
background: var(--c-subtle);
color: var(--c-text);
font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre;
}
golem15-seo-graph .seo-graph__links {
display: flex;
flex-direction: column;
gap: 8px;
}
golem15-seo-graph .seo-graph__link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--c-primary);
font-size: 14px;
align-self: flex-start;
}
golem15-seo-graph .seo-graph__button {
align-self: flex-start;
height: 42px;
padding: 0 18px;
border: 1px solid var(--c-border-strong);
border-radius: 10px;
background: var(--c-surface);
color: var(--c-text);
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 150ms;
}
golem15-seo-graph .seo-graph__button:hover {
background: var(--c-subtle);
}

View File

@@ -177,7 +177,12 @@ func decodeSchema(app func() *backpack.App) func(context.Context, pact.FormField
// parents as <type>:<id>.
func decodeParent(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
raw, ok := stringValue(in.Value)
value := in.Value
if m, isMap := value.(map[string]any); isMap {
// The loaded {selected, options} sent back unchanged.
value = m["selected"]
}
raw, ok := stringValue(value)
raw = strings.TrimSpace(raw)
if !ok {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.api.value_invalid"}}

View File

@@ -0,0 +1,180 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { checkCustom, parseValue, serializeValue, validCurrency, validPrice } from '../../assets/src/elements/schema'
import { labelsFor } from '../../assets/src/elements/labels'
import { HostFailure, mountWidget, settle } from './host'
// E8 golem15-seo-schema, E9 golem15-seo-graph and golem15-seo-parent
// (UI-SPEC C17, C18, P33-23).
beforeAll(async () => {
await import('../../assets/src/elements/index')
})
afterEach(() => {
vi.useRealTimers()
document.body.replaceChildren()
})
function mountSchema(kind: string, value: string): HTMLElement & { value: string } {
const node = document.createElement('golem15-seo-schema') as HTMLElement & { value: string }
node.setAttribute('ui-locale', 'en')
node.setAttribute('options', JSON.stringify({ kind }))
node.value = value
document.body.append(node)
return node
}
describe('E8 value helpers', () => {
it('round-trips the Service fields as the same JSON string', () => {
const raw = JSON.stringify({ type: 'Service', fields: { serviceType: 'Workshops', areaServed: 'Warszawa', price: '150', currency: 'PLN' }, custom: '' })
expect(serializeValue(parseValue(raw))).toBe(raw)
expect(serializeValue(parseValue(''))).toBe('')
})
it('checks prices, currencies and custom JSON-LD', () => {
expect(validPrice('149,99')).toBe(true)
expect(validPrice('abc')).toBe(false)
expect(validCurrency('PLN')).toBe(true)
expect(validCurrency('ZLOTY')).toBe(false)
expect(checkCustom('{"@type": "Thing"}')).toEqual({ kind: 'valid', nodes: 1 })
expect(checkCustom('[{"a": 1}, {"b": 2}]')).toEqual({ kind: 'valid', nodes: 2 })
expect(checkCustom('42').kind).toBe('notObject')
expect(checkCustom('{"a": "' + 'x'.repeat(17000) + '"}').kind).toBe('tooLarge')
const bad = checkCustom('{\n "a": 1,\n oops\n}')
expect(bad.kind).toBe('error')
if (bad.kind === 'error') {
expect(bad.line).toBe(3)
expect(bad.column).toBeGreaterThan(0)
}
})
})
describe('E8 golem15-seo-schema', () => {
it('renders the article type options with the default and its note, custom collapsed', () => {
const node = mountSchema('article', '')
const options = [...node.querySelectorAll('option')].map((o) => o.textContent)
expect(options).toEqual(['Default (BlogPosting)', 'BlogPosting (blog post)', 'Article (article)', 'NewsArticle (news)'])
expect(node.textContent).toContain(labelsFor('en').noteArticle)
expect(node.querySelector('textarea')).toBeNull()
expect(node.querySelector('.seo-schema__disclosure')!.getAttribute('aria-expanded')).toBe('false')
})
it('shows the Service grid and keeps the last valid price', () => {
const node = mountSchema('page', JSON.stringify({ type: 'Service', fields: { price: '100' }, custom: '' }))
expect(node.querySelectorAll('.seo-schema__grid input')).toHaveLength(4)
const changes: string[] = []
node.addEventListener('summer-change', (e) => changes.push((e as CustomEvent).detail.value))
const price = node.querySelectorAll<HTMLInputElement>('.seo-schema__grid input')[2]
price.value = 'abc'
price.dispatchEvent(new Event('input'))
expect(node.textContent).toContain(labelsFor('en').priceInvalid)
expect(JSON.parse(node.value).fields.price).toBe('100')
price.value = '149,99'
price.dispatchEvent(new Event('input'))
expect(JSON.parse(changes[changes.length - 1]).fields.price).toBe('149,99')
})
it('opens set custom JSON-LD and reports parse errors with line and column', () => {
vi.useFakeTimers()
const node = mountSchema('page', JSON.stringify({ type: '', fields: {}, custom: '{"a": 1}' }))
const area = node.querySelector('textarea')!
expect(area).not.toBeNull()
area.value = '{"a": '
area.dispatchEvent(new Event('input'))
vi.advanceTimersByTime(300)
expect(node.querySelector('.seo-schema__status')!.textContent).toMatch(/^Line 1, column \d+:/)
area.value = '{"x": "' + 'y'.repeat(17000) + '"}'
area.dispatchEvent(new Event('input'))
vi.advanceTimersByTime(300)
expect(node.querySelector('.seo-schema__status')!.textContent).toBe(labelsFor('en').jsonTooLarge)
})
it('shows a single fixed type for collections', () => {
const node = mountSchema('collection', '')
expect(node.querySelector('select')).toBeNull()
expect(node.querySelector('.seo-schema__fixed')!.textContent).toBe('CollectionPage (listing)')
})
})
const answer = {
locale: 'en',
url: 'https://example.test/blog/post',
graph: { '@context': 'https://schema.org', '@graph': [{ '@type': 'BlogPosting' }] },
nodes: [
{ id: 'https://example.test/blog/post#article', type: 'BlogPosting', rules: true, findings: [{ level: 'recommended', property: 'image' }] },
{ id: 'https://example.test/blog/post#video-1', type: 'VideoObject', rules: true, findings: [{ level: 'required', property: 'uploadDate' }] },
{ id: 'https://example.test/blog/post#webpage', type: 'WebPage', rules: false, findings: [] },
],
}
describe('E9 golem15-seo-graph', () => {
it('asks on connect and renders the nodes, findings, JSON and the Rich Results link', async () => {
const host = mountWidget('golem15-seo-graph', { attrs: { 'ui-locale': 'en' }, answer: () => answer })
await settle()
expect(host.posted).toEqual([{ op: 'graph', locale: '' }])
const text = host.node.textContent ?? ''
expect(text).toContain('JSON-LD for EN — https://example.test/blog/post')
expect(text).toContain('Missing recommended property “image” (BlogPosting)')
expect(text).toContain('Missing required property “uploadDate” (VideoObject)')
expect(text).toContain('WebPage has no rich-result rules at Google')
const link = host.node.querySelector<HTMLAnchorElement>('.seo-graph__link')!
expect(link.href).toBe('https://search.google.com/test/rich-results?url=https%3A%2F%2Fexample.test%2Fblog%2Fpost')
expect(link.rel).toBe('noopener')
host.node.querySelector<HTMLButtonElement>('.seo-graph__disclosure')!.click()
expect(host.node.querySelector('pre')!.textContent).toContain('"@context": "https://schema.org"')
expect(host.node.getAttribute('aria-busy')).toBe('false')
})
it('shows allGood without findings, re-checks 800 ms after a fill change and follows the locale', async () => {
const clean = { ...answer, nodes: [{ id: '#webpage', type: 'WebPage', rules: false, findings: [] }] }
const host = mountWidget('golem15-seo-graph', { attrs: { 'ui-locale': 'en' }, answer: () => clean })
await settle()
expect(host.node.textContent).toContain(labelsFor('en').allGood)
host.node.setAttribute('fill-values', JSON.stringify({ seo_schema: '{"type":"Article"}' }))
await new Promise((r) => setTimeout(r, 850))
await settle()
expect(host.posted).toHaveLength(2)
window.dispatchEvent(new CustomEvent('summer:ml-locale', { detail: 'pl' }))
await settle()
expect(host.posted[2]).toEqual({ op: 'graph', locale: 'pl' })
})
it('keeps the last graph and shows the danger line when the action fails', async () => {
let fail = false
const host = mountWidget('golem15-seo-graph', {
attrs: { 'ui-locale': 'en' },
answer: () => {
if (fail) {
throw new HostFailure('nope')
}
return answer
},
})
await settle()
fail = true
host.node.querySelector<HTMLButtonElement>('.seo-graph__button')!.click()
await settle()
expect(host.node.querySelector('.seo-graph__error')!.textContent).toBe(labelsFor('en').graphFailed)
expect(host.node.textContent).toContain('Missing recommended property “image”')
})
})
describe('golem15-seo-parent', () => {
it('lists the options of its value with the default and emits the choice', () => {
const node = document.createElement('golem15-seo-parent') as HTMLElement & { value: unknown }
node.setAttribute('ui-locale', 'en')
node.value = { selected: 'acme-section:2', options: [{ value: 'acme-section:1', label: 'Recipes' }, { value: 'acme-section:2', label: 'Mushrooms' }] }
document.body.append(node)
const select = node.querySelector('select')!
expect([...select.options].map((o) => o.textContent)).toEqual(['Default (first category)', 'Recipes', 'Mushrooms'])
expect(select.value).toBe('acme-section:2')
const changes: string[] = []
node.addEventListener('summer-change', (e) => changes.push((e as CustomEvent).detail.value))
select.value = ''
select.dispatchEvent(new Event('change'))
expect(changes).toEqual([''])
// A stored choice that is no longer a parent falls back to the default.
node.value = { selected: 'acme-section:9', options: [{ value: 'acme-section:1', label: 'Recipes' }] }
expect(node.value).toBe('')
})
})