Criando um feed RSS com Nuxt Content

· Alessandro Jean

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.

Cuidado

Esta não é uma rota de API, então não deve ser colocada em /server/api.

O esqueleto do arquivo é basicamente este:

feed.xml.get.ts
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.

feed.xml.get.ts
import RSS from 'rss';

Para começar, precisamos criar o feed e colocar algumas informações básicas.

feed.xml.get.ts
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.

feed.xml.get.ts
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.

feed.xml.get.ts
setResponseHeader(event, 'Content-Type', 'text/xml');

Então nós podemos finalmente retornar o feed XML.

feed.xml.get.ts
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.

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.

blog.vue
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.

utils/markdown.ts
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.

utils/markdown.ts
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.

utils/markdown.ts
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.

utils/markdown.ts
.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:

utils/markdown.ts
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.xml.get.ts
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.