@relvehq/agent-ready 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +364 -3
- package/SETUP.md +354 -0
- package/dist/bin/agent-ready.d.ts +23 -0
- package/dist/bin/agent-ready.js +444 -0
- package/dist/cli/init.d.ts +70 -0
- package/dist/cli/init.js +366 -0
- package/dist/core/accept.d.ts +8 -0
- package/dist/core/accept.js +76 -0
- package/dist/core/config.d.ts +145 -0
- package/dist/core/config.js +103 -0
- package/dist/core/convert.d.ts +22 -0
- package/dist/core/convert.js +334 -0
- package/dist/core/detect.d.ts +30 -0
- package/dist/core/detect.js +82 -0
- package/dist/core/easy.d.ts +19 -0
- package/dist/core/easy.js +117 -0
- package/dist/core/enrich.d.ts +30 -0
- package/dist/core/enrich.js +155 -0
- package/dist/core/llms.d.ts +11 -0
- package/dist/core/llms.js +71 -0
- package/dist/core/report.d.ts +15 -0
- package/dist/core/report.js +46 -0
- package/dist/core/resolve.d.ts +43 -0
- package/dist/core/resolve.js +204 -0
- package/dist/core/respond.d.ts +18 -0
- package/dist/core/respond.js +59 -0
- package/dist/core/sitemap.d.ts +31 -0
- package/dist/core/sitemap.js +140 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +76 -0
- package/dist/next/dashboard.d.ts +14 -0
- package/dist/next/dashboard.js +56 -0
- package/dist/next/index.d.ts +42 -0
- package/dist/next/index.js +100 -0
- package/dist/next/route.d.ts +26 -0
- package/dist/next/route.js +167 -0
- package/dist/node/index.d.ts +8 -0
- package/dist/node/index.js +191 -0
- package/dist/proof/audit.d.ts +47 -0
- package/dist/proof/audit.js +253 -0
- package/dist/proof/bots.d.ts +24 -0
- package/dist/proof/bots.js +60 -0
- package/dist/proof/dashboard.d.ts +8 -0
- package/dist/proof/dashboard.js +77 -0
- package/dist/proof/hits.d.ts +18 -0
- package/dist/proof/hits.js +51 -0
- package/dist/proof/jsonl.d.ts +5 -0
- package/dist/proof/jsonl.js +80 -0
- package/dist/proof/stats.d.ts +39 -0
- package/dist/proof/stats.js +127 -0
- package/dist/refine/check.d.ts +17 -0
- package/dist/refine/check.js +134 -0
- package/dist/refine/extract.d.ts +7 -0
- package/dist/refine/extract.js +120 -0
- package/dist/refine/file-store.d.ts +6 -0
- package/dist/refine/file-store.js +87 -0
- package/dist/refine/model.d.ts +30 -0
- package/dist/refine/model.js +105 -0
- package/dist/refine/prompts.d.ts +49 -0
- package/dist/refine/prompts.js +178 -0
- package/dist/refine/refine.d.ts +38 -0
- package/dist/refine/refine.js +75 -0
- package/dist/refine/remote.d.ts +57 -0
- package/dist/refine/remote.js +238 -0
- package/dist/refine/run.d.ts +52 -0
- package/dist/refine/run.js +66 -0
- package/dist/refine/types.d.ts +17 -0
- package/dist/refine/types.js +8 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-admin.php +550 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-converter.php +762 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-llms.php +192 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-markdown-builder.php +455 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-negotiation.php +334 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-refiner.php +894 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-response.php +226 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-router.php +421 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-settings.php +201 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-store.php +583 -0
- package/dist/wp/relve-agent-ready/includes/index.php +2 -0
- package/dist/wp/relve-agent-ready/index.php +2 -0
- package/dist/wp/relve-agent-ready/readme.txt +108 -0
- package/dist/wp/relve-agent-ready/relve-agent-ready.php +81 -0
- package/dist/wp/relve-agent-ready/uninstall.php +35 -0
- package/package.json +64 -6
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* Converter: rendered post HTML -> Markdown, with DOMDocument only.
|
|
4
|
+
*
|
|
5
|
+
* The npm package uses turndown + GFM; this is a small equivalent for the
|
|
6
|
+
* markup WordPress produces (blocks, classic editor, page builders):
|
|
7
|
+
* headings, paragraphs, br, strong/em/del, links, images (alt + src), nested
|
|
8
|
+
* ul/ol, blockquote, pre/code (language from the class), inline code, GFM
|
|
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.
|
|
12
|
+
*
|
|
13
|
+
* Pure PHP (ext-dom): no WordPress calls.
|
|
14
|
+
*
|
|
15
|
+
* @package Relve_Agent_Ready
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
if ( ! defined( 'ABSPATH' ) ) {
|
|
19
|
+
exit;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* HTML to Markdown.
|
|
24
|
+
*/
|
|
25
|
+
class Relve_AR_Converter {
|
|
26
|
+
|
|
27
|
+
/** Elements removed with everything inside them. */
|
|
28
|
+
const JUNK = array( 'script', 'style', 'noscript', 'template', 'iframe', 'object', 'embed', 'svg', 'canvas', 'video', 'audio', 'form', 'input', 'button', 'select', 'textarea', 'label', 'nav', 'dialog', 'link', 'meta', 'head', 'title' );
|
|
29
|
+
|
|
30
|
+
/** Elements that start a new block. Everything else is inline. */
|
|
31
|
+
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
|
+
|
|
33
|
+
/** Absolute URL of the page: base for relative links and images. @var string */
|
|
34
|
+
private $base;
|
|
35
|
+
|
|
36
|
+
/** Turn <h1> in the content into <h2> (the document has its own H1). @var bool */
|
|
37
|
+
private $demote_h1;
|
|
38
|
+
|
|
39
|
+
/** Shortcode names that appear with a closing tag somewhere. @var string[] */
|
|
40
|
+
private $closing_shortcodes = array();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Convert HTML to Markdown.
|
|
44
|
+
*
|
|
45
|
+
* @param string $html HTML fragment or document.
|
|
46
|
+
* @param string $base Absolute page URL.
|
|
47
|
+
* @param array $options { demote_h1: bool (default true) }.
|
|
48
|
+
* @return string Markdown, trimmed ("" when nothing is left).
|
|
49
|
+
*/
|
|
50
|
+
public static function convert( $html, $base, $options = array() ) {
|
|
51
|
+
$c = new self();
|
|
52
|
+
$c->base = (string) $base;
|
|
53
|
+
$c->demote_h1 = ! isset( $options['demote_h1'] ) || $options['demote_h1'];
|
|
54
|
+
return $c->run( (string) $html );
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse, clean and render.
|
|
59
|
+
*
|
|
60
|
+
* @param string $html HTML.
|
|
61
|
+
* @return string
|
|
62
|
+
*/
|
|
63
|
+
private function run( $html ) {
|
|
64
|
+
if ( '' === trim( $html ) || ! class_exists( 'DOMDocument' ) ) {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
if ( preg_match_all( '#\[/([a-zA-Z][\w-]*)\]#', $html, $m ) ) {
|
|
68
|
+
$this->closing_shortcodes = array_unique( array_map( 'strtolower', $m[1] ) );
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
$doc = new DOMDocument();
|
|
72
|
+
$previous = libxml_use_internal_errors( true );
|
|
73
|
+
$wrapped = '<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body>' . $html . '</body></html>';
|
|
74
|
+
$loaded = $doc->loadHTML( $wrapped, LIBXML_NONET | LIBXML_COMPACT );
|
|
75
|
+
libxml_clear_errors();
|
|
76
|
+
libxml_use_internal_errors( $previous );
|
|
77
|
+
if ( ! $loaded ) {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
$body = $doc->getElementsByTagName( 'body' )->item( 0 );
|
|
81
|
+
if ( ! $body ) {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
$this->clean( $body );
|
|
85
|
+
$blocks = $this->blocks( $body );
|
|
86
|
+
return trim( implode( "\n\n", $blocks ) );
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── cleaning ──────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Remove junk, hidden elements and comments.
|
|
93
|
+
*
|
|
94
|
+
* @param DOMNode $root Root.
|
|
95
|
+
* @return void
|
|
96
|
+
*/
|
|
97
|
+
private function clean( DOMNode $root ) {
|
|
98
|
+
$remove = array();
|
|
99
|
+
$walk = function ( DOMNode $node ) use ( &$walk, &$remove ) {
|
|
100
|
+
foreach ( iterator_to_array( $node->childNodes ) as $child ) {
|
|
101
|
+
if ( XML_COMMENT_NODE === $child->nodeType || XML_PI_NODE === $child->nodeType ) {
|
|
102
|
+
$remove[] = $child;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if ( XML_ELEMENT_NODE !== $child->nodeType ) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
$tag = strtolower( $child->nodeName );
|
|
109
|
+
if ( in_array( $tag, self::JUNK, true ) || $this->is_hidden( $child ) ) {
|
|
110
|
+
$remove[] = $child;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
$walk( $child );
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
$walk( $root );
|
|
117
|
+
foreach ( $remove as $node ) {
|
|
118
|
+
if ( $node->parentNode ) {
|
|
119
|
+
$node->parentNode->removeChild( $node );
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Hidden by attribute or inline style.
|
|
126
|
+
*
|
|
127
|
+
* @param DOMElement $el Element.
|
|
128
|
+
* @return bool
|
|
129
|
+
*/
|
|
130
|
+
private function is_hidden( $el ) {
|
|
131
|
+
if ( $el->hasAttribute( 'hidden' ) ) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if ( 'true' === strtolower( $el->getAttribute( 'aria-hidden' ) ) ) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
$style = strtolower( $el->getAttribute( 'style' ) );
|
|
138
|
+
return '' !== $style && (bool) preg_match( '/display\s*:\s*none|visibility\s*:\s*hidden/', $style );
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── blocks ────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Render a container's children as a list of Markdown blocks. Runs of
|
|
145
|
+
* inline content become paragraphs, so page-builder div soup reads as
|
|
146
|
+
* paragraphs.
|
|
147
|
+
*
|
|
148
|
+
* @param DOMNode $parent Container.
|
|
149
|
+
* @return string[]
|
|
150
|
+
*/
|
|
151
|
+
private function blocks( DOMNode $parent ) {
|
|
152
|
+
$out = array();
|
|
153
|
+
$inline = '';
|
|
154
|
+
foreach ( $parent->childNodes as $child ) {
|
|
155
|
+
if ( $this->is_block( $child ) ) {
|
|
156
|
+
$this->flush( $inline, $out );
|
|
157
|
+
$inline = '';
|
|
158
|
+
foreach ( $this->block( $child ) as $b ) {
|
|
159
|
+
if ( '' !== trim( $b ) ) {
|
|
160
|
+
$out[] = $b;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
$inline .= $this->inline( $child );
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
$this->flush( $inline, $out );
|
|
168
|
+
return $out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Turn collected inline Markdown into a paragraph block.
|
|
173
|
+
*
|
|
174
|
+
* @param string $inline Inline Markdown.
|
|
175
|
+
* @param string[] $out Blocks (appended to).
|
|
176
|
+
* @return void
|
|
177
|
+
*/
|
|
178
|
+
private function flush( $inline, &$out ) {
|
|
179
|
+
$p = $this->paragraph( $inline );
|
|
180
|
+
if ( '' !== $p ) {
|
|
181
|
+
$out[] = $p;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Tidy a paragraph: trim each line, collapse spaces, escape characters
|
|
187
|
+
* that would start a block at the beginning of a line.
|
|
188
|
+
*
|
|
189
|
+
* @param string $text Inline Markdown.
|
|
190
|
+
* @return string
|
|
191
|
+
*/
|
|
192
|
+
private function paragraph( $text ) {
|
|
193
|
+
$lines = array();
|
|
194
|
+
foreach ( explode( "\n", $text ) as $line ) {
|
|
195
|
+
$line = trim( preg_replace( '/ {2,}/', ' ', $line ) );
|
|
196
|
+
if ( '' === $line ) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
$lines[] = $this->escape_line_start( $line );
|
|
200
|
+
}
|
|
201
|
+
return implode( "\n", $lines );
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Escape a leading #, >, -, +, "1." that would change the block type.
|
|
206
|
+
*
|
|
207
|
+
* @param string $line Line.
|
|
208
|
+
* @return string
|
|
209
|
+
*/
|
|
210
|
+
private function escape_line_start( $line ) {
|
|
211
|
+
if ( preg_match( '/^(#{1,6}\s|>|[-+]\s|=+\s*$|-{3,}\s*$)/', $line ) ) {
|
|
212
|
+
return '\\' . $line;
|
|
213
|
+
}
|
|
214
|
+
if ( preg_match( '/^(\d+)\.(\s)/', $line ) ) {
|
|
215
|
+
return preg_replace( '/^(\d+)\./', '$1\\.', $line );
|
|
216
|
+
}
|
|
217
|
+
return $line;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Does this node start a block?
|
|
222
|
+
*
|
|
223
|
+
* @param DOMNode $node Node.
|
|
224
|
+
* @return bool
|
|
225
|
+
*/
|
|
226
|
+
private function is_block( DOMNode $node ) {
|
|
227
|
+
return XML_ELEMENT_NODE === $node->nodeType && in_array( strtolower( $node->nodeName ), self::BLOCKS, true );
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Render one block element.
|
|
232
|
+
*
|
|
233
|
+
* @param DOMElement $el Element.
|
|
234
|
+
* @return string[]
|
|
235
|
+
*/
|
|
236
|
+
private function block( DOMElement $el ) {
|
|
237
|
+
$tag = strtolower( $el->nodeName );
|
|
238
|
+
switch ( $tag ) {
|
|
239
|
+
case 'h1':
|
|
240
|
+
case 'h2':
|
|
241
|
+
case 'h3':
|
|
242
|
+
case 'h4':
|
|
243
|
+
case 'h5':
|
|
244
|
+
case 'h6':
|
|
245
|
+
$level = (int) substr( $tag, 1 );
|
|
246
|
+
if ( 1 === $level && $this->demote_h1 ) {
|
|
247
|
+
$level = 2;
|
|
248
|
+
}
|
|
249
|
+
$text = $this->one_line( $this->inline_children( $el ) );
|
|
250
|
+
return '' === $text ? array() : array( str_repeat( '#', $level ) . ' ' . $text );
|
|
251
|
+
case 'p':
|
|
252
|
+
case 'dt':
|
|
253
|
+
case 'summary':
|
|
254
|
+
case 'caption':
|
|
255
|
+
$p = $this->paragraph( $this->inline_children( $el ) );
|
|
256
|
+
if ( 'dt' === $tag && '' !== $p ) {
|
|
257
|
+
$p = '**' . $this->one_line( $p ) . '**';
|
|
258
|
+
}
|
|
259
|
+
return array( $p );
|
|
260
|
+
case 'hr':
|
|
261
|
+
return array( '---' );
|
|
262
|
+
case 'pre':
|
|
263
|
+
return array( $this->code_block( $el ) );
|
|
264
|
+
case 'blockquote':
|
|
265
|
+
$inner = implode( "\n\n", $this->blocks( $el ) );
|
|
266
|
+
if ( '' === trim( $inner ) ) {
|
|
267
|
+
return array();
|
|
268
|
+
}
|
|
269
|
+
$lines = array();
|
|
270
|
+
foreach ( explode( "\n", $inner ) as $line ) {
|
|
271
|
+
$lines[] = '' === $line ? '>' : '> ' . $line;
|
|
272
|
+
}
|
|
273
|
+
return array( implode( "\n", $lines ) );
|
|
274
|
+
case 'ul':
|
|
275
|
+
case 'ol':
|
|
276
|
+
return array( $this->list_block( $el, 'ol' === $tag ) );
|
|
277
|
+
case 'table':
|
|
278
|
+
return array( $this->table( $el ) );
|
|
279
|
+
case 'li':
|
|
280
|
+
// A stray <li> outside a list: render it as a one-item list.
|
|
281
|
+
return array( $this->list_item( $el, '- ' ) );
|
|
282
|
+
default:
|
|
283
|
+
return $this->blocks( $el );
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Fenced code block. Language from "language-x" / "lang-x" / "brush: x".
|
|
289
|
+
*
|
|
290
|
+
* @param DOMElement $pre The <pre>.
|
|
291
|
+
* @return string
|
|
292
|
+
*/
|
|
293
|
+
private function code_block( DOMElement $pre ) {
|
|
294
|
+
$classes = $pre->getAttribute( 'class' ) . ' ' . $pre->getAttribute( 'data-lang' );
|
|
295
|
+
foreach ( $pre->childNodes as $child ) {
|
|
296
|
+
if ( XML_ELEMENT_NODE === $child->nodeType && 'code' === strtolower( $child->nodeName ) ) {
|
|
297
|
+
$classes .= ' ' . $child->getAttribute( 'class' ) . ' ' . $child->getAttribute( 'data-lang' );
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
$lang = '';
|
|
301
|
+
if ( preg_match( '/(?:^|\s)(?:language|lang)-([A-Za-z0-9_+#.-]+)/', $classes, $m ) ) {
|
|
302
|
+
$lang = $m[1];
|
|
303
|
+
} elseif ( preg_match( '/brush:\s*([A-Za-z0-9_+#-]+)/', $classes, $m ) ) {
|
|
304
|
+
$lang = $m[1];
|
|
305
|
+
} elseif ( preg_match( '/^\s*([A-Za-z0-9_+#-]+)\s*$/', $pre->getAttribute( 'data-lang' ), $m ) ) {
|
|
306
|
+
$lang = $m[1];
|
|
307
|
+
}
|
|
308
|
+
$code = str_replace( array( "\r\n", "\r" ), "\n", $pre->textContent );
|
|
309
|
+
$code = preg_replace( '/^\n/', '', $code );
|
|
310
|
+
$code = rtrim( $code, "\n" );
|
|
311
|
+
$fence = '```';
|
|
312
|
+
if ( preg_match_all( '/`{3,}/', $code, $m ) ) {
|
|
313
|
+
$longest = max( array_map( 'strlen', $m[0] ) );
|
|
314
|
+
$fence = str_repeat( '`', $longest + 1 );
|
|
315
|
+
}
|
|
316
|
+
return $fence . strtolower( $lang ) . "\n" . $code . "\n" . $fence;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A ul / ol. Nested lists are indented under their item.
|
|
321
|
+
*
|
|
322
|
+
* @param DOMElement $list The list.
|
|
323
|
+
* @param bool $ordered Ordered?
|
|
324
|
+
* @return string
|
|
325
|
+
*/
|
|
326
|
+
private function list_block( DOMElement $list, $ordered ) {
|
|
327
|
+
$n = $ordered && is_numeric( $list->getAttribute( 'start' ) ) ? (int) $list->getAttribute( 'start' ) : 1;
|
|
328
|
+
$items = array();
|
|
329
|
+
$loose = false;
|
|
330
|
+
foreach ( $list->childNodes as $child ) {
|
|
331
|
+
if ( XML_ELEMENT_NODE !== $child->nodeType ) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
$tag = strtolower( $child->nodeName );
|
|
335
|
+
if ( 'ul' === $tag || 'ol' === $tag ) {
|
|
336
|
+
// Invalid but common: a list directly inside a list. Attach it
|
|
337
|
+
// to the previous item.
|
|
338
|
+
$nested = $this->indent( $this->list_block( $child, 'ol' === $tag ), ' ' );
|
|
339
|
+
if ( $items ) {
|
|
340
|
+
$items[ count( $items ) - 1 ] .= "\n" . $nested;
|
|
341
|
+
} else {
|
|
342
|
+
$items[] = $nested;
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
$marker = $ordered ? $n . '. ' : '- ';
|
|
347
|
+
$item = $this->list_item( $child, $marker );
|
|
348
|
+
if ( '' === $item ) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
foreach ( $child->childNodes as $grand ) {
|
|
352
|
+
if ( XML_ELEMENT_NODE === $grand->nodeType && 'p' === strtolower( $grand->nodeName ) ) {
|
|
353
|
+
$loose = true;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
$items[] = $item;
|
|
357
|
+
++$n;
|
|
358
|
+
}
|
|
359
|
+
return implode( $loose ? "\n\n" : "\n", $items );
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* One list item: marker on the first line, the rest indented to match.
|
|
364
|
+
*
|
|
365
|
+
* @param DOMElement $li The item.
|
|
366
|
+
* @param string $marker "- " or "N. ".
|
|
367
|
+
* @return string
|
|
368
|
+
*/
|
|
369
|
+
private function list_item( DOMElement $li, $marker ) {
|
|
370
|
+
$blocks = $this->blocks( $li );
|
|
371
|
+
if ( ! $blocks ) {
|
|
372
|
+
return '';
|
|
373
|
+
}
|
|
374
|
+
$tight = true;
|
|
375
|
+
foreach ( $li->childNodes as $c ) {
|
|
376
|
+
if ( XML_ELEMENT_NODE === $c->nodeType && 'p' === strtolower( $c->nodeName ) ) {
|
|
377
|
+
$tight = false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
$content = implode( $tight ? "\n" : "\n\n", $blocks );
|
|
381
|
+
$pad = str_repeat( ' ', strlen( $marker ) );
|
|
382
|
+
$lines = explode( "\n", $content );
|
|
383
|
+
$out = $marker . array_shift( $lines );
|
|
384
|
+
foreach ( $lines as $line ) {
|
|
385
|
+
$out .= "\n" . ( '' === $line ? '' : $pad . $line );
|
|
386
|
+
}
|
|
387
|
+
return $out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Indent every non-empty line.
|
|
392
|
+
*
|
|
393
|
+
* @param string $text Text.
|
|
394
|
+
* @param string $pad Indent.
|
|
395
|
+
* @return string
|
|
396
|
+
*/
|
|
397
|
+
private function indent( $text, $pad ) {
|
|
398
|
+
$lines = explode( "\n", $text );
|
|
399
|
+
foreach ( $lines as $i => $line ) {
|
|
400
|
+
if ( '' !== $line ) {
|
|
401
|
+
$lines[ $i ] = $pad . $line;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return implode( "\n", $lines );
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* GFM table. The first row is the header row (GFM needs one).
|
|
409
|
+
*
|
|
410
|
+
* @param DOMElement $table The table.
|
|
411
|
+
* @return string
|
|
412
|
+
*/
|
|
413
|
+
private function table( DOMElement $table ) {
|
|
414
|
+
$rows = array();
|
|
415
|
+
$caption = '';
|
|
416
|
+
$collect = function ( DOMNode $node ) use ( &$collect, &$rows, &$caption ) {
|
|
417
|
+
foreach ( $node->childNodes as $child ) {
|
|
418
|
+
if ( XML_ELEMENT_NODE !== $child->nodeType ) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
$tag = strtolower( $child->nodeName );
|
|
422
|
+
if ( 'caption' === $tag ) {
|
|
423
|
+
$caption = $this->one_line( $this->inline_children( $child ) );
|
|
424
|
+
} elseif ( 'thead' === $tag || 'tbody' === $tag || 'tfoot' === $tag ) {
|
|
425
|
+
$collect( $child );
|
|
426
|
+
} elseif ( 'tr' === $tag ) {
|
|
427
|
+
$cells = array();
|
|
428
|
+
foreach ( $child->childNodes as $cell ) {
|
|
429
|
+
if ( XML_ELEMENT_NODE === $cell->nodeType && in_array( strtolower( $cell->nodeName ), array( 'td', 'th' ), true ) ) {
|
|
430
|
+
$cells[] = $this->cell( $cell );
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if ( $cells ) {
|
|
434
|
+
$rows[] = $cells;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
$collect( $table );
|
|
440
|
+
if ( ! $rows ) {
|
|
441
|
+
return $caption;
|
|
442
|
+
}
|
|
443
|
+
$width = 0;
|
|
444
|
+
foreach ( $rows as $r ) {
|
|
445
|
+
$width = max( $width, count( $r ) );
|
|
446
|
+
}
|
|
447
|
+
$lines = array();
|
|
448
|
+
foreach ( $rows as $i => $r ) {
|
|
449
|
+
$r = array_pad( $r, $width, '' );
|
|
450
|
+
$lines[] = '| ' . implode( ' | ', $r ) . ' |';
|
|
451
|
+
if ( 0 === $i ) {
|
|
452
|
+
$lines[] = '|' . str_repeat( ' --- |', $width );
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
$out = implode( "\n", $lines );
|
|
456
|
+
return '' !== $caption ? $caption . "\n\n" . $out : $out;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* One table cell on one line, pipes escaped.
|
|
461
|
+
*
|
|
462
|
+
* @param DOMElement $cell Cell.
|
|
463
|
+
* @return string
|
|
464
|
+
*/
|
|
465
|
+
private function cell( DOMElement $cell ) {
|
|
466
|
+
$text = implode( ' ', $this->blocks( $cell ) );
|
|
467
|
+
$text = $this->one_line( $text );
|
|
468
|
+
return str_replace( '|', '\\|', $text );
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── inline ────────────────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Inline Markdown for all children of a node.
|
|
475
|
+
*
|
|
476
|
+
* @param DOMNode $node Node.
|
|
477
|
+
* @return string
|
|
478
|
+
*/
|
|
479
|
+
private function inline_children( DOMNode $node ) {
|
|
480
|
+
$out = '';
|
|
481
|
+
foreach ( $node->childNodes as $child ) {
|
|
482
|
+
$out .= $this->inline( $child );
|
|
483
|
+
}
|
|
484
|
+
return $out;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Inline Markdown for one node.
|
|
489
|
+
*
|
|
490
|
+
* @param DOMNode $node Node.
|
|
491
|
+
* @return string
|
|
492
|
+
*/
|
|
493
|
+
private function inline( DOMNode $node ) {
|
|
494
|
+
if ( XML_TEXT_NODE === $node->nodeType || XML_CDATA_SECTION_NODE === $node->nodeType ) {
|
|
495
|
+
return $this->text( $node->nodeValue );
|
|
496
|
+
}
|
|
497
|
+
if ( XML_ELEMENT_NODE !== $node->nodeType ) {
|
|
498
|
+
return '';
|
|
499
|
+
}
|
|
500
|
+
$tag = strtolower( $node->nodeName );
|
|
501
|
+
switch ( $tag ) {
|
|
502
|
+
case 'br':
|
|
503
|
+
return "\n";
|
|
504
|
+
case 'strong':
|
|
505
|
+
case 'b':
|
|
506
|
+
return $this->wrap( $this->inline_children( $node ), '**' );
|
|
507
|
+
case 'em':
|
|
508
|
+
case 'i':
|
|
509
|
+
case 'cite':
|
|
510
|
+
return $this->wrap( $this->inline_children( $node ), '_' );
|
|
511
|
+
case 'del':
|
|
512
|
+
case 's':
|
|
513
|
+
case 'strike':
|
|
514
|
+
return $this->wrap( $this->inline_children( $node ), '~~' );
|
|
515
|
+
case 'code':
|
|
516
|
+
case 'kbd':
|
|
517
|
+
case 'samp':
|
|
518
|
+
return $this->inline_code( $node->textContent );
|
|
519
|
+
case 'a':
|
|
520
|
+
return $this->link( $node );
|
|
521
|
+
case 'img':
|
|
522
|
+
return $this->image( $node );
|
|
523
|
+
case 'pre':
|
|
524
|
+
return ' ' . $this->inline_code( $node->textContent ) . ' ';
|
|
525
|
+
default:
|
|
526
|
+
if ( $this->is_block( $node ) ) {
|
|
527
|
+
// A block inside inline context (e.g. a div in a link):
|
|
528
|
+
// keep its text on the line.
|
|
529
|
+
return ' ' . $this->inline_children( $node ) . ' ';
|
|
530
|
+
}
|
|
531
|
+
return $this->inline_children( $node );
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Escape text and drop shortcode leftovers.
|
|
537
|
+
*
|
|
538
|
+
* @param string $text Raw text.
|
|
539
|
+
* @return string
|
|
540
|
+
*/
|
|
541
|
+
private function text( $text ) {
|
|
542
|
+
$text = str_replace( "\xC2\xA0", ' ', (string) $text );
|
|
543
|
+
$text = $this->strip_shortcodes( $text );
|
|
544
|
+
$text = preg_replace( '/\s+/u', ' ', $text );
|
|
545
|
+
if ( null === $text ) {
|
|
546
|
+
return '';
|
|
547
|
+
}
|
|
548
|
+
return preg_replace( '/([\\\\`*_\[\]])/', '\\\\$1', $text );
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Remove shortcodes a builder left unrendered: any [/name], any
|
|
553
|
+
* [name attr=...], and a bare [name] only when [/name] also appears (so
|
|
554
|
+
* "[sic]" or "[1]" in prose survive).
|
|
555
|
+
*
|
|
556
|
+
* @param string $text Text.
|
|
557
|
+
* @return string
|
|
558
|
+
*/
|
|
559
|
+
private function strip_shortcodes( $text ) {
|
|
560
|
+
if ( false === strpos( $text, '[' ) ) {
|
|
561
|
+
return $text;
|
|
562
|
+
}
|
|
563
|
+
$closing = $this->closing_shortcodes;
|
|
564
|
+
$result = preg_replace_callback(
|
|
565
|
+
'#\[(/?)([a-zA-Z][\w-]*)((?:\s[^\[\]]*)?)/?\]#',
|
|
566
|
+
function ( $m ) use ( $closing ) {
|
|
567
|
+
if ( '/' === $m[1] ) {
|
|
568
|
+
return '';
|
|
569
|
+
}
|
|
570
|
+
if ( false !== strpos( $m[3], '=' ) ) {
|
|
571
|
+
return '';
|
|
572
|
+
}
|
|
573
|
+
return in_array( strtolower( $m[2] ), $closing, true ) ? '' : $m[0];
|
|
574
|
+
},
|
|
575
|
+
$text
|
|
576
|
+
);
|
|
577
|
+
return null === $result ? $text : $result;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Wrap inline content in a delimiter, keeping outer spaces outside.
|
|
582
|
+
*
|
|
583
|
+
* @param string $inner Inline Markdown.
|
|
584
|
+
* @param string $mark Delimiter.
|
|
585
|
+
* @return string
|
|
586
|
+
*/
|
|
587
|
+
private function wrap( $inner, $mark ) {
|
|
588
|
+
if ( '' === trim( $inner ) ) {
|
|
589
|
+
return $inner;
|
|
590
|
+
}
|
|
591
|
+
preg_match( '/^(\s*)(.*?)(\s*)$/s', $inner, $m );
|
|
592
|
+
return $m[1] . $mark . $m[2] . $mark . $m[3];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Inline code span with a fence longer than any backtick run inside.
|
|
597
|
+
*
|
|
598
|
+
* @param string $code Raw code.
|
|
599
|
+
* @return string
|
|
600
|
+
*/
|
|
601
|
+
private function inline_code( $code ) {
|
|
602
|
+
$code = preg_replace( '/\s*\n\s*/', ' ', str_replace( "\xC2\xA0", ' ', (string) $code ) );
|
|
603
|
+
if ( '' === trim( $code ) ) {
|
|
604
|
+
return '';
|
|
605
|
+
}
|
|
606
|
+
$fence = '`';
|
|
607
|
+
if ( preg_match_all( '/`+/', $code, $m ) ) {
|
|
608
|
+
$fence = str_repeat( '`', max( array_map( 'strlen', $m[0] ) ) + 1 );
|
|
609
|
+
}
|
|
610
|
+
$pad = ( '`' === substr( $code, 0, 1 ) || '`' === substr( $code, -1 ) ) ? ' ' : '';
|
|
611
|
+
return $fence . $pad . $code . $pad . $fence;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* A link. javascript: links and links without text are unwrapped.
|
|
616
|
+
*
|
|
617
|
+
* @param DOMElement $a Anchor.
|
|
618
|
+
* @return string
|
|
619
|
+
*/
|
|
620
|
+
private function link( DOMElement $a ) {
|
|
621
|
+
$inner = $this->one_line( $this->inline_children( $a ) );
|
|
622
|
+
$href = trim( $a->getAttribute( 'href' ) );
|
|
623
|
+
if ( '' === $href ) {
|
|
624
|
+
return $inner;
|
|
625
|
+
}
|
|
626
|
+
$abs = self::absolute( $href, $this->base );
|
|
627
|
+
if ( null === $abs || preg_match( '/^\s*(javascript|data|vbscript):/i', $abs ) ) {
|
|
628
|
+
return $inner;
|
|
629
|
+
}
|
|
630
|
+
if ( '' === $inner ) {
|
|
631
|
+
return '';
|
|
632
|
+
}
|
|
633
|
+
return '[' . $inner . '](' . self::url_for_markdown( $abs ) . ')';
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* An image: alt + absolute src. Lazy-load attributes are preferred over a
|
|
638
|
+
* data: placeholder; emoji images become their alt text.
|
|
639
|
+
*
|
|
640
|
+
* @param DOMElement $img Image.
|
|
641
|
+
* @return string
|
|
642
|
+
*/
|
|
643
|
+
private function image( DOMElement $img ) {
|
|
644
|
+
$alt = $this->one_line( str_replace( "\xC2\xA0", ' ', $img->getAttribute( 'alt' ) ) );
|
|
645
|
+
if ( preg_match( '/(^|\s)(emoji|wp-smiley)(\s|$)/', $img->getAttribute( 'class' ) ) ) {
|
|
646
|
+
return $alt;
|
|
647
|
+
}
|
|
648
|
+
$src = '';
|
|
649
|
+
foreach ( array( 'data-lazy-src', 'data-src', 'src' ) as $attr ) {
|
|
650
|
+
$v = trim( $img->getAttribute( $attr ) );
|
|
651
|
+
if ( '' !== $v && ! preg_match( '/^data:/i', $v ) ) {
|
|
652
|
+
$src = $v;
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if ( '' === $src ) {
|
|
657
|
+
return '';
|
|
658
|
+
}
|
|
659
|
+
$abs = self::absolute( $src, $this->base );
|
|
660
|
+
if ( null === $abs || ! preg_match( '#^https?://#i', $abs ) ) {
|
|
661
|
+
return '';
|
|
662
|
+
}
|
|
663
|
+
$alt = str_replace( array( '[', ']' ), array( '\\[', '\\]' ), $alt );
|
|
664
|
+
return ' . ')';
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Collapse whitespace to one line.
|
|
669
|
+
*
|
|
670
|
+
* @param string $s Text.
|
|
671
|
+
* @return string
|
|
672
|
+
*/
|
|
673
|
+
private function one_line( $s ) {
|
|
674
|
+
return trim( preg_replace( '/\s+/u', ' ', (string) $s ) );
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ── URLs ──────────────────────────────────────────────────────────────
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Spaces and parentheses would end a Markdown link destination.
|
|
681
|
+
*
|
|
682
|
+
* @param string $url URL.
|
|
683
|
+
* @return string
|
|
684
|
+
*/
|
|
685
|
+
public static function url_for_markdown( $url ) {
|
|
686
|
+
return str_replace( array( ' ', '(', ')' ), array( '%20', '%28', '%29' ), $url );
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Resolve $href against $base (RFC 3986, enough for HTML attributes).
|
|
691
|
+
*
|
|
692
|
+
* @param string $href Reference.
|
|
693
|
+
* @param string $base Absolute base URL.
|
|
694
|
+
* @return string|null
|
|
695
|
+
*/
|
|
696
|
+
public static function absolute( $href, $base ) {
|
|
697
|
+
$href = trim( (string) $href );
|
|
698
|
+
if ( '' === $href ) {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
if ( preg_match( '#^[a-zA-Z][a-zA-Z0-9+.-]*:#', $href ) ) {
|
|
702
|
+
return $href;
|
|
703
|
+
}
|
|
704
|
+
$b = parse_url( (string) $base );
|
|
705
|
+
if ( ! $b || empty( $b['scheme'] ) || empty( $b['host'] ) ) {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
$origin = $b['scheme'] . '://' . $b['host'] . ( isset( $b['port'] ) ? ':' . $b['port'] : '' );
|
|
709
|
+
if ( 0 === strpos( $href, '//' ) ) {
|
|
710
|
+
return $b['scheme'] . ':' . $href;
|
|
711
|
+
}
|
|
712
|
+
$base_path = isset( $b['path'] ) && '' !== $b['path'] ? $b['path'] : '/';
|
|
713
|
+
$base_query = isset( $b['query'] ) ? '?' . $b['query'] : '';
|
|
714
|
+
if ( '#' === $href[0] ) {
|
|
715
|
+
return $origin . $base_path . $base_query . $href;
|
|
716
|
+
}
|
|
717
|
+
if ( '?' === $href[0] ) {
|
|
718
|
+
return $origin . $base_path . $href;
|
|
719
|
+
}
|
|
720
|
+
if ( '/' === $href[0] ) {
|
|
721
|
+
$path = $href;
|
|
722
|
+
} else {
|
|
723
|
+
$dir = substr( $base_path, 0, strrpos( $base_path, '/' ) + 1 );
|
|
724
|
+
$path = $dir . $href;
|
|
725
|
+
}
|
|
726
|
+
// Split off query / fragment before removing dot segments.
|
|
727
|
+
$suffix = '';
|
|
728
|
+
$cut = strcspn( $path, '?#' );
|
|
729
|
+
if ( $cut < strlen( $path ) ) {
|
|
730
|
+
$suffix = substr( $path, $cut );
|
|
731
|
+
$path = substr( $path, 0, $cut );
|
|
732
|
+
}
|
|
733
|
+
return $origin . self::remove_dot_segments( $path ) . $suffix;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* RFC 3986 5.2.4.
|
|
738
|
+
*
|
|
739
|
+
* @param string $path Path.
|
|
740
|
+
* @return string
|
|
741
|
+
*/
|
|
742
|
+
private static function remove_dot_segments( $path ) {
|
|
743
|
+
$out = array();
|
|
744
|
+
foreach ( explode( '/', $path ) as $i => $seg ) {
|
|
745
|
+
if ( '.' === $seg ) {
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if ( '..' === $seg ) {
|
|
749
|
+
if ( count( $out ) > 1 ) {
|
|
750
|
+
array_pop( $out );
|
|
751
|
+
}
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
$out[] = $seg;
|
|
755
|
+
}
|
|
756
|
+
$result = implode( '/', $out );
|
|
757
|
+
if ( preg_match( '#/\.\.?$#', $path ) ) {
|
|
758
|
+
$result .= '/';
|
|
759
|
+
}
|
|
760
|
+
return '' === $result || '/' !== $result[0] ? '/' . ltrim( $result, '/' ) : $result;
|
|
761
|
+
}
|
|
762
|
+
}
|