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.
498 lines
18 KiB
TypeScript
498 lines
18 KiB
TypeScript
// 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}`
|
||
}
|