@relvehq/agent-ready 0.3.0 → 0.4.0

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.
Files changed (29) hide show
  1. package/README.md +24 -3
  2. package/SETUP.md +354 -354
  3. package/dist/core/report.d.ts +9 -3
  4. package/dist/core/report.js +30 -10
  5. package/dist/core/version.d.ts +1 -0
  6. package/dist/core/version.js +8 -0
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.js +5 -1
  9. package/dist/refine/check.d.ts +3 -1
  10. package/dist/refine/check.js +43 -0
  11. package/dist/refine/remote.d.ts +3 -2
  12. package/dist/refine/remote.js +3 -2
  13. package/dist/wp/relve-agent-ready/includes/class-relve-ar-admin.php +560 -550
  14. package/dist/wp/relve-agent-ready/includes/class-relve-ar-converter.php +762 -762
  15. package/dist/wp/relve-agent-ready/includes/class-relve-ar-llms.php +192 -192
  16. package/dist/wp/relve-agent-ready/includes/class-relve-ar-markdown-builder.php +455 -455
  17. package/dist/wp/relve-agent-ready/includes/class-relve-ar-negotiation.php +334 -334
  18. package/dist/wp/relve-agent-ready/includes/class-relve-ar-refiner.php +894 -894
  19. package/dist/wp/relve-agent-ready/includes/class-relve-ar-reporter.php +147 -0
  20. package/dist/wp/relve-agent-ready/includes/class-relve-ar-response.php +226 -226
  21. package/dist/wp/relve-agent-ready/includes/class-relve-ar-router.php +422 -421
  22. package/dist/wp/relve-agent-ready/includes/class-relve-ar-settings.php +210 -201
  23. package/dist/wp/relve-agent-ready/includes/class-relve-ar-store.php +583 -583
  24. package/dist/wp/relve-agent-ready/includes/index.php +2 -2
  25. package/dist/wp/relve-agent-ready/index.php +2 -2
  26. package/dist/wp/relve-agent-ready/readme.txt +119 -108
  27. package/dist/wp/relve-agent-ready/relve-agent-ready.php +82 -81
  28. package/dist/wp/relve-agent-ready/uninstall.php +35 -35
  29. package/package.json +1 -1
@@ -1,455 +1,455 @@
1
- <?php
2
- /**
3
- * Markdown builder: a post, page, the posts front page or an archive as one
4
- * Markdown document, from WordPress's own data (not scraped HTML).
5
- *
6
- * ---
7
- * title: "..."
8
- * canonical: https://site/post/
9
- * author: "..."
10
- * published: 2026-10-01T09:00:00Z
11
- * modified: 2026-10-02T10:00:00Z
12
- * categories: ["News"]
13
- * tags: ["AI"]
14
- * description: "..."
15
- * source: https://site/post/
16
- * generator: agent-ready/native
17
- * lang: en-US
18
- * ---
19
- *
20
- * # Title
21
- *
22
- * ![featured image](...)
23
- *
24
- * Content...
25
- *
26
- * The front-matter rules are the package's (core/enrich.ts): empty values
27
- * dropped, URLs / ISO dates / language tags plain, everything else
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.
30
- *
31
- * @package Relve_Agent_Ready
32
- */
33
-
34
- if ( ! defined( 'ABSPATH' ) ) {
35
- exit;
36
- }
37
-
38
- /**
39
- * Builds Markdown documents.
40
- */
41
- class Relve_AR_Markdown_Builder {
42
-
43
- /** Posts listed on the front page / an archive. */
44
- const ARCHIVE_LIMIT = 50;
45
-
46
- // ── pure helpers (unit-tested) ────────────────────────────────────────
47
-
48
- /**
49
- * A YAML front-matter block. Empty values (null, "", empty list) are dropped.
50
- *
51
- * @param array $fields Ordered key => string|int|string[].
52
- * @return string
53
- */
54
- public static function front_matter( $fields ) {
55
- $lines = array( '---' );
56
- foreach ( $fields as $key => $value ) {
57
- if ( null === $value || '' === $value || array() === $value ) {
58
- continue;
59
- }
60
- if ( is_array( $value ) ) {
61
- $items = array();
62
- foreach ( $value as $item ) {
63
- $item = self::one_line( (string) $item );
64
- if ( '' !== $item ) {
65
- $items[] = self::json( $item );
66
- }
67
- }
68
- if ( ! $items ) {
69
- continue;
70
- }
71
- $lines[] = $key . ': [' . implode( ', ', $items ) . ']';
72
- continue;
73
- }
74
- $lines[] = $key . ': ' . self::yaml_scalar( $value );
75
- }
76
- $lines[] = '---';
77
- return implode( "\n", $lines );
78
- }
79
-
80
- /**
81
- * Plain scalars for URLs, ISO dates, language tags and the generator;
82
- * JSON-quoted (valid YAML) for everything else. Same rule as the package.
83
- *
84
- * @param string|int $value Value.
85
- * @return string
86
- */
87
- public static function yaml_scalar( $value ) {
88
- if ( is_int( $value ) || is_float( $value ) ) {
89
- return (string) $value;
90
- }
91
- $one = self::one_line( (string) $value );
92
- if ( preg_match( '#^(https?://[^\s\#]+|\d{4}-\d{2}-\d{2}T[\d:.]+Z|[a-z]{2,3}(-[A-Za-z]{2,4})?|agent-ready/[a-z]+)$#', $one ) ) {
93
- return $one;
94
- }
95
- return self::json( $one );
96
- }
97
-
98
- /**
99
- * Does the Markdown start with a front-matter block?
100
- *
101
- * @param string $markdown Markdown.
102
- * @return bool
103
- */
104
- public static function has_front_matter( $markdown ) {
105
- return (bool) preg_match( '/^---\r?\n[\s\S]*?\r?\n---\r?\n/', (string) $markdown );
106
- }
107
-
108
- /**
109
- * 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
- *
112
- * @param array $fields Front-matter fields.
113
- * @param string $body Markdown body (starting with its H1).
114
- * @return string
115
- */
116
- public static function assemble( $fields, $body ) {
117
- $body = trim( (string) $body );
118
- if ( self::has_front_matter( $body . "\n" ) ) {
119
- return $body . "\n";
120
- }
121
- return self::front_matter( $fields ) . "\n\n" . $body . "\n";
122
- }
123
-
124
- /**
125
- * Body of a single post or page: one H1, optional image, content.
126
- *
127
- * @param string $title Title.
128
- * @param string $content_md Content as Markdown (H1s already demoted).
129
- * @param string|null $image_url Featured image URL.
130
- * @param string $image_alt Featured image alt text.
131
- * @return string
132
- */
133
- public static function single_body( $title, $content_md, $image_url = null, $image_alt = '' ) {
134
- $parts = array( '# ' . self::heading_text( $title ) );
135
- if ( $image_url ) {
136
- $alt = str_replace( array( '[', ']' ), array( '\\[', '\\]' ), self::one_line( $image_alt ) );
137
- $parts[] = '![' . $alt . '](' . Relve_AR_Converter::url_for_markdown( $image_url ) . ')';
138
- }
139
- if ( '' !== trim( (string) $content_md ) ) {
140
- $parts[] = trim( $content_md );
141
- }
142
- return implode( "\n\n", $parts );
143
- }
144
-
145
- /**
146
- * Body of a list page: H1, optional intro, a list of posts.
147
- *
148
- * @param string $title Heading.
149
- * @param string $intro Intro text (plain).
150
- * @param array[] $items Each { title, url, date (Y-m-d), excerpt }.
151
- * @return string
152
- */
153
- public static function list_body( $title, $intro, $items ) {
154
- $parts = array( '# ' . self::heading_text( $title ) );
155
- $intro = self::one_line( (string) $intro );
156
- if ( '' !== $intro ) {
157
- $parts[] = $intro;
158
- }
159
- $lines = array();
160
- foreach ( $items as $item ) {
161
- $t = self::link_text( $item['title'] );
162
- if ( '' === $t ) {
163
- continue;
164
- }
165
- $line = '- [' . $t . '](' . Relve_AR_Converter::url_for_markdown( $item['url'] ) . ')';
166
- if ( ! empty( $item['date'] ) ) {
167
- $line .= ' (' . $item['date'] . ')';
168
- }
169
- $excerpt = self::one_line( isset( $item['excerpt'] ) ? (string) $item['excerpt'] : '' );
170
- if ( '' !== $excerpt ) {
171
- $line .= ': ' . $excerpt;
172
- }
173
- $lines[] = $line;
174
- }
175
- $parts[] = $lines ? implode( "\n", $lines ) : '_No posts yet._';
176
- return implode( "\n\n", $parts );
177
- }
178
-
179
- /**
180
- * Shorten plain text to $max characters on a word boundary.
181
- *
182
- * @param string $text Text.
183
- * @param int $max Max characters.
184
- * @return string
185
- */
186
- public static function shorten( $text, $max = 300 ) {
187
- $text = self::one_line( (string) $text );
188
- if ( self::strlen( $text ) <= $max ) {
189
- return $text;
190
- }
191
- $cut = self::substr( $text, 0, $max - 3 );
192
- $space = strrpos( $cut, ' ' );
193
- if ( false !== $space && $space > strlen( $cut ) / 2 ) {
194
- $cut = substr( $cut, 0, $space );
195
- }
196
- return rtrim( $cut, " ,.;:" ) . '...';
197
- }
198
-
199
- /**
200
- * Collapse whitespace.
201
- *
202
- * @param string $s Text.
203
- * @return string
204
- */
205
- public static function one_line( $s ) {
206
- $r = preg_replace( '/\s+/u', ' ', str_replace( "\xC2\xA0", ' ', (string) $s ) );
207
- return trim( null === $r ? (string) $s : $r );
208
- }
209
-
210
- /**
211
- * Text safe inside [link text].
212
- *
213
- * @param string $s Text.
214
- * @return string
215
- */
216
- public static function link_text( $s ) {
217
- return str_replace( array( '[', ']' ), '', self::one_line( $s ) );
218
- }
219
-
220
- /**
221
- * Heading text on one line.
222
- *
223
- * @param string $s Text.
224
- * @return string
225
- */
226
- private static function heading_text( $s ) {
227
- $t = self::one_line( $s );
228
- return '' === $t ? 'Untitled' : $t;
229
- }
230
-
231
- /**
232
- * JSON string as the package's JSON.stringify writes it.
233
- *
234
- * @param string $s String.
235
- * @return string
236
- */
237
- private static function json( $s ) {
238
- $j = json_encode( $s, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
239
- return false === $j ? '""' : $j;
240
- }
241
-
242
- /**
243
- * Character length (mbstring when available).
244
- *
245
- * @param string $s Text.
246
- * @return int
247
- */
248
- private static function strlen( $s ) {
249
- return function_exists( 'mb_strlen' ) ? mb_strlen( $s, 'UTF-8' ) : strlen( $s );
250
- }
251
-
252
- /**
253
- * Character substring (mbstring when available).
254
- *
255
- * @param string $s Text.
256
- * @param int $start Start.
257
- * @param int $len Length.
258
- * @return string
259
- */
260
- private static function substr( $s, $start, $len ) {
261
- return function_exists( 'mb_substr' ) ? mb_substr( $s, $start, $len, 'UTF-8' ) : substr( $s, $start, $len );
262
- }
263
-
264
- // ── WordPress-bound builders ──────────────────────────────────────────
265
-
266
- /**
267
- * Markdown for a post or page.
268
- *
269
- * @param WP_Post $post The post (published, public, no password).
270
- * @param string $canonical Canonical permalink.
271
- * @return string
272
- */
273
- public static function for_post( $post, $canonical ) {
274
- $title = html_entity_decode( wp_strip_all_tags( get_the_title( $post ) ), ENT_QUOTES, 'UTF-8' );
275
- $html = self::render_content( $post );
276
- $content = Relve_AR_Converter::convert( $html, $canonical, array( 'demote_h1' => true ) );
277
-
278
- $image_url = null;
279
- $image_alt = '';
280
- $thumb_id = get_post_thumbnail_id( $post );
281
- if ( $thumb_id ) {
282
- $image_url = wp_get_attachment_image_url( $thumb_id, 'full' );
283
- $image_alt = (string) get_post_meta( $thumb_id, '_wp_attachment_image_alt', true );
284
- if ( '' === $image_alt ) {
285
- $image_alt = $title;
286
- }
287
- }
288
-
289
- $categories = array();
290
- $tags = array();
291
- if ( is_object_in_taxonomy( $post->post_type, 'category' ) ) {
292
- foreach ( (array) get_the_category( $post->ID ) as $term ) {
293
- $categories[] = html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' );
294
- }
295
- }
296
- if ( is_object_in_taxonomy( $post->post_type, 'post_tag' ) ) {
297
- $post_tags = get_the_tags( $post->ID );
298
- if ( is_array( $post_tags ) ) {
299
- foreach ( $post_tags as $term ) {
300
- $tags[] = html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' );
301
- }
302
- }
303
- }
304
-
305
- $fields = array(
306
- 'title' => $title,
307
- 'canonical' => $canonical,
308
- 'author' => html_entity_decode( (string) get_the_author_meta( 'display_name', $post->post_author ), ENT_QUOTES, 'UTF-8' ),
309
- 'published' => get_post_time( 'Y-m-d\TH:i:s\Z', true, $post ),
310
- 'modified' => get_post_modified_time( 'Y-m-d\TH:i:s\Z', true, $post ),
311
- 'categories' => $categories,
312
- 'tags' => $tags,
313
- 'description' => self::description_for( $post ),
314
- 'source' => $canonical,
315
- 'generator' => 'agent-ready/native',
316
- 'lang' => get_bloginfo( 'language' ),
317
- );
318
- $fields = apply_filters( 'relve_agent_ready_front_matter', $fields, $post );
319
- $body = self::single_body( $title, $content, $image_url ? $image_url : null, $image_alt );
320
- return self::assemble( $fields, apply_filters( 'relve_agent_ready_markdown_body', $body, $post ) );
321
- }
322
-
323
- /**
324
- * The post's content rendered the way the theme would (blocks, shortcodes,
325
- * embeds), with the post set up as the global post.
326
- *
327
- * @param WP_Post $post Post.
328
- * @return string HTML.
329
- */
330
- public static function render_content( $post ) {
331
- $previous_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null;
332
- $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
333
- setup_postdata( $post );
334
- $html = apply_filters( 'the_content', $post->post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core hook.
335
- $html = str_replace( ']]>', ']]&gt;', (string) $html );
336
- $GLOBALS['post'] = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
337
- if ( $previous_post instanceof WP_Post ) {
338
- setup_postdata( $previous_post );
339
- }
340
- return $html;
341
- }
342
-
343
- /**
344
- * Front matter for a stored (refined) file that has none of its own.
345
- *
346
- * @param string $markdown Stored Markdown.
347
- * @param string $canonical Canonical URL.
348
- * @param WP_Post|null $post The post the path resolved to, if any.
349
- * @param string $title Title for non-post targets.
350
- * @return string
351
- */
352
- public static function for_stored( $markdown, $canonical, $post = null, $title = '' ) {
353
- if ( $post ) {
354
- $title = html_entity_decode( wp_strip_all_tags( get_the_title( $post ) ), ENT_QUOTES, 'UTF-8' );
355
- }
356
- return self::assemble(
357
- array(
358
- 'title' => $title,
359
- 'canonical' => $canonical,
360
- 'source' => $canonical,
361
- 'generator' => 'agent-ready/stored',
362
- 'lang' => get_bloginfo( 'language' ),
363
- ),
364
- $markdown
365
- );
366
- }
367
-
368
- /**
369
- * Description: Yoast meta description, else the excerpt, else the first
370
- * words of the content.
371
- *
372
- * @param WP_Post $post Post.
373
- * @return string
374
- */
375
- 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
- }
380
- if ( '' === trim( $desc ) && '' !== trim( (string) $post->post_excerpt ) ) {
381
- $desc = $post->post_excerpt;
382
- }
383
- if ( '' === trim( $desc ) ) {
384
- $desc = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ), 40, '...' );
385
- }
386
- return self::shorten( html_entity_decode( wp_strip_all_tags( $desc ), ENT_QUOTES, 'UTF-8' ), 300 );
387
- }
388
-
389
- /**
390
- * Markdown for the posts front page / posts page or a category, tag or
391
- * author archive: an H1 and its first 50 posts.
392
- *
393
- * @param string $kind home|category|post_tag|author.
394
- * @param int $object_id Term or user ID (0 for home).
395
- * @param string $canonical Canonical URL.
396
- * @return string
397
- */
398
- public static function for_archive( $kind, $object_id, $canonical ) {
399
- $args = array(
400
- 'post_type' => 'post',
401
- 'post_status' => 'publish',
402
- 'posts_per_page' => self::ARCHIVE_LIMIT,
403
- 'ignore_sticky_posts' => true,
404
- 'has_password' => false,
405
- 'no_found_rows' => true,
406
- );
407
- $intro = '';
408
- if ( 'category' === $kind ) {
409
- $term = get_term( $object_id, 'category' );
410
- $title = $term && ! is_wp_error( $term ) ? $term->name : '';
411
- $intro = $term && ! is_wp_error( $term ) ? $term->description : '';
412
- $args['cat'] = (int) $object_id;
413
- $title = sprintf( 'Category: %s', $title );
414
- } elseif ( 'post_tag' === $kind ) {
415
- $term = get_term( $object_id, 'post_tag' );
416
- $intro = $term && ! is_wp_error( $term ) ? $term->description : '';
417
- $args['tag_id'] = (int) $object_id;
418
- $title = sprintf( 'Tag: %s', $term && ! is_wp_error( $term ) ? $term->name : '' );
419
- } elseif ( 'author' === $kind ) {
420
- $args['author'] = (int) $object_id;
421
- $title = sprintf( 'Posts by %s', get_the_author_meta( 'display_name', $object_id ) );
422
- $intro = (string) get_the_author_meta( 'description', $object_id );
423
- } else {
424
- $title = get_bloginfo( 'name' );
425
- $intro = get_bloginfo( 'description' );
426
- $page = (int) get_option( 'page_for_posts' );
427
- if ( 'page' === get_option( 'show_on_front' ) && $page ) {
428
- $title = get_the_title( $page );
429
- }
430
- }
431
- $title = html_entity_decode( wp_strip_all_tags( (string) $title ), ENT_QUOTES, 'UTF-8' );
432
- $intro = html_entity_decode( wp_strip_all_tags( (string) $intro ), ENT_QUOTES, 'UTF-8' );
433
-
434
- $query = new WP_Query( apply_filters( 'relve_agent_ready_archive_query_args', $args, $kind, $object_id ) );
435
- $items = array();
436
- foreach ( $query->posts as $p ) {
437
- $items[] = array(
438
- 'title' => html_entity_decode( wp_strip_all_tags( get_the_title( $p ) ), ENT_QUOTES, 'UTF-8' ),
439
- 'url' => get_permalink( $p ),
440
- 'date' => get_post_time( 'Y-m-d', true, $p ),
441
- 'excerpt' => self::description_for( $p ),
442
- );
443
- }
444
-
445
- $fields = array(
446
- 'title' => $title,
447
- 'canonical' => $canonical,
448
- 'description' => self::shorten( $intro, 300 ),
449
- 'source' => $canonical,
450
- 'generator' => 'agent-ready/native',
451
- 'lang' => get_bloginfo( 'language' ),
452
- );
453
- return self::assemble( $fields, self::list_body( $title, $intro, $items ) );
454
- }
455
- }
1
+ <?php
2
+ /**
3
+ * Markdown builder: a post, page, the posts front page or an archive as one
4
+ * Markdown document, from WordPress's own data (not scraped HTML).
5
+ *
6
+ * ---
7
+ * title: "..."
8
+ * canonical: https://site/post/
9
+ * author: "..."
10
+ * published: 2026-10-01T09:00:00Z
11
+ * modified: 2026-10-02T10:00:00Z
12
+ * categories: ["News"]
13
+ * tags: ["AI"]
14
+ * description: "..."
15
+ * source: https://site/post/
16
+ * generator: agent-ready/native
17
+ * lang: en-US
18
+ * ---
19
+ *
20
+ * # Title
21
+ *
22
+ * ![featured image](...)
23
+ *
24
+ * Content...
25
+ *
26
+ * The front-matter rules are the package's (core/enrich.ts): empty values
27
+ * dropped, URLs / ISO dates / language tags plain, everything else
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.
30
+ *
31
+ * @package Relve_Agent_Ready
32
+ */
33
+
34
+ if ( ! defined( 'ABSPATH' ) ) {
35
+ exit;
36
+ }
37
+
38
+ /**
39
+ * Builds Markdown documents.
40
+ */
41
+ class Relve_AR_Markdown_Builder {
42
+
43
+ /** Posts listed on the front page / an archive. */
44
+ const ARCHIVE_LIMIT = 50;
45
+
46
+ // ── pure helpers (unit-tested) ────────────────────────────────────────
47
+
48
+ /**
49
+ * A YAML front-matter block. Empty values (null, "", empty list) are dropped.
50
+ *
51
+ * @param array $fields Ordered key => string|int|string[].
52
+ * @return string
53
+ */
54
+ public static function front_matter( $fields ) {
55
+ $lines = array( '---' );
56
+ foreach ( $fields as $key => $value ) {
57
+ if ( null === $value || '' === $value || array() === $value ) {
58
+ continue;
59
+ }
60
+ if ( is_array( $value ) ) {
61
+ $items = array();
62
+ foreach ( $value as $item ) {
63
+ $item = self::one_line( (string) $item );
64
+ if ( '' !== $item ) {
65
+ $items[] = self::json( $item );
66
+ }
67
+ }
68
+ if ( ! $items ) {
69
+ continue;
70
+ }
71
+ $lines[] = $key . ': [' . implode( ', ', $items ) . ']';
72
+ continue;
73
+ }
74
+ $lines[] = $key . ': ' . self::yaml_scalar( $value );
75
+ }
76
+ $lines[] = '---';
77
+ return implode( "\n", $lines );
78
+ }
79
+
80
+ /**
81
+ * Plain scalars for URLs, ISO dates, language tags and the generator;
82
+ * JSON-quoted (valid YAML) for everything else. Same rule as the package.
83
+ *
84
+ * @param string|int $value Value.
85
+ * @return string
86
+ */
87
+ public static function yaml_scalar( $value ) {
88
+ if ( is_int( $value ) || is_float( $value ) ) {
89
+ return (string) $value;
90
+ }
91
+ $one = self::one_line( (string) $value );
92
+ if ( preg_match( '#^(https?://[^\s\#]+|\d{4}-\d{2}-\d{2}T[\d:.]+Z|[a-z]{2,3}(-[A-Za-z]{2,4})?|agent-ready/[a-z]+)$#', $one ) ) {
93
+ return $one;
94
+ }
95
+ return self::json( $one );
96
+ }
97
+
98
+ /**
99
+ * Does the Markdown start with a front-matter block?
100
+ *
101
+ * @param string $markdown Markdown.
102
+ * @return bool
103
+ */
104
+ public static function has_front_matter( $markdown ) {
105
+ return (bool) preg_match( '/^---\r?\n[\s\S]*?\r?\n---\r?\n/', (string) $markdown );
106
+ }
107
+
108
+ /**
109
+ * 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
+ *
112
+ * @param array $fields Front-matter fields.
113
+ * @param string $body Markdown body (starting with its H1).
114
+ * @return string
115
+ */
116
+ public static function assemble( $fields, $body ) {
117
+ $body = trim( (string) $body );
118
+ if ( self::has_front_matter( $body . "\n" ) ) {
119
+ return $body . "\n";
120
+ }
121
+ return self::front_matter( $fields ) . "\n\n" . $body . "\n";
122
+ }
123
+
124
+ /**
125
+ * Body of a single post or page: one H1, optional image, content.
126
+ *
127
+ * @param string $title Title.
128
+ * @param string $content_md Content as Markdown (H1s already demoted).
129
+ * @param string|null $image_url Featured image URL.
130
+ * @param string $image_alt Featured image alt text.
131
+ * @return string
132
+ */
133
+ public static function single_body( $title, $content_md, $image_url = null, $image_alt = '' ) {
134
+ $parts = array( '# ' . self::heading_text( $title ) );
135
+ if ( $image_url ) {
136
+ $alt = str_replace( array( '[', ']' ), array( '\\[', '\\]' ), self::one_line( $image_alt ) );
137
+ $parts[] = '![' . $alt . '](' . Relve_AR_Converter::url_for_markdown( $image_url ) . ')';
138
+ }
139
+ if ( '' !== trim( (string) $content_md ) ) {
140
+ $parts[] = trim( $content_md );
141
+ }
142
+ return implode( "\n\n", $parts );
143
+ }
144
+
145
+ /**
146
+ * Body of a list page: H1, optional intro, a list of posts.
147
+ *
148
+ * @param string $title Heading.
149
+ * @param string $intro Intro text (plain).
150
+ * @param array[] $items Each { title, url, date (Y-m-d), excerpt }.
151
+ * @return string
152
+ */
153
+ public static function list_body( $title, $intro, $items ) {
154
+ $parts = array( '# ' . self::heading_text( $title ) );
155
+ $intro = self::one_line( (string) $intro );
156
+ if ( '' !== $intro ) {
157
+ $parts[] = $intro;
158
+ }
159
+ $lines = array();
160
+ foreach ( $items as $item ) {
161
+ $t = self::link_text( $item['title'] );
162
+ if ( '' === $t ) {
163
+ continue;
164
+ }
165
+ $line = '- [' . $t . '](' . Relve_AR_Converter::url_for_markdown( $item['url'] ) . ')';
166
+ if ( ! empty( $item['date'] ) ) {
167
+ $line .= ' (' . $item['date'] . ')';
168
+ }
169
+ $excerpt = self::one_line( isset( $item['excerpt'] ) ? (string) $item['excerpt'] : '' );
170
+ if ( '' !== $excerpt ) {
171
+ $line .= ': ' . $excerpt;
172
+ }
173
+ $lines[] = $line;
174
+ }
175
+ $parts[] = $lines ? implode( "\n", $lines ) : '_No posts yet._';
176
+ return implode( "\n\n", $parts );
177
+ }
178
+
179
+ /**
180
+ * Shorten plain text to $max characters on a word boundary.
181
+ *
182
+ * @param string $text Text.
183
+ * @param int $max Max characters.
184
+ * @return string
185
+ */
186
+ public static function shorten( $text, $max = 300 ) {
187
+ $text = self::one_line( (string) $text );
188
+ if ( self::strlen( $text ) <= $max ) {
189
+ return $text;
190
+ }
191
+ $cut = self::substr( $text, 0, $max - 3 );
192
+ $space = strrpos( $cut, ' ' );
193
+ if ( false !== $space && $space > strlen( $cut ) / 2 ) {
194
+ $cut = substr( $cut, 0, $space );
195
+ }
196
+ return rtrim( $cut, " ,.;:" ) . '...';
197
+ }
198
+
199
+ /**
200
+ * Collapse whitespace.
201
+ *
202
+ * @param string $s Text.
203
+ * @return string
204
+ */
205
+ public static function one_line( $s ) {
206
+ $r = preg_replace( '/\s+/u', ' ', str_replace( "\xC2\xA0", ' ', (string) $s ) );
207
+ return trim( null === $r ? (string) $s : $r );
208
+ }
209
+
210
+ /**
211
+ * Text safe inside [link text].
212
+ *
213
+ * @param string $s Text.
214
+ * @return string
215
+ */
216
+ public static function link_text( $s ) {
217
+ return str_replace( array( '[', ']' ), '', self::one_line( $s ) );
218
+ }
219
+
220
+ /**
221
+ * Heading text on one line.
222
+ *
223
+ * @param string $s Text.
224
+ * @return string
225
+ */
226
+ private static function heading_text( $s ) {
227
+ $t = self::one_line( $s );
228
+ return '' === $t ? 'Untitled' : $t;
229
+ }
230
+
231
+ /**
232
+ * JSON string as the package's JSON.stringify writes it.
233
+ *
234
+ * @param string $s String.
235
+ * @return string
236
+ */
237
+ private static function json( $s ) {
238
+ $j = json_encode( $s, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
239
+ return false === $j ? '""' : $j;
240
+ }
241
+
242
+ /**
243
+ * Character length (mbstring when available).
244
+ *
245
+ * @param string $s Text.
246
+ * @return int
247
+ */
248
+ private static function strlen( $s ) {
249
+ return function_exists( 'mb_strlen' ) ? mb_strlen( $s, 'UTF-8' ) : strlen( $s );
250
+ }
251
+
252
+ /**
253
+ * Character substring (mbstring when available).
254
+ *
255
+ * @param string $s Text.
256
+ * @param int $start Start.
257
+ * @param int $len Length.
258
+ * @return string
259
+ */
260
+ private static function substr( $s, $start, $len ) {
261
+ return function_exists( 'mb_substr' ) ? mb_substr( $s, $start, $len, 'UTF-8' ) : substr( $s, $start, $len );
262
+ }
263
+
264
+ // ── WordPress-bound builders ──────────────────────────────────────────
265
+
266
+ /**
267
+ * Markdown for a post or page.
268
+ *
269
+ * @param WP_Post $post The post (published, public, no password).
270
+ * @param string $canonical Canonical permalink.
271
+ * @return string
272
+ */
273
+ public static function for_post( $post, $canonical ) {
274
+ $title = html_entity_decode( wp_strip_all_tags( get_the_title( $post ) ), ENT_QUOTES, 'UTF-8' );
275
+ $html = self::render_content( $post );
276
+ $content = Relve_AR_Converter::convert( $html, $canonical, array( 'demote_h1' => true ) );
277
+
278
+ $image_url = null;
279
+ $image_alt = '';
280
+ $thumb_id = get_post_thumbnail_id( $post );
281
+ if ( $thumb_id ) {
282
+ $image_url = wp_get_attachment_image_url( $thumb_id, 'full' );
283
+ $image_alt = (string) get_post_meta( $thumb_id, '_wp_attachment_image_alt', true );
284
+ if ( '' === $image_alt ) {
285
+ $image_alt = $title;
286
+ }
287
+ }
288
+
289
+ $categories = array();
290
+ $tags = array();
291
+ if ( is_object_in_taxonomy( $post->post_type, 'category' ) ) {
292
+ foreach ( (array) get_the_category( $post->ID ) as $term ) {
293
+ $categories[] = html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' );
294
+ }
295
+ }
296
+ if ( is_object_in_taxonomy( $post->post_type, 'post_tag' ) ) {
297
+ $post_tags = get_the_tags( $post->ID );
298
+ if ( is_array( $post_tags ) ) {
299
+ foreach ( $post_tags as $term ) {
300
+ $tags[] = html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' );
301
+ }
302
+ }
303
+ }
304
+
305
+ $fields = array(
306
+ 'title' => $title,
307
+ 'canonical' => $canonical,
308
+ 'author' => html_entity_decode( (string) get_the_author_meta( 'display_name', $post->post_author ), ENT_QUOTES, 'UTF-8' ),
309
+ 'published' => get_post_time( 'Y-m-d\TH:i:s\Z', true, $post ),
310
+ 'modified' => get_post_modified_time( 'Y-m-d\TH:i:s\Z', true, $post ),
311
+ 'categories' => $categories,
312
+ 'tags' => $tags,
313
+ 'description' => self::description_for( $post ),
314
+ 'source' => $canonical,
315
+ 'generator' => 'agent-ready/native',
316
+ 'lang' => get_bloginfo( 'language' ),
317
+ );
318
+ $fields = apply_filters( 'relve_agent_ready_front_matter', $fields, $post );
319
+ $body = self::single_body( $title, $content, $image_url ? $image_url : null, $image_alt );
320
+ return self::assemble( $fields, apply_filters( 'relve_agent_ready_markdown_body', $body, $post ) );
321
+ }
322
+
323
+ /**
324
+ * The post's content rendered the way the theme would (blocks, shortcodes,
325
+ * embeds), with the post set up as the global post.
326
+ *
327
+ * @param WP_Post $post Post.
328
+ * @return string HTML.
329
+ */
330
+ public static function render_content( $post ) {
331
+ $previous_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null;
332
+ $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
333
+ setup_postdata( $post );
334
+ $html = apply_filters( 'the_content', $post->post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core hook.
335
+ $html = str_replace( ']]>', ']]&gt;', (string) $html );
336
+ $GLOBALS['post'] = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
337
+ if ( $previous_post instanceof WP_Post ) {
338
+ setup_postdata( $previous_post );
339
+ }
340
+ return $html;
341
+ }
342
+
343
+ /**
344
+ * Front matter for a stored (refined) file that has none of its own.
345
+ *
346
+ * @param string $markdown Stored Markdown.
347
+ * @param string $canonical Canonical URL.
348
+ * @param WP_Post|null $post The post the path resolved to, if any.
349
+ * @param string $title Title for non-post targets.
350
+ * @return string
351
+ */
352
+ public static function for_stored( $markdown, $canonical, $post = null, $title = '' ) {
353
+ if ( $post ) {
354
+ $title = html_entity_decode( wp_strip_all_tags( get_the_title( $post ) ), ENT_QUOTES, 'UTF-8' );
355
+ }
356
+ return self::assemble(
357
+ array(
358
+ 'title' => $title,
359
+ 'canonical' => $canonical,
360
+ 'source' => $canonical,
361
+ 'generator' => 'agent-ready/stored',
362
+ 'lang' => get_bloginfo( 'language' ),
363
+ ),
364
+ $markdown
365
+ );
366
+ }
367
+
368
+ /**
369
+ * Description: Yoast meta description, else the excerpt, else the first
370
+ * words of the content.
371
+ *
372
+ * @param WP_Post $post Post.
373
+ * @return string
374
+ */
375
+ 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
+ }
380
+ if ( '' === trim( $desc ) && '' !== trim( (string) $post->post_excerpt ) ) {
381
+ $desc = $post->post_excerpt;
382
+ }
383
+ if ( '' === trim( $desc ) ) {
384
+ $desc = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ), 40, '...' );
385
+ }
386
+ return self::shorten( html_entity_decode( wp_strip_all_tags( $desc ), ENT_QUOTES, 'UTF-8' ), 300 );
387
+ }
388
+
389
+ /**
390
+ * Markdown for the posts front page / posts page or a category, tag or
391
+ * author archive: an H1 and its first 50 posts.
392
+ *
393
+ * @param string $kind home|category|post_tag|author.
394
+ * @param int $object_id Term or user ID (0 for home).
395
+ * @param string $canonical Canonical URL.
396
+ * @return string
397
+ */
398
+ public static function for_archive( $kind, $object_id, $canonical ) {
399
+ $args = array(
400
+ 'post_type' => 'post',
401
+ 'post_status' => 'publish',
402
+ 'posts_per_page' => self::ARCHIVE_LIMIT,
403
+ 'ignore_sticky_posts' => true,
404
+ 'has_password' => false,
405
+ 'no_found_rows' => true,
406
+ );
407
+ $intro = '';
408
+ if ( 'category' === $kind ) {
409
+ $term = get_term( $object_id, 'category' );
410
+ $title = $term && ! is_wp_error( $term ) ? $term->name : '';
411
+ $intro = $term && ! is_wp_error( $term ) ? $term->description : '';
412
+ $args['cat'] = (int) $object_id;
413
+ $title = sprintf( 'Category: %s', $title );
414
+ } elseif ( 'post_tag' === $kind ) {
415
+ $term = get_term( $object_id, 'post_tag' );
416
+ $intro = $term && ! is_wp_error( $term ) ? $term->description : '';
417
+ $args['tag_id'] = (int) $object_id;
418
+ $title = sprintf( 'Tag: %s', $term && ! is_wp_error( $term ) ? $term->name : '' );
419
+ } elseif ( 'author' === $kind ) {
420
+ $args['author'] = (int) $object_id;
421
+ $title = sprintf( 'Posts by %s', get_the_author_meta( 'display_name', $object_id ) );
422
+ $intro = (string) get_the_author_meta( 'description', $object_id );
423
+ } else {
424
+ $title = get_bloginfo( 'name' );
425
+ $intro = get_bloginfo( 'description' );
426
+ $page = (int) get_option( 'page_for_posts' );
427
+ if ( 'page' === get_option( 'show_on_front' ) && $page ) {
428
+ $title = get_the_title( $page );
429
+ }
430
+ }
431
+ $title = html_entity_decode( wp_strip_all_tags( (string) $title ), ENT_QUOTES, 'UTF-8' );
432
+ $intro = html_entity_decode( wp_strip_all_tags( (string) $intro ), ENT_QUOTES, 'UTF-8' );
433
+
434
+ $query = new WP_Query( apply_filters( 'relve_agent_ready_archive_query_args', $args, $kind, $object_id ) );
435
+ $items = array();
436
+ foreach ( $query->posts as $p ) {
437
+ $items[] = array(
438
+ 'title' => html_entity_decode( wp_strip_all_tags( get_the_title( $p ) ), ENT_QUOTES, 'UTF-8' ),
439
+ 'url' => get_permalink( $p ),
440
+ 'date' => get_post_time( 'Y-m-d', true, $p ),
441
+ 'excerpt' => self::description_for( $p ),
442
+ );
443
+ }
444
+
445
+ $fields = array(
446
+ 'title' => $title,
447
+ 'canonical' => $canonical,
448
+ 'description' => self::shorten( $intro, 300 ),
449
+ 'source' => $canonical,
450
+ 'generator' => 'agent-ready/native',
451
+ 'lang' => get_bloginfo( 'language' ),
452
+ );
453
+ return self::assemble( $fields, self::list_body( $title, $intro, $items ) );
454
+ }
455
+ }