From 40a6ae30e6ffbea5b8711db770a64640ae7f03e5 Mon Sep 17 00:00:00 2001 From: James Casey Date: Sat, 28 Feb 2026 21:17:37 +0000 Subject: [PATCH 1/5] Improve docs site: HTML comment fix, ADR generation, mobile responsiveness Fix HTML comment stripping for pulldown-cmark 0.13 (multi-line HtmlBlock events), generate ADR pages with dedicated navigation sidebar, extract Tooling page from language-features.md, add class hierarchy summaries, simplify stdlib README, update CNAME to www.beamtalk.dev, and add responsive CSS for mobile with sidebar overlay tap-to-close. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docs.yml | 2 +- .../beamtalk-cli/src/commands/doc/assets.rs | 56 ++++ .../beamtalk-cli/src/commands/doc/layout.rs | 45 ++- crates/beamtalk-cli/src/commands/doc/mod.rs | 53 ++- .../beamtalk-cli/src/commands/doc/renderer.rs | 100 ++++-- crates/beamtalk-cli/src/commands/doc/site.rs | 316 +++++++++++++++++- docs/beamtalk-language-features.md | 116 +------ docs/beamtalk-tooling.md | 87 +++++ stdlib/src/README.md | 48 +-- 9 files changed, 626 insertions(+), 197 deletions(-) create mode 100644 docs/beamtalk-tooling.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6d7bd6ba0..8f661466e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -87,7 +87,7 @@ jobs: run: cp scripts/install.sh _site/install.sh - name: Write CNAME for custom domain - run: echo "beamtalk.dev" > _site/CNAME + run: echo "www.beamtalk.dev" > _site/CNAME - name: Upload Pages artifact uses: actions/upload-pages-artifact@v4 diff --git a/crates/beamtalk-cli/src/commands/doc/assets.rs b/crates/beamtalk-cli/src/commands/doc/assets.rs index d5a8f3a67..d40fd6120 100644 --- a/crates/beamtalk-cli/src/commands/doc/assets.rs +++ b/crates/beamtalk-cli/src/commands/doc/assets.rs @@ -226,6 +226,15 @@ body { color: var(--fg); } +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + z-index: 9; +} +.sidebar-overlay.active { display: block; } + @media (max-width: 768px) { .sidebar { transform: translateX(-100%); } .sidebar.open { @@ -235,6 +244,42 @@ body { .sidebar-toggle { display: block; } .main-content { margin-left: 0; padding: 2rem 1.25rem; padding-top: 3.5rem; } .nav-links a:not(.nav-github):not(:first-child) { display: none; } + + /* Collapse TOC to single column */ + .toc ul { column-count: 1; } + + /* Make tables scroll horizontally */ + table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; } + + /* Scale hero heading */ + .landing-hero-text h1 { font-size: 2rem; } + + /* Scale page headings */ + h1 { font-size: 1.5rem; } + + /* Stack method header on narrow screens */ + .method-header { flex-direction: column; gap: 0.4rem; } + .source-link { margin-left: 0; } + + /* Code block horizontal scroll with tighter padding */ + pre { padding: 1rem; font-size: 0.82rem; } +} + +@media (max-width: 480px) { + /* Very small screens: tighter padding */ + .main-content { padding: 1.5rem 1rem; padding-top: 3.5rem; } + .landing-content { padding: 1.5rem 1rem 1.5rem; } + + /* Full-width CTAs */ + .landing-cta { flex-direction: column; } + .btn-primary, .btn-secondary { width: 100%; text-align: center; } + + /* Smallest heading */ + .landing-hero-text h1 { font-size: 1.75rem; } + + /* Reduce method card padding */ + .method { padding: 1rem; } + } /* --- Typography --- */ @@ -428,6 +473,17 @@ tbody tr:hover { background: var(--sidebar-bg); } .hierarchy-tree > ul { border-left: none; padding-left: 0; } .hierarchy-tree li { margin: 0.25rem 0; font-size: 0.9rem; } .hierarchy-tree a { font-weight: 500; } +.hierarchy-tree .class-summary { + font-size: 0.8rem; + color: var(--fg-muted); + margin-left: 0.4rem; +} + +/* --- ADR list --- */ +.adr-list { list-style: none; padding: 0; margin: 1.5rem 0; } +.adr-list li { margin: 0.4rem 0; font-size: 0.95rem; } +.adr-list a { color: var(--accent); text-decoration: none; } +.adr-list a:hover { text-decoration: underline; } /* --- Inherited methods --- */ .inherited-section { margin-top: 2.5rem; } diff --git a/crates/beamtalk-cli/src/commands/doc/layout.rs b/crates/beamtalk-cli/src/commands/doc/layout.rs index 5441263c9..c4e98ebc3 100644 --- a/crates/beamtalk-cli/src/commands/doc/layout.rs +++ b/crates/beamtalk-cli/src/commands/doc/layout.rs @@ -144,6 +144,17 @@ pub(super) fn write_hierarchy_tree(html: &mut String, classes: &[ClassInfo]) { v.sort_unstable(); } + // Build name → first-line summary map + let summaries: HashMap<&str, &str> = classes + .iter() + .filter_map(|c| { + c.doc_comment + .as_ref() + .and_then(|d| d.lines().next()) + .map(|line| (c.name.as_str(), line)) + }) + .collect(); + // Find roots (classes without a parent in our set) let class_names: std::collections::HashSet<&str> = classes.iter().map(|c| c.name.as_str()).collect(); @@ -166,23 +177,41 @@ pub(super) fn write_hierarchy_tree(html: &mut String, classes: &[ClassInfo]) { html.push_str("

Class Hierarchy

\n"); html.push_str("\n\n"); } /// Recursively write a hierarchy tree node. -fn write_hierarchy_node(html: &mut String, name: &str, children: &HashMap>) { - let _ = write!( - html, - "
  • {name}", - name = html_escape(name), - ); +fn write_hierarchy_node( + html: &mut String, + name: &str, + children: &HashMap>, + summaries: &HashMap<&str, &str>, +) { + let escaped = html_escape(name); + let _ = write!(html, "
  • {escaped}"); + + if let Some(summary) = summaries.get(name) { + // Strip a leading "ClassName — " prefix if present (common doc style) + let separator = " — "; + let text = summary + .find(separator) + .map(|i| summary[i + separator.len()..].trim()) + .unwrap_or(summary); + if !text.is_empty() { + let _ = write!( + html, + " {}", + html_escape(text) + ); + } + } if let Some(kids) = children.get(name) { html.push_str("\n
      \n"); for kid in kids { - write_hierarchy_node(html, kid, children); + write_hierarchy_node(html, kid, children, summaries); } html.push_str("
    \n"); } diff --git a/crates/beamtalk-cli/src/commands/doc/mod.rs b/crates/beamtalk-cli/src/commands/doc/mod.rs index be5b26eda..eed26c39a 100644 --- a/crates/beamtalk-cli/src/commands/doc/mod.rs +++ b/crates/beamtalk-cli/src/commands/doc/mod.rs @@ -35,7 +35,7 @@ use assets::{write_css, write_search_js}; use extractor::{collect_inherited_methods, find_source_files, parse_class_info}; use layout::build_sidebar_html; use renderer::{write_class_page, write_index_page}; -use site::{generate_prose_docs, write_site_landing_page}; +use site::{generate_adr_docs, generate_prose_docs, write_site_landing_page}; /// Prose documentation pages to render from the docs/ directory. const PROSE_PAGES: &[(&str, &str, &str)] = &[ @@ -66,6 +66,7 @@ const PROSE_PAGES: &[(&str, &str, &str)] = &[ "agent-native-development.html", "Agent-Native Development", ), + ("beamtalk-tooling.md", "tooling.html", "Tooling"), ( "known-limitations.md", "known-limitations.html", @@ -88,12 +89,13 @@ pub fn run(path: &str, output_dir: &str) -> Result<()> { Ok(()) } -/// Generate the full documentation site with landing page, API docs, and prose pages. +/// Generate the full documentation site with landing page, API docs, prose pages, and ADRs. /// /// Site structure: /// - `/` — Landing page with navigation /// - `/apidocs/` — API reference (class docs from `.bt` files) /// - `/docs/` — Prose documentation pages rendered from markdown +/// - `/adr/` — Architecture Decision Records #[instrument(skip_all, fields(lib_path = %lib_path, docs_path = %docs_path, output = %output_dir))] pub fn run_site(lib_path: &str, docs_path: &str, output_dir: &str) -> Result<()> { info!("Generating documentation site"); @@ -109,14 +111,17 @@ pub fn run_site(lib_path: &str, docs_path: &str, output_dir: &str) -> Result<()> let apidocs_path = output_path.join("apidocs"); generate_api_docs(&lib_source, &apidocs_path, "../")?; - // 2. Generate prose docs in docs/ subdirectory + // 2. Generate ADR pages; collect link-rewriting pairs for prose rendering let docs_source = Utf8PathBuf::from(docs_path); - generate_prose_docs(&docs_source, &output_path, PROSE_PAGES)?; + let adr_links = generate_adr_docs(&docs_source, &output_path)?; - // 3. Generate landing page + // 3. Generate prose docs in docs/ subdirectory (with ADR link rewriting) + generate_prose_docs(&docs_source, &output_path, PROSE_PAGES, &adr_links)?; + + // 4. Generate landing page write_site_landing_page(&output_path, PROSE_PAGES)?; - // 4. Write shared CSS to root (prose pages and landing page reference it) + // 5. Write shared CSS to root (prose pages and landing page reference it) write_css(&output_path)?; println!(" Site root: {output_path}/"); @@ -408,6 +413,26 @@ mod tests { assert!(html.contains("<script>")); } + #[test] + fn test_render_doc_drops_html_comments() { + // Multi-line block comment (as seen in stdlib/src/README.md) + let multiline = "\n# Title\n\nBody."; + let html = render_doc(multiline); + assert!(!html.contains("\n# Title\n"; + let html = render_doc(inline); + assert!(!html.contains(" after."; + let html = render_doc(inline2); + assert!(!html.contains("`) are silently dropped so that +/// license headers in README files do not appear in rendered output. pub(super) fn render_doc(doc: &str) -> String { use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag, TagEnd}; @@ -51,11 +53,16 @@ pub(super) fn render_doc(doc: &str) -> String { let mut in_beamtalk_code = false; let mut in_code_block = false; - // Transform events in a single pass so push_html preserves formatter state - // (table header/body context, list nesting, etc.). - let events = parser.filter_map(|event| match event { - Event::Start(Tag::CodeBlock(kind)) => { - let lang = match &kind { + // pulldown-cmark 0.13 emits HtmlBlock content as one Event::Html per line, + // wrapped by Start(HtmlBlock)/End(HtmlBlock). We must collect all events + // first so we can examine each HtmlBlock span as a whole before deciding + // whether it is a pure comment and should be dropped. + let raw_events: Vec> = parser.collect(); + let events = strip_html_comments(raw_events); + + let events = events.into_iter().filter_map(|event| match event { + Event::Start(Tag::CodeBlock(ref kind)) => { + let lang = match kind { CodeBlockKind::Fenced(lang) => lang.as_ref(), CodeBlockKind::Indented => "", }; @@ -83,18 +90,6 @@ pub(super) fn render_doc(doc: &str) -> String { code_text.push_str(&text); None } - // Drop HTML comments (e.g. license headers); sanitize all other raw HTML. - // Only drop events that are purely a comment (starts *and* ends with the - // comment delimiters) so mixed chunks like `y` are not - // silently swallowed. - Event::Html(raw) | Event::InlineHtml(raw) => { - let trimmed = raw.trim(); - if trimmed.starts_with("") { - None - } else { - Some(Event::Text(raw)) - } - } _ if in_code_block => None, other => Some(other), }); @@ -103,6 +98,61 @@ pub(super) fn render_doc(doc: &str) -> String { html } +/// Remove HTML comment blocks from a pulldown-cmark event stream. +/// +/// pulldown-cmark emits each `HtmlBlock` as a `Start(HtmlBlock)` sentinel, +/// one `Event::Html` per source line, then `End(HtmlBlock)`. We accumulate +/// the full block text and drop the entire span when it is a pure HTML comment +/// (``). Non-comment HTML blocks are emitted as escaped +/// `Event::Text` to prevent markup injection. +/// +/// Inline HTML comments (`Event::InlineHtml`) that are a pure comment on a +/// single token are also dropped; other inline HTML is escaped. +fn strip_html_comments(events: Vec>) -> Vec> { + use pulldown_cmark::{CowStr, Event, Tag, TagEnd}; + + let mut out = Vec::with_capacity(events.len()); + let mut iter = events.into_iter(); + + while let Some(event) = iter.next() { + match event { + Event::Start(Tag::HtmlBlock) => { + // Consume the whole block and decide whether to keep it. + let mut content = String::new(); + for inner in iter.by_ref() { + match inner { + Event::End(TagEnd::HtmlBlock) => break, + Event::Html(s) => content.push_str(&s), + _ => {} + } + } + let trimmed = content.trim(); + if !(trimmed.starts_with("")) { + // Not a comment — emit as escaped text. + out.push(Event::Text(CowStr::Boxed(content.into_boxed_str()))); + } + } + Event::InlineHtml(raw) => { + let trimmed = raw.trim(); + if !(trimmed.starts_with("")) { + out.push(Event::Text(raw)); + } + } + // Bare Event::Html outside an HtmlBlock (should not normally occur, + // but handle defensively). + Event::Html(raw) => { + let trimmed = raw.trim(); + if !(trimmed.starts_with("")) { + out.push(Event::Text(raw)); + } + } + other => out.push(other), + } + } + + out +} + /// Write the index (landing) page. /// /// If `readme` is provided (from source dir's README.md), it is rendered @@ -123,8 +173,12 @@ pub(super) fn write_index_page( html.push_str("
    \n"); html.push_str( "\n", + onclick=\"var s=document.querySelector('.sidebar'),o=document.getElementById('sidebar-overlay');\ +s.classList.toggle('open');o.classList.toggle('active');\" \ + aria-label=\"Toggle navigation\">☰\n\ +
    \n", ); html.push_str(sidebar_html); html.push_str("
    \n"); @@ -189,8 +243,12 @@ pub(super) fn write_class_page( html.push_str("
    \n"); html.push_str( "\n", + onclick=\"var s=document.querySelector('.sidebar'),o=document.getElementById('sidebar-overlay');\ +s.classList.toggle('open');o.classList.toggle('active');\" \ + aria-label=\"Toggle navigation\">☰\n\ +
    \n", ); html.push_str(&sidebar_html.replace( &format!("\">{}", html_escape(&class.name)), diff --git a/crates/beamtalk-cli/src/commands/doc/site.rs b/crates/beamtalk-cli/src/commands/doc/site.rs index 73d4d3b65..cd1905a9f 100644 --- a/crates/beamtalk-cli/src/commands/doc/site.rs +++ b/crates/beamtalk-cli/src/commands/doc/site.rs @@ -1,11 +1,11 @@ // Copyright 2026 James Casey // SPDX-License-Identifier: Apache-2.0 -//! Prose documentation and site landing page generation. +//! Prose documentation, ADR, and site landing page generation. //! //! **DDD Context:** CLI / Documentation -use camino::Utf8Path; +use camino::{Utf8Path, Utf8PathBuf}; use miette::{Context, IntoDiagnostic, Result}; use std::fmt::Write as _; use std::fs; @@ -15,11 +15,19 @@ use super::highlighter::highlight_beamtalk; use super::layout::{page_footer_simple, page_header}; use super::renderer::{html_escape, render_doc}; +// --------------------------------------------------------------------------- +// Prose docs +// --------------------------------------------------------------------------- + /// Generate prose documentation pages from markdown files. +/// +/// `extra_links` is a list of `(source_fragment, dest_html)` pairs used to +/// rewrite additional cross-references (e.g. ADR links) before rendering. pub(super) fn generate_prose_docs( docs_source: &Utf8Path, site_root: &Utf8Path, prose_pages: &[(&str, &str, &str)], + extra_links: &[(String, String)], ) -> Result<()> { let docs_output = site_root.join("docs"); fs::create_dir_all(&docs_output) @@ -48,23 +56,19 @@ pub(super) fn generate_prose_docs( .into_diagnostic() .wrap_err_with(|| format!("Failed to read '{source}'"))?; - // Rewrite cross-references to sibling prose docs (.md → .html) - let markdown = rewrite_prose_links(&markdown, prose_pages); + // Rewrite cross-references to sibling prose docs and ADRs + let markdown = rewrite_prose_links(&markdown, prose_pages, extra_links); let page_title = format!("{title} — Beamtalk"); let mut html = String::new(); html.push_str(&page_header(&page_title, "../style.css", "../")); html.push_str("
    \n"); - html.push_str( - "\n", - ); + html.push_str(SIDEBAR_TOGGLE); html.push_str(&prose_nav(output_file, prose_pages)); html.push_str("
    \n"); html.push_str("
    "); html.push_str("Home › "); - html.push_str("Docs › "); + html.push_str("Docs › "); html.push_str(&html_escape(title)); html.push_str("
    \n"); html.push_str(&render_doc(&markdown)); @@ -91,6 +95,7 @@ fn prose_nav(active_file: &str, prose_pages: &[(&str, &str, &str)]) -> String { html.push_str("\n"); html.push_str("\n"); html.push_str("\n"); @@ -109,16 +114,288 @@ fn prose_nav(active_file: &str, prose_pages: &[(&str, &str, &str)]) -> String { /// Rewrite cross-references between prose docs from `.md` to `.html`. /// -/// Prose markdown files contain links like `[principles](beamtalk-principles.md)`. -/// After rendering, those need to point to the generated `.html` files. -fn rewrite_prose_links(markdown: &str, prose_pages: &[(&str, &str, &str)]) -> String { +/// Also applies `extra_links` rewrites (e.g. ADR source paths → rendered URLs). +fn rewrite_prose_links( + markdown: &str, + prose_pages: &[(&str, &str, &str)], + extra_links: &[(String, String)], +) -> String { let mut result = markdown.to_string(); for &(source_file, output_file, _) in prose_pages { result = result.replace(source_file, output_file); } + for (source, dest) in extra_links { + result = result.replace(source.as_str(), dest.as_str()); + } + result +} + +// --------------------------------------------------------------------------- +// ADR generation +// --------------------------------------------------------------------------- + +/// Metadata for a single Architecture Decision Record. +struct AdrInfo { + /// Zero-padded number string, e.g. `"0001"`. + number: String, + /// Full stem of the source file, e.g. `"0001-no-compound-assignment"`. + slug: String, + /// Human title extracted from the H1, e.g. `"No Compound Assignment in Beamtalk"`. + title: String, + /// Output HTML filename, e.g. `"0001-no-compound-assignment.html"`. + output_file: String, +} + +/// Generate ADR pages from `docs/ADR/*.md` files. +/// +/// Returns a list of `(source_fragment, dest_html)` link rewriting pairs for +/// use in prose doc rendering, e.g.: +/// `("ADR/0004-persistent-workspace-management.md", +/// "../adr/0004-persistent-workspace-management.html")`. +/// +/// Returns an empty vec (and does nothing) if the ADR directory does not exist. +pub(super) fn generate_adr_docs( + docs_source: &Utf8Path, + site_root: &Utf8Path, +) -> Result> { + let adr_source = docs_source.join("ADR"); + if !adr_source.exists() { + return Ok(Vec::new()); + } + + let adr_output = site_root.join("adr"); + fs::create_dir_all(&adr_output) + .into_diagnostic() + .wrap_err("Failed to create adr/ output directory")?; + + let adrs = discover_adrs(&adr_source)?; + if adrs.is_empty() { + return Ok(Vec::new()); + } + + // Render each ADR page + for adr in &adrs { + let source_path = adr_source.join(format!("{}.md", adr.slug)); + let content = fs::read_to_string(&source_path) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to read ADR '{}'", adr.slug))?; + // Rewrite within-ADR links: sibling .md → .html (same directory) + let content = rewrite_adr_internal_links(&content, &adrs); + render_adr_page(adr, &adrs, &content, &adr_output)?; + } + + // Render ADR index + render_adr_index(&adrs, &adr_output)?; + + println!("Generated {} ADR page(s)", adrs.len()); + + // Return link-rewriting pairs for prose doc rendering. + // Prose pages live in /docs/, so the relative path to /adr/ is ../adr/. + let links = adrs + .iter() + .map(|a| { + ( + format!("ADR/{}.md", a.slug), + format!("../adr/{}", a.output_file), + ) + }) + .collect(); + Ok(links) +} + +/// Discover and parse ADR files from the given directory. +fn discover_adrs(adr_source: &Utf8Path) -> Result> { + let mut adrs: Vec = fs::read_dir(adr_source) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to read ADR directory '{adr_source}'"))? + .filter_map(|entry| { + let entry = entry.ok()?; + let path = Utf8PathBuf::from_path_buf(entry.path()).ok()?; + let name = path.file_name()?.to_string(); + // Skip non-markdown and the template + if path.extension() != Some("md") || name == "TEMPLATE.md" { + return None; + } + let stem = path.file_stem()?.to_string(); + // Must start with digits (NNNN-) + let number: String = stem.chars().take_while(|c| c.is_ascii_digit()).collect(); + if number.is_empty() { + return None; + } + let content = fs::read_to_string(&path).ok()?; + let title = extract_adr_title(&content); + Some(AdrInfo { + number, + slug: stem, + title, + output_file: format!("{}.html", path.file_stem()?), + }) + }) + .collect(); + + adrs.sort_by(|a, b| a.number.cmp(&b.number)); + Ok(adrs) +} + +/// Extract the human title from an ADR's first H1 heading. +/// +/// Strips the `ADR NNNN: ` prefix if present, e.g.: +/// `# ADR 0001: No Compound Assignment` → `"No Compound Assignment"`. +fn extract_adr_title(content: &str) -> String { + for line in content.lines() { + if let Some(rest) = line.strip_prefix("# ") { + // Strip "ADR NNNN: " prefix (case-insensitive check) + let rest_lower = rest.to_ascii_lowercase(); + if rest_lower.starts_with("adr ") { + if let Some(colon) = rest.find(": ") { + return rest[colon + 2..].trim().to_string(); + } + } + return rest.trim().to_string(); + } + } + String::from("Untitled ADR") +} + +/// Rewrite sibling ADR links within an ADR page (`.md` → `.html`, same dir). +fn rewrite_adr_internal_links(content: &str, adrs: &[AdrInfo]) -> String { + let mut result = content.to_string(); + for adr in adrs { + let md = format!("{}.md", adr.slug); + result = result.replace(&md, &adr.output_file); + } result } +/// Render a single ADR page. +fn render_adr_page( + adr: &AdrInfo, + all_adrs: &[AdrInfo], + content: &str, + adr_output: &Utf8Path, +) -> Result<()> { + let page_title = format!("ADR {} — Beamtalk", adr.number); + let mut html = String::new(); + html.push_str(&page_header(&page_title, "../style.css", "../")); + html.push_str("
    \n"); + html.push_str(SIDEBAR_TOGGLE); + html.push_str(&adr_nav(&adr.output_file, all_adrs)); + html.push_str("
    \n"); + html.push_str("
    "); + html.push_str("Home › "); + html.push_str("Architecture Decisions › "); + let _ = write!(html, "ADR {}", html_escape(&adr.number)); + html.push_str("
    \n"); + html.push_str(&render_doc(content)); + html.push_str("
    \n"); + html.push_str(&page_footer_simple()); + + let out_path = adr_output.join(&adr.output_file); + fs::write(&out_path, html) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to write {}", adr.output_file))?; + debug!("Generated {out_path}"); + Ok(()) +} + +/// Render the ADR index page listing all decisions. +fn render_adr_index(adrs: &[AdrInfo], adr_output: &Utf8Path) -> Result<()> { + let mut html = String::new(); + html.push_str(&page_header( + "Architecture Decisions — Beamtalk", + "../style.css", + "../", + )); + html.push_str("
    \n"); + html.push_str(SIDEBAR_TOGGLE); + html.push_str(&adr_nav("index.html", adrs)); + html.push_str("
    \n"); + html.push_str("
    "); + html.push_str("Home › "); + html.push_str("Architecture Decisions"); + html.push_str("
    \n"); + html.push_str("

    Architecture Decisions

    \n"); + html.push_str( + "

    Key design decisions with context, alternatives considered, \ + and consequences.

    \n", + ); + html.push_str("
      \n"); + for adr in adrs { + let _ = writeln!( + html, + "
    • {num} — {title}
    • ", + file = adr.output_file, + num = html_escape(&adr.number), + title = html_escape(&adr.title), + ); + } + html.push_str("
    \n"); + html.push_str("
    \n"); + html.push_str(&page_footer_simple()); + + let index_path = adr_output.join("index.html"); + fs::write(&index_path, html) + .into_diagnostic() + .wrap_err("Failed to write adr/index.html")?; + debug!("Generated {index_path}"); + Ok(()) +} + +/// Build the sidebar navigation for ADR pages. +fn adr_nav(active_file: &str, adrs: &[AdrInfo]) -> String { + let mut html = String::new(); + html.push_str("\n"); + html +} + +// --------------------------------------------------------------------------- +// Landing page +// --------------------------------------------------------------------------- + +/// Sidebar toggle button + tap-to-close overlay (shared across page types). +const SIDEBAR_TOGGLE: &str = "\ +\n\ +
    \n"; + /// Return (emoji, description) for a prose doc card on the landing page. /// /// Keyed by `output_file` (the stable `.html` filename) rather than display @@ -154,6 +431,10 @@ fn landing_card_meta(output_file: &str) -> (&'static str, &'static str) { "🔒", "Security model, threat analysis, and sandboxing for untrusted code.", ), + "tooling.html" => ( + "🛠", + "CLI, REPL, VS Code extension, and testing framework.", + ), "known-limitations.html" => ( "⚠️", "Current limitations and unimplemented features to be aware of.", @@ -236,6 +517,15 @@ pub(super) fn write_site_landing_page( ); html.push_str("\n"); + // ADR card + html.push_str("\n"); + html.push_str("📐\n"); + html.push_str("

    Architecture Decisions

    \n"); + html.push_str( + "

    Key design decisions — context, alternatives considered, and consequences.

    \n", + ); + html.push_str("
    \n"); + // Prose docs cards for &(_, file, title) in prose_pages { let (emoji, desc) = landing_card_meta(file); diff --git a/docs/beamtalk-language-features.md b/docs/beamtalk-language-features.md index b894e529d..a2d565ec6 100644 --- a/docs/beamtalk-language-features.md +++ b/docs/beamtalk-language-features.md @@ -4,7 +4,7 @@ Planned language features for beamtalk. See [beamtalk-principles.md](beamtalk-pr **Status:** Active development — implemented features are stable; planned sections are marked inline. -**Syntax note:** Beamtalk uses a cleaned-up Smalltalk syntax: `//` comments (not `"..."`), standard math precedence (not left-to-right), and optional statement terminators (newlines work). +**Syntax note:** Beamtalk uses a modernised Smalltalk syntax: `//` comments (not `"..."`), standard math precedence (not left-to-right), and optional statement terminators (newlines work). --- @@ -160,7 +160,7 @@ Actor subclass: Counter ProtoObject (minimal - identity, DNU) └─ Object (reflection + new) ├─ Integer, String (primitives) - ├─ Value (immutable value objects - ADR 0042) + ├─ Value (immutable value objects) │ └─ Point, Color (value types) └─ Actor (process-based + spawn) └─ Counter, Server (actors) @@ -801,16 +801,25 @@ Pattern matching can bind variables in match arms: Hot code reload via message sends — no dedicated `patch` syntax needed. ```beamtalk -// Replace a method on a running class +// Canonical Counter (already running in the workspace) +Actor subclass: Counter + state: value = 0 + increment => self.value := self.value + 1 + getValue => self.value + +// Replace a single method — existing instances pick it up immediately Counter >> increment => Telemetry log: "incrementing" self.value := self.value + 1 -// Redefine a class to update all future instances +// Redefine the class to add state — new instances get the updated shape Actor subclass: Counter - state: value = 0, lastModified = nil - increment => self.value := self.value + 1 - getValue => ^self.value + state: value = 0 + state: lastModified = nil + increment => + self.value := self.value + 1 + self.lastModified := DateTime now + getValue => self.value ``` --- @@ -1297,95 +1306,4 @@ The full list of structural intrinsics: `actorSpawn`, `actorSpawnWith`, `blockVa --- -## Tooling - -Tooling is part of the language, not an afterthought. Beamtalk is designed to be used interactively. - -### CLI Tools - -```bash -# Project management -beamtalk new myapp # Create new project -beamtalk build # Compile to BEAM -beamtalk run # Compile and start -beamtalk check # Check for errors without compiling -beamtalk daemon start/stop # Manage compiler daemon - -# Development -beamtalk repl # Interactive REPL -``` - -### REPL Features - -```beamtalk -// Spawn and interact -counter := Counter spawn -counter increment -counter getValue // => 1 -``` - -### VS Code Extension - -The [Beamtalk VS Code extension](https://github.com/jamesc/beamtalk/tree/main/editors/vscode) provides: - -- Syntax highlighting for `.bt` files -- Language Server Protocol (LSP) integration -- Error diagnostics with source spans - -### Testing Framework - -Beamtalk includes a native test framework inspired by Smalltalk's SUnit. - -```beamtalk -// stdlib/test/counter_test.bt - -TestCase subclass: CounterTest - - testInitialValue => - self assert: (Counter spawn getValue) equals: 0 - - testIncrement => - self assert: (Counter spawn increment) equals: 1 - - testMultipleIncrements => - counter := Counter spawn - 3 timesRepeat: [counter increment] - self assert: (counter getValue) equals: 3 -``` - -Each test method gets a fresh instance with `setUp` → test → `tearDown` lifecycle. - -#### Assertion Methods - -| Method | Description | Example | -|--------|-------------|---------| -| `assert:` | Assert condition is true | `self assert: (x > 0)` | -| `assert:equals:` | Assert two values are equal | `self assert: result equals: 42` | -| `deny:` | Assert condition is false | `self deny: list isEmpty` | -| `should:raise:` | Assert block raises error | `self should: [1 / 0] raise: #badarith` | -| `fail:` | Unconditional failure | `self fail: "not implemented"` | - -#### Running Tests - -```bash -beamtalk test -``` - -#### REPL Integration - -Run tests interactively from the REPL using either `:` shortcuts or native message sends: - -```text -> :load stdlib/test/counter_test.bt -Loaded CounterTest - -> :test CounterTest -Running 1 test class... - ✓ testIncrement - ✓ testMultipleIncrements -2 passed, 0 failed - -// Equivalent native API — works from compiled code too: -> (Workspace test: CounterTest) failed -// => 0 -``` +See [Tooling](beamtalk-tooling.md) for CLI tools, REPL, VS Code extension, and testing framework. diff --git a/docs/beamtalk-tooling.md b/docs/beamtalk-tooling.md new file mode 100644 index 000000000..20f3e1e86 --- /dev/null +++ b/docs/beamtalk-tooling.md @@ -0,0 +1,87 @@ +# Tooling + +Tooling is part of the language, not an afterthought. Beamtalk is designed to be used interactively. + +## CLI Tools + +```bash +# Project management +beamtalk new myapp # Create new project +beamtalk build # Compile to BEAM +beamtalk run # Compile and start +beamtalk check # Check for errors without compiling +beamtalk daemon start/stop # Manage compiler daemon + +# Development +beamtalk repl # Interactive REPL +beamtalk test # Run test suite +``` + +## REPL Features + +```beamtalk +// Spawn and interact +counter := Counter spawn +counter increment +counter getValue // => 1 +``` + +## VS Code Extension + +The [Beamtalk VS Code extension](https://github.com/jamesc/beamtalk/tree/main/editors/vscode) provides: + +- Syntax highlighting for `.bt` files +- Language Server Protocol (LSP) integration +- Error diagnostics with source spans + +## Testing Framework + +Beamtalk includes a native test framework inspired by Smalltalk's SUnit. + +```beamtalk +// stdlib/test/counter_test.bt + +TestCase subclass: CounterTest + + testInitialValue => + self assert: (Counter spawn getValue) equals: 0 + + testIncrement => + self assert: (Counter spawn increment) equals: 1 + + testMultipleIncrements => + counter := Counter spawn + 3 timesRepeat: [counter increment] + self assert: (counter getValue) equals: 3 +``` + +Each test method gets a fresh instance with `setUp` → test → `tearDown` lifecycle. + +### Assertion Methods + +| Method | Description | Example | +|--------|-------------|---------| +| `assert:` | Assert condition is true | `self assert: (x > 0)` | +| `assert:equals:` | Assert two values are equal | `self assert: result equals: 42` | +| `deny:` | Assert condition is false | `self deny: list isEmpty` | +| `should:raise:` | Assert block raises error | `self should: [1 / 0] raise: #badarith` | +| `fail:` | Unconditional failure | `self fail: "not implemented"` | + +### REPL Integration + +Run tests interactively from the REPL using either `:` shortcuts or native message sends: + +```text +> :load stdlib/test/counter_test.bt +Loaded CounterTest + +> :test CounterTest +Running 1 test class... + ✓ testIncrement + ✓ testMultipleIncrements +2 passed, 0 failed + +// Equivalent native API — works from compiled code too: +> (Workspace test: CounterTest) failed +// => 0 +``` diff --git a/stdlib/src/README.md b/stdlib/src/README.md index 934675fdf..09e4d844b 100644 --- a/stdlib/src/README.md +++ b/stdlib/src/README.md @@ -2,52 +2,6 @@ Copyright 2026 James Casey SPDX-License-Identifier: Apache-2.0 --> -# Beamtalk Standard Library - The standard library for Beamtalk, where everything is a message send. -## Class Hierarchy - -```text -ProtoObject (minimal root - identity, DNU) - └─ Object (value types - reflection, nil testing) - ├─ Integer, Float, String, Atom (sealed primitives) - ├─ True, False, Nil (sealed primitives) - ├─ Block (closures) - ├─ Collection (abstract) - │ ├─ SequenceableCollection - │ │ ├─ Array (Erlang tuple) - │ │ └─ List (Erlang list) - │ ├─ Set (ordsets) - │ └─ Dictionary (Erlang map) - ├─ Beamtalk (system reflection) - └─ Actor (process-based) - └─ (user actors: Counter, Worker, etc.) -``` - -See each `.bt` file for full API documentation and usage examples. - -## BEAM Mapping - -| Beamtalk | Erlang/BEAM | Has Process? | -|----------|-------------|--------------| -| `ProtoObject` | Abstract root | — | -| `Object` | Value type root | ❌ | -| `Actor` | `gen_server` process | ✅ | -| `Integer` | Erlang integer | ❌ | -| `Float` | Erlang float | ❌ | -| `True` / `False` | Atoms `true` / `false` | ❌ | -| `Nil` | Atom `nil` | ❌ | -| `Atom` | Erlang atom | ❌ | -| `Block` | Anonymous fun (closure) | ❌ | -| `String` | Binary `<<"UTF-8">>` | ❌ | -| `Array` | Erlang tuple `{...}` | ❌ | -| `List` | Erlang list `[...]` | ❌ | -| `Set` | `ordsets` (sorted list) | ❌ | -| `Dictionary` | Erlang map `#{...}` | ❌ | - -## Design - -- [Object Model](../docs/beamtalk-object-model.md) — class hierarchy and metaclasses -- [Design Principles](../docs/beamtalk-principles.md) — philosophy and rationale -- [Language Features](../docs/beamtalk-language-features.md) — full syntax specification +See each class page for full API documentation and usage examples. From 628c55581b67ac86572186c7eeb55cc20257cca5 Mon Sep 17 00:00:00 2001 From: James Casey Date: Sat, 28 Feb 2026 21:18:36 +0000 Subject: [PATCH 2/5] Fix clippy warnings: map_or, match_same_arms, redundant closure Co-Authored-By: Claude Sonnet 4.6 --- crates/beamtalk-cli/src/commands/doc/layout.rs | 3 +-- crates/beamtalk-cli/src/commands/doc/renderer.rs | 12 +++--------- crates/beamtalk-cli/src/commands/doc/site.rs | 2 +- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/crates/beamtalk-cli/src/commands/doc/layout.rs b/crates/beamtalk-cli/src/commands/doc/layout.rs index c4e98ebc3..a5400bb50 100644 --- a/crates/beamtalk-cli/src/commands/doc/layout.rs +++ b/crates/beamtalk-cli/src/commands/doc/layout.rs @@ -197,8 +197,7 @@ fn write_hierarchy_node( let separator = " — "; let text = summary .find(separator) - .map(|i| summary[i + separator.len()..].trim()) - .unwrap_or(summary); + .map_or(summary, |i| summary[i + separator.len()..].trim()); if !text.is_empty() { let _ = write!( html, diff --git a/crates/beamtalk-cli/src/commands/doc/renderer.rs b/crates/beamtalk-cli/src/commands/doc/renderer.rs index 6ab2a3763..a12f03d30 100644 --- a/crates/beamtalk-cli/src/commands/doc/renderer.rs +++ b/crates/beamtalk-cli/src/commands/doc/renderer.rs @@ -132,15 +132,9 @@ fn strip_html_comments(events: Vec>) -> Vec { - let trimmed = raw.trim(); - if !(trimmed.starts_with("")) { - out.push(Event::Text(raw)); - } - } - // Bare Event::Html outside an HtmlBlock (should not normally occur, - // but handle defensively). - Event::Html(raw) => { + // Inline or bare Html outside an HtmlBlock — drop if pure comment, + // otherwise escape to prevent injection. + Event::InlineHtml(raw) | Event::Html(raw) => { let trimmed = raw.trim(); if !(trimmed.starts_with("")) { out.push(Event::Text(raw)); diff --git a/crates/beamtalk-cli/src/commands/doc/site.rs b/crates/beamtalk-cli/src/commands/doc/site.rs index cd1905a9f..14ee0fc43 100644 --- a/crates/beamtalk-cli/src/commands/doc/site.rs +++ b/crates/beamtalk-cli/src/commands/doc/site.rs @@ -218,7 +218,7 @@ fn discover_adrs(adr_source: &Utf8Path) -> Result> { } let stem = path.file_stem()?.to_string(); // Must start with digits (NNNN-) - let number: String = stem.chars().take_while(|c| c.is_ascii_digit()).collect(); + let number: String = stem.chars().take_while(char::is_ascii_digit).collect(); if number.is_empty() { return None; } From f69c2d62c1a3e1a68b0e7244b4165c339e28fae2 Mon Sep 17 00:00:00 2001 From: James Casey Date: Sat, 28 Feb 2026 21:18:52 +0000 Subject: [PATCH 3/5] Fix type mismatch: dereference &&str in map_or default Co-Authored-By: Claude Sonnet 4.6 --- crates/beamtalk-cli/src/commands/doc/layout.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/beamtalk-cli/src/commands/doc/layout.rs b/crates/beamtalk-cli/src/commands/doc/layout.rs index a5400bb50..e26233d2c 100644 --- a/crates/beamtalk-cli/src/commands/doc/layout.rs +++ b/crates/beamtalk-cli/src/commands/doc/layout.rs @@ -197,7 +197,7 @@ fn write_hierarchy_node( let separator = " — "; let text = summary .find(separator) - .map_or(summary, |i| summary[i + separator.len()..].trim()); + .map_or(*summary, |i| summary[i + separator.len()..].trim()); if !text.is_empty() { let _ = write!( html, From 070f7781d4959f84a590a2e19f07ba8acaf16d1b Mon Sep 17 00:00:00 2001 From: James Casey Date: Sat, 28 Feb 2026 21:19:17 +0000 Subject: [PATCH 4/5] Apply cargo fmt formatting Co-Authored-By: Claude Sonnet 4.6 --- crates/beamtalk-cli/src/commands/doc/mod.rs | 20 ++++++++++++++++---- crates/beamtalk-cli/src/commands/doc/site.rs | 5 +---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/beamtalk-cli/src/commands/doc/mod.rs b/crates/beamtalk-cli/src/commands/doc/mod.rs index eed26c39a..a82590572 100644 --- a/crates/beamtalk-cli/src/commands/doc/mod.rs +++ b/crates/beamtalk-cli/src/commands/doc/mod.rs @@ -418,9 +418,18 @@ mod tests { // Multi-line block comment (as seen in stdlib/src/README.md) let multiline = "\n# Title\n\nBody."; let html = render_doc(multiline); - assert!(!html.contains("\n# Title\n"; @@ -430,7 +439,10 @@ mod tests { // Inline comment within paragraph text let inline2 = "Before after."; let html = render_doc(inline2); - assert!(!html.contains("