added on local at 2026-07-01 12:34:24
| 1 | package MODS::PTMatrix::SEO; | |
| 2 | #====================================================================== | |
| 3 | # TaskForge -- SEO + Open Graph + Twitter Card helper. | |
| 4 | # | |
| 5 | # Single scope (for now): platform SEO -- rows in platform_settings | |
| 6 | # keyed by setting_key ('seo.title', 'seo.description', 'seo.og_image', | |
| 7 | # 'seo.canonical_base', 'seo.robots_index', etc.). | |
| 8 | # | |
| 9 | # render_tags() builds the <meta> block (title + description + OG + | |
| 10 | # twitter) the marketing wrapper injects into <head>. Sensible fallbacks | |
| 11 | # at every layer so a brand-new install still emits good defaults. | |
| 12 | # | |
| 13 | # Adapted from MODS::WebSTLs::SEO with the per-storefront block removed | |
| 14 | # (TaskForge isn't multi-tenant on the marketing side) and the | |
| 15 | # platform_settings UPSERT adjusted to TF's table shape (no id PK, no | |
| 16 | # updated_by_user_id column -- setting_key IS the PK). | |
| 17 | #====================================================================== | |
| 18 | use strict; | |
| 19 | use warnings; | |
| 20 | ||
| 21 | sub new { | |
| 22 | my ($class, %args) = @_; | |
| 23 | return bless({}, $class); | |
| 24 | } | |
| 25 | ||
| 26 | # ---- Schema readiness ------------------------------------------------ | |
| 27 | sub platform_settings_ready { | |
| 28 | my ($self, $db, $dbh, $DB) = @_; | |
| 29 | my $r = $db->db_readwrite($dbh, qq~ | |
| 30 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 31 | WHERE table_schema='$DB' AND table_name='platform_settings' | |
| 32 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 33 | return ($r && $r->{n}) ? 1 : 0; | |
| 34 | } | |
| 35 | ||
| 36 | # ---- Platform-wide SEO ---------------------------------------------- | |
| 37 | sub get_platform_settings { | |
| 38 | my ($self, $db, $dbh, $DB) = @_; | |
| 39 | return {} unless $self->platform_settings_ready($db, $dbh, $DB); | |
| 40 | my @rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 41 | SELECT setting_key, setting_value | |
| 42 | FROM `${DB}`.platform_settings | |
| 43 | WHERE setting_key LIKE 'seo.%' | |
| 44 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 45 | my %out; | |
| 46 | foreach my $r (@rows) { | |
| 47 | $out{ $r->{setting_key} } = $r->{setting_value}; | |
| 48 | } | |
| 49 | return \%out; | |
| 50 | } | |
| 51 | ||
| 52 | sub set_platform_setting { | |
| 53 | my ($self, $db, $dbh, $DB, $key, $value, $actor_uid) = @_; | |
| 54 | return 0 unless $key; | |
| 55 | return 0 unless $self->platform_settings_ready($db, $dbh, $DB); | |
| 56 | ||
| 57 | # Dotted lowercase + alphanumeric/underscore only. | |
| 58 | $key =~ s/[^a-z0-9._]//g; | |
| 59 | $key = substr($key, 0, 80); | |
| 60 | return 0 unless length $key; | |
| 61 | ||
| 62 | my $sv = defined $value ? $value : ''; | |
| 63 | $sv =~ s/'/''/g; | |
| 64 | ||
| 65 | # TF platform_settings has no updated_by_user_id column -- ignore the | |
| 66 | # actor param. Kept in the signature for caller-side symmetry with | |
| 67 | # WebSTLs's set_platform_setting. | |
| 68 | $db->db_readwrite($dbh, qq~ | |
| 69 | INSERT INTO `${DB}`.platform_settings | |
| 70 | SET setting_key='$key', | |
| 71 | setting_value='$sv' | |
| 72 | ON DUPLICATE KEY UPDATE | |
| 73 | setting_value='$sv' | |
| 74 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 75 | return 1; | |
| 76 | } | |
| 77 | ||
| 78 | # ---- Tag rendering --------------------------------------------------- | |
| 79 | # Builds the head <meta> block for the marketing pages (landing, | |
| 80 | # pricing, features, etc.) and any other page that wants Open Graph | |
| 81 | # / Twitter Card metadata. Pulls from platform_settings and falls | |
| 82 | # back to sensible defaults. | |
| 83 | sub render_tags { | |
| 84 | my ($self, %a) = @_; | |
| 85 | my $platform = $a{platform} || {}; # hash from get_platform_settings() | |
| 86 | my $page_title = $a{page_title} || ''; | |
| 87 | my $request_url = $a{request_url} || ''; | |
| 88 | ||
| 89 | my $title = $page_title || $platform->{'seo.title'} || 'PTMatrix'; | |
| 90 | my $description = $platform->{'seo.description'} || ''; | |
| 91 | my $keywords = $platform->{'seo.keywords'} || ''; | |
| 92 | my $canonical = $request_url || $platform->{'seo.canonical_base'} || ''; | |
| 93 | my $og_image = $platform->{'seo.og_image'} || ''; | |
| 94 | my $og_site = $platform->{'seo.og_site_name'}|| 'PTMatrix'; | |
| 95 | my $twit_card = $platform->{'seo.twitter_card'}|| 'summary_large_image'; | |
| 96 | my $twit_site = $platform->{'seo.twitter_site'}|| ''; | |
| 97 | ||
| 98 | my $robots_on = 1; | |
| 99 | if (defined $platform->{'seo.robots_index'} && $platform->{'seo.robots_index'} eq '0') { | |
| 100 | $robots_on = 0; | |
| 101 | } | |
| 102 | my $robots_str = $robots_on ? 'index,follow' : 'noindex,nofollow'; | |
| 103 | ||
| 104 | # If og_image is a relative path and we have a canonical base, prefix it. | |
| 105 | if ($og_image && $og_image =~ m{^/} && $platform->{'seo.canonical_base'}) { | |
| 106 | my $base = $platform->{'seo.canonical_base'}; | |
| 107 | $base =~ s{/+$}{}; | |
| 108 | $og_image = $base . $og_image; | |
| 109 | } | |
| 110 | ||
| 111 | foreach ($title, $description, $keywords, $canonical, $og_image, $og_site, $twit_card, $twit_site) { | |
| 112 | $_ = '' unless defined $_; | |
| 113 | s/&/&/g; s/</</g; s/>/>/g; s/"/"/g; | |
| 114 | } | |
| 115 | ||
| 116 | my $h = ''; | |
| 117 | $h .= qq~ <meta name="description" content="$description">\n~ if length $description; | |
| 118 | $h .= qq~ <meta name="keywords" content="$keywords">\n~ if length $keywords; | |
| 119 | $h .= qq~ <meta name="robots" content="$robots_str">\n~; | |
| 120 | $h .= qq~ <link rel="canonical" href="$canonical">\n~ if length $canonical; | |
| 121 | $h .= qq~ <meta property="og:title" content="$title">\n~; | |
| 122 | $h .= qq~ <meta property="og:description" content="$description">\n~ if length $description; | |
| 123 | $h .= qq~ <meta property="og:type" content="website">\n~; | |
| 124 | $h .= qq~ <meta property="og:site_name" content="$og_site">\n~ if length $og_site; | |
| 125 | $h .= qq~ <meta property="og:image" content="$og_image">\n~ if length $og_image; | |
| 126 | $h .= qq~ <meta property="og:url" content="$canonical">\n~ if length $canonical; | |
| 127 | $h .= qq~ <meta name="twitter:card" content="$twit_card">\n~; | |
| 128 | $h .= qq~ <meta name="twitter:title" content="$title">\n~; | |
| 129 | $h .= qq~ <meta name="twitter:description" content="$description">\n~ if length $description; | |
| 130 | $h .= qq~ <meta name="twitter:image" content="$og_image">\n~ if length $og_image; | |
| 131 | $h .= qq~ <meta name="twitter:site" content="$twit_site">\n~ if length $twit_site; | |
| 132 | return $h; | |
| 133 | } | |
| 134 | ||
| 135 | sub _trim { | |
| 136 | my ($s, $max) = @_; | |
| 137 | return '' unless defined $s; | |
| 138 | $s =~ s/^\s+|\s+$//g; | |
| 139 | $s =~ s/[\r\n]+/ /g; | |
| 140 | $max ||= 255; | |
| 141 | return substr($s, 0, $max); | |
| 142 | } | |
| 143 | ||
| 144 | 1; |