Files
sm-seo-plugin/assets/src/elements/labels.ts
Jakub Zych 3ecbd1f711 fix: the result preview and the robots.txt tester survive a dropped first action (CR-04)
Both elements sent their first summer-action from connectedCallback and
kept it in flight forever when the host dropped it (the v0.5.1 widget host
appends the element before it keeps a reference). A shared ActionQueue no
longer trusts that an emitted action was taken: one the host did not mark
busy synchronously is retried once on the next task, a second drop shows
an error, and a disconnect forgets the request in flight. The first
request is sent on a microtask. Element tests drive a fake host that
mirrors WidgetField's contract, including the append-first ordering, and
the robots.txt tester gets its own tests.
2026-10-27 15:24:33 +01:00

498 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Element copy of the SEO field elements (UI-SPEC copywriting E1 to E6,
// 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
// E4
size: string
clean: string
unknown: string
unknownSuggest: string
noGroup: string
badPath: string
noColon: string
ownSitemap: string
crawlDelay: string
moreFindings: string
tooLarge: string
// E5
url: string
urlPlaceholder: string
agent: string
allowedRule: string
allowedNone: string
blocked: string
blockedBot: string
unsaved: string
stagingNote: string
urlRequired: string
testFailed: string
// E6
exportHeading: string
importHeading: string
dataset: string
dsRedirects: string
dsRules: string
dsRecords: string
download: string
chooseFile: string
noFile: string
summary: string
bNew: string
bChanged: string
bUnchanged: string
bRefused: string
bWarning: string
colLine: string
colChange: string
colAddress: string
colField: string
colBefore: string
colAfter: string
colNote: string
rowCap: string
refusedBlocks: string
nothing: string
discard: string
fileTooLarge: string
readFailed: string
openHistory: string
applyCount: Plural
}
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',
size: ':size of 500 KB',
clean: 'No problems found.',
unknown: 'Line :line: “:word” is not a robots.txt directive.',
unknownSuggest: 'Line :line: “:word” is not a robots.txt directive. Did you mean “:suggestion”?',
noGroup: 'Line :line: this rule has no User-agent line above it, so crawlers ignore it.',
badPath: 'Line :line: a path must start with / or *.',
noColon: 'Line :line: write the directive as “Name: value”.',
ownSitemap: 'Line :line: Sitemap lines are added automatically. This one is kept as written.',
crawlDelay: 'Line :line: Google ignores Crawl-delay. Bing and Yandex use it.',
moreFindings: 'and :count more',
tooLarge: 'Over 500 KB: the file cannot be saved.',
url: 'Address',
urlPlaceholder: '/konto/ustawienia',
agent: 'Crawler',
allowedRule: 'Allowed for :agent. Rule: line :line “:rule”.',
allowedNone: 'Allowed for :agent: no rule matches this address.',
blocked: 'Blocked for :agent by line :line “:rule”.',
blockedBot: 'Blocked for :agent by its switch on the AI bots tab.',
unsaved: 'Tests the text in the editor and the AI bot switches, including unsaved changes.',
stagingNote: 'On this environment every address is blocked. The result shows the production behaviour.',
urlRequired: 'Enter an address starting with /.',
testFailed: 'The test could not be run. Try again.',
exportHeading: 'Export',
importHeading: 'Import',
dataset: 'Data',
dsRedirects: 'Redirects',
dsRules: 'URL rules',
dsRecords: 'SEO of records',
download: 'Download CSV',
chooseFile: 'Choose CSV file',
noFile: 'No file chosen',
summary: ':new new, :changed changed, :unchanged unchanged, :refused refused',
bNew: 'New',
bChanged: 'Changed',
bUnchanged: 'Unchanged',
bRefused: 'Refused',
bWarning: 'Warning',
colLine: 'Line',
colChange: 'Change',
colAddress: 'Address',
colField: 'Field',
colBefore: 'Before',
colAfter: 'After',
colNote: 'Note',
rowCap: 'Showing the first 200 of :count rows. All rows are applied.',
refusedBlocks: 'Fix the refused rows in the file and preview it again. Nothing is applied while a row is refused.',
nothing: 'The file changes nothing.',
discard: 'Discard preview',
fileTooLarge: 'This file is larger than :limit. Split it into smaller files, or import it through MCP.',
readFailed: 'The file could not be read.',
openHistory: 'Open History',
applyCount: { one: 'Apply :count change', other: 'Apply :count changes' },
}
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',
size: ':size z 500 KB',
clean: 'Nie znaleziono problemów.',
unknown: 'Wiersz :line: „:word” nie jest dyrektywą robots.txt.',
unknownSuggest: 'Wiersz :line: „:word” nie jest dyrektywą robots.txt. Czy chodziło o „:suggestion”?',
noGroup: 'Wiersz :line: nad tą regułą nie ma wiersza User-agent, więc roboty ją ignorują.',
badPath: 'Wiersz :line: ścieżka musi zaczynać się od / lub *.',
noColon: 'Wiersz :line: zapisz dyrektywę jako „Nazwa: wartość”.',
ownSitemap: 'Wiersz :line: wiersze Sitemap są dodawane automatycznie. Ten zostaje bez zmian.',
crawlDelay: 'Wiersz :line: Google ignoruje Crawl-delay. Bing i Yandex go stosują.',
moreFindings: 'i :count więcej',
tooLarge: 'Ponad 500 KB: nie można zapisać pliku.',
url: 'Adres',
urlPlaceholder: '/konto/ustawienia',
agent: 'Robot',
allowedRule: 'Dozwolone dla :agent. Reguła: wiersz :line „:rule”.',
allowedNone: 'Dozwolone dla :agent: żadna reguła nie pasuje do tego adresu.',
blocked: 'Zablokowane dla :agent przez wiersz :line „:rule”.',
blockedBot: 'Zablokowane dla :agent przełącznikiem w zakładce Boty AI.',
unsaved: 'Sprawdza tekst w edytorze i przełączniki botów AI, także niezapisane zmiany.',
stagingNote: 'W tym środowisku każdy adres jest zablokowany. Wynik pokazuje zachowanie na produkcji.',
urlRequired: 'Wpisz adres zaczynający się od /.',
testFailed: 'Nie udało się uruchomić testu. Spróbuj ponownie.',
exportHeading: 'Eksport',
importHeading: 'Import',
dataset: 'Dane',
dsRedirects: 'Przekierowania',
dsRules: 'Reguły adresów',
dsRecords: 'SEO rekordów',
download: 'Pobierz CSV',
chooseFile: 'Wybierz plik CSV',
noFile: 'Nie wybrano pliku',
summary: 'Nowe: :new, zmienione: :changed, bez zmian: :unchanged, odrzucone: :refused',
bNew: 'Nowy',
bChanged: 'Zmieniony',
bUnchanged: 'Bez zmian',
bRefused: 'Odrzucony',
bWarning: 'Ostrzeżenie',
colLine: 'Wiersz',
colChange: 'Zmiana',
colAddress: 'Adres',
colField: 'Pole',
colBefore: 'Przed',
colAfter: 'Po',
colNote: 'Uwagi',
rowCap: 'Pokazano pierwsze 200 z :count wierszy. Zastosowane zostaną wszystkie.',
refusedBlocks: 'Popraw odrzucone wiersze w pliku i ponownie wyświetl podgląd. Nic nie zostanie zastosowane, dopóki jakiś wiersz jest odrzucony.',
nothing: 'Plik niczego nie zmienia.',
discard: 'Odrzuć podgląd',
fileTooLarge: 'Ten plik jest większy niż :limit. Podziel go na mniejsze pliki lub zaimportuj przez MCP.',
readFailed: 'Nie udało się odczytać pliku.',
openHistory: 'Otwórz Historię zmian',
applyCount: { one: 'Zastosuj :count zmianę', few: 'Zastosuj :count zmiany', many: 'Zastosuj :count zmian', other: 'Zastosuj :count zmiany' },
}
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}`
}