12 changed files with 9155 additions and 14110 deletions
@ -1,244 +1,164 @@ |
|||||||
/** |
/** |
||||||
* Extracts the table of contents from AsciiDoc HTML output |
* HTML utility functions for processing AsciiDoctor output |
||||||
* Returns the TOC HTML and the content HTML without the TOC |
*
|
||||||
|
* Functions: |
||||||
|
* - extractTOC: Extract table of contents from HTML |
||||||
|
* - sanitizeHTML: Sanitize HTML to prevent XSS attacks |
||||||
|
* - processLinks: Add target="_blank" to external links |
||||||
*/ |
*/ |
||||||
export function extractTOC(html: string): { toc: string; contentWithoutTOC: string } { |
|
||||||
// AsciiDoc with toc: 'left' generates a TOC in a div with id="toc" or class="toc"
|
|
||||||
let tocContent = ''; |
|
||||||
let contentWithoutTOC = html; |
|
||||||
|
|
||||||
// Find the start of the TOC div - try multiple patterns
|
export interface TOCResult { |
||||||
const tocStartPatterns = [ |
toc: string; |
||||||
/<div\s+id=["']toc["']\s+class=["']toc["'][^>]*>/i, |
contentWithoutTOC: string; |
||||||
/<div\s+id=["']toc["'][^>]*>/i, |
} |
||||||
/<div\s+class=["']toc["'][^>]*>/i, |
|
||||||
/<nav\s+id=["']toc["'][^>]*>/i, |
|
||||||
]; |
|
||||||
|
|
||||||
let tocStartIdx = -1; |
|
||||||
let tocStartTag = ''; |
|
||||||
|
|
||||||
for (const pattern of tocStartPatterns) { |
|
||||||
const match = html.match(pattern); |
|
||||||
if (match && match.index !== undefined) { |
|
||||||
tocStartIdx = match.index; |
|
||||||
tocStartTag = match[0]; |
|
||||||
break; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (tocStartIdx === -1) { |
|
||||||
// No TOC found
|
|
||||||
return { toc: '', contentWithoutTOC: html }; |
|
||||||
} |
|
||||||
|
|
||||||
// Find the matching closing tag by counting div/nav tags
|
|
||||||
const searchStart = tocStartIdx + tocStartTag.length; |
|
||||||
let depth = 1; |
|
||||||
let i = searchStart; |
|
||||||
|
|
||||||
while (i < html.length && depth > 0) { |
|
||||||
// Look for opening or closing div/nav tags
|
|
||||||
if (i + 4 < html.length && html.substring(i, i + 4).toLowerCase() === '<div') { |
|
||||||
// Check if it's a closing tag
|
|
||||||
if (i + 5 < html.length && html[i + 4] === '/') { |
|
||||||
depth--; |
|
||||||
const closeIdx = html.indexOf('>', i); |
|
||||||
if (closeIdx === -1) break; |
|
||||||
i = closeIdx + 1; |
|
||||||
} else { |
|
||||||
// Opening tag - find the end (handle attributes and self-closing)
|
|
||||||
const closeIdx = html.indexOf('>', i); |
|
||||||
if (closeIdx === -1) break; |
|
||||||
// Check if it's self-closing (look for /> before the >)
|
|
||||||
const tagContent = html.substring(i, closeIdx); |
|
||||||
if (!tagContent.endsWith('/')) { |
|
||||||
depth++; |
|
||||||
} |
|
||||||
i = closeIdx + 1; |
|
||||||
} |
|
||||||
} else if (i + 5 < html.length && html.substring(i, i + 5).toLowerCase() === '</div') { |
|
||||||
depth--; |
|
||||||
const closeIdx = html.indexOf('>', i); |
|
||||||
if (closeIdx === -1) break; |
|
||||||
i = closeIdx + 1; |
|
||||||
} else if (i + 5 < html.length && html.substring(i, i + 5).toLowerCase() === '</nav') { |
|
||||||
depth--; |
|
||||||
const closeIdx = html.indexOf('>', i); |
|
||||||
if (closeIdx === -1) break; |
|
||||||
i = closeIdx + 1; |
|
||||||
} else if (i + 4 < html.length && html.substring(i, i + 4).toLowerCase() === '<nav') { |
|
||||||
// Handle opening nav tags
|
|
||||||
const closeIdx = html.indexOf('>', i); |
|
||||||
if (closeIdx === -1) break; |
|
||||||
const tagContent = html.substring(i, closeIdx); |
|
||||||
if (!tagContent.endsWith('/')) { |
|
||||||
depth++; |
|
||||||
} |
|
||||||
i = closeIdx + 1; |
|
||||||
} else { |
|
||||||
i++; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (depth === 0) { |
|
||||||
// Found the matching closing tag
|
|
||||||
const tocEndIdx = i; |
|
||||||
// Extract the TOC content (inner HTML)
|
|
||||||
const tocFullHTML = html.substring(tocStartIdx, tocEndIdx); |
|
||||||
// Extract just the inner content (without the outer div tags)
|
|
||||||
let innerStart = tocStartTag.length; |
|
||||||
let innerEnd = tocFullHTML.length; |
|
||||||
// Find the last </div> or </nav>
|
|
||||||
if (tocFullHTML.endsWith('</div>')) { |
|
||||||
innerEnd -= 6; |
|
||||||
} else if (tocFullHTML.endsWith('</nav>')) { |
|
||||||
innerEnd -= 7; |
|
||||||
} |
|
||||||
tocContent = tocFullHTML.substring(innerStart, innerEnd).trim(); |
|
||||||
|
|
||||||
// Remove the toctitle div if present (AsciiDoc adds "Table of Contents" title)
|
|
||||||
tocContent = tocContent.replace(/<div\s+id=["']toctitle["'][^>]*>.*?<\/div>\s*/gis, ''); |
|
||||||
tocContent = tocContent.trim(); |
|
||||||
|
|
||||||
// Remove the TOC from the content
|
/** |
||||||
contentWithoutTOC = html.substring(0, tocStartIdx) + html.substring(tocEndIdx); |
* Extract table of contents from AsciiDoctor HTML output |
||||||
|
* AsciiDoctor generates a <div id="toc"> with class="toc" containing the TOC |
||||||
|
*/ |
||||||
|
export function extractTOC(html: string): TOCResult { |
||||||
|
// Match the TOC div - AsciiDoctor generates it with id="toc" and class="toc"
|
||||||
|
const tocMatch = html.match(/<div[^>]*id=["']toc["'][^>]*>([\s\S]*?)<\/div>/i); |
||||||
|
|
||||||
|
if (tocMatch) { |
||||||
|
const toc = tocMatch[0]; // Full TOC div
|
||||||
|
const contentWithoutTOC = html.replace(toc, '').trim(); |
||||||
|
return { toc, contentWithoutTOC }; |
||||||
} |
} |
||||||
|
|
||||||
// Extract just the body content if the HTML includes full document structure
|
// Fallback: try to match by class="toc"
|
||||||
// AsciiDoctor might return full HTML with <html>, <head>, <body> tags
|
const tocClassMatch = html.match(/<div[^>]*class=["'][^"']*toc[^"']*["'][^>]*>([\s\S]*?)<\/div>/i); |
||||||
// Check if this is a full HTML document
|
|
||||||
const isFullDocument = /^\s*<!DOCTYPE|^\s*<html/i.test(contentWithoutTOC); |
if (tocClassMatch) { |
||||||
|
const toc = tocClassMatch[0]; |
||||||
if (isFullDocument) { |
const contentWithoutTOC = html.replace(toc, '').trim(); |
||||||
// Extract body content using a more robust approach
|
return { toc, contentWithoutTOC }; |
||||||
// Find the opening <body> tag
|
|
||||||
const bodyStartMatch = contentWithoutTOC.match(/<body[^>]*>/i); |
|
||||||
if (bodyStartMatch && bodyStartMatch.index !== undefined) { |
|
||||||
const bodyStart = bodyStartMatch.index + bodyStartMatch[0].length; |
|
||||||
|
|
||||||
// Find the closing </body> tag by searching backwards from the end
|
|
||||||
// This is more reliable than regex for nested content
|
|
||||||
const bodyEndMatch = contentWithoutTOC.lastIndexOf('</body>'); |
|
||||||
|
|
||||||
if (bodyEndMatch !== -1 && bodyEndMatch > bodyStart) { |
|
||||||
contentWithoutTOC = contentWithoutTOC.substring(bodyStart, bodyEndMatch).trim(); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
} |
||||||
|
|
||||||
// Remove any remaining document structure tags that might have slipped through
|
// No TOC found
|
||||||
contentWithoutTOC = contentWithoutTOC |
return { |
||||||
.replace(/<html[^>]*>/gi, '') |
toc: '', |
||||||
.replace(/<\/html>/gi, '') |
contentWithoutTOC: html, |
||||||
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '') |
}; |
||||||
.replace(/<body[^>]*>/gi, '') |
|
||||||
.replace(/<\/body>/gi, ''); |
|
||||||
|
|
||||||
// Clean up any extra whitespace
|
|
||||||
contentWithoutTOC = contentWithoutTOC.trim(); |
|
||||||
|
|
||||||
return { toc: tocContent, contentWithoutTOC }; |
|
||||||
} |
} |
||||||
|
|
||||||
/** |
/** |
||||||
* Performs basic HTML sanitization to prevent XSS |
* Sanitize HTML to prevent XSS attacks |
||||||
|
* Removes dangerous scripts and event handlers while preserving safe HTML |
||||||
|
*
|
||||||
|
* This is a basic sanitizer. For production use, consider using a library like DOMPurify |
||||||
*/ |
*/ |
||||||
export function sanitizeHTML(html: string): string { |
export function sanitizeHTML(html: string): string { |
||||||
|
let sanitized = html; |
||||||
|
|
||||||
// Remove script tags and their content
|
// Remove script tags and their content
|
||||||
html = html.replace(/<script[^>]*>.*?<\/script>/gis, ''); |
sanitized = sanitized.replace(/<script[\s\S]*?<\/script>/gi, ''); |
||||||
|
|
||||||
// Remove event handlers (onclick, onerror, etc.)
|
// Remove event handlers from attributes (onclick, onerror, etc.)
|
||||||
html = html.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, ''); |
sanitized = sanitized.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, ''); |
||||||
|
sanitized = sanitized.replace(/\s*on\w+\s*=\s*[^\s>]*/gi, ''); |
||||||
// Remove javascript: protocol in links
|
|
||||||
html = html.replace(/javascript:/gi, ''); |
// Remove javascript: protocol in href and src attributes
|
||||||
|
sanitized = sanitized.replace(/href\s*=\s*["']javascript:[^"']*["']/gi, 'href="#"'); |
||||||
// Remove data: URLs that could be dangerous
|
sanitized = sanitized.replace(/src\s*=\s*["']javascript:[^"']*["']/gi, 'src=""'); |
||||||
html = html.replace(/data:\s*text\/html/gi, ''); |
|
||||||
|
// Remove data: URLs that might contain scripts (allow images)
|
||||||
return html; |
// This is more permissive - you might want to be stricter
|
||||||
|
sanitized = sanitized.replace(/src\s*=\s*["']data:text\/html[^"']*["']/gi, 'src=""'); |
||||||
|
|
||||||
|
// Remove iframe with dangerous sources
|
||||||
|
sanitized = sanitized.replace(/<iframe[^>]*src\s*=\s*["']javascript:[^"']*["'][^>]*>[\s\S]*?<\/iframe>/gi, ''); |
||||||
|
|
||||||
|
// Remove object and embed tags (often used for XSS)
|
||||||
|
sanitized = sanitized.replace(/<object[\s\S]*?<\/object>/gi, ''); |
||||||
|
sanitized = sanitized.replace(/<embed[\s\S]*?>/gi, ''); |
||||||
|
|
||||||
|
// Remove style tags with potentially dangerous content
|
||||||
|
// We keep style attributes but remove <style> tags
|
||||||
|
sanitized = sanitized.replace(/<style[\s\S]*?<\/style>/gi, ''); |
||||||
|
|
||||||
|
// Remove link tags with javascript: or data: URLs
|
||||||
|
sanitized = sanitized.replace(/<link[^>]*href\s*=\s*["'](javascript|data):[^"']*["'][^>]*>/gi, ''); |
||||||
|
|
||||||
|
// Remove meta tags with http-equiv="refresh" (can be used for redirects)
|
||||||
|
sanitized = sanitized.replace(/<meta[^>]*http-equiv\s*=\s*["']refresh["'][^>]*>/gi, ''); |
||||||
|
|
||||||
|
return sanitized; |
||||||
} |
} |
||||||
|
|
||||||
/** |
/** |
||||||
* Processes HTML links to add target="_blank" to external links |
* Process links to add target="_blank" and rel="noreferrer noopener" to external links |
||||||
* This function is available for use but not currently called automatically. |
*
|
||||||
* It can be used in post-processing if needed. |
* External links are links that don't match the base domain. |
||||||
|
* Internal links (same domain) are left unchanged. |
||||||
*/ |
*/ |
||||||
export function processLinks(html: string, linkBaseURL: string): string { |
export function processLinks(html: string, linkBaseURL: string): string { |
||||||
// Extract domain from linkBaseURL for comparison
|
if (!linkBaseURL) { |
||||||
let linkBaseDomain = ''; |
return html; |
||||||
if (linkBaseURL) { |
} |
||||||
|
|
||||||
|
// Extract base domain from linkBaseURL
|
||||||
|
let baseDomain: string | null = null; |
||||||
|
try { |
||||||
|
const urlMatch = linkBaseURL.match(/^https?:\/\/([^\/]+)/); |
||||||
|
if (urlMatch) { |
||||||
|
baseDomain = urlMatch[1]; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// If parsing fails, don't process links
|
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
if (!baseDomain) { |
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
// Process anchor tags with href attributes
|
||||||
|
return html.replace(/<a\s+([^>]*\s+)?href\s*=\s*["']([^"']+)["']([^>]*?)>/gi, (match, before, href, after) => { |
||||||
|
// Skip if already has target attribute
|
||||||
|
if (match.includes('target=')) { |
||||||
|
return match; |
||||||
|
} |
||||||
|
|
||||||
|
// Skip if it's not an http/https link
|
||||||
|
if (!/^https?:\/\//i.test(href)) { |
||||||
|
return match; |
||||||
|
} |
||||||
|
|
||||||
|
// Skip if it's already a special link type (nostr, wikilink, etc.)
|
||||||
|
if (match.includes('class="nostr-link"') || |
||||||
|
match.includes('class="wikilink"') || |
||||||
|
match.includes('class="hashtag-link"')) { |
||||||
|
return match; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if it's an external link
|
||||||
|
let isExternal = true; |
||||||
try { |
try { |
||||||
// Use URL constructor if available (Node.js 10+)
|
const hrefMatch = href.match(/^https?:\/\/([^\/]+)/); |
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (hrefMatch && hrefMatch[1] === baseDomain) { |
||||||
const URLConstructor = (globalThis as any).URL; |
isExternal = false; |
||||||
if (URLConstructor) { |
|
||||||
const url = new URLConstructor(linkBaseURL); |
|
||||||
linkBaseDomain = url.hostname; |
|
||||||
} else { |
|
||||||
throw new Error('URL not available'); |
|
||||||
} |
} |
||||||
} catch { |
} catch { |
||||||
// Fallback to simple string parsing if URL constructor fails
|
// If parsing fails, assume external
|
||||||
const url = linkBaseURL.replace(/^https?:\/\//, ''); |
|
||||||
const parts = url.split('/'); |
|
||||||
if (parts.length > 0) { |
|
||||||
linkBaseDomain = parts[0]; |
|
||||||
} |
|
||||||
} |
} |
||||||
} |
|
||||||
|
// Only add target="_blank" to external links
|
||||||
// Regex to match <a> tags with href attributes
|
|
||||||
const linkRegex = /<a\s+([^>]*?)href\s*=\s*["']([^"']+)["']([^>]*?)>/g; |
|
||||||
|
|
||||||
return html.replace(linkRegex, (match, before, href, after) => { |
|
||||||
// Check if it's an external link (starts with http:// or https://)
|
|
||||||
const isExternal = href.startsWith('http://') || href.startsWith('https://'); |
|
||||||
|
|
||||||
if (isExternal) { |
if (isExternal) { |
||||||
// Check if it's pointing to our own domain
|
// Check if there's already a rel attribute
|
||||||
if (linkBaseDomain) { |
if (match.includes('rel=')) { |
||||||
try { |
// Add to existing rel attribute if it doesn't already have noreferrer noopener
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (!match.includes('noreferrer') && !match.includes('noopener')) { |
||||||
const URLConstructor = (globalThis as any).URL; |
return match.replace(/rel\s*=\s*["']([^"']+)["']/i, 'rel="$1 noreferrer noopener"'); |
||||||
if (URLConstructor) { |
|
||||||
const hrefUrl = new URLConstructor(href); |
|
||||||
if (hrefUrl.hostname === linkBaseDomain) { |
|
||||||
// Same domain - open in same tab (remove any existing target attribute)
|
|
||||||
return match.replace(/\s*target\s*=\s*["'][^"']*["']/gi, ''); |
|
||||||
} |
|
||||||
} else { |
|
||||||
throw new Error('URL not available'); |
|
||||||
} |
|
||||||
} catch { |
|
||||||
// If URL parsing fails, use simple string check
|
|
||||||
if (href.includes(linkBaseDomain)) { |
|
||||||
return match.replace(/\s*target\s*=\s*["'][^"']*["']/gi, ''); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// External link - add target="_blank" and rel="noopener noreferrer" if not already present
|
|
||||||
if (!match.includes('target=')) { |
|
||||||
if (!match.includes('rel=')) { |
|
||||||
return match.replace('>', ' target="_blank" rel="noopener noreferrer">'); |
|
||||||
} else { |
|
||||||
// Update existing rel attribute to include noopener if not present
|
|
||||||
const updatedMatch = match.replace(/rel\s*=\s*["']([^"']*)["']/gi, (relMatch, relValue) => { |
|
||||||
if (!relValue.includes('noopener')) { |
|
||||||
return `rel="${relValue} noopener noreferrer"`; |
|
||||||
} |
|
||||||
return relMatch; |
|
||||||
}); |
|
||||||
return updatedMatch.replace('>', ' target="_blank">'); |
|
||||||
} |
} |
||||||
|
// Add target="_blank" before the closing >
|
||||||
|
return match.replace(/>$/, ' target="_blank">'); |
||||||
|
} else { |
||||||
|
// Add both target and rel
|
||||||
|
return match.replace(/>$/, ' target="_blank" rel="noreferrer noopener">'); |
||||||
} |
} |
||||||
} else { |
|
||||||
// Local/relative link - ensure it opens in same tab (remove target if present)
|
|
||||||
return match.replace(/\s*target\s*=\s*["'][^"']*["']/gi, ''); |
|
||||||
} |
} |
||||||
|
|
||||||
return match; |
return match; |
||||||
}); |
}); |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,586 @@ |
|||||||
|
import { Parser } from '../parser'; |
||||||
|
import * as fs from 'fs'; |
||||||
|
import * as path from 'path'; |
||||||
|
import { ProcessResult } from '../types'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared utilities for generating test reports |
||||||
|
*/ |
||||||
|
|
||||||
|
export interface TestData { |
||||||
|
original: string; |
||||||
|
result: ProcessResult; |
||||||
|
} |
||||||
|
|
||||||
|
export interface ReportData { |
||||||
|
markdown: TestData; |
||||||
|
asciidoc: TestData; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate HTML test report from parsed documents |
||||||
|
*/ |
||||||
|
export function generateHTMLReport(data: ReportData): string { |
||||||
|
const { markdown, asciidoc } = data; |
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||||
|
<title>GC Parser Test Report</title> |
||||||
|
<style> |
||||||
|
* { |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; |
||||||
|
line-height: 1.6; |
||||||
|
color: #333; |
||||||
|
background: #f5f5f5; |
||||||
|
padding: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.container { |
||||||
|
max-width: 1400px; |
||||||
|
margin: 0 auto; |
||||||
|
} |
||||||
|
|
||||||
|
h1 { |
||||||
|
color: #2c3e50; |
||||||
|
margin-bottom: 10px; |
||||||
|
font-size: 2.5em; |
||||||
|
} |
||||||
|
|
||||||
|
.subtitle { |
||||||
|
color: #7f8c8d; |
||||||
|
margin-bottom: 30px; |
||||||
|
font-size: 1.1em; |
||||||
|
} |
||||||
|
|
||||||
|
.section { |
||||||
|
background: white; |
||||||
|
border-radius: 8px; |
||||||
|
padding: 30px; |
||||||
|
margin-bottom: 30px; |
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.section h2 { |
||||||
|
color: #34495e; |
||||||
|
margin-bottom: 20px; |
||||||
|
padding-bottom: 10px; |
||||||
|
border-bottom: 2px solid #3498db; |
||||||
|
font-size: 1.8em; |
||||||
|
} |
||||||
|
|
||||||
|
.section h3 { |
||||||
|
color: #2c3e50; |
||||||
|
margin-top: 25px; |
||||||
|
margin-bottom: 15px; |
||||||
|
font-size: 1.3em; |
||||||
|
} |
||||||
|
|
||||||
|
.tabs { |
||||||
|
display: flex; |
||||||
|
gap: 10px; |
||||||
|
margin-bottom: 20px; |
||||||
|
border-bottom: 2px solid #e0e0e0; |
||||||
|
} |
||||||
|
|
||||||
|
.tab { |
||||||
|
padding: 12px 24px; |
||||||
|
background: #f8f9fa; |
||||||
|
border: none; |
||||||
|
border-top-left-radius: 6px; |
||||||
|
border-top-right-radius: 6px; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 1em; |
||||||
|
font-weight: 500; |
||||||
|
color: #555; |
||||||
|
transition: all 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.tab:hover { |
||||||
|
background: #e9ecef; |
||||||
|
} |
||||||
|
|
||||||
|
.tab.active { |
||||||
|
background: #3498db; |
||||||
|
color: white; |
||||||
|
} |
||||||
|
|
||||||
|
.tab-content { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
.tab-content.active { |
||||||
|
display: block; |
||||||
|
} |
||||||
|
|
||||||
|
.metadata-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); |
||||||
|
gap: 15px; |
||||||
|
margin-top: 15px; |
||||||
|
} |
||||||
|
|
||||||
|
.metadata-item { |
||||||
|
background: #f8f9fa; |
||||||
|
padding: 12px; |
||||||
|
border-radius: 4px; |
||||||
|
border-left: 3px solid #3498db; |
||||||
|
} |
||||||
|
|
||||||
|
.metadata-item strong { |
||||||
|
color: #2c3e50; |
||||||
|
display: block; |
||||||
|
margin-bottom: 5px; |
||||||
|
} |
||||||
|
|
||||||
|
.metadata-item code { |
||||||
|
background: #e9ecef; |
||||||
|
padding: 2px 6px; |
||||||
|
border-radius: 3px; |
||||||
|
font-size: 0.9em; |
||||||
|
} |
||||||
|
|
||||||
|
.code-block { |
||||||
|
background: #2d2d2d; |
||||||
|
color: #f8f8f2; |
||||||
|
padding: 15px; |
||||||
|
border-radius: 6px; |
||||||
|
overflow-x: auto; |
||||||
|
font-family: 'Courier New', monospace; |
||||||
|
font-size: 0.9em; |
||||||
|
line-height: 1.5; |
||||||
|
margin: 15px 0; |
||||||
|
max-height: 400px; |
||||||
|
overflow-y: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.code-block pre { |
||||||
|
margin: 0; |
||||||
|
white-space: pre-wrap; |
||||||
|
word-wrap: break-word; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-output { |
||||||
|
background: white; |
||||||
|
border: 1px solid #ddd; |
||||||
|
padding: 20px; |
||||||
|
border-radius: 6px; |
||||||
|
margin: 15px 0; |
||||||
|
min-height: 200px; |
||||||
|
} |
||||||
|
|
||||||
|
.rendered-output * { |
||||||
|
max-width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
.stats { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); |
||||||
|
gap: 15px; |
||||||
|
margin-top: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.stat-card { |
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
||||||
|
color: white; |
||||||
|
padding: 20px; |
||||||
|
border-radius: 8px; |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
.stat-card .number { |
||||||
|
font-size: 2.5em; |
||||||
|
font-weight: bold; |
||||||
|
margin-bottom: 5px; |
||||||
|
} |
||||||
|
|
||||||
|
.stat-card .label { |
||||||
|
font-size: 0.9em; |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
.list-item { |
||||||
|
background: #f8f9fa; |
||||||
|
padding: 8px 12px; |
||||||
|
margin: 5px 0; |
||||||
|
border-radius: 4px; |
||||||
|
border-left: 3px solid #95a5a6; |
||||||
|
} |
||||||
|
|
||||||
|
.list-item code { |
||||||
|
background: #e9ecef; |
||||||
|
padding: 2px 6px; |
||||||
|
border-radius: 3px; |
||||||
|
font-size: 0.85em; |
||||||
|
} |
||||||
|
|
||||||
|
.success-badge { |
||||||
|
display: inline-block; |
||||||
|
background: #27ae60; |
||||||
|
color: white; |
||||||
|
padding: 4px 12px; |
||||||
|
border-radius: 12px; |
||||||
|
font-size: 0.85em; |
||||||
|
font-weight: 500; |
||||||
|
margin-left: 10px; |
||||||
|
} |
||||||
|
|
||||||
|
.warning-badge { |
||||||
|
display: inline-block; |
||||||
|
background: #f39c12; |
||||||
|
color: white; |
||||||
|
padding: 4px 12px; |
||||||
|
border-radius: 12px; |
||||||
|
font-size: 0.85em; |
||||||
|
font-weight: 500; |
||||||
|
margin-left: 10px; |
||||||
|
} |
||||||
|
|
||||||
|
.comparison { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: 1fr 1fr; |
||||||
|
gap: 20px; |
||||||
|
margin-top: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 768px) { |
||||||
|
.comparison { |
||||||
|
grid-template-columns: 1fr; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.json-view { |
||||||
|
background: #f8f9fa; |
||||||
|
padding: 15px; |
||||||
|
border-radius: 6px; |
||||||
|
overflow-x: auto; |
||||||
|
font-family: 'Courier New', monospace; |
||||||
|
font-size: 0.85em; |
||||||
|
max-height: 300px; |
||||||
|
overflow-y: auto; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div class="container"> |
||||||
|
<h1>GC Parser Test Report</h1> |
||||||
|
<p class="subtitle">Generated: ${new Date().toLocaleString()}</p> |
||||||
|
|
||||||
|
<!-- Markdown Section --> |
||||||
|
<div class="section"> |
||||||
|
<h2>Markdown Document Test <span class="success-badge">✓ Parsed</span></h2> |
||||||
|
|
||||||
|
<div class="tabs"> |
||||||
|
<button class="tab active" onclick="showTab('md-overview')">Overview</button> |
||||||
|
<button class="tab" onclick="showTab('md-original')">Original Content</button> |
||||||
|
<button class="tab" onclick="showTab('md-rendered')">Rendered Output</button> |
||||||
|
<button class="tab" onclick="showTab('md-metadata')">Metadata</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="md-overview" class="tab-content active"> |
||||||
|
<div class="stats"> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${markdown.result.nostrLinks.length}</div> |
||||||
|
<div class="label">Nostr Links</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${markdown.result.wikilinks.length}</div> |
||||||
|
<div class="label">Wikilinks</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${markdown.result.hashtags.length}</div> |
||||||
|
<div class="label">Hashtags</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${markdown.result.links.length}</div> |
||||||
|
<div class="label">Links</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${markdown.result.media.length}</div> |
||||||
|
<div class="label">Media URLs</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${markdown.result.hasLaTeX ? 'Yes' : 'No'}</div> |
||||||
|
<div class="label">Has LaTeX</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${markdown.result.hasMusicalNotation ? 'Yes' : 'No'}</div> |
||||||
|
<div class="label">Has Music</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h3>Frontmatter</h3> |
||||||
|
${markdown.result.frontmatter ? ` |
||||||
|
<div class="metadata-grid"> |
||||||
|
${Object.entries(markdown.result.frontmatter).map(([key, value]) => ` |
||||||
|
<div class="metadata-item"> |
||||||
|
<strong>${escapeHtml(key)}</strong> |
||||||
|
<code>${escapeHtml(JSON.stringify(value))}</code> |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
</div> |
||||||
|
` : '<p><em>No frontmatter found</em></p>'}
|
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="md-original" class="tab-content"> |
||||||
|
<h3>Original Markdown Content</h3> |
||||||
|
<div class="code-block"> |
||||||
|
<pre>${escapeHtml(markdown.original)}</pre> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="md-rendered" class="tab-content"> |
||||||
|
<h3>Rendered HTML Output</h3> |
||||||
|
<div class="rendered-output"> |
||||||
|
${markdown.result.content} |
||||||
|
</div> |
||||||
|
<details style="margin-top: 15px;"> |
||||||
|
<summary style="cursor: pointer; color: #3498db; font-weight: 500;">View Raw HTML</summary> |
||||||
|
<div class="code-block" style="margin-top: 10px;"> |
||||||
|
<pre>${escapeHtml(markdown.result.content)}</pre> |
||||||
|
</div> |
||||||
|
</details> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="md-metadata" class="tab-content"> |
||||||
|
<h3>Extracted Metadata</h3> |
||||||
|
|
||||||
|
${markdown.result.nostrLinks.length > 0 ? ` |
||||||
|
<h4>Nostr Links (${markdown.result.nostrLinks.length})</h4> |
||||||
|
${markdown.result.nostrLinks.map((link: any) => ` |
||||||
|
<div class="list-item"> |
||||||
|
<strong>${escapeHtml(link.type)}</strong>: <code>${escapeHtml(link.bech32)}</code> |
||||||
|
${link.text ? ` - ${escapeHtml(link.text)}` : ''} |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${markdown.result.wikilinks.length > 0 ? ` |
||||||
|
<h4>Wikilinks (${markdown.result.wikilinks.length})</h4> |
||||||
|
${markdown.result.wikilinks.map((wl: any) => ` |
||||||
|
<div class="list-item"> |
||||||
|
<code>${escapeHtml(wl.original)}</code> → dtag: <code>${escapeHtml(wl.dtag)}</code> |
||||||
|
${wl.display ? ` (display: ${escapeHtml(wl.display)})` : ''} |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${markdown.result.hashtags.length > 0 ? ` |
||||||
|
<h4>Hashtags (${markdown.result.hashtags.length})</h4> |
||||||
|
${markdown.result.hashtags.map((tag: string) => ` |
||||||
|
<div class="list-item"> |
||||||
|
<code>#${escapeHtml(tag)}</code> |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${markdown.result.links.length > 0 ? ` |
||||||
|
<h4>Links (${markdown.result.links.length})</h4> |
||||||
|
${markdown.result.links.map((link: any) => ` |
||||||
|
<div class="list-item"> |
||||||
|
<a href="${escapeHtml(link.url)}" target="_blank">${escapeHtml(link.text || link.url)}</a> |
||||||
|
${link.isExternal ? '<span class="warning-badge">External</span>' : ''} |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${markdown.result.media.length > 0 ? ` |
||||||
|
<h4>Media URLs (${markdown.result.media.length})</h4> |
||||||
|
${markdown.result.media.map((url: string) => ` |
||||||
|
<div class="list-item"> |
||||||
|
<a href="${escapeHtml(url)}" target="_blank">${escapeHtml(url)}</a> |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${markdown.result.tableOfContents ? ` |
||||||
|
<h4>Table of Contents</h4> |
||||||
|
<div class="rendered-output"> |
||||||
|
${markdown.result.tableOfContents} |
||||||
|
</div> |
||||||
|
` : ''}
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- AsciiDoc Section --> |
||||||
|
<div class="section"> |
||||||
|
<h2>AsciiDoc Document Test <span class="success-badge">✓ Parsed</span></h2> |
||||||
|
|
||||||
|
<div class="tabs"> |
||||||
|
<button class="tab active" onclick="showTab('ad-overview')">Overview</button> |
||||||
|
<button class="tab" onclick="showTab('ad-original')">Original Content</button> |
||||||
|
<button class="tab" onclick="showTab('ad-rendered')">Rendered Output</button> |
||||||
|
<button class="tab" onclick="showTab('ad-metadata')">Metadata</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="ad-overview" class="tab-content active"> |
||||||
|
<div class="stats"> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${asciidoc.result.nostrLinks.length}</div> |
||||||
|
<div class="label">Nostr Links</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${asciidoc.result.wikilinks.length}</div> |
||||||
|
<div class="label">Wikilinks</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${asciidoc.result.hashtags.length}</div> |
||||||
|
<div class="label">Hashtags</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${asciidoc.result.links.length}</div> |
||||||
|
<div class="label">Links</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${asciidoc.result.media.length}</div> |
||||||
|
<div class="label">Media URLs</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${asciidoc.result.hasLaTeX ? 'Yes' : 'No'}</div> |
||||||
|
<div class="label">Has LaTeX</div> |
||||||
|
</div> |
||||||
|
<div class="stat-card"> |
||||||
|
<div class="number">${asciidoc.result.hasMusicalNotation ? 'Yes' : 'No'}</div> |
||||||
|
<div class="label">Has Music</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h3>Frontmatter</h3> |
||||||
|
${asciidoc.result.frontmatter ? ` |
||||||
|
<div class="metadata-grid"> |
||||||
|
${Object.entries(asciidoc.result.frontmatter).map(([key, value]) => ` |
||||||
|
<div class="metadata-item"> |
||||||
|
<strong>${escapeHtml(key)}</strong> |
||||||
|
<code>${escapeHtml(JSON.stringify(value))}</code> |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
</div> |
||||||
|
` : '<p><em>No frontmatter found</em></p>'}
|
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="ad-original" class="tab-content"> |
||||||
|
<h3>Original AsciiDoc Content</h3> |
||||||
|
<div class="code-block"> |
||||||
|
<pre>${escapeHtml(asciidoc.original)}</pre> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="ad-rendered" class="tab-content"> |
||||||
|
<h3>Rendered HTML Output</h3> |
||||||
|
<div class="rendered-output"> |
||||||
|
${asciidoc.result.content} |
||||||
|
</div> |
||||||
|
<details style="margin-top: 15px;"> |
||||||
|
<summary style="cursor: pointer; color: #3498db; font-weight: 500;">View Raw HTML</summary> |
||||||
|
<div class="code-block" style="margin-top: 10px;"> |
||||||
|
<pre>${escapeHtml(asciidoc.result.content)}</pre> |
||||||
|
</div> |
||||||
|
</details> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div id="ad-metadata" class="tab-content"> |
||||||
|
<h3>Extracted Metadata</h3> |
||||||
|
|
||||||
|
${asciidoc.result.nostrLinks.length > 0 ? ` |
||||||
|
<h4>Nostr Links (${asciidoc.result.nostrLinks.length})</h4> |
||||||
|
${asciidoc.result.nostrLinks.map((link: any) => ` |
||||||
|
<div class="list-item"> |
||||||
|
<strong>${escapeHtml(link.type)}</strong>: <code>${escapeHtml(link.bech32)}</code> |
||||||
|
${link.text ? ` - ${escapeHtml(link.text)}` : ''} |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${asciidoc.result.wikilinks.length > 0 ? ` |
||||||
|
<h4>Wikilinks (${asciidoc.result.wikilinks.length})</h4> |
||||||
|
${asciidoc.result.wikilinks.map((wl: any) => ` |
||||||
|
<div class="list-item"> |
||||||
|
<code>${escapeHtml(wl.original)}</code> → dtag: <code>${escapeHtml(wl.dtag)}</code> |
||||||
|
${wl.display ? ` (display: ${escapeHtml(wl.display)})` : ''} |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${asciidoc.result.hashtags.length > 0 ? ` |
||||||
|
<h4>Hashtags (${asciidoc.result.hashtags.length})</h4> |
||||||
|
${asciidoc.result.hashtags.map((tag: string) => ` |
||||||
|
<div class="list-item"> |
||||||
|
<code>#${escapeHtml(tag)}</code> |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${asciidoc.result.links.length > 0 ? ` |
||||||
|
<h4>Links (${asciidoc.result.links.length})</h4> |
||||||
|
${asciidoc.result.links.map((link: any) => ` |
||||||
|
<div class="list-item"> |
||||||
|
<a href="${escapeHtml(link.url)}" target="_blank">${escapeHtml(link.text || link.url)}</a> |
||||||
|
${link.isExternal ? '<span class="warning-badge">External</span>' : ''} |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${asciidoc.result.media.length > 0 ? ` |
||||||
|
<h4>Media URLs (${asciidoc.result.media.length})</h4> |
||||||
|
${asciidoc.result.media.map((url: string) => ` |
||||||
|
<div class="list-item"> |
||||||
|
<a href="${escapeHtml(url)}" target="_blank">${escapeHtml(url)}</a> |
||||||
|
</div> |
||||||
|
`).join('')}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${asciidoc.result.tableOfContents ? ` |
||||||
|
<h4>Table of Contents</h4> |
||||||
|
<div class="rendered-output"> |
||||||
|
${asciidoc.result.tableOfContents} |
||||||
|
</div> |
||||||
|
` : ''}
|
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
function showTab(tabId) { |
||||||
|
// Hide all tab contents
|
||||||
|
const allContents = document.querySelectorAll('.tab-content'); |
||||||
|
allContents.forEach(content => content.classList.remove('active')); |
||||||
|
|
||||||
|
// Remove active class from all tabs
|
||||||
|
const allTabs = document.querySelectorAll('.tab'); |
||||||
|
allTabs.forEach(tab => tab.classList.remove('active')); |
||||||
|
|
||||||
|
// Show selected tab content
|
||||||
|
const selectedContent = document.getElementById(tabId); |
||||||
|
if (selectedContent) { |
||||||
|
selectedContent.classList.add('active'); |
||||||
|
} |
||||||
|
|
||||||
|
// Add active class to clicked tab
|
||||||
|
event.target.classList.add('active'); |
||||||
|
} |
||||||
|
</script> |
||||||
|
</body> |
||||||
|
</html>`;
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Escape HTML special characters |
||||||
|
*/ |
||||||
|
export function escapeHtml(text: string): string { |
||||||
|
const map: Record<string, string> = { |
||||||
|
'&': '&', |
||||||
|
'<': '<', |
||||||
|
'>': '>', |
||||||
|
'"': '"', |
||||||
|
"'": ''', |
||||||
|
}; |
||||||
|
return text.replace(/[&<>"']/g, (m) => map[m]); |
||||||
|
} |
||||||
Loading…
Reference in new issue