Plugin 1.3 Dynamic Tags, faq_ueberschrift, quadratische Bilder, Bausteine aus dem Text

This commit is contained in:
2026-09-22 12:00:31 +02:00
parent f713b3b9dd
commit 54bd0b02a9
11 changed files with 351 additions and 157 deletions

View File

@@ -165,6 +165,7 @@ Cowork legt je Artikel eine Datei `artikel/artikel-<slug>.json` an:
"cta_ziel": "https://skrift.de/muster-anfordern",
"content_html": "<h1>...</h1><p>...</p>",
"kurzantwort": "Ein Absatz, der die Hauptfrage direkt beantwortet.",
"faq_ueberschrift": "Fragen zu den Kosten",
"faq": [{"frage": "...", "antwort": "..."}],
"seo_titel": "max. 60 Zeichen",
"seo_beschreibung": "max. 155 Zeichen",
@@ -198,6 +199,13 @@ Das Mu-Plugin `skrift-ratgeber-felder.php` (ab Version 1.1) bringt dafuer mit:
im Widget, FAQPage-Schema per Schalter. Beitraege ohne FAQ zeigen nichts an.
- **Elementor-Widget "Skrift Kurzantwort"** fuer die Kurzantwort-Box,
loest Preis-Shortcodes im Text auf.
- **Dynamic Tags, Gruppe "Skrift"** (ab Plugin 1.3), nutzbar in jedem Text- oder
Ueberschrift-Widget ueber das Symbol fuer dynamische Inhalte:
- *Lesezeit*: aus Beitragsinhalt, Kurzantwort und FAQ, 200 Woerter pro Minute
(einstellbar), Ausgabe z. B. "6 Min."
- *FAQ-Ueberschrift*: Feld `faq_ueberschrift`, sonst der Standardtext aus dem
Tag. Hat der Beitrag kein FAQ, bleibt die Ausgabe leer.
- *Stand-Datum*: Feld `stand_datum` im Format `d.m.Y`, einstellbar
- Shortcodes `[skrift_faq titel="..."]` und `[skrift_kurzantwort]` als
Alternative, etwa im Elementor-Shortcode-Widget.
- `cta_ziel` und `stand_datum` gibt Elementor Pro direkt ueber das Dynamic Tag
@@ -247,6 +255,11 @@ Workflow 2 durchsucht `content_html` nach ausgeschriebenen Betraegen
(`2,56 EUR`, `0,95 €`) und meldet jeden Treffer in der Benachrichtigung.
Der Import wird deswegen nicht abgebrochen, der Beitrag ist ja ein Entwurf.
**Keine Bausteine aus dem Seitenlayout im Text.** Der Block "Auch fuer
Privatpersonen." und der Abschnitt "Kostenloses Handschriftmuster anfordern."
stehen im Single-Post-Template und gehoeren nicht in `content_html`. Die
Stilpruefung meldet beide.
**FAQ nicht in content_html schreiben.** Sie gehoeren in das Feld `faq` und
werden im Beitrag ueber `[skrift_faq]` ausgegeben. Dasselbe gilt fuer die
Kurzantwort, die als eigenes Feld `kurzantwort` transportiert wird.
@@ -375,7 +388,8 @@ Hat ein importierter Beitrag noch kein Beitragsbild, erzeugt Workflow 2 eins:
2. Prompt nach `ratgeber/bildstil.md` bauen, die Farbe zusaetzlich mit Namen,
weil das Modell reine Hexwerte etwas zu kraeftig umsetzt
3. `POST https://api.openai.com/v1/images/generations` mit `gpt-image-2`,
Groesse `1536x864` (16:9 direkt, kein Zuschnitt noetig), `output_format: webp`,
Groesse `1200x1200` (quadratisch fuer die Artikelseite, Motiv mittig im
Streifen, damit die Uebersicht auf 16:9 zuschneiden kann), `output_format: webp`,
`quality: medium`. Antwort in `data[0].b64_json`, Dauer rund 25 Sekunden.
4. Als WebP in die Mediathek laden, Alt-Text "Illustration zum Artikel: <Titel>"
5. Als `featured_media` setzen und gegenpruefen

View File

@@ -317,10 +317,14 @@ const brief = {
+ 'Keine Wettbewerbernamen. Zielwert im Import ist 65.',
links: 'Interne Links primaer auf Business-Leistungsseiten: handgeschriebene-businessbriefe, '
+ 'handgeschriebene-postkarten, unterschriftenservice, automatisierte-follow-ups. '
+ 'artikel.cta_ziel mindestens zweimal verlinken, im Text und im Abschluss-CTA, mindestens zwei '
+ 'Business-Links insgesamt. papier-schriftmuster und kontakt nur ergaenzend. Genau ein Link auf '
+ 'handschrift-fuer-privatpersonen, und nur im Block "Auch fuer Privatpersonen.". Keine Links auf '
+ 'review, agb, datenschutz, impressum, zahlungsmethoden, lieferung-versand.',
+ 'artikel.cta_ziel mindestens einmal im Fliesstext verlinken, mindestens zwei Business-Links insgesamt. '
+ 'papier-schriftmuster nur ergaenzend. Keine Links auf Privatkunden-Seiten, review, agb, datenschutz, '
+ 'impressum, zahlungsmethoden, lieferung-versand.',
bausteine: 'Kein Block "Auch fuer Privatpersonen." und kein Abschnitt "Kostenloses Handschriftmuster '
+ 'anfordern." im Artikeltext, beides liefert das Seitenlayout. Der Artikel endet mit dem letzten '
+ 'inhaltlichen Abschnitt, nie mit einer Zusammenfassung.',
faq_ueberschrift: 'Eigene FAQ-Ueberschrift als Feld faq_ueberschrift, konkret zum Thema, z. B. '
+ '"Fragen zu den Kosten". Satzfall, ohne Punkt am Ende.',
stil: 'Kurze und lange Saetze mischen. Absaetze unterschiedlich lang. Keine Formeln wie '
+ '"Genau das", "Der Grund ist", "nicht nur, sondern auch", "entscheidend ist", '
+ '"der groesste Hebel", "macht den Unterschied". Keine Aufzaehlung mit erstens, zweitens. '

View File

@@ -74,7 +74,7 @@ KONF = node("Konfiguration", "n8n-nodes-base.set", 3.4, [-240, 190], {
{"id": nid(), "name": "elementor_ausgabe", "value": False, "type": "boolean"},
{"id": nid(), "name": "bild_aktiv", "value": True, "type": "boolean"},
{"id": nid(), "name": "bild_modell", "value": "gpt-image-2", "type": "string"},
{"id": nid(), "name": "bild_groesse", "value": "1536x864", "type": "string"},
{"id": nid(), "name": "bild_groesse", "value": "1200x1200", "type": "string"},
{"id": nid(), "name": "bild_qualitaet", "value": "medium", "type": "string"},
{"id": nid(), "name": "faq_ueberschrift", "value": "Häufige Fragen", "type": "string"},
{"id": nid(), "name": "benachrichtigung_an", "value": "privat@lucas-orth.de", "type": "string"},
@@ -187,7 +187,7 @@ const faqText = (Array.isArray(artikel.faq) ? artikel.faq : [])
const bewertungs_html = [
artikel.kurzantwort ? `<p>${artikel.kurzantwort}</p>` : '',
artikel.content_html || '',
faqText ? `<h2>Häufige Fragen</h2>\n${faqText}` : '',
faqText ? `<h2>${artikel.faq_ueberschrift || 'Häufige Fragen'}</h2>\n${faqText}` : '',
].filter(Boolean).join('\n');
const praxis = Array.isArray(artikel.praxis) && artikel.praxis.length ? artikel.praxis
@@ -440,6 +440,7 @@ const heute = new Date().toISOString().slice(0, 10);
const meta = {
kurzantwort: a.kurzantwort || '',
faq_ueberschrift: a.faq_ueberschrift || '',
cta_ziel: a.cta_ziel || '',
stand_datum: a.stand_datum || heute,
faq: Array.isArray(a.faq)
@@ -659,7 +660,7 @@ const prompt = [
'Clean closed outlines, no stray marks, no hatching and no small dashes inside the shapes.',
'Object fills are flat off-white cream (#F9F7F5), no shading, no highlights.',
`Motif: ${zeile.bildmotiv}${leer}. Shown straight from the front, not tilted.`,
'Minimal and iconic, lots of empty space, the object takes about 35 percent of the image height.',
'The whole motif sits in the exact center and stays inside the middle band of the image: it takes about 28 percent of the image height and at most 60 percent of the image width, with wide empty background above and below.',
'No text, no letters, no numbers, no currency symbols, no logo, no border, no frame, no rounded corners, no people.',
].join(' ');

View File

@@ -99,17 +99,14 @@ function stilpruefung(artikel) {
const cta = slugVon(artikel.cta_ziel);
if (!BUSINESS.includes(cta)) add('Links: CTA-Ziel ist keine Business-Leistungsseite', `cta_ziel "${artikel.cta_ziel || 'leer'}", erlaubt: ${BUSINESS.join(', ')}`);
const ctaAnzahl = slugs.filter(s => s === cta).length;
if (ctaAnzahl < 2) add('Links: CTA-Ziel zu selten verlinkt', `${ctaAnzahl}x im Text, mindestens 2x (im Text und im Abschluss-CTA)`);
if (ctaAnzahl < 1) add('Links: CTA-Ziel nicht verlinkt', 'cta_ziel mindestens einmal im Fliesstext verlinken');
const bus = slugs.filter(s => BUSINESS.includes(s));
const priv = slugs.filter(s => PRIVAT.includes(s));
if (bus.length < 2) add('Links: zu wenige Business-Links', `${bus.length} Link(s) auf Business-Seiten, mindestens 2`);
if (priv.length > 1) add('Links: zu viele Privatkunden-Links', `${priv.length} Links, hoechstens einer im Block "Auch für Privatpersonen."`);
if (priv.length) {
const block = (html.split(/<h3>Auch für Privatpersonen\.<\/h3>/)[1] || '').split(/<h2/)[0];
const imBlock = PRIVAT.some(s => block.includes(s));
if (!imBlock) add('Links: Privatkunden-Link ausserhalb des Privat-Blocks', 'Privat-Links nur im Block "Auch für Privatpersonen."');
}
if (priv.length >= bus.length && priv.length > 0) add('Links: Privat-Links nicht in der Minderheit', `${bus.length} Business gegen ${priv.length} Privat`);
if (priv.length) add('Links: Privatkunden-Link im Text', 'Privatkunden-Seiten nicht aus dem Artikeltext verlinken, das uebernimmt das Seitenlayout');
// Bausteine, die das Seitenlayout bereits liefert
if (/Auch für Privatpersonen/i.test(html)) add('Baustein doppelt: "Auch für Privatpersonen."', 'Block aus dem Artikeltext entfernen, er steht im Seitenlayout');
if (/<h[23][^>]*>[^<]*(Muster anfordern|Handschriftmuster)/i.test(html)) add('Baustein doppelt: Muster-CTA', 'Abschnitt "Kostenloses Handschriftmuster anfordern." entfernen, er steht im Seitenlayout');
const unbekannt = [...new Set(slugs.filter(s => s && !BUSINESS.includes(s) && !NEUTRAL.includes(s) && !PRIVAT.includes(s)))];
unbekannt.forEach(s => add('Links: kein Linkziel fuer Ratgeber', `/${s}/ (z. B. Dankeseite, Rechtstexte)`));

6
build/stiltest.js Normal file
View File

@@ -0,0 +1,6 @@
// Aufruf: node build/stiltest.js artikel/artikel-SLUG.json
const { stilpruefung } = require('./stilpruefung.js');
const a = require(require('path').resolve(process.argv[2]));
const h = stilpruefung(a);
console.log(`${h.length} Hinweise`);
h.forEach(x => console.log(` [${x.art}] ${x.stelle}`));

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

View File

@@ -162,3 +162,15 @@
padding: 16px 18px;
}
}
/* ---------- Uebersicht: quadratisches Beitragsbild als 16:9-Kachel ----------
Gehoert NICHT ins Einzelbeitrag-Template, sondern ins Loop-Item bzw. die
Archivseite. Im Bild-Widget unter Erweitert die CSS-Klasse
"skrift-kachelbild" setzen. Das Motiv sitzt mittig, der Zuschnitt nimmt
oben und unten nur Hintergrund weg. */
.skrift-kachelbild img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
object-position: center;
}

View File

@@ -1,8 +1,8 @@
<?php
/**
* Plugin Name: Skrift Ratgeber Felder
* Description: Meta-Felder fuer Ratgeberartikel (kurzantwort, faq, cta_ziel, stand_datum) mit Eingabebox im Editor, Shortcodes und Elementor-Widgets, die das FAQ-Feld automatisch als Umschalter ausgeben.
* Version: 1.2.0
* Description: Meta-Felder fuer Ratgeberartikel (kurzantwort, faq, faq_ueberschrift, cta_ziel, stand_datum) mit Eingabebox im Editor, Shortcodes, Elementor-Widgets fuer FAQ und Kurzantwort sowie Dynamic Tags fuer Lesezeit, FAQ-Ueberschrift und Stand.
* Version: 1.3.0
* Author: Lucas Orth
*
* Ablage: wp-content/mu-plugins/skrift-ratgeber-felder.php
@@ -30,6 +30,14 @@ add_action( 'init', function () {
'auth_callback' => 'skrift_ratgeber_meta_auth',
) );
register_post_meta( 'post', 'faq_ueberschrift', array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => 'skrift_ratgeber_meta_auth',
) );
register_post_meta( 'post', 'cta_ziel', array(
'type' => 'string',
'single' => true,
@@ -92,6 +100,7 @@ function skrift_ratgeber_metabox( $post ) {
$kurz = (string) get_post_meta( $post->ID, 'kurzantwort', true );
$cta = (string) get_post_meta( $post->ID, 'cta_ziel', true );
$stand = (string) get_post_meta( $post->ID, 'stand_datum', true );
$faq_u = (string) get_post_meta( $post->ID, 'faq_ueberschrift', true );
$faq = get_post_meta( $post->ID, 'faq', true );
if ( ! is_array( $faq ) ) {
$faq = array();
@@ -125,6 +134,11 @@ function skrift_ratgeber_metabox( $post ) {
</p>
</div>
<p>
<label for="skrift_faq_ueberschrift">FAQ-Ueberschrift</label>
<input type="text" id="skrift_faq_ueberschrift" name="skrift_faq_ueberschrift" class="widefat" value="<?php echo esc_attr( $faq_u ); ?>" placeholder="Leer lassen fuer den Standardtext aus Elementor">
</p>
<label>FAQ</label>
<div id="skrift-faq-liste">
<?php foreach ( $faq as $i => $e ) : ?>
@@ -183,6 +197,7 @@ add_action( 'save_post_post', function ( $post_id ) {
}
update_post_meta( $post_id, 'kurzantwort', wp_kses_post( wp_unslash( $_POST['skrift_kurzantwort'] ?? '' ) ) );
update_post_meta( $post_id, 'faq_ueberschrift', sanitize_text_field( wp_unslash( $_POST['skrift_faq_ueberschrift'] ?? '' ) ) );
update_post_meta( $post_id, 'cta_ziel', esc_url_raw( wp_unslash( $_POST['skrift_cta_ziel'] ?? '' ) ) );
update_post_meta( $post_id, 'stand_datum', skrift_ratgeber_datum( sanitize_text_field( wp_unslash( $_POST['skrift_stand_datum'] ?? '' ) ) ) );
@@ -297,6 +312,40 @@ function skrift_kurzantwort_html( $post_id = 0 ) {
return '<div class="skrift-kurzantwort">' . wp_kses_post( wpautop( do_shortcode( $text ) ) ) . '</div>';
}
function skrift_lesezeit_minuten( $post_id = 0, $wpm = 200 ) {
$post_id = $post_id ? (int) $post_id : get_the_ID();
$post = get_post( $post_id );
if ( ! $post ) {
return 1;
}
$text = do_shortcode( (string) $post->post_content ) . ' ' . (string) get_post_meta( $post_id, 'kurzantwort', true );
foreach ( skrift_faq_eintraege( $post_id ) as $e ) {
$text .= ' ' . $e['frage'] . ' ' . ( $e['antwort'] ?? '' );
}
$text = trim( wp_strip_all_tags( do_shortcode( $text ) ) );
$woerter = '' === $text ? 0 : count( preg_split( '/\s+/u', $text ) );
$wpm = max( 100, (int) $wpm );
return max( 1, (int) ceil( $woerter / $wpm ) );
}
function skrift_faq_ueberschrift( $post_id = 0, $standard = 'Häufige Fragen' ) {
$post_id = $post_id ? (int) $post_id : get_the_ID();
if ( empty( skrift_faq_eintraege( $post_id ) ) ) {
return '';
}
$u = trim( (string) get_post_meta( $post_id, 'faq_ueberschrift', true ) );
return '' !== $u ? $u : (string) $standard;
}
function skrift_stand_formatiert( $post_id = 0, $format = 'd.m.Y' ) {
$post_id = $post_id ? (int) $post_id : get_the_ID();
$s = (string) get_post_meta( $post_id, 'stand_datum', true );
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $s ) ) {
$s = get_the_modified_date( 'Y-m-d', $post_id );
}
return date_i18n( $format ? $format : 'd.m.Y', strtotime( $s . ' 12:00:00' ) );
}
/* Shortcodes, z. B. fuer das Elementor-Shortcode-Widget oder klassische Themes */
add_shortcode( 'skrift_faq', function ( $atts ) {
@@ -560,3 +609,114 @@ add_action( 'elementor/widgets/register', function ( $widgets_manager ) {
$widgets_manager->register( new Skrift_FAQ_Widget() );
$widgets_manager->register( new Skrift_Kurzantwort_Widget() );
} );
/* ------------------------------------------------------------------------
* 5. Elementor Dynamic Tags (Gruppe "Skrift")
* In jedem Text- oder Ueberschrift-Widget ueber das Symbol fuer dynamische
* Inhalte waehlbar. "Davor" und "Danach" stellt Elementor selbst bereit.
* --------------------------------------------------------------------- */
add_action( 'elementor/dynamic_tags/register', function ( $dynamic_tags ) {
if ( ! class_exists( '\Elementor\Core\DynamicTags\Tag' ) || ! method_exists( $dynamic_tags, 'register' ) ) {
return;
}
$dynamic_tags->register_group( 'skrift', array( 'title' => 'Skrift' ) );
if ( ! class_exists( 'Skrift_Tag_Lesezeit' ) ) {
class Skrift_Tag_Lesezeit extends \Elementor\Core\DynamicTags\Tag {
public function get_name() {
return 'skrift-lesezeit';
}
public function get_title() {
return 'Lesezeit';
}
public function get_group() {
return 'skrift';
}
public function get_categories() {
return array( \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY );
}
protected function register_controls() {
$this->add_control( 'wpm', array(
'label' => 'Woerter pro Minute',
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 200,
'min' => 100,
'max' => 400,
) );
$this->add_control( 'einheit', array(
'label' => 'Einheit',
'type' => \Elementor\Controls_Manager::TEXT,
'default' => ' Min.',
) );
}
public function render() {
$wpm = (int) $this->get_settings( 'wpm' );
$einheit = (string) $this->get_settings( 'einheit' );
echo esc_html( skrift_lesezeit_minuten( get_the_ID(), $wpm ? $wpm : 200 ) . $einheit );
}
}
}
if ( ! class_exists( 'Skrift_Tag_FAQ_Ueberschrift' ) ) {
class Skrift_Tag_FAQ_Ueberschrift extends \Elementor\Core\DynamicTags\Tag {
public function get_name() {
return 'skrift-faq-ueberschrift';
}
public function get_title() {
return 'FAQ-Ueberschrift';
}
public function get_group() {
return 'skrift';
}
public function get_categories() {
return array( \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY );
}
protected function register_controls() {
$this->add_control( 'standard', array(
'label' => 'Standardtext',
'type' => \Elementor\Controls_Manager::TEXT,
'default' => 'Häufige Fragen',
'description' => 'Gilt, wenn im Beitrag keine eigene FAQ-Ueberschrift gepflegt ist. Hat der Beitrag kein FAQ, bleibt die Ausgabe leer.',
) );
}
public function render() {
echo esc_html( skrift_faq_ueberschrift( get_the_ID(), (string) $this->get_settings( 'standard' ) ) );
}
}
}
if ( ! class_exists( 'Skrift_Tag_Stand' ) ) {
class Skrift_Tag_Stand extends \Elementor\Core\DynamicTags\Tag {
public function get_name() {
return 'skrift-stand';
}
public function get_title() {
return 'Stand-Datum';
}
public function get_group() {
return 'skrift';
}
public function get_categories() {
return array( \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY );
}
protected function register_controls() {
$this->add_control( 'format', array(
'label' => 'Datumsformat',
'type' => \Elementor\Controls_Manager::TEXT,
'default' => 'd.m.Y',
'description' => 'PHP-Datumsformat, z. B. d.m.Y oder j. F Y',
) );
}
public function render() {
echo esc_html( skrift_stand_formatiert( get_the_ID(), (string) $this->get_settings( 'format' ) ) );
}
}
}
$dynamic_tags->register( new Skrift_Tag_Lesezeit() );
$dynamic_tags->register( new Skrift_Tag_FAQ_Ueberschrift() );
$dynamic_tags->register( new Skrift_Tag_Stand() );
} );

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long