| 1 | package codeview |
| 2 | |
| 3 | import ( |
| 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. |
| 14 | var 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. |
| 32 | var 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. |
| 38 | var 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. |
| 46 | var 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. |
| 51 | var 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. |
| 55 | func 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. |
| 71 | var 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. |
| 95 | var 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. |
| 110 | var 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. |
| 121 | var 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. |
| 143 | var 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. |
| 151 | var 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. |
| 157 | type 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. |
| 182 | func 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 | |
| 239 | func has(attrs map[string]string, name string) bool { _, ok := attrs[name]; return ok } |
| 240 | |
| 241 | func 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. |
| 251 | func 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. |
| 270 | func 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. |
| 276 | func 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. |
| 404 | func 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. |
| 436 | func 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. |
| 449 | func 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 | |