Criando um feed RSS com Nuxt Content
Disponibilizar um feed RSS quando você está utilizando o Nuxt Content não é difícil, mas você pode acabar encontrando alguns obstáculos no caminho.
Gerando o feed
Iremos começar criando a rota no servidor que irá disponibilizar o RSS. Normalmente é conveniente colocar o feed no mesmo caminho que seu conteúdo está. Por exemplo, se sua lista de posts fica em /blog, o caminho /blog/feed.xml é uma boa escolha.
Usando o Nuxt, podemos criar essa rota ao criar o arquivo server/routes/blog/feed.xml.get.ts.
Esta não é uma rota de API, então não deve ser colocada em /server/api.
O esqueleto do arquivo é basicamente este:
import { queryCollection } from '@nuxt/content/server';
export default defineEventHandler(async (event) => {
const posts = await queryCollection(event, 'blog')
.order('created_at', 'DESC')
.limit(10)
.all();
return posts;
});
O que queremos fazer é manipular as postagens tal que a rota na verdade retorne um feed RSS válido. Podemos usar o pacote rss para nos ajudar, já que ele contém um construtor de RSS que facilita as coisas.
Nós iremos usar a classe RSS.
import RSS from 'rss';
Para começar, precisamos criar o feed e colocar algumas informações básicas.
const url = 'https://exemplo.org';
const feed = new RSS({
title: 'Blog de exemplo',
description: 'Somente um blog.',
site_url: url,
feed_url: `${url}/blog/feed.xml`,
language: 'pt-BR',
custom_elements: [
{ icon: `${url}/img/apple-touch-icon.png` },
],
custom_namespaces: {
content: 'http://purl.org/rss/1.0/modules/content/',
dc: 'http://purl.org/dc/elements/1.1/',
sy: 'http://purl.org/rss/1.0/modules/syndication/',
},
});
Algumas das propriedades são bem diretas. Outras são só complementos, como a custom_namespaces, que permite que o XML gerado tenha tags extras que não estão na especificação do RSS.
Como nós temos uma lista de postagens, podemos iterar sobre ela e criar cada item do feed.
for (const post of posts) {
feed.item({
title: post.title,
guid: `${url}/post/${post.path}`,
url: `${url}/post/${post.path}`,
description: post.description,
date: new Date(post.created_at),
categories: post.category ? [post.category] : undefined,
custom_elements: [
{ 'dc:creator': { _cdata: 'Fulano de Tal' } },
],
});
}
Agora que o feed está completo, podemos definir o cabeçalho Content-Type.
setResponseHeader(event, 'Content-Type', 'text/xml');
Então nós podemos finalmente retornar o feed XML.
return feed.xml();
Como exemplo, você pode dar uma olhada no RSS deste site.
Pré-renderizando
Se seu site usa um deploy com um servidor, já está tudo certo. Mas, se usa um deploy como site estático, como o GitHub Pages, você precisa de uma etapa adicional.
Precisamos informar ao Nuxt para também pré-renderizar esta rota do servidor durante o build. Isso pode ser feito editando a propriedade nitro no arquivo nuxt.config.ts.
export default defineNuxtConfig({
nitro: {
prerender: {
routes: ['/blog/feed.xml'],
},
},
});
Informando leitores de RSS sobre o feed
Você pode por um link para o feed RSS na sua página, mas isso só vai funcionar se o usuário copiar e colar manualmente no seu leitor de RSS. Você pode deixar as coisas um pouco mais fáceis ao usar uma tag <link rel="alternate"> no <head> do seu HTML.
Com isso, se o usuário colar o URL de seu blog no leitor RSS, ele poderá obter o link do RSS automaticamente. No Nuxt, isso pode ser feito com o composable useHead.
useHead({
link: [{
rel: 'alternate',
type: 'application/rss+xml',
title: 'Feed (RSS)',
href: '/blog/feed.xml',
}],
});
Isso não é obrigatório, mas é considerado uma boa prática.
Disponibilizando o HTML da postagem
O feed atual irá funcionar corretamente para todos os usuários, mas nós estamos somente disponibilizando os links: não há conteúdo para as postagens. A maioria dos sites faz somente isso, mas acredito que é uma boa pedida também disponibilizar o conteúdo completo da postagem, assim o usuário pode ler em seu ambiente preferido.
Esta é a parte mais difícil de ser feita com Nuxt Content. No momento que escrevo este artigo, o Nuxt Content não parece ter um método que retorna sua postagem renderizada em HTML no contexto do servidor. Precisamos fazer isso manualmente.
O Nuxt Content usa o @nuxtjs/mdc por baixo dos panos, que por sua vez usa os projetos remark e o rehype do ecossistema do Unified.
Se você der uma olhada na propriedade post.body, pode notar que é uma Árvore Sintática Abstrata (ASA) do Minimark. Poderíamos usar o pacote minimark para obter a string do Markdown, mas eu encontrei alguns problemas de conversão quando tentei. É mais fácil obter o código-fonte do arquivo Markdown diretamente.
Para deixar as coisas reutilizáveis, iremos criar uma função utilitária no servidor chamada markdownToHtml.
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
export async function markdownToHtml(fileName: string) {
const filePath = join(process.cwd(), 'content', `${fileName}.md`);
const markdown = await readFile(filePath, 'utf-8');
}
A parte difícil é criar o pipeline da biblioteca Unified de tal modo que ele faça uma conversão similar a que o Nuxt Content faz. Podemos começar importando os plugins.
import rehypeSlug from 'rehype-slug';
import rehypeStringify from 'rehype-stringify';
import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
import remarkMdc from 'remark-mdc';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';
E então criando o pipeline.
const result = await unified()
.use(remarkParse)
.use(remarkFrontmatter, ['yaml'])
.use(remarkGfm)
.use(remarkMdc)
.use(remarkRehype)
.use(rehypeSlug)
.use(rehypeStringify)
.process(markdown);
return String(result);
Isso irá fazer o parse do Markdown, criar uma ASA, que então irá ser convertida para HAST e processada até que seja reduzida a uma string final com o HTML.
Para a maioria dos casos, isso irá funcionar, mas o MDC suporta componentes Vue, o que é um problema neste caso. A abordagem que eu escolhi foi converter os componentes que eu uso em componentes simples e equivalentes que usam apenas tags já existentes do HTML.
Se você tem algum componente no seu Markdown, o pipeline existente irá colocá-lo no HTML como se ele fosse um elemento personalizado. Por exemplo, o Note.vue, chamado por ::note no Markdown, irá ser transformado em <note></note>, que não é um elemento existente do HTML.
Podemos contornar isso usando o plugin rehype-components. Também iremos precisar instalar o pacote hastscript.
O que esse plugin faz é converter elementos personalizados em outros que você especifica. Podemos colocar ele no pipeline com o seguinte código.
.use(rehypeComponents, {
components: {
'note': Note,
},
})
E então criamos um componente Note para a substituição. Neste caso, o elemento Note é originalmente um callout. A abordagem que eu escolhi foi transformá-lo numa <div> com <p><strong>Nota:</strong></p> no seu primeiro filho.
O componente Note pode ser definido como:
import { h } from 'hastscript';
import type { ComponentFunction } from 'rehype-components';
const Note: ComponentFunction = (_, children) => h('div', [
h('p', h('strong', 'Nota:')),
...children,
]);
Neste exemplo, o seguinte bloco Note no Markdown
::note
Esta é uma nota
::
Será transformada no seguinte HTML:
<div>
<p><strong>Nota:</strong></p>
<p>Esta é uma nota</p>
</div>
Como você deve ter imaginado, você irá precisar fazer isso para cada componente Vue que você usa nos seus arquivos Markdown.
Com a função completada, podemos colocar o resultado em cada item do feed:
feed.item({
title: post.title,
guid: `${url}/post/${slug}`,
url: `${url}/post/${slug}`,
description: post.description,
date: new Date(post.created_at),
categories: post.category ? [post.category] : undefined,
custom_elements: [
{ 'dc:creator': { _cdata: 'Fulano de Tal' } },
{ 'content:encoded': { _cdata: await markdownToHtml(post.path) } }, ],
});
Agora o feed RSS está completo com o conteúdo de suas postagens também.
Outra abordagem que eu considerei
Uma idéia alternativa que eu tive para resolver este problema foi usar um dos hooks do Nuxt após o build. Você pode escrever um script personalizado que faça o parse do HTML gerado para /blog para pegar os links, e então fazer o parse de cada arquivo de postagem gerado para extrair a parte do texto.
Isso vai funcionar, mas dependendo dos seus elementos personalizados, você pode acabar obtendo um HTML sujo quando comparado ao fazer o parse do Markdown. Por exemplo, alguns dos blocos <pre> que eu uso aqui são customizados para incluir uma janela bonitinha ao redor com o nome do arquivo e também um botão para copiar o código. Se você não tratar esses casos, os leitores RSS podem não renderizar o conteúdo de uma maneira correta.
Conclusão
Seria legal se a equipe do Nuxt disponibilizasse uma maneira mais fácil de fazer isso, já que feeds RSS ainda são bem úteis e usados por algumas pessoas. Talvez com uma reescrita do módulo para usar Comark eles acabem implementando? Quem sabe.