mode() ) { return; } add_action( 'template_redirect', [ $this, 'maybe_serve' ], 4 ); add_action( 'wp_head', [ $this, 'render_link' ], 3 ); // On `wp`, not `send_headers`: WordPress sends headers before it runs // the main query, so `is_singular()` cannot answer yet on that hook. // `wp` fires once the query is settled and before any output. if ( 'negotiate' === $this->mode() ) { add_action( 'wp', [ $this, 'vary_html' ] ); } } /** * Tells caches that the HTML of a singular URL varies with `Accept`. * * In negotiate mode the same URL answers Markdown to an agent and HTML * to a browser; without this header an edge cache keyed on the URL alone * can hand one to the other. The emitter is a parameter because * `header()` is a PHP internal a unit test cannot replace; WordPress * calls this with the `WP` object, which is not callable and so falls * back to `header()`. * * @param mixed $emit Header emitter, `header()` when not callable. * @return void */ public function vary_html( $emit = null ): void { if ( ! is_singular() ) { return; } $emit = is_callable( $emit ) ? $emit : 'header'; $emit( 'Vary: Accept', false ); } /** * Answers with Markdown when this request asked for it. * * @return void */ public function maybe_serve(): void { $post = $this->current_post(); if ( ! $post instanceof WP_Post || ! $this->is_requested() ) { return; } $this->serve( $post ); } /** * Points browsers and crawlers at the Markdown of the page they are reading. * * @return void */ public function render_link(): void { $post = $this->current_post(); if ( ! $post instanceof WP_Post ) { return; } $url = add_query_arg( self::QUERY_ARG, self::QUERY_VALUE, (string) get_permalink( $post ) ); printf( "\n", esc_url( $url ) ); } /** * Whether an `Accept` header prefers Markdown over HTML. * * A type nobody named has no q-value, so `text/markdown` alone wins and * `text/html` alone loses. A wildcard is not read as a vote for Markdown: a * browser sends one at the end of every `Accept` header, and answering those * with Markdown would replace the site with its source. * * Public because it is pure, and unit tested as such. * * @param string $accept Raw `Accept` header. * @return bool True when Markdown is ranked above HTML. */ public static function prefers_markdown( string $accept ): bool { $ranked = self::accept_map( $accept ); $markdown = $ranked['text/markdown'] ?? 0.0; if ( $markdown <= 0.0 ) { return false; } return $markdown > ( $ranked['text/html'] ?? 0.0 ); } /** * One front matter block from an ordered list of fields. * * Public because it is pure, and unit tested as such. * * @param array $fields Field name => value, in the order they are written. * @return string YAML front matter, closed and followed by a blank line. */ public static function front_matter( array $fields ): string { $lines = [ '---' ]; foreach ( $fields as $name => $value ) { $lines[] = $name . ': ' . self::front_matter_value( (string) $value ); } $lines[] = '---'; return implode( "\n", $lines ) . "\n\n"; } /** * One front matter value, quoted when it has to be. * * A title is arbitrary text, and three characters of it decide whether the * block still parses: a colon starts a mapping, a quote starts a string and * a hash starts a comment. Any of them and the value is written as a double * quoted YAML scalar. Newlines never survive: a value is one line. * * Public because it is pure, and unit tested as such. * * @param string $value Raw value. * @return string Value as a YAML scalar. */ public static function front_matter_value( string $value ): string { $value = (string) preg_replace( '/\s+/u', ' ', str_replace( [ "\r\n", "\r", "\n", "\t" ], ' ', $value ) ); $value = trim( $value ); if ( '' === $value ) { return '""'; } if ( preg_match( '/[:"#]/', $value ) ) { return '"' . str_replace( [ '\\', '"' ], [ '\\\\', '\"' ], $value ) . '"'; } return $value; } /** * The whole response body of one post. * * @param WP_Post $post Post being served. * @return string Front matter, the title as an `h1`, then the content as Markdown. */ private function body( WP_Post $post ): string { $post_id = (int) $post->ID; $author = (int) $post->post_author; $title = ContentData::plain_text( get_the_title( $post ) ); $front = self::front_matter( [ 'title' => $title, 'url' => (string) get_permalink( $post ), 'date' => (string) get_post_time( 'c', true, $post ), 'modified' => (string) get_post_modified_time( 'c', true, $post ), 'author' => $author > 0 ? (string) get_the_author_meta( 'display_name', $author ) : '', 'language' => (string) ( SiteData::identity()['language'] ?? '' ), ] ); return $front . '# ' . $title . "\n\n" . ContentData::markdown_for( $post_id ) . "\n"; } /** * The mode the settings are in. * * @return string One of `off`, `query` or `negotiate`. */ private function mode(): string { $mode = (string) Options::get( 'content.markdown', 'query' ); return in_array( $mode, [ 'off', 'query', 'negotiate' ], true ) ? $mode : 'query'; } /** * The post this request would serve, when there is one that may be served. * * A draft, a private post and a password protected one are all left to the * HTML side, which decides who may read them; this endpoint answers for * published, public posts only. * * @return WP_Post|null The post, or null when this is not such a request. */ private function current_post(): ?WP_Post { if ( is_admin() || is_feed() || is_embed() || ! is_singular() ) { return null; } $post = get_post(); if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status ) { return null; } $type = get_post_type_object( (string) $post->post_type ); if ( null === $type || empty( $type->public ) || post_password_required( $post ) ) { return null; } return $post; } /** * Whether this request asked for Markdown. * * @return bool True when the query string or the `Accept` header asked. */ private function is_requested(): bool { $mode = $this->mode(); if ( 'off' === $mode ) { return false; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only public GET; a nonce would keep every agent and every shared cache out. $format = isset( $_GET[ self::QUERY_ARG ] ) ? sanitize_key( wp_unslash( $_GET[ self::QUERY_ARG ] ) ) : ''; if ( self::QUERY_VALUE === $format ) { return true; } if ( 'negotiate' !== $mode ) { return false; } $accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) : ''; return self::prefers_markdown( $accept ); } /** * Sends the Markdown response and ends the request. * * @param WP_Post $post Post being served. * @return void */ private function serve( WP_Post $post ): void { $body = $this->body( $post ); $url = esc_url_raw( (string) get_permalink( $post ) ); status_header( 200 ); header( 'Content-Type: text/markdown; charset=utf-8' ); header( 'Vary: Accept' ); header( 'X-Robots-Tag: noindex' ); header( 'Cache-Control: public, max-age=' . self::MAX_AGE ); if ( '' !== $url ) { header( 'Link: <' . $url . '>; rel="canonical"' ); } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Markdown body sent with a fixed Content-Type; HTML escaping would corrupt it. echo $body; exit; } /** * The media types of an `Accept` header, with their q-values. * * @param string $accept Raw header. * @return array Lower cased media type => q-value. */ private static function accept_map( string $accept ): array { $ranked = []; foreach ( explode( ',', $accept ) as $part ) { $pieces = explode( ';', $part ); $type = strtolower( trim( (string) array_shift( $pieces ) ) ); if ( '' === $type ) { continue; } $ranked[ $type ] = self::quality( $pieces ); } return $ranked; } /** * The q-value of one `Accept` entry. * * @param array $parameters Parameters that followed the media type. * @return float Quality between 0 and 1, defaulting to 1. */ private static function quality( array $parameters ): float { foreach ( $parameters as $parameter ) { $pair = explode( '=', $parameter, 2 ); if ( 'q' !== strtolower( trim( $pair[0] ) ) || ! isset( $pair[1] ) ) { continue; } $quality = (float) trim( $pair[1] ); return max( 0.0, min( 1.0, $quality ) ); } return 1.0; } }