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:
kert
2026-02-28 23:08:47 -05:00
parent 2c0a69a4d8
commit 9776915b50
19 changed files with 1002 additions and 1 deletions

7
.gitignore vendored
View File

@@ -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/

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -0,0 +1,3 @@
module.exports = {
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
};

46
docs/docs/intro.md Normal file
View 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
View 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
View 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
View 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"
}
}

View 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()

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"extends": "@docusaurus/module-type-aliases/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"jsx": "react-jsx"
}
}

View File

@@ -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">&#128214;</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>

View File

@@ -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")