1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
package main

import (
	"bytes"
	"fmt"
	"html"
	"os/exec"
	"path"
	"strings"

	"github.com/alecthomas/chroma/v2"
	chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
	"github.com/alecthomas/chroma/v2/lexers"
	"github.com/alecthomas/chroma/v2/styles"
	"github.com/yuin/goldmark"
	highlighting "github.com/yuin/goldmark-highlighting/v2"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/text"
	"github.com/yuin/goldmark/util"
)

// We render with chroma using CSS classes (not inline styles) so we can ship a
// single stylesheet supporting both light and dark via prefers-color-scheme.
// See cssStylesheet in serve.go for the emitted classes.

const (
	lightStyle = "github"
	darkStyle  = "github-dark"
)

// chromaFormatterOptions configures both the formatter that emits the markup
// (chromaFormatter) and the one that emits the stylesheet (buildCSS). They MUST
// be the same options: several rules WriteCSS emits are derived from the
// formatter's configuration rather than from the style, so a stylesheet
// generated by a differently-configured formatter silently omits them (tab
// width, the full-width code column of the line-number table, and the
// :target rules that highlight a line linked to by anchor).
var chromaFormatterOptions = []chromahtml.Option{
	chromahtml.WithClasses(true),
	chromahtml.WithLineNumbers(true),
	chromahtml.LineNumbersInTable(true),
	chromahtml.TabWidth(4),
}

// chromaFormatter emits <pre>/<span class="..."> using chroma's short CSS
// class names (chromaShortClasses), matching the stylesheet we serve.
var chromaFormatter = chromahtml.New(chromaFormatterOptions...)

// markdown is configured once with GFM + chroma-highlighted fenced code blocks,
// so code inside a README looks the same as a standalone source file.
var markdown = goldmark.New(
	goldmark.WithExtensions(
		extension.GFM,
		highlighting.NewHighlighting(
			highlighting.WithStyle(lightStyle),
			highlighting.WithFormatOptions(
				chromahtml.WithClasses(true),
			),
		),
	),
	goldmark.WithParserOptions(
		// Slugified `id` attributes on every heading, generated during block
		// parsing (and deduplicated within a document), which the transformer
		// below turns into linkable anchors.
		parser.WithAutoHeadingID(),
		parser.WithASTTransformers(
			util.Prioritized(relURLTransformer{}, 100),
			util.Prioritized(headingAnchorTransformer{}, 200),
		),
	),
)

// highlightSource renders a source file to an HTML fragment (a chroma <pre>
// block). It picks a lexer from the filename, falling back to content
// analysis, then a plain fallback lexer.
func highlightSource(filePath string, content []byte) (string, error) {
	lexer := lexers.Match(filePath)
	if lexer == nil {
		lexer = lexers.Analyse(string(content))
	}
	if lexer == nil {
		lexer = lexers.Fallback
	}
	lexer = chroma.Coalesce(lexer)

	// The style only matters for token classification here; colors come from
	// our own stylesheet. Use a stable style so class names are consistent.
	style := styles.Get(lightStyle)
	if style == nil {
		style = styles.Fallback
	}

	iterator, err := lexer.Tokenise(nil, string(content))
	if err != nil {
		return "", fmt.Errorf("tokenise %q: %w", filePath, err)
	}

	var buf bytes.Buffer
	if err := chromaFormatter.Format(&buf, style, iterator); err != nil {
		return "", fmt.Errorf("format %q: %w", filePath, err)
	}
	return buf.String(), nil
}

// renderMarkdown converts markdown to an HTML fragment, with fenced code
// blocks highlighted by chroma. Relative image and link destinations are
// resolved to absolute source-forge URLs based on the markdown file's own
// location (project + path), so they work regardless of which page the
// fragment is embedded in (file view, directory listing, or ?full= view).
func renderMarkdown(content []byte, project, filePath string) (string, error) {
	// The directory that relative URLs in this file resolve against, expressed
	// as an absolute source-forge path, e.g. "/Profpatsch/nix/buildGo".
	baseDir := "/" + project
	if d := path.Dir(filePath); d != "." && d != "" {
		baseDir += "/" + d
	}

	ctx := parser.NewContext()
	ctx.Set(relBaseKey, baseDir)

	var buf bytes.Buffer
	if err := markdown.Convert(content, &buf, parser.WithContext(ctx)); err != nil {
		return "", fmt.Errorf("render markdown: %w", err)
	}
	return buf.String(), nil
}

// relBaseKey stores the absolute base directory for resolving relative URLs.
var relBaseKey = parser.NewContextKey()

// relURLTransformer rewrites relative link/image destinations in the parsed
// markdown AST to absolute source-forge URLs. Absolute URLs (with a scheme,
// protocol-relative, root-relative, or pure fragments) are left untouched.
type relURLTransformer struct{}

func (relURLTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
	base, _ := pc.Get(relBaseKey).(string)
	if base == "" {
		return
	}
	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		switch node := n.(type) {
		case *ast.Image:
			node.Destination = resolveRelURL(base, node.Destination)
		case *ast.Link:
			node.Destination = resolveRelURL(base, node.Destination)
		}
		return ast.WalkContinue, nil
	})
}

// resolveRelURL joins a relative destination onto the absolute base dir. It
// leaves alone anything that already has a scheme, is protocol-relative,
// root-relative, or is a bare fragment/query.
func resolveRelURL(base string, dest []byte) []byte {
	s := string(dest)
	if s == "" || strings.HasPrefix(s, "/") || strings.HasPrefix(s, "#") ||
		strings.HasPrefix(s, "?") || strings.HasPrefix(s, "//") {
		return dest
	}
	// A scheme like http:, https:, mailto: — leave untouched.
	if i := strings.IndexByte(s, ':'); i > 0 {
		if j := strings.IndexAny(s, "/?#"); j == -1 || i < j {
			return dest
		}
	}
	return []byte(path.Join(base, s))
}

// headingAnchorTransformer turns every heading into a link to itself, so a
// section of a rendered README or manpage can be linked to directly. The
// heading's own text becomes the link (rather than a trailing "#" marker
// revealed on hover), matching what mandoc's HTML backend produces for its
// section headers — including the `permalink` class, which pageCSS styles to
// inherit the heading's colour so it does not read as an ordinary link.
//
// The `id` itself comes from goldmark's WithAutoHeadingID (set above), which
// slugifies the heading text and disambiguates collisions within a document.
// Note "within a document": the ?full= view concatenates several separately
// rendered fragments, so two files that share a heading do produce a duplicate
// id on that page. That is accepted deliberately — the alternative, prefixing
// every id with its file path, would uglify every anchor URL on the site to fix
// a page that is already noindex, and where a browser simply jumps to the first
// match.
type headingAnchorTransformer struct{}

func (headingAnchorTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		heading, ok := n.(*ast.Heading)
		if !ok {
			return ast.WalkContinue, nil
		}
		id, ok := heading.AttributeString("id")
		if !ok {
			return ast.WalkContinue, nil
		}
		idBytes, ok := id.([]byte)
		if !ok || len(idBytes) == 0 {
			return ast.WalkContinue, nil
		}
		// A heading that already contains a link must be left alone: wrapping
		// it would nest an <a> inside an <a>, which is invalid HTML and which
		// browsers recover from by splitting the element.
		if containsLink(heading) {
			return ast.WalkSkipChildren, nil
		}

		link := ast.NewLink()
		link.Destination = append([]byte("#"), idBytes...)
		link.SetAttributeString("class", []byte("permalink"))
		// Move the heading's children under the link, then the link under the
		// heading. Children must be collected first: AppendChild rewires the
		// sibling pointers we would otherwise be iterating.
		var children []ast.Node
		for c := heading.FirstChild(); c != nil; c = c.NextSibling() {
			children = append(children, c)
		}
		heading.RemoveChildren(heading)
		for _, c := range children {
			link.AppendChild(link, c)
		}
		heading.AppendChild(heading, link)

		return ast.WalkSkipChildren, nil
	})
}

// containsLink reports whether a node has a link anywhere beneath it.
func containsLink(n ast.Node) bool {
	found := false
	_ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		switch c.Kind() {
		case ast.KindLink, ast.KindAutoLink:
			found = true
			return ast.WalkStop, nil
		}
		return ast.WalkContinue, nil
	})
	return found
}

// isMarkdown reports whether a path should be treated as markdown.
func isMarkdown(p string) bool {
	switch strings.ToLower(path.Ext(p)) {
	case ".md", ".markdown":
		return true
	}
	return false
}

// manpageTitleHTML renders the heading shown above a manpage rendered below a
// directory listing, naming it in the conventional "name(section)" form and
// linking to the page's own source file, e.g.
//
//	<h1 class="manpage-title"><a href="/proj/dir/timetrack.1">timetrack(1)</a></h1>
//
// It exists because a directory may render several manpages, and may render
// them under a README: without a heading the prose blocks would run together
// with nothing to say which file each came from.
//
// The title is derived from the filename rather than recovered from mandoc's
// own "NAME(section) - volume" line, which stripManpageChrome removes: the
// filename is always present and always right, whereas that line carries a
// volume field ("General Commands Manual", or whatever .Os expanded to) that
// says nothing here.
//
// It is an <h1> deliberately. mandoc renders .Sh sections as <h1>, so this
// heading is their sibling, not their parent — the page's outline reads
// TIMETRACK(1), NAME, SYNOPSIS — and an <h2> above <h1>s would invert the
// document order for anyone navigating by heading level.
//
// p must satisfy isManpage; the caller only ever passes such a path.
func manpageTitleHTML(project, p string) string {
	base := path.Base(p)
	section := strings.TrimPrefix(path.Ext(base), ".")
	name := strings.TrimSuffix(base, path.Ext(base))
	return fmt.Sprintf(
		"<h1 class=\"manpage-title\"><a href=\"/%s/%s\">%s(%s)</a></h1>\n",
		html.EscapeString(project), html.EscapeString(p),
		html.EscapeString(name), html.EscapeString(section))
}

// isManpage reports whether a path looks like an mdoc/man source page, i.e. it
// ends in a single-digit section extension (.1 through .9). These are rendered
// as prose below a directory's listing, alongside any README.md.
func isManpage(p string) bool {
	ext := path.Ext(p)
	if len(ext) != 2 || ext[0] != '.' {
		return false
	}
	return ext[1] >= '1' && ext[1] <= '9'
}

// renderManpage formats an mdoc/man source page to an HTML fragment. It shells
// out to mandoc's markdown backend (which produces clean, README-like Markdown)
// and then reuses the normal markdown pipeline, so a rendered manpage looks the
// same as a rendered README. mandoc must be on PATH.
//
// mandoc frames the output with a "TITLE(section) - volume" line at the top and
// an "OS - date" line at the bottom; both are stripped so only the body prose
// remains.
func renderManpage(content []byte, project, filePath string) (string, error) {
	cmd := exec.Command("mandoc", "-T", "markdown")
	cmd.Stdin = bytes.NewReader(content)
	var out, errBuf bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &errBuf
	if err := cmd.Run(); err != nil {
		return "", fmt.Errorf("mandoc %q: %w: %s", filePath, err, errBuf.String())
	}

	md := stripManpageChrome(out.Bytes())
	return renderMarkdown(md, project, filePath)
}

// stripManpageChrome removes mandoc's title/volume header line and its OS/date
// footer line from the generated Markdown, leaving just the document body. The
// header is the first non-empty line (of the form "NAME(1) - Volume"); the
// footer is the last non-empty line (of the form "OS - Date").
func stripManpageChrome(md []byte) []byte {
	lines := strings.Split(string(md), "\n")

	// Drop the header: the first non-empty line, plus any blank lines around it.
	start := 0
	for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
		start++
	}
	if start < len(lines) && strings.Contains(lines[start], ") - ") &&
		!strings.HasPrefix(lines[start], "#") {
		start++
	}
	for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
		start++
	}

	// Drop the footer: the last non-empty line, if it looks like "OS - Date".
	end := len(lines)
	for end > start && strings.TrimSpace(lines[end-1]) == "" {
		end--
	}
	if end > start && strings.Contains(lines[end-1], " - ") &&
		!strings.HasPrefix(lines[end-1], "#") &&
		!strings.HasPrefix(strings.TrimSpace(lines[end-1]), ">") {
		end--
	}

	return []byte(strings.Join(lines[start:end], "\n"))
}

// plainFallback renders content as an escaped <pre> block, used when chroma
// highlighting fails for some reason.
func plainFallback(content []byte) string {
	return "<pre class=\"chroma\">" + html.EscapeString(string(content)) + "</pre>"
}