Validates Watson SOP content based on standardized rules, accessibility, and CSS compliance
// ==UserScript==
// @name Content Validator
// @namespace https://github.com/MajaBukvic/Scripts
// @version 3.1
// @description Validates Watson SOP content based on standardized rules, accessibility, and CSS compliance
// @author Maja Bukvic
// @match https://share.amazon.com/sites/amazonwatson/*
// @grant GM_download
// @license MIT
// @supportURL https://github.com/MajaBukvic/Scripts/issues
// @homepage https://github.com/MajaBukvic/Scripts/tree/main/Content-validator
// ==/UserScript==
(function() {
'use strict';
// ===========================================
// USAGE LOGGING CONFIGURATION
// ===========================================
const LOG_CONFIG = {
siteUrl: 'https://share.amazon.com/sites/amazonwatson',
listName: 'TampermonkeyUsageLog',
entityType: 'SP.Data.TampermonkeyUsageLogListItem',
scriptName: 'Content Validator',
// Internal column names from SharePoint
columns: {
title: 'Title',
username: 'tg5f',
action: 'aflk',
pageUrl: 'k3hk',
scriptName: 'pk8k'
}
};
// ===========================================
// USAGE LOGGING FUNCTIONS
// ===========================================
function logUsage(action) {
try {
fetch(LOG_CONFIG.siteUrl + '/_api/contextinfo', {
method: 'POST',
headers: { 'Accept': 'application/json;odata=verbose' },
credentials: 'include'
})
.then(function(r) { return r.json(); })
.then(function(digestData) {
var digest = digestData.d.GetContextWebInformation.FormDigestValue;
var listUrl = LOG_CONFIG.siteUrl + "/_api/web/lists/getbytitle('" + LOG_CONFIG.listName + "')/items";
var itemData = {
'__metadata': { 'type': LOG_CONFIG.entityType }
};
// Use internal column names
itemData[LOG_CONFIG.columns.title] = new Date().toISOString();
itemData[LOG_CONFIG.columns.username] = getLogUsername();
itemData[LOG_CONFIG.columns.action] = action;
itemData[LOG_CONFIG.columns.pageUrl] = window.location.href.substring(0, 250);
itemData[LOG_CONFIG.columns.scriptName] = LOG_CONFIG.scriptName;
return fetch(listUrl, {
method: 'POST',
headers: {
'Accept': 'application/json;odata=verbose',
'Content-Type': 'application/json;odata=verbose',
'X-RequestDigest': digest
},
credentials: 'include',
body: JSON.stringify(itemData)
});
})
.then(function(r) {
if (r.ok) {
console.log('Usage logged:', action);
}
})
.catch(function(e) {
// Fail silently - don't break the main script
console.log('Usage logging skipped');
});
} catch (e) {
// Fail silently
}
}
function getLogUsername() {
// Try SharePoint context first
if (typeof _spPageContextInfo !== 'undefined') {
if (_spPageContextInfo.userDisplayName) {
return _spPageContextInfo.userDisplayName;
}
if (_spPageContextInfo.userLoginName) {
return _spPageContextInfo.userLoginName;
}
}
// Try common page elements
var selectors = ['.ms-core-menu-title', '#USER_NAME', '.user-name', '#userDisplayName'];
for (var i = 0; i < selectors.length; i++) {
var el = document.querySelector(selectors[i]);
if (el && el.textContent.trim()) {
return el.textContent.trim();
}
}
return 'unknown';
}
// ===========================================
// CONFIGURATION
// ===========================================
const CONFIG = {
// Core stylesheets - at least ONE must be present
coreStylesheets: [
'watson-sop-standard.css',
'interactive_features.css',
'buttons.css',
'function_menu.css'
],
// Table stylesheets - required if tables exist
tableStylesheets: [
'grey_2_tone.css',
'whitetable.css',
'keytermstable.css',
'definitiontable.css'
],
// System stylesheets to ignore
systemStylesheets: [
'madcap',
'corev15',
'core.css',
'controls',
'search',
'sprite',
'skinsupport'
],
// Standard button classes
buttonClasses: [
'button--1', 'button--2', 'button--3', 'button--4',
'animated_button_red', 'animated_button_green',
'collapsebtn', 'expandbtn', 'pulsing_button',
'update-button', 'collapsible', 'collapsible-hide',
'tablinks', 'dropbtn', 'filter-button'
],
// Standard callout classes
calloutClasses: [
'example', 'exception', 'bestPractice', 'note', 'tip',
'warning', 'important', 'SOPupdate', 'quote',
'annotation', 'accessibility', 'template'
],
// Standard image classes
imageClasses: [
'flowchart', 'zoom', 'flag', 'icon', 'no_border', 'tiny'
],
// Standard colored margin classes
coloredMarginClasses: [
'green', 'blue', 'orange', 'pink', 'red', 'yellow',
'purple', 'darkblue', 'darkgreen', 'hotpink', 'aqua', 'sage'
],
// Standard emphasis box classes
emphasisBoxClasses: [
'orangeBox', 'blueBox', 'greyBox', 'inkBox'
],
// Standard list classes
listClasses: [
'disc', 'Square', 'Circle', 'Important_reminders'
],
// Non-standard fonts to flag
nonStandardFonts: [
'Arial', 'Helvetica', 'Calibri', 'Times New Roman',
'Verdana', 'Georgia', 'Tahoma', 'Comic Sans'
],
// Deprecated HTML attributes
deprecatedAttributes: [
{ attr: 'align', suggestion: 'Use CSS text-align or margin instead' },
{ attr: 'valign', suggestion: 'Use CSS vertical-align instead' },
{ attr: 'bgcolor', suggestion: 'Use CSS background-color instead' },
{ attr: 'hspace', suggestion: 'Use CSS margin instead' },
{ attr: 'vspace', suggestion: 'Use CSS margin instead' },
{ attr: 'nowrap', suggestion: 'Use CSS white-space: nowrap instead' },
{ attr: 'background', suggestion: 'Use CSS background-image instead' }
]
};
// ===========================================
// INITIALIZATION
// ===========================================
function addValidationButton() {
const menuContainer = document.querySelector('div[style*="position:relative;float:right;right:25px;padding-top:7px;"]');
if (menuContainer) {
const span = document.createElement('span');
const link = document.createElement('a');
link.className = 'watson-menu-link';
link.href = '#';
link.style.cursor = 'pointer';
link.innerHTML = '🔍 Validate Content';
link.addEventListener('click', (e) => {
e.preventDefault();
validateAndExport();
});
span.appendChild(link);
const exportContainer = document.getElementById('exportLinkContainer');
if (exportContainer) {
exportContainer.parentNode.insertBefore(span, exportContainer.nextSibling);
} else {
menuContainer.appendChild(span);
}
}
}
function initScript() {
addValidationButton();
// Log script load
logUsage('script_loaded');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initScript);
} else {
initScript();
}
// ===========================================
// MAIN VALIDATION FUNCTION
// ===========================================
function validateAndExport() {
// Log validation start
logUsage('validation_started');
const issues = [];
console.log('=== Content Validator v3.1 Starting ===');
// Find ALL content containers
const allContentContainers = document.querySelectorAll('div.ms-rtestate-field');
console.log('Validator: Found', allContentContainers.length, 'ms-rtestate-field containers');
if (allContentContainers.length === 0) {
alert('Could not find any div.ms-rtestate-field containers on this page!');
return;
}
// Find WatsonSOPBody and WatsonSOPSource
let watsonBody = null;
let watsonSource = null;
// Search all containers
allContentContainers.forEach((container, index) => {
const body = container.querySelector('.WatsonSOPBody');
if (body) {
console.log('Validator: Found WatsonSOPBody in container', index);
watsonBody = body;
}
const source = container.querySelector('#WatsonSOPSource');
if (source) {
watsonSource = source;
}
});
// Fallback: search entire document
if (!watsonBody) {
watsonBody = document.querySelector('.WatsonSOPBody');
}
if (!watsonSource) {
watsonSource = document.querySelector('#WatsonSOPSource');
}
console.log('Validator: WatsonSOPBody found:', !!watsonBody);
console.log('Validator: WatsonSOPSource found:', !!watsonSource);
// ===========================================
// STYLESHEET VALIDATION
// ===========================================
validateStylesheets(allContentContainers, watsonBody, issues);
// ===========================================
// WATSON SOP SOURCE VALIDATION
// ===========================================
validateWatsonSOPSource(watsonSource, allContentContainers, issues);
// ===========================================
// WATSON SOP BODY VALIDATION
// ===========================================
if (!watsonBody) {
// Check if incorrectly used as ID
let watsonBodyAsId = document.querySelector('#WatsonSOPBody');
if (watsonBodyAsId) {
issues.push({
type: 'Structure',
element: '#WatsonSOPBody',
issue: 'WatsonSOPBody incorrectly used as ID instead of class',
required: true,
suggestion: 'Change <div id="WatsonSOPBody"> to <div class="WatsonSOPBody">',
location: getElementLocation(watsonBodyAsId)
});
watsonBody = watsonBodyAsId;
} else {
issues.push({
type: 'Structure',
element: '.WatsonSOPBody',
issue: 'Missing div.WatsonSOPBody',
required: true,
suggestion: 'Add <div class="WatsonSOPBody">SOP content</div>',
location: ''
});
}
}
if (watsonBody) {
// Check for inline styles on WatsonSOPBody
if (watsonBody.hasAttribute('style')) {
issues.push({
type: 'Structure',
element: '.WatsonSOPBody',
issue: 'WatsonSOPBody has inline styles',
required: true,
suggestion: 'Remove inline styles from WatsonSOPBody div',
location: getElementLocation(watsonBody)
});
}
// Parse content for validation
const parser = new DOMParser();
const tempDoc = parser.parseFromString('<div>' + watsonBody.innerHTML + '</div>', 'text/html');
const parsedBody = tempDoc.body.firstChild;
// Remove WatsonByline elements
const bylineElements = parsedBody.querySelectorAll('div.WatsonByline');
bylineElements.forEach(el => el.remove());
console.log('Validator: Content parsed, length:', parsedBody.innerHTML.length);
// Run all validations
runAllValidations(parsedBody, issues);
}
// Output results
console.log('=== Validation Complete ===');
console.log('Total issues found:', issues.length);
// Log validation complete with issue count
logUsage('validation_complete_' + issues.length + '_issues');
if (issues.length > 0) {
exportToCSV(issues);
} else {
alert('No issues found in the content!');
}
}
function runAllValidations(doc, issues) {
// Watson SOP Structure
console.log('--- Watson SOP Structure Validations ---');
validateFloatingTOCStructure(doc, issues);
validateClassIdUsage(doc, issues);
// Watson Content Rules
console.log('--- Watson Content Rules ---');
validateSemanticTags(doc, issues);
validateHeadingHierarchy(doc, issues);
validateTableStructure(doc, issues);
validateListConsistency(doc, issues);
validateCallOuts(doc, issues);
validateUIElements(doc, issues);
validateBlurbs(doc, issues);
validateContentRatios(doc, issues);
validateOrderedListsStartingWithIf(doc, issues);
validateFloatingText(doc, issues);
// Standard HTML Checks
console.log('--- Standard HTML Checks ---');
validateStandardHTML(doc, issues);
validateDeprecatedAttributes(doc, issues);
validateInternalLinks(doc, issues);
// Accessibility Checks
console.log('--- Accessibility Checks ---');
validateAccessibility(doc, issues);
// Image and Link Checks
console.log('--- Image and Link Checks ---');
validateImages(doc, issues);
validateLinks(doc, issues);
// CSS Compliance Checks
console.log('--- CSS Compliance Checks ---');
validateCSSCompliance(doc, issues);
// Content Quality Checks
console.log('--- Content Quality Checks ---');
validateContentQuality(doc, issues);
// Inline Style Checks
console.log('--- Inline Style Checks ---');
validateInlineStyles(doc, issues);
}
// ===========================================
// STYLESHEET VALIDATION
// ===========================================
function validateStylesheets(containers, watsonBody, issues) {
const allApprovedStylesheets = [...CONFIG.coreStylesheets, ...CONFIG.tableStylesheets];
const linkedStylesheets = [];
// Collect stylesheets from all containers
containers.forEach(container => {
const linkTags = container.querySelectorAll('link[rel="stylesheet"]');
linkTags.forEach(link => {
const href = link.getAttribute('href');
if (href) {
const filename = href.split('/').pop().split('?')[0].toLowerCase();
if (!linkedStylesheets.some(s => s.filename === filename)) {
linkedStylesheets.push({ filename, fullHref: href });
}
}
});
});
// Also check document head
document.querySelectorAll('head link[rel="stylesheet"]').forEach(link => {
const href = link.getAttribute('href');
if (href) {
const filename = href.split('/').pop().split('?')[0].toLowerCase();
if (!linkedStylesheets.some(s => s.filename === filename)) {
linkedStylesheets.push({ filename, fullHref: href });
}
}
});
console.log('Validator: Found', linkedStylesheets.length, 'stylesheets');
// Check for core stylesheet
const hasCoreStylesheet = linkedStylesheets.some(sheet =>
CONFIG.coreStylesheets.some(core => sheet.filename === core)
);
if (!hasCoreStylesheet) {
issues.push({
type: 'CSS',
element: '<link rel="stylesheet">',
issue: 'Missing required core stylesheet',
required: true,
suggestion: `Add at least one: ${CONFIG.coreStylesheets.map(s => s.replace('.css', '')).join(', ')}`,
location: ''
});
}
// Check for table stylesheet if tables exist
if (watsonBody) {
const tables = watsonBody.querySelectorAll('table');
if (tables.length > 0) {
const hasTableStylesheet = linkedStylesheets.some(sheet =>
CONFIG.tableStylesheets.some(tableCSS => sheet.filename === tableCSS)
);
if (!hasTableStylesheet) {
issues.push({
type: 'CSS',
element: 'Table stylesheet',
issue: `Found ${tables.length} table(s) but no table stylesheet linked`,
required: true,
suggestion: `Add one: ${CONFIG.tableStylesheets.map(s => s.replace('.css', '')).join(', ')}`,
location: ''
});
}
}
}
// Flag non-approved stylesheets
containers.forEach(container => {
container.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
const href = link.getAttribute('href');
if (href) {
const filename = href.split('/').pop().split('?')[0].toLowerCase();
const isApproved = allApprovedStylesheets.some(approved => filename === approved);
const isSystem = CONFIG.systemStylesheets.some(sys =>
filename.includes(sys) || href.toLowerCase().includes(sys)
);
if (!isApproved && !isSystem) {
const alreadyFlagged = issues.some(i => i.element === `<link href="${filename}">`);
if (!alreadyFlagged) {
issues.push({
type: 'CSS',
element: `<link href="${filename}">`,
issue: `Non-standardized stylesheet: ${filename}`,
required: true,
suggestion: 'Replace with an approved stylesheet',
location: href
});
}
}
}
});
});
}
// ===========================================
// WATSON SOP SOURCE VALIDATION
// ===========================================
function validateWatsonSOPSource(watsonSource, containers, issues) {
// Check for class instead of ID
let watsonSourceAsClass = null;
containers.forEach(container => {
const sourceAsClass = container.querySelector('.WatsonSOPSource');
if (sourceAsClass) watsonSourceAsClass = sourceAsClass;
});
if (!watsonSourceAsClass) {
watsonSourceAsClass = document.querySelector('.WatsonSOPSource');
}
if (watsonSourceAsClass && !watsonSource) {
issues.push({
type: 'Structure',
element: '.WatsonSOPSource',
issue: 'WatsonSOPSource incorrectly used as class instead of ID',
required: true,
suggestion: 'Change <div class="WatsonSOPSource"> to <div id="WatsonSOPSource">',
location: getElementLocation(watsonSourceAsClass)
});
watsonSource = watsonSourceAsClass;
} else if (!watsonSource && !watsonSourceAsClass) {
issues.push({
type: 'Structure',
element: '#WatsonSOPSource',
issue: 'Missing #WatsonSOPSource element',
required: true,
suggestion: 'Add <div id="WatsonSOPSource">version history link</div>',
location: ''
});
return;
}
if (watsonSource) {
// Check inline styles (allow display:none)
const styleAttr = watsonSource.getAttribute('style');
if (styleAttr) {
const normalizedStyle = styleAttr.toLowerCase().replace(/\s/g, '').replace(/;$/, '');
if (normalizedStyle !== 'display:none') {
issues.push({
type: 'Structure',
element: '#WatsonSOPSource',
issue: 'WatsonSOPSource has inline styles other than display:none',
required: true,
suggestion: 'Remove inline styles (display:none is acceptable)',
location: getElementLocation(watsonSource)
});
}
}
// Check URL ends with .htm
const content = watsonSource.textContent.trim();
if (content && content.toLowerCase().includes('.aspx')) {
issues.push({
type: 'Structure',
element: '#WatsonSOPSource',
issue: 'Version history URL ends with .aspx instead of .htm',
required: true,
suggestion: 'Change URL from .aspx to .htm',
location: content
});
}
// Check for comments
let hasComments = false;
watsonSource.childNodes.forEach(node => {
if (node.nodeType === 8) hasComments = true;
});
if (hasComments) {
issues.push({
type: 'Structure',
element: '#WatsonSOPSource',
issue: 'WatsonSOPSource contains comments',
required: true,
suggestion: 'Remove comments from WatsonSOPSource',
location: ''
});
}
}
}
// ===========================================
// FLOATING TOC STRUCTURE VALIDATION
// ===========================================
function validateFloatingTOCStructure(doc, issues) {
const colTOC = doc.querySelector('#col-TOC');
const colBody = doc.querySelector('#col-body');
const row = doc.querySelector('.row');
const toc = doc.querySelector('#toc');
if (!colTOC && !colBody && !row && !toc) {
return; // No TOC structure
}
// Check row is class not ID
const rowAsId = doc.querySelector('[id="row"]');
if (rowAsId && !rowAsId.classList.contains('row')) {
issues.push({
type: 'Structure',
element: '#row',
issue: 'row incorrectly used as ID instead of class',
required: true,
suggestion: 'Change <div id="row"> to <div class="row">',
location: getElementLocation(rowAsId)
});
}
if (!row && (colTOC || colBody)) {
issues.push({
type: 'Structure',
element: '.row',
issue: 'Missing div.row when col-TOC or col-body present',
required: true,
suggestion: 'Add <div class="row"> to contain #col-TOC and #col-body',
location: ''
});
}
if (row) {
if (colTOC && !row.contains(colTOC)) {
issues.push({
type: 'Structure',
element: '#col-TOC',
issue: '#col-TOC must be inside div.row',
required: true,
suggestion: 'Move #col-TOC inside div.row',
location: getElementLocation(colTOC)
});
}
if (colBody && !row.contains(colBody)) {
issues.push({
type: 'Structure',
element: '#col-body',
issue: '#col-body must be inside div.row',
required: true,
suggestion: 'Move #col-body inside div.row',
location: getElementLocation(colBody)
});
}
if (colTOC) {
const sticky = colTOC.querySelector('.sticky');
if (!sticky) {
issues.push({
type: 'Structure',
element: '.sticky',
issue: 'Missing .sticky div inside #col-TOC',
required: true,
suggestion: 'Add <div class="sticky"> inside #col-TOC',
location: getElementLocation(colTOC)
});
} else if (toc && !sticky.contains(toc)) {
issues.push({
type: 'Structure',
element: '#toc',
issue: '#toc must be inside .sticky',
required: true,
suggestion: 'Move #toc inside .sticky div',
location: getElementLocation(toc)
});
}
}
}
}
// ===========================================
// CLASS/ID USAGE VALIDATION
// ===========================================
function validateClassIdUsage(doc, issues) {
// toc should be ID
doc.querySelectorAll('.toc').forEach(el => {
if (el.id !== 'toc') {
issues.push({
type: 'Structure',
element: '.toc',
issue: 'toc incorrectly used as class instead of ID',
required: true,
suggestion: 'Change <div class="toc"> to <div id="toc">',
location: getElementLocation(el)
});
}
});
// col-TOC should be ID
doc.querySelectorAll('.col-TOC').forEach(el => {
if (el.id !== 'col-TOC') {
issues.push({
type: 'Structure',
element: '.col-TOC',
issue: 'col-TOC incorrectly used as class instead of ID',
required: true,
suggestion: 'Change <div class="col-TOC"> to <div id="col-TOC">',
location: getElementLocation(el)
});
}
});
// col-body should be ID
doc.querySelectorAll('.col-body').forEach(el => {
if (el.id !== 'col-body') {
issues.push({
type: 'Structure',
element: '.col-body',
issue: 'col-body incorrectly used as class instead of ID',
required: true,
suggestion: 'Change <div class="col-body"> to <div id="col-body">',
location: getElementLocation(el)
});
}
});
}
// ===========================================
// SEMANTIC TAGS VALIDATION
// ===========================================
function validateSemanticTags(doc, issues) {
// Check for <b> tags
const bTags = doc.querySelectorAll('b');
if (bTags.length > 0) {
issues.push({
type: 'Structure',
element: '<b>',
issue: `Found ${bTags.length} <b> tag(s) - should use <strong>`,
required: true,
suggestion: 'Replace <b> with <strong> for semantic HTML',
location: getElementLocation(bTags[0])
});
}
// Check for <i> tags (exclude font icons)
const iTags = doc.querySelectorAll('i');
const nonIconITags = Array.from(iTags).filter(el => {
const className = el.className || '';
return !className.includes('fa') && !className.includes('icon');
});
if (nonIconITags.length > 0) {
issues.push({
type: 'Structure',
element: '<i>',
issue: `Found ${nonIconITags.length} <i> tag(s) - should use <em>`,
required: true,
suggestion: 'Replace <i> with <em> for semantic HTML (font icons excluded)',
location: getElementLocation(nonIconITags[0])
});
}
}
// ===========================================
// HEADING HIERARCHY VALIDATION
// ===========================================
function validateHeadingHierarchy(doc, issues) {
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
let previousLevel = 0;
headings.forEach((heading) => {
const currentLevel = parseInt(heading.tagName.charAt(1));
if (previousLevel > 0 && currentLevel > previousLevel + 1) {
issues.push({
type: 'Structure',
element: heading.tagName,
issue: `Heading hierarchy violated: h${previousLevel} to h${currentLevel} (skipped level)`,
required: true,
suggestion: `After h${previousLevel}, use h${previousLevel + 1}, not h${currentLevel}`,
location: `${heading.tagName}: ${heading.textContent.substring(0, 50)}`
});
}
previousLevel = currentLevel;
});
// Check for multiple h1 tags
const h1Tags = doc.querySelectorAll('h1');
if (h1Tags.length > 1) {
issues.push({
type: 'Structure',
element: '<h1>',
issue: `Found ${h1Tags.length} <h1> tags - should only have one`,
required: true,
suggestion: 'Use only one <h1> per page',
location: getElementLocation(h1Tags[1])
});
}
}
// ===========================================
// TABLE STRUCTURE VALIDATION (ENHANCED)
// ===========================================
function validateTableStructure(doc, issues) {
const tables = doc.querySelectorAll('table');
tables.forEach((table, index) => {
const tableNum = index + 1;
// Check for table header
const thead = table.querySelector('thead');
const thElements = table.querySelectorAll('th');
if (!thead && thElements.length === 0) {
issues.push({
type: 'Structure',
element: `table[${tableNum}]`,
issue: 'Table missing header (no <thead> or <th>)',
required: true,
suggestion: 'Add <thead> with <th> elements',
location: getElementLocation(table)
});
}
// Check for deprecated attributes
const deprecatedTableAttrs = ['border', 'cellpadding', 'width', 'height', 'bgcolor', 'align'];
deprecatedTableAttrs.forEach(attr => {
if (table.hasAttribute(attr)) {
issues.push({
type: 'HTML',
element: `table[${tableNum}]`,
issue: `Table has deprecated "${attr}" attribute`,
required: true,
suggestion: `Remove ${attr} attribute and use CSS instead`,
location: `${attr}="${table.getAttribute(attr)}"`
});
}
});
// Check for bgcolor on rows/cells
const bgcolorElements = table.querySelectorAll('[bgcolor]');
if (bgcolorElements.length > 0) {
issues.push({
type: 'HTML',
element: `table[${tableNum}]`,
issue: `Found ${bgcolorElements.length} element(s) with deprecated "bgcolor"`,
required: true,
suggestion: 'Use CSS background-color instead',
location: ''
});
}
// Check for valign
const valignElements = table.querySelectorAll('[valign]');
if (valignElements.length > 0) {
issues.push({
type: 'HTML',
element: `table[${tableNum}]`,
issue: `Found ${valignElements.length} element(s) with deprecated "valign"`,
required: true,
suggestion: 'Use CSS vertical-align instead',
location: ''
});
}
// Check for nested tables
const nestedTables = table.querySelectorAll('table');
if (nestedTables.length > 0) {
issues.push({
type: 'Structure',
element: `table[${tableNum}]`,
issue: `Table contains ${nestedTables.length} nested table(s)`,
required: false,
suggestion: 'Avoid nested tables - use CSS grid/flexbox',
location: ''
});
}
// Check for inconsistent column count
const rows = table.querySelectorAll('tr');
if (rows.length > 1) {
const columnCounts = [];
rows.forEach((row, rowIndex) => {
let colCount = 0;
row.querySelectorAll('td, th').forEach(cell => {
colCount += parseInt(cell.getAttribute('colspan')) || 1;
});
if (colCount > 0) {
columnCounts.push({ row: rowIndex + 1, count: colCount });
}
});
const uniqueCounts = [...new Set(columnCounts.map(c => c.count))];
if (uniqueCounts.length > 1) {
const maxCount = Math.max(...uniqueCounts);
const inconsistentRows = columnCounts.filter(c => c.count !== maxCount).map(c => c.row);
if (inconsistentRows.length > 0 && inconsistentRows.length < rows.length * 0.5) {
issues.push({
type: 'Structure',
element: `table[${tableNum}]`,
issue: `Inconsistent column count in rows: ${inconsistentRows.slice(0, 5).join(', ')}`,
required: false,
suggestion: 'Ensure all rows have same column count (accounting for colspan). Merging cells can produce incosistent results for content comparison and users of AT.',
location: ''
});
}
}
}
// Check for empty rows
let emptyRowCount = 0;
rows.forEach(row => {
const cells = row.querySelectorAll('td, th');
const hasContent = Array.from(cells).some(cell => cell.textContent.trim().length > 0);
if (!hasContent && cells.length > 0) {
emptyRowCount++;
}
});
if (emptyRowCount > 0) {
issues.push({
type: 'Structure',
element: `table[${tableNum}]`,
issue: `Table has ${emptyRowCount} empty row(s)`,
required: false,
suggestion: 'Remove empty rows or add content',
location: ''
});
}
// Check for single-row/single-column tables
const dataRows = table.querySelectorAll('tbody tr, table > tr');
if (dataRows.length === 1 && !thead) {
issues.push({
type: 'Structure',
element: `table[${tableNum}]`,
issue: 'Single-row table - might be misused for layout',
required: false,
suggestion: 'Consider if a table is appropriate here or if you can use different formatting',
location: ''
});
}
if (rows.length > 0) {
const firstRowCells = rows[0].querySelectorAll('td, th');
let totalCols = 0;
firstRowCells.forEach(cell => {
totalCols += parseInt(cell.getAttribute('colspan')) || 1;
});
if (totalCols === 1 && rows.length > 2) {
issues.push({
type: 'Structure',
element: `table[${tableNum}]`,
issue: 'Single-column table - might be misused for layout',
required: false,
suggestion: 'Consider using a list or different formats instead',
location: ''
});
}
if (totalCols > 6) {
issues.push({
type: 'Structure',
element: `table[${tableNum}]`,
issue: `Table has ${totalCols} columns - may be hard to read`,
required: false,
suggestion: 'Consider splitting into multiple tables',
location: ''
});
}
}
// Check for excessive inline styles on cells
const cellsWithStyles = table.querySelectorAll('td[style], th[style]');
if (cellsWithStyles.length > 10) {
issues.push({
type: 'CSS Compliance',
element: `table[${tableNum}]`,
issue: `Table has ${cellsWithStyles.length} cells with inline styles`,
required: false,
suggestion: 'Use CSS classes instead of cell formatting for consistency',
location: ''
});
}
// Check for consistent <p> tag usage
const cells = table.querySelectorAll('td');
if (cells.length > 1) {
const cellsWithP = Array.from(cells).filter(cell => cell.querySelector('p'));
const cellsWithoutP = Array.from(cells).filter(cell =>
!cell.querySelector('p') && cell.textContent.trim().length > 0
);
if (cellsWithP.length > 0 && cellsWithoutP.length > 0) {
const total = cellsWithP.length + cellsWithoutP.length;
if (Math.min(cellsWithP.length, cellsWithoutP.length) > total * 0.2) {
issues.push({
type: 'Structure',
element: `table[${tableNum}]`,
issue: `Inconsistent <p> usage (${cellsWithP.length} with, ${cellsWithoutP.length} without)`,
required: false,
suggestion: 'Be consistent with <p> tags in cells',
location: ''
});
}
}
}
});
}
// ===========================================
// LIST CONSISTENCY VALIDATION
// ===========================================
function validateListConsistency(doc, issues) {
const lists = doc.querySelectorAll('ol, ul');
lists.forEach((list, index) => {
const listItems = list.querySelectorAll(':scope > li');
if (listItems.length < 2) return;
let itemsWithP = 0;
let itemsWithoutP = 0;
listItems.forEach(li => {
if (li.querySelector(':scope > p')) {
itemsWithP++;
} else if (li.textContent.trim().length > 0) {
itemsWithoutP++;
}
});
if (itemsWithP > 0 && itemsWithoutP > 0) {
const total = itemsWithP + itemsWithoutP;
if (Math.min(itemsWithP, itemsWithoutP) > total * 0.2) {
issues.push({
type: 'Structure',
element: `${list.tagName.toLowerCase()}[${index + 1}]`,
issue: `Inconsistent <p> usage (${itemsWithP} with, ${itemsWithoutP} without)`,
required: false,
suggestion: 'Be consistent with <p> tags in list items',
location: ''
});
}
}
});
}
// ===========================================
// CALLOUT VALIDATION
// ===========================================
function validateCallOuts(doc, issues) {
const selector = CONFIG.calloutClasses.map(c => `div.${c}`).join(', ');
const callouts = doc.querySelectorAll(selector);
callouts.forEach((callout) => {
const innerHTML = callout.innerHTML.trim();
const startsWithStrong = /^<strong/i.test(innerHTML);
if (!startsWithStrong) {
const className = Array.from(callout.classList).find(c => CONFIG.calloutClasses.includes(c));
issues.push({
type: 'Structure',
element: `div.${className}`,
issue: 'Call-out may be missing intro text in <strong>',
required: true,
suggestion: `It looks like your call-out is missing an introductory text or it is different from what was intended. If so, add <strong>${className.charAt(0).toUpperCase() + className.slice(1)}:</strong> at the beginning.`,
location: getElementLocation(callout)
});
}
});
}
// ===========================================
// UI ELEMENTS VALIDATION
// ===========================================
function validateUIElements(doc, issues) {
const html = doc.innerHTML;
const uiPatternRegex = /(Click|Select|Choose|Press|Navigate to|Open|Tap|Double-click)\s+<strong(?![^>]*class\s*=\s*["'][^"']*\bui\b[^"']*["'])[^>]*>([^<]+)<\/strong>/gi;
let match;
const foundIssues = new Set();
let count = 0;
while ((match = uiPatternRegex.exec(html)) !== null && count < 10) {
const elementName = match[2].trim();
if (!foundIssues.has(elementName) && elementName.length < 50 && !elementName.includes('_')) {
foundIssues.add(elementName);
count++;
issues.push({
type: 'Structure',
element: '<strong>',
issue: `UI element "${elementName}" should use <strong class="ui">`,
required: true,
suggestion: `If this is indeed a UI element that user interacts with, change to <strong class="ui">${elementName}</strong>`,
location: `"${match[1]} ${elementName}"`
});
}
}
}
// ===========================================
// BLURB VALIDATION
// ===========================================
function validateBlurbs(doc, issues) {
const html = doc.innerHTML;
const blurbPattern = /<strong(?![^>]*class\s*=\s*["'][^"']*\bblurb\b[^"']*["'])[^>]*>([a-zA-Z][a-zA-Z0-9]*_[a-zA-Z0-9_]+)<\/strong>/gi;
let match;
const foundBlurbs = new Set();
while ((match = blurbPattern.exec(html)) !== null) {
const blurbName = match[1].trim();
if (!foundBlurbs.has(blurbName)) {
foundBlurbs.add(blurbName);
issues.push({
type: 'Structure',
element: '<strong>',
issue: `Blurb "${blurbName}" should use <strong class="blurb">`,
required: true,
suggestion: `This looks like a blurb title. If so, change to <strong class="blurb">${blurbName}</strong>.`,
location: `Blurb: ${blurbName}`
});
}
}
// Check <b> tags with blurb patterns
const bBlurbPattern = /<b>([a-zA-Z][a-zA-Z0-9]*_[a-zA-Z0-9_]+)<\/b>/gi;
while ((match = bBlurbPattern.exec(html)) !== null) {
const blurbName = match[1].trim();
if (!foundBlurbs.has(blurbName)) {
foundBlurbs.add(blurbName);
issues.push({
type: 'Structure',
element: '<b>',
issue: `Blurb "${blurbName}" uses <b> instead of <strong class="blurb">`,
required: true,
suggestion: `This looks like a blurb title. If so, change to <strong class="blurb">${blurbName}</strong>.`,
location: `Blurb: ${blurbName}`
});
}
}
}
// ===========================================
// CONTENT RATIOS VALIDATION
// ===========================================
function validateContentRatios(doc, issues) {
const totalText = doc.textContent.length;
if (totalText < 500) return;
// Table ratio
const tables = doc.querySelectorAll('table');
let tableTextLength = 0;
tables.forEach(table => tableTextLength += table.textContent.length);
const tableRatio = tableTextLength / totalText;
if (tableRatio > 0.3) {
issues.push({
type: 'Structure',
element: 'tables',
issue: `More than 30% content in tables (${Math.round(tableRatio * 100)}%)`,
required: false,
suggestion: 'Consider reducing table usage',
location: ''
});
}
// Callout ratio
const selector = CONFIG.calloutClasses.map(c => `div.${c}`).join(', ');
const callouts = doc.querySelectorAll(selector);
let calloutTextLength = 0;
callouts.forEach(callout => calloutTextLength += callout.textContent.length);
const calloutRatio = calloutTextLength / totalText;
if (calloutRatio > 0.3) {
issues.push({
type: 'Structure',
element: 'callouts',
issue: `More than 30% content in call-outs (${Math.round(calloutRatio * 100)}%)`,
required: false,
suggestion: 'Consider reducing call-out usage',
location: ''
});
}
// Consecutive callouts
if (callouts.length > 2) {
let maxConsecutive = 1;
let currentConsecutive = 1;
for (let i = 1; i < callouts.length; i++) {
const prev = callouts[i - 1];
const curr = callouts[i];
if (prev.parentElement === curr.parentElement) {
let sibling = prev.nextElementSibling;
let foundContent = false;
while (sibling && sibling !== curr) {
if (sibling.textContent.trim().length > 20) {
foundContent = true;
break;
}
sibling = sibling.nextElementSibling;
}
if (!foundContent) {
currentConsecutive++;
maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
} else {
currentConsecutive = 1;
}
} else {
currentConsecutive = 1;
}
}
if (maxConsecutive > 2) {
issues.push({
type: 'Structure',
element: 'callouts',
issue: `Found ${maxConsecutive} consecutive call-outs`,
required: false,
suggestion: 'Avoid more than 2 consecutive call-outs',
location: ''
});
}
}
}
// ===========================================
// ORDERED LISTS STARTING WITH IF
// ===========================================
function validateOrderedListsStartingWithIf(doc, issues) {
const orderedLists = doc.querySelectorAll('ol');
orderedLists.forEach((ol, index) => {
const listItems = ol.querySelectorAll(':scope > li');
let ifCount = 0;
listItems.forEach(li => {
const text = li.textContent.trim().toLowerCase();
if (text.startsWith('if ') || text.startsWith('if:') || text.startsWith('if,')) {
ifCount++;
}
});
if (ifCount >= 2) {
issues.push({
type: 'Structure',
element: `ol[${index + 1}]`,
issue: `Ordered list has ${ifCount} items starting with "If"`,
required: false,
suggestion: 'Consider restructuring conditional steps',
location: ''
});
}
});
}
// ===========================================
// FLOATING TEXT VALIDATION
// ===========================================
function validateFloatingText(doc, issues) {
const containers = [
{ element: doc, name: 'WatsonSOPBody' },
{ element: doc.querySelector('#col-body'), name: '#col-body' }
];
containers.forEach(({ element, name }) => {
if (!element) return;
Array.from(element.childNodes).forEach(node => {
if (node.nodeType === 3) {
const text = node.textContent.trim();
if (text.length > 30) {
issues.push({
type: 'Structure',
element: 'floating text',
issue: `Unwrapped text in ${name}`,
required: true,
suggestion: 'Wrap text in <p>, <div>, etc.',
location: `"${text.substring(0, 50)}..."`
});
}
}
});
});
}
// ===========================================
// STANDARD HTML VALIDATION
// ===========================================
function validateStandardHTML(doc, issues) {
// Deprecated tags
const deprecatedTags = ['font', 'center', 'marquee', 'blink', 'strike'];
deprecatedTags.forEach(tag => {
const elements = doc.querySelectorAll(tag);
if (elements.length > 0) {
issues.push({
type: 'HTML',
element: `<${tag}>`,
issue: `Found ${elements.length} deprecated <${tag}> tag(s)`,
required: true,
suggestion: 'Use modern HTML/CSS alternatives',
location: getElementLocation(elements[0])
});
}
});
// Empty lists
doc.querySelectorAll('ul, ol').forEach(list => {
if (list.querySelectorAll('li').length === 0) {
issues.push({
type: 'HTML',
element: `<${list.tagName.toLowerCase()}>`,
issue: 'Empty list detected',
required: true,
suggestion: 'Remove empty list or add items',
location: getElementLocation(list)
});
}
});
// Multiple <br> tags
const html = doc.innerHTML;
const brMatches = html.match(/<br\s*\/?>\s*<br\s*\/?>/gi);
if (brMatches && brMatches.length > 3) {
issues.push({
type: 'HTML',
element: '<br><br>',
issue: `Found ${brMatches.length} consecutive <br> pairs`,
required: false,
suggestion: 'Use CSS margins/padding instead',
location: ''
});
}
// Multiple <hr> tags
const hrMatches = html.match(/<hr\s*\/?>\s*<hr\s*\/?>/gi);
if (hrMatches && hrMatches.length > 3) {
issues.push({
type: 'HTML',
element: '<hr><hr>',
issue: `Found ${hrMatches.length} consecutive <hr> pairs`,
required: false,
suggestion: 'Use consistent singular line breaks (hr)',
location: ''
});
}
// MS Office markup
if (/mso-[a-zA-Z-]+:/gi.test(html)) {
issues.push({
type: 'HTML',
element: 'mso-* styles',
issue: 'Microsoft Office styles detected',
required: true,
suggestion: 'Paste as plain text to remove Office formatting',
location: ''
});
}
// Duplicate IDs
const idCounts = {};
doc.querySelectorAll('[id]').forEach(el => {
const id = el.id;
if (id) idCounts[id] = (idCounts[id] || 0) + 1;
});
Object.keys(idCounts).forEach(id => {
if (idCounts[id] > 1) {
issues.push({
type: 'HTML',
element: `id="${id}"`,
issue: `Duplicate ID: "${id}" appears ${idCounts[id]} times`,
required: true,
suggestion: 'IDs must be unique',
location: ''
});
}
});
}
// ===========================================
// DEPRECATED ATTRIBUTES VALIDATION
// ===========================================
function validateDeprecatedAttributes(doc, issues) {
CONFIG.deprecatedAttributes.forEach(({ attr, suggestion }) => {
const foundElements = doc.querySelectorAll(`[${attr}]`);
if (foundElements.length > 0) {
issues.push({
type: 'HTML',
element: `[${attr}]`,
issue: `Found ${foundElements.length} element(s) with deprecated "${attr}"`,
required: true,
suggestion: suggestion,
location: getElementLocation(foundElements[0])
});
}
});
}
// ===========================================
// INTERNAL LINKS VALIDATION
// ===========================================
function validateInternalLinks(doc, issues) {
const internalLinks = doc.querySelectorAll('a[href^="#"]');
const brokenLinks = [];
internalLinks.forEach(link => {
const href = link.getAttribute('href');
if (href && href !== '#') {
const targetId = href.substring(1);
const target = doc.querySelector(`[id="${targetId}"], [name="${targetId}"]`);
if (!target) {
brokenLinks.push({
href: href,
text: link.textContent.trim().substring(0, 30)
});
}
}
});
if (brokenLinks.length > 0) {
const display = brokenLinks.slice(0, 5);
issues.push({
type: 'HTML',
element: '<a href="#">',
issue: `Found ${brokenLinks.length} broken internal link(s)`,
required: true,
suggestion: 'Ensure links point to existing id/name attributes',
location: display.map(l => `${l.href}`).join(', ')
});
}
}
// ===========================================
// ACCESSIBILITY VALIDATION
// ===========================================
function validateAccessibility(doc, issues) {
// Images without alt
const imagesNoAlt = doc.querySelectorAll('img:not([alt])');
if (imagesNoAlt.length > 0) {
issues.push({
type: 'Accessibility',
element: '<img>',
issue: `Found ${imagesNoAlt.length} image(s) missing alt attribute`,
required: true,
suggestion: 'Add descriptive alt text',
location: getElementLocation(imagesNoAlt[0])
});
}
// Images with empty alt
const imagesEmptyAlt = doc.querySelectorAll('img[alt=""]');
if (imagesEmptyAlt.length > 0) {
issues.push({
type: 'Accessibility',
element: '<img alt="">',
issue: `Found ${imagesEmptyAlt.length} image(s) with empty alt`,
required: false,
suggestion: 'Verify these are decorative images',
location: ''
});
}
// iframes without title
const iframesNoTitle = doc.querySelectorAll('iframe:not([title])');
if (iframesNoTitle.length > 0) {
issues.push({
type: 'Accessibility',
element: '<iframe>',
issue: `Found ${iframesNoTitle.length} iframe(s) missing title`,
required: true,
suggestion: 'Add title attribute',
location: ''
});
}
// Videos without controls
const videosNoControls = doc.querySelectorAll('video:not([controls])');
if (videosNoControls.length > 0) {
issues.push({
type: 'Accessibility',
element: '<video>',
issue: `Found ${videosNoControls.length} video(s) missing controls`,
required: true,
suggestion: 'Add controls attribute',
location: ''
});
}
// Audio without controls
const audioNoControls = doc.querySelectorAll('audio:not([controls])');
if (audioNoControls.length > 0) {
issues.push({
type: 'Accessibility',
element: '<audio>',
issue: `Found ${audioNoControls.length} audio element(s) missing controls`,
required: true,
suggestion: 'Add controls attribute',
location: ''
});
}
// Links without href (exclude bookmarks with name/id)
const linksNoHref = doc.querySelectorAll('a:not([href]):not([name]):not([id])');
if (linksNoHref.length > 0) {
issues.push({
type: 'Accessibility',
element: '<a>',
issue: `Found ${linksNoHref.length} anchor(s) missing href (not bookmarks)`,
required: true,
suggestion: 'Add href or use <button>',
location: ''
});
}
// Table headers without scope
const thNoScope = doc.querySelectorAll('th:not([scope])');
if (thNoScope.length > 5) {
issues.push({
type: 'Accessibility',
element: '<th>',
issue: `Found ${thNoScope.length} table header(s) missing scope`,
required: false,
suggestion: 'Add scope="col" or scope="row"',
location: ''
});
}
// Buttons without accessible name
let emptyButtons = 0;
doc.querySelectorAll('button').forEach(button => {
const hasText = button.textContent.trim();
const hasAriaLabel = button.hasAttribute('aria-label');
const hasTitle = button.hasAttribute('title');
if (!hasText && !hasAriaLabel && !hasTitle) {
emptyButtons++;
}
});
if (emptyButtons > 0) {
issues.push({
type: 'Accessibility',
element: '<button>',
issue: `Found ${emptyButtons} button(s) without accessible name`,
required: true,
suggestion: 'Add text, aria-label, or title',
location: ''
});
}
// Generic link text
const genericTexts = ['click here', 'here', 'read more', 'learn more', 'more', 'link'];
let genericLinkCount = 0;
doc.querySelectorAll('a[href]').forEach(link => {
const text = link.textContent.trim().toLowerCase();
if (genericTexts.includes(text)) {
genericLinkCount++;
}
});
if (genericLinkCount > 0) {
issues.push({
type: 'Accessibility',
element: '<a>',
issue: `Found ${genericLinkCount} link(s) with generic text`,
required: false,
suggestion: 'Use descriptive link text',
location: ''
});
}
}
// ===========================================
// IMAGE VALIDATION
// ===========================================
function validateImages(doc, issues) {
const images = doc.querySelectorAll('img');
let imagesWithInlineBorder = 0;
let imagesWithInlineSize = 0;
let imagesWithBorderAttr = 0;
let imagesWithAlignAttr = 0;
images.forEach(img => {
const style = img.getAttribute('style') || '';
if (style.includes('border')) imagesWithInlineBorder++;
if (style.includes('width') || style.includes('height')) imagesWithInlineSize++;
if (img.hasAttribute('border')) imagesWithBorderAttr++;
if (img.hasAttribute('align')) imagesWithAlignAttr++;
});
if (imagesWithInlineBorder > 0) {
issues.push({
type: 'CSS Compliance',
element: '<img style="border">',
issue: `Found ${imagesWithInlineBorder} image(s) with inline border`,
required: false,
suggestion: 'Use img.no_border class or standard CSS',
location: ''
});
}
if (imagesWithInlineSize > 3) {
issues.push({
type: 'CSS Compliance',
element: '<img style="width/height">',
issue: `Found ${imagesWithInlineSize} image(s) with inline sizing`,
required: false,
suggestion: 'Use image classes (tiny, icon, flag, flowchart)',
location: ''
});
}
if (imagesWithBorderAttr > 0) {
issues.push({
type: 'HTML',
element: '<img border="">',
issue: `Found ${imagesWithBorderAttr} image(s) with deprecated border attr`,
required: true,
suggestion: 'Remove border attribute, use CSS',
location: ''
});
}
if (imagesWithAlignAttr > 0) {
issues.push({
type: 'HTML',
element: '<img align="">',
issue: `Found ${imagesWithAlignAttr} image(s) with deprecated align attr`,
required: true,
suggestion: 'Remove align attribute, use CSS',
location: ''
});
}
}
// ===========================================
// LINK VALIDATION
// ===========================================
function validateLinks(doc, issues) {
const links = doc.querySelectorAll('a[href]');
let aspxLinks = 0;
let httpLinks = 0;
let emptyTextLinks = 0;
links.forEach(link => {
const href = link.getAttribute('href');
const text = link.textContent.trim();
if (href && href.toLowerCase().includes('.aspx')) {
aspxLinks++;
}
if (href && href.startsWith('http://') && !href.includes('localhost')) {
httpLinks++;
}
if (!text && !link.querySelector('img') && !link.hasAttribute('aria-label') &&
!link.hasAttribute('name') && !link.hasAttribute('id')) {
emptyTextLinks++;
}
});
if (httpLinks > 0) {
issues.push({
type: 'Security',
element: '<a href="http://">',
issue: `Found ${httpLinks} link(s) using insecure http://`,
required: true,
suggestion: 'Update to https://',
location: ''
});
}
if (emptyTextLinks > 0) {
issues.push({
type: 'Accessibility',
element: '<a>',
issue: `Found ${emptyTextLinks} link(s) with no text content`,
required: true,
suggestion: 'Add descriptive text or aria-label',
location: ''
});
}
}
// ===========================================
// CSS COMPLIANCE VALIDATION
// ===========================================
function validateCSSCompliance(doc, issues) {
validateButtonUsage(doc, issues);
validateCalloutCSS(doc, issues);
validateColoredMargins(doc, issues);
validateInteractiveElements(doc, issues);
validateTypography(doc, issues);
validateListClasses(doc, issues);
validateLayoutContainers(doc, issues);
validateEmphasisBoxes(doc, issues);
validateSemanticSpans(doc, issues);
}
function validateButtonUsage(doc, issues) {
const buttons = doc.querySelectorAll('button');
let buttonsWithoutClass = 0;
let buttonsWithInlineStyles = 0;
buttons.forEach(button => {
const classes = button.className.split(' ').filter(c => c.trim());
const hasStandardClass = classes.some(cls => CONFIG.buttonClasses.includes(cls));
if (!hasStandardClass && classes.length === 0) {
buttonsWithoutClass++;
}
const style = button.getAttribute('style') || '';
if (style.includes('background') || style.includes('color') ||
style.includes('border') || style.includes('font')) {
buttonsWithInlineStyles++;
}
});
if (buttonsWithoutClass > 0) {
issues.push({
type: 'CSS Compliance',
element: '<button>',
issue: `Found ${buttonsWithoutClass} button(s) without standard class`,
required: false,
suggestion: 'Use: button--1/2/3/4, animated_button_red/green, update-button, etc.',
location: ''
});
}
if (buttonsWithInlineStyles > 0) {
issues.push({
type: 'CSS Compliance',
element: '<button style="">',
issue: `Found ${buttonsWithInlineStyles} button(s) with inline styles`,
required: true,
suggestion: 'Use standard button classes from Buttons.css',
location: ''
});
}
}
function validateCalloutCSS(doc, issues) {
// Check for non-standard callout divs
const allDivs = doc.querySelectorAll('div');
let nonStandardCallouts = 0;
allDivs.forEach(div => {
const style = (div.getAttribute('style') || '').toLowerCase();
if ((style.includes('background-color') || style.includes('background:')) &&
(style.includes('border') || style.includes('padding'))) {
const hasStandardClass = CONFIG.calloutClasses.some(cls => div.classList.contains(cls));
if (!hasStandardClass) {
nonStandardCallouts++;
}
}
});
if (nonStandardCallouts > 0) {
issues.push({
type: 'CSS Compliance',
element: '<div style="background...">',
issue: `Found ${nonStandardCallouts} non-standard callout div(s)`,
required: true,
suggestion: `Use standard classes: ${CONFIG.calloutClasses.join(', ')}`,
location: ''
});
}
// Check callouts for overriding styles
const selector = CONFIG.calloutClasses.map(c => `div.${c}`).join(', ');
doc.querySelectorAll(selector).forEach(callout => {
const style = callout.getAttribute('style') || '';
if (style.includes('background') || style.includes('border-color')) {
issues.push({
type: 'CSS Compliance',
element: `div.${callout.className.split(' ')[0]}`,
issue: 'Callout has inline styles overriding standard CSS',
required: true,
suggestion: 'Remove inline background/border styles',
location: getElementLocation(callout)
});
}
});
}
function validateColoredMargins(doc, issues) {
// Check for inline border-left styles
const divsWithBorderLeft = doc.querySelectorAll('div[style*="border-left"]');
let nonStandardBorderLeft = 0;
divsWithBorderLeft.forEach(div => {
const hasStandardClass = CONFIG.coloredMarginClasses.some(color => div.classList.contains(color));
if (!hasStandardClass) {
nonStandardBorderLeft++;
}
});
if (nonStandardBorderLeft > 0) {
issues.push({
type: 'CSS Compliance',
element: '<div style="border-left...">',
issue: `Found ${nonStandardBorderLeft} div(s) with inline border-left`,
required: true,
suggestion: `Use standard classes: ${CONFIG.coloredMarginClasses.join(', ')}`,
location: ''
});
}
}
function validateInteractiveElements(doc, issues) {
// Collapsible sections
const collapsibles = doc.querySelectorAll('.collapsible, .collapsible-hide');
collapsibles.forEach(collapsible => {
if (collapsible.tagName !== 'BUTTON' && !collapsible.tagName.match(/^H[1-6]$/)) {
issues.push({
type: 'CSS Compliance',
element: '.collapsible',
issue: `Collapsible is <${collapsible.tagName.toLowerCase()}> instead of <button> or heading`,
required: false,
suggestion: 'Use <button class="collapsible"> or <h4 class="collapsible">',
location: ''
});
}
if (!collapsible.hasAttribute('aria-expanded')) {
issues.push({
type: 'Accessibility',
element: '.collapsible',
issue: 'Collapsible missing aria-expanded',
required: false,
suggestion: 'Add aria-expanded="false"',
location: ''
});
}
const nextSibling = collapsible.nextElementSibling;
if (!nextSibling || !nextSibling.classList.contains('collapsed')) {
issues.push({
type: 'Structure',
element: '.collapsible',
issue: 'Collapsible not followed by .collapsed div',
required: true,
suggestion: 'Add <div class="collapsed">content</div> after',
location: ''
});
}
});
// Flip cards
doc.querySelectorAll('.flip-card').forEach(card => {
if (!card.querySelector('.flip-card-inner')) {
issues.push({
type: 'Structure',
element: '.flip-card',
issue: 'Flip card missing .flip-card-inner',
required: true,
suggestion: 'Add <div class="flip-card-inner">',
location: ''
});
}
if (!card.querySelector('.flip-card-front') || !card.querySelector('.flip-card-back')) {
issues.push({
type: 'Structure',
element: '.flip-card',
issue: 'Flip card missing front or back',
required: true,
suggestion: 'Add .flip-card-front and .flip-card-back',
location: ''
});
}
if (!card.hasAttribute('tabindex')) {
issues.push({
type: 'Accessibility',
element: '.flip-card',
issue: 'Flip card missing tabindex',
required: false,
suggestion: 'Add tabindex="0"',
location: ''
});
}
});
// Tabs
doc.querySelectorAll('.tab').forEach(tab => {
const buttons = tab.querySelectorAll('button');
buttons.forEach(button => {
if (!button.classList.contains('tablinks')) {
issues.push({
type: 'CSS Compliance',
element: '.tab button',
issue: 'Tab button missing .tablinks class',
required: false,
suggestion: 'Add class="tablinks"',
location: ''
});
}
});
});
// Dropdowns
doc.querySelectorAll('.dropdown').forEach(dropdown => {
if (!dropdown.querySelector('.dropdown-content')) {
issues.push({
type: 'Structure',
element: '.dropdown',
issue: 'Dropdown missing .dropdown-content',
required: true,
suggestion: 'Add <div class="dropdown-content">',
location: ''
});
}
});
}
function validateTypography(doc, issues) {
const html = doc.innerHTML;
// Non-standard fonts
CONFIG.nonStandardFonts.forEach(font => {
const fontRegex = new RegExp(`font-family[^;]*${font}`, 'gi');
if (fontRegex.test(html)) {
issues.push({
type: 'CSS Compliance',
element: `font-family: ${font}`,
issue: `Non-standard font "${font}" detected`,
required: true,
suggestion: 'Use "Amazon Ember" as defined in CSS',
location: ''
});
}
});
// Pixel font sizes
const pxFontSizes = html.match(/font-size\s*:\s*\d+px/gi);
if (pxFontSizes && pxFontSizes.length > 3) {
issues.push({
type: 'CSS Compliance',
element: 'font-size: px',
issue: `Found ${pxFontSizes.length} inline px font-size declarations`,
required: false,
suggestion: 'Use rem units (1rem, 0.875rem, etc.) or CSS',
location: ''
});
}
// Inline colors
const inlineColors = html.match(/\bcolor\s*:\s*#[0-9a-fA-F]{3,6}/gi);
if (inlineColors && inlineColors.length > 5) {
issues.push({
type: 'CSS Compliance',
element: 'color: #...',
issue: `Found ${inlineColors.length} inline color declarations`,
required: false,
suggestion: 'Use CSS classes or CSS variables',
location: ''
});
}
// Headings with inline styles
const headingsWithStyles = doc.querySelectorAll('h1[style], h2[style], h3[style], h4[style], h5[style], h6[style]');
if (headingsWithStyles.length > 0) {
issues.push({
type: 'CSS Compliance',
element: '<h1-h6 style="">',
issue: `Found ${headingsWithStyles.length} heading(s) with inline styles`,
required: true,
suggestion: 'Use standard CSS heading styles',
location: ''
});
}
}
function validateListClasses(doc, issues) {
// Lists with inline list-style
const listsWithInlineStyle = doc.querySelectorAll('ul[style*="list-style"], ol[style*="list-style"]');
if (listsWithInlineStyle.length > 0) {
issues.push({
type: 'CSS Compliance',
element: '<ul/ol style="list-style">',
issue: `Found ${listsWithInlineStyle.length} list(s) with inline list-style`,
required: false,
suggestion: `Use standard classes: ${CONFIG.listClasses.join(', ')}`,
location: ''
});
}
// DO/DO_NOT items not in list
doc.querySelectorAll('li.DO, li.DO_NOT').forEach(item => {
if (!item.closest('ul, ol')) {
issues.push({
type: 'Structure',
element: `li.${item.className}`,
issue: 'DO/DO_NOT item not inside a list',
required: true,
suggestion: 'Place inside <ul> or <ol>',
location: ''
});
}
});
}
function validateLayoutContainers(doc, issues) {
// Horizontal containers
doc.querySelectorAll('.LayoutContainerHorizontal').forEach(container => {
const children = container.querySelectorAll('.horizontalContainer');
if (children.length === 0) {
issues.push({
type: 'Structure',
element: '.LayoutContainerHorizontal',
issue: 'No .horizontalContainer children',
required: true,
suggestion: 'Add <div class="horizontalContainer"> children',
location: ''
});
}
children.forEach(child => {
const style = child.getAttribute('style') || '';
if (style.includes('width') && style.includes('px')) {
issues.push({
type: 'CSS Compliance',
element: '.horizontalContainer',
issue: 'Fixed pixel width on container',
required: false,
suggestion: 'Let CSS handle responsive sizing',
location: ''
});
}
});
});
// Row containers
doc.querySelectorAll('.row').forEach(row => {
const style = row.getAttribute('style') || '';
if (style.includes('width')) {
issues.push({
type: 'CSS Compliance',
element: '.row',
issue: 'Row has inline width style',
required: false,
suggestion: '.row should be 100% as defined in CSS',
location: ''
});
}
});
}
function validateEmphasisBoxes(doc, issues) {
// Non-standard box divs
const divsWithBorder = doc.querySelectorAll('div[style*="border:"], div[style*="border-color"]');
let nonStandardBoxes = 0;
divsWithBorder.forEach(div => {
const style = div.getAttribute('style') || '';
const hasStandardClass = CONFIG.emphasisBoxClasses.some(cls => div.classList.contains(cls));
const isCallout = CONFIG.calloutClasses.some(cls => div.classList.contains(cls));
const isColoredMargin = CONFIG.coloredMarginClasses.some(cls => div.classList.contains(cls));
if ((style.includes('border-radius') || style.includes('padding')) &&
!hasStandardClass && !isCallout && !isColoredMargin) {
nonStandardBoxes++;
}
});
if (nonStandardBoxes > 0) {
issues.push({
type: 'CSS Compliance',
element: '<div style="border...">',
issue: `Found ${nonStandardBoxes} non-standard box div(s)`,
required: false,
suggestion: `Use: ${CONFIG.emphasisBoxClasses.join(', ')}`,
location: ''
});
}
}
function validateSemanticSpans(doc, issues) {
// Inline italic spans
const italicSpans = doc.querySelectorAll('span[style*="italic"]');
if (italicSpans.length > 0) {
issues.push({
type: 'CSS Compliance',
element: '<span style="font-style:italic">',
issue: `Found ${italicSpans.length} span(s) with inline italic`,
required: true,
suggestion: 'Use <em> tag instead',
location: ''
});
}
// Inline bold spans
const boldSpans = doc.querySelectorAll('span[style*="bold"], span[style*="font-weight"]');
if (boldSpans.length > 0) {
issues.push({
type: 'CSS Compliance',
element: '<span style="font-weight:bold">',
issue: `Found ${boldSpans.length} span(s) with inline bold`,
required: true,
suggestion: 'Use <strong> with appropriate class',
location: ''
});
}
}
// ===========================================
// CONTENT QUALITY VALIDATION
// ===========================================
function validateContentQuality(doc, issues) {
// Long paragraphs
let longParagraphs = 0;
doc.querySelectorAll('p').forEach(p => {
const wordCount = p.textContent.trim().split(/\s+/).length;
if (wordCount > 150) longParagraphs++;
});
if (longParagraphs > 0) {
issues.push({
type: 'Content',
element: '<p>',
issue: `Found ${longParagraphs} paragraph(s) with 150+ words`,
required: false,
suggestion: 'Break into smaller paragraphs',
location: ''
});
}
// Long headings
let longHeadings = 0;
doc.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(h => {
if (h.textContent.trim().length > 80) longHeadings++;
});
if (longHeadings > 0) {
issues.push({
type: 'Content',
element: '<h1-h6>',
issue: `Found ${longHeadings} heading(s) longer than 80 characters`,
required: false,
suggestion: 'Keep headings concise',
location: ''
});
}
// Empty paragraphs
let emptyParagraphs = 0;
doc.querySelectorAll('p').forEach(p => {
if (!p.textContent.trim() && p.children.length === 0) emptyParagraphs++;
});
if (emptyParagraphs > 2) {
issues.push({
type: 'HTML',
element: '<p>',
issue: `Found ${emptyParagraphs} empty paragraph(s)`,
required: false,
suggestion: 'Remove empty paragraphs or use CSS margins',
location: ''
});
}
// Placeholder text
const allText = doc.textContent;
const placeholderPatterns = [
{ pattern: /lorem ipsum/i, name: 'Lorem Ipsum' },
{ pattern: /\[insert.*?\]/i, name: '[Insert...]' },
{ pattern: /\[add.*?\]/i, name: '[Add...]' },
{ pattern: /\[todo\]/i, name: '[TODO]' },
{ pattern: /\[placeholder\]/i, name: '[Placeholder]' },
{ pattern: /xxxx+/i, name: 'XXXX' },
{ pattern: /\btbd\b/i, name: 'TBD' }
];
placeholderPatterns.forEach(({ pattern, name }) => {
if (pattern.test(allText)) {
issues.push({
type: 'Content',
element: 'text',
issue: `Possible placeholder text found: ${name}`,
required: true,
suggestion: 'Replace placeholder with actual content',
location: ''
});
}
});
// Deeply nested lists (4+ levels)
const deeplyNestedLists = doc.querySelectorAll('ul ul ul ul, ol ol ol ol, ul ol ol ol, ol ul ul ul');
if (deeplyNestedLists.length > 0) {
issues.push({
type: 'Structure',
element: '<ul>/<ol>',
issue: `Found ${deeplyNestedLists.length} list(s) nested 4+ levels`,
required: false,
suggestion: 'Consider restructuring for better readability',
location: ''
});
}
// Very long lists (more than 15 items at top level)
doc.querySelectorAll('ul, ol').forEach((list, index) => {
// Only check top-level lists (not nested)
if (!list.parentElement.closest('ul, ol')) {
const directItems = list.querySelectorAll(':scope > li');
if (directItems.length > 15) {
issues.push({
type: 'Content',
element: `${list.tagName.toLowerCase()}[${index + 1}]`,
issue: `List has ${directItems.length} items (more than 15)`,
required: false,
suggestion: 'Consider breaking into smaller lists with subheadings',
location: ''
});
}
}
});
}
// ===========================================
// INLINE STYLES VALIDATION
// ===========================================
function validateInlineStyles(doc, issues) {
const elementsWithStyles = doc.querySelectorAll('[style]');
let totalInlineStyles = elementsWithStyles.length;
let importantCount = 0;
let outlineNoneCount = 0;
let displayNoneCount = 0;
let positionAbsoluteCount = 0;
elementsWithStyles.forEach(el => {
const style = (el.getAttribute('style') || '').toLowerCase();
if (style.includes('!important')) importantCount++;
if (style.includes('outline') && style.includes('none')) outlineNoneCount++;
if (style.includes('display') && style.includes('none')) displayNoneCount++;
if (style.includes('position') && style.includes('absolute')) positionAbsoluteCount++;
});
// Only flag if excessive inline styles
if (totalInlineStyles > 50) {
issues.push({
type: 'CSS',
element: 'style=""',
issue: `Found ${totalInlineStyles} elements with inline styles`,
required: false,
suggestion: 'Consider moving styles to CSS classes',
location: ''
});
}
if (importantCount > 0) {
issues.push({
type: 'CSS',
element: '!important',
issue: `Found ${importantCount} use(s) of !important`,
required: true,
suggestion: 'Avoid !important - fix CSS cascade instead',
location: ''
});
}
if (outlineNoneCount > 0) {
issues.push({
type: 'Accessibility',
element: 'outline: none',
issue: `Found ${outlineNoneCount} element(s) with outline:none`,
required: true,
suggestion: 'Do not remove focus outlines',
location: ''
});
}
if (displayNoneCount > 5) {
issues.push({
type: 'CSS',
element: 'display: none',
issue: `Found ${displayNoneCount} element(s) with display:none`,
required: false,
suggestion: 'Review hidden elements for appropriateness',
location: ''
});
}
if (positionAbsoluteCount > 3) {
issues.push({
type: 'CSS',
element: 'position: absolute',
issue: `Found ${positionAbsoluteCount} element(s) with position:absolute`,
required: false,
suggestion: 'Absolute positioning may break responsive layout',
location: ''
});
}
}
// ===========================================
// UTILITY FUNCTIONS
// ===========================================
function getElementLocation(element) {
if (!element) return '';
try {
let html = element.outerHTML || '';
if (html.length > 150) {
const tagEnd = html.indexOf('>');
if (tagEnd > 0 && tagEnd < 150) {
html = html.substring(0, tagEnd + 1) + '...';
} else {
html = html.substring(0, 150) + '...';
}
}
return html;
} catch (e) {
return '';
}
}
function exportToCSV(issues) {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const headers = ['Type', 'Element', 'Issue', 'Required', 'Suggestion', 'Location'];
const csvContent = [
headers.join(','),
...issues.map(issue => [
issue.type,
issue.element,
issue.issue,
issue.required ? 'Yes' : 'No',
issue.suggestion,
issue.location || 'N/A'
].map(field => `"${String(field).replace(/"/g, '""').replace(/\n/g, ' ')}"`).join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' });
const pageTitle = document.title.replace(/[^a-z0-9]/gi, '_').toLowerCase();
const filename = `content-validation_${pageTitle}_${timestamp}.csv`;
GM_download({
url: URL.createObjectURL(blob),
name: filename
});
const summary = summarizeIssues(issues);
alert(`Validation complete!\n\n${summary}\n\nReport saved as: ${filename}`);
} catch (e) {
console.error('Error exporting to CSV:', e);
// Fallback: show issues in console
console.table(issues);
alert('Error creating CSV. Check console for results.');
}
}
function summarizeIssues(issues) {
const summary = {};
const requiredCount = issues.filter(i => i.required).length;
issues.forEach(issue => {
if (!summary[issue.type]) {
summary[issue.type] = 0;
}
summary[issue.type]++;
});
let summaryText = `Found ${issues.length} total issues (${requiredCount} required fixes):\n`;
for (const [type, count] of Object.entries(summary).sort((a, b) => b[1] - a[1])) {
summaryText += `\n• ${type}: ${count}`;
}
return summaryText;
}
})();