diff --git a/package.json b/package.json index 3fbfe68..64b5031 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,7 @@ { - "flat": [ - { - "path": "posts/example.md", - "title": "Example Post", - "tags": ["tag1", "tag2"], - "isIndex": false, - "isPinned": true, - "ctime": 1731168000000, - "mtime": 1731168000000, - "ext": ".md", - "excerpt": "This is an example excerpt from the post content..." - }, - { - "path": "section/index.html", - "title": "Section Index", - "tags": [], - "isIndex": true, - "isPinned": false, - "ctime": 1731168000000, - "mtime": 1731168000000, - "ext": ".html", - "excerpt": "Welcome to the section..." - } - ], - "sections": ["posts", "section"], - "tags": ["tag1", "tag2"], - "hierarchies": { - "section": ["subsection"] + "name": "the-fold-within", + "version": "3.0.0", + "dependencies": { + "pdf-parse": "^1.1.1" } } diff --git a/public/app.js b/public/app.js index 23cfa28..cee598a 100755 --- a/public/app.js +++ b/public/app.js @@ -1,10 +1,3 @@ -/** - * app.js – v3.3.4 DIAGNOSTIC RESONANCE - * High-coherence, readable, maintainable. - * No hacks. No surgery. Only truth. - * Now with diagnostic overlays for rupture illumination. - */ - const els = { menuBtn: document.getElementById("menuBtn"), primaryNav: document.getElementById("primaryNav"), @@ -24,25 +17,12 @@ const els = { let indexData = null; let sidebarOpen = false; let currentParent = null; -let indexFiles = null; // Cached index files +let indexFiles = null; // Cached -// ΔTRUTH: Diagnostic overlay for error/clarity banners. -// Rationale: Fixed red banner for immediate visibility; z-index above topbar. -function showDiagnostic(message) { - const banner = document.createElement('div'); - banner.style = 'position: fixed; top: 0; left: 0; width: 100%; background: #ff4d4d; color: white; padding: 10px; z-index: 1001; text-align: center; font-weight: bold;'; - banner.innerHTML = message; - document.body.appendChild(banner); -} - -// === INITIALIZATION === async function init() { try { indexData = await (await fetch("index.json")).json(); - if (indexData.flat.length === 0) { - showDiagnostic('index.json loaded but no content files found. Add .md or .html files to public/ sections and run node tools/generate-index.mjs.'); - } - indexFiles = indexData.flat.filter(f => f.isIndex); + indexFiles = indexData.flat.filter(f => f.isIndex); // Cache populateNav(); populateSections(); populateTags(); @@ -50,14 +30,11 @@ async function init() { renderList(); handleHash(); window.addEventListener("hashchange", handleHash); - console.info('%cThe Fold Within: Harmony sustained.', 'color:#e0b84b'); } catch (e) { - showDiagnostic('Failed to load index.json. Check Network tab for 404 or console for errors. Ensure deployed from public/ directory and index.json is generated.'); - els.viewer.innerHTML = "

Error

Failed to load site data. See diagnostic banner for fixes.

"; + els.viewer.innerHTML = "

Error

Failed to load site data.

"; } } -// === NAVIGATION === function populateNav() { els.primaryNav.innerHTML = 'Home'; const navSections = [...new Set( @@ -78,8 +55,11 @@ function populateSections() { els.sectionSelect.appendChild(opt); }); - const defaultSection = indexData.sections.includes("posts") ? "posts" : indexData.sections[0]; - if (defaultSection) els.sectionSelect.value = defaultSection; + if (indexData.sections.includes("posts")) { + els.sectionSelect.value = "posts"; + } else if (indexData.sections.length > 0) { + els.sectionSelect.value = indexData.sections[0]; + } } function populateTags() { @@ -90,7 +70,11 @@ function populateTags() { }); } -// === UI WIRING === +function formatTimestamp(ms) { + const d = new Date(ms); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; +} + function wireUI() { els.menuBtn.addEventListener("click", () => { sidebarOpen = !sidebarOpen; @@ -111,7 +95,6 @@ function wireUI() { [els.tagSelect, els.sortSelect, els.searchMode].forEach(el => el.addEventListener("change", renderList)); els.searchBox.addEventListener("input", renderList); - // Close sidebar on content click (mobile) els.content.addEventListener("click", (e) => { if (window.innerWidth < 1024 && document.body.classList.contains("sidebar-open")) { if (!e.target.closest("#sidebar")) { @@ -122,7 +105,6 @@ function wireUI() { }); } -// === LIST RENDERING === function renderList() { const section = els.sectionSelect.value; const tags = Array.from(els.tagSelect.selectedOptions).map(o => o.value.toLowerCase()); @@ -142,13 +124,10 @@ function renderList() { posts.sort((a, b) => sort === "newest" ? b.mtime - a.mtime : a.mtime - b.mtime); els.postList.innerHTML = posts.length ? "" : "
  • No posts found.
  • "; - if (!posts.length) { - showDiagnostic('No posts found in current filters. If persistent, check index.json for flat entries or add content files and regenerate.'); - } posts.forEach(p => { const li = document.createElement("li"); const pin = p.isPinned ? "Star " : ""; - const time = new Date(p.ctime).toLocaleDateString(); + const time = formatTimestamp(p.ctime); li.innerHTML = `${pin}${p.title}${time}`; els.postList.appendChild(li); }); @@ -164,7 +143,7 @@ function loadDefaultForSection(section) { location.hash = `#/${pinned.path}`; } -// === SUBNAV (NESTED HORIZON) === +// NESTED HORIZON: Deep-Aware Sub-Navigation function renderSubNav(parent) { const subnav = els.subNav; subnav.innerHTML = ""; @@ -180,14 +159,17 @@ function renderSubNav(parent) { subnav.appendChild(link); }); - requestAnimationFrame(() => subnav.classList.add("visible")); + requestAnimationFrame(() => { + subnav.classList.add("visible"); + }); } -// === HASH ROUTING === async function handleHash() { els.viewer.innerHTML = ""; const rel = location.hash.replace(/^#\//, ""); - const parts = rel.split("/").filter(Boolean); + const parts = rel.split("/").filter(Boolean); // e.g., ["about", "Mark"] + + // Determine current depth parent for subnav const currentParentPath = parts.slice(0, -1).join("/") || parts[0] || null; if (currentParentPath !== currentParent) { @@ -195,40 +177,76 @@ async function handleHash() { renderSubNav(currentParent); } + // Sync sidebar section to top-level const topSection = parts[0] || null; if (topSection && indexData.sections.includes(topSection)) { els.sectionSelect.value = topSection; renderList(); } - if (rel === '' || rel === '#') return renderDefault(); - if (!rel) return renderDefault(); + // CASE: Trailing slash → render index at *current* level if (rel.endsWith('/')) { const currentPath = parts.join("/"); + const indexFile = indexFiles.find(f => { const dir = f.path.split("/").slice(0, -1).join("/"); return dir === currentPath; }); if (indexFile) { - if (indexFile.ext === ".md") { - await renderMarkdown(indexFile.path); - } else { - await renderIframe("/" + indexFile.path); + try { + if (indexFile.ext === ".md") { + const src = await fetch(indexFile.path).then(r => r.ok ? r.text() : ""); + const html = marked.parse(src || `# ${currentPath.split("/").pop()}\n\nNo content yet.`); + els.viewer.innerHTML = `
    ${html}
    `; + } else { + const iframe = document.createElement("iframe"); + iframe.src = "/" + indexFile.path; + iframe.loading = "eager"; + iframe.setAttribute("sandbox", "allow-same-origin allow-scripts allow-forms"); + els.viewer.appendChild(iframe); + + iframe.onload = () => { + try { + const doc = iframe.contentDocument; + const body = doc.body; + const hasContent = body && body.innerText.trim().length > 50; + if (!hasContent) { + doc.body.innerHTML = ` +
    +

    ${currentPath.split("/").pop()}

    +

    No content yet.

    +
    + `; + doc.body.style.background = "#0b0b0b"; + } + } catch (e) {} + }; + } + } catch (e) { + els.viewer.innerHTML = `

    ${currentPath.split("/").pop()}

    No content yet.

    `; } } else { - if (topSection) loadDefaultForSection(topSection); - else els.viewer.innerHTML = `

    ${currentPath.split("/").pop()}

    No content yet.

    `; + // No index → show children or fallback + if (topSection) { + els.sectionSelect.value = topSection; + renderList(); + loadDefaultForSection(topSection); + } else { + els.viewer.innerHTML = `

    ${currentPath.split("/").pop()}

    No content yet.

    `; + } } - } else { + } + // CASE: Direct file + else { const file = indexData.flat.find(f => f.path === rel); if (!file) { els.viewer.innerHTML = "

    404

    Not found.

    "; return; } - file.ext === ".md" ? await renderMarkdown(file.path) : await renderIframe("/" + file.path); + file.ext === ".md" ? await renderMarkdown(file.path) : renderIframe(file.path); } } @@ -237,80 +255,36 @@ async function renderMarkdown(rel) { els.viewer.innerHTML = `
    ${marked.parse(src || "# Untitled")}
    `; } -// === PREVIEW + PORTAL ENGINE === -async function renderIframe(rel) { - const preview = await generatePreview(rel); - const portalBtn = ``; +function renderIframe(rel) { + const iframe = document.createElement("iframe"); + iframe.src = "/" + rel; + iframe.loading = "eager"; + iframe.setAttribute("sandbox", "allow-same-origin allow-scripts allow-forms"); + els.viewer.appendChild(iframe); - els.viewer.innerHTML = ` -
    ${portalBtn}
    -
    ${preview}
    - `; - - els.viewer.querySelector(".portal-btn").addEventListener("click", e => { - window.open(e.target.dataset.src, "_blank", "noopener,noreferrer"); - }); + iframe.onload = () => { + try { + const doc = iframe.contentDocument; + const style = doc.createElement("style"); + style.textContent = ` + html,body{background:#0b0b0b;color:#e6e3d7;font-family:Inter,sans-serif;margin:0;padding:2rem;} + *{max-width:720px;margin:auto;} + img, video, iframe {max-width:100%;height:auto;} + `; + doc.head.appendChild(style); + } catch (e) {} + }; } -async function generatePreview(rel) { - try { - const res = await fetch(rel); - if (!res.ok) throw new Error(); - const html = await res.text(); - - function sanitizeHTML(html) { - return html - .replace(//gi, "") - .replace(//gi, "") - .replace(/]*rel=["']stylesheet["'][^>]*>/gi, "") - .replace(/\s+(on\w+)=["'][^"']*["']/gi, "") - .replace(/\s+style=["'][^"']*["']/gi, "") - .replace(/^\s+|\s+$/g, '') - .replace(/(\n\s*){2,}/g, '\n') - .replace(/

    \s*<\/p>/gi, '') - .replace(//gi, ''); - } - - let content = sanitizeHTML(html.match(/]*>([\s\S]*)<\/body>/i)?.[1] || html); - - const div = document.createElement("div"); - div.innerHTML = content; - trimPreview(div, 3, 3000); // depth, char limit - - return div.innerHTML || `

    Empty content.

    `; - } catch { - return `

    Preview unavailable. Open directly.

    `; - } -} - -function trimPreview(el, maxDepth, charLimit, depth = 0, chars = 0) { - if (depth > maxDepth || chars > charLimit) { - el.innerHTML = "..."; - return; - } - let total = chars; - for (const child of [...el.children]) { - total += child.textContent.length; - if (total > charLimit || depth > maxDepth) { - child.remove(); - } else { - trimPreview(child, maxDepth, charLimit, depth + 1, total); - } - } -} - -// === DEFAULT VIEW === function renderDefault() { - const defaultSection = indexData.sections.includes("posts") ? "posts" : indexData.sections[0]; + const defaultSection = indexData.sections.includes("posts") ? "posts" : (indexData.sections[0] || null); if (defaultSection) { els.sectionSelect.value = defaultSection; renderList(); loadDefaultForSection(defaultSection); } else { - showDiagnostic('No sections detected in index.json. Create folders with .md/.html files in public/ and run node tools/generate-index.mjs to regenerate.'); - els.viewer.innerHTML = "

    Welcome

    Add content to begin. See diagnostic banner for details.

    "; + els.viewer.innerHTML = "

    Welcome

    Add content to begin.

    "; } } -// === START === init(); diff --git a/public/index.html b/public/index.html index a91fc2e..ebbabaa 100755 --- a/public/index.html +++ b/public/index.html @@ -1,60 +1,62 @@ - - - The Fold Within – v3.3.3 - - - - - - + + +The Fold Within + + + + + + - -
    - - -
    - - - - + +
    +
    +
    diff --git a/public/styles.css b/public/styles.css index 8509bf9..2e0363b 100755 --- a/public/styles.css +++ b/public/styles.css @@ -1,32 +1,26 @@ -/* ΔFIELD: Global reset and root variables establish the foundational rhythm of the entire system. - * Rationale: Centralized vars enable thematic coherence; box-sizing ensures predictable layout without surprises. */ - :root { - --bg: #0b0b0b; /* ΔBREATH: Deep void background for immersive field. */ - --fg: #e6e3d7; /* ΔBREATH: Soft foreground for readable harmony. */ - --accent: #e0b84b; /* ΔBREATH: Golden accent evokes eternal light within the fold. */ - --topbar-h: 56px; /* ΔHORIZON: Fixed height for consistent vertical anchoring. */ - --subnav-h: 44px; /* ΔHORIZON: Subtle secondary layer for nested horizons. */ - --transition: .3s ease; /* ΔBREATH: Smooth easing for all motions, preventing abrupt ruptures. */ - --radius: 8px; /* ΔFIELD: Gentle curves symbolize seamless integration. */ + --bg: #0b0b0b; + --fg: #e6e3d7; + --accent: #e0b84b; + --topbar-h: 56px; + --subnav-h: 44px; + --transition: .3s ease; + --radius: 8px; } -* { box-sizing: border-box; } /* ΔFIELD: Universal sizing model eliminates phantom padding issues. */ +* { box-sizing: border-box; } html, body { - margin: 0; padding: 0; height: 100%; /* ΔFIELD: Full-viewport claim ensures no external gaps. */ + margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--fg); - font-family: 'Inter', system-ui, sans-serif; /* ΔHORIZON: Modern, readable stack for cross-device truth. */ - line-height: 1.6; /* ΔRHYTHM: Baseline vertical rhythm for breathing text. */ + font-family: 'Inter', system-ui, sans-serif; + line-height: 1.6; } -/* === TOPBAR & NAV === */ -/* ΔHORIZON: Fixed topbar as the eternal anchor; flex for adaptive content flow. - * Rationale: Z-index 1000 ensures primacy; overflow hidden prevents visual noise. */ .topbar { position: fixed; top: 0; left: 0; right: 0; height: var(--topbar-h); display: flex; align-items: center; - background: #111; border-bottom: 1px solid #222; /* ΔBREATH: Subtle divide for depth perception. */ + background: #111; border-bottom: 1px solid #222; padding: 0 1rem; z-index: 1000; gap: 1.5rem; overflow: hidden; } @@ -35,11 +29,9 @@ html, body { background: none; border: 0; color: var(--accent); font-size: 1.4rem; cursor: pointer; font-weight: bold; line-height: 1; padding: 0; margin: 0; - min-width: 2.5rem; /* ΔFIELD: Minimal width for touch-friendly interaction. */ + min-width: 2.5rem; } -/* ΔHORIZON: Primary nav as flexible horizon line; wrap and scroll for overflow. - * Rationale: White-space nowrap preserves link integrity. */ .primary-nav { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: center; overflow-x: auto; @@ -49,16 +41,14 @@ html, body { .primary-nav a { color: var(--accent); text-decoration: none; font-weight: 600; font-size: 1.1rem; - transition: all var(--transition); /* ΔBREATH: Unified motion for hover resonance. */ + transition: all var(--transition); } .primary-nav a:hover { - text-shadow: 0 0 10px var(--accent); /* ΔHORIZON: Glow for interactive feedback; increased for DPI crispness. */ + text-shadow: 0 0 8px var(--accent); } -/* === SUBNAV === */ -/* ΔRECURSION: Subnav as nested horizon; hidden by default, revealed on context. - * Rationale: Opacity/transform transition with animation for smooth unfolding. */ +/* NESTED HORIZON: Emergent Sub-Navigation with Animation */ .sub-nav { display: none; position: fixed; @@ -82,12 +72,12 @@ html, body { display: flex; opacity: 1; transform: translateY(0); - animation: fadeSlideDown .3s ease forwards; /* ΔBREATH: Entrance animation embodies revelation. */ + animation: fadeSlideDown .3s ease forwards; } @keyframes fadeSlideDown { from { opacity: 0; transform: translateY(-5px); } - to { opacity: 1; transform: translateY(0); } /* ΔBREATH: Gentle descent for rhythmic integration. */ + to { opacity: 1; transform: translateY(0); } } .sub-nav a { @@ -99,12 +89,9 @@ html, body { } .sub-nav a:hover { - text-shadow: 0 0 8px var(--accent); /* ΔHORIZON: Subdued glow for secondary layer; consistent with primary. */ + text-shadow: 0 0 6px var(--accent); } -/* === SIDEBAR === */ -/* ΔRECURSION: Sidebar as foldable field extension; transform for smooth reveal. - * Rationale: Media query for persistent desktop view; mobile toggle for horizon respect. */ #sidebar { position: fixed; top: calc(var(--topbar-h) + var(--subnav-h)); left: 0; width: 300px; bottom: 0; overflow: auto; @@ -113,214 +100,98 @@ html, body { z-index: 900; padding: 1rem; } -body.sidebar-open #sidebar { transform: translateX(0); } /* ΔBREATH: State-based unfolding. */ +body.sidebar-open #sidebar { transform: translateX(0); } .sidebar-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; padding-bottom: .5rem; border-bottom: 1px solid #222; /* ΔRHYTHM: Internal divide for structural clarity. */ + margin-bottom: 1rem; padding-bottom: .5rem; border-bottom: 1px solid #222; } .sidebar-header h3 { - margin: 0; font-size: 1.1rem; color: var(--accent); /* ΔFIELD: Accent for thematic emphasis. */ + margin: 0; font-size: 1.1rem; color: var(--accent); } .toggle-btn { background: none; border: 1px solid #333; color: var(--fg); padding: .25rem .5rem; border-radius: var(--radius); - font-size: .8rem; cursor: pointer; /* ΔHORIZON: Compact control for filter toggling. */ + font-size: .8rem; cursor: pointer; } -#postListSection { margin-bottom: 1rem; } /* ΔRHYTHM: Breathing space before filters. */ +#postListSection { margin-bottom: 1rem; } #postList { - list-style: none; margin: 0; padding: 0; /* ΔFIELD: Clean slate for post enumeration. */ + list-style: none; margin: 0; padding: 0; } #postList li { margin: .75rem 0; padding: .5rem 0; - border-bottom: 1px dashed #222; /* ΔRHYTHM: Dashed for subtle separation. */ + border-bottom: 1px dashed #222; transition: color var(--transition); } #postList a { color: var(--fg); text-decoration: none; font-weight: 500; - display: block; /* ΔFIELD: Block for full clickable area. */ + display: block; } -#postList a:hover { color: var(--accent); } /* ΔBREATH: Color shift for hover life. */ +#postList a:hover { color: var(--accent); } #postList small { - display: block; font-size: .8rem; color: #888; margin-top: .25rem; /* ΔRHYTHM: Subdued meta for hierarchy. */ + display: block; font-size: .8rem; color: #888; margin-top: .25rem; } .filter-panel { background: #0a0a0a; border: 1px solid #222; border-radius: var(--radius); padding: .75rem; - margin-top: 1rem; /* ΔFIELD: Contained unit for filter isolation. */ + margin-top: 1rem; } .filter-panel summary { cursor: pointer; font-weight: 600; color: var(--accent); - margin-bottom: .5rem; user-select: none; /* ΔHORIZON: Non-selectable for clean interaction. */ + margin-bottom: .5rem; user-select: none; } .filter-controls { - display: flex; flex-direction: column; gap: .75rem; /* ΔRHYTHM: Vertical stack for mobile-friendly flow. */ + display: flex; flex-direction: column; gap: .75rem; } .filter-controls label { - font-size: .85rem; color: var(--accent); margin-bottom: .25rem; /* ΔFIELD: Accent labels for guidance. */ + font-size: .85rem; color: var(--accent); margin-bottom: .25rem; } .filter-controls select, .filter-controls input { background: #111; color: var(--fg); border: 1px solid #333; - padding: .5rem; border-radius: var(--radius); font-size: .9rem; /* ΔHORIZON: Consistent input styling. */ + padding: .5rem; border-radius: var(--radius); font-size: .9rem; } .filter-controls select[multiple] { - height: 80px; /* ΔFIELD: Fixed height for multi-select usability. */ + height: 80px; } .content { margin-top: calc(var(--topbar-h) + var(--subnav-h)); margin-left: 0; - transition: margin-left var(--transition), margin-top var(--transition); /* ΔBREATH: Adaptive margins for sidebar integration. */ + transition: margin-left var(--transition), margin-top var(--transition); min-height: calc(100vh - var(--topbar-h) - var(--subnav-h)); - padding: 0; + padding: 2rem 1rem; } @media (min-width: 1024px) { - #sidebar { transform: translateX(0); top: calc(var(--topbar-h) + var(--subnav-h)); } /* ΔHORIZON: Persistent sidebar on wide views. */ + #sidebar { transform: translateX(0); top: calc(var(--topbar-h) + var(--subnav-h)); } .content { margin-left: 300px; } } -/* === VIEWER – BREATHING FIELD === */ -/* ΔFIELD: Viewer as the core canvas; flex column for stacked content. - * Rationale: Hover gradient adds subtle interactivity, symbolizing depth. */ -.viewer { - display: flex; - flex-direction: column; - height: 100%; - transition: background 1s ease; /* ΔBREATH: Slow transition for ambient shift. */ -} - -.viewer:hover { - background: radial-gradient(circle at center, #111 0%, #0b0b0b 80%); /* ΔBREATH: Radial fade evokes inner light. */ -} - .viewer > * { - width: 100%; - margin: 0; - padding: 0; - animation: fadeIn .4s ease-out; /* ΔBREATH: Entrance fade for new content. */ + max-width: 720px; margin: auto; padding: 2rem 1rem; + animation: fadeIn .4s ease-out; } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } /* ΔBREATH: Upward reveal for rhythmic entry. */ + to { opacity: 1; transform: translateY(0); } } -/* === PREVIEW + PORTAL === */ -/* ΔTRUTH: Preview header as sticky portal trigger; flex-end for right alignment. - * Rationale: Semi-transparent bg for subtle overlay. */ -.preview-header { - display: flex; - justify-content: flex-end; - padding: 0.5rem 1rem; - background: rgba(224, 184, 75, 0.08); /* ΔBREATH: Golden haze for accent tie-in. */ - border-bottom: 1px solid #333; - position: sticky; - top: 0; - z-index: 10; -} - -.portal-btn { - background: none; - border: 1px solid var(--accent); - color: var(--accent); - border-radius: 6px; - padding: 0.25rem 0.75rem; - font-size: 0.85rem; - cursor: pointer; - transition: all 0.2s ease; /* ΔBREATH: Quick response for interactivity. */ -} - -.portal-btn:hover { - background: var(--accent); - color: var(--bg); /* ΔFIELD: Inversion for clear state change. */ -} - -.preview-content { - padding: 3rem 4vw; /* ΔRHYTHM: Generous padding for breathing margins. */ - max-width: 90ch; /* ΔHORIZON: Readable line length per typography best practices. */ - margin: auto; - line-height: 1.7; /* ΔRHYTHM: Enhanced vertical rhythm for previews. */ - color: var(--fg); - font-family: 'Inter', system-ui, sans-serif; -} - -.preview-content * { - background: transparent !important; /* ΔTRUTH: Override authored styles for coherence. */ - color: inherit !important; - font-family: inherit !important; /* ΔFIELD: Enforce systemic harmony over external chaos. */ -} - -.preview-content img, -.preview-content video, -.preview-content iframe { - max-width: 100%; - height: auto; - border-radius: 8px; - margin: 1.5rem 0; - display: block; /* ΔRHYTHM: Centered media with breathing space. */ -} - -.preview-content a { - color: var(--accent); - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 2px; /* ΔFIELD: Subtle underline for readability. */ -} - -.preview-content a:hover { - text-shadow: 0 0 6px var(--accent); /* ΔBREATH: Glow for link resonance. */ -} - -/* === PREVIEW SPACING FIX === */ - -/* ΔRHYTHM: Reset first-child to eliminate phantom top gaps. - * Rationale: !important overrides any authored margins for truth. */ -.preview-content > *:first-child { - margin-top: 0 !important; - padding-top: 0 !important; -} - -/* ΔRHYTHM: Ensure last-child has consistent bottom rhythm. */ -.preview-content > *:last-child { - margin-bottom: 3rem; -} - -/* ΔHORIZON: Mobile scaling for tighter, readable flow. */ -@media (max-width: 768px) { - .preview-content { - padding: 2rem 6vw; - line-height: 1.65; - } -} - -/* ΔBREATH: Ellipsis as intentional truncation marker with pulse. - * Rationale: Centered, opaque for aesthetic liminality; animation breathes life. */ -.preview-content p:empty::before { - content: "..."; - color: var(--accent); - opacity: 0.6; +.viewer iframe { + width: 100%; height: calc(100vh - var(--topbar-h) - var(--subnav-h) - 80px); + border: 0; border-radius: var(--radius); background: transparent; display: block; - text-align: center; - margin: 1.5rem 0; - letter-spacing: 0.2em; - animation: fadeEllipsis 1.2s ease-in-out infinite alternate; -} - -@keyframes fadeEllipsis { - from { opacity: 0.3; letter-spacing: 0.15em; } - to { opacity: 0.6; letter-spacing: 0.25em; } /* ΔBREATH: Oscillation mimics eternal breath. */ } diff --git a/tools/generate-index.mjs b/tools/generate-index.mjs index cc018bf..861d8a8 100755 --- a/tools/generate-index.mjs +++ b/tools/generate-index.mjs @@ -1,106 +1,127 @@ -// ΔFIELD: Node.js script to generate public/index.json from filesystem. -// Rationale: Async fs for efficiency; walks public/ excluding tools/ and index.json. -// Dependencies: fs/promises, path, gray-matter (for frontmatter parsing). -// Install: npm install gray-matter -// Usage: node tools/generate-index.mjs +#!/usr/bin/env node +import { promises as fs } from "fs"; +import path from "path"; +import pdf from "pdf-parse"; -import fs from 'fs/promises'; -import path from 'path'; -import matter from 'gray-matter'; +const ROOT = "public"; +const OUT = path.join(ROOT, "index.json"); +const EXCERPT_LENGTH = 400; -const PUBLIC_DIR = path.join(process.cwd(), 'public'); -const EXCLUDE_DIRS = ['tools']; -const INDEX_FILE = 'index.json'; -const EXCERPT_LENGTH = 200; +function dateFromName(name) { + const m = name.match(/^(\d{4}-\d{2}-\d{2})/); + return m ? new Date(m[0]).getTime() : null; +} -// ΔRECURSION: Walk directory recursively, collect file metadata. -async function walk(dir, base = '') { - const entries = await fs.readdir(dir, { withFileTypes: true }); - let files = []; - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - const relPath = path.join(base, entry.name); - if (entry.isDirectory()) { - if (!EXCLUDE_DIRS.includes(entry.name)) { - files = [...files, ...(await walk(fullPath, relPath))]; - } - } else if (['.md', '.html'].includes(path.extname(entry.name))) { - const stats = await fs.stat(fullPath); - const content = await fs.readFile(fullPath, 'utf-8'); - const { data: frontmatter, content: body } = matter(content); - const title = frontmatter.title || extractTitle(body) || entry.name.replace(/\.[^/.]+$/, ''); - const excerpt = extractExcerpt(body).slice(0, EXCERPT_LENGTH) + '...'; - const tags = frontmatter.tags || []; - const isPinned = !!frontmatter.pinned; - const isIndex = entry.name.startsWith('index.'); - files.push({ - path: relPath.replace(/\\/g, '/'), // Normalize to / - title, - tags, - isIndex, - isPinned, - ctime: stats.birthtimeMs, - mtime: stats.mtimeMs, - ext: path.extname(entry.name), - excerpt - }); - } +async function readHead(abs, full = false) { + const fh = await fs.open(abs, "r"); + const size = full ? await fs.stat(abs).then(s => Math.min(s.size, EXCERPT_LENGTH * 2)) : 64 * 1024; + const buf = Buffer.alloc(size); + const { bytesRead } = await fh.read(buf, 0, size, 0); + await fh.close(); + return buf.slice(0, bytesRead).toString("utf8"); +} + +function parseTitle(raw, ext) { + if (ext === ".md") return raw.match(/^\s*#\s+(.+?)\s*$/m)?.[1].trim(); + if (ext === ".html") return raw.match(/]*>([^<]+)<\/title>/i)?.[1].trim(); + return null; +} + +function extractExcerpt(raw, ext) { + if (ext === ".md") raw = raw.replace(/^#.*\n/, '').trim(); + if (ext === ".html") raw = raw.replace(/[\s\S]*<\/head>/i, '').replace(/<[^>]+>/g, ' ').trim(); + return raw.replace(/\s+/g, ' ').slice(0, EXCERPT_LENGTH); +} + +function extractTags(raw, ext, pdfData) { + let tags = []; + if (ext === ".md") { + const m = raw.match(/^\s*tags:\s*(.+)$/im); + if (m) tags = m[1].split(',').map(t => t.trim().toLowerCase()); + } else if (ext === ".html") { + const m = raw.match(/ t.trim().toLowerCase()); + } else if (ext === ".pdf" && pdfData?.info?.Subject) { + tags = pdfData.info.Subject.split(',').map(t => t.trim().toLowerCase()); } - return files; + return tags; } -// ΔTRUTH: Extract title from first # in MD or /<h1> in HTML. -function extractTitle(content) { - const mdMatch = content.match(/^#+\s*(.+)/m); - if (mdMatch) return mdMatch[1].trim(); - const htmlMatch = content.match(/<title>(.+?)<\/title>|<h1>(.+?)<\/h1>/i); - return htmlMatch ? (htmlMatch[1] || htmlMatch[2]).trim() : ''; -} +async function collectFiles(relBase = "", flat = []) { + const abs = path.join(ROOT, relBase); + const entries = await fs.readdir(abs, { withFileTypes: true }); -// ΔTRUTH: Extract first non-empty paragraph. -function extractExcerpt(content) { - const lines = content.split('\n').filter(line => line.trim()); - let excerpt = ''; - for (const line of lines) { - if (!line.startsWith('#') && !line.startsWith('<')) { - excerpt += line + ' '; - if (excerpt.length > EXCERPT_LENGTH) break; + for (const e of entries) { + if (e.name.startsWith(".")) continue; + + const rel = path.posix.join(relBase, e.name); + const absPath = path.join(ROOT, rel); + + if (rel.toLowerCase() === "index.html" || rel.toLowerCase() === "index.md") continue; + + if (e.isDirectory()) { + await collectFiles(rel, flat); + continue; } + + const ext = path.posix.extname(e.name).toLowerCase(); + if (![".md", ".html", ".pdf"].includes(ext)) continue; + + const st = await fs.stat(absPath); + let raw, pdfData, title; + if (ext === ".pdf") { + const buffer = await fs.readFile(absPath); + pdfData = await pdf(buffer); + raw = pdfData.text; + title = pdfData.info.Title || e.name.replace(/\.pdf$/, "").trim(); + } else { + raw = await readHead(absPath, true); + title = parseTitle(raw, ext) || e.name.replace(new RegExp(`\\${ext}$`), "").trim(); + } + + const ctime = st.birthtimeMs || st.mtimeMs || dateFromName(e.name) || st.mtimeMs; + const mtime = dateFromName(e.name) ?? st.mtimeMs; + const baseName = e.name.toLowerCase(); + + flat.push({ + type: "file", + name: e.name, + title, + path: rel, + ext, + ctime, + mtime, + excerpt: extractExcerpt(raw, ext), + tags: extractTags(raw, ext, pdfData), + isIndex: baseName.startsWith("index."), + isPinned: baseName.startsWith("pinned.") + }); } - return excerpt.trim(); + return flat; } -// ΔRECURSION: Build hierarchies from paths. -function buildHierarchies(files) { - const hierarchies = {}; - files.forEach(file => { - const parts = file.path.split('/').slice(0, -1); - for (let i = 0; i < parts.length; i++) { - const parent = parts.slice(0, i).join('/') || null; - const child = parts[i]; - if (parent) { +(async () => { + try { + const flat = await collectFiles(); + const sections = [...new Set(flat.filter(f => !f.isIndex).map(f => f.path.split("/")[0]))].sort(); + const hierarchies = {}; + for (const f of flat.filter(f => f.isIndex)) { + const parts = f.path.split("/"); + if (parts.length > 2) { + const parent = parts.slice(0, -2).join("/"); + const child = parts[parts.length - 2]; if (!hierarchies[parent]) hierarchies[parent] = []; - if (!hierarchies[parent].includes(child)) hierarchies[parent].push(child); - } else { - if (!hierarchies.root) hierarchies.root = []; - if (!hierarchies.root.includes(child)) hierarchies.root.push(child); + if (!hierarchies[parent].includes(child)) { + hierarchies[parent].push(child); + } } } - }); - delete hierarchies.root; // Focus on section-level - Object.values(hierarchies).forEach(subs => subs.sort()); - return hierarchies; -} + const allTags = [...new Set(flat.flatMap(f => f.tags))].sort(); -// ΔFIELD: Main execution. -async function generateIndex() { - const flat = await walk(PUBLIC_DIR); - const sections = [...new Set(flat.map(f => f.path.split('/')[0]).filter(Boolean))].sort(); - const tags = [...new Set(flat.flatMap(f => f.tags))].sort(); - const hierarchies = buildHierarchies(flat); - const indexData = { flat, sections, tags, hierarchies }; - await fs.writeFile(path.join(PUBLIC_DIR, INDEX_FILE), JSON.stringify(indexData, null, 2)); - console.log('index.json generated.'); -} - -generateIndex().catch(console.error); + await fs.writeFile(OUT, JSON.stringify({ flat, sections, tags: allTags, hierarchies }, null, 2)); + console.log(`index.json built: ${flat.length} files, ${sections.length} sections, ${Object.keys(hierarchies).length} hierarchies, ${allTags.length} tags.`); + } catch (e) { + console.error("Build failed:", e); + process.exit(1); + } +})();