about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorelioat <elioat@tilde.institute>2025-03-15 21:32:47 -0400
committerelioat <elioat@tilde.institute>2025-03-15 21:32:47 -0400
commit08bc2eccd539f1bd30d9807e76de79fd30b389c0 (patch)
tree5889390264a295673cb8f9a79ac0ab4949969f18
parent048d5be07a287e7ea0650264eb28908e15993dda (diff)
downloadtour-08bc2eccd539f1bd30d9807e76de79fd30b389c0.tar.gz
*
-rw-r--r--html/immoral/app.js730
-rw-r--r--html/immoral/index.html167
2 files changed, 897 insertions, 0 deletions
diff --git a/html/immoral/app.js b/html/immoral/app.js
new file mode 100644
index 0000000..f90a6f3
--- /dev/null
+++ b/html/immoral/app.js
@@ -0,0 +1,730 @@
+/**
+ * An Immoral Web Font Vacuum
+ * A tool to find and extract web fonts from any website
+ * 
+ * We sort of set up a pipeline where each step processes the data and passes it to the next:
+ * 1. URL Input > 2. Fetch HTML > 3. Parse & Extract > 4. Process CSS > 5. Display Results
+ * 
+ */
+
+/**
+ * Proxy List
+ * Proxies are buggy, and temperamental...so why not use a whole lot of them!
+ * List of CORS proxies we can try if the main one fails.
+ * Keeps track of the last working proxy to optimize future requests.
+ * @type {Array<{name: string, url: string, urlFormatter: (url: string) => string}>}
+ */
+const CORS_PROXIES = [
+    {
+        name: 'allorigins',
+        url: 'https://api.allorigins.win/raw?url=',
+        urlFormatter: url => `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`
+    },
+    {
+        name: 'corsproxy.io',
+        url: 'https://corsproxy.io/?',
+        urlFormatter: url => `https://corsproxy.io/?${encodeURIComponent(url)}`
+    },
+    {
+        name: 'cors.sh',
+        url: 'https://cors.sh/',
+        urlFormatter: url => `https://cors.sh/${url}`
+    },
+    {
+        name: 'corsanywhere',
+        url: 'https://cors-anywhere.herokuapp.com/', // FIXME: pretty certain this one doesn't work without human intervention
+        urlFormatter: url => `https://cors-anywhere.herokuapp.com/${url}`
+    },
+    {
+        name: 'thingproxy',
+        url: 'https://thingproxy.freeboard.io/fetch/',
+        urlFormatter: url => `https://thingproxy.freeboard.io/fetch/${url}`
+    }
+];
+
+// Keep track of which proxy worked last
+let lastWorkingProxyIndex = 0;
+let proxyFailureCount = 0;
+const MAX_PROXY_FAILURES = 3;
+
+async function fetchWithProxies(url, attempt = 0, isBinary = false) {
+    // Start with the last working proxy
+    const startIndex = lastWorkingProxyIndex;
+    
+    for (let i = 0; i < CORS_PROXIES.length; i++) {
+        // Calculate the current proxy index, wrapping around if necessary
+        const proxyIndex = (startIndex + i) % CORS_PROXIES.length;
+        const proxy = CORS_PROXIES[proxyIndex];
+        
+        try {
+            console.log(`Trying proxy: ${proxy.name} for URL: ${url}`);
+            
+            const fetchOptions = {
+                headers: {
+                    'Accept': isBinary ? '*/*' : 'text/html,application/xhtml+xml,text/css',
+                    'Origin': window.location.origin
+                },
+                mode: 'cors'
+            };
+            
+            const response = await fetch(proxy.urlFormatter(url), fetchOptions);
+            
+            if (response.ok) {
+                lastWorkingProxyIndex = proxyIndex;
+                proxyFailureCount = 0;
+                return response;
+            }
+        } catch (error) {
+            console.log(`Proxy ${proxy.name} failed:`, error);
+            proxyFailureCount++;
+            
+            // If we've had too many failures, wait a bit before continuing
+            if (proxyFailureCount >= MAX_PROXY_FAILURES) {
+                await new Promise(resolve => setTimeout(resolve, 1000));
+                proxyFailureCount = 0;
+            }
+        }
+    }
+    
+    throw new Error('All proxies failed to fetch the resource');
+}
+
+async function downloadFont(url, filename) {
+    try {
+        console.log('Downloading font from:', url);
+        const response = await fetchWithProxies(url, 0, true);
+        
+        const arrayBuffer = await response.arrayBuffer();
+        if (arrayBuffer.byteLength === 0) {
+            throw new Error('Received empty font file');
+        }
+        
+        // Convert ArrayBuffer to Blob with proper MIME type
+        const blob = new Blob([arrayBuffer], { type: getFontMimeType(url) });
+        
+        // Temporary link to trigger the download
+        const objectUrl = URL.createObjectURL(blob);
+        const link = document.createElement('a');
+        link.href = objectUrl;
+        link.download = filename;
+        
+        document.body.appendChild(link);
+        link.click();
+        
+        // Small delay to ensure download starts before cleanup
+        setTimeout(() => {
+            document.body.removeChild(link);
+            URL.revokeObjectURL(objectUrl);
+        }, 100);
+        
+        return true;
+    } catch (error) {
+        console.error('Error downloading font:', error);
+        alert(`Error downloading font: ${error.message}`);
+        return false;
+    }
+}
+
+// Assume the MIME type based on the file's extension
+function getFontMimeType(url) {
+    const extension = url.split('.').pop().toLowerCase().split('?')[0];
+    switch (extension) {
+        case 'woff':
+            return 'font/woff';
+        case 'woff2':
+            return 'font/woff2';
+        case 'ttf':
+            return 'font/ttf';
+        case 'otf':
+            return 'font/otf';
+        default:
+            return 'application/octet-stream';
+    }
+}
+
+/**
+ * Sets up event listeners and initializes the app.
+ * 
+ * 1. User enters URL and clicks Analyze
+ * 2. Fetch the HTML through a CORS proxy
+ * 3. Parse HTML to find:
+ *    - Direct font links
+ *    - Stylesheet links
+ *    - Inline styles
+ * 4. Process each CSS source to find @font-face rules
+ * 5. Display results with preview/download options
+ */
+document.addEventListener('DOMContentLoaded', () => {
+    const urlInput = document.getElementById('urlInput');
+    const analyzeBtn = document.getElementById('analyzeBtn');
+    const resultsDiv = document.getElementById('results');
+    const errorDiv = document.getElementById('error');
+    
+    /**
+     * Two different methods to load the font:
+     * - Using a data URL
+     * - Fallback: Using a blob URL if data URL fails
+     * 
+     * @param {string} url - The URL of the font file to preview
+     * @param {string} fontFamily - The font-family name to use
+     * @returns {Promise<boolean>} - Whether the font was successfully loaded
+     */
+    async function previewFont(url, fontFamily) {
+        try {
+            console.log('Loading font from:', url);
+            const response = await fetchWithProxies(url, 0, true);
+            
+            const arrayBuffer = await response.arrayBuffer();
+            if (arrayBuffer.byteLength === 0) {
+                throw new Error('Received empty font file');
+            }
+
+            // Convert ArrayBuffer to Blob
+            const blob = new Blob([arrayBuffer], { type: getFontMimeType(url) });
+            
+            // Try using a data URL instead of a blob URL
+            const reader = new FileReader();
+            
+            return new Promise((resolve, reject) => {
+                reader.onload = async function() {
+                    try {
+                        const dataUrl = reader.result;
+                        
+                        const fontFace = new FontFace(fontFamily, `url(${dataUrl})`, {
+                            style: 'normal',
+                            weight: '400',
+                            display: 'swap'
+                        });
+                        
+                        const loadedFont = await fontFace.load();
+                        document.fonts.add(loadedFont);
+                        
+                        resolve(true);
+                    } catch (loadError) {
+                        console.error('Font load error:', loadError);
+                        
+                        // Try fallback method with blob URL
+                        try {
+                            const fontUrl = URL.createObjectURL(blob);
+                            const fontFace = new FontFace(fontFamily, `url(${fontUrl})`, {
+                                style: 'normal',
+                                weight: '400',
+                                display: 'swap'
+                            });
+                            
+                            const loadedFont = await fontFace.load();
+                            document.fonts.add(loadedFont);
+                            
+                            URL.revokeObjectURL(fontUrl);
+                            resolve(true);
+                        } catch (fallbackError) {
+                            console.error('Font fallback load error:', fallbackError);
+                            reject(fallbackError);
+                        }
+                    }
+                };
+                
+                reader.onerror = function() {
+                    reject(new Error('Failed to read font file'));
+                };
+                
+                reader.readAsDataURL(blob);
+            });
+        } catch (error) {
+            console.error('Error loading font for preview:', error);
+            return false;
+        }
+    }
+    
+    /**
+     * Click handler for the Analyze button.
+     * 
+     * 1. Validate input URL
+     * 2. Fetch and parse webpage
+     * 3. Look for fonts in:
+     *    - Direct links (preload, regular links)
+     *    - CSS files (external and inline)
+     *    - Common font directories
+     * 4. Display results
+     */
+    analyzeBtn.addEventListener('click', async () => {
+        const url = urlInput.value.trim();
+        if (!url) {
+            showError('Please enter a valid URL');
+            return;
+        }
+
+        try {
+            // Clear previous results and errors
+            resultsDiv.innerHTML = '';
+            errorDiv.style.display = 'none';
+
+            // Show loading state
+            resultsDiv.innerHTML = '<p>Analyzing webpage. Sometimes this takes a while.</p>';
+
+            // Fetch the target webpage through the proxy system
+            const response = await fetchWithProxies(url);
+            const html = await response.text();
+
+            // Create a temporary DOM to parse the HTML
+            const parser = new DOMParser();
+            const doc = parser.parseFromString(html, 'text/html');
+
+            // Find all potential font sources
+            const fontUrls = new Set();
+            
+            // Get the base URL (domain)
+            const baseUrlObj = new URL(url);
+            const domain = baseUrlObj.origin;
+            
+            // Brute force common font paths, for scenarios where the font is not found in the css
+            const commonFontPaths = [
+                '/assets/fonts/',
+                '/fonts/',
+                '/assets/',
+                '/css/fonts/',
+                '/wp-content/themes/*/fonts/',
+                '/static/fonts/'
+            ];
+            
+            console.log('Checking common font paths for:', domain);
+
+            // Check for direct font links in the HTML
+            extractDirectFontLinks(doc, url, fontUrls);
+
+            // Check stylesheet links
+            const cssPromises = Array.from(doc.querySelectorAll('link[rel="stylesheet"]')).map(link => {
+                const href = link.getAttribute('href');
+                if (!href) return Promise.resolve();
+                
+                const cssUrl = new URL(href, url).href;
+                console.log('Processing CSS URL:', cssUrl);
+                return processCssUrl(cssUrl, fontUrls, url);
+            });
+
+            // Wait for all CSS files to be processed
+            await Promise.all(cssPromises.filter(Boolean));
+
+            // Check style tags
+            doc.querySelectorAll('style').forEach(style => {
+                extractFontUrlsFromCss(style.textContent, url, fontUrls);
+            });
+
+            // If no fonts found, try checking common paths
+            if (fontUrls.size === 0) {
+                console.log('No fonts found in CSS, checking common paths...');
+                
+                const commonPathPromises = commonFontPaths.map(async path => {
+                    try {
+                        // Try to access the directory
+                        const directoryUrl = new URL(path, domain).href;
+                        console.log('Checking directory:', directoryUrl);
+                        
+                        // We can't list directory contents directly, but we can try common font names
+                        const commonFontNames = ['font', 'fonts', 'webfont', 'custom-font', 'main'];
+                        const fontExtensions = ['woff', 'woff2', 'ttf', 'otf'];
+                        
+                        for (const name of commonFontNames) {
+                            for (const ext of fontExtensions) {
+                                const fontUrl = `${directoryUrl}${name}.${ext}`;
+                                try {
+                                    const fontResponse = await fetchWithProxies(fontUrl, 0, true);
+                                    if (fontResponse.ok) {
+                                        console.log('Found font at common path:', fontUrl);
+                                        fontUrls.add({
+                                            url: fontUrl,
+                                            family: 'Unknown Font',
+                                            filename: `${name}.${ext}`
+                                        });
+                                    }
+                                } catch (error) {
+                                    // Ignore errors for common path checks
+                                }
+                            }
+                        }
+                    } catch (error) {
+                        // Ignore errors for common path checks
+                    }
+                });
+                
+                await Promise.all(commonPathPromises);
+            }
+
+            // Display results
+            if (fontUrls.size === 0) {
+                resultsDiv.innerHTML = '<p>No web fonts (WOFF/TTF/WOFF2/OTF) were found on this page.</p>';
+            } else {
+                displayFontUrls(fontUrls);
+            }
+
+        } catch (error) {
+            showError(`Error analyzing the webpage: ${error.message}`);
+            console.error('Full error:', error);
+        }
+    });
+
+    /**
+     * Processes a the URL of a CSS file to extract font information.
+     * 
+     * @param {string} cssUrl - The URL of the CSS file to process
+     * @param {Set} fontUrls - Set to store found font URLs
+     * @param {string} baseUrl - Base URL for resolving relative paths
+     */
+    async function processCssUrl(cssUrl, fontUrls, baseUrl) {
+        try {
+            console.log('Fetching CSS from:', cssUrl);
+            const response = await fetchWithProxies(cssUrl);
+            const css = await response.text();
+            
+            // Extract font URLs from the CSS content
+            extractFontUrlsFromCss(css, cssUrl, fontUrls);
+        } catch (error) {
+            console.error(`Error processing CSS from ${cssUrl}:`, error);
+        }
+    }
+
+    /**
+     * Extract font URLs from CSS content
+     * 
+     * @param {string} css - The CSS content to process
+     * @param {string} cssUrl - The URL of the CSS file (for resolving relative paths)
+     * @param {Set} fontUrls - Set to store found font URLs
+     */
+    function extractFontUrlsFromCss(css, cssUrl, fontUrls) {
+        // Get the base URL for resolving relative paths
+        const baseUrl = new URL(cssUrl).origin;
+        
+        // Match @font-face blocks
+        const fontFaceRegex = /@font-face\s*{[^}]*}/g;
+        const urlRegex = /url\(['"]?([^'"\)]+)['"]?\)/g;
+        const fontFamilyRegex = /font-family\s*:\s*['"]?([^'";]*)['"]?/;
+        
+        let fontFaceMatch;
+        while ((fontFaceMatch = fontFaceRegex.exec(css)) !== null) {
+            const fontFaceBlock = fontFaceMatch[0];
+            
+            // Extract font-family name
+            const familyMatch = fontFaceBlock.match(fontFamilyRegex);
+            const fontFamily = familyMatch ? familyMatch[1].trim() : 'Unknown Font';
+            
+            // Clean up the CSS rule for display
+            const cleanRule = fontFaceBlock.replace(/\s+/g, ' ').trim();
+            
+            // Extract all URLs from this @font-face block
+            let urlMatch;
+            while ((urlMatch = urlRegex.exec(fontFaceBlock)) !== null) {
+                try {
+                    let fontUrl = urlMatch[1].trim();
+                    
+                    // Skip data: URLs
+                    if (fontUrl.startsWith('data:')) {
+                        console.log('Skipping data: URL font');
+                        continue;
+                    }
+                    
+                    // Only process known font file types
+                    if (!fontUrl.match(/\.(woff2?|ttf|otf|eot)(\?.*)?$/i)) {
+                        continue;
+                    }
+                    
+                    // Resolve relative URLs
+                    if (fontUrl.startsWith('//')) {
+                        fontUrl = 'https:' + fontUrl;
+                    } else if (!fontUrl.startsWith('http')) {
+                        fontUrl = new URL(fontUrl, cssUrl).href;
+                    }
+                    
+                    const filename = fontUrl.split('/').pop().split('?')[0];
+                    console.log(`Found font in CSS: ${fontUrl} (${fontFamily})`);
+                    
+                    fontUrls.add({
+                        url: fontUrl,
+                        family: fontFamily,
+                        filename: filename,
+                        cssRule: cleanRule
+                    });
+                } catch (error) {
+                    console.error('Error processing font URL:', urlMatch[1], error);
+                }
+            }
+        }
+    }
+
+    /**
+     * Find direct font links in HTML.
+     * Checks two types of links:
+     * 1. Preload links with as="font"
+     * 2. Regular <a> tags pointing to font files
+     * 
+     * @param {Document} doc - Parsed HTML document
+     * @param {string} baseUrl - Base URL for resolving relative paths
+     * @param {Set} fontUrls - Set to store found font URLs
+     */
+    function extractDirectFontLinks(doc, baseUrl, fontUrls) {
+        // Check for preload links
+        doc.querySelectorAll('link[rel="preload"][as="font"], link[rel="stylesheet"]').forEach(link => {
+            const href = link.getAttribute('href');
+            if (href && href.match(/\.(woff|woff2|ttf|otf|css)(\?.*)?$/i)) {
+                try {
+                    const absoluteUrl = new URL(href, baseUrl).href;
+                    if (href.match(/\.css(\?.*)?$/i)) {
+                        processCssUrl(absoluteUrl, fontUrls, baseUrl);
+                    } else {
+                        const filename = href.split('/').pop().split('?')[0];
+                        let fontFamilyName = 'Unknown Font';
+                        if (link.dataset.fontFamily) {
+                            fontFamilyName = link.dataset.fontFamily;
+                        }
+                        
+                        console.log(`Found preloaded font: ${absoluteUrl} (${fontFamilyName})`);
+                        fontUrls.add({
+                            url: absoluteUrl,
+                            family: fontFamilyName,
+                            filename: filename,
+                            cssRule: `@font-face { font-family: "${fontFamilyName}"; src: url("${absoluteUrl}") format("${getFormatFromFilename(filename)}"); }`
+                        });
+                    }
+                } catch (error) {
+                    console.error('Error resolving font URL:', href, error);
+                }
+            }
+        });
+    }
+
+    /**
+     * Get the format string for a font file based on its filename
+     * @param {string} filename 
+     * @returns {string}
+     */
+    function getFormatFromFilename(filename) {
+        const ext = filename.split('.').pop().toLowerCase();
+        switch (ext) {
+            case 'woff2':
+                return 'woff2';
+            case 'woff':
+                return 'woff';
+            case 'ttf':
+                return 'truetype';
+            case 'otf':
+                return 'opentype';
+            default:
+                return ext;
+        }
+    }
+
+    /**
+     * The V of MVC
+     *  
+     * - Auto-preview for 3 or fewer fonts
+     * - Manual preview toggle for 4+ fonts
+     * - Download buttons
+     * - Font information display
+     * - CSS rule display
+     * - Preview with multiple sizes
+     * 
+     * @param {Array} urls - Array of font data objects to display
+     */
+    async function displayFontUrls(urls) {
+        const resultsDiv = document.getElementById('results');
+        resultsDiv.innerHTML = '';
+
+        // Convert urls to array if it's a Set, or use as is if already array
+        const fontsArray = Array.isArray(urls) ? urls : Array.from(urls);
+        
+        if (fontsArray.length === 0) {
+            resultsDiv.innerHTML = '<p>No fonts found on this webpage.</p>';
+            return;
+        }
+
+        const container = document.createElement('div');
+        container.style.display = 'flex';
+        container.style.flexDirection = 'column';
+        container.style.gap = '2rem';
+        container.style.maxWidth = '800px';
+        container.style.margin = '0 auto';
+
+        const shouldAutoPreview = fontsArray.length < 4;
+
+        for (let fontData of fontsArray) {
+            const fontItem = document.createElement('div');
+            fontItem.style.border = '2px solid var(--dark)';
+            fontItem.style.padding = '1rem';
+            fontItem.style.background = 'var(--beige)';
+            fontItem.style.position = 'relative';
+
+            const accentBar = document.createElement('div');
+            accentBar.style.position = 'absolute';
+            accentBar.style.top = '0';
+            accentBar.style.left = '0';
+            accentBar.style.right = '0';
+            accentBar.style.height = '4px';
+            accentBar.style.background = 'var(--accent)';
+            fontItem.appendChild(accentBar);
+
+            const fontInfo = document.createElement('div');
+            fontInfo.style.marginTop = '0.5rem';
+            
+            const fontName = document.createElement('h3');
+            fontName.style.margin = '0';
+            fontName.style.textTransform = 'uppercase';
+            fontName.textContent = fontData.family;
+            fontInfo.appendChild(fontName);
+
+            const fontType = document.createElement('div');
+            fontType.style.display = 'inline-block';
+            fontType.style.background = 'var(--dark)';
+            fontType.style.color = 'var(--beige)';
+            fontType.style.padding = '0.2rem 0.5rem';
+            fontType.style.marginTop = '0.5rem';
+            fontType.style.fontSize = '0.8rem';
+            fontType.textContent = fontData.filename.split('.').pop().toUpperCase();
+            fontInfo.appendChild(fontType);
+
+            const previewContainer = document.createElement('div');
+            previewContainer.style.marginTop = '1rem';
+            previewContainer.style.padding = '1rem';
+            previewContainer.style.border = '1px dashed var(--dark)';
+            
+            // Hide preview container initially if not auto-previewing
+            if (!shouldAutoPreview) {
+                previewContainer.style.display = 'none';
+            }
+            
+            const previewLabel = document.createElement('div');
+            previewLabel.style.fontWeight = 'bold';
+            previewLabel.style.marginBottom = '0.5rem';
+            previewLabel.textContent = 'Preview:';
+            previewContainer.appendChild(previewLabel);
+
+            const preview = document.createElement('div');
+            preview.style.marginBottom = '1rem';
+            preview.id = `preview-${fontData.filename}`;
+            preview.textContent = 'The quick brown fox jumps over the lazy dog 0123456789';
+            previewContainer.appendChild(preview);
+
+            // Add CSS Rule section
+            if (fontData.cssRule) {
+                const cssContainer = document.createElement('div');
+                cssContainer.style.marginTop = '1rem';
+                cssContainer.style.marginBottom = '1rem';
+                cssContainer.style.padding = '1rem';
+                cssContainer.style.background = 'var(--dark)';
+                cssContainer.style.color = 'var(--beige)';
+                cssContainer.style.borderRadius = '4px';
+                cssContainer.style.position = 'relative';
+                
+                const cssLabel = document.createElement('div');
+                cssLabel.style.position = 'absolute';
+                cssLabel.style.top = '-10px';
+                cssLabel.style.left = '10px';
+                cssLabel.style.background = 'var(--accent)';
+                cssLabel.style.color = 'var(--dark)';
+                cssLabel.style.padding = '0 0.5rem';
+                cssLabel.style.fontSize = '0.8rem';
+                cssLabel.style.fontWeight = 'bold';
+                cssLabel.textContent = '@font-face';
+                cssContainer.appendChild(cssLabel);
+                
+                const cssContent = document.createElement('pre');
+                cssContent.style.margin = '0';
+                cssContent.style.fontFamily = 'monospace';
+                cssContent.style.fontSize = '0.9rem';
+                cssContent.style.whiteSpace = 'pre-wrap';
+                cssContent.style.wordBreak = 'break-all';
+                
+                // Format the CSS rule nicely
+                const formattedCss = fontData.cssRule
+                    .replace(/{/, ' {\n    ')
+                    .replace(/;/g, ';\n    ')
+                    .replace(/}/g, '\n}')
+                    .replace(/\s+}/g, '}')
+                    .trim();
+                
+                cssContent.textContent = formattedCss;
+                cssContainer.appendChild(cssContent);
+                
+                fontInfo.appendChild(cssContainer);
+            }
+
+            const sizeVariations = document.createElement('div');
+            sizeVariations.style.borderTop = '1px solid var(--dark)';
+            sizeVariations.style.paddingTop = '0.5rem';
+            sizeVariations.style.marginTop = '0.5rem';
+            
+            [12, 18, 24].forEach(size => {
+                const sizePreview = document.createElement('div');
+                sizePreview.style.fontSize = `${size}px`;
+                sizePreview.textContent = `${size}px - The quick brown fox jumps over the lazy dog 0123456789`;
+                sizeVariations.appendChild(sizePreview);
+            });
+            
+            previewContainer.appendChild(sizeVariations);
+
+            const buttonContainer = document.createElement('div');
+            buttonContainer.style.display = 'flex';
+            buttonContainer.style.gap = '0.5rem';
+            buttonContainer.style.marginTop = '1rem';
+
+            const downloadBtn = document.createElement('button');
+            downloadBtn.textContent = '⬇ Download';
+            downloadBtn.style.flex = '1';
+            downloadBtn.addEventListener('click', () => downloadFont(fontData.url, fontData.filename));
+            buttonContainer.appendChild(downloadBtn);
+
+            if (!shouldAutoPreview) {
+                const previewBtn = document.createElement('button');
+                previewBtn.textContent = '👁 Preview';
+                previewBtn.style.flex = '1';
+                
+                let isPreviewVisible = false;
+                previewBtn.addEventListener('click', async () => {
+                    if (!isPreviewVisible) {
+                        previewContainer.style.display = 'block';
+                        const previewElement = document.getElementById(`preview-${fontData.filename}`);
+                        if (await previewFont(fontData.url, fontData.family)) {
+                            previewElement.style.fontFamily = fontData.family;
+                            sizeVariations.querySelectorAll('div').forEach(div => {
+                                div.style.fontFamily = fontData.family;
+                            });
+                            previewBtn.textContent = '👁 Hide Preview';
+                            isPreviewVisible = true;
+                        } else {
+                            // If preview fails, hide the container again
+                            previewContainer.style.display = 'none';
+                        }
+                    } else {
+                        previewContainer.style.display = 'none';
+                        previewBtn.textContent = '👁 Preview';
+                        isPreviewVisible = false;
+                    }
+                });
+                buttonContainer.appendChild(previewBtn);
+            }
+
+            fontItem.appendChild(fontInfo);
+            fontItem.appendChild(previewContainer);
+            fontItem.appendChild(buttonContainer);
+            container.appendChild(fontItem);
+
+            if (shouldAutoPreview) {
+                // Use setTimeout to ensure the DOM is ready
+                setTimeout(async () => {
+                    const previewElement = document.getElementById(`preview-${fontData.filename}`);
+                    if (await previewFont(fontData.url, fontData.family)) {
+                        previewElement.style.fontFamily = fontData.family;
+                        sizeVariations.querySelectorAll('div').forEach(div => {
+                            div.style.fontFamily = fontData.family;
+                        });
+                    }
+                }, 100);
+            }
+        }
+
+        resultsDiv.appendChild(container);
+    }
+
+    function showError(message) {
+        errorDiv.textContent = message;
+        errorDiv.style.display = 'block';
+    }
+}); 
\ No newline at end of file
diff --git a/html/immoral/index.html b/html/immoral/index.html
new file mode 100644
index 0000000..b6eee24
--- /dev/null
+++ b/html/immoral/index.html
@@ -0,0 +1,167 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>An Immoral Web Font Vacuum</title>
+    <meta name="description" content="Enter a URL to find, preview, and download web fonts (WOFF/TTF/WOFF2/OTF) present on the page.">
+    <style>
+        :root {
+            --beige: #f5f2e9;
+            --dark: #111111;
+            --accent: #ff4d00;
+            --grid-line: #ccbea7;
+            --container-bg: #ffffff;
+            --focus-outline: #2563eb;
+        }
+        
+        body {
+            font-family: 'Courier New', monospace;
+            max-width: 900px;
+            margin: 0 auto;
+            padding: 1rem;
+            line-height: 1.5;
+            background: var(--beige);
+            color: var(--dark);
+        }
+        
+        h1, h2 {
+            text-transform: uppercase;
+            letter-spacing: 2px;
+            border-bottom: 3px solid var(--accent);
+            padding-bottom: 0.5rem;
+            font-weight: 900;
+        }
+        
+        .container {
+            background: var(--container-bg);
+            padding: 2rem;
+            border: 3px solid var(--dark);
+            box-shadow: 8px 8px 0 var(--dark);
+            margin-top: 2rem;
+        }
+        
+        .input-group {
+            display: flex;
+            gap: 1rem;
+            margin-bottom: 2rem;
+            border: 2px solid var(--dark);
+            padding: 1rem;
+            background: var(--beige);
+        }
+        
+        .sr-only {
+            position: absolute;
+            width: 1px;
+            height: 1px;
+            padding: 0;
+            margin: -1px;
+            overflow: hidden;
+            clip: rect(0, 0, 0, 0);
+            white-space: nowrap;
+            border: 0;
+        }
+        
+        input[type="url"] {
+            flex: 1;
+            padding: 0.75rem;
+            font-size: 1rem;
+            border: 2px solid var(--dark);
+            background: var(--beige);
+            font-family: 'Courier New', monospace;
+        }
+        
+        input[type="url"]:focus {
+            outline: 3px solid var(--focus-outline);
+            outline-offset: 2px;
+        }
+        
+        button {
+            padding: 0.75rem 1.5rem;
+            font-size: 1rem;
+            background: var(--dark);
+            color: var(--beige);
+            border: 2px solid var(--dark);
+            cursor: pointer;
+            text-transform: uppercase;
+            font-weight: bold;
+            font-family: 'Courier New', monospace;
+            transition: all 0.2s;
+        }
+        
+        button:hover,
+        button:focus-visible {
+            background: var(--accent);
+            transform: translateY(-2px);
+            outline: 3px solid var(--focus-outline);
+            outline-offset: 2px;
+        }
+        
+        button:focus:not(:focus-visible) {
+            outline: none;
+        }
+        
+        .error {
+            color: var(--accent);
+            padding: 1rem;
+            background: rgba(255, 77, 0, 0.1);
+            border: 2px solid var(--accent);
+            margin-top: 1rem;
+            display: none;
+            font-weight: bold;
+            role: "alert";
+        }
+        
+        #results {
+            margin-top: 1rem;
+        }
+
+        /* Skip link for keyboard users */
+        .skip-link {
+            position: absolute;
+            top: -40px;
+            left: 0;
+            background: var(--dark);
+            color: var(--beige);
+            padding: 8px;
+            z-index: 100;
+            transition: top 0.2s;
+        }
+
+        .skip-link:focus {
+            top: 0;
+            outline: 3px solid var(--focus-outline);
+        }
+    </style>
+</head>
+<body>
+    <a href="#main-content" class="skip-link">Skip to main content</a>
+    <div class="container" id="main-content">
+        <h1>Immoral Web Font Vacuum</h1>
+        <p>Enter a URL to find, preview, and download web fonts (WOFF/TTF/WOFF2/OTF) present on the page.</p>
+        
+        <form class="input-group" role="search" aria-label="Website URL search form" onsubmit="event.preventDefault();">
+            <label for="urlInput" class="sr-only">Website URL</label>
+            <input 
+                type="url" 
+                id="urlInput" 
+                name="urlInput"
+                placeholder="Enter website URL (e.g., https://example.com)" 
+                required
+                aria-required="true"
+                aria-describedby="urlHint"
+            >
+            <span id="urlHint" class="sr-only">Enter the full website URL including https:// or http://</span>
+            <button 
+                id="analyzeBtn" 
+                type="submit"
+                aria-label="Analyze website for fonts"
+            >Analyze Fonts</button>
+        </form>
+
+        <div id="error" class="error" role="alert" aria-live="polite"></div>
+        <div id="results" role="region" aria-label="Font analysis results"></div>
+    </div>
+    <script src="app.js"></script>
+</body>
+</html>