Repository navigation
v1.6.0
LW SEO v1.6.0
Installation
Via Composer:
composer require lwplugins/lw-seoManual Installation:
- Download
lw-seo-1.6.0.zip - Upload to
/wp-content/plugins/ - Activate in WordPress admin
Changelog
Upgrade notes
- Content Signals now default to "not specified" (neither grants nor restricts) instead of allowing everything. If you never explicitly saved these settings, your site now sends no
Content-Signal/ai-content-signalsvalue until you set one on the AI/LLM tab. - robots.txt is now built entirely through WordPress's core
robots_txtfilter. A physicalrobots.txtfile in the site root always took priority over WordPress's virtual one and still does — the Advanced tab andwp lw-seo robots previewnow warn when one exists. - Blocking "Claude-Web" no longer has any effect (Anthropic retired that token); the setting auto-migrates to blocking
ClaudeBoton upgrade. cohere-aiwas removed from the crawler list; any block toggle you had for it is dropped on upgrade.- The custom Markdown override field (post/term) can now only be set by users with the
unfiltered_htmlcapability. Other roles see it read-only; their previously saved values are kept as-is — review overrides saved by non-admin roles before relying on them. - The Markdown endpoint (
/md) now answers 404, not 403, for a private post the visitor can't read (so it no longer confirms the post exists), and never serves a password-protected post's Markdown even with a valid password cookie. X-Content-Signalsis deprecated;Content-Signalis now sent alongside it and will become the only header in a later release. Update anything reading the old header.- llms.txt now has one section per post type (keyed by post type, not by its label) and lists every listed post type, not just posts and pages — re-check the AI/LLM tab's per-type toggles and item limit after upgrading.
Added
XML sitemap
- Every public custom post type is included automatically; each can be switched off individually on the Sitemap tab. Custom taxonomies are opt-in (off by default); tags now have their own toggle alongside categories.
- One
PostProvider/TaxonomyProvideris built per enabled type, lazily, so a post type registered later oninit(ACF, CPT UI, etc.) is still picked up. - Per-type sitemap URLs now accept hyphens and digits in the type/taxonomy name —
sitemap-case-study.xml,sitemap-top-10.xml— not just letters and underscores. - Noindex post types/taxonomies, and individually noindexed or password-protected posts, are left out of every sitemap.
- The documented filters
lw_seo_sitemap_post_types,lw_seo_sitemap_exclude_postandlw_seo_sitemap_urls( $items, $name, $page )are now actually implemented.
llms.txt
- Rewritten to the llmstxt.org v2 structure: one
##section per post type (pages in menu order, other types newest first). Two types that share a label, or a type literally named "Optional", each keep their own section — the post type name is appended to the heading to disambiguate. - Admin-configurable summary (rendered as the document's blockquote) and a free-text introduction.
- Per-section item limit (
llms_txt_max_items, default 100, clamped to 1-500). - Section descriptions come from each post's SEO description, falling back to its excerpt.
- Optional "Extra links" textarea (
llms_txt_optional_links, oneTitle | URL | optional descriptionper line), always rendered last under its own "Optional" heading — it can never merge with a content section. - Optional Markdown links (
llms_txt_markdown_links): section links point at each post's/mdMarkdown URL instead of its HTML permalink. - Opt-in
/llms-full.txt(llms_full_txt_enabled), concatenating the Markdown of every listed post, capped at 1 MiB with a truncation notice. - Both documents are cached in a transient (see Fixed for the invalidation rules).
- New filter
lw_seo_llms_txt_post_typesto add or remove post types from the section list.
Markdown endpoint
- Accept-header negotiation now honours q-values: on the plain HTML URL, Markdown is served only when its q-value is above 0 and at least
text/html's (both default toq=1when omitted). - Subdirectory-aware request-path handling.
- The static front page's Markdown is now served at
/md/(site root + suffix), the same way any other page's is at{page-url}/md/. - Discovery links per the llms.txt v2 convention:
<link rel="alternate" type="text/markdown">and<link rel="describedby">in<head>on every AI-visible singular page, mirrored as HTTPLinkheader entries; the Markdown response itself sendsLink: <canonical-html-url>; rel="canonical"back. - HTML-to-Markdown converter rewritten: Gutenberg images/tables/captions render correctly, nested and ordered lists no longer duplicate, blockquotes survive,
<hr>/<br>are preserved, fenced code blocks carry a language and size their backtick fence to the content, lazy-loaded images are handled, and navigation/form/script/style noise is stripped.
AI crawlers
- Crawler registry rebuilt from each vendor's current documentation, 22 tokens in total:
GPTBot/OAI-SearchBot/ChatGPT-User(OpenAI),ClaudeBot/Claude-SearchBot/Claude-User(Anthropic),Google-Extended(Google),Applebot-Extended(Apple),PerplexityBot/Perplexity-User(Perplexity),meta-externalagent/meta-webindexer/meta-externalfetcher(Meta),Amazonbot/Amzn-SearchBot/Amzn-User(Amazon),MistralAI-Training/MistralAI-Index/MistralAI-User(Mistral AI),CCBot(Common Crawl),AI2Bot(Allen Institute for AI),Bytespider(ByteDance). - Each crawler is tagged with a purpose — training, search, or user-triggered — and the AI/LLM tab groups them by purpose with a "block all training / search / user-triggered" toggle (
block_purpose_training,block_purpose_search,block_purpose_user), on top of the existing per-crawlerblock_<key>toggle. - New filter
lw_seo_ai_crawlersto add third-party crawlers to the registry; filtered-in crawlers are governed by the purpose toggles only, with no individual setting. - Admin note that OpenAI, Perplexity, Meta and Amazon each document that their user-triggered fetchers may disregard
robots.txtfor a specific, user-initiated request.
Content Signals
- Three-state values (not specified / allow / disallow) for
search,ai-inputandai-train, matching the Cloudflare Content Signals Policy vocabulary. - Standard
Content-SignalHTTP header (directive syntax, e.g.search=yes, ai-train=no), alongside the existingai-content-signalsmeta tag.
robots.txt
Content-Signal:line inserted inside theUser-agent: *group, plus the verbatim Cloudflare Content Signals Policy comment block, whenever at least one signal is set.- Advanced tab: live preview of the robots.txt WordPress would actually serve, and a warning naming the file path when a physical
robots.txtin the site root would take priority over it.
Admin UI
- Notice on the LW SEO settings pages when Yoast SEO, Rank Math or All in One SEO is also active.
- Sitemap tab: per-post-type toggles, a tags toggle, and per-taxonomy opt-in toggles.
- AI/LLM tab restructured into a Content Signals block (three selects), the llms.txt block (summary/intro/post types/limits/extra links/llms-full), and the crawler groups (purpose toggles + per-crawler checkboxes).
WP-CLI
wp lw-seo llms preview [--full],wp lw-seo llms flush,wp lw-seo llms info.wp lw-seo robots preview(same physical-file warning as the Advanced tab).wp lw-seo crawlers list [--format=<table|json|csv|yaml>], listing each crawler's company, purpose(s) and block reason (individual/purpose:<name>/-).wp lw-seo sitemap infonow also lists every currently-built per-type sitemap.wp lw-seo option setis validated through the same sanitizer the settings form uses: typed bool/int parsing (true/1/on/yesand their opposites, case-insensitive; anything else errors instead of silently storingfalse), map options accept a JSON object (or akey.subkeydot-path to update one entry without touching the rest), and choice options are validated against a fixed list with the allowed values in the error message.- New
docs/cli.mddocuments everywp lw-seocommand.
Internals
Content\PostTypes: shared helper for the public, viewable post types and taxonomies the sitemap, llms.txt and Markdown endpoint all rely on.Content\Eligibility: shared gate (is_post_eligible(),is_ai_visible()) used by the sitemap, llms.txt and the Markdown endpoint, backing the newlw_seo_post_is_eligiblefilter — restrict-only: it can drop a post that already passed the built-in checks, but can never add one that didn't.Admin\SettingsSanitizerextracted from the settings page, withint,map,textareaandchoiceoption-type support, shared by both the settings form andwp lw-seo option set.Upgrader: runs per-version option migrations and schedules a rewrite-rules flush on every version change, tracked in the newlw_seo_versionoption.
Filters
lw_seo_post_is_eligible,lw_seo_llms_txt_post_types,lw_seo_ai_crawlersare new; the previously documentedlw_seo_sitemap_post_types,lw_seo_sitemap_exclude_postandlw_seo_sitemap_urlsare implemented for the first time.
Changed
Content Signals
- New installs default every signal to "not specified"; existing settings keep their saved values (stored booleans migrate to
yes/noon upgrade). - Sites that never saved these settings previously sent
ai-train=yes, ai-input=yes, search=yesby default; they now send no signal until one is explicitly configured. - The
lw_seo_content_signalsfilter now receives only the signals that are actually set — callbacks must read keys withisset()/??rather than assuming all three keys are present. - The header is now
Content-Signal;X-Content-Signalsis still sent as a deprecated alias and will be removed in a later release.
Markdown endpoint
- Private posts the visitor can't read now answer 404 instead of 403 (existence not revealed); password-protected posts still answer 403, and a post entered via a valid password cookie is still never served at
/md— a password-protected post is never AI-visible, cookie or not.
robots.txt
- Built entirely through WordPress's core
robots_txtfilter instead of a separate^robots\.txt$rewrite rule that bypassed it, dropping other plugins' contributions and hard-coding/wp-admin/itself.
Docs
docs/developers.md: removed 13 hook sections for hooks nothing inincludes/ever fired (lw_seo_title,lw_seo_title_separator,lw_seo_meta_description,lw_seo_description_length,lw_seo_og_tags,lw_seo_default_social_image,lw_seo_schema,lw_seo_local_schema,lw_seo_breadcrumb_items,lw_seo_breadcrumb_separator,lw_seo_before_meta,lw_seo_after_meta,lw_seo_settings_saved); every remaining hook's name and arguments were re-checked against the code, and thelw_seo_content_signalsdocblock now says it runs last and receives only the set signals.docs/settings-sitemap.md,docs/settings-ai.md,docs/settings-advanced.md,docs/markdown-endpoint.mdanddocs/site-manager-abilities.mdrewritten for the 1.6.0 behaviour; newdocs/cli.md.
Translations
lw-seo.potregenerated (531 strings); the Hungarian (hu_HU) translation updated to 100% (92 new strings translated, 3 duplicate entries collapsed, obsolete strings dropped).
Fixed
- Blocking "Claude-Web" did not block Anthropic's crawler; the setting now migrates to
ClaudeBot. - llms.txt linked the draft Privacy Policy page, listed the static front page twice, included noindex and password-protected posts, and showed HTML entities instead of decoded text.
- Markdown on the HTML URL (Accept negotiation) had no
Vary: Accept, so page caches could serve the Markdown response to browsers; q-values are now honoured instead of a plain substring match. - Markdown frontmatter was invalid YAML for titles containing apostrophes.
- HTML to Markdown lost Gutenberg images and tables, duplicated nested lists, flattened blockquotes, and dropped
<hr>/<br>. <strong>Note: </strong>textlost the space after the bold text (rendered glued to the following word, and CommonMark would have ignored the emphasis markers entirely since whitespace sat just inside them).- robots.txt was generated outside the
robots_txtfilter, dropping other plugins' rules and hard-coding/wp-admin/. - Noindex post types and taxonomies were still listed in the sitemap.
- llms-full.txt generation no longer leaves the global
$postchanged after it runs. - robots.txt's Content-Signal insertion now normalizes CRLF line endings before matching the
User-agent: *group. - YAML frontmatter stays parseable when a value contains C1 control characters, the U+FFFE/U+FFFF noncharacters, or invalid UTF-8 (previously PyYAML/libyaml rejected the block, or the value came out empty).
- llms.txt: two post types with the same plural label, or a type literally labelled "Optional", no longer lose a section to the other.
- LW Site Manager
set-metacollapsed the Markdown override onto one line (sanitize_text_fieldfolds newlines); it now usessanitize_textarea_field, like the meta box, and keeps its newlines. - llms.txt cache invalidation is far more precise: it now rebuilds only for a published post of a listed type (a scheduled post going live, an unpublish, or a trash all count), for the plugin's own post meta keys, for a term edit, for a site address/title/permalink-structure change, after a plugin version change, and after settings are saved (registered even while llms.txt is off, so re-enabling it never serves a copy cached from before). Saving a revision, autosave, draft, or a post of an unlisted type no longer clears the cache.
Security
- The Markdown endpoint no longer serves noindex content or content with AI Input set to "No"; private posts use the
read_postcapability (so custom capability setups are respected) and answer 404 instead of 403 when access is denied. - Category/tag Markdown honours a site-wide or per-term
ai-input=no; a term's "## Posts" list only includes posts eligible under the same gate as the sitemap and llms.txt. javascript:,vbscript:anddata:URLs are dropped from Markdown link/image/iframe destinations, including entity-encoded,<…>-wrapped and backslash-obfuscated variants of the scheme.- Markdown URLs percent-encode control characters, backtick and backslash so an obfuscated destination can no longer break out of the link syntax and let the next piece of Markdown be read as a live link or raw HTML.
- Inline code fences are sized to one backtick longer than the longest backtick run in their content (with CommonMark's padding rule), and every point that concatenates rendered Markdown pieces (inline text, block/list/container children) inserts a space when it would otherwise fuse two backtick fences or leave a trailing backslash escaping the next piece — closing off ways a decoded
<img onerror>-style payload could end up read as live HTML by a downstream CommonMark renderer. - Text nodes, image
alttext, and heading/list-item titles (post titles, product attribute labels/values, term names) are Markdown-escaped so post content can no longer inject a live link, emphasis, autolink or raw HTML tag into the output; WooCommerce product attribute values and labels go through the same escaping before being placed in the attribute table. - YAML frontmatter values are
\u-escaped for<,>,[,]and backtick, so a hostile title, author name, category/tag, excerpt or price can't be read as a heading, link or raw HTML by a CommonMark renderer without a frontmatter extension. - llms.txt and llms-full.txt are now built as a logged-out visitor (user ID 0, restored afterwards even if the builder throws), so the shared cached copy no longer contains whatever
the_content, a shortcode, or a membership/LMS plugin showed the user — often an admin — who happened to trigger the rebuild. - llms.txt escapes Markdown syntax in the site title, summary, section headings, link titles and descriptions (SEO description or excerpt), and filters and percent-encodes link URLs through the same scheme filter as the Markdown endpoint, so an author-controlled title or excerpt can no longer inject a link or raw HTML.
- Per-post and per-term content signal values are whitelisted to
yes/no; any other submitted value deletes the meta row instead of being stored. - The custom Markdown override (post and term) can only be set by users with the
unfiltered_htmlcapability, because it is served unescaped at/md; other users see the field read-only and their existing value is preserved untouched (neither overwritten nor deleted). This applies to the post editor meta box, the term edit screen, and the LW Site Managerset-metaability alike. - LW Site Manager
set-metanow requiresedit_post/edit_termon the specific target object (previously only the genericedit_postscapability), and reports fields it silently left unchanged asskippedin the response (currently onlymarkdown_contentwithoutunfiltered_html). - LW Site Manager
get-meta,get-content-signalsandget-markdownnow require the target object to be publicly readable, or the caller to haveedit_post/edit_termon it — previously any caller with the genericcan_edit_postspermission could read another user's drafts, private posts, password-protected posts, or terms of a non-public taxonomy.
Removed
cohere-airemoved from the crawler list (not documented by Cohere).
Full Changelog: v1.5.1...v1.6.0