diff --git a/packages/markdown/src/factory.ts b/packages/markdown/src/factory.ts index a2384b84..1aff91d3 100644 --- a/packages/markdown/src/factory.ts +++ b/packages/markdown/src/factory.ts @@ -65,6 +65,12 @@ export interface RawFlaggableSpec { spec: T; hasFlags?: boolean; reasons?: string[]; + head?: { + format?: 'json' | 'yaml'; + pluginName?: string; + params?: Record; // Serializable version of Map + wasDefaultId?: boolean; + }; } export interface SpecContainer { diff --git a/packages/markdown/src/plugins/config.ts b/packages/markdown/src/plugins/config.ts index dd8e9371..00a7c366 100644 --- a/packages/markdown/src/plugins/config.ts +++ b/packages/markdown/src/plugins/config.ts @@ -79,6 +79,129 @@ export function parseFenceInfo(info: string): { return { format, pluginName, params, wasDefaultId }; } +/** + * Parsed fence head information + */ +export interface ParsedHead { + format: 'json' | 'yaml'; + pluginName: string; + params: Map; + wasDefaultId: boolean; +} + +/** + * Combined head and body parsing result + */ +export interface HeadBodyResult { + head: ParsedHead; + body: { + spec: T | null; + error?: string; + }; +} + +/** + * Convert ParsedHead to a serializable format for JSON storage. + * Converts the params Map to a plain object so it can be serialized to JSON. + * @param head The parsed head information with params as a Map + * @returns Serializable object with params as a plain Record + */ +export function convertHeadToSerializable(head: ParsedHead) { + return { + format: head.format, + pluginName: head.pluginName, + params: Object.fromEntries(head.params), + wasDefaultId: head.wasDefaultId + }; +} + +/** + * Parse body content as JSON or YAML based on format. + * @param content The fence content + * @param format The format ('json' or 'yaml') + * @returns Parsed object and error if any + */ +function parseBodyContent(content: string, format: 'json' | 'yaml'): { + spec: T | null; + error?: string; +} { + const formatName = format === 'yaml' ? 'YAML' : 'JSON'; + + try { + let parsed: unknown; + if (format === 'yaml') { + parsed = yaml.load(content.trim()); + } else { + parsed = JSON.parse(content.trim()); + } + + // Handle null/undefined results from YAML parsing (empty content) + if (parsed === null || parsed === undefined) { + return { + spec: null, + error: `Empty or null ${formatName} content` + }; + } + + // Return the parsed result - caller is responsible for validating structure + return { spec: parsed as T }; + } catch (e) { + return { + spec: null, + error: `malformed ${formatName}: ${e instanceof Error ? e.message : String(e)}` + }; + } +} + +/** + * Parse body content as JSON or YAML based on fence info. + * @param content The fence content + * @param info The fence info string (used to detect format) + * @returns Parsed object and format metadata + */ +export function parseBody(content: string, info: string): { + spec: T | null; + format: 'json' | 'yaml'; + error?: string; +} { + const { format } = parseFenceInfo(info); + const result = parseBodyContent(content, format); + + return { + spec: result.spec, + format, + error: result.error + }; +} + +/** + * Parse both head (fence info) and body (content) together. + * @param content The fence content + * @param info The fence info string + * @returns Combined head and body parsing result + */ +export function parseHeadAndBody(content: string, info: string): HeadBodyResult { + // Parse head once + const headInfo = parseFenceInfo(info); + const head: ParsedHead = { + format: headInfo.format, + pluginName: headInfo.pluginName, + params: headInfo.params, + wasDefaultId: headInfo.wasDefaultId + }; + + // Parse body using the already-parsed format + const bodyResult = parseBodyContent(content, headInfo.format); + + return { + head, + body: { + spec: bodyResult.spec, + error: bodyResult.error + } + }; +} + /* //Tests for parseFenceInfo const tests: [string, { format: 'json' | 'yaml'; pluginName: string; variableId: string | undefined; wasDefaultId: boolean }][] = [ @@ -135,45 +258,50 @@ tests.forEach(([input, expected], i) => { */ /** - * Creates a plugin that can parse both JSON and YAML formats + * Creates a plugin that can parse both JSON and YAML formats. + * This handles both "head" (fence info) and "body" (content) parsing. */ export function flaggablePlugin(pluginName: PluginNames, className: string, flagger?: (spec: T) => RawFlaggableSpec, attrs?: object) { const plugin: Plugin = { name: pluginName, fence: (token, index) => { - let content = token.content.trim(); - let spec: T; - let flaggableSpec: RawFlaggableSpec; - - // Determine format from token info const info = token.info.trim(); - const isYaml = info.startsWith('yaml '); - const formatName = isYaml ? 'YAML' : 'JSON'; + const content = token.content.trim(); - try { - if (isYaml) { - spec = yaml.load(content) as T; - } else { - spec = JSON.parse(content); - } - } catch (e) { + // Parse both head and body using the helper function + const { head, body } = parseHeadAndBody(content, info); + + let flaggableSpec: RawFlaggableSpec; + if (body.error) { + // Parsing failed flaggableSpec = { spec: null, hasFlags: true, - reasons: [`malformed ${formatName}`], + reasons: [body.error], + head: convertHeadToSerializable(head) }; - } - if (spec) { + } else if (body.spec) { + // Parsing succeeded, apply flagger if provided if (flagger) { - flaggableSpec = flagger(spec); + flaggableSpec = flagger(body.spec); } else { - flaggableSpec = { spec }; + flaggableSpec = { spec: body.spec }; } + // Add head information to the result + flaggableSpec.head = convertHeadToSerializable(head); + } else { + // No spec (shouldn't happen, but handle it) + flaggableSpec = { + spec: null, + hasFlags: true, + reasons: ['No spec provided'], + head: convertHeadToSerializable(head) + }; } - if (flaggableSpec) { - content = JSON.stringify(flaggableSpec); - } - return sanitizedHTML('div', { class: className, id: `${pluginName}-${index}`, ...attrs }, content, true); + + // Store the flaggable spec as JSON in the div + const jsonContent = JSON.stringify(flaggableSpec); + return sanitizedHTML('div', { class: className, id: `${pluginName}-${index}`, ...attrs }, jsonContent, true); }, hydrateSpecs: (renderer, errorHandler) => { const flagged: SpecReview[] = []; diff --git a/packages/markdown/src/plugins/mermaid.ts b/packages/markdown/src/plugins/mermaid.ts index 98ef923c..3345e282 100644 --- a/packages/markdown/src/plugins/mermaid.ts +++ b/packages/markdown/src/plugins/mermaid.ts @@ -48,14 +48,13 @@ import { Plugin, RawFlaggableSpec, IInstance } from '../factory.js'; import { ErrorHandler } from '../renderer.js'; import { sanitizedHTML } from '../sanitize.js'; -import { flaggablePlugin } from './config.js'; +import { flaggablePlugin, parseHeadAndBody, convertHeadToSerializable } from './config.js'; import { pluginClassName } from './util.js'; import { PluginNames } from './interfaces.js'; import { TemplateToken, tokenizeTemplate } from 'common'; import { MermaidConfig } from 'mermaid'; import type Mermaid from 'mermaid'; import { MermaidElementProps, MermaidTemplate } from '@microsoft/chartifact-schema'; -import * as yaml from 'js-yaml'; interface MermaidInstance { id: string; @@ -170,35 +169,25 @@ function loadMermaidFromCDN(): Promise { export const mermaidPlugin: Plugin = { ...flaggablePlugin(pluginName, className), fence: (token, index) => { + const info = token.info.trim(); const content = token.content.trim(); + + // Try to parse as JSON/YAML using the helper function + const { head, body } = parseHeadAndBody(content, info); + let spec: MermaidSpec; - let flaggableSpec: RawFlaggableSpec; - - // Determine format from token info (like flaggablePlugin does) - const info = token.info.trim(); - const isYaml = info.startsWith('yaml '); - - // Try to parse as YAML or JSON based on format - try { - let parsed: any; - if (isYaml) { - parsed = yaml.load(content); - } else { - parsed = JSON.parse(content); - } - - if (parsed && typeof parsed === 'object') { - spec = parsed as MermaidSpec; - } else { - // If it's valid YAML/JSON but not a proper MermaidSpec object, treat as raw text - spec = { diagramText: content }; - } - } catch (e) { - // If YAML/JSON parsing fails, treat as raw text + + if (body.spec && typeof body.spec === 'object') { + // Parsing succeeded and it's an object - use it as MermaidSpec + spec = body.spec; + } else { + // If parsing failed or result is not an object, treat as raw text spec = { diagramText: content }; } - flaggableSpec = inspectMermaidSpec(spec); + const flaggableSpec = inspectMermaidSpec(spec); + // Add head information to the result + flaggableSpec.head = convertHeadToSerializable(head); const json = JSON.stringify(flaggableSpec); return sanitizedHTML('div', { class: className, id: `${pluginName}-${index}` }, json, true); diff --git a/packages/markdown/src/plugins/vega-lite.ts b/packages/markdown/src/plugins/vega-lite.ts index 7ab1bd90..813ff5a4 100644 --- a/packages/markdown/src/plugins/vega-lite.ts +++ b/packages/markdown/src/plugins/vega-lite.ts @@ -5,13 +5,12 @@ import { Plugin, RawFlaggableSpec } from '../factory.js'; import { sanitizedHTML } from '../sanitize.js'; -import { flaggablePlugin } from './config.js'; +import { flaggablePlugin, parseHeadAndBody, convertHeadToSerializable } from './config.js'; import { pluginClassName } from './util.js'; import { inspectVegaSpec, vegaPlugin } from './vega.js'; import { compile, TopLevelSpec } from 'vega-lite'; import { Spec } from 'vega'; import { PluginNames } from './interfaces.js'; -import * as yaml from 'js-yaml'; const pluginName: PluginNames = 'vega-lite'; const className = pluginClassName(pluginName); @@ -19,45 +18,59 @@ const className = pluginClassName(pluginName); export const vegaLitePlugin: Plugin = { ...flaggablePlugin(pluginName, className), fence: (token, index) => { - let content = token.content.trim(); - let spec: TopLevelSpec; - let flaggableSpec: RawFlaggableSpec; - - // Determine format from token info const info = token.info.trim(); - const isYaml = info.startsWith('yaml '); - const formatName = isYaml ? 'YAML' : 'JSON'; + const content = token.content.trim(); - try { - if (isYaml) { - spec = yaml.load(content) as TopLevelSpec; - } else { - spec = JSON.parse(content); - } - } catch (e) { + // Parse both head and body using the helper function + const { head, body } = parseHeadAndBody(content, info); + + let flaggableSpec: RawFlaggableSpec; + + if (body.error) { + // Parsing failed flaggableSpec = { spec: null, hasFlags: true, - reasons: [`malformed ${formatName}`], + reasons: [body.error], + head: convertHeadToSerializable(head) }; - } - if (spec) { + } else if (body.spec) { + // Parsing succeeded, try to compile to Vega try { - const vegaSpec = compile(spec); - flaggableSpec = inspectVegaSpec(vegaSpec.spec); - } - catch (e) { + const vegaSpec = compile(body.spec); + // inspectVegaSpec returns RawFlaggableSpec (Vega), but we store as TopLevelSpec + const inspected = inspectVegaSpec(vegaSpec.spec); + // Create a compatible flaggableSpec that uses the compiled Vega spec + // Note: This plugin compiles Vega-Lite to Vega and stores the compiled spec. + // The type assertion is needed because we're storing a Vega Spec where TopLevelSpec is expected. + // This is a pre-existing design decision in the vega-lite plugin architecture. + flaggableSpec = { + spec: vegaSpec.spec as any as TopLevelSpec, + hasFlags: inspected.hasFlags, + reasons: inspected.reasons, + head: convertHeadToSerializable(head) + }; + } catch (e) { flaggableSpec = { spec: null, hasFlags: true, - reasons: [`failed to compile vega spec`], + reasons: [`failed to compile vega spec: ${e instanceof Error ? e.message : String(e)}`], + head: convertHeadToSerializable(head) }; } + } else { + // body.spec is null (can happen with empty/null YAML content) + // This is a legitimate case handled by parseHeadAndBody for empty or invalid content + flaggableSpec = { + spec: null, + hasFlags: true, + reasons: body.error ? [body.error] : ['No spec provided'], + head: convertHeadToSerializable(head) + }; } - if (flaggableSpec) { - content = JSON.stringify(flaggableSpec); - } - return sanitizedHTML('div', { class: pluginClassName(vegaPlugin.name), id: `${pluginName}-${index}` }, content, true); + + const jsonContent = JSON.stringify(flaggableSpec); + return sanitizedHTML('div', { class: pluginClassName(vegaPlugin.name), id: `${pluginName}-${index}` }, jsonContent, true); }, hydratesBefore: vegaPlugin.name, };