@redpanda-data/docs-extensions-and-macros 4.2.5 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.adoc CHANGED
@@ -123,7 +123,7 @@ antora:
123
123
  - home:ROOT:attachment$custom-file.txt
124
124
  ----
125
125
 
126
- ==== Registration example
126
+ ==== Registration
127
127
 
128
128
  [source,yaml]
129
129
  ----
@@ -155,7 +155,7 @@ Any elements, classes, or IDs that you want to exclude from the index.
155
155
  index-latest-only (optional)::
156
156
  Whether to index all versions or just the latest version of a component.
157
157
 
158
- ==== Registration example
158
+ ==== Registration
159
159
 
160
160
  ```yaml
161
161
  antora:
@@ -246,7 +246,7 @@ This extension does not require any environment variables.
246
246
 
247
247
  There are no configurable options for this extension.
248
248
 
249
- ==== Registration example
249
+ ==== Registration
250
250
 
251
251
  ```yaml
252
252
  antora:
@@ -297,7 +297,7 @@ antora:
297
297
  upgrade_doc: ROOT:upgrade:index.adoc
298
298
  ----
299
299
 
300
- ==== Registration example
300
+ ==== Registration
301
301
 
302
302
  You can register the extension with a customized configuration for different components in your playbook:
303
303
 
@@ -456,7 +456,7 @@ NOTE: If you don't set the environment variable, the latest version of Redpanda
456
456
 
457
457
  There are no configurable options for this extension.
458
458
 
459
- ==== Registration Example
459
+ ==== Registration
460
460
 
461
461
  ```yaml
462
462
  antora:
@@ -488,7 +488,7 @@ The following attributes are available to the latest version of the `ROOT` compo
488
488
 
489
489
  NOTE: If you don't set the environment variable, the latest versions may not be fetched. When the environment variable is not set, the extension sends unauthenticated requests to GitHub. Unauthenticated requests may result in hitting the API rate limit and cause GitHub to reject the request.
490
490
 
491
- ==== Registration example
491
+ ==== Registration
492
492
 
493
493
  ```yaml
494
494
  antora:
@@ -508,7 +508,7 @@ This extension does not require any environment variables.
508
508
 
509
509
  There are no configurable options for this extension. It operates based on site attributes defined in `add-global-attributes.js` to determine valid categories and subcategories.
510
510
 
511
- ==== Registration example
511
+ ==== Registration
512
512
 
513
513
  Register the `validate-attributes` extension in the Antora playbook under the `antora.extensions` key like so:
514
514
 
@@ -531,7 +531,7 @@ This extension operates without requiring any specific environment variables.
531
531
 
532
532
  This extension does not offer configurable options. It uses the inherent attributes of pages to determine relationships based on `page-categories` and deployment types (`env-kubernetes`, `env-linux`, `env-docker`, `page-cloud`).
533
533
 
534
- ==== Registration example
534
+ ==== Registration
535
535
 
536
536
  To integrate the `related-docs-extension` into your Antora playbook, add it under the `antora.extensions` key as demonstrated below:
537
537
 
@@ -554,7 +554,7 @@ This extension does not require any environment variables.
554
554
 
555
555
  The extension operates without explicit configuration options. It automatically processes documentation pages to identify and link related labs based on shared `page-categories` attributes and deployment types (`env-kubernetes`, `env-linux`, `env-docker`, `page-cloud`).
556
556
 
557
- ==== Registration example
557
+ ==== Registration
558
558
 
559
559
  Include the `related-labs-extension` in the Antora playbook under the `antora.extensions` key as follows:
560
560
 
@@ -579,7 +579,7 @@ The extension accepts the following configuration options:
579
579
 
580
580
  attributespath (optional):: Specifies the path to a local YAML file that contains global attributes. If this is provided, the extension will load attributes from this file first. If this path is not provided or no valid attributes are found in the file, the extension will fall back to loading attributes from the `shared` component.
581
581
 
582
- ==== Registration example
582
+ ==== Registration
583
583
 
584
584
  ```yml
585
585
  antora:
@@ -602,7 +602,7 @@ This extension does not require any environment variables.
602
602
 
603
603
  There are no configurable options for this extension.
604
604
 
605
- ==== Registration example
605
+ ==== Registration
606
606
 
607
607
  ```yaml
608
608
  antora:
@@ -640,7 +640,7 @@ data.replacements (required):: An array of replacement configurations. Each conf
640
640
 
641
641
  NOTE: Ensure that `file_patterns` accurately reflect the paths of the attachments you want to process. Overly broad patterns may include unintended files, while overly restrictive patterns might exclude necessary resources.
642
642
 
643
- ==== Registration Example
643
+ ==== Registration
644
644
 
645
645
  This is an example of how to register and configure the `replace-attributes-in-attachments` extension in your Antora playbook. This example demonstrates defining multiple replacement configurations, each targeting different components and specifying their own file patterns and custom replacements.
646
646
 
@@ -698,7 +698,7 @@ Term files should follow the following structure:
698
698
  This is the detailed description of the term.
699
699
  ```
700
700
 
701
- ==== Registration example
701
+ ==== Registration
702
702
 
703
703
  ```yml
704
704
  antora:
@@ -729,7 +729,7 @@ Whether to add unlisted pages to the navigation. The default is `false` (unliste
729
729
  unlistedPagesHeading (optional)::
730
730
  The heading under which to list the unlisted pages in the navigation. The default is 'Unlisted Pages'.
731
731
 
732
- ==== Registration example
732
+ ==== Registration
733
733
 
734
734
  ```yaml
735
735
  antora:
@@ -749,7 +749,7 @@ IMPORTANT: Be sure to register each extension under the `asciidoc.extensions` ke
749
749
 
750
750
  This extension adds the necessary classes to make line numbers and line highlighting work with Prism.js.
751
751
 
752
- ==== Registration example
752
+ ==== Registration
753
753
 
754
754
  ```yaml
755
755
  antora:
@@ -763,6 +763,108 @@ This section documents the Asciidoc macros that are provided by this library and
763
763
 
764
764
  IMPORTANT: Be sure to register each extension under the `asciidoc.extensions` key in the playbook, not the `antora.extensions` key.
765
765
 
766
+ === data_template
767
+
768
+ The `data_template` block processor lets you render dynamic AsciiDoc content from external or local data sources (JSON, YAML, or plain text) using Handlebars templates.
769
+
770
+ This is useful for generating documentation from structured data like config fields, component metadata, or examples.
771
+
772
+ === Usage
773
+
774
+ You can use the `data_template` block macro to dynamically generate AsciiDoc content from structured data files such as JSON or YAML. The macro uses a Handlebars template to render the data.
775
+
776
+ [source,asciidoc]
777
+ ----
778
+ [data_template, ROOT:example$connect.json]
779
+ --
780
+ == Component: {{{name}}}
781
+
782
+ Summary: {{{summary}}}
783
+
784
+ {{#each fields}}
785
+ === {{name}}
786
+
787
+ *Type*: `{{type}}`
788
+
789
+ {{{description}}}
790
+ {{/each}}
791
+ --
792
+ ----
793
+
794
+ The block content is a Handlebars template. Fields from the data file are injected into this template during site build.
795
+
796
+ The macro accepts one or two positional attributes:
797
+
798
+ 1. The first attribute (`dataPath`) is required and should be the resource ID of the data file.
799
+ 2. The second attribute (`overrides`) is optional and allows you to override or merge values from a secondary file.
800
+
801
+ You can apply overrides by specifying a second file:
802
+
803
+ [source,asciidoc]
804
+ ----
805
+ [data_template, ROOT:example$connect.json, ROOT:example$overrides.json]
806
+ --
807
+ ...template content...
808
+ --
809
+ ----
810
+
811
+ In this case:
812
+
813
+ - The macro first loads and parses `connect.json`.
814
+ - Then it loads `overrides.json` and **merges** its values into the base file.
815
+ - Arrays of objects (such as fields or processors) are merged by matching objects with the same `name` key.
816
+ - The result is passed into the Handlebars template.
817
+
818
+ This is useful for tweaking content (like updating a `description`) without modifying the original source file.
819
+
820
+ ==== Triple mustaches
821
+
822
+ When your data contains AsciiDoc markup (like lists, admonitions, or headings), use triple curly braces:
823
+
824
+ [source,handlebars]
825
+ ----
826
+ {{{description}}}
827
+ ----
828
+
829
+ This tells Handlebars to not escape the content, so Asciidoctor can render it correctly.
830
+
831
+ ==== Handlebars helpers
832
+
833
+ The following helpers are available inside templates:
834
+
835
+ * `eq`, `ne` — Equality helpers.
836
+ * `uppercase` — Converts text to uppercase.
837
+ * `renderConnectFields` — Renders config fields for Redpanda Connect.
838
+ * `renderConnectExamples` — Renders usage examples.
839
+ * `selectByJsonPath` — Selects items from the data using a JSONPath expression.
840
+
841
+ ==== Registration
842
+
843
+ To use this macro, register it in your Antora playbook:
844
+
845
+ [source,yaml]
846
+ ----
847
+ asciidoc:
848
+ extensions:
849
+ - '@redpanda-data/docs-extensions-and-macros/macros/data-template'
850
+ ----
851
+
852
+ ==== Example
853
+
854
+ You can use the `selectByJsonPath` helper to filter data. For example, if you want to render only the `redis` processor's fields from a JSON file, you can do it like this:
855
+
856
+ [source,asciidoc]
857
+ ----
858
+ [data_template, redpanda-connect:ROOT:example$connect.json]
859
+ --
860
+ {{#selectByJsonPath this "$.processors[?(@.name=='redis')]" }}
861
+ {{renderConnectFields this.config.children}}
862
+ {{/selectByJsonPath}}
863
+ --
864
+ ----
865
+
866
+ This will render only the fields for the `redis` processor.
867
+
766
868
  === config_ref
767
869
 
768
870
  This inline macro is used to generate a reference to a configuration value in the Redpanda documentation. The macro's parameters allow for control over the generated reference's format and the type of output produced.
@@ -798,7 +900,7 @@ For example:
798
900
  config_ref:example_config,true,tunable-properties[]
799
901
  ----
800
902
 
801
- ==== Registration example
903
+ ==== Registration
802
904
 
803
905
  [,yaml]
804
906
  ----
@@ -807,6 +909,67 @@ asciidoc:
807
909
  - '@redpanda-data/docs-extensions-and-macros/macros/config-ref'
808
910
  ----
809
911
 
912
+ === data_template
913
+
914
+ The `data_template` macro provides a way to dynamically generate AsciiDoc content by combining external or local data sources with Handlebars templates. When you use the `data_template` block macro, the extension performs the following steps:
915
+
916
+ * Resolves the `dataPath` attribute to locate a data file (in JSON, YAML, or raw text format).
917
+ * Fetches and caches external resources or reads local files from the Antora content catalog.
918
+ * Parses the data file.
919
+ * Compiles the block's content as a Handlebars template, injecting the parsed data.
920
+ * Processes the resulting text as AsciiDoc using Asciidoctor to generate the final HTML output.
921
+
922
+ By default, Handlebars escapes HTML to prevent potential security issues. However, if your data includes AsciiDoc markup (such as headings, lists, or formatting directives), escaping it will prevent Asciidoctor from converting the markup correctly.
923
+
924
+ To ensure that your AsciiDoc syntax is preserved during template rendering, **use the triple curly braces syntax** in your Handlebars templates. For example, if your JSON file contains a `description` field with AsciiDoc content, reference it like this:
925
+
926
+ [source,handlebars]
927
+ ----
928
+ {{{description}}}
929
+ ----
930
+
931
+ This tells Handlebars to output the content unescaped, allowing Asciidoctor to process the raw AsciiDoc markup correctly.
932
+
933
+ ==== Usage
934
+
935
+ In an AsciiDoc document, you can invoke the data template macro as follows:
936
+
937
+ [,asciidoc]
938
+ ----
939
+ [data_template, ROOT:example$connect.json]
940
+ --
941
+ Version: {{{version}}}
942
+
943
+ {{#each buffers}}
944
+
945
+ === {{{this.name}}}
946
+
947
+ Status: {{{this.status}}}
948
+
949
+ {{#if (eq this.name 'memory')}}
950
+ This is a custom description for the memory buffer.
951
+ {{else}}
952
+ {{{this.summary}}}
953
+ {{/if}}
954
+
955
+ {{/each}}
956
+
957
+ --
958
+ ----
959
+
960
+ ==== Registration
961
+
962
+ Register the macro in your Antora playbook under the `asciidoc.extensions` key:
963
+
964
+ [source,yaml]
965
+ ----
966
+ asciidoc:
967
+ extensions:
968
+ - require: '@redpanda-data/docs-extensions-and-macros/macros/data-template'
969
+ ----
970
+
971
+ This configuration ensures that during the build process, the data template macro is executed to fetch, parse, and render data as part of your docs.
972
+
810
973
  === glossterm
811
974
 
812
975
  The `glossterm` inline macro provides a way to define and reference glossary terms in your AsciiDoc documents.
@@ -855,7 +1018,7 @@ Whether to enable tooltips for the defined terms. Valid values are:
855
1018
 
856
1019
  The last two options are intended to support js/css tooltip solutions such as tippy.js.
857
1020
 
858
- ==== Registration example
1021
+ ==== Registration
859
1022
 
860
1023
  [,yaml]
861
1024
  ----
@@ -897,7 +1060,7 @@ For default values and documentation for configuration options, see the https://
897
1060
 
898
1061
  If you do not specify a Helm reference value, the macro generates a link without specifying a path.
899
1062
 
900
- ==== Registration example
1063
+ ==== Registration
901
1064
 
902
1065
  [,yaml]
903
1066
  ----
@@ -918,7 +1081,7 @@ The categories are fetched from the `connectCategoriesData` that's generated in
918
1081
  components_by_category::[<type>]
919
1082
  ```
920
1083
 
921
- ==== Registration example
1084
+ ==== Registration
922
1085
 
923
1086
  ```yaml
924
1087
  asciidoc:
@@ -938,7 +1101,7 @@ The types are fetched from the `flatComponentsData` that's generated in the <<Co
938
1101
  component_table::[]
939
1102
  ```
940
1103
 
941
- ==== Registration example
1104
+ ==== Registration
942
1105
 
943
1106
  ```yaml
944
1107
  asciidoc:
@@ -958,7 +1121,7 @@ The types are fetched from the `flatComponentsData` that's generated in the <<Co
958
1121
  component_type_dropdown::[]
959
1122
  ```
960
1123
 
961
- ==== Registration example
1124
+ ==== Registration
962
1125
 
963
1126
  ```yaml
964
1127
  asciidoc:
@@ -0,0 +1,591 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * data_template macro for Asciidoctor.js
5
+ *
6
+ * This module defines a [data_template] block macro that leverages Handlebars templates to allow us to reference data in JSON or YAML files directly inside Asciidoc pages.
7
+ * It processes external or local data sources (in JSON, YAML, or raw text), compiles a Handlebars template
8
+ * provided within the block, and then parses the resulting content as AsciiDoc using Asciidoctor.
9
+ */
10
+
11
+ // This global Opal object is available because Antora uses Asciidoctor.js (compiled using Opal) to convert AsciiDoc into HTML.
12
+ const loggerLib = require('@antora/logger');
13
+ loggerLib.configure({
14
+ format: 'pretty',
15
+ level: 'error'
16
+ });
17
+ const path = require('path').posix;
18
+ const logger = loggerLib.getLogger('data-template');
19
+ const handlebars = require('handlebars');
20
+ const loadAsciiDoc = require('@antora/asciidoc-loader')
21
+ const jsonpath = require('jsonpath-plus');
22
+ const yaml = require('yaml');
23
+ // For synchronous HTTP fetching.
24
+ const request = require('sync-request');
25
+ const computeOut = require('../util/compute-out.js');
26
+ const createAsciiDocFile = require('../util/create-asciidoc-file.js');
27
+
28
+ // In-memory cache for external resources (avoid repeated network calls)
29
+ const externalCache = new Map();
30
+
31
+ // ========= Handlebars helpers =============
32
+
33
+ /**
34
+ * Converts a string to uppercase.
35
+ *
36
+ * @param {string} str - The string to convert.
37
+ * @returns {string} The uppercase version of the input string.
38
+ */
39
+ function uppercase(str) {
40
+ return String(str).toUpperCase();
41
+ }
42
+
43
+ /**
44
+ * Checks if two values are equal.
45
+ *
46
+ * @param {*} a - The first value.
47
+ * @param {*} b - The second value.
48
+ * @returns {string} True if the values are equal.
49
+ */
50
+ function eq(a, b) {
51
+ if (a === b) {
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ /**
58
+ * Checks if two values are not equal.
59
+ *
60
+ * @param {*} a - The first value.
61
+ * @param {*} b - The second value.
62
+ * @returns {string} False if the values are not equal.
63
+ */
64
+ function ne(a, b) {
65
+ if (a !== b) {
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ /**
72
+ * Renders the children of a configuration object.
73
+ *
74
+ * @param {Array<Object>} children - An array of child objects.
75
+ * @returns {string} The rendered string containing the configuration details.
76
+ */
77
+ function renderConnectFields(children, prefix = '') {
78
+ if (!children || !Array.isArray(children) || children.length === 0) {
79
+ return '';
80
+ }
81
+
82
+ let output = '';
83
+ prefix = typeof prefix === 'string' ? prefix : '';
84
+
85
+ children.forEach(child => {
86
+ const isArray = child.kind === 'array';
87
+ if (!child.name) return;
88
+ const currentPath = prefix ? `${prefix}.${child.name}${isArray ? '[]' : ''}` : `${child.name}${isArray ? '[]' : ''}`;
89
+
90
+ // Section header for the field.
91
+ output += `=== \`${currentPath}\`\n\n`;
92
+
93
+ // Append description if available.
94
+ if (child.description) {
95
+ output += `${child.description}\n\n`;
96
+ }
97
+
98
+ // Inject admonition if the config is secret.
99
+ if (child.is_secret === true) {
100
+ output += `include::redpanda-connect:components:partial$secret_warning.adoc[]\n\n`;
101
+ }
102
+
103
+ // Insert version requirement if a version is provided.
104
+ if (child.version) {
105
+ output += `Requires version ${child.version} or later.\n\n`;
106
+ }
107
+
108
+ // Append type.
109
+ output += `*Type*: \`${child.type}\`\n\n`;
110
+
111
+ // For non-object types, output the default value if present.
112
+ if (child.type !== 'object' && child.default !== undefined) {
113
+ if (child.default === "") {
114
+ output += `*Default*: \`""\`\n\n`;
115
+ } else {
116
+ output += `*Default*: \`${child.default}\`\n\n`;
117
+ }
118
+ }
119
+
120
+ // If annotated_options is present, build the AsciiDoc table.
121
+ if (child.annotated_options && Array.isArray(child.annotated_options) && child.annotated_options.length > 0) {
122
+ output += "[cols=\"1m,2a\"]\n";
123
+ output += "|===\n";
124
+ output += "|Option |Summary\n\n";
125
+ child.annotated_options.forEach(optionPair => {
126
+ // Ensure each optionPair is an array with at least two items:
127
+ if (Array.isArray(optionPair) && optionPair.length >= 2) {
128
+ output += `|${optionPair[0]}\n|${optionPair[1]}\n\n`;
129
+ }
130
+ });
131
+ output += "|===\n\n";
132
+ }
133
+
134
+ if (child.options && Array.isArray(child.options) && child.options.length > 0) {
135
+ output += `*Options*: ${child.options.map(option => `\`${option}\``).join(', ')}\n\n`;
136
+ }
137
+
138
+
139
+ // If examples are provided, add a fenced YAML block.
140
+ if (child.examples) {
141
+ output += "```yaml\n";
142
+ output += "# Examples:\n";
143
+
144
+ // Branch for string fields.
145
+ if (child.type === 'string') {
146
+ // If the field is an array of strings.
147
+ if (child.kind === 'array') {
148
+ child.examples.forEach(exampleGroup => {
149
+ output += `${child.name}:\n`;
150
+ if (Array.isArray(exampleGroup)) {
151
+ exampleGroup.forEach(exampleValue => {
152
+ if (typeof exampleValue === 'string' && exampleValue.includes('\n')) {
153
+ // Use literal block syntax for multi-line strings.
154
+ output += ` - |-\n`;
155
+ let indentedLines = exampleValue
156
+ .split('\n')
157
+ .map(line => ' ' + line)
158
+ .join('\n');
159
+ output += `${indentedLines}\n`;
160
+ } else {
161
+ output += ` - ${exampleValue}\n`;
162
+ }
163
+ });
164
+ } else {
165
+ // Fallback for single value example group.
166
+ if (typeof exampleGroup === 'string' && exampleGroup.includes('\n')) {
167
+ output += ` - |-\n`;
168
+ let indentedLines = exampleGroup
169
+ .split('\n')
170
+ .map(line => ' ' + line)
171
+ .join('\n');
172
+ output += `${indentedLines}\n`;
173
+ } else {
174
+ output += ` - ${exampleGroup}\n`;
175
+ }
176
+ }
177
+ output += "\n";
178
+ });
179
+ } else {
180
+ // For non-array string examples, output them as key/value pairs.
181
+ child.examples.forEach(example => {
182
+ if (example.includes('\n')) {
183
+ output += `${child.name}: |-\n`;
184
+ let indentedLines = example.split('\n').map(line => ' ' + line).join('\n');
185
+ output += `${indentedLines}\n`;
186
+ } else {
187
+ output += `${child.name}: ${example}\n`;
188
+ }
189
+ });
190
+ }
191
+ }
192
+ // Branch for processor fields.
193
+ else if (child.type === 'processor') {
194
+ if (child.kind === 'array') {
195
+ child.examples.forEach(exampleGroup => {
196
+ output += `${child.name}:\n`;
197
+ if (Array.isArray(exampleGroup)) {
198
+ exampleGroup.forEach(exampleObj => {
199
+ let yamlSnippet = yaml.stringify(exampleObj).trim();
200
+ let lines = yamlSnippet.split('\n');
201
+ let formattedLines = lines.map((line, idx) => {
202
+ return idx === 0 ? " - " + line : " " + line;
203
+ }).join('\n');
204
+ output += formattedLines + "\n";
205
+ });
206
+ } else {
207
+ let yamlSnippet = yaml.stringify(exampleGroup).trim();
208
+ let lines = yamlSnippet.split('\n');
209
+ let formattedLines = lines.map((line, idx) => {
210
+ return idx === 0 ? " - " + line : " " + line;
211
+ }).join('\n');
212
+ output += formattedLines + "\n";
213
+ }
214
+ output += "\n";
215
+ });
216
+ } else {
217
+ child.examples.forEach(example => {
218
+ output += `${child.name}: ${example}\n`;
219
+ });
220
+ }
221
+ }
222
+ // Branch for object fields.
223
+ else if (child.type === 'object') {
224
+ // If this object is actually an array of objects.
225
+ if (child.kind === 'array') {
226
+ child.examples.forEach(exampleGroup => {
227
+ output += `${child.name}:\n`;
228
+ if (Array.isArray(exampleGroup)) {
229
+ exampleGroup.forEach(exampleObj => {
230
+ let yamlSnippet = yaml.stringify(exampleObj).trim();
231
+ let lines = yamlSnippet.split('\n');
232
+ let formattedLines = lines.map((line, idx) => {
233
+ return idx === 0 ? " - " + line : " " + line;
234
+ }).join('\n');
235
+ output += formattedLines + "\n";
236
+ });
237
+ } else {
238
+ let yamlSnippet = yaml.stringify(exampleGroup).trim();
239
+ let lines = yamlSnippet.split('\n');
240
+ let formattedLines = lines.map((line, idx) => {
241
+ return idx === 0 ? " - " + line : " " + line;
242
+ }).join('\n');
243
+ output += formattedLines + "\n";
244
+ }
245
+ output += "\n";
246
+ });
247
+ } else {
248
+ // Fallback for non-array object examples.
249
+ child.examples.forEach(example => {
250
+ if (typeof example === 'object') {
251
+ let yamlSnippet = yaml.stringify(example).trim();
252
+ let lines = yamlSnippet.split('\n');
253
+ let formattedLines = lines.map((line, idx) => idx === 0 ? line : " " + line).join('\n');
254
+ output += `${child.name}:\n${formattedLines}\n`;
255
+ } else {
256
+ output += `${child.name}: ${example}\n`;
257
+ }
258
+ });
259
+ }
260
+ }
261
+ // Fallback for any other field types.
262
+ else {
263
+ child.examples.forEach(example => {
264
+ output += `${child.name}: ${example}\n`;
265
+ });
266
+ }
267
+
268
+ output += "```\n\n";
269
+ }
270
+
271
+ // Recursively render any nested children.
272
+ if (child.children && Array.isArray(child.children) && child.children.length > 0) {
273
+ output += renderConnectFields(child.children, currentPath);
274
+ }
275
+ });
276
+
277
+ // Return a SafeString so that Handlebars doesn't escape special characters.
278
+ return new handlebars.SafeString(output);
279
+ }
280
+
281
+ /**
282
+ * Renders a list of examples.
283
+ *
284
+ * @param {Array<Object>} examples - An array of example objects.
285
+ * @returns {string} The rendered string containing the examples.
286
+ */
287
+ function renderConnectExamples(examples) {
288
+ // If there are no examples, return an empty string.
289
+ if (!examples || !Array.isArray(examples) || examples.length === 0) {
290
+ return '';
291
+ }
292
+ // Start with a level-2 heading for all examples.
293
+ let output = '';
294
+ // Iterate over each example.
295
+ examples.forEach(example => {
296
+ // Render the example title as a level-3 heading.
297
+ if (example.title) {
298
+ output += `=== ${example.title}\n\n`;
299
+ }
300
+
301
+ // Render the summary if provided.
302
+ if (example.summary) {
303
+ output += `${example.summary}\n\n`;
304
+ }
305
+
306
+ // Render the example config inside an AsciiDoc code block.
307
+ // Using a [source,yaml] block and "----" as delimiters.
308
+ if (example.config) {
309
+ output += `[source,yaml]\n----\n`;
310
+ output += example.config.trim() + "\n";
311
+ output += "----\n\n";
312
+ }
313
+ });
314
+ // Return as a SafeString so that Handlebars doesn't escape markup.
315
+ return new handlebars.SafeString(output);
316
+ }
317
+
318
+ /**
319
+ * Selects data from a JSON object using a JSONPath expression.
320
+ *
321
+ * @param {Object} context - The JSON object to query.
322
+ * @param {string} pathExpression - The JSONPath expression to use for selection.
323
+ * @param {Object} options - Handlebars options object.
324
+ * @returns {string} The rendered string containing the selected data.
325
+ */
326
+
327
+ function selectByJsonPath(context, pathExpression, options) {
328
+ // Query the context with the provided JSONPath expression.
329
+ pathExpression = (typeof pathExpression === 'string' && pathExpression !== '') ? pathExpression : "$";
330
+ const results = jsonpath.JSONPath({ path: pathExpression, json: context });
331
+ // If no results are found, render the inverse block.
332
+ if (!results || results.length === 0) {
333
+ return options.inverse ? options.inverse(this) : '';
334
+ }
335
+
336
+ // If exactly one result is found, use that as the context.
337
+ if (results.length === 1) {
338
+ return options.fn(results[0]);
339
+ }
340
+
341
+ // Otherwise, if multiple results are found, iterate over them.
342
+ let resultString = '';
343
+ results.forEach(result => {
344
+ resultString += options.fn(result);
345
+ });
346
+ return resultString;
347
+ }
348
+
349
+ // Register all helpers with Handlebars
350
+ handlebars.registerHelper('uppercase', uppercase);
351
+ handlebars.registerHelper('eq', eq);
352
+ handlebars.registerHelper('ne', ne);
353
+ handlebars.registerHelper('renderConnectFields', renderConnectFields);
354
+ handlebars.registerHelper('renderConnectExamples', renderConnectExamples);
355
+ handlebars.registerHelper('selectByJsonPath', selectByJsonPath);
356
+
357
+
358
+
359
+ // ============= End of helpers ===========================
360
+
361
+
362
+ /**
363
+ * Recursively merges properties from the `overrides` object into the `target` object.
364
+ *
365
+ * - If both `target[key]` and `overrides[key]` are arrays, it matches each item in `target` with an item in `overrides`
366
+ * that has the same `name` property, then merges selected fields and also recursively processes nested objects.
367
+ * - If both `target[key]` and `overrides[key]` are plain objects, it merges them recursively.
368
+ * - Otherwise, it simply replaces `target[key]` with `overrides[key]`.
369
+ *
370
+ * @param {Object} target The object into which overrides will be merged.
371
+ * @param {Object} overrides The object containing override properties.
372
+ * @returns {Object} The updated `target` object.
373
+ */
374
+ function mergeOverrides(target, overrides) {
375
+ if (!overrides || typeof overrides !== 'object') return target;
376
+
377
+ for (let key in overrides) {
378
+ // Handle arrays by matching items on 'name'
379
+ if (Array.isArray(target[key]) && Array.isArray(overrides[key])) {
380
+ target[key] = target[key].map(item => {
381
+ const overrideItem = overrides[key].find(o => o.name === item.name);
382
+ if (overrideItem) {
383
+ // Only override allowed fields if they are explicitly defined
384
+ ['description', 'type'].forEach(field => {
385
+ if (overrideItem.hasOwnProperty(field)) {
386
+ item[field] = overrideItem[field];
387
+ }
388
+ });
389
+
390
+ // Recursively handle nested children
391
+ item = mergeOverrides(item, overrideItem);
392
+ }
393
+ return item;
394
+ });
395
+
396
+ // Recurse into nested objects
397
+ } else if (
398
+ typeof target[key] === 'object' &&
399
+ typeof overrides[key] === 'object' &&
400
+ !Array.isArray(target[key]) &&
401
+ !Array.isArray(overrides[key])
402
+ ) {
403
+ target[key] = mergeOverrides(target[key], overrides[key]);
404
+
405
+ // Only override top-level description/type if defined in overrides
406
+ } else if (['description', 'type'].includes(key) && overrides.hasOwnProperty(key)) {
407
+ target[key] = overrides[key];
408
+ }
409
+ }
410
+ return target;
411
+ }
412
+
413
+
414
+ function processData_TemplateBlock(parent, reader, attrs, config, extensionRef) {
415
+ const catalog = config.contentCatalog;
416
+ if (!catalog) {
417
+ logger.error('[data_template] Error: content catalog not found');
418
+ return extensionRef.createBlock(parent, 'paragraph', 'Error: content catalog not found', attrs);
419
+ }
420
+
421
+ // The dataPath may be an Antora resource ID (for local files)
422
+ // or an external URL (like https://example.com/data.json)
423
+ const resourceId = attrs.dataPath;
424
+ if (!resourceId) {
425
+ const msg = '[data_template] Error: No data resource ID provided.';
426
+ return extensionRef.createBlock(parent, 'paragraph', msg, attrs);
427
+ }
428
+
429
+ let contentStr;
430
+ let ext = '';
431
+
432
+ if (resourceId.startsWith('http://') || resourceId.startsWith('https://')) {
433
+ // Handle external resource
434
+ try {
435
+ if (externalCache.has(resourceId)) {
436
+ contentStr = externalCache.get(resourceId);
437
+ } else {
438
+ const res = request('GET', resourceId, { timeout: 5000 });
439
+ contentStr = res.getBody('utf8');
440
+ externalCache.set(resourceId, contentStr);
441
+ }
442
+ // Determine file extension from the URL’s pathname.
443
+ try {
444
+ const urlObj = new URL(resourceId);
445
+ const pathname = urlObj.pathname;
446
+ ext = pathname.substring(pathname.lastIndexOf('.')).toLowerCase();
447
+ } catch (err) {
448
+ ext = '';
449
+ }
450
+ } catch (err) {
451
+ const msg = `[data_template] Error fetching external resource: ${err}`;
452
+ return extensionRef.createBlock(parent, 'paragraph', msg, attrs);
453
+ }
454
+ } else {
455
+ // Handle local resource using Antora's content catalog.
456
+ const fileSrc = config.file && config.file.src;
457
+ const resourceFile = catalog.resolveResource(resourceId, fileSrc);
458
+ if (!resourceFile) {
459
+ const msg = `[data_template] Could not resolve resource: ${resourceId}`;
460
+ return extensionRef.createBlock(parent, 'paragraph', msg, attrs);
461
+ }
462
+ try {
463
+ contentStr = resourceFile.contents.toString();
464
+ ext = resourceFile.src.extname.toLowerCase();
465
+ } catch (err) {
466
+ const msg = `[data_template] Error reading local resource: ${err}`;
467
+ return extensionRef.createBlock(parent, 'paragraph', msg, attrs);
468
+ }
469
+ }
470
+
471
+ // Load and parse the data from the resource.
472
+ let dataObj = null;
473
+ try {
474
+ if (ext === '.json') {
475
+ dataObj = JSON.parse(contentStr);
476
+ } else if (ext === '.yaml' || ext === '.yml') {
477
+ dataObj = yaml.parse(contentStr);
478
+ } else {
479
+ // Fallback: try JSON first, then yaml, then default to raw text.
480
+ try {
481
+ dataObj = JSON.parse(contentStr);
482
+ } catch (jsonErr) {
483
+ try {
484
+ dataObj = yaml.parse(contentStr);
485
+ } catch (yamlErr) {
486
+ dataObj = { text: contentStr };
487
+ }
488
+ }
489
+ }
490
+ } catch (err) {
491
+ const msg = `[data_template] Error parsing data: ${err}`;
492
+ return extensionRef.createBlock(parent, 'paragraph', msg, attrs);
493
+ }
494
+
495
+ if (attrs.overrides) {
496
+ try {
497
+ const overridesFile = catalog.resolveResource(attrs.overrides, config.file.src);
498
+ if (overridesFile) {
499
+ const overridesStr = overridesFile.contents.toString();
500
+ const overridesObj = JSON.parse(overridesStr);
501
+ dataObj = mergeOverrides(dataObj, overridesObj);
502
+ }
503
+ } catch (err) {
504
+ logger.error(`[data_template] Error applying overrides: ${err}`);
505
+ }
506
+ }
507
+ dataObj.__rawYAML = contentStr;
508
+
509
+ // Compile the Handlebars template from the block’s content.
510
+ let templateSource = reader.getLines().join('\n');
511
+ templateSource = templateSource.replace(/@@tab-content@@/g, '--')
512
+ let compiledText = '';
513
+ try {
514
+ const template = handlebars.compile(templateSource);
515
+ compiledText = template(dataObj);
516
+ } catch (err) {
517
+ const msg = `[data_template] Handlebars error: ${err}`;
518
+ return extensionRef.createBlock(parent, 'paragraph', msg, attrs);
519
+ }
520
+
521
+ // The following block takes the Handlebars-generated AsciiDoc content (`compiledText`)
522
+ // and parses it in the context of the parent document (`doc`). This is important
523
+ // because it allows Antora’s custom include logic and other extensions to be applied
524
+ // to the content as if it were part of the original AsciiDoc source. After parsing
525
+ // and converting the new document, we append the resulting blocks back into the
526
+ // parent node, merging the dynamically generated content into the final
527
+ // output. Any errors during parsing are caught and reported as a paragraph block.
528
+ try {
529
+ const sourceFile = config.file?.src;
530
+ const baseDir = sourceFile?.relative ? path.dirname(sourceFile.relative) : 'fragments';
531
+ const uniqueName = `data_template-${Date.now()}.adoc`;
532
+ const relativePath = path.join(baseDir, uniqueName);
533
+ const doc = parent.getDocument();
534
+ const attributes = doc.getAttributes()
535
+
536
+ const file = {
537
+ contents: Buffer.from(`${compiledText}`),
538
+ src: {
539
+ component: attributes['page-component-name'],
540
+ version: attributes['page-component-version'],
541
+ module: attributes['page-module'],
542
+ family: 'page',
543
+ relative: relativePath,
544
+ },
545
+ };
546
+ try {
547
+ file.out = computeOut.call(config.contentCatalog, file.src)
548
+ const outFile = createAsciiDocFile(config.contentCatalog, file);
549
+ const newDoc = loadAsciiDoc(outFile, config.contentCatalog, {
550
+ ...doc.getOptions(),
551
+ relativizeResourceRefs: true,
552
+ attributes: {
553
+ ...(doc.getOptions().attributes || {}),
554
+ ...(attributes || {}),
555
+ },
556
+ });
557
+ newDoc.getBlocks().forEach((b) => {
558
+ parent.append(b);
559
+ });
560
+ return null;
561
+ } catch (err) {
562
+ console.warn('❌ loadAsciiDoc threw:', err);
563
+ return extensionRef.createBlock(parent, 'paragraph', `[data_template] loadAsciiDoc error: ${err.message}`, attrs);
564
+ }
565
+ } catch (err) {
566
+ const msg = `[data_template] Error parsing compiled template as AsciiDoc: ${err}`;
567
+ return extensionRef.createBlock(parent, 'paragraph', msg, attrs);
568
+ }
569
+ }
570
+
571
+ module.exports.register = (registry, context) => {
572
+ if (!registry && context) return;
573
+
574
+ const toProc = (fn) => Object.defineProperty(fn, '$$arity', { value: fn.length });
575
+
576
+ function createExtensionGroup({ contentCatalog, file }) {
577
+ return function () {
578
+ this.block('data_template', function () {
579
+ this.positionalAttributes(['dataPath', 'overrides']);
580
+ this.onContext('open');
581
+ this.process((parent, reader, attrs) => {
582
+ return processData_TemplateBlock(parent, reader, attrs, { contentCatalog, file }, this);
583
+ });
584
+ });
585
+ };
586
+ }
587
+
588
+ registry.$groups().$store('data-template-ext', toProc(createExtensionGroup(context)));
589
+ return registry
590
+ };
591
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "4.2.5",
3
+ "version": "4.3.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -50,7 +50,8 @@
50
50
  "./macros/glossary": "./macros/glossary.js",
51
51
  "./macros/rp-connect-components": "./macros/rp-connect-components.js",
52
52
  "./macros/config-ref": "./macros/config-ref.js",
53
- "./macros/helm-ref": "./macros/helm-ref.js"
53
+ "./macros/helm-ref": "./macros/helm-ref.js",
54
+ "./macros/data-template": "./macros/data-template.js"
54
55
  },
55
56
  "files": [
56
57
  "extensions",
@@ -71,15 +72,19 @@
71
72
  "chalk": "4.1.2",
72
73
  "gulp": "^4.0.2",
73
74
  "gulp-connect": "^5.7.0",
75
+ "handlebars": "^4.7.8",
74
76
  "html-entities": "2.3",
75
77
  "js-yaml": "^4.1.0",
78
+ "jsonpath-plus": "^10.3.0",
76
79
  "lodash": "^4.17.21",
77
80
  "micromatch": "^4.0.8",
78
81
  "node-fetch": "^3.3.2",
79
82
  "node-html-parser": "5.4.2-0",
80
83
  "papaparse": "^5.4.1",
81
84
  "semver": "^7.6.0",
82
- "tar": "^7.4.3"
85
+ "sync-request": "^6.1.0",
86
+ "tar": "^7.4.3",
87
+ "yaml": "^2.7.0"
83
88
  },
84
89
  "devDependencies": {
85
90
  "@antora/cli": "3.1.4",