@oml/markdown 0.16.0 → 0.16.2

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.
@@ -1,7 +1,26 @@
1
1
  // Copyright (c) 2026 Modelware. All rights reserved.
2
2
 
3
3
  import { createMarkdownRendererRegistry } from '../renderers/registry.js';
4
- import { setIriLabelSnapshot } from '../renderers/renderer.js';
4
+ import { setIriLabelSnapshot, displayLabelFromIri } from '../renderers/renderer.js';
5
+
6
+ // Replace [[iri]] tokens in an HTML string with wikilink anchors, skipping content inside tags.
7
+ function expandWikilinksInHtml(html: string): string {
8
+ return html.replace(/(<[^>]*>)|\[\[([^\]]+)\]\]/g, (match, tag: string | undefined, iri: string | undefined) => {
9
+ if (tag !== undefined) {
10
+ // Only pass through real HTML tags: <tagname, </tagname, <!-- , <!doctype, <?.
11
+ // Tag names are letters/digits/hyphens only — no colons or slashes.
12
+ // <http://...> fails because ':' immediately follows the tag name letters.
13
+ if (/^<[!?]/.test(tag) || /^<\/?[a-zA-Z][a-zA-Z0-9-]*(?:[\s>\/]|$)/.test(tag)) return tag;
14
+ return tag.replace(/</g, '&lt;').replace(/>/g, '&gt;');
15
+ }
16
+ const trimmed = (iri ?? '').trim();
17
+ if (!trimmed) return match;
18
+ const label = displayLabelFromIri(trimmed);
19
+ const safeIri = trimmed.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
20
+ const safeLabel = label.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
21
+ return `<a class="wikilink" iri="${safeIri}" href="#" title="${safeIri}">${safeLabel}</a>`;
22
+ });
23
+ }
5
24
 
6
25
  const SUPPORTED = new Set(['table', 'tree', 'graph', 'chart', 'diagram', 'list', 'text', 'matrix', 'table-editor']);
7
26
 
@@ -626,9 +645,9 @@ function getScriptSparqlCache(): Record<string, Record<string, QueryResult>> {
626
645
  return parseJsonNode<Record<string, Record<string, QueryResult>>>('oml-md-script-sparql-cache', {});
627
646
  }
628
647
 
629
- const PYTHON_PANEL_CACHE_STORAGE_KEY = 'oml-md-python-panel-cache-v1';
630
- const R_PANEL_CACHE_STORAGE_KEY = 'oml-md-r-panel-cache-v1';
631
- const JS_PANEL_CACHE_STORAGE_KEY = 'oml-md-js-panel-cache-v1';
648
+ const PYTHON_PANEL_CACHE_STORAGE_KEY = 'oml-md-python-panel-cache-v2';
649
+ const R_PANEL_CACHE_STORAGE_KEY = 'oml-md-r-panel-cache-v2';
650
+ const JS_PANEL_CACHE_STORAGE_KEY = 'oml-md-js-panel-cache-v2';
632
651
  const MAX_PYTHON_PANEL_CACHE_ENTRIES = 64;
633
652
  const MAX_R_PANEL_CACHE_ENTRIES = 64;
634
653
  const MAX_JS_PANEL_CACHE_ENTRIES = 64;
@@ -737,7 +756,7 @@ function rememberJsPanelCache(key: string, html: string): void {
737
756
 
738
757
  function buildPythonPanelCacheKey(blockId: string, source: string, blockCache: Record<string, QueryResult>): string {
739
758
  const page = `${window.location.origin}${window.location.pathname}${window.location.search}`;
740
- return `python|${page}|${blockId}|${hash32(source)}|${hash32(JSON.stringify(blockCache))}`;
759
+ return `python-v2|${page}|${blockId}|${hash32(source)}|${hash32(JSON.stringify(blockCache))}`;
741
760
  }
742
761
 
743
762
  function buildRPanelCacheKey(blockId: string, source: string, blockCache: Record<string, QueryResult>): string {
@@ -841,7 +860,7 @@ async function executeJsCode(
841
860
  const el = document.createElement('div');
842
861
  el.className = 'oml-md-js-html-output';
843
862
  el.style.color = window.getComputedStyle(document.body).color;
844
- el.innerHTML = html;
863
+ el.innerHTML = expandWikilinksInHtml(html);
845
864
  container.appendChild(el);
846
865
  };
847
866
  const appendText = (text: string): void => {
@@ -852,13 +871,26 @@ async function executeJsCode(
852
871
  container.appendChild(pre);
853
872
  };
854
873
  const display = (content: unknown): void => {
855
- if (typeof content === 'string') { appendHtml(content); } else { appendText(String(content)); }
874
+ if (typeof content === 'string') {
875
+ appendHtml(content);
876
+ } else if (content instanceof Node) {
877
+ clearStatus();
878
+ const el = document.createElement('div');
879
+ el.className = 'oml-md-js-html-output';
880
+ el.style.color = window.getComputedStyle(document.body).color;
881
+ el.appendChild(content);
882
+ container.appendChild(el);
883
+ } else {
884
+ appendText(String(content));
885
+ }
856
886
  };
857
887
  const query = async (sparql: string): Promise<QueryResult> => {
858
888
  return blockCache[sparql] ?? { success: false, columns: [], rows: [], error: 'SPARQL not pre-fetched for this query' };
859
889
  };
860
890
  const table = (result: QueryResult): string => sparqlResultToHtmlTable(result);
861
891
  const load = async (url: string): Promise<void> => loadScript(url);
892
+ const update = async (_ops: unknown): Promise<{ errors?: unknown[] }> => ({});
893
+ const interactive = (html: string, _ops: unknown): string => html;
862
894
 
863
895
  const savedLog = console.log;
864
896
  const savedWarn = console.warn;
@@ -874,8 +906,8 @@ async function executeJsCode(
874
906
  };
875
907
  try {
876
908
  const AsyncFunction = Object.getPrototypeOf(async function () { /* */ }).constructor as new (...a: string[]) => (...a: unknown[]) => Promise<void>;
877
- const fn = new AsyncFunction('display', 'query', 'table', 'load', code);
878
- await fn(display, query, table, load);
909
+ const fn = new AsyncFunction('display', 'query', 'table', 'load', 'update', 'interactive', code);
910
+ await fn(display, query, table, load, update, interactive);
879
911
  clearStatus();
880
912
  return {};
881
913
  } catch (error) {
@@ -914,6 +946,9 @@ async function applyJsBlocks(): Promise<void> {
914
946
  const panel = makeScriptPanel();
915
947
  pre.replaceWith(panel);
916
948
  const result = await executeJsCode(source, blockCache, panel);
949
+ if (!panel.isConnected) {
950
+ continue;
951
+ }
917
952
  appendResult(panel, result);
918
953
  if (!result.error) {
919
954
  rememberJsPanelCache(cacheKey, panel.innerHTML);
@@ -1065,6 +1100,20 @@ def table(data):
1065
1100
  for r in rows
1066
1101
  )
1067
1102
  display(f'<div class="oml-md-table-wrapper"><table class="oml-md-table"><thead><tr>{hdr}</tr></thead><tbody>{bdy}</tbody></table></div>')
1103
+
1104
+ async def update(_operations):
1105
+ return {}
1106
+
1107
+ def interactive(html, _operations):
1108
+ return str(html)
1109
+
1110
+ async def js(code, **kwargs):
1111
+ code = code.replace('{{', '\x01').replace('}}', '\x02')
1112
+ for k, v in kwargs.items():
1113
+ code = code.replace(f'{{{k}}}', str(v))
1114
+ code = code.replace('\x01', '{').replace('\x02', '}')
1115
+ result = await _js_eval(code)
1116
+ return result.to_py() if hasattr(result, 'to_py') else result
1068
1117
  `;
1069
1118
 
1070
1119
  let pyodideExecQueue: Promise<unknown> = Promise.resolve();
@@ -1101,7 +1150,7 @@ async function _executePyCode(
1101
1150
  const el = document.createElement('div');
1102
1151
  el.className = 'oml-md-js-html-output';
1103
1152
  el.style.color = window.getComputedStyle(document.body).color;
1104
- el.innerHTML = html;
1153
+ el.innerHTML = expandWikilinksInHtml(html);
1105
1154
  container.appendChild(el);
1106
1155
  };
1107
1156
  const appendText = (text: string): void => {
@@ -1128,6 +1177,12 @@ async function _executePyCode(
1128
1177
  pyodide.globals.set('_js_display', appendHtml);
1129
1178
  pyodide.globals.set('_js_query', (sparql: string) =>
1130
1179
  blockCache[sparql] ?? { success: false, columns: [], rows: [], error: 'SPARQL not pre-fetched' });
1180
+ pyodide.globals.set('_js_update', async (_opsJson: string) => ({}));
1181
+ pyodide.globals.set('_js_eval', async (code: string) => {
1182
+ // eslint-disable-next-line no-new-func
1183
+ const AsyncFunction = Object.getPrototypeOf(async function () { /* */ }).constructor as new (...a: string[]) => () => Promise<unknown>;
1184
+ return new AsyncFunction(code)();
1185
+ });
1131
1186
  pyodide.setStdout({ batched: appendText });
1132
1187
  pyodide.setStderr({ batched: () => { /* suppress library noise */ } });
1133
1188
  try {
@@ -1158,6 +1213,9 @@ async function applyPyBlocks(): Promise<void> {
1158
1213
  const panel = makeScriptPanel();
1159
1214
  pre.replaceWith(panel);
1160
1215
  const result = await executePyCode(source, blockCache, panel);
1216
+ if (!panel.isConnected) {
1217
+ continue;
1218
+ }
1161
1219
  appendResult(panel, result);
1162
1220
  if (!result.error) {
1163
1221
  rememberPythonPanelCache(cacheKey, panel.innerHTML);
@@ -1221,6 +1279,18 @@ load <- function(pkg) {
1221
1279
  if (!requireNamespace(pkg, quietly=TRUE)) { webr::install(pkg) }
1222
1280
  library(pkg, character.only=TRUE)
1223
1281
  }
1282
+ interactive <- function(html, operations) as.character(html)
1283
+ js <- function(code, ...) {
1284
+ args <- list(...)
1285
+ code <- gsub('{{', '\x01', code, fixed=TRUE)
1286
+ code <- gsub('}}', '\x02', code, fixed=TRUE)
1287
+ for (nm in names(args)) {
1288
+ code <- gsub(paste0('{', nm, '}'), as.character(args[[nm]]), code, fixed=TRUE)
1289
+ }
1290
+ code <- gsub('\x01', '{', code, fixed=TRUE)
1291
+ code <- gsub('\x02', '}', code, fixed=TRUE)
1292
+ webr::eval_js(paste0('(async()=>{\n', code, '\n})()'))
1293
+ }
1224
1294
  `;
1225
1295
 
1226
1296
  function buildRDataFrameCode(result: QueryResult, varName: string): string {
@@ -1298,7 +1368,7 @@ async function executeRCode(
1298
1368
  const el = document.createElement('div');
1299
1369
  el.className = 'oml-md-js-html-output';
1300
1370
  el.style.color = window.getComputedStyle(document.body).color;
1301
- el.innerHTML = html;
1371
+ el.innerHTML = expandWikilinksInHtml(html);
1302
1372
  container.appendChild(el);
1303
1373
  };
1304
1374
  const appendText = (text: string): void => {
@@ -1367,6 +1437,9 @@ async function applyRBlocks(): Promise<void> {
1367
1437
  const panel = makeScriptPanel();
1368
1438
  pre.replaceWith(panel);
1369
1439
  const result = await executeRCode(source, blockCache, panel);
1440
+ if (!panel.isConnected) {
1441
+ continue;
1442
+ }
1370
1443
  appendResult(panel, result);
1371
1444
  if (!result.error) {
1372
1445
  rememberRPanelCache(cacheKey, panel.innerHTML);
@@ -8,8 +8,13 @@ export function bindTemplateParameters(
8
8
  explicitArgs: Readonly<Record<string, TemplateValue>> = {}
9
9
  ): TemplateBindResult {
10
10
  const values: Record<string, TemplateValue> = {};
11
- const missingRequired: string[] = [];
12
11
  const parameters = template.parameters ?? [];
12
+ const declaredIds = new Set(parameters.map((p) => p.id));
13
+ for (const argId of Object.keys(explicitArgs)) {
14
+ if (!declaredIds.has(argId)) {
15
+ throw new Error(`Unknown template parameter: ${argId}`);
16
+ }
17
+ }
13
18
  for (const parameter of parameters) {
14
19
  const explicit = explicitArgs[parameter.id];
15
20
  if (explicit !== undefined) {
@@ -22,12 +27,11 @@ export function bindTemplateParameters(
22
27
  continue;
23
28
  }
24
29
  if (parameter.required) {
25
- missingRequired.push(parameter.id);
26
- continue;
30
+ throw new Error(`Missing required template parameter: ${parameter.id}`);
27
31
  }
28
32
  values[parameter.id] = naturalDefaultValue(parameter.type);
29
33
  }
30
- return { values, missingRequired };
34
+ return { values };
31
35
  }
32
36
 
33
37
  function resolveTemplateDefaultValue(
@@ -92,8 +96,9 @@ function resolveContextToken(token: string, context: TemplateInvocationContext):
92
96
  function coerceParameterValue(parameter: TemplateParameterDefinition, value: TemplateValue): TemplateValue {
93
97
  switch (parameter.type) {
94
98
  case 'string':
95
- case 'iri':
96
99
  return coerceString(parameter, value);
100
+ case 'iri':
101
+ return coerceIri(parameter, value);
97
102
  case 'number':
98
103
  return coerceNumber(parameter, value);
99
104
  case 'boolean':
@@ -115,6 +120,18 @@ function coerceString(parameter: TemplateParameterDefinition, value: TemplateVal
115
120
  throw new Error(`Template parameter '${parameter.id}' must be a string.`);
116
121
  }
117
122
 
123
+ function coerceIri(parameter: TemplateParameterDefinition, value: TemplateValue): string {
124
+ if (typeof value !== 'string') {
125
+ throw new Error(`Template parameter '${parameter.id}' must be a valid IRI.`);
126
+ }
127
+ try {
128
+ new URL(value);
129
+ } catch {
130
+ throw new Error(`Template parameter '${parameter.id}' must be a valid IRI, got: ${value}`);
131
+ }
132
+ return value;
133
+ }
134
+
118
135
  function coerceNumber(parameter: TemplateParameterDefinition, value: TemplateValue): number {
119
136
  if (typeof value === 'number' && Number.isFinite(value)) {
120
137
  return value;
@@ -144,18 +161,30 @@ function coerceBoolean(parameter: TemplateParameterDefinition, value: TemplateVa
144
161
  throw new Error(`Template parameter '${parameter.id}' must be a boolean.`);
145
162
  }
146
163
 
164
+ function validateIriArrayEntries(parameter: TemplateParameterDefinition, entries: string[]): string[] {
165
+ for (const entry of entries) {
166
+ try {
167
+ new URL(entry);
168
+ } catch {
169
+ throw new Error(`Template parameter '${parameter.id}' contains an invalid IRI: ${entry}`);
170
+ }
171
+ }
172
+ return entries;
173
+ }
174
+
147
175
  function coerceIriArray(parameter: TemplateParameterDefinition, value: TemplateValue): string[] {
148
176
  if (Array.isArray(value) && value.every((entry) => typeof entry === 'string')) {
149
- return value;
177
+ return validateIriArrayEntries(parameter, value as string[]);
150
178
  }
151
179
  if (typeof value === 'string') {
152
180
  const normalized = value.trim();
153
181
  if (!normalized) {
154
182
  return [];
155
183
  }
156
- return normalized.split(',').map((entry) => entry.trim()).filter((entry) => entry.length > 0);
184
+ const entries = normalized.split(',').map((entry) => entry.trim()).filter((entry) => entry.length > 0);
185
+ return validateIriArrayEntries(parameter, entries);
157
186
  }
158
- throw new Error(`Template parameter '${parameter.id}' must be an array of strings.`);
187
+ throw new Error(`Template parameter '${parameter.id}' must be an array of IRIs.`);
159
188
  }
160
189
 
161
190
  function coerceJson(parameter: TemplateParameterDefinition, value: TemplateValue): TemplateValue {
@@ -6,7 +6,6 @@ import type { TemplateDefinition, TemplateInvocation, TemplateValue } from './ty
6
6
  export interface TemplateRenderResult {
7
7
  output: string;
8
8
  values: Record<string, TemplateValue>;
9
- missingRequired: string[];
10
9
  }
11
10
 
12
11
  export function renderTemplate(
@@ -18,7 +17,6 @@ export function renderTemplate(
18
17
  return {
19
18
  output,
20
19
  values: bound.values,
21
- missingRequired: bound.missingRequired
22
20
  };
23
21
  }
24
22
 
@@ -26,13 +24,14 @@ export function interpolateTemplateBody(
26
24
  source: string,
27
25
  values: Readonly<Record<string, TemplateValue>>
28
26
  ): string {
29
- return source.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match: string, key: string): string => {
27
+ return source.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?:\|([^|}]*)(?:\|([^}]*))?)?\}/g, (_match: string, key: string, delimiter: string | undefined, entryTemplate: string | undefined): string => {
30
28
  if (!(key in values)) {
31
29
  return _match;
32
30
  }
33
31
  const value = values[key];
34
32
  if (Array.isArray(value)) {
35
- return value.map((entry) => String(entry)).join(', ');
33
+ const format = (entry: string) => entryTemplate ? entryTemplate.replace('%s', entry) : entry;
34
+ return value.map((entry) => format(String(entry))).join(delimiter ?? ', ');
36
35
  }
37
36
  if (value === null || value === undefined) {
38
37
  return '';
@@ -97,5 +97,4 @@ export interface TemplateResolutionResult {
97
97
 
98
98
  export interface TemplateBindResult {
99
99
  values: Record<string, TemplateValue>;
100
- missingRequired: string[];
101
100
  }