Files
sm-seo-plugin/assets/src/elements/graph.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

241 lines
7.7 KiB
TypeScript

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', {})
}
}