@oml/server 0.14.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 +94 -0
- package/package.json +34 -0
- package/src/cli.ts +189 -0
- package/src/index.ts +9 -0
- package/src/lsp/diagram-server.ts +48 -0
- package/src/lsp/language-server.ts +423 -0
- package/src/lsp/protocol/browser-fs-protocol.ts +21 -0
- package/src/lsp/protocol/reasoner-protocol.ts +86 -0
- package/src/lsp/providers/browser-fs-provider.ts +85 -0
- package/src/lsp/providers/hybrid-fs-provider.ts +134 -0
- package/src/rest/export.ts +118 -0
- package/src/rest/routes.ts +117 -0
- package/src/rest/server.ts +2517 -0
- package/src/rest/template.ts +276 -0
- package/src/rest/validation.ts +555 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Copyright (c) 2026 Modelware. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
extractLeadingFrontMatter,
|
|
8
|
+
parseTemplateComposeDirective,
|
|
9
|
+
parseTemplateDefinition,
|
|
10
|
+
renderTemplate,
|
|
11
|
+
type TemplateBindingSource,
|
|
12
|
+
type TemplateDefinition,
|
|
13
|
+
type TemplateInvocation,
|
|
14
|
+
type TemplateValue,
|
|
15
|
+
} from '@oml/markdown';
|
|
16
|
+
|
|
17
|
+
export type TemplateCatalogEntry = {
|
|
18
|
+
sourcePath: string;
|
|
19
|
+
definition: TemplateDefinition;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type TemplateRenderContext = {
|
|
23
|
+
workspaceRoot: string;
|
|
24
|
+
sourceMarkdownPath: string;
|
|
25
|
+
contextOntologyIri?: string;
|
|
26
|
+
contextModelUri?: string;
|
|
27
|
+
contextMemberIri?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function isTemplateMarkdownFile(content: string): boolean {
|
|
31
|
+
const frontMatter = extractLeadingFrontMatter(content);
|
|
32
|
+
if (!frontMatter?.data || typeof frontMatter.data !== 'object' || Array.isArray(frontMatter.data)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return Object.prototype.hasOwnProperty.call(frontMatter.data, 'template');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function frontMatterString(data: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
39
|
+
if (!data) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const value = data[key];
|
|
43
|
+
return typeof value === 'string' ? value : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeContextOntologyIri(value: string | undefined): string | undefined {
|
|
47
|
+
if (!value) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const trimmed = value.trim();
|
|
51
|
+
if (!trimmed) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return trimmed.replace(/^<|>$/g, '').replace(/[\/#]+$/, '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function findFilesByExtension(root: string, extension: string): Promise<string[]> {
|
|
58
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
59
|
+
const files: string[] = [];
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const fullPath = path.join(root, entry.name);
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
files.push(...await findFilesByExtension(fullPath, extension));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (entry.isFile() && fullPath.toLowerCase().endsWith(extension.toLowerCase())) {
|
|
70
|
+
files.push(fullPath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return files;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function buildTemplateCatalog(workspaceRoot: string): Promise<ReadonlyMap<string, TemplateCatalogEntry[]>> {
|
|
77
|
+
const byId = new Map<string, TemplateCatalogEntry[]>();
|
|
78
|
+
const markdownFiles = await findFilesByExtension(workspaceRoot, '.md');
|
|
79
|
+
for (const filePath of markdownFiles) {
|
|
80
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
81
|
+
const sourceUri = pathToFileURL(filePath).toString();
|
|
82
|
+
const definition = parseTemplateDefinition(content, sourceUri);
|
|
83
|
+
if (!definition) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
appendTemplateEntry(byId, filePath, definition);
|
|
87
|
+
}
|
|
88
|
+
for (const [id, entries] of byId.entries()) {
|
|
89
|
+
entries.sort((left, right) => {
|
|
90
|
+
const leftRank = Number.isFinite(left.definition.rank) ? Number(left.definition.rank) : 0;
|
|
91
|
+
const rightRank = Number.isFinite(right.definition.rank) ? Number(right.definition.rank) : 0;
|
|
92
|
+
if (leftRank !== rightRank) {
|
|
93
|
+
return rightRank - leftRank;
|
|
94
|
+
}
|
|
95
|
+
return left.sourcePath.localeCompare(right.sourcePath);
|
|
96
|
+
});
|
|
97
|
+
byId.set(id, entries);
|
|
98
|
+
}
|
|
99
|
+
return byId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function appendTemplateEntry(
|
|
103
|
+
index: Map<string, TemplateCatalogEntry[]>,
|
|
104
|
+
sourcePath: string,
|
|
105
|
+
definition: TemplateDefinition,
|
|
106
|
+
): void {
|
|
107
|
+
const id = normalizeTemplateId(definition.id);
|
|
108
|
+
if (!id) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const existing = index.get(id) ?? [];
|
|
112
|
+
existing.push({ sourcePath, definition: { ...definition, id } });
|
|
113
|
+
index.set(id, existing);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function expandTemplateComposeBlocks(
|
|
117
|
+
markdown: string,
|
|
118
|
+
templateCatalog: ReadonlyMap<string, TemplateCatalogEntry[]>,
|
|
119
|
+
context: TemplateRenderContext,
|
|
120
|
+
): Promise<string> {
|
|
121
|
+
const maxExpansionPasses = 12;
|
|
122
|
+
let current = markdown;
|
|
123
|
+
for (let pass = 0; pass < maxExpansionPasses; pass++) {
|
|
124
|
+
const composeBlockPattern = /```template[^\n]*\r?\n([\s\S]*?)\r?\n```/g;
|
|
125
|
+
let replaced = '';
|
|
126
|
+
let lastIndex = 0;
|
|
127
|
+
let expandedAny = false;
|
|
128
|
+
let match: RegExpExecArray | null;
|
|
129
|
+
while ((match = composeBlockPattern.exec(current)) !== null) {
|
|
130
|
+
expandedAny = true;
|
|
131
|
+
const blockStart = match.index;
|
|
132
|
+
const blockEnd = match.index + match[0].length;
|
|
133
|
+
replaced += current.slice(lastIndex, blockStart);
|
|
134
|
+
const directiveSource = match[1] ?? '';
|
|
135
|
+
const directive = parseTemplateComposeDirective(directiveSource);
|
|
136
|
+
if (!directive) {
|
|
137
|
+
throw new Error(`Invalid template compose block in ${context.sourceMarkdownPath}.`);
|
|
138
|
+
}
|
|
139
|
+
const template = selectTemplateDefinition(templateCatalog, directive.id);
|
|
140
|
+
if (!template) {
|
|
141
|
+
throw new Error(`No markdown template found for id '${directive.id}' in ${context.sourceMarkdownPath}.`);
|
|
142
|
+
}
|
|
143
|
+
const invocationArgs: Record<string, TemplateValue> = { ...directive.args };
|
|
144
|
+
for (const [parameterId, source] of Object.entries(directive.bind)) {
|
|
145
|
+
const value = resolveTemplateBindingValue(source, context);
|
|
146
|
+
if (value !== undefined) {
|
|
147
|
+
invocationArgs[parameterId] = value;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const selection = context.contextMemberIri ? [context.contextMemberIri.trim()] : [];
|
|
151
|
+
const invocation: TemplateInvocation = {
|
|
152
|
+
templateId: template.definition.id,
|
|
153
|
+
mode: 'compose',
|
|
154
|
+
context: {
|
|
155
|
+
invocation: {
|
|
156
|
+
mode: 'compose',
|
|
157
|
+
sourceDocumentUri: pathToFileURL(context.sourceMarkdownPath).toString(),
|
|
158
|
+
referenceDocumentUri: pathToFileURL(context.sourceMarkdownPath).toString(),
|
|
159
|
+
},
|
|
160
|
+
model: {
|
|
161
|
+
ontologyIri: context.contextOntologyIri,
|
|
162
|
+
modelUri: context.contextModelUri,
|
|
163
|
+
},
|
|
164
|
+
focus: {
|
|
165
|
+
memberIri: context.contextMemberIri,
|
|
166
|
+
},
|
|
167
|
+
selection: {
|
|
168
|
+
iris: selection,
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
args: invocationArgs,
|
|
172
|
+
};
|
|
173
|
+
const rendered = renderTemplate(template.definition, invocation);
|
|
174
|
+
if (rendered.missingRequired.length > 0) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Template '${template.definition.id}' is missing required parameter(s): ${rendered.missingRequired.join(', ')}.`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
replaced += absolutizeTemplateHrefValues(
|
|
180
|
+
rendered.output,
|
|
181
|
+
template.sourcePath,
|
|
182
|
+
context.sourceMarkdownPath,
|
|
183
|
+
context.workspaceRoot
|
|
184
|
+
);
|
|
185
|
+
lastIndex = blockEnd;
|
|
186
|
+
}
|
|
187
|
+
if (!expandedAny) {
|
|
188
|
+
return current;
|
|
189
|
+
}
|
|
190
|
+
replaced += current.slice(lastIndex);
|
|
191
|
+
current = replaced;
|
|
192
|
+
}
|
|
193
|
+
throw new Error(`Template compose expansion exceeded ${maxExpansionPasses} passes in ${context.sourceMarkdownPath}.`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function selectTemplateDefinition(
|
|
197
|
+
templateCatalog: ReadonlyMap<string, TemplateCatalogEntry[]>,
|
|
198
|
+
templateId: string,
|
|
199
|
+
): TemplateCatalogEntry | undefined {
|
|
200
|
+
const normalized = normalizeTemplateId(templateId);
|
|
201
|
+
if (!normalized) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
const candidates = templateCatalog.get(normalized);
|
|
205
|
+
return candidates?.[0];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveTemplateBindingValue(
|
|
209
|
+
source: TemplateBindingSource,
|
|
210
|
+
context: TemplateRenderContext,
|
|
211
|
+
): TemplateValue | undefined {
|
|
212
|
+
const member = context.contextMemberIri?.trim();
|
|
213
|
+
const ontology = context.contextOntologyIri?.trim();
|
|
214
|
+
const modelUri = context.contextModelUri?.trim();
|
|
215
|
+
const selection = member ? [member] : [];
|
|
216
|
+
if (source === 'context.member') {
|
|
217
|
+
return member || undefined;
|
|
218
|
+
}
|
|
219
|
+
if (source === 'context.ontology') {
|
|
220
|
+
return ontology || undefined;
|
|
221
|
+
}
|
|
222
|
+
if (source === 'context.modelUri') {
|
|
223
|
+
return modelUri || undefined;
|
|
224
|
+
}
|
|
225
|
+
if (source === 'context.selection[*]') {
|
|
226
|
+
return selection;
|
|
227
|
+
}
|
|
228
|
+
if (source === 'user') {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
const indexedSelection = /^context\.selection\[(\d+)]$/.exec(source);
|
|
232
|
+
if (!indexedSelection) {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
const index = Number.parseInt(indexedSelection[1] ?? '', 10);
|
|
236
|
+
if (!Number.isFinite(index) || index < 0 || index >= selection.length) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
return selection[index];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizeTemplateId(value: string): string {
|
|
243
|
+
let normalized = value.trim();
|
|
244
|
+
const wikilinkMatch = /^\[\[([^\]]+)\]\]$/.exec(normalized);
|
|
245
|
+
if (wikilinkMatch) {
|
|
246
|
+
normalized = (wikilinkMatch[1] ?? '').trim();
|
|
247
|
+
}
|
|
248
|
+
return normalized.replace(/^<|>$/g, '');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function absolutizeTemplateHrefValues(
|
|
252
|
+
markdown: string,
|
|
253
|
+
templateSourcePath: string,
|
|
254
|
+
referenceMarkdownPath: string,
|
|
255
|
+
workspaceRoot: string,
|
|
256
|
+
): string {
|
|
257
|
+
const templateDir = path.dirname(templateSourcePath);
|
|
258
|
+
return markdown.replace(/(\bhref\s*:\s*)(["']?)([^"'\r\n]+)\2/g, (_m, prefix: string, quote: string, raw: string) => {
|
|
259
|
+
const value = raw.trim();
|
|
260
|
+
if (!value || value.startsWith('#') || value.startsWith('data:') || value.startsWith('workspace:/')) {
|
|
261
|
+
return `${prefix}${quote}${value}${quote}`;
|
|
262
|
+
}
|
|
263
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
|
|
264
|
+
return `${prefix}${quote}${value}${quote}`;
|
|
265
|
+
}
|
|
266
|
+
const resolved = path.resolve(templateDir, value);
|
|
267
|
+
const relative = path.relative(workspaceRoot, resolved);
|
|
268
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
269
|
+
return `${prefix}${quote}${value}${quote}`;
|
|
270
|
+
}
|
|
271
|
+
const referenceDir = path.dirname(referenceMarkdownPath);
|
|
272
|
+
const fromReference = path.relative(referenceDir, resolved).split(path.sep).join('/');
|
|
273
|
+
const normalized = fromReference.startsWith('.') ? fromReference : `./${fromReference}`;
|
|
274
|
+
return `${prefix}${quote}${normalized}${quote}`;
|
|
275
|
+
});
|
|
276
|
+
}
|