rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
27package designsystem
28
29import (
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.
42type proseManifest struct {
43 Examples []proseEntry `json:"examples"`
44}
45
46type proseEntry struct {
47 Name string `json:"name"`
48 Family string `json:"family"`
49 Kind string `json:"kind"`
50 States []proseState `json:"states"`
51}
52
53type proseState struct {
54 ID string `json:"id"`
55}
56
57type 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.
64type proseFile struct {
65 Families map[string]proseFamily `json:"families"`
66 Examples map[string]proseExample `json:"examples"`
67 Pages prosePages `json:"pages"`
68}
69
70type proseFamily struct {
71 Title string `json:"title"`
72 Blurb string `json:"blurb"`
73}
74
75type 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.
83type proseWarning struct {
84 Title string `json:"title"`
85 Body string `json:"body"`
86}
87
88type prosePages struct {
89 Idioms []proseRule `json:"idioms"`
90}
91
92type 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.
101type 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.
110var 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.
130var 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.
139var 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
144var 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.
148var 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.
173var 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.
183var 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
242type 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
252func 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
280func (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
291func (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.
308func 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.
329func (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.
339func (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.
361func (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.
386func (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.
483func (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.
502func (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