Private, browser-local preflight
Structured Data Validator
Paste JSON-LD to find JSON syntax errors, duplicate keys, malformed JSON-LD keywords, and likely spelling mistakes in common Schema.org terms.
Your markup stays here. Validation runs in this browser. The tool does not upload code, fetch URLs, resolve remote contexts, or store your input.
1. Paste JSON-LD
Use raw JSON or one complete <script type="application/ld+json"> block. Limit: 200,000 code units.
Structured data
0 / 200,000 code units
Validate data
Reset
Example
Article
Product and offer
Local business
Organization graph
Load example
2. Local report
Paste markup, then choose Validate data.
— Errors
— Warnings
— Typed entities
— UTF-8 bytes
Show diagnostics
All severities
Errors only
Warnings only
Information only
No report yet
Diagnostics will include a severity, JSON path, and explanation.
Embedding-safe formatted JSON-LD
Formatted output
Formatting preserves the parsed JSON value and escapes every literal < as \u003C, preventing the output from forming an HTML script end tag when embedded.
Copy formatted JSON
Download .jsonld
What this validator checks
First it checks JSON grammar and duplicate object names. Then it inspects JSON-LD document shape and common keyword values such as @context, @type, @id, @graph, @value, and @list. With a Schema.org context, it also offers conservative spelling hints from an offline common-term snapshot.
How to read the report
Error: invalid JSON or a shape that conflicts with the local JSON-LD checks.
Warning: suspicious or incomplete markup worth reviewing.
Information: a limit of local analysis, including an unknown vocabulary term.
Exact JSON paths help you find nested values. Formatting changes whitespace and embedding-sensitive escaping only; it does not repair semantics. Before offering formatted output, the tool rejects non-finite, signed-zero, unsafe-integer, underflowed, or over-precise number tokens when the browser would serialize a different mathematical value.
Limits and final verification
This is not a full JSON-LD expansion or RDF processor. Context aliases, term definitions, containers, base/vocabulary resolution, framing forms, RDF conversion, and remote-context behavior are deliberately unchecked. It never reads a live page, checks visible-content parity, or applies a search engine's current feature rules. Unknown terms may be valid extensions or newer Schema.org releases.
Check the final markup with Schema.org Check relevant Google features separately
' && trimmed[end] !== '/') { searchAt = end; continue; }
while (end < trimmed.length && isHtmlSpace(trimmed[end])) end += 1;
if (trimmed[end] === '>') { closeStart = candidate; closeEnd = end + 1; break; }
throw new Error('The JSON-LD script closing tag is malformed.');
}
if (closeStart < openingEnd) throw new Error('The script wrapper must include a complete closing tag.');
if (trimmed.slice(closeEnd).trim() !== '') throw new Error('Only one script wrapper is accepted, with no content after its closing tag.');
const body = trimmed.slice(openingEnd,closeStart);
return body.trim();
};
const scanJson = (text) => {
let at = 0;
let tokens = 0;
const duplicates = [];
const numbers = [];
const fail = (message) => { const error = new Error(message); error.position = at; throw error; };
const bump = () => { tokens += 1; if (tokens > MAX_TOKENS) fail('Token limit exceeded (10,000). Split the markup into smaller blocks.'); };
const ws = () => { while (at < text.length && /[\t\n\r ]/.test(text[at])) at += 1; };
const string = () => {
if (text[at] !== '"') fail('Expected a JSON string.');
const start = at++;
while (at < text.length) {
const c = text[at++];
if (c === '"') {
const raw = text.slice(start, at);
try { return JSON.parse(raw); } catch (_error) { fail('Invalid escape or control character in string.'); }
}
if (c === '\\') { if (at >= text.length) fail('Unterminated escape sequence.'); at += 1; }
else if (c.charCodeAt(0) < 32) fail('Unescaped control character in string.');
}
fail('Unterminated JSON string.');
};
const value = (depth, path) => {
if (depth > MAX_DEPTH) fail('Nesting depth exceeds 64.');
ws(); bump();
const c = text[at];
if (c === '{') return object(depth, path);
if (c === '[') return array(depth, path);
if (c === '"') { string(); return; }
const literal = text.slice(at).match(/^(?:true|false|null)/);
if (literal) { at += literal[0].length; return; }
const number = text.slice(at).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
if (number) {
if (number[0].length > MAX_NUMBER_LENGTH) fail('A JSON number exceeds the 1,000-character safety limit.');
numbers.push({path, lexeme:number[0]});
at += number[0].length;
return;
}
fail('Expected a JSON value.');
};
const object = (depth, path) => {
at += 1; ws(); const names = new Set();
if (text[at] === '}') { at += 1; return; }
while (at < text.length) {
ws(); const key = string(); bump(); const childPath = pathFor(path, key);
if (names.has(key)) duplicates.push({path:childPath, key});
names.add(key); ws();
if (text[at++] !== ':') fail('Expected a colon after an object name.');
value(depth + 1, childPath); ws();
if (text[at] === '}') { at += 1; return; }
if (text[at++] !== ',') fail('Expected a comma or closing brace.');
}
fail('Unclosed object.');
};
const array = (depth, path) => {
at += 1; ws(); let index = 0;
if (text[at] === ']') { at += 1; return; }
while (at < text.length) {
value(depth + 1, path + '[' + index + ']'); index += 1; ws();
if (text[at] === ']') { at += 1; return; }
if (text[at++] !== ',') fail('Expected a comma or closing bracket.');
}
fail('Unclosed array.');
};
ws(); value(0, '$'); ws();
if (at !== text.length) fail('Unexpected content after the JSON value.');
return {duplicates, numbers, tokens};
};
const exactDecimalKey = (lexeme) => {
const match = lexeme.match(/^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/);
if (!match) return null;
const negative = match[1] === '-';
const fraction = match[3] || '';
let digits = (match[2] + fraction).replace(/^0+/, '');
if (!digits) return '0';
let exponent;
try { exponent = BigInt(match[4] || '0') - BigInt(fraction.length); }
catch (_error) { return null; }
const trailing = (digits.match(/0+$/) || [''])[0].length;
if (trailing) { digits = digits.slice(0, -trailing); exponent += BigInt(trailing); }
return (negative ? '-' : '') + digits + 'e' + exponent.toString();
};
const numberPreservesValue = (lexeme) => {
const parsed = Number(lexeme);
if (!Number.isFinite(parsed)) return false;
if (Object.is(parsed, -0)) return false;
if (Number.isInteger(parsed) && !Number.isSafeInteger(parsed)) return false;
const serialized = JSON.stringify(parsed);
if (typeof serialized !== 'string') return false;
const inputKey = exactDecimalKey(lexeme);
const outputKey = exactDecimalKey(serialized);
return inputKey !== null && inputKey === outputKey;
};
const inspectContext = (value, path) => {
const valid = value === null || typeof value === 'string' || isObject(value) || (Array.isArray(value) && value.every((part) => part === null || typeof part === 'string' || isObject(part)));
if (!valid) add('error', path, '@context must be null, a string, an object, or an array containing those values.');
const remote = typeof value === 'string' ? [value] : Array.isArray(value) ? value.filter((part) => typeof part === 'string') : [];
remote.forEach((iri) => { if (!isAbsoluteIri(iri)) add('warning', path, 'A string @context is normally an absolute IRI. This tool does not fetch it.'); });
};
const inspect = (data) => {
if (!(isObject(data) || Array.isArray(data))) { add('error', '$', 'A JSON-LD document must have an object or array at the top level, not a primitive or null.'); return; }
if (Array.isArray(data) && data.length === 0) add('warning', '$', 'The top-level array is empty.');
const rootSchema = isObject(data) && Object.prototype.hasOwnProperty.call(data,'@context') ? schemaContext(data['@context'],false) : false;
const stack = [{value:data,path:'$',depth:0,schema:rootSchema,expected:true,root:true}];
let visits = 0;
let maxDepth = 0;
let cappedEntities = false;
while (stack.length) {
const item = stack.pop();
visits += 1;
if (visits > MAX_VISITS) { add('error', item.path, 'Traversal limit exceeded (10,000 values and properties). Split the markup.'); break; }
if (item.depth > MAX_DEPTH) { add('error', item.path, 'Nesting depth exceeds 64.'); continue; }
maxDepth = Math.max(maxDepth, item.depth);
if (Array.isArray(item.value)) {
item.value.forEach((child, index) => stack.push({value:child,path:item.path+'['+index+']',depth:item.depth+1,schema:item.schema,expected:item.expected,root:false}));
continue;
}
if (!isObject(item.value)) {
if (item.expected) add('error', item.path, 'A top-level or @graph item must be an object.');
continue;
}
const object = item.value;
const hasLocalContext = Object.prototype.hasOwnProperty.call(object,'@context');
const localSchema = hasLocalContext ? schemaContext(object['@context'],item.schema) : item.schema;
const keys = Object.keys(object);
if (Object.prototype.hasOwnProperty.call(object, '@context')) inspectContext(object['@context'], pathFor(item.path,'@context'));
if (item.root && !Object.prototype.hasOwnProperty.call(object,'@context')) add('warning', item.path, 'No top-level @context was found. A standalone Schema.org block normally declares one.');
if (item.expected && !Object.prototype.hasOwnProperty.call(object,'@type') && !Object.prototype.hasOwnProperty.call(object,'@graph')) add('warning', item.path, 'This node has neither @type nor @graph. Confirm that its meaning comes from an enclosing property.');
const isValueObject = Object.prototype.hasOwnProperty.call(object,'@value');
const isListObject = Object.prototype.hasOwnProperty.call(object,'@list');
const isSetObject = Object.prototype.hasOwnProperty.call(object,'@set');
if (Object.prototype.hasOwnProperty.call(object,'@type') && !isValueObject) {
const rawTypes = Array.isArray(object['@type']) ? object['@type'] : [object['@type']];
if (object['@type'] !== null && rawTypes.some((type) => typeof type !== 'string' || !type.trim())) add('error', pathFor(item.path,'@type'), '@type must be null, a non-empty string, or an array of non-empty strings.');
else {
if (object['@type'] !== null && rawTypes.length && !isListObject && !isSetObject) {
if (entities.length < MAX_ENTITIES) entities.push({path:item.path,types:rawTypes.slice(0,8),name:typeof object.name === 'string' ? object.name.slice(0,120) : '',id:typeof object['@id'] === 'string' ? object['@id'].slice(0,180) : '',properties:keys.filter((key) => !key.startsWith('@')).length});
else if (!cappedEntities) { add('error', item.path, 'Typed entity limit exceeded (500). Split the markup.'); cappedEntities = true; }
}
if (localSchema && object['@type'] !== null) rawTypes.forEach((type) => {
if (/^[A-Za-z][A-Za-z0-9]*$/.test(type) && !knownTypes.has(type)) {
const suggestion = typeByLower.get(type.toLowerCase());
add(suggestion ? 'warning' : 'info', pathFor(item.path,'@type'), suggestion ? 'Schema.org type names are case-sensitive. Did you mean '+suggestion+'?' : '“'+type+'” is not in this tool’s common-type snapshot. It may be valid, newer, or an extension; check Schema.org.');
}
});
}
}
if (Object.prototype.hasOwnProperty.call(object,'@id')) {
if (typeof object['@id'] !== 'string' || !object['@id'].trim()) add('error', pathFor(item.path,'@id'), '@id must be a non-empty string.');
else if (!isAbsoluteIri(object['@id']) && !object['@id'].startsWith('_:')) add('info', pathFor(item.path,'@id'), 'This relative or compact @id depends on the active context/base; it was not resolved locally.');
}
if (Object.prototype.hasOwnProperty.call(object,'@graph') && !(isObject(object['@graph']) || Array.isArray(object['@graph']))) add('error', pathFor(item.path,'@graph'), '@graph must contain an object or array of objects.');
if (Object.prototype.hasOwnProperty.call(object,'@included') && !(isObject(object['@included']) || Array.isArray(object['@included']))) add('error', pathFor(item.path,'@included'), '@included must contain an object or array of objects.');
if (isListObject) {
const extra = keys.filter((key) => key !== '@list' && key !== '@index');
if (extra.length) add('error', item.path, 'A list object can contain only @list and optional @index.');
}
if (isSetObject) {
const extra = keys.filter((key) => key !== '@set' && key !== '@index');
if (extra.length) add('error', item.path, 'A set object can contain only @set and optional @index.');
}
if (Object.prototype.hasOwnProperty.call(object,'@reverse') && !isObject(object['@reverse'])) add('error', pathFor(item.path,'@reverse'), '@reverse must contain an object.');
if (Object.prototype.hasOwnProperty.call(object,'@language') && object['@language'] !== null && (typeof object['@language'] !== 'string' || !/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(object['@language']))) add('error', pathFor(item.path,'@language'), '@language must be null or a non-empty BCP 47-style tag using letters, digits, and hyphen-separated subtags.');
if (Object.prototype.hasOwnProperty.call(object,'@direction') && object['@direction'] !== null && object['@direction'] !== 'ltr' && object['@direction'] !== 'rtl') add('error', pathFor(item.path,'@direction'), '@direction must be null, “ltr”, or “rtl”.');
if (Object.prototype.hasOwnProperty.call(object,'@index') && typeof object['@index'] !== 'string') add('error', pathFor(item.path,'@index'), '@index must be a string.');
if (isValueObject) {
const allowed = new Set(['@value','@type','@language','@direction','@index','@context']);
const extra = keys.filter((key) => !allowed.has(key));
if (extra.length) add('error', item.path, 'A value object contains properties that are not allowed with @value: '+extra.slice(0,4).join(', ')+'.');
if (Object.prototype.hasOwnProperty.call(object,'@type') && (Object.prototype.hasOwnProperty.call(object,'@language') || Object.prototype.hasOwnProperty.call(object,'@direction'))) add('error', item.path, 'A value object cannot combine @type with @language or @direction.');
if (Object.prototype.hasOwnProperty.call(object,'@type') && object['@type'] !== null && (typeof object['@type'] !== 'string' || !object['@type'].trim())) add('error', pathFor(item.path,'@type'), 'The @type in a value object must be null or one non-empty string.');
if ((object['@language'] !== undefined && object['@language'] !== null || object['@direction'] !== undefined && object['@direction'] !== null) && object['@value'] !== null && typeof object['@value'] !== 'string') add('error', pathFor(item.path,'@value'), 'A language-tagged or direction-tagged @value must be null or a string.');
if ((isObject(object['@value']) || Array.isArray(object['@value'])) && object['@type'] !== '@json') add('error', pathFor(item.path,'@value'), 'An object or array @value requires @type set to @json.');
} else if (Object.prototype.hasOwnProperty.call(object,'@language') || Object.prototype.hasOwnProperty.call(object,'@direction')) {
add('error', item.path, '@language and @direction are only valid here as members of a value object containing @value.');
}
keys.forEach((key) => {
const childPath = pathFor(item.path,key);
if (key.startsWith('@') && !keywords.has(key)) add('warning', childPath, 'Unknown @-keyword. JSON-LD processors ignore unrecognized keywords; check the spelling.');
if (!key.startsWith('@') && localSchema && /^[A-Za-z][A-Za-z0-9-]*$/.test(key) && !knownProperties.has(key)) {
const suggestion = propertyByLower.get(key.toLowerCase());
add(suggestion ? 'warning' : 'info', childPath, suggestion ? 'Schema.org property names are case-sensitive. Did you mean '+suggestion+'?' : '“'+key+'” is not in this tool’s common-property snapshot. It may still be valid; check its type on Schema.org.');
}
if (!key) add('warning', childPath, 'An empty property name is difficult to interpret.');
const child = object[key];
if (key !== '@context' && key !== '@value' && (isObject(child) || Array.isArray(child))) stack.push({value:child,path:childPath,depth:item.depth+1,schema:localSchema,expected:key === '@graph' || key === '@included',root:false});
});
}
return {visits,maxDepth};
};
const clearFieldError = () => { input.removeAttribute('aria-invalid'); inputError.textContent = ''; };
const setFieldError = (message) => { input.setAttribute('aria-invalid','true'); inputError.textContent = message; };
const setMetric = (node, value) => { node.textContent = String(value); };
const commitOutput = (value) => {
const next = typeof value === 'string' ? value : '';
if (output.value !== next) { outputIdentity += 1; copyAttemptToken += 1; }
normalized = next;
output.value = next;
normalizedWrap.hidden = !next;
};
const invalidate = (message) => {
diagnostics = []; diagnosticOverflow = false; entities = []; commitOutput('');
filter.disabled = true; issuesList.replaceChildren(); entityList.replaceChildren(); entitiesWrap.hidden = true;
setMetric(errorCount,'—'); setMetric(warningCount,'—'); setMetric(entityCount,'—'); setMetric(byteCount,'—');
empty.hidden = false; empty.textContent = 'Results cleared because the input changed. Validate the current markup.'; shown.textContent = 'No current report';
status.dataset.level = ''; status.textContent = message || 'Input changed. Validate again for a current report.';
};
const renderDiagnostics = (announceFilter) => {
const wanted = Object.prototype.hasOwnProperty.call(filterLabels,filter.value) ? filter.value : 'all';
const visible = wanted === 'all' ? diagnostics : diagnostics.filter((item) => item.level === wanted);
issuesList.replaceChildren();
visible.forEach((item) => {
const li = document.createElement('li'); li.className = 'wm-structured-data-validator-issue'; li.dataset.level = item.level;
const title = document.createElement('p'); title.className = 'wm-structured-data-validator-issue-title'; title.textContent = item.level.charAt(0).toUpperCase()+item.level.slice(1);
const path = document.createElement('code'); path.className = 'wm-structured-data-validator-issue-path'; path.textContent = item.path;
const message = document.createElement('p'); message.className = 'wm-structured-data-validator-issue-message'; message.textContent = item.message;
li.append(title,path,message); issuesList.append(li);
});
empty.hidden = visible.length > 0;
empty.textContent = diagnostics.length ? 'No diagnostics match this filter.' : 'No issues found by the bounded local checks.';
shown.textContent = visible.length+' of '+diagnostics.length+' diagnostics shown';
if (announceFilter) {
const filterRevision = revision;
const message = filterLabels[wanted]+': showing '+visible.length+' of '+diagnostics.length+' diagnostics.';
status.dataset.level=''; status.textContent='';
requestAnimationFrame(() => { if (filterRevision === revision && !filter.disabled) status.textContent=message; });
}
};
const renderEntities = () => {
entityList.replaceChildren();
entities.slice(0,MAX_ENTITIES).forEach((entity) => {
const li = document.createElement('li'); li.className = 'wm-structured-data-validator-entity';
const pieces = [entity.types.join(' + '), entity.path, entity.properties+' properties'];
if (entity.name) pieces.push('name: '+entity.name);
if (entity.id) pieces.push('@id: '+entity.id);
li.textContent = pieces.join(' · '); entityList.append(li);
});
entitiesWrap.hidden = entities.length === 0;
};
const positionMessage = (error, sourceText) => {
const match = String(error && error.message || '').match(/position\s+(\d+)/i);
if (!match) return String(error && error.message || 'Invalid JSON.');
const position = Math.min(Number(match[1]), sourceText.length);
const before = sourceText.slice(0,position);
const lines = before.split(/\r\n?|\n/);
return String(error.message)+' (line '+lines.length+', column '+(lines[lines.length-1].length+1)+').';
};
const utf8Bytes = (text) => typeof TextEncoder === 'function' ? new TextEncoder().encode(text).length : new Blob([text]).size;
const validate = (focusResults) => {
revision += 1; clearFieldError(); diagnostics = []; diagnosticOverflow = false; entities = []; normalized = '';
const source = input.value;
inputCount.textContent = source.length.toLocaleString()+' / 200,000 code units';
if (source.length > LIMIT) { setFieldError('Input exceeds the 200,000-code-unit limit.'); invalidate('Input is too large. Shorten or split it before validation.'); setFieldError('Input exceeds the 200,000-code-unit limit.'); input.focus(); return; }
if (!source.trim()) { setFieldError('Paste JSON-LD before validating.'); invalidate('Nothing to validate. Paste a JSON-LD object or array.'); setFieldError('Paste JSON-LD before validating.'); input.focus(); return; }
let text;
try { text = unwrap(source); } catch (error) { add('error','$',String(error.message)); finish(null,utf8Bytes(source),focusResults); setFieldError(String(error.message)); input.focus(); return; }
if (!text) { add('error','$','The JSON-LD script block is empty.'); finish(null,utf8Bytes(source),focusResults); setFieldError('The JSON-LD script block is empty.'); input.focus(); return; }
let scan;
try { scan = scanJson(text); } catch (error) {
let parsedError = error;
try { JSON.parse(text); } catch (nativeError) { parsedError = nativeError; }
add('error','$',positionMessage(parsedError,text)); finish(null,utf8Bytes(text),focusResults); setFieldError('JSON syntax or safety limits must be fixed before structure checks can run.'); input.focus(); return;
}
scan.duplicates.forEach((duplicate) => add('error',duplicate.path,'Duplicate object name “'+duplicate.key+'”. JSON-LD requires unique names; native parsers may silently keep only the last value.'));
scan.numbers.forEach((number) => {
if (!numberPreservesValue(number.lexeme)) add('error',number.path,'This JSON number cannot be parsed and formatted by this browser without changing its mathematical value. Quote it when it is an identifier, or use a value with safely preserved precision and range.');
});
let data;
try { data = JSON.parse(text); } catch (error) { add('error','$',positionMessage(error,text)); finish(null,utf8Bytes(text),focusResults); setFieldError('JSON syntax must be fixed before structure checks can run.'); input.focus(); return; }
inspect(data);
normalized = scan.duplicates.length || scan.numbers.some((number) => !numberPreservesValue(number.lexeme)) ? '' : JSON.stringify(data,null,2).replace(/ {
const errors = diagnostics.filter((item) => item.level === 'error').length;
const warnings = diagnostics.filter((item) => item.level === 'warning').length;
setMetric(errorCount,errors); setMetric(warningCount,warnings); setMetric(entityCount,entities.length); setMetric(byteCount,bytes.toLocaleString());
filter.disabled = false; renderDiagnostics(false); renderEntities();
commitOutput(normalized);
if (errors) { status.dataset.level='error'; status.textContent='Local validation found '+errors+' error'+(errors===1?'':'s')+' and '+warnings+' warning'+(warnings===1?'':'s')+'. This is not a search-eligibility verdict.'; }
else if (warnings) { status.dataset.level='warning'; status.textContent='JSON parsed and local checks found no errors, with '+warnings+' warning'+(warnings===1?'':'s')+' to review. This is not a search-eligibility verdict.'; }
else { status.dataset.level='good'; status.textContent='JSON parsed and passed the bounded local checks. This does not prove full JSON-LD conformance, Schema.org validity, or search eligibility.'; }
if (diagnosticOverflow) status.textContent += ' The report is capped at 100 diagnostics, with higher severities retained.';
if (data !== null && focusResults) resultsHeading.focus();
};
const copyOutput = async () => {
const attemptToken = ++copyAttemptToken;
const attempted = normalized;
const attemptedIdentity = outputIdentity;
const isAttemptCurrent = () => Boolean(attempted) && attemptedIdentity === outputIdentity && attempted === normalized && attempted === output.value;
const ownsAttempt = () => attemptToken === copyAttemptToken;
if (!attempted) { status.dataset.level='error'; status.textContent='There is no current formatted output to copy.'; return; }
let copied = false;
let selected = false;
try { if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') throw new Error('Clipboard API unavailable'); await navigator.clipboard.writeText(attempted); if (!ownsAttempt()) return; copied = true; }
catch (_error) {
if (!ownsAttempt()) return;
if (!isAttemptCurrent()) { status.dataset.level='error'; status.textContent='Copy did not complete, and the formatted output changed or was cleared. Validate the current markup again.'; return; }
const ownerDocument = output.ownerDocument || document;
try {
output.focus();
if (!ownsAttempt() || !isAttemptCurrent()) return;
output.select();
selected = Boolean(attempted) && ownsAttempt() && isAttemptCurrent() && ownerDocument.activeElement === output && typeof output.selectionStart === 'number' && typeof output.selectionEnd === 'number' && output.selectionStart === 0 && output.selectionEnd === attempted.length;
}
catch (_selectionError) { selected = false; }
if (selected) {
try { copied = typeof ownerDocument.execCommand === 'function' && ownerDocument.execCommand('copy'); } catch (_fallbackError) { copied = false; }
}
}
if (!ownsAttempt()) return;
if (!isAttemptCurrent()) { status.dataset.level = copied ? 'warning' : 'error'; status.textContent = copied ? 'Earlier formatted JSON was copied; the formatted output changed or was cleared.' : 'Copy failed, and the formatted output changed or was cleared.'; return; }
status.dataset.level = copied ? 'good' : 'error';
status.textContent = copied ? 'Formatted JSON copied.' : selected ? 'Automatic copy failed. The exact formatted output is selected for manual copying.' : 'Automatic copy and selection failed. Select the visible formatted output manually.';
};
const removePendingDownload = (record) => {
const index = pendingDownloads.indexOf(record);
if (index >= 0) pendingDownloads.splice(index, 1);
};
const reportCleanupWarning = () => {
cleanupWarning = true;
downloadCleanupStatus.hidden = false;
downloadCleanupStatus.textContent = 'Temporary download cleanup could not be fully confirmed. Further downloads are bounded; reloading the page lets the browser reclaim remaining temporary resources.';
};
const cleanupDownload = (record, revokeNow) => {
let complete = true;
if (record.anchor) {
try { if (typeof record.anchor.remove === 'function') record.anchor.remove(); } catch (_removeError) { /* Try the owning parent below. */ }
if (record.anchor.parentNode) {
try { if (typeof record.anchor.parentNode.removeChild !== 'function') throw new Error('No parent removal method'); record.anchor.parentNode.removeChild(record.anchor); }
catch (_parentRemoveError) { complete = false; }
}
if (!record.anchor.parentNode) record.anchor = null;
else complete = false;
}
if (revokeNow && record.url) {
try { URL.revokeObjectURL(record.url); record.url = ''; }
catch (_revokeError) { complete = false; }
}
if (!record.anchor && !record.url) removePendingDownload(record);
if (!complete) reportCleanupWarning();
return complete;
};
const retryPendingDownloads = () => { pendingDownloads.slice().forEach((record) => cleanupDownload(record, record.revokeReady)); };
const downloadOutput = () => {
if (!normalized) { status.dataset.level='error'; status.textContent='There is no current formatted output to download.'; return; }
retryPendingDownloads();
if (pendingDownloads.length >= MAX_PENDING_DOWNLOADS) { status.dataset.level='error'; status.textContent='Another download was not created because four temporary downloads still await browser cleanup. Wait a moment, copy the formatted JSON, or reload the page.'; return; }
const record = {url:'', anchor:null,revokeReady:false};
let started = false;
try {
const blob = new Blob([normalized+'\n'],{type:'application/ld+json;charset=utf-8'});
const createdUrl = URL.createObjectURL(blob);
if (typeof createdUrl !== 'string' || !createdUrl) throw new Error('The browser returned no usable object URL.');
record.url = createdUrl;
pendingDownloads.push(record);
record.anchor = document.createElement('a'); record.anchor.href=record.url; record.anchor.download='structured-data.jsonld'; record.anchor.hidden=true; wmStructuredDataValidatorRoot.append(record.anchor); record.anchor.click(); started = true;
cleanupDownload(record, false);
const release = () => { record.revokeReady = true; cleanupDownload(record, true); };
try { setTimeout(release,0); }
catch (_timerError) { record.revokeReady = true; cleanupDownload(record, true); }
status.dataset.level = cleanupWarning ? 'warning' : 'good';
status.textContent = cleanupWarning ? 'Download started for structured-data.jsonld, but temporary cleanup could not be fully confirmed.' : 'Download started for structured-data.jsonld.';
} catch (_error) {
cleanupDownload(record, true);
status.dataset.level = started ? 'warning' : 'error';
status.textContent = started ? 'The download was started, but later browser cleanup could not be confirmed. Copy the formatted JSON if no file appears.' : 'The browser could not start the download. Copy the formatted JSON instead.';
}
};
input.addEventListener('input', () => { revision += 1; clearFieldError(); inputCount.textContent=input.value.length.toLocaleString()+' / 200,000 code units'; invalidate('Input changed. Validate again for a current report.'); });
input.addEventListener('keydown', (event) => { if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); validate(true); } });
validateButton.addEventListener('click', () => validate(true));
resetButton.addEventListener('click', () => { revision += 1; input.value=''; example.value='article'; filter.value='all'; clearFieldError(); inputCount.textContent='0 / 200,000 code units'; invalidate('Reset complete. Paste markup or load an example.'); empty.textContent='Diagnostics will include a severity, JSON path, and explanation.'; input.focus(); });
loadButton.addEventListener('click', () => { revision += 1; input.value=JSON.stringify(examples[example.value] || examples.article,null,2); input.dispatchEvent(new Event('input')); validate(false); input.focus(); input.setSelectionRange(0,0); });
filter.addEventListener('change', () => renderDiagnostics(true));
copyButton.addEventListener('click', copyOutput);
downloadButton.addEventListener('click', downloadOutput);
})();