Skip to main content

lib/mix/tasks/skua.gen.seo.ex

defmodule Mix.Tasks.Skua.Gen.Seo do
  @shortdoc "Scaffold public-facing robots.txt + llms.txt (crawl + AI discovery)"
  @moduledoc """
  Generate editable, public-facing discovery files and wire them to be served:

      mix skua.gen.seo

  Creates:

    * `priv/static/robots.txt` — public crawl rules. The conventional
      scoped/authenticated prefixes a Skua app generates (`/users`, `/dashboard`,
      `/dev`) are **Disallowed by default**, so private surfaces stay out of
      crawlers. Includes a commented `Sitemap:` line to fill in later. A stock
      phx.new `robots.txt` is replaced; a robots.txt this task already wrote (it
      carries a marker comment) is left untouched so your edits survive re-runs.
    * `priv/static/llms.txt` — an [llms.txt](https://llmstxt.org) template (H1
      name, summary, link sections) describing your **public** content, for LLMs
      and agents. Never overwritten once it exists — it's yours to edit.

  And wires `lib/<app>_web.ex`'s `static_paths/0` to include `llms.txt` (and
  `robots.txt` if somehow absent) so both serve at `/llms.txt` and `/robots.txt`.

  ## Why static files

  Both are served by `Plug.Static`, which sits *above* the router — so they never
  pass through your `:browser`/auth pipelines and can't leak scoped or
  authenticated routes. That's the default: public-only. Point them at real
  content by editing the two files; nothing else is required.

  Idempotent: re-running adds nothing already present, preserves your `llms.txt`
  and any marker-bearing `robots.txt`, and re-applies the `static_paths/0` edit
  only if needed.
  """
  use Mix.Task

  @impl Mix.Task
  def run(_args) do
    if Mix.Project.umbrella?() do
      Mix.raise("Run `mix skua.gen.seo` from inside your *_web app, not the umbrella root.")
    end

    app = to_string(Mix.Project.config()[:app])
    app_name = app |> String.split("_") |> Enum.map_join(" ", &String.capitalize/1)
    web_ex = Path.join("lib", "#{app}_web.ex")

    Mix.shell().info([:cyan, "\n  skua.gen.seo\n", :reset])

    # Files first (they must exist before static_paths lists them): phx.new 1.8
    # sets `Plug.Static, raise_on_missing_only: code_reloading?`, so in dev a
    # listed-but-missing path raises at boot. Writing the files first avoids it.
    results = [
      write_llms("priv/static/llms.txt", app_name),
      write_robots("priv/static/robots.txt"),
      patch_static_paths(web_ex)
    ]

    print(results)

    Mix.shell().info([
      :green,
      "\n  ✓ ",
      :reset,
      "SEO files ready: /robots.txt and /llms.txt (public, scoped routes off by\n      default). Edit ",
      :bright,
      "priv/static/llms.txt",
      :reset,
      " and ",
      :bright,
      "priv/static/robots.txt",
      :reset,
      " to point at your content.\n"
    ])
  end

  # llms.txt is the user's content — only write it if absent.
  defp write_llms(path, app_name) do
    if File.exists?(path) do
      {:skip, path <> " (kept your llms.txt)"}
    else
      File.mkdir_p!(Path.dirname(path))
      File.write!(path, Skua.Seo.llms_txt(app_name))
      {:ok, path}
    end
  end

  # robots.txt: overwrite a stock phx.new file with a useful default, but never
  # clobber one we already wrote (it carries the marker) — that preserves edits.
  defp write_robots(path) do
    cond do
      File.exists?(path) and String.contains?(File.read!(path), Skua.Seo.robots_marker()) ->
        {:skip, path <> " (kept your robots.txt)"}

      true ->
        File.mkdir_p!(Path.dirname(path))
        File.write!(path, Skua.Seo.robots_txt())
        {:ok, path}
    end
  end

  defp patch_static_paths(web_ex) do
    if File.exists?(web_ex) do
      case Skua.Seo.static_paths_seo(File.read!(web_ex)) do
        :skip -> {:skip, web_ex <> " (static_paths already serves them)"}
        {:ok, new} -> File.write!(web_ex, new) && {:ok, web_ex <> " (static_paths updated)"}
        {:manual, msg} -> {:manual, web_ex, msg}
      end
    else
      {:manual, web_ex,
       "not found — add \"robots.txt\" and \"llms.txt\" to static_paths/0 yourself."}
    end
  end

  defp print(results) do
    Enum.each(results, fn
      {:ok, p} -> Mix.shell().info([:green, "  ✓ ", :reset, p])
      {:skip, p} -> Mix.shell().info([:faint, "  • already done — ", p, :reset])
      {:manual, p, m} -> Mix.shell().info([:yellow, "  ! ", :reset, p, ": ", m])
    end)
  end
end