seo.php 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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. 'name' => DW_SITE_NAME,
  126. 'url' => DW_CANONICAL,
  127. 'image' => DW_CANONICAL . '/og-image.jpg',
  128. 'jobTitle' => 'Web Developer',
  129. 'sameAs' => array(
  130. 'https://davidwindham.com',
  131. 'https://github.com/windhamdavid',
  132. 'https://en.wikipedia.org/wiki/User:Windhamdavid',
  133. 'https://mastodon.social/@windhamdavid',
  134. 'https://www.linkedin.com/in/windhamdavid',
  135. 'https://www.facebook.com/windhamdavid',
  136. 'https://www.reddit.com/user/windhamdavid',
  137. 'https://news.ycombinator.com/user?id=windhamdavid',
  138. ),
  139. );
  140. $website = array(
  141. '@context' => 'https://schema.org',
  142. '@type' => 'WebSite',
  143. 'url' => DW_CANONICAL,
  144. 'name' => DW_SITE_NAME,
  145. 'potentialAction' => array(
  146. '@type' => 'SearchAction',
  147. 'target' => DW_CANONICAL . '/?s={search_term_string}',
  148. 'query-input' => 'required name=search_term_string',
  149. ),
  150. );
  151. echo '<script type="application/ld+json">' . wp_json_encode( $person, JSON_UNESCAPED_SLASHES ) . "</script>\n";
  152. echo '<script type="application/ld+json">' . wp_json_encode( $website, JSON_UNESCAPED_SLASHES ) . "</script>\n";
  153. }
  154. /** Emit the whole head block. Hooked to wp_head; front-page.php calls it directly. */
  155. function dw_seo_head() {
  156. dw_seo_favicon();
  157. dw_seo_opengraph();
  158. dw_seo_jsonld();
  159. }
  160. add_action( 'wp_head', 'dw_seo_head', 5 );