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:
@@ -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
2
assets/dist/seo.css
vendored
File diff suppressed because one or more lines are too long
2
assets/dist/seo.js
vendored
2
assets/dist/seo.js
vendored
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
101
controllers/robots.go
Normal 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{}
|
||||
)
|
||||
10
controllers/robots/_robots_served.htm
Normal file
10
controllers/robots/_robots_served.htm
Normal 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 -}}
|
||||
6
controllers/robots/_staging.htm
Normal file
6
controllers/robots/_staging.htm
Normal 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 -}}
|
||||
13
controllers/robots/config_form.yaml
Normal file
13
controllers/robots/config_form.yaml
Normal 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
|
||||
@@ -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
252
extensions_robots.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
16
models/robots/fields.yaml
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user