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:
2026-10-27 11:51:41 +01:00
parent 3dd936202f
commit 51413fbda4
21 changed files with 1336 additions and 26 deletions

View File

@@ -38,6 +38,13 @@ func (*Plugin) Navigation() []pact.NavigationItem {
Permissions: []string{controllers.PermissionManage},
Controller: controllers.RulesControllerID,
},
{
Code: "robots",
Label: "golem15.seo::lang.nav.robots",
Icon: "file-text",
Permissions: []string{controllers.PermissionManage},
Controller: controllers.RobotsControllerID,
},
{
Code: "history",
Label: "golem15.seo::lang.nav.history",

2
assets/dist/seo.css vendored

File diff suppressed because one or more lines are too long

2
assets/dist/seo.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -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,
}

View File

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

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

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

View File

@@ -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 {

View File

@@ -10,12 +10,14 @@ import (
"git.golem15.com/golem15/summercms/modules/pact"
)
// The elements of the SEO field types (assets/src/elements).
// The elements of the SEO field types and widgets (assets/src/elements).
const (
ElementText = "golem15-seo-text"
ElementRobots = "golem15-seo-robots"
ElementSERP = "golem15-seo-serp"
ElementImage = "golem15-seo-image"
ElementText = "golem15-seo-text"
ElementRobots = "golem15-seo-robots"
ElementSERP = "golem15-seo-serp"
ElementImage = "golem15-seo-image"
ElementRobotsTxt = "golem15-seo-robotstxt"
ElementRobotsTester = "golem15-seo-robotstester"
)
// The element bundle every SEO field type loads.
@@ -27,13 +29,18 @@ var (
// errRobotsInvalid is the refusal of a malformed robots value.
const errRobotsInvalid = "golem15.seo::lang.robots.invalid"
// errRobotsTooLarge refuses a robots.txt over MaxRobotsBytes.
const errRobotsTooLarge = "golem15.seo::lang.robots.too_large"
// FieldTypes returns the SEO admin field types:
// - seotext and mlseotext (E1): text with a live length counter; option
// measure: title (input) or description (textarea);
// - seorobots and mlseorobots (E2): the robots directives editor; option
// scope: record, rule or type; Decode refuses a malformed value;
// - seoimage (E7): an uploaded social image stored in the media bucket,
// its URL as the value (D-100); uploads need permission.
// its URL as the value (D-100); uploads need permission;
// - robotstxt (E4): the robots.txt editor with live lint; Decode refuses
// a file over 500 KB (D-22).
//
// app is asked for the booted application at request time.
func FieldTypes(app func() *backpack.App, permission string) []pact.FormFieldType {
@@ -61,9 +68,29 @@ func FieldTypes(app func() *backpack.App, permission string) []pact.FormFieldTyp
Scripts: elementScripts, Styles: elementStyles,
Decode: decodeImage, Upload: UploadImage(app, permission),
},
{
Name: "robotstxt", Element: ElementRobotsTxt,
Scripts: elementScripts, Styles: elementStyles,
Decode: decodeRobotsTxt,
},
}
}
// decodeRobotsTxt stores a robots.txt with Unix line ends and refuses one
// over MaxRobotsBytes (robots.too_large); lint findings are warnings in
// the element and never block a save.
func decodeRobotsTxt(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
s, ok := stringValue(in.Value)
if !ok {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"The " + in.Field + " field must be text."}}
}
s = strings.ReplaceAll(s, "\r\n", "\n")
if len(s) > MaxRobotsBytes {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errRobotsTooLarge}}
}
return pact.FormFieldDecoded{Value: s}, nil
}
func checkMeasure(options map[string]any) error {
if raw, ok := options["measure"]; ok {
if s, _ := raw.(string); s != "title" && s != "description" {

View File

@@ -32,6 +32,7 @@ func AdminControllers(app func() *backpack.App) []pact.AdminController {
redirectsAdminController{app: app},
notFoundAdminController{app: app},
rulesAdminController{app: app},
robotsAdminController{app: app},
historyAdminController{app: app},
}
}

101
controllers/robots.go Normal file
View File

@@ -0,0 +1,101 @@
package controllers
import (
"context"
"errors"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RobotsControllerID is the robots.txt screen (UI-SPEC C10).
const RobotsControllerID = "golem15.seo.robots"
// robotsAdminController edits the robots.txt singleton (D-22, D-24,
// D-25). Its form holds the staging callout and the served file (P4); the
// editor (E4), the tester (E5) and the AI bot switches come from the
// plugin's own form extension on this controller, so the tester can read
// the unsaved editor text and switches.
type robotsAdminController struct {
app func() *backpack.App
}
func (robotsAdminController) ID() string { return RobotsControllerID }
func (robotsAdminController) ModelName() string { return `Golem15\SEO\Models\Robots` }
func (robotsAdminController) ConfigDir() string { return "controllers/robots" }
func (robotsAdminController) NewRecord() any { return &models.Robots{} }
func (robotsAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// SingletonRecord returns robots row 1, creating it when the migration's
// row is gone (first or create, safe against a concurrent first request).
func (robotsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
if db == nil {
return nil, errors.New("golem15.seo: database is not configured")
}
var rows []models.Robots
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) > 0 {
return &rows[0], nil
}
row := models.Robots{ID: 1, Bots: models.BotSwitches{}}
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return nil, err
}
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
return nil, errors.New("golem15.seo: robots row is missing")
}
return &rows[0], nil
}
// StagingView is the staging callout partial's view model.
type StagingView struct {
Staging bool
}
// ServedView is P4: the file crawlers get and, outside production, the
// production version under it.
type ServedView struct {
Served string
Production string
Staging bool
}
// PartialData serves the staging callout and P4.
func (c robotsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) {
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil, err
}
staging := svc.Staging()
switch name {
case "staging":
return StagingView{Staging: staging}, nil
case "robots_served":
production, err := svc.ServedFile(ctx, true)
if err != nil {
return nil, err
}
view := ServedView{Served: production, Staging: staging}
if staging {
view.Served, view.Production = classes.NonProductionRobots, production
}
return view, nil
}
return nil, nil
}
var (
_ pact.AdminController = robotsAdminController{}
_ pact.AdminPermissioned = robotsAdminController{}
_ pact.AdminRecordSource = robotsAdminController{}
_ pact.AdminSingleton = robotsAdminController{}
_ pact.AdminPartialData = robotsAdminController{}
)

View File

@@ -0,0 +1,10 @@
{{- with .Data -}}
<div class="summer-markdown">
<p>{{ trans "golem15.seo::lang.robots.served_comment" }}</p>
<pre>{{ .Served }}</pre>
{{- if .Staging }}
<p><strong>{{ trans "golem15.seo::lang.robots.served_production" }}</strong></p>
<pre>{{ .Production }}</pre>
{{- end }}
</div>
{{- end -}}

View File

@@ -0,0 +1,6 @@
{{- if .Data.Staging -}}
<div class="summer-callout summer-callout--warning" role="status">
<p class="summer-callout__title">{{ trans "golem15.seo::lang.robots.staging_title" }}</p>
<p class="summer-callout__text">{{ trans "golem15.seo::lang.robots.staging_text" }}</p>
</div>
{{- end -}}

View File

@@ -0,0 +1,13 @@
# robots.txt singleton (UI-SPEC C10): the singleton footer, no delete.
name: golem15.seo::lang.robots.title
form: ~/plugins/golem15/seo/models/robots/fields.yaml
modelClass: Golem15\SEO\Models\Robots
defaultRedirect: golem15/seo/robots
update:
redirect: golem15/seo/robots
redirectClose: golem15/seo/redirects
messages:
update: golem15.seo::lang.robots.title
saved: backend::lang.messages.form.saved

View File

@@ -39,6 +39,7 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
}
out = append(out, pact.AdminActionExtension{Controller: controllers.RulesControllerID, Form: p.ruleHistory()})
out = append(out, pact.AdminActionExtension{Controller: controllers.RedirectsControllerID, Form: p.redirectChecks()})
out = append(out, pact.AdminActionExtension{Controller: controllers.RobotsControllerID, Form: p.robotsForm()})
return out
}

252
extensions_robots.go Normal file
View File

@@ -0,0 +1,252 @@
package seo
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/controllers"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/goccy/go-yaml"
)
// The robots.txt form's tab keys (UI-SPEC C10).
const (
TabRobotsFile = "golem15.seo::lang.robots.tab_file"
TabRobotsBots = "golem15.seo::lang.robots.tab_bots"
)
// RobotsTestAction is the tester's widget action (E5).
const RobotsTestAction = "robots_test"
// botComments are the switch comments per bot kind.
var botComments = map[string]string{
classes.BotSearch: "golem15.seo::lang.bots.search_comment",
classes.BotUser: "golem15.seo::lang.bots.user_comment",
classes.BotTraining: "golem15.seo::lang.bots.training_comment",
}
// robotsDocument builds the robots form's fields document: the editor
// (E4) and the tester (E5, filled from the editor and every bot switch)
// after the staging callout on the robots.txt tab, then the AI bots tab
// with one switch per bot in classes.Bots order, labelled with its token
// and operator.
func robotsDocument() ([]byte, error) {
type tabSpec struct {
Name string `yaml:"name"`
After string `yaml:"after"`
}
fill := []string{"content"}
for _, b := range classes.Bots {
fill = append(fill, classes.BotField(b.Token))
}
fields := yaml.MapSlice{
{Key: "content", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.seo::lang.robots.content"},
{Key: "comment", Value: "golem15.seo::lang.robots.content_comment"},
{Key: "type", Value: "robotstxt"},
{Key: "span", Value: "full"},
{Key: "after", Value: "staging"},
{Key: "tab", Value: TabRobotsFile},
}},
{Key: "tester", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.seo::lang.robots.tester"},
{Key: "type", Value: "widget"},
{Key: "widget", Value: classes.ElementRobotsTester},
{Key: "action", Value: RobotsTestAction},
{Key: "fill", Value: fill},
{Key: "span", Value: "full"},
{Key: "after", Value: "content"},
{Key: "tab", Value: TabRobotsFile},
}},
}
for i, b := range classes.Bots {
span := "left"
if i%2 == 1 {
span = "right"
}
fields = append(fields, yaml.MapItem{Key: classes.BotField(b.Token), Value: yaml.MapSlice{
{Key: "label", Value: b.Token + " (" + b.Operator + ")"},
{Key: "comment", Value: botComments[b.Kind]},
{Key: "type", Value: "switch"},
{Key: "default", Value: true},
{Key: "span", Value: span},
{Key: "tab", Value: TabRobotsBots},
}})
}
return yaml.Marshal(yaml.MapSlice{
{Key: "tabs", Value: []tabSpec{{Name: TabRobotsBots, After: TabRobotsFile}}},
{Key: "fields", Value: fields},
})
}
// robotsForm is the plugin's own extension on the robots.txt screen
// (D-22, D-24, D-25): the editor, the tester and the AI bot switches,
// loaded from and saved to the robots row inside the save transaction.
// Save records history and answers robots.blocks_noindex on content when
// the rules block a noindex address from Googlebot.
func (p *Plugin) robotsForm() *pact.AdminFormExtension {
doc, err := robotsDocument()
if err != nil {
panic(fmt.Errorf("golem15.seo: robots form: %w", err))
}
return &pact.AdminFormExtension{
FieldsYAML: doc,
Permissions: []string{controllers.PermissionManage},
Actions: []pact.AdminAction{p.robotsTestAction()},
Load: func(_ context.Context, in pact.AdminFormLoad) (map[string]any, error) {
row, ok := in.Record.(*models.Robots)
if !ok || row == nil {
return nil, nil
}
out := map[string]any{"content": row.Content}
for _, b := range classes.Bots {
out[classes.BotField(b.Token)] = row.Bots.Allowed(b.Token)
}
return out, nil
},
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
svc := p.Service()
if svc == nil {
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
}
row, ok := in.Record.(*models.Robots)
if !ok || row == nil {
return pact.AdminFormSaveResult{}, nil
}
before := robotsValues(*row)
if raw, ok := in.Values["content"]; ok {
if text, ok := raw.(string); ok {
row.Content = text
}
}
bots := models.BotSwitches{}
for token, on := range row.Bots {
bots[token] = on
}
for _, b := range classes.Bots {
if raw, ok := in.Values[classes.BotField(b.Token)]; ok {
bots[b.Token] = truthy(raw)
}
}
row.Bots = bots
now := time.Now().UTC()
row.UpdatedAt = &now
if err := in.DB.Model(&models.Robots{}).Where("id = ?", row.ID).Updates(map[string]any{
"content": row.Content, "bots": row.Bots, "updated_at": now,
}).Error; err != nil {
return pact.AdminFormSaveResult{}, err
}
if err := svc.Record(ctx, in.DB, classes.Change{
Kind: models.SubjectRobots,
SubjectID: strconv.FormatUint(uint64(row.ID), 10),
What: svc.Phrase("golem15.seo::lang.history.what_robots", nil),
Before: before,
After: robotsValues(*row),
}); err != nil {
return pact.AdminFormSaveResult{}, err
}
blocked, err := svc.BlockedNoindex(in.DB, row.Content, row.Bots)
if err != nil || len(blocked) == 0 {
return pact.AdminFormSaveResult{}, err
}
list := blocked
if len(list) > 10 {
list = append(append([]string{}, list[:10]...), "…")
}
return pact.AdminFormSaveResult{Notices: map[string][]pact.FormFieldNotice{"content": {{
Message: "golem15.seo::lang.robots.blocks_noindex",
Params: map[string]string{"list": strings.Join(list, ", ")},
Count: len(blocked),
}}}}, nil
},
}
}
// robotsValues are the robots row's values for history.
func robotsValues(r models.Robots) map[string]any {
bots := map[string]any{}
for _, b := range classes.Bots {
bots[b.Token] = r.Bots.Allowed(b.Token)
}
return map[string]any{"content": r.Content, "bots": bots}
}
// robotsTestPayload is the tester's request: op context answers the
// crawler list and the environment; op test answers whether agent may
// fetch url under the editor's unsaved text and switches.
type robotsTestPayload struct {
Op string `json:"op"`
URL string `json:"url"`
Agent string `json:"agent"`
}
// robotsTesterContext is the tester's context answer.
type robotsTesterContext struct {
Op string `json:"op"`
Agents []string `json:"agents"`
Bots []string `json:"bots"`
Staging bool `json:"staging"`
}
// robotsTestResult is the tester's test answer.
type robotsTestResult struct {
Op string `json:"op"`
URL string `json:"url"`
classes.RobotsTest
}
// testerAgents are the crawlers E5 offers before the AI bots.
var testerAgents = []string{"Googlebot", "Googlebot-Image", "Bingbot"}
// robotsTestAction is the tester's widget action (E5, D-22, D-99).
func (p *Plugin) robotsTestAction() pact.AdminAction {
return pact.AdminAction{
Name: RobotsTestAction,
Label: "golem15.seo::lang.robots.test",
Permissions: []string{controllers.PermissionManage},
Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
svc := p.Service()
if svc == nil {
return pact.AdminActionResult{}, classes.ErrNotBooted
}
var payload robotsTestPayload
if len(in.Payload) > 0 {
if err := json.Unmarshal(in.Payload, &payload); err != nil {
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.robots.test_invalid"}}}
}
}
if payload.Op == "context" || payload.Op == "" {
out := robotsTesterContext{Op: "context", Agents: append([]string{}, testerAgents...), Staging: svc.Staging()}
for _, b := range classes.Bots {
out.Agents = append(out.Agents, b.Token)
out.Bots = append(out.Bots, b.Token)
}
out.Agents = append(out.Agents, "*")
return pact.AdminActionResult{Data: out}, nil
}
path := strings.TrimSpace(payload.URL)
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || len(path) > classes.MaxURLBytes {
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.robots.url_required"}}}
}
agent := strings.TrimSpace(payload.Agent)
if agent == "" {
agent = "Googlebot"
}
content, _ := in.Values["content"].(string)
bots := models.BotSwitches{}
for _, b := range classes.Bots {
if raw, ok := in.Values[classes.BotField(b.Token)]; ok {
bots[b.Token] = truthy(raw)
}
}
return pact.AdminActionResult{Data: robotsTestResult{Op: "test", URL: path, RobotsTest: svc.TestRobots(content, bots, agent, path)}}, nil
},
}
}

View File

@@ -8,9 +8,10 @@ import (
)
// FormFieldTypes registers the SEO field types (classes.FieldTypes):
// seotext, mlseotext, seorobots, mlseorobots and seoimage. Their element
// bundle loads on every admin page, so the SEO tab works on other plugins'
// forms.
// seotext, mlseotext, seorobots, mlseorobots, seoimage and robotstxt.
// Their element bundle loads on every admin page, so the SEO tab works on
// other plugins' forms and the robots.txt tester element (E5) is defined
// wherever its widget renders.
func (p *Plugin) FormFieldTypes() []pact.FormFieldType {
return classes.FieldTypes(func() *backpack.App {
if p == nil {

View File

@@ -2,6 +2,7 @@ plugin:
name: SEO
description: Search engine metadata for every content type, URL rules and global SEO settings.
nav:
robots: robots.txt
notfound: 404 log
redirects: Redirects
seo: SEO
@@ -38,6 +39,23 @@ robots:
inherit: Inherit
site_default: Site default
staging_title: This is not the production environment
title: "robots.txt"
tab_file: "robots.txt"
tab_bots: "AI bots"
staging_text: "Crawlers get “Disallow: /” and every page is sent with “X-Robots-Tag: noindex”, whatever you save here. The text below applies in production."
content: "robots.txt rules"
content_comment: "Sitemap lines and the AI bot rules are added automatically when the file is served."
tester: "Test an address"
served: "What crawlers get"
served_comment: "The saved version, as served at /robots.txt. Save to update it."
served_production: "Production"
too_large: "robots.txt may be at most 500 KB. Google ignores everything after that."
test: "Test URL"
test_invalid: "The test request is not valid."
url_required: "Enter an address starting with /."
blocks_noindex:
one: ":count noindex address is blocked by these rules, so Google cannot see its noindex: :list"
other: ":count noindex addresses are blocked by these rules, so Google cannot see their noindex: :list"
status:
rule_title: A URL rule overrides part of this tab
rule_text: The rule for “:url” sets :fields. Change them in SEO › URL rules.
@@ -268,3 +286,7 @@ notfound:
delete_selected_confirm:
one: "Remove :count address from the log? It is added again on its next 404."
other: "Remove :count addresses from the log? They are added again on their next 404."
bots:
search_comment: "AI search and answers. Off blocks it in robots.txt."
training_comment: "Collects content for AI training. Off blocks it in robots.txt."
user_comment: "Fetches a page when a user asks for it. Off blocks it in robots.txt."

View File

@@ -2,6 +2,7 @@ plugin:
name: SEO
description: Metadane dla wyszukiwarek we wszystkich typach treści, reguły adresów i globalne ustawienia SEO.
nav:
robots: robots.txt
notfound: Dziennik 404
redirects: Przekierowania
seo: SEO
@@ -38,6 +39,25 @@ robots:
inherit: Dziedzicz
site_default: Domyślne
staging_title: To nie jest środowisko produkcyjne
title: "robots.txt"
tab_file: "robots.txt"
tab_bots: "Boty AI"
staging_text: "Roboty dostają „Disallow: /”, a każda strona jest wysyłana z „X-Robots-Tag: noindex”, niezależnie od tego, co tu zapiszesz. Poniższy tekst obowiązuje na produkcji."
content: "Reguły robots.txt"
content_comment: "Wiersze Sitemap i reguły botów AI są dodawane automatycznie przy serwowaniu pliku."
tester: "Sprawdź adres"
served: "Co otrzymują roboty"
served_comment: "Zapisana wersja, tak jak jest serwowana pod /robots.txt. Zapisz, aby ją odświeżyć."
served_production: "Produkcja"
too_large: "robots.txt może mieć najwyżej 500 KB. Google ignoruje wszystko powyżej."
test: "Sprawdź adres"
test_invalid: "Żądanie testu jest nieprawidłowe."
url_required: "Wpisz adres zaczynający się od /."
blocks_noindex:
one: ":count adres z noindex jest blokowany przez te reguły, więc Google nie zobaczy noindex: :list"
few: ":count adresy z noindex są blokowane przez te reguły, więc Google nie zobaczy noindex: :list"
many: ":count adresów z noindex jest blokowanych przez te reguły, więc Google nie zobaczy noindex: :list"
other: ":count adresu z noindex jest blokowane przez te reguły, więc Google nie zobaczy noindex: :list"
status:
rule_title: Reguła adresu nadpisuje część tej zakładki
rule_text: 'Reguła dla „:url” ustawia: :fields. Zmień je w SEO › Reguły adresów.'
@@ -276,3 +296,7 @@ notfound:
few: "Usunąć :count adresy z dziennika? Wrócą przy następnym błędzie 404."
many: "Usunąć :count adresów z dziennika? Wrócą przy następnym błędzie 404."
other: "Usunąć :count adresu z dziennika? Wrócą przy następnym błędzie 404."
bots:
search_comment: "Wyszukiwanie i odpowiedzi AI. Wyłączenie blokuje go w robots.txt."
training_comment: "Zbiera treści do trenowania AI. Wyłączenie blokuje go w robots.txt."
user_comment: "Pobiera stronę na prośbę użytkownika. Wyłączenie blokuje go w robots.txt."

16
models/robots/fields.yaml Normal file
View File

@@ -0,0 +1,16 @@
# robots.txt screen (UI-SPEC C10), tab robots.txt: the staging callout and
# the served file (P4). The plugin's own form extension on the robots
# controller adds the editor (E4) and the tester (E5) between them, and the
# AI bots tab.
fields:
staging:
type: partial
path: staging
span: full
tab: golem15.seo::lang.robots.tab_file
served:
label: golem15.seo::lang.robots.served
type: partial
path: robots_served
span: full
tab: golem15.seo::lang.robots.tab_file

View File

@@ -113,3 +113,81 @@ func TestRobotsTester(t *testing.T) {
t.Fatalf("a bot that is on = %+v, want allowed", got)
}
}
// TestRobotsScreen smoke-tests the robots.txt screen (UI-SPEC C10, D-22,
// D-24, D-25): the form layout, a save of the rules and a bot switch with
// history, the noindex-blocked notice, the size refusal, the tester action
// on unsaved values and the served-file partial.
func TestRobotsScreen(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
api := adminAPI("/golem15/seo/robots")
fields := env.formSchema(t, token, "golem15/seo/robots")
var names []string
for _, f := range fields {
names = append(names, f.Name)
}
if strings.Join(names[:4], ",") != "staging,content,tester,served" || len(names) != 4+11 || names[4] != "bot_oai_searchbot" || names[8] != "bot_claude_user" {
t.Fatalf("robots form fields = %v, want staging, content, tester, served, then eleven bot switches", names)
}
env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/private/x", "match": "exact", "robots": "noindex,follow"})
rec := env.expect(t, http.StatusOK, http.MethodPut, api+"/1", token, map[string]any{
"content": "User-agent: *\r\nDisallow: /private\r\n", "bot_gptbot": false,
})
var notice bool
for _, n := range saveNotices(t, rec.Body.Bytes()) {
if n.Field == "content" && strings.Contains(n.Message, "1 noindex address is blocked") && strings.Contains(n.Message, "/private/x") {
notice = true
}
}
if !notice {
t.Fatalf("save = %s, want robots.blocks_noindex on content", rec.Body.String())
}
var row struct {
Content string
Bots string
}
if err := env.db.Raw(`SELECT content, bots::text AS bots FROM golem15_seo_robots WHERE id = 1`).Scan(&row).Error; err != nil {
t.Fatal(err)
}
if row.Content != "User-agent: *\nDisallow: /private\n" || !strings.Contains(row.Bots, `"GPTBot": false`) {
t.Fatalf("stored robots = %+v, want the rules with Unix line ends and GPTBot off", row)
}
if rows := env.revisions(t, "robots"); len(rows) != 1 || rows[0].Via != "admin" {
t.Fatalf("robots history = %+v, want one admin revision", rows)
}
show := env.expect(t, http.StatusOK, http.MethodGet, api+"/1", token, nil).Body.String()
if !strings.Contains(show, `"bot_gptbot":false`) || !strings.Contains(show, `"bot_ccbot":true`) {
t.Fatalf("show = %s, want the switches", show)
}
served := env.expect(t, http.StatusOK, http.MethodGet, api+"/partials/robots_served?id=1", token, nil).Body.String()
if !strings.Contains(served, "User-agent: GPTBot") || !strings.Contains(served, "Sitemap: "+siteURL+"/sitemap.xml") {
t.Fatalf("served partial = %s, want the composed file", served)
}
rec = env.call(t, http.MethodPut, api+"/1", token, map[string]any{"content": strings.Repeat("#", 501*1024)})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "at most 500 KB") {
t.Fatalf("oversized robots.txt = %d, want 422 robots.too_large", rec.Code)
}
rec = env.expect(t, http.StatusOK, http.MethodPost, api+"/widgets/tester", token, map[string]any{
"record_id": 1, "values": map[string]any{"content": "User-agent: *\nDisallow: /a\n", "bot_gptbot": true},
"payload": map[string]any{"op": "test", "url": "/a/b", "agent": "Googlebot"},
})
if body := rec.Body.String(); !strings.Contains(body, `"allowed":false`) || !strings.Contains(body, `"line":2`) || !strings.Contains(body, `"rule":"Disallow: /a"`) {
t.Fatalf("tester = %s, want blocked by line 2 of the unsaved text", body)
}
rec = env.expect(t, http.StatusOK, http.MethodPost, api+"/widgets/tester", token, map[string]any{
"record_id": 1, "values": map[string]any{"content": "", "bot_claude_user": false},
"payload": map[string]any{"op": "test", "url": "/", "agent": "Claude-User"},
})
if body := rec.Body.String(); !strings.Contains(body, `"bot":true`) {
t.Fatalf("tester with Claude-User off = %s, want blocked by its switch", body)
}
rec = env.expect(t, http.StatusOK, http.MethodPost, api+"/widgets/tester", token, map[string]any{"record_id": 1, "payload": map[string]any{"op": "context"}})
if body := rec.Body.String(); !strings.Contains(body, `"Claude-User"`) || !strings.Contains(body, `"*"`) || !strings.Contains(body, `"Googlebot-Image"`) {
t.Fatalf("tester context = %s, want the crawler list", body)
}
}