@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.
- package/README.md +24 -3
- package/SETUP.md +354 -354
- package/dist/core/report.d.ts +9 -3
- package/dist/core/report.js +30 -10
- package/dist/core/version.d.ts +1 -0
- package/dist/core/version.js +8 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -1
- package/dist/refine/check.d.ts +3 -1
- package/dist/refine/check.js +43 -0
- package/dist/refine/remote.d.ts +3 -2
- package/dist/refine/remote.js +3 -2
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-admin.php +560 -550
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-converter.php +762 -762
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-llms.php +192 -192
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-markdown-builder.php +455 -455
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-negotiation.php +334 -334
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-refiner.php +894 -894
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-reporter.php +147 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-response.php +226 -226
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-router.php +422 -421
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-settings.php +210 -201
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-store.php +583 -583
- package/dist/wp/relve-agent-ready/includes/index.php +2 -2
- package/dist/wp/relve-agent-ready/index.php +2 -2
- package/dist/wp/relve-agent-ready/readme.txt +119 -108
- package/dist/wp/relve-agent-ready/relve-agent-ready.php +82 -81
- package/dist/wp/relve-agent-ready/uninstall.php +35 -35
- package/package.json +1 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Reporter: share agent visits with Relve (changes/agent-ready-telemetry).
|
|
4
|
+
*
|
|
5
|
+
* Off unless both "Enabled" and "Share visit stats with Relve" are ticked.
|
|
6
|
+
* After a Markdown page or llms.txt has been sent, one small JSON report goes
|
|
7
|
+
* to RELVE_AR_HITS_ENDPOINT:
|
|
8
|
+
*
|
|
9
|
+
* { key: "ar_wp_<12 hex of md5(home_url())>", client: "wordpress/<ver>",
|
|
10
|
+
* event: { ts, host, path, via, source, status, tokens, bytes,
|
|
11
|
+
* userAgent, durationMs } }
|
|
12
|
+
*
|
|
13
|
+
* Never the Markdown, never the visitor's IP. The request is non-blocking
|
|
14
|
+
* with a 1 second timeout, and where PHP-FPM allows it the agent's response
|
|
15
|
+
* is finished first, so the agent never waits on Relve.
|
|
16
|
+
*
|
|
17
|
+
* @package Relve_Agent_Ready
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
if ( ! defined( 'ABSPATH' ) ) {
|
|
21
|
+
exit;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Visit reports to Relve.
|
|
26
|
+
*/
|
|
27
|
+
class Relve_AR_Reporter {
|
|
28
|
+
|
|
29
|
+
const DEFAULT_ENDPOINT = 'https://relvehq.com/api/agent-ready/v1/hits';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Endpoint URL: RELVE_AR_HITS_ENDPOINT, filterable (relve_ar_hits_endpoint).
|
|
33
|
+
*
|
|
34
|
+
* @return string
|
|
35
|
+
*/
|
|
36
|
+
public static function endpoint() {
|
|
37
|
+
$url = defined( 'RELVE_AR_HITS_ENDPOINT' ) ? (string) RELVE_AR_HITS_ENDPOINT : self::DEFAULT_ENDPOINT;
|
|
38
|
+
return (string) apply_filters( 'relve_ar_hits_endpoint', $url );
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether this site reports at all.
|
|
43
|
+
*
|
|
44
|
+
* @param array $settings Settings.
|
|
45
|
+
* @return bool
|
|
46
|
+
*/
|
|
47
|
+
public static function active( $settings ) {
|
|
48
|
+
return ! empty( $settings['enabled'] ) && ! empty( $settings['share_stats'] );
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The site's dashboard key: stable per home URL, not a secret.
|
|
53
|
+
*
|
|
54
|
+
* @return string
|
|
55
|
+
*/
|
|
56
|
+
public static function site_key() {
|
|
57
|
+
return 'ar_wp_' . substr( md5( (string) home_url() ), 0, 12 );
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One visit, in the package's HitEvent shape.
|
|
62
|
+
*
|
|
63
|
+
* @param string $path Requested page path (without ".md"; "via" says how it was asked).
|
|
64
|
+
* @param string $via suffix|query|accept|llms.
|
|
65
|
+
* @param string $source native|stored|llms.
|
|
66
|
+
* @param array $response Relve_AR_Response::build() result.
|
|
67
|
+
* @param string $body The full Markdown (only its size is used).
|
|
68
|
+
* @param array $server $_SERVER.
|
|
69
|
+
* @return array
|
|
70
|
+
*/
|
|
71
|
+
public static function event( $path, $via, $source, $response, $body, $server ) {
|
|
72
|
+
$headers = isset( $response['headers'] ) && is_array( $response['headers'] ) ? $response['headers'] : array();
|
|
73
|
+
$start = isset( $server['REQUEST_TIME_FLOAT'] ) ? (float) $server['REQUEST_TIME_FLOAT'] : 0.0;
|
|
74
|
+
$ua = isset( $server['HTTP_USER_AGENT'] ) ? substr( (string) $server['HTTP_USER_AGENT'], 0, 512 ) : '';
|
|
75
|
+
return array(
|
|
76
|
+
'ts' => gmdate( 'Y-m-d\TH:i:s\Z' ),
|
|
77
|
+
'host' => (string) wp_parse_url( home_url(), PHP_URL_HOST ),
|
|
78
|
+
'path' => substr( (string) $path, 0, 2048 ),
|
|
79
|
+
'via' => (string) $via,
|
|
80
|
+
'source' => (string) $source,
|
|
81
|
+
'status' => isset( $response['status'] ) ? (int) $response['status'] : 200,
|
|
82
|
+
'tokens' => isset( $headers['x-markdown-tokens'] ) ? (int) $headers['x-markdown-tokens'] : Relve_AR_Response::estimate_tokens( (string) $body ),
|
|
83
|
+
'bytes' => strlen( (string) $body ),
|
|
84
|
+
'userAgent' => $ua,
|
|
85
|
+
'durationMs' => $start > 0 ? max( 0, (int) round( ( microtime( true ) - $start ) * 1000 ) ) : null,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* wp_remote_post() arguments for one event.
|
|
91
|
+
*
|
|
92
|
+
* @param array $event Event.
|
|
93
|
+
* @return array
|
|
94
|
+
*/
|
|
95
|
+
public static function request_args( $event ) {
|
|
96
|
+
$version = defined( 'RELVE_AR_VERSION' ) ? RELVE_AR_VERSION : '0';
|
|
97
|
+
$body = json_encode( // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- flags wp_json_encode does not add.
|
|
98
|
+
array(
|
|
99
|
+
'key' => self::site_key(),
|
|
100
|
+
'client' => 'wordpress/' . $version,
|
|
101
|
+
'event' => $event,
|
|
102
|
+
),
|
|
103
|
+
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE
|
|
104
|
+
);
|
|
105
|
+
$ua = 'relve-agent-ready-wp/' . $version;
|
|
106
|
+
return array(
|
|
107
|
+
'method' => 'POST',
|
|
108
|
+
'timeout' => 1,
|
|
109
|
+
'blocking' => false,
|
|
110
|
+
'redirection' => 0,
|
|
111
|
+
'user-agent' => $ua,
|
|
112
|
+
'headers' => array(
|
|
113
|
+
'Content-Type' => 'application/json',
|
|
114
|
+
'User-Agent' => $ua,
|
|
115
|
+
),
|
|
116
|
+
'body' => (string) $body,
|
|
117
|
+
'data_format' => 'body',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Report one served response. Call after Relve_AR_Response::send(); never
|
|
123
|
+
* throws.
|
|
124
|
+
*
|
|
125
|
+
* @param array $settings Settings.
|
|
126
|
+
* @param string $path Requested path.
|
|
127
|
+
* @param string $via How the Markdown was asked for.
|
|
128
|
+
* @param string $source Where it came from.
|
|
129
|
+
* @param array $response Relve_AR_Response::build() result.
|
|
130
|
+
* @param string $body The full Markdown.
|
|
131
|
+
* @return void
|
|
132
|
+
*/
|
|
133
|
+
public static function report( $settings, $path, $via, $source, $response, $body ) {
|
|
134
|
+
if ( ! self::active( $settings ) ) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
$event = self::event( $path, $via, $source, $response, $body, $_SERVER );
|
|
139
|
+
if ( function_exists( 'fastcgi_finish_request' ) ) {
|
|
140
|
+
fastcgi_finish_request(); // The agent has its answer; report after.
|
|
141
|
+
}
|
|
142
|
+
wp_remote_post( self::endpoint(), self::request_args( $event ) );
|
|
143
|
+
} catch ( Throwable $e ) {
|
|
144
|
+
// A Relve outage must never touch the site.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -1,226 +1,226 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
/**
|
|
3
|
-
* Response: headers, ETag / 304, Cache-Control, token estimate.
|
|
4
|
-
*
|
|
5
|
-
* PHP port of @relvehq/agent-ready core/respond.ts and the adapters' header
|
|
6
|
-
* choices. build() is pure (an array in, an array out) so it is unit-tested;
|
|
7
|
-
* send() writes it with header() and echo.
|
|
8
|
-
*
|
|
9
|
-
* @package Relve_Agent_Ready
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
if ( ! defined( 'ABSPATH' ) ) {
|
|
13
|
-
exit;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Builds and sends Markdown / llms.txt / 404 responses.
|
|
18
|
-
*/
|
|
19
|
-
class Relve_AR_Response {
|
|
20
|
-
|
|
21
|
-
const MARKDOWN_CONTENT_TYPE = 'text/markdown; charset=utf-8';
|
|
22
|
-
const TEXT_CONTENT_TYPE = 'text/plain; charset=utf-8';
|
|
23
|
-
|
|
24
|
-
/** `.md`, `?format=md` and llms.txt: their own URLs, safe for shared caches. */
|
|
25
|
-
const CACHE_CONTROL = 'public, s-maxage=300, stale-while-revalidate=86400';
|
|
26
|
-
|
|
27
|
-
/** Negotiated by Accept on the page's own URL: never in a shared cache. */
|
|
28
|
-
const NEGOTIATED_CACHE_CONTROL = 'private, max-age=300';
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Cache-Control for a request that asked via $via.
|
|
32
|
-
*
|
|
33
|
-
* @param string $via suffix|query|accept|llms.
|
|
34
|
-
* @return string
|
|
35
|
-
*/
|
|
36
|
-
public static function cache_control_for( $via ) {
|
|
37
|
-
return 'accept' === $via ? self::NEGOTIATED_CACHE_CONTROL : self::CACHE_CONTROL;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Rough token count, as the package: ceil(length / 4), where length is
|
|
42
|
-
* the JavaScript string length (UTF-16 code units).
|
|
43
|
-
*
|
|
44
|
-
* @param string $text Text.
|
|
45
|
-
* @return int
|
|
46
|
-
*/
|
|
47
|
-
public static function estimate_tokens( $text ) {
|
|
48
|
-
return (int) ceil( self::utf16_length( $text ) / 4 );
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Length in UTF-16 code units (what JavaScript's .length counts).
|
|
53
|
-
*
|
|
54
|
-
* @param string $text UTF-8 text.
|
|
55
|
-
* @return int
|
|
56
|
-
*/
|
|
57
|
-
public static function utf16_length( $text ) {
|
|
58
|
-
$text = (string) $text;
|
|
59
|
-
$chars = preg_match_all( '/./su', $text );
|
|
60
|
-
if ( false === $chars ) {
|
|
61
|
-
return strlen( $text ); // Not valid UTF-8: count bytes.
|
|
62
|
-
}
|
|
63
|
-
$astral = preg_match_all( '/[\x{10000}-\x{10FFFF}]/u', $text );
|
|
64
|
-
return $chars + (int) $astral;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Strong ETag: first 32 hex chars of SHA-256(body).
|
|
69
|
-
*
|
|
70
|
-
* @param string $body Body.
|
|
71
|
-
* @return string
|
|
72
|
-
*/
|
|
73
|
-
public static function etag_for( $body ) {
|
|
74
|
-
return '"' . substr( hash( 'sha256', (string) $body ), 0, 32 ) . '"';
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* ETag for a body. A `generated_at:` front-matter line is left out of the
|
|
79
|
-
* hash (it changes every time) and the ETag is then weak, as the package.
|
|
80
|
-
*
|
|
81
|
-
* @param string $body Body.
|
|
82
|
-
* @return string
|
|
83
|
-
*/
|
|
84
|
-
public static function etag_for_body( $body ) {
|
|
85
|
-
$stable = preg_replace( '/^generated_at:.*\n?/m', '', (string) $body, 1 );
|
|
86
|
-
return $stable === $body ? self::etag_for( $body ) : 'W/' . self::etag_for( $stable );
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* RFC 9110 weak comparison; "*" matches any current representation.
|
|
91
|
-
*
|
|
92
|
-
* @param string|null $header If-None-Match value.
|
|
93
|
-
* @param string $etag Our ETag.
|
|
94
|
-
* @return bool
|
|
95
|
-
*/
|
|
96
|
-
public static function if_none_match_hits( $header, $etag ) {
|
|
97
|
-
if ( ! is_string( $header ) || '' === $header ) {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
$bare = preg_replace( '#^W/#', '', $etag );
|
|
101
|
-
foreach ( explode( ',', $header ) as $raw ) {
|
|
102
|
-
$t = trim( $raw );
|
|
103
|
-
if ( '*' === $t || preg_replace( '#^W/#', '', $t ) === $bare ) {
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Build a Markdown (or llms.txt) response.
|
|
112
|
-
*
|
|
113
|
-
* @param string $body Body.
|
|
114
|
-
* @param array $args {
|
|
115
|
-
* @type string $method GET or HEAD.
|
|
116
|
-
* @type string|null $if_none_match If-None-Match header.
|
|
117
|
-
* @type string|null $canonical Absolute canonical URL; null leaves Link out.
|
|
118
|
-
* @type string $cache_control Cache-Control.
|
|
119
|
-
* @type string $content_type Content-Type (default text/markdown).
|
|
120
|
-
* @type string $source x-agent-ready-source value.
|
|
121
|
-
* @type bool $noindex Send X-Robots-Tag: noindex (default true).
|
|
122
|
-
* @type int|null $tokens Token count (default: estimated from body).
|
|
123
|
-
* }
|
|
124
|
-
* @return array { status: int, headers: array<string,string>, body: string }
|
|
125
|
-
*/
|
|
126
|
-
public static function build( $body, $args ) {
|
|
127
|
-
$args = array_merge(
|
|
128
|
-
array(
|
|
129
|
-
'method' => 'GET',
|
|
130
|
-
'if_none_match' => null,
|
|
131
|
-
'canonical' => null,
|
|
132
|
-
'cache_control' => self::CACHE_CONTROL,
|
|
133
|
-
'content_type' => self::MARKDOWN_CONTENT_TYPE,
|
|
134
|
-
'source' => 'native',
|
|
135
|
-
'noindex' => true,
|
|
136
|
-
'tokens' => null,
|
|
137
|
-
),
|
|
138
|
-
(array) $args
|
|
139
|
-
);
|
|
140
|
-
$body = (string) $body;
|
|
141
|
-
$etag = self::etag_for_body( $body );
|
|
142
|
-
$tokens = null === $args['tokens'] ? self::estimate_tokens( $body ) : (int) $args['tokens'];
|
|
143
|
-
|
|
144
|
-
$headers = array( 'Content-Type' => $args['content_type'] );
|
|
145
|
-
$headers['Vary'] = 'Accept';
|
|
146
|
-
if ( $args['canonical'] ) {
|
|
147
|
-
$headers['Link'] = '<' . $args['canonical'] . '>; rel="canonical"';
|
|
148
|
-
}
|
|
149
|
-
if ( $args['noindex'] ) {
|
|
150
|
-
$headers['X-Robots-Tag'] = 'noindex';
|
|
151
|
-
}
|
|
152
|
-
$headers['x-markdown-tokens'] = (string) $tokens;
|
|
153
|
-
$headers['Cache-Control'] = $args['cache_control'];
|
|
154
|
-
$headers['ETag'] = $etag;
|
|
155
|
-
$headers['x-agent-ready-source'] = $args['source'];
|
|
156
|
-
|
|
157
|
-
if ( self::if_none_match_hits( $args['if_none_match'], $etag ) ) {
|
|
158
|
-
unset( $headers['Content-Type'] );
|
|
159
|
-
return array(
|
|
160
|
-
'status' => 304,
|
|
161
|
-
'headers' => $headers,
|
|
162
|
-
'body' => '',
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
$headers['Content-Length'] = (string) strlen( $body );
|
|
166
|
-
return array(
|
|
167
|
-
'status' => 200,
|
|
168
|
-
'headers' => $headers,
|
|
169
|
-
'body' => 'HEAD' === strtoupper( (string) $args['method'] ) ? '' : $body,
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* A plain-text error (the 404 for `.md` / `?format=md` with nothing to serve).
|
|
175
|
-
*
|
|
176
|
-
* @param int $status Status code.
|
|
177
|
-
* @param string $method GET or HEAD.
|
|
178
|
-
* @param string|null $canonical Canonical URL of the page, if known.
|
|
179
|
-
* @return array
|
|
180
|
-
*/
|
|
181
|
-
public static function plain( $status, $method = 'GET', $canonical = null ) {
|
|
182
|
-
$body = "No Markdown version of this page is available.\n";
|
|
183
|
-
$headers = array(
|
|
184
|
-
'Content-Type' => self::TEXT_CONTENT_TYPE,
|
|
185
|
-
'Cache-Control' => 'no-store',
|
|
186
|
-
'Vary' => 'Accept',
|
|
187
|
-
'X-Robots-Tag' => 'noindex',
|
|
188
|
-
);
|
|
189
|
-
if ( $canonical ) {
|
|
190
|
-
$headers['Link'] = '<' . $canonical . '>; rel="canonical"';
|
|
191
|
-
}
|
|
192
|
-
$headers['Content-Length'] = (string) strlen( $body );
|
|
193
|
-
return array(
|
|
194
|
-
'status' => (int) $status,
|
|
195
|
-
'headers' => $headers,
|
|
196
|
-
'body' => 'HEAD' === strtoupper( (string) $method ) ? '' : $body,
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Write a built response. Removes headers WordPress already queued that
|
|
202
|
-
* do not belong on Markdown (HTML Link headers, no-cache from nocache_headers()).
|
|
203
|
-
* The caller exits afterwards.
|
|
204
|
-
*
|
|
205
|
-
* @param array $response From build() or plain().
|
|
206
|
-
* @return void
|
|
207
|
-
*/
|
|
208
|
-
public static function send( $response ) {
|
|
209
|
-
if ( ! headers_sent() ) {
|
|
210
|
-
foreach ( array( 'Link', 'X-Pingback', 'Expires', 'Pragma', 'Last-Modified', 'Cache-Control', 'Content-Type', 'X-Robots-Tag' ) as $name ) {
|
|
211
|
-
header_remove( $name );
|
|
212
|
-
}
|
|
213
|
-
if ( function_exists( 'status_header' ) ) {
|
|
214
|
-
status_header( $response['status'] );
|
|
215
|
-
} else {
|
|
216
|
-
http_response_code( $response['status'] );
|
|
217
|
-
}
|
|
218
|
-
foreach ( $response['headers'] as $name => $value ) {
|
|
219
|
-
header( $name . ': ' . $value, true );
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
if ( '' !== $response['body'] ) {
|
|
223
|
-
echo $response['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- text/markdown or text/plain body, never HTML.
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Response: headers, ETag / 304, Cache-Control, token estimate.
|
|
4
|
+
*
|
|
5
|
+
* PHP port of @relvehq/agent-ready core/respond.ts and the adapters' header
|
|
6
|
+
* choices. build() is pure (an array in, an array out) so it is unit-tested;
|
|
7
|
+
* send() writes it with header() and echo.
|
|
8
|
+
*
|
|
9
|
+
* @package Relve_Agent_Ready
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
if ( ! defined( 'ABSPATH' ) ) {
|
|
13
|
+
exit;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Builds and sends Markdown / llms.txt / 404 responses.
|
|
18
|
+
*/
|
|
19
|
+
class Relve_AR_Response {
|
|
20
|
+
|
|
21
|
+
const MARKDOWN_CONTENT_TYPE = 'text/markdown; charset=utf-8';
|
|
22
|
+
const TEXT_CONTENT_TYPE = 'text/plain; charset=utf-8';
|
|
23
|
+
|
|
24
|
+
/** `.md`, `?format=md` and llms.txt: their own URLs, safe for shared caches. */
|
|
25
|
+
const CACHE_CONTROL = 'public, s-maxage=300, stale-while-revalidate=86400';
|
|
26
|
+
|
|
27
|
+
/** Negotiated by Accept on the page's own URL: never in a shared cache. */
|
|
28
|
+
const NEGOTIATED_CACHE_CONTROL = 'private, max-age=300';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cache-Control for a request that asked via $via.
|
|
32
|
+
*
|
|
33
|
+
* @param string $via suffix|query|accept|llms.
|
|
34
|
+
* @return string
|
|
35
|
+
*/
|
|
36
|
+
public static function cache_control_for( $via ) {
|
|
37
|
+
return 'accept' === $via ? self::NEGOTIATED_CACHE_CONTROL : self::CACHE_CONTROL;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Rough token count, as the package: ceil(length / 4), where length is
|
|
42
|
+
* the JavaScript string length (UTF-16 code units).
|
|
43
|
+
*
|
|
44
|
+
* @param string $text Text.
|
|
45
|
+
* @return int
|
|
46
|
+
*/
|
|
47
|
+
public static function estimate_tokens( $text ) {
|
|
48
|
+
return (int) ceil( self::utf16_length( $text ) / 4 );
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Length in UTF-16 code units (what JavaScript's .length counts).
|
|
53
|
+
*
|
|
54
|
+
* @param string $text UTF-8 text.
|
|
55
|
+
* @return int
|
|
56
|
+
*/
|
|
57
|
+
public static function utf16_length( $text ) {
|
|
58
|
+
$text = (string) $text;
|
|
59
|
+
$chars = preg_match_all( '/./su', $text );
|
|
60
|
+
if ( false === $chars ) {
|
|
61
|
+
return strlen( $text ); // Not valid UTF-8: count bytes.
|
|
62
|
+
}
|
|
63
|
+
$astral = preg_match_all( '/[\x{10000}-\x{10FFFF}]/u', $text );
|
|
64
|
+
return $chars + (int) $astral;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Strong ETag: first 32 hex chars of SHA-256(body).
|
|
69
|
+
*
|
|
70
|
+
* @param string $body Body.
|
|
71
|
+
* @return string
|
|
72
|
+
*/
|
|
73
|
+
public static function etag_for( $body ) {
|
|
74
|
+
return '"' . substr( hash( 'sha256', (string) $body ), 0, 32 ) . '"';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* ETag for a body. A `generated_at:` front-matter line is left out of the
|
|
79
|
+
* hash (it changes every time) and the ETag is then weak, as the package.
|
|
80
|
+
*
|
|
81
|
+
* @param string $body Body.
|
|
82
|
+
* @return string
|
|
83
|
+
*/
|
|
84
|
+
public static function etag_for_body( $body ) {
|
|
85
|
+
$stable = preg_replace( '/^generated_at:.*\n?/m', '', (string) $body, 1 );
|
|
86
|
+
return $stable === $body ? self::etag_for( $body ) : 'W/' . self::etag_for( $stable );
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* RFC 9110 weak comparison; "*" matches any current representation.
|
|
91
|
+
*
|
|
92
|
+
* @param string|null $header If-None-Match value.
|
|
93
|
+
* @param string $etag Our ETag.
|
|
94
|
+
* @return bool
|
|
95
|
+
*/
|
|
96
|
+
public static function if_none_match_hits( $header, $etag ) {
|
|
97
|
+
if ( ! is_string( $header ) || '' === $header ) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
$bare = preg_replace( '#^W/#', '', $etag );
|
|
101
|
+
foreach ( explode( ',', $header ) as $raw ) {
|
|
102
|
+
$t = trim( $raw );
|
|
103
|
+
if ( '*' === $t || preg_replace( '#^W/#', '', $t ) === $bare ) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build a Markdown (or llms.txt) response.
|
|
112
|
+
*
|
|
113
|
+
* @param string $body Body.
|
|
114
|
+
* @param array $args {
|
|
115
|
+
* @type string $method GET or HEAD.
|
|
116
|
+
* @type string|null $if_none_match If-None-Match header.
|
|
117
|
+
* @type string|null $canonical Absolute canonical URL; null leaves Link out.
|
|
118
|
+
* @type string $cache_control Cache-Control.
|
|
119
|
+
* @type string $content_type Content-Type (default text/markdown).
|
|
120
|
+
* @type string $source x-agent-ready-source value.
|
|
121
|
+
* @type bool $noindex Send X-Robots-Tag: noindex (default true).
|
|
122
|
+
* @type int|null $tokens Token count (default: estimated from body).
|
|
123
|
+
* }
|
|
124
|
+
* @return array { status: int, headers: array<string,string>, body: string }
|
|
125
|
+
*/
|
|
126
|
+
public static function build( $body, $args ) {
|
|
127
|
+
$args = array_merge(
|
|
128
|
+
array(
|
|
129
|
+
'method' => 'GET',
|
|
130
|
+
'if_none_match' => null,
|
|
131
|
+
'canonical' => null,
|
|
132
|
+
'cache_control' => self::CACHE_CONTROL,
|
|
133
|
+
'content_type' => self::MARKDOWN_CONTENT_TYPE,
|
|
134
|
+
'source' => 'native',
|
|
135
|
+
'noindex' => true,
|
|
136
|
+
'tokens' => null,
|
|
137
|
+
),
|
|
138
|
+
(array) $args
|
|
139
|
+
);
|
|
140
|
+
$body = (string) $body;
|
|
141
|
+
$etag = self::etag_for_body( $body );
|
|
142
|
+
$tokens = null === $args['tokens'] ? self::estimate_tokens( $body ) : (int) $args['tokens'];
|
|
143
|
+
|
|
144
|
+
$headers = array( 'Content-Type' => $args['content_type'] );
|
|
145
|
+
$headers['Vary'] = 'Accept';
|
|
146
|
+
if ( $args['canonical'] ) {
|
|
147
|
+
$headers['Link'] = '<' . $args['canonical'] . '>; rel="canonical"';
|
|
148
|
+
}
|
|
149
|
+
if ( $args['noindex'] ) {
|
|
150
|
+
$headers['X-Robots-Tag'] = 'noindex';
|
|
151
|
+
}
|
|
152
|
+
$headers['x-markdown-tokens'] = (string) $tokens;
|
|
153
|
+
$headers['Cache-Control'] = $args['cache_control'];
|
|
154
|
+
$headers['ETag'] = $etag;
|
|
155
|
+
$headers['x-agent-ready-source'] = $args['source'];
|
|
156
|
+
|
|
157
|
+
if ( self::if_none_match_hits( $args['if_none_match'], $etag ) ) {
|
|
158
|
+
unset( $headers['Content-Type'] );
|
|
159
|
+
return array(
|
|
160
|
+
'status' => 304,
|
|
161
|
+
'headers' => $headers,
|
|
162
|
+
'body' => '',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
$headers['Content-Length'] = (string) strlen( $body );
|
|
166
|
+
return array(
|
|
167
|
+
'status' => 200,
|
|
168
|
+
'headers' => $headers,
|
|
169
|
+
'body' => 'HEAD' === strtoupper( (string) $args['method'] ) ? '' : $body,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A plain-text error (the 404 for `.md` / `?format=md` with nothing to serve).
|
|
175
|
+
*
|
|
176
|
+
* @param int $status Status code.
|
|
177
|
+
* @param string $method GET or HEAD.
|
|
178
|
+
* @param string|null $canonical Canonical URL of the page, if known.
|
|
179
|
+
* @return array
|
|
180
|
+
*/
|
|
181
|
+
public static function plain( $status, $method = 'GET', $canonical = null ) {
|
|
182
|
+
$body = "No Markdown version of this page is available.\n";
|
|
183
|
+
$headers = array(
|
|
184
|
+
'Content-Type' => self::TEXT_CONTENT_TYPE,
|
|
185
|
+
'Cache-Control' => 'no-store',
|
|
186
|
+
'Vary' => 'Accept',
|
|
187
|
+
'X-Robots-Tag' => 'noindex',
|
|
188
|
+
);
|
|
189
|
+
if ( $canonical ) {
|
|
190
|
+
$headers['Link'] = '<' . $canonical . '>; rel="canonical"';
|
|
191
|
+
}
|
|
192
|
+
$headers['Content-Length'] = (string) strlen( $body );
|
|
193
|
+
return array(
|
|
194
|
+
'status' => (int) $status,
|
|
195
|
+
'headers' => $headers,
|
|
196
|
+
'body' => 'HEAD' === strtoupper( (string) $method ) ? '' : $body,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Write a built response. Removes headers WordPress already queued that
|
|
202
|
+
* do not belong on Markdown (HTML Link headers, no-cache from nocache_headers()).
|
|
203
|
+
* The caller exits afterwards.
|
|
204
|
+
*
|
|
205
|
+
* @param array $response From build() or plain().
|
|
206
|
+
* @return void
|
|
207
|
+
*/
|
|
208
|
+
public static function send( $response ) {
|
|
209
|
+
if ( ! headers_sent() ) {
|
|
210
|
+
foreach ( array( 'Link', 'X-Pingback', 'Expires', 'Pragma', 'Last-Modified', 'Cache-Control', 'Content-Type', 'X-Robots-Tag' ) as $name ) {
|
|
211
|
+
header_remove( $name );
|
|
212
|
+
}
|
|
213
|
+
if ( function_exists( 'status_header' ) ) {
|
|
214
|
+
status_header( $response['status'] );
|
|
215
|
+
} else {
|
|
216
|
+
http_response_code( $response['status'] );
|
|
217
|
+
}
|
|
218
|
+
foreach ( $response['headers'] as $name => $value ) {
|
|
219
|
+
header( $name . ': ' . $value, true );
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if ( '' !== $response['body'] ) {
|
|
223
|
+
echo $response['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- text/markdown or text/plain body, never HTML.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|