Release / Build and publish image (push) Failing after 10m6s
Beskeden efter "Fetch now" stod på dansk uanset sprog, og med et "(r)" i stedet for rigtig bøjning. Den bruger nu t() og plural() som resten. Vagten burde have fanget den og gjorde det ikke: den krævede et anførselstegn lige efter app.toast(, og denne stod som en skabelonstreng inde i en ternær hen over to linjer. Den læser nu hele argumentlisten frem til den matchende slutparentes, så det er formen på kaldet og ikke tilfældig placering, der afgør, om en streng bliver set. Sat på prøve ved at lægge den gamle tekst tilbage: begge strenge blev fanget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
5.7 KiB
Go
154 lines
5.7 KiB
Go
package i18n
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Danish prose left in a component is the failure the dictionaries cannot see.
|
|
//
|
|
// `i18n_test.go` checks that the two dictionaries agree. It cannot check the thing
|
|
// that actually went wrong: a string that was never put in either, because nobody
|
|
// noticed it. Three survived the sweep — "Ingen nyere version fundet.", "Ikke mere
|
|
// i dag." and a token hint split across two lines — and they survived it for the
|
|
// same reason. The sweep grepped for æøå, and none of them contain one.
|
|
//
|
|
// So this reads the templates instead and looks for the words that give Danish
|
|
// away whatever letters it happens to use. A word list rather than a language
|
|
// detector: this repository ships one static binary, and a list of thirty common
|
|
// words catches the sentences a person would actually write.
|
|
// The same words, inside the code rather than the markup.
|
|
//
|
|
// `confirm()`, `alert()` and `app.toast()` take a string argument, and a string
|
|
// argument lives in the script block where the scanner above never looks. Six of
|
|
// them survived the whole i18n sweep that way — every destructive confirmation in
|
|
// the app, which is the worst possible set to leave in one language, because it is
|
|
// the text somebody reads in the half-second before they agree to lose something.
|
|
func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
|
|
// Every string literal in the argument list of one of the three, not just one
|
|
// sitting flush against the paren. A toast written as a ternary over two lines
|
|
// hid Danish from the earlier version of this guard for a whole release.
|
|
// Deliberately still narrow: a general scan of every literal in a script block
|
|
// would drown in class names, keys and query fragments.
|
|
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(`)
|
|
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|gemmes|hentet|hentes|beskeder|opgave|opgaver|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
|
|
|
|
var found []string
|
|
root := filepath.Join("..", "..", "web", "src")
|
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return err
|
|
}
|
|
if !strings.HasSuffix(path, ".svelte") && !strings.HasSuffix(path, ".js") {
|
|
return nil
|
|
}
|
|
if strings.Contains(path, "locales") {
|
|
return nil
|
|
}
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(root, path)
|
|
src := string(body)
|
|
for _, loc := range call.FindAllStringIndex(src, -1) {
|
|
for _, lit := range literalsInCall(src[loc[1]:]) {
|
|
if danish.MatchString(lit) {
|
|
found = append(found, rel+": "+lit)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk %s: %v", root, err)
|
|
}
|
|
|
|
if len(found) > 0 {
|
|
t.Errorf("%d dialog or toast string(s) are written in Danish inside the code. "+
|
|
"They are what somebody reads before agreeing to lose something, and an "+
|
|
"English interface shows them in Danish:\n %s",
|
|
len(found), strings.Join(found, "\n "))
|
|
}
|
|
}
|
|
|
|
// literalsInCall returns the string literals in one argument list, given the text
|
|
// just past its opening paren. It stops at the matching close paren so the next
|
|
// statement's strings are somebody else's problem.
|
|
func literalsInCall(src string) []string {
|
|
var out []string
|
|
depth := 1
|
|
for i := 0; i < len(src); i++ {
|
|
switch c := src[i]; c {
|
|
case '(':
|
|
depth++
|
|
case ')':
|
|
if depth--; depth == 0 {
|
|
return out
|
|
}
|
|
case '\'', '"', '`':
|
|
end := strings.IndexByte(src[i+1:], c)
|
|
if end < 0 {
|
|
return out
|
|
}
|
|
if lit := src[i+1 : i+1+end]; len(lit) >= 4 {
|
|
out = append(out, lit)
|
|
}
|
|
i += end + 1
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
|
|
// Text nodes of three characters or more, and the three attributes a person
|
|
// reads. Anything inside {…} is already an expression — usually a t() call —
|
|
// and is not what this is looking for.
|
|
text := regexp.MustCompile(`>\s*([^<>{}\n][^<>{}]{3,})\s*<`)
|
|
attrs := regexp.MustCompile(`(?:aria-label|title|placeholder)="([^"{][^"]{3,})"`)
|
|
// Words that are Danish and are not also English. "is", "for" and "to" would
|
|
// match English prose and are deliberately absent.
|
|
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb)\b`)
|
|
comments := regexp.MustCompile(`(?s)<!--.*?-->`)
|
|
styles := regexp.MustCompile(`(?s)<style>.*`)
|
|
|
|
var found []string
|
|
root := filepath.Join("..", "..", "web", "src")
|
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".svelte") {
|
|
return err
|
|
}
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
markup := string(body)
|
|
if at := strings.Index(markup, "</script>"); at > 0 {
|
|
markup = markup[at:]
|
|
}
|
|
markup = styles.ReplaceAllString(comments.ReplaceAllString(markup, ""), "")
|
|
|
|
rel, _ := filepath.Rel(root, path)
|
|
for _, m := range append(text.FindAllStringSubmatch(markup, -1),
|
|
attrs.FindAllStringSubmatch(markup, -1)...) {
|
|
phrase := strings.TrimSpace(m[1])
|
|
if danish.MatchString(phrase) {
|
|
found = append(found, rel+": "+strings.Join(strings.Fields(phrase), " "))
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk %s: %v", root, err)
|
|
}
|
|
|
|
if len(found) > 0 {
|
|
t.Errorf("%d Danish string(s) are written into a component instead of going through "+
|
|
"t(). An English interface shows them in Danish, and neither dictionary knows "+
|
|
"they exist:\n %s", len(found), strings.Join(found, "\n "))
|
|
}
|
|
}
|