rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package codeview
2
3import (
4 "regexp"
5 "strings"
6)
7
8// blockElements are the elements whose boxes are blocks: whitespace
9// between two tags where one of these is involved renders as nothing,
10// so a line break there moves no pixel. Everything else (a, span,
11// button, label, input, svg) is inline or inline-block, where a space
12// is a space a reader sees, and is broken only where one of the rules
13// in layout says the space cannot show.
14var blockElements = map[string]bool{
15 "address": true, "article": true, "aside": true, "blockquote": true, "details": true,
16 "dialog": true, "dd": true, "div": true, "dl": true, "dt": true, "fieldset": true,
17 "figcaption": true, "figure": true, "footer": true, "form": true, "h1": true, "h2": true,
18 "h3": true, "h4": true, "h5": true, "h6": true, "header": true, "hgroup": true, "hr": true,
19 "legend": true, "li": true, "main": true, "nav": true, "ol": true, "p": true, "pre": true,
20 "search": true, "section": true, "summary": true, "table": true, "tbody": true, "td": true,
21 "tfoot": true, "th": true, "thead": true, "tr": true, "ul": true,
22 // Inside a select, which listElements covers: an option and a
23 // group are drawn by the control, not laid out as text.
24 "optgroup": true, "option": true,
25}
26
27// listElements hold a list the control draws (a select's options, a
28// datalist's suggestions), so nothing between their children renders
29// and each child can have a line. The element itself is inline-level:
30// a select sits in a sentence like a word, and the space after it
31// shows, so it is not a block and is never broken out of its line.
32var listElements = map[string]bool{"select": true, "datalist": true, "optgroup": true}
33
34// rawElements keep every byte inside them. A <pre> or <textarea>
35// renders its whitespace as written, script and style are not markup,
36// an svg's paths are drawing, not structure a reader reads, and a
37// template's content is not rendered at all.
38var rawElements = map[string]bool{"pre": true, "textarea": true, "svg": true, "script": true, "style": true, "template": true}
39
40// preservingStyle matches an inline style that makes whitespace render
41// as written: white-space pre, pre-wrap, pre-line or break-spaces, or the
42// longhand that says the same. Under any of them a line break and an
43// indent are text a reader sees, so the element's whole subtree is left
44// as it is, the way a <pre> is. nowrap is not here: it collapses spaces
45// exactly as normal does and only stops the wrapping.
46var preservingStyle = regexp.MustCompile(`(?i)white-space(?:-collapse)?\s*:\s*(?:pre|pre-wrap|pre-line|break-spaces|preserve|preserve-breaks|preserve-spaces)\b`)
47
48// preservingAttrs are the attributes whose rules in tokens.css preserve
49// whitespace. None do today; one that comes to must be named here, or
50// the formatter will lay its content out as if spaces collapsed.
51var preservingAttrs = map[string]bool{}
52
53// preserves says whether the element opened at p keeps its content's
54// whitespace as written, so its subtree must not be reformatted.
55func preserves(p piece) bool {
56 if rawElements[p.name] {
57 return true
58 }
59 attrs := attrsOf(p.text)
60 for a := range attrs {
61 if preservingAttrs[a] {
62 return true
63 }
64 }
65 return preservingStyle.MatchString(attrs["style"])
66}
67
68// silentElements render nothing, so a space beside one is only seen if
69// something visible is beside it too. layout treats them as absent when
70// it asks whether a gap is at the edge of a line.
71var silentElements = map[string]bool{"script": true, "style": true, "template": true, "link": true, "meta": true}
72
73// ── Flex and grid containers ─────────────────────────────────────────
74//
75// Whitespace between the children of a flex or grid container renders
76// as nothing: a run of only whitespace makes no item, and a run of text
77// is an item of its own whose leading and trailing spaces fall at the
78// edges of its own line. So inside one, every child can have a line of
79// its own. This is the list of the containers css/tokens.css lays out
80// that way, by the attribute that does it, and it is a list
81// rather than a guess on purpose: a container wrongly on it would move
82// a reader's text, and one missing from it only keeps a line long.
83//
84// An entry is here only when the element is flex or grid at every
85// width and in every context the rule names. The shell chrome that
86// switches between none, contents and flex across breakpoints is not,
87// and stays inline. css/tokens.css is the authority. No drive here yet
88// renders every example both ways and compares the boxes, as the
89// gallery this was written for did; until one does, an attribute added
90// to tokens.css as a flex or grid container has to be added here by
91// hand.
92
93// flexAttrs are the attributes that make their element a block-level
94// flex or grid container.
95var flexAttrs = map[string]bool{
96 "rst-page-header": true, "rst-lbar": true, "rst-search": true, "rst-row": true, "rst-row-lead": true,
97 "rst-pagination": true, "rst-stats": true, "rst-stat": true, "rst-person-av": true, "rst-callout": true,
98 "rst-detail": true, "rst-box-head": true, "rst-lrow": true, "rst-choice-cards": true, "rst-seg-tabs": true,
99 "rst-field-row": true, "rst-form-bar": true, "rst-form-actions": true, "rst-form": true, "rst-form-foot": true,
100 "rst-modal-overlay": true, "rst-modal-panel": true, "rst-bulkbar": true, "rst-shell-nav": true,
101 "rst-shell-rail-foot": true, "rst-signin": true, "rst-signin-brand": true, "rst-signin-door": true,
102 "rst-signin-form": true, "rst-signin-passkey": true, "rst-signin-or": true, "rst-signin-providers": true,
103 "rst-stage": true, "rst-error-cta": true, "rst-dtp-pick": true, "rst-dtp-row": true, "rst-cal-head": true,
104 "rst-cal-nav": true, "rst-media": true, "rst-media-name": true,
105}
106
107// inlineFlexAttrs are the attributes that make their element an
108// inline-level flex container: its children may each have a line, but
109// the element itself sits in its parent's line like a word.
110var inlineFlexAttrs = map[string]bool{
111 "rst-btn": true, "rst-search-clear": true, "rst-status": true, "rst-meter": true, "rst-person": true,
112 "rst-callout-ic": true, "rst-caret": true, "rst-ftok": true, "rst-switch": true, "rst-help": true,
113 "rst-bulkbar-close": true, "rst-selbox": true, "rst-shell-avatar": true, "rst-row-action": true,
114 "rst-bulkbar-escalate": true, "rst-modal-close": true, "rst-shell-brand": true,
115}
116
117// flexInContext are the containers tokens.css makes flex or grid only
118// inside another: a dropdown's summary, the modal's rail, a choice
119// card. ancestor is an attribute on the parent, or on any ancestor when
120// deep is set. inline marks the inline-level ones.
121var flexInContext = []struct {
122 tag, attr, ancestor string
123 deep, inline bool
124}{
125 {tag: "summary", ancestor: "rst-dropdown"},
126 {tag: "summary", ancestor: "rst-row-menu"},
127 {tag: "summary", ancestor: "rst-shell-profile", inline: true},
128 {tag: "nav", ancestor: "rst-modal-panel"},
129 {tag: "label", ancestor: "rst-choice-cards", deep: true},
130 {attr: "rst-tblock-head", ancestor: "rst-tblock"},
131 {attr: "rst-page", ancestor: "rst-stage"},
132 {attr: "rst-shell-bar", ancestor: "rst-shell-topbar", deep: true},
133 {attr: "rst-shell-bar", ancestor: "rst-shell-console", deep: true},
134 {tag: "a", ancestor: "rst-ftok", deep: true, inline: true},
135 {tag: "a", ancestor: "rst-back-nav", deep: true, inline: true},
136 {tag: "a", ancestor: "rst-signin-providers", inline: true},
137}
138
139// blockAttrs are the attributes tokens.css makes display: block at
140// every width, on elements that are inline by default: a field's label
141// and its messages, a person's name and address, a choice card's two
142// lines.
143var blockAttrs = map[string]bool{
144 "rst-field-label": true, "rst-field-help": true, "rst-field-error": true, "rst-field-hint": true,
145 "rst-person-name": true, "rst-person-email": true, "rst-choice-title": true, "rst-choice-desc": true,
146 "rst-tblock-title": true, "rst-tblock-desc": true, "rst-row-sub": true, "rst-job-bar": true,
147}
148
149// blockInContext are the elements tokens.css makes blocks only inside
150// another: the links and buttons of a dropdown's or a row menu's panel.
151var blockInContext = []struct{ tag, ancestor string }{
152 {"a", "rst-dropdown-menu"}, {"button", "rst-dropdown-menu"},
153 {"a", "rst-row-menu-panel"}, {"button", "rst-row-menu-panel"},
154}
155
156// elem is one open element and what its box does to whitespace.
157type elem struct {
158 name string
159 attrs map[string]string
160 // flex: its children are flex or grid items.
161 flex bool
162 // ownLines: its content starts a line of its own, so a space at the
163 // start of that content is at the edge of a line (a block, any flex
164 // or grid container, a button).
165 ownLines bool
166 // blockLevel: the element is not part of its parent's line, so
167 // whatever follows it starts a new one.
168 blockLevel bool
169 // elements: it has an element among its children. A flex item's
170 // text gets a line of its own only beside one: a button holding a
171 // label and nothing else reads best as it is, <button>Save</button>,
172 // and a spinner beside that label is two children to list.
173 elements bool
174 // broken: a line break was written inside it, so its end tag goes on
175 // a line of its own too. One that opened and closed on the same line
176 // keeps its end there: <small>AB3PX · <bdi>…</bdi></small>.
177 broken bool
178}
179
180// describeElem works out what an element opened at p does, given the
181// elements it sits in.
182func describeElem(p piece, stack []elem) elem {
183 e := elem{name: p.name, attrs: attrsOf(p.text)}
184 e.blockLevel = blockElements[p.name]
185 inline := false
186 for _, c := range blockInContext {
187 if c.tag == p.name && len(stack) > 0 && has(stack[len(stack)-1].attrs, c.ancestor) {
188 e.blockLevel = true
189 }
190 }
191 for a := range e.attrs {
192 if blockAttrs[a] {
193 e.blockLevel = true
194 }
195 if flexAttrs[a] {
196 e.flex = true
197 }
198 if inlineFlexAttrs[a] {
199 e.flex, inline = true, true
200 }
201 }
202 // A block button is flex, not inline-flex: [rst-btn~="block"].
203 if v, ok := e.attrs["rst-btn"]; ok && hasWord(v, "block") {
204 inline = false
205 }
206 for _, c := range flexInContext {
207 if (c.tag != "" && c.tag != p.name) || (c.attr != "" && !has(e.attrs, c.attr)) {
208 continue
209 }
210 for i := len(stack) - 1; i >= 0; i-- {
211 if has(stack[i].attrs, c.ancestor) {
212 e.flex, inline = true, c.inline
213 break
214 }
215 if !c.deep {
216 break
217 }
218 }
219 }
220 // .rst-nm small: the meta line under a row's name is a block.
221 if p.name == "small" {
222 for i := len(stack) - 1; i >= 0; i-- {
223 if hasWord(stack[i].attrs["class"], "rst-nm") {
224 e.blockLevel = true
225 break
226 }
227 }
228 }
229 if e.flex && !inline {
230 e.blockLevel = true
231 }
232 if listElements[p.name] {
233 e.flex = true
234 }
235 e.ownLines = e.blockLevel || e.flex || p.name == "button"
236 return e
237}
238
239func has(attrs map[string]string, name string) bool { _, ok := attrs[name]; return ok }
240
241func hasWord(list, word string) bool {
242 for _, w := range strings.Fields(list) {
243 if w == word {
244 return true
245 }
246 }
247 return false
248}
249
250// silent says whether the tag at p renders nothing at all.
251func silent(p piece) bool {
252 if silentElements[p.name] {
253 return true
254 }
255 return p.name == "input" && strings.EqualFold(attrsOf(p.text)["type"], "hidden")
256}
257
258// Format lays rendered HTML out for reading: a line break and two
259// spaces of indent per open element, wherever whitespace there cannot
260// change what renders. That is between two tags where one is a block;
261// between the children of a flex or grid container, its text included;
262// and at the edge of a line, where a space is removed when the line is
263// laid out, which is what lets the hidden inputs at the head of a form
264// stand one to a line. Text, inline runs, attribute values and
265// everything inside a raw element or one whose style preserves
266// whitespace are left byte for byte, except the
267// spaces at the two ends of a flex item's text, which never render. No
268// column limit is promised: an inline run stays one line however long
269// it is, and the page soft-wraps it instead.
270func Format(html string) string {
271 return layout(html, func(depth int) string { return "\n" + strings.Repeat(" ", depth) })
272}
273
274// layout walks the markup and writes brk(depth) wherever Format may
275// break, and the original bytes everywhere else.
276func layout(s string, brk func(depth int) string) string {
277 ps := pieces(s)
278 var b strings.Builder
279 var stack []elem
280 parent := func() *elem {
281 if len(stack) == 0 {
282 return nil
283 }
284 return &stack[len(stack)-1]
285 }
286 // edge: nothing visible since the start of the current line. The
287 // fragment starts at one: every sample sits in a block.
288 edge := true
289 // gap: the last thing written was a tag, or a flex item's text, and
290 // only the whitespace in pending has followed it. prevBlock is
291 // whether that tag was a block's.
292 gap, prevBlock := false, false
293 // opened: the last tag written opened the element on top of the
294 // stack. A line break between it and its own end would put
295 // whitespace in an element that had none, and :empty, which the
296 // shells use to hide an empty foot, would stop matching.
297 opened := false
298 pending := ""
299 for i := 0; i < len(ps); i++ {
300 p := ps[i]
301 switch p.kind {
302 case commentPiece:
303 b.WriteString(pending)
304 b.WriteString(p.text)
305 pending, gap, opened = "", false, false
306 continue
307 case textPiece:
308 core := strings.TrimSpace(p.text)
309 if core == "" && gap {
310 pending += p.text
311 continue
312 }
313 if par := parent(); par != nil && par.flex && par.elements && core != "" {
314 // A flex item of its own: the spaces at its two ends fall
315 // at the edges of its line and never render.
316 if gap {
317 par.broken = true
318 b.WriteString(brk(len(stack)))
319 } else {
320 b.WriteString(p.text[:strings.Index(p.text, core)])
321 }
322 b.WriteString(core)
323 pending = p.text[strings.Index(p.text, core)+len(core):]
324 gap, prevBlock, edge, opened = true, false, false, false
325 continue
326 }
327 b.WriteString(pending)
328 b.WriteString(p.text)
329 pending, gap, opened = "", false, false
330 if core != "" {
331 edge = false
332 }
333 continue
334 }
335
336 closes := p.end && parent() != nil && parent().name == p.name
337 inFlex := parent() != nil && parent().flex
338 var e elem
339 if !p.end {
340 e = describeElem(p, stack)
341 }
342 thisBlock := blockElements[p.name] || (!p.end && e.blockLevel) || (closes && parent().blockLevel)
343 // Never into the start of an inline element: its first child on a
344 // line of its own while its end shares a line with its last
345 // reads as a mistake, whatever it renders as.
346 intoInline := opened && !parent().ownLines
347 breakable := gap && !(closes && (opened || !parent().broken)) &&
348 (prevBlock || thisBlock || inFlex || !intoInline && (edge || lineEndsAt(ps, i, stack)))
349 opened = false
350 if closes {
351 stack = stack[:len(stack)-1]
352 }
353 if breakable {
354 if par := parent(); par != nil {
355 par.broken = true
356 }
357 b.WriteString(brk(len(stack)))
358 } else {
359 b.WriteString(pending)
360 }
361 pending = ""
362 b.WriteString(p.text)
363 gap, prevBlock = true, thisBlock
364
365 switch {
366 case p.end:
367 // Past a block the next thing starts a line; past anything
368 // else it shares the line with what just closed.
369 edge = thisBlock
370 case !p.void && preserves(p):
371 j := closing(ps, i)
372 for k := i + 1; k <= j; k++ {
373 b.WriteString(ps[k].text)
374 }
375 i = j
376 if !silent(p) {
377 edge = e.blockLevel
378 }
379 prevBlock = e.blockLevel
380 case p.void:
381 if p.name == "br" {
382 edge = true
383 } else if !silent(p) {
384 edge = false
385 }
386 default:
387 e.elements = hasElementChild(ps, i)
388 stack = append(stack, e)
389 opened = true
390 if e.ownLines {
391 edge = true
392 }
393 }
394 }
395 b.WriteString(pending)
396 return b.String()
397}
398
399// lineEndsAt says whether nothing visible stands between ps[i] and the
400// end of the current line: only whitespace and silent elements, then a
401// block, a line break, the end of a container that has lines of its own,
402// or the end of the fragment. A space there is the last thing on its
403// line and is removed when the line is laid out.
404func lineEndsAt(ps []piece, i int, stack []elem) bool {
405 for k := i; k < len(ps); k++ {
406 q := ps[k]
407 switch {
408 case q.kind == commentPiece:
409 continue
410 case q.kind == textPiece:
411 if strings.TrimSpace(q.text) != "" {
412 return false
413 }
414 continue
415 case q.end:
416 if len(stack) == 0 || stack[len(stack)-1].name != q.name {
417 return blockElements[q.name]
418 }
419 return stack[len(stack)-1].ownLines
420 case silent(q):
421 if rawElements[q.name] && !q.void {
422 k = closing(ps, k)
423 }
424 continue
425 case q.name == "br" || blockElements[q.name]:
426 return true
427 default:
428 return describeElem(q, stack).blockLevel
429 }
430 }
431 return true
432}
433
434// hasElementChild says whether the element opened at ps[i] has a tag
435// anywhere before its end.
436func hasElementChild(ps []piece, i int) bool {
437 end := closing(ps, i)
438 for k := i + 1; k < end; k++ {
439 if ps[k].kind == tagPiece {
440 return true
441 }
442 }
443 return false
444}
445
446// closing is the index of the tag that closes the raw element opened
447// at ps[i], counting nested elements of the same name; the last piece
448// when the markup never closes it.
449func closing(ps []piece, i int) int {
450 name, open := ps[i].name, 1
451 for j := i + 1; j < len(ps); j++ {
452 if ps[j].kind != tagPiece || ps[j].name != name {
453 continue
454 }
455 if ps[j].end {
456 open--
457 if open == 0 {
458 return j
459 }
460 } else if !ps[j].void {
461 open++
462 }
463 }
464 return len(ps) - 1
465}
466