@relvehq/agent-ready 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,11 @@
7
7
  * headings, paragraphs, br, strong/em/del, links, images (alt + src), nested
8
8
  * ul/ol, blockquote, pre/code (language from the class), inline code, GFM
9
9
  * tables, hr. Scripts, styles, forms, iframes, nav, svg and hidden elements
10
- * are dropped, as are shortcodes a page builder left behind. Whitespace is
11
- * collapsed and URLs are made absolute against the page URL.
10
+ * are dropped, as are shortcodes a page builder left behind and loading
11
+ * placeholders. Accordions are opened first (the question kept as text, the
12
+ * panel shown), and a <summary> keeps its heading or turns bold. Whitespace
13
+ * is collapsed and URLs are made absolute against the page URL. The
14
+ * accordion and placeholder rules are the package's (core/convert.ts).
12
15
  *
13
16
  * Pure PHP (ext-dom): no WordPress calls.
14
17
  *
@@ -30,6 +33,27 @@ class Relve_AR_Converter {
30
33
  /** Elements that start a new block. Everything else is inline. */
31
34
  const BLOCKS = array( 'address', 'article', 'aside', 'blockquote', 'center', 'dd', 'details', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', 'main', 'ol', 'p', 'pre', 'section', 'summary', 'table', 'tbody', 'thead', 'tfoot', 'tr', 'td', 'th', 'ul', 'body', 'html', 'caption' );
32
35
 
36
+ /** Class words that mark an accordion panel or FAQ answer. */
37
+ const DISCLOSURE_CLASS_RE = '/(^|[\s_-])(accordion|faqs?)([\s_-]|$)/i';
38
+
39
+ /** Trigger roles that are not accordions. */
40
+ const NOT_A_DISCLOSURE_ROLE = '/^(tab|menuitem|combobox|option)$/i';
41
+
42
+ /** Panel roles that are not accordion panels. */
43
+ const NOT_A_PANEL_ROLE = '/^(menu|menubar|listbox|dialog|alertdialog|navigation|tree|grid)$/i';
44
+
45
+ /** Expander labels: controls, not content. Their panel opens, the label goes. */
46
+ const CONTROL_LABEL_RE = '/^(?:(?:show|read|view|see|load)\s+(?:more|less|all)|expand|collapse|toggle|open|close)\b/i';
47
+
48
+ /** Class or id words of loading placeholders. */
49
+ const PLACEHOLDER_RE = '/(^|[\s_-])(skeleton|shimmer|spinner|loader|loading)([\s_-]|$)|(^|\s)animate-(pulse|spin)(\s|$)/i';
50
+
51
+ /** A block whose whole text is a loading message. */
52
+ const LOADING_TEXT_RE = '/^(?:loading|fetching|please wait)\b[^.!?]{0,40}(?:…|\.{3})$|^(?:loading|fetching|please wait)\.?$/iu';
53
+
54
+ /** Placeholders hold less text than this. */
55
+ const PLACEHOLDER_MAX_TEXT = 200;
56
+
33
57
  /** Absolute URL of the page: base for relative links and images. @var string */
34
58
  private $base;
35
59
 
@@ -81,6 +105,8 @@ class Relve_AR_Converter {
81
105
  if ( ! $body ) {
82
106
  return '';
83
107
  }
108
+ $this->open_disclosures( $doc );
109
+ $this->drop_placeholders( $doc );
84
110
  $this->clean( $body );
85
111
  $blocks = $this->blocks( $body );
86
112
  return trim( implode( "\n\n", $blocks ) );
@@ -121,6 +147,158 @@ class Relve_AR_Converter {
121
147
  }
122
148
  }
123
149
 
150
+ /**
151
+ * Accordions and FAQ toggles: the question is a <button> wired to its
152
+ * panel by aria-controls, and a closed panel is hidden. Show the panel and
153
+ * keep the question as text, before buttons and hidden elements are
154
+ * dropped. Tabs, popups and menus are left alone, and so is a panel that
155
+ * is a menu, listbox or dialog or holds navigation, and a trigger whose
156
+ * panel isn't there. Hidden elements whose class names an accordion or
157
+ * FAQ are shown as well.
158
+ *
159
+ * @param DOMDocument $doc Document.
160
+ * @return void
161
+ */
162
+ private function open_disclosures( DOMDocument $doc ) {
163
+ $xpath = new DOMXPath( $doc );
164
+ foreach ( iterator_to_array( $xpath->query( '//*[@aria-controls and @aria-expanded]' ) ) as $trigger ) {
165
+ if ( $trigger->hasAttribute( 'aria-haspopup' ) || preg_match( self::NOT_A_DISCLOSURE_ROLE, $trigger->getAttribute( 'role' ) ) ) {
166
+ continue;
167
+ }
168
+ $panels = array();
169
+ foreach ( preg_split( '/\s+/', trim( $trigger->getAttribute( 'aria-controls' ) ) ) as $id ) {
170
+ $found = '' === $id ? null : $xpath->query( '//*[@id=' . self::xpath_literal( $id ) . ']' );
171
+ if ( $found && $found->length ) {
172
+ $panels[] = $found->item( 0 );
173
+ }
174
+ }
175
+ if ( ! $panels ) {
176
+ continue;
177
+ }
178
+ foreach ( $panels as $panel ) {
179
+ if ( preg_match( self::NOT_A_PANEL_ROLE, $panel->getAttribute( 'role' ) ) || 'nav' === strtolower( $panel->nodeName )
180
+ || $xpath->query( './/nav | .//*[@role="navigation"] | .//*[@role="menu"]', $panel )->length ) {
181
+ continue 2;
182
+ }
183
+ }
184
+ foreach ( $panels as $panel ) {
185
+ $this->show( $panel );
186
+ }
187
+ $this->keep_trigger_text( $doc, $trigger );
188
+ }
189
+ foreach ( iterator_to_array( $xpath->query( '//*[@class]' ) ) as $el ) {
190
+ if ( preg_match( self::DISCLOSURE_CLASS_RE, $el->getAttribute( 'class' ) ) ) {
191
+ $this->show( $el );
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Undo hidden, aria-hidden and an inline display:none / visibility:hidden.
198
+ *
199
+ * @param DOMElement $el Element.
200
+ * @return void
201
+ */
202
+ private function show( DOMElement $el ) {
203
+ $el->removeAttribute( 'hidden' );
204
+ if ( 'true' === strtolower( $el->getAttribute( 'aria-hidden' ) ) ) {
205
+ $el->removeAttribute( 'aria-hidden' );
206
+ }
207
+ if ( $el->hasAttribute( 'style' ) ) {
208
+ $el->setAttribute( 'style', (string) preg_replace( '/(?:display\s*:\s*none|visibility\s*:\s*hidden)\s*;?/i', '', $el->getAttribute( 'style' ) ) );
209
+ }
210
+ }
211
+
212
+ /**
213
+ * A question <button> becomes its text: plain inside a heading, summary
214
+ * or dt, else a bold paragraph. Expander labels are left to be dropped.
215
+ *
216
+ * @param DOMDocument $doc Document.
217
+ * @param DOMElement $trigger Trigger.
218
+ * @return void
219
+ */
220
+ private function keep_trigger_text( DOMDocument $doc, DOMElement $trigger ) {
221
+ if ( 'button' !== strtolower( $trigger->nodeName ) || ! $trigger->parentNode ) {
222
+ return;
223
+ }
224
+ $xpath = new DOMXPath( $doc );
225
+ foreach ( iterator_to_array( $xpath->query( './/svg | .//*[@aria-hidden="true"]', $trigger ) ) as $icon ) {
226
+ $icon->parentNode->removeChild( $icon );
227
+ }
228
+ $text = $this->one_line( $trigger->textContent );
229
+ if ( '' === $text || preg_match( self::CONTROL_LABEL_RE, $text ) ) {
230
+ return;
231
+ }
232
+ for ( $up = $trigger->parentNode; $up && XML_ELEMENT_NODE === $up->nodeType; $up = $up->parentNode ) {
233
+ if ( preg_match( '/^(h[1-6]|summary|dt)$/', strtolower( $up->nodeName ) ) ) {
234
+ $trigger->parentNode->replaceChild( $doc->createTextNode( $text ), $trigger );
235
+ return;
236
+ }
237
+ }
238
+ $p = $doc->createElement( 'p' );
239
+ $strong = $doc->createElement( 'strong' );
240
+ $strong->appendChild( $doc->createTextNode( $text ) );
241
+ $p->appendChild( $strong );
242
+ $trigger->parentNode->replaceChild( $p, $trigger );
243
+ }
244
+
245
+ /**
246
+ * Loading placeholders: busy regions, progress bars, skeleton / shimmer /
247
+ * spinner / loader elements (small, no media), and blocks whose whole text
248
+ * is a loading message ("Loading reviews…"). Text that only starts with
249
+ * "Loading" stays: the message needs an ellipsis, or to be the bare word.
250
+ *
251
+ * @param DOMDocument $doc Document.
252
+ * @return void
253
+ */
254
+ private function drop_placeholders( DOMDocument $doc ) {
255
+ $xpath = new DOMXPath( $doc );
256
+ $small = function ( DOMElement $el ) use ( $xpath ) {
257
+ $text = $this->one_line( $el->textContent );
258
+ $len = function_exists( 'mb_strlen' ) ? mb_strlen( $text, 'UTF-8' ) : strlen( $text );
259
+ return $len < self::PLACEHOLDER_MAX_TEXT && 0 === $xpath->query( './/img | .//picture | .//video | .//table', $el )->length;
260
+ };
261
+ $skip = function ( DOMElement $el ) {
262
+ return in_array( strtolower( $el->nodeName ), array( 'html', 'body', 'main', 'img', 'picture', 'video', 'source', 'td', 'th' ), true );
263
+ };
264
+ $remove = function ( DOMNode $el ) {
265
+ if ( $el->parentNode ) {
266
+ $el->parentNode->removeChild( $el );
267
+ }
268
+ };
269
+ foreach ( iterator_to_array( $xpath->query( '//*[@aria-busy="true"] | //*[@role="progressbar"]' ) ) as $el ) {
270
+ if ( ! $skip( $el ) && $small( $el ) ) {
271
+ $remove( $el );
272
+ }
273
+ }
274
+ foreach ( iterator_to_array( $xpath->query( '//*[@id or @class]' ) ) as $el ) {
275
+ if ( ! $skip( $el ) && preg_match( self::PLACEHOLDER_RE, $el->getAttribute( 'id' ) . ' ' . $el->getAttribute( 'class' ) ) && $small( $el ) ) {
276
+ $remove( $el );
277
+ }
278
+ }
279
+ foreach ( iterator_to_array( $xpath->query( '//p | //div | //span | //li | //section | //small | //em | //strong' ) ) as $el ) {
280
+ if ( $el->parentNode && preg_match( self::LOADING_TEXT_RE, $this->one_line( $el->textContent ) ) ) {
281
+ $remove( $el );
282
+ }
283
+ }
284
+ }
285
+
286
+ /**
287
+ * A string as an XPath literal (XPath 1.0 has no escapes).
288
+ *
289
+ * @param string $s Value.
290
+ * @return string
291
+ */
292
+ private static function xpath_literal( $s ) {
293
+ if ( false === strpos( $s, "'" ) ) {
294
+ return "'" . $s . "'";
295
+ }
296
+ if ( false === strpos( $s, '"' ) ) {
297
+ return '"' . $s . '"';
298
+ }
299
+ return "concat('" . str_replace( "'", "', \"'\", '", $s ) . "')";
300
+ }
301
+
124
302
  /**
125
303
  * Hidden by attribute or inline style.
126
304
  *
@@ -248,9 +426,19 @@ class Relve_AR_Converter {
248
426
  }
249
427
  $text = $this->one_line( $this->inline_children( $el ) );
250
428
  return '' === $text ? array() : array( str_repeat( '#', $level ) . ' ' . $text );
429
+ case 'summary':
430
+ // A heading inside keeps its level; a text-only summary is a
431
+ // question or a label: bold.
432
+ $inner = $this->blocks( $el );
433
+ foreach ( $inner as $b ) {
434
+ if ( preg_match( '/^#{1,6} /', $b ) ) {
435
+ return $inner;
436
+ }
437
+ }
438
+ $text = $this->one_line( implode( ' ', $inner ) );
439
+ return '' === $text ? array() : array( '**' . $text . '**' );
251
440
  case 'p':
252
441
  case 'dt':
253
- case 'summary':
254
442
  case 'caption':
255
443
  $p = $this->paragraph( $this->inline_children( $el ) );
256
444
  if ( 'dt' === $tag && '' !== $p ) {
@@ -26,7 +26,8 @@
26
26
  * The front-matter rules are the package's (core/enrich.ts): empty values
27
27
  * dropped, URLs / ISO dates / language tags plain, everything else
28
28
  * JSON-quoted. A document that already has front matter (a refined file
29
- * usually does not, but a hand-made one may) is left exactly as it is.
29
+ * usually does, a hand-made one may) keeps its block; only a title or
30
+ * description it lacks is added.
30
31
  *
31
32
  * @package Relve_Agent_Ready
32
33
  */
@@ -107,7 +108,8 @@ class Relve_AR_Markdown_Builder {
107
108
 
108
109
  /**
109
110
  * Final document: front matter, then the body. A body that already has
110
- * front matter is returned as it is (plus a final newline).
111
+ * front matter keeps it, with a missing title or description added from
112
+ * $fields (plus a final newline).
111
113
  *
112
114
  * @param array $fields Front-matter fields.
113
115
  * @param string $body Markdown body (starting with its H1).
@@ -116,11 +118,59 @@ class Relve_AR_Markdown_Builder {
116
118
  public static function assemble( $fields, $body ) {
117
119
  $body = trim( (string) $body );
118
120
  if ( self::has_front_matter( $body . "\n" ) ) {
119
- return $body . "\n";
121
+ return self::merge_front_matter( $body, $fields ) . "\n";
120
122
  }
121
123
  return self::front_matter( $fields ) . "\n\n" . $body . "\n";
122
124
  }
123
125
 
126
+ /**
127
+ * Add a title or description the document's front matter lacks (title
128
+ * first, description after the title). Keys it has, and every other
129
+ * line, are kept as they are. No front matter: returned unchanged.
130
+ *
131
+ * @param string $markdown Markdown starting with front matter.
132
+ * @param array $fields Field values.
133
+ * @return string
134
+ */
135
+ public static function merge_front_matter( $markdown, $fields ) {
136
+ if ( ! preg_match( '/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/', (string) $markdown, $m ) ) {
137
+ return (string) $markdown;
138
+ }
139
+ $lines = preg_split( '/\r?\n/', $m[1] );
140
+ $keys = array();
141
+ foreach ( $lines as $line ) {
142
+ if ( preg_match( '/^([A-Za-z_][\w-]*):/', $line, $k ) ) {
143
+ $keys[ $k[1] ] = true;
144
+ }
145
+ }
146
+ $changed = false;
147
+ foreach ( array( 'title', 'description' ) as $key ) {
148
+ $value = isset( $fields[ $key ] ) && is_scalar( $fields[ $key ] ) ? self::one_line( (string) $fields[ $key ] ) : '';
149
+ if ( isset( $keys[ $key ] ) || '' === $value ) {
150
+ continue;
151
+ }
152
+ $at = 0;
153
+ if ( 'description' === $key ) {
154
+ foreach ( $lines as $i => $line ) {
155
+ if ( 0 === strpos( $line, 'title:' ) ) {
156
+ $at = $i + 1;
157
+ while ( $at < count( $lines ) && preg_match( '/^[ \t]/', $lines[ $at ] ) ) {
158
+ ++$at;
159
+ }
160
+ break;
161
+ }
162
+ }
163
+ }
164
+ array_splice( $lines, $at, 0, array( $key . ': ' . self::yaml_scalar( $value ) ) );
165
+ $keys[ $key ] = true;
166
+ $changed = true;
167
+ }
168
+ if ( ! $changed ) {
169
+ return (string) $markdown;
170
+ }
171
+ return "---\n" . implode( "\n", $lines ) . "\n---" . ( '' !== $m[2] ? "\n" : '' ) . substr( (string) $markdown, strlen( $m[0] ) );
172
+ }
173
+
124
174
  /**
125
175
  * Body of a single post or page: one H1, optional image, content.
126
176
  *
@@ -341,7 +391,10 @@ class Relve_AR_Markdown_Builder {
341
391
  }
342
392
 
343
393
  /**
344
- * Front matter for a stored (refined) file that has none of its own.
394
+ * Front matter for a stored (refined) file: a whole block when it has
395
+ * none, else its own with a missing title or description added. Files
396
+ * written before descriptions were added get one this way, without a
397
+ * rewrite.
345
398
  *
346
399
  * @param string $markdown Stored Markdown.
347
400
  * @param string $canonical Canonical URL.
@@ -355,28 +408,26 @@ class Relve_AR_Markdown_Builder {
355
408
  }
356
409
  return self::assemble(
357
410
  array(
358
- 'title' => $title,
359
- 'canonical' => $canonical,
360
- 'source' => $canonical,
361
- 'generator' => 'agent-ready/stored',
362
- 'lang' => get_bloginfo( 'language' ),
411
+ 'title' => $title,
412
+ 'description' => $post ? self::description_for( $post ) : '',
413
+ 'canonical' => $canonical,
414
+ 'source' => $canonical,
415
+ 'generator' => 'agent-ready/stored',
416
+ 'lang' => get_bloginfo( 'language' ),
363
417
  ),
364
418
  $markdown
365
419
  );
366
420
  }
367
421
 
368
422
  /**
369
- * Description: Yoast meta description, else the excerpt, else the first
370
- * words of the content.
423
+ * Description: the SEO plugin's meta description, else the excerpt, else
424
+ * the first words of the content.
371
425
  *
372
426
  * @param WP_Post $post Post.
373
427
  * @return string
374
428
  */
375
429
  public static function description_for( $post ) {
376
- $desc = (string) get_post_meta( $post->ID, '_yoast_wpseo_metadesc', true );
377
- if ( '' !== $desc && false !== strpos( $desc, '%%' ) ) {
378
- $desc = function_exists( 'wpseo_replace_vars' ) ? (string) wpseo_replace_vars( $desc, $post ) : '';
379
- }
430
+ $desc = self::seo_description( $post );
380
431
  if ( '' === trim( $desc ) && '' !== trim( (string) $post->post_excerpt ) ) {
381
432
  $desc = $post->post_excerpt;
382
433
  }
@@ -386,6 +437,60 @@ class Relve_AR_Markdown_Builder {
386
437
  return self::shorten( html_entity_decode( wp_strip_all_tags( $desc ), ENT_QUOTES, 'UTF-8' ), 300 );
387
438
  }
388
439
 
440
+ /**
441
+ * The meta description an SEO plugin stores for the post: Yoast, then
442
+ * Rank Math, SEOPress and All in One SEO. Template variables are resolved
443
+ * with the plugin's own function when it is loaded; a description still
444
+ * holding them is skipped. "" when none has one.
445
+ *
446
+ * @param WP_Post $post Post.
447
+ * @return string
448
+ */
449
+ public static function seo_description( $post ) {
450
+ // Yoast: %%vars%%.
451
+ $desc = (string) get_post_meta( $post->ID, '_yoast_wpseo_metadesc', true );
452
+ if ( '' !== $desc && false !== strpos( $desc, '%%' ) ) {
453
+ $desc = function_exists( 'wpseo_replace_vars' ) ? (string) wpseo_replace_vars( $desc, $post ) : '';
454
+ }
455
+ if ( '' !== trim( $desc ) ) {
456
+ return $desc;
457
+ }
458
+
459
+ // Rank Math: %vars%.
460
+ $desc = (string) get_post_meta( $post->ID, 'rank_math_description', true );
461
+ if ( '' !== $desc && preg_match( '/%[a-z_]+%/i', $desc ) ) {
462
+ $replace = array( 'RankMath\Helper', 'replace_vars' );
463
+ $desc = is_callable( $replace ) ? (string) call_user_func( $replace, $desc, $post ) : '';
464
+ }
465
+ if ( '' !== trim( $desc ) && ! preg_match( '/%[a-z_]+%/i', $desc ) ) {
466
+ return $desc;
467
+ }
468
+
469
+ // SEOPress: %%vars%%, resolved only at render time; skipped when present.
470
+ $desc = (string) get_post_meta( $post->ID, '_seopress_titles_desc', true );
471
+ if ( '' !== trim( $desc ) && false === strpos( $desc, '%%' ) ) {
472
+ return $desc;
473
+ }
474
+
475
+ // All in One SEO: #tags, resolved by its own API.
476
+ $desc = '';
477
+ if ( function_exists( 'aioseo' ) ) {
478
+ try {
479
+ $aioseo = aioseo();
480
+ if ( isset( $aioseo->meta->description ) && is_callable( array( $aioseo->meta->description, 'getDescription' ) ) ) {
481
+ $desc = (string) $aioseo->meta->description->getDescription( $post );
482
+ }
483
+ } catch ( Throwable $e ) {
484
+ $desc = '';
485
+ }
486
+ }
487
+ if ( '' !== trim( $desc ) ) {
488
+ return $desc;
489
+ }
490
+ $desc = (string) get_post_meta( $post->ID, '_aioseo_description', true );
491
+ return preg_match( '/#[a-z_]+/i', $desc ) ? '' : $desc;
492
+ }
493
+
389
494
  /**
390
495
  * Markdown for the posts front page / posts page or a category, tag or
391
496
  * author archive: an H1 and its first 50 posts.
@@ -22,7 +22,7 @@ An agent can ask in three ways:
22
22
 
23
23
  What it serves:
24
24
 
25
- * Posts and pages (published, public, not password-protected, of the post types you enable): YAML front matter (title, canonical, author, published, modified, categories, tags, description, source), one `# Title` heading, the featured image and the content converted to Markdown (headings, lists, links, images, tables, code blocks).
25
+ * Posts and pages (published, public, not password-protected, of the post types you enable): YAML front matter (title, canonical, author, published, modified, categories, tags, description, source), one `# Title` heading, the featured image and the content converted to Markdown (headings, lists, links, images, tables, code blocks). Collapsed accordion and FAQ answers are included, with their questions; loading placeholders such as "Loading…" are left out.
26
26
  * The posts front page and category, tag and author archives: a heading and a list of their latest 50 posts.
27
27
  * `llms.txt` under your home path: your site name, tagline and links to the `.md` version of up to 200 recent posts and pages.
28
28
  * Rewritten files: with your own OpenAI key, each post is rewritten into clean Markdown with Relve's prompt (see below) and served first.
@@ -37,7 +37,7 @@ The plugin is installed switched off. Nothing changes until you tick "Enabled" i
37
37
  The Markdown built from WordPress is faithful but plain. For agent-ready pages that read well (a clear summary, clean headings, tables kept, theme clutter gone), save your OpenAI API key in Settings -> Agent Ready:
38
38
 
39
39
  * When a post of an enabled type is published or updated, WP-Cron sends it about 30 seconds later (never while you wait on the editor) to Relve's refine service at `https://relvehq.com/api/agent-ready/v1/refine`: the permalink, and a small HTML page with the title, description, canonical link, structured data and the rendered content.
40
- * Relve rewrites it with its own prompt, using your key for that single OpenAI call, checks the result and sends back the Markdown. The plugin stores it as that post's file in `wp-content/uploads/relve-agent-ready/md/`; it is served first (`x-agent-ready-source: stored`).
40
+ * Relve rewrites it with its own prompt, using your key for that single OpenAI call, checks the result and sends back the Markdown. The post's own description and its FAQ answers (from its structured data) are kept word for word. The plugin stores it as that post's file in `wp-content/uploads/relve-agent-ready/md/`; it is served first (`x-agent-ready-source: stored`), with the post's description in its front matter.
41
41
  * A post that has not changed since its last rewrite is answered "unchanged" without a model call, so it costs nothing.
42
42
  * "Rewrite all now" queues every published post of the enabled types; WP-Cron works through them 5 at a time. The settings page shows progress, posts rewritten, the last run and the last error code.
43
43
  * If OpenAI rejects the key or the account is out of quota, rewriting pauses and an admin notice says so. Saving a new key resumes it.
@@ -69,9 +69,9 @@ The `Accept` header, paths ending in `.md`, the `format` query parameter and `/l
69
69
 
70
70
  The plugin sets `DONOTCACHEPAGE` on every Markdown response and tells WP Rocket never to cache `.md` URLs or llms.txt. A page cache that already holds the HTML of a page may still answer an `Accept: text/markdown` request with that HTML. That is safe (the agent gets the normal page); the `.md` URL always returns Markdown.
71
71
 
72
- = I use Yoast SEO. =
72
+ = I use Yoast SEO, Rank Math, SEOPress or All in One SEO. =
73
73
 
74
- The description in the front matter uses your Yoast meta description when there is one. If Yoast's own llms.txt feature is on, the "auto" mode leaves llms.txt to Yoast; choose "On" to serve this plugin's version instead.
74
+ The description in the front matter uses your SEO plugin's meta description when there is one (Yoast first, then Rank Math, SEOPress and All in One SEO), else the excerpt, else the first words of the post. If Yoast's own llms.txt feature is on, the "auto" mode leaves llms.txt to Yoast; choose "On" to serve this plugin's version instead.
75
75
 
76
76
  = Does Relve see my OpenAI key? =
77
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relvehq/agent-ready",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "license": "MIT",
5
5
  "description": "Make your site agent-ready: serve every page as clean Markdown to AI agents (Accept: text/markdown, .md URLs, llms.txt), refine it with AI ahead of time, and prove it with a free checker and a visits dashboard. Next.js adapter included.",
6
6
  "keywords": [