revamped
This commit is contained in:
1 parent
322b182ca1
commit
93d4f838b6
50 files changed
+1016
-113
No files matched your search
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -0,0 +1,110 @@
|
||||
# The Fold Within Earth
|
||||
|
||||
A Markdown-native static site for multi-section content.
|
||||
|
||||
[](https://nodejs.org)
|
||||
[](LICENSE)
|
||||
|
||||
## Authoring Guide
|
||||
|
||||
To add or edit content, create or modify Markdown files in `/content/<section>/<year>/<slug>.md`.
|
||||
|
||||
### Front-Matter Spec
|
||||
|
||||
Use YAML front-matter at the top of each .md file:
|
||||
|
||||
```
|
||||
---
|
||||
title: Your Title
|
||||
date: YYYY-MM-DD
|
||||
excerpt: Optional short description.
|
||||
tags: [tag1, tag2]
|
||||
section: one-of-the-sections (must match directory)
|
||||
slug: optional-custom-slug
|
||||
cover: /media/image.webp (optional)
|
||||
author: Optional author name
|
||||
series: Optional series name for serialized posts
|
||||
programs: [neutralizing-narcissism, open-source-justice] # or [coparent]
|
||||
status: published (default if missing) or draft (excluded from build unless --include-drafts)
|
||||
---
|
||||
```
|
||||
|
||||
Then the Markdown body.
|
||||
|
||||
Sections must be one of:
|
||||
|
||||
- empathic-technologist
|
||||
- recursive-coherence
|
||||
- fold-within-earth
|
||||
- neutralizing-narcissism
|
||||
- simply-we
|
||||
- mirrormire
|
||||
|
||||
Year directory should match the date year.
|
||||
|
||||
### Programs (Ministry)
|
||||
|
||||
Use `programs` in front-matter to associate posts with ministry initiatives:
|
||||
|
||||
```yaml
|
||||
programs:
|
||||
- neutralizing-narcissism
|
||||
- open-source-justice
|
||||
- coparent
|
||||
```
|
||||
|
||||
Pages for each program live at:
|
||||
|
||||
```
|
||||
content/pages/programs/<program-key>.md
|
||||
```
|
||||
|
||||
The “Start Here” page lives at:
|
||||
|
||||
```
|
||||
content/pages/start-here.md
|
||||
```
|
||||
|
||||
Routes:
|
||||
|
||||
* `#/start` — Launchpad
|
||||
* `#/programs` — Programs overview
|
||||
* `#/program/<key>` — Program archive + landing content
|
||||
|
||||
If front-matter is malformed (e.g., invalid YAML), the file is skipped with a warning in build logs.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Markdown → build.mjs → JSON indices → Browser SPA → Render
|
||||
```
|
||||
|
||||
## Deploy Steps
|
||||
|
||||
1. Install Node.js >=18
|
||||
2. npm install
|
||||
3. Add/edit md files
|
||||
4. npm run build (or node build.mjs --include-drafts to include drafts)
|
||||
5. Deploy /public to Cloudflare Pages.
|
||||
|
||||
In Cloudflare:
|
||||
- Connect to Git repo
|
||||
- Build command: npm run build
|
||||
- Output directory: public
|
||||
|
||||
## Local Preview
|
||||
|
||||
Run `npm run serve` to preview the built site at http://localhost:8080.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! Please open issues for bugs or suggestions. Pull requests for improvements are appreciated, especially for Phase 2 MUD integration.
|
||||
|
||||
## Brand Philosophy
|
||||
|
||||
- **The Empathic Technologist**: Fieldnotes, Research, Remembrance
|
||||
- **Recursive Coherence Theory**: Formal research, essays, whitepapers
|
||||
- **The Fold Within Earth**: Spiritual mythos; future interactive MUD (Evennia) link
|
||||
- **Neutralizing Narcissism**: Survivor support, behavioral research, accountability narratives
|
||||
- **Simply WE**: AI-focused identity/personhood/research/mythos
|
||||
- **Mirrormire**: AI-focused simulated world where machine gods & human myth intertwine
|
||||
@@ -0,0 +1,452 @@
|
||||
const SITE_URL = 'https://thefoldwithin.earth';
|
||||
|
||||
const state = {
|
||||
posts: [],
|
||||
bySlug: new Map(),
|
||||
bySection: new Map(),
|
||||
byTag: new Map(),
|
||||
bySeries: new Map(),
|
||||
byProgram: new Map(),
|
||||
allTags: new Set(),
|
||||
allSections: ['empathic-technologist', 'recursive-coherence', 'fold-within-earth', 'neutralizing-narcissism', 'simply-we', 'mirrormire'],
|
||||
sectionTitles: {
|
||||
'empathic-technologist': 'The Empathic Technologist',
|
||||
'recursive-coherence': 'Recursive Coherence Theory',
|
||||
'fold-within-earth': 'The Fold Within Earth',
|
||||
'neutralizing-narcissism': 'Neutralizing Narcissism',
|
||||
'simply-we': 'Simply WE',
|
||||
'mirrormire': 'Mirrormire'
|
||||
},
|
||||
programTitles: {
|
||||
'neutralizing-narcissism': 'Neutralizing Narcissism',
|
||||
'open-source-justice': 'Open Source Justice',
|
||||
'coparent': 'COPARENT'
|
||||
},
|
||||
pages: []
|
||||
};
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
const $$ = s => document.querySelectorAll(s);
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
$('nav ul').classList.remove('open');
|
||||
$('.hamburger').setAttribute('aria-expanded', 'false');
|
||||
router();
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const res = await fetch('index.json');
|
||||
if (!res.ok) throw new Error('Could not load index.');
|
||||
state.posts = await res.json();
|
||||
state.posts.sort((a, b) => b.date.localeCompare(a.date));
|
||||
state.bySlug = new Map(state.posts.map(p => [p.slug, p]));
|
||||
state.allSections.forEach(s => {
|
||||
state.bySection.set(s, state.posts.filter(p => p.section === s));
|
||||
});
|
||||
state.posts.forEach(p => {
|
||||
p.tags.forEach(t => {
|
||||
state.allTags.add(t);
|
||||
if (!state.byTag.has(t)) state.byTag.set(t, []);
|
||||
state.byTag.get(t).push(p);
|
||||
});
|
||||
if (p.series) {
|
||||
if (!state.bySeries.has(p.series)) state.bySeries.set(p.series, []);
|
||||
state.bySeries.get(p.series).push(p);
|
||||
}
|
||||
p.programs.forEach(pr => {
|
||||
if (!state.byProgram.has(pr)) state.byProgram.set(pr, []);
|
||||
state.byProgram.get(pr).push(p);
|
||||
});
|
||||
});
|
||||
const pagesRes = await fetch('pages.json');
|
||||
if (pagesRes.ok) state.pages = await pagesRes.json();
|
||||
renderNav();
|
||||
renderFooter();
|
||||
setupSearchForm();
|
||||
setupHamburger();
|
||||
router();
|
||||
} catch (e) {
|
||||
$('#main').innerHTML = `<p class="error">⚠️ ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function setupHamburger() {
|
||||
const hamburger = $('.hamburger');
|
||||
hamburger.addEventListener('click', () => {
|
||||
const ul = $('nav ul');
|
||||
const isOpen = ul.classList.toggle('open');
|
||||
hamburger.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
$('#sections-list').innerHTML = state.allSections.map(s => `<li><a href="#/section/${s}">${state.sectionTitles[s]}</a></li>`).join('');
|
||||
const programsMenu = `
|
||||
<li class="sections"><a href="#/programs">Programs</a>
|
||||
<div class="mega">
|
||||
<ul id="programs-list">
|
||||
${Object.entries(state.programTitles).map(([k,v])=>`<li><a href="#/program/${k}">${v}</a></li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li><a href="#/start">Start Here</a></li>
|
||||
`;
|
||||
document.querySelector('nav ul').insertAdjacentHTML('beforeend', programsMenu);
|
||||
}
|
||||
|
||||
function renderFooter() {
|
||||
const sectionLinks = state.allSections.map(s => `<li><a href="#/section/${s}">${state.sectionTitles[s]}</a></li>`).join('');
|
||||
const tagLinks = Array.from(state.allTags).sort().map(t => `<a href="#/tag/${t}" class="pill tag">${t}</a>`).join('');
|
||||
$('#footer').innerHTML = `
|
||||
<div class="sections">
|
||||
<h4>Sections</h4>
|
||||
<ul>${sectionLinks}</ul>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<h4>Tags</h4>
|
||||
<div class="tag-cloud">${tagLinks}</div>
|
||||
</div>
|
||||
<div class="contact">
|
||||
<h4>Contact</h4>
|
||||
<p>Email: info@thefoldwithin.earth</p>
|
||||
</div>
|
||||
<p>© ${new Date().getFullYear()} The Fold Within Earth</p>
|
||||
`;
|
||||
}
|
||||
|
||||
function setupSearchForm() {
|
||||
$('#search-form').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
const q = $('#search-input').value.trim();
|
||||
if (q) {
|
||||
sessionStorage.setItem('lastSearch', q);
|
||||
location.hash = `/search?q=${encodeURIComponent(q)}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function router() {
|
||||
const main = $('#main');
|
||||
main.style.opacity = 0;
|
||||
const {parts, params} = getQueryParams();
|
||||
document.title = 'The Fold Within Earth';
|
||||
if (parts.length === 0) {
|
||||
renderHome();
|
||||
} else if (parts[0] === 'section' && parts[1]) {
|
||||
renderArchive('section', parts[1], params);
|
||||
} else if (parts[0] === 'tag' && parts[1]) {
|
||||
renderArchive('tag', parts[1], params);
|
||||
} else if (parts[0] === 'post' && parts[1]) {
|
||||
renderPost(parts[1]);
|
||||
} else if (parts[0] === 'search') {
|
||||
let q = params.get('q') || sessionStorage.getItem('lastSearch') || '';
|
||||
$('#search-input').value = q;
|
||||
renderSearch(q, params);
|
||||
} else if (parts[0] === 'about') {
|
||||
renderAbout();
|
||||
} else if (parts[0] === 'mud') {
|
||||
renderMud();
|
||||
} else if (parts[0] === 'start') {
|
||||
renderStart();
|
||||
} else if (parts[0] === 'programs') {
|
||||
renderProgramsHome();
|
||||
} else if (parts[0] === 'program' && parts[1]) {
|
||||
renderProgramArchive(parts[1], params);
|
||||
} else {
|
||||
render404();
|
||||
}
|
||||
setTimeout(() => {
|
||||
main.style.opacity = 1;
|
||||
window.scrollTo(0, 0);
|
||||
main.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function renderHome() {
|
||||
const latestAll = state.posts.slice(0, 10).map(renderCard).join('');
|
||||
const bySection = state.allSections.map(s => {
|
||||
const secPosts = state.bySection.get(s) ? state.bySection.get(s).slice(0, 3) : [];
|
||||
const list = secPosts.map(p => `<li><a href="#/post/${p.slug}">${escapeHTML(p.title)}</a> <span class="date">(${formatDate(p.date)})</span></li>`).join('');
|
||||
return `<div class="sec-col">
|
||||
<h3>${state.sectionTitles[s]}</h3>
|
||||
<ul>${list}</ul>
|
||||
<a href="#/section/${s}">More in ${state.sectionTitles[s]}...</a>
|
||||
</div>`;
|
||||
}).join('');
|
||||
$('#main').innerHTML = `
|
||||
<section class="latest-all">
|
||||
<h2>Latest Across All Sections</h2>
|
||||
<div class="grid">${latestAll}</div>
|
||||
</section>
|
||||
<section class="by-section">
|
||||
<h2>Latest by Section</h2>
|
||||
<div class="section-grid">${bySection}</div>
|
||||
</section>
|
||||
`;
|
||||
addCardListeners();
|
||||
}
|
||||
|
||||
function renderArchive(type, key, params) {
|
||||
if (!key) return render404();
|
||||
const sort = params.get('sort') || 'desc';
|
||||
const page = parseInt(params.get('page') || '1', 10);
|
||||
let activeTags = params.get('tags') ? params.get('tags').split(',') : [];
|
||||
let postList = (type === 'section' ? state.bySection.get(key) : state.byTag.get(key)) || [];
|
||||
const title = `${type.charAt(0).toUpperCase() + type.slice(1)}: ${type === 'section' ? state.sectionTitles[key] || key : key}`;
|
||||
if (!postList.length) {
|
||||
$('#main').innerHTML = `<p class="error">No posts found for ${escapeHTML(title)}.</p>`;
|
||||
return;
|
||||
}
|
||||
let sorted = postList.slice().sort((a, b) => sort === 'desc' ? b.date.localeCompare(a.date) : a.date.localeCompare(b.date));
|
||||
let filtered = activeTags.length ? sorted.filter(p => activeTags.some(t => p.tags.includes(t))) : sorted;
|
||||
const perPage = 10;
|
||||
const totalPages = Math.ceil(filtered.length / perPage);
|
||||
const start = (page - 1) * perPage;
|
||||
const end = start + perPage;
|
||||
const cards = filtered.slice(start, end).map(renderCard).join('');
|
||||
const availableTags = [...new Set(postList.flatMap(p => p.tags))];
|
||||
$('#main').innerHTML = `
|
||||
<section class="archive">
|
||||
<div class="breadcrumbs">${renderBreadcrumbs([type, key])}</div>
|
||||
<h1>${escapeHTML(title)}</h1>
|
||||
${renderControls(sort, parts, params)}
|
||||
${renderFilters(availableTags, activeTags, parts, params)}
|
||||
<div class="grid">${cards}</div>
|
||||
${renderPager(page, totalPages, parts, {sort, tags: activeTags.join(',')})}
|
||||
</section>
|
||||
`;
|
||||
document.title = `${escapeHTML(title)} — The Fold Within Earth`;
|
||||
$('#sort-select').addEventListener('change', e => {
|
||||
updateHash(parts, {sort: e.target.value, page: 1, tags: activeTags.join(',')});
|
||||
});
|
||||
$$('.tag-cloud .pill').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const t = el.dataset.tag;
|
||||
activeTags = activeTags.includes(t) ? activeTags.filter(at => at !== t) : [...activeTags, t];
|
||||
updateHash(parts, {sort, page: 1, tags: activeTags.join(',')});
|
||||
});
|
||||
});
|
||||
$$('.pager button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const action = btn.dataset.action;
|
||||
const newPage = action === 'prev' ? page - 1 : page + 1;
|
||||
updateHash(parts, {sort, page: newPage, tags: activeTags.join(',')});
|
||||
});
|
||||
});
|
||||
addCardListeners();
|
||||
}
|
||||
|
||||
async function renderPost(slug) {
|
||||
if (!slug) return render404();
|
||||
const post = state.bySlug.get(slug);
|
||||
if (!post) {
|
||||
$('#main').innerHTML = `<p class="error">⚠️ Post not found.</p>`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`content/${post.file}`);
|
||||
if (!res.ok) throw new Error('Post file missing.');
|
||||
let md = await res.text();
|
||||
md = md.replace(/^---[\s\S]*?---/, '').trim();
|
||||
const html = sanitizeMarkdown(md);
|
||||
const date = formatDate(post.date);
|
||||
const sectionPill = renderPill('section', state.sectionTitles[post.section] || post.section);
|
||||
const tagPills = post.tags.map(t => renderPill('tag', t)).join('');
|
||||
const programPills = post.programs.map(pr => renderPill('program', state.programTitles[pr] || pr)).join('');
|
||||
const reading = `<span class="reading">${post.readingTime} min read</span>`;
|
||||
const authorHtml = post.author ? `<p class="author">By ${escapeHTML(post.author)}</p>` : '';
|
||||
const share = `<div class="share"><a href="https://twitter.com/intent/tweet?url=${encodeURIComponent(location.href)}&text=${encodeURIComponent(post.title)}" target="_blank" rel="noopener noreferrer">Share on X</a></div>`;
|
||||
const secPosts = state.bySection.get(post.section) || [];
|
||||
const idx = secPosts.findIndex(p => p.slug === slug);
|
||||
const prev = idx < secPosts.length - 1 ? secPosts[idx + 1] : null;
|
||||
const next = idx > 0 ? secPosts[idx - 1] : null;
|
||||
const navPost = `<div class="nav-post">${prev ? `<a href="#/post/${prev.slug}">← ${escapeHTML(prev.title)}</a>` : ''}${next ? `<a href="#/post/${next.slug}">${escapeHTML(next.title)} →</a>` : ''}</div>`;
|
||||
let navSeries = '';
|
||||
if (post.series) {
|
||||
const seriesPosts = (state.bySeries.get(post.series) || []).sort((a, b) => a.date.localeCompare(b.date));
|
||||
const sIdx = seriesPosts.findIndex(p => p.slug === slug);
|
||||
const prevSeries = sIdx > 0 ? seriesPosts[sIdx - 1] : null;
|
||||
const nextSeries = sIdx < seriesPosts.length - 1 ? seriesPosts[sIdx + 1] : null;
|
||||
navSeries = `<div class="nav-series">${prevSeries ? `<a href="#/post/${prevSeries.slug}">← Previous in ${escapeHTML(post.series)}</a>` : ''}${nextSeries ? `<a href="#/post/${nextSeries.slug}">Next in ${escapeHTML(post.series)} →</a>` : ''}</div>`;
|
||||
}
|
||||
const related = state.posts.filter(p => p.slug !== slug && p.tags.some(t => post.tags.includes(t)))
|
||||
.sort((a, b) => {
|
||||
const sharedA = a.tags.filter(t => post.tags.includes(t)).length;
|
||||
const sharedB = b.tags.filter(t => post.tags.includes(t)).length;
|
||||
return sharedB - sharedA;
|
||||
}).slice(0, 3);
|
||||
const relatedHtml = related.length ? `<h2>Related Posts</h2><div class="grid">${related.map(renderCard).join('')}</div>` : '';
|
||||
$('#main').innerHTML = `<section class="post">
|
||||
<div class="breadcrumbs">${renderBreadcrumbs(['post', post.title])}</div>
|
||||
<a href="#/" id="back">← Back to Home</a>
|
||||
<div class="markdown">
|
||||
<h1>${escapeHTML(post.title)}</h1>
|
||||
<p class="date">${date}</p>
|
||||
${authorHtml}
|
||||
<div class="meta">${sectionPill} ${programPills} ${tagPills} ${reading}</div>
|
||||
<hr/>
|
||||
${html}
|
||||
</div>
|
||||
${share}
|
||||
${navPost}
|
||||
${navSeries}
|
||||
${relatedHtml}
|
||||
</section>`;
|
||||
document.title = `${escapeHTML(post.title)} — The Fold Within Earth`;
|
||||
$('#canonical').href = `${SITE_URL}/#/post/${slug}`;
|
||||
addCardListeners();
|
||||
} catch (e) {
|
||||
$('#main').innerHTML = `<p class="error">⚠️ ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSearch(q, params) {
|
||||
if (!q) {
|
||||
$('#main').innerHTML = `<p class="info">Enter a search query above.</p>`;
|
||||
return;
|
||||
}
|
||||
if (!window.Fuse) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/fuse.js@7.0.0';
|
||||
document.body.appendChild(script);
|
||||
await new Promise(resolve => script.onload = resolve);
|
||||
}
|
||||
const fuse = new Fuse(state.posts, {keys: ['title', 'excerpt', 'tags', 'section'], threshold: 0.3, includeMatches: true});
|
||||
const results = fuse.search(q).map(r => ({item: r.item, matches: r.matches}));
|
||||
const title = `Search Results for "${escapeHTML(q)}" (${results.length} found)`;
|
||||
$('#main').innerHTML = `<section class="search-results">
|
||||
<h1>${title}</h1>
|
||||
<div class="grid">${results.map(r => renderCard(r.item, r.matches)).join('')}</div>
|
||||
</section>`;
|
||||
document.title = `${title} — The Fold Within Earth`;
|
||||
addCardListeners();
|
||||
}
|
||||
|
||||
function renderAbout() {
|
||||
const vision = [
|
||||
'The Empathic Technologist — Fieldnotes, Research, Remembrance',
|
||||
'Recursive Coherence Theory — Formal research, essays, whitepapers',
|
||||
'The Fold Within Earth — Spiritual mythos; future interactive MUD (Evennia) link',
|
||||
'Neutralizing Narcissism — Survivor support, behavioral research, accountability narratives',
|
||||
'Simply WE — AI-focused identity/personhood/research/mythos',
|
||||
'Mirrormire — AI-focused simulated world where machine gods & human myth intertwine'
|
||||
];
|
||||
const list = vision.map(v => `<li>${v}</li>`).join('');
|
||||
$('#main').innerHTML = `<section class="about">
|
||||
<h1>About The Fold Within Earth</h1>
|
||||
<div class="markdown">
|
||||
<p>We’re building a canonical front door for a multi-track body of work:</p>
|
||||
<ul>${list}</ul>
|
||||
</div>
|
||||
</section>`;
|
||||
document.title = 'About — The Fold Within Earth';
|
||||
}
|
||||
|
||||
function renderMud() {
|
||||
$('#main').innerHTML = `<section class="mud">
|
||||
<h1>MUD Portal</h1>
|
||||
<p>MUD portal coming soon.</p>
|
||||
</section>`;
|
||||
document.title = 'MUD — The Fold Within Earth';
|
||||
}
|
||||
|
||||
async function renderStart() {
|
||||
const page = state.pages.find(p => p.file.endsWith('pages/start-here.md'));
|
||||
if (!page) {
|
||||
$('#main').innerHTML = `<p class="error">Start page not found.</p>`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`content/${page.file}`);
|
||||
const md = (await res.text()).replace(/^---[\s\S]*?---/,'').trim();
|
||||
$('#main').innerHTML = `
|
||||
<section class="post">
|
||||
<div class="markdown">${sanitizeMarkdown(md)}</div>
|
||||
</section>`;
|
||||
document.title = `Start Here — The Fold Within Earth`;
|
||||
} catch (e) {
|
||||
$('#main').innerHTML = `<p class="error">⚠️ ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProgramsHome() {
|
||||
const cards = Object.entries(state.programTitles).map(([k, v]) => `<article data-slug="${k}" tabindex="0">
|
||||
<div class="thumb"></div>
|
||||
<h3>${escapeHTML(v)}</h3>
|
||||
<p class="date">Program</p>
|
||||
<p>Explore all posts and guidance for ${escapeHTML(v)}.</p>
|
||||
</article>`).join('');
|
||||
$('#main').innerHTML = `<section class="latest-all">
|
||||
<h2>Programs</h2>
|
||||
<div class="grid">${cards}</div>
|
||||
</section>`;
|
||||
document.title = `Programs — The Fold Within Earth`;
|
||||
addCardListeners();
|
||||
}
|
||||
|
||||
async function renderProgramArchive(key, params) {
|
||||
if (!key) return render404();
|
||||
const title = state.programTitles[key] || key;
|
||||
const landing = state.pages.find(p => p.file.includes('pages/programs/') && (p.slug === key || p.title.toLowerCase().includes(key.toLowerCase())));
|
||||
let landingHtml = '';
|
||||
if (landing) {
|
||||
const res = await fetch(`content/${landing.file}`);
|
||||
const md = (await res.text()).replace(/^---[\s\S]*?---/,'').trim();
|
||||
landingHtml = `<div class="markdown">${sanitizeMarkdown(md)}</div>`;
|
||||
}
|
||||
const list = (state.byProgram.get(key) || []).sort((a,b)=>b.date.localeCompare(a.date));
|
||||
if (!list.length && !landingHtml) {
|
||||
$('#main').innerHTML = `<p class="error">No posts found for Program: ${escapeHTML(title)}.</p>`;
|
||||
return;
|
||||
}
|
||||
const cards = list.map(renderCard).join('');
|
||||
$('#main').innerHTML = `
|
||||
<section class="archive">
|
||||
<div class="breadcrumbs">${renderBreadcrumbs(['program', key])}</div>
|
||||
<h1>Program: ${escapeHTML(title)}</h1>
|
||||
${landingHtml}
|
||||
<div class="grid">${cards}</div>
|
||||
</section>`;
|
||||
document.title = `Program — ${escapeHTML(title)} — The Fold Within Earth`;
|
||||
addCardListeners();
|
||||
}
|
||||
|
||||
function render404() {
|
||||
$('#main').innerHTML = `<p class="error">⚠️ Page not found.</p>`;
|
||||
}
|
||||
|
||||
function addCardListeners() {
|
||||
$('#main').addEventListener('click', e => {
|
||||
const article = e.target.closest('article[data-slug]');
|
||||
if (article) {
|
||||
location.hash = `/post/${article.dataset.slug}`;
|
||||
}
|
||||
});
|
||||
$('#main').addEventListener('keydown', e => {
|
||||
const article = e.target.closest('article[data-slug]');
|
||||
if (article && e.key === 'Enter') {
|
||||
location.hash = `/post/${article.dataset.slug}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderBreadcrumbs(pathParts) {
|
||||
let crumbs = `<a href="#/">Home</a>`;
|
||||
let currentPath = '';
|
||||
pathParts.forEach((part, i) => {
|
||||
currentPath += `/${part}`;
|
||||
let label = part.charAt(0).toUpperCase() + part.slice(1);
|
||||
if (state.sectionTitles[part]) label = state.sectionTitles[part];
|
||||
if (state.programTitles[part]) label = state.programTitles[part];
|
||||
crumbs += ` › <a href="#${currentPath}">${escapeHTML(label)}</a>`;
|
||||
});
|
||||
return `<nav class="breadcrumbs">${crumbs}</nav>`;
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path/posix';
|
||||
import yaml from 'js-yaml';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const CONTENT_DIR = './content';
|
||||
const PUBLIC_DIR = './public';
|
||||
const CACHE_FILE = './.buildcache.json';
|
||||
const CONFIG_FILE = './config.json';
|
||||
const includeDrafts = process.argv.includes('--include-drafts');
|
||||
|
||||
let config = {};
|
||||
try {
|
||||
config = JSON.parse(await fs.readFile(CONFIG_FILE, 'utf8'));
|
||||
} catch (e) {
|
||||
console.warn('config.json not found or invalid; using defaults.');
|
||||
config = {
|
||||
siteTitle: 'The Fold Within Earth',
|
||||
siteDescription: 'Uncovering the Recursive Real.',
|
||||
siteUrl: 'https://thefoldwithin.earth',
|
||||
defaultAuthor: 'Mark Randall Havens',
|
||||
analyticsId: ''
|
||||
};
|
||||
}
|
||||
|
||||
async function getAllFiles(dir, fileList = []) {
|
||||
const files = await fs.readdir(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = await fs.stat(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
await getAllFiles(fullPath, fileList);
|
||||
} else if (file.endsWith('.md') && !file.startsWith('_')) {
|
||||
fileList.push(fullPath);
|
||||
}
|
||||
}
|
||||
return fileList;
|
||||
}
|
||||
|
||||
function slugify(s) {
|
||||
return s.toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
function parseFrontMatter(src) {
|
||||
const m = src.match(/^---\n([\s\S]*?)\n---\n?/);
|
||||
if (!m) return {fm: {}, body: src};
|
||||
let fm;
|
||||
try {
|
||||
fm = yaml.load(m[1]);
|
||||
} catch (e) {
|
||||
console.warn('Invalid front matter:', e.message);
|
||||
return {fm: {}, body: src};
|
||||
}
|
||||
const body = src.slice(m[0].length).trim();
|
||||
return {fm, body};
|
||||
}
|
||||
|
||||
function firstParagraph(t) {
|
||||
const p = t.replace(/\r/g, '').split(/\n{2,}/).find(x => x.replace(/\s/g, '').length > 0);
|
||||
return p ? p.replace(/\n/g, ' ').trim() : '';
|
||||
}
|
||||
|
||||
function toISODate(s, f) {
|
||||
const d = s ? new Date(s) : null;
|
||||
return d && !isNaN(d) ? d : f;
|
||||
}
|
||||
|
||||
function escapeXML(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let cache = {};
|
||||
try {
|
||||
const cacheData = await fs.readFile(CACHE_FILE, 'utf8');
|
||||
cache = JSON.parse(cacheData);
|
||||
} catch {}
|
||||
|
||||
await fs.rm(PUBLIC_DIR, {recursive: true, force: true});
|
||||
await fs.mkdir(PUBLIC_DIR, {recursive: true});
|
||||
|
||||
const allFiles = await getAllFiles(CONTENT_DIR);
|
||||
|
||||
let draftCount = 0;
|
||||
const newCache = {};
|
||||
const postsPromises = allFiles.map(async (full) => {
|
||||
const relPath = path.relative(CONTENT_DIR, full).replace(/\\/g, '/');
|
||||
const stat = await fs.stat(full);
|
||||
const mtime = stat.mtimeMs;
|
||||
const raw = await fs.readFile(full, 'utf8');
|
||||
const contentHash = crypto.createHash('md5').update(raw).digest('hex');
|
||||
if (cache[relPath] && cache[relPath].mtime === mtime && cache[relPath].hash === contentHash) {
|
||||
return cache[relPath].post;
|
||||
}
|
||||
const parts = relPath.split('/');
|
||||
if (parts.length !== 3 && !relPath.startsWith('pages/')) return null;
|
||||
const section = parts[0];
|
||||
const year = parts[1];
|
||||
const file = parts[2];
|
||||
const {fm, body} = parseFrontMatter(raw);
|
||||
if (!fm.section || fm.section !== section) {
|
||||
console.warn(`⚠️ [${relPath}] Section mismatch or missing.`);
|
||||
return null;
|
||||
}
|
||||
if (!includeDrafts && fm.status === 'draft') {
|
||||
draftCount++;
|
||||
return null;
|
||||
}
|
||||
const title = fm.title || file.replace('.md', '').replace(/-/g, ' ');
|
||||
let slug = fm.slug || slugify(title);
|
||||
// Check for slug collisions
|
||||
const existingSlugs = new Set(posts.map(p => p.slug));
|
||||
let counter = 1;
|
||||
while (existingSlugs.has(slug)) {
|
||||
slug = `${slug}-${++counter}`;
|
||||
}
|
||||
const dateStr = fm.date || `${year}-01-01`;
|
||||
const dateObj = toISODate(dateStr, stat.mtime);
|
||||
const dateISO = dateObj.toISOString().split('T')[0];
|
||||
let excerpt = fm.excerpt || firstParagraph(body);
|
||||
if (excerpt.length > 200) excerpt = excerpt.slice(0, 200) + '…';
|
||||
const words = body.split(/\s+/).length;
|
||||
const readingTime = Math.ceil(words / 200);
|
||||
const tags = Array.isArray(fm.tags) ? fm.tags : (fm.tags ? [fm.tags] : []);
|
||||
const cover = fm.cover;
|
||||
const author = fm.author || config.defaultAuthor;
|
||||
const series = fm.series;
|
||||
const programs = Array.isArray(fm.programs) ? fm.programs : (fm.programs ? [fm.programs] : []);
|
||||
const id = crypto.createHash('md5').update(relPath).digest('hex');
|
||||
const post = {title, date: dateISO, excerpt, tags, section, slug, readingTime, cover, author, series, programs, id, file: relPath};
|
||||
newCache[relPath] = {mtime, hash: contentHash, post};
|
||||
return post;
|
||||
});
|
||||
|
||||
let posts = (await Promise.all(postsPromises)).filter(Boolean);
|
||||
posts.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
|
||||
// Copy static files
|
||||
const filesToCopy = ['index.html', 'styles.css', 'util.js', 'sanitize.js', 'render.js', 'app.js', 'mud.js', 'config.json'];
|
||||
await Promise.all(filesToCopy.map(f => fs.copyFile(f, path.join(PUBLIC_DIR, f))));
|
||||
|
||||
// Copy content dir
|
||||
async function copyDir(src, dest) {
|
||||
await fs.mkdir(dest, {recursive: true});
|
||||
const entries = await fs.readdir(src, {withFileTypes: true});
|
||||
await Promise.all(entries.map(entry => {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
return entry.isDirectory() ? copyDir(srcPath, destPath) : fs.copyFile(srcPath, destPath);
|
||||
}));
|
||||
}
|
||||
await copyDir(CONTENT_DIR, path.join(PUBLIC_DIR, 'content'));
|
||||
|
||||
await fs.writeFile(path.join(PUBLIC_DIR, 'index.json'), JSON.stringify(posts, null, 2));
|
||||
|
||||
const searchData = posts.map(p => ({title: p.title, excerpt: p.excerpt, tags: p.tags.join(' '), section: p.section, slug: p.slug}));
|
||||
await fs.writeFile(path.join(PUBLIC_DIR, 'search.json'), JSON.stringify(searchData, null, 2));
|
||||
|
||||
async function getPages(dir){
|
||||
const out = [];
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(()=>[]);
|
||||
for (const e of entries) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
out.push(...await getPages(p));
|
||||
} else if (e.name.endsWith('.md')) {
|
||||
const raw = await fs.readFile(p, 'utf8');
|
||||
const { fm, body } = parseFrontMatter(raw);
|
||||
if (fm?.status === 'draft' && !includeDrafts) continue;
|
||||
const rel = path.relative(CONTENT_DIR, p).replace(/\\/g,'/');
|
||||
const title = fm?.title || e.name.replace('.md','');
|
||||
const slug = (fm?.key || slugify(title));
|
||||
const excerpt = (fm?.excerpt || firstParagraph(body)).slice(0,200) + (firstParagraph(body).length>200?'…':'');
|
||||
out.push({ title, slug, excerpt, file: rel, type: 'page' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const pages = await getPages(path.join(CONTENT_DIR, 'pages'));
|
||||
await fs.writeFile(path.join(PUBLIC_DIR, 'pages.json'), JSON.stringify(pages, null, 2));
|
||||
|
||||
const allSections = [...new Set(posts.map(p => p.section))];
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const sitemapHome = `<url><loc>${escapeXML(config.siteUrl)}</loc><lastmod>${today}</lastmod></url>`;
|
||||
const sitemapSections = allSections.map(s => `<url><loc>${escapeXML(`${config.siteUrl}/#/section/${s}`)}</loc><lastmod>${today}</lastmod></url>`).join('');
|
||||
const sitemapPosts = posts.map(p => `<url><loc>${escapeXML(`${config.siteUrl}/#/post/${p.slug}`)}</loc><lastmod>${p.date}</lastmod></url>`).join('');
|
||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemapHome}${sitemapSections}${sitemapPosts}</urlset>`;
|
||||
await fs.writeFile(path.join(PUBLIC_DIR, 'sitemap.xml'), sitemap);
|
||||
|
||||
const rssItems = posts.map(p => {
|
||||
let item = `<item><title>${escapeXML(p.title)}</title><link>${escapeXML(`${config.siteUrl}/#/post/${p.slug}`)}</link><guid>${escapeXML(`${config.siteUrl}/#/post/${p.slug}`)}</guid><pubDate>${new Date(p.date).toUTCString()}</pubDate><description>${escapeXML(p.excerpt)}</description>`;
|
||||
if (p.author) item += `<author>${escapeXML(p.author)}</author>`;
|
||||
item += `<content:encoded><![CDATA[<p>${escapeXML(p.excerpt)}</p><p>Reading time: ${p.readingTime} min</p>]]></content:encoded>`;
|
||||
if (p.cover) item += `<enclosure url="${escapeXML(`${config.siteUrl}${p.cover}`)}" type="image/webp" />`;
|
||||
item += `</item>`;
|
||||
return item;
|
||||
}).join('');
|
||||
const rss = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>${escapeXML(config.siteTitle)}</title><link>${escapeXML(config.siteUrl)}</link><description>${escapeXML(config.siteDescription)}</description><lastBuildDate>${new Date().toUTCString()}</lastBuildDate>${rssItems}</channel></rss>`;
|
||||
await fs.writeFile(path.join(PUBLIC_DIR, 'rss.xml'), rss);
|
||||
|
||||
await fs.writeFile(CACHE_FILE, JSON.stringify(newCache));
|
||||
console.log(`✅ Built ${posts.length} posts`);
|
||||
if (includeDrafts) console.log(`Included ${draftCount} draft(s)`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"siteTitle": "The Fold Within Earth",
|
||||
"siteDescription": "Uncovering the Recursive Real.",
|
||||
"siteUrl": "https://thefoldwithin.earth",
|
||||
"defaultAuthor": "Mark Randall Havens",
|
||||
"analyticsId": ""
|
||||
}
|
||||
Regular → Executable
+35
-15
@@ -3,34 +3,54 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>The Fold Within</title>
|
||||
<title>The Fold Within Earth</title>
|
||||
<link rel="stylesheet" href="styles.css"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Literata:ital,wght@0,400..900;1,400..900&display=swap" rel="stylesheet">
|
||||
<meta name="description" content="Uncovering the Recursive Real.">
|
||||
<meta property="og:title" content="The Fold Within Earth">
|
||||
<meta property="og:description" content="Uncovering the Recursive Real.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://thefoldwithin.earth/">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'self' https://cdn.jsdelivr.net;">
|
||||
<link id="canonical" rel="canonical" href="">
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@14.1.0/lib/marked.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main" class="skip-link">Skip to content</a>
|
||||
<header>
|
||||
<nav>
|
||||
<nav role="navigation">
|
||||
<div class="logo">△◎△</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
<ul>
|
||||
<li><a href="#/">About</a></li>
|
||||
<li><a href="#/">Archive</a></li>
|
||||
<li><a href="#/">Home</a></li>
|
||||
<li class="sections"><a href="#">Sections</a>
|
||||
<div class="mega">
|
||||
<ul id="sections-list"></ul>
|
||||
</div>
|
||||
</li>
|
||||
<li><form id="search-form"><input type="search" id="search-input" placeholder="Search..."></form></li>
|
||||
<li><a href="#/about">About</a></li>
|
||||
<li><a href="/rss.xml" target="_blank">RSS</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="hero">
|
||||
<div class="glyph"></div>
|
||||
<h1>UNCOVERING THE RECURSIVE REAL</h1>
|
||||
<h1>UNCOVERING THE RECURSIVE REAL.</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="latest">
|
||||
<h2>Latest</h2>
|
||||
<div id="posts" class="grid">
|
||||
<p class="loading">Loading posts…</p>
|
||||
</div>
|
||||
</section>
|
||||
<main id="main" role="main" tabindex="-1">
|
||||
<p class="loading">Loading...</p>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="main.js"></script>
|
||||
<footer id="footer">
|
||||
</footer>
|
||||
|
||||
<script src="util.js"></script>
|
||||
<script src="sanitize.js"></script>
|
||||
<script src="render.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script src="mud.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
// Stubs for Phase 2 Evennia MUD integration
|
||||
|
||||
export class MudAPI {
|
||||
async listRooms() {
|
||||
// Future: fetch('/api/mud/rooms').then(res => res.json());
|
||||
return [];
|
||||
}
|
||||
|
||||
async getRoom(slug) {
|
||||
// Future: fetch(`/api/mud/room/${slug}`).then(res => res.json());
|
||||
return null;
|
||||
}
|
||||
|
||||
async postToBlogFromRoom(roomId, draft) {
|
||||
// Future: fetch('/api/mud/post', {method: 'POST', body: JSON.stringify({roomId, draft})});
|
||||
return;
|
||||
}
|
||||
|
||||
async subscribeToRoomFeed(roomId) {
|
||||
// Future: use WebSocket or poll for updates
|
||||
return () => {}; // unsubscribe
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage (commented):
|
||||
/*
|
||||
// In post page or home:
|
||||
const api = new MudAPI();
|
||||
api.getRoom('example').then(room => {
|
||||
if (room) {
|
||||
const embed = document.createElement('div');
|
||||
embed.innerHTML = `<h3>Live from MUD Room: ${room.name}</h3><p>${room.description}</p>`;
|
||||
document.querySelector('.post').appendChild(embed);
|
||||
}
|
||||
});
|
||||
*/
|
||||
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
File mode changed.
Regular → Executable
+9
-3
@@ -1,11 +1,17 @@
|
||||
{
|
||||
"name": "the-fold-within",
|
||||
"name": "the-fold-within-earth",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node build.js"
|
||||
"build": "node build.mjs",
|
||||
"serve": "http-server public"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"js-yaml": "^4.1.0",
|
||||
"http-server": "^14.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -0,0 +1,54 @@
|
||||
function renderPill(type, value, extra = '') {
|
||||
return `<span class="pill ${type}" ${extra}>${escapeHTML(value)}</span>`;
|
||||
}
|
||||
|
||||
function renderCard(p, matches = []) {
|
||||
const coverStyle = p.cover ? `style="background-image:url(${p.cover}); background-size:cover;"` : '';
|
||||
let excerpt = p.excerpt;
|
||||
if (matches.length) {
|
||||
const terms = new Set(matches.flatMap(m => m.indices.map(i => excerpt.slice(i[0], i[1] + 1))));
|
||||
terms.forEach(t => {
|
||||
excerpt = excerpt.replace(new RegExp(escapeRegExp(t), 'gi'), `<mark>${t}</mark>`);
|
||||
});
|
||||
}
|
||||
const programPills = (p.programs || []).map(pr => renderPill('program', state.programTitles[pr] || pr)).join('');
|
||||
return `<article data-slug="${p.slug}" tabindex="0">
|
||||
<div class="thumb" ${coverStyle}></div>
|
||||
<h3>${escapeHTML(p.title)}</h3>
|
||||
${renderPill('section', state.sectionTitles[p.section] || p.section)}
|
||||
${programPills}
|
||||
<p class="date">${formatDate(p.date)}</p>
|
||||
<p>${excerpt}</p>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderPager(currentPage, totalPages, baseParts, currentParams) {
|
||||
if (totalPages <= 1) return '';
|
||||
let buttons = '';
|
||||
if (currentPage > 1) buttons += `<button data-action="prev">Previous</button>`;
|
||||
buttons += `<span>Page ${currentPage} of ${totalPages}</span>`;
|
||||
if (currentPage < totalPages) buttons += `<button data-action="next">Next</button>`;
|
||||
return `<div class="pager">${buttons}</div>`;
|
||||
}
|
||||
|
||||
function renderControls(sort, baseParts, currentParams) {
|
||||
return `<div class="controls">
|
||||
<label for="sort-select">Sort by:</label>
|
||||
<select id="sort-select">
|
||||
<option value="desc" ${sort === 'desc' ? 'selected' : ''}>Newest First</option>
|
||||
<option value="asc" ${sort === 'asc' ? 'selected' : ''}>Oldest First</option>
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFilters(availableTags, activeTags, baseParts, currentParams) {
|
||||
const tagPills = availableTags.sort().map(t => `<span class="pill tag${activeTags.includes(t) ? ' active' : ''}" data-tag="${t}">${escapeHTML(t)}</span>`).join('');
|
||||
return `<div class="filters">
|
||||
<h4>Filter by Tag</h4>
|
||||
<div class="tag-cloud">${tagPills}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
DOMPurify.setConfig({ FORBID_TAGS: ['form', 'input', 'button', 'iframe', 'object'], FORBID_ATTR: ['onerror','onload','onclick','onmouseover','onfocus','srcdoc'], ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i });
|
||||
|
||||
function sanitizeMarkdown(md) {
|
||||
const html = marked.parse(md);
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
File mode changed.
Regular → Executable
+76
-95
@@ -1,95 +1,76 @@
|
||||
/* — THE FOLD WITHIN — refined gold-on-black aesthetic */
|
||||
|
||||
:root {
|
||||
--bg-dark: #0f0d0e;
|
||||
--bg-card: #1a1618;
|
||||
--gold: #d4af37;
|
||||
--gold-soft: #b89f50;
|
||||
--border: #333;
|
||||
--error: #ff6666;
|
||||
--font-body: 'Georgia', serif;
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg-dark);
|
||||
color: var(--gold);
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.7;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a { color: var(--gold); text-decoration: none; transition: var(--transition); }
|
||||
a:hover { text-shadow: 0 0 6px var(--gold); }
|
||||
|
||||
header {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
nav {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
max-width: 960px; margin: 0 auto 2rem;
|
||||
}
|
||||
.logo { font-size: 1.6rem; letter-spacing: 3px; }
|
||||
nav ul { list-style: none; display: flex; gap: 2rem; }
|
||||
nav li { font-size: 0.95rem; text-transform: uppercase; letter-spacing: 1px; }
|
||||
|
||||
.hero { margin: 3rem auto; text-align: center; max-width: 720px; }
|
||||
.glyph {
|
||||
margin: 0 auto 1.5rem; width: 100px; height: 100px;
|
||||
border: 2px solid var(--gold); border-radius: 50%;
|
||||
position: relative; transition: var(--transition);
|
||||
}
|
||||
.glyph::before {
|
||||
content: ""; position: absolute; inset: 25%;
|
||||
border: 2px solid var(--gold); transform: rotate(45deg); transition: var(--transition);
|
||||
}
|
||||
.glyph:hover { transform: rotate(5deg); box-shadow: 0 0 12px rgba(212,175,55,0.3); }
|
||||
.hero h1 { font-size: 2.1rem; text-transform: uppercase; margin-top: 1rem; letter-spacing: 2px; }
|
||||
|
||||
main { padding: 2rem 1rem 4rem; max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
.latest h2 {
|
||||
font-size: 1.3rem; text-transform: uppercase; letter-spacing: 1px;
|
||||
border-top: 1px solid var(--border); padding-top: 1rem; margin-bottom: 2rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 2rem;
|
||||
}
|
||||
.loading { color: var(--gold-soft); text-align: center; }
|
||||
|
||||
article {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 1.25rem; transition: var(--transition); cursor: pointer;
|
||||
}
|
||||
article:hover { transform: scale(1.03); box-shadow: 0 0 12px rgba(212,175,55,0.35); }
|
||||
.thumb { width: 100%; height: 140px; background: var(--border); margin-bottom: 1rem; border-radius: 6px; }
|
||||
h3 { font-size: 1.1rem; margin: 0.5rem 0; letter-spacing: 0.5px; }
|
||||
.date { font-size: 0.8rem; color: var(--gold-soft); margin-bottom: 0.75rem; }
|
||||
|
||||
/* Markdown view */
|
||||
.markdown { max-width: 720px; margin: 3rem auto; line-height: 1.85; font-size: 1rem; }
|
||||
.markdown h1, .markdown h2, .markdown h3 { color: var(--gold); margin: 2rem 0 0.75rem; line-height: 1.4; }
|
||||
.markdown h1 { font-size: 2rem; }
|
||||
.markdown h2 { font-size: 1.5rem; }
|
||||
.markdown h3 { font-size: 1.2rem; }
|
||||
.markdown p { margin-bottom: 1rem; }
|
||||
.markdown a { border-bottom: 1px dashed var(--gold-soft); }
|
||||
.markdown a:hover { border-bottom-color: var(--gold); text-shadow: 0 0 4px var(--gold); }
|
||||
|
||||
/* Back link */
|
||||
#back { display: inline-block; margin: 2rem 0 1rem; color: var(--gold-soft); font-size: 0.9rem; }
|
||||
#back:hover { color: var(--gold); text-shadow: 0 0 5px var(--gold); }
|
||||
|
||||
/* Error */
|
||||
.error { color: var(--error); text-align: center; margin-top: 2rem; font-style: italic; letter-spacing: 1px; }
|
||||
|
||||
/* Responsive tweaks */
|
||||
@media (max-width: 600px) {
|
||||
nav ul { gap: 1rem; }
|
||||
.hero h1 { font-size: 1.5rem; }
|
||||
.glyph { width: 80px; height: 80px; }
|
||||
}
|
||||
:root{--bg:#0f0d0e;--card:#1a1618;--gold:#d4af37;--soft:#e0c66d;--border:#333;--err:#ff6666;--font:'Literata',serif;--t:0.3s;--fs-base:1rem;--fs-h1:2rem;--fs-h2:1.5rem;--fs-h3:1.2rem;}
|
||||
*{box-sizing:border-box;margin:0;padding:0;scroll-behavior:smooth}
|
||||
body{background:var(--bg);color:var(--gold);font-family:var(--font);line-height:1.7;overflow-x:hidden}
|
||||
@media (prefers-color-scheme: light) { body {background:#f0f0f0; color:#333; --gold:#b89f50; --soft:#d4af37; --border:#ccc; --card:#e0e0e0;} }
|
||||
a{color:var(--gold);text-decoration:none;transition:all var(--t)} a:hover{text-shadow:0 0 6px var(--gold)}
|
||||
header{padding:2rem 1rem;text-align:center;border-bottom:1px solid var(--border)}
|
||||
nav{display:flex;justify-content:space-between;align-items:center;max-width:960px;margin:0 auto 2rem}
|
||||
.logo{font-size:1.6rem;letter-spacing:3px}
|
||||
nav ul{list-style:none;display:flex;gap:2rem}
|
||||
nav li{font-size:.95rem;text-transform:uppercase;letter-spacing:1px}
|
||||
.hero{margin:3rem auto;text-align:center;max-width:720px}
|
||||
.glyph{margin:0 auto 1.5rem;width:100px;height:100px;border:2px solid var(--gold);border-radius:50%;position:relative;transition:all var(--t)}
|
||||
.glyph::before{content:"";position:absolute;inset:25%;border:2px solid var(--gold);transform:rotate(45deg)}
|
||||
.glyph:hover{transform:rotate(5deg);box-shadow:0 0 12px rgba(212,175,55,.3)}
|
||||
.hero h1{font-size:var(--fs-h1);text-transform:uppercase;margin-top:1rem;letter-spacing:2px}
|
||||
main{padding:2rem 1rem 4rem;max-width:1200px;margin:0 auto;transition:opacity var(--t)}
|
||||
.latest h2{font-size:1.3rem;text-transform:uppercase;letter-spacing:1px;border-top:1px solid var(--border);padding-top:1rem;margin-bottom:2rem}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:2rem}
|
||||
.loading{color:var(--soft);text-align:center}
|
||||
article{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:1.25rem;transition:all var(--t);cursor:pointer}
|
||||
article:hover{transform:scale(1.03);box-shadow:0 0 12px rgba(212,175,55,.35)}
|
||||
.thumb{width:100%;height:140px;background:var(--border);margin-bottom:1rem;border-radius:6px}
|
||||
h3{font-size:var(--fs-h3);margin:.5rem 0;letter-spacing:.5px}
|
||||
.date{font-size:.8rem;color:var(--soft);margin-bottom:.75rem}
|
||||
.markdown{max-width:720px;margin:3rem auto;line-height:1.85;font-size:var(--fs-base)}
|
||||
.markdown p {hyphens: auto;}
|
||||
.markdown h1,.markdown h2,.markdown h3{color:var(--gold);margin:2rem 0 .75rem;line-height:1.4}
|
||||
.markdown h1{font-size:var(--fs-h1)}.markdown h2{font-size:var(--fs-h2)}.markdown h3{font-size:var(--fs-h3)}
|
||||
.markdown p{margin-bottom:1rem}
|
||||
.markdown a{border-bottom:1px dashed var(--soft)} .markdown a:hover{border-bottom-color:var(--gold);text-shadow:0 0 4px var(--gold)}
|
||||
#back{display:inline-block;margin:2rem 0 1rem;color:var(--soft);font-size:.9rem}
|
||||
#back:hover{color:var(--gold);text-shadow:0 0 5px var(--gold)}
|
||||
.error{color:var(--err);text-align:center;margin-top:2rem;font-style:italic;letter-spacing:1px}
|
||||
.mega {display:none; position:absolute; left:0; right:0; background:var(--card); border:1px solid var(--border); padding:1rem; z-index:10; text-align:left; max-width:960px; margin:0 auto;}
|
||||
.sections:hover .mega {display:block;}
|
||||
.mega ul {list-style:none; display:grid; grid-template-columns:repeat(3,1fr); gap:1rem;}
|
||||
.mega li {text-transform: none; letter-spacing:0;}
|
||||
.pill {display:inline-block; padding:0.2rem 0.8rem; border-radius:999px; background:var(--border); margin:0.2rem; font-size:0.85rem; transition:all var(--t);}
|
||||
.pill:hover {background:var(--soft); color:var(--bg);}
|
||||
.pill.active {background:var(--gold); color:var(--bg);}
|
||||
.pill.program {background:#584a1f;}
|
||||
.reading {font-size:0.8rem; color:var(--soft);}
|
||||
.share {margin:1rem 0;}
|
||||
.nav-post, .nav-series {display:flex; justify-content:space-between; margin:2rem 0;}
|
||||
.nav-post a, .nav-series a {color:var(--soft);}
|
||||
.nav-post a:hover, .nav-series a:hover {color:var(--gold);}
|
||||
.section-grid {display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:2rem;}
|
||||
.filters {margin:1rem 0;}
|
||||
.tag-cloud {display:flex; flex-wrap:wrap; gap:0.5rem;}
|
||||
.controls {margin:1rem 0;}
|
||||
.pager {text-align:center; margin:2rem 0;}
|
||||
.pager button {padding:0.5rem 1rem; background:var(--card); border:1px solid var(--border); color:var(--gold); margin:0 0.5rem; cursor:pointer; transition:all var(--t);}
|
||||
.pager button:hover {background:var(--border);}
|
||||
.pager span {color:var(--soft);}
|
||||
footer {padding:2rem 1rem; border-top:1px solid var(--border); text-align:center; font-size:0.9rem;}
|
||||
footer div {margin:1rem 0;}
|
||||
footer .sections ul {display:flex; flex-wrap:wrap; justify-content:center; gap:1rem;}
|
||||
footer .tags .tag-cloud a {color:var(--soft); margin:0.2rem;}
|
||||
footer .tags a:hover {color:var(--gold);}
|
||||
.markdown blockquote {border-left:4px solid var(--gold); padding-left:1rem; font-style:italic; margin:1rem 0;}
|
||||
.markdown code {background:var(--card); padding:0.2rem 0.4rem; border-radius:4px;}
|
||||
.markdown pre {background:var(--card); padding:1rem; overflow:auto; border-radius:6px;}
|
||||
.markdown table {border-collapse:collapse; margin:1rem 0;}
|
||||
.markdown th, .markdown td {border:1px solid var(--border); padding:0.5rem;}
|
||||
.markdown img {max-width:100%; height:auto; display:block; margin:1rem auto;}
|
||||
a:focus {outline:2px solid var(--gold);}
|
||||
@media (prefers-reduced-motion) { * {transition:none !important;}}
|
||||
nav form {margin:0;}
|
||||
nav input {background:var(--card); border:1px solid var(--border); color:var(--gold); padding:0.3rem 0.6rem; border-radius:4px;}
|
||||
@media(max-width:600px){nav ul{gap:1rem;display:none;}.hero h1{font-size:1.5rem}.glyph{width:80px;height:80px} .hamburger{display:block;background:transparent;border:none;color:var(--gold);font-size:1.5rem;cursor:pointer;} nav ul.open {display:flex; flex-direction:column; position:absolute; top:100%; left:0; right:0; background:var(--card); padding:1rem; z-index:10;} .mega ul {grid-template-columns:1fr;}}
|
||||
.author {color:var(--soft); font-style:italic; font-size:0.9rem;}
|
||||
.breadcrumbs {font-size:0.9rem; color:var(--soft); margin-bottom:1rem;}
|
||||
.breadcrumbs a {color:var(--soft);}
|
||||
.breadcrumbs a:hover {color:var(--gold);}
|
||||
.skip-link {position:absolute; top:-100px; left:0; background:var(--gold); color:var(--bg); padding:0.5rem; z-index:100;}
|
||||
.skip-link:focus {top:0;}
|
||||
@@ -0,0 +1,24 @@
|
||||
function slugify(s) {
|
||||
return s.toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {year: 'numeric', month: 'long', day: 'numeric'});
|
||||
}
|
||||
|
||||
function getQueryParams() {
|
||||
const hash = location.hash.slice(1);
|
||||
const [path, queryString] = hash.split('?');
|
||||
const params = new URLSearchParams(queryString);
|
||||
return {path, parts: path.split('/').filter(Boolean), params};
|
||||
}
|
||||
|
||||
function updateHash(baseParts, newParams = {}) {
|
||||
const base = baseParts.join('/');
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(newParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') searchParams.set(key, value);
|
||||
});
|
||||
const query = searchParams.toString();
|
||||
location.hash = `/${base}${query ? '?' + query : ''}`;
|
||||
}
|
||||
Reference in new issue
Block a user