mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Escape API-driven text before injecting it into tab/panel markup to reduce XSS risk, and add a robust clipboard fallback plus safer tab panel selection for edge-case category names.
This commit is contained in:
+208
-156
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
initGlassTerminal,
|
||||
renderTerminalLayout,
|
||||
initGlassTerminal,
|
||||
renderTerminalLayout,
|
||||
} from "./js/components/glass-terminal.js";
|
||||
import { initLensEffect } from "./js/components/lens.js";
|
||||
import { initFrameworkViz } from "./js/components/framework-viz.js";
|
||||
@@ -17,40 +17,67 @@ let allCommands = [];
|
||||
// CONTENT LOADING
|
||||
// ============================================
|
||||
|
||||
function escapeHtml(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function copyTextFallback(text) {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.setAttribute("readonly", "");
|
||||
textArea.style.position = "absolute";
|
||||
textArea.style.left = "-9999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContent() {
|
||||
try {
|
||||
const [commandsRes, patternsRes] = await Promise.all([
|
||||
fetch("/api/commands"),
|
||||
fetch("/api/patterns"),
|
||||
]);
|
||||
try {
|
||||
const [commandsRes, patternsRes] = await Promise.all([
|
||||
fetch("/api/commands"),
|
||||
fetch("/api/patterns"),
|
||||
]);
|
||||
|
||||
// Check for HTTP errors
|
||||
if (!commandsRes.ok) {
|
||||
throw new Error(`Commands API failed: ${commandsRes.status}`);
|
||||
}
|
||||
if (!patternsRes.ok) {
|
||||
throw new Error(`Patterns API failed: ${patternsRes.status}`);
|
||||
}
|
||||
// Check for HTTP errors
|
||||
if (!commandsRes.ok) {
|
||||
throw new Error(`Commands API failed: ${commandsRes.status}`);
|
||||
}
|
||||
if (!patternsRes.ok) {
|
||||
throw new Error(`Patterns API failed: ${patternsRes.status}`);
|
||||
}
|
||||
|
||||
allCommands = await commandsRes.json();
|
||||
const patternsData = await patternsRes.json();
|
||||
allCommands = await commandsRes.json();
|
||||
const patternsData = await patternsRes.json();
|
||||
|
||||
// Render commands (Glass Terminal)
|
||||
renderTerminalLayout(allCommands);
|
||||
// Render commands (Glass Terminal)
|
||||
renderTerminalLayout(allCommands);
|
||||
|
||||
// Render patterns with tabbed navigation
|
||||
renderPatternsWithTabs(patternsData.patterns, patternsData.antipatterns);
|
||||
} catch (error) {
|
||||
console.error("Failed to load content:", error);
|
||||
showLoadError(error);
|
||||
}
|
||||
// Render patterns with tabbed navigation
|
||||
renderPatternsWithTabs(patternsData.patterns, patternsData.antipatterns);
|
||||
} catch (error) {
|
||||
console.error("Failed to load content:", error);
|
||||
showLoadError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function showLoadError(error) {
|
||||
// Show error in commands section
|
||||
const commandsGallery = document.querySelector('.commands-gallery');
|
||||
if (commandsGallery) {
|
||||
commandsGallery.innerHTML = `
|
||||
// Show error in commands section
|
||||
const commandsGallery = document.querySelector(".commands-gallery");
|
||||
if (commandsGallery) {
|
||||
commandsGallery.innerHTML = `
|
||||
<div class="load-error" role="alert">
|
||||
<div class="load-error-icon" aria-hidden="true">⚠</div>
|
||||
<h3 class="load-error-title">Failed to load commands</h3>
|
||||
@@ -60,12 +87,12 @@ function showLoadError(error) {
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Show error in patterns section
|
||||
const patternsContainer = document.getElementById("patterns-categories");
|
||||
if (patternsContainer) {
|
||||
patternsContainer.innerHTML = `
|
||||
// Show error in patterns section
|
||||
const patternsContainer = document.getElementById("patterns-categories");
|
||||
if (patternsContainer) {
|
||||
patternsContainer.innerHTML = `
|
||||
<div class="load-error" role="alert">
|
||||
<div class="load-error-icon" aria-hidden="true">⚠</div>
|
||||
<h3 class="load-error-title">Failed to load patterns</h3>
|
||||
@@ -75,139 +102,152 @@ function showLoadError(error) {
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderPatternsWithTabs(patterns, antipatterns) {
|
||||
const container = document.getElementById("patterns-categories");
|
||||
if (!container || !patterns || !antipatterns) return;
|
||||
const container = document.getElementById("patterns-categories");
|
||||
if (!container || !patterns || !antipatterns) return;
|
||||
|
||||
// Create a map of antipatterns by category name
|
||||
const antipatternMap = {};
|
||||
antipatterns.forEach(cat => {
|
||||
antipatternMap[cat.name] = cat.items;
|
||||
});
|
||||
// Create a map of antipatterns by category name
|
||||
const antipatternMap = {};
|
||||
antipatterns.forEach((cat) => {
|
||||
antipatternMap[cat.name] = cat.items;
|
||||
});
|
||||
|
||||
// Generate unique IDs for tabs
|
||||
const tabId = (name) => `pattern-tab-${name.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const panelId = (name) => `pattern-panel-${name.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
// Generate unique IDs for tabs
|
||||
const tabId = (name) =>
|
||||
`pattern-tab-${name.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
const panelId = (name) =>
|
||||
`pattern-panel-${name.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
|
||||
// Build tabs with WAI-ARIA attributes
|
||||
const tabsHTML = patterns
|
||||
.map((category, i) => `<button
|
||||
class="pattern-tab${i === 0 ? ' active' : ''}"
|
||||
data-tab="${category.name}"
|
||||
// Build tabs with WAI-ARIA attributes
|
||||
const tabsHTML = patterns
|
||||
.map((category, i) => {
|
||||
const categoryName = String(category.name ?? "");
|
||||
return `<button
|
||||
class="pattern-tab${i === 0 ? " active" : ""}"
|
||||
data-tab="${escapeHtml(categoryName)}"
|
||||
role="tab"
|
||||
id="${tabId(category.name)}"
|
||||
aria-selected="${i === 0 ? 'true' : 'false'}"
|
||||
aria-controls="${panelId(category.name)}"
|
||||
tabindex="${i === 0 ? '0' : '-1'}"
|
||||
>${category.name}</button>`)
|
||||
.join("");
|
||||
id="${tabId(categoryName)}"
|
||||
aria-selected="${i === 0 ? "true" : "false"}"
|
||||
aria-controls="${panelId(categoryName)}"
|
||||
tabindex="${i === 0 ? "0" : "-1"}"
|
||||
>${escapeHtml(categoryName)}</button>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// Build panels with WAI-ARIA attributes
|
||||
const panelsHTML = patterns
|
||||
.map((category, i) => {
|
||||
const antiItems = antipatternMap[category.name] || [];
|
||||
return `
|
||||
// Build panels with WAI-ARIA attributes
|
||||
const panelsHTML = patterns
|
||||
.map((category, i) => {
|
||||
const categoryName = String(category.name ?? "");
|
||||
const antiItems = antipatternMap[category.name] || [];
|
||||
return `
|
||||
<div
|
||||
class="pattern-panel${i === 0 ? ' active' : ''}"
|
||||
data-panel="${category.name}"
|
||||
class="pattern-panel${i === 0 ? " active" : ""}"
|
||||
data-panel="${escapeHtml(categoryName)}"
|
||||
role="tabpanel"
|
||||
id="${panelId(category.name)}"
|
||||
aria-labelledby="${tabId(category.name)}"
|
||||
${i !== 0 ? 'hidden' : ''}
|
||||
id="${panelId(categoryName)}"
|
||||
aria-labelledby="${tabId(categoryName)}"
|
||||
${i !== 0 ? "hidden" : ""}
|
||||
>
|
||||
<div class="pattern-columns">
|
||||
<div class="pattern-column pattern-column--anti">
|
||||
<span class="pattern-column-label" id="dont-label-${i}">Don't</span>
|
||||
<ul class="pattern-list" aria-labelledby="dont-label-${i}">
|
||||
${antiItems.map((item) => `<li class="pattern-item pattern-item--anti">${item}</li>`).join("")}
|
||||
${antiItems.map((item) => `<li class="pattern-item pattern-item--anti">${escapeHtml(item)}</li>`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pattern-column pattern-column--do">
|
||||
<span class="pattern-column-label" id="do-label-${i}">Do</span>
|
||||
<ul class="pattern-list" aria-labelledby="do-label-${i}">
|
||||
${category.items.map((item) => `<li class="pattern-item pattern-item--do">${item}</li>`).join("")}
|
||||
${category.items.map((item) => `<li class="pattern-item pattern-item--do">${escapeHtml(item)}</li>`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
container.innerHTML = `
|
||||
container.innerHTML = `
|
||||
<div class="pattern-tabs" role="tablist" aria-label="Pattern categories">${tabsHTML}</div>
|
||||
<div class="pattern-panels">${panelsHTML}</div>
|
||||
`;
|
||||
|
||||
const tabs = container.querySelectorAll('.pattern-tab');
|
||||
const panels = container.querySelectorAll('.pattern-panel');
|
||||
const tabs = container.querySelectorAll(".pattern-tab");
|
||||
const panels = container.querySelectorAll(".pattern-panel");
|
||||
|
||||
// Function to switch tabs
|
||||
const switchTab = (newTab) => {
|
||||
const tabName = newTab.dataset.tab;
|
||||
// Function to switch tabs
|
||||
const switchTab = (newTab) => {
|
||||
const tabName = newTab.dataset.tab;
|
||||
|
||||
// Update ARIA attributes on all tabs
|
||||
tabs.forEach(t => {
|
||||
t.classList.remove('active');
|
||||
t.setAttribute('aria-selected', 'false');
|
||||
t.setAttribute('tabindex', '-1');
|
||||
});
|
||||
// Update ARIA attributes on all tabs
|
||||
tabs.forEach((t) => {
|
||||
t.classList.remove("active");
|
||||
t.setAttribute("aria-selected", "false");
|
||||
t.setAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
// Activate the new tab
|
||||
newTab.classList.add('active');
|
||||
newTab.setAttribute('aria-selected', 'true');
|
||||
newTab.setAttribute('tabindex', '0');
|
||||
newTab.focus();
|
||||
// Activate the new tab
|
||||
newTab.classList.add("active");
|
||||
newTab.setAttribute("aria-selected", "true");
|
||||
newTab.setAttribute("tabindex", "0");
|
||||
newTab.focus();
|
||||
|
||||
// Update panels
|
||||
panels.forEach(p => {
|
||||
p.classList.remove('active');
|
||||
p.setAttribute('hidden', '');
|
||||
});
|
||||
const activePanel = container.querySelector(`[data-panel="${tabName}"]`);
|
||||
activePanel.classList.add('active');
|
||||
activePanel.removeAttribute('hidden');
|
||||
};
|
||||
// Update panels
|
||||
panels.forEach((p) => {
|
||||
p.classList.remove("active");
|
||||
p.setAttribute("hidden", "");
|
||||
});
|
||||
const escapedTabName =
|
||||
typeof CSS !== "undefined" && CSS.escape
|
||||
? CSS.escape(tabName)
|
||||
: tabName.replaceAll('"', '\\"');
|
||||
const activePanel = container.querySelector(
|
||||
`[data-panel="${escapedTabName}"]`,
|
||||
);
|
||||
if (!activePanel) return;
|
||||
activePanel.classList.add("active");
|
||||
activePanel.removeAttribute("hidden");
|
||||
};
|
||||
|
||||
// Tab click handling
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => switchTab(tab));
|
||||
});
|
||||
// Tab click handling
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener("click", () => switchTab(tab));
|
||||
});
|
||||
|
||||
// Keyboard navigation (Arrow keys, Home, End)
|
||||
tabs.forEach((tab, index) => {
|
||||
tab.addEventListener('keydown', (e) => {
|
||||
let targetIndex = index;
|
||||
// Keyboard navigation (Arrow keys, Home, End)
|
||||
tabs.forEach((tab, index) => {
|
||||
tab.addEventListener("keydown", (e) => {
|
||||
let targetIndex = index;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
targetIndex = index === 0 ? tabs.length - 1 : index - 1;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
targetIndex = index === tabs.length - 1 ? 0 : index + 1;
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
targetIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
targetIndex = tabs.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
targetIndex = index === 0 ? tabs.length - 1 : index - 1;
|
||||
break;
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
targetIndex = index === tabs.length - 1 ? 0 : index + 1;
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
targetIndex = 0;
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
targetIndex = tabs.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
switchTab(tabs[targetIndex]);
|
||||
});
|
||||
});
|
||||
switchTab(tabs[targetIndex]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -216,45 +256,57 @@ function renderPatternsWithTabs(patterns, antipatterns) {
|
||||
|
||||
// Handle bundle download clicks via event delegation
|
||||
document.addEventListener("click", (e) => {
|
||||
const bundleBtn = e.target.closest("[data-bundle]");
|
||||
if (bundleBtn) {
|
||||
const provider = bundleBtn.dataset.bundle;
|
||||
const prefixToggle = document.getElementById('prefix-toggle');
|
||||
const usePrefixed = prefixToggle && prefixToggle.checked;
|
||||
const bundleName = usePrefixed ? `${provider}-prefixed` : provider;
|
||||
window.location.href = `/api/download/bundle/${bundleName}`;
|
||||
}
|
||||
const bundleBtn = e.target.closest("[data-bundle]");
|
||||
if (bundleBtn) {
|
||||
const provider = bundleBtn.dataset.bundle;
|
||||
const prefixToggle = document.getElementById("prefix-toggle");
|
||||
const usePrefixed = prefixToggle && prefixToggle.checked;
|
||||
const bundleName = usePrefixed ? `${provider}-prefixed` : provider;
|
||||
window.location.href = `/api/download/bundle/${bundleName}`;
|
||||
}
|
||||
|
||||
// Handle copy button clicks
|
||||
const copyBtn = e.target.closest("[data-copy]");
|
||||
if (copyBtn) {
|
||||
const textToCopy = copyBtn.dataset.copy;
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
copyBtn.classList.add('copied');
|
||||
setTimeout(() => copyBtn.classList.remove('copied'), 1500);
|
||||
});
|
||||
}
|
||||
// Handle copy button clicks
|
||||
const copyBtn = e.target.closest("[data-copy]");
|
||||
if (copyBtn) {
|
||||
const textToCopy = copyBtn.dataset.copy;
|
||||
const onCopySuccess = () => {
|
||||
copyBtn.classList.add("copied");
|
||||
setTimeout(() => copyBtn.classList.remove("copied"), 1500);
|
||||
};
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(textToCopy)
|
||||
.then(onCopySuccess)
|
||||
.catch(() => {
|
||||
if (copyTextFallback(textToCopy)) {
|
||||
onCopySuccess();
|
||||
}
|
||||
});
|
||||
} else if (copyTextFallback(textToCopy)) {
|
||||
onCopySuccess();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ============================================
|
||||
// STARTUP
|
||||
// ============================================
|
||||
|
||||
function init() {
|
||||
initAnchorScroll();
|
||||
initHashTracking();
|
||||
initLensEffect();
|
||||
initScrollReveal();
|
||||
initGlassTerminal();
|
||||
initFrameworkViz();
|
||||
loadContent();
|
||||
initAnchorScroll();
|
||||
initHashTracking();
|
||||
initLensEffect();
|
||||
initScrollReveal();
|
||||
initGlassTerminal();
|
||||
initFrameworkViz();
|
||||
loadContent();
|
||||
|
||||
document.body.classList.add("loaded");
|
||||
document.body.classList.add("loaded");
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
init();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user