mdast-control 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +359 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +534 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/lsp/requests.d.ts +215 -0
- package/dist/lsp/requests.js +613 -0
- package/dist/lsp/requests.js.map +1 -0
- package/dist/lsp/server.d.ts +1 -0
- package/dist/lsp/server.js +110 -0
- package/dist/lsp/server.js.map +1 -0
- package/dist/lsp-methods.d.ts +25 -0
- package/dist/lsp-methods.js +26 -0
- package/dist/lsp-methods.js.map +1 -0
- package/dist/markdown.d.ts +17 -0
- package/dist/markdown.js +140 -0
- package/dist/markdown.js.map +1 -0
- package/dist/plugins.d.ts +144 -0
- package/dist/plugins.js +833 -0
- package/dist/plugins.js.map +1 -0
- package/dist/query.d.ts +8 -0
- package/dist/query.js +518 -0
- package/dist/query.js.map +1 -0
- package/dist/table-interchange.d.ts +22 -0
- package/dist/table-interchange.js +191 -0
- package/dist/table-interchange.js.map +1 -0
- package/dist/table-model.d.ts +15 -0
- package/dist/table-model.js +105 -0
- package/dist/table-model.js.map +1 -0
- package/dist/table-structural.d.ts +9 -0
- package/dist/table-structural.js +190 -0
- package/dist/table-structural.js.map +1 -0
- package/dist/table.d.ts +49 -0
- package/dist/table.js +131 -0
- package/dist/table.js.map +1 -0
- package/dist/transport.d.ts +6 -0
- package/dist/transport.js +7 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +60 -0
- package/scripts/verify-package.mjs +252 -0
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
import { parseMarkdown, stringifyAst } from './markdown.js';
|
|
2
|
+
import { MDAST_REQUEST_METHODS } from './lsp-methods.js';
|
|
3
|
+
import { queryAst } from './query.js';
|
|
4
|
+
import { tableToDelimited } from './table.js';
|
|
5
|
+
export class PluginOperationError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
plugin;
|
|
8
|
+
operation;
|
|
9
|
+
details;
|
|
10
|
+
constructor(shape) {
|
|
11
|
+
super(shape.message);
|
|
12
|
+
this.name = 'PluginOperationError';
|
|
13
|
+
this.code = shape.code;
|
|
14
|
+
this.plugin = shape.plugin;
|
|
15
|
+
this.operation = shape.operation;
|
|
16
|
+
this.details = shape.details;
|
|
17
|
+
}
|
|
18
|
+
toJSON() {
|
|
19
|
+
return {
|
|
20
|
+
code: this.code,
|
|
21
|
+
plugin: this.plugin,
|
|
22
|
+
operation: this.operation,
|
|
23
|
+
message: this.message,
|
|
24
|
+
...(this.details === undefined ? {} : { details: structuredClone(this.details) }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const CORE_CAPABILITIES = {
|
|
29
|
+
queryOperators: ['=', '*=', '^=', '$=', '!=', '~=', '>', '>=', '<', '<='],
|
|
30
|
+
genericOperations: ['query', 'insert', 'delete', 'replace', 'wrap', 'unwrap', 'move', 'copy'],
|
|
31
|
+
customRequests: Object.values(MDAST_REQUEST_METHODS),
|
|
32
|
+
};
|
|
33
|
+
const BUILTIN_PLUGINS = [
|
|
34
|
+
{
|
|
35
|
+
id: 'table',
|
|
36
|
+
version: '0.1.0',
|
|
37
|
+
elementKinds: ['table', 'tableRow', 'tableCell'],
|
|
38
|
+
title: 'Table Tools',
|
|
39
|
+
description: 'Table-oriented query, edit, and CSV/TSV conversion capabilities.',
|
|
40
|
+
availability: 'available',
|
|
41
|
+
queryExtensions: [
|
|
42
|
+
{
|
|
43
|
+
kind: 'pseudo-field',
|
|
44
|
+
name: 'rowIndex',
|
|
45
|
+
targetKinds: ['tableRow', 'tableCell'],
|
|
46
|
+
valueType: 'number',
|
|
47
|
+
operators: ['=', '!=', '>', '>=', '<', '<='],
|
|
48
|
+
description: 'Matches the zero-based body row index for table rows and cells.',
|
|
49
|
+
availability: 'available',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
kind: 'pseudo-field',
|
|
53
|
+
name: 'cellText',
|
|
54
|
+
targetKinds: ['tableCell'],
|
|
55
|
+
valueType: 'string',
|
|
56
|
+
operators: ['=', '*=', '^=', '$=', '!=', '~='],
|
|
57
|
+
description: 'Matches flattened cell text.',
|
|
58
|
+
availability: 'available',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
kind: 'pseudo-field',
|
|
62
|
+
name: 'columnIndex',
|
|
63
|
+
targetKinds: ['tableCell'],
|
|
64
|
+
valueType: 'number',
|
|
65
|
+
operators: ['=', '!=', '>', '>=', '<', '<='],
|
|
66
|
+
description: 'Matches the zero-based column index for a table cell.',
|
|
67
|
+
availability: 'available',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
kind: 'pseudo-field',
|
|
71
|
+
name: 'headerText',
|
|
72
|
+
targetKinds: ['tableCell'],
|
|
73
|
+
valueType: 'string',
|
|
74
|
+
operators: ['=', '*=', '^=', '$=', '!=', '~='],
|
|
75
|
+
description: 'Matches the header text for a cell column.',
|
|
76
|
+
availability: 'available',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
kind: 'pseudo-field',
|
|
80
|
+
name: 'rowCount',
|
|
81
|
+
targetKinds: ['table'],
|
|
82
|
+
valueType: 'number',
|
|
83
|
+
operators: ['=', '!=', '>', '>=', '<', '<='],
|
|
84
|
+
description: 'Matches the number of body rows in a normalized table.',
|
|
85
|
+
availability: 'available',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
kind: 'pseudo-field',
|
|
89
|
+
name: 'columnCount',
|
|
90
|
+
targetKinds: ['table'],
|
|
91
|
+
valueType: 'number',
|
|
92
|
+
operators: ['=', '!=', '>', '>=', '<', '<='],
|
|
93
|
+
description: 'Matches the normalized logical width of a table.',
|
|
94
|
+
availability: 'available',
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
operations: [
|
|
98
|
+
{
|
|
99
|
+
name: 'updateCell',
|
|
100
|
+
title: 'Update Table Cell',
|
|
101
|
+
description: 'Replace the content of matched table cells.',
|
|
102
|
+
targetKinds: ['tableCell'],
|
|
103
|
+
targetCardinality: 'zero-or-more',
|
|
104
|
+
category: 'edit',
|
|
105
|
+
params: [
|
|
106
|
+
{
|
|
107
|
+
name: 'markdown',
|
|
108
|
+
type: 'string',
|
|
109
|
+
required: true,
|
|
110
|
+
description: 'Replacement markdown for the target cell.',
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
result: {
|
|
114
|
+
type: 'mutation',
|
|
115
|
+
shapeDescription: 'Updated markdown plus changed count.',
|
|
116
|
+
},
|
|
117
|
+
availability: 'available',
|
|
118
|
+
stability: 'experimental',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'exportDelimited',
|
|
122
|
+
title: 'Export Delimited Table',
|
|
123
|
+
description: 'Convert a matched table into CSV or TSV text.',
|
|
124
|
+
targetKinds: ['table'],
|
|
125
|
+
targetCardinality: 'exactly-one',
|
|
126
|
+
category: 'convert',
|
|
127
|
+
params: [
|
|
128
|
+
{
|
|
129
|
+
name: 'format',
|
|
130
|
+
type: 'enum',
|
|
131
|
+
required: true,
|
|
132
|
+
description: 'Delimited output format.',
|
|
133
|
+
enumValues: ['csv', 'tsv'],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'includeHeader',
|
|
137
|
+
type: 'boolean',
|
|
138
|
+
required: false,
|
|
139
|
+
description: 'Include the header row; defaults to true.',
|
|
140
|
+
defaultValue: true,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'lineTerminator',
|
|
144
|
+
type: 'enum',
|
|
145
|
+
required: false,
|
|
146
|
+
description: 'Output record separator.',
|
|
147
|
+
enumValues: ['lf', 'crlf'],
|
|
148
|
+
defaultValue: 'lf',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'quoteMode',
|
|
152
|
+
type: 'enum',
|
|
153
|
+
required: false,
|
|
154
|
+
description: 'Quote only required fields or every field.',
|
|
155
|
+
enumValues: ['minimal', 'always'],
|
|
156
|
+
defaultValue: 'minimal',
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
result: {
|
|
160
|
+
type: 'text',
|
|
161
|
+
shapeDescription: 'Delimited text for the matched table.',
|
|
162
|
+
},
|
|
163
|
+
availability: 'available',
|
|
164
|
+
stability: 'experimental',
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
converters: [
|
|
168
|
+
{
|
|
169
|
+
name: 'markdownTableToCsv',
|
|
170
|
+
sourceFormat: 'markdown-table',
|
|
171
|
+
targetFormat: 'csv',
|
|
172
|
+
description: 'Converts a markdown table to CSV.',
|
|
173
|
+
params: [],
|
|
174
|
+
availability: 'available',
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'markdownTableToTsv',
|
|
178
|
+
sourceFormat: 'markdown-table',
|
|
179
|
+
targetFormat: 'tsv',
|
|
180
|
+
description: 'Converts a markdown table to TSV.',
|
|
181
|
+
params: [],
|
|
182
|
+
availability: 'available',
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
handlers: {
|
|
186
|
+
updateCell: ({ markdown, query, args }) => {
|
|
187
|
+
const tree = parseMarkdown(markdown);
|
|
188
|
+
const matches = queryAst(tree, query);
|
|
189
|
+
const replacement = parseTableCellChildren(readRequiredStringArg(args, 'markdown'));
|
|
190
|
+
for (const match of matches) {
|
|
191
|
+
if (match.node.type !== 'tableCell') {
|
|
192
|
+
throw new Error(`Operation "table/updateCell" requires tableCell matches, received "${match.node.type}"`);
|
|
193
|
+
}
|
|
194
|
+
match.node.children = replacement.map((child) => structuredClone(child));
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
kind: 'mutation',
|
|
198
|
+
changed: matches.length,
|
|
199
|
+
markdown: stringifyAst(tree),
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
exportDelimited: ({ markdown, query, args }) => {
|
|
203
|
+
const format = readRequiredEnumArg(args, 'format', ['csv', 'tsv']);
|
|
204
|
+
const lineTerminator = readOptionalEnumArg(args, 'lineTerminator', ['lf', 'crlf']) ?? 'lf';
|
|
205
|
+
const quoteMode = readOptionalEnumArg(args, 'quoteMode', ['minimal', 'always']) ?? 'minimal';
|
|
206
|
+
return {
|
|
207
|
+
kind: 'text',
|
|
208
|
+
text: tableToDelimited(markdown, query, format, {
|
|
209
|
+
includeHeader: readOptionalBooleanArg(args, 'includeHeader') ?? true,
|
|
210
|
+
lineTerminator: lineTerminator === 'crlf' ? '\r\n' : '\n',
|
|
211
|
+
quoteMode: quoteMode,
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: 'codeBlock',
|
|
219
|
+
version: '0.1.0',
|
|
220
|
+
elementKinds: ['code'],
|
|
221
|
+
title: 'Code Block Tools',
|
|
222
|
+
description: 'Code-block-specific edit, analyze, and conversion capabilities.',
|
|
223
|
+
availability: 'available',
|
|
224
|
+
operations: [
|
|
225
|
+
{
|
|
226
|
+
name: 'setLanguage',
|
|
227
|
+
title: 'Set Code Block Language',
|
|
228
|
+
description: 'Set or clear the language token on matched code blocks.',
|
|
229
|
+
targetKinds: ['code'],
|
|
230
|
+
targetCardinality: 'one-or-more',
|
|
231
|
+
category: 'edit',
|
|
232
|
+
params: [
|
|
233
|
+
{
|
|
234
|
+
name: 'language',
|
|
235
|
+
type: 'string',
|
|
236
|
+
required: true,
|
|
237
|
+
description: 'Fence language token; an empty string clears it.',
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
result: {
|
|
241
|
+
type: 'mutation',
|
|
242
|
+
shapeDescription: 'Updated markdown plus changed count.',
|
|
243
|
+
},
|
|
244
|
+
availability: 'available',
|
|
245
|
+
stability: 'experimental',
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: 'replaceContent',
|
|
249
|
+
title: 'Replace Code Block Content',
|
|
250
|
+
description: 'Replace the literal content of matched code blocks.',
|
|
251
|
+
targetKinds: ['code'],
|
|
252
|
+
targetCardinality: 'one-or-more',
|
|
253
|
+
category: 'edit',
|
|
254
|
+
params: [
|
|
255
|
+
{
|
|
256
|
+
name: 'text',
|
|
257
|
+
type: 'string',
|
|
258
|
+
required: true,
|
|
259
|
+
description: 'Literal replacement content.',
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
result: {
|
|
263
|
+
type: 'mutation',
|
|
264
|
+
shapeDescription: 'Updated markdown plus changed count.',
|
|
265
|
+
},
|
|
266
|
+
availability: 'available',
|
|
267
|
+
stability: 'experimental',
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: 'appendContent',
|
|
271
|
+
title: 'Append Code Block Content',
|
|
272
|
+
description: 'Append literal content to matched code blocks without an implicit separator.',
|
|
273
|
+
targetKinds: ['code'],
|
|
274
|
+
targetCardinality: 'one-or-more',
|
|
275
|
+
category: 'edit',
|
|
276
|
+
params: [
|
|
277
|
+
{
|
|
278
|
+
name: 'text',
|
|
279
|
+
type: 'string',
|
|
280
|
+
required: true,
|
|
281
|
+
description: 'Literal content appended exactly as supplied.',
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
result: {
|
|
285
|
+
type: 'mutation',
|
|
286
|
+
shapeDescription: 'Updated markdown plus changed count.',
|
|
287
|
+
},
|
|
288
|
+
availability: 'available',
|
|
289
|
+
stability: 'experimental',
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: 'extractContent',
|
|
293
|
+
title: 'Extract Code Block Content',
|
|
294
|
+
description: 'Return the literal content of exactly one code block.',
|
|
295
|
+
targetKinds: ['code'],
|
|
296
|
+
targetCardinality: 'exactly-one',
|
|
297
|
+
category: 'convert',
|
|
298
|
+
params: [],
|
|
299
|
+
result: {
|
|
300
|
+
type: 'text',
|
|
301
|
+
shapeDescription: 'Literal code block content.',
|
|
302
|
+
},
|
|
303
|
+
availability: 'available',
|
|
304
|
+
stability: 'experimental',
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: 'validateContent',
|
|
308
|
+
title: 'Validate Code Block Content',
|
|
309
|
+
description: 'Validate matched code blocks using a plugin-owned deterministic format validator.',
|
|
310
|
+
targetKinds: ['code'],
|
|
311
|
+
targetCardinality: 'one-or-more',
|
|
312
|
+
category: 'analyze',
|
|
313
|
+
params: [
|
|
314
|
+
{
|
|
315
|
+
name: 'format',
|
|
316
|
+
type: 'enum',
|
|
317
|
+
required: true,
|
|
318
|
+
description: 'Validation format.',
|
|
319
|
+
enumValues: ['json'],
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
result: {
|
|
323
|
+
type: 'json',
|
|
324
|
+
shapeDescription: 'Validation summary with one issue per invalid matched block.',
|
|
325
|
+
},
|
|
326
|
+
availability: 'available',
|
|
327
|
+
stability: 'experimental',
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
name: 'formatContent',
|
|
331
|
+
title: 'Format Code Block Content',
|
|
332
|
+
description: 'Format matched code blocks using a plugin-owned deterministic formatter.',
|
|
333
|
+
targetKinds: ['code'],
|
|
334
|
+
targetCardinality: 'one-or-more',
|
|
335
|
+
category: 'edit',
|
|
336
|
+
params: [
|
|
337
|
+
{
|
|
338
|
+
name: 'format',
|
|
339
|
+
type: 'enum',
|
|
340
|
+
required: true,
|
|
341
|
+
description: 'Formatting language.',
|
|
342
|
+
enumValues: ['json'],
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
name: 'indent',
|
|
346
|
+
type: 'number',
|
|
347
|
+
required: false,
|
|
348
|
+
description: 'JSON indentation width from 0 through 10.',
|
|
349
|
+
defaultValue: 2,
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
result: {
|
|
353
|
+
type: 'mutation',
|
|
354
|
+
shapeDescription: 'Updated markdown plus changed count.',
|
|
355
|
+
},
|
|
356
|
+
availability: 'available',
|
|
357
|
+
stability: 'experimental',
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
converters: [
|
|
361
|
+
{
|
|
362
|
+
name: 'codeBlockToText',
|
|
363
|
+
sourceFormat: 'markdown-code-block',
|
|
364
|
+
targetFormat: 'text',
|
|
365
|
+
description: 'Extracts literal content from one code block.',
|
|
366
|
+
params: [],
|
|
367
|
+
availability: 'available',
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: 'jsonCodeBlockFormat',
|
|
371
|
+
sourceFormat: 'json-code-block',
|
|
372
|
+
targetFormat: 'json-code-block',
|
|
373
|
+
description: 'Validates and deterministically formats JSON code-block content.',
|
|
374
|
+
params: [],
|
|
375
|
+
availability: 'available',
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
handlers: {
|
|
379
|
+
setLanguage: ({ tree, matches, args }) => {
|
|
380
|
+
const language = readRequiredStringArg(args, 'language');
|
|
381
|
+
if (language !== '' && (!/^\S+$/.test(language) || language.includes('`'))) {
|
|
382
|
+
throw new Error('Code block language must be empty or a non-whitespace token without backticks');
|
|
383
|
+
}
|
|
384
|
+
for (const match of matches) {
|
|
385
|
+
match.node.lang = language === '' ? null : language;
|
|
386
|
+
}
|
|
387
|
+
return codeMutationResult(tree, matches.length);
|
|
388
|
+
},
|
|
389
|
+
replaceContent: ({ tree, matches, args }) => {
|
|
390
|
+
const text = readRequiredStringArg(args, 'text');
|
|
391
|
+
for (const match of matches) {
|
|
392
|
+
match.node.value = text;
|
|
393
|
+
}
|
|
394
|
+
return codeMutationResult(tree, matches.length);
|
|
395
|
+
},
|
|
396
|
+
appendContent: ({ tree, matches, args }) => {
|
|
397
|
+
const text = readRequiredStringArg(args, 'text');
|
|
398
|
+
for (const match of matches) {
|
|
399
|
+
match.node.value = `${typeof match.node.value === 'string' ? match.node.value : ''}${text}`;
|
|
400
|
+
}
|
|
401
|
+
return codeMutationResult(tree, matches.length);
|
|
402
|
+
},
|
|
403
|
+
extractContent: ({ matches }) => ({
|
|
404
|
+
kind: 'text',
|
|
405
|
+
text: readCodeContent(matches[0]),
|
|
406
|
+
}),
|
|
407
|
+
validateContent: ({ matches, args }) => {
|
|
408
|
+
readRequiredEnumArg(args, 'format', ['json']);
|
|
409
|
+
const issues = matches.flatMap((match) => {
|
|
410
|
+
try {
|
|
411
|
+
JSON.parse(readCodeContent(match));
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
return [{
|
|
416
|
+
path: [...match.path],
|
|
417
|
+
message: error instanceof Error ? error.message : 'Invalid JSON content',
|
|
418
|
+
}];
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
return {
|
|
422
|
+
kind: 'json',
|
|
423
|
+
value: {
|
|
424
|
+
format: 'json',
|
|
425
|
+
valid: issues.length === 0,
|
|
426
|
+
checked: matches.length,
|
|
427
|
+
issues,
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
},
|
|
431
|
+
formatContent: ({ tree, matches, args }) => {
|
|
432
|
+
readRequiredEnumArg(args, 'format', ['json']);
|
|
433
|
+
const indent = readRequiredNumberArg(args, 'indent');
|
|
434
|
+
if (!Number.isInteger(indent) || indent < 0 || indent > 10) {
|
|
435
|
+
throw new Error('JSON indentation must be an integer from 0 through 10');
|
|
436
|
+
}
|
|
437
|
+
for (const match of matches) {
|
|
438
|
+
match.node.value = JSON.stringify(JSON.parse(readCodeContent(match)), null, indent);
|
|
439
|
+
}
|
|
440
|
+
return codeMutationResult(tree, matches.length);
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
];
|
|
445
|
+
export class PluginRegistry {
|
|
446
|
+
core;
|
|
447
|
+
plugins;
|
|
448
|
+
byId;
|
|
449
|
+
byElementKind;
|
|
450
|
+
constructor(core = CORE_CAPABILITIES, plugins = BUILTIN_PLUGINS) {
|
|
451
|
+
this.core = cloneCoreCapabilities(core);
|
|
452
|
+
this.plugins = [];
|
|
453
|
+
this.byId = new Map();
|
|
454
|
+
this.byElementKind = new Map();
|
|
455
|
+
this.registerPlugins(plugins);
|
|
456
|
+
}
|
|
457
|
+
registerPlugin(plugin) {
|
|
458
|
+
if (this.byId.has(plugin.id)) {
|
|
459
|
+
throw new Error(`Plugin "${plugin.id}" is already registered`);
|
|
460
|
+
}
|
|
461
|
+
validatePluginDefinition(plugin, this.plugins);
|
|
462
|
+
const normalized = cloneRegistryPlugin(plugin);
|
|
463
|
+
this.plugins.push(normalized);
|
|
464
|
+
this.byId.set(normalized.id, normalized);
|
|
465
|
+
for (const kind of normalized.elementKinds) {
|
|
466
|
+
const plugins = this.byElementKind.get(kind) ?? [];
|
|
467
|
+
plugins.push(normalized);
|
|
468
|
+
this.byElementKind.set(kind, plugins);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
registerPlugins(plugins) {
|
|
472
|
+
for (const plugin of plugins) {
|
|
473
|
+
this.registerPlugin(plugin);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
getPlugin(id) {
|
|
477
|
+
const plugin = this.byId.get(id);
|
|
478
|
+
return plugin ? clonePublicPlugin(plugin) : undefined;
|
|
479
|
+
}
|
|
480
|
+
listPlugins() {
|
|
481
|
+
return this.plugins.map(clonePublicPlugin);
|
|
482
|
+
}
|
|
483
|
+
listPluginsForElementKind(kind) {
|
|
484
|
+
return (this.byElementKind.get(kind) ?? []).map(clonePublicPlugin);
|
|
485
|
+
}
|
|
486
|
+
getCapabilities() {
|
|
487
|
+
return {
|
|
488
|
+
core: cloneCoreCapabilities(this.core),
|
|
489
|
+
plugins: this.listPlugins(),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
getPluginCapabilities(pluginId) {
|
|
493
|
+
return this.getPlugin(pluginId);
|
|
494
|
+
}
|
|
495
|
+
getElementCapabilities(elementKind) {
|
|
496
|
+
return {
|
|
497
|
+
elementKind,
|
|
498
|
+
plugins: this.listPluginsForElementKind(elementKind),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
getOperationDescriptor(pluginId, operationName) {
|
|
502
|
+
const plugin = this.byId.get(pluginId);
|
|
503
|
+
const operation = plugin?.operations.find((candidate) => candidate.name === operationName);
|
|
504
|
+
return operation ? cloneOperation(operation) : undefined;
|
|
505
|
+
}
|
|
506
|
+
listQueryExtensions() {
|
|
507
|
+
return this.plugins.flatMap((plugin) => (plugin.queryExtensions ?? []).map(cloneQueryExtension));
|
|
508
|
+
}
|
|
509
|
+
runOperation(request) {
|
|
510
|
+
const plugin = this.byId.get(request.plugin);
|
|
511
|
+
if (!plugin) {
|
|
512
|
+
throw pluginError(request, 'unknown-plugin', `Unknown plugin "${request.plugin}"`);
|
|
513
|
+
}
|
|
514
|
+
const descriptor = plugin.operations.find((candidate) => candidate.name === request.operation);
|
|
515
|
+
if (!descriptor) {
|
|
516
|
+
throw pluginError(request, 'unknown-operation', `Unknown operation "${request.operation}" for plugin "${request.plugin}"`);
|
|
517
|
+
}
|
|
518
|
+
if (plugin.availability !== 'available' || descriptor.availability !== 'available') {
|
|
519
|
+
throw pluginError(request, 'operation-unavailable', `Plugin operation "${request.plugin}/${request.operation}" is not available`);
|
|
520
|
+
}
|
|
521
|
+
const args = validateOperationArgs(request, descriptor, request.args ?? {});
|
|
522
|
+
const handler = plugin.handlers?.[request.operation];
|
|
523
|
+
if (!handler) {
|
|
524
|
+
throw pluginError(request, 'operation-unavailable', `Plugin operation "${request.plugin}/${request.operation}" is not implemented`);
|
|
525
|
+
}
|
|
526
|
+
let tree;
|
|
527
|
+
let matches;
|
|
528
|
+
try {
|
|
529
|
+
tree = parseMarkdown(request.markdown);
|
|
530
|
+
matches = queryAst(tree, request.query);
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
throw pluginError(request, 'invalid-query', `Plugin operation "${request.plugin}/${request.operation}" received an invalid query: ${errorMessage(error)}`);
|
|
534
|
+
}
|
|
535
|
+
validateTargetMatches(request, descriptor, matches);
|
|
536
|
+
try {
|
|
537
|
+
return handler({
|
|
538
|
+
markdown: request.markdown,
|
|
539
|
+
query: request.query,
|
|
540
|
+
args,
|
|
541
|
+
descriptor: cloneOperation(descriptor),
|
|
542
|
+
tree,
|
|
543
|
+
matches,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
if (error instanceof PluginOperationError) {
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
throw pluginError(request, 'semantic-error', `Plugin operation "${request.plugin}/${request.operation}" failed: ${errorMessage(error)}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
export const defaultPluginRegistry = new PluginRegistry();
|
|
555
|
+
export function getCapabilities() {
|
|
556
|
+
return defaultPluginRegistry.getCapabilities();
|
|
557
|
+
}
|
|
558
|
+
export function getPluginCapabilities(pluginId) {
|
|
559
|
+
return defaultPluginRegistry.getPluginCapabilities(pluginId);
|
|
560
|
+
}
|
|
561
|
+
export function getElementCapabilities(elementKind) {
|
|
562
|
+
return defaultPluginRegistry.getElementCapabilities(elementKind);
|
|
563
|
+
}
|
|
564
|
+
export function getOperationDescriptor(pluginId, operationName) {
|
|
565
|
+
return defaultPluginRegistry.getOperationDescriptor(pluginId, operationName);
|
|
566
|
+
}
|
|
567
|
+
export function listPlugins() {
|
|
568
|
+
return defaultPluginRegistry.listPlugins();
|
|
569
|
+
}
|
|
570
|
+
export function listPluginsForElementKind(kind) {
|
|
571
|
+
return defaultPluginRegistry.listPluginsForElementKind(kind);
|
|
572
|
+
}
|
|
573
|
+
export function listQueryExtensions() {
|
|
574
|
+
return defaultPluginRegistry.listQueryExtensions();
|
|
575
|
+
}
|
|
576
|
+
export function runPluginOperation(request) {
|
|
577
|
+
return defaultPluginRegistry.runOperation(request);
|
|
578
|
+
}
|
|
579
|
+
function cloneCoreCapabilities(core) {
|
|
580
|
+
return {
|
|
581
|
+
queryOperators: [...core.queryOperators],
|
|
582
|
+
genericOperations: [...core.genericOperations],
|
|
583
|
+
customRequests: [...core.customRequests],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function cloneRegistryPlugin(plugin) {
|
|
587
|
+
return {
|
|
588
|
+
...plugin,
|
|
589
|
+
elementKinds: [...plugin.elementKinds],
|
|
590
|
+
operations: plugin.operations.map(cloneOperation),
|
|
591
|
+
queryExtensions: plugin.queryExtensions?.map(cloneQueryExtension),
|
|
592
|
+
converters: plugin.converters?.map(cloneConverter),
|
|
593
|
+
handlers: plugin.handlers ? { ...plugin.handlers } : undefined,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
function clonePublicPlugin(plugin) {
|
|
597
|
+
return {
|
|
598
|
+
id: plugin.id,
|
|
599
|
+
version: plugin.version,
|
|
600
|
+
elementKinds: [...plugin.elementKinds],
|
|
601
|
+
title: plugin.title,
|
|
602
|
+
description: plugin.description,
|
|
603
|
+
availability: plugin.availability,
|
|
604
|
+
operations: plugin.operations.map(cloneOperation),
|
|
605
|
+
queryExtensions: plugin.queryExtensions?.map(cloneQueryExtension),
|
|
606
|
+
converters: plugin.converters?.map(cloneConverter),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function cloneOperation(operation) {
|
|
610
|
+
return {
|
|
611
|
+
...operation,
|
|
612
|
+
targetKinds: [...operation.targetKinds],
|
|
613
|
+
params: operation.params.map(cloneParam),
|
|
614
|
+
result: { ...operation.result },
|
|
615
|
+
examples: operation.examples ? [...operation.examples] : undefined,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function cloneQueryExtension(extension) {
|
|
619
|
+
return {
|
|
620
|
+
...extension,
|
|
621
|
+
targetKinds: [...extension.targetKinds],
|
|
622
|
+
operators: [...extension.operators],
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function cloneConverter(converter) {
|
|
626
|
+
return {
|
|
627
|
+
...converter,
|
|
628
|
+
params: converter.params.map(cloneParam),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function cloneParam(param) {
|
|
632
|
+
return {
|
|
633
|
+
...param,
|
|
634
|
+
enumValues: param.enumValues ? [...param.enumValues] : undefined,
|
|
635
|
+
...(param.defaultValue === undefined ? {} : { defaultValue: structuredClone(param.defaultValue) }),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function validateOperationArgs(request, descriptor, args) {
|
|
639
|
+
if (!isRecord(args)) {
|
|
640
|
+
throw pluginError(request, 'invalid-params', `Operation "${descriptor.name}" requires an object args payload`);
|
|
641
|
+
}
|
|
642
|
+
const knownNames = new Set(descriptor.params.map((param) => param.name));
|
|
643
|
+
const unknownNames = Object.keys(args).filter((name) => !knownNames.has(name));
|
|
644
|
+
if (unknownNames.length > 0) {
|
|
645
|
+
throw pluginError(request, 'invalid-params', `Operation "${descriptor.name}" received unknown arg "${unknownNames[0]}"`, { unknownArgs: unknownNames });
|
|
646
|
+
}
|
|
647
|
+
const normalized = { ...args };
|
|
648
|
+
for (const param of descriptor.params) {
|
|
649
|
+
const value = normalized[param.name];
|
|
650
|
+
if (value === undefined) {
|
|
651
|
+
if (param.required) {
|
|
652
|
+
throw pluginError(request, 'invalid-params', `Operation "${descriptor.name}" requires arg "${param.name}"`, { parameter: param.name });
|
|
653
|
+
}
|
|
654
|
+
if (param.defaultValue !== undefined) {
|
|
655
|
+
normalized[param.name] = structuredClone(param.defaultValue);
|
|
656
|
+
}
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (!matchesParamType(value, param)) {
|
|
660
|
+
throw pluginError(request, 'invalid-params', `Operation "${descriptor.name}" received invalid arg "${param.name}"`, { parameter: param.name, expectedType: param.type });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return normalized;
|
|
664
|
+
}
|
|
665
|
+
function matchesParamType(value, param) {
|
|
666
|
+
if (param.type === 'json') {
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
if (param.type === 'enum') {
|
|
670
|
+
return typeof value === 'string' && (param.enumValues?.includes(value) ?? true);
|
|
671
|
+
}
|
|
672
|
+
if (param.type === 'number') {
|
|
673
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
674
|
+
}
|
|
675
|
+
return typeof value === param.type;
|
|
676
|
+
}
|
|
677
|
+
function validateTargetMatches(request, descriptor, matches) {
|
|
678
|
+
const count = matches.length;
|
|
679
|
+
const cardinalityValid = descriptor.targetCardinality === 'zero-or-more'
|
|
680
|
+
|| (descriptor.targetCardinality === 'zero-or-one' && count <= 1)
|
|
681
|
+
|| (descriptor.targetCardinality === 'one-or-more' && count >= 1)
|
|
682
|
+
|| (descriptor.targetCardinality === 'exactly-one' && count === 1);
|
|
683
|
+
if (!cardinalityValid) {
|
|
684
|
+
throw pluginError(request, 'target-cardinality', `Operation "${request.plugin}/${request.operation}" requires ${descriptor.targetCardinality} target, found ${String(count)}`, { expected: descriptor.targetCardinality, actual: count });
|
|
685
|
+
}
|
|
686
|
+
const unsupported = matches.find((match) => !descriptor.targetKinds.includes(match.node.type));
|
|
687
|
+
if (unsupported) {
|
|
688
|
+
throw pluginError(request, 'unsupported-target', `Operation "${request.plugin}/${request.operation}" does not support target kind "${unsupported.node.type}"`, { expectedKinds: [...descriptor.targetKinds], actualKind: unsupported.node.type, path: [...unsupported.path] });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function validatePluginDefinition(plugin, registered) {
|
|
692
|
+
if (!plugin.id.trim()) {
|
|
693
|
+
throw new Error('Plugin id must not be empty');
|
|
694
|
+
}
|
|
695
|
+
if (!plugin.version.trim()) {
|
|
696
|
+
throw new Error(`Plugin "${plugin.id}" version must not be empty`);
|
|
697
|
+
}
|
|
698
|
+
if (plugin.elementKinds.length === 0 || plugin.elementKinds.some((kind) => !kind.trim())) {
|
|
699
|
+
throw new Error(`Plugin "${plugin.id}" must declare non-empty element kinds`);
|
|
700
|
+
}
|
|
701
|
+
assertUnique(plugin.id, 'element kind', plugin.elementKinds);
|
|
702
|
+
assertUnique(plugin.id, 'operation', plugin.operations.map((operation) => operation.name));
|
|
703
|
+
assertUnique(plugin.id, 'query extension', (plugin.queryExtensions ?? []).map((extension) => extension.name));
|
|
704
|
+
const operationNames = new Set(plugin.operations.map((operation) => operation.name));
|
|
705
|
+
for (const operation of plugin.operations) {
|
|
706
|
+
if (!operation.name.trim() || operation.targetKinds.length === 0) {
|
|
707
|
+
throw new Error(`Plugin "${plugin.id}" has an invalid operation descriptor`);
|
|
708
|
+
}
|
|
709
|
+
assertUnique(plugin.id, `parameter for operation "${operation.name}"`, operation.params.map((param) => param.name));
|
|
710
|
+
if (operation.availability === 'available' && !plugin.handlers?.[operation.name]) {
|
|
711
|
+
throw new Error(`Plugin "${plugin.id}" available operation "${operation.name}" requires a handler`);
|
|
712
|
+
}
|
|
713
|
+
if (plugin.availability === 'planned' && operation.availability === 'available') {
|
|
714
|
+
throw new Error(`Planned plugin "${plugin.id}" cannot expose available operation "${operation.name}"`);
|
|
715
|
+
}
|
|
716
|
+
for (const param of operation.params) {
|
|
717
|
+
if (!param.name.trim()) {
|
|
718
|
+
throw new Error(`Plugin "${plugin.id}" operation "${operation.name}" has an empty parameter name`);
|
|
719
|
+
}
|
|
720
|
+
if (param.type === 'enum' && (!param.enumValues || param.enumValues.length === 0)) {
|
|
721
|
+
throw new Error(`Plugin "${plugin.id}" operation "${operation.name}" enum "${param.name}" needs values`);
|
|
722
|
+
}
|
|
723
|
+
if (param.defaultValue !== undefined && !matchesParamType(param.defaultValue, param)) {
|
|
724
|
+
throw new Error(`Plugin "${plugin.id}" operation "${operation.name}" has an invalid default for "${param.name}"`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
for (const handlerName of Object.keys(plugin.handlers ?? {})) {
|
|
729
|
+
if (!operationNames.has(handlerName)) {
|
|
730
|
+
throw new Error(`Plugin "${plugin.id}" has a handler for unknown operation "${handlerName}"`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
for (const extension of plugin.queryExtensions ?? []) {
|
|
734
|
+
for (const otherPlugin of registered) {
|
|
735
|
+
const conflict = (otherPlugin.queryExtensions ?? []).find((candidate) => candidate.name === extension.name
|
|
736
|
+
&& candidate.targetKinds.some((kind) => extension.targetKinds.includes(kind))
|
|
737
|
+
&& (candidate.valueType !== extension.valueType
|
|
738
|
+
|| !sameStringSet(candidate.operators, extension.operators)));
|
|
739
|
+
if (conflict) {
|
|
740
|
+
throw new Error(`Plugin "${plugin.id}" query extension "${extension.name}" conflicts with plugin "${otherPlugin.id}"`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
function sameStringSet(left, right) {
|
|
746
|
+
return left.length === right.length && left.every((value) => right.includes(value));
|
|
747
|
+
}
|
|
748
|
+
function assertUnique(pluginId, label, values) {
|
|
749
|
+
const seen = new Set();
|
|
750
|
+
for (const value of values) {
|
|
751
|
+
if (seen.has(value)) {
|
|
752
|
+
throw new Error(`Plugin "${pluginId}" declares duplicate ${label} "${value}"`);
|
|
753
|
+
}
|
|
754
|
+
seen.add(value);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function readRequiredStringArg(args, name) {
|
|
758
|
+
const value = args[name];
|
|
759
|
+
if (typeof value !== 'string') {
|
|
760
|
+
throw new Error(`Operation requires string arg "${name}"`);
|
|
761
|
+
}
|
|
762
|
+
return value;
|
|
763
|
+
}
|
|
764
|
+
function readRequiredNumberArg(args, name) {
|
|
765
|
+
const value = args[name];
|
|
766
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
767
|
+
throw new Error(`Operation requires finite number arg "${name}"`);
|
|
768
|
+
}
|
|
769
|
+
return value;
|
|
770
|
+
}
|
|
771
|
+
function readRequiredEnumArg(args, name, values) {
|
|
772
|
+
const value = args[name];
|
|
773
|
+
if (typeof value !== 'string' || !values.includes(value)) {
|
|
774
|
+
throw new Error(`Operation requires enum arg "${name}" in [${values.join(', ')}]`);
|
|
775
|
+
}
|
|
776
|
+
return value;
|
|
777
|
+
}
|
|
778
|
+
function readOptionalBooleanArg(args, name) {
|
|
779
|
+
const value = args[name];
|
|
780
|
+
if (value === undefined || typeof value === 'boolean') {
|
|
781
|
+
return value;
|
|
782
|
+
}
|
|
783
|
+
throw new Error(`Operation requires boolean arg "${name}"`);
|
|
784
|
+
}
|
|
785
|
+
function readOptionalEnumArg(args, name, values) {
|
|
786
|
+
const value = args[name];
|
|
787
|
+
if (value === undefined) {
|
|
788
|
+
return undefined;
|
|
789
|
+
}
|
|
790
|
+
if (typeof value !== 'string' || !values.includes(value)) {
|
|
791
|
+
throw new Error(`Operation requires enum arg "${name}" in [${values.join(', ')}]`);
|
|
792
|
+
}
|
|
793
|
+
return value;
|
|
794
|
+
}
|
|
795
|
+
function parseTableCellChildren(markdown) {
|
|
796
|
+
const fragment = parseMarkdown(markdown).children ?? [];
|
|
797
|
+
if (fragment.length === 0) {
|
|
798
|
+
return [];
|
|
799
|
+
}
|
|
800
|
+
if (fragment.length !== 1 || fragment[0].type !== 'paragraph' || !Array.isArray(fragment[0].children)) {
|
|
801
|
+
throw new Error('Table cell replacement markdown must parse to a single paragraph');
|
|
802
|
+
}
|
|
803
|
+
return fragment[0].children;
|
|
804
|
+
}
|
|
805
|
+
function readCodeContent(match) {
|
|
806
|
+
if (!match || match.node.type !== 'code') {
|
|
807
|
+
throw new Error('Code-block operation requires a code target');
|
|
808
|
+
}
|
|
809
|
+
return typeof match.node.value === 'string' ? match.node.value : '';
|
|
810
|
+
}
|
|
811
|
+
function codeMutationResult(tree, changed) {
|
|
812
|
+
return {
|
|
813
|
+
kind: 'mutation',
|
|
814
|
+
changed,
|
|
815
|
+
markdown: stringifyAst(tree),
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
function pluginError(request, code, message, details) {
|
|
819
|
+
return new PluginOperationError({
|
|
820
|
+
code,
|
|
821
|
+
plugin: request.plugin,
|
|
822
|
+
operation: request.operation,
|
|
823
|
+
message,
|
|
824
|
+
...(details === undefined ? {} : { details }),
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
function errorMessage(error) {
|
|
828
|
+
return error instanceof Error ? error.message : String(error);
|
|
829
|
+
}
|
|
830
|
+
function isRecord(value) {
|
|
831
|
+
return typeof value === 'object' && value !== null;
|
|
832
|
+
}
|
|
833
|
+
//# sourceMappingURL=plugins.js.map
|