Generate LikeC4 DSL
The other pages of this section turn LikeC4 DSL into code. This one goes the other way: from data to DSL.
Use it to import an architecture from another source (a service catalog, a cloud inventory, an API registry),
to scaffold a project, or to write the result of a script back as .c4 files.
Everything is exported from likec4/generators/likec4 (the same module is published as @likec4/generators/likec4).
It works on three levels:
| You have | Use |
|---|---|
| The whole model as data | generate(data) |
| One piece: an element, a view, an expression | print(ops.x(), value) |
| Something the model data cannot describe | your own operator, composed from the same building blocks |
Generate a whole model
Section titled “Generate a whole model”generate takes model data and returns the DSL as a string:
import { generate } from 'likec4/generators/likec4'
const dsl = generate({ specification: { elements: { actor: { style: { shape: 'person' } }, system: {}, component: {}, }, tags: ['deprecated'], }, elements: [ { id: 'customer', kind: 'actor', title: 'Customer' }, { id: 'cloud', kind: 'system', title: 'Cloud' }, { id: 'cloud.api', kind: 'component', technology: 'Node.js', tags: ['deprecated'] }, ], relations: [ { source: 'customer', target: 'cloud.api', title: 'uses' }, ], views: { index: { _type: 'element', id: 'index', title: 'Landscape', rules: [{ include: [{ wildcard: true }] }], }, },})specification { element actor { style { shape person } } element system element component
tag deprecated}
model { customer = actor 'Customer'
cloud = system 'Cloud' { api = component { #deprecated technology 'Node.js' } }
customer -> cloud.api 'uses'}
views { view index { title 'Landscape'
include * }}The input has five optional parts:
| Key | Printed as |
|---|---|
specification |
specification { }: element kinds, deployment node kinds, relationship kinds, tags |
elements |
Elements of model { }, nested by their ids: cloud.api goes inside cloud |
relations |
Relationships of model { }, after the elements |
deployment |
deployment { }: nodes, instances and deployment relationships (deployments is accepted too) |
views |
views { }: element, deployment and dynamic views |
elements and relations are arrays or records. Parts that are missing or empty are not printed, and generate({}) returns an empty string.
The shape is the one of a parsed LikeC4 model, so the data of the Model Builder and of a loaded workspace fit as they are:
import { Builder } from 'likec4/model/builder'import { generate } from 'likec4/generators/likec4'
const data = Builder .specification({ elements: { system: {}, component: {} }, }) .model(({ system, component, rel }, _) => _( system('cloud').with( component('api'), component('db'), ), rel('cloud.api', 'cloud.db', 'reads'), ) ) .build()
const dsl = generate(data)specification { element system element component}
model { cloud = system { api = component
db = component }
cloud.api -> cloud.db 'reads'}For a loaded workspace there is a shortcut, likec4.toDSL(): it calls generate with the parsed model.
Indentation
Section titled “Indentation”The second argument sets the indentation: a number of spaces (2 by default) or a string.
import { generate } from 'likec4/generators/likec4'
const dsl = generate( { elements: [ { id: 'cloud', kind: 'system' }, { id: 'cloud.api', kind: 'component' }, ], }, { indent: 4 },)model { cloud = system { api = component }}How it works
Section titled “How it works”generate is a thin wrapper. The work is done by operators, and generate only runs the one that prints a whole model.
value ──▶ operators ──▶ output node ──▶ text
ops.model() materialize(op, value) └─ body('model') resolves indentation └─ lines(2) and line breaks ├─ foreach(element) └─ foreach(relationship)- An operator is a function that prints a part of the DSL. You get one by calling a factory:
ops.model(),ops.expression(),ops.print('->'). Operators take other operators as arguments, so a printer is a tree:ops.model()is abody('model')around the elements and the relationships. - Every operator receives a context:
ctx, the value to print, andout, the place to print to. It appends tooutand returns the context. Operators likeproperty,selectandforeachnarrow the value: they take a part ofctxand run nested operators with that part as theirctx. - The output is a tree of nodes, not a string. A nested block does not know how deep it is: indentation and line breaks are resolved once, when the tree is turned into text by
materialize(printandgeneratecall it). - Empty parts disappear.
body,lines,spaceBetween,indentandmergeskip the operators that printed nothing. An element without properties gets no{ }, a model without relationships gets no empty lines. - Operators for LikeC4 constructs carry a schema. Before printing, they parse the value with it: the value is checked and brought to one form.
What the schemas do
Section titled “What the schemas do”The schemas are what makes the output valid DSL. They reject values that cannot be printed, and they accept convenient forms of the same value:
import { ops, print } from 'likec4/generators/likec4'
const dsl = print(ops.model(), { elements: [ { id: 'cloud', kind: 'system', shape: 'browser', // shortcut for style.shape tags: ['#internal'], // with or without '#' links: ['https://likec4.dev'], // a string or { url, title } }, ], relations: [ { source: 'cloud', // a string or { model: 'cloud' } target: { model: 'cloud' }, description: { md: '**bold**\ntext' }, // a string, { txt } or { md } }, ],})model { cloud = system { #internal link https://likec4.dev style { shape browser } }
cloud -> cloud { description ''' **bold** text ''' }}A value that does not match the schema throws a ZodError:
import { ops, print } from 'likec4/generators/likec4'import * as z from 'zod'
let message = ''try { // 'circle' is not a shape print(ops.models.element(), { id: 'cloud', kind: 'system', shape: 'circle' })} catch (error) { message = z.prettifyError(error as z.ZodError)}✖ Invalid option: expected one of "rectangle"|"person"|"browser"|"mobile"|"cylinder"|"storage"|"queue"|"bucket"|"document"|"component"|"agent" → at shapeelements and relations of a whole model accept an array or a record, so their error is a union error: the message says Invalid input at elements, and the details are in error.issues[0].errors.
The schemas are exported as schemas, grouped the same way as the operators: schemas.model, schemas.specification, schemas.deployment, schemas.views, schemas.expr, schemas.common and schemas.likec4data for the whole input.
They are Zod schemas, so you can validate data yourself, before printing. The input types are there as well, for example schemas.likec4data.Input and schemas.model.element.Input.
Print one piece
Section titled “Print one piece”print runs one operator with a value. ops has an operator for every construct that generate prints, so you can print a fragment and put it into a file you write yourself:
import { ops, print } from 'likec4/generators/likec4'
const expression = print(ops.expression(), { ref: { model: 'cloud.frontend' }, selector: 'descendants',})
const relationship = print(ops.models.relationship(), { source: 'cloud.api', target: 'cloud.db', kind: 'async', title: 'writes', line: 'dashed',})
const view = print(ops.elementView(), { _type: 'element', id: 'index', title: 'Landscape', rules: [ { include: [{ wildcard: true }] }, { direction: 'LR' }, ],})cloud.frontend.**cloud.api -[async]-> cloud.db 'writes' { style { line dashed }}view index { title 'Landscape'
include * autoLayout LeftRight}print(op, value, { indent }) takes the same indentation as generate, and printWithTabIndent(op, value) indents with tabs.
Operators of the language
Section titled “Operators of the language”| Operator | Prints |
|---|---|
ops.likec4data() |
The whole model, this is what generate runs |
ops.specification() |
specification { } |
ops.specifications.elementKind('element')(), .relationshipKind(), .tagSpecification() |
One entry of the specification, from a [name, spec] pair; elementKind('deploymentNode')() prints a deployment node kind |
ops.model() |
model { } with nested elements and relationships |
ops.models.element(), ops.models.relationship() |
One element (its name is the last part of the id), one relationship |
ops.deployment() |
deployment { } |
ops.deployments.instance(), ops.deployments.relationship() |
One deployed instance, one deployment relationship |
ops.views.views() |
views { } from a record of views |
ops.elementView(), ops.deploymentView(), ops.dynamicView() |
One view |
ops.views.* |
Parts of a view: viewRuleInclude, viewRuleStyle, viewRuleGroup, viewRuleAutoLayout, step, … |
ops.expression() |
A predicate expression, as in include and exclude |
ops.expr.* |
Parts of an expression: fqnExpr, relationExpr, whereOperator, … |
ops.props.* |
Properties: tagsProperty, descriptionProperty, linksProperty, metadataProperty, styleProperties, … |
Every operator with a schema has a second form, .noparse(): ops.model.noparse() prints a value that is already parsed and skips the schema. The built-in operators use it for their nested parts, so a value is parsed once.
Build your own operator
Section titled “Build your own operator”The operators of the language are composed from small building blocks, and the same blocks are exported in ops.
Use them to print what the model data cannot describe, or to print your own data directly, without converting it to the LikeC4 shape first.
For example, model data has no extend: by the time the model is parsed, an extension is merged into its element.
To add owners to existing elements without touching their files, print extend blocks:
import { type Op, materialize, ops } from 'likec4/generators/likec4'
type Ownership = { element: string team: string tier?: number tags?: string[] links?: string[]}
const { body, foreach, printProperty, property, select, separateNewLine, spaceBetween, text } = ops
// extend <element> { ... }const extendElement = (): Op<Ownership> => spaceBetween( ops.print('extend'), printProperty('element'), body( // Reuse the operators of the language ops.props.tagsProperty(), select( o => ({ owner: o.team, tier: o.tier }), body('metadata')( property('owner', spaceBetween(ops.print('owner'), text())), property('tier'), ), ), ops.props.linksProperty(), ), )
// model { extend ... extend ... }const extendModel = (): Op<Ownership[]> => body('model')( foreach(extendElement(), separateNewLine(2)), )
const dsl = materialize(extendModel(), [ { element: 'cloud.api', team: 'platform', tier: 1, tags: ['critical'] }, { element: 'cloud.db', team: 'data', links: ['https://wiki.example.com/db'] },])model { extend cloud.api { #critical metadata { owner 'platform' tier 1 } }
extend cloud.db { metadata { owner 'data' } link https://wiki.example.com/db }}Notice what the blocks do for you: cloud.db has no tier and no tags, and nothing is printed for them; the string value is quoted by text; the nested metadata block is indented without knowing its depth.
Building blocks
Section titled “Building blocks”Text
| Operator | Appends |
|---|---|
print(), print(value), print(fn) |
The context, a constant, or the result of fn(ctx). Strings, numbers and booleans only, as they are |
inlineText() |
A string in single quotes on one line; line breaks become spaces, quotes are escaped |
text() |
A string in single quotes; a string with line breaks becomes an indented block |
markdown() |
A string in triple quotes, as an indented block |
markdownOrString() |
A string, { txt } or { md }, with the matching quotes |
space(), eq(), newline(), noop() |
A space, =, a line break, nothing |
Layout
| Operator | Result |
|---|---|
spaceBetween(...ops) |
Outputs on one line, separated by a space |
lines(...ops), lines(n)(...ops) |
Each output on its own line; lines(2) leaves an empty line between them |
body(...ops), body(keyword)(...ops), body(open, close)(...ops) |
Outputs on separate lines, indented and wrapped in { }, keyword { } or open close |
indent(...ops), indent(text) |
Outputs on new lines, one level deeper |
merge(...ops) |
Outputs one after another, with nothing between them |
join({ operations, separator, … }) |
Outputs joined with your own separator, prefix or suffix |
Context
| Operator | Runs nested operators |
|---|---|
property(name, op) |
With ctx[name], if it is not null or undefined. Without op it prints name value |
printProperty(name) |
Prints ctx[name] |
select(fn, ...ops) |
With fn(ctx), if it is not null or undefined |
foreach(op, separator) |
With every item of an iterable ctx; separators are separateComma(), separateNewLine(n), separateWith(s) |
foreachNewLine(...ops) |
With every item, each on its own line |
when(condition, ...ops) |
With the same ctx, if condition(ctx) is true |
guard(isType, ...ops), guard(schema, ...ops) |
With the same ctx, narrowed by a type guard or parsed by a Zod schema (a mismatch with a schema throws) |
withctx(value)(...ops) |
With value instead of ctx |
lazy(() => op) |
With the same ctx; for operators that refer to themselves |
Running
| Function | Result |
|---|---|
materialize(op, value, { indent }) |
The text |
materialize(op), materialize({ op, indent }) |
A function (value) => string, to print many values with one operator |
materialize, withctx and indent are also exported next to ops, and so are the types: Op<A> is an operator for a value of type A, Ctx<A> is its context, Output is the output node, and ctxOf<typeof op> gives the value type of an operator.
An operator with a schema
Section titled “An operator with a schema”To write an operator from scratch, or to give one a schema, use ops.base: operation(fn) wraps a function ({ ctx, out }) => … into an operator, and zodOp(schema)(op) adds parsing in front of an operator.
With a schema, your printer rejects wrong data the same way the built-in operators do:
import { ops, print } from 'likec4/generators/likec4'import * as z from 'zod'
const { body, printProperty, property, spaceBetween, text } = ops
const service = z.object({ name: z.string().regex(/^[a-z][a-z0-9-]*$/), description: z.string().optional(), language: z.enum(['go', 'ts', 'java']).optional(),})
// <name> = service { ... }const serviceElement = ops.base.zodOp(service)( spaceBetween( printProperty('name'), ops.print('= service'), body( ops.props.descriptionProperty(), property('language', spaceBetween(ops.print('technology'), text())), ), ),)
const dsl = print(serviceElement(), { name: 'billing', description: 'Invoices', language: 'go',})billing = service { description 'Invoices' technology 'go'}print(serviceElement(), { name: 'Billing Service' }) throws a ZodError: the name would not be a valid identifier.
Limits
Section titled “Limits”The generator prints what is in the data, and model data is not the source files.
- Comments, formatting and the order of statements are lost. The data has none of them. Elements are printed parents first, relationships after all elements.
- One output for the whole model. A model read from many files, or extended across them, is printed as a single document;
extendblocks are already merged into their elements. - Some parts of a model are not printed by
generate: definitions of global predicates and styles, custom colors of the specification, elements imported from other projects,diffblocks and manual layouts. These keys of the data are ignored without an error. - Three view rules are not supported yet:
global style,global predicateandrank. A view with one of them cannot be printed, the generator throwsnot implemented. This applies tolikec4.toDSL()as well. - Values equal to the kind’s defaults are omitted.
generatedropsdescription,summary,technology,tagsand style properties of an element when they repeat the specification of its kind. - Empty lines between blocks are indented. They contain the indentation of their block; trim trailing whitespace if it matters to you.
So do not use the generator to reformat or to edit existing files: read a file, print it, and you get the same model in a different text.
To reformat sources in place, use likec4 format; to apply changes to them, use the model edits of the API.