feat: SEO field elements and bundle
This commit is contained in:
121
assets/src/elements/image.ts
Normal file
121
assets/src/elements/image.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { el, emit, labelsFor, uid, uiLocaleOf, type Labels } from './labels'
|
||||
|
||||
export const IMAGE_TAG = 'golem15-seo-image'
|
||||
|
||||
/** Whether a stored image URL may be shown: a path or an http(s) URL. */
|
||||
export function safeImageURL(url: string): boolean {
|
||||
return /^\/(?!\/)/.test(url) || /^https?:\/\//i.test(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* E7 golem15-seo-image (D-100): the social image of the SEO tab, the URL
|
||||
* rule form and the settings. It uploads its own file through the field
|
||||
* type's upload route (summer-upload / summer-upload-result) and keeps the
|
||||
* stored URL as its value; Remove clears it.
|
||||
*/
|
||||
export class SeoImageElement extends HTMLElement {
|
||||
static observedAttributes = ['disabled', 'invalid', 'ui-locale']
|
||||
|
||||
private current = ''
|
||||
private pending = ''
|
||||
private error = ''
|
||||
|
||||
private readonly onUploadResult = (ev: Event): void => {
|
||||
const detail = (ev as CustomEvent).detail as { id?: string; url?: string; error?: string } | undefined
|
||||
if (!detail || detail.id !== this.pending) {
|
||||
return
|
||||
}
|
||||
this.pending = ''
|
||||
if (typeof detail.url === 'string' && detail.url !== '') {
|
||||
this.current = detail.url
|
||||
this.error = ''
|
||||
emit(this, 'summer-change', { value: this.current })
|
||||
} else {
|
||||
this.error = detail.error || this.labels().imageFailed
|
||||
}
|
||||
this.render()
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.current
|
||||
}
|
||||
|
||||
set value(v: unknown) {
|
||||
this.current = typeof v === 'string' ? v : ''
|
||||
this.render()
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.addEventListener('summer-upload-result', this.onUploadResult)
|
||||
this.render()
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.removeEventListener('summer-upload-result', this.onUploadResult)
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
if (this.isConnected) {
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
private labels(): Labels {
|
||||
return labelsFor(uiLocaleOf(this))
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const labels = this.labels()
|
||||
const disabled = this.hasAttribute('disabled')
|
||||
const parts: HTMLElement[] = []
|
||||
if (this.current && safeImageURL(this.current)) {
|
||||
const figure = el('div', 'seo-image__preview')
|
||||
const img = el('img', 'seo-image__img')
|
||||
img.src = this.current
|
||||
img.alt = labels.imageAlt
|
||||
img.width = 240
|
||||
img.height = 126
|
||||
figure.append(img)
|
||||
parts.push(figure)
|
||||
}
|
||||
if (!disabled) {
|
||||
const actions = el('div', 'seo-image__actions')
|
||||
const inputId = uid('seo-image-file')
|
||||
const input = el('input', 'seo-visually-hidden')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp'
|
||||
input.id = inputId
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
this.pending = uid('seo-image-upload')
|
||||
this.error = ''
|
||||
this.render()
|
||||
emit(this, 'summer-upload', { id: this.pending, file })
|
||||
})
|
||||
const choose = el('label', 'seo-image__button', this.current ? labels.imageReplace : labels.imageUpload)
|
||||
choose.htmlFor = inputId
|
||||
actions.append(input, choose)
|
||||
if (this.current) {
|
||||
const remove = el('button', 'seo-image__button seo-image__button--secondary', labels.imageRemove)
|
||||
remove.type = 'button'
|
||||
remove.addEventListener('click', () => {
|
||||
this.current = ''
|
||||
this.error = ''
|
||||
emit(this, 'summer-change', { value: '' })
|
||||
this.render()
|
||||
})
|
||||
actions.append(remove)
|
||||
}
|
||||
parts.push(actions)
|
||||
}
|
||||
const statusLine = el('div', 'seo-image__status' + (this.error ? ' seo-image__status--error' : ''))
|
||||
statusLine.setAttribute('role', 'status')
|
||||
statusLine.textContent = this.pending ? labels.imageUploading : this.error
|
||||
parts.push(statusLine)
|
||||
this.classList.toggle('seo-image--invalid', this.hasAttribute('invalid'))
|
||||
this.replaceChildren(...parts)
|
||||
}
|
||||
}
|
||||
23
assets/src/elements/index.ts
Normal file
23
assets/src/elements/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import './styles.css'
|
||||
import { IMAGE_TAG, SeoImageElement } from './image'
|
||||
import { ROBOTS_TAG, SeoRobotsElement } from './robots'
|
||||
import { SERP_TAG, SeoSerpElement } from './serp'
|
||||
import { SeoTextElement, TEXT_TAG } from './text'
|
||||
|
||||
// The admin SPA loads this script once on every admin page (the SEO field
|
||||
// types list it in Scripts); defining a tag twice would throw, so a second
|
||||
// evaluation is a no-op.
|
||||
if (!customElements.get(TEXT_TAG)) {
|
||||
customElements.define(TEXT_TAG, SeoTextElement)
|
||||
}
|
||||
if (!customElements.get(ROBOTS_TAG)) {
|
||||
customElements.define(ROBOTS_TAG, SeoRobotsElement)
|
||||
}
|
||||
if (!customElements.get(SERP_TAG)) {
|
||||
customElements.define(SERP_TAG, SeoSerpElement)
|
||||
}
|
||||
if (!customElements.get(IMAGE_TAG)) {
|
||||
customElements.define(IMAGE_TAG, SeoImageElement)
|
||||
}
|
||||
|
||||
export { IMAGE_TAG, ROBOTS_TAG, SERP_TAG, SeoImageElement, SeoRobotsElement, SeoSerpElement, SeoTextElement, TEXT_TAG }
|
||||
338
assets/src/elements/labels.ts
Normal file
338
assets/src/elements/labels.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
// Element copy of the SEO field elements (UI-SPEC copywriting E1, E2, E3,
|
||||
// plus E7 for the social image), chosen by the ui-locale attribute. Plural
|
||||
// texts are CLDR maps; placeholders use :name.
|
||||
|
||||
/** A CLDR plural map: one/other in English, one/few/many/other in Polish. */
|
||||
export interface Plural {
|
||||
one: string
|
||||
few?: string
|
||||
many?: string
|
||||
other: string
|
||||
}
|
||||
|
||||
export interface Labels {
|
||||
// E1
|
||||
count: string
|
||||
good: string
|
||||
emptyTitle: string
|
||||
emptyDescription: string
|
||||
shortTitle: string
|
||||
shortDescription: string
|
||||
longTitle: string
|
||||
longDescription: string
|
||||
// E2
|
||||
indexing: string
|
||||
links: string
|
||||
inherit: string
|
||||
siteDefault: string
|
||||
index: string
|
||||
noindex: string
|
||||
follow: string
|
||||
nofollow: string
|
||||
advanced: string
|
||||
advancedSet: Plural
|
||||
maxSnippet: string
|
||||
maxSnippetHint: string
|
||||
maxImagePreview: string
|
||||
notSet: string
|
||||
none: string
|
||||
standard: string
|
||||
large: string
|
||||
maxVideoPreview: string
|
||||
noarchive: string
|
||||
nosnippet: string
|
||||
noimageindex: string
|
||||
unavailableAfter: string
|
||||
invalidNumber: string
|
||||
// E3
|
||||
desktop: string
|
||||
mobile: string
|
||||
device: string
|
||||
language: string
|
||||
fromTemplate: string
|
||||
fromContent: string
|
||||
noDescription: string
|
||||
noUrl: string
|
||||
savedNote: string
|
||||
checking: string
|
||||
dupTitle: Plural
|
||||
dupDescription: Plural
|
||||
andMore: string
|
||||
keyphrase: string
|
||||
kpTitle: string
|
||||
kpTitleNo: string
|
||||
kpH1: string
|
||||
kpH1No: string
|
||||
kpDesc: string
|
||||
kpDescNo: string
|
||||
kpUrl: string
|
||||
kpUrlNo: string
|
||||
kpLength: string
|
||||
kpLengthNo: string
|
||||
contextFailed: string
|
||||
// E7
|
||||
imageUpload: string
|
||||
imageReplace: string
|
||||
imageRemove: string
|
||||
imageUploading: string
|
||||
imageFailed: string
|
||||
imageAlt: string
|
||||
}
|
||||
|
||||
const en: Labels = {
|
||||
count: ':chars characters · :px / :max px',
|
||||
good: 'Good length',
|
||||
emptyTitle: 'Empty: the title template is used',
|
||||
emptyDescription: 'Empty: the template or the excerpt is used',
|
||||
shortTitle: 'Short: aim for 30–60 characters',
|
||||
shortDescription: 'Short: aim for 120–160 characters',
|
||||
longTitle: 'Too long: Google cuts titles after about 580 px',
|
||||
longDescription: 'Too long: Google cuts descriptions after about 920 px on desktop and 680 px on mobile',
|
||||
indexing: 'Indexing',
|
||||
links: 'Links',
|
||||
inherit: 'Inherit',
|
||||
siteDefault: 'Site default',
|
||||
index: 'Index',
|
||||
noindex: 'Noindex',
|
||||
follow: 'Follow',
|
||||
nofollow: 'Nofollow',
|
||||
advanced: 'Advanced directives',
|
||||
advancedSet: { one: ':count set', other: ':count set' },
|
||||
maxSnippet: 'Maximum snippet length (characters)',
|
||||
maxSnippetHint: 'Empty: no limit. 0: no snippet. -1: no limit.',
|
||||
maxImagePreview: 'Image preview size',
|
||||
notSet: 'Not set',
|
||||
none: 'None',
|
||||
standard: 'Standard',
|
||||
large: 'Large',
|
||||
maxVideoPreview: 'Maximum video preview (seconds)',
|
||||
noarchive: 'Do not show a cached copy (noarchive)',
|
||||
nosnippet: 'Do not show a snippet (nosnippet)',
|
||||
noimageindex: 'Do not index images (noimageindex)',
|
||||
unavailableAfter: 'Remove from results after',
|
||||
invalidNumber: 'Enter a whole number, -1 or more.',
|
||||
desktop: 'Desktop',
|
||||
mobile: 'Mobile',
|
||||
device: 'Preview device',
|
||||
language: 'Language: :locale',
|
||||
fromTemplate: 'Title from the template “:template”',
|
||||
fromContent: 'Description from the content excerpt',
|
||||
noDescription: 'No description: Google picks text from the page.',
|
||||
noUrl: 'The address appears after the first save.',
|
||||
savedNote: 'The address and the H1 come from the saved record. Save to refresh them.',
|
||||
checking: 'Checking for duplicates…',
|
||||
dupTitle: { one: 'Same title as :count other page: :list', other: 'Same title as :count other pages: :list' },
|
||||
dupDescription: { one: 'Same description as :count other page: :list', other: 'Same description as :count other pages: :list' },
|
||||
andMore: 'and :count more',
|
||||
keyphrase: 'Focus keyphrase “:phrase”',
|
||||
kpTitle: 'In the SEO title',
|
||||
kpTitleNo: 'Not in the SEO title',
|
||||
kpH1: 'In the H1',
|
||||
kpH1No: 'Not in the H1',
|
||||
kpDesc: 'In the meta description',
|
||||
kpDescNo: 'Not in the meta description',
|
||||
kpUrl: 'In the address',
|
||||
kpUrlNo: 'Not in the address',
|
||||
kpLength: 'Text length: :count words',
|
||||
kpLengthNo: 'Text length: :count words, aim for at least 300',
|
||||
contextFailed: 'The preview data could not be loaded.',
|
||||
imageUpload: 'Upload image',
|
||||
imageReplace: 'Replace image',
|
||||
imageRemove: 'Remove',
|
||||
imageUploading: 'Uploading…',
|
||||
imageFailed: 'The image could not be uploaded.',
|
||||
imageAlt: 'Social image preview',
|
||||
}
|
||||
|
||||
const pl: Labels = {
|
||||
count: 'Znaki: :chars · :px / :max px',
|
||||
good: 'Dobra długość',
|
||||
emptyTitle: 'Puste: używany jest szablon tytułu',
|
||||
emptyDescription: 'Puste: używany jest szablon lub zajawka',
|
||||
shortTitle: 'Krótki: celuj w 30–60 znaków',
|
||||
shortDescription: 'Krótki: celuj w 120–160 znaków',
|
||||
longTitle: 'Za długi: Google ucina tytuły po ok. 580 px',
|
||||
longDescription: 'Za długi: Google ucina opisy po ok. 920 px na komputerze i 680 px na telefonie',
|
||||
indexing: 'Indeksowanie',
|
||||
links: 'Linki',
|
||||
inherit: 'Dziedzicz',
|
||||
siteDefault: 'Domyślne',
|
||||
index: 'Indeksuj',
|
||||
noindex: 'Nie indeksuj',
|
||||
follow: 'Śledź',
|
||||
nofollow: 'Nie śledź',
|
||||
advanced: 'Dyrektywy zaawansowane',
|
||||
advancedSet: { one: 'ustawiona :count', few: 'ustawione :count', many: 'ustawionych :count', other: 'ustawione :count' },
|
||||
maxSnippet: 'Maksymalna długość fragmentu (znaki)',
|
||||
maxSnippetHint: 'Puste: bez limitu. 0: bez fragmentu. -1: bez limitu.',
|
||||
maxImagePreview: 'Rozmiar podglądu obrazu',
|
||||
notSet: 'Nie ustawiono',
|
||||
none: 'Brak',
|
||||
standard: 'Standardowy',
|
||||
large: 'Duży',
|
||||
maxVideoPreview: 'Maksymalny podgląd wideo (sekundy)',
|
||||
noarchive: 'Nie pokazuj kopii (noarchive)',
|
||||
nosnippet: 'Nie pokazuj fragmentu (nosnippet)',
|
||||
noimageindex: 'Nie indeksuj obrazów (noimageindex)',
|
||||
unavailableAfter: 'Usuń z wyników po dniu',
|
||||
invalidNumber: 'Wpisz liczbę całkowitą, -1 lub większą.',
|
||||
desktop: 'Komputer',
|
||||
mobile: 'Telefon',
|
||||
device: 'Urządzenie podglądu',
|
||||
language: 'Język: :locale',
|
||||
fromTemplate: 'Tytuł z szablonu „:template”',
|
||||
fromContent: 'Opis z zajawki treści',
|
||||
noDescription: 'Brak opisu: Google wybierze tekst ze strony.',
|
||||
noUrl: 'Adres pojawi się po pierwszym zapisie.',
|
||||
savedNote: 'Adres i H1 pochodzą z zapisanego rekordu. Zapisz, aby je odświeżyć.',
|
||||
checking: 'Sprawdzanie duplikatów…',
|
||||
dupTitle: {
|
||||
one: 'Ten sam tytuł co :count inna strona: :list',
|
||||
few: 'Ten sam tytuł co :count inne strony: :list',
|
||||
many: 'Ten sam tytuł co :count innych stron: :list',
|
||||
other: 'Ten sam tytuł co :count innej strony: :list',
|
||||
},
|
||||
dupDescription: {
|
||||
one: 'Ten sam opis co :count inna strona: :list',
|
||||
few: 'Ten sam opis co :count inne strony: :list',
|
||||
many: 'Ten sam opis co :count innych stron: :list',
|
||||
other: 'Ten sam opis co :count innej strony: :list',
|
||||
},
|
||||
andMore: 'i :count więcej',
|
||||
keyphrase: 'Fraza kluczowa „:phrase”',
|
||||
kpTitle: 'W tytule SEO',
|
||||
kpTitleNo: 'Brak w tytule SEO',
|
||||
kpH1: 'W nagłówku H1',
|
||||
kpH1No: 'Brak w nagłówku H1',
|
||||
kpDesc: 'W opisie meta',
|
||||
kpDescNo: 'Brak w opisie meta',
|
||||
kpUrl: 'W adresie',
|
||||
kpUrlNo: 'Brak w adresie',
|
||||
kpLength: 'Długość tekstu: :count słów',
|
||||
kpLengthNo: 'Długość tekstu: :count słów, celuj w co najmniej 300',
|
||||
contextFailed: 'Nie udało się wczytać danych podglądu.',
|
||||
imageUpload: 'Wgraj obraz',
|
||||
imageReplace: 'Zmień obraz',
|
||||
imageRemove: 'Usuń',
|
||||
imageUploading: 'Wgrywanie…',
|
||||
imageFailed: 'Nie udało się wgrać obrazu.',
|
||||
imageAlt: 'Podgląd obrazu w mediach społecznościowych',
|
||||
}
|
||||
|
||||
const dictionaries: Record<string, Labels> = { en, pl }
|
||||
|
||||
/** The interface language of an element: its ui-locale, else the page's. */
|
||||
export function uiLocaleOf(el: Element): string {
|
||||
const raw = el.getAttribute('ui-locale') || document.documentElement.lang || 'en'
|
||||
return raw.toLowerCase().split(/[-_]/)[0] || 'en'
|
||||
}
|
||||
|
||||
/** The dictionary of a ui-locale; unknown languages get English. */
|
||||
export function labelsFor(locale: string): Labels {
|
||||
return dictionaries[locale] ?? en
|
||||
}
|
||||
|
||||
/** Replaces :name placeholders, longest names first. */
|
||||
export function fmt(text: string, params: Record<string, string | number> = {}): string {
|
||||
const names = Object.keys(params).sort((a, b) => b.length - a.length)
|
||||
let out = text
|
||||
for (const name of names) {
|
||||
out = out.split(':' + name).join(String(params[name]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Picks the CLDR plural form of count in locale and fills :count. */
|
||||
export function plural(forms: Plural, count: number, locale: string, params: Record<string, string | number> = {}): string {
|
||||
let category: Intl.LDMLPluralRule = 'other'
|
||||
try {
|
||||
category = new Intl.PluralRules(locale).select(count)
|
||||
} catch {
|
||||
category = count === 1 ? 'one' : 'other'
|
||||
}
|
||||
const text = (category === 'one' && forms.one) || (category === 'few' && forms.few) || (category === 'many' && forms.many) || forms.other
|
||||
return fmt(text, { ...params, count: new Intl.NumberFormat(locale).format(count) })
|
||||
}
|
||||
|
||||
/** Formats a number in the ui-locale. */
|
||||
export function num(value: number, locale: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(locale).format(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const SVG = 'http://www.w3.org/2000/svg'
|
||||
|
||||
/** The lucide icons the elements draw, as path data on a 24px grid. */
|
||||
const ICONS: Record<string, string[]> = {
|
||||
info: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'M12 16v-4', 'M12 8h.01'],
|
||||
'triangle-alert': [
|
||||
'm21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3',
|
||||
'M12 9v4',
|
||||
'M12 17h.01',
|
||||
],
|
||||
'circle-check': ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'm9 12 2 2 4-4'],
|
||||
'circle-alert': ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'M12 8v4', 'M12 16h.01'],
|
||||
'chevron-down': ['m6 9 6 6 6-6'],
|
||||
}
|
||||
|
||||
/** An aria-hidden inline SVG icon of size px. */
|
||||
export function icon(name: string, size = 14): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG, 'svg')
|
||||
svg.setAttribute('width', String(size))
|
||||
svg.setAttribute('height', String(size))
|
||||
svg.setAttribute('viewBox', '0 0 24 24')
|
||||
svg.setAttribute('fill', 'none')
|
||||
svg.setAttribute('stroke', 'currentColor')
|
||||
svg.setAttribute('stroke-width', '2')
|
||||
svg.setAttribute('stroke-linecap', 'round')
|
||||
svg.setAttribute('stroke-linejoin', 'round')
|
||||
svg.setAttribute('aria-hidden', 'true')
|
||||
svg.setAttribute('class', 'seo-icon seo-icon--' + name)
|
||||
for (const d of ICONS[name] ?? []) {
|
||||
const path = document.createElementNS(SVG, 'path')
|
||||
path.setAttribute('d', d)
|
||||
svg.appendChild(path)
|
||||
}
|
||||
return svg
|
||||
}
|
||||
|
||||
/** Creates an element with a class and optional text (set as textContent). */
|
||||
export function el<K extends keyof HTMLElementTagNameMap>(tag: K, className = '', text?: string): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag)
|
||||
if (className) {
|
||||
node.className = className
|
||||
}
|
||||
if (text !== undefined) {
|
||||
node.textContent = text
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/** Reads a JSON attribute; invalid or absent JSON gives fallback. */
|
||||
export function jsonAttr<T>(element: Element, name: string, fallback: T): T {
|
||||
const raw = element.getAttribute(name)
|
||||
if (raw === null || raw === '') {
|
||||
return fallback
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatches a bubbling, composed CustomEvent. */
|
||||
export function emit(target: EventTarget, type: string, detail: unknown): void {
|
||||
target.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }))
|
||||
}
|
||||
|
||||
let sequence = 0
|
||||
|
||||
/** A document-unique id for aria references. */
|
||||
export function uid(prefix: string): string {
|
||||
sequence += 1
|
||||
return `${prefix}-${sequence}`
|
||||
}
|
||||
106
assets/src/elements/measure.ts
Normal file
106
assets/src/elements/measure.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// Pixel measurement of result-preview text (D-09, Q-3). Google renders
|
||||
// titles in 20px Arial and descriptions in 14px Arial; the limits are the
|
||||
// widths after which it truncates.
|
||||
|
||||
export const TITLE_FONT = '20px Arial, sans-serif'
|
||||
export const DESCRIPTION_FONT = '14px Arial, sans-serif'
|
||||
export const TITLE_MAX_PX = 580
|
||||
export const DESCRIPTION_MAX_PX = 920
|
||||
export const DESCRIPTION_MOBILE_MAX_PX = 680
|
||||
export const TITLE_SHORT_CHARS = 30
|
||||
export const DESCRIPTION_SHORT_CHARS = 120
|
||||
|
||||
export type Measure = 'title' | 'description'
|
||||
export type Status = 'empty' | 'short' | 'long' | 'good'
|
||||
|
||||
let context: CanvasRenderingContext2D | null | undefined
|
||||
|
||||
function canvas(): CanvasRenderingContext2D | null {
|
||||
if (context !== undefined) {
|
||||
return context
|
||||
}
|
||||
try {
|
||||
const c = document.createElement('canvas')
|
||||
const ctx = c.getContext('2d')
|
||||
context = ctx && typeof ctx.measureText === 'function' ? ctx : null
|
||||
} catch {
|
||||
context = null
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/** Average Arial advance per character class, in em, when no canvas is available. */
|
||||
function estimate(text: string, sizePx: number): number {
|
||||
let em = 0
|
||||
for (const ch of text) {
|
||||
if (/[ilIj.,:;'!|]/.test(ch)) {
|
||||
em += 0.28
|
||||
} else if (/[mwMW@]/.test(ch)) {
|
||||
em += 0.85
|
||||
} else if (/[A-Z]/.test(ch)) {
|
||||
em += 0.67
|
||||
} else if (ch === ' ') {
|
||||
em += 0.28
|
||||
} else {
|
||||
em += 0.53
|
||||
}
|
||||
}
|
||||
return em * sizePx
|
||||
}
|
||||
|
||||
/** The width of text in font, in CSS pixels. */
|
||||
export function textWidth(text: string, font: string): number {
|
||||
const ctx = canvas()
|
||||
if (ctx) {
|
||||
ctx.font = font
|
||||
const width = ctx.measureText(text).width
|
||||
if (Number.isFinite(width) && (width > 0 || text === '')) {
|
||||
return width
|
||||
}
|
||||
}
|
||||
const size = Number.parseFloat(font) || 14
|
||||
return estimate(text, size)
|
||||
}
|
||||
|
||||
/** text cut to fit maxPx with an ellipsis, or text itself when it fits. */
|
||||
export function truncate(text: string, font: string, maxPx: number): string {
|
||||
if (textWidth(text, font) <= maxPx) {
|
||||
return text
|
||||
}
|
||||
const chars = Array.from(text)
|
||||
let lo = 0
|
||||
let hi = chars.length
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2)
|
||||
if (textWidth(chars.slice(0, mid).join('').trimEnd() + '…', font) <= maxPx) {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
return chars.slice(0, lo).join('').trimEnd() + '…'
|
||||
}
|
||||
|
||||
/** The font and pixel limit of a measured field. */
|
||||
export function limits(measure: Measure): { font: string; maxPx: number; shortChars: number } {
|
||||
return measure === 'title'
|
||||
? { font: TITLE_FONT, maxPx: TITLE_MAX_PX, shortChars: TITLE_SHORT_CHARS }
|
||||
: { font: DESCRIPTION_FONT, maxPx: DESCRIPTION_MAX_PX, shortChars: DESCRIPTION_SHORT_CHARS }
|
||||
}
|
||||
|
||||
/** The C4 status of a value: pixels decide "long", characters "short". */
|
||||
export function status(value: string, measure: Measure): { status: Status; chars: number; px: number; maxPx: number } {
|
||||
const { font, maxPx, shortChars } = limits(measure)
|
||||
const trimmed = value.trim()
|
||||
const chars = Array.from(trimmed).length
|
||||
const px = Math.round(textWidth(trimmed, font))
|
||||
let s: Status = 'good'
|
||||
if (trimmed === '') {
|
||||
s = 'empty'
|
||||
} else if (px > maxPx) {
|
||||
s = 'long'
|
||||
} else if (chars < shortChars) {
|
||||
s = 'short'
|
||||
}
|
||||
return { status: s, chars, px, maxPx }
|
||||
}
|
||||
357
assets/src/elements/robots.ts
Normal file
357
assets/src/elements/robots.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { el, emit, icon, jsonAttr, labelsFor, plural, uid, uiLocaleOf, type Labels } from './labels'
|
||||
|
||||
export const ROBOTS_TAG = 'golem15-seo-robots'
|
||||
|
||||
/**
|
||||
* A robots value (UI-SPEC C5): comma-separated directives in the fixed
|
||||
* order index|noindex, follow|nofollow, noarchive, nosnippet, noimageindex,
|
||||
* max-snippet:N, max-image-preview:none|standard|large,
|
||||
* max-video-preview:N, unavailable_after:YYYY-MM-DD. The grammar is the
|
||||
* same as classes/robots_value.go; empty inherits everything.
|
||||
*/
|
||||
export interface Robots {
|
||||
index: '' | 'index' | 'noindex'
|
||||
follow: '' | 'follow' | 'nofollow'
|
||||
noarchive: boolean
|
||||
nosnippet: boolean
|
||||
noimageindex: boolean
|
||||
maxSnippet: number | null
|
||||
maxImagePreview: '' | 'none' | 'standard' | 'large'
|
||||
maxVideoPreview: number | null
|
||||
unavailableAfter: string
|
||||
}
|
||||
|
||||
export function emptyRobots(): Robots {
|
||||
return {
|
||||
index: '',
|
||||
follow: '',
|
||||
noarchive: false,
|
||||
nosnippet: false,
|
||||
noimageindex: false,
|
||||
maxSnippet: null,
|
||||
maxImagePreview: '',
|
||||
maxVideoPreview: null,
|
||||
unavailableAfter: '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a robots value; null when it is malformed. */
|
||||
export function parseRobots(value: string): Robots | null {
|
||||
const r = emptyRobots()
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '') {
|
||||
return r
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
for (const part of trimmed.split(',')) {
|
||||
const d = part.trim().toLowerCase()
|
||||
if (d === '') {
|
||||
return null
|
||||
}
|
||||
const colon = d.indexOf(':')
|
||||
const name = (colon < 0 ? d : d.slice(0, colon)).trim()
|
||||
const arg = colon < 0 ? null : d.slice(colon + 1).trim()
|
||||
const group = name === 'noindex' ? 'index' : name === 'nofollow' ? 'follow' : name
|
||||
if (seen.has(group)) {
|
||||
return null
|
||||
}
|
||||
seen.add(group)
|
||||
switch (name) {
|
||||
case 'index':
|
||||
case 'noindex':
|
||||
if (arg !== null) return null
|
||||
r.index = name
|
||||
break
|
||||
case 'follow':
|
||||
case 'nofollow':
|
||||
if (arg !== null) return null
|
||||
r.follow = name
|
||||
break
|
||||
case 'noarchive':
|
||||
case 'nosnippet':
|
||||
case 'noimageindex':
|
||||
if (arg !== null) return null
|
||||
r[name] = true
|
||||
break
|
||||
case 'max-snippet':
|
||||
case 'max-video-preview': {
|
||||
if (arg === null || !/^-?\d+$/.test(arg)) return null
|
||||
const n = Number.parseInt(arg, 10)
|
||||
if (n < -1) return null
|
||||
if (name === 'max-snippet') r.maxSnippet = n
|
||||
else r.maxVideoPreview = n
|
||||
break
|
||||
}
|
||||
case 'max-image-preview':
|
||||
if (arg !== 'none' && arg !== 'standard' && arg !== 'large') return null
|
||||
r.maxImagePreview = arg
|
||||
break
|
||||
case 'unavailable_after':
|
||||
if (arg === null || !/^\d{4}-\d{2}-\d{2}$/.test(arg) || Number.isNaN(Date.parse(arg + 'T00:00:00Z'))) return null
|
||||
r.unavailableAfter = arg
|
||||
break
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
/** Serialises a robots value in the fixed order, joined by ",". */
|
||||
export function serializeRobots(r: Robots): string {
|
||||
const out: string[] = []
|
||||
if (r.index) out.push(r.index)
|
||||
if (r.follow) out.push(r.follow)
|
||||
if (r.noarchive) out.push('noarchive')
|
||||
if (r.nosnippet) out.push('nosnippet')
|
||||
if (r.noimageindex) out.push('noimageindex')
|
||||
if (r.maxSnippet !== null) out.push('max-snippet:' + r.maxSnippet)
|
||||
if (r.maxImagePreview) out.push('max-image-preview:' + r.maxImagePreview)
|
||||
if (r.maxVideoPreview !== null) out.push('max-video-preview:' + r.maxVideoPreview)
|
||||
if (r.unavailableAfter) out.push('unavailable_after:' + r.unavailableAfter)
|
||||
return out.join(',')
|
||||
}
|
||||
|
||||
/** The number of advanced directives set. */
|
||||
export function advancedCount(r: Robots): number {
|
||||
return [r.noarchive, r.nosnippet, r.noimageindex, r.maxSnippet !== null, r.maxImagePreview !== '', r.maxVideoPreview !== null, r.unavailableAfter !== ''].filter(Boolean).length
|
||||
}
|
||||
|
||||
/**
|
||||
* E2 golem15-seo-robots (UI-SPEC C5): two segmented radio groups
|
||||
* (indexing, links) and an advanced disclosure; the value is the robots
|
||||
* string, emitted with summer-change on every edit.
|
||||
*/
|
||||
export class SeoRobotsElement extends HTMLElement {
|
||||
static observedAttributes = ['options', 'disabled', 'invalid', 'ui-locale', 'locale']
|
||||
|
||||
private current = ''
|
||||
private model: Robots = emptyRobots()
|
||||
private open = false
|
||||
|
||||
get value(): string {
|
||||
return this.current
|
||||
}
|
||||
|
||||
set value(v: unknown) {
|
||||
const next = typeof v === 'string' ? v : v == null ? '' : String(v)
|
||||
if (next === this.current && this.childElementCount > 0) {
|
||||
return
|
||||
}
|
||||
this.current = next
|
||||
this.model = parseRobots(next) ?? emptyRobots()
|
||||
this.open = this.open || advancedCount(this.model) > 0
|
||||
this.render()
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.open = advancedCount(this.model) > 0
|
||||
this.render()
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
if (this.isConnected) {
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
private labels(): Labels {
|
||||
return labelsFor(uiLocaleOf(this))
|
||||
}
|
||||
|
||||
private commit(): void {
|
||||
this.current = serializeRobots(this.model)
|
||||
emit(this, 'summer-change', { value: this.current })
|
||||
this.render()
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const labels = this.labels()
|
||||
const disabled = this.hasAttribute('disabled')
|
||||
const options = jsonAttr<Record<string, unknown>>(this, 'options', {})
|
||||
const inheritLabel = options.scope === 'type' ? labels.siteDefault : labels.inherit
|
||||
this.classList.toggle('seo-robots--invalid', this.hasAttribute('invalid'))
|
||||
const focused = document.activeElement && this.contains(document.activeElement) ? (document.activeElement as HTMLElement).dataset.focusKey : undefined
|
||||
|
||||
const groups = el('div', 'seo-robots__groups')
|
||||
groups.append(
|
||||
this.radioGroup(labels.indexing, 'index', [
|
||||
['', inheritLabel, 'inherit'],
|
||||
['index', labels.index, 'ok'],
|
||||
['noindex', labels.noindex, 'danger'],
|
||||
], disabled),
|
||||
this.radioGroup(labels.links, 'follow', [
|
||||
['', inheritLabel, 'inherit'],
|
||||
['follow', labels.follow, 'ok'],
|
||||
['nofollow', labels.nofollow, 'danger'],
|
||||
], disabled),
|
||||
)
|
||||
|
||||
const panelId = uid('seo-robots-advanced')
|
||||
const toggle = el('button', 'seo-robots__toggle')
|
||||
toggle.type = 'button'
|
||||
toggle.setAttribute('aria-expanded', this.open ? 'true' : 'false')
|
||||
toggle.setAttribute('aria-controls', panelId)
|
||||
toggle.dataset.focusKey = 'toggle'
|
||||
toggle.append(el('span', '', labels.advanced), icon('chevron-down'))
|
||||
toggle.addEventListener('click', () => {
|
||||
this.open = !this.open
|
||||
this.render()
|
||||
})
|
||||
const row = el('div', 'seo-robots__advanced-row')
|
||||
row.append(toggle)
|
||||
const count = advancedCount(this.model)
|
||||
if (count > 0) {
|
||||
row.append(el('span', 'seo-robots__summary', plural(labels.advancedSet, count, uiLocaleOf(this))))
|
||||
}
|
||||
|
||||
const panel = el('div', 'seo-robots__panel')
|
||||
panel.id = panelId
|
||||
panel.hidden = !this.open
|
||||
panel.append(
|
||||
this.numberField(labels.maxSnippet, labels.maxSnippetHint, 'maxSnippet', disabled, labels),
|
||||
this.selectField(labels, disabled),
|
||||
this.numberField(labels.maxVideoPreview, '', 'maxVideoPreview', disabled, labels),
|
||||
this.dateField(labels, disabled),
|
||||
)
|
||||
const checks = el('div', 'seo-robots__checks')
|
||||
for (const key of ['noarchive', 'nosnippet', 'noimageindex'] as const) {
|
||||
const label = el('label', 'seo-robots__check')
|
||||
const box = el('input')
|
||||
box.type = 'checkbox'
|
||||
box.checked = this.model[key]
|
||||
box.disabled = disabled
|
||||
box.dataset.focusKey = key
|
||||
box.addEventListener('change', () => {
|
||||
this.model[key] = box.checked
|
||||
this.commit()
|
||||
})
|
||||
label.append(box, el('span', '', labels[key]))
|
||||
checks.append(label)
|
||||
}
|
||||
panel.append(checks)
|
||||
|
||||
this.replaceChildren(groups, row, panel)
|
||||
if (focused) {
|
||||
this.querySelector<HTMLElement>(`[data-focus-key="${focused}"]`)?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
private radioGroup(caption: string, key: 'index' | 'follow', items: [string, string, string][], disabled: boolean): HTMLElement {
|
||||
const wrap = el('div', 'seo-robots__group')
|
||||
const captionId = uid('seo-robots-caption')
|
||||
const cap = el('span', 'seo-robots__caption', caption)
|
||||
cap.id = captionId
|
||||
const track = el('div', 'seo-robots__track')
|
||||
track.setAttribute('role', 'radiogroup')
|
||||
track.setAttribute('aria-labelledby', captionId)
|
||||
const current = this.model[key]
|
||||
items.forEach(([value, text, tone], i) => {
|
||||
const seg = el('button', 'seo-robots__segment seo-robots__segment--' + tone, text)
|
||||
seg.type = 'button'
|
||||
seg.setAttribute('role', 'radio')
|
||||
const checked = current === value
|
||||
seg.setAttribute('aria-checked', checked ? 'true' : 'false')
|
||||
seg.tabIndex = checked ? 0 : -1
|
||||
seg.disabled = disabled
|
||||
seg.dataset.focusKey = key + '-' + i
|
||||
seg.addEventListener('click', () => this.choose(key, value))
|
||||
seg.addEventListener('keydown', (ev) => {
|
||||
const delta = ev.key === 'ArrowRight' || ev.key === 'ArrowDown' ? 1 : ev.key === 'ArrowLeft' || ev.key === 'ArrowUp' ? -1 : 0
|
||||
if (delta === 0 || disabled) {
|
||||
return
|
||||
}
|
||||
ev.preventDefault()
|
||||
const next = (i + delta + items.length) % items.length
|
||||
this.choose(key, items[next][0])
|
||||
this.querySelector<HTMLElement>(`[data-focus-key="${key}-${next}"]`)?.focus()
|
||||
})
|
||||
track.append(seg)
|
||||
})
|
||||
wrap.append(cap, track)
|
||||
return wrap
|
||||
}
|
||||
|
||||
private choose(key: 'index' | 'follow', value: string): void {
|
||||
if (this.hasAttribute('disabled')) {
|
||||
return
|
||||
}
|
||||
if (key === 'index') {
|
||||
this.model.index = value as Robots['index']
|
||||
} else {
|
||||
this.model.follow = value as Robots['follow']
|
||||
}
|
||||
this.commit()
|
||||
}
|
||||
|
||||
private numberField(caption: string, hint: string, key: 'maxSnippet' | 'maxVideoPreview', disabled: boolean, labels: Labels): HTMLElement {
|
||||
const wrap = el('label', 'seo-robots__field')
|
||||
wrap.append(el('span', 'seo-robots__label', caption))
|
||||
const input = el('input', 'seo-robots__input')
|
||||
input.type = 'number'
|
||||
input.min = '-1'
|
||||
input.step = '1'
|
||||
input.disabled = disabled
|
||||
input.dataset.focusKey = key
|
||||
const v = this.model[key]
|
||||
input.value = v === null ? '' : String(v)
|
||||
const error = el('span', 'seo-robots__error')
|
||||
error.setAttribute('role', 'status')
|
||||
input.addEventListener('change', () => {
|
||||
const raw = input.value.trim()
|
||||
if (raw === '') {
|
||||
this.model[key] = null
|
||||
this.commit()
|
||||
return
|
||||
}
|
||||
if (!/^-?\d+$/.test(raw) || Number.parseInt(raw, 10) < -1) {
|
||||
error.textContent = labels.invalidNumber
|
||||
return
|
||||
}
|
||||
this.model[key] = Number.parseInt(raw, 10)
|
||||
this.commit()
|
||||
})
|
||||
wrap.append(input)
|
||||
if (hint) {
|
||||
wrap.append(el('span', 'seo-robots__hint', hint))
|
||||
}
|
||||
wrap.append(error)
|
||||
return wrap
|
||||
}
|
||||
|
||||
private selectField(labels: Labels, disabled: boolean): HTMLElement {
|
||||
const wrap = el('label', 'seo-robots__field')
|
||||
wrap.append(el('span', 'seo-robots__label', labels.maxImagePreview))
|
||||
const select = el('select', 'seo-robots__input')
|
||||
select.disabled = disabled
|
||||
select.dataset.focusKey = 'maxImagePreview'
|
||||
for (const [value, text] of [['', labels.notSet], ['none', labels.none], ['standard', labels.standard], ['large', labels.large]] as const) {
|
||||
const option = el('option', '', text)
|
||||
option.value = value
|
||||
option.selected = this.model.maxImagePreview === value
|
||||
select.append(option)
|
||||
}
|
||||
select.addEventListener('change', () => {
|
||||
this.model.maxImagePreview = select.value as Robots['maxImagePreview']
|
||||
this.commit()
|
||||
})
|
||||
wrap.append(select)
|
||||
return wrap
|
||||
}
|
||||
|
||||
private dateField(labels: Labels, disabled: boolean): HTMLElement {
|
||||
const wrap = el('label', 'seo-robots__field')
|
||||
wrap.append(el('span', 'seo-robots__label', labels.unavailableAfter))
|
||||
const input = el('input', 'seo-robots__input')
|
||||
input.type = 'date'
|
||||
input.disabled = disabled
|
||||
input.dataset.focusKey = 'unavailableAfter'
|
||||
input.value = this.model.unavailableAfter
|
||||
input.addEventListener('change', () => {
|
||||
this.model.unavailableAfter = /^\d{4}-\d{2}-\d{2}$/.test(input.value) ? input.value : ''
|
||||
this.commit()
|
||||
})
|
||||
wrap.append(input)
|
||||
return wrap
|
||||
}
|
||||
}
|
||||
434
assets/src/elements/serp.ts
Normal file
434
assets/src/elements/serp.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { el, emit, fmt, icon, jsonAttr, labelsFor, plural, uiLocaleOf, type Labels } from './labels'
|
||||
import { DESCRIPTION_FONT, DESCRIPTION_MAX_PX, DESCRIPTION_MOBILE_MAX_PX, TITLE_FONT, TITLE_MAX_PX, truncate } from './measure'
|
||||
|
||||
export const SERP_TAG = 'golem15-seo-serp'
|
||||
|
||||
/** One locale of the seo_snippet context answer. */
|
||||
export interface ContextLocale {
|
||||
locale: string
|
||||
url: string | null
|
||||
h1: string | null
|
||||
siteName: string
|
||||
titleTemplate: string
|
||||
defaultTitle: string
|
||||
defaultDescription: string | null
|
||||
descriptionSource: string
|
||||
bodyWords: number | null
|
||||
}
|
||||
|
||||
interface ContextData {
|
||||
op: 'context'
|
||||
host: string
|
||||
locales: ContextLocale[]
|
||||
}
|
||||
|
||||
interface DuplicateItem {
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface DuplicatesData {
|
||||
op: 'duplicates'
|
||||
locale: string
|
||||
title: DuplicateItem[]
|
||||
description: DuplicateItem[]
|
||||
}
|
||||
|
||||
type Payload = { op: 'context' } | { op: 'duplicates'; locale: string; title: string; description: string }
|
||||
|
||||
const DUPLICATE_DELAY_MS = 800
|
||||
const MAX_LINKS = 3
|
||||
const LENGTH_WORDS = 300
|
||||
|
||||
/** Fills a title template the way classes/templates.go does. */
|
||||
export function renderTemplate(tpl: string, vars: { title: string; description?: string; site: string }): string {
|
||||
if (tpl.trim() === '') {
|
||||
return ''
|
||||
}
|
||||
const out = tpl
|
||||
.split('%title%').join(vars.title)
|
||||
.split('%description%').join(vars.description ?? '')
|
||||
.split('%site%').join(vars.site)
|
||||
return out.split(/\s+/).filter(Boolean).join(' ').replace(/^[\s|\-–—·:,]+|[\s|\-–—·:,]+$/gu, '')
|
||||
}
|
||||
|
||||
/** Lowercases and strips diacritics (ł becomes l) for keyphrase checks. */
|
||||
export function normalizeText(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/ł/g, 'l')
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
}
|
||||
|
||||
/** The words of a text after normalizeText. */
|
||||
export function words(s: string): string[] {
|
||||
return normalizeText(s).split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Q-4 stem rule: two words match when equal, or when both have at least 5
|
||||
* characters and share a prefix of at least max(5, shorter length − 3)
|
||||
* characters ("grzyby" matches "grzybów").
|
||||
*/
|
||||
export function wordsMatch(a: string, b: string): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
const ca = Array.from(a)
|
||||
const cb = Array.from(b)
|
||||
if (ca.length < 5 || cb.length < 5) {
|
||||
return false
|
||||
}
|
||||
const need = Math.max(5, Math.min(ca.length, cb.length) - 3)
|
||||
let shared = 0
|
||||
while (shared < ca.length && shared < cb.length && ca[shared] === cb[shared]) {
|
||||
shared++
|
||||
}
|
||||
return shared >= need
|
||||
}
|
||||
|
||||
/** Whether the phrase's words appear in text in order (C6 matching). */
|
||||
export function phraseIn(phrase: string, text: string): boolean {
|
||||
const p = words(phrase)
|
||||
if (p.length === 0) {
|
||||
return false
|
||||
}
|
||||
const t = words(text)
|
||||
for (let start = 0; start + p.length <= t.length; start++) {
|
||||
let ok = true
|
||||
for (let i = 0; i < p.length; i++) {
|
||||
if (!wordsMatch(p[i], t[start + i])) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** A locale map or a plain value read for one locale. */
|
||||
function valueIn(fill: Record<string, unknown>, key: string, locale: string): string {
|
||||
const v = fill[key]
|
||||
if (typeof v === 'string') {
|
||||
return v
|
||||
}
|
||||
if (v && typeof v === 'object') {
|
||||
const text = (v as Record<string, unknown>)[locale]
|
||||
return typeof text === 'string' ? text : ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* E3 golem15-seo-serp (UI-SPEC C6): the live search-result preview widget
|
||||
* of the SEO tab and the URL rule form, talking to the seo_snippet widget
|
||||
* action through summer-action only.
|
||||
*/
|
||||
export class SeoSerpElement extends HTMLElement {
|
||||
static observedAttributes = ['fill-values', 'busy', 'state', 'record-id', 'ui-locale', 'locale', 'disabled']
|
||||
|
||||
private context: ContextData | null = null
|
||||
private contextFailed = false
|
||||
private device: 'desktop' | 'mobile' = 'desktop'
|
||||
private shownLocale = ''
|
||||
private inflight: Payload | null = null
|
||||
private queued: Payload | null = null
|
||||
private duplicates = new Map<string, DuplicatesData>()
|
||||
private checking = false
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
private lastChecked = ''
|
||||
private connected = false
|
||||
|
||||
private readonly onResult = (ev: Event): void => {
|
||||
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
|
||||
const data = detail?.data as ContextData | DuplicatesData | undefined
|
||||
this.inflight = null
|
||||
if (data && data.op === 'context') {
|
||||
this.context = data
|
||||
this.contextFailed = false
|
||||
this.render()
|
||||
this.scheduleDuplicates(true)
|
||||
} else if (data && data.op === 'duplicates') {
|
||||
this.duplicates.set(data.locale, data)
|
||||
this.checking = false
|
||||
this.render()
|
||||
}
|
||||
this.flush()
|
||||
}
|
||||
|
||||
private readonly onLocale = (ev: Event): void => {
|
||||
const locale = (ev as CustomEvent).detail
|
||||
if (typeof locale === 'string' && locale !== '' && locale !== this.shownLocale) {
|
||||
this.shownLocale = locale
|
||||
this.render()
|
||||
this.scheduleDuplicates(false)
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.connected = true
|
||||
this.addEventListener('summer-result', this.onResult)
|
||||
window.addEventListener('summer:ml-locale', this.onLocale)
|
||||
this.render()
|
||||
this.request({ op: 'context' })
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.connected = false
|
||||
this.removeEventListener('summer-result', this.onResult)
|
||||
window.removeEventListener('summer:ml-locale', this.onLocale)
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
||||
if (!this.connected) {
|
||||
return
|
||||
}
|
||||
if (name === 'busy' && value === null) {
|
||||
this.flush()
|
||||
return
|
||||
}
|
||||
if (name === 'state' && value === 'error') {
|
||||
if (this.inflight?.op === 'context') {
|
||||
this.contextFailed = true
|
||||
}
|
||||
this.checking = false
|
||||
this.inflight = null
|
||||
this.render()
|
||||
return
|
||||
}
|
||||
if (name === 'fill-values') {
|
||||
this.render()
|
||||
this.scheduleDuplicates(false)
|
||||
return
|
||||
}
|
||||
this.render()
|
||||
}
|
||||
|
||||
private labels(): Labels {
|
||||
return labelsFor(uiLocaleOf(this))
|
||||
}
|
||||
|
||||
/** Sends payload, or queues it (one at most) while a request runs. */
|
||||
private request(payload: Payload): void {
|
||||
if (this.hasAttribute('busy') || this.inflight) {
|
||||
this.queued = payload
|
||||
return
|
||||
}
|
||||
this.inflight = payload
|
||||
emit(this, 'summer-action', { payload })
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (this.hasAttribute('busy') || this.inflight || !this.queued) {
|
||||
return
|
||||
}
|
||||
const next = this.queued
|
||||
this.queued = null
|
||||
this.request(next)
|
||||
}
|
||||
|
||||
private fill(): Record<string, unknown> {
|
||||
return jsonAttr<Record<string, unknown>>(this, 'fill-values', {})
|
||||
}
|
||||
|
||||
private locale(): ContextLocale | null {
|
||||
const locales = this.context?.locales ?? []
|
||||
if (locales.length === 0) {
|
||||
return null
|
||||
}
|
||||
return locales.find((l) => l.locale === this.shownLocale) ?? locales[0]
|
||||
}
|
||||
|
||||
private scheduleDuplicates(now: boolean): void {
|
||||
const loc = this.locale()
|
||||
if (!loc) {
|
||||
return
|
||||
}
|
||||
const fill = this.fill()
|
||||
const title = valueIn(fill, 'seo_title', loc.locale) || valueIn(fill, 'title', loc.locale)
|
||||
const description = valueIn(fill, 'seo_description', loc.locale) || valueIn(fill, 'description', loc.locale)
|
||||
const key = JSON.stringify([loc.locale, title, description])
|
||||
if (key === this.lastChecked) {
|
||||
return
|
||||
}
|
||||
clearTimeout(this.timer)
|
||||
const send = (): void => {
|
||||
this.lastChecked = key
|
||||
this.checking = true
|
||||
this.render()
|
||||
this.request({ op: 'duplicates', locale: loc.locale, title, description })
|
||||
}
|
||||
if (now) {
|
||||
send()
|
||||
} else {
|
||||
this.timer = setTimeout(send, DUPLICATE_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const labels = this.labels()
|
||||
const ui = uiLocaleOf(this)
|
||||
if (!this.context && !this.contextFailed) {
|
||||
this.setAttribute('aria-busy', 'true')
|
||||
this.replaceChildren(el('div', 'seo-serp__skeleton'))
|
||||
return
|
||||
}
|
||||
this.removeAttribute('aria-busy')
|
||||
const loc = this.locale()
|
||||
const localeCode = loc?.locale ?? this.shownLocale
|
||||
const fill = this.fill()
|
||||
const seoTitle = (valueIn(fill, 'seo_title', localeCode) || valueIn(fill, 'title', localeCode)).trim()
|
||||
const seoDescription = (valueIn(fill, 'seo_description', localeCode) || valueIn(fill, 'description', localeCode)).trim()
|
||||
const keyphrase = valueIn(fill, 'seo_keyphrase', localeCode).trim()
|
||||
const siteName = loc?.siteName ?? ''
|
||||
const title = seoTitle || renderTemplate(loc?.titleTemplate ?? '', { title: loc?.defaultTitle ?? '', site: siteName }) || loc?.defaultTitle || ''
|
||||
const description = seoDescription || loc?.defaultDescription || ''
|
||||
|
||||
const header = el('div', 'seo-serp__header')
|
||||
const track = el('div', 'seo-serp__track')
|
||||
track.setAttribute('role', 'radiogroup')
|
||||
track.setAttribute('aria-label', labels.device)
|
||||
for (const device of ['desktop', 'mobile'] as const) {
|
||||
const seg = el('button', 'seo-serp__segment', labels[device])
|
||||
seg.type = 'button'
|
||||
seg.setAttribute('role', 'radio')
|
||||
seg.setAttribute('aria-checked', this.device === device ? 'true' : 'false')
|
||||
seg.addEventListener('click', () => {
|
||||
this.device = device
|
||||
this.render()
|
||||
})
|
||||
track.append(seg)
|
||||
}
|
||||
header.append(track)
|
||||
if (localeCode) {
|
||||
header.append(el('span', 'seo-serp__language', fmt(labels.language, { locale: localeCode.toUpperCase() })))
|
||||
}
|
||||
|
||||
const card = el('div', 'serp-card' + (this.device === 'mobile' ? ' serp-card--mobile' : ''))
|
||||
const top = el('div', 'serp-card__site')
|
||||
top.append(el('span', 'serp-card__favicon', Array.from(siteName || this.context?.host || '?')[0]?.toUpperCase() ?? '?'))
|
||||
const names = el('div', 'serp-card__names')
|
||||
names.append(el('span', 'serp-card__sitename', siteName || this.context?.host || ''))
|
||||
names.append(el('span', 'serp-card__crumbs', breadcrumb(this.context?.host ?? '', loc?.url ?? null)))
|
||||
top.append(names)
|
||||
card.append(top)
|
||||
card.append(el('div', 'serp-card__title', truncate(title, TITLE_FONT, TITLE_MAX_PX)))
|
||||
if (description) {
|
||||
const max = this.device === 'mobile' ? DESCRIPTION_MOBILE_MAX_PX : DESCRIPTION_MAX_PX
|
||||
card.append(el('div', 'serp-card__description', truncate(description, DESCRIPTION_FONT, max)))
|
||||
} else {
|
||||
card.append(el('div', 'serp-card__description serp-card__description--missing', labels.noDescription))
|
||||
}
|
||||
|
||||
const notes = el('div', 'seo-serp__notes')
|
||||
if (this.contextFailed) {
|
||||
const fail = el('div', 'seo-serp__error')
|
||||
fail.setAttribute('role', 'status')
|
||||
fail.append(icon('circle-alert'), el('span', '', labels.contextFailed))
|
||||
notes.append(fail)
|
||||
}
|
||||
if (!seoTitle && loc?.titleTemplate) {
|
||||
notes.append(el('div', '', fmt(labels.fromTemplate, { template: loc.titleTemplate })))
|
||||
}
|
||||
if (!seoDescription && loc?.defaultDescription && loc.descriptionSource === 'content') {
|
||||
notes.append(el('div', '', labels.fromContent))
|
||||
}
|
||||
if (loc && loc.url === null) {
|
||||
notes.append(el('div', '', labels.noUrl))
|
||||
}
|
||||
if ((this.getAttribute('record-id') ?? '') !== '' && loc) {
|
||||
notes.append(el('div', '', labels.savedNote))
|
||||
}
|
||||
|
||||
const checks = el('div', 'seo-serp__checks')
|
||||
checks.setAttribute('role', 'status')
|
||||
if (this.checking) {
|
||||
checks.append(el('div', 'seo-serp__checking', labels.checking))
|
||||
} else {
|
||||
const dup = this.duplicates.get(localeCode)
|
||||
if (dup) {
|
||||
for (const [items, forms] of [[dup.title, labels.dupTitle], [dup.description, labels.dupDescription]] as const) {
|
||||
if (items.length > 0) {
|
||||
checks.append(duplicateLine(items, forms, labels, ui))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parts: HTMLElement[] = [header, card, notes, checks]
|
||||
if (keyphrase && loc) {
|
||||
parts.push(keyphraseChecks(keyphrase, { title, description, h1: loc.h1, url: loc.url, bodyWords: loc.bodyWords }, labels, ui))
|
||||
}
|
||||
this.replaceChildren(...parts)
|
||||
}
|
||||
}
|
||||
|
||||
/** host › segment › segment of a path. */
|
||||
function breadcrumb(host: string, url: string | null): string {
|
||||
const segments = (url ?? '').split('?')[0].split('/').filter(Boolean).map((s) => {
|
||||
try {
|
||||
return decodeURIComponent(s)
|
||||
} catch {
|
||||
return s
|
||||
}
|
||||
})
|
||||
return [host, ...segments].filter(Boolean).join(' › ')
|
||||
}
|
||||
|
||||
function duplicateLine(items: DuplicateItem[], forms: Labels['dupTitle'], labels: Labels, ui: string): HTMLElement {
|
||||
const line = el('div', 'seo-serp__check seo-serp__check--warn')
|
||||
line.append(icon('triangle-alert'))
|
||||
const text = el('span')
|
||||
const marker = '\u0000list\u0000'
|
||||
const sentence = plural(forms, items.length, ui, { list: marker })
|
||||
const [before, after] = sentence.split(marker)
|
||||
text.append(document.createTextNode(before ?? ''))
|
||||
items.slice(0, MAX_LINKS).forEach((item, i) => {
|
||||
if (i > 0) {
|
||||
text.append(document.createTextNode(', '))
|
||||
}
|
||||
if (item.url.startsWith('/') && !item.url.startsWith('//')) {
|
||||
const a = el('a', 'seo-serp__link', item.label)
|
||||
a.href = item.url
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener'
|
||||
text.append(a)
|
||||
} else {
|
||||
text.append(document.createTextNode(item.label))
|
||||
}
|
||||
})
|
||||
if (items.length > MAX_LINKS) {
|
||||
text.append(document.createTextNode(' ' + fmt(labels.andMore, { count: items.length - MAX_LINKS })))
|
||||
}
|
||||
text.append(document.createTextNode(after ?? ''))
|
||||
line.append(text)
|
||||
return line
|
||||
}
|
||||
|
||||
function keyphraseChecks(phrase: string, page: { title: string; description: string; h1: string | null; url: string | null; bodyWords: number | null }, labels: Labels, ui: string): HTMLElement {
|
||||
const box = el('div', 'seo-serp__keyphrase')
|
||||
box.append(el('div', 'seo-serp__keyphrase-title', fmt(labels.keyphrase, { phrase })))
|
||||
const line = (ok: boolean, yes: string, no: string): void => {
|
||||
const row = el('div', 'seo-serp__check ' + (ok ? 'seo-serp__check--ok' : 'seo-serp__check--warn'))
|
||||
row.append(icon(ok ? 'circle-check' : 'triangle-alert'), el('span', '', ok ? yes : no))
|
||||
box.append(row)
|
||||
}
|
||||
line(phraseIn(phrase, page.title), labels.kpTitle, labels.kpTitleNo)
|
||||
if (page.h1 !== null) {
|
||||
line(phraseIn(phrase, page.h1), labels.kpH1, labels.kpH1No)
|
||||
}
|
||||
line(phraseIn(phrase, page.description), labels.kpDesc, labels.kpDescNo)
|
||||
if (page.url !== null) {
|
||||
line(phraseIn(phrase, page.url.replace(/[-_/]+/g, ' ')), labels.kpUrl, labels.kpUrlNo)
|
||||
}
|
||||
if (page.bodyWords !== null) {
|
||||
const count = new Intl.NumberFormat(ui).format(page.bodyWords)
|
||||
line(page.bodyWords >= LENGTH_WORDS, fmt(labels.kpLength, { count }), fmt(labels.kpLengthNo, { count }))
|
||||
}
|
||||
return box
|
||||
}
|
||||
459
assets/src/elements/styles.css
Normal file
459
assets/src/elements/styles.css
Normal file
@@ -0,0 +1,459 @@
|
||||
/* SEO field elements (UI-SPEC C4, C5, C6, C14). Light DOM, every rule
|
||||
scoped under its element tag, colours from the public --c-* variables
|
||||
only (except the E-1 mimic palette inside golem15-seo-serp .serp-card). */
|
||||
|
||||
golem15-seo-text,
|
||||
golem15-seo-robots,
|
||||
golem15-seo-serp,
|
||||
golem15-seo-image {
|
||||
display: block;
|
||||
color: var(--c-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
golem15-seo-text .seo-visually-hidden,
|
||||
golem15-seo-image .seo-visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
golem15-seo-text .seo-text__control {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
transition: border-color 150ms, box-shadow 150ms;
|
||||
}
|
||||
|
||||
golem15-seo-text .seo-text__control--area {
|
||||
height: auto;
|
||||
min-height: 96px;
|
||||
padding: 10px 14px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
golem15-seo-text .seo-text__control:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px var(--c-ring);
|
||||
}
|
||||
|
||||
golem15-seo-text.seo-text--invalid .seo-text__control {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
golem15-seo-text .seo-text__counter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-seo-text .seo-text__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
golem15-seo-text .seo-text__icon {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
golem15-seo-text .seo-text__count {
|
||||
color: var(--c-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
golem15-seo-text[data-status='empty'] .seo-text__status {
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
golem15-seo-text[data-status='good'] .seo-text__status {
|
||||
color: var(--c-ok-text, var(--c-text));
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__groups {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__caption,
|
||||
golem15-seo-robots .seo-robots__summary,
|
||||
golem15-seo-robots .seo-robots__hint {
|
||||
color: var(--c-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__track,
|
||||
golem15-seo-serp .seo-serp__track {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 12px;
|
||||
background: var(--c-subtle);
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__segment,
|
||||
golem15-seo-serp .seo-serp__segment {
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--c-muted);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms, color 150ms;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__segment[aria-checked='true'],
|
||||
golem15-seo-serp .seo-serp__segment[aria-checked='true'] {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
box-shadow: var(--shadow-tab, none);
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__segment--ok[aria-checked='true'] {
|
||||
background: var(--c-ok-bg, var(--c-surface));
|
||||
color: var(--c-ok-text, var(--c-text));
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__segment--danger[aria-checked='true'] {
|
||||
background: var(--c-danger-soft);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__segment:focus-visible,
|
||||
golem15-seo-robots .seo-robots__toggle:focus-visible,
|
||||
golem15-seo-serp .seo-serp__segment:focus-visible,
|
||||
golem15-seo-serp .seo-serp__link:focus-visible,
|
||||
golem15-seo-image .seo-image__button:focus-visible {
|
||||
outline: 3px solid var(--c-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__advanced-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__toggle[aria-expanded='true'] .seo-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__input {
|
||||
box-sizing: border-box;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px var(--c-ring);
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__error {
|
||||
color: var(--c-danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__checks {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-robots .seo-robots__check input {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
accent-color: var(--c-primary);
|
||||
}
|
||||
|
||||
golem15-seo-robots.seo-robots--invalid .seo-robots__track {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__skeleton {
|
||||
max-width: 600px;
|
||||
height: 132px;
|
||||
border-radius: 12px;
|
||||
background: var(--c-skel);
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__language,
|
||||
golem15-seo-serp .seo-serp__notes {
|
||||
color: var(--c-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-seo-serp .serp-card {
|
||||
--serp-bg: #ffffff;
|
||||
--serp-site: #202124;
|
||||
--serp-title: #1a0dab;
|
||||
--serp-text: #4d5156;
|
||||
box-sizing: border-box;
|
||||
max-width: 600px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 12px;
|
||||
background: var(--serp-bg);
|
||||
font-family: Arial, sans-serif;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
golem15-seo-serp .serp-card--mobile {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
golem15-seo-serp .serp-card__site {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
golem15-seo-serp .serp-card__favicon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 50%;
|
||||
background: var(--c-subtle);
|
||||
color: var(--c-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
golem15-seo-serp .serp-card__names {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--serp-site);
|
||||
}
|
||||
|
||||
golem15-seo-serp .serp-card__sitename {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
golem15-seo-serp .serp-card__crumbs {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
golem15-seo-serp .serp-card__title {
|
||||
margin-top: 6px;
|
||||
color: var(--serp-title);
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
golem15-seo-serp .serp-card--mobile .serp-card__title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
golem15-seo-serp .serp-card__description {
|
||||
margin-top: 4px;
|
||||
color: var(--serp-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
golem15-seo-serp .serp-card__description--missing {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__error {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__checks,
|
||||
golem15-seo-serp .seo-serp__keyphrase {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__checking {
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__keyphrase-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__check {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__check--ok {
|
||||
color: var(--c-ok-text, var(--c-text));
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__check .seo-icon {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
golem15-seo-serp .seo-serp__link {
|
||||
color: var(--c-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
golem15-seo-image .seo-image__preview {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-image .seo-image__img {
|
||||
display: block;
|
||||
width: 240px;
|
||||
height: 126px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 10px;
|
||||
background: var(--c-subtle);
|
||||
}
|
||||
|
||||
golem15-seo-image .seo-image__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-image .seo-image__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
golem15-seo-image .seo-image__status {
|
||||
margin-top: 6px;
|
||||
color: var(--c-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-seo-image .seo-image__status--error {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
golem15-seo-image.seo-image--invalid .seo-image__img {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
golem15-seo-robots .seo-robots__panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
golem15-seo-serp .serp-card,
|
||||
golem15-seo-serp .serp-card--mobile {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
golem15-seo-text .seo-text__control,
|
||||
golem15-seo-robots .seo-robots__segment,
|
||||
golem15-seo-serp .seo-serp__segment {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
160
assets/src/elements/text.ts
Normal file
160
assets/src/elements/text.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { el, emit, fmt, icon, jsonAttr, labelsFor, num, uid, uiLocaleOf, type Labels } from './labels'
|
||||
import { status, type Measure, type Status } from './measure'
|
||||
|
||||
export const TEXT_TAG = 'golem15-seo-text'
|
||||
|
||||
const STATUS_ICON: Record<Status, string> = {
|
||||
empty: 'info',
|
||||
short: 'triangle-alert',
|
||||
long: 'triangle-alert',
|
||||
good: 'circle-check',
|
||||
}
|
||||
|
||||
/**
|
||||
* E1 golem15-seo-text (UI-SPEC C4): a text input (measure: title) or a
|
||||
* textarea (measure: description) with a live character and pixel counter
|
||||
* and a length status. The value is a plain string; the element emits
|
||||
* summer-change with the right-trimmed value on every input.
|
||||
*/
|
||||
export class SeoTextElement extends HTMLElement {
|
||||
static observedAttributes = ['options', 'disabled', 'invalid', 'ui-locale', 'locale']
|
||||
|
||||
private current = ''
|
||||
private control: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||
private statusIcon: HTMLSpanElement | null = null
|
||||
private statusText: HTMLSpanElement | null = null
|
||||
private countText: HTMLSpanElement | null = null
|
||||
private live: HTMLSpanElement | null = null
|
||||
private lastStatus: Status | null = null
|
||||
private builtFor = ''
|
||||
|
||||
get value(): string {
|
||||
return this.current
|
||||
}
|
||||
|
||||
set value(v: unknown) {
|
||||
this.current = typeof v === 'string' ? v : v == null ? '' : String(v)
|
||||
if (this.control && this.control.value !== this.current) {
|
||||
this.control.value = this.current
|
||||
}
|
||||
this.update(false)
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.build()
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string): void {
|
||||
if (!this.isConnected) {
|
||||
return
|
||||
}
|
||||
if (name === 'options' || name === 'ui-locale') {
|
||||
this.build()
|
||||
return
|
||||
}
|
||||
this.sync()
|
||||
}
|
||||
|
||||
private measure(): Measure {
|
||||
const options = jsonAttr<Record<string, unknown>>(this, 'options', {})
|
||||
return options.measure === 'description' ? 'description' : 'title'
|
||||
}
|
||||
|
||||
private labels(): Labels {
|
||||
return labelsFor(uiLocaleOf(this))
|
||||
}
|
||||
|
||||
private build(): void {
|
||||
const key = this.measure() + '|' + uiLocaleOf(this)
|
||||
if (this.builtFor === key && this.control) {
|
||||
this.sync()
|
||||
return
|
||||
}
|
||||
this.builtFor = key
|
||||
this.replaceChildren()
|
||||
const counterId = uid('seo-text-counter')
|
||||
const measure = this.measure()
|
||||
const control = measure === 'description' ? el('textarea', 'seo-text__control seo-text__control--area') : el('input', 'seo-text__control')
|
||||
if (control instanceof HTMLTextAreaElement) {
|
||||
control.rows = 3
|
||||
} else {
|
||||
control.type = 'text'
|
||||
}
|
||||
control.value = this.current
|
||||
control.setAttribute('aria-describedby', counterId)
|
||||
const label = this.getAttribute('field-name')
|
||||
if (label) {
|
||||
control.setAttribute('aria-label', label)
|
||||
}
|
||||
control.addEventListener('input', () => {
|
||||
if (this.hasAttribute('disabled')) {
|
||||
return
|
||||
}
|
||||
this.current = control.value.replace(/\s+$/u, '')
|
||||
this.update(true)
|
||||
emit(this, 'summer-change', { value: this.current })
|
||||
})
|
||||
const counter = el('div', 'seo-text__counter')
|
||||
counter.id = counterId
|
||||
const start = el('span', 'seo-text__status')
|
||||
this.statusIcon = el('span', 'seo-text__icon')
|
||||
this.statusText = el('span', 'seo-text__status-text')
|
||||
start.append(this.statusIcon, this.statusText)
|
||||
this.countText = el('span', 'seo-text__count')
|
||||
counter.append(start, this.countText)
|
||||
this.live = el('span', 'seo-visually-hidden')
|
||||
this.live.setAttribute('aria-live', 'polite')
|
||||
this.live.setAttribute('role', 'status')
|
||||
this.control = control
|
||||
this.lastStatus = null
|
||||
this.append(control, counter, this.live)
|
||||
this.sync()
|
||||
}
|
||||
|
||||
private sync(): void {
|
||||
if (!this.control) {
|
||||
return
|
||||
}
|
||||
const disabled = this.hasAttribute('disabled')
|
||||
this.control.readOnly = disabled
|
||||
this.classList.toggle('seo-text--invalid', this.hasAttribute('invalid'))
|
||||
this.control.setAttribute('aria-invalid', this.hasAttribute('invalid') ? 'true' : 'false')
|
||||
this.update(false)
|
||||
}
|
||||
|
||||
private update(announce: boolean): void {
|
||||
if (!this.statusText || !this.countText || !this.statusIcon) {
|
||||
return
|
||||
}
|
||||
const labels = this.labels()
|
||||
const locale = uiLocaleOf(this)
|
||||
const measure = this.measure()
|
||||
const result = status(this.current, measure)
|
||||
const text = statusText(labels, result.status, measure)
|
||||
this.statusIcon.replaceChildren(icon(STATUS_ICON[result.status]))
|
||||
this.statusText.textContent = text
|
||||
this.dataset.status = result.status
|
||||
this.countText.textContent = fmt(labels.count, {
|
||||
chars: num(result.chars, locale),
|
||||
px: num(result.px, locale),
|
||||
max: num(result.maxPx, locale),
|
||||
})
|
||||
if (announce && this.live && result.status !== this.lastStatus) {
|
||||
this.live.textContent = text
|
||||
}
|
||||
this.lastStatus = result.status
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(labels: Labels, s: Status, measure: Measure): string {
|
||||
switch (s) {
|
||||
case 'empty':
|
||||
return measure === 'title' ? labels.emptyTitle : labels.emptyDescription
|
||||
case 'short':
|
||||
return measure === 'title' ? labels.shortTitle : labels.shortDescription
|
||||
case 'long':
|
||||
return measure === 'title' ? labels.longTitle : labels.longDescription
|
||||
default:
|
||||
return labels.good
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user