add markdown support

This commit is contained in:
2026-03-08 21:56:08 +01:00
parent fe37c9046d
commit 4022975e4d
8 changed files with 403 additions and 101 deletions

152
src/lib/configTypes.ts Normal file
View File

@@ -0,0 +1,152 @@
/**
* Registry of config node types. Each type defines syntax highlighting,
* insert-menu blocks, and how to render the content (after Nunjucks) for the renderer.
* Add a new entry here and wire its language in ConfigNode to add a new config type.
*/
export type ConfigTypeId = 'plantuml' | 'markdown'
/** A single insert option (label + snippet to insert at cursor). */
export type InsertBlock = { label: string; snippet: string }
/** A group of insert options (e.g. "Diagram" with startuml, actor, etc.). */
export type InsertBlockGroup = { label: string; items: InsertBlock[] }
export type InsertBlockOrGroup = InsertBlock | InsertBlockGroup
function isGroup(b: InsertBlockOrGroup): b is InsertBlockGroup {
return 'items' in b && Array.isArray((b as InsertBlockGroup).items)
}
export type ConfigType = {
id: ConfigTypeId
label: string
/** CodeMirror language key; used to pick the extension in ConfigNode. */
language: ConfigTypeId
/** Options under Insert → [type-specific]. Can be flat blocks or groups. */
insertBlocks: InsertBlockOrGroup[]
/** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */
render: (content: string) => Promise<string>
}
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
{ label: 'Actor', snippet: 'actor ' },
{ label: 'Participant', snippet: 'participant "" as ' },
{ label: 'Arrow', snippet: ' -> ' },
{ label: 'Note', snippet: 'note right of ' },
{
label: 'Nunjucks',
items: [
{ label: 'Variable {{ }}', snippet: '{{ }}' },
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
{ label: 'set', snippet: '{% set = %}' },
{ label: 'extends', snippet: '{% extends "" %}' },
{ label: 'include', snippet: '{% include "" %}' },
{ label: 'import', snippet: '{% import "" as %}' },
],
},
]
const MARKDOWN_INSERT_BLOCKS: InsertBlockOrGroup[] = [
{ label: 'Heading 1', snippet: '# ' },
{ label: 'Heading 2', snippet: '## ' },
{ label: 'Heading 3', snippet: '### ' },
{ label: 'Bold', snippet: '****' },
{ label: 'Italic', snippet: '**' },
{ label: 'Code inline', snippet: '``' },
{ label: 'Code block', snippet: '```\n\n```' },
{ label: 'Link', snippet: '[]()' },
{ label: 'Image', snippet: '![]()' },
{ label: 'List item', snippet: '- ' },
{ label: 'Blockquote', snippet: '> ' },
{
label: 'Nunjucks',
items: [
{ label: 'Variable {{ }}', snippet: '{{ }}' },
{ label: 'if / endif', snippet: '{% if %}\n \n{% endif %}' },
{ label: 'for / endfor', snippet: '{% for in %}\n \n{% endfor %}' },
{ label: 'set', snippet: '{% set = %}' },
],
},
]
/** Markdown to HTML using dynamic import to avoid loading if only PlantUML is used. */
async function renderMarkdown(content: string): Promise<string> {
const { marked } = await import('marked')
const html = typeof marked.parse === 'function' ? await marked.parse(content) : (marked as (s: string) => string)(content)
return typeof html === 'string' ? html : String(html)
}
export const CONFIG_TYPES: ConfigType[] = [
{
id: 'plantuml',
label: 'Diagram',
language: 'plantuml',
insertBlocks: PLANTUML_INSERT_BLOCKS,
render: async (content: string) => {
const res = await fetch(KROKI_PLANTUML_SVG, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: content,
})
if (!res.ok) {
const err = await res.text()
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
}
return res.text()
},
},
{
id: 'markdown',
label: 'Markdown',
language: 'markdown',
insertBlocks: MARKDOWN_INSERT_BLOCKS,
render: renderMarkdown,
},
]
export const CONFIG_TYPE_IDS = CONFIG_TYPES.map((t) => t.id)
export const DEFAULT_CONFIG_TYPE_ID: ConfigTypeId = 'plantuml'
export function getConfigType(id: ConfigTypeId): ConfigType {
const t = CONFIG_TYPES.find((c) => c.id === id)
if (!t) throw new Error(`Unknown config type: ${id}`)
return t
}
export function getDefaultContentForConfigType(configTypeId: ConfigTypeId): string {
if (configTypeId === 'plantuml') return '@startuml\n\n@enduml\n'
if (configTypeId === 'markdown') return ''
return ''
}
/** Flatten insert blocks for iteration: either a single block or a group's items. */
export function* iterateInsertBlocks(blocks: InsertBlockOrGroup[]): Generator<InsertBlock> {
for (const b of blocks) {
if (isGroup(b)) {
for (const item of b.items) yield item
} else {
yield b
}
}
}
export { isGroup }
/** Content from config node data (backward compat: content ?? plantuml). */
export function getConfigContent(data: Record<string, unknown> | undefined): string {
if (!data) return ''
const content = data.content ?? data.plantuml
return typeof content === 'string' ? content : ''
}
/** Config type id from data (default plantuml). */
export function getConfigTypeId(data: Record<string, unknown> | undefined): ConfigTypeId {
if (!data || data.configType == null) return 'plantuml'
const id = data.configType
return CONFIG_TYPE_IDS.includes(id as ConfigTypeId) ? (id as ConfigTypeId) : 'plantuml'
}

View File

@@ -72,7 +72,7 @@ export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number
}
const DEFAULT_DATA: Record<string, any> = {
config: { plantuml: '@startuml\n\n@enduml\n', title: '' },
config: { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: '' },
render: {},
variable: { value: '', valueType: 'string' },
function: {
@@ -91,7 +91,7 @@ export function getDefaultDataForType(type: string, newId?: string): any {
/** Data for Reset action: clears code completely for config/function; same as default for others */
export function getResetDataForType(type: string, nodeId?: string): any {
if (type === 'config') {
return { plantuml: '@startuml\n\n@enduml\n', title: nodeId ? `${nodeId}` : '' }
return { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: nodeId ? `${nodeId}` : '' }
}
if (type === 'function') {
return { body: '' }