diff --git a/cmd/brief/html.go b/cmd/brief/html.go new file mode 100644 index 0000000..9118836 --- /dev/null +++ b/cmd/brief/html.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/git-pkgs/brief" + "github.com/git-pkgs/brief/detect" + "github.com/git-pkgs/brief/kb" + "github.com/git-pkgs/brief/remote" + "github.com/git-pkgs/brief/report" +) + +func cmdHTML(args []string) { + fs := flag.NewFlagSet("brief html", flag.ExitOnError) + out := fs.String("o", "", "Output file (default stdout)") + keep := fs.Bool("keep", false, "Keep downloaded remote source") + depth := fs.Int("depth", -1, "Git clone depth (0 = full clone, default shallow)") + dir := fs.String("dir", "", "Directory to clone remote source into") + scanDepth := fs.Int("scan-depth", 0, "Max directory depth for language detection (0 = unlimited)") + skip := fs.String("skip", "", "Additional directories to skip, comma-separated") + tracked := fs.Bool("tracked", false, "Only consider files tracked by git") + _ = fs.Parse(args) + + path := "." + if fs.NArg() > 0 { + path = fs.Arg(0) + } + + src, err := remote.Resolve(context.Background(), path, remote.Options{ + Keep: *keep, + Depth: *depth, + Dir: *dir, + }) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + code := runHTML(src.Dir, *out, *scanDepth, *skip, *tracked) + src.Cleanup() + os.Exit(code) +} + +func runHTML(dir, out string, scanDepth int, skip string, tracked bool) int { + knowledgeBase, err := kb.Load(brief.KnowledgeFS) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error loading knowledge base: %v\n", err) + return 1 + } + + engine := detect.New(knowledgeBase, dir) + engine.ScanDepth = scanDepth + engine.TrackedOnly = tracked + if skip != "" { + engine.SkipDirs = strings.Split(skip, ",") + } + r, err := engine.Run() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + + var w io.Writer = os.Stdout + if out != "" { + f, ferr := os.Create(out) //nolint:gosec + if ferr != nil { + _, _ = fmt.Fprintf(os.Stderr, "error creating %s: %v\n", out, ferr) + return 1 + } + defer func() { _ = f.Close() }() + w = f + } + + if err := report.HTML(w, r); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error rendering HTML: %v\n", err) + return 1 + } + if out != "" { + _, _ = fmt.Fprintf(os.Stderr, "wrote %s\n", out) + } + return 0 +} diff --git a/cmd/brief/main.go b/cmd/brief/main.go index 7efe2c4..db933bb 100644 --- a/cmd/brief/main.go +++ b/cmd/brief/main.go @@ -54,6 +54,9 @@ func main() { case "outline": cmdOutline(os.Args[2:]) return + case "html": + cmdHTML(os.Args[2:]) + return } } diff --git a/go.mod b/go.mod index fc5adb1..d883ae8 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/git-pkgs/purl v0.1.12 github.com/git-pkgs/registries v0.6.1 github.com/git-pkgs/spdx v0.1.4 + github.com/stretchr/testify v1.11.1 golang.org/x/term v0.43.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -199,7 +200,6 @@ require ( github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/tetafro/godot v1.5.4 // indirect github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect diff --git a/report/html.go b/report/html.go new file mode 100644 index 0000000..3ae04d8 --- /dev/null +++ b/report/html.go @@ -0,0 +1,705 @@ +package report + +import ( + "fmt" + "hash/fnv" + "html/template" + "io" + "path/filepath" + "sort" + "strings" + + "github.com/git-pkgs/brief" +) + +// HTML writes the report as a self-contained HTML page. +func HTML(w io.Writer, r *brief.Report) error { + return htmlTmpl.Execute(w, newHTMLData(r)) +} + +type htmlData struct { + R *brief.Report + Title string + RepoURL string + Langs []langSlice + ToolGroups []toolGroup + Direct []brief.DepInfo + Transitive []brief.DepInfo + DepSummary string + Resources []resourceLink + Facts []fact +} + +type langSlice struct { + Name string + Lines int + Percent float64 + Color string +} + +type toolGroup struct { + Key string + Label string + Icon string + Tools []brief.Detection +} + +type resourceLink struct { + Label string + Path string + Note string + Icon string +} + +type fact struct { + Label string + Value string +} + +func newHTMLData(r *brief.Report) *htmlData { + d := &htmlData{R: r, Title: projectTitle(r), RepoURL: repoWebURL(r)} + d.Langs = languageSlices(r.Lines) + d.ToolGroups = orderedToolGroups(r) + d.Direct, d.Transitive = splitDeps(r.Dependencies) + d.DepSummary = depSummary(r.Dependencies) + d.Resources = resourceLinks(r.Resources) + d.Facts = facts(r) + return d +} + +func projectTitle(r *brief.Report) string { + if r.Git != nil { + if u := r.Git.Remotes["origin"]; u != "" { + s := strings.TrimSuffix(u, ".git") + s = strings.TrimSuffix(s, "/") + if i := strings.LastIndexAny(s, "/:"); i >= 0 && i < len(s)-1 { + return s[i+1:] + } + } + } + if r.Path != "" && r.Path != "." { + return filepath.Base(r.Path) + } + return "project" +} + +func repoWebURL(r *brief.Report) string { + if r.Git == nil { + return "" + } + u := r.Git.Remotes["origin"] + if u == "" { + return "" + } + u = strings.TrimSuffix(u, ".git") + if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") { + return u + } + if rest, ok := strings.CutPrefix(u, "git@"); ok { + return "https://" + strings.Replace(rest, ":", "/", 1) + } + return "" +} + +//nolint:mnd // 100 is percent, 360 is hue degrees +func languageSlices(lc *brief.LineCount) []langSlice { + if lc == nil || len(lc.ByLanguage) == 0 { + return nil + } + skip := map[string]bool{"License": true, "Plain Text": true, "gitignore": true} + type kv struct { + k string + v int + } + var pairs []kv + total := 0 + for k, v := range lc.ByLanguage { + if skip[k] || v == 0 { + continue + } + pairs = append(pairs, kv{k, v}) + total += v + } + if total == 0 { + return nil + } + sort.Slice(pairs, func(i, j int) bool { return pairs[i].v > pairs[j].v }) + + const maxSlices = 8 + var out []langSlice + other := 0 + for i, p := range pairs { + if i >= maxSlices { + other += p.v + continue + } + out = append(out, langSlice{ + Name: p.k, + Lines: p.v, + Percent: float64(p.v) / float64(total) * 100, + Color: hashColor(p.k), + }) + } + if other > 0 { + out = append(out, langSlice{ + Name: "Other", + Lines: other, + Percent: float64(other) / float64(total) * 100, + Color: "#6e7681", + }) + } + return out +} + +func hashColor(s string) string { + const hueRange = 360 + h := fnv.New32a() + _, _ = h.Write([]byte(s)) + return fmt.Sprintf("hsl(%d 65%% 55%%)", h.Sum32()%hueRange) +} + +//nolint:goconst // category keys repeat CategoryOrder/CategoryLabels by design +var categoryIcons = map[string]string{ + "language": "code", + "package_manager": "package", + "test": "flask-conical", + "lint": "search-check", + "format": "align-left", + "typecheck": "check-check", + "docs": "book-open", + "build": "hammer", + "library": "library", + "codegen": "wand", + "database": "database", + "security": "shield", + "ci": "rocket", + "container": "container", + "infrastructure": "server", + "monorepo": "layers", + "environment": "leaf", + "i18n": "languages", + "release": "tag", + "coverage": "gauge", + "dependency_bot": "bot", +} + +func categoryIcon(cat string) string { + if i, ok := categoryIcons[cat]; ok { + return i + } + return "circle-dot" +} + +const languageLineThreshold = 10.0 + +func majorLanguages(langs []brief.Detection, lc *brief.LineCount) []brief.Detection { + if lc == nil || len(lc.ByLanguage) == 0 { + return langs + } + total := 0 + for _, n := range lc.ByLanguage { + total += n + } + if total == 0 { + return langs + } + var out []brief.Detection + for _, l := range langs { + n, ok := lc.ByLanguage[l.Name] + if !ok { + out = append(out, l) + continue + } + if float64(n)/float64(total)*100 >= languageLineThreshold { + out = append(out, l) + } + } + if len(out) == 0 { + return langs + } + return out +} + +func orderedToolGroups(r *brief.Report) []toolGroup { + var groups []toolGroup + add := func(key, label string, dets []brief.Detection) { + if len(dets) == 0 { + return + } + groups = append(groups, toolGroup{Key: key, Label: label, Icon: categoryIcon(key), Tools: dets}) + } + add("language", "Language", majorLanguages(r.Languages, r.Lines)) + add("package_manager", "Package Manager", r.PackageManagers) + seen := map[string]bool{} + for _, cat := range CategoryOrder { + if dets := r.Tools[cat]; len(dets) > 0 { + label := CategoryLabels[cat] + if label == "" { + label = cat + } + add(cat, label, dets) + seen[cat] = true + } + } + extras := make([]string, 0) + for cat := range r.Tools { + if !seen[cat] { + extras = append(extras, cat) + } + } + sort.Strings(extras) + for _, cat := range extras { + add(cat, cat, r.Tools[cat]) + } + return groups +} + +func splitDeps(deps []brief.DepInfo) (direct, transitive []brief.DepInfo) { + for _, d := range deps { + if d.Direct { + direct = append(direct, d) + } else { + transitive = append(transitive, d) + } + } + return direct, transitive +} + +func resourceLinks(res *brief.ResourceInfo) []resourceLink { + if res == nil { + return nil + } + var out []resourceLink + add := func(label, path, note, icon string) { + if path != "" { + out = append(out, resourceLink{Label: label, Path: path, Note: note, Icon: icon}) + } + } + add("Readme", res.Readme, "", "book-open") + add("Changelog", res.Changelog, "", "history") + add("Roadmap", res.Roadmap, "", "map") + add("License", res.License, res.LicenseType, "scale") + for _, k := range sortedKeys(res.Community) { + add("Community", res.Community[k], k, "users") + } + for _, k := range sortedKeys(res.Security) { + add("Security", res.Security[k], k, "shield") + } + for _, k := range sortedKeys(res.Legal) { + add("Legal", res.Legal[k], k, "gavel") + } + for _, k := range sortedKeys(res.Metadata) { + add("Metadata", res.Metadata[k], k, "file-text") + } + for _, k := range sortedKeys(res.Agents) { + add("Agents", res.Agents[k], k, "bot") + } + return out +} + +func facts(r *brief.Report) []fact { + var out []fact + if r.Resources != nil && r.Resources.LicenseType != "" { + out = append(out, fact{"License", r.Resources.LicenseType}) + } + if r.Style != nil && r.Style.Indentation != "" { + out = append(out, fact{"Indent", r.Style.Indentation}) + } + if r.Style != nil && r.Style.LineEnding != "" { + out = append(out, fact{"Line ending", r.Style.LineEnding}) + } + if r.Layout != nil && len(r.Layout.SourceDirs) > 0 { + out = append(out, fact{"Source", joinDirs(r.Layout.SourceDirs)}) + } + if r.Layout != nil && len(r.Layout.TestDirs) > 0 { + out = append(out, fact{"Tests", joinDirs(r.Layout.TestDirs)}) + } + if r.Platforms != nil && len(r.Platforms.CIMatrixOS) > 0 { + out = append(out, fact{"CI OS", strings.Join(r.Platforms.CIMatrixOS, ", ")}) + } + if r.Git != nil && r.Git.DefaultBranch != "" { + out = append(out, fact{"Default branch", r.Git.DefaultBranch}) + } + return out +} + +//nolint:mnd // small formatting helpers +var htmlFuncs = template.FuncMap{ + "comma": func(n int) string { + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + var b strings.Builder + pre := len(s) % 3 + if pre > 0 { + b.WriteString(s[:pre]) + if len(s) > pre { + b.WriteByte(',') + } + } + for i := pre; i < len(s); i += 3 { + if i > pre { + b.WriteByte(',') + } + b.WriteString(s[i : i+3]) + } + return b.String() + }, + "pct": func(f float64) string { + if f > 0 && f < 0.1 { + return "<0.1" + } + return fmt.Sprintf("%.1f", f) + }, + "short": func(s string) string { + const n = 12 + if len(s) > n { + return s[:n] + } + return s + }, + "safeCSS": func(s string) template.CSS { return template.CSS(s) }, + "dict": func(kv ...any) map[string]any { + m := make(map[string]any, len(kv)/2) + for i := 0; i+1 < len(kv); i += 2 { + if k, ok := kv[i].(string); ok { + m[k] = kv[i+1] + } + } + return m + }, +} + +var htmlTmpl = template.Must(template.New("report").Funcs(htmlFuncs).Parse(htmlPage)) + +const htmlPage = ` + + + + +{{.Title}} · brief + + + + + + + +
+
+

{{.Title}}

+

{{.R.Path}}

+
+ {{if .RepoURL}} + Repository + {{end}} +
+ +
+ {{with .R.Lines}} + {{template "stat" dict "v" (comma .TotalLines) "l" "lines" "i" "file-code"}} + {{template "stat" dict "v" (comma .TotalFiles) "l" "files" "i" "files"}} + {{end}} + {{if .R.Dependencies}} + {{template "stat" dict "v" (printf "%d" (len .Direct)) "l" "direct deps" "i" "package"}} + {{end}} + {{template "stat" dict "v" (printf "%d" .R.Stats.ToolsMatched) "l" "tools" "i" "wrench"}} + {{with .R.Git}}{{if .CommitCount}} + {{template "stat" dict "v" (comma .CommitCount) "l" "commits" "i" "git-commit-horizontal"}} + {{end}}{{end}} + {{with .R.Resources}}{{if .LicenseType}} + {{template "stat" dict "v" .LicenseType "l" "license" "i" "scale"}} + {{end}}{{end}} +
+ +{{if .Langs}} +
+

Languages

+
+ {{range .Langs}}{{end}} +
+ +
+{{end}} + +{{if .ToolGroups}} +
+

Toolchain

+
+ {{range .ToolGroups}} +
+
+

+ {{.Label}} + {{if gt (len .Tools) 1}}{{len .Tools}}{{end}} +

+
+
+ {{range .Tools}} +
+
+ {{if .Homepage}}{{.Name}}{{else}}{{.Name}}{{end}} + {{if .Description}}{{.Description}}{{end}} +
+ {{with .Command}} +
+ {{.Run}} + +
+ {{end}} + {{if or .Docs .Repo .Lockfile .ConfigFiles}} +
+ {{if .Docs}} Docs{{end}} + {{if .Repo}} Source{{end}} + {{if .Lockfile}} {{.Lockfile}}{{end}} + {{if .ConfigFiles}}{{range $i, $f := .ConfigFiles}}{{if $i}} · {{end}}{{$f}}{{end}}{{end}} +
+ {{end}} +
+ {{end}} +
+
+ {{end}} +
+
+{{end}} + +{{if .R.Dependencies}} +
+

Dependencies

+
+
+ +

+ + Packages + {{if .DepSummary}}{{.DepSummary}}{{else}}{{len .R.Dependencies}}{{end}} +

+
+
+
+ +
+ {{template "deptable" .Direct}} +
+ {{if .Transitive}} + + {{end}} +
+
+
+
+
+{{end}} + +{{if or .Resources .R.Scripts}} +
+

Project

+ {{if .Resources}} +
+ {{range .Resources}} + + + {{.Label}}{{if .Note}}{{.Note}}{{end}} + + {{end}} +
+ {{end}} + {{if .R.Scripts}} +
+
+ +

+ + Scripts {{len .R.Scripts}} +

+
+
+
+ + + {{range .R.Scripts}} + + + + + + {{end}} + +
{{.Name}}{{.Run}}{{.Source}}
+
+
+
+
+ {{end}} +
+{{end}} + +{{if .Facts}} +
+

Conventions

+
+
+ {{range .Facts}} +
{{.Label}}
{{.Value}}
+ {{end}} +
+
+
+{{end}} + + + + + + + +{{define "stat"}} +
+ +
+
{{.v}}
+
{{.l}}
+
+
+{{end}} + +{{define "deptable"}} +
+ + + {{range .}} + + + + + {{end}} + +
{{.Name}}{{if and .Scope (ne .Scope "runtime")}}{{.Scope}}{{end}}{{short .Version}}
+
+{{end}} +` diff --git a/report/html_test.go b/report/html_test.go new file mode 100644 index 0000000..c2e7146 --- /dev/null +++ b/report/html_test.go @@ -0,0 +1,188 @@ +package report + +import ( + "bytes" + "strings" + "testing" + + "github.com/git-pkgs/brief" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHTML(t *testing.T) { + r := &brief.Report{ + Version: "test", + Path: "/tmp/proj", + Languages: []brief.Detection{ + {Name: "Go", Homepage: "https://go.dev", Description: "Compiled language"}, + }, + PackageManagers: []brief.Detection{ + {Name: "Go Modules", Command: &brief.Command{Run: "go mod download"}, Lockfile: "go.sum"}, + }, + Tools: map[string][]brief.Detection{ + "test": {{Name: "go test", Command: &brief.Command{Run: "go test ./..."}, Docs: "https://pkg.go.dev/testing"}}, + "lint": {{Name: "golangci-lint", ConfigFiles: []string{".golangci.yml"}}}, + }, + Scripts: []brief.Script{{Name: "build", Run: "go build ./...", Source: "Makefile"}}, + Style: &brief.StyleInfo{Indentation: "tabs", LineEnding: "LF"}, + Layout: &brief.LayoutInfo{SourceDirs: []string{"cmd"}}, + Platforms: &brief.PlatformInfo{ + CIMatrixOS: []string{"ubuntu-latest"}, + }, + Resources: &brief.ResourceInfo{ + Readme: "README.md", + License: "LICENSE", + LicenseType: "MIT", + Community: map[string]string{"contributing": "CONTRIBUTING.md"}, + }, + Git: &brief.GitInfo{ + Branch: "main", + DefaultBranch: "main", + Remotes: map[string]string{"origin": "git@github.com:acme/proj.git"}, + CommitCount: 1234, + }, + Lines: &brief.LineCount{ + TotalFiles: 42, + TotalLines: 12345, + ByLanguage: map[string]int{"Go": 10000, "Markdown": 2345}, + Source: "scc", + }, + Dependencies: []brief.DepInfo{ + {Name: "github.com/a/b", Version: "v1.0.0", Scope: brief.ScopeRuntime, Direct: true}, + {Name: "github.com/c/d", Version: "v2.0.0", Scope: brief.ScopeDevelopment, Direct: true}, + {Name: "github.com/e/f", Version: "v0.1.0", Scope: brief.ScopeRuntime, Direct: false}, + }, + Stats: brief.Stats{DurationMS: 12.3, FilesChecked: 42, ToolsMatched: 4, ToolsChecked: 100}, + } + + var buf bytes.Buffer + require.NoError(t, HTML(&buf, r)) + out := buf.String() + + assert.True(t, strings.HasPrefix(out, "")) + for _, s := range []string{ + "proj · brief", + "https://github.com/acme/proj", + "basecoat-css", + "lucide", + "data-lucide=", + "12,345", + "1,234", + "go test ./...", + "data-copy=", + ".golangci.yml", + "github.com/a/b", + "github.com/e/f", + `role="tablist"`, + "README.md", + "CONTRIBUTING.md", + "MIT", + "tabs", + "ubuntu-latest", + } { + assert.Contains(t, out, s, "missing %q", s) + } +} + +func TestHTMLEmptyReport(t *testing.T) { + r := &brief.Report{Version: "test", Path: ".", Tools: map[string][]brief.Detection{}} + var buf bytes.Buffer + require.NoError(t, HTML(&buf, r)) + assert.Contains(t, buf.String(), "project · brief") +} + +func TestProjectTitle(t *testing.T) { + cases := []struct { + name string + r *brief.Report + want string + }{ + {"ssh remote", &brief.Report{Git: &brief.GitInfo{Remotes: map[string]string{"origin": "git@github.com:acme/widget.git"}}}, "widget"}, + {"https remote", &brief.Report{Git: &brief.GitInfo{Remotes: map[string]string{"origin": "https://gitlab.com/group/sub/thing"}}}, "thing"}, + {"path fallback", &brief.Report{Path: "/home/u/code/myproj"}, "myproj"}, + {"empty", &brief.Report{Path: "."}, "project"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, projectTitle(tc.r)) + }) + } +} + +func TestRepoWebURL(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"git@github.com:acme/proj.git", "https://github.com/acme/proj"}, + {"https://github.com/acme/proj.git", "https://github.com/acme/proj"}, + {"http://example.com/r", "http://example.com/r"}, + {"ssh://weird", ""}, + } + for _, tc := range cases { + got := repoWebURL(&brief.Report{Git: &brief.GitInfo{Remotes: map[string]string{"origin": tc.in}}}) + assert.Equal(t, tc.want, got, "input %q", tc.in) + } + assert.Equal(t, "", repoWebURL(&brief.Report{})) +} + +func TestLanguageSlices(t *testing.T) { + lc := &brief.LineCount{ByLanguage: map[string]int{ + "Go": 800, "Ruby": 150, "License": 50, "Plain Text": 10, + }} + got := languageSlices(lc) + require.Len(t, got, 2) + assert.Equal(t, "Go", got[0].Name) + assert.InDelta(t, 84.2, got[0].Percent, 0.1) + assert.Equal(t, "Ruby", got[1].Name) + + assert.Nil(t, languageSlices(nil)) + assert.Nil(t, languageSlices(&brief.LineCount{ByLanguage: map[string]int{"License": 10}})) +} + +func TestMajorLanguages(t *testing.T) { + langs := []brief.Detection{{Name: "TypeScript"}, {Name: "JavaScript"}, {Name: "Elm"}} + lc := &brief.LineCount{ByLanguage: map[string]int{"TypeScript": 900, "JavaScript": 50, "Markdown": 50}} + + got := majorLanguages(langs, lc) + require.Len(t, got, 2) + assert.Equal(t, "TypeScript", got[0].Name) + assert.Equal(t, "Elm", got[1].Name) // no line entry, kept + + assert.Equal(t, langs, majorLanguages(langs, nil)) + assert.Equal(t, langs, majorLanguages(langs, &brief.LineCount{})) + + tiny := []brief.Detection{{Name: "JavaScript"}} + assert.Equal(t, tiny, majorLanguages(tiny, lc)) // nothing passes, fall back +} + +func TestLanguageSlicesOther(t *testing.T) { + by := map[string]int{} + for i, n := range []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"} { + by[n] = 100 - i + } + got := languageSlices(&brief.LineCount{ByLanguage: by}) + require.Len(t, got, 9) + assert.Equal(t, "Other", got[8].Name) +} + +func TestSplitDeps(t *testing.T) { + d, tr := splitDeps([]brief.DepInfo{ + {Name: "a", Direct: true}, + {Name: "b", Direct: false}, + {Name: "c", Direct: true}, + }) + assert.Len(t, d, 2) + assert.Len(t, tr, 1) +} + +func TestComma(t *testing.T) { + f := htmlFuncs["comma"].(func(int) string) + assert.Equal(t, "0", f(0)) + assert.Equal(t, "12", f(12)) + assert.Equal(t, "999", f(999)) + assert.Equal(t, "1,000", f(1000)) + assert.Equal(t, "12,345", f(12345)) + assert.Equal(t, "1,234,567", f(1234567)) +}