@redpanda-data/docs-extensions-and-macros 5.46.0 → 5.47.1

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.
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ // The two prefixes `related_topics` has used since it was free text, and the two
4
+ // booleans `see_also` replaced them with. Both spellings stay supported: the
5
+ // prefix is what 79 entries in the live overrides file are written as, and the
6
+ // booleans are what a JSON schema can actually check.
7
+ const CLOUD_PREFIX = 'cloud-only:';
8
+ const SELF_MANAGED_PREFIX = 'self-managed-only:';
9
+
10
+ /**
11
+ * Read the audience an override value is scoped to.
12
+ *
13
+ * One parser for every field that can be narrowed to a single docs build, so
14
+ * `see_also`, `links`, description paragraphs and admonitions cannot drift apart
15
+ * on how the scope is spelled. Strings carry the scope as a leading
16
+ * `cloud-only:` / `self-managed-only:` prefix; objects carry it as a
17
+ * `cloud_only` / `self_managed_only` boolean beside a `content` string
18
+ * (`self_hosted_only` is the deprecated spelling of the same flag).
19
+ *
20
+ * Setting both would wrap the value in `ifdef` and `ifndef` at once, so it would
21
+ * render in neither build. The schema rejects that on objects; on a string it
22
+ * cannot happen, since only one prefix can lead.
23
+ *
24
+ * @param {string|{content: string, cloud_only?: boolean, self_managed_only?: boolean}} item
25
+ * @returns {{content: string, cloudOnly: boolean, selfManagedOnly: boolean}|null} Null when the item carries no usable content.
26
+ */
27
+ function parseAudience(item) {
28
+ if (typeof item === 'string') {
29
+ const trimmed = item.trim();
30
+ if (trimmed.startsWith(CLOUD_PREFIX)) {
31
+ return { content: trimmed.slice(CLOUD_PREFIX.length).trim(), cloudOnly: true, selfManagedOnly: false };
32
+ }
33
+ if (trimmed.startsWith(SELF_MANAGED_PREFIX)) {
34
+ return { content: trimmed.slice(SELF_MANAGED_PREFIX.length).trim(), cloudOnly: false, selfManagedOnly: true };
35
+ }
36
+ return { content: trimmed, cloudOnly: false, selfManagedOnly: false };
37
+ }
38
+ if (item && typeof item === 'object' && typeof item.content === 'string') {
39
+ return {
40
+ content: item.content.trim(),
41
+ cloudOnly: item.cloud_only === true,
42
+ // self_hosted_only is the old name for the same flag. The product is
43
+ // called Self-Managed and the string prefix has always been
44
+ // `self-managed-only:`, so one concept was going by two different words
45
+ // depending on whether you wrote it as a prefix or a boolean. Still read,
46
+ // because it is in the published schema, but nothing emits it.
47
+ selfManagedOnly: item.self_managed_only === true || item.self_hosted_only === true,
48
+ };
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Wrap AsciiDoc in the conditional for one audience, or return it unchanged when
55
+ * it belongs in every build.
56
+ *
57
+ * The blank lines are not cosmetic. A conditional is resolved by the
58
+ * preprocessor, so one glued to neighbouring prose leaves those lines adjacent
59
+ * and Asciidoctor merges them into a single paragraph. Worse, the merge depends
60
+ * on the branch: a conditional wrapping a delimited block renders correctly with
61
+ * the attribute set and merges its neighbours with it unset, so a self-managed
62
+ * preview can look right while cloud-docs silently loses its paragraph breaks.
63
+ * Nothing errors and no text goes missing, so only a structural assertion on
64
+ * rendered HTML catches it -- see __tests__/tools/property-extractor/conditional-rendering.test.js.
65
+ *
66
+ * Emits the bare `endif::[]` and puts the cloud branch first, matching the
67
+ * convention already used throughout templates/property.hbs.
68
+ *
69
+ * @param {string} content - AsciiDoc to wrap.
70
+ * @param {{cloudOnly: boolean, selfManagedOnly: boolean}} scope - Audience, as returned by parseAudience.
71
+ * @returns {string} The content, wrapped in a blank-line-separated conditional when it is scoped.
72
+ */
73
+ function wrapForAudience(content, scope) {
74
+ if (!scope || (!scope.cloudOnly && !scope.selfManagedOnly)) return content;
75
+ const directive = scope.cloudOnly ? 'ifdef::env-cloud[]' : 'ifndef::env-cloud[]';
76
+ return `\n${directive}\n${content}\nendif::[]\n`;
77
+ }
78
+
79
+ /**
80
+ * Build the paired conditional for a value that differs by audience, so each
81
+ * build sees its own form of the same passage.
82
+ *
83
+ * Used where a link cannot survive in one build -- a cross-page xref to a page
84
+ * Cloud does not publish -- and the sentence still has to read correctly there.
85
+ * Cloud branch first, same as wrapForAudience.
86
+ *
87
+ * @param {string} cloudContent - What the Cloud build shows.
88
+ * @param {string} selfManagedContent - What the self-managed build shows.
89
+ * @returns {string} Both branches, blank-line separated.
90
+ */
91
+ function wrapBothAudiences(cloudContent, selfManagedContent) {
92
+ if (cloudContent === selfManagedContent) return cloudContent;
93
+ // A blank line between the two directive pairs, not just around the
94
+ // whole block. Nothing downstream of this function currently calls it
95
+ // twice for overlapping text (applyLinksToText groups every scoped spec
96
+ // matching one paragraph into a single call), so nothing today depends on
97
+ // this specific line -- but paragraphBounds treats an unbroken run of
98
+ // conditional directives as ONE paragraph, exactly the mechanism that let
99
+ // a second duplication nest inside a first one's endif::[]/ifndef::[]
100
+ // pair with no blank line to stop it. Kept here too, defensively, so a
101
+ // future caller of this function alone does not have to rediscover that.
102
+ return [
103
+ '',
104
+ 'ifdef::env-cloud[]',
105
+ cloudContent,
106
+ 'endif::[]',
107
+ '',
108
+ 'ifndef::env-cloud[]',
109
+ selfManagedContent,
110
+ 'endif::[]',
111
+ '',
112
+ ].join('\n');
113
+ }
114
+
115
+ /**
116
+ * Find conditionals that are glued to neighbouring content.
117
+ *
118
+ * The complement to the cross-branch paragraph check in the corpus tests, and
119
+ * the only one of the two that catches the symmetric case. When a conditional in
120
+ * prose has no blank line before it, both builds merge the neighbouring
121
+ * paragraphs, so comparing the two renders sees no difference and reports
122
+ * nothing -- yet both are wrong. Reading the source catches it.
123
+ *
124
+ * Scoped to prose: a conditional whose neighbour is a table row (`|`), a block
125
+ * delimiter, a block attribute line (`[...]`), a list item or another
126
+ * conditional is left alone. Table cells have carried their own conditional
127
+ * shape since long before this, and a delimiter terminates the paragraph on its
128
+ * own.
129
+ *
130
+ * @param {string} text - AsciiDoc to inspect.
131
+ * @returns {Array<{line: number, directive: string, neighbour: string, side: ('before'|'after')}>}
132
+ */
133
+ function findGluedConditionals(text) {
134
+ if (typeof text !== 'string' || !text.includes('::')) return [];
135
+ const lines = text.split('\n');
136
+ const offenders = [];
137
+ const selfTerminating = (line) => {
138
+ const t = line.trim();
139
+ if (t === '') return true;
140
+ if (/^if(n?)def::/.test(t) || t === 'endif::[]') return true;
141
+ if (/^(?:-{4,}|={4,}|\.{4,}|\*{4,}|_{4,}|\+{4,}|\/{4,})$/.test(t)) return true;
142
+ if (/^\[.*\]$/.test(t)) return true; // block attribute, e.g. [NOTE] or [cols="1s,2a"]
143
+ if (/^[|!]/.test(t)) return true; // table row or cell
144
+ if (/^[*.-]+\s/.test(t) || /^\d+\.\s/.test(t)) return true; // list item
145
+ if (/^\.\S/.test(t)) return true; // block title, e.g. .Enterprise license required
146
+ return false;
147
+ };
148
+ lines.forEach((line, i) => {
149
+ const trimmed = line.trim();
150
+ if (/^if(n?)def::/.test(trimmed)) {
151
+ const before = i === 0 ? '' : lines[i - 1];
152
+ if (!selfTerminating(before)) {
153
+ offenders.push({ line: i + 1, directive: trimmed, neighbour: before.trim(), side: 'before' });
154
+ }
155
+ }
156
+ if (trimmed === 'endif::[]') {
157
+ const after = i === lines.length - 1 ? '' : lines[i + 1];
158
+ if (!selfTerminating(after)) {
159
+ offenders.push({ line: i + 1, directive: trimmed, neighbour: after.trim(), side: 'after' });
160
+ }
161
+ }
162
+ });
163
+ return offenders;
164
+ }
165
+
166
+ module.exports = parseAudience;
167
+ module.exports.parseAudience = parseAudience;
168
+ module.exports.wrapForAudience = wrapForAudience;
169
+ module.exports.wrapBothAudiences = wrapBothAudiences;
170
+ module.exports.findGluedConditionals = findGluedConditionals;
171
+ module.exports.CLOUD_PREFIX = CLOUD_PREFIX;
172
+ module.exports.SELF_MANAGED_PREFIX = SELF_MANAGED_PREFIX;
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ const { CLOUD_PREFIX, SELF_MANAGED_PREFIX } = require('./audienceScope.js');
4
+
5
+ // A prefix that was clearly MEANT to scope a value to one docs build, spelled
6
+ // in a way the parser does not accept. Deliberately generous: underscores for
7
+ // hyphens, a space before the colon, any casing, a missing separator, and the
8
+ // retired `self-hosted-only` product name.
9
+ //
10
+ // Why this matters more than an unmatched link: an unrecognised prefix is not
11
+ // dropped, it is published. `cloud_only: Cloud clusters require...` renders on
12
+ // the page as that literal string, prefix and all, in both builds. Nothing
13
+ // errors, nothing is missing, and the only way to notice is to read the
14
+ // rendered page.
15
+ const SUSPICIOUS_PREFIX = /^[\s]*((?:cloud|self[-_ ]?managed|self[-_ ]?hosted)[-_ ]?only)[\s]*:/i;
16
+
17
+ // Keys that look like an attempt at the audience booleans but are not the two
18
+ // the templates read. `self_hosted_only` is absent on purpose: it is the
19
+ // deprecated spelling and parseAudience still honours it.
20
+ const VALID_FLAGS = new Set(['cloud_only', 'self_managed_only', 'self_hosted_only']);
21
+ const SUSPICIOUS_FLAG = /^(?:cloud|self[-_ ]?managed|self[-_ ]?hosted)[-_ ]?only$/i;
22
+
23
+ /**
24
+ * Report a string whose leading prefix looks like a failed audience scope.
25
+ *
26
+ * @param {unknown} value
27
+ * @returns {string|null} The offending prefix, or null when the value is fine.
28
+ */
29
+ function findSuspiciousPrefix (value) {
30
+ if (typeof value !== 'string') return null;
31
+ const trimmed = value.trim();
32
+ // An exact prefix is correct by definition, so check those first.
33
+ if (trimmed.startsWith(CLOUD_PREFIX) || trimmed.startsWith(SELF_MANAGED_PREFIX)) return null;
34
+ const match = SUSPICIOUS_PREFIX.exec(trimmed);
35
+ return match ? match[1] : null;
36
+ }
37
+
38
+ /**
39
+ * Report object keys that look like an attempt at the audience booleans.
40
+ *
41
+ * A misspelled flag is silent in a different way: the template's `{{#if
42
+ * cloud_only}}` is simply false, so the value renders in BOTH builds rather
43
+ * than the one the writer scoped it to.
44
+ *
45
+ * @param {unknown} item
46
+ * @returns {string[]} The offending key names.
47
+ */
48
+ function findSuspiciousFlags (item) {
49
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
50
+ return Object.keys(item).filter((key) => !VALID_FLAGS.has(key) && SUSPICIOUS_FLAG.test(key));
51
+ }
52
+
53
+ function pushPrefix (problems, property, field, value) {
54
+ const prefix = findSuspiciousPrefix(value);
55
+ if (prefix) {
56
+ problems.push({
57
+ property,
58
+ field,
59
+ problem: 'unrecognized-prefix',
60
+ detail: prefix,
61
+ text: String(value).trim().slice(0, 120)
62
+ });
63
+ }
64
+ }
65
+
66
+ function pushFlags (problems, property, field, item) {
67
+ for (const key of findSuspiciousFlags(item)) {
68
+ problems.push({ property, field, problem: 'unrecognized-flag', detail: key, text: '' });
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Walk every audience-scopable field on every property and report the scopes
74
+ * that will not take effect.
75
+ *
76
+ * Run BEFORE applyPropertyLinks: that pass flattens an array description into
77
+ * a single string, after which a per-paragraph prefix can no longer be
78
+ * attributed to the paragraph it came from.
79
+ *
80
+ * @param {Record<string, object>} properties
81
+ * @returns {Array<{property: string, field: string, problem: string, detail: string, text: string}>}
82
+ */
83
+ function findAudienceScopeProblems (properties) {
84
+ const problems = [];
85
+ for (const [name, prop] of Object.entries(properties || {})) {
86
+ if (!prop || typeof prop !== 'object') continue;
87
+
88
+ if (Array.isArray(prop.description)) {
89
+ prop.description.forEach((para, i) => pushPrefix(problems, name, `description[${i}]`, para));
90
+ }
91
+
92
+ if (prop.links && typeof prop.links === 'object') {
93
+ for (const [key, value] of Object.entries(prop.links)) {
94
+ pushPrefix(problems, name, `links["${key}"]`, value);
95
+ }
96
+ }
97
+
98
+ // Both spellings: `related_topics` is the field 118 live entries use, and
99
+ // `see_also` is what replaced it.
100
+ for (const field of ['see_also', 'related_topics']) {
101
+ const items = prop[field];
102
+ if (!Array.isArray(items)) continue;
103
+ items.forEach((item, i) => {
104
+ pushPrefix(problems, name, `${field}[${i}]`, item);
105
+ pushFlags(problems, name, `${field}[${i}]`, item);
106
+ });
107
+ }
108
+
109
+ for (const field of ['admonitions', 'includes']) {
110
+ const items = prop[field];
111
+ if (!Array.isArray(items)) continue;
112
+ items.forEach((item, i) => pushFlags(problems, name, `${field}[${i}]`, item));
113
+ }
114
+ }
115
+ return problems;
116
+ }
117
+
118
+ module.exports = findAudienceScopeProblems;
119
+ module.exports.findAudienceScopeProblems = findAudienceScopeProblems;
120
+ module.exports.findSuspiciousPrefix = findSuspiciousPrefix;
121
+ module.exports.findSuspiciousFlags = findSuspiciousFlags;
@@ -17,6 +17,9 @@ module.exports = {
17
17
  normalizePropertyAnchors: require('./normalizePropertyAnchors.js'),
18
18
  canonicalizePropertyXrefs: require('./canonicalizePropertyXrefs.js'),
19
19
  seeAlsoView: require('./seeAlsoView.js'),
20
+ audienceScope: require('./audienceScope.js'),
21
+ applyPropertyLinks: require('./applyPropertyLinks.js'),
22
+ findAudienceScopeProblems: require('./findAudienceScopeProblems.js'),
20
23
  capitalize: require('./capitalize.js'),
21
24
  length: require('./length.js'),
22
25
  };
@@ -1,12 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ const parseAudience = require('./audienceScope.js');
4
+
3
5
  /**
4
6
  * Normalizes a property's related-topics data into one shape for the
5
7
  * templates to render, regardless of which field or convention the override
6
8
  * author used:
7
9
  *
8
10
  * - see_also (current): array of items, each either a plain string (shown
9
- * everywhere) or {content, cloud_only, self_hosted_only} (shown only in
11
+ * everywhere) or {content, cloud_only, self_managed_only} (shown only in
10
12
  * the named build). Schema-validated — see docs-data/property-overrides.schema.json.
11
13
  * - related_topics (deprecated, still read for back-compat with existing
12
14
  * override files): array of strings, optionally prefixed with
@@ -14,8 +16,11 @@
14
16
  * behaviour. That prefix was free text with no validation; see_also
15
17
  * replaces it with data a schema can check.
16
18
  *
19
+ * Both spellings are read by the shared helpers/audienceScope.js parser, which
20
+ * every audience-scoped override field uses, so they cannot drift apart.
21
+ *
17
22
  * @param {object} property - A property record, as extracted or overridden.
18
- * @returns {Array<{content: string, cloudOnly: boolean, selfHostedOnly: boolean}>}
23
+ * @returns {Array<{content: string, cloudOnly: boolean, selfManagedOnly: boolean}>}
19
24
  */
20
25
  function normalizeSeeAlso(property) {
21
26
  if (!property) return [];
@@ -26,26 +31,7 @@ function normalizeSeeAlso(property) {
26
31
  : [];
27
32
 
28
33
  return rawItems
29
- .map((item) => {
30
- if (typeof item === 'string') {
31
- const trimmed = item.trim();
32
- if (trimmed.startsWith('cloud-only:')) {
33
- return { content: trimmed.slice('cloud-only:'.length).trim(), cloudOnly: true, selfHostedOnly: false };
34
- }
35
- if (trimmed.startsWith('self-managed-only:')) {
36
- return { content: trimmed.slice('self-managed-only:'.length).trim(), cloudOnly: false, selfHostedOnly: true };
37
- }
38
- return { content: trimmed, cloudOnly: false, selfHostedOnly: false };
39
- }
40
- if (item && typeof item === 'object' && typeof item.content === 'string') {
41
- return {
42
- content: item.content.trim(),
43
- cloudOnly: item.cloud_only === true,
44
- selfHostedOnly: item.self_hosted_only === true,
45
- };
46
- }
47
- return null;
48
- })
34
+ .map((item) => parseAudience(item))
49
35
  .filter((item) => item && item.content);
50
36
  }
51
37
 
@@ -56,7 +42,7 @@ function normalizeSeeAlso(property) {
56
42
  * once instead of duplicated per item.
57
43
  *
58
44
  * sectionType is 'cloud' only when every item is cloud-only, 'self-managed'
59
- * only when every item is self-hosted-only, and 'normal' otherwise (a mix of
45
+ * only when every item is self-managed-only, and 'normal' otherwise (a mix of
60
46
  * conditional and/or unconditional items, each wrapped individually).
61
47
  *
62
48
  * @param {object} property - A property record, as extracted or overridden.
@@ -67,7 +53,7 @@ function seeAlsoView(property) {
67
53
  let sectionType = 'normal';
68
54
  if (items.length > 0) {
69
55
  if (items.every((item) => item.cloudOnly)) sectionType = 'cloud';
70
- else if (items.every((item) => item.selfHostedOnly)) sectionType = 'self-managed';
56
+ else if (items.every((item) => item.selfManagedOnly)) sectionType = 'self-managed';
71
57
  }
72
58
  return { items, sectionType };
73
59
  }
@@ -1243,15 +1243,43 @@ def _normalize_admonitions(admonitions):
1243
1243
  continue
1244
1244
  if entry["title"]:
1245
1245
  normalized_entry["title"] = entry["title"]
1246
+ # The entry is rebuilt rather than copied, so every field the templates
1247
+ # read has to be carried across explicitly. These two scope the whole
1248
+ # admonition block to one docs build; setting both would wrap it in
1249
+ # ifdef and ifndef at once so it rendered in neither, which the schema
1250
+ # rejects and the generator drops with a warning.
1251
+ if entry.get("cloud_only") is True:
1252
+ normalized_entry["cloud_only"] = True
1253
+ # self_hosted_only is the deprecated spelling of self_managed_only, read
1254
+ # so an override written against the older schema still scopes, and
1255
+ # normalized to the current name so only one spelling reaches the
1256
+ # templates.
1257
+ if entry.get("self_managed_only") is True or entry.get("self_hosted_only") is True:
1258
+ normalized_entry["self_managed_only"] = True
1246
1259
  normalized.append(normalized_entry)
1247
1260
  return normalized
1248
1261
 
1249
1262
 
1250
1263
  def _apply_override_to_existing_property(property_dict, override, overrides_file_path):
1251
1264
  """Apply overrides to an existing property."""
1252
- # Apply description override
1265
+ # Apply description override. Either a plain string or, when a paragraph has
1266
+ # to be scoped to one docs build, an array of paragraphs; the generator
1267
+ # flattens the array into AsciiDoc so the audience conditional never has to
1268
+ # be hand-written into the JSON string.
1253
1269
  if "description" in override:
1254
1270
  property_dict["description"] = override["description"]
1271
+
1272
+ # Declared links: {"<text in the prose>": "<target>"}. Carried through as
1273
+ # data and turned into AsciiDoc by the generator, which is what keeps the
1274
+ # description itself plain prose the overrides audit can compare to source.
1275
+ if "links" in override:
1276
+ property_dict["links"] = override["links"]
1277
+
1278
+ # Shared partials pulled into the property entry (the "internal use only"
1279
+ # warning, the HTTP Proxy breaking-change notice). Carried as data so the
1280
+ # description stays prose the overrides audit can compare against source.
1281
+ if "includes" in override:
1282
+ property_dict["includes"] = override["includes"]
1255
1283
 
1256
1284
  # Apply version override (introduced in version)
1257
1285
  if "version" in override:
@@ -1282,7 +1310,7 @@ def _apply_override_to_existing_property(property_dict, override, overrides_file
1282
1310
  logger.warning(f"related_topics for property must be an array")
1283
1311
 
1284
1312
  # Apply see_also override. Structural shape (plain string, or an object
1285
- # naming exactly one of cloud_only/self_hosted_only) is enforced by
1313
+ # naming exactly one of cloud_only/self_managed_only) is enforced by
1286
1314
  # docs-data/property-overrides.schema.json via `doc-tools validate
1287
1315
  # property-overrides`, not here — this just passes the data through for
1288
1316
  # seeAlsoView.js to normalize at render time.
@@ -27,10 +27,34 @@ endif::[]
27
27
 
28
28
  No description available.
29
29
 
30
+ {{/if}}
31
+ {{#if includes}}
32
+ {{#each includes}}
33
+
34
+ {{#if cloud_only}}
35
+ ifdef::env-cloud[]
36
+ {{/if}}
37
+ {{#if self_managed_only}}
38
+ ifndef::env-cloud[]
39
+ {{/if}}
40
+ include::{{{target}}}
41
+ {{#if cloud_only}}
42
+ endif::[]
43
+ {{/if}}
44
+ {{#if self_managed_only}}
45
+ endif::[]
46
+ {{/if}}
47
+ {{/each}}
30
48
  {{/if}}
31
49
  {{#if admonitions}}
32
50
  {{#each admonitions}}
33
51
 
52
+ {{#if cloud_only}}
53
+ ifdef::env-cloud[]
54
+ {{/if}}
55
+ {{#if self_managed_only}}
56
+ ifndef::env-cloud[]
57
+ {{/if}}
34
58
  {{#if title}}
35
59
  .{{{title}}}
36
60
  {{/if}}
@@ -38,6 +62,12 @@ No description available.
38
62
  ====
39
63
  {{{text}}}
40
64
  ====
65
+ {{#if cloud_only}}
66
+ endif::[]
67
+ {{/if}}
68
+ {{#if self_managed_only}}
69
+ endif::[]
70
+ {{/if}}
41
71
  {{/each}}
42
72
  {{/if}}
43
73
  {{#if is_enterprise}}
@@ -222,7 +252,7 @@ endif::[]
222
252
  ifdef::env-cloud[]
223
253
  * {{{content}}}
224
254
  endif::[]
225
- {{else if selfHostedOnly}}
255
+ {{else if selfManagedOnly}}
226
256
  ifndef::env-cloud[]
227
257
  * {{{content}}}
228
258
  endif::[]
@@ -236,7 +266,7 @@ endif::[]
236
266
  ifdef::env-cloud[]
237
267
  {{{content}}}
238
268
  endif::[]
239
- {{else if selfHostedOnly}}
269
+ {{else if selfManagedOnly}}
240
270
  ifndef::env-cloud[]
241
271
  {{{content}}}
242
272
  endif::[]
@@ -25,10 +25,34 @@
25
25
 
26
26
  No description available.
27
27
 
28
+ {{/if}}
29
+ {{#if includes}}
30
+ {{#each includes}}
31
+
32
+ {{#if cloud_only}}
33
+ ifdef::env-cloud[]
34
+ {{/if}}
35
+ {{#if self_managed_only}}
36
+ ifndef::env-cloud[]
37
+ {{/if}}
38
+ include::{{{target}}}
39
+ {{#if cloud_only}}
40
+ endif::[]
41
+ {{/if}}
42
+ {{#if self_managed_only}}
43
+ endif::[]
44
+ {{/if}}
45
+ {{/each}}
28
46
  {{/if}}
29
47
  {{#if admonitions}}
30
48
  {{#each admonitions}}
31
49
 
50
+ {{#if cloud_only}}
51
+ ifdef::env-cloud[]
52
+ {{/if}}
53
+ {{#if self_managed_only}}
54
+ ifndef::env-cloud[]
55
+ {{/if}}
32
56
  {{#if title}}
33
57
  .{{{title}}}
34
58
  {{/if}}
@@ -36,6 +60,12 @@ No description available.
36
60
  ====
37
61
  {{{text}}}
38
62
  ====
63
+ {{#if cloud_only}}
64
+ endif::[]
65
+ {{/if}}
66
+ {{#if self_managed_only}}
67
+ endif::[]
68
+ {{/if}}
39
69
  {{/each}}
40
70
  {{/if}}
41
71
  {{#if is_enterprise}}
@@ -179,7 +209,7 @@ endif::[]
179
209
  ifdef::env-cloud[]
180
210
  * {{{content}}}
181
211
  endif::[]
182
- {{else if selfHostedOnly}}
212
+ {{else if selfManagedOnly}}
183
213
  ifndef::env-cloud[]
184
214
  * {{{content}}}
185
215
  endif::[]
@@ -193,7 +223,7 @@ endif::[]
193
223
  ifdef::env-cloud[]
194
224
  {{{content}}}
195
225
  endif::[]
196
- {{else if selfHostedOnly}}
226
+ {{else if selfManagedOnly}}
197
227
  ifndef::env-cloud[]
198
228
  {{{content}}}
199
229
  endif::[]
@@ -93,10 +93,16 @@ function ensureHeadingSeparation (body) {
93
93
  * Single-backtick spans still apply attribute substitution, so spans are
94
94
  * escaped too; `----` listing blocks and ```/~~~ fences are verbatim and stay
95
95
  * untouched (a `\{` inside a fence would render as a literal backslash).
96
+ *
97
+ * The name may start uppercase or with an underscore, because environment
98
+ * variable placeholders are the common case in prose: Asciidoctor downcases
99
+ * a reference before looking it up, so ${SALESFORCE_CLIENT_SECRET} outside a
100
+ * fence is consumed exactly like a lowercase one and logs the missing
101
+ * attribute as salesforce_client_secret.
96
102
  */
97
103
  function escapePlaceholderBraces (body) {
98
104
  return annotateVerbatimLines(body).map(({ line, verbatim }) => (
99
- verbatim ? line : line.replace(/(?<![\\{])\{([a-z][\w.-]{1,30})\}/g, '\\{$1}')
105
+ verbatim ? line : line.replace(/(?<![\\{])\{([A-Za-z_][\w.-]{1,30})\}/g, '\\{$1}')
100
106
  )).join('\n');
101
107
  }
102
108
 
@@ -2,6 +2,7 @@ const yaml = require('yaml');
2
2
  const renderYamlList = require('./renderYamlList');
3
3
  const handlebars = require('handlebars');
4
4
  const { connectFieldName } = require('./flattenConnectFields');
5
+ const { escapePlaceholderBraces } = require('./renderConnectDescription');
5
6
 
6
7
  /**
7
8
  * Renders the children of a configuration object into AsciiDoc.
@@ -68,7 +69,9 @@ module.exports = function renderConnectFields(children, prefix = '') {
68
69
  block += `=== \`${currentPath}\`\n\n`;
69
70
 
70
71
  // --- Beta badge logic (now uses is_beta) ---
71
- let desc = child.description || '';
72
+ // Escaped before the badge is prepended, not after: the badge's own
73
+ // tooltip={page-beta-text} is a real attribute reference and must survive.
74
+ let desc = escapePlaceholderBraces(child.description || '');
72
75
  if (child.is_beta) {
73
76
  // Remove any leading "BETA:" label (case-insensitive, trims leading whitespace)
74
77
  desc = 'badge::[label=Beta, size=large, tooltip={page-beta-text}]\n\n' + desc.replace(/^\s*BETA:\s*/i, '');