diff --git a/.gitignore b/.gitignore index a00b25f..b168a32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /_build/ /deps/ +/cover/ diff --git a/.iex.exs b/.iex.exs new file mode 100644 index 0000000..a287a3b --- /dev/null +++ b/.iex.exs @@ -0,0 +1,2 @@ +alias Publishing.Repo +alias Publishing.{Article, Blog} diff --git a/Dockerfile b/Dockerfile index a6b275a..63f1877 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /branchpage RUN mix do local.hex --force, local.rebar --force -RUN apk add inotify-tools +RUN apk add npm inotify-tools # ----------------- @@ -23,6 +23,7 @@ ENV MIX_ENV=$MIX_ENV # install mix dependencies COPY mix.exs mix.lock ./ COPY apps/web/mix.exs apps/web/mix.exs +COPY apps/publishing/mix.exs apps/publishing/mix.exs COPY config config RUN mix do deps.get, deps.compile --skip-umbrella-children @@ -36,8 +37,6 @@ RUN mix compile # ----------------- FROM build AS release -RUN apk add npm - # install node dependencies RUN npm ci --prefix ./apps/web/assets --no-audit @@ -59,7 +58,6 @@ FROM alpine:3.13.3 WORKDIR /branchpage ARG MIX_ENV=prod -ENV MIX_ENV=$MIX_ENV # install dependencies RUN apk add ncurses-libs curl diff --git a/apps/publishing/.formatter.exs b/apps/publishing/.formatter.exs new file mode 100644 index 0000000..d7ef50f --- /dev/null +++ b/apps/publishing/.formatter.exs @@ -0,0 +1,5 @@ +# Used by "mix format" +[ + import_deps: [:ecto, :phoenix], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/apps/publishing/.gitignore b/apps/publishing/.gitignore new file mode 100644 index 0000000..bf33fc9 --- /dev/null +++ b/apps/publishing/.gitignore @@ -0,0 +1,24 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +blog-*.tar + diff --git a/apps/publishing/README.md b/apps/publishing/README.md new file mode 100644 index 0000000..3e3f96a --- /dev/null +++ b/apps/publishing/README.md @@ -0,0 +1,21 @@ +# Publishing + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `publishing` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:publishing, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at [https://hexdocs.pm/publishing](https://hexdocs.pm/publishing). + diff --git a/apps/publishing/coveralls.json b/apps/publishing/coveralls.json new file mode 100644 index 0000000..7d29f6b --- /dev/null +++ b/apps/publishing/coveralls.json @@ -0,0 +1,11 @@ +{ + "skip_files": [ + "lib/publishing/application.ex", + "lib/publishing/release.ex", + "lib/publishing/repo.ex", + "test/support" + ], + "coverage_options": { + "treat_no_relevant_lines_as_covered": true + } +} diff --git a/apps/publishing/lib/publishing/application.ex b/apps/publishing/lib/publishing/application.ex new file mode 100644 index 0000000..a64fbff --- /dev/null +++ b/apps/publishing/lib/publishing/application.ex @@ -0,0 +1,11 @@ +defmodule Publishing.Application do + @moduledoc false + + use Application + + def start(_type, _args) do + children = [Publishing.Repo] + + Supervisor.start_link(children, strategy: :one_for_one, name: Publishing.Supervisor) + end +end diff --git a/apps/publishing/lib/publishing/helper.ex b/apps/publishing/lib/publishing/helper.ex new file mode 100644 index 0000000..55d9f31 --- /dev/null +++ b/apps/publishing/lib/publishing/helper.ex @@ -0,0 +1,34 @@ +defmodule Publishing.Helper do + @moduledoc """ + Helpers for the publishing application. + """ + + @current_year Date.utc_today().year + @last_year @current_year - 1 + + @doc """ + Formats a datetime into "Month. day" format + + Examples + iex> format_date(~D[2021-06-15]) + "Jun 15" + + iex> format_date(~D[2020-06-21]) + "Last year" + + iex> format_date(~D[2019-12-12]) + "2 years ago" + """ + def format_date(%{year: @current_year} = datetime) do + Timex.format!(datetime, "%b %e", :strftime) + end + + def format_date(%{year: @last_year}) do + "Last year" + end + + def format_date(%{year: year}) do + n = @current_year - year + "#{n} years ago" + end +end diff --git a/apps/publishing/lib/publishing/integration.ex b/apps/publishing/lib/publishing/integration.ex new file mode 100644 index 0000000..ec7cbd3 --- /dev/null +++ b/apps/publishing/lib/publishing/integration.ex @@ -0,0 +1,32 @@ +defmodule Publishing.Integration do + @moduledoc """ + Integrations modules extract information from code hosting platforms. + """ + + alias Publishing.Integration.Github + + @callback get_content(String.t()) :: {:ok, String.t()} | {:error, integer} + @callback get_username(String.t()) :: {:ok, String.t() | {:error, :username}} + @callback get_blog_data(String.t()) :: {:ok, map} | {:error, :blog} + + @doc """ + Returns the `url`'s integration module. + + ## Currently supported integrations + * Github: `Publishing.Integration.Github` + + Examples: + iex> service("https://github.com/teste") + {:ok, Publishing.Integration.Github} + + iex> service("https://gitlab.com/teste") + {:error, :integration} + """ + @spec service(String.t()) :: {:ok, module} | {:error, :integration} + def service(url) do + case URI.parse(url) do + %URI{host: "github.com"} -> {:ok, Github} + _ -> {:error, :integration} + end + end +end diff --git a/apps/publishing/lib/publishing/integration/github.ex b/apps/publishing/lib/publishing/integration/github.ex new file mode 100644 index 0000000..02bae37 --- /dev/null +++ b/apps/publishing/lib/publishing/integration/github.ex @@ -0,0 +1,102 @@ +defmodule Publishing.Integration.Github do + @moduledoc """ + Integrates with github. + """ + + use Tesla + + @behaviour Publishing.Integration + + plug Tesla.Middleware.BaseUrl, "https://api.github.com/" + plug Tesla.Middleware.Headers, [{"Authorization", "Bearer #{token()}"}] + plug Tesla.Middleware.Headers, [{"User-Agent", "branchpage"}] + plug Tesla.Middleware.JSON + + defp token, do: Application.get_env(:publishing, __MODULE__)[:token] + + def get_blog_data(username) do + body = %{ + query: "query($login: String!){user(login: $login){bio name avatarUrl}}", + variables: %{login: username} + } + + case post("graphql", body) do + {:ok, %{status: 200, body: response}} -> + %{ + "data" => %{ + "user" => %{ + "name" => name, + "bio" => bio, + "avatarUrl" => avatar_url + } + } + } = response + + data = %{fullname: name, bio: bio, avatar_url: avatar_url} + + {:ok, data} + + _ -> + {:error, :blog} + end + end + + @doc """ + Returns the GitHub username from the `url`. + + Examples: + iex> get_username("https://github.com/felipelincoln/branchpage/blob/main/README.md") + {:ok, "felipelincoln"} + + iex> get_username("https://github.com/") + {:error, :username} + """ + @spec get_username(String.t()) :: {:ok, String.t()} | {:error, :username} + def get_username(url) when is_binary(url) do + case decompose(url) do + [username, _] -> {:ok, username} + [] -> {:error, :username} + end + end + + @doc """ + Retrieve the raw content of a resource's `url` from github. + """ + @spec get_content(String.t()) :: {:ok, Stream.t()} | {:error, integer} + def get_content(""), do: {:error, 404} + + def get_content(url) when is_binary(url) do + raw = + url + |> decompose() + |> raw_url() + + case Tesla.get(raw) do + {:ok, %{status: 200, body: body}} -> + {:ok, body} + + {:ok, %{status: code}} -> + {:error, code} + end + end + + defp decompose(url) do + with %URI{path: path} when is_binary(path) <- URI.parse(url), + ["", username, repository, "blob" | tail] <- String.split(path, "/"), + resource <- Enum.join([repository] ++ tail, "/"), + true <- not_empty_string(username), + true <- not_empty_string(resource) do + [username, resource] + else + _ -> [] + end + end + + defp raw_url([username, resource]) do + "https://raw.githubusercontent.com/#{username}/#{resource}" + end + + defp raw_url([]), do: "" + + defp not_empty_string(str), do: is_binary(str) and str != "" +end diff --git a/apps/publishing/lib/publishing/manage.ex b/apps/publishing/lib/publishing/manage.ex new file mode 100644 index 0000000..90cbb7b --- /dev/null +++ b/apps/publishing/lib/publishing/manage.ex @@ -0,0 +1,185 @@ +defmodule Publishing.Manage do + @moduledoc """ + Manage's public API. + """ + + alias Publishing.Integration + alias Publishing.Manage.{Article, Blog, Platform} + alias Publishing.Markdown + alias Publishing.Repo + + import Ecto.Query + + def load_blog!(username) do + db_blog = + Blog + |> Repo.get_by!(username: username) + |> Repo.preload([:articles, :platform]) + + blog = build_blog(db_blog) + + content = %{ + fullname: blog.fullname, + bio: blog.bio, + avatar_url: blog.avatar_url + } + + {:ok, _} = + db_blog + |> Blog.changeset(content) + |> Repo.update() + + Map.merge(db_blog, content) + rescue + _error -> + reraise Publishing.PageNotFound, __STACKTRACE__ + end + + defp build_blog(%Blog{} = blog) do + %{username: username, platform: %{name: platform}} = blog + + {:ok, integration} = Integration.service(platform) + {:ok, content} = integration.get_blog_data(username) + + %Blog{} + |> Map.merge(content) + end + + @doc """ + Loads an article from database. + """ + @spec load_article!(String.t(), any) :: Article.t() + def load_article!(username, id) do + db_article = + Article + |> Repo.get!(id) + |> Repo.preload(:blog) + + ^username = db_article.blog.username + + {:ok, article} = + with {:error, _} <- build_article(db_article.url) do + Repo.delete(db_article) + + :fail + end + + content = %{ + title: article.title, + body: article.body, + preview: article.preview + } + + {:ok, _} = + db_article + |> Article.changeset(content) + |> Repo.update() + + Map.merge(db_article, content) + rescue + _error -> + reraise Publishing.PageNotFound, __STACKTRACE__ + end + + @doc """ + Saves an article struct to the database. + """ + @spec save_article(Article.t()) :: {:ok, Article.t()} | {:error, String.t()} + def save_article(%Article{} = article) do + {:ok, blog} = upsert_blog(article) + + attrs = + article + |> Map.from_struct() + |> Map.merge(%{blog_id: blog.id}) + + %Article{} + |> Article.changeset(attrs) + |> Repo.insert() + |> case do + {:error, changeset} -> + {:error, Article.get_error(changeset)} + + {:ok, %Article{}} = success -> + success + end + end + + @doc """ + Build an article struct from the given `url`. + """ + @spec build_article(String.t()) :: {:ok, Article.t()} | {:error, String.t()} + def build_article(url) do + with url <- String.trim(url), + {:ok, _url} <- validate_url(url), + {:ok, integration} <- Integration.service(url), + {:ok, username} <- integration.get_username(url), + {:ok, content} <- integration.get_content(url) do + title = Markdown.get_heading(content) + preview = Markdown.get_preview(content) + html = Markdown.get_body(content) + blog = %Blog{username: username} + + {:ok, %Article{body: html, preview: preview, title: title, url: url, blog: blog}} + else + {:error, :scheme} -> + {:error, "Invalid scheme. Use http or https"} + + {:error, :extension} -> + {:error, "Invalid extension. Must be .md"} + + {:error, :integration} -> + {:error, "Not integrated with #{host(url)} yet"} + + {:error, :username} -> + {:error, "Invalid #{host(url)} resource"} + + {:error, 404} -> + {:error, "Page not found"} + + {:error, status} when is_integer(status) -> + {:error, "Failed to retrieve page content. (error #{status})"} + end + end + + defp get_platform(url) do + platform_url = + url + |> URI.parse() + |> Map.merge(%{path: "/"}) + |> URI.to_string() + + Repo.one(from Platform, where: [name: ^platform_url]) + end + + defp upsert_blog(%Article{} = article) do + %{url: url, blog: %{username: username}} = article + platform = get_platform(url) + + case Repo.one(from Blog, where: [username: ^username]) do + nil -> + attrs = %{username: username, platform_id: platform.id} + + %Blog{} + |> Blog.changeset(attrs) + |> Repo.insert() + + blog -> + {:ok, blog} + end + end + + defp host(url), do: URI.parse(url).host + + defp validate_url(url) do + case URI.parse(url) do + %URI{scheme: scheme} when scheme not in ["http", "https"] -> + {:error, :scheme} + + %URI{path: path} -> + if MIME.from_path(path || "/") == "text/markdown", + do: {:ok, url}, + else: {:error, :extension} + end + end +end diff --git a/apps/publishing/lib/publishing/manage/article.ex b/apps/publishing/lib/publishing/manage/article.ex new file mode 100644 index 0000000..e45d062 --- /dev/null +++ b/apps/publishing/lib/publishing/manage/article.ex @@ -0,0 +1,37 @@ +defmodule Publishing.Manage.Article do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @optional_fields ~w(title preview url blog_id)a + @required_fields ~w()a + + schema "article" do + field :title, :string + field :url, :string + field :preview, :string + field :body, :string, virtual: true + + belongs_to :blog, Publishing.Manage.Blog, type: :binary_id + + timestamps() + end + + def changeset(%__MODULE__{} = struct, %{} = attrs) do + struct + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_required(@required_fields) + |> validate_length(:title, max: 255) + |> assoc_constraint(:blog) + |> unique_constraint(:url) + end + + @doc """ + Prints a message relative to the first error in the `changeset`. + """ + def get_error(%Ecto.Changeset{errors: [{:url, _reason} | _tail]}) do + "This article has already been published!" + end +end diff --git a/apps/publishing/lib/publishing/manage/blog.ex b/apps/publishing/lib/publishing/manage/blog.ex new file mode 100644 index 0000000..6cd8416 --- /dev/null +++ b/apps/publishing/lib/publishing/manage/blog.ex @@ -0,0 +1,32 @@ +defmodule Publishing.Manage.Blog do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @optional_fields ~w(fullname username bio donate_url avatar_url platform_id)a + @required_fields ~w()a + + schema "blog" do + field :fullname, :string + field :username, :string + field :bio, :string + field :donate_url, :string + field :avatar_url, :string + + belongs_to :platform, Publishing.Manage.Platform + has_many :articles, Publishing.Manage.Article + + timestamps() + end + + def changeset(%__MODULE__{} = struct, %{} = attrs) do + struct + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_required(@required_fields) + |> validate_length(:fullname, max: 255) + |> validate_length(:username, max: 255) + |> validate_length(:bio, max: 255) + end +end diff --git a/apps/publishing/lib/publishing/manage/platform.ex b/apps/publishing/lib/publishing/manage/platform.ex new file mode 100644 index 0000000..a71c420 --- /dev/null +++ b/apps/publishing/lib/publishing/manage/platform.ex @@ -0,0 +1,23 @@ +defmodule Publishing.Manage.Platform do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @optional_fields ~w(name)a + @required_fields ~w()a + + schema "platform" do + field :name, :string + + has_many :blogs, Publishing.Manage.Blog + end + + def changeset(%__MODULE__{} = struct, %{} = attrs) do + struct + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_required(@required_fields) + |> validate_length(:name, max: 255) + |> unique_constraint(:name) + end +end diff --git a/apps/publishing/lib/publishing/markdown.ex b/apps/publishing/lib/publishing/markdown.ex new file mode 100644 index 0000000..cc86f92 --- /dev/null +++ b/apps/publishing/lib/publishing/markdown.ex @@ -0,0 +1,102 @@ +defmodule Publishing.Markdown do + @moduledoc """ + Module for handling raw markdown texts. + """ + + @preview_length Application.compile_env!(:publishing, :markdown)[:preview_length] + @heading_length Application.compile_env!(:publishing, :markdown)[:heading_length] + @heading_default Application.compile_env!(:publishing, :markdown)[:heading_default] + + @doc """ + Transform markdown into HMTL performing additional mutations. + + ## Features + * Removes the first `#` heading + * Add `language-none` to inline and code blocks. + + Example: + iex> get_body("# title") + "" + + iex> get_body("## title") + "

\\ntitle

\\n" + + iex> get_body("`some code`") + "

\\nsome code

\\n" + + iex> get_body("```\\nsome code\\n```") + "
some code
\\n" + """ + @spec get_body(String.t()) :: list + def get_body(markdown) do + markdown + |> to_ast() + |> remove_heading() + |> add_code_class() + |> Earmark.Transform.transform() + end + + @doc """ + Returns the markdown's main title or the given `default` (optional). + + Examples: + iex> get_heading("# Hello World!\\nLorem ipsum...") + "Hello World!" + + iex> get_heading("Lorem ipsum dolor sit amet...", "Untitled") + "Untitled" + """ + @spec get_heading(String.t()) :: String.t() + def get_heading(markdown, default \\ @heading_default) when is_binary(markdown) do + with {:ok, ast, _} <- EarmarkParser.as_ast(markdown), + [{"h1", _, [title], _} | _tail] when is_binary(title) <- ast do + title + |> String.slice(0, @heading_length) + |> String.trim() + else + _ -> default + end + end + + def get_preview(markdown) do + title_size = + "# #{get_heading(markdown)}\n" + |> byte_size + + preview_length = @preview_length + title_size + + if byte_size(markdown) > preview_length do + markdown + |> String.slice(0, preview_length) + |> String.trim() + |> Kernel.<>(" ...") + |> get_body() + else + markdown + |> String.trim() + |> get_body() + end + end + + defp to_ast(markdown) do + {:ok, ast, _} = EarmarkParser.as_ast(markdown, code_class_prefix: "language-") + + ast + end + + defp remove_heading([{"h1", _, [_title], _} | tail]), do: tail + defp remove_heading(ast), do: ast + + defp add_code_class(ast) do + Earmark.Transform.map_ast(ast, fn + {"code", [], [content], %{}} -> + {"code", [{"class", "language-none"}], [content], %{}} + + {"code", [{"class", "inline"}], [content], %{}} -> + {"code", [{"class", "language-none"}], [content], %{}} + + tag -> + tag + end) + end +end diff --git a/apps/publishing/lib/publishing/page_not_found.ex b/apps/publishing/lib/publishing/page_not_found.ex new file mode 100644 index 0000000..c935dcb --- /dev/null +++ b/apps/publishing/lib/publishing/page_not_found.ex @@ -0,0 +1,8 @@ +defmodule Publishing.PageNotFound do + defexception message: "Page not found." + + defimpl Plug.Exception, for: __MODULE__ do + def status(_), do: 404 + def actions(_), do: [] + end +end diff --git a/apps/publishing/lib/publishing/release.ex b/apps/publishing/lib/publishing/release.ex new file mode 100644 index 0000000..17434c1 --- /dev/null +++ b/apps/publishing/lib/publishing/release.ex @@ -0,0 +1,30 @@ +defmodule Publishing.Release do + @moduledoc """ + Module for running migrations and rollback using + the release binary. + Example: bin/web eval "Publishing.Release.migrate" + """ + + @app :publishing + + def migrate do + load_app() + + for repo <- repos() do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) + end + end + + def rollback(repo, version) do + load_app() + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + end + + defp repos do + Application.fetch_env!(@app, :ecto_repos) + end + + defp load_app do + Application.load(@app) + end +end diff --git a/apps/publishing/lib/publishing/repo.ex b/apps/publishing/lib/publishing/repo.ex new file mode 100644 index 0000000..206cd53 --- /dev/null +++ b/apps/publishing/lib/publishing/repo.ex @@ -0,0 +1,7 @@ +defmodule Publishing.Repo do + @moduledoc false + + use Ecto.Repo, + otp_app: :publishing, + adapter: Ecto.Adapters.Postgres +end diff --git a/apps/publishing/mix.exs b/apps/publishing/mix.exs new file mode 100644 index 0000000..0c9a127 --- /dev/null +++ b/apps/publishing/mix.exs @@ -0,0 +1,48 @@ +defmodule Publishing.MixProject do + use Mix.Project + + def project do + [ + app: :publishing, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.10", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + test_coverage: [tool: ExCoveralls], + deps: deps(), + aliases: aliases() + ] + end + + def application do + [ + mod: {Publishing.Application, []}, + extra_applications: [:ssl, :mime] + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + {:ecto_sql, "~> 3.4"}, + {:postgrex, ">= 0.0.0"}, + {:earmark, "~> 1.4.15"}, + {:timex, "~> 3.0"}, + {:tesla, "~> 1.4.0"}, + {:ex_machina, "~> 2.7.0", only: :test}, + {:mox, "~> 1.0", only: :test} + ] + end + + defp aliases do + [ + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] + ] + end +end diff --git a/apps/publishing/priv/repo/migrations/20210420121650_create_platform_table.exs b/apps/publishing/priv/repo/migrations/20210420121650_create_platform_table.exs new file mode 100644 index 0000000..899a722 --- /dev/null +++ b/apps/publishing/priv/repo/migrations/20210420121650_create_platform_table.exs @@ -0,0 +1,11 @@ +defmodule Publishing.Repo.Migrations.CreatePlatformTable do + use Ecto.Migration + + def change do + create table(:platform) do + add :name, :string + end + + create unique_index(:platform, :name) + end +end diff --git a/apps/publishing/priv/repo/migrations/20210421121019_create_blog_table.exs b/apps/publishing/priv/repo/migrations/20210421121019_create_blog_table.exs new file mode 100755 index 0000000..46378bb --- /dev/null +++ b/apps/publishing/priv/repo/migrations/20210421121019_create_blog_table.exs @@ -0,0 +1,17 @@ +defmodule Publishing.Repo.Migrations.CreateBlogTable do + use Ecto.Migration + + def change do + create table(:blog, primary_key: false) do + add :id, :uuid, primary_key: true, default: fragment("gen_random_uuid()") + add :fullname, :string + add :username, :string + add :bio, :string + add :donate_url, :text + add :avatar_url, :text + add :platform_id, references(:platform) + + timestamps() + end + end +end diff --git a/apps/publishing/priv/repo/migrations/20210421123442_create_article_table.exs b/apps/publishing/priv/repo/migrations/20210421123442_create_article_table.exs new file mode 100644 index 0000000..5a86896 --- /dev/null +++ b/apps/publishing/priv/repo/migrations/20210421123442_create_article_table.exs @@ -0,0 +1,17 @@ +defmodule Publishing.Repo.Migrations.CreateArticleTable do + use Ecto.Migration + + def change do + create table(:article, primary_key: false) do + add :id, :uuid, primary_key: true, default: fragment("gen_random_uuid()") + add :title, :string + add :preview, :text + add :url, :text + add :blog_id, references(:blog, type: :uuid) + + timestamps() + end + + create unique_index(:article, :url) + end +end diff --git a/apps/publishing/test/publishing/helper_test.exs b/apps/publishing/test/publishing/helper_test.exs new file mode 100644 index 0000000..264fa46 --- /dev/null +++ b/apps/publishing/test/publishing/helper_test.exs @@ -0,0 +1,4 @@ +defmodule Publishing.HelperTest do + use ExUnit.Case, async: true + doctest Publishing.Helper, import: true +end diff --git a/apps/publishing/test/publishing/integration/github_test.exs b/apps/publishing/test/publishing/integration/github_test.exs new file mode 100644 index 0000000..dc89b77 --- /dev/null +++ b/apps/publishing/test/publishing/integration/github_test.exs @@ -0,0 +1,35 @@ +defmodule Publishing.Integration.GithubTest do + use ExUnit.Case, async: true + doctest Publishing.Integration.Github, import: true + + alias Publishing.Integration.Github + alias Publishing.Tesla.Mock, as: TeslaMock + + import Mox + + @valid_url "https://github.com/felipelincoln/branchpage/blob/main/README.md" + @valid_raw_url "https://raw.githubusercontent.com/felipelincoln/branchpage/main/README.md" + @valid_body "# Documet title\n\nSome description" + + @invalid_url "https://github.com/" + @invalid_raw_url "" + + test "get_content/1 on valid url returns content" do + expect(TeslaMock, :call, &get_content/2) + + assert Github.get_content(@valid_url) == {:ok, @valid_body} + end + + test "get_content/1 non existing url returns 404" do + expect(TeslaMock, :call, &get_content/2) + + assert Github.get_content(@invalid_url) == {:error, 404} + end + + test "get_content/1 on empty string returns 404" do + assert Github.get_content("") == {:error, 404} + end + + defp get_content(%{url: @valid_raw_url}, _), do: {:ok, %{status: 200, body: @valid_body}} + defp get_content(%{url: @invalid_raw_url}, _), do: {:ok, %{status: 404}} +end diff --git a/apps/publishing/test/publishing/integration_test.exs b/apps/publishing/test/publishing/integration_test.exs new file mode 100644 index 0000000..55d3d61 --- /dev/null +++ b/apps/publishing/test/publishing/integration_test.exs @@ -0,0 +1,4 @@ +defmodule Publishing.IntegrationTest do + use ExUnit.Case, async: true + doctest Publishing.Integration, import: true +end diff --git a/apps/publishing/test/publishing/manage/article_test.exs b/apps/publishing/test/publishing/manage/article_test.exs new file mode 100644 index 0000000..ceef33a --- /dev/null +++ b/apps/publishing/test/publishing/manage/article_test.exs @@ -0,0 +1,37 @@ +defmodule Publishing.Manage.ArticleTest do + use ExUnit.Case, async: true + + import Publishing.ChangesetHelpers + alias Publishing.Manage.Article + + @valid_empty_attrs %{} + @valid_attrs %{title: "title", url: "url", blog_id: "blog_id"} + @invalid_cast_attrs %{title: 0, url: 0, blog_id: 0} + @invalid_length_attrs %{title: long_string()} + + test "changeset/2 with valid empty params" do + changeset = Article.changeset(%Article{}, @valid_empty_attrs) + assert changeset.valid? + end + + test "changeset/2 with valid params" do + changeset = Article.changeset(%Article{}, @valid_attrs) + assert changeset.valid? + end + + test "changeset/2 with invalid cast params" do + changeset = Article.changeset(%Article{}, @invalid_cast_attrs) + refute changeset.valid? + + assert %{title: [:cast]} = errors_on(changeset) + assert %{url: [:cast]} = errors_on(changeset) + assert %{blog_id: [:cast]} = errors_on(changeset) + end + + test "changeset/2 with invalid length params" do + changeset = Article.changeset(%Article{}, @invalid_length_attrs) + refute changeset.valid? + + assert %{title: [:length]} = errors_on(changeset) + end +end diff --git a/apps/publishing/test/publishing/manage/blog_test.exs b/apps/publishing/test/publishing/manage/blog_test.exs new file mode 100644 index 0000000..e5aba19 --- /dev/null +++ b/apps/publishing/test/publishing/manage/blog_test.exs @@ -0,0 +1,48 @@ +defmodule Publishing.Manage.BlogTest do + use ExUnit.Case, async: true + + import Publishing.ChangesetHelpers + alias Publishing.Manage.Blog + + @valid_empty_attrs %{} + @valid_attrs %{ + fullname: "fullname", + username: "username", + bio: "bio", + donate_url: "donate_url" + } + @invalid_cast_attrs %{fullname: 0, username: 0, bio: 0, donate_url: 0} + @invalid_length_attrs %{ + fullname: long_string(), + username: long_string(), + bio: long_string() + } + + test "changeset/2 with valid empty params" do + changeset = Blog.changeset(%Blog{}, @valid_empty_attrs) + assert changeset.valid? + end + + test "changeset/2 with valid params" do + changeset = Blog.changeset(%Blog{}, @valid_attrs) + assert changeset.valid? + end + + test "changeset/2 with invalid cast params" do + changeset = Blog.changeset(%Blog{}, @invalid_cast_attrs) + refute changeset.valid? + + assert %{fullname: [:cast]} = errors_on(changeset) + assert %{username: [:cast]} = errors_on(changeset) + assert %{bio: [:cast]} = errors_on(changeset) + end + + test "changeset/2 with invalid length params" do + changeset = Blog.changeset(%Blog{}, @invalid_length_attrs) + refute changeset.valid? + + assert %{fullname: [:length]} = errors_on(changeset) + assert %{username: [:length]} = errors_on(changeset) + assert %{bio: [:length]} = errors_on(changeset) + end +end diff --git a/apps/publishing/test/publishing/manage/platform_test.exs b/apps/publishing/test/publishing/manage/platform_test.exs new file mode 100644 index 0000000..08405bc --- /dev/null +++ b/apps/publishing/test/publishing/manage/platform_test.exs @@ -0,0 +1,41 @@ +defmodule Publishing.Manage.PlatformTest do + use Publishing.DataCase + + alias Publishing.Factory + alias Publishing.Manage.Platform + alias Publishing.Repo + + import Publishing.ChangesetHelpers + + @valid_empty_attrs %{} + @valid_attrs %{name: "test-name"} + @invalid_cast_attrs %{name: 0} + + test "changeset/2 with valid empty params" do + changeset = Platform.changeset(%Platform{}, @valid_empty_attrs) + assert changeset.valid? + end + + test "changeset/2 with valid params" do + changeset = Platform.changeset(%Platform{}, @valid_attrs) + assert changeset.valid? + end + + test "changeset/2 with invalid cast params" do + changeset = Platform.changeset(%Platform{}, @invalid_cast_attrs) + refute changeset.valid? + + assert %{name: [:cast]} = errors_on(changeset) + end + + test "changeset/2 with existing platform returns error on insert" do + _ = Factory.insert(:platform, name: "platform") + + {:error, changeset} = + %Platform{} + |> Platform.changeset(%{name: "platform"}) + |> Repo.insert() + + assert %{name: [nil]} = errors_on(changeset) + end +end diff --git a/apps/publishing/test/publishing/manage_test.exs b/apps/publishing/test/publishing/manage_test.exs new file mode 100644 index 0000000..8f8ce9d --- /dev/null +++ b/apps/publishing/test/publishing/manage_test.exs @@ -0,0 +1,176 @@ +defmodule Publishing.ManageTest do + use Publishing.DataCase + + alias Publishing.Factory + alias Publishing.Manage + alias Publishing.Manage.{Article, Blog} + alias Publishing.Tesla.Mock, as: TeslaMock + + import Mox + + @valid_url "https://github.com/felipelincoln/branchpage/blob/main/README.md" + @valid_raw_url "https://raw.githubusercontent.com/felipelincoln/branchpage/main/README.md" + @valid_username "felipelincoln" + @valid_body "# Document title\n\nSome description" + @valid_title "Document title" + @valid_html "

\nSome description

\n" + @valid_preview "

\nSome ...

\n" + + @updated_body "# Updated document title\n\nTest" + @updated_title "Updated document title" + @updated_html "

\nTest

\n" + @updated_preview "

\nTest

\n" + + @invalid_404_url "https://github.com/f/b/blob/repo/a.md" + @invalid_404_raw_url "https://raw.githubusercontent.com/f/b/repo/a.md" + @invalid_500_url "https://github.com/f/b/blob/repo/500.md" + @invalid_500_raw_url "https://raw.githubusercontent.com/f/b/repo/500.md" + + setup :create_github_platform + + test "build_article/1 on invalid url returns error tuple" do + assert {:error, "Invalid scheme. Use http or https"} = Manage.build_article("") + + assert {:error, "Invalid extension. Must be .md"} = Manage.build_article("https://") + + assert {:error, "Not integrated with integration.com yet"} = + Manage.build_article("https://integration.com/f/b/blob/b.md") + + assert {:error, "Invalid github.com resource"} = + Manage.build_article("https://github.com/test.md") + end + + test "build_article/1 non existing url returns 404" do + expect(TeslaMock, :call, &raw/2) + + assert {:error, "Page not found"} = Manage.build_article(@invalid_404_url) + end + + test "build_article/1 on invalid url returns status code" do + expect(TeslaMock, :call, &raw/2) + + assert {:error, "Failed to retrieve page content. (error 500)"} = + Manage.build_article(@invalid_500_url) + end + + test "build_article/1 on valid url returns article" do + expect(TeslaMock, :call, &raw/2) + + assert {:ok, %Article{} = article} = Manage.build_article(@valid_url) + + assert article.url == @valid_url + assert article.title == @valid_title + assert article.body == @valid_html + assert article.preview == @valid_preview + assert article.blog == %Blog{username: @valid_username} + end + + test "save_article/1 saves an article" do + expect(TeslaMock, :call, &raw/2) + {:ok, %Article{} = article} = Manage.build_article(@valid_url) + + assert {:ok, %Article{}} = Manage.save_article(article) + end + + test "save_article/1 on existing article returns error" do + expect(TeslaMock, :call, &raw/2) + {:ok, %Article{} = article} = Manage.build_article(@valid_url) + + assert {:ok, %Article{}} = Manage.save_article(article) + assert {:error, "This article has already been published!"} = Manage.save_article(article) + end + + test "load_article!/2 loads an article from database" do + expect(TeslaMock, :call, 2, &raw/2) + {:ok, %Article{} = article} = Manage.build_article(@valid_url) + {:ok, %Article{id: id}} = Manage.save_article(article) + + assert (%Article{} = article) = Manage.load_article!(@valid_username, id) + assert article.url == @valid_url + assert article.title == @valid_title + assert article.body == @valid_html + assert article.preview == @valid_preview + assert %Blog{username: @valid_username} = article.blog + end + + test "load_article!/2 updates title and preview" do + expect(TeslaMock, :call, &raw/2) + expect(TeslaMock, :call, &raw_updated/2) + + {:ok, %Article{} = article} = Manage.build_article(@valid_url) + {:ok, %Article{id: id}} = Manage.save_article(article) + + assert (%Article{} = article) = Manage.load_article!(@valid_username, id) + assert article.url == @valid_url + assert %Blog{username: @valid_username} = article.blog + + assert article.body == @updated_html + assert article.title == @updated_title + assert article.preview == @updated_preview + end + + test "load_article!/2 deletes article if deleted from integration" do + expect(TeslaMock, :call, &raw/2) + expect(TeslaMock, :call, &raw_deleted/2) + + {:ok, %Article{} = article} = Manage.build_article(@valid_url) + + {:ok, %Article{id: id}} = Manage.save_article(article) + + assert %Article{} = Publishing.Repo.get(Article, id) + assert_raise Publishing.PageNotFound, fn -> Manage.load_article!(@valid_username, id) end + assert Publishing.Repo.get(Article, id) == nil + end + + test "load_article!/2 on non existing article raises PageNotFound" do + assert_raise Publishing.PageNotFound, fn -> Manage.load_article!(@valid_username, "") end + end + + test "load_article!/2 on non matching username raises PageNotFound" do + expect(TeslaMock, :call, 2, &raw/2) + {:ok, %Article{} = article} = Manage.build_article(@valid_url) + {:ok, %Article{}} = Manage.save_article(article) + + assert_raise Publishing.PageNotFound, fn -> Manage.load_article!("invalid", "") end + end + + test "load_blog!/1 on non existing username raises PageNotFound" do + assert_raise Publishing.PageNotFound, fn -> Manage.load_blog!("") end + end + + test "load_blog!/1 return blogs with preloaded articles", %{platform: platform} do + expect(TeslaMock, :call, &api/2) + + blog = Factory.insert(:blog, username: "test", platform_id: platform.id) + _articles = Factory.insert_pair(:article, blog_id: blog.id) + + assert (%Blog{} = blog) = Manage.load_blog!("test") + assert [_, _] = blog.articles + end + + defp raw_deleted(%{url: @valid_raw_url}, _), do: {:ok, %{status: 404}} + defp raw_updated(%{url: @valid_raw_url}, _), do: {:ok, %{status: 200, body: @updated_body}} + defp raw(%{url: @valid_raw_url}, _), do: {:ok, %{status: 200, body: @valid_body}} + defp raw(%{url: @invalid_404_raw_url}, _), do: {:ok, %{status: 404}} + defp raw(%{url: @invalid_500_raw_url}, _), do: {:ok, %{status: 500}} + + defp api(%{url: "https://api.github.com/graphql"}, _) do + response = %{ + "data" => %{ + "user" => %{ + "name" => "as", + "bio" => "as", + "avatarUrl" => "adad" + } + } + } + + {:ok, %{body: response, status: 200}} + end + + defp create_github_platform(_) do + platform = Factory.insert(:platform, name: "https://github.com/") + + %{platform: platform} + end +end diff --git a/apps/publishing/test/publishing/markdown_test.exs b/apps/publishing/test/publishing/markdown_test.exs new file mode 100644 index 0000000..a267d5a --- /dev/null +++ b/apps/publishing/test/publishing/markdown_test.exs @@ -0,0 +1,4 @@ +defmodule Publishing.MarkdownTest do + use ExUnit.Case, async: true + doctest Publishing.Markdown, import: true +end diff --git a/apps/publishing/test/support/changeset_helpers.ex b/apps/publishing/test/support/changeset_helpers.ex new file mode 100644 index 0000000..a236c63 --- /dev/null +++ b/apps/publishing/test/support/changeset_helpers.ex @@ -0,0 +1,20 @@ +defmodule Publishing.ChangesetHelpers do + @moduledoc """ + Helpers for testing ecto changesets. + """ + + @doc """ + Transforms changeset errors into a map of validation keys. + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors( + changeset, + fn {_msg, opts} -> opts[:validation] end + ) + end + + @doc """ + Generate a 256 byte sized string. + """ + def long_string, do: String.duplicate("a", 256) +end diff --git a/apps/publishing/test/support/data_case.ex b/apps/publishing/test/support/data_case.ex new file mode 100644 index 0000000..dc07588 --- /dev/null +++ b/apps/publishing/test/support/data_case.ex @@ -0,0 +1,20 @@ +defmodule Publishing.DataCase do + @moduledoc """ + Module for tests requiring database integration + """ + + use ExUnit.CaseTemplate + + alias Ecto.Adapters.SQL.Sandbox + alias Publishing.Repo + + setup context do + :ok = Sandbox.checkout(Repo) + + unless context[:async] do + Sandbox.mode(Repo, {:shared, self()}) + end + + :ok + end +end diff --git a/apps/publishing/test/support/factory.ex b/apps/publishing/test/support/factory.ex new file mode 100644 index 0000000..12e1b99 --- /dev/null +++ b/apps/publishing/test/support/factory.ex @@ -0,0 +1,13 @@ +defmodule Publishing.Factory do + @moduledoc """ + Module for teste data fabrication + """ + + use ExMachina.Ecto, repo: Publishing.Repo + + alias Publishing.Manage.{Article, Blog, Platform} + + def article_factory, do: %Article{} + def blog_factory, do: %Blog{} + def platform_factory, do: %Platform{} +end diff --git a/apps/publishing/test/test_helper.exs b/apps/publishing/test/test_helper.exs new file mode 100644 index 0000000..f7ac122 --- /dev/null +++ b/apps/publishing/test/test_helper.exs @@ -0,0 +1,3 @@ +ExUnit.start() + +Mox.defmock(Publishing.Tesla.Mock, for: Tesla.Adapter) diff --git a/apps/web/assets/.babelrc b/apps/web/assets/.babelrc new file mode 100644 index 0000000..45297a2 --- /dev/null +++ b/apps/web/assets/.babelrc @@ -0,0 +1,9 @@ +{ + "plugins": [ + ["prismjs", { + "languages": [], + "theme": "okaidia", + "css": true + }] + ] +} diff --git a/apps/web/assets/css/base.css b/apps/web/assets/css/base.css index ebf157f..9eba049 100644 --- a/apps/web/assets/css/base.css +++ b/apps/web/assets/css/base.css @@ -27,6 +27,10 @@ @apply flex items-center justify-between px-mb sm:px-sc mb-mb sm:mb-sc h-16 sm:h-20 } + .flash { + @apply sm:float-right mx-mb mb-mb sm:mr-sc py-2 px-4 border border-gray-200 shadow-md + } + .navbar-side-item { @apply w-1/5 min-w-min whitespace-nowrap flex items-center h-16 } @@ -34,4 +38,994 @@ .container { @apply max-w-screen-sm sm:mx-auto box-content px-mb sm:px-sc } + + .input { + @apply w-full border-b border-gray-300 focus:border-gray-800 focus:outline-none p-0 sm:p-1 + } +} + +.markdown-body .octicon { + display: inline-block; + fill: currentColor; + vertical-align: text-bottom; +} + +.markdown-body .anchor { + float: left; + line-height: 1; + margin-left: -20px; + padding-right: 4px; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: #1b1f23; + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1:hover .anchor .octicon-link:before, +.markdown-body h2:hover .anchor .octicon-link:before, +.markdown-body h3:hover .anchor .octicon-link:before, +.markdown-body h4:hover .anchor .octicon-link:before, +.markdown-body h5:hover .anchor .octicon-link:before, +.markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: ' '; + display: inline-block; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath fill-rule='evenodd' d='M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z'%3E%3C/path%3E%3C/svg%3E"); +}.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + line-height: 1.5; + color: #24292e; + font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +.markdown-body details { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body a { + background-color: initial; +} + +.markdown-body a:active, +.markdown-body a:hover { + outline-width: 0; +} + +.markdown-body strong { + font-weight: inherit; + font-weight: bolder; +} + +.markdown-body h1 { + font-size: 2em; + margin: .67em 0; +} + +.markdown-body img { + border-style: none; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre { + font-family: monospace,monospace; + font-size: 1em; +} + +.markdown-body hr { + box-sizing: initial; + height: 0; + overflow: visible; +} + +.markdown-body input { + font: inherit; + margin: 0; +} + +.markdown-body input { + overflow: visible; +} + +.markdown-body [type=checkbox] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body * { + box-sizing: border-box; +} + +.markdown-body input { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.markdown-body a { + color: #0366d6; + text-decoration: none; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body strong { + font-weight: 600; +} + +.markdown-body hr { + height: 0; + margin: 15px 0; + overflow: hidden; + background: transparent; + border: 0; + border-bottom: 1px solid #dfe2e5; +} + +.markdown-body hr:after, +.markdown-body hr:before { + display: table; + content: ""; +} + +.markdown-body hr:after { + clear: both; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace; + line-height: 10px; + color: #444d56; + vertical-align: middle; + background-color: #fafbfc; + border: 1px solid #d1d5da; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #d1d5da; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body h1 { + font-size: 32px; +} + +.markdown-body h1, +.markdown-body h2 { + font-weight: 600; +} + +.markdown-body h2 { + font-size: 24px; +} + +.markdown-body h3 { + font-size: 20px; +} + +.markdown-body h3, +.markdown-body h4 { + font-weight: 600; +} + +.markdown-body h4 { + font-size: 16px; +} + +.markdown-body h5 { + font-size: 14px; +} + +.markdown-body h5, +.markdown-body h6 { + font-weight: 600; +} + +.markdown-body h6 { + font-size: 12px; +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; +} + +.markdown-body ol, +.markdown-body ul { + padding-left: 0; + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ol ol ol, +.markdown-body ol ul ol, +.markdown-body ul ol ol, +.markdown-body ul ul ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body code, +.markdown-body pre { + font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body input::-webkit-inner-spin-button, +.markdown-body input::-webkit-outer-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.markdown-body :checked+.radio-label { + position: relative; + z-index: 1; + border-color: #0366d6; +} + +.markdown-body .border { + border: 1px solid #e1e4e8!important; +} + +.markdown-body .border-0 { + border: 0!important; +} + +.markdown-body .border-bottom { + border-bottom: 1px solid #e1e4e8!important; +} + +.markdown-body .rounded-1 { + border-radius: 3px!important; +} + +.markdown-body .bg-white { + background-color: #fff!important; +} + +.markdown-body .bg-gray-light { + background-color: #fafbfc!important; +} + +.markdown-body .text-gray-light { + color: #6a737d!important; +} + +.markdown-body .mb-0 { + margin-bottom: 0!important; +} + +.markdown-body .my-2 { + margin-top: 8px!important; + margin-bottom: 8px!important; +} + +.markdown-body .pl-0 { + padding-left: 0!important; +} + +.markdown-body .py-0 { + padding-top: 0!important; + padding-bottom: 0!important; +} + +.markdown-body .pl-1 { + padding-left: 4px!important; +} + +.markdown-body .pl-2 { + padding-left: 8px!important; +} + +.markdown-body .py-2 { + padding-top: 8px!important; + padding-bottom: 8px!important; +} + +.markdown-body .pl-3, +.markdown-body .px-3 { + padding-left: 16px!important; +} + +.markdown-body .px-3 { + padding-right: 16px!important; +} + +.markdown-body .pl-4 { + padding-left: 24px!important; +} + +.markdown-body .pl-5 { + padding-left: 32px!important; +} + +.markdown-body .pl-6 { + padding-left: 40px!important; +} + +.markdown-body .f6 { + font-size: 12px!important; +} + +.markdown-body .lh-condensed { + line-height: 1.25!important; +} + +.markdown-body .text-bold { + font-weight: 600!important; +} + +.markdown-body .pl-c { + color: #6a737d; +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: #005cc5; +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: #6f42c1; +} + +.markdown-body .pl-s .pl-s1, +.markdown-body .pl-smi { + color: #24292e; +} + +.markdown-body .pl-ent { + color: #22863a; +} + +.markdown-body .pl-k { + color: #d73a49; +} + +.markdown-body .pl-pds, +.markdown-body .pl-s, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sra, +.markdown-body .pl-sr .pl-sre { + color: #032f62; +} + +.markdown-body .pl-smw, +.markdown-body .pl-v { + color: #e36209; +} + +.markdown-body .pl-bu { + color: #b31d28; +} + +.markdown-body .pl-ii { + color: #fafbfc; + background-color: #b31d28; +} + +.markdown-body .pl-c2 { + color: #fafbfc; + background-color: #d73a49; +} + +.markdown-body .pl-c2:before { + content: "^M"; +} + +.markdown-body .pl-sr .pl-cce { + font-weight: 700; + color: #22863a; +} + +.markdown-body .pl-ml { + color: #735c0f; +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: 700; + color: #005cc5; +} + +.markdown-body .pl-mi { + font-style: italic; + color: #24292e; +} + +.markdown-body .pl-mb { + font-weight: 700; + color: #24292e; +} + +.markdown-body .pl-md { + color: #b31d28; + background-color: #ffeef0; +} + +.markdown-body .pl-mi1 { + color: #22863a; + background-color: #f0fff4; +} + +.markdown-body .pl-mc { + color: #e36209; + background-color: #ffebda; +} + +.markdown-body .pl-mi2 { + color: #f6f8fa; + background-color: #005cc5; +} + +.markdown-body .pl-mdr { + font-weight: 700; + color: #6f42c1; +} + +.markdown-body .pl-ba { + color: #586069; +} + +.markdown-body .pl-sg { + color: #959da5; +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: #032f62; +} + +.markdown-body .mb-0 { + margin-bottom: 0!important; +} + +.markdown-body .my-2 { + margin-bottom: 8px!important; +} + +.markdown-body .my-2 { + margin-top: 8px!important; +} + +.markdown-body .pl-0 { + padding-left: 0!important; +} + +.markdown-body .py-0 { + padding-top: 0!important; + padding-bottom: 0!important; +} + +.markdown-body .pl-1 { + padding-left: 4px!important; +} + +.markdown-body .pl-2 { + padding-left: 8px!important; +} + +.markdown-body .py-2 { + padding-top: 8px!important; + padding-bottom: 8px!important; +} + +.markdown-body .pl-3 { + padding-left: 16px!important; +} + +.markdown-body .pl-4 { + padding-left: 24px!important; +} + +.markdown-body .pl-5 { + padding-left: 32px!important; +} + +.markdown-body .pl-6 { + padding-left: 40px!important; +} + +.markdown-body .pl-7 { + padding-left: 48px!important; +} + +.markdown-body .pl-8 { + padding-left: 64px!important; +} + +.markdown-body .pl-9 { + padding-left: 80px!important; +} + +.markdown-body .pl-10 { + padding-left: 96px!important; +} + +.markdown-body .pl-11 { + padding-left: 112px!important; +} + +.markdown-body .pl-12 { + padding-left: 128px!important; +} + +.markdown-body hr { + border-bottom-color: #eee; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace; + line-height: 10px; + color: #444d56; + vertical-align: middle; + background-color: #fafbfc; + border: 1px solid #d1d5da; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #d1d5da; +} + +.markdown-body:after, +.markdown-body:before { + display: table; + content: ""; +} + +.markdown-body:after { + clear: both; +} + +.markdown-body>:first-child { + margin-top: 0!important; +} + +.markdown-body>:last-child { + margin-bottom: 0!important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body blockquote, +.markdown-body details, +.markdown-body dl, +.markdown-body ol, +.markdown-body p, +.markdown-body pre, +.markdown-body table, +.markdown-body ul { + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body hr { + height: .25em; + padding: 0; + margin: 24px 0; + background-color: #e1e4e8; + border: 0; +} + +.markdown-body blockquote { + padding: 0 1em; + color: #6a737d; + border-left: .25em solid #dfe2e5; +} + +.markdown-body blockquote>:first-child { + margin-top: 0; +} + +.markdown-body blockquote>:last-child { + margin-bottom: 0; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; +} + +.markdown-body h1 { + font-size: 2em; +} + +.markdown-body h1, +.markdown-body h2 { + padding-bottom: .3em; + border-bottom: 1px solid #eaecef; +} + +.markdown-body h2 { + font-size: 1.5em; +} + +.markdown-body h3 { + font-size: 1.25em; +} + +.markdown-body h4 { + font-size: 1em; +} + +.markdown-body h5 { + font-size: .875em; +} + +.markdown-body h6 { + font-size: .85em; + color: #6a737d; +} + +.markdown-body ol, +.markdown-body ul { + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ol ul, +.markdown-body ul ol, +.markdown-body ul ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li { + word-wrap: break-all; +} + +.markdown-body li>p { + margin-top: 16px; +} + +.markdown-body li+li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: 600; +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body table { + display: block; + width: 100%; + overflow: auto; +} + +.markdown-body table th { + font-weight: 600; +} + +.markdown-body table td, +.markdown-body table th { + padding: 6px 13px; + border: 1px solid #dfe2e5; +} + +.markdown-body table tr { + background-color: #fff; + border-top: 1px solid #c6cbd1; +} + +.markdown-body table tr:nth-child(2n) { + background-color: #f6f8fa; +} + +.markdown-body img { + max-width: 100%; + box-sizing: initial; + background-color: #fff; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body code { + padding: .2em .4em; + margin: 0; + font-size: 85%; + background-color: rgba(27,31,35,.05); + border-radius: 3px; +} + +.markdown-body pre { + word-wrap: normal; +} + +.markdown-body pre>code { + padding: 0; + margin: 0; + font-size: 100%; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #f6f8fa; + border-radius: 3px; +} + +.markdown-body pre code { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: initial; + border: 0; +} + +.markdown-body .commit-tease-sha { + display: inline-block; + font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace; + font-size: 90%; + color: #444d56; +} + +.markdown-body .full-commit .btn-outline:not(:disabled):hover { + color: #005cc5; + border-color: #005cc5; +} + +.markdown-body .blob-wrapper { + overflow-x: auto; + overflow-y: hidden; +} + +.markdown-body .blob-wrapper-embedded { + max-height: 240px; + overflow-y: auto; +} + +.markdown-body .blob-num { + width: 1%; + min-width: 50px; + padding-right: 10px; + padding-left: 10px; + font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace; + font-size: 12px; + line-height: 20px; + color: rgba(27,31,35,.3); + text-align: right; + white-space: nowrap; + vertical-align: top; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.markdown-body .blob-num:hover { + color: rgba(27,31,35,.6); +} + +.markdown-body .blob-num:before { + content: attr(data-line-number); +} + +.markdown-body .blob-code { + position: relative; + padding-right: 10px; + padding-left: 10px; + line-height: 20px; + vertical-align: top; +} + +.markdown-body .blob-code-inner { + overflow: visible; + font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace; + font-size: 12px; + color: #24292e; + word-wrap: normal; + white-space: pre; +} + +.markdown-body .pl-token.active, +.markdown-body .pl-token:hover { + cursor: pointer; + background: #ffea7f; +} + +.markdown-body .tab-size[data-tab-size="1"] { + -moz-tab-size: 1; + tab-size: 1; +} + +.markdown-body .tab-size[data-tab-size="2"] { + -moz-tab-size: 2; + tab-size: 2; +} + +.markdown-body .tab-size[data-tab-size="3"] { + -moz-tab-size: 3; + tab-size: 3; +} + +.markdown-body .tab-size[data-tab-size="4"] { + -moz-tab-size: 4; + tab-size: 4; +} + +.markdown-body .tab-size[data-tab-size="5"] { + -moz-tab-size: 5; + tab-size: 5; +} + +.markdown-body .tab-size[data-tab-size="6"] { + -moz-tab-size: 6; + tab-size: 6; +} + +.markdown-body .tab-size[data-tab-size="7"] { + -moz-tab-size: 7; + tab-size: 7; +} + +.markdown-body .tab-size[data-tab-size="8"] { + -moz-tab-size: 8; + tab-size: 8; +} + +.markdown-body .tab-size[data-tab-size="9"] { + -moz-tab-size: 9; + tab-size: 9; +} + +.markdown-body .tab-size[data-tab-size="10"] { + -moz-tab-size: 10; + tab-size: 10; +} + +.markdown-body .tab-size[data-tab-size="11"] { + -moz-tab-size: 11; + tab-size: 11; +} + +.markdown-body .tab-size[data-tab-size="12"] { + -moz-tab-size: 12; + tab-size: 12; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item+.task-list-item { + margin-top: 3px; +} + +.markdown-body .task-list-item input { + margin: 0 .2em .25em -1.6em; + vertical-align: middle; } diff --git a/apps/web/assets/js/main.js b/apps/web/assets/js/main.js index f595c9c..7efa39f 100644 --- a/apps/web/assets/js/main.js +++ b/apps/web/assets/js/main.js @@ -2,9 +2,17 @@ import "../css/base.css" import {Socket} from "phoenix" import LiveSocket from "phoenix_live_view" +import Prism from "prismjs" + +let Hooks = {} +Hooks.CodeHighlight = { + mounted(){ + this.handleEvent("highlightAll", () => Prism.highlightAll()); + } +} let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") -let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) +let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}}) liveSocket.connect() diff --git a/apps/web/assets/package-lock.json b/apps/web/assets/package-lock.json index 8f02121..0b556ad 100644 --- a/apps/web/assets/package-lock.json +++ b/apps/web/assets/package-lock.json @@ -1441,6 +1441,12 @@ "object.assign": "^4.1.0" } }, + "babel-plugin-prismjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-prismjs/-/babel-plugin-prismjs-2.0.1.tgz", + "integrity": "sha512-GqQGa3xX3Z2ft97oDbGvEFoxD8nKqb3ZVszrOc5H7icnEUA56BIjVYm86hfZZA82uuHLwTIfCXbEKzKG1BzKzg==", + "dev": true + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -1568,6 +1574,17 @@ "tslib": "^1.9.0" } }, + "clipboard": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.8.tgz", + "integrity": "sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ==", + "optional": true, + "requires": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, "clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -1769,6 +1786,12 @@ "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", "dev": true }, + "delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "optional": true + }, "detective": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", @@ -2142,6 +2165,15 @@ "slash": "^3.0.0" } }, + "good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=", + "optional": true, + "requires": { + "delegate": "^3.1.2" + } + }, "graceful-fs": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", @@ -2938,6 +2970,14 @@ "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", "dev": true }, + "prismjs": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz", + "integrity": "sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA==", + "requires": { + "clipboard": "^2.0.0" + } + }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -3130,6 +3170,12 @@ "ajv-keywords": "^3.5.2" } }, + "select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=", + "optional": true + }, "semver": { "version": "7.3.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", @@ -3341,6 +3387,12 @@ "terser": "^5.5.1" } }, + "tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "optional": true + }, "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", diff --git a/apps/web/assets/package.json b/apps/web/assets/package.json index a5476d6..a45315f 100644 --- a/apps/web/assets/package.json +++ b/apps/web/assets/package.json @@ -7,13 +7,15 @@ "dependencies": { "phoenix": "file:../../../deps/phoenix", "phoenix_html": "file:../../../deps/phoenix_html", - "phoenix_live_view": "file:../../../deps/phoenix_live_view" + "phoenix_live_view": "file:../../../deps/phoenix_live_view", + "prismjs": "^1.23.0" }, "devDependencies": { "@babel/core": "^7.12.10", "@babel/preset-env": "^7.12.11", "autoprefixer": "^10.2.4", "babel-loader": "^8.2.2", + "babel-plugin-prismjs": "^2.0.1", "copy-webpack-plugin": "^7.0.0", "css-loader": "^5.0.1", "mini-css-extract-plugin": "^1.3.5", diff --git a/apps/web/lib/web/live/article_live.ex b/apps/web/lib/web/live/article_live.ex index 21a8714..226f166 100644 --- a/apps/web/lib/web/live/article_live.ex +++ b/apps/web/lib/web/live/article_live.ex @@ -2,7 +2,11 @@ defmodule Web.ArticleLive do @moduledoc false use Phoenix.LiveView + import Phoenix.HTML, only: [raw: 1] + import Publishing.Helper, only: [format_date: 1] + + alias Publishing.Manage @meta %{ title: "branchpage title", @@ -11,30 +15,19 @@ defmodule Web.ArticleLive do } @impl true - def mount(%{"username" => _username, "article" => _}, _session, socket) do + def mount(%{"username" => username, "article" => id}, _session, socket) do + article = Manage.load_article!(username, id) + name = "Felipe Lincoln" - date = Timex.today() |> Timex.format!("%b %e", :strftime) - title = "Lorem ipsum dolor sit amet. Consectetur adipiscing elit." - - body_html = """ -

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ornare eu mi eget lacinia. Maecenas tincidunt risus vel mi vehicula, sit amet varius ligula porta. Pellentesque id ex viverra, pellentesque neque eget, aliquet magna. Sed viverra egestas pulvinar. Mauris luctus egestas ante, et facilisis ligula vulputate sit amet. Nunc ut ipsum velit. Vivamus finibus scelerisque nibh, ac dapibus mauris finibus ut. Sed consequat nibh at pharetra ornare.

-
- -
-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ornare eu mi eget lacinia. Maecenas tincidunt risus vel mi vehicula, sit amet varius ligula porta. Pellentesque id ex viverra, pellentesque neque eget, aliquet magna. Sed viverra egestas pulvinar. Mauris luctus egestas ante, et facilisis ligula vulputate sit amet. Nunc ut ipsum velit. Vivamus finibus scelerisque nibh, ac dapibus mauris finibus ut. Sed consequat nibh at pharetra ornare.

-

Vivamus nunc dui, pellentesque quis nulla vel, laoreet facilisis sem. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Duis libero nibh, varius ac lacus nec, gravida facilisis mi. Mauris urna libero, laoreet sit amet gravida ac, pharetra eu magna. Morbi iaculis egestas interdum. Donec a mi sit amet ipsum consequat malesuada. Nam magna erat, tempus eget tortor nec, fringilla tincidunt odio. Mauris finibus eget mi vitae aliquet. Maecenas a est vitae urna vestibulum vulputate id at urna. Donec id feugiat orci, vel tincidunt nunc. Quisque dapibus libero porta fringilla consectetur.

-

Aenean nunc augue, bibendum vitae aliquet sed, rhoncus at mauris. Morbi eleifend ultrices commodo. Donec bibendum justo a ultricies vehicula. Sed iaculis enim et nisl auctor, nec ultricies tortor suscipit. Phasellus non convallis dui. Nullam scelerisque quis tortor nec sollicitudin. Pellentesque tristique pretium nunc a tincidunt. Fusce sodales nunc sapien, at scelerisque diam fermentum sit amet. Fusce eu arcu magna. Curabitur ullamcorper elementum nisi vitae efficitur. Curabitur in venenatis magna. Fusce suscipit ex lectus, at iaculis est efficitur nec. Cras vitae dolor placerat, placerat sapien sit amet, tempor magna. Fusce sagittis leo sit amet magna venenatis fringilla. Phasellus euismod purus ex, quis egestas odio elementum sed.

-

Suspendisse semper diam sit amet enim placerat, nec suscipit dolor efficitur. Suspendisse nec erat vitae felis dignissim rhoncus porta sit amet ipsum. Quisque consequat leo nec est hendrerit aliquet. Donec accumsan sem ut sapien placerat facilisis. Sed pharetra efficitur mi, quis condimentum ipsum egestas quis. Duis ultrices, nibh ut placerat tincidunt, elit dolor tincidunt orci, id ornare metus urna accumsan ex. Duis quis ullamcorper ex, blandit sagittis metus. Quisque urna mi, condimentum quis tortor non, maximus maximus felis. Suspendisse accumsan augue purus, ut pharetra nunc congue at.

-

Fusce sit amet euismod arcu. In hac habitasse platea dictumst. Morbi non ullamcorper turpis. Curabitur rutrum venenatis nisl, sit amet pharetra erat rhoncus non. Suspendisse et arcu vitae eros bibendum porta convallis vel dolor. Maecenas eget sodales ex. Vestibulum feugiat purus vitae risus ultrices, sed vehicula justo posuere. Nulla commodo, elit sed tincidunt facilisis, nunc nunc posuere ligula, bibendum auctor mi ante et augue. Pellentesque finibus facilisis ex, eget volutpat turpis aliquet ac. Nullam lectus erat, finibus sed purus eu, maximus commodo enim. Curabitur tincidunt justo nec justo laoreet sodales vitae a nibh.

- """ + username = article.blog.username socket = socket - |> assign(@meta) + |> assign(:meta, @meta) |> assign(:name, name) - |> assign(:date, date) - |> assign(:title, title) - |> assign(:body_html, body_html) + |> assign(:username, username) + |> assign(:article, article) + |> push_event("highlightAll", %{}) {:ok, socket} end diff --git a/apps/web/lib/web/live/article_live.html.leex b/apps/web/lib/web/live/article_live.html.leex index b94a4c7..5134df8 100644 --- a/apps/web/lib/web/live/article_live.html.leex +++ b/apps/web/lib/web/live/article_live.html.leex @@ -1,11 +1,11 @@ @@ -13,10 +13,10 @@
- -

<%= @title %>

-

<%= @date %><%= @name %>

+ +

<%= @article.title %>

+

<%= @article.inserted_at %><%= @name %>

-
<%= raw @body_html %>
+
<%= raw @article.body %>
diff --git a/apps/web/lib/web/live/blog_live.ex b/apps/web/lib/web/live/blog_live.ex index de27f4c..7920651 100644 --- a/apps/web/lib/web/live/blog_live.ex +++ b/apps/web/lib/web/live/blog_live.ex @@ -2,7 +2,11 @@ defmodule Web.BlogLive do @moduledoc false use Phoenix.LiveView + + alias Publishing.Manage + import Phoenix.HTML, only: [raw: 1] + import Publishing.Helper, only: [format_date: 1] @meta %{ title: "branchpage title", @@ -11,29 +15,16 @@ defmodule Web.BlogLive do } @impl true - def mount(%{"username" => _username}, _session, socket) do - articles = [ - %{ - title: "Lorem ipsum dolor sit amet. Consectetur adipiscing elit.", - date: Timex.today() |> Timex.format!("%b %e", :strftime), - body_html: """ -

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ornare eu mi eget lacinia. Maecenas tincidunt risus vel mi vehicula, sit amet varius ligula porta. Pellentesque id ex viverra, pellentesque neque eget, aliquet magna. Sed viverra egestas pulvinar. Mauris luctus egestas ante, et facilisis ligula vulputate sit amet. Nunc ut ipsum velit. Vivamus finibus scelerisque nibh, ac dapibus mauris finibus ut. Sed consequat nibh at pharetra ornare.

-

Vivamus nunc dui, pellentesque quis nulla vel, laoreet facilisis sem. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Duis libero nibh, varius ac lacus nec, gravida facilisis mi. Mauris urna libero ...

- """ - }, - %{ - title: "Consectetur adipiscing elit.", - date: Timex.today() |> Timex.format!("%b %e", :strftime), - body_html: """ -

Vivamus nunc dui, pellentesque quis nulla vel, laoreet facilisis sem. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Duis libero nibh, varius ac lacus nec, gravida facilisis mi. Mauris urna libero, laoreet sit amet gravida ac, pharetra eu magna. Morbi iaculis egestas interdum. Donec a mi sit amet ipsum consequat malesuada. Nam magna erat, tempus eget tortor nec, fringilla tincidunt odio. Mauris finibus eget mi vitae aliquet. Maecenas a est vitae urna vestibulum vulputate id at urna. Donec id feugiat orci, vel tincidunt nunc. Quisque dapibus libero porta fringilla consectetur ...

- """ - } - ] + def mount(%{"username" => username}, _session, socket) do + blog = Manage.load_blog!(username) + articles = blog.articles socket = socket - |> assign(@meta) + |> assign(:meta, @meta) + |> assign(:blog, blog) |> assign(:articles, articles) + |> push_event("highlightAll", %{}) {:ok, socket} end diff --git a/apps/web/lib/web/live/blog_live.html.leex b/apps/web/lib/web/live/blog_live.html.leex index 8631a21..c7acb14 100644 --- a/apps/web/lib/web/live/blog_live.html.leex +++ b/apps/web/lib/web/live/blog_live.html.leex @@ -5,22 +5,23 @@
-
-

Fernando Lincoln

-

Hi, I am lorem ipsum dolor sit amet consectetur adipiscing elit.

+
+

<%= @blog.fullname %>

+

<%= @blog.bio %>

-
+
<%= for a <- @articles do %> + <% link = "/#{@blog.username}/#{a.id}" %>
<% end %> diff --git a/apps/web/lib/web/live/home_live.ex b/apps/web/lib/web/live/home_live.ex index 8c50c8b..981cb89 100644 --- a/apps/web/lib/web/live/home_live.ex +++ b/apps/web/lib/web/live/home_live.ex @@ -2,7 +2,6 @@ defmodule Web.HomeLive do @moduledoc false use Phoenix.LiveView - import Phoenix.HTML, only: [raw: 1] @meta %{ title: "branchpage title", @@ -12,32 +11,9 @@ defmodule Web.HomeLive do @impl true def mount(_params, _session, socket) do - articles = [ - %{ - title: "Lorem ipsum dolor sit amet. Consectetur adipiscing elit.", - date: Timex.today() |> Timex.format!("%b %e", :strftime), - body_html: """ -

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ornare eu mi eget lacinia. Maecenas tincidunt risus vel mi vehicula, sit amet varius ligula porta. Pellentesque id ex viverra, pellentesque neque eget, aliquet magna. Sed viverra egestas pulvinar. Mauris luctus egestas ante, et facilisis ligula vulputate.

-
- -
- """ - }, - %{ - title: "Consectetur adipiscing elit.", - date: Timex.today() |> Timex.format!("%b %e", :strftime), - body_html: """ -

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ornare eu mi eget lacinia. Maecenas tincidunt risus vel mi vehicula, sit amet varius ligula porta. Pellentesque id ex viverra, pellentesque neque eget, aliquet magna. Sed viverra egestas pulvinar. Mauris luctus egestas ante, et facilisis ligula vulputate sit amet. Nunc ut ipsum velit. Vivamus finibus scelerisque nibh, ac dapibus mauris finibus ut. Sed consequat nibh at pharetra ornare.

-

Vivamus nunc dui, pellentesque quis nulla vel, laoreet facilisis sem. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Duis libero nibh, varius ac lacus nec, gravida facilisis mi. Mauris urna libero, laoreet sit amet gravida ac, pharetra eu magna. Morbi iaculis egestas interdum. Donec a mi sit amet ipsum consequat malesuada. Nam magna erat, tempus eget tortor nec, fringilla tincidunt odio. Mauris finibus eget mi vitae aliquet. Maecenas a est vitae urna vestibulum vulputate id at urna. Donec id feugiat orci, vel tincidunt nunc. Quisque dapibus libero porta fringilla consectetur.

-

Aenean nunc augue, bibendum vitae aliquet sed, rhoncus at mauris. Morbi eleifend ultrices commodo. Donec bibendum justo a ultricies vehicula. Sed iaculis enim et nisl auctor, nec ultricies tortor suscipit. Phasellus non convallis dui. Nullam scelerisque quis tortor nec sollicitudin. Pellentesque tristique pretium nunc a tincidunt. Fusce sodales nunc sapien, at scelerisque diam fermentum sit amet. Fusce eu arcu magna. Curabitur ullamcorper elementum nisi vitae efficitur. Curabitur in venenatis magna. Fusce suscipit ex lectus, at iaculis est efficitur nec. Cras vitae dolor placerat, placerat sapien sit amet, tempor magna. Fusce sagittis leo sit amet magna venenatis fringilla. Phasellus euismod purus ex, quis egestas odio elementum sed.

- """ - } - ] - socket = socket - |> assign(@meta) - |> assign(:articles, articles) + |> assign(:meta, @meta) {:ok, socket} end diff --git a/apps/web/lib/web/live/home_live.html.leex b/apps/web/lib/web/live/home_live.html.leex index 5cfd068..9f401af 100644 --- a/apps/web/lib/web/live/home_live.html.leex +++ b/apps/web/lib/web/live/home_live.html.leex @@ -1,7 +1,6 @@ @@ -10,18 +9,4 @@
landing section
- -
- <%= for a <- @articles do %> - -
- <% end %> -
diff --git a/apps/web/lib/web/live/new_live.ex b/apps/web/lib/web/live/new_live.ex index f2c1f36..13f7317 100644 --- a/apps/web/lib/web/live/new_live.ex +++ b/apps/web/lib/web/live/new_live.ex @@ -4,6 +4,10 @@ defmodule Web.NewLive do use Phoenix.LiveView import Phoenix.HTML, only: [raw: 1] + alias Publishing.Manage + alias Web.ArticleLive + alias Web.Router.Helpers, as: Routes + @meta %{ title: "branchpage title", description: "some description", @@ -12,26 +16,92 @@ defmodule Web.NewLive do @impl true def mount(_params, _session, socket) do - name = "Felipe Lincoln" - date = Timex.today() |> Timex.format!("%b %e", :strftime) - title = "Lorem ipsum dolor sit amet. Consectetur adipiscing elit." - - body_html = """ -

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ornare eu mi eget lacinia. Maecenas tincidunt risus vel mi vehicula, sit amet varius ligula porta. Pellentesque id ex viverra, pellentesque neque eget, aliquet magna. Sed viverra egestas pulvinar. Mauris luctus egestas ante, et facilisis ligula vulputate sit amet. Nunc ut ipsum velit. Vivamus finibus scelerisque nibh, ac dapibus mauris finibus ut. Sed consequat nibh at pharetra ornare.

-

Vivamus nunc dui, pellentesque quis nulla vel, laoreet facilisis sem. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Duis libero nibh, varius ac lacus nec, gravida facilisis mi. Mauris urna libero, laoreet sit amet gravida ac, pharetra eu magna. Morbi iaculis egestas interdum. Donec a mi sit amet ipsum consequat malesuada. Nam magna erat, tempus eget tortor nec, fringilla tincidunt odio. Mauris finibus eget mi vitae aliquet. Maecenas a est vitae urna vestibulum vulputate id at urna. Donec id feugiat orci, vel tincidunt nunc. Quisque dapibus libero porta fringilla consectetur.

-

Aenean nunc augue, bibendum vitae aliquet sed, rhoncus at mauris. Morbi eleifend ultrices commodo. Donec bibendum justo a ultricies vehicula. Sed iaculis enim et nisl auctor, nec ultricies tortor suscipit. Phasellus non convallis dui. Nullam scelerisque quis tortor nec sollicitudin. Pellentesque tristique pretium nunc a tincidunt. Fusce sodales nunc sapien, at scelerisque diam fermentum sit amet. Fusce eu arcu magna. Curabitur ullamcorper elementum nisi vitae efficitur. Curabitur in venenatis magna. Fusce suscipit ex lectus, at iaculis est efficitur nec. Cras vitae dolor placerat, placerat sapien sit amet, tempor magna. Fusce sagittis leo sit amet magna venenatis fringilla. Phasellus euismod purus ex, quis egestas odio elementum sed.

-

Suspendisse semper diam sit amet enim placerat, nec suscipit dolor efficitur. Suspendisse nec erat vitae felis dignissim rhoncus porta sit amet ipsum. Quisque consequat leo nec est hendrerit aliquet. Donec accumsan sem ut sapien placerat facilisis. Sed pharetra efficitur mi, quis condimentum ipsum egestas quis. Duis ultrices, nibh ut placerat tincidunt, elit dolor tincidunt orci, id ornare metus urna accumsan ex. Duis quis ullamcorper ex, blandit sagittis metus. Quisque urna mi, condimentum quis tortor non, maximus maximus felis. Suspendisse accumsan augue purus, ut pharetra nunc congue at.

-

Fusce sit amet euismod arcu. In hac habitasse platea dictumst. Morbi non ullamcorper turpis. Curabitur rutrum venenatis nisl, sit amet pharetra erat rhoncus non. Suspendisse et arcu vitae eros bibendum porta convallis vel dolor. Maecenas eget sodales ex. Vestibulum feugiat purus vitae risus ultrices, sed vehicula justo posuere. Nulla commodo, elit sed tincidunt facilisis, nunc nunc posuere ligula, bibendum auctor mi ante et augue. Pellentesque finibus facilisis ex, eget volutpat turpis aliquet ac. Nullam lectus erat, finibus sed purus eu, maximus commodo enim. Curabitur tincidunt justo nec justo laoreet sodales vitae a nibh.

- """ - socket = socket - |> assign(@meta) - |> assign(:name, name) - |> assign(:date, date) - |> assign(:title, title) - |> assign(:body_html, body_html) + |> assign(:meta, @meta) + |> assign(:validation, nil) + |> assign(:error, nil) + |> assign(:article, nil) + |> assign(:loading, false) + |> assign(:url, "") {:ok, socket} end + + @impl true + def handle_info(:preview, socket) do + url = socket.assigns.url + + case Manage.build_article(url) do + {:ok, article} -> + socket = + socket + |> assign(:article, article) + |> assign(:loading, false) + |> push_event("highlightAll", %{}) + + {:noreply, socket} + + {:error, validation} -> + socket = + socket + |> assign(:validation, validation) + |> assign(:loading, false) + + {:noreply, socket} + end + end + + @impl true + def handle_event("preview", %{"url" => ""}, socket) do + socket = + socket + |> assign(:validation, nil) + |> assign(:error, nil) + |> assign(:article, nil) + + {:noreply, socket} + end + + @impl true + def handle_event("preview", %{"url" => url}, socket) do + send(self(), :preview) + + socket = + socket + |> assign(:validation, nil) + |> assign(:error, nil) + |> assign(:article, nil) + |> assign(:loading, true) + |> assign(:url, url) + + {:noreply, socket} + end + + @impl true + def handle_event("publish", _params, %{assigns: %{article: nil}} = socket), + do: {:noreply, socket} + + @impl true + def handle_event("publish", _params, socket) do + case Manage.save_article(socket.assigns.article) do + {:ok, article} -> + username = socket.assigns.article.blog.username + path = Routes.live_path(socket, ArticleLive, username, article.id) + + {:noreply, push_redirect(socket, to: path)} + + {:error, reason} -> + {:noreply, assign(socket, :error, reason)} + end + end + + @impl true + def handle_event("clear-flash", _params, socket) do + socket = + socket + |> assign(:error, nil) + + {:noreply, socket} + end end diff --git a/apps/web/lib/web/live/new_live.html.leex b/apps/web/lib/web/live/new_live.html.leex index 3a790eb..97869f1 100644 --- a/apps/web/lib/web/live/new_live.html.leex +++ b/apps/web/lib/web/live/new_live.html.leex @@ -1,25 +1,33 @@ +<%= if @error do %> +
+

Error

+

<%= raw @error %>

+
+<% end %> +
+

<%= @validation %>

+

<%= if @loading, do: "Loading..." %>

+ <%= if @article do %>
-

<%= @title %>

+

<%= @article.title %>

Preview

-
<%= raw @body_html %>
+
<%= raw @article.body %>
+ <% end %>
diff --git a/apps/web/lib/web/live/search_live.ex b/apps/web/lib/web/live/search_live.ex deleted file mode 100644 index 12ebb89..0000000 --- a/apps/web/lib/web/live/search_live.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule Web.SearchLive do - @moduledoc false - - use Phoenix.LiveView - import Phoenix.HTML, only: [raw: 1] - - @meta %{ - title: "branchpage title", - description: "some description", - social_image: "/images/cover.png" - } - - @impl true - def mount(params, _session, socket) do - q = Map.get(params, "q") - - articles = [ - %{ - title: "Lorem ipsum dolor sit amet. Consectetur adipiscing elit.", - date: Timex.today() |> Timex.format!("%b %e", :strftime), - body_html: """ -

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ornare eu mi eget lacinia. Maecenas tincidunt risus vel mi vehicula, sit amet varius ligula porta. Pellentesque id ex viverra, pellentesque neque eget, aliquet magna. Sed viverra egestas pulvinar. Mauris luctus egestas ante, et facilisis ligula vulputate sit amet. Nunc ut ipsum velit. Vivamus finibus scelerisque nibh, ac dapibus mauris finibus ut. Sed consequat nibh at pharetra ornare.

-

Vivamus nunc dui, pellentesque quis nulla vel, laoreet facilisis sem. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Duis libero nibh, varius ac lacus nec, gravida facilisis mi. Mauris urna libero, laoreet sit amet gravida ac, pharetra eu magna. Morbi iaculis egestas interdum. Donec a mi sit amet ipsum consequat malesuada. Nam magna erat, tempus eget tortor nec, fringilla tincidunt odio. Mauris finibus eget mi vitae aliquet. Maecenas a est vitae urna vestibulum vulputate id at urna. Donec id feugiat orci, vel tincidunt nunc. Quisque dapibus libero porta fringilla consectetur.

-

Aenean nunc augue, bibendum vitae aliquet sed, rhoncus at mauris. Morbi eleifend ultrices commodo. Donec bibendum justo a ultricies vehicula. Sed iaculis enim et nisl auctor, nec ultricies tortor suscipit. Phasellus non convallis dui. Nullam scelerisque quis tortor nec sollicitudin. Pellentesque tristique pretium nunc a tincidunt. Fusce sodales nunc sapien, at scelerisque diam fermentum sit amet. Fusce eu arcu magna. Curabitur ullamcorper elementum nisi vitae efficitur. Curabitur in venenatis magna. Fusce suscipit ex lectus, at iaculis est efficitur nec. Cras vitae dolor placerat, placerat sapien sit amet, tempor magna. Fusce sagittis leo sit amet magna venenatis fringilla. Phasellus euismod purus ex, quis egestas odio elementum sed.

- """ - }, - %{ - title: "Consectetur adipiscing elit.", - date: Timex.today() |> Timex.format!("%b %e", :strftime), - body_html: """ -

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ornare eu mi eget lacinia. Maecenas tincidunt risus vel mi vehicula, sit amet varius ligula porta. Pellentesque id ex viverra, pellentesque neque eget, aliquet magna. Sed viverra egestas pulvinar. Mauris luctus egestas ante, et facilisis ligula vulputate sit amet. Nunc ut ipsum velit. Vivamus finibus scelerisque nibh, ac dapibus mauris finibus ut. Sed consequat nibh at pharetra ornare.

-

Vivamus nunc dui, pellentesque quis nulla vel, laoreet facilisis sem. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Duis libero nibh, varius ac lacus nec, gravida facilisis mi. Mauris urna libero, laoreet sit amet gravida ac, pharetra eu magna. Morbi iaculis egestas interdum. Donec a mi sit amet ipsum consequat malesuada. Nam magna erat, tempus eget tortor nec, fringilla tincidunt odio. Mauris finibus eget mi vitae aliquet. Maecenas a est vitae urna vestibulum vulputate id at urna. Donec id feugiat orci, vel tincidunt nunc. Quisque dapibus libero porta fringilla consectetur.

-

Aenean nunc augue, bibendum vitae aliquet sed, rhoncus at mauris. Morbi eleifend ultrices commodo. Donec bibendum justo a ultricies vehicula. Sed iaculis enim et nisl auctor, nec ultricies tortor suscipit. Phasellus non convallis dui. Nullam scelerisque quis tortor nec sollicitudin. Pellentesque tristique pretium nunc a tincidunt. Fusce sodales nunc sapien, at scelerisque diam fermentum sit amet. Fusce eu arcu magna. Curabitur ullamcorper elementum nisi vitae efficitur. Curabitur in venenatis magna. Fusce suscipit ex lectus, at iaculis est efficitur nec. Cras vitae dolor placerat, placerat sapien sit amet, tempor magna. Fusce sagittis leo sit amet magna venenatis fringilla. Phasellus euismod purus ex, quis egestas odio elementum sed.

- """ - } - ] - - socket = - socket - |> assign(@meta) - |> assign(:q, q) - |> assign(:articles, articles) - - {:ok, socket} - end -end diff --git a/apps/web/lib/web/live/search_live.html.leex b/apps/web/lib/web/live/search_live.html.leex deleted file mode 100644 index d284bd8..0000000 --- a/apps/web/lib/web/live/search_live.html.leex +++ /dev/null @@ -1,27 +0,0 @@ - - -
-
- <%= for a <- @articles do %> - -
- <% end %> -
-
diff --git a/apps/web/lib/web/router.ex b/apps/web/lib/web/router.ex index f2b0837..b4bb0d7 100644 --- a/apps/web/lib/web/router.ex +++ b/apps/web/lib/web/router.ex @@ -13,7 +13,6 @@ defmodule Web.Router do live "/", HomeLive live "/new", NewLive - live "/search", SearchLive live "/:username", BlogLive live "/:username/:article", ArticleLive end diff --git a/apps/web/lib/web/templates/layout/base.html.eex b/apps/web/lib/web/templates/layout/base.html.eex index bec0171..6355226 100644 --- a/apps/web/lib/web/templates/layout/base.html.eex +++ b/apps/web/lib/web/templates/layout/base.html.eex @@ -1,25 +1,25 @@ - <%= @title %> + <%= @meta.title %> <%= csrf_meta_tag() %> - + - - - + + + - - - + + + - - + + + - + <%= @inner_content %>