diff --git a/pom.xml b/pom.xml
index 4d179d3f..8191471f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -39,6 +39,7 @@
tasks/argocd
tasks/aws
tasks/confluence
+ tasks/docs
tasks/git
tasks/gremlin
tasks/hashivault
diff --git a/tasks/docs/README.md b/tasks/docs/README.md
new file mode 100644
index 00000000..24b57b56
--- /dev/null
+++ b/tasks/docs/README.md
@@ -0,0 +1,102 @@
+# Concord Plugin: `docs`
+
+The `docs` plugin generates Markdown documentation from Concord YAML flows.
+It extracts flow descriptions, input/output parameters,
+and produces a [mdBook](https://rust-lang.github.io/mdBook/)
+compatible structure - ready for publishing.
+
+## Usage
+
+To use this plugin in your Concord flow:
+
+```yaml
+flows:
+ generateDocs:
+ - task: docs
+ in:
+ output: "${workDir}/docs"
+ bookTitle: "My Project Documentation"
+```
+
+Or define it via a separate Concord profile:
+
+```yaml
+profiles:
+ docs:
+ configuration:
+ extraDependencies:
+ - "mvn://com.walmartlabs.concord.plugins:docs-task:"
+ flows:
+ renderFlowDocs:
+ - task: docs
+ in:
+ bookTitle: "My Project Documentation"
+ output: "${workDir}/docs"
+```
+
+## Task input parameters
+
+| Input Key | Type | Required | Description |
+|----------------------------|---------|----------|-----------------------------------------------------------------------------------------|
+| `output` | string | ✓ | Output directory for generated files |
+| `bookTitle` | string | ✓ | Title used in the `book.toml` metadata |
+| `includeUndocumentedFlows` | boolean | | Generate docs for flows without description (default: true ) |
+| `sourceBaseUrl` | string | | Base URL for linking to flow source files (e.g., https://github.com/ORG/REPO/blob/main) |
+| `flowsDir` | string | | Directory containing external Concord flows |
+
+> **Note:** If `flowsDir` is specified, flow imports will not be resolved during documentation generation.
+
+## Example Output
+
+- `src/**/*.md`: flow documentation
+- `src/SUMMARY.md`: mdBook-style summary with structure
+- `book.toml`: book metadata file
+- `flows.json`: flow descriptions in JSON format
+
+```
+docs/
+├── book.toml
+└── src/
+ ├── my-flow.yaml.md
+ ├── another-flow.yaml.md
+ └── SUMMARY.md
+```
+
+## Flow description format
+
+Flow descriptions are parsed from comments above each flow definition using the following format:
+
+```yaml
+##
+#
+# in:
+# myParam: , mandatory|optional,
+# out:
+# myParam: , mandatory|optional,
+##
+```
+
+Where:
+
+``:A human-readable description of the flow (optional).
+
+``:
+- Basic types: string | int | number | boolean | object | date | any
+- Arrays: string[] | int[] | number[] | boolean[] | object[] | date[] | any[]
+ *int === number, just an alias*
+- Custom types: any string value
+
+``: Description of the parameter (optional).
+
+### Describing Objects
+
+You can describe nested object parameters using dot notation:
+
+```yaml
+##
+# in:
+# objectParam: object, mandatory|optional,
+# objectParam.key1: , mandatory|optional,
+# objectParam.key2: , mandatory|optional,
+##
+```
diff --git a/tasks/docs/example/concord.yaml b/tasks/docs/example/concord.yaml
new file mode 100644
index 00000000..3c5b1078
--- /dev/null
+++ b/tasks/docs/example/concord.yaml
@@ -0,0 +1,29 @@
+configuration:
+ runtime: concord-v2
+
+flows:
+ ##
+ # My flow description
+ #
+ # in:
+ # param1: string, mandatory, param1 description
+ # param2: object, optional
+ # param2.key: string, mandatory, key description
+ # out:
+ # out1: string, mandatory, out1 description
+ # out2: string, optional
+ ##
+ myFlow:
+ - log: "Hello"
+
+profiles:
+ docs:
+ configuration:
+ extraDependencies:
+ - "mvn://com.walmartlabs.concord.plugins:docs-task:2.7.1-SNAPSHOT"
+ flows:
+ renderFlowDocs:
+ - task: docs
+ in:
+ bookTitle: "My Project Documentation"
+ output: "${workDir}/docs"
diff --git a/tasks/docs/pom.xml b/tasks/docs/pom.xml
new file mode 100644
index 00000000..9dfdd405
--- /dev/null
+++ b/tasks/docs/pom.xml
@@ -0,0 +1,81 @@
+
+
+
+
+ com.walmartlabs.concord.plugins
+ concord-plugins-parent
+ 2.7.1-SNAPSHOT
+ ../../pom.xml
+
+
+ 4.0.0
+
+ docs-task
+ takari-jar
+
+
+
+
+
+
+ com.walmartlabs.concord.runtime.v2
+ concord-runtime-sdk-v2
+ provided
+
+
+ com.walmartlabs.concord.runtime.v2
+ concord-runtime-model-v2
+ provided
+
+
+ com.walmartlabs.concord
+ concord-common
+ ${concord.version}
+ provided
+
+
+ com.walmartlabs.concord
+ concord-imports
+ ${concord.version}
+ provided
+
+
+
+ javax.inject
+ javax.inject
+ provided
+
+
+ org.slf4j
+ slf4j-api
+ provided
+
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+ provided
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ provided
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ provided
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ ch.qos.logback
+ logback-classic
+ test
+
+
+
diff --git a/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/DocsTaskV2.java b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/DocsTaskV2.java
new file mode 100644
index 00000000..ccbc0aa6
--- /dev/null
+++ b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/DocsTaskV2.java
@@ -0,0 +1,186 @@
+package com.walmartlabs.concord.plugins.docs;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc., Concord Authors
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.walmartlabs.concord.imports.ImportsListener;
+import com.walmartlabs.concord.imports.NoopImportManager;
+import com.walmartlabs.concord.runtime.v2.NoopImportsNormalizer;
+import com.walmartlabs.concord.runtime.v2.ProjectLoaderV2;
+import com.walmartlabs.concord.runtime.v2.model.Flow;
+import com.walmartlabs.concord.runtime.v2.model.ProcessDefinition;
+import com.walmartlabs.concord.runtime.v2.sdk.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static com.walmartlabs.concord.plugins.docs.FlowDescriptionParser.FlowDescription;
+
+@Named("docs")
+public class DocsTaskV2 implements Task {
+
+ private static final Logger log = LoggerFactory.getLogger(DocsTaskV2.class);
+
+ private final Context context;
+
+ @Inject
+ public DocsTaskV2(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public TaskResult.SimpleResult execute(Variables input) throws Exception {
+ var includeUndocumentedFlows = input.getBoolean("includeUndocumentedFlows", true);
+ var flowsDir = getPathOrNull(input, "flowsDir");
+
+ ProcessDefinition processDefinition;
+ if (flowsDir == null) {
+ log.info("Loading flows from current process");
+ flowsDir = context.workingDirectory();
+ processDefinition = context.execution().processDefinition();
+ } else {
+ log.info("Loading flows from '{}'", flowsDir);
+ processDefinition = loadProcessDefinition(flowsDir);
+ }
+
+ var flowLinesByFileName = collectFlowLineNumbers(processDefinition.flows());
+ var flowDescriptionByFileName = new LinkedHashMap>();
+ var undocumentedFlowsByFileName = new LinkedHashMap>();
+ for (var entry : flowLinesByFileName.entrySet()) {
+ var sourcePath = flowsDir.resolve(entry.getKey());
+ if (!Files.exists(sourcePath)) {
+ log.warn("Flows file '{}' does not exist", entry.getKey());
+ continue;
+ }
+
+ var lines = Files.readAllLines(sourcePath);
+ var descriptions = new ArrayList();
+ var missing = new ArrayList();
+
+ for (var flowLineNum : entry.getValue()) {
+ var desc = FlowDescriptionParser.parse(flowLineNum.flowName, lines, flowLineNum.lineNum);
+ if (desc != null) {
+ descriptions.add(desc);
+ } else {
+ if (includeUndocumentedFlows) {
+ descriptions.add(new FlowDescription(flowLineNum.lineNum + 1, flowLineNum.flowName, null, List.of(), List.of(), List.of()));
+ }
+ missing.add(flowLineNum.flowName);
+ }
+ }
+
+ if (!descriptions.isEmpty()) {
+ flowDescriptionByFileName.put(entry.getKey(), descriptions);
+ }
+ if (!missing.isEmpty()) {
+ undocumentedFlowsByFileName.put(entry.getKey(), missing);
+ }
+ }
+
+ var outputDir = Path.of(input.assertString("output"));
+ MdBookGenerator.generate(input.assertString("bookTitle"), flowDescriptionByFileName, input.getString("sourceBaseUrl"), outputDir);
+
+ logMissingFlowDocs(processDefinition.flows().size(), undocumentedFlowsByFileName);
+
+ new ObjectMapper()
+ .setSerializationInclusion(JsonInclude.Include.NON_NULL)
+ .setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
+ .writerWithDefaultPrettyPrinter()
+ .writeValue(outputDir.resolve("flows.json").toFile(), flowDescriptionByFileName);
+
+ return TaskResult.success();
+ }
+
+ private Path getPathOrNull(Variables input, String key) {
+ var v = input.getString(key);
+ if (v == null) {
+ return null;
+ }
+ var p = normalize(context.workingDirectory(), v);
+ if (Files.notExists(p)) {
+ throw new UserDefinedException("'" + key + "' directory does not exists: " + p);
+ }
+ return p;
+ }
+
+ private ProcessDefinition loadProcessDefinition(Path flowsDir) {
+ ProjectLoaderV2.Result loadResult;
+ try {
+ loadResult = new ProjectLoaderV2(new NoopImportManager())
+ .load(flowsDir, new NoopImportsNormalizer(), ImportsListener.NOP_LISTENER);
+ } catch (Exception e) {
+ log.error("Error while loading flows: {}", e.getMessage(), e);
+ throw new RuntimeException("Error while loading flows: " + e.getMessage());
+ }
+
+ return loadResult.getProjectDefinition();
+ }
+
+ private static Map> collectFlowLineNumbers(Map flows) {
+ return flows.entrySet().stream()
+ .map(entry -> {
+ var loc = entry.getValue().location();
+ var fileName = loc.fileName();
+ int lineNum = loc.lineNum() - 1; // Concord line numbers are 1 based :)
+ if (fileName == null || lineNum < 0) {
+ return null;
+ }
+ return Map.entry(fileName, new FlowLineNum(entry.getKey(), lineNum));
+ })
+ .filter(Objects::nonNull)
+ .collect(
+ Collectors.groupingBy(
+ Map.Entry::getKey,
+ Collectors.mapping(Map.Entry::getValue, Collectors.toList())
+ )
+ );
+ }
+
+ private static Path normalize(Path workDir, String path) {
+ var p = Path.of(path);
+ if (p.startsWith(workDir)) {
+ return p.toAbsolutePath();
+ }
+ return workDir.resolve(p).toAbsolutePath();
+ }
+
+ private static void logMissingFlowDocs(int total, Map> missing) {
+ if (missing.isEmpty()) {
+ return;
+ }
+
+ log.warn("Flows without description ({}/{}):", missing.values().stream().mapToInt(List::size).sum(), total);
+
+ for (var entry : missing.entrySet()) {
+ log.warn("{} ({}):\n{}", entry.getKey(), entry.getValue().size(), String.join("\n", entry.getValue()));
+ }
+ }
+
+ record FlowLineNum (String flowName, int lineNum) {
+ }
+}
diff --git a/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParser.java b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParser.java
new file mode 100644
index 00000000..05936884
--- /dev/null
+++ b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParser.java
@@ -0,0 +1,182 @@
+package com.walmartlabs.concord.plugins.docs;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc., Concord Authors
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public final class FlowDescriptionParser {
+
+ public record FlowDescription (int lineNum, String name, String description, List in, List out, List tags) {
+
+ record Parameter (String name, String type, boolean required, String description) {
+ }
+ }
+
+ public static FlowDescription parse(String flowName, List lines, int flowLineNum) {
+ if (!lines.get(flowLineNum).contains(flowName)) {
+ throw new IllegalArgumentException("Invalid flowLineNum " + flowLineNum + ": expected flow name '" + flowName+ "' at line '" + lines.get(flowLineNum) + "'");
+ }
+
+ var descriptionStart = findDescriptionStart(lines, flowLineNum);
+ if (descriptionStart == -1) {
+ return null;
+ }
+
+ return parseCommentBlock(flowName, lines, descriptionStart, flowLineNum);
+ }
+
+ private static FlowDescription parseCommentBlock(String flowName, List lines, int startLine, int endLine) {
+ enum State { DESCRIPTION, INPUTS, OUTPUTS, OTHER }
+
+ var state = State.DESCRIPTION;
+ var descriptionBuilder = new StringBuilder();
+ var inputs = new ArrayList();
+ var outputs = new ArrayList();
+ var tags = List.of();
+
+ for (var i = startLine; i < endLine; i++) {
+ var line = lines.get(i).trim();
+ if (!line.startsWith("#")) {
+ continue;
+ }
+
+ var content = line.replaceFirst("#+", "").trim();
+
+ if ("in:".equalsIgnoreCase(content)) {
+ state = State.INPUTS;
+ continue;
+ } else if ("out:".equalsIgnoreCase(content)) {
+ state = State.OUTPUTS;
+ continue;
+ } else if (content.toLowerCase().startsWith("tags:")) {
+ var tagsContent = content.substring(5).trim();
+ if (!tagsContent.isEmpty()) {
+ tags = Arrays.asList(tagsContent.split("\\s*,\\s*"));
+ }
+ state = State.OTHER;
+ continue;
+ }
+
+ switch (state) {
+ case DESCRIPTION -> {
+ if (!content.isEmpty()) {
+ if (!descriptionBuilder.isEmpty()) {
+ descriptionBuilder.append(" ");
+ }
+ descriptionBuilder.append(content);
+ }
+ }
+ case INPUTS -> {
+ var param = parseParameterLine(content);
+ if (param != null) {
+ inputs.add(param);
+ }
+ }
+ case OUTPUTS -> {
+ var param = parseParameterLine(content);
+ if (param != null) {
+ outputs.add(param);
+ }
+ }
+ case OTHER -> {
+ // ignore other lines
+ }
+ }
+ }
+
+ return new FlowDescription(endLine + 1, flowName, descriptionBuilder.isEmpty() ? null : descriptionBuilder.toString(), inputs, outputs, tags);
+ }
+
+ private static int findDescriptionStart(List lines, int flowLineNum) {
+ if (flowLineNum >= lines.size()) {
+ return -1;
+ }
+
+ int result = -1;
+ var expectedIndent = getIndent(lines.get(flowLineNum));
+ for (var i = flowLineNum - 1; i >= 0; i--) {
+ var line = lines.get(i);
+ var trimmedLine = line.trim();
+ if (trimmedLine.isEmpty()) {
+ continue;
+ }
+ var lineIndent = getIndent(line);
+ if (lineIndent != expectedIndent) {
+ break;
+ }
+ if (trimmedLine.startsWith("#")) {
+ result = i;
+ } else {
+ break;
+ }
+ }
+ return result;
+ }
+
+ private static int getIndent(String line) {
+ var index = 0;
+ while (index < line.length() && Character.isWhitespace(line.charAt(index))) {
+ index++;
+ }
+ return index;
+ }
+
+ private static FlowDescription.Parameter parseParameterLine(String content) {
+ int nameEnd = content.indexOf(':');
+ if (nameEnd == -1) {
+ return null;
+ }
+
+ int typeEnd = content.indexOf(',', nameEnd);
+ if (typeEnd == -1) {
+ return null;
+ }
+
+ int requiredEnd = content.indexOf(',', typeEnd + 1);
+ if (requiredEnd == -1) {
+ requiredEnd = content.length();
+ }
+
+ var name = content.substring(0, nameEnd).trim();
+ var type = content.substring(nameEnd + 1, typeEnd).trim();
+
+ var requiredString = content.substring(typeEnd + 1, requiredEnd).trim();
+ var required = requiredToBoolean(requiredString);
+ String description = null;
+ if (requiredEnd < content.length()) {
+ description = content.substring(requiredEnd + 1).trim();
+ if (description.isEmpty()) {
+ description = null;
+ }
+ }
+
+ return new FlowDescription.Parameter(name, type, required, description);
+ }
+
+ private static boolean requiredToBoolean(String str) {
+ return "mandatory".equalsIgnoreCase(str) || "required".equalsIgnoreCase(str);
+ }
+
+ private FlowDescriptionParser() {
+ }
+}
diff --git a/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/MdBookGenerator.java b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/MdBookGenerator.java
new file mode 100644
index 00000000..c22341a4
--- /dev/null
+++ b/tasks/docs/src/main/java/com/walmartlabs/concord/plugins/docs/MdBookGenerator.java
@@ -0,0 +1,200 @@
+package com.walmartlabs.concord.plugins.docs;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc., Concord Authors
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.*;
+
+import static com.walmartlabs.concord.plugins.docs.FlowDescriptionParser.FlowDescription;
+
+public final class MdBookGenerator {
+
+ private static final Comparator SUMMARY_COMPARATOR = Comparator.comparing(
+ e -> Arrays.asList(e.title().split("/")),
+ (a, b) -> {
+ int len = Math.min(a.size(), b.size());
+ for (int i = 0; i < len; i++) {
+ int cmp = a.get(i).compareTo(b.get(i));
+ if (cmp != 0) {
+ return cmp;
+ }
+ }
+ return Integer.compare(a.size(), b.size());
+ });
+
+ private record SummaryEntry (String title, String link) {
+ }
+
+ public static void generate(String title, LinkedHashMap> flowDescriptionByFileName, String sourceBaseUrl, Path outputDir) throws IOException {
+ var outputSrcDir = outputDir.resolve("src");
+ Files.createDirectories(outputSrcDir);
+
+ var flowPagePath = new HashMap();
+ for (var entry : flowDescriptionByFileName.entrySet()) {
+ var outFile = writeFlowsPage(entry.getKey(), entry.getValue(), sourceBaseUrl, outputSrcDir);
+ flowPagePath.put(entry.getKey(), outFile);
+ }
+ writeSummary(flowPagePath, outputSrcDir);
+ writeBook(title, outputDir);
+ }
+
+ private static void writeSummary(Map filesToPaths, Path outputDir) throws IOException {
+ var sorted = filesToPaths.entrySet().stream()
+ .map(e -> {
+ var title = e.getKey();
+ var link = "./" + outputDir.relativize(e.getValue().toAbsolutePath());
+ return new SummaryEntry(title, link);
+ })
+ .sorted(SUMMARY_COMPARATOR)
+ .toList();
+
+ var output = outputDir.resolve("SUMMARY.md");
+
+ try (var writer = Files.newBufferedWriter(output, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
+ writer.write("# Summary\n\n");
+
+ var unfoldedEntries = new ArrayList();
+ String currentTopLevel = null;
+ String currentFolderPath = null;
+ for (var entry : sorted) {
+ var parts = entry.title().split("/");
+ if (parts.length < 2) {
+ unfoldedEntries.add(entry);
+ continue;
+ }
+
+ var topLevel = parts[0];
+ var fileName = parts[parts.length - 1];
+ var folderPath = String.join("/", Arrays.copyOfRange(parts, 1, parts.length - 1));
+
+ if (!topLevel.equals(currentTopLevel)) {
+ writer.write("\n# " + topLevel + "\n\n");
+ currentTopLevel = topLevel;
+ currentFolderPath = null;
+ }
+
+ if (!folderPath.equals(currentFolderPath)) {
+ writer.write("- [" + folderPath + "]()\n");
+ currentFolderPath = folderPath;
+ }
+
+ writer.write(" - [" + fileName + "](" + entry.link() + ")\n");
+ }
+
+ if (!unfoldedEntries.isEmpty()) {
+ if (unfoldedEntries.size() != filesToPaths.size()) {
+ writer.write("---\n");
+ }
+
+ for (var entry : unfoldedEntries) {
+ writer.write(" - [" + entry.title() + "](" + entry.link() + ")\n");
+ }
+ }
+ }
+ }
+
+ private static Path writeFlowsPage(String flowFileName, List flows, String sourceBaseUrl, Path outputDir) throws IOException {
+ var output = outputDir.resolve(flowFileName + ".md");
+ Files.createDirectories(output.getParent());
+
+ var normalizedBaseUrl = normalizeUrl(sourceBaseUrl);
+ try (var writer = Files.newBufferedWriter(output, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
+ writer.write("# " + flowFileName + "\n\n");
+
+ for (var flow : flows) {
+ writer.write("## `" + flow.name() + "`\n\n");
+ writer.write("**Description**: \n" + orNone(flow.description()) + "\n\n");
+
+ if (!flow.tags().isEmpty()) {
+ writer.write("**Tags**: `" + String.join("`, `", flow.tags()) + "`\n\n");
+ }
+
+ writeParamTable(writer, "Inputs", flow.in());
+ writeParamTable(writer, "Outputs", flow.out());
+
+ if (sourceBaseUrl != null) {
+ var link = normalizedBaseUrl + flowFileName + "#L" + flow.lineNum();
+ writer.write("View source code\n\n");
+ }
+
+ writer.write("---\n\n");
+ }
+ }
+ return output.toAbsolutePath();
+ }
+
+ private static String normalizeUrl(String sourceBaseUrl) {
+ if (sourceBaseUrl == null || sourceBaseUrl.endsWith("/")) {
+ return sourceBaseUrl;
+ }
+
+ return sourceBaseUrl + "/";
+ }
+
+ private static void writeBook(String title, Path outputDir) throws IOException {
+ var output = outputDir.resolve("book.toml");
+ try (var writer = Files.newBufferedWriter(output, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
+ writer.write("[book]\n");
+ writer.write("authors = [\"concord-docs\"]\n");
+ writer.write("language = \"en\"\n");
+ writer.write("src = \"src\"\n");
+ writer.write("title = \""+ title + "\"\n");
+ }
+ }
+
+ private static void writeParamTable(Appendable writer, String label, List params) throws IOException {
+ if (params == null || params.isEmpty()) {
+ writer.append("**").append(label).append("**: \n_None_\n\n");
+ return;
+ }
+
+ writer.append("**").append(label).append("**:\n\n");
+ writer.append("| Name | Type | Required | Description |\n");
+ writer.append("|------|------|----------|-------------|\n");
+
+ for (var p : params) {
+ writer.append(String.format("| `%s` | %s | %s | %s |\n",
+ p.name(),
+ p.type(),
+ p.required() ? "✓" : "",
+ escapeMd(p.description())));
+ }
+
+ writer.append("\n");
+ }
+
+ private static String orNone(String s) {
+ return (s == null || s.isBlank()) ? "_None_" : s;
+ }
+
+ private static String escapeMd(String text) {
+ if (text == null) {
+ return "";
+ }
+
+ return text.replace("|", "\\|")
+ .replace("_", "\\_")
+ .replace("*", "\\*");
+ }
+}
diff --git a/tasks/docs/src/test/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParserTest.java b/tasks/docs/src/test/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParserTest.java
new file mode 100644
index 00000000..91d8ba90
--- /dev/null
+++ b/tasks/docs/src/test/java/com/walmartlabs/concord/plugins/docs/FlowDescriptionParserTest.java
@@ -0,0 +1,175 @@
+package com.walmartlabs.concord.plugins.docs;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc., Concord Authors
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public final class FlowDescriptionParserTest {
+
+ @Test
+ void testParseFullCommentBlock() {
+ var lines = """
+ flows:
+ ##
+ # Creates a compute instance
+ #
+ # in:
+ # label: string, mandatory, label for the instance
+ # hostname: string, optional, hostname of the instance
+ # out:
+ # result: string, mandatory, Some result
+ # tags: compute, infra
+ ##
+ createInstance:
+ """.lines().toList();
+
+ var result = FlowDescriptionParser.parse("createInstance", lines, lines.size() - 1);
+
+ assertNotNull(result);
+ assertEquals("createInstance", result.name());
+ assertEquals("Creates a compute instance", result.description());
+
+ assertEquals(List.of("compute", "infra"), result.tags());
+
+ assertEquals(2, result.in().size());
+
+ var param1 = result.in().get(0);
+ assertEquals("label", param1.name());
+ assertEquals("string", param1.type());
+ assertTrue(param1.required());
+ assertEquals("label for the instance", param1.description());
+
+ var param2 = result.in().get(1);
+ assertEquals("hostname", param2.name());
+ assertEquals("string", param2.type());
+ assertFalse(param2.required());
+ assertEquals("hostname of the instance", param2.description());
+
+ assertEquals(1, result.out().size());
+ var oparam1 = result.out().get(0);
+ assertEquals("result", oparam1.name());
+ assertEquals("string", oparam1.type());
+ assertTrue(oparam1.required());
+ assertEquals("Some result", oparam1.description());
+ }
+
+ @Test
+ void testMissingCommentBlockReturnsNull() {
+ List lines = List.of(
+ "flows:",
+ " createInstance:"
+ );
+
+ var result = FlowDescriptionParser.parse("createInstance", lines, 1);
+ assertNull(result);
+ }
+
+ @Test
+ void testOnlyDescription() {
+ List lines = List.of(
+ "flows:",
+ " ##",
+ " # Just a description without inputs",
+ " ##",
+ " simpleFlow:"
+ );
+
+ var result = FlowDescriptionParser.parse("simpleFlow", lines, 4);
+ assertNotNull(result);
+ assertEquals("Just a description without inputs", result.description());
+ assertTrue(result.in().isEmpty());
+ assertTrue(result.out().isEmpty());
+ assertTrue(result.tags().isEmpty());
+ }
+
+ @Test
+ void testParsingWithoutDescription() {
+ var lines = """
+ flows:
+ #
+ # in:
+ # label: string, mandatory, label for the instance
+ # hostname: string, optional, hostname of the instance
+ # out:
+ # result: string, mandatory, Some result
+ # tags: compute, infra
+ createInstance:
+ """.lines().toList();
+
+ var result = FlowDescriptionParser.parse("createInstance", lines, 8);
+
+ assertNotNull(result);
+ assertEquals("createInstance", result.name());
+ assertNull(result.description());
+
+ assertEquals(2, result.in().size());
+ assertEquals(1, result.out().size());
+ assertEquals(2, result.tags().size());
+ }
+
+ @Test
+ void testParamParsingWithoutDescription() {
+ var lines = """
+ flows:
+ #
+ # in:
+ # label: string, mandatory
+ # hostname: string, optional,
+ # out:
+ # result: string, mandatory,
+ # tags: compute, infra
+ createInstance:
+ """.lines().toList();
+
+ var result = FlowDescriptionParser.parse("createInstance", lines, 8);
+
+ assertNotNull(result);
+ assertEquals("createInstance", result.name());
+ assertNull(result.description());
+
+ assertEquals(2, result.in().size());
+
+ var param1 = result.in().get(0);
+ assertEquals("label", param1.name());
+ assertEquals("string", param1.type());
+ assertTrue(param1.required());
+ assertNull(param1.description());
+
+ var param2 = result.in().get(1);
+ assertEquals("hostname", param2.name());
+ assertEquals("string", param2.type());
+ assertFalse(param2.required());
+ assertNull(param2.description());
+
+ assertEquals(1, result.out().size());
+ var oparam1 = result.out().get(0);
+ assertEquals("result", oparam1.name());
+ assertEquals("string", oparam1.type());
+ assertTrue(oparam1.required());
+ assertNull(oparam1.description());
+
+ assertEquals(2, result.tags().size());
+ }
+}
diff --git a/tasks/docs/src/test/resources/logback.xml b/tasks/docs/src/test/resources/logback.xml
new file mode 100644
index 00000000..ceaaef4b
--- /dev/null
+++ b/tasks/docs/src/test/resources/logback.xml
@@ -0,0 +1,13 @@
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] [%-5level] %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+