diff --git a/docs/user-guide/cli.rst b/docs/user-guide/cli.rst
index 753fbadd..e6fd250b 100644
--- a/docs/user-guide/cli.rst
+++ b/docs/user-guide/cli.rst
@@ -4,3 +4,19 @@ Command Line Interface
.. click:: felis.cli:cli
:prog: felis
:nested: full
+
+Browser prototype
+================
+
+The ``browser`` command generates a static HTML site for browsing schema
+structure (schemas, tables, and columns):
+
+.. code-block:: bash
+
+ felis browser --output-dir ./site tests/data/test.yml tests/data/sales.yaml
+
+You can use shell expansion to provide file lists:
+
+.. code-block:: bash
+
+ felis browser --output-dir ./site ../sdm_schemas/yml/*.yaml
diff --git a/pyproject.toml b/pyproject.toml
index 770e2c86..12ae0727 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,6 +27,7 @@ dependencies = [
"astropy",
"click",
"deepdiff",
+ "jinja2",
"lsst-resources",
"lsst-utils",
"numpy",
@@ -43,6 +44,7 @@ Source = "https://github.com/lsst/felis"
[project.optional-dependencies]
test = [
+ "beautifulsoup4>=4.12",
"pytest >= 3.2"
]
dev = [
@@ -61,7 +63,14 @@ where = ["python"]
zip-safe = true
[tool.setuptools.package-data]
-"felis" = ["py.typed", "config/tap_schema/*.yaml", "config/tap_schema/*.csv"]
+"felis" = [
+ "py.typed",
+ "config/tap_schema/*.yaml",
+ "config/tap_schema/*.csv",
+ "browser/templates/*.j2",
+ "browser/static/*.css",
+ "browser/static/*.js",
+]
[tool.setuptools.dynamic]
version = { attr = "lsst_versions.get_lsst_version" }
diff --git a/python/felis/browser/__init__.py b/python/felis/browser/__init__.py
new file mode 100644
index 00000000..25c09990
--- /dev/null
+++ b/python/felis/browser/__init__.py
@@ -0,0 +1,5 @@
+"""Static schema browser generator."""
+
+from .render import render_static_site
+
+__all__ = ["render_static_site"]
diff --git a/python/felis/browser/render.py b/python/felis/browser/render.py
new file mode 100644
index 00000000..99f44ca4
--- /dev/null
+++ b/python/felis/browser/render.py
@@ -0,0 +1,331 @@
+from __future__ import annotations
+
+import logging
+import re
+from importlib import resources
+from pathlib import Path
+
+from jinja2 import Environment, PackageLoader, select_autoescape
+
+from felis.datamodel import Schema
+
+logger = logging.getLogger(__name__)
+_GRAPHIC_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"}
+
+
+def _slugify(value: str) -> str:
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
+ return slug or "schema"
+
+
+def _schema_name(schema: Schema, source_path: str) -> str:
+ if schema.name:
+ return schema.name
+ return Path(source_path).stem
+
+
+def _column_label(column_id: str) -> str:
+ return column_id.rsplit(".", 1)[-1].lstrip("#")
+
+
+def _table_label(column_id: str) -> str:
+ return column_id.lstrip("#").split(".", 1)[0]
+
+
+def _format_field_value(value: object) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ if isinstance(value, list):
+ return ", ".join(str(item) for item in value)
+ if isinstance(value, dict):
+ return str(value)
+ return str(value)
+
+
+def _validate_site_icon(site_icon: Path) -> None:
+ if site_icon.suffix.lower() not in _GRAPHIC_SUFFIXES:
+ raise ValueError(f"Site icon must be a graphics file: {site_icon}")
+
+
+def render_static_site(
+ schemas: list[Schema],
+ source_paths: list[str],
+ output_dir: Path,
+ include_column_details: bool = True,
+ site_icon: Path | None = None,
+ icon_link_url: str | None = None,
+) -> None:
+ """Render a minimal static browser site for browsing schemas.
+
+ The generated pages include schema, table, and column navigation.
+
+ Parameters
+ ----------
+ schemas
+ List of schemas to render.
+ source_paths
+ List of source paths corresponding to the schemas, used for display
+ purposes.
+ output_dir
+ Directory where the rendered HTML files will be saved.
+ include_column_details
+ If True, include expandable per-column details in table pages.
+ site_icon
+ Optional path to a graphics file copied into assets and shown in the
+ page header.
+ icon_link_url
+ Optional URL to open when the site icon is clicked.
+ """
+ output_dir.mkdir(parents=True, exist_ok=True)
+ (output_dir / "assets").mkdir(parents=True, exist_ok=True)
+
+ site_icon_href: str | None = None
+ if site_icon is not None:
+ _validate_site_icon(site_icon)
+ site_icon_ext = site_icon.suffix.lower()
+ icon_output_path = output_dir / "assets" / f"site-icon{site_icon_ext}"
+ icon_output_path.write_bytes(site_icon.read_bytes())
+ site_icon_href = f"assets/{icon_output_path.name}"
+ elif icon_link_url:
+ logger.warning("Icon link URL provided but no site icon was configured; ignoring icon link")
+
+ env = Environment(
+ loader=PackageLoader("felis.browser", "templates"),
+ autoescape=select_autoescape(["html", "xml"]),
+ trim_blocks=True,
+ lstrip_blocks=True,
+ )
+
+ schema_pages: list[dict[str, object]] = []
+ filename_counts: dict[str, int] = {}
+ table_filename_counts: dict[str, int] = {}
+
+ for schema, source_path in zip(schemas, source_paths, strict=True):
+ name = _schema_name(schema, source_path)
+ logger.debug("Building schema: %s", name)
+ schema_slug = _slugify(name)
+ count = filename_counts.get(schema_slug, 0) + 1
+ filename_counts[schema_slug] = count
+ filename = f"{schema_slug}.html" if count == 1 else f"{schema_slug}-{count}.html"
+
+ prepared_tables: list[dict[str, object]] = []
+ for table in schema.tables:
+ logger.debug("Building table: %s.%s", name, table.name)
+ table_anchor = f"table-{_slugify(table.name)}"
+ table_base = f"{schema_slug}--{_slugify(table.name)}"
+ table_count = table_filename_counts.get(table_base, 0) + 1
+ table_filename_counts[table_base] = table_count
+ table_filename = f"{table_base}.html" if table_count == 1 else f"{table_base}-{table_count}.html"
+
+ columns: list[dict[str, object]] = []
+ for column in table.columns:
+ logger.debug("Building column: %s.%s.%s", name, table.name, column.name)
+ col_slug = _slugify(column.name)
+ details_fields: list[dict[str, str]] = []
+ if include_column_details:
+ model = column.__class__
+ defaults_by_alias = {
+ (field_info.alias or field_name): field_info.default
+ for field_name, field_info in model.model_fields.items()
+ }
+
+ column_data = column.model_dump(by_alias=True, exclude_none=False, exclude_defaults=False)
+ for key, value in column_data.items():
+ if key == "@id":
+ continue
+ if value is None:
+ continue
+ if key in defaults_by_alias and value == defaults_by_alias[key]:
+ continue
+ details_fields.append({"key": key, "display_value": _format_field_value(value)})
+
+ columns.append(
+ {
+ "id": column.id,
+ "name": column.name,
+ "datatype": str(column.datatype),
+ "ucd": column.ivoa_ucd or "",
+ "description": column.description or "",
+ "unit": column.ivoa_unit or column.fits_tunit or "",
+ "anchor": f"{table_anchor}-col-{col_slug}",
+ "details_anchor": f"{table_anchor}-col-{col_slug}-details"
+ if include_column_details
+ else "",
+ "details_fields": details_fields,
+ }
+ )
+
+ prepared_tables.append(
+ {
+ "table": table,
+ "anchor": table_anchor,
+ "page_filename": table_filename,
+ "columns": columns,
+ }
+ )
+
+ schema_column_targets: dict[str, dict[str, str]] = {}
+ for prepared in prepared_tables:
+ table_name = str(getattr(prepared["table"], "name"))
+ table_page_filename = str(prepared["page_filename"])
+ for column in prepared["columns"]:
+ column_id = str(column["id"])
+ schema_column_targets[column_id] = {
+ "id": column_id,
+ "name": str(column["name"]),
+ "anchor": str(column["anchor"]),
+ "table_name": table_name,
+ "table_page_filename": table_page_filename,
+ }
+
+ table_entries: list[dict[str, object]] = []
+ for prepared in prepared_tables:
+ table = prepared["table"]
+ table_anchor = str(prepared["anchor"])
+ table_filename = str(prepared["page_filename"])
+ columns: list[dict[str, object]] = prepared["columns"]
+ column_anchors = {str(column["id"]): str(column["anchor"]) for column in columns}
+
+ def _column_entry(column_id: str) -> dict[str, str]:
+ if column_id in schema_column_targets:
+ return dict(schema_column_targets[column_id])
+ return {
+ "id": column_id,
+ "name": _column_label(column_id),
+ "anchor": column_anchors.get(column_id, ""),
+ "table_name": _table_label(column_id),
+ "table_page_filename": "",
+ }
+
+ primary_key_entries: list[dict[str, str]] = []
+ if table.primary_key is not None:
+ primary_key_columns = (
+ table.primary_key if isinstance(table.primary_key, list) else [table.primary_key]
+ )
+ for column_id in primary_key_columns:
+ column_id_str = str(column_id)
+ primary_key_entries.append(_column_entry(column_id_str))
+
+ index_entries: list[dict[str, object]] = []
+ index_filename_counts: dict[str, int] = {}
+ for index in table.indexes:
+ index_base = f"{table_anchor}-index-{_slugify(index.name)}"
+ index_count = index_filename_counts.get(index_base, 0) + 1
+ index_filename_counts[index_base] = index_count
+ index_anchor = f"{index_base}" if index_count == 1 else f"{index_base}-{index_count}"
+ if index.columns is not None:
+ definition_kind = "columns"
+ definition_values = [_column_entry(str(column_id)) for column_id in index.columns]
+ else:
+ definition_kind = "expressions"
+ definition_values = list(index.expressions or [])
+ index_entries.append(
+ {
+ "name": index.name,
+ "description": index.description or "",
+ "anchor": index_anchor,
+ "definition_kind": definition_kind,
+ "definition_values": definition_values,
+ }
+ )
+
+ constraint_entries: list[dict[str, object]] = []
+ constraint_filename_counts: dict[str, int] = {}
+ for constraint in table.constraints:
+ constraint_base = f"{table_anchor}-constraint-{_slugify(constraint.name)}"
+ constraint_count = constraint_filename_counts.get(constraint_base, 0) + 1
+ constraint_filename_counts[constraint_base] = constraint_count
+ constraint_anchor = (
+ f"{constraint_base}" if constraint_count == 1 else f"{constraint_base}-{constraint_count}"
+ )
+ constraint_type = str(getattr(constraint, "type", ""))
+ entry: dict[str, object] = {
+ "name": constraint.name,
+ "type": constraint_type,
+ "description": constraint.description or "",
+ "anchor": constraint_anchor,
+ "deferrable": constraint.deferrable,
+ "initially": constraint.initially or "",
+ }
+
+ if constraint_type == "Check":
+ entry["expression"] = str(getattr(constraint, "expression", ""))
+ elif constraint_type == "Unique":
+ unique_columns = [str(col) for col in getattr(constraint, "columns", [])]
+ entry["columns"] = [_column_entry(column_id) for column_id in unique_columns]
+ elif constraint_type == "ForeignKey":
+ fk_columns = [str(col) for col in getattr(constraint, "columns", [])]
+ ref_columns = [str(col) for col in getattr(constraint, "referenced_columns", [])]
+ entry["columns"] = [_column_entry(column_id) for column_id in fk_columns]
+ entry["referenced_columns"] = [_column_entry(column_id) for column_id in ref_columns]
+ entry["on_delete"] = str(getattr(constraint, "on_delete", "") or "")
+ entry["on_update"] = str(getattr(constraint, "on_update", "") or "")
+
+ constraint_entries.append(entry)
+
+ table_entries.append(
+ {
+ "name": table.name,
+ "description": table.description or "",
+ "anchor": table_anchor,
+ "page_filename": table_filename,
+ "primary_key": primary_key_entries,
+ "include_column_details": include_column_details,
+ "columns": columns,
+ "indexes": index_entries,
+ "constraints": constraint_entries,
+ }
+ )
+
+ schema_pages.append(
+ {
+ "name": name,
+ "description": schema.description or "",
+ "source_path": source_path,
+ "filename": filename,
+ "tables": table_entries,
+ }
+ )
+
+ schema_pages.sort(key=lambda page: str(page["name"]).lower())
+
+ index_template = env.get_template("index.html.j2")
+ schema_template = env.get_template("schema.html.j2")
+ table_template = env.get_template("table.html.j2")
+
+ (output_dir / "index.html").write_text(
+ index_template.render(
+ schemas=schema_pages, site_icon_href=site_icon_href, icon_link_url=icon_link_url
+ ),
+ encoding="utf-8",
+ )
+
+ for page in schema_pages:
+ (output_dir / str(page["filename"])).write_text(
+ schema_template.render(
+ current_schema=page,
+ schemas=schema_pages,
+ site_icon_href=site_icon_href,
+ icon_link_url=icon_link_url,
+ ),
+ encoding="utf-8",
+ )
+
+ for table in page["tables"]:
+ (output_dir / str(table["page_filename"])).write_text(
+ table_template.render(
+ current_schema=page,
+ current_table=table,
+ schemas=schema_pages,
+ site_icon_href=site_icon_href,
+ icon_link_url=icon_link_url,
+ ),
+ encoding="utf-8",
+ )
+
+ css_source = resources.files("felis.browser").joinpath("static/style.css").read_text(encoding="utf-8")
+ (output_dir / "assets" / "style.css").write_text(css_source, encoding="utf-8")
+ js_source = resources.files("felis.browser").joinpath("static/sidebar.js").read_text(encoding="utf-8")
+ (output_dir / "assets" / "sidebar.js").write_text(js_source, encoding="utf-8")
diff --git a/python/felis/browser/static/sidebar.js b/python/felis/browser/static/sidebar.js
new file mode 100644
index 00000000..24bfb269
--- /dev/null
+++ b/python/felis/browser/static/sidebar.js
@@ -0,0 +1,193 @@
+(function () {
+ function initSidebarResize() {
+ var sidebar = document.querySelector(".sidebar");
+ var resizer = document.getElementById("sidebar-resizer");
+ if (!sidebar || !resizer) {
+ return;
+ }
+
+ var isDragging = false;
+
+ function onMouseMove(event) {
+ if (!isDragging) {
+ return;
+ }
+ var minWidth = 220;
+ var maxWidth = Math.floor(window.innerWidth * 0.6);
+ var newWidth = Math.min(Math.max(event.clientX, minWidth), maxWidth);
+ sidebar.style.width = String(newWidth) + "px";
+ }
+
+ function onMouseUp() {
+ isDragging = false;
+ document.body.style.userSelect = "";
+ }
+
+ resizer.addEventListener("mousedown", function () {
+ if (window.innerWidth <= 900) {
+ return;
+ }
+ isDragging = true;
+ document.body.style.userSelect = "none";
+ });
+
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+ }
+
+ function setCollapsed(collapsed) {
+ document.body.classList.toggle("sidebar-collapsed", collapsed);
+ var button = document.getElementById("sidebar-toggle");
+ if (button) {
+ button.setAttribute("aria-expanded", String(!collapsed));
+ button.textContent = collapsed ? "Show Menu" : "Hide Menu";
+ }
+ }
+
+ function initSidebarToggle() {
+ var button = document.getElementById("sidebar-toggle");
+ if (!button) {
+ return;
+ }
+
+ button.addEventListener("click", function () {
+ var collapsed = !document.body.classList.contains("sidebar-collapsed");
+ setCollapsed(collapsed);
+ });
+
+ setCollapsed(false);
+ }
+
+ function initTreeSectionSummaries() {
+ var summaries = document.querySelectorAll("details.tree-section > summary");
+ summaries.forEach(function (summary) {
+ summary.addEventListener("click", function (event) {
+ var link = event.target.closest("a");
+ if (link && summary.contains(link)) {
+ event.preventDefault();
+ event.stopPropagation();
+ window.location.href = link.getAttribute("href");
+ return;
+ }
+
+ // Allow toggling only when clicking near the native disclosure marker.
+ var markerWidth = 20;
+ var bounds = summary.getBoundingClientRect();
+ if (event.clientX > bounds.left + markerWidth) {
+ event.preventDefault();
+ }
+ });
+ });
+ }
+
+ function setDetailsOpen(root, isOpen) {
+ var detailsNodes = root.querySelectorAll("details");
+ detailsNodes.forEach(function (node) {
+ node.open = isOpen;
+ });
+ }
+
+ function initTreeControls() {
+ var sidebars = document.querySelectorAll(".sidebar");
+ sidebars.forEach(function (sidebar) {
+ sidebar.addEventListener("click", function (event) {
+ var button = event.target.closest("button[data-tree-action]");
+ if (!button || !sidebar.contains(button)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ var action = button.getAttribute("data-tree-action");
+ if (action === "expand-all") {
+ setDetailsOpen(sidebar, true);
+ return;
+ }
+
+ if (action === "collapse-all") {
+ setDetailsOpen(sidebar, false);
+ return;
+ }
+
+ var detailsNode = button.closest("details");
+ if (!detailsNode) {
+ return;
+ }
+
+ if (action === "expand-node") {
+ detailsNode.open = true;
+ setDetailsOpen(detailsNode, true);
+ return;
+ }
+
+ if (action === "collapse-node") {
+ setDetailsOpen(detailsNode, false);
+ detailsNode.open = false;
+ }
+ });
+ });
+ }
+
+ function setColumnDetailsExpanded(toggleButton, expanded) {
+ var targetId = toggleButton.getAttribute("data-column-details-target");
+ if (!targetId) {
+ return;
+ }
+
+ var detailsRow = document.getElementById(targetId);
+ if (!detailsRow) {
+ return;
+ }
+
+ detailsRow.hidden = !expanded;
+ toggleButton.setAttribute("aria-expanded", String(expanded));
+ }
+
+ function initColumnDetailsControls() {
+ document.addEventListener("click", function (event) {
+ var toggleButton = event.target.closest("button[data-column-details-target]");
+ if (toggleButton) {
+ var expanded = toggleButton.getAttribute("aria-expanded") === "true";
+ setColumnDetailsExpanded(toggleButton, !expanded);
+ return;
+ }
+
+ var actionButton = event.target.closest("button[data-column-details-action]");
+ if (!actionButton) {
+ return;
+ }
+
+ var action = actionButton.getAttribute("data-column-details-action");
+ var panel = actionButton.closest(".panel");
+ if (!panel) {
+ return;
+ }
+
+ var toggleButtons = panel.querySelectorAll("button[data-column-details-target]");
+ toggleButtons.forEach(function (button) {
+ if (action === "expand-all") {
+ setColumnDetailsExpanded(button, true);
+ } else if (action === "collapse-all") {
+ setColumnDetailsExpanded(button, false);
+ }
+ });
+ });
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", function () {
+ initSidebarToggle();
+ initSidebarResize();
+ initTreeSectionSummaries();
+ initTreeControls();
+ initColumnDetailsControls();
+ });
+ } else {
+ initSidebarToggle();
+ initSidebarResize();
+ initTreeSectionSummaries();
+ initTreeControls();
+ initColumnDetailsControls();
+ }
+})();
diff --git a/python/felis/browser/static/style.css b/python/felis/browser/static/style.css
new file mode 100644
index 00000000..37a942df
--- /dev/null
+++ b/python/felis/browser/static/style.css
@@ -0,0 +1,359 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: "Source Sans 3", "Noto Sans", sans-serif;
+ line-height: 1.4;
+ background: #f4f6f9;
+ color: #1e293b;
+}
+
+.header {
+ background: #0f766e;
+ color: #ffffff;
+ padding: 1rem;
+ position: relative;
+}
+
+.header-site-icon {
+ max-height: 4.2rem;
+ max-width: 28rem;
+ width: auto;
+ height: auto;
+ object-fit: contain;
+ display: block;
+}
+
+.header-site-icon-link {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ line-height: 0;
+}
+
+.breadcrumb {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem;
+ margin: 0;
+ font-size: 1.4rem;
+ font-weight: 700;
+}
+
+.breadcrumb a {
+ color: #ffffff;
+ text-decoration: none;
+}
+
+.breadcrumb a:hover {
+ text-decoration: underline;
+}
+
+.breadcrumb-kind {
+ color: #d1fae5;
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
+.source-file {
+ margin: 0.4rem 0 0;
+}
+
+.source-file-label {
+ font-weight: 600;
+}
+
+.sidebar-toggle {
+ margin-top: 0.6rem;
+ border: 1px solid #e2e8f0;
+ background: #ffffff;
+ color: #0f766e;
+ border-radius: 6px;
+ padding: 0.35rem 0.6rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.sidebar-toggle:hover {
+ background: #f8fafc;
+}
+
+.layout {
+ display: flex;
+ min-height: calc(100vh - 80px);
+}
+
+.sidebar {
+ background: #ffffff;
+ border-right: 1px solid #dbe1ea;
+ padding: 1rem;
+ width: 300px;
+ min-width: 220px;
+ max-width: 60vw;
+ max-height: calc(100vh - 80px);
+ overflow-y: auto;
+ overflow-x: auto;
+ flex: 0 0 auto;
+}
+
+.sidebar-resizer {
+ width: 8px;
+ cursor: col-resize;
+ background: linear-gradient(to right, #e2e8f0, #f8fafc);
+ border-right: 1px solid #dbe1ea;
+ flex: 0 0 8px;
+}
+
+.sidebar-resizer:hover {
+ background: linear-gradient(to right, #cbd5e1, #e2e8f0);
+}
+
+.content {
+ flex: 1;
+ padding: 1rem;
+}
+
+.panel {
+ background: #ffffff;
+ border: 1px solid #dbe1ea;
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1rem;
+}
+
+.panel-plain {
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+ padding: 0;
+ margin-bottom: 0;
+}
+
+.tree,
+.tree ul,
+.schema-list {
+ list-style: none;
+ margin: 0;
+ padding-left: 1rem;
+}
+
+.tree > li,
+.schema-list > li {
+ margin-bottom: 0.5rem;
+}
+
+.tree-node {
+ margin: 0.25rem 0;
+}
+
+.sidebar-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin-bottom: 0.75rem;
+}
+
+.tree-action-button {
+ border: 1px solid #cbd5e1;
+ background: #f8fafc;
+ color: #1e293b;
+ border-radius: 6px;
+ font-size: 0.78rem;
+ padding: 0.15rem 0.4rem;
+ cursor: pointer;
+}
+
+.tree-action-button:hover {
+ background: #eef2ff;
+}
+
+.tree-action-icon {
+ width: 1.1rem;
+ height: 1.1rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+
+.tree-action-icon .icon {
+ width: 0.72rem;
+ height: 0.72rem;
+ display: block;
+}
+
+.tree-node > summary {
+ cursor: pointer;
+ font-weight: 600;
+ color: #0f172a;
+ padding: 0.1rem 0;
+ white-space: nowrap;
+}
+
+.tree-node-actions {
+ display: inline-flex;
+ gap: 0.25rem;
+ margin-left: 0.6rem;
+ vertical-align: middle;
+}
+
+.tree-node > summary:hover {
+ color: #0f766e;
+}
+
+.tree-node ul {
+ margin-top: 0.2rem;
+}
+
+.tree-columns,
+.tree-indexes,
+.tree-constraints {
+ margin-left: 1rem;
+}
+
+.tree-columns > summary,
+.tree-indexes > summary,
+.tree-constraints > summary {
+ font-weight: 500;
+ color: #334155;
+}
+
+.tree a {
+ white-space: nowrap;
+}
+
+body.sidebar-collapsed .sidebar {
+ width: 0;
+ min-width: 0;
+ max-width: 0;
+ padding: 0;
+ border: 0;
+ overflow: hidden;
+ resize: none;
+}
+
+body.sidebar-collapsed .sidebar-resizer {
+ width: 0;
+ flex-basis: 0;
+ border: 0;
+}
+
+a {
+ color: #0f766e;
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+th,
+td {
+ border: 1px solid #dbe1ea;
+ text-align: left;
+ padding: 0.5rem;
+ vertical-align: top;
+}
+
+th {
+ background: #f8fafc;
+}
+
+.muted {
+ color: #475569;
+ font-size: 0.9rem;
+}
+
+.column-details-actions {
+ display: flex;
+ gap: 0.35rem;
+ margin: 0.5rem 0 0.7rem;
+}
+
+.column-details-toggle {
+ border: 1px solid #cbd5e1;
+ background: #f8fafc;
+ color: #1e293b;
+ border-radius: 4px;
+ font-size: 0.72rem;
+ width: 1.4rem;
+ height: 1.4rem;
+ padding: 0;
+ margin-right: 0.35rem;
+ cursor: pointer;
+ vertical-align: middle;
+}
+
+.column-details-toggle:hover {
+ background: #eef2ff;
+}
+
+.column-details-toggle .toggle-expanded {
+ display: none;
+}
+
+.column-details-toggle[aria-expanded="true"] .toggle-expanded {
+ display: inline;
+}
+
+.column-details-toggle[aria-expanded="true"] .toggle-collapsed {
+ display: none;
+}
+
+.column-details-row > td {
+ background: #f8fafc;
+ padding: 0.45rem;
+}
+
+.column-details-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.column-details-table .field-key {
+ width: 30%;
+ background: #eef2f7;
+ font-weight: 600;
+}
+
+.column-details-table .field-value {
+ width: 70%;
+}
+
+@media (max-width: 900px) {
+ .layout {
+ flex-direction: column;
+ }
+
+ .sidebar {
+ width: 100%;
+ min-width: 0;
+ max-width: none;
+ resize: none;
+ border-right: 0;
+ border-bottom: 1px solid #dbe1ea;
+ max-height: 35vh;
+ }
+
+ .sidebar-resizer {
+ display: none;
+ }
+
+ body.sidebar-collapsed .sidebar {
+ max-height: 0;
+ }
+
+ .header-site-icon {
+ max-height: 3rem;
+ max-width: 18rem;
+ }
+}
diff --git a/python/felis/browser/templates/index.html.j2 b/python/felis/browser/templates/index.html.j2
new file mode 100644
index 00000000..67d543ea
--- /dev/null
+++ b/python/felis/browser/templates/index.html.j2
@@ -0,0 +1,137 @@
+
+
+
+
+
+ Home
+
+
+
+
+
+
+
+
+
+
+
+ Select a schema to browse its tables and columns.
+
+ {% for schema in schemas %}
+ -
+ {{ schema.name }}
+ {% set table_count = schema.tables | length %}
+ ({{ table_count }} {{ "table" if table_count == 1 else "tables" }})
+ {% if schema.description %}
+
{{ schema.description }}
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
+
+
+
diff --git a/python/felis/browser/templates/schema.html.j2 b/python/felis/browser/templates/schema.html.j2
new file mode 100644
index 00000000..17f12808
--- /dev/null
+++ b/python/felis/browser/templates/schema.html.j2
@@ -0,0 +1,138 @@
+
+
+
+
+
+ {{ current_schema.name }} - Felis Browser
+
+
+
+
+
+
+
+
+
+
+
+ Source File. {{ current_schema.source_path }}
+ Select a table to view its columns and details.
+
+ {% for table in current_schema.tables %}
+ -
+ {{ table.name }}
+ {% set column_count = table.columns | length %}
+ ({{ column_count }} {{ "column" if column_count == 1 else "columns" }})
+ {% if table.description %}
+
{{ table.description }}
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
+
+
+
diff --git a/python/felis/browser/templates/table.html.j2 b/python/felis/browser/templates/table.html.j2
new file mode 100644
index 00000000..39247349
--- /dev/null
+++ b/python/felis/browser/templates/table.html.j2
@@ -0,0 +1,308 @@
+
+
+
+
+
+ {{ current_table.name }} - {{ current_schema.name }} - Felis Browser
+
+
+
+
+
+
+
+
+
+
+
+ {% if current_table.description %}
+ {{ current_table.description }}
+ {% endif %}
+ {% if current_table.primary_key %}
+ Primary Key
+
+ {% for key_column in current_table.primary_key -%}
+ {%- if not loop.first %}, {% endif -%}
+ {%- if key_column.anchor -%}
+ {{ key_column.name }}
+ {%- else -%}
+ {{ key_column.name }}
+ {%- endif -%}
+ {%- endfor %}
+
+ {% endif %}
+ Columns
+ {% if current_table.include_column_details %}
+
+
+
+
+ {% endif %}
+
+
+
+ | Column |
+ Datatype |
+ UCD |
+ Unit |
+ Description |
+
+
+
+ {% for column in current_table.columns %}
+
+ |
+ {% if current_table.include_column_details %}
+
+ {% endif %}
+ {{ column.name }}
+ |
+ {{ column.datatype }} |
+ {{ column.ucd }} |
+ {{ column.unit }} |
+ {{ column.description }} |
+
+ {% if current_table.include_column_details %}
+
+
+
+
+ {% for field in column.details_fields %}
+
+ | {{ field.key }} |
+ {{ field.display_value }} |
+
+ {% endfor %}
+
+
+ |
+
+ {% endif %}
+ {% endfor %}
+
+
+ {% if current_table.indexes %}
+ Indexes
+
+
+
+ | Index |
+ Type |
+ Definition |
+ Description |
+
+
+
+ {% for index in current_table.indexes %}
+
+ | {{ index.name }} |
+ {{ index.definition_kind }} |
+
+ {% if index.definition_kind == "columns" %}
+ {% for col in index.definition_values %}
+ {% if not loop.first %}, {% endif %}
+ {% if col.anchor %}
+ {{ col.name }}
+ {% else %}
+ {{ col.name }}
+ {% endif %}
+ {% endfor %}
+ {% else %}
+ {{ index.definition_values | join(', ') }}
+ {% endif %}
+ |
+ {{ index.description }} |
+
+ {% endfor %}
+
+
+ {% endif %}
+ {% if current_table.constraints %}
+ Constraints
+
+
+
+ | Constraint |
+ Type |
+ Definition |
+ Description |
+
+
+
+ {% for constraint in current_table.constraints %}
+
+ | {{ constraint.name }} |
+ {{ constraint.type }} |
+
+ {% if constraint.type == "Check" %}
+ {{ constraint.expression }}
+ {% elif constraint.type == "Unique" %}
+ {% for col in constraint.columns %}
+ {% if not loop.first %}, {% endif %}
+ {% if col.anchor %}
+ {{ col.name }}
+ {% else %}
+ {{ col.name }}
+ {% endif %}
+ {% endfor %}
+ {% elif constraint.type == "ForeignKey" %}
+
+ Columns:
+ {% for col in constraint.columns %}
+ {% if not loop.first %}, {% endif %}
+ {% if col.anchor %}
+ {{ col.name }}
+ {% else %}
+ {{ col.name }}
+ {% endif %}
+ {% endfor %}
+
+
+ References:
+ {% for ref_col in constraint.referenced_columns %}
+ {% if not loop.first %}, {% endif %}
+ {% if ref_col.table_page_filename %}
+ {{ ref_col.table_name }}.{% if ref_col.anchor %} {{ ref_col.name }}{% else %}{{ ref_col.name }}{% endif %}
+ {% else %}
+ {{ ref_col.id }}
+ {% endif %}
+ {% endfor %}
+
+ {% if constraint.on_delete %}
+ On Delete: {{ constraint.on_delete }}
+ {% endif %}
+ {% if constraint.on_update %}
+ On Update: {{ constraint.on_update }}
+ {% endif %}
+ {% endif %}
+ {% if constraint.deferrable %}
+ Deferrable: true
+ {% if constraint.initially %}
+ Initially: {{ constraint.initially }}
+ {% endif %}
+ {% endif %}
+ |
+ {{ constraint.description }} |
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
+
diff --git a/python/felis/cli.py b/python/felis/cli.py
index 9e4355fa..599a3092 100644
--- a/python/felis/cli.py
+++ b/python/felis/cli.py
@@ -25,12 +25,14 @@
import logging
from collections.abc import Iterable
+from pathlib import Path
from typing import IO
import click
from pydantic import ValidationError
from . import __version__
+from .browser import render_static_site
from .datamodel import Schema
from .db.database_context import create_database_context
from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
@@ -646,5 +648,96 @@ def dump(
logger.info("Dumped '%s' to '%s'", uris[0], uris[1])
+@cli.command("browser", help="Generate a static schema browser site from YAML files")
+@click.option(
+ "--output-dir",
+ type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
+ required=True,
+ help="Output directory for generated static HTML",
+)
+@click.option(
+ "--disable-column-details",
+ is_flag=True,
+ help="Do not include expandable column details in table pages.",
+)
+@click.option(
+ "--site-icon",
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
+ default=None,
+ help="Path to a site icon image to center in the page header.",
+)
+@click.option(
+ "--icon-link-url",
+ type=str,
+ default=None,
+ help="URL to open when clicking the site icon.",
+)
+@click.argument(
+ "files",
+ nargs=-1,
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
+ required=True,
+)
+@click.pass_context
+def browser(
+ ctx: click.Context,
+ output_dir: Path,
+ disable_column_details: bool,
+ site_icon: Path | None,
+ icon_link_url: str | None,
+ files: tuple[Path, ...],
+) -> None:
+ """Generate a static HTML browser for one or more YAML schema files."""
+ paths = sorted(files)
+
+ for path in paths:
+ if path.suffix.lower() not in {".yaml", ".yml"}:
+ raise click.ClickException(f"Input file must be .yaml or .yml: {path}")
+
+ if site_icon is not None and site_icon.suffix.lower() not in {
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".svg",
+ ".webp",
+ ".ico",
+ }:
+ raise click.ClickException(f"Site icon must be a graphics file: {site_icon}")
+
+ schemas: list[Schema] = []
+ source_paths: list[str] = []
+ for path in paths:
+ try:
+ schema = Schema.from_uri(
+ str(path),
+ context={
+ "id_generation": ctx.obj["id_generation"],
+ "column_ref_index_increment": ctx.obj["column_ref_index_increment"],
+ },
+ )
+ except ValidationError as e:
+ raise click.ClickException(f"Schema validation failed for '{path}': {e}") from e
+ except Exception as e:
+ raise click.ClickException(f"Failed loading schema '{path}': {e}") from e
+ schemas.append(schema)
+ source_paths.append(str(path))
+
+ try:
+ render_static_site(
+ schemas=schemas,
+ source_paths=source_paths,
+ output_dir=output_dir,
+ include_column_details=not disable_column_details,
+ site_icon=site_icon,
+ icon_link_url=icon_link_url,
+ )
+ except Exception as e:
+ raise click.ClickException(f"Failed rendering browser site: {e}") from e
+
+ click.echo(f"Generated browser site at: {output_dir}")
+ click.echo(f"Loaded {len(paths)} file(s), rendered {len(schemas)} schema page(s)")
+
+
if __name__ == "__main__":
cli()
diff --git a/requirements.txt b/requirements.txt
index 0edf4293..deec7a51 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,9 @@
alembic
astropy
+beautifulsoup4>=4.12
click
deepdiff
+jinja2
lsst-resources @ git+https://github.com/lsst/resources@main
lsst-utils @ git+https://github.com/lsst/utils@main
numpy
diff --git a/tests/test_browser.py b/tests/test_browser.py
new file mode 100644
index 00000000..19ce89c0
--- /dev/null
+++ b/tests/test_browser.py
@@ -0,0 +1,604 @@
+# This file is part of felis.
+#
+# Developed for the LSST Data Management System.
+# This product includes software developed by the LSST Project
+# (https://www.lsst.org).
+# See the COPYRIGHT file at the top-level directory of this distribution
+# for details of code ownership.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+import os
+import tempfile
+import unittest
+from pathlib import Path
+
+from bs4 import BeautifulSoup
+from bs4.element import Tag
+
+from felis.browser.render import render_static_site
+from felis.datamodel import Schema
+
+TEST_DIR = os.path.abspath(os.path.dirname(__file__))
+
+
+def render_browser_site(
+ output_dir: str,
+ files: list[str],
+ include_column_details: bool = True,
+ site_icon: str | None = None,
+ icon_link_url: str | None = None,
+) -> None:
+ """Render browser pages from schema files using renderer directly."""
+ paths = sorted(files)
+ for path in paths:
+ if os.path.splitext(path)[1].lower() not in {".yaml", ".yml"}:
+ raise ValueError(f"Input file must be .yaml or .yml: {path}")
+
+ schemas: list[Schema] = []
+ source_paths: list[str] = []
+ for path in paths:
+ schemas.append(
+ Schema.from_uri(path, context={"id_generation": True, "column_ref_index_increment": None})
+ )
+ source_paths.append(path)
+
+ render_static_site(
+ schemas=schemas,
+ source_paths=source_paths,
+ output_dir=Path(output_dir),
+ include_column_details=include_column_details,
+ site_icon=Path(site_icon) if site_icon is not None else None,
+ icon_link_url=icon_link_url,
+ )
+
+
+def parse_html(content: str) -> BeautifulSoup:
+ """Parse rendered HTML for structural assertions in tests."""
+ return BeautifulSoup(content, "html.parser")
+
+
+def get_page_html_by_title(output_dir: str, title: str) -> str:
+ """Return rendered page HTML whose title exactly matches ``title``."""
+ for name in os.listdir(output_dir):
+ if not name.endswith(".html"):
+ continue
+ page_path = os.path.join(output_dir, name)
+ with open(page_path, encoding="utf-8") as f:
+ page_html = f.read()
+ page_soup = parse_html(page_html)
+ page_title = page_soup.find("title")
+ if page_title is not None and page_title.get_text(strip=True) == title:
+ return page_html
+ raise AssertionError(f"Rendered page with title '{title}' was not found")
+
+
+def get_page_soup_by_title(output_dir: str, title: str) -> BeautifulSoup:
+ """Return a parsed HTML tree for a rendered page title."""
+ return parse_html(get_page_html_by_title(output_dir, title))
+
+
+def find_link(container: BeautifulSoup | Tag, href: str, title: str | None = None) -> Tag | None:
+ """Find a link by href and optional title."""
+ attrs: dict[str, str] = {"href": href}
+ if title is not None:
+ attrs["title"] = title
+ return container.find("a", attrs=attrs)
+
+
+def find_details_section(soup: BeautifulSoup, section_class: str) -> Tag | None:
+ """Find a details section by its class name."""
+ return soup.find("details", class_=section_class)
+
+
+def find_summary_link(section: Tag | None, href: str) -> Tag | None:
+ """Find the summary link in a details section by href."""
+ if section is None:
+ return None
+ summary = section.find("summary")
+ if summary is None:
+ return None
+ return find_link(summary, href)
+
+
+def find_section_summary_link(soup: BeautifulSoup, section_class: str, href: str) -> Tag | None:
+ """Find a summary link within a named details section."""
+ for section in soup.find_all("details", class_=section_class):
+ link = find_summary_link(section, href)
+ if link is not None:
+ return link
+ return None
+
+
+def find_section_by_summary_href(soup: BeautifulSoup, section_class: str, href: str) -> Tag | None:
+ """Find a details section by class and summary-link href."""
+ for section in soup.find_all("details", class_=section_class):
+ if find_summary_link(section, href) is not None:
+ return section
+ return None
+
+
+class BrowserTestCase(unittest.TestCase):
+ """Tests for the static browser command."""
+
+ def test_browser_renders_table_features(self) -> None:
+ """Render table/browser features and validate key structure/content.
+
+ This test covers navigation controls, section wiring, details rows,
+ and index/constraint rendering (including FK references).
+ """
+ schema_yaml = """
+name: demo
+"@id": "#demo"
+tables:
+ - name: ParentTable
+ "@id": "#ParentTable"
+ description: Parent rows.
+ columns:
+ - name: id
+ "@id": "#ParentTable.id"
+ datatype: int
+ description: Parent key.
+ primaryKey: "#ParentTable.id"
+
+ - name: ChildTable
+ "@id": "#ChildTable"
+ description: Child rows.
+ columns:
+ - name: id
+ "@id": "#ChildTable.id"
+ datatype: int
+ description: Child key.
+ - name: code
+ "@id": "#ChildTable.code"
+ datatype: int
+ "ivoa:ucd": "meta.code"
+ "tap:std": 0
+ description: Business code.
+ - name: parent_id
+ "@id": "#ChildTable.parent_id"
+ datatype: int
+ description: Parent link.
+ - name: amount
+ "@id": "#ChildTable.amount"
+ datatype: int
+ description: Positive amount.
+ primaryKey: "#ChildTable.id"
+ indexes:
+ - name: IDX_ChildTable_code
+ "@id": "#IDX_ChildTable_code"
+ description: Index on code.
+ columns:
+ - "#ChildTable.code"
+ constraints:
+ - name: CK_ChildTable_amount_positive
+ "@id": "#CK_ChildTable_amount_positive"
+ "@type": Check
+ description: Amount must be positive.
+ expression: "amount > 0"
+ - name: UQ_ChildTable_code_parent
+ "@id": "#UQ_ChildTable_code_parent"
+ "@type": Unique
+ description: Unique code per parent.
+ columns:
+ - "#ChildTable.code"
+ - "#ChildTable.parent_id"
+ - name: FK_ChildTable_ParentTable
+ "@id": "#FK_ChildTable_ParentTable"
+ "@type": ForeignKey
+ description: Child must reference a parent.
+ columns:
+ - "#ChildTable.parent_id"
+ referencedColumns:
+ - "#ParentTable.id"
+ on_delete: CASCADE
+ on_update: NO ACTION
+""".strip()
+
+ with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir:
+ schema_path = os.path.join(tmpdir, "constraints.yaml")
+ output_dir = os.path.join(tmpdir, "browser")
+ with open(schema_path, "w", encoding="utf-8") as f:
+ f.write(schema_yaml)
+
+ render_browser_site(output_dir=output_dir, files=[schema_path])
+
+ self.assertTrue(os.path.exists(os.path.join(output_dir, "index.html")))
+ schema_soup = get_page_soup_by_title(output_dir, "demo - Felis Browser")
+ child_table_soup = get_page_soup_by_title(output_dir, "ChildTable - demo - Felis Browser")
+
+ # Validate base breadcrumb navigation and global tree controls.
+ self.assertIsNotNone(find_link(schema_soup, "index.html"))
+ self.assertIsNotNone(find_link(child_table_soup, "index.html"))
+ self.assertIsNotNone(schema_soup.find(attrs={"data-tree-action": "expand-all"}))
+ self.assertIsNotNone(schema_soup.find(attrs={"data-tree-action": "collapse-all"}))
+ self.assertIsNotNone(child_table_soup.find(attrs={"data-tree-action": "expand-node"}))
+ self.assertIsNotNone(child_table_soup.find(attrs={"data-tree-action": "collapse-node"}))
+ self.assertIsNotNone(child_table_soup.find("button", attrs={"aria-label": "Expand schema"}))
+ self.assertIsNotNone(child_table_soup.find("button", attrs={"aria-label": "Collapse table"}))
+ self.assertIsNotNone(child_table_soup.find("svg", class_="icon-chevron-right"))
+ self.assertIsNotNone(child_table_soup.find("svg", class_="icon-chevron-down"))
+
+ # Validate tooltip-bearing links in sidebar/tree context.
+ self.assertIsNotNone(child_table_soup.find(attrs={"title": "Parent rows."}))
+ self.assertIsNotNone(child_table_soup.find(attrs={"title": "Child rows."}))
+ self.assertIsNotNone(child_table_soup.find(attrs={"title": "Business code."}))
+ self.assertIsNotNone(child_table_soup.find(attrs={"title": "Index on code."}))
+ self.assertIsNotNone(child_table_soup.find(attrs={"title": "Unique code per parent."}))
+ self.assertIsNotNone(
+ find_link(
+ child_table_soup,
+ "demo--childtable.html#table-childtable-index-idx-childtable-code",
+ title="Index on code.",
+ )
+ )
+
+ # Validate section summary linkss.
+ columns_summary = find_section_summary_link(
+ child_table_soup,
+ section_class="tree-columns",
+ href="demo--childtable.html#table-childtable-columns",
+ )
+ indexes_summary = find_section_summary_link(
+ child_table_soup,
+ section_class="tree-indexes",
+ href="demo--childtable.html#table-childtable-indexes",
+ )
+ constraints_summary = find_section_summary_link(
+ child_table_soup,
+ section_class="tree-constraints",
+ href="demo--childtable.html#table-childtable-constraints",
+ )
+ self.assertIsNotNone(columns_summary)
+ self.assertIsNotNone(indexes_summary)
+ self.assertIsNotNone(constraints_summary)
+ if columns_summary is not None:
+ self.assertFalse(columns_summary.has_attr("title"))
+ if indexes_summary is not None:
+ self.assertFalse(indexes_summary.has_attr("title"))
+ if constraints_summary is not None:
+ self.assertFalse(constraints_summary.has_attr("title"))
+
+ # Validate section collapsed/open state.
+ columns_section = find_section_by_summary_href(
+ child_table_soup,
+ section_class="tree-columns",
+ href="demo--childtable.html#table-childtable-columns",
+ )
+ indexes_section = find_section_by_summary_href(
+ child_table_soup,
+ section_class="tree-indexes",
+ href="demo--childtable.html#table-childtable-indexes",
+ )
+ constraints_section = find_section_by_summary_href(
+ child_table_soup,
+ section_class="tree-constraints",
+ href="demo--childtable.html#table-childtable-constraints",
+ )
+ self.assertIsNotNone(columns_section)
+ self.assertIsNotNone(indexes_section)
+ self.assertIsNotNone(constraints_section)
+ if columns_section is not None:
+ self.assertFalse(columns_section.has_attr("open"))
+ if indexes_section is not None:
+ self.assertFalse(indexes_section.has_attr("open"))
+ if constraints_section is not None:
+ self.assertFalse(constraints_section.has_attr("open"))
+
+ # Validate columns/details table content and detail-toggle wiring.
+ self.assertIsNotNone(child_table_soup.find(id="table-childtable-primary-key"))
+ self.assertIsNotNone(child_table_soup.find(string="IDX_ChildTable_code"))
+ self.assertIsNotNone(child_table_soup.find("th", string="UCD"))
+ self.assertIsNotNone(child_table_soup.find("td", string="meta.code"))
+ self.assertIsNotNone(child_table_soup.find(attrs={"data-column-details-action": "expand-all"}))
+ self.assertIsNotNone(child_table_soup.find(attrs={"data-column-details-action": "collapse-all"}))
+ self.assertIsNotNone(
+ child_table_soup.find(
+ attrs={"data-column-details-target": "table-childtable-col-code-details"}
+ )
+ )
+
+ # Validate content of column details row and that default values
+ # are omitted.
+ code_details_row = child_table_soup.find(id="table-childtable-col-code-details")
+ self.assertIsNotNone(code_details_row)
+ if code_details_row is not None:
+ self.assertTrue(code_details_row.has_attr("hidden"))
+ self.assertIsNotNone(code_details_row.find("th", class_="field-key", string="datatype"))
+ self.assertIsNotNone(code_details_row.find("td", class_="field-value", string="int"))
+ self.assertIsNone(code_details_row.find("th", class_="field-key", string="@id"))
+ self.assertIsNone(code_details_row.find("th", class_="field-key", string="tap:std"))
+
+ self.assertIsNotNone(find_link(child_table_soup, "#table-childtable-col-code"))
+ self.assertIsNotNone(find_link(child_table_soup, "#table-childtable-col-parent-id"))
+ self.assertIsNone(child_table_soup.find(string="#ChildTable.code"))
+
+ # Validate constraints section and cross-table FK references.
+ constraints_anchor = find_section_summary_link(
+ child_table_soup,
+ section_class="tree-constraints",
+ href="demo--childtable.html#table-childtable-constraints",
+ )
+ self.assertIsNotNone(constraints_anchor)
+ if constraints_anchor is not None:
+ self.assertEqual(constraints_anchor.get_text(strip=True), "Constraints")
+
+ self.assertIsNotNone(child_table_soup.find(id="table-childtable-constraints"))
+ self.assertIsNotNone(child_table_soup.find(string="CK_ChildTable_amount_positive"))
+ self.assertIsNotNone(child_table_soup.find("code", string="amount > 0"))
+ self.assertIsNotNone(child_table_soup.find(string="UQ_ChildTable_code_parent"))
+ self.assertIsNotNone(
+ find_link(
+ child_table_soup,
+ "demo--childtable.html#table-childtable-constraint-fk-childtable-parenttable",
+ )
+ )
+ self.assertIsNotNone(find_link(child_table_soup, "demo--parenttable.html"))
+ self.assertIsNotNone(
+ find_link(
+ child_table_soup,
+ "demo--parenttable.html#table-parenttable-col-id",
+ )
+ )
+
+ fk_row = child_table_soup.find(id="table-childtable-constraint-fk-childtable-parenttable")
+ self.assertIsNotNone(fk_row)
+ if fk_row is not None:
+ fk_text = fk_row.get_text(" ", strip=True)
+ self.assertIn("On Delete:", fk_text)
+ self.assertIn("CASCADE", fk_text)
+ self.assertIn("On Update:", fk_text)
+ self.assertIn("NO ACTION", fk_text)
+
+ # Validate schema-page singular/plural column count grammar.
+ schema_list = schema_soup.find("ul", class_="schema-list")
+ self.assertIsNotNone(schema_list)
+ if schema_list is not None:
+ count_labels = [
+ element.get_text(strip=True)
+ for element in schema_list.find_all(class_="muted")
+ if element.get_text(strip=True).startswith("(")
+ ]
+ self.assertIn("(1 column)", count_labels)
+ self.assertIn("(4 columns)", count_labels)
+
+ def test_browser_file_list(self) -> None:
+ """Generate browser output from multiple schema files.
+
+ Validate homepage links and singular/plural table count labels.
+ """
+ schema_a = """
+name: alpha
+"@id": "#alpha"
+description: Alpha schema.
+tables:
+ - name: A
+ "@id": "#A"
+ columns:
+ - name: id
+ "@id": "#A.id"
+ datatype: int
+ description: key
+""".strip()
+ schema_b = """
+name: beta
+"@id": "#beta"
+description: Beta schema.
+tables:
+ - name: B
+ "@id": "#B"
+ columns:
+ - name: id
+ "@id": "#B.id"
+ datatype: int
+ description: key
+ - name: B2
+ "@id": "#B2"
+ columns:
+ - name: id
+ "@id": "#B2.id"
+ datatype: int
+ description: key
+""".strip()
+
+ with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir:
+ file_a = os.path.join(tmpdir, "a.yaml")
+ file_b = os.path.join(tmpdir, "b.yaml")
+ output_dir = os.path.join(tmpdir, "browser-list")
+ with open(file_a, "w", encoding="utf-8") as f:
+ f.write(schema_a)
+ with open(file_b, "w", encoding="utf-8") as f:
+ f.write(schema_b)
+
+ render_browser_site(output_dir=output_dir, files=[file_a, file_b])
+
+ self.assertTrue(os.path.exists(os.path.join(output_dir, "index.html")))
+ index_soup = get_page_soup_by_title(output_dir, "Home")
+
+ # Validate top-level page shell links.
+ self.assertEqual(index_soup.title.get_text(strip=True), "Home")
+ self.assertIsNotNone(index_soup.find("nav", attrs={"aria-label": "Breadcrumb"}))
+ self.assertIsNotNone(index_soup.find("nav", class_="breadcrumb"))
+ self.assertIsNotNone(find_link(index_soup, "alpha.html"))
+ self.assertIsNotNone(find_link(index_soup, "beta.html"))
+ self.assertIsNotNone(index_soup.find(string="Alpha schema."))
+ self.assertIsNotNone(index_soup.find(string="Beta schema."))
+
+ # Validate singular/plural table count grammar per schema.
+ schema_list = index_soup.find("ul", class_="schema-list")
+ self.assertIsNotNone(schema_list)
+ if schema_list is not None:
+ count_labels = [
+ element.get_text(strip=True)
+ for element in schema_list.find_all(class_="muted")
+ if element.get_text(strip=True).startswith("(")
+ ]
+ self.assertIn("(1 table)", count_labels)
+ self.assertIn("(2 tables)", count_labels)
+
+ def test_browser_compound_primary_key_spacing(self) -> None:
+ """Render compound primary keys without whitespace before commas."""
+ schema_yaml = """
+name: demo
+"@id": "#demo"
+tables:
+ - name: CompoundKeyTable
+ "@id": "#CompoundKeyTable"
+ columns:
+ - name: first_id
+ "@id": "#CompoundKeyTable.first_id"
+ datatype: int
+ - name: second_id
+ "@id": "#CompoundKeyTable.second_id"
+ datatype: int
+ primaryKey:
+ - "#CompoundKeyTable.first_id"
+ - "#CompoundKeyTable.second_id"
+""".strip()
+
+ with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir:
+ schema_path = os.path.join(tmpdir, "compound-key.yaml")
+ output_dir = os.path.join(tmpdir, "browser")
+ with open(schema_path, "w", encoding="utf-8") as f:
+ f.write(schema_yaml)
+
+ render_browser_site(output_dir=output_dir, files=[schema_path])
+
+ table_soup = get_page_soup_by_title(output_dir, "CompoundKeyTable - demo - Felis Browser")
+
+ # Validate both primary key links are present and comma spacing is
+ # normalized.
+ primary_key_header = table_soup.find(id="table-compoundkeytable-primary-key")
+ self.assertIsNotNone(primary_key_header)
+ if primary_key_header is not None:
+ primary_key_value = primary_key_header.find_next("p")
+ self.assertIsNotNone(primary_key_value)
+ if primary_key_value is not None:
+ self.assertEqual(
+ primary_key_value.get_text(" ", strip=True).replace(" ,", ","),
+ "first_id, second_id",
+ )
+ self.assertIsNotNone(find_link(primary_key_value, "#table-compoundkeytable-col-first-id"))
+ self.assertIsNotNone(
+ find_link(primary_key_value, "#table-compoundkeytable-col-second-id")
+ )
+
+ def test_browser_non_yaml_file_error(self) -> None:
+ """Fail when input file is not YAML."""
+ with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir:
+ output_dir = os.path.join(tmpdir, "browser-invalid")
+ bad_file = os.path.join(tmpdir, "not_yaml.txt")
+ with open(bad_file, "w", encoding="utf-8") as f:
+ f.write("hello")
+
+ with self.assertRaisesRegex(ValueError, "Input file must be .yaml or .yml"):
+ render_browser_site(output_dir=output_dir, files=[bad_file])
+
+ def test_browser_disable_column_details(self) -> None:
+ """Do not render expandable column details when disabled."""
+ schema_yaml = """
+name: demo
+"@id": "#demo"
+tables:
+ - name: ChildTable
+ "@id": "#ChildTable"
+ columns:
+ - name: code
+ "@id": "#ChildTable.code"
+ datatype: int
+ description: Business code.
+""".strip()
+
+ with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir:
+ schema_path = os.path.join(tmpdir, "disable-details.yaml")
+ output_dir = os.path.join(tmpdir, "browser")
+ with open(schema_path, "w", encoding="utf-8") as f:
+ f.write(schema_yaml)
+
+ render_browser_site(output_dir=output_dir, files=[schema_path], include_column_details=False)
+
+ table_soup = get_page_soup_by_title(output_dir, "ChildTable - demo - Felis Browser")
+
+ # Validate details controls/rows are fully absent when disabled.
+ self.assertIsNone(table_soup.find(attrs={"data-column-details-action": "expand-all"}))
+ self.assertIsNone(table_soup.find(attrs={"data-column-details-action": "collapse-all"}))
+ self.assertIsNone(
+ table_soup.find(attrs={"data-column-details-target": "table-childtable-col-code-details"})
+ )
+ self.assertEqual(len(table_soup.find_all(class_="column-details-row")), 0)
+ self.assertEqual(len(table_soup.find_all(class_="field-key")), 0)
+ self.assertEqual(len(table_soup.find_all(class_="field-value")), 0)
+
+ def test_browser_site_icon(self) -> None:
+ """Render a centered header icon and optional link when configured."""
+ schema_yaml = """
+name: demo
+"@id": "#demo"
+tables:
+ - name: ChildTable
+ "@id": "#ChildTable"
+ columns:
+ - name: code
+ "@id": "#ChildTable.code"
+ datatype: int
+""".strip()
+
+ with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir:
+ schema_path = os.path.join(tmpdir, "icon.yaml")
+ output_dir = os.path.join(tmpdir, "browser")
+ icon_path = os.path.join(tmpdir, "icon.png")
+ with open(schema_path, "w", encoding="utf-8") as f:
+ f.write(schema_yaml)
+ with open(icon_path, "wb") as f:
+ f.write(b"\x89PNG\r\n\x1a\n")
+
+ render_browser_site(
+ output_dir=output_dir,
+ files=[schema_path],
+ site_icon=icon_path,
+ icon_link_url="https://www.lsst.org",
+ )
+
+ self.assertTrue(os.path.exists(os.path.join(output_dir, "assets", "site-icon.png")))
+ index_soup = get_page_soup_by_title(output_dir, "Home")
+
+ # Validate icon link wrapper and image source in rendered home
+ # page.
+ self.assertIsNotNone(
+ index_soup.find("a", class_="header-site-icon-link", href="https://www.lsst.org")
+ )
+ self.assertIsNotNone(
+ index_soup.find("img", class_="header-site-icon", src="assets/site-icon.png")
+ )
+
+ def test_browser_icon_link_warning_without_icon(self) -> None:
+ """Warn when icon_link_url is set but site_icon is not configured."""
+ schema_yaml = """
+name: demo
+"@id": "#demo"
+tables:
+ - name: ChildTable
+ "@id": "#ChildTable"
+ columns:
+ - name: code
+ "@id": "#ChildTable.code"
+ datatype: int
+""".strip()
+
+ with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir:
+ schema_path = os.path.join(tmpdir, "icon-warning.yaml")
+ output_dir = os.path.join(tmpdir, "browser")
+ with open(schema_path, "w", encoding="utf-8") as f:
+ f.write(schema_yaml)
+
+ with self.assertLogs("felis.browser.render", level="WARNING") as captured:
+ render_browser_site(
+ output_dir=output_dir,
+ files=[schema_path],
+ icon_link_url="https://www.lsst.org",
+ )
+
+ self.assertTrue(any("ignoring icon link" in message for message in captured.output))