This commit is contained in:
thePR0M3TH3AN
2025-07-10 19:37:30 -04:00
parent de5be5f09b
commit 40d16101e0
38 changed files with 11003 additions and 13 deletions

2
docs/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

17
docs/.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- run: npm test

2
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
_site/
node_modules/

View File

@@ -1,25 +1,55 @@
# SeedPass Documentation
# Archivox
This directory contains supplementary guides for using SeedPass.
Archivox is a lightweight static site generator aimed at producing documentation sites similar to "Read the Docs". Write your content in Markdown, run the generator, and deploy the static files anywhere.
## Quick Example: Get a TOTP Code
[![Build Status](https://github.com/PR0M3TH3AN/Archivox/actions/workflows/ci.yml/badge.svg)](https://github.com/PR0M3TH3AN/Archivox/actions/workflows/ci.yml)
Run `seedpass entry get <query>` to retrieve a time-based one-time password (TOTP).
The `<query>` can be a label, title, or index. A progress bar shows the remaining
seconds in the current period.
## Features
- Markdown based pages with automatic navigation
- Responsive layout with sidebar and search powered by Lunr.js
- Simple configuration through `config.yaml`
- Extensible via plugins and custom templates
## Getting Started
Install the dependencies and start the development server:
```bash
$ seedpass entry get "email"
[##########----------] 15s
Code: 123456
npm install
npm run dev
```
To show all stored TOTP codes with their countdown timers, run:
The site will be available at `http://localhost:8080`. Edit files inside the `content/` directory to update pages.
To create a new project from the starter template you can run:
```bash
$ seedpass entry totp-codes
npx create-archivox my-docs --install
```
## CLI and API Reference
## Building
When you are ready to publish your documentation run:
See [advanced_cli.md](advanced_cli.md) for a list of command examples. Detailed information about the REST API is available in [api_reference.md](api_reference.md). When starting the API, set `SEEDPASS_CORS_ORIGINS` if you need to allow requests from specific web origins.
```bash
npm run build
```
The generated site is placed in the `_site/` folder.
## Customization
- **`config.yaml`** change the site title, theme options and other settings.
- **`plugins/`** add JavaScript files exporting hook functions such as `onPageRendered` to extend the build process.
- **`templates/`** modify or replace the Nunjucks templates for full control over the HTML.
## Hosting
Upload the contents of `_site/` to any static host. For Netlify you can use the provided `netlify.toml`:
```toml
[build]
command = "npm run build"
publish = "_site"
```
## Documentation
See the files under the `docs/` directory for a full guide to Archivox including an integration tutorial for existing projects.
Archivox is released under the MIT License.

View File

@@ -0,0 +1,34 @@
const { buildNav } = require('../src/generator');
test('generates navigation tree', () => {
const pages = [
{ file: 'guide/install.md', data: { title: 'Install', order: 1 } },
{ file: 'guide/usage.md', data: { title: 'Usage', order: 2 } },
{ file: 'guide/nested/info.md', data: { title: 'Info', order: 1 } }
];
const tree = buildNav(pages);
const guide = tree.find(n => n.name === 'guide');
expect(guide).toBeDefined();
expect(guide.children.length).toBe(3);
const install = guide.children.find(c => c.name === 'install.md');
expect(install.path).toBe('/guide/install.html');
});
test('adds display names and section flags', () => {
const pages = [
{ file: '02-api.md', data: { title: 'API', order: 2 } },
{ file: '01-guide/index.md', data: { title: 'Guide', order: 1 } },
{ file: '01-guide/setup.md', data: { title: 'Setup', order: 2 } },
{ file: 'index.md', data: { title: 'Home', order: 10 } }
];
const nav = buildNav(pages);
expect(nav[0].name).toBe('index.md');
const guide = nav.find(n => n.name === '01-guide');
expect(guide.displayName).toBe('Guide');
expect(guide.isSection).toBe(true);
const api = nav.find(n => n.name === '02-api.md');
expect(api.displayName).toBe('API');
// alphabetical within same order
expect(nav[1].name).toBe('01-guide');
expect(nav[2].name).toBe('02-api.md');
});

View File

@@ -0,0 +1,13 @@
const fs = require('fs');
const path = require('path');
const loadConfig = require('../src/config/loadConfig');
test('loads configuration and merges defaults', () => {
const dir = fs.mkdtempSync(path.join(__dirname, 'cfg-'));
const file = path.join(dir, 'config.yaml');
fs.writeFileSync(file, 'site:\n title: Test Site\n');
const cfg = loadConfig(file);
expect(cfg.site.title).toBe('Test Site');
expect(cfg.navigation.search).toBe(true);
fs.rmSync(dir, { recursive: true, force: true });
});

View File

@@ -0,0 +1,23 @@
const fs = require('fs');
const path = require('path');
const loadPlugins = require('../src/config/loadPlugins');
test('plugin hook modifies data', async () => {
const dir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'plugins-'));
const pluginFile = path.join(dir, 'test.plugin.js');
fs.writeFileSync(
pluginFile,
"module.exports = { onParseMarkdown: ({ content }) => ({ content: content + '!!' }) };\n"
);
const plugins = loadPlugins({ pluginsDir: dir, plugins: ['test.plugin'] });
let data = { content: 'hello' };
for (const plugin of plugins) {
if (typeof plugin.onParseMarkdown === 'function') {
const res = await plugin.onParseMarkdown(data);
if (res !== undefined) data = res;
}
}
expect(data.content).toBe('hello!!');
fs.rmSync(dir, { recursive: true, force: true });
});

View File

@@ -0,0 +1,77 @@
jest.mock('@11ty/eleventy', () => {
const fs = require('fs');
const path = require('path');
return class Eleventy {
constructor(input, output) {
this.input = input;
this.output = output;
}
setConfig() {}
async write() {
const walk = d => {
const entries = fs.readdirSync(d, { withFileTypes: true });
let files = [];
for (const e of entries) {
const p = path.join(d, e.name);
if (e.isDirectory()) files = files.concat(walk(p));
else if (p.endsWith('.md')) files.push(p);
}
return files;
};
for (const file of walk(this.input)) {
const rel = path.relative(this.input, file).replace(/\.md$/, '.html');
const dest = path.join(this.output, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, '<header></header><aside class="sidebar"></aside>');
}
}
};
});
const fs = require('fs');
const path = require('path');
const os = require('os');
const { generate } = require('../src/generator');
function getPaths(tree) {
const paths = [];
for (const node of tree) {
if (node.path) paths.push(node.path);
if (node.children) paths.push(...getPaths(node.children));
}
return paths;
}
test('markdown files render with layout and appear in nav/search', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'df-test-'));
const contentDir = path.join(tmp, 'content');
const outputDir = path.join(tmp, '_site');
fs.mkdirSync(path.join(contentDir, 'guide'), { recursive: true });
fs.writeFileSync(path.join(contentDir, 'index.md'), '# Home\nWelcome');
fs.writeFileSync(path.join(contentDir, 'guide', 'install.md'), '# Install\nSteps');
const configPath = path.join(tmp, 'config.yaml');
fs.writeFileSync(configPath, 'site:\n title: Test\n');
await generate({ contentDir, outputDir, configPath });
const indexHtml = fs.readFileSync(path.join(outputDir, 'index.html'), 'utf8');
const installHtml = fs.readFileSync(path.join(outputDir, 'guide', 'install.html'), 'utf8');
expect(indexHtml).toContain('<header');
expect(indexHtml).toContain('<aside class="sidebar"');
expect(installHtml).toContain('<header');
expect(installHtml).toContain('<aside class="sidebar"');
const nav = JSON.parse(fs.readFileSync(path.join(outputDir, 'navigation.json'), 'utf8'));
const navPaths = getPaths(nav);
expect(navPaths).toContain('/index.html');
expect(navPaths).toContain('/guide/install.html');
const search = JSON.parse(fs.readFileSync(path.join(outputDir, 'search-index.json'), 'utf8'));
const docs = search.docs.map(d => d.id);
expect(docs).toContain('index.html');
expect(docs).toContain('guide/install.html');
const installDoc = search.docs.find(d => d.id === 'guide/install.html');
expect(installDoc.body).toContain('Steps');
fs.rmSync(tmp, { recursive: true, force: true });
});

View File

@@ -0,0 +1,128 @@
jest.mock('@11ty/eleventy', () => {
const fs = require('fs');
const path = require('path');
return class Eleventy {
constructor(input, output) {
this.input = input;
this.output = output;
}
setConfig() {}
async write() {
const walk = d => {
const entries = fs.readdirSync(d, { withFileTypes: true });
let files = [];
for (const e of entries) {
const p = path.join(d, e.name);
if (e.isDirectory()) files = files.concat(walk(p));
else if (p.endsWith('.md')) files.push(p);
}
return files;
};
for (const file of walk(this.input)) {
const rel = path.relative(this.input, file).replace(/\.md$/, '.html');
const dest = path.join(this.output, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(
dest,
`<!DOCTYPE html><html><head><link rel="stylesheet" href="/assets/theme.css"></head><body><header><button id="sidebar-toggle" class="sidebar-toggle">☰</button></header><div class="container"><aside class="sidebar"></aside><main></main></div><script src="/assets/theme.js"></script></body></html>`
);
}
}
};
});
const fs = require('fs');
const path = require('path');
const http = require('http');
const os = require('os');
const puppeteer = require('puppeteer');
const { generate } = require('../src/generator');
jest.setTimeout(30000);
let server;
let browser;
let port;
let tmp;
beforeAll(async () => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'df-responsive-'));
const contentDir = path.join(tmp, 'content');
const outputDir = path.join(tmp, '_site');
fs.mkdirSync(contentDir, { recursive: true });
fs.writeFileSync(path.join(contentDir, 'index.md'), '# Home\n');
await generate({ contentDir, outputDir });
fs.cpSync(path.join(__dirname, '../assets'), path.join(outputDir, 'assets'), { recursive: true });
server = http.createServer((req, res) => {
let filePath = path.join(outputDir, req.url === '/' ? 'index.html' : req.url);
if (req.url.startsWith('/assets')) {
filePath = path.join(outputDir, req.url);
}
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
const ext = path.extname(filePath).slice(1);
const type = { html: 'text/html', js: 'text/javascript', css: 'text/css' }[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': type });
res.end(data);
});
});
await new Promise(resolve => {
server.listen(0, () => {
port = server.address().port;
resolve();
});
});
browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
});
afterAll(async () => {
if (browser) await browser.close();
if (server) server.close();
fs.rmSync(tmp, { recursive: true, force: true });
});
test('sidebar opens on small screens', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 500, height: 800 });
await page.goto(`http://localhost:${port}/`);
await page.waitForSelector('#sidebar-toggle');
await page.click('#sidebar-toggle');
await new Promise(r => setTimeout(r, 300));
const bodyClass = await page.evaluate(() => document.body.classList.contains('sidebar-open'));
const sidebarLeft = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).left);
expect(bodyClass).toBe(true);
expect(sidebarLeft).toBe('0px');
});
test('clicking outside closes sidebar on small screens', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 500, height: 800 });
await page.goto(`http://localhost:${port}/`);
await page.waitForSelector('#sidebar-toggle');
await page.click('#sidebar-toggle');
await new Promise(r => setTimeout(r, 300));
await page.click('main');
await new Promise(r => setTimeout(r, 300));
const bodyClass = await page.evaluate(() => document.body.classList.contains('sidebar-open'));
expect(bodyClass).toBe(false);
});
test('sidebar toggles on large screens', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1024, height: 800 });
await page.goto(`http://localhost:${port}/`);
await page.waitForSelector('#sidebar-toggle');
await new Promise(r => setTimeout(r, 300));
let sidebarWidth = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).width);
expect(sidebarWidth).toBe('240px');
await page.click('#sidebar-toggle');
await new Promise(r => setTimeout(r, 300));
sidebarWidth = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).width);
expect(sidebarWidth).toBe('0px');
});

3475
docs/assets/lunr.js Normal file

File diff suppressed because it is too large Load Diff

160
docs/assets/theme.css Normal file
View File

@@ -0,0 +1,160 @@
:root {
--bg-color: #ffffff;
--text-color: #333333;
--sidebar-bg: #f3f3f3;
--sidebar-width: 240px;
}
[data-theme="dark"] {
--bg-color: #222222;
--text-color: #eeeeee;
--sidebar-bg: #333333;
}
body {
margin: 0;
background: var(--bg-color);
color: var(--text-color);
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.header {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
background: var(--sidebar-bg);
position: sticky;
top: 0;
z-index: 1100;
}
.search-input {
margin-left: auto;
padding: 0.25rem;
}
.search-results {
display: none;
position: absolute;
right: 1rem;
top: 100%;
background: var(--bg-color);
border: 1px solid #ccc;
width: 250px;
max-height: 200px;
overflow-y: auto;
z-index: 100;
}
.search-results a {
display: block;
padding: 0.25rem;
color: var(--text-color);
text-decoration: none;
}
.search-results a:hover {
background: var(--sidebar-bg);
}
.search-results .no-results {
padding: 0.25rem;
}
.logo { text-decoration: none; color: var(--text-color); font-weight: bold; }
.sidebar-toggle,
.theme-toggle { background: none; border: none; font-size: 1.2rem; margin-right: 1rem; cursor: pointer; }
.container { display: flex; flex: 1; }
.sidebar {
width: var(--sidebar-width);
background: var(--sidebar-bg);
padding: 1rem;
box-sizing: border-box;
}
.sidebar ul { list-style: none; padding: 0; margin: 0; }
.sidebar li { margin: 0.25rem 0; }
.sidebar a { text-decoration: none; color: var(--text-color); display: block; padding: 0.25rem 0; }
.sidebar nav { font-size: 0.9rem; }
.nav-link:hover { text-decoration: underline; }
.nav-link.active { font-weight: bold; }
.nav-section summary {
list-style: none;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
}
.nav-section summary::-webkit-details-marker { display: none; }
.nav-section summary::before {
content: '▸';
display: inline-block;
margin-right: 0.25rem;
transition: transform 0.2s ease;
}
.nav-section[open] > summary::before { transform: rotate(90deg); }
.nav-level { padding-left: 1rem; margin-left: 0.5rem; border-left: 2px solid #ccc; }
.sidebar ul ul { padding-left: 1rem; margin-left: 0.5rem; border-left: 2px solid #ccc; }
main {
flex: 1;
padding: 2rem;
}
.breadcrumbs a { color: var(--text-color); text-decoration: none; }
.footer {
text-align: center;
padding: 1rem;
background: var(--sidebar-bg);
position: relative;
}
.footer-links {
margin-bottom: 0.5rem;
}
.footer-links a {
margin: 0 0.5rem;
text-decoration: none;
color: var(--text-color);
}
.footer-permanent-links {
position: absolute;
right: 0.5rem;
bottom: 0.25rem;
font-size: 0.8rem;
opacity: 0.7;
}
.footer-permanent-links a {
margin-left: 0.5rem;
text-decoration: none;
color: var(--text-color);
}
.sidebar-overlay {
display: none;
}
@media (max-width: 768px) {
body.sidebar-open .sidebar-overlay {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 999;
}
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -100%;
top: 0;
height: 100%;
overflow-y: auto;
transition: none;
z-index: 1000;
}
body.sidebar-open .sidebar { left: 0; }
}
@media (min-width: 769px) {
.sidebar {
transition: width 0.2s ease;
}
body:not(.sidebar-open) .sidebar {
width: 0;
padding: 0;
overflow: hidden;
}
}

107
docs/assets/theme.js Normal file
View File

@@ -0,0 +1,107 @@
document.addEventListener('DOMContentLoaded', () => {
const sidebarToggle = document.getElementById('sidebar-toggle');
const themeToggle = document.getElementById('theme-toggle');
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
const sidebar = document.getElementById('sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const root = document.documentElement;
function setTheme(theme) {
root.dataset.theme = theme;
localStorage.setItem('theme', theme);
}
const stored = localStorage.getItem('theme');
if (stored) setTheme(stored);
if (window.innerWidth > 768) {
document.body.classList.add('sidebar-open');
}
sidebarToggle?.addEventListener('click', () => {
document.body.classList.toggle('sidebar-open');
});
sidebarOverlay?.addEventListener('click', () => {
document.body.classList.remove('sidebar-open');
});
themeToggle?.addEventListener('click', () => {
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
setTheme(next);
});
// search
let lunrIndex;
let docs = [];
async function loadIndex() {
if (lunrIndex) return;
try {
const res = await fetch('/search-index.json');
const data = await res.json();
lunrIndex = lunr.Index.load(data.index);
docs = data.docs;
} catch (e) {
console.error('Search index failed to load', e);
}
}
function highlight(text, q) {
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + ')', 'gi');
return text.replace(re, '<mark>$1</mark>');
}
searchInput?.addEventListener('input', async e => {
const q = e.target.value.trim();
await loadIndex();
if (!lunrIndex || !q) {
searchResults.style.display = 'none';
searchResults.innerHTML = '';
return;
}
const matches = lunrIndex.search(q);
searchResults.innerHTML = '';
if (!matches.length) {
searchResults.innerHTML = '<div class="no-results">No matches found</div>';
searchResults.style.display = 'block';
return;
}
matches.forEach(m => {
const doc = docs.find(d => d.id === m.ref);
if (!doc) return;
const a = document.createElement('a');
a.href = doc.url;
const snippet = doc.body ? doc.body.slice(0, 160) + (doc.body.length > 160 ? '...' : '') : '';
a.innerHTML = '<strong>' + highlight(doc.title, q) + '</strong><br><small>' + highlight(snippet, q) + '</small>';
searchResults.appendChild(a);
});
searchResults.style.display = 'block';
});
document.addEventListener('click', e => {
if (!searchResults.contains(e.target) && e.target !== searchInput) {
searchResults.style.display = 'none';
}
if (
window.innerWidth <= 768 &&
document.body.classList.contains('sidebar-open') &&
sidebar &&
!sidebar.contains(e.target) &&
e.target !== sidebarToggle
) {
document.body.classList.remove('sidebar-open');
}
});
// breadcrumbs
const bc = document.getElementById('breadcrumbs');
if (bc) {
const parts = location.pathname.split('/').filter(Boolean);
let path = '';
bc.innerHTML = '<a href="/">Home</a>';
parts.forEach((p) => {
path += '/' + p;
bc.innerHTML += ' / <a href="' + path + '">' + p.replace(/-/g, ' ') + '</a>';
});
}
});

45
docs/bin/create-archivox.js Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
function main() {
const args = process.argv.slice(2);
const install = args.includes('--install');
const targetArg = args.find(a => !a.startsWith('-')) || '.';
const targetDir = path.resolve(process.cwd(), targetArg);
const templateDir = path.join(__dirname, '..', 'starter');
copyDir(templateDir, targetDir);
const pkgPath = path.join(targetDir, 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const version = require('../package.json').version;
if (pkg.dependencies && pkg.dependencies.archivox)
pkg.dependencies.archivox = `^${version}`;
pkg.name = path.basename(targetDir);
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
}
if (install) {
execSync('npm install', { cwd: targetDir, stdio: 'inherit' });
}
console.log(`Archivox starter created at ${targetDir}`);
}
main();

15
docs/build-docs.js Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
const path = require('path');
const { generate } = require('./src/generator');
(async () => {
try {
const contentDir = path.join(__dirname, 'docs', 'content');
const configPath = path.join(__dirname, 'docs', 'config.yaml');
const outputDir = path.join(__dirname, '_site');
await generate({ contentDir, outputDir, configPath });
} catch (err) {
console.error(err);
process.exit(1);
}
})();

13
docs/docs/config.yaml Normal file
View File

@@ -0,0 +1,13 @@
site:
title: "Archivox Docs"
description: "Simple static docs."
navigation:
search: true
footer:
links:
- text: "Custom Link 1"
url: "https://example.com"
- text: "Custom Link 2"
url: "https://example.com/other"

View File

@@ -0,0 +1,3 @@
# Getting Started
Welcome to SeedPass!

View File

@@ -0,0 +1,15 @@
# Archivox Documentation
Welcome to the official documentation for **Archivox**, a lightweight static site generator designed for "Read&nbsp;the&nbsp;Docs" style websites.
## Quick Start
```bash
npm install
npm run dev # start local server at http://localhost:8080
npm run build # generate the _site/ folder
```
Archivox converts Markdown files inside a `content/` folder into a full documentation site with search, navigation, and responsive design.
Check the **Getting Started** section to learn how to run Archivox locally and the **Project Integration** guide to drop Archivox into an existing codebase.

11
docs/docs/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "docs",
"private": true,
"scripts": {
"dev": "eleventy --serve",
"build": "node node_modules/archivox/src/generator/index.js"
},
"dependencies": {
"archivox": "^1.0.0"
}
}

3
docs/netlify.toml Normal file
View File

@@ -0,0 +1,3 @@
[build]
command = "node build-docs.js"
publish = "_site"

6357
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
docs/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "archivox",
"version": "1.0.0",
"description": "Archivox static site generator",
"scripts": {
"dev": "eleventy --serve",
"build": "node src/generator/index.js",
"test": "jest"
},
"dependencies": {
"@11ty/eleventy": "^2.0.1",
"gray-matter": "^4.0.3",
"marked": "^11.1.1",
"lunr": "^2.3.9",
"js-yaml": "^4.1.0"
},
"devDependencies": {
"jest": "^29.6.1",
"puppeteer": "^24.12.1"
},
"license": "MIT",
"bin": {
"create-archivox": "./bin/create-archivox.js"
}
}

View File

@@ -0,0 +1,7 @@
module.exports = {
onPageRendered: async ({ html, file }) => {
// Example: inject analytics script into each page
const snippet = '\n<script>console.log("Page viewed: ' + file + '")</script>';
return { html: html.replace('</body>', `${snippet}</body>`) };
}
};

View File

@@ -0,0 +1,70 @@
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key])
) {
target[key] = deepMerge(target[key] || {}, source[key]);
} else if (source[key] !== undefined) {
target[key] = source[key];
}
}
return target;
}
function loadConfig(configPath = path.join(process.cwd(), 'config.yaml')) {
let raw = {};
if (fs.existsSync(configPath)) {
try {
raw = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
} catch (e) {
console.error(`Failed to parse ${configPath}: ${e.message}`);
process.exit(1);
}
}
const defaults = {
site: {
title: 'Archivox',
description: '',
logo: '',
favicon: ''
},
navigation: {
search: true
},
footer: {},
theme: {
name: 'minimal',
darkMode: false
},
features: {},
pluginsDir: 'plugins',
plugins: []
};
const config = deepMerge(defaults, raw);
const errors = [];
if (
!config.site ||
typeof config.site.title !== 'string' ||
!config.site.title.trim()
) {
errors.push('site.title is required in config.yaml');
}
if (errors.length) {
errors.forEach(err => console.error(`Config error: ${err}`));
process.exit(1);
}
return config;
}
module.exports = loadConfig;

View File

@@ -0,0 +1,24 @@
const path = require('path');
const fs = require('fs');
function loadPlugins(config) {
const dir = path.resolve(process.cwd(), config.pluginsDir || 'plugins');
const names = Array.isArray(config.plugins) ? config.plugins : [];
const plugins = [];
for (const name of names) {
const file = path.join(dir, name.endsWith('.js') ? name : `${name}.js`);
if (fs.existsSync(file)) {
try {
const mod = require(file);
plugins.push(mod);
} catch (e) {
console.error(`Failed to load plugin ${name}:`, e);
}
} else {
console.warn(`Plugin not found: ${file}`);
}
}
return plugins;
}
module.exports = loadPlugins;

235
docs/src/generator/index.js Normal file
View File

@@ -0,0 +1,235 @@
// Generator entry point for Archivox
const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
const lunr = require('lunr');
const marked = require('marked');
const { lexer } = marked;
const loadConfig = require('../config/loadConfig');
const loadPlugins = require('../config/loadPlugins');
function formatName(name) {
return name
.replace(/^\d+[-_]?/, '')
.replace(/\.md$/, '');
}
async function readDirRecursive(dir) {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const res = path.resolve(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await readDirRecursive(res));
} else {
files.push(res);
}
}
return files;
}
function buildNav(pages) {
const tree = {};
for (const page of pages) {
const rel = page.file.replace(/\\/g, '/');
if (rel === 'index.md') {
if (!tree.children) tree.children = [];
tree.children.push({
name: 'index.md',
children: [],
page: page.data,
path: `/${rel.replace(/\.md$/, '.html')}`,
order: page.data.order || 0
});
continue;
}
const parts = rel.split('/');
let node = tree;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isLast = i === parts.length - 1;
const isIndex = isLast && part === 'index.md';
if (isIndex) {
node.page = page.data;
node.path = `/${rel.replace(/\.md$/, '.html')}`;
node.order = page.data.order || 0;
break;
}
if (!node.children) node.children = [];
let child = node.children.find(c => c.name === part);
if (!child) {
child = { name: part, children: [] };
node.children.push(child);
}
node = child;
if (isLast) {
node.page = page.data;
node.path = `/${rel.replace(/\.md$/, '.html')}`;
node.order = page.data.order || 0;
}
}
}
function finalize(node, isRoot = false) {
if (node.page && node.page.title) {
node.displayName = node.page.title;
} else if (node.name) {
node.displayName = formatName(node.name);
}
if (node.children) {
node.children.forEach(c => finalize(c));
node.children.sort((a, b) => {
const orderDiff = (a.order || 0) - (b.order || 0);
if (orderDiff !== 0) return orderDiff;
return (a.displayName || '').localeCompare(b.displayName || '');
});
node.isSection = node.children.length > 0;
} else {
node.isSection = false;
}
if (isRoot && node.children) {
const idx = node.children.findIndex(c => c.name === 'index.md');
if (idx > 0) {
const [first] = node.children.splice(idx, 1);
node.children.unshift(first);
}
}
}
finalize(tree, true);
return tree.children || [];
}
async function generate({ contentDir = 'content', outputDir = '_site', configPath } = {}) {
const config = loadConfig(configPath);
const plugins = loadPlugins(config);
async function runHook(name, data) {
for (const plugin of plugins) {
if (typeof plugin[name] === 'function') {
const res = await plugin[name](data);
if (res !== undefined) data = res;
}
}
return data;
}
if (!fs.existsSync(contentDir)) {
console.error(`Content directory not found: ${contentDir}`);
return;
}
const files = await readDirRecursive(contentDir);
const pages = [];
const assets = [];
const searchDocs = [];
for (const file of files) {
const rel = path.relative(contentDir, file);
if (file.endsWith('.md')) {
const srcStat = await fs.promises.stat(file);
const outPath = path.join(outputDir, rel.replace(/\.md$/, '.html'));
if (fs.existsSync(outPath)) {
const outStat = await fs.promises.stat(outPath);
if (srcStat.mtimeMs <= outStat.mtimeMs) {
continue; // skip unchanged
}
}
let raw = await fs.promises.readFile(file, 'utf8');
const mdObj = await runHook('onParseMarkdown', { file: rel, content: raw });
if (mdObj && mdObj.content) raw = mdObj.content;
const parsed = matter(raw);
const tokens = lexer(parsed.content || '');
const firstHeading = tokens.find(t => t.type === 'heading');
const title = parsed.data.title || (firstHeading ? firstHeading.text : path.basename(rel, '.md'));
const headings = tokens.filter(t => t.type === 'heading').map(t => t.text).join(' ');
const htmlBody = require('marked').parse(parsed.content || '');
const bodyText = htmlBody.replace(/<[^>]+>/g, ' ');
pages.push({ file: rel, data: { ...parsed.data, title } });
searchDocs.push({ id: rel.replace(/\.md$/, '.html'), url: '/' + rel.replace(/\.md$/, '.html'), title, headings, body: bodyText });
} else {
assets.push(rel);
}
}
const nav = buildNav(pages);
await fs.promises.mkdir(outputDir, { recursive: true });
await fs.promises.writeFile(path.join(outputDir, 'navigation.json'), JSON.stringify(nav, null, 2));
await fs.promises.writeFile(path.join(outputDir, 'config.json'), JSON.stringify(config, null, 2));
const searchIndex = lunr(function() {
this.ref('id');
this.field('title');
this.field('headings');
this.field('body');
searchDocs.forEach(d => this.add(d));
});
await fs.promises.writeFile(
path.join(outputDir, 'search-index.json'),
JSON.stringify({ index: searchIndex.toJSON(), docs: searchDocs }, null, 2)
);
const nunjucks = require('nunjucks');
const env = new nunjucks.Environment(
new nunjucks.FileSystemLoader('templates')
);
env.addGlobal('navigation', nav);
env.addGlobal('config', config);
for (const page of pages) {
const outPath = path.join(outputDir, page.file.replace(/\.md$/, '.html'));
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
const srcPath = path.join(contentDir, page.file);
const raw = await fs.promises.readFile(srcPath, 'utf8');
const { content, data } = matter(raw);
const body = require('marked').parse(content);
const pageContext = {
title: data.title || page.data.title,
content: body,
page: { url: '/' + page.file.replace(/\.md$/, '.html') }
};
let html = env.render('layout.njk', pageContext);
const result = await runHook('onPageRendered', { file: page.file, html });
if (result && result.html) html = result.html;
await fs.promises.writeFile(outPath, html);
}
for (const asset of assets) {
const srcPath = path.join(contentDir, asset);
const destPath = path.join(outputDir, asset);
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
try {
const sharp = require('sharp');
if (/(png|jpg|jpeg)/i.test(path.extname(asset))) {
await sharp(srcPath).toFile(destPath);
continue;
}
} catch (e) {
// sharp not installed, fallback
}
await fs.promises.copyFile(srcPath, destPath);
}
// Copy the main assets directory (theme, js, etc.)
// Always resolve assets relative to the Archivox package so it works
// regardless of the current working directory or config location.
const mainAssetsSrc = path.resolve(__dirname, '../../assets');
const mainAssetsDest = path.join(outputDir, 'assets');
if (fs.existsSync(mainAssetsSrc)) {
console.log(`Copying main assets from ${mainAssetsSrc} to ${mainAssetsDest}`);
// Use fs.promises.cp for modern Node.js, it's like `cp -R`
await fs.promises.cp(mainAssetsSrc, mainAssetsDest, { recursive: true });
}
}
module.exports = { generate, buildNav };
if (require.main === module) {
generate().catch(err => {
console.error(err);
process.exit(1);
});
}

6
docs/starter/config.yaml Normal file
View File

@@ -0,0 +1,6 @@
site:
title: "Archivox Docs"
description: "Simple static docs."
navigation:
search: true

View File

@@ -0,0 +1,3 @@
# Install
Run `npm install` then `npm run build` to generate your site.

View File

@@ -0,0 +1,3 @@
# Getting Started
This section helps you begin with Archivox.

View File

@@ -0,0 +1,3 @@
# Welcome to Archivox
This is your new documentation site. Start editing files in the `content/` folder.

11
docs/starter/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "my-archivox-site",
"private": true,
"scripts": {
"dev": "eleventy --serve",
"build": "node node_modules/archivox/src/generator/index.js"
},
"dependencies": {
"archivox": "*"
}
}

23
docs/templates/layout.njk vendored Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en" data-theme="{% if config.theme.darkMode %}dark{% else %}light{% endif %}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title | default(config.site.title) }}</title>
<link rel="stylesheet" href="/assets/theme.css" />
</head>
<body>
{% include "partials/header.njk" %}
<div id="sidebar-overlay" class="sidebar-overlay"></div>
<div class="container">
{% include "partials/sidebar.njk" %}
<main id="content">
<nav id="breadcrumbs" class="breadcrumbs"></nav>
{{ content | safe }}
</main>
</div>
{% include "partials/footer.njk" %}
<script src="/assets/lunr.js"></script>
<script src="/assets/theme.js"></script>
</body>
</html>

14
docs/templates/partials/footer.njk vendored Normal file
View File

@@ -0,0 +1,14 @@
<footer class="footer">
{% if config.footer.links %}
<nav class="footer-links">
{% for link in config.footer.links %}
<a href="{{ link.url }}">{{ link.text }}</a>
{% endfor %}
</nav>
{% endif %}
<p>&copy; {{ config.site.title }}</p>
<div class="footer-permanent-links">
<a href="https://github.com/PR0M3TH3AN/Archivox">GitHub</a>
<a href="https://nostrtipjar.netlify.app/?n=npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx">Tip Jar</a>
</div>
</footer>

7
docs/templates/partials/header.njk vendored Normal file
View File

@@ -0,0 +1,7 @@
<header class="header">
<button id="sidebar-toggle" class="sidebar-toggle" aria-label="Toggle navigation">☰</button>
<a href="/" class="logo">{{ config.site.title }}</a>
<input id="search-input" class="search-input" type="search" placeholder="Search..." aria-label="Search" />
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode">🌓</button>
<div id="search-results" class="search-results"></div>
</header>

29
docs/templates/partials/sidebar.njk vendored Normal file
View File

@@ -0,0 +1,29 @@
{% macro renderNav(items, pageUrl) %}
<ul>
{% for item in items %}
<li>
{% if item.children and item.children.length %}
{% set sectionPath = item.path | replace('index.html', '') %}
<details class="nav-section" {% if pageUrl.startsWith(sectionPath) %}open{% endif %}>
<summary>
<a href="{{ item.path }}" class="nav-link{% if item.path === pageUrl %} active{% endif %}">
{{ item.displayName or item.page.title }}
</a>
</summary>
{{ renderNav(item.children, pageUrl) }}
</details>
{% else %}
<a href="{{ item.path }}" class="nav-link{% if item.path === pageUrl %} active{% endif %}">
{{ item.displayName or item.page.title }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
<aside class="sidebar" id="sidebar">
<nav>
{{ renderNav(navigation, page.url) }}
</nav>
</aside>