feat: init
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
.DS_Store
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/ ./
|
||||||
|
COPY frontend/dist/ ./static/
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
158
README.md
Normal file
158
README.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Micronomicon
|
||||||
|
|
||||||
|
A self-hosted web editor for writing Markdown and publishing `.mu` pages to a NomadNet node.
|
||||||
|
|
||||||
|
Write in Markdown → preview as Micron → publish directly to `~/.nomadnetwork/storage/pages/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Python 3.13+ (for local backend development only)
|
||||||
|
- Node 20+ (for local frontend development only)
|
||||||
|
- A running NomadNet container named `nomadnet` (for the restart button)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build the frontend
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 2. Create source directories
|
||||||
|
mkdir -p ~/.nomadnetwork/storage/pages ~/.micron-editor/sources
|
||||||
|
|
||||||
|
# 3. Start the stack
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
App is available at `http://localhost:8080`.
|
||||||
|
|
||||||
|
### Tailscale HTTPS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tailscale serve --bg https+insecure://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
Run backend and frontend separately with hot reload.
|
||||||
|
|
||||||
|
**Backend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
||||||
|
SOURCES_DIR=~/.micron-editor/sources \
|
||||||
|
uvicorn main:app --reload --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # proxies /api → localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:5173`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------------------|------------------|--------------------------------------|
|
||||||
|
| `PAGES_DIR` | `/data/pages` | NomadNet pages directory |
|
||||||
|
| `SOURCES_DIR` | `/data/sources` | Markdown source files directory |
|
||||||
|
| `NOMADNET_CONTAINER` | `nomadnet` | Docker container name to restart |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|----------|---------------------|-----------------------------------------------|
|
||||||
|
| `GET` | `/api/health` | Health check |
|
||||||
|
| `POST` | `/api/convert` | Convert `{ markdown }` → `{ micron }` |
|
||||||
|
| `GET` | `/api/pages` | List all pages with metadata |
|
||||||
|
| `GET` | `/api/pages/{name}` | Read page (markdown source + micron output) |
|
||||||
|
| `POST` | `/api/pages/{name}` | Save `{ markdown, publish }` — draft or live |
|
||||||
|
| `DELETE` | `/api/pages/{name}` | Delete source and/or `.mu` file |
|
||||||
|
| `GET` | `/api/graph` | Graph nodes + edges from parsed link sources |
|
||||||
|
| `POST` | `/api/restart` | Restart NomadNet Docker container |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
micronomicon/
|
||||||
|
Dockerfile
|
||||||
|
compose.yml
|
||||||
|
backend/
|
||||||
|
main.py ← FastAPI app + static file serving
|
||||||
|
converter.py ← md2txt wrapper (POST /api/convert)
|
||||||
|
pages.py ← file management (CRUD /api/pages)
|
||||||
|
graph.py ← link parser (GET /api/graph)
|
||||||
|
docker_utils.py ← container restart (POST /api/restart)
|
||||||
|
requirements.txt
|
||||||
|
frontend/
|
||||||
|
src/
|
||||||
|
App.tsx
|
||||||
|
routes/ ← DashboardView, EditorView, GraphView
|
||||||
|
components/ ← dashboard/, editor/, shared/, ui/ (shadcn)
|
||||||
|
stores/ ← editorStore, pagesStore (Zustand)
|
||||||
|
hooks/ ← useConversion, useGraph, useUnsavedGuard
|
||||||
|
lib/ ← utils (cn)
|
||||||
|
|
||||||
|
~/.nomadnetwork/storage/pages/ ← published .mu files (NomadNet serves these)
|
||||||
|
~/.micron-editor/sources/ ← markdown sources (managed by this app)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
New Page → /editor/new → Save Draft → .md saved to sources/
|
||||||
|
→ Publish → .md saved + .mu written to pages/
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Draft** — `.md` exists, no `.mu`. Not visible on NomadNet.
|
||||||
|
- **Published** — both `.md` and `.mu` exist.
|
||||||
|
- **Orphan** — `.mu` exists but no `.md` source (e.g. pages created outside this tool).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-----------|-----------------------------------------|
|
||||||
|
| Backend | Python 3.13 + FastAPI + uvicorn |
|
||||||
|
| Converter | md2txt (micron renderer) |
|
||||||
|
| Frontend | React 19 + Vite + TypeScript |
|
||||||
|
| UI | shadcn/ui + Tailwind CSS v4 |
|
||||||
|
| Editor | CodeMirror 6 |
|
||||||
|
| Graph | React Flow + dagre |
|
||||||
|
| State | Zustand |
|
||||||
|
| Container | Docker + Compose |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Limitations (Phase 1)
|
||||||
|
|
||||||
|
- Micron preview is plain text — full terminal rendering comes in a later phase (micron-parser-js iframe)
|
||||||
|
- `[[` link autocomplete not yet implemented (Phase 2)
|
||||||
|
- Graph view is read-only; click a node to open it in the editor
|
||||||
|
- No metrics (Phase 4)
|
||||||
28
backend/converter.py
Normal file
28
backend/converter.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ConvertRequest(BaseModel):
|
||||||
|
markdown: str
|
||||||
|
width: int = 80
|
||||||
|
|
||||||
|
|
||||||
|
class ConvertResponse(BaseModel):
|
||||||
|
micron: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/convert", response_model=ConvertResponse)
|
||||||
|
async def convert(req: ConvertRequest):
|
||||||
|
try:
|
||||||
|
from md2txt import convert_markdown
|
||||||
|
|
||||||
|
result = convert_markdown(
|
||||||
|
req.markdown,
|
||||||
|
width=req.width,
|
||||||
|
renderer_name="micron",
|
||||||
|
)
|
||||||
|
return ConvertResponse(micron=result)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
|
||||||
25
backend/docker_utils.py
Normal file
25
backend/docker_utils.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
NOMADNET_CONTAINER = os.environ.get("NOMADNET_CONTAINER", "nomadnet")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/restart")
|
||||||
|
async def restart_nomadnet():
|
||||||
|
try:
|
||||||
|
import docker
|
||||||
|
|
||||||
|
client = docker.from_env()
|
||||||
|
container = client.containers.get(NOMADNET_CONTAINER)
|
||||||
|
container.restart()
|
||||||
|
return {"status": "restarted", "container": NOMADNET_CONTAINER}
|
||||||
|
except docker.errors.NotFound:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Container '{NOMADNET_CONTAINER}' not found",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
81
backend/graph.py
Normal file
81
backend/graph.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
||||||
|
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
||||||
|
|
||||||
|
# Matches markdown links: [text](slug) where slug has no protocol or path separators
|
||||||
|
_INTERNAL_LINK = re.compile(r"\[([^\]]+)\]\(([a-zA-Z0-9_-]+)\)")
|
||||||
|
|
||||||
|
|
||||||
|
class GraphNode(BaseModel):
|
||||||
|
id: str
|
||||||
|
published: bool
|
||||||
|
title: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GraphEdge(BaseModel):
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
|
||||||
|
|
||||||
|
class GraphData(BaseModel):
|
||||||
|
nodes: list[GraphNode]
|
||||||
|
edges: list[GraphEdge]
|
||||||
|
|
||||||
|
|
||||||
|
def _all_page_names() -> set[str]:
|
||||||
|
names: set[str] = set()
|
||||||
|
if PAGES_DIR.is_dir():
|
||||||
|
for f in PAGES_DIR.iterdir():
|
||||||
|
if f.suffix == ".mu" and f.is_file():
|
||||||
|
names.add(f.stem)
|
||||||
|
if SOURCES_DIR.is_dir():
|
||||||
|
for f in SOURCES_DIR.iterdir():
|
||||||
|
if f.suffix == ".md" and f.is_file():
|
||||||
|
names.add(f.stem)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_title(markdown: str) -> str | None:
|
||||||
|
for line in markdown.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("# "):
|
||||||
|
return stripped[2:].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/graph", response_model=GraphData)
|
||||||
|
async def get_graph():
|
||||||
|
all_names = _all_page_names()
|
||||||
|
nodes: list[GraphNode] = []
|
||||||
|
edges: list[GraphEdge] = []
|
||||||
|
|
||||||
|
for name in sorted(all_names):
|
||||||
|
md_path = SOURCES_DIR / f"{name}.md"
|
||||||
|
mu_path = PAGES_DIR / f"{name}.mu"
|
||||||
|
|
||||||
|
title = None
|
||||||
|
if md_path.is_file():
|
||||||
|
content = md_path.read_text(encoding="utf-8")
|
||||||
|
title = _extract_title(content)
|
||||||
|
|
||||||
|
# Parse internal links
|
||||||
|
for match in _INTERNAL_LINK.finditer(content):
|
||||||
|
target = match.group(2)
|
||||||
|
if target in all_names:
|
||||||
|
edges.append(GraphEdge(source=name, target=target))
|
||||||
|
|
||||||
|
nodes.append(GraphNode(
|
||||||
|
id=name,
|
||||||
|
published=mu_path.is_file(),
|
||||||
|
title=title,
|
||||||
|
))
|
||||||
|
|
||||||
|
return GraphData(nodes=nodes, edges=edges)
|
||||||
28
backend/main.py
Normal file
28
backend/main.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from converter import router as converter_router
|
||||||
|
from pages import router as pages_router
|
||||||
|
from graph import router as graph_router
|
||||||
|
from docker_utils import router as docker_router
|
||||||
|
|
||||||
|
app = FastAPI(title="Micron Page Editor")
|
||||||
|
|
||||||
|
app.include_router(converter_router, prefix="/api")
|
||||||
|
app.include_router(pages_router, prefix="/api")
|
||||||
|
app.include_router(graph_router, prefix="/api")
|
||||||
|
app.include_router(docker_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# Serve built frontend as static files (SPA fallback)
|
||||||
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
if static_dir.is_dir():
|
||||||
|
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
||||||
136
backend/pages.py
Normal file
136
backend/pages.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
||||||
|
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
||||||
|
|
||||||
|
|
||||||
|
class PageMeta(BaseModel):
|
||||||
|
name: str
|
||||||
|
title: str | None = None
|
||||||
|
published: bool = False
|
||||||
|
has_source: bool = False
|
||||||
|
last_modified: float | None = None
|
||||||
|
size: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PageDetail(BaseModel):
|
||||||
|
name: str
|
||||||
|
markdown: str | None = None
|
||||||
|
micron: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SaveRequest(BaseModel):
|
||||||
|
markdown: str
|
||||||
|
publish: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_title(markdown: str) -> str | None:
|
||||||
|
for line in markdown.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("# "):
|
||||||
|
return stripped[2:].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _list_all_page_names() -> set[str]:
|
||||||
|
names: set[str] = set()
|
||||||
|
if PAGES_DIR.is_dir():
|
||||||
|
for f in PAGES_DIR.iterdir():
|
||||||
|
if f.suffix == ".mu" and f.is_file():
|
||||||
|
names.add(f.stem)
|
||||||
|
if SOURCES_DIR.is_dir():
|
||||||
|
for f in SOURCES_DIR.iterdir():
|
||||||
|
if f.suffix == ".md" and f.is_file():
|
||||||
|
names.add(f.stem)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _page_meta(name: str) -> PageMeta:
|
||||||
|
mu_path = PAGES_DIR / f"{name}.mu"
|
||||||
|
md_path = SOURCES_DIR / f"{name}.md"
|
||||||
|
|
||||||
|
title = None
|
||||||
|
if md_path.is_file():
|
||||||
|
title = _extract_title(md_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
published = mu_path.is_file()
|
||||||
|
last_modified = mu_path.stat().st_mtime if published else None
|
||||||
|
size = mu_path.stat().st_size if published else None
|
||||||
|
|
||||||
|
return PageMeta(
|
||||||
|
name=name,
|
||||||
|
title=title,
|
||||||
|
published=published,
|
||||||
|
has_source=md_path.is_file(),
|
||||||
|
last_modified=last_modified,
|
||||||
|
size=size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pages", response_model=list[PageMeta])
|
||||||
|
async def list_pages():
|
||||||
|
return [_page_meta(n) for n in sorted(_list_all_page_names())]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pages/{name}", response_model=PageDetail)
|
||||||
|
async def get_page(name: str):
|
||||||
|
md_path = SOURCES_DIR / f"{name}.md"
|
||||||
|
mu_path = PAGES_DIR / f"{name}.mu"
|
||||||
|
|
||||||
|
if not md_path.is_file() and not mu_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Page not found")
|
||||||
|
|
||||||
|
markdown = md_path.read_text(encoding="utf-8") if md_path.is_file() else None
|
||||||
|
micron = mu_path.read_text(encoding="utf-8") if mu_path.is_file() else None
|
||||||
|
|
||||||
|
return PageDetail(name=name, markdown=markdown, micron=micron)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pages/{name}", response_model=PageMeta)
|
||||||
|
async def save_page(name: str, req: SaveRequest):
|
||||||
|
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
PAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Always save markdown source
|
||||||
|
md_path = SOURCES_DIR / f"{name}.md"
|
||||||
|
md_path.write_text(req.markdown, encoding="utf-8")
|
||||||
|
|
||||||
|
# Optionally publish
|
||||||
|
if req.publish:
|
||||||
|
try:
|
||||||
|
from md2txt import convert_markdown
|
||||||
|
|
||||||
|
micron = convert_markdown(
|
||||||
|
req.markdown,
|
||||||
|
width=80,
|
||||||
|
renderer_name="micron",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
|
||||||
|
|
||||||
|
mu_path = PAGES_DIR / f"{name}.mu"
|
||||||
|
mu_path.write_text(micron, encoding="utf-8")
|
||||||
|
|
||||||
|
return _page_meta(name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/pages/{name}")
|
||||||
|
async def delete_page(name: str):
|
||||||
|
md_path = SOURCES_DIR / f"{name}.md"
|
||||||
|
mu_path = PAGES_DIR / f"{name}.mu"
|
||||||
|
|
||||||
|
if not md_path.is_file() and not mu_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Page not found")
|
||||||
|
|
||||||
|
if md_path.is_file():
|
||||||
|
md_path.unlink()
|
||||||
|
if mu_path.is_file():
|
||||||
|
mu_path.unlink()
|
||||||
|
|
||||||
|
return {"deleted": name}
|
||||||
4
backend/requirements.txt
Normal file
4
backend/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
docker>=7.0
|
||||||
|
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
||||||
14
compose.yml
Normal file
14
compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
micron-editor:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ~/.nomadnetwork/storage/pages:/data/pages
|
||||||
|
- ~/.micron-editor/sources:/data/sources
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
- PAGES_DIR=/data/pages
|
||||||
|
- SOURCES_DIR=/data/sources
|
||||||
|
- NOMADNET_CONTAINER=nomadnet
|
||||||
|
restart: always
|
||||||
Reference in New Issue
Block a user