Ghost CMS
Theme
Web

Desarrollando un Ghost CMS Theme [Parte 2]

Aquí te explico cuales son los archivos necesarios para crear un tema para Ghost CMS.

Ivan Robles

13 min read
Desarrollando un Ghost CMS Theme [Parte 2]

En la entrada anterior configuramos, seteamos y fijamos las bases para comenzar a crear nuestro propio tema, o template para el CMS Ghost.

Esta vez voy a explicar un lo básico acerca de un tema para Ghost CMS.

Primeramente estos temas utilizan handlebars, como lenguaje para sus templates. (Algo así como HTML con funcionalidades extendidas).

La estructura de ficheros mínima es como sigue:

.
├── /assets
|   └── /css
|       ├── screen.css
|   ├── /fonts
|   ├── /images
|   ├── /js
├── default.hbs
├── index.hbs [required]
└── post.hbs [required]
└── package.json [required]

Solo se requieren los archivos index.hbs  y post.hbs . También un package.json, pero a estas alturas es raro los proyectos que no cuenten con el.

Solo como recordatorio, estamos usando TailwindCSS, así que estaremos agregando estilos por medio de sus clases en las etiquetas HTML de los documentos hbs. Así estaremos estilizando a la vez que hacemos la estructura HTML.

Comenzamos con los archivos que son obligatorios:

package.json

Contiene información sobre el tema, nombre, descripción, versión y autor. Ademas puede contener otras configuraciones para implementar en el tema:

{
  "name": "the-sharmaz",
  "description": "A ghost theme based on casper started theme",
  "version": "0.1.0"
	"author": {
		"email": "irae45@gmail.com",
		"name": "Ivan Robles",
		"url": "<https://ivanrobles.pro/>"
	},
  "license": "MIT",
  "demo": "<https://ivanrobles.pro>",
	"repository": {
		"type": "git",
		"url": "<https://github.com/Sharmaz/the-sharmaz.git>"
	},
	"bugs": "<https://github.com/Sharmaz/the-sharmaz/issues>",
	"engines": {
		"ghost": ">=5.0.0"
	},
	"scripts": {
		"build": "rollup -c --environment BUILD:production && npx tailwindcss -i ./assets/css/index.css -o ./assets/built/index.css --minify",
		"dev": "concurrently \\"rollup -c --environment BUILD:development -w\\" \\"npx tailwindcss -i ./assets/css/index.css -o ./assets/built/index.css --watch\\" ",
		"pretest": "npm run build",
		"test": "npx gscan .",
		"zip": "npm run build && bestzip $npm_package_name.zip assets/* partials/* members/* *.hbs package.json"
	},
**}

Las configuraciones o propiedades adicionales pueden ser:

El numero de posts por pagina, podríamos tener alguna pagina que contenga la lista de 10 posts por ejemplo :

"config": {
	...
	"posts_per_page": 10 // 👈 Post por pagina limitados a 10
  ...
},

Tamaños de imágenes, Ghost CMS automáticamente genera copias de las imágenes con los tamaños especificados en esta configuración, sirven como cache:

"config": {
  ...
  "image_sizes": {
    "l": {
      "width": 1200
    },
    "m": {
      "width": 600
    },
    "s": {
      "width": 300
    },
    "xl": {
      "width": 2000
    },
    "xs": {
      "width": 100
    },
    "xxs": {
      "width": 30
    }
  },
  ...
},

Card assets, es contenido cuyo estilo viene por default y sus clases CSS comienzan con el prefijo kg un ejemplo puede ser gallery y sus clases son kg-gallery-container, kg-gallery-row, .kg-gallery-image.

El contenido de tipo cards disponible son audio, blockquote, bookmark, button, callout, file, gallery, header, nft, product, toggle, video y signup:

"config": {
  ...
  "card_assets": true, // 👈 Así viene por default. False para excluir todos.
  "card_assets": {
    "exclude": [  // 👈 Excluimos para sobre-escribir los estilos.
	  "bookmark", // 👈 Especificamos cuales vamos a excluir.
	  "gallery"
    ]  // 
  }
  ...
},

Custom settings, estas configuraciones se pueden pasar directamente a los templates .hbs por medio del objeto @custom , no se puede abusar de esta funcionalidad, cada tema esta limitada a tener un máximo de 20 custom settings:

"config": {
  ...
  "custom": {
    "feed_layout": {
	  "default": "Classic",
	  "group": "homepage",
	  "options": [
	    "Classic",
		"Grid",
		"List"
	  ],
	  "type": "select"
	}
  },
  ...
},

Ahora vamos con los archivos de Handlebars que son obligatorios (casi):

default.hbs

Aunque no es obligatorio se recomienda partir de este archivo, el cual va a contener la estructura básica de documento HTML: <html>, <head>, <body>:

<!DOCTYPE html> // 👇 Los objetos que comienzan con @ vienen del Admin de Ghost
<html lang="{{@site.locale}}" class="scroll-smooth">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="{{asset "built/index.css"}}" />
  <script src="{{asset "built/index.js"}}" defer></script>
  <title>{{meta_title}}</title>
  {{ghost_head}} // 👈 Lo que viene entre {{ }} son variables
</head>
<body class="{{body_class}}">
  <div>
    {{> "header"}} // 👈 Así llamamos a los parciales en este caso el header.hbs
    <main>
      {{{body}}} // 👈 Helper que dice que hay cosas dentro
    </main>
    {{> "footer"}} // 👈 partial de footer.hbs
  </div>
  {{ghost_foot}}
</body>
</html>

index.hbs

En este archivo vamos a tener la lista de todos los posts, donde vamos a iterar los, en caso de no tener un default.hbs este archivo es el que llevaría la estructura HTML y dentro la iteración de los posts:

{{!< default}}
// 👆 Esto significa que va dentro del partial de default.hbs
// Segun la libreria express-hbs
<div class="page">
  <div class="main-container w-full">
    <div class="feed relative grid gap-10">
      {{#foreach posts}}
	      {{> "card" page=../pagination.page }} // 👈 partial card.hbs por cada post
      {{/foreach}}
    </div>
  </div>
</div>

post.hbs

Dentro de este archivo hay que darles su respectiva estructura a la pagina que muestra un post:

{{!< default}}
{{#post}}
<article class="{{post_class}}">
  <header class="pb-[8vmin] gh-canvas">
    <div class="post-card-tags ">
      {{#foreach tags limit="3"}}
	      // 👆 interamos sobre las tags del blog y agregamos estructura html
      {{/foreach}}
    </div>
    <h1 class="text-3xl">{{title}}</h1>
    {{#if custom_excerpt}} // 👈 Si existe el resumen lo mostramos
	    <p class="text-left">{{custom_excerpt}}</p>
    {{/if}}
    <section class="flex justify-start">
      <ul class="author-list">
        {{#foreach authors}}
	       // 👆 interamos sobre los autores y agregamos estructura html
        {{/foreach}}
      </ul>
    </section>
    {{#if feature_image}} // 👈 Si existe imagen principal la mostramos
    <figure class="w-full mt-[8vmin] lg:w-[1200px] md:mx-auto">
      <picture>
        <source srcset="...tamanios, formatos y urls">
        
        <img class="ms-auto me-auto w-full"
          srcset=""
          src="{{img_url feature_image size=" l"}}"
          alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}">
      </picture>
      {{#if feature_image_caption}}
      <figcaption>{{feature_image_caption}}</figcaption>
      {{/if}}
    </figure>
    {{/if}}
  </header>
  <div class="gh-content gh-canvas md:w-[720px] mx-4 md:mx-auto">
    {{content}} // 👈 Aqui va el contenido del post
  </div>
</article>
{{/post}}

Ahora vamos con los archivos de Handlebars opcionales, vamos a comenzar con lo que tenemos en la carpeta /partials :

card.hbs

Digamos que este es uno de mis archivos principales, muestra una card por cada post, contiene la imagen principal, etiquetas, titulo, el excerpt (resumen), autor y la fecha.

En la mayoría de paginas de este blog existen cards, se encuentran en el home, la pagina de tags, la pagina de autor y la pagina de error.

<article class="post-card {{post_class}}{{#match @custom.feed_layout "Classic"}}{{#is "home"}}{{#has index="0"}} post-card-large{{/has}}{{#has index="1,2"}} dynamic{{/has}}{{/is}}{{/match}}{{#match @custom.feed_layout "Grid"}} keep-ratio{{/match}}{{#match @custom.feed_layout "List"}}{{#is "home, paged"}} post-card-large{{/is}}{{/match}}{{#unless access}} post-access-{{visibility}}{{/unless}}">
  {{#if feature_image}}
    <a class="post-card-image-link relative overflow-hidden block mb-8 after:block after:pb-[55%] after:content-['']" href="{{url}}">
      <img class="post-card-image rounded-3xl"
        srcset="{{img_url feature_image size="s"}} 300w,
                {{img_url feature_image size="m"}} 600w,
                {{img_url feature_image size="l"}} 1000w,
                {{img_url feature_image size="xl"}} 2000w"
        sizes="(max-width: 1000px) 400px, 800px"
        src="{{img_url feature_image size="m"}}"
        alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"
        loading="lazy"
      />
    </a>
  {{/if}}
  <div class="post-card-content">
    <a class="post-card-content-link" href="{{url}}">
      <header class="post-card-header">
        {{#if tags}}
          <div class="post-card-tags flex flex-wrap">
            {{#foreach tags limit="3"}}
              <div class="post-card-primary-tag {{#has index="0"}} tag-red{{/has}}
                  {{#has index="1"}} tag-orange{{/has}} {{#has index="2"}} tag-purple{{/has}}
                  px-[2px] flex justify-center align-middle text-center"
              >
                <span class="block bg-background px-4 rounded-full my-[2px]">
                  {{name}}
                </span>
              </div>
            {{/foreach}}
          </div>
        {{/if}}
        <h2 class="post-card-title">
          {{title}}
        </h2>
      </header>
      {{#if excerpt}}
        <div class="post-card-excerpt">{{excerpt}}</div>
      {{/if}}
    </a>
    <footer class="post-card-meta">
      <section class="article-byline-content">
        {{#if authors}}
          <ul class="author-list w-1/4">
            {{#foreach authors}}
              <li class="author-list-item">
                <a href="{{url}}" class="author-avatar">
                  <img class="w-1/2 rounded-full" src="{{img_url profile_image size="xs"}}" alt="{{name}}" />
                </a>
              </li>
            {{/foreach}}
          </ul>
        {{/if}}
        <div class="w-full text-lg">
          <h4 class="author-name">{{authors}}</h4>
          <div>
            <time datetime="{{date format="YYYY-MM-DD"}}">{{date}}</time>
            {{#if reading_time}}
              <span>&bull;</span> {{reading_time}}
            {{/if}}
          </div>
        </div>
      </section>
    </footer>
  </div>
</article>

header.hbs

Este es el menú superior que es sticky, contiene el icono, nombre del blog unos links y el search. Lo vamos a encontrar por todo el blog.

<header class="sticky top-0 z-20 backdrop-blur">
  <nav class="max-w-[1200px] mx-auto flex justify-between items-center h-16 px-4 xl:p-0">
    <div>
      <a href="{{@site.url}}">
        {{#if @site.logo}}
        <div class="flex items-center">
          <img src="{{@site.logo}}" alt="{{@site.title}}" width="48" height="48" />
          <div class="text-2xl ml-4">
            {{@site.title}}
          </div>
        </div>
        {{else}}
        {{@site.title}}
        {{/if}}
      </a>
    </div>
    <div id="head-menu"
      class="head-menu hidden md:block absolute md:static w-screen md:w-auto h-screen md:h-auto top-[66px] left-0 justify-center items-start">
      {{navigation}}
    </div>
    <div id="head-actions"
      class="hidden md:block absolute md:static w-screen md:w-auto h-screen md:h-auto top-[250px] left-0 justify-center items-center">
      <div>
        {{search}}
      </div>
    </div>
    <div role="button" aria-hidden="true" class="burger-button h-8 w-8 md:hidden relative">
      <div class="absolute h-8 w-8 left-0 top-4 flex flex-col items-center">
        <div class="burger-element -translate-y-3" />
      </div>
      <div class="burger-element" />
    </div>
    <div class="burger-element translate-y-3" />
    </div>
    </div>
    </div>
  </nav>
  <div class="purple-gradient w-full h-0.5"></div>
</header>

footer.hbs

Es el footer que tiene unos iconos como links y un botón que te lleva a la parte superior de la pagina que se este visualizando. También lo vamos a encontrar por todo el blog.

<footer class="text-xl md:text-2xl mt-16">
  <div class="purple-gradient w-full h-0.5"></div>
  <div class="max-w-[1200px] mx-auto flex flex-col md:flex-row justify-between items-center md:w-[720px] lg:w-full py-4">
    <section class="copyright my-3 md:my-0 md:w-1/3 justify-start"><a href="{{@site.url}}">{{@site.title}}</a> &copy;
      {{date format="YYYY"}}</section>
    <div class="footer-social my-3 md:my-0 flex md:w-1/3 justify-center">
      <a class="block w-8 h-8 md:w-10 md:h-10" href="<https://github.com/Sharmaz>">{{> "icons/github"}}</a>
      <a class="block w-8 h-8 md:w-10 md:h-10" href="<https://www.linkedin.com/in/ivanroblesalonso/>">{{>
        "icons/linkedin"}}</a>
      <a class="block w-8 h-8 md:w-10 md:h-10" href="<https://twitter.com/elSharmaz>">{{> "icons/twitter"}}</a>
    </div>
    <div class="my-3 md:my-0 md:w-1/3 flex justify-end">
      <a href="#"
        class="flex justify-center items-center w-10 h-10 border-solid border-2 border-dark-orchid rounded-full -rotate-90">
        <div class=" w-4 h-4 flex justify-center items-center">
          {{> "icons/arrow-right"}}
        </div>
      </a>
    </div>
  </div>
</footer>

github.hbs

Los iconos en SVG  van a estar en formato .hbs como por ejemplo este icono de github en partial/icons/github.hbs:

<?xml version="1.0" ?><svg data-name="Layer 1" viewBox="0 0 24 24" xmlns="<http://www.w3.org/2000/svg>"><path d="M12,2.2467A10.00042,10.00042,0,0,0,8.83752,21.73419c.5.08752.6875-.21247.6875-.475,0-.23749-.01251-1.025-.01251-1.86249C7,19.85919,6.35,18.78423,6.15,18.22173A3.636,3.636,0,0,0,5.125,16.8092c-.35-.1875-.85-.65-.01251-.66248A2.00117,2.00117,0,0,1,6.65,17.17169a2.13742,2.13742,0,0,0,2.91248.825A2.10376,2.10376,0,0,1,10.2,16.65923c-2.225-.25-4.55-1.11254-4.55-4.9375a3.89187,3.89187,0,0,1,1.025-2.6875,3.59373,3.59373,0,0,1,.1-2.65s.83747-.26251,2.75,1.025a9.42747,9.42747,0,0,1,5,0c1.91248-1.3,2.75-1.025,2.75-1.025a3.59323,3.59323,0,0,1,.1,2.65,3.869,3.869,0,0,1,1.025,2.6875c0,3.83747-2.33752,4.6875-4.5625,4.9375a2.36814,2.36814,0,0,1,.675,1.85c0,1.33752-.01251,2.41248-.01251,2.75,0,.26251.1875.575.6875.475A10.0053,10.0053,0,0,0,12,2.2467Z"/></svg>

Fuera del directorio partials vamos a contar con estos otros archivos .hbs que son opcionales pero no menos importantes.

home.hbs

Aquí se provee contenido para el home page, yo decidí no utilizarlo, debido a que con renderear las cards de los posts en el index, me es suficiente.

page.hbs

Para paginas estáticas, es opcional y si no creamos uno va a tomar lo que tengamos en post, para las paginas. Yo si tengo uno de estos:

{{!< default}}
{{#post}}
  <article class="py-[8vmin] page-content {{post_class}}">
    {{#match @page.show_title_and_feature_image}}
      <header class="pb-8 gh-canvas md:w-[720px] mx-4 md:mx-auto">
        <h1 class="font-bold text-3xl md:text-5xl text-left">{{title}}</h1>
          {{#if feature_image}}
            <figure class="w-full mt-[8vmin]">
              <img class="ms-auto me-auto w-full" src="{{feature_image}}" alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}" />
                {{#if feature_image_caption}}
                  <figcaption>{{feature_image_caption}}</figcaption>
                {{/if}}
            </figure>
          {{/if}}
      </header>
    {{/match}}
    <div class="gh-content gh-canvas md:w-[720px] mx-4 md:mx-auto">
      {{content}}
    </div>
  </article>
{{/post}}

tag.hbs

Este es para crear una pagina donde se listen las cards de todos los posts referentes a una tag, también tengo uno de estos:

{{!< default}}
<div class="page">
  <div class="pb-0 w-full px-4 md:px-6 lg:px-0 lg:max-w-[1200px] my-0 lg:mx-auto">
    {{#tag}}
      <header class="my-0 mx-auto pt-[8vmin] pb-[4vmin] text-center">
        <h1 class="text-2xl md:text-4xl font-bold text-left">{{name}}</h1>
        <p class="text-base md:text-2xl text-left mx-0 px-0">
          {{#if description}}
            {{description}}
          {{else}}
            A collection of {{plural ../pagination.total empty='posts' singular='% post' plural='% posts'}}
          {{/if}}
        </p>
        {{#if feature_image}}
          <img class="mt-[4vmin]" src="{{feature_image}}" alt="{{name}}" />
        {{/if}}
      </header>
    {{/tag}}
    <div
      class="post-feed relative grid gap-10 md:gap-y-[4.8vmin] md:gap-x-[4vmin] grid-cols-[1fr] md:grid-cols-4 lg:grid-cols-6 py-12">
      {{#foreach posts}}
        {{> "card"}} // 👈 partial card.hbs por cada post
      {{/foreach}}
    </div>
  </div>
</div>

author.hbs

Esta es una pagina que contiene información del autor y una lista en cards de los posts escritos por ese autor:

{{!< default}}
<div class="page">
  <div class="w-full px-4 md:px-6 lg:px-0 lg:max-w-[1200px] my-0 lg:mx-auto">
    {{#author}}
      <header class="my-0 mx-auto pt-[8vmin] pb-[4vmin] text-center">
        {{#if profile_image}}
          <img class="mx-auto mb-6 rounded-full overflow-hidden object-cover w-[64px] md:w-[150px]" src="{{profile_image}}"
            alt="{{name}}" />
        {{/if}}
        <h1 class="text-2xl md:text-5xl font-bold">{{name}}</h1>
        {{#if bio}}
          <p class="text-base md:text-2xl my-4 px-[6vmin] opacity-50">{{bio}}</p>
        {{/if}}
        <div class="text-2xl">
          <div class="author-links">
            {{#if website}}
              <a href="{{website}}" target="_blank" rel="noopener">Website</a>
            {{/if}}
            {{#if twitter}}
              <a href="{{twitter_url}}" target="_blank" rel="noopener">Twitter</a>
            {{/if}}
            {{#if facebook}}
              <a href="{{facebook_url}}" target="_blank" rel="noopener">Facebook</a>
            {{/if}}
          </div>
        </div>
        {{#if cover_image}}
          <img class="mt-[4vmin]" src="{{cover_image}}" alt="{{name}}" />
        {{/if}}
      </header>
    {{/author}}
    <div
      class="post-feed relative grid gap-10 md:gap-y-[4.8vmin] md:gap-x-[4vmin] grid-cols-[1fr] md:grid-cols-4 lg:grid-cols-6 py-12">
      {{#foreach posts}}
        {{> "card"}}
      {{/foreach}}
    </div>
  </div>
</div>

error.hbs

Not found, Server error, etc. Estos se muestran con este archivo, o incluso puedes crear específicamente uno para cada error:

<!DOCTYPE html>
<html lang="{{@site.locale}}" class="scroll-smooth">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="{{asset "built/index.css"}}" />
  <script src="{{asset "built/index.js"}}" defer></script>
  <title>{{meta_title}}</title>
  {{ghost_head}}
</head>
<body class="{{body_class}}">
<div>
  {{> "header"}}
  <main>
    <section class="gh-error">
      <div class="w-full px-4 md:px-6 lg:px-0 lg:max-w-[1200px] my-0 lg:mx-auto">
        <section class="flex flex-col justify-center items-center my-16">
            <h1 class="font-bold text-9xl">{{statusCode}}</h1>
            <p class="text-3xl">{{message}}</p>
            <p class="mt-4 underline underline-offset-[5px] decoration-dark-orchid"><a href="{{@site.url}}">Go to the front page →</a></p>
            {{#if errorDetails}}
              <section>
                <h4>Theme errors:</h4>
                <ul>
                  {{#foreach errorDetails}}
                    <li>
                      <h5>{{{rule}}}</h5>
                      {{#foreach failures}}
                        <span><strong>Ref:</strong> {{ref}}</span><br>
                        <span><strong>Message:</strong> {{message}}</span>
                      {{/foreach}}
                    </li>
                  {{/foreach}}
                </ul>
              </section>
            {{/if}}
        </section>
        {{#get "posts" limit="3" as |more_posts|}}
          {{#if more_posts}}
            <aside class="read-more-wrap outer mt-24 md:w-[720px] lg:w-[1200px] mx-4 md:mx-auto">
              <h3 class="text-xl font-bold my-4">Otros Posts...</h3>
              <div class="read-more inner grid gap-[4vmin] md:grid-cols-4 lg:grid-cols-6">
                {{#foreach more_posts}}
                  {{> "card"}}
                {{/foreach}}
              </div>
            </aside>
          {{/if}}
        {{/get}}
      </div>
    </section>
  </main>
  {{> "footer"}}
</div>
{{ghost_foot}}
</body>
</html>

Conclusión

El gusto se rompe en géneros,  existen muchos colores y sabores en esto del templating. Bueno en realidad todo depende de las necesidades. La necesidad que yo estoy cubriendo con mi maravilloso tema The Sharmaz 🤩 es la de tener un tema para mi blog.

En caso de necesitar (o querer) cosas mas allá de mostrar las entradas de blog, lo que vendría siendo un tema multi funcional hay que ir agregando mas partials, investigar y leer acerca de como extender estas funcionalidades dentro del CMS de Ghost.

Nos leemos luego!!