From 79e068c5e704a3a0ef198d2e4632fdf37c34ce64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 02:27:29 +0000 Subject: [PATCH 1/8] Initial plan From f534f658a16fdf7c6f83bbdc06de5dd25b799d2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 02:34:11 +0000 Subject: [PATCH 2/8] Refactor: Add parseBody helper and update vega-lite and mermaid plugins Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- packages/markdown/src/plugins/config.ts | 80 +++++++++++++++------- packages/markdown/src/plugins/mermaid.ts | 39 ++++------- packages/markdown/src/plugins/vega-lite.ts | 52 +++++++------- 3 files changed, 94 insertions(+), 77 deletions(-) diff --git a/packages/markdown/src/plugins/config.ts b/packages/markdown/src/plugins/config.ts index dd8e9371..9a7d5fd6 100644 --- a/packages/markdown/src/plugins/config.ts +++ b/packages/markdown/src/plugins/config.ts @@ -79,6 +79,37 @@ export function parseFenceInfo(info: string): { return { format, pluginName, params, wasDefaultId }; } +/** + * 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 formatName = format === 'yaml' ? 'YAML' : 'JSON'; + + try { + let spec: T; + if (format === 'yaml') { + spec = yaml.load(content.trim()) as T; + } else { + spec = JSON.parse(content.trim()); + } + return { spec, format }; + } catch (e) { + return { + spec: null, + format, + error: `malformed ${formatName}: ${e instanceof Error ? e.message : String(e)}` + }; + } +} + /* //Tests for parseFenceInfo const tests: [string, { format: 'json' | 'yaml'; pluginName: string; variableId: string | undefined; wasDefaultId: boolean }][] = [ @@ -135,45 +166,46 @@ 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 body content using the helper function + const parseResult = parseBody(content, info); + + let flaggableSpec: RawFlaggableSpec; + if (parseResult.error) { + // Parsing failed flaggableSpec = { spec: null, hasFlags: true, - reasons: [`malformed ${formatName}`], + reasons: [parseResult.error], }; - } - if (spec) { + } else if (parseResult.spec) { + // Parsing succeeded, apply flagger if provided if (flagger) { - flaggableSpec = flagger(spec); + flaggableSpec = flagger(parseResult.spec); } else { - flaggableSpec = { spec }; + flaggableSpec = { spec: parseResult.spec }; } + } else { + // No spec (shouldn't happen, but handle it) + flaggableSpec = { + spec: null, + hasFlags: true, + reasons: ['No spec provided'], + }; } - 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..b2eca475 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, parseBody } 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,23 @@ 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 parseResult = parseBody(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 (parseResult.spec && typeof parseResult.spec === 'object') { + // Parsing succeeded and it's an object - use it as MermaidSpec + spec = parseResult.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); 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..f35e7605 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, parseBody } 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,44 @@ 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 body content using the helper function + const parseResult = parseBody(content, info); + + let flaggableSpec: RawFlaggableSpec; + + if (parseResult.error) { + // Parsing failed flaggableSpec = { spec: null, hasFlags: true, - reasons: [`malformed ${formatName}`], + reasons: [parseResult.error], }; - } - if (spec) { + } else if (parseResult.spec) { + // Parsing succeeded, try to compile to Vega try { - const vegaSpec = compile(spec); + const vegaSpec = compile(parseResult.spec); flaggableSpec = inspectVegaSpec(vegaSpec.spec); - } - catch (e) { + } 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)}`], }; } + } else { + // No spec (shouldn't happen) + flaggableSpec = { + spec: null, + hasFlags: true, + reasons: ['No spec provided'], + }; } - 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, }; From 270b89e1cc43ed1b5823b81efac860b3aa7f0d9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 02:39:47 +0000 Subject: [PATCH 3/8] Fix code review issues: type safety and null handling in parseBody Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- packages/markdown/src/plugins/config.ts | 19 +++++++++++++++---- packages/markdown/src/plugins/vega-lite.ts | 12 ++++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/markdown/src/plugins/config.ts b/packages/markdown/src/plugins/config.ts index 9a7d5fd6..ee5feee5 100644 --- a/packages/markdown/src/plugins/config.ts +++ b/packages/markdown/src/plugins/config.ts @@ -94,13 +94,24 @@ export function parseBody(content: string, info: string): { const formatName = format === 'yaml' ? 'YAML' : 'JSON'; try { - let spec: T; + let parsed: unknown; if (format === 'yaml') { - spec = yaml.load(content.trim()) as T; + parsed = yaml.load(content.trim()); } else { - spec = JSON.parse(content.trim()); + parsed = JSON.parse(content.trim()); } - return { spec, format }; + + // Handle null/undefined results from YAML parsing (empty content) + if (parsed === null || parsed === undefined) { + return { + spec: null, + format, + error: `Empty or null ${formatName} content` + }; + } + + // Return the parsed result - caller is responsible for validating structure + return { spec: parsed as T, format }; } catch (e) { return { spec: null, diff --git a/packages/markdown/src/plugins/vega-lite.ts b/packages/markdown/src/plugins/vega-lite.ts index f35e7605..c3f2c730 100644 --- a/packages/markdown/src/plugins/vega-lite.ts +++ b/packages/markdown/src/plugins/vega-lite.ts @@ -24,7 +24,7 @@ export const vegaLitePlugin: Plugin = { // Parse body content using the helper function const parseResult = parseBody(content, info); - let flaggableSpec: RawFlaggableSpec; + let flaggableSpec: RawFlaggableSpec; if (parseResult.error) { // Parsing failed @@ -37,7 +37,15 @@ export const vegaLitePlugin: Plugin = { // Parsing succeeded, try to compile to Vega try { const vegaSpec = compile(parseResult.spec); - flaggableSpec = inspectVegaSpec(vegaSpec.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 actually stores the compiled Vega spec, not the original TopLevelSpec + flaggableSpec = { + spec: vegaSpec.spec as any as TopLevelSpec, // Type assertion needed due to Vega vs Vega-Lite types + hasFlags: inspected.hasFlags, + reasons: inspected.reasons + }; } catch (e) { flaggableSpec = { spec: null, From f4548b1b75075938f3586fe96378a47ab887c520 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 02:42:26 +0000 Subject: [PATCH 4/8] Add clarifying comments about vega-lite type assertions and null handling Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- packages/markdown/src/plugins/vega-lite.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/markdown/src/plugins/vega-lite.ts b/packages/markdown/src/plugins/vega-lite.ts index c3f2c730..8d0c77bd 100644 --- a/packages/markdown/src/plugins/vega-lite.ts +++ b/packages/markdown/src/plugins/vega-lite.ts @@ -40,9 +40,11 @@ export const vegaLitePlugin: Plugin = { // 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 actually stores the compiled Vega spec, not the original TopLevelSpec + // 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, // Type assertion needed due to Vega vs Vega-Lite types + spec: vegaSpec.spec as any as TopLevelSpec, hasFlags: inspected.hasFlags, reasons: inspected.reasons }; @@ -54,11 +56,12 @@ export const vegaLitePlugin: Plugin = { }; } } else { - // No spec (shouldn't happen) + // parseResult.spec is null (can happen with empty/null YAML content) + // This is a legitimate case handled by parseBody for empty or invalid content flaggableSpec = { spec: null, hasFlags: true, - reasons: ['No spec provided'], + reasons: parseResult.error ? [parseResult.error] : ['No spec provided'], }; } From 07e4649af2a0e4d94db1dba0444587404367a6c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 03:12:09 +0000 Subject: [PATCH 5/8] Add head/body structure to flaggablePlugin with parseHeadAndBody Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- packages/markdown/src/factory.ts | 6 ++ packages/markdown/src/plugins/config.ts | 82 ++++++++++++++++++++-- packages/markdown/src/plugins/mermaid.ts | 15 ++-- packages/markdown/src/plugins/vega-lite.ts | 46 +++++++++--- 4 files changed, 127 insertions(+), 22 deletions(-) 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 ee5feee5..8c7da9a9 100644 --- a/packages/markdown/src/plugins/config.ts +++ b/packages/markdown/src/plugins/config.ts @@ -79,6 +79,27 @@ 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; + }; +} + /** * Parse body content as JSON or YAML based on fence info. * @param content The fence content @@ -121,6 +142,34 @@ export function parseBody(content: string, info: string): { } } +/** + * 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 + const headInfo = parseFenceInfo(info); + const head: ParsedHead = { + format: headInfo.format, + pluginName: headInfo.pluginName, + params: headInfo.params, + wasDefaultId: headInfo.wasDefaultId + }; + + // Parse body + const bodyResult = parseBody(content, info); + + 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 }][] = [ @@ -187,30 +236,49 @@ export function flaggablePlugin(pluginName: PluginNames, className: string, f const info = token.info.trim(); const content = token.content.trim(); - // Parse body content using the helper function - const parseResult = parseBody(content, info); + // Parse both head and body using the helper function + const { head, body } = parseHeadAndBody(content, info); let flaggableSpec: RawFlaggableSpec; - if (parseResult.error) { + if (body.error) { // Parsing failed flaggableSpec = { spec: null, hasFlags: true, - reasons: [parseResult.error], + reasons: [body.error], + head: { + format: head.format, + pluginName: head.pluginName, + params: Object.fromEntries(head.params), + wasDefaultId: head.wasDefaultId + } }; - } else if (parseResult.spec) { + } else if (body.spec) { // Parsing succeeded, apply flagger if provided if (flagger) { - flaggableSpec = flagger(parseResult.spec); + flaggableSpec = flagger(body.spec); } else { - flaggableSpec = { spec: parseResult.spec }; + flaggableSpec = { spec: body.spec }; } + // Add head information to the result + flaggableSpec.head = { + format: head.format, + pluginName: head.pluginName, + params: Object.fromEntries(head.params), + wasDefaultId: head.wasDefaultId + }; } else { // No spec (shouldn't happen, but handle it) flaggableSpec = { spec: null, hasFlags: true, reasons: ['No spec provided'], + head: { + format: head.format, + pluginName: head.pluginName, + params: Object.fromEntries(head.params), + wasDefaultId: head.wasDefaultId + } }; } diff --git a/packages/markdown/src/plugins/mermaid.ts b/packages/markdown/src/plugins/mermaid.ts index b2eca475..034d714d 100644 --- a/packages/markdown/src/plugins/mermaid.ts +++ b/packages/markdown/src/plugins/mermaid.ts @@ -48,7 +48,7 @@ import { Plugin, RawFlaggableSpec, IInstance } from '../factory.js'; import { ErrorHandler } from '../renderer.js'; import { sanitizedHTML } from '../sanitize.js'; -import { flaggablePlugin, parseBody } from './config.js'; +import { flaggablePlugin, parseHeadAndBody } from './config.js'; import { pluginClassName } from './util.js'; import { PluginNames } from './interfaces.js'; import { TemplateToken, tokenizeTemplate } from 'common'; @@ -173,19 +173,26 @@ export const mermaidPlugin: Plugin = { const content = token.content.trim(); // Try to parse as JSON/YAML using the helper function - const parseResult = parseBody(content, info); + const { head, body } = parseHeadAndBody(content, info); let spec: MermaidSpec; - if (parseResult.spec && typeof parseResult.spec === 'object') { + if (body.spec && typeof body.spec === 'object') { // Parsing succeeded and it's an object - use it as MermaidSpec - spec = parseResult.spec; + spec = body.spec; } else { // If parsing failed or result is not an object, treat as raw text spec = { diagramText: content }; } const flaggableSpec = inspectMermaidSpec(spec); + // Add head information to the result + flaggableSpec.head = { + format: head.format, + pluginName: head.pluginName, + params: Object.fromEntries(head.params), + wasDefaultId: head.wasDefaultId + }; 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 8d0c77bd..68a539b3 100644 --- a/packages/markdown/src/plugins/vega-lite.ts +++ b/packages/markdown/src/plugins/vega-lite.ts @@ -5,7 +5,7 @@ import { Plugin, RawFlaggableSpec } from '../factory.js'; import { sanitizedHTML } from '../sanitize.js'; -import { flaggablePlugin, parseBody } from './config.js'; +import { flaggablePlugin, parseHeadAndBody } from './config.js'; import { pluginClassName } from './util.js'; import { inspectVegaSpec, vegaPlugin } from './vega.js'; import { compile, TopLevelSpec } from 'vega-lite'; @@ -21,22 +21,28 @@ export const vegaLitePlugin: Plugin = { const info = token.info.trim(); const content = token.content.trim(); - // Parse body content using the helper function - const parseResult = parseBody(content, info); + // Parse both head and body using the helper function + const { head, body } = parseHeadAndBody(content, info); let flaggableSpec: RawFlaggableSpec; - if (parseResult.error) { + if (body.error) { // Parsing failed flaggableSpec = { spec: null, hasFlags: true, - reasons: [parseResult.error], + reasons: [body.error], + head: { + format: head.format, + pluginName: head.pluginName, + params: Object.fromEntries(head.params), + wasDefaultId: head.wasDefaultId + } }; - } else if (parseResult.spec) { + } else if (body.spec) { // Parsing succeeded, try to compile to Vega try { - const vegaSpec = compile(parseResult.spec); + 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 @@ -46,22 +52,40 @@ export const vegaLitePlugin: Plugin = { flaggableSpec = { spec: vegaSpec.spec as any as TopLevelSpec, hasFlags: inspected.hasFlags, - reasons: inspected.reasons + reasons: inspected.reasons, + head: { + format: head.format, + pluginName: head.pluginName, + params: Object.fromEntries(head.params), + wasDefaultId: head.wasDefaultId + } }; } catch (e) { flaggableSpec = { spec: null, hasFlags: true, reasons: [`failed to compile vega spec: ${e instanceof Error ? e.message : String(e)}`], + head: { + format: head.format, + pluginName: head.pluginName, + params: Object.fromEntries(head.params), + wasDefaultId: head.wasDefaultId + } }; } } else { - // parseResult.spec is null (can happen with empty/null YAML content) - // This is a legitimate case handled by parseBody for empty or invalid content + // 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: parseResult.error ? [parseResult.error] : ['No spec provided'], + reasons: body.error ? [body.error] : ['No spec provided'], + head: { + format: head.format, + pluginName: head.pluginName, + params: Object.fromEntries(head.params), + wasDefaultId: head.wasDefaultId + } }; } From 6a2ade4c6787284ca74039c6208d823ed8d9360e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 03:16:16 +0000 Subject: [PATCH 6/8] Extract convertHeadToSerializable helper to reduce duplication Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- packages/markdown/src/plugins/config.ts | 33 ++++++++++------------ packages/markdown/src/plugins/mermaid.ts | 9 ++---- packages/markdown/src/plugins/vega-lite.ts | 30 ++++---------------- 3 files changed, 22 insertions(+), 50 deletions(-) diff --git a/packages/markdown/src/plugins/config.ts b/packages/markdown/src/plugins/config.ts index 8c7da9a9..223ec833 100644 --- a/packages/markdown/src/plugins/config.ts +++ b/packages/markdown/src/plugins/config.ts @@ -100,6 +100,18 @@ export interface HeadBodyResult { }; } +/** + * Convert ParsedHead to a serializable format for JSON storage + */ +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 fence info. * @param content The fence content @@ -246,12 +258,7 @@ export function flaggablePlugin(pluginName: PluginNames, className: string, f spec: null, hasFlags: true, reasons: [body.error], - head: { - format: head.format, - pluginName: head.pluginName, - params: Object.fromEntries(head.params), - wasDefaultId: head.wasDefaultId - } + head: convertHeadToSerializable(head) }; } else if (body.spec) { // Parsing succeeded, apply flagger if provided @@ -261,24 +268,14 @@ export function flaggablePlugin(pluginName: PluginNames, className: string, f flaggableSpec = { spec: body.spec }; } // Add head information to the result - flaggableSpec.head = { - format: head.format, - pluginName: head.pluginName, - params: Object.fromEntries(head.params), - wasDefaultId: head.wasDefaultId - }; + flaggableSpec.head = convertHeadToSerializable(head); } else { // No spec (shouldn't happen, but handle it) flaggableSpec = { spec: null, hasFlags: true, reasons: ['No spec provided'], - head: { - format: head.format, - pluginName: head.pluginName, - params: Object.fromEntries(head.params), - wasDefaultId: head.wasDefaultId - } + head: convertHeadToSerializable(head) }; } diff --git a/packages/markdown/src/plugins/mermaid.ts b/packages/markdown/src/plugins/mermaid.ts index 034d714d..3345e282 100644 --- a/packages/markdown/src/plugins/mermaid.ts +++ b/packages/markdown/src/plugins/mermaid.ts @@ -48,7 +48,7 @@ import { Plugin, RawFlaggableSpec, IInstance } from '../factory.js'; import { ErrorHandler } from '../renderer.js'; import { sanitizedHTML } from '../sanitize.js'; -import { flaggablePlugin, parseHeadAndBody } 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'; @@ -187,12 +187,7 @@ export const mermaidPlugin: Plugin = { const flaggableSpec = inspectMermaidSpec(spec); // Add head information to the result - flaggableSpec.head = { - format: head.format, - pluginName: head.pluginName, - params: Object.fromEntries(head.params), - wasDefaultId: head.wasDefaultId - }; + 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 68a539b3..813ff5a4 100644 --- a/packages/markdown/src/plugins/vega-lite.ts +++ b/packages/markdown/src/plugins/vega-lite.ts @@ -5,7 +5,7 @@ import { Plugin, RawFlaggableSpec } from '../factory.js'; import { sanitizedHTML } from '../sanitize.js'; -import { flaggablePlugin, parseHeadAndBody } 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'; @@ -32,12 +32,7 @@ export const vegaLitePlugin: Plugin = { spec: null, hasFlags: true, reasons: [body.error], - head: { - format: head.format, - pluginName: head.pluginName, - params: Object.fromEntries(head.params), - wasDefaultId: head.wasDefaultId - } + head: convertHeadToSerializable(head) }; } else if (body.spec) { // Parsing succeeded, try to compile to Vega @@ -53,24 +48,14 @@ export const vegaLitePlugin: Plugin = { spec: vegaSpec.spec as any as TopLevelSpec, hasFlags: inspected.hasFlags, reasons: inspected.reasons, - head: { - format: head.format, - pluginName: head.pluginName, - params: Object.fromEntries(head.params), - wasDefaultId: head.wasDefaultId - } + head: convertHeadToSerializable(head) }; } catch (e) { flaggableSpec = { spec: null, hasFlags: true, reasons: [`failed to compile vega spec: ${e instanceof Error ? e.message : String(e)}`], - head: { - format: head.format, - pluginName: head.pluginName, - params: Object.fromEntries(head.params), - wasDefaultId: head.wasDefaultId - } + head: convertHeadToSerializable(head) }; } } else { @@ -80,12 +65,7 @@ export const vegaLitePlugin: Plugin = { spec: null, hasFlags: true, reasons: body.error ? [body.error] : ['No spec provided'], - head: { - format: head.format, - pluginName: head.pluginName, - params: Object.fromEntries(head.params), - wasDefaultId: head.wasDefaultId - } + head: convertHeadToSerializable(head) }; } From 47edf958f49ce5f8762df51a2e35efceeeb0f67b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 03:19:24 +0000 Subject: [PATCH 7/8] Optimize parseHeadAndBody to avoid double parsing of fence info Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- packages/markdown/src/plugins/config.ts | 41 +++++++++++++++++-------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/markdown/src/plugins/config.ts b/packages/markdown/src/plugins/config.ts index 223ec833..a9179800 100644 --- a/packages/markdown/src/plugins/config.ts +++ b/packages/markdown/src/plugins/config.ts @@ -113,17 +113,15 @@ export function convertHeadToSerializable(head: ParsedHead) { } /** - * Parse body content as JSON or YAML based on fence info. + * Parse body content as JSON or YAML based on format. * @param content The fence content - * @param info The fence info string (used to detect format) - * @returns Parsed object and format metadata + * @param format The format ('json' or 'yaml') + * @returns Parsed object and error if any */ -export function parseBody(content: string, info: string): { +function parseBodyContent(content: string, format: 'json' | 'yaml'): { spec: T | null; - format: 'json' | 'yaml'; error?: string; } { - const { format } = parseFenceInfo(info); const formatName = format === 'yaml' ? 'YAML' : 'JSON'; try { @@ -138,22 +136,41 @@ export function parseBody(content: string, info: string): { if (parsed === null || parsed === undefined) { return { spec: null, - format, error: `Empty or null ${formatName} content` }; } // Return the parsed result - caller is responsible for validating structure - return { spec: parsed as T, format }; + return { spec: parsed as T }; } catch (e) { return { spec: null, - format, 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 @@ -161,7 +178,7 @@ export function parseBody(content: string, info: string): { * @returns Combined head and body parsing result */ export function parseHeadAndBody(content: string, info: string): HeadBodyResult { - // Parse head + // Parse head once const headInfo = parseFenceInfo(info); const head: ParsedHead = { format: headInfo.format, @@ -170,8 +187,8 @@ export function parseHeadAndBody(content: string, info: string): HeadBodyResu wasDefaultId: headInfo.wasDefaultId }; - // Parse body - const bodyResult = parseBody(content, info); + // Parse body using the already-parsed format + const bodyResult = parseBodyContent(content, headInfo.format); return { head, From 39ff0d77f8e4fecbedc3dc764b9c89a9834ca179 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 03:21:34 +0000 Subject: [PATCH 8/8] Add JSDoc documentation to convertHeadToSerializable Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- packages/markdown/src/plugins/config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/markdown/src/plugins/config.ts b/packages/markdown/src/plugins/config.ts index a9179800..00a7c366 100644 --- a/packages/markdown/src/plugins/config.ts +++ b/packages/markdown/src/plugins/config.ts @@ -101,7 +101,10 @@ export interface HeadBodyResult { } /** - * Convert ParsedHead to a serializable format for JSON storage + * 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 {