| @@ -912,8 +912,17 @@ | ||
| 912 | 912 | if ( $this->invalid_request_query_vars !== null ) { |
| 913 | 913 | return; |
| 914 | 914 | } |
| 915 | 915 | |
| 916 | + // A single-doc request whose main query returned nothing (private or | |
| 917 | + // draft doc for a visitor who can't read it, a doc in another/inactive | |
| 918 | + // WPML language) must stay a 404. Marking it singular here would render | |
| 919 | + // the single template against a null post and expose the doc's title | |
| 920 | + // through the queried object. See betterdocs/betterdocs#173. | |
| 921 | + if ( $this->is_singular_docs_request() && ! $this->main_query_has_posts() ) { | |
| 922 | + return; | |
| 923 | + } | |
| 924 | + | |
| 916 | 925 | // Check if we have doc_category or knowledge_base in query vars |
| 917 | 926 | if ( isset( $wp_query->query_vars['doc_category'] ) && ! empty( $wp_query->query_vars['doc_category'] ) ) { |
| 918 | 927 | // If this is already identified as singular, don't override it |
| 919 | 928 | if ( $wp_query->is_singular() || $wp_query->is_singular ) { |
| @@ -948,14 +957,40 @@ | ||
| 948 | 957 | |
| 949 | 958 | // Check if we have 'docs' query var (alternative to 'name') |
| 950 | 959 | if ( isset( $wp_query->query_vars['docs'] ) && ! empty( $wp_query->query_vars['docs'] ) ) { |
| 951 | 960 | |
| 961 | + // Verify the requested doc actually exists (and is visible to the | |
| 962 | + // current user) before forcing a single-doc render. Category-in-path | |
| 963 | + // permalinks put the doc slug in the `docs` var alongside a valid | |
| 964 | + // doc_category; without this guard a missing slug under a real | |
| 965 | + // category rendered the single template against a null post — an | |
| 966 | + // HTTP 200 soft-404 plus "read property on null" warnings on every | |
| 967 | + // (often bot) hit. See betterdocs/betterdocs#169. | |
| 968 | + $doc_post = get_page_by_path( $wp_query->query_vars['docs'], OBJECT, 'docs' ); | |
| 969 | + $doc_visible = $doc_post | |
| 970 | + && ( 'private' !== $doc_post->post_status || current_user_can( 'read_private_docs' ) ); | |
| 971 | + | |
| 972 | + if ( ! $doc_visible ) { | |
| 973 | + // No such doc (or private and not permitted) — serve a genuine | |
| 974 | + // 404 instead of a single render against a null post. | |
| 975 | + $wp_query->set_404(); | |
| 976 | + status_header( 404 ); | |
| 977 | + nocache_headers(); | |
| 978 | + add_filter( 'template_include', function( $template ) { | |
| 979 | + $not_found = get_404_template(); | |
| 980 | + return $not_found ? $not_found : $template; | |
| 981 | + }, 999 ); | |
| 982 | + return; | |
| 983 | + } | |
| 984 | + | |
| 952 | 985 | // Explicitly set this as a single post |
| 953 | - $wp_query->is_single = true; | |
| 954 | - $wp_query->is_singular = true; | |
| 955 | - $wp_query->is_404 = false; | |
| 956 | - $wp_query->is_archive = false; | |
| 957 | - $wp_query->is_tax = false; | |
| 986 | + $wp_query->is_single = true; | |
| 987 | + $wp_query->is_singular = true; | |
| 988 | + $wp_query->is_404 = false; | |
| 989 | + $wp_query->is_archive = false; | |
| 990 | + $wp_query->is_tax = false; | |
| 991 | + $wp_query->queried_object = $doc_post; | |
| 992 | + $wp_query->queried_object_id = $doc_post->ID; | |
| 958 | 993 | return; |
| 959 | 994 | } |
| 960 | 995 | |
| 961 | 996 | // If 'name' is set, check if a post with that name exists |
| @@ -1054,8 +1089,16 @@ | ||
| 1054 | 1089 | if ( $this->invalid_request_query_vars !== null ) { |
| 1055 | 1090 | return $status_header; |
| 1056 | 1091 | } |
| 1057 | 1092 | |
| 1093 | + // A single-doc request whose main query returned nothing is a genuine | |
| 1094 | + // 404 — whatever the slug/term lookups below would find. Checked before | |
| 1095 | + // both overrides, so neither can turn it into a soft-404 200. | |
| 1096 | + // See betterdocs/betterdocs#173. | |
| 1097 | + if ( 404 === (int) $code && $this->is_singular_docs_request() && ! $this->main_query_has_posts() ) { | |
| 1098 | + return $status_header; | |
| 1099 | + } | |
| 1100 | + | |
| 1058 | 1101 | // If a 404 is being sent but the queried object is a valid single docs post, |
| 1059 | 1102 | // override with 200. This guards against false 404s on single docs pages. |
| 1060 | 1103 | if ( $code == 404 && |
| 1061 | 1104 | isset( $wp_query->queried_object ) && |
| @@ -1075,8 +1118,24 @@ | ||
| 1075 | 1118 | if ( $code == 404 && ( |
| 1076 | 1119 | (isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category'])) || |
| 1077 | 1120 | (isset($wp_query->query_vars['doc_tag']) && ! empty($wp_query->query_vars['doc_tag'])) |
| 1078 | 1121 | ) ) { |
| 1122 | + // A single-doc request (category-in-path permalinks carry the doc slug | |
| 1123 | + // in the `docs`/`name` var alongside doc_category) must resolve to a real | |
| 1124 | + // doc. A valid doc is already served 200 by the queried-object branch | |
| 1125 | + // above; if we reach here with a single-doc indicator but no resolvable | |
| 1126 | + // post, the doc does not exist — keep the genuine 404 rather than forcing | |
| 1127 | + // a soft-404 200 off the category term alone. See betterdocs/betterdocs#169. | |
| 1128 | + $single_doc_slug = ''; | |
| 1129 | + if ( isset( $wp_query->query_vars['docs'] ) && ! empty( $wp_query->query_vars['docs'] ) ) { | |
| 1130 | + $single_doc_slug = $wp_query->query_vars['docs']; | |
| 1131 | + } elseif ( isset( $wp_query->query_vars['name'] ) && ! empty( $wp_query->query_vars['name'] ) ) { | |
| 1132 | + $single_doc_slug = $wp_query->query_vars['name']; | |
| 1133 | + } | |
| 1134 | + if ( '' !== $single_doc_slug && ! get_page_by_path( $single_doc_slug, OBJECT, 'docs' ) ) { | |
| 1135 | + return $status_header; | |
| 1136 | + } | |
| 1137 | + | |
| 1079 | 1138 | // Validate existence before forcing 200 |
| 1080 | 1139 | // Use encoded fallback so Bengali/Arabic/CJK slugs are found correctly. |
| 1081 | 1140 | if ( isset($wp_query->query_vars['doc_category']) && ! empty($wp_query->query_vars['doc_category']) ) { |
| 1082 | 1141 | $term = $this->get_term_by_slug_or_encoded( $wp_query->query_vars['doc_category'], 'doc_category' ); |
| @@ -1100,8 +1159,41 @@ | ||
| 1100 | 1159 | return 'HTTP/1.1 200 OK'; |
| 1101 | 1160 | } |
| 1102 | 1161 | |
| 1103 | 1162 | return $status_header; |
| 1163 | + } | |
| 1164 | + | |
| 1165 | + /** | |
| 1166 | + * Whether the main query targets a single doc (`name`, `docs` or `p`), | |
| 1167 | + * as opposed to an archive. | |
| 1168 | + * | |
| 1169 | + * @return bool | |
| 1170 | + */ | |
| 1171 | + protected function is_singular_docs_request() { | |
| 1172 | + global $wp_query; | |
| 1173 | + | |
| 1174 | + if ( ! $wp_query instanceof \WP_Query ) { | |
| 1175 | + return false; | |
| 1176 | + } | |
| 1177 | + | |
| 1178 | + $query_vars = $wp_query->query_vars; | |
| 1179 | + | |
| 1180 | + return ! empty( $query_vars['name'] ) | |
| 1181 | + || ! empty( $query_vars['docs'] ) | |
| 1182 | + || ( isset( $query_vars['p'] ) && (int) $query_vars['p'] > 0 ); | |
| 1183 | + } | |
| 1184 | + | |
| 1185 | + /** | |
| 1186 | + * Whether the main query actually returned posts. Slug and term lookups | |
| 1187 | + * only prove something exists; this is what the visitor can see — it | |
| 1188 | + * already honours post status, capabilities and WPML's language filter. | |
| 1189 | + * | |
| 1190 | + * @return bool | |
| 1191 | + */ | |
| 1192 | + protected function main_query_has_posts() { | |
| 1193 | + global $wp_query; | |
| 1194 | + | |
| 1195 | + return $wp_query instanceof \WP_Query && ! empty( $wp_query->posts ); | |
| 1104 | 1196 | } |
| 1105 | 1197 | |
| 1106 | 1198 | /** |
| 1107 | 1199 | * Ensure tax_query is always initialized as an object |