clone

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

main
diagramzip / renderers / edge / src / languages / structurizr.ts
structurizr.ts 20.0K
1import { RenderError } from '../runtime/errors'23export type StructurizrKind = 'person' | 'softwareSystem' | 'container' | 'component' | 'deploymentNode' | 'infrastructureNode' | 'containerInstance' | 'softwareSystemInstance'4export interface StructurizrElement { id: string; kind: StructurizrKind; name: string; description: string; technology: string; tags: string[]; parent?: string; modelRef?: string; environment?: string }5export interface StructurizrRelationship { from: string; to: string; description: string; technology: string }6export interface StructurizrStyle { background?: string; color?: string; fontSize?: number; shape?: string; opacity?: number }7export interface StructurizrView { kind: string; key: string; scope?: string; includes: string[]; relationships: StructurizrRelationship[]; direction?: string; animations: string[] }8export interface StructurizrWorkspace { name: string; elements: StructurizrElement[]; relationships: StructurizrRelationship[]; views: StructurizrView[]; styles: Map<string, StructurizrStyle>; themes: string[] }910const MAX_LINES = 20_00011const MAX_ELEMENTS = 4_00012const MAX_RELATIONSHIPS = 12_00013const MAX_NESTING = 4814const DECLARATIONS = new Set(['person', 'softwareSystem', 'softwaresystem', 'container', 'component', 'deploymentNode', 'infrastructureNode', 'containerInstance', 'softwareSystemInstance'])15const VIEW_KINDS = new Set(['systemlandscape', 'systemcontext', 'container', 'component', 'dynamic', 'deployment'])1617function fail(message: string): never { throw new RenderError(422, 'invalid_structurizr', message) }1819function stripComments(source: string): string[] {20 const lines: string[] = []; let block = false21 for (const original of source.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n')) {22 let line = ''; let quote = false23 for (let i = 0; i < original.length; i++) {24 const two = original.slice(i, i + 2)25 if (block) { if (two === '*/') { block = false; i++ } continue }26 if (!quote && two === '/*') { block = true; i++; continue }27 const ch = original[i]!28 if (ch === '"') { quote = !quote; line += ch; continue }29 if (!quote && ch === '#' && /\s|$/.test(original[i + 1] ?? '')) break30 line += ch31 }32 lines.push(line)33 }34 if (block) fail('Unterminated block comment.')35 return lines36}3738function args(text: string): string[] {39 const result: string[] = []; let value = ''; let quote = false; let escaped = false40 const flush = () => { if (value.trim()) result.push(value.trim()); value = '' }41 for (const ch of text.trim()) {42 if (escaped) { value += ch; escaped = false; continue }43 if (quote && ch === '\\') { value += ch; escaped = true; continue }44 if (ch === '"') { quote = !quote; value += ch; continue }45 if (!quote && /\s/.test(ch)) { flush(); continue }46 value += ch47 }48 if (quote) fail('Unterminated string literal.')49 flush(); return result50}5152function unquote(value: string | undefined): string {53 const v = value ?? ''54 if (v.startsWith('"') && v.endsWith('"')) return v.slice(1, -1).replaceAll('\\"', '"').replaceAll('\\n', '\n').replaceAll('\\\\', '\\')55 return v56}5758function declaration(line: string): { id: string; kind: string; values: string[] } | undefined {59 const match = line.match(/^(?:(?<id>[A-Za-z_][\w.-]*)\s*=\s*)?(?<kind>[A-Za-z][\w]*)\s+(?<rest>.*)$/)60 if (!match || !DECLARATIONS.has(match.groups!.kind!)) return undefined61 return { id: match.groups!.id ?? '', kind: match.groups!.kind!, values: args(match.groups!.rest!) }62}6364function relation(line: string): StructurizrRelationship | undefined {65 const match = line.match(/^(\S+)\s*->\s*(\S+)(?:\s+("(?:\\.|[^"\\])*"))?(?:\s+("(?:\\.|[^"\\])*"))?$/)66 return match ? { from: match[1]!, to: match[2]!, description: unquote(match[3]), technology: unquote(match[4]) } : undefined67}6869function styleValue(value: string): string { return unquote(value) }70const COLORS = /^(?:#[0-9a-f]{3,8}|(?:black|white|red|green|blue|yellow|orange|purple|gray|grey|transparent))$/i71const SHAPES = new Set(['rectangle', 'roundrectangle', 'roundedbox', 'circle', 'hexagon', 'person', 'node', 'webbrowser', 'mobiledevicelandscape', 'cylinder'])72function validateStyle(tag: string, key: string, value: string): void {73 if ((key === 'background' || key === 'color') && !COLORS.test(value)) fail(`Unsupported ${key} color for style ${tag}.`)74 if (key === 'shape' && !SHAPES.has(value.toLowerCase())) fail(`Unsupported shape for style ${tag}.`)75 if (key === 'fontSize' && (!Number.isInteger(Number(value)) || Number(value) < 1 || Number(value) > 96)) fail(`Invalid fontSize for style ${tag}.`)76 if (key === 'opacity' && (!Number.isInteger(Number(value)) || Number(value) < 0 || Number(value) > 100)) fail(`Invalid opacity for style ${tag}.`)77}7879export function parseStructurizr(source: string): StructurizrWorkspace {80 const lines = stripComments(source); if (lines.length > MAX_LINES) fail('Structurizr source has too many lines.')81 const workspace: StructurizrWorkspace = { name: '', elements: [], relationships: [], views: [], styles: new Map(), themes: [] }82 const stack: Array<{ kind: string; value?: unknown; owner?: string }> = []83 let currentView: StructurizrView | undefined84 let currentStyle: { tag: string; relationship: boolean } | undefined85 let generated = 086 const context = () => stack.at(-1)?.kind ?? ''87 const owner = () => [...stack].reverse().find(item => item.owner)?.owner88 const push = (item: { kind: string; value?: unknown; owner?: string }) => {89 if (stack.length >= MAX_NESTING) fail('Structurizr nesting is too deep.')90 stack.push(item)91 }92 const addElement = (item: StructurizrElement) => {93 if (++generated > MAX_ELEMENTS) fail('Structurizr workspace has too many elements.')94 if (workspace.elements.some(existing => existing.id === item.id)) fail(`Duplicate element identifier: ${item.id}.`)95 workspace.elements.push(item)96 }97 const parseBlockOpen = (line: string): string => line.replace(/\s*\{\s*$/, '').trim()98 for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {99 let line = lines[lineNumber]!.trim(); if (!line) continue100 if (line === '}') {101 const closed = stack.pop()?.kind102 if (closed === 'view') currentView = undefined103 if (closed === 'style') currentStyle = undefined104 continue105 }106 const opens = line.endsWith('{'); if (opens) line = parseBlockOpen(line)107 if (line.startsWith('workspace')) {108 const values = args(line.slice('workspace'.length)); workspace.name = unquote(values[0]);109 if (opens) push({ kind: 'workspace' }); continue110 }111 if (line === 'model') { if (opens) push({ kind: 'model' }); continue }112 if (line === 'views') { if (opens) push({ kind: 'views' }); continue }113 if (/^group\s+/i.test(line) && opens) { const values = args(line.replace(/^group\s+/i, '')); push({ kind: 'group', value: unquote(values[0]) }); continue }114 if (/^(?:[A-Za-z_][\w.-]*\s*=\s*)?deploymentEnvironment\b/i.test(line) && opens) { const match = line.match(/^(?:(?<id>[A-Za-z_][\w.-]*)\s*=\s*)?deploymentEnvironment\s*(?<rest>.*)$/i)!; const values = args(match.groups!.rest!); push({ kind: 'deployment', value: unquote(values[0]) }); continue }115 if (/^(?:[A-Za-z_][\w.-]*\s*=\s*)?deploymentNode\b/i.test(line) && opens) {116 const d = declaration(line); if (!d) fail(`Invalid deployment node at line ${lineNumber + 1}.`); const values = d.values117 const id = d.id || `deployment_${workspace.elements.length}`118 addElement({ id, kind: 'deploymentNode', name: unquote(values[0]), description: unquote(values[1]), technology: unquote(values[2]), tags: [], environment: stack.find(item => item.kind === 'deployment')?.value as string | undefined, parent: owner() && workspace.elements.find(item => item.id === owner() && item.kind === 'deploymentNode') ? owner() : undefined }); push({ kind: 'deploymentNode', owner: id }); continue119 }120 if (/^(?:[A-Za-z_][\w.-]*\s*=\s*)?infrastructureNode\b/i.test(line) && opens) {121 const d = declaration(line); if (!d) fail(`Invalid infrastructure node at line ${lineNumber + 1}.`); const values = d.values122 const id = d.id || `infrastructure_${workspace.elements.length}`123 addElement({ id, kind: 'infrastructureNode', name: unquote(values[0]), description: unquote(values[1]), technology: unquote(values[2]), tags: [], environment: stack.find(item => item.kind === 'deployment')?.value as string | undefined, parent: owner() && workspace.elements.find(item => item.id === owner() && item.kind === 'deploymentNode') ? owner() : undefined }); push({ kind: 'deploymentNode', owner: id }); continue124 }125 if (context() === 'views' && opens && !/^styles\b/i.test(line)) {126 const parts = args(line); const kind = parts.shift()?.toLowerCase() ?? ''; if (!VIEW_KINDS.has(kind)) fail(`Unsupported Structurizr view: ${kind}.`)127 const view: StructurizrView = { kind, key: '', scope: '', includes: [], relationships: [], animations: [] }128 if (kind === 'systemlandscape') view.key = unquote(parts[0]) || 'SystemLandscape'129 else if (kind === 'systemcontext' || kind === 'container' || kind === 'component' || kind === 'dynamic') { view.scope = unquote(parts[0]); view.key = unquote(parts[1]) || `${kind}_${view.scope}` }130 else { view.scope = unquote(parts[1]) || unquote(parts[0]); view.key = unquote(parts[2]) || unquote(parts[1]) || `${kind}_${view.scope}` }131 workspace.views.push(view); currentView = view; push({ kind: 'view' }); continue132 }133 const d = declaration(line)134 if (d && ['containerInstance', 'softwareSystemInstance'].includes(d.kind)) { const values = d.values; const target = workspace.elements.find(item => item.id === unquote(values[0])); if (!target) fail(`Unknown instance target: ${values[0]}.`); addElement({ id: d.id || `${d.kind}_${workspace.elements.length}`, kind: d.kind as StructurizrKind, name: target.name, description: unquote(values[1]) || target.description, technology: unquote(values[2]) || target.technology, tags: [...target.tags], modelRef: target.id, environment: stack.find(item => item.kind === 'deployment')?.value as string | undefined, parent: owner() && workspace.elements.find(item => item.id === owner() && item.kind === 'deploymentNode') ? owner() : undefined }); continue }135 if (d && DECLARATIONS.has(d.kind)) {136 const kind = d.kind.toLowerCase() === 'softwaresystem' ? 'softwareSystem' : d.kind as StructurizrKind137 const values = d.values; const current = owner() ? workspace.elements.find(item => item.id === owner()) : undefined138 const parent = current && ['softwareSystem', 'container'].includes(current.kind) ? current.id : undefined139 const id = d.id || `${kind}_${workspace.elements.length}`140 addElement({ id, kind, name: unquote(values[0]), description: unquote(values[1]), technology: unquote(values[2]), tags: unquote(values[3]).split(',').map(tag => tag.trim()).filter(Boolean), parent });141 if (opens) push({ kind: kind as string, owner: id }); continue142 }143 if (/^tags\s+/i.test(line)) { const tags = args(line.replace(/^tags\s+/i, '')).map(unquote); const element = owner() ? workspace.elements.find(item => item.id === owner()) : undefined; if (element) element.tags.push(...tags); continue }144 if (/^description\s+/i.test(line)) { const element = owner() ? workspace.elements.find(item => item.id === owner()) : undefined; if (element) element.description = unquote(args(line.replace(/^description\s+/i, ''))[0]); else fail(`Description has no owning element at line ${lineNumber + 1}.`); continue }145 if (line.startsWith('views')) continue146 if (currentView) {147 if (/^include\s+\*/i.test(line)) { currentView.includes.push('*'); continue }148 if (/^include\s+/i.test(line)) { currentView.includes.push(...args(line.replace(/^include\s+/i, '')).map(unquote)); continue }149 if (/^auto?layout\b/i.test(line)) { currentView.direction = args(line.replace(/^auto?layout\s*/i, ''))[0]; continue }150 if (line.toLowerCase() === 'animation' && opens) { push({ kind: 'animation' }); continue }151 const dynamicRelation = relation(line); if (dynamicRelation) { currentView.relationships.push(dynamicRelation); continue }152 }153 if (context() === 'animation') { const ids = line.replace(/^[0-9]+\s*:\s*/, '').split(/\s+/).filter(token => /^[A-Za-z_][\w.-]*$/.test(token)); currentView?.animations.push(...ids); continue }154 if (context() === 'model' || context() === 'deployment' || context() === 'deploymentNode') { const r = relation(line); if (r) { if (workspace.relationships.push(r) > MAX_RELATIONSHIPS) fail('Structurizr workspace has too many relationships.'); continue } }155 if (/^theme\s+/i.test(line) || /^themes\s+/i.test(line)) {156 const themes = args(line.replace(/^themes?\s+/i, '')).map(unquote)157 for (const theme of themes) if (theme !== 'default' && !/^https:\/\/static\.structurizr\.com\/themes\/(?:amazon-web-services|google-cloud-platform|kubernetes|microsoft-azure|oracle-cloud-infrastructure)(?:[-/][\w.-]+)*\/?$/i.test(theme)) fail(`Unsupported Structurizr theme: ${theme}.`)158 workspace.themes.push(...themes); continue159 }160 if (context() === 'styles' && opens && /^(element|relationship)\s+/i.test(line)) { const values = args(line.replace(/^(?:element|relationship)\s+/i, '')); currentStyle = { tag: unquote(values[0]), relationship: /^relationship/i.test(line) }; push({ kind: 'style' }); continue }161 if (context() === 'style' && currentStyle) {162 const match = line.match(/^(\w+)\s+(.+)$/); const key = match?.[1] ?? ''; const value = styleValue(match?.[2] ?? ''); if (['fontSize', 'opacity', 'background', 'color', 'shape'].includes(key)) { validateStyle(currentStyle.tag, key, value); const style = workspace.styles.get(currentStyle.tag) ?? {}; if (key === 'fontSize' || key === 'opacity') (style as Record<string, unknown>)[key] = Number(value); else (style as Record<string, unknown>)[key] = value; workspace.styles.set(currentStyle.tag, style) } continue163 }164 if (line === 'styles' && opens) { push({ kind: 'styles' }); continue }165 if (line.startsWith('!')) fail(`Structurizr directive is not supported: ${line.split(/\s/)[0]}.`)166 if (line !== 'include *' && line !== 'autoLayout' && line !== 'autolayout') fail(`Unsupported Structurizr syntax at line ${lineNumber + 1}: ${line.slice(0, 100)}`)167 }168 if (stack.length) fail('Unclosed Structurizr block.')169 if (!workspace.views.length) fail('Empty diagram, does not have any view.')170 return workspace171}172173function plantEscape(value: string): string { return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n') }174function alias(value: string): string { return `s_${value.replace(/[^A-Za-z0-9_]/g, '_')}` }175function implicitTag(kind: StructurizrKind): string { return ({ person: 'Person', softwareSystem: 'Software System', container: 'Container', component: 'Component', deploymentNode: 'Deployment Node', infrastructureNode: 'Infrastructure Node', containerInstance: 'Container', softwareSystemInstance: 'Software System' } as Record<StructurizrKind, string>)[kind] }176177export function lowerStructurizr(workspace: StructurizrWorkspace, options: Record<string, string>): string {178 if (Object.keys(options).some(key => key !== 'view-key' && key !== 'output')) fail('Unsupported Structurizr option.')179 const viewKey = options['view-key']; const view = viewKey ? workspace.views.find(item => item.key === viewKey) : workspace.views[0]180 if (!view) fail(`Unable to find view for key: ${viewKey}.`)181 const output = (options.output ?? 'diagram').trim() || 'diagram'; if (output !== 'diagram' && output !== 'legend') fail(`Unknown output option: ${output}.`)182 if (output === 'legend') return '@startuml\nlegend\n|= Tag |= Background |\n' + [...workspace.styles.entries()].map(([tag, style]) => `| ${plantEscape(tag)} | ${plantEscape(style.background ?? '')} |`).join('\n') + '\nendlegend\n@enduml'183 const byId = new Map(workspace.elements.map(item => [item.id, item]))184 const selected = new Set<string>()185 if (view.kind === 'systemlandscape') { for (const item of workspace.elements) if (item.kind === 'person' || item.kind === 'softwareSystem') selected.add(item.id) }186 else if (view.kind === 'systemcontext') { if (view.scope && byId.has(view.scope)) selected.add(view.scope) }187 else if (view.kind === 'container') { if (view.scope && byId.has(view.scope)) for (const item of workspace.elements) if (item.parent === view.scope && item.kind === 'container') selected.add(item.id) }188 else if (view.kind === 'component') { if (view.scope && byId.has(view.scope)) for (const item of workspace.elements) if (item.parent === view.scope && item.kind === 'component') selected.add(item.id) }189 else if (view.kind === 'deployment') { const env = view.scope; for (const item of workspace.elements) if (['deploymentNode', 'infrastructureNode', 'containerInstance', 'softwareSystemInstance'].includes(item.kind) && (!env || item.environment?.toLowerCase() === env.toLowerCase())) selected.add(item.id) }190 else for (const item of workspace.elements) selected.add(item.id)191 for (const id of view.includes) if (id !== '*' && byId.has(id)) selected.add(id)192 const viewRelationships = view.kind === 'dynamic' ? view.relationships : workspace.relationships193 if (view.kind === 'dynamic') {194 for (const item of viewRelationships) { selected.add(item.from); selected.add(item.to) }195 } else if (view.kind !== 'deployment') {196 const seed = new Set(selected)197 const relatedKindAllowed = (id: string): boolean => {198 const element = byId.get(id)199 if (!element) return false200 if (view.kind === 'systemcontext') return element.kind === 'person' || element.kind === 'softwareSystem'201 if (view.kind === 'container') return element.kind === 'person' || element.kind === 'softwareSystem' || element.kind === 'container'202 if (view.kind === 'component') return element.kind !== 'component' || element.parent === view.scope203 return true204 }205 for (const item of viewRelationships) {206 if (seed.has(item.from) && relatedKindAllowed(item.to)) selected.add(item.to)207 if (seed.has(item.to) && relatedKindAllowed(item.from)) selected.add(item.from)208 }209 }210 const lines = ['@startuml', view.direction?.toLowerCase() === 'lr' ? 'left to right direction' : 'top to bottom direction']211 const styleIds = new Map<string, string>(); let styleIndex = 0212 for (const [tag, style] of workspace.styles) { const id = `style_${styleIndex++}`; styleIds.set(tag, id); if (style.background) lines.push(`skinparam rectangle<<${id}>> BackgroundColor ${style.background}`); if (style.color) lines.push(`skinparam rectangle<<${id}>> FontColor ${style.color}`); if (style.fontSize) lines.push(`skinparam rectangle<<${id}>> FontSize ${style.fontSize}`); if (style.opacity !== undefined) lines.push(`skinparam rectangle<<${id}>> Transparency ${100 - style.opacity}`); if (style.shape === 'roundrectangle' || style.shape === 'roundedbox') lines.push(`skinparam rectangle<<${id}>> RoundCorner 20`) }213 for (const element of workspace.elements) {214 if (!selected.has(element.id)) continue215 const text = `${plantEscape(element.name)}${element.description ? `\\n[${plantEscape(element.description)}]` : ''}`216 const tags = [implicitTag(element.kind), ...element.tags].filter((tag, index, all) => all.indexOf(tag) === index)217 const stereotypes = tags.map(tag => styleIds.get(tag)).filter(Boolean).map(id => `<<${id}>>`).join('')218 const style = tags.map(tag => workspace.styles.get(tag)).find(Boolean)219 const color = style?.background ? ` ${style.background}` : ''220 const shape = tags.map(tag => workspace.styles.get(tag)?.shape).find(Boolean)?.toLowerCase()221 const type = element.kind === 'person' || shape === 'person' ? 'actor' : element.kind === 'infrastructureNode' || element.kind === 'deploymentNode' || shape === 'node' ? 'node' : shape === 'circle' ? 'circle' : shape === 'cylinder' ? 'database' : 'rectangle'222 lines.push(`${type} "${text}" as ${alias(element.id)}${stereotypes}${color}`)223 }224 const relationships = viewRelationships225 for (const item of relationships) {226 if (!selected.has(item.from) || !selected.has(item.to)) continue227 const label = plantEscape(item.description + (item.technology ? ` [${item.technology}]` : ''))228 lines.push(`${alias(item.from)} --> ${alias(item.to)}${label ? ` : ${label}` : ''}`)229 }230 lines.push('@enduml'); return lines.join('\n')231}