diff --git a/docs/go/carousel.go b/docs/go/carousel.go new file mode 100644 index 00000000..c3b5382e --- /dev/null +++ b/docs/go/carousel.go @@ -0,0 +1,87 @@ +package dangdocs + +import ( + "fmt" + "strings" + + "github.com/vito/booklit" + "github.com/vito/dang/v2/pkg/dang" +) + +// DangCarousel renders a feature carousel: a strip of \dang-feature slides, +// each a titled, distinctive-feature showcase. docs/js/carousel.js +// progressively enhances it into a tabbed, arrow-navigated reel; without +// JavaScript every slide renders stacked and fully readable. +// +// \dang-carousel{ +// \dang-feature{Prototype objects}{{{ +// ...code... +// }}} +// }{ +// \dang-feature{Copy-on-write}{{{ +// ...code... +// }}} +// } +func (p Plugin) DangCarousel(slides ...booklit.Content) booklit.Content { + return booklit.Styled{ + Style: "carousel", + Content: booklit.Sequence(slides), + Block: true, + } +} + +// DangFeature renders one carousel slide: a feature title above a Dang snippet +// rendered exactly as a \dang-literate block — same template, styling, baked +// output, and client-side editor (see literate.go / docs/js/playground.js), so +// the carousel reuses the site's one code-snippet mechanism rather than +// inventing another. +// +// The one difference from a page literate block is isolation: each slide +// evaluates in its own fresh standard-library session (not the page-shared +// chain), since slides are independent showcases that each (re)declare their +// own types. The slide wrapper carries data-dang-literate-chain so playground.js +// replays it as a standalone chain client-side too. As with any literate block +// the snippet is evaluated at build time and a parse/type/eval failure fails +// the docs build, so the examples can't rot. +// +// \dang-feature{Prototype objects}{{{ +// type Greeter { name: String! greet: String! { `hi ${name}` } } +// Greeter("world").greet +// }}} +func (p Plugin) DangFeature(title, code booklit.Content) (booklit.Content, error) { + source := strings.TrimRight(code.String(), "\n") + + // A fresh session per slide keeps slides independent — one slide's + // declarations must not leak into the next. + typeScope, valueScope := dang.BuildScopesFromImports("", nil) + sess := &literateSession{typeScope: typeScope, valueScope: valueScope} + + stdout, value, err := literateEval(source, sess) + if err != nil { + return nil, fmt.Errorf(`\dang-feature %q in %s: %w`, title.String(), p.section.FilePath(), err) + } + + // Build the same Styled "dang-literate" content a \dang-literate block + // produces (literate.go's literateBlock), so it renders through + // dang-literate.tmpl identically. + partials := booklit.Partials{} + if stdout != "" { + partials["Stdout"] = booklit.String(stdout) + } + if value != "" { + partials["Value"] = highlightResult(value) + } + literate := booklit.Styled{ + Style: "dang-literate", + Content: p.highlightDang(source), + Partials: partials, + Block: true, + } + + return booklit.Styled{ + Style: "carousel-slide", + Content: literate, + Partials: booklit.Partials{"Title": title}, + Block: true, + }, nil +} diff --git a/docs/html/carousel-slide.tmpl b/docs/html/carousel-slide.tmpl new file mode 100644 index 00000000..56541434 --- /dev/null +++ b/docs/html/carousel-slide.tmpl @@ -0,0 +1,4 @@ + diff --git a/docs/html/carousel.tmpl b/docs/html/carousel.tmpl new file mode 100644 index 00000000..c3b7afd6 --- /dev/null +++ b/docs/html/carousel.tmpl @@ -0,0 +1 @@ + diff --git a/docs/html/page.tmpl b/docs/html/page.tmpl index be43abf6..b28f21e0 100644 --- a/docs/html/page.tmpl +++ b/docs/html/page.tmpl @@ -268,6 +268,59 @@ pre code{display:block} .dang-literate:focus-within .dang-literate-controls{opacity:1} @media (hover:none){.dang-literate-controls{opacity:1}} +/* ── feature carousel (carousel.js) ──────────────────────────────────── */ +/* A showcase reel of distinctive features; each \dang-feature slide is a + titled, build-evaluated snippet. carousel.js adds .is-enhanced, which is + what collapses the stacked slides into a tabbed, arrow-navigated panel — + without JS every slide just renders in sequence, fully readable. */ +.dang-carousel{ + border:1px solid var(--code-border);border-radius:8px;margin:1.5rem 0; + background:var(--bg2);overflow:hidden; +} +/* Tab strip of feature names, built by carousel.js — the menu of "what's + different", and the primary jump-to-slide control. Scrolls sideways rather + than wrapping on a narrow viewport so it never towers over the code. */ +.dang-carousel-tabs{ + display:flex;gap:.25rem;padding:.5rem .5rem 0;overflow-x:auto; + border-bottom:1px solid var(--code-border);background:var(--bg2); +} +.dang-carousel-tab{ + font-family:'Inter',system-ui,sans-serif;font-size:.76rem;font-weight:500;cursor:pointer; + border:1px solid transparent;border-bottom:none;background:transparent;color:var(--fg2); + padding:.35rem .65rem;border-radius:6px 6px 0 0;line-height:1.3;white-space:nowrap; + transition:color .12s,background .12s; +} +.dang-carousel-tab:hover{color:var(--fg);background:var(--bg3)} +.dang-carousel-tab.is-active{color:var(--accent2);background:var(--code-bg);border-color:var(--code-border)} +/* Enhanced: only the active slide is shown. */ +.dang-carousel.is-enhanced .dang-carousel-slide{display:none} +.dang-carousel.is-enhanced .dang-carousel-slide.is-active{display:block} +/* Unenhanced fallback: stack the slides with a divider between them. */ +.dang-carousel-slide + .dang-carousel-slide{border-top:1px solid var(--code-border)} +.dang-carousel-title{ + font-size:.82rem;font-weight:600;color:var(--fg); + padding:.6rem .9rem .1rem; +} +/* Each slide embeds a real \dang-literate block; strip its standalone framing + so it sits flush inside the carousel rather than as a bordered card. */ +.dang-carousel-slide .dang-literate{margin:0;border:none;border-radius:0;background:var(--code-bg)} +/* Prev / counter / next, built by carousel.js. */ +.dang-carousel-foot{ + display:flex;align-items:center;justify-content:flex-end;gap:.5rem; + padding:.4rem .6rem;background:var(--bg2);border-top:1px solid var(--code-border); +} +.dang-carousel-arrow{ + font-family:'Inter',system-ui,sans-serif;font-size:.9rem;cursor:pointer; + border:1px solid var(--code-border);background:var(--bg3);color:var(--fg); + width:1.7rem;height:1.7rem;border-radius:5px;line-height:1; + display:flex;align-items:center;justify-content:center;transition:border-color .12s,color .12s; +} +.dang-carousel-arrow:hover{border-color:var(--fg2);color:var(--accent2)} +.dang-carousel-count{ + font-size:.74rem;color:var(--fg2);font-family:'JetBrains Mono',monospace; + min-width:3rem;text-align:center; +} + table{width:100%;border-collapse:collapse;font-size:.85rem;margin:.75rem 0} th{text-align:left;padding:.5rem .6rem;color:var(--fg2);font-weight:500;border-bottom:1px solid var(--code-border)} td{padding:.4rem .6rem;border-bottom:1px solid var(--td-border)} @@ -366,5 +419,6 @@ footer{margin-top:4rem;padding-top:1.5rem;border-top:1px solid var(--code-border + diff --git a/docs/js/carousel.js b/docs/js/carousel.js new file mode 100644 index 00000000..0cc1080f --- /dev/null +++ b/docs/js/carousel.js @@ -0,0 +1,119 @@ +// carousel.js — progressive enhancement for \dang-carousel blocks. +// +// Each carousel is authored as a stack of slides: +// +// +// +// Without JS the reader sees every slide in sequence — a plain list of +// examples. With JS we add a tab strip of feature names (jump to any slide), a +// prev/counter/next footer, and arrow-key navigation, and show one slide at a +// time. The snippets are baked at build time (docs/go/carousel.go), so nothing +// here loads or evaluates Dang — this is pure presentation. + +(function () { + "use strict"; + + function enhance(carousel) { + var slides = Array.prototype.slice.call( + carousel.querySelectorAll(":scope > [data-dang-carousel-slide]") + ); + // A lone slide isn't a carousel; leave it as the plain block it already is. + if (slides.length < 2) return; + + var active = -1; + + // Tab strip: one tab per slide, labelled by the slide's feature title. + var tabs = document.createElement("div"); + tabs.className = "dang-carousel-tabs"; + tabs.setAttribute("role", "tablist"); + var tabBtns = slides.map(function (slide, i) { + var titleEl = slide.querySelector(".dang-carousel-title"); + var label = titleEl ? titleEl.textContent.trim() : "Example " + (i + 1); + var tab = document.createElement("button"); + tab.className = "dang-carousel-tab"; + tab.type = "button"; + tab.textContent = label; + tab.setAttribute("role", "tab"); + slide.setAttribute("role", "tabpanel"); + tab.addEventListener("click", function () { show(i); }); + tabs.appendChild(tab); + return tab; + }); + + // Footer: prev arrow, "n / total" counter, next arrow. + var foot = document.createElement("div"); + foot.className = "dang-carousel-foot"; + var prev = document.createElement("button"); + prev.className = "dang-carousel-arrow"; + prev.type = "button"; + prev.setAttribute("aria-label", "Previous feature"); + prev.innerHTML = "‹"; // ‹ + var count = document.createElement("span"); + count.className = "dang-carousel-count"; + var next = document.createElement("button"); + next.className = "dang-carousel-arrow"; + next.type = "button"; + next.setAttribute("aria-label", "Next feature"); + next.innerHTML = "›"; // › + foot.appendChild(prev); + foot.appendChild(count); + foot.appendChild(next); + prev.addEventListener("click", function () { show(active - 1); }); + next.addEventListener("click", function () { show(active + 1); }); + + carousel.insertBefore(tabs, carousel.firstChild); + carousel.appendChild(foot); + carousel.classList.add("is-enhanced"); + + function show(i) { + var n = slides.length; + i = ((i % n) + n) % n; // wrap around at both ends + if (i === active) return; + active = i; + slides.forEach(function (s, j) { + var on = j === i; + s.classList.toggle("is-active", on); + s.setAttribute("aria-hidden", on ? "false" : "true"); + }); + tabBtns.forEach(function (t, j) { + var on = j === i; + t.classList.toggle("is-active", on); + t.setAttribute("aria-selected", on ? "true" : "false"); + t.tabIndex = on ? 0 : -1; + }); + count.textContent = (i + 1) + " / " + n; + // The revealed slide embeds a \dang-literate editor whose textarea + // autosizes by measuring scrollHeight — which reads 0 while the slide is + // display:none. playground.js re-runs every editor's autosize on window + // resize, so nudging it here corrects the just-shown slide's height. + window.dispatchEvent(new Event("resize")); + } + + // Left/right arrow keys page through — but not while editing a slide's + // code (the embedded editor needs the arrows to move the caret). + carousel.addEventListener("keydown", function (e) { + if (e.target && e.target.closest("textarea, input")) return; + if (e.key === "ArrowLeft") { e.preventDefault(); show(active - 1); } + else if (e.key === "ArrowRight") { e.preventDefault(); show(active + 1); } + }); + + show(0); + } + + function init() { + var carousels = document.querySelectorAll("[data-dang-carousel]"); + for (var i = 0; i < carousels.length; i++) enhance(carousels[i]); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/docs/js/playground.js b/docs/js/playground.js index 40809ee1..0ac2d3f3 100644 --- a/docs/js/playground.js +++ b/docs/js/playground.js @@ -1011,8 +1011,21 @@ for (var j = 0; j < repls.length; j++) enhanceRepl(repls[j]); var lazies = document.querySelectorAll("[data-dang-repl-lazy]"); for (var k = 0; k < lazies.length; k++) enhanceLazyRepl(lazies[k]); + // Literate blocks replay as a chain (notebook semantics: one shared + // session, top to bottom). By default the whole page is one chain; a + // [data-dang-literate-chain] ancestor scopes an independent chain, so e.g. + // each carousel slide replays on its own fresh session instead of being + // folded into the page's chain. var lits = document.querySelectorAll("[data-dang-literate]"); - if (lits.length) enhanceLiterateChain(lits); + if (lits.length) { + var groups = new Map(); // chain root (element or document) -> blocks + for (var m = 0; m < lits.length; m++) { + var root = lits[m].closest("[data-dang-literate-chain]") || document; + if (!groups.has(root)) groups.set(root, []); + groups.get(root).push(lits[m]); + } + groups.forEach(function (blocks) { enhanceLiterateChain(blocks); }); + } } // Capture any OAuth token handed back in the fragment before enhancing, so diff --git a/docs/lit/index.md b/docs/lit/index.md index d38c8fa9..904e992c 100644 --- a/docs/lit/index.md +++ b/docs/lit/index.md @@ -15,6 +15,77 @@ functions are loaded directly from the schema. \shell{go install github.com/vito/dang/v2/cmd/dang@latest} +\dang-carousel{ +\dang-feature{Prototype objects}{{{ +# `type` declares a type AND its constructor in one go +type User { + name: String! + greeting: String! { `Hi, I'm ${name}` } +} + +User("Ada").greeting +}}} +}{ +\dang-feature{Multi-field selection}{{{ +type Repo { + name: String! + stars: Int! { 1000 } +} + +# select many fields at once; against a GraphQL schema this is +# ONE query whose fields resolve in parallel +[Repo("dang"), Repo("booklit")].{{ name, stars }}.map { r => `${r.name} ★${r.stars}` } +}}} +}{ +\dang-feature{Copy-on-write}{{{ +type Counter { + n: Int! + bump: Counter! { n += 1; self } +} + +let c = Counter(0) +# values are immutable — methods fork the receiver, so c never changes +[c.bump.bump.n, c.n] +}}} +}{ +\dang-feature{Null tracking}{{{ +# String may be null; String! cannot — the type system tracks the gap +let name: String = "Dang" + +# inside the guard, name narrows from String to String! +if (name != null) { name.toUpper } else { "(none)" } +}}} +}{ +\dang-feature{Optional parens}{{{ +type Circle { + r: Float! + area: Float! { 3.14159 * r * r } +} + +let c = Circle(2.0) +# r is stored, area is computed — but they're accessed identically +[c.r, c.area] +}}} +}{ +\dang-feature{Everything is an expression}{{{ +# case yields a value, like if/loop/try — assign it, return it, pass it +classify(n: Int!): String! { + case (n) { + 0 => "zero" + else => if (n > 0) "positive" else "negative" + } +} + +[classify(0), classify(7), classify(-3)] +}}} +}{ +\dang-feature{Testing built in}{{{ +# assert is a builtin — high-level testing, no framework +assert { [1, 2, 3].map { x => x * 2 } == [2, 4, 6] } +"tests pass" +}}} +} + \dang-playground{{{ # Edit me, then hit Run — this evaluates in your browser. type Greeter {