Add standalone monaco-json-interpolation package

Extract the JSON interpolation language as a standalone npm package that
works with the official Monaco Editor as a peer dependency.

Features:
- Zero dependencies on monaco-editor internals
- Simple registration API: register() and getDefaults()
- Variable context for custom completions and hover
- Monarch tokenizer with nextEmbedded for JavaScript
- TypeScript types included
- ESM and CommonJS builds via tsup

Usage:
```typescript
import { register, getDefaults } from 'monaco-json-interpolation';

register();
getDefaults().setVariableContext({
  getVariables: () => [{ name: 'env', value: 'prod' }]
});
```
This commit is contained in:
Claude 2025-12-09 19:58:21 +00:00
parent 4540e05e5a
commit 6039262df7
No known key found for this signature in database
6 changed files with 835 additions and 0 deletions

View file

@ -0,0 +1,157 @@
# monaco-json-interpolation
A Monaco Editor add-on that provides JSON language support with `${...}` variable interpolation.
## Features
- **Syntax Highlighting**: Full JSON syntax highlighting with embedded JavaScript inside `${...}`
- **Variable Completions**: Autocomplete for your custom variables inside interpolations
- **Hover Information**: See variable types, descriptions, and current values on hover
- **JSONC Support**: Comments (`//`, `/* */`) and trailing commas are allowed
- **Folding**: Code folding for objects and arrays
## Installation
```bash
npm install monaco-json-interpolation
```
## Usage
### Basic Setup
```typescript
import * as monaco from 'monaco-editor';
import { register, getDefaults } from 'monaco-json-interpolation';
// Register the language (call once at startup)
register();
// Create an editor with the new language
const editor = monaco.editor.create(document.getElementById('container'), {
value: '{\n "message": "Hello, ${name}!"\n}',
language: 'json-interpolation'
});
```
### Providing Variables
```typescript
import { getDefaults } from 'monaco-json-interpolation';
// Set up variable context for completions and hover
getDefaults().setVariableContext({
getVariables: () => [
{
name: 'name',
type: 'string',
value: 'World',
description: 'The name to greet'
},
{
name: 'env',
type: 'string',
value: 'production',
description: 'Current environment'
},
{
name: 'config',
type: 'object',
value: { debug: false, port: 3000 },
description: 'Application configuration'
}
]
});
```
### Dynamic Variables
You can also provide variables asynchronously:
```typescript
getDefaults().setVariableContext({
getVariables: async () => {
const response = await fetch('/api/variables');
return response.json();
}
});
```
## API Reference
### `register(): LanguageServiceDefaults`
Registers the `json-interpolation` language with Monaco Editor. Should be called once before creating editors. Returns the language service defaults for configuration.
### `getDefaults(): LanguageServiceDefaults`
Gets the language service defaults. Automatically registers the language if not already registered.
### `LanguageServiceDefaults`
```typescript
interface LanguageServiceDefaults {
readonly languageId: string;
readonly variableContext: VariableContextProvider | null;
setVariableContext(provider: VariableContextProvider | null): void;
setDiagnosticsOptions(options: DiagnosticsOptions): void;
setModeConfiguration(modeConfiguration: ModeConfiguration): void;
}
```
### `VariableDefinition`
```typescript
interface VariableDefinition {
name: string; // Variable name (without $)
type?: string; // Type for display (e.g., 'string', 'number')
value?: unknown; // Current value (shown in hover)
description?: string; // Description for hover/completion
detail?: string; // Additional detail text
}
```
### `VariableContextProvider`
```typescript
interface VariableContextProvider {
getVariables(): VariableDefinition[] | Promise<VariableDefinition[]>;
resolveVariable?(name: string): unknown | Promise<unknown>;
}
```
## Example
```typescript
import * as monaco from 'monaco-editor';
import jsonInterpolation from 'monaco-json-interpolation';
// Register and configure
const defaults = jsonInterpolation.register();
defaults.setVariableContext({
getVariables: () => [
{ name: 'API_URL', type: 'string', value: 'https://api.example.com' },
{ name: 'VERSION', type: 'string', value: '1.0.0' },
{ name: 'DEBUG', type: 'boolean', value: false }
]
});
// Create editor
const editor = monaco.editor.create(document.getElementById('editor'), {
value: `{
"endpoint": "\${API_URL}/users",
"version": "\${VERSION}",
"settings": {
"debug": \${DEBUG}
}
}`,
language: 'json-interpolation',
theme: 'vs-dark'
});
```
## License
MIT

View file

@ -0,0 +1,43 @@
{
"name": "monaco-json-interpolation",
"version": "1.0.0",
"description": "Monaco Editor language support for JSON with ${...} variable interpolation",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": [
"monaco",
"monaco-editor",
"json",
"interpolation",
"template",
"variables"
],
"author": "",
"license": "MIT",
"peerDependencies": {
"monaco-editor": ">=0.30.0"
},
"devDependencies": {
"monaco-editor": "^0.52.0",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,334 @@
/*---------------------------------------------------------------------------------------------
* Monaco JSON Interpolation
* Standalone add-on for Monaco Editor providing JSON with ${...} variable interpolation
*--------------------------------------------------------------------------------------------*/
import * as monaco from 'monaco-editor';
import { conf, language } from './tokenizer';
// Re-export types
export * from './types';
// --- Language Registration ---
const LANGUAGE_ID = 'json-interpolation';
let isRegistered = false;
let currentDefaults: LanguageServiceDefaultsImpl | null = null;
/**
* Register the json-interpolation language with Monaco Editor.
* This should be called once before creating any editors with this language.
*/
export function register(): LanguageServiceDefaults {
if (isRegistered && currentDefaults) {
return currentDefaults;
}
// Register the language
monaco.languages.register({
id: LANGUAGE_ID,
extensions: ['.jsonc', '.json5'],
aliases: ['JSON with Interpolation', 'json-interpolation'],
mimetypes: ['application/json-interpolation']
});
// Set the Monarch tokenizer
monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, language);
// Set the language configuration
monaco.languages.setLanguageConfiguration(LANGUAGE_ID, conf);
// Create defaults
currentDefaults = new LanguageServiceDefaultsImpl();
// Register providers
registerProviders(currentDefaults);
isRegistered = true;
return currentDefaults;
}
/**
* Get the language service defaults for configuring the language.
* Automatically registers the language if not already registered.
*/
export function getDefaults(): LanguageServiceDefaults {
if (!currentDefaults) {
return register();
}
return currentDefaults;
}
// --- Types ---
import type {
VariableDefinition,
VariableContextProvider,
LanguageServiceDefaults,
DiagnosticsOptions,
ModeConfiguration
} from './types';
// --- Implementation ---
class LanguageServiceDefaultsImpl implements LanguageServiceDefaults {
private _onDidChange = new monaco.Emitter<LanguageServiceDefaults>();
private _diagnosticsOptions: DiagnosticsOptions;
private _modeConfiguration: ModeConfiguration;
private _variableContext: VariableContextProvider | null = null;
constructor() {
this._diagnosticsOptions = {
validate: true,
allowComments: true,
allowTrailingCommas: true,
schemas: [],
schemaValidation: 'warning',
comments: 'ignore',
trailingCommas: 'ignore'
};
this._modeConfiguration = {
completionItems: true,
hovers: true,
documentSymbols: true,
tokens: true,
foldingRanges: true,
diagnostics: true
};
}
get onDidChange(): monaco.IEvent<LanguageServiceDefaults> {
return this._onDidChange.event;
}
get languageId(): string {
return LANGUAGE_ID;
}
get modeConfiguration(): ModeConfiguration {
return this._modeConfiguration;
}
get diagnosticsOptions(): DiagnosticsOptions {
return this._diagnosticsOptions;
}
get variableContext(): VariableContextProvider | null {
return this._variableContext;
}
setDiagnosticsOptions(options: DiagnosticsOptions): void {
this._diagnosticsOptions = options || {};
this._onDidChange.fire(this);
}
setModeConfiguration(modeConfiguration: ModeConfiguration): void {
this._modeConfiguration = modeConfiguration || {};
this._onDidChange.fire(this);
}
setVariableContext(provider: VariableContextProvider | null): void {
this._variableContext = provider;
this._onDidChange.fire(this);
}
}
// --- Providers ---
function registerProviders(defaults: LanguageServiceDefaultsImpl): void {
// Variable completion provider
monaco.languages.registerCompletionItemProvider(LANGUAGE_ID, {
triggerCharacters: ['$', '{'],
async provideCompletionItems(
model: monaco.editor.ITextModel,
position: monaco.Position
): Promise<monaco.languages.CompletionList | null> {
const variableContext = defaults.variableContext;
if (!variableContext) {
return null;
}
// Check if we're inside an interpolation ${...}
const textUntilPosition = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column
});
const lastInterpolationStart = textUntilPosition.lastIndexOf('${');
if (lastInterpolationStart === -1) {
return null;
}
const afterInterpolationStart = textUntilPosition.substring(lastInterpolationStart);
if (afterInterpolationStart.includes('}')) {
return null;
}
const variables = await variableContext.getVariables();
const wordInfo = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
startColumn: wordInfo.startColumn,
endLineNumber: position.lineNumber,
endColumn: wordInfo.endColumn
};
const suggestions: monaco.languages.CompletionItem[] = variables.map((variable) => ({
label: variable.name,
kind: monaco.languages.CompletionItemKind.Variable,
detail: variable.detail || variable.type,
documentation: formatDocumentation(variable),
insertText: variable.name,
range: range
}));
return { suggestions };
}
});
// Variable hover provider
monaco.languages.registerHoverProvider(LANGUAGE_ID, {
async provideHover(
model: monaco.editor.ITextModel,
position: monaco.Position
): Promise<monaco.languages.Hover | null> {
const variableContext = defaults.variableContext;
if (!variableContext) {
return null;
}
const line = model.getLineContent(position.lineNumber);
const offset = position.column - 1;
// Find interpolation boundaries
let inInterpolation = false;
let interpStart = -1;
for (let i = 0; i < line.length - 1; i++) {
if (line[i] === '$' && line[i + 1] === '{') {
if (i < offset) {
inInterpolation = true;
interpStart = i + 2;
}
} else if (line[i] === '}' && inInterpolation) {
if (i >= offset) {
break;
}
inInterpolation = false;
}
}
if (!inInterpolation || interpStart === -1) {
return null;
}
const wordInfo = model.getWordAtPosition(position);
if (!wordInfo) {
return null;
}
const variableName = wordInfo.word;
const variables = await variableContext.getVariables();
const variable = variables.find((v) => v.name === variableName);
if (!variable) {
return null;
}
const contents: monaco.IMarkdownString[] = [];
if (variable.type) {
contents.push({
value: `\`\`\`typescript\n(variable) ${variable.name}: ${variable.type}\n\`\`\``
});
} else {
contents.push({
value: `\`\`\`typescript\n(variable) ${variable.name}\n\`\`\``
});
}
if (variable.description) {
contents.push({ value: variable.description });
}
if (variable.value !== undefined) {
contents.push({
value: `**Current value:**\n\`\`\`json\n${JSON.stringify(variable.value, null, 2)}\n\`\`\``
});
}
return {
contents,
range: {
startLineNumber: position.lineNumber,
startColumn: wordInfo.startColumn,
endLineNumber: position.lineNumber,
endColumn: wordInfo.endColumn
}
};
}
});
// Folding range provider (basic JSON-like folding)
monaco.languages.registerFoldingRangeProvider(LANGUAGE_ID, {
provideFoldingRanges(
model: monaco.editor.ITextModel
): monaco.languages.FoldingRange[] {
const ranges: monaco.languages.FoldingRange[] = [];
const stack: { char: string; line: number }[] = [];
for (let i = 1; i <= model.getLineCount(); i++) {
const line = model.getLineContent(i);
for (const char of line) {
if (char === '{' || char === '[') {
stack.push({ char, line: i });
} else if (char === '}' || char === ']') {
const open = stack.pop();
if (open && open.line < i) {
ranges.push({
start: open.line,
end: i,
kind: monaco.languages.FoldingRangeKind.Region
});
}
}
}
}
return ranges;
}
});
}
function formatDocumentation(
variable: VariableDefinition
): string | monaco.IMarkdownString {
let doc = '';
if (variable.description) {
doc += variable.description;
}
if (variable.value !== undefined) {
if (doc) {
doc += '\n\n';
}
doc += `**Current value:** \`${JSON.stringify(variable.value)}\``;
}
return doc ? { value: doc } : '';
}
// --- Convenience export ---
export const jsonInterpolation = {
register,
getDefaults,
LANGUAGE_ID
};
export default jsonInterpolation;

View file

@ -0,0 +1,127 @@
/*---------------------------------------------------------------------------------------------
* JSON with Interpolation - Monarch Tokenizer
* Supports ${...} interpolation with embedded JavaScript highlighting
*--------------------------------------------------------------------------------------------*/
import type * as monaco from 'monaco-editor';
export const conf: monaco.languages.LanguageConfiguration = {
wordPattern: /(-?\d*\.\d\w*)|([^\[\{\]\}\:\"\,\s]+)/g,
comments: {
lineComment: '//',
blockComment: ['/*', '*/']
},
brackets: [
['{', '}'],
['[', ']'],
['${', '}']
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '"', close: '"', notIn: ['string'] },
{ open: '${', close: '}' }
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '"', close: '"' }
],
folding: {
markers: {
start: /^\s*\/\/\s*#?region\b/,
end: /^\s*\/\/\s*#?endregion\b/
}
}
};
export const language: monaco.languages.IMonarchLanguage = {
defaultToken: '',
tokenPostfix: '.json-interpolation',
escapes: /\\(?:["\\/bfnrt]|u[0-9A-Fa-f]{4})/,
tokenizer: {
root: [
[/\s+/, ''],
[/\/\/.*$/, 'comment'],
[/\/\*/, 'comment', '@comment'],
[/[{]/, 'delimiter.bracket', '@object'],
[/\[/, 'delimiter.array', '@array']
],
object: [
[/\s+/, ''],
[/\/\/.*$/, 'comment'],
[/\/\*/, 'comment', '@comment'],
[/"/, 'string.key', '@propertyName'],
[/:/, 'delimiter.colon'],
[/,/, 'delimiter.comma'],
{ include: '@value' },
[/\}/, 'delimiter.bracket', '@pop']
],
propertyName: [
[/[^"\\]+/, 'string.key'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/"/, 'string.key', '@pop']
],
array: [
[/\s+/, ''],
[/\/\/.*$/, 'comment'],
[/\/\*/, 'comment', '@comment'],
[/,/, 'delimiter.comma'],
{ include: '@value' },
[/\]/, 'delimiter.array', '@pop']
],
value: [
[/"/, 'string.value', '@string'],
[/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/, 'number'],
[/true|false/, 'keyword'],
[/null/, 'keyword'],
[/\{/, 'delimiter.bracket', '@object'],
[/\[/, 'delimiter.array', '@array']
],
string: [
[
/\$\{/,
{
token: 'delimiter.bracket.interpolation',
next: '@interpolation',
nextEmbedded: 'javascript'
}
],
[/[^"\\$]+/, 'string.value'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/\$(?!\{)/, 'string.value'],
[/"/, 'string.value', '@pop']
],
interpolation: [
[
/\}/,
{
token: 'delimiter.bracket.interpolation',
next: '@pop',
nextEmbedded: '@pop'
}
]
],
comment: [
[/[^/*]+/, 'comment'],
[/\*\//, 'comment', '@pop'],
[/[/*]/, 'comment']
]
}
};

View file

@ -0,0 +1,148 @@
/*---------------------------------------------------------------------------------------------
* Monaco JSON Interpolation - Type Definitions
*--------------------------------------------------------------------------------------------*/
import type * as monaco from 'monaco-editor';
/**
* Represents a variable that can be used in interpolation
*/
export interface VariableDefinition {
/**
* The name of the variable (without $ prefix)
*/
readonly name: string;
/**
* The type of the variable for display purposes
*/
readonly type?: string;
/**
* Description shown in hover and completion
*/
readonly description?: string;
/**
* The current value of the variable (for hover preview)
*/
readonly value?: unknown;
/**
* Optional detail text shown in completion item
*/
readonly detail?: string;
}
/**
* Context provider for interpolation variables
*/
export interface VariableContextProvider {
/**
* Get all available variables
*/
getVariables(): VariableDefinition[] | Promise<VariableDefinition[]>;
/**
* Resolve a variable by name (optional, for nested property access)
*/
resolveVariable?(name: string): unknown | Promise<unknown>;
}
/**
* Diagnostics configuration options
*/
export interface DiagnosticsOptions {
/**
* If set, the validator will be enabled
*/
readonly validate?: boolean;
/**
* If set, comments are tolerated
*/
readonly allowComments?: boolean;
/**
* If set, trailing commas are tolerated
*/
readonly allowTrailingCommas?: boolean;
/**
* A list of known schemas
*/
readonly schemas?: {
readonly uri: string;
readonly fileMatch?: string[];
readonly schema?: unknown;
}[];
/**
* The severity of problems from schema validation
*/
readonly schemaValidation?: 'error' | 'warning' | 'ignore';
/**
* The severity of trailing commas
*/
readonly trailingCommas?: 'error' | 'warning' | 'ignore';
/**
* The severity of comments
*/
readonly comments?: 'error' | 'warning' | 'ignore';
}
/**
* Mode configuration options
*/
export interface ModeConfiguration {
/**
* Defines whether the built-in completionItemProvider is enabled
*/
readonly completionItems?: boolean;
/**
* Defines whether the built-in hoverProvider is enabled
*/
readonly hovers?: boolean;
/**
* Defines whether the built-in documentSymbolProvider is enabled
*/
readonly documentSymbols?: boolean;
/**
* Defines whether the built-in tokens provider is enabled
*/
readonly tokens?: boolean;
/**
* Defines whether the built-in foldingRange provider is enabled
*/
readonly foldingRanges?: boolean;
/**
* Defines whether the built-in diagnostic provider is enabled
*/
readonly diagnostics?: boolean;
}
/**
* Language service configuration defaults
*/
export interface LanguageServiceDefaults {
readonly languageId: string;
readonly onDidChange: monaco.IEvent<LanguageServiceDefaults>;
readonly diagnosticsOptions: DiagnosticsOptions;
readonly modeConfiguration: ModeConfiguration;
readonly variableContext: VariableContextProvider | null;
setDiagnosticsOptions(options: DiagnosticsOptions): void;
setModeConfiguration(modeConfiguration: ModeConfiguration): void;
/**
* Set the variable context provider for interpolation completions and hover
*/
setVariableContext(provider: VariableContextProvider | null): void;
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}