Compare commits

..

No commits in common. "main" and "v1.1.0" have entirely different histories.
main ... v1.1.0

36 changed files with 255 additions and 5128 deletions

View file

@ -3,8 +3,8 @@
![PHP 8.0+](https://img.shields.io/badge/PHP-8.0%2B-blue) ![PHP 8.0+](https://img.shields.io/badge/PHP-8.0%2B-blue)
![WordPress 6.0+](https://img.shields.io/badge/WordPress-6.0%2B-21759b) ![WordPress 6.0+](https://img.shields.io/badge/WordPress-6.0%2B-21759b)
![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0--or--later-green) ![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0--or--later-green)
![Version](https://img.shields.io/badge/Version-1.3.0-orange) ![Version](https://img.shields.io/badge/Version-1.1.0-orange)
![Tests](https://img.shields.io/badge/Tests-163%20passing-brightgreen) ![Tests](https://img.shields.io/badge/Tests-112%20passing-brightgreen)
🇬🇧 [English version → README.md](README.md) 🇬🇧 [English version → README.md](README.md)
@ -26,7 +26,7 @@ Die KI-Welle hat es schlimmer gemacht. Plugins fingen an, „KI-gestützte" Feat
BreznGEO verfolgt einen anderen Ansatz: BreznGEO verfolgt einen anderen Ansatz:
- **Direkter API-Zugriff.** Du hinterlegst deinen eigenen Key von OpenAI, Anthropic, Google, xAI oder OpenRouter (600+ Modelle über einen Key). BreznGEO ruft die API direkt auf. Kein Mittelsmann, keine Marge, keine Daten über Server Dritter. - **Direkter API-Zugriff.** Du hinterlegst deinen eigenen Key von OpenAI, Anthropic, Google oder xAI. BreznGEO ruft die API direkt auf. Kein Mittelsmann, keine Marge, keine Daten über Server Dritter.
- **Klarer Output, kein Lärm.** Metabeschreibungen, Strukturdaten, KI-Inhaltsblöcke für GEO, Bot-Steuerung. Keine Lesbarkeits-Scores, keine Keyword-Dichte-Meter, keine Upsell-Banner. - **Klarer Output, kein Lärm.** Metabeschreibungen, Strukturdaten, KI-Inhaltsblöcke für GEO, Bot-Steuerung. Keine Lesbarkeits-Scores, keine Keyword-Dichte-Meter, keine Upsell-Banner.
- **Keine Subscription.** GPL-2.0. Kostenlos auf beliebig vielen Sites nutzbar. Die einzigen Kosten sind die API-Nutzung — typischerweise Bruchteile eines Cents pro Beitrag. - **Keine Subscription.** GPL-2.0. Kostenlos auf beliebig vielen Sites nutzbar. Die einzigen Kosten sind die API-Nutzung — typischerweise Bruchteile eines Cents pro Beitrag.
- **Keine Telemetrie.** BreznGEO sendet keine Daten nach Hause. Kein Usage-Tracking, kein Remote-Logging, keine Analytics, die den eigenen Server verlassen. - **Keine Telemetrie.** BreznGEO sendet keine Daten nach Hause. Kein Usage-Tracking, kein Remote-Logging, keine Analytics, die den eigenen Server verlassen.
@ -65,7 +65,6 @@ brezngeo/
│ ├── editor-meta.js # Meta Editor Box: Live-Zähler, KI-Regen-Button │ ├── editor-meta.js # Meta Editor Box: Live-Zähler, KI-Regen-Button
│ ├── geo-editor.js # GEO Block Editor: Generieren / Löschen Button │ ├── geo-editor.js # GEO Block Editor: Generieren / Löschen Button
│ ├── geo-frontend.css # Minimales Stylesheet für .brezngeo-geo auf dem Frontend │ ├── geo-frontend.css # Minimales Stylesheet für .brezngeo-geo auf dem Frontend
│ ├── keyword-analysis.js # Keyword-Analyse: AJAX-Checks, Ergebnis-Rendering, KI-Handler
│ ├── link-suggest.js # Interne Link-Vorschläge: Trigger, UI, Apply (Gutenberg + Classic) │ ├── link-suggest.js # Interne Link-Vorschläge: Trigger, UI, Apply (Gutenberg + Classic)
│ └── seo-widget.js # SEO Analyse Widget: Live-Auswertung im Editor │ └── seo-widget.js # SEO Analyse Widget: Live-Auswertung im Editor
├── includes/ ├── includes/
@ -75,8 +74,6 @@ brezngeo/
│ │ ├── BulkPage.php # Bulk Generator Admin-Seite │ │ ├── BulkPage.php # Bulk Generator Admin-Seite
│ │ ├── GeoEditorBox.php # GEO Block Meta-Box im Post-Editor │ │ ├── GeoEditorBox.php # GEO Block Meta-Box im Post-Editor
│ │ ├── GeoPage.php # GEO Block Einstellungsseite │ │ ├── GeoPage.php # GEO Block Einstellungsseite
│ │ ├── KeywordMetaBox.php # Keyword-Analyse Meta-Box im Post-Editor
│ │ ├── KeywordPage.php # Keyword-Analyse Einstellungsseite
│ │ ├── LinkAnalysis.php # AJAX-Handler für Link-Analyse Dashboard │ │ ├── LinkAnalysis.php # AJAX-Handler für Link-Analyse Dashboard
│ │ ├── LinkSuggestPage.php # Einstellungsseite für interne Link-Vorschläge │ │ ├── LinkSuggestPage.php # Einstellungsseite für interne Link-Vorschläge
│ │ ├── MetaEditorBox.php # Meta Description Meta-Box im Post-Editor │ │ ├── MetaEditorBox.php # Meta Description Meta-Box im Post-Editor
@ -93,7 +90,6 @@ brezngeo/
│ │ ├── GeoBlock.php # GEO Quick Overview Block (Frontend-Ausgabe) │ │ ├── GeoBlock.php # GEO Quick Overview Block (Frontend-Ausgabe)
│ │ ├── LlmsTxt.php # /llms.txt Endpunkt mit ETag/Cache │ │ ├── LlmsTxt.php # /llms.txt Endpunkt mit ETag/Cache
│ │ ├── LinkSuggest.php # Interne Link-Vorschläge: Matching-Engine + AJAX-Handler + Meta-Box │ │ ├── LinkSuggest.php # Interne Link-Vorschläge: Matching-Engine + AJAX-Handler + Meta-Box
│ │ ├── KeywordAnalysis.php # Keyword-Analyse Engine: 10 SEO-Checks
│ │ ├── MetaGenerator.php # Kernlogik: KI-Aufruf, Speichern, Bulk, AJAX │ │ ├── MetaGenerator.php # Kernlogik: KI-Aufruf, Speichern, Bulk, AJAX
│ │ ├── RobotsTxt.php # robots.txt Bot-Blocking via WP-Filter │ │ ├── RobotsTxt.php # robots.txt Bot-Blocking via WP-Filter
│ │ └── SchemaEnhancer.php # JSON-LD Schema.org Ausgabe in wp_head │ │ └── SchemaEnhancer.php # JSON-LD Schema.org Ausgabe in wp_head
@ -101,16 +97,14 @@ brezngeo/
│ │ ├── BulkQueue.php # Mutex-Lock für Bulk-Prozesse (Transient-basiert) │ │ ├── BulkQueue.php # Mutex-Lock für Bulk-Prozesse (Transient-basiert)
│ │ ├── FallbackMeta.php # Meta-Extraktion aus Post-Content ohne KI │ │ ├── FallbackMeta.php # Meta-Extraktion aus Post-Content ohne KI
│ │ ├── KeyVault.php # API-Key Verschleierung vor dem Schreiben in die DB │ │ ├── KeyVault.php # API-Key Verschleierung vor dem Schreiben in die DB
│ │ ├── KeywordVariants.php # Locale-basierte Keyword-Varianten (EN/DE)
│ │ └── TokenEstimator.php # Grobe Token-Schätzung für Kostenvorschau im Bulk │ │ └── TokenEstimator.php # Grobe Token-Schätzung für Kostenvorschau im Bulk
│ └── Providers/ │ └── Providers/
│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText │ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
│ ├── ProviderRegistry.php # Registry-Pattern: Provider registrieren und abrufen │ ├── ProviderRegistry.php # Registry-Pattern: Provider registrieren und abrufen
│ ├── AnthropicProvider.php # Claude API (Messages API) │ ├── AnthropicProvider.php # Claude API (Messages API)
│ ├── GeminiProvider.php # Google Gemini (generateContent API) │ ├── GeminiProvider.php # Google Gemini (generateContent API)
│ ├── GrokProvider.php # xAI Grok (OpenAI-kompatibler Endpunkt) │ ├── GrokProvider.php # xAI Grok (OpenAI-kompatibler Endpunkt)
│ ├── OpenAIProvider.php # OpenAI GPT (Chat Completions API) │ └── OpenAIProvider.php # OpenAI GPT (Chat Completions API)
│ └── OpenRouterProvider.php # OpenRouter (600+ Modelle über eine OpenAI-kompatible API)
└── vendor/ # Composer-Abhängigkeiten (nur Produktionsstand) └── vendor/ # Composer-Abhängigkeiten (nur Produktionsstand)
``` ```
@ -207,36 +201,6 @@ Batch-Verarbeitung aller veröffentlichten Beiträge ohne Meta-Beschreibung. Lä
--- ---
### Keyword-Analyse
Meta-Box im Post-Editor, die die Keyword-Nutzung über zehn On-Page-SEO-Checks analysiert:
| Check | Was geprüft wird |
|---|---|
| Titel | Keyword im Beitragstitel vorhanden |
| Überschriften | Keyword in mindestens einer H2H6 |
| Dichte | Keyword-Dichte im Zielbereich (Standard 0,5 %2,5 %) |
| Bild-Alt | Mindestens ein Bild hat das Keyword im `alt`-Attribut |
| Meta Description | Keyword in der Meta-Beschreibung |
| Slug | Keyword in der URL |
| Erster Absatz | Keyword im ersten Absatz |
| Letzter Absatz | Keyword im letzten Absatz |
| Bild-Titel/Caption | Keyword in mindestens einem Bild-`title` oder `<figcaption>` |
| Excerpt | Keyword im Beitragsauszug |
**Primär- + Sekundär-Keywords:** Das Hauptkeyword wird mit strengeren Schwellenwerten geprüft, Sekundär-Keywords mit gelockerten Minimums.
**Update-Modi:** `live` (debounced beim Tippen), `manual` (Button-Klick) oder `save` (beim Speichern).
**Locale-basierte Varianten:** `KeywordVariants` generiert Compound-Formen (Leerzeichen ↔ Bindestrich), Trailing-s und sprachspezifische Suffixe (EN: -es, -ing, -ed; DE: -er, -en, -e).
**Optionale KI-Features** (bei konfiguriertem API-Key):
- **Suggest** — KI-generierte Keyword-Vorschläge basierend auf dem Artikelinhalt
- **Optimize** — Content-Optimierungstipps für das aktuelle Keyword
- **Semantic** — verwandte semantische Keywords für breitere Themenabdeckung
---
### Crawler Log ### Crawler Log
Loggt Besuche bekannter KI-Bots in der Tabelle `{prefix}brezngeo_crawler_log` (bot_name, ip_hash SHA-256, url, visited_at). Einträge älter als 90 Tage werden automatisch bereinigt. Dashboard zeigt 30-Tage-Zusammenfassung. Loggt Besuche bekannter KI-Bots in der Tabelle `{prefix}brezngeo_crawler_log` (bot_name, ip_hash SHA-256, url, visited_at). Einträge älter als 90 Tage werden automatisch bereinigt. Dashboard zeigt 30-Tage-Zusammenfassung.
@ -255,7 +219,6 @@ Loggt Besuche bekannter KI-Bots in der Tabelle `{prefix}brezngeo_crawler_log` (b
| `brezngeo_geo_settings` | GEO Block: Modus, Position, Labels, CSS, Prompt, Farbschema | | `brezngeo_geo_settings` | GEO Block: Modus, Position, Labels, CSS, Prompt, Farbschema |
| `brezngeo_robots_settings` | robots.txt: blockierte Bots | | `brezngeo_robots_settings` | robots.txt: blockierte Bots |
| `brezngeo_llms_settings` | llms.txt: Titel, Beschreibung, Featured-Links, Footer, Seitenanzahl | | `brezngeo_llms_settings` | llms.txt: Titel, Beschreibung, Featured-Links, Footer, Seitenanzahl |
| `brezngeo_keyword_settings` | Keyword-Analyse: Update-Modus, Zieldichte, Min. Vorkommen, Post-Types, Debounce |
| `brezngeo_usage_stats` | Akkumulierte Token-Nutzung: `tokens_in`, `tokens_out`, `count` | | `brezngeo_usage_stats` | Akkumulierte Token-Nutzung: `tokens_in`, `tokens_out`, `count` |
| `brezngeo_first_activated` | Unix-Timestamp der Erstaktivierung (für Welcome Notice) | | `brezngeo_first_activated` | Unix-Timestamp der Erstaktivierung (für Welcome Notice) |
@ -269,9 +232,6 @@ Loggt Besuche bekannter KI-Bots in der Tabelle `{prefix}brezngeo_crawler_log` (b
| `_bre_geo_summary` | GEO Block Summary | | `_bre_geo_summary` | GEO Block Summary |
| `_bre_geo_bullets` | GEO Block Key Points (JSON-Array) | | `_bre_geo_bullets` | GEO Block Key Points (JSON-Array) |
| `_bre_geo_faq` | GEO Block FAQ (JSON-Array) | | `_bre_geo_faq` | GEO Block FAQ (JSON-Array) |
| `_brezngeo_keyword_main` | Primäres Keyword |
| `_brezngeo_keyword_secondary` | Sekundäre Keywords (kommagetrennt) |
| `_brezngeo_keyword_results` | Gecachte Analyse-Ergebnisse (JSON) |
### Transients ### Transients
@ -283,7 +243,7 @@ Loggt Besuche bekannter KI-Bots in der Tabelle `{prefix}brezngeo_crawler_log` (b
| `brezngeo_meta_stats` | 5 Minuten | Dashboard Meta-Coverage-Abfrage | | `brezngeo_meta_stats` | 5 Minuten | Dashboard Meta-Coverage-Abfrage |
| `brezngeo_crawler_summary` | 5 Minuten | Dashboard Crawler-Zusammenfassung (letzte 30 Tage) | | `brezngeo_crawler_summary` | 5 Minuten | Dashboard Crawler-Zusammenfassung (letzte 30 Tage) |
> **Uninstall:** `uninstall.php` löscht `brezngeo_settings`, `brezngeo_keyword_settings` und die Post-Meta-Keys `_brezngeo_meta_description`, `_brezngeo_keyword_main`, `_brezngeo_keyword_secondary`, `_brezngeo_keyword_results` für alle Posts. Die übrigen Option-Keys und die `brezngeo_crawler_log`-Tabelle müssen manuell gelöscht werden. > **Uninstall:** `uninstall.php` löscht `brezngeo_settings` und `_bre_meta_description` für alle Posts. Die übrigen Option-Keys und die `brezngeo_crawler_log`-Tabelle müssen manuell gelöscht werden.
--- ---
@ -300,11 +260,10 @@ Kein `openssl_*` oder externe Extension nötig — läuft auf jeder PHP 8.0+ Ins
**Sicherheitsgrenzen:** XOR mit statischem Salt ist Verschleierung, keine kryptografische Verschlüsselung. Für maximale Sicherheit können Keys als `wp-config.php`-Konstanten definiert werden: **Sicherheitsgrenzen:** XOR mit statischem Salt ist Verschleierung, keine kryptografische Verschlüsselung. Für maximale Sicherheit können Keys als `wp-config.php`-Konstanten definiert werden:
```php ```php
define( 'BREZNGEO_OPENAI_KEY', 'sk-...' ); define( 'BREZNGEO_OPENAI_KEY', 'sk-...' );
define( 'BREZNGEO_ANTHROPIC_KEY', 'sk-ant-...' ); define( 'BREZNGEO_ANTHROPIC_KEY', 'sk-ant-...' );
define( 'BREZNGEO_GEMINI_KEY', 'AI...' ); define( 'BREZNGEO_GEMINI_KEY', 'AI...' );
define( 'BREZNGEO_GROK_KEY', 'xai-...' ); define( 'BREZNGEO_GROK_KEY', 'xai-...' );
define( 'BREZNGEO_OPENROUTER_KEY', 'sk-or-...' );
``` ```
### CSRF-Schutz und Capability Checks ### CSRF-Schutz und Capability Checks
@ -338,9 +297,6 @@ CrawlerLog speichert IPs ausschließlich als SHA-256-Hash. Originalwert wird nie
| Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` | | Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` |
| Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` | | Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` |
| xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` | | xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` |
| OpenRouter | `OpenRouterProvider` | `https://openrouter.ai/api/v1/chat/completions` |
**Zu OpenRouter:** Ein einziger API-Key öffnet den Zugang zu 600+ Modellen von OpenAI, Anthropic, Google, Meta, Mistral, xAI, DeepSeek u.v.m. Die kuratierte Marketing/SEO-Auswahl wird on demand geladen ("Modelle laden"-Button) und 12 Stunden im Transient gecached. Preise werden automatisch aus OpenRouter übernommen. Eigene Modell-IDs (z. B. `anthropic/claude-opus-4.7`) werden unterstützt.
Neuen Provider hinzufügen: `ProviderInterface` implementieren, in `Core.php` via `$registry->register()` eintragen — erscheint automatisch in allen Dropdowns. Neuen Provider hinzufügen: `ProviderInterface` implementieren, in `Core.php` via `$registry->register()` eintragen — erscheint automatisch in allen Dropdowns.
@ -376,7 +332,6 @@ Alle Endpunkte erfordern `manage_options` (kein `nopriv`).
| `brezngeo_regen_meta` | `MetaEditorBox::ajax_regen` | Meta-Beschreibung für einzelnen Post neu generieren | | `brezngeo_regen_meta` | `MetaEditorBox::ajax_regen` | Meta-Beschreibung für einzelnen Post neu generieren |
| `brezngeo_test_connection` | `ProviderPage::ajax_test_connection` | API-Key und Verbindung testen | | `brezngeo_test_connection` | `ProviderPage::ajax_test_connection` | API-Key und Verbindung testen |
| `brezngeo_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Standard-Prompt zurücksetzen | | `brezngeo_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Standard-Prompt zurücksetzen |
| `brezngeo_openrouter_load_models` | `ProviderPage::ajax_openrouter_load_models` | Kuratierte OpenRouter-Marketing/SEO-Modell-Liste laden (12 h Transient-Cache) |
| `brezngeo_link_analysis` | `LinkAnalysis::ajax_analyse` | Link-Analyse ausführen | | `brezngeo_link_analysis` | `LinkAnalysis::ajax_analyse` | Link-Analyse ausführen |
| `brezngeo_link_suggestions` | `LinkSuggest::ajax_suggest` | Top-10 interne Link-Vorschläge für aktuellen Beitrag zurückgeben | | `brezngeo_link_suggestions` | `LinkSuggest::ajax_suggest` | Top-10 interne Link-Vorschläge für aktuellen Beitrag zurückgeben |
| `brezngeo_geo_generate` | `GeoEditorBox::ajax_generate` | GEO Block generieren | | `brezngeo_geo_generate` | `GeoEditorBox::ajax_generate` | GEO Block generieren |
@ -388,10 +343,6 @@ Alle Endpunkte erfordern `manage_options` (kein `nopriv`).
| `brezngeo_bulk_stats` | `MetaGenerator::ajaxBulkStats` | Fortschritt abrufen | | `brezngeo_bulk_stats` | `MetaGenerator::ajaxBulkStats` | Fortschritt abrufen |
| `brezngeo_bulk_release` | `MetaGenerator::ajaxBulkRelease` | Mutex-Lock manuell freigeben | | `brezngeo_bulk_release` | `MetaGenerator::ajaxBulkRelease` | Mutex-Lock manuell freigeben |
| `brezngeo_bulk_status` | `MetaGenerator::ajaxBulkStatus` | Lock-Status prüfen | | `brezngeo_bulk_status` | `MetaGenerator::ajaxBulkStatus` | Lock-Status prüfen |
| `brezngeo_keyword_analyze` | `KeywordMetaBox::ajax_analyze` | Keyword-Analyse für einen Post ausführen |
| `brezngeo_keyword_ai_suggest` | `KeywordMetaBox::ajax_ai_suggest` | KI-Keyword-Vorschläge |
| `brezngeo_keyword_ai_optimize` | `KeywordMetaBox::ajax_ai_optimize` | KI-Content-Optimierungstipps |
| `brezngeo_keyword_ai_semantic` | `KeywordMetaBox::ajax_ai_semantic` | KI-semantische Keyword-Analyse |
--- ---
@ -427,7 +378,7 @@ Kein JavaScript-Build-Step. Alle Assets unter `assets/` sind direkte JS/CSS-Date
| Caching | WordPress Transients | | Caching | WordPress Transients |
| Frontend | Vanilla JS + jQuery (WordPress-integriert), kein Build-Step | | Frontend | Vanilla JS + jQuery (WordPress-integriert), kein Build-Step |
| I18n | `.pot`-File, Text-Domain `brezngeo` | | I18n | `.pot`-File, Text-Domain `brezngeo` |
| Tests | PHPUnit (163 Tests, 311 Assertions) | | Tests | PHPUnit (102 Tests, 216 Assertions) |
| Coding Standard | WordPress PHPCS | | Coding Standard | WordPress PHPCS |
| Lizenz | GPL-2.0-or-later | | Lizenz | GPL-2.0-or-later |

View file

@ -3,8 +3,8 @@
![PHP 8.0+](https://img.shields.io/badge/PHP-8.0%2B-blue) ![PHP 8.0+](https://img.shields.io/badge/PHP-8.0%2B-blue)
![WordPress 6.0+](https://img.shields.io/badge/WordPress-6.0%2B-21759b) ![WordPress 6.0+](https://img.shields.io/badge/WordPress-6.0%2B-21759b)
![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0--or--later-green) ![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0--or--later-green)
![Version](https://img.shields.io/badge/Version-1.3.0-orange) ![Version](https://img.shields.io/badge/Version-1.1.0-orange)
![Tests](https://img.shields.io/badge/Tests-163%20passing-brightgreen) ![Tests](https://img.shields.io/badge/Tests-112%20passing-brightgreen)
🇩🇪 [Deutsche Version → README.de.md](README.de.md) 🇩🇪 [Deutsche Version → README.de.md](README.de.md)
@ -26,7 +26,7 @@ The AI wave made it worse. Plugins started offering "AI-powered" features — bu
BreznGEO takes a different approach: BreznGEO takes a different approach:
- **Direct API access.** You store your own key from OpenAI, Anthropic, Google, xAI, or OpenRouter (600+ models through one key). BreznGEO calls the API directly. No middleman, no margin, no data passing through third-party servers. - **Direct API access.** You store your own key from OpenAI, Anthropic, Google, or xAI. BreznGEO calls the API directly. No middleman, no margin, no data passing through third-party servers.
- **Clear output, not noise.** Meta descriptions, structured data, GEO content blocks, bot management. No readability scores, no keyword density meters, no upsell banners. - **Clear output, not noise.** Meta descriptions, structured data, GEO content blocks, bot management. No readability scores, no keyword density meters, no upsell banners.
- **No subscription.** GPL-2.0. Free to use on any number of sites. The only costs are API usage — typically fractions of a cent per post. - **No subscription.** GPL-2.0. Free to use on any number of sites. The only costs are API usage — typically fractions of a cent per post.
- **No telemetry.** BreznGEO sends no data home. No usage tracking, no remote logging, no analytics leaving your server. - **No telemetry.** BreznGEO sends no data home. No usage tracking, no remote logging, no analytics leaving your server.
@ -65,7 +65,6 @@ brezngeo/
│ ├── editor-meta.js # Meta editor box: live counter, AI regen button │ ├── editor-meta.js # Meta editor box: live counter, AI regen button
│ ├── geo-editor.js # GEO block editor: generate / clear button │ ├── geo-editor.js # GEO block editor: generate / clear button
│ ├── geo-frontend.css # Minimal stylesheet for .brezngeo-geo on frontend │ ├── geo-frontend.css # Minimal stylesheet for .brezngeo-geo on frontend
│ ├── keyword-analysis.js # Keyword analysis: AJAX checks, result rendering, AI handlers
│ ├── link-suggest.js # Internal link suggestions: trigger, UI, apply (Gutenberg + Classic) │ ├── link-suggest.js # Internal link suggestions: trigger, UI, apply (Gutenberg + Classic)
│ └── seo-widget.js # SEO analysis widget: live evaluation in editor │ └── seo-widget.js # SEO analysis widget: live evaluation in editor
├── includes/ ├── includes/
@ -75,8 +74,6 @@ brezngeo/
│ │ ├── BulkPage.php # Bulk generator admin page │ │ ├── BulkPage.php # Bulk generator admin page
│ │ ├── GeoEditorBox.php # GEO block meta box in post editor │ │ ├── GeoEditorBox.php # GEO block meta box in post editor
│ │ ├── GeoPage.php # GEO block settings page │ │ ├── GeoPage.php # GEO block settings page
│ │ ├── KeywordMetaBox.php # Keyword analysis meta box in post editor
│ │ ├── KeywordPage.php # Keyword analysis settings page
│ │ ├── LinkAnalysis.php # AJAX handler for link analysis dashboard │ │ ├── LinkAnalysis.php # AJAX handler for link analysis dashboard
│ │ ├── LinkSuggestPage.php # Internal link suggestions settings page │ │ ├── LinkSuggestPage.php # Internal link suggestions settings page
│ │ ├── MetaEditorBox.php # Meta description meta box in post editor │ │ ├── MetaEditorBox.php # Meta description meta box in post editor
@ -93,7 +90,6 @@ brezngeo/
│ │ ├── GeoBlock.php # GEO Quick Overview block (frontend output) │ │ ├── GeoBlock.php # GEO Quick Overview block (frontend output)
│ │ ├── LlmsTxt.php # /llms.txt endpoint with ETag/cache │ │ ├── LlmsTxt.php # /llms.txt endpoint with ETag/cache
│ │ ├── LinkSuggest.php # Internal link suggestions: matching engine + AJAX handler + meta box │ │ ├── LinkSuggest.php # Internal link suggestions: matching engine + AJAX handler + meta box
│ │ ├── KeywordAnalysis.php # Keyword analysis engine: 10 SEO checks
│ │ ├── MetaGenerator.php # Core logic: AI call, save, bulk, AJAX │ │ ├── MetaGenerator.php # Core logic: AI call, save, bulk, AJAX
│ │ ├── RobotsTxt.php # robots.txt bot blocking via WP filter │ │ ├── RobotsTxt.php # robots.txt bot blocking via WP filter
│ │ └── SchemaEnhancer.php # JSON-LD Schema.org output in wp_head │ │ └── SchemaEnhancer.php # JSON-LD Schema.org output in wp_head
@ -101,16 +97,14 @@ brezngeo/
│ │ ├── BulkQueue.php # Mutex lock for bulk processes (transient-based) │ │ ├── BulkQueue.php # Mutex lock for bulk processes (transient-based)
│ │ ├── FallbackMeta.php # Meta extraction from post content without AI │ │ ├── FallbackMeta.php # Meta extraction from post content without AI
│ │ ├── KeyVault.php # API key obfuscation before writing to DB │ │ ├── KeyVault.php # API key obfuscation before writing to DB
│ │ ├── KeywordVariants.php # Locale-aware keyword variant generation (EN/DE)
│ │ └── TokenEstimator.php # Rough token estimate for cost preview in bulk │ │ └── TokenEstimator.php # Rough token estimate for cost preview in bulk
│ └── Providers/ │ └── Providers/
│ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText │ ├── ProviderInterface.php # Interface: getId, getName, getModels, testConnection, generateText
│ ├── ProviderRegistry.php # Registry pattern: register and retrieve providers │ ├── ProviderRegistry.php # Registry pattern: register and retrieve providers
│ ├── AnthropicProvider.php # Claude API (Messages API) │ ├── AnthropicProvider.php # Claude API (Messages API)
│ ├── GeminiProvider.php # Google Gemini (generateContent API) │ ├── GeminiProvider.php # Google Gemini (generateContent API)
│ ├── GrokProvider.php # xAI Grok (OpenAI-compatible endpoint) │ ├── GrokProvider.php # xAI Grok (OpenAI-compatible endpoint)
│ ├── OpenAIProvider.php # OpenAI GPT (Chat Completions API) │ └── OpenAIProvider.php # OpenAI GPT (Chat Completions API)
│ └── OpenRouterProvider.php # OpenRouter (600+ models via one OpenAI-compatible API)
└── vendor/ # Composer dependencies (production only) └── vendor/ # Composer dependencies (production only)
``` ```
@ -271,36 +265,6 @@ Entries older than 90 days are automatically cleaned up via weekly cron (`brezng
--- ---
### Keyword Analysis
Meta box in the post editor that analyzes keyword usage across ten on-page SEO checks:
| Check | What it evaluates |
|---|---|
| Title | Keyword present in the post title |
| Headings | Keyword appears in at least one H2H6 |
| Density | Keyword density within target range (default 0.5 %2.5 %) |
| Image Alt | At least one image has the keyword in its `alt` attribute |
| Meta Description | Keyword present in the meta description |
| Slug | Keyword present in the URL slug |
| First Paragraph | Keyword appears in the opening paragraph |
| Last Paragraph | Keyword appears in the closing paragraph |
| Image Title/Caption | Keyword in at least one image `title` or `<figcaption>` |
| Excerpt | Keyword present in the post excerpt |
**Primary + secondary keywords:** The primary keyword is evaluated against all ten checks with stricter thresholds. Secondary keywords support multiple entries and use relaxed minimums.
**Update modes:** `live` (debounced while typing), `manual` (button click), or `save` (on post save).
**Locale-aware variants:** `KeywordVariants` generates compound forms (space ↔ hyphen), trailing-s, and language-specific suffixes (EN: -es, -ing, -ed; DE: -er, -en, -e) to match keyword variations automatically.
**Optional AI features** (when an API key is configured):
- **Suggest** — AI-generated keyword suggestions based on post content
- **Optimize** — content optimization tips for the current keyword
- **Semantic** — related semantic keywords for broader topic coverage
---
### Meta Editor Box ### Meta Editor Box
Meta box in the post editor (Classic and Block Editor): Meta box in the post editor (Classic and Block Editor):
@ -346,7 +310,6 @@ Results are cached for 1 hour in the transient cache (`brezngeo_link_analysis`).
| `brezngeo_geo_settings` | GEO block: mode, position, labels, CSS, prompt, color scheme | | `brezngeo_geo_settings` | GEO block: mode, position, labels, CSS, prompt, color scheme |
| `brezngeo_robots_settings` | robots.txt: blocked bots | | `brezngeo_robots_settings` | robots.txt: blocked bots |
| `brezngeo_llms_settings` | llms.txt: title, description, featured links, footer, page count | | `brezngeo_llms_settings` | llms.txt: title, description, featured links, footer, page count |
| `brezngeo_keyword_settings` | Keyword analysis: update mode, target density, min occurrences, post types, debounce |
| `brezngeo_usage_stats` | Accumulated token usage: `tokens_in`, `tokens_out`, `count` | | `brezngeo_usage_stats` | Accumulated token usage: `tokens_in`, `tokens_out`, `count` |
| `brezngeo_first_activated` | Unix timestamp of first activation (used by welcome notice) | | `brezngeo_first_activated` | Unix timestamp of first activation (used by welcome notice) |
@ -360,9 +323,6 @@ Results are cached for 1 hour in the transient cache (`brezngeo_link_analysis`).
| `_bre_geo_summary` | GEO block summary | | `_bre_geo_summary` | GEO block summary |
| `_bre_geo_bullets` | GEO block key points (JSON array) | | `_bre_geo_bullets` | GEO block key points (JSON array) |
| `_bre_geo_faq` | GEO block FAQ (JSON array) | | `_bre_geo_faq` | GEO block FAQ (JSON array) |
| `_brezngeo_keyword_main` | Primary keyword |
| `_brezngeo_keyword_secondary` | Secondary keywords (comma-separated) |
| `_brezngeo_keyword_results` | Cached analysis results (JSON) |
### Custom Database Table ### Custom Database Table
@ -383,8 +343,8 @@ Results are cached for 1 hour in the transient cache (`brezngeo_link_analysis`).
### Uninstall cleanup ### Uninstall cleanup
`uninstall.php` removes on plugin deletion: `uninstall.php` removes on plugin deletion:
- Options `brezngeo_settings`, `brezngeo_keyword_settings` - Option `brezngeo_settings`
- Post meta `_brezngeo_meta_description`, `_brezngeo_keyword_main`, `_brezngeo_keyword_secondary`, `_brezngeo_keyword_results` for all posts - Post meta `_bre_meta_description` for all posts
> Note: The remaining option keys and the `brezngeo_crawler_log` table are not automatically removed. For full cleanup, delete these manually. > Note: The remaining option keys and the `brezngeo_crawler_log` table are not automatically removed. For full cleanup, delete these manually.
@ -409,11 +369,10 @@ Plaintext key → XOR(key, sha256(AUTH_KEY . SECURE_AUTH_KEY)) → base64
**Security boundary:** XOR with a static salt is obfuscation, not cryptographic encryption. An attacker with access to **both** the database **and** `wp-config.php` can reconstruct the key. For maximum security, keys can be defined as `wp-config.php` constants — these take precedence over the database version: **Security boundary:** XOR with a static salt is obfuscation, not cryptographic encryption. An attacker with access to **both** the database **and** `wp-config.php` can reconstruct the key. For maximum security, keys can be defined as `wp-config.php` constants — these take precedence over the database version:
```php ```php
define( 'BREZNGEO_OPENAI_KEY', 'sk-...' ); define( 'BREZNGEO_OPENAI_KEY', 'sk-...' );
define( 'BREZNGEO_ANTHROPIC_KEY', 'sk-ant-...' ); define( 'BREZNGEO_ANTHROPIC_KEY', 'sk-ant-...' );
define( 'BREZNGEO_GEMINI_KEY', 'AI...' ); define( 'BREZNGEO_GEMINI_KEY', 'AI...' );
define( 'BREZNGEO_GROK_KEY', 'xai-...' ); define( 'BREZNGEO_GROK_KEY', 'xai-...' );
define( 'BREZNGEO_OPENROUTER_KEY', 'sk-or-...' );
``` ```
In the admin UI, keys are always displayed masked: `••••••Ab3c9` (only the last 5 characters visible). In the admin UI, keys are always displayed masked: `••••••Ab3c9` (only the last 5 characters visible).
@ -446,7 +405,7 @@ The crawler log stores IP addresses exclusively as SHA-256 hashes. The original
## AI Providers ## AI Providers
BreznGEO supports five providers, all implementing the same `ProviderInterface`: BreznGEO supports four providers, all implementing the same `ProviderInterface`:
| Provider | Class | API Base URL | | Provider | Class | API Base URL |
|---|---|---| |---|---|---|
@ -454,9 +413,6 @@ BreznGEO supports five providers, all implementing the same `ProviderInterface`:
| Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` | | Anthropic | `AnthropicProvider` | `https://api.anthropic.com/v1/messages` |
| Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` | | Google Gemini | `GeminiProvider` | `https://generativelanguage.googleapis.com/...` |
| xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` | | xAI Grok | `GrokProvider` | `https://api.x.ai/v1/chat/completions` |
| OpenRouter | `OpenRouterProvider` | `https://openrouter.ai/api/v1/chat/completions` |
**About OpenRouter:** A single API key opens access to 600+ models from OpenAI, Anthropic, Google, Meta, Mistral, xAI, DeepSeek and others. The curated Marketing/SEO selection is fetched on demand ("Load models" button) and cached for 12 hours. Pricing is read from OpenRouter automatically. Custom model IDs (e.g. `anthropic/claude-opus-4.7`) are supported.
### Adding a New Provider ### Adding a New Provider
@ -530,7 +486,6 @@ All endpoints are exclusively accessible to logged-in users with `manage_options
| `brezngeo_regen_meta` | `MetaEditorBox::ajax_regen` | Regenerate meta description for a single post | | `brezngeo_regen_meta` | `MetaEditorBox::ajax_regen` | Regenerate meta description for a single post |
| `brezngeo_test_connection` | `ProviderPage::ajax_test_connection` | Test API key and connection | | `brezngeo_test_connection` | `ProviderPage::ajax_test_connection` | Test API key and connection |
| `brezngeo_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Reset to default prompt | | `brezngeo_get_default_prompt` | `ProviderPage::ajax_get_default_prompt` | Reset to default prompt |
| `brezngeo_openrouter_load_models` | `ProviderPage::ajax_openrouter_load_models` | Fetch curated OpenRouter marketing/SEO model list (12 h transient cache) |
| `brezngeo_link_analysis` | `LinkAnalysis::ajax_analyse` | Run link analysis for the dashboard | | `brezngeo_link_analysis` | `LinkAnalysis::ajax_analyse` | Run link analysis for the dashboard |
| `brezngeo_link_suggestions` | `LinkSuggest::ajax_suggest` | Return top-10 internal link suggestions for current post | | `brezngeo_link_suggestions` | `LinkSuggest::ajax_suggest` | Return top-10 internal link suggestions for current post |
| `brezngeo_geo_generate` | `GeoEditorBox::ajax_generate` | Generate GEO block for a single post | | `brezngeo_geo_generate` | `GeoEditorBox::ajax_generate` | Generate GEO block for a single post |
@ -542,10 +497,6 @@ All endpoints are exclusively accessible to logged-in users with `manage_options
| `brezngeo_bulk_stats` | `MetaGenerator::ajaxBulkStats` | Retrieve progress and stats of running bulk | | `brezngeo_bulk_stats` | `MetaGenerator::ajaxBulkStats` | Retrieve progress and stats of running bulk |
| `brezngeo_bulk_release` | `MetaGenerator::ajaxBulkRelease` | Manually release bulk mutex lock | | `brezngeo_bulk_release` | `MetaGenerator::ajaxBulkRelease` | Manually release bulk mutex lock |
| `brezngeo_bulk_status` | `MetaGenerator::ajaxBulkStatus` | Check bulk lock status | | `brezngeo_bulk_status` | `MetaGenerator::ajaxBulkStatus` | Check bulk lock status |
| `brezngeo_keyword_analyze` | `KeywordMetaBox::ajax_analyze` | Run keyword analysis for a post |
| `brezngeo_keyword_ai_suggest` | `KeywordMetaBox::ajax_ai_suggest` | AI keyword suggestions |
| `brezngeo_keyword_ai_optimize` | `KeywordMetaBox::ajax_ai_optimize` | AI content optimization tips |
| `brezngeo_keyword_ai_semantic` | `KeywordMetaBox::ajax_ai_semantic` | AI semantic keyword analysis |
--- ---
@ -582,7 +533,7 @@ The plugin has no JavaScript build step. All assets under `assets/` are direct J
| Caching | WordPress transients (llms.txt, link analysis, bulk lock) | | Caching | WordPress transients (llms.txt, link analysis, bulk lock) |
| Frontend | Vanilla JS + jQuery (WordPress-bundled), no build step | | Frontend | Vanilla JS + jQuery (WordPress-bundled), no build step |
| i18n | `.pot` file, text domain `brezngeo` | | i18n | `.pot` file, text domain `brezngeo` |
| Tests | PHPUnit (163 tests, 311 assertions) | | Tests | PHPUnit (102 tests, 216 assertions) |
| Coding standard | WordPress PHPCS | | Coding standard | WordPress PHPCS |
| License | GPL-2.0-or-later | | License | GPL-2.0-or-later |

View file

@ -33,68 +33,6 @@ jQuery( function ( $ ) {
} ); } );
} ); } );
// OpenRouter: toggle custom-model field when dropdown value is __custom__
function updateOpenRouterCustom() {
var val = $( '#brezngeo-openrouter-model' ).val();
$( '.brezngeo-openrouter-custom-wrap' ).toggle( val === '__custom__' );
}
$( document ).on( 'change', '#brezngeo-openrouter-model', function () {
updateOpenRouterCustom();
var $opt = $( this ).find( 'option:selected' );
var inVal = $opt.data( 'input' );
var outVal = $opt.data( 'output' );
if ( inVal !== undefined && inVal !== '' ) {
$( '#brezngeo-openrouter-pricing' ).html(
'Input $<span class="or-price-input">' + Number( inVal ).toFixed( 4 ) +
'</span> / 1M \u00b7 Output $<span class="or-price-output">' + Number( outVal ).toFixed( 4 ) +
'</span> / 1M'
);
}
} );
if ( $( '#brezngeo-openrouter-model' ).length ) updateOpenRouterCustom();
// OpenRouter: "Load models" button — fetches curated Marketing/SEO list from OpenRouter
$( document ).on( 'click', '.brezngeo-openrouter-load-btn', function () {
var btn = $( this );
var status = $( '.brezngeo-openrouter-load-status' );
var select = $( '#brezngeo-openrouter-model' );
btn.prop( 'disabled', true );
status.removeClass( 'success error' ).text( brezngeoAdmin.testing );
$.post( brezngeoAdmin.ajaxUrl, {
action: 'brezngeo_openrouter_load_models',
nonce: brezngeoAdmin.nonce,
} ).done( function ( res ) {
if ( ! res.success ) {
status.addClass( 'error' ).text( '\u2717 ' + res.data );
return;
}
var models = res.data;
var previous = select.val();
// Preserve the __custom__ option at the bottom
select.find( 'option' ).not( '[value="__custom__"]' ).remove();
select.find( 'option[value=""]' ).remove();
$.each( models, function ( id, meta ) {
var opt = $( '<option></option>' )
.val( id )
.text( meta.label || id )
.attr( 'data-input', meta.input_cost )
.attr( 'data-output', meta.output_cost );
select.find( 'option[value="__custom__"]' ).before( opt );
} );
if ( previous && select.find( 'option[value="' + previous.replace( /"/g, '\\"' ) + '"]' ).length ) {
select.val( previous );
}
select.trigger( 'change' );
status.addClass( 'success' ).text( '\u2713 ' + Object.keys( models ).length );
} ).fail( function () {
status.addClass( 'error' ).text( '\u2717 ' + brezngeoAdmin.networkError );
} ).always( function () {
btn.prop( 'disabled', false );
} );
} );
$( '#brezngeo-reset-prompt' ).on( 'click', function () { $( '#brezngeo-reset-prompt' ).on( 'click', function () {
if ( ! confirm( brezngeoAdmin.resetConfirm ) ) return; if ( ! confirm( brezngeoAdmin.resetConfirm ) ) return;
$.post( brezngeoAdmin.ajaxUrl, { $.post( brezngeoAdmin.ajaxUrl, {

View file

@ -1,327 +0,0 @@
/* global jQuery, wp, brezngeoKeyword, tinyMCE, ajaxurl */
( function ( $ ) {
'use strict';
if ( typeof brezngeoKeyword === 'undefined' ) { return; }
var cfg = brezngeoKeyword;
var i18n = cfg.i18n;
var isRunning = false;
var debounceTimer = null;
var $box = $( '#brezngeo-keyword-box' );
var $main = $( '#brezngeo-keyword-main' );
var $secList = $( '#brezngeo-keyword-secondary-list' );
var $addSec = $( '#brezngeo-keyword-add-secondary' );
var $analyze = $( '#brezngeo-keyword-analyze' );
var $status = $( '#brezngeo-keyword-status' );
var $results = $( '#brezngeo-keyword-results' );
var $aiActions = $( '#brezngeo-keyword-ai-actions' );
var $aiResults = $( '#brezngeo-keyword-ai-results' );
if ( ! $box.length ) { return; }
/* ── Helpers ─────────────────────────────────────────── */
function getContent() {
if ( window.wp && wp.data && wp.data.select( 'core/editor' ) ) {
try {
return wp.data.select( 'core/editor' ).getEditedPostContent();
} catch ( e ) { /* fall through */ }
}
if ( typeof tinyMCE !== 'undefined' && tinyMCE.activeEditor && ! tinyMCE.activeEditor.isHidden() ) {
return tinyMCE.activeEditor.getContent();
}
return $( '#content' ).val() || '';
}
function setStatus( msg, isError ) {
$status.text( msg ).css( 'color', isError ? '#dc3232' : '#46b450' );
if ( msg ) {
setTimeout( function () { $status.text( '' ); }, 5000 );
}
}
function getSecondaryKeywords() {
var keywords = [];
$secList.find( 'input[name="brezngeo_keyword_secondary[]"]' ).each( function () {
var val = $.trim( $( this ).val() );
if ( val ) { keywords.push( val ); }
} );
return keywords;
}
/* ── Secondary keyword repeater ──────────────────────── */
$addSec.on( 'click', function () {
var $row = $( '<div class="brezngeo-keyword-secondary-row" style="display:flex;gap:6px;margin-bottom:4px;">' +
'<input type="text" name="brezngeo_keyword_secondary[]" style="flex:1;box-sizing:border-box;">' +
'<button type="button" class="button brezngeo-keyword-remove-secondary">&times;</button>' +
'</div>' );
$secList.append( $row );
$row.find( 'input' ).focus();
} );
$secList.on( 'click', '.brezngeo-keyword-remove-secondary', function () {
$( this ).closest( '.brezngeo-keyword-secondary-row' ).remove();
} );
/* ── Status icons ────────────────────────────────────── */
function statusIcon( status ) {
switch ( status ) {
case 'pass': return '<span style="color:#46b450;">&#x2705;</span>';
case 'warn': return '<span style="color:#ffb900;">&#x26A0;&#xFE0F;</span>';
case 'fail': return '<span style="color:#dc3232;">&#x274C;</span>';
default: return '';
}
}
/* ── Render results ──────────────────────────────────── */
function renderResults( data ) {
var html = '';
// Main keyword results.
if ( data.main && data.main.checks ) {
html += '<h4 style="margin:0 0 8px;">' + escHtml( data.main.keyword ) + '</h4>';
html += '<div style="margin-bottom:16px;">';
$.each( data.main.checks, function ( i, check ) {
html += '<div style="padding:3px 0;">' +
statusIcon( check.status ) + ' ' +
'<strong>' + escHtml( check.label ) + '</strong> &mdash; ' +
escHtml( check.message ) +
'</div>';
} );
html += '</div>';
}
// Secondary keyword results.
if ( data.secondary ) {
$.each( data.secondary, function ( kw, checks ) {
html += '<h4 style="margin:12px 0 6px;font-size:13px;color:#555;">' + escHtml( kw ) + '</h4>';
$.each( checks, function ( i, check ) {
html += '<div style="padding:2px 0;font-size:13px;">' +
statusIcon( check.status ) + ' ' +
'<strong>' + escHtml( check.label ) + '</strong> &mdash; ' +
escHtml( check.message ) +
'</div>';
} );
} );
}
$results.html( html );
// Show AI action buttons after results are displayed.
if ( $aiActions.length ) {
$aiActions.show();
}
}
function escHtml( str ) {
var div = document.createElement( 'div' );
div.appendChild( document.createTextNode( str || '' ) );
return div.innerHTML;
}
/* ── Core: run analysis ──────────────────────────────── */
function runAnalysis() {
var keyword = $.trim( $main.val() );
if ( ! keyword ) {
setStatus( i18n.noKeyword, true );
return;
}
if ( isRunning ) { return; }
var content = getContent();
isRunning = true;
$analyze.prop( 'disabled', true );
setStatus( i18n.analyzing, false );
$.post( cfg.ajaxUrl, {
action: 'brezngeo_keyword_analyze',
nonce: cfg.nonce,
post_id: cfg.postId,
post_content: content,
main_keyword: keyword,
secondary_keywords: JSON.stringify( getSecondaryKeywords() ),
} )
.done( function ( res ) {
if ( res && res.success ) {
renderResults( res.data );
setStatus( '', false );
} else {
setStatus( res.data || i18n.error, true );
}
} )
.fail( function () {
setStatus( i18n.error, true );
} )
.always( function () {
isRunning = false;
$analyze.prop( 'disabled', false );
} );
}
/* ── Triggers ────────────────────────────────────────── */
// Manual mode: button click.
$analyze.on( 'click', runAnalysis );
// Live mode: debounced on editor changes.
if ( cfg.updateMode === 'live' ) {
function debouncedAnalysis() {
clearTimeout( debounceTimer );
debounceTimer = setTimeout( function () {
if ( $.trim( $main.val() ) ) {
runAnalysis();
}
}, cfg.debounceMs );
}
// Gutenberg.
if ( window.wp && wp.data && wp.data.subscribe ) {
var lastContent = '';
wp.data.subscribe( function () {
var content = getContent();
if ( content !== lastContent ) {
lastContent = content;
debouncedAnalysis();
}
} );
}
// Classic editor.
if ( typeof tinyMCE !== 'undefined' ) {
$( document ).on( 'tinymce-editor-init', function ( evt, editor ) {
editor.on( 'input', debouncedAnalysis );
} );
}
$( '#content' ).on( 'input', debouncedAnalysis );
$main.on( 'input', debouncedAnalysis );
}
// On-save mode.
if ( cfg.updateMode === 'save' ) {
if ( window.wp && wp.data && wp.data.subscribe ) {
var wasSaving = false;
wp.data.subscribe( function () {
var isSaving = wp.data.select( 'core/editor' ).isSavingPost();
if ( wasSaving && ! isSaving ) {
runAnalysis();
}
wasSaving = isSaving;
} );
}
$( '#publish, #save-post' ).on( 'click', function () {
setTimeout( runAnalysis, 500 );
} );
}
/* ── AI: Suggest keywords ────────────────────────────── */
$( '#brezngeo-keyword-ai-suggest' ).on( 'click', function () {
var $btn = $( this );
var content = getContent();
$btn.prop( 'disabled', true );
setStatus( i18n.suggesting, false );
$.post( cfg.ajaxUrl, {
action: 'brezngeo_keyword_ai_suggest',
nonce: cfg.nonce,
post_id: cfg.postId,
post_content: content,
} )
.done( function ( res ) {
if ( res && res.success && res.data ) {
if ( res.data.main ) {
$main.val( res.data.main );
}
if ( res.data.secondary && res.data.secondary.length ) {
$secList.empty();
$.each( res.data.secondary, function ( i, kw ) {
var $row = $( '<div class="brezngeo-keyword-secondary-row" style="display:flex;gap:6px;margin-bottom:4px;">' +
'<input type="text" name="brezngeo_keyword_secondary[]" value="' + escHtml( kw ) + '" style="flex:1;box-sizing:border-box;">' +
'<button type="button" class="button brezngeo-keyword-remove-secondary">&times;</button>' +
'</div>' );
$secList.append( $row );
} );
}
setStatus( '', false );
} else {
setStatus( res.data || i18n.error, true );
}
} )
.fail( function () { setStatus( i18n.error, true ); } )
.always( function () { $btn.prop( 'disabled', false ); } );
} );
/* ── AI: Optimization Tips ───────────────────────────── */
$( '#brezngeo-keyword-ai-optimize' ).on( 'click', function () {
var $btn = $( this );
var content = getContent();
var keyword = $.trim( $main.val() );
if ( ! keyword ) { setStatus( i18n.noKeyword, true ); return; }
$btn.prop( 'disabled', true );
setStatus( i18n.optimizing, false );
$.post( cfg.ajaxUrl, {
action: 'brezngeo_keyword_ai_optimize',
nonce: cfg.nonce,
post_id: cfg.postId,
post_content: content,
main_keyword: keyword,
} )
.done( function ( res ) {
if ( res && res.success && res.data ) {
var html = '<h4>' + escHtml( 'Optimization Tips' ) + '</h4><ul style="margin:6px 0 0 16px;">';
$.each( res.data, function ( i, tip ) {
html += '<li style="margin-bottom:4px;">' + escHtml( tip ) + '</li>';
} );
html += '</ul>';
$aiResults.html( html );
setStatus( '', false );
} else {
setStatus( res.data || i18n.error, true );
}
} )
.fail( function () { setStatus( i18n.error, true ); } )
.always( function () { $btn.prop( 'disabled', false ); } );
} );
/* ── AI: Semantic Analysis ───────────────────────────── */
$( '#brezngeo-keyword-ai-semantic' ).on( 'click', function () {
var $btn = $( this );
var content = getContent();
var keyword = $.trim( $main.val() );
if ( ! keyword ) { setStatus( i18n.noKeyword, true ); return; }
$btn.prop( 'disabled', true );
setStatus( i18n.semantic, false );
$.post( cfg.ajaxUrl, {
action: 'brezngeo_keyword_ai_semantic',
nonce: cfg.nonce,
post_id: cfg.postId,
post_content: content,
main_keyword: keyword,
} )
.done( function ( res ) {
if ( res && res.success && res.data ) {
var html = '<h4>' + escHtml( 'Semantic Analysis' ) + '</h4>';
html += '<div style="white-space:pre-wrap;font-size:13px;line-height:1.5;">' + escHtml( res.data ) + '</div>';
$aiResults.html( html );
setStatus( '', false );
} else {
setStatus( res.data || i18n.error, true );
}
} )
.fail( function () { setStatus( i18n.error, true ); } )
.always( function () { $btn.prop( 'disabled', false ); } );
} );
} )( jQuery );

View file

@ -3,7 +3,7 @@
* Plugin Name: BreznGEO * Plugin Name: BreznGEO
* Plugin URI: https://brezngeo.com/ * Plugin URI: https://brezngeo.com/
* Description: AI-powered meta descriptions, GEO structured data, and llms.txt for WordPress. * Description: AI-powered meta descriptions, GEO structured data, and llms.txt for WordPress.
* Version: 1.3.0 * Version: 1.1.0
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 8.0 * Requires PHP: 8.0
* Author: NoSchmarrn.dev * Author: NoSchmarrn.dev
@ -18,7 +18,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
define( 'BREZNGEO_VERSION', '1.3.0' ); define( 'BREZNGEO_VERSION', '1.1.0' );
define( 'BREZNGEO_FILE', __FILE__ ); define( 'BREZNGEO_FILE', __FILE__ );
define( 'BREZNGEO_DIR', plugin_dir_path( __FILE__ ) ); define( 'BREZNGEO_DIR', plugin_dir_path( __FILE__ ) );
define( 'BREZNGEO_URL', plugin_dir_url( __FILE__ ) ); define( 'BREZNGEO_URL', plugin_dir_url( __FILE__ ) );

View file

@ -17,10 +17,9 @@ class AdminMenu {
public static function get_ai_features(): array { public static function get_ai_features(): array {
$defaults = array( $defaults = array(
'meta' => false, 'meta' => false,
'geo' => false, 'geo' => false,
'links' => false, 'links' => false,
'keywords' => false,
); );
$saved = get_option( self::OPTION_KEY_AI_FEATURES, array() ); $saved = get_option( self::OPTION_KEY_AI_FEATURES, array() );
$saved = is_array( $saved ) ? $saved : array(); $saved = is_array( $saved ) ? $saved : array();
@ -41,17 +40,16 @@ class AdminMenu {
update_option( update_option(
self::OPTION_KEY_AI_FEATURES, self::OPTION_KEY_AI_FEATURES,
array( array(
'meta' => ! empty( $input['meta'] ), 'meta' => ! empty( $input['meta'] ),
'geo' => ! empty( $input['geo'] ), 'geo' => ! empty( $input['geo'] ),
'links' => ! empty( $input['links'] ), 'links' => ! empty( $input['links'] ),
'keywords' => ! empty( $input['keywords'] ),
) )
); );
wp_safe_redirect( wp_safe_redirect(
add_query_arg( add_query_arg(
array( array(
'page' => 'brezngeo', 'page' => 'brezngeo',
'brezngeo-saved' => '1', 'brezngeo-saved' => '1',
), ),
admin_url( 'admin.php' ) admin_url( 'admin.php' )
@ -168,15 +166,6 @@ class AdminMenu {
array( new LinkSuggestPage(), 'render' ) array( new LinkSuggestPage(), 'render' )
); );
add_submenu_page(
'brezngeo',
__( 'Keyword Analysis', 'brezngeo' ),
__( 'Keyword Analysis', 'brezngeo' ),
'manage_options',
'brezngeo-keyword',
array( new KeywordPage(), 'render' )
);
add_submenu_page( add_submenu_page(
'brezngeo', 'brezngeo',
__( 'GEO Quick Overview', 'brezngeo' ), __( 'GEO Quick Overview', 'brezngeo' ),
@ -207,8 +196,8 @@ class AdminMenu {
$provider = $prov_obj ? $prov_obj->getName() : $provider_key; $provider = $prov_obj ? $prov_obj->getName() : $provider_key;
} }
$post_types = $settings['meta_post_types'] ?? array( 'post', 'page' ); $post_types = $settings['meta_post_types'] ?? array( 'post', 'page' );
$meta_stats = $this->get_meta_stats( $post_types ); $meta_stats = $this->get_meta_stats( $post_types );
$brezngeo_compat = $this->get_compat_info(); $brezngeo_compat = $this->get_compat_info();
$brezngeo_show_welcome = $this->should_show_welcome(); $brezngeo_show_welcome = $this->should_show_welcome();

View file

@ -1,433 +0,0 @@
<?php
/**
* Keyword analysis meta box for the post editor.
*
* @package BreznGEO
*/
namespace BreznGEO\Admin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use BreznGEO\Features\KeywordAnalysis;
use BreznGEO\Helpers\KeywordVariants;
use BreznGEO\Helpers\TokenEstimator;
use BreznGEO\ProviderRegistry;
/**
* Registers and renders the keyword analysis meta box with AJAX handlers.
*/
class KeywordMetaBox {
public const META_MAIN = '_brezngeo_keyword_main';
public const META_SECONDARY = '_brezngeo_keyword_secondary';
public const META_RESULTS = '_brezngeo_keyword_results';
/**
* Register hooks for the keyword meta box.
*
* @return void
*/
public function register(): void {
add_action( 'add_meta_boxes', array( $this, 'add_boxes' ) );
add_action( 'save_post', array( $this, 'save' ), 10, 2 );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
add_action( 'wp_ajax_brezngeo_keyword_analyze', array( $this, 'ajax_analyze' ) );
add_action( 'wp_ajax_brezngeo_keyword_ai_suggest', array( $this, 'ajax_ai_suggest' ) );
add_action( 'wp_ajax_brezngeo_keyword_ai_optimize', array( $this, 'ajax_ai_optimize' ) );
add_action( 'wp_ajax_brezngeo_keyword_ai_semantic', array( $this, 'ajax_ai_semantic' ) );
}
/**
* Add the keyword analysis meta box to configured post types.
*
* @return void
*/
public function add_boxes(): void {
$settings = self::get_settings();
$post_types = $settings['post_types'] ?? array( 'post', 'page' );
foreach ( $post_types as $pt ) {
add_meta_box(
'brezngeo_keyword_box',
__( 'Keyword Analysis (BreznGEO)', 'brezngeo' ),
array( $this, 'render' ),
$pt,
'normal',
'default'
);
}
}
/**
* Render the keyword analysis meta box.
*
* @param \WP_Post $post The current post object.
* @return void
*/
public function render( \WP_Post $post ): void {
$main_keyword = get_post_meta( $post->ID, self::META_MAIN, true );
$secondary_json = get_post_meta( $post->ID, self::META_SECONDARY, true );
$secondary = ! empty( $secondary_json ) ? json_decode( $secondary_json, true ) : array();
$cached_results = get_post_meta( $post->ID, self::META_RESULTS, true );
$settings = self::get_settings();
$ai_features = AdminMenu::get_ai_features();
$global_settings = SettingsPage::getSettings();
$has_ai = ! empty( $global_settings['ai_enabled'] )
&& ! empty( $global_settings['api_keys'][ $global_settings['provider'] ] ?? '' );
$ai_keywords = $has_ai && ! empty( $ai_features['keywords'] );
wp_nonce_field( 'brezngeo_keyword_save_' . $post->ID, 'brezngeo_keyword_nonce' );
include BREZNGEO_DIR . 'includes/Admin/views/keyword-meta-box.php';
}
/**
* Save keyword meta data on post save.
*
* @param int $post_id The post ID.
* @param \WP_Post $post The post object.
* @return void
*/
public function save( int $post_id, \WP_Post $post ): void {
if ( ! isset( $_POST['brezngeo_keyword_nonce'] ) ) {
return;
}
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['brezngeo_keyword_nonce'] ) ), 'brezngeo_keyword_save_' . $post_id ) ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
$main = sanitize_text_field( wp_unslash( $_POST['brezngeo_keyword_main'] ?? '' ) );
update_post_meta( $post_id, self::META_MAIN, $main );
$raw_secondary = isset( $_POST['brezngeo_keyword_secondary'] ) && is_array( $_POST['brezngeo_keyword_secondary'] )
? array_map( 'sanitize_text_field', wp_unslash( $_POST['brezngeo_keyword_secondary'] ) )
: array();
$secondary = array_values(
array_filter(
$raw_secondary,
function ( $kw ) {
return '' !== trim( $kw );
}
)
);
update_post_meta( $post_id, self::META_SECONDARY, wp_json_encode( $secondary, JSON_UNESCAPED_UNICODE ) );
}
/**
* Enqueue scripts and styles for the keyword meta box.
*
* @param string $hook The current admin page hook.
* @return void
*/
public function enqueue( string $hook ): void {
if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
return;
}
$settings = self::get_settings();
$ai_features = AdminMenu::get_ai_features();
$global_settings = SettingsPage::getSettings();
$has_ai = ! empty( $global_settings['ai_enabled'] )
&& ! empty( $global_settings['api_keys'][ $global_settings['provider'] ] ?? '' );
wp_enqueue_script(
'brezngeo-keyword-analysis',
BREZNGEO_URL . 'assets/keyword-analysis.js',
array( 'jquery' ),
BREZNGEO_VERSION,
true
);
wp_localize_script(
'brezngeo-keyword-analysis',
'brezngeoKeyword',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'brezngeo_admin' ),
'postId' => get_the_ID(),
'updateMode' => $settings['update_mode'] ?? 'manual',
'debounceMs' => (int) ( $settings['live_debounce_ms'] ?? 800 ),
'aiEnabled' => $has_ai && ! empty( $ai_features['keywords'] ),
'i18n' => array(
'analyzing' => __( 'Analyzing…', 'brezngeo' ),
'error' => __( 'Analysis error.', 'brezngeo' ),
'noKeyword' => __( 'Please enter a main keyword.', 'brezngeo' ),
'suggesting' => __( 'Getting suggestions…', 'brezngeo' ),
'optimizing' => __( 'Getting optimization tips…', 'brezngeo' ),
'semantic' => __( 'Running semantic analysis…', 'brezngeo' ),
),
)
);
}
/**
* AJAX handler for keyword analysis.
*
* @return void
*/
public function ajax_analyze(): void {
check_ajax_referer( 'brezngeo_admin', 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
}
$post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) );
$post_content = wp_kses_post( wp_unslash( $_POST['post_content'] ?? '' ) );
$main_keyword = sanitize_text_field( wp_unslash( $_POST['main_keyword'] ?? '' ) );
$raw_secondary = isset( $_POST['secondary_keywords'] ) ? sanitize_text_field( wp_unslash( $_POST['secondary_keywords'] ) ) : '';
$secondary = ! empty( $raw_secondary ) ? json_decode( $raw_secondary, true ) : array();
if ( ! is_array( $secondary ) ) {
$secondary = array();
}
$secondary = array_map( 'sanitize_text_field', $secondary );
if ( '' === $main_keyword ) {
wp_send_json_error( __( 'No keyword provided.', 'brezngeo' ) );
}
$settings = self::get_settings();
$locale = function_exists( 'get_locale' ) ? get_locale() : 'en_US';
$data = KeywordAnalysis::extract_content_data( $post_content, $post_id );
$thresholds = array(
'target_density' => (float) ( $settings['target_density'] ?? 1.5 ),
'density_margin' => 0.5,
'min_occurrences' => (int) ( $settings['min_occurrences_primary'] ?? 3 ),
'min_occurrences_secondary' => (int) ( $settings['min_occurrences_secondary'] ?? 1 ),
);
$main_results = KeywordAnalysis::analyze( $main_keyword, $data, $thresholds, true, $locale );
$secondary_results = array();
foreach ( $secondary as $kw ) {
$kw = trim( $kw );
if ( '' === $kw ) {
continue;
}
$secondary_results[ $kw ] = KeywordAnalysis::analyze( $kw, $data, $thresholds, false, $locale );
}
$response = array(
'main' => array(
'keyword' => $main_keyword,
'checks' => $main_results,
),
'secondary' => $secondary_results,
);
// Cache results as post meta.
if ( $post_id > 0 ) {
update_post_meta( $post_id, self::META_RESULTS, wp_json_encode( $response, JSON_UNESCAPED_UNICODE ) );
}
wp_send_json_success( $response );
}
/**
* AJAX handler for AI keyword suggestions.
*
* @return void
*/
public function ajax_ai_suggest(): void {
check_ajax_referer( 'brezngeo_admin', 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
}
$ai_features = AdminMenu::get_ai_features();
if ( empty( $ai_features['keywords'] ) ) {
wp_send_json_error( __( 'AI keyword features are not activated.', 'brezngeo' ) );
}
$post_content = wp_kses_post( wp_unslash( $_POST['post_content'] ?? '' ) );
$post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) );
$title = $post_id > 0 ? get_the_title( $post_id ) : '';
$content = wp_strip_all_tags( $post_content );
$content = TokenEstimator::truncate( $content, 2000 );
$language = self::detect_language();
$prompt = "Analyze the following article and suggest the best SEO keywords.\n" .
"Title: {$title}\n" .
"Content: {$content}\n\n" .
"Language: {$language}\n" .
"Respond ONLY with valid JSON in this format: {\"main\": \"primary keyword\", \"secondary\": [\"keyword1\", \"keyword2\", \"keyword3\"]}\n" .
'Suggest 1 main keyword and up to 3 secondary keywords. Keep them concise and relevant.';
$result = self::call_ai( $prompt );
if ( null === $result ) {
wp_send_json_error( __( 'AI generation failed. Check provider settings.', 'brezngeo' ) );
}
$parsed = json_decode( $result, true );
if ( ! is_array( $parsed ) || empty( $parsed['main'] ) ) {
// Try to extract JSON from response.
if ( preg_match( '/\{[^}]+\}/', $result, $json_match ) ) {
$parsed = json_decode( $json_match[0], true );
}
}
if ( is_array( $parsed ) && ! empty( $parsed['main'] ) ) {
wp_send_json_success(
array(
'main' => sanitize_text_field( $parsed['main'] ),
'secondary' => array_map( 'sanitize_text_field', (array) ( $parsed['secondary'] ?? array() ) ),
)
);
} else {
wp_send_json_error( __( 'Could not parse AI response.', 'brezngeo' ) );
}
}
/**
* AJAX handler for AI optimization tips.
*
* @return void
*/
public function ajax_ai_optimize(): void {
check_ajax_referer( 'brezngeo_admin', 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
}
$ai_features = AdminMenu::get_ai_features();
if ( empty( $ai_features['keywords'] ) ) {
wp_send_json_error( __( 'AI keyword features are not activated.', 'brezngeo' ) );
}
$post_content = wp_kses_post( wp_unslash( $_POST['post_content'] ?? '' ) );
$main_keyword = sanitize_text_field( wp_unslash( $_POST['main_keyword'] ?? '' ) );
$content = wp_strip_all_tags( $post_content );
$content = TokenEstimator::truncate( $content, 2000 );
$language = self::detect_language();
$prompt = "You are an SEO expert. The target keyword is: \"{$main_keyword}\".\n" .
"Article content: {$content}\n\n" .
"Language: {$language}\n" .
"Provide 3-5 concrete, actionable optimization tips to improve this article's ranking for the target keyword.\n" .
'Respond ONLY with a JSON array of strings, e.g.: ["Tip 1", "Tip 2", "Tip 3"]';
$result = self::call_ai( $prompt );
if ( null === $result ) {
wp_send_json_error( __( 'AI generation failed. Check provider settings.', 'brezngeo' ) );
}
$parsed = json_decode( $result, true );
if ( ! is_array( $parsed ) ) {
if ( preg_match( '/\[.*\]/s', $result, $json_match ) ) {
$parsed = json_decode( $json_match[0], true );
}
}
if ( is_array( $parsed ) && ! empty( $parsed ) ) {
wp_send_json_success( array_map( 'sanitize_text_field', array_values( $parsed ) ) );
} else {
wp_send_json_error( __( 'Could not parse AI response.', 'brezngeo' ) );
}
}
/**
* AJAX handler for AI semantic analysis.
*
* @return void
*/
public function ajax_ai_semantic(): void {
check_ajax_referer( 'brezngeo_admin', 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
}
$ai_features = AdminMenu::get_ai_features();
if ( empty( $ai_features['keywords'] ) ) {
wp_send_json_error( __( 'AI keyword features are not activated.', 'brezngeo' ) );
}
$post_content = wp_kses_post( wp_unslash( $_POST['post_content'] ?? '' ) );
$main_keyword = sanitize_text_field( wp_unslash( $_POST['main_keyword'] ?? '' ) );
$content = wp_strip_all_tags( $post_content );
$content = TokenEstimator::truncate( $content, 2000 );
$language = self::detect_language();
$prompt = "You are an SEO expert. Perform a semantic analysis of the following article for the keyword: \"{$main_keyword}\".\n" .
"Article content: {$content}\n\n" .
"Language: {$language}\n" .
"Analyze:\n" .
"1. Topic coverage — which related subtopics are present?\n" .
"2. Related terms — which relevant terms are present, which are missing?\n" .
"3. Content gaps — what topics should be added?\n\n" .
'Respond in plain text, structured with clear headings. Keep it concise (max 200 words). Respond in the article language.';
$result = self::call_ai( $prompt );
if ( null === $result ) {
wp_send_json_error( __( 'AI generation failed. Check provider settings.', 'brezngeo' ) );
}
wp_send_json_success( sanitize_textarea_field( $result ) );
}
/**
* Call AI provider with a prompt.
*
* @param string $prompt The prompt to send.
* @return string|null AI response text or null on failure.
*/
private static function call_ai( string $prompt ): ?string {
$settings = SettingsPage::getSettings();
$registry = ProviderRegistry::instance();
$provider = $registry->get( $settings['provider'] );
$api_key = $settings['api_keys'][ $settings['provider'] ] ?? '';
if ( ! $provider || empty( $api_key ) || empty( $settings['ai_enabled'] ) ) {
return null;
}
$model = $settings['models'][ $settings['provider'] ] ?? array_key_first( $provider->getModels() );
try {
$result = $provider->generateText( $prompt, $api_key, $model, 500 );
$tokens_in = TokenEstimator::estimate( $prompt );
$tokens_out = TokenEstimator::estimate( $result );
\BreznGEO\Features\MetaGenerator::record_usage( $tokens_in, $tokens_out );
return $result;
} catch ( \Exception $e ) {
return null;
}
}
/**
* Detect language for AI prompts.
*/
private static function detect_language(): string {
$locale = function_exists( 'get_locale' ) ? get_locale() : 'en_US';
$locale_map = array(
'de' => 'German',
'en' => 'English',
);
$lang = mb_strtolower( mb_substr( $locale, 0, 2 ) );
return $locale_map[ $lang ] ?? 'English';
}
/**
* Get keyword analysis settings with defaults.
*
* @return array
*/
public static function get_settings(): array {
$defaults = array(
'update_mode' => 'manual',
'target_density' => 1.5,
'min_occurrences_primary' => 3,
'min_occurrences_secondary' => 1,
'post_types' => array( 'post', 'page' ),
'live_debounce_ms' => 800,
);
$saved = get_option( 'brezngeo_keyword_settings', array() );
$saved = is_array( $saved ) ? $saved : array();
return array_merge( $defaults, $saved );
}
}

View file

@ -1,103 +0,0 @@
<?php
/**
* Keyword analysis settings page.
*
* @package BreznGEO
*/
namespace BreznGEO\Admin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles registration and rendering of keyword analysis settings.
*/
class KeywordPage {
public const OPTION_KEY = 'brezngeo_keyword_settings';
/**
* Register hooks for the keyword settings page.
*
* @return void
*/
public function register(): void {
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Register the keyword settings with WordPress.
*
* @return void
*/
public function register_settings(): void {
register_setting(
'brezngeo_keyword',
self::OPTION_KEY,
array( 'sanitize_callback' => array( $this, 'sanitize' ) )
);
}
/**
* Enqueue assets for the keyword settings page.
*
* @param string $hook The current admin page hook.
* @return void
*/
public function enqueue_assets( string $hook ): void {
if ( 'brezngeo_page_brezngeo-keyword' !== $hook ) {
return;
}
wp_enqueue_style( 'brezngeo-admin', BREZNGEO_URL . 'assets/admin.css', array(), BREZNGEO_VERSION );
}
/**
* Sanitize keyword settings input.
*
* @param mixed $input Raw settings input.
* @return array Sanitized settings.
*/
public function sanitize( mixed $input ): array {
$input = is_array( $input ) ? $input : array();
$clean = array();
$allowed_modes = array( 'live', 'manual', 'save' );
$clean['update_mode'] = in_array( $input['update_mode'] ?? '', $allowed_modes, true )
? $input['update_mode'] : 'manual';
$clean['target_density'] = max( 0.1, min( 5.0, (float) ( $input['target_density'] ?? 1.5 ) ) );
$clean['min_occurrences_primary'] = max( 1, (int) ( $input['min_occurrences_primary'] ?? 3 ) );
$clean['min_occurrences_secondary'] = max( 1, (int) ( $input['min_occurrences_secondary'] ?? 1 ) );
$clean['live_debounce_ms'] = max( 300, min( 3000, (int) ( $input['live_debounce_ms'] ?? 800 ) ) );
$all_post_types = array_keys( get_post_types( array( 'public' => true ) ) );
$clean['post_types'] = array_values(
array_intersect(
array_map( 'sanitize_key', (array) ( $input['post_types'] ?? array() ) ),
$all_post_types
)
);
if ( empty( $clean['post_types'] ) ) {
$clean['post_types'] = array( 'post', 'page' );
}
return $clean;
}
/**
* Render the keyword settings page.
*
* @return void
*/
public function render(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$settings = KeywordMetaBox::get_settings();
$post_types = get_post_types( array( 'public' => true ), 'objects' );
include BREZNGEO_DIR . 'includes/Admin/views/keyword-settings.php';
}
}

View file

@ -7,15 +7,13 @@ if ( ! defined( 'ABSPATH' ) ) {
use BreznGEO\ProviderRegistry; use BreznGEO\ProviderRegistry;
use BreznGEO\Helpers\KeyVault; use BreznGEO\Helpers\KeyVault;
use BreznGEO\Providers\OpenRouterProvider;
class ProviderPage { class ProviderPage {
private const PRICING_URLS = array( private const PRICING_URLS = array(
'openai' => 'https://openai.com/de-DE/api/pricing', 'openai' => 'https://openai.com/de-DE/api/pricing',
'anthropic' => 'https://platform.claude.com/docs/en/about-claude/pricing', 'anthropic' => 'https://platform.claude.com/docs/en/about-claude/pricing',
'gemini' => 'https://ai.google.dev/gemini-api/docs/pricing?hl=de', 'gemini' => 'https://ai.google.dev/gemini-api/docs/pricing?hl=de',
'grok' => 'https://docs.x.ai/developers/models', 'grok' => 'https://docs.x.ai/developers/models',
'openrouter' => 'https://openrouter.ai/models',
); );
public function register(): void { public function register(): void {
@ -23,7 +21,6 @@ class ProviderPage {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'wp_ajax_brezngeo_test_connection', array( $this, 'ajax_test_connection' ) ); add_action( 'wp_ajax_brezngeo_test_connection', array( $this, 'ajax_test_connection' ) );
add_action( 'wp_ajax_brezngeo_get_default_prompt', array( $this, 'ajax_get_default_prompt' ) ); add_action( 'wp_ajax_brezngeo_get_default_prompt', array( $this, 'ajax_get_default_prompt' ) );
add_action( 'wp_ajax_brezngeo_openrouter_load_models', array( $this, 'ajax_openrouter_load_models' ) );
} }
public function register_settings(): void { public function register_settings(): void {
@ -74,23 +71,10 @@ class ProviderPage {
$clean['api_keys'][ $provider_id ] = $existing['api_keys'][ $provider_id ]; $clean['api_keys'][ $provider_id ] = $existing['api_keys'][ $provider_id ];
} }
} }
// Preserve DB-stored keys for providers whose UI field was disabled
// (wp-config.php constant override) and therefore never submitted.
foreach ( ( $existing['api_keys'] ?? array() ) as $provider_id => $stored ) {
if ( ! isset( $clean['api_keys'][ $provider_id ] ) ) {
$clean['api_keys'][ $provider_id ] = $stored;
}
}
$clean['models'] = array(); $clean['models'] = array();
foreach ( ( $input['models'] ?? array() ) as $provider_id => $model ) { foreach ( ( $input['models'] ?? array() ) as $provider_id => $model ) {
$pid = sanitize_key( $provider_id ); $clean['models'][ sanitize_key( $provider_id ) ] = sanitize_text_field( $model );
$value = sanitize_text_field( $model );
if ( $pid === 'openrouter' && $value === '__custom__' ) {
$custom_raw = (string) ( $input['openrouter_custom_model'] ?? '' );
$value = sanitize_text_field( $custom_raw );
}
$clean['models'][ $pid ] = $value;
} }
$clean['costs'] = array(); $clean['costs'] = array();
@ -108,18 +92,6 @@ class ProviderPage {
} }
} }
$selected_openrouter = $clean['models']['openrouter'] ?? '';
if ( $selected_openrouter !== '' ) {
$cached = get_transient( \BreznGEO\Providers\OpenRouterProvider::MODELS_CACHE );
if ( is_array( $cached ) && isset( $cached[ $selected_openrouter ] ) ) {
$meta = $cached[ $selected_openrouter ];
$clean['costs']['openrouter'][ $selected_openrouter ] = array(
'input' => (float) ( $meta['input_cost'] ?? 0 ),
'output' => (float) ( $meta['output_cost'] ?? 0 ),
);
}
}
return $clean; return $clean;
} }
@ -154,59 +126,6 @@ class ProviderPage {
wp_send_json_success( SettingsPage::getDefaultPrompt() ); wp_send_json_success( SettingsPage::getDefaultPrompt() );
} }
public function ajax_openrouter_load_models(): void {
check_ajax_referer( 'brezngeo_admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( __( 'Insufficient permissions.', 'brezngeo' ) );
}
$response = wp_remote_get(
OpenRouterProvider::MODELS_URL . '?category=marketing',
array(
'timeout' => 15,
'headers' => array(
'Accept' => 'application/json',
'HTTP-Referer' => home_url( '/' ),
'X-Title' => 'BreznGEO',
),
)
);
if ( is_wp_error( $response ) ) {
wp_send_json_error( $response->get_error_message() );
}
$code = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $code !== 200 || ! isset( $body['data'] ) || ! is_array( $body['data'] ) ) {
$msg = $body['error']['message'] ?? "HTTP $code";
wp_send_json_error( $msg );
}
$normalized = array();
foreach ( $body['data'] as $model ) {
if ( ! is_array( $model ) || empty( $model['id'] ) ) {
continue;
}
$id = (string) $model['id'];
$label = isset( $model['name'] ) && is_string( $model['name'] ) && $model['name'] !== '' ? (string) $model['name'] : $id;
$input_per_token = isset( $model['pricing']['prompt'] ) ? (float) $model['pricing']['prompt'] : 0.0;
$output_per_token = isset( $model['pricing']['completion'] ) ? (float) $model['pricing']['completion'] : 0.0;
$normalized[ $id ] = array(
'label' => $label,
'input_cost' => round( $input_per_token * 1_000_000, 4 ),
'output_cost' => round( $output_per_token * 1_000_000, 4 ),
);
}
if ( empty( $normalized ) ) {
wp_send_json_error( __( 'No models returned by OpenRouter.', 'brezngeo' ) );
}
set_transient( OpenRouterProvider::MODELS_CACHE, $normalized, 12 * HOUR_IN_SECONDS );
wp_send_json_success( $normalized );
}
public function render(): void { public function render(): void {
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
return; return;

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO\Admin; namespace BreznGEO\Admin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use BreznGEO\Helpers\KeyVault; use BreznGEO\Helpers\KeyVault;
class SettingsPage { class SettingsPage {
@ -23,12 +19,6 @@ class SettingsPage {
*/ */
public const OPTION_KEY_SCHEMA = 'brezngeo_schema_settings'; public const OPTION_KEY_SCHEMA = 'brezngeo_schema_settings';
/**
* Provider IDs that support a wp-config.php constant override.
* A defined constant wins over the DB value and locks the UI field.
*/
private const CONSTANT_KEY_PROVIDERS = array( 'openai', 'anthropic', 'gemini', 'grok', 'openrouter' );
/** /**
* Returns merged settings from both option keys with defaults applied. * Returns merged settings from both option keys with defaults applied.
* Called by MetaGenerator, SchemaEnhancer, BulkPage, and admin pages. * Called by MetaGenerator, SchemaEnhancer, BulkPage, and admin pages.
@ -68,37 +58,9 @@ class SettingsPage {
$settings['api_keys'][ $id ] = $decrypted !== '' ? $decrypted : $stored; $settings['api_keys'][ $id ] = $decrypted !== '' ? $decrypted : $stored;
} }
// wp-config.php constants override the DB value and lock the admin field.
$settings['api_keys_locked'] = array();
foreach ( self::CONSTANT_KEY_PROVIDERS as $provider_id ) {
$constant = self::constantNameForProvider( $provider_id );
if ( $constant !== '' && defined( $constant ) ) {
$settings['api_keys'][ $provider_id ] = (string) constant( $constant );
$settings['api_keys_locked'][ $provider_id ] = true;
}
}
return $settings; return $settings;
} }
/**
* Returns the wp-config.php constant name for a given provider ID,
* or empty string if the provider has no constant override.
*
* Must be pure no WP hooks, no option reads. Called once per entry
* in CONSTANT_KEY_PROVIDERS during getSettings().
*
* The website (de/index.html, howto.html) already promises the names
* BREZNGEO_OPENAI_KEY, BREZNGEO_ANTHROPIC_KEY, BREZNGEO_GEMINI_KEY,
* BREZNGEO_GROK_KEY and BREZNGEO_OPENROUTER_KEY those are the contract.
*/
private static function constantNameForProvider( string $provider_id ): string {
if ( ! in_array( $provider_id, self::CONSTANT_KEY_PROVIDERS, true ) ) {
return '';
}
return 'BREZNGEO_' . strtoupper( $provider_id ) . '_KEY';
}
public static function getDefaultPrompt(): string { public static function getDefaultPrompt(): string {
$locale = get_locale(); $locale = get_locale();
$is_german = str_starts_with( $locale, 'de_' ); $is_german = str_starts_with( $locale, 'de_' );

View file

@ -156,18 +156,6 @@
</p> </p>
</td> </td>
</tr> </tr>
<tr>
<td style="padding:6px 0;">
<label>
<input type="checkbox" name="brezngeo_ai_features[keywords]" value="1"
<?php checked( $ai_features['keywords'] ); ?>>
<strong><?php esc_html_e( 'Keyword Analysis', 'brezngeo' ); ?></strong>
</label>
<p style="margin:2px 0 0 22px;color:#777;font-size:12px;">
<?php esc_html_e( 'AI-powered keyword suggestions, optimization tips, and semantic analysis.', 'brezngeo' ); ?>
</p>
</td>
</tr>
</table> </table>
<p style="margin-top:12px;"> <p style="margin-top:12px;">
<?php submit_button( __( 'Save', 'brezngeo' ), 'secondary', 'submit', false ); ?> <?php submit_button( __( 'Save', 'brezngeo' ), 'secondary', 'submit', false ); ?>

View file

@ -1,68 +0,0 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<div id="brezngeo-keyword-box"
data-post-id="<?php echo esc_attr( $post->ID ); ?>"
data-nonce="<?php echo esc_attr( wp_create_nonce( 'brezngeo_admin' ) ); ?>">
<p style="margin-bottom:4px;">
<label for="brezngeo-keyword-main"><strong><?php esc_html_e( 'Main Keyword', 'brezngeo' ); ?></strong></label>
</p>
<input type="text" id="brezngeo-keyword-main" name="brezngeo_keyword_main"
value="<?php echo esc_attr( $main_keyword ); ?>"
style="width:100%;box-sizing:border-box;"
placeholder="<?php esc_attr_e( 'e.g. Passau travel guide', 'brezngeo' ); ?>">
<p style="margin-bottom:4px;margin-top:12px;">
<strong><?php esc_html_e( 'Secondary Keywords', 'brezngeo' ); ?></strong>
</p>
<div id="brezngeo-keyword-secondary-list">
<?php if ( ! empty( $secondary ) ) : ?>
<?php foreach ( $secondary as $brezngeo_kw ) : ?>
<div class="brezngeo-keyword-secondary-row" style="display:flex;gap:6px;margin-bottom:4px;">
<input type="text" name="brezngeo_keyword_secondary[]"
value="<?php echo esc_attr( $brezngeo_kw ); ?>"
style="flex:1;box-sizing:border-box;">
<button type="button" class="button brezngeo-keyword-remove-secondary">&times;</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<p>
<button type="button" class="button" id="brezngeo-keyword-add-secondary">
+ <?php esc_html_e( 'Add Keyword', 'brezngeo' ); ?>
</button>
</p>
<p style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;">
<button type="button" class="button button-primary" id="brezngeo-keyword-analyze">
<?php esc_html_e( 'Analyze', 'brezngeo' ); ?>
</button>
<?php if ( $ai_keywords ) : ?>
<button type="button" class="button" id="brezngeo-keyword-ai-suggest">
<?php esc_html_e( 'Suggest Keywords', 'brezngeo' ); ?> &#10024;
</button>
<?php endif; ?>
<span id="brezngeo-keyword-status" style="align-self:center;font-size:12px;"></span>
</p>
<div id="brezngeo-keyword-results" style="margin-top:16px;">
<?php if ( ! empty( $cached_results ) ) : ?>
<p style="font-size:11px;color:#999;"><?php esc_html_e( 'Showing cached results. Click "Analyze" to refresh.', 'brezngeo' ); ?></p>
<?php endif; ?>
</div>
<?php if ( $ai_keywords ) : ?>
<div id="brezngeo-keyword-ai-actions" style="margin-top:12px;display:none;">
<button type="button" class="button" id="brezngeo-keyword-ai-optimize">
<?php esc_html_e( 'Optimization Tips', 'brezngeo' ); ?> &#10024;
</button>
<button type="button" class="button" id="brezngeo-keyword-ai-semantic">
<?php esc_html_e( 'Semantic Analysis', 'brezngeo' ); ?> &#10024;
</button>
</div>
<div id="brezngeo-keyword-ai-results" style="margin-top:12px;"></div>
<?php endif; ?>
</div>

View file

@ -1,95 +0,0 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<div class="wrap brezngeo-settings">
<h1><?php esc_html_e( 'Keyword Analysis', 'brezngeo' ); ?></h1>
<?php settings_errors( 'brezngeo_keyword' ); ?>
<form method="post" action="options.php">
<?php settings_fields( 'brezngeo_keyword' ); ?>
<h2><?php esc_html_e( 'Analysis Settings', 'brezngeo' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e( 'Update Mode', 'brezngeo' ); ?></th>
<td>
<select name="brezngeo_keyword_settings[update_mode]">
<option value="manual" <?php selected( $settings['update_mode'], 'manual' ); ?>>
<?php esc_html_e( 'Manual — click "Analyze" button', 'brezngeo' ); ?>
</option>
<option value="live" <?php selected( $settings['update_mode'], 'live' ); ?>>
<?php esc_html_e( 'Live — auto-analyze while typing', 'brezngeo' ); ?>
</option>
<option value="save" <?php selected( $settings['update_mode'], 'save' ); ?>>
<?php esc_html_e( 'On Save — analyze when post is saved', 'brezngeo' ); ?>
</option>
</select>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Target Keyword Density (%)', 'brezngeo' ); ?></th>
<td>
<input type="number" name="brezngeo_keyword_settings[target_density]"
value="<?php echo esc_attr( $settings['target_density'] ); ?>"
min="0.1" max="5.0" step="0.1" style="width:80px;">
<p class="description"><?php esc_html_e( 'Recommended: 1.02.0%. Pass range is ±0.5% around the target.', 'brezngeo' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Min. Occurrences (Primary)', 'brezngeo' ); ?></th>
<td>
<input type="number" name="brezngeo_keyword_settings[min_occurrences_primary]"
value="<?php echo esc_attr( $settings['min_occurrences_primary'] ); ?>"
min="1" max="50" style="width:80px;">
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Min. Occurrences (Secondary)', 'brezngeo' ); ?></th>
<td>
<input type="number" name="brezngeo_keyword_settings[min_occurrences_secondary]"
value="<?php echo esc_attr( $settings['min_occurrences_secondary'] ); ?>"
min="1" max="50" style="width:80px;">
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Live Mode Debounce (ms)', 'brezngeo' ); ?></th>
<td>
<input type="number" name="brezngeo_keyword_settings[live_debounce_ms]"
value="<?php echo esc_attr( $settings['live_debounce_ms'] ); ?>"
min="300" max="3000" step="100" style="width:100px;">
<p class="description"><?php esc_html_e( 'Delay in milliseconds before live analysis triggers after typing stops.', 'brezngeo' ); ?></p>
</td>
</tr>
</table>
<h2><?php esc_html_e( 'Post Types', 'brezngeo' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e( 'Show keyword meta box on', 'brezngeo' ); ?></th>
<td>
<?php foreach ( $post_types as $brezngeo_pt ) : ?>
<label style="display:block;margin-bottom:4px;">
<input type="checkbox" name="brezngeo_keyword_settings[post_types][]"
value="<?php echo esc_attr( $brezngeo_pt->name ); ?>"
<?php checked( in_array( $brezngeo_pt->name, $settings['post_types'], true ) ); ?>>
<?php echo esc_html( $brezngeo_pt->labels->name ); ?>
</label>
<?php endforeach; ?>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
<p class="brezngeo-footer">
BreznGEO <?php echo esc_html( BREZNGEO_VERSION ); ?> &mdash;
<?php esc_html_e( 'developed by', 'brezngeo' ); ?>
<a href="https://noschmarrn.dev" target="_blank" rel="noopener">noschmarrn.dev</a>
<?php esc_html_e( 'for', 'brezngeo' ); ?>
<a href="https://donau2space.de" target="_blank" rel="noopener">Donau2Space.de</a>
</p>
</div>

View file

@ -1,81 +0,0 @@
<?php
/**
* OpenRouter-specific model field. Rendered from provider.php.
*
* Expected in scope:
* $provider (OpenRouterProvider), $settings (array), $pricing_urls (array)
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$brezngeo_or_models = $provider->getModels();
$brezngeo_or_saved_model = $settings['models']['openrouter'] ?? '';
$brezngeo_or_is_custom = $brezngeo_or_saved_model !== '' && ! array_key_exists( $brezngeo_or_saved_model, $brezngeo_or_models );
$brezngeo_or_cached_pricing = get_transient( \BreznGEO\Providers\OpenRouterProvider::MODELS_CACHE );
$brezngeo_or_cache_is_array = is_array( $brezngeo_or_cached_pricing );
$brezngeo_or_selected_pricing = ( $brezngeo_or_cache_is_array && isset( $brezngeo_or_cached_pricing[ $brezngeo_or_saved_model ] ) )
? $brezngeo_or_cached_pricing[ $brezngeo_or_saved_model ]
: null;
?>
<br><br>
<label><?php esc_html_e( 'Model:', 'brezngeo' ); ?></label>
<select name="brezngeo_settings[models][openrouter]" class="brezngeo-openrouter-model-select" id="brezngeo-openrouter-model">
<?php if ( empty( $brezngeo_or_models ) ) : ?>
<option value=""><?php esc_html_e( 'No models loaded yet — click "Load models"', 'brezngeo' ); ?></option>
<?php else : ?>
<?php foreach ( $brezngeo_or_models as $brezngeo_or_mid => $brezngeo_or_label ) : ?>
<option value="<?php echo esc_attr( $brezngeo_or_mid ); ?>"
<?php selected( $brezngeo_or_saved_model, $brezngeo_or_mid ); ?>
data-input="<?php echo esc_attr( isset( $brezngeo_or_cached_pricing[ $brezngeo_or_mid ]['input_cost'] ) ? $brezngeo_or_cached_pricing[ $brezngeo_or_mid ]['input_cost'] : '' ); ?>"
data-output="<?php echo esc_attr( isset( $brezngeo_or_cached_pricing[ $brezngeo_or_mid ]['output_cost'] ) ? $brezngeo_or_cached_pricing[ $brezngeo_or_mid ]['output_cost'] : '' ); ?>">
<?php echo esc_html( $brezngeo_or_label ); ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
<option value="__custom__" <?php selected( $brezngeo_or_is_custom ); ?>>
<?php esc_html_e( 'Custom model ID…', 'brezngeo' ); ?>
</option>
</select>
<button type="button" class="button brezngeo-openrouter-load-btn">
<?php esc_html_e( 'Load models', 'brezngeo' ); ?>
</button>
<span class="brezngeo-openrouter-load-status" aria-live="polite"></span>
<div class="brezngeo-openrouter-custom-wrap" style="<?php echo $brezngeo_or_is_custom ? '' : 'display:none;'; ?>margin-top:10px;">
<label for="brezngeo-openrouter-custom">
<?php esc_html_e( 'Custom model ID:', 'brezngeo' ); ?>
</label>
<input type="text"
id="brezngeo-openrouter-custom"
name="brezngeo_settings[openrouter_custom_model]"
value="<?php echo esc_attr( $brezngeo_or_is_custom ? $brezngeo_or_saved_model : '' ); ?>"
placeholder="<?php esc_attr_e( 'e.g. anthropic/claude-opus-4.7', 'brezngeo' ); ?>"
class="regular-text">
<p class="description">
<a href="https://brezngeo.com/faq.html#openrouter" target="_blank" rel="noopener">
<?php esc_html_e( 'Learn how to find OpenRouter model IDs →', 'brezngeo' ); ?>
</a>
</p>
</div>
<?php if ( ! empty( $pricing_urls['openrouter'] ) ) : ?>
<p style="margin-top:8px;">
<a href="<?php echo esc_url( $pricing_urls['openrouter'] ); ?>" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'Browse all OpenRouter models →', 'brezngeo' ); ?>
</a>
</p>
<?php endif; ?>
<p style="margin-top:12px;"><strong><?php esc_html_e( 'Pricing (automatically from OpenRouter, per 1M tokens):', 'brezngeo' ); ?></strong></p>
<div class="brezngeo-openrouter-pricing-display" id="brezngeo-openrouter-pricing" style="font-size:12px;color:#555;">
<?php if ( $brezngeo_or_selected_pricing ) : ?>
Input $<span class="or-price-input"><?php echo esc_html( number_format( (float) $brezngeo_or_selected_pricing['input_cost'], 4 ) ); ?></span>
/ 1M · Output $<span class="or-price-output"><?php echo esc_html( number_format( (float) $brezngeo_or_selected_pricing['output_cost'], 4 ) ); ?></span>
/ 1M
<?php elseif ( $brezngeo_or_is_custom ) : ?>
<em><?php esc_html_e( 'Pricing unknown for custom models — will be populated after you click "Load models".', 'brezngeo' ); ?></em>
<?php else : ?>
<em><?php esc_html_e( 'Click "Load models" to fetch pricing from OpenRouter.', 'brezngeo' ); ?></em>
<?php endif; ?>
</div>

View file

@ -36,26 +36,9 @@
</td> </td>
</tr> </tr>
<?php foreach ( $providers as $id => $provider ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?> <?php foreach ( $providers as $id => $provider ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound ?>
<?php $brezngeo_locked = ! empty( $settings['api_keys_locked'][ $id ] ); ?>
<tr class="brezngeo-provider-row" data-provider="<?php echo esc_attr( $id ); ?>"> <tr class="brezngeo-provider-row" data-provider="<?php echo esc_attr( $id ); ?>">
<th scope="row"><?php echo esc_html( $provider->getName() ); ?> <?php esc_html_e( 'API Key', 'brezngeo' ); ?></th> <th scope="row"><?php echo esc_html( $provider->getName() ); ?> <?php esc_html_e( 'API Key', 'brezngeo' ); ?></th>
<td> <td>
<?php if ( $brezngeo_locked ) : ?>
<span class="brezngeo-key-saved">
<?php
printf(
/* translators: %s: wp-config.php constant name */
esc_html__( 'Loaded from wp-config.php: %s', 'brezngeo' ),
'<code>BREZNGEO_' . esc_html( strtoupper( $id ) ) . '_KEY</code>'
);
?>
</span><br>
<input type="password" value="" placeholder="&#8212;" class="regular-text" disabled>
<button type="button" class="button brezngeo-test-btn" data-provider="<?php echo esc_attr( $id ); ?>">
<?php esc_html_e( 'Test connection', 'brezngeo' ); ?>
</button>
<span class="brezngeo-test-result" id="test-result-<?php echo esc_attr( $id ); ?>"></span>
<?php else : ?>
<?php if ( ! empty( $masked_keys[ $id ] ) ) : ?> <?php if ( ! empty( $masked_keys[ $id ] ) ) : ?>
<span class="brezngeo-key-saved"> <span class="brezngeo-key-saved">
<?php esc_html_e( 'Saved:', 'brezngeo' ); ?> <code><?php echo esc_html( $masked_keys[ $id ] ); ?></code> <?php esc_html_e( 'Saved:', 'brezngeo' ); ?> <code><?php echo esc_html( $masked_keys[ $id ] ); ?></code>
@ -71,10 +54,6 @@
<?php esc_html_e( 'Test connection', 'brezngeo' ); ?> <?php esc_html_e( 'Test connection', 'brezngeo' ); ?>
</button> </button>
<span class="brezngeo-test-result" id="test-result-<?php echo esc_attr( $id ); ?>"></span> <span class="brezngeo-test-result" id="test-result-<?php echo esc_attr( $id ); ?>"></span>
<?php endif; ?>
<?php if ( $id === 'openrouter' ) : ?>
<?php include BREZNGEO_DIR . 'includes/Admin/views/partials/openrouter-model-field.php'; ?>
<?php else : ?>
<br><br> <br><br>
<label><?php esc_html_e( 'Model:', 'brezngeo' ); ?></label> <label><?php esc_html_e( 'Model:', 'brezngeo' ); ?></label>
<select name="brezngeo_settings[models][<?php echo esc_attr( $id ); ?>]"> <select name="brezngeo_settings[models][<?php echo esc_attr( $id ); ?>]">
@ -89,23 +68,23 @@
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<?php <?php
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
$pricing_url = $pricing_urls[ $id ] ?? ''; $pricing_url = $pricing_urls[ $id ] ?? '';
if ( $pricing_url ) : if ( $pricing_url ) :
?> ?>
<p style="margin-top:8px;"> <p style="margin-top:8px;">
<a href="<?php echo esc_url( $pricing_url ); ?>" target="_blank" rel="noopener noreferrer"> <a href="<?php echo esc_url( $pricing_url ); ?>" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'View current pricing →', 'brezngeo' ); ?> <?php esc_html_e( 'View current pricing →', 'brezngeo' ); ?>
</a> </a>
</p> </p>
<?php endif; ?> <?php endif; ?>
<p style="margin-top:12px;"><strong><?php esc_html_e( 'Cost per 1 million tokens (for the Bulk cost overview):', 'brezngeo' ); ?></strong></p> <p style="margin-top:12px;"><strong><?php esc_html_e( 'Cost per 1 million tokens (for the Bulk cost overview):', 'brezngeo' ); ?></strong></p>
<?php <?php
foreach ( $provider->getModels() as $model_id => $model_label ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound foreach ( $provider->getModels() as $model_id => $model_label ) : // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound
$saved_costs = $settings['costs'][ $id ][ $model_id ] ?? array(); $saved_costs = $settings['costs'][ $id ][ $model_id ] ?? array();
?> ?>
<div style="margin-bottom:6px;display:flex;align-items:center;gap:12px;"> <div style="margin-bottom:6px;display:flex;align-items:center;gap:12px;">
<label style="min-width:180px;font-size:12px;"><?php echo esc_html( $model_label ); ?>:</label> <label style="min-width:180px;font-size:12px;"><?php echo esc_html( $model_label ); ?>:</label>
<span>Input $<input type="number" step="0.0001" min="0" <span>Input $<input type="number" step="0.0001" min="0"
@ -117,8 +96,7 @@
value="<?php echo esc_attr( $saved_costs['output'] ?? '' ); ?>" value="<?php echo esc_attr( $saved_costs['output'] ?? '' ); ?>"
placeholder="z.B. 0.60" style="width:75px;"> / 1M</span> placeholder="z.B. 0.60" style="width:75px;"> / 1M</span>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO; namespace BreznGEO;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Core { class Core {
private static ?Core $instance = null; private static ?Core $instance = null;
@ -16,7 +12,6 @@ class Core {
} }
public function init(): void { public function init(): void {
load_plugin_textdomain( 'brezngeo', false, dirname( plugin_basename( BREZNGEO_FILE ) ) . '/languages' );
$this->load_dependencies(); $this->load_dependencies();
$this->register_hooks(); $this->register_hooks();
} }
@ -28,7 +23,6 @@ class Core {
require_once BREZNGEO_DIR . 'includes/Providers/AnthropicProvider.php'; require_once BREZNGEO_DIR . 'includes/Providers/AnthropicProvider.php';
require_once BREZNGEO_DIR . 'includes/Providers/GeminiProvider.php'; require_once BREZNGEO_DIR . 'includes/Providers/GeminiProvider.php';
require_once BREZNGEO_DIR . 'includes/Providers/GrokProvider.php'; require_once BREZNGEO_DIR . 'includes/Providers/GrokProvider.php';
require_once BREZNGEO_DIR . 'includes/Providers/OpenRouterProvider.php';
require_once BREZNGEO_DIR . 'includes/Helpers/KeyVault.php'; require_once BREZNGEO_DIR . 'includes/Helpers/KeyVault.php';
require_once BREZNGEO_DIR . 'includes/Helpers/TokenEstimator.php'; require_once BREZNGEO_DIR . 'includes/Helpers/TokenEstimator.php';
require_once BREZNGEO_DIR . 'includes/Helpers/FallbackMeta.php'; require_once BREZNGEO_DIR . 'includes/Helpers/FallbackMeta.php';
@ -54,10 +48,6 @@ class Core {
require_once BREZNGEO_DIR . 'includes/Admin/GeoEditorBox.php'; require_once BREZNGEO_DIR . 'includes/Admin/GeoEditorBox.php';
require_once BREZNGEO_DIR . 'includes/Admin/SchemaMetaBox.php'; require_once BREZNGEO_DIR . 'includes/Admin/SchemaMetaBox.php';
require_once BREZNGEO_DIR . 'includes/Admin/SchemaPage.php'; require_once BREZNGEO_DIR . 'includes/Admin/SchemaPage.php';
require_once BREZNGEO_DIR . 'includes/Helpers/KeywordVariants.php';
require_once BREZNGEO_DIR . 'includes/Features/KeywordAnalysis.php';
require_once BREZNGEO_DIR . 'includes/Admin/KeywordMetaBox.php';
require_once BREZNGEO_DIR . 'includes/Admin/KeywordPage.php';
} }
private function register_hooks(): void { private function register_hooks(): void {
@ -66,7 +56,6 @@ class Core {
$registry->register( new Providers\AnthropicProvider() ); $registry->register( new Providers\AnthropicProvider() );
$registry->register( new Providers\GeminiProvider() ); $registry->register( new Providers\GeminiProvider() );
$registry->register( new Providers\GrokProvider() ); $registry->register( new Providers\GrokProvider() );
$registry->register( new Providers\OpenRouterProvider() );
( new Features\MetaGenerator() )->register(); ( new Features\MetaGenerator() )->register();
( new Features\SchemaEnhancer() )->register(); ( new Features\SchemaEnhancer() )->register();
@ -91,8 +80,6 @@ class Core {
( new Admin\GeoEditorBox() )->register(); ( new Admin\GeoEditorBox() )->register();
( new Admin\SchemaMetaBox() )->register(); ( new Admin\SchemaMetaBox() )->register();
( new Admin\SchemaPage() )->register(); ( new Admin\SchemaPage() )->register();
( new Admin\KeywordMetaBox() )->register();
( new Admin\KeywordPage() )->register();
} }
} }
} }

View file

@ -1,474 +0,0 @@
<?php
/**
* Keyword analysis checks for SEO optimization.
*
* @package BreznGEO
*/
namespace BreznGEO\Features;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use BreznGEO\Helpers\KeywordVariants;
/**
* Runs keyword presence and density checks against post content.
*/
class KeywordAnalysis {
/**
* Extract structured content data from HTML and post context.
*
* @param string $html Post content HTML.
* @param int $post_id Post ID for meta lookups.
* @return array{title: string, headings: string[], paragraphs: string[], images: array, slug: string, excerpt: string, meta_description: string, word_count: int, plain_text: string}
*/
public static function extract_content_data( string $html, int $post_id = 0 ): array {
$title = '';
if ( $post_id > 0 ) {
$title = get_the_title( $post_id );
}
$headings = array();
if ( preg_match_all( '/<h[2-6][^>]*>(.*?)<\/h[2-6]>/si', $html, $matches ) ) {
$headings = array_map( 'wp_strip_all_tags', $matches[1] );
}
$paragraphs = array();
if ( preg_match_all( '/<p[^>]*>(.*?)<\/p>/si', $html, $matches ) ) {
$paragraphs = array_map( 'wp_strip_all_tags', $matches[1] );
$paragraphs = array_values(
array_filter(
$paragraphs,
function ( $p ) {
return '' !== trim( $p );
}
)
);
}
$images = array();
if ( preg_match_all( '/<img[^>]+>/si', $html, $img_matches ) ) {
foreach ( $img_matches[0] as $img_tag ) {
$alt = '';
$img_title = '';
$caption = '';
if ( preg_match( '/alt=["\']([^"\']*)["\']/', $img_tag, $alt_m ) ) {
$alt = $alt_m[1];
}
if ( preg_match( '/title=["\']([^"\']*)["\']/', $img_tag, $title_m ) ) {
$img_title = $title_m[1];
}
$images[] = array(
'alt' => $alt,
'title' => $img_title,
'caption' => $caption,
);
}
}
$slug = '';
if ( $post_id > 0 ) {
$permalink = get_permalink( $post_id );
if ( $permalink ) {
$path = wp_parse_url( $permalink, PHP_URL_PATH );
$slug = $path ? trim( $path, '/' ) : '';
}
}
$excerpt = '';
if ( $post_id > 0 && function_exists( 'get_the_excerpt' ) ) {
$excerpt = get_the_excerpt( $post_id );
}
$meta_description = '';
if ( $post_id > 0 ) {
$meta_description = get_post_meta( $post_id, '_brezngeo_meta_description', true );
}
$plain_text = wp_strip_all_tags( $html );
$word_count = str_word_count( $plain_text );
return array(
'title' => $title,
'headings' => $headings,
'paragraphs' => $paragraphs,
'images' => $images,
'slug' => $slug,
'excerpt' => $excerpt,
'meta_description' => $meta_description,
'word_count' => $word_count,
'plain_text' => $plain_text,
);
}
/**
* Run all keyword checks.
*
* @param string $keyword The keyword to check.
* @param array $data Content data from extract_content_data().
* @param array $thresholds Settings: target_density, min_occurrences.
* @param bool $is_primary Whether this is the primary keyword.
* @param string $locale Locale for variant generation.
* @return array[] Array of check results.
*/
public static function analyze( string $keyword, array $data, array $thresholds = array(), bool $is_primary = true, string $locale = '' ): array {
$defaults = array(
'target_density' => 1.5,
'min_occurrences' => 3,
'density_margin' => 0.5,
);
$thresholds = array_merge( $defaults, $thresholds );
if ( ! $is_primary ) {
$thresholds['min_occurrences'] = $thresholds['min_occurrences_secondary'] ?? 1;
}
$variants = KeywordVariants::generate( $keyword, $locale );
$checks = array(
self::check_title( $keyword, $data, $variants ),
self::check_headings( $keyword, $data, $variants ),
self::check_density( $keyword, $data, $variants, $thresholds ),
self::check_image_alts( $keyword, $data, $variants ),
self::check_meta_description( $keyword, $data, $variants ),
self::check_slug( $keyword, $data, $variants ),
self::check_first_paragraph( $keyword, $data, $variants ),
self::check_last_paragraph( $keyword, $data, $variants ),
self::check_image_title_caption( $keyword, $data, $variants ),
self::check_excerpt( $keyword, $data, $variants ),
);
return $checks;
}
/**
* Check 1: Keyword in title.
*
* @param string $keyword The keyword.
* @param array $data Content data.
* @param string[] $variants Keyword variants.
* @return array Check result.
*/
public static function check_title( string $keyword, array $data, array $variants ): array {
$found = KeywordVariants::keyword_present( $keyword, $data['title'] ?? '', $variants );
return array(
'id' => 'title',
'label' => __( 'Title', 'brezngeo' ),
'status' => $found ? 'pass' : 'fail',
'message' => $found
? __( 'Keyword found in title.', 'brezngeo' )
: __( 'Keyword not found in title.', 'brezngeo' ),
);
}
/**
* Check 2: Keyword in headings (H2-H6).
*
* @param string $keyword The keyword.
* @param array $data Content data.
* @param string[] $variants Keyword variants.
* @return array Check result.
*/
public static function check_headings( string $keyword, array $data, array $variants ): array {
$headings = $data['headings'] ?? array();
$found = false;
foreach ( $headings as $heading ) {
if ( KeywordVariants::keyword_present( $keyword, $heading, $variants ) ) {
$found = true;
break;
}
}
return array(
'id' => 'headings',
'label' => __( 'Headings', 'brezngeo' ),
'status' => $found ? 'pass' : 'fail',
'message' => $found
? __( 'Keyword found in subheading.', 'brezngeo' )
: __( 'Keyword not found in any H2-H6.', 'brezngeo' ),
);
}
/**
* Check 3: Keyword density.
*
* @param string $keyword The keyword.
* @param array $data Content data.
* @param string[] $variants Keyword variants.
* @param array $thresholds Density thresholds.
* @return array Check result.
*/
public static function check_density( string $keyword, array $data, array $variants, array $thresholds ): array {
$plain = $data['plain_text'] ?? '';
$word_count = $data['word_count'] ?? 0;
$count = KeywordVariants::count_occurrences( $plain, $variants );
if ( 0 === $word_count || 0 === $count ) {
return array(
'id' => 'density',
'label' => __( 'Keyword Density', 'brezngeo' ),
'status' => 'fail',
'message' => __( 'Keyword not found in content.', 'brezngeo' ),
'details' => array(
'count' => $count,
'density' => 0,
),
);
}
$density = ( $count / $word_count ) * 100;
$target = (float) $thresholds['target_density'];
$margin = (float) $thresholds['density_margin'];
$diff = abs( $density - $target );
if ( $diff <= $margin ) {
$status = 'pass';
} elseif ( $density > 0 ) {
$status = 'warn';
} else {
$status = 'fail';
}
return array(
'id' => 'density',
'label' => __( 'Keyword Density', 'brezngeo' ),
'status' => $status,
/* translators: 1: actual density percentage, 2: target density percentage */
'message' => sprintf( __( '%1$.1f%% (target: %2$.1f%%)', 'brezngeo' ), $density, $target ),
'details' => array(
'count' => $count,
'density' => round( $density, 2 ),
),
);
}
/**
* Check 4: Keyword in image alt texts.
*
* @param string $keyword The keyword.
* @param array $data Content data.
* @param string[] $variants Keyword variants.
* @return array Check result.
*/
public static function check_image_alts( string $keyword, array $data, array $variants ): array {
$images = $data['images'] ?? array();
if ( empty( $images ) ) {
return array(
'id' => 'image_alts',
'label' => __( 'Image Alt Texts', 'brezngeo' ),
'status' => 'warn',
'message' => __( 'No images found.', 'brezngeo' ),
);
}
foreach ( $images as $img ) {
if ( KeywordVariants::keyword_present( $keyword, $img['alt'] ?? '', $variants ) ) {
return array(
'id' => 'image_alts',
'label' => __( 'Image Alt Texts', 'brezngeo' ),
'status' => 'pass',
'message' => __( 'Keyword found in image alt text.', 'brezngeo' ),
);
}
}
return array(
'id' => 'image_alts',
'label' => __( 'Image Alt Texts', 'brezngeo' ),
'status' => 'fail',
'message' => __( 'No image contains keyword in alt text.', 'brezngeo' ),
);
}
/**
* Check 5: Keyword in meta description.
*
* @param string $keyword The keyword.
* @param array $data Content data.
* @param string[] $variants Keyword variants.
* @return array Check result.
*/
public static function check_meta_description( string $keyword, array $data, array $variants ): array {
$meta = $data['meta_description'] ?? '';
if ( '' === $meta ) {
return array(
'id' => 'meta_description',
'label' => __( 'Meta Description', 'brezngeo' ),
'status' => 'warn',
'message' => __( 'Meta description is empty.', 'brezngeo' ),
);
}
$found = KeywordVariants::keyword_present( $keyword, $meta, $variants );
return array(
'id' => 'meta_description',
'label' => __( 'Meta Description', 'brezngeo' ),
'status' => $found ? 'pass' : 'fail',
'message' => $found
? __( 'Keyword found in meta description.', 'brezngeo' )
: __( 'Keyword not found in meta description.', 'brezngeo' ),
);
}
/**
* Check 6: Keyword in URL/slug.
*
* @param string $keyword The keyword.
* @param array $data Content data.
* @param string[] $variants Keyword variants.
* @return array Check result.
*/
public static function check_slug( string $keyword, array $data, array $variants ): array {
$slug = mb_strtolower( $data['slug'] ?? '' );
$found = KeywordVariants::keyword_present( $keyword, $slug, $variants );
return array(
'id' => 'slug',
'label' => __( 'URL / Slug', 'brezngeo' ),
'status' => $found ? 'pass' : 'fail',
'message' => $found
? __( 'Keyword found in URL.', 'brezngeo' )
: __( 'Keyword not found in URL.', 'brezngeo' ),
);
}
/**
* Check 7: Keyword in first paragraph.
*
* @param string $keyword The keyword.
* @param array $data Content data.
* @param string[] $variants Keyword variants.
* @return array Check result.
*/
public static function check_first_paragraph( string $keyword, array $data, array $variants ): array {
$paragraphs = $data['paragraphs'] ?? array();
if ( empty( $paragraphs ) ) {
return array(
'id' => 'first_paragraph',
'label' => __( 'First Paragraph', 'brezngeo' ),
'status' => 'fail',
'message' => __( 'No paragraphs found.', 'brezngeo' ),
);
}
$found = KeywordVariants::keyword_present( $keyword, $paragraphs[0], $variants );
return array(
'id' => 'first_paragraph',
'label' => __( 'First Paragraph', 'brezngeo' ),
'status' => $found ? 'pass' : 'fail',
'message' => $found
? __( 'Keyword found in first paragraph.', 'brezngeo' )
: __( 'Keyword not found in first paragraph.', 'brezngeo' ),
);
}
/**
* Check 8: Keyword in last paragraph.
*
* @param string $keyword The keyword.
* @param array $data Content data.
* @param string[] $variants Keyword variants.
* @return array Check result.
*/
public static function check_last_paragraph( string $keyword, array $data, array $variants ): array {
$paragraphs = $data['paragraphs'] ?? array();
if ( empty( $paragraphs ) ) {
return array(
'id' => 'last_paragraph',
'label' => __( 'Last Paragraph', 'brezngeo' ),
'status' => 'fail',
'message' => __( 'No paragraphs found.', 'brezngeo' ),
);
}
$last = end( $paragraphs );
$found = KeywordVariants::keyword_present( $keyword, $last, $variants );
return array(
'id' => 'last_paragraph',
'label' => __( 'Last Paragraph', 'brezngeo' ),
'status' => $found ? 'pass' : 'fail',
'message' => $found
? __( 'Keyword found in last paragraph.', 'brezngeo' )
: __( 'Keyword not found in last paragraph.', 'brezngeo' ),
);
}
/**
* Check 9: Keyword in image title or caption.
*
* @param string $keyword The keyword.
* @param array $data Content data.
* @param string[] $variants Keyword variants.
* @return array Check result.
*/
public static function check_image_title_caption( string $keyword, array $data, array $variants ): array {
$images = $data['images'] ?? array();
if ( empty( $images ) ) {
return array(
'id' => 'image_title_caption',
'label' => __( 'Image Title/Caption', 'brezngeo' ),
'status' => 'warn',
'message' => __( 'No images found.', 'brezngeo' ),
);
}
foreach ( $images as $img ) {
$title_text = $img['title'] ?? '';
$caption_text = $img['caption'] ?? '';
if ( KeywordVariants::keyword_present( $keyword, $title_text, $variants )
|| KeywordVariants::keyword_present( $keyword, $caption_text, $variants ) ) {
return array(
'id' => 'image_title_caption',
'label' => __( 'Image Title/Caption', 'brezngeo' ),
'status' => 'pass',
'message' => __( 'Keyword found in image title or caption.', 'brezngeo' ),
);
}
}
return array(
'id' => 'image_title_caption',
'label' => __( 'Image Title/Caption', 'brezngeo' ),
'status' => 'fail',
'message' => __( 'Keyword not found in any image title or caption.', 'brezngeo' ),
);
}
/**
* Check 10: Keyword in excerpt.
*
* @param string $keyword The keyword.
* @param array $data Content data.
* @param string[] $variants Keyword variants.
* @return array Check result.
*/
public static function check_excerpt( string $keyword, array $data, array $variants ): array {
$excerpt = $data['excerpt'] ?? '';
if ( '' === trim( $excerpt ) ) {
return array(
'id' => 'excerpt',
'label' => __( 'Excerpt', 'brezngeo' ),
'status' => 'warn',
'message' => __( 'Excerpt is empty.', 'brezngeo' ),
);
}
$found = KeywordVariants::keyword_present( $keyword, $excerpt, $variants );
return array(
'id' => 'excerpt',
'label' => __( 'Excerpt', 'brezngeo' ),
'status' => $found ? 'pass' : 'fail',
'message' => $found
? __( 'Keyword found in excerpt.', 'brezngeo' )
: __( 'Keyword not found in excerpt.', 'brezngeo' ),
);
}
}

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO\Helpers; namespace BreznGEO\Helpers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class BulkQueue { class BulkQueue {
private const LOCK_KEY = 'brezngeo_bulk_running'; private const LOCK_KEY = 'brezngeo_bulk_running';
private const LOCK_TTL = 900; // 15 minutes private const LOCK_TTL = 900; // 15 minutes

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO\Helpers; namespace BreznGEO\Helpers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class FallbackMeta { class FallbackMeta {
private const MIN = 150; private const MIN = 150;
private const MAX = 160; private const MAX = 160;

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO\Helpers; namespace BreznGEO\Helpers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/** /**
* Obfuscates API keys for database storage using XOR with a derived WP-salt key. * Obfuscates API keys for database storage using XOR with a derived WP-salt key.
* *

View file

@ -1,115 +0,0 @@
<?php
/**
* Keyword variant generation and matching.
*
* @package BreznGEO
*/
namespace BreznGEO\Helpers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Generates locale-aware keyword variants and provides matching utilities.
*/
class KeywordVariants {
/**
* Generate keyword variants based on locale.
*
* @param string $keyword The keyword to generate variants for.
* @param string $locale WordPress locale (e.g. 'en_US', 'de_DE').
* @return string[] Array of unique lowercase variants including the original.
*/
public static function generate( string $keyword, string $locale = '' ): array {
$keyword = trim( $keyword );
if ( '' === $keyword ) {
return array();
}
if ( '' === $locale ) {
$locale = function_exists( 'get_locale' ) ? get_locale() : 'en_US';
}
$lower = mb_strtolower( $keyword );
$variants = array( $lower );
// Universal: compound variants (space ↔ hyphen).
if ( mb_strpos( $lower, ' ' ) !== false ) {
$variants[] = str_replace( ' ', '-', $lower );
} elseif ( mb_strpos( $lower, '-' ) !== false ) {
$variants[] = str_replace( '-', ' ', $lower );
}
// Universal: trailing-s.
if ( mb_substr( $lower, -1 ) !== 's' ) {
$variants[] = $lower . 's';
}
$lang = mb_strtolower( mb_substr( $locale, 0, 2 ) );
$suffixes = self::get_suffixes( $lang );
foreach ( $suffixes as $suffix ) {
$variants[] = $lower . $suffix;
}
return array_values( array_unique( $variants ) );
}
/**
* Check if a keyword (or any of its variants) is present in text.
*
* @param string $keyword The keyword.
* @param string $text The text to search in.
* @param string[] $variants Pre-generated variants from generate().
* @return bool
*/
public static function keyword_present( string $keyword, string $text, array $variants ): bool {
$text_lower = mb_strtolower( $text );
foreach ( $variants as $variant ) {
if ( mb_strpos( $text_lower, $variant ) !== false ) {
return true;
}
}
return false;
}
/**
* Count occurrences of a keyword (including variants) in text.
*
* @param string $text The text to search in.
* @param string[] $variants Pre-generated variants from generate().
* @return int Total count across all variants (no double-counting of overlapping matches).
*/
public static function count_occurrences( string $text, array $variants ): int {
$text_lower = mb_strtolower( $text );
$count = 0;
foreach ( $variants as $variant ) {
$count += mb_substr_count( $text_lower, $variant );
}
return $count;
}
/**
* Get locale-specific suffixes.
*
* @param string $lang Two-letter language code.
* @return string[]
*/
private static function get_suffixes( string $lang ): array {
switch ( $lang ) {
case 'en':
return array( 'es', 'ing', 'ed' );
case 'de':
return array( 'er', 'en', 'e' );
default:
return array();
}
}
}

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO\Helpers; namespace BreznGEO\Helpers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class TokenEstimator { class TokenEstimator {
/** /**
* Pricing per 1k tokens [provider][model][input|output] * Pricing per 1k tokens [provider][model][input|output]

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO\Providers; namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class AnthropicProvider implements ProviderInterface { class AnthropicProvider implements ProviderInterface {
private const API_URL = 'https://api.anthropic.com/v1/messages'; private const API_URL = 'https://api.anthropic.com/v1/messages';

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO\Providers; namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class GeminiProvider implements ProviderInterface { class GeminiProvider implements ProviderInterface {
private const API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models/'; private const API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models/';

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO\Providers; namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class GrokProvider implements ProviderInterface { class GrokProvider implements ProviderInterface {
private const API_URL = 'https://api.x.ai/v1/chat/completions'; private const API_URL = 'https://api.x.ai/v1/chat/completions';

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO\Providers; namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class OpenAIProvider implements ProviderInterface { class OpenAIProvider implements ProviderInterface {
private const API_URL = 'https://api.openai.com/v1/chat/completions'; private const API_URL = 'https://api.openai.com/v1/chat/completions';

View file

@ -1,106 +0,0 @@
<?php
namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class OpenRouterProvider implements ProviderInterface {
public const API_URL = 'https://openrouter.ai/api/v1/chat/completions';
public const MODELS_URL = 'https://openrouter.ai/api/v1/models';
public const MODELS_CACHE = 'brezngeo_openrouter_models';
public const FALLBACK_TEST = 'openai/gpt-4o-mini';
public function getId(): string {
return 'openrouter'; }
public function getName(): string {
return 'OpenRouter'; }
/**
* Returns cached curated Marketing/SEO models plus any saved custom model.
* When the cache is empty, returns an empty array the admin view shows a "Load models" hint.
*/
public function getModels(): array {
$cached = get_transient( self::MODELS_CACHE );
$models = array();
if ( is_array( $cached ) ) {
foreach ( $cached as $id => $meta ) {
if ( is_array( $meta ) && isset( $meta['label'] ) ) {
$models[ (string) $id ] = (string) $meta['label'];
}
}
}
if ( class_exists( '\BreznGEO\Admin\SettingsPage' ) ) {
$settings = \BreznGEO\Admin\SettingsPage::getSettings();
$custom = $settings['models'][ $this->getId() ] ?? '';
if ( is_string( $custom ) && $custom !== '' && ! isset( $models[ $custom ] ) ) {
$models[ $custom ] = $custom . ' (' . __( 'custom', 'brezngeo' ) . ')';
}
}
return $models;
}
public function testConnection( string $api_key ): array {
try {
$this->generateText( 'Say "ok"', $api_key, self::FALLBACK_TEST, 5 );
return array(
'success' => true,
'message' => __( 'Connection successful', 'brezngeo' ),
);
} catch ( \RuntimeException $e ) {
return array(
'success' => false,
'message' => $e->getMessage(),
);
}
}
public function generateText( string $prompt, string $api_key, string $model, int $max_tokens = 300 ): string {
$response = wp_remote_post(
self::API_URL,
array(
'timeout' => 30,
'headers' => array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
'HTTP-Referer' => home_url( '/' ),
'X-Title' => 'BreznGEO',
),
'body' => wp_json_encode(
array(
'model' => $model,
'messages' => array(
array(
'role' => 'user',
'content' => $prompt,
),
),
'max_tokens' => $max_tokens,
)
),
)
);
return $this->parseResponse( $response );
}
private function parseResponse( $response ): string {
if ( is_wp_error( $response ) ) {
throw new \RuntimeException( esc_html( $response->get_error_message() ) );
}
$code = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $code !== 200 ) {
$msg = $body['error']['message'] ?? "HTTP $code";
throw new \RuntimeException( esc_html( $msg ) );
}
return trim( $body['choices'][0]['message']['content'] ?? '' );
}
}

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO\Providers; namespace BreznGEO\Providers;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
interface ProviderInterface { interface ProviderInterface {
/** Unique machine-readable ID, e.g. 'openai' */ /** Unique machine-readable ID, e.g. 'openai' */
public function getId(): string; public function getId(): string;

View file

@ -1,10 +1,6 @@
<?php <?php
namespace BreznGEO; namespace BreznGEO;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use BreznGEO\Providers\ProviderInterface; use BreznGEO\Providers\ProviderInterface;
class ProviderRegistry { class ProviderRegistry {

Binary file not shown.

View file

@ -18,6 +18,9 @@ msgstr ""
"X-Domain: brezngeo\n" "X-Domain: brezngeo\n"
#: includes/Admin/AdminMenu.php:11 #: includes/Admin/AdminMenu.php:11
msgid "BreznGEO"
msgstr "BreznGEO"
#: includes/Admin/AdminMenu.php:12 #: includes/Admin/AdminMenu.php:12
msgid "BreznGEO" msgid "BreznGEO"
msgstr "BreznGEO" msgstr "BreznGEO"
@ -1086,582 +1089,3 @@ msgstr "Generiert GEO-optimierte Inhaltsblöcke mit KI für bessere LLM-Sichtbar
#: includes/Admin/views/dashboard.php #: includes/Admin/views/dashboard.php
msgid "Save" msgid "Save"
msgstr "Speichern" msgstr "Speichern"
# --- v1.3.3 i18n pass ---
#: includes/Admin/views/dashboard.php
#: includes/Admin/AdminMenu.php
#: includes/Admin/views/keyword-settings.php
msgid "Keyword Analysis"
msgstr "Keyword-Analyse"
#: includes/Admin/views/dashboard.php
msgid "AI-powered keyword suggestions, optimization tips, and semantic analysis."
msgstr "KI-basierte Keyword-Vorschläge, Optimierungstipps und semantische Analyse."
#: includes/Admin/views/meta.php
msgid "SEO Widget"
msgstr "SEO-Widget"
#: includes/Admin/views/meta.php
msgid "Theme outputs post title as H1 (suppresses \"no H1\" warning in editor)"
msgstr "Theme gibt Beitragstitel als H1 aus (unterdrückt \"kein H1\"-Warnung im Editor)"
#: includes/Admin/views/meta.php
msgid "Most themes render the post title as an H1 tag on the front end. Enable this to avoid false warnings in the SEO Widget when the content itself contains no H1."
msgstr "Die meisten Themes geben den Beitragstitel als H1 im Frontend aus. Aktivieren, um falsche Warnungen im SEO-Widget zu vermeiden, wenn der Inhalt selbst kein H1 enthält."
#: includes/Admin/views/meta.php
msgid "No AI provider active."
msgstr "Kein KI-Anbieter aktiv."
#: includes/Admin/views/meta.php
msgid "Meta descriptions will use the fallback method (first paragraph of the post) until an API key is configured and AI generation is enabled."
msgstr "Meta-Beschreibungen verwenden die Fallback-Methode (erster Absatz des Beitrags), bis ein API-Schlüssel konfiguriert und die KI-Generierung aktiviert ist."
#: includes/Admin/views/meta.php
msgid "Configure AI Provider →"
msgstr "KI-Anbieter konfigurieren →"
#: includes/Admin/views/meta.php
msgid "Fallback mode active — configure an AI provider to enable AI generation."
msgstr "Fallback-Modus aktiv — einen KI-Anbieter konfigurieren, um KI-Generierung zu aktivieren."
#: includes/Admin/views/txt.php
msgid "Clear Cache"
msgstr "Cache leeren"
#: includes/Admin/views/txt.php
msgid "URL:"
msgstr "URL:"
#: includes/Admin/KeywordMetaBox.php
msgid "Keyword Analysis (BreznGEO)"
msgstr "Keyword-Analyse (BreznGEO)"
#: includes/Admin/views/keyword-meta-box.php
msgid "Main Keyword"
msgstr "Haupt-Keyword"
#: includes/Admin/views/keyword-meta-box.php
msgid "e.g. Passau travel guide"
msgstr "z. B. Passau Reiseführer"
#: includes/Admin/views/keyword-meta-box.php
msgid "Secondary Keywords"
msgstr "Neben-Keywords"
#: includes/Admin/views/keyword-meta-box.php
msgid "Add Keyword"
msgstr "Keyword hinzufügen"
#: includes/Admin/views/keyword-meta-box.php
msgid "Analyze"
msgstr "Analysieren"
#: includes/Admin/views/keyword-meta-box.php
msgid "Suggest Keywords"
msgstr "Keywords vorschlagen"
#: includes/Admin/views/keyword-meta-box.php
msgid "Showing cached results. Click \"Analyze\" to refresh."
msgstr "Gecachte Ergebnisse. Klicke \"Analysieren\" zum Aktualisieren."
#: includes/Admin/views/keyword-meta-box.php
msgid "Optimization Tips"
msgstr "Optimierungstipps"
#: includes/Admin/views/keyword-meta-box.php
msgid "Semantic Analysis"
msgstr "Semantische Analyse"
#: includes/Admin/KeywordMetaBox.php (JS i18n)
msgid "Analyzing…"
msgstr "Analysiere…"
#: includes/Admin/KeywordMetaBox.php
msgid "Please enter a main keyword."
msgstr "Bitte ein Haupt-Keyword eingeben."
#: includes/Admin/KeywordMetaBox.php
msgid "Getting suggestions…"
msgstr "Hole Vorschläge…"
#: includes/Admin/KeywordMetaBox.php
msgid "Getting optimization tips…"
msgstr "Hole Optimierungstipps…"
#: includes/Admin/KeywordMetaBox.php
msgid "Running semantic analysis…"
msgstr "Semantische Analyse läuft…"
#: includes/Admin/KeywordMetaBox.php
msgid "AI keyword features are not activated."
msgstr "KI-Keyword-Funktionen sind nicht aktiviert."
#: includes/Admin/KeywordMetaBox.php
msgid "AI generation failed. Check provider settings."
msgstr "KI-Generierung fehlgeschlagen. Provider-Einstellungen prüfen."
#: includes/Admin/KeywordMetaBox.php
msgid "Could not parse AI response."
msgstr "KI-Antwort konnte nicht verarbeitet werden."
#: includes/Admin/KeywordMetaBox.php
msgid "No keyword provided."
msgstr "Kein Keyword angegeben."
#: includes/Admin/SeoWidget.php
msgid "No H1 heading"
msgstr "Keine H1-Überschrift"
#: includes/Admin/SeoWidget.php
msgid "Multiple H1 headings"
msgstr "Mehrere H1-Überschriften"
#: includes/Admin/SeoWidget.php
msgid "No internal links"
msgstr "Keine internen Links"
#: includes/Admin/SeoWidget.php
msgid "internal"
msgstr "intern"
#: includes/Admin/SeoWidget.php
msgid "external"
msgstr "extern"
#: includes/Admin/SeoWidget.php
msgid "min"
msgstr "Min."
#: includes/Admin/views/keyword-settings.php
msgid "Analysis Settings"
msgstr "Analyse-Einstellungen"
#: includes/Admin/views/keyword-settings.php
msgid "Update Mode"
msgstr "Aktualisierungsmodus"
#: includes/Admin/views/keyword-settings.php
msgid "Manual — click \"Analyze\" button"
msgstr "Manuell — Button \"Analysieren\" klicken"
#: includes/Admin/views/keyword-settings.php
msgid "Live — auto-analyze while typing"
msgstr "Live — automatisch beim Tippen analysieren"
#: includes/Admin/views/keyword-settings.php
msgid "On Save — analyze when post is saved"
msgstr "Beim Speichern — analysieren wenn Beitrag gespeichert wird"
#: includes/Admin/views/keyword-settings.php
msgid "Target Keyword Density (%)"
msgstr "Ziel-Keyword-Dichte (%)"
#: includes/Admin/views/keyword-settings.php
msgid "Recommended: 1.02.0%. Pass range is ±0.5% around the target."
msgstr "Empfohlen: 1,02,0 %. Toleranzbereich ist ±0,5 % um den Zielwert."
#: includes/Admin/views/keyword-settings.php
msgid "Min. Occurrences (Primary)"
msgstr "Mind. Vorkommen (Primär)"
#: includes/Admin/views/keyword-settings.php
msgid "Min. Occurrences (Secondary)"
msgstr "Mind. Vorkommen (Sekundär)"
#: includes/Admin/views/keyword-settings.php
msgid "Live Mode Debounce (ms)"
msgstr "Live-Modus Verzögerung (ms)"
#: includes/Admin/views/keyword-settings.php
msgid "Delay in milliseconds before live analysis triggers after typing stops."
msgstr "Verzögerung in Millisekunden bevor die Live-Analyse nach dem Tippen startet."
#: includes/Admin/views/keyword-settings.php
msgid "Show keyword meta box on"
msgstr "Keyword-Meta-Box anzeigen bei"
#: includes/Admin/views/bulk.php
msgid "No AI provider connected — descriptions will be generated from content without AI (fallback mode)."
msgstr "Kein KI-Anbieter verbunden — Beschreibungen werden ohne KI aus dem Inhalt generiert (Fallback-Modus)."
#: includes/Admin/views/geo.php
msgid "Labels"
msgstr "Beschriftungen"
#: includes/Admin/views/geo.php
msgid "Styling"
msgstr "Darstellung"
#: includes/Admin/views/geo.php
msgid "Position"
msgstr "Position"
#: includes/Admin/views/geo.php
msgid "Theme"
msgstr "Theme"
#: includes/Admin/views/geo.php
msgid "Light"
msgstr "Hell"
#: includes/Admin/views/geo.php
msgid "Dark"
msgstr "Dunkel"
#: includes/Admin/views/geo.php
msgid "Minimal"
msgstr "Minimal"
#: includes/Admin/views/geo.php
msgid "Brezn"
msgstr "Brezn"
# --- Schema.org ---
#: includes/Admin/AdminMenu.php
msgid "Schema.org"
msgstr "Schema.org"
#: includes/Admin/AdminMenu.php
msgid "AI disabled"
msgstr "KI deaktiviert"
#: includes/Admin/AdminMenu.php
msgid "\xe2\x80\x94 Not configured \xe2\x80\x94"
msgstr "\xe2\x80\x94 Nicht konfiguriert \xe2\x80\x94"
#: includes/Admin/views/schema.php
msgid "BreznGEO Schema"
msgstr "BreznGEO Schema"
#: includes/Admin/views/schema-meta-box.php
msgid "Schema Type"
msgstr "Schema-Typ"
#: includes/Admin/views/schema-meta-box.php
msgid "\xe2\x80\x94 No Schema \xe2\x80\x94"
msgstr "\xe2\x80\x94 Kein Schema \xe2\x80\x94"
#: includes/Admin/views/schema.php
msgid "BlogPosting / Article (with embedded Author + Image)"
msgstr "BlogPosting / Artikel (mit eingebettetem Autor + Bild)"
#: includes/Admin/views/schema.php
msgid "FAQPage (from GEO Quick Overview \xe2\x80\x94 automatic)"
msgstr "FAQPage (aus GEO Schnell\xc3\xbcberblick \xe2\x80\x94 automatisch)"
#: includes/Admin/views/schema.php
msgid "ImageObject (Featured Image)"
msgstr "ImageObject (Beitragsbild)"
#: includes/Admin/views/schema.php
msgid "VideoObject (auto-detect YouTube/Vimeo)"
msgstr "VideoObject (YouTube/Vimeo automatisch erkennen)"
#: includes/Admin/views/schema-meta-box.php
msgid "Recipe"
msgstr "Rezept"
#: includes/Admin/views/schema-meta-box.php
msgid "Recipe Name"
msgstr "Rezeptname"
#: includes/Admin/views/schema-meta-box.php
msgid "Recipe (Metabox in Post Editor)"
msgstr "Rezept (Metabox im Beitragseditor)"
#: includes/Admin/views/schema-meta-box.php
msgid "Event"
msgstr "Veranstaltung"
#: includes/Admin/views/schema-meta-box.php
msgid "Event Name"
msgstr "Veranstaltungsname"
#: includes/Admin/views/schema-meta-box.php
msgid "Event (Metabox in Post Editor)"
msgstr "Veranstaltung (Metabox im Beitragseditor)"
#: includes/Admin/views/schema-meta-box.php
msgid "HowTo Guide"
msgstr "HowTo-Anleitung"
#: includes/Admin/views/schema-meta-box.php
msgid "HowTo (Metabox in Post Editor)"
msgstr "HowTo (Metabox im Beitragseditor)"
#: includes/Admin/views/schema-meta-box.php
msgid "Review / Rating"
msgstr "Bewertung / Rating"
#: includes/Admin/views/schema-meta-box.php
msgid "Review with Rating (Metabox in Post Editor)"
msgstr "Bewertung mit Rating (Metabox im Beitragseditor)"
#: includes/Admin/views/schema-meta-box.php
msgid "Guide Name"
msgstr "Anleitung Name"
#: includes/Admin/views/schema-meta-box.php
msgid "Steps (one line = one step)"
msgstr "Schritte (eine Zeile = ein Schritt)"
#: includes/Admin/views/schema-meta-box.php
msgid "Ingredients (one per line)"
msgstr "Zutaten (eine pro Zeile)"
#: includes/Admin/views/schema-meta-box.php
msgid "Instructions (one step per line)"
msgstr "Anleitung (ein Schritt pro Zeile)"
#: includes/Admin/views/schema-meta-box.php
msgid "Prep Time (min)"
msgstr "Vorbereitungszeit (Min.)"
#: includes/Admin/views/schema-meta-box.php
msgid "Cook Time (min)"
msgstr "Kochzeit (Min.)"
#: includes/Admin/views/schema-meta-box.php
msgid "Servings"
msgstr "Portionen"
#: includes/Admin/views/schema-meta-box.php
msgid "Rating (1\xe2\x80\x935)"
msgstr "Bewertung (1\xe2\x80\x935)"
#: includes/Admin/views/schema-meta-box.php
msgid "Reviewed Product / Service"
msgstr "Bewertetes Produkt / Dienstleistung"
#: includes/Admin/views/schema-meta-box.php
msgid "Start Date"
msgstr "Startdatum"
#: includes/Admin/views/schema-meta-box.php
msgid "End Date (optional)"
msgstr "Enddatum (optional)"
#: includes/Admin/views/schema-meta-box.php
msgid "Location or URL"
msgstr "Ort oder URL"
#: includes/Admin/views/schema-meta-box.php
msgid "Online Event"
msgstr "Online-Veranstaltung"
#: includes/Admin/views/schema-meta-box.php
msgid "Post not found."
msgstr "Beitrag nicht gefunden."
# --- Keyword Analysis Checks ---
#: includes/Features/KeywordAnalysis.php
msgid "Keyword Density"
msgstr "Keyword-Dichte"
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in title."
msgstr "Keyword im Titel gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in title."
msgstr "Keyword nicht im Titel gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in subheading."
msgstr "Keyword in Zwischenüberschrift gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in any H2-H6."
msgstr "Keyword in keiner H2-H6 gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in URL."
msgstr "Keyword in URL gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in URL."
msgstr "Keyword nicht in URL gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in meta description."
msgstr "Keyword in Meta-Beschreibung gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in meta description."
msgstr "Keyword nicht in Meta-Beschreibung gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Meta description is empty."
msgstr "Meta-Beschreibung ist leer."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in first paragraph."
msgstr "Keyword im ersten Absatz gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in first paragraph."
msgstr "Keyword nicht im ersten Absatz gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in last paragraph."
msgstr "Keyword im letzten Absatz gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in last paragraph."
msgstr "Keyword nicht im letzten Absatz gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in excerpt."
msgstr "Keyword im Textauszug gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in excerpt."
msgstr "Keyword nicht im Textauszug gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Excerpt is empty."
msgstr "Textauszug ist leer."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in image alt text."
msgstr "Keyword im Bild-Alt-Text gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "No image contains keyword in alt text."
msgstr "Kein Bild enthält das Keyword im Alt-Text."
#: includes/Features/KeywordAnalysis.php
msgid "No images found."
msgstr "Keine Bilder gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword found in image title or caption."
msgstr "Keyword im Bildtitel oder Beschriftung gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in any image title or caption."
msgstr "Keyword in keinem Bildtitel oder Beschriftung gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "No paragraphs found."
msgstr "Keine Absätze gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "Keyword not found in content."
msgstr "Keyword nicht im Inhalt gefunden."
#: includes/Features/KeywordAnalysis.php
msgid "URL / Slug"
msgstr "URL / Slug"
#: includes/Features/KeywordAnalysis.php
msgid "First Paragraph"
msgstr "Erster Absatz"
#: includes/Features/KeywordAnalysis.php
msgid "Last Paragraph"
msgstr "Letzter Absatz"
#: includes/Features/KeywordAnalysis.php
msgid "Image Alt Texts"
msgstr "Bild-Alt-Texte"
#: includes/Features/KeywordAnalysis.php
msgid "Image Title/Caption"
msgstr "Bildtitel / Beschriftung"
#: includes/Features/KeywordAnalysis.php
msgid "Excerpt"
msgstr "Textauszug"
#: includes/Features/KeywordAnalysis.php
msgid "Meta Description"
msgstr "Meta-Beschreibung"
#: includes/Features/KeywordAnalysis.php
msgid "FAQ"
msgstr "FAQ"
#: includes/Features/KeywordAnalysis.php
#, php-format
msgid "%1$.1f%% (target: %2$.1f%%)"
msgstr "%1$.1f %% (Ziel: %2$.1f %%)"
#: includes/Admin/views/geo.php
msgid "The GEO block will not be generated automatically until an API key is configured and AI generation is enabled."
msgstr "Der GEO-Block wird erst automatisch generiert, wenn ein API-Schlüssel konfiguriert und die KI-Generierung aktiviert ist."
#: includes/Admin/views/geo.php
msgid "Variables: {title}, {content}, {language}"
msgstr "Variablen: {title}, {content}, {language}"
#: includes/Admin/views/geo.php
#, php-format
msgid "Want to customise further? <a href=\"%s\" target=\"_blank\" rel=\"noopener\">Learn how to style the block via your theme &rarr;</a>"
msgstr "Mehr anpassen? <a href=\"%s\" target=\"_blank\" rel=\"noopener\">Erfahre, wie du den Block per Theme stylen kannst &rarr;</a>"
#: includes/Admin/views/geo.php
msgid "Left border stripe and expand arrow colour. Leave empty for the default blue. Not used by the Minimal theme."
msgstr "Farbe des linken Rands und Expand-Pfeils. Leer lassen für Standard-Blau. Wird vom Minimal-Theme nicht verwendet."
#: includes/Admin/views/geo.php
msgid "Light \xe2\x80\x94 clean card with a blue accent. Dark \xe2\x80\x94 same for dark-mode sites. Minimal \xe2\x80\x94 borderless, left stripe only. Brezn \xe2\x80\x94 Brezn blue with diamond header pattern."
msgstr "Hell \xe2\x80\x94 klare Karte mit blauem Akzent. Dunkel \xe2\x80\x94 dasselbe f\xc3\xbcr Dark-Mode-Seiten. Minimal \xe2\x80\x94 rahmenlos, nur linker Streifen. Brezn \xe2\x80\x94 Brezn-Blau mit Rautenmuster."
#: includes/Admin/views/bulk.php
#, php-format
msgid "No AI provider connected \xe2\x80\x94 descriptions will be generated from content without AI (fallback mode). <a href=\"%s\">Configure a provider \xe2\x86\x92</a>"
msgstr "Kein KI-Anbieter verbunden \xe2\x80\x94 Beschreibungen werden ohne KI aus dem Inhalt generiert (Fallback-Modus). <a href=\"%s\">Anbieter konfigurieren \xe2\x86\x92</a>"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Load models"
msgstr "Modelle laden"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Custom model ID\xe2\x80\xa6"
msgstr "Eigene Modell-ID\xe2\x80\xa6"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Custom model ID:"
msgstr "Eigene Modell-ID:"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "e.g. anthropic/claude-opus-4.7"
msgstr "z. B. anthropic/claude-opus-4.7"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Learn how to find OpenRouter model IDs \xe2\x86\x92"
msgstr "So findest du OpenRouter-Modell-IDs \xe2\x86\x92"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Browse all OpenRouter models \xe2\x86\x92"
msgstr "Alle OpenRouter-Modelle ansehen \xe2\x86\x92"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "No models loaded yet \xe2\x80\x94 click \"Load models\""
msgstr "Noch keine Modelle geladen \xe2\x80\x94 auf \"Modelle laden\" klicken"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Pricing (automatically from OpenRouter, per 1M tokens):"
msgstr "Preise (automatisch von OpenRouter, pro 1M Tokens):"
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Pricing unknown for custom models \xe2\x80\x94 will be populated after you click \"Load models\"."
msgstr "Preise f\xc3\xbcr eigene Modelle unbekannt \xe2\x80\x94 werden nach Klick auf \"Modelle laden\" bef\xc3\xbcllt."
#: includes/Admin/views/partials/openrouter-model-field.php
msgid "Click \"Load models\" to fetch pricing from OpenRouter."
msgstr "Klick auf \"Modelle laden\", um Preise von OpenRouter zu holen."
#: includes/Providers/OpenRouterProvider.php
msgid "custom"
msgstr "benutzerdefiniert"
#: includes/Admin/ProviderPage.php
msgid "No models returned by OpenRouter."
msgstr "OpenRouter hat keine Modelle geliefert."

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ Contributors: mifupadev
Tags: seo, ai, meta description, schema, llms.txt Tags: seo, ai, meta description, schema, llms.txt
Requires at least: 6.0 Requires at least: 6.0
Tested up to: 6.9 Tested up to: 6.9
Stable tag: 1.3.0 Stable tag: 1.1.0
Requires PHP: 8.0 Requires PHP: 8.0
License: GPL-2.0-or-later License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html License URI: https://www.gnu.org/licenses/gpl-2.0.html
@ -20,16 +20,15 @@ All AI features are optional. Without an API key, the plugin falls back to local
= Learn more = = Learn more =
* Website: <a href="https://brezngeo.com/">brezngeo.com</a> * Website: https://brezngeo.com/
* FAQ: <a href="https://brezngeo.com/faq.html">brezngeo.com/faq</a> * FAQ: https://brezngeo.com/faq.html
* Live demo: <a href="https://brezngeo.com/demo.html">brezngeo.com/demo</a> * Live demo: https://brezngeo.com/demo.html
= At a glance = = At a glance =
* Generates AI meta descriptions automatically on publish — falls back to clean local extraction without any API key * Generates AI meta descriptions automatically on publish — falls back to clean local extraction without any API key
* Adds a GEO Quick Overview block to each post: AI-generated summary, key bullet points, optional FAQ * Adds a GEO Quick Overview block to each post: AI-generated summary, key bullet points, optional FAQ
* Suggests internal links while writing — text-based matching works without AI; optional AI upgrade for semantic ranking * Suggests internal links while writing — text-based matching works without AI; optional AI upgrade for semantic ranking
* Analyzes keyword usage in real time — checks title, headings, density, images, and more with locale-aware variant matching
* Bulk-generates descriptions for all existing posts that have none * Bulk-generates descriptions for all existing posts that have none
* Adds Schema.org JSON-LD structured data for search engines and AI retrieval systems * Adds Schema.org JSON-LD structured data for search engines and AI retrieval systems
* Serves `/llms.txt` — a machine-readable content index for AI discovery tools * Serves `/llms.txt` — a machine-readable content index for AI discovery tools
@ -69,13 +68,12 @@ Finds all published posts without a meta description (including descriptions set
= Multi-Provider AI Support = = Multi-Provider AI Support =
Choose from five AI providers — or access 600+ models through a single OpenRouter API key. Switch at any time without losing your settings: Choose from four AI providers and switch at any time without losing your settings:
* OpenAI (GPT-4.1, GPT-4o, GPT-4o mini, and more) * OpenAI (GPT-4.1, GPT-4o, GPT-4o mini, and more)
* Anthropic Claude (Claude 3.5 Sonnet, Claude 3 Haiku, and more) * Anthropic Claude (Claude 3.5 Sonnet, Claude 3 Haiku, and more)
* Google Gemini (Gemini 2.0 Flash, Gemini 1.5 Pro, and more) * Google Gemini (Gemini 2.0 Flash, Gemini 1.5 Pro, and more)
* xAI Grok (Grok 3, Grok 3 mini, and more) * xAI Grok (Grok 3, Grok 3 mini, and more)
* OpenRouter (access to 600+ models including Claude, GPT, Gemini, Llama, Mistral, DeepSeek, and more through a single API key)
= Schema.org Enhancer (GEO) = = Schema.org Enhancer (GEO) =
@ -100,10 +98,6 @@ Block individual AI training and data-harvesting bots directly from the WordPres
Automatically logs visits from known AI bots. Stores the bot name, a SHA-256-hashed IP address, and the requested URL. Entries older than 90 days are purged automatically. A 30-day summary is shown on the plugin dashboard. Automatically logs visits from known AI bots. Stores the bot name, a SHA-256-hashed IP address, and the requested URL. Entries older than 90 days are purged automatically. A 30-day summary is shown on the plugin dashboard.
= Keyword Analysis =
A post editor meta box that analyzes keyword usage in real time. Enter a primary keyword and optional secondary keywords — the plugin checks title, headings, keyword density, image alt text, meta description, URL slug, first and last paragraph, image titles and captions, and excerpt. Each check reports pass, warning, or fail status with actionable feedback. Three update modes: live (debounced while typing), manual (button click), or on save. Optional AI features (when an API key is configured): keyword suggestions, content optimization tips, and semantic keyword analysis. Supports locale-aware keyword variant matching for English and German. Configurable via a dedicated settings page (target density, minimum occurrences, post types, debounce interval).
= Post Editor Integration = = Post Editor Integration =
A "Meta Description" meta box in the post and page editor shows the current description, its source (AI / Fallback / Manual), a live character counter, and a "Regenerate with AI" button. A sidebar SEO widget displays word count, reading time, heading structure, and link counts with live warnings. A "Meta Description" meta box in the post and page editor shows the current description, its source (AI / Fallback / Manual), a live character counter, and a "Regenerate with AI" button. A sidebar SEO widget displays word count, reading time, heading structure, and link counts with live warnings.
@ -141,7 +135,7 @@ An API key is required for AI-generated meta descriptions. Without one, the plug
= How much does it cost to generate meta descriptions? = = How much does it cost to generate meta descriptions? =
Cost depends on the AI provider and model you choose. A single meta description typically uses fewer than 1,500 tokens (input + output combined). As a rough reference, 1,000 descriptions with GPT-4o mini has cost around $0.50$1.00 at recent rates — but AI provider pricing changes over time. The AI Provider settings page links directly to the current pricing page for each supported provider. For OpenRouter, per-model pricing is fetched directly from the API and displayed in-plugin after you click "Load models". Cost depends on the AI provider and model you choose. A single meta description typically uses fewer than 1,500 tokens (input + output combined). As a rough reference, 1,000 descriptions with GPT-4o mini has cost around $0.50$1.00 at recent rates — but AI provider pricing changes over time. The AI Provider settings page links directly to the current pricing page for each supported provider.
= Are my API keys stored securely? = = Are my API keys stored securely? =
@ -195,66 +189,35 @@ The following features may send data to the selected AI provider:
* **Meta Descriptions** — post title and content excerpt are sent to generate a meta description. Triggered on publish, on update, or via the Bulk Generator. * **Meta Descriptions** — post title and content excerpt are sent to generate a meta description. Triggered on publish, on update, or via the Bulk Generator.
* **GEO Block** — post title and content are sent to generate a Quick Overview block (summary, key points, optional FAQ). Triggered on publish/update or manually from the post editor. * **GEO Block** — post title and content are sent to generate a Quick Overview block (summary, key points, optional FAQ). Triggered on publish/update or manually from the post editor.
* **Internal Link Suggestions (AI upgrade)** — up to 20 pre-scored candidate link pairs (post titles and URLs) are sent for semantic ranking. Triggered manually, on save, or on a timed interval — all configurable by the user. * **Internal Link Suggestions (AI upgrade)** — up to 20 pre-scored candidate link pairs (post titles and URLs) are sent for semantic ranking. Triggered manually, on save, or on a timed interval — all configurable by the user.
* **Keyword Analysis (AI upgrade)** — post content and keyword are sent for AI-powered keyword suggestions, content optimization tips, and semantic keyword analysis. Triggered manually from the post editor meta box.
No data is transmitted during normal page loads or to visitors. No data is transmitted during normal page loads or to visitors.
= OpenAI = = OpenAI =
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions); post content and keyword (keyword analysis). * Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
* API endpoint: `https://api.openai.com/v1/` * API endpoint: `https://api.openai.com/v1/`
* Privacy policy: https://openai.com/policies/privacy-policy/ * Privacy policy: https://openai.com/policies/privacy-policy/
* Terms of use: https://openai.com/policies/terms-of-use/ * Terms of use: https://openai.com/policies/terms-of-use/
= Anthropic Claude = = Anthropic Claude =
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions); post content and keyword (keyword analysis). * Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
* API endpoint: `https://api.anthropic.com/` * API endpoint: `https://api.anthropic.com/`
* Privacy policy: https://www.anthropic.com/privacy * Privacy policy: https://www.anthropic.com/privacy
* Terms of use: https://www.anthropic.com/legal/consumer-terms * Terms of use: https://www.anthropic.com/legal/consumer-terms
= Google Gemini = = Google Gemini =
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions); post content and keyword (keyword analysis). * Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
* API endpoint: `https://generativelanguage.googleapis.com/` * API endpoint: `https://generativelanguage.googleapis.com/`
* Privacy policy: https://policies.google.com/privacy * Privacy policy: https://policies.google.com/privacy
* Terms of use: https://ai.google.dev/gemini-api/terms?hl=en * Terms of use: https://ai.google.dev/gemini-api/terms?hl=en
= xAI Grok = = xAI Grok =
* Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions); post content and keyword (keyword analysis). * Data sent: Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions).
* API endpoint: `https://api.x.ai/` * API endpoint: `https://api.x.ai/`
* Privacy policy: https://x.ai/privacy-policy * Privacy policy: https://x.ai/privacy-policy
* Terms of use: https://x.ai/legal/terms-of-service * Terms of use: https://x.ai/legal/terms-of-service
= OpenRouter =
* Data sent (only when selected as active provider): Post title and content excerpt (meta descriptions, GEO Block); candidate post titles and URLs (link suggestions); post content and keyword (keyword analysis).
* Additional request (only when you click "Load models" in the provider settings): a list of available models is fetched from the OpenRouter models API. No user data is sent with this request.
* API endpoints: `https://openrouter.ai/api/v1/chat/completions` (text generation), `https://openrouter.ai/api/v1/models` (model list, on demand).
* Note: OpenRouter is a routing aggregator. The actual AI model selected by the user may be served by OpenAI, Anthropic, Google, Meta, xAI, Mistral, DeepSeek or another upstream provider. See OpenRouter's privacy policy for details on upstream routing.
* Privacy policy: https://openrouter.ai/privacy
* Terms of use: https://openrouter.ai/terms
== Changelog == == Changelog ==
= 1.3.0 =
* New: OpenRouter as a fifth AI provider — access to 600+ models (Claude, GPT, Gemini, Llama, Mistral, DeepSeek, and more) through a single API key.
* New: On-demand model loader fetches OpenRouter's curated Marketing/SEO model list with live per-model pricing.
* New: Custom model ID field lets you route to any OpenRouter model (e.g. anthropic/claude-opus-4.7) without waiting for a plugin update.
* New: OpenRouter joins the wp-config.php key-mapping (BREZNGEO_OPENROUTER_KEY) — define any provider key as a constant to keep it out of the database entirely; the admin field becomes read-only.
= 1.2.2 =
* i18n: Added explicit load_plugin_textdomain() call for reliable translation loading on ClassicPress and other WordPress derivatives.
= 1.2.1 =
* Security: Added ABSPATH direct access guards to all PHP class files.
* i18n: Complete German translation — all 394 UI strings now translated.
* i18n: Regenerated .po/.mo/.pot translation files.
= 1.2.0 =
* New: Keyword Analysis meta box in the post editor — checks keyword usage across title, headings, density, image alts, meta description, slug, first/last paragraph, image title/caption, and excerpt.
* New: Primary and secondary keyword support with configurable minimum occurrences.
* New: Three analysis update modes: live (debounced), manual, and on-save.
* New: Locale-aware keyword variant matching for English and German (compound words, suffixes).
* New: Optional AI-powered keyword suggestions, content optimization tips, and semantic keyword analysis.
* New: Keyword Analysis settings page with target density, minimum occurrences, post type selection, and debounce configuration.
= 1.1.0 = = 1.1.0 =
* Fixed Google Gemini API terms URL that caused too many redirects during WordPress.org review. * Fixed Google Gemini API terms URL that caused too many redirects during WordPress.org review.
* Improved input sanitization in Schema.org meta box — uses `map_deep()` with `sanitize_textarea_field` instead of relying on downstream sanitization with phpcs suppression. * Improved input sanitization in Schema.org meta box — uses `map_deep()` with `sanitize_textarea_field` instead of relying on downstream sanitization with phpcs suppression.
@ -283,15 +246,6 @@ No data is transmitted during normal page loads or to visitors.
== Upgrade Notice == == Upgrade Notice ==
= 1.2.2 =
Fixes translation loading on ClassicPress and other WordPress derivatives.
= 1.2.1 =
Adds ABSPATH security guards to all files and completes German translation.
= 1.2.0 =
Adds Keyword Analysis: real-time keyword checks in the post editor with optional AI-powered suggestions.
= 1.1.0 = = 1.1.0 =
Fixes WordPress.org review issues: corrected Google Gemini terms URL and improved inline input sanitization. Fixes WordPress.org review issues: corrected Google Gemini terms URL and improved inline input sanitization.

View file

@ -3,8 +3,4 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit; exit;
} }
delete_option( 'brezngeo_settings' ); delete_option( 'brezngeo_settings' );
delete_option( 'brezngeo_keyword_settings' );
delete_post_meta_by_key( '_brezngeo_meta_description' ); delete_post_meta_by_key( '_brezngeo_meta_description' );
delete_post_meta_by_key( '_brezngeo_keyword_main' );
delete_post_meta_by_key( '_brezngeo_keyword_secondary' );
delete_post_meta_by_key( '_brezngeo_keyword_results' );