defmodule Dialup.Router do @moduledoc false defmacro __using__(opts) do app_dir = Keyword.fetch!(opts, :app_dir) quote bind_quoted: [app_dir: app_dir] do @_dialup_app_dir app_dir @page_files Path.wildcard(Path.join(@_dialup_app_dir, "**/page.ex")) @layout_files Path.wildcard(Path.join(@_dialup_app_dir, "**/layout.ex")) @error_files Path.wildcard(Path.join(@_dialup_app_dir, "**/error.ex")) @external_resource @_dialup_app_dir for file <- @page_files ++ @layout_files ++ @error_files do @external_resource file end # app/layout.ex → {".", Dialup.App.Layout} # app/user/layout.ex → {"user", Dialup.App.User.Layout} @layout_map Map.new(@layout_files, fn file -> relative = Path.relative_to(file, @_dialup_app_dir) dir = Path.dirname(relative) module = dir |> String.split("/") |> Enum.reject(&(&1 == ".")) |> Enum.map(&Macro.camelize/1) |> then(fn parts -> parts ++ ["Layout"] end) |> then(fn parts -> Module.concat(["Dialup", "App"] ++ parts) end) {dir, module} end) @error_map Map.new(@error_files, fn file -> relative = Path.relative_to(file, @_dialup_app_dir) dir = Path.dirname(relative) module = dir |> String.split("/") |> Enum.reject(&(&1 == ".")) |> Enum.map(&Macro.camelize/1) |> then(fn parts -> parts ++ ["Error"] end) |> then(fn parts -> Module.concat(["Dialup", "App"] ++ parts) end) {dir, module} end) # lambda内でも参照できるよう変数に束縛 layout_map_snapshot = @layout_map # ルート情報を生成(静的・動的を分離) route_infos = Enum.map(@page_files, fn file -> relative = Path.relative_to(file, @_dialup_app_dir) dir = Path.dirname(relative) {url_path, is_dynamic} = case dir do "." -> {"/", false} d -> # [id] 形式の動的セグメントを検出 segments = String.split(d, "/") has_dynamic = Enum.any?(segments, fn seg -> String.starts_with?(seg, "[") and String.ends_with?(seg, "]") end) {"/" <> d, has_dynamic} end page_module = dir |> String.split("/") |> Enum.reject(&(&1 == ".")) |> Enum.map(fn segment -> # [id] → Id, [slug] → Slug に変換 if String.starts_with?(segment, "[") and String.ends_with?(segment, "]") do segment |> String.slice(1..-2//1) |> Macro.camelize() else Macro.camelize(segment) end end) |> then(fn parts -> parts ++ ["Page"] end) |> then(fn parts -> Module.concat(["Dialup", "App"] ++ parts) end) # 親ディレクトリのレイアウトを収集 ancestor_dirs = case dir do "." -> ["."] d -> parts = String.split(d, "/") ["."] ++ for i <- 1..length(parts), do: Enum.take(parts, i) |> Enum.join("/") end layouts = Enum.flat_map(ancestor_dirs, fn d -> case Map.get(layout_map_snapshot, d) do nil -> [] mod -> [mod] end end) # パターンマッチング用情報 pattern = if is_dynamic do segments = String.split(dir, "/") Enum.map(segments, fn seg -> if String.starts_with?(seg, "[") and String.ends_with?(seg, "]") do param_name = String.slice(seg, 1..-2//1) {:param, param_name} else {:static, seg} end end) else nil end {url_path, %{ page: page_module, layouts: layouts, is_dynamic: is_dynamic, pattern: pattern, dir: dir }} end) @routes Map.new(route_infos) # 静的ルートと動的ルートを分離 @static_routes Map.filter(@routes, fn {_, v} -> not v.is_dynamic end) @dynamic_routes Map.filter(@routes, fn {_, v} -> v.is_dynamic end) @doc false def __routes__, do: @routes def layouts_for(path) do {clean_path, _} = split_path_query(path) case Map.get(@static_routes, clean_path) do nil -> case match_dynamic_route(clean_path) do nil -> [] {_, info} -> info.layouts end %{layouts: layouts} -> layouts end end def error_page_for(path) do {clean_path, _} = split_path_query(path || "/") segments = String.split(String.trim_leading(clean_path, "/"), "/", trim_empty: true) candidate_dirs = ["."] ++ for i <- 1..length(segments), do: Enum.take(segments, i) |> Enum.join("/") candidate_dirs |> Enum.reverse() |> Enum.find_value(fn d -> Map.get(@error_map, d) end) end def page_for(path) do {clean_path, _} = split_path_query(path) case Map.get(@static_routes, clean_path) do nil -> case match_dynamic_route(clean_path) do nil -> nil {_, info} -> info.page end %{page: mod} -> mod end end def path_params(path) do {clean_path, query} = split_path_query(path) query_params = parse_query_string(query) path_only_params = case Map.get(@static_routes, clean_path) do nil -> case match_dynamic_route(clean_path) do nil -> %{} {_, info} -> extract_params(clean_path, info.pattern) end _ -> %{} end # パスパラメータがクエリパラメータより優先される Map.merge(query_params, path_only_params) end def dispatch(path, assigns) do {clean_path, _} = split_path_query(path) case Map.get(@static_routes, clean_path) do nil -> case match_dynamic_route(clean_path) do nil -> {:error, :not_found} {_, info} -> {:ok, Dialup.Router.render_with_layouts(info.page, info.layouts, assigns)} end %{page: page_mod, layouts: layouts} -> {:ok, Dialup.Router.render_with_layouts(page_mod, layouts, assigns)} end end defp split_path_query(path) do case String.split(path, "?", parts: 2) do [p, q] -> {p, q} [p] -> {p, ""} end end defp parse_query_string(""), do: %{} defp parse_query_string(query), do: URI.decode_query(query) # 動的ルートマッチング defp match_dynamic_route(path) do request_segments = String.split(String.trim_leading(path, "/"), "/", trim_empty: true) Enum.find_value(@dynamic_routes, nil, fn {route_path, info} -> route_segments = String.split(String.trim_leading(route_path, "/"), "/", trim_empty: true) if length(request_segments) == length(route_segments) do matched = Enum.zip(request_segments, route_segments) |> Enum.all?(fn {req_seg, route_seg} -> # 動的セグメントは任意の値にマッチ、静的セグメントは完全一致 String.starts_with?(route_seg, "[") or req_seg == route_seg end) if matched, do: {route_path, info}, else: nil else nil end end) end # パラメータ抽出 defp extract_params(path, pattern) do request_segments = String.split(String.trim_leading(path, "/"), "/", trim_empty: true) Enum.zip(request_segments, pattern) |> Enum.reduce(%{}, fn {req_seg, pat_seg}, acc -> case pat_seg do {:param, name} -> Map.put(acc, name, req_seg) {:static, _} -> acc end end) end end end def mount_session(app_module, path, initial_session \\ %{}) do layouts = app_module.layouts_for(path) Enum.reduce(layouts, {initial_session, MapSet.new()}, fn layout_mod, {session, keys} -> {:ok, new_session} = layout_mod.mount(session) new_keys = MapSet.new(Map.keys(new_session)) {new_session, MapSet.union(keys, new_keys)} end) end def mount_assigns(app_module, path, opts \\ []) do params = app_module.path_params(path) initial_session = Keyword.get(opts, :initial_session, %{}) {session, session_keys} = mount_session(app_module, path, initial_session) case app_module.page_for(path) do nil -> {:ok, merge_mount_assigns(session, params, path, %{})} page_module -> case mount_page(page_module, params, session, session_keys) do {:ok, page_assigns} -> {:ok, merge_mount_assigns(session, params, path, page_assigns)} {:redirect, redirect_path} -> {:redirect, redirect_path} {:redirect, redirect_path, _session, _page_assigns} -> {:redirect, redirect_path} {:error, {:invalid_mount_return, _other}} -> {:error, :invalid_mount} end end end @redirect_follow_limit 5 @doc false def mount_assigns_resolved(app_module, path, opts \\ []) do mount_assigns_resolved(app_module, path, opts, 0) end defp mount_assigns_resolved(_app_module, _path, _opts, depth) when depth > @redirect_follow_limit do {:error, :redirect_loop} end defp mount_assigns_resolved(app_module, path, opts, depth) do case mount_assigns(app_module, path, opts) do {:redirect, redirect_path} -> mount_assigns_resolved(app_module, redirect_path, opts, depth + 1) {:ok, assigns} -> {:ok, path, assigns} {:error, reason} -> {:error, reason} end end @doc false def mount_page(page_module, params, session, session_keys) do case page_module.mount(params, session) do {:ok, result} -> {:ok, Map.drop(result, MapSet.to_list(session_keys))} {:redirect, path} when is_binary(path) -> {:redirect, Dialup.Auth.safe_redirect_path(path)} {:redirect, path, merged} when is_binary(path) and is_map(merged) -> {:redirect, Dialup.Auth.safe_redirect_path(path), Map.take(merged, MapSet.to_list(session_keys)), Map.drop(merged, MapSet.to_list(session_keys))} other -> {:error, {:invalid_mount_return, other}} end end defp merge_mount_assigns(session, params, path, page_assigns) do session |> Map.merge(page_assigns) |> Map.put(:params, params) |> Map.put(:current_path, path) end def render_with_layouts(page_mod, layouts, assigns) do page_html = page_mod.render(assigns) |> to_html() page_html = wrap_with_scope(page_html, page_mod) use_layout = if function_exported?(page_mod, :__layout__, 0), do: page_mod.__layout__(), else: true if use_layout do wrapped = layouts |> Enum.reverse() |> Enum.reduce(page_html, fn layout_mod, inner -> html = layout_mod.render(Map.put(assigns, :inner_content, inner)) |> to_html() wrap_with_scope(html, layout_mod) end) css = collect_css(layouts, page_mod) case css do "" -> wrapped _ -> ~s(" <> wrapped end else css = collect_css([], page_mod) case css do "" -> page_html _ -> ~s(" <> page_html end end end def render_with_layouts_raw(inner_html, inner_mod, layouts, assigns) do inner_html = wrap_with_scope(inner_html, inner_mod) wrapped = layouts |> Enum.reverse() |> Enum.reduce(inner_html, fn layout_mod, inner -> html = layout_mod.render(Map.put(assigns, :inner_content, inner)) |> to_html() wrap_with_scope(html, layout_mod) end) css_mods = layouts ++ [inner_mod] css = collect_css_from(css_mods) case css do "" -> wrapped _ -> ~s(" <> wrapped end end defp wrap_with_scope(html, mod) do if function_exported?(mod, :__css_scope__, 0) do case mod.__css_scope__() do nil -> html scope -> ~s(