Packages
Generates type-safe Phoenix.Component modules from DaisyUI documentation. Parses DaisyUI docs, detects variants, and creates production-ready components.
Current section
Files
Jump to
Current section
Files
lib/oopsie_daisy.ex
defmodule OopsieDaisy do
@moduledoc """
Generates Phoenix.Component modules from DaisyUI documentation.
## Quick Start
# Generate all components (auto-detects your app name)
{:ok, results} = OopsieDaisy.generate()
# Generate specific components
{:ok, results} = OopsieDaisy.generate(components: ["button", "badge"])
Most Phoenix developers will use the mix task instead: `mix oopsie_daisy.gen`
## What It Does
OopsieDaisy:
1. Clones the DaisyUI repository
2. Parses component documentation (markdown files)
3. Extracts HTML examples
4. Analyzes CSS classes to detect variants (colors, sizes, styles)
5. Generates type-safe Phoenix.Component modules
## Generated Components
Every component includes:
- Type-safe `attr` declarations with allowed values
- Helper functions for class composition
- Support for custom classes and HTML attributes
- Proper Phoenix.Component patterns
## Options
See `generate/1` for all available options.
"""
alias OopsieDaisy.{Cloner, Parser}
alias OopsieDaisy.Generator.{Analyzer, Template}
@doc """
Generates Phoenix.Component modules from DaisyUI documentation.
## Options
* `:components` - List of component names (default: all). Example: `["button", "badge"]`
* `:output_dir` - Where to write files (default: `"lib/oopsie_daisy_components"`)
* `:base_module` - Module namespace (default: auto-detected from your app name)
* `:base_dir` - Where DaisyUI is cloned (default: current directory)
## Auto-Detection
If you don't provide `:base_module`, it's detected from your Phoenix app name:
* Phoenix app `my_app` → `MyAppWeb.Components.*`
* Non-Phoenix app → `MyApp.Components.*`
* Falls back to `OopsieDaisy.Components`
## Examples
# Generate all components
{:ok, results} = OopsieDaisy.generate()
# Generate specific components
{:ok, results} = OopsieDaisy.generate(components: ["button", "badge"])
# Custom module namespace
{:ok, results} = OopsieDaisy.generate(base_module: "MyApp.UI")
# Custom output directory
{:ok, results} = OopsieDaisy.generate(output_dir: "lib/my_app_web/components")
## Returns
`{:ok, results}` where `results` is a list of `{:ok, file_path}` or `{:error, reason}` tuples.
"""
def generate(opts \\ []) do
with {:ok, _path} <- ensure_daisyui(opts),
{:ok, path_groups} <- load_components(opts),
{:ok, results} <- generate_components(path_groups, opts) do
{:ok, results}
end
end
defp ensure_daisyui(opts) do
base_dir = Keyword.get(opts, :base_dir, File.cwd!())
Cloner.ensure_available(base_dir: base_dir)
end
defp load_components(opts) do
base_dir = Keyword.get(opts, :base_dir, File.cwd!())
component_filter = Keyword.get(opts, :components)
components_dir =
Path.join([
base_dir,
"tmp",
"daisyui",
"packages",
"docs",
"src",
"routes",
"(routes)",
"components"
])
unless File.dir?(components_dir) do
{:error, "Components directory not found: #{components_dir}"}
else
markdown_files =
Path.join(components_dir, "**/*.md")
|> Path.wildcard()
|> Enum.sort()
path_groups =
markdown_files
|> Enum.map(&extract_from_file/1)
|> Enum.reject(&is_nil/1)
|> filter_components(component_filter)
{:ok, path_groups}
end
end
defp extract_from_file(file_path) do
content = File.read!(file_path)
lines = String.split(content, "\n")
examples = Parser.parse_file_lines(lines, file_path)
if Enum.empty?(examples) do
nil
else
title_groups =
examples
|> Enum.map(fn example ->
%Parser.TitleGroup{
title: example.title,
elements: example.elements
}
end)
%Parser.PathGroup{
path: file_path,
title_groups: title_groups
}
end
end
defp filter_components(path_groups, nil), do: path_groups
defp filter_components(path_groups, component_names) do
path_groups
|> Enum.filter(fn path_group ->
component_name =
path_group.path
|> Path.dirname()
|> Path.basename()
|> String.downcase()
component_name in component_names
end)
end
defp generate_components(path_groups, opts) do
output_dir = Keyword.get(opts, :output_dir, "lib/oopsie_daisy_components")
base_module = Keyword.get(opts, :base_module) || detect_base_module()
File.mkdir_p!(output_dir)
results =
path_groups
|> Enum.map(&Analyzer.analyze_path_group(&1, base_module: base_module))
|> Enum.map(&write_component(&1, output_dir))
{:ok, results}
end
defp write_component(spec, output_dir) do
code = Template.render_module(spec)
file_path = Path.join(output_dir, "#{spec.file_name}.ex")
case File.write(file_path, code) do
:ok -> {:ok, file_path}
{:error, reason} -> {:error, reason}
end
end
@doc """
Detects the base module namespace from your app's name.
Used automatically by `generate/1` when `:base_module` is not provided.
## How It Works
1. Gets app name from `Mix.Project.config()`
2. Converts to PascalCase (e.g., `my_app` → `MyApp`)
3. Checks if `MyAppWeb` module exists (indicates Phoenix app)
4. Returns appropriate namespace
## Examples
# In Phoenix app "my_app"
OopsieDaisy.detect_base_module()
#=> "MyAppWeb.Components"
# In non-Phoenix app "my_lib"
OopsieDaisy.detect_base_module()
#=> "MyLib.Components"
Returns `"OopsieDaisy.Components"` if detection fails.
"""
def detect_base_module do
try do
config = Mix.Project.config()
app_name = config[:app]
if app_name do
# Convert app name to PascalCase module name
base_module_name =
app_name
|> to_string()
|> String.split("_")
|> Enum.map(&String.capitalize/1)
|> Enum.join()
cond do
# Check if AppWeb module exists (indicates Phoenix app)
Code.ensure_loaded?(Module.concat([base_module_name <> "Web"])) ->
"#{base_module_name}Web.Components"
# Fall back to App.Components
true ->
"#{base_module_name}.Components"
end
else
"OopsieDaisy.Components"
end
rescue
_ -> "OopsieDaisy.Components"
end
end
end