Packages
A pluggable storybook for your Phoenix components.
Security advisory:
This version has known vulnerabilities.
View advisories
Current section
Files
Jump to
Current section
Files
lib/phoenix_storybook/rendering/code_renderer.ex
defmodule PhoenixStorybook.Rendering.CodeRenderer do
@moduledoc """
Responsible for rendering your components code snippet, for a given
`PhoenixStorybook.Variation`.
Uses the `Makeup` library for syntax highlighting.
"""
import Phoenix.Component
alias Makeup.Formatters.HTML.HTMLFormatter
alias Makeup.Lexers.{ElixirLexer, HEExLexer}
alias Phoenix.HTML
alias PhoenixStorybook.Rendering.{RenderingContext, RenderingVariation}
alias PhoenixStorybook.Stories.Attr
alias PhoenixStorybook.Stories.StorySource
alias PhoenixStorybook.TemplateHelpers
alias PhoenixStorybook.ThemeHelpers
@doc """
Renders a component code snippet from a `RenderingContext`.
Returns a `Phoenix.LiveView.Rendered`.
"""
def render(context)
def render(context = %RenderingContext{type: :component, function: function}) do
render(function, context)
end
def render(context = %RenderingContext{type: :live_component, component: component}) do
render(component, context)
end
def render(fun_or_mod, context = %RenderingContext{}) do
if TemplateHelpers.code_hidden?(context.template) do
render(
fun_or_mod,
%RenderingContext{context | template: TemplateHelpers.default_template()}
)
else
heex =
cond do
TemplateHelpers.variation_template?(context.template) ->
Enum.map_join(context.variations, "\n", fn v = %RenderingVariation{} ->
heex =
component_code_heex(
context.story,
fun_or_mod,
strip_attributes(context, v),
v.let,
v.slots,
context.template
)
context.template
|> TemplateHelpers.set_variation_dom_id(v.dom_id)
|> TemplateHelpers.replace_template_variation(heex, _indent = true)
end)
TemplateHelpers.variation_group_template?(context.template) ->
heex =
Enum.map_join(context.variations, "\n", fn v = %RenderingVariation{} ->
component_code_heex(
context.story,
fun_or_mod,
strip_attributes(context, v),
v.let,
v.slots,
context.template
)
end)
TemplateHelpers.replace_template_variation_group(
context.template,
heex,
_indent = true
)
true ->
context.template
end
assigns =
assign(
%{__changed__: %{}},
heex: format_code(heex, :heex, context.options)
)
if context.options[:format] == false do
~H[{@heex}]
else
~H"""
<pre class={pre_class()}>
<%= @heex %>
</pre>
"""
end
end
end
@doc """
Renders source of a component story.
Returns a rendered HEEx template.
"""
def render_component_source(story) do
render_source = story.render_source()
cond do
!render_source ->
render_source(nil)
story.storybook_type() == :component and story.render_source() == :function ->
story.__module_source__()
|> StorySource.strip_function_source(story.function())
|> render_source()
true ->
render_source(story.__module_source__())
end
end
def render_source(source, assigns \\ %{__changed__: %{}})
def render_source(nil, _assigns), do: nil
def render_source(source, assigns) do
assigns = assign(assigns, source: source)
~H"""
<pre class={[pre_class(), "psb:dark:border-slate-600"]}>
<%= format_code(@source, :elixir) %>
</pre>
"""
end
def render_code_block(code, lang, options \\ []) do
assigns = assign(%{__changed__: %{}}, code: code, lang: lang, options: options)
~H"""
<pre class={pre_class()}>
<%= format_code(@code, @lang, @options) %>
</pre>
"""
end
defp pre_class do
"""
psb highlight psb:p-2 psb:md:p-3 psb:border psb:border-slate-800 psb:text-xs psb:md:text-sm
psb:rounded-md psb:bg-slate-800! psb:whitespace-pre-wrap psb:break-normal
"""
end
defp component_code_heex(story, function, attributes, let, slots, template)
when is_function(function) do
fun = function_name(function)
self_closed? = Enum.empty?(slots)
trim_empty_lines("""
#{"<.#{fun}"}#{maybe_wrap_attributes(story, attributes, let, template)}#{if self_closed?, do: "/>", else: ">"}
#{if slots, do: indent_slots(slots)}
#{unless self_closed?, do: "</.#{fun}>"}
""")
end
defp component_code_heex(story, module, attributes, let, slots, template)
when is_atom(module) do
mod = module_name(module)
self_closed? = Enum.empty?(slots)
trim_empty_lines("""
#{"<.live_component"}#{maybe_wrap_attributes(story, attributes, let, template, mod)}#{if self_closed?, do: "/>", else: ">"}
#{if slots, do: indent_slots(slots)}
#{unless self_closed?, do: "</.live_component>"}
""")
end
@wrap_attributes_threshold 80
defp maybe_wrap_attributes(story, attributes, let, template, module \\ nil) do
attrs =
[
let_markup(let),
template_attributes_markup(template),
attributes_markup(story, attributes)
]
|> then(fn attrs ->
if module, do: ["module={#{module}}" | attrs], else: attrs
end)
|> List.flatten()
|> Enum.reject(&(&1 == ""))
attrs_binary = Enum.join(attrs, " ")
if String.length(attrs_binary) > @wrap_attributes_threshold do
"\n " <> Enum.join(attrs, "\n ") <> "\n"
else
" " <> attrs_binary
end
end
defp let_markup(nil), do: []
defp let_markup(let), do: [":let={#{to_string(let)}}"]
defp attributes_markup(story \\ nil, attributes)
defp attributes_markup(_story, attributes) when map_size(attributes) == 0, do: ""
defp attributes_markup(story, attributes) do
attributes_definitions = if story, do: story.merged_attributes(), else: []
attributes
|> Enum.map(fn
{name, {:eval, val}} ->
~s|#{name}={#{val}}|
{name, val} when is_binary(val) ->
~s|#{name}="#{val}"|
{_name, false} ->
nil
{name, true} ->
name
{name, val} ->
case find_attribute_definition(attributes_definitions, name) do
%Attr{type: :global} -> attributes_markup(val)
_ -> ~s|#{name}={#{inspect_val(val)}}|
end
end)
|> Enum.reject(&is_nil/1)
end
defp find_attribute_definition(attributes_definitions, attr_id) do
Enum.find(attributes_definitions, fn %Attr{id: id} -> id == attr_id end)
end
defp template_attributes_markup(template) do
TemplateHelpers.extract_placeholder_attributes(template)
end
defp inspect_val(struct = %{__struct__: struct_name}) when is_struct(struct) do
full_name = struct_name |> to_string() |> String.replace_leading("Elixir.", "")
aliased_name = full_name |> String.split(".") |> Enum.at(-1)
struct |> inspect() |> String.replace(full_name, aliased_name)
end
defp inspect_val(val), do: inspect(val, structs: false)
defp indent_slots(slots) do
Enum.map_join(slots, "\n", &indent_slot/1)
end
defp indent_slot(slot, indent_size \\ 2) do
indent = Enum.map_join(1..indent_size, fn _ -> " " end)
slot
|> String.split("\n")
|> Enum.reject(&(&1 == ""))
|> Enum.map_join("\n", &(indent <> &1))
end
defp trim_empty_lines(code) do
code |> String.split("\n") |> Enum.reject(&(&1 == "")) |> Enum.join("\n")
end
defp format_code(code, lang, opts \\ []) do
format? = Keyword.get(opts, :format, true)
trim? = Keyword.get(opts, :trim, true)
code = if trim?, do: String.trim(code), else: code
code =
if format? do
case lang do
:elixir -> code |> ElixirLexer.lex() |> HTMLFormatter.format_inner_as_binary([])
:heex -> code |> HEExLexer.lex() |> HTMLFormatter.format_inner_as_binary([])
_ -> code
end
else
code
end
HTML.raw(code)
end
defp function_name(fun), do: Function.info(fun)[:name]
defp module_name(mod), do: mod |> to_string() |> String.split(".") |> Enum.at(-1)
# If :id is a declared attribute, it is important enough to be shown as component markup,
# otherwise, we keep it hidden.
# Theme attributes are also stripped from code when set by the storybook's theme selector.
defp strip_attributes(
%RenderingContext{story: story, group_id: group_id, backend_module: backend_module},
%RenderingVariation{
id: v_id,
attributes: attributes
}
) do
if Enum.any?(story.merged_attributes(), &(&1.id == :id)) do
Map.put(attributes, :id, TemplateHelpers.unique_variation_id(story, {group_id, v_id}))
else
Map.delete(attributes, :id)
end
|> then(fn attributes ->
case ThemeHelpers.theme_strategy(backend_module, :assign) do
nil -> attributes
theme_assign -> Map.delete(attributes, theme_assign)
end
end)
end
end