diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3d5f6e58..954695bb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -29,6 +29,12 @@ Chartifact consists of several interoperating modules: ### Testing - Currently we don't have much test coverage. We can add this later. +### Version Control +- **Do NOT check in UMD build artifacts**: Files matching `*.umd.js` in `docs/dist/v1/` are build outputs and should not be committed in PRs +- **Do NOT edit `docs/schema/idoc_v1.d.ts` directly**: This file is generated from `packages/schema-doc/src/`. Edit the source files in the schema-doc package instead +- **Do NOT check in built schema files**: Files `docs/schema/idoc_v1.d.ts` and `docs/schema/idoc_v1.json` are generated from `packages/schema-doc/src/` during the build process and should not be committed in PRs +- These files are generated during the build process and committing them creates unnecessary merge conflicts + ## Project-Specific Conventions 1. **File Formats**: diff --git a/packages/compiler/src/validate/common.ts b/packages/compiler/src/validate/common.ts index dceca5b8..c0ec5de8 100644 --- a/packages/compiler/src/validate/common.ts +++ b/packages/compiler/src/validate/common.ts @@ -5,7 +5,7 @@ import { validateTransforms } from './transforms.js'; const illegalChars = '/|\\\'"`,.;:~-=+?!@#$%^&*()[]{}<>'; -export const ignoredSignals = ['width', 'height', 'padding', 'autosize', 'background', 'style', 'parent', 'datum', 'item', 'event', 'cursor', 'origins']; +export const ignoredSignals = ['width', 'height', 'padding', 'autosize', 'background', 'style', 'parent', 'datum', 'item', 'event', 'cursor', 'value']; // Utility functions for property validation export function validateRequiredString(value: any, propertyName: string, elementType: string): string[] { diff --git a/packages/markdown/src/factory.ts b/packages/markdown/src/factory.ts index c435b73c..a2384b84 100644 --- a/packages/markdown/src/factory.ts +++ b/packages/markdown/src/factory.ts @@ -152,31 +152,41 @@ export function create() { if (directPlugin) { return directPlugin; } + + // Split info into words for further processing + const infoWords = info.split(/\s+/); + // Third priority: Check for plugin names with additional parameters (like "csv variableId") - else { - const infoWords = info.split(/\s+/); - if (infoWords.length > 0) { - const pluginPrefix = findPluginByPrefix(infoWords[0]); - if (pluginPrefix) { - return pluginPrefix; - } + if (infoWords.length > 0) { + const pluginPrefix = findPluginByPrefix(infoWords[0]); + if (pluginPrefix) { + return pluginPrefix; } } + // Fourth priority: Check if it starts with "json " and extract the plugin name - if (info.startsWith('json ')) { - const jsonPluginName = info.slice(5).trim(); - const jsonPlugin = findPlugin(jsonPluginName); + if (infoWords[0] === 'json' && infoWords.length > 1) { + const jsonPlugin = findPlugin(infoWords[1]); if (jsonPlugin) { return jsonPlugin; } + // Default to 'value' plugin for json data (e.g., "json products") + const valuePlugin = findPlugin('value'); + if (valuePlugin) { + return valuePlugin; + } } // Fifth priority: Check if it starts with "yaml " and extract the plugin name - else if (info.startsWith('yaml ')) { - const yamlPluginName = info.slice(5).trim(); - const yamlPlugin = findPlugin(yamlPluginName); + else if (info.startsWith('yaml ') && infoWords.length > 1) { + const yamlPlugin = findPlugin(infoWords[1]); if (yamlPlugin) { return yamlPlugin; } + // Default to 'value' plugin for yaml data (e.g., "yaml products") + const valuePlugin = findPlugin('value'); + if (valuePlugin) { + return valuePlugin; + } } } diff --git a/packages/markdown/src/plugins/config.ts b/packages/markdown/src/plugins/config.ts index 6aeefcca..dd8e9371 100644 --- a/packages/markdown/src/plugins/config.ts +++ b/packages/markdown/src/plugins/config.ts @@ -9,6 +9,131 @@ import { getJsonScriptTag } from "./util.js"; import { SpecReview } from 'common'; import * as yaml from 'js-yaml'; +/** + * Parse fence info and extract all metadata. + * @param info The fence info string + * @returns Object with format, pluginName, params (including variableId), and wasDefaultId flag + */ +export function parseFenceInfo(info: string): { + format: 'json' | 'yaml'; + pluginName: string; + params: Map; + wasDefaultId: boolean; +} { + const parts = info.trim().split(/\s+/); + + // Determine format (json is default) + let format: 'json' | 'yaml' = 'json'; + let startIndex = 0; + + if (parts[0] === 'json' || parts[0] === 'yaml') { + format = parts[0]; + startIndex = 1; + } + + // The next part could be the plugin name OR a parameter + let pluginName = ''; + let pluginNameIndex = startIndex; + + if (startIndex < parts.length && !parts[startIndex].includes(':')) { + // It's a plugin name (doesn't have a colon) + pluginName = parts[startIndex]; + pluginNameIndex = startIndex + 1; + } + + const params = new Map(); + let variableId: string | null = null; + + // Parse remaining parts starting after plugin name (if found) + for (let i = pluginNameIndex; i < parts.length; i++) { + const part = parts[i]; + const colonIndex = part.indexOf(':'); + + if (colonIndex > 0) { + // Parameter with colon + const key = part.slice(0, colonIndex); + const value = part.slice(colonIndex + 1); + + if (value) { + // Format: "key:value" + params.set(key, value); + } else if (i + 1 < parts.length) { + // Format: "key: value" (value in next part) + params.set(key, parts[++i]); + } + } else if (!variableId) { + // First non-parameter value becomes variableId + variableId = part; + } + } + + // If variableId param exists, use it; otherwise use the direct value + const explicitVariableId = params.get('variableId'); + const finalVariableId = explicitVariableId || variableId; + const wasDefaultId = !finalVariableId; + + if (finalVariableId) { + params.set('variableId', finalVariableId); + } + + return { format, pluginName, params, wasDefaultId }; +} + +/* +//Tests for parseFenceInfo +const tests: [string, { format: 'json' | 'yaml'; pluginName: string; variableId: string | undefined; wasDefaultId: boolean }][] = [ + //Direct format cases + ["dsv products", { format: "json", pluginName: "dsv", variableId: "products", wasDefaultId: false }], + ["csv officeSupplies", { format: "json", pluginName: "csv", variableId: "officeSupplies", wasDefaultId: false }], + + //Explicit format cases + ["json inventory", { format: "json", pluginName: "inventory", variableId: undefined, wasDefaultId: true }], + ["yaml products", { format: "yaml", pluginName: "products", variableId: undefined, wasDefaultId: true }], + + //Explicit plugin name + ["json value inventory", { format: "json", pluginName: "value", variableId: "inventory", wasDefaultId: false }], + ["yaml value products", { format: "yaml", pluginName: "value", variableId: "products", wasDefaultId: false }], + + //Direct plugin without format + ["value inventory", { format: "json", pluginName: "value", variableId: "inventory", wasDefaultId: false }], + + //Explicit variableId parameter (no space) + ["json variableId:inventory", { format: "json", pluginName: "", variableId: "inventory", wasDefaultId: false }], + ["dsv variableId:products", { format: "json", pluginName: "dsv", variableId: "products", wasDefaultId: false }], + ["json value variableId:inventory", { format: "json", pluginName: "value", variableId: "inventory", wasDefaultId: false }], + ["yaml value variableId:products", { format: "yaml", pluginName: "value", variableId: "products", wasDefaultId: false }], + + //Explicit variableId parameter (with space) + ["json variableId: inventory", { format: "json", pluginName: "", variableId: "inventory", wasDefaultId: false }], + ["dsv variableId: products", { format: "json", pluginName: "dsv", variableId: "products", wasDefaultId: false }], + ["json value variableId: inventory", { format: "json", pluginName: "value", variableId: "inventory", wasDefaultId: false }], + ["yaml value variableId: products", { format: "yaml", pluginName: "value", variableId: "products", wasDefaultId: false }], + + //Multiple parameters + ["dsv products delimiter:|", { format: "json", pluginName: "dsv", variableId: "products", wasDefaultId: false }], + ["dsv delimiter:| variableId:products", { format: "json", pluginName: "dsv", variableId: "products", wasDefaultId: false }], + ["dsv delimiter: | variableId: products", { format: "json", pluginName: "dsv", variableId: "products", wasDefaultId: false }], + + //No variableId + ["json value", { format: "json", pluginName: "value", variableId: undefined, wasDefaultId: true }], + ["dsv", { format: "json", pluginName: "dsv", variableId: undefined, wasDefaultId: true }], +]; + +tests.forEach(([input, expected], i) => { + const result = parseFenceInfo(input); + const variableId = result.params.get('variableId'); + const pass = + result.format === expected.format && + result.pluginName === expected.pluginName && + variableId === expected.variableId && + result.wasDefaultId === expected.wasDefaultId; + + console.log( + `${pass ? '✅' : '❌'} Test ${i + 1}: ${pass ? 'PASS' : `FAIL\n Input: "${input}"\n Got: ${JSON.stringify({format: result.format, pluginName: result.pluginName, variableId, wasDefaultId: result.wasDefaultId})}\n Expected: ${JSON.stringify(expected)}`}` + ); +}); +*/ + /** * Creates a plugin that can parse both JSON and YAML formats */ diff --git a/packages/markdown/src/plugins/dsv.ts b/packages/markdown/src/plugins/dsv.ts index a12a1e8d..561c25e6 100644 --- a/packages/markdown/src/plugins/dsv.ts +++ b/packages/markdown/src/plugins/dsv.ts @@ -9,6 +9,7 @@ import { sanitizedHTML, sanitizeHtmlComment } from '../sanitize.js'; import { pluginClassName } from './util.js'; import { PluginNames } from './interfaces.js'; import { SpecReview } from 'common'; +import { parseFenceInfo } from './config.js'; interface DsvInstance { id: string; @@ -24,65 +25,58 @@ export interface DsvSpec { } /** - * Utility function to parse variable ID from fence info. - * Supports both "pluginName variableId" and "pluginName variableId:name" formats. - * @param info The fence info string (e.g., "csv myData" or "csv variableId:myData") + * Utility function to parse DSV fence info. + * Supports formats like: + * - "dsv products delimiter:|" + * - "dsv delimiter:| variableId:products" + * - "dsv delimiter: | variableId: products" + * @param info The fence info string * @param pluginName The plugin name (csv, tsv, dsv) * @param index The fence index for default naming - * @returns Object with variableId and wasDefaultId flag + * @returns Object with variableId, delimiter, and flags */ -export function parseVariableId(info: string, pluginName: string, index: number): { variableId: string; wasDefaultId: boolean } { - const parts = info.trim().split(/\s+/); +export function parseDsvInfo(info: string, pluginName: string, index: number): { + variableId: string; + delimiter: string; + wasDefaultId: boolean; + wasDefaultDelimiter: boolean; +} { + const { params, wasDefaultId } = parseFenceInfo(info); - // Check for explicit variableId: parameter - for (const part of parts) { - if (part.startsWith('variableId:')) { - return { - variableId: part.slice(11).trim(), // Remove 'variableId:' prefix and trim spaces - wasDefaultId: false - }; - } - } + // Get variableId (already handled by parseFenceInfo) + const variableId = params.get('variableId') || `${pluginName}Data${index}`; - // Check for direct format (second parameter that's not a special parameter) - if (parts.length >= 2) { - const secondPart = parts[1]; - if (!secondPart.startsWith('delimiter:') && !secondPart.startsWith('variableId:')) { - return { - variableId: secondPart, - wasDefaultId: false - }; - } - } + // Get delimiter from parameter or use default + let delimiter = params.get('delimiter') || ','; + const wasDefaultDelimiter = !params.has('delimiter'); + + // Handle special delimiter characters + if (delimiter === '\\t') delimiter = '\t'; + if (delimiter === '\\n') delimiter = '\n'; + if (delimiter === '\\r') delimiter = '\r'; - // Default variable ID - return { - variableId: `${pluginName}Data${index}`, - wasDefaultId: true + return { + variableId, + delimiter, + wasDefaultId, + wasDefaultDelimiter }; } /** - * Utility function to parse delimiter from fence info. - * @param info The fence info string (e.g., "dsv delimiter:| variableId:myData") - * @returns Object with delimiter and wasDefaultDelimiter flag + * Utility function to parse variable ID from fence info. + * Used by CSV and TSV plugins. + * @param info The fence info string (e.g., "csv myData" or "csv variableId:myData") + * @param pluginName The plugin name (csv, tsv, dsv) + * @param index The fence index for default naming + * @returns Object with variableId and wasDefaultId flag */ -export function parseDelimiter(info: string): { delimiter: string; wasDefaultDelimiter: boolean } { - const parts = info.trim().split(/\s+/); - - for (const part of parts) { - if (part.startsWith('delimiter:')) { - let delimiter = part.slice(10).trim(); // Remove 'delimiter:' prefix and trim spaces - // Handle special cases - if (delimiter === '\\t') delimiter = '\t'; - if (delimiter === '\\n') delimiter = '\n'; - if (delimiter === '\\r') delimiter = '\r'; - return { delimiter, wasDefaultDelimiter: false }; - } - } - - // Default to comma - return { delimiter: ',', wasDefaultDelimiter: true }; +export function parseVariableId(info: string, pluginName: string, index: number): { variableId: string; wasDefaultId: boolean } { + const result = parseDsvInfo(info, pluginName, index); + return { + variableId: result.variableId, + wasDefaultId: result.wasDefaultId + }; } function inspectDsvSpec(spec: DsvSpec): RawFlaggableSpec { @@ -92,17 +86,13 @@ function inspectDsvSpec(spec: DsvSpec): RawFlaggableSpec { reasons: [] }; - // Flag if we had to use defaults + // Only flag if we had to use default variable ID + // Using default delimiter (comma) is fine and shouldn't be flagged if (spec.wasDefaultId) { result.hasFlags = true; result.reasons.push('No variable ID specified - using default'); } - if (spec.wasDefaultDelimiter) { - result.hasFlags = true; - result.reasons.push('No delimiter specified - using default comma'); - } - return result; } @@ -115,9 +105,8 @@ export const dsvPlugin: Plugin = { const content = token.content.trim(); const info = token.info.trim(); - // Use utility functions to parse delimiter and variable ID - const { delimiter, wasDefaultDelimiter } = parseDelimiter(info); - const { variableId, wasDefaultId } = parseVariableId(info, 'dsv', index); + // Parse both delimiter and variable ID in one pass + const { variableId, delimiter, wasDefaultId, wasDefaultDelimiter } = parseDsvInfo(info, 'dsv', index); return sanitizedHTML('pre', { id: `${pluginName}-${index}`, diff --git a/packages/markdown/src/plugins/index.ts b/packages/markdown/src/plugins/index.ts index 9071173e..82b4b611 100644 --- a/packages/markdown/src/plugins/index.ts +++ b/packages/markdown/src/plugins/index.ts @@ -9,6 +9,7 @@ import { checkboxPlugin } from './checkbox.js'; import { commentPlugin } from './comment.js'; import { cssPlugin } from './css.js'; import { csvPlugin } from './csv.js'; +import { valuePlugin } from './value.js'; import { dsvPlugin } from './dsv.js'; import { googleFontsPlugin } from './google-fonts.js'; import { dropdownPlugin } from './dropdown.js'; @@ -30,6 +31,7 @@ export function registerNativePlugins() { registerMarkdownPlugin(commentPlugin); registerMarkdownPlugin(cssPlugin); registerMarkdownPlugin(csvPlugin); + registerMarkdownPlugin(valuePlugin); registerMarkdownPlugin(dsvPlugin); registerMarkdownPlugin(googleFontsPlugin); registerMarkdownPlugin(dropdownPlugin); diff --git a/packages/markdown/src/plugins/interfaces.ts b/packages/markdown/src/plugins/interfaces.ts index ab81a30e..ccdc58e8 100644 --- a/packages/markdown/src/plugins/interfaces.ts +++ b/packages/markdown/src/plugins/interfaces.ts @@ -4,6 +4,7 @@ */ export { CheckboxSpec } from './checkbox.js'; export { CsvSpec } from './csv.js'; +export { ValueSpec } from './value.js'; export { DropdownSpec } from './dropdown.js'; export { DsvSpec } from './dsv.js'; export { ImageSpec } from './image.js'; @@ -18,13 +19,13 @@ export { TsvSpec } from './tsv.js'; export type PluginNames = '#' | + 'checkbox' | 'css' | 'csv' | - 'checkbox' | 'dropdown' | 'dsv' | - 'image' | 'google-fonts' | + 'image' | 'mermaid' | 'number' | 'placeholders' | @@ -34,5 +35,6 @@ export type PluginNames = 'textbox' | 'treebark' | 'tsv' | - 'vega-lite' | - 'vega'; + 'value' | + 'vega' | + 'vega-lite'; diff --git a/packages/markdown/src/plugins/mermaid.ts b/packages/markdown/src/plugins/mermaid.ts index c8e566e7..98ef923c 100644 --- a/packages/markdown/src/plugins/mermaid.ts +++ b/packages/markdown/src/plugins/mermaid.ts @@ -61,6 +61,7 @@ interface MermaidInstance { id: string; spec: MermaidElementProps; container: Element; + renderingDiagram: string; lastRenderedDiagram: string; signals: Record; tokens: TemplateToken[]; @@ -176,7 +177,6 @@ export const mermaidPlugin: Plugin = { // Determine format from token info (like flaggablePlugin does) const info = token.info.trim(); const isYaml = info.startsWith('yaml '); - const formatName = isYaml ? 'YAML' : 'JSON'; // Try to parse as YAML or JSON based on format try { @@ -186,7 +186,7 @@ export const mermaidPlugin: Plugin = { } else { parsed = JSON.parse(content); } - + if (parsed && typeof parsed === 'object') { spec = parsed as MermaidSpec; } else { @@ -231,14 +231,13 @@ export const mermaidPlugin: Plugin = { container, signals: {}, tokens, + renderingDiagram: null, lastRenderedDiagram: null, }; mermaidInstances.push(mermaidInstance); // For raw text mode, render immediately - if (spec.diagramText && typeof spec.diagramText === 'string') { - await renderRawDiagram(mermaidInstance.id, mermaidInstance.container, spec.diagramText, errorHandler, pluginName, index); - } + await renderRawDiagram(mermaidInstance, spec.diagramText, errorHandler, pluginName, index); } const instances = mermaidInstances.map((mermaidInstance, index): IInstance => { @@ -300,10 +299,7 @@ export const mermaidPlugin: Plugin = { } } - if (diagramText && mermaidInstance.lastRenderedDiagram !== diagramText) { - await renderRawDiagram(mermaidInstance.id, mermaidInstance.container, diagramText, errorHandler, pluginName, index); - mermaidInstance.lastRenderedDiagram = diagramText; - } + await renderRawDiagram(mermaidInstance, diagramText, errorHandler, pluginName, index); } else { mermaidInstance.container.innerHTML = '
No data available to render diagram
'; } @@ -313,8 +309,7 @@ export const mermaidPlugin: Plugin = { if (typeof value === 'string' && value.trim().length > 0) { // Render raw Mermaid text from variable - await renderRawDiagram(mermaidInstance.id, mermaidInstance.container, value, errorHandler, pluginName, index); - mermaidInstance.lastRenderedDiagram = value; + await renderRawDiagram(mermaidInstance, value, errorHandler, pluginName, index); } else { // Clear container if variable is empty mermaidInstance.container.innerHTML = '
No diagram to display
'; @@ -336,11 +331,25 @@ function isValidMermaid(diagramText: string) { return lines.length > 1 && lines[1].trim().length > 0; } -async function renderRawDiagram(id: string, container: Element, diagramText: string, errorHandler: ErrorHandler, pluginName: string, index: number) { +async function renderRawDiagram(mermaidInstance: MermaidInstance, diagramText: string, errorHandler: ErrorHandler, pluginName: string, index: number) { + + if (!diagramText || typeof diagramText !== 'string' || diagramText.trim().length === 0) { + return; + } + + if (mermaidInstance.renderingDiagram === diagramText) { + // Already rendering this diagram + return; + } + + mermaidInstance.renderingDiagram = diagramText; + if (typeof mermaid === 'undefined') { await loadMermaidFromCDN(); } + const { id, container } = mermaidInstance; + if (typeof mermaid === 'undefined') { container.innerHTML = '
Mermaid library not loaded dynamically
'; return; @@ -354,6 +363,8 @@ async function renderRawDiagram(id: string, container: Element, diagramText: str try { const { svg } = await mermaid.render(id, diagramText); container.innerHTML = svg; + mermaidInstance.lastRenderedDiagram = diagramText; + mermaidInstance.renderingDiagram = null; } catch (error) { container.innerHTML = `
Failed to render diagram ${id}
${diagramText}
`; errorHandler(error instanceof Error ? error : new Error(String(error)), pluginName, index, 'render', container); diff --git a/packages/markdown/src/plugins/value.ts b/packages/markdown/src/plugins/value.ts new file mode 100644 index 00000000..1550c0ff --- /dev/null +++ b/packages/markdown/src/plugins/value.ts @@ -0,0 +1,224 @@ +/** +* Copyright (c) Microsoft Corporation. +* Licensed under the MIT License. +*/ + +import { Batch, IInstance, Plugin, RawFlaggableSpec } from '../factory.js'; +import { sanitizedScriptTag, sanitizeHtmlComment } from '../sanitize.js'; +import { pluginClassName } from './util.js'; +import { PluginNames } from './interfaces.js'; +import { SpecReview } from 'common'; +import { parseFenceInfo } from './config.js'; +import * as yaml from 'js-yaml'; + +interface ValueInstance { + id: string; + spec: ValueSpec; + data: object[]; +} + +export interface ValueSpec { + variableId: string; + wasDefaultId?: boolean; +} + +function inspectValueSpec(spec: ValueSpec): RawFlaggableSpec { + const result: RawFlaggableSpec = { + spec, + hasFlags: false, + reasons: [] + }; + + // Flag if we had to use defaults + if (spec.wasDefaultId) { + result.hasFlags = true; + result.reasons.push('No variable ID specified - using default'); + } + + return result; +} + +const pluginName: PluginNames = 'value'; +const className = pluginClassName(pluginName); + +export const valuePlugin: Plugin = { + name: pluginName, + fence: (token, index) => { + const content = token.content.trim(); + const info = token.info.trim(); + + // Parse fence info + const { format, pluginName: parsedPluginName, params, wasDefaultId } = parseFenceInfo(info); + + // If pluginName isn't "value" AND isn't empty AND no explicit variableId, + // it's actually the variableId (e.g., "json inventory") + let variableId: string; + let actualWasDefaultId: boolean; + + if (parsedPluginName && parsedPluginName !== pluginName && !params.has('variableId')) { + // The parsed plugin name is actually the variableId + variableId = parsedPluginName; + actualWasDefaultId = false; + } else { + // Normal case: get variableId from params or use default + variableId = params.get('variableId') || `${format}Value${index}`; + actualWasDefaultId = wasDefaultId; + } + + // Use script tag with application/json type for storage + const scriptElement = sanitizedScriptTag(content, { + id: `${pluginName}-${index}`, + class: className, + 'data-variable-id': variableId, + 'data-was-default-id': actualWasDefaultId.toString(), + 'data-format': format + }); + + return scriptElement.outerHTML; + }, + hydrateSpecs: (renderer, errorHandler) => { + const flagged: SpecReview[] = []; + const containers = renderer.element.querySelectorAll(`.${className}`); + + for (const [index, container] of Array.from(containers).entries()) { + try { + const variableId = container.getAttribute('data-variable-id'); + const wasDefaultId = container.getAttribute('data-was-default-id') === 'true'; + + if (!variableId) { + errorHandler(new Error('No variable ID found'), pluginName, index, 'parse', container); + continue; + } + + const spec: ValueSpec = { variableId, wasDefaultId }; + const flaggableSpec = inspectValueSpec(spec); + + const f: SpecReview = { + approvedSpec: null, + pluginName, + containerId: container.id + }; + + if (flaggableSpec.hasFlags) { + f.blockedSpec = flaggableSpec.spec; + f.reason = flaggableSpec.reasons?.join(', ') || 'Unknown reason'; + } else { + f.approvedSpec = flaggableSpec.spec; + } + + flagged.push(f); + } catch (e) { + errorHandler(e instanceof Error ? e : new Error(String(e)), pluginName, index, 'parse', container); + } + } + + return flagged; + }, + hydrateComponent: async (renderer, errorHandler, specs) => { + const { signalBus } = renderer; + const valueInstances: ValueInstance[] = []; + + for (let index = 0; index < specs.length; index++) { + const specReview = specs[index]; + if (!specReview.approvedSpec) { + continue; + } + + const container = renderer.element.querySelector(`#${specReview.containerId}`); + if (!container) { + errorHandler(new Error('Container not found'), pluginName, index, 'init', null); + continue; + } + + try { + const content = container.textContent?.trim(); + if (!content) { + errorHandler(new Error('No value content found'), pluginName, index, 'parse', container); + continue; + } + + const spec: ValueSpec = specReview.approvedSpec; + const format = container.getAttribute('data-format') || 'json'; + + // Parse JSON or YAML content + let data: object[]; + try { + let parsed: any; + + if (format === 'yaml') { + // Parse YAML + parsed = yaml.load(content); + } else { + // Parse JSON + parsed = JSON.parse(content); + } + + // Ensure data is an array + if (Array.isArray(parsed)) { + data = parsed; + } else { + // If it's a single object, wrap it in an array + data = [parsed]; + } + } catch (parseError) { + errorHandler( + new Error(`Invalid ${format.toUpperCase()}: ${parseError instanceof Error ? parseError.message : String(parseError)}`), + pluginName, + index, + 'parse', + container + ); + continue; + } + + const valueInstance: ValueInstance = { + id: `${pluginName}-${index}`, + spec, + data + }; + valueInstances.push(valueInstance); + + // Add a safe comment before the container to show that value was loaded + const comment = sanitizeHtmlComment(`${format.toUpperCase()} value loaded: ${data.length} rows for variable '${spec.variableId}'`); + container.insertAdjacentHTML('beforebegin', comment); + + } catch (e) { + errorHandler(e instanceof Error ? e : new Error(String(e)), pluginName, index, 'parse', container); + } + } + + const instances = valueInstances.map((valueInstance): IInstance => { + const { spec, data } = valueInstance; + + const initialSignals = [{ + name: spec.variableId, + value: data, + priority: 1, + isData: true, + }]; + + return { + ...valueInstance, + initialSignals, + beginListening() { + // Value data is static, but we broadcast it when listening begins + const batch: Batch = { + [spec.variableId]: { + value: data, + isData: true, + }, + }; + signalBus.broadcast(valueInstance.id, batch); + }, + getCurrentSignalValue: () => { + return data; + }, + destroy: () => { + // No cleanup needed for JSON data + }, + }; + }); + + return instances; + }, +}; diff --git a/packages/markdown/src/plugins/vega.ts b/packages/markdown/src/plugins/vega.ts index e179832b..37901e37 100644 --- a/packages/markdown/src/plugins/vega.ts +++ b/packages/markdown/src/plugins/vega.ts @@ -28,6 +28,7 @@ interface VegaInstance extends SpecInit { batch?: Batch; dataSignals: string[]; needToRun?: boolean; + isListening?: boolean; } const pluginName: PluginNames = 'vega'; @@ -113,17 +114,25 @@ export const vegaPlugin: Plugin = { receiveBatch: async (batch, from) => { signalBus.log(vegaInstance.id, 'received batch', batch, from); return new Promise(resolve => { - view.runAfter(async () => { + if (vegaInstance.isListening) { + view.runAfter(async () => { + if (receiveBatch(batch, signalBus, vegaInstance)) { + signalBus.log(vegaInstance.id, 'running after _pulse, changes from', from); + view.resize(); + vegaInstance.needToRun = true; + } else { + signalBus.log(vegaInstance.id, 'no changes'); + } + signalBus.log(vegaInstance.id, 'running view after _pulse finished'); + resolve(); + }); + } else { + //not yet listening, so just receive the batch without running if (receiveBatch(batch, signalBus, vegaInstance)) { - signalBus.log(vegaInstance.id, 'running after _pulse, changes from', from); - view.resize(); vegaInstance.needToRun = true; - } else { - signalBus.log(vegaInstance.id, 'no changes'); } - signalBus.log(vegaInstance.id, 'running view after _pulse finished'); resolve(); - }); + } }); }, broadcastComplete: async () => { @@ -180,6 +189,11 @@ export const vegaPlugin: Plugin = { //signalBus.log(vegaInstance.id, 'not listening to signal, no match', signalName); } } + vegaInstance.isListening = true; + if (vegaInstance.needToRun) { + view.runAsync(); + vegaInstance.needToRun = false; + } }, getCurrentSignalValue: (signalName: string) => { const matchSignal = spec.signals?.find(signal => signal.name === signalName); @@ -219,7 +233,7 @@ function receiveBatch(batch: Batch, signalBus: SignalBus, vegaInstance: VegaInst logReason = 'not updating data, no match'; } else { logReason = 'updating data'; - + // Use structuredClone to ensure deep copy // vega may efficiently have symbols on data to cache a datum's values // so this needs to appear to be new data diff --git a/packages/markdown/src/renderer.ts b/packages/markdown/src/renderer.ts index 27d515bf..62866a02 100644 --- a/packages/markdown/src/renderer.ts +++ b/packages/markdown/src/renderer.ts @@ -181,12 +181,18 @@ export class Renderer { } reset() { + //carry old values over + const { logLevel, logWatchIds } = this.signalBus; //cancel the old signal bus, which may have active listeners this.signalBus.deactivate(); //create a new signal bus this.signalBus = new SignalBus(defaultCommonOptions.dataSignalPrefix!); + + //restore old values + this.signalBus.logLevel = logLevel; + this.signalBus.logWatchIds = logWatchIds; for (const pluginName of Object.keys(this.instances)) { const instances = this.instances[pluginName]; diff --git a/packages/markdown/src/sanitize.ts b/packages/markdown/src/sanitize.ts index 526bf8f1..cabe8f84 100644 --- a/packages/markdown/src/sanitize.ts +++ b/packages/markdown/src/sanitize.ts @@ -26,11 +26,7 @@ export function sanitizedHTML(tagName: string, attributes: { [key: string]: stri if (precedeWithScriptTag) { // Create a script tag that precedes the main element - const scriptElement = domDocument.createElement('script'); - scriptElement.setAttribute('type', 'application/json'); - // Only escape the dangerous sequence that could break out of script tag - const safeContent = content.replace(/<\/script>/gi, '<\\/script>'); - scriptElement.innerHTML = safeContent; + const scriptElement = sanitizedScriptTag(content); // Return script tag followed by empty element return scriptElement.outerHTML + element.outerHTML; @@ -43,6 +39,30 @@ export function sanitizedHTML(tagName: string, attributes: { [key: string]: stri return element.outerHTML; } +export function sanitizedScriptTag(content: string, attributes?: { [key: string]: string }): HTMLScriptElement { + if (!domDocument) { + throw new Error('No DOM Document available. Please set domDocument using setDomDocument.'); + } + + const scriptElement = domDocument.createElement('script'); + + // Set default type to application/json + scriptElement.setAttribute('type', 'application/json'); + + // Set additional attributes if provided + if (attributes) { + Object.keys(attributes).forEach(key => { + scriptElement.setAttribute(key, attributes[key]); + }); + } + + // Only escape the dangerous sequence that could break out of script tag + const safeContent = content.replace(/<\/script>/gi, '<\\/script>'); + scriptElement.innerHTML = safeContent; + + return scriptElement; +} + export function sanitizeHtmlComment(content: string) { // First escape the content safely diff --git a/packages/markdown/src/signalbus.ts b/packages/markdown/src/signalbus.ts index ec7faf9a..9ec69334 100644 --- a/packages/markdown/src/signalbus.ts +++ b/packages/markdown/src/signalbus.ts @@ -91,7 +91,9 @@ export class SignalBus { //set current values for (const signalName in batch) { const signalDep = this.signalDeps[signalName]; - signalDep.value = batch[signalName].value; + if (signalDep) { + signalDep.value = batch[signalName].value; + } } if (this.broadcastingStack.length === 0) { @@ -143,14 +145,15 @@ export class SignalBus { //set the initial batch on each peer this.log('beginListening', 'begin initial batch', this.signalDeps); + const initialBatch: Batch = {}; + for (const signalName in this.signalDeps) { + const signalDep = this.signalDeps[signalName]; + const { value, isData } = signalDep; + initialBatch[signalName] = { value, isData }; + } + for (const peer of this.peers) { - const batch: Batch = {}; - for (const signalName in this.signalDeps) { - const signalDep = this.signalDeps[signalName]; - const { value, isData } = signalDep; - batch[signalName] = { value, isData }; - } - peer.receiveBatch && peer.receiveBatch(batch, 'initial'); + peer.receiveBatch && peer.receiveBatch(initialBatch, 'initial'); } //need to call broadcast complete to ensure that all peers have the initial values diff --git a/packages/web-deploy/json/features/2.4.inline-csv-data.idoc.json b/packages/web-deploy/json/features/2.4.inline-csv-data.idoc.json index 7bc39955..2e1055f1 100644 --- a/packages/web-deploy/json/features/2.4.inline-csv-data.idoc.json +++ b/packages/web-deploy/json/features/2.4.inline-csv-data.idoc.json @@ -1,14 +1,48 @@ { "$schema": "../../../../docs/schema/idoc_v1.json", "title": "Feature: Inline Delimited Data", + "dataLoaders": [ + { + "dataSourceName": "officeSupplies", + "type": "inline", + "format": "csv", + "content": [ + "item,price", + "Stapler,12.99", + "Pen,1.25", + "Lamp,29.99" + ] + }, + { + "dataSourceName": "people", + "type": "inline", + "format": "tsv", + "content": [ + "name\tage\tcity", + "Alice\t30\tNew York", + "Bob\t25\tLos Angeles", + "Charlie\t35\tChicago" + ] + }, + { + "dataSourceName": "products", + "type": "inline", + "format": "dsv", + "delimiter": "|", + "content": [ + "product|category|rating", + "Laptop|Electronics|4.5", + "Chair|Furniture|4.2", + "Book|Education|4.8" + ] + } + ], "groups": [ { "groupId": "inline_csv", "elements": [ "## Inline CSV Data", "You can provide CSV data directly as a code block within the document using the **csv plugin** with a `variableId` parameter. This is useful for small datasets or when you want to include sample data without external dependencies.", - "", - "```csv officeSupplies\nitem,price\nStapler,12.99\nPen,1.25\nLamp,29.99\n```", { "type": "tabulator", "dataSourceName": "officeSupplies", @@ -25,8 +59,6 @@ "elements": [ "## Inline TSV Data", "You can also use tab-separated values (TSV) with the **tsv plugin** and a `variableId` parameter:", - "", - "```tsv people\nname\tage\tcity\nAlice\t30\tNew York\nBob\t25\tLos Angeles\nCharlie\t35\tChicago\n```", { "type": "tabulator", "dataSourceName": "people", @@ -43,8 +75,6 @@ "elements": [ "## Inline DSV Data (Custom Delimiter)", "For custom delimiters, use the **dsv plugin** with `delimiter:` and `variableId:` parameters. Here's an example with pipe-separated values:", - "", - "```dsv delimiter:| variableId:products\nproduct|category|rating\nLaptop|Electronics|4.5\nChair|Furniture|4.2\nBook|Education|4.8\n```", { "type": "tabulator", "dataSourceName": "products", @@ -57,4 +87,4 @@ ] } ] -} +} \ No newline at end of file