- 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
262 lines
8.3 KiB
TypeScript
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)
|
|
}
|
|
}
|