rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

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.
7package build
8
9import (
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.
22func 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.
29const 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.
33func 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.
37const 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.
42const svgNS = "http://www.w3.org/2000/svg"
43
44// bundleName is the one-file theme's file name for a theme.
45func 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.
57func 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.
84func 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.
211func 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.
226func 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