package gallery import ( "encoding/json" "errors" "fmt" "html" "html/template" "io/fs" "regexp" "strings" "sync" "unicode" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" ) // ── Sample content in the page's language ──────────────────────────── // // The samples speak the language of the page they are on. A Japanese // page with English buttons was showing how English fits a component, // which is the one language a reader can already picture: German runs // long, Arabic runs right to left, and the only way to see either inside // a component is to put it there. The stand-ins stay as written: a // person is called Grace Hopper in every language, an address is an // address and a route is a route. // // This is Rastrillo's markup walker, under Rastrillo's names. Fiesta's // samples are all markup (the examples' own documents), so Rastrillo's // second walker, for a partial's data, has no counterpart here. Every // sample string is looked up by its English in // i18n/samples..json, which hack/seed/run-translations.sh // writes as Rastrillo's sampleProse is written, and every lookup is // traced as samples., so the parity gate and the leak gate // read what the frames asked for. // // Three things are Fiesta's own. localAttrs also writes the enhanced // controls' data-rst- attributes from the framework's own frozen // i18n/.json, so the select and date fields speak the page's // language without a script table. An element that carries lang is in // that language already (the language menu's endonyms) and stays as // written. And a word's sense, which Rastrillo's typed data carries and // plain HTML cannot, is named per example in sampleSenses. // // What stays as written is decided by rule rather than listed, so a new // sample is translated by default and the parity gate asks for its // eleven translations; see keepsAsWritten. The list that exists, // sampleStandIns, is the names and brands no rule can recognise. // sampleStandIns are the names, brands and fixed values that stay as // written inside a string that is otherwise translated. "Actions for // Grace Hopper" is translated around the name; "Grace Hopper" alone is // not looked up at all. TestSampleTranslationsKeepTheirStandIns holds // every translation of a key that carries one to carrying it too. // Rastrillo's list, then the names Fiesta's seeded examples carry that // Rastrillo's do not. var sampleStandIns = []string{ // People. "Grace Hopper", "Ada Lovelace", "Alan Turing", "Fiona Reid", "Otto Neurath", "Hedy Lamarr", "Mary Sherman", // Brands and products: the gallery's own sample apps, the framework, // and the companies whose buttons the sign-in screen draws. "Harbour", "rastrillo", "Google", "Apple", "WCAG", // Fixed values a translation would only damage: a shortened count, // and a postal address that is somewhere. "4.1k", "42 Wharf Road, Dublin 2", // Fiesta's own: the framework's name, the sample app the shells // frame, the sign-in service the screens name, and the two agents // the actor and token samples name. Then the fixed values: the // currency code the money field shows and posts beside its amount // (Rastrillo keeps it as a data key, Currency), a request's // reference, and the reference an error page quotes. "Fiesta", "Party planner", "Keymail", "Claude", "Codex", "EUR", "REQ-4471", "k3f9tq", } var ( // sampleIdent is a machine's word: a field name, an option's value, a // slug. A lowercase letter first, then no spaces. sampleIdent = regexp.MustCompile(`^\p{Ll}[\p{L}\p{N}_.\-]*$`) // sampleCode is a reference with a digit in it (AB3PX, D02, #4471), // or a one- or two-letter initial. sampleCode = regexp.MustCompile(`#?\b(?:[A-Z]*[0-9][A-Z0-9]*|[A-Z]{1,2})\b`) sampleMail = regexp.MustCompile(`\S+@\S*`) ) // keepsAsWritten says whether a sample string stays as it is in every // language: a URL, an address, a machine's identifier, a number with no // words around it, or nothing but stand-ins. func keepsAsWritten(s string) bool { s = strings.TrimSpace(html.UnescapeString(s)) if s == "" || strings.HasPrefix(s, "/") || strings.HasPrefix(s, "?") || strings.HasPrefix(s, "#") && !strings.Contains(s, " ") || strings.Contains(s, "://") { return true } if sampleIdent.MatchString(s) { return true } return !hasWords(s) } // hasWords says whether anything a reader would call a word is left in s // once the stand-ins, the email addresses and the codes are taken out. func hasWords(s string) bool { for _, k := range sampleStandIns { s = strings.ReplaceAll(s, k, "") } s = sampleMail.ReplaceAllString(s, "") s = sampleCode.ReplaceAllString(s, "") return strings.IndexFunc(s, unicode.IsLetter) >= 0 } // verbSense is Rastrillo's suffix for a sample word that is an action // where the same English is a noun elsewhere in the samples: Archive in // a row's menu against the Archive a topbar links to. English spells // the two alike and most of the eleven do not (Archivar and Archivo), // so the action is looked up under its own key, the word plus // verbSense. English still reads the bare word. const verbSense = " (verb)" // nounSense is the other way round: a sample word that names a thing // where the catalog's bare word is the action. The seg-tabs group // "View" names the view a reader picks; the bare View is a row menu's // link, a verb in most of the eleven (Russian Открыть, open), which // would label the group as a command. const nounSense = " (noun)" // withoutSense is a sample key's English: the key with its sense, if // it has one, taken off. func withoutSense(key string) string { return strings.TrimSuffix(strings.TrimSuffix(key, verbSense), nounSense) } // sampleSenses names, per example, the sample text whose key is not its // English. Rastrillo marks the sense in its typed sample data // (verb("Archive")); an example here is plain HTML, which cannot, so // the example is named instead. localMarkup and EnglishStrings both // apply it, so the walker and the exporter cannot disagree. Rastrillo's // sampleProse has two verb keys, and Fiesta's examples use both words // in that sense: the row menu's Archive button and the bulk bar's // Export action. var sampleSenses = map[string]map[string]string{ "row-menu": {"Archive": "Archive" + verbSense}, "bulk-bar": {"Export": "Export" + verbSense}, "seg-tabs": {"View": "View" + nounSense}, } // sampleID is the id a sample string is traced under: its key, which is // its English or, for a word in a sense, its English and the sense. func sampleID(key string) string { return "samples." + key } // sampleCatalogs is every non-English locale's sample translations, // read once from i18n/samples..json: an English key to its // translation, as Rastrillo's sampleProse rows are. var sampleCatalogs = sync.OnceValues(func() (map[string]catalog, error) { out := map[string]catalog{} for _, locale := range Locales { if locale == "en" { continue } name := "i18n/samples." + locale + ".json" b, err := fs.ReadFile(catalogFiles, name) if err != nil { return nil, fmt.Errorf("the %s sample catalog: %w", locale, err) } var c catalog if err := json.Unmarshal(b, &c); err != nil { return nil, fmt.Errorf("%s: %w", name, err) } out[locale] = c } return out, nil }) // samplesIn is one locale's sample translations; nil for English. func samplesIn(locale string) catalog { cs, err := sampleCatalogs() if err != nil { return nil } return cs[locale] } // sampleText is one sample string in locale, by its key: the catalog's // translation, or the English (the key without its sense) for English // and for a key the catalog does not answer, which the parity gate // fails. Every lookup is traced. func sampleText(locale, key string) string { trace(sampleID(key)) en := withoutSense(key) if locale == "en" { return en } if t := samplesIn(locale)[key]; t != "" { return t } return en } // controlStrings is the framework's own catalog for each locale, as the // enhanced controls read it from a data-rst- attribute: every // string as it is, and the date-words vocabulary as its JSON, compact // with its keys sorted, as the seeded documents write it. var controlStrings = sync.OnceValues(func() (map[string]map[string]string, error) { out := map[string]map[string]string{} for _, locale := range Locales { b, ok := fiesta.Strings(locale) if !ok { return nil, fmt.Errorf("the framework has no i18n/%s.json", locale) } var raw map[string]json.RawMessage if err := json.Unmarshal(b, &raw); err != nil { return nil, fmt.Errorf("i18n/%s.json: %w", locale, err) } table := map[string]string{} for k, v := range raw { var s string if json.Unmarshal(v, &s) == nil { table[k] = s continue } var words map[string]string if err := json.Unmarshal(v, &words); err != nil { return nil, fmt.Errorf("i18n/%s.json: %s is neither a string nor a table of words", locale, k) } j, err := json.Marshal(words) if err != nil { return nil, err } table[k] = string(j) } out[locale] = table } return out, nil }) // ── Markup ─────────────────────────────────────────────────────────── // spokenAttrs are the attributes a person reads or hears. Every other // attribute is the machine's, including value on anything but a text // input: an