Singular Markdown
Markdown alternates for public singular content at permalink + .md, with Link headers on HTML responses.
by Dmitry Shutko · github.com/shoot56/singular-markdown · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/shoot56/singular-markdown/archive/refs/heads/main.zipDeclares an update source (shoot56/singular-markdown), so updates arrive through the plugin's own updater.
Readme
Singular Markdown
WordPress plugin (theme-agnostic) that:
- Caches a Markdown representation of each eligible singular URL under
wp-content/uploads/singular-markdown-cache/. - Serves it at the same path as the HTML permalink with
.mdappended (rewrite rules), for example/about/→/about.md. - Adds both an HTTP
Linkheader and a<link rel="alternate" type="text/markdown" href="...">tag in<head>on eligible HTML singular/archive responses. - Queues regeneration on save, trash, delete, and supports background full regeneration from Settings → Singular Markdown.
After install or update, open Settings → Permalinks and click Save once if .md URLs return 404 (rewrite flush).
How Markdown is generated
- Fetch HTML —
GETthe public permalink (timeout: Settings → Singular Markdown → Markdown generation → HTML fetch timeout). - Pick main fragment — first matching selector from Main content selectors (plus built-in fallbacks), or fall back to filtered
post_content. - Strip noise — remove nodes matching the built-in list plus Extra strip selectors, then apply the
singular_markdown_excluded_selectorsfilter. - Convert — page title as
#heading, duplicate leadingh1removed when it matches the title, block HTML → Markdown, then thesingular_markdown_outputfilter.
Automatic conversion runs in scheduled background jobs. Public .md requests do not run HTML fetching or conversion synchronously: if no cache exists yet, the request queues generation and returns 503 Retry-After; if stale cache exists, stale Markdown is served while regeneration is queued.
Archive/home Markdown (for example /blog.md or category archive .md URLs) is generated from the archive query: # Archive Title, then a list of eligible posts with ## [Post Title](permalink) and each post excerpt/trimmed content. Archive caches use a short TTL and regenerate in the background.
Per-post custom Markdown
For each included post type (same scope as automatic Markdown), the editor shows a Singular Markdown meta box:
- Automatic (from HTML) — default; Markdown is built from the public HTML of the permalink.
- Custom Markdown — when selected and the textarea is non-empty, that body is served at the
.mdURL (aftersingular_markdown_output). The file cache is updated on save so batch jobs stay consistent.
If Custom is selected but the textarea is empty, the choice is stored as automatic (same as HTML conversion).
Eligibility (indexability)
By default, Markdown is generated only for published, non–password-protected posts that have a permalink and pass layered checks:
- Post type: built-in
postandpageare always candidates when the type ispublic(even if another plugin setspublicly_queryableto false for them). Other types match WordPresspublic+publicly_queryableand are not blocked internally; you can still exclude types in settings. - Excluded post IDs (settings).
- Excluded taxonomy terms (settings): posts assigned any listed term are skipped.
- SEO noindex (when meta is present): Yoast SEO, Rank Math, SEOPress, All in One SEO.
- Canonical mismatch: if a canonical URL is set in supported SEO meta and does not match the post permalink (or resolve to the same post), the post is skipped.
- Force-include post IDs (settings): overrides post type exclusion, term exclusion, SEO noindex, canonical mismatch, and excluded post IDs. Password-protected posts are never included.
Developers can override the final decision with filters (see below).
Configuration
Settings → Singular Markdown
- Exclude entire post types from Markdown generation.
- Exclude specific post IDs (comma-separated).
- Force-include post IDs (comma-separated).
- Exclude by taxonomy term (one per line:
taxonomy:term_idortaxonomy:term-slug). - Listing pages: map normal WordPress pages to a post type when those pages use a template to display posts. Their
.mdURLs are generated as archive-style post lists. - Optional main content selectors (one per line: tag,
#id, or.classonly). Tried in order, then built-in fallbacks. - Extra strip selectors (one per line) — removed from the main fragment after the built-in strip list (cookie banners, sidebars, etc.).
- HTML fetch timeout (5–120 seconds) for loading the public HTML before conversion.
- Cache status: shows cached singular/archive file counts, total cache size, newest cache time, approximate background regeneration progress, and queued post/archive jobs. If progress has advanced but no batch cron is scheduled, the status is shown as paused.
- Eligibility diagnostics: enter a post ID or front-end URL to see eligibility, reason code, and message.
- Purge ineligible cache files: removes cached
.mdfiles for posts that no longer qualify (no full regeneration). - Purge all Markdown cache and regenerate: removes every cached
.mdfile, including archive/listing cache, then schedules background regeneration. Use after changing strip selectors or generation logic. - Save settings / Regenerate: also purges ineligible cache files and schedules a background regeneration.
- Per-post meta box (edit screen): choose automatic Markdown from HTML or custom Markdown for that entry (included post types only).
Filters
singular_markdown_allowed_post_types— default included post type names (after mergingpost/pagewhen public).singular_markdown_excluded_selectors— full list of selectors removed from the main fragment (built-in defaults + Extra strip selectors from settings are merged before this filter runs).singular_markdown_main_content_selectors— ordered list of selectors used to find main HTML before Markdown conversion.singular_markdown_output— final Markdown string.singular_markdown_is_post_eligible—(bool $eligible, int $post_id)after built-in rules; returnfalseto exclude ortrueto include.singular_markdown_eligibility—(array $result, int $post_id)with keyseligible,code,message; adjust or replace the full diagnostic result.singular_markdown_fetch_timeout—(int $timeout, int $post_id)seconds for the HTML fetch request.singular_markdown_retry_fetch_without_sslverify—(bool $allowed, int $post_id, string $url, WP_Error $error)allows one local/self-signed HTTPS retry without SSL verification when the initial rendered HTML fetch fails. Defaults to local/development or.localhosts only.singular_markdown_cache_control—(string $header, int $post_id)Cache-Control header for successful.mdresponses.singular_markdown_cache_eligibility—(bool $cache, int $post_id)whether to cache eligibility decisions for five minutes.singular_markdown_listing_posts_per_page—(int $posts_per_page, array $mapping)number of posts in configured listing page Markdown.singular_markdown_archive_image_size—(string|int[] $size, WP_Post $post)featured image size for archive/listing Markdown entries. Default:medium.singular_markdown_archive_output—(string $markdown, string $slug_path, WP_Query $query)final archive Markdown.singular_markdown_archive_title—(string $title, string $slug_path, WP_Query $query)archive Markdown heading.singular_markdown_archive_cache_ttl—(int $ttl, string $slug_path)archive cache TTL in seconds.
Internal query vars
Rewrite uses short internal vars sing_md and sing_md_path (not part of the public URL).
Manual verification
- Publish a normal page: HTML has
Linkheader and a<link rel="alternate" type="text/markdown" href="...">tag;/path.mdreturns cached Markdown after background generation (a first uncached request may return503 Retry-After). - Unpublish or trash:
.mdreturns 404; cache file removed after save/cron. - Add post ID to Exclude post IDs:
.mdandLinkdisappear; cache purged on save/regenerate. - Mark post noindex in Yoast (or another supported SEO plugin): excluded unless force-included.
- Force-include a noindex post ID:
.mdis served again (not password-protected). - Cache status shows cached file counts and background regeneration state after generation starts.
- Diagnostics with post ID or URL shows expected
codeand message. - Open a posts/archive page such as
/blog/: HTML has the<head>alternate tag;/blog.mdcontains the archive title, featured images when available, and post entries after background generation. - Configure a Listing page mapping such as
News → post:/news.mdcontains the selected post type listing instead of only the page body.
Uninstall
Deleting the plugin runs uninstall.php, which removes plugin options (current and legacy cache key) and Markdown cache directories.
Migration
If you previously used an older build of this plugin under a different folder name, activating Singular Markdown may copy stored options once into singular_markdown_options and clear the previous scheduled cron hook.