Retour

Générer du HTML avec Ruby (sans Rails)

Enregistrer
Temps de lecture: 8 min Vues: 318 Niveau: Intermédiaire
9 jours par Franci-lobbie LALANE
Générer du HTML avec Ruby (sans Rails)
Tags:
#ruby #active_record #rack #puma #erb #nokogiri #phlex #html #web #templates #sqlite

Intro

Aujourd'hui, on te montre comment on passe d'un langage interprété (Ruby) à du HTML côté client en passant par un serveur web minimal. On va rester en Ruby “pur” et bâtir petit à petit, jusqu'à un mini-projet façon mini‑rails.
Objectifs:
  • utiliser Rack + Puma dans tous les exemples
  • générer du HTML de 4 façons: string, ERB, Nokogiri::Builder, Phlex
  • expliquer les briques: Proc Rack, env, [status, headers, body], 200, Content‑Type, objet ERB
  • pousser l'exemple Phlex avec assets (JS/CSS), routing simple, et base de données SQLite via Active Record
Prérequis rapides
Au choix:
Option A - gems globales
gem install rack puma nokogiri phlex activerecord sqlite3

Option B - Bundler (recommandé)
bundle init
# Gemfile :
# gem "rack"
# gem "puma"
# gem "nokogiri"
# gem "phlex"
# gem "activerecord"
# gem "sqlite3"
bundle install

Dans chacun de nos scripts, on utilisera Puma: require "rack/handler/puma" puis Rack::Handler::Puma.run app, Port: 9292. Tu pourras ensuite visiter http://localhost:9292.

1) HTML en string avec Rack et Puma

On créer le fichier app.rb
require "rack"
require "rack/handler/puma"

app = Proc.new do |env|
  html = <<~HTML
    <!doctype html>
    <html>
      <head><meta charset="utf-8"><title>Hello monde!</title></head>
      <body>
        <h1>Super Ruby</h1>
        <p>Du HTML juste avec du Ruby, vraiment ?</p>
      </body>
    </html>
  HTML

  [200, { "Content-Type" => "text/html; charset=utf-8" }, [html]]
end

Rack::Handler::Puma.run app, Port: 9292

Lance ruby app.rb, puis visite http://localhost:9292.
Comprendre les briques
  • app = Proc.new { |env| ... }: une appli Rack est un objet appelable qui reçoit env (un Hash avec la requête: méthode, chemin, headers...) et retourne un triplet [status, headers, body].
  • env: exemples utiles env["REQUEST_METHOD"], env["PATH_INFO"], env["QUERY_STRING"].
  • 200: c'est le code HTTP OK. Comme par exemple (404: not found, 302: redirect, 500: server error).
  • {"Content-Type" => "text/html; charset=utf-8"}: indique le type de contenu au navigateur. Pour du JSON: application/json.
  • body: doit être énumérable (Array de strings, généralement). Ici [[html]].
Résumé: on construit le HTML, puis on renvoie [status, headers, body] dans cet ordre.

2) ERB: un template réutilisable

On créer le fichier index.erb
<!doctype html>
<html>
  <head><meta charset="utf-8"><title><%= title %></title></head>
  <body>
    <h1><%= title %></h1>
    <ul>
      <% items.each do |item| %>
        <li><%= item %></li>
      <% end %>
    </ul>
  </body>
</html>

Puis app_erb.rb
require "rack"
require "erb"
require "rack/handler/puma"

TEMPLATE = ERB.new(File.read("index.erb"))

app = Proc.new do |env|
  title = "Ma page ERB"
  items = ["Ruby", "Toulouse", "Rack", "ERB", "Captain Ruby"]

  html = TEMPLATE.result_with_hash(title: title, items: items)
  [200, { "Content-Type" => "text/html; charset=utf-8" }, [html]]
end

Rack::Handler::Puma.run app, Port: 9292

C'est quoi un objet ERB ?
  • ERB lit un fichier HTML avec <% %> (Ruby) et <%= %> (Ruby qui affiche).
  • ERB.new(...) compile le template.
  • result_with_hash(...) évalue le template avec des variables.
Pour comparer les moteurs, on a un billet: HAML vs ERB : quel moteur de template choisir pour vos vues Rails ? sur Captain Ruby.

3) Nokogiri::HTML::Builder: générer le HTML en Ruby

A la racine: app_nokogiri.rb
require "rack"
require "nokogiri"
require "rack/handler/puma"

app = Proc.new do |env|
  builder = Nokogiri::HTML::Builder.new do |doc|
    doc.html do
      doc.head do
        doc.meta charset: "utf-8"
        doc.title "Nokogiri Builder"
      end
      doc.body do
        doc.h1 "Liste d'articles"
        doc.ul do
          %w[ruby html nokogiri].each do |name|
            doc.li { doc.a name.capitalize, href: "/tags/#{name}" }
          end
        end
      end
    end
  end

  [200, { "Content-Type" => "text/html; charset=utf-8" }, [builder.to_html]]
end

Rack::Handler::Puma.run app, Port: 9292

Pourquoi c'est pratique ? Boucles/conditions naturelles, markup toujours valide, et post‑traitement DOM facile.

4) Phlex en mode mini‑rails : assets, routing, base de données

On passe à un mini projet structuré pour se rapprocher d'une app web réelle.
L'arborescence
mini-rails/
  Gemfile
  app.rb
  db/
    setup.rb
    development.sqlite3
  views/
    layout.rb
    posts_index.rb
    posts_show.rb
    home_page.rb
  public/
    style.css
    app.js

Notre Gemfile
gem "rack"
gem "puma"
gem "phlex"
gem "activerecord"
gem "sqlite3"

bundle install
4.1 DB: Active Record sans Rails
db/setup.rb
require "active_record"
ActiveRecord::Base.establish_connection(
  adapter: "sqlite3",
  database: File.expand_path("./db/development.sqlite3", __dir__)
)

unless ActiveRecord::Base.connection.table_exists?(:posts)
  ActiveRecord::Schema.define do
    create_table :posts do |t|
      t.string :title, null: false
      t.text :body, null: false
      t.timestamps
    end
  end
end

class Post < ActiveRecord::Base; end

4.2 Vues Phlex
views/layout.rb
require "phlex"

class Layout < Phlex::HTML
  def initialize(title: "Mini-Rails"); @title = title; end

  def view_template
    doctype
    html do
      head do
        meta charset: "utf-8"
        title { @title }
        link rel: "stylesheet", href: "/style.css"
        script src: "/app.js"
      end
      body do
        header do
          h1 { "Mini-Rails" }
          nav do
            a(href: "/") { "Accueil" }
            plain " | "
            a(href: "/posts") { "Posts" }
          end
        end

        main do
          yield if block_given?
        end
      end
    end
  end
end

views/posts_index.rb
require "phlex"
require_relative "layout"

class PostsIndex < Phlex::HTML
  def initialize(posts:)
    @posts = posts
  end
  
  def view_template
    render Layout.new(title: "Posts") do
      h2 { "Articles" }
      
      if @posts.any?
        p { "Nombre d'articles: #{@posts.count}" }
        ul do
          @posts.each do |post|
            li do
              a(href: "/posts/#{post.id}") { post.title }
            end
          end
        end
      else
        p { "Aucun article pour le moment." }
        p { a(href: "#", onclick: "alert('Créez votre premier article!')") { "Créer le premier article" } }
      end
      
      h3 { "Nouvel article" }
      form(action: "/posts", method: "post") do
        div do
          label { "Titre" }
          br
          input(type: "text", name: "title", style: "width: 300px;")
        end
        div do
          label { "Contenu" }
          br
          textarea(name: "body", style: "width: 300px; height: 100px;")
        end
        div do
          button(type: "submit") { "Créer" }
        end
      end
    end
  end
end

views/posts_show.rb
require "phlex"
require_relative "layout"

class PostsShow < Phlex::HTML
  def initialize(post:); @post = post; end
  
  def view_template
    render Layout.new(title: @post.title) do
      article do
        h2 { @post.title }
        p { @post.body }
        p { a(href: "/posts") { "← Retour" } }
      end
    end
  end
end

4.3 Une page d'acceuil simple
views/home_page.rb
require "phlex"
require_relative "layout"

class HomePage < Phlex::HTML
  def view_template
    render Layout.new(title: "Accueil") do
      h2 { "Bienvenue !" }
      p  { "Ceci est la page d’accueil." }
      p  { a(href: "/posts") { "Voir les posts →" } }
    end
  end
end
4.4 App + routing + assets
app.rb
require "rack"
require "rack/handler/puma"
require "rack/files"
require_relative "db/setup"
require_relative "views/posts_index"
require_relative "views/posts_show"
require_relative "views/home_page"   # ← ajoute ceci

PUBLIC_DIR = File.expand_path("./public", __dir__)

app = Proc.new do |env|
  req = Rack::Request.new(env)

  # Assets statiques
  if req.path.start_with?("/style.css") || req.path.start_with?("/app.js")
    next Rack::Files.new(PUBLIC_DIR).call(env)
  end

  case [req.request_method, req.path]
  when ["GET", "/"]
    html = HomePage.new.call         # ← n’appelle plus Layout directement ici
    [200, { "Content-Type" => "text/html; charset=utf-8" }, [html]]

  when ["GET", "/posts"]
    posts = Post.order(created_at: :desc).limit(50).to_a
    html  = PostsIndex.new(posts: posts).call
    [200, { "Content-Type" => "text/html; charset=utf-8" }, [html]]

  when ["POST", "/posts"]
    title = req.params["title"]&.strip
    body  = req.params["body"]&.strip
    if title.to_s.empty? || body.to_s.empty?
      next [422, { "Content-Type" => "text/html; charset=utf-8" }, ["Titre et contenu requis."]]
    end
    post = Post.create!(title: title, body: body)
    [302, { "Location" => "/posts/#{post.id}" }, []]

  else
    if req.get? && req.path =~ %r{^/posts/(\d+)$}
      id   = req.path.match(%r{/posts/(\d+)$})[1]
      post = Post.find_by(id: id)
      next [404, { "Content-Type" => "text/html" }, ["Not found"]] unless post
      html = PostsShow.new(post: post).call
      [200, { "Content-Type" => "text/html; charset=utf-8" }, [html]]
    else
      [404, { "Content-Type" => "text/plain" }, ["Not found"]]
    end
  end
end

Rack::Handler::Puma.run app, Port: 9292

public/style.css (exemple rapide)
body {
  font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  line-height: 1.6;
  background-color: #f5f5f5;
}

header {
  border-bottom: 2px solid #007bff;
  padding-bottom: 15px;
  margin-bottom: 30px;
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

h1 {
  color: #007bff;
  margin: 0;
}

nav {
  margin-top: 10px;
}

nav a {
  text-decoration: none;
  color: #007bff;
  font-weight: 500;
  margin-right: 10px;
}

nav a:hover {
  text-decoration: underline;
}

main {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

ul {
  list-style-type: none;
  padding: 0;
}

li {
  margin-bottom: 10px;
  padding: 10px;
  background: #f8f9fa;
  border-radius: 4px;
  border-left: 4px solid #007bff;
}

form {
  margin-top: 30px;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  background: #f8f9fa;
}

input,
textarea {
  width: 100%;
  max-width: 500px;
  margin-bottom: 15px;
  padding: 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-family: inherit;
}

button {
  background: #007bff;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
}

button:hover {
  background: #0056b3;
}

article {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}


public/app.js (exemple minimal)
console.log("Mini‑Rails prêt !");

Lance bundle exec ruby app.rb et visite http://localhost:9292. Tu as maintenant:
  • des assets servis depuis public/
  • un routing simple en fonction de la méthode et du chemin
  • un mini-blog avec SQLite + Active Record (création et lecture)

Conclusion

On est partis d’un simple Proc Rack qui renvoie une page "Hello monde", et on a fini avec un mini-blog qui sert du HTML, du CSS, du JS, et qui cause à une base SQLite via Active Record. Le fil rouge ne bouge pas: on prend une requête, on renvoie [status, headers, body]. Entre les deux, on choisit l’outil qui va bien:
  • string brute quand on veut juste tester un truc vite fait,
  • ERB pour rester proche du HTML,
  • Nokogiri pour générer du markup avec de la logique métier,
  • Phlex quand on veut des vues composables, testables, et une structure propre.
L’idée n’est pas de réinventer Rails, mais de comprendre ce qu’il fait pour nous. Une fois qu’on a mis les mains dans Rack, env, les statuts HTTP et les en-têtes, Rails devient plus clair… et Ruby sans Rails devient moins intimidant.
Et maintenant ?
On te laisse deux pistes simples pour continuer:
  1. Ajouter l’édition et la suppression des articles (GET/POST /posts/:id/edit, DELETE /posts/:id).
  2. Introduire des layouts/composants Phlex réutilisables (footer, flash messages), et un config.ru pour lancer avec rackup.
Si tu tentes l’une des variantes, dis-nous ce que tu as bricolé. On est chauds pour en faire un suivi.

Happy coding ! 😄



Laissez un commentaire

Se connecterpour laisser un commentaire.