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:

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.