| 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. |
| 11 | package example |
| 12 | |
| 13 | import ( |
| 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. |
| 26 | type 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. |
| 40 | type 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. |
| 55 | type 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. |
| 62 | type Idiom struct { |
| 63 | Example string `json:"example"` |
| 64 | State string `json:"state"` |
| 65 | } |
| 66 | |
| 67 | // Piece is one state's markup with its address. |
| 68 | type 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. |
| 74 | func 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. |
| 87 | func (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. |
| 98 | func (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). |
| 108 | func (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. |
| 119 | func 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. |
| 127 | func 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. |
| 144 | func 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. |
| 158 | func 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. |
| 195 | func 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. |
| 206 | func 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. |
| 219 | type 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. |
| 226 | var 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. |
| 235 | func 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. |
| 287 | func 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 | |
| 298 | const ( |
| 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. |
| 307 | func 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. |
| 324 | func 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 | |