clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / renderers / edge / src / adapters / c4-lowerer.ts
c4-lowerer.ts 6.7K
1import { RenderError } from '../runtime/errors'23type EntityKind = 'person' | 'rectangle' | 'database' | 'queue'45function splitArgs(value: string): string[] {6 const result: string[] = []7 let start = 0; let depth = 0; let quote = false; let escaped = false8 for (let i = 0; i < value.length; i++) {9 const char = value[i]10 if (escaped) { escaped = false; continue }11 if (char === '\\' && quote) { escaped = true; continue }12 if (char === '"') { quote = !quote; continue }13 if (!quote && char === '(') depth++14 if (!quote && char === ')') depth--15 if (!quote && depth === 0 && char === ',') { result.push(value.slice(start, i).trim()); start = i + 1 }16 }17 result.push(value.slice(start).trim())18 return result19}2021function unquote(value = ''): string {22 const trimmed = value.trim()23 if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1).replaceAll('\\"', '"')24 return trimmed25}2627function alias(value: string, macro: string): string {28 const result = unquote(value)29 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(result)) {30 throw new RenderError(400, 'invalid_c4', `${macro} has an invalid alias.`)31 }32 return result33}3435function c4Text(value: string): string {36 return unquote(value).replaceAll('\r', '').replaceAll('\n', '\\n').replaceAll('"', '\\"')37}3839function tags(value: string): string {40 const result = unquote(value).split(',').map(tag => tag.trim()).filter(Boolean)41 if (result.some(tag => !/^[A-Za-z0-9_.-]+$/.test(tag))) {42 throw new RenderError(400, 'invalid_c4', 'C4 tags may contain only letters, numbers, dots, underscores, and hyphens.')43 }44 return result.join(',')45}4647function call(line: string): { name: string; args: string[] } | undefined {48 const match = line.trim().match(/^([A-Za-z][A-Za-z0-9_]*)\s*\((.*)\)\s*$/)49 return match ? { name: match[1]!, args: splitArgs(match[2]!) } : undefined50}5152function entity(name: string, args: string[], kind: EntityKind): string {53 if (args.length < 2) throw new RenderError(400, 'invalid_c4', `${name} requires an alias and label.`)54 const lowerName = name.toLowerCase()55 const containerLike = lowerName.startsWith('container') || lowerName.startsWith('component')56 const entityAlias = alias(args[0]!, name)57 const label = c4Text(args[1]!)58 const technology = containerLike ? c4Text(args[2] ?? '') : ''59 const description = c4Text(args[containerLike ? 3 : 2] ?? '')60 const entityTags = tags(args[containerLike ? 5 : 4] ?? '')61 const external = lowerName.endsWith('_ext')62 const semantic = lowerName.replace(/_ext$/, '').replace('systemdb', 'system-db').replace('systemqueue', 'system-queue').replace('containerdb', 'container-db').replace('containerqueue', 'container-queue').replace('componentdb', 'component-db').replace('componentqueue', 'component-queue') + (external ? '-external' : '')63 const stereotype = ` <<c4-${semantic}${entityTags ? `,${entityTags}` : ''}>>`64 const color = semantic === 'person' ? '#08427B' : semantic === 'person-external' ? '#686868' : external ? '#999999' : semantic === 'system' ? '#1168BD' : '#438DD5'65 const details = [technology, description].filter(Boolean).map(value => `[${value}]`).join('\\n')66 const entityLabel = details ? `${label}\\n${details}` : label67 return `${kind} "${entityLabel}" as ${entityAlias}${stereotype} ${color}`68}6970function relationship(name: string, args: string[]): string {71 if (args.length < 3) throw new RenderError(400, 'invalid_c4', `${name} requires a source, destination, and label.`)72 const from = alias(args[0]!, name); const to = alias(args[1]!, name); const label = c4Text(args[2]!)73 const arrow = name.toLowerCase() === 'rel_back' ? '<--' : name.toLowerCase() === 'rel_neighbor' ? '..' : '-->'74 const technology = c4Text(args[3] ?? '')75 return `${from} ${arrow} ${to} : ${label}${technology ? ` [${technology}]` : ''}`76}7778const entityKinds: Record<string, EntityKind> = {79 person: 'person', person_ext: 'person', system: 'rectangle', system_ext: 'rectangle', systemdb: 'database', systemdb_ext: 'database', systemqueue: 'queue', systemqueue_ext: 'queue',80 container: 'rectangle', container_ext: 'rectangle', containerdb: 'database', containerdb_ext: 'database', containerqueue: 'queue', containerqueue_ext: 'queue',81 component: 'rectangle', component_ext: 'rectangle', componentdb: 'database', componentdb_ext: 'database', componentqueue: 'queue', componentqueue_ext: 'queue',82}8384const boundaries = new Set(['boundary', 'enterprise_boundary', 'system_boundary', 'container_boundary', 'component_boundary'])85const relationships = new Set(['rel', 'rel_back', 'rel_neighbor'])8687export function lowerC4(source: string): string {88 const lines = source.replace(/!include\s+(?:<C4\/)?C4_(?:Context|Container|Component|Deployment|Dynamic|Sequence)(?:\.puml)?(?:>)?/gi, '').split(/\r?\n/)89 const output: string[] = []90 let boundaryDepth = 091 for (const line of lines) {92 const trimmed = line.trim()93 if (!trimmed || trimmed.startsWith("'") || /^@start|^@end/i.test(trimmed)) {94 output.push(line)95 continue96 }97 const layout = trimmed.match(/^LAYOUT_(LANDSCAPE|LR|LEFT_RIGHT|TOP_DOWN|TB|RIGHT_LEFT|RL|BOTTOM_UP|BT)\s*$/i)98 if (layout) {99 const direction = layout[1]!.toUpperCase()100 output.push(['LR', 'LEFT_RIGHT', 'LANDSCAPE'].includes(direction) ? 'left to right direction' : ['RL', 'RIGHT_LEFT'].includes(direction) ? 'right to left direction' : ['BT', 'BOTTOM_UP'].includes(direction) ? 'bottom to top direction' : 'top to bottom direction')101 continue102 }103 if (/^SHOW_LEGEND\s*\(/i.test(trimmed)) { output.push('legend', '| C4-PlantUML | lowered C4 view |', 'endlegend'); continue }104 if (/^Boundary_End\s*\(/i.test(trimmed)) { if (boundaryDepth === 0) throw new RenderError(400, 'invalid_c4', 'Boundary_End has no open boundary.'); boundaryDepth--; output.push('}'); continue }105 const parsed = call(trimmed)106 if (!parsed) {107 if (/^!(?:include|import|theme|load)/i.test(trimmed)) throw new RenderError(400, 'unsupported_c4', `Unsupported C4 directive: ${trimmed.slice(0, 80)}`)108 output.push(line); continue109 }110 const name = parsed.name.toLowerCase()111 if (entityKinds[name]) { output.push(entity(parsed.name, parsed.args, entityKinds[name])); continue }112 if (relationships.has(name)) { output.push(relationship(parsed.name, parsed.args)); continue }113 if (boundaries.has(name)) {114 if (parsed.args.length < 2) throw new RenderError(400, 'invalid_c4', `${parsed.name} requires an alias and label.`)115 output.push(`rectangle "${c4Text(parsed.args[1]!)}" as ${alias(parsed.args[0]!, parsed.name)} {`); boundaryDepth++; continue116 }117 throw new RenderError(400, 'unsupported_c4', `Unsupported C4 macro: ${parsed.name}`)118 }119 if (boundaryDepth !== 0) throw new RenderError(400, 'invalid_c4', 'C4 boundary is not closed.')120 return output.join('\n')121}