@relvehq/agent-ready 0.4.1 → 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.
- package/README.md +12 -4
- package/SETUP.md +1 -1
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +4 -1
- package/dist/core/convert.d.ts +17 -0
- package/dist/core/convert.js +181 -8
- package/dist/core/enrich.d.ts +11 -2
- package/dist/core/enrich.js +53 -3
- package/dist/core/faq.d.ts +39 -0
- package/dist/core/faq.js +232 -0
- package/dist/core/resolve.js +5 -3
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/dist/refine/check.d.ts +1 -1
- package/dist/refine/check.js +39 -5
- package/dist/refine/extract.js +20 -2
- package/dist/refine/finalize.d.ts +2 -0
- package/dist/refine/finalize.js +32 -0
- package/dist/refine/refine.js +4 -2
- package/dist/refine/remote.d.ts +1 -1
- package/dist/refine/types.d.ts +2 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-converter.php +191 -3
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-markdown-builder.php +120 -15
- package/dist/wp/relve-agent-ready/readme.txt +4 -4
- package/package.json +2 -2
|
@@ -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
|
|
11
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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'
|
|
359
|
-
'
|
|
360
|
-
'
|
|
361
|
-
'
|
|
362
|
-
'
|
|
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:
|
|
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 = (
|
|
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
|
|
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.
|
|
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": [
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"perplexity",
|
|
21
21
|
"seo"
|
|
22
22
|
],
|
|
23
|
-
"homepage": "https://
|
|
23
|
+
"homepage": "https://engine.relve.ai/search/agent-ready",
|
|
24
24
|
"author": "Relve (https://relvehq.com)",
|
|
25
25
|
"main": "./dist/index.js",
|
|
26
26
|
"types": "./dist/index.d.ts",
|