post_meta = $post_meta; $this->graph_builder = $graph_builder; $this->validator = $validator; $this->features = $features; } private function ai_enabled(): bool { return null === $this->features || $this->features->enabled( 'ai' ); } public function register(): void { add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ), 10, 2 ); add_action( 'save_post', array( $this, 'save' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); // User-requested placement (M62 follow-up): directly under the main // content editor, not buried inside the SEO meta box further down // the screen. `edit_form_after_editor` fires right after the // Classic Editor's own

', (int) MetaFields::AI_CONTEXT_MAX_LENGTH, esc_textarea( $ai_context_keyword ) ); } echo '

'; echo wp_kses( HelpIcon::render( 'seo_title' ), KsesTags::help_icon() ); if ( $this->ai_enabled() ) { printf( ' ', esc_html__( 'Generate with AI', 'acez-seo' ) ); } /** * Extra AI actions for this meta field, rendered right after the base * plugin's own "Generate with AI" button (M69 Etap 5). * * The "Translate from the original" button (M35) used to sit here * behind a licence check; it lives in acez-seo-pro now. A listener * that outputs a `button.acez-seo-ai-generate` with data-field and * data-target is wired up automatically — meta-box-ai.js binds by * class, so the addon needs no JS of its own for this editor. * * @param \WP_Post $post * @param string $field 'title' or 'description'. * @param string $target DOM id of the input the result should fill. */ do_action( 'acez_seo_metabox_meta_field_actions', $post, 'title', 'acez_seo_title' ); echo '

'; printf( '

', esc_attr( $title ) ); echo '

'; echo wp_kses( HelpIcon::render( 'seo_description' ), KsesTags::help_icon() ); if ( $this->ai_enabled() ) { printf( ' ', esc_html__( 'Generate with AI', 'acez-seo' ) ); } /** * Extra AI actions for this meta field, rendered right after the base * plugin's own "Generate with AI" button (M69 Etap 5). * * The "Translate from the original" button (M35) used to sit here * behind a licence check; it lives in acez-seo-pro now. A listener * that outputs a `button.acez-seo-ai-generate` with data-field and * data-target is wired up automatically — meta-box-ai.js binds by * class, so the addon needs no JS of its own for this editor. * * @param \WP_Post $post * @param string $field 'title' or 'description'. * @param string $target DOM id of the input the result should fill. */ do_action( 'acez_seo_metabox_meta_field_actions', $post, 'description', 'acez_seo_description' ); echo '

'; printf( '

', esc_textarea( $description ) ); $this->render_lite_section( $post ); echo '

'; echo wp_kses( HelpIcon::render( 'seo_canonical' ), KsesTags::help_icon() ); echo '
'; printf( '

', esc_attr( $canonical ) ); printf( '

  %5$s

', checked( $robots_index, true, false ), esc_html__( 'Index', 'acez-seo' ), checked( $robots_follow, true, false ), esc_html__( 'Follow', 'acez-seo' ), wp_kses( HelpIcon::render( 'seo_robots' ), KsesTags::help_icon() ) ); printf( '

%3$s

', checked( $sitemap_exclude, true, false ), esc_html__( 'Exclude from XML sitemap', 'acez-seo' ), wp_kses( HelpIcon::render( 'seo_sitemap_exclude' ), KsesTags::help_icon() ) ); printf( '

%3$s

', checked( $llms_exclude, true, false ), esc_html__( 'Exclude from llms.txt', 'acez-seo' ), wp_kses( HelpIcon::render( 'seo_llms_exclude' ), KsesTags::help_icon() ) ); $this->render_howto_field( $post ); $this->render_product_fields( $post ); $this->render_custom_schema_section( $post ); $this->render_diagnostics( $post ); } private function render_howto_field( \WP_Post $post ): void { $enabled = (bool) $this->post_meta->get( $post->ID, MetaFields::HOWTO_ENABLED ); echo '
'; printf( '

%3$s

', checked( $enabled, true, false ), esc_html__( 'Treat as HowTo (steps taken from the first numbered list in the content)', 'acez-seo' ), wp_kses( HelpIcon::render( 'howto_field' ), KsesTags::help_icon() ) ); } private function render_product_fields( \WP_Post $post ): void { if ( 'product' !== $post->post_type ) { return; } $price = (string) $this->post_meta->get( $post->ID, MetaFields::PRODUCT_PRICE ); $currency = (string) $this->post_meta->get( $post->ID, MetaFields::PRODUCT_CURRENCY ); $sku = (string) $this->post_meta->get( $post->ID, MetaFields::PRODUCT_SKU ); $availability = (string) $this->post_meta->get( $post->ID, MetaFields::PRODUCT_AVAILABILITY ); $using_wc = function_exists( 'wc_get_product' ); echo '

' . esc_html__( 'Product data (schema.org)', 'acez-seo' ) . wp_kses( HelpIcon::render( 'product_fields' ), KsesTags::help_icon() ) . '

'; if ( $this->ai_enabled() ) { printf( '


%2$s

', esc_html__( 'Generate product description with AI', 'acez-seo' ), esc_html__( 'The result goes into the "Meta description" field above (also used as the product description in schema.org) — review before saving.', 'acez-seo' ) ); } if ( $using_wc ) { echo '

' . esc_html__( 'WooCommerce detected — price, currency, SKU, and availability are pulled automatically from the product. The fields below are ignored.', 'acez-seo' ) . '

'; return; } printf( '

', esc_html__( 'Price', 'acez-seo' ), esc_attr( $price ) ); printf( '

', esc_html__( 'Currency (ISO 4217 code)', 'acez-seo' ), esc_attr( $currency ) ); printf( '

', esc_html__( 'SKU', 'acez-seo' ), esc_attr( $sku ) ); $availability_options = array( 'InStock' => __( 'In stock', 'acez-seo' ), 'OutOfStock' => __( 'Out of stock', 'acez-seo' ), 'PreOrder' => __( 'Pre-order', 'acez-seo' ), 'LimitedAvailability' => __( 'Limited availability', 'acez-seo' ), ); echo '

'; } private function render_custom_schema_section( \WP_Post $post ): void { $override_raw = (string) $this->post_meta->get( $post->ID, MetaFields::SCHEMA_JSON ); $override = '' !== $override_raw ? json_decode( $override_raw, true ) : null; $custom_raw = (string) $this->post_meta->get( $post->ID, MetaFields::CUSTOM_SCHEMAS ); $custom_items = '' !== $custom_raw ? (array) json_decode( $custom_raw, true ) : array(); echo '

' . esc_html__( 'Advanced: custom schema.org', 'acez-seo' ) . wp_kses( HelpIcon::render( 'schema_graph' ), KsesTags::help_icon() ) . '

'; $type_override = MetaFields::sanitize_schema_type_override( (string) $this->post_meta->get( $post->ID, MetaFields::SCHEMA_TYPE_OVERRIDE ) ); echo '

'; echo wp_kses( HelpIcon::render( 'schema_type_override' ), KsesTags::help_icon() ); echo '
' . esc_html__( 'When automatic detection gets it wrong (e.g. a page with a blog built using a third-party builder module), pick the correct type for the main node — its properties will still be filled in automatically from the post\'s data.', 'acez-seo' ) . '
'; echo '

'; $this->render_forced_schema_fields( $post, $type_override ); echo '


'; echo '' . esc_html__( 'If filled in, this replaces the ENTIRE auto-generated graph (the entries below are then ignored).', 'acez-seo' ) . '
'; printf( '

', esc_attr__( 'e.g. {"@type":"Recipe","name":"..."} — leave blank to use the automatic graph', 'acez-seo' ), esc_textarea( is_array( $override ) ? (string) wp_json_encode( $override, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) : '' ) ); echo '

' . esc_html__( 'Additional schema.org nodes (merged into the automatic graph)', 'acez-seo' ) . '

'; echo '
'; foreach ( $custom_items as $item ) { if ( is_array( $item ) ) { $this->render_custom_schema_row( (string) ( $item['label'] ?? '' ), is_array( $item['node'] ?? null ) ? $item['node'] : array() ); } } echo '
'; echo ''; echo '

'; $this->render_node_builder(); if ( $this->ai_enabled() ) { $ai_context_schema = (string) $this->post_meta->get( $post->ID, MetaFields::AI_CONTEXT_SCHEMA ); echo '

'; echo wp_kses( HelpIcon::render( 'ai_context_schema' ), KsesTags::help_icon() ); echo '

'; printf( '

', (int) MetaFields::AI_CONTEXT_MAX_LENGTH, esc_textarea( $ai_context_schema ) ); printf( '

%1$s
%6$s

', esc_html__( 'Or generate a node proposal with AI (it lands as a new row above — review and adjust before saving):', 'acez-seo' ), esc_attr__( 'FAQ (AI proposal)', 'acez-seo' ), esc_html__( 'Generate FAQ node with AI', 'acez-seo' ), esc_attr__( 'HowTo (AI proposal)', 'acez-seo' ), esc_html__( 'Generate HowTo node with AI', 'acez-seo' ), wp_kses( HelpIcon::render( 'ai_schema_nodes' ), KsesTags::help_icon() ) ); } } /** * M51 — the free "Lite" analysis: keyphrase field + live lights. Renders * NOTHING when the Pro addon is active (its full analysis, with its own * keyphrase field bound to the SAME meta key, replaces this section). */ private function render_lite_section( \WP_Post $post ): void { if ( ! \AcezSeo\Analysis\LiteAnalyzer::enabled() ) { return; } $keyphrase = (string) $this->post_meta->get( $post->ID, MetaFields::KEYPHRASE ); echo '
'; echo '

'; echo wp_kses( HelpIcon::render( 'lite_analysis' ), KsesTags::help_icon() ); if ( $this->ai_enabled() ) { printf( ' ', esc_html__( 'Generate with AI', 'acez-seo' ) ); } echo '

'; printf( '

', esc_attr( $keyphrase ) ); echo ''; printf( '

%1$s

', esc_html__( 'The Pro version understands Polish inflection and synonyms, analyzes readability, and runs an AI content review.', 'acez-seo' ), esc_attr__( 'Hide this hint', 'acez-seo' ) ); echo '
'; } /** * M66 part D — manual property overrides for the FORCED main node. * * The fields themselves live in acez-seo-pro since M69 Etap 3: a licence * unlocking code that ships in the WordPress.org package is exactly what * Guideline 5 forbids. What stays here is the extension point and, when * no addon answers it, a static teaser — marketing copy, which Guideline * 11 allows on our own editor screen. * * Which of the two renders is decided by has_action(), deliberately NOT * by a licence check (M69 Etap 2 doctrine): the base plugin no longer * knows about licences at all. With the addon installed but unlicensed, * the addon draws its own signpost — otherwise the hint would vanish for * precisely the users it is meant to reach. */ private function render_forced_schema_fields( \WP_Post $post, string $type_override ): void { $hidden = '' === $type_override || 'none' === $type_override; if ( ! has_action( 'acez_seo_metabox_schema_fields' ) ) { $this->render_pro_teaser_locked_fields( $hidden ); return; } /** * Renders the forced node's property fields inside this meta box. * * The addon must reuse `id="acez-seo-forced-schema-fields"` and honour * $hidden: the base plugin's own custom-schema-repeater.js toggles * that element live when the type dropdown changes, so the fields * appear and disappear without a page reload exactly as before. * * @param \WP_Post $post * @param bool $hidden Whether no real type is forced yet, so the block starts hidden. * @param string $type_override The forced type name ('' or 'none' when unset). */ do_action( 'acez_seo_metabox_schema_fields', $post, $hidden, $type_override ); } /** * M66 (option d) — disabled preview of the part-D fields, shown when no * addon answers `acez_seo_metabox_schema_fields`. Field markup mirrors the * real fields acez-seo-pro renders, minus `name=""` (never posted) and * plus `disabled` — same labels, so what is being sold is obvious. Since * M69 Etap 3 this is genuinely dead markup: there is no code path here * that could store or apply these values, whatever a client posts. */ private function render_pro_teaser_locked_fields( bool $hidden ): void { echo '
'; echo wp_kses( $this->pro_teaser_badge(), KsesTags::badge() ); echo '
'; printf( '


', esc_html__( 'Node name / headline', 'acez-seo' ) ); printf( '


', esc_html__( 'Node description', 'acez-seo' ) ); printf( '


', esc_html__( 'Image URL (optional)', 'acez-seo' ) ); echo '
'; printf( '%2$s', esc_url( $this->license_tab_url() ), esc_html__( 'Unlock manual node-property editing in Pro →', 'acez-seo' ) ); echo '
'; } /** * M66 part E — the "add a node from a form" builder for the custom-nodes * repeater. * * Pure convenience UI: the builder only COMPOSES the JSON, which is then * appended as an ordinary repeater row and travels the existing save and * sanitization path — a form-built node is indistinguishable from a * hand-pasted one. That is exactly why it could be sold as Pro with a * render-time gate and nothing else; and exactly why, since M69 Etap 4, * the form itself lives in acez-seo-pro instead: shipping it here and * unlocking it with a licence is what Guideline 5 forbids. * * Unlike the forced-node fields, this is NOT tied to the type-override * state — a custom node is useful whether or not the main node's type is * forced — so the teaser shows unconditionally when no addon answers. */ private function render_node_builder(): void { if ( ! has_action( 'acez_seo_metabox_node_builder' ) ) { $this->render_pro_teaser_locked_node_builder(); return; } /** * Renders the form-based node builder under the custom-nodes repeater. * * The addon appends finished rows through the repeater's own JS API * (`window.AcezSeoSchemaRepeater.addRow`), never by touching the *