Skip to content

Commit 8ffcb86

Browse files
authored
feat(viewer): align local CLI viewer with share viewer design (#56) (#57)
Mirror the gander.md share viewer redesign in the local CLI viewer: add the goose logo + 'gander' wordmark to the TOC left column, and add a 'Get your gander at gander.md/cli' CTA below the content with the small goose logo. Move the live-reload SSE swap target from <main id="content"> to an inner <div id="content-body"> so the CTA survives every save. Update the mermaid init script scope to match. Pairs with #41 per the CLI-pairing convention.
1 parent 22e30e4 commit 8ffcb86

2 files changed

Lines changed: 169 additions & 8 deletions

File tree

render.go

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,21 @@ body {
146146
color: #6a737d;
147147
margin: 0 0 0.75rem 0;
148148
}
149+
.gander-toc a.gander-toc-logo {
150+
display: inline-flex;
151+
align-items: center;
152+
gap: 0.5rem;
153+
color: #1b283f;
154+
text-decoration: none;
155+
margin: 0 0 1.25rem 0;
156+
}
157+
.gander-toc-logo svg { display: block; }
158+
.gander-toc-wordmark {
159+
font-size: 1.2rem;
160+
font-weight: 600;
161+
letter-spacing: -0.01em;
162+
line-height: 1;
163+
}
149164
.gander-toc ul {
150165
list-style: none;
151166
padding: 0;
@@ -212,6 +227,22 @@ img { max-width: 100%; }
212227
ul, ol { padding-left: 2em; }
213228
li + li { margin-top: 0.25em; }
214229
230+
.gander-md-cta {
231+
color: #586069;
232+
font-size: 0.85em;
233+
text-align: center;
234+
padding: 1.5rem 3rem 2rem;
235+
border-top: 1px solid #eaecef;
236+
margin-top: 3rem;
237+
}
238+
.gander-md-cta a { color: #0366d6; font-weight: 500; }
239+
.gander-md-viewer-logo {
240+
display: flex;
241+
justify-content: center;
242+
margin-bottom: 0.6rem;
243+
}
244+
.gander-md-viewer-logo svg { display: block; }
245+
215246
@media (min-width: 950px) {
216247
.gander-layout:not(.gander-layout--no-toc) {
217248
grid-template-columns: 250px 1fr;
@@ -234,6 +265,9 @@ li + li { margin-top: 0.25em; }
234265
.gander-content {
235266
padding: 2rem 1.5rem;
236267
}
268+
.gander-md-cta {
269+
padding: 1.5rem 1.5rem 2rem;
270+
}
237271
}
238272
`
239273

@@ -299,7 +333,7 @@ const tocScript = `
299333
const mermaidInitScript = `
300334
(function() {
301335
function transform() {
302-
var scope = document.getElementById('content') || document.body;
336+
var scope = document.getElementById('content-body') || document.body;
303337
var blocks = scope.querySelectorAll('pre > code.language-mermaid');
304338
blocks.forEach(function(code) {
305339
var pre = code.parentElement;
@@ -332,17 +366,19 @@ const mermaidInitScript = `
332366
})();
333367
`
334368

369+
const viewerLogoSVG = `<svg class="gander-md-viewer-logo-svg" width="26" height="24" viewBox="232 283 411 371" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#1b283f" stroke="none" fill-rule="evenodd" d="M 384.994 283.934 C 380.142 285.410, 376.312 287.913, 372.578 292.046 C 365.409 299.981, 365.534 299.128, 365.196 342.169 L 364.893 380.837 357.696 381.988 C 338.734 385.018, 322.806 391.579, 307 402.869 C 297.754 409.473, 286.374 420.674, 254.542 454.500 L 233.839 476.500 249.169 477.139 C 265.500 477.820, 271.202 479.050, 281 484.008 C 296.659 491.932, 308.617 507.104, 314.884 527 C 316.010 530.575, 318.299 540.988, 319.971 550.140 C 323.219 567.916, 326.338 578.723, 331.109 588.724 C 345.677 619.266, 377.918 644.357, 412 651.675 C 419.910 653.373, 427.486 653.500, 521.071 653.500 L 621.641 653.500 627.528 650.490 C 631.191 648.616, 634.654 645.853, 636.698 643.173 C 643.385 634.406, 643.083 641.179, 642.777 506.783 L 642.500 385.264 593.500 335.487 C 564.163 305.685, 543.209 285.166, 541.282 284.355 C 538.665 283.253, 524.014 283.016, 462.782 283.086 C 421.377 283.133, 386.372 283.515, 384.994 283.934 M 389.500 300.954 C 388.400 301.409, 386.375 302.991, 385 304.470 L 382.500 307.159 382.214 343.579 C 381.945 377.974, 382.028 380.001, 383.714 380.026 C 384.696 380.040, 390 380.487, 395.500 381.018 C 415.623 382.963, 437.240 390.084, 454.453 400.438 C 464.908 406.727, 477.070 416.058, 476.647 417.465 C 476.487 417.996, 472.713 420.525, 468.259 423.084 C 436.404 441.392, 409.629 468.945, 396.054 497.387 C 382.109 526.605, 379.259 557.860, 388.112 584.500 C 392.223 596.873, 397.928 606.125, 406.994 615.121 C 415.727 623.786, 425.919 629.465, 439.500 633.229 C 447.426 635.427, 448.281 635.450, 531.500 635.745 C 610.255 636.025, 615.719 635.933, 619 634.272 C 626.485 630.483, 625.997 639.212, 625.998 509.250 L 626 393 588.468 393 C 556.854 393, 550.352 392.756, 547.232 391.452 C 542.290 389.388, 539.124 386.341, 536.890 381.500 C 535.197 377.832, 535.042 374.284, 535.022 338.750 L 535 300 463.250 300.063 C 423.788 300.098, 390.600 300.499, 389.500 300.954 M 465.832 493.693 C 455.499 503.975, 446.318 513.918, 445.429 515.791 C 442.365 522.248, 443.407 523.881, 464.250 545.291 C 482.295 563.827, 483.725 565.063, 487.095 565.032 C 491.465 564.993, 494 562.509, 494 558.267 C 494 555.636, 491.552 552.692, 477.017 537.846 C 467.676 528.306, 460.026 520.215, 460.017 519.867 C 460.008 519.519, 467.350 512.094, 476.333 503.367 C 485.317 494.640, 493.217 486.471, 493.889 485.212 C 496.216 480.854, 492.643 475, 487.655 475 C 485.053 475, 481.938 477.668, 465.832 493.693 M 527.073 477.635 C 525.933 479.084, 525 481.235, 525 482.416 C 525 483.859, 530.787 490.387, 542.659 502.335 L 560.319 520.109 542.659 537.868 C 531.868 548.720, 525.006 556.379, 525.015 557.563 C 525.053 562.637, 529.917 566.656, 534.133 565.098 C 536.635 564.174, 573.002 528.850, 574.592 525.800 C 576.283 522.556, 576.400 518.657, 574.894 515.684 C 574.285 514.483, 565.177 504.837, 554.653 494.250 C 537.785 477.279, 535.142 475, 532.332 475 C 530.080 475, 528.537 475.773, 527.073 477.635"/></svg>`
370+
335371
const reloadScript = `
336372
(function() {
337373
if (!window.EventSource) return;
338374
var es = new EventSource('/events');
339375
es.addEventListener('content', function(e) {
340-
var content = document.getElementById('content');
341-
if (!content) return;
376+
var body = document.getElementById('content-body');
377+
if (!body) return;
342378
var html;
343379
try { html = JSON.parse(e.data); } catch (err) { return; }
344380
var y = window.scrollY;
345-
content.innerHTML = html;
381+
body.innerHTML = html;
346382
if (typeof window.ganderRebuildTOC === 'function') {
347383
window.ganderRebuildTOC();
348384
}
@@ -364,10 +400,11 @@ func buildHTML(content string, headings []Heading, withLiveReload bool) string {
364400
tocHTML := ""
365401
layoutClass := "gander-layout gander-layout--no-toc"
366402
if len(headings) >= 2 {
367-
tocHTML = `<nav class="gander-toc" id="toc">
403+
tocHTML = fmt.Sprintf(`<nav class="gander-toc" id="toc">
404+
<a href="https://gander.md/" class="gander-toc-logo">%s<span class="gander-toc-wordmark">gander</span></a>
368405
<div class="gander-toc-title">On this page</div>
369406
<ul id="toc-list"></ul>
370-
</nav>`
407+
</nav>`, viewerLogoSVG)
371408
layoutClass = "gander-layout"
372409
}
373410

@@ -376,6 +413,8 @@ func buildHTML(content string, headings []Heading, withLiveReload bool) string {
376413
script = tocScript + reloadScript
377414
}
378415

416+
ctaHTML := fmt.Sprintf(`<div class="gander-md-cta"><div class="gander-md-viewer-logo">%s</div>Get your gander at <a href="https://gander.md/cli">gander.md/cli</a></div>`, viewerLogoSVG)
417+
379418
return fmt.Sprintf(`<!DOCTYPE html>
380419
<html lang="en">
381420
<head>
@@ -390,7 +429,8 @@ func buildHTML(content string, headings []Heading, withLiveReload bool) string {
390429
<script type="application/json" id="headings-data">%s</script>
391430
<div class="%s" id="gander-layout">
392431
%s
393-
<main class="gander-content" id="content">
432+
<main class="gander-content">
433+
<div id="content-body">%s</div>
394434
%s
395435
</main>
396436
</div>
@@ -402,5 +442,5 @@ func buildHTML(content string, headings []Heading, withLiveReload bool) string {
402442
%s
403443
</script>
404444
</body>
405-
</html>`, cssStyle, headingsJSON, layoutClass, tocHTML, content, mermaidInitScript, script)
445+
</html>`, cssStyle, headingsJSON, layoutClass, tocHTML, content, ctaHTML, mermaidInitScript, script)
406446
}

render_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,125 @@ func TestBuildHTMLMermaidRunsAfterLiveReload(t *testing.T) {
186186
if lastMermaid < idxReload {
187187
t.Error("ganderRenderMermaid should be called from the live-reload SSE handler (after content swaps)")
188188
}
189+
}
190+
191+
func TestBuildHTMLTOCLogoAndWordmark(t *testing.T) {
192+
_, headings := renderMarkdownWithIDs("# A\n\n## B\n\nbody")
193+
page := buildHTML("<p>x</p>", headings, false)
194+
195+
if !strings.Contains(page, `<a href="https://gander.md/" class="gander-toc-logo">`) {
196+
t.Error("TOC should render a clickable logo link above 'On this page'")
197+
}
198+
if !strings.Contains(page, `<span class="gander-toc-wordmark">gander</span>`) {
199+
t.Error("TOC should render a 'gander' wordmark next to the logo")
200+
}
201+
if !strings.Contains(page, `class="gander-toc-logo"`) {
202+
t.Error("TOC logo link missing gander-toc-logo class")
203+
}
204+
}
205+
206+
func TestBuildHTMLCTABelowMain(t *testing.T) {
207+
_, headings := renderMarkdownWithIDs("# A\n\n## B\n\nbody")
208+
page := buildHTML("<p>x</p>", headings, false)
209+
210+
if !strings.Contains(page, `class="gander-md-cta"`) {
211+
t.Error("page should render the share-viewer footer CTA")
212+
}
213+
if !strings.Contains(page, `class="gander-md-viewer-logo"`) {
214+
t.Error("CTA should render the small goose logo above the link")
215+
}
216+
if !strings.Contains(page, `<a href="https://gander.md/cli">gander.md/cli</a>`) {
217+
t.Error("CTA should link to https://gander.md/cli with 'gander.md/cli' label")
218+
}
219+
if !strings.Contains(page, "Get your gander at") {
220+
t.Error("CTA should include 'Get your gander at' copy")
221+
}
222+
223+
mainOpen := strings.Index(page, `<main class="gander-content">`)
224+
if mainOpen < 0 {
225+
t.Fatal("page missing <main class=\"gander-content\">")
226+
}
227+
ctaIdx := strings.Index(page, `class="gander-md-cta"`)
228+
if ctaIdx <= mainOpen {
229+
t.Errorf("CTA must render below <main>, got ctaIdx=%d mainOpen=%d", ctaIdx, mainOpen)
230+
}
231+
}
232+
233+
func TestBuildHTMLCTARendersWithoutTOC(t *testing.T) {
234+
_, headings := renderMarkdownWithIDs("# Only one")
235+
page := buildHTML("<p>x</p>", headings, false)
236+
237+
if !strings.Contains(page, `class="gander-md-cta"`) {
238+
t.Error("CTA should render even when there is no TOC")
239+
}
240+
if strings.Contains(page, `<a href="https://gander.md/" class="gander-toc-logo">`) {
241+
t.Error("TOC logo link should not render when there are fewer than 2 headings")
242+
}
243+
}
244+
245+
func TestBuildHTMLCSSIncludesNewClasses(t *testing.T) {
246+
page := buildHTML("<p>x</p>", nil, false)
247+
for _, cls := range []string{
248+
".gander-md-cta",
249+
".gander-md-viewer-logo",
250+
".gander-toc-logo",
251+
".gander-toc-wordmark",
252+
} {
253+
if !strings.Contains(page, cls) {
254+
t.Errorf("buildHTML CSS missing %q", cls)
255+
}
256+
}
257+
}
258+
259+
func TestBuildHTMLContentBodyInsideMain(t *testing.T) {
260+
_, headings := renderMarkdownWithIDs("# A\n\n## B\n\nbody")
261+
page := buildHTML("<p>x</p>", headings, true)
262+
263+
bodyOpen := strings.Index(page, `<div id="content-body">`)
264+
if bodyOpen < 0 {
265+
t.Fatal("page missing #content-body swap target")
266+
}
267+
bodyClose := strings.Index(page[bodyOpen:], `</div>`)
268+
if bodyClose < 0 {
269+
t.Fatal("page #content-body not closed")
270+
}
271+
bodyClose += bodyOpen
272+
bodyInner := page[bodyOpen:bodyClose]
273+
274+
for _, forbidden := range []string{
275+
`class="gander-md-cta"`,
276+
`gander.md/cli`,
277+
} {
278+
if strings.Contains(bodyInner, forbidden) {
279+
t.Errorf("SSE swap target must not contain %q; the CTA needs to survive live reloads", forbidden)
280+
}
281+
}
282+
283+
if !strings.Contains(page, `getElementById('content-body')`) {
284+
t.Error("SSE reload script must target #content-body, not #content")
285+
}
286+
if strings.Contains(page, `getElementById('content')`) {
287+
t.Error("SSE reload script must not target the old #content id")
288+
}
289+
290+
mainOpen := strings.Index(page, `<main class="gander-content">`)
291+
if mainOpen >= bodyOpen {
292+
t.Error("#content-body must render inside <main>")
293+
}
294+
295+
ctaIdx := strings.Index(page, `class="gander-md-cta"`)
296+
if ctaIdx <= bodyClose {
297+
t.Errorf("CTA must render after the SSE swap target, got ctaIdx=%d bodyClose=%d", ctaIdx, bodyClose)
298+
}
299+
}
300+
301+
func TestBuildHTMLMermaidScopesToContentBody(t *testing.T) {
302+
page := buildHTML("<p>x</p>", nil, false)
303+
304+
if !strings.Contains(page, `getElementById('content-body')`) {
305+
t.Error("mermaid init script should scope to #content-body")
306+
}
307+
if strings.Contains(page, "getElementById('content') || document.body") {
308+
t.Error("mermaid init script should not scope to the old #content id")
309+
}
189310
}

0 commit comments

Comments
 (0)