seo.php 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. <?php
  2. /**
  3. * SEO / social head metadata — favicon, OpenGraph, Twitter cards, JSON-LD.
  4. *
  5. * There's no SEO plugin, so the theme emits this itself. dw_seo_head() is hooked to
  6. * wp_head (covers every block/classic page, post, and archive). The bespoke
  7. * front-page.php deliberately skips wp_head(), so it calls dw_seo_head() directly.
  8. *
  9. * URLs are built from home_url()/get_permalink() so they stay correct through the
  10. * davidawindham.com -> davidwindham.com move. Favicon links are root-relative so every
  11. * property (WP, /work, /rtc, /photo) can point at the same shared /favicon.* at the root.
  12. */
  13. if ( ! defined( 'DW_SITE_NAME' ) ) { define( 'DW_SITE_NAME', 'David A. Windham' ); }
  14. if ( ! defined( 'DW_TWITTER' ) ) { define( 'DW_TWITTER', '@windhamdavid' ); }
  15. // Canonical brand domain (the consolidation target). The JSON-LD *identity* nodes point
  16. // here. Per-page og:url / <link rel=canonical> stay on the serving domain (home_url) so
  17. // they don't point at not-yet-migrated pages; they flip automatically when siteurl moves.
  18. if ( ! defined( 'DW_CANONICAL' ) ) { define( 'DW_CANONICAL', 'https://davidwindham.com' ); }
  19. /** Shared favicon links (root-hosted; same files served for every property). */
  20. function dw_seo_favicon() {
  21. echo "<link rel=\"icon\" href=\"/favicon.ico\" sizes=\"any\">\n";
  22. echo "<link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/favicon-32x32.png\">\n";
  23. echo "<link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/favicon-16x16.png\">\n";
  24. echo "<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n";
  25. }
  26. /** Plain-text description from a post's content WITHOUT rendering dynamic blocks.
  27. *
  28. * The old path (dw_good_excerpt) ran the full the_content filter here, which re-renders any
  29. * embedded Gravity Forms block during wp_head — wasteful, and it trips a GF Post-Category
  30. * warning on the guestbook/contact pages. excerpt_remove_blocks() drops the form (and other
  31. * dynamic blocks) first, so only static text blocks get rendered. Returns a trimmed string. */
  32. function dw_seo_excerpt_from_content( $id ) {
  33. $post = get_post( $id );
  34. if ( ! $post ) {
  35. return '';
  36. }
  37. $text = trim( (string) $post->post_excerpt );
  38. if ( $text === '' ) {
  39. $content = (string) $post->post_content;
  40. if ( function_exists( 'excerpt_remove_blocks' ) ) {
  41. $content = excerpt_remove_blocks( $content ); // strip forms + other dynamic blocks
  42. }
  43. $text = do_blocks( $content );
  44. }
  45. $text = wp_strip_all_tags( strip_shortcodes( $text ) );
  46. $text = trim( preg_replace( '/\s+/', ' ', $text ) );
  47. if ( mb_strlen( $text ) > 300 ) {
  48. $text = rtrim( mb_substr( $text, 0, 300 ) ) . '…';
  49. }
  50. return $text;
  51. }
  52. /** Page description: post meta_desc field -> excerpt -> site tagline. Returns a string. */
  53. function dw_seo_description() {
  54. if ( is_singular() ) {
  55. $id = get_queried_object_id();
  56. $d = trim( (string) get_post_meta( $id, 'meta_desc', true ) );
  57. if ( $d === '' ) {
  58. $d = dw_seo_excerpt_from_content( $id );
  59. }
  60. if ( $d !== '' ) {
  61. return $d;
  62. }
  63. }
  64. $tagline = trim( (string) get_bloginfo( 'description' ) );
  65. return $tagline !== '' ? $tagline : DW_SITE_NAME;
  66. }
  67. /** OG image: the post's featured image on singulars, else the shared default. Absolute. */
  68. function dw_seo_image() {
  69. if ( is_singular() && has_post_thumbnail() ) {
  70. $src = wp_get_attachment_image_src( get_post_thumbnail_id(), 'large' );
  71. if ( $src ) {
  72. return $src[0];
  73. }
  74. }
  75. return home_url( '/og-image.jpg' ); // shared image at the web root, same-origin
  76. }
  77. /** Absolute URL for the current view (og:url). */
  78. function dw_seo_url() {
  79. if ( is_front_page() ) {
  80. return home_url( '/' );
  81. }
  82. if ( is_singular() ) {
  83. return get_permalink();
  84. }
  85. if ( is_category() || is_tag() || is_tax() ) {
  86. $link = get_term_link( get_queried_object() );
  87. if ( ! is_wp_error( $link ) ) {
  88. return $link;
  89. }
  90. }
  91. global $wp;
  92. return home_url( '/' . ltrim( $wp->request, '/' ) );
  93. }
  94. /** OpenGraph + Twitter card tags for pages / archives / front page.
  95. *
  96. * Single POSTS are intentionally skipped — dw_opengraph() in inc/tweaks.php owns their
  97. * OG, including the featured-video setup (og:video + twitter:player -> /container/ ->
  98. * single-container.php). Doubling up here would emit conflicting tags and break that. */
  99. function dw_seo_opengraph() {
  100. if ( is_single() ) {
  101. return;
  102. }
  103. $title = wp_get_document_title();
  104. $desc = dw_seo_description();
  105. $url = dw_seo_url();
  106. $img = dw_seo_image();
  107. $type = is_single() ? 'article' : 'website';
  108. printf( "<meta property=\"og:type\" content=\"%s\">\n", esc_attr( $type ) );
  109. printf( "<meta property=\"og:site_name\" content=\"%s\">\n", esc_attr( DW_SITE_NAME ) );
  110. printf( "<meta property=\"og:title\" content=\"%s\">\n", esc_attr( $title ) );
  111. printf( "<meta property=\"og:description\" content=\"%s\">\n", esc_attr( $desc ) );
  112. printf( "<meta property=\"og:url\" content=\"%s\">\n", esc_url( $url ) );
  113. printf( "<meta property=\"og:image\" content=\"%s\">\n", esc_url( $img ) );
  114. printf( "<meta name=\"twitter:card\" content=\"summary_large_image\">\n" );
  115. printf( "<meta name=\"twitter:creator\" content=\"%s\">\n", esc_attr( DW_TWITTER ) );
  116. printf( "<meta name=\"twitter:title\" content=\"%s\">\n", esc_attr( $title ) );
  117. printf( "<meta name=\"twitter:description\" content=\"%s\">\n", esc_attr( $desc ) );
  118. printf( "<meta name=\"twitter:image\" content=\"%s\">\n", esc_url( $img ) );
  119. }
  120. /** Person + WebSite JSON-LD, site-wide. */
  121. function dw_seo_jsonld() {
  122. $person = array(
  123. '@context' => 'https://schema.org',
  124. '@type' => 'Person',
  125. '@id' => DW_CANONICAL . '/#person', // stable identity per-page nodes reference
  126. 'name' => DW_SITE_NAME,
  127. 'url' => DW_CANONICAL,
  128. 'image' => DW_CANONICAL . '/og-image.jpg',
  129. 'jobTitle' => 'Web Developer',
  130. 'sameAs' => array(
  131. 'https://davidwindham.com',
  132. 'https://github.com/windhamdavid',
  133. 'https://en.wikipedia.org/wiki/User:Windhamdavid',
  134. 'https://mastodon.social/@windhamdavid',
  135. 'https://www.linkedin.com/in/windhamdavid',
  136. 'https://www.facebook.com/windhamdavid',
  137. 'https://www.reddit.com/user/windhamdavid',
  138. 'https://news.ycombinator.com/user?id=windhamdavid',
  139. ),
  140. );
  141. $website = array(
  142. '@context' => 'https://schema.org',
  143. '@type' => 'WebSite',
  144. 'url' => DW_CANONICAL,
  145. 'name' => DW_SITE_NAME,
  146. 'potentialAction' => array(
  147. '@type' => 'SearchAction',
  148. 'target' => DW_CANONICAL . '/?s={search_term_string}',
  149. 'query-input' => 'required name=search_term_string',
  150. ),
  151. );
  152. echo '<script type="application/ld+json">' . wp_json_encode( $person, JSON_UNESCAPED_SLASHES ) . "</script>\n";
  153. echo '<script type="application/ld+json">' . wp_json_encode( $website, JSON_UNESCAPED_SLASHES ) . "</script>\n";
  154. }
  155. /** BlogPosting JSON-LD on single posts. author/publisher reference the site Person
  156. * (@id) emitted by dw_seo_jsonld() on the same page, so it all resolves to one entity. */
  157. function dw_seo_jsonld_article() {
  158. if ( ! is_single() ) {
  159. return;
  160. }
  161. $id = get_queried_object_id();
  162. $url = get_permalink( $id );
  163. $author = array( '@type' => 'Person', '@id' => DW_CANONICAL . '/#person', 'name' => DW_SITE_NAME );
  164. $node = array(
  165. '@context' => 'https://schema.org',
  166. '@type' => 'BlogPosting',
  167. '@id' => $url . '#article',
  168. 'mainEntityOfPage' => $url,
  169. 'url' => $url,
  170. 'headline' => get_the_title( $id ),
  171. 'datePublished' => get_the_date( 'c', $id ),
  172. 'dateModified' => get_the_modified_date( 'c', $id ),
  173. 'author' => $author,
  174. 'publisher' => $author,
  175. 'image' => dw_seo_image(),
  176. 'description' => dw_seo_description(),
  177. );
  178. echo '<script type="application/ld+json">' . wp_json_encode( $node, JSON_UNESCAPED_SLASHES ) . "</script>\n";
  179. }
  180. /** BreadcrumbList JSON-LD — hierarchical pages (Home > ancestors > page), posts
  181. * (Home > first category > post), and term archives (Home > term). */
  182. function dw_seo_jsonld_breadcrumbs() {
  183. if ( is_front_page() ) {
  184. return;
  185. }
  186. $crumbs = array( array( 'name' => 'Home', 'url' => home_url( '/' ) ) );
  187. if ( is_singular() ) {
  188. $id = get_queried_object_id();
  189. if ( is_page() ) {
  190. foreach ( array_reverse( get_post_ancestors( $id ) ) as $ancestor ) {
  191. $crumbs[] = array( 'name' => get_the_title( $ancestor ), 'url' => get_permalink( $ancestor ) );
  192. }
  193. } elseif ( is_single() ) {
  194. $cats = get_the_category( $id );
  195. if ( ! empty( $cats ) ) {
  196. $crumbs[] = array( 'name' => $cats[0]->name, 'url' => get_category_link( $cats[0]->term_id ) );
  197. }
  198. }
  199. $crumbs[] = array( 'name' => get_the_title( $id ), 'url' => get_permalink( $id ) );
  200. } elseif ( is_category() || is_tag() || is_tax() ) {
  201. $term = get_queried_object();
  202. $link = get_term_link( $term );
  203. $crumbs[] = array( 'name' => $term->name, 'url' => is_wp_error( $link ) ? home_url( '/' ) : $link );
  204. } else {
  205. return; // home / search / date views get no meaningful trail
  206. }
  207. if ( count( $crumbs ) < 2 ) {
  208. return;
  209. }
  210. $items = array();
  211. foreach ( $crumbs as $i => $c ) {
  212. $items[] = array(
  213. '@type' => 'ListItem',
  214. 'position' => $i + 1,
  215. 'name' => $c['name'],
  216. 'item' => $c['url'],
  217. );
  218. }
  219. $node = array(
  220. '@context' => 'https://schema.org',
  221. '@type' => 'BreadcrumbList',
  222. 'itemListElement' => $items,
  223. );
  224. echo '<script type="application/ld+json">' . wp_json_encode( $node, JSON_UNESCAPED_SLASHES ) . "</script>\n";
  225. }
  226. /** Emit the whole head block. Hooked to wp_head; front-page.php calls it directly. */
  227. function dw_seo_head() {
  228. dw_seo_favicon();
  229. dw_seo_opengraph();
  230. dw_seo_jsonld();
  231. dw_seo_jsonld_article();
  232. dw_seo_jsonld_breadcrumbs();
  233. }
  234. add_action( 'wp_head', 'dw_seo_head', 5 );