Plugin Directory

Changeset 3423355


Ignore:
Timestamp:
12/19/2025 05:33:40 AM (2 months ago)
Author:
sethsm
Message:

Update to version 4.3 from GitHub

Location:
search-appearance-toolkit-seo-44
Files:
2 added
22 edited
1 copied

Legend:

Unmodified
Added
Removed
  • search-appearance-toolkit-seo-44/tags/4.3/README.md

    r3405454 r3423355  
    88* **Tags:** seo, on-page seo, schema, structured data, xml sitemaps
    99* **Requires at least:** 5.5
    10 * **Tested up to:** 6.8
    11 * **Stable tag:** 4.2.0
     10* **Tested up to:** 6.9
     11* **Stable tag:** 4.3.0
    1212* **Requires PHP:** 7.4
    1313* **License:** GPLv2 or later
     
    8585* **Knowledge Graph Control:** A dedicated interface to manage your brand's digital identity. Define your Founder, Founding Date, Contact Info, and professional Credentials to improve E-E-A-T signals.
    8686* **Include Images and Videos:** A built-in tool automatically finds all images and embedded YouTube videos in your content and adds them to the schema, boosting their appearance in search results.
    87 * **FAQ and How-To Detection:** Enable a smart scanner to detect patterns in your content for FAQ and How-To sections on your website and incorporate this useful format into the schema.
     87* **FAQ and How-To Detection:** Enable smart scanners to detect patterns in your content for FAQ and How-To sections on your website and incorporate this useful format into the schema.
     88* **Jump Links HowTo Scanner:** The plugin can use a Jump Links Block as a "Map" to generate detailed HowTo schema steps, while simultaneously scanning your content for Prep Time, Yield, Supplies, and Tools.
     89* **Table of Contents Schema:** Automatically generates `hasPart` structured data that mirrors your Jump Links Block, helping search engines understand your article's deep structure.
    8890* **Modern Output:** All structured data is generated in the modern JSON-LD format preferred by search engines, following the guidelines set by [Schema.org](https://schema.org/).
    8991* **Granular Control:**  Tailor the Schema settings to fit your site's needs through Enable/disable settings, including on Custom Post Types and Taxonomies.
     
    128130SEO 44's integration with Google Tag Manager pushes valuable events to the `dataLayer` for use in Google Analytics. The plugin provides an import file and detailed instructions for setting up Google Tag Manager and Google Analytics to receive event tracking data.
    129131
    130 ## Site Verification Tags
     132### Site Verification Tags
    131133Verify your site with search engines quickly and easily. Paste your verification codes for **Google Search Console** and **Bing Webmaster Tools** into the corresponding fields in the Integrations tab. The plugin handles the rest, adding site verification meta tags to your website's head. No coding required.
     134
     135### YouTube Data API
     136To ensure that your site's video schema is as accurate as possible, you may add your YouTube Data API Key.  The plugin uses this key to fetch the upload date for any YouTube video embedded in your content, replacing less reliable page scraping options and fallbacks to the post publish date.
    132137
    133138---
     
    138143Search Appearance Toolkit (SEO 44) gives you control over the technical, on-page SEO factors that help search engines understand and rank your content. Key benefits include:
    139144* **Optimized Snippets:** Control how your titles and descriptions appear in search results.
    140 * **Rich Results:** The advanced Schema.org data helps you earn rich results like FAQs, How-Tos, and breadcrumbs in Google.
     145* **Rich Results:** The advanced Schema.org data improves indexing and helps you earn rich results in Google.
    141146* **Crawlability:** A clean and comprehensive XML sitemap ensures search engines can find and index all of your important content and images.
    142147* **User Engagement:** The Jump Links Block improves user experience, which is a positive ranking signal, and can help you earn "Jump to" links in search results.
     
    170175SEO 44 helps your content look great when shared on social media platforms. You can enable the automatic generation of **Open Graph** (og:) tags, which Facebook, LinkedIn, and Pinterest use, and **Twitter Card** meta tags for when your content appears on X (formerly Twitter). This ensures your posts have the correct title, description, and preview image when shared.
    171176
    172 Use the plugin's Google Tag Manager Integration to facilitate additional connections with soclal media platforms. Use the Organization Schema settings to associate all of your social media profiles with your website.
     177Use the plugin's Google Tag Manager Integration to facilitate additional connections with social media platforms. Use the Organization Schema settings to associate all of your social media profiles with your website.
    173178
    174179### How are social media images handled?
     
    193198
    194199### What are the benefits of using FAQPage and HowTo schema?
    195 The plugin intelligently scans your content for patterns that match question-and-answer formats or step-by-step instructions (when this option is enabled). The plugin locates this content and automatically generates FAQPage or HowTo schema that presents this content within the JSON-LD.
    196 
    197 The benefit of this is that Google can use this structured data to display your content in special, highly visible formats in the search results. An FAQ page might appear as a rich snippet with expandable questions, while a How-To article can be featured in a step-by-step guide. Rich snippets make your search results stand out, which can significantly improve your click-through rate (CTR).
     200The plugin intelligently scans your content for patterns that match question-and-answer formats or step-by-step instructions (when this option is enabled). The plugin locates this content and automatically generates FAQPage or HowTo schema that presents this content as structured data.
     201
     202The benefit of this is that Google can use this structured data to display your content in special, highly visible formats in the search results. An FAQ page might appear as a rich snippet with expandable questions or feature within a People Also Ask (PAA) result, while a How-To article can be featured in a step-by-step guide in AI Overviews. Search results that stand out can improve your click-through rate (CTR).
    198203
    199204Read more about the [FAQPage and HowTo Schema](https://seo44plugin.com/search-appearance-toolkit-seo-44/schema-structured-data/#benefits-of-faqpage-and-howto-schema) created by SEO 44.
     
    457462    * **Google:** [Terms of Service](https://policies.google.com/terms), [Privacy Policy](https://policies.google.com/privacy)
    458463    * **Bing (Microsoft):** [Microsoft Services Agreement](https://www.microsoft.com/en-us/servicesagreement/), [Microsoft Privacy Statement](https://privacy.microsoft.com/en-us/privacystatement)
     464 
     465### YouTube Data API Integration & Video Metadata
     466
     467* **Service Description:** This plugin connects to YouTube to fetch the "Upload Date" for videos embedded in your content. This ensures your VideoObject schema is accurate.
     468* **Data Sent and Conditions:**
     469    * **Method 1 (API):** If you provide a YouTube API Key in settings, the plugin sends the Video ID to the Google Data API.
     470    * **Method 2 (Public Fallback):** If no API Key is present, the plugin acts as a standard browser and fetches the public video page (via `wp_remote_get`) to locate the upload date in the page meta tags.
     471    * **Conditions:** This occurs automatically when a post with a YouTube embed is updated, provided that Schema generation is enabled.
     472* **Service Provider Links:**
     473    * **YouTube:** [Terms of Service](https://www.youtube.com/t/terms), [Google Privacy Policy](https://policies.google.com/privacy)
    459474
    460475---
     
    574589
    575590## Changelog
     591
     592### 4.3.0
     593* **FEATURE:** **Table of Contents Schema:** Automatically generates `hasPart` schema synchronized with the Jump Links Block to support "Jump-to" links in search results.
     594* **FEATURE:** **Advanced HowTo Schema:** A new "Block-Aware" scanner uses the Jump Links Block to strictly define steps while intelligently mining the introduction for tools, time, yield, and video data.
     595* **TWEAK:** Added a "Generate HowTo Schema" checkbox to the SEO 44 metabox, giving users explicit control over when the advanced scanner runs.
     596* **FEATURE:** **YouTube Integration:** Added support for the YouTube Data API v3 to fetch accurate video upload dates for VideoObject schema.
     597* **TESTED:** Tested to WordPress Version 6.9
    576598
    577599### 4.2.0
     
    585607* **TWEAK:** Updated the image uploader JavaScript to support multiple distinct upload buttons on the settings page.
    586608
    587 ### 4.1.0
    588 **Jump Links Block Updates:**
    589 * **FEATURE:** **Sticky Positioning:** Keep your table of contents visible while users scroll. Includes a "Top Offset" slider to clear sticky headers, a "Jump Offset" slider to ensure that the sticky header does not cover the heading text, and a "Disable on Mobile" toggle to preserve screen space on small devices.
    590 * **FEATURE:** **Auto-Hide Title:** Implemented a smart "sticky state" detection. When the block sticks, the title gently collapses and fades out to keep the interface clean (this occurs when a block title is used alongside sticky positioning).
    591 * **FEATURE:** **Smart Indentation:** Added a "Create Visual Hierarchy" toggle. When enabled, H3 and H4 sub-headings are visually indented to create a clear, nested outline structure.
    592 * **FEATURE:** **Block Background:** You can now set a background color for the entire block container, perfect for creating "card-style" floating navigation.
    593 * **FEATURE:** **ScrollSpy:** Automatically highlights the active link in the table of contents as the user scrolls through the corresponding section of the post.
    594 * **FEATURE:** Added support for Border and Spacing controls. You can now add borders, rounded corners, margins, and padding to the Jump Links block directly from the editor settings.
    595 * **FEATURE:** Added a "Title tag" control. You can now choose the specific HTML tag (H2, H3, H4, H5, Paragraph, or Div) for the "On This Page" heading to better match your document structure.
    596 * **REFACTOR:** Optimized the block's styling logic to use CSS variables on the parent container instead of inline styles for every link. This reduces the block's HTML size and improves rendering performance.
    597 * **FIX:** Resolved an accessibility and HTML validation issue where using multiple Jump Links blocks on a single page created duplicate element IDs. Each block now generates a unique instance ID.
    598 * **PERFORMANCE:** Refactored the front-end JavaScript to use event delegation for smooth scrolling. This reduces memory usage by attaching a single event listener to the block instead of individual listeners for every link.
    599 * **TWEAK:** Reorganized the sidebar settings for better clarity between Block Title settings and Content Inclusion settings.
    600 
    601 ### 4.0.0
    602 * **FEATURE:** Added a new "Integrations" tab for third-party services like Google Tag Manager and Webmaster Tools.
    603 * **FEATURE:** Added Google Tag Manager (GTM) integration. The plugin can now automatically inject the GTM container script into the site's <head> and <body> based on your ID.
    604 * **FEATURE:** Added Webmaster Verification. You can now add your Google Search Console and Bing Webmaster Tools verification codes directly from the plugin settings.
    605 * **FEATURE:** Added automatic GTM event tracking. When enabled, the plugin can push the following events to the dataLayer:
    606     * Rich SEO dataLayer: Pushes page type, category, author, and tags on page load for advanced GTM triggers.
    607     * Scroll Depth Tracking: Pushes 'scroll_depth' events at 25%, 50%, 75%, and 100% of the page.
    608     * External & Affiliate Clicks:* Pushes 'external_link_click' or 'affiliate_link_click' (for `rel="sponsored"` links).
    609     * Jump Link Clicks: Pushes a 'jump_link_click' event when a user clicks a link in the Jump Links Block.
    610 * **FEATURE:** Added a Google Tag Manager recipe import file (`seo44-gtm-recipe-importer.json`) and new FAQ instructions to fully configure GTM and GA4 event tracking.
    611 * **ENHANCEMENT:** Centralized all GTM event tracking into a new, efficient `global-tracker.js` file that uses event delegation for better performance.
    612 * **TWEAK:** Improved the "Integrations" settings page UI for clarity, adding clarifying tooltips and a file downloader.
    613 
    614 For a complete list of changes, please see the [full changelog](https://github.com/SethSmigelski/search-appearance-toolkit-seo-44/blob/main/changelog.txt) or the `changelog.txt` file included with the plugin.
    615 
    616609---
    617610
  • search-appearance-toolkit-seo-44/tags/4.3/build/index.asset.php

    r3405454 r3423355  
    1 <?php return array('dependencies' => array('react', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '9e72772c3731a4c27337');
     1<?php return array('dependencies' => array('react', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '2c1769c1ec087de88890');
  • search-appearance-toolkit-seo-44/tags/4.3/build/index.js

    r3403604 r3423355  
    1 (()=>{"use strict";var e,t={313:()=>{const e=window.wp.blocks,t=window.React,a=window.wp.i18n,o=window.wp.blockEditor,l=window.wp.components,n=window.wp.data,r=window.wp.element;function s(e){return(new DOMParser).parseFromString(e,"text/html").body.textContent||""}const i=(0,t.createElement)("svg",{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M3 21v-2h18v2zm8-4v-6.175L9.4 12.4L8 11l4-4l4 4l-1.4 1.4l-1.6-1.575V17zM3 5V3h18v2z"})),c=(0,t.createElement)("svg",{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M3 5V3h18v2zm9 12l-4-4l1.4-1.4l1.6 1.575V7h2v6.175l1.6-1.575L16 13zm-9 4v-2h18v2z"})),h=(0,t.createElement)("svg",{className:"arrow-down",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 16l-6-6 1.41-1.41L12 13.17l4.59-4.58L18 10l-6 6z"})),p=JSON.parse('{"UU":"seo44/jump-links"}');(0,e.registerBlockType)(p.UU,{edit:function({attributes:e,setAttributes:p}){const{headingLevels:k,headings:u,showHeading:d,headingText:m,headingTag:g,layout:_,listStyle:b,isEditing:v,isCollapsible:f,isSmartIndentation:x,fontSize:w,textColor:C,linkColor:E,blockBackgroundColor:y,linkBackgroundColor:B,linkBackgroundColorHover:S,linkBorderColor:z,linkBorderRadius:T,isSticky:N,stickyOffset:H,jumpOffset:M,stickyStrategy:P}=e,O={color:C,fontSize:w,"--jump-link-font-size":w||"18px","--seo44-link-color":E,"--seo44-link-bg":"horizontal"===_?B:void 0,"--seo44-link-hover-bg":"horizontal"===_?S:void 0,"--seo44-link-border-color":"horizontal"===_?z:void 0,"--seo44-link-radius":"horizontal"===_&&T?`${T}px`:void 0,"--seo44-block-bg":y,"--seo44-sticky-offset":N?`${H}px`:void 0},I="ol"===b?"ol":"ul",{createInfoNotice:L}=(0,n.useDispatch)("core/notices"),j=e.blockInstanceId?`seo44-jump-links-list-${e.blockInstanceId}`:"seo44-jump-links-list",$=(0,o.useBlockProps)({style:O});$.className=`${$.className} ${"horizontal"===_?"is-layout-horizontal":""} ${f&&!v?"is-collapsible":""} ${"none"===b?"list-style-none":""}`.trim();const D=(0,n.useSelect)(e=>e("core/block-editor").getBlocks(),[]),{updateBlockAttributes:V}=(0,n.useDispatch)("core/block-editor");(0,r.useEffect)(()=>{const t={};e.blockInstanceId||(t.blockInstanceId=Math.random().toString(36).substr(2,9)),Object.keys(t).length>0&&p(t);const o=D.filter(e=>"core/heading"===e.name&&k.includes(`h${e.attributes.level}`)),l=new Set;let n=!1;const r=new Map(u.map(e=>[e.anchor,e])),i=[];for(const e of o){const t=s(e.attributes.content);let a=e.attributes.anchor||t.toLowerCase().replace(/[^a-z0-9\s-]/g,"").trim().replace(/\s+/g,"-"),o=a,c=2;for(;l.has(o);)o=`${a}-${c}`,c++,n=!0;l.add(o),e.attributes.anchor!==o&&V(e.clientId,{anchor:o});const h=r.get(e.attributes.anchor)||r.get(o),p=h&&h.linkText!==h.text?h.linkText:t,k=!h||h.isVisible;i.push({anchor:o,text:t,linkText:p,isVisible:k,level:e.attributes.level})}JSON.stringify(i)!==JSON.stringify(u)&&p({headings:i}),n&&L((0,a.__)("Jump Links Block: Duplicate headings were found. Unique IDs have been auto-generated, but this may be a sign of redundancy. Please review your headings for clarity.","search-appearance-toolkit-seo-44"),{type:"snackbar"})},[D,k,u,e.blockInstanceId,p,V,L]),(0,r.useEffect)(()=>{"horizontal"===_&&"none"!==b&&p({listStyle:"none"})},[_,b,p]);const R=(e,t)=>{const a=[...u],o=a.splice(e,1)[0];"up"===t?a.splice(e-1,0,o):a.splice(e+1,0,o),p({headings:a})},J=e=>{const t=k.includes(e)?k.filter(t=>t!==e):[...k,e];p({headingLevels:t.sort()})};return(0,t.createElement)(r.Fragment,null,(0,t.createElement)(o.InspectorControls,null,(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Presentation","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ButtonGroup,null,(0,t.createElement)(l.Button,{isPrimary:!v,isPressed:!v,onClick:()=>p({isEditing:!1})},(0,a.__)("Viewing Mode","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.Button,{isPrimary:v,isPressed:v,onClick:()=>p({isEditing:!0})},(0,a.__)("Editing Mode","search-appearance-toolkit-seo-44"))),(0,t.createElement)("p",{className:"description"},(0,a.__)("Switch to Editing Mode to customize link text, visibility, and order.","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Appearance","search-appearance-toolkit-seo-44")},(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Layout","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.ButtonGroup,null,(0,t.createElement)(l.Button,{isPrimary:"vertical"===_,isPressed:"vertical"===_,onClick:()=>p({layout:"vertical"})},(0,a.__)("Vertical","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.Button,{isPrimary:"horizontal"===_,isPressed:"horizontal"===_,onClick:()=>p({layout:"horizontal"})},(0,a.__)("Horizontal","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Make Jump Links Area Expandable","search-appearance-toolkit-seo-44"),help:(0,a.__)('Conserve screen space by collapsing a long list of jump links, providing users with an elegant "show more" button to see the entire list.',"search-appearance-toolkit-seo-44"),checked:f,onChange:()=>p({isCollapsible:!f}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(l.SelectControl,{label:(0,a.__)("List Style","search-appearance-toolkit-seo-44"),value:b,options:[{label:(0,a.__)("Bulleted","search-appearance-toolkit-seo-44"),value:"ul"},{label:(0,a.__)("Numbered","search-appearance-toolkit-seo-44"),value:"ol"},{label:(0,a.__)("None","search-appearance-toolkit-seo-44"),value:"none"}],onChange:e=>p({listStyle:e}),disabled:"horizontal"===_,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.FontSizePicker,{fontSizes:[{name:(0,a.__)("S","search-appearance-toolkit-seo-44"),slug:"small",size:"14px"},{name:(0,a.__)("M","search-appearance-toolkit-seo-44"),slug:"normal",size:"17px"},{name:(0,a.__)("L","search-appearance-toolkit-seo-44"),slug:"large",size:"20px"},{name:(0,a.__)("XL","search-appearance-toolkit-seo-44"),slug:"extra-large",size:"23px"}],value:w,onChange:e=>p({fontSize:e}),withReset:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(o.PanelColorSettings,{title:(0,a.__)("Colors","search-appearance-toolkit-seo-44"),colorSettings:[{value:y,onChange:e=>p({blockBackgroundColor:e}),label:(0,a.__)("Block Background","search-appearance-toolkit-seo-44")},{value:E,onChange:e=>p({linkColor:e}),label:(0,a.__)("Link Color","search-appearance-toolkit-seo-44")},{value:C,onChange:e=>p({textColor:e}),label:(0,a.__)("Other Text Color","search-appearance-toolkit-seo-44")}]}),"horizontal"===_&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)("hr",null),(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Horizontal Link Styles","search-appearance-toolkit-seo-44"))),(0,t.createElement)(o.PanelColorSettings,{title:(0,a.__)("Link Colors","search-appearance-toolkit-seo-44"),colorSettings:[{value:B,onChange:e=>p({linkBackgroundColor:e}),label:(0,a.__)("Background","search-appearance-toolkit-seo-44")},{value:S,onChange:e=>p({linkBackgroundColorHover:e}),label:(0,a.__)("Background Hover","search-appearance-toolkit-seo-44")},{value:z,onChange:e=>p({linkBorderColor:e}),label:(0,a.__)("Border","search-appearance-toolkit-seo-44")}]}),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Link Border Radius","search-appearance-toolkit-seo-44"),value:T,onChange:e=>p({linkBorderRadius:e}),min:0,max:50,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}))),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Content Settings","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Display Block Title","search-appearance-toolkit-seo-44"),checked:d,onChange:()=>p({showHeading:!d}),__nextHasNoMarginBottom:!0}),d&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)(l.TextControl,{label:(0,a.__)("Title Text","search-appearance-toolkit-seo-44"),value:m,onChange:e=>p({headingText:e}),help:(0,a.__)("The text that appears above your list of links.","search-appearance-toolkit-seo-44")}),(0,t.createElement)(l.SelectControl,{label:(0,a.__)("Title Tag","search-appearance-toolkit-seo-44"),value:g,options:[{label:"H2",value:"h2"},{label:"H3",value:"h3"},{label:"H4",value:"h4"},{label:"H5",value:"h5"},{label:"Paragraph (Bold)",value:"p"},{label:"Div (No Semantic Value)",value:"div"}],onChange:e=>p({headingTag:e}),help:(0,a.__)("Choose a level that fits your page's structure.","search-appearance-toolkit-seo-44"),__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0})),(0,t.createElement)("hr",null),(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Included Headings","search-appearance-toolkit-seo-44"))),(0,t.createElement)("p",{className:"description",style:{marginBottom:"10px"}},(0,a.__)("Select which heading levels from your post content should appear in the jump links list.","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.CheckboxControl,{label:"H2",checked:k.includes("h2"),onChange:()=>J("h2")}),(0,t.createElement)(l.CheckboxControl,{label:"H3",checked:k.includes("h3"),onChange:()=>J("h3")}),(0,t.createElement)(l.CheckboxControl,{label:"H4",checked:k.includes("h4"),onChange:()=>J("h4")}),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Create Visual Hierarchy","search-appearance-toolkit-seo-44"),help:(0,a.__)("Indents sub-headings (H3, H4) to create a nested outline structure.","search-appearance-toolkit-seo-44"),checked:x,onChange:()=>p({isSmartIndentation:!x}),__nextHasNoMarginBottom:!0})),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Position Settings","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Sticky Position","search-appearance-toolkit-seo-44"),help:(0,a.__)("Keep the table of contents visible while scrolling.","search-appearance-toolkit-seo-44"),checked:N,onChange:()=>p({isSticky:!N}),__nextHasNoMarginBottom:!0}),N&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)("p",{className:"description",style:{marginBottom:"15px"}},(0,a.__)("Customize how the block behaves when it sticks to the top of the screen.","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Top Offset (px)","search-appearance-toolkit-seo-44"),help:(0,a.__)("The distance between the top of the screen and the block when stuck (useful for clearing sticky headers).","search-appearance-toolkit-seo-44"),value:H,onChange:e=>p({stickyOffset:e}),min:0,max:200,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Jump Offset (px)","search-appearance-toolkit-seo-44"),help:(0,a.__)("The buffer distance to stop *before* the heading. Increase this if your sticky header covers the text.","search-appearance-toolkit-seo-44"),value:M,onChange:e=>p({jumpOffset:e}),min:0,max:200,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Disable on Mobile","search-appearance-toolkit-seo-44"),help:(0,a.__)("Prevents the block from sticking on small screens to save reading space.","search-appearance-toolkit-seo-44"),checked:"desktop-only"===P,onChange:e=>p({stickyStrategy:e?"desktop-only":"always"}),__nextHasNoMarginBottom:!0})))),(0,t.createElement)("div",{...$},d&&(0,t.createElement)(o.RichText,{tagName:g,className:"wp-block-seo44-jump-links-heading",value:m,onChange:e=>p({headingText:e}),placeholder:(0,a.__)("On This Page","search-appearance-toolkit-seo-44")}),u.length>0?(0,t.createElement)("nav",{"aria-label":(0,a.__)("Table of contents","search-appearance-toolkit-seo-44")},(0,t.createElement)(I,{id:j},u.map((e,o)=>v?(0,t.createElement)("li",{key:e.anchor},(0,t.createElement)(l.TextControl,{value:e.linkText,onChange:e=>((e,t)=>{const a=[...u];a[e].linkText=t,p({headings:a})})(o,e)}),(0,t.createElement)("div",{className:"edit-controls-wrapper"},(0,t.createElement)("div",{className:"reorder-buttons"},(0,t.createElement)(l.Button,{icon:i,label:(0,a.__)("Move Up","search-appearance-toolkit-seo-44"),onClick:()=>R(o,"up"),disabled:0===o}),(0,t.createElement)(l.Button,{icon:c,label:(0,a.__)("Move Down","search-appearance-toolkit-seo-44"),onClick:()=>R(o,"down"),disabled:o===u.length-1})),(0,t.createElement)(l.ToggleControl,{label:!1!==e.isVisible?(0,a.__)("Included","search-appearance-toolkit-seo-44"):(0,a.__)("This Jump Link will not be shown","search-appearance-toolkit-seo-44"),checked:!1!==e.isVisible,onChange:()=>(e=>{const t=[...u];t[e].isVisible=!t[e].isVisible,p({headings:t})})(o),__nextHasNoMarginBottom:!0}))):!1!==e.isVisible&&(0,t.createElement)("li",{key:e.anchor,className:x?`seo44-jump-link-level-${e.level}`:""},(0,t.createElement)("a",{href:`#${e.anchor}`,onClick:e=>e.preventDefault()},e.linkText)))),!v&&f&&u.length>0&&(0,t.createElement)(l.Tooltip,{text:(0,a.__)("This button is functional on the front-end to expand the list.","search-appearance-toolkit-seo-44")},(0,t.createElement)("button",{type:"button",className:"seo-44-show-more","aria-label":(0,a.__)("Show More","search-appearance-toolkit-seo-44"),"aria-expanded":"false","aria-controls":j,onClick:()=>{L((0,a.__)('The "Show More" button is interactive on the published page.',"search-appearance-toolkit-seo-44"),{type:"snackbar"})}},h))):(0,t.createElement)("p",null,(0,a.__)("No headings found. Select a heading level in the block settings to generate links.","search-appearance-toolkit-seo-44"))))},save:function({attributes:e}){const{blockInstanceId:l,layout:n,isCollapsible:r,isSmartIndentation:s,headings:i,showHeading:c,headingText:h,headingTag:p,listStyle:k,fontSize:u,textColor:d,linkColor:m,blockBackgroundColor:g,linkBackgroundColor:_,linkBackgroundColorHover:b,linkBorderColor:v,linkBorderRadius:f,isSticky:x,stickyOffset:w,jumpOffset:C,stickyStrategy:E}=e,y={color:d,fontSize:u,"--jump-link-font-size":u||"18px","--seo44-block-bg":g,"--seo44-link-color":m,"--seo44-link-bg":"horizontal"===n?_:void 0,"--seo44-link-hover-bg":"horizontal"===n?b:void 0,"--seo44-link-border-color":"horizontal"===n?v:void 0,"--seo44-link-radius":"horizontal"===n&&f?`${f}px`:void 0,"--seo44-sticky-offset":x?`${w}px`:void 0},B="ol"===k?"ol":"ul",S=`seo44-jump-links-list-${l}`,z=o.useBlockProps.save({className:`${"horizontal"===n?"is-layout-horizontal":""} ${r?"is-collapsible":""} ${"none"===k?"list-style-none":""} ${x?"is-sticky":""} ${"desktop-only"===E?"sticky-desktop-only":""}`.trim(),style:y,"data-seo44-jump-offset":x?C:30}),T=(0,t.createElement)("svg",{className:"arrow-down",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 16l-6-6 1.41-1.41L12 13.17l4.59-4.58L18 10l-6 6z"})),N=(0,t.createElement)("svg",{className:"arrow-up",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14l-6-6z"}));return(0,t.createElement)("div",{...z},(0,t.createElement)("div",{className:"seo44-sticky-sentinel","aria-hidden":"true"}),c&&(0,t.createElement)(o.RichText.Content,{tagName:p||"h2",className:"wp-block-seo44-jump-links-heading",value:h}),i&&i.length>0&&(0,t.createElement)("nav",{"aria-label":(0,a.__)("Table of contents","search-appearance-toolkit-seo-44")},(0,t.createElement)(B,{id:S},i.filter(e=>!1!==e.isVisible).map(e=>(0,t.createElement)("li",{key:e.anchor,className:s?`seo44-jump-link-level-${e.level}`:""},(0,t.createElement)("a",{href:`#${e.anchor}`},e.linkText)))),r&&(0,t.createElement)("button",{type:"button",className:"seo-44-show-more","aria-label":(0,a.__)("Show More","search-appearance-toolkit-seo-44"),"aria-expanded":"false","aria-controls":S},T,N)))}})}},a={};function o(e){var l=a[e];if(void 0!==l)return l.exports;var n=a[e]={exports:{}};return t[e](n,n.exports,o),n.exports}o.m=t,e=[],o.O=(t,a,l,n)=>{if(!a){var r=1/0;for(h=0;h<e.length;h++){for(var[a,l,n]=e[h],s=!0,i=0;i<a.length;i++)(!1&n||r>=n)&&Object.keys(o.O).every(e=>o.O[e](a[i]))?a.splice(i--,1):(s=!1,n<r&&(r=n));if(s){e.splice(h--,1);var c=l();void 0!==c&&(t=c)}}return t}n=n||0;for(var h=e.length;h>0&&e[h-1][2]>n;h--)e[h]=e[h-1];e[h]=[a,l,n]},o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={57:0,350:0};o.O.j=t=>0===e[t];var t=(t,a)=>{var l,n,[r,s,i]=a,c=0;if(r.some(t=>0!==e[t])){for(l in s)o.o(s,l)&&(o.m[l]=s[l]);if(i)var h=i(o)}for(t&&t(a);c<r.length;c++)n=r[c],o.o(e,n)&&e[n]&&e[n][0](),e[n]=0;return o.O(h)},a=globalThis.webpackChunkseo_44_jump_links_block=globalThis.webpackChunkseo_44_jump_links_block||[];a.forEach(t.bind(null,0)),a.push=t.bind(null,a.push.bind(a))})();var l=o.O(void 0,[350],()=>o(313));l=o.O(l)})();
     1(()=>{"use strict";var e,t={313(){const e=window.wp.blocks,t=window.React,a=window.wp.i18n,o=window.wp.blockEditor,l=window.wp.components,n=window.wp.data,r=window.wp.element;function s(e){return(new DOMParser).parseFromString(e,"text/html").body.textContent||""}const i=(0,t.createElement)("svg",{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M3 21v-2h18v2zm8-4v-6.175L9.4 12.4L8 11l4-4l4 4l-1.4 1.4l-1.6-1.575V17zM3 5V3h18v2z"})),c=(0,t.createElement)("svg",{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M3 5V3h18v2zm9 12l-4-4l1.4-1.4l1.6 1.575V7h2v6.175l1.6-1.575L16 13zm-9 4v-2h18v2z"})),h=(0,t.createElement)("svg",{className:"arrow-down",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 16l-6-6 1.41-1.41L12 13.17l4.59-4.58L18 10l-6 6z"})),p=JSON.parse('{"UU":"seo44/jump-links"}');(0,e.registerBlockType)(p.UU,{edit:function({attributes:e,setAttributes:p}){const{headingLevels:k,headings:u,showHeading:d,headingText:m,headingTag:g,layout:_,listStyle:b,isEditing:v,isCollapsible:f,isSmartIndentation:x,fontSize:w,textColor:C,linkColor:E,blockBackgroundColor:y,linkBackgroundColor:B,linkBackgroundColorHover:S,linkBorderColor:z,linkBorderRadius:T,isSticky:N,stickyOffset:H,jumpOffset:M,stickyStrategy:P}=e,O={color:C,fontSize:w,"--jump-link-font-size":w||"18px","--seo44-link-color":E,"--seo44-link-bg":"horizontal"===_?B:void 0,"--seo44-link-hover-bg":"horizontal"===_?S:void 0,"--seo44-link-border-color":"horizontal"===_?z:void 0,"--seo44-link-radius":"horizontal"===_&&T?`${T}px`:void 0,"--seo44-block-bg":y,"--seo44-sticky-offset":N?`${H}px`:void 0},I="ol"===b?"ol":"ul",{createInfoNotice:L}=(0,n.useDispatch)("core/notices"),j=e.blockInstanceId?`seo44-jump-links-list-${e.blockInstanceId}`:"seo44-jump-links-list",$=(0,o.useBlockProps)({style:O});$.className=`${$.className} ${"horizontal"===_?"is-layout-horizontal":""} ${f&&!v?"is-collapsible":""} ${"none"===b?"list-style-none":""}`.trim();const D=(0,n.useSelect)(e=>e("core/block-editor").getBlocks(),[]),{updateBlockAttributes:V}=(0,n.useDispatch)("core/block-editor");(0,r.useEffect)(()=>{const t={};e.blockInstanceId||(t.blockInstanceId=Math.random().toString(36).substr(2,9)),Object.keys(t).length>0&&p(t);const o=D.filter(e=>"core/heading"===e.name&&k.includes(`h${e.attributes.level}`)),l=new Set;let n=!1;const r=new Map(u.map(e=>[e.anchor,e])),i=[];for(const e of o){const t=s(e.attributes.content);let a=e.attributes.anchor||t.toLowerCase().replace(/[^a-z0-9\s-]/g,"").trim().replace(/\s+/g,"-"),o=a,c=2;for(;l.has(o);)o=`${a}-${c}`,c++,n=!0;l.add(o),e.attributes.anchor!==o&&V(e.clientId,{anchor:o});const h=r.get(e.attributes.anchor)||r.get(o),p=h&&h.linkText!==h.text?h.linkText:t,k=!h||h.isVisible;i.push({anchor:o,text:t,linkText:p,isVisible:k,level:e.attributes.level})}JSON.stringify(i)!==JSON.stringify(u)&&p({headings:i}),n&&L((0,a.__)("Jump Links Block: Duplicate headings were found. Unique IDs have been auto-generated, but this may be a sign of redundancy. Please review your headings for clarity.","search-appearance-toolkit-seo-44"),{type:"snackbar"})},[D,k,u,e.blockInstanceId,p,V,L]),(0,r.useEffect)(()=>{"horizontal"===_&&"none"!==b&&p({listStyle:"none"})},[_,b,p]);const R=(e,t)=>{const a=[...u],o=a.splice(e,1)[0];"up"===t?a.splice(e-1,0,o):a.splice(e+1,0,o),p({headings:a})},J=e=>{const t=k.includes(e)?k.filter(t=>t!==e):[...k,e];p({headingLevels:t.sort()})};return(0,t.createElement)(r.Fragment,null,(0,t.createElement)(o.InspectorControls,null,(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Presentation","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ButtonGroup,null,(0,t.createElement)(l.Button,{isPrimary:!v,isPressed:!v,onClick:()=>p({isEditing:!1})},(0,a.__)("Viewing Mode","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.Button,{isPrimary:v,isPressed:v,onClick:()=>p({isEditing:!0})},(0,a.__)("Editing Mode","search-appearance-toolkit-seo-44"))),(0,t.createElement)("p",{className:"description"},(0,a.__)("Switch to Editing Mode to customize link text, visibility, and order.","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Appearance","search-appearance-toolkit-seo-44")},(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Layout","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.ButtonGroup,null,(0,t.createElement)(l.Button,{isPrimary:"vertical"===_,isPressed:"vertical"===_,onClick:()=>p({layout:"vertical"})},(0,a.__)("Vertical","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.Button,{isPrimary:"horizontal"===_,isPressed:"horizontal"===_,onClick:()=>p({layout:"horizontal"})},(0,a.__)("Horizontal","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Make Jump Links Area Expandable","search-appearance-toolkit-seo-44"),help:(0,a.__)('Conserve screen space by collapsing a long list of jump links, providing users with an elegant "show more" button to see the entire list.',"search-appearance-toolkit-seo-44"),checked:f,onChange:()=>p({isCollapsible:!f}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(l.SelectControl,{label:(0,a.__)("List Style","search-appearance-toolkit-seo-44"),value:b,options:[{label:(0,a.__)("Bulleted","search-appearance-toolkit-seo-44"),value:"ul"},{label:(0,a.__)("Numbered","search-appearance-toolkit-seo-44"),value:"ol"},{label:(0,a.__)("None","search-appearance-toolkit-seo-44"),value:"none"}],onChange:e=>p({listStyle:e}),disabled:"horizontal"===_,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.FontSizePicker,{fontSizes:[{name:(0,a.__)("S","search-appearance-toolkit-seo-44"),slug:"small",size:"14px"},{name:(0,a.__)("M","search-appearance-toolkit-seo-44"),slug:"normal",size:"17px"},{name:(0,a.__)("L","search-appearance-toolkit-seo-44"),slug:"large",size:"20px"},{name:(0,a.__)("XL","search-appearance-toolkit-seo-44"),slug:"extra-large",size:"23px"}],value:w,onChange:e=>p({fontSize:e}),withReset:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(o.PanelColorSettings,{title:(0,a.__)("Colors","search-appearance-toolkit-seo-44"),colorSettings:[{value:y,onChange:e=>p({blockBackgroundColor:e}),label:(0,a.__)("Block Background","search-appearance-toolkit-seo-44")},{value:E,onChange:e=>p({linkColor:e}),label:(0,a.__)("Link Color","search-appearance-toolkit-seo-44")},{value:C,onChange:e=>p({textColor:e}),label:(0,a.__)("Other Text Color","search-appearance-toolkit-seo-44")}]}),"horizontal"===_&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)("hr",null),(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Horizontal Link Styles","search-appearance-toolkit-seo-44"))),(0,t.createElement)(o.PanelColorSettings,{title:(0,a.__)("Link Colors","search-appearance-toolkit-seo-44"),colorSettings:[{value:B,onChange:e=>p({linkBackgroundColor:e}),label:(0,a.__)("Background","search-appearance-toolkit-seo-44")},{value:S,onChange:e=>p({linkBackgroundColorHover:e}),label:(0,a.__)("Background Hover","search-appearance-toolkit-seo-44")},{value:z,onChange:e=>p({linkBorderColor:e}),label:(0,a.__)("Border","search-appearance-toolkit-seo-44")}]}),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Link Border Radius","search-appearance-toolkit-seo-44"),value:T,onChange:e=>p({linkBorderRadius:e}),min:0,max:50,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}))),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Content Settings","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Display Block Title","search-appearance-toolkit-seo-44"),checked:d,onChange:()=>p({showHeading:!d}),__nextHasNoMarginBottom:!0}),d&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)(l.TextControl,{label:(0,a.__)("Title Text","search-appearance-toolkit-seo-44"),value:m,onChange:e=>p({headingText:e}),help:(0,a.__)("The text that appears above your list of links.","search-appearance-toolkit-seo-44")}),(0,t.createElement)(l.SelectControl,{label:(0,a.__)("Title Tag","search-appearance-toolkit-seo-44"),value:g,options:[{label:"H2",value:"h2"},{label:"H3",value:"h3"},{label:"H4",value:"h4"},{label:"H5",value:"h5"},{label:"Paragraph (Bold)",value:"p"},{label:"Div (No Semantic Value)",value:"div"}],onChange:e=>p({headingTag:e}),help:(0,a.__)("Choose a level that fits your page's structure.","search-appearance-toolkit-seo-44"),__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0})),(0,t.createElement)("hr",null),(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Included Headings","search-appearance-toolkit-seo-44"))),(0,t.createElement)("p",{className:"description",style:{marginBottom:"10px"}},(0,a.__)("Select which heading levels from your post content should appear in the jump links list.","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.CheckboxControl,{label:"H2",checked:k.includes("h2"),onChange:()=>J("h2")}),(0,t.createElement)(l.CheckboxControl,{label:"H3",checked:k.includes("h3"),onChange:()=>J("h3")}),(0,t.createElement)(l.CheckboxControl,{label:"H4",checked:k.includes("h4"),onChange:()=>J("h4")}),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Create Visual Hierarchy","search-appearance-toolkit-seo-44"),help:(0,a.__)("Indents sub-headings (H3, H4) to create a nested outline structure.","search-appearance-toolkit-seo-44"),checked:x,onChange:()=>p({isSmartIndentation:!x}),__nextHasNoMarginBottom:!0})),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Position Settings","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Sticky Position","search-appearance-toolkit-seo-44"),help:(0,a.__)("Keep the table of contents visible while scrolling.","search-appearance-toolkit-seo-44"),checked:N,onChange:()=>p({isSticky:!N}),__nextHasNoMarginBottom:!0}),N&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)("p",{className:"description",style:{marginBottom:"15px"}},(0,a.__)("Customize how the block behaves when it sticks to the top of the screen.","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Top Offset (px)","search-appearance-toolkit-seo-44"),help:(0,a.__)("The distance between the top of the screen and the block when stuck (useful for clearing sticky headers).","search-appearance-toolkit-seo-44"),value:H,onChange:e=>p({stickyOffset:e}),min:0,max:200,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Jump Offset (px)","search-appearance-toolkit-seo-44"),help:(0,a.__)("The buffer distance to stop *before* the heading. Increase this if your sticky header covers the text.","search-appearance-toolkit-seo-44"),value:M,onChange:e=>p({jumpOffset:e}),min:0,max:200,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Disable on Mobile","search-appearance-toolkit-seo-44"),help:(0,a.__)("Prevents the block from sticking on small screens to save reading space.","search-appearance-toolkit-seo-44"),checked:"desktop-only"===P,onChange:e=>p({stickyStrategy:e?"desktop-only":"always"}),__nextHasNoMarginBottom:!0})))),(0,t.createElement)("div",{...$},d&&(0,t.createElement)(o.RichText,{tagName:g,className:"wp-block-seo44-jump-links-heading",value:m,onChange:e=>p({headingText:e}),placeholder:(0,a.__)("On This Page","search-appearance-toolkit-seo-44")}),u.length>0?(0,t.createElement)("nav",{"aria-label":(0,a.__)("Table of contents","search-appearance-toolkit-seo-44")},(0,t.createElement)(I,{id:j},u.map((e,o)=>v?(0,t.createElement)("li",{key:e.anchor},(0,t.createElement)(l.TextControl,{value:e.linkText,onChange:e=>((e,t)=>{const a=[...u];a[e].linkText=t,p({headings:a})})(o,e)}),(0,t.createElement)("div",{className:"edit-controls-wrapper"},(0,t.createElement)("div",{className:"reorder-buttons"},(0,t.createElement)(l.Button,{icon:i,label:(0,a.__)("Move Up","search-appearance-toolkit-seo-44"),onClick:()=>R(o,"up"),disabled:0===o}),(0,t.createElement)(l.Button,{icon:c,label:(0,a.__)("Move Down","search-appearance-toolkit-seo-44"),onClick:()=>R(o,"down"),disabled:o===u.length-1})),(0,t.createElement)(l.ToggleControl,{label:!1!==e.isVisible?(0,a.__)("Included","search-appearance-toolkit-seo-44"):(0,a.__)("This Jump Link will not be shown","search-appearance-toolkit-seo-44"),checked:!1!==e.isVisible,onChange:()=>(e=>{const t=[...u];t[e].isVisible=!t[e].isVisible,p({headings:t})})(o),__nextHasNoMarginBottom:!0}))):!1!==e.isVisible&&(0,t.createElement)("li",{key:e.anchor,className:x?`seo44-jump-link-level-${e.level}`:""},(0,t.createElement)("a",{href:`#${e.anchor}`,onClick:e=>e.preventDefault()},e.linkText)))),!v&&f&&u.length>0&&(0,t.createElement)(l.Tooltip,{text:(0,a.__)("This button is functional on the front-end to expand the list.","search-appearance-toolkit-seo-44")},(0,t.createElement)("button",{type:"button",className:"seo-44-show-more","aria-label":(0,a.__)("Show More","search-appearance-toolkit-seo-44"),"aria-expanded":"false","aria-controls":j,onClick:()=>{L((0,a.__)('The "Show More" button is interactive on the published page.',"search-appearance-toolkit-seo-44"),{type:"snackbar"})}},h))):(0,t.createElement)("p",null,(0,a.__)("No headings found. Select a heading level in the block settings to generate links.","search-appearance-toolkit-seo-44"))))},save:function({attributes:e}){const{blockInstanceId:l,layout:n,isCollapsible:r,isSmartIndentation:s,headings:i,showHeading:c,headingText:h,headingTag:p,listStyle:k,fontSize:u,textColor:d,linkColor:m,blockBackgroundColor:g,linkBackgroundColor:_,linkBackgroundColorHover:b,linkBorderColor:v,linkBorderRadius:f,isSticky:x,stickyOffset:w,jumpOffset:C,stickyStrategy:E}=e,y={color:d,fontSize:u,"--jump-link-font-size":u||"18px","--seo44-block-bg":g,"--seo44-link-color":m,"--seo44-link-bg":"horizontal"===n?_:void 0,"--seo44-link-hover-bg":"horizontal"===n?b:void 0,"--seo44-link-border-color":"horizontal"===n?v:void 0,"--seo44-link-radius":"horizontal"===n&&f?`${f}px`:void 0,"--seo44-sticky-offset":x?`${w}px`:void 0},B="ol"===k?"ol":"ul",S=`seo44-jump-links-list-${l}`,z=o.useBlockProps.save({className:`${"horizontal"===n?"is-layout-horizontal":""} ${r?"is-collapsible":""} ${"none"===k?"list-style-none":""} ${x?"is-sticky":""} ${"desktop-only"===E?"sticky-desktop-only":""}`.trim(),style:y,"data-seo44-jump-offset":x?C:30}),T=(0,t.createElement)("svg",{className:"arrow-down",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 16l-6-6 1.41-1.41L12 13.17l4.59-4.58L18 10l-6 6z"})),N=(0,t.createElement)("svg",{className:"arrow-up",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14l-6-6z"}));return(0,t.createElement)("div",{...z},(0,t.createElement)("div",{className:"seo44-sticky-sentinel","aria-hidden":"true"}),c&&(0,t.createElement)(o.RichText.Content,{tagName:p||"h2",className:"wp-block-seo44-jump-links-heading",value:h}),i&&i.length>0&&(0,t.createElement)("nav",{"aria-label":(0,a.__)("Table of contents","search-appearance-toolkit-seo-44")},(0,t.createElement)(B,{id:S},i.filter(e=>!1!==e.isVisible).map(e=>(0,t.createElement)("li",{key:e.anchor,className:s?`seo44-jump-link-level-${e.level}`:""},(0,t.createElement)("a",{href:`#${e.anchor}`},e.linkText)))),r&&(0,t.createElement)("button",{type:"button",className:"seo-44-show-more","aria-label":(0,a.__)("Show More","search-appearance-toolkit-seo-44"),"aria-expanded":"false","aria-controls":S},T,N)))}})}},a={};function o(e){var l=a[e];if(void 0!==l)return l.exports;var n=a[e]={exports:{}};return t[e](n,n.exports,o),n.exports}o.m=t,e=[],o.O=(t,a,l,n)=>{if(!a){var r=1/0;for(h=0;h<e.length;h++){for(var[a,l,n]=e[h],s=!0,i=0;i<a.length;i++)(!1&n||r>=n)&&Object.keys(o.O).every(e=>o.O[e](a[i]))?a.splice(i--,1):(s=!1,n<r&&(r=n));if(s){e.splice(h--,1);var c=l();void 0!==c&&(t=c)}}return t}n=n||0;for(var h=e.length;h>0&&e[h-1][2]>n;h--)e[h]=e[h-1];e[h]=[a,l,n]},o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={57:0,350:0};o.O.j=t=>0===e[t];var t=(t,a)=>{var l,n,[r,s,i]=a,c=0;if(r.some(t=>0!==e[t])){for(l in s)o.o(s,l)&&(o.m[l]=s[l]);if(i)var h=i(o)}for(t&&t(a);c<r.length;c++)n=r[c],o.o(e,n)&&e[n]&&e[n][0](),e[n]=0;return o.O(h)},a=globalThis.webpackChunkseo_44_jump_links_block=globalThis.webpackChunkseo_44_jump_links_block||[];a.forEach(t.bind(null,0)),a.push=t.bind(null,a.push.bind(a))})();var l=o.O(void 0,[350],()=>o(313));l=o.O(l)})();
  • search-appearance-toolkit-seo-44/tags/4.3/changelog.txt

    r3405454 r3423355  
    11== Changelog ==
     2
     3= 4.3.0 =
     4* FEATURE: **Table of Contents Schema:** Automatically generates `hasPart` schema synchronized with the Jump Links Block to support "Jump-to" links in search results.
     5* FEATURE: **Advanced HowTo Schema:** A new "Block-Aware" scanner uses the Jump Links Block to strictly define steps while intelligently mining the introduction for tools, time, yield, and video data.
     6* TWEAK: Added a "Generate HowTo Schema" checkbox to the SEO 44 metabox, giving users explicit control over when the advanced scanner runs.
     7* FEATURE: **YouTube Integration:** Added support for the YouTube Data API v3 to fetch accurate video upload dates for VideoObject schema.
     8* TESTED: Tested to WordPress Version 6.9
    29
    310= 4.2.0 =
  • search-appearance-toolkit-seo-44/tags/4.3/includes/class-seo44-core.php

    r3403604 r3423355  
    11<?php
     2//4.3 - Added dependencies for block-editor-script.js to access Jump Links Block anchor ids for hasPart and HowTo schema.
    23class SEO44_Core {
    34
     
    6263    public function admin_enqueue_assets($hook) {
    6364        if ('post.php' == $hook || 'post-new.php' == $hook) {
     65            // CSS
    6466            wp_enqueue_style('seo44-admin-styles', plugins_url('../css/admin-styles.css', __FILE__), [], SEO44_VERSION);
    65             wp_enqueue_script('seo44-admin-script', plugins_url('../js/admin-script.js', __FILE__), ['jquery'], SEO44_VERSION, true);
     67            // General Admin Script (Classic + Block)
     68            wp_enqueue_script('seo44-admin-script', plugins_url('../js/admin-script.js', __FILE__),
     69                ['jquery'],
     70                SEO44_VERSION,
     71                true);
     72
     73            // Localize the General Script (needed for Snippet Preview)
    6674            wp_localize_script('seo44-admin-script', 'seo44_data', [
    6775                'post_title' => get_the_title(get_the_ID()),
     
    6977                'permalink' => get_permalink(get_the_ID())
    7078            ]);
     79            // Block Editor Specific Script (Passthrough Logic For Jump Links)
     80            // We enqueue this separately with the Block Editor dependencies.
     81            // WordPress will simply NOT load this file if these handles don't exist (e.g. Classic Editor).
     82            wp_enqueue_script('seo44-block-editor-script', plugins_url('../js/block-editor-script.js', __FILE__),
     83                ['wp-data', 'wp-editor', 'wp-blocks'], // Dependencies are important for Jump Links Block data
     84                SEO44_VERSION,
     85                true
     86            );
    7187        }
    7288       if ('settings_page_search-appearance-toolkit-seo-44' == $hook) {
  • search-appearance-toolkit-seo-44/tags/4.3/includes/class-seo44-frontend.php

    r3405454 r3423355  
    11<?php
     2// Version 4.3
     3// Create hasPart and HowTo schema from Jump Links Block using metafield passthrough
     4// Added YouTube Data API support for more accurate video upload date
     5
    26class SEO44_Frontend {
    37    public function __construct() {
     
    59        add_action('wp_head', [$this, 'output_header_tags']);
    610        add_action('wp_head', [$this, 'output_schema_json_ld'], 99);
     11        add_action('init', [$this, 'register_schema_meta_fields']); // Retrieve Jump Links Block data for schema via a hidden metabox field
    712
    813        $taxonomies = get_taxonomies(['public' => true]);
     
    1217        }
    1318    }
    14 
     19    // Get meta field from metabox for Jump Links Block + HowTo Schema
     20    public function register_schema_meta_fields() {
     21        register_post_meta('post', '_seo44_howto_step_ids', [
     22            'single'       => true,
     23            'type'         => 'array',
     24            'show_in_rest' => [
     25                'schema' => [
     26                    'type'  => 'array',
     27                    'items' => [
     28                        'type' => 'string',
     29                    ],
     30                ],
     31            ],
     32            'auth_callback' => function() { return current_user_can('edit_posts'); }
     33        ]);
     34    }
     35
     36    //Title
    1537    public function filter_document_title($title_parts) {
    16         if (!seo44_get_option('enable_tags')) return $title_parts;
    17        
     38        if (!seo44_get_option('enable_tags')) return $title_parts;
     39       
    1840        $custom_title = '';
    1941        $fallback_title = '';
     
    2345            $custom_title = seo44_get_option('homepage_title');
    2446        } elseif (is_singular()) {
    25             $custom_title = get_post_meta(get_the_ID(), seo44_get_option('title_key'), true);
     47            $custom_title = get_post_meta(get_the_ID(), seo44_get_option('title_key'), true);
    2648            $fallback_title = get_the_title(get_the_ID());
    27         } elseif (is_category() || is_tag() || is_tax()) {
     49        } elseif (is_category() || is_tag() || is_tax()) {
    2850            $term_id = get_queried_object_id();
    2951            $custom_title = get_term_meta($term_id, 'seo44_title', true);
     
    5274            unset($title_parts['site'], $title_parts['tagline']);
    5375        }
    54         return $title_parts;
    55     }
    56    
    57     public function add_term_meta_fields($term, $taxonomy) {
     76        return $title_parts;
     77    }
     78   
     79    // Meta Tags
     80    public function add_term_meta_fields($term, $taxonomy) {
    5881        $title = get_term_meta($term->term_id, 'seo44_title', true);
    5982        $description = get_term_meta($term->term_id, 'seo44_description', true);
     
    95118    }
    96119   
    97     public function output_header_tags() {
    98         if (!seo44_get_option('enable_tags')) { return; }
    99         $description = '';
    100         $social_title = '';
    101         $current_url = '';
    102    
    103         if (is_front_page()) {
    104             $description = seo44_get_option('homepage_description');
    105             $social_title = seo44_get_option('homepage_title') ?: get_bloginfo('name');
    106             $current_url = home_url('/');
    107         } elseif (is_singular()) {
    108             $post_id = get_the_ID();
    109             $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
    110             $custom_title = get_post_meta($post_id, seo44_get_option('title_key'), true);
    111             $social_title = $custom_title ?: get_the_title($post_id);
    112             $current_url = get_permalink($post_id);
     120    public function output_header_tags() {
     121        if (!seo44_get_option('enable_tags')) { return; }
     122        $description = '';
     123        $social_title = '';
     124        $current_url = '';
     125   
     126        if (is_front_page()) {
     127            $description = seo44_get_option('homepage_description');
     128            $social_title = seo44_get_option('homepage_title') ?: get_bloginfo('name');
     129            $current_url = home_url('/');
     130        } elseif (is_singular()) {
     131            $post_id = get_the_ID();
     132            $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
     133            $custom_title = get_post_meta($post_id, seo44_get_option('title_key'), true);
     134            $social_title = $custom_title ?: get_the_title($post_id);
     135            $current_url = get_permalink($post_id);
    113136            if (empty($description)) {
    114                 $post_content = get_the_content(null, false, get_the_ID());
     137                $post_content = get_the_content(null, false, get_the_ID());
    115138                $description = wp_trim_words(strip_shortcodes(wp_strip_all_tags($post_content)), 25, '...');
    116             }
    117            
    118         } elseif (is_category() || is_tag() || is_tax()) {
    119             $term = get_queried_object();
    120             $description = get_term_meta($term->term_id, 'seo44_description', true);
    121             $custom_title = get_term_meta($term->term_id, 'seo44_title', true);
    122             $social_title = $custom_title ?: single_term_title('', false) . ' - ' . get_bloginfo('name');
    123             $current_url = get_term_link($term);
     139            }
     140           
     141        } elseif (is_category() || is_tag() || is_tax()) {
     142            $term = get_queried_object();
     143            $description = get_term_meta($term->term_id, 'seo44_description', true);
     144            $custom_title = get_term_meta($term->term_id, 'seo44_title', true);
     145            $social_title = $custom_title ?: single_term_title('', false) . ' - ' . get_bloginfo('name');
     146            $current_url = get_term_link($term);
    124147            if (empty($description)) {
    125148                $term_description = trim(wp_strip_all_tags($term->description));
     
    130153                }
    131154            }
    132         }
    133    
    134         if (!empty($description)) {
    135             printf('<meta name="description" content="%s">' . "\n", esc_attr(wp_strip_all_tags(html_entity_decode($description, ENT_QUOTES, 'UTF-8'))));
    136         }
    137    
    138         if (is_singular()) {
    139             $post_id = get_the_ID();
    140             $keywords = get_post_meta($post_id, seo44_get_option('keywords_key'), true);
    141             if (seo44_get_option('include_keywords') && !empty($keywords)) {
    142                 printf('<meta name="keywords" content="%s">' . "\n", esc_attr(wp_strip_all_tags(html_entity_decode($keywords, ENT_QUOTES, 'UTF-8'))));
    143             }
    144             if (is_singular() && seo44_get_option('include_author')) {
    145                 $author_id = get_post_field('post_author', $post_id);
    146                 if ($author_id) {
    147                     printf('<meta name="author" content="%s">' . "\n", esc_attr($this->get_author_name($author_id)));
    148                 }
    149             }
    150         }
    151    
    152         if (seo44_get_option('enable_og_tags') || seo44_get_option('enable_twitter_tags')) {
    153             $clean_social_title = esc_attr(wp_strip_all_tags(html_entity_decode($social_title, ENT_QUOTES, 'UTF-8')));
    154             $clean_description = !empty($description) ? esc_attr(wp_strip_all_tags(html_entity_decode($description, ENT_QUOTES, 'UTF-8'))) : '';
    155            
    156             if (empty($clean_description) && is_singular()) {
    157                  $clean_description = esc_attr(wp_strip_all_tags(get_the_excerpt(get_the_ID())));
    158             }
    159    
    160             $image_url = ''; $image_width = ''; $image_height = '';
    161             $image_id = 0;
    162             if (is_singular() && has_post_thumbnail()) {
    163                 $image_id = get_post_thumbnail_id();
    164             }
    165             if (!$image_id) {
    166                 $image_id = seo44_get_option('default_social_image_id', 0);
    167             }
    168             if ($image_id) {
    169                 $image_data = wp_get_attachment_image_src($image_id, 'full');
    170                 if ($image_data) {
    171                     list($image_url, $image_width, $image_height) = $image_data;
    172                 }
    173             }
    174    
    175             if (seo44_get_option('enable_og_tags')) {
    176                 printf('<meta property="og:title" content="%s">' . "\n", esc_attr($clean_social_title));
    177                 printf('<meta property="og:url" content="%s">' . "\n", esc_url($current_url));
    178                 printf('<meta property="og:site_name" content="%s">' . "\n", esc_attr(get_bloginfo('name')));
    179                 if (!empty($clean_description)) { printf('<meta property="og:description" content="%s">' . "\n", esc_attr($clean_description)); }
    180                 if (is_singular('post')) {
    181                      echo '<meta property="og:type" content="article">' . "\n";
    182                 } else {
    183                      echo '<meta property="og:type" content="website">' . "\n";
    184                 }
    185                 if (!empty($image_url)) {
    186                     printf('<meta property="og:image" content="%s">' . "\n", esc_url($image_url));
    187                     if (!empty($image_width)) { printf('<meta property="og:image:width" content="%s">' . "\n", esc_attr($image_width)); }
    188                     if (!empty($image_height)) { printf('<meta property="og:image:height" content="%s">' . "\n", esc_attr($image_height)); }
    189                 }
    190                 $fb_app_id = seo44_get_option('fb_app_id');
    191                 if (!empty($fb_app_id)) { printf('<meta property="fb:app_id" content="%s">' . "\n", esc_attr($fb_app_id)); }
    192             }
    193    
    194             if (seo44_get_option('enable_twitter_tags')) {
    195                 echo '<meta name="twitter:card" content="summary_large_image">' . "\n";
    196                 $twitter_handle = seo44_get_option('twitter_handle');
    197                 if ( !empty($twitter_handle) && is_string($twitter_handle) ) {
    198                     $handle = esc_attr(str_replace('@', '', $twitter_handle));
    199                     printf('<meta name="twitter:site" content="@%s">' . "\n", esc_attr($handle));
    200                     printf('<meta name="twitter:creator" content="@%s">' . "\n", esc_attr($handle));
    201                 }
    202                 printf('<meta name="twitter:title" content="%s">' . "\n", esc_attr($clean_social_title));
    203                 if (!empty($clean_description)) { printf('<meta name="twitter:description" content="%s">' . "\n", esc_attr($clean_description)); }
    204                 if (!empty($image_url)) { printf('<meta name="twitter:image" content="%s">' . "\n", esc_url($image_url)); }
    205             }
    206         }
    207     }
    208 
     155        }
     156   
     157        if (!empty($description)) {
     158            printf('<meta name="description" content="%s">' . "\n", esc_attr(wp_strip_all_tags(html_entity_decode($description, ENT_QUOTES, 'UTF-8'))));
     159        }
     160   
     161        if (is_singular()) {
     162            $post_id = get_the_ID();
     163            $keywords = get_post_meta($post_id, seo44_get_option('keywords_key'), true);
     164            if (seo44_get_option('include_keywords') && !empty($keywords)) {
     165                printf('<meta name="keywords" content="%s">' . "\n", esc_attr(wp_strip_all_tags(html_entity_decode($keywords, ENT_QUOTES, 'UTF-8'))));
     166            }
     167            if (is_singular() && seo44_get_option('include_author')) {
     168                $author_id = get_post_field('post_author', $post_id);
     169                if ($author_id) {
     170                    printf('<meta name="author" content="%s">' . "\n", esc_attr($this->get_author_name($author_id)));
     171                }
     172            }
     173        }
     174   
     175        if (seo44_get_option('enable_og_tags') || seo44_get_option('enable_twitter_tags')) {
     176            $clean_social_title = esc_attr(wp_strip_all_tags(html_entity_decode($social_title, ENT_QUOTES, 'UTF-8')));
     177            $clean_description = !empty($description) ? esc_attr(wp_strip_all_tags(html_entity_decode($description, ENT_QUOTES, 'UTF-8'))) : '';
     178           
     179            if (empty($clean_description) && is_singular()) {
     180                 $clean_description = esc_attr(wp_strip_all_tags(get_the_excerpt(get_the_ID())));
     181            }
     182   
     183            $image_url = ''; $image_width = ''; $image_height = '';
     184            $image_id = 0;
     185            if (is_singular() && has_post_thumbnail()) {
     186                $image_id = get_post_thumbnail_id();
     187            }
     188            if (!$image_id) {
     189                $image_id = seo44_get_option('default_social_image_id', 0);
     190            }
     191            if ($image_id) {
     192                $image_data = wp_get_attachment_image_src($image_id, 'full');
     193                if ($image_data) {
     194                    list($image_url, $image_width, $image_height) = $image_data;
     195                }
     196            }
     197   
     198            if (seo44_get_option('enable_og_tags')) {
     199                printf('<meta property="og:title" content="%s">' . "\n", esc_attr($clean_social_title));
     200                printf('<meta property="og:url" content="%s">' . "\n", esc_url($current_url));
     201                printf('<meta property="og:site_name" content="%s">' . "\n", esc_attr(get_bloginfo('name')));
     202                if (!empty($clean_description)) { printf('<meta property="og:description" content="%s">' . "\n", esc_attr($clean_description)); }
     203                if (is_singular('post')) {
     204                     echo '<meta property="og:type" content="article">' . "\n";
     205                } else {
     206                     echo '<meta property="og:type" content="website">' . "\n";
     207                }
     208                if (!empty($image_url)) {
     209                    printf('<meta property="og:image" content="%s">' . "\n", esc_url($image_url));
     210                    if (!empty($image_width)) { printf('<meta property="og:image:width" content="%s">' . "\n", esc_attr($image_width)); }
     211                    if (!empty($image_height)) { printf('<meta property="og:image:height" content="%s">' . "\n", esc_attr($image_height)); }
     212                }
     213                $fb_app_id = seo44_get_option('fb_app_id');
     214                if (!empty($fb_app_id)) { printf('<meta property="fb:app_id" content="%s">' . "\n", esc_attr($fb_app_id)); }
     215            }
     216   
     217            if (seo44_get_option('enable_twitter_tags')) {
     218                echo '<meta name="twitter:card" content="summary_large_image">' . "\n";
     219                $twitter_handle = seo44_get_option('twitter_handle');
     220                if ( !empty($twitter_handle) && is_string($twitter_handle) ) {
     221                    $handle = esc_attr(str_replace('@', '', $twitter_handle));
     222                    printf('<meta name="twitter:site" content="@%s">' . "\n", esc_attr($handle));
     223                    printf('<meta name="twitter:creator" content="@%s">' . "\n", esc_attr($handle));
     224                }
     225                printf('<meta name="twitter:title" content="%s">' . "\n", esc_attr($clean_social_title));
     226                if (!empty($clean_description)) { printf('<meta name="twitter:description" content="%s">' . "\n", esc_attr($clean_description)); }
     227                if (!empty($image_url)) { printf('<meta name="twitter:image" content="%s">' . "\n", esc_url($image_url)); }
     228            }
     229        }
     230    }
     231
     232    // Now For The Schema Structured Data
    209233    public function output_schema_json_ld() {
    210234        // Note: The following line is intentionally not nonce-checked.
    211         // This is a safe, read-only check used by the admin-side scanner to get a clean view of the page
    212         // without our own schema being output. It does not process or save any data.
    213235        $scan_param = isset($_GET['seo44_scan']) ? sanitize_key(wp_unslash($_GET['seo44_scan'])) : '';
    214236        if ($scan_param === 'true') { return; }
     
    218240        $base_schema = [];
    219241        $special_schemas = [];
    220         $breadcrumb_schema = []; // New variable for breadcrumbs
     242        $breadcrumb_schema = []; // New variable for breadcrumbs
    221243        $post_id = get_the_ID();
    222244
     
    225247            $base_schema = $this->get_schema_for_website();
    226248
    227             // NEW: Add Organization Schema to homepage
    228             if (seo44_get_option('enable_organization_schema')) {
    229                 $org_schema = $this->get_schema_for_organization();
    230                 if (!empty($org_schema)) {
    231                     $special_schemas[] = $org_schema;
    232                 }
    233             }
    234            
     249            // Add Organization Schema to homepage
     250            if (seo44_get_option('enable_organization_schema')) {
     251                $org_schema = $this->get_schema_for_organization();
     252                if (!empty($org_schema)) {
     253                    $special_schemas[] = $org_schema;
     254                }
     255            }
     256           
    235257        } elseif ( (is_category() || is_tag() || is_tax()) && seo44_get_option('enable_schema_on_taxonomies') ) {
    236258            $base_schema = $this->get_schema_for_taxonomy();
     
    246268        }
    247269
    248         // 3. Attempt to generate special schema
    249         if (is_singular() && seo44_get_option('enable_advanced_schema')) {
    250             $detected_schemas = $this->detect_and_generate_special_schema($post_id);
    251             // Merge instead of overwrite - important for organization schema
    252             if (!empty($detected_schemas)) {
    253                 $special_schemas = array_merge($special_schemas, $detected_schemas);
    254             }
    255         }
    256        
    257        
    258         // 4.  Hook for Add-ons ---
    259         // This filter allows other plugins to add their own custom schema parts to the graph.
    260         $addon_schema_parts = apply_filters( 'seo44_add_schema_parts', [], $post_id );
    261         // --- End Hook ---
    262 
    263         // 5. Combine all schemas for output
    264         $final_schema_parts = [];
     270        // 3. Attempt to generate special schema (FAQ / HowTo)
     271        if (is_singular() && seo44_get_option('enable_advanced_schema')) {
     272            $detected_schemas = $this->detect_and_generate_special_schema($post_id);
     273            // Merge instead of overwrite (Prevents erasing Organization schema on static pages)
     274            if (!empty($detected_schemas)) {
     275                $special_schemas = array_merge($special_schemas, $detected_schemas);
     276            }
     277        }
     278       
     279        // 4.  Hook for Add-ons ---
     280        // This filter allows other plugins to add their own custom schema parts to the graph.
     281        $addon_schema_parts = apply_filters( 'seo44_add_schema_parts', [], $post_id );
     282        // --- End Hook ---
     283
     284        // 5. Combine all schemas for output
     285        $final_schema_parts = [];
    265286        if (!empty($base_schema)) {
    266287            $final_schema_parts[] = $base_schema;
     
    272293            $final_schema_parts = array_merge($final_schema_parts, $special_schemas);
    273294        }
    274        
    275         // include any add-on schemas that pass is_array sanity check
    276         if ( ! empty( $addon_schema_parts ) && is_array( $addon_schema_parts ) ) {
    277             $final_schema_parts = array_merge( $final_schema_parts, $addon_schema_parts );
    278         }
     295       
     296        // include any add-on schemas that pass is_array sanity check
     297        if ( ! empty( $addon_schema_parts ) && is_array( $addon_schema_parts ) ) {
     298            $final_schema_parts = array_merge( $final_schema_parts, $addon_schema_parts );
     299        }
    279300       
    280301        $final_schema = [];
     
    301322        }
    302323    }
    303    
    304     // --- Helper Functions ---
     324   
     325    // --- Helper Functions ---
    305326
    306327    // --- Generate BreadcrumbList Schema for Singular Content ---
     
    363384        ];
    364385    }
    365    
     386   
    366387    // --- Helper Function For Types ---
    367388    public static function get_schema_for_post($post_id) {
    368         global $post;
    369         $post = get_post($post_id);
    370         setup_postdata($post);
    371        
    372         // Get the author's ID and website URL to refer authorship
    373         $author_id = $post->post_author;
    374         $author_url = get_the_author_meta('user_url', $author_id);
    375    
    376         // Build the author schema array
    377         $author_schema = [
    378             '@type' => 'Person',
    379             'name'  => self::get_author_name_static( $author_id ), // Pass the author ID
    380         ];
    381    
    382         // If the author has a website, add the @id and url properties
    383         if ( ! empty( $author_url ) ) {
    384             // Ensure the URL has a trailing slash before adding the fragment.
    385             $canonical_author_url = trailingslashit( $author_url );
    386        
    387             $author_schema['@id'] = $canonical_author_url . '#person';
    388             $author_schema['url'] = $canonical_author_url;
    389         }
    390    
    391         $schema = [
    392             '@context'         => 'https://schema.org',
    393             '@type'            => 'Article',
    394             'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => get_permalink($post_id)],
    395             'headline'         => get_the_title($post_id),
    396             'datePublished'    => get_the_date('c', $post_id),
    397             'dateModified'     => get_the_modified_date('c', $post_id),
    398             'author'           => $author_schema, // Use the author schema array
    399             'publisher'        => ['@type' => 'Organization', 'name' => get_bloginfo('name')]
    400         ];
    401         if (get_site_icon_url()) { $schema['publisher']['logo'] = ['@type' => 'ImageObject', 'url' => get_site_icon_url()]; }
    402         if (has_post_thumbnail($post_id)) {
    403             $image_id = get_post_thumbnail_id($post_id);
    404             $image_data = wp_get_attachment_image_src($image_id, 'full');
    405             if ($image_data) {
    406                 $schema['image'] = [
    407                     '@type' => 'ImageObject',
    408                     'url' => $image_data[0],
    409                     'width' => $image_data[1],
    410                     'height' => $image_data[2]
    411                 ];
    412             }
    413         }
    414         // NEW: Parse content for additional media if the setting is enabled
     389        global $post;
     390        $post = get_post($post_id);
     391        setup_postdata($post);
     392       
     393        // Get the author's ID and website URL to refer authorship
     394        $author_id = $post->post_author;
     395        $author_url = get_the_author_meta('user_url', $author_id);
     396   
     397        // Build the author schema array
     398        $author_schema = [
     399            '@type' => 'Person',
     400            'name'  => self::get_author_name_static( $author_id ), // Pass the author ID
     401        ];
     402   
     403        // If the author has a website, add the @id and url properties
     404        if ( ! empty( $author_url ) ) {
     405            // Ensure the URL has a trailing slash before adding the fragment.
     406            $canonical_author_url = trailingslashit( $author_url );
     407       
     408            $author_schema['@id'] = $canonical_author_url . '#person';
     409            $author_schema['url'] = $canonical_author_url;
     410        }
     411   
     412        $schema = [
     413            '@context'         => 'https://schema.org',
     414            '@type'            => 'Article',
     415            'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => get_permalink($post_id)],
     416            'headline'         => get_the_title($post_id),
     417            'datePublished'    => get_the_date('c', $post_id),
     418            'dateModified'     => get_the_modified_date('c', $post_id),
     419            'author'           => $author_schema, // Use the author schema array
     420            'publisher'        => ['@type' => 'Organization', 'name' => get_bloginfo('name')]
     421        ];
     422        if (get_site_icon_url()) { $schema['publisher']['logo'] = ['@type' => 'ImageObject', 'url' => get_site_icon_url()]; }
     423       
     424        // FIX: Initialize image key safely here to prevent "Undefined array key" warning
     425        $schema['image'] = []; // Default to empty array       
     426        if (has_post_thumbnail($post_id)) {
     427            $image_id = get_post_thumbnail_id($post_id);
     428            $image_data = wp_get_attachment_image_src($image_id, 'full');
     429            if ($image_data) {
     430                $schema['image'][] = [   
     431                    '@type' => 'ImageObject',
     432                    'url' => $image_data[0],
     433                    'width' => $image_data[1],
     434                    'height' => $image_data[2]
     435                ];
     436            }
     437        }
     438        // NEW: Parse content for additional media if the setting is enabled
    415439        if (seo44_get_option('scan_content_for_schema')) {
    416440            $media_schema = self::parse_content_for_media_schema($post_id);
    417             if (!empty($media_schema['images'])) {
    418                 // Check if a featured image (or other image) already exists
    419                 $existing_images = isset($schema['image']) ? (array)$schema['image'] : [];
    420                
    421                 // Merge existing images with content images
    422                 $schema['image'] = array_merge($existing_images, $media_schema['images']);
    423             }
     441            if (!empty($media_schema['images'])) {
     442                // 1. Re-index to prevent "0": {...} keys
     443                $schema['image'] = array_merge($schema['image'], $media_schema['images']);
     444            }
    424445            if (!empty($media_schema['videos'])) {
    425446                $schema['video'] = $media_schema['videos'];
    426447            }
    427448        }
    428        
    429         $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
    430         if (!empty($description)) { $schema['description'] = esc_html(wp_strip_all_tags($description)); }
    431         $excerpt = get_the_excerpt($post_id);
    432         if (!empty($excerpt)) { $schema['abstract'] = esc_html(wp_strip_all_tags($excerpt)); }
    433         $content = get_the_content(null, false, $post_id);
    434         $schema['wordCount'] = str_word_count(wp_strip_all_tags($content));
    435    
    436         $category = get_the_category($post_id);
    437         if (!empty($category) && $category[0]->name !== 'Uncategorized') {
    438             $schema['articleSection'] = esc_html($category[0]->name);
    439         }
    440    
    441         $tags = get_the_tags($post_id);
    442         if ($tags) {
    443             $keywords = [];
    444             foreach ($tags as $tag) { $keywords[] = $tag->name; }
    445             $schema['keywords'] = esc_html(implode(', ', $keywords));
    446         }
    447    
    448         wp_reset_postdata();
    449         return $schema;
     449
     450        // 2. Ensure clean array (re-index)
     451        if (!empty($schema['image'])) {
     452            $schema['image'] = array_values($schema['image']);
     453            // If only one image, Google prefers object over array, but array is valid.
     454            // To match your request, we keep it as array_values to remove numeric keys.
     455        } else {
     456            unset($schema['image']);
     457        }
     458       
     459        $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
     460        if (!empty($description)) { $schema['description'] = esc_html(wp_strip_all_tags($description)); }
     461        $excerpt = get_the_excerpt($post_id);
     462        if (!empty($excerpt)) { $schema['abstract'] = esc_html(wp_strip_all_tags($excerpt)); }
     463        $content = get_the_content(null, false, $post_id);
     464        $schema['wordCount'] = str_word_count(wp_strip_all_tags($content));
     465   
     466        $category = get_the_category($post_id);
     467        if (!empty($category) && $category[0]->name !== 'Uncategorized') {
     468            $schema['articleSection'] = esc_html($category[0]->name);
     469        }
     470   
     471        $tags = get_the_tags($post_id);
     472        if ($tags) {
     473            $keywords = [];
     474            foreach ($tags as $tag) { $keywords[] = $tag->name; }
     475            $schema['keywords'] = esc_html(implode(', ', $keywords));
     476        }
     477   
     478        // NEW: Dynamic Table of Contents (hasPart)
     479        // Only run this if we are scanning content, to save performance
     480        if (seo44_get_option('enable_jumplinks_schema')) {
     481            $toc_parts = self::generate_has_part_schema($content, $post->ID);
     482            if (!empty($toc_parts)) {
     483                $schema['hasPart'] = $toc_parts;
     484            }
     485        }
     486        wp_reset_postdata();
     487        return $schema;
    450488    }
    451489   
    452490    public function get_schema_for_page($post_id) {
    453         $schema = [
    454             '@context' => 'https://schema.org',
    455             '@type' => 'WebPage',
    456             'url' => get_permalink($post_id),
    457             'headline' => get_the_title($post_id),
    458             'datePublished' => get_the_date('c', $post_id),
    459             'dateModified' => get_the_modified_date('c', $post_id),
    460         ];
    461    
    462         // Add the meta description
    463         $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
    464         if (!empty($description)) {
    465             $schema['description'] = esc_html(wp_strip_all_tags($description));
    466         }
    467    
    468         // Add the featured image as a detailed ImageObject
    469         if (has_post_thumbnail($post_id)) {
    470             $image_id = get_post_thumbnail_id($post_id);
    471             $image_data = wp_get_attachment_image_src($image_id, 'full');
    472             if ($image_data) {
    473                 $schema['primaryImageOfPage'] = [
    474                     '@type' => 'ImageObject',
    475                     'url' => $image_data[0],
    476                     'width' => $image_data[1],
    477                     'height' => $image_data[2]
    478                 ];
    479             }
    480         }
    481     // Parse content for additional media if the setting is enabled
    482     // NEW CODE for get_schema_for_page
    483     if (seo44_get_option('scan_content_for_schema')) {
    484         $media_schema = self::parse_content_for_media_schema($post_id);
    485        
    486         if (!empty($media_schema['images'])) {
    487             // 1. Start with the content images found
    488             $all_images = $media_schema['images'];
    489    
    490             // 2. If a Featured Image (primaryImageOfPage) exists, add it to the start of the list
    491             if (isset($schema['primaryImageOfPage'])) {
    492                 array_unshift($all_images, $schema['primaryImageOfPage']);
    493             }
    494    
    495             // 3. Set the 'image' property to the complete list
    496             $schema['image'] = $all_images;
    497    
    498             // Optional: You can choose to keep or unset primaryImageOfPage.
    499             // Keeping it is usually better for SEO so Google knows which one is "Main".
    500             // This would be the code to cleanup the unset:
    501             // unset($schema['primaryImageOfPage']);
    502         }
    503        
    504         if (!empty($media_schema['videos'])) {
    505             $schema['video'] = $media_schema['videos'];
    506         }
    507     }
    508    
    509         return $schema;
     491        $schema = [
     492            '@context' => 'https://schema.org',
     493            '@type' => 'WebPage',
     494            'url' => get_permalink($post_id),
     495            'headline' => get_the_title($post_id),
     496            'datePublished' => get_the_date('c', $post_id),
     497            'dateModified' => get_the_modified_date('c', $post_id),
     498        ];
     499   
     500        // Add the meta description
     501        $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
     502        if (!empty($description)) {
     503            $schema['description'] = esc_html(wp_strip_all_tags($description));
     504        }
     505   
     506        // Add the featured image as a detailed ImageObject
     507        $schema['image'] = [];
     508        if (has_post_thumbnail($post_id)) {
     509            $image_id = get_post_thumbnail_id($post_id);
     510            $image_data = wp_get_attachment_image_src($image_id, 'full');
     511            if ($image_data) {
     512                $schema['primaryImageOfPage'] = [
     513                    '@type' => 'ImageObject',
     514                    'url' => $image_data[0],
     515                    'width' => $image_data[1],
     516                    'height' => $image_data[2]
     517                ];
     518                // Initialize main image list with featured image
     519                $schema['image'][] = $schema['primaryImageOfPage'];
     520            }
     521        }
     522
     523        // Parse content for additional media if the setting is enabled
     524        // NEW CODE for get_schema_for_page
     525        if (seo44_get_option('scan_content_for_schema')) {
     526            $media_schema = self::parse_content_for_media_schema($post_id);
     527           
     528            if (!empty($media_schema['images'])) {
     529                // 1. Re-index to prevent "0": {...} keys
     530                $schema['image'] = array_merge($schema['image'], $media_schema['images']);
     531            }
     532           
     533            if (!empty($media_schema['videos'])) {
     534                $schema['video'] = $media_schema['videos'];
     535            }
     536        }
     537       
     538        // 2. Ensure clean array (re-index)
     539        if (!empty($schema['image'])) {
     540            $schema['image'] = array_values($schema['image']);
     541        } else {
     542            unset($schema['image']);
     543        }
     544
     545       // NEW: Dynamic Table of Contents (hasPart)
     546        // Only run this if we are scanning content, to save performance
     547        if (seo44_get_option('enable_jumplinks_schema')) {
     548            // We need to fetch content first since get_schema_for_page doesn't always have it ready
     549            $post_content = get_the_content(null, false, $post_id);
     550            $toc_parts = self::generate_has_part_schema($post_content, $post_id);
     551           
     552            if (!empty($toc_parts)) {
     553                $schema['hasPart'] = $toc_parts;
     554            }
     555        }
     556
     557        return $schema;
    510558    }
    511559    public function get_schema_for_website() {
    512         $schema = [
    513             '@context' => 'https://schema.org',
    514             '@type' => 'WebSite',
    515             'url' => home_url('/'),
    516             'name' => get_bloginfo('name'),
    517             'description' => get_bloginfo('description'),
    518             'potentialAction' => [
    519                 '@type' => 'SearchAction',
    520                 'target' => home_url('/?s={search_term_string}'),
    521                 'query-input' => 'required name=search_term_string',
    522             ],
    523         ];
    524         return $schema;
    525     }
    526 
    527     // --- Assemble Organization Schema ---
    528     public function get_schema_for_organization() {
     560        $schema = [
     561            '@context' => 'https://schema.org',
     562            '@type' => 'WebSite',
     563            'url' => home_url('/'),
     564            'name' => get_bloginfo('name'),
     565            'description' => get_bloginfo('description'),
     566            'potentialAction' => [
     567                '@type' => 'SearchAction',
     568                'target' => home_url('/?s={search_term_string}'),
     569                'query-input' => 'required name=search_term_string',
     570            ],
     571        ];
     572        return $schema;
     573    }
     574
     575    // --- Assemble Organization Schema ---
     576    public function get_schema_for_organization() {
    529577        // 1. Name & URL
    530578        $name = seo44_get_option('org_name') ?: get_bloginfo('name');
     
    553601        // For sameAs, a URL is desired. Add URLs from fields and construct Twitter / X and Facebook URL
    554602
    555         // Twitter/X: Handle logic
    556         $twitter_handle = seo44_get_option('twitter_handle');
    557         if ( !empty($twitter_handle) && is_string($twitter_handle) ) {
    558             // Clean handle just in case they added @
    559             $clean_handle = str_replace('@', '', $twitter_handle);
    560             $same_as[] = 'https://x.com/' . esc_attr($clean_handle);
    561         }
     603        // Twitter/X: Handle logic
     604        $twitter_handle = seo44_get_option('twitter_handle');
     605        if ( !empty($twitter_handle) && is_string($twitter_handle) ) {
     606            // Clean handle just in case they added @
     607            $clean_handle = str_replace('@', '', $twitter_handle);
     608            $same_as[] = 'https://x.com/' . esc_attr($clean_handle);
     609        }
    562610       
    563611        $extras = ['social_facebook', 'social_instagram', 'social_linkedin', 'social_youtube', 'social_tiktok'];
     
    566614            if ($val) $same_as[] = esc_url($val);
    567615        }
    568         // NEW: Process Additional URLs (One per line)
    569         $additional_urls = seo44_get_option('social_additional');
    570         if ( !empty($additional_urls) && is_string($additional_urls) ) {
    571             // Split by newline, trim whitespace, and filter empty lines
    572             $urls = array_filter(array_map('trim', explode("\n", $additional_urls)));
    573            
    574             foreach ($urls as $raw_url) {
    575                 // Validate it is a real URL before adding
    576                 $clean_url = esc_url_raw($raw_url);
    577                 if (!empty($clean_url)) {
    578                     $same_as[] = $clean_url;
    579                 }
    580             }
    581         }
     616        // NEW: Process Additional URLs (One per line)
     617        $additional_urls = seo44_get_option('social_additional');
     618        if ( !empty($additional_urls) && is_string($additional_urls) ) {
     619            // Split by newline, trim whitespace, and filter empty lines
     620            $urls = array_filter(array_map('trim', explode("\n", $additional_urls)));
     621           
     622            foreach ($urls as $raw_url) {
     623                // Validate it is a real URL before adding
     624                $clean_url = esc_url_raw($raw_url);
     625                if (!empty($clean_url)) {
     626                    $same_as[] = $clean_url;
     627                }
     628            }
     629        }
    582630
    583631        // 4. Build the Schema
     
    589637        ];
    590638
    591         // Add Alternate Name
    592         $alt_name = seo44_get_option('org_alternate_name');
    593         if ($alt_name) {
    594             $schema['alternateName'] = $alt_name;
    595         }
     639        // Add Alternate Name
     640        $alt_name = seo44_get_option('org_alternate_name');
     641        if ($alt_name) {
     642            $schema['alternateName'] = $alt_name;
     643        }
    596644
    597645        // Add Tagline (Slogan)
     
    613661
    614662        // Contact Point (Updated to include Email)
    615         $phone = seo44_get_option('org_phone');
    616         $email = seo44_get_option('org_email');
    617        
    618         if ($phone || $email) {
    619             $contact_point = ['@type' => 'ContactPoint'];
    620             if ($phone) {
    621                 $contact_point['telephone'] = $phone;
    622                 $contact_point['contactType'] = 'customer service';
    623             }
    624             if ($email) {
    625                 $contact_point['email'] = $email;
    626             }
    627             $schema['contactPoint'] = $contact_point;
    628         }
    629        
    630         // Email at the top level is also good practice
    631         if ($email) {
    632             $schema['email'] = $email;
    633         }
    634 
    635         // 5. Address
    636         $street = seo44_get_option('org_address_street');
    637         $city   = seo44_get_option('org_address_city');
    638         if ($street && $city) {
    639             $schema['address'] = [
    640                 '@type'           => 'PostalAddress',
    641                 'streetAddress'   => $street,
    642                 'addressLocality' => $city,
    643                 'addressRegion'   => seo44_get_option('org_address_state'),
    644                 'postalCode'      => seo44_get_option('org_address_zip'),
    645                 'addressCountry'  => seo44_get_option('org_address_country')
    646             ];
    647         }
    648 
    649         // Service Area (New)
    650         $area_served = seo44_get_option('org_area_served');
    651         if ($area_served) {
    652             $schema['areaServed'] = [
    653                 '@type' => 'Place',
    654                 'name'  => $area_served
    655             ];
    656         }
    657         // 6. Founder
    658         $founder = seo44_get_option('org_founder');
    659         if ($founder) {
    660             $schema['founder'] = [
    661                 '@type' => 'Person',
    662                 'name'  => $founder
    663             ];
    664         }
    665    
    666         // 7. Founding Date
    667         $founding_date = seo44_get_option('org_founding_date');
    668         if ( !empty($founding_date) && is_string($founding_date) ) {
    669             // Basic validation: ensure it looks somewhat like a year or date
    670             // You can leave it as raw string, Google parses ISO 8601 (YYYY-MM-DD) well.
    671             $schema['foundingDate'] = strip_tags($founding_date);
    672         }
    673         // 8. Professional License (New)
    674         $license = seo44_get_option('org_license');
    675         if ($license) {
    676             // "hasCredential" is the modern schema property for this
    677             $schema['hasCredential'] = [
    678                 '@type' => 'EducationalOccupationalCredential',
    679                 'credentialCategory' => 'license',
    680                 'name' => $license, // e.g., "Contractor License #123456"
    681                 'recognizedBy' => [
    682                     '@type' => 'Organization',
    683                     'name' => 'State Licensing Board' // Generic fallback since we don't ask for the issuer
    684                 ]
    685             ];
    686            
    687             // Also add it as a simple identifier for wider compatibility
    688             $schema['identifier'] = $license;
    689         }
    690         // FINAL STEP: Apply Filters for Extensibility using 'seo44_organization_schema'
     663        $phone = seo44_get_option('org_phone');
     664        $email = seo44_get_option('org_email');
     665       
     666        if ($phone || $email) {
     667            $contact_point = ['@type' => 'ContactPoint'];
     668            if ($phone) {
     669                $contact_point['telephone'] = $phone;
     670                $contact_point['contactType'] = 'customer service';
     671            }
     672            if ($email) {
     673                $contact_point['email'] = $email;
     674            }
     675            $schema['contactPoint'] = $contact_point;
     676        }
     677       
     678        // Email at the top level is also good practice
     679        if ($email) {
     680            $schema['email'] = $email;
     681        }
     682
     683        // 5. Address
     684        $street = seo44_get_option('org_address_street');
     685        $city   = seo44_get_option('org_address_city');
     686        if ($street && $city) {
     687            $schema['address'] = [
     688                '@type'           => 'PostalAddress',
     689                'streetAddress'   => $street,
     690                'addressLocality' => $city,
     691                'addressRegion'   => seo44_get_option('org_address_state'),
     692                'postalCode'      => seo44_get_option('org_address_zip'),
     693                'addressCountry'  => seo44_get_option('org_address_country')
     694            ];
     695        }
     696
     697        // Service Area (New)
     698        $area_served = seo44_get_option('org_area_served');
     699        if ($area_served) {
     700            $schema['areaServed'] = [
     701                '@type' => 'Place',
     702                'name'  => $area_served
     703            ];
     704        }
     705        // 6. Founder
     706        $founder = seo44_get_option('org_founder');
     707        if ($founder) {
     708            $schema['founder'] = [
     709                '@type' => 'Person',
     710                'name'  => $founder
     711            ];
     712        }
     713   
     714        // 7. Founding Date
     715        $founding_date = seo44_get_option('org_founding_date');
     716        if ( !empty($founding_date) && is_string($founding_date) ) {
     717            // Basic validation: ensure it looks somewhat like a year or date
     718            // You can leave it as raw string, Google parses ISO 8601 (YYYY-MM-DD) well.
     719            $schema['foundingDate'] = wp_strip_all_tags($founding_date);
     720        }
     721        // 8. Professional License (New)
     722        $license = seo44_get_option('org_license');
     723        if ($license) {
     724            // "hasCredential" is the modern schema property for this
     725            $schema['hasCredential'] = [
     726                '@type' => 'EducationalOccupationalCredential',
     727                'credentialCategory' => 'license',
     728                'name' => $license, // e.g., "Contractor License #123456"
     729                'recognizedBy' => [
     730                    '@type' => 'Organization',
     731                    'name' => 'State Licensing Board' // Generic fallback since we don't ask for the issuer
     732                ]
     733            ];
     734           
     735            // Also add it as a simple identifier for wider compatibility
     736            $schema['identifier'] = $license;
     737        }
     738        // FINAL STEP: Apply Filters for Extensibility using 'seo44_organization_schema'
    691739        // Allows developers to add properties like 'duns', 'naics', 'awards', etc.
    692         return apply_filters('seo44_organization_schema', $schema);
    693     }
    694 
    695     // --- Intelligent Schema Detection ---
     740        return apply_filters('seo44_organization_schema', $schema);
     741    }
     742
     743    // --- Intelligent Schema Detection ---
    696744   
    697745    /**
     
    703751        $post = get_post($post_id);
    704752        if (!$post) return [];
    705 
     753       
     754        // 1. Try Advanced HowTo (Jump Links Content Miner)
     755        // This runs FIRST to see if we have a robust HowTo available
     756        $advanced_howto = $this->generate_advanced_howto_schema($post);
     757       
     758        $final_schemas = [];
     759        if (!empty($advanced_howto)) {
     760            $final_schemas[] = $advanced_howto;
     761        }
     762
     763        // 2. Run standard detection for FAQ and HowTo (if not already found)
     764        // We still run this for FAQPage, but we might skip HowTo if advanced found one
    706765        if (has_blocks($post->post_content)) {
    707             // Use the precise block-based parser for modern content
    708             return $this->parse_blocks_for_special_schema($post_id, parse_blocks($post->post_content));
     766            $standard_schemas = $this->parse_blocks_for_special_schema($post_id, parse_blocks($post->post_content), !empty($advanced_howto)); // Pass "skip_howto" flag
    709767        } else {
    710768            // Use the HTML fallback for classic editor or page builder content
    711             // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
     769            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
    712770            $rendered_content = apply_filters('the_content', $post->post_content);
    713             return $this->parse_html_for_special_schema($post_id, $rendered_content);
    714         }
    715     }
    716    
    717    
     771            $standard_schemas = $this->parse_html_for_special_schema($post_id, $rendered_content, !empty($advanced_howto));
     772        }
     773       
     774        if (!empty($standard_schemas)) {
     775             $final_schemas = array_merge($final_schemas, $standard_schemas);
     776        }
     777       
     778        return $final_schemas;
     779    }
     780   
     781
    718782    /**
    719783     * NEW: Parses HTML content for FAQ and How-To patterns (the fallback method).
     784     * Advanced HowTo Scanner (The "Content Miner")
     785     * Uses Jump Links block as a map to mine content between headings.
    720786     */
    721     private function parse_html_for_special_schema($post_id, $html) {
     787    private function generate_advanced_howto_schema($post) {
     788        // 0. Check Cache
     789        $cache_key = 'seo44_howto_' . $post->ID . '_' . $post->post_modified;
     790        $cached_schema = get_transient($cache_key);
     791        if ($cached_schema !== false) {
     792            return $cached_schema;
     793        }
     794        // 1. Strict Check: Is the HowTo Checkbox enabled?
     795        $enable_howto_meta = get_post_meta($post->ID, '_seo44_enable_howto', true);
     796       
     797        if ($enable_howto_meta !== 'yes') {
     798            return null;
     799        }
     800
     801        // 2. Parse Blocks and Find Jump Links Block
     802        $blocks = parse_blocks($post->post_content);
     803        $flat_blocks = $this->flatten_blocks($blocks);
     804        $jump_links_block = $this->find_jump_links_block($flat_blocks);
     805       
     806        if (!$jump_links_block) return null;
     807       
     808        // 3. Setup Variables
     809        $desc_key = seo44_get_option('description_key') ?: 'seo44_description';
     810        $desc = get_post_meta($post->ID, $desc_key, true);
     811        if (empty($desc)) {
     812             $desc = get_post_meta($post->ID, '_yoast_wpseo_metadesc', true) ?: get_post_meta($post->ID, '_aioseop_description', true);
     813             if (empty($desc)) {
     814                 $desc = get_the_excerpt($post->ID);
     815                 if (empty($desc)) {
     816                     $desc = wp_trim_words(strip_shortcodes($post->post_content), 25);
     817                 }
     818             }
     819        }
     820
     821        $steps = [];
     822        $current_step = null;
     823        $tools = [];
     824        $supplies = [];
     825        $total_time = '';
     826        $prep_time = '';
     827        $perform_time = '';
     828        $found_first_step = false;
     829        $potential_list_type = '';
     830        $video_schema = [];
     831        $yield = '';
     832       
     833        // 4. Iterate Blocks
     834        foreach ($flat_blocks as $block) {
     835           
     836            // --- PHASE A: PRE-STEP SCANNING (Intro) ---
     837            if (!$found_first_step) {
     838               
     839                if ($block['blockName'] === 'core/paragraph') {
     840                    // FIX: Decode entities to ensure non-breaking spaces don't break the regex
     841                    $text = html_entity_decode(wp_strip_all_tags($block['innerHTML']));
     842                   
     843                    // FIX: Updated Total Time Regex to prevent matching "Prep Time" as "Time"
     844                    // Logic: Match "Total Time" OR "Time" that is NOT preceded by Prep/Active/Cook
     845                    if (empty($total_time)) {
     846                        $total_time = $this->parse_duration_string($text, '(?:Total\s+|(?<!Prep\s|ation\s|Active\s|Cook\s))Time:\s*');
     847                    }
     848                    if (empty($prep_time)) $prep_time = $this->parse_duration_string($text, 'Prep(?:aration)?\s*Time:\s*');
     849                    if (empty($perform_time)) $perform_time = $this->parse_duration_string($text, '(?:Perform|Active|Cook)\s*Time:\s*');
     850
     851                    if (empty($yield) && preg_match('/(?:Yields|Makes):\s*(.+)/i', $text, $matches)) {
     852                        $yield = trim($matches[1]);
     853                    }
     854
     855                    // FIX: Scan for Comma-Separated Tools/Supplies in Paragraphs
     856                    // e.g. "Tools: Hammer, Nails, Wood"
     857                    if (preg_match('/(?:Tools?|Equipment|Ingredients?|Supplies|Materials?):\s*(.+)/i', $text, $matches)) {
     858                        $type = (stripos($matches[0], 'tool') !== false || stripos($matches[0], 'equipment') !== false) ? 'tool' : 'supply';
     859                        $items = explode(',', $matches[1]);
     860                        foreach ($items as $item) {
     861                            $clean_item = trim($item);
     862                            $clean_item = rtrim($clean_item, '.'); // Remove trailing period
     863                            if (!empty($clean_item)) {
     864                                if ($type === 'tool') {
     865                                    $tools[] = ['@type' => 'HowToTool', 'name' => $clean_item];
     866                                } else {
     867                                    $supplies[] = ['@type' => 'HowToSupply', 'name' => $clean_item];
     868                                }
     869                            }
     870                        }
     871                    }
     872                }
     873
     874                // (Bullet list logic remains the same)
     875                if ($block['blockName'] === 'core/heading') {
     876                    $text = strtolower(wp_strip_all_tags($block['innerHTML']));
     877                    if (strpos($text, 'tool') !== false || strpos($text, 'equipment') !== false) {
     878                        $potential_list_type = 'tool';
     879                    } elseif (strpos($text, 'material') !== false || strpos($text, 'suppl') !== false || strpos($text, 'ingredient') !== false) {
     880                        $potential_list_type = 'supply';
     881                    } else {
     882                        $potential_list_type = '';
     883                    }
     884                }
     885                if ($block['blockName'] === 'core/list' && $potential_list_type) {
     886                     if (preg_match_all('/<li[^>]*>(.*?)<\/li>/s', $block['innerHTML'], $list_matches)) {
     887                         foreach ($list_matches[1] as $li_text) {
     888                             $clean_li = trim(wp_strip_all_tags($li_text));
     889                             if ($potential_list_type === 'tool') {
     890                                 $tools[] = ['@type' => 'HowToTool', 'name' => $clean_li];
     891                             } else {
     892                                 $supplies[] = ['@type' => 'HowToSupply', 'name' => $clean_li];
     893                             }
     894                         }
     895                     }
     896                     $potential_list_type = '';
     897                }
     898               
     899                // (Video logic remains the same)
     900                if ($block['blockName'] === 'core/embed' && isset($block['attrs']['providerNameSlug']) && $block['attrs']['providerNameSlug'] === 'youtube') {
     901                    $video_url = $block['attrs']['url'];
     902                    $video_id = self::extract_youtube_video_id($video_url);
     903                    $upload_date = self::get_youtube_upload_date($video_id, $post->ID);
     904                   
     905                    $oembed_url = 'https://www.youtube.com/oembed?format=json&url=' . urlencode($video_url);
     906                    $response = wp_remote_get($oembed_url);
     907                   
     908                    $vid_title = ''; $vid_thumb = ''; $vid_author = '';
     909                    if (!is_wp_error($response)) {
     910                        $data = json_decode(wp_remote_retrieve_body($response), true);
     911                        if ($data) {
     912                            $vid_title = $data['title'];
     913                            $vid_thumb = $data['thumbnail_url'];
     914                            $vid_author = $data['author_name'];
     915                        }
     916                    }
     917                    if (empty($vid_title)) $vid_title = get_the_title($post->ID) . ' Video Tutorial';
     918                    if (empty($vid_thumb)) $vid_thumb = 'https://i.ytimg.com/vi/' . $video_id . '/hqdefault.jpg';
     919
     920                    $video_schema = [
     921                        '@type' => 'VideoObject',
     922                        'name' => $vid_title,
     923                        'description' => get_the_title($post->ID) . ' - Video Guide',
     924                        'thumbnailUrl' => $vid_thumb,
     925                        'uploadDate' => $upload_date,
     926                        'embedUrl' => $video_url,
     927                        'contentUrl' => $video_url
     928                    ];
     929                    if ($vid_author) {
     930                        $video_schema['author'] = ['@type' => 'Person', 'name' => $vid_author];
     931                    }
     932                }
     933            }
     934
     935            // --- PHASE B: STEP MINING ---
     936            if ($block['blockName'] === 'core/heading') {
     937                if (preg_match('/id="([^"]+)"/', $block['innerHTML'], $id_match)) {
     938                    $anchor_id = $id_match[1];
     939                   
     940                    // --- EXCLUSION LOGIC START ---
     941                    $whitelist_ids = get_post_meta($post->ID, '_seo44_howto_step_ids', true);
     942                    $should_include = true;
     943
     944                    // STRATEGY A: Whitelist (New System)
     945                    if ( ! empty($whitelist_ids) && is_array($whitelist_ids) ) {
     946                        if ( ! in_array($anchor_id, $whitelist_ids) ) {
     947                            $should_include = false;
     948                        }
     949                        // FIX: Global Guard for Intro/Description
     950                        // Even if whitelisted, ignore Intro headings so they don't consume metadata
     951                        // May not need this...
     952                        if (stripos($anchor_id, 'intro') !== false || stripos($anchor_id, 'description') !== false) {
     953                            $should_include = false;
     954                        }
     955                    }
     956                    // STRATEGY B: Fallback
     957                    else {
     958                        if (stripos($anchor_id, 'intro') !== false) { $should_include = false; }
     959                        if (substr($anchor_id, -7) === '-nostep') { $should_include = false; }
     960                    }
     961
     962                    if ( ! $should_include ) {
     963                        if ($current_step) { $steps[] = $current_step; $current_step = null; }
     964                        continue;
     965                    }
     966                    // --- EXCLUSION LOGIC END ---
     967
     968                    $found_first_step = true;
     969                    if ($current_step) { $steps[] = $current_step; }
     970                   
     971                    $current_step = [
     972                        '@type' => 'HowToStep',
     973                        'url'   => get_permalink($post->ID) . '#' . $anchor_id,
     974                        'name'  => trim(wp_strip_all_tags($block['innerHTML'])),
     975                        'text'  => '',
     976                        'image' => []
     977                    ];
     978                    continue;
     979                }
     980            }
     981           
     982            if ($current_step) {
     983                if ($block['blockName'] === 'core/paragraph') {
     984                    $text = trim(wp_strip_all_tags($block['innerHTML']));
     985                    if (!empty($text)) { $current_step['text'] .= $text . ' '; }
     986                }
     987                if ($block['blockName'] === 'core/image') {
     988                     if (preg_match('/src="([^"]+)"/', $block['innerHTML'], $match)) {
     989                         if (!isset($current_step['image'])) { $current_step['image'] = []; }
     990                         $current_step['image'][] = $match[1];
     991                     }
     992                }
     993                if ($block['blockName'] === 'core/list') {
     994                    $current_step['text'] .= trim(wp_strip_all_tags($block['innerHTML'])) . ' ';
     995                }
     996            }
     997        }
     998       
     999        if ($current_step) { $steps[] = $current_step; }
     1000       
     1001        if (!empty($steps)) {
     1002            foreach ($steps as $k => $step) {
     1003                if (empty($step['image'])) {
     1004                    $steps[$k]['image'] = '';
     1005                } elseif (count($step['image']) === 1) {
     1006                    $steps[$k]['image'] = $step['image'][0];
     1007                }
     1008                $steps[$k]['text'] = trim($step['text']);
     1009            }
     1010
     1011            $howto = [
     1012                '@context' => 'https://schema.org',
     1013                '@type'    => 'HowTo',
     1014                'name'     => get_the_title($post->ID),
     1015                'description' => $desc,
     1016                'step'     => $steps
     1017            ];
     1018
     1019            if ($total_time) { $howto['totalTime'] = $total_time; }
     1020            if ($prep_time) { $howto['prepTime'] = $prep_time; }
     1021            if ($perform_time) { $howto['performTime'] = $perform_time; }
     1022            if (!empty($tools)) { $howto['tool'] = $tools; }
     1023            if (!empty($supplies)) { $howto['supply'] = $supplies; }
     1024            if (!empty($video_schema)) { $howto['video'] = $video_schema; }
     1025            if ($yield) { $howto['yield'] = $yield; }
     1026            $howto['disambiguatingDescription'] = $desc;
     1027           
     1028            if (has_post_thumbnail($post->ID)) {
     1029                 $howto['image'] = get_the_post_thumbnail_url($post->ID, 'full');
     1030            }
     1031            $categories = get_the_category($post->ID);
     1032            if (!empty($categories)) {
     1033                 $howto['about'] = ['@type' => 'Thing', 'name' => $categories[0]->name];
     1034            }
     1035            $tags = get_the_tags($post->ID);
     1036            if (!empty($tags)) {
     1037                 $howto['teaches'] = ['@type' => 'DefinedTerm', 'name' => $tags[0]->name];
     1038            }
     1039
     1040            set_transient($cache_key, $howto, DAY_IN_SECONDS);
     1041            return $howto;
     1042        }
     1043        return null;
     1044    }
     1045
     1046    /**
     1047     * Helper: Parse Duration Strings
     1048     * FIX: Re-wrote to accept raw patterns without delimiters to prevent syntax errors.
     1049     */
     1050    private function parse_duration_string($text, $prefix_pattern) {
     1051        // We construct the full regex here to ensure delimiters wrap the WHOLE pattern.
     1052        // Matches: Label -> (1) Hours -> (2) Minutes
     1053        $regex = '/' . $prefix_pattern . '(?:(\d+)\s*(?:hour|hr)s?\s*)?(?:and\s*)?(?:(\d+)\s*(?:minute|min)s?)?/i';
     1054       
     1055        if (preg_match($regex, $text, $matches)) {
     1056            $hours = !empty($matches[1]) ? intval($matches[1]) : 0;
     1057            $minutes = !empty($matches[2]) ? intval($matches[2]) : 0;
     1058           
     1059            if ($hours > 0 || $minutes > 0) {
     1060                 $duration = 'PT';
     1061                 if ($hours > 0) $duration .= $hours . 'H';
     1062                 if ($minutes > 0) $duration .= $minutes . 'M';
     1063                 return $duration;
     1064            }
     1065        }
     1066        return '';
     1067    }
     1068
     1069//    private function get_youtube_id($url) {
     1070//        preg_match('/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i', $url, $matches);
     1071//        return isset($matches[1]) ? $matches[1] : '';
     1072//    }   
     1073
     1074    /**
     1075     * Helper: Flattens nested block structures into a single linear array.
     1076     */
     1077    private function flatten_blocks($blocks) {
     1078        $flat = [];
     1079        foreach ($blocks as $block) {
     1080            $flat[] = $block;
     1081            if (!empty($block['innerBlocks'])) {
     1082                $flat = array_merge($flat, $this->flatten_blocks($block['innerBlocks']));
     1083            }
     1084        }
     1085        return $flat;
     1086    }
     1087
     1088    /**
     1089     * Helper to find the Jump Links block recursively
     1090     * Updated to be broader and find any block with 'jump-links' in the name
     1091     * FIX: Added check to ensure blockName is a string before strpos
     1092     */
     1093    private function find_jump_links_block($blocks) {
     1094        foreach ($blocks as $block) {
     1095            // Broad check for any jump links block
     1096            if (isset($block['blockName']) && is_string($block['blockName']) && strpos($block['blockName'], 'jump-links') !== false) {
     1097                return $block;
     1098            }
     1099            // Recursive check for inner blocks (e.g. inside Groups/Columns)
     1100            if (!empty($block['innerBlocks'])) {
     1101                $found = $this->find_jump_links_block($block['innerBlocks']);
     1102                if ($found) return $found;
     1103            }
     1104        }
     1105        return null;
     1106    }
     1107
     1108    private function parse_html_for_special_schema($post_id, $html, $skip_howto = false) {
    7221109        $final_schemas = [];
    7231110       
     
    7491136        }
    7501137       
    751         // HowTo Detection in HTML
    752         if (preg_match('/<h[2-4][^>]*>(installation|directions|instructions)<\/h[2-4]>/i', $html, $howto_heading_match)) {
     1138        // HowTo Detection (Skipped if Advanced scanner found one)
     1139        if (!$skip_howto && preg_match('/<h[2-4][^>]*>(installation|directions|instructions)<\/h[2-4]>/i', $html, $howto_heading_match)) {
    7531140            preg_match('/' . preg_quote($howto_heading_match[0], '/') . '.*?<ol.*?>(.*?)<\/ol>/is', $html, $ol_match);
    7541141            if(isset($ol_match[1])) {
     
    7771164     * The original block-based parser, now in its own function.
    7781165     */
    779     private function parse_blocks_for_special_schema($post_id, $blocks) {       
     1166    private function parse_blocks_for_special_schema($post_id, $blocks, $skip_howto = false) {       
    7801167        $faq_questions = [];
    7811168        $howto_steps = [];
     
    7861173        $final_schemas = [];
    7871174
    788         foreach ($blocks as $block) {
     1175        foreach ($blocks as $block) {
    7891176            $heading_text = strtolower(wp_strip_all_tags($block['innerHTML']));
    7901177           
     
    7961183                }
    7971184                $is_faq_section = false;
    798                 $is_howto_section = false; // Stop looking for HowTo steps as well
     1185                $is_howto_section = false;
    7991186                $current_answer_blocks = [];
    8001187                continue;
     
    8191206            }
    8201207
    821            // --- HowTo Detection ---
    822             if (!$is_faq_section && $block['blockName'] === 'core/heading' && preg_match('/(installation|directions|instructions)/i', $heading_text)) {
    823                 $is_howto_section = true;
    824                 // Use trim() to remove leading/trailing whitespace and newlines
    825                 $howto_heading_text = trim(wp_strip_all_tags($block['innerHTML']));
    826                 continue; // Find the heading, then look for the list in subsequent blocks
    827             }
    828             // If we are in a how-to section and find the first ordered list, process it.
    829             if ($is_howto_section && $block['blockName'] === 'core/list' && isset($block['attrs']['ordered']) && $block['attrs']['ordered']) {
    830                
    831                  if (!empty($block['innerBlocks'])) {
    832                     foreach($block['innerBlocks'] as $list_item_block) {
    833                         if ($list_item_block['blockName'] === 'core/list-item') {
    834                             $howto_steps[] = ['@type' => 'HowToStep', 'text' => esc_html(wp_strip_all_tags($list_item_block['innerHTML']))];
     1208            // HowTo Logic (Skipped if Advanced scanner found one)
     1209            if (!$skip_howto) {
     1210                if (!$is_faq_section && $block['blockName'] === 'core/heading' && preg_match('/(installation|directions|instructions)/i', $heading_text)) {
     1211                    $is_howto_section = true;
     1212                    // Use trim() to remove leading/trailing whitespace and newlines
     1213                    $howto_heading_text = trim(wp_strip_all_tags($block['innerHTML']));
     1214                    continue;  // Find the heading, then look for the list in subsequent blocks
     1215                }
     1216                // If we are in a how-to section and find the first ordered list, process it.
     1217                if ($is_howto_section && $block['blockName'] === 'core/list' && isset($block['attrs']['ordered']) && $block['attrs']['ordered']) {
     1218                     if (!empty($block['innerBlocks'])) {
     1219                        foreach($block['innerBlocks'] as $list_item_block) {
     1220                            if ($list_item_block['blockName'] === 'core/list-item') {
     1221                                $howto_steps[] = ['@type' => 'HowToStep', 'text' => esc_html(wp_strip_all_tags($list_item_block['innerHTML']))];
     1222                            }
    8351223                        }
    8361224                    }
    837                 }
    838                  $is_howto_section = false; // Stop after finding the first ordered list
     1225                     $is_howto_section = false; // Stop after finding the first ordered list
     1226                }
    8391227            }
    8401228        }
     
    8761264
    8771265    /**
    878      *  HELPER: Cleans and formats block content for an answer.
     1266     * HELPER: Cleans and formats block content for an answer.
    8791267     */
    8801268    private function clean_and_format_answer($blocks) {
     
    9091297        // Clean up remaining HTML, extra whitespace, and decode entities
    9101298        $clean_text = trim(wp_strip_all_tags(html_entity_decode($answer_html)));
    911         return preg_replace('/\n\s*\n/', "\n", $clean_text); // Collapse multiple newlines
    912     }
    913    
     1299        return preg_replace('/\n\s*\n/', "\n", $clean_text);
     1300    }
     1301   
    9141302    // --- MEDIA PARSING FUNCTION ---
    9151303
     
    9271315        $content = $post->post_content;
    9281316        $found_block_image_urls = [];
    929         $found_video_urls = []; // Fixed: Added missing variable declaration
     1317        $found_video_urls = [];
     1318
     1319        $default_description = get_the_title($post_id) . ' Video';
    9301320
    9311321        if (has_blocks($content)) {
    9321322            $blocks = parse_blocks($content);
    9331323            foreach ($blocks as $block) {
     1324
    9341325                // Handle Image Blocks
    9351326                if ($block['blockName'] === 'core/image' && !empty($block['attrs']['id'])) {
     
    9471338                        }
    9481339                        $images[] = $image_object;
    949                         $found_block_image_urls[] = $image_data[0]; // Keep track of URLs we've already added
     1340                        $found_block_image_urls[] = $image_data[0];
    9501341                    }
    9511342                }
    9521343
    953                 // Handle YouTube Embed Blocks using the oEmbed API
     1344                // Handle YouTube Embed Blocks using the oEmbed API + API/Scraping
    9541345                if ($block['blockName'] === 'core/embed' && isset($block['attrs']['providerNameSlug']) && $block['attrs']['providerNameSlug'] === 'youtube') {
    955                     $oembed_url = 'https://www.youtube.com/oembed?format=json&url=' . urlencode($block['attrs']['url']);
     1346                    $video_url = $block['attrs']['url'];
     1347                   
     1348                    // 1. Get Accurate Date
     1349                    $video_id = self::extract_youtube_video_id($video_url);
     1350                    $upload_date = self::get_youtube_upload_date($video_id, $post_id);
     1351
     1352                    // 2. Get Metadata
     1353                    $oembed_url = 'https://www.youtube.com/oembed?format=json&url=' . urlencode($video_url);
    9561354                    $response = wp_remote_get($oembed_url);
     1355                   
    9571356                    if (!is_wp_error($response)) {
    9581357                        $data = json_decode(wp_remote_retrieve_body($response), true);
     
    9621361                                'name' => $data['title'],
    9631362                                'thumbnailUrl' => $data['thumbnail_url'],
    964                                 'embedUrl' => $block['attrs']['url'],
    965                                 'author' => ['@type' => 'Person', 'name' => $data['author_name']]
     1363                                'embedUrl' => $video_url,
     1364                                'contentUrl' => $video_url,
     1365                                'author' => ['@type' => 'Person', 'name' => $data['author_name']],
     1366                                'uploadDate' => $upload_date, // Uses API/Scraped date
     1367                                'description' => $default_description
    9661368                            ];
    967                             $found_video_urls[] = $block['attrs']['url']; // Track processed videos
     1369                            $found_video_urls[] = $video_url;
    9681370                        }
    9691371                    }
     
    9721374        }
    9731375       
    974  // --- Universal Fallback for <img> and <iframe> tags in Classic Editor or HTML Blocks---
    975        
    976         // Find Images (logic is unchanged)
     1376        // --- Universal Fallback for <img> and <iframe> tags ---
     1377       
     1378        // Find Images
    9771379        if (preg_match_all('/<img[^>]+>/i', $content, $img_matches)) {
    9781380            foreach ($img_matches[0] as $img_tag) {
     
    9821384                if (preg_match('/src\s*=\s*[\'"]([^\'"]+)[\'"]/i', $img_tag, $src_matches)) {
    9831385                    $url = $src_matches[1];
    984                     // Add the image ONLY if we haven't already added it from a core/image block
    9851386                    if (!in_array($url, $found_block_image_urls)) {
    9861387                        $images[] = ['@type' => 'ImageObject', 'url' => $url];
    9871388                    }
    9881389                }
    989             }
    990         }
    991 
    992         // RESTORED: Find iframes
     1390            }
     1391        }
     1392
     1393        // Find iframes for YouTube
    9931394        if (preg_match_all('/<iframe[^>]+src="([^"]+)"[^>]*>/i', $content, $iframe_matches)) {
    9941395            foreach($iframe_matches[1] as $iframe_src) {
    995                 // Skip if we already processed this video from a block
    996                 if (in_array($iframe_src, $found_video_urls)) {
    997                     continue;
    998                 }
    999 
    1000                 if (strpos($iframe_src, 'youtube.com/embed') !== false) {
    1001                     preg_match('/embed\/([a-zA-Z0-9_-]+)/i', $iframe_src, $id_matches);
    1002                     $video_id = $id_matches[1] ?? null;
    1003                     if ($video_id) {
    1004                         $videos[] = [
    1005                             '@type' => 'VideoObject',
    1006                             'name' => 'Embedded YouTube Video', // Title isn't available from a raw iframe
    1007                             'thumbnailUrl' => 'https://i.ytimg.com/vi/' . $video_id . '/hqdefault.jpg',
    1008                             'embedUrl' => 'https://www.youtube.com/watch?v=' . $video_id,
    1009                         ];
    1010                     }
     1396                if (in_array($iframe_src, $found_video_urls)) { continue; }
     1397
     1398                // Use the static helper to check ID
     1399                $video_id = self::extract_youtube_video_id($iframe_src);
     1400               
     1401                if ($video_id) {
     1402                    $upload_date = self::get_youtube_upload_date($video_id, $post_id);
     1403                   
     1404                    $videos[] = [
     1405                        '@type' => 'VideoObject',
     1406                        'name' => 'Embedded YouTube Video',
     1407                        'description' => $default_description,
     1408                        'uploadDate' => $upload_date,
     1409                        'thumbnailUrl' => 'https://i.ytimg.com/vi/' . $video_id . '/hqdefault.jpg',
     1410                        'embedUrl' => 'https://www.youtube.com/watch?v=' . $video_id,
     1411                        'contentUrl' => 'https://www.youtube.com/watch?v=' . $video_id
     1412                    ];
    10111413                }
    10121414            }
     
    10151417        return ['images' => $images, 'videos' => $videos];
    10161418    }
    1017    
    1018     //  Function to generate BreadcrumbList schema for Taxonomy pages (not posts & Pages)
     1419   
     1420    //  Function to generate BreadcrumbList schema for Taxonomy pages (not posts & Pages)
    10191421    public function get_schema_for_taxonomy() {
    10201422        $term = get_queried_object();
     
    10631465        return $schema;
    10641466    }
    1065    
    1066     // Function to create author name
    1067    
    1068     public function get_author_name( $author_id ) {
    1069         $format = seo44_get_option('author_format', 'display_name');
    1070         switch ($format) {
    1071             case 'first_last': return get_the_author_meta('first_name', $author_id) . ' ' . get_the_author_meta('last_name', $author_id);
    1072             case 'last_first': return get_the_author_meta('last_name', $author_id) . ', ' . get_the_author_meta('first_name', $author_id);
    1073             default: return get_the_author_meta('display_name', $author_id);
    1074         }
    1075     }
    1076     public static function get_author_name_static( $author_id ) {
    1077         $frontend = new self();
    1078         return $frontend->get_author_name( $author_id );
    1079     }
     1467   
     1468    // Function to create author name
     1469    // With more graceful logic for missing name fields
     1470    public function get_author_name( $author_id ) {
     1471        $format = seo44_get_option('author_format', 'display_name');
     1472       
     1473        // 1. Get the raw values first
     1474        $first_name = get_the_author_meta( 'first_name', $author_id );
     1475        $last_name  = get_the_author_meta( 'last_name', $author_id );
     1476       
     1477        $formatted_name = '';
     1478
     1479        switch ( $format ) {
     1480            case 'first_last':
     1481                // 2. Concatenate and trim.
     1482                // If both are empty, trim() ensures we get "", not " ".
     1483                // If one is missing, it removes the leading/trailing space.
     1484                $formatted_name = trim( $first_name . ' ' . $last_name );
     1485                break;
     1486
     1487            case 'last_first':
     1488                // 3. Smart comma handling.
     1489                // Only add the comma if BOTH fields have data.
     1490                if ( ! empty( $last_name ) && ! empty( $first_name ) ) {
     1491                    $formatted_name = $last_name . ', ' . $first_name;
     1492                } else {
     1493                    // If one is missing, fall back to simple concatenation/trim without the comma
     1494                    $formatted_name = trim( $last_name . ' ' . $first_name );
     1495                }
     1496                break;
     1497               
     1498            // Default case is handled by the fallback check below
     1499        }
     1500
     1501        // 4. The Fallback Check
     1502        // If formatted_name is empty (because fields were empty) OR format was 'display_name'
     1503        if ( empty( $formatted_name ) ) {
     1504            return get_the_author_meta( 'display_name', $author_id );
     1505        }
     1506
     1507        return $formatted_name;
     1508    }
     1509
     1510    public static function get_author_name_static( $author_id ) {
     1511        $frontend = new self();
     1512        return $frontend->get_author_name( $author_id );
     1513    }
     1514   
     1515    /**
     1516     * Parses content for headings with IDs to create a "Table of Contents" schema.
     1517     * @param string $content The post content.
     1518     * @return array Array of WebPageElement objects.
     1519     */
     1520
     1521    // This could be updated to use IDS passed through to field from jump links block to be more efficient than scan and remove need for checkbox.
     1522    // UPDATED: Now respects whitelist to match "included" jump links
     1523    private static function generate_has_part_schema($content, $post_id) {
     1524        $has_part = [];
     1525       
     1526        // Retrieve the whitelist of IDs (generated by JS when saving the Jump Links block)
     1527        $whitelist_ids = get_post_meta($post_id, '_seo44_howto_step_ids', true);
     1528       
     1529        // Regex to find H2-H4 tags that have an ID attribute
     1530        if (preg_match_all('/<h[2-4][^>]*id="([^"]+)"[^>]*>(.*?)<\/h[2-4]>/i', $content, $matches, PREG_SET_ORDER)) {
     1531           
     1532            foreach ($matches as $match) {
     1533                $anchor_id = $match[1];
     1534                $heading_text = wp_strip_all_tags($match[2]);
     1535               
     1536                // --- FILTER LOGIC ---
     1537                // Default to include everything if no whitelist exists (e.g. old post, no jump links block)
     1538                // But if a whitelist exists (array), enforce it strictly.
     1539                $should_include = true;
     1540               
     1541                if ( ! empty($whitelist_ids) && is_array($whitelist_ids) ) {
     1542                    if ( ! in_array($anchor_id, $whitelist_ids) ) {
     1543                        $should_include = false;
     1544                    }
     1545                }
     1546               
     1547                if ($should_include && !empty($anchor_id) && !empty($heading_text)) {
     1548                    $has_part[] = [
     1549                        '@type' => 'WebPageElement',
     1550                        'name'  => $heading_text,
     1551                        'url'   => get_permalink($post_id) . '#' . $anchor_id,
     1552                        'cssSelector' => '#' . $anchor_id
     1553                    ];
     1554                }
     1555            }
     1556        }
     1557        return $has_part;
     1558    }
     1559
     1560
     1561    /**
     1562     * Extracts YouTube video ID from various URL formats
     1563     */
     1564    private static function extract_youtube_video_id($url) {
     1565        $patterns = [
     1566            '/youtube\.com\/watch\?v=([a-zA-Z0-9_-]+)/',            // Standard
     1567            '/youtube\.com\/embed\/([a-zA-Z0-9_-]+)/',              // Embed
     1568            '/youtu\.be\/([a-zA-Z0-9_-]+)/',                        // Short URL
     1569            '/youtube\.com\/v\/([a-zA-Z0-9_-]+)/',                  // Old format
     1570        ];
     1571       
     1572        foreach ($patterns as $pattern) {
     1573            if (preg_match($pattern, $url, $matches)) {
     1574                return $matches[1];
     1575            }
     1576        }
     1577       
     1578        return null;
     1579    }
     1580
     1581    /**
     1582     * Gets YouTube video upload date with multiple fallback strategies
     1583     * @param string $video_id YouTube video ID
     1584     * @param int $post_id WordPress post ID for fallback
     1585     * @return string ISO 8601 formatted date
     1586     */
     1587    private static function get_youtube_upload_date($video_id, $post_id) {
     1588        if (empty($video_id)) {
     1589            return get_the_date('c', $post_id);
     1590        }
     1591       
     1592        // Strategy 1: Check transient cache (24 hour cache to avoid API limits)
     1593        $cache_key = 'seo44_yt_date_' . $video_id;
     1594        $cached_date = get_transient($cache_key);
     1595       
     1596        if ($cached_date !== false) {
     1597            return $cached_date;
     1598        }
     1599       
     1600        // Strategy 2: Try YouTube Data API (if configured)
     1601        $api_key = seo44_get_option('youtube_api_key');
     1602       
     1603        if (!empty($api_key)) {
     1604            $api_date = self::fetch_youtube_date_from_api($video_id, $api_key);
     1605           
     1606            if ($api_date) {
     1607                // Cache for 24 hours
     1608                set_transient($cache_key, $api_date, DAY_IN_SECONDS);
     1609                return $api_date;
     1610            }
     1611        }
     1612       
     1613        // Strategy 3: Try scraping YouTube page (no API key needed, but less reliable)
     1614        $scraped_date = self::scrape_youtube_upload_date($video_id);
     1615       
     1616        if ($scraped_date) {
     1617            // Cache for 24 hours
     1618            set_transient($cache_key, $scraped_date, DAY_IN_SECONDS);
     1619            return $scraped_date;
     1620        }
     1621       
     1622        // Strategy 4: Fallback to post publication date
     1623        return get_the_date('c', $post_id);
     1624    }
     1625
     1626    /**
     1627     * Fetches upload date from YouTube Data API v3
     1628     */
     1629    private static function fetch_youtube_date_from_api($video_id, $api_key) {
     1630        $api_url = sprintf(
     1631            'https://www.googleapis.com/youtube/v3/videos?id=%s&key=%s&part=snippet',
     1632            urlencode($video_id),
     1633            urlencode($api_key)
     1634        );
     1635       
     1636        $response = wp_remote_get($api_url, [
     1637            'timeout' => 5,
     1638            'sslverify' => true
     1639        ]);
     1640       
     1641        if (is_wp_error($response)) {
     1642            return null;
     1643        }
     1644       
     1645        $body = wp_remote_retrieve_body($response);
     1646        $data = json_decode($body, true);
     1647       
     1648        if (isset($data['items'][0]['snippet']['publishedAt'])) {
     1649            return $data['items'][0]['snippet']['publishedAt'];
     1650        }
     1651       
     1652        return null;
     1653    }
     1654
     1655    /**
     1656     * Scrapes upload date from YouTube page HTML (fallback method, no API key needed)
     1657     * IMPPROVED: Scans Meta tags first, then JSON-LD, then internal player data.
     1658     */
     1659    private static function scrape_youtube_upload_date($video_id) {
     1660        $video_page_url = 'https://www.youtube.com/watch?v=' . $video_id;
     1661       
     1662        $response = wp_remote_get($video_page_url, [
     1663            'timeout' => 10,
     1664            // Use a generic browser agent to avoid being served a "bare" bot page
     1665            'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
     1666            'sslverify' => true
     1667        ]);
     1668       
     1669        if (is_wp_error($response)) {
     1670            return null;
     1671        }
     1672       
     1673        $html = wp_remote_retrieve_body($response);
     1674       
     1675        // Strategy A: Look for Meta Tags (Most Reliable)
     1676        // YouTube usually includes: <meta itemprop="datePublished" content="YYYY-MM-DD">
     1677        if (preg_match('/<meta\s+itemprop="datePublished"\s+content="([^"]+)"/i', $html, $matches)) {
     1678            return $matches[1];
     1679        }
     1680        if (preg_match('/<meta\s+itemprop="uploadDate"\s+content="([^"]+)"/i', $html, $matches)) {
     1681            return $matches[1];
     1682        }
     1683
     1684        // Strategy B: JSON-LD Schema (Your original method)
     1685        if (preg_match('/"uploadDate":"([^"]+)"/', $html, $matches)) {
     1686            return $matches[1];
     1687        }
     1688       
     1689        // Strategy C: Look for microformatDataRenderer (Internal YouTube Data)
     1690        // This is often found inside the large ytInitialPlayerResponse JSON object
     1691        if (preg_match('/"publishDate":"([^"]+)"/', $html, $matches)) {
     1692            $date_string = $matches[1]; // Usually YYYY-MM-DD
     1693            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_string)) {
     1694                return $date_string;
     1695            }
     1696            // Fallback for compact format YYYYMMDD
     1697            if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $date_string, $date_parts)) {
     1698                return sprintf('%s-%s-%s', $date_parts[1], $date_parts[2], $date_parts[3]);
     1699            }
     1700        }
     1701       
     1702        return null;
     1703    }
    10801704}
  • search-appearance-toolkit-seo-44/tags/4.3/includes/class-seo44-metabox.php

    r3389330 r3423355  
    11<?php
     2// Version 4.3 - Added a Generate HowTo Schema checkbox that appears via activation word
    23class SEO44_Metabox {
    34
     
    2324        $description = get_post_meta($post->ID, seo44_get_option('description_key'), true);
    2425        $keywords = get_post_meta($post->ID, seo44_get_option('keywords_key'), true);
    25         $jump_link_headings = get_post_meta($post->ID, '_seo44_jump_link_headings', true);
    26         wp_nonce_field('seo44_jump_links_nonce', 'seo44_jump_links_nonce_field');
     26
     27        // NEW: Retrieve the HowTo toggle state
     28        // We use 'yes' for checked, empty for unchecked
     29        $enable_howto = get_post_meta($post->ID, '_seo44_enable_howto', true);
     30       
     31        // Logic to determine initial visibility: Show if already checked
     32        $howto_wrapper_style = ($enable_howto === 'yes') ? '' : 'display:none;';
     33
    2734    ?>
    28         <input type="hidden" id="seo44_jump_link_headings_field" name="seo44_jump_link_headings" value="<?php echo esc_attr($jump_link_headings); ?>">
     35       
    2936        <p>
    3037            <label for="seo44_title"><strong><?php esc_html_e('SEO Title', 'search-appearance-toolkit-seo-44'); ?></strong></label><br>
     
    5461        </p>
    5562        <?php endif; ?>
     63        <div id="seo44-howto-trigger-wrapper" style="<?php echo esc_attr($howto_wrapper_style); ?>">
     64            <p>
     65                <label for="seo44_enable_howto">
     66                    <input type="checkbox" id="seo44_enable_howto" name="seo44_enable_howto" value="yes" <?php checked($enable_howto, 'yes'); ?>>
     67                    <strong><?php esc_html_e('Generate HowTo Schema', 'search-appearance-toolkit-seo-44'); ?></strong>
     68                </label>
     69                <br>
     70                <span class="description">
     71                    <?php esc_html_e('It looks like you are publishing a "How-To" guide. When this box is checked, SEO 44 will generate HowTo structured data.', 'search-appearance-toolkit-seo-44'); ?>
     72                </span>
     73            </p>
     74        </div>
    5675        <hr>
    5776        <div id="seo44-snippet-preview">
     
    8099    public function save_meta_box_data($post_id) {
    81100        if (!isset($_POST['seo44_meta_box_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['seo44_meta_box_nonce'])), 'seo44_save_meta_box_data')) return;
    82         if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
    83         $post_type = get_post_type($post_id);
    84         // Robust capability checking
    85         if (!current_user_can("edit_{$post_type}s")) {
     101          if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
     102          $post_type = get_post_type($post_id);
     103          // Robust capability checking
     104          if (!current_user_can("edit_{$post_type}s")) {
    86105            return;
    87         }
    88         if (isset($_POST['seo44_title'])) {
     106          }
     107          if (isset($_POST['seo44_title'])) {
    89108            update_post_meta($post_id, seo44_get_option('title_key'), sanitize_text_field(wp_unslash($_POST['seo44_title'])));
    90109        }
     
    95114            update_post_meta($post_id, seo44_get_option('keywords_key'), sanitize_text_field(wp_unslash($_POST['seo44_keywords'])));
    96115        }
    97             // Save Logic for Hidden Box for Jump Links Block data
    98         if (isset($_POST['seo44_jump_links_nonce_field']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['seo44_jump_links_nonce_field'])), 'seo44_jump_links_nonce')) {
    99             if (isset($_POST['seo44_jump_link_headings'])) {
    100                 update_post_meta($post_id, '_seo44_jump_link_headings', sanitize_text_field(wp_unslash($_POST['seo44_jump_link_headings'])));
    101             }
     116        // NEW: Save HowTo Checkbox
     117        if (isset($_POST['seo44_enable_howto'])) {
     118            update_post_meta($post_id, '_seo44_enable_howto', 'yes');
     119        } else {
     120            // Checkboxes don't send data if unchecked, so we must explicitly delete or update to 'no'
     121            // Only do this if our nonce is verified (which implies the form was actually submitted)
     122            update_post_meta($post_id, '_seo44_enable_howto', 'no');
    102123        }
     124       
    103125    }
    104126}
  • search-appearance-toolkit-seo-44/tags/4.3/includes/class-seo44-settings.php

    r3405454 r3423355  
    11<?php
     2// Version 4.3
     3// Added hasPart Table of Contents Schema, advanced HowTo Schema
     4// Added YouTube Data API to Integrations
    25class SEO44_Settings {
    36
     
    5962        add_settings_field('seo44_schema_tools', __('Schema Scanner', 'search-appearance-toolkit-seo-44'), [$this, 'render_schema_tools'], 'seo-44_schema', 'seo44_schema_settings_section');
    6063        add_settings_field('seo44_enable_schema', __('Enable Schema', 'search-appearance-toolkit-seo-44'), [$this, 'render_checkbox_field'], 'seo-44_schema', 'seo44_schema_settings_section', ['id' => 'enable_schema', 'label' => __('Output JSON-LD to your webpages.', 'search-appearance-toolkit-seo-44')]);
    61         // NEW: Add checkbox for scanning content
    6264        add_settings_field('seo44_scan_content_for_schema', __('Scan Content for Media', 'search-appearance-toolkit-seo-44'), [$this, 'render_checkbox_field'], 'seo-44_schema', 'seo44_schema_settings_section', [
    6365            'id' => 'scan_content_for_schema',
     
    6567            'tooltip' => 'This provides more detail to search engines but can be slightly more resource-intensive.'
    6668        ]);
    67         // NEW: Add checkbox for advanced schema detection
    68         add_settings_field('seo44_enable_advanced_schema', __('Enable Advanced Schema Detection', 'search-appearance-toolkit-seo-44'), [$this, 'render_checkbox_field'], 'seo-44_schema', 'seo44_schema_settings_section', [
    69             'id' => 'enable_advanced_schema',
    70             'label' => __('Scan content for patterns to generate FAQ and How-To schema.', 'search-appearance-toolkit-seo-44'),
    71             'tooltip' => 'If special formats are detected, they will be added to your page\'s schema structured data.'
    72         ]);
     69        // UPDATED: Using custom callback to include the Jump Links Block recommendation
     70        add_settings_field('seo44_enable_advanced_schema', __('Enable Advanced Schema Detection', 'search-appearance-toolkit-seo-44'), [$this, 'render_advanced_schema_checkbox'], 'seo-44_schema', 'seo44_schema_settings_section');
     71
     72        // NEW: Jump Links Integration Field
     73        add_settings_field('seo44_enable_jumplinks_schema',
     74            __('Generate "Table of Contents" Schema', 'search-appearance-toolkit-seo-44'),
     75            [$this, 'render_checkbox_field'],
     76            'seo-44_schema',
     77            'seo44_schema_settings_section',
     78            [
     79                'id' => 'enable_jumplinks_schema',
     80                'label' => __('Automatically create "hasPart" schema for headings highlighted in Jump Links Blocks, aligning structured data with Jump Links to reinforce support for Jump-to links in search results.', 'search-appearance-toolkit-seo-44'),
     81                'tooltip' => 'This helps Google understand the deep structure of your article by mapping your headings (H2-H4) as distinct sections, complementing the visual Jump Links Block on your page.'
     82            ]
     83        );
    7384        add_settings_field('seo44_enable_schema_on_taxonomies', __('Enable Schema on Taxonomies', 'search-appearance-toolkit-seo-44'), [$this, 'render_checkbox_field'], 'seo-44_schema', 'seo44_schema_settings_section', [
    7485            'id' => 'enable_schema_on_taxonomies',
     
    145156            ]
    146157        );
    147        
    148        
    149        
    150158        // 3. Add Address Fields (Crucial for Local SEO & Disambiguation)
    151159        add_settings_field(
     
    190198        );
    191199       
    192 
    193200        add_settings_field(
    194201            'org_phone',
     
    391398            ]
    392399        );
     400        // --- Site APIs Header ---
     401        add_settings_field(
     402            'seo44_site_apis_header',
     403            '', // Empty label for full-width header
     404            [$this, 'render_site_apis_header_field'],
     405            'seo-44_integrations',
     406            'seo44_integrations_section'
     407        );
     408
     409        // --- YouTube API Key ---
     410        add_settings_field(
     411            'youtube_api_key',
     412            __('YouTube API Key', 'search-appearance-toolkit-seo-44'),
     413            [$this, 'render_youtube_api_key_field'],
     414            'seo-44_integrations',
     415            'seo44_integrations_section',
     416            [
     417                'label_for' => 'youtube_api_key',
     418                'tooltip'   => __('This will be used to improve the Video Object schema created for embedded YouTube Videos.', 'search-appearance-toolkit-seo-44')
     419            ]
     420        );
    393421        // --- end of settings_init ---
    394422    }
     
    400428            'enable_tags', 'include_keywords', 'include_author',
    401429            'enable_og_tags', 'enable_twitter_tags',
    402             'enable_schema', 'scan_content_for_schema', 'enable_advanced_schema', 'enable_schema_on_cpts', 'enable_schema_on_taxonomies','enable_organization_schema',
     430            'enable_schema', 'scan_content_for_schema', 'enable_jumplinks_schema', 'enable_advanced_schema', 'enable_schema_on_cpts', 'enable_schema_on_taxonomies','enable_organization_schema',
    403431            'enable_sitemaps', 'enable_sitemap_ping',
    404432            'sitemap_include_images', 'sitemap_include_content_images',
     
    423451            'org_address_street', 'org_address_city', 'org_address_state', 'org_address_zip', 'org_address_country',
    424452            'org_phone', 'org_email', 'org_area_served',
     453            'youtube_api_key',
    425454        ];
    426455        foreach ($text_fields as $tf) {
     
    594623    <?php
    595624}
     625    // UPDATED: Custom renderer for the Advanced Schema Checkbox
     626    public function render_advanced_schema_checkbox() {
     627        $id = 'enable_advanced_schema';
     628        $checked = seo44_get_option($id);
     629       
     630        // Output the standard checkbox
     631        printf(
     632            '<label for="%s"><input type="checkbox" id="%s" name="seo44_settings[%s]" value="1" %s /> %s</label>',
     633            esc_attr($id),
     634            esc_attr($id),
     635            esc_attr($id),
     636            checked($checked, 1, false),
     637            esc_html__('Scan content for patterns to generate FAQ and How-To schema.', 'search-appearance-toolkit-seo-44')
     638        );
     639        seo44_render_tooltip('If special formats are detected, they will be added to your page\'s schema structured data.');
     640
     641        // Add the new recommendation text with the link
     642        echo '<p class="description">';
     643        echo esc_html__('For the most robust HowTo Schema, use a Jump Links Blocks for your How To steps. ', 'search-appearance-toolkit-seo-44');
     644        echo '<a href="https://seo44plugin.com/search-appearance-toolkit-seo-44/schema-structured-data/" target="_blank">' . esc_html__('(Learn more)', 'search-appearance-toolkit-seo-44') . '</a>';
     645        echo '</p>';
     646       
     647    }
    596648
    597649    public function render_textarea_field($args) {
     
    775827        echo '<p class="description">' . esc_html__('These tags are used to prove you own your site to search engines. Paste in your verification codes here and they will be added to your site\'s <head>.', 'search-appearance-toolkit-seo-44') . '</p>';
    776828    }
     829    public function render_site_apis_header_field() {
     830        echo '<h3>' . esc_html__('Site APIs', 'search-appearance-toolkit-seo-44') . '</h3>';
     831        echo '<p class="description">' . esc_html__('Adding a YouTube API key fetches accurate video upload dates for schema structured data. Without this, video upload dates may fall back to your post publication date.', 'search-appearance-toolkit-seo-44') . '</p>';
     832    }
     833    public function render_youtube_api_key_field($args) {
     834        $value = seo44_get_option('youtube_api_key');
     835        $tooltip = isset($args['tooltip']) ? $args['tooltip'] : '';
     836        ?>
     837        <input type="text"
     838               id="youtube_api_key"
     839               name="seo44_settings[youtube_api_key]"
     840               value="<?php echo esc_attr($value); ?>"
     841               class="regular-text code">
     842       
     843        <?php if ($tooltip) { seo44_render_tooltip($tooltip); } ?>
     844
     845        <p class="description">
     846            <?php esc_html_e('Enter your YouTube Data API v3 key.', 'search-appearance-toolkit-seo-44'); ?>
     847            <a href="https://developers.google.com/youtube/v3/getting-started" target="_blank">
     848                <?php esc_html_e('Get API Key', 'search-appearance-toolkit-seo-44'); ?>
     849            </a>
     850        </p>
     851        <?php
     852    }
    777853
    778854    public function settings_page_html() {
     
    780856            return;
    781857        }
    782    
     858
    783859        // --- START: SECURE TAB SWITCHING LOGIC ---
    784860   
  • search-appearance-toolkit-seo-44/tags/4.3/js/admin-script.js

    r3389330 r3423355  
    11jQuery(document).ready(function($) {
    22
    3     // --- NEW: Add custom class to metabox heading ---
     3    // --- Add custom class to metabox heading ---
    44    // Find the metabox by its ID and then find the title element inside it.
    55    var metabox = $('#seo44_meta_box');
     
    77        metabox.find('h2.hndle').addClass('seo44-metabox-heading');
    88    }
     9
     10    // --- "Use Example" Title Button ---
     11    $('#seo44-use-example-title').on('click', function() {
     12        var exampleText = $('#seo44-title-example').text();
     13        var titleInput = $('#seo44_title');
     14        titleInput.val(exampleText);
     15       
     16        // Trigger the keyup event to update the snippet preview and character counter
     17        titleInput.trigger('keyup');
     18    });
    919
    1020    // --- Character Counter Functionality (from v1.4) ---
     
    3646    createCounter('seo44_description', 'seo44_description_char_count', 160);
    3747
    38     // --- NEW: "Use Example" Title Button ---
    39     $('#seo44-use-example-title').on('click', function() {
    40         var exampleText = $('#seo44-title-example').text();
    41         var titleInput = $('#seo44_title');
    42         titleInput.val(exampleText);
     48
     49    // --- HowTo Schema Trigger Logic ---
     50    const howToWrapper = $('#seo44-howto-trigger-wrapper');
     51    const howToCheckbox = $('#seo44_enable_howto');
     52    const descriptionField = $('#seo44_description');
     53   
     54    // List of triggers (lowercase)
     55    const triggers = ['step guide', 'step-by-step', 'how to', 'how-to', 'guide', 'walkthrough', 'directions', 'instructions', 'tutorial'];
     56
     57    // Flag to track if user has manually overridden the automation
     58    let userHasInteracted = false;
     59
     60    // Listen for manual clicks
     61    howToCheckbox.on('change', function() {
     62        userHasInteracted = true;
     63    });
     64
     65    function checkHowToTriggers() {
     66        // 1. If user manually touched the box, do nothing.
     67        if (userHasInteracted) return;
     68
     69        // 2. Get description text
     70        const text = descriptionField.val().toLowerCase();
    4371       
    44         // Trigger the keyup event to update the snippet preview and character counter
    45         titleInput.trigger('keyup');
    46     });
     72        // 3. Check for triggers
     73        const hasTrigger = triggers.some(trigger => text.includes(trigger));
     74
     75        if (hasTrigger) {
     76            // Found a trigger!
     77            howToWrapper.show(); // Make visible
     78           
     79            // Only check it if it wasn't already checked (to avoid redundant events)
     80            if (!howToCheckbox.is(':checked')) {
     81                howToCheckbox.prop('checked', true);
     82            }
     83        } else {
     84            // No trigger found.
     85            // Behavior: If we are purely automated (user hasn't clicked),
     86            // we hide it and uncheck it.
     87            howToWrapper.hide();
     88            howToCheckbox.prop('checked', false);
     89        }
     90    }
     91
     92    // Run on keyup in description
     93    descriptionField.on('keyup change paste', checkHowToTriggers);
     94
     95    // Run once on load to handle pre-filled content (if user hasn't saved yet)
     96    // We delay slightly to ensure values are populated
     97    setTimeout(function() {
     98        // Only run auto-check if the box is currently hidden/unchecked.
     99        // If it's already visible/checked from DB, we respect that state.
     100        if (!howToCheckbox.is(':checked')) {
     101            checkHowToTriggers();
     102        } else {
     103            // If it IS checked from DB, mark as "interacted" so we don't auto-uncheck it
     104            // just because the user edits the description and removes a keyword.
     105            userHasInteracted = true;
     106        }
     107    }, 500);
     108
    47109
    48110    // --- Snippet Preview Functionality ---
    49111    const titleInput = $('#seo44_title');
    50112    const descriptionInput = $('#seo44_description');
    51    
     113   
    52114    const previewHeaderUrl = $('.seo44-preview-breadcrumb-url');
    53115    const previewTitle = $('.seo44-preview-title');
     
    58120    const siteName = seo44_data.site_name;
    59121    const permalink = seo44_data.permalink;
    60    
     122   
    61123    function updatePreview() {
    62124        // Update Title
    63125        let titleVal = titleInput.val();
    64         previewTitle.text(titleVal ? titleVal : defaultTitle);
    65        
    66        
    67        // if (titleVal) {
    68        //     previewTitle.text(titleVal);
    69        // } else {
    70             // If the input is empty, show "Post Title - Site Name"
    71         //    previewTitle.text(defaultTitle + ' - ' + siteName);
    72         //}
     126        previewTitle.text(titleVal ? titleVal : defaultTitle);
    73127
    74128        // Update Description
     
    80134            previewDescription.text('Enter a meta description to see how it will appear in search results...');
    81135        }
    82        
    83         // Update Breadcrumb URL
     136       
     137        // Update Breadcrumb URL
    84138        let breadcrumb = permalink.replace(/^https?:\/\//, '').replace(/\/$/, '');
    85139        breadcrumb = breadcrumb.replace(/\//g, ' &rsaquo; '); // &rsaquo; is the > symbol
  • search-appearance-toolkit-seo-44/tags/4.3/readme.txt

    r3405454 r3423355  
    33Tags: seo, on-page seo, schema, structured data, xml sitemaps
    44Requires at least: 5.5
    5 Tested up to: 6.8
    6 Stable tag: 4.2
     5Tested up to: 6.9
     6Stable tag: 4.3
    77Requires PHP: 7.4
    88License: GPLv2 or later
     
    7272* **Knowledge Graph Control:** A dedicated interface to manage your brand's digital identity. Define your Founder, Founding Date, Contact Info, and professional Credentials to improve E-E-A-T signals.
    7373* **Include Images and Videos:** A built-in tool automatically finds all images and embedded YouTube videos in your content and adds them to the schema, boosting their appearance in search results.
    74 * **FAQ and How-To Detection:** Enable a smart scanner to detect patterns in your content for FAQ and How-To sections on your website and incorporate this useful format into the schema.
     74* **FAQ and How-To Detection:** Enable smart scanners to detect patterns in your content for FAQ and How-To sections on your website and incorporate this useful format into the schema.
     75* **Jump Links HowTo Scanner:** The plugin can use a Jump Links Block as a "Map" to generate detailed HowTo schema steps, while simultaneously scanning your content for Prep Time, Yield, Supplies, and Tools.
     76* **Table of Contents Schema:** Automatically generates `hasPart` structured data that mirrors your Jump Links Block, helping search engines understand your article's deep structure.
    7577* **Modern Output:** All structured data is generated in the modern JSON-LD format preferred by search engines, following the guidelines set by [Schema.org](https://schema.org/).
    7678* **Granular Control:**  Tailor the Schema settings to fit your site's needs through Enable/disable settings, including on Custom Post Types and Taxonomies.
     
    101103The Search Appearance Toolkit serves as a hub for connecting your site to essential third-party services, helping you to create valuable analytics data and site insights.
    102104
    103 = Site Verification =
    104 Verify your site with search engines quickly and easily. Paste your verification codes for **Google Search Console** and **Bing Webmaster Tools** into the corresponding fields in the Integrations tab. The plugin handles the rest, adding site verification meta tags to your website's head. No coding required.
    105 
    106105= Google Tag Manager (GTM) Integration =
    107106Easily integrate with Google Tag Manager by pasting your `GTM-XXXXXXX` ID into the settings field. The plugin will correctly and safely inject the GTM scripts into your site's `<head>` and `<body>` on every page. No coding required.
     
    115114* **Jump Link Click Tracking:** Tracks engagement with your Jump Links Block by pushing a `jump_link_click` event, letting you see which sections your users are most interested in.
    116115
    117 SEO 44's integration with Google Tag Manager pushes valuable events to the `dataLayer` for use in Google Analytics. The plugin includes an import file and detailed [Instructions for setting up Google Tag Manager and Google Analytics to receive event-tracking data](https://seo44plugin.com/search-appearance-toolkit-seo-44/integrations-setup-guide/).
     116SEO 44's integration with Google Tag Manager pushes valuable events to the `dataLayer` for use in Google Analytics. The plugin includes an import file and detailed [Instructions for setting up Google Tag Manager and Google Analytics to receive event-tracking data](https://seo44plugin.com/search-appearance-toolkit-seo-44/integrations-setup-guide/).
     117
     118= Site Verification =
     119Verify your site with search engines quickly and easily. Paste your verification codes for **Google Search Console** and **Bing Webmaster Tools** into the corresponding fields in the Integrations tab. The plugin handles the rest, adding site verification meta tags to your website's head. No coding required.
     120
     121= YouTube Data API =
     122To ensure that your site's video schema is as accurate as possible, you may add your YouTube Data API Key.  The plugin uses this key to fetch the upload date for any YouTube video embedded in your content, replacing less reliable page scraping options and fallbacks to the post publish date.
    118123
    119124== Frequently Asked Questions ==
     
    122127Search Appearance Toolkit (SEO 44) gives you control over the technical, on-page SEO factors that help search engines understand and rank your content. Key benefits include:
    123128* **Optimized Snippets:** Control how your titles and descriptions appear in search results.
    124 * **Rich Results:** The advanced Schema.org data helps you earn rich results like FAQs, How-Tos, and breadcrumbs in Google.
     129* **Rich Results:** The advanced Schema.org data improves indexing and helps you earn rich results in Google.
    125130* **Crawlability:** A clean and comprehensive XML sitemap ensures search engines can find and index all of your important content and images.
    126131* **User Engagement:** The Jump Links Block improves user experience, which is a positive ranking signal, and can help you earn "Jump to" links in search results.
     
    154159SEO 44 helps your content look great when shared on social media platforms. You can enable the automatic generation of **Open Graph** (og:) tags, which Facebook, LinkedIn, and Pinterest use, and **Twitter Card** meta tags for when your content appears on X (formerly Twitter). This ensures your posts have the correct title, description, and preview image when shared.
    155160
    156 Use the plugin's Google Tag Manager Integration to facilitate additional connections with soclal media platforms. Use the Organization Schema settings to associate all of your social media profiles with your website.
     161Use the plugin's Google Tag Manager Integration to facilitate additional connections with social media platforms. Use the Organization Schema settings to associate all of your social media profiles with your website.
    157162
    158163= How are social media images handled? =
     
    177182
    178183= What are the benefits of using FAQPage and HowTo schema? =
    179 The plugin intelligently scans your content for patterns that match question-and-answer formats or step-by-step instructions (when this option is enabled). The plugin locates this content and automatically generates FAQPage or HowTo schema that presents this content within the JSON-LD.
    180 
    181 The benefit of this is that Google can use this structured data to display your content in special, highly visible formats in the search results. An FAQ page might appear as a rich snippet with expandable questions, while a How-To article can be featured in a step-by-step guide. Rich snippets make your search results stand out, which can significantly improve your click-through rate (CTR).
     184The plugin intelligently scans your content for patterns that match question-and-answer formats or step-by-step instructions (when this option is enabled). The plugin locates this content and automatically generates FAQPage or HowTo schema that presents this content as structured data.
     185
     186The benefit of this is that Google can use this structured data to display your content in special, highly visible formats in the search results. An FAQ page might appear as a rich snippet with expandable questions or feature within a People Also Ask (PAA) result, while a How-To article can be featured in a step-by-step guide in AI Overviews. Search results that stand out can improve your click-through rate (CTR).
    182187
    183188Read more about the [FAQPage and HowTo Schema](https://seo44plugin.com/search-appearance-toolkit-seo-44/schema-structured-data/#benefits-of-faqpage-and-howto-schema) created by SEO 44.
     
    285290    * **Google:** [Terms of Service](https://policies.google.com/terms), [Privacy Policy](https://policies.google.com/privacy)
    286291    * **Bing (Microsoft):** [Microsoft Services Agreement](https://www.microsoft.com/en-us/servicesagreement/), [Microsoft Privacy Statement](https://privacy.microsoft.com/en-us/privacystatement)
     292
     293= YouTube Data API Integration & Video Metadata =
     294
     295* **Service Description:** This plugin connects to YouTube to fetch the "Upload Date" for videos embedded in your content. This ensures your VideoObject schema is accurate.
     296* **Data Sent and Conditions:**
     297    * **Method 1 (API):** If you provide a YouTube API Key in settings, the plugin sends the Video ID to the Google Data API.
     298    * **Method 2 (Public Fallback):** If no API Key is present, the plugin acts as a standard browser and fetches the public video page (via `wp_remote_get`) to locate the upload date in the page meta tags.
     299    * **Conditions:** This occurs automatically when a post with a YouTube embed is updated, provided that Schema generation is enabled.
     300* **Service Provider Links:**
     301    * **YouTube:** [Terms of Service](https://www.youtube.com/t/terms), [Google Privacy Policy](https://policies.google.com/privacy)
    287302
    288303== Screenshots ==
     
    315330== Changelog ==
    316331
     332= 4.3.0 =
     333* FEATURE: **Table of Contents Schema:** Automatically generates `hasPart` schema synchronized with the Jump Links Block to support "Jump-to" links in search results.
     334* FEATURE: **Advanced HowTo Schema:** A new "Block-Aware" scanner uses the Jump Links Block to strictly define steps while intelligently mining the introduction for tools, time, yield, and video data.
     335* TWEAK: Added a "Generate HowTo Schema" checkbox to the SEO 44 metabox, giving users explicit control over when the advanced scanner runs.
     336* FEATURE: **YouTube Integration:** Added support for the YouTube Data API v3 to fetch accurate video upload dates for VideoObject schema.
     337* TESTED: Tested to WordPress Version 6.9
     338
    317339= 4.2.0 =
    318340* FEATURE: **Rich Organization Schema:** Added a comprehensive settings section to generate Organization structured data for the Knowledge Graph.
     
    325347* TWEAK: Updated the image uploader JavaScript to support multiple distinct upload buttons on the settings page.
    326348
    327 = 4.1.0 =
    328 **Jump Links Block Updates:**
    329 * FEATURE: **Sticky Positioning:** Keep your table of contents visible while users scroll. Includes a "Top Offset" slider to clear sticky headers, a "Jump Offset" slider to ensure that the sticky header does not cover the heading text, and a "Disable on Mobile" toggle to preserve screen space on small devices.
    330 * FEATURE: **Auto-Hide Title:** Implemented a smart "sticky state" detection. When the block sticks, the title gently collapses and fades out to keep the interface clean (this occurs when a block title is used alongside sticky positioning).
    331 * FEATURE: **Smart Indentation:** Added a "Create Visual Hierarchy" toggle. When enabled, H3 and H4 sub-headings are visually indented to create a clear, nested outline structure.
    332 * FEATURE: **Block Background:** You can now set a background color for the entire block container, perfect for creating "card-style" floating navigation.
    333 * FEATURE: **ScrollSpy:** Automatically highlights the active link in the table of contents as the user scrolls through the corresponding section of the post.
    334 * FEATURE: Added support for Border and Spacing controls. You can now add borders, rounded corners, margins, and padding to the Jump Links block directly from the editor settings.
    335 * FEATURE: Added a "Title tag" control. You can now choose the specific HTML tag (H2, H3, H4, H5, Paragraph, or Div) for the "On This Page" heading to better match your document structure.
    336 * REFACTOR: Optimized the block's styling logic to use CSS variables on the parent container instead of inline styles for every link. This reduces the block's HTML size and improves rendering performance.
    337 * FIX: Resolved an accessibility and HTML validation issue where using multiple Jump Links blocks on a single page created duplicate element IDs. Each block now generates a unique instance ID.
    338 * PERFORMANCE: Refactored the front-end JavaScript to use event delegation for smooth scrolling. This reduces memory usage by attaching a single event listener to the block instead of individual listeners for every link.
    339 * TWEAK: Reorganized the sidebar settings for better clarity between Block Title settings and Content Inclusion settings.
    340 
    341 = 4.0.0 =
    342 * FEATURE: Added a new "Integrations" tab for third-party services like Google Tag Manager and Webmaster Tools.
    343 * FEATURE: Added Google Tag Manager (GTM) integration. The plugin can now automatically inject the GTM container script into the site's <head> and <body> based on your ID.
    344 * FEATURE: Added Webmaster Verification. You can now add your Google Search Console and Bing Webmaster Tools verification codes directly from the plugin settings.
    345 * FEATURE: Added automatic GTM event tracking. When enabled, the plugin can push the following events to the dataLayer:
    346     * Rich SEO dataLayer: Pushes page type, category, author, and tags on page load for advanced GTM triggers.
    347     * Scroll Depth Tracking: Pushes 'scroll_depth' events at 25%, 50%, 75%, and 100% of the page.
    348     * External & Affiliate Clicks:* Pushes 'external_link_click' or 'affiliate_link_click' (for `rel="sponsored"` links).
    349     * Jump Link Clicks: Pushes a 'jump_link_click' event when a user clicks a link in the Jump Links Block.
    350 * FEATURE: Added a Google Tag Manager recipe import file (`seo44-gtm-recipe-importer.json`) and new FAQ instructions to fully configure GTM and GA4 event tracking.
    351 * ENHANCEMENT: Centralized all GTM event tracking into a new, efficient `global-tracker.js` file that uses event delegation for better performance.
    352 * TWEAK: Improved the "Integrations" settings page UI for clarity, adding clarifying tooltips and a file downloader.
    353 
    354349For a complete list of changes, please see the [full changelog](https://seo44plugin.com/search-appearance-toolkit-seo-44/changelog/) or the `changelog.txt` file included with the plugin.
    355350
  • search-appearance-toolkit-seo-44/tags/4.3/seo-44.php

    r3405454 r3423355  
    44 * Plugin URI:        https://www.sethcreates.com/plugins-for-wordpress/search-appearance-toolkit-seo-44/
    55 * Description:       A lightweight, powerful SEO plugin for essential meta tags, advanced schema, XML sitemaps, jump links, and easy migration from other plugins.
    6  * Version:           4.2.0
     6 * Version:           4.3
    77 * Author:            Seth Smigelski
    88 * Author URI:        https://www.sethcreates.com/plugins-for-wordpress/
     
    1414if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly
    1515
    16 define( 'SEO44_VERSION', '4.2.0' );
     16define( 'SEO44_VERSION', '4.3' );
    1717define( 'SEO44_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
    1818
  • search-appearance-toolkit-seo-44/trunk/README.md

    r3405454 r3423355  
    88* **Tags:** seo, on-page seo, schema, structured data, xml sitemaps
    99* **Requires at least:** 5.5
    10 * **Tested up to:** 6.8
    11 * **Stable tag:** 4.2.0
     10* **Tested up to:** 6.9
     11* **Stable tag:** 4.3.0
    1212* **Requires PHP:** 7.4
    1313* **License:** GPLv2 or later
     
    8585* **Knowledge Graph Control:** A dedicated interface to manage your brand's digital identity. Define your Founder, Founding Date, Contact Info, and professional Credentials to improve E-E-A-T signals.
    8686* **Include Images and Videos:** A built-in tool automatically finds all images and embedded YouTube videos in your content and adds them to the schema, boosting their appearance in search results.
    87 * **FAQ and How-To Detection:** Enable a smart scanner to detect patterns in your content for FAQ and How-To sections on your website and incorporate this useful format into the schema.
     87* **FAQ and How-To Detection:** Enable smart scanners to detect patterns in your content for FAQ and How-To sections on your website and incorporate this useful format into the schema.
     88* **Jump Links HowTo Scanner:** The plugin can use a Jump Links Block as a "Map" to generate detailed HowTo schema steps, while simultaneously scanning your content for Prep Time, Yield, Supplies, and Tools.
     89* **Table of Contents Schema:** Automatically generates `hasPart` structured data that mirrors your Jump Links Block, helping search engines understand your article's deep structure.
    8890* **Modern Output:** All structured data is generated in the modern JSON-LD format preferred by search engines, following the guidelines set by [Schema.org](https://schema.org/).
    8991* **Granular Control:**  Tailor the Schema settings to fit your site's needs through Enable/disable settings, including on Custom Post Types and Taxonomies.
     
    128130SEO 44's integration with Google Tag Manager pushes valuable events to the `dataLayer` for use in Google Analytics. The plugin provides an import file and detailed instructions for setting up Google Tag Manager and Google Analytics to receive event tracking data.
    129131
    130 ## Site Verification Tags
     132### Site Verification Tags
    131133Verify your site with search engines quickly and easily. Paste your verification codes for **Google Search Console** and **Bing Webmaster Tools** into the corresponding fields in the Integrations tab. The plugin handles the rest, adding site verification meta tags to your website's head. No coding required.
     134
     135### YouTube Data API
     136To ensure that your site's video schema is as accurate as possible, you may add your YouTube Data API Key.  The plugin uses this key to fetch the upload date for any YouTube video embedded in your content, replacing less reliable page scraping options and fallbacks to the post publish date.
    132137
    133138---
     
    138143Search Appearance Toolkit (SEO 44) gives you control over the technical, on-page SEO factors that help search engines understand and rank your content. Key benefits include:
    139144* **Optimized Snippets:** Control how your titles and descriptions appear in search results.
    140 * **Rich Results:** The advanced Schema.org data helps you earn rich results like FAQs, How-Tos, and breadcrumbs in Google.
     145* **Rich Results:** The advanced Schema.org data improves indexing and helps you earn rich results in Google.
    141146* **Crawlability:** A clean and comprehensive XML sitemap ensures search engines can find and index all of your important content and images.
    142147* **User Engagement:** The Jump Links Block improves user experience, which is a positive ranking signal, and can help you earn "Jump to" links in search results.
     
    170175SEO 44 helps your content look great when shared on social media platforms. You can enable the automatic generation of **Open Graph** (og:) tags, which Facebook, LinkedIn, and Pinterest use, and **Twitter Card** meta tags for when your content appears on X (formerly Twitter). This ensures your posts have the correct title, description, and preview image when shared.
    171176
    172 Use the plugin's Google Tag Manager Integration to facilitate additional connections with soclal media platforms. Use the Organization Schema settings to associate all of your social media profiles with your website.
     177Use the plugin's Google Tag Manager Integration to facilitate additional connections with social media platforms. Use the Organization Schema settings to associate all of your social media profiles with your website.
    173178
    174179### How are social media images handled?
     
    193198
    194199### What are the benefits of using FAQPage and HowTo schema?
    195 The plugin intelligently scans your content for patterns that match question-and-answer formats or step-by-step instructions (when this option is enabled). The plugin locates this content and automatically generates FAQPage or HowTo schema that presents this content within the JSON-LD.
    196 
    197 The benefit of this is that Google can use this structured data to display your content in special, highly visible formats in the search results. An FAQ page might appear as a rich snippet with expandable questions, while a How-To article can be featured in a step-by-step guide. Rich snippets make your search results stand out, which can significantly improve your click-through rate (CTR).
     200The plugin intelligently scans your content for patterns that match question-and-answer formats or step-by-step instructions (when this option is enabled). The plugin locates this content and automatically generates FAQPage or HowTo schema that presents this content as structured data.
     201
     202The benefit of this is that Google can use this structured data to display your content in special, highly visible formats in the search results. An FAQ page might appear as a rich snippet with expandable questions or feature within a People Also Ask (PAA) result, while a How-To article can be featured in a step-by-step guide in AI Overviews. Search results that stand out can improve your click-through rate (CTR).
    198203
    199204Read more about the [FAQPage and HowTo Schema](https://seo44plugin.com/search-appearance-toolkit-seo-44/schema-structured-data/#benefits-of-faqpage-and-howto-schema) created by SEO 44.
     
    457462    * **Google:** [Terms of Service](https://policies.google.com/terms), [Privacy Policy](https://policies.google.com/privacy)
    458463    * **Bing (Microsoft):** [Microsoft Services Agreement](https://www.microsoft.com/en-us/servicesagreement/), [Microsoft Privacy Statement](https://privacy.microsoft.com/en-us/privacystatement)
     464 
     465### YouTube Data API Integration & Video Metadata
     466
     467* **Service Description:** This plugin connects to YouTube to fetch the "Upload Date" for videos embedded in your content. This ensures your VideoObject schema is accurate.
     468* **Data Sent and Conditions:**
     469    * **Method 1 (API):** If you provide a YouTube API Key in settings, the plugin sends the Video ID to the Google Data API.
     470    * **Method 2 (Public Fallback):** If no API Key is present, the plugin acts as a standard browser and fetches the public video page (via `wp_remote_get`) to locate the upload date in the page meta tags.
     471    * **Conditions:** This occurs automatically when a post with a YouTube embed is updated, provided that Schema generation is enabled.
     472* **Service Provider Links:**
     473    * **YouTube:** [Terms of Service](https://www.youtube.com/t/terms), [Google Privacy Policy](https://policies.google.com/privacy)
    459474
    460475---
     
    574589
    575590## Changelog
     591
     592### 4.3.0
     593* **FEATURE:** **Table of Contents Schema:** Automatically generates `hasPart` schema synchronized with the Jump Links Block to support "Jump-to" links in search results.
     594* **FEATURE:** **Advanced HowTo Schema:** A new "Block-Aware" scanner uses the Jump Links Block to strictly define steps while intelligently mining the introduction for tools, time, yield, and video data.
     595* **TWEAK:** Added a "Generate HowTo Schema" checkbox to the SEO 44 metabox, giving users explicit control over when the advanced scanner runs.
     596* **FEATURE:** **YouTube Integration:** Added support for the YouTube Data API v3 to fetch accurate video upload dates for VideoObject schema.
     597* **TESTED:** Tested to WordPress Version 6.9
    576598
    577599### 4.2.0
     
    585607* **TWEAK:** Updated the image uploader JavaScript to support multiple distinct upload buttons on the settings page.
    586608
    587 ### 4.1.0
    588 **Jump Links Block Updates:**
    589 * **FEATURE:** **Sticky Positioning:** Keep your table of contents visible while users scroll. Includes a "Top Offset" slider to clear sticky headers, a "Jump Offset" slider to ensure that the sticky header does not cover the heading text, and a "Disable on Mobile" toggle to preserve screen space on small devices.
    590 * **FEATURE:** **Auto-Hide Title:** Implemented a smart "sticky state" detection. When the block sticks, the title gently collapses and fades out to keep the interface clean (this occurs when a block title is used alongside sticky positioning).
    591 * **FEATURE:** **Smart Indentation:** Added a "Create Visual Hierarchy" toggle. When enabled, H3 and H4 sub-headings are visually indented to create a clear, nested outline structure.
    592 * **FEATURE:** **Block Background:** You can now set a background color for the entire block container, perfect for creating "card-style" floating navigation.
    593 * **FEATURE:** **ScrollSpy:** Automatically highlights the active link in the table of contents as the user scrolls through the corresponding section of the post.
    594 * **FEATURE:** Added support for Border and Spacing controls. You can now add borders, rounded corners, margins, and padding to the Jump Links block directly from the editor settings.
    595 * **FEATURE:** Added a "Title tag" control. You can now choose the specific HTML tag (H2, H3, H4, H5, Paragraph, or Div) for the "On This Page" heading to better match your document structure.
    596 * **REFACTOR:** Optimized the block's styling logic to use CSS variables on the parent container instead of inline styles for every link. This reduces the block's HTML size and improves rendering performance.
    597 * **FIX:** Resolved an accessibility and HTML validation issue where using multiple Jump Links blocks on a single page created duplicate element IDs. Each block now generates a unique instance ID.
    598 * **PERFORMANCE:** Refactored the front-end JavaScript to use event delegation for smooth scrolling. This reduces memory usage by attaching a single event listener to the block instead of individual listeners for every link.
    599 * **TWEAK:** Reorganized the sidebar settings for better clarity between Block Title settings and Content Inclusion settings.
    600 
    601 ### 4.0.0
    602 * **FEATURE:** Added a new "Integrations" tab for third-party services like Google Tag Manager and Webmaster Tools.
    603 * **FEATURE:** Added Google Tag Manager (GTM) integration. The plugin can now automatically inject the GTM container script into the site's <head> and <body> based on your ID.
    604 * **FEATURE:** Added Webmaster Verification. You can now add your Google Search Console and Bing Webmaster Tools verification codes directly from the plugin settings.
    605 * **FEATURE:** Added automatic GTM event tracking. When enabled, the plugin can push the following events to the dataLayer:
    606     * Rich SEO dataLayer: Pushes page type, category, author, and tags on page load for advanced GTM triggers.
    607     * Scroll Depth Tracking: Pushes 'scroll_depth' events at 25%, 50%, 75%, and 100% of the page.
    608     * External & Affiliate Clicks:* Pushes 'external_link_click' or 'affiliate_link_click' (for `rel="sponsored"` links).
    609     * Jump Link Clicks: Pushes a 'jump_link_click' event when a user clicks a link in the Jump Links Block.
    610 * **FEATURE:** Added a Google Tag Manager recipe import file (`seo44-gtm-recipe-importer.json`) and new FAQ instructions to fully configure GTM and GA4 event tracking.
    611 * **ENHANCEMENT:** Centralized all GTM event tracking into a new, efficient `global-tracker.js` file that uses event delegation for better performance.
    612 * **TWEAK:** Improved the "Integrations" settings page UI for clarity, adding clarifying tooltips and a file downloader.
    613 
    614 For a complete list of changes, please see the [full changelog](https://github.com/SethSmigelski/search-appearance-toolkit-seo-44/blob/main/changelog.txt) or the `changelog.txt` file included with the plugin.
    615 
    616609---
    617610
  • search-appearance-toolkit-seo-44/trunk/build/index.asset.php

    r3405454 r3423355  
    1 <?php return array('dependencies' => array('react', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '9e72772c3731a4c27337');
     1<?php return array('dependencies' => array('react', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n'), 'version' => '2c1769c1ec087de88890');
  • search-appearance-toolkit-seo-44/trunk/build/index.js

    r3403604 r3423355  
    1 (()=>{"use strict";var e,t={313:()=>{const e=window.wp.blocks,t=window.React,a=window.wp.i18n,o=window.wp.blockEditor,l=window.wp.components,n=window.wp.data,r=window.wp.element;function s(e){return(new DOMParser).parseFromString(e,"text/html").body.textContent||""}const i=(0,t.createElement)("svg",{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M3 21v-2h18v2zm8-4v-6.175L9.4 12.4L8 11l4-4l4 4l-1.4 1.4l-1.6-1.575V17zM3 5V3h18v2z"})),c=(0,t.createElement)("svg",{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M3 5V3h18v2zm9 12l-4-4l1.4-1.4l1.6 1.575V7h2v6.175l1.6-1.575L16 13zm-9 4v-2h18v2z"})),h=(0,t.createElement)("svg",{className:"arrow-down",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 16l-6-6 1.41-1.41L12 13.17l4.59-4.58L18 10l-6 6z"})),p=JSON.parse('{"UU":"seo44/jump-links"}');(0,e.registerBlockType)(p.UU,{edit:function({attributes:e,setAttributes:p}){const{headingLevels:k,headings:u,showHeading:d,headingText:m,headingTag:g,layout:_,listStyle:b,isEditing:v,isCollapsible:f,isSmartIndentation:x,fontSize:w,textColor:C,linkColor:E,blockBackgroundColor:y,linkBackgroundColor:B,linkBackgroundColorHover:S,linkBorderColor:z,linkBorderRadius:T,isSticky:N,stickyOffset:H,jumpOffset:M,stickyStrategy:P}=e,O={color:C,fontSize:w,"--jump-link-font-size":w||"18px","--seo44-link-color":E,"--seo44-link-bg":"horizontal"===_?B:void 0,"--seo44-link-hover-bg":"horizontal"===_?S:void 0,"--seo44-link-border-color":"horizontal"===_?z:void 0,"--seo44-link-radius":"horizontal"===_&&T?`${T}px`:void 0,"--seo44-block-bg":y,"--seo44-sticky-offset":N?`${H}px`:void 0},I="ol"===b?"ol":"ul",{createInfoNotice:L}=(0,n.useDispatch)("core/notices"),j=e.blockInstanceId?`seo44-jump-links-list-${e.blockInstanceId}`:"seo44-jump-links-list",$=(0,o.useBlockProps)({style:O});$.className=`${$.className} ${"horizontal"===_?"is-layout-horizontal":""} ${f&&!v?"is-collapsible":""} ${"none"===b?"list-style-none":""}`.trim();const D=(0,n.useSelect)(e=>e("core/block-editor").getBlocks(),[]),{updateBlockAttributes:V}=(0,n.useDispatch)("core/block-editor");(0,r.useEffect)(()=>{const t={};e.blockInstanceId||(t.blockInstanceId=Math.random().toString(36).substr(2,9)),Object.keys(t).length>0&&p(t);const o=D.filter(e=>"core/heading"===e.name&&k.includes(`h${e.attributes.level}`)),l=new Set;let n=!1;const r=new Map(u.map(e=>[e.anchor,e])),i=[];for(const e of o){const t=s(e.attributes.content);let a=e.attributes.anchor||t.toLowerCase().replace(/[^a-z0-9\s-]/g,"").trim().replace(/\s+/g,"-"),o=a,c=2;for(;l.has(o);)o=`${a}-${c}`,c++,n=!0;l.add(o),e.attributes.anchor!==o&&V(e.clientId,{anchor:o});const h=r.get(e.attributes.anchor)||r.get(o),p=h&&h.linkText!==h.text?h.linkText:t,k=!h||h.isVisible;i.push({anchor:o,text:t,linkText:p,isVisible:k,level:e.attributes.level})}JSON.stringify(i)!==JSON.stringify(u)&&p({headings:i}),n&&L((0,a.__)("Jump Links Block: Duplicate headings were found. Unique IDs have been auto-generated, but this may be a sign of redundancy. Please review your headings for clarity.","search-appearance-toolkit-seo-44"),{type:"snackbar"})},[D,k,u,e.blockInstanceId,p,V,L]),(0,r.useEffect)(()=>{"horizontal"===_&&"none"!==b&&p({listStyle:"none"})},[_,b,p]);const R=(e,t)=>{const a=[...u],o=a.splice(e,1)[0];"up"===t?a.splice(e-1,0,o):a.splice(e+1,0,o),p({headings:a})},J=e=>{const t=k.includes(e)?k.filter(t=>t!==e):[...k,e];p({headingLevels:t.sort()})};return(0,t.createElement)(r.Fragment,null,(0,t.createElement)(o.InspectorControls,null,(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Presentation","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ButtonGroup,null,(0,t.createElement)(l.Button,{isPrimary:!v,isPressed:!v,onClick:()=>p({isEditing:!1})},(0,a.__)("Viewing Mode","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.Button,{isPrimary:v,isPressed:v,onClick:()=>p({isEditing:!0})},(0,a.__)("Editing Mode","search-appearance-toolkit-seo-44"))),(0,t.createElement)("p",{className:"description"},(0,a.__)("Switch to Editing Mode to customize link text, visibility, and order.","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Appearance","search-appearance-toolkit-seo-44")},(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Layout","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.ButtonGroup,null,(0,t.createElement)(l.Button,{isPrimary:"vertical"===_,isPressed:"vertical"===_,onClick:()=>p({layout:"vertical"})},(0,a.__)("Vertical","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.Button,{isPrimary:"horizontal"===_,isPressed:"horizontal"===_,onClick:()=>p({layout:"horizontal"})},(0,a.__)("Horizontal","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Make Jump Links Area Expandable","search-appearance-toolkit-seo-44"),help:(0,a.__)('Conserve screen space by collapsing a long list of jump links, providing users with an elegant "show more" button to see the entire list.',"search-appearance-toolkit-seo-44"),checked:f,onChange:()=>p({isCollapsible:!f}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(l.SelectControl,{label:(0,a.__)("List Style","search-appearance-toolkit-seo-44"),value:b,options:[{label:(0,a.__)("Bulleted","search-appearance-toolkit-seo-44"),value:"ul"},{label:(0,a.__)("Numbered","search-appearance-toolkit-seo-44"),value:"ol"},{label:(0,a.__)("None","search-appearance-toolkit-seo-44"),value:"none"}],onChange:e=>p({listStyle:e}),disabled:"horizontal"===_,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.FontSizePicker,{fontSizes:[{name:(0,a.__)("S","search-appearance-toolkit-seo-44"),slug:"small",size:"14px"},{name:(0,a.__)("M","search-appearance-toolkit-seo-44"),slug:"normal",size:"17px"},{name:(0,a.__)("L","search-appearance-toolkit-seo-44"),slug:"large",size:"20px"},{name:(0,a.__)("XL","search-appearance-toolkit-seo-44"),slug:"extra-large",size:"23px"}],value:w,onChange:e=>p({fontSize:e}),withReset:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(o.PanelColorSettings,{title:(0,a.__)("Colors","search-appearance-toolkit-seo-44"),colorSettings:[{value:y,onChange:e=>p({blockBackgroundColor:e}),label:(0,a.__)("Block Background","search-appearance-toolkit-seo-44")},{value:E,onChange:e=>p({linkColor:e}),label:(0,a.__)("Link Color","search-appearance-toolkit-seo-44")},{value:C,onChange:e=>p({textColor:e}),label:(0,a.__)("Other Text Color","search-appearance-toolkit-seo-44")}]}),"horizontal"===_&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)("hr",null),(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Horizontal Link Styles","search-appearance-toolkit-seo-44"))),(0,t.createElement)(o.PanelColorSettings,{title:(0,a.__)("Link Colors","search-appearance-toolkit-seo-44"),colorSettings:[{value:B,onChange:e=>p({linkBackgroundColor:e}),label:(0,a.__)("Background","search-appearance-toolkit-seo-44")},{value:S,onChange:e=>p({linkBackgroundColorHover:e}),label:(0,a.__)("Background Hover","search-appearance-toolkit-seo-44")},{value:z,onChange:e=>p({linkBorderColor:e}),label:(0,a.__)("Border","search-appearance-toolkit-seo-44")}]}),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Link Border Radius","search-appearance-toolkit-seo-44"),value:T,onChange:e=>p({linkBorderRadius:e}),min:0,max:50,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}))),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Content Settings","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Display Block Title","search-appearance-toolkit-seo-44"),checked:d,onChange:()=>p({showHeading:!d}),__nextHasNoMarginBottom:!0}),d&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)(l.TextControl,{label:(0,a.__)("Title Text","search-appearance-toolkit-seo-44"),value:m,onChange:e=>p({headingText:e}),help:(0,a.__)("The text that appears above your list of links.","search-appearance-toolkit-seo-44")}),(0,t.createElement)(l.SelectControl,{label:(0,a.__)("Title Tag","search-appearance-toolkit-seo-44"),value:g,options:[{label:"H2",value:"h2"},{label:"H3",value:"h3"},{label:"H4",value:"h4"},{label:"H5",value:"h5"},{label:"Paragraph (Bold)",value:"p"},{label:"Div (No Semantic Value)",value:"div"}],onChange:e=>p({headingTag:e}),help:(0,a.__)("Choose a level that fits your page's structure.","search-appearance-toolkit-seo-44"),__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0})),(0,t.createElement)("hr",null),(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Included Headings","search-appearance-toolkit-seo-44"))),(0,t.createElement)("p",{className:"description",style:{marginBottom:"10px"}},(0,a.__)("Select which heading levels from your post content should appear in the jump links list.","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.CheckboxControl,{label:"H2",checked:k.includes("h2"),onChange:()=>J("h2")}),(0,t.createElement)(l.CheckboxControl,{label:"H3",checked:k.includes("h3"),onChange:()=>J("h3")}),(0,t.createElement)(l.CheckboxControl,{label:"H4",checked:k.includes("h4"),onChange:()=>J("h4")}),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Create Visual Hierarchy","search-appearance-toolkit-seo-44"),help:(0,a.__)("Indents sub-headings (H3, H4) to create a nested outline structure.","search-appearance-toolkit-seo-44"),checked:x,onChange:()=>p({isSmartIndentation:!x}),__nextHasNoMarginBottom:!0})),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Position Settings","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Sticky Position","search-appearance-toolkit-seo-44"),help:(0,a.__)("Keep the table of contents visible while scrolling.","search-appearance-toolkit-seo-44"),checked:N,onChange:()=>p({isSticky:!N}),__nextHasNoMarginBottom:!0}),N&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)("p",{className:"description",style:{marginBottom:"15px"}},(0,a.__)("Customize how the block behaves when it sticks to the top of the screen.","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Top Offset (px)","search-appearance-toolkit-seo-44"),help:(0,a.__)("The distance between the top of the screen and the block when stuck (useful for clearing sticky headers).","search-appearance-toolkit-seo-44"),value:H,onChange:e=>p({stickyOffset:e}),min:0,max:200,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Jump Offset (px)","search-appearance-toolkit-seo-44"),help:(0,a.__)("The buffer distance to stop *before* the heading. Increase this if your sticky header covers the text.","search-appearance-toolkit-seo-44"),value:M,onChange:e=>p({jumpOffset:e}),min:0,max:200,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Disable on Mobile","search-appearance-toolkit-seo-44"),help:(0,a.__)("Prevents the block from sticking on small screens to save reading space.","search-appearance-toolkit-seo-44"),checked:"desktop-only"===P,onChange:e=>p({stickyStrategy:e?"desktop-only":"always"}),__nextHasNoMarginBottom:!0})))),(0,t.createElement)("div",{...$},d&&(0,t.createElement)(o.RichText,{tagName:g,className:"wp-block-seo44-jump-links-heading",value:m,onChange:e=>p({headingText:e}),placeholder:(0,a.__)("On This Page","search-appearance-toolkit-seo-44")}),u.length>0?(0,t.createElement)("nav",{"aria-label":(0,a.__)("Table of contents","search-appearance-toolkit-seo-44")},(0,t.createElement)(I,{id:j},u.map((e,o)=>v?(0,t.createElement)("li",{key:e.anchor},(0,t.createElement)(l.TextControl,{value:e.linkText,onChange:e=>((e,t)=>{const a=[...u];a[e].linkText=t,p({headings:a})})(o,e)}),(0,t.createElement)("div",{className:"edit-controls-wrapper"},(0,t.createElement)("div",{className:"reorder-buttons"},(0,t.createElement)(l.Button,{icon:i,label:(0,a.__)("Move Up","search-appearance-toolkit-seo-44"),onClick:()=>R(o,"up"),disabled:0===o}),(0,t.createElement)(l.Button,{icon:c,label:(0,a.__)("Move Down","search-appearance-toolkit-seo-44"),onClick:()=>R(o,"down"),disabled:o===u.length-1})),(0,t.createElement)(l.ToggleControl,{label:!1!==e.isVisible?(0,a.__)("Included","search-appearance-toolkit-seo-44"):(0,a.__)("This Jump Link will not be shown","search-appearance-toolkit-seo-44"),checked:!1!==e.isVisible,onChange:()=>(e=>{const t=[...u];t[e].isVisible=!t[e].isVisible,p({headings:t})})(o),__nextHasNoMarginBottom:!0}))):!1!==e.isVisible&&(0,t.createElement)("li",{key:e.anchor,className:x?`seo44-jump-link-level-${e.level}`:""},(0,t.createElement)("a",{href:`#${e.anchor}`,onClick:e=>e.preventDefault()},e.linkText)))),!v&&f&&u.length>0&&(0,t.createElement)(l.Tooltip,{text:(0,a.__)("This button is functional on the front-end to expand the list.","search-appearance-toolkit-seo-44")},(0,t.createElement)("button",{type:"button",className:"seo-44-show-more","aria-label":(0,a.__)("Show More","search-appearance-toolkit-seo-44"),"aria-expanded":"false","aria-controls":j,onClick:()=>{L((0,a.__)('The "Show More" button is interactive on the published page.',"search-appearance-toolkit-seo-44"),{type:"snackbar"})}},h))):(0,t.createElement)("p",null,(0,a.__)("No headings found. Select a heading level in the block settings to generate links.","search-appearance-toolkit-seo-44"))))},save:function({attributes:e}){const{blockInstanceId:l,layout:n,isCollapsible:r,isSmartIndentation:s,headings:i,showHeading:c,headingText:h,headingTag:p,listStyle:k,fontSize:u,textColor:d,linkColor:m,blockBackgroundColor:g,linkBackgroundColor:_,linkBackgroundColorHover:b,linkBorderColor:v,linkBorderRadius:f,isSticky:x,stickyOffset:w,jumpOffset:C,stickyStrategy:E}=e,y={color:d,fontSize:u,"--jump-link-font-size":u||"18px","--seo44-block-bg":g,"--seo44-link-color":m,"--seo44-link-bg":"horizontal"===n?_:void 0,"--seo44-link-hover-bg":"horizontal"===n?b:void 0,"--seo44-link-border-color":"horizontal"===n?v:void 0,"--seo44-link-radius":"horizontal"===n&&f?`${f}px`:void 0,"--seo44-sticky-offset":x?`${w}px`:void 0},B="ol"===k?"ol":"ul",S=`seo44-jump-links-list-${l}`,z=o.useBlockProps.save({className:`${"horizontal"===n?"is-layout-horizontal":""} ${r?"is-collapsible":""} ${"none"===k?"list-style-none":""} ${x?"is-sticky":""} ${"desktop-only"===E?"sticky-desktop-only":""}`.trim(),style:y,"data-seo44-jump-offset":x?C:30}),T=(0,t.createElement)("svg",{className:"arrow-down",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 16l-6-6 1.41-1.41L12 13.17l4.59-4.58L18 10l-6 6z"})),N=(0,t.createElement)("svg",{className:"arrow-up",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14l-6-6z"}));return(0,t.createElement)("div",{...z},(0,t.createElement)("div",{className:"seo44-sticky-sentinel","aria-hidden":"true"}),c&&(0,t.createElement)(o.RichText.Content,{tagName:p||"h2",className:"wp-block-seo44-jump-links-heading",value:h}),i&&i.length>0&&(0,t.createElement)("nav",{"aria-label":(0,a.__)("Table of contents","search-appearance-toolkit-seo-44")},(0,t.createElement)(B,{id:S},i.filter(e=>!1!==e.isVisible).map(e=>(0,t.createElement)("li",{key:e.anchor,className:s?`seo44-jump-link-level-${e.level}`:""},(0,t.createElement)("a",{href:`#${e.anchor}`},e.linkText)))),r&&(0,t.createElement)("button",{type:"button",className:"seo-44-show-more","aria-label":(0,a.__)("Show More","search-appearance-toolkit-seo-44"),"aria-expanded":"false","aria-controls":S},T,N)))}})}},a={};function o(e){var l=a[e];if(void 0!==l)return l.exports;var n=a[e]={exports:{}};return t[e](n,n.exports,o),n.exports}o.m=t,e=[],o.O=(t,a,l,n)=>{if(!a){var r=1/0;for(h=0;h<e.length;h++){for(var[a,l,n]=e[h],s=!0,i=0;i<a.length;i++)(!1&n||r>=n)&&Object.keys(o.O).every(e=>o.O[e](a[i]))?a.splice(i--,1):(s=!1,n<r&&(r=n));if(s){e.splice(h--,1);var c=l();void 0!==c&&(t=c)}}return t}n=n||0;for(var h=e.length;h>0&&e[h-1][2]>n;h--)e[h]=e[h-1];e[h]=[a,l,n]},o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={57:0,350:0};o.O.j=t=>0===e[t];var t=(t,a)=>{var l,n,[r,s,i]=a,c=0;if(r.some(t=>0!==e[t])){for(l in s)o.o(s,l)&&(o.m[l]=s[l]);if(i)var h=i(o)}for(t&&t(a);c<r.length;c++)n=r[c],o.o(e,n)&&e[n]&&e[n][0](),e[n]=0;return o.O(h)},a=globalThis.webpackChunkseo_44_jump_links_block=globalThis.webpackChunkseo_44_jump_links_block||[];a.forEach(t.bind(null,0)),a.push=t.bind(null,a.push.bind(a))})();var l=o.O(void 0,[350],()=>o(313));l=o.O(l)})();
     1(()=>{"use strict";var e,t={313(){const e=window.wp.blocks,t=window.React,a=window.wp.i18n,o=window.wp.blockEditor,l=window.wp.components,n=window.wp.data,r=window.wp.element;function s(e){return(new DOMParser).parseFromString(e,"text/html").body.textContent||""}const i=(0,t.createElement)("svg",{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M3 21v-2h18v2zm8-4v-6.175L9.4 12.4L8 11l4-4l4 4l-1.4 1.4l-1.6-1.575V17zM3 5V3h18v2z"})),c=(0,t.createElement)("svg",{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M3 5V3h18v2zm9 12l-4-4l1.4-1.4l1.6 1.575V7h2v6.175l1.6-1.575L16 13zm-9 4v-2h18v2z"})),h=(0,t.createElement)("svg",{className:"arrow-down",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 16l-6-6 1.41-1.41L12 13.17l4.59-4.58L18 10l-6 6z"})),p=JSON.parse('{"UU":"seo44/jump-links"}');(0,e.registerBlockType)(p.UU,{edit:function({attributes:e,setAttributes:p}){const{headingLevels:k,headings:u,showHeading:d,headingText:m,headingTag:g,layout:_,listStyle:b,isEditing:v,isCollapsible:f,isSmartIndentation:x,fontSize:w,textColor:C,linkColor:E,blockBackgroundColor:y,linkBackgroundColor:B,linkBackgroundColorHover:S,linkBorderColor:z,linkBorderRadius:T,isSticky:N,stickyOffset:H,jumpOffset:M,stickyStrategy:P}=e,O={color:C,fontSize:w,"--jump-link-font-size":w||"18px","--seo44-link-color":E,"--seo44-link-bg":"horizontal"===_?B:void 0,"--seo44-link-hover-bg":"horizontal"===_?S:void 0,"--seo44-link-border-color":"horizontal"===_?z:void 0,"--seo44-link-radius":"horizontal"===_&&T?`${T}px`:void 0,"--seo44-block-bg":y,"--seo44-sticky-offset":N?`${H}px`:void 0},I="ol"===b?"ol":"ul",{createInfoNotice:L}=(0,n.useDispatch)("core/notices"),j=e.blockInstanceId?`seo44-jump-links-list-${e.blockInstanceId}`:"seo44-jump-links-list",$=(0,o.useBlockProps)({style:O});$.className=`${$.className} ${"horizontal"===_?"is-layout-horizontal":""} ${f&&!v?"is-collapsible":""} ${"none"===b?"list-style-none":""}`.trim();const D=(0,n.useSelect)(e=>e("core/block-editor").getBlocks(),[]),{updateBlockAttributes:V}=(0,n.useDispatch)("core/block-editor");(0,r.useEffect)(()=>{const t={};e.blockInstanceId||(t.blockInstanceId=Math.random().toString(36).substr(2,9)),Object.keys(t).length>0&&p(t);const o=D.filter(e=>"core/heading"===e.name&&k.includes(`h${e.attributes.level}`)),l=new Set;let n=!1;const r=new Map(u.map(e=>[e.anchor,e])),i=[];for(const e of o){const t=s(e.attributes.content);let a=e.attributes.anchor||t.toLowerCase().replace(/[^a-z0-9\s-]/g,"").trim().replace(/\s+/g,"-"),o=a,c=2;for(;l.has(o);)o=`${a}-${c}`,c++,n=!0;l.add(o),e.attributes.anchor!==o&&V(e.clientId,{anchor:o});const h=r.get(e.attributes.anchor)||r.get(o),p=h&&h.linkText!==h.text?h.linkText:t,k=!h||h.isVisible;i.push({anchor:o,text:t,linkText:p,isVisible:k,level:e.attributes.level})}JSON.stringify(i)!==JSON.stringify(u)&&p({headings:i}),n&&L((0,a.__)("Jump Links Block: Duplicate headings were found. Unique IDs have been auto-generated, but this may be a sign of redundancy. Please review your headings for clarity.","search-appearance-toolkit-seo-44"),{type:"snackbar"})},[D,k,u,e.blockInstanceId,p,V,L]),(0,r.useEffect)(()=>{"horizontal"===_&&"none"!==b&&p({listStyle:"none"})},[_,b,p]);const R=(e,t)=>{const a=[...u],o=a.splice(e,1)[0];"up"===t?a.splice(e-1,0,o):a.splice(e+1,0,o),p({headings:a})},J=e=>{const t=k.includes(e)?k.filter(t=>t!==e):[...k,e];p({headingLevels:t.sort()})};return(0,t.createElement)(r.Fragment,null,(0,t.createElement)(o.InspectorControls,null,(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Presentation","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ButtonGroup,null,(0,t.createElement)(l.Button,{isPrimary:!v,isPressed:!v,onClick:()=>p({isEditing:!1})},(0,a.__)("Viewing Mode","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.Button,{isPrimary:v,isPressed:v,onClick:()=>p({isEditing:!0})},(0,a.__)("Editing Mode","search-appearance-toolkit-seo-44"))),(0,t.createElement)("p",{className:"description"},(0,a.__)("Switch to Editing Mode to customize link text, visibility, and order.","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Appearance","search-appearance-toolkit-seo-44")},(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Layout","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.ButtonGroup,null,(0,t.createElement)(l.Button,{isPrimary:"vertical"===_,isPressed:"vertical"===_,onClick:()=>p({layout:"vertical"})},(0,a.__)("Vertical","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.Button,{isPrimary:"horizontal"===_,isPressed:"horizontal"===_,onClick:()=>p({layout:"horizontal"})},(0,a.__)("Horizontal","search-appearance-toolkit-seo-44"))),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Make Jump Links Area Expandable","search-appearance-toolkit-seo-44"),help:(0,a.__)('Conserve screen space by collapsing a long list of jump links, providing users with an elegant "show more" button to see the entire list.',"search-appearance-toolkit-seo-44"),checked:f,onChange:()=>p({isCollapsible:!f}),__nextHasNoMarginBottom:!0}),(0,t.createElement)(l.SelectControl,{label:(0,a.__)("List Style","search-appearance-toolkit-seo-44"),value:b,options:[{label:(0,a.__)("Bulleted","search-appearance-toolkit-seo-44"),value:"ul"},{label:(0,a.__)("Numbered","search-appearance-toolkit-seo-44"),value:"ol"},{label:(0,a.__)("None","search-appearance-toolkit-seo-44"),value:"none"}],onChange:e=>p({listStyle:e}),disabled:"horizontal"===_,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.FontSizePicker,{fontSizes:[{name:(0,a.__)("S","search-appearance-toolkit-seo-44"),slug:"small",size:"14px"},{name:(0,a.__)("M","search-appearance-toolkit-seo-44"),slug:"normal",size:"17px"},{name:(0,a.__)("L","search-appearance-toolkit-seo-44"),slug:"large",size:"20px"},{name:(0,a.__)("XL","search-appearance-toolkit-seo-44"),slug:"extra-large",size:"23px"}],value:w,onChange:e=>p({fontSize:e}),withReset:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(o.PanelColorSettings,{title:(0,a.__)("Colors","search-appearance-toolkit-seo-44"),colorSettings:[{value:y,onChange:e=>p({blockBackgroundColor:e}),label:(0,a.__)("Block Background","search-appearance-toolkit-seo-44")},{value:E,onChange:e=>p({linkColor:e}),label:(0,a.__)("Link Color","search-appearance-toolkit-seo-44")},{value:C,onChange:e=>p({textColor:e}),label:(0,a.__)("Other Text Color","search-appearance-toolkit-seo-44")}]}),"horizontal"===_&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)("hr",null),(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Horizontal Link Styles","search-appearance-toolkit-seo-44"))),(0,t.createElement)(o.PanelColorSettings,{title:(0,a.__)("Link Colors","search-appearance-toolkit-seo-44"),colorSettings:[{value:B,onChange:e=>p({linkBackgroundColor:e}),label:(0,a.__)("Background","search-appearance-toolkit-seo-44")},{value:S,onChange:e=>p({linkBackgroundColorHover:e}),label:(0,a.__)("Background Hover","search-appearance-toolkit-seo-44")},{value:z,onChange:e=>p({linkBorderColor:e}),label:(0,a.__)("Border","search-appearance-toolkit-seo-44")}]}),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Link Border Radius","search-appearance-toolkit-seo-44"),value:T,onChange:e=>p({linkBorderRadius:e}),min:0,max:50,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}))),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Content Settings","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Display Block Title","search-appearance-toolkit-seo-44"),checked:d,onChange:()=>p({showHeading:!d}),__nextHasNoMarginBottom:!0}),d&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)(l.TextControl,{label:(0,a.__)("Title Text","search-appearance-toolkit-seo-44"),value:m,onChange:e=>p({headingText:e}),help:(0,a.__)("The text that appears above your list of links.","search-appearance-toolkit-seo-44")}),(0,t.createElement)(l.SelectControl,{label:(0,a.__)("Title Tag","search-appearance-toolkit-seo-44"),value:g,options:[{label:"H2",value:"h2"},{label:"H3",value:"h3"},{label:"H4",value:"h4"},{label:"H5",value:"h5"},{label:"Paragraph (Bold)",value:"p"},{label:"Div (No Semantic Value)",value:"div"}],onChange:e=>p({headingTag:e}),help:(0,a.__)("Choose a level that fits your page's structure.","search-appearance-toolkit-seo-44"),__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0})),(0,t.createElement)("hr",null),(0,t.createElement)("p",null,(0,t.createElement)("strong",null,(0,a.__)("Included Headings","search-appearance-toolkit-seo-44"))),(0,t.createElement)("p",{className:"description",style:{marginBottom:"10px"}},(0,a.__)("Select which heading levels from your post content should appear in the jump links list.","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.CheckboxControl,{label:"H2",checked:k.includes("h2"),onChange:()=>J("h2")}),(0,t.createElement)(l.CheckboxControl,{label:"H3",checked:k.includes("h3"),onChange:()=>J("h3")}),(0,t.createElement)(l.CheckboxControl,{label:"H4",checked:k.includes("h4"),onChange:()=>J("h4")}),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Create Visual Hierarchy","search-appearance-toolkit-seo-44"),help:(0,a.__)("Indents sub-headings (H3, H4) to create a nested outline structure.","search-appearance-toolkit-seo-44"),checked:x,onChange:()=>p({isSmartIndentation:!x}),__nextHasNoMarginBottom:!0})),(0,t.createElement)(l.PanelBody,{title:(0,a.__)("Position Settings","search-appearance-toolkit-seo-44")},(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Sticky Position","search-appearance-toolkit-seo-44"),help:(0,a.__)("Keep the table of contents visible while scrolling.","search-appearance-toolkit-seo-44"),checked:N,onChange:()=>p({isSticky:!N}),__nextHasNoMarginBottom:!0}),N&&(0,t.createElement)(r.Fragment,null,(0,t.createElement)("p",{className:"description",style:{marginBottom:"15px"}},(0,a.__)("Customize how the block behaves when it sticks to the top of the screen.","search-appearance-toolkit-seo-44")),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Top Offset (px)","search-appearance-toolkit-seo-44"),help:(0,a.__)("The distance between the top of the screen and the block when stuck (useful for clearing sticky headers).","search-appearance-toolkit-seo-44"),value:H,onChange:e=>p({stickyOffset:e}),min:0,max:200,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.RangeControl,{label:(0,a.__)("Jump Offset (px)","search-appearance-toolkit-seo-44"),help:(0,a.__)("The buffer distance to stop *before* the heading. Increase this if your sticky header covers the text.","search-appearance-toolkit-seo-44"),value:M,onChange:e=>p({jumpOffset:e}),min:0,max:200,__nextHasNoMarginBottom:!0,__next40pxDefaultSize:!0}),(0,t.createElement)(l.ToggleControl,{label:(0,a.__)("Disable on Mobile","search-appearance-toolkit-seo-44"),help:(0,a.__)("Prevents the block from sticking on small screens to save reading space.","search-appearance-toolkit-seo-44"),checked:"desktop-only"===P,onChange:e=>p({stickyStrategy:e?"desktop-only":"always"}),__nextHasNoMarginBottom:!0})))),(0,t.createElement)("div",{...$},d&&(0,t.createElement)(o.RichText,{tagName:g,className:"wp-block-seo44-jump-links-heading",value:m,onChange:e=>p({headingText:e}),placeholder:(0,a.__)("On This Page","search-appearance-toolkit-seo-44")}),u.length>0?(0,t.createElement)("nav",{"aria-label":(0,a.__)("Table of contents","search-appearance-toolkit-seo-44")},(0,t.createElement)(I,{id:j},u.map((e,o)=>v?(0,t.createElement)("li",{key:e.anchor},(0,t.createElement)(l.TextControl,{value:e.linkText,onChange:e=>((e,t)=>{const a=[...u];a[e].linkText=t,p({headings:a})})(o,e)}),(0,t.createElement)("div",{className:"edit-controls-wrapper"},(0,t.createElement)("div",{className:"reorder-buttons"},(0,t.createElement)(l.Button,{icon:i,label:(0,a.__)("Move Up","search-appearance-toolkit-seo-44"),onClick:()=>R(o,"up"),disabled:0===o}),(0,t.createElement)(l.Button,{icon:c,label:(0,a.__)("Move Down","search-appearance-toolkit-seo-44"),onClick:()=>R(o,"down"),disabled:o===u.length-1})),(0,t.createElement)(l.ToggleControl,{label:!1!==e.isVisible?(0,a.__)("Included","search-appearance-toolkit-seo-44"):(0,a.__)("This Jump Link will not be shown","search-appearance-toolkit-seo-44"),checked:!1!==e.isVisible,onChange:()=>(e=>{const t=[...u];t[e].isVisible=!t[e].isVisible,p({headings:t})})(o),__nextHasNoMarginBottom:!0}))):!1!==e.isVisible&&(0,t.createElement)("li",{key:e.anchor,className:x?`seo44-jump-link-level-${e.level}`:""},(0,t.createElement)("a",{href:`#${e.anchor}`,onClick:e=>e.preventDefault()},e.linkText)))),!v&&f&&u.length>0&&(0,t.createElement)(l.Tooltip,{text:(0,a.__)("This button is functional on the front-end to expand the list.","search-appearance-toolkit-seo-44")},(0,t.createElement)("button",{type:"button",className:"seo-44-show-more","aria-label":(0,a.__)("Show More","search-appearance-toolkit-seo-44"),"aria-expanded":"false","aria-controls":j,onClick:()=>{L((0,a.__)('The "Show More" button is interactive on the published page.',"search-appearance-toolkit-seo-44"),{type:"snackbar"})}},h))):(0,t.createElement)("p",null,(0,a.__)("No headings found. Select a heading level in the block settings to generate links.","search-appearance-toolkit-seo-44"))))},save:function({attributes:e}){const{blockInstanceId:l,layout:n,isCollapsible:r,isSmartIndentation:s,headings:i,showHeading:c,headingText:h,headingTag:p,listStyle:k,fontSize:u,textColor:d,linkColor:m,blockBackgroundColor:g,linkBackgroundColor:_,linkBackgroundColorHover:b,linkBorderColor:v,linkBorderRadius:f,isSticky:x,stickyOffset:w,jumpOffset:C,stickyStrategy:E}=e,y={color:d,fontSize:u,"--jump-link-font-size":u||"18px","--seo44-block-bg":g,"--seo44-link-color":m,"--seo44-link-bg":"horizontal"===n?_:void 0,"--seo44-link-hover-bg":"horizontal"===n?b:void 0,"--seo44-link-border-color":"horizontal"===n?v:void 0,"--seo44-link-radius":"horizontal"===n&&f?`${f}px`:void 0,"--seo44-sticky-offset":x?`${w}px`:void 0},B="ol"===k?"ol":"ul",S=`seo44-jump-links-list-${l}`,z=o.useBlockProps.save({className:`${"horizontal"===n?"is-layout-horizontal":""} ${r?"is-collapsible":""} ${"none"===k?"list-style-none":""} ${x?"is-sticky":""} ${"desktop-only"===E?"sticky-desktop-only":""}`.trim(),style:y,"data-seo44-jump-offset":x?C:30}),T=(0,t.createElement)("svg",{className:"arrow-down",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 16l-6-6 1.41-1.41L12 13.17l4.59-4.58L18 10l-6 6z"})),N=(0,t.createElement)("svg",{className:"arrow-up",width:"24",height:"24",viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",focusable:"false"},(0,t.createElement)("path",{d:"M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14l-6-6z"}));return(0,t.createElement)("div",{...z},(0,t.createElement)("div",{className:"seo44-sticky-sentinel","aria-hidden":"true"}),c&&(0,t.createElement)(o.RichText.Content,{tagName:p||"h2",className:"wp-block-seo44-jump-links-heading",value:h}),i&&i.length>0&&(0,t.createElement)("nav",{"aria-label":(0,a.__)("Table of contents","search-appearance-toolkit-seo-44")},(0,t.createElement)(B,{id:S},i.filter(e=>!1!==e.isVisible).map(e=>(0,t.createElement)("li",{key:e.anchor,className:s?`seo44-jump-link-level-${e.level}`:""},(0,t.createElement)("a",{href:`#${e.anchor}`},e.linkText)))),r&&(0,t.createElement)("button",{type:"button",className:"seo-44-show-more","aria-label":(0,a.__)("Show More","search-appearance-toolkit-seo-44"),"aria-expanded":"false","aria-controls":S},T,N)))}})}},a={};function o(e){var l=a[e];if(void 0!==l)return l.exports;var n=a[e]={exports:{}};return t[e](n,n.exports,o),n.exports}o.m=t,e=[],o.O=(t,a,l,n)=>{if(!a){var r=1/0;for(h=0;h<e.length;h++){for(var[a,l,n]=e[h],s=!0,i=0;i<a.length;i++)(!1&n||r>=n)&&Object.keys(o.O).every(e=>o.O[e](a[i]))?a.splice(i--,1):(s=!1,n<r&&(r=n));if(s){e.splice(h--,1);var c=l();void 0!==c&&(t=c)}}return t}n=n||0;for(var h=e.length;h>0&&e[h-1][2]>n;h--)e[h]=e[h-1];e[h]=[a,l,n]},o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={57:0,350:0};o.O.j=t=>0===e[t];var t=(t,a)=>{var l,n,[r,s,i]=a,c=0;if(r.some(t=>0!==e[t])){for(l in s)o.o(s,l)&&(o.m[l]=s[l]);if(i)var h=i(o)}for(t&&t(a);c<r.length;c++)n=r[c],o.o(e,n)&&e[n]&&e[n][0](),e[n]=0;return o.O(h)},a=globalThis.webpackChunkseo_44_jump_links_block=globalThis.webpackChunkseo_44_jump_links_block||[];a.forEach(t.bind(null,0)),a.push=t.bind(null,a.push.bind(a))})();var l=o.O(void 0,[350],()=>o(313));l=o.O(l)})();
  • search-appearance-toolkit-seo-44/trunk/changelog.txt

    r3405454 r3423355  
    11== Changelog ==
     2
     3= 4.3.0 =
     4* FEATURE: **Table of Contents Schema:** Automatically generates `hasPart` schema synchronized with the Jump Links Block to support "Jump-to" links in search results.
     5* FEATURE: **Advanced HowTo Schema:** A new "Block-Aware" scanner uses the Jump Links Block to strictly define steps while intelligently mining the introduction for tools, time, yield, and video data.
     6* TWEAK: Added a "Generate HowTo Schema" checkbox to the SEO 44 metabox, giving users explicit control over when the advanced scanner runs.
     7* FEATURE: **YouTube Integration:** Added support for the YouTube Data API v3 to fetch accurate video upload dates for VideoObject schema.
     8* TESTED: Tested to WordPress Version 6.9
    29
    310= 4.2.0 =
  • search-appearance-toolkit-seo-44/trunk/includes/class-seo44-core.php

    r3403604 r3423355  
    11<?php
     2//4.3 - Added dependencies for block-editor-script.js to access Jump Links Block anchor ids for hasPart and HowTo schema.
    23class SEO44_Core {
    34
     
    6263    public function admin_enqueue_assets($hook) {
    6364        if ('post.php' == $hook || 'post-new.php' == $hook) {
     65            // CSS
    6466            wp_enqueue_style('seo44-admin-styles', plugins_url('../css/admin-styles.css', __FILE__), [], SEO44_VERSION);
    65             wp_enqueue_script('seo44-admin-script', plugins_url('../js/admin-script.js', __FILE__), ['jquery'], SEO44_VERSION, true);
     67            // General Admin Script (Classic + Block)
     68            wp_enqueue_script('seo44-admin-script', plugins_url('../js/admin-script.js', __FILE__),
     69                ['jquery'],
     70                SEO44_VERSION,
     71                true);
     72
     73            // Localize the General Script (needed for Snippet Preview)
    6674            wp_localize_script('seo44-admin-script', 'seo44_data', [
    6775                'post_title' => get_the_title(get_the_ID()),
     
    6977                'permalink' => get_permalink(get_the_ID())
    7078            ]);
     79            // Block Editor Specific Script (Passthrough Logic For Jump Links)
     80            // We enqueue this separately with the Block Editor dependencies.
     81            // WordPress will simply NOT load this file if these handles don't exist (e.g. Classic Editor).
     82            wp_enqueue_script('seo44-block-editor-script', plugins_url('../js/block-editor-script.js', __FILE__),
     83                ['wp-data', 'wp-editor', 'wp-blocks'], // Dependencies are important for Jump Links Block data
     84                SEO44_VERSION,
     85                true
     86            );
    7187        }
    7288       if ('settings_page_search-appearance-toolkit-seo-44' == $hook) {
  • search-appearance-toolkit-seo-44/trunk/includes/class-seo44-frontend.php

    r3405454 r3423355  
    11<?php
     2// Version 4.3
     3// Create hasPart and HowTo schema from Jump Links Block using metafield passthrough
     4// Added YouTube Data API support for more accurate video upload date
     5
    26class SEO44_Frontend {
    37    public function __construct() {
     
    59        add_action('wp_head', [$this, 'output_header_tags']);
    610        add_action('wp_head', [$this, 'output_schema_json_ld'], 99);
     11        add_action('init', [$this, 'register_schema_meta_fields']); // Retrieve Jump Links Block data for schema via a hidden metabox field
    712
    813        $taxonomies = get_taxonomies(['public' => true]);
     
    1217        }
    1318    }
    14 
     19    // Get meta field from metabox for Jump Links Block + HowTo Schema
     20    public function register_schema_meta_fields() {
     21        register_post_meta('post', '_seo44_howto_step_ids', [
     22            'single'       => true,
     23            'type'         => 'array',
     24            'show_in_rest' => [
     25                'schema' => [
     26                    'type'  => 'array',
     27                    'items' => [
     28                        'type' => 'string',
     29                    ],
     30                ],
     31            ],
     32            'auth_callback' => function() { return current_user_can('edit_posts'); }
     33        ]);
     34    }
     35
     36    //Title
    1537    public function filter_document_title($title_parts) {
    16         if (!seo44_get_option('enable_tags')) return $title_parts;
    17        
     38        if (!seo44_get_option('enable_tags')) return $title_parts;
     39       
    1840        $custom_title = '';
    1941        $fallback_title = '';
     
    2345            $custom_title = seo44_get_option('homepage_title');
    2446        } elseif (is_singular()) {
    25             $custom_title = get_post_meta(get_the_ID(), seo44_get_option('title_key'), true);
     47            $custom_title = get_post_meta(get_the_ID(), seo44_get_option('title_key'), true);
    2648            $fallback_title = get_the_title(get_the_ID());
    27         } elseif (is_category() || is_tag() || is_tax()) {
     49        } elseif (is_category() || is_tag() || is_tax()) {
    2850            $term_id = get_queried_object_id();
    2951            $custom_title = get_term_meta($term_id, 'seo44_title', true);
     
    5274            unset($title_parts['site'], $title_parts['tagline']);
    5375        }
    54         return $title_parts;
    55     }
    56    
    57     public function add_term_meta_fields($term, $taxonomy) {
     76        return $title_parts;
     77    }
     78   
     79    // Meta Tags
     80    public function add_term_meta_fields($term, $taxonomy) {
    5881        $title = get_term_meta($term->term_id, 'seo44_title', true);
    5982        $description = get_term_meta($term->term_id, 'seo44_description', true);
     
    95118    }
    96119   
    97     public function output_header_tags() {
    98         if (!seo44_get_option('enable_tags')) { return; }
    99         $description = '';
    100         $social_title = '';
    101         $current_url = '';
    102    
    103         if (is_front_page()) {
    104             $description = seo44_get_option('homepage_description');
    105             $social_title = seo44_get_option('homepage_title') ?: get_bloginfo('name');
    106             $current_url = home_url('/');
    107         } elseif (is_singular()) {
    108             $post_id = get_the_ID();
    109             $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
    110             $custom_title = get_post_meta($post_id, seo44_get_option('title_key'), true);
    111             $social_title = $custom_title ?: get_the_title($post_id);
    112             $current_url = get_permalink($post_id);
     120    public function output_header_tags() {
     121        if (!seo44_get_option('enable_tags')) { return; }
     122        $description = '';
     123        $social_title = '';
     124        $current_url = '';
     125   
     126        if (is_front_page()) {
     127            $description = seo44_get_option('homepage_description');
     128            $social_title = seo44_get_option('homepage_title') ?: get_bloginfo('name');
     129            $current_url = home_url('/');
     130        } elseif (is_singular()) {
     131            $post_id = get_the_ID();
     132            $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
     133            $custom_title = get_post_meta($post_id, seo44_get_option('title_key'), true);
     134            $social_title = $custom_title ?: get_the_title($post_id);
     135            $current_url = get_permalink($post_id);
    113136            if (empty($description)) {
    114                 $post_content = get_the_content(null, false, get_the_ID());
     137                $post_content = get_the_content(null, false, get_the_ID());
    115138                $description = wp_trim_words(strip_shortcodes(wp_strip_all_tags($post_content)), 25, '...');
    116             }
    117            
    118         } elseif (is_category() || is_tag() || is_tax()) {
    119             $term = get_queried_object();
    120             $description = get_term_meta($term->term_id, 'seo44_description', true);
    121             $custom_title = get_term_meta($term->term_id, 'seo44_title', true);
    122             $social_title = $custom_title ?: single_term_title('', false) . ' - ' . get_bloginfo('name');
    123             $current_url = get_term_link($term);
     139            }
     140           
     141        } elseif (is_category() || is_tag() || is_tax()) {
     142            $term = get_queried_object();
     143            $description = get_term_meta($term->term_id, 'seo44_description', true);
     144            $custom_title = get_term_meta($term->term_id, 'seo44_title', true);
     145            $social_title = $custom_title ?: single_term_title('', false) . ' - ' . get_bloginfo('name');
     146            $current_url = get_term_link($term);
    124147            if (empty($description)) {
    125148                $term_description = trim(wp_strip_all_tags($term->description));
     
    130153                }
    131154            }
    132         }
    133    
    134         if (!empty($description)) {
    135             printf('<meta name="description" content="%s">' . "\n", esc_attr(wp_strip_all_tags(html_entity_decode($description, ENT_QUOTES, 'UTF-8'))));
    136         }
    137    
    138         if (is_singular()) {
    139             $post_id = get_the_ID();
    140             $keywords = get_post_meta($post_id, seo44_get_option('keywords_key'), true);
    141             if (seo44_get_option('include_keywords') && !empty($keywords)) {
    142                 printf('<meta name="keywords" content="%s">' . "\n", esc_attr(wp_strip_all_tags(html_entity_decode($keywords, ENT_QUOTES, 'UTF-8'))));
    143             }
    144             if (is_singular() && seo44_get_option('include_author')) {
    145                 $author_id = get_post_field('post_author', $post_id);
    146                 if ($author_id) {
    147                     printf('<meta name="author" content="%s">' . "\n", esc_attr($this->get_author_name($author_id)));
    148                 }
    149             }
    150         }
    151    
    152         if (seo44_get_option('enable_og_tags') || seo44_get_option('enable_twitter_tags')) {
    153             $clean_social_title = esc_attr(wp_strip_all_tags(html_entity_decode($social_title, ENT_QUOTES, 'UTF-8')));
    154             $clean_description = !empty($description) ? esc_attr(wp_strip_all_tags(html_entity_decode($description, ENT_QUOTES, 'UTF-8'))) : '';
    155            
    156             if (empty($clean_description) && is_singular()) {
    157                  $clean_description = esc_attr(wp_strip_all_tags(get_the_excerpt(get_the_ID())));
    158             }
    159    
    160             $image_url = ''; $image_width = ''; $image_height = '';
    161             $image_id = 0;
    162             if (is_singular() && has_post_thumbnail()) {
    163                 $image_id = get_post_thumbnail_id();
    164             }
    165             if (!$image_id) {
    166                 $image_id = seo44_get_option('default_social_image_id', 0);
    167             }
    168             if ($image_id) {
    169                 $image_data = wp_get_attachment_image_src($image_id, 'full');
    170                 if ($image_data) {
    171                     list($image_url, $image_width, $image_height) = $image_data;
    172                 }
    173             }
    174    
    175             if (seo44_get_option('enable_og_tags')) {
    176                 printf('<meta property="og:title" content="%s">' . "\n", esc_attr($clean_social_title));
    177                 printf('<meta property="og:url" content="%s">' . "\n", esc_url($current_url));
    178                 printf('<meta property="og:site_name" content="%s">' . "\n", esc_attr(get_bloginfo('name')));
    179                 if (!empty($clean_description)) { printf('<meta property="og:description" content="%s">' . "\n", esc_attr($clean_description)); }
    180                 if (is_singular('post')) {
    181                      echo '<meta property="og:type" content="article">' . "\n";
    182                 } else {
    183                      echo '<meta property="og:type" content="website">' . "\n";
    184                 }
    185                 if (!empty($image_url)) {
    186                     printf('<meta property="og:image" content="%s">' . "\n", esc_url($image_url));
    187                     if (!empty($image_width)) { printf('<meta property="og:image:width" content="%s">' . "\n", esc_attr($image_width)); }
    188                     if (!empty($image_height)) { printf('<meta property="og:image:height" content="%s">' . "\n", esc_attr($image_height)); }
    189                 }
    190                 $fb_app_id = seo44_get_option('fb_app_id');
    191                 if (!empty($fb_app_id)) { printf('<meta property="fb:app_id" content="%s">' . "\n", esc_attr($fb_app_id)); }
    192             }
    193    
    194             if (seo44_get_option('enable_twitter_tags')) {
    195                 echo '<meta name="twitter:card" content="summary_large_image">' . "\n";
    196                 $twitter_handle = seo44_get_option('twitter_handle');
    197                 if ( !empty($twitter_handle) && is_string($twitter_handle) ) {
    198                     $handle = esc_attr(str_replace('@', '', $twitter_handle));
    199                     printf('<meta name="twitter:site" content="@%s">' . "\n", esc_attr($handle));
    200                     printf('<meta name="twitter:creator" content="@%s">' . "\n", esc_attr($handle));
    201                 }
    202                 printf('<meta name="twitter:title" content="%s">' . "\n", esc_attr($clean_social_title));
    203                 if (!empty($clean_description)) { printf('<meta name="twitter:description" content="%s">' . "\n", esc_attr($clean_description)); }
    204                 if (!empty($image_url)) { printf('<meta name="twitter:image" content="%s">' . "\n", esc_url($image_url)); }
    205             }
    206         }
    207     }
    208 
     155        }
     156   
     157        if (!empty($description)) {
     158            printf('<meta name="description" content="%s">' . "\n", esc_attr(wp_strip_all_tags(html_entity_decode($description, ENT_QUOTES, 'UTF-8'))));
     159        }
     160   
     161        if (is_singular()) {
     162            $post_id = get_the_ID();
     163            $keywords = get_post_meta($post_id, seo44_get_option('keywords_key'), true);
     164            if (seo44_get_option('include_keywords') && !empty($keywords)) {
     165                printf('<meta name="keywords" content="%s">' . "\n", esc_attr(wp_strip_all_tags(html_entity_decode($keywords, ENT_QUOTES, 'UTF-8'))));
     166            }
     167            if (is_singular() && seo44_get_option('include_author')) {
     168                $author_id = get_post_field('post_author', $post_id);
     169                if ($author_id) {
     170                    printf('<meta name="author" content="%s">' . "\n", esc_attr($this->get_author_name($author_id)));
     171                }
     172            }
     173        }
     174   
     175        if (seo44_get_option('enable_og_tags') || seo44_get_option('enable_twitter_tags')) {
     176            $clean_social_title = esc_attr(wp_strip_all_tags(html_entity_decode($social_title, ENT_QUOTES, 'UTF-8')));
     177            $clean_description = !empty($description) ? esc_attr(wp_strip_all_tags(html_entity_decode($description, ENT_QUOTES, 'UTF-8'))) : '';
     178           
     179            if (empty($clean_description) && is_singular()) {
     180                 $clean_description = esc_attr(wp_strip_all_tags(get_the_excerpt(get_the_ID())));
     181            }
     182   
     183            $image_url = ''; $image_width = ''; $image_height = '';
     184            $image_id = 0;
     185            if (is_singular() && has_post_thumbnail()) {
     186                $image_id = get_post_thumbnail_id();
     187            }
     188            if (!$image_id) {
     189                $image_id = seo44_get_option('default_social_image_id', 0);
     190            }
     191            if ($image_id) {
     192                $image_data = wp_get_attachment_image_src($image_id, 'full');
     193                if ($image_data) {
     194                    list($image_url, $image_width, $image_height) = $image_data;
     195                }
     196            }
     197   
     198            if (seo44_get_option('enable_og_tags')) {
     199                printf('<meta property="og:title" content="%s">' . "\n", esc_attr($clean_social_title));
     200                printf('<meta property="og:url" content="%s">' . "\n", esc_url($current_url));
     201                printf('<meta property="og:site_name" content="%s">' . "\n", esc_attr(get_bloginfo('name')));
     202                if (!empty($clean_description)) { printf('<meta property="og:description" content="%s">' . "\n", esc_attr($clean_description)); }
     203                if (is_singular('post')) {
     204                     echo '<meta property="og:type" content="article">' . "\n";
     205                } else {
     206                     echo '<meta property="og:type" content="website">' . "\n";
     207                }
     208                if (!empty($image_url)) {
     209                    printf('<meta property="og:image" content="%s">' . "\n", esc_url($image_url));
     210                    if (!empty($image_width)) { printf('<meta property="og:image:width" content="%s">' . "\n", esc_attr($image_width)); }
     211                    if (!empty($image_height)) { printf('<meta property="og:image:height" content="%s">' . "\n", esc_attr($image_height)); }
     212                }
     213                $fb_app_id = seo44_get_option('fb_app_id');
     214                if (!empty($fb_app_id)) { printf('<meta property="fb:app_id" content="%s">' . "\n", esc_attr($fb_app_id)); }
     215            }
     216   
     217            if (seo44_get_option('enable_twitter_tags')) {
     218                echo '<meta name="twitter:card" content="summary_large_image">' . "\n";
     219                $twitter_handle = seo44_get_option('twitter_handle');
     220                if ( !empty($twitter_handle) && is_string($twitter_handle) ) {
     221                    $handle = esc_attr(str_replace('@', '', $twitter_handle));
     222                    printf('<meta name="twitter:site" content="@%s">' . "\n", esc_attr($handle));
     223                    printf('<meta name="twitter:creator" content="@%s">' . "\n", esc_attr($handle));
     224                }
     225                printf('<meta name="twitter:title" content="%s">' . "\n", esc_attr($clean_social_title));
     226                if (!empty($clean_description)) { printf('<meta name="twitter:description" content="%s">' . "\n", esc_attr($clean_description)); }
     227                if (!empty($image_url)) { printf('<meta name="twitter:image" content="%s">' . "\n", esc_url($image_url)); }
     228            }
     229        }
     230    }
     231
     232    // Now For The Schema Structured Data
    209233    public function output_schema_json_ld() {
    210234        // Note: The following line is intentionally not nonce-checked.
    211         // This is a safe, read-only check used by the admin-side scanner to get a clean view of the page
    212         // without our own schema being output. It does not process or save any data.
    213235        $scan_param = isset($_GET['seo44_scan']) ? sanitize_key(wp_unslash($_GET['seo44_scan'])) : '';
    214236        if ($scan_param === 'true') { return; }
     
    218240        $base_schema = [];
    219241        $special_schemas = [];
    220         $breadcrumb_schema = []; // New variable for breadcrumbs
     242        $breadcrumb_schema = []; // New variable for breadcrumbs
    221243        $post_id = get_the_ID();
    222244
     
    225247            $base_schema = $this->get_schema_for_website();
    226248
    227             // NEW: Add Organization Schema to homepage
    228             if (seo44_get_option('enable_organization_schema')) {
    229                 $org_schema = $this->get_schema_for_organization();
    230                 if (!empty($org_schema)) {
    231                     $special_schemas[] = $org_schema;
    232                 }
    233             }
    234            
     249            // Add Organization Schema to homepage
     250            if (seo44_get_option('enable_organization_schema')) {
     251                $org_schema = $this->get_schema_for_organization();
     252                if (!empty($org_schema)) {
     253                    $special_schemas[] = $org_schema;
     254                }
     255            }
     256           
    235257        } elseif ( (is_category() || is_tag() || is_tax()) && seo44_get_option('enable_schema_on_taxonomies') ) {
    236258            $base_schema = $this->get_schema_for_taxonomy();
     
    246268        }
    247269
    248         // 3. Attempt to generate special schema
    249         if (is_singular() && seo44_get_option('enable_advanced_schema')) {
    250             $detected_schemas = $this->detect_and_generate_special_schema($post_id);
    251             // Merge instead of overwrite - important for organization schema
    252             if (!empty($detected_schemas)) {
    253                 $special_schemas = array_merge($special_schemas, $detected_schemas);
    254             }
    255         }
    256        
    257        
    258         // 4.  Hook for Add-ons ---
    259         // This filter allows other plugins to add their own custom schema parts to the graph.
    260         $addon_schema_parts = apply_filters( 'seo44_add_schema_parts', [], $post_id );
    261         // --- End Hook ---
    262 
    263         // 5. Combine all schemas for output
    264         $final_schema_parts = [];
     270        // 3. Attempt to generate special schema (FAQ / HowTo)
     271        if (is_singular() && seo44_get_option('enable_advanced_schema')) {
     272            $detected_schemas = $this->detect_and_generate_special_schema($post_id);
     273            // Merge instead of overwrite (Prevents erasing Organization schema on static pages)
     274            if (!empty($detected_schemas)) {
     275                $special_schemas = array_merge($special_schemas, $detected_schemas);
     276            }
     277        }
     278       
     279        // 4.  Hook for Add-ons ---
     280        // This filter allows other plugins to add their own custom schema parts to the graph.
     281        $addon_schema_parts = apply_filters( 'seo44_add_schema_parts', [], $post_id );
     282        // --- End Hook ---
     283
     284        // 5. Combine all schemas for output
     285        $final_schema_parts = [];
    265286        if (!empty($base_schema)) {
    266287            $final_schema_parts[] = $base_schema;
     
    272293            $final_schema_parts = array_merge($final_schema_parts, $special_schemas);
    273294        }
    274        
    275         // include any add-on schemas that pass is_array sanity check
    276         if ( ! empty( $addon_schema_parts ) && is_array( $addon_schema_parts ) ) {
    277             $final_schema_parts = array_merge( $final_schema_parts, $addon_schema_parts );
    278         }
     295       
     296        // include any add-on schemas that pass is_array sanity check
     297        if ( ! empty( $addon_schema_parts ) && is_array( $addon_schema_parts ) ) {
     298            $final_schema_parts = array_merge( $final_schema_parts, $addon_schema_parts );
     299        }
    279300       
    280301        $final_schema = [];
     
    301322        }
    302323    }
    303    
    304     // --- Helper Functions ---
     324   
     325    // --- Helper Functions ---
    305326
    306327    // --- Generate BreadcrumbList Schema for Singular Content ---
     
    363384        ];
    364385    }
    365    
     386   
    366387    // --- Helper Function For Types ---
    367388    public static function get_schema_for_post($post_id) {
    368         global $post;
    369         $post = get_post($post_id);
    370         setup_postdata($post);
    371        
    372         // Get the author's ID and website URL to refer authorship
    373         $author_id = $post->post_author;
    374         $author_url = get_the_author_meta('user_url', $author_id);
    375    
    376         // Build the author schema array
    377         $author_schema = [
    378             '@type' => 'Person',
    379             'name'  => self::get_author_name_static( $author_id ), // Pass the author ID
    380         ];
    381    
    382         // If the author has a website, add the @id and url properties
    383         if ( ! empty( $author_url ) ) {
    384             // Ensure the URL has a trailing slash before adding the fragment.
    385             $canonical_author_url = trailingslashit( $author_url );
    386        
    387             $author_schema['@id'] = $canonical_author_url . '#person';
    388             $author_schema['url'] = $canonical_author_url;
    389         }
    390    
    391         $schema = [
    392             '@context'         => 'https://schema.org',
    393             '@type'            => 'Article',
    394             'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => get_permalink($post_id)],
    395             'headline'         => get_the_title($post_id),
    396             'datePublished'    => get_the_date('c', $post_id),
    397             'dateModified'     => get_the_modified_date('c', $post_id),
    398             'author'           => $author_schema, // Use the author schema array
    399             'publisher'        => ['@type' => 'Organization', 'name' => get_bloginfo('name')]
    400         ];
    401         if (get_site_icon_url()) { $schema['publisher']['logo'] = ['@type' => 'ImageObject', 'url' => get_site_icon_url()]; }
    402         if (has_post_thumbnail($post_id)) {
    403             $image_id = get_post_thumbnail_id($post_id);
    404             $image_data = wp_get_attachment_image_src($image_id, 'full');
    405             if ($image_data) {
    406                 $schema['image'] = [
    407                     '@type' => 'ImageObject',
    408                     'url' => $image_data[0],
    409                     'width' => $image_data[1],
    410                     'height' => $image_data[2]
    411                 ];
    412             }
    413         }
    414         // NEW: Parse content for additional media if the setting is enabled
     389        global $post;
     390        $post = get_post($post_id);
     391        setup_postdata($post);
     392       
     393        // Get the author's ID and website URL to refer authorship
     394        $author_id = $post->post_author;
     395        $author_url = get_the_author_meta('user_url', $author_id);
     396   
     397        // Build the author schema array
     398        $author_schema = [
     399            '@type' => 'Person',
     400            'name'  => self::get_author_name_static( $author_id ), // Pass the author ID
     401        ];
     402   
     403        // If the author has a website, add the @id and url properties
     404        if ( ! empty( $author_url ) ) {
     405            // Ensure the URL has a trailing slash before adding the fragment.
     406            $canonical_author_url = trailingslashit( $author_url );
     407       
     408            $author_schema['@id'] = $canonical_author_url . '#person';
     409            $author_schema['url'] = $canonical_author_url;
     410        }
     411   
     412        $schema = [
     413            '@context'         => 'https://schema.org',
     414            '@type'            => 'Article',
     415            'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => get_permalink($post_id)],
     416            'headline'         => get_the_title($post_id),
     417            'datePublished'    => get_the_date('c', $post_id),
     418            'dateModified'     => get_the_modified_date('c', $post_id),
     419            'author'           => $author_schema, // Use the author schema array
     420            'publisher'        => ['@type' => 'Organization', 'name' => get_bloginfo('name')]
     421        ];
     422        if (get_site_icon_url()) { $schema['publisher']['logo'] = ['@type' => 'ImageObject', 'url' => get_site_icon_url()]; }
     423       
     424        // FIX: Initialize image key safely here to prevent "Undefined array key" warning
     425        $schema['image'] = []; // Default to empty array       
     426        if (has_post_thumbnail($post_id)) {
     427            $image_id = get_post_thumbnail_id($post_id);
     428            $image_data = wp_get_attachment_image_src($image_id, 'full');
     429            if ($image_data) {
     430                $schema['image'][] = [   
     431                    '@type' => 'ImageObject',
     432                    'url' => $image_data[0],
     433                    'width' => $image_data[1],
     434                    'height' => $image_data[2]
     435                ];
     436            }
     437        }
     438        // NEW: Parse content for additional media if the setting is enabled
    415439        if (seo44_get_option('scan_content_for_schema')) {
    416440            $media_schema = self::parse_content_for_media_schema($post_id);
    417             if (!empty($media_schema['images'])) {
    418                 // Check if a featured image (or other image) already exists
    419                 $existing_images = isset($schema['image']) ? (array)$schema['image'] : [];
    420                
    421                 // Merge existing images with content images
    422                 $schema['image'] = array_merge($existing_images, $media_schema['images']);
    423             }
     441            if (!empty($media_schema['images'])) {
     442                // 1. Re-index to prevent "0": {...} keys
     443                $schema['image'] = array_merge($schema['image'], $media_schema['images']);
     444            }
    424445            if (!empty($media_schema['videos'])) {
    425446                $schema['video'] = $media_schema['videos'];
    426447            }
    427448        }
    428        
    429         $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
    430         if (!empty($description)) { $schema['description'] = esc_html(wp_strip_all_tags($description)); }
    431         $excerpt = get_the_excerpt($post_id);
    432         if (!empty($excerpt)) { $schema['abstract'] = esc_html(wp_strip_all_tags($excerpt)); }
    433         $content = get_the_content(null, false, $post_id);
    434         $schema['wordCount'] = str_word_count(wp_strip_all_tags($content));
    435    
    436         $category = get_the_category($post_id);
    437         if (!empty($category) && $category[0]->name !== 'Uncategorized') {
    438             $schema['articleSection'] = esc_html($category[0]->name);
    439         }
    440    
    441         $tags = get_the_tags($post_id);
    442         if ($tags) {
    443             $keywords = [];
    444             foreach ($tags as $tag) { $keywords[] = $tag->name; }
    445             $schema['keywords'] = esc_html(implode(', ', $keywords));
    446         }
    447    
    448         wp_reset_postdata();
    449         return $schema;
     449
     450        // 2. Ensure clean array (re-index)
     451        if (!empty($schema['image'])) {
     452            $schema['image'] = array_values($schema['image']);
     453            // If only one image, Google prefers object over array, but array is valid.
     454            // To match your request, we keep it as array_values to remove numeric keys.
     455        } else {
     456            unset($schema['image']);
     457        }
     458       
     459        $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
     460        if (!empty($description)) { $schema['description'] = esc_html(wp_strip_all_tags($description)); }
     461        $excerpt = get_the_excerpt($post_id);
     462        if (!empty($excerpt)) { $schema['abstract'] = esc_html(wp_strip_all_tags($excerpt)); }
     463        $content = get_the_content(null, false, $post_id);
     464        $schema['wordCount'] = str_word_count(wp_strip_all_tags($content));
     465   
     466        $category = get_the_category($post_id);
     467        if (!empty($category) && $category[0]->name !== 'Uncategorized') {
     468            $schema['articleSection'] = esc_html($category[0]->name);
     469        }
     470   
     471        $tags = get_the_tags($post_id);
     472        if ($tags) {
     473            $keywords = [];
     474            foreach ($tags as $tag) { $keywords[] = $tag->name; }
     475            $schema['keywords'] = esc_html(implode(', ', $keywords));
     476        }
     477   
     478        // NEW: Dynamic Table of Contents (hasPart)
     479        // Only run this if we are scanning content, to save performance
     480        if (seo44_get_option('enable_jumplinks_schema')) {
     481            $toc_parts = self::generate_has_part_schema($content, $post->ID);
     482            if (!empty($toc_parts)) {
     483                $schema['hasPart'] = $toc_parts;
     484            }
     485        }
     486        wp_reset_postdata();
     487        return $schema;
    450488    }
    451489   
    452490    public function get_schema_for_page($post_id) {
    453         $schema = [
    454             '@context' => 'https://schema.org',
    455             '@type' => 'WebPage',
    456             'url' => get_permalink($post_id),
    457             'headline' => get_the_title($post_id),
    458             'datePublished' => get_the_date('c', $post_id),
    459             'dateModified' => get_the_modified_date('c', $post_id),
    460         ];
    461    
    462         // Add the meta description
    463         $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
    464         if (!empty($description)) {
    465             $schema['description'] = esc_html(wp_strip_all_tags($description));
    466         }
    467    
    468         // Add the featured image as a detailed ImageObject
    469         if (has_post_thumbnail($post_id)) {
    470             $image_id = get_post_thumbnail_id($post_id);
    471             $image_data = wp_get_attachment_image_src($image_id, 'full');
    472             if ($image_data) {
    473                 $schema['primaryImageOfPage'] = [
    474                     '@type' => 'ImageObject',
    475                     'url' => $image_data[0],
    476                     'width' => $image_data[1],
    477                     'height' => $image_data[2]
    478                 ];
    479             }
    480         }
    481     // Parse content for additional media if the setting is enabled
    482     // NEW CODE for get_schema_for_page
    483     if (seo44_get_option('scan_content_for_schema')) {
    484         $media_schema = self::parse_content_for_media_schema($post_id);
    485        
    486         if (!empty($media_schema['images'])) {
    487             // 1. Start with the content images found
    488             $all_images = $media_schema['images'];
    489    
    490             // 2. If a Featured Image (primaryImageOfPage) exists, add it to the start of the list
    491             if (isset($schema['primaryImageOfPage'])) {
    492                 array_unshift($all_images, $schema['primaryImageOfPage']);
    493             }
    494    
    495             // 3. Set the 'image' property to the complete list
    496             $schema['image'] = $all_images;
    497    
    498             // Optional: You can choose to keep or unset primaryImageOfPage.
    499             // Keeping it is usually better for SEO so Google knows which one is "Main".
    500             // This would be the code to cleanup the unset:
    501             // unset($schema['primaryImageOfPage']);
    502         }
    503        
    504         if (!empty($media_schema['videos'])) {
    505             $schema['video'] = $media_schema['videos'];
    506         }
    507     }
    508    
    509         return $schema;
     491        $schema = [
     492            '@context' => 'https://schema.org',
     493            '@type' => 'WebPage',
     494            'url' => get_permalink($post_id),
     495            'headline' => get_the_title($post_id),
     496            'datePublished' => get_the_date('c', $post_id),
     497            'dateModified' => get_the_modified_date('c', $post_id),
     498        ];
     499   
     500        // Add the meta description
     501        $description = get_post_meta($post_id, seo44_get_option('description_key'), true);
     502        if (!empty($description)) {
     503            $schema['description'] = esc_html(wp_strip_all_tags($description));
     504        }
     505   
     506        // Add the featured image as a detailed ImageObject
     507        $schema['image'] = [];
     508        if (has_post_thumbnail($post_id)) {
     509            $image_id = get_post_thumbnail_id($post_id);
     510            $image_data = wp_get_attachment_image_src($image_id, 'full');
     511            if ($image_data) {
     512                $schema['primaryImageOfPage'] = [
     513                    '@type' => 'ImageObject',
     514                    'url' => $image_data[0],
     515                    'width' => $image_data[1],
     516                    'height' => $image_data[2]
     517                ];
     518                // Initialize main image list with featured image
     519                $schema['image'][] = $schema['primaryImageOfPage'];
     520            }
     521        }
     522
     523        // Parse content for additional media if the setting is enabled
     524        // NEW CODE for get_schema_for_page
     525        if (seo44_get_option('scan_content_for_schema')) {
     526            $media_schema = self::parse_content_for_media_schema($post_id);
     527           
     528            if (!empty($media_schema['images'])) {
     529                // 1. Re-index to prevent "0": {...} keys
     530                $schema['image'] = array_merge($schema['image'], $media_schema['images']);
     531            }
     532           
     533            if (!empty($media_schema['videos'])) {
     534                $schema['video'] = $media_schema['videos'];
     535            }
     536        }
     537       
     538        // 2. Ensure clean array (re-index)
     539        if (!empty($schema['image'])) {
     540            $schema['image'] = array_values($schema['image']);
     541        } else {
     542            unset($schema['image']);
     543        }
     544
     545       // NEW: Dynamic Table of Contents (hasPart)
     546        // Only run this if we are scanning content, to save performance
     547        if (seo44_get_option('enable_jumplinks_schema')) {
     548            // We need to fetch content first since get_schema_for_page doesn't always have it ready
     549            $post_content = get_the_content(null, false, $post_id);
     550            $toc_parts = self::generate_has_part_schema($post_content, $post_id);
     551           
     552            if (!empty($toc_parts)) {
     553                $schema['hasPart'] = $toc_parts;
     554            }
     555        }
     556
     557        return $schema;
    510558    }
    511559    public function get_schema_for_website() {
    512         $schema = [
    513             '@context' => 'https://schema.org',
    514             '@type' => 'WebSite',
    515             'url' => home_url('/'),
    516             'name' => get_bloginfo('name'),
    517             'description' => get_bloginfo('description'),
    518             'potentialAction' => [
    519                 '@type' => 'SearchAction',
    520                 'target' => home_url('/?s={search_term_string}'),
    521                 'query-input' => 'required name=search_term_string',
    522             ],
    523         ];
    524         return $schema;
    525     }
    526 
    527     // --- Assemble Organization Schema ---
    528     public function get_schema_for_organization() {
     560        $schema = [
     561            '@context' => 'https://schema.org',
     562            '@type' => 'WebSite',
     563            'url' => home_url('/'),
     564            'name' => get_bloginfo('name'),
     565            'description' => get_bloginfo('description'),
     566            'potentialAction' => [
     567                '@type' => 'SearchAction',
     568                'target' => home_url('/?s={search_term_string}'),
     569                'query-input' => 'required name=search_term_string',
     570            ],
     571        ];
     572        return $schema;
     573    }
     574
     575    // --- Assemble Organization Schema ---
     576    public function get_schema_for_organization() {
    529577        // 1. Name & URL
    530578        $name = seo44_get_option('org_name') ?: get_bloginfo('name');
     
    553601        // For sameAs, a URL is desired. Add URLs from fields and construct Twitter / X and Facebook URL
    554602
    555         // Twitter/X: Handle logic
    556         $twitter_handle = seo44_get_option('twitter_handle');
    557         if ( !empty($twitter_handle) && is_string($twitter_handle) ) {
    558             // Clean handle just in case they added @
    559             $clean_handle = str_replace('@', '', $twitter_handle);
    560             $same_as[] = 'https://x.com/' . esc_attr($clean_handle);
    561         }
     603        // Twitter/X: Handle logic
     604        $twitter_handle = seo44_get_option('twitter_handle');
     605        if ( !empty($twitter_handle) && is_string($twitter_handle) ) {
     606            // Clean handle just in case they added @
     607            $clean_handle = str_replace('@', '', $twitter_handle);
     608            $same_as[] = 'https://x.com/' . esc_attr($clean_handle);
     609        }
    562610       
    563611        $extras = ['social_facebook', 'social_instagram', 'social_linkedin', 'social_youtube', 'social_tiktok'];
     
    566614            if ($val) $same_as[] = esc_url($val);
    567615        }
    568         // NEW: Process Additional URLs (One per line)
    569         $additional_urls = seo44_get_option('social_additional');
    570         if ( !empty($additional_urls) && is_string($additional_urls) ) {
    571             // Split by newline, trim whitespace, and filter empty lines
    572             $urls = array_filter(array_map('trim', explode("\n", $additional_urls)));
    573            
    574             foreach ($urls as $raw_url) {
    575                 // Validate it is a real URL before adding
    576                 $clean_url = esc_url_raw($raw_url);
    577                 if (!empty($clean_url)) {
    578                     $same_as[] = $clean_url;
    579                 }
    580             }
    581         }
     616        // NEW: Process Additional URLs (One per line)
     617        $additional_urls = seo44_get_option('social_additional');
     618        if ( !empty($additional_urls) && is_string($additional_urls) ) {
     619            // Split by newline, trim whitespace, and filter empty lines
     620            $urls = array_filter(array_map('trim', explode("\n", $additional_urls)));
     621           
     622            foreach ($urls as $raw_url) {
     623                // Validate it is a real URL before adding
     624                $clean_url = esc_url_raw($raw_url);
     625                if (!empty($clean_url)) {
     626                    $same_as[] = $clean_url;
     627                }
     628            }
     629        }
    582630
    583631        // 4. Build the Schema
     
    589637        ];
    590638
    591         // Add Alternate Name
    592         $alt_name = seo44_get_option('org_alternate_name');
    593         if ($alt_name) {
    594             $schema['alternateName'] = $alt_name;
    595         }
     639        // Add Alternate Name
     640        $alt_name = seo44_get_option('org_alternate_name');
     641        if ($alt_name) {
     642            $schema['alternateName'] = $alt_name;
     643        }
    596644
    597645        // Add Tagline (Slogan)
     
    613661
    614662        // Contact Point (Updated to include Email)
    615         $phone = seo44_get_option('org_phone');
    616         $email = seo44_get_option('org_email');
    617        
    618         if ($phone || $email) {
    619             $contact_point = ['@type' => 'ContactPoint'];
    620             if ($phone) {
    621                 $contact_point['telephone'] = $phone;
    622                 $contact_point['contactType'] = 'customer service';
    623             }
    624             if ($email) {
    625                 $contact_point['email'] = $email;
    626             }
    627             $schema['contactPoint'] = $contact_point;
    628         }
    629        
    630         // Email at the top level is also good practice
    631         if ($email) {
    632             $schema['email'] = $email;
    633         }
    634 
    635         // 5. Address
    636         $street = seo44_get_option('org_address_street');
    637         $city   = seo44_get_option('org_address_city');
    638         if ($street && $city) {
    639             $schema['address'] = [
    640                 '@type'           => 'PostalAddress',
    641                 'streetAddress'   => $street,
    642                 'addressLocality' => $city,
    643                 'addressRegion'   => seo44_get_option('org_address_state'),
    644                 'postalCode'      => seo44_get_option('org_address_zip'),
    645                 'addressCountry'  => seo44_get_option('org_address_country')
    646             ];
    647         }
    648 
    649         // Service Area (New)
    650         $area_served = seo44_get_option('org_area_served');
    651         if ($area_served) {
    652             $schema['areaServed'] = [
    653                 '@type' => 'Place',
    654                 'name'  => $area_served
    655             ];
    656         }
    657         // 6. Founder
    658         $founder = seo44_get_option('org_founder');
    659         if ($founder) {
    660             $schema['founder'] = [
    661                 '@type' => 'Person',
    662                 'name'  => $founder
    663             ];
    664         }
    665    
    666         // 7. Founding Date
    667         $founding_date = seo44_get_option('org_founding_date');
    668         if ( !empty($founding_date) && is_string($founding_date) ) {
    669             // Basic validation: ensure it looks somewhat like a year or date
    670             // You can leave it as raw string, Google parses ISO 8601 (YYYY-MM-DD) well.
    671             $schema['foundingDate'] = strip_tags($founding_date);
    672         }
    673         // 8. Professional License (New)
    674         $license = seo44_get_option('org_license');
    675         if ($license) {
    676             // "hasCredential" is the modern schema property for this
    677             $schema['hasCredential'] = [
    678                 '@type' => 'EducationalOccupationalCredential',
    679                 'credentialCategory' => 'license',
    680                 'name' => $license, // e.g., "Contractor License #123456"
    681                 'recognizedBy' => [
    682                     '@type' => 'Organization',
    683                     'name' => 'State Licensing Board' // Generic fallback since we don't ask for the issuer
    684                 ]
    685             ];
    686            
    687             // Also add it as a simple identifier for wider compatibility
    688             $schema['identifier'] = $license;
    689         }
    690         // FINAL STEP: Apply Filters for Extensibility using 'seo44_organization_schema'
     663        $phone = seo44_get_option('org_phone');
     664        $email = seo44_get_option('org_email');
     665       
     666        if ($phone || $email) {
     667            $contact_point = ['@type' => 'ContactPoint'];
     668            if ($phone) {
     669                $contact_point['telephone'] = $phone;
     670                $contact_point['contactType'] = 'customer service';
     671            }
     672            if ($email) {
     673                $contact_point['email'] = $email;
     674            }
     675            $schema['contactPoint'] = $contact_point;
     676        }
     677       
     678        // Email at the top level is also good practice
     679        if ($email) {
     680            $schema['email'] = $email;
     681        }
     682
     683        // 5. Address
     684        $street = seo44_get_option('org_address_street');
     685        $city   = seo44_get_option('org_address_city');
     686        if ($street && $city) {
     687            $schema['address'] = [
     688                '@type'           => 'PostalAddress',
     689                'streetAddress'   => $street,
     690                'addressLocality' => $city,
     691                'addressRegion'   => seo44_get_option('org_address_state'),
     692                'postalCode'      => seo44_get_option('org_address_zip'),
     693                'addressCountry'  => seo44_get_option('org_address_country')
     694            ];
     695        }
     696
     697        // Service Area (New)
     698        $area_served = seo44_get_option('org_area_served');
     699        if ($area_served) {
     700            $schema['areaServed'] = [
     701                '@type' => 'Place',
     702                'name'  => $area_served
     703            ];
     704        }
     705        // 6. Founder
     706        $founder = seo44_get_option('org_founder');
     707        if ($founder) {
     708            $schema['founder'] = [
     709                '@type' => 'Person',
     710                'name'  => $founder
     711            ];
     712        }
     713   
     714        // 7. Founding Date
     715        $founding_date = seo44_get_option('org_founding_date');
     716        if ( !empty($founding_date) && is_string($founding_date) ) {
     717            // Basic validation: ensure it looks somewhat like a year or date
     718            // You can leave it as raw string, Google parses ISO 8601 (YYYY-MM-DD) well.
     719            $schema['foundingDate'] = wp_strip_all_tags($founding_date);
     720        }
     721        // 8. Professional License (New)
     722        $license = seo44_get_option('org_license');
     723        if ($license) {
     724            // "hasCredential" is the modern schema property for this
     725            $schema['hasCredential'] = [
     726                '@type' => 'EducationalOccupationalCredential',
     727                'credentialCategory' => 'license',
     728                'name' => $license, // e.g., "Contractor License #123456"
     729                'recognizedBy' => [
     730                    '@type' => 'Organization',
     731                    'name' => 'State Licensing Board' // Generic fallback since we don't ask for the issuer
     732                ]
     733            ];
     734           
     735            // Also add it as a simple identifier for wider compatibility
     736            $schema['identifier'] = $license;
     737        }
     738        // FINAL STEP: Apply Filters for Extensibility using 'seo44_organization_schema'
    691739        // Allows developers to add properties like 'duns', 'naics', 'awards', etc.
    692         return apply_filters('seo44_organization_schema', $schema);
    693     }
    694 
    695     // --- Intelligent Schema Detection ---
     740        return apply_filters('seo44_organization_schema', $schema);
     741    }
     742
     743    // --- Intelligent Schema Detection ---
    696744   
    697745    /**
     
    703751        $post = get_post($post_id);
    704752        if (!$post) return [];
    705 
     753       
     754        // 1. Try Advanced HowTo (Jump Links Content Miner)
     755        // This runs FIRST to see if we have a robust HowTo available
     756        $advanced_howto = $this->generate_advanced_howto_schema($post);
     757       
     758        $final_schemas = [];
     759        if (!empty($advanced_howto)) {
     760            $final_schemas[] = $advanced_howto;
     761        }
     762
     763        // 2. Run standard detection for FAQ and HowTo (if not already found)
     764        // We still run this for FAQPage, but we might skip HowTo if advanced found one
    706765        if (has_blocks($post->post_content)) {
    707             // Use the precise block-based parser for modern content
    708             return $this->parse_blocks_for_special_schema($post_id, parse_blocks($post->post_content));
     766            $standard_schemas = $this->parse_blocks_for_special_schema($post_id, parse_blocks($post->post_content), !empty($advanced_howto)); // Pass "skip_howto" flag
    709767        } else {
    710768            // Use the HTML fallback for classic editor or page builder content
    711             // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
     769            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
    712770            $rendered_content = apply_filters('the_content', $post->post_content);
    713             return $this->parse_html_for_special_schema($post_id, $rendered_content);
    714         }
    715     }
    716    
    717    
     771            $standard_schemas = $this->parse_html_for_special_schema($post_id, $rendered_content, !empty($advanced_howto));
     772        }
     773       
     774        if (!empty($standard_schemas)) {
     775             $final_schemas = array_merge($final_schemas, $standard_schemas);
     776        }
     777       
     778        return $final_schemas;
     779    }
     780   
     781
    718782    /**
    719783     * NEW: Parses HTML content for FAQ and How-To patterns (the fallback method).
     784     * Advanced HowTo Scanner (The "Content Miner")
     785     * Uses Jump Links block as a map to mine content between headings.
    720786     */
    721     private function parse_html_for_special_schema($post_id, $html) {
     787    private function generate_advanced_howto_schema($post) {
     788        // 0. Check Cache
     789        $cache_key = 'seo44_howto_' . $post->ID . '_' . $post->post_modified;
     790        $cached_schema = get_transient($cache_key);
     791        if ($cached_schema !== false) {
     792            return $cached_schema;
     793        }
     794        // 1. Strict Check: Is the HowTo Checkbox enabled?
     795        $enable_howto_meta = get_post_meta($post->ID, '_seo44_enable_howto', true);
     796       
     797        if ($enable_howto_meta !== 'yes') {
     798            return null;
     799        }
     800
     801        // 2. Parse Blocks and Find Jump Links Block
     802        $blocks = parse_blocks($post->post_content);
     803        $flat_blocks = $this->flatten_blocks($blocks);
     804        $jump_links_block = $this->find_jump_links_block($flat_blocks);
     805       
     806        if (!$jump_links_block) return null;
     807       
     808        // 3. Setup Variables
     809        $desc_key = seo44_get_option('description_key') ?: 'seo44_description';
     810        $desc = get_post_meta($post->ID, $desc_key, true);
     811        if (empty($desc)) {
     812             $desc = get_post_meta($post->ID, '_yoast_wpseo_metadesc', true) ?: get_post_meta($post->ID, '_aioseop_description', true);
     813             if (empty($desc)) {
     814                 $desc = get_the_excerpt($post->ID);
     815                 if (empty($desc)) {
     816                     $desc = wp_trim_words(strip_shortcodes($post->post_content), 25);
     817                 }
     818             }
     819        }
     820
     821        $steps = [];
     822        $current_step = null;
     823        $tools = [];
     824        $supplies = [];
     825        $total_time = '';
     826        $prep_time = '';
     827        $perform_time = '';
     828        $found_first_step = false;
     829        $potential_list_type = '';
     830        $video_schema = [];
     831        $yield = '';
     832       
     833        // 4. Iterate Blocks
     834        foreach ($flat_blocks as $block) {
     835           
     836            // --- PHASE A: PRE-STEP SCANNING (Intro) ---
     837            if (!$found_first_step) {
     838               
     839                if ($block['blockName'] === 'core/paragraph') {
     840                    // FIX: Decode entities to ensure non-breaking spaces don't break the regex
     841                    $text = html_entity_decode(wp_strip_all_tags($block['innerHTML']));
     842                   
     843                    // FIX: Updated Total Time Regex to prevent matching "Prep Time" as "Time"
     844                    // Logic: Match "Total Time" OR "Time" that is NOT preceded by Prep/Active/Cook
     845                    if (empty($total_time)) {
     846                        $total_time = $this->parse_duration_string($text, '(?:Total\s+|(?<!Prep\s|ation\s|Active\s|Cook\s))Time:\s*');
     847                    }
     848                    if (empty($prep_time)) $prep_time = $this->parse_duration_string($text, 'Prep(?:aration)?\s*Time:\s*');
     849                    if (empty($perform_time)) $perform_time = $this->parse_duration_string($text, '(?:Perform|Active|Cook)\s*Time:\s*');
     850
     851                    if (empty($yield) && preg_match('/(?:Yields|Makes):\s*(.+)/i', $text, $matches)) {
     852                        $yield = trim($matches[1]);
     853                    }
     854
     855                    // FIX: Scan for Comma-Separated Tools/Supplies in Paragraphs
     856                    // e.g. "Tools: Hammer, Nails, Wood"
     857                    if (preg_match('/(?:Tools?|Equipment|Ingredients?|Supplies|Materials?):\s*(.+)/i', $text, $matches)) {
     858                        $type = (stripos($matches[0], 'tool') !== false || stripos($matches[0], 'equipment') !== false) ? 'tool' : 'supply';
     859                        $items = explode(',', $matches[1]);
     860                        foreach ($items as $item) {
     861                            $clean_item = trim($item);
     862                            $clean_item = rtrim($clean_item, '.'); // Remove trailing period
     863                            if (!empty($clean_item)) {
     864                                if ($type === 'tool') {
     865                                    $tools[] = ['@type' => 'HowToTool', 'name' => $clean_item];
     866                                } else {
     867                                    $supplies[] = ['@type' => 'HowToSupply', 'name' => $clean_item];
     868                                }
     869                            }
     870                        }
     871                    }
     872                }
     873
     874                // (Bullet list logic remains the same)
     875                if ($block['blockName'] === 'core/heading') {
     876                    $text = strtolower(wp_strip_all_tags($block['innerHTML']));
     877                    if (strpos($text, 'tool') !== false || strpos($text, 'equipment') !== false) {
     878                        $potential_list_type = 'tool';
     879                    } elseif (strpos($text, 'material') !== false || strpos($text, 'suppl') !== false || strpos($text, 'ingredient') !== false) {
     880                        $potential_list_type = 'supply';
     881                    } else {
     882                        $potential_list_type = '';
     883                    }
     884                }
     885                if ($block['blockName'] === 'core/list' && $potential_list_type) {
     886                     if (preg_match_all('/<li[^>]*>(.*?)<\/li>/s', $block['innerHTML'], $list_matches)) {
     887                         foreach ($list_matches[1] as $li_text) {
     888                             $clean_li = trim(wp_strip_all_tags($li_text));
     889                             if ($potential_list_type === 'tool') {
     890                                 $tools[] = ['@type' => 'HowToTool', 'name' => $clean_li];
     891                             } else {
     892                                 $supplies[] = ['@type' => 'HowToSupply', 'name' => $clean_li];
     893                             }
     894                         }
     895                     }
     896                     $potential_list_type = '';
     897                }
     898               
     899                // (Video logic remains the same)
     900                if ($block['blockName'] === 'core/embed' && isset($block['attrs']['providerNameSlug']) && $block['attrs']['providerNameSlug'] === 'youtube') {
     901                    $video_url = $block['attrs']['url'];
     902                    $video_id = self::extract_youtube_video_id($video_url);
     903                    $upload_date = self::get_youtube_upload_date($video_id, $post->ID);
     904                   
     905                    $oembed_url = 'https://www.youtube.com/oembed?format=json&url=' . urlencode($video_url);
     906                    $response = wp_remote_get($oembed_url);
     907                   
     908                    $vid_title = ''; $vid_thumb = ''; $vid_author = '';
     909                    if (!is_wp_error($response)) {
     910                        $data = json_decode(wp_remote_retrieve_body($response), true);
     911                        if ($data) {
     912                            $vid_title = $data['title'];
     913                            $vid_thumb = $data['thumbnail_url'];
     914                            $vid_author = $data['author_name'];
     915                        }
     916                    }
     917                    if (empty($vid_title)) $vid_title = get_the_title($post->ID) . ' Video Tutorial';
     918                    if (empty($vid_thumb)) $vid_thumb = 'https://i.ytimg.com/vi/' . $video_id . '/hqdefault.jpg';
     919
     920                    $video_schema = [
     921                        '@type' => 'VideoObject',
     922                        'name' => $vid_title,
     923                        'description' => get_the_title($post->ID) . ' - Video Guide',
     924                        'thumbnailUrl' => $vid_thumb,
     925                        'uploadDate' => $upload_date,
     926                        'embedUrl' => $video_url,
     927                        'contentUrl' => $video_url
     928                    ];
     929                    if ($vid_author) {
     930                        $video_schema['author'] = ['@type' => 'Person', 'name' => $vid_author];
     931                    }
     932                }
     933            }
     934
     935            // --- PHASE B: STEP MINING ---
     936            if ($block['blockName'] === 'core/heading') {
     937                if (preg_match('/id="([^"]+)"/', $block['innerHTML'], $id_match)) {
     938                    $anchor_id = $id_match[1];
     939                   
     940                    // --- EXCLUSION LOGIC START ---
     941                    $whitelist_ids = get_post_meta($post->ID, '_seo44_howto_step_ids', true);
     942                    $should_include = true;
     943
     944                    // STRATEGY A: Whitelist (New System)
     945                    if ( ! empty($whitelist_ids) && is_array($whitelist_ids) ) {
     946                        if ( ! in_array($anchor_id, $whitelist_ids) ) {
     947                            $should_include = false;
     948                        }
     949                        // FIX: Global Guard for Intro/Description
     950                        // Even if whitelisted, ignore Intro headings so they don't consume metadata
     951                        // May not need this...
     952                        if (stripos($anchor_id, 'intro') !== false || stripos($anchor_id, 'description') !== false) {
     953                            $should_include = false;
     954                        }
     955                    }
     956                    // STRATEGY B: Fallback
     957                    else {
     958                        if (stripos($anchor_id, 'intro') !== false) { $should_include = false; }
     959                        if (substr($anchor_id, -7) === '-nostep') { $should_include = false; }
     960                    }
     961
     962                    if ( ! $should_include ) {
     963                        if ($current_step) { $steps[] = $current_step; $current_step = null; }
     964                        continue;
     965                    }
     966                    // --- EXCLUSION LOGIC END ---
     967
     968                    $found_first_step = true;
     969                    if ($current_step) { $steps[] = $current_step; }
     970                   
     971                    $current_step = [
     972                        '@type' => 'HowToStep',
     973                        'url'   => get_permalink($post->ID) . '#' . $anchor_id,
     974                        'name'  => trim(wp_strip_all_tags($block['innerHTML'])),
     975                        'text'  => '',
     976                        'image' => []
     977                    ];
     978                    continue;
     979                }
     980            }
     981           
     982            if ($current_step) {
     983                if ($block['blockName'] === 'core/paragraph') {
     984                    $text = trim(wp_strip_all_tags($block['innerHTML']));
     985                    if (!empty($text)) { $current_step['text'] .= $text . ' '; }
     986                }
     987                if ($block['blockName'] === 'core/image') {
     988                     if (preg_match('/src="([^"]+)"/', $block['innerHTML'], $match)) {
     989                         if (!isset($current_step['image'])) { $current_step['image'] = []; }
     990                         $current_step['image'][] = $match[1];
     991                     }
     992                }
     993                if ($block['blockName'] === 'core/list') {
     994                    $current_step['text'] .= trim(wp_strip_all_tags($block['innerHTML'])) . ' ';
     995                }
     996            }
     997        }
     998       
     999        if ($current_step) { $steps[] = $current_step; }
     1000       
     1001        if (!empty($steps)) {
     1002            foreach ($steps as $k => $step) {
     1003                if (empty($step['image'])) {
     1004                    $steps[$k]['image'] = '';
     1005                } elseif (count($step['image']) === 1) {
     1006                    $steps[$k]['image'] = $step['image'][0];
     1007                }
     1008                $steps[$k]['text'] = trim($step['text']);
     1009            }
     1010
     1011            $howto = [
     1012                '@context' => 'https://schema.org',
     1013                '@type'    => 'HowTo',
     1014                'name'     => get_the_title($post->ID),
     1015                'description' => $desc,
     1016                'step'     => $steps
     1017            ];
     1018
     1019            if ($total_time) { $howto['totalTime'] = $total_time; }
     1020            if ($prep_time) { $howto['prepTime'] = $prep_time; }
     1021            if ($perform_time) { $howto['performTime'] = $perform_time; }
     1022            if (!empty($tools)) { $howto['tool'] = $tools; }
     1023            if (!empty($supplies)) { $howto['supply'] = $supplies; }
     1024            if (!empty($video_schema)) { $howto['video'] = $video_schema; }
     1025            if ($yield) { $howto['yield'] = $yield; }
     1026            $howto['disambiguatingDescription'] = $desc;
     1027           
     1028            if (has_post_thumbnail($post->ID)) {
     1029                 $howto['image'] = get_the_post_thumbnail_url($post->ID, 'full');
     1030            }
     1031            $categories = get_the_category($post->ID);
     1032            if (!empty($categories)) {
     1033                 $howto['about'] = ['@type' => 'Thing', 'name' => $categories[0]->name];
     1034            }
     1035            $tags = get_the_tags($post->ID);
     1036            if (!empty($tags)) {
     1037                 $howto['teaches'] = ['@type' => 'DefinedTerm', 'name' => $tags[0]->name];
     1038            }
     1039
     1040            set_transient($cache_key, $howto, DAY_IN_SECONDS);
     1041            return $howto;
     1042        }
     1043        return null;
     1044    }
     1045
     1046    /**
     1047     * Helper: Parse Duration Strings
     1048     * FIX: Re-wrote to accept raw patterns without delimiters to prevent syntax errors.
     1049     */
     1050    private function parse_duration_string($text, $prefix_pattern) {
     1051        // We construct the full regex here to ensure delimiters wrap the WHOLE pattern.
     1052        // Matches: Label -> (1) Hours -> (2) Minutes
     1053        $regex = '/' . $prefix_pattern . '(?:(\d+)\s*(?:hour|hr)s?\s*)?(?:and\s*)?(?:(\d+)\s*(?:minute|min)s?)?/i';
     1054       
     1055        if (preg_match($regex, $text, $matches)) {
     1056            $hours = !empty($matches[1]) ? intval($matches[1]) : 0;
     1057            $minutes = !empty($matches[2]) ? intval($matches[2]) : 0;
     1058           
     1059            if ($hours > 0 || $minutes > 0) {
     1060                 $duration = 'PT';
     1061                 if ($hours > 0) $duration .= $hours . 'H';
     1062                 if ($minutes > 0) $duration .= $minutes . 'M';
     1063                 return $duration;
     1064            }
     1065        }
     1066        return '';
     1067    }
     1068
     1069//    private function get_youtube_id($url) {
     1070//        preg_match('/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i', $url, $matches);
     1071//        return isset($matches[1]) ? $matches[1] : '';
     1072//    }   
     1073
     1074    /**
     1075     * Helper: Flattens nested block structures into a single linear array.
     1076     */
     1077    private function flatten_blocks($blocks) {
     1078        $flat = [];
     1079        foreach ($blocks as $block) {
     1080            $flat[] = $block;
     1081            if (!empty($block['innerBlocks'])) {
     1082                $flat = array_merge($flat, $this->flatten_blocks($block['innerBlocks']));
     1083            }
     1084        }
     1085        return $flat;
     1086    }
     1087
     1088    /**
     1089     * Helper to find the Jump Links block recursively
     1090     * Updated to be broader and find any block with 'jump-links' in the name
     1091     * FIX: Added check to ensure blockName is a string before strpos
     1092     */
     1093    private function find_jump_links_block($blocks) {
     1094        foreach ($blocks as $block) {
     1095            // Broad check for any jump links block
     1096            if (isset($block['blockName']) && is_string($block['blockName']) && strpos($block['blockName'], 'jump-links') !== false) {
     1097                return $block;
     1098            }
     1099            // Recursive check for inner blocks (e.g. inside Groups/Columns)
     1100            if (!empty($block['innerBlocks'])) {
     1101                $found = $this->find_jump_links_block($block['innerBlocks']);
     1102                if ($found) return $found;
     1103            }
     1104        }
     1105        return null;
     1106    }
     1107
     1108    private function parse_html_for_special_schema($post_id, $html, $skip_howto = false) {
    7221109        $final_schemas = [];
    7231110       
     
    7491136        }
    7501137       
    751         // HowTo Detection in HTML
    752         if (preg_match('/<h[2-4][^>]*>(installation|directions|instructions)<\/h[2-4]>/i', $html, $howto_heading_match)) {
     1138        // HowTo Detection (Skipped if Advanced scanner found one)
     1139        if (!$skip_howto && preg_match('/<h[2-4][^>]*>(installation|directions|instructions)<\/h[2-4]>/i', $html, $howto_heading_match)) {
    7531140            preg_match('/' . preg_quote($howto_heading_match[0], '/') . '.*?<ol.*?>(.*?)<\/ol>/is', $html, $ol_match);
    7541141            if(isset($ol_match[1])) {
     
    7771164     * The original block-based parser, now in its own function.
    7781165     */
    779     private function parse_blocks_for_special_schema($post_id, $blocks) {       
     1166    private function parse_blocks_for_special_schema($post_id, $blocks, $skip_howto = false) {       
    7801167        $faq_questions = [];
    7811168        $howto_steps = [];
     
    7861173        $final_schemas = [];
    7871174
    788         foreach ($blocks as $block) {
     1175        foreach ($blocks as $block) {
    7891176            $heading_text = strtolower(wp_strip_all_tags($block['innerHTML']));
    7901177           
     
    7961183                }
    7971184                $is_faq_section = false;
    798                 $is_howto_section = false; // Stop looking for HowTo steps as well
     1185                $is_howto_section = false;
    7991186                $current_answer_blocks = [];
    8001187                continue;
     
    8191206            }
    8201207
    821            // --- HowTo Detection ---
    822             if (!$is_faq_section && $block['blockName'] === 'core/heading' && preg_match('/(installation|directions|instructions)/i', $heading_text)) {
    823                 $is_howto_section = true;
    824                 // Use trim() to remove leading/trailing whitespace and newlines
    825                 $howto_heading_text = trim(wp_strip_all_tags($block['innerHTML']));
    826                 continue; // Find the heading, then look for the list in subsequent blocks
    827             }
    828             // If we are in a how-to section and find the first ordered list, process it.
    829             if ($is_howto_section && $block['blockName'] === 'core/list' && isset($block['attrs']['ordered']) && $block['attrs']['ordered']) {
    830                
    831                  if (!empty($block['innerBlocks'])) {
    832                     foreach($block['innerBlocks'] as $list_item_block) {
    833                         if ($list_item_block['blockName'] === 'core/list-item') {
    834                             $howto_steps[] = ['@type' => 'HowToStep', 'text' => esc_html(wp_strip_all_tags($list_item_block['innerHTML']))];
     1208            // HowTo Logic (Skipped if Advanced scanner found one)
     1209            if (!$skip_howto) {
     1210                if (!$is_faq_section && $block['blockName'] === 'core/heading' && preg_match('/(installation|directions|instructions)/i', $heading_text)) {
     1211                    $is_howto_section = true;
     1212                    // Use trim() to remove leading/trailing whitespace and newlines
     1213                    $howto_heading_text = trim(wp_strip_all_tags($block['innerHTML']));
     1214                    continue;  // Find the heading, then look for the list in subsequent blocks
     1215                }
     1216                // If we are in a how-to section and find the first ordered list, process it.
     1217                if ($is_howto_section && $block['blockName'] === 'core/list' && isset($block['attrs']['ordered']) && $block['attrs']['ordered']) {
     1218                     if (!empty($block['innerBlocks'])) {
     1219                        foreach($block['innerBlocks'] as $list_item_block) {
     1220                            if ($list_item_block['blockName'] === 'core/list-item') {
     1221                                $howto_steps[] = ['@type' => 'HowToStep', 'text' => esc_html(wp_strip_all_tags($list_item_block['innerHTML']))];
     1222                            }
    8351223                        }
    8361224                    }
    837                 }
    838                  $is_howto_section = false; // Stop after finding the first ordered list
     1225                     $is_howto_section = false; // Stop after finding the first ordered list
     1226                }
    8391227            }
    8401228        }
     
    8761264
    8771265    /**
    878      *  HELPER: Cleans and formats block content for an answer.
     1266     * HELPER: Cleans and formats block content for an answer.
    8791267     */
    8801268    private function clean_and_format_answer($blocks) {
     
    9091297        // Clean up remaining HTML, extra whitespace, and decode entities
    9101298        $clean_text = trim(wp_strip_all_tags(html_entity_decode($answer_html)));
    911         return preg_replace('/\n\s*\n/', "\n", $clean_text); // Collapse multiple newlines
    912     }
    913    
     1299        return preg_replace('/\n\s*\n/', "\n", $clean_text);
     1300    }
     1301   
    9141302    // --- MEDIA PARSING FUNCTION ---
    9151303
     
    9271315        $content = $post->post_content;
    9281316        $found_block_image_urls = [];
    929         $found_video_urls = []; // Fixed: Added missing variable declaration
     1317        $found_video_urls = [];
     1318
     1319        $default_description = get_the_title($post_id) . ' Video';
    9301320
    9311321        if (has_blocks($content)) {
    9321322            $blocks = parse_blocks($content);
    9331323            foreach ($blocks as $block) {
     1324
    9341325                // Handle Image Blocks
    9351326                if ($block['blockName'] === 'core/image' && !empty($block['attrs']['id'])) {
     
    9471338                        }
    9481339                        $images[] = $image_object;
    949                         $found_block_image_urls[] = $image_data[0]; // Keep track of URLs we've already added
     1340                        $found_block_image_urls[] = $image_data[0];
    9501341                    }
    9511342                }
    9521343
    953                 // Handle YouTube Embed Blocks using the oEmbed API
     1344                // Handle YouTube Embed Blocks using the oEmbed API + API/Scraping
    9541345                if ($block['blockName'] === 'core/embed' && isset($block['attrs']['providerNameSlug']) && $block['attrs']['providerNameSlug'] === 'youtube') {
    955                     $oembed_url = 'https://www.youtube.com/oembed?format=json&url=' . urlencode($block['attrs']['url']);
     1346                    $video_url = $block['attrs']['url'];
     1347                   
     1348                    // 1. Get Accurate Date
     1349                    $video_id = self::extract_youtube_video_id($video_url);
     1350                    $upload_date = self::get_youtube_upload_date($video_id, $post_id);
     1351
     1352                    // 2. Get Metadata
     1353                    $oembed_url = 'https://www.youtube.com/oembed?format=json&url=' . urlencode($video_url);
    9561354                    $response = wp_remote_get($oembed_url);
     1355                   
    9571356                    if (!is_wp_error($response)) {
    9581357                        $data = json_decode(wp_remote_retrieve_body($response), true);
     
    9621361                                'name' => $data['title'],
    9631362                                'thumbnailUrl' => $data['thumbnail_url'],
    964                                 'embedUrl' => $block['attrs']['url'],
    965                                 'author' => ['@type' => 'Person', 'name' => $data['author_name']]
     1363                                'embedUrl' => $video_url,
     1364                                'contentUrl' => $video_url,
     1365                                'author' => ['@type' => 'Person', 'name' => $data['author_name']],
     1366                                'uploadDate' => $upload_date, // Uses API/Scraped date
     1367                                'description' => $default_description
    9661368                            ];
    967                             $found_video_urls[] = $block['attrs']['url']; // Track processed videos
     1369                            $found_video_urls[] = $video_url;
    9681370                        }
    9691371                    }
     
    9721374        }
    9731375       
    974  // --- Universal Fallback for <img> and <iframe> tags in Classic Editor or HTML Blocks---
    975        
    976         // Find Images (logic is unchanged)
     1376        // --- Universal Fallback for <img> and <iframe> tags ---
     1377       
     1378        // Find Images
    9771379        if (preg_match_all('/<img[^>]+>/i', $content, $img_matches)) {
    9781380            foreach ($img_matches[0] as $img_tag) {
     
    9821384                if (preg_match('/src\s*=\s*[\'"]([^\'"]+)[\'"]/i', $img_tag, $src_matches)) {
    9831385                    $url = $src_matches[1];
    984                     // Add the image ONLY if we haven't already added it from a core/image block
    9851386                    if (!in_array($url, $found_block_image_urls)) {
    9861387                        $images[] = ['@type' => 'ImageObject', 'url' => $url];
    9871388                    }
    9881389                }
    989             }
    990         }
    991 
    992         // RESTORED: Find iframes
     1390            }
     1391        }
     1392
     1393        // Find iframes for YouTube
    9931394        if (preg_match_all('/<iframe[^>]+src="([^"]+)"[^>]*>/i', $content, $iframe_matches)) {
    9941395            foreach($iframe_matches[1] as $iframe_src) {
    995                 // Skip if we already processed this video from a block
    996                 if (in_array($iframe_src, $found_video_urls)) {
    997                     continue;
    998                 }
    999 
    1000                 if (strpos($iframe_src, 'youtube.com/embed') !== false) {
    1001                     preg_match('/embed\/([a-zA-Z0-9_-]+)/i', $iframe_src, $id_matches);
    1002                     $video_id = $id_matches[1] ?? null;
    1003                     if ($video_id) {
    1004                         $videos[] = [
    1005                             '@type' => 'VideoObject',
    1006                             'name' => 'Embedded YouTube Video', // Title isn't available from a raw iframe
    1007                             'thumbnailUrl' => 'https://i.ytimg.com/vi/' . $video_id . '/hqdefault.jpg',
    1008                             'embedUrl' => 'https://www.youtube.com/watch?v=' . $video_id,
    1009                         ];
    1010                     }
     1396                if (in_array($iframe_src, $found_video_urls)) { continue; }
     1397
     1398                // Use the static helper to check ID
     1399                $video_id = self::extract_youtube_video_id($iframe_src);
     1400               
     1401                if ($video_id) {
     1402                    $upload_date = self::get_youtube_upload_date($video_id, $post_id);
     1403                   
     1404                    $videos[] = [
     1405                        '@type' => 'VideoObject',
     1406                        'name' => 'Embedded YouTube Video',
     1407                        'description' => $default_description,
     1408                        'uploadDate' => $upload_date,
     1409                        'thumbnailUrl' => 'https://i.ytimg.com/vi/' . $video_id . '/hqdefault.jpg',
     1410                        'embedUrl' => 'https://www.youtube.com/watch?v=' . $video_id,
     1411                        'contentUrl' => 'https://www.youtube.com/watch?v=' . $video_id
     1412                    ];
    10111413                }
    10121414            }
     
    10151417        return ['images' => $images, 'videos' => $videos];
    10161418    }
    1017    
    1018     //  Function to generate BreadcrumbList schema for Taxonomy pages (not posts & Pages)
     1419   
     1420    //  Function to generate BreadcrumbList schema for Taxonomy pages (not posts & Pages)
    10191421    public function get_schema_for_taxonomy() {
    10201422        $term = get_queried_object();
     
    10631465        return $schema;
    10641466    }
    1065    
    1066     // Function to create author name
    1067    
    1068     public function get_author_name( $author_id ) {
    1069         $format = seo44_get_option('author_format', 'display_name');
    1070         switch ($format) {
    1071             case 'first_last': return get_the_author_meta('first_name', $author_id) . ' ' . get_the_author_meta('last_name', $author_id);
    1072             case 'last_first': return get_the_author_meta('last_name', $author_id) . ', ' . get_the_author_meta('first_name', $author_id);
    1073             default: return get_the_author_meta('display_name', $author_id);
    1074         }
    1075     }
    1076     public static function get_author_name_static( $author_id ) {
    1077         $frontend = new self();
    1078         return $frontend->get_author_name( $author_id );
    1079     }
     1467   
     1468    // Function to create author name
     1469    // With more graceful logic for missing name fields
     1470    public function get_author_name( $author_id ) {
     1471        $format = seo44_get_option('author_format', 'display_name');
     1472       
     1473        // 1. Get the raw values first
     1474        $first_name = get_the_author_meta( 'first_name', $author_id );
     1475        $last_name  = get_the_author_meta( 'last_name', $author_id );
     1476       
     1477        $formatted_name = '';
     1478
     1479        switch ( $format ) {
     1480            case 'first_last':
     1481                // 2. Concatenate and trim.
     1482                // If both are empty, trim() ensures we get "", not " ".
     1483                // If one is missing, it removes the leading/trailing space.
     1484                $formatted_name = trim( $first_name . ' ' . $last_name );
     1485                break;
     1486
     1487            case 'last_first':
     1488                // 3. Smart comma handling.
     1489                // Only add the comma if BOTH fields have data.
     1490                if ( ! empty( $last_name ) && ! empty( $first_name ) ) {
     1491                    $formatted_name = $last_name . ', ' . $first_name;
     1492                } else {
     1493                    // If one is missing, fall back to simple concatenation/trim without the comma
     1494                    $formatted_name = trim( $last_name . ' ' . $first_name );
     1495                }
     1496                break;
     1497               
     1498            // Default case is handled by the fallback check below
     1499        }
     1500
     1501        // 4. The Fallback Check
     1502        // If formatted_name is empty (because fields were empty) OR format was 'display_name'
     1503        if ( empty( $formatted_name ) ) {
     1504            return get_the_author_meta( 'display_name', $author_id );
     1505        }
     1506
     1507        return $formatted_name;
     1508    }
     1509
     1510    public static function get_author_name_static( $author_id ) {
     1511        $frontend = new self();
     1512        return $frontend->get_author_name( $author_id );
     1513    }
     1514   
     1515    /**
     1516     * Parses content for headings with IDs to create a "Table of Contents" schema.
     1517     * @param string $content The post content.
     1518     * @return array Array of WebPageElement objects.
     1519     */
     1520
     1521    // This could be updated to use IDS passed through to field from jump links block to be more efficient than scan and remove need for checkbox.
     1522    // UPDATED: Now respects whitelist to match "included" jump links
     1523    private static function generate_has_part_schema($content, $post_id) {
     1524        $has_part = [];
     1525       
     1526        // Retrieve the whitelist of IDs (generated by JS when saving the Jump Links block)
     1527        $whitelist_ids = get_post_meta($post_id, '_seo44_howto_step_ids', true);
     1528       
     1529        // Regex to find H2-H4 tags that have an ID attribute
     1530        if (preg_match_all('/<h[2-4][^>]*id="([^"]+)"[^>]*>(.*?)<\/h[2-4]>/i', $content, $matches, PREG_SET_ORDER)) {
     1531           
     1532            foreach ($matches as $match) {
     1533                $anchor_id = $match[1];
     1534                $heading_text = wp_strip_all_tags($match[2]);
     1535               
     1536                // --- FILTER LOGIC ---
     1537                // Default to include everything if no whitelist exists (e.g. old post, no jump links block)
     1538                // But if a whitelist exists (array), enforce it strictly.
     1539                $should_include = true;
     1540               
     1541                if ( ! empty($whitelist_ids) && is_array($whitelist_ids) ) {
     1542                    if ( ! in_array($anchor_id, $whitelist_ids) ) {
     1543                        $should_include = false;
     1544                    }
     1545                }
     1546               
     1547                if ($should_include && !empty($anchor_id) && !empty($heading_text)) {
     1548                    $has_part[] = [
     1549                        '@type' => 'WebPageElement',
     1550                        'name'  => $heading_text,
     1551                        'url'   => get_permalink($post_id) . '#' . $anchor_id,
     1552                        'cssSelector' => '#' . $anchor_id
     1553                    ];
     1554                }
     1555            }
     1556        }
     1557        return $has_part;
     1558    }
     1559
     1560
     1561    /**
     1562     * Extracts YouTube video ID from various URL formats
     1563     */
     1564    private static function extract_youtube_video_id($url) {
     1565        $patterns = [
     1566            '/youtube\.com\/watch\?v=([a-zA-Z0-9_-]+)/',            // Standard
     1567            '/youtube\.com\/embed\/([a-zA-Z0-9_-]+)/',              // Embed
     1568            '/youtu\.be\/([a-zA-Z0-9_-]+)/',                        // Short URL
     1569            '/youtube\.com\/v\/([a-zA-Z0-9_-]+)/',                  // Old format
     1570        ];
     1571       
     1572        foreach ($patterns as $pattern) {
     1573            if (preg_match($pattern, $url, $matches)) {
     1574                return $matches[1];
     1575            }
     1576        }
     1577       
     1578        return null;
     1579    }
     1580
     1581    /**
     1582     * Gets YouTube video upload date with multiple fallback strategies
     1583     * @param string $video_id YouTube video ID
     1584     * @param int $post_id WordPress post ID for fallback
     1585     * @return string ISO 8601 formatted date
     1586     */
     1587    private static function get_youtube_upload_date($video_id, $post_id) {
     1588        if (empty($video_id)) {
     1589            return get_the_date('c', $post_id);
     1590        }
     1591       
     1592        // Strategy 1: Check transient cache (24 hour cache to avoid API limits)
     1593        $cache_key = 'seo44_yt_date_' . $video_id;
     1594        $cached_date = get_transient($cache_key);
     1595       
     1596        if ($cached_date !== false) {
     1597            return $cached_date;
     1598        }
     1599       
     1600        // Strategy 2: Try YouTube Data API (if configured)
     1601        $api_key = seo44_get_option('youtube_api_key');
     1602       
     1603        if (!empty($api_key)) {
     1604            $api_date = self::fetch_youtube_date_from_api($video_id, $api_key);
     1605           
     1606            if ($api_date) {
     1607                // Cache for 24 hours
     1608                set_transient($cache_key, $api_date, DAY_IN_SECONDS);
     1609                return $api_date;
     1610            }
     1611        }
     1612       
     1613        // Strategy 3: Try scraping YouTube page (no API key needed, but less reliable)
     1614        $scraped_date = self::scrape_youtube_upload_date($video_id);
     1615       
     1616        if ($scraped_date) {
     1617            // Cache for 24 hours
     1618            set_transient($cache_key, $scraped_date, DAY_IN_SECONDS);
     1619            return $scraped_date;
     1620        }
     1621       
     1622        // Strategy 4: Fallback to post publication date
     1623        return get_the_date('c', $post_id);
     1624    }
     1625
     1626    /**
     1627     * Fetches upload date from YouTube Data API v3
     1628     */
     1629    private static function fetch_youtube_date_from_api($video_id, $api_key) {
     1630        $api_url = sprintf(
     1631            'https://www.googleapis.com/youtube/v3/videos?id=%s&key=%s&part=snippet',
     1632            urlencode($video_id),
     1633            urlencode($api_key)
     1634        );
     1635       
     1636        $response = wp_remote_get($api_url, [
     1637            'timeout' => 5,
     1638            'sslverify' => true
     1639        ]);
     1640       
     1641        if (is_wp_error($response)) {
     1642            return null;
     1643        }
     1644       
     1645        $body = wp_remote_retrieve_body($response);
     1646        $data = json_decode($body, true);
     1647       
     1648        if (isset($data['items'][0]['snippet']['publishedAt'])) {
     1649            return $data['items'][0]['snippet']['publishedAt'];
     1650        }
     1651       
     1652        return null;
     1653    }
     1654
     1655    /**
     1656     * Scrapes upload date from YouTube page HTML (fallback method, no API key needed)
     1657     * IMPPROVED: Scans Meta tags first, then JSON-LD, then internal player data.
     1658     */
     1659    private static function scrape_youtube_upload_date($video_id) {
     1660        $video_page_url = 'https://www.youtube.com/watch?v=' . $video_id;
     1661       
     1662        $response = wp_remote_get($video_page_url, [
     1663            'timeout' => 10,
     1664            // Use a generic browser agent to avoid being served a "bare" bot page
     1665            'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
     1666            'sslverify' => true
     1667        ]);
     1668       
     1669        if (is_wp_error($response)) {
     1670            return null;
     1671        }
     1672       
     1673        $html = wp_remote_retrieve_body($response);
     1674       
     1675        // Strategy A: Look for Meta Tags (Most Reliable)
     1676        // YouTube usually includes: <meta itemprop="datePublished" content="YYYY-MM-DD">
     1677        if (preg_match('/<meta\s+itemprop="datePublished"\s+content="([^"]+)"/i', $html, $matches)) {
     1678            return $matches[1];
     1679        }
     1680        if (preg_match('/<meta\s+itemprop="uploadDate"\s+content="([^"]+)"/i', $html, $matches)) {
     1681            return $matches[1];
     1682        }
     1683
     1684        // Strategy B: JSON-LD Schema (Your original method)
     1685        if (preg_match('/"uploadDate":"([^"]+)"/', $html, $matches)) {
     1686            return $matches[1];
     1687        }
     1688       
     1689        // Strategy C: Look for microformatDataRenderer (Internal YouTube Data)
     1690        // This is often found inside the large ytInitialPlayerResponse JSON object
     1691        if (preg_match('/"publishDate":"([^"]+)"/', $html, $matches)) {
     1692            $date_string = $matches[1]; // Usually YYYY-MM-DD
     1693            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_string)) {
     1694                return $date_string;
     1695            }
     1696            // Fallback for compact format YYYYMMDD
     1697            if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $date_string, $date_parts)) {
     1698                return sprintf('%s-%s-%s', $date_parts[1], $date_parts[2], $date_parts[3]);
     1699            }
     1700        }
     1701       
     1702        return null;
     1703    }
    10801704}
  • search-appearance-toolkit-seo-44/trunk/includes/class-seo44-metabox.php

    r3389330 r3423355  
    11<?php
     2// Version 4.3 - Added a Generate HowTo Schema checkbox that appears via activation word
    23class SEO44_Metabox {
    34
     
    2324        $description = get_post_meta($post->ID, seo44_get_option('description_key'), true);
    2425        $keywords = get_post_meta($post->ID, seo44_get_option('keywords_key'), true);
    25         $jump_link_headings = get_post_meta($post->ID, '_seo44_jump_link_headings', true);
    26         wp_nonce_field('seo44_jump_links_nonce', 'seo44_jump_links_nonce_field');
     26
     27        // NEW: Retrieve the HowTo toggle state
     28        // We use 'yes' for checked, empty for unchecked
     29        $enable_howto = get_post_meta($post->ID, '_seo44_enable_howto', true);
     30       
     31        // Logic to determine initial visibility: Show if already checked
     32        $howto_wrapper_style = ($enable_howto === 'yes') ? '' : 'display:none;';
     33
    2734    ?>
    28         <input type="hidden" id="seo44_jump_link_headings_field" name="seo44_jump_link_headings" value="<?php echo esc_attr($jump_link_headings); ?>">
     35       
    2936        <p>
    3037            <label for="seo44_title"><strong><?php esc_html_e('SEO Title', 'search-appearance-toolkit-seo-44'); ?></strong></label><br>
     
    5461        </p>
    5562        <?php endif; ?>
     63        <div id="seo44-howto-trigger-wrapper" style="<?php echo esc_attr($howto_wrapper_style); ?>">
     64            <p>
     65                <label for="seo44_enable_howto">
     66                    <input type="checkbox" id="seo44_enable_howto" name="seo44_enable_howto" value="yes" <?php checked($enable_howto, 'yes'); ?>>
     67                    <strong><?php esc_html_e('Generate HowTo Schema', 'search-appearance-toolkit-seo-44'); ?></strong>
     68                </label>
     69                <br>
     70                <span class="description">
     71                    <?php esc_html_e('It looks like you are publishing a "How-To" guide. When this box is checked, SEO 44 will generate HowTo structured data.', 'search-appearance-toolkit-seo-44'); ?>
     72                </span>
     73            </p>
     74        </div>
    5675        <hr>
    5776        <div id="seo44-snippet-preview">
     
    8099    public function save_meta_box_data($post_id) {
    81100        if (!isset($_POST['seo44_meta_box_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['seo44_meta_box_nonce'])), 'seo44_save_meta_box_data')) return;
    82         if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
    83         $post_type = get_post_type($post_id);
    84         // Robust capability checking
    85         if (!current_user_can("edit_{$post_type}s")) {
     101          if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
     102          $post_type = get_post_type($post_id);
     103          // Robust capability checking
     104          if (!current_user_can("edit_{$post_type}s")) {
    86105            return;
    87         }
    88         if (isset($_POST['seo44_title'])) {
     106          }
     107          if (isset($_POST['seo44_title'])) {
    89108            update_post_meta($post_id, seo44_get_option('title_key'), sanitize_text_field(wp_unslash($_POST['seo44_title'])));
    90109        }
     
    95114            update_post_meta($post_id, seo44_get_option('keywords_key'), sanitize_text_field(wp_unslash($_POST['seo44_keywords'])));
    96115        }
    97             // Save Logic for Hidden Box for Jump Links Block data
    98         if (isset($_POST['seo44_jump_links_nonce_field']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['seo44_jump_links_nonce_field'])), 'seo44_jump_links_nonce')) {
    99             if (isset($_POST['seo44_jump_link_headings'])) {
    100                 update_post_meta($post_id, '_seo44_jump_link_headings', sanitize_text_field(wp_unslash($_POST['seo44_jump_link_headings'])));
    101             }
     116        // NEW: Save HowTo Checkbox
     117        if (isset($_POST['seo44_enable_howto'])) {
     118            update_post_meta($post_id, '_seo44_enable_howto', 'yes');
     119        } else {
     120            // Checkboxes don't send data if unchecked, so we must explicitly delete or update to 'no'
     121            // Only do this if our nonce is verified (which implies the form was actually submitted)
     122            update_post_meta($post_id, '_seo44_enable_howto', 'no');
    102123        }
     124       
    103125    }
    104126}
  • search-appearance-toolkit-seo-44/trunk/includes/class-seo44-settings.php

    r3405454 r3423355  
    11<?php
     2// Version 4.3
     3// Added hasPart Table of Contents Schema, advanced HowTo Schema
     4// Added YouTube Data API to Integrations
    25class SEO44_Settings {
    36
     
    5962        add_settings_field('seo44_schema_tools', __('Schema Scanner', 'search-appearance-toolkit-seo-44'), [$this, 'render_schema_tools'], 'seo-44_schema', 'seo44_schema_settings_section');
    6063        add_settings_field('seo44_enable_schema', __('Enable Schema', 'search-appearance-toolkit-seo-44'), [$this, 'render_checkbox_field'], 'seo-44_schema', 'seo44_schema_settings_section', ['id' => 'enable_schema', 'label' => __('Output JSON-LD to your webpages.', 'search-appearance-toolkit-seo-44')]);
    61         // NEW: Add checkbox for scanning content
    6264        add_settings_field('seo44_scan_content_for_schema', __('Scan Content for Media', 'search-appearance-toolkit-seo-44'), [$this, 'render_checkbox_field'], 'seo-44_schema', 'seo44_schema_settings_section', [
    6365            'id' => 'scan_content_for_schema',
     
    6567            'tooltip' => 'This provides more detail to search engines but can be slightly more resource-intensive.'
    6668        ]);
    67         // NEW: Add checkbox for advanced schema detection
    68         add_settings_field('seo44_enable_advanced_schema', __('Enable Advanced Schema Detection', 'search-appearance-toolkit-seo-44'), [$this, 'render_checkbox_field'], 'seo-44_schema', 'seo44_schema_settings_section', [
    69             'id' => 'enable_advanced_schema',
    70             'label' => __('Scan content for patterns to generate FAQ and How-To schema.', 'search-appearance-toolkit-seo-44'),
    71             'tooltip' => 'If special formats are detected, they will be added to your page\'s schema structured data.'
    72         ]);
     69        // UPDATED: Using custom callback to include the Jump Links Block recommendation
     70        add_settings_field('seo44_enable_advanced_schema', __('Enable Advanced Schema Detection', 'search-appearance-toolkit-seo-44'), [$this, 'render_advanced_schema_checkbox'], 'seo-44_schema', 'seo44_schema_settings_section');
     71
     72        // NEW: Jump Links Integration Field
     73        add_settings_field('seo44_enable_jumplinks_schema',
     74            __('Generate "Table of Contents" Schema', 'search-appearance-toolkit-seo-44'),
     75            [$this, 'render_checkbox_field'],
     76            'seo-44_schema',
     77            'seo44_schema_settings_section',
     78            [
     79                'id' => 'enable_jumplinks_schema',
     80                'label' => __('Automatically create "hasPart" schema for headings highlighted in Jump Links Blocks, aligning structured data with Jump Links to reinforce support for Jump-to links in search results.', 'search-appearance-toolkit-seo-44'),
     81                'tooltip' => 'This helps Google understand the deep structure of your article by mapping your headings (H2-H4) as distinct sections, complementing the visual Jump Links Block on your page.'
     82            ]
     83        );
    7384        add_settings_field('seo44_enable_schema_on_taxonomies', __('Enable Schema on Taxonomies', 'search-appearance-toolkit-seo-44'), [$this, 'render_checkbox_field'], 'seo-44_schema', 'seo44_schema_settings_section', [
    7485            'id' => 'enable_schema_on_taxonomies',
     
    145156            ]
    146157        );
    147        
    148        
    149        
    150158        // 3. Add Address Fields (Crucial for Local SEO & Disambiguation)
    151159        add_settings_field(
     
    190198        );
    191199       
    192 
    193200        add_settings_field(
    194201            'org_phone',
     
    391398            ]
    392399        );
     400        // --- Site APIs Header ---
     401        add_settings_field(
     402            'seo44_site_apis_header',
     403            '', // Empty label for full-width header
     404            [$this, 'render_site_apis_header_field'],
     405            'seo-44_integrations',
     406            'seo44_integrations_section'
     407        );
     408
     409        // --- YouTube API Key ---
     410        add_settings_field(
     411            'youtube_api_key',
     412            __('YouTube API Key', 'search-appearance-toolkit-seo-44'),
     413            [$this, 'render_youtube_api_key_field'],
     414            'seo-44_integrations',
     415            'seo44_integrations_section',
     416            [
     417                'label_for' => 'youtube_api_key',
     418                'tooltip'   => __('This will be used to improve the Video Object schema created for embedded YouTube Videos.', 'search-appearance-toolkit-seo-44')
     419            ]
     420        );
    393421        // --- end of settings_init ---
    394422    }
     
    400428            'enable_tags', 'include_keywords', 'include_author',
    401429            'enable_og_tags', 'enable_twitter_tags',
    402             'enable_schema', 'scan_content_for_schema', 'enable_advanced_schema', 'enable_schema_on_cpts', 'enable_schema_on_taxonomies','enable_organization_schema',
     430            'enable_schema', 'scan_content_for_schema', 'enable_jumplinks_schema', 'enable_advanced_schema', 'enable_schema_on_cpts', 'enable_schema_on_taxonomies','enable_organization_schema',
    403431            'enable_sitemaps', 'enable_sitemap_ping',
    404432            'sitemap_include_images', 'sitemap_include_content_images',
     
    423451            'org_address_street', 'org_address_city', 'org_address_state', 'org_address_zip', 'org_address_country',
    424452            'org_phone', 'org_email', 'org_area_served',
     453            'youtube_api_key',
    425454        ];
    426455        foreach ($text_fields as $tf) {
     
    594623    <?php
    595624}
     625    // UPDATED: Custom renderer for the Advanced Schema Checkbox
     626    public function render_advanced_schema_checkbox() {
     627        $id = 'enable_advanced_schema';
     628        $checked = seo44_get_option($id);
     629       
     630        // Output the standard checkbox
     631        printf(
     632            '<label for="%s"><input type="checkbox" id="%s" name="seo44_settings[%s]" value="1" %s /> %s</label>',
     633            esc_attr($id),
     634            esc_attr($id),
     635            esc_attr($id),
     636            checked($checked, 1, false),
     637            esc_html__('Scan content for patterns to generate FAQ and How-To schema.', 'search-appearance-toolkit-seo-44')
     638        );
     639        seo44_render_tooltip('If special formats are detected, they will be added to your page\'s schema structured data.');
     640
     641        // Add the new recommendation text with the link
     642        echo '<p class="description">';
     643        echo esc_html__('For the most robust HowTo Schema, use a Jump Links Blocks for your How To steps. ', 'search-appearance-toolkit-seo-44');
     644        echo '<a href="https://seo44plugin.com/search-appearance-toolkit-seo-44/schema-structured-data/" target="_blank">' . esc_html__('(Learn more)', 'search-appearance-toolkit-seo-44') . '</a>';
     645        echo '</p>';
     646       
     647    }
    596648
    597649    public function render_textarea_field($args) {
     
    775827        echo '<p class="description">' . esc_html__('These tags are used to prove you own your site to search engines. Paste in your verification codes here and they will be added to your site\'s <head>.', 'search-appearance-toolkit-seo-44') . '</p>';
    776828    }
     829    public function render_site_apis_header_field() {
     830        echo '<h3>' . esc_html__('Site APIs', 'search-appearance-toolkit-seo-44') . '</h3>';
     831        echo '<p class="description">' . esc_html__('Adding a YouTube API key fetches accurate video upload dates for schema structured data. Without this, video upload dates may fall back to your post publication date.', 'search-appearance-toolkit-seo-44') . '</p>';
     832    }
     833    public function render_youtube_api_key_field($args) {
     834        $value = seo44_get_option('youtube_api_key');
     835        $tooltip = isset($args['tooltip']) ? $args['tooltip'] : '';
     836        ?>
     837        <input type="text"
     838               id="youtube_api_key"
     839               name="seo44_settings[youtube_api_key]"
     840               value="<?php echo esc_attr($value); ?>"
     841               class="regular-text code">
     842       
     843        <?php if ($tooltip) { seo44_render_tooltip($tooltip); } ?>
     844
     845        <p class="description">
     846            <?php esc_html_e('Enter your YouTube Data API v3 key.', 'search-appearance-toolkit-seo-44'); ?>
     847            <a href="https://developers.google.com/youtube/v3/getting-started" target="_blank">
     848                <?php esc_html_e('Get API Key', 'search-appearance-toolkit-seo-44'); ?>
     849            </a>
     850        </p>
     851        <?php
     852    }
    777853
    778854    public function settings_page_html() {
     
    780856            return;
    781857        }
    782    
     858
    783859        // --- START: SECURE TAB SWITCHING LOGIC ---
    784860   
  • search-appearance-toolkit-seo-44/trunk/js/admin-script.js

    r3389330 r3423355  
    11jQuery(document).ready(function($) {
    22
    3     // --- NEW: Add custom class to metabox heading ---
     3    // --- Add custom class to metabox heading ---
    44    // Find the metabox by its ID and then find the title element inside it.
    55    var metabox = $('#seo44_meta_box');
     
    77        metabox.find('h2.hndle').addClass('seo44-metabox-heading');
    88    }
     9
     10    // --- "Use Example" Title Button ---
     11    $('#seo44-use-example-title').on('click', function() {
     12        var exampleText = $('#seo44-title-example').text();
     13        var titleInput = $('#seo44_title');
     14        titleInput.val(exampleText);
     15       
     16        // Trigger the keyup event to update the snippet preview and character counter
     17        titleInput.trigger('keyup');
     18    });
    919
    1020    // --- Character Counter Functionality (from v1.4) ---
     
    3646    createCounter('seo44_description', 'seo44_description_char_count', 160);
    3747
    38     // --- NEW: "Use Example" Title Button ---
    39     $('#seo44-use-example-title').on('click', function() {
    40         var exampleText = $('#seo44-title-example').text();
    41         var titleInput = $('#seo44_title');
    42         titleInput.val(exampleText);
     48
     49    // --- HowTo Schema Trigger Logic ---
     50    const howToWrapper = $('#seo44-howto-trigger-wrapper');
     51    const howToCheckbox = $('#seo44_enable_howto');
     52    const descriptionField = $('#seo44_description');
     53   
     54    // List of triggers (lowercase)
     55    const triggers = ['step guide', 'step-by-step', 'how to', 'how-to', 'guide', 'walkthrough', 'directions', 'instructions', 'tutorial'];
     56
     57    // Flag to track if user has manually overridden the automation
     58    let userHasInteracted = false;
     59
     60    // Listen for manual clicks
     61    howToCheckbox.on('change', function() {
     62        userHasInteracted = true;
     63    });
     64
     65    function checkHowToTriggers() {
     66        // 1. If user manually touched the box, do nothing.
     67        if (userHasInteracted) return;
     68
     69        // 2. Get description text
     70        const text = descriptionField.val().toLowerCase();
    4371       
    44         // Trigger the keyup event to update the snippet preview and character counter
    45         titleInput.trigger('keyup');
    46     });
     72        // 3. Check for triggers
     73        const hasTrigger = triggers.some(trigger => text.includes(trigger));
     74
     75        if (hasTrigger) {
     76            // Found a trigger!
     77            howToWrapper.show(); // Make visible
     78           
     79            // Only check it if it wasn't already checked (to avoid redundant events)
     80            if (!howToCheckbox.is(':checked')) {
     81                howToCheckbox.prop('checked', true);
     82            }
     83        } else {
     84            // No trigger found.
     85            // Behavior: If we are purely automated (user hasn't clicked),
     86            // we hide it and uncheck it.
     87            howToWrapper.hide();
     88            howToCheckbox.prop('checked', false);
     89        }
     90    }
     91
     92    // Run on keyup in description
     93    descriptionField.on('keyup change paste', checkHowToTriggers);
     94
     95    // Run once on load to handle pre-filled content (if user hasn't saved yet)
     96    // We delay slightly to ensure values are populated
     97    setTimeout(function() {
     98        // Only run auto-check if the box is currently hidden/unchecked.
     99        // If it's already visible/checked from DB, we respect that state.
     100        if (!howToCheckbox.is(':checked')) {
     101            checkHowToTriggers();
     102        } else {
     103            // If it IS checked from DB, mark as "interacted" so we don't auto-uncheck it
     104            // just because the user edits the description and removes a keyword.
     105            userHasInteracted = true;
     106        }
     107    }, 500);
     108
    47109
    48110    // --- Snippet Preview Functionality ---
    49111    const titleInput = $('#seo44_title');
    50112    const descriptionInput = $('#seo44_description');
    51    
     113   
    52114    const previewHeaderUrl = $('.seo44-preview-breadcrumb-url');
    53115    const previewTitle = $('.seo44-preview-title');
     
    58120    const siteName = seo44_data.site_name;
    59121    const permalink = seo44_data.permalink;
    60    
     122   
    61123    function updatePreview() {
    62124        // Update Title
    63125        let titleVal = titleInput.val();
    64         previewTitle.text(titleVal ? titleVal : defaultTitle);
    65        
    66        
    67        // if (titleVal) {
    68        //     previewTitle.text(titleVal);
    69        // } else {
    70             // If the input is empty, show "Post Title - Site Name"
    71         //    previewTitle.text(defaultTitle + ' - ' + siteName);
    72         //}
     126        previewTitle.text(titleVal ? titleVal : defaultTitle);
    73127
    74128        // Update Description
     
    80134            previewDescription.text('Enter a meta description to see how it will appear in search results...');
    81135        }
    82        
    83         // Update Breadcrumb URL
     136       
     137        // Update Breadcrumb URL
    84138        let breadcrumb = permalink.replace(/^https?:\/\//, '').replace(/\/$/, '');
    85139        breadcrumb = breadcrumb.replace(/\//g, ' &rsaquo; '); // &rsaquo; is the > symbol
  • search-appearance-toolkit-seo-44/trunk/readme.txt

    r3405454 r3423355  
    33Tags: seo, on-page seo, schema, structured data, xml sitemaps
    44Requires at least: 5.5
    5 Tested up to: 6.8
    6 Stable tag: 4.2
     5Tested up to: 6.9
     6Stable tag: 4.3
    77Requires PHP: 7.4
    88License: GPLv2 or later
     
    7272* **Knowledge Graph Control:** A dedicated interface to manage your brand's digital identity. Define your Founder, Founding Date, Contact Info, and professional Credentials to improve E-E-A-T signals.
    7373* **Include Images and Videos:** A built-in tool automatically finds all images and embedded YouTube videos in your content and adds them to the schema, boosting their appearance in search results.
    74 * **FAQ and How-To Detection:** Enable a smart scanner to detect patterns in your content for FAQ and How-To sections on your website and incorporate this useful format into the schema.
     74* **FAQ and How-To Detection:** Enable smart scanners to detect patterns in your content for FAQ and How-To sections on your website and incorporate this useful format into the schema.
     75* **Jump Links HowTo Scanner:** The plugin can use a Jump Links Block as a "Map" to generate detailed HowTo schema steps, while simultaneously scanning your content for Prep Time, Yield, Supplies, and Tools.
     76* **Table of Contents Schema:** Automatically generates `hasPart` structured data that mirrors your Jump Links Block, helping search engines understand your article's deep structure.
    7577* **Modern Output:** All structured data is generated in the modern JSON-LD format preferred by search engines, following the guidelines set by [Schema.org](https://schema.org/).
    7678* **Granular Control:**  Tailor the Schema settings to fit your site's needs through Enable/disable settings, including on Custom Post Types and Taxonomies.
     
    101103The Search Appearance Toolkit serves as a hub for connecting your site to essential third-party services, helping you to create valuable analytics data and site insights.
    102104
    103 = Site Verification =
    104 Verify your site with search engines quickly and easily. Paste your verification codes for **Google Search Console** and **Bing Webmaster Tools** into the corresponding fields in the Integrations tab. The plugin handles the rest, adding site verification meta tags to your website's head. No coding required.
    105 
    106105= Google Tag Manager (GTM) Integration =
    107106Easily integrate with Google Tag Manager by pasting your `GTM-XXXXXXX` ID into the settings field. The plugin will correctly and safely inject the GTM scripts into your site's `<head>` and `<body>` on every page. No coding required.
     
    115114* **Jump Link Click Tracking:** Tracks engagement with your Jump Links Block by pushing a `jump_link_click` event, letting you see which sections your users are most interested in.
    116115
    117 SEO 44's integration with Google Tag Manager pushes valuable events to the `dataLayer` for use in Google Analytics. The plugin includes an import file and detailed [Instructions for setting up Google Tag Manager and Google Analytics to receive event-tracking data](https://seo44plugin.com/search-appearance-toolkit-seo-44/integrations-setup-guide/).
     116SEO 44's integration with Google Tag Manager pushes valuable events to the `dataLayer` for use in Google Analytics. The plugin includes an import file and detailed [Instructions for setting up Google Tag Manager and Google Analytics to receive event-tracking data](https://seo44plugin.com/search-appearance-toolkit-seo-44/integrations-setup-guide/).
     117
     118= Site Verification =
     119Verify your site with search engines quickly and easily. Paste your verification codes for **Google Search Console** and **Bing Webmaster Tools** into the corresponding fields in the Integrations tab. The plugin handles the rest, adding site verification meta tags to your website's head. No coding required.
     120
     121= YouTube Data API =
     122To ensure that your site's video schema is as accurate as possible, you may add your YouTube Data API Key.  The plugin uses this key to fetch the upload date for any YouTube video embedded in your content, replacing less reliable page scraping options and fallbacks to the post publish date.
    118123
    119124== Frequently Asked Questions ==
     
    122127Search Appearance Toolkit (SEO 44) gives you control over the technical, on-page SEO factors that help search engines understand and rank your content. Key benefits include:
    123128* **Optimized Snippets:** Control how your titles and descriptions appear in search results.
    124 * **Rich Results:** The advanced Schema.org data helps you earn rich results like FAQs, How-Tos, and breadcrumbs in Google.
     129* **Rich Results:** The advanced Schema.org data improves indexing and helps you earn rich results in Google.
    125130* **Crawlability:** A clean and comprehensive XML sitemap ensures search engines can find and index all of your important content and images.
    126131* **User Engagement:** The Jump Links Block improves user experience, which is a positive ranking signal, and can help you earn "Jump to" links in search results.
     
    154159SEO 44 helps your content look great when shared on social media platforms. You can enable the automatic generation of **Open Graph** (og:) tags, which Facebook, LinkedIn, and Pinterest use, and **Twitter Card** meta tags for when your content appears on X (formerly Twitter). This ensures your posts have the correct title, description, and preview image when shared.
    155160
    156 Use the plugin's Google Tag Manager Integration to facilitate additional connections with soclal media platforms. Use the Organization Schema settings to associate all of your social media profiles with your website.
     161Use the plugin's Google Tag Manager Integration to facilitate additional connections with social media platforms. Use the Organization Schema settings to associate all of your social media profiles with your website.
    157162
    158163= How are social media images handled? =
     
    177182
    178183= What are the benefits of using FAQPage and HowTo schema? =
    179 The plugin intelligently scans your content for patterns that match question-and-answer formats or step-by-step instructions (when this option is enabled). The plugin locates this content and automatically generates FAQPage or HowTo schema that presents this content within the JSON-LD.
    180 
    181 The benefit of this is that Google can use this structured data to display your content in special, highly visible formats in the search results. An FAQ page might appear as a rich snippet with expandable questions, while a How-To article can be featured in a step-by-step guide. Rich snippets make your search results stand out, which can significantly improve your click-through rate (CTR).
     184The plugin intelligently scans your content for patterns that match question-and-answer formats or step-by-step instructions (when this option is enabled). The plugin locates this content and automatically generates FAQPage or HowTo schema that presents this content as structured data.
     185
     186The benefit of this is that Google can use this structured data to display your content in special, highly visible formats in the search results. An FAQ page might appear as a rich snippet with expandable questions or feature within a People Also Ask (PAA) result, while a How-To article can be featured in a step-by-step guide in AI Overviews. Search results that stand out can improve your click-through rate (CTR).
    182187
    183188Read more about the [FAQPage and HowTo Schema](https://seo44plugin.com/search-appearance-toolkit-seo-44/schema-structured-data/#benefits-of-faqpage-and-howto-schema) created by SEO 44.
     
    285290    * **Google:** [Terms of Service](https://policies.google.com/terms), [Privacy Policy](https://policies.google.com/privacy)
    286291    * **Bing (Microsoft):** [Microsoft Services Agreement](https://www.microsoft.com/en-us/servicesagreement/), [Microsoft Privacy Statement](https://privacy.microsoft.com/en-us/privacystatement)
     292
     293= YouTube Data API Integration & Video Metadata =
     294
     295* **Service Description:** This plugin connects to YouTube to fetch the "Upload Date" for videos embedded in your content. This ensures your VideoObject schema is accurate.
     296* **Data Sent and Conditions:**
     297    * **Method 1 (API):** If you provide a YouTube API Key in settings, the plugin sends the Video ID to the Google Data API.
     298    * **Method 2 (Public Fallback):** If no API Key is present, the plugin acts as a standard browser and fetches the public video page (via `wp_remote_get`) to locate the upload date in the page meta tags.
     299    * **Conditions:** This occurs automatically when a post with a YouTube embed is updated, provided that Schema generation is enabled.
     300* **Service Provider Links:**
     301    * **YouTube:** [Terms of Service](https://www.youtube.com/t/terms), [Google Privacy Policy](https://policies.google.com/privacy)
    287302
    288303== Screenshots ==
     
    315330== Changelog ==
    316331
     332= 4.3.0 =
     333* FEATURE: **Table of Contents Schema:** Automatically generates `hasPart` schema synchronized with the Jump Links Block to support "Jump-to" links in search results.
     334* FEATURE: **Advanced HowTo Schema:** A new "Block-Aware" scanner uses the Jump Links Block to strictly define steps while intelligently mining the introduction for tools, time, yield, and video data.
     335* TWEAK: Added a "Generate HowTo Schema" checkbox to the SEO 44 metabox, giving users explicit control over when the advanced scanner runs.
     336* FEATURE: **YouTube Integration:** Added support for the YouTube Data API v3 to fetch accurate video upload dates for VideoObject schema.
     337* TESTED: Tested to WordPress Version 6.9
     338
    317339= 4.2.0 =
    318340* FEATURE: **Rich Organization Schema:** Added a comprehensive settings section to generate Organization structured data for the Knowledge Graph.
     
    325347* TWEAK: Updated the image uploader JavaScript to support multiple distinct upload buttons on the settings page.
    326348
    327 = 4.1.0 =
    328 **Jump Links Block Updates:**
    329 * FEATURE: **Sticky Positioning:** Keep your table of contents visible while users scroll. Includes a "Top Offset" slider to clear sticky headers, a "Jump Offset" slider to ensure that the sticky header does not cover the heading text, and a "Disable on Mobile" toggle to preserve screen space on small devices.
    330 * FEATURE: **Auto-Hide Title:** Implemented a smart "sticky state" detection. When the block sticks, the title gently collapses and fades out to keep the interface clean (this occurs when a block title is used alongside sticky positioning).
    331 * FEATURE: **Smart Indentation:** Added a "Create Visual Hierarchy" toggle. When enabled, H3 and H4 sub-headings are visually indented to create a clear, nested outline structure.
    332 * FEATURE: **Block Background:** You can now set a background color for the entire block container, perfect for creating "card-style" floating navigation.
    333 * FEATURE: **ScrollSpy:** Automatically highlights the active link in the table of contents as the user scrolls through the corresponding section of the post.
    334 * FEATURE: Added support for Border and Spacing controls. You can now add borders, rounded corners, margins, and padding to the Jump Links block directly from the editor settings.
    335 * FEATURE: Added a "Title tag" control. You can now choose the specific HTML tag (H2, H3, H4, H5, Paragraph, or Div) for the "On This Page" heading to better match your document structure.
    336 * REFACTOR: Optimized the block's styling logic to use CSS variables on the parent container instead of inline styles for every link. This reduces the block's HTML size and improves rendering performance.
    337 * FIX: Resolved an accessibility and HTML validation issue where using multiple Jump Links blocks on a single page created duplicate element IDs. Each block now generates a unique instance ID.
    338 * PERFORMANCE: Refactored the front-end JavaScript to use event delegation for smooth scrolling. This reduces memory usage by attaching a single event listener to the block instead of individual listeners for every link.
    339 * TWEAK: Reorganized the sidebar settings for better clarity between Block Title settings and Content Inclusion settings.
    340 
    341 = 4.0.0 =
    342 * FEATURE: Added a new "Integrations" tab for third-party services like Google Tag Manager and Webmaster Tools.
    343 * FEATURE: Added Google Tag Manager (GTM) integration. The plugin can now automatically inject the GTM container script into the site's <head> and <body> based on your ID.
    344 * FEATURE: Added Webmaster Verification. You can now add your Google Search Console and Bing Webmaster Tools verification codes directly from the plugin settings.
    345 * FEATURE: Added automatic GTM event tracking. When enabled, the plugin can push the following events to the dataLayer:
    346     * Rich SEO dataLayer: Pushes page type, category, author, and tags on page load for advanced GTM triggers.
    347     * Scroll Depth Tracking: Pushes 'scroll_depth' events at 25%, 50%, 75%, and 100% of the page.
    348     * External & Affiliate Clicks:* Pushes 'external_link_click' or 'affiliate_link_click' (for `rel="sponsored"` links).
    349     * Jump Link Clicks: Pushes a 'jump_link_click' event when a user clicks a link in the Jump Links Block.
    350 * FEATURE: Added a Google Tag Manager recipe import file (`seo44-gtm-recipe-importer.json`) and new FAQ instructions to fully configure GTM and GA4 event tracking.
    351 * ENHANCEMENT: Centralized all GTM event tracking into a new, efficient `global-tracker.js` file that uses event delegation for better performance.
    352 * TWEAK: Improved the "Integrations" settings page UI for clarity, adding clarifying tooltips and a file downloader.
    353 
    354349For a complete list of changes, please see the [full changelog](https://seo44plugin.com/search-appearance-toolkit-seo-44/changelog/) or the `changelog.txt` file included with the plugin.
    355350
  • search-appearance-toolkit-seo-44/trunk/seo-44.php

    r3405454 r3423355  
    44 * Plugin URI:        https://www.sethcreates.com/plugins-for-wordpress/search-appearance-toolkit-seo-44/
    55 * Description:       A lightweight, powerful SEO plugin for essential meta tags, advanced schema, XML sitemaps, jump links, and easy migration from other plugins.
    6  * Version:           4.2.0
     6 * Version:           4.3
    77 * Author:            Seth Smigelski
    88 * Author URI:        https://www.sethcreates.com/plugins-for-wordpress/
     
    1414if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly
    1515
    16 define( 'SEO44_VERSION', '4.2.0' );
     16define( 'SEO44_VERSION', '4.3' );
    1717define( 'SEO44_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
    1818
Note: See TracChangeset for help on using the changeset viewer.