Como implementei a busca offline para o meu site estático feito em Hugo

Este blog começou como um experimento e um objetivo: não custar nada no fim do mês.

Por isso decidi criar um site estático com um gerador chamado Hugo. Isso significa que não existe um backend para o blog; não existe uma arquitetura Cliente/Servidor, apenas o cliente, e todo o conteúdo do site é pré-renderizado.

Mas nem por isso ele precisa ser muito restrito em funcionalidades. Não é a falta de um servidor que impediria ter um recurso de busca de conteúdo no site.

A questão então era como implementar uma busca off-line para o blog.

Pesquisa

Após uma breve pesquisa encontrei uma página de documentação do Hugo descrevendo as principais estratégias para implementar um sistema de busca para um blog com Hugo.

Essas ferramentas variam bastante em complexidade e na abordagem para resolver o problema. Algumas geram o recurso de busca quase pronto, enquanto outras se limitam a gerar um arquivo de índice pesquisável por ferramentas especializadas.

Escolhi uma abordagem que julguei ser ao mesmo tempo flexível e que proporcionaria a maior quantidade de aprendizado.

Por isso, decidi usar uma ferramenta chamada Lunr.js (se lê ‘lunar’).

Lunr.js

Fluxograma descrevendo o funcionamento da busca, com a geração do índice durante o processo de integração contínua e publicação do json gerado junto com o site

Lunr.js é uma ferramenta que permite realizar buscas em índices previamente gerados. Ela ainda apresenta vários atributos interessantes:

  • Não possui dependências
  • Pode ser executada diretamente do navegador
  • É extensível através de plugins
  • Fácil de usar

A título de curiosidade, a ferramenta é inspirada em um projeto da Apache chamado Solr.

São necessários apenas três passos para fazer uma busca com o Lunr.js. O primeiro é criar um array de objetos contendo as informações pesquisáveis:

var posts = [{
  "uri": "/posts/arquitetura-gritante",
  "title": "Arquitetura Gritante",
  "metadescription": "descrição para o Google",
  "content": "Um dos meus capítulos preferidos do livro Arquitetura Limpa...",
  "tags": ["arquitetura gritante", "arquitetura limpa", "SOLID", "SRP"]
}, {
  "uri": "/posts/continuous-delivery-blog-com-hugo",
  "title": "Entrega contínua de blogs Hugo com GitHub Actions",
  "metadescription": "descrição para o Google",
  "content": "O [Hugo](https://gohugo.io) é hoje um dos...",
  "tags": ["hugo", "continuous delivery", "github actions"]
}, {
  "uri": "/posts/pattern-matching-csharp",
  "title": "Pattern matching no C# 8.0",
  "metadescription": "descrição para o Google",
  "content": "A partir do C# 7.0 a linguagem começou a receber...",
  "tags": ["csharp", "pattern matching", "type matching"]
}];

Com esses dados em mãos, podemos usar o Lunr.js para gerar um índice de busca:

var idx = lunr(function () {
  this.ref('uri'); // esta é a informação identifica que identifica o post nos resultados
  this.field('title');
  this.field('content');
  this.field('tags');

  posts.forEach(function (doc) {
    this.add(doc);
  }, this);
});

E com o índice gerado, é só questão de executar a busca:

var results = idx.search("pattern matching");

Certo, mas se para gerar o índice, é necessário um array (em javascript) com as informações dos posts - que, aliás, o Hugo não gera -, então um primeiro passo é dar um jeito de gerar essas informações.

Seria necessário algum tipo de extrator: uma ferramenta que leia o conteúdo do blog e exporte um JSON com as informações necessárias dos posts.

É aí que entra o hugo-lunr.

Hugo Lunr

O Hugo-lunr é uma ferramenta feita em NodeJS que faz justamente isso: lê cada arquivo markdown (.md) abaixo de /content/posts e interpreta o arquivo a fim de gerar o dataset para a geração de índice do Lunr.js.

Para contextualizar, o conteúdo do blog é estruturado da seguinte forma:

Árvore de arquivos de posts seguindo o padrão content/posts/nome-post/index.md

Árvore de arquivos dos posts

O plano

Depois de estudar um pouco o Lunr.js e fazer um teste usando o Hugo-lunr fiquei convencido de que poderia obter um bom resultado com essa solução.

O plano então era implementar o seguinte:

Fluxograma descrevendo o funcionamento da busca, com a geração do índice durante o processo de integração contínua e publicação do json gerado junto com o site

Funcionamento do sistema de busca

Era só uma questão de gerar os arquivos na pasta /static para o Hugo incluir automaticamente na publicação do site, e usá-los no javascript. Muito simples.

Mas é claro que haveria imprevistos.

Os desafios

O primeiro problema que tive foi com o Hugo-lunr, e em parte foi por conta da forma como eu organizei a publicação.

Para agendar um post, eu configuro uma data de publicação futura, e o processo de entrega contínua no GitHub Actions está configurado para rodar todos os dias às 8 horas da manhã. Por padrão. Como por padrão o Hugo não inclui posts com datas futuras, no dia que a data da postagem deixa de ser futuro, ela é automaticamente incluída no post. Mas até lá não.

Publicação do site todos os dias às 8h. Uma bola de papel amassado representa os posts agendados.

E o Hugo-lunr não suportava este comportamento.

Por isso precisei criar um fork do repositório. Criei um novo parâmetro para permitir filtrar posts futuros, além do filtro de rascunhos que já existia, e abri um Pull Request.

Só depois de uma semana percebi que o repositório estava abandonado, com pull requests sem resposta desde 2016. Não demorou para que aparecessem novas necessidades, então decidi seguir com o fork e modificá-lo conforme o necesssário.

Os metadados

Logo depois de ter publicado com sucesso a primeira versão do blog com o JSON de índice, comecei a implementação do tão sonhado sistema de busca.

Não demorou muito e descobri que faltava mais uma peça do quebra-cabeças: o arquivo do índice não preserva o conteúdo do dataset, então a única informação retornada era a uri do post. Infelizmente isso não era suficiente para gerar uma página de resultados. Eu precisava de metadados.

Então adicionei uma rotina para gerar um dataset menor para ser distribuído junto com índice. Este JSON é bastante semelhante ao dataset original, mas contendo apenas informações essencias para montar uma view minimamente decente:

var posts = [{
  "uri": "/posts/arquitetura-gritante",
  "title": "Arquitetura Gritante",
  "metadescription": "Já ouviu falar em Arquitetura Gritante? Neste artigo exploramos o que torna gritante a arquitetura de uma aplicação, e como isso pode beneficiar um projeto de software."
}
// ...
];

No fim, o script de geração dos arquivos ficou razoavelmente simples:

var fs = require('fs');
var lunr = require("lunr")
require("lunr-languages/lunr.stemmer.support")(lunr)
require("lunr-languages/lunr.pt")(lunr)

function createSearchIndex() {
  // o arquivo search-data.json é o dataset gerado pelo meu fork do Hugo-lunr
  fs.readFile('./search-data.json', 'utf8', function(err, data) {
      if (err)
        throw err;

      // aqui é feita a geração do índice
      const jsonData = JSON.parse(data)
      const idx = lunr(function () {
        this.use(lunr.pt)
        this.ref('uri')
        this.field('title', { 'boost': 1.5 })
        this.field('tags', { 'boost': 1.2 })
        this.field('content')
        this.field('metadescription')

        jsonData.forEach(doc => {
          this.add(doc)
        }, this)
      })

      // o índice é salvo em JSON para depois publicar junto com o site
      const serializedIndex = JSON.stringify(idx)
      fs.writeFile('./search-index.json', serializedIndex, 'utf-8', function(err) {
        if (err)
          throw err;
      })

      // o 'data companion' é basicamente um view model para construção da view de resultados
      const dataCompanion = jsonData.map((doc) => {
        return {
          'uri': doc.uri,
          'completeUri': `/posts${doc.uri}`.replace(/\/index$/, ""),
          'title': doc.title,
          'metadescription': doc.metadescription
        };
      })
      // o 'data companion' também é salvo em JSON para depois publicar junto com o site
      fs.writeFile('./search-data-companion.json', JSON.stringify(dataCompanion), 'utf-8', function(err) {
        if (err)
          throw err;
      })
  })
}
createSearchIndex()

Concluindo o quebra-cabeças

Com os arquivos search-index.json e search-data-companion.json, só faltava fazer a última parte. Montar uma view básica para a busca, e implementar a configuração da busca na inicialização da página.

  const promiseDownloadSearchIndexFile = getJSON(searchIndexFilename);
  const promiseDownloadSearchDataFile = getJSON(searchMetadataFilename);
  Promise
    .all([promiseDownloadSearchIndexFile, promiseDownloadSearchDataFile])
    .then((values) => {
      const indexJson = values[0]; // search-index.json
      const metadata = values[1]; // search-data-companion.json

      // aqui é carregado o arquivo de índice baixado de /search-index.json
      const index = lunr.Index.load(indexJson);
      const searchFor = function (terms) {
        return index.search(terms);
      };
      console.log('search index succesfully loaded');
      // ...
    });

E finalmente o evento da busca no input:

const searchInput = document.getElementById('search-input')
searchInput.addEventListener('search', function () {
  terms = this.value;
  if (terms && terms.length > 0) {
    const searchResults = searchFor(terms);
    if (searchResults.length > 0) {
      // se a busca no índice retornar alguma coisa, busco os viewmodels correspondentes
      // e atualizo a view
      const searchResultsViewModels = searchResults.map((searchResult) => {
        return metadata.filter((doc => doc.uri === searchResult.ref))[0];
      });
      updateSearchResults(Mustache.render(searchResultTemplate, {
        'searchResults': searchResultsViewModels
      }));
    }
    else {
      updateSearchResults(Mustache.render(noResultsFoundTemplate, {}));
    }
  }
  else {
    updateSearchResults('');
  }
});

E é assim que foi implementado o sistema de busca deste blog. No futuro pretendo fazer algumas melhorias, talvez algum plugin para o Lunr.

Imagem do modal de resultados.

Um dos problemas que o sistema de busca atual apresenta é que não é possível pesquisar símbolos especiais. Se você pesquisar C#, por exemplo, não vai encontrar nada, pois o analisador léxico do Lunr removeu o “#”.

Imagem do modal de resultados, sem nenhum resultado.

Fora isso, fiquei bastante satisfeito com o resultado. E o mais importante, é que essa implementação rendeu um bom aprendizado. Se você conhece outras técnicas para implementar um sistema de busca desses, ou simplesmente gostaria de compartilhar, deixei um comentário!

Outras opções para compartilhar:
comments powered by Disqus