| 1 | package gallery |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "html/template" |
| 7 | "strings" |
| 8 | |
| 9 | "amadan.net/rastrillo/fiesta/example" |
| 10 | "amadan.net/rastrillo/fiesta/internal/codeview" |
| 11 | ) |
| 12 | |
| 13 | // The preview widget: Desktop, Mobile and HTML over one frame, as |
| 14 | // Rastrillo's gallery draws it, without its Template tab. The frame |
| 15 | // shows the state's own document at a virtual width and a measured |
| 16 | // height; the HTML tab shows the markup a reader copies, without the |
| 17 | // container the manifest's wrapper puts it in, and names that |
| 18 | // container on the line above. A grouped example (status-pill, badge, |
| 19 | // meter) is one widget whose frame is a row document of every state, |
| 20 | // with one code block per state. |
| 21 | |
| 22 | // previewView is one widget. Group names the widget's radio group, |
| 23 | // unique on the page. Style carries the frame's virtual heights |
| 24 | // (--ds-h and --ds-hm). Class is " ds-view--page" on the examples whose |
| 25 | // document is a whole page and empty on the rest, with its own leading |
| 26 | // space so the template writes class="ds-view{{.Class}}" without a |
| 27 | // conditional; a grouped widget adds " ds-view--rows". Wrapper is the |
| 28 | // line naming the container a wrapped sample needs; Source the markup |
| 29 | // the HTML tab shows, formatted and highlighted. NoCopy marks a block |
| 30 | // that offers no Copy button, as Rastrillo does for an Illustration; no |
| 31 | // Fiesta example is one. Rows is a grouped example's code, one block |
| 32 | // per state, and Source is empty then. |
| 33 | type previewView struct { |
| 34 | Group string |
| 35 | Style template.CSS |
| 36 | Class string |
| 37 | Src string |
| 38 | Wrapper template.HTML |
| 39 | Source template.HTML |
| 40 | Title string |
| 41 | NoCopy bool |
| 42 | Rows []rowCode |
| 43 | } |
| 44 | |
| 45 | // rowCode is one state of a grouped example in the HTML panel. |
| 46 | type rowCode struct { |
| 47 | State string |
| 48 | Source template.HTML |
| 49 | } |
| 50 | |
| 51 | // HasHTML says whether the widget draws an HTML tab: it has markup to |
| 52 | // show, as one block or as a grouped example's rows. |
| 53 | func (v previewView) HasHTML() bool { return v.Source != "" || len(v.Rows) > 0 } |
| 54 | |
| 55 | // grouped names the examples shown as one widget: a row of every state |
| 56 | // in one frame, and one code block per state. Rastrillo's three. |
| 57 | var grouped = map[string]bool{"status-pill": true, "badge": true, "meter": true} |
| 58 | |
| 59 | // pageFrame reports whether an example's document is a whole page |
| 60 | // rather than a component inside one: the layout documents, the two |
| 61 | // shell idioms and the modal, whose backdrop is fixed to the frame's |
| 62 | // viewport and wants a window-sized one. It picks the widget's width |
| 63 | // class: a page lays out at a virtual 1200px, everything else at |
| 64 | // 900px; view.css carries the argument for the two numbers. The |
| 65 | // sign-in screens are components, as in Rastrillo: a card, not a |
| 66 | // window. Getting this wrong costs a rendering, never a broken page. |
| 67 | func pageFrame(e example.Entry) bool { |
| 68 | return e.Document || strings.HasPrefix(e.Name, "shell-") || e.Name == "modal" |
| 69 | } |
| 70 | |
| 71 | // previewClass is pageFrame as the template writes it. |
| 72 | func previewClass(e example.Entry) string { |
| 73 | if pageFrame(e) { |
| 74 | return " ds-view--page" |
| 75 | } |
| 76 | return "" |
| 77 | } |
| 78 | |
| 79 | // wrapperMarkup is the opening markup of the container a manifest |
| 80 | // wrapper puts a body in, as the HTML tab names it. The seeded form's |
| 81 | // method and action are left out: they belong to the reader's form, |
| 82 | // and the seed's action="#" is a placeholder nobody should copy. "" is |
| 83 | // no wrapper; anything else the manifest does not know is an error. |
| 84 | func wrapperMarkup(w string) (string, error) { |
| 85 | switch w { |
| 86 | case "list": |
| 87 | return `<div rst-list>`, nil |
| 88 | case "form": |
| 89 | return `<section rst-box><form rst-form>`, nil |
| 90 | case "box": |
| 91 | return `<section rst-box>`, nil |
| 92 | case "stats": |
| 93 | return `<div rst-stats>`, nil |
| 94 | case "": |
| 95 | return "", nil |
| 96 | } |
| 97 | return "", fmt.Errorf("no such wrapper %q", w) |
| 98 | } |
| 99 | |
| 100 | // unwrap is a seeded body without the outer element(s) its wrapper |
| 101 | // names: what the HTML tab shows and Copy copies. The body has to open |
| 102 | // with each of the wrapper's tags in turn, each carrying the wrapper's |
| 103 | // attribute (so a bare <section> is not a box) and whatever else the |
| 104 | // seed gave it, and to close them in reverse at its end; a body that |
| 105 | // does not is an error, so a seeded document that lacks its wrapper |
| 106 | // fails the render rather than being copied with a wrapper line over |
| 107 | // markup that has none. Whitespace between the wrapper's tags goes |
| 108 | // with them; the inner markup is kept as written. |
| 109 | func unwrap(markup, wrapper string) (string, error) { |
| 110 | open, err := wrapperMarkup(wrapper) |
| 111 | if err != nil { |
| 112 | return "", err |
| 113 | } |
| 114 | if open == "" { |
| 115 | return markup, nil |
| 116 | } |
| 117 | var tags, attrs []string |
| 118 | for _, tag := range strings.SplitAfter(open, ">") { |
| 119 | if tag == "" { |
| 120 | continue |
| 121 | } |
| 122 | fields := strings.Fields(strings.Trim(tag, "<>")) |
| 123 | tags, attrs = append(tags, fields[0]), append(attrs, fields[1]) |
| 124 | } |
| 125 | rest := markup |
| 126 | for i, tag := range tags { |
| 127 | rest = strings.TrimLeft(rest, " \t\r\n") |
| 128 | end := strings.IndexByte(rest, '>') |
| 129 | if !strings.HasPrefix(rest, "<"+tag) || end < 0 { |
| 130 | return "", fmt.Errorf("the body does not open with <%s>; its %s wrapper puts it inside %s", tag, wrapper, open) |
| 131 | } |
| 132 | fields := strings.Fields(rest[1:end]) |
| 133 | if len(fields) == 0 || fields[0] != tag || !carriesAttr(fields[1:], attrs[i]) { |
| 134 | return "", fmt.Errorf("the body opens with %s, not <%s %s>; its %s wrapper puts it inside %s", rest[:end+1], tag, attrs[i], wrapper, open) |
| 135 | } |
| 136 | rest = rest[end+1:] |
| 137 | } |
| 138 | // The outermost wrapper closes last, so its closing tag comes off |
| 139 | // first. |
| 140 | for i := range tags { |
| 141 | rest = strings.TrimRight(rest, " \t\r\n") |
| 142 | close := "</" + tags[i] + ">" |
| 143 | if !strings.HasSuffix(rest, close) { |
| 144 | return "", fmt.Errorf("the body does not close with %s; its %s wrapper puts it inside %s", close, wrapper, open) |
| 145 | } |
| 146 | rest = rest[:len(rest)-len(close)] |
| 147 | } |
| 148 | // A wrapper never closed would have taken an inner element's |
| 149 | // closing tag as its own: the inner markup has to open and close |
| 150 | // each wrapper tag as many times. |
| 151 | for _, tag := range tags { |
| 152 | if strings.Count(rest, "<"+tag+">")+strings.Count(rest, "<"+tag+" ") != strings.Count(rest, "</"+tag+">") { |
| 153 | return "", fmt.Errorf("the body does not close its %s wrapper; it puts the markup inside %s", wrapper, open) |
| 154 | } |
| 155 | } |
| 156 | return rest, nil |
| 157 | } |
| 158 | |
| 159 | // carriesAttr says whether a tag's attribute fields carry name, bare or |
| 160 | // with a value. |
| 161 | func carriesAttr(fields []string, name string) bool { |
| 162 | for _, f := range fields { |
| 163 | if f == name || strings.HasPrefix(f, name+"=") { |
| 164 | return true |
| 165 | } |
| 166 | } |
| 167 | return false |
| 168 | } |
| 169 | |
| 170 | // frameTitle is the frame's accessible name: the example and the |
| 171 | // state's label, or the example's name alone where the state has no |
| 172 | // label (the idioms' default states), rather than a name that ends in |
| 173 | // a bare colon. |
| 174 | func (s *site) frameTitle(locale string, e example.Entry, st example.State) string { |
| 175 | label := s.label(locale, e, st) |
| 176 | if label == "" { |
| 177 | return e.Name |
| 178 | } |
| 179 | return s.say(locale, "gallery.frame.title", "example", e.Name, "state", label) |
| 180 | } |
| 181 | |
| 182 | // wrapLine is the sentence above a wrapped sample's code, naming the |
| 183 | // container with its opening markup in a <code>, as Rastrillo's line |
| 184 | // does. The sentence is the page's, escaped; the markup goes in after, |
| 185 | // so the words around it can be any language's. |
| 186 | func (s *site) wrapLine(locale, open string) template.HTML { |
| 187 | sentence := template.HTMLEscapeString(s.say(locale, "gallery.wrap")) |
| 188 | code := "<code>" + template.HTMLEscapeString(open) + "</code>" |
| 189 | return template.HTML(strings.Replace(sentence, "{wrapper}", code, 1)) |
| 190 | } |
| 191 | |
| 192 | // preview is one state's widget: the frame at frameURL, titled for the |
| 193 | // example and state, at its measured heights, and the code for the |
| 194 | // HTML tab: a component's body without its wrapper, with the line that |
| 195 | // names the wrapper, or a layout's whole document. |
| 196 | func (s *site) preview(p page, e example.Entry, st example.State, frameURL, code string) (previewView, error) { |
| 197 | v := previewView{ |
| 198 | Group: anchorID(e.Name, st.ID), |
| 199 | Style: previewStyle(p.Locale, e.Name+"/"+st.ID), |
| 200 | Class: previewClass(e), |
| 201 | Src: frameURL, |
| 202 | Title: s.frameTitle(p.Locale, e, st), |
| 203 | } |
| 204 | if !e.Document { |
| 205 | open, err := wrapperMarkup(e.Wrapper) |
| 206 | if err != nil { |
| 207 | return previewView{}, err |
| 208 | } |
| 209 | if code, err = unwrap(code, e.Wrapper); err != nil { |
| 210 | return previewView{}, err |
| 211 | } |
| 212 | if open != "" { |
| 213 | v.Wrapper = s.wrapLine(p.Locale, open) |
| 214 | } |
| 215 | } |
| 216 | if code != "" { |
| 217 | v.Source = codeview.Highlight(codeview.Format(code)) |
| 218 | } |
| 219 | return v, nil |
| 220 | } |
| 221 | |
| 222 | // rowsStyle lays a grouped document out as a row that wraps into rows |
| 223 | // on a phone, each state under its label. It is in the document's own |
| 224 | // <style> because a ds- class would need gallery.css inside every |
| 225 | // frame. |
| 226 | const rowsStyle = "body > ul { display: flex; flex-wrap: wrap; gap: 1rem 1.5rem; list-style: none; margin: 0; padding: 0; }\n" + |
| 227 | "body > ul > li > p { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); margin: 0 0 0.35rem; }\n" |
| 228 | |
| 229 | // groupedPreview is one grouped example's widget and the body of the |
| 230 | // row document its frame loads: every state, labelled, in one frame; |
| 231 | // one code block per state in the HTML panel. The row document is |
| 232 | // built like a seeded example, so frame treats it like one. A grouped |
| 233 | // example with a wrapper is refused: the grouped panels carry no |
| 234 | // wrapper line, so its rows would be copied without their container. |
| 235 | func (s *site) groupedPreview(p page, e example.Entry) (view previewView, rowDoc string, err error) { |
| 236 | if e.Wrapper != "" { |
| 237 | return previewView{}, "", fmt.Errorf("%s is grouped and wrapped in %q; the grouped panels have no wrapper line", e.Name, e.Wrapper) |
| 238 | } |
| 239 | if len(e.States) == 0 { |
| 240 | return previewView{}, "", errors.New(e.Name + " is grouped and has no states") |
| 241 | } |
| 242 | view = previewView{ |
| 243 | Group: anchorID(e.Name, "row"), |
| 244 | Style: previewStyle(p.Locale, e.Name+"/row"), |
| 245 | Class: previewClass(e) + " ds-view--rows", |
| 246 | Src: s.href(p.Theme, p.Locale, "frames/"+e.Name+"/row.html"), |
| 247 | Title: e.Name, |
| 248 | } |
| 249 | var body strings.Builder |
| 250 | body.WriteString("<ul>") |
| 251 | for _, st := range e.States { |
| 252 | markup, err := example.Markup(s.src, e.Name, st.ID) |
| 253 | if err != nil { |
| 254 | return previewView{}, "", err |
| 255 | } |
| 256 | markup = localMarkup(p.Locale, e.Name, markup) |
| 257 | label := s.label(p.Locale, e, st) |
| 258 | body.WriteString("<li><p>" + template.HTMLEscapeString(label) + "</p>" + markup + "</li>") |
| 259 | view.Rows = append(view.Rows, rowCode{State: label, Source: codeview.Highlight(codeview.Format(markup))}) |
| 260 | } |
| 261 | body.WriteString("</ul>") |
| 262 | return view, exampleDocument(view.Title, e.Scripts, rowsStyle, body.String()), nil |
| 263 | } |
| 264 | |
| 265 | // viewTemplate is the widget. The tabs are radios in labels sharing |
| 266 | // the widget's name, none checked; the frame sits in a box on the |
| 267 | // stage; the HTML panel holds one block, or a grouped example's rows |
| 268 | // each under its label. Nothing in it runs: view.css switches the |
| 269 | // panels on :has(). |
| 270 | const viewTemplate = `{{define "ds-view"}}<div class="ds-view{{.Class}}" style="{{.Style}}"> |
| 271 | <fieldset class="ds-view__tabs"><legend class="rst-sr-only">{{say "gallery.view.legend"}}</legend> |
| 272 | <span class="ds-view__pair"><label class="ds-view__tab ds-view__tab--d"><input type="radio" name="{{.Group}}">{{say "gallery.view.desktop"}}</label> |
| 273 | <label class="ds-view__tab ds-view__tab--m"><input type="radio" name="{{.Group}}">{{say "gallery.view.mobile"}}</label></span> |
| 274 | {{if .HasHTML}}<span class="ds-view__pair"><label class="ds-view__tab ds-view__tab--h"><input type="radio" name="{{.Group}}">{{say "gallery.html"}}</label></span> |
| 275 | {{end}}</fieldset> |
| 276 | <div class="ds-view__stage"><div class="ds-view__box"><iframe class="ds-view__frame" title="{{.Title}}" src="{{.Src}}" loading="lazy"></iframe></div></div> |
| 277 | {{if .Rows}}<div class="ds-view__code ds-view__code--h"> |
| 278 | {{range .Rows}}<p class="ds-state">{{.State}}</p> |
| 279 | <pre class="ds-src rst-mono" tabindex="0"><code>{{.Source}}</code></pre> |
| 280 | {{end}}</div> |
| 281 | {{else if .Source}}<div class="ds-view__code ds-view__code--h"> |
| 282 | {{if .Wrapper}}<p class="ds-wrap">{{.Wrapper}}</p> |
| 283 | {{end}}<pre class="ds-src rst-mono" tabindex="0"{{if .NoCopy}} data-ds-nocopy{{end}}><code>{{.Source}}</code></pre> |
| 284 | </div> |
| 285 | {{end}}</div>{{end}}` |
| 286 | |
| 287 | func init() { registerTemplate(viewTemplate) } |
| 288 | |