rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1package www
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "io/fs"
9 "path"
10 "regexp"
11 "slices"
12 "strings"
13)
14
15// Languages is every language the landing page is built in, English
16// first and the rest in code order. Arabic is written right to left.
17// yue is in Fiesta's own string tables but is not a site language.
18var Languages = []string{"en", "ar", "bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "zh-Hans"}
19
20// Dir is the direction lang is written in, as the html element's dir
21// attribute wants it: rtl for Arabic, ltr for everything else.
22func Dir(lang string) string {
23 if lang == "ar" {
24 return "rtl"
25 }
26 return "ltr"
27}
28
29// RequiredKeys is every key a landing page catalog holds, sorted. A
30// catalog has exactly these: a key the page would render empty, or one
31// the page never reads, is a mistake Strings reports.
32var RequiredKeys = []string{
33 "copied",
34 "copy",
35 "copy.failed",
36 "cta.see",
37 "cta.themes",
38 "description",
39 "foot.carlos.framework",
40 "foot.carlos.platform",
41 "foot.carlos.start",
42 "foot.docs",
43 "foot.eleven",
44 "foot.letters",
45 "foot.licence",
46 "foot.principles",
47 "foot.source",
48 "foot.tagline",
49 "foot.trust",
50 "foot.version",
51 "headline",
52 "install",
53 "lang.label",
54 "lede",
55 "nav.design",
56 "nav.label",
57 "nav.start",
58 "nav.themes",
59 "sample.cancel",
60 "sample.coming",
61 "sample.courses",
62 "sample.desserts",
63 "sample.dish.glutenfree",
64 "sample.dish.nuts",
65 "sample.dish.vegetarian",
66 "sample.dishes",
67 "sample.email",
68 "sample.export",
69 "sample.fade",
70 "sample.guest.noreply",
71 "sample.guest.plusone",
72 "sample.guest.vegetarian",
73 "sample.guests",
74 "sample.invite",
75 "sample.invited",
76 "sample.label",
77 "sample.mains",
78 "sample.menu",
79 "sample.music",
80 "sample.nav.label",
81 "sample.next",
82 "sample.next.body",
83 "sample.overview",
84 "sample.partyname",
85 "sample.playing",
86 "sample.public",
87 "sample.reminders",
88 "sample.save",
89 "sample.saved",
90 "sample.search",
91 "sample.searched",
92 "sample.settings",
93 "sample.shuffle",
94 "sample.songs",
95 "sample.starters",
96 "sample.timezone",
97 "scheme.dark",
98 "scheme.label",
99 "scheme.light",
100 "scheme.system",
101 "skip",
102 "theme.day",
103 "theme.label",
104 "theme.plain",
105 "theme.signal",
106 "title",
107 "why.a11y.body",
108 "why.a11y.title",
109 "why.attrs.body",
110 "why.attrs.title",
111 "why.dark.body",
112 "why.dark.title",
113 "why.depth.body",
114 "why.depth.title",
115 "why.link.body",
116 "why.link.title",
117 "why.rtl.body",
118 "why.rtl.title",
119}
120
121// stringsDir is the directory under the site source holding the
122// landing page's catalogs, one <lang>.json each: a JSON object from
123// key to string, nothing else.
124const stringsDir = "landing/strings"
125
126// placeholder is a {name} a template fills in. The English string
127// decides which placeholders a key has; a translation holds the same
128// ones, or the page would say something different in that language.
129var placeholder = regexp.MustCompile(`\{[^{}]*\}`)
130
131// Strings reads the landing page's catalog for lang from the site
132// source, landing/strings/<lang>.json, and returns it key by key. It
133// refuses a catalog that is missing, that lacks a required key or
134// holds one the page never reads, that leaves a value empty, or that
135// drops or adds a placeholder the English string has; each error names
136// the key. English is read alongside every other language for the
137// placeholder check.
138func Strings(siteDir fs.FS, lang string) (map[string]string, error) {
139 out, err := catalog(siteDir, lang)
140 if err != nil {
141 return nil, err
142 }
143 if lang == "en" {
144 return out, nil
145 }
146 en, err := catalog(siteDir, "en")
147 if err != nil {
148 return nil, err
149 }
150 for _, key := range RequiredKeys {
151 want, got := placeholder.FindAllString(en[key], -1), placeholder.FindAllString(out[key], -1)
152 slices.Sort(want)
153 slices.Sort(got)
154 if !slices.Equal(want, got) {
155 return nil, fmt.Errorf("www: %s: %s has placeholders %v, but the English has %v", name(lang), key, got, want)
156 }
157 }
158 return out, nil
159}
160
161// name is the catalog's path under the site source.
162func name(lang string) string {
163 return path.Join(stringsDir, lang+".json")
164}
165
166// catalog reads one language's catalog and checks it against
167// RequiredKeys. It decodes token by token rather than into a map, so
168// a key written twice is refused instead of the second quietly
169// winning, and so a value that is not a string is named.
170func catalog(siteDir fs.FS, lang string) (map[string]string, error) {
171 data, err := fs.ReadFile(siteDir, name(lang))
172 if err != nil {
173 return nil, fmt.Errorf("www: reading the %s catalog: %w", lang, err)
174 }
175 dec := json.NewDecoder(bytes.NewReader(data))
176 if tok, err := dec.Token(); err != nil || tok != json.Delim('{') {
177 return nil, fmt.Errorf("www: %s is not a JSON object", name(lang))
178 }
179 out := make(map[string]string, len(RequiredKeys))
180 for dec.More() {
181 tok, err := dec.Token()
182 if err != nil {
183 return nil, fmt.Errorf("www: %s: %w", name(lang), err)
184 }
185 key := tok.(string)
186 if _, dup := out[key]; dup {
187 return nil, fmt.Errorf("www: %s: %s is written twice", name(lang), key)
188 }
189 var value string
190 if err := dec.Decode(&value); err != nil {
191 return nil, fmt.Errorf("www: %s: %s: %w", name(lang), key, err)
192 }
193 if strings.TrimSpace(value) == "" {
194 return nil, fmt.Errorf("www: %s: %s is empty", name(lang), key)
195 }
196 out[key] = value
197 }
198 if _, err := dec.Token(); err != nil {
199 return nil, fmt.Errorf("www: %s: %w", name(lang), err)
200 }
201 if _, err := dec.Token(); err != io.EOF {
202 return nil, fmt.Errorf("www: %s has content after the object", name(lang))
203 }
204 for _, key := range RequiredKeys {
205 if _, ok := out[key]; !ok {
206 return nil, fmt.Errorf("www: %s lacks %s", name(lang), key)
207 }
208 }
209 if len(out) != len(RequiredKeys) {
210 for key := range out {
211 if _, ok := slices.BinarySearch(RequiredKeys, key); !ok {
212 return nil, fmt.Errorf("www: %s has %s, which the page never reads", name(lang), key)
213 }
214 }
215 }
216 return out, nil
217}
218