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