11 min de lectura
Usando Inkdrop para gestionar el contenido de mi blog en Astro

Mi blog está construido con Astro y el contenido de las entradas vive como archivos Markdown dentro del mismo repositorio.

Este enfoque siempre me ha gustado porque mantiene el blog bastante simple: no necesito una base de datos ni un CMS en producción. Astro simplemente toma los archivos estáticos, los procesa mediante el Content Layer y genera el sitio.

El único detalle era la experiencia al momento de escribir. Hasta ahora, escribir una entrada significaba abrir el proyecto, crear el archivo Markdown correspondiente y trabajar directamente desde mi editor de código. Funciona, pero quería separar un poco el proceso de escribir del proceso de desarrollar el blog.

Ahí fue donde decidí integrar Inkdrop.

La idea

Ya utilizaba Inkdrop para escribir notas técnicas, así que tenía sentido utilizarlo también como editor para las entradas del blog.

Inkdrop es una aplicación de notas en Markdown, nativa de IA y pensada para desarrolladores, con un flujo de contexto fluido entre tú y tus agentes, sincronización cifrada y disponible en todas las plataformas.

Pero había una condición importante: no quería convertir Inkdrop en un CMS del cual dependiera mi sitio. Quería mantener el mismo flujo que ya tenía con Astro:

Markdown + imágenes

Astro Content Layer

Blog

La única diferencia sería de dónde salen esos archivos:

Inkdrop

@inkdropapp/live-export

Markdown + imágenes

Astro Content Layer

Blog

Inkdrop se encarga de la experiencia de escritura y organización de las notas, mientras que el resultado continúa siendo contenido estático dentro de mi repositorio.

Para conseguirlo utilicé @inkdropapp/live-export.

@inkdropapp/live-export

Inkdrop cuenta con un servidor HTTP local que permite acceder a las notas desde programas externos.

Sobre esta funcionalidad existe @inkdropapp/live-export, una herramienta que permite leer las notas de un notebook y controlar programáticamente cómo deben exportarse al filesystem.

Primero creo una instancia de LiveExporter utilizando las credenciales configuradas en Inkdrop:

const liveExport = new LiveExporter({
  username: process.env.INKDROP_USERNAME,
  password: process.env.INKDROP_PASSWORD,
  port: Number(process.env.INKDROP_PORT ?? 19840),
});

Toda esta configuración vive en variables de entorno para evitar tener credenciales o IDs específicos dentro del código. Posteriormente inicio el proceso indicando el notebook que contiene las entradas de mi blog:

await liveExport.start({
  live: false,
  bookId: process.env.INKDROP_BOOKID,

  // ...
});

En mi caso utilizo:

live: false

porque quiero que el script realice una exportación puntual.

live-export también soporta live: true, que permite mantener el proceso escuchando cambios y exportar nuevamente una nota mientras se edita. Por ahora prefiero ejecutar explícitamente el script cuando quiero generar el contenido.

La documentación oficial de Inkdrop utiliza precisamente Astro como ejemplo para explicar este flujo con live-export.

La estructura que espera mi Content Layer

Antes de integrar Inkdrop ya tenía definida la estructura que utiliza mi blog.

Las entradas están separadas por idioma:

src/
└── data/
    └── blog/
        ├── es/
        │   └── mi-post/
        │       ├── index.md
        │       └── cover.png

        └── en/
            └── my-post/
                ├── index.md
                └── cover.png

Por eso una de mis principales reglas para la integración era:

No quería adaptar mi blog a Inkdrop. Quería adaptar la exportación de Inkdrop a la estructura que mi blog ya utilizaba.

Para hacerlo creé algunas funciones auxiliares.

Determinando el directorio de una entrada

Cada nota tiene dos propiedades importantes en su frontmatter:

---
locale: es
slug: usando-inkdrop-con-astro
---

Actualmente solamente permito dos idiomas:

const SUPPORTED_LOCALES = new Set(["es", "en"]);

Con esa información puedo determinar dónde debe vivir cada post:

function getPostDirectory(frontmatter) {
  const { locale, slug } = frontmatter;

  if (!slug) {
    throw new Error("Missing slug");
  }

  if (!SUPPORTED_LOCALES.has(locale)) {
    throw new Error(`Unsupported locale "${locale}"`);
  }

  if (locale === "en") {
    return path.join(BLOG_DIR, "en", slug);
  }

  return path.join(BLOG_DIR, "es", slug);
}

Por ejemplo:

locale: es
slug: usando-inkdrop-con-astro

terminaría en:

src/data/blog/es/usando-inkdrop-con-astro/

Mientras que:

locale: en
slug: using-inkdrop-with-astro

terminaría en:

src/data/blog/en/using-inkdrop-with-astro/

Además de organizar los archivos, esto permite validar desde el proceso de exportación que una entrada tenga toda la información necesaria.

preProcessNote: preparando el frontmatter

Una de las funciones que proporciona live-export es preProcessNote. Como su nombre lo indica, se ejecuta antes de procesar y escribir la nota.

En mi caso la utilizo para complementar y validar el frontmatter:

preProcessNote: ({ note, frontmatter, tags }) => {
  frontmatter.title = note.title;

  const noteDraft =
    !(note.status === "completed") && !frontmatter.draft;

  frontmatter.draft = noteDraft;

  frontmatter.tags = tags.map((tag) => tag.name);

  if (!frontmatter.slug) {
    throw new Error(`Missing slug in "${note.title}"`);
  }

  if (!frontmatter.locale) {
    throw new Error(`Missing locale in "${note.title}"`);
  }

  if (!SUPPORTED_LOCALES.has(frontmatter.locale)) {
    throw new Error(
      `Unsupported locale "${frontmatter.locale}" in "${note.title}"`,
    );
  }
},

Aquí ocurren varias cosas.

El título viene directamente de Inkdrop

No necesito mantener el título duplicado dentro del frontmatter.

Simplemente tomo el título de la nota:

frontmatter.title = note.title;

Esto significa que cambiar el nombre de la nota en Inkdrop también cambia el title que terminará consumiendo Astro.

Los tags también vienen de Inkdrop

Los tags asociados a la nota se convierten en un array:

frontmatter.tags = tags.map((tag) => tag.name);

Así puedo utilizar directamente el sistema de tags de Inkdrop para organizar las entradas y posteriormente exponer esa información en Astro.

También valido el frontmatter

Finalmente compruebo que existan los campos que mi blog necesita:

if (!frontmatter.slug) {
  throw new Error(`Missing slug in "${note.title}"`);
}

if (!frontmatter.locale) {
  throw new Error(`Missing locale in "${note.title}"`);
}

y que el locale sea uno de los soportados:

if (!SUPPORTED_LOCALES.has(frontmatter.locale)) {
  throw new Error(
    `Unsupported locale "${frontmatter.locale}" in "${note.title}"`,
  );
}

Prefiero que el proceso falle durante la exportación a terminar generando una entrada en una ruta incorrecta.

pathForNote: decidiendo qué exportar y dónde

Esta función determina el archivo al que se va a exportar cada nota.

Mi implementación es:

pathForNote: ({ frontmatter }) => {
  if (frontmatter.draft) {
    return false;
  }

  const postDirectory = getPostDirectory(frontmatter);

  fs.mkdirSync(postDirectory, {
    recursive: true,
  });

  return path.join(
    getPostDirectory(frontmatter),
    "index.md",
  );
},

Hay dos comportamientos importantes aquí.

Primero:

if (frontmatter.draft) {
  return false;
}

En live-export, retornar false indica que esa nota no debe exportarse. Esto me permite mantener entradas incompletas en Inkdrop sin generar todavía archivos dentro del blog. Si la entrada sí está lista, creo su directorio:

fs.mkdirSync(postDirectory, {
  recursive: true,
});

y regreso la ruta final:

return path.join(
  getPostDirectory(frontmatter),
  "index.md",
);

Por ejemplo:

src/data/blog/es/usando-inkdrop-con-astro/index.md

De esta forma cada entrada continúa utilizando exactamente la misma estructura que esperaba mi Content Layer antes de integrar Inkdrop.

urlForNote: generando la URL del post

live-export también proporciona urlForNote. Esta función permite indicar qué URL corresponde a una nota exportada y es especialmente útil cuando existen enlaces entre notas.

Primero genero la URL con una función auxiliar:

function getPostUrl(frontmatter) {
  const { locale, slug } = frontmatter;

  if (locale === "en") {
    return `${BLOGENTRIES}/en/${slug}/`;
  }

  return `${BLOGENTRIES}/es/${slug}/`;
}

Y posteriormente la utilizo desde el exporter:

urlForNote: ({ frontmatter }) => {
  if (frontmatter.draft) {
    return false;
  }

  return getPostUrl(frontmatter);
},

Al igual que con pathForNote, las entradas marcadas como draft simplemente no tienen una URL exportada.

pathForFile: manejando las imágenes

Las imágenes fueron probablemente la parte que necesitó un poco más de adaptación. Quería poder agregar imágenes normalmente dentro de Inkdrop, pero los archivos generados debían respetar la estructura de cada entrada.

Para eso utilizo pathForFile.

pathForFile: ({
  mdastNode,
  extension,
  frontmatter,
}) => {
  const alt = mdastNode.alt?.trim();

  if (!alt) {
    return false;
  }

  const postDirectory = getPostDirectory(frontmatter);

  fs.mkdirSync(postDirectory, {
    recursive: true,
  });

  const isCover = alt === "cover";

  const filename = isCover
    ? "cover.png"
    : `${toKebabCase(alt)}${extension}`;

  const url = `./${filename}`;

  if (isCover) {
    frontmatter.image = url;
  }

  return {
    filePath: path.join(postDirectory, filename),
    url,
  };
},

Aquí utilizo el alt de la imagen como parte de la convención.

Por ejemplo, una imagen dentro de Inkdrop podría tener:

![Arquitectura del proyecto](...)

El alt se transforma utilizando toKebabCase:

`${toKebabCase(alt)}${extension}`

por lo que podría terminar como:

arquitectura-del-proyecto.png

y el archivo se guarda dentro del mismo directorio del post.

El caso especial de cover.png

Para el cover quería una convención todavía más sencilla. Dentro de Inkdrop solamente tengo que utilizar:

Cuando pathForFile encuentra una imagen cuyo alt es exactamente cover:

const isCover = alt === "cover";

fuerza el nombre del archivo a:

cover.png
const filename = isCover
  ? "cover.png"
  : `${toKebabCase(alt)}${extension}`;

Además agrega automáticamente la imagen al frontmatter:

if (isCover) {
  frontmatter.image = url;
}

Así que una nota que inicialmente tiene algo parecido a:

---
locale: es
slug: usando-inkdrop-con-astro
---

termina con una referencia como:

image: ./cover.png

Y físicamente tengo:

usando-inkdrop-con-astro/
├── index.md
└── cover.png

Esta parte era importante porque image es utilizada por mi blog para la imagen asociada al post, incluyendo la metadata que utilizo al compartir la entrada. Al mismo tiempo puedo seguir viendo el cover directamente dentro de Inkdrop mientras estoy escribiendo.

postProcessNote: quitando el cover del contenido

Esto genera un pequeño problema.

Necesito tener:

dentro de Inkdrop para poder visualizar la imagen. Pero no quiero que esa imagen aparezca en el contenido final del artículo porque Astro ya conoce su ubicación mediante:

image: ./cover.png

Aquí entra postProcessNote. Esta función se ejecuta al final del procesamiento y permite modificar el Markdown antes de escribirlo al filesystem.

Mi implementación simplemente elimina la imagen identificada como cover:

postProcessNote: ({ md }) => {
  return md.replace(
    /!\[cover\]\([^)]+\)\s*/g,
    "",
  );
},

Entonces en Inkdrop puedo tener:

---
locale: es
slug: usando-inkdrop-con-astro
---

# Introducción

Contenido de mi entrada...

Pero el archivo generado termina conceptualmente así:

---
locale: es
slug: usando-inkdrop-con-astro
image: ./cover.png
---

# Introducción

Contenido de mi entrada...

Mientras que el filesystem contiene:

usando-inkdrop-con-astro/
├── index.md
└── cover.png

Esto me permite tener una buena experiencia de escritura en Inkdrop sin tener que adaptar cómo renderizo los posts dentro de Astro.

El flujo completo

Juntando todas las piezas, el flujo termina siendo:

Escribo el post en Inkdrop

preProcessNote

completa y valida el frontmatter

pathForNote

determina dónde guardar index.md

pathForFile

exporta imágenes y genera cover.png

postProcessNote

limpia el Markdown final

Astro Content Layer

Una vez ejecutado el exporter termino nuevamente con algo extremadamente simple:

Markdown + imágenes

Es decir, desde el punto de vista de Astro prácticamente nada cambió.

Inkdrop no forma parte del blog

Y esta probablemente sea la parte que más me gusta de esta integración. Inkdrop no está involucrado cuando alguien visita mi blog. Tampoco necesito consultar su API en producción ni realizar peticiones a algún CMS durante el build.

Inkdrop solamente es una herramienta dentro de mi flujo de desarrollo:

Inkdrop

inkdrop-export.mjs

src/data/blog/

Astro Content Layer

Build

Los archivos generados siguen viviendo dentro de mi proyecto y puedo mantenerlos normalmente en Git.

Incluso si en algún momento dejara de utilizar Inkdrop, el contenido seguiría siendo Markdown normal.

Al final

Integrar Inkdrop terminó siendo mucho más sencillo de lo que inicialmente imaginaba.

La parte interesante de @inkdropapp/live-export es que no impone una estructura para los archivos generados.

Funciones como:

preProcessNote
pathForNote
urlForNote
pathForFile
postProcessNote

permiten intervenir en prácticamente todo el proceso de exportación. En lugar de cambiar la arquitectura de mi blog para adaptarla a una herramienta externa, pude hacer exactamente lo contrario: adaptar Inkdrop a la estructura que mi blog ya tenía. Ahora puedo concentrarme en escribir desde Inkdrop, utilizar sus notebooks, estados, tags e imágenes y, cuando una entrada está lista, convertirla en los mismos archivos estáticos que Astro ya sabía procesar.

Para mí, esa es probablemente la mejor parte de esta integración: mejoré la experiencia para escribir sin agregar complejidad al blog que termina llegando a producción.