Article
Building a Simple CMS as a VS Code Extension
"I do most of my writing in Cursor" - Kent C. Dodds
When Kent mentioned this, I immediately started thinking about how our Course Builder CMS software could be adapted to accommodate this preference and remove some friction from the publishing process.
<Callout> Local-first is the idea that your data is co-located with your UI and works offline. Your data where you want it when you need it. When you are ready, you can sync to a remote database so that others can access the data.We build content management systems that let developer creators publish to various websites (like this one). As developers, these creators are typically using an IDE like VS Code in their day-to-day work. </Callout>
If you'd like to dive straight into the madness, here's the full source for our WIP CMS on Github.
Creating a VS Code Extension
Here's the official getting started tutorial for VS Code. It starts with the generator:
npx --package yo --package generator-code -- yo code
The yo package is Yeoman and generator-code is this VS Code extension generator.
The docs are good and you've got a number of choices to make in terms of what kind of extension you want to build. For us that meant a standard extension using TypeScript which is the default choice when you run the generator.
Displaying Remote Markdown Documents as Tabs
We store our documents in a database. They are markdown (mdx specifically), but don't have any frontmatter/yaml in the documents. All of the metadata is stored in the documents database row. Here's how that table looks like as a drizzle schema definition:
export function getContentResourceSchema(mysqlTable: MySqlTableFn) {
return mysqlTable(
'ContentResource',
{
id: varchar('id', { length: 255 }).notNull().primaryKey(),
type: varchar('type', { length: 255 }).notNull(),
createdById: varchar('createdById', { length: 255 }).notNull(),
fields: json('fields').$type<Record<string, any>>().default({}),
currentVersionId: varchar('currentVersionId', { length: 255 }),
createdAt: timestamp('createdAt', {
mode: 'date',
fsp: 3,
}).default(sql`CURRENT_TIMESTAMP(3)`),
updatedAt: timestamp('updatedAt', {
mode: 'date',
fsp: 3,
}).default(sql`CURRENT_TIMESTAMP(3)`),
deletedAt: timestamp('deletedAt', {
mode: 'date',
fsp: 3,
}),
},
(cm) => ({
typeIdx: index('type_idx').on(cm.type),
createdByIdx: index('createdById_idx').on(cm.createdById),
createdAtIdx: index('createdAt_idx').on(cm.createdAt),
currentVersionIdIdx: index('currentVersionId_idx').on(
cm.currentVersionId,
),
}),
)
}
Here's the source for the above in context.
For the type of content we want to edit we are interested in two properties stored in fields JSON column. They are the body - or the actual markdown, and the title which is the presented title of the content. We also access the slug field as well.
Using a "Virtual" Markdown Code Fence
Handling Media
Creating a React WebView
There are several types of views that you can create in VS Code extensions, including webviews which allow you to build rich UI experiences using HTML, CSS and JavaScript. Webviews run in their own context but can communicate with the extension through message passing.