@oml/cli 0.7.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 +75 -0
- package/bin/cli.js +6 -0
- package/out/auth.d.ts +25 -0
- package/out/auth.js +253 -0
- package/out/auth.js.map +1 -0
- package/out/backend/backend-types.d.ts +19 -0
- package/out/backend/backend-types.js +3 -0
- package/out/backend/backend-types.js.map +1 -0
- package/out/backend/create-backend.d.ts +2 -0
- package/out/backend/create-backend.js +6 -0
- package/out/backend/create-backend.js.map +1 -0
- package/out/backend/direct-backend.d.ts +17 -0
- package/out/backend/direct-backend.js +97 -0
- package/out/backend/direct-backend.js.map +1 -0
- package/out/backend/reasoned-output.d.ts +38 -0
- package/out/backend/reasoned-output.js +568 -0
- package/out/backend/reasoned-output.js.map +1 -0
- package/out/cli.d.ts +1 -0
- package/out/cli.js +132 -0
- package/out/cli.js.map +1 -0
- package/out/commands/closure.d.ts +33 -0
- package/out/commands/closure.js +537 -0
- package/out/commands/closure.js.map +1 -0
- package/out/commands/compile.d.ts +11 -0
- package/out/commands/compile.js +63 -0
- package/out/commands/compile.js.map +1 -0
- package/out/commands/lint.d.ts +5 -0
- package/out/commands/lint.js +31 -0
- package/out/commands/lint.js.map +1 -0
- package/out/commands/reason.d.ts +13 -0
- package/out/commands/reason.js +62 -0
- package/out/commands/reason.js.map +1 -0
- package/out/commands/render.d.ts +15 -0
- package/out/commands/render.js +753 -0
- package/out/commands/render.js.map +1 -0
- package/out/commands/validate.d.ts +5 -0
- package/out/commands/validate.js +186 -0
- package/out/commands/validate.js.map +1 -0
- package/out/main.d.ts +1 -0
- package/out/main.js +4 -0
- package/out/main.js.map +1 -0
- package/out/update.d.ts +1 -0
- package/out/update.js +79 -0
- package/out/update.js.map +1 -0
- package/out/util.d.ts +10 -0
- package/out/util.js +63 -0
- package/out/util.js.map +1 -0
- package/package.json +36 -0
- package/src/auth.ts +315 -0
- package/src/backend/backend-types.ts +25 -0
- package/src/backend/create-backend.ts +8 -0
- package/src/backend/direct-backend.ts +114 -0
- package/src/backend/reasoned-output.ts +697 -0
- package/src/cli.ts +147 -0
- package/src/commands/closure.ts +624 -0
- package/src/commands/compile.ts +88 -0
- package/src/commands/lint.ts +35 -0
- package/src/commands/reason.ts +79 -0
- package/src/commands/render.ts +1021 -0
- package/src/commands/validate.ts +226 -0
- package/src/main.ts +5 -0
- package/src/update.ts +103 -0
- package/src/util.ts +83 -0
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
// Copyright (c) 2026 Modelware. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import * as url from 'node:url';
|
|
5
|
+
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import {
|
|
10
|
+
MarkdownHandlerRegistry,
|
|
11
|
+
MarkdownPreviewRuntime,
|
|
12
|
+
extractLeadingFrontMatter,
|
|
13
|
+
stripLeadingFrontMatter,
|
|
14
|
+
type MdBlockExecutionResult,
|
|
15
|
+
type MdBlockKind,
|
|
16
|
+
type MdExecutableBlock
|
|
17
|
+
} from '@oml/markdown';
|
|
18
|
+
import { STATIC_MARKDOWN_RUNTIME_BUNDLE_FILE, STATIC_MARKDOWN_RUNTIME_CSS } from '@oml/markdown/static';
|
|
19
|
+
import { normalizeFormatExtension } from '../backend/reasoned-output.js';
|
|
20
|
+
import { createBackend } from '../backend/create-backend.js';
|
|
21
|
+
import type { CliBackend } from '../backend/backend-types.js';
|
|
22
|
+
import { formatDuration } from '../util.js';
|
|
23
|
+
import type { ReasonOptions } from './reason.js';
|
|
24
|
+
import { reasonAction } from './reason.js';
|
|
25
|
+
import { resolveCompileOutputRoot, resolveCompileWorkspaceRoot } from './compile.js';
|
|
26
|
+
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
|
|
27
|
+
const markdownRuntime = new MarkdownPreviewRuntime(new MarkdownHandlerRegistry());
|
|
28
|
+
const SUPPORTED_MD_BLOCK_KINDS = new Set<MdBlockKind>([
|
|
29
|
+
'table',
|
|
30
|
+
'tree',
|
|
31
|
+
'graph',
|
|
32
|
+
'chart',
|
|
33
|
+
'diagram',
|
|
34
|
+
'list',
|
|
35
|
+
'text',
|
|
36
|
+
'matrix',
|
|
37
|
+
'table-editor'
|
|
38
|
+
]);
|
|
39
|
+
const LINK_ATTRIBUTE_KEYS = new Set(['href', 'src', 'xlinkHref', 'xlink:href']);
|
|
40
|
+
const RENDER_PROFILE = process.env.OML_RENDER_PROFILE === '1';
|
|
41
|
+
|
|
42
|
+
export type RenderOptions = {
|
|
43
|
+
workspace?: string,
|
|
44
|
+
md: string,
|
|
45
|
+
web: string,
|
|
46
|
+
owl?: string,
|
|
47
|
+
format?: string,
|
|
48
|
+
context?: string,
|
|
49
|
+
clean?: boolean,
|
|
50
|
+
only?: boolean,
|
|
51
|
+
pretty?: boolean,
|
|
52
|
+
uniqueNamesAssumption?: boolean,
|
|
53
|
+
explanations?: boolean,
|
|
54
|
+
profile?: boolean
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const renderAction = async (
|
|
58
|
+
opts: RenderOptions
|
|
59
|
+
): Promise<void> => {
|
|
60
|
+
const workspaceRoot = resolveCompileWorkspaceRoot(opts.workspace);
|
|
61
|
+
const owlOutputRoot = resolveCompileOutputRoot(workspaceRoot, opts.owl);
|
|
62
|
+
const rdfFormat = normalizeFormatExtension(opts.format);
|
|
63
|
+
if (!opts.only) {
|
|
64
|
+
const reasonOpts: ReasonOptions = {
|
|
65
|
+
workspace: workspaceRoot,
|
|
66
|
+
owl: owlOutputRoot,
|
|
67
|
+
format: rdfFormat,
|
|
68
|
+
clean: opts.clean,
|
|
69
|
+
pretty: opts.pretty,
|
|
70
|
+
only: false,
|
|
71
|
+
checkOnly: false,
|
|
72
|
+
uniqueNamesAssumption: opts.uniqueNamesAssumption,
|
|
73
|
+
explanations: opts.explanations,
|
|
74
|
+
profile: opts.profile,
|
|
75
|
+
};
|
|
76
|
+
await reasonAction(reasonOpts);
|
|
77
|
+
}
|
|
78
|
+
const backend = createBackend({
|
|
79
|
+
owlOutputRoot,
|
|
80
|
+
rdfFormat,
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
const renderStartedAt = Date.now();
|
|
84
|
+
const markdownRoot = path.resolve(workspaceRoot, opts.md);
|
|
85
|
+
const output = path.resolve(opts.web);
|
|
86
|
+
const workspaceStat = await fs.stat(workspaceRoot).catch(() => undefined);
|
|
87
|
+
if (!workspaceStat || !workspaceStat.isDirectory()) {
|
|
88
|
+
console.error(chalk.red(`Workspace folder does not exist: ${workspaceRoot}`));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
const markdownStat = await fs.stat(markdownRoot).catch(() => undefined);
|
|
92
|
+
if (!markdownStat || !markdownStat.isDirectory()) {
|
|
93
|
+
console.error(chalk.red(`Markdown folder does not exist: ${markdownRoot}`));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (isSameOrDescendant(markdownRoot, output)) {
|
|
98
|
+
console.error(chalk.red('Web output folder cannot be inside the markdown folder.'));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (opts.clean) {
|
|
103
|
+
await fs.rm(output, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const renderableMarkdownFiles = await discoverRenderableMarkdownFiles(markdownRoot, output);
|
|
107
|
+
const discoveredWorkspaceMarkdownFiles = await discoverMarkdownFiles(workspaceRoot, output);
|
|
108
|
+
const templateFiles = await discoverTemplateMarkdownFiles(discoveredWorkspaceMarkdownFiles);
|
|
109
|
+
const templateCatalog = buildTemplateCatalog(templateFiles);
|
|
110
|
+
const explicitContextModelUri = resolveModelUriOption(opts.context, workspaceRoot);
|
|
111
|
+
const templateGenerationEnabled = explicitContextModelUri !== undefined;
|
|
112
|
+
const wikiPageIndex = buildWikiPageIndex(renderableMarkdownFiles, markdownRoot, output);
|
|
113
|
+
const generatedInstancePages = new Set<string>();
|
|
114
|
+
const instancePageByIri = new Map<string, string>();
|
|
115
|
+
const attemptedInstanceIris = new Set<string>();
|
|
116
|
+
const workspaceAssetFiles = new Set<string>();
|
|
117
|
+
const staticAssets = await writeStaticAssets(output);
|
|
118
|
+
let blockArtifactFiles = 0;
|
|
119
|
+
for (const file of renderableMarkdownFiles) {
|
|
120
|
+
const relative = path.relative(markdownRoot, file);
|
|
121
|
+
const outputFile = path.join(output, relative.replace(/\.md$/i, '.html'));
|
|
122
|
+
const rendered = await renderMarkdownFile(
|
|
123
|
+
backend,
|
|
124
|
+
workspaceRoot,
|
|
125
|
+
markdownRoot,
|
|
126
|
+
output,
|
|
127
|
+
file,
|
|
128
|
+
outputFile,
|
|
129
|
+
staticAssets.runtimeScriptFile,
|
|
130
|
+
staticAssets.stylesheetFile,
|
|
131
|
+
templateGenerationEnabled,
|
|
132
|
+
explicitContextModelUri,
|
|
133
|
+
wikiPageIndex,
|
|
134
|
+
templateCatalog,
|
|
135
|
+
generatedInstancePages,
|
|
136
|
+
instancePageByIri
|
|
137
|
+
,
|
|
138
|
+
attemptedInstanceIris
|
|
139
|
+
);
|
|
140
|
+
const referencedWorkspaceAssets = rendered.workspaceAssets;
|
|
141
|
+
for (const asset of referencedWorkspaceAssets) {
|
|
142
|
+
workspaceAssetFiles.add(asset);
|
|
143
|
+
}
|
|
144
|
+
blockArtifactFiles += rendered.blockArtifactFiles;
|
|
145
|
+
}
|
|
146
|
+
for (const file of workspaceAssetFiles) {
|
|
147
|
+
const workspaceRelative = path.relative(workspaceRoot, file);
|
|
148
|
+
if (workspaceRelative.startsWith('..') || path.isAbsolute(workspaceRelative)) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (workspaceRelative.toLowerCase().endsWith('.md')) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const markdownRelative = path.relative(markdownRoot, file);
|
|
155
|
+
const isInsideMarkdownRoot = !markdownRelative.startsWith('..') && !path.isAbsolute(markdownRelative);
|
|
156
|
+
const outputRelative = isInsideMarkdownRoot ? markdownRelative : workspaceRelative;
|
|
157
|
+
const target = path.join(output, outputRelative);
|
|
158
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
159
|
+
await fs.copyFile(file, target);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (renderableMarkdownFiles.length === 0) {
|
|
163
|
+
console.log(chalk.yellow(`No markdown files found under ${markdownRoot}.`));
|
|
164
|
+
}
|
|
165
|
+
console.log(chalk.green(`render: ${renderableMarkdownFiles.length} markdown file(s) rendered in ${path.relative(process.cwd(), output) || output} [${formatDuration(Date.now() - renderStartedAt)}]`));
|
|
166
|
+
logRenderTiming('render.total', renderStartedAt, `${renderableMarkdownFiles.length} markdown file(s)`);
|
|
167
|
+
} finally {
|
|
168
|
+
await backend.dispose();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
function isSameOrDescendant(parent: string, candidate: string): boolean {
|
|
173
|
+
const relative = path.relative(parent, candidate);
|
|
174
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type TemplateFile = {
|
|
178
|
+
filePath: string;
|
|
179
|
+
typeIri: string;
|
|
180
|
+
priority: number;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
type TemplateCatalog = {
|
|
184
|
+
byType: Map<string, TemplateFile[]>;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
async function discoverTemplateMarkdownFiles(markdownFiles: ReadonlyArray<string>): Promise<TemplateFile[]> {
|
|
188
|
+
const templateFiles: TemplateFile[] = [];
|
|
189
|
+
for (const filePath of markdownFiles) {
|
|
190
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
191
|
+
const frontMatter = extractLeadingFrontMatter(content);
|
|
192
|
+
const rawType = frontMatter?.data?.type;
|
|
193
|
+
if (typeof rawType === 'string' && rawType.trim().length > 0) {
|
|
194
|
+
const priorityValue = frontMatter?.data?.priority;
|
|
195
|
+
const priority = typeof priorityValue === 'number'
|
|
196
|
+
? priorityValue
|
|
197
|
+
: Number.parseInt(String(priorityValue ?? '0'), 10);
|
|
198
|
+
templateFiles.push({
|
|
199
|
+
filePath,
|
|
200
|
+
typeIri: normalizeTemplateTypeIri(rawType),
|
|
201
|
+
priority: Number.isFinite(priority) ? priority : 0
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return templateFiles;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function normalizeTemplateTypeIri(value: string): string {
|
|
209
|
+
return value.trim().replace(/^<|>$/g, '');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildTemplateCatalog(templateFiles: ReadonlyArray<TemplateFile>): TemplateCatalog {
|
|
213
|
+
const byType = new Map<string, TemplateFile[]>();
|
|
214
|
+
for (const template of templateFiles) {
|
|
215
|
+
const list = byType.get(template.typeIri) ?? [];
|
|
216
|
+
list.push(template);
|
|
217
|
+
byType.set(template.typeIri, list);
|
|
218
|
+
}
|
|
219
|
+
for (const list of byType.values()) {
|
|
220
|
+
list.sort((left, right) => (left.priority - right.priority) || left.filePath.localeCompare(right.filePath));
|
|
221
|
+
}
|
|
222
|
+
return { byType };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function resolveModelUriOption(value: string | undefined, workspaceRoot: string): string | undefined {
|
|
226
|
+
if (!value) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const trimmed = value.trim();
|
|
230
|
+
if (!trimmed) {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
if (trimmed.startsWith('workspace:/')) {
|
|
234
|
+
const resolved = resolveWorkspacePath(workspaceRoot, trimmed);
|
|
235
|
+
return resolved ? url.pathToFileURL(resolved).toString() : undefined;
|
|
236
|
+
}
|
|
237
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
|
|
238
|
+
return trimmed;
|
|
239
|
+
}
|
|
240
|
+
const absolute = path.isAbsolute(trimmed)
|
|
241
|
+
? trimmed
|
|
242
|
+
: path.resolve(workspaceRoot, trimmed);
|
|
243
|
+
return url.pathToFileURL(absolute).toString();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function discoverRenderableMarkdownFiles(root: string, outputRoot: string): Promise<string[]> {
|
|
247
|
+
const markdownFiles = await discoverMarkdownFiles(root, outputRoot);
|
|
248
|
+
const renderableFiles: string[] = [];
|
|
249
|
+
for (const filePath of markdownFiles) {
|
|
250
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
251
|
+
const frontMatter = extractLeadingFrontMatter(content);
|
|
252
|
+
const rawType = frontMatter?.data?.type;
|
|
253
|
+
if (typeof rawType === 'string' && rawType.trim().length > 0) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
renderableFiles.push(filePath);
|
|
257
|
+
}
|
|
258
|
+
return renderableFiles;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function discoverMarkdownFiles(root: string, outputRoot: string): Promise<string[]> {
|
|
262
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
263
|
+
const markdownFiles: string[] = [];
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
const fullPath = path.join(root, entry.name);
|
|
266
|
+
if (entry.isDirectory()) {
|
|
267
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (isSameOrDescendant(outputRoot, fullPath)) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
markdownFiles.push(...await discoverMarkdownFiles(fullPath, outputRoot));
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (!entry.isFile()) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (entry.name.toLowerCase().endsWith('.md')) {
|
|
280
|
+
markdownFiles.push(fullPath);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return markdownFiles;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function renderMarkdownFile(
|
|
287
|
+
backend: CliBackend,
|
|
288
|
+
workspaceRoot: string,
|
|
289
|
+
inputRoot: string,
|
|
290
|
+
outputRoot: string,
|
|
291
|
+
inputFile: string,
|
|
292
|
+
outputFile: string,
|
|
293
|
+
runtimeScriptFile: string,
|
|
294
|
+
stylesheetFile: string,
|
|
295
|
+
templateGenerationEnabled: boolean,
|
|
296
|
+
defaultContextModelUri: string | undefined,
|
|
297
|
+
wikiPageIndex: ReadonlyMap<string, string>,
|
|
298
|
+
templateCatalog: TemplateCatalog,
|
|
299
|
+
generatedInstancePages: Set<string>,
|
|
300
|
+
instancePageByIri: Map<string, string>,
|
|
301
|
+
attemptedInstanceIris: Set<string>,
|
|
302
|
+
currentPageStack: Set<string> = new Set<string>(),
|
|
303
|
+
explicitMarkdown?: string,
|
|
304
|
+
forcedModelUri?: string
|
|
305
|
+
): Promise<{ workspaceAssets: Set<string>; blockArtifactFiles: number }> {
|
|
306
|
+
const pageStartedAt = Date.now();
|
|
307
|
+
const markdown = explicitMarkdown ?? await fs.readFile(inputFile, 'utf-8');
|
|
308
|
+
const prepared = markdownRuntime.prepare(markdown);
|
|
309
|
+
const rewriteResult = rewriteRenderedLinks(prepared.renderedHtml, {
|
|
310
|
+
workspaceRoot,
|
|
311
|
+
inputRoot,
|
|
312
|
+
inputFile,
|
|
313
|
+
outputRoot,
|
|
314
|
+
outputFile,
|
|
315
|
+
});
|
|
316
|
+
const executableBlocks = toExecutableBlocks(prepared.codeBlocks);
|
|
317
|
+
const optionsByBlockId = new Map(prepared.codeBlocks.map((block) => [block.id, block.options] as const));
|
|
318
|
+
const modelUri = forcedModelUri ?? resolveContextModelUri(inputFile, prepared.contextUri, workspaceRoot) ?? defaultContextModelUri;
|
|
319
|
+
const blockResults = executableBlocks.length === 0
|
|
320
|
+
? []
|
|
321
|
+
: (await backend.executeMarkdownBlocks({
|
|
322
|
+
documentUri: url.pathToFileURL(path.resolve(inputFile)).toString(),
|
|
323
|
+
modelUri,
|
|
324
|
+
blocks: executableBlocks
|
|
325
|
+
}, workspaceRoot)).results;
|
|
326
|
+
logRenderTiming('render.page.blocks', pageStartedAt, path.relative(workspaceRoot, inputFile));
|
|
327
|
+
const renderedBlockResults: RenderedBlockResult[] = blockResults.map((result) => ({
|
|
328
|
+
...result,
|
|
329
|
+
options: optionsByBlockId.get(result.blockId)
|
|
330
|
+
}));
|
|
331
|
+
const blockRewriteResult = rewriteBlockResultLinks(renderedBlockResults, {
|
|
332
|
+
workspaceRoot,
|
|
333
|
+
inputRoot,
|
|
334
|
+
inputFile,
|
|
335
|
+
outputRoot,
|
|
336
|
+
outputFile,
|
|
337
|
+
});
|
|
338
|
+
const blockArtifacts = await writeBlockArtifacts(outputFile, blockRewriteResult.results);
|
|
339
|
+
const pageWikilinkIris = collectWikilinkIrisFromHtml(rewriteResult.html);
|
|
340
|
+
const iriAliasHrefByIri = templateGenerationEnabled && modelUri
|
|
341
|
+
? await generateInstanceTemplatePages(
|
|
342
|
+
backend,
|
|
343
|
+
workspaceRoot,
|
|
344
|
+
inputRoot,
|
|
345
|
+
outputRoot,
|
|
346
|
+
outputFile,
|
|
347
|
+
runtimeScriptFile,
|
|
348
|
+
stylesheetFile,
|
|
349
|
+
templateGenerationEnabled,
|
|
350
|
+
modelUri,
|
|
351
|
+
pageWikilinkIris,
|
|
352
|
+
renderedBlockResults,
|
|
353
|
+
wikiPageIndex,
|
|
354
|
+
templateCatalog,
|
|
355
|
+
generatedInstancePages,
|
|
356
|
+
instancePageByIri,
|
|
357
|
+
attemptedInstanceIris,
|
|
358
|
+
currentPageStack
|
|
359
|
+
)
|
|
360
|
+
: {};
|
|
361
|
+
logRenderTiming('render.page.templates', pageStartedAt, path.relative(workspaceRoot, inputFile));
|
|
362
|
+
const runtimeScriptRelative = toRelativeWebPath(path.dirname(outputFile), runtimeScriptFile);
|
|
363
|
+
const stylesheetRelative = toRelativeWebPath(path.dirname(outputFile), stylesheetFile);
|
|
364
|
+
const wikiLinkHrefByKey = buildWikiLinkHrefMapForPage(wikiPageIndex, outputFile);
|
|
365
|
+
const resolvedInstanceLinks = Object.fromEntries(
|
|
366
|
+
[...instancePageByIri.entries()].map(([iri, absolute]) => [iri, toRelativeWebPath(path.dirname(outputFile), absolute)])
|
|
367
|
+
);
|
|
368
|
+
const html = wrapHtml(
|
|
369
|
+
rewriteResult.html,
|
|
370
|
+
runtimeScriptRelative,
|
|
371
|
+
stylesheetRelative,
|
|
372
|
+
blockArtifacts.manifest,
|
|
373
|
+
blockRewriteResult.results,
|
|
374
|
+
wikiLinkHrefByKey,
|
|
375
|
+
{ ...resolvedInstanceLinks, ...iriAliasHrefByIri },
|
|
376
|
+
templateGenerationEnabled && Boolean(modelUri)
|
|
377
|
+
);
|
|
378
|
+
await fs.mkdir(path.dirname(outputFile), { recursive: true });
|
|
379
|
+
await fs.writeFile(outputFile, html, 'utf-8');
|
|
380
|
+
const workspaceAssets = new Set<string>(rewriteResult.workspaceAssets);
|
|
381
|
+
for (const asset of blockRewriteResult.workspaceAssets) {
|
|
382
|
+
workspaceAssets.add(asset);
|
|
383
|
+
}
|
|
384
|
+
logRenderTiming('render.page.total', pageStartedAt, path.relative(workspaceRoot, inputFile));
|
|
385
|
+
return { workspaceAssets, blockArtifactFiles: blockArtifacts.count };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
type RenderedBlockResult = MdBlockExecutionResult & {
|
|
389
|
+
options?: Record<string, unknown>;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
function toExecutableBlocks(codeBlocks: ReadonlyArray<{
|
|
393
|
+
id: string;
|
|
394
|
+
language: string;
|
|
395
|
+
content: string;
|
|
396
|
+
meta?: string;
|
|
397
|
+
options?: Record<string, unknown>;
|
|
398
|
+
lineStart: number;
|
|
399
|
+
lineEnd: number;
|
|
400
|
+
}>): MdExecutableBlock[] {
|
|
401
|
+
const executable: MdExecutableBlock[] = [];
|
|
402
|
+
for (const block of codeBlocks) {
|
|
403
|
+
const kind = toMdBlockKind(block.language);
|
|
404
|
+
if (!kind) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
executable.push({
|
|
408
|
+
id: block.id,
|
|
409
|
+
kind,
|
|
410
|
+
source: block.content,
|
|
411
|
+
meta: block.meta,
|
|
412
|
+
options: block.options,
|
|
413
|
+
lineStart: block.lineStart,
|
|
414
|
+
lineEnd: block.lineEnd
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return executable;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function toMdBlockKind(language: string): MdBlockKind | undefined {
|
|
421
|
+
return SUPPORTED_MD_BLOCK_KINDS.has(language as MdBlockKind) ? (language as MdBlockKind) : undefined;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function resolveContextModelUri(documentFile: string, contextUri: string | undefined, workspaceRoot: string): string | undefined {
|
|
425
|
+
if (!contextUri) {
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
const trimmed = contextUri.trim();
|
|
429
|
+
if (!trimmed) {
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
if (trimmed.startsWith('workspace:/')) {
|
|
433
|
+
const resolved = resolveWorkspacePath(workspaceRoot, trimmed);
|
|
434
|
+
return resolved ? url.pathToFileURL(resolved).toString() : undefined;
|
|
435
|
+
}
|
|
436
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
|
|
437
|
+
return trimmed;
|
|
438
|
+
}
|
|
439
|
+
if (trimmed.startsWith('/')) {
|
|
440
|
+
return url.pathToFileURL(path.resolve(workspaceRoot, trimmed.replace(/^\/+/, ''))).toString();
|
|
441
|
+
}
|
|
442
|
+
return url.pathToFileURL(path.resolve(path.dirname(documentFile), trimmed)).toString();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function writeStaticAssets(outputRoot: string): Promise<{
|
|
446
|
+
runtimeScriptFile: string;
|
|
447
|
+
stylesheetFile: string;
|
|
448
|
+
}> {
|
|
449
|
+
const runtimeBundle = await loadStaticRuntimeBundle();
|
|
450
|
+
const sourceStylesheet = await loadCodeBlockStylesheet();
|
|
451
|
+
const mergedStylesheet = `${sourceStylesheet}\n\n${STATIC_MARKDOWN_RUNTIME_CSS}\n`;
|
|
452
|
+
const runtimeVersion = createHash('sha1').update(runtimeBundle).digest('hex').slice(0, 12);
|
|
453
|
+
const stylesheetVersion = createHash('sha1').update(mergedStylesheet).digest('hex').slice(0, 12);
|
|
454
|
+
const runtimeFile = path.join(outputRoot, '_oml', `markdown-static-${runtimeVersion}.js`);
|
|
455
|
+
const stylesheetFile = path.join(outputRoot, '_oml', `markdown-webview-${stylesheetVersion}.css`);
|
|
456
|
+
await fs.mkdir(path.dirname(runtimeFile), { recursive: true });
|
|
457
|
+
await fs.writeFile(runtimeFile, runtimeBundle, 'utf-8');
|
|
458
|
+
await fs.writeFile(stylesheetFile, mergedStylesheet, 'utf-8');
|
|
459
|
+
return { runtimeScriptFile: runtimeFile, stylesheetFile };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function loadStaticRuntimeBundle(): Promise<string> {
|
|
463
|
+
const require = createRequire(import.meta.url);
|
|
464
|
+
const staticEntry = require.resolve('@oml/markdown/static');
|
|
465
|
+
const bundlePath = path.join(path.dirname(staticEntry), STATIC_MARKDOWN_RUNTIME_BUNDLE_FILE);
|
|
466
|
+
try {
|
|
467
|
+
return await fs.readFile(bundlePath, 'utf-8');
|
|
468
|
+
} catch {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`Unable to load markdown static runtime bundle at '${bundlePath}'. `
|
|
471
|
+
+ 'Run the markdown package build to generate it.'
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function loadCodeBlockStylesheet(): Promise<string> {
|
|
477
|
+
const candidates = [
|
|
478
|
+
path.resolve(__dirname, '..', '..', '..', 'extension', 'src', 'webview', 'markdown-webview.css'),
|
|
479
|
+
path.resolve(__dirname, '..', '..', '..', 'extension', 'out', 'webview', 'markdown-webview.css'),
|
|
480
|
+
];
|
|
481
|
+
for (const candidate of candidates) {
|
|
482
|
+
try {
|
|
483
|
+
return await fs.readFile(candidate, 'utf-8');
|
|
484
|
+
} catch {
|
|
485
|
+
// Try next candidate.
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return '';
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function writeBlockArtifacts(outputFile: string, results: ReadonlyArray<RenderedBlockResult>): Promise<{
|
|
492
|
+
count: number;
|
|
493
|
+
manifest: Array<{ blockId: string; path: string }>;
|
|
494
|
+
}> {
|
|
495
|
+
if (results.length === 0) {
|
|
496
|
+
return { count: 0, manifest: [] };
|
|
497
|
+
}
|
|
498
|
+
const blockDirName = `${path.basename(outputFile, '.html')}.blocks`;
|
|
499
|
+
const blockDir = path.join(path.dirname(outputFile), blockDirName);
|
|
500
|
+
await fs.mkdir(blockDir, { recursive: true });
|
|
501
|
+
const manifest: Array<{ blockId: string; path: string }> = [];
|
|
502
|
+
for (const result of results) {
|
|
503
|
+
const safeId = sanitizeBlockId(result.blockId);
|
|
504
|
+
const fileName = `${safeId}.json`;
|
|
505
|
+
const filePath = path.join(blockDir, fileName);
|
|
506
|
+
await fs.writeFile(filePath, JSON.stringify(result, null, 2), 'utf-8');
|
|
507
|
+
manifest.push({
|
|
508
|
+
blockId: result.blockId,
|
|
509
|
+
path: `./${blockDirName}/${fileName}`
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
return { count: results.length, manifest };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function rewriteBlockResultLinks(
|
|
516
|
+
results: ReadonlyArray<RenderedBlockResult>,
|
|
517
|
+
context: {
|
|
518
|
+
workspaceRoot: string;
|
|
519
|
+
inputRoot: string;
|
|
520
|
+
inputFile: string;
|
|
521
|
+
outputRoot: string;
|
|
522
|
+
outputFile: string;
|
|
523
|
+
}
|
|
524
|
+
): {
|
|
525
|
+
results: RenderedBlockResult[];
|
|
526
|
+
workspaceAssets: Set<string>;
|
|
527
|
+
} {
|
|
528
|
+
const workspaceAssets = new Set<string>();
|
|
529
|
+
const rewrittenResults = results.map((result) => rewriteLinkValue(result, context, workspaceAssets));
|
|
530
|
+
return { results: rewrittenResults, workspaceAssets };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function rewriteLinkValue<T>(
|
|
534
|
+
value: T,
|
|
535
|
+
context: {
|
|
536
|
+
workspaceRoot: string;
|
|
537
|
+
inputRoot: string;
|
|
538
|
+
inputFile: string;
|
|
539
|
+
outputRoot: string;
|
|
540
|
+
outputFile: string;
|
|
541
|
+
},
|
|
542
|
+
workspaceAssets: Set<string>,
|
|
543
|
+
parentKey?: string
|
|
544
|
+
): T {
|
|
545
|
+
if (typeof value === 'string') {
|
|
546
|
+
if (parentKey && LINK_ATTRIBUTE_KEYS.has(parentKey)) {
|
|
547
|
+
return rewriteHref(value, context, workspaceAssets) as T;
|
|
548
|
+
}
|
|
549
|
+
return value;
|
|
550
|
+
}
|
|
551
|
+
if (Array.isArray(value)) {
|
|
552
|
+
return value.map((entry) => rewriteLinkValue(entry, context, workspaceAssets)) as T;
|
|
553
|
+
}
|
|
554
|
+
if (!value || typeof value !== 'object') {
|
|
555
|
+
return value;
|
|
556
|
+
}
|
|
557
|
+
const source = value as Record<string, unknown>;
|
|
558
|
+
const rewritten: Record<string, unknown> = {};
|
|
559
|
+
for (const [key, entry] of Object.entries(source)) {
|
|
560
|
+
rewritten[key] = rewriteLinkValue(entry, context, workspaceAssets, key);
|
|
561
|
+
}
|
|
562
|
+
return rewritten as T;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function sanitizeBlockId(value: string): string {
|
|
566
|
+
const trimmed = value.trim();
|
|
567
|
+
if (!trimmed) {
|
|
568
|
+
return 'block';
|
|
569
|
+
}
|
|
570
|
+
return trimmed.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function rewriteRenderedLinks(
|
|
574
|
+
content: string,
|
|
575
|
+
context: {
|
|
576
|
+
workspaceRoot: string;
|
|
577
|
+
inputRoot: string;
|
|
578
|
+
inputFile: string;
|
|
579
|
+
outputRoot: string;
|
|
580
|
+
outputFile: string;
|
|
581
|
+
}
|
|
582
|
+
): {
|
|
583
|
+
html: string;
|
|
584
|
+
workspaceAssets: Set<string>;
|
|
585
|
+
} {
|
|
586
|
+
const workspaceAssets = new Set<string>();
|
|
587
|
+
const html = content.replace(/(\b(?:href|src)\s*=\s*)(["'])([^"']+)\2/gi, (_match, prefix: string, quote: string, rawHref: string) => {
|
|
588
|
+
const rewritten = rewriteHref(rawHref, context, workspaceAssets);
|
|
589
|
+
return `${prefix}${quote}${rewritten}${quote}`;
|
|
590
|
+
});
|
|
591
|
+
return { html, workspaceAssets };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function rewriteHref(
|
|
595
|
+
href: string,
|
|
596
|
+
context: {
|
|
597
|
+
workspaceRoot: string;
|
|
598
|
+
inputRoot: string;
|
|
599
|
+
inputFile: string;
|
|
600
|
+
outputRoot: string;
|
|
601
|
+
outputFile: string;
|
|
602
|
+
},
|
|
603
|
+
workspaceAssets: Set<string>
|
|
604
|
+
): string {
|
|
605
|
+
const parts = splitHref(href);
|
|
606
|
+
if (!parts.path) {
|
|
607
|
+
return href;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const trimmedPath = parts.path.trim();
|
|
611
|
+
if (!trimmedPath || trimmedPath.startsWith('#') || trimmedPath.startsWith('//')) {
|
|
612
|
+
return href;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (trimmedPath.startsWith('workspace:/')) {
|
|
616
|
+
const resolved = resolveWorkspacePath(context.workspaceRoot, trimmedPath);
|
|
617
|
+
if (!resolved) {
|
|
618
|
+
return href;
|
|
619
|
+
}
|
|
620
|
+
if (!resolved.toLowerCase().endsWith('.md')) {
|
|
621
|
+
workspaceAssets.add(resolved);
|
|
622
|
+
}
|
|
623
|
+
const workspaceRelative = path.relative(context.workspaceRoot, resolved);
|
|
624
|
+
const inputRelative = path.relative(context.inputRoot, resolved);
|
|
625
|
+
const isInsideInput = !inputRelative.startsWith('..') && !path.isAbsolute(inputRelative);
|
|
626
|
+
const outputRelative = isInsideInput ? inputRelative : workspaceRelative;
|
|
627
|
+
const targetInOutput = path.join(
|
|
628
|
+
context.outputRoot,
|
|
629
|
+
resolved.toLowerCase().endsWith('.md')
|
|
630
|
+
? outputRelative.replace(/\.md$/i, '.html')
|
|
631
|
+
: outputRelative
|
|
632
|
+
);
|
|
633
|
+
const rewrittenPath = toRelativeWebPath(path.dirname(context.outputFile), targetInOutput);
|
|
634
|
+
return `${rewrittenPath}${parts.query}${parts.fragment}`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmedPath)) {
|
|
638
|
+
return href;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (!trimmedPath.startsWith('/')) {
|
|
642
|
+
const resolved = path.resolve(path.dirname(context.inputFile), trimmedPath);
|
|
643
|
+
const workspaceRelative = path.relative(context.workspaceRoot, resolved);
|
|
644
|
+
if (!workspaceRelative.startsWith('..') && !path.isAbsolute(workspaceRelative) && !resolved.toLowerCase().endsWith('.md')) {
|
|
645
|
+
workspaceAssets.add(resolved);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const rewrittenPath = trimmedPath.toLowerCase().endsWith('.md')
|
|
650
|
+
? trimmedPath.slice(0, -3) + '.html'
|
|
651
|
+
: trimmedPath;
|
|
652
|
+
return `${rewrittenPath}${parts.query}${parts.fragment}`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function resolveWorkspacePath(workspaceRoot: string, hrefPath: string): string | undefined {
|
|
656
|
+
const relative = hrefPath.slice('workspace:/'.length).replace(/^\/+/, '');
|
|
657
|
+
const resolved = path.resolve(workspaceRoot, relative);
|
|
658
|
+
const relativeToWorkspace = path.relative(workspaceRoot, resolved);
|
|
659
|
+
if (relativeToWorkspace.startsWith('..') || path.isAbsolute(relativeToWorkspace)) {
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
return resolved;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function splitHref(href: string): { path: string; query: string; fragment: string } {
|
|
666
|
+
const hashIndex = href.indexOf('#');
|
|
667
|
+
const pathAndQuery = hashIndex >= 0 ? href.slice(0, hashIndex) : href;
|
|
668
|
+
const fragment = hashIndex >= 0 ? href.slice(hashIndex) : '';
|
|
669
|
+
const queryIndex = pathAndQuery.indexOf('?');
|
|
670
|
+
return {
|
|
671
|
+
path: queryIndex >= 0 ? pathAndQuery.slice(0, queryIndex) : pathAndQuery,
|
|
672
|
+
query: queryIndex >= 0 ? pathAndQuery.slice(queryIndex) : '',
|
|
673
|
+
fragment
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function toRelativeWebPath(fromDir: string, toFile: string): string {
|
|
678
|
+
const relative = path.relative(fromDir, toFile).split(path.sep).join('/');
|
|
679
|
+
if (!relative || relative === '.') {
|
|
680
|
+
return '.';
|
|
681
|
+
}
|
|
682
|
+
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function normalizeWikiPathKey(raw: string): string {
|
|
686
|
+
const normalized = raw.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/');
|
|
687
|
+
if (!normalized) {
|
|
688
|
+
return '';
|
|
689
|
+
}
|
|
690
|
+
const withoutHtml = normalized.replace(/\.html$/i, '');
|
|
691
|
+
return withoutHtml.replace(/^\.\//, '').toLowerCase();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function buildWikiPageIndex(markdownFiles: ReadonlyArray<string>, inputRoot: string, outputRoot: string): Map<string, string> {
|
|
695
|
+
const index = new Map<string, string>();
|
|
696
|
+
for (const markdownFile of markdownFiles) {
|
|
697
|
+
const relativeInput = path.relative(inputRoot, markdownFile).split(path.sep).join('/');
|
|
698
|
+
const normalizedRelative = normalizeWikiPathKey(relativeInput.replace(/\.md$/i, ''));
|
|
699
|
+
if (!normalizedRelative) {
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
const outputFile = path.join(outputRoot, relativeInput.replace(/\.md$/i, '.html'));
|
|
703
|
+
index.set(normalizedRelative, outputFile);
|
|
704
|
+
}
|
|
705
|
+
return index;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function buildWikiLinkHrefMapForPage(
|
|
709
|
+
wikiPageIndex: ReadonlyMap<string, string>,
|
|
710
|
+
outputFile: string
|
|
711
|
+
): Record<string, string> {
|
|
712
|
+
const map: Record<string, string> = {};
|
|
713
|
+
for (const [key, targetFile] of wikiPageIndex.entries()) {
|
|
714
|
+
map[key] = toRelativeWebPath(path.dirname(outputFile), targetFile);
|
|
715
|
+
}
|
|
716
|
+
return map;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function collectFragmentIrisFromValue(value: unknown, collected: Set<string>): void {
|
|
720
|
+
if (typeof value === 'string') {
|
|
721
|
+
const trimmed = value.trim();
|
|
722
|
+
if (trimmed.length > 0) {
|
|
723
|
+
const iriPattern = /[a-z][a-z0-9+.-]*:[^\s<>"')\]]+/gi;
|
|
724
|
+
for (const match of trimmed.matchAll(iriPattern)) {
|
|
725
|
+
let iri = (match[0] ?? '').trim();
|
|
726
|
+
iri = iri.replace(/[.,;:!?]+$/, '');
|
|
727
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(iri)) {
|
|
728
|
+
collected.add(iri);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (Array.isArray(value)) {
|
|
735
|
+
for (const entry of value) {
|
|
736
|
+
collectFragmentIrisFromValue(entry, collected);
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (value && typeof value === 'object') {
|
|
741
|
+
for (const entry of Object.values(value)) {
|
|
742
|
+
collectFragmentIrisFromValue(entry, collected);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function collectWikilinkIrisFromResults(results: ReadonlyArray<RenderedBlockResult>): Set<string> {
|
|
748
|
+
const collected = new Set<string>();
|
|
749
|
+
for (const result of results) {
|
|
750
|
+
collectFragmentIrisFromValue(result, collected);
|
|
751
|
+
}
|
|
752
|
+
return collected;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function collectWikilinkIrisFromHtml(renderedHtml: string): Set<string> {
|
|
756
|
+
const collected = new Set<string>();
|
|
757
|
+
const pattern = /<a\b[^>]*\bclass=(["'])[^"']*\bwikilink\b[^"']*\1[^>]*\biri=(["'])([^"']+)\2/gi;
|
|
758
|
+
for (const match of renderedHtml.matchAll(pattern)) {
|
|
759
|
+
const iri = (match[3] ?? '').trim();
|
|
760
|
+
if (iri) {
|
|
761
|
+
collected.add(iri);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return collected;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function sanitizePathSegment(value: string): string {
|
|
768
|
+
const decoded = decodeURIComponent(value);
|
|
769
|
+
const trimmed = decoded.trim();
|
|
770
|
+
if (!trimmed) {
|
|
771
|
+
return 'instance';
|
|
772
|
+
}
|
|
773
|
+
return encodeURIComponent(trimmed);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function resolveInstanceOutputFile(outputRoot: string, iri: string): string | undefined {
|
|
777
|
+
let parsed: URL;
|
|
778
|
+
try {
|
|
779
|
+
parsed = new URL(iri);
|
|
780
|
+
} catch {
|
|
781
|
+
return undefined;
|
|
782
|
+
}
|
|
783
|
+
if (!parsed.hostname) {
|
|
784
|
+
return undefined;
|
|
785
|
+
}
|
|
786
|
+
const host = parsed.hostname.toLowerCase();
|
|
787
|
+
const pathSegments = parsed.pathname
|
|
788
|
+
.split('/')
|
|
789
|
+
.map((segment) => segment.trim())
|
|
790
|
+
.filter((segment) => segment.length > 0)
|
|
791
|
+
.map((segment) => sanitizePathSegment(segment));
|
|
792
|
+
const fragment = parsed.hash.replace(/^#/, '').trim();
|
|
793
|
+
const fileSegments = [host, ...pathSegments];
|
|
794
|
+
if (fragment.length > 0) {
|
|
795
|
+
fileSegments.push(sanitizePathSegment(fragment));
|
|
796
|
+
} else if (fileSegments.length === 1) {
|
|
797
|
+
fileSegments.push('index');
|
|
798
|
+
}
|
|
799
|
+
if (fileSegments.length === 0) {
|
|
800
|
+
return undefined;
|
|
801
|
+
}
|
|
802
|
+
return path.join(outputRoot, ...fileSegments) + '.html';
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function selectTemplateForTypes(catalog: TemplateCatalog, typeIris: ReadonlySet<string>): TemplateFile | undefined {
|
|
806
|
+
const candidates: TemplateFile[] = [];
|
|
807
|
+
for (const typeIri of typeIris) {
|
|
808
|
+
const templates = catalog.byType.get(typeIri);
|
|
809
|
+
if (!templates || templates.length === 0) {
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
candidates.push(...templates);
|
|
813
|
+
}
|
|
814
|
+
if (candidates.length === 0) {
|
|
815
|
+
return undefined;
|
|
816
|
+
}
|
|
817
|
+
candidates.sort((left, right) => (left.priority - right.priority) || left.filePath.localeCompare(right.filePath));
|
|
818
|
+
return candidates[0];
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function queryRdfTypesForIris(
|
|
822
|
+
backend: CliBackend,
|
|
823
|
+
workspaceRoot: string,
|
|
824
|
+
modelUri: string,
|
|
825
|
+
iris: ReadonlyArray<string>
|
|
826
|
+
): Promise<Map<string, Set<string>>> {
|
|
827
|
+
const startedAt = Date.now();
|
|
828
|
+
const byIri = new Map<string, Set<string>>();
|
|
829
|
+
if (iris.length === 0) {
|
|
830
|
+
return byIri;
|
|
831
|
+
}
|
|
832
|
+
const valuesClause = iris.map((iri) => `<${iri}>`).join(' ');
|
|
833
|
+
const query = `PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
|
834
|
+
SELECT ?s ?type
|
|
835
|
+
WHERE {
|
|
836
|
+
VALUES ?s { ${valuesClause} }
|
|
837
|
+
GRAPH ?g { ?s rdf:type ?type . }
|
|
838
|
+
}`;
|
|
839
|
+
const result = await backend.executeMarkdownBlocks({
|
|
840
|
+
documentUri: 'file:///__oml_cli_wikilink_types__.md',
|
|
841
|
+
modelUri,
|
|
842
|
+
blocks: [{
|
|
843
|
+
id: '__oml_cli_wikilink_types__',
|
|
844
|
+
kind: 'table',
|
|
845
|
+
source: query,
|
|
846
|
+
lineStart: 0,
|
|
847
|
+
lineEnd: 0,
|
|
848
|
+
}]
|
|
849
|
+
}, workspaceRoot);
|
|
850
|
+
const block = result.results[0];
|
|
851
|
+
if (!block || block.status !== 'ok' || block.format !== 'table' || !block.payload) {
|
|
852
|
+
return byIri;
|
|
853
|
+
}
|
|
854
|
+
const subjectIndex = block.payload.columns.findIndex((column) => column === 's');
|
|
855
|
+
const typeIndex = block.payload.columns.findIndex((column) => column === 'type');
|
|
856
|
+
const left = subjectIndex >= 0 ? subjectIndex : 0;
|
|
857
|
+
const right = typeIndex >= 0 ? typeIndex : 1;
|
|
858
|
+
for (const row of block.payload.rows) {
|
|
859
|
+
const subject = (row[left] ?? '').trim();
|
|
860
|
+
const typeIri = (row[right] ?? '').trim().replace(/^<|>$/g, '');
|
|
861
|
+
if (!subject || !typeIri) {
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
const types = byIri.get(subject) ?? new Set<string>();
|
|
865
|
+
types.add(typeIri);
|
|
866
|
+
byIri.set(subject, types);
|
|
867
|
+
}
|
|
868
|
+
logRenderTiming('render.types', startedAt, `${iris.length} iri(s)`);
|
|
869
|
+
return byIri;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function buildTemplatePageMarkdown(templateMarkdown: string, focusIri: string, queryContextOption: string): string {
|
|
873
|
+
const body = stripLeadingFrontMatter(templateMarkdown).split('${contextIri}').join(focusIri);
|
|
874
|
+
const escapedContext = JSON.stringify(queryContextOption);
|
|
875
|
+
return `---
|
|
876
|
+
contextUri: ${escapedContext}
|
|
877
|
+
---
|
|
878
|
+
${body}`;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function generateInstanceTemplatePages(
|
|
882
|
+
backend: CliBackend,
|
|
883
|
+
workspaceRoot: string,
|
|
884
|
+
inputRoot: string,
|
|
885
|
+
outputRoot: string,
|
|
886
|
+
pageOutputFile: string,
|
|
887
|
+
runtimeScriptFile: string,
|
|
888
|
+
stylesheetFile: string,
|
|
889
|
+
templateGenerationEnabled: boolean,
|
|
890
|
+
queryContextModelUri: string,
|
|
891
|
+
pageWikilinkIris: ReadonlySet<string>,
|
|
892
|
+
results: ReadonlyArray<RenderedBlockResult>,
|
|
893
|
+
wikiPageIndex: ReadonlyMap<string, string>,
|
|
894
|
+
templateCatalog: TemplateCatalog,
|
|
895
|
+
generatedInstancePages: Set<string>,
|
|
896
|
+
instancePageByIri: Map<string, string>,
|
|
897
|
+
attemptedInstanceIris: Set<string>,
|
|
898
|
+
currentPageStack: Set<string>
|
|
899
|
+
): Promise<Record<string, string>> {
|
|
900
|
+
const startedAt = Date.now();
|
|
901
|
+
const iriToHref: Record<string, string> = {};
|
|
902
|
+
if (!templateGenerationEnabled) {
|
|
903
|
+
return iriToHref;
|
|
904
|
+
}
|
|
905
|
+
const wikilinkIris = [...new Set<string>([
|
|
906
|
+
...pageWikilinkIris,
|
|
907
|
+
...collectWikilinkIrisFromResults(results),
|
|
908
|
+
])];
|
|
909
|
+
const unresolved = wikilinkIris.filter((iri) => !attemptedInstanceIris.has(iri));
|
|
910
|
+
for (const iri of unresolved) {
|
|
911
|
+
attemptedInstanceIris.add(iri);
|
|
912
|
+
}
|
|
913
|
+
const typesByIri = await queryRdfTypesForIris(backend, workspaceRoot, queryContextModelUri, unresolved);
|
|
914
|
+
for (const iri of unresolved) {
|
|
915
|
+
const types = typesByIri.get(iri) ?? new Set<string>();
|
|
916
|
+
const template = selectTemplateForTypes(templateCatalog, types);
|
|
917
|
+
if (!template) {
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
const instanceOutputFile = resolveInstanceOutputFile(outputRoot, iri);
|
|
921
|
+
if (!instanceOutputFile) {
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
instancePageByIri.set(iri, instanceOutputFile);
|
|
925
|
+
if (generatedInstancePages.has(instanceOutputFile) || currentPageStack.has(instanceOutputFile)) {
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
generatedInstancePages.add(instanceOutputFile);
|
|
929
|
+
const templateMarkdown = await fs.readFile(template.filePath, 'utf-8');
|
|
930
|
+
const instantiatedMarkdown = buildTemplatePageMarkdown(templateMarkdown, iri, queryContextModelUri);
|
|
931
|
+
const nextStack = new Set(currentPageStack);
|
|
932
|
+
nextStack.add(instanceOutputFile);
|
|
933
|
+
await renderMarkdownFile(
|
|
934
|
+
backend,
|
|
935
|
+
workspaceRoot,
|
|
936
|
+
inputRoot,
|
|
937
|
+
outputRoot,
|
|
938
|
+
template.filePath,
|
|
939
|
+
instanceOutputFile,
|
|
940
|
+
runtimeScriptFile,
|
|
941
|
+
stylesheetFile,
|
|
942
|
+
templateGenerationEnabled,
|
|
943
|
+
queryContextModelUri,
|
|
944
|
+
wikiPageIndex,
|
|
945
|
+
templateCatalog,
|
|
946
|
+
generatedInstancePages,
|
|
947
|
+
instancePageByIri,
|
|
948
|
+
attemptedInstanceIris,
|
|
949
|
+
nextStack,
|
|
950
|
+
instantiatedMarkdown,
|
|
951
|
+
queryContextModelUri
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
for (const iri of wikilinkIris) {
|
|
955
|
+
const absolute = instancePageByIri.get(iri);
|
|
956
|
+
if (!absolute) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
iriToHref[iri] = toRelativeWebPath(path.dirname(pageOutputFile), absolute);
|
|
960
|
+
}
|
|
961
|
+
logRenderTiming('render.wikilinks', startedAt, `${unresolved.length} unresolved iri(s)`);
|
|
962
|
+
return iriToHref;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function logRenderTiming(label: string, startedAt: number, detail: string): void {
|
|
966
|
+
if (!RENDER_PROFILE) {
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const elapsed = Date.now() - startedAt;
|
|
970
|
+
console.log(chalk.gray(`[render-profile] ${label} ${elapsed}ms ${detail}`));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function wrapHtml(
|
|
974
|
+
content: string,
|
|
975
|
+
runtimeScriptPath: string,
|
|
976
|
+
stylesheetPath: string,
|
|
977
|
+
blockManifest: Array<{ blockId: string; path: string }>,
|
|
978
|
+
blockResults: ReadonlyArray<RenderedBlockResult>,
|
|
979
|
+
wikiLinkHrefByKey: Record<string, string>,
|
|
980
|
+
iriAliasHrefByIri: Record<string, string>,
|
|
981
|
+
linkingEnabled: boolean
|
|
982
|
+
): string {
|
|
983
|
+
const escapedManifest = escapeJsonForScript(JSON.stringify(blockManifest));
|
|
984
|
+
const inlineResults = Object.fromEntries(blockResults.map((result) => [result.blockId, result]));
|
|
985
|
+
const escapedInlineResults = escapeJsonForScript(JSON.stringify(inlineResults));
|
|
986
|
+
const escapedWikiIndex = escapeJsonForScript(JSON.stringify(wikiLinkHrefByKey));
|
|
987
|
+
const escapedIriAliasIndex = escapeJsonForScript(JSON.stringify(iriAliasHrefByIri));
|
|
988
|
+
const escapedLinkingConfig = escapeJsonForScript(JSON.stringify({ linkingEnabled }));
|
|
989
|
+
return `<!doctype html>
|
|
990
|
+
<html lang="en">
|
|
991
|
+
<head>
|
|
992
|
+
<meta charset="UTF-8">
|
|
993
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
994
|
+
<title>OML Markdown</title>
|
|
995
|
+
<link rel="stylesheet" href="${escapeAttribute(stylesheetPath)}">
|
|
996
|
+
</head>
|
|
997
|
+
<body>
|
|
998
|
+
${content}
|
|
999
|
+
<script id="oml-md-block-manifest" type="application/json">${escapedManifest}</script>
|
|
1000
|
+
<script id="oml-md-block-inline-results" type="application/json">${escapedInlineResults}</script>
|
|
1001
|
+
<script id="oml-md-wikilink-index" type="application/json">${escapedWikiIndex}</script>
|
|
1002
|
+
<script id="oml-md-wikilink-iri-aliases" type="application/json">${escapedIriAliasIndex}</script>
|
|
1003
|
+
<script id="oml-md-wikilink-config" type="application/json">${escapedLinkingConfig}</script>
|
|
1004
|
+
<script src="${escapeAttribute(runtimeScriptPath)}"></script>
|
|
1005
|
+
</body>
|
|
1006
|
+
</html>
|
|
1007
|
+
`;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function escapeAttribute(value: string): string {
|
|
1011
|
+
return value
|
|
1012
|
+
.replace(/&/g, '&')
|
|
1013
|
+
.replace(/</g, '<')
|
|
1014
|
+
.replace(/>/g, '>')
|
|
1015
|
+
.replace(/"/g, '"')
|
|
1016
|
+
.replace(/'/g, ''');
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function escapeJsonForScript(value: string): string {
|
|
1020
|
+
return value.replace(/</g, '\\u003c');
|
|
1021
|
+
}
|