*/ private static $media_notes = array(); /** * A single post/page rendered as standalone markdown with small front matter. * * @param int $post_id Post ID. * @return string */ public static function post( $post_id ) { $post = get_post( $post_id ); // Never expose what the HTML site itself gates: non-published statuses and // password-protected posts — whose body would otherwise be dumped in full // to any anonymous client via .md, Accept negotiation, or /llms-full.txt. if ( ! $post || 'publish' !== $post->post_status || '' !== (string) $post->post_password ) { return "# Not found\n"; } $title = self::text( get_the_title( $post ) ); $url = get_permalink( $post ); $out = '# ' . $title . "\n\n"; $meta = array(); if ( 'post' === $post->post_type ) { $meta[] = get_the_date( 'Y-m-d', $post ); $author = get_the_author_meta( 'display_name', $post->post_author ); if ( $author ) { $meta[] = 'by ' . $author; } $cats = wp_get_post_terms( $post->ID, 'category', array( 'fields' => 'names' ) ); if ( ! is_wp_error( $cats ) && ! empty( $cats ) ) { // Term names are stored HTML-escaped ("Tips & Tricks"); markdown wants text. $meta[] = 'in ' . html_entity_decode( implode( ', ', $cats ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } } $meta[] = '<' . $url . '>'; $out .= '*' . implode( ' · ', $meta ) . "*\n\n"; // Per-page AI description as a lead blockquote — a one-line summary so an agent // fetching this .md knows what the page is before reading the body, even when the // JSON-LD stands down for an SEO plugin. Same defensive contract as the topics/body // below: this serves UNAUTHENTICATED endpoints, so a filter that throws must degrade // to the rest of the document, never fatal the request. try { $summary = Description::for_post( $post ); } catch ( \Throwable $e ) { $summary = ''; } if ( '' !== $summary ) { $out .= '> ' . self::text( $summary ) . "\n\n"; } // Per-page AI topics as a front-matter line — an agent fetching this .md gets // what the page is about even when the JSON-LD stands down for an SEO plugin. // Topics::for_post() runs the `agentimus_topics` filter (third-party code), and // the body below runs `the_content` — every shortcode, block and page-builder // callback on the site. This method serves UNAUTHENTICATED endpoints (/slug.md, // `Accept: text/markdown`, and each item of /llms-full.txt), so a single post // whose filters throw must degrade to title + metadata, never fatal the request // (or, in the full-text loop, take the whole document down with it). try { $topics = Topics::for_post( $post ); } catch ( \Throwable $e ) { $topics = array(); } if ( ! empty( $topics ) ) { $out .= 'Topics: ' . implode( ', ', $topics ) . "\n\n"; } // The owner's lines about this post's media, so each player in the body can // be followed by what it actually holds. Cleared in the finally below: a // leftover map would annotate the NEXT post's players with this one's notes. try { // resolve(), not all(): notes are STORED against the URL the author saw, // and looked up here by media identity — the same video arrives as // `watch?v=ID` in one render and `embed/ID` in another. Reading the raw // map made every lookup miss, so a note written in the editor silently // never reached this document. self::$media_notes = MediaContext::resolve( $post->ID ); } catch ( \Throwable $e ) { self::$media_notes = array(); } try { $out .= self::from_html( Content::markdown_source( $post ) ); } catch ( \Throwable $e ) { // A shortcode/block/page-builder callback inside the_content threw — serve // the title + metadata already assembled rather than 500 the endpoint. $out = rtrim( $out ); } finally { self::$media_notes = array(); } return rtrim( $out ) . "\n"; } /** * Convert a fragment of HTML to clean markdown. * * @param string $html HTML. * @return string */ public static function from_html( $html ) { $html = trim( (string) $html ); if ( '' === $html ) { return ''; } if ( ! class_exists( 'DOMDocument' ) ) { return trim( wp_strip_all_tags( $html ) ) . "\n"; } $dom = new \DOMDocument(); $prev = libxml_use_internal_errors( true ); $dom->loadHTML( '
' . $html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); libxml_use_internal_errors( $prev ); $root = $dom->getElementById( 'agentimus-md-root' ); $md = $root ? self::children( $root ) : ''; $md = preg_replace( "/[ \t]+\n/", "\n", $md ); $md = preg_replace( "/\n{3,}/", "\n\n", $md ); return trim( $md ) . "\n"; } /** * Concatenate the markdown of a node's children. * * @param \DOMNode $node Parent node. * @return string */ private static function children( $node ) { $out = ''; foreach ( $node->childNodes as $child ) { $out .= self::node( $child ); } return $out; } /** * Render one DOM node to markdown. * * @param \DOMNode $node Node. * @return string */ private static function node( $node ) { if ( XML_TEXT_NODE === $node->nodeType ) { return preg_replace( '/\s+/', ' ', $node->nodeValue ); } if ( XML_ELEMENT_NODE !== $node->nodeType ) { return ''; } $tag = strtolower( $node->nodeName ); $role = strtolower( trim( (string) $node->getAttribute( 'role' ) ) ); // Tabbed UIs (core/tabs in WP 7.1, and any ARIA-correct page-builder tabs): // the tab strip duplicates each panel's label as a row of buttons that would // render as one run-on word blob — skip the strip, and re-attach each label // to its own panel below via aria-labelledby. if ( 'tablist' === $role ) { return ''; } if ( 'table' === $tag ) { $t = self::table_node( $node ); return '' === $t ? '' : "\n\n" . $t . "\n\n"; } // core/playlist (WP 7.1) tracks: the title/artist/length spans inside the // track button would otherwise run together ("Opening TrackArtist One3:21"). if ( 'button' === $tag && self::has_class( $node, 'wp-block-playlist-track__button' ) ) { $line = self::playlist_track( $node ); if ( '' !== $line ) { return $line; } } $inner = self::children( $node ); if ( 'tabpanel' === $role ) { $t = trim( $inner ); if ( '' === $t ) { return ''; } $label = self::labelled_by( $node ); return "\n\n" . ( '' === $label ? '' : '**' . $label . "**\n\n" ) . $t . "\n\n"; } switch ( $tag ) { case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': $level = (int) substr( $tag, 1 ); return "\n\n" . str_repeat( '#', $level ) . ' ' . trim( $inner ) . "\n\n"; case 'p': return "\n\n" . trim( $inner ) . "\n\n"; case 'br': return " \n"; case 'hr': return "\n\n---\n\n"; case 'strong': case 'b': $t = trim( $inner ); return '' === $t ? '' : '**' . $t . '**'; case 'em': case 'i': $t = trim( $inner ); return '' === $t ? '' : '*' . $t . '*'; case 'del': case 's': $t = trim( $inner ); return '' === $t ? '' : '~~' . $t . '~~'; case 'code': return '`' . trim( $node->textContent ) . '`'; case 'pre': // Not textContent: wpautop renders line breaks inside core/preformatted // and core/verse as
, which textContent silently drops — flattening // multi-line diagrams and poetry to one line. return "\n\n```\n" . rtrim( self::pre_text( $node ) ) . "\n```\n\n"; case 'a': $href = trim( (string) $node->getAttribute( 'href' ) ); $text = trim( $inner ); if ( '' === $href ) { return $text; } return '[' . ( '' !== $text ? $text : $href ) . '](' . $href . ')'; case 'img': $src = trim( (string) $node->getAttribute( 'src' ) ); $alt = trim( (string) $node->getAttribute( 'alt' ) ); return '' === $src ? '' : '![' . $alt . '](' . $src . ')'; case 'iframe': case 'video': case 'audio': // A player has no text content at all, so it used to leave NOTHING // here — a page built around a video read as an empty page. Name it // and link it: a reader of the plain-text edition at least learns // the media exists and where it is. What it SAYS still has to come // from a transcript ({@see PageCheck::check_video()}); this line // never pretends otherwise. return self::media_line( $node, $tag ); case 'blockquote': $t = trim( $inner ); return "\n\n" . preg_replace( '/^/m', '> ', $t ) . "\n\n"; case 'ul': case 'ol': return "\n\n" . self::list_node( $node, $tag ) . "\n\n"; case 'li': return $inner; // Composed by list_node(). case 'figure': // An embed block with no player element of its own to speak for it. // Two ways that happens, and both leave the reader of this document // unable to tell there is a video here at all: // // - WordPress only swaps an embed's URL for an iframe inside the // loop, and this document is generated OFF the loop — so on a // real site the figure usually arrives carrying a bare URL. // - Script-based providers (TikTok, Instagram) never render an // iframe server-side at all; they render a
and put // the video's address on its `cite` attribute. // // A figure that DOES contain a player is left alone: the player's // own case below already named it, and naming it twice would be // worse than not naming it once. if ( 0 === $node->getElementsByTagName( 'iframe' )->length && 0 === $node->getElementsByTagName( 'video' )->length ) { $embedded = PageCheck::embed_figure_video( $node ); if ( $embedded ) { $line = "\n\n" . __( 'Video', 'agentimus' ) . ': [' . $embedded['url'] . '](' . $embedded['url'] . ")\n" . self::media_note( $embedded['url'] ) . "\n"; // Keep whatever else the figure rendered — a TikTok caption, or // the author's own
— since both are real text an // assistant can quote. The bare URL is stripped out first: an // unresolved embed carries its address as body text, and the // line above already states it. $rest = str_replace( $embedded['url'], '', $inner ); $rest = trim( (string) preg_replace( "/\n{3,}/", "\n\n", $rest ) ); if ( '' !== $rest ) { return $line . $rest . "\n\n"; } return $line; } } $t = trim( $inner ); return '' === $t ? '' : "\n\n" . $t . "\n\n"; case 'figcaption': case 'div': case 'section': case 'article': case 'header': case 'footer': case 'main': case 'nav': case 'aside': $t = trim( $inner ); return '' === $t ? '' : "\n\n" . $t . "\n\n"; default: return $inner; } } /** * Render a