import { ActionQueue } from './actions' import { el, fmt, icon, labelsFor, num, uiLocaleOf, type Labels } from './labels' export const LINKS_TAG = 'golem15-seo-links' /** A page this text could link to. */ export interface LinkOut { phrase: string title: string url: string } /** A page that could link here. */ export interface LinkIn { phrase: string title: string adminUrl: string } /** The seo_links action's answer (UI-SPEC C19). */ export interface LinksData { crawledAt: string | null inbound: number | null outbound: number | null orphan: boolean out: LinkOut[] in: LinkIn[] } interface Payload { op: 'links' locale: string } /** Whether the page can write to the clipboard (a secure context). */ function canCopy(): boolean { return typeof navigator !== 'undefined' && !!navigator.clipboard && typeof navigator.clipboard.writeText === 'function' } /** A crawl time in the ui-locale. */ export function crawlTime(value: string, locale: string): string { const d = new Date(value) if (Number.isNaN(d.getTime())) { return value } try { return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d) } catch { return d.toISOString() } } /** * E10 golem15-seo-links (UI-SPEC C19, D-82): the page's inbound and * outbound link counts from the last crawl, the orphan warning, up to 10 * pages this text could link to (with Copy address) and 10 pages that * could link here. It asks through summer-action only ({op: links, * locale}), on connect and on the ML locale switch, and changes nothing * itself. */ export class SeoLinksElement extends HTMLElement { static observedAttributes = ['busy', 'state', 'ui-locale', 'disabled'] private data: LinksData | null = null private failed = false private pending = false private shownLocale = '' private copied = '' private copiedTimer: 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 LinksData | undefined if (data && Array.isArray(data.out) && Array.isArray(data.in)) { 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.load() } } connectedCallback(): void { this.connected = true this.addEventListener('summer-result', this.onResult) window.addEventListener('summer:ml-locale', this.onLocale) this.render() queueMicrotask(() => { if (this.connected) { this.load() } }) } disconnectedCallback(): void { this.connected = false this.removeEventListener('summer-result', this.onResult) window.removeEventListener('summer:ml-locale', this.onLocale) clearTimeout(this.copiedTimer) 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 } this.render() } private labels(): Labels { return labelsFor(uiLocaleOf(this)) } /** Asks for the links of the shown locale (one queued request at most). */ load(): void { this.pending = true this.actions.request({ op: 'links', locale: this.shownLocale }) this.render() } private fail(): void { this.failed = true this.pending = this.actions.current !== null this.render() } /** Writes url to the clipboard and says so for 2 s. */ private copy(url: string): void { if (!canCopy()) { return } void navigator.clipboard.writeText(url).then(() => { this.copied = url clearTimeout(this.copiedTimer) this.copiedTimer = setTimeout(() => { this.copied = '' this.render() }, 2000) this.render() }, () => undefined) } /** The suggestion line “:phrase” → :title with the title as a link (or text). */ private line(labels: Labels, phrase: string, title: string, href: string): HTMLElement { const line = el('span', 'seo-links__line') const [before, after = ''] = labels.linksPhrase.split(':title') line.append(fmt(before ?? '', { phrase })) if (href) { const a = el('a', 'seo-links__title', title) a.href = href a.target = '_blank' a.rel = 'noopener' line.append(a) } else { line.append(el('span', 'seo-links__title', title)) } line.append(fmt(after, { phrase })) return line } private render(): void { if (!this.connected) { return } const labels = this.labels() const locale = uiLocaleOf(this) const sections: HTMLElement[] = [] this.setAttribute('aria-busy', String(this.pending)) if (this.pending) { const line = el('p', 'seo-links__meta seo-links__checking', labels.linksChecking) line.setAttribute('role', 'status') sections.push(line) } const data = this.data if (data) { const counts = el('div', 'seo-links__counts') if (data.crawledAt !== null && data.inbound !== null && data.outbound !== null) { const stats = el('div', 'summer-stats') for (const [value, label] of [ [data.inbound, labels.linksInbound], [data.outbound, labels.linksOutbound], ] as Array<[number, string]>) { const stat = el('div', 'summer-stat') stat.append(el('span', 'summer-stat__value', num(value, locale)), el('span', 'summer-stat__label', label)) stats.append(stat) } counts.append(stats, el('p', 'seo-links__meta', fmt(labels.linksCrawled, { time: crawlTime(data.crawledAt, locale) }))) } else { counts.append(el('p', 'seo-links__meta seo-links__not-crawled', labels.linksNotCrawled)) } sections.push(counts) if (data.orphan) { const callout = el('div', 'summer-callout summer-callout--warning seo-links__orphan') callout.append(el('p', 'summer-callout__title', labels.linksOrphanTitle), el('p', 'summer-callout__text', labels.linksOrphanText)) sections.push(callout) } const copyable = canCopy() const out = el('div', 'seo-links__list seo-links__list--out') out.append(el('p', 'seo-links__heading', labels.linksOut)) for (const s of data.out.slice(0, 10)) { const row = el('div', 'seo-links__row') row.append(this.line(labels, s.phrase, s.title, s.url)) if (copyable && !this.hasAttribute('disabled')) { const button = el('button', 'seo-links__copy', labels.linksCopy) button.type = 'button' button.addEventListener('click', () => this.copy(s.url)) row.append(button) } else { row.append(el('code', 'seo-links__url', s.url)) } out.append(row) } if (data.out.length === 0) { out.append(el('p', 'seo-links__meta seo-links__none', labels.linksNone)) } sections.push(out) const inbound = el('div', 'seo-links__list seo-links__list--in') inbound.append(el('p', 'seo-links__heading', labels.linksIn)) for (const s of data.in.slice(0, 10)) { const row = el('div', 'seo-links__row') row.append(this.line(labels, s.phrase, s.title, s.adminUrl)) inbound.append(row) } if (data.in.length === 0) { inbound.append(el('p', 'seo-links__meta seo-links__none', labels.linksNone)) } sections.push(inbound) } if (this.failed) { const line = el('p', 'seo-links__error') line.setAttribute('role', 'status') line.append(icon('circle-alert'), el('span', '', labels.linksFailed)) sections.push(line) } const status = el('p', 'seo-links__status', this.copied ? labels.linksCopied : '') status.setAttribute('role', 'status') sections.push(status) this.replaceChildren(...sections) } }