initial commit

This commit is contained in:
jorts 2025-08-06 13:00:32 +01:00
commit fe781b9475
13 changed files with 1496 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__/

420
docs/assets/extra.css Normal file
View File

@ -0,0 +1,420 @@
.meganote { font-style: italic; } /* example utility class */
#wiki-multipane {
display: grid;
grid-template-columns: minmax(25rem, 1fr) minmax(20rem, 1fr); /* main (min 600px) | source (400px) */
grid-template-rows: 1fr;
gap: 1rem;
}
.megapage-grid {
/* Remove grid properties - let Material handle its normal layout */
display: block;
}
/* Source pane positioning - fixed relative to viewport but below header */
#wiki-source-pane {
border-left: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.5rem 0 0 0.5rem;
position: fixed;
top: 2.5rem; /* Below Material's header */
right: 0rem;
width: calc(40% - 1.5rem); /* Responsive width minus gap */
min-width: 20rem;
height: calc(100vh - 2.5rem); /* Full height minus header and small margin */
padding: 0;
background: var(--md-default-bg-color);
z-index: 100;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Source pane header with controls */
.source-pane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0rem;
border-bottom: 1px solid var(--md-default-fg-color--lightest);
background: var(--md-default-bg-color);
flex-shrink: 0;
position: absolute;
width: 100%;
min-width: 20rem;
z-index: 1000;
opacity: 1.0;
}
.source-pane-links {
display: flex;
gap: 0;
align-items: center;
}
.source-pane-controls {
display: flex;
gap: 0.25rem;
align-items: center;
}
.source-control-btn {
background: var(--md-default-accent-fg-color);
color: var(--md-default-fg-color);
/* border: 1px solid var(--md-default-fg-color--light); */
font-size: 12px;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
transition: background-color 0.2s;
}
.source-control-btn:hover {
background: var(--md-default-fg-color--lightest);
text-decoration: none;
}
.source-control-btn .icon {
font-size: 10px;
}
#source-frame {
width: 100%;
height: 100%;
flex: 1;
border: none;
border-radius: 0;
}
/* Adjust main content margin when source pane is visible */
#wiki-multipane:has(#wiki-source-pane[style*="block"]) #wiki-main-pane {
margin-right: 1rem;
}
/* Close button styling */
.close-btn {
background: var(--md-accent-fg-color);
color: white;
border: none;
font-size: 16px;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
line-height: 1;
font-weight: bold;
}
.close-btn:hover {
opacity: 0.8;
}
/* Hide the source pane when there's not enough space for both main (600px) + source (400px) + gap */
@media (max-width: 64rem) { /* 600px + 400px + gap ≈ 1024px */
#wiki-multipane { grid-template-columns: 1fr; }
#wiki-source-pane { display: none !important; }
#wiki-main-pane { margin-right: 0 !important; }
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
#wiki-source-pane {
border-left-color: var(--md-default-fg-color--lightest);
}
}
.md-footer {
display: none;
}ove source parameter from hash
const currentHash = window.location.hash;
const newHash = currentHash
.replace(/[#&]source=[^&]*/, '')
.replace(/^&/, '#')
.replace(/&$/, '');
if (newHash !== currentHash) {
history.pushState(null, '', newHash || '#');
}
}
async function discoverAndPreloadSources() {
console.log('🔍 Discovering available source pages...');
// Find all source-link elements on the page
const sourceLinks = document.querySelectorAll('a.source-link');
const discoveryPromises = [];
for (const link of sourceLinks) {
const originalUrl = link.href;
const localPath = `/sources/${await slug(originalUrl)}`;
// Check if local version exists (do this once during preload)
discoveryPromises.push(
fetch(localPath, { method: 'HEAD' })
.then(response => {
if (response.ok) {
sourceCache.registerSource(originalUrl, localPath);
console.log(`📍 Registered: ${originalUrl} -> ${localPath}`);
}
})
.catch(() => {
// Local version doesn't exist, that's fine
})
);
}
await Promise.all(discoveryPromises);
console.log(` Discovery complete. Found ${sourceCache.sourceRegistry.size} local sources.`);
}
async function preloadSource(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const content = await response.text();
sourceCache.set(url, content);
return content;
} catch (error) {
console.warn(`Failed to preload source: ${url}`, error);
return null;
}
}
async function preloadAllSources() {
if (sourceCache.preloadComplete) return;
await discoverAndPreloadSources();
// Preload a few of the most recently discovered sources
const localSources = Array.from(sourceCache.sourceRegistry.values()).slice(0, 10); // Limit to first 10
if (localSources.length > 0) {
console.log(`📦 Preloading ${localSources.length} source pages...`);
const preloadPromises = localSources.map(async (localPath) => {
if (!sourceCache.has(localPath)) {
await preloadSource(localPath);
}
});
await Promise.all(preloadPromises);
console.log(`🚀 Preloaded ${sourceCache.size()} source pages`);
}
sourceCache.preloadComplete = true;
}
function createDataUrl(htmlContent) {
// Create a data URL for the HTML content
const blob = new Blob([htmlContent], { type: 'text/html' });
return URL.createObjectURL(blob);
}
function updateSourceControls() {
const originalBtn = document.querySelector('.source-control-original');
const archiveBtn = document.querySelector('.source-control-archive');
if (!currentSourceUrl || !originalBtn || !archiveBtn) return;
// Update original button
originalBtn.href = currentSourceUrl;
// Update archive button
if (sourceCache.isAvailableLocally(currentSourceUrl)) {
const localPath = sourceCache.getLocalPath(currentSourceUrl);
archiveBtn.href = localPath;
archiveBtn.style.display = 'inline-flex';
} else {
archiveBtn.style.display = 'none';
}
}
async function showSourcePane(sourceUrl) {
const pane = document.getElementById('wiki-source-pane');
const frame = document.getElementById('source-frame');
if (!hasSpaceForSourcePane()) {
window.open(sourceUrl, '_blank');
return;
}
// Update current source URL for header controls
currentSourceUrl = sourceUrl;
// Show pane first for immediate feedback
if (pane) pane.style.display = 'block';
// Update header controls
updateSourceControls();
// Determine target without any network requests
let target = sourceUrl;
if (sourceCache.isAvailableLocally(sourceUrl)) {
target = sourceCache.getLocalPath(sourceUrl);
}
// Check if we have cached content (instant load)
if (sourceCache.has(target)) {
const cachedContent = sourceCache.get(target);
const dataUrl = createDataUrl(cachedContent);
if (frame) frame.src = dataUrl;
console.log(` Instant load from cache: ${target}`);
} else {
// Only make network request if not cached yet
if (target.startsWith('/sources/')) {
console.log(`📥 Loading and caching: ${target}`);
const content = await preloadSource(target);
if (content && frame) {
const dataUrl = createDataUrl(content);
frame.src = dataUrl;
} else if (frame) {
frame.src = target; // Fallback
}
} else {
// External URL - load directly
if (frame) frame.src = target;
}
}
// Update URL
setSourceUrlInHash(sourceUrl);
}
function hideSourcePane() {
const pane = document.getElementById('wiki-source-pane');
if (pane) {
pane.style.display = 'none';
currentSourceUrl = null;
}
removeSourceFromHash();
}
function createSourcePaneHeader() {
const header = document.createElement('div');
header.className = 'source-pane-header';
const title = document.createElement('span');
title.textContent = 'Source';
title.style.fontWeight = 'bold';
title.style.fontSize = '14px';
const controls = document.createElement('div');
controls.className = 'source-pane-controls';
// Original button
const originalBtn = document.createElement('a');
originalBtn.className = 'source-control-btn source-control-original';
originalBtn.target = '_blank';
originalBtn.innerHTML = '<span class="icon">↗</span>original';
// Archive button
const archiveBtn = document.createElement('a');
archiveBtn.className = 'source-control-btn source-control-archive';
archiveBtn.target = '_blank';
archiveBtn.innerHTML = '<span class="icon">↗</span>archive';
// Close button
const closeBtn = document.createElement('button');
closeBtn.className = 'close-btn';
closeBtn.innerHTML = '×';
closeBtn.addEventListener('click', hideSourcePane);
controls.appendChild(originalBtn);
controls.appendChild(archiveBtn);
controls.appendChild(closeBtn);
header.appendChild(title);
header.appendChild(controls);
return header;
}
/* ---------- main ----------------------------------------------------- */
document.addEventListener('DOMContentLoaded', async () => {
const pane = document.getElementById('wiki-source-pane'); // wrapper <aside>
const frame = document.getElementById('source-frame'); // iframe inside it
if (pane) pane.style.display = 'none'; // start hidden
// Add header with controls to source pane
if (pane && !pane.querySelector('.source-pane-header')) {
const header = createSourcePaneHeader();
pane.insertBefore(header, frame);
}
// Preload sources in the background
setTimeout(() => {
preloadAllSources().catch(console.error);
}, 1000); // Wait 1 second after page load
// Check URL hash on page load
const initialSourceUrl = getSourceUrlFromHash();
if (initialSourceUrl && hasSpaceForSourcePane()) {
showSourcePane(initialSourceUrl);
}
document.body.addEventListener('click', async ev => {
const link = ev.target.closest('a.source-link');
if (!link) return;
ev.preventDefault();
await showSourcePane(link.href);
});
/* Handle window resize - hide source pane if not enough space */
window.addEventListener('resize', () => {
if (pane && !hasSpaceForSourcePane()) {
hideSourcePane();
}
});
/* Handle browser back/forward buttons */
window.addEventListener('popstate', () => {
const sourceUrl = getSourceUrlFromHash();
if (sourceUrl && hasSpaceForSourcePane()) {
showSourcePane(sourceUrl);
} else {
hideSourcePane();
}
});
// Add cache management to window for debugging
if (typeof window !== 'undefined') {
window.sourceCache = sourceCache;
window.clearSourceCache = () => {
sourceCache.clear();
console.log('🗑️ Source cache cleared');
};
window.preloadAllSources = preloadAllSources;
}
});
```
Key features of the new header:
1. **Header Bar**: Added a clean header with title "Source" and controls on the right
2. **Original Button**: ` original` - Opens the original URL in a new tab
3. **Archive Button**: ` archive` - Opens the local archived version in a new tab (only shows if archive exists)
4. **Close Button**: `×` - Closes the source pane (red accent color)
5. **Dynamic Updates**: Buttons update their links when you switch between different sources
6. **Responsive Design**: Header adapts to the pane width and theme colors
7. **Flexbox Layout**: Source pane now uses flexbox with header at top and iframe filling remaining space
The header will show:
- `[] original [] archive ... [×]` when an archive exists locally
- `[] original ... [×]` when only the original URL is available
The styling matches Material's design system and works in both light and dark modes!

539
docs/assets/extra.js Normal file
View File

@ -0,0 +1,539 @@
/* assets/extra.js
*
* Shows #source-pane only when we actually have a source to display.
* Opens source externally when there's not enough space for the iframe.
* Makes source pane state stateful via URL hash.
* Preloads and caches all source pages for instant loading.
*/
/* ---------- cache system -------------------------------------------- */
class SourceCache {
constructor() {
this.cache = new Map();
this.sourceRegistry = new Map(); // Maps original URLs to local paths
this.preloadComplete = false;
this.cacheTimeout = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
}
get(url) {
const cached = this.cache.get(url);
if (cached) {
// Check if cache entry has expired
if (Date.now() - cached.timestamp > this.cacheTimeout) {
console.log(`⏰ Cache expired for: ${url}`);
this.cache.delete(url);
return null;
}
// Move to end (LRU) and update access time
this.cache.delete(url);
cached.lastAccessed = Date.now();
this.cache.set(url, cached);
return cached.content;
}
return null;
}
set(url, content) {
const now = Date.now();
this.cache.set(url, {
content: content,
timestamp: now,
lastAccessed: now
});
}
has(url) {
const cached = this.cache.get(url);
if (cached) {
// Check if expired
if (Date.now() - cached.timestamp > this.cacheTimeout) {
this.cache.delete(url);
return false;
}
return true;
}
return false;
}
getLocalPath(originalUrl) {
return this.sourceRegistry.get(originalUrl);
}
registerSource(originalUrl, localPath) {
this.sourceRegistry.set(originalUrl, localPath);
}
isAvailableLocally(originalUrl) {
return this.sourceRegistry.has(originalUrl);
}
clear() {
this.cache.clear();
this.sourceRegistry.clear();
}
size() {
return this.cache.size;
}
setCacheTimeout(hours) {
this.cacheTimeout = hours * 60 * 60 * 1000;
console.log(`🕐 Cache timeout set to ${hours} hours`);
}
cleanExpired() {
const now = Date.now();
let cleaned = 0;
for (const [url, cached] of this.cache.entries()) {
if (now - cached.timestamp > this.cacheTimeout) {
this.cache.delete(url);
cleaned++;
}
}
if (cleaned > 0) {
console.log(`🧹 Cleaned ${cleaned} expired cache entries`);
}
return cleaned;
}
getCacheStats() {
const now = Date.now();
const stats = {
total: this.cache.size,
fresh: 0,
stale: 0,
avgAge: 0
};
let totalAge = 0;
for (const cached of this.cache.values()) {
const age = now - cached.timestamp;
totalAge += age;
if (age > this.cacheTimeout) {
stats.stale++;
} else {
stats.fresh++;
}
}
stats.avgAge = stats.total > 0 ? Math.round(totalAge / stats.total / 1000 / 60) : 0; // minutes
return stats;
}
}
const sourceCache = new SourceCache();
let currentSourceUrl = null; // Track current source for header buttons
/* ---------- helpers -------------------------------------------------- */
async function sha1hex(str) {
const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(str));
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function slug(url) {
const u = new URL(url);
const host = u.host.toLowerCase().replace(/\W+/g, '-').replace(/^-+|-+$/g, '');
const path = (u.pathname.replace(/^\/|\/$/g, '').toLowerCase().replace(/\W+/g, '-').slice(0, 60)) || 'root';
const hash = (await sha1hex(url)).slice(0, 10);
return `${host}__${path}__${hash}.html`;
}
function hasSpaceForSourcePane() {
// Check if screen is wide enough for main content (600px) + source pane (400px) + gap
return window.innerWidth >= 1024; // 64rem ≈ 1024px
}
function getSourceUrlFromHash() {
// Extract source URL from hash like #source=https://example.com
const hash = window.location.hash;
const match = hash.match(/[#&]source=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
function setSourceUrlInHash(url) {
// Update URL hash with source parameter
const currentHash = window.location.hash;
const newHash = currentHash.includes('source=')
? currentHash.replace(/([#&])source=[^&]*/, `$1source=${encodeURIComponent(url)}`)
: (currentHash ? `${currentHash}&source=${encodeURIComponent(url)}` : `#source=${encodeURIComponent(url)}`);
history.pushState(null, '', newHash);
}
function removeSourceFromHash() {
// Remove source parameter from hash
const currentHash = window.location.hash;
const newHash = currentHash
.replace(/[#&]source=[^&]*/, '')
.replace(/^&/, '#')
.replace(/&$/, '');
if (newHash !== currentHash) {
history.pushState(null, '', newHash || '#');
}
}
async function discoverAndPreloadSources() {
console.log('🔍 Discovering available source pages...');
// Find all source-link elements on the page
const sourceLinks = document.querySelectorAll('a.source-link');
const discoveryPromises = [];
for (const link of sourceLinks) {
const originalUrl = link.href;
const localPath = `/sources/${await slug(originalUrl)}`;
// Check if local version exists (do this once during preload)
discoveryPromises.push(
fetch(localPath, { method: 'HEAD' })
.then(response => {
if (response.ok) {
sourceCache.registerSource(originalUrl, localPath);
console.log(`📍 Registered: ${originalUrl} -> ${localPath}`);
}
})
.catch(() => {
// Local version doesn't exist, that's fine
})
);
}
await Promise.all(discoveryPromises);
console.log(`✅ Discovery complete. Found ${sourceCache.sourceRegistry.size} local sources.`);
}
async function preloadSource(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const content = await response.text();
sourceCache.set(url, content);
return content;
} catch (error) {
console.warn(`Failed to preload source: ${url}`, error);
return null;
}
}
async function preloadAllSources() {
if (sourceCache.preloadComplete) return;
await discoverAndPreloadSources();
// Preload a few of the most recently discovered sources
const localSources = Array.from(sourceCache.sourceRegistry.values()).slice(0, 10); // Limit to first 10
if (localSources.length > 0) {
console.log(`📦 Preloading ${localSources.length} source pages...`);
const preloadPromises = localSources.map(async (localPath) => {
if (!sourceCache.has(localPath)) {
await preloadSource(localPath);
}
});
await Promise.all(preloadPromises);
console.log(`🚀 Preloaded ${sourceCache.size()} source pages`);
}
sourceCache.preloadComplete = true;
}
function createDataUrl(htmlContent) {
// Create a data URL for the HTML content
const blob = new Blob([htmlContent], { type: 'text/html' });
return URL.createObjectURL(blob);
}
async function updateSourceControls() {
const originalBtn = document.querySelector('.source-control-original');
const archiveBtn = document.querySelector('.source-control-archive');
if (!currentSourceUrl || !originalBtn || !archiveBtn) return;
// Update original button
originalBtn.href = currentSourceUrl;
// Check if archive is available (including checking if it exists)
let localPath = null;
if (sourceCache.isAvailableLocally(currentSourceUrl)) {
localPath = sourceCache.getLocalPath(currentSourceUrl);
} else {
// Try to find the archive if not in registry yet
const expectedLocalPath = `/sources/${await slug(currentSourceUrl)}`;
try {
const response = await fetch(expectedLocalPath, { method: 'HEAD' });
if (response.ok) {
localPath = expectedLocalPath;
sourceCache.registerSource(currentSourceUrl, expectedLocalPath);
}
} catch {
// No archive available
}
}
// Update archive button
if (localPath) {
archiveBtn.href = localPath;
archiveBtn.style.display = 'inline-flex';
} else {
archiveBtn.style.display = 'none';
}
}
async function showSourcePane(sourceUrl) {
const pane = document.getElementById('wiki-source-pane');
const frame = document.getElementById('source-frame');
if (!hasSpaceForSourcePane()) {
window.open(sourceUrl, '_blank');
return;
}
// Update current source URL for header controls
currentSourceUrl = sourceUrl;
// Show pane first for immediate feedback
if (pane) pane.style.display = 'block';
// Update header controls (async to check for archives)
updateSourceControls().catch(console.error);
// Always prefer archived version if available, never load original in iframe
let target = sourceUrl;
if (sourceCache.isAvailableLocally(sourceUrl)) {
target = sourceCache.getLocalPath(sourceUrl);
} else {
// If no local archive exists, try to generate the expected path and check if it exists
const expectedLocalPath = `/sources/${await slug(sourceUrl)}`;
try {
const response = await fetch(expectedLocalPath, { method: 'HEAD' });
if (response.ok) {
target = expectedLocalPath;
sourceCache.registerSource(sourceUrl, expectedLocalPath);
}
} catch {
// No local archive available - show message instead of loading original
if (frame) {
const noArchiveHtml = `
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: system-ui, sans-serif;
padding: 2rem;
text-align: center;
color: #666;
background: #fafafa;
}
.message {
max-width: 30rem;
margin: 0 auto;
line-height: 1.6;
}
.url {
word-break: break-all;
background: #f0f0f0;
padding: 0.5rem;
border-radius: 0.25rem;
margin: 1rem 0;
font-family: monospace;
font-size: 0.9rem;
}
a { color: #007acc; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="message">
<h3>📄 No Archive Available</h3>
<p>This source hasn't been archived yet. The original URL cannot be loaded in a frame due to security restrictions.</p>
<div class="url">${sourceUrl}</div>
<p>Use the "original" button above to open it in a new tab.</p>
</div>
</body>
</html>
`;
const dataUrl = createDataUrl(noArchiveHtml);
frame.src = dataUrl;
}
setSourceUrlInHash(sourceUrl);
return;
}
}
// Load archived content
if (sourceCache.has(target)) {
const cachedContent = sourceCache.get(target);
const dataUrl = createDataUrl(cachedContent);
if (frame) frame.src = dataUrl;
console.log(`⚡ Instant load from cache: ${target}`);
} else {
// Load and cache the archived content
console.log(`📥 Loading and caching: ${target}`);
const content = await preloadSource(target);
if (content && frame) {
const dataUrl = createDataUrl(content);
frame.src = dataUrl;
} else if (frame) {
// Show error message if archive fails to load
const errorHtml = `
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: system-ui, sans-serif;
padding: 2rem;
text-align: center;
color: #666;
background: #fafafa;
}
</style>
</head>
<body>
<h3> Archive Load Error</h3>
<p>Failed to load the archived content.</p>
<p>Use the "original" button to open in a new tab.</p>
</body>
</html>
`;
const dataUrl = createDataUrl(errorHtml);
frame.src = dataUrl;
}
}
// Update URL
setSourceUrlInHash(sourceUrl);
}
function hideSourcePane() {
const pane = document.getElementById('wiki-source-pane');
if (pane) {
pane.style.display = 'none';
currentSourceUrl = null;
}
removeSourceFromHash();
}
function createSourcePaneHeader() {
const header = document.createElement('div');
header.className = 'source-pane-header';
// Left side - links
const links = document.createElement('div');
links.className = 'source-pane-links';
// Original button
const originalBtn = document.createElement('a');
originalBtn.className = 'source-control-btn source-control-original';
originalBtn.target = '_blank';
originalBtn.innerHTML = '<span class="icon">↗</span>original';
// Archive button
const archiveBtn = document.createElement('a');
archiveBtn.className = 'source-control-btn source-control-archive';
archiveBtn.target = '_blank';
archiveBtn.innerHTML = '<span class="icon">↗</span>archive';
links.appendChild(originalBtn);
links.appendChild(archiveBtn);
// Right side - close button only
const controls = document.createElement('div');
controls.className = 'source-pane-controls';
// Close button
const closeBtn = document.createElement('button');
closeBtn.className = 'close-btn';
closeBtn.innerHTML = '×';
closeBtn.addEventListener('click', hideSourcePane);
controls.appendChild(closeBtn);
header.appendChild(links);
header.appendChild(controls);
return header;
}
/* ---------- main ----------------------------------------------------- */
document.addEventListener('DOMContentLoaded', async () => {
const pane = document.getElementById('wiki-source-pane'); // wrapper <aside>
const frame = document.getElementById('source-frame'); // iframe inside it
if (pane) pane.style.display = 'none'; // start hidden
// Add header with controls to source pane
if (pane && !pane.querySelector('.source-pane-header')) {
const header = createSourcePaneHeader();
pane.insertBefore(header, frame);
}
// Preload sources in the background
setTimeout(() => {
preloadAllSources().catch(console.error);
}, 1000); // Wait 1 second after page load
// Check URL hash on page load
const initialSourceUrl = getSourceUrlFromHash();
if (initialSourceUrl && hasSpaceForSourcePane()) {
showSourcePane(initialSourceUrl);
}
document.body.addEventListener('click', async ev => {
const link = ev.target.closest('a.source-link');
if (!link) return;
ev.preventDefault();
await showSourcePane(link.href);
});
/* Handle window resize - hide source pane if not enough space */
window.addEventListener('resize', () => {
if (pane && !hasSpaceForSourcePane()) {
hideSourcePane();
}
});
/* Handle browser back/forward buttons */
window.addEventListener('popstate', () => {
const sourceUrl = getSourceUrlFromHash();
if (sourceUrl && hasSpaceForSourcePane()) {
showSourcePane(sourceUrl);
} else {
hideSourcePane();
}
});
// Periodic cache cleanup every hour
setInterval(() => {
sourceCache.cleanExpired();
}, 60 * 60 * 1000); // 1 hour
// Add cache management to window for debugging
if (typeof window !== 'undefined') {
window.sourceCache = sourceCache;
window.clearSourceCache = () => {
sourceCache.clear();
console.log('🗑️ Source cache cleared');
};
window.preloadAllSources = preloadAllSources;
window.setCacheTimeout = (hours) => sourceCache.setCacheTimeout(hours);
window.getCacheStats = () => {
const stats = sourceCache.getCacheStats();
console.log('📊 Cache Stats:', stats);
return stats;
};
window.cleanExpiredCache = () => sourceCache.cleanExpired();
}
});

21
docs/index.md Normal file
View File

@ -0,0 +1,21 @@
# Ring-Making Mega-Guide
## How to make wax rings
Blah blah…
[getting started](https://old.reddit.com/r/RingMaking/comments/osjkgf/hey_i_wanna_start_with_ringmaking/){ .source-link }
### Tools brief list
- Mandrel
- Wax sprue
- Torch
### Mandrel {.tool}
The steel **ring mandrel** is used for sizing and shaping. The steel **ring mandrel** is used for sizing and shaping. The steel **ring mandrel** is used for sizing and shaping. The steel **ring mandrel** is used for sizing and shaping.
[Using mandrel](https://www.wirejewelry.com/jewelry_making_tips_techniques/Using-a-Ring-Mandrel_222.html){ .source-link }
## Casting the ring
do they thing

View File

@ -0,0 +1,190 @@
<meta charset='utf-8'>
<base target='_blank'>
<style>
body{font-family:system-ui,sans-serif;max-width:50rem;margin:2rem auto;line-height:1.6;padding:1rem}
img,iframe{max-width:100%}
.post-content{background:#f9f9f9;padding:1rem;border-radius:5px;margin:1rem 0}
.archive-header{background:#f0f8ff;border:1px solid #e0e0e0;border-radius:5px;padding:0.75rem;margin-bottom:1rem;font-size:0.9rem}
.archive-info{margin-bottom:0.5rem;color:#666}
.archive-source{color:#666}
.archive-header a{color:#007acc;text-decoration:none}
.archive-header a:hover{text-decoration:underline}
@media (prefers-color-scheme: dark) {
.archive-header{background:#1a1a2e;border-color:#333;color:#e0e0e0}
.archive-info, .archive-source{color:#ccc}
.archive-header a{color:#66b3ff}
}
</style>
<div class="archive-header">
<div class="archive-info">
<strong>📄 Archived:</strong> 2025-08-06 10:54:18 UTC
</div>
<div class="archive-source">
<strong>🔗 Source:</strong> <a href="https://old.reddit.com/r/RingMaking/comments/osjkgf/hey_i_wanna_start_with_ringmaking/">https://old.reddit.com/r/RingMaking/comments/osjkgf/hey_i_wanna_start_with_ringmaking/</a>
</div>
</div>
<script>
// Archive metadata for cache management
window.archiveData = {
url: 'https://old.reddit.com/r/RingMaking/comments/osjkgf/hey_i_wanna_start_with_ringmaking/',
archivedAt: "2025-08-06T10:54:18.225289+00:00Z",
timestamp: 1754477658225
};
</script>
<hr>
<h1>Hey I wanna start with ringmaking</h1>
<p><strong>r/RingMaking</strong> • by u/maundama • 6 points</p>
<div class='post-content'><div class="md"><p>I have been thinking abaut starting making rings at home for a pretty long while and have been binging every video on YouTube abaut it.
So my question is what equipment do I absolutely need to make good rings? (I mainly wanna start with coin and wooden rings)
The equipment quickly skyrockets in price so I want to know what I need.</p>
</div></div>
<hr>
<h2>Comments</h2>
<div style="margin-left: 0px; border-left: 3px solid #ddd; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/RichterScaleRings</strong> • 3 points
</div>
<div>
<div class="md"><p>You can get started making wood rings with nothing but a drill press and some hand tools if you get creative. I did a video on my YT channel making a ring with minimal tools. </p>
<p>A small wood lathe makes life easier and is fairly affordable. You can buy mandrels, but its easy enough to make your own from wood scraps till your ready to spend more.</p>
</div>
</div>
<div style="margin-left: 20px; border-left: 3px solid #eee; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/maundama</strong> • 1 points
</div>
<div>
<div class="md"><p>Thanks but I want to also do coin rings ad similar easy metal work
Your advice on wooden rings is still appreciated though</p>
</div>
</div>
<div style="margin-left: 40px; border-left: 3px solid #eee; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/RichterScaleRings</strong> • 4 points
</div>
<div>
<div class="md"><p>Coin rings are an entirely different setup of their own from other metal rings. Theyre very different from traditionally fabricated rings, which is entirely different again from other metal rings like Damascus steel or titanium</p>
</div>
</div>
<div style="margin-left: 60px; border-left: 3px solid #eee; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/nightlords_blue</strong> • 1 points
</div>
<div>
<div class="md"><p>What would you say is the best way to start on a pair of metal band-style rings? I'm wanting to handmake a pair for my girlfriend and I, for when I propose, but I don't know where to start, really.</p>
</div>
</div>
<div style="margin-left: 80px; border-left: 3px solid #eee; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/RichterScaleRings</strong> • 1 points
</div>
<div>
<div class="md"><p>Depends a lot on what kind of metal you want to use and how much you want to spend on equipment really. If you want to use silver or gold you could make some very basic bands by buying the metal stock close to size and bending, soldering, filing, sanding to shape for maybe 150-200$ worth of tools</p>
</div>
</div>
<div style="margin-left: 100px; border-left: 3px solid #eee; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/nightlords_blue</strong> • 1 points
</div>
<div>
<div class="md"><p>My budget isn't insane (couple hundred bucks all round, if that's feasible) and I don't want to use any precious metals. I saw a sheet of Damascus steel I could get for like $30 - probably won't be great, but I've heard that rings made out of it are good quality.</p>
<p>We're practical people and I'm just wanting to learn about the craft and make a few pieces for sentiment and out of love. I've seen a few really cool looking rings on here that are just simple bands polished down with inlays, and even though the inlaying would likely be too complex for me, I'm down to try whatever.</p>
</div>
</div>
<div style="margin-left: 120px; border-left: 3px solid #eee; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/RichterScaleRings</strong> • 1 points
</div>
<div>
<div class="md"><p>Enough silver for a couple rings wouldnt be too crazy price wise. Damascus is a whole other beast. You wont be able to solder like silver or gold, so youll have to find a workaround there- usually a Damascus ring would be made from solid rod . Plus youll need a setup for acid etching Damascus too.</p>
</div>
</div>
<div style="margin-left: 140px; border-left: 3px solid #eee; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/nightlords_blue</strong> • 1 points
</div>
<div>
<div class="md"><p>Okay. Do you have any advice on <em>how</em> to start? Like the physical process of making aforementioned rings?</p>
<p></p>
<p>Sorry to press with the questions, but I'm an absolute novice.</p>
</div>
</div>
<div style="margin-left: 160px; border-left: 3px solid #eee; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/RichterScaleRings</strong> • 1 points
</div>
<div>
<div class="md"><p>There are probably plenty of YouTube videos for making a basic band, but the high level process would be cut your stock to size with a jewelers saw, bend it into rough shape on a ring mandrel with a hammer/mallet, solder the joint together, clean off any scale from soldering with pickle pot or files and sandpaper, round it out and size the ring back on the ring mandrel, then file and sandpaper for the final shaping and finishing. You could stop with a brushed or satin finish if you want or get some polishing compound and buffing wheels to polish it out.</p>
</div>
</div>
<div style="margin-left: 180px; border-left: 3px solid #eee; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/nightlords_blue</strong> • 1 points
</div>
<div>
<div class="md"><p>Thanks for the help. Much appreciated!</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div style="margin-left: 0px; border-left: 3px solid #ddd; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/Fictional_or_True</strong> • 2 points
</div>
<div>
<div class="md"><p>I started with spoon rings (Im still in the very starting stages and have only made four rings.) I started out with buying a ring mandrel. It came with a nylon and rubber hammer. Thats all I used, plus some tools I had like snips, wrenches, and larger hammers, to make my rings. If you want to start with metal rings without getting tons of tools, spoon/fork rings are the way to start, in my opinion.</p>
</div>
</div>
<div style="margin-left: 20px; border-left: 3px solid #eee; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/SameResolution4737</strong> • 1 points
</div>
<div>
<div class="md"><p>The trouble with that is stock for me - I'm a member of two different Facebook pages for buying/selling silver plate &amp; sterling silver flatware. If it is a reasonable price it's gone before I even get a chance &amp; the rest is priced way above my price point. Plus the only ones I seem to be able to sell are stainless steel - even though it's the same price as the silver (I ought to tack on a $5 surcharge for difficulty.)</p>
</div>
</div>
</div>
</div>
<div style="margin-left: 0px; border-left: 3px solid #ddd; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/Zealousideal-Ad9859</strong> • 1 points
</div>
<div>
<div class="md"><p>This will get you started:
<a href="https://www.instagram.com/p/COg3_73LMdd/?utm_medium=copy_link">https://www.instagram.com/p/COg3_73LMdd/?utm_medium=copy_link</a></p>
</div>
</div>
</div>
<div style="margin-left: 0px; border-left: 3px solid #ddd; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/SameResolution4737</strong> • 1 points
</div>
<div>
<div class="md"><p>As far as coin rings - look up a YouTube channel The Mint/Change You Can Wear. Skylar is sort of the dean of coin ring making. He has one video which gives you 3 setups for making rings: budget, moderate &amp; deluxe. He also has some videos on making coin rings basically with hand tools. You want his earlier videos - he's now getting into setting stones in his rings &amp; other, more advanced techniques (which are fine after you get your feet under you.)</p>
</div>
</div>
</div>

View File

@ -0,0 +1,42 @@
<meta charset='utf-8'>
<base target='_blank'>
<style>
body{font-family:system-ui,sans-serif;max-width:50rem;margin:2rem auto;line-height:1.6;padding:1rem}
img,iframe{max-width:100%}
.post-content{background:#f9f9f9;padding:1rem;border-radius:5px;margin:1rem 0}
.archive-header{background:#f0f8ff;border:1px solid #e0e0e0;border-radius:5px;padding:0.75rem;margin-bottom:1rem;font-size:0.9rem}
.archive-info{margin-bottom:0.5rem;color:#666}
.archive-source{color:#666}
.archive-header a{color:#007acc;text-decoration:none}
.archive-header a:hover{text-decoration:underline}
@media (prefers-color-scheme: dark) {
.archive-header{background:#1a1a2e;border-color:#333;color:#e0e0e0}
.archive-info, .archive-source{color:#ccc}
.archive-header a{color:#66b3ff}
}
</style>
<div class="archive-header">
<div class="archive-info">
<strong>📄 Archived:</strong> 2025-08-06 10:54:18 UTC
</div>
<div class="archive-source">
<strong>🔗 Source:</strong> <a href="https://www.wirejewelry.com/jewelry_making_tips_techniques/Using-a-Ring-Mandrel_222.html">https://www.wirejewelry.com/jewelry_making_tips_techniques/Using-a-Ring-Mandrel_222.html</a>
</div>
</div>
<script>
// Archive metadata for cache management
window.archiveData = {
url: 'https://www.wirejewelry.com/jewelry_making_tips_techniques/Using-a-Ring-Mandrel_222.html',
archivedAt: "2025-08-06T10:54:18.442165+00:00Z",
timestamp: 1754477658442
};
</script>
<hr>
<h1>Using a Ring Mandrel</h1>
<html><body><div><div class="author-text"><p></p><p>A die-hard rockhound, lapidary, and wire jewelry designer, instructor and author, Dale credits her mom for spurring the main interest that led to Dale's chosen career. Her mother was an avid rockhound and many, weekend family adventures involved traipsing through the White Mountains of New Hampshire in search of abandoned pegmatite mines. This is where Dales fascination with, and education of rocks and minerals began, some 40 years ago.</p>
<p>With more than 40 years of experience, Dale uses absolutely no solder or glue in her traditional wire jewelry designs, and teaches how at jewelry making events in North America, Russia, Ukraine and Switzerland. Her award winning work is sold in galleries and has appeared in many printed publications.</p>
<p>Author of numerous magazine and Internet articles and the bestselling book, “Wirework: An Illustrated Guide to the Art of Wire Wrapping”, Dale has made several series of instructional DVDs for WireJewelry.com.</p>
<p>hen she is not teaching or writing, Dale creates new designs in SE Tennessee where she enjoys her family, furry friends, gardens and tons of rocks.</p></div>
</div></body></html>

3
makefile Normal file
View File

@ -0,0 +1,3 @@
sync-sources:
python3 tools/sync_sources.py

32
mkdocs.yml Normal file
View File

@ -0,0 +1,32 @@
site_name: Ring-Making Mega-Guide
site_url: https://docs.example.com
theme:
name: material
custom_dir: overrides
features:
- toc.integrate # left rail = in-page TOC :contentReference[oaicite:0]{index=0}
- navigation.instant # SPA-style speed
- navigation.expand
markdown_extensions:
- footnotes
- attr_list # keep this if you use {.source-link}
# - pymdownx.citations # ←- delete or comment out
plugins:
- search
- bibtex: # <— mkdocs-bibtex we just installed
bib_file: references.bib # put your .bib in the repo root
# csl_file: chicago-fullnote-bibliography.csl # optional style
extra_css:
- assets/extra.css
extra_javascript:
- assets/extra.js
extra:
social: false # optional removes top-right social icons

13
overrides/main.html Normal file
View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<div id="wiki-multipane">
<div id="wiki-main-pane" class="megapage-grid">
{{ super() }} {# Materials normal two-column layout #}
</div>
<aside id="wiki-source-pane">
<iframe id="source-frame" title="Source preview"></iframe>
</aside>
</div>
{% endblock %}

0
references.bib Normal file
View File

196
tools/fetch.py Normal file
View File

@ -0,0 +1,196 @@
"""
Usage:
python tools/fetch.py <url> <out_dir> [--force]
"""
import requests, sys, pathlib, slug, argparse, re, html, datetime
from readability import Document
from bs4 import BeautifulSoup
from urllib.parse import urlparse
def reader_mode(html_content: str) -> str:
doc = Document(html_content)
body = BeautifulSoup(doc.summary(), "html.parser")
return f"<h1>{doc.short_title()}</h1>\n{body}"
def is_reddit_url(url: str) -> bool:
"""Check if URL is a Reddit link"""
parsed = urlparse(url)
return 'reddit.com' in parsed.netloc
def clean_reddit_html(html_content):
"""Clean up Reddit's HTML content"""
if not html_content:
return ""
# Decode HTML entities
cleaned = html.unescape(html_content)
# Parse and clean up the HTML
soup = BeautifulSoup(cleaned, 'html.parser')
# Remove Reddit-specific formatting comments
for comment in soup.find_all(string=lambda text: isinstance(text, str) and
('SC_OFF' in text or 'SC_ON' in text)):
comment.extract()
return str(soup)
def archive_reddit(url: str) -> str:
"""Archive Reddit post with comments using JSON API"""
try:
# Convert to JSON API endpoint
json_url = url.rstrip('/') + '.json'
headers = {"User-Agent": "Mozilla/5.0 (ArchiveBot/1.0)"}
response = requests.get(json_url, timeout=30, headers=headers)
response.raise_for_status()
data = response.json()
# Extract post data
post = data[0]['data']['children'][0]['data']
comments = data[1]['data']['children'] if len(data) > 1 else []
# Build HTML
html_content = f"<h1>{post['title']}</h1>\n"
html_content += f"<p><strong>r/{post['subreddit']}</strong> • by u/{post['author']}{post['score']} points</p>\n"
if post.get('selftext_html'):
html_content += f"<div class='post-content'>{clean_reddit_html(post['selftext_html'])}</div>\n"
html_content += "<hr>\n<h2>Comments</h2>\n"
html_content += format_comments(comments)
return html_content
except Exception as e:
print(f"⚠ Reddit JSON failed ({e}), trying HTML fallback...")
# Fallback: get HTML and try to preserve more content
response = requests.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 (ArchiveBot/1.0)"})
soup = BeautifulSoup(response.text, 'html.parser')
# Try to get comments section too
comments_section = soup.find('div', class_=re.compile('comments|commentarea'))
main_content = reader_mode(response.text)
if comments_section:
main_content += "<hr>\n<h2>Comments</h2>\n" + str(comments_section)
return main_content
def format_comments(comments, depth=0):
"""Format Reddit comments recursively with proper HTML"""
html_content = ""
for comment in comments:
if comment['kind'] != 't1': # Skip non-comments
continue
data = comment['data']
if data.get('body') in ['[deleted]', '[removed]', None]:
continue
# Style based on depth
margin_left = depth * 20
border_color = '#ddd' if depth == 0 else '#eee'
html_content += f'''
<div style="margin-left: {margin_left}px; border-left: 3px solid {border_color}; padding: 10px; margin: 10px 0; background: #fafafa;">
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>u/{data.get("author", "[deleted]")}</strong> {data.get("score", 0)} points
</div>
<div>
{clean_reddit_html(data.get("body_html", ""))}
</div>
'''
# Handle replies recursively
if data.get('replies') and isinstance(data['replies'], dict):
html_content += format_comments(data['replies']['data']['children'], depth + 1)
html_content += '</div>\n'
return html_content
def generate_archive_header(url: str, archive_date: datetime.datetime) -> str:
"""Generate archive header with date and metadata"""
formatted_date = archive_date.strftime('%Y-%m-%d %H:%M:%S UTC')
iso_date = archive_date.isoformat() + 'Z'
return f'''<div class="archive-header">
<div class="archive-info">
<strong>📄 Archived:</strong> {formatted_date}
</div>
<div class="archive-source">
<strong>🔗 Source:</strong> <a href="{url}">{url}</a>
</div>
</div>
<script>
// Archive metadata for cache management
window.archiveData = {{
url: {repr(url)},
archivedAt: "{iso_date}",
timestamp: {int(archive_date.timestamp() * 1000)}
}};
</script>
<hr>'''
def archive(url: str, out_dir: pathlib.Path, force: bool):
out_dir.mkdir(parents=True, exist_ok=True)
fname = out_dir / slug.slug(url)
if fname.exists() and not force:
print(f"✓ cached: {url}")
return
print(f"↓ fetching: {url}")
try:
archive_date = datetime.datetime.now(datetime.timezone.utc)
if is_reddit_url(url):
content = archive_reddit(url)
else:
html_response = requests.get(url, timeout=30, headers={
"User-Agent": "Mozilla/5.0 (ArchiveBot/1.0)"
}).text
content = reader_mode(html_response)
# Enhanced styling with archive header
archive_style = """
<style>
body{font-family:system-ui,sans-serif;max-width:50rem;margin:2rem auto;line-height:1.6;padding:1rem}
img,iframe{max-width:100%}
.post-content{background:#f9f9f9;padding:1rem;border-radius:5px;margin:1rem 0}
.archive-header{background:#f0f8ff;border:1px solid #e0e0e0;border-radius:5px;padding:0.75rem;margin-bottom:1rem;font-size:0.9rem}
.archive-info{margin-bottom:0.5rem;color:#666}
.archive-source{color:#666}
.archive-header a{color:#007acc;text-decoration:none}
.archive-header a:hover{text-decoration:underline}
@media (prefers-color-scheme: dark) {
.archive-header{background:#1a1a2e;border-color:#333;color:#e0e0e0}
.archive-info, .archive-source{color:#ccc}
.archive-header a{color:#66b3ff}
}
</style>
"""
fname.write_text(
"<meta charset='utf-8'>\n" +
"<base target='_blank'>\n" +
archive_style + "\n" +
generate_archive_header(url, archive_date) + "\n" +
content,
encoding="utf-8"
)
print(f"✓ saved : {fname.relative_to(out_dir.parent)}")
except Exception as e:
print(f"✗ failed : {url} - {e}")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("url")
ap.add_argument("out_dir")
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
archive(args.url, pathlib.Path(args.out_dir), args.force)

13
tools/slug.py Normal file
View File

@ -0,0 +1,13 @@
# tools/slug.py
import hashlib, sys, urllib.parse, pathlib, re
def slug(url: str) -> str:
parsed = urllib.parse.urlparse(url)
host = re.sub(r'\W+', '-', parsed.netloc.lower()).strip('-')
path = re.sub(r'\W+', '-', parsed.path.strip('/').lower())[:60]
h = hashlib.sha1(url.encode()).hexdigest()[:10]
return f"{host}__{path or 'root'}__{h}.html"
if __name__ == "__main__":
print(slug(sys.argv[1]))

26
tools/sync_sources.py Normal file
View File

@ -0,0 +1,26 @@
"""
Scan every Markdown file under docs/, find links marked `.source-link`,
and make sure a local archive exists in docs/sources/.
"""
import pathlib, re, subprocess, sys, slug, fetch, argparse
DOCS = pathlib.Path("docs")
OUT = DOCS / "sources"
LINK = re.compile(r'\[.*?\]\((https?://[^\s\)]+)\)\{[^}]*?\.source-link[^}]*?}')
def main(force: bool):
urls = set()
for md in DOCS.rglob("*.md"):
for m in LINK.finditer(md.read_text()):
urls.add(m.group(1))
for url in sorted(urls):
fetch.archive(url, OUT, force)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--force", action="store_true",
help="re-fetch even if local copy exists")
args = ap.parse_args()
main(args.force)