initial commit

This commit is contained in:
Break27 2024-09-19 22:19:43 +08:00
commit d14e1a9ed1
21 changed files with 5155 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
config.yaml Normal file
View File

@ -0,0 +1,16 @@
name: Sprachbund
description: an example web application
language: en
vault:
root: public
cleanup: true
view:
index:
notfound:
metadata:
hidden:
- example
- foo/bar.md

11
netlify.toml Normal file
View File

@ -0,0 +1,11 @@
[build]
publish = "dist/"
command = "npm run build"
[build.processing.html]
pretty_urls = true
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

3451
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "sprachbund",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"clean": "rm -rf ./dist",
"update": "node ./src/scripts/sprachbund.js",
"preview": "vite preview"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.15",
"ali-oss": "^6.21.0",
"autoprefixer": "^10.4.20",
"front-matter": "^4.0.2",
"mustache": "^4.2.0",
"postcss": "^8.4.45",
"tailwindcss": "^3.4.10",
"vite": "^5.4.1",
"yaml": "^2.5.1"
},
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"alpinejs": "^3.14.1",
"graphology": "^0.25.4",
"graphology-layout": "^0.6.1",
"graphology-layout-force": "^0.2.4",
"marked": "^14.1.1",
"minisearch": "^7.1.0",
"sigma": "^3.0.0-beta.29"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

2
public/.gitignore vendored Normal file
View File

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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

After

Width:  |  Height:  |  Size: 995 B

3
src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

43
src/index.html Normal file
View File

@ -0,0 +1,43 @@
<!doctype html>
<html lang="{{ language }}">
<head>
<meta charset="UTF-8" />
<link href="/index.css" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="/assets/javascript.svg" />
<meta name="application-name" content="{{ name }}" />
<meta name="description" content="{{ description }}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script defer type="module" src="/index.js"></script>
<title>{{ name }}</title>
</head>
<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)"
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">
<button class="md:hidden block" @click="open = !open">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M2 4.75A.75.75 0 0 1 2.75 4h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 4.75ZM2 10a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 10Zm0 5.25a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" />
</svg>
</button>
<div class="md:text-xl text-lg font-semibold">{{ name }}</div>
<div class="italic text-neutral-400 md:block hidden">{{ description }}</div>
</div>
<div class="bg-white pointer-events-auto -translate-x-full z-10"
:class="open ? 'translate-x-0 transition' : 'md:translate-x-0 transition'"
><div x-html="search"></div>
</div>
<div class="bg-white pointer-events-auto -translate-x-full overflow-y-auto overscroll-contain h-full"
:class="open ? 'translate-x-0 transition' : 'md:translate-x-0 transition'"
><div x-html="navigation"></div>
</div>
</div>
</div>
<div x-html="content"
class="flex max-w-5xl min-h-screen size-full"
></div>
</div>
</body>
</html>

8
src/index.js Normal file
View File

@ -0,0 +1,8 @@
import app from './scripts/app'
import Alpine from 'alpinejs'
window.Alpine = Alpine;
Alpine.data('app', app);
Alpine.start();

11
src/paths.js Normal file
View File

@ -0,0 +1,11 @@
// determine if this code is running locally
// (under node.js)
const NATIVE = typeof window === 'undefined'
// paths config
export const PATH = {};
PATH.CONFIG = 'config.yaml';
PATH.OUTPUT = 'dist';
PATH.OBJECT = `${NATIVE ? PATH.OUTPUT : ''}/.objects`;
PATH.INDEX = `${PATH.OBJECT}/index`;
PATH.METADATA = `${PATH.OBJECT}/metadata`;

164
src/scripts/app.js Normal file
View File

@ -0,0 +1,164 @@
import { Engine } from './search'
import { Index, State } from './object'
import router, { Routes } from './router'
import zettelkasten from './zettelkasten'
import { marked } from 'marked'
export default () => ({
router,
index: null,
navigation: new class extends State {
toString() {
return `
<div class="flex flex-col size-full px-5 pt-3 pb-8 text-neutral-600">
${this.value}
</div>
`;
}
fail() {
return `
<div class="flex size-full justify-center 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="m5.965 4.904 9.131 9.131a6.5 6.5 0 0 0-9.131-9.131Zm8.07 10.192L4.904 5.965a6.5 6.5 0 0 0 9.131 9.131ZM4.343 4.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 4.343 4.343Z" clip-rule="evenodd" />
</svg>
</div>
`;
}
},
search: new class extends State {
constructor() {
super();
this.engine = Engine.Default();
}
toString() {
return this.engine.template('search.engine');
}
},
content: new class extends State {
init() {
return `
<div class="flex size-full justify-center self-center">
<svg class="animate-spin size-6 text-gray-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
`;
}
fail() {
return `
<div class="xl:text-9xl lg:text-8xl md:text-7xl text-6xl text-neutral-200 pt-4 pl-6 select-none">
An unexpected error occurred.
</div>
`;
}
},
graph: new class extends State {
toString() {
return `
<div class="uppercase text-sm font-semibold mb-3">Interactive graph</div>
<div x-data="{ expand: false,
anchor: $el.parentElement,
destroy: () => $el.remove(),
collapse: () => { $data.expand = false;
$data.anchor.appendChild($el);
$data.toggleGlobal(false)
},
toggleGlobal: (value) => router.emit('graphview toggle global', { value })
}"
x-effect="if (expand) document.querySelector('body').firstElementChild.appendChild($el)"
:class="expand ? 'fixed flex size-full max-h-screen py-10 px-24 z-50' : '2xl:size-72 size-64'"
>
<div class="relative rounded-md border size-full p-1 bg-white"
x-init="graph.value?.nodeType ? $el.appendChild(graph.value) : $el.innerHTML = graph.value"
@click="if (expand && $el.children.length === 1) { destroy(); toggleGlobal(false) }"
>
<div class="flex items-center gap-x-1 mr-1 mt-1 absolute top-0 right-0 z-20 text-neutral-600">
<button x-show="expand" class="hover:text-red-500" @click="collapse()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</button>
<button x-show="!expand" class="hover:text-red-500" @click="expand = true; toggleGlobal(true)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path d="M13 4.5a2.5 2.5 0 1 1 .702 1.737L6.97 9.604a2.518 2.518 0 0 1 0 .792l6.733 3.367a2.5 2.5 0 1 1-.671 1.341l-6.733-3.367a2.5 2.5 0 1 1 0-3.475l6.733-3.366A2.52 2.52 0 0 1 13 4.5Z" />
</svg>
</button>
<button x-show="!expand" class="hover:text-red-500" @click="expand = true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
<path fill-rule="evenodd" d="M5.22 14.78a.75.75 0 0 0 1.06 0l7.22-7.22v5.69a.75.75 0 0 0 1.5 0v-7.5a.75.75 0 0 0-.75-.75h-7.5a.75.75 0 0 0 0 1.5h5.69l-7.22 7.22a.75.75 0 0 0 0 1.06Z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<template x-if="expand">
<div class="absolute inset-0 size-full bg-neutral-400 opacity-25 -z-20" @click="collapse()">
</div>
</template>
</div>
`;
}
fail() {
return `
<div class="flex size-full justify-center 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="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
</svg>
</div>
`;
}
},
async 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.graph.transition(async () => {
const { Engine } = await import('./graph');
let engine = Engine.fromIndex(this.index);
return engine.createInstance();
});
this.navigation.transition(() => {
let [tree, searchable] = this.index.createTree();
let search = this.search.engine.getInstance();
search.addAllAsync(searchable);
return tree.render(false);
});
this.content.transition(() => {
return view?.render() ?? '';
});
return url;
}
Routes.other = path => {
this.content.transition(() => {
let [,view] = this.index.createView(path);
return view.render();
});
}
marked.use(zettelkasten());
},
})

204
src/scripts/graph.js Normal file
View File

@ -0,0 +1,204 @@
import router from './router'
import { Sigma } from 'sigma'
import Graph from 'graphology'
import random from 'graphology-layout/random'
import ForceSupervisor from 'graphology-layout-force/worker'
export class Engine {
constructor(graph) {
this.graph = graph;
this.layout = null;
this.instance = null;
}
static fromIndex(index) {
let graph = new Graph();
let links = Object.entries(index.links);
for (let [src, targets] of links) {
let source = index.object[src];
let label = source?.name ?? src;
let path = source?.path ?? src;
if (! graph.hasNode(src)) {
graph.addNode(src, { label, path });
}
for (let dest of targets) {
let target = index.object[dest];
let label = target?.name ?? dest;
let path = target?.path ?? dest;
if (! graph.hasNode(dest)) {
graph.addNode(dest, { label, path });
}
if (! graph.hasEdge(src, dest)) {
graph.addEdge(src, dest);
}
}
}
random.assign(graph);
return new Engine(graph);
}
createInstance() {
let settings = { allowInvalidContainer: true };
let container = document.createElement('div');
container.style.width = '100%';
container.style.height = '100%';
this.instance = new Sigma(this.graph, container, settings);
this.setupInteraction();
this.setupGraphStyle();
return container;
}
setupInteraction() {
let params = { isNodeFixed: (_, attr) => attr.highlighted };
this.layout = new ForceSupervisor(this.graph, params);
this.layout.start();
// State for drag'n'drop
let activeNode = null;
let isDragging = false;
let preventHop = false;
let timeout = 100;
// On mouse down on a node
// - we enable the drag mode
// - save in the dragged node in the state
// - highlight the node
// - disable the camera so its state is not updated
this.instance.on("downNode", (e) => {
isDragging = true;
activeNode = e.node;
this.graph.setNodeAttribute(activeNode, "highlighted", true);
// prevent hopping to another node
// when timeout is reached
preventHop = false;
setTimeout(() => preventHop = true, timeout);
});
// On mouse move, if the drag mode is enabled, we change the position of the draggedNode
this.instance.getMouseCaptor().on("mousemovebody", (e) => {
if (!isDragging || !activeNode) return;
// Get new position of node
const pos = this.instance.viewportToGraph(e);
this.graph.setNodeAttribute(activeNode, "x", pos.x);
this.graph.setNodeAttribute(activeNode, "y", pos.y);
// Prevent sigma to move camera:
e.preventSigmaDefault();
e.original.preventDefault();
e.original.stopPropagation();
});
// On mouse up, we reset the autoscale and the dragging mode
this.instance.getMouseCaptor().on("mouseup", () => {
if (activeNode) {
this.graph.removeNodeAttribute(activeNode, "highlighted");
}
isDragging = false;
activeNode = null;
});
// Disable the autoscale at the first down interaction
this.instance.getMouseCaptor().on("mousedown", () => {
if (! this.instance.getCustomBBox()) {
this.instance.setCustomBBox(this.instance.getBBox())
}
});
// hopping - act as an link when not dragged
this.instance.on('clickNode', e => {
if (! preventHop) {
let path = this.graph.getNodeAttribute(e.node, 'path');
router.goto(path);
}
preventHop = false;
});
}
setupGraphStyle() {
let localNodes = [];
let localEdges = [];
let showGlobal = false;
router.on('graphview toggle global', ({ value }) => {
showGlobal = value;
if (value) localEdges = this.graph.edges();
});
router.on('retrieve', ({ path }) => {
let cache = this.instance.nodeDataCache;
if (!(path in cache)) {
localNodes = [];
localEdges = [];
return;
}
let camera = this.instance.getCamera();
let { x, y } = cache[path];
// panover currently visited node
camera.animate({ x, y, ratio: 0.075 }, { easing: "linear", duration: 500 });
localNodes = this.graph.neighbors(path).concat(path);
localEdges = localNodes.reduce((r, n) => r.concat(this.graph.edges(n)), []);
});
let activeNodes = [];
let activeEdges = [];
this.instance.on('enterNode', e => {
activeNodes = this.graph.neighbors(e.node) + e.node;
activeEdges = this.graph.edges(e.node);
this.instance.refresh({
partialGraph: { edges: activeEdges },
skipIndexation: true
});
});
this.instance.on('leaveNode', e => {
activeNodes = [];
activeEdges = [];
this.instance.refresh({
partialGraph: { edges: localEdges },
skipIndexation: true
});
});
this.instance.setSetting('nodeReducer', (node, data) => {
data.hidden = !showGlobal && !localNodes.includes(node);
data.color = activeNodes.includes(node) ? '#e9757c' : '#999';
return data;
});
this.instance.setSetting('edgeReducer', (edge, data) => {
data.color = activeEdges.includes(edge) ? '#e9757c' : '#999';
return data;
});
}
dispose() {
this.layout.kill();
this.instance.kill();
}
}

473
src/scripts/object.js Normal file
View File

@ -0,0 +1,473 @@
import { PATH } from '../paths.js';
import router from './router.js';
import { decode } from '@msgpack/msgpack'
import { marked } from 'marked'
export class State {
constructor() {
this.value = this.init();
}
toString() { return this.value }
init() { return '' }
fail() { return '' }
async transition(val) {
try {
this.value = this.init();
this.value = await val() ?? this.value;
} catch (e) {
console.error(e);
this.value = this.fail();
}
}
}
class BaseObject {
static fromObject(o) {
return Object.assign(new this(), o);
}
}
class BaseNode extends BaseObject {
constructor(type) {
super();
this.type = type;
}
fullname() { return this.name }
static fromObject(o) {
switch (o?.type) {
case 'Document':
return Object.assign(new Document(), o);
case 'Attachment':
return Object.assign(new Attachment(), o);
default:
throw new Error('invalid type');
}
}
}
export class Index extends BaseObject {
constructor(view, metadata, object, links = {}) {
super();
this.view = view;
this.metadata = metadata;
this.object = object;
this.links = links;
}
static Metadata(x) {
return {
hidden: x?.hidden,
}
}
static View(x) {
return {
index: x?.index,
notfound: x?.notfound
}
}
static async fromPack() {
let binary = await fetch(PATH.INDEX);
let buffer = await binary.arrayBuffer();
let data = decode(buffer);
return Index.fromObject(data);
}
unflatten() {
let result = [], level = { result };
let searchable = [];
let id = 0;
let buildWith = (entry) => (r, name, i, a) => {
if (! r[name]) {
r[name] = { result: [] };
if (i !== a.length - 1) {
// if this node is a Folder
let children = r[name].result;
let node = new Folder(name, children);
r.result.push(node);
} else {
let node = BaseNode.fromObject(entry);
r.result.push(node);
if (node.type === 'Document') {
node['id'] = id++;
searchable.push(node);
}
}
}
return r[name];
}
for (let [path, entry] of Object.entries(this.object)) {
path.split('/').reduce(buildWith(entry), level);
}
result.sort((a, b) => {
if (a.type === 'Folder' && b.type !== 'Folder') return -1;
return a.name.localeCompare(b.name);
});
return [result, searchable];
}
linkWith(src, target) {
if (! this.links[target]) this.links[target] = [];
this.links[target].push(src);
}
getObject(path) {
if (path.startsWith('/')) path = path.slice(1);
if (!(path in this.object)) path = path + '.md';
try { var o = BaseNode.fromObject(this.object[path]) }
catch (e) { return null }
router.emit('retrieve', { path });
return o;
}
createTree() {
let [tree, searchable] = this.unflatten();
let root = new Folder('root', tree);
let metadata = this.metadata;
for (let path of metadata.hidden) {
// find and remove hidden paths
root.find(path, (parent, i) => parent.splice(i, 1));
}
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 {
constructor(name, children) {
super('Folder');
this.name = name;
this.children = children;
}
find(path, callback) {
let name = path.split('/', 1)[0];
let next = path.slice(name.length + 1);
let index = 0;
for (let child of this.children) {
if (child.fullname() === name) {
return child.type === 'Folder' && next
? child.find(next, callback)
: callback(this.children, index);
}
index += 1;
}
}
render(wrap = true) {
let html = '';
for (let node of this.children) {
if (node.type === 'Folder') {
html += node.render();
continue;
}
html += `
<div data-path="/${node.path}" data-active="false"
class="flex md:ml-3 ml-0 pl-3 *:w-full select-none border-l
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)"
>${node.name}</a>
</div>
`;
}
let base = 24;
let maxHeight = base * this.children.length;
if (wrap) return `
<div x-data="{ open: false }" @notify="open = true">
<div class="flex items-center gap-1 hover:text-neutral-400 md:justify-normal justify-between md:pr-0 pr-4 select-none cursor-pointer" @click="open = !open">
<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>
</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]"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="max-h-0"
x-transition:enter-end="max-h-[var(--max)]"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="max-h-[var(--max)]"
x-transition:leave-end="max-h-0"
>${html}</div>
</div>
`;
return `
<div x-data="{ active: null, navigate: false }"
x-init="router.on('ready', ({ url }) => {
let isFromNav = navigate;
active?.setAttribute('data-active', false);
active = $el.querySelector(\`[data-path=&quot;\${url}&quot;]\`);
active?.setAttribute('data-active', true);
if (isFromNav) return;
let node = active;
if (node) while ((node = node.parentElement) !== $el) {
// notify all parent elements on the tree
// and cause them to open
node.dispatchEvent(new CustomEvent('notify'));
}
setTimeout(() => active?.scrollIntoView(
{ behavior: 'smooth', block: 'center' }), 150);
});
router.on('navigate', () => {
navigate = true;
setTimeout(() => navigate = false, 100);
})"
>${html}</div>
`;
}
}
export class Document extends BaseNode {
constructor(name, path, metadata, content) {
super('Document');
this.name = name;
this.path = encodeURI(path);
this.metadata = metadata;
this.content = content;
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';
}
render() {
let node = document.querySelector('meta[name="application-name"]');
let appName = node.content;
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>
<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 -->
<div x-html="graph"></div>
<!-- Headings list -->
<div x-data="{ headings: [], hash: decodeURI(window.location.hash) }"
x-init="if (! hash) window.scroll(0, 0);
$nextTick(() => headings = $refs.page.querySelectorAll(':not([data-type]) > :is(h1, h2, h3, h4, h5, h6)'))"
x-show="headings.length > 0"
class="mr-8"
>
<div class="uppercase text-sm font-semibold mb-3">On this page</div>
<div class="overflow-y-auto overscroll-contain 2xl:max-h-96 max-h-64">
<template x-for="heading in headings">
<div :data-level="heading.tagName"
x-data="{ id: '#' + heading.innerText, scroll: () => heading.scrollIntoView() }"
x-init="if (hash === id) scroll()"
class="data-[level=H1]:pl-0 data-[level=H1]:border-0 data-[level=H1]:ml-0
data-[level=H2]:ml-1 data-[level=H3]:ml-5 data-[level=H4]:ml-9 data-[level=H5]:ml-[3.25rem] ml-[4.25rem]
pl-3 border-l"
>
<a x-text="heading.innerText"
class="text-neutral-500 hover:text-neutral-800"
:href="id"
@click="scroll()"
></a>
</div>
</template>
</div>
</div>
</div>
</div>
`;
}
inline(attributes = {}) {
let html = marked(this.content);
let path = this.path;
let hash;
if (attributes.hash) {
path = path + attributes.hash;
hash = attributes.hash.slice(1);
html = `
<div x-init="
$nextTick(() => {
let outer = $el.parentElement;
let block = $el.querySelector(&quot;${attributes.hash}&quot;);
if (block) return outer.replaceWith(block);
let headings = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
let list = Array.from($el.children);
let head = list.findIndex(e => headings.includes(e.nodeName) && e.innerText === &quot;${hash}&quot;);
let next = list.slice(head + 1).findIndex(e => headings.includes(e.nodeName));
if (head === -1) return;
let nodes = list.slice(head, next === -1 ? undefined : head + next + 1);
if (nodes.length !== 0) return outer.replaceWith(...nodes);
outer.replaceChildren($el.children);
})"
>${html}</div>
`;
}
return `
<div class="relative border-l-4 border-red-400 my-6 pl-4 pb-1 embed" data-type="embed">
<div class="absolute top-0 right-0">
<button @click="router.goto(&quot;${path}&quot;)" class="rounded-md p-1 hover:bg-neutral-200">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path d="M12.232 4.232a2.5 2.5 0 0 1 3.536 3.536l-1.225 1.224a.75.75 0 0 0 1.061 1.06l1.224-1.224a4 4 0 0 0-5.656-5.656l-3 3a4 4 0 0 0 .225 5.865.75.75 0 0 0 .977-1.138 2.5 2.5 0 0 1-.142-3.667l3-3Z" />
<path d="M11.603 7.963a.75.75 0 0 0-.977 1.138 2.5 2.5 0 0 1 .142 3.667l-3 3a2.5 2.5 0 0 1-3.536-3.536l1.225-1.224a.75.75 0 0 0-1.061-1.06l-1.224 1.224a4 4 0 1 0 5.656 5.656l3-3a4 4 0 0 0-.225-5.865Z" />
</svg>
</button>
</div>
<div data-type="embed">
<div class="font-semibold">
${this.name}
</div>
${html}
</div>
</div>
`;
}
}
export class Attachment extends BaseNode {
static IMAGE = ['png', 'svg', 'jpg', 'jpeg', 'jfif', 'pjpeg', 'pjp', 'ico', 'cur'];
static AUDIO = ['mp3', 'aac', 'ogg'];
static VIDEO = ['mp4', 'webm'];
constructor(name, hash, path, extension) {
super('Attachment');
this.name = name;
this.hash = hash;
this.path = encodeURI(path);
this.extension = extension;
}
fullname() {
return [this.name, this.extension].join('.');
}
inline(attributes = {}) {
let source = `${PATH.OBJECT}/${this.hash}`;
let type = this.extension, html = '';
for (let [key, value] of Object.entries(attributes)) {
html += `${key}="${value}" `;
}
if (Attachment.IMAGE.includes(type)) {
return `
<img src="${source}" ${html}/>
`;
}
if (Attachment.AUDIO.includes(type)) {
return `
<audio src="${source}" type="audio/${type}" ${html}controls></audio>
`;
}
if (Attachment.VIDEO.includes(type)) {
return `
<video src="${source}" type="video/${type}" ${html}controls></video>
`;
}
}
render() {
let html = this.inline();
let node = document.querySelector('meta[name="application-name"]');
let appName = node.content;
let title = this.name;
document.title = `${title} - ${appName}`;
if (html) return `
<div class="flex h-screen w-full justify-center items-center">
<div class="flex w-10/12 justify-center">${html}</div>
</div>
`;
return `
<div class="xl:text-9xl lg:text-8xl md:text-7xl text-6xl text-neutral-200 pt-4 pl-6 select-none">
Unsupported file format.
</div>
`;
}
}

41
src/scripts/router.js Normal file
View File

@ -0,0 +1,41 @@
export const Routes = {
index() {},
other() {}
}
const Events = [
/* eventName: [callbacks], */
]
export default {
react() {
let url = window.location.pathname;
if (url === '/') {
url = encodeURI(Routes.index());
history.replaceState({}, '', url);
} else {
Routes.other(decodeURI(url));
}
this.emit('ready', { url });
},
start() {
window.addEventListener("popstate", () => this.react());
this.react();
},
from(event) {
event.preventDefault();
this.goto(event.target.href, '');
},
goto(url, base = '/') {
if (! url) throw new Error('invalid path: ', url);
history.pushState({}, '', base + url);
this.react();
},
emit(event, params) {
Events[event]?.forEach(fn => fn(params));
},
on(event, callback) {
if (! Events[event]) Events[event] = [];
Events[event]?.push(callback);
}
}

71
src/scripts/search.js Normal file
View File

@ -0,0 +1,71 @@
import MiniSearch from 'minisearch'
const Singleton = {};
export class Engine {
constructor(options, queryOptions) {
Singleton.instance = new MiniSearch(options);
this.queryOptions = queryOptions;
}
static Default() {
let options = {
fields: ['name', 'content'],
storeFields: ['name', 'path']
};
let queryOptions = {
prefix: true,
fuzzy: 0.2
};
return new Engine(options, queryOptions);
}
template(namespace) {
return `
<div x-data="{ value: '', active: false }" class="relative mx-5 mt-6" @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...">
</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">
<template x-for="result in results">
<div x-html="${namespace}.generateHTML(result)" class="flex size-full"></div>
</template>
<template x-if="results.length === 0">
<div class="text-center text-sm text-neutral-600 py-2">
No results found.
</div>
</template>
</div>
</div>
</div>
`;
}
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"
>
<span>${result.name}</span>
<span class="text-xs text-neutral-500"
x-show="${result.path.includes('/')}"
>${decodeURI(result.path.split('/').slice(0, -1).join('/'))}</span>
</button>
`;
}
getInstance() {
return Singleton.instance;
}
search(text) {
return Singleton.instance.search(text, this.queryOptions);
}
}

227
src/scripts/sprachbund.js Normal file
View File

@ -0,0 +1,227 @@
import { PATH } from '../paths.js'
import { transform } from './zettelkasten.js'
import { Index, Attachment, Document } from './object.js'
import { createHash } from 'node:crypto'
import { encode, decode } from '@msgpack/msgpack'
import Mustache from 'mustache'
import OSS from 'ali-oss'
import YAML from 'yaml'
import fs from 'node:fs'
import fm from'front-matter'
const CONFIG = YAML.parse(
fs.readFileSync(PATH.CONFIG, 'utf8'));
function walkSourceDir([root, parent], callback) {
let base = [root, parent].filter(Boolean).join('/');
let filenames = fs.readdirSync(base);
for (let name of filenames) {
// omit filenames starting with '.'
if (name.startsWith(".")) continue;
let path = [base, name].join('/');
let relpath = [parent, name].filter(Boolean).join('/');
let stat = fs.statSync(path);
let timestamp = stat.mtimeMs;
if (stat.isDirectory()) {
walkSourceDir([root, relpath], callback);
continue;
}
let [extension, short] = bisect(name);
let context = {
root,
name,
path,
parent,
relpath,
extension,
short,
timestamp
};
if (callback && callback(context)) {
break;
}
}
}
function bisect(filename) {
let parts = filename.split('.');
let extension = parts.length > 1
? parts.pop().toLowerCase()
: '';
let short = parts.join('.') || filename;
return [extension, short];
}
function buildMdDocs(index, ctx) /* always return falsy values */ {
let { attributes, body } = fm(ctx.content);
let pathname = [ctx.parent, ctx.short].filter(Boolean).join('/');
let text = transform.wikilink(body, href => {
let [path, hash] = href.split('#', 2);
let [hasExt,] = bisect(path);
let needle = hasExt ? path : path + '.md';
// if link is pointing to itself
// skip linking
if (!path || needle === ctx.name || needle === ctx.relpath) {
let path = [ctx.parent, ctx.short].filter(Boolean).join('/');
let result = [path, hash].filter(Boolean).join('#');
return [result, ctx.short];
}
let displayName, root;
// if link is 'shortest'
if (! needle.includes('/')) {
walkSourceDir([ctx.root], findPath);
}
function findPath({ name, short, parent, relpath }) {
// find the path to this (shortest) link
// returning true breaks the loop
if (needle === name) {
path = relpath;
root = parent;
displayName = short;
return true;
}
}
if (needle.endsWith('.md')) {
// create link only when
// it is pointing to a markdown document
index.linkWith(ctx.relpath, path);
// truncate link when its referee is found
if (root) path = [root, displayName].filter(Boolean).join('/');
}
let result = [path, hash].filter(Boolean).join('#');
return [result, displayName];
});
index.object[ctx.relpath] =
new Document(ctx.short, pathname, attributes, text);
}
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);
}
let content = fs.readFileSync(ctx.path);
let shasum = createHash('sha1');
shasum.update(content);
let hash = shasum.digest('hex');
index.object[ctx.relpath] =
new Attachment(ctx.short, hash, ctx.relpath, ctx.extension);
// skip writing to file when hash already exists
// this procedure prevents duplicate files being indexed
// for multiple times
let path = `${PATH.OBJECT}/${hash}`;
if (fs.existsSync(path)) return;
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, {});
if (! fs.existsSync(PATH.INDEX)) {
// create index if not found
// or reuse it
fs.mkdirSync(PATH.OBJECT, { recursive: true });
walkSourceDir([config.vault.root], 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 => {
let path = ctx.path;
let entry = legacy.object[path];
// if no such legacy entry found, continue
// indexing and writing to object file
if (! entry) return buildObject(index, ctx);
// if found, remove the legacy entry
// the remaining entries are unnecessary and safe for cleanup
delete legacy.object[path];
// compare the timestamps to detect modification
// insert the entry to index if no changes detected
if (entry["timestamp"] === ctx.timestamp) {
index.object[path] = entry;
index.links[path] = legacy.links[path];
}
});
for (let [path, entry] of Object.entries(legacy.object)) {
if (entry.type === 'Attachment') {
let hash = legacy.object[path]["hash"];
// cleanup legacy entries
fs.rmSync(`${PATH.OBJECT}/${hash}`, { force: true });
}
}
return index;
}
if (process.argv[1] === import.meta.filename) {
// when running under node.js solely
sprachbund().writeBundle();
}
export default function sprachbund() {
return {
name: 'sprachbund',
transformIndexHtml(html) {
return Mustache.render(html, {
name: CONFIG?.name,
description: CONFIG?.description,
language: CONFIG?.language,
});
},
writeBundle() {
let index = buildIndex(CONFIG);
let data = encode(index);
fs.writeFileSync(PATH.INDEX, data);
},
}
}

341
src/scripts/zettelkasten.js Normal file
View File

@ -0,0 +1,341 @@
export default () => {
return {
renderer,
extensions: [comment(), block()]
}
}
export const renderer = {
link({ href, text }) {
try { var external = new URL(href).href }
catch (e) {}
if (external) {
return `
<span class="inline-flex gap-0.5">
<a href="${external}" target="_blank" rel="noopener noreferrer">${text}</a>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5 text-neutral-400 mt-2.5">
<path fill-rule="evenodd" d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Z" clip-rule="evenodd" />
<path fill-rule="evenodd" d="M6.194 12.753a.75.75 0 0 0 1.06.053L16.5 4.44v2.81a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.553l-9.056 8.194a.75.75 0 0 0-.053 1.06Z" clip-rule="evenodd" />
</svg>
</span>
`;
}
return `
<a href="${href}" @click="router.from($event)">${text}</a>
`;
},
image({ href, text }) {
try { var external = new URL(href).href }
catch (e) {}
let attrs = {};
let [width, height] = text.split('x', 2);
if (! height) height = width;
if (! isNaN(parseInt(width))) attrs = { width, height };
if (external) {
return `
<img src="${href}"
${attrs.width ? 'width="' + width + '"' : ''}
${attrs.height ? 'height="' + height + '"' : ''}
/>
`;
}
let url = new URL(href, window.location.origin);
if (url.hash) attrs.hash = url.hash;
return `
<div x-data='{ target: null, attrs: ${JSON.stringify(attrs)} }'
x-init="target = index.getObject(&quot;${decodeURI(url.pathname)}&quot;)"
x-html="target?.inline(attrs) ?? $el.innerHTML"
>
<a href="${url.href}" @click="router.from($event)">${text}</a>
</div>
`;
},
blockquote({ raw, text, tokens }) {
// try matching callouts
let rule = /^ {0,3}> ?\[!(.+)\](-?)([^\n]*)/;
let match = rule.exec(raw);
if (! match) {
let body = this.parser.parse(tokens);
return `<blockquote>\n${body}</blockquote>\n`;
}
let type = match[1].trim().toLowerCase();
let foldable = Boolean(match[2]);
let title = match[3].trim();
let style = getCalloutStyle(type);
if (tokens.length > 1) {
tokens.shift();
} else {
let trim = text.slice(text.indexOf('\n'));
tokens[0].tokens[0].text = trim;
}
if (tokens[0].tokens?.length > 1) {
tokens[0].tokens.shift();
}
return `
<div class="rounded px-4 py-2 my-6 ${style.background}"
x-data="{ open: ${!foldable} }"
data-callout="${type}"
>
<div class="inline-flex items-center w-full gap-x-1.5 px-2
${style.text} ${foldable ? 'cursor-pointer select-none' : ''}"
@click="if (${foldable}) open = !open"
>
<span>${style.icon}</span>
<span class="font-semibold">${title}</span>
<span class="${foldable ? 'transition' : 'hidden'}" :class="open ? 'rotate-90' : ''">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
<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>
</div>
<div x-show="open" class="overflow-hidden transition-[max-height]"
x-init="$el.style.setProperty('--max', $el.offsetHeight + 'px')"
x-transition:enter="ease-out duration-200"
x-transition:enter-start="max-h-0"
x-transition:enter-end="max-h-[var(--max)]"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="max-h-[var(--max)]"
x-transition:leave-end="max-h-0"
>
${this.parser.parse(tokens)}
</div>
</div>
`;
}
}
export const transform = {
wikilink(src, callback) {
let rule = /(!)?\[\[([^\]]+)\]\]/;
let markdown = '';
let match = [];
let start = 0;
while (match = rule.exec(src.slice(start))) {
let raw = match[0];
let isEmbed = match[1] ?? '';
let [href, text] = match[2].trim().split('|', 2);
let [path, displayName] = callback(href);
let bound = start + match.index;
let display = text ?? displayName ?? path;
let head = src.slice(start, bound);
let tail = isEmbed + `[${display}](/${encodeURI(path)})`;
markdown += head + tail;
start = bound + raw.length;
}
return markdown + src.slice(start);
},
}
function comment() {
return {
name: 'comment',
level: 'inline',
start(src) {
return src.match(/%%/)?.index;
},
tokenizer(src, tokens) {
let rule = /^%%([\s\S]+?)%%/;
let match = rule.exec(src);
if (match) return {
type: 'comment',
raw: match[0],
text: match[1]
}
},
renderer(token) {
return '';
}
}
}
function block() {
return {
name: 'block',
level: 'block',
start(src) {
return src.match(/\^/)?.index;
},
tokenizer(src, tokens) {
let rule = /^\^(\S+)/;
let match = rule.exec(src);
if (match) return {
type: 'block',
raw: match[0],
id: match[1],
tokens: [tokens.pop()]
}
},
renderer({ id, tokens }) {
let body = this.parser.parse(tokens);
return `<div id="${id}">${body}</div>`;
}
}
}
function getCalloutStyle(type) {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>
`;
let background = 'bg-sky-100';
let text = 'text-blue-500';
if (type === 'note') {
return { icon, background, text };
}
if (type === 'info') {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
`;
return { icon, background, text };
}
if (type === 'todo') {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
`;
return { icon, background, text };
}
if (type === 'bug') {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0 1 12 12.75Zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 0 1-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 0 0 2.248-2.354M12 12.75a2.25 2.25 0 0 1-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 0 0-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 0 1 .4-2.253M12 8.25a2.25 2.25 0 0 0-2.248 2.146M12 8.25a2.25 2.25 0 0 1 2.248 2.146M8.683 5a6.032 6.032 0 0 1-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0 1 15.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 0 0-.575-1.752M4.921 6a24.048 24.048 0 0 0-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 0 1-5.223 1.082" />
</svg>
`;
let background = 'bg-rose-100';
let text = 'text-red-500';
return { icon, background, text };
}
if (type === 'example') {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
`;
let background = 'bg-violet-100';
let text = 'text-violet-500';
return { icon, background, text };
}
if (['abstract', 'summary', 'tldr'].includes(type)) {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" />
</svg>
`;
let background = 'bg-teal-100';
let text = 'text-teal-500';
return { icon, background, text };
}
if (['tip', 'hint', 'important'].includes(type)) {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 0 0 .495-7.468 5.99 5.99 0 0 0-1.925 3.547 5.975 5.975 0 0 1-2.133-1.001A3.75 3.75 0 0 0 12 18Z" />
</svg>
`;
let background = 'bg-teal-100';
let text = 'text-teal-500';
return { icon, background, text };
}
if (['success', 'check', 'done'].includes(type)) {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
`;
let background = 'bg-green-100';
let text = 'text-green-500';
return { icon, background, text };
}
if (['question', 'help', 'faq'].includes(type)) {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
</svg>
`;
let background = 'bg-orange-100';
let text = 'text-orange-500';
return { icon, background, text };
}
if (['warning', 'caution', 'attention'].includes(type)) {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
`;
let background = 'bg-orange-100';
let text = 'text-orange-500';
return { icon, background, text };
}
if (['failure', 'fail', 'missing'].includes(type)) {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
`;
let background = 'bg-rose-100';
let text = 'text-red-500';
return { icon, background, text };
}
if (['danger', 'error'].includes(type)) {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />
</svg>
`;
let background = 'bg-rose-100';
let text = 'text-red-500';
return { icon, background, text };
}
if (['quote', 'cite'].includes(type)) {
let icon = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
`;
let background = 'bg-neutral-100';
let text = 'text-neutral-500';
return { icon, background, text };
}
return { icon , background, text };
}

10
tailwind.config.js Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{html,js}"],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography')
],
}

14
vite.config.js Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import sprachbund from './src/scripts/sprachbund'
export default defineConfig({
root: 'src',
build: {
outDir: '../dist',
assetsDir: '.assets',
emptyOutDir: true
},
plugins: [
sprachbund()
]
})