11 min read
Using Inkdrop to manage my Astro blog content

My blog is built with Astro, and its posts live as Markdown files inside the same repository.

I’ve always liked this approach because it keeps the blog fairly simple: I don’t need a database or a CMS in production. Astro simply takes the static files, processes them through the Content Layer, and generates the site.

The only issue was the writing experience. Until now, writing a post meant opening the project, creating the corresponding Markdown file, and working directly from my code editor. It works, but I wanted to separate the process of writing from the process of developing the blog.

That’s when I decided to integrate Inkdrop.

The idea

I was already using Inkdrop to write technical notes, so it made sense to use it as the editor for my blog posts as well.

Inkdrop is an AI-native Markdown note app for developers — smooth context flow between you and your agents, encrypted sync, every platform.

There was one important requirement, though: I didn’t want to turn Inkdrop into a CMS that my site depended on. I wanted to keep the same workflow I already had with Astro:

Markdown + images

Astro Content Layer

Blog

The only difference would be where those files came from:

Inkdrop

@inkdropapp/live-export

Markdown + images

Astro Content Layer

Blog

Inkdrop handles the writing experience and note organization, while the final result remains static content inside my repository.

To make this work, I used @inkdropapp/live-export.

@inkdropapp/live-export

Inkdrop provides a local HTTP server that allows external programs to access notes.

On top of that functionality, @inkdropapp/live-export provides a way to read notes from a notebook and programmatically control how they are exported to the filesystem.

First, I create a LiveExporter instance using the credentials configured in Inkdrop:

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

All of this configuration lives in environment variables so I don’t have to keep credentials or specific IDs in the code. Then I start the process by specifying the notebook that contains my blog posts:

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

  // ...
});

In my case, I use:

live: false

because I want the script to perform a one-time export.

live-export also supports live: true, which keeps the process listening for changes and exports a note again while it is being edited. For now, I prefer to explicitly run the script whenever I want to generate the content.

Inkdrop’s official documentation actually uses Astro as an example to explain this workflow with live-export.

The structure expected by my Content Layer

Before integrating Inkdrop, I had already defined the structure used by my blog.

Posts are separated by language:

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

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

Because of that, one of my main rules for the integration was:

I didn’t want to adapt my blog to Inkdrop. I wanted to adapt Inkdrop’s export process to the structure my blog was already using.

To do that, I created a few helper functions.

Determining a post’s directory

Each note has two important properties in its frontmatter:

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

At the moment, I only support two languages:

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

With that information, I can determine where each post should live:

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);
}

For example:

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

would end up in:

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

While:

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

would end up in:

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

Besides organizing the files, this also lets me validate during the export process that a post contains all the information it needs.

preProcessNote: preparing the frontmatter

One of the functions provided by live-export is preProcessNote. As its name suggests, it runs before the note is processed and written.

I use it to complete and validate the 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}"`,
    );
  }
},

Several things happen here.

The title comes directly from Inkdrop

I don’t need to keep the title duplicated in the frontmatter.

I simply take the note’s title:

frontmatter.title = note.title;

This means that changing the note’s name in Inkdrop also changes the title that Astro will eventually consume.

Tags also come from Inkdrop

The tags associated with the note are converted into an array:

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

This lets me use Inkdrop’s tag system directly to organize posts and later expose that information in Astro.

I also validate the frontmatter

Finally, I check that the fields required by my blog are present:

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

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

and that the locale is one of the supported values:

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

I’d rather have the process fail during export than end up generating a post in the wrong location.

pathForNote: deciding what to export and where

This function determines the file where each note will be exported.

My implementation looks like this:

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

  const postDirectory = getPostDirectory(frontmatter);

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

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

There are two important behaviors here.

First:

if (frontmatter.draft) {
  return false;
}

In live-export, returning false means that the note should not be exported. This lets me keep unfinished posts in Inkdrop without generating files inside the blog yet. If the post is ready, I create its directory:

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

and return the final path:

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

For example:

src/data/blog/en/using-inkdrop-with-astro/index.md

This way, every post continues to use exactly the same structure my Content Layer expected before I integrated Inkdrop.

urlForNote: generating the post URL

live-export also provides urlForNote. This function lets you specify which URL corresponds to an exported note and is especially useful when there are links between notes.

First, I generate the URL with a helper function:

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

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

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

Then I use it from the exporter:

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

  return getPostUrl(frontmatter);
},

Just like with pathForNote, posts marked as drafts simply don’t get an exported URL.

pathForFile: handling images

Images were probably the part that needed a little more adaptation. I wanted to be able to add images normally in Inkdrop, but the generated files still had to respect the structure of each post.

For that, I use 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,
  };
},

Here I use the image’s alt text as part of the naming convention.

For example, an image in Inkdrop could look like this:

![Project architecture](...)

The alt text is transformed using toKebabCase:

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

so it could end up as:

project-architecture.png

and the file is saved inside the same directory as the post.

The special case of cover.png

For the cover image, I wanted an even simpler convention. Inside Inkdrop, I only need to use:

When pathForFile finds an image whose alt is exactly cover:

const isCover = alt === "cover";

it forces the filename to:

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

It also automatically adds the image to the frontmatter:

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

So a note that initially looks something like this:

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

ends up with a reference like:

image: ./cover.png

And on the filesystem I get:

using-inkdrop-with-astro/
├── index.md
└── cover.png

This was important because image is used by my blog as the image associated with the post, including the metadata used when sharing the post. At the same time, I can still see the cover directly in Inkdrop while I’m writing.

postProcessNote: removing the cover from the content

This creates a small problem.

I need to have:

inside Inkdrop so I can see the image. But I don’t want that image to appear in the final article content because Astro already knows its location through:

image: ./cover.png

This is where postProcessNote comes in. This function runs at the end of the process and lets me modify the Markdown before it is written to the filesystem.

My implementation simply removes the image identified as cover:

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

So in Inkdrop I can have:

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

# Introduction

My post content...

But the generated file conceptually ends up like this:

---
locale: en
slug: using-inkdrop-with-astro
image: ./cover.png
---

# Introduction

My post content...

While the filesystem contains:

using-inkdrop-with-astro/
├── index.md
└── cover.png

This gives me a good writing experience in Inkdrop without having to change how I render posts in Astro.

The complete workflow

Putting all the pieces together, the workflow looks like this:

Write the post in Inkdrop

preProcessNote

completes and validates the frontmatter

pathForNote

determines where to save index.md

pathForFile

exports images and generates cover.png

postProcessNote

cleans up the final Markdown

Astro Content Layer

Once the exporter finishes, I end up again with something extremely simple:

Markdown + images

In other words, from Astro’s point of view, almost nothing changed.

Inkdrop is not part of the blog

And this is probably the part I like the most about this integration. Inkdrop isn’t involved when someone visits my blog. I also don’t need to call its API in production or make requests to a CMS during the build.

Inkdrop is only a tool in my development workflow:

Inkdrop

inkdrop-export.mjs

src/data/blog/

Astro Content Layer

Build

The generated files still live inside my project, and I can keep them in Git as usual.

Even if I stopped using Inkdrop at some point, the content would still be regular Markdown.

In the end

Integrating Inkdrop ended up being much simpler than I initially expected.

The interesting part of @inkdropapp/live-export is that it doesn’t impose a specific structure on the generated files.

Functions such as:

preProcessNote
pathForNote
urlForNote
pathForFile
postProcessNote

let you intervene in almost every part of the export process. Instead of changing my blog’s architecture to adapt it to an external tool, I was able to do exactly the opposite: adapt Inkdrop to the structure my blog already had. Now I can focus on writing in Inkdrop, use its notebooks, statuses, tags, and images, and when a post is ready, turn it into the same static files Astro already knew how to process.

For me, that’s probably the best part of this integration: I improved the writing experience without adding complexity to the blog that ultimately reaches production.