rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package example reads the design system's examples: one document
2// per component state under examples/, listed by examples/examples.json.
3// It is public so that an application which embeds Fiesta can read the
4// same examples its gallery and its tests do, and it links no browser.
5//
6// Every state is its own document, so its ids, labels and fixed
7// overlays never meet another state's. Markup returns the body of one,
8// exactly as the file holds it; Document returns the whole file, for
9// the layouts, whose <html> and <head> are part of what they show;
10// Slot fills a layout's content region.
11package example
12
13import (
14 "encoding/json"
15 "errors"
16 "fmt"
17 "io/fs"
18 "strings"
19
20 "golang.org/x/net/html"
21)
22
23// Manifest is examples/examples.json: every example in gallery order,
24// and the idiom table that says which example and state each of the
25// design system's markup idioms became.
26type Manifest struct {
27 Examples []Entry `json:"examples"`
28 Idioms map[string]Idiom `json:"idioms"`
29}
30
31// Entry is one example: a directory under examples/ holding one
32// document per state.
33//
34// Kind is one of component, idiom, layout, format and screen. Document
35// marks a layout, whose states are whole pages with a slot rather than
36// markup to drop into one. Wrapper names the container a component's
37// markup keeps as its outermost element: "", list, form, box or stats.
38// Scripts lists the optional scripts the markup needs, by file name.
39// Default is the state later readers use.
40type Entry struct {
41 Name string `json:"name"`
42 Title string `json:"title"`
43 Family string `json:"family"`
44 Kind string `json:"kind"`
45 Document bool `json:"document"`
46 Wrapper string `json:"wrapper"`
47 Scripts []string `json:"scripts"`
48 Default string `json:"default"`
49 States []State `json:"states"`
50}
51
52// State is one state of an example: its id (the file's stem), its
53// English label, and the application routes its markup links to,
54// sorted.
55type State struct {
56 ID string `json:"id"`
57 Label string `json:"label"`
58 SampleURLs []string `json:"sampleURLs"`
59}
60
61// Idiom names the example and state an idiom key maps to.
62type Idiom struct {
63 Example string `json:"example"`
64 State string `json:"state"`
65}
66
67// Piece is one state's markup with its address.
68type Piece struct {
69 Name, State, Markup string
70}
71
72// Load reads examples/examples.json from fsys, which is fiesta.FS() or
73// any tree laid out the same way.
74func Load(fsys fs.FS) (*Manifest, error) {
75 b, err := fs.ReadFile(fsys, "examples/examples.json")
76 if err != nil {
77 return nil, fmt.Errorf("example: %w", err)
78 }
79 var m Manifest
80 if err := json.Unmarshal(b, &m); err != nil {
81 return nil, fmt.Errorf("example: examples/examples.json: %w", err)
82 }
83 return &m, nil
84}
85
86// Entry returns the example called name.
87func (m *Manifest) Entry(name string) (Entry, bool) {
88 for _, e := range m.Examples {
89 if e.Name == name {
90 return e, true
91 }
92 }
93 return Entry{}, false
94}
95
96// Default returns the id of the state later readers use for name: the
97// one the manifest names as its default.
98func (m *Manifest) Default(name string) (string, bool) {
99 e, ok := m.Entry(name)
100 if !ok {
101 return "", false
102 }
103 return e.Default, true
104}
105
106// Idiom returns the example and state the idiom key maps to, so
107// Idiom("dropdown") gives ("dropdown", "menu-idiom", true).
108func (m *Manifest) Idiom(key string) (name, state string, ok bool) {
109 ref, ok := m.Idioms[key]
110 if !ok {
111 return "", "", false
112 }
113 return ref.Example, ref.State, true
114}
115
116// file is the path of one state's document inside the tree. A name or
117// state holding a path separator is refused rather than resolved: the
118// two are identifiers from the manifest, never paths.
119func file(name, state string) (string, error) {
120 if name == "" || state == "" || strings.ContainsAny(name, "/\\") || strings.ContainsAny(state, "/\\") {
121 return "", fmt.Errorf("example: %q/%q is not an example state", name, state)
122 }
123 return "examples/" + name + "/" + state + ".html", nil
124}
125
126// Document returns one state's whole document.
127func Document(fsys fs.FS, name, state string) (string, error) {
128 p, err := file(name, state)
129 if err != nil {
130 return "", err
131 }
132 b, err := fs.ReadFile(fsys, p)
133 if err != nil {
134 return "", fmt.Errorf("example: %w", err)
135 }
136 return string(b), nil
137}
138
139// Markup returns the bytes between <body> and </body> of one state's
140// document, exactly as written, trimmed of one leading and one
141// trailing newline. The tokenizer is used only to find the two tags,
142// so a nested container or a <template> inside the body is bytes like
143// any other, and nothing is re-serialised.
144func Markup(fsys fs.FS, name, state string) (string, error) {
145 doc, err := Document(fsys, name, state)
146 if err != nil {
147 return "", err
148 }
149 start, end, err := body(doc)
150 if err != nil {
151 return "", fmt.Errorf("example: %s/%s: %w", name, state, err)
152 }
153 return strings.TrimSuffix(strings.TrimPrefix(doc[start:end], "\n"), "\n"), nil
154}
155
156// body returns the offsets of the first byte after the <body> start
157// tag and of the </body> end tag.
158func body(doc string) (start, end int, err error) {
159 z := html.NewTokenizer(strings.NewReader(doc))
160 offset := 0
161 start, end = -1, -1
162 for {
163 tt := z.Next()
164 raw := len(z.Raw())
165 switch tt {
166 case html.ErrorToken:
167 if start < 0 {
168 return 0, 0, errors.New("no <body> tag")
169 }
170 return 0, 0, errors.New("no </body> tag")
171 case html.StartTagToken:
172 if n, _ := z.TagName(); start < 0 && string(n) == "body" {
173 start = offset + raw
174 }
175 case html.EndTagToken:
176 if n, _ := z.TagName(); start >= 0 && string(n) == "body" {
177 return start, offset, nil
178 }
179 }
180 offset += raw
181 }
182}
183
184// Element returns the one element in markup that carries attribute,
185// from its start tag to its matching end tag, exactly as written.
186//
187// The examples show a component inside the row it sits in, and a
188// reader that wants the bare component (the gallery's header, a drive
189// that boxes a dropdown on its own, a golden test against a partial)
190// would otherwise take the row with it. It is an error when no element
191// carries attribute, when two do at the outermost level (the second
192// would go unseen), and when the element is never closed (markup cut
193// short would otherwise read as a smaller component). A carrier inside
194// another counts once, as the outer one.
195func Element(markup, attribute string) (string, error) {
196 c, err := carrier(markup, attribute)
197 if err != nil {
198 return "", err
199 }
200 return markup[c.start:c.end], nil
201}
202
203// Contents returns what lies between that element's start and end tags,
204// exactly as written. A void element has none, and is an error, since
205// the caller asked for a container.
206func Contents(markup, attribute string) (string, error) {
207 c, err := carrier(markup, attribute)
208 if err != nil {
209 return "", err
210 }
211 if c.void {
212 return "", fmt.Errorf("example: the element carrying %s is a void element and holds nothing", attribute)
213 }
214 return markup[c.open:c.close], nil
215}
216
217// span is where one element lies in its markup: start to end is the
218// whole element, open to close what its tags enclose.
219type span struct {
220 start, open, close, end int
221 void bool
222}
223
224// voidElements have no end tag, so the element is its start tag alone;
225// counting depth for them would wait for a </input> that never comes.
226var voidElements = map[string]bool{
227 "area": true, "base": true, "br": true, "col": true, "embed": true, "hr": true, "img": true,
228 "input": true, "link": true, "meta": true, "source": true, "track": true, "wbr": true,
229}
230
231// carrier finds the one outermost element carrying attribute. Only
232// elements of the carrier's own tag name are counted on the way to its
233// end tag: a <details rst-menu-group> inside a dropdown's panel closes
234// with a </details> that is not the dropdown's.
235func carrier(markup, attribute string) (span, error) {
236 z := html.NewTokenizer(strings.NewReader(markup))
237 offset := 0
238 var found span
239 count, depth := 0, 0
240 tag := ""
241 for {
242 tt := z.Next()
243 raw := len(z.Raw())
244 switch tt {
245 case html.ErrorToken:
246 switch {
247 case depth > 0:
248 return span{}, fmt.Errorf("example: the element carrying %s is never closed", attribute)
249 case count == 0:
250 return span{}, fmt.Errorf("example: no element carries %s", attribute)
251 }
252 return found, nil
253 case html.StartTagToken, html.SelfClosingTagToken:
254 name, more := z.TagName()
255 if depth > 0 {
256 if string(name) == tag && tt == html.StartTagToken {
257 depth++
258 }
259 break
260 }
261 if !hasAttr(z, more, attribute) {
262 break
263 }
264 if count++; count > 1 {
265 return span{}, fmt.Errorf("example: more than one element carries %s", attribute)
266 }
267 tag = string(name)
268 found = span{start: offset, open: offset + raw}
269 if tt == html.SelfClosingTagToken || voidElements[tag] {
270 found.close, found.end, found.void = offset+raw, offset+raw, true
271 break
272 }
273 depth = 1
274 case html.EndTagToken:
275 if name, _ := z.TagName(); depth > 0 && string(name) == tag {
276 if depth--; depth == 0 {
277 found.close, found.end = offset, offset+raw
278 }
279 }
280 }
281 offset += raw
282 }
283}
284
285// hasAttr reports whether the start tag the tokenizer is on carries
286// the attribute name.
287func hasAttr(z *html.Tokenizer, more bool, name string) bool {
288 for more {
289 var k []byte
290 k, _, more = z.TagAttr()
291 if string(k) == name {
292 return true
293 }
294 }
295 return false
296}
297
298const (
299 slotOpen = "<!-- fiesta:slot -->"
300 slotClose = "<!-- /fiesta:slot -->"
301)
302
303// Slot replaces what lies between the slot comments of a layout
304// document with content, keeping the comments, so the result can be
305// slotted again. It is an error when the comments are absent or
306// appear twice.
307func Slot(doc, content string) (string, error) {
308 if n := strings.Count(doc, slotOpen); n != 1 {
309 return "", fmt.Errorf("example: the document holds %d slots, want one", n)
310 }
311 if n := strings.Count(doc, slotClose); n != 1 {
312 return "", fmt.Errorf("example: the document closes %d slots, want one", n)
313 }
314 i := strings.Index(doc, slotOpen)
315 j := strings.Index(doc, slotClose)
316 if j < i {
317 return "", errors.New("example: the slot closes before it opens")
318 }
319 return doc[:i+len(slotOpen)] + content + doc[j:], nil
320}
321
322// All returns every non-document state, in manifest order, each with
323// its markup.
324func All(fsys fs.FS) ([]Piece, error) {
325 m, err := Load(fsys)
326 if err != nil {
327 return nil, err
328 }
329 var out []Piece
330 for _, e := range m.Examples {
331 if e.Document {
332 continue
333 }
334 for _, s := range e.States {
335 markup, err := Markup(fsys, e.Name, s.ID)
336 if err != nil {
337 return nil, err
338 }
339 out = append(out, Piece{Name: e.Name, State: s.ID, Markup: markup})
340 }
341 }
342 return out, nil
343}
344