Add spec explorer: structured interactive view of SX spec files

- _spec_explorer_data() helper: parses spec files into sections, defines,
  effects, params, source blocks, and Python translations via PyEmitter
- specs-explorer.sx: 10 defcomp components for explorer UI — cards with
  effect badges, typed param lists, collapsible SX/Python translation panels
- Route at /language/specs/explore/<slug> via docs.sx
- "Explore" link on existing spec detail pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 00:16:33 +00:00
parent 56f49f29fb
commit 4aa2133b39
4 changed files with 511 additions and 11 deletions

View File

@@ -349,15 +349,22 @@
"extensions" (~spec-overview-content
:spec-title "Extensions"
:spec-files (make-spec-files extension-spec-items))
:else (let ((spec (find-spec slug)))
(if spec
(~spec-detail-content
:spec-title (get spec "title")
:spec-desc (get spec "desc")
:spec-filename (get spec "filename")
:spec-source (read-spec-file (get spec "filename"))
:spec-prose (get spec "prose"))
(~spec-not-found :slug slug)))))))
:else (cond
(starts-with? slug "explore/")
(let ((spec-slug (slice slug 8 (string-length slug)))
(data (spec-explorer-data spec-slug)))
(if data
(~spec-explorer-content :data data)
(~spec-not-found :slug spec-slug)))
:else (let ((spec (find-spec slug)))
(if spec
(~spec-detail-content
:spec-title (get spec "title")
:spec-desc (get spec "desc")
:spec-filename (get spec "filename")
:spec-source (read-spec-file (get spec "filename"))
:spec-prose (get spec "prose"))
(~spec-not-found :slug slug))))))))
;; ---------------------------------------------------------------------------
;; Bootstrappers section

View File

@@ -34,6 +34,7 @@ def _register_sx_helpers() -> None:
"offline-demo-data": _offline_demo_data,
"prove-data": _prove_data,
"page-helpers-demo-data": _page_helpers_demo_data,
"spec-explorer-data": _spec_explorer_data,
})
@@ -141,6 +142,298 @@ def _read_spec_file(filename: str) -> str:
return ";; spec file not found"
def _spec_explorer_data(slug: str) -> dict | None:
"""Parse a spec file into structured metadata for the spec explorer.
Returns sections with defines, effects, params, source, and translations.
"""
import os
import re
from shared.sx.parser import parse_all
from shared.sx.types import Symbol, Keyword
# Look up spec metadata from nav-data (via find-spec helper)
from shared.sx.jinja_bridge import get_component_env
env = get_component_env()
all_specs = env.get("all-spec-items", [])
spec_meta = None
for item in all_specs:
if isinstance(item, dict) and item.get("slug") == slug:
spec_meta = item
break
if not spec_meta:
return None
filename = spec_meta.get("filename", "")
title = spec_meta.get("title", slug)
desc = spec_meta.get("desc", "")
# Read the raw source
ref_dir = os.path.join(os.path.dirname(__file__), "..", "..", "shared", "sx", "ref")
if not os.path.isdir(ref_dir):
ref_dir = "/app/shared/sx/ref"
filepath = os.path.join(ref_dir, filename)
try:
with open(filepath, encoding="utf-8") as f:
source = f.read()
except FileNotFoundError:
return None
lines = source.split("\n")
# --- 1. Section splitting ---
sections: list[dict] = []
current_section: dict | None = None
i = 0
while i < len(lines):
line = lines[i]
# Detect section dividers: ;; ---...
if re.match(r"^;; -{10,}", line):
# Look for title in following comment lines
title_lines = []
j = i + 1
while j < len(lines) and lines[j].startswith(";;"):
content = lines[j][2:].strip()
if re.match(r"^-{10,}", content):
j += 1
break
if content:
title_lines.append(content)
j += 1
if title_lines:
section_title = title_lines[0]
# Collect comment block after section header
comment_lines = []
k = j
while k < len(lines) and lines[k].startswith(";;"):
c = lines[k][2:].strip()
if re.match(r"^-{5,}", c) or re.match(r"^={5,}", c):
break
if c:
comment_lines.append(c)
k += 1
current_section = {
"title": section_title,
"comment": " ".join(comment_lines) if comment_lines else None,
"defines": [],
}
sections.append(current_section)
i = j
continue
i += 1
# If no sections found, create a single implicit one
if not sections:
current_section = {"title": filename, "comment": None, "defines": []}
sections.append(current_section)
# --- 2. Parse AST ---
try:
exprs = parse_all(source)
except Exception:
exprs = []
# --- 3. Process each top-level define ---
# Build a line-number index: find where each top-level form starts
def _find_source_block(name: str, form: str = "define") -> tuple[str, int]:
"""Find the source text of a define form by scanning raw source."""
patterns = [
f"({form} {name} ",
f"({form} {name}\n",
]
for pat in patterns:
idx = source.find(pat)
if idx >= 0:
# Count balanced parens from idx
depth = 0
end = idx
for ci, ch in enumerate(source[idx:], idx):
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
end = ci + 1
break
line_num = source[:idx].count("\n") + 1
return source[idx:end], line_num
return "", 0
def _extract_effects(expr: list) -> list[str]:
"""Extract :effects [...] from a define form."""
if len(expr) >= 4 and isinstance(expr[2], Keyword) and expr[2].name == "effects":
eff_list = expr[3]
if isinstance(eff_list, list):
return [s.name if isinstance(s, Symbol) else str(s) for s in eff_list]
return []
def _extract_params(expr: list) -> list[dict]:
"""Extract params from the fn/lambda body of a define."""
# Find the fn/lambda form
val_expr = expr[4] if (len(expr) >= 5 and isinstance(expr[2], Keyword)
and expr[2].name == "effects") else expr[2] if len(expr) >= 3 else None
if not isinstance(val_expr, list) or not val_expr:
return []
if not isinstance(val_expr[0], Symbol):
return []
if val_expr[0].name not in ("fn", "lambda"):
return []
if len(val_expr) < 2 or not isinstance(val_expr[1], list):
return []
params_list = val_expr[1]
result = []
i = 0
while i < len(params_list):
p = params_list[i]
if isinstance(p, Symbol) and p.name in ("&rest", "&key"):
result.append({"name": p.name, "type": None})
i += 1
continue
if isinstance(p, Symbol):
result.append({"name": p.name, "type": None})
elif isinstance(p, list) and len(p) == 3:
# (name :as type)
name_s, kw, type_s = p
if isinstance(name_s, Symbol) and isinstance(kw, Keyword) and kw.name == "as":
type_str = type_s.name if isinstance(type_s, Symbol) else str(type_s)
result.append({"name": name_s.name, "type": type_str})
else:
result.append({"name": str(p), "type": None})
else:
result.append({"name": str(p), "type": None})
i += 1
return result
def _assign_to_section(line_num: int) -> dict:
"""Find which section a line belongs to."""
best = sections[0]
for s in sections:
# Section starts at its first define or earlier
if s["defines"]:
first_line = s["defines"][0].get("line", 0)
else:
first_line = 0
# Use section order — defines after a section header belong to it
# Simple approach: find section by scanning source position
return best
# Process defines
all_defines: list[dict] = []
py_emitter = None
for expr in exprs:
if not isinstance(expr, list) or len(expr) < 2:
continue
if not isinstance(expr[0], Symbol):
continue
head = expr[0].name
if head not in ("define", "define-async"):
continue
name_node = expr[1]
name = name_node.name if isinstance(name_node, Symbol) else str(name_node)
effects = _extract_effects(expr)
params = _extract_params(expr)
src, line_num = _find_source_block(name, head)
kind = "function"
# Check if it's a constant (no fn/lambda body)
val_idx = 4 if (len(expr) >= 5 and isinstance(expr[2], Keyword)
and expr[2].name == "effects") else 2
if val_idx < len(expr):
val = expr[val_idx]
if isinstance(val, list) and val and isinstance(val[0], Symbol) and val[0].name in ("fn", "lambda"):
kind = "async-function" if head == "define-async" else "function"
else:
kind = "constant"
if head == "define-async":
kind = "async-function"
# --- Python translation ---
py_code = None
try:
if py_emitter is None:
from shared.sx.ref.bootstrap_py import PyEmitter
py_emitter = PyEmitter()
if head == "define-async":
py_code = py_emitter._emit_define_async(expr)
else:
py_code = py_emitter._emit_define(expr)
except Exception:
pass
define_entry = {
"name": name,
"kind": kind,
"effects": effects,
"params": params,
"source": src,
"line": line_num,
"python": py_code,
}
all_defines.append(define_entry)
# --- Assign defines to sections ---
# Match by line number: each define belongs to the section whose header
# precedes it in the source
section_line_map: list[tuple[int, dict]] = []
for s in sections:
# Find the line where section title appears
t = s["title"]
for li, line in enumerate(lines, 1):
if t in line:
section_line_map.append((li, s))
break
section_line_map.sort(key=lambda x: x[0])
for d in all_defines:
dl = d.get("line", 0)
target_section = sections[0]
for sl, s in section_line_map:
if dl >= sl:
target_section = s
target_section["defines"].append(d)
# --- Stats ---
pure_count = sum(1 for d in all_defines if not d["effects"])
mutation_count = sum(1 for d in all_defines if "mutation" in d["effects"])
io_count = sum(1 for d in all_defines if "io" in d["effects"])
render_count = sum(1 for d in all_defines if "render" in d["effects"])
# --- Platform interface ---
platform_items = []
for line in lines:
m = re.match(r"^;;\s+\((\S+)\s+(.*?)\)\s+→\s+(\S+)\s+—\s+(.+)", line)
if m:
platform_items.append({
"name": m.group(1),
"params": m.group(2),
"returns": m.group(3),
"doc": m.group(4).strip(),
})
# Filter out empty sections
sections = [s for s in sections if s["defines"]]
return {
"filename": filename,
"title": title,
"desc": desc,
"sections": sections,
"platform-interface": platform_items,
"stats": {
"total-defines": len(all_defines),
"pure-count": pure_count,
"mutation-count": mutation_count,
"io-count": io_count,
"render-count": render_count,
"lines": len(lines),
},
}
def _bootstrapper_data(target: str) -> dict:
"""Return bootstrapper source and generated output for a target.