Skip to content
6 changes: 6 additions & 0 deletions packages/markdown/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export interface RawFlaggableSpec<T> {
spec: T;
hasFlags?: boolean;
reasons?: string[];
head?: {
format?: 'json' | 'yaml';
pluginName?: string;
params?: Record<string, string>; // Serializable version of Map
wasDefaultId?: boolean;
};
}

export interface SpecContainer<T> {
Expand Down
176 changes: 152 additions & 24 deletions packages/markdown/src/plugins/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
wasDefaultId: boolean;
}

/**
* Combined head and body parsing result
*/
export interface HeadBodyResult<T> {
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<T>(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<T>(content: string, info: string): {
spec: T | null;
format: 'json' | 'yaml';
error?: string;
} {
const { format } = parseFenceInfo(info);
const result = parseBodyContent<T>(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<T>(content: string, info: string): HeadBodyResult<T> {
// 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<T>(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 }][] = [
Expand Down Expand Up @@ -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<T>(pluginName: PluginNames, className: string, flagger?: (spec: T) => RawFlaggableSpec<T>, attrs?: object) {
const plugin: Plugin<T> = {
name: pluginName,
fence: (token, index) => {
let content = token.content.trim();
let spec: T;
let flaggableSpec: RawFlaggableSpec<T>;

// 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<T>(content, info);

let flaggableSpec: RawFlaggableSpec<T>;
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<T>[] = [];
Expand Down
41 changes: 15 additions & 26 deletions packages/markdown/src/plugins/mermaid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -170,35 +169,25 @@ function loadMermaidFromCDN(): Promise<void> {
export const mermaidPlugin: Plugin<MermaidSpec> = {
...flaggablePlugin<MermaidSpec>(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<MermaidSpec>(content, info);

let spec: MermaidSpec;
let flaggableSpec: RawFlaggableSpec<MermaidSpec>;

// 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);
Expand Down
69 changes: 41 additions & 28 deletions packages/markdown/src/plugins/vega-lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,59 +5,72 @@

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);

export const vegaLitePlugin: Plugin<TopLevelSpec> = {
...flaggablePlugin<TopLevelSpec>(pluginName, className),
fence: (token, index) => {
let content = token.content.trim();
let spec: TopLevelSpec;
let flaggableSpec: RawFlaggableSpec<Spec>;

// 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<TopLevelSpec>(content, info);

let flaggableSpec: RawFlaggableSpec<TopLevelSpec>;

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<Spec> (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,
};
Loading