feat: SEO field elements and bundle

This commit is contained in:
2026-10-27 10:53:59 +01:00
parent ab8905c429
commit deebd9aa65
15 changed files with 3871 additions and 0 deletions

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

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

View 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}`
}

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

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

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