From 6039262df79df97b5aa00c7a9f6f2eb919c381f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Dec 2025 19:58:21 +0000 Subject: [PATCH] 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' }] }); ``` --- packages/monaco-json-interpolation/README.md | 157 ++++++++ .../monaco-json-interpolation/package.json | 43 +++ .../monaco-json-interpolation/src/index.ts | 334 ++++++++++++++++++ .../src/tokenizer.ts | 127 +++++++ .../monaco-json-interpolation/src/types.ts | 148 ++++++++ .../monaco-json-interpolation/tsconfig.json | 26 ++ 6 files changed, 835 insertions(+) create mode 100644 packages/monaco-json-interpolation/README.md create mode 100644 packages/monaco-json-interpolation/package.json create mode 100644 packages/monaco-json-interpolation/src/index.ts create mode 100644 packages/monaco-json-interpolation/src/tokenizer.ts create mode 100644 packages/monaco-json-interpolation/src/types.ts create mode 100644 packages/monaco-json-interpolation/tsconfig.json diff --git a/packages/monaco-json-interpolation/README.md b/packages/monaco-json-interpolation/README.md new file mode 100644 index 00000000..8ac66905 --- /dev/null +++ b/packages/monaco-json-interpolation/README.md @@ -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; + resolveVariable?(name: string): unknown | Promise; +} +``` + +## 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 diff --git a/packages/monaco-json-interpolation/package.json b/packages/monaco-json-interpolation/package.json new file mode 100644 index 00000000..4ba7c320 --- /dev/null +++ b/packages/monaco-json-interpolation/package.json @@ -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" + } +} diff --git a/packages/monaco-json-interpolation/src/index.ts b/packages/monaco-json-interpolation/src/index.ts new file mode 100644 index 00000000..b8d59c9f --- /dev/null +++ b/packages/monaco-json-interpolation/src/index.ts @@ -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(); + 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 { + 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 { + 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 { + 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; diff --git a/packages/monaco-json-interpolation/src/tokenizer.ts b/packages/monaco-json-interpolation/src/tokenizer.ts new file mode 100644 index 00000000..0c79fe75 --- /dev/null +++ b/packages/monaco-json-interpolation/src/tokenizer.ts @@ -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'] + ] + } +}; diff --git a/packages/monaco-json-interpolation/src/types.ts b/packages/monaco-json-interpolation/src/types.ts new file mode 100644 index 00000000..5b39f154 --- /dev/null +++ b/packages/monaco-json-interpolation/src/types.ts @@ -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; + + /** + * Resolve a variable by name (optional, for nested property access) + */ + resolveVariable?(name: string): unknown | Promise; +} + +/** + * 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; + 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; +} diff --git a/packages/monaco-json-interpolation/tsconfig.json b/packages/monaco-json-interpolation/tsconfig.json new file mode 100644 index 00000000..8f8af4a0 --- /dev/null +++ b/packages/monaco-json-interpolation/tsconfig.json @@ -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"] +}