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
|
.coverage
|
||||||
dist/
|
dist/
|
||||||
*.egg-info/
|
*.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:
|
when:
|
||||||
- path: "zotero/**"
|
- 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 ─────────────────────────────────────
|
# ── Upload scan results ─────────────────────────────────────
|
||||||
- name: upload-notebooks-scan
|
- name: upload-notebooks-scan
|
||||||
image: woodpeckerci/plugin-s3
|
image: woodpeckerci/plugin-s3
|
||||||
@@ -175,6 +200,7 @@ steps:
|
|||||||
- upload-notebooks-scan
|
- upload-notebooks-scan
|
||||||
- push-zotero
|
- push-zotero
|
||||||
- upload-zotero-scan
|
- upload-zotero-scan
|
||||||
|
- push-docs
|
||||||
|
|
||||||
# ── Provision: derive credentials and rotate backends ────────
|
# ── Provision: derive credentials and rotate backends ────────
|
||||||
- name: provision
|
- name: provision
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- path: "zotero/**"
|
- 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 ─────────────────────────────────────────
|
# ── Nginx / Dashboard ─────────────────────────────────────────
|
||||||
- name: validate-nginx
|
- name: validate-nginx
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
|
|||||||
@@ -340,6 +340,13 @@ services:
|
|||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
docs:
|
||||||
|
image: localhost:3000/homelab/docs:latest
|
||||||
|
container_name: docs
|
||||||
|
networks:
|
||||||
|
- gateway
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# Observability Stack
|
# Observability Stack
|
||||||
jaeger:
|
jaeger:
|
||||||
image: jaegertracing/all-in-one:latest
|
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>
|
<h1>HOMELAB</h1>
|
||||||
<p class="subtitle">COMMAND CENTER</p>
|
<p class="subtitle">COMMAND CENTER</p>
|
||||||
<div class="score-bar">
|
<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>
|
<span class="score-item">STATUS: ONLINE</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -76,6 +76,16 @@
|
|||||||
<div class="stripe"></div>
|
<div class="stripe"></div>
|
||||||
</a>
|
</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">
|
<a data-subdomain="nessie" data-path="/api/v2/config" target="_blank" class="tile catalog">
|
||||||
<span class="badge data">DATA</span>
|
<span class="badge data">DATA</span>
|
||||||
<span class="status"></span>
|
<span class="status"></span>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{{- $domain := env "DOMAIN" | default "homelab.fhirworx.io" -}}
|
{{- $domain := env "DOMAIN" | default "homelab.fhirworx.io" -}}
|
||||||
{{- $reef := dict
|
{{- $reef := dict
|
||||||
"dashboard" (dict "port" "80" "theme" true "extra_hosts" (list $domain) "mw" "secure-headers")
|
"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")
|
"gitea" (dict "port" "3000" "theme" true "mw" "secure-headers")
|
||||||
"woodpecker-server" (dict "port" "8000" "theme" true "subdomain" "ci" "mw" "local-only,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")
|
"notebooks" (dict "port" "2718" "theme" true "mw" "local-only,secure-headers")
|
||||||
|
|||||||
Reference in New Issue
Block a user