diff --git a/src/server/public/assets/css/styles.css b/src/server/public/assets/css/styles.css index 77759ef..faca8c0 100644 --- a/src/server/public/assets/css/styles.css +++ b/src/server/public/assets/css/styles.css @@ -23,6 +23,29 @@ body { animation: glow 3s ease-in-out infinite; } +/* Code blocks enhancements */ +.code-block pre { + background: transparent !important; + margin: 0; + padding: 0; +} + +.code-block pre code { + display: block; + padding: 1rem 1.25rem; + font-size: 0.9rem; + line-height: 1.5; +} + +/* Etiquetas visuales API/CLI (apoyadas por header dinámico) */ +.code-block .is-api { + background: linear-gradient(90deg, rgba(99,102,241,0.15), rgba(147,51,234,0.15)); +} + +.code-block .is-cli { + background: linear-gradient(90deg, rgba(236,72,153,0.15), rgba(59,130,246,0.15)); +} + /* Scrollbar personalizado (opcional, moderno) */ ::-webkit-scrollbar { width: 8px; diff --git a/src/server/public/assets/js/code.js b/src/server/public/assets/js/code.js new file mode 100644 index 0000000..a5a52fb --- /dev/null +++ b/src/server/public/assets/js/code.js @@ -0,0 +1,86 @@ +// Mejora de bloques de código: resaltar, barra de acciones (copiar), y etiquetas API/CLI +(function () { + function enhanceCodeBlocks() { + const pres = document.querySelectorAll('pre'); + pres.forEach((pre) => { + if (pre.dataset.enhanced === '1') return; + pre.dataset.enhanced = '1'; + + // Asegurar que exista + let codeEl = pre.querySelector('code'); + const textRaw = pre.textContent || ''; + if (!codeEl) { + codeEl = document.createElement('code'); + codeEl.textContent = textRaw; + const t = textRaw.trimStart(); + const isJSON = t.startsWith('{') || t.startsWith('['); + const isCLI = /^(!|\$|curl\s|#)/m.test(t); + if (isJSON) codeEl.className = 'language-json'; + else if (isCLI) codeEl.className = 'language-bash'; + else codeEl.className = 'language-text'; + pre.textContent = ''; + pre.appendChild(codeEl); + } + + const wrapper = document.createElement('div'); + wrapper.className = 'code-block group relative overflow-hidden rounded-xl border border-white/10 bg-slate-900/70 shadow-lg animate-[slideIn_0.4s_ease-out]'; + + const header = document.createElement('div'); + header.className = 'flex items-center justify-between px-3 py-2 text-xs bg-slate-800/60 border-b border-white/10'; + + const label = document.createElement('span'); + label.className = 'font-mono tracking-wide text-slate-300'; + + const lang = (codeEl.className || '').toLowerCase(); + let kind = ''; + if (/bash|shell|sh/.test(lang)) kind = 'CLI'; + else if (/json|typescript|javascript/.test(lang)) kind = 'API'; + label.textContent = kind || (lang.replace('language-', '').toUpperCase() || 'CODE'); + if (kind === 'API') header.classList.add('is-api'); + else if (kind === 'CLI') header.classList.add('is-cli'); + + const actions = document.createElement('div'); + actions.className = 'flex items-center gap-1'; + + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'rounded-md px-2 py-1 text-slate-300 hover:text-white hover:bg-white/10 transition'; + copyBtn.textContent = 'Copiar'; + copyBtn.addEventListener('click', async () => { + try { + const text = codeEl.innerText || pre.innerText || ''; + await navigator.clipboard.writeText(text); + copyBtn.textContent = 'Copiado!'; + setTimeout(() => (copyBtn.textContent = 'Copiar'), 1200); + } catch (err) { + // silencioso + } + }); + + header.appendChild(label); + header.appendChild(actions); + actions.appendChild(copyBtn); + + const content = document.createElement('div'); + content.className = 'relative'; + if (pre.parentNode) pre.parentNode.insertBefore(wrapper, pre); + wrapper.appendChild(header); + wrapper.appendChild(content); + content.appendChild(pre); + + pre.classList.add('overflow-auto'); + }); + + if (window.hljs) { + document.querySelectorAll('pre code').forEach((el) => { + window.hljs.highlightElement(el); + }); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', enhanceCodeBlocks); + } else { + enhanceCodeBlocks(); + } +})(); diff --git a/src/server/server.ts b/src/server/server.ts index 3f731ac..cdb6365 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,11 +1,14 @@ import { createServer, IncomingMessage, ServerResponse } from "node:http"; import { promises as fs } from "node:fs"; import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; import path from "node:path"; import ejs from "ejs"; const publicDir = path.join(__dirname, "public"); const viewsDir = path.join(__dirname, "views"); +// Compresión síncrona (rápida para tamaños pequeños de HTML/CSS/JS) // Cargar metadatos del proyecto para usarlos como variables en las vistas let pkg: { @@ -112,6 +115,29 @@ function applySecurityHeaders(base: Record = {}) { }; } +function computeEtag(buf: Buffer): string { + // Weak ETag derived from content sha1 and length + const hash = createHash("sha1").update(buf).digest("base64"); + return `W/"${buf.length.toString(16)}-${hash}"`; +} + +function acceptsEncoding(req: IncomingMessage, enc: string): boolean { + const ae = (req.headers["accept-encoding"] as string) || ""; + return ae + .split(",") + .map((s) => s.trim()) + .includes(enc); +} + +function shouldCompress(mime: string): boolean { + return ( + mime.startsWith("text/") || + mime.includes("json") || + mime.includes("javascript") || + mime.includes("svg") + ); +} + const resolvePath = (pathname: string): string => { const decoded = decodeURIComponent(pathname); let target = decoded; @@ -128,6 +154,7 @@ const resolvePath = (pathname: string): string => { }; const sendResponse = async ( + req: IncomingMessage, res: ServerResponse, filePath: string, statusCode = 200 @@ -138,18 +165,49 @@ const sendResponse = async ( ? "no-cache" : "public, max-age=86400, immutable"; + const stat = await fs.stat(filePath).catch(() => undefined); const data = await fs.readFile(filePath); - res.writeHead( - statusCode, - applySecurityHeaders({ - "Content-Type": mimeType, - "Cache-Control": cacheControl, - }) - ); - res.end(data); + const etag = computeEtag(data); + + // Conditional requests + const inm = (req.headers["if-none-match"] as string) || ""; + if (inm && inm === etag) { + res.writeHead( + 304, + applySecurityHeaders({ + ETag: etag, + "Cache-Control": cacheControl, + ...(stat ? { "Last-Modified": stat.mtime.toUTCString() } : {}), + }) + ); + res.end(); + return; + } + + let body: any = data; + const headers: Record = { + "Content-Type": mimeType, + "Cache-Control": cacheControl, + ETag: etag, + ...(stat ? { "Last-Modified": stat.mtime.toUTCString() } : {}), + }; + + if (shouldCompress(mimeType) && acceptsEncoding(req, "gzip")) { + try { + body = gzipSync(data); + headers["Content-Encoding"] = "gzip"; + headers["Vary"] = "Accept-Encoding"; + } catch { + // Si falla compresión, enviar sin comprimir + } + } + + res.writeHead(statusCode, applySecurityHeaders(headers)); + res.end(body); }; const renderTemplate = async ( + req: IncomingMessage, res: ServerResponse, template: string, locals: Record = {}, @@ -157,7 +215,7 @@ const renderTemplate = async ( ) => { const pageFile = path.join(viewsDir, "pages", `${template}.ejs`); const layoutFile = path.join(viewsDir, "layouts", "layout.ejs"); - const body = await ejs.renderFile(pageFile, locals, { async: true }); + const pageBody = await ejs.renderFile(pageFile, locals, { async: true }); const defaultTitle = `${ locals.appName ?? pkg.name ?? "Amayo Bot" } | Guía Completa`; @@ -168,18 +226,43 @@ const renderTemplate = async ( scripts: null, ...locals, title: locals.title ?? defaultTitle, - body, + body: pageBody, }, { async: true } ); - res.writeHead( - statusCode, - applySecurityHeaders({ - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-cache", - }) - ); - res.end(html); + const htmlBuffer = Buffer.from(html, "utf8"); + const etag = computeEtag(htmlBuffer); + + // Conditional ETag for dynamic page (fresh each deploy change) + const inm = (req.headers["if-none-match"] as string) || ""; + if (inm && inm === etag) { + res.writeHead( + 304, + applySecurityHeaders({ ETag: etag, "Cache-Control": "no-cache" }) + ); + res.end(); + return; + } + + let respBody: any = htmlBuffer; + const headers: Record = { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-cache", + ETag: etag, + }; + + if (acceptsEncoding(req, "gzip")) { + try { + respBody = gzipSync(htmlBuffer); + headers["Content-Encoding"] = "gzip"; + headers["Vary"] = "Accept-Encoding"; + } catch { + // continuar sin comprimir + } + } + + res.writeHead(statusCode, applySecurityHeaders(headers)); + res.end(respBody); }; export const server = createServer( @@ -250,7 +333,7 @@ export const server = createServer( year: "numeric", }); const djsVersion = pkg?.dependencies?.["discord.js"] ?? "15.0.0-dev"; - await renderTemplate(res, "index", { + await renderTemplate(req, res, "index", { appName: pkg.name ?? "Amayo Bot", version: pkg.version ?? "2.0.0", djsVersion, @@ -268,12 +351,12 @@ export const server = createServer( } try { - await sendResponse(res, filePath); + await sendResponse(req, res, filePath); } catch (error: any) { if (error.code === "ENOENT") { const notFoundPath = path.join(publicDir, "404.html"); try { - await sendResponse(res, notFoundPath, 404); + await sendResponse(req, res, notFoundPath, 404); } catch { res.writeHead( 404, @@ -285,7 +368,7 @@ export const server = createServer( } } else if (error.code === "EISDIR") { const indexPath = path.join(filePath, "index.html"); - await sendResponse(res, indexPath); + await sendResponse(req, res, indexPath); } else { console.error("[Server] Error al servir archivo:", error); res.writeHead( diff --git a/src/server/views/layouts/layout.ejs b/src/server/views/layouts/layout.ejs index f7b08b3..939995a 100644 --- a/src/server/views/layouts/layout.ejs +++ b/src/server/views/layouts/layout.ejs @@ -6,6 +6,10 @@ <%= title || `${appName} | Guía Completa` %> + + + + + <% if (typeof scripts !== 'undefined' && scripts) { %> <%= scripts %> <% } %>