/', '', $html);
$html = preg_replace('##is', '', $html);
$html = preg_replace('##is', '', $html);
$html = preg_replace('#]*>(.*?) #is', '', $html);
// Convert YouTube/Vimeo iframes to markdown links (must run before generic iframe strip).
$html = preg_replace_callback(
'##is',
function ($m) {
$src = $m[1];
$title = isset($m[2]) && $m[2] !== '' ? $m[2] : 'Video';
if (preg_match('#(youtube\.com|youtu\.be|vimeo\.com)#i', $src)) {
return "\n[" . $title . '](' . $src . ")\n";
}
return '';
},
$html
);
// Convert to newline and
to ---.
$html = preg_replace('# #i', "\n", $html);
$html = preg_replace('# #i', "\n\n---\n\n", $html);
// Headings h1-h6.
for ($i = 1; $i <= 6; $i++) {
$hashes = str_repeat('#', $i);
$html = preg_replace_callback(
'#]*>(.*?) #is',
function ($m) use ($hashes) {
$text = self::strip_tags_preserve(trim($m[1]));
return "\n\n" . $hashes . ' ' . $text . "\n\n";
},
$html
);
}
// Bold.
$html = preg_replace_callback(
'#<(?:strong|b)\b[^>]*>(.*?)(?:strong|b)>#is',
function ($m) { return '**' . trim(self::strip_tags_preserve($m[1])) . '**'; },
$html
);
// Italic.
$html = preg_replace_callback(
'#<(?:em|i)\b[^>]*>(.*?)(?:em|i)>#is',
function ($m) { return '*' . trim(self::strip_tags_preserve($m[1])) . '*'; },
$html
);
// Images -> .
$html = preg_replace_callback(
'# ]*)/?>#i',
function ($m) {
$attrs = $m[1];
$alt = '';
$src = '';
if (preg_match('#\balt=["\']([^"\']*)["\']#i', $attrs, $a)) {
$alt = $a[1];
}
if (preg_match('#\bsrc=["\']([^"\']+)["\']#i', $attrs, $s)) {
$src = $s[1];
}
if ($src === '') {
return '';
}
return '';
},
$html
);
// Links -> [text](url).
$html = preg_replace_callback(
'#]*href=["\']([^"\']+)["\'][^>]*>(.*?) #is',
function ($m) {
$href = $m[1];
$text = trim(self::strip_tags_preserve($m[2]));
if ($text === '') {
$text = $href;
}
return '[' . $text . '](' . $href . ')';
},
$html
);
// Fenced code blocks.
$html = preg_replace_callback(
'#]*>\s*]*>(.*?)\s* #is',
function ($m) {
$code = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
return "\n\n```\n" . trim($code) . "\n```\n\n";
},
$html
);
$html = preg_replace_callback(
'#]*>(.*?) #is',
function ($m) {
$code = html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return "\n\n```\n" . trim($code) . "\n```\n\n";
},
$html
);
// Inline code.
$html = preg_replace_callback(
'#]*>(.*?)#is',
function ($m) {
$code = html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return '`' . $code . '`';
},
$html
);
// Blockquotes.
$html = preg_replace_callback(
'#]*>(.*?) #is',
function ($m) {
$inner = trim(self::strip_tags_preserve($m[1]));
$lines = preg_split('/\r?\n/', $inner);
$lines = array_map(function ($l) { return '> ' . ltrim($l); }, $lines);
return "\n\n" . implode("\n", $lines) . "\n\n";
},
$html
);
// Tables.
$html = preg_replace_callback(
'##is',
[self::class, 'convert_table'],
$html
);
// Lists (nested supported via recursion).
$html = self::convert_lists($html);
// Paragraphs.
$html = preg_replace_callback(
'#]*>(.*?)
#is',
function ($m) {
$text = trim(self::strip_tags_preserve($m[1]));
if ($text === '') {
return '';
}
return "\n\n" . $text . "\n\n";
},
$html
);
// Strip any remaining tags but keep the inner text.
$html = self::strip_tags_preserve($html);
// Decode entities after stripping tags.
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Collapse excessive blank lines.
$html = preg_replace("/\n{3,}/", "\n\n", $html);
return trim($html);
}
/**
* Convert a WordPress post to markdown.
*
* Runs `the_content` filter and do_shortcode() first so page-builder
* content (Elementor, Divi, Beaver Builder, etc.) is rendered before
* conversion.
*
* @param int $post_id Post ID.
* @param array $options {
* Optional. {
* @type bool $include_frontmatter Prepend YAML frontmatter. Default true.
* @type bool $include_featured_image Prepend featured image as markdown. Default true.
* }
* }
* @return string Markdown output (empty string if post not found).
*/
public static function convert_post($post_id, $options = [])
{
$post = get_post(absint($post_id));
if (!$post) {
return '';
}
$options = wp_parse_args($options, [
'include_frontmatter' => true,
'include_featured_image' => true,
]);
$raw_content = $post->post_content;
$filtered = apply_filters('the_content', $raw_content);
$filtered = do_shortcode($filtered);
$markdown = self::convert($filtered);
$prefix = '';
if (!empty($options['include_frontmatter'])) {
$author_name = '';
if (!empty($post->post_author)) {
$author = get_userdata($post->post_author);
if ($author && !empty($author->display_name)) {
$author_name = $author->display_name;
}
}
$frontmatter = "---\n";
$frontmatter .= 'title: ' . self::yaml_escape($post->post_title) . "\n";
$frontmatter .= 'slug: ' . self::yaml_escape($post->post_name) . "\n";
$frontmatter .= 'date: ' . self::yaml_escape($post->post_date) . "\n";
if ($author_name !== '') {
$frontmatter .= 'author: ' . self::yaml_escape($author_name) . "\n";
}
$frontmatter .= "---\n\n";
$prefix .= $frontmatter;
}
if (!empty($options['include_featured_image'])) {
$thumb_id = get_post_thumbnail_id($post_id);
if ($thumb_id) {
$src = wp_get_attachment_url($thumb_id);
if ($src) {
$alt = get_post_meta($thumb_id, '_wp_attachment_image_alt', true);
if (!is_string($alt)) {
$alt = '';
}
if ($alt === '') {
$alt = $post->post_title;
}
$prefix .= '\n\n";
}
}
}
return $prefix . $markdown;
}
/**
* Escape a string for safe use as a YAML scalar value.
*
* @param string $value
* @return string
*/
private static function yaml_escape($value)
{
$value = (string) $value;
// Wrap in double quotes and escape backslashes and quotes.
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
return '"' . $escaped . '"';
}
/**
* Strip HTML tags but leave a usable inline form (decodes entities later).
*
* @param string $html
* @return string
*/
private static function strip_tags_preserve($html)
{
// Replace block-ish containers with newlines so stripping doesn't mash content.
$html = preg_replace('#(?:div|section|article|aside|header|footer|main|nav|figure|figcaption)>#i', "\n", $html);
$html = preg_replace('#<(?:div|section|article|aside|header|footer|main|nav|figure|figcaption)\b[^>]*>#i', '', $html);
// Strip remaining tags.
return strip_tags($html);
}
/**
* Convert a block into a pipe-style markdown table.
*
* @param array $matches Regex matches from convert(); index 1 is inner HTML.
* @return string
*/
private static function convert_table($matches)
{
$inner = $matches[1];
// Flatten any thead/tbody wrappers for easier row extraction.
$inner = preg_replace('#?(?:thead|tbody|tfoot)\b[^>]*>#i', '', $inner);
if (!preg_match_all('#]*>(.*?) #is', $inner, $rows_match)) {
return '';
}
$rows = [];
$header_used = false;
foreach ($rows_match[1] as $row_index => $row_html) {
$cells = [];
$is_header_row = false;
if (preg_match_all('#<(th|td)\b[^>]*>(.*?)\1>#is', $row_html, $cell_match, PREG_SET_ORDER)) {
foreach ($cell_match as $cm) {
$cell_text = trim(self::strip_tags_preserve($cm[2]));
$cell_text = html_entity_decode($cell_text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$cell_text = str_replace(['|', "\n", "\r"], ['\\|', ' ', ' '], $cell_text);
$cells[] = $cell_text;
if (strtolower($cm[1]) === 'th') {
$is_header_row = true;
}
}
}
if (empty($cells)) {
continue;
}
$rows[] = ['cells' => $cells, 'is_header' => $is_header_row];
}
if (empty($rows)) {
return '';
}
$output = "\n\n";
$col_count = 0;
foreach ($rows as $row) {
if (count($row['cells']) > $col_count) {
$col_count = count($row['cells']);
}
}
// Header row: use first header row, else synthesise from first row.
$header_row = null;
foreach ($rows as $idx => $row) {
if ($row['is_header']) {
$header_row = $row;
unset($rows[$idx]);
break;
}
}
if ($header_row === null) {
$header_row = array_shift($rows);
}
$header_cells = array_pad($header_row['cells'], $col_count, '');
$output .= '| ' . implode(' | ', $header_cells) . " |\n";
$output .= '|' . str_repeat(' --- |', $col_count) . "\n";
foreach ($rows as $row) {
$cells = array_pad($row['cells'], $col_count, '');
$output .= '| ' . implode(' | ', $cells) . " |\n";
}
return $output . "\n";
}
/**
* Convert / lists to markdown, preserving nesting.
*
* Iteratively replaces the inner-most list until no lists remain.
*
* @param string $html
* @return string
*/
private static function convert_lists($html)
{
// Loop until there are no lists left (handles nesting).
$guard = 0;
while ($guard < 50 && preg_match('#<(ul|ol)\b[^>]*>((?:(?!#is', $html, $m, PREG_OFFSET_CAPTURE)) {
$tag = strtolower($m[1][0]);
$inner = $m[2][0];
$full = $m[0][0];
$offset = $m[0][1];
$converted = self::render_list_items($inner, $tag, 0);
$html = substr($html, 0, $offset) . "\n\n" . $converted . "\n\n" . substr($html, $offset + strlen($full));
$guard++;
}
// Clean up anything left unconverted (defensive).
$html = preg_replace('#?(?:ul|ol)\b[^>]*>#i', '', $html);
$html = preg_replace('#]*>#i', "- ", $html);
$html = preg_replace('# #i', "\n", $html);
return $html;
}
/**
* Render items for a given list tag.
*
* @param string $inner Inner HTML of the list.
* @param string $tag 'ul' or 'ol'.
* @param int $depth Current nesting depth.
* @return string
*/
private static function render_list_items($inner, $tag, $depth)
{
if (!preg_match_all('# ]*>(.*?) #is', $inner, $items)) {
return '';
}
$out = '';
$indent = str_repeat(' ', max(0, $depth));
$counter = 1;
foreach ($items[1] as $item_html) {
// Recursively convert nested lists inside the .
$nested_converted = $item_html;
while (preg_match('#<(ul|ol)\b[^>]*>((?:(?!#is', $nested_converted, $m, PREG_OFFSET_CAPTURE)) {
$inner_nested = $m[2][0];
$inner_tag = strtolower($m[1][0]);
$sub = self::render_list_items($inner_nested, $inner_tag, $depth + 1);
$nested_converted = substr($nested_converted, 0, $m[0][1])
. "\n" . $sub
. substr($nested_converted, $m[0][1] + strlen($m[0][0]));
}
$text = trim(self::strip_tags_preserve($nested_converted));
if ($text === '') {
continue;
}
$marker = ($tag === 'ol') ? ($counter . '.') : '-';
// The line itself.
$lines = preg_split('/\r?\n/', $text);
$first = array_shift($lines);
$out .= $indent . $marker . ' ' . ltrim($first) . "\n";
foreach ($lines as $line) {
if (trim($line) === '') {
continue;
}
$out .= $indent . ' ' . ltrim($line) . "\n";
}
$counter++;
}
return rtrim($out, "\n");
}
}