From 480da77bb162e4cf59e5a8ea4ee17136fdaa182a Mon Sep 17 00:00:00 2001 From: Mark Randall Havens Date: Sun, 9 Nov 2025 19:37:10 +0000 Subject: [PATCH] update --- package.json | 32 +- public/app.js | 624 ++++++++++++++++++++------------------- public/index.html | 54 ++-- public/styles.css | 129 ++++---- tools/generate-index.mjs | 217 ++++++-------- 5 files changed, 546 insertions(+), 510 deletions(-) diff --git a/package.json b/package.json index 93321a0..3fbfe68 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,31 @@ { - "name": "the-fold-within", - "version": "3.3.1", - "dependencies": { - "pdf-parse": "^1.1.1" + "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"] } } diff --git a/public/app.js b/public/app.js index aa78fcf..205d1de 100755 --- a/public/app.js +++ b/public/app.js @@ -1,309 +1,333 @@ -:root { - --bg: #0b0b0b; - --fg: #e6e3d7; - --accent: #e0b84b; - --topbar-h: 56px; - --subnav-h: 44px; - --transition: .3s ease; - --radius: 8px; -} +/** + * app.js – v3.3.3 FIELD COMMENTARY EDITION + * High-coherence, readable, maintainable blueprint. + * No hacks. No surgery. Only truth. + * Enhanced with modular sanitization, resilient recursion, and inline rationale. + * + * ΔFIELD: This script orchestrates the breathing field: navigation, routing, rendering. + * Rationale: Dependency-free; async fetches for dynamic content; stateful only where essential (e.g., sidebar). + * Assumptions: index.json provides metadata; marked.js for MD; DOM elements pre-exist in HTML skeleton. + */ -* { box-sizing: border-box; } +const els = { + menuBtn: document.getElementById("menuBtn"), /* ΔHORIZON: Toggle for sidebar. */ + primaryNav: document.getElementById("primaryNav"), /* ΔHORIZON: Top-level sections. */ + subNav: document.getElementById("subNav"), /* ΔRECURSION: Nested sub-horizons. */ + sectionSelect: document.getElementById("sectionSelect"), /* ΔFIELD: Filter by section. */ + tagSelect: document.getElementById("tagSelect"), /* ΔFIELD: Multi-tag filter. */ + sortSelect: document.getElementById("sortSelect"), /* ΔRHYTHM: Time-based sorting. */ + searchMode: document.getElementById("searchMode"), /* ΔTRUTH: Scope of search. */ + searchBox: document.getElementById("searchBox"), /* ΔTRUTH: Query input. */ + postList: document.getElementById("postList"), /* ΔFIELD: Dynamic post enumeration. */ + viewer: document.getElementById("viewer"), /* ΔFIELD: Content rendering canvas. */ + content: document.getElementById("content"), /* ΔHORIZON: Main wrapper for click events. */ + toggleControls: document.getElementById("toggleControls"), /* ΔHORIZON: Filter panel toggle. */ + filterPanel: document.getElementById("filterPanel") /* ΔFIELD: Collapsible filters. */ +}; -html, body { - margin: 0; padding: 0; height: 100%; - background: var(--bg); color: var(--fg); - font-family: 'Inter', system-ui, sans-serif; - line-height: 1.6; -} +let indexData = null; /* ΔRECURSION: Cached metadata for all operations. */ +let sidebarOpen = false; /* ΔBREATH: State for mobile sidebar. */ +let currentParent = null; /* ΔRECURSION: Track for subnav rendering. */ +let indexFiles = null; // Cached index files /* ΔRECURSION: Pre-filtered for quick lookups. */ -/* === TOPBAR & NAV === */ -.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; - padding: 0 1rem; z-index: 1000; gap: 1.5rem; - overflow: hidden; -} - -#menuBtn { - 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; -} - -.primary-nav { - display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: center; - overflow-x: auto; - white-space: nowrap; -} - -.primary-nav a { - color: var(--accent); text-decoration: none; - font-weight: 600; font-size: 1.1rem; - transition: all var(--transition); -} - -.primary-nav a:hover { - text-shadow: 0 0 10px var(--accent); -} - -/* === SUBNAV === */ -.sub-nav { - display: none; - position: fixed; - top: var(--topbar-h); - left: 0; right: 0; - height: var(--subnav-h); - background: #0e0e0e; - border-bottom: 1px solid #222; - padding: 0 1rem; - gap: 1rem; - overflow-x: auto; - white-space: nowrap; - z-index: 950; - align-items: center; - opacity: 0; - transform: translateY(-5px); - transition: opacity var(--transition), transform var(--transition); -} - -.sub-nav.visible { - display: flex; - opacity: 1; - transform: translateY(0); - animation: fadeSlideDown .3s ease forwards; -} - -@keyframes fadeSlideDown { - from { opacity: 0; transform: translateY(-5px); } - to { opacity: 1; transform: translateY(0); } -} - -.sub-nav a { - color: var(--accent); - text-decoration: none; - font-weight: 500; - font-size: 0.95rem; - transition: all var(--transition); -} - -.sub-nav a:hover { - text-shadow: 0 0 8px var(--accent); -} - -/* === SIDEBAR === */ -#sidebar { - position: fixed; top: calc(var(--topbar-h) + var(--subnav-h)); left: 0; - width: 300px; bottom: 0; overflow: auto; - background: #111; border-right: 1px solid #222; - transform: translateX(-100%); transition: transform var(--transition); - z-index: 900; padding: 1rem; -} - -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; -} - -.sidebar-header h3 { - 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; -} - -#postListSection { margin-bottom: 1rem; } - -#postList { - list-style: none; margin: 0; padding: 0; -} - -#postList li { - margin: .75rem 0; padding: .5rem 0; - border-bottom: 1px dashed #222; - transition: color var(--transition); -} - -#postList a { - color: var(--fg); text-decoration: none; font-weight: 500; - display: block; -} - -#postList a:hover { color: var(--accent); } - -#postList small { - 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; -} - -.filter-panel summary { - cursor: pointer; font-weight: 600; color: var(--accent); - margin-bottom: .5rem; user-select: none; -} - -.filter-controls { - display: flex; flex-direction: column; gap: .75rem; -} - -.filter-controls label { - 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; -} - -.filter-controls select[multiple] { - height: 80px; -} - -.content { - margin-top: calc(var(--topbar-h) + var(--subnav-h)); margin-left: 0; - transition: margin-left var(--transition), margin-top var(--transition); - min-height: calc(100vh - var(--topbar-h) - var(--subnav-h)); - padding: 0; -} - -@media (min-width: 1024px) { - #sidebar { transform: translateX(0); top: calc(var(--topbar-h) + var(--subnav-h)); } - .content { margin-left: 300px; } -} - -/* === VIEWER – BREATHING FIELD === */ -.viewer { - display: flex; - flex-direction: column; - height: 100%; - transition: background 1s ease; -} - -.viewer:hover { - background: radial-gradient(circle at center, #111 0%, #0b0b0b 80%); -} - -.viewer > * { - width: 100%; - margin: 0; - padding: 0; - animation: fadeIn .4s ease-out; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} - -/* === PREVIEW + PORTAL === */ -.preview-header { - display: flex; - justify-content: flex-end; - padding: 0.5rem 1rem; - background: rgba(224, 184, 75, 0.08); - 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; -} - -.portal-btn:hover { - background: var(--accent); - color: var(--bg); -} - -.preview-content { - padding: 3rem 4vw; - max-width: 90ch; - margin: auto; - line-height: 1.7; - color: var(--fg); - font-family: 'Inter', system-ui, sans-serif; -} - -.preview-content * { - background: transparent !important; - color: inherit !important; - font-family: inherit !important; -} - -.preview-content img, -.preview-content video, -.preview-content iframe { - max-width: 100%; - height: auto; - border-radius: 8px; - margin: 1.5rem 0; - display: block; -} - -.preview-content a { - color: var(--accent); - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 2px; -} - -.preview-content a:hover { - text-shadow: 0 0 6px var(--accent); -} - -/* === PREVIEW SPACING FIX === */ - -/* Remove top margin from the first visible child */ -.preview-content > *:first-child { - margin-top: 0 !important; - padding-top: 0 !important; -} - -/* Consistent bottom rhythm for the last visible element */ -.preview-content > *:last-child { - margin-bottom: 3rem; -} - -/* Responsive typography scaling for mobile preview */ -@media (max-width: 768px) { - .preview-content { - padding: 2rem 6vw; - line-height: 1.65; +// === INITIALIZATION === +/* ΔFIELD: Async init loads data and wires UI; fallback for errors. + * Rationale: Single entry point; console log as harmony affirmation. */ +async function init() { + try { + indexData = await (await fetch("index.json")).json(); /* ΔTRUTH: Source of all content truth. */ + indexFiles = indexData.flat.filter(f => f.isIndex); /* ΔRECURSION: Cache for directory indices. */ + populateNav(); /* ΔHORIZON: Build primary nav from sections. */ + populateSections(); /* ΔFIELD: Populate section dropdown. */ + populateTags(); /* ΔFIELD: Populate tag multi-select. */ + wireUI(); /* ΔHORIZON: Attach all event listeners. */ + renderList(); /* ΔFIELD: Initial post list render. */ + handleHash(); /* ΔRECURSION: Process current URL state. */ + window.addEventListener("hashchange", handleHash); /* ΔRECURSION: Listen for navigation. */ + console.info('%cThe Fold Within: Harmony sustained.', 'color:#e0b84b'); /* ΔFIELD: Dev resonance. */ + } catch (e) { + els.viewer.innerHTML = "

Error

Failed to load site data.

"; /* ΔTRUTH: Graceful failure. */ } } -/* Ellipsis alignment fix */ -.preview-content p:empty::before { - content: "..."; - color: var(--accent); - opacity: 0.6; - display: block; - text-align: center; - margin: 1.5rem 0; - letter-spacing: 0.2em; - animation: fadeEllipsis 1.2s ease-in-out infinite alternate; +// === NAVIGATION === +/* ΔHORIZON: Dynamically build primary nav from unique top-level sections. + * Rationale: Sort for alphabetical order; capitalize for aesthetics. */ +function populateNav() { + els.primaryNav.innerHTML = 'Home'; /* ΔFIELD: Fixed home anchor. */ + const navSections = [...new Set( + indexData.flat + .filter(f => f.isIndex && f.path.split("/").length > 1) + .map(f => f.path.split("/")[0]) + )].sort(); + navSections.forEach(s => { + els.primaryNav.innerHTML += `${s.charAt(0).toUpperCase() + s.slice(1)}`; + }); } -@keyframes fadeEllipsis { - from { opacity: 0.3; letter-spacing: 0.15em; } - to { opacity: 0.6; letter-spacing: 0.25em; } +/* ΔFIELD: Section dropdown with default to 'posts' if available. + * Rationale: 'all' option for broad views. */ +function populateSections() { + els.sectionSelect.innerHTML = ''; + indexData.sections.forEach(s => { + const opt = document.createElement("option"); + opt.value = s; opt.textContent = s; + els.sectionSelect.appendChild(opt); + }); + + const defaultSection = indexData.sections.includes("posts") ? "posts" : indexData.sections[0]; + if (defaultSection) els.sectionSelect.value = defaultSection; } + +/* ΔFIELD: Tags as multi-select options. + * Rationale: Lowercase normalization in filters for case-insensitivity. */ +function populateTags() { + indexData.tags.forEach(t => { + const opt = document.createElement("option"); + opt.value = t; opt.textContent = t; + els.tagSelect.appendChild(opt); + }); +} + +// === UI WIRING === +/* ΔHORIZON: Attach listeners for interactivity. + * Rationale: Centralized wiring; mobile-specific sidebar close on content click. */ +function wireUI() { + els.menuBtn.addEventListener("click", () => { + sidebarOpen = !sidebarOpen; + document.body.classList.toggle("sidebar-open", sidebarOpen); /* ΔBREATH: Class toggle for CSS-driven motion. */ + }); + + els.toggleControls.addEventListener("click", () => { + const open = els.filterPanel.open; + els.filterPanel.open = !open; + els.toggleControls.textContent = open ? "Filters" : "Hide"; /* ΔFIELD: Dynamic label for state clarity. */ + }); + + els.sectionSelect.addEventListener("change", () => { + renderList(); + if (els.sectionSelect.value !== "all") loadDefaultForSection(els.sectionSelect.value); /* ΔRECURSION: Auto-load default on section change. */ + }); + + [els.tagSelect, els.sortSelect, els.searchMode].forEach(el => el.addEventListener("change", renderList)); + els.searchBox.addEventListener("input", renderList); /* ΔTRUTH: Real-time filtering on input. */ + + // 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")) { + document.body.classList.remove("sidebar-open"); + sidebarOpen = false; /* ΔHORIZON: Gesture respect for mobile usability. */ + } + } + }); +} + +// === LIST RENDERING === +/* ΔFIELD: Dynamic filtering and sorting of posts. + * Rationale: Chainable filters; fallback message; pinned denoted with 'Star'. */ +function renderList() { + const section = els.sectionSelect.value; + const tags = Array.from(els.tagSelect.selectedOptions).map(o => o.value.toLowerCase()); + const sort = els.sortSelect.value; + const mode = els.searchMode.value; + const query = els.searchBox.value.toLowerCase(); + + let posts = indexData.flat.filter(p => !p.isIndex); /* ΔTRUTH: Exclude indices for content focus. */ + if (section !== "all") posts = posts.filter(p => p.path.split('/')[0] === section); + if (tags.length) posts = posts.filter(p => tags.every(t => p.tags.includes(t))); /* ΔFIELD: AND logic for tags. */ + if (query) { + posts = posts.filter(p => { + const text = mode === "content" ? p.title + " " + p.excerpt : p.title; + return text.toLowerCase().includes(query); /* ΔTRUTH: Scoped search for efficiency. */ + }); + } + posts.sort((a, b) => sort === "newest" ? b.mtime - a.mtime : a.mtime - b.mtime); /* ΔRHYTHM: Time-based order. */ + + els.postList.innerHTML = posts.length ? "" : "
  • No posts found.
  • "; + posts.forEach(p => { + const li = document.createElement("li"); + const pin = p.isPinned ? "Star " : ""; + const time = new Date(p.ctime).toLocaleDateString(); /* ΔRHYTHM: Human-readable date. */ + li.innerHTML = `${pin}${p.title}${time}`; + els.postList.appendChild(li); + }); +} + +/* ΔRECURSION: Load pinned or latest for section fallback. + * Rationale: Prevents empty states; redirects via hash for routing consistency. */ +function loadDefaultForSection(section) { + const posts = indexData.flat.filter(p => p.path.split('/')[0] === section && !p.isIndex); + if (!posts.length) { + els.viewer.innerHTML = `

    ${section}

    No content yet.

    `; + return; + } + const pinned = posts.find(p => p.isPinned) || posts.sort((a,b) => b.mtime - a.mtime)[0]; + location.hash = `#/${pinned.path}`; +} + +// === SUBNAV (NESTED HORIZON) === +/* ΔRECURSION: Render subnav based on parent hierarchy. + * Rationale: Clear on change; RAF for smooth visible class addition. */ +function renderSubNav(parent) { + const subnav = els.subNav; + subnav.innerHTML = ""; + subnav.classList.remove("visible"); + + if (!parent || !indexData.hierarchies?.[parent]) return; /* ΔTRUTH: Early exit if no subs. */ + + const subs = indexData.hierarchies[parent]; + subs.forEach(child => { + const link = document.createElement("a"); + link.href = `#/${parent}/${child}/`; + link.textContent = child.charAt(0).toUpperCase() + child.slice(1); + subnav.appendChild(link); + }); + + requestAnimationFrame(() => subnav.classList.add("visible")); /* ΔBREATH: Deferred for animation prep. */ +} + +// === HASH ROUTING === +/* ΔRECURSION: Core router; parses hash, renders accordingly. + * Rationale: Resilient to edge cases; recursive via parent tracking; fallbacks to defaults. */ +async function handleHash() { + els.viewer.innerHTML = ""; /* ΔFIELD: Clear canvas for fresh render. */ + const rel = location.hash.replace(/^#\//, ""); + const parts = rel.split("/").filter(Boolean); + const currentParentPath = parts.slice(0, -1).join("/") || parts[0] || null; + + if (currentParentPath !== currentParent) { + currentParent = currentParentPath; + renderSubNav(currentParent); /* ΔRECURSION: Update subnav on parent change. */ + } + + const topSection = parts[0] || null; + if (topSection && indexData.sections.includes(topSection)) { + els.sectionSelect.value = topSection; + renderList(); /* ΔFIELD: Sync list with section. */ + } + + if (rel === '' || rel === '#') return renderDefault(); /* ΔRECURSION: Resilient home handling. */ + + if (!rel) return renderDefault(); + + if (rel.endsWith('/')) { + const currentPath = parts.join("/"); + const indexFile = indexFiles.find(f => { + const dir = f.path.split("/").slice(0, -1).join("/"); + return dir === currentPath; /* ΔTRUTH: Match directory to index file. */ + }); + + if (indexFile) { + if (indexFile.ext === ".md") { + await renderMarkdown(indexFile.path); + } else { + await renderIframe("/" + indexFile.path); + } + } else { + if (topSection) loadDefaultForSection(topSection); + else els.viewer.innerHTML = `

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

    No content yet.

    `; /* ΔFIELD: Placeholder for empty dirs. */ + } + } else { + const file = indexData.flat.find(f => f.path === rel); + if (!file) { + els.viewer.innerHTML = "

    404

    Not found.

    "; /* ΔTRUTH: Honest error. */ + return; + } + file.ext === ".md" ? await renderMarkdown(file.path) : await renderIframe("/" + file.path); /* ΔFIELD: Type-based rendering. */ + } +} + +/* ΔTRUTH: Fetch and parse Markdown; fallback to 'Untitled'. + * Rationale: Uses marked.js (assumed global); wraps in article for styling. */ +async function renderMarkdown(rel) { + const src = await fetch(rel).then(r => r.ok ? r.text() : ""); + els.viewer.innerHTML = `
    ${marked.parse(src || "# Untitled")}
    `; +} + +// === PREVIEW + PORTAL ENGINE === +/* ΔFIELD: Render sanitized preview with portal button. + * Rationale: Button opens full in new tab for immersion preservation. */ +async function renderIframe(rel) { + const preview = await generatePreview(rel); + const portalBtn = ``; + + els.viewer.innerHTML = ` +
    ${portalBtn}
    +
    ${preview}
    + `; + + els.viewer.querySelector(".portal-btn").addEventListener("click", e => { + window.open(e.target.dataset.src, "_blank", "noopener,noreferrer"); /* ΔTRUTH: Secure external open. */ + }); +} + +/* ΔTRUTH: Generate safe, trimmed preview from HTML. + * Rationale: Extract body; sanitize; trim recursively; fallback link on error. */ +async function generatePreview(rel) { + try { + const res = await fetch(rel); + if (!res.ok) throw new Error(); + const html = await res.text(); + + /* ΔTRUTH: Modular sanitizer strips scripts, styles, events, inline CSS; normalizes whitespace. + * Rationale: Prevents injection/XSS; removes phantoms for clean rhythm. */ + 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 /* ΔFIELD: Bounds for performance. */ + + return div.innerHTML || `

    Empty content.

    `; + } catch { + return `

    Preview unavailable. Open directly.

    `; /* ΔTRUTH: Fallback preserves access. */ + } +} + +/* ΔRECURSION: Trim DOM tree to depth/char limits. + * Rationale: Cumulative total ensures balanced siblings; removes excess for preview focus. */ +function trimPreview(el, maxDepth, charLimit, depth = 0, chars = 0) { + if (depth > maxDepth || chars > charLimit) { + el.innerHTML = "..."; /* ΔBREATH: Ellipsis as truncation breath. */ + return; + } + let total = chars; + for (const child of [...el.children]) { + total += child.textContent.length; /* ΔRECURSION: Pre-calculate to avoid bias. */ + if (total > charLimit || depth > maxDepth) { + child.remove(); + } else { + trimPreview(child, maxDepth, charLimit, depth + 1, total); + } + } +} + +// === DEFAULT VIEW === +/* ΔFIELD: Render home with default section fallback. + * Rationale: Prioritizes 'posts'; welcoming placeholder if empty. */ +function renderDefault() { + const defaultSection = indexData.sections.includes("posts") ? "posts" : indexData.sections[0]; + if (defaultSection) { + els.sectionSelect.value = defaultSection; + renderList(); + loadDefaultForSection(defaultSection); + } else { + els.viewer.innerHTML = "

    Welcome

    Add content to begin.

    "; + } +} + +// === START === +init(); /* ΔFIELD: Invoke the blueprint's origin. */ diff --git a/public/index.html b/public/index.html index af81a08..a91fc2e 100755 --- a/public/index.html +++ b/public/index.html @@ -2,61 +2,59 @@ - - The Fold Within - - - + + The Fold Within – v3.3.3 + + + + + - - +
    - +
    - + - + - +
    -
    +
    + diff --git a/public/styles.css b/public/styles.css index aa78fcf..8509bf9 100755 --- a/public/styles.css +++ b/public/styles.css @@ -1,27 +1,32 @@ +/* Δ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; - --fg: #e6e3d7; - --accent: #e0b84b; - --topbar-h: 56px; - --subnav-h: 44px; - --transition: .3s ease; - --radius: 8px; + --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. */ } -* { box-sizing: border-box; } +* { box-sizing: border-box; } /* ΔFIELD: Universal sizing model eliminates phantom padding issues. */ html, body { - margin: 0; padding: 0; height: 100%; + margin: 0; padding: 0; height: 100%; /* ΔFIELD: Full-viewport claim ensures no external gaps. */ background: var(--bg); color: var(--fg); - font-family: 'Inter', system-ui, sans-serif; - line-height: 1.6; + 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. */ } /* === 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; + background: #111; border-bottom: 1px solid #222; /* ΔBREATH: Subtle divide for depth perception. */ padding: 0 1rem; z-index: 1000; gap: 1.5rem; overflow: hidden; } @@ -30,9 +35,11 @@ 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; + min-width: 2.5rem; /* ΔFIELD: Minimal width for touch-friendly interaction. */ } +/* Δ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; @@ -42,14 +49,16 @@ html, body { .primary-nav a { color: var(--accent); text-decoration: none; font-weight: 600; font-size: 1.1rem; - transition: all var(--transition); + transition: all var(--transition); /* ΔBREATH: Unified motion for hover resonance. */ } .primary-nav a:hover { - text-shadow: 0 0 10px var(--accent); + text-shadow: 0 0 10px var(--accent); /* ΔHORIZON: Glow for interactive feedback; increased for DPI crispness. */ } /* === SUBNAV === */ +/* ΔRECURSION: Subnav as nested horizon; hidden by default, revealed on context. + * Rationale: Opacity/transform transition with animation for smooth unfolding. */ .sub-nav { display: none; position: fixed; @@ -73,12 +82,12 @@ html, body { display: flex; opacity: 1; transform: translateY(0); - animation: fadeSlideDown .3s ease forwards; + animation: fadeSlideDown .3s ease forwards; /* ΔBREATH: Entrance animation embodies revelation. */ } @keyframes fadeSlideDown { from { opacity: 0; transform: translateY(-5px); } - to { opacity: 1; transform: translateY(0); } + to { opacity: 1; transform: translateY(0); } /* ΔBREATH: Gentle descent for rhythmic integration. */ } .sub-nav a { @@ -90,10 +99,12 @@ html, body { } .sub-nav a:hover { - text-shadow: 0 0 8px var(--accent); + text-shadow: 0 0 8px var(--accent); /* ΔHORIZON: Subdued glow for secondary layer; consistent with primary. */ } /* === 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; @@ -102,116 +113,120 @@ html, body { z-index: 900; padding: 1rem; } -body.sidebar-open #sidebar { transform: translateX(0); } +body.sidebar-open #sidebar { transform: translateX(0); } /* ΔBREATH: State-based unfolding. */ .sidebar-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; padding-bottom: .5rem; border-bottom: 1px solid #222; + margin-bottom: 1rem; padding-bottom: .5rem; border-bottom: 1px solid #222; /* ΔRHYTHM: Internal divide for structural clarity. */ } .sidebar-header h3 { - margin: 0; font-size: 1.1rem; color: var(--accent); + margin: 0; font-size: 1.1rem; color: var(--accent); /* ΔFIELD: Accent for thematic emphasis. */ } .toggle-btn { background: none; border: 1px solid #333; color: var(--fg); padding: .25rem .5rem; border-radius: var(--radius); - font-size: .8rem; cursor: pointer; + font-size: .8rem; cursor: pointer; /* ΔHORIZON: Compact control for filter toggling. */ } -#postListSection { margin-bottom: 1rem; } +#postListSection { margin-bottom: 1rem; } /* ΔRHYTHM: Breathing space before filters. */ #postList { - list-style: none; margin: 0; padding: 0; + list-style: none; margin: 0; padding: 0; /* ΔFIELD: Clean slate for post enumeration. */ } #postList li { margin: .75rem 0; padding: .5rem 0; - border-bottom: 1px dashed #222; + border-bottom: 1px dashed #222; /* ΔRHYTHM: Dashed for subtle separation. */ transition: color var(--transition); } #postList a { color: var(--fg); text-decoration: none; font-weight: 500; - display: block; + display: block; /* ΔFIELD: Block for full clickable area. */ } -#postList a:hover { color: var(--accent); } +#postList a:hover { color: var(--accent); } /* ΔBREATH: Color shift for hover life. */ #postList small { - display: block; font-size: .8rem; color: #888; margin-top: .25rem; + display: block; font-size: .8rem; color: #888; margin-top: .25rem; /* ΔRHYTHM: Subdued meta for hierarchy. */ } .filter-panel { background: #0a0a0a; border: 1px solid #222; border-radius: var(--radius); padding: .75rem; - margin-top: 1rem; + margin-top: 1rem; /* ΔFIELD: Contained unit for filter isolation. */ } .filter-panel summary { cursor: pointer; font-weight: 600; color: var(--accent); - margin-bottom: .5rem; user-select: none; + margin-bottom: .5rem; user-select: none; /* ΔHORIZON: Non-selectable for clean interaction. */ } .filter-controls { - display: flex; flex-direction: column; gap: .75rem; + display: flex; flex-direction: column; gap: .75rem; /* ΔRHYTHM: Vertical stack for mobile-friendly flow. */ } .filter-controls label { - font-size: .85rem; color: var(--accent); margin-bottom: .25rem; + font-size: .85rem; color: var(--accent); margin-bottom: .25rem; /* ΔFIELD: Accent labels for guidance. */ } .filter-controls select, .filter-controls input { background: #111; color: var(--fg); border: 1px solid #333; - padding: .5rem; border-radius: var(--radius); font-size: .9rem; + padding: .5rem; border-radius: var(--radius); font-size: .9rem; /* ΔHORIZON: Consistent input styling. */ } .filter-controls select[multiple] { - height: 80px; + height: 80px; /* ΔFIELD: Fixed height for multi-select usability. */ } .content { margin-top: calc(var(--topbar-h) + var(--subnav-h)); margin-left: 0; - transition: margin-left var(--transition), margin-top var(--transition); + transition: margin-left var(--transition), margin-top var(--transition); /* ΔBREATH: Adaptive margins for sidebar integration. */ min-height: calc(100vh - var(--topbar-h) - var(--subnav-h)); padding: 0; } @media (min-width: 1024px) { - #sidebar { transform: translateX(0); top: calc(var(--topbar-h) + var(--subnav-h)); } + #sidebar { transform: translateX(0); top: calc(var(--topbar-h) + var(--subnav-h)); } /* ΔHORIZON: Persistent sidebar on wide views. */ .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; + transition: background 1s ease; /* ΔBREATH: Slow transition for ambient shift. */ } .viewer:hover { - background: radial-gradient(circle at center, #111 0%, #0b0b0b 80%); + 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; + animation: fadeIn .4s ease-out; /* ΔBREATH: Entrance fade for new content. */ } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } + to { opacity: 1; transform: translateY(0); } /* ΔBREATH: Upward reveal for rhythmic entry. */ } /* === 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); + background: rgba(224, 184, 75, 0.08); /* ΔBREATH: Golden haze for accent tie-in. */ border-bottom: 1px solid #333; position: sticky; top: 0; @@ -226,27 +241,27 @@ body.sidebar-open #sidebar { transform: translateX(0); } padding: 0.25rem 0.75rem; font-size: 0.85rem; cursor: pointer; - transition: all 0.2s ease; + transition: all 0.2s ease; /* ΔBREATH: Quick response for interactivity. */ } .portal-btn:hover { background: var(--accent); - color: var(--bg); + color: var(--bg); /* ΔFIELD: Inversion for clear state change. */ } .preview-content { - padding: 3rem 4vw; - max-width: 90ch; + 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; + 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; + background: transparent !important; /* ΔTRUTH: Override authored styles for coherence. */ color: inherit !important; - font-family: inherit !important; + font-family: inherit !important; /* ΔFIELD: Enforce systemic harmony over external chaos. */ } .preview-content img, @@ -256,34 +271,35 @@ body.sidebar-open #sidebar { transform: translateX(0); } height: auto; border-radius: 8px; margin: 1.5rem 0; - display: block; + 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; + text-underline-offset: 2px; /* ΔFIELD: Subtle underline for readability. */ } .preview-content a:hover { - text-shadow: 0 0 6px var(--accent); + text-shadow: 0 0 6px var(--accent); /* ΔBREATH: Glow for link resonance. */ } /* === PREVIEW SPACING FIX === */ -/* Remove top margin from the first visible child */ +/* Δ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; } -/* Consistent bottom rhythm for the last visible element */ +/* ΔRHYTHM: Ensure last-child has consistent bottom rhythm. */ .preview-content > *:last-child { margin-bottom: 3rem; } -/* Responsive typography scaling for mobile preview */ +/* ΔHORIZON: Mobile scaling for tighter, readable flow. */ @media (max-width: 768px) { .preview-content { padding: 2rem 6vw; @@ -291,7 +307,8 @@ body.sidebar-open #sidebar { transform: translateX(0); } } } -/* Ellipsis alignment fix */ +/* Δ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); @@ -305,5 +322,5 @@ body.sidebar-open #sidebar { transform: translateX(0); } @keyframes fadeEllipsis { from { opacity: 0.3; letter-spacing: 0.15em; } - to { opacity: 0.6; letter-spacing: 0.25em; } + 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 9bd5a10..cc018bf 100755 --- a/tools/generate-index.mjs +++ b/tools/generate-index.mjs @@ -1,133 +1,106 @@ -#!/usr/bin/env node -/** - * generate-index.mjs - * Scans public/ for .md, .html, .pdf - * Builds index.json: flat list, sections, tags, hierarchies - * Zero config. Filesystem = truth. - */ -import { promises as fs } from "fs"; -import path from "path"; -import pdf from "pdf-parse"; +// Δ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 -const ROOT = "public"; -const OUT = path.join(ROOT, "index.json"); -const EXCERPT_LENGTH = 400; +import fs from 'fs/promises'; +import path from 'path'; +import matter from 'gray-matter'; -function dateFromName(name) { - const m = name.match(/^(\d{4}-\d{2}-\d{2})/); - return m ? new Date(m[0]).getTime() : null; -} +const PUBLIC_DIR = path.join(process.cwd(), 'public'); +const EXCLUDE_DIRS = ['tools']; +const INDEX_FILE = 'index.json'; +const EXCERPT_LENGTH = 200; -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 tags; -} - -async function collectFiles(relBase = "", flat = []) { - const abs = path.join(ROOT, relBase); - const entries = await fs.readdir(abs, { withFileTypes: true }); - - 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; +// Δ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 + }); } - - 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 flat; + return files; } -(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]; +// Δ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() : ''; +} + +// Δ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; + } + } + return excerpt.trim(); +} + +// Δ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) { if (!hierarchies[parent]) hierarchies[parent] = []; - if (!hierarchies[parent].includes(child)) { - hierarchies[parent].push(child); - } + if (!hierarchies[parent].includes(child)) hierarchies[parent].push(child); + } else { + if (!hierarchies.root) hierarchies.root = []; + if (!hierarchies.root.includes(child)) hierarchies.root.push(child); } } - const allTags = [...new Set(flat.flatMap(f => f.tags))].sort(); + }); + delete hierarchies.root; // Focus on section-level + Object.values(hierarchies).forEach(subs => subs.sort()); + return hierarchies; +} - 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); - } -})(); +// Δ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);