Compare commits

...

10 Commits

14 changed files with 876 additions and 256 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
dist-vault
*.local
# Editor directories and files

View File

@@ -3,14 +3,13 @@ description: an example web application
language: en
vault:
root: public
cleanup: true
view:
index:
notfound:
type: file
path: /path/to/vault
metadata:
hidden:
- example
- foo/bar.md
routes:
index:
error:

721
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"clean": "rm -rf ./dist",
"clean": "rm -rf ./dist*",
"update": "node ./src/scripts/sprachbund.js",
"preview": "vite preview"
},
@@ -14,6 +14,7 @@
"@tailwindcss/typography": "^0.5.15",
"autoprefixer": "^10.4.20",
"front-matter": "^4.0.2",
"minio": "^8.0.2",
"mustache": "^4.2.0",
"postcss": "^8.4.45",
"tailwindcss": "^3.4.10",

2
public/.gitignore vendored
View File

@@ -1,2 +0,0 @@
*
!.gitignore

View File

@@ -13,7 +13,7 @@
<body class="antialiased md:subpixel-antialiased">
<div x-data="app" class="relative flex flex-col md:flex-row lg:justify-center justify-normal">
<div class="2xl:min-w-80 min-w-72 min-h-12 max-h-screen">
<div x-data="{ open: false }" x-init="router.on('ready', () => open = false)"
<div x-data="{ open: false }" x-init="router.on('active', () => open = false)"
class="flex flex-col fixed 2xl:w-80 md:w-72 size-full md:border-r border-0 pointer-events-none z-20"
>
<div class="md:block flex items-center gap-x-4 px-5 md:pt-8 py-3 md:border-0 border-b bg-white pointer-events-auto select-none">

View File

@@ -6,6 +6,7 @@ export const PATH = {};
PATH.CONFIG = 'config.yaml';
PATH.OUTPUT = 'dist';
PATH.OBJECT = `${NATIVE ? PATH.OUTPUT : ''}/.objects`;
PATH.S3TEMP = 'dist-vault';
PATH.OBJECT = `${NATIVE ? PATH.OUTPUT : ''}/_site`;
PATH.INDEX = `${PATH.OBJECT}/index`;
PATH.METADATA = `${PATH.OBJECT}/metadata`;

View File

@@ -1,6 +1,6 @@
import { Engine } from './search'
import { Index, State } from './object'
import router, { Routes } from './router'
import router from './router'
import zettelkasten from './zettelkasten'
import { marked } from 'marked'
@@ -60,6 +60,21 @@ export default () => ({
</div>
`;
}
fallback() {
this.value = `
<div class="flex size-full justify-center self-center">
<div class="flex flex-col items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-24 text-neutral-200">
<path fill-rule="evenodd" d="M4.5 2A1.5 1.5 0 0 0 3 3.5v13A1.5 1.5 0 0 0 4.5 18h11a1.5 1.5 0 0 0 1.5-1.5V7.621a1.5 1.5 0 0 0-.44-1.06l-4.12-4.122A1.5 1.5 0 0 0 11.378 2H4.5Zm2.25 8.5a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5h-6.5Zm0 3a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5h-6.5Z" clip-rule="evenodd" />
</svg>
<div class="font-semibold">
NOT FOUND
</div>
</div>
</div>
`;
}
},
graph: new class extends State {
@@ -68,10 +83,10 @@ export default () => ({
<div class="uppercase text-sm font-semibold mb-3">Interactive graph</div>
<div x-data="{ expand: false,
anchor: $el.parentElement,
destroy: () => $el.remove(),
remove: () => $el.remove(),
collapse: () => { $data.expand = false;
$data.anchor.appendChild($el);
$data.toggleGlobal(false)
$data.toggleGlobal(false);
},
toggleGlobal: (value) => router.emit('graphview toggle global', { value })
}"
@@ -81,7 +96,7 @@ export default () => ({
<div class="relative rounded-md border size-full p-1 bg-white"
x-data="{ ready: false }"
x-init="if (graph.value?.nodeType) { ready = true; $el.appendChild(graph.value) }"
@click="if (expand && $el.children.length === 1) { destroy(); toggleGlobal(false) }"
@click="if ($el.children.length < 4) { remove(); toggleGlobal(false) }"
>
<template x-if="ready">
<div class="flex items-center gap-x-1 mr-1 mt-1 absolute top-0 right-0 z-20 text-neutral-600">
@@ -103,7 +118,7 @@ export default () => ({
</div>
</template>
<template x-if="!ready">
<div x-html="graph.init()" class="size-full">
<div x-html="graph.value" class="size-full">
</div>
</template>
</div>
@@ -137,22 +152,18 @@ export default () => ({
}
},
async init() {
init() {
this.content.transition(async () => {
this.index = await Index.fromPack();
this.router.start();
console.log(this.index);
});
Routes.index = () => {
let [url, view] = this.index.createView();
this.content.value = ''; // clear init state
this.graph.transition(async () => {
const { Engine } = await import('./graph');
let engine = Engine.fromIndex(this.index);
return engine.createInstance(url);
let engine = Engine.fromIndex(this.index);
let node = await engine.createInstance();
return node;
});
this.navigation.transition(() => {
@@ -163,20 +174,20 @@ export default () => ({
return tree.render(false);
});
this.content.transition(() => {
return view?.render() ?? '';
this.router.start(this.index.metadata.routes);
});
return url;
this.router.route = (path) => {
let view = this.index.getObject(path);
this.content.value = view?.render();
return Boolean(view);
}
Routes.other = path => {
this.content.transition(() => {
let [,view] = this.index.createView(path);
return view.render();
this.router.on('error', () => {
this.content.fallback();
});
}
marked.use(zettelkasten());
},
}
})

View File

@@ -48,20 +48,23 @@ export class Engine {
return new Engine(graph);
}
createInstance(url) {
async createInstance() {
let settings = { allowInvalidContainer: true };
let container = document.createElement('div');
container.style.width = '100%';
container.style.height = '100%';
let nodes = this.graph.nodes().length;
let delay = nodes * 20;
this.instance = new Sigma(this.graph, container, settings);
this.setupInteraction();
this.setupGraphStyle();
this.setupGraphStyle(delay);
let camera = this.instance.getCamera();
router.emit('retrieve', { path: url });
camera.animate({ ratio: 1 }, { easing: "linear", duration: 200 });
await new Promise((resolve) => {
setTimeout(() => resolve(), delay);
});
return container;
}
@@ -136,7 +139,7 @@ export class Engine {
});
}
setupGraphStyle() {
setupGraphStyle(delay) {
let localNodes = [];
let localEdges = [];
let showGlobal = false;
@@ -146,7 +149,7 @@ export class Engine {
if (value) localEdges = this.graph.edges();
});
router.on('retrieve', ({ path }) => {
let event = router.on('retrieve', ({ path }) => {
let cache = this.instance.nodeDataCache;
if (!(path in cache)) {
@@ -165,6 +168,8 @@ export class Engine {
localEdges = localNodes.reduce((r, n) => r.concat(this.graph.edges(n)), []);
});
setTimeout(() => event.recall(), delay);
let activeNodes = [];
let activeEdges = [];

View File

@@ -53,9 +53,8 @@ class BaseNode extends BaseObject {
}
export class Index extends BaseObject {
constructor(view, metadata, object, links = {}) {
constructor(metadata, object, links = {}) {
super();
this.view = view;
this.metadata = metadata;
this.object = object;
this.links = links;
@@ -64,13 +63,7 @@ export class Index extends BaseObject {
static Metadata(x) {
return {
hidden: x?.hidden,
}
}
static View(x) {
return {
index: x?.index,
notfound: x?.notfound
routes: x?.routes,
}
}
@@ -151,27 +144,6 @@ export class Index extends BaseObject {
return [root, searchable];
}
createView(path = '') {
let entry = this.getObject(path);
let view = this.view;
if (! path) {
path = view.index ?? '';
entry = this.getObject(path);
if (!entry) return [path, Document.Blank()];
}
if (! entry) {
path = view.notfound ?? '';
entry = this.getObject(path);
if (!entry) return [path, Document.NotFound()];
}
return [path, entry];
}
}
export class Folder extends BaseNode {
@@ -211,8 +183,8 @@ export class Folder extends BaseNode {
data-[active=true]:text-red-600 hover:text-neutral-950
data-[active=true]:border-red-600 hover:border-neutral-800"
>
<a class="truncate" href="/${node.path}"
@click="router.emit('navigate'); router.from($event)"
<a class="truncate md:text-base text-lg" href="/${node.path}"
@click="navigate = true; router.from($event)"
>${node.name}</a>
</div>
`;
@@ -227,9 +199,11 @@ export class Folder extends BaseNode {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" :class="open ? 'rotate-90' : ''">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
<span class="md:order-last order-first">${this.name}</span>
<span class="md:order-last order-first md:text-base text-lg">
${this.name}
</span>
</div>
<div x-show="open" style="--max: ${maxHeight}px" class="flex flex-col md:ml-4 ml-1 mb-1 md:pr-0 pr-8 overflow-hidden transition-[max-height]"
<div x-show="open" style="--max: ${maxHeight}px" class="flex flex-col md:ml-4 ml-1 mb-1 md:pr-0 pr-1.5 overflow-hidden transition-[max-height]"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="max-h-0"
x-transition:enter-end="max-h-[var(--max)]"
@@ -242,13 +216,14 @@ export class Folder extends BaseNode {
return `
<div x-data="{ active: null, navigate: false }"
x-init="router.on('ready', ({ url }) => {
let isFromNav = navigate;
x-init="let react = () => {
let path = window.location.pathname;
active?.setAttribute('data-active', false);
active = $el.querySelector(\`[data-path=&quot;\${url}&quot;]\`);
active = $el.querySelector(\`[data-path=&quot;\${path}&quot;]\`);
active?.setAttribute('data-active', true);
if (isFromNav) return;
if (navigate) return navigate = false;
let node = active;
if (node) while ((node = node.parentElement) !== $el) {
@@ -258,11 +233,11 @@ export class Folder extends BaseNode {
}
setTimeout(() => active?.scrollIntoView(
{ behavior: 'smooth', block: 'center' }), 150);
};
router.on('ready', () => {
$nextTick(() => react());
});
router.on('navigate', () => {
navigate = true;
setTimeout(() => navigate = false, 100);
})"
$nextTick(() => react())"
>${html}</div>
`;
}
@@ -278,29 +253,6 @@ export class Document extends BaseNode {
this.links = [];
}
static NotFound() {
return {
render: () => `
<div class="flex size-full justify-center self-center">
<div class="flex flex-col items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-24 text-neutral-200">
<path fill-rule="evenodd" d="M4.5 2A1.5 1.5 0 0 0 3 3.5v13A1.5 1.5 0 0 0 4.5 18h11a1.5 1.5 0 0 0 1.5-1.5V7.621a1.5 1.5 0 0 0-.44-1.06l-4.12-4.122A1.5 1.5 0 0 0 11.378 2H4.5Zm2.25 8.5a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5h-6.5Zm0 3a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5h-6.5Z" clip-rule="evenodd" />
</svg>
<div class="font-semibold">
NOT FOUND
</div>
</div>
</div>
`
}
}
static Blank() {
return {
render: () => ''
}
}
fullname() {
return this.name + '.md';
}
@@ -312,13 +264,7 @@ export class Document extends BaseNode {
let title = this.metadata.title ?? this.name;
document.title = `${title} - ${appName}`;
return `
<div class="flex flex-col w-full md:mx-12 m-6 md:mt-8">
<div class="font-bold text-4xl mb-6">${title}</div>
<div x-ref="page" class="prose prose-neutral md:max-w-prose max-w-none w-full">
${marked(this.content)}
</div>
</div>
let sidebar = () => `
<div class="lg:block hidden 2xl:min-w-80 max-w-72 w-full max-h-screen">
<div class="flex flex-col fixed gap-y-8 pt-6 2xl:w-80 w-72 select-none">
<!-- Interactive Graph -->
@@ -352,6 +298,20 @@ export class Document extends BaseNode {
</div>
</div>
`;
return `
<div x-init="$nextTick(() => router.emit('ready'))"
class="flex flex-col w-full md:mx-12 m-6 md:mt-8"
>
<div class="font-bold text-4xl mb-6">${title}</div>
<div x-ref="page" class="prose prose-neutral max-w-none w-full
${this.metadata['no_sidebar'] ? '' : 'md:max-w-prose'}"
>
${marked(this.content)}
</div>
</div>
${this.metadata['no_sidebar'] ? '' : sidebar()}
`;
}
inline(attributes = {}) {

View File

@@ -1,24 +1,34 @@
export const Routes = {
index() {},
other() {}
const Routes = {
index: null,
error: null
}
const Events = [
/* eventName: [callbacks], */
]
const Events = {
entries: { /* eventName: [callbacks], */ },
history: [ /* { eventName, params, }, */ ],
}
export default {
react() {
let url = window.location.pathname;
if (url === '/') {
url = encodeURI(Routes.index());
history.replaceState({}, '', url);
} else {
Routes.other(decodeURI(url));
let path = decodeURI(window.location.pathname);
if (path === '/') {
if (! Routes.index) return;
history.replaceState({}, '', Routes.index);
return this.react();
}
this.emit('ready', { url });
if (! this.route(path)) {
if (! Routes.error) this.emit('error');
else this.goto(Routes.error);
}
this.emit('active');
},
start() {
route(path) {
return true;
},
start(routes) {
Object.assign(Routes, routes);
window.addEventListener("popstate", () => this.react());
this.react();
},
@@ -31,11 +41,26 @@ export default {
history.pushState({}, '', base + url);
this.react();
},
emit(event, params) {
Events[event]?.forEach(fn => fn(params));
emit(eventName, params) {
Events.history.unshift({ eventName, params });
Events.history.length = Math.min(Events.history.length, 50);
Events.entries[eventName]?.forEach(fn => fn(params));
},
on(event, callback) {
if (! Events[event]) Events[event] = [];
Events[event]?.push(callback);
on(eventName, callback) {
if (! Events.entries[eventName]) Events.entries[eventName] = [];
Events.entries[eventName]?.push(callback);
return {
recall: () => {
for (let e of Events.history)
if (e.eventName === eventName)
return callback(e.params);
},
cancel: () => {
let callbacks = Events.entries[eventName];
this.on(eventName, () => Events.entries[eventName] = callbacks);
}
};
}
}

View File

@@ -25,12 +25,19 @@ export class Engine {
template(namespace) {
return `
<div x-data="{ value: '', active: false }" class="relative mx-5 mt-6" @focusin="active = true" @focusout="active = false">
<div class="relative mx-5 mt-6 md:text-base text-xl"
x-data="{ value: '', active: false }"
@focusin="active = true" @focusout="active = false"
>
<div class="flex focus-within:ring-2 ring-neutral-300 transition w-full items-center rounded-md border pl-1 py-0.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 text-neutral-400">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z" clip-rule="evenodd" />
</svg>
<input type="text" x-model="value" class="w-full px-2 placeholder:text-neutral-300 focus:outline-none" placeholder="Search page or heading...">
<input x-model="value"
class="w-full px-2 placeholder:text-neutral-300 placeholder:text-sm focus:outline-none"
type="text"
placeholder="Search page or heading..."
>
</div>
<div x-show="active && value" class="absolute rounded-md border bg-white left-0 top-0 mt-9 min-w-48 max-h-96 shadow-xl overflow-y-auto overscroll-contain">
<div x-data="{ results: [] }" x-effect="results = ${namespace}.search(value)" class="empty:hidden flex flex-col p-1.5">
@@ -51,7 +58,7 @@ export class Engine {
generateHTML(result) {
return `
<button class="flex flex-col size-full gap-y-1.5 text-start rounded-md hover:bg-neutral-200 p-2"
@click="router.goto(&quot;${result.path}&quot;); active = false"
@click="$nextTick(() => active = false); router.goto(&quot;${result.path}&quot;)"
>
<span>${result.name}</span>
<span class="text-xs text-neutral-500"

View File

@@ -2,12 +2,13 @@ import { PATH } from '../paths.js'
import { transform } from './zettelkasten.js'
import { Index, Attachment, Document } from './object.js'
import { createHash } from 'node:crypto'
import { Client } from 'minio'
import { createHash } from 'crypto'
import { encode, decode } from '@msgpack/msgpack'
import Mustache from 'mustache'
import YAML from 'yaml'
import fs from 'node:fs'
import fs from 'fs'
import fm from'front-matter'
@@ -20,8 +21,8 @@ function walkSourceDir([root, parent], callback) {
let filenames = fs.readdirSync(base);
for (let name of filenames) {
// omit filenames starting with '.'
if (name.startsWith(".")) continue;
// omit filenames starting with '.' or '_'
if (name.startsWith(".") || name.startsWith('_')) continue;
let path = [base, name].join('/');
let relpath = [parent, name].filter(Boolean).join('/');
@@ -63,7 +64,9 @@ function bisect(filename) {
}
function buildMdDocs(index, ctx) /* always return falsy values */ {
let { attributes, body } = fm(ctx.content);
let content = fs.readFileSync(ctx.path, 'utf8');
let { attributes, body } = fm(content);
let pathname = [ctx.parent, ctx.short].filter(Boolean).join('/');
let text = transform.wikilink(body, href => {
@@ -116,10 +119,7 @@ function buildMdDocs(index, ctx) /* always return falsy values */ {
function buildObject(index, ctx) /* always return falsy values */ {
if (ctx.extension === 'md') {
let content = fs.readFileSync(ctx.path, 'utf8');
let context = { content, ...ctx };
return buildMdDocs(index, context);
return buildMdDocs(index, ctx);
}
let content = fs.readFileSync(ctx.path);
@@ -140,37 +140,25 @@ function buildObject(index, ctx) /* always return falsy values */ {
fs.writeFileSync(path, content);
}
function buildIndex(config, vault) {
let metadata = Index.Metadata(config["metadata"]);
let view = Index.View(config["view"]);
let index = new Index(view, metadata, {});
function buildIndex(metadata, vault) {
let index = new Index(metadata, {});
if (! fs.existsSync(PATH.INDEX)) {
// create index if not found
// or reuse it
fs.mkdirSync(PATH.OBJECT, { recursive: true });
walkSourceDir([config.vault.root], ctx => {
walkSourceDir([vault], ctx => {
buildObject(index, ctx);
});
if (config.vault.cleanup) {
fs.readdirSync(config.vault.root).forEach(name => {
if (name !== '.gitignore') {
let path = [config.vault.root, name].join('/');
fs.rmSync(path, { recursive: true, force: true });
}
});
}
return index;
}
let buffer = fs.readFileSync(PATH.INDEX);
let legacy = decode(buffer);
walkSourceDir([config.vault.root], ctx => {
walkSourceDir([vault], ctx => {
let path = ctx.path;
let entry = legacy.object[path];
// if no such legacy entry found, continue
@@ -200,6 +188,51 @@ function buildIndex(config, vault) {
return index;
}
function prepareVault(config) {
switch (config["type"]) {
case "minio":
fs.rmSync(PATH.S3TEMP, { recursive: true, force: true });
fs.mkdirSync(PATH.S3TEMP);
let bucket = config["bucket"];
let prefix = config["prefix"];
let client = new Client(config["client"]);
let stream = client.listObjectsV2(bucket, prefix, true);
stream.on('data', item => {
let name = item.name.replace(prefix, '');
let path = `${PATH.S3TEMP}/${name}`;
if (name.endsWith('/')) {
if (! fs.existsSync(path)) fs.mkdirSync(path);
return;
}
stream.pause();
console.log(item.name);
client.fGetObject(bucket, item.name, path)
.catch(err => { throw err })
.finally(() => stream.resume());
});
return new Promise((resolve, reject) => {
stream.on('close', () => resolve(PATH.S3TEMP));
stream.on('error', er => reject(er));
});
case "file":
let path = config["path"];
if (fs.lstatSync(path).isDirectory()) {
return new Promise((resolve) => resolve(path));
}
throw new Error(path + ": not a valid vault");
default:
throw new Error("invalid vault type");
}
}
if (process.argv[1] === import.meta.filename) {
// when running under node.js solely
sprachbund().writeBundle();
@@ -216,11 +249,11 @@ export default function sprachbund() {
language: CONFIG?.language,
});
},
writeBundle() {
let index = buildIndex(CONFIG);
let data = encode(index);
async writeBundle() {
let vault = await prepareVault(CONFIG.vault);
let index = buildIndex(CONFIG.metadata, vault);
fs.writeFileSync(PATH.INDEX, data);
fs.writeFileSync(PATH.INDEX, encode(index));
},
}
}

View File

@@ -5,7 +5,7 @@ export default defineConfig({
root: 'src',
build: {
outDir: '../dist',
assetsDir: '.assets',
assetsDir: '_assets',
emptyOutDir: true
},
plugins: [