feat: robots.txt editor and tester screen
- golem15.seo.robots singleton (UI-SPEC C10): staging callout and the served file (P4); the plugin's own extension adds the editor, the tester and the AI bots tab, saved to the robots row with history - field type robotstxt (E4, live lint, size meter; Decode refuses over 500 KB) and widget golem15-seo-robotstester (E5) on robots_test, which tests the unsaved text and switches through grobotstxt - saving rules that block a noindex address answers robots.blocks_noindex
This commit is contained in:
@@ -1,23 +1,39 @@
|
||||
import './styles.css'
|
||||
import { IMAGE_TAG, SeoImageElement } from './image'
|
||||
import { ROBOTS_TAG, SeoRobotsElement } from './robots'
|
||||
import { ROBOTSTESTER_TAG, SeoRobotsTesterElement } from './robotstester'
|
||||
import { ROBOTSTXT_TAG, SeoRobotsTxtElement } from './robotstxt'
|
||||
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)
|
||||
const elements: Array<[string, CustomElementConstructor]> = [
|
||||
[TEXT_TAG, SeoTextElement],
|
||||
[ROBOTS_TAG, SeoRobotsElement],
|
||||
[SERP_TAG, SeoSerpElement],
|
||||
[IMAGE_TAG, SeoImageElement],
|
||||
[ROBOTSTXT_TAG, SeoRobotsTxtElement],
|
||||
[ROBOTSTESTER_TAG, SeoRobotsTesterElement],
|
||||
]
|
||||
for (const [tag, ctor] of elements) {
|
||||
if (!customElements.get(tag)) {
|
||||
customElements.define(tag, ctor)
|
||||
}
|
||||
}
|
||||
|
||||
export { IMAGE_TAG, ROBOTS_TAG, SERP_TAG, SeoImageElement, SeoRobotsElement, SeoSerpElement, SeoTextElement, TEXT_TAG }
|
||||
export {
|
||||
IMAGE_TAG,
|
||||
ROBOTS_TAG,
|
||||
ROBOTSTESTER_TAG,
|
||||
ROBOTSTXT_TAG,
|
||||
SERP_TAG,
|
||||
SeoImageElement,
|
||||
SeoRobotsElement,
|
||||
SeoRobotsTesterElement,
|
||||
SeoRobotsTxtElement,
|
||||
SeoSerpElement,
|
||||
SeoTextElement,
|
||||
TEXT_TAG,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 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
|
||||
// E4 and E5, 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. */
|
||||
@@ -77,6 +78,29 @@ export interface Labels {
|
||||
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
|
||||
}
|
||||
|
||||
const en: Labels = {
|
||||
@@ -142,6 +166,27 @@ const en: Labels = {
|
||||
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 /.',
|
||||
}
|
||||
|
||||
const pl: Labels = {
|
||||
@@ -217,6 +262,27 @@ const pl: Labels = {
|
||||
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 /.',
|
||||
}
|
||||
|
||||
const dictionaries: Record<string, Labels> = { en, pl }
|
||||
|
||||
244
assets/src/elements/robotstester.ts
Normal file
244
assets/src/elements/robotstester.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { el, emit, fmt, icon, jsonAttr, labelsFor, uid, uiLocaleOf, type Labels } from './labels'
|
||||
|
||||
export const ROBOTSTESTER_TAG = 'golem15-seo-robotstester'
|
||||
|
||||
/** The tester's context answer: the crawler list and the environment. */
|
||||
interface ContextData {
|
||||
op: 'context'
|
||||
agents: string[]
|
||||
bots: string[]
|
||||
staging: boolean
|
||||
}
|
||||
|
||||
/** The tester's test answer. */
|
||||
interface TestData {
|
||||
op: 'test'
|
||||
url: string
|
||||
allowed: boolean
|
||||
line: number
|
||||
rule: string
|
||||
bot: boolean
|
||||
agent: string
|
||||
staging: boolean
|
||||
}
|
||||
|
||||
type Payload = { op: 'context' } | { op: 'test'; url: string; agent: string }
|
||||
|
||||
/** The crawler list before the context answer arrives. */
|
||||
const DEFAULT_AGENTS = ['Googlebot', 'Googlebot-Image', 'Bingbot', '*']
|
||||
|
||||
/** The result line of a test answer in the element's language. */
|
||||
export function resultText(labels: Labels, r: TestData): string {
|
||||
const params = { agent: r.agent, line: r.line, rule: r.rule }
|
||||
if (r.allowed) {
|
||||
return r.line > 0 ? fmt(labels.allowedRule, params) : fmt(labels.allowedNone, params)
|
||||
}
|
||||
return r.bot ? fmt(labels.blockedBot, params) : fmt(labels.blocked, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* E5 golem15-seo-robotstester (UI-SPEC C10): an address input, a crawler
|
||||
* select and the test button (labelled from the action), and the result
|
||||
* line. It tests the editor's text and the AI bot switches the form holds,
|
||||
* including unsaved changes (the widget's fill), through the robots_test
|
||||
* action only.
|
||||
*/
|
||||
export class SeoRobotsTesterElement extends HTMLElement {
|
||||
static observedAttributes = ['busy', 'state', 'ui-locale', 'disabled', 'label', 'busy-label']
|
||||
|
||||
private context: ContextData | null = null
|
||||
private result: TestData | null = null
|
||||
private error = ''
|
||||
private inflight: Payload | null = null
|
||||
private queued: Payload | null = null
|
||||
private connected = false
|
||||
private input: HTMLInputElement | null = null
|
||||
private select: HTMLSelectElement | null = null
|
||||
private button: HTMLButtonElement | null = null
|
||||
private line: HTMLDivElement | null = null
|
||||
private notes: HTMLDivElement | null = null
|
||||
private builtFor = ''
|
||||
|
||||
private readonly onResult = (ev: Event): void => {
|
||||
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
|
||||
const data = detail?.data as ContextData | TestData | undefined
|
||||
this.inflight = null
|
||||
if (data && data.op === 'context') {
|
||||
this.context = data
|
||||
this.build(true)
|
||||
} else if (data && data.op === 'test') {
|
||||
this.result = data
|
||||
this.error = ''
|
||||
this.renderResult()
|
||||
}
|
||||
this.flush()
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.connected = true
|
||||
this.addEventListener('summer-result', this.onResult)
|
||||
this.build(false)
|
||||
this.request({ op: 'context' })
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.connected = false
|
||||
this.removeEventListener('summer-result', this.onResult)
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
||||
if (!this.connected) {
|
||||
return
|
||||
}
|
||||
if (name === 'busy' && value === null) {
|
||||
this.flush()
|
||||
}
|
||||
if (name === 'state' && value === 'error') {
|
||||
this.inflight = null
|
||||
}
|
||||
if (name === 'ui-locale') {
|
||||
this.build(true)
|
||||
return
|
||||
}
|
||||
this.syncControls()
|
||||
}
|
||||
|
||||
private labels(): Labels {
|
||||
return labelsFor(uiLocaleOf(this))
|
||||
}
|
||||
|
||||
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 build(force: boolean): void {
|
||||
const key = uiLocaleOf(this) + '|' + (this.context?.agents ?? DEFAULT_AGENTS).join(',')
|
||||
if (!force && this.builtFor === key && this.input) {
|
||||
this.syncControls()
|
||||
return
|
||||
}
|
||||
this.builtFor = key
|
||||
const labels = this.labels()
|
||||
const keepUrl = this.input?.value ?? ''
|
||||
const keepAgent = this.select?.value ?? 'Googlebot'
|
||||
this.replaceChildren()
|
||||
|
||||
const row = el('div', 'seo-robotstester__row')
|
||||
const urlId = uid('seo-robotstester-url')
|
||||
const urlWrap = el('div', 'seo-robotstester__field seo-robotstester__field--url')
|
||||
const urlLabel = el('label', 'seo-robotstester__label', labels.url)
|
||||
urlLabel.htmlFor = urlId
|
||||
const input = el('input', 'seo-robotstester__input')
|
||||
input.id = urlId
|
||||
input.type = 'text'
|
||||
input.placeholder = labels.urlPlaceholder
|
||||
input.value = keepUrl
|
||||
input.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault()
|
||||
this.test()
|
||||
}
|
||||
})
|
||||
urlWrap.append(urlLabel, input)
|
||||
|
||||
const agentId = uid('seo-robotstester-agent')
|
||||
const agentWrap = el('div', 'seo-robotstester__field seo-robotstester__field--agent')
|
||||
const agentLabel = el('label', 'seo-robotstester__label', labels.agent)
|
||||
agentLabel.htmlFor = agentId
|
||||
const select = el('select', 'seo-robotstester__select')
|
||||
select.id = agentId
|
||||
for (const agent of this.context?.agents ?? DEFAULT_AGENTS) {
|
||||
const option = el('option', '', agent)
|
||||
option.value = agent
|
||||
select.append(option)
|
||||
}
|
||||
select.value = keepAgent
|
||||
if (select.value !== keepAgent) {
|
||||
select.value = 'Googlebot'
|
||||
}
|
||||
agentWrap.append(agentLabel, select)
|
||||
|
||||
const button = el('button', 'seo-robotstester__button')
|
||||
button.type = 'button'
|
||||
button.addEventListener('click', () => this.test())
|
||||
row.append(urlWrap, agentWrap, button)
|
||||
|
||||
const line = el('div', 'seo-robotstester__result')
|
||||
line.setAttribute('role', 'status')
|
||||
const notes = el('div', 'seo-robotstester__notes')
|
||||
this.input = input
|
||||
this.select = select
|
||||
this.button = button
|
||||
this.line = line
|
||||
this.notes = notes
|
||||
this.append(row, line, notes)
|
||||
this.syncControls()
|
||||
this.renderResult()
|
||||
}
|
||||
|
||||
private syncControls(): void {
|
||||
if (!this.button || !this.input || !this.select) {
|
||||
return
|
||||
}
|
||||
const disabled = this.hasAttribute('disabled')
|
||||
const busy = this.hasAttribute('busy')
|
||||
this.input.readOnly = disabled
|
||||
this.select.disabled = disabled
|
||||
this.button.hidden = disabled
|
||||
this.button.disabled = busy
|
||||
this.button.textContent = busy ? this.getAttribute('busy-label') || this.getAttribute('label') || '' : this.getAttribute('label') || ''
|
||||
}
|
||||
|
||||
private test(): void {
|
||||
if (this.hasAttribute('disabled') || !this.input || !this.select) {
|
||||
return
|
||||
}
|
||||
const url = this.input.value.trim()
|
||||
if (!url.startsWith('/')) {
|
||||
this.error = this.labels().urlRequired
|
||||
this.result = null
|
||||
this.renderResult()
|
||||
return
|
||||
}
|
||||
this.error = ''
|
||||
this.request({ op: 'test', url, agent: this.select.value })
|
||||
}
|
||||
|
||||
private renderResult(): void {
|
||||
if (!this.line || !this.notes) {
|
||||
return
|
||||
}
|
||||
const labels = this.labels()
|
||||
this.line.className = 'seo-robotstester__result'
|
||||
if (this.error) {
|
||||
this.line.classList.add('seo-robotstester__result--error')
|
||||
this.line.replaceChildren(el('span', '', this.error))
|
||||
} else if (this.result) {
|
||||
const allowed = this.result.allowed
|
||||
this.line.classList.add(allowed ? 'seo-robotstester__result--allowed' : 'seo-robotstester__result--blocked')
|
||||
this.line.replaceChildren(icon(allowed ? 'circle-check' : 'triangle-alert'), el('span', '', resultText(labels, this.result)))
|
||||
} else {
|
||||
this.line.replaceChildren()
|
||||
}
|
||||
const notes: HTMLElement[] = []
|
||||
if (this.context?.staging || jsonAttr<{ staging?: boolean }>(this, 'data', {}).staging) {
|
||||
notes.push(el('p', 'seo-robotstester__note', labels.stagingNote))
|
||||
}
|
||||
notes.push(el('p', 'seo-robotstester__note', labels.unsaved))
|
||||
this.notes.replaceChildren(...notes)
|
||||
}
|
||||
}
|
||||
239
assets/src/elements/robotstxt.ts
Normal file
239
assets/src/elements/robotstxt.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { el, emit, fmt, icon, labelsFor, num, uid, uiLocaleOf, type Labels } from './labels'
|
||||
|
||||
export const ROBOTSTXT_TAG = 'golem15-seo-robotstxt'
|
||||
|
||||
/** The largest robots.txt the server stores: Google reads 500 KiB. */
|
||||
export const MAX_ROBOTS_BYTES = 500 * 1024
|
||||
|
||||
/** The findings shown before "and :count more". */
|
||||
export const MAX_FINDINGS = 10
|
||||
|
||||
/** The directives the lint knows, as written in the suggestion. */
|
||||
const DIRECTIVES = ['User-agent', 'Allow', 'Disallow', 'Sitemap', 'Crawl-delay', 'Host', 'Clean-param']
|
||||
|
||||
export type FindingKind = 'unknown' | 'unknownSuggest' | 'noGroup' | 'badPath' | 'noColon' | 'ownSitemap' | 'crawlDelay'
|
||||
|
||||
export interface Finding {
|
||||
kind: FindingKind
|
||||
line: number
|
||||
word?: string
|
||||
suggestion?: string
|
||||
}
|
||||
|
||||
/** Levenshtein distance of two short strings. */
|
||||
function distance(a: string, b: string): number {
|
||||
const row = Array.from({ length: b.length + 1 }, (_, i) => i)
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
let prev = row[0]
|
||||
row[0] = i
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const tmp = row[j]
|
||||
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
|
||||
prev = tmp
|
||||
}
|
||||
}
|
||||
return row[b.length]
|
||||
}
|
||||
|
||||
/** The closest known directive within two edits, or ''. */
|
||||
function suggest(word: string): string {
|
||||
const lower = word.toLowerCase()
|
||||
let best = ''
|
||||
let bestDistance = 3
|
||||
for (const d of DIRECTIVES) {
|
||||
const dist = distance(lower, d.toLowerCase())
|
||||
if (dist < bestDistance) {
|
||||
best = d
|
||||
bestDistance = dist
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Lints a robots.txt (UI-SPEC E4): every finding is a warning that never
|
||||
* blocks a save. Lines are 1-based; comments (#) and blank lines are
|
||||
* skipped.
|
||||
*/
|
||||
export function lintRobots(text: string): Finding[] {
|
||||
const out: Finding[] = []
|
||||
let group = false
|
||||
text.split(/\r?\n/).forEach((raw, index) => {
|
||||
const line = index + 1
|
||||
const hash = raw.indexOf('#')
|
||||
const body = (hash >= 0 ? raw.slice(0, hash) : raw).trim()
|
||||
if (body === '') {
|
||||
return
|
||||
}
|
||||
const colon = body.indexOf(':')
|
||||
if (colon < 0) {
|
||||
out.push({ kind: 'noColon', line })
|
||||
return
|
||||
}
|
||||
const word = body.slice(0, colon).trim()
|
||||
const value = body.slice(colon + 1).trim()
|
||||
switch (word.toLowerCase()) {
|
||||
case 'user-agent':
|
||||
group = true
|
||||
return
|
||||
case 'allow':
|
||||
case 'disallow':
|
||||
if (!group) {
|
||||
out.push({ kind: 'noGroup', line })
|
||||
} else if (value !== '' && !value.startsWith('/') && !value.startsWith('*')) {
|
||||
out.push({ kind: 'badPath', line })
|
||||
}
|
||||
return
|
||||
case 'sitemap':
|
||||
out.push({ kind: 'ownSitemap', line })
|
||||
return
|
||||
case 'crawl-delay':
|
||||
out.push({ kind: 'crawlDelay', line })
|
||||
return
|
||||
case 'host':
|
||||
case 'clean-param':
|
||||
return
|
||||
}
|
||||
const suggestion = suggest(word)
|
||||
out.push(suggestion ? { kind: 'unknownSuggest', line, word, suggestion } : { kind: 'unknown', line, word })
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** The UTF-8 size of text in bytes. */
|
||||
export function byteSize(text: string): number {
|
||||
return new TextEncoder().encode(text).length
|
||||
}
|
||||
|
||||
/** The text of a finding in the element's language. */
|
||||
export function findingText(labels: Labels, f: Finding): string {
|
||||
return fmt(labels[f.kind], { line: f.line, word: f.word ?? '', suggestion: f.suggestion ?? '' })
|
||||
}
|
||||
|
||||
/**
|
||||
* E4 golem15-seo-robotstxt (UI-SPEC C10): the robots.txt editor, a
|
||||
* monospaced textarea with a live lint panel (at most ten findings) and a
|
||||
* size meter. Findings never block a save; only a file over 500 KB is
|
||||
* refused, by the field type's Decode. The value is a plain string; the
|
||||
* element emits summer-change on every input.
|
||||
*/
|
||||
export class SeoRobotsTxtElement extends HTMLElement {
|
||||
static observedAttributes = ['disabled', 'invalid', 'ui-locale']
|
||||
|
||||
private current = ''
|
||||
private control: HTMLTextAreaElement | null = null
|
||||
private panel: HTMLDivElement | null = null
|
||||
private meter: HTMLDivElement | 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()
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.build()
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string): void {
|
||||
if (!this.isConnected) {
|
||||
return
|
||||
}
|
||||
if (name === 'ui-locale') {
|
||||
this.build()
|
||||
return
|
||||
}
|
||||
this.sync()
|
||||
}
|
||||
|
||||
private labels(): Labels {
|
||||
return labelsFor(uiLocaleOf(this))
|
||||
}
|
||||
|
||||
private build(): void {
|
||||
const key = uiLocaleOf(this)
|
||||
if (this.builtFor === key && this.control) {
|
||||
this.sync()
|
||||
return
|
||||
}
|
||||
this.builtFor = key
|
||||
this.replaceChildren()
|
||||
const panelId = uid('seo-robotstxt-lint')
|
||||
const control = el('textarea', 'seo-robotstxt__control')
|
||||
control.spellcheck = false
|
||||
control.setAttribute('wrap', 'off')
|
||||
control.value = this.current
|
||||
control.setAttribute('aria-describedby', panelId)
|
||||
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
|
||||
this.update()
|
||||
emit(this, 'summer-change', { value: this.current })
|
||||
})
|
||||
const panel = el('div', 'seo-robotstxt__lint')
|
||||
panel.id = panelId
|
||||
panel.setAttribute('role', 'status')
|
||||
const meter = el('div', 'seo-robotstxt__size')
|
||||
this.control = control
|
||||
this.panel = panel
|
||||
this.meter = meter
|
||||
this.append(control, panel, meter)
|
||||
this.sync()
|
||||
}
|
||||
|
||||
private sync(): void {
|
||||
if (!this.control) {
|
||||
return
|
||||
}
|
||||
this.control.readOnly = this.hasAttribute('disabled')
|
||||
this.classList.toggle('seo-robotstxt--invalid', this.hasAttribute('invalid'))
|
||||
this.control.setAttribute('aria-invalid', this.hasAttribute('invalid') ? 'true' : 'false')
|
||||
this.update()
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
if (!this.panel || !this.meter) {
|
||||
return
|
||||
}
|
||||
const labels = this.labels()
|
||||
const locale = uiLocaleOf(this)
|
||||
const findings = lintRobots(this.current)
|
||||
const items: HTMLElement[] = []
|
||||
if (findings.length === 0) {
|
||||
const item = el('div', 'seo-robotstxt__finding seo-robotstxt__finding--clean')
|
||||
item.append(icon('circle-check'), el('span', '', labels.clean))
|
||||
items.push(item)
|
||||
} else {
|
||||
for (const f of findings.slice(0, MAX_FINDINGS)) {
|
||||
const item = el('div', 'seo-robotstxt__finding')
|
||||
item.append(icon('triangle-alert'), el('span', '', findingText(labels, f)))
|
||||
items.push(item)
|
||||
}
|
||||
if (findings.length > MAX_FINDINGS) {
|
||||
items.push(el('div', 'seo-robotstxt__more', fmt(labels.moreFindings, { count: num(findings.length - MAX_FINDINGS, locale) })))
|
||||
}
|
||||
}
|
||||
this.panel.replaceChildren(...items)
|
||||
const bytes = byteSize(this.current)
|
||||
const kb = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(bytes / 1024) + ' KB'
|
||||
this.meter.replaceChildren(el('span', '', fmt(labels.size, { size: kb })))
|
||||
const over = bytes > MAX_ROBOTS_BYTES
|
||||
this.classList.toggle('seo-robotstxt--over', over)
|
||||
if (over) {
|
||||
this.meter.append(el('span', 'seo-robotstxt__over', labels.tooLarge))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
/* SEO field elements (UI-SPEC C4, C5, C6, C14). Light DOM, every rule
|
||||
/* SEO field elements (UI-SPEC C4, C5, C6, C10, 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 {
|
||||
golem15-seo-image,
|
||||
golem15-seo-robotstxt,
|
||||
golem15-seo-robotstester {
|
||||
display: block;
|
||||
color: var(--c-text);
|
||||
font-size: 14px;
|
||||
@@ -440,6 +442,187 @@ golem15-seo-image.seo-image--invalid .seo-image__img {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
/* E4 golem15-seo-robotstxt */
|
||||
golem15-seo-robotstxt .seo-robotstxt__control {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
resize: vertical;
|
||||
transition: border-color 150ms, box-shadow 150ms;
|
||||
}
|
||||
|
||||
golem15-seo-robotstxt .seo-robotstxt__control:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px var(--c-ring);
|
||||
}
|
||||
|
||||
golem15-seo-robotstxt.seo-robotstxt--invalid .seo-robotstxt__control,
|
||||
golem15-seo-robotstxt.seo-robotstxt--over .seo-robotstxt__control {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
golem15-seo-robotstxt .seo-robotstxt__lint {
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 10px;
|
||||
background: var(--c-subtle);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-seo-robotstxt .seo-robotstxt__finding {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
golem15-seo-robotstxt .seo-robotstxt__finding + .seo-robotstxt__finding {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
golem15-seo-robotstxt .seo-robotstxt__finding .seo-icon {
|
||||
flex: none;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
golem15-seo-robotstxt .seo-robotstxt__finding--clean {
|
||||
color: var(--c-ok-text, var(--c-text));
|
||||
}
|
||||
|
||||
golem15-seo-robotstxt .seo-robotstxt__more {
|
||||
margin-top: 4px;
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
golem15-seo-robotstxt .seo-robotstxt__size {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
color: var(--c-muted);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
golem15-seo-robotstxt .seo-robotstxt__over {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
/* E5 golem15-seo-robotstester */
|
||||
golem15-seo-robotstester .seo-robotstester__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__field--url {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__field--agent {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__label {
|
||||
color: var(--c-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__input,
|
||||
golem15-seo-robotstester .seo-robotstester__select {
|
||||
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;
|
||||
font-size: 14px;
|
||||
transition: border-color 150ms, box-shadow 150ms;
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__input:focus,
|
||||
golem15-seo-robotstester .seo-robotstester__select:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px var(--c-ring);
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__button {
|
||||
height: 42px;
|
||||
padding: 0 16px;
|
||||
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-robotstester .seo-robotstester__button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--c-ring);
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__result {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__result .seo-icon {
|
||||
flex: none;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__result--allowed {
|
||||
color: var(--c-ok-text, var(--c-text));
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__result--blocked {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__result--error {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
golem15-seo-robotstester .seo-robotstester__note {
|
||||
margin: 6px 0 0;
|
||||
color: var(--c-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
golem15-seo-robots .seo-robots__panel {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -451,6 +634,9 @@ golem15-seo-image.seo-image--invalid .seo-image__img {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
golem15-seo-robotstxt .seo-robotstxt__control,
|
||||
golem15-seo-robotstester .seo-robotstester__input,
|
||||
golem15-seo-robotstester .seo-robotstester__select,
|
||||
golem15-seo-text .seo-text__control,
|
||||
golem15-seo-robots .seo-robots__segment,
|
||||
golem15-seo-serp .seo-serp__segment {
|
||||
|
||||
Reference in New Issue
Block a user