| 1 | //go:build fiestaseed |
| 2 | |
| 3 | // The Fiesta prose exporter for internal/designsystem. hack/seed/run-prose.sh |
| 4 | // copies this file into a scratch rastrillo worktree at the seed commit |
| 5 | // and runs TestFiestaProse there, so it reads the gallery's English |
| 6 | // where it is written: the family tables, the idiom blurbs, the shell |
| 7 | // views, the format and screen docs, and the two idiom rules. It |
| 8 | // reads, under FIESTA_SEED_OUT, the committed examples/examples.json |
| 9 | // and hack/seed/sources.json, and writes two files there: |
| 10 | // |
| 11 | // - examples/prose.json, every explanation the gallery shows, keyed |
| 12 | // by family, example and state, with a screen's warning callout as |
| 13 | // a title and body of its own beside the state's note; |
| 14 | // - hack/seed/prose-review.json, the strings that could not carry |
| 15 | // verbatim, each with the rastrillo text, the reason, and the |
| 16 | // approved replacement once there is one. |
| 17 | // |
| 18 | // A string joins the review queue when it mentions something Fiesta |
| 19 | // does not have (a Go template, a partial, a server, a cookie, |
| 20 | // rastrillo itself) or when, read against Fiesta's HTML, it is not |
| 21 | // true. The mechanical part is proseReason, over proseWords, |
| 22 | // proseFields and proseScripts; the rest is a judgement, written down |
| 23 | // in proseUntrue with its reason. A queued |
| 24 | // string's slot in prose.json is empty until approvedProse carries |
| 25 | // its replacement, so a reseed reproduces the approved text and never |
| 26 | // the rastrillo one. |
| 27 | package designsystem |
| 28 | |
| 29 | import ( |
| 30 | "encoding/json" |
| 31 | "fmt" |
| 32 | "os" |
| 33 | "path/filepath" |
| 34 | "regexp" |
| 35 | "sort" |
| 36 | "strings" |
| 37 | "testing" |
| 38 | ) |
| 39 | |
| 40 | // The manifest's shape and sources.json's, mirrored from Fiesta. The |
| 41 | // tags are the contract; this side cannot import the other. |
| 42 | type proseManifest struct { |
| 43 | Examples []proseEntry `json:"examples"` |
| 44 | } |
| 45 | |
| 46 | type proseEntry struct { |
| 47 | Name string `json:"name"` |
| 48 | Family string `json:"family"` |
| 49 | Kind string `json:"kind"` |
| 50 | States []proseState `json:"states"` |
| 51 | } |
| 52 | |
| 53 | type proseState struct { |
| 54 | ID string `json:"id"` |
| 55 | } |
| 56 | |
| 57 | type proseSource struct { |
| 58 | Kind string `json:"kind"` |
| 59 | Source string `json:"source"` |
| 60 | State int `json:"state"` |
| 61 | } |
| 62 | |
| 63 | // prose.json's shape, mirrored from Fiesta's gallery package. |
| 64 | type proseFile struct { |
| 65 | Families map[string]proseFamily `json:"families"` |
| 66 | Examples map[string]proseExample `json:"examples"` |
| 67 | Pages prosePages `json:"pages"` |
| 68 | } |
| 69 | |
| 70 | type proseFamily struct { |
| 71 | Title string `json:"title"` |
| 72 | Blurb string `json:"blurb"` |
| 73 | } |
| 74 | |
| 75 | type proseExample struct { |
| 76 | Blurb string `json:"blurb"` |
| 77 | States map[string]string `json:"states,omitempty"` |
| 78 | Warnings map[string]proseWarning `json:"warnings,omitempty"` |
| 79 | } |
| 80 | |
| 81 | // proseWarning is the callout after one state's note: a screen's |
| 82 | // WarningTitle and WarningBody, each a slot of its own. |
| 83 | type proseWarning struct { |
| 84 | Title string `json:"title"` |
| 85 | Body string `json:"body"` |
| 86 | } |
| 87 | |
| 88 | type prosePages struct { |
| 89 | Idioms []proseRule `json:"idioms"` |
| 90 | } |
| 91 | |
| 92 | type proseRule struct { |
| 93 | Idiom string `json:"idiom"` |
| 94 | Title string `json:"title"` |
| 95 | Body string `json:"body"` |
| 96 | } |
| 97 | |
| 98 | // proseReview is one line of hack/seed/prose-review.json: the slot, |
| 99 | // what rastrillo says there, why that cannot carry, and what Fiesta |
| 100 | // says instead once copy review has approved it. |
| 101 | type proseReview struct { |
| 102 | ID string `json:"id"` |
| 103 | Seed string `json:"seed"` |
| 104 | Reason string `json:"reason"` |
| 105 | Text string `json:"text"` |
| 106 | } |
| 107 | |
| 108 | // proseWords are the words that put a string in the review queue: |
| 109 | // each names something Fiesta's HTML does not have. |
| 110 | var proseWords = []struct { |
| 111 | what string |
| 112 | re *regexp.Regexp |
| 113 | }{ |
| 114 | {"Go", regexp.MustCompile(`\bGo\b`)}, |
| 115 | {"a template", regexp.MustCompile(`(?i)\btemplates?\b`)}, |
| 116 | {"{{", regexp.MustCompile(`\{\{`)}, |
| 117 | {"ui.", regexp.MustCompile(`\bui\.`)}, |
| 118 | {"rastrillo", regexp.MustCompile(`(?i)rastrillo`)}, |
| 119 | {"a partial", regexp.MustCompile(`(?i)\bpartials?\b`)}, |
| 120 | {"/_theme", regexp.MustCompile(`/_theme`)}, |
| 121 | {"a cookie", regexp.MustCompile(`(?i)\bcookies?\b`)}, |
| 122 | {"a server", regexp.MustCompile(`(?i)\bservers?\b`)}, |
| 123 | {".go", regexp.MustCompile(`\.go\b`)}, |
| 124 | } |
| 125 | |
| 126 | // proseFields are the capitalised field names a note uses as a |
| 127 | // template argument. Only the names no English sentence here starts |
| 128 | // with: Label, Name, Title, Select, Page and Path are judged by eye |
| 129 | // instead, in proseUntrue. |
| 130 | var proseFields = []string{ |
| 131 | "Lead", "Exact", "Primary", "Hidden", "ClearHref", "Placeholder", "Fit", "Desc", |
| 132 | "SelectName", "CancelLabel", "Alert", "Tone", "Options", "Plain", "RevokePath", |
| 133 | "RevokeAgentPath", "RevokeAllPath", "Created", "Currency", "Render", "PageData", |
| 134 | } |
| 135 | |
| 136 | // proseScripts are the script files a string may name: the ones |
| 137 | // Fiesta ships under that name. Any other .js in a string is a |
| 138 | // script Fiesta does not have. |
| 139 | var proseScripts = map[string]bool{ |
| 140 | "anchor.js": true, "busy.js": true, "calendar.js": true, "copy.js": true, "datetime.js": true, |
| 141 | "fiesta.js": true, "light.js": true, "select.js": true, "shell.js": true, |
| 142 | } |
| 143 | |
| 144 | var proseScript = regexp.MustCompile(`\b[a-z][a-z0-9-]*\.js\b`) |
| 145 | |
| 146 | // proseUntrue is the judgement: strings that pass the word list and |
| 147 | // are still not true of Fiesta's HTML, by slot, with the reason. |
| 148 | var proseUntrue = map[string]string{ |
| 149 | "examples.pagination.states.first-page-default-accessible-name": "Label is the template's argument, and the catalog is rastrillo's", |
| 150 | "examples.field-text.blurb": "Name is the template's argument; in the HTML the ids are written out", |
| 151 | "examples.field-daterange.blurb": "the derived ids are the template's; in the HTML they are written out", |
| 152 | "examples.notice.blurb": "describes the template's argument, which the HTML does not have", |
| 153 | "examples.form-error.blurb": "describes the template's argument, which the HTML does not have", |
| 154 | "examples.field-select.blurb": "the ten-option rule is the template's; in the HTML the data-rst-select attribute is what arms select.js", |
| 155 | "examples.job-status.blurb": "Fiesta ships no script that polls, and nothing here emits the attribute", |
| 156 | "examples.job-status.states.running": "Fiesta ships no script that polls, so this state asks the network for nothing", |
| 157 | "examples.layout-column.blurb": "Fiesta scaffolds nothing", |
| 158 | "examples.format-money.blurb": "money is a template function", |
| 159 | "examples.field-date.states.enhanced-input-type-tomorrow-or-in-3-weeks": "the page has no catalog; the words are on the attribute, and i18n/<lang>.json has the other languages", |
| 160 | "examples.field-datetime.states.enhanced-input-type-tomorrow-or-in-3-weeks": "the page has no catalog; the words are on the attribute, and i18n/<lang>.json has the other languages", |
| 161 | "examples.error-page.states.anything-else-the-generic-pair": "the framework catalog is rastrillo's; Fiesta's example is six documents", |
| 162 | } |
| 163 | |
| 164 | // proseAlways are the slots that are queued whatever rastrillo says |
| 165 | // there, with the reason. The standalone scheme menu is the one state |
| 166 | // Fiesta wrote itself: its page remembers the choice, and rastrillo's |
| 167 | // note describes a server. The sign-in example has no sentence about |
| 168 | // the set of screens, because screenDocs() explains each one and not |
| 169 | // the whole. Two of its screens' notes describe what the app behind |
| 170 | // the screen does, which Fiesta's static example cannot: one has a |
| 171 | // hard-coded address and forms that post, the other a link to # and |
| 172 | // nothing that moves on. |
| 173 | var proseAlways = map[string]string{ |
| 174 | "examples.scheme-menu.states.standalone": "rastrillo's note describes a server; this state's page remembers the choice itself", |
| 175 | "examples.signin.blurb": "no rastrillo source: screenDocs() explains each screen and not the set, so this one sentence is new", |
| 176 | "examples.signin.states.coming-back-with-keymail": "claims behaviour the static example lacks: remembering and checking the address", |
| 177 | "examples.signin.states.on-the-way-to-keymail": "claims behaviour the static example lacks: moving on by itself", |
| 178 | } |
| 179 | |
| 180 | // approvedProse is the review's answer, by slot. Nothing joins this |
| 181 | // map without copy review; a reseed writes these and never the |
| 182 | // rastrillo text they replace. |
| 183 | var approvedProse = map[string]string{ |
| 184 | "examples.actor.states.an-agent-acting-for-a-person": "The agent's name, then the person it acted for. When the app itself did something, show nothing.", |
| 185 | "examples.api-tokens.blurb": "The API token page: the one-time reveal, the create form, the list and the revoke buttons.", |
| 186 | "examples.api-tokens.states.four-tokens-two-of-them-live": "The revoke forms post to /tokens/revoke, /tokens/revoke-agent and /tokens/revoke-all, under the page's own path. Point them wherever your app handles them.", |
| 187 | "examples.api-tokens.states.just-created-the-secret-shown-once": "Show this in the response to the create form itself, and do not redirect it: the secret is in that response and nowhere else.", |
| 188 | "examples.callout.states.negative-announced-as-an-alert": "role=\"alert\" makes a screen reader announce it as soon as it appears. Keep it for messages that cannot wait.", |
| 189 | "examples.choice-field.blurb": "Option cards: the preferred radio or checkbox group. The title stays short; the description explains.", |
| 190 | "examples.confirm-form.states.ordinary-default-cancel-wording": "The cancel link says Cancel, which is the usual wording. Change it when the form's own words are clearer.", |
| 191 | "examples.error-page.states.anything-else-the-generic-pair": "Any status that has no wording of its own gets this generic pair: a title that says something is wrong and a body that says to try again.", |
| 192 | "examples.field-date.blurb": "A native date input, plus the natural-language combobox datetime.js adds when the input carries data-rst-date. Type \"tomorrow\" into it.", |
| 193 | "examples.field-date.states.enhanced-input-type-tomorrow-or-in-3-weeks": "The words it parses are on data-rst-date-words, here in the page's language. i18n/<lang>.json has them in twelve languages, so the field can parse whichever language the page is in.", |
| 194 | "examples.field-daterange.blurb": "A start and an end as one labelled fieldset. Give every input its own name and id.", |
| 195 | "examples.field-daterange.states.date-and-time-seeded": "Commit the start with the end still empty and datetime.js fills the end in an hour later. Nothing in the markup does that; the script does.", |
| 196 | "examples.field-datetime.states.enhanced-input-type-tomorrow-or-in-3-weeks": "The words it parses are on data-rst-date-words, here in the page's language. i18n/<lang>.json has them in twelve languages, so the field can parse whichever language the page is in.", |
| 197 | "examples.field-money.blurb": "A money amount in one currency. The currency code shows beside the label and is sent with the form, so a currency that changed while someone was typing is caught on save.", |
| 198 | "examples.field-select.blurb": "The field envelope around a native select. Add data-rst-select and select.js turns it into a filterable combobox; the native select is still what the form submits. Worth it from about ten options.", |
| 199 | "examples.field-select.states.hand-written-with-optgroups": "Grouped choices are optgroups you write yourself. select.js keeps the groups rather than dropping them; a select can also refuse the enhancement outright with data-rst-select=\"false\".", |
| 200 | "examples.field-select.states.twelve-options-the-filterable-combobox": "Add data-rst-select from about ten options. Below that, leave it off: search over a handful of items is furniture, not help.", |
| 201 | "examples.field-text.blurb": "One labelled text input. The required marker's star is aria-hidden; the input's required attribute is the programmatic signal.", |
| 202 | "examples.field-text.states.the-headline-field": "Give the main field of a form, a title or a name, rst-input=\"primary\" for a more prominent input.", |
| 203 | "examples.field-url.blurb": "A web address field that accepts example.com with or without https://. Whatever reads the form has to add the scheme and check the address.", |
| 204 | "examples.form-error.blurb": "A form's summary error line, role=\"alert\". Leave it out when there is no error.", |
| 205 | "examples.form-foot.states.idle-the-button-before-anything-happens": "A button that CHANGES something gets a loading state; a button that only reveals something, e.g. a disclosure, a dropdown, a tab, does not. busy.js applies that rule to every submit button in every form, with nothing to opt into.", |
| 206 | "examples.form-foot.states.working-what-busy-js-writes-on-the-way-out": "Only the button that was clicked: every other submit button in the form keeps its name and its value, and the form itself is guarded against a second submit. data-busy=\"false\" opts out, on the form or on one button; data-busy-label replaces the text. With scripts off none of this happens and the form submits exactly as it always did, so a second submit is still your app's job to catch.", |
| 207 | "examples.form-layout.blurb": "The attributes that give a form its rhythm and its save bar. They wrap a run of fields you compose yourself.", |
| 208 | "examples.format-dates.blurb": "Put a date in a <time> tag, with a version of the date a computer can read. Write the date itself however your readers expect to see it. Fiesta will not format it for you.", |
| 209 | "examples.format-money.blurb": "Store each amount as a whole number of the currency's smallest unit, such as cents, with its currency code beside it. Write it the way the reader's language does. The currency always comes from your data, never from the reader.", |
| 210 | "examples.job-status.blurb": "A background job's progress: a spinner, a line of text and a bar. The data-poll attributes say where a page could fetch a fresh copy and how often. Fiesta ships no script that does that, so wire it up yourself, and leave the attributes off once the job is done.", |
| 211 | "examples.job-status.states.running": "The data-poll attributes are where a script would fetch a fresh copy from, and how often. Nothing in Fiesta does, so on this page it stays running.", |
| 212 | "examples.layout-column.blurb": "The plain centred page an app starts on: a skip link, a title, and the content column.", |
| 213 | "examples.list-bar-search.states.empty-accessible-name-from-the-catalog": "No placeholder. The input's accessible name is its aria-label, which an app writes in the page's language.", |
| 214 | "examples.list-bar-search.states.with-a-query": "The ✕ is a link to the same screen with the query dropped. The status filter rides in a hidden input and survives it.", |
| 215 | "examples.list-bar-search.states.with-a-query-on-page-4": "The page number rides in a hidden input, so a new search would carry page=4 into a result set that no longer has four pages. The ✕ link drops it: point it at the list's first page.", |
| 216 | "examples.locale-menu.blurb": "The language switcher: a details menu of one-field POST forms, so the choice is sent to your app and the reader stays on the same path.", |
| 217 | "examples.locale-menu.states.twelve-languages-current-highlighted": "Each language is a one-field form, so choosing one is a POST your app answers. A static site can use a menu of plain links instead.", |
| 218 | "examples.media-grid.states.screenshots-a-logo-and-a-banner": "Give a logo or a banner rst-media-frame=\"contain\". A screenshot keeps the default and shows its top. A tile with no description says \"No description\".", |
| 219 | "examples.media-grid.states.select-mode-one-tile-selected": "Wrap the grid and a bulk-bar in one form. Each tile's checkbox posts its value under the name \"id\"; rename it if your app expects another. Inside the form, menu items must be links.", |
| 220 | "examples.meter.states.over-budget": "Over budget, the bar is full and the number says by how much: write value=\"100\" and leave the real figure to the text.", |
| 221 | "examples.notice.blurb": "A one-line confirmation after a redirect: one sentence in a paragraph with role=\"status\". Leave it out when there is nothing to say.", |
| 222 | "examples.pagination.states.first-page-default-accessible-name": "The nav's aria-label is the plain word Pagination. Say which list it pages when a page has more than one.", |
| 223 | "examples.scheme-menu.blurb": "The colour-scheme switch: a details menu of one-field POST forms for Light, Dark and System. Posted, the choice goes to your app; standalone, fiesta.js remembers it in the browser.", |
| 224 | "examples.scheme-menu.states.standalone": "No app behind it: the forms have no action, and with data-rst-scheme-store=\"local\" on the <html> element fiesta.js remembers the choice in the browser. The gallery's own header uses this one.", |
| 225 | "examples.scheme-menu.states.three-choices-dark-current": "Each scheme is a one-field form, so choosing one is a POST your app answers, and the page comes back with the chosen scheme marked current.", |
| 226 | "examples.seg-tabs.blurb": "A segmented control of plain links. The current tab is aria-current on a link.", |
| 227 | "examples.shell-topbar.blurb": "The topbar shell's own chrome, as markup. The whole page it belongs to is layout-topbar, under Shells.", |
| 228 | "examples.signin.blurb": "The sign-in screen in every state it passes through: asking for an address, coming back, after the link is sent, when something went wrong, and the other ways in.", |
| 229 | "examples.signin.states.asking-for-an-address": "One field and one button. Nothing to remember and nothing to steal.", |
| 230 | "examples.signin.states.coming-back-with-keymail": "For someone coming back: the address they used last time is on the button, so one tap sends it. Remembering that address, and checking it again, is your app's job.", |
| 231 | "examples.signin.states.on-the-way-to-keymail": "A holding page while someone is sent on to sign in. Your app moves the page on by itself; the link is there in case it does not.", |
| 232 | "examples.signin.warnings.email-and-password.body": "People reuse them, they leak, and you inherit the job of storing them safely. Use a link in your email plus a passkey, or passkeys on their own. The markup is here because some products still need it.", |
| 233 | "examples.stat.states.a-shortened-number": "Showing 4.1k instead of 4120? Put the full number in a <data> tag's value. Screen readers only read what is on screen, so keep anything a person needs in the number itself.", |
| 234 | "examples.stat.states.the-big-number": "Give one stat in the row rst-stat=\"lead\" to make it big. It is the same component, just larger.", |
| 235 | "examples.status-pill.states.neutral": "Neutral is the default tone, so rst-tone=\"neutral\" could be left off.", |
| 236 | "families.choices-and-actions.blurb": "The controls that pick from options, and the rows that end a form. Each one sits in a padded rst-box, because that is the card a form goes in.", |
| 237 | "families.form.blurb": "The fields a person types into, in the states a real form puts them in. Each one sits in a padded rst-box, because that is the card a form goes in.", |
| 238 | "families.screens.blurb": "Whole screens built from the components. Start your own from one of them.", |
| 239 | "families.shells.blurb": "The page frames an app is built in: a column, a top bar, a sidebar, a console and a stage. Each opens full width.", |
| 240 | } |
| 241 | |
| 242 | type proser struct { |
| 243 | t *testing.T |
| 244 | out string |
| 245 | m proseManifest |
| 246 | sources map[string]proseSource |
| 247 | file proseFile |
| 248 | queue []proseReview |
| 249 | carried []proseReview |
| 250 | } |
| 251 | |
| 252 | func TestFiestaProse(t *testing.T) { |
| 253 | out := os.Getenv("FIESTA_SEED_OUT") |
| 254 | if out == "" { |
| 255 | t.Skip("FIESTA_SEED_OUT is unset; this test is the Fiesta prose exporter, run by hack/seed/run-prose.sh") |
| 256 | } |
| 257 | p := &proser{t: t, out: out, sources: map[string]proseSource{}} |
| 258 | p.read("examples/examples.json", &p.m) |
| 259 | p.read("hack/seed/sources.json", &p.sources) |
| 260 | p.file = proseFile{Families: map[string]proseFamily{}, Examples: map[string]proseExample{}} |
| 261 | p.families() |
| 262 | p.examples() |
| 263 | p.rules() |
| 264 | p.check() |
| 265 | p.write("examples/prose.json", p.file) |
| 266 | sort.Slice(p.queue, func(i, j int) bool { return p.queue[i].ID < p.queue[j].ID }) |
| 267 | p.write("hack/seed/prose-review.json", p.queue) |
| 268 | // The inventory, for the run's log: every carried string with its |
| 269 | // slot, then every queued one with its reason. |
| 270 | sort.Slice(p.carried, func(i, j int) bool { return p.carried[i].ID < p.carried[j].ID }) |
| 271 | fmt.Printf("fiesta prose: %d strings carried, %d queued for review\n", len(p.carried), len(p.queue)) |
| 272 | for _, c := range p.carried { |
| 273 | fmt.Printf("carried: %s: %s\n", c.ID, c.Seed) |
| 274 | } |
| 275 | for _, q := range p.queue { |
| 276 | fmt.Printf("review: %s: %s\n", q.ID, q.Reason) |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | func (p *proser) read(rel string, v any) { |
| 281 | p.t.Helper() |
| 282 | b, err := os.ReadFile(filepath.Join(p.out, filepath.FromSlash(rel))) |
| 283 | if err != nil { |
| 284 | p.t.Fatal(err) |
| 285 | } |
| 286 | if err := json.Unmarshal(b, v); err != nil { |
| 287 | p.t.Fatalf("%s: %v", rel, err) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | func (p *proser) write(rel string, v any) { |
| 292 | p.t.Helper() |
| 293 | b, err := json.MarshalIndent(v, "", " ") |
| 294 | if err != nil { |
| 295 | p.t.Fatal(err) |
| 296 | } |
| 297 | path := filepath.Join(p.out, filepath.FromSlash(rel)) |
| 298 | if err := os.WriteFile(path, append(b, '\n'), 0o644); err != nil { |
| 299 | p.t.Fatal(err) |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | // proseReason is the mechanical part of the review: why text cannot |
| 304 | // carry, or "" when the word list, the template arguments and the |
| 305 | // script names all pass. internal/gallery/prose_rule_test.go holds |
| 306 | // the same text and applies it to prose.json, and a test there fails |
| 307 | // when the two drift. |
| 308 | func proseReason(text string) string { |
| 309 | for _, w := range proseWords { |
| 310 | if w.re.MatchString(text) { |
| 311 | return "mentions " + w.what |
| 312 | } |
| 313 | } |
| 314 | for _, f := range proseFields { |
| 315 | if regexp.MustCompile(`\b` + f + `\b`).MatchString(text) { |
| 316 | return "uses the template argument " + f |
| 317 | } |
| 318 | } |
| 319 | for _, s := range proseScript.FindAllString(text, -1) { |
| 320 | if !proseScripts[s] { |
| 321 | return "names " + s + ", which Fiesta does not ship" |
| 322 | } |
| 323 | } |
| 324 | return "" |
| 325 | } |
| 326 | |
| 327 | // reason says why text cannot carry, or "" when it can: the |
| 328 | // mechanical rule, then the judgement. |
| 329 | func (p *proser) reason(id, text string) string { |
| 330 | if why := proseReason(text); why != "" { |
| 331 | return why |
| 332 | } |
| 333 | return proseUntrue[id] |
| 334 | } |
| 335 | |
| 336 | // slot decides one string: carried verbatim, or queued with its |
| 337 | // reason and the approved text if there is one. It returns what |
| 338 | // prose.json holds at that slot. |
| 339 | func (p *proser) slot(id, seed string) string { |
| 340 | p.t.Helper() |
| 341 | if seed == "" && proseAlways[id] == "" { |
| 342 | return "" |
| 343 | } |
| 344 | why := proseAlways[id] |
| 345 | if why == "" { |
| 346 | why = p.reason(id, seed) |
| 347 | } |
| 348 | if why == "" { |
| 349 | p.carried = append(p.carried, proseReview{ID: id, Seed: seed}) |
| 350 | return seed |
| 351 | } |
| 352 | text := approvedProse[id] |
| 353 | p.queue = append(p.queue, proseReview{ID: id, Seed: seed, Reason: why, Text: text}) |
| 354 | return text |
| 355 | } |
| 356 | |
| 357 | // families carries the six sample families' Title and Blurb, and for |
| 358 | // Fiesta's other four families the page kind that holds them in |
| 359 | // rastrillo's gallery. Getting started, tokens and icons are pages |
| 360 | // Fiesta's README, landing page and gallery do themselves. |
| 361 | func (p *proser) families() { |
| 362 | seen := map[string]bool{} |
| 363 | for _, e := range p.m.Examples { |
| 364 | seen[e.Family] = true |
| 365 | } |
| 366 | kinds := map[string]pageKind{} |
| 367 | for _, pk := range pageKinds() { |
| 368 | kinds[pk.Kind] = pk |
| 369 | } |
| 370 | for key := range seen { |
| 371 | pk, ok := kinds[key] |
| 372 | if !ok { |
| 373 | p.t.Fatalf("family %s is no page kind at the seed", key) |
| 374 | } |
| 375 | p.file.Families[key] = proseFamily{ |
| 376 | Title: p.slot("families."+key+".title", pk.Title), |
| 377 | Blurb: p.slot("families."+key+".blurb", pk.Blurb), |
| 378 | } |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | // examples walks the manifest in its own order and fills each |
| 383 | // example's blurb and each state's note from the source sources.json |
| 384 | // names for it. A rastrillo state several Fiesta states came from |
| 385 | // gives its note to the first of them. |
| 386 | func (p *proser) examples() { |
| 387 | partials := map[string]partialDoc{} |
| 388 | for _, fam := range families() { |
| 389 | for _, doc := range fam.Partials { |
| 390 | partials[doc.Name] = doc |
| 391 | } |
| 392 | } |
| 393 | shells := map[string]string{} |
| 394 | for _, sv := range shellViews("", "day", "en") { |
| 395 | shells[sv.Name] = sv.Blurb |
| 396 | } |
| 397 | formats := formatDocs() |
| 398 | screens := screenDocs() |
| 399 | noted := map[string]bool{} |
| 400 | for _, e := range p.m.Examples { |
| 401 | ex := proseExample{States: map[string]string{}} |
| 402 | blurbID := "examples." + e.Name + ".blurb" |
| 403 | for i, st := range e.States { |
| 404 | key := e.Name + "/" + st.ID |
| 405 | src, ok := p.sources[key] |
| 406 | if !ok { |
| 407 | p.t.Fatalf("sources.json has no %s", key) |
| 408 | } |
| 409 | noteID := "examples." + e.Name + ".states." + st.ID |
| 410 | warnID := "examples." + e.Name + ".warnings." + st.ID |
| 411 | var blurb, note, warnTitle, warnBody string |
| 412 | switch src.Kind { |
| 413 | case "partial": |
| 414 | doc, ok := partials[src.Source] |
| 415 | if !ok || src.State >= len(doc.States) { |
| 416 | p.t.Fatalf("%s: no partial %s state %d", key, src.Source, src.State) |
| 417 | } |
| 418 | blurb, note = doc.Blurb, doc.States[src.State].Note |
| 419 | case "idiom": |
| 420 | blurb = idiomBlurbs[src.Source] |
| 421 | if e.Kind != "idiom" { |
| 422 | // The idiom rides on a component (dropdown): the |
| 423 | // component's blurb is the example's, and the |
| 424 | // idiom's is the note on its state. |
| 425 | blurb, note = "", idiomBlurbs[src.Source] |
| 426 | } |
| 427 | case "shell": |
| 428 | blurb = shells[src.Source] |
| 429 | case "format": |
| 430 | if src.State >= len(formats) || formats[src.State].Key != src.Source { |
| 431 | p.t.Fatalf("%s: format %d is not %s", key, src.State, src.Source) |
| 432 | } |
| 433 | blurb = formats[src.State].Body |
| 434 | case "screen": |
| 435 | if src.State >= len(screens) || screens[src.State].Key != src.Source { |
| 436 | p.t.Fatalf("%s: screen %d is not %s", key, src.State, src.Source) |
| 437 | } |
| 438 | // The screen's blurb is the state's note; its warning, |
| 439 | // where it has one, is the callout after the note, with |
| 440 | // the title and body each a slot of its own. |
| 441 | doc := screens[src.State] |
| 442 | note = doc.Blurb |
| 443 | warnTitle, warnBody = doc.WarningTitle, doc.WarningBody |
| 444 | default: |
| 445 | p.t.Fatalf("%s: unknown source kind %q", key, src.Kind) |
| 446 | } |
| 447 | // The example's blurb is its first state's source's: every |
| 448 | // state of an example shares one source, except the idiom |
| 449 | // a component carries, which is never first. |
| 450 | if i == 0 { |
| 451 | ex.Blurb = p.slot(blurbID, blurb) |
| 452 | } |
| 453 | origin := fmt.Sprintf("%s/%s/%d", src.Kind, src.Source, src.State) |
| 454 | if noted[origin] && proseAlways[noteID] == "" { |
| 455 | note, warnTitle, warnBody = "", "", "" |
| 456 | } |
| 457 | if note != "" { |
| 458 | noted[origin] = true |
| 459 | } |
| 460 | if v := p.slot(noteID, note); v != "" { |
| 461 | ex.States[st.ID] = v |
| 462 | } else if _, queued := proseAlways[noteID]; queued { |
| 463 | ex.States[st.ID] = "" |
| 464 | } |
| 465 | if warnBody != "" { |
| 466 | if ex.Warnings == nil { |
| 467 | ex.Warnings = map[string]proseWarning{} |
| 468 | } |
| 469 | ex.Warnings[st.ID] = proseWarning{ |
| 470 | Title: p.slot(warnID+".title", warnTitle), |
| 471 | Body: p.slot(warnID+".body", warnBody), |
| 472 | } |
| 473 | } |
| 474 | } |
| 475 | if len(ex.States) == 0 { |
| 476 | ex.States = nil |
| 477 | } |
| 478 | p.file.Examples[e.Name] = ex |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | // rules carries the two idiom rules, in idiom order. |
| 483 | func (p *proser) rules() { |
| 484 | names := make([]string, 0, len(idiomRules)) |
| 485 | for name := range idiomRules { |
| 486 | names = append(names, name) |
| 487 | } |
| 488 | sort.Strings(names) |
| 489 | for _, name := range names { |
| 490 | rule := idiomRules[name] |
| 491 | p.file.Pages.Idioms = append(p.file.Pages.Idioms, proseRule{ |
| 492 | Idiom: name, |
| 493 | Title: p.slot("pages.idioms."+name+".title", rule.Title), |
| 494 | Body: p.slot("pages.idioms."+name+".body", rule.Body), |
| 495 | }) |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | // check refuses an approved or judged slot that names nothing in the |
| 500 | // manifest, so a typo in one of the tables above fails the run |
| 501 | // rather than writing a string no page shows. |
| 502 | func (p *proser) check() { |
| 503 | known := map[string]bool{} |
| 504 | for key := range p.file.Families { |
| 505 | known["families."+key+".title"] = true |
| 506 | known["families."+key+".blurb"] = true |
| 507 | } |
| 508 | for _, e := range p.m.Examples { |
| 509 | known["examples."+e.Name+".blurb"] = true |
| 510 | for _, st := range e.States { |
| 511 | known["examples."+e.Name+".states."+st.ID] = true |
| 512 | known["examples."+e.Name+".warnings."+st.ID+".title"] = true |
| 513 | known["examples."+e.Name+".warnings."+st.ID+".body"] = true |
| 514 | } |
| 515 | } |
| 516 | for name := range idiomRules { |
| 517 | known["pages.idioms."+name+".title"] = true |
| 518 | known["pages.idioms."+name+".body"] = true |
| 519 | } |
| 520 | for _, table := range []map[string]string{approvedProse, proseUntrue, proseAlways} { |
| 521 | for id := range table { |
| 522 | if !known[id] { |
| 523 | p.t.Errorf("%s names no slot in the manifest", id) |
| 524 | } |
| 525 | } |
| 526 | } |
| 527 | queued := map[string]bool{} |
| 528 | for _, q := range p.queue { |
| 529 | queued[q.ID] = true |
| 530 | } |
| 531 | for id := range approvedProse { |
| 532 | if !queued[id] { |
| 533 | p.t.Errorf("%s is approved but was never queued; the rastrillo text there carries as it is", id) |
| 534 | } |
| 535 | } |
| 536 | if strings.TrimSpace(p.file.Families["primitives"].Title) == "" { |
| 537 | p.t.Error("the primitives family has no title") |
| 538 | } |
| 539 | } |
| 540 | |