$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 += '
';
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 += '
';
def.recommended.forEach(function(fieldKey){
var meta = (def.fields || {})[fieldKey];
if (!meta) return;
html += renderField(fieldKey, meta, state.fields[fieldKey], fieldKey, false);
});
html += '
';
}
// Section Optional / Advanced.
if ((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('