From a2611caecb06789f00575699f6996b2e736b76ab Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Sun, 12 Jul 2026 15:18:24 +0200 Subject: [PATCH 1/2] feat: Add an esdm documentation command that renders the model as a Markdown tree. Add `esdm documentation`, which writes the resolved model as a Markdown directory tree to --output. Each element becomes a page at its containment path, so the tree mirrors the reference notation (#8): the element esdm:library/cataloging/book/acquired lives at library/cataloging/book/acquired.md, and supplying a base URL turns a reference into a link into the tree. A container element is written as a directory with a README.md index; a leaf becomes .md. The command mirrors view and glossary: -d/--directory to read (default the current directory) and an optional [path] to narrow to a subtree, keeping full paths so pages still line up with their references. It refuses to write into a non-empty directory unless --force clears it first, so the output always mirrors the model with no orphaned pages. This first slice renders the structure: each page's name, reference, description, and an index of its children grouped by kind. The kind-specific detail sections, the cross-links between related elements, and the documentation page for the command follow next. The Go package is named docgen rather than documentation because the toolchain excludes every file in a package literally named `documentation` from the build; the CLI command is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T9ZRRMdA7iKk6in8u12rK6 --- cmd/esdm/commands/docgen/build.go | 372 ++++++++++++++++++++++ cmd/esdm/commands/docgen/build_test.go | 259 +++++++++++++++ cmd/esdm/commands/docgen/command.go | 63 ++++ cmd/esdm/commands/docgen/documentation.go | 14 + cmd/esdm/commands/docgen/render.go | 109 +++++++ cmd/esdm/commands/docgen/write.go | 80 +++++ cmd/esdm/commands/docgen/write_test.go | 77 +++++ cmd/esdm/commands/root/command.go | 2 + 8 files changed, 976 insertions(+) create mode 100644 cmd/esdm/commands/docgen/build.go create mode 100644 cmd/esdm/commands/docgen/build_test.go create mode 100644 cmd/esdm/commands/docgen/command.go create mode 100644 cmd/esdm/commands/docgen/documentation.go create mode 100644 cmd/esdm/commands/docgen/render.go create mode 100644 cmd/esdm/commands/docgen/write.go create mode 100644 cmd/esdm/commands/docgen/write_test.go diff --git a/cmd/esdm/commands/docgen/build.go b/cmd/esdm/commands/docgen/build.go new file mode 100644 index 0000000..103c4e5 --- /dev/null +++ b/cmd/esdm/commands/docgen/build.go @@ -0,0 +1,372 @@ +package docgen + +import ( + "fmt" + "path" + "sort" + "strings" + + "github.com/thenativeweb/esdm/ast" + "github.com/thenativeweb/esdm/model" + "github.com/thenativeweb/esdm/modelpath" +) + +// Page is one rendered Markdown file: a relative slash path within the +// output tree and its content. +type Page struct { + Path string + Content string +} + +// documented is the shared surface every model view exposes through +// its embedded base: a name and a description. +type documented interface { + Name() ast.Node + Description() ast.Node +} + +// scoped is a documented view that additionally carries a scope, which +// is every kind except domain and context-mapping. +type scoped interface { + documented + Scope() ast.Node +} + +// node is one element in the containment tree. A node with children is +// written as a directory with a README.md index; a node without +// children is written as .md. +type node struct { + kind string + name string + description string + segs []string + children []*node +} + +// Build turns the resolved model into the set of pages to write, +// narrowed to the region identified by p. The empty path selects the +// whole model and adds a root index; a non-empty path selects a single +// element and its subtree, keeping the full containment paths so the +// pages still line up with their references. An unknown or too-deep +// path segment is rejected as invalid input, mirroring esdm view. +func Build(m *model.Model, p modelpath.Path) ([]Page, error) { + top := buildTopLevel(m) + + if len(p.Segments) == 0 { + pages := []Page{{Path: "README.md", Content: renderRoot(top)}} + collectPages(top, &pages) + return pages, nil + } + + target, err := narrow(top, p.Segments) + if err != nil { + return nil, err + } + + var pages []Page + collectPages([]*node{target}, &pages) + return pages, nil +} + +// buildTopLevel builds the domains and, if the model has any context +// mappings, the context-mapping namespace that holds them. +func buildTopLevel(m *model.Model) []*node { + var top []*node + for _, domain := range sortedByName(m.Domains) { + domainName := bareName(domain) + top = append(top, newNode("domain", []string{domainName}, domain, buildDomainChildren(m, domainName))) + } + + mappings := buildContextMappings(m) + if len(mappings) > 0 { + top = append(top, &node{ + kind: "context-mappings", + name: "context-mapping", + segs: []string{"context-mapping"}, + children: mappings, + }) + } + + return top +} + +// buildDomainChildren builds every element scoped to the given domain. +func buildDomainChildren(m *model.Model, domain string) []*node { + var out []*node + + for _, view := range selectSorted(m.Subdomains, func(v model.SubdomainView) bool { return inScope(v, domain, "") }) { + out = append(out, newNode("subdomain", []string{domain, bareName(view)}, view, nil)) + } + for _, view := range selectSorted(m.EventHandlers, func(v model.EventHandlerView) bool { return inScope(v, domain, "") }) { + out = append(out, newNode("event-handler", []string{domain, bareName(view)}, view, nil)) + } + for _, view := range selectSorted(m.Policies, func(v model.PolicyView) bool { return inScope(v, domain, "") }) { + out = append(out, newNode("policy", []string{domain, bareName(view)}, view, nil)) + } + for _, view := range selectSorted(m.ExternalSystems, func(v model.ExternalSystemView) bool { return inScope(v, domain, "") }) { + out = append(out, newNode("external-system", []string{domain, bareName(view)}, view, nil)) + } + for _, view := range selectSorted(m.Extensions.DomainStorytelling.Stories, func(v model.DomainStoryView) bool { return inScope(v, domain, "") }) { + out = append(out, newNode("domain-story", []string{domain, bareName(view)}, view, nil)) + } + for _, view := range selectSorted(m.ProcessManagers, func(v model.ProcessManagerView) bool { return inScope(v, domain, "") }) { + name := bareName(view) + out = append(out, newNode("process-manager", []string{domain, name}, view, buildFeatures(m, []string{domain, name}, "process-manager", domain, "", name))) + } + for _, view := range selectSorted(m.BoundedContexts, func(v model.BoundedContextView) bool { return inScope(v, domain, "") }) { + name := bareName(view) + out = append(out, newNode("bounded-context", []string{domain, name}, view, buildBoundedContextChildren(m, domain, name))) + } + + return out +} + +// buildBoundedContextChildren builds every element scoped to the given +// bounded context, including its free-standing events. +func buildBoundedContextChildren(m *model.Model, domain, boundedContext string) []*node { + var out []*node + + for _, view := range selectSorted(m.Aggregates, func(v model.AggregateView) bool { return inScope(v, domain, boundedContext) }) { + name := bareName(view) + out = append(out, newNode("aggregate", []string{domain, boundedContext, name}, view, buildAggregateChildren(m, domain, boundedContext, name))) + } + for _, view := range selectSorted(m.DynamicConsistencyBoundaries, func(v model.DynamicConsistencyBoundaryView) bool { return inScope(v, domain, boundedContext) }) { + name := bareName(view) + out = append(out, newNode("dynamic-consistency-boundary", []string{domain, boundedContext, name}, view, buildDcbChildren(m, domain, boundedContext, name))) + } + for _, view := range selectSorted(m.ReadModels, func(v model.ReadModelView) bool { return inScope(v, domain, boundedContext) }) { + name := bareName(view) + out = append(out, newNode("read-model", []string{domain, boundedContext, name}, view, buildFeatures(m, []string{domain, boundedContext, name}, "read-model", domain, boundedContext, name))) + } + for _, view := range selectSorted(m.Queries, func(v model.QueryView) bool { return inScope(v, domain, boundedContext) }) { + out = append(out, newNode("query", []string{domain, boundedContext, bareName(view)}, view, nil)) + } + for _, view := range selectSorted(m.Entities, func(v model.EntityView) bool { return inScope(v, domain, boundedContext) }) { + out = append(out, newNode("entity", []string{domain, boundedContext, bareName(view)}, view, nil)) + } + for _, view := range selectSorted(m.ValueObjects, func(v model.ValueObjectView) bool { return inScope(v, domain, boundedContext) }) { + out = append(out, newNode("value-object", []string{domain, boundedContext, bareName(view)}, view, nil)) + } + for _, view := range selectSorted(m.DomainServices, func(v model.DomainServiceView) bool { return inScope(v, domain, boundedContext) }) { + out = append(out, newNode("domain-service", []string{domain, boundedContext, bareName(view)}, view, nil)) + } + for _, view := range selectSorted(m.Actors, func(v model.ActorView) bool { return inScope(v, domain, boundedContext) }) { + out = append(out, newNode("actor", []string{domain, boundedContext, bareName(view)}, view, nil)) + } + for _, view := range selectSorted(m.Events, func(v model.EventView) bool { + return inScope(v, domain, boundedContext) && model.ScopeText(v.Scope(), "aggregate") == "" + }) { + out = append(out, newNode("event", []string{domain, boundedContext, bareName(view)}, view, nil)) + } + + return out +} + +// buildAggregateChildren builds the commands, events, and features of +// an aggregate. +func buildAggregateChildren(m *model.Model, domain, boundedContext, aggregate string) []*node { + var out []*node + + for _, view := range selectSorted(m.Commands, func(v model.CommandView) bool { + return inScope(v, domain, boundedContext) && model.ScopeText(v.Scope(), "aggregate") == aggregate + }) { + out = append(out, newNode("command", []string{domain, boundedContext, aggregate, bareName(view)}, view, nil)) + } + for _, view := range selectSorted(m.Events, func(v model.EventView) bool { + return inScope(v, domain, boundedContext) && model.ScopeText(v.Scope(), "aggregate") == aggregate + }) { + out = append(out, newNode("event", []string{domain, boundedContext, aggregate, bareName(view)}, view, nil)) + } + out = append(out, buildFeatures(m, []string{domain, boundedContext, aggregate}, "aggregate", domain, boundedContext, aggregate)...) + + return out +} + +// buildDcbChildren builds the commands and features of a dynamic +// consistency boundary. +func buildDcbChildren(m *model.Model, domain, boundedContext, dcb string) []*node { + var out []*node + + for _, view := range selectSorted(m.Commands, func(v model.CommandView) bool { + return inScope(v, domain, boundedContext) && model.ScopeText(v.Scope(), "dynamicConsistencyBoundary") == dcb + }) { + out = append(out, newNode("command", []string{domain, boundedContext, dcb, bareName(view)}, view, nil)) + } + out = append(out, buildFeatures(m, []string{domain, boundedContext, dcb}, "dynamic-consistency-boundary", domain, boundedContext, dcb)...) + + return out +} + +// buildFeatures builds the features that target the element at +// parentSegs, identified by its kind, domain, bounded context, and +// name. +func buildFeatures(m *model.Model, parentSegs []string, kind, domain, boundedContext, target string) []*node { + var out []*node + for _, feature := range sortedByName(m.Extensions.GivenWhenThen.Features) { + featureKind, featureDomain, featureBC, featureTarget := featureParent(feature) + if featureKind != kind || featureDomain != domain || featureBC != boundedContext || featureTarget != target { + continue + } + segs := append(append([]string{}, parentSegs...), bareName(feature)) + out = append(out, newNode("feature", segs, feature, nil)) + } + return out +} + +// buildContextMappings builds the context-mapping leaves, addressed +// through the context-mapping namespace because they have no enclosing +// domain. +func buildContextMappings(m *model.Model) []*node { + var out []*node + for _, mapping := range sortedByName(m.ContextMappings) { + out = append(out, newNode("context-mapping", []string{"context-mapping", bareName(mapping)}, mapping, nil)) + } + return out +} + +// featureParent reports the kind, domain, bounded context, and name of +// the element a feature targets. The bounded context is empty for a +// process-manager target, which is domain-scoped. +func featureParent(feature model.FeatureView) (kind, domain, boundedContext, target string) { + scope := feature.Scope() + domain = model.ScopeText(scope, "domain") + + switch { + case scope.HasField("aggregate"): + return "aggregate", domain, model.ScopeText(scope, "boundedContext"), model.ScopeText(scope, "aggregate") + case scope.HasField("dynamicConsistencyBoundary"): + return "dynamic-consistency-boundary", domain, model.ScopeText(scope, "boundedContext"), model.ScopeText(scope, "dynamicConsistencyBoundary") + case scope.HasField("processManager"): + return "process-manager", domain, "", model.ScopeText(scope, "processManager") + case scope.HasField("readModel"): + return "read-model", domain, model.ScopeText(scope, "boundedContext"), model.ScopeText(scope, "readModel") + } + + return "", domain, "", "" +} + +// narrow walks the top-level nodes segment by segment, returning the +// node the path identifies. Its error wording mirrors esdm view so +// both commands reject invalid input the same way. +func narrow(nodes []*node, segments []string) (*node, error) { + var matched *node + current := nodes + + for i, seg := range segments { + var found *node + for _, candidate := range current { + if candidate.name == seg { + found = candidate + break + } + } + if found == nil { + if i == 0 { + return nil, fmt.Errorf("no entity %q under model root", seg) + } + return nil, fmt.Errorf("no entity %q under %q", seg, strings.Join(segments[:i], "/")) + } + matched = found + current = found.children + } + + return matched, nil +} + +// collectPages walks the tree and appends one page per node. +func collectPages(nodes []*node, pages *[]Page) { + for _, n := range nodes { + *pages = append(*pages, Page{Path: n.filePath(), Content: renderPage(n)}) + collectPages(n.children, pages) + } +} + +// filePath is the node's relative slash path: a README.md index inside +// a directory when the node has children, otherwise a .md leaf. +func (n *node) filePath() string { + joined := strings.Join(n.segs, "/") + if len(n.children) > 0 { + return joined + "/README.md" + } + return joined + ".md" +} + +// newNode builds a node from a view, reading its name and description. +func newNode(kind string, segs []string, view documented, children []*node) *node { + name, _ := view.Name().Text() + description, _ := view.Description().Text() + return &node{ + kind: kind, + name: name, + description: strings.TrimSpace(description), + segs: segs, + children: children, + } +} + +// relLink is the relative link from the page at fromPath to the page +// at toPath, both relative slash paths within the tree. +func relLink(fromPath, toPath string) string { + fromDir := path.Dir(fromPath) + var fromParts []string + if fromDir != "." && fromDir != "" { + fromParts = strings.Split(fromDir, "/") + } + toParts := strings.Split(toPath, "/") + + shared := 0 + for shared < len(fromParts) && shared < len(toParts)-1 && fromParts[shared] == toParts[shared] { + shared++ + } + + var rel []string + for range fromParts[shared:] { + rel = append(rel, "..") + } + rel = append(rel, toParts[shared:]...) + return strings.Join(rel, "/") +} + +// sortedByName returns the map's views ordered by their bare name. +func sortedByName[V documented](m map[string]V) []V { + out := make([]V, 0, len(m)) + for _, v := range m { + out = append(out, v) + } + sort.Slice(out, func(i, j int) bool { + return bareName(out[i]) < bareName(out[j]) + }) + return out +} + +// selectSorted returns the matching views ordered by their bare name. +func selectSorted[V scoped](m map[string]V, match func(V) bool) []V { + var out []V + for _, v := range m { + if match(v) { + out = append(out, v) + } + } + sort.Slice(out, func(i, j int) bool { + return bareName(out[i]) < bareName(out[j]) + }) + return out +} + +// bareName reads a view's bare name. +func bareName(view documented) string { + name, _ := view.Name().Text() + return name +} + +// inScope reports whether a scoped view sits in the given domain and, +// when boundedContext is non-empty, that bounded context. +func inScope[V scoped](view V, domain, boundedContext string) bool { + if model.ScopeText(view.Scope(), "domain") != domain { + return false + } + if boundedContext != "" && model.ScopeText(view.Scope(), "boundedContext") != boundedContext { + return false + } + return true +} diff --git a/cmd/esdm/commands/docgen/build_test.go b/cmd/esdm/commands/docgen/build_test.go new file mode 100644 index 0000000..f6d5ad5 --- /dev/null +++ b/cmd/esdm/commands/docgen/build_test.go @@ -0,0 +1,259 @@ +package docgen_test + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/thenativeweb/esdm/cmd/esdm/commands/docgen" + "github.com/thenativeweb/esdm/model" + "github.com/thenativeweb/esdm/modelpath" + "github.com/thenativeweb/esdm/runner" +) + +const modelYAML = `apiVersion: schema.esdm.io/core/v1 +kind: domain +name: library +--- +apiVersion: schema.esdm.io/core/v1 +kind: bounded-context +name: cataloging +scope: + domain: library +--- +apiVersion: schema.esdm.io/core/v1 +kind: aggregate +name: book +scope: + domain: library + boundedContext: cataloging +identifiedBy: + source: state + field: isbn +state: + type: object + properties: + title: { type: string } + isbn: { type: string } + required: [title, isbn] +--- +apiVersion: schema.esdm.io/core/v1 +kind: command +name: acquire +scope: + domain: library + boundedContext: cataloging + aggregate: book +data: + type: object + properties: + title: { type: string } + isbn: { type: string } + required: [title, isbn] +publishes: + - acquired +--- +apiVersion: schema.esdm.io/core/v1 +kind: event +name: acquired +scope: + domain: library + boundedContext: cataloging + aggregate: book +data: + type: object + properties: + title: { type: string } + isbn: { type: string } + required: [title, isbn] +--- +apiVersion: schema.esdm.io/core/v1 +kind: read-model +name: books +scope: + domain: library + boundedContext: cataloging +paradigm: tabular +schema: + type: array + items: + type: object + properties: + title: { type: string } + isbn: { type: string } + required: [title, isbn] +projections: + - boundedContext: cataloging + aggregate: book + event: acquired + rule: Append a row. +--- +apiVersion: schema.esdm.io/core/v1 +kind: query +name: list-books +scope: + domain: library + boundedContext: cataloging +readModel: books +result: + type: array + items: + type: object + properties: + title: { type: string } + isbn: { type: string } + required: [title, isbn] +` + +func loadModel(t *testing.T, yaml string) *model.Model { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "model.esdm.yaml"), []byte(yaml), 0o644)) + _, m, err := runner.RunWithModel(context.Background(), dir) + require.NoError(t, err) + require.NotNil(t, m) + return m +} + +func paths(pages []docgen.Page) []string { + out := make([]string, 0, len(pages)) + for _, page := range pages { + out = append(out, page.Path) + } + sort.Strings(out) + return out +} + +func pageByPath(t *testing.T, pages []docgen.Page, path string) docgen.Page { + t.Helper() + for _, page := range pages { + if page.Path == path { + return page + } + } + require.Failf(t, "page not found", "no page at %q", path) + return docgen.Page{} +} + +func TestBuild(t *testing.T) { + t.Run("places every element at its containment path", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{}) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{ + "README.md", + "library/README.md", + "library/cataloging/README.md", + "library/cataloging/book/README.md", + "library/cataloging/book/acquire.md", + "library/cataloging/book/acquired.md", + "library/cataloging/books.md", + "library/cataloging/list-books.md", + }, paths(pages)) + }) + + t.Run("writes a container as a README index and a leaf as a named file", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{}) + require.NoError(t, err) + + names := paths(pages) + assert.Contains(t, names, "library/cataloging/book/README.md") + assert.Contains(t, names, "library/cataloging/book/acquired.md") + }) + + t.Run("narrows to a single element and its subtree, keeping full paths", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{Segments: []string{"library", "cataloging", "book"}}) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{ + "library/cataloging/book/README.md", + "library/cataloging/book/acquire.md", + "library/cataloging/book/acquired.md", + }, paths(pages)) + }) + + t.Run("renders the reference and contents on a container page", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{}) + require.NoError(t, err) + + book := pageByPath(t, pages, "library/cataloging/book/README.md") + assert.Contains(t, book.Content, "# book") + assert.Contains(t, book.Content, "Reference: `esdm:library/cataloging/book` (aggregate)") + assert.Contains(t, book.Content, "## Commands") + assert.Contains(t, book.Content, "[acquire](acquire.md)") + assert.Contains(t, book.Content, "## Events") + assert.Contains(t, book.Content, "[acquired](acquired.md)") + }) + + t.Run("renders the reference on a leaf page", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{}) + require.NoError(t, err) + + acquired := pageByPath(t, pages, "library/cataloging/book/acquired.md") + assert.Contains(t, acquired.Content, "# acquired") + assert.Contains(t, acquired.Content, "Reference: `esdm:library/cataloging/book/acquired` (event)") + }) + + t.Run("indexes the domains from the root page", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{}) + require.NoError(t, err) + + root := pageByPath(t, pages, "README.md") + assert.Contains(t, root.Content, "# Documentation") + assert.Contains(t, root.Content, "[library](library/README.md)") + }) + + t.Run("links a bounded context to its bounded-context-scoped elements", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{}) + require.NoError(t, err) + + cataloging := pageByPath(t, pages, "library/cataloging/README.md") + assert.Contains(t, cataloging.Content, "[book](book/README.md)") + assert.Contains(t, cataloging.Content, "[books](books.md)") + assert.Contains(t, cataloging.Content, "[list-books](list-books.md)") + }) + + t.Run("rejects an unknown top-level segment", func(t *testing.T) { + m := loadModel(t, modelYAML) + + _, err := docgen.Build(m, modelpath.Path{Segments: []string{"nonexistent"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "under model root") + }) + + t.Run("rejects an unknown segment under a known one", func(t *testing.T) { + m := loadModel(t, modelYAML) + + _, err := docgen.Build(m, modelpath.Path{Segments: []string{"library", "nonexistent"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), `under "library"`) + }) + + t.Run("rejects a path that reaches below a leaf", func(t *testing.T) { + m := loadModel(t, modelYAML) + + _, err := docgen.Build(m, modelpath.Path{Segments: []string{"library", "cataloging", "book", "acquired", "deeper"}}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "acquired")) + }) +} diff --git a/cmd/esdm/commands/docgen/command.go b/cmd/esdm/commands/docgen/command.go new file mode 100644 index 0000000..2057545 --- /dev/null +++ b/cmd/esdm/commands/docgen/command.go @@ -0,0 +1,63 @@ +package docgen + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/thenativeweb/esdm/modelpath" + "github.com/thenativeweb/esdm/runner" +) + +var ( + directory string + output string + force bool +) + +func init() { + Command.Flags().StringVarP(&directory, "directory", "d", ".", "directory containing the model to document") + Command.Flags().StringVarP(&output, "output", "o", "", "directory to write the Markdown tree to") + Command.Flags().BoolVar(&force, "force", false, "clear and rewrite the output directory when it is not empty") + _ = Command.MarkFlagRequired("output") +} + +// Command is the cobra command instance registered by the root +// command. It runs the resolver pipeline and writes the model as a +// Markdown directory tree to the output directory. Linter findings do +// not block the output; only an unresolvable model or invalid input is +// treated as an error. +var Command = &cobra.Command{ + Use: "documentation [path]", + Short: "Renders an ESDM model as a Markdown directory tree", + Long: "Renders the ESDM model in --directory as a Markdown directory tree written to --output. Each element becomes a page at its containment path, so GitHub and MkDocs can both read the result.", + Example: " esdm documentation --output ./docs\n esdm documentation --output ./docs /\n esdm documentation --directory ./model --output ./docs --force", + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + SilenceErrors: false, + RunE: func(command *cobra.Command, args []string) error { + var rawPath string + if len(args) > 0 { + rawPath = args[0] + } + path, err := modelpath.ParsePath(rawPath) + if err != nil { + return err + } + + _, m, err := runner.RunWithModel(command.Context(), directory) + if err != nil { + return err + } + if m == nil { + return fmt.Errorf("the resolver could not produce a model for %q; run `esdm lint` for diagnostics", directory) + } + + pages, err := Build(m, path) + if err != nil { + return err + } + + return Write(pages, output, force) + }, +} diff --git a/cmd/esdm/commands/docgen/documentation.go b/cmd/esdm/commands/docgen/documentation.go new file mode 100644 index 0000000..adf1a32 --- /dev/null +++ b/cmd/esdm/commands/docgen/documentation.go @@ -0,0 +1,14 @@ +// Package docgen renders a resolved ESDM model as a Markdown +// directory tree. Each model element becomes one page, placed at its +// containment path so the tree mirrors the reference notation: an +// element addressed as esdm:/<...>/ lives at the same +// path on disk. An element that contains other elements is written as +// a directory with a README.md index page; a leaf element is written +// as .md. Supplying a base URL therefore turns any reference +// into a link into the rendered tree. +// +// The command narrows to a subtree via an optional model path and +// refuses to write into a non-empty directory unless --force clears it +// first, so the output always mirrors the model exactly with no +// orphaned pages. +package docgen diff --git a/cmd/esdm/commands/docgen/render.go b/cmd/esdm/commands/docgen/render.go new file mode 100644 index 0000000..ee83fc7 --- /dev/null +++ b/cmd/esdm/commands/docgen/render.go @@ -0,0 +1,109 @@ +package docgen + +import ( + "fmt" + "strings" +) + +// childKindOrder fixes the order in which a page's child sections +// appear, so a page always lists its contents the same way. +var childKindOrder = []string{ + "domain", + "subdomain", + "bounded-context", + "aggregate", + "dynamic-consistency-boundary", + "command", + "event", + "read-model", + "query", + "entity", + "value-object", + "domain-service", + "actor", + "process-manager", + "event-handler", + "policy", + "external-system", + "feature", + "domain-story", + "context-mappings", +} + +// sectionHeadings maps a child kind to the heading of its section. +var sectionHeadings = map[string]string{ + "domain": "Domains", + "subdomain": "Subdomains", + "bounded-context": "Bounded Contexts", + "aggregate": "Aggregates", + "dynamic-consistency-boundary": "Dynamic Consistency Boundaries", + "command": "Commands", + "event": "Events", + "read-model": "Read Models", + "query": "Queries", + "entity": "Entities", + "value-object": "Value Objects", + "domain-service": "Domain Services", + "actor": "Actors", + "process-manager": "Process Managers", + "event-handler": "Event Handlers", + "policy": "Policies", + "external-system": "External Systems", + "feature": "Features", + "domain-story": "Domain Stories", + "context-mappings": "Context Mappings", +} + +// renderRoot renders the tree's entry page, indexing the domains and +// the context-mapping namespace. +func renderRoot(top []*node) string { + var b strings.Builder + b.WriteString("# Documentation\n") + renderContents(&b, "README.md", top) + return b.String() +} + +// renderPage renders one element's page: its name, reference, +// description, and an index of its children. +func renderPage(n *node) string { + var b strings.Builder + + if n.kind == "context-mappings" { + b.WriteString("# Context Mappings\n") + renderContents(&b, n.filePath(), n.children) + return b.String() + } + + fmt.Fprintf(&b, "# %s\n\n", n.name) + fmt.Fprintf(&b, "Reference: `esdm:%s` (%s)\n", strings.Join(n.segs, "/"), n.kind) + if n.description != "" { + fmt.Fprintf(&b, "\n%s\n", n.description) + } + renderContents(&b, n.filePath(), n.children) + + return b.String() +} + +// renderContents writes one section per child kind, in a fixed order, +// linking each child relative to fromPath. +func renderContents(b *strings.Builder, fromPath string, children []*node) { + if len(children) == 0 { + return + } + + groups := map[string][]*node{} + for _, child := range children { + groups[child.kind] = append(groups[child.kind], child) + } + + for _, kind := range childKindOrder { + group := groups[kind] + if len(group) == 0 { + continue + } + fmt.Fprintf(b, "\n## %s\n\n", sectionHeadings[kind]) + for _, child := range group { + fmt.Fprintf(b, "- [%s](%s)\n", child.name, relLink(fromPath, child.filePath())) + } + } +} diff --git a/cmd/esdm/commands/docgen/write.go b/cmd/esdm/commands/docgen/write.go new file mode 100644 index 0000000..e44d38c --- /dev/null +++ b/cmd/esdm/commands/docgen/write.go @@ -0,0 +1,80 @@ +package docgen + +import ( + "fmt" + "os" + "path/filepath" +) + +// Write writes the pages into outputDir. It refuses to write into a +// directory that exists and is not empty unless force is set, in which +// case it clears the directory first so the tree mirrors the model +// exactly with no orphaned pages. +func Write(pages []Page, outputDir string, force bool) error { + info, err := os.Stat(outputDir) + switch { + case err == nil: + if !info.IsDir() { + return fmt.Errorf("output %q exists and is not a directory", outputDir) + } + empty, err := isEmptyDir(outputDir) + if err != nil { + return err + } + if !empty { + if !force { + return fmt.Errorf("output directory %q is not empty; pass --force to clear and rewrite it", outputDir) + } + err = clearDir(outputDir) + if err != nil { + return err + } + } + case os.IsNotExist(err): + err = os.MkdirAll(outputDir, 0o755) + if err != nil { + return err + } + default: + return err + } + + for _, page := range pages { + full := filepath.Join(outputDir, filepath.FromSlash(page.Path)) + err = os.MkdirAll(filepath.Dir(full), 0o755) + if err != nil { + return err + } + err = os.WriteFile(full, []byte(page.Content), 0o644) + if err != nil { + return err + } + } + + return nil +} + +// isEmptyDir reports whether the directory has no entries. +func isEmptyDir(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + return len(entries) == 0, nil +} + +// clearDir removes every entry inside the directory, leaving the +// directory itself in place. +func clearDir(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + err = os.RemoveAll(filepath.Join(dir, entry.Name())) + if err != nil { + return err + } + } + return nil +} diff --git a/cmd/esdm/commands/docgen/write_test.go b/cmd/esdm/commands/docgen/write_test.go new file mode 100644 index 0000000..1c6ad43 --- /dev/null +++ b/cmd/esdm/commands/docgen/write_test.go @@ -0,0 +1,77 @@ +package docgen_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/thenativeweb/esdm/cmd/esdm/commands/docgen" +) + +func samplePages() []docgen.Page { + return []docgen.Page{ + {Path: "README.md", Content: "root"}, + {Path: "library/README.md", Content: "domain"}, + {Path: "library/cataloging/book/acquired.md", Content: "event"}, + } +} + +func TestWrite(t *testing.T) { + t.Run("creates the tree and writes each page", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "docs") + + err := docgen.Write(samplePages(), out, false) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(out, "library", "cataloging", "book", "acquired.md")) + require.NoError(t, err) + assert.Equal(t, "event", string(content)) + + root, err := os.ReadFile(filepath.Join(out, "README.md")) + require.NoError(t, err) + assert.Equal(t, "root", string(root)) + }) + + t.Run("refuses to write into a non-empty directory without force", func(t *testing.T) { + out := t.TempDir() + stray := filepath.Join(out, "stray.txt") + require.NoError(t, os.WriteFile(stray, []byte("keep"), 0o644)) + + err := docgen.Write(samplePages(), out, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "--force") + + _, statErr := os.Stat(stray) + assert.NoError(t, statErr) + }) + + t.Run("clears the directory before writing when force is set", func(t *testing.T) { + out := t.TempDir() + stray := filepath.Join(out, "stray.txt") + require.NoError(t, os.WriteFile(stray, []byte("remove"), 0o644)) + + err := docgen.Write(samplePages(), out, true) + require.NoError(t, err) + + _, statErr := os.Stat(stray) + assert.True(t, os.IsNotExist(statErr)) + + content, err := os.ReadFile(filepath.Join(out, "README.md")) + require.NoError(t, err) + assert.Equal(t, "root", string(content)) + }) + + t.Run("writes into an existing empty directory", func(t *testing.T) { + out := t.TempDir() + + err := docgen.Write(samplePages(), out, false) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(out, "library", "README.md")) + require.NoError(t, err) + assert.Equal(t, "domain", string(content)) + }) +} diff --git a/cmd/esdm/commands/root/command.go b/cmd/esdm/commands/root/command.go index 4b01f52..0bd21cc 100644 --- a/cmd/esdm/commands/root/command.go +++ b/cmd/esdm/commands/root/command.go @@ -3,6 +3,7 @@ package root import ( "github.com/spf13/cobra" "github.com/thenativeweb/esdm/cmd/esdm/commands/addschema" + "github.com/thenativeweb/esdm/cmd/esdm/commands/docgen" "github.com/thenativeweb/esdm/cmd/esdm/commands/glossary" "github.com/thenativeweb/esdm/cmd/esdm/commands/lint" "github.com/thenativeweb/esdm/cmd/esdm/commands/updateschema" @@ -12,6 +13,7 @@ import ( func init() { Command.AddCommand(addschema.Command) + Command.AddCommand(docgen.Command) Command.AddCommand(glossary.Command) Command.AddCommand(lint.Command) Command.AddCommand(updateschema.Command) From 03ec963e0866fc57efa5d3b307f4998b5062cecb Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Sun, 12 Jul 2026 15:38:58 +0200 Subject: [PATCH 2/2] feat: Render per-kind details, cross-links, and docs for esdm documentation. Flesh out each page with the detail the model records for its kind, aligned with `esdm view --with-details`: an aggregate's identity and state fields, a command's payload and published events, a read model's projections and paradigm, invariants and constraints, a bounded context's ubiquitous language, and so on. Wherever one element names another - publishes, handles, emits, projections, a query's read model, a context mapping's endpoints - the page links to it with a relative link, resolved through an index of the emitted pages so a link is never broken; a target outside the (possibly narrowed) output falls back to the element's reference. The ubiquitous-language rendering reuses the glossary command's term extraction, now exported as CollectTerms, rather than duplicating it. Document the command: a getting-started walkthrough and a CLI reference entry, both following the existing command pages. Also tighten naming for readability: containment paths are `segments` rather than `segs`, references are spelled out rather than `ref`, and a shadowed local is renamed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01T9ZRRMdA7iKk6in8u12rK6 --- cmd/esdm/commands/docgen/build.go | 64 ++- cmd/esdm/commands/docgen/build_test.go | 60 +++ cmd/esdm/commands/docgen/details.go | 466 ++++++++++++++++++ cmd/esdm/commands/docgen/render.go | 7 +- cmd/esdm/commands/docgen/write.go | 8 +- cmd/esdm/commands/glossary/glossary.go | 10 +- .../running-esdm-documentation.md | 107 ++++ documentation/docs/reference/cli.md | 28 ++ documentation/mkdocs.yml | 1 + 9 files changed, 718 insertions(+), 33 deletions(-) create mode 100644 cmd/esdm/commands/docgen/details.go create mode 100644 documentation/docs/getting-started/running-esdm-documentation.md diff --git a/cmd/esdm/commands/docgen/build.go b/cmd/esdm/commands/docgen/build.go index 103c4e5..1536cea 100644 --- a/cmd/esdm/commands/docgen/build.go +++ b/cmd/esdm/commands/docgen/build.go @@ -39,7 +39,8 @@ type node struct { kind string name string description string - segs []string + segments []string + view any children []*node } @@ -52,19 +53,36 @@ type node struct { func Build(m *model.Model, p modelpath.Path) ([]Page, error) { top := buildTopLevel(m) - if len(p.Segments) == 0 { - pages := []Page{{Path: "README.md", Content: renderRoot(top)}} - collectPages(top, &pages) - return pages, nil + var roots []*node + isWholeModel := len(p.Segments) == 0 + if isWholeModel { + roots = top + } else { + target, err := narrow(top, p.Segments) + if err != nil { + return nil, err + } + roots = []*node{target} } - target, err := narrow(top, p.Segments) - if err != nil { - return nil, err + var emitted []*node + flatten(roots, &emitted) + + // The path index maps an element's containment path to the file + // its page lives in, so a cross-link resolves to a page only when + // that page is part of this (possibly narrowed) output. + index := map[string]string{} + for _, n := range emitted { + index[strings.Join(n.segments, "/")] = n.filePath() } var pages []Page - collectPages([]*node{target}, &pages) + if isWholeModel { + pages = append(pages, Page{Path: "README.md", Content: renderRoot(top)}) + } + for _, n := range emitted { + pages = append(pages, Page{Path: n.filePath(), Content: renderPage(n, index)}) + } return pages, nil } @@ -82,7 +100,7 @@ func buildTopLevel(m *model.Model) []*node { top = append(top, &node{ kind: "context-mappings", name: "context-mapping", - segs: []string{"context-mapping"}, + segments: []string{"context-mapping"}, children: mappings, }) } @@ -198,17 +216,17 @@ func buildDcbChildren(m *model.Model, domain, boundedContext, dcb string) []*nod } // buildFeatures builds the features that target the element at -// parentSegs, identified by its kind, domain, bounded context, and +// parentSegments, identified by its kind, domain, bounded context, and // name. -func buildFeatures(m *model.Model, parentSegs []string, kind, domain, boundedContext, target string) []*node { +func buildFeatures(m *model.Model, parentSegments []string, kind, domain, boundedContext, target string) []*node { var out []*node for _, feature := range sortedByName(m.Extensions.GivenWhenThen.Features) { featureKind, featureDomain, featureBC, featureTarget := featureParent(feature) if featureKind != kind || featureDomain != domain || featureBC != boundedContext || featureTarget != target { continue } - segs := append(append([]string{}, parentSegs...), bareName(feature)) - out = append(out, newNode("feature", segs, feature, nil)) + segments := append(append([]string{}, parentSegments...), bareName(feature)) + out = append(out, newNode("feature", segments, feature, nil)) } return out } @@ -273,33 +291,35 @@ func narrow(nodes []*node, segments []string) (*node, error) { return matched, nil } -// collectPages walks the tree and appends one page per node. -func collectPages(nodes []*node, pages *[]Page) { +// flatten appends every node in the trees to out, depth first. +func flatten(nodes []*node, out *[]*node) { for _, n := range nodes { - *pages = append(*pages, Page{Path: n.filePath(), Content: renderPage(n)}) - collectPages(n.children, pages) + *out = append(*out, n) + flatten(n.children, out) } } // filePath is the node's relative slash path: a README.md index inside // a directory when the node has children, otherwise a .md leaf. func (n *node) filePath() string { - joined := strings.Join(n.segs, "/") + joined := strings.Join(n.segments, "/") if len(n.children) > 0 { return joined + "/README.md" } return joined + ".md" } -// newNode builds a node from a view, reading its name and description. -func newNode(kind string, segs []string, view documented, children []*node) *node { +// newNode builds a node from a view, reading its name and description +// and keeping the view for kind-specific detail rendering. +func newNode(kind string, segments []string, view documented, children []*node) *node { name, _ := view.Name().Text() description, _ := view.Description().Text() return &node{ kind: kind, name: name, description: strings.TrimSpace(description), - segs: segs, + segments: segments, + view: view, children: children, } } diff --git a/cmd/esdm/commands/docgen/build_test.go b/cmd/esdm/commands/docgen/build_test.go index f6d5ad5..e6158a3 100644 --- a/cmd/esdm/commands/docgen/build_test.go +++ b/cmd/esdm/commands/docgen/build_test.go @@ -257,3 +257,63 @@ func TestBuild(t *testing.T) { assert.True(t, strings.Contains(err.Error(), "acquired")) }) } + +func TestDetails(t *testing.T) { + t.Run("renders an aggregate's identity and state fields", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{}) + require.NoError(t, err) + + book := pageByPath(t, pages, "library/cataloging/book/README.md") + assert.Contains(t, book.Content, "## Identity") + assert.Contains(t, book.Content, "`isbn` field, from the state") + assert.Contains(t, book.Content, "## State") + assert.Contains(t, book.Content, "- `title`") + }) + + t.Run("renders a command's payload and links its published events", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{}) + require.NoError(t, err) + + acquire := pageByPath(t, pages, "library/cataloging/book/acquire.md") + assert.Contains(t, acquire.Content, "## Payload") + assert.Contains(t, acquire.Content, "## Publishes") + assert.Contains(t, acquire.Content, "- [acquired](acquired.md)") + }) + + t.Run("links a read model's projections to the projected events", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{}) + require.NoError(t, err) + + books := pageByPath(t, pages, "library/cataloging/books.md") + assert.Contains(t, books.Content, "## Paradigm") + assert.Contains(t, books.Content, "## Projections") + assert.Contains(t, books.Content, "- [acquired](book/acquired.md):") + }) + + t.Run("links a query to its read model", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{}) + require.NoError(t, err) + + listBooks := pageByPath(t, pages, "library/cataloging/list-books.md") + assert.Contains(t, listBooks.Content, "## Read Model") + assert.Contains(t, listBooks.Content, "[books](books.md)") + }) + + t.Run("falls back to the reference when a linked target is outside the output", func(t *testing.T) { + m := loadModel(t, modelYAML) + + pages, err := docgen.Build(m, modelpath.Path{Segments: []string{"library", "cataloging", "books"}}) + require.NoError(t, err) + + books := pageByPath(t, pages, "library/cataloging/books.md") + assert.Contains(t, books.Content, "`esdm:library/cataloging/book/acquired`") + }) +} diff --git a/cmd/esdm/commands/docgen/details.go b/cmd/esdm/commands/docgen/details.go new file mode 100644 index 0000000..5bfa1a6 --- /dev/null +++ b/cmd/esdm/commands/docgen/details.go @@ -0,0 +1,466 @@ +package docgen + +import ( + "fmt" + "sort" + "strings" + + "github.com/thenativeweb/esdm/ast" + "github.com/thenativeweb/esdm/cmd/esdm/commands/glossary" + "github.com/thenativeweb/esdm/model" +) + +// renderDetails writes the kind-specific detail sections of a page, +// aligned with what `esdm view --with-details` shows for each kind. +// Sections that name other elements link to them relative to the +// current page when the target is part of this output; otherwise they +// fall back to the element's reference so the page never carries a +// broken link. +func renderDetails(b *strings.Builder, n *node, index map[string]string) { + fromFile := n.filePath() + domain := n.segments[0] + + switch v := n.view.(type) { + case model.BoundedContextView: + renderUbiquitousLanguage(b, v) + + case model.SubdomainView: + renderScalar(b, "Type", v.Type()) + renderRefList(b, "Bounded Contexts", fromFile, domainScopedPaths(domain, scalarNames(v.BoundedContexts().Seq())), index) + + case model.AggregateView: + renderIdentity(b, v.IdentifiedBy()) + renderFields(b, "State", v.State()) + renderNameRules(b, "Invariants", v.Invariants().Seq()) + + case model.DynamicConsistencyBoundaryView: + renderConsults(b, fromFile, domain, v.Consults().Seq(), index) + renderNameRules(b, "Invariants", v.Invariants().Seq()) + + case model.CommandView: + renderFields(b, "Payload", v.Data()) + renderRefList(b, "Publishes", fromFile, publishedEvents(n, v), index) + renderRefList(b, "Actors", fromFile, boundedContextScopedPaths(domain, n.segments[1], scalarNames(v.Actors().Seq())), index) + renderNameRules(b, "Constraints", v.Constraints().Seq()) + + case model.EventView: + renderFields(b, "Payload", v.Data()) + + case model.ReadModelView: + renderScalar(b, "Paradigm", v.Paradigm()) + renderProjections(b, fromFile, domain, v.Projections().Seq(), index) + + case model.QueryView: + renderQueryReadModel(b, fromFile, domain, n.segments[1], v, index) + renderRefList(b, "Actors", fromFile, boundedContextScopedPaths(domain, n.segments[1], scalarNames(v.Actors().Seq())), index) + renderNameRules(b, "Constraints", v.Constraints().Seq()) + + case model.EntityView: + renderFields(b, "Schema", v.Schema()) + renderIdentity(b, v.IdentifiedBy()) + renderNameRules(b, "Invariants", v.Invariants().Seq()) + + case model.ValueObjectView: + renderFields(b, "Schema", v.Schema()) + renderNameRules(b, "Invariants", v.Invariants().Seq()) + + case model.DomainServiceView: + renderFunctions(b, v.Functions().Seq()) + + case model.ActorView: + renderScalar(b, "Type", v.Type()) + renderBullets(b, "Responsibilities", scalarNames(v.Responsibilities().Seq())) + + case model.PolicyView: + renderScalar(b, "Delivery Guarantee", v.DeliveryGuarantee()) + renderRefList(b, "Handles", fromFile, eventReferences(domain, v.Handles().Seq()), index) + renderRefList(b, "Emits", fromFile, commandReferences(domain, v.Emits().Seq()), index) + + case model.EventHandlerView: + renderScalar(b, "Delivery Guarantee", v.DeliveryGuarantee()) + renderRefList(b, "Handles", fromFile, eventReferences(domain, v.Handles().Seq()), index) + + case model.ProcessManagerView: + renderScalar(b, "Delivery Guarantee", v.DeliveryGuarantee()) + renderReactions(b, fromFile, domain, v.Reactions().Seq(), index) + renderNameRules(b, "Invariants", v.Invariants().Seq()) + + case model.ExternalSystemView: + renderScalar(b, "Direction", v.Direction()) + renderScalar(b, "Category", v.Category()) + + case model.FeatureView: + renderScenarios(b, v.Scenarios().Seq()) + + case model.DomainStoryView: + renderStory(b, v) + + case model.ContextMappingView: + renderScalar(b, "Type", v.Type()) + renderEndpoints(b, fromFile, v, index) + } +} + +// renderScalar writes a single-value section when the value is present. +func renderScalar(b *strings.Builder, heading string, value ast.Node) { + content, ok := value.Text() + if !ok || content == "" { + return + } + fmt.Fprintf(b, "\n## %s\n\n%s\n", heading, content) +} + +// renderFields writes the sorted property names of a JSON schema as a +// list, matching the field summary `esdm view` shows. +func renderFields(b *strings.Builder, heading string, schema ast.Node) { + names := schemaFieldNames(schema) + if len(names) == 0 { + return + } + fmt.Fprintf(b, "\n## %s\n\n", heading) + for _, name := range names { + fmt.Fprintf(b, "- `%s`\n", name) + } +} + +// renderBullets writes a plain bullet list section. +func renderBullets(b *strings.Builder, heading string, items []string) { + if len(items) == 0 { + return + } + fmt.Fprintf(b, "\n## %s\n\n", heading) + for _, item := range items { + fmt.Fprintf(b, "- %s\n", item) + } +} + +// renderNameRules writes a section for name/rule pairs, used by both +// invariants and constraints. +func renderNameRules(b *strings.Builder, heading string, items []ast.Node) { + if len(items) == 0 { + return + } + fmt.Fprintf(b, "\n## %s\n\n", heading) + for _, item := range items { + fmt.Fprintf(b, "- **%s**: %s\n", text(item, "name"), text(item, "rule")) + } +} + +// renderRefList writes a section that links to each target element. +func renderRefList(b *strings.Builder, heading, fromFile string, targets [][]string, index map[string]string) { + if len(targets) == 0 { + return + } + fmt.Fprintf(b, "\n## %s\n\n", heading) + for _, segments := range targets { + fmt.Fprintf(b, "- %s\n", referenceMarkdown(fromFile, segments, index)) + } +} + +// renderIdentity describes how instances of an aggregate or entity are +// identified. +func renderIdentity(b *strings.Builder, identifiedBy ast.Node) { + source := text(identifiedBy, "source") + var line string + switch source { + case "state": + line = fmt.Sprintf("By its `%s` field, from the state.", text(identifiedBy, "field")) + case "schema": + line = fmt.Sprintf("By its `%s` field, from the schema.", text(identifiedBy, "field")) + case "static": + line = fmt.Sprintf("Statically, as `%s`.", text(identifiedBy, "value")) + case "generated": + line = fmt.Sprintf("Generated by `%s`.", text(identifiedBy, "generator")) + default: + return + } + fmt.Fprintf(b, "\n## Identity\n\n%s\n", line) +} + +// renderProjections lists the events a read model projects, with the +// projection rule. +func renderProjections(b *strings.Builder, fromFile, domain string, projections []ast.Node, index map[string]string) { + if len(projections) == 0 { + return + } + fmt.Fprintf(b, "\n## Projections\n\n") + for _, projection := range projections { + fmt.Fprintf(b, "- %s: %s\n", referenceMarkdown(fromFile, eventReferenceSegments(domain, projection), index), text(projection, "rule")) + } +} + +// renderConsults lists the events a dynamic consistency boundary +// consults, with the criteria. +func renderConsults(b *strings.Builder, fromFile, domain string, consults []ast.Node, index map[string]string) { + if len(consults) == 0 { + return + } + fmt.Fprintf(b, "\n## Consults\n\n") + for _, consult := range consults { + fmt.Fprintf(b, "- %s: %s\n", referenceMarkdown(fromFile, eventReferenceSegments(domain, consult), index), text(consult, "criteria")) + } +} + +// renderReactions lists a process manager's reactions to events and +// timers. +func renderReactions(b *strings.Builder, fromFile, domain string, reactions []ast.Node, index map[string]string) { + if len(reactions) == 0 { + return + } + fmt.Fprintf(b, "\n## Reactions\n\n") + for _, reaction := range reactions { + rule := text(reaction, "rule") + when := reaction.Field("when") + if timer, ok := when.Field("timer").Text(); ok && timer != "" { + fmt.Fprintf(b, "- On timer `%s`: %s\n", timer, rule) + continue + } + fmt.Fprintf(b, "- On %s: %s\n", referenceMarkdown(fromFile, eventReferenceSegments(domain, when), index), rule) + } +} + +// renderQueryReadModel links a query to the read model it reads from. +func renderQueryReadModel(b *strings.Builder, fromFile, domain, boundedContext string, query model.QueryView, index map[string]string) { + name, ok := query.ReadModel().Text() + if !ok || name == "" { + return + } + fmt.Fprintf(b, "\n## Read Model\n\n%s\n", referenceMarkdown(fromFile, []string{domain, boundedContext, name}, index)) +} + +// renderFunctions lists a domain service's functions. +func renderFunctions(b *strings.Builder, functions []ast.Node) { + if len(functions) == 0 { + return + } + fmt.Fprintf(b, "\n## Functions\n\n") + for _, function := range functions { + fmt.Fprintf(b, "- `%s`\n", text(function, "name")) + } +} + +// renderScenarios lists a feature's scenarios by name. +func renderScenarios(b *strings.Builder, scenarios []ast.Node) { + if len(scenarios) == 0 { + return + } + fmt.Fprintf(b, "\n## Scenarios\n\n") + for _, scenario := range scenarios { + fmt.Fprintf(b, "- %s\n", text(scenario, "name")) + } +} + +// renderStory lists a domain story's classifying properties. +func renderStory(b *strings.Builder, story model.DomainStoryView) { + var lines []string + if pointInTime, ok := story.PointInTime().Text(); ok && pointInTime != "" { + lines = append(lines, "- Point in time: "+pointInTime) + } + if granularity, ok := story.Granularity().Text(); ok && granularity != "" { + lines = append(lines, "- Granularity: "+granularity) + } + if domainPurity, ok := story.DomainPurity().Text(); ok && domainPurity != "" { + lines = append(lines, "- Domain purity: "+domainPurity) + } + if len(lines) == 0 { + return + } + fmt.Fprintf(b, "\n## Story\n\n%s\n", strings.Join(lines, "\n")) +} + +// renderUbiquitousLanguage renders a bounded context's terms, reusing +// the glossary command's term extraction. +func renderUbiquitousLanguage(b *strings.Builder, boundedContext model.BoundedContextView) { + terms := glossary.CollectTerms(boundedContext.UbiquitousLanguage()) + if len(terms) == 0 { + return + } + fmt.Fprintf(b, "\n## Ubiquitous Language\n\n") + for _, term := range terms { + fmt.Fprintf(b, "- **%s**: %s\n", term.Term, term.Definition) + for _, avoid := range term.Avoid { + if avoid.Reason != "" { + fmt.Fprintf(b, " - avoid *%s*: %s\n", avoid.Term, avoid.Reason) + continue + } + fmt.Fprintf(b, " - avoid *%s*\n", avoid.Term) + } + } +} + +// renderEndpoints links a context mapping to its two endpoints. +func renderEndpoints(b *strings.Builder, fromFile string, contextMapping model.ContextMappingView, index map[string]string) { + roles := []struct { + label string + node ast.Node + }{ + {"Customer", contextMapping.Customer()}, + {"Supplier", contextMapping.Supplier()}, + {"Conformist", contextMapping.Conformist()}, + {"Upstream", contextMapping.Upstream()}, + {"Downstream", contextMapping.Downstream()}, + {"Host", contextMapping.Host()}, + {"Consumer", contextMapping.Consumer()}, + {"Publisher", contextMapping.Publisher()}, + } + + var lines []string + for _, role := range roles { + if link, ok := endpointLink(fromFile, role.node, index); ok { + lines = append(lines, fmt.Sprintf("- %s: %s", role.label, link)) + } + } + for _, participant := range contextMapping.Participants().Seq() { + if link, ok := endpointLink(fromFile, participant, index); ok { + lines = append(lines, "- Participant: "+link) + } + } + + if len(lines) == 0 { + return + } + fmt.Fprintf(b, "\n## Endpoints\n\n%s\n", strings.Join(lines, "\n")) +} + +// endpointLink builds a link to a context-mapping endpoint, which is +// either a bounded context or an external system in some domain. +func endpointLink(fromFile string, endpoint ast.Node, index map[string]string) (string, bool) { + if !endpoint.Exists() { + return "", false + } + domain := text(endpoint, "domain") + if boundedContext := text(endpoint, "boundedContext"); boundedContext != "" { + return referenceMarkdown(fromFile, []string{domain, boundedContext}, index), true + } + if externalSystem := text(endpoint, "externalSystem"); externalSystem != "" { + return referenceMarkdown(fromFile, []string{domain, externalSystem}, index), true + } + return "", false +} + +// referenceMarkdown links to the element at segments when its page is part of +// this output; otherwise it renders the element's reference so the +// link is never broken. +func referenceMarkdown(fromFile string, segments []string, index map[string]string) string { + logical := strings.Join(segments, "/") + if file, ok := index[logical]; ok { + return fmt.Sprintf("[%s](%s)", segments[len(segments)-1], relLink(fromFile, file)) + } + return fmt.Sprintf("`esdm:%s`", logical) +} + +// eventReferenceSegments turns an event-reference triple into a containment +// path, collapsing to three segments for a free-standing event. The +// domain is the referencing element's, since triples omit it. +func eventReferenceSegments(domain string, reference ast.Node) []string { + boundedContext := text(reference, "boundedContext") + aggregate := text(reference, "aggregate") + event := text(reference, "event") + if aggregate == "" { + return []string{domain, boundedContext, event} + } + return []string{domain, boundedContext, aggregate, event} +} + +// commandReferenceSegments turns a command-reference triple into a containment +// path, whose parent is either an aggregate or a dynamic consistency +// boundary. +func commandReferenceSegments(domain string, reference ast.Node) []string { + boundedContext := text(reference, "boundedContext") + parent := text(reference, "aggregate") + if parent == "" { + parent = text(reference, "dynamicConsistencyBoundary") + } + return []string{domain, boundedContext, parent, text(reference, "command")} +} + +// eventReferences maps event-reference triples to containment paths. +func eventReferences(domain string, references []ast.Node) [][]string { + var out [][]string + for _, reference := range references { + out = append(out, eventReferenceSegments(domain, reference)) + } + return out +} + +// commandReferences maps command-reference triples to containment paths. +func commandReferences(domain string, references []ast.Node) [][]string { + var out [][]string + for _, reference := range references { + out = append(out, commandReferenceSegments(domain, reference)) + } + return out +} + +// publishedEvents resolves a command's published event names to +// containment paths. An aggregate's command publishes that aggregate's +// events; a command on a dynamic consistency boundary publishes +// free-standing events. +func publishedEvents(n *node, command model.CommandView) [][]string { + domain := n.segments[0] + boundedContext := n.segments[1] + parentIsAggregate := text(command.Scope(), "aggregate") != "" + + var out [][]string + for _, event := range scalarNames(command.Publishes().Seq()) { + if parentIsAggregate { + out = append(out, []string{domain, boundedContext, n.segments[2], event}) + continue + } + out = append(out, []string{domain, boundedContext, event}) + } + return out +} + +// domainScopedPaths builds domain-scoped containment paths from bare names. +func domainScopedPaths(domain string, names []string) [][]string { + var out [][]string + for _, name := range names { + out = append(out, []string{domain, name}) + } + return out +} + +// boundedContextScopedPaths builds bounded-context-scoped containment paths from bare +// names. +func boundedContextScopedPaths(domain, boundedContext string, names []string) [][]string { + var out [][]string + for _, name := range names { + out = append(out, []string{domain, boundedContext, name}) + } + return out +} + +// scalarNames reads a sequence of scalar names. +func scalarNames(seq []ast.Node) []string { + var out []string + for _, item := range seq { + if v, ok := item.Text(); ok { + out = append(out, v) + } + } + return out +} + +// schemaFieldNames returns the sorted top-level property names of a +// JSON schema node. +func schemaFieldNames(schema ast.Node) []string { + properties := schema.Field("properties") + if !properties.Exists() { + return nil + } + var names []string + for _, entry := range properties.Entries() { + if key, ok := entry.Key.Text(); ok { + names = append(names, key) + } + } + sort.Strings(names) + return names +} + +// text reads a scalar field, returning "" when it is absent. +func text(n ast.Node, field string) string { + value, _ := n.Field(field).Text() + return value +} diff --git a/cmd/esdm/commands/docgen/render.go b/cmd/esdm/commands/docgen/render.go index ee83fc7..c965e35 100644 --- a/cmd/esdm/commands/docgen/render.go +++ b/cmd/esdm/commands/docgen/render.go @@ -64,8 +64,8 @@ func renderRoot(top []*node) string { } // renderPage renders one element's page: its name, reference, -// description, and an index of its children. -func renderPage(n *node) string { +// description, kind-specific details, and an index of its children. +func renderPage(n *node, index map[string]string) string { var b strings.Builder if n.kind == "context-mappings" { @@ -75,10 +75,11 @@ func renderPage(n *node) string { } fmt.Fprintf(&b, "# %s\n\n", n.name) - fmt.Fprintf(&b, "Reference: `esdm:%s` (%s)\n", strings.Join(n.segs, "/"), n.kind) + fmt.Fprintf(&b, "Reference: `esdm:%s` (%s)\n", strings.Join(n.segments, "/"), n.kind) if n.description != "" { fmt.Fprintf(&b, "\n%s\n", n.description) } + renderDetails(&b, n, index) renderContents(&b, n.filePath(), n.children) return b.String() diff --git a/cmd/esdm/commands/docgen/write.go b/cmd/esdm/commands/docgen/write.go index e44d38c..03062b5 100644 --- a/cmd/esdm/commands/docgen/write.go +++ b/cmd/esdm/commands/docgen/write.go @@ -7,7 +7,7 @@ import ( ) // Write writes the pages into outputDir. It refuses to write into a -// directory that exists and is not empty unless force is set, in which +// directory that exists and is not isEmpty unless force is set, in which // case it clears the directory first so the tree mirrors the model // exactly with no orphaned pages. func Write(pages []Page, outputDir string, force bool) error { @@ -17,13 +17,13 @@ func Write(pages []Page, outputDir string, force bool) error { if !info.IsDir() { return fmt.Errorf("output %q exists and is not a directory", outputDir) } - empty, err := isEmptyDir(outputDir) + isEmpty, err := isEmptyDir(outputDir) if err != nil { return err } - if !empty { + if !isEmpty { if !force { - return fmt.Errorf("output directory %q is not empty; pass --force to clear and rewrite it", outputDir) + return fmt.Errorf("output directory %q is not isEmpty; pass --force to clear and rewrite it", outputDir) } err = clearDir(outputDir) if err != nil { diff --git a/cmd/esdm/commands/glossary/glossary.go b/cmd/esdm/commands/glossary/glossary.go index ead19dd..2047a71 100644 --- a/cmd/esdm/commands/glossary/glossary.go +++ b/cmd/esdm/commands/glossary/glossary.go @@ -54,7 +54,7 @@ func Build(m *model.Model, p modelpath.Path) (*Glossary, error) { g := &Glossary{} for _, boundedContext := range boundedContexts { - terms := collectTerms(boundedContext.UbiquitousLanguage()) + terms := CollectTerms(boundedContext.UbiquitousLanguage()) if len(terms) == 0 { continue } @@ -67,11 +67,13 @@ func Build(m *model.Model, p modelpath.Path) (*Glossary, error) { return g, nil } -// collectTerms turns the ubiquitousLanguage sequence node +// CollectTerms turns the ubiquitousLanguage sequence node // into the sorted, typed term list. Entries missing a term // or definition are skipped defensively, even though the -// schema requires both. -func collectTerms(ubiquitousLanguage ast.Node) []Term { +// schema requires both. It is exported so other commands (the +// documentation tree) can render the same terms without +// duplicating the extraction. +func CollectTerms(ubiquitousLanguage ast.Node) []Term { var terms []Term for _, entry := range ubiquitousLanguage.Seq() { term := strings.TrimSpace(textOf(entry, "term")) diff --git a/documentation/docs/getting-started/running-esdm-documentation.md b/documentation/docs/getting-started/running-esdm-documentation.md new file mode 100644 index 0000000..b26ae47 --- /dev/null +++ b/documentation/docs/getting-started/running-esdm-documentation.md @@ -0,0 +1,107 @@ +# Running esdm documentation + +This page walks through the **documentation workflow**: how to render an ESDM model as a tree of Markdown files, how to read a page it produces, how to scope the output to one region of the model, and how to regenerate it safely. + +This page assumes you have a small model on disk. Either **[Your First Model with AI](/getting-started/your-first-model-with-ai.md)** or **[Your First Model by Hand](/getting-started/your-first-model.md)** produces one of the right size; the examples below build on the by-hand `library` model's `cataloging` context. The **[CLI reference](/reference/cli.md#esdm-documentation)** documents every flag in detail; this page focuses on the workflow. + +## What esdm documentation Produces + +Where **[esdm view](/getting-started/running-esdm-view.md)** prints a transient summary to the terminal and **[esdm glossary](/getting-started/running-esdm-glossary.md)** writes a single document, `esdm documentation` writes a whole **directory tree** to disk: one Markdown page per element, laid out along the model's containment hierarchy. A Domain is a directory, the Bounded Contexts inside it are directories, and so on down to the Commands and Events, which are files. + +The layout is the point. **Each element's page sits at the path you would use to name it** – the `acquired` Event of the `book` Aggregate lives at `library/cataloging/book/acquired.md` – so the tree doubles as an addressing scheme, and a reader who knows an element's place in the domain knows where its page is. + +## Generating the Tree + +The output directory is required, because writing a whole tree into the wrong place should never happen by accident. Pass it with `-o` / `--output`: + +```shell +./esdm documentation --output ./docs +``` + +From the `library` model, that writes: + +```text +docs/ + README.md + library/ + README.md + cataloging/ + README.md + book/ + README.md + acquire.md + acquired.md + books.md + list-books.md +``` + +An element that contains others – a Domain, a Bounded Context, an Aggregate – becomes a directory with a `README.md` index; a leaf element becomes `.md`. **GitHub renders each `README.md` as the landing page of its directory**, so browsing the tree on GitHub reads top-down without any configuration. + +## Reading a Page + +Every page opens with the element's name and its reference, then renders the detail the model carries. The `book` Aggregate's index page looks like this: + +```markdown +# book + +Reference: `esdm:library/cataloging/book` (aggregate) + +## Identity + +By its `isbn` field, from the state. + +## State + +- `author` +- `isbn` +- `title` + +## Commands + +- [acquire](acquire.md) + +## Events + +- [acquired](acquired.md) +``` + +The detail sections mirror what `esdm view --with-details` shows for each kind: an Aggregate's identity and state, a Command's payload and the Events it publishes, a Read Model's projections, and so on. **Wherever one element names another, the page links to it** with a relative link, so the tree is navigable: the `acquire` Command links to the `acquired` Event, and the `list-books` Query links to the `books` Read Model. When a linked element falls outside the generated output, the page shows its reference instead, so a link is never broken. + +## Filtering by Path + +When the model spans several Domains and Bounded Contexts, you'll often want just one. Pass a **path** to scope the output to a subtree. The path follows the model hierarchy and uses the same shape as **[esdm view](/getting-started/running-esdm-view.md)**, so what you learn there carries over. + +```shell +./esdm documentation --output ./docs library/cataloging +``` + +This writes only the `cataloging` subtree, and – importantly – **keeps the full path prefix**, so `book`'s page is still at `library/cataloging/book/README.md`. The pages line up with their references whether you render the whole model or one corner of it. An unknown or too-deep segment is rejected as invalid input, so a typo fails loudly instead of producing an empty tree. + +## Regenerating Safely + +A documentation tree should mirror the model exactly, with no pages left over from elements you have since renamed or removed. To protect against writing over unrelated files, `esdm documentation` **refuses to write into a directory that already has content**: + +```text +output directory "./docs" is not empty; pass --force to clear and rewrite it +``` + +Passing `--force` clears the output directory first and then writes the fresh tree, so the result always reflects the current model and nothing else: + +```shell +./esdm documentation --output ./docs --force +``` + +Because `--force` deletes the directory's existing contents, point `--output` at a directory that holds only generated documentation – not at a directory you also keep other work in. + +## Publishing the Tree + +The output is neutral Markdown with relative links and no tool-specific configuration – no `mkdocs.yml`, no theme files. That keeps it portable: GitHub renders it directly, and a static-site generator such as MkDocs can pick it up as its `docs` directory. Committing the tree next to the model gives everyone a browsable, always-current view of the domain, and regenerating it after a model change keeps it honest. + +Linter findings do not block the output; as long as the model resolves, the command renders whatever it finds. Run **[esdm lint](/getting-started/running-esdm-lint.md)** first when you want the model checked for correctness before you publish from it. + +## Where to Go Next + +- **[Running esdm view](/getting-started/running-esdm-view.md)** renders the same structure to the terminal for a quick look; `esdm documentation` writes it to disk to keep and publish. +- **[Running esdm glossary](/getting-started/running-esdm-glossary.md)** writes just the ubiquitous language as a single document; the documentation tree includes it on each Bounded Context's page. +- **[Running esdm lint](/getting-started/running-esdm-lint.md)** checks the model for correctness before you publish a tree from it. +- **[CLI: esdm documentation](/reference/cli.md#esdm-documentation)** documents every flag and the path syntax in full. diff --git a/documentation/docs/reference/cli.md b/documentation/docs/reference/cli.md index 4385a70..4127932 100644 --- a/documentation/docs/reference/cli.md +++ b/documentation/docs/reference/cli.md @@ -18,6 +18,34 @@ esdm add-schema The command refuses to run if a `schemas/` directory already exists. Refreshing an existing one is the job of `esdm update-schema`; refusing here keeps the two paths cleanly separated and prevents an accidental overwrite of edited or pinned schema files. +## `esdm documentation` + +### Invocation + +```shell +esdm documentation [path] [flags] +``` + +### Anatomy + +`esdm documentation` renders an ESDM model as a Markdown directory tree written to an output directory. Every element becomes one page, placed at its containment path: a Domain, Bounded Context, or Aggregate becomes a directory with a `README.md` index, and a leaf element such as a Command or Event becomes `.md`. Each page carries the element's reference, the detail the model records for its kind – aligned with `esdm view --with-details` – and relative links to the elements it names, so the tree is navigable on GitHub and in a static-site generator alike. + +The output directory is required and is selected with `-o` / `--output`; there is no default, so a whole tree is never written by accident. The directory holding the model to read is selected with `-d` / `--directory`, defaulting to the current working directory. + +The optional `[path]` argument narrows the output to a sub-region of the model, using the same path shape as `esdm view` – Domain, then Bounded Context, then the elements inside. The narrowed pages keep their full containment paths, so they still line up with their references. A segment that names no such element, or that reaches below a leaf, is rejected as invalid input. + +So that the tree mirrors the model exactly, `esdm documentation` refuses to write into an output directory that exists and is not empty. Passing `--force` clears the directory first and then writes the fresh tree; because that removes the directory's existing contents, `--output` should point at a directory that holds only generated documentation. + +Linter findings do not block the output; as long as the model resolves, the command renders whatever it finds. The exit code is `0` on success. It is non-zero only when the path argument is invalid, when the output directory is not empty and `--force` was not given, or when the model cannot be resolved at all – run `esdm lint` to find out why in the last case. + +Typical invocations look like this: + +```shell +esdm documentation --output ./docs +esdm documentation --output ./docs / +esdm documentation --output ./docs --force +``` + ## `esdm glossary` ### Invocation diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index fc39f3e..1133a5d 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -82,6 +82,7 @@ nav: - Running esdm lint: getting-started/running-esdm-lint.md - Running esdm view: getting-started/running-esdm-view.md - Running esdm glossary: getting-started/running-esdm-glossary.md + - Running esdm documentation: getting-started/running-esdm-documentation.md - Editor Support: getting-started/editor-support.md - Concepts: - Overview: concepts/overview.md