- 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
241 lines
7.7 KiB
TypeScript
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', {})
|
|
}
|
|
}
|