This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new b223a80dd6e1 CAMEL-25283: camel-kamelet - document how to write a
custom Kamelet, and let camel_catalog_doc return a component's sub-pages
(#27299)
b223a80dd6e1 is described below
commit b223a80dd6e16bfb001fca4dd7869579bff4f660
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 2 21:47:55 2026 +0200
CAMEL-25283: camel-kamelet - document how to write a custom Kamelet, and
let camel_catalog_doc return a component's sub-pages (#27299)
* CAMEL-25283: camel-kamelet - document how to write a custom Kamelet, and
let camel_catalog_doc return a component's sub-pages
The kamelet page gets a short section on writing a custom Kamelet and its
three kinds (source, sink, action), and a
new sub-page kamelet-custom.adoc shows the file, the parameters ({{name}},
{{?name}}, defaults), one example of each
kind, how a route uses them, and where Camel finds a Kamelet. The examples
were run with camel run.
camel_catalog_doc now answers docPage for components too: the sub-pages a
component page links to under others are
listed as docPages with their titles, and docPage=<page> returns one. A
model is pointed at the Kamelet page from the
kamelet answer, from its samples, and when a .kamelet.yaml it validates or
writes is invalid.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-25283: write the inline placeholders as literal monospace so Antora
does not read them as attributes
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---------
Signed-off-by: Claus Ibsen <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../org/apache/camel/catalog/docs.properties | 1 +
.../camel/catalog/docs/kamelet-component.adoc | 12 ++
.../apache/camel/catalog/docs/kamelet-custom.adoc | 228 +++++++++++++++++++++
.../src/main/docs/kamelet-component.adoc | 12 ++
.../src/main/docs/kamelet-custom.adoc | 228 +++++++++++++++++++++
docs/components/modules/others/nav.adoc | 1 +
.../modules/others/pages/kamelet-custom.adoc | 1 +
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 13 ++
.../dsl/jbang/core/commands/ai/CatalogDocs.java | 48 ++++-
.../dsl/jbang/core/commands/ai/CatalogSamples.java | 4 +
.../commands/ai/CatalogDocComponentPagesTest.java | 147 +++++++++++++
11 files changed, 693 insertions(+), 2 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
index 2f4b8a857b91..3971d7d6053c 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
@@ -330,6 +330,7 @@ jta
jte-component
kafka-component
kamelet-component
+kamelet-custom
kamelet-eip
kamelet-main
kamelet-main-support
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-component.adoc
index 0ae87469eadf..dbf297fb88f6 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-component.adoc
@@ -39,6 +39,18 @@ The *kamelet* endpoint is *lenient*, which means that the
endpoint accepts addit
If a xref:manual::route-template.adoc[Route Template] is not found, the
*kamelet* endpoint tries to load the related *kamelet* definition from the file
system (by default `classpath:kamelets`). The default resolution mechanism
expects _Kamelets_ files to have the extension `.kamelet.yaml`.
+=== Writing a custom Kamelet
+
+You can write your own Kamelets, for a piece of an integration that several
routes need. A Kamelet is a
+`.kamelet.yaml` file with a route template and a description of its
parameters, and it is one of three kinds,
+set by the `camel.apache.org/kamelet.type` label:
+
+* `source`: produces messages, and is used as the start of a route (`from:
kamelet:<name>`).
+* `sink`: receives the message of a route and sends it somewhere (`to:
kamelet:<name>`).
+* `action`: changes the message as a step in the middle of a route (`to:
kamelet:<name>`).
+
+See xref:others:kamelet-custom.adoc[Writing a custom Kamelet] for the file,
with an example of each kind.
+
=== Error Handling
The error handling when using kamelets are using the same error handling
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-custom.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-custom.adoc
new file mode 100644
index 000000000000..122e53d64257
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/kamelet-custom.adoc
@@ -0,0 +1,228 @@
+= Kamelet - Writing a custom Kamelet
+:tabs-sync-option:
+
+xref:ROOT:kamelet-component.adoc[Back to Kamelet Component]
+
+A Kamelet is a route template in a `.kamelet.yaml` file, together with a
description of the parameters it takes.
+You write one when the same piece of an integration is needed in several
routes: a policy such as removing
+personal data, a connection to a system with your company's settings, or a
destination every route writes to.
+The routes then use it by name, with their own parameter values, like any
other endpoint.
+
+== The three kinds of Kamelet
+
+Every Kamelet is one of three kinds, set by the
`camel.apache.org/kamelet.type` label. The kind decides where its
+template starts and ends, and where a route uses it.
+
+[cols="1,3,2",options="header"]
+|===
+| Kind | Template | Used in a route as
+
+| `source`
+| Starts at a component that produces messages (a timer, a queue, an API), and
ends with `to: kamelet:sink`, which
+ hands each message to the route.
+| `from: kamelet:<name>`
+
+| `sink`
+| Starts with `from: kamelet:source`, which receives the message of the route,
and ends at a component that sends
+ it somewhere (a file, a topic, an API).
+| `to: kamelet:<name>`, usually as the last step
+
+| `action`
+| Starts with `from: kamelet:source` and changes the message (filter fields,
convert, enrich). The changed message
+ goes back to the route.
+| `to: kamelet:<name>`, as a step in the middle
+|===
+
+== The file
+
+A Kamelet file is a YAML object (not a list of routes like a `.camel.yaml`
file) with these parts:
+
+`apiVersion` and `kind`:: always `camel.apache.org/v1` and `Kamelet`.
+`metadata.name`:: the name routes use in `kamelet:<name>`. Name the file after
it: `<name>.kamelet.yaml`.
+`metadata.labels`:: `camel.apache.org/kamelet.type` with `source`, `sink` or
`action`.
+`spec.definition`:: the parameters as a JSON schema: a `title` and
`description` for the Kamelet, the names of the
+`required` parameters, and under `properties` each parameter with its `title`,
`description`, `type`, and an
+optional `default`.
+`spec.dependencies`:: the Camel components and libraries the template uses, as
`camel:<name>` or
+`mvn:<groupId>:<artifactId>:<version>`.
+`spec.template`:: the route itself, written as in the YAML DSL: a `from` with
its `steps`, and optionally `beans`.
+
+In the template a parameter is written as a property placeholder:
`+{{customer}}+`. A parameter that may be left out
+is written `+{{?customer}}+`, which removes the option it sets when no value
is given, instead of failing.
+
+== A source Kamelet
+
+This source produces a sample order at a fixed interval. The route that uses
it chooses the customer, and may change
+the interval, which has a default.
+
+.order-source.kamelet.yaml
+[source,yaml]
+----
+apiVersion: camel.apache.org/v1
+kind: Kamelet
+metadata:
+ name: order-source
+ labels:
+ camel.apache.org/kamelet.type: source
+spec:
+ definition:
+ title: Order Source
+ description: Produces a sample order at a fixed interval
+ required:
+ - customer
+ properties:
+ customer:
+ title: Customer
+ description: The customer name to put in the order
+ type: string
+ period:
+ title: Period
+ description: Milliseconds between orders
+ type: integer
+ default: 5000
+ dependencies:
+ - "camel:timer"
+ template:
+ from:
+ uri: timer:orders
+ parameters:
+ period: "{{period}}"
+ steps:
+ - setBody:
+ simple:
+ expression: '{"id": "${exchangeId}", "customer": "{{customer}}",
"email": "[email protected]", "country": "DK"}'
+ - to:
+ uri: kamelet:sink
+----
+
+== An action Kamelet
+
+This action keeps only the fields of a JSON object that are named in its
`allowlist` parameter, so personal data
+such as the customer and email does not travel further.
+
+.content-filter-action.kamelet.yaml
+[source,yaml]
+----
+apiVersion: camel.apache.org/v1
+kind: Kamelet
+metadata:
+ name: content-filter-action
+ labels:
+ camel.apache.org/kamelet.type: action
+spec:
+ definition:
+ title: Content Filter Action
+ description: Keeps only the listed fields of a JSON object
+ required:
+ - allowlist
+ properties:
+ allowlist:
+ title: Allowlist
+ description: Comma separated names of the fields to keep
+ type: string
+ dependencies:
+ - "camel:jq"
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - setBody:
+ jq:
+ expression: 'with_entries(select(.key as $k | "{{allowlist}}" |
split(",") | index($k)))'
+----
+
+== A sink Kamelet
+
+This sink writes each message to a file in a directory.
+
+.archive-sink.kamelet.yaml
+[source,yaml]
+----
+apiVersion: camel.apache.org/v1
+kind: Kamelet
+metadata:
+ name: archive-sink
+ labels:
+ camel.apache.org/kamelet.type: sink
+spec:
+ definition:
+ title: Archive Sink
+ description: Writes each message to a file in a directory
+ required:
+ - directory
+ properties:
+ directory:
+ title: Directory
+ description: The directory to write the files to
+ type: string
+ fileExtension:
+ title: File Extension
+ description: The extension of the file names
+ type: string
+ default: json
+ dependencies:
+ - "camel:file"
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - to:
+ uri: "file:{{directory}}"
+ parameters:
+ fileName: "${exchangeId}.{{fileExtension}}"
+----
+
+== Using the Kamelets in a route
+
+A route uses each Kamelet by name, passing the parameters as endpoint
parameters. Parameters with a default can be
+left out, or given to override the default:
+
+.orders.camel.yaml
+[source,yaml]
+----
+- route:
+ id: export-orders
+ from:
+ uri: kamelet:order-source
+ parameters:
+ customer: Donald
+ period: 2000
+ steps:
+ - to:
+ uri: kamelet:content-filter-action
+ parameters:
+ allowlist: id,country
+ - log:
+ message: "Filtered: ${body}"
+ - to:
+ uri: kamelet:archive-sink
+ parameters:
+ directory: target/orders
+----
+
+Each file written then holds only the fields that are allowed, such as
`{"id":"...","country":"DK"}`.
+
+== Where Camel finds a Kamelet
+
+When a route uses `kamelet:<name>`, Camel loads `<name>.kamelet.yaml` from the
`location` of the Kamelet component,
+which is `classpath:kamelets` by default. Set
`camel.component.kamelet.location` to look elsewhere; several locations
+can be given, separated by comma.
+
+With the Camel CLI, the Kamelet files can simply be run with the route:
+
+[source,bash]
+----
+camel run orders.camel.yaml order-source.kamelet.yaml
content-filter-action.kamelet.yaml archive-sink.kamelet.yaml
+----
+
+A Kamelet can also call another Kamelet in its template, as any route can.
+
+== Checking a Kamelet
+
+`camel validate yaml` checks a Kamelet file: the template against the YAML
DSL, with the errors reported at their
+line in the template.
+
+[source,bash]
+----
+camel validate yaml content-filter-action.kamelet.yaml
+----
diff --git a/components/camel-kamelet/src/main/docs/kamelet-component.adoc
b/components/camel-kamelet/src/main/docs/kamelet-component.adoc
index 0ae87469eadf..dbf297fb88f6 100644
--- a/components/camel-kamelet/src/main/docs/kamelet-component.adoc
+++ b/components/camel-kamelet/src/main/docs/kamelet-component.adoc
@@ -39,6 +39,18 @@ The *kamelet* endpoint is *lenient*, which means that the
endpoint accepts addit
If a xref:manual::route-template.adoc[Route Template] is not found, the
*kamelet* endpoint tries to load the related *kamelet* definition from the file
system (by default `classpath:kamelets`). The default resolution mechanism
expects _Kamelets_ files to have the extension `.kamelet.yaml`.
+=== Writing a custom Kamelet
+
+You can write your own Kamelets, for a piece of an integration that several
routes need. A Kamelet is a
+`.kamelet.yaml` file with a route template and a description of its
parameters, and it is one of three kinds,
+set by the `camel.apache.org/kamelet.type` label:
+
+* `source`: produces messages, and is used as the start of a route (`from:
kamelet:<name>`).
+* `sink`: receives the message of a route and sends it somewhere (`to:
kamelet:<name>`).
+* `action`: changes the message as a step in the middle of a route (`to:
kamelet:<name>`).
+
+See xref:others:kamelet-custom.adoc[Writing a custom Kamelet] for the file,
with an example of each kind.
+
=== Error Handling
The error handling when using kamelets are using the same error handling
diff --git a/components/camel-kamelet/src/main/docs/kamelet-custom.adoc
b/components/camel-kamelet/src/main/docs/kamelet-custom.adoc
new file mode 100644
index 000000000000..122e53d64257
--- /dev/null
+++ b/components/camel-kamelet/src/main/docs/kamelet-custom.adoc
@@ -0,0 +1,228 @@
+= Kamelet - Writing a custom Kamelet
+:tabs-sync-option:
+
+xref:ROOT:kamelet-component.adoc[Back to Kamelet Component]
+
+A Kamelet is a route template in a `.kamelet.yaml` file, together with a
description of the parameters it takes.
+You write one when the same piece of an integration is needed in several
routes: a policy such as removing
+personal data, a connection to a system with your company's settings, or a
destination every route writes to.
+The routes then use it by name, with their own parameter values, like any
other endpoint.
+
+== The three kinds of Kamelet
+
+Every Kamelet is one of three kinds, set by the
`camel.apache.org/kamelet.type` label. The kind decides where its
+template starts and ends, and where a route uses it.
+
+[cols="1,3,2",options="header"]
+|===
+| Kind | Template | Used in a route as
+
+| `source`
+| Starts at a component that produces messages (a timer, a queue, an API), and
ends with `to: kamelet:sink`, which
+ hands each message to the route.
+| `from: kamelet:<name>`
+
+| `sink`
+| Starts with `from: kamelet:source`, which receives the message of the route,
and ends at a component that sends
+ it somewhere (a file, a topic, an API).
+| `to: kamelet:<name>`, usually as the last step
+
+| `action`
+| Starts with `from: kamelet:source` and changes the message (filter fields,
convert, enrich). The changed message
+ goes back to the route.
+| `to: kamelet:<name>`, as a step in the middle
+|===
+
+== The file
+
+A Kamelet file is a YAML object (not a list of routes like a `.camel.yaml`
file) with these parts:
+
+`apiVersion` and `kind`:: always `camel.apache.org/v1` and `Kamelet`.
+`metadata.name`:: the name routes use in `kamelet:<name>`. Name the file after
it: `<name>.kamelet.yaml`.
+`metadata.labels`:: `camel.apache.org/kamelet.type` with `source`, `sink` or
`action`.
+`spec.definition`:: the parameters as a JSON schema: a `title` and
`description` for the Kamelet, the names of the
+`required` parameters, and under `properties` each parameter with its `title`,
`description`, `type`, and an
+optional `default`.
+`spec.dependencies`:: the Camel components and libraries the template uses, as
`camel:<name>` or
+`mvn:<groupId>:<artifactId>:<version>`.
+`spec.template`:: the route itself, written as in the YAML DSL: a `from` with
its `steps`, and optionally `beans`.
+
+In the template a parameter is written as a property placeholder:
`+{{customer}}+`. A parameter that may be left out
+is written `+{{?customer}}+`, which removes the option it sets when no value
is given, instead of failing.
+
+== A source Kamelet
+
+This source produces a sample order at a fixed interval. The route that uses
it chooses the customer, and may change
+the interval, which has a default.
+
+.order-source.kamelet.yaml
+[source,yaml]
+----
+apiVersion: camel.apache.org/v1
+kind: Kamelet
+metadata:
+ name: order-source
+ labels:
+ camel.apache.org/kamelet.type: source
+spec:
+ definition:
+ title: Order Source
+ description: Produces a sample order at a fixed interval
+ required:
+ - customer
+ properties:
+ customer:
+ title: Customer
+ description: The customer name to put in the order
+ type: string
+ period:
+ title: Period
+ description: Milliseconds between orders
+ type: integer
+ default: 5000
+ dependencies:
+ - "camel:timer"
+ template:
+ from:
+ uri: timer:orders
+ parameters:
+ period: "{{period}}"
+ steps:
+ - setBody:
+ simple:
+ expression: '{"id": "${exchangeId}", "customer": "{{customer}}",
"email": "[email protected]", "country": "DK"}'
+ - to:
+ uri: kamelet:sink
+----
+
+== An action Kamelet
+
+This action keeps only the fields of a JSON object that are named in its
`allowlist` parameter, so personal data
+such as the customer and email does not travel further.
+
+.content-filter-action.kamelet.yaml
+[source,yaml]
+----
+apiVersion: camel.apache.org/v1
+kind: Kamelet
+metadata:
+ name: content-filter-action
+ labels:
+ camel.apache.org/kamelet.type: action
+spec:
+ definition:
+ title: Content Filter Action
+ description: Keeps only the listed fields of a JSON object
+ required:
+ - allowlist
+ properties:
+ allowlist:
+ title: Allowlist
+ description: Comma separated names of the fields to keep
+ type: string
+ dependencies:
+ - "camel:jq"
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - setBody:
+ jq:
+ expression: 'with_entries(select(.key as $k | "{{allowlist}}" |
split(",") | index($k)))'
+----
+
+== A sink Kamelet
+
+This sink writes each message to a file in a directory.
+
+.archive-sink.kamelet.yaml
+[source,yaml]
+----
+apiVersion: camel.apache.org/v1
+kind: Kamelet
+metadata:
+ name: archive-sink
+ labels:
+ camel.apache.org/kamelet.type: sink
+spec:
+ definition:
+ title: Archive Sink
+ description: Writes each message to a file in a directory
+ required:
+ - directory
+ properties:
+ directory:
+ title: Directory
+ description: The directory to write the files to
+ type: string
+ fileExtension:
+ title: File Extension
+ description: The extension of the file names
+ type: string
+ default: json
+ dependencies:
+ - "camel:file"
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - to:
+ uri: "file:{{directory}}"
+ parameters:
+ fileName: "${exchangeId}.{{fileExtension}}"
+----
+
+== Using the Kamelets in a route
+
+A route uses each Kamelet by name, passing the parameters as endpoint
parameters. Parameters with a default can be
+left out, or given to override the default:
+
+.orders.camel.yaml
+[source,yaml]
+----
+- route:
+ id: export-orders
+ from:
+ uri: kamelet:order-source
+ parameters:
+ customer: Donald
+ period: 2000
+ steps:
+ - to:
+ uri: kamelet:content-filter-action
+ parameters:
+ allowlist: id,country
+ - log:
+ message: "Filtered: ${body}"
+ - to:
+ uri: kamelet:archive-sink
+ parameters:
+ directory: target/orders
+----
+
+Each file written then holds only the fields that are allowed, such as
`{"id":"...","country":"DK"}`.
+
+== Where Camel finds a Kamelet
+
+When a route uses `kamelet:<name>`, Camel loads `<name>.kamelet.yaml` from the
`location` of the Kamelet component,
+which is `classpath:kamelets` by default. Set
`camel.component.kamelet.location` to look elsewhere; several locations
+can be given, separated by comma.
+
+With the Camel CLI, the Kamelet files can simply be run with the route:
+
+[source,bash]
+----
+camel run orders.camel.yaml order-source.kamelet.yaml
content-filter-action.kamelet.yaml archive-sink.kamelet.yaml
+----
+
+A Kamelet can also call another Kamelet in its template, as any route can.
+
+== Checking a Kamelet
+
+`camel validate yaml` checks a Kamelet file: the template against the YAML
DSL, with the errors reported at their
+line in the template.
+
+[source,bash]
+----
+camel validate yaml content-filter-action.kamelet.yaml
+----
diff --git a/docs/components/modules/others/nav.adoc
b/docs/components/modules/others/nav.adoc
index ebccf4f508eb..4a991e597a07 100644
--- a/docs/components/modules/others/nav.adoc
+++ b/docs/components/modules/others/nav.adoc
@@ -38,6 +38,7 @@
** xref:jfr.adoc[JFR]
** xref:jsoup.adoc[Jsoup]
** xref:jta.adoc[JTA]
+** xref:kamelet-custom.adoc[Kamelet - Writing a custom Kamelet]
** xref:keycloak-consumer.adoc[Keycloak Consumer Operations]
** xref:keycloak-producer.adoc[Keycloak Producer Operations]
** xref:keycloak-security.adoc[Keycloak Security Policies]
diff --git a/docs/components/modules/others/pages/kamelet-custom.adoc
b/docs/components/modules/others/pages/kamelet-custom.adoc
new file mode 120000
index 000000000000..be528337c081
--- /dev/null
+++ b/docs/components/modules/others/pages/kamelet-custom.adoc
@@ -0,0 +1 @@
+../../../../../components/camel-kamelet/src/main/docs/kamelet-custom.adoc
\ No newline at end of file
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index 216298f941e4..c529be10c390 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -477,6 +477,17 @@ public final class AuthoringTools {
return sb.toString();
}
+ /** Where the shape of a Kamelet file is explained, said where an agent
gets one wrong (CAMEL-25283). */
+ static final String KAMELET_GUIDE = "How to write a Kamelet (the file, and
the source, sink and action kinds): "
+ + "camel_catalog_doc name=kamelet
docPage=custom";
+
+ private static void putKameletGuide(JsonObject result, String file,
List<String> errors) {
+ String name = file != null ? file.toLowerCase(Locale.ROOT) : "";
+ if (!errors.isEmpty() && (name.endsWith(".kamelet.yaml") ||
name.endsWith(".kamelet.yml"))) {
+ result.put("guide", KAMELET_GUIDE);
+ }
+ }
+
private static String commaLines(String list) {
return list == null ? null : list.replace(',', '\n');
}
@@ -503,6 +514,7 @@ public final class AuthoringTools {
result.put("valid", errors.isEmpty());
result.put("file", file);
result.put("errors", new JsonArray(errors));
+ putKameletGuide(result, file, errors);
// the problems whose fix is certain, as edits an agent can apply
(camel_edit_file find/replace)
JsonArray fixes = new JsonArray();
String[] lines = content.split("\n", -1);
@@ -923,6 +935,7 @@ public final class AuthoringTools {
result.put("errors", new JsonArray(errors));
result.put("message", "The file was not written: the content
has validation errors. Fix them and"
+ " call camel_write_file again.");
+ putKameletGuide(result, file, errors);
return result;
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
index 8472684617c3..dd4b522109f0 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java
@@ -19,10 +19,13 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
+import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import java.util.TreeMap;
+import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.camel.catalog.CamelCatalog;
@@ -197,9 +200,21 @@ public final class CatalogDocs {
ComponentModel cm = catalog.componentModel(name);
if (cm != null) {
String adoc = catalog.asciiDoc(name + "-component");
+ List<JsonObject> pages = componentDocPages(cm.getScheme(),
adoc);
+ if (page != null && !page.isEmpty()) {
+ // a sub-page the component page links to, such as how to
write a custom Kamelet
+ String sub = pages.stream().anyMatch(p ->
page.equals(p.getString("page")))
+ ? catalog.asciiDoc(cm.getScheme() + "-" + page) :
null;
+ if (sub == null) {
+ JsonObject err = error("No doc page '" + page + "' for
component " + name);
+ err.put("docPages", new JsonArray(pages));
+ return err;
+ }
+ return componentDoc(cm, lowerFilter, OptionScope.NONE,
false, sub, null, pages);
+ }
// the whole page when it was asked for, else its first
section, which the options cannot say
return componentDoc(cm, lowerFilter, scope, includeHeaders,
includeDoc ? adoc : null,
- includeDoc ? null : docExcerpt(adoc,
DOC_EXCERPT_BUDGET));
+ includeDoc ? null : docExcerpt(adoc,
DOC_EXCERPT_BUDGET), pages);
}
JsonObject group = mainOptionsGroup(catalog, name);
if (group != null) {
@@ -1032,7 +1047,7 @@ public final class CatalogDocs {
private static JsonObject componentDoc(
ComponentModel model, String filter, OptionScope scope, boolean
includeHeaders, String doc,
- String docExcerpt) {
+ String docExcerpt, List<JsonObject> docPages) {
JsonObject result = new JsonObject();
result.put("kind", "component");
result.put("name", model.getScheme());
@@ -1114,9 +1129,38 @@ public final class CatalogDocs {
result.put("documentation", docExcerpt);
result.put("documentationHint", "the start of the component's
documentation page; includeDoc=true for all of it");
}
+ if (!docPages.isEmpty()) {
+ // named with their titles, so a model can tell which one answers
its question
+ result.put("docPages", new JsonArray(docPages));
+ result.put("docPagesHint", "docPage=<page> returns that
documentation page as text");
+ }
return result;
}
+ /**
+ * The sub-pages of a component's documentation, found from the links of
its page to them
+ * ({@code xref:others:kamelet-custom.adoc[Writing a custom Kamelet]}):
the page name to ask for with
+ * {@code docPage}, and its title.
+ */
+ static List<JsonObject> componentDocPages(String scheme, String adoc) {
+ List<JsonObject> pages = new ArrayList<>();
+ if (adoc == null || scheme == null) {
+ return pages;
+ }
+ Matcher m = Pattern.compile("xref:others:" + Pattern.quote(scheme) +
"-([a-z0-9][a-z0-9-]*)\\.adoc\\[([^\\]]+)]")
+ .matcher(adoc);
+ Set<String> seen = new HashSet<>();
+ while (m.find()) {
+ if (seen.add(m.group(1))) {
+ JsonObject jo = new JsonObject();
+ jo.put("page", m.group(1));
+ jo.put("title", m.group(2));
+ pages.add(jo);
+ }
+ }
+ return pages;
+ }
+
private static JsonObject dataFormatDoc(
DataFormatModel model, String filter, OptionScope scope, String
doc, String docExcerpt) {
JsonObject result = new JsonObject();
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
index 42f9856ca1e5..da2cdc36882f 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogSamples.java
@@ -533,6 +533,10 @@ public final class CatalogSamples {
answer.put("note", "none of the examples of the documentation uses
a " + name + " endpoint; they show the "
+ "component in another way (a properties
function, a policy, a converter)");
}
+ if ("component".equals(kind) && "kamelet".equals(name)) {
+ // the samples show how a route uses a Kamelet; writing the
.kamelet.yaml file is on its own page
+ answer.put("guide", AuthoringTools.KAMELET_GUIDE);
+ }
return answer;
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocComponentPagesTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocComponentPagesTest.java
new file mode 100644
index 000000000000..321036374ef9
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocComponentPagesTest.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.camel.dsl.jbang.core.commands.ai;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * CAMEL-25283: the sub-pages of a component's documentation can be asked for
with {@code docPage}, and a model is told
+ * where the page on writing a custom Kamelet is: in the kamelet answer, in
its samples, and when a Kamelet file it
+ * wrote is invalid.
+ */
+class CatalogDocComponentPagesTest {
+
+ private static JsonObject catalogDoc(Map<String, String> args) throws
Exception {
+ String json = String.valueOf(ToolRegistry.execute("camel_catalog_doc",
new ToolContext(), new HashMap<>(args)));
+ return (JsonObject) Jsoner.deserialize(json);
+ }
+
+ private static JsonObject page(JsonArray pages, String name) {
+ for (Object o : pages) {
+ JsonObject p = (JsonObject) o;
+ if (name.equals(p.getString("page"))) {
+ return p;
+ }
+ }
+ return null;
+ }
+
+ @Test
+ public void testTheKameletAnswerNamesTheCustomKameletPage() throws
Exception {
+ JsonObject answer = catalogDoc(Map.of("name", "kamelet"));
+ JsonArray pages = (JsonArray) answer.get("docPages");
+ assertNotNull(pages, "no docPages in: " + answer.toJson());
+ JsonObject custom = page(pages, "custom");
+ assertNotNull(custom, "custom is not a doc page: " + pages.toJson());
+ assertEquals("Writing a custom Kamelet", custom.getString("title"));
+ assertNotNull(answer.getString("docPagesHint"));
+ // the pages of another artifact that share the prefix are not
sub-pages of the component
+ assertNull(page(pages, "main"));
+ }
+
+ @Test
+ public void testTheCustomKameletPageIsReturnedAsText() throws Exception {
+ JsonObject answer = catalogDoc(Map.of("name", "kamelet", "docPage",
"custom"));
+ String doc = answer.getString("doc");
+ assertNotNull(doc, "no doc in: " + answer.toJson());
+ for (String kind : List.of("source", "sink", "action")) {
+ assertTrue(doc.contains("camel.apache.org/kamelet.type: " + kind),
"no " + kind + " Kamelet in the page");
+ }
+ assertTrue(doc.contains("{{?"), "the page does not say how an optional
parameter is written");
+ // the page is the answer: no option list around it
+ assertNull(answer.get("options"));
+ assertNull(answer.get("documentation"));
+ }
+
+ @Test
+ public void testAnUnknownPageListsThePagesThereAre() throws Exception {
+ JsonObject answer = catalogDoc(Map.of("name", "kamelet", "docPage",
"nosuchpage"));
+ assertNotNull(answer.getString("error"));
+ assertNotNull(page((JsonArray) answer.get("docPages"), "custom"));
+ }
+
+ @Test
+ public void testTheSubPagesOfOtherComponentsAreFoundToo() throws Exception
{
+ JsonArray pages = (JsonArray) catalogDoc(Map.of("name",
"aws2-s3")).get("docPages");
+ assertNotNull(pages);
+ assertNotNull(page(pages, "streaming"), pages.toJson());
+ assertNotNull(page(pages, "consumer-examples"), pages.toJson());
+ String doc = catalogDoc(Map.of("name", "aws2-s3", "docPage",
"streaming")).getString("doc");
+ assertTrue(doc.startsWith("= AWS S3 - Streaming Upload"),
doc.substring(0, Math.min(80, doc.length())));
+ }
+
+ @Test
+ public void testAComponentWithoutSubPagesHasNoDocPages() throws Exception {
+ assertNull(catalogDoc(Map.of("name", "timer")).get("docPages"));
+ }
+
+ @Test
+ public void testAnInvalidKameletFileSaysWhereTheGuideIs() {
+ String kamelet = """
+ apiVersion: camel.apache.org/v1
+ kind: Kamelet
+ metadata:
+ name: my-action
+ labels:
+ camel.apache.org/kamelet.type: action
+ spec:
+ definition:
+ title: My Action
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - jq: "."
+ """;
+ JsonObject invalid = AuthoringTools.validate(new ToolContext(),
"my-action.kamelet.yaml", kamelet);
+ assertFalse(invalid.getBoolean("valid"));
+ assertEquals(AuthoringTools.KAMELET_GUIDE, invalid.getString("guide"));
+
+ String fixed
+ = kamelet.replace("- jq: \".\"", "- setBody:\n
jq:\n expression: \".\"");
+ JsonObject valid = AuthoringTools.validate(new ToolContext(),
"my-action.kamelet.yaml", fixed);
+ assertTrue(valid.getBoolean("valid"), valid.toJson());
+ assertNull(valid.get("guide"));
+
+ // a route file is not a Kamelet
+ JsonObject route = AuthoringTools.validate(new ToolContext(),
"my.camel.yaml",
+ "- from:\n uri: timer:x\n steps:\n - jq: \".\"\n");
+ assertFalse(route.getBoolean("valid"));
+ assertNull(route.get("guide"));
+ }
+
+ @Test
+ public void testTheKameletSamplesSayWhereTheGuideIs() {
+ JsonObject answer = CatalogSamples.sample(new DefaultCamelCatalog(),
"component", "kamelet", 2);
+ assertEquals(AuthoringTools.KAMELET_GUIDE, answer.getString("guide"),
answer.toJson());
+ assertNull(CatalogSamples.sample(new DefaultCamelCatalog(),
"component", "timer", 2).get("guide"));
+ }
+}