semantic-heading-hierarchy
Version:
A JavaScript library that automatically invisibly corrects improper heading hierarchies for better accessibility. Used for user or admin edited content where the developer doesn't have 100% direct control over the content.
207 lines (204 loc) • 7.68 kB
JavaScript
// src/core.ts
function healHeadings(containerOrSelector = document.body, options = false) {
let logResults = false;
let classPrefix = "hs-";
let forceSingleH1 = false;
if (typeof options === "boolean") {
logResults = options;
} else if (typeof options === "object" && options !== null) {
logResults = options.logResults || false;
classPrefix = options.classPrefix || "hs-";
forceSingleH1 = options.forceSingleH1 || false;
}
if (typeof localStorage !== "undefined") {
const logOverride = localStorage.getItem("healHeadings.logResults");
if (logOverride !== null) {
logResults = logOverride === "true";
}
}
let container;
if (typeof containerOrSelector === "string") {
const elements = document.querySelectorAll(containerOrSelector);
if (elements.length === 0) {
console.warn(`No elements found for selector: ${containerOrSelector}`);
return;
}
if (elements.length > 1) {
console.error(`Multiple elements found for selector: ${containerOrSelector}. Selector must match exactly one element.`);
return;
}
container = elements[0];
} else {
container = containerOrSelector;
}
if (!container || !(container instanceof Element)) {
console.warn("Invalid container provided to healHeadings");
return;
}
const h1Element = container.querySelector("h1");
if (!h1Element) {
if (logResults) {
console.log("No H1 found - skipping heading structure fix");
}
return;
}
const allHeadings = Array.from(container.querySelectorAll("h1, h2, h3, h4, h5, h6"));
const h1Index = allHeadings.indexOf(h1Element);
const headingsBeforeH1 = allHeadings.slice(0, h1Index);
if (headingsBeforeH1.length > 0) {
const headingTypes = headingsBeforeH1.map((h) => h.tagName.toLowerCase()).join(", ");
console.warn(`\u26A0\uFE0F Found ${headingsBeforeH1.length} heading(s) before H1: ${headingTypes}. These headings will be ignored for accessibility compliance. Consider restructuring your HTML to place all content headings after the main H1.`);
}
const additionalH1s = allHeadings.slice(h1Index + 1).filter((h) => h.tagName.toLowerCase() === "h1");
if (additionalH1s.length > 0) {
if (forceSingleH1) {
console.warn(`\u26A0\uFE0F Found ${additionalH1s.length} additional H1 element(s) after the first H1. These will be converted to H2 elements due to forceSingleH1 option.`);
} else {
console.warn(`\u26A0\uFE0F Found ${additionalH1s.length} additional H1 element(s) after the first H1. These will be ignored. Consider using the forceSingleH1 option to convert them to H2 elements.`);
}
}
const headingsAfterH1 = allHeadings.slice(h1Index + 1);
let headings;
if (forceSingleH1) {
headings = headingsAfterH1;
} else {
headings = headingsAfterH1.filter((heading) => heading.tagName.toLowerCase() !== "h1");
}
if (headings.length === 0) {
if (logResults) {
console.log("No H2-H6 headings found after H1 - nothing to fix");
}
return;
}
if (logResults) {
console.log(`Found ${headings.length} heading(s) to process after H1`);
}
let previousLevel = 1;
const elementsToReplace = [];
for (let i = 0; i < headings.length; i++) {
const heading = headings[i];
const originalTag = heading.tagName.toLowerCase();
const originalLevel = parseInt(originalTag.charAt(1), 10);
const listItem = heading.closest("li");
if (listItem) {
const parentList = listItem.parentElement;
if (parentList) {
const siblingItems = parentList.querySelectorAll("li");
if (siblingItems.length > 1) {
if (logResults) {
console.log(`Skipping ${originalTag} in list with ${siblingItems.length} items`);
}
continue;
}
}
}
let newLevel;
if (originalLevel === 1 && forceSingleH1) {
newLevel = 2;
} else if (originalLevel <= previousLevel) {
newLevel = Math.max(2, originalLevel);
} else {
newLevel = Math.min(originalLevel, previousLevel + 1);
}
newLevel = Math.max(2, Math.min(newLevel, 6));
if (newLevel !== originalLevel) {
elementsToReplace.push({
original: heading,
newLevel,
originalLevel,
originalTag
});
if (logResults) {
console.log(`Will change ${originalTag.toUpperCase()} \u2192 H${newLevel} (will add ${classPrefix}${originalLevel} class)`);
}
}
previousLevel = newLevel;
}
elementsToReplace.forEach((item) => {
const { original, newLevel, originalLevel, originalTag } = item;
original.classList.add(`${classPrefix}${originalLevel}`);
const newHeading = document.createElement(`h${newLevel}`);
Array.from(original.attributes).forEach((attr) => {
newHeading.setAttribute(attr.name, attr.value);
});
newHeading.innerHTML = original.innerHTML;
newHeading.setAttribute("data-prev-heading", originalLevel.toString());
if (original.parentNode) {
original.parentNode.replaceChild(newHeading, original);
}
if (logResults) {
console.log(`Replaced ${originalTag.toUpperCase()} with H${newLevel}, added ${classPrefix}${originalLevel} class`);
}
});
if (logResults) {
console.log(`Heading structure fix complete. Modified ${elementsToReplace.length} heading(s)`);
}
}
// src/logging.ts
function enableHeadingLogging() {
if (typeof localStorage !== "undefined") {
localStorage.setItem("healHeadings.logResults", "true");
console.log("\u2705 Detailed heading healing logging ENABLED globally");
} else {
console.warn("localStorage not available - cannot enable global logging");
}
}
function disableHeadingLogging() {
if (typeof localStorage !== "undefined") {
localStorage.setItem("healHeadings.logResults", "false");
console.log("\u274C Detailed heading healing logging DISABLED globally");
} else {
console.warn("localStorage not available - cannot disable global logging");
}
}
function clearHeadingLogging() {
if (typeof localStorage !== "undefined") {
localStorage.removeItem("healHeadings.logResults");
console.log("\u{1F504} Heading healing logging reset - will use function parameter");
} else {
console.warn("localStorage not available - cannot clear global logging");
}
}
function getHeadingLoggingStatus() {
if (typeof localStorage !== "undefined") {
const setting = localStorage.getItem("healHeadings.logResults");
if (setting === null) {
console.log("\u{1F4CB} Heading healing logging: Using function parameter (no override set)");
} else {
console.log(`\u{1F4CB} Heading healing logging: ${setting === "true" ? "ENABLED" : "DISABLED"} (localStorage override)`);
}
return setting;
} else {
console.log("\u{1F4CB} Heading healing logging: localStorage not available");
return null;
}
}
function createLoggingInterface() {
return {
enable: enableHeadingLogging,
disable: disableHeadingLogging,
clear: clearHeadingLogging,
status: getHeadingLoggingStatus
};
}
// src/index.ts
var SemanticHeadingHierarchy = {
/**
* Fix heading hierarchies in the specified container
* @param containerOrSelector - CSS selector string or DOM element to search within
* @param options - Options object or boolean for logResults (for backwards compatibility)
*/
fix: healHeadings,
/**
* Logging control methods
*/
logging: createLoggingInterface()
};
var index_default = SemanticHeadingHierarchy;
if (typeof window !== "undefined") {
window.SemanticHeadingHierarchy = SemanticHeadingHierarchy;
}
export {
SemanticHeadingHierarchy,
index_default as default
};