Do you want to pick up from where you left of?
Take me there

NimblePublisher

NimblePublisher es un motor de publicación minimalista basado en ficheros con soporte para Markdown y coloreado de sintaxis.

¿Por qué usar NimblePublisher?

NimblePublisher es una librería sencilla diseñada para publicar contenido parseado de ficheros locales que usan sintaxis Markdown. Un ejemplo de uso típico sería la publicación de un blog.

Esta librería encapsula la mayoría del código que Dashbit usa para su propio blog, como se explicó en su post Welcome to our blog: how it was made! - y donde explican por qué prefirieron parsear el contenido de archivos locales en lugar de usar una base de datos o un CMS más complejo.

Crea tu contenido

Vamos a crear nuestro propio blog. En nuestro ejemplo, estamos usando una aplicación Phoenix pero Phoenix no es un requisito. Ya que NimblePublisher sólo se encarga de parsear el contenido de los ficheros locales, puedes usarlo en cualquier aplicación Elixir.

En primer lugar, vamos a crear una aplicación Phoenix para nuestro ejemplo. La llamaremos NimbleSchool, y la crearemos de esta manera porque no necesitamos Ecto allá donde vamos:

mix phx.new nimble_school --no-ecto

Ahora, vamos a añadir algunos posts. Necesitamos comenzar creando un directorio que contendrá nuestros posts. Los tendremos organizados por año en este formato:

/priv/posts/AÑO/MES-DIA-ID.md

Por ejemplo, comenzamos con estos dos posts:

/priv/posts/2020/10-28-hello-world.md
/priv/posts/2020/11-04-exciting-news.md

Un post típico estará escrito usando la sintaxis Markdown, con una sección de metadatos en la parte superior, y el contenido debajo separado por ---, de esta manera:

%{
  title: "Hello World!",
  author: "Jaime Iniesta",
  tags: ~w(hello),
  description: "Our first blog post is here"
}
---
Si, este es **el post** que estabas esperando.

Te dejaré que seas creativo escribiendo tus propios posts. Simplemente asegúrate de seguir el formato de arriba para los metadatos y el contenido.

Con estos posts en su sitio, vamos a instalar NimblePublisher para así poder parsear el contenido y construir nuestro contexto Blog.

Cómo instalar NimblePublisher

En primer lugar, añade nimble_publisher como dependencia. Opcionalmente puedes incluir coloreadores de sintaxis, en este caso añadiremos soporte para colorear código Elixir y Erlang.

En nuestra aplicación Phoenix, añadiremos esto en mix.exs:

  defp deps do
    [
      ...,
      {:nimble_publisher, "~> 0.1.1"},
      {:makeup_elixir, ">= 0.0.0"},
      {:makeup_erlang, ">= 0.0.0"}
    ]
  end

Después de ejecutar mix deps.get para instalar las dependencias, estarás listo para continuar construyendo el blog.

Cómo construir el contexto Blog

Vamos a definir un struct Post para contener el contenido parseado de los ficheros. Va a esperar una clave para cada clave de los metadatos, y también una clave :date para la fecha, que será parseada del nombre de fichero. Crea un fichero lib/nimble_school/blog/post.ex con este contenido:

defmodule NimbleSchool.Blog.Post do
  @enforce_keys [:id, :author, :title, :body, :description, :tags, :date]
  defstruct [:id, :author, :title, :body, :description, :tags, :date]

  def build(filename, attrs, body) do
    [year, month_day_id] = filename |> Path.rootname() |> Path.split() |> Enum.take(-2)
    [month, day, id] = String.split(month_day_id, "-", parts: 3)
    date = Date.from_iso8601!("#{year}-#{month}-#{day}")
    struct!(__MODULE__, [id: id, date: date, body: body] ++ Map.to_list(attrs))
  end
end

El módulo Post define el struct para los metadatos y contenido, y también define una función build/3 para la lógica necesaria para parsear un fichero con los contenidos del post.

Con esta struct Post definida, podemos definir nuestro contexto Blog que usará NimblePublisher para convertir los ficheros locales en posts. Crea lib/nimble_school/blog/blog.ex con este contenido:

defmodule NimbleSchool.Blog do
  alias NimbleSchool.Blog.Post

  use NimblePublisher,
    build: Post,
    from: Application.app_dir(:nimble_school, "priv/posts/**/*.md"),
    as: :posts,
    highlighters: [:makeup_elixir, :makeup_erlang]

  # La variable @posts es definida primero por NimblePublisher.
  # Vamos a modificarla más para ordenar todos los posts por fecha descendiente.
  @posts Enum.sort_by(@posts, & &1.date, {:desc, Date})

  # También definimos las tags
  @tags @posts |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort()

  # Y finalmente lo exportamos todo
  def all_posts, do: @posts
  def all_tags, do: @tags
end

Como puedes ver, el contexto Blog usa NimblePublisher para construir la colección de Post a partir del directorio local indicado, empleando los coloreadores de sintaxis que queramos usar.

NimblePublisher creará la variable @posts, que más adelante procesamos para ordenar los posts de manera descendente por :date como normalmente queremos en un blog.

También definiremos @tags sacándolas de los @posts.

Por último, definimos all_posts/0 y all_tags/0 que simplemente devolverán lo que acabamos de parsear.

¡Vamos a probarlo! Entra en una consola con iex -S mix y ejecuta:

iex(1)> NimbleSchool.Blog.all_posts()
[
  %NimbleSchool.Blog.Post{
    author: "Jaime Iniesta",
    body: "<p>\nAwesome, this is our second post in our great blog.</p>\n",
    date: ~D[2020-11-04],
    description: "Second blog post",
    id: "exciting-news",
    tags: ["exciting", "news"],
    title: "Exciting News!"
  },
  %NimbleSchool.Blog.Post{
    author: "Jaime Iniesta",
    body: "<p>\nYes, this is <strong>the post</strong> you’ve been waiting for.</p>\n",
    date: ~D[2020-10-28],
    description: "Our first blog post is here",
    id: "hello-world",
    tags: ["hello"],
    title: "Hello World!"
  }
]


iex(2)> NimbleSchool.Blog.all_tags() 
["exciting", "hello", "news"]

¿No es genial? Ya tenemos todos nuestros posts parseados, con la sintaxis de Markdown interpretada, y listos para ser usados. ¡Y también tenemos tags!

Ahora, es importante destacar que NimblePublisher se está ocupando de parsear los ficheros y construir la variable @posts con todos ellos, y tú continúas a partir de ahí para definir las funciones que necesites. Por ejemplo, si necesitas una función para traer los posts recientes, la puedes definir así:

def recent_posts(num \\ 5), do: Enum.take(all_posts(), num)  

Como puedes observar, hemos evitado usar @posts dentro de nuestra nueva función y hemos usado all_posts() en su lugar. Si no, la variable @posts habría sido expandida por el compilador dos veces, haciendo una copia completa de todos los posts.

Vamos a definir algunas funciones más para tener nuestro ejemplo de blog completo. Necesitaremos obtener un post por su id y también listar todos los posts para un tag determinado. Define lo siguiente dentro del contexto Blog:

defmodule NotFoundError, do: defexception [:message, plug_status: 404]

def get_post_by_id!(id) do
  Enum.find(all_posts(), &(&1.id == id)) ||
    raise NotFoundError, "post with id=#{id} not found"
end

def get_posts_by_tag!(tag) do
  case Enum.filter(all_posts(), &(tag in &1.tags)) do
    [] -> raise NotFoundError, "posts with tag=#{tag} not found"
    posts -> posts
  end
end

Cómo servir tu contenido

Ahora que tenemos una manera de obtener todos nuestros posts y tags, servirlos simplemente significa conectar las rutas, controladores, vistas y plantillas de la manera acostumbrada. Para este ejemplo no nos complicaremos y simplemente listaremos todos los posts y traeremos cada post por su id. Se deja como un ejercicio al lector listar los posts por tag y ampliar el layout con los posts recientes.

Rutas

Define las siguientes rutas en lib/nimble_school_web/router.ex:

scope "/", NimbleSchoolWeb do
  pipe_through :browser

  get "/blog", BlogController, :index
  get "/blog/:id", BlogController, :show
end

Controlador

Define un controlador para servir los posts en lib/nimble_school_web/controllers/blog_controller.ex:

defmodule NimbleSchoolWeb.BlogController do
  use NimbleSchoolWeb, :controller

  alias NimbleSchool.Blog

  def index(conn, _params) do
    render(conn, "index.html", posts: Blog.all_posts())
  end

  def show(conn, %{"id" => id}) do
    render(conn, "show.html", post: Blog.get_post_by_id!(id))
  end
end

Vista

Crea el módulo de vista donde puedes colocar los helpers que necesites para pintar la vista. De momento es simplemente:

defmodule NimbleSchoolWeb.BlogView do
  use NimbleSchoolWeb, :view
end

Plantilla

Finalmente, crea los ficheros HTML para pintar el contenido. Dentro de lib/nimble_school_web/templates/blog/index.html.eex define esto para pintar el contenido de la lista de posts:

<h1>Listing all posts</h1>

<%= for post <- @posts do %>
  <div id="<%= post.id %>" style="margin-bottom: 3rem;">
    <h2>
      <%= link post.title, to: Routes.blog_path(@conn, :show, post)%>
    </h2>

    <p>
      <time><%= post.date %></time> by <%= post.author %>
    </p>

    <p>
      Tagged as <%= Enum.join(post.tags, ", ") %>
    </p>

    <%= raw post.description %>
  </div>
<% end %>

Y crea lib/nimble_school_web/templates/blog/show.html.eex para pintar un post individual:

<%= link "← All posts", to: Routes.blog_path(@conn, :index)%>

<h1><%= @post.title %></h1>

<p>
  <time><%= @post.date %></time> by <%= @post.author %>
</p>

<p>
  Tagged as <%= Enum.join(@post.tags, ", ") %>
</p>

<%= raw @post.body %>

¡Probemos el blog

¡Ya está todo preparado!

Lanza el servidor web con iex -S mix phx.server y visita http://localhost:4000/blog para ver tu nuevo blog.

Cómo ampliar los metadatos

NimblePublisher es muy flexible a la hora de definir la estructura de nuestros posts y metadatos. Por ejemplo, supongamos que queremos añadir una clave :published para marcar los posts, y mostrar sólo aquellos donde sea true.

Sólo necesitamos añadir la clave :published al struct Post, y también a los metadatos de los posts. En el módulo Blog podemos definir:

def all_posts, do: @posts

def published_posts, do: Enum.filter(all_posts(), &(&1.published == true))

def recent_posts(num \\ 5), do: Enum.take(published_posts(), num)

Coloreado de sintaxis

NimblePublisher usa la librería Makeup para el coloreado de sintaxis. Tendrás que generar las clases CSS para el estilo que prefieras de uno de los definidos aquí.

Por ejemplo, vamos a usar el :tango_style. En una sesión de iex -S mix ejecuta:

Makeup.stylesheet(:tango_style, "makeup") |> IO.puts()

Y coloca las clases CSS generadas en tus hojas de estilo.

Cómo servir otro contenido

NimblePublisher también puede ser empleado para construir otros contextos de publicación con una estructura diferente.

Por ejemplo, podríamos mantener una colección de Preguntas Frecuentes (FAQs), en este caso probablemente no necesitemos fechas ni autores, y una estructura con :id, :question and :answer sería suficiente:

Podríamos colocar nuestros ficheros de contenidos en una estructura de directorio diferente, por ejemplo:

/priv/faqs/is-there-a-free-trial.md
/priv/faqs/when-did-it-start.md

Y podríamos definir nuestra struct Faq y la función para construirla en lib/nimble_school/faqs/faq.ex de esta manera:

defmodule NimbleSchool.Faqs.Faq do
  @enforce_keys [:id, :question, :answer]
  defstruct [:id, :question, :answer]

  def build(filename, attrs, body) do
    [id] = filename |> Path.rootname() |> Path.split() |> Enum.take(-1)
    struct!(__MODULE__, [id: id, answer: body] ++ Map.to_list(attrs))
  end
end

Nuestro contexto Faqs en lib/nimble_school/faqs/faqs.ex sería algo así como:

defmodule NimbleSchool.Faqs do
  alias NimbleSchool.Faqs.Faq

  use NimblePublisher,
    build: Faq,
    from: Application.app_dir(:nimble_school, "priv/faqs/*.md"),
    as: :faqs

  # La variable @faqs es definida primero por NimblePublisher.
  # Vamos a modificarla más para ordenar todas las FAQs por fecha descendiente.
  @faqs Enum.sort_by(@faqs, & &1.question)

  # Y finalmente lo exportamos
  def all_faqs, do: @faqs
end

Código fuente para el blog de ejemplo

Puedes encontrar el código para este ejemplo en https://github.com/jaimeiniesta/nimble_school

¿Encontraste un error o quieres contribuir a la lección? ¡Edita esta lección en GitHub!