From d980c8532918a1908b47d2a5571432520e425102 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 30 Mar 2025 13:58:58 +0200 Subject: refactor: port to Go --- .gitignore | 2 +- README.md | 9 ++ build.go | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++++ build.py | 168 ------------------------------------- fetch_and_build.sh | 2 +- go.mod | 10 +++ go.sum | 17 ++++ head.html | 26 ------ script.js | 18 ++-- style.css | 6 +- template.html.tmpl | 53 ++++++++++++ 11 files changed, 346 insertions(+), 206 deletions(-) create mode 100644 build.go delete mode 100755 build.py create mode 100644 go.mod create mode 100644 go.sum delete mode 100644 head.html create mode 100644 template.html.tmpl diff --git a/.gitignore b/.gitignore index 23f3028..55b34c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ caniuse.rs/ rust/ -target/ +out/ lib_feats.txt diff --git a/README.md b/README.md index e69de29..64548ff 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,9 @@ +# rust-features + +An autogenerated web page listing Rust features. + +Hosted under https://rust-features.push-f.com/. + +# Local development + + go run build.go diff --git a/build.go b/build.go new file mode 100644 index 0000000..9e91800 --- /dev/null +++ b/build.go @@ -0,0 +1,241 @@ +package main + +import ( + _ "embed" + "encoding/json" + "fmt" + "html/template" + "maps" + "os" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/BurntSushi/toml" + log "github.com/sirupsen/logrus" +) + +var codeRegex = regexp.MustCompile("`(.+?)`") + +//go:embed template.html.tmpl +var templateText string + +//go:embed script.js +var script []byte + +//go:embed style.css +var style []byte + +func main() { + caniuseRepo := "caniuse.rs" + outDir := "out" + + libFeaturesText, err := os.ReadFile("lib_feats.txt") + if err != nil { + log.Fatalf("error reading lib_feats.txt: %s", err) + } + libFeatureFlags := strings.Split(string(libFeaturesText), "\n") + + versionInfos := make(map[string]VersionInfo) + versionsPath := fmt.Sprintf(caniuseRepo + "/data/versions.toml") + _, err = toml.DecodeFile(versionsPath, &versionInfos) + if err != nil { + log.Fatalf("error parsing %s: %s", versionsPath, err) + } + + if len(versionInfos) == 0 { + log.Fatal("found no versions in versions.toml") + } + + versions := make([]Version, 0) + sortedVersions := slices.SortedFunc(maps.Keys(versionInfos), compareVersion) + slices.Reverse(sortedVersions) + + for _, name := range append([]string{"unstable"}, sortedVersions...) { + features := getFeatures(caniuseRepo, name) + if len(features) == 0 { + log.Infof("no features found for %s", name) + } + libFeatures := make([]Feature, 0) + nonLibFeatures := make([]Feature, 0) + for _, feature := range features { + if slices.Contains(libFeatureFlags, feature.Flag) || strings.Contains(feature.Title, " impl for ") { + libFeatures = append(libFeatures, feature) + } else { + nonLibFeatures = append(nonLibFeatures, feature) + } + } + + version := Version{ + Name: name, + LibFeatures: nilAsEmptyArray(slices.SortedFunc(slices.Values(libFeatures), compareFeature)), + NonLibFeatures: nilAsEmptyArray(slices.SortedFunc(slices.Values(nonLibFeatures), compareFeature)), + } + versionInfo := versionInfos[name] + if versionInfo.BlogPostPath != nil { + url := fmt.Sprintf("https://blog.rust-lang.org/%s", *versionInfo.BlogPostPath) + version.BlogPostURL = &url + } + + versions = append(versions, version) + } + + tmpl := template.New("template.html.tmpl").Funcs(template.FuncMap{ + "formatBackticks": func(s string) template.HTML { + return template.HTML(codeRegex.ReplaceAllStringFunc(s, func(code string) string { + return fmt.Sprintf( + "%s", + template.HTMLEscapeString(codeRegex.FindStringSubmatch(code)[1]), + ) + })) + }, + }) + tmpl, err = tmpl.Parse(templateText) + + if err != nil { + log.Fatalf("error parsing template: %s", err) + } + + err = os.MkdirAll(outDir, 0o755) + if err != nil { + log.Fatalf("error creating directory: %s", err) + } + + outputFile, err := os.Create(outDir + "/index.html") + if err != nil { + log.Fatalf("error creating index.html: %s", err) + } + defer outputFile.Close() + + err = tmpl.Execute(outputFile, + map[string]any{ + "Versions": versions, + }, + ) + if err != nil { + log.Fatalf("error executing template: %s", err) + } + + outputFile, err = os.Create(outDir + "/data.json") + if err != nil { + log.Fatalf("error creating data.json: %s", err) + } + defer outputFile.Close() + + encoder := json.NewEncoder(outputFile) + err = encoder.Encode(versions) + if err != nil { + log.Fatalf("error encoding JSON: %s", err) + } + + err = os.WriteFile(outDir+"/script.js", script, 0o644) + if err != nil { + log.Fatalf("error creating script.js: %s", err) + } + + err = os.WriteFile(outDir+"/style.css", style, 0o644) + if err != nil { + log.Fatalf("error creating style.css: %s", err) + } +} + +type Version struct { + Name string `json:"name"` + BlogPostURL *string `json:"-"` + LibFeatures []Feature `json:"lib"` + NonLibFeatures []Feature `json:"non_lib"` +} + +// Version info from "caniuse.rs/data/versions.toml". +type VersionInfo struct { + BlogPostPath *string `toml:"blog_post_path"` +} + +// Data from a .toml file in "caniuse.rs/data/{version}/". +type Feature struct { + Title string `toml:"title" json:"title"` + Flag string `toml:"flag" json:"flag"` + TrackingIssueId *uint `toml:"tracking_issue_id" json:"-"` + ImplPrId *uint `toml:"impl_pr_id" json:"-"` + StabilizationPrId *uint `toml:"stabilization_pr_id" json:"-"` + Aliases []string `toml:"aliases" json:"aliases"` + Items []string `toml:"items" json:"items"` + URL string `json:"url"` +} + +func getFeatures(caniuseRepo string, version string) map[string]Feature { + dirPath := caniuseRepo + "/data/" + version + entries, err := os.ReadDir(dirPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + log.Fatal(err) + } + + features := make(map[string]Feature) + + for _, entry := range entries { + var feature Feature + _, err := toml.DecodeFile(dirPath+"/"+entry.Name(), &feature) + if err != nil { + log.Fatalf("Error parsing %s: %s\n", dirPath+entry.Name(), err) + } + if feature.TrackingIssueId != nil { + feature.URL = fmt.Sprintf("https://github.com/rust-lang/rust/issues/%d", *feature.TrackingIssueId) + } else if feature.ImplPrId != nil { + feature.URL = fmt.Sprintf("https://github.com/rust-lang/rust/pull/%d", *feature.ImplPrId) + } else if feature.StabilizationPrId != nil { + feature.URL = fmt.Sprintf("https://github.com/rust-lang/rust/pull/%d", *feature.StabilizationPrId) + } + feature.Title = strings.TrimPrefix(feature.Title, "the ") + feature.Title = strings.Replace(feature.Title, "implementation", "impl", 1) + var key string + if feature.Flag == "" { + key = entry.Name() + } else { + key = feature.Flag + } + _, exists := features[key] + if exists { + // caniuse.rs sometimes intentionally has several .toml files + // for the same feature flag (with different titles). + // In this case we only want to display the feature once. + feature.Title = feature.Flag + } + features[key] = feature + } + return features +} + +func compareFeature(a, b Feature) int { + aTitle := strings.ToLower(strings.TrimLeft(a.Title, "`")) + bTitle := strings.ToLower(strings.TrimLeft(b.Title, "`")) + if aTitle > bTitle { + return 1 + } else if aTitle < bTitle { + return -1 + } + return 0 +} + +func compareVersion(a, b string) int { + aParts := strings.Split(a, ".") + bParts := strings.Split(b, ".") + + aMajor, _ := strconv.Atoi(aParts[0]) + bMajor, _ := strconv.Atoi(bParts[0]) + if aMajor != bMajor { + return aMajor - bMajor + } + + aMinor, _ := strconv.Atoi(aParts[1]) + bMinor, _ := strconv.Atoi(bParts[1]) + return aMinor - bMinor +} + +// workaround for https://github.com/golang/go/issues/37711 +func nilAsEmptyArray[T any](slice []T) []T { + return append([]T{}, slice...) +} diff --git a/build.py b/build.py deleted file mode 100755 index d0f7fba..0000000 --- a/build.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python3 -import html -import json -import os -import re -import shutil -import tomllib - - -def get_features(dirname): - feature_data = {} - for feature in sorted(os.listdir('caniuse.rs/data/' + dirname)): - with open('caniuse.rs/data/' + dirname + '/' + feature, 'rb') as f: - name = feature.split('.')[0] - feature_data[name] = tomllib.load(f) - - # new dict because we want to deduplicate features - features = {} - - # caniuse.rs sometimes has several .toml files for one feature flag e.g. - # For the const_io feature flag it has 6 .toml files, all with the same - # tracking_issue_id but different titles. - # - # That makes sense for a search-centric application. Not so much for a - # static-site generator since showing the same link 6 times is confusing. - for feat, data in feature_data.items(): - key = data['flag'] if 'flag' in data else feat - - url = None - if 'tracking_issue_id' in data: - url = 'https://github.com/rust-lang/rust/issues/{}'.format( - data['tracking_issue_id'] - ) - elif 'impl_pr_id' in data: - url = 'https://github.com/rust-lang/rust/pull/{}'.format(data['impl_pr_id']) - elif 'stabilization_pr_id' in data: - url = 'https://github.com/rust-lang/rust/pull/{}'.format( - data['stabilization_pr_id'] - ) - data['url'] = url - data['filename'] = feat - - if data['title'].startswith('the '): - data['title'] = data['title'][len('the ') :] - data['title'] = data['title'].replace('implementation', 'impl') - - if key in features: - data['title'] = data['flag'].replace('_', ' ') - - if key in features and features[key]['url'] != url: - print( - 'different urls for feature {}:\n* {}: {}\n* {}: {}'.format( - key, - data['filename'], - data['url'], - features[key]['filename'], - features[key]['url'], - ) - ) - - features[key] = data - - features = dict( - sorted(features.items(), key=lambda t: t[1]['title'].replace('`', '').lower()) - ) - - lib_features = {} - non_lib_features = {} - - for key, data in features.items(): - if ('flag' in data and data['flag'] in library_flags) or 'impl for' in data[ - 'title' - ]: - lib_features[key] = data - else: - non_lib_features[key] = data - - return dict(lib_features=lib_features, non_lib_features=non_lib_features) - - -with open('lib_feats.txt') as f: - library_flags = set([l.strip() for l in f]) - -with open('caniuse.rs/data/versions.toml', 'rb') as f: - versions = tomllib.load(f) - -for version, data in versions.items(): - try: - data['features'] = get_features(version) - except FileNotFoundError: - pass - -versions = dict(reversed(list(versions.items()))) - -unstable_features = get_features('unstable') - - -os.makedirs('target', exist_ok=True) -shutil.copy('style.css', 'target') -shutil.copy('script.js', 'target') - -with open('target/data.json', 'w') as f: - data = dict(unstable=dict(features=unstable_features), **versions) - json.dump(data, f) - - -def write_features(f, id, features): - if features['non_lib_features']: - write_feature_list(f, id + '-non-lib', features['non_lib_features']) - if features['lib_features']: - f.write('

library

'.format(id + '-lib')) - write_feature_list(f, id + '-lib', features['lib_features']) - - -def write_feature_list(f, id, features): - f.write('') - - -with open('target/index.html', 'w') as f: - with open('head.html') as h: - f.write(h.read()) - - f.write('

Unstable features

') - write_features(f, 'unstable', unstable_features) - - after_beta = False - - for version, data in versions.items(): - f.write('
'.format(version)) - f.write('

{}'.format(version)) - channel = data.get('channel') - if after_beta: - channel = 'stable' - after_beta = False - if channel: - if channel == 'nightly': - channel = 'stabilized' - elif channel == 'beta': - after_beta = True - f.write(' [{}]'.format(channel)) - f.write('

') - - if 'blog_post_path' in data: - f.write( - '{}'.format( - data['blog_post_path'], 'release notes' - ) - ) - f.write('
') - - if 'features' in data: - write_features(f, version, data['features']) diff --git a/fetch_and_build.sh b/fetch_and_build.sh index 760b03d..ba1c91c 100755 --- a/fetch_and_build.sh +++ b/fetch_and_build.sh @@ -2,4 +2,4 @@ (cd rust; git checkout master; git pull --ff-only) ./find.sh | grep -oP '(?<=feature = ")([^"]+)' > lib_feats.txt rm -r build/ -./build.py +go run build.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..37a793b --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module push-f.com/rust-features + +go 1.23.5 + +require ( + github.com/BurntSushi/toml v1.5.0 + github.com/sirupsen/logrus v1.9.3 +) + +require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4f5a5ea --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/head.html b/head.html deleted file mode 100644 index 2caa40b..0000000 --- a/head.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - -Rust features - - - - - -
-

Rust features

- - - -
-
-
- -page generated by push-f - from the caniuse.rs dataset diff --git a/script.js b/script.js index a631288..0e2491e 100644 --- a/script.js +++ b/script.js @@ -6,32 +6,32 @@ input.focus(); input.addEventListener('input', (e) => { const query = e.target.value.toLowerCase(); - for (const [key, data] of Object.entries(versions)) { - const heading = document.getElementById(key); + for (const version of versions) { + const heading = document.getElementById(version.name); - if (data.features == undefined) { + if (version.lib.length == 0 && version.non_lib.length == 0) { heading.hidden = query.length != 0; continue; } - const nonLibResults = search(data.features.non_lib_features, query); - const libResults = search(data.features.lib_features, query); + const nonLibResults = search(version.non_lib, query); + const libResults = search(version.lib, query); const totalResultCount = nonLibResults.length + libResults.length; // so that release notes don't get in the way when ing through results document.body.classList.toggle('hide-release-notes', totalResultCount == 0); - let list = document.getElementById(key + '-non-lib-list'); + let list = document.getElementById(version.name + '-non-lib-list'); if (list) { list.replaceChildren(...renderList(nonLibResults).children); list.hidden = nonLibResults.length == 0; } - list = document.getElementById(key + '-lib-list'); + list = document.getElementById(version.name + '-lib-list'); if (list) { list.replaceChildren(...renderList(libResults).children); list.hidden = libResults.length == 0; - document.getElementById(key + '-lib').hidden = libResults.length == 0; + document.getElementById(version.name + '-lib-heading').hidden = libResults.length == 0; } heading.hidden = totalResultCount == 0; @@ -40,7 +40,7 @@ })(); function search(features, query) { - return Object.values(features).filter((feat) => { + return features.filter((feat) => { if (feat.title.toLowerCase().replaceAll('`', '').includes(query)) { return true; } diff --git a/style.css b/style.css index 8c8559f..6ad3fd2 100644 --- a/style.css +++ b/style.css @@ -2,7 +2,11 @@ body { font-family: sans; } -.columns { +a { + text-decoration: none; +} + +ul { columns: 300px; } diff --git a/template.html.tmpl b/template.html.tmpl new file mode 100644 index 0000000..d993725 --- /dev/null +++ b/template.html.tmpl @@ -0,0 +1,53 @@ + + + + +Rust features + + + + + +
+

Rust features

+ + + +
+
+
+ +page generated by push-f + from the caniuse.rs dataset + +{{range .Versions}} +
+

{{.Name}}

+ {{with .BlogPostURL}} + release notes + {{end}} +
+ {{if .NonLibFeatures}} + + {{end}} + {{if .LibFeatures}} +

library

+ + {{end}} +{{end}} +{{define "feature" -}} + {{.Title | formatBackticks}} +{{- end}} -- cgit v1.2.3