Skip to content

Commit 9f3ebb8

Browse files
committed
Add custom theme support for styles
1 parent 16859d0 commit 9f3ebb8

9 files changed

Lines changed: 445 additions & 17 deletions

File tree

docs/content/docs/App/Styling.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ To use TailwindCSS, in app settings, add
4242

4343
Tailwind CSS works by scanning the HTML files for class names, generating the corresponding styles and then writing them to a static CSS file. A watcher process is started when an app using Tailwind is loaded in dev mode. The output of the watcher is written to `static/gen/css/style.css` file. This file is automatically included as part of the `openrun_gen_import` template.
4444

45-
To ensure that the tailwind watcher is started, the tailwind CLI needs to be installed manually. With the default Tailwind 4/daisyUI 5 config, install the Tailwind CLI and daisyUI npm packages, for example `npm install -D tailwindcss @tailwindcss/cli daisyui`. The [standalone CLI](https://tailwindcss.com/blog/standalone-cli) can also be used when it includes the required plugins.
45+
To ensure that the tailwind watcher is started, the tailwind CLI needs to be installed manually. The [standalone CLI](https://tailwindcss.com/blog/standalone-cli) is the easiest option: no npm or node_modules setup is required. The npm packages (`npm install -D tailwindcss @tailwindcss/cli daisyui`, with `npx tailwindcss` as the command) also work.
4646

4747
The OpenRun server config file has the following entries:
4848

@@ -72,6 +72,40 @@ To use [DaisyUI](https://daisyui.com/), in app settings, add
7272
style=ace.style("daisyui", themes=["dark"])
7373
```
7474

75-
Change to the preferred [theme](https://daisyui.com/docs/themes/). DaisyUI is a good option to use to get great default styling for components, with the full flexibility of Tailwind. OpenRun takes care of creating the config files. With `tailwind_version = 4`, OpenRun writes the daisyUI plugin and theme list into `style/input.css` using the daisyUI 5 `@plugin "daisyui"` syntax. Using the CDN version of DaisyUI or Tailwind is not recommended since that will cause the style files to be large.
75+
Change to the preferred [theme](https://daisyui.com/docs/themes/). DaisyUI is a good option to use to get great default styling for components, with the full flexibility of Tailwind. OpenRun takes care of creating the config files. With `tailwind_version = 4`, OpenRun writes the daisyUI plugin and theme list into `style/input.css` using the daisyUI 5 `@plugin` syntax. Using the CDN version of DaisyUI or Tailwind is not recommended since that will cause the style files to be large.
76+
77+
The standalone tailwind CLI does not bundle daisyUI. Since a `tailwindcss_command` is configured, OpenRun automatically downloads the prebundled daisyUI plugin (a single `daisyui.js` file, no node_modules required) into the app's work directory and references it from the generated `style/input.css`. The download happens once and is cached across server restarts. The download location can be overridden (for example to an internal mirror) with:
78+
79+
```toml {filename="openrun.toml"}
80+
[system]
81+
daisyui_url = "https://internal.example.com/daisyui.js"
82+
```
83+
84+
If the download fails (for example, no network access), OpenRun falls back to the `@plugin "daisyui"` node_modules based reference, which requires `npm install daisyui` such that daisyui is resolvable from the app work directory.
7685

7786
If using [Actions]({{< ref "/docs/actions/" >}}), DaisyUI styles are automatically included. The themes can be customized using the `light` and `dark` property.
87+
88+
### Custom Themes
89+
90+
With `tailwind_version = 4`, fully custom [daisyUI themes](https://daisyui.com/docs/themes/#how-to-add-a-new-custom-theme) can be defined with the `custom_themes` property: a dict of theme name to the theme's CSS properties. Setting `light`/`dark` to a custom theme name makes it the default for that color scheme:
91+
92+
```python {filename="app.star"}
93+
style=ace.style("daisyui",
94+
light="mybrand-light",
95+
dark="mybrand-dark",
96+
custom_themes={
97+
"mybrand-light": {
98+
"color-scheme": "light",
99+
"--color-base-100": "#ffffff",
100+
"--color-primary": "#007700",
101+
# ... other daisyUI theme variables
102+
},
103+
"mybrand-dark": {
104+
"color-scheme": "dark",
105+
"--color-base-100": "#17221a",
106+
"--color-primary": "#00c200",
107+
},
108+
})
109+
```
110+
111+
OpenRun generates a daisyUI theme plugin block per custom theme in `style/input.css` (using the prebundled daisyUI theme plugin, downloaded automatically like the main plugin; `daisyui_theme_url` overrides the download location). Custom themes can be mixed with the bundled theme names in `themes`. When only custom themes are used, the bundled themes are disabled, keeping the generated CSS small.

internal/app/apptype/builtins.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,18 +183,24 @@ func createFragmentBuiltin(_ *starlark.Thread, _ *starlark.Builtin, args starlar
183183
func createStyleBuiltin(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
184184
var library, light, dark starlark.String
185185
var themes *starlark.List
186+
var customThemes *starlark.Dict
186187
var disableWatcher starlark.Bool
187-
if err := starlark.UnpackArgs(FRAGMENT, args, kwargs, "library", &library, "themes?", &themes, "disable_watcher?", &disableWatcher, "light?", &light, "dark?", &dark); err != nil {
188+
if err := starlark.UnpackArgs(FRAGMENT, args, kwargs, "library", &library, "themes?", &themes, "disable_watcher?", &disableWatcher,
189+
"light?", &light, "dark?", &dark, "custom_themes?", &customThemes); err != nil {
188190
return nil, fmt.Errorf("error unpacking style args: %w", err)
189191
}
190192

191193
if themes == nil {
192194
themes = starlark.NewList([]starlark.Value{})
193195
}
196+
if customThemes == nil {
197+
customThemes = starlark.NewDict(0)
198+
}
194199

195200
fields := starlark.StringDict{
196201
"library": library,
197202
"themes": themes,
203+
"custom_themes": customThemes,
198204
"disable_watcher": disableWatcher,
199205
"light": cmp.Or(light, DEFAULT_DAISYUI_LIGHT_THEME),
200206
"dark": cmp.Or(dark, DEFAULT_DAISYUI_DARK_THEME),

internal/app/dev/appdev.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"bytes"
88
"embed"
99
"encoding/json"
10+
"fmt"
1011
"io"
1112
"net/http"
1213
"slices"
@@ -97,6 +98,36 @@ func (a *AppDev) downloadFile(url string, appFS *appfs.WritableSourceFs, path st
9798
return nil
9899
}
99100

101+
// downloadWorkFile downloads the url into the app work directory, unless it was
102+
// already downloaded for this app in the current server session.
103+
func (a *AppDev) downloadWorkFile(url string, path string) error {
104+
if alreadyDone, ok := a.filesDownloaded[url]; ok && slices.Contains(alreadyDone, path) {
105+
a.Trace().Msgf("File %s:%s already downloaded", url, path)
106+
return nil
107+
}
108+
109+
a.Info().Msgf("Downloading %s into %s", url, path)
110+
111+
resp, err := http.Get(url)
112+
if err != nil {
113+
return err
114+
}
115+
defer resp.Body.Close() //nolint:errcheck
116+
if resp.StatusCode != http.StatusOK {
117+
return fmt.Errorf("error downloading %s : status %d", url, resp.StatusCode)
118+
}
119+
120+
var buf bytes.Buffer
121+
if _, err = io.Copy(&buf, resp.Body); err != nil {
122+
return err
123+
}
124+
if err = a.workFS.Write(path, buf.Bytes()); err != nil {
125+
return err
126+
}
127+
a.filesDownloaded[url] = append(a.filesDownloaded[url], path)
128+
return nil
129+
}
130+
100131
// SetupJsLibs sets up the js libraries for the app.
101132
func (a *AppDev) SetupJsLibs() error {
102133
hasHtmx := false

internal/app/dev/styling.go

Lines changed: 171 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package dev
55

66
import (
7+
"cmp"
8+
"crypto/sha256"
79
"fmt"
810
"os"
911
"os/exec"
@@ -22,8 +24,35 @@ import (
2224

2325
const (
2426
STYLE_FILE_PATH = "static/gen/css/style.css"
27+
28+
// The standalone tailwindcss v4 CLI does not bundle daisyui; @plugin "daisyui"
29+
// is resolved through node_modules, walking up from the input.css directory.
30+
// To avoid requiring any node_modules setup, the prebundled daisyui plugin is
31+
// downloaded next to the generated input.css and referenced by relative path.
32+
// The download urls are configured with daisyui_url and daisyui_theme_url in
33+
// the [system] server config.
34+
DAISYUI_MODULE_REF = "daisyui" // node_modules based plugin reference
35+
DAISYUI_THEME_MODULE_REF = "daisyui/theme" // node_modules based theme plugin reference
2536
)
2637

38+
// DaisyUIPluginFile is the work dir file name for the prebundled daisyui
39+
// plugin downloaded from url. The name includes a hash of the url so that a
40+
// url (version) change triggers a fresh download.
41+
func DaisyUIPluginFile(url string) string {
42+
return pluginFileName("daisyui", url)
43+
}
44+
45+
// DaisyUIThemePluginFile is the work dir file name for the prebundled daisyui
46+
// theme plugin (used for custom themes) downloaded from url.
47+
func DaisyUIThemePluginFile(url string) string {
48+
return pluginFileName("daisyui-theme", url)
49+
}
50+
51+
func pluginFileName(prefix, url string) string {
52+
hash := sha256.Sum256([]byte(url))
53+
return fmt.Sprintf("%s-%x.js", prefix, hash[:4])
54+
}
55+
2756
const (
2857
TailwindCSS types.StyleType = "tailwindcss"
2958
DaisyUI types.StyleType = "daisyui"
@@ -35,17 +64,27 @@ const (
3564
// when the App is loaded. It keeps track of the watcher process required to rebuild the
3665
// CSS file when the tailwind/daisy config changes. The reload mutex lock in App is used to
3766
// ensure only one call to the watcher is done at a time, no locking is implemented in AppStyle
67+
// CustomTheme is a daisyui custom theme: the theme name and its CSS
68+
// properties (color-scheme, --color-* etc), in declaration order
69+
type CustomTheme struct {
70+
Name string
71+
Props [][2]string
72+
}
73+
3874
type AppStyle struct {
39-
appId types.AppId
40-
library types.StyleType
41-
themes []string
42-
libraryUrl string
43-
DisableWatcher bool
44-
watcher *exec.Cmd
45-
watcherState *WatcherState
46-
watcherStdout *os.File
47-
Light string
48-
Dark string
75+
appId types.AppId
76+
library types.StyleType
77+
themes []string
78+
customThemes []CustomTheme
79+
libraryUrl string
80+
DisableWatcher bool
81+
watcher *exec.Cmd
82+
watcherState *WatcherState
83+
watcherStdout *os.File
84+
Light string
85+
Dark string
86+
daisyUIPluginRef string // plugin reference to use in the generated input.css
87+
daisyUIThemePluginRef string // theme plugin reference for custom themes
4988
}
5089

5190
// WatcherState is the state of the watcher process as of when it was last started.
@@ -93,6 +132,9 @@ func (s *AppStyle) Init(appId types.AppId, appDef *starlarkstruct.Struct) error
93132
if disableWatcher, err = apptype.GetBoolAttr(styleDef, "disable_watcher"); err != nil {
94133
return err
95134
}
135+
if s.customThemes, err = getCustomThemes(styleDef); err != nil {
136+
return err
137+
}
96138
themes = append(themes, s.Light, s.Dark)
97139
slices.Sort(themes)
98140
themes = slices.Compact(themes)
@@ -122,6 +164,50 @@ func (s *AppStyle) Init(appId types.AppId, appDef *starlarkstruct.Struct) error
122164
return nil
123165
}
124166

167+
// getCustomThemes reads the custom_themes style attribute: a dict of theme
168+
// name to a dict of CSS properties. Declaration order is preserved so the
169+
// generated input.css is stable across reloads.
170+
func getCustomThemes(styleDef *starlarkstruct.Struct) ([]CustomTheme, error) {
171+
customAttr, err := styleDef.Attr("custom_themes")
172+
if err != nil || customAttr == nil {
173+
return nil, nil // custom_themes not defined
174+
}
175+
176+
customDict, ok := customAttr.(*starlark.Dict)
177+
if !ok {
178+
return nil, fmt.Errorf("custom_themes must be a dict of theme name to theme properties")
179+
}
180+
181+
customThemes := make([]CustomTheme, 0, customDict.Len())
182+
for _, item := range customDict.Items() {
183+
name, ok := item[0].(starlark.String)
184+
if !ok || name.GoString() == "" {
185+
return nil, fmt.Errorf("custom_themes keys must be non-empty theme name strings")
186+
}
187+
188+
propsDict, ok := item[1].(*starlark.Dict)
189+
if !ok {
190+
return nil, fmt.Errorf("custom theme %s must be a dict of CSS properties", name.GoString())
191+
}
192+
193+
theme := CustomTheme{Name: name.GoString(), Props: make([][2]string, 0, propsDict.Len())}
194+
for _, prop := range propsDict.Items() {
195+
propName, ok := prop[0].(starlark.String)
196+
if !ok || propName.GoString() == "" {
197+
return nil, fmt.Errorf("custom theme %s property names must be non-empty strings", theme.Name)
198+
}
199+
propValue, ok := prop[1].(starlark.String)
200+
if !ok {
201+
return nil, fmt.Errorf("custom theme %s property %s value must be a string", theme.Name, propName.GoString())
202+
}
203+
theme.Props = append(theme.Props, [2]string{propName.GoString(), propValue.GoString()})
204+
}
205+
customThemes = append(customThemes, theme)
206+
}
207+
208+
return customThemes, nil
209+
}
210+
125211
// Setup sets up the style library for the app. This is called when the app is reloaded.
126212
func (s *AppStyle) Setup(dev *AppDev) error {
127213
switch s.library {
@@ -131,6 +217,20 @@ func (s *AppStyle) Setup(dev *AppDev) error {
131217
case TailwindCSS:
132218
fallthrough
133219
case DaisyUI:
220+
if s.library == DaisyUI {
221+
if tailwindVersion(dev.systemConfig) == types.TailwindVersionLegacy {
222+
if len(s.customThemes) > 0 {
223+
return fmt.Errorf("custom_themes require tailwind_version %d", types.TailwindVersionCurrent)
224+
}
225+
} else {
226+
url := dev.systemConfig.DaisyUIURL
227+
s.daisyUIPluginRef = s.resolvePluginFile(dev, DaisyUIPluginFile(url), url, DAISYUI_MODULE_REF)
228+
if len(s.customThemes) > 0 {
229+
themeUrl := dev.systemConfig.DaisyUIThemeURL
230+
s.daisyUIThemePluginRef = s.resolvePluginFile(dev, DaisyUIThemePluginFile(themeUrl), themeUrl, DAISYUI_THEME_MODULE_REF)
231+
}
232+
}
233+
}
134234
// Generate the tailwind/daisyui config files
135235
return s.setupTailwindConfig(dev.Config.Routing.TemplateLocations, dev.sourceFS, dev.workFS, tailwindVersion(dev.systemConfig))
136236
case Other:
@@ -251,17 +351,49 @@ func (s *AppStyle) legacyDaisyThemes() string {
251351
return fmt.Sprintf(" daisyui: { themes: [%s], },", quotedThemes.String())
252352
}
253353

354+
// resolvePluginFile returns the plugin reference to use in the generated
355+
// input.css. When a tailwind CLI is configured, the prebundled plugin file is
356+
// downloaded into the app work dir, next to input.css (cached across
357+
// restarts), so that the standalone tailwindcss CLI works without requiring
358+
// node_modules. Falls back to the node_modules based reference if the
359+
// download fails.
360+
func (s *AppStyle) resolvePluginFile(dev *AppDev, fileName, url, moduleRef string) string {
361+
if strings.TrimSpace(dev.systemConfig.TailwindCSSCommand) == "" || url == "" {
362+
// No watcher will run (or no download url is configured); keep the
363+
// node_modules based reference for externally run tailwind builds
364+
return moduleRef
365+
}
366+
367+
localRef := "./" + fileName
368+
filePath := path.Join("style", fileName)
369+
if fi, err := dev.workFS.Stat(filePath); err == nil && fi.Size() > 0 {
370+
return localRef
371+
}
372+
373+
if err := dev.downloadWorkFile(url, filePath); err != nil {
374+
dev.Warn().Err(err).Msgf("Error downloading daisyui plugin from %s, falling back to node_modules resolution", url)
375+
return moduleRef
376+
}
377+
return localRef
378+
}
379+
254380
func (s *AppStyle) daisyUIPlugin() string {
255381
if s.library != DaisyUI {
256382
return ""
257383
}
258384

259-
if len(s.themes) == 0 {
260-
return `@plugin "daisyui";`
385+
customNames := map[string]bool{}
386+
for _, theme := range s.customThemes {
387+
customNames[theme.Name] = true
261388
}
262389

390+
// Custom themes are excluded from the bundled themes list, they are
391+
// defined through the theme plugin instead
263392
themes := make([]string, 0, len(s.themes))
264393
for _, theme := range s.themes {
394+
if customNames[theme] {
395+
continue
396+
}
265397
themeConfig := theme
266398
if theme == s.Light {
267399
themeConfig += " --default"
@@ -272,9 +404,34 @@ func (s *AppStyle) daisyUIPlugin() string {
272404
themes = append(themes, themeConfig)
273405
}
274406

275-
return fmt.Sprintf(`@plugin "daisyui" {
407+
pluginRef := cmp.Or(s.daisyUIPluginRef, DAISYUI_MODULE_REF)
408+
var buf strings.Builder
409+
if len(themes) > 0 {
410+
fmt.Fprintf(&buf, `@plugin "%s" {
276411
themes: %s;
277-
}`, strings.Join(themes, ", "))
412+
}`, pluginRef, strings.Join(themes, ", "))
413+
} else if len(s.customThemes) > 0 {
414+
// Only custom themes are used, disable the bundled themes
415+
fmt.Fprintf(&buf, `@plugin "%s" {
416+
themes: false;
417+
}`, pluginRef)
418+
} else {
419+
fmt.Fprintf(&buf, `@plugin "%s";`, pluginRef)
420+
}
421+
422+
themePluginRef := cmp.Or(s.daisyUIThemePluginRef, DAISYUI_THEME_MODULE_REF)
423+
for _, theme := range s.customThemes {
424+
fmt.Fprintf(&buf, "\n\t@plugin \"%s\" {\n", themePluginRef)
425+
fmt.Fprintf(&buf, "\t name: \"%s\";\n", theme.Name)
426+
fmt.Fprintf(&buf, "\t default: %t;\n", theme.Name == s.Light)
427+
fmt.Fprintf(&buf, "\t prefersdark: %t;\n", theme.Name == s.Dark)
428+
for _, prop := range theme.Props {
429+
fmt.Fprintf(&buf, "\t %s: %s;\n", prop[0], prop[1])
430+
}
431+
buf.WriteString("\t}")
432+
}
433+
434+
return buf.String()
278435
}
279436

280437
func sourceDirectives(contentList string) string {

internal/app/tests/app_test_helper.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ func CreateDevModeTestAppTailwindVersion(logger *types.Logger, fileData map[stri
3232
return CreateTestAppIntSystemConfig(logger, "/test", "", fileData, true, nil, nil, nil, "app_dev_testapp", types.AppSettings{}, nil, nil, nil, systemConfig)
3333
}
3434

35+
func CreateDevModeTestAppSystemConfig(logger *types.Logger, fileData map[string]string, systemConfig types.SystemConfig) (*app.App, *appfs.WorkFs, error) {
36+
return CreateTestAppIntSystemConfig(logger, "/test", "", fileData, true, nil, nil, nil, "app_dev_testapp", types.AppSettings{}, nil, nil, nil, systemConfig)
37+
}
38+
3539
func CreateTestApp(logger *types.Logger, fileData map[string]string) (*app.App, *appfs.WorkFs, error) {
3640
return CreateTestAppInt(logger, "/test", "", fileData, false, nil, nil, nil, "app_prd_testapp", types.AppSettings{}, nil, nil, nil)
3741
}

0 commit comments

Comments
 (0)