// 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 = { 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 { 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 { 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 = { 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(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(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}` }