add Docusaurus docs site with API reference and bibliography browser
Griffe-based docstring extraction (277 modules across 9 packages), bib.Store export to searchable JSON (6560 items), throwback-themed Docusaurus site served by nginx:alpine behind Traefik at docs.homelab.fhirworx.io. Multi-stage Dockerfile, CI pipeline steps, and dashboard tile included.
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -14,3 +14,10 @@ notebooks/__marimo__/
|
||||
.coverage
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Docs (auto-generated at build time)
|
||||
docs/docs/api/
|
||||
docs/static/library.json
|
||||
docs/node_modules/
|
||||
docs/build/
|
||||
docs/.docusaurus/
|
||||
|
||||
@@ -113,6 +113,31 @@ steps:
|
||||
when:
|
||||
- path: "zotero/**"
|
||||
|
||||
# ── Docs image ─────────────────────────────────────────────
|
||||
- name: build-docs
|
||||
image: docker:cli
|
||||
volumes:
|
||||
- /run/user/1000/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- docker build -t localhost:3000/homelab/docs:${CI_COMMIT_SHA:0:8} -f docs/Dockerfile .
|
||||
- docker tag localhost:3000/homelab/docs:${CI_COMMIT_SHA:0:8} localhost:3000/homelab/docs:latest
|
||||
|
||||
- name: push-docs
|
||||
image: docker:cli
|
||||
volumes:
|
||||
- /run/user/1000/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: registry_user
|
||||
REGISTRY_PASS:
|
||||
from_secret: registry_pass
|
||||
commands:
|
||||
- echo "$REGISTRY_PASS" | docker login localhost:3000 -u "$REGISTRY_USER" --password-stdin
|
||||
- docker push localhost:3000/homelab/docs:${CI_COMMIT_SHA:0:8}
|
||||
- docker push localhost:3000/homelab/docs:latest
|
||||
depends_on:
|
||||
- build-docs
|
||||
|
||||
# ── Upload scan results ─────────────────────────────────────
|
||||
- name: upload-notebooks-scan
|
||||
image: woodpeckerci/plugin-s3
|
||||
@@ -175,6 +200,7 @@ steps:
|
||||
- upload-notebooks-scan
|
||||
- push-zotero
|
||||
- upload-zotero-scan
|
||||
- push-docs
|
||||
|
||||
# ── Provision: derive credentials and rotate backends ────────
|
||||
- name: provision
|
||||
|
||||
@@ -55,6 +55,26 @@ steps:
|
||||
when:
|
||||
- path: "zotero/**"
|
||||
|
||||
# ── Docs image ────────────────────────────────────────────────
|
||||
- name: hadolint-docs
|
||||
image: hadolint/hadolint:latest-debian
|
||||
commands:
|
||||
- hadolint docs/Dockerfile
|
||||
when:
|
||||
- path: "docs/**"
|
||||
|
||||
- name: build-docs
|
||||
image: docker:cli
|
||||
volumes:
|
||||
- /run/user/1000/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- docker build -t ci-test/docs:${CI_COMMIT_SHA:0:8} -f docs/Dockerfile .
|
||||
- docker rmi ci-test/docs:${CI_COMMIT_SHA:0:8}
|
||||
depends_on:
|
||||
- hadolint-docs
|
||||
when:
|
||||
- path: "docs/**"
|
||||
|
||||
# ── Nginx / Dashboard ─────────────────────────────────────────
|
||||
- name: validate-nginx
|
||||
image: nginx:alpine
|
||||
|
||||
@@ -340,6 +340,13 @@ services:
|
||||
- no-new-privileges:true
|
||||
restart: unless-stopped
|
||||
|
||||
docs:
|
||||
image: localhost:3000/homelab/docs:latest
|
||||
container_name: docs
|
||||
networks:
|
||||
- gateway
|
||||
restart: unless-stopped
|
||||
|
||||
# Observability Stack
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:latest
|
||||
|
||||
48
docs/Dockerfile
Normal file
48
docs/Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
# Stage 1: Extract docs from Python source + export bib library
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS extract
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy source code and extraction scripts
|
||||
COPY src/ src/
|
||||
COPY docs/scripts/ docs/scripts/
|
||||
|
||||
# Extract API docs via griffe (pure AST, no imports)
|
||||
RUN uv run --with griffe python docs/scripts/extract_docs.py
|
||||
|
||||
# Export bibliography (needs bib deps from src/)
|
||||
COPY data/bib.sqlite data/bib.sqlite
|
||||
COPY pyproject.toml .
|
||||
RUN uv run python docs/scripts/export_library.py
|
||||
|
||||
|
||||
# Stage 2: Build Docusaurus static site
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /docs
|
||||
|
||||
# Install dependencies
|
||||
COPY docs/package.json docs/package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy Docusaurus config and source
|
||||
COPY docs/docusaurus.config.js docs/sidebars.js docs/babel.config.js docs/tsconfig.json ./
|
||||
COPY docs/src/ src/
|
||||
COPY docs/static/ static/
|
||||
COPY docs/docs/ docs/
|
||||
|
||||
# Copy generated API docs and library.json from extract stage
|
||||
COPY --from=extract /build/docs/docs/api/ docs/api/
|
||||
COPY --from=extract /build/docs/static/library.json static/library.json
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Stage 3: Serve with nginx
|
||||
# hadolint ignore=DL3006
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /docs/build /usr/share/nginx/html
|
||||
COPY docs/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
3
docs/babel.config.js
Normal file
3
docs/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
|
||||
};
|
||||
46
docs/docs/intro.md
Normal file
46
docs/docs/intro.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
slug: /
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Stack
|
||||
|
||||
Python-first data platform for healthcare analytics, replacing dbt with
|
||||
narwhals DataFrame-agnostic expression functions backed by DuckDB.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **aco** | ACO REACH analytics — express functions, pipe layer, lake |
|
||||
| **api** | API client modules for homelab services |
|
||||
| **bcda** | Beneficiary Claims Data API client and pipeline |
|
||||
| **bib** | Bibliography store — CMS rules, regulations, manuals |
|
||||
| **bls** | Bureau of Labor Statistics data access |
|
||||
| **ccw** | Chronic Conditions Warehouse documentation codex |
|
||||
| **cms** | CMS table models, logging, and shared utilities |
|
||||
| **pfs** | Physician Fee Schedule pipeline and rules |
|
||||
| **rex** | Record expression engine — copybook parsing, storage |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── aco/ # ACO REACH express functions + pipe layer
|
||||
│ ├── express/ # narwhals DataFrame functions
|
||||
│ ├── pipe/ # pipeline runner + step modules
|
||||
│ └── lake.py # DuckDB lakehouse interface
|
||||
├── api/ # homelab API clients
|
||||
├── bcda/ # BCDA API client + CCLF pipeline
|
||||
├── bib/ # bibliography store (SQLite)
|
||||
├── bls/ # BLS data loader
|
||||
├── ccw/ # CCW documentation codex
|
||||
├── cms/ # shared CMS models + logging
|
||||
├── pfs/ # Physician Fee Schedule
|
||||
└── rex/ # record expression engine
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- [Library](/library) — searchable bibliography of CMS rules, regulations, and manuals
|
||||
- [API Reference](/docs/api) — auto-generated from docstrings
|
||||
73
docs/docusaurus.config.js
Normal file
73
docs/docusaurus.config.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: "Stack",
|
||||
tagline: "Healthcare data platform documentation",
|
||||
favicon: "img/logo.svg",
|
||||
url: "http://docs.homelab.fhirworx.io",
|
||||
baseUrl: "/",
|
||||
onBrokenLinks: "warn",
|
||||
onBrokenMarkdownLinks: "warn",
|
||||
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||
({
|
||||
docs: {
|
||||
sidebarPath: "./sidebars.js",
|
||||
},
|
||||
blog: false,
|
||||
theme: {
|
||||
customCss: "./src/css/custom.css",
|
||||
},
|
||||
}),
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig:
|
||||
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||
({
|
||||
colorMode: {
|
||||
defaultMode: "dark",
|
||||
disableSwitch: true,
|
||||
respectPrefersColorScheme: false,
|
||||
},
|
||||
navbar: {
|
||||
title: "Stack",
|
||||
logo: {
|
||||
alt: "Stack",
|
||||
src: "img/logo.svg",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "docSidebar",
|
||||
sidebarId: "docsSidebar",
|
||||
position: "left",
|
||||
label: "Docs",
|
||||
},
|
||||
{
|
||||
to: "/library",
|
||||
label: "Library",
|
||||
position: "left",
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: "dark",
|
||||
copyright: "Healthcare data platform — built with Docusaurus.",
|
||||
},
|
||||
prism: {
|
||||
theme: require("prism-react-renderer").themes.dracula,
|
||||
additionalLanguages: ["python", "sql", "bash"],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
16
docs/nginx.conf
Normal file
16
docs/nginx.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
34
docs/package.json
Normal file
34
docs/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "stack-docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.7.0",
|
||||
"@docusaurus/preset-classic": "^3.7.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.7.0",
|
||||
"@docusaurus/types": "^3.7.0",
|
||||
"typescript": "~5.6.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [">0.5%", "not dead", "not op_mini all"],
|
||||
"development": ["last 3 chrome version", "last 3 firefox version", "last 5 safari version"]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
}
|
||||
71
docs/scripts/export_library.py
Normal file
71
docs/scripts/export_library.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Export bib.Store contents to static JSON for the library browser.
|
||||
|
||||
Reads ``data/bib.sqlite`` and writes ``docs/static/library.json``
|
||||
with items, collections, and tags. Gracefully writes empty JSON if
|
||||
the database is missing.
|
||||
|
||||
Usage::
|
||||
|
||||
uv run python docs/scripts/export_library.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BIB_DB = Path("data/bib.sqlite")
|
||||
OUT_PATH = Path("docs/static/library.json")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not BIB_DB.exists():
|
||||
print(f"bib.sqlite not found at {BIB_DB}, writing empty library.json")
|
||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_PATH.write_text(
|
||||
json.dumps({"items": [], "collections": [], "tags": []}, indent=2)
|
||||
)
|
||||
return
|
||||
|
||||
# Import here so griffe stage doesn't need bib deps
|
||||
sys.path.insert(0, str(Path("src").resolve()))
|
||||
from bib.store import Store
|
||||
|
||||
store = Store(str(BIB_DB))
|
||||
items = store.list_items()
|
||||
collections = store.list_collections()
|
||||
tags = store.list_tags()
|
||||
|
||||
serialized_items = []
|
||||
for item in items:
|
||||
abstract = item.abstract or ""
|
||||
if len(abstract) > 300:
|
||||
abstract = abstract[:297] + "..."
|
||||
serialized_items.append(
|
||||
{
|
||||
"key": item.key,
|
||||
"item_type": item.item_type,
|
||||
"title": item.title,
|
||||
"url": item.url,
|
||||
"date_published": item.date_published,
|
||||
"abstract": abstract,
|
||||
"tags": item.tags,
|
||||
"collections": item.collections,
|
||||
"institution": item.institution,
|
||||
}
|
||||
)
|
||||
|
||||
data = {
|
||||
"items": serialized_items,
|
||||
"collections": collections,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_PATH.write_text(json.dumps(data, indent=2, default=str))
|
||||
print(f"Wrote {len(serialized_items)} items to {OUT_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
190
docs/scripts/extract_docs.py
Normal file
190
docs/scripts/extract_docs.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Extract API docs from Python source using griffe.
|
||||
|
||||
Walks each package under ``src/``, parses NumPy-style docstrings via
|
||||
griffe's pure-AST analysis (no imports needed), and writes one Markdown
|
||||
file per module into ``docs/docs/api/{package}/{module}.md``.
|
||||
|
||||
Usage::
|
||||
|
||||
uv run --with griffe python docs/scripts/extract_docs.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from griffe import Class, Function, GriffeLoader, Module, Object
|
||||
|
||||
PACKAGES = ["aco", "api", "bcda", "bib", "bls", "ccw", "cms", "pfs", "rex"]
|
||||
SRC_DIR = Path("src")
|
||||
OUT_DIR = Path("docs/docs/api")
|
||||
|
||||
|
||||
def _is_public(name: str) -> bool:
|
||||
return not name.startswith("_")
|
||||
|
||||
|
||||
def _render_docstring(obj: Object) -> str:
|
||||
"""Render an object's docstring as markdown."""
|
||||
if not obj.docstring:
|
||||
return ""
|
||||
return obj.docstring.value.strip() + "\n"
|
||||
|
||||
|
||||
def _render_signature(func: Function) -> str:
|
||||
"""Render a function signature."""
|
||||
params = []
|
||||
for p in func.parameters:
|
||||
if p.name in ("self", "cls"):
|
||||
continue
|
||||
part = p.name
|
||||
if p.annotation:
|
||||
part += f": {p.annotation}"
|
||||
if p.default is not None:
|
||||
default = str(p.default)
|
||||
# Truncate very long defaults
|
||||
if len(default) > 60:
|
||||
default = default[:57] + "..."
|
||||
part += f" = {default}"
|
||||
params.append(part)
|
||||
return f"({', '.join(params)})"
|
||||
|
||||
|
||||
def _render_function(func: Function, heading: str = "###") -> str:
|
||||
"""Render a function to markdown."""
|
||||
lines = [f"{heading} `{func.name}`\n"]
|
||||
sig = _render_signature(func)
|
||||
lines.append(f"```python\n{func.name}{sig}\n```\n")
|
||||
doc = _render_docstring(func)
|
||||
if doc:
|
||||
lines.append(doc)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _render_class(cls: Class) -> str:
|
||||
"""Render a class and its public methods."""
|
||||
lines = [f"## `{cls.name}`\n"]
|
||||
doc = _render_docstring(cls)
|
||||
if doc:
|
||||
lines.append(doc)
|
||||
|
||||
# __init__ signature
|
||||
if "__init__" in cls.members:
|
||||
init = cls.members["__init__"]
|
||||
if isinstance(init, Function):
|
||||
sig = _render_signature(init)
|
||||
lines.append(f"```python\n{cls.name}{sig}\n```\n")
|
||||
|
||||
# Public methods
|
||||
methods = [
|
||||
m
|
||||
for name, m in cls.members.items()
|
||||
if isinstance(m, Function) and _is_public(name) and name != "__init__"
|
||||
]
|
||||
if methods:
|
||||
lines.append("**Methods:**\n")
|
||||
for method in methods:
|
||||
lines.append(_render_function(method, heading="####"))
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _render_module(mod: Module) -> str | None:
|
||||
"""Render a module to markdown. Returns None if nothing public."""
|
||||
classes = [
|
||||
m for name, m in mod.members.items()
|
||||
if isinstance(m, Class) and _is_public(name)
|
||||
]
|
||||
functions = [
|
||||
m for name, m in mod.members.items()
|
||||
if isinstance(m, Function) and _is_public(name)
|
||||
]
|
||||
|
||||
if not classes and not functions:
|
||||
return None
|
||||
|
||||
# Module name without package prefix
|
||||
parts = mod.path.split(".")
|
||||
title = ".".join(parts)
|
||||
|
||||
lines = [
|
||||
f"---\ntitle: {title}\n---\n",
|
||||
f"# `{title}`\n",
|
||||
]
|
||||
|
||||
doc = _render_docstring(mod)
|
||||
if doc:
|
||||
lines.append(doc)
|
||||
|
||||
for cls in classes:
|
||||
lines.append(_render_class(cls))
|
||||
|
||||
if functions:
|
||||
if classes:
|
||||
lines.append("---\n")
|
||||
lines.append("## Functions\n")
|
||||
for func in functions:
|
||||
lines.append(_render_function(func))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def extract_package(loader: GriffeLoader, package: str) -> int:
|
||||
"""Extract docs for one package. Returns count of files written."""
|
||||
try:
|
||||
pkg = loader.load(package)
|
||||
except Exception as exc:
|
||||
print(f" skip {package}: {exc}")
|
||||
return 0
|
||||
|
||||
pkg_dir = OUT_DIR / package
|
||||
pkg_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write category metadata for Docusaurus sidebar
|
||||
cat_json = pkg_dir / "_category_.json"
|
||||
cat_json.write_text(
|
||||
f'{{"label": "{package}", "position": {PACKAGES.index(package) + 2}}}\n'
|
||||
)
|
||||
|
||||
count = 0
|
||||
|
||||
def _walk(mod: Module) -> None:
|
||||
nonlocal count
|
||||
content = _render_module(mod)
|
||||
if content:
|
||||
# Use module path relative to package for filename
|
||||
rel = mod.path.replace(f"{package}.", "").replace(".", "/")
|
||||
out_path = pkg_dir / f"{rel}.md"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(content)
|
||||
count += 1
|
||||
print(f" {mod.path} -> {out_path}")
|
||||
|
||||
for name, member in mod.members.items():
|
||||
if isinstance(member, Module) and _is_public(name):
|
||||
_walk(member)
|
||||
|
||||
_walk(pkg)
|
||||
return count
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Clean output directory
|
||||
if OUT_DIR.exists():
|
||||
import shutil
|
||||
shutil.rmtree(OUT_DIR)
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
loader = GriffeLoader(search_paths=[str(SRC_DIR)])
|
||||
total = 0
|
||||
|
||||
for package in PACKAGES:
|
||||
print(f"Extracting {package}...")
|
||||
n = extract_package(loader, package)
|
||||
total += n
|
||||
|
||||
print(f"\nDone: {total} module docs written to {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
12
docs/sidebars.js
Normal file
12
docs/sidebars.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
docsSidebar: [
|
||||
"intro",
|
||||
{
|
||||
type: "autogenerated",
|
||||
dirName: "api",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
module.exports = sidebars;
|
||||
59
docs/src/css/custom.css
Normal file
59
docs/src/css/custom.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/* Throwback palette — matches css/inject.css */
|
||||
|
||||
:root {
|
||||
--ifm-color-primary: #3366cc;
|
||||
--ifm-color-primary-dark: #2d5bb8;
|
||||
--ifm-color-primary-darker: #2a56ad;
|
||||
--ifm-color-primary-darkest: #23478f;
|
||||
--ifm-color-primary-light: #4775d1;
|
||||
--ifm-color-primary-lighter: #527dd4;
|
||||
--ifm-color-primary-lightest: #7597dd;
|
||||
--ifm-background-color: #0a1628;
|
||||
--ifm-font-family-base: "IBM Plex Mono", "Fira Code", "Cascadia Code",
|
||||
ui-monospace, monospace;
|
||||
--ifm-font-family-monospace: "IBM Plex Mono", "Fira Code", monospace;
|
||||
--ifm-code-font-size: 90%;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--ifm-background-color: #0a1628;
|
||||
--ifm-background-surface-color: #0d1e36;
|
||||
--ifm-color-primary: #3366cc;
|
||||
--ifm-color-primary-dark: #2d5bb8;
|
||||
--ifm-color-primary-darker: #2a56ad;
|
||||
--ifm-color-primary-darkest: #23478f;
|
||||
--ifm-color-primary-light: #4775d1;
|
||||
--ifm-color-primary-lighter: #527dd4;
|
||||
--ifm-color-primary-lightest: #7597dd;
|
||||
--ifm-navbar-background-color: #0d1e36;
|
||||
--ifm-footer-background-color: #060e1a;
|
||||
--ifm-font-color-base: #e8e0d4;
|
||||
--ifm-heading-color: #e8e0d4;
|
||||
--ifm-link-color: #66aaff;
|
||||
--ifm-menu-color: #a0b0c8;
|
||||
--ifm-toc-link-color: #a0b0c8;
|
||||
--ifm-code-background: #111d30;
|
||||
--ifm-hr-border-color: #1a2a44;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(51, 102, 204, 0.15);
|
||||
}
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap");
|
||||
|
||||
.navbar__title {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.footer--dark {
|
||||
border-top: 3px solid #cc2244;
|
||||
}
|
||||
|
||||
/* Red accent bar on hero */
|
||||
.hero {
|
||||
border-bottom: 3px solid #cc2244;
|
||||
}
|
||||
|
||||
/* Sidebar active link */
|
||||
.menu__link--active:not(.menu__link--sublist) {
|
||||
border-left: 3px solid #cc2244;
|
||||
}
|
||||
363
docs/src/pages/library.tsx
Normal file
363
docs/src/pages/library.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
// Types matching export_library.py output
|
||||
interface LibraryItem {
|
||||
key: string;
|
||||
item_type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date_published: string;
|
||||
abstract: string;
|
||||
tags: string[];
|
||||
collections: string[];
|
||||
institution: string;
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
key: string;
|
||||
name: string;
|
||||
parent_key: string;
|
||||
item_count: number;
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface LibraryData {
|
||||
items: LibraryItem[];
|
||||
collections: Collection[];
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
// Group tags by namespace (e.g., "module:pfs" -> "module")
|
||||
function groupTags(tags: Tag[]): Record<string, Tag[]> {
|
||||
const groups: Record<string, Tag[]> = {};
|
||||
for (const tag of tags) {
|
||||
const idx = tag.name.indexOf(":");
|
||||
const ns = idx > 0 ? tag.name.slice(0, idx) : "other";
|
||||
if (!groups[ns]) groups[ns] = [];
|
||||
groups[ns].push(tag);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export default function Library(): JSX.Element {
|
||||
const [data, setData] = useState<LibraryData | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedCollection, setSelectedCollection] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/library.json")
|
||||
.then((r) => r.json())
|
||||
.then((d: LibraryData) => setData(d))
|
||||
.catch(() => setData({ items: [], collections: [], tags: [] }));
|
||||
}, []);
|
||||
|
||||
const tagGroups = useMemo(
|
||||
() => (data ? groupTags(data.tags) : {}),
|
||||
[data],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return [];
|
||||
let items = data.items;
|
||||
|
||||
if (selectedCollection) {
|
||||
items = items.filter((i) => i.collections.includes(selectedCollection));
|
||||
}
|
||||
|
||||
if (selectedTags.size > 0) {
|
||||
items = items.filter((i) =>
|
||||
[...selectedTags].every((t) => i.tags.includes(t)),
|
||||
);
|
||||
}
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter(
|
||||
(i) =>
|
||||
i.title.toLowerCase().includes(q) ||
|
||||
i.abstract.toLowerCase().includes(q) ||
|
||||
i.tags.some((t) => t.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [data, search, selectedCollection, selectedTags]);
|
||||
|
||||
const pageItems = useMemo(
|
||||
() => filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
|
||||
[filtered, page],
|
||||
);
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(tag)) next.delete(tag);
|
||||
else next.add(tag);
|
||||
return next;
|
||||
});
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Layout title="Library">
|
||||
<main style={{ padding: "2rem" }}>Loading...</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="Library" description="CMS rules, regulations, and manuals">
|
||||
<main
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "1.5rem",
|
||||
padding: "1.5rem",
|
||||
maxWidth: 1400,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
{/* Sidebar */}
|
||||
<aside style={{ minWidth: 220, maxWidth: 260, flexShrink: 0 }}>
|
||||
<h3>Collections</h3>
|
||||
<ul style={{ listStyle: "none", padding: 0, fontSize: "0.85rem" }}>
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCollection("");
|
||||
setPage(0);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: !selectedCollection ? "#3366cc" : "#a0b0c8",
|
||||
cursor: "pointer",
|
||||
padding: "2px 0",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
}}
|
||||
>
|
||||
All ({data.items.length})
|
||||
</button>
|
||||
</li>
|
||||
{data.collections.map((c) => (
|
||||
<li key={c.key}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCollection(c.key);
|
||||
setPage(0);
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color:
|
||||
selectedCollection === c.key ? "#3366cc" : "#a0b0c8",
|
||||
cursor: "pointer",
|
||||
padding: "2px 0",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
}}
|
||||
>
|
||||
{c.name} ({c.item_count})
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3>Tags</h3>
|
||||
{Object.entries(tagGroups).map(([ns, tags]) => (
|
||||
<div key={ns} style={{ marginBottom: "0.75rem" }}>
|
||||
<strong style={{ fontSize: "0.8rem", textTransform: "uppercase" }}>
|
||||
{ns}
|
||||
</strong>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginTop: 4 }}>
|
||||
{tags.map((t) => (
|
||||
<button
|
||||
key={t.name}
|
||||
onClick={() => toggleTag(t.name)}
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
padding: "1px 6px",
|
||||
borderRadius: 3,
|
||||
border: "1px solid #1a2a44",
|
||||
background: selectedTags.has(t.name)
|
||||
? "#3366cc"
|
||||
: "#0d1e36",
|
||||
color: selectedTags.has(t.name) ? "#fff" : "#a0b0c8",
|
||||
cursor: "pointer",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
{t.name.includes(":") ? t.name.split(":")[1] : t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<section style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search title, abstract, or tags..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem 0.75rem",
|
||||
fontSize: "0.9rem",
|
||||
background: "#0d1e36",
|
||||
border: "1px solid #1a2a44",
|
||||
borderRadius: 4,
|
||||
color: "#e8e0d4",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: "0.85rem", color: "#a0b0c8" }}>
|
||||
{filtered.length} results
|
||||
{totalPages > 1 && ` — page ${page + 1} of ${totalPages}`}
|
||||
</p>
|
||||
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
borderBottom: "2px solid #1a2a44",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<th style={{ padding: "6px 8px" }}>Title</th>
|
||||
<th style={{ padding: "6px 8px", width: 80 }}>Type</th>
|
||||
<th style={{ padding: "6px 8px", width: 100 }}>Date</th>
|
||||
<th style={{ padding: "6px 8px" }}>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((item) => (
|
||||
<tr
|
||||
key={item.key}
|
||||
style={{ borderBottom: "1px solid #111d30" }}
|
||||
>
|
||||
<td style={{ padding: "6px 8px" }}>
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "#66aaff" }}
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "6px 8px", color: "#a0b0c8" }}>
|
||||
{item.item_type}
|
||||
</td>
|
||||
<td style={{ padding: "6px 8px", color: "#a0b0c8" }}>
|
||||
{item.date_published}
|
||||
</td>
|
||||
<td style={{ padding: "6px 8px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{item.tags.slice(0, 5).map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
padding: "0 4px",
|
||||
background: "#111d30",
|
||||
borderRadius: 2,
|
||||
color: "#a0b0c8",
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{item.tags.length > 5 && (
|
||||
<span style={{ fontSize: "0.7rem", color: "#666" }}>
|
||||
+{item.tags.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
background: "#0d1e36",
|
||||
border: "1px solid #1a2a44",
|
||||
borderRadius: 3,
|
||||
color: "#a0b0c8",
|
||||
cursor: page === 0 ? "not-allowed" : "pointer",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
background: "#0d1e36",
|
||||
border: "1px solid #1a2a44",
|
||||
borderRadius: 3,
|
||||
color: "#a0b0c8",
|
||||
cursor: page >= totalPages - 1 ? "not-allowed" : "pointer",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
8
docs/static/img/logo.svg
vendored
Normal file
8
docs/static/img/logo.svg
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" shape-rendering="crispEdges">
|
||||
<rect width="32" height="32" rx="4" fill="#0a1628"/>
|
||||
<rect y="28" width="32" height="4" rx="0" fill="#cc2244"/>
|
||||
<!-- H letterform: pixel-art style, scaled 2x -->
|
||||
<rect x="6" y="4" width="6" height="20" fill="#e8e0d4"/>
|
||||
<rect x="20" y="4" width="6" height="20" fill="#e8e0d4"/>
|
||||
<rect x="12" y="10" width="8" height="6" fill="#e8e0d4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
7
docs/tsconfig.json
Normal file
7
docs/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@docusaurus/module-type-aliases/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
<h1>HOMELAB</h1>
|
||||
<p class="subtitle">COMMAND CENTER</p>
|
||||
<div class="score-bar">
|
||||
<span class="score-item">SERVICES: 13</span>
|
||||
<span class="score-item">SERVICES: 14</span>
|
||||
<span class="score-item">STATUS: ONLINE</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -76,6 +76,16 @@
|
||||
<div class="stripe"></div>
|
||||
</a>
|
||||
|
||||
<a data-subdomain="docs" target="_blank" class="tile research">
|
||||
<span class="badge new">NEW</span>
|
||||
<span class="status"></span>
|
||||
<span class="icon">📖</span>
|
||||
<h2 class="tile-title">DOCS</h2>
|
||||
<p class="tile-desc">API reference and CMS bibliography browser</p>
|
||||
<span class="tile-port"></span>
|
||||
<div class="stripe"></div>
|
||||
</a>
|
||||
|
||||
<a data-subdomain="nessie" data-path="/api/v2/config" target="_blank" class="tile catalog">
|
||||
<span class="badge data">DATA</span>
|
||||
<span class="status"></span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{{- $domain := env "DOMAIN" | default "homelab.fhirworx.io" -}}
|
||||
{{- $reef := dict
|
||||
"dashboard" (dict "port" "80" "theme" true "extra_hosts" (list $domain) "mw" "secure-headers")
|
||||
"docs" (dict "port" "80" "theme" true "mw" "secure-headers")
|
||||
"gitea" (dict "port" "3000" "theme" true "mw" "secure-headers")
|
||||
"woodpecker-server" (dict "port" "8000" "theme" true "subdomain" "ci" "mw" "local-only,secure-headers")
|
||||
"notebooks" (dict "port" "2718" "theme" true "mw" "local-only,secure-headers")
|
||||
|
||||
Reference in New Issue
Block a user