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
|
package main
import (
"bytes"
"fmt"
"html"
"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/extension"
"github.com/yuin/goldmark/parser"
)
// Rendering a post to HTML
// ============================================================================
//
// The renderer never decides where an asset's bytes can be fetched from. It
// receives an AssetURL function and calls it for every image and model.
//
// That indirection is the seam between authoring and publishing. Right now the
// only caller is the preview, which serves blobs straight out of SQLite at
// /asset/{id}/{variant}. A later static export can render the very same posts
// by passing a function that returns /assets/{slug}/{name}.webp after writing
// those files out. Publishing then becomes a new caller of this code rather
// than a second, subtly diverging copy of it.
type AssetURL func(asset *Asset, variant string) string
// Syntax highlighting emits CSS classes rather than inline styles, so one
// stylesheet can carry both a light and a dark palette via
// prefers-color-scheme. The style below only classifies tokens; the colours
// come from the emitted stylesheet.
const (
lightStyle = "github"
darkStyle = "github-dark"
)
var chromaFormatter = chromahtml.New(
chromahtml.WithClasses(true),
chromahtml.TabWidth(4),
)
// markdownRenderer is configured once: GFM (tables, strikethrough,
// autolinks, task lists) plus chroma-highlighted fenced code blocks, so a
// fenced block inside a markdown block looks identical to a dedicated code
// block.
var markdownRenderer = goldmark.New(
goldmark.WithExtensions(
extension.GFM,
highlighting.NewHighlighting(
highlighting.WithStyle(lightStyle),
highlighting.WithFormatOptions(chromahtml.WithClasses(true)),
),
),
goldmark.WithParserOptions(
// Slugified id attributes on headings, so a post's sections are
// linkable.
parser.WithAutoHeadingID(),
),
)
// renderBlock renders a single block to an HTML fragment.
//
// A block that fails to render returns an error rather than partial HTML, so a
// caller can decide whether to skip the block or fail the page. renderPost
// chooses to skip, since one broken block should not hide a whole post.
func renderBlock(b *Block, assetURL AssetURL) (string, error) {
switch b.Kind {
case KindMarkdown:
return renderMarkdown(b.Content)
case KindCode:
return renderCode(b.Content, b.Meta.Language)
case KindImage:
return renderImage(b, assetURL)
case KindSTL:
return renderSTL(b, assetURL)
default:
return "", fmt.Errorf("cannot render block kind %q", b.Kind)
}
}
// renderPost renders every block of a post, concatenated.
//
// usesSTL reports whether any block needs the WebGL viewer, so the page can
// load that script only on pages that actually contain a model.
func renderPost(blocks []Block, assetURL AssetURL) (out string, usesSTL bool, err error) {
var buf strings.Builder
for i := range blocks {
b := &blocks[i]
if b.Kind == KindSTL {
usesSTL = true
}
frag, rerr := renderBlock(b, assetURL)
if rerr != nil {
// Surface the failure in place instead of dropping the block
// silently: an author needs to see that something is wrong, and
// the surrounding post still renders.
fmt.Fprintf(&buf, `<div class="block-error">could not render %s block: %s</div>`+"\n",
html.EscapeString(b.Kind), html.EscapeString(rerr.Error()))
continue
}
buf.WriteString(frag)
buf.WriteString("\n")
}
return buf.String(), usesSTL, nil
}
func renderMarkdown(src string) (string, error) {
var buf bytes.Buffer
if err := markdownRenderer.Convert([]byte(src), &buf); err != nil {
return "", fmt.Errorf("render markdown: %w", err)
}
return buf.String(), nil
}
// renderCode highlights a code block. An empty or unknown language falls back
// to content analysis and finally to no highlighting at all, so a code block is
// never rejected merely for having an unrecognised language tag.
func renderCode(src, language string) (string, error) {
lexer := lexers.Get(language)
if lexer == nil {
lexer = lexers.Analyse(src)
}
if lexer == nil {
lexer = lexers.Fallback
}
lexer = chroma.Coalesce(lexer)
style := styles.Get(lightStyle)
if style == nil {
style = styles.Fallback
}
iterator, err := lexer.Tokenise(nil, src)
if err != nil {
return "", fmt.Errorf("tokenise code: %w", err)
}
var buf bytes.Buffer
if err := chromaFormatter.Format(&buf, style, iterator); err != nil {
return "", fmt.Errorf("format code: %w", err)
}
return buf.String(), nil
}
// renderImage emits a <figure> with a responsive <img>.
//
// The 800w and 1600w webp renditions are offered via srcset and the browser
// picks by viewport and pixel density. width/height come from the (already
// EXIF-rotated) original so the aspect ratio is right and the browser can
// reserve space before the bytes arrive, avoiding layout shift.
func renderImage(b *Block, assetURL AssetURL) (string, error) {
if b.Asset == nil {
return "", fmt.Errorf("image block %d has no asset", b.ID)
}
small := assetURL(b.Asset, VariantWebP800)
large := assetURL(b.Asset, VariantWebP1600)
var buf strings.Builder
buf.WriteString(`<figure class="block-image">`)
fmt.Fprintf(&buf,
`<img src="%s" srcset="%s 800w, %s 1600w" sizes="(max-width: 60ch) 100vw, 60ch" `+
`width="%d" height="%d" loading="lazy" decoding="async" alt="%s">`,
html.EscapeString(small), html.EscapeString(small), html.EscapeString(large),
b.Asset.Width, b.Asset.Height, html.EscapeString(b.Meta.Alt))
if b.Meta.Caption != "" {
fmt.Fprintf(&buf, `<figcaption>%s</figcaption>`, html.EscapeString(b.Meta.Caption))
}
buf.WriteString(`</figure>`)
return buf.String(), nil
}
// renderSTL emits the container the WebGL viewer attaches to, plus a download
// link beneath it.
//
// The mesh is not inlined into the page; the viewer fetches the packed vertex
// buffer from data-mesh as an ArrayBuffer and uploads it to the GPU unchanged.
//
// The download link sits outside the viewer, as a sibling, rather than inside
// it as a fallback that the viewer replaces. That way the original file stays
// reachable whether or not the viewer ever starts: without JavaScript, without
// WebGL, and equally when the model is displaying perfectly and the reader
// simply wants the STL.
func renderSTL(b *Block, assetURL AssetURL) (string, error) {
if b.Asset == nil {
return "", fmt.Errorf("stl block %d has no asset", b.ID)
}
mesh := assetURL(b.Asset, VariantMesh)
orig := assetURL(b.Asset, VariantOriginal)
var buf strings.Builder
buf.WriteString(`<figure class="block-stl">`)
// The viewer is inert until activated; see stl-viewer.js. Its inner text is
// what shows when no script runs at all.
fmt.Fprintf(&buf,
`<div class="stl-viewer" data-mesh="%s">`+
`<p class="stl-noscript">3D model (needs JavaScript and WebGL to display)</p>`+
`</div>`,
html.EscapeString(mesh))
fmt.Fprintf(&buf,
`<p class="stl-download"><a href="%s" download>Download %s</a></p>`,
html.EscapeString(orig), html.EscapeString(b.Asset.Filename))
if b.Meta.Caption != "" {
fmt.Fprintf(&buf, `<figcaption>%s</figcaption>`, html.EscapeString(b.Meta.Caption))
}
buf.WriteString(`</figure>`)
return buf.String(), nil
}
// chromaCSS returns the syntax-highlighting stylesheet: the light palette,
// plus the dark palette wrapped in a prefers-color-scheme query. Generated
// from chroma's own styles so the classes always match what the formatter
// emits.
func chromaCSS() string {
var buf bytes.Buffer
if s := styles.Get(lightStyle); s != nil {
chromaFormatter.WriteCSS(&buf, s)
}
var dark bytes.Buffer
if s := styles.Get(darkStyle); s != nil {
chromaFormatter.WriteCSS(&dark, s)
}
if dark.Len() > 0 {
buf.WriteString("\n@media (prefers-color-scheme: dark) {\n")
buf.Write(dark.Bytes())
buf.WriteString("\n}\n")
}
return buf.String()
}
|