Files
sm-seo-plugin/assets/src/elements/links.ts
Jakub Zych 49cc241f58 feat: internal links widget with counts and suggestions
- E10 golem15-seo-links in the SEO tab after the graph preview (types
  with a body): inbound and outbound counts from the last finished crawl,
  the orphan warning, up to 10 pages to link to (Copy address) and 10
  that could link here
- classes/linkgraph.go builds the report from the crawl's pages and
  links; the crawler's orphan suggestions share NormalText
- GET /_seo/api/v1/links and seo_get_link_suggestions; widget action
  seo_links mapped in AdminOperations
- element bundle rebuilt
2026-11-04 16:40:16 +01:00

262 lines
8.3 KiB
TypeScript

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<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 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)
}
}