diff --git a/cmd/daco/internal/app.go b/cmd/daco/internal/app.go index eb14b94..c7b6750 100644 --- a/cmd/daco/internal/app.go +++ b/cmd/daco/internal/app.go @@ -15,6 +15,7 @@ import ( "github.com/dacolabs/cli/internal/translate/databrickssql" "github.com/dacolabs/cli/internal/translate/dqxyaml" "github.com/dacolabs/cli/internal/translate/gotypes" + "github.com/dacolabs/cli/internal/translate/markdown" "github.com/dacolabs/cli/internal/translate/protobuf" "github.com/dacolabs/cli/internal/translate/pydantic" "github.com/dacolabs/cli/internal/translate/pyspark" @@ -39,6 +40,7 @@ func registerTranslators() translate.Register { translators["protobuf"] = &protobuf.Translator{} translators["spark-sql"] = &sparksql.Translator{} translators["dqx-yaml"] = &dqxyaml.Translator{} + translators["markdown"] = &markdown.Translator{} return translators } diff --git a/internal/translate/markdown/README.md b/internal/translate/markdown/README.md new file mode 100644 index 0000000..3d050aa --- /dev/null +++ b/internal/translate/markdown/README.md @@ -0,0 +1,122 @@ +# Markdown + +Translates JSON Schema to human-readable Markdown documentation (.md). + +## Example + +**Input** (JSON Schema): + +```json +{ + "type": "object", + "description": "User information", + "properties": { + "name": { "type": "string", "description": "Full name" }, + "age": { "type": "integer" } + }, + "required": ["name"] +} +``` + +**Output** (Markdown): + +```markdown +# Users + +User information + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Full name | +| `age` | integer | No | | +``` + +## Supported JSON Schema Features + +### Type Keywords +- [x] type +- [x] enum +- [x] const + +### Type Values +- [x] string +- [x] integer +- [x] number +- [x] boolean +- [x] array +- [x] object +- [ ] null + +### Schema Composition +- [ ] allOf +- [ ] anyOf +- [ ] oneOf +- [ ] not + +### Object Keywords +- [x] properties +- [x] required +- [ ] additionalProperties +- [ ] patternProperties +- [ ] propertyNames +- [ ] minProperties / maxProperties +- [ ] unevaluatedProperties +- [ ] dependentRequired + +### Array Keywords +- [x] items +- [ ] prefixItems +- [ ] contains +- [x] minItems / maxItems +- [ ] uniqueItems +- [ ] unevaluatedItems +- [ ] maxContains / minContains + +### Numeric Validation +- [x] minimum / maximum +- [x] exclusiveMinimum / exclusiveMaximum +- [x] multipleOf + +### String Validation +- [x] minLength / maxLength +- [x] pattern + +### References & Definitions +- [x] $ref +- [x] $defs +- [ ] $id +- [ ] $anchor +- [ ] $dynamicRef / $dynamicAnchor + +### String Formats +- [x] date +- [x] date-time +- [x] time +- [x] duration +- [x] uuid +- [x] uri / uri-reference / uri-template +- [x] iri / iri-reference +- [x] email / idn-email +- [x] hostname / idn-hostname +- [x] ipv4 / ipv6 +- [x] json-pointer / relative-json-pointer +- [x] regex + +### Annotations +- [x] description +- [ ] title +- [ ] default +- [ ] deprecated +- [ ] readOnly / writeOnly +- [ ] examples + +### Conditional +- [ ] if / then / else +- [ ] dependentSchemas + +### Content +- [ ] contentEncoding +- [ ] contentMediaType +- [ ] contentSchema diff --git a/internal/translate/markdown/markdown.go.tmpl b/internal/translate/markdown/markdown.go.tmpl new file mode 100644 index 0000000..990ceae --- /dev/null +++ b/internal/translate/markdown/markdown.go.tmpl @@ -0,0 +1,23 @@ +# {{ .Root.Name }} +{{ if .Description }} +{{ .Description }} +{{ end }} +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +{{- range .Root.Fields }} +| `{{ .Name }}` | {{ .Type }} | {{ if .Nullable }}No{{ else }}Yes{{ end }} | {{ .Description }}{{- if formatConstraints .Constraints }} ({{ formatConstraints .Constraints }}){{ end }} | +{{- end }} +{{ range .Defs }} + +--- + +## {{ .Name }} + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +{{- range .Fields }} +| `{{ .Name }}` | {{ .Type }} | {{ if .Nullable }}No{{ else }}Yes{{ end }} | {{ .Description }}{{- if formatConstraints .Constraints }} ({{ formatConstraints .Constraints }}){{ end }} | +{{- end }} +{{ end }} diff --git a/internal/translate/markdown/resolver.go b/internal/translate/markdown/resolver.go new file mode 100644 index 0000000..fa56c43 --- /dev/null +++ b/internal/translate/markdown/resolver.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Daco Labs + +// Package markdown provides markdown schema documentation utilities. +package markdown + +import ( + "github.com/dacolabs/cli/internal/translate" +) + +type resolver struct{} + +func (r *resolver) PrimitiveType(schemaType, format string) string { + return schemaType +} + +func (r *resolver) ArrayType(elemType string) string { + return "array(" + elemType + ")" +} + +func (r *resolver) RefType(defName string) string { + return "[" + translate.ToPascalCase(defName) + "](#" + translate.ToPascalCase(defName) + ")" +} + +func (r *resolver) FormatDefName(defName string) string { + return translate.ToPascalCase(defName) +} + +func (r *resolver) FormatRootName(portName string) string { + return translate.ToPascalCase(portName) +} + +func (r *resolver) EnrichField(f *translate.Field) {} diff --git a/internal/translate/markdown/translator.go b/internal/translate/markdown/translator.go new file mode 100644 index 0000000..e18df30 --- /dev/null +++ b/internal/translate/markdown/translator.go @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Daco Labs + +package markdown + +import ( + "bytes" + "embed" + "fmt" + "strings" + "text/template" + + "github.com/dacolabs/cli/internal/translate" + "github.com/dacolabs/jsonschema-go/jsonschema" +) + +//go:embed markdown.go.tmpl +var tmplFS embed.FS + +var funcMap = template.FuncMap{ + "formatConstraints": formatConstraints, +} + +var tmpl = template.Must(template.New("markdown.go.tmpl").Funcs(funcMap).ParseFS(tmplFS, "markdown.go.tmpl")) + +// Translator translates JSON schemas to markdown documentation. +type Translator struct{} + +// FileExtension returns the file extension for markdown files. +func (t *Translator) FileExtension() string { + return ".md" +} + +// Translate converts a JSON schema to markdown documentation. +func (t *Translator) Translate(portName string, schema *jsonschema.Schema, _ string) ([]byte, error) { + data, err := translate.Prepare(portName, schema, &resolver{}) + if err != nil { + return nil, fmt.Errorf("failed to prepare schema data: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "markdown.go.tmpl", data); err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + return buf.Bytes(), nil +} + +// formatConstraints formats the constraints for a field as a human-readable string. +// Template functions receive values by value, so we cannot use a pointer here. +func formatConstraints(c translate.Constraints) string { //nolint:gocritic // hugeParam: template functions receive values by value + var parts []string + + if len(c.Enum) > 0 { + enumVals := make([]string, len(c.Enum)) + for i, v := range c.Enum { + enumVals[i] = fmt.Sprintf("`%v`", v) + } + parts = append(parts, "enum: "+strings.Join(enumVals, ", ")) + } + + if c.Const != nil { + parts = append(parts, fmt.Sprintf("const: `%v`", *c.Const)) + } + + if c.Pattern != "" { + parts = append(parts, fmt.Sprintf("pattern: `%s`", c.Pattern)) + } + + if c.Format != "" { + parts = append(parts, fmt.Sprintf("format: %s", c.Format)) + } + + if c.MinLength != nil { + parts = append(parts, fmt.Sprintf("minLength: %d", *c.MinLength)) + } + + if c.MaxLength != nil { + parts = append(parts, fmt.Sprintf("maxLength: %d", *c.MaxLength)) + } + + if c.Minimum != nil { + parts = append(parts, fmt.Sprintf("minimum: %v", *c.Minimum)) + } + + if c.Maximum != nil { + parts = append(parts, fmt.Sprintf("maximum: %v", *c.Maximum)) + } + + if c.ExclusiveMinimum != nil { + parts = append(parts, fmt.Sprintf("exclusiveMinimum: %v", *c.ExclusiveMinimum)) + } + + if c.ExclusiveMaximum != nil { + parts = append(parts, fmt.Sprintf("exclusiveMaximum: %v", *c.ExclusiveMaximum)) + } + + if c.MultipleOf != nil { + parts = append(parts, fmt.Sprintf("multipleOf: %v", *c.MultipleOf)) + } + + if c.MinItems != nil { + parts = append(parts, fmt.Sprintf("minItems: %d", *c.MinItems)) + } + + if c.MaxItems != nil { + parts = append(parts, fmt.Sprintf("maxItems: %d", *c.MaxItems)) + } + + return strings.Join(parts, ", ") +} diff --git a/internal/translate/markdown/translator_test.go b/internal/translate/markdown/translator_test.go new file mode 100644 index 0000000..801b285 --- /dev/null +++ b/internal/translate/markdown/translator_test.go @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Daco Labs + +package markdown + +import ( + "strings" + "testing" + + "github.com/dacolabs/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTranslate_SimpleObject(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string"}, + "age": {Type: "integer"}, + }, + } + + translator := &Translator{} + output, err := translator.Translate("users", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + assert.Contains(t, result, "# Users") + assert.Contains(t, result, "| `name` | string | No |") + assert.Contains(t, result, "| `age` | integer | No |") +} + +func TestTranslate_AllPrimitiveTypes(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Required: []string{"str", "int", "num", "flag"}, + Properties: map[string]*jsonschema.Schema{ + "str": {Type: "string"}, + "int": {Type: "integer"}, + "num": {Type: "number"}, + "flag": {Type: "boolean"}, + }, + } + + translator := &Translator{} + output, err := translator.Translate("types", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + assert.Contains(t, result, "| `str` | string | Yes |") + assert.Contains(t, result, "| `int` | integer | Yes |") + assert.Contains(t, result, "| `num` | number | Yes |") + assert.Contains(t, result, "| `flag` | boolean | Yes |") +} + +func TestTranslate_DateFormats(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Required: []string{"created_at", "birth_date", "uuid"}, + Properties: map[string]*jsonschema.Schema{ + "created_at": {Type: "string", Format: "date-time"}, + "birth_date": {Type: "string", Format: "date"}, + "uuid": {Type: "string", Format: "uuid"}, + }, + } + + translator := &Translator{} + output, err := translator.Translate("dates", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + assert.Contains(t, result, "| `created_at` | string | Yes |") + assert.Contains(t, result, "format: date-time") + assert.Contains(t, result, "| `birth_date` | string | Yes |") + assert.Contains(t, result, "format: date") + assert.Contains(t, result, "| `uuid` | string | Yes |") + assert.Contains(t, result, "format: uuid") +} + +func TestTranslate_ArrayType(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "tags": { + Type: "array", + Items: &jsonschema.Schema{Type: "string"}, + }, + }, + } + + translator := &Translator{} + output, err := translator.Translate("items", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + assert.Contains(t, result, "array(string)") +} + +func TestTranslate_NestedObject(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "address": { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "street": {Type: "string"}, + "city": {Type: "string"}, + }, + }, + }, + } + + translator := &Translator{} + output, err := translator.Translate("user", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + assert.Contains(t, result, "## Address") + assert.Contains(t, result, "[Address](#Address)") +} + +func TestTranslate_WithDefs(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Required: []string{"address"}, + Properties: map[string]*jsonschema.Schema{ + "address": {Ref: "#/$defs/Address"}, + }, + Defs: map[string]*jsonschema.Schema{ + "Address": { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "street": {Type: "string"}, + }, + }, + }, + } + + translator := &Translator{} + output, err := translator.Translate("user", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + assert.Contains(t, result, "## Address") + assert.Contains(t, result, "[Address](#Address)") +} + +func TestTranslate_TopologicalOrder(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "customer": {Ref: "#/$defs/Customer"}, + }, + Defs: map[string]*jsonschema.Schema{ + "Customer": { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string"}, + "address": {Ref: "#/$defs/Address"}, + }, + }, + "Address": { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "street": {Type: "string"}, + }, + }, + }, + } + + translator := &Translator{} + output, err := translator.Translate("order", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + addressIdx := strings.Index(result, "## Address") + customerIdx := strings.Index(result, "## Customer") + + assert.NotEqual(t, addressIdx, -1, "Address section not found") + assert.NotEqual(t, customerIdx, -1, "Customer section not found") + assert.Less(t, addressIdx, customerIdx, "Address should come before Customer") +} + +func TestTranslate_RequiredFields(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Required: []string{"id", "name"}, + Properties: map[string]*jsonschema.Schema{ + "id": {Type: "integer"}, + "name": {Type: "string"}, + "optional": {Type: "string"}, + }, + } + + translator := &Translator{} + output, err := translator.Translate("user", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + assert.Contains(t, result, "| `id` | integer | Yes |") + assert.Contains(t, result, "| `name` | string | Yes |") + assert.Contains(t, result, "| `optional` | string | No |") +} + +func TestTranslate_WithDescription(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Description: "This is a user schema", + Properties: map[string]*jsonschema.Schema{ + "name": {Type: "string", Description: "The user's full name"}, + }, + } + + translator := &Translator{} + output, err := translator.Translate("user", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + assert.Contains(t, result, "This is a user schema") + assert.Contains(t, result, "The user's full name") +} + +func TestTranslate_WithConstraints(t *testing.T) { + minLen := 1 + maxLen := 100 + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "email": { + Type: "string", + Format: "email", + MinLength: &minLen, + MaxLength: &maxLen, + }, + "status": { + Type: "string", + Enum: []any{"active", "inactive", "pending"}, + }, + }, + } + + translator := &Translator{} + output, err := translator.Translate("user", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + assert.Contains(t, result, "format: email") + assert.Contains(t, result, "minLength: 1") + assert.Contains(t, result, "maxLength: 100") + assert.Contains(t, result, "`active`") + assert.Contains(t, result, "`inactive`") + assert.Contains(t, result, "`pending`") +} + +func TestTranslate_ArrayOfObjects(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "items": { + Type: "array", + Items: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "id": {Type: "integer"}, + "name": {Type: "string"}, + }, + }, + }, + }, + } + + translator := &Translator{} + output, err := translator.Translate("list", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + assert.Contains(t, result, "## Items") + assert.Contains(t, result, "array([Items](#Items))") +} + +func TestTranslate_Title(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "x": {Type: "string"}, + }, + } + + translator := &Translator{} + output, err := translator.Translate("test_schema", schema, "schemas") + require.NoError(t, err) + + result := string(output) + + assert.Contains(t, result, "# TestSchema") +} + +func TestFileExtension(t *testing.T) { + translator := &Translator{} + assert.Equal(t, ".md", translator.FileExtension()) +}