array( array( 'handle' => 'aiforya-sm-tab-builder', 'src' => AIFORYA_SM_URL . 'assets/css/tab-builder.css', 'ver' => AIFORYA_SM_VERSION, ), ), ); } public function render( array $args = array() ): void { $service = Aiforya_Schema_Markup_Builder::get_instance(); $schema_catalog = Aiforya_Schema_Markup_Builder::schema_catalog(); $nested_catalog = Aiforya_Schema_Markup_Builder::nested_catalog(); $available = Aiforya_Schema_Markup_Builder::available_types(); $templates = $service->get_templates(); $nonce = wp_create_nonce( 'aiforya_sm_admin_nonce' ); // Helper D2 : pastille d'aide « ? » charte (AiforyaTooltip). $text déjà traduit. $tip = static function ( $text ) { printf( '', esc_attr( $text ) ); }; ?>

0
{}

$schema_catalog, 'nestedCatalog' => $nested_catalog, 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => $nonce, 'ajouter' => __( '+ Add', 'aiforya-schema-markup' ), 'retirer' => __( 'Remove', 'aiforya-schema-markup' ), 'champsRequis' => __( 'Required fields', 'aiforya-schema-markup' ), 'champsRecommandes' => __( 'Recommended fields', 'aiforya-schema-markup' ), 'champsAvances' => __( 'Advanced fields', 'aiforya-schema-markup' ), 'deplier' => __( 'Expand', 'aiforya-schema-markup' ), 'replier' => __( 'Collapse', 'aiforya-schema-markup' ), 'jsonLdValide' => __( 'Valid JSON-LD', 'aiforya-schema-markup' ), 'jsonLdInvalide' => __( 'Invalid JSON-LD', 'aiforya-schema-markup' ), 'leTypeEstRequis' => __( 'The @type is required.', 'aiforya-schema-markup' ), 'jsonLdCopieDans' => __( 'JSON-LD copied to clipboard.', 'aiforya-schema-markup' ), 'impossibleDeCopierSelectionnez' => __( 'Unable to copy — please select and copy manually.', 'aiforya-schema-markup' ), 'reinitialiserTousLesChamps' => __( 'Reset all fields? This action cannot be undone.', 'aiforya-schema-markup' ), 'templateSupprime' => __( 'Template deleted.', 'aiforya-schema-markup' ), 'supprimerCeTemplate' => __( 'Delete this template?', 'aiforya-schema-markup' ), 'templateChargeModifiezLes' => __( 'Template loaded. Edit the fields if needed, then apply.', 'aiforya-schema-markup' ), 'aucunPostTrouve' => __( 'No posts found.', 'aiforya-schema-markup' ), 'recherche' => __( 'Searching…', 'aiforya-schema-markup' ), 'schemaApplique' => __( 'Schema applied!', 'aiforya-schema-markup' ), 'templateEnregistre' => __( 'Template saved.', 'aiforya-schema-markup' ), 'champRequisManquant' => __( 'Required field missing: ', 'aiforya-schema-markup' ), 'astuce' => __( 'Tip', 'aiforya-schema-markup' ), 'ajouterUnChampPersonnalise' => __( 'Add a custom field', 'aiforya-schema-markup' ), 'nomDuChampEx' => __( 'Field name (e.g. copyrightHolder)', 'aiforya-schema-markup' ), 'value' => __( 'Value', 'aiforya-schema-markup' ), 'ajouterCeChamp' => __( 'Add this field', 'aiforya-schema-markup' ), 'echec' => __( 'Failed.', 'aiforya-schema-markup' ), 'leNomDuTemplate' => __( 'Template name is required.', 'aiforya-schema-markup' ), 'postSelectionne' => __( 'Selected post:', 'aiforya-schema-markup' ), 'changer' => __( 'Change', 'aiforya-schema-markup' ), 'glisserPourReordonner' => __( 'Drag to reorder', 'aiforya-schema-markup' ), 'champRequisGoogleRich' => __( 'Required field for Google Rich Results.', 'aiforya-schema-markup' ), 'monter' => __( 'Move up', 'aiforya-schema-markup' ), 'descendre' => __( 'Move down', 'aiforya-schema-markup' ), 'selectionnezUnTypePour' => __( 'Select a type to start building. The Builder covers the 17 most widely used types in SEO in 2026.', 'aiforya-schema-markup' ), 'nonSupporteParLe' => __( 'not supported by the Builder.', 'aiforya-schema-markup' ), ) ); $js = <<<'JS' var L = window.aiforyaSmBuilderL10n || {}; jQuery(function($){ 'use strict'; var ajaxUrl = window.ajaxurl || L.ajaxUrl; var nonce = L.nonce; var SCHEMA_CATALOG = L.schemaCatalog; var NESTED_CATALOG = L.nestedCatalog; var I18N = { addItem: L.ajouter, remove: L.retirer, requiredFields: L.champsRequis, recommendedFields: L.champsRecommandes, advancedFields: L.champsAvances, expand: L.deplier, collapse: L.replier, validJson: L.jsonLdValide, invalidJson: L.jsonLdInvalide, missingType: L.leTypeEstRequis, copied: L.jsonLdCopieDans, copyFailed: L.impossibleDeCopierSelectionnez, resetConfirm: L.reinitialiserTousLesChamps, tplDeleted: L.templateSupprime, tplDeleteConfirm: L.supprimerCeTemplate, tplLoaded: L.templateChargeModifiezLes, noResults: L.aucunPostTrouve, searching: L.recherche, applied: L.schemaApplique, tplSaved: L.templateEnregistre, missingRequired: L.champRequisManquant, fieldHint: L.astuce, customField: L.ajouterUnChampPersonnalise, customFieldName: L.nomDuChampEx, customFieldValue: L.value, addCustomField: L.ajouterCeChamp, schemaError: L.echec, emptyName: L.leNomDuTemplate, postSelected: L.postSelectionne, changePost: L.changer, dragHint: L.glisserPourReordonner }; // ----------------------------------------------------------------- // Setup : modals + toast vers body (doctrine pattern AIFORYA). // ----------------------------------------------------------------- $('#aiforya-sm-builder-toast-host').appendTo('body'); $('#aiforya-sm-builder-tpl-modal').appendTo('body'); $('#aiforya-sm-builder-apply-modal').appendTo('body'); function smToast(message, type) { type = type || 'info'; var $host = $('#aiforya-sm-builder-toast-host'); var $t = $('
', { 'class': 'aiforya-sm-toast aiforya-sm-toast--' + type, role: 'status' }).text(message); $host.append($t); setTimeout(function(){ $t.addClass('is-visible'); }, 10); setTimeout(function(){ $t.removeClass('is-visible'); setTimeout(function(){ $t.remove(); }, 300); }, 4500); } function escHtml(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; }); } // ----------------------------------------------------------------- // ÉTAT GLOBAL — un objet plain qui mirroir le JSON-LD final. // state.type : string (ex: 'Recipe') // state.fields : objet imbriqué key → value | object | array // state.custom : array<{name, value}> de champs custom ajoutés // ----------------------------------------------------------------- var state = { type: '', fields: {}, custom: [] }; // Rendu d'un input simple selon field_meta.type. function renderInput(meta, value, path) { var t = meta.type || 'text'; var v = (value == null) ? '' : value; var attr = 'data-path="' + escHtml(path) + '"'; var input; if (t === 'textarea') { input = ''; } else if (t === 'select') { var opts = ''; Object.keys(meta.options || {}).forEach(function(k){ opts += ''; }); input = ''; } else if (t === 'string_array') { var lines = Array.isArray(v) ? v.join('\n') : (typeof v === 'string' ? v : ''); input = ''; } else if (t === 'number') { input = ''; } else if (t === 'date') { input = ''; } else if (t === 'datetime') { input = ''; } else if (t === 'tel') { input = ''; } else if (t === 'url') { input = ''; } else if (t === 'duration') { input = ''; } else { input = ''; } return input; } function renderField(name, meta, value, path, isRequired) { var label = meta.label || name; var description = meta.description || ''; var html = '
'; html += '
'; html += ''; html += '' + escHtml(name) + ''; html += '
'; if (meta.type === 'nested_object') { html += renderNestedObject(name, meta, value, path); } else if (meta.type === 'nested_array') { html += renderNestedArray(name, meta, value, path); } else { html += renderInput(meta, value, path); } if (description) { html += '

' + escHtml(description) + '

'; } html += '
'; return html; } function renderNestedObject(name, meta, value, path) { var nestedType = meta.nested_type; var nestedDef = NESTED_CATALOG[nestedType] || SCHEMA_CATALOG[nestedType]; if (!nestedDef) return '

' + escHtml(nestedType) + ' indisponible.

'; var v = (value && typeof value === 'object' && !Array.isArray(value)) ? value : {}; var html = '
'; html += ''; html += '' + escHtml(nestedType) + ''; html += '' + escHtml(nestedDef.description || '') + ''; html += ''; html += '
'; Object.keys(nestedDef.fields || {}).forEach(function(subKey){ var subMeta = nestedDef.fields[subKey]; html += renderField(subKey, subMeta, v[subKey], path + '.' + subKey, false); }); html += '
'; return html; } function renderNestedArray(name, meta, value, path) { var nestedType = meta.nested_type; var nestedDef = NESTED_CATALOG[nestedType] || SCHEMA_CATALOG[nestedType]; if (!nestedDef) return '

' + escHtml(nestedType) + ' indisponible.

'; var arr = Array.isArray(value) ? value : []; var itemLabel = meta.item_label || nestedType; var html = '
'; html += '
'; arr.forEach(function(item, idx){ html += renderArrayItem(itemLabel, idx, nestedDef, item, path + '[' + idx + ']'); }); html += '
'; html += ''; html += '
'; return html; } function renderArrayItem(itemLabel, idx, nestedDef, value, path) { var v = (value && typeof value === 'object' && !Array.isArray(value)) ? value : {}; var html = '
'; html += '
'; html += ''; html += ''; html += ''; html += '' + escHtml(itemLabel) + ' #' + (idx + 1) + ''; html += ''; html += ''; html += ''; html += '
'; html += '
'; Object.keys(nestedDef.fields || {}).forEach(function(subKey){ var subMeta = nestedDef.fields[subKey]; html += renderField(subKey, subMeta, v[subKey], path + '.' + subKey, false); }); html += '
'; html += '
'; return html; } function renderSchemaForm() { var $host = $('#aiforya-sm-builder-fields-host'); if (!state.type || !SCHEMA_CATALOG[state.type]) { $host.html(''); $('#aiforya-sm-builder-type-desc').text(L.selectionnezUnTypePour); return; } var def = SCHEMA_CATALOG[state.type]; $('#aiforya-sm-builder-type-desc').text(def.description || ''); var html = ''; // Section Required. if ((def.required || []).length) { html += '
'; html += '

' + escHtml(I18N.requiredFields) + ' ' + def.required.length + '

'; html += '
'; def.required.forEach(function(fieldKey){ var meta = (def.fields || {})[fieldKey]; if (!meta) return; html += renderField(fieldKey, meta, state.fields[fieldKey], fieldKey, true); }); html += '
'; } // Section Recommended. if ((def.recommended || []).length) { html += ''; } // Section Optional / Advanced. if ((def.optional || []).length) { html += '
'; html += '

' + escHtml(I18N.advancedFields) + ' ' + def.optional.length + '

'; html += '
'; def.optional.forEach(function(fieldKey){ var meta = (def.fields || {})[fieldKey]; if (!meta) return; html += renderField(fieldKey, meta, state.fields[fieldKey], fieldKey, false); }); html += '
'; } // Section Custom (always at end). html += '
'; html += '

' + escHtml(I18N.customField) + '

'; html += '
'; state.custom.forEach(function(c, idx){ html += '
'; html += ''; html += ''; html += ''; html += '
'; }); html += ''; html += '
'; $host.html(html); } // ----------------------------------------------------------------- // Path utilities — get/set valeur via path "a.b[3].c". // ----------------------------------------------------------------- function parsePath(path) { var tokens = []; var re = /([^.\[\]]+)|\[(\d+)\]/g; var m; while ((m = re.exec(path)) !== null) { tokens.push(m[2] !== undefined ? parseInt(m[2], 10) : m[1]); } return tokens; } function setByPath(root, path, value) { var tokens = parsePath(path); if (!tokens.length) return; var cur = root; for (var i = 0; i < tokens.length - 1; i++) { var k = tokens[i]; var next = tokens[i + 1]; if (typeof next === 'number') { if (!Array.isArray(cur[k])) cur[k] = []; } else { if (cur[k] == null || typeof cur[k] !== 'object' || Array.isArray(cur[k])) cur[k] = {}; } cur = cur[k]; } var last = tokens[tokens.length - 1]; if (value === '' || value == null || (Array.isArray(value) && !value.length)) { if (Array.isArray(cur)) { cur[last] = ''; } else { delete cur[last]; } } else { cur[last] = value; } } // ----------------------------------------------------------------- // Build JSON-LD final from state. // ----------------------------------------------------------------- function pruneEmpty(value) { if (Array.isArray(value)) { var arr = value.map(pruneEmpty).filter(function(v){ return v !== undefined && v !== null && v !== '' && !(typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0); }); return arr.length ? arr : undefined; } if (value && typeof value === 'object') { var out = {}; Object.keys(value).forEach(function(k){ var v = pruneEmpty(value[k]); if (v !== undefined) out[k] = v; }); return Object.keys(out).length ? out : undefined; } if (value === '' || value === null || value === undefined) return undefined; return value; } // Wrap nested objects with @type when missing (Person, Organization, Place, ...). function injectAtTypes(value, expectedType) { if (Array.isArray(value)) { return value.map(function(item){ return injectAtTypes(item, expectedType); }); } if (value && typeof value === 'object') { var out = {}; if (expectedType && !value['@type']) { out['@type'] = expectedType; } else if (value['@type']) { out['@type'] = value['@type']; } Object.keys(value).forEach(function(k){ if (k === '@type') return; out[k] = value[k]; }); return out; } return value; } // Walk schema_catalog/nested_catalog to know expected nested_type for each path. function buildJsonLd() { var pruned = pruneEmpty(state.fields) || {}; if (!state.type) return null; var rootDef = SCHEMA_CATALOG[state.type] || { fields: {} }; var jsonld = { '@context': 'https://schema.org', '@type': state.type }; Object.keys(pruned).forEach(function(key){ var meta = (rootDef.fields || {})[key]; var v = pruned[key]; if (meta && meta.type === 'nested_object' && meta.nested_type) { jsonld[key] = injectAtTypes(v, meta.nested_type); } else if (meta && meta.type === 'nested_array' && meta.nested_type) { var arr = Array.isArray(v) ? v : [v]; jsonld[key] = arr.map(function(item){ return injectAtTypes(item, meta.nested_type); }); } else if (meta && meta.type === 'string_array') { jsonld[key] = Array.isArray(v) ? v : String(v).split('\n').map(function(s){ return s.trim(); }).filter(Boolean); } else if (meta && meta.type === 'number' && v !== '') { jsonld[key] = Number(v); } else { jsonld[key] = v; } }); // Special: FAQPage.mainEntity[].acceptedAnswer string → wrap into Answer object. if (state.type === 'FAQPage' && Array.isArray(jsonld.mainEntity)) { jsonld.mainEntity = jsonld.mainEntity.map(function(q){ if (q && typeof q === 'object' && typeof q.acceptedAnswer === 'string') { q.acceptedAnswer = { '@type': 'Answer', text: q.acceptedAnswer }; } return q; }); } // Special: Review.reviewRating string → wrap into Rating object. ['Review'].forEach(function(t){ if (state.type === t && typeof jsonld.reviewRating === 'string') { jsonld.reviewRating = { '@type': 'Rating', ratingValue: jsonld.reviewRating }; } }); // Special: Product.review[].reviewRating string → wrap. if (Array.isArray(jsonld.review)) { jsonld.review = jsonld.review.map(function(rev){ if (rev && typeof rev === 'object' && typeof rev.reviewRating === 'string') { rev.reviewRating = { '@type': 'Rating', ratingValue: rev.reviewRating }; } return rev; }); } // Custom fields appended at end (non-typed scalars). state.custom.forEach(function(c){ var k = String(c.name || '').trim(); var v = String(c.value || '').trim(); if (k && v) { // Try parse JSON for richer values, fallback to string. try { jsonld[k] = JSON.parse(v); } catch(e) { jsonld[k] = v; } } }); return jsonld; } // ----------------------------------------------------------------- // Score qualité déterministe (complétude des champs, calcul local JS). // ----------------------------------------------------------------- function computeScore(jsonld) { if (!jsonld || !state.type || !SCHEMA_CATALOG[state.type]) return 0; var def = SCHEMA_CATALOG[state.type]; var requiredArr = def.required || []; var recommendedArr = def.recommended || []; var requiredCount = requiredArr.length || 1; var recommendedCount = recommendedArr.length || 0; var rPresent = 0; requiredArr.forEach(function(k){ if (jsonld[k] !== undefined && jsonld[k] !== '' && !(Array.isArray(jsonld[k]) && !jsonld[k].length)) rPresent++; }); var recPresent = 0; recommendedArr.forEach(function(k){ if (jsonld[k] !== undefined && jsonld[k] !== '' && !(Array.isArray(jsonld[k]) && !jsonld[k].length)) recPresent++; }); var score = (rPresent / requiredCount) * 70; if (recommendedCount > 0) { score += (recPresent / recommendedCount) * 25; } else { score += (rPresent / requiredCount) * 25; } // Structure bonus. if (state.type === 'FAQPage' && Array.isArray(jsonld.mainEntity)) { if (jsonld.mainEntity.length >= 5) score += 4; else if (jsonld.mainEntity.length >= 3) score += 2; } if (state.type === 'HowTo' && Array.isArray(jsonld.step)) { if (jsonld.step.length >= 5) score += 3; else if (jsonld.step.length >= 3) score += 2; } if (state.type === 'Recipe') { if (Array.isArray(jsonld.recipeIngredient) && jsonld.recipeIngredient.length >= 5) score += 2; if (Array.isArray(jsonld.recipeInstructions) && jsonld.recipeInstructions.length >= 4) score += 2; } return Math.max(0, Math.min(100, Math.round(score))); } function scoreStatus(score) { if (score >= 90) return 'excellent'; if (score >= 70) return 'good'; if (score >= 50) return 'partial'; return 'weak'; } // ----------------------------------------------------------------- // RE-RENDER preview (JSON-LD + score + validation). // ----------------------------------------------------------------- function renderPreview() { var jsonld = buildJsonLd(); var jsonStr = jsonld ? JSON.stringify(jsonld, null, 2) : '{}'; $('#aiforya-sm-builder-jsonld code').text(jsonStr); var score = computeScore(jsonld); var status = scoreStatus(score); var $score = $('#aiforya-sm-builder-score'); $score.attr('data-score', score).attr('data-status', status); $score.find('.aiforya-sm-builder-score-num').text(score); var circle = $score.find('.aiforya-sm-builder-score-circle')[0]; if (circle && circle.style && circle.style.setProperty) { circle.style.setProperty('--p', score + '%'); } // Validation messages. var $v = $('#aiforya-sm-builder-validation').empty(); if (!state.type) { $v.append('
' + escHtml(I18N.missingType) + '
'); return; } var def = SCHEMA_CATALOG[state.type]; var missing = (def.required || []).filter(function(k){ return jsonld[k] === undefined || jsonld[k] === '' || (Array.isArray(jsonld[k]) && !jsonld[k].length); }); missing.forEach(function(k){ $v.append('
' + escHtml(I18N.missingRequired + k) + '
'); }); if (!missing.length) { $v.append('
' + escHtml(I18N.validJson) + '
'); } } // ----------------------------------------------------------------- // Bind events. // ----------------------------------------------------------------- $('#aiforya-sm-builder-type').on('change', function(){ state.type = $(this).val(); state.fields = {}; state.custom = []; renderSchemaForm(); renderPreview(); }); // Live update sur tout input/textarea/select. $(document).on('input change', '.aiforya-sm-builder-input', function(){ var $el = $(this); var path = $el.data('path'); if (!path) return; var v; if ($el.data('array')) { v = $el.val().split('\n').map(function(s){ return s.trim(); }).filter(Boolean); } else if ($el.attr('type') === 'number') { var raw = $el.val(); v = raw === '' ? '' : Number(raw); } else { v = $el.val(); } setByPath(state.fields, path, v); renderPreview(); }); // Add nested array item. $(document).on('click', '.aiforya-sm-builder-array-add', function(){ var $wrap = $(this).closest('.aiforya-sm-builder-array-wrap'); var path = $wrap.data('array-path'); if (!Array.isArray(getByPath(state.fields, path))) { setByPath(state.fields, path, []); } var arr = getByPath(state.fields, path); arr.push({}); setByPath(state.fields, path, arr); renderSchemaForm(); renderPreview(); }); $(document).on('click', '.aiforya-sm-builder-array-remove', function(){ var $item = $(this).closest('.aiforya-sm-builder-array-item'); var $wrap = $item.closest('.aiforya-sm-builder-array-wrap'); var path = $wrap.data('array-path'); var idx = parseInt($item.data('index'), 10); var arr = getByPath(state.fields, path) || []; arr.splice(idx, 1); setByPath(state.fields, path, arr); renderSchemaForm(); renderPreview(); }); $(document).on('click', '.aiforya-sm-builder-array-up, .aiforya-sm-builder-array-down', function(){ var dir = $(this).hasClass('aiforya-sm-builder-array-up') ? -1 : 1; var $item = $(this).closest('.aiforya-sm-builder-array-item'); var $wrap = $item.closest('.aiforya-sm-builder-array-wrap'); var path = $wrap.data('array-path'); var idx = parseInt($item.data('index'), 10); var arr = getByPath(state.fields, path) || []; var newIdx = idx + dir; if (newIdx < 0 || newIdx >= arr.length) return; var tmp = arr[idx]; arr[idx] = arr[newIdx]; arr[newIdx] = tmp; setByPath(state.fields, path, arr); renderSchemaForm(); renderPreview(); }); function getByPath(root, path) { var tokens = parsePath(path); var cur = root; for (var i = 0; i < tokens.length; i++) { if (cur == null) return undefined; cur = cur[tokens[i]]; } return cur; } // Custom fields. $(document).on('click', '.aiforya-sm-builder-custom-add', function(){ state.custom.push({ name: '', value: '' }); renderSchemaForm(); renderPreview(); }); $(document).on('input', '.aiforya-sm-builder-custom-name, .aiforya-sm-builder-custom-value', function(){ var $row = $(this).closest('.aiforya-sm-builder-custom-row'); var idx = parseInt($row.data('custom-idx'), 10); if (!state.custom[idx]) return; state.custom[idx].name = $row.find('.aiforya-sm-builder-custom-name').val(); state.custom[idx].value = $row.find('.aiforya-sm-builder-custom-value').val(); renderPreview(); }); $(document).on('click', '.aiforya-sm-builder-custom-remove', function(){ var $row = $(this).closest('.aiforya-sm-builder-custom-row'); var idx = parseInt($row.data('custom-idx'), 10); state.custom.splice(idx, 1); renderSchemaForm(); renderPreview(); }); // ----------------------------------------------------------------- // Boutons droite : Copier / Tester Google / Reset. // ----------------------------------------------------------------- $('#aiforya-sm-builder-copy-btn').on('click', function(){ var txt = $('#aiforya-sm-builder-jsonld code').text(); if (!txt) return; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(txt).then(function(){ smToast(I18N.copied, 'success'); }, function(){ smToast(I18N.copyFailed, 'warning'); }); } else { var $ta = $('