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 | 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 | undefined private connected = false private readonly actions = new ActionQueue(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 { return jsonAttr>(this, 'fill-values', {}) } }