Saltar para o conteúdo
duxt

Typesense

Consuma o único hook de build do duxt em uma coleção do Typesense — cada fonte, versão e idioma, atualizados por um id estável.

O duxt emite um único hook de build, e ele entrega cada página de cada fonte, versão e idioma como registros planos. Um site que já opera o Typesense transforma esses registros em uma coleção própria — sem rastrear de novo o site renderizado e sem reimplementar a lógica de fontes, versões e locales do duxt.

Usar a camada complementar

Para a integração pronta a usar, instala @kirchdev/duxt-typesense e estende-a depois do duxt. Sincroniza o conjunto completo através de uma coleção temporária e da troca de alias, remove a coleção anterior gerida e liga o diálogo de pesquisa existente com uma chave apenas de pesquisa. Os passos abaixo continuam a ser uma receita para sincronização personalizada.

pnpm add @kirchdev/duxt-typesense
export default defineNuxtConfig({
  extends: ['@kirchdev/duxt', '@kirchdev/duxt-typesense'],
  duxtTypesense: {
    endpoint: process.env.TYPESENSE_ENDPOINT,
    collection: 'docs',
    adminKey: process.env.TYPESENSE_ADMIN_KEY,
    searchKey: process.env.TYPESENSE_SEARCH_KEY
  }
})

Mantém a chave de administração exclusivamente na configuração de compilação. searchKey deve permitir apenas documents:search para este alias, e o Typesense deve aceitar pedidos do navegador a partir do teu site.

Passos

  1. Instale o cliente no seu site. É uma dependência de build: a sincronização roda no Node durante o nuxt build, nunca no navegador.
pnpm add -D typesense
  1. Declare a coleção. Cada campo de um registro do duxt corresponde a um campo do Typesense, e os dois campos de procedência são aqueles por que um leitor filtra:
import type { CollectionCreateSchema } from 'typesense';

export const docs: CollectionCreateSchema = {
  name: 'docs',
  fields: [
    { name: 'url', type: 'string' },
    { name: 'title', type: 'string' },
    { name: 'content', type: 'string' },
    { name: 'source', type: 'string', facet: true },
    { name: 'version', type: 'string', facet: true, optional: true },
    { name: 'locale', type: 'string', facet: true }
  ]
};

Tipado como CollectionCreateSchema, porque o passo 4 entrega este mesmo objeto ao Typesense para criar a coleção com ele.

O id não está na lista de propósito: o Typesense já trata id como a chave primária do documento, e o id do duxt é um resumo hexadecimal da URL — válido como chave, igual em qualquer máquina e inalterado enquanto a página permanecer onde está. É isso que permite à sincronização fazer upsert em vez de reconstruir.

version é optional, porque uma fonte com uma única versão não carrega nenhuma. A versão é retirada de source deliberadamente, para que uma faceta construída sobre ele sobreviva a um lançamento e version ao lado seja o que estreita para uma só.

  1. Escreva o módulo que ouve. Um módulo Nuxt no seu próprio site é toda a integração — defineNuxtModule, um hook, um import:
import { defineNuxtModule } from '@nuxt/kit';
import { Client } from 'typesense';
import type { DuxtSearchRecord } from '@kirchdev/duxt/search-records';
import { docs } from '../server/typesense-schema';

export default defineNuxtModule({
  meta: { name: 'typesense-sync' },
  setup(_options, nuxt) {
    const key = process.env.TYPESENSE_ADMIN_KEY;
    // Sem chave é um build local, não uma falha: pule em vez de quebrá-lo.
    if (!key) return;

    nuxt.hook('duxt:search:records', async (records) => {
      const client = new Client({
        nodes: [{ url: process.env.TYPESENSE_URL! }],
        apiKey: key
      });

      await sync(client, records);
    });
  }
});

A carga está congelada. Mapeie-a para documentos seus — nunca reescreva um registro nem ordene o array no lugar, porque todos os outros ouvintes recebem os mesmos objetos.

  1. Sincronize o conjunto inteiro e remova o que se foi. O hook entrega o conjunto completo e atual a cada build, então o próprio conjunto é a instrução — tudo o que a coleção guarda e não está nele foi apagado, movido ou renomeado:
async function sync(client: Client, records: readonly DuxtSearchRecord[]) {
  const collection = client.collections(docs.name);

  // O Typesense não cria nada implicitamente: importar para uma coleção
  // que não existe falha de imediato, então o primeiro build a cria.
  if (!(await collection.exists())) await client.collections().create(docs);

  await collection.documents().import(
    records.map((record) => ({ ...record })),
    { action: 'upsert' }
  );

  // Tudo o que o build não entregou já não está no site.
  const kept = new Set(records.map((record) => record.id));
  const live = await collection.documents().export();

  const stale = live
    .split('\n')
    .filter(Boolean)
    .map((line) => JSON.parse(line).id as string)
    .filter((id) => !kept.has(id));

  // Apague em lotes: cada id entra na string do filtro, e uma única
  // renomeação grande construiria um que o Typesense rejeita.
  for (let index = 0; index < stale.length; index += 100) {
    const batch = stale.slice(index, index + 100).join(',');
    await collection.documents().delete({ filter_by: `id:[${batch}]` });
  }
}

A coleção é criada na primeira execução e deixada em paz depois, e os ids obsoletos são apagados em lotes — o Typesense toma o filtro como uma única string, e uma renomeação que mova uma fonte inteira construiria um que ele rejeita.

  1. Busque do navegador com uma chave só de busca. A chave de administração acima assina escritas e jamais pode chegar a uma página. Emita uma chave com escopo e apenas de busca para o cliente, e filtre pelas facetas que o esquema declarou:
// `locale` é o idioma do CONTEÚDO, não o idioma da interface.
// `version` não existe numa fonte sem versões: a cláusula só entra quando
// há uma; fixa, filtraria por `undefined`.
const filters = [`locale:${locale}`];
if (version) filters.push(`version:${version}`);

const results = await client.collections('docs').documents().search({
  q: query,
  query_by: 'title,content',
  filter_by: filters.join(' && ')
});
  1. Confira um build de ponta a ponta. Rode um build com a chave definida e confirme que a coleção guarda um registro de uma página em cada fonte, versão e idioma que o site serve — não apenas no padrão. O primeiro build cria a coleção; cada um depois faz upsert na mesma.

O que os registros já resolvem por você

Quatro propriedades da carga são o que torna correta uma sincronização tão curta, e todas as quatro são o contrato do hook, não o conselho deste guia:

PropriedadeO que ela dá à sincronização
id deriva de urlUma chave primária estável, então upsert basta e reconstruir nunca é preciso
A carga é o conjunto completoRemover é comparar com um build, sem rastreio e sem contabilidade
O duxt não dispara com uma análise vaziaUm build que nada analisou nunca pode mandar esvaziar o índice
A ordem é determinísticaDois builds diferem exatamente onde o site diferiu

Uma página que escreveu search: false, e uma tradução em um idioma que o site não roteia, não produzem registros — então nenhuma delas chega ao Typesense.

Transformar isso em um pacote

O módulo acima tem ~40 linhas e pertence a um site. Assim que um segundo site precisar dele, a forma de compartilhá-lo é uma camada Nuxt, não um plugin: uma camada carrega o módulo, seus padrões de app.config e seus componentes em um pacote versionado, e um consumidor a adiciona ao lado do duxt.

export default defineNuxtConfig({
  extends: ['@kirchdev/duxt', 'duxt-typesense']
});

Essa camada é dona das credenciais, da configuração do serviço, da política de sincronização e da interface de busca no navegador. O duxt não ganha nenhuma delas — que é toda a razão do formato do hook.

Checklist

  • O cliente é dependência do seu site, não da camada
  • A chave de administração vive no ambiente de build e nunca chega a uma página
  • A coleção é criada no primeiro build e faceta source, version e locale
  • Registros não mais entregues são removidos — por comparação em lotes ou troca de alias
  • Um build sem a chave pula a sincronização em vez de falhar
  • Cada fonte, versão e idioma está representada na coleção
Esta página foi útil?