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;
}
}