diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | README.md | 9 | ||||
-rw-r--r-- | build.go | 241 | ||||
-rwxr-xr-x | build.py | 168 | ||||
-rwxr-xr-x | fetch_and_build.sh | 2 | ||||
-rw-r--r-- | go.mod | 10 | ||||
-rw-r--r-- | go.sum | 17 | ||||
-rw-r--r-- | head.html | 26 | ||||
-rw-r--r-- | script.js | 18 | ||||
-rw-r--r-- | style.css | 6 | ||||
-rw-r--r-- | template.html.tmpl | 53 |
11 files changed, 346 insertions, 206 deletions
@@ -1,4 +1,4 @@ caniuse.rs/ rust/ -target/ +out/ lib_feats.txt @@ -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( + "<code>%s</code>", + 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('<h3 id="{}">library</h3>'.format(id + '-lib')) - write_feature_list(f, id + '-lib', features['lib_features']) - - -def write_feature_list(f, id, features): - f.write('<ul id={}-list'.format(id)) - if len(features) > 5: - f.write(' class=columns') - f.write('>') - for feat, data in features.items(): - f.write('<li><a') - if 'flag' in data: - f.write(' title="{}"'.format(data['flag'])) - url = data['url'] - if url: - f.write(f' href="{url}"') - f.write('>') - title = html.escape(data['title']) - title = re.sub('`(.+?)`', lambda m: '<code>{}</code>'.format(m.group(1)), title) - f.write(title) - f.write('</a></li>') - f.write('</ul>') - - -with open('target/index.html', 'w') as f: - with open('head.html') as h: - f.write(h.read()) - - f.write('<h2 id=unstable>Unstable features</h2>') - write_features(f, 'unstable', unstable_features) - - after_beta = False - - for version, data in versions.items(): - f.write('<div id={0} class=release>'.format(version)) - f.write('<h2>{}'.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('</h2>') - - if 'blog_post_path' in data: - f.write( - '<a class=release-notes href="https://blog.rust-lang.org/{}">{}</a>'.format( - data['blog_post_path'], 'release notes' - ) - ) - f.write('</div>') - - 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 @@ -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 @@ -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 @@ -<!doctype html> -<html> -<head> -<meta charset=utf-8> -<title>Rust features</title> -<link rel="stylesheet" href="style.css" /> -<meta name="viewport" content="width=device-width, initial-scale=1"> -</head> -<body> - -<div class=header> -<div class=left><h1>Rust features</h1></div> -<input id=search autocomplete=off tabindex=4> -<noscript> -With JavaScript you get a search box here. -<style> -#search { display: none; } -</style> -</noscript> -<script src=script.js></script> -<div class=right></div> -</div> -<div style="clear: both"></div> - -page generated by <a tabindex=1 target=_blank href="https://push-f.com/">push-f</a> - from the <a tabindex=2 target=_blank href="https://github.com/jplatte/caniuse.rs">caniuse.rs dataset</a> @@ -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 <Tab>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; } @@ -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 @@ +<!doctype html> +<html> +<head> +<meta charset=utf-8> +<title>Rust features</title> +<link rel="stylesheet" href="style.css" /> +<meta name="viewport" content="width=device-width, initial-scale=1"> +</head> +<body> + +<div class=header> +<div class=left><h1>Rust features</h1></div> +<input id=search autocomplete=off tabindex=4> +<noscript> +With JavaScript you get a search box here. +<style> +#search { display: none; } +</style> +</noscript> +<script src=script.js></script> +<div class=right></div> +</div> +<div style="clear: both"></div> + +page generated by <a tabindex=1 target=_blank href="https://push-f.com/">push-f</a> + from the <a tabindex=2 target=_blank href="https://github.com/jplatte/caniuse.rs">caniuse.rs dataset</a> + +{{range .Versions}} + <div class=release> + <h2 id="{{.Name}}">{{.Name}}</h2> + {{with .BlogPostURL}} + <a href="{{.}}" class=release-notes>release notes</a> + {{end}} + </div> + {{if .NonLibFeatures}} + <ul id="{{.Name}}-non-lib-list"> + {{- range .NonLibFeatures}} + <li>{{template "feature" .}}</li> + {{- end}} + </ul> + {{end}} + {{if .LibFeatures}} + <h3 id="{{.Name}}-lib-heading">library</h3> + <ul id="{{.Name}}-lib-list"> + {{- range .LibFeatures}} + <li>{{template "feature" .}}</li> + {{- end}} + </ul> + {{end}} +{{end}} +{{define "feature" -}} + <a {{with .URL}}href="{{.}}"{{end}} {{with .Flag}}title="{{.}}"{{end}}>{{.Title | formatBackticks}}</a> +{{- end}} |