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
to localise")
}
return doc[:body[1]] + localMarkup(locale, name, doc[body[1]:end]) + doc[end:], nil
}
// localDocument is one state's document in locale: its body localised
// and its title the frame's (localTitle). English is the document as
// written.
func (s *site) localDocument(locale string, e example.Entry, st example.State, doc string) (string, error) {
doc, err := localBody(locale, e.Name, doc)
if err != nil {
return "", err
}
return s.localTitle(locale, e, st, doc)
}
// localTitle is a state's document with its the frame's title
// in the page's language, the example's name and the state's label, in
// place of the English the example wrote there. English is the
// document as written.
func (s *site) localTitle(locale string, e example.Entry, st example.State, doc string) (string, error) {
if locale == "en" {
return doc, nil
}
if n := len(titleText.FindAllStringIndex(doc, -1)); n != 1 {
return "", fmt.Errorf("the document has %d elements, want one", n)
}
title := "" + template.HTMLEscapeString(s.frameTitle(locale, e, st)) + ""
return titleText.ReplaceAllLiteralString(doc, title), nil
}
// localHTML is a document's in the page's language and its
// direction. A document that does not open as English left to right
// is an error, so a seed written otherwise is not silently left as
// English.
func localHTML(doc, locale string) (string, error) {
if locale == "en" {
return doc, nil
}
if !strings.Contains(doc, ltrHTML) {
return "", fmt.Errorf("no %s to localise", ltrHTML)
}
return strings.Replace(doc, ltrHTML, `