Add a plugin
This page builds a plugin end to end. The example is a todo language with the extension .todo — a deliberately small language, so the plugin machinery stays visible.
Follow Add a language for the grammar work in more depth, and The grammar DSL for the rule syntax.
1. Create the packages
Inside app/packages, create language-todo and service-todo. Both are workspace packages, so they are picked up by app/package.json automatically.
// app/packages/language-todo/package.json
{
"name": "@mdeo/language-todo",
"version": "0.1.0",
"type": "module",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"files": ["dist", "src"],
"dependencies": {
"@mdeo/language-common": "^0.1.0",
"@mdeo/language-shared": "^0.1.0",
"@mdeo/plugin": "^0.1.0"
}
}// app/packages/service-todo/package.json
{
"name": "@mdeo/service-todo",
"version": "0.1.0",
"private": true,
"type": "module",
"files": ["dist", "src"],
"scripts": {
"build:static": "vite build",
"watch:static": "vite build --watch",
"start": "node dist/index.js",
"watch": "tsx watch src/index.ts"
},
"dependencies": {
"@mdeo/language-common": "^0.1.0",
"@mdeo/language-todo": "^0.1.0",
"@mdeo/plugin": "^0.1.0",
"@mdeo/service-common": "^0.1.0",
"lucide": "^1.29.0"
},
"devDependencies": {
"@types/node": "^25.3.1",
"tsx": "^4.21.0",
"vite": "^7.3.1"
}
}Add both to app/tsconfig.build.json so npm run build:packages compiles them.
2. Define the grammar
// app/packages/language-todo/src/grammar/todoTypes.ts
import { createInterface, Optional } from "@mdeo/language-common";
import type { ASTType } from "@mdeo/language-common";
export const TodoItem = createInterface("TodoItem").attrs({
name: String,
priority: Optional(Number),
text: String
});
export type TodoItemType = ASTType<typeof TodoItem>;
export const TodoList = createInterface("TodoList").attrs({
items: [TodoItem]
});
export type TodoListType = ASTType<typeof TodoList>;// app/packages/language-todo/src/grammar/todoRules.ts
import { createRule, ID, INT, NEWLINE, STRING, many, optional, or } from "@mdeo/language-common";
import { TodoItem, TodoList } from "./todoTypes.js";
export const TodoItemRule = createRule("TodoItemRule")
.returns(TodoItem)
.as(({ set }) => [
"todo",
set("name", ID),
optional("(", set("priority", INT), ")"),
":",
set("text", STRING)
]);
export const TodoListRule = createRule("TodoListRule")
.returns(TodoList)
.as(({ add }) => [many(or(add("items", TodoItemRule), NEWLINE))]);3. Build the Langium language plugin
// app/packages/language-todo/src/plugin.ts
import {
HIDDEN_NEWLINE,
ML_COMMENT,
SL_COMMENT,
WS,
type LangiumLanguagePlugin,
type LangiumLanguagePluginProvider
} from "@mdeo/language-common";
import {
DefaultAstSerializer,
IdValueConverter,
NewlineAwareTokenBuilder,
SerializerFormatter,
registerDefaultTokenSerializers
} from "@mdeo/language-shared";
import { TodoListRule } from "./grammar/todoRules.js";
import { registerTodoValidationChecks } from "./validation/todoValidator.js";
const todoPlugin: LangiumLanguagePlugin<object> = {
rootRule: TodoListRule,
additionalTerminals: [WS, HIDDEN_NEWLINE, ML_COMMENT, SL_COMMENT],
module: {
parser: {
TokenBuilder: () => new NewlineAwareTokenBuilder(new Set(["{"]), new Set(["("]), new Set(["}", ")"])),
ValueConverter: () => new IdValueConverter()
},
lsp: {
Formatter: (services) => new SerializerFormatter(services)
},
AstSerializer: (services) => new DefaultAstSerializer(services)
},
postCreate(services) {
registerDefaultTokenSerializers(services);
registerTodoValidationChecks(services);
}
};
export const todoPluginProvider: LangiumLanguagePluginProvider<object> = {
create: () => todoPlugin
};4. Serve the language module
// app/packages/service-todo/src/served/language.ts
import { todoPluginProvider } from "@mdeo/language-todo";
export default todoPluginProvider;// app/packages/service-todo/vite.config.ts
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
build: {
lib: {
entry: { language: resolve(__dirname, "src/served/language.ts") },
formats: ["es"],
cssFileName: "styles"
},
outDir: "static",
emptyOutDir: true,
sourcemap: true
}
});5. Declare the manifest and start the service
// app/packages/service-todo/src/index.ts
import { ListTodo } from "lucide";
import {
convertIcon,
defaultLanguageConfiguration,
defaultMonarchTokenProvider,
serializeMonarchTokensProvider
} from "@mdeo/language-common";
import {
AST_HANDLER_KEY,
astHandler,
initializePluginContext,
parseServiceConfigFromEnv,
startLanguageService,
type LanguageServiceConfig,
type ServiceConfig,
type ServicePluginDefinition
} from "@mdeo/service-common";
import type { LanguagePlugin } from "@mdeo/plugin";
const todoLanguagePlugin: LanguagePlugin = {
id: "todo",
name: "Todo",
extension: ".todo",
icon: convertIcon(ListTodo),
serverPlugin: { import: "language.js" },
graphicalEditorPlugin: undefined,
textualEditorPlugin: {
languageConfiguration: defaultLanguageConfiguration,
monarchTokensProvider: serializeMonarchTokensProvider({
...defaultMonarchTokenProvider,
keywords: ["todo"]
})
},
isGenerated: false
};
// Must run before the language package is imported.
initializePluginContext();
const { todoPluginProvider } = await import("@mdeo/language-todo");
const todoServicePlugin: ServicePluginDefinition = {
id: "todo-service",
name: "Todo",
description: "Language support for todo lists (.todo files)",
icon: convertIcon(ListTodo),
languagePlugins: [todoLanguagePlugin],
contributionPlugins: []
};
const todoLanguageConfig: LanguageServiceConfig = {
languagePlugin: todoLanguagePlugin,
languagePluginProvider: todoPluginProvider,
fileDataHandlers: { [AST_HANDLER_KEY]: astHandler }
};
const config: ServiceConfig = {
...parseServiceConfigFromEnv(),
plugin: todoServicePlugin,
languages: [todoLanguageConfig]
};
await startLanguageService(config);6. Run and register it
cd app
npm run build:packages
npm run -w @mdeo/service-todo build:static
PORT=3010 BACKEND_API_URL=http://localhost:8080/api npm run -w @mdeo/service-todo startCheck the manifest:
curl http://localhost:3010/ | jqThen register the plugin in the workbench as an administrator, under Settings → Plugins, with the URL http://localhost:3010. Add it to a project, create a .todo file, and the language is live.
When developing against the Vite dev server, add a proxy entry to app/packages/workbench/vite.config.ts so the plugin is reachable under the same origin:
"/plugin/todo": {
target: "http://localhost:3010",
changeOrigin: true,
secure: false,
ws: true,
rewrite: (path) => path.replace(/^\/plugin\/todo/, ""),
configure: addCoopCoepHeaders
}7. Package it for deployment
Add a Dockerfile under infra/docker/todo/, a service entry to the compose files, and the plugin URL to DEFAULT_PLUGIN_URLS so fresh installations pick it up automatically.