rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package gallery
2
3import (
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.
33type 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.
46type 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.
53func (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.
57var 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.
67func 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.
72func 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.
84func 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.
109func 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.
161func 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.
174func (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.
186func (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.
196func (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.
226const 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.
235func (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().
270const 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
287func init() { registerTemplate(viewTemplate) }
288