clone

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

main
diagramzip / renderers / edge / src / languages / erd.ts
erd.ts 14.1K
1/**2 * Source-to-DOT lowering for BurntSushi/erd's small `.er` language.3 *4 * This module deliberately stops at DOT. GraphViz execution belongs to the5 * renderer unit, which makes the parser/model useful in isolation and keeps6 * user input away from the execution boundary.7 */89export type Cardinality = 'zero-one' | 'one' | 'zero-plus' | 'one-plus'1011export interface ErdOption { name: string; value: string }12export interface ErdAttribute { field: string; pk: boolean; fk: boolean; options: ErdOption[] }13export interface ErdEntity { name: string; attributes: ErdAttribute[]; headerOptions: ErdOption[]; options: ErdOption[] }14export interface ErdRelation { entity1: string; entity2: string; card1: Cardinality; card2: Cardinality; options: ErdOption[] }15export interface ErdDocument { entities: ErdEntity[]; relations: ErdRelation[]; titleOptions: ErdOption[]; headerOptions: ErdOption[]; entityOptions: ErdOption[]; relationOptions: ErdOption[] }1617export class ErdSyntaxError extends Error {18 constructor(message: string, public readonly line: number) {19 super(`ERD line ${line}: ${message}`)20 this.name = 'ErdSyntaxError'21 }22}2324const CARDINALITIES: Record<string, Cardinality> = { '?': 'zero-one', '1': 'one', '*': 'zero-plus', '+': 'one-plus' }25const ALLOWED_OPTIONS = new Set(['label', 'color', 'bgcolor', 'size', 'font', 'border', 'border-color', 'cellspacing', 'cellborder', 'cellpadding', 'text-alignment'])26const INTEGER_OPTIONS = new Set(['border', 'cellspacing', 'cellborder', 'cellpadding'])2728function optionDefaults(name: 'title' | 'header' | 'entity' | 'relationship'): ErdOption[] {29 if (name === 'title') return [{ name: 'size', value: '30' }]30 if (name === 'header') return [{ name: 'size', value: '16' }]31 if (name === 'entity') return [{ name: 'border', value: '0' }, { name: 'cellborder', value: '1' }, { name: 'cellspacing', value: '0' }, { name: 'cellpadding', value: '4' }, { name: 'font', value: 'Helvetica' }]32 return []33}3435function mergeOptions(specific: ErdOption[], inherited: ErdOption[], defaults: ErdOption[] = []): ErdOption[] {36 const result = new Map<string, ErdOption>()37 for (const option of [...defaults, ...inherited, ...specific]) result.set(option.name, option)38 return [...result.values()]39}4041function parseOptions(text: string, line: number): ErdOption[] {42 const trimmed = text.trim()43 if (!trimmed) return []44 if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) throw new ErdSyntaxError('options must be enclosed in {...}', line)45 const body = trimmed.slice(1, -1).trim()46 if (!body) return []47 const options: ErdOption[] = []48 let i = 049 while (i < body.length) {50 while (/\s/.test(body[i] ?? '')) i++51 const start = i52 while (/[A-Za-z-]/.test(body[i] ?? '')) i++53 const name = body.slice(start, i)54 if (!ALLOWED_OPTIONS.has(name)) throw new ErdSyntaxError(`option '${name || body.slice(start)}' does not exist`, line)55 while (/\s/.test(body[i] ?? '')) i++56 if (body[i++] !== ':') throw new ErdSyntaxError(`expected ':' after option '${name}'`, line)57 while (/\s/.test(body[i] ?? '')) i++58 if (body[i++] !== '"') throw new ErdSyntaxError(`option '${name}' value must be quoted`, line)59 let value = ''60 while (i < body.length && body[i] !== '"') value += body[i++]61 if (body[i++] !== '"') throw new ErdSyntaxError(`unterminated value for option '${name}'`, line)62 if (!value) throw new ErdSyntaxError(`option '${name}' value cannot be empty`, line)63 if (INTEGER_OPTIONS.has(name) && (!/^\d+$/.test(value) || Number(value) > 255)) throw new ErdSyntaxError(`option '${name}' must be an integer from 0 to 255`, line)64 if (name === 'size' && (!/^\d+(?:\.\d+)?$/.test(value) || !Number.isFinite(Number(value)) || Number(value) <= 0)) throw new ErdSyntaxError("option 'size' must be a positive finite number", line)65 if (name === 'text-alignment' && !['left', 'center', 'right'].includes(value.toLowerCase())) throw new ErdSyntaxError("option 'text-alignment' must be left, center, or right", line)66 if (['color', 'bgcolor', 'border-color'].includes(name) && !/^[^\u0000-\u001f\u007f"<>&]+$/.test(value)) throw new ErdSyntaxError(`option '${name}' has an invalid color`, line)67 options.push({ name, value })68 while (/\s/.test(body[i] ?? '')) i++69 if (i === body.length) break70 if (body[i++] !== ',') throw new ErdSyntaxError(`expected ',' after option '${name}'`, line)71 while (/\s/.test(body[i] ?? '')) i++72 if (i === body.length) break73 }74 return options75}7677function unquote(value: string, line: number): string {78 const trimmed = value.trim()79 if (!trimmed) throw new ErdSyntaxError('identifier cannot be empty', line)80 const quote = trimmed[0]81 if (quote === '"' || quote === "'" || quote === '`') {82 if (trimmed.at(-1) !== quote || trimmed.length < 3) throw new ErdSyntaxError('unterminated quoted identifier', line)83 return trimmed.slice(1, -1)84 }85 if (!/^[A-Za-z0-9_]+$/.test(trimmed)) throw new ErdSyntaxError(`invalid identifier '${trimmed}'`, line)86 return trimmed87}8889function splitOptions(text: string): [string, string] {90 const index = text.indexOf('{')91 return index < 0 ? [text.trim(), ''] : [text.slice(0, index).trim(), text.slice(index).trim()]92}9394function entityClose(text: string, line: number): number {95 let quote = ''96 for (let index = 1; index < text.length; index++) {97 const char = text[index]!98 if (quote) {99 if (char === quote) quote = ''100 continue101 }102 if (char === '"' || char === "'" || char === '`') quote = char103 else if (char === ']') return index104 }105 throw new ErdSyntaxError('unterminated entity declaration', line)106}107108export function parseErd(source: string): ErdDocument {109 const document: ErdDocument = { entities: [], relations: [], titleOptions: [], headerOptions: [], entityOptions: [], relationOptions: [] }110 let current: ErdEntity | undefined111 let inGlobalPreamble = true112 const relationLines: number[] = []113 for (const record of logicalRecords(source)) {114 const lineNo = record.line115 const raw = record.text116 const directive = /^(title|header|entity|relationship)\b(.*)$/i.exec(raw)117 if (inGlobalPreamble && directive) {118 const options = parseOptions(directive[2]!.trim(), lineNo)119 const target = ({ title: 'titleOptions', header: 'headerOptions', entity: 'entityOptions', relationship: 'relationOptions' } as const)[directive[1]!.toLowerCase() as 'title' | 'header' | 'entity' | 'relationship']120 document[target] = options121 continue122 }123 inGlobalPreamble = false124 if (raw.startsWith('[')) {125 const close = entityClose(raw, lineNo)126 const entity = unquote(raw.slice(1, close), lineNo)127 const [, optionText] = splitOptions(raw.slice(close + 1).trim())128 const specific = parseOptions(optionText, lineNo)129 current = {130 name: entity,131 attributes: [],132 headerOptions: mergeOptions(specific, document.headerOptions, optionDefaults('header')),133 options: mergeOptions(specific, document.entityOptions, optionDefaults('entity')),134 }135 document.entities.push(current)136 continue137 }138 const relation = /^(.+?)\s*([?1*+])--([?1*+])\s*(.+?)(?:\s+(\{.*\}))?$/.exec(raw)139 if (relation) {140 const left = unquote(relation[1]!, lineNo)141 const right = unquote(relation[4]!, lineNo)142 const card1 = CARDINALITIES[relation[2]!]143 const card2 = CARDINALITIES[relation[3]!]144 if (!card1 || !card2) throw new ErdSyntaxError('invalid relationship cardinality', lineNo)145 relationLines.push(lineNo)146 document.relations.push({ entity1: left, entity2: right, card1, card2, options: mergeOptions(relation[5] ? parseOptions(relation[5], lineNo) : [], document.relationOptions) })147 continue148 }149 if (!current) throw new ErdSyntaxError('attribute comes before first entity', lineNo)150 const attrMatch = /^([*+\s]*)(?:"([^"]+)"|'([^']+)'|`([^`]+)`|([^\s{]+))(?:\s+(\{.*\}))?$/.exec(raw)151 if (!attrMatch) throw new ErdSyntaxError('invalid attribute declaration', lineNo)152 const field = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? attrMatch[5]153 current.attributes.push({ field: field!, pk: attrMatch[1]!.includes('*'), fk: attrMatch[1]!.includes('+'), options: mergeOptions(attrMatch[6] ? parseOptions(attrMatch[6], lineNo) : [], [], [{ name: 'text-alignment', value: 'left' }]) })154 }155 const names = new Set(document.entities.map(entity => entity.name))156 for (const [index, relation] of document.relations.entries()) {157 const line = relationLines[index] ?? 0158 if (!names.has(relation.entity1)) throw new ErdSyntaxError(`unknown entity '${relation.entity1}' in relationship`, line)159 if (!names.has(relation.entity2)) throw new ErdSyntaxError(`unknown entity '${relation.entity2}' in relationship`, line)160 }161 return document162}163164function logicalRecords(source: string): Array<{ text: string; line: number }> {165 const records: Array<{ text: string; line: number }> = []166 const lines = source.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n')167 let buffer = ''168 let startLine = 0169 let quote = ''170 let braces = 0171 for (let index = 0; index < lines.length; index++) {172 let fragment = ''173 const physical = lines[index]!174 for (let offset = 0; offset < physical.length; offset++) {175 const char = physical[offset]!176 if (quote) {177 fragment += char178 if (char === quote) quote = ''179 continue180 }181 if (char === '"' || char === "'" || char === '`') {182 quote = char183 fragment += char184 continue185 }186 if (char === '#') break187 if (char === '{') braces++188 if (char === '}') braces--189 if (braces < 0) throw new ErdSyntaxError("unexpected '}'", index + 1)190 fragment += char191 }192 const trimmed = fragment.trim()193 if (trimmed) {194 if (!buffer) startLine = index + 1195 buffer += `${buffer ? ' ' : ''}${trimmed}`196 }197 if (buffer && braces === 0 && !quote) {198 records.push({ text: buffer, line: startLine })199 buffer = ''200 }201 }202 if (buffer || braces > 0 || quote) throw new ErdSyntaxError('unterminated options or quoted value', startLine || lines.length)203 return records204}205206function dotQuote(value: string): string { return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n')}"` }207function html(value: string): string { return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;') }208function option(options: ErdOption[], name: string): string | undefined { return options.find(item => item.name === name)?.value }209function attrValue(options: ErdOption[], name: string, fallback: string): string { return option(options, name) ?? fallback }210function cardinality(card: Cardinality): string { return ({ 'zero-one': '{0,1}', one: '1', 'zero-plus': '0..N', 'one-plus': '1..N' })[card] }211function font(text: string, options: ErdOption[]): string {212 const attrs = [213 option(options, 'color') ? `COLOR="${html(option(options, 'color')!)}"` : '',214 option(options, 'font') ? `FACE="${html(option(options, 'font')!)}"` : '',215 option(options, 'size') ? `POINT-SIZE="${html(option(options, 'size')!)}"` : '',216 ].filter(Boolean)217 return attrs.length ? `<FONT ${attrs.join(' ')}>${text}</FONT>` : text218}219function align(value: string): string { return ({ left: 'LEFT', center: 'CENTER', right: 'RIGHT' })[value.toLowerCase()] ?? 'LEFT' }220function cellAttributes(options: ErdOption[]): string {221 return [222 `ALIGN="${align(attrValue(options, 'text-alignment', 'left'))}"`,223 option(options, 'bgcolor') ? `BGCOLOR="${html(option(options, 'bgcolor')!)}"` : '',224 option(options, 'border') ? `BORDER="${html(option(options, 'border')!)}"` : '',225 option(options, 'border-color') ? `COLOR="${html(option(options, 'border-color')!)}"` : '',226 option(options, 'cellborder') ? `CELLBORDER="${html(option(options, 'cellborder')!)}"` : '',227 option(options, 'cellpadding') ? `CELLPADDING="${html(option(options, 'cellpadding')!)}"` : '',228 option(options, 'cellspacing') ? `CELLSPACING="${html(option(options, 'cellspacing')!)}"` : '',229 ].filter(Boolean).join(' ')230}231232function entityLabel(entity: ErdEntity): string {233 const headerLabel = option(entity.headerOptions, 'label')234 const header = font(`<B>${html(entity.name)}</B>${headerLabel ? ` ${html(headerLabel)}` : ''}`, entity.headerOptions)235 const rows = [`<TR><TD COLSPAN="1">${header}</TD></TR>`]236 for (const attr of entity.attributes) {237 let text = html(attr.field)238 if (attr.pk) text = `<U>${text}</U>`239 if (attr.fk) text = `<I>${text}</I>`240 text = font(text, attr.options)241 const label = option(attr.options, 'label')242 if (label) text += font(` [${html(label)}]`, attr.options)243 rows.push(`<TR><TD ${cellAttributes(attr.options)}>${text}</TD></TR>`)244 }245 const table = `<TABLE BORDER="${attrValue(entity.options, 'border', '0')}"${option(entity.options, 'border-color') ? ` COLOR="${html(option(entity.options, 'border-color')!)}"` : ''}${option(entity.options, 'bgcolor') ? ` BGCOLOR="${html(option(entity.options, 'bgcolor')!)}"` : ''} CELLBORDER="${attrValue(entity.options, 'cellborder', '1')}" CELLSPACING="${attrValue(entity.options, 'cellspacing', '0')}" CELLPADDING="${attrValue(entity.options, 'cellpadding', '4')}">${rows.join('')}</TABLE>`246 return font(table, entity.options)247}248249export function erdToDot(document: ErdDocument): string {250 const lines = ['graph ERD {', ' rankdir=LR;', ' splines=spline;', ' node [shape=plaintext];', ' edge [color=gray50, minlen=2, style=dashed];']251 const title = option(document.titleOptions, 'label')252 const titleOptions = mergeOptions(document.titleOptions, [], optionDefaults('title'))253 if (title) lines.push(` graph [label=<${font(html(title), titleOptions)}>, labelloc=t, labeljust=l];`)254 for (const entity of document.entities) lines.push(` ${dotQuote(entity.name)} [label=<${entityLabel(entity)}>];`)255 for (const relation of document.relations) {256 const label = option(relation.options, 'label')257 const relationLabel = label ? font(html(` ${label} `), relation.options) : ''258 lines.push(` ${dotQuote(relation.entity1)} -- ${dotQuote(relation.entity2)} [taillabel=<${font(cardinality(relation.card1), relation.options)}>, headlabel=<${font(cardinality(relation.card2), relation.options)}>${label ? `, label=<${relationLabel}>` : ''}];`)259 }260 return `${lines.join('\n')}\n}`261}