Web Dev Drops

Construindo um gerador de site estático em 40 linhas com Node.js

avatar
Douglas Matoso
Atualizado em 10/10/2017
Leitura: 9 min.

Existem excelentes geradores de sites estáticos, em diferentes linguagens, e com muitas funcionalidades, mas construir o seu próprio é mais fácil do que você imagina, e podemos aprender algumas coisas novas no processo.

Por que construir o seu próprio?

Quando estava planejando o meu site pessoal — um site simples, tipo portfólio, com poucas páginas, algumas informações sobre mim, habilidades e projetos — eu decidi que seria estático (é rápido, não precisa configurar um backend e pode ser hospedado em qualquer lugar). Já tive alguma experiência com Jekyll, Hugo and Hexo, mas eles tem mais features do que eu realmente precisava, a imaginei que levaria menos tempo criando o meu próprio do que instalando e configurando alguma dessas ferramentas. Fora o aprendizado!

Os requisitos

Os requisitos que o gerador precisaria atender são:

  • Gerar arquivos HTML a partir de templates EJS
  • Usar um arquivo de layout, para que todas as páginas tenham o mesmo cabeçalho, menu, rodapé, etc.
  • Permitir partials (blocos de componentes de interface reusáveis).
  • Ler configurações globais do site a partir de um arquivos (título do site, descrição, etc.)
  • Ler dados de arquivos JSON. Por exemplo: lista de projetos, para que eu possa iterar e construir a página “Projetos”

Por que templates EJS?
Porque EJS é simples. Não é preciso aprender uma nova sintaxe. É só JavaScript embutido em HTML.

Estrutura de pastas

public/
src/
assets/
data/
pages/
partials/
layout.ejs
site.config.js

  • public: onde o site gerado será salvo.
  • src: arquivos fonte do site.
    src/assets: arquivos de CSS, JS, imagens, etc. Serão copiados sem alteração para a pasta public.
    src/data: dados em JSON que serão usados no site.
    src/pages: arquivos de template que serão compilados em HTML e serão as páginas do site. A mesma estrutura de diretórios aqui será replicada no site final.
    src/partials: contém as partials (templates de blocos reusáveis).
    src/layout.ejs: contém a estrutura comum para todas as páginas, com uma marcação <%- body %> onde o conteúdo de cada página será inserido.
  • site.config.js: exporta um objeto com as configurações globais do site.

O gerador

Todo o código do gerador está no arquivo scripts/build.js, que pode ser executado com o comando npm run build, sempre que quisermos reconstruir o site. Basta adiciona a seguinte entrada no bloco scripts do package.json:

"build": "node ./scripts/build"

Este é o código completo do gerador: 
(Abaixo eu explico cada parte.)

const fse = require('fs-extra')
const path = require('path')
const { promisify } = require('util')
const ejsRenderFile = promisify(require('ejs').renderFile)
const globP = promisify(require('glob'))
const config = require('../site.config')

const srcPath = './src'
const distPath = './public'

// clear destination folder
fse.emptyDirSync(distPath)

// copy assets folder
fse.copy(`${srcPath}/assets`, `${distPath}/assets`)

// read page templates
globP('**/*.ejs', { cwd: `${srcPath}/pages` })
  .then((files) => {
    files.forEach((file) => {
      const fileData = path.parse(file)
      const destPath = path.join(distPath, fileData.dir)

      // create destination directory
      fse
        .mkdirs(destPath)
        .then(() => {
          // render page
          return ejsRenderFile(
            `${srcPath}/pages/${file}`,
            Object.assign({}, config)
          )
        })
        .then((pageContents) => {
          // render layout with page contents
          return ejsRenderFile(
            `${srcPath}/layout.ejs`,
            Object.assign({}, config, { body: pageContents })
          )
        })
        .then((layoutContent) => {
          // save the html file
          fse.writeFile(`${destPath}/${fileData.name}.html`, layoutContent)
        })
        .catch((err) => {
          console.error(err)
        })
    })
  })
  .catch((err) => {
    console.error(err)
  })

Dependências

Para este conjunto básico de funcionalidades, precisamos apenas de três dependências:

  • ejs
    Compila nossos templates para HTML.
  • fs-extra
    Adiciona funções extra ao módulo de file system nativo do Node (fs) e dá suporte a promises às funções existentes.
  • glob
    Lê uma pasta recursivamente, retornando todos os arquivos que casam com um padrão específico.

Promisify all the things!

Uma coisa a ser notada no código é que usamos o util.promisify do Node para converter funções que usam callback em funções que retornam promise. Isso deixa o código mais curto, limpo e fácil de ler.

const { promisify } = require('util')
const ejsRenderFile = promisify(require('ejs').renderFile)
const globP = promisify(require('glob'))

Carregar as configurações

No topo importamos o arquivo de configurações, para disponibilizar estes dados mais tarde para os templates.

const config = require('../site.config')

Este arquivo, por sua vez, importa dados adicionais da pasta data:

const projects = require('./src/data/projects')

module.exports = {
  site: {
    title: 'NanoGen',
    description: 'Micro Static Site Generator in Node.js',
    projects,
  },
}

Limpar a pasta public

Usamos emptyDirSync do fs-extra para limpar a pasta public.

fse.emptyDirSync(distPath)

Copiar os assets

Aqui usamos copy, também do fs-extra, que copia uma pasta com todo seu conteúdo.

fse.copy(`${srcPath}/assets`, `${distPath}/assets`)

Compilar as páginas

Primeiro usamos o glob (a versão _promisificada — existe essa palavra?) para recursivamente varrer a pasta _src/pages procurando por arquivos .ejs. Ela retorna um array com o caminho de todos os arquivos encontrados.

globP('**/*.ejs', { cwd: `${srcPath}/pages` })
  .then((files) => {

Para cada template encontrado, usamos path.parse do Node para separar os componentes do arquivo (como caminho, nome e extensão). Então criamos uma pasta de destino daquela página, usando fs-extra mkdirs.

files.forEach((file) => {
  const fileData = path.parse(file)
  const destPath = path.join(distPath, fileData.dir)

// create destination directory
  fse.mkdirs(destPath)

Então usamos EJS para compilar a página, passando os dados de configuração, para que possam ser acessados no corpo do template. Como estamos usando a versão promisificada do ejs.renderFile, podemos retornar aqui a chamada para pegar o resultado no próximo then.

.then(() => {
  // render page
  return ejsRenderFile(`${srcPath}/pages/${file}`, Object.assign({}, config))
})

No próximo bloco temos o corpo da página compilado. Agora compilamos o template de layout, passando o conteúdo da página no atributo body.

.then((pageContents) => {
  // render layout with page contents
  return ejsRenderFile(`${srcPath}/layout.ejs`, Object.assign({}, config, { body: pageContents }))
})

Finalmente pegamos o resultado, que é HTML compilado do layout + a página e salvamos em um arquivo HTML, com o mesmo nome do template original.

.then((layoutContent) => {
  // save the html file
  fse.writeFile(`${destPath}/${fileData.name}.html`, layoutContent)
})

Servidor de desenvolvimento

Para ficar mais fácil de visualizar o resultado, adicionamos um servidor de desenvolvimento simples, como o módulo serve, e adicionamos o seguinte no bloco scripts do package.json:

"serve": "serve ./public"

Então basta rodar npm run serve e acessar http://localhost:5000

Resultado

O exemplo completo, até aqui, pode ser encontrado em: https://github.com/doug2k1/nanogen/tree/legacy

Bonus 1: Markdown e front matter

A maioria dos geradores de site estático permitem escrever conteúdo no formato Markdown. Eles também permitem adicionar dados adicionais no topo de cada página (conhecido como front matter) no formato YAML, por exemplo:

---
title: Hello World
date: 2013/7/13 20:46:25
---

Com algumas alterações podemos adicionar estas mesmas funcionalidades.

Novas dependências

Precisamos adicionar duas novas dependências:

  • marked
    Compila Markdown para HTML.
  • front-matter
    Extrai meta dados (front matter) dos arquivos.

Incluir novos tipos de arquivos

Mudamos o padrão do glob para incluir arquivos com extensão .md. Mantemos o .ejs para permitir páginas mais complexas que não seriam possíveis apenas com Markdown, e também incluímos .html, caso queira incluir alguma página de HTML puro.

globP('**/*.@(md|ejs|html)', { cwd: `${srcPath}/pages` })

Extrair front matter

Para cada arquivo encontrado com essas extensões, precisamos carregar o conteúdo do arquivo para que o front-matter consiga extrair os dados no topo.

.then(() => {
  // read page file
  return fse.readFile(`${srcPath}/pages/${file}`, 'utf-8')
})

Passamos o conteúdo do arquivo para o front-matter. Ele retorna um objeto com um campo attributes contendo os meta dados encontrados, e um campo body contendo o restante do conteúdo do arquivo. Nós então incrementamos com estes dados a configuração que será passada para cada template.

.then((data) => {
  // extract front matter
  const pageData = frontMatter(data)
  const templateConfig = Object.assign({}, config, { page: pageData.attributes })

Compilar arquivos para HTML

Agora compilamos o conteúdo de cada página para HTML. Dependendo da extensão do arquivo nós usamos o marked (para .md), EJS (para .ejs) ou não fazemos nada se já for .html.

let pageContent

switch (fileData.ext) {
  case '.md':
    pageContent = marked(pageData.body)
    break
  case '.ejs':
    pageContent = ejs.render(pageData.body, templateConfig)
    break
  default:
    pageContent = pageData.body
}

Finalmente, compilamos o layout, como antes, injetando o HTML da página.

Uma coisa legal que dá pra fazer com front matter é ter títulos individuais para cada página:

---
title: Another Page
---

E no layout, onde vai o título, exibir esta informação, assim:

<title><%= page.title ? `${page.title} | ` : '' %><%= site.title %></title>

Cada página vai ter seu próprio título na tag <title>.

Bonus 2: Múltiplos layouts

Outra funcionalidade interessante é a possibilidade de usar um layout diferente em páginas específicas. Como agora cada página pode ter uma configuração no front matter, podemos usá-la para setar um layout diferente:

---
layout: minimal
---

Separar os arquivos de layout

Para organizar os diferentes layouts, coloquei na pasta src/layouts:

src/layouts/
default.ejs
mininal.ejs

Renderizar o layout correto

Se a configuração layout estiver presente no front matter, compilamos o arquivo de layout com o mesmo nome. Se não estiver definido, usamos o default.

const layout = pageData.attributes.layout || 'default'

return ejsRenderFile(
  `${srcPath}/layouts/${layout}.ejs`,
  Object.assign({}, templateConfig, { body: pageContent })
)

Resultado

O código completo, com as funcionalidades extra pode ser encontrado aqui: https://github.com/doug2k1/nanogen/tree/legacy

Editado: depois de um tempo eu decidi transformar o projeto em um módulo de linha de comando para ser mais fácil de usar, que está na branch master do repositório. O código original criado neste post está na branch legacy (link acima).

Mesmo com estas funcionalidade a mais, o arquivo ficou próximo de 60 linhas. 😉

Próximos passos

Se você quer ir além, algumas funcionalidades que podem ser implementadas sem grandes dificuldades são:

  • Servidor de desenvolvimento com reload automático
    Você pode usar módulos como live-server (já vem com reload automático) e chokidar (dispara um evento quando um arquivo é modificado).
  • Deploy automático
    Adicionar scripts para fazer deploy do site para serviços de hospedagem como GitHub Pages, ou para copiar os arquivos para seu servidor via SSH (com comandos como scp ou rsync)
  • Suporte para outros formatos de CSS/JS
    Adicionar algum pré-processamento nos seus assets (SASS para CSS, ES6 para ES5, etc) antes de copiá-los para a pasta de destino.
  • Melhor saída no console
    Adicionar alguns console.log para indicar em qual estapa do processo está, ou para indicar erros. Você pode usar algo como chalk para deixar essas mensagens mais bonitas.

Feedback? Sugestões? Comente ou entre em contato!

Comentários

Comentários desabilitados