initial commit
This commit is contained in:
commit
fe781b9475
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
420
docs/assets/extra.css
Normal file
420
docs/assets/extra.css
Normal 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
539
docs/assets/extra.js
Normal 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
21
docs/index.md
Normal 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
|
||||||
|
|
@ -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 it’s 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. They’re 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 wouldn’t be too crazy price wise. Damascus is a whole other beast. You won’t be able to solder like silver or gold, so you’ll have to find a workaround there- usually a Damascus ring would be made from solid rod . Plus you’ll 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 (I’m 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. That’s 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 & sterling silver flatware. If it is a reasonable price it's gone before I even get a chance & 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 & 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 & other, more advanced techniques (which are fine after you get your feet under you.)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -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 Dale’s 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>
|
32
mkdocs.yml
Normal file
32
mkdocs.yml
Normal 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
13
overrides/main.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="wiki-multipane">
|
||||||
|
<div id="wiki-main-pane" class="megapage-grid">
|
||||||
|
{{ super() }} {# Material’s 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
0
references.bib
Normal file
196
tools/fetch.py
Normal file
196
tools/fetch.py
Normal 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
13
tools/slug.py
Normal 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
26
tools/sync_sources.py
Normal 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)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user