Cómo simplificar la creación de contenidos con layouts de Astro
En “Cómo refatorizar Astro para mejorar la mantenibilidad del código”, te contaba cómo simplificar el código para facilitar el mantenimiento. Pero podemos hacer más para facilitarnos la vida a la hora de crear contenidos.
Por ejemplo, ahora mismo aún estamos obligados a tocar cierta cantidad de código para crear páginas, cuando lo ideal es poder centrarse solo en el contenido.
¡Ojo! No digo que solo tenga que haber contenido puro en las páginas. Lo que digo es que no debería ser necesario tocar código para tener una nueva página.
Por eso, en este artículo vamos a centrarnos en simplificar el código de las páginas hasta el punto que podamos crear una página únicamente con contenido MarkDown.
Un layout
para dominarlos a todos
Cuando mencionaba el código duplicado en el artículo anterior, ponía como ejemplo la estructura base del HTML de la web:
---
import Head from '@components/Head.astro';
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';
---
<!doctype html>
<html lang="en">
<Head title={...} description={...}>
...
</Head>
<body>
<Header />
<main>
...
</main>
<Footer />
</body>
</html>
Podemos extraerlo en un layout
genérico para evitar duplicarlo cada vez que queramos crear una página, archivo o tipo de contenido nuevo.
Para ello, necesitamos tener en cuenta algunas cosas:
1. las partes que he sustituido por puntos suspensivos (…),
tendrán que ser sustituidas por parámetros o slots
de Astro.
En este caso, los parámetros de Head
los recogeremos como parámetros y los puntos suspensivos bajo el Head
y dentro del main
pasarán a ser slots
. Además, como solo puede haber un slot
anónimo, nombraremos el slot
del head
como tal.
Tras los cambios, el código quedará así:
---
import Head from '@components/Head.astro';
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';
type Props = {
description: string;
title: string;
}
const { description, title } = Astro.props;
---
<!doctype html>
<html lang="en">
<Head
description={description}
title={title}
>
<slot name="head" />
</Head>
<body>
<Header />
<main>
<slot />
</main>
<Footer />
</body>
</html>
Ahora, guarda el código en un nuevo archivo llamado src/layouts/MainLayout.astro
y refactoriza:
1. src/layouts/PageContent.astro
, que quedará así:
---
import type { CollectionEntry } from 'astro:content';
import Date from '@components/Date.astro';
import MainLayout from '@layouts/MainLayout.astro';
import type { Collection } from '@/types/collections.d';
type Props = CollectionEntry<Collection>['data'];
const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
---
<MainLayout
description={description}
title={title}
>
<style slot="head">
...
</style>
<article>
<div class="hero-image">
{heroImage && <img width={1020} height={510} src={heroImage} alt="" />}
</div>
<div class="prose">
<div class="title">
<div class="date">
<Date date={pubDate} />
{updatedDate && (
<div class="last-updated-on">
Last updated on <Date date={updatedDate} />
</div>
)}
</div>
<h1>{title}</h1>
<hr />
</div>
<slot />
</div>
</article>
</MainLayout>
2. src/pages/blog/index.astro
, que quedará así:
---
import { getCollection } from 'astro:content';
import Date from '@/components/Date.astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '@/consts';
import MainLayout from '@/layouts/MainLayout.astro';
import type { Collection, Post } from '@/types/collections.d';
const collection: Collection = "blog";
const posts: Post[] = (await getCollection(collection)).sort(
(a: Post, b: Post) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<MainLayout
description={SITE_DESCRIPTION}
title={SITE_TITLE}
>
<style slot="head">
...
</style>
<section>
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.slug}/`}>
<img width={720} height={360} src={post.data.heroImage} alt="" />
<h4 class="title">{post.data.title}</h4>
<p class="date">
<Date date={post.data.pubDate} />
</p>
</a>
</li>
))}
</ul>
</section>
</MainLayout>
3. src/pages/index.astro
, que quedará así:
---
import { SITE_TITLE, SITE_DESCRIPTION } from '@/consts';
import MainLayout from '@/layouts/MainLayout.astro';
---
<MainLayout
description={SITE_DESCRIPTION}
title={SITE_TITLE}
>
<h1>🧑🚀 Hello, Astronaut!</h1>
<p>
Welcome to the non-official <a href="https://astro.build/">Astro</a> blog starter template
created by <a href="https://github.com/borjalofe">Borja LoFe</a>. This template serves as a
lightweight, minimally-styled starting point for anyone looking to build a personal website,
blog, or portfolio with Astro.
</p>
<p>
This template comes with a few integrations already configured in your
<code>astro.config.mjs</code> file. You can customize your setup with
<a href="https://astro.build/integrations">Astro Integrations</a> to add tools like Tailwind,
React, or Vue to your project.
</p>
<p>Here are a few ideas on how to get started with the template:</p>
<ul>
<li>Edit this page in <code>src/pages/index.astro</code></li>
<li>Edit the site header items in <code>src/components/Header.astro</code></li>
<li>Add your name to the footer in <code>src/components/Footer.astro</code></li>
<li>Check out the included blog posts in <code>src/content/blog/</code></li>
<li>Customize the blog post page layout in <code>src/layouts/BlogPost.astro</code></li>
</ul>
<p>
Have fun! If you get stuck, remember to <a href="https://docs.astro.build/">read the docs</a>
or <a href="https://github.com/borjalofe/astro-blog-template/issues">create an issue</a> to
ask questions.
</p>
</MainLayout>
2. la etiqueta main
debe contener únicamente el contenido específico de la página (el contenido del artículo, por ejemplo), pero nosotros deberíamos poder poner contenido secundario antes y/o después de esa etiqueta. Podemos solucionarlo de 2 maneras:
- no ponemos
main
en estelayout
y recordamos ponerlo en cada nuevolayout
, componente de página, … que creemos a partir de ahora, - creamos
slots
nombrados para añadir contenido antes y después delmain
Por comodidad, he elegido la segunda opción, por lo que he ido al archivo src/layouts/MainLayout.astro
y he añadido los slots
nombrados.
Tras los cambios, el código queda tal que así:
---
import Head from '@components/Head.astro';
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';
type Props = {
description: string;
title: string;
}
const { description, title } = Astro.props;
---
<!doctype html>
<html lang="en">
<Head
description={description}
title={title}
>
<slot name="head" />
</Head>
<body>
<Header />
<slot name="pre-content" />
<main>
<slot />
</main>
<slot name="post-content" />
<Footer />
</body>
</html>
Escribir sin preocuparse del código
Ahora mismo, las páginas son archivos de Astro (*.astro
) en los que siempre tendremos que recordar añadir el contenido dentro de la etiqueta PageContent
.
Además, si quisiéramos exportar el contenido, tendremos que eliminar el código de Astro para poder hacerlo.
Entre el posible olvido y la necesidad de limpiar para exportar, ¿no sería mejor evitar el código (de Astro o del que sea) en las páginas con contenido?
En mi caso, sí.
Por otro lado, reducir los archivos de contenido a MarkDown no impide que luego usemos MDX (MarkDown + XML (e.g. JSX)) en un futuro.
Sin embargo, este cambio implica que necesitamos gestionar cómo pasamos los parámetros necesarios a los archivos de Astro, especialmente cuando estamos usando TypeScript y eso nos exige un tipo para ello.
Dentro src/types/common.frontmatter.d.ts
Creamos el archivo src/types/common.frontmatter.d.ts
con las propiedades de Frontmatter que pueden ser comunes a todos los archivos de contenido (de ahí el nombre “Common Frontmatter”):
export type CommonFrontmatter = {
title: string;
description: string;
heroImage?: string;
}
Puedes ver que los datos que van a tener todos los contenidos en común son:
- tiltulo,
- descripción,
- imagen de portada (o imagen destacada en algunos sistemas), opcional
Los layouts
aceptarán una prop con el frontmatter
que acabamos de crear
Bueno, en realidad, Astro ya gestiona que puedas acceder al frontmatter de un archivo MarkDown desde una propiedad frontmatter.
Nosotros la vamos a aprovechar para poder pasar nuestro propio frontmatter.
De hecho, puesto que en nuestro src/layouts/MainLayout.astro
todas las propiedades de frontmatter se pasan al componente src/components/Head.astro
, vamos a modificar éste, que pasará de:
---
// Import the global.css file here so that it is included on
// all pages through the use of the <Head /> component.
import '@styles/global.css';
interface Props {
title: string;
description: string;
image?: string;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image = '/blog-placeholder-1.jpg' } = Astro.props;
---
...
a:
---
// Import the global.css file here so that it is included on
// all pages through the use of the <Head /> component.
import '@styles/global.css';
import { type CommonFrontmatter } from '@/types/common.frontmatter';
type Props = {
frontmatter: CommonFrontmatter,
}
const {
frontmatter: {
description,
title,
heroImage,
}
} = Astro.props;
const image = heroImage ?? '/blog-placeholder-1.jpg';
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
---
...
Esto implica cambiar src/layouts/MainLayout.astro
de:
---
import Head from '@components/Head.astro';
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';
type Props = {
description: string;
title: string;
}
const { description, title } = Astro.props;
---
<!doctype html>
<html lang="en">
<Head
description={description}
title={title}
>
...
a:
---
import Head from '@components/Head.astro';
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';
import { type CommonFrontmatter } from '@/types/common.frontmatter';
type Props = {
frontmatter: CommonFrontmatter,
}
const { frontmatter } = Astro.props;
---
<!doctype html>
<html lang="en">
<Head
frontmatter={frontmatter}
>
...
También necesitaremos cambiar src/layouts/PageContent.astro
de:
---
...
import type { Collection } from '@/types/collections.d';
type Props = CollectionEntry<Collection>['data'];
const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
---
<MainLayout
description={description}
title={title}
>
...
a:
---
...
import type { Collection } from '@/types/collections.d';
import { type CommonFrontmatter } from '@/types/common.frontmatter';
type Props = CollectionEntry<Collection>['data'];
const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
const frontmatter: CommonFrontmatter = {
title,
description,
}
---
<MainLayout
frontmatter={frontmatter}
>
...
Finalmente, tendremos que cambiar el archivo src/pages/about.astro
, que usa el anterior layout, de:
---
import PageContent from '@/layouts/PageContent.astro';
---
<PageContent
title="About Me"
description="Lorem ipsum dolor sit amet"
pubDate={new Date('August 08 2021')}
heroImage="/blog-placeholder-about.jpg"
>
a:
---
import type { CollectionEntry } from 'astro:content';
import PageContent from '@/layouts/PageContent.astro';
import type { Collection } from '@/types/collections.d';
const props: CollectionEntry<Collection>['data'] = {
title: 'About Me',
description: 'Lorem ipsum dolor sit amet',
heroImage: '/blog-placeholder-about.jpg',
pubDate: new Date('August 08 2021'),
};
---
<PageContent
{...props}
>
Una pequeña reflexión
Estamos usando una plantilla que está creada para contenido de colecciones (en este caso, el blog) para una página (que no pertenece a ninguna colección). De hecho, ¿necesitamos la fecha de publicación de la página? ¿Importa cuándo se publicó una página “de Inicio”, “Sobre mí”, “de Contacto”…
Porque sí es importante saber cuándo se publicó/actualizó la información de un artículo pero ¿de una página?
Por eso, vamos a dividir src/layouts/PageContent.astro
en dos layouts:
src/layouts/PageLayout.astro
gestionará las páginas (es decir, los archivos que estén ensrc/pages
),src/layouts/PostLayout.astro
gestionará los artículos de las colecciones (es decir, los archivos que estén ensrc/content
),
Las únicas diferencias ahora mismo son:
- el contenido que gestione
src/layouts/PostLayout.astro
se renderizará dentro de la etiquetaarticle
, src/layouts/PostLayout.astro
mostrará fecha de publicación y actualización,src/layouts/PageLayout.astro
no mostrará el título de la página configurado en elfrontmatter
,src/layout/PageLayout.astro
tendrá una única prop llamadafrontmatter
del tipoCommonFrontmatter
mientras quesrc/layout/PostLayout.astro
se quedará con las props actuales del tipoCollectionEntry<Collection>['data]
,
Además, esto quiere decir que ya no habrá archivos que usen src/layouts/MainLayout.astro
como layout, por lo que lo moveremos a la carpeta src/components
y, al no ser ya un layout, lo renombraremos como HTML.astro
.
Tras los cambios, src/layouts/PostLayout.astro
quedará así:
---
import type { CollectionEntry } from 'astro:content';
import Date from '@components/Date.astro';
import HTML from '@components/HTML.astro';
import type { Collection } from '@/types/collections.d';
import { type CommonFrontmatter } from '@/types/common.frontmatter';
type Props = CollectionEntry<Collection>['data'];
const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
const frontmatter: CommonFrontmatter = {
title,
description,
}
---
<HTML
frontmatter={frontmatter}
>
<article class="prose">
<div class="hero-image">
{heroImage && <img width={1020} height={510} src={heroImage} alt="" />}
</div>
<div class="title">
<div class="date">
<Date date={pubDate} />
{updatedDate && (
<div class="last-updated-on">
Last updated on <Date date={updatedDate} />
</div>
)}
</div>
<h1>{title}</h1>
<hr />
</div>
<slot />
</article>
<style>
.hero-image {
width: 100%;
}
.hero-image img {
display: block;
margin: 0 auto;
border-radius: 12px;
box-shadow: var(--box-shadow);
}
.prose {
width: 720px;
max-width: calc(100% - 2em);
margin: auto;
padding: 1em;
color: rgb(var(--gray-dark));
}
.title {
margin-bottom: 1em;
padding: 1em 0;
text-align: center;
line-height: 1;
}
.title h1 {
margin: 0 0 0.5em 0;
}
.date {
margin-bottom: 0.5em;
color: rgb(var(--gray));
}
.last-updated-on {
font-style: italic;
}
</style>
</HTML>
Y src/layouts/PageLayout.astro
así:
---
import HTML from '@components/HTML.astro';
import { type CommonFrontmatter } from '@/types/common.frontmatter';
type Props = {
frontmatter: CommonFrontmatter;
};
const { frontmatter } = Astro.props;
const { heroImage } = frontmatter;
---
<HTML
frontmatter={frontmatter}
>
<div class="prose">
<div class="hero-image">
{heroImage && <img width={1020} height={510} src={heroImage} alt="" />}
</div>
<slot />
</div>
<style>
.hero-image {
width: 100%;
}
.hero-image img {
display: block;
margin: 0 auto;
border-radius: 12px;
box-shadow: var(--box-shadow);
}
.prose {
width: 720px;
max-width: calc(100% - 2em);
margin: auto;
padding: 1em;
color: rgb(var(--gray-dark));
}
</style>
</HTML>
Quitar el código de las páginas y artículos
Con los cambios que hemos hecho, podemos hacer que todas las páginas se conviertan en archivos de MarkDown o MDX. El caso más sencillo es la página src/pages/about.astro
. Si le cambiamos la extensión a .md
y eliminamos el código, se quedará así:
---
title: About Me
description: Lorem ipsum dolor sit amet
heroImage: /blog-placeholder-about.jpg
---
Lorem ipsum...
Por supuesto, esto no aplica ningún layout pero, por fortuna, Astro nos tiene cubiertos en eso: basta con añadir el atributo layout
al frontmatter con el layout que queramos aplicar y eso será suficiente para corregirlo.
Tras este cambio, el archivo te quedará así:
---
title: About Me
description: Lorem ipsum dolor sit amet
layout: '@layouts/PageLayout.astro'
heroImage: /blog-placeholder-about.jpg
---
Lorem ipsum...
Y se verá como debe.
La página de inicio es un caso especial
En general, en la página de inicio queremos que el título y la descripción que aparezcan en las meta etiquetas y etiquetas del head
sean los de la web en sí.
Podríamos añadir código a src/layouts/PageLayout.astro
para gestionar este caso, pero es más sencillo crear un nuevo layout y gestionarlo:
- crea
src/layouts/HomepageLayout.astro
, - añade el siguiente código en el archivo:
--- import { SITE_TITLE, SITE_DESCRIPTION } from '@/consts'; import PageLayout from '@layouts/PageLayout.astro'; import { type CommonFrontmatter } from '@/types/common.frontmatter'; type Props = { frontmatter: CommonFrontmatter; }; const { frontmatter } = Astro.props; const homeFrontmatter: CommonFrontmatter = { ...frontmatter, title: SITE_TITLE, description: SITE_DESCRIPTION, }; --- <PageLayout frontmatter={homeFrontmatter} > <slot /> </PageLayout>
- refactoriza
src/pages/index.astro
comosrc/pages/index.md
y, tras quitar el código, te quedará como sigue:
--- title: Home description: This is the homepage layout: '@layouts/HomepageLayout.astro' --- # 🧑🚀 Hello, Astronaut! Welcome to the non-official [Astro](https://astro.build/) blog starter templatecreated by [Borja LoFe](https://github.com/borjalofe). This template serves as a lightweight, minimally-styled starting point for anyone looking to build a personal website,blog, or portfolio with Astro. This template comes with a few integrations already configured in your`astro.config.mjs` file. You can customize your setup with [Astro Integrations](https://astro.build/integrations) to add tools like Tailwind,React, or Vue to your project. Here are a few ideas on how to get started with the template: - Edit this page in `src/pages/index.astro` - Edit the site header items in `src/components/Header.astro` - Add your name to the footer in `src/components/Footer.astro` - Check out the included blog posts in `src/content/blog/` - Customize the blog post page layout in `src/layouts/BlogPost.astro` Have fun! If you get stuck, remember to [read the docs](https://docs.astro.build/) or [create an issue](https://github.com/borjalofe/astro-blog-template/issues) toask questions.
- refactoriza
El caso no tan especial de la página de blog
La página de blog no deja de ser una página de archivo para la colección blog
… y podemos tener muchas otras colecciones cada una con su propio archivo.
Por eso, vamos a crear un nuevo tipo y layout para gestionar los archivos.
Necesitaremos el tipo para poder definir qué colección se renderizará en el archivo
Frontmatter para Archivo
Puesto que queremos poder definir las páginas con MarkDown básico (sin desdeñar MDX), toda la configuración del archivo debe poder pasarse como propiedades de frontmatter.
Para eso, vamos a crear el archivo src/types/archive.frontmatter.ts
y vamos a añadir el siguiente código en él:
import type { Collection } from '@/types/collections.d';
import type { CommonFrontmatter } from "@/types/common.frontmatter";
export type ArchiveFrontmatter = CommonFrontmatter & {
collection: Collection;
}
Nota:
A diferencia de las interfaces (que solo permiten extender de ellas), los tipos permiten aplicar operaciones de grupo sobre ellos para crear nuevos tipos.
Por eso, si ves la definición de
Collection
, ahora mismo aparece solo “blog”, pero podemos añadir nuevas colecciones con el operadoro
(que se representa con la barra vertical|
) de manera que fijamos las opciones a elegir para una colección.En el caso del tipo
ArchiveFrontmatter
estamos usando el operadory
generando una unión de tipos. Es decir,ArchiveFrontmmater
tiene lo mismo queCommonFrontmatter
y, además, la propiedadcollection
.De esta forma, podemos generar tipos nuevos con facilidad.
Layout para Archivo
Vamos a crear el layout para archivo (en el archivo src/layouts/ArchiveLayout.astro
) siguiendo la misma lógica que para crear el de la página de inicio: importando el layout de páginas y añadiendo lo que es específico del archivo.
Por eso, creamos el archivo src/layout/ArchiveLayout.astro
y escribimos el siguiente código en él:
---
import { getCollection } from 'astro:content';
import Date from '@components/Date.astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '@/consts';
import PageLayout from '@layouts/PageLayout.astro';
import type { Post } from '@/types/collections.d';
import { type ArchiveFrontmatter } from '@/types/archive.frontmatter';
import { type CommonFrontmatter } from '@/types/common.frontmatter';
type Props = {
frontmatter: ArchiveFrontmatter;
};
const { frontmatter } = Astro.props;
const homeFrontmatter: CommonFrontmatter = {
...frontmatter,
title: SITE_TITLE,
description: SITE_DESCRIPTION,
};
const { collection } = frontmatter;
const posts: Post[] = (await getCollection(collection)).sort(
(a: Post, b: Post) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<PageLayout
frontmatter={homeFrontmatter}
>
<section>
<ul>
{
posts.map((post) => (
<li>
<a href={`/blog/${post.slug}/`}>
<img width={720} height={360} src={post.data.heroImage} alt="" />
<h4 class="title">{post.data.title}</h4>
<p class="date">
<Date date={post.data.pubDate} />
</p>
</a>
</li>
))
}
</ul>
</section>
<style>
ul {
display: flex;
flex-wrap: wrap;
gap: 2rem;
list-style-type: none;
margin: 0;
padding: 0;
}
ul li {
width: calc(50% - 1rem);
}
ul li * {
text-decoration: none;
transition: 0.2s ease;
}
ul li:first-child {
width: 100%;
margin-bottom: 1rem;
text-align: center;
}
ul li:first-child img {
width: 100%;
}
ul li:first-child .title {
font-size: 2.369rem;
}
ul li img {
margin-bottom: 0.5rem;
border-radius: 12px;
}
ul li a {
display: block;
}
.title {
margin: 0;
color: rgb(var(--black));
line-height: 1;
}
.date {
margin: 0;
color: rgb(var(--gray));
}
ul li a:hover h4,
ul li a:hover .date {
color: rgb(var(--accent));
}
ul a:hover img {
box-shadow: var(--box-shadow);
}
@media (max-width: 720px) {
ul {
gap: 0.5em;
}
ul li {
width: 100%;
text-align: center;
}
ul li:first-child {
margin-bottom: 0;
}
ul li:first-child .title {
font-size: 1.563em;
}
}
</style>
</PageLayout>
Si comparas este código con el de src/pages/blog/index.astro
verás que solo hay 2:
- hemos definido una propiedad
frontmatter
del tipo que acabamos de crear, - y, no hay una
collection
definida.
Con esto, tenemos un layout de archivo para colecciones completamente genérico.
Archivo de blog
Definir un archivo para colección es de lo más sencillo ahora. Solo necesitamos crear el archivo index.md
en la carpeta src/pages/<collection>/
donde <collection>
es el nombre de la colección.
Puesto que en este caso es el blog, sustituiremos el archivo src/pages/blog/index.astro
por el archivo src/pages/blog/index.md
, que tendrá el siguiente código:
---
title: Blog
description: These are our blog's posts
layout: '@layouts/ArchiveLayout.astro'
collection: blog
---
¿Sencillo, verdad?
Una pequeña trampa
Gracias por llegar hasta aquí. En próximos artículos, seguiré contando cómo crear una plantilla personalizada.
Aunque si no quieres crearla, puedes usar la mía (la que tendrás, más o menos, si sigues todos los pasos de todos los artículos de esta serie) con el siguiente comando:
yarn create astro -- --template borjalofe/astro-blog-template