Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ go install github.com/pgrundev/pgbook@latest
| 01 | Tables and data types | _in progress_ |
| 02 | SELECT, INSERT, UPDATE, DELETE | _in progress_ |
| 03 | Joins | _in progress_ |
| 04 | **Index basics** — why some queries are instant | ✅ `pgbook read indexes` |
| 04 | **Index basics** — why some queries are instant (hands-on tutorial, five steps) | ✅ `pgbook read indexes` |
| 05 | **Transactions** — grouping statements safely | _in progress_ |
| 06 | Reading EXPLAIN | _in progress_ |

Expand Down
33 changes: 33 additions & 0 deletions internal/render/banner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package render

import "strings"

// logo is the pgbook wordmark. Kept under 80 columns so it never wraps
// in a default terminal.
var logo = []string{
` _ _ `,
` _ __ __ _| |__ ___ ___ | | __`,
`| '_ \ / _` + "`" + ` | '_ \ / _ \ / _ \| |/ /`,
`| |_) | (_| | |_) | (_) | (_) | < `,
`| .__/ \__, |_.__/ \___/ \___/|_|\_\`,
`|_| |___/ `,
}

// Banner returns the greeting logo printed by a bare `pgbook`.
func Banner(color bool) string {
var b strings.Builder
b.WriteString("\n")
for _, line := range logo {
line = strings.TrimRight(line, " ")
if color {
line = ansiBold + ansiCyan + line + ansiReset
}
b.WriteString(" " + line + "\n")
}
tagline := "the Postgres Book in your terminal · pgbook.dev"
if color {
tagline = ansiDim + tagline + ansiReset
}
b.WriteString("\n " + tagline + "\n")
return b.String()
}
133 changes: 113 additions & 20 deletions internal/render/render.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
// Package render turns lesson markdown into terminal text.
package render

import "strings"
import (
"fmt"
"regexp"
"strconv"
"strings"
)

// Options controls rendering.
type Options struct {
Color bool
}

const (
ansiReset = "\x1b[0m"
ansiBold = "\x1b[1m"
ansiDim = "\x1b[2m"
ansiCyan = "\x1b[36m"
ansiYellow = "\x1b[33m"
ansiReset = "\x1b[0m"
ansiBold = "\x1b[1m"
ansiDim = "\x1b[2m"
ansiInverse = "\x1b[7m"
ansiCyan = "\x1b[36m"
ansiMagenta = "\x1b[35m"
ansiYellow = "\x1b[33m"
ansiGreen = "\x1b[32m"
)

// stepHeading matches tutorial step headings such as
// "## Step 2: Watch a query crawl". Steps are numbered in the source so
// the website and PDF read naturally; the terminal draws a tracker.
var stepHeading = regexp.MustCompile(`^## Step (\d+)\s*[:.—–-]\s*(.+?)\s*$`)

const stepRule = "────────────────────────────────────────────────────────"

// Render converts markdown to terminal output.
func Render(md string, opts Options) string {
var b strings.Builder
Expand All @@ -26,19 +41,34 @@ func Render(md string, opts Options) string {
return code + s + ansiReset
}

lines := strings.Split(md, "\n")
totalSteps := countSteps(lines)

inCode := false
for _, line := range strings.Split(md, "\n") {
codeLang := ""
for _, line := range lines {
switch {
case strings.HasPrefix(strings.TrimSpace(line), "```"):
inCode = !inCode
codeLang = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "```")))
case inCode && opts.Color && isSQL(codeLang):
b.WriteString(" " + highlightSQL(line) + "\n")
case inCode:
b.WriteString(" " + style(ansiCyan, line) + "\n")
case strings.HasPrefix(line, "### "):
b.WriteString(style(ansiBold, strings.ToUpper(strings.TrimPrefix(line, "### "))) + "\n")
case stepHeading.MatchString(line):
m := stepHeading.FindStringSubmatch(line)
n, _ := strconv.Atoi(m[1])
b.WriteString(stepHeader(n, totalSteps, m[2], opts))
case strings.HasPrefix(line, "## "):
b.WriteString(style(ansiBold, strings.ToUpper(strings.TrimPrefix(line, "## "))) + "\n")
case strings.HasPrefix(line, "# "):
b.WriteString(style(ansiBold, strings.ToUpper(strings.TrimPrefix(line, "# "))) + "\n")
case strings.HasPrefix(line, "- [ ] "):
b.WriteString(" " + style(ansiYellow, "☐") + " " + inline(line[6:], opts) + "\n")
case strings.HasPrefix(line, "- [x] ") || strings.HasPrefix(line, "- [X] "):
b.WriteString(" " + style(ansiGreen, "☑") + " " + inline(line[6:], opts) + "\n")
case strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* "):
b.WriteString(" • " + inline(line[2:], opts) + "\n")
case strings.HasPrefix(line, "> "):
Expand All @@ -52,22 +82,85 @@ func Render(md string, opts Options) string {
return b.String()
}

// inline strips light markdown emphasis; with color, `code` spans dim.
// isSQL reports whether a code fence language should get SQL colors.
func isSQL(lang string) bool {
return lang == "sql" || lang == "psql" || lang == "postgresql" || lang == "plpgsql"
}

// countSteps returns the number of step headings outside code blocks.
func countSteps(lines []string) int {
n := 0
inCode := false
for _, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "```") {
inCode = !inCode
continue
}
if !inCode && stepHeading.MatchString(line) {
n++
}
}
return n
}

// stepHeader draws a boxed step title with a progress tracker, e.g.
//
// ────────────────────────────────────────
// 1 ─ [2] ─ 3 ─ 4 ─ 5 STEP 2 OF 5
// WATCH A QUERY CRAWL
// ────────────────────────────────────────
func stepHeader(n, total int, title string, opts Options) string {
style := func(code, s string) string {
if !opts.Color {
return s
}
return code + s + ansiReset
}
var marks []string
for i := 1; i <= total; i++ {
switch {
case i == n:
marks = append(marks, style(ansiBold+ansiInverse, "["+strconv.Itoa(i)+"]"))
case i < n:
marks = append(marks, style(ansiDim, strconv.Itoa(i)))
default:
marks = append(marks, strconv.Itoa(i))
}
}
tracker := strings.Join(marks, style(ansiDim, " ─ "))

var b strings.Builder
b.WriteString("\n" + style(ansiDim, stepRule) + "\n")
b.WriteString(" " + tracker + " " + style(ansiBold, fmt.Sprintf("STEP %d OF %d", n, total)) + "\n")
b.WriteString(" " + style(ansiBold, strings.ToUpper(inline(title, Options{}))) + "\n")
b.WriteString(style(ansiDim, stepRule) + "\n")
return b.String()
}

// inline strips light markdown emphasis; with color, `code` spans dim
// and **bold** spans bold.
func inline(s string, opts Options) string {
if strings.Count(s, "`")%2 == 0 && strings.Contains(s, "`") {
parts := strings.Split(s, "`")
var out strings.Builder
for i, p := range parts {
if i%2 == 1 && opts.Color {
out.WriteString(ansiDim + p + ansiReset)
} else {
out.WriteString(p)
}
s = spans(s, "`", ansiDim, opts.Color)
s = spans(s, "**", ansiBold, opts.Color)
return strings.ReplaceAll(s, "**", "") // stray, unpaired markers
}

// spans removes a paired emphasis marker, wrapping the enclosed text in
// code when color is on. Unbalanced markers are left untouched.
func spans(s, marker, code string, color bool) string {
if !strings.Contains(s, marker) || strings.Count(s, marker)%2 != 0 {
return s
}
parts := strings.Split(s, marker)
var out strings.Builder
for i, p := range parts {
if i%2 == 1 && color {
out.WriteString(code + p + ansiReset)
} else {
out.WriteString(p)
}
s = out.String()
}
s = strings.ReplaceAll(s, "**", "")
return s
return out.String()
}

// ShouldColor reports whether output should use ANSI colors, given
Expand Down
79 changes: 79 additions & 0 deletions internal/render/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,82 @@ func TestShouldColor(t *testing.T) {
t.Error("TTY without NO_COLOR: ShouldColor = false, want true")
}
}

const tutorial = "## Why some queries are instant\n\nIntro.\n\n## Step 1: Get a Postgres to play with\n\nText.\n\n### Your turn\n\n- [ ] Start Postgres.\n- [x] Open psql.\n\n## Step 2: Watch a query crawl\n\nMore.\n\n```sql\n## Step 9: not a real step, inside a code block\n```\n\n## Step 3: Add an index\n\n## Step 4: When the index does not help\n\n## Step 5: Indexes are not free\n\n## What you learned\n"

func TestRenderStepHeadersShowProgress(t *testing.T) {
out := Render(tutorial, Options{Color: false})
for _, want := range []string{
"STEP 1 OF 5",
"GET A POSTGRES TO PLAY WITH",
"[1] ─ 2 ─ 3 ─ 4 ─ 5",
"STEP 2 OF 5",
"1 ─ [2] ─ 3 ─ 4 ─ 5",
"STEP 5 OF 5",
"1 ─ 2 ─ 3 ─ 4 ─ [5]",
"WHAT YOU LEARNED",
} {
if !strings.Contains(out, want) {
t.Errorf("step render missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "STEP 9") || strings.Contains(out, "OF 6") {
t.Errorf("step heading inside a code block was counted as a step:\n%s", out)
}
if strings.Contains(out, "## Step 1") || strings.Contains(out, "Step 1:") {
t.Errorf("raw step heading leaked into output:\n%s", out)
}
}

func TestRenderStepHeadersColorResets(t *testing.T) {
out := Render(tutorial, Options{Color: true})
if !strings.Contains(out, "STEP 2 OF 5") {
t.Fatalf("color step render missing header:\n%s", out)
}
if strings.Count(out, "\x1b[0m") == 0 {
t.Errorf("color step render never resets attributes")
}
}

func TestRenderChecklist(t *testing.T) {
out := Render(tutorial, Options{Color: false})
if !strings.Contains(out, "☐ Start Postgres.") {
t.Errorf("unchecked item not rendered as ☐:\n%s", out)
}
if !strings.Contains(out, "☑ Open psql.") {
t.Errorf("checked item not rendered as ☑:\n%s", out)
}
if strings.Contains(out, "[ ]") || strings.Contains(out, "[x]") {
t.Errorf("raw checkbox markers leaked into output:\n%s", out)
}
}

func TestRenderBoldSpans(t *testing.T) {
plain := Render("**1. A function** around the column.\n", Options{Color: false})
if !strings.Contains(plain, "1. A function around the column.") || strings.Contains(plain, "**") {
t.Errorf("plain bold span wrong: %q", plain)
}
color := Render("**1. A function** around the column.\n", Options{Color: true})
if !strings.Contains(color, "\x1b[1m1. A function\x1b[0m") {
t.Errorf("color bold span wrong: %q", color)
}
}

func TestBanner(t *testing.T) {
plain := Banner(false)
if strings.Contains(plain, "\x1b[") {
t.Errorf("plain banner contains ANSI escapes:\n%s", plain)
}
if !strings.Contains(plain, "pgbook.dev") {
t.Errorf("banner should name the site:\n%s", plain)
}
for _, line := range strings.Split(plain, "\n") {
if n := len([]rune(line)); n > 80 {
t.Errorf("banner line is %d columns, want <= 80: %q", n, line)
}
}
color := Banner(true)
if !strings.Contains(color, "\x1b[") || !strings.Contains(color, "\x1b[0m") {
t.Errorf("color banner has no ANSI escapes or never resets:\n%s", color)
}
}
Loading
Loading