diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ec93400 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Keep the Docker build context small and reproducible. Everything needed is +# fetched/built inside the image; local artifacts are excluded. +.git +.gitignore +_build +deps +doc +cover +tmp +erl_crash.dump + +# Node / asset build outputs (npm install + esbuild run inside the image). +assets/node_modules +priv/static/assets/css/storybook.css +priv/static/assets/js/storybook.js +priv/static/assets/js/docs.js + +# Vendored reference repos (not part of the build). +vendor + +# Test harness + tooling not needed for the docs release. +test +.elixir_ls +.superpowers +.claude + +# Deploy/meta files +Dockerfile +.dockerignore +fly.toml +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..681962d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,82 @@ +# Multi-stage build for the Shadix docs site (the dev harness compiled for +# production as the `shadix_docs` release, MIX_ENV=docs). +# +# Stage 1 builds assets + the OTP release; stage 2 is a slim runtime that only +# carries the assembled release. Both stages are Alpine (musl): the release is +# dynamically linked against the builder's libc, so the runner libc must match. +# Base image versions track mise.toml (Erlang 29.0.2 / Elixir 1.20.1). + +ARG ELIXIR_IMAGE="hexpm/elixir:1.20.1-erlang-29.0.2-alpine-3.21.7" +ARG RUNNER_IMAGE="alpine:3.21" +ARG NODE_IMAGE="node:24-alpine" + +# ---- Stage 1: build ---------------------------------------------------------- +FROM ${ELIXIR_IMAGE} AS builder + +# Node + npm are needed only to install the assets/ JS deps (@floating-ui/dom) +# that esbuild bundles. Copy the musl build from node:alpine so it runs here. +COPY --from=node:24-alpine /usr/local/bin/node /usr/local/bin/node +COPY --from=node:24-alpine /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm + +# build-base/git for any native build steps; libstdc++ + libgcc let the copied +# node binary and the esbuild/tailwind standalone CLIs run on musl. +RUN apk add --no-cache build-base git ca-certificates libstdc++ libgcc + +WORKDIR /app + +# Build in the dedicated docs environment. +ENV MIX_ENV="docs" +# Give hex more headroom on slow/flaky networks (default is 15s per request). +ENV HEX_HTTP_TIMEOUT=120 + +RUN for i in 1 2 3 4 5; do mix local.hex --force && mix local.rebar --force && break; \ + echo "local.hex/rebar attempt $i failed; retrying..."; sleep 5; done + +# Fetch deps first (cached unless mix.exs/mix.lock/config change). Retry to ride +# out transient hex registry timeouts. +COPY mix.exs mix.lock ./ +COPY config config +RUN for i in 1 2 3 4 5; do mix deps.get --only $MIX_ENV && break; \ + echo "deps.get attempt $i failed; retrying..."; sleep 5; done +RUN mix deps.compile + +# Install the esbuild/tailwind standalone binaries used by `mix assets.build`. +# On musl these resolve to the -musl builds automatically. +RUN mix esbuild.install --if-missing && mix tailwind.install --if-missing + +# Install assets JS deps (esbuild resolves @floating-ui/dom from here). +COPY assets assets +RUN cd assets && npm install + +# Copy source and build assets + the release. +COPY lib lib +COPY dev dev +COPY priv priv +RUN mix assets.build +RUN mix compile +RUN mix release shadix_docs + +# ---- Stage 2: runtime -------------------------------------------------------- +FROM ${RUNNER_IMAGE} AS runner + +# Runtime libs the BEAM links against on Alpine. +RUN apk add --no-cache libstdc++ openssl ncurses-libs libgcc ca-certificates + +# musl ships a built-in UTF-8 locale; just point the BEAM at it. +ENV LANG=C.UTF-8 + +WORKDIR /app +RUN chown nobody /app + +ENV MIX_ENV="docs" +# Fly maps its proxy to this port; runtime.exs also defaults to 8080. +ENV PORT=8080 + +COPY --from=builder --chown=nobody:root /app/_build/docs/rel/shadix_docs ./ + +USER nobody + +EXPOSE 8080 + +CMD ["/app/bin/shadix_docs", "start"] diff --git a/assets/package-lock.json b/assets/package-lock.json new file mode 100644 index 0000000..a1acedd --- /dev/null +++ b/assets/package-lock.json @@ -0,0 +1,38 @@ +{ + "name": "shadix-assets", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shadix-assets", + "dependencies": { + "@floating-ui/dom": "^1.6.13" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + } + } +} diff --git a/config/docs.exs b/config/docs.exs new file mode 100644 index 0000000..4937de4 --- /dev/null +++ b/config/docs.exs @@ -0,0 +1,15 @@ +import Config + +# Compile-time configuration for the deployable docs site (MIX_ENV=docs). +# Runtime values (port, secret_key_base, host) are set in config/runtime.exs. +config :shadix, Shadix.DevEndpoint, + adapter: Bandit.PhoenixAdapter, + url: [host: "localhost"], + pubsub_server: Shadix.DevPubSub, + live_view: [signing_salt: "shadix-storybook-salt"], + check_origin: false, + server: true + +config :phoenix, :json_library, Jason + +config :logger, level: :info diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..c798e34 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,23 @@ +import Config + +# Runtime configuration for the docs-site release (MIX_ENV=docs). Evaluated when +# the release boots, so it reads the container's environment variables. +if config_env() == :docs do + port = String.to_integer(System.get_env("PORT") || "8080") + + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + Generate one with: mix phx.gen.secret (or: openssl rand -base64 48) + """ + + host = System.get_env("PHX_HOST") || "localhost" + + config :shadix, Shadix.DevEndpoint, + # Bind all interfaces (IPv6 any also accepts IPv4-mapped) so Fly's proxy can + # reach the app inside the container. + http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: port], + url: [host: host, port: 443, scheme: "https"], + secret_key_base: secret_key_base +end diff --git a/dev/application.ex b/dev/application.ex new file mode 100644 index 0000000..9dea5b1 --- /dev/null +++ b/dev/application.ex @@ -0,0 +1,28 @@ +defmodule Shadix.DocsApp do + @moduledoc """ + OTP application for the deployable Shadix docs site (compiled only outside + `:prod`; wired as the release `mod:` only when `MIX_ENV=docs`). + + Mirrors the supervision tree booted ad-hoc by `dev.exs` for local work, but + here it is started by the release so the endpoint comes up under the OTP + supervisor. Endpoint configuration comes from `config/docs.exs` (compile time) + and `config/runtime.exs` (PORT / SECRET_KEY_BASE / PHX_HOST at boot). + """ + use Application + + @impl true + def start(_type, _args) do + children = [ + {Phoenix.PubSub, name: Shadix.DevPubSub}, + Shadix.DevEndpoint + ] + + Supervisor.start_link(children, strategy: :one_for_one, name: Shadix.DocsSupervisor) + end + + @impl true + def config_change(changed, _new, removed) do + Shadix.DevEndpoint.config_change(changed, removed) + :ok + end +end diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..5e7d806 --- /dev/null +++ b/fly.toml @@ -0,0 +1,42 @@ +# Fly.io configuration for the Shadix docs site. +# +# First-time setup: +# fly launch --no-deploy --copy-config --name shadix-docs # or `fly apps create shadix-docs` +# fly secrets set SECRET_KEY_BASE=$(openssl rand -base64 48) +# fly deploy +# +# Change `app`, `primary_region`, and PHX_HOST to match your account. + +app = "shadix-docs" +primary_region = "ams" + +[build] + +[env] + PORT = "8080" + PHX_HOST = "shadix.workinghypothes.is" + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + + [http_service.concurrency] + type = "connections" + hard_limit = 200 + soft_limit = 100 + + # The LiveView docs site responds 200 on "/". + [[http_service.checks]] + interval = "15s" + timeout = "2s" + grace_period = "10s" + method = "GET" + path = "/" + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" diff --git a/mix.exs b/mix.exs index 77a2f85..ff174b4 100644 --- a/mix.exs +++ b/mix.exs @@ -9,27 +9,46 @@ defmodule Shadix.MixProject do app: :shadix, version: @version, elixir: "~> 1.20", - start_permanent: Mix.env() == :prod, + start_permanent: Mix.env() in [:prod, :docs], elixirc_paths: elixirc_paths(Mix.env()), deps: deps(), aliases: aliases(), description: "shadcn-style copy-paste UI components for Phoenix LiveView", package: package(), source_url: @source_url, - docs: docs() + docs: docs(), + releases: releases() ] end # Run "mix help compile.app" to learn about applications. + # + # The docs deployment (built with MIX_ENV=docs) starts a supervised endpoint + # via Shadix.DocsApp. When Shadix is consumed as a library dependency it is + # compiled in :prod, so `mod:` is absent and no server is ever started. def application do + [extra_applications: [:logger]] ++ docs_application(Mix.env()) + end + + defp docs_application(:docs), do: [mod: {Shadix.DocsApp, []}] + defp docs_application(_), do: [] + + # The deployable docs site (the dev harness compiled for production). Built + # with `MIX_ENV=docs mix release shadix_docs`; see Dockerfile / fly.toml. + defp releases do [ - extra_applications: [:logger] + shadix_docs: [ + include_executables_for: [:unix], + applications: [shadix: :permanent] + ] ] end # The storybook dev harness lives in dev/ and is only compiled outside :prod. + # The :docs env compiles it for the production docs-site release. defp elixirc_paths(:prod), do: ["lib"] defp elixirc_paths(:test), do: ["lib", "dev", "test/support"] + defp elixirc_paths(:docs), do: ["lib", "dev"] defp elixirc_paths(_), do: ["lib", "dev"] # Run "mix help deps" to learn about dependencies. @@ -38,17 +57,18 @@ defmodule Shadix.MixProject do {:phoenix_live_view, "~> 1.1"}, {:jason, "~> 1.4"}, - # Dev/test harness: storybook previews + assertion helpers + TS hook builds + # Dev/test harness + docs deployment: storybook previews, assertion helpers, + # and TS hook builds. The :docs env reuses these to build the public site. # (phoenix itself arrives transitively via phoenix_live_view) - {:phoenix_storybook, "~> 1.2", only: [:dev, :test]}, - {:esbuild, "~> 0.10", only: [:dev], runtime: false}, + {:phoenix_storybook, "~> 1.2", only: [:dev, :test, :docs]}, + {:esbuild, "~> 0.10", only: [:dev, :docs], runtime: false}, {:floki, "~> 0.38", only: :test}, {:lazy_html, ">= 0.1.0", only: :test}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, - # Dev harness HTTP server + Tailwind v4 CLI wrapper (storybook only) - {:bandit, "~> 1.5", only: :dev}, - {:tailwind, "~> 0.3", only: :dev, runtime: false} + # Dev harness / docs-site HTTP server + Tailwind v4 CLI wrapper. + {:bandit, "~> 1.5", only: [:dev, :docs]}, + {:tailwind, "~> 0.3", only: [:dev, :docs], runtime: false} ] end