| 1 | // Package build makes the release: the files a site serves under |
| 2 | // /<version>/ and a static page links from one tag. |
| 3 | // |
| 4 | // Dist is the whole of it, a pure function of the tree it is handed, |
| 5 | // so the same tree at the same version is the same bytes and the same |
| 6 | // SHA256SUMS on anyone's machine. cmd/fiesta writes what Dist returns. |
| 7 | package build |
| 8 | |
| 9 | import ( |
| 10 | "bytes" |
| 11 | "crypto/sha256" |
| 12 | "encoding/hex" |
| 13 | "fmt" |
| 14 | "io/fs" |
| 15 | "sort" |
| 16 | "strings" |
| 17 | ) |
| 18 | |
| 19 | // Banner is the one comment the release keeps, at the top of every |
| 20 | // stylesheet and script: the version, the licence and where to read |
| 21 | // more. Its /*! opener is the form minifiers keep by convention. |
| 22 | func Banner(version string) string { |
| 23 | return "/*! Fiesta " + version + " | MPL-2.0 | https://fiesta.rastrillo.org */\n" |
| 24 | } |
| 25 | |
| 26 | // OneLinkLimit is the most a one-file theme may weigh gzipped: 25 KiB. |
| 27 | // It is what a static page that links fiesta.css alone pays, so it is |
| 28 | // the figure the size gate holds every bundle under. |
| 29 | const OneLinkLimit = 25 * 1024 |
| 30 | |
| 31 | // Gzipped is the length of b compressed at gzip level 6, the level a |
| 32 | // server or edge that compresses on the fly typically uses. |
| 33 | func Gzipped(b []byte) int { return gzipped(b) } |
| 34 | |
| 35 | // defaultTheme is the theme fiesta.css carries; every other theme's |
| 36 | // one-file form is fiesta-<theme>.css. |
| 37 | const defaultTheme = "day" |
| 38 | |
| 39 | // svgNS is the namespace a standalone SVG document declares. The |
| 40 | // inline icons leave it out, since HTML supplies it, and an <img> or |
| 41 | // a CSS url() will not draw an SVG without it. |
| 42 | const svgNS = "http://www.w3.org/2000/svg" |
| 43 | |
| 44 | // bundleName is the one-file theme's file name for a theme. |
| 45 | func bundleName(theme string) string { |
| 46 | if theme == defaultTheme { |
| 47 | return "fiesta.css" |
| 48 | } |
| 49 | return "fiesta-" + theme + ".css" |
| 50 | } |
| 51 | |
| 52 | // bundlesIn is the one-file theme the dist owes for every theme it |
| 53 | // carries: each top-level .css that is neither tokens.css, shell.css |
| 54 | // nor a bundle is a theme's own file, and its bundle is bundleName of |
| 55 | // it. Derived from the dist rather than listed, so a theme added to |
| 56 | // css/themes/ cannot ship without its bundle passing the gate. |
| 57 | func bundlesIn(dist map[string][]byte) []string { |
| 58 | var names []string |
| 59 | for name := range dist { |
| 60 | if strings.Contains(name, "/") || !strings.HasSuffix(name, ".css") || |
| 61 | name == "tokens.css" || name == "shell.css" || name == bundleName(defaultTheme) || strings.HasPrefix(name, "fiesta-") { |
| 62 | continue |
| 63 | } |
| 64 | names = append(names, bundleName(strings.TrimSuffix(name, ".css"))) |
| 65 | } |
| 66 | sort.Strings(names) |
| 67 | return names |
| 68 | } |
| 69 | |
| 70 | // Dist builds the release from src, which needs LICENSE, css/, js/, |
| 71 | // icons/ and i18n/ and is read for nothing else, keyed by path |
| 72 | // relative to the version directory: |
| 73 | // |
| 74 | // - fiesta.css, fiesta-<theme>.css: the banner, then tokens.css and |
| 75 | // the theme joined by a newline, stripped of comments; |
| 76 | // - tokens.css, <theme>.css, shell.css: the banner and the stripped |
| 77 | // file; |
| 78 | // - <script>.js: the banner and the stripped script; |
| 79 | // - i18n/<lang>.json as committed, and i18n/<lang>.js, which adds |
| 80 | // the table to self.fiestaStrings; |
| 81 | // - icons.svg, a sprite of one <symbol id="rst-icon-<slug>"> per |
| 82 | // icon, and icons/<slug>.svg, the inline icon with its xmlns; |
| 83 | // - LICENSE, NOTICE (the icons' Lucide notice), and SHA256SUMS. |
| 84 | func Dist(src fs.FS, version string) (map[string][]byte, error) { |
| 85 | out := map[string][]byte{} |
| 86 | banner := []byte(Banner(version)) |
| 87 | read := func(path string) ([]byte, error) { |
| 88 | b, err := fs.ReadFile(src, path) |
| 89 | if err != nil { |
| 90 | return nil, fmt.Errorf("the tree has no %s: %w", path, err) |
| 91 | } |
| 92 | return b, nil |
| 93 | } |
| 94 | list := func(dir, suffix string) ([]string, error) { |
| 95 | entries, err := fs.ReadDir(src, dir) |
| 96 | if err != nil { |
| 97 | return nil, fmt.Errorf("the tree has no %s/: %w", dir, err) |
| 98 | } |
| 99 | var names []string |
| 100 | for _, e := range entries { |
| 101 | if !e.IsDir() && strings.HasSuffix(e.Name(), suffix) { |
| 102 | names = append(names, strings.TrimSuffix(e.Name(), suffix)) |
| 103 | } |
| 104 | } |
| 105 | if len(names) == 0 { |
| 106 | return nil, fmt.Errorf("the tree's %s/ holds no %s file", dir, suffix) |
| 107 | } |
| 108 | sort.Strings(names) |
| 109 | return names, nil |
| 110 | } |
| 111 | |
| 112 | license, err := read("LICENSE") |
| 113 | if err != nil { |
| 114 | return nil, err |
| 115 | } |
| 116 | out["LICENSE"] = license |
| 117 | notice, err := read("icons/LICENSE-lucide.txt") |
| 118 | if err != nil { |
| 119 | return nil, err |
| 120 | } |
| 121 | out["NOTICE"] = notice |
| 122 | |
| 123 | tokens, err := read("css/tokens.css") |
| 124 | if err != nil { |
| 125 | return nil, err |
| 126 | } |
| 127 | out["tokens.css"] = append(append([]byte{}, banner...), stripCSSComments(tokens)...) |
| 128 | shell, err := read("css/shell.css") |
| 129 | if err != nil { |
| 130 | return nil, err |
| 131 | } |
| 132 | out["shell.css"] = append(append([]byte{}, banner...), stripCSSComments(shell)...) |
| 133 | themes, err := list("css/themes", ".css") |
| 134 | if err != nil { |
| 135 | return nil, err |
| 136 | } |
| 137 | for _, theme := range themes { |
| 138 | css, err := read("css/themes/" + theme + ".css") |
| 139 | if err != nil { |
| 140 | return nil, err |
| 141 | } |
| 142 | out[theme+".css"] = append(append([]byte{}, banner...), stripCSSComments(css)...) |
| 143 | joined := append(append(append([]byte{}, tokens...), '\n'), css...) |
| 144 | out[bundleName(theme)] = append(append([]byte{}, banner...), stripCSSComments(joined)...) |
| 145 | } |
| 146 | |
| 147 | scripts, err := list("js", ".js") |
| 148 | if err != nil { |
| 149 | return nil, err |
| 150 | } |
| 151 | for _, name := range scripts { |
| 152 | js, err := read("js/" + name + ".js") |
| 153 | if err != nil { |
| 154 | return nil, err |
| 155 | } |
| 156 | out[name+".js"] = append(append([]byte{}, banner...), stripJSComments(js)...) |
| 157 | } |
| 158 | |
| 159 | langs, err := list("i18n", ".json") |
| 160 | if err != nil { |
| 161 | return nil, err |
| 162 | } |
| 163 | for _, lang := range langs { |
| 164 | table, err := read("i18n/" + lang + ".json") |
| 165 | if err != nil { |
| 166 | return nil, err |
| 167 | } |
| 168 | out["i18n/"+lang+".json"] = table |
| 169 | out["i18n/"+lang+".js"] = []byte(`(self.fiestaStrings = self.fiestaStrings || {})["` + lang + `"] = ` + string(table) + ";\n") |
| 170 | } |
| 171 | |
| 172 | slugs, err := list("icons", ".svg") |
| 173 | if err != nil { |
| 174 | return nil, err |
| 175 | } |
| 176 | var sprite bytes.Buffer |
| 177 | sprite.WriteString(`<svg xmlns="` + svgNS + `">` + "\n") |
| 178 | for _, slug := range slugs { |
| 179 | svg, err := read("icons/" + slug + ".svg") |
| 180 | if err != nil { |
| 181 | return nil, err |
| 182 | } |
| 183 | children, err := iconChildren(svg) |
| 184 | if err != nil { |
| 185 | return nil, fmt.Errorf("icons/%s.svg: %w", slug, err) |
| 186 | } |
| 187 | sprite.WriteString(`<symbol id="rst-icon-` + slug + `" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">`) |
| 188 | sprite.Write(children) |
| 189 | sprite.WriteString("</symbol>\n") |
| 190 | out["icons/"+slug+".svg"] = bytes.Replace(svg, []byte("<svg"), []byte(`<svg xmlns="`+svgNS+`"`), 1) |
| 191 | } |
| 192 | sprite.WriteString("</svg>\n") |
| 193 | out["icons.svg"] = sprite.Bytes() |
| 194 | |
| 195 | names := make([]string, 0, len(out)) |
| 196 | for name := range out { |
| 197 | names = append(names, name) |
| 198 | } |
| 199 | sort.Strings(names) |
| 200 | var sums bytes.Buffer |
| 201 | for _, name := range names { |
| 202 | sum := sha256.Sum256(out[name]) |
| 203 | sums.WriteString(hex.EncodeToString(sum[:]) + " " + name + "\n") |
| 204 | } |
| 205 | out["SHA256SUMS"] = sums.Bytes() |
| 206 | return out, nil |
| 207 | } |
| 208 | |
| 209 | // iconChildren is what sits between an inline icon's <svg> root tags: |
| 210 | // the paths the sprite's symbol wraps. |
| 211 | func iconChildren(svg []byte) ([]byte, error) { |
| 212 | s := bytes.TrimSpace(svg) |
| 213 | open := bytes.IndexByte(s, '>') |
| 214 | if !bytes.HasPrefix(s, []byte("<svg")) || open < 0 || !bytes.HasSuffix(s, []byte("</svg>")) { |
| 215 | return nil, fmt.Errorf("not an <svg> element") |
| 216 | } |
| 217 | return bytes.TrimSpace(s[open+1 : len(s)-len("</svg>")]), nil |
| 218 | } |
| 219 | |
| 220 | // CheckSize is the size gate: every theme the dist carries has its |
| 221 | // one-file bundle, and each gzips to under OneLinkLimit, or the error |
| 222 | // names the first that does not and gives both figures, in bytes and |
| 223 | // in KiB. A bundle the dist does not hold fails it too, and so does a |
| 224 | // dist with no theme at all; a gate that passed on absence would be |
| 225 | // no gate. |
| 226 | func CheckSize(dist map[string][]byte) error { |
| 227 | bundles := bundlesIn(dist) |
| 228 | if len(bundles) == 0 { |
| 229 | return fmt.Errorf("the dist carries no theme, so there is no one-file theme to hold to the limit") |
| 230 | } |
| 231 | for _, name := range bundles { |
| 232 | b, ok := dist[name] |
| 233 | if !ok { |
| 234 | return fmt.Errorf("%s is not in the dist", name) |
| 235 | } |
| 236 | if g := Gzipped(b); g >= OneLinkLimit { |
| 237 | return fmt.Errorf("%s is %d bytes gzipped (%.1f KiB); the one-link limit is %d (%.1f KiB)", name, g, float64(g)/1024, OneLinkLimit, float64(OneLinkLimit)/1024) |
| 238 | } |
| 239 | } |
| 240 | return nil |
| 241 | } |
| 242 | |