*/ 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( '
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 /
to markdown, handling nesting via indentation. * * @param \DOMNode $node Node. * @param string $type ul|ol. * @return string */ private static function list_node( $node, $type ) { $lines = array(); $i = 1; foreach ( $node->childNodes as $child ) { if ( XML_ELEMENT_NODE !== $child->nodeType || 'li' !== strtolower( $child->nodeName ) ) { continue; } $marker = 'ol' === $type ? ( $i++ . '.' ) : '-'; $content = trim( self::children( $child ) ); $content = preg_replace( "/\n/", "\n ", $content ); $lines[] = $marker . ' ' . $content; } return implode( "\n", $lines ); } /** * One honest line for a player element. Names what the media IS and where it * lives — never what it says. * * The noun is only "Video"/"Audio" where the markup proves it: a bare *