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

194
sx/sx/specs-explorer.sx Normal file
View File

@@ -0,0 +1,194 @@
;; ---------------------------------------------------------------------------
;; Spec Explorer — structured interactive view of SX spec files
;; ---------------------------------------------------------------------------
(defcomp ~spec-explorer-content (&key data)
(~doc-page :title (str (get data "title") " — Explorer")
;; Header with filename and source link
(~spec-explorer-header
:filename (get data "filename")
:title (get data "title")
:desc (get data "desc")
:slug (replace (get data "filename") ".sx" ""))
;; Stats bar
(~spec-explorer-stats :stats (get data "stats"))
;; Sections
(map (fn (section)
(~spec-explorer-section :section section))
(get data "sections"))
;; Platform interface
(when (not (empty? (get data "platform-interface")))
(~spec-platform-interface :items (get data "platform-interface")))))
;; ---------------------------------------------------------------------------
;; Header
;; ---------------------------------------------------------------------------
(defcomp ~spec-explorer-header (&key filename title desc slug)
(div :class "mb-6"
(div :class "flex items-center justify-between"
(div
(h1 :class "text-2xl font-bold text-stone-800" title)
(p :class "text-sm text-stone-500 mt-1" desc))
(a :href (str "/language/specs/" slug)
:sx-get (str "/language/specs/" slug)
:sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "text-sm text-violet-600 hover:text-violet-800 font-medium"
"View Source"))
(p :class "text-xs text-stone-400 font-mono mt-2" filename)))
;; ---------------------------------------------------------------------------
;; Stats bar
;; ---------------------------------------------------------------------------
(defcomp ~spec-explorer-stats (&key stats)
(div :class "flex flex-wrap gap-2 mb-6 text-xs"
(span :class "bg-stone-100 text-stone-600 px-2 py-0.5 rounded font-medium"
(str (get stats "total-defines") " defines"))
(when (> (get stats "pure-count") 0)
(span :class "bg-green-100 text-green-700 px-2 py-0.5 rounded"
(str (get stats "pure-count") " pure")))
(when (> (get stats "mutation-count") 0)
(span :class "bg-amber-100 text-amber-700 px-2 py-0.5 rounded"
(str (get stats "mutation-count") " mutation")))
(when (> (get stats "io-count") 0)
(span :class "bg-orange-100 text-orange-700 px-2 py-0.5 rounded"
(str (get stats "io-count") " io")))
(when (> (get stats "render-count") 0)
(span :class "bg-sky-100 text-sky-700 px-2 py-0.5 rounded"
(str (get stats "render-count") " render")))
(span :class "bg-stone-100 text-stone-500 px-2 py-0.5 rounded"
(str (get stats "lines") " lines"))))
;; ---------------------------------------------------------------------------
;; Section
;; ---------------------------------------------------------------------------
(defcomp ~spec-explorer-section (&key section)
(div :class "mb-8"
(h2 :class "text-lg font-semibold text-stone-700 border-b border-stone-200 pb-1 mb-3"
:id (replace (lower-case (get section "title")) " " "-")
(get section "title"))
(when (get section "comment")
(p :class "text-sm text-stone-500 mb-3" (get section "comment")))
(div :class "space-y-4"
(map (fn (d) (~spec-explorer-define :d d))
(get section "defines")))))
;; ---------------------------------------------------------------------------
;; Define card — one function/constant
;; ---------------------------------------------------------------------------
(defcomp ~spec-explorer-define (&key d)
(div :class "rounded border border-stone-200 p-4"
:id (str "fn-" (get d "name"))
;; Name + effect badges
(div :class "flex items-center gap-2 flex-wrap"
(span :class "font-mono font-semibold text-stone-800" (get d "name"))
(span :class "text-xs text-stone-400" (get d "kind"))
(if (empty? (get d "effects"))
(span :class "text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700" "pure")
(map (fn (eff) (~spec-effect-badge :effect eff))
(get d "effects"))))
;; Params
(when (not (empty? (get d "params")))
(~spec-param-list :params (get d "params")))
;; Translation panels
(~spec-ring-translations
:source (get d "source")
:python (get d "python"))))
;; ---------------------------------------------------------------------------
;; Effect badge
;; ---------------------------------------------------------------------------
(defcomp ~spec-effect-badge (&key effect)
(span :class (str "text-xs px-1.5 py-0.5 rounded "
(case effect
"mutation" "bg-amber-100 text-amber-700"
"io" "bg-orange-100 text-orange-700"
"render" "bg-sky-100 text-sky-700"
:else "bg-stone-100 text-stone-500"))
effect))
;; ---------------------------------------------------------------------------
;; Param list
;; ---------------------------------------------------------------------------
(defcomp ~spec-param-list (&key params)
(div :class "mt-1 flex flex-wrap gap-1"
(map (fn (p)
(let ((name (get p "name"))
(typ (get p "type")))
(if (or (= name "&rest") (= name "&key"))
(span :class "text-xs font-mono text-violet-500" name)
(span :class "text-xs font-mono px-1 py-0.5 rounded bg-stone-50 border border-stone-200"
(if typ
(<> (span :class "text-stone-700" name)
(span :class "text-stone-400" " : ")
(span :class "text-violet-600" typ))
(span :class "text-stone-700" name))))))
params)))
;; ---------------------------------------------------------------------------
;; Translation panels (Ring 2)
;; ---------------------------------------------------------------------------
(defcomp ~spec-ring-translations (&key source python)
(when (not (= source ""))
(div :class "mt-3 border border-stone-200 rounded-lg overflow-hidden"
;; SX source — always open
(details :open "true"
(summary :class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer"
"SX")
(pre :class "text-xs p-3 overflow-x-auto bg-white"
(code (highlight source "sx"))))
;; Python
(when python
(details
(summary :class "px-3 py-1.5 bg-stone-50 text-xs font-medium text-stone-600 cursor-pointer border-t border-stone-200"
"Python")
(pre :class "text-xs p-3 overflow-x-auto bg-white"
(code (highlight python "python"))))))))
;; ---------------------------------------------------------------------------
;; Platform interface table (Ring 3)
;; ---------------------------------------------------------------------------
(defcomp ~spec-platform-interface (&key items)
(div :class "mt-8"
(h2 :class "text-lg font-semibold text-stone-700 border-b border-stone-200 pb-1 mb-3"
"Platform Interface")
(p :class "text-sm text-stone-500 mb-3"
"Functions the host platform must provide.")
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-50"
(th :class "px-3 py-2 font-medium text-stone-600" "Name")
(th :class "px-3 py-2 font-medium text-stone-600" "Params")
(th :class "px-3 py-2 font-medium text-stone-600" "Returns")
(th :class "px-3 py-2 font-medium text-stone-600" "Description")))
(tbody
(map (fn (item)
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" (get item "name"))
(td :class "px-3 py-2 font-mono text-xs text-stone-500" (get item "params"))
(td :class "px-3 py-2 font-mono text-xs text-stone-500" (get item "returns"))
(td :class "px-3 py-2 text-stone-600" (get item "doc"))))
items))))))

View File

@@ -334,9 +334,15 @@ deps.sx depends on: eval")))
(defcomp ~spec-detail-content (&key (spec-title :as string) (spec-desc :as string) (spec-filename :as string) (spec-source :as string) (spec-prose :as string?))
(~doc-page :title spec-title
(div :class "flex items-baseline gap-3 mb-4"
(div :class "flex items-center gap-3 mb-4"
(span :class "text-sm text-stone-400 font-mono" spec-filename)
(span :class "text-sm text-stone-500" spec-desc))
(span :class "text-sm text-stone-500 flex-1" spec-desc)
(a :href (str "/language/specs/explore/" (replace spec-filename ".sx" ""))
:sx-get (str "/language/specs/explore/" (replace spec-filename ".sx" ""))
:sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:class "text-sm text-violet-600 hover:text-violet-800 font-medium whitespace-nowrap"
"Explore"))
(when spec-prose
(div :class "mb-6 space-y-3"
(p :class "text-stone-600 leading-relaxed" spec-prose)

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.