@redpanda-data/docs-extensions-and-macros 4.15.6 → 4.15.8

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.
@@ -244,7 +244,8 @@ async function generateRpcnConnectorDocs(options) {
244
244
  templateBloblang,
245
245
  writeFullDrafts,
246
246
  cgoOnly = [], // Array of cgo-only connectors from cgo binary inspection
247
- cloudOnly = [] // Array of cloud-only connectors from cloud binary inspection
247
+ cloudOnly = [], // Array of cloud-only connectors from cloud binary inspection
248
+ csvMetadata = [] // Array of CSV metadata with support levels
248
249
  } = options;
249
250
 
250
251
  // Read connector index (JSON or YAML)
@@ -274,6 +275,16 @@ async function generateRpcnConnectorDocs(options) {
274
275
  });
275
276
  }
276
277
 
278
+ // Build a Map of CSV metadata for fast support level lookup
279
+ const csvMetadataMap = new Map();
280
+ if (Array.isArray(csvMetadata)) {
281
+ csvMetadata.forEach(item => {
282
+ if (item.type && item.name) {
283
+ csvMetadataMap.set(`${item.type}:${item.name}`, item);
284
+ }
285
+ });
286
+ }
287
+
277
288
  // Apply overrides if provided
278
289
  if (overrides && fs.existsSync(overrides)) {
279
290
  const ovRaw = fs.readFileSync(overrides, 'utf8');
@@ -395,6 +406,7 @@ async function generateRpcnConnectorDocs(options) {
395
406
  }
396
407
 
397
408
  // Check if this connector is cgo-only or cloud-only and mark it
409
+ // Use plural type for CGO/cloud detection (matches test expectations)
398
410
  const connectorKey = `${type}:${name}`;
399
411
  const isCloudOnly = cloudOnlySet.has(connectorKey);
400
412
  const isCgoOnly = cgoOnlySet.has(connectorKey);
@@ -411,6 +423,13 @@ async function generateRpcnConnectorDocs(options) {
411
423
  item.cloudOnly = true;
412
424
  }
413
425
 
426
+ // Lookup support level from CSV metadata using singular type
427
+ const csvKey = `${item.type}:${name}`;
428
+ const csvData = csvMetadataMap.get(csvKey);
429
+ if (csvData && csvData.support) {
430
+ item.support = csvData.support;
431
+ }
432
+
414
433
  let content;
415
434
  try {
416
435
  content = compiledTemplate(item);
@@ -453,6 +472,28 @@ async function generateRpcnConnectorDocs(options) {
453
472
  if (!Array.isArray(items)) continue;
454
473
  const outRoot = path.join(outputRoot, folder);
455
474
  fs.mkdirSync(outRoot, { recursive: true });
475
+
476
+ // Get current item names
477
+ const currentNames = new Set(items.filter(fn => fn.name).map(fn => fn.name));
478
+
479
+ // Delete partials that no longer exist in the data
480
+ if (fs.existsSync(outRoot)) {
481
+ const existingFiles = fs.readdirSync(outRoot).filter(f => f.endsWith('.adoc'));
482
+ for (const file of existingFiles) {
483
+ const name = file.replace('.adoc', '');
484
+ // Skip hand-authored files (by convention, start with underscore)
485
+ if (name.startsWith('_')) {
486
+ console.log(`Skipping hand-authored file: ${file}`);
487
+ continue;
488
+ }
489
+ if (!currentNames.has(name)) {
490
+ const filePath = path.join(outRoot, file);
491
+ fs.unlinkSync(filePath);
492
+ console.log(`Deleted removed ${folder} partial: ${file}`);
493
+ }
494
+ }
495
+ }
496
+
456
497
  // Use custom or default template
457
498
  const bloblangTemplatePath = templateBloblang || path.resolve(__dirname, './templates/bloblang-function.hbs');
458
499
  const bloblangTemplate = handlebars.compile(fs.readFileSync(bloblangTemplatePath, 'utf8'));
@@ -465,6 +506,71 @@ async function generateRpcnConnectorDocs(options) {
465
506
  partialFiles.push(path.relative(process.cwd(), outPath));
466
507
  }
467
508
  }
509
+
510
+ // Generate overview pages for Bloblang methods and functions
511
+ const guidesRoot = path.join(process.cwd(), 'modules/guides/pages/bloblang');
512
+ fs.mkdirSync(guidesRoot, { recursive: true });
513
+
514
+ // Generate methods.adoc with categorized methods
515
+ if (dataObj['bloblang-methods']) {
516
+ const methodsData = dataObj['bloblang-methods'];
517
+ const categoriesMap = new Map();
518
+
519
+ // Extract categories and their methods
520
+ for (const method of methodsData) {
521
+ if (!method.name || !method.categories || !Array.isArray(method.categories)) continue;
522
+
523
+ for (const cat of method.categories) {
524
+ const categoryName = cat.Category;
525
+ if (!categoryName) continue;
526
+
527
+ if (!categoriesMap.has(categoryName)) {
528
+ categoriesMap.set(categoryName, []);
529
+ }
530
+ categoriesMap.get(categoryName).push(method.name);
531
+ }
532
+ }
533
+
534
+ // Sort categories and prepare data for template
535
+ const categories = Array.from(categoriesMap.entries())
536
+ .map(([name, methods]) => ({
537
+ name,
538
+ methods: methods.sort()
539
+ }))
540
+ .sort((a, b) => {
541
+ // Special ordering: General first, Deprecated last
542
+ if (a.name === 'General') return -1;
543
+ if (b.name === 'General') return 1;
544
+ if (a.name === 'Deprecated') return 1;
545
+ if (b.name === 'Deprecated') return -1;
546
+ return a.name.localeCompare(b.name);
547
+ });
548
+
549
+ const methodsTemplatePath = path.resolve(__dirname, './templates/bloblang-methods-overview.hbs');
550
+ const methodsTemplate = handlebars.compile(fs.readFileSync(methodsTemplatePath, 'utf8'));
551
+ const methodsAdoc = methodsTemplate({ categories });
552
+ const methodsOutPath = path.join(guidesRoot, 'methods.adoc');
553
+ fs.writeFileSync(methodsOutPath, methodsAdoc, 'utf8');
554
+ console.log('Generated methods.adoc overview page');
555
+ partialFiles.push(path.relative(process.cwd(), methodsOutPath));
556
+ }
557
+
558
+ // Generate functions.adoc with all functions
559
+ if (dataObj['bloblang-functions']) {
560
+ const functionsData = dataObj['bloblang-functions'];
561
+ const functionNames = functionsData
562
+ .filter(fn => fn.name)
563
+ .map(fn => fn.name)
564
+ .sort();
565
+
566
+ const functionsTemplatePath = path.resolve(__dirname, './templates/bloblang-functions-overview.hbs');
567
+ const functionsTemplate = handlebars.compile(fs.readFileSync(functionsTemplatePath, 'utf8'));
568
+ const functionsAdoc = functionsTemplate({ functions: functionNames });
569
+ const functionsOutPath = path.join(guidesRoot, 'functions.adoc');
570
+ fs.writeFileSync(functionsOutPath, functionsAdoc, 'utf8');
571
+ console.log('Generated functions.adoc overview page');
572
+ partialFiles.push(path.relative(process.cwd(), functionsOutPath));
573
+ }
468
574
  }
469
575
 
470
576
  // Common/Advanced config snippet YAMLs in modules/components/examples
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ const octokit = require('../../cli-utils/octokit-client');
4
+ const { hasGitHubToken } = require('../../cli-utils/github-token');
5
+ const semver = require('semver');
6
+
7
+ /**
8
+ * GitHub release discovery utilities for Redpanda Connect
9
+ *
10
+ * Provides functions to discover and filter GitHub releases between versions.
11
+ */
12
+
13
+ // Cache for GitHub releases to avoid repeated API calls
14
+ let releaseCache = null;
15
+ let cacheTimestamp = null;
16
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
17
+
18
+ /**
19
+ * Fetch all releases from redpanda-data/connect repository
20
+ * @param {boolean} useCache - Whether to use cached results
21
+ * @returns {Promise<Array>} Array of release objects
22
+ */
23
+ async function fetchAllReleases(useCache = true) {
24
+ // Check cache
25
+ if (useCache && releaseCache && cacheTimestamp) {
26
+ const age = Date.now() - cacheTimestamp;
27
+ if (age < CACHE_TTL_MS) {
28
+ console.log(`✓ Using cached releases (${Math.round(age / 1000)}s old)`);
29
+ return releaseCache;
30
+ }
31
+ }
32
+
33
+ console.log('Fetching releases from GitHub...');
34
+
35
+ // Warn if no token available (only once per execution)
36
+ if (!hasGitHubToken() && !fetchAllReleases._warnedAboutToken) {
37
+ console.warn('⚠️ No GitHub token found. API rate limits will be more restrictive.');
38
+ console.warn(' Set GITHUB_TOKEN or GH_TOKEN environment variable for higher limits.');
39
+ fetchAllReleases._warnedAboutToken = true;
40
+ }
41
+
42
+ try {
43
+ // Fetch all releases (paginated)
44
+ const releases = await octokit.paginate(
45
+ octokit.rest.repos.listReleases,
46
+ {
47
+ owner: 'redpanda-data',
48
+ repo: 'connect',
49
+ per_page: 100
50
+ }
51
+ );
52
+
53
+ console.log(`✓ Fetched ${releases.length} releases from GitHub`);
54
+
55
+ // Update cache
56
+ releaseCache = releases;
57
+ cacheTimestamp = Date.now();
58
+
59
+ return releases;
60
+ } catch (error) {
61
+ if (error.status === 403 && error.response?.headers?.['x-ratelimit-remaining'] === '0') {
62
+ const resetTime = new Date(parseInt(error.response.headers['x-ratelimit-reset']) * 1000);
63
+ throw new Error(
64
+ `GitHub API rate limit exceeded. Resets at ${resetTime.toLocaleTimeString()}. ` +
65
+ `Consider setting GITHUB_TOKEN environment variable for higher limits.`
66
+ );
67
+ }
68
+
69
+ throw new Error(`Failed to fetch GitHub releases: ${error.message}`);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Parse version from GitHub release tag
75
+ * Handles formats: "v4.50.0", "4.50.0", "v4.50.0-beta.1"
76
+ * @param {string} tag - Release tag name
77
+ * @returns {string|null} Normalized version string or null if invalid
78
+ */
79
+ function parseVersionFromTag(tag) {
80
+ if (!tag) return null;
81
+
82
+ // Remove 'v' prefix if present
83
+ const normalized = tag.startsWith('v') ? tag.slice(1) : tag;
84
+
85
+ // Validate semver format
86
+ const version = semver.valid(normalized);
87
+ return version;
88
+ }
89
+
90
+ /**
91
+ * Check if a version is a pre-release (beta, RC, alpha, etc.)
92
+ * @param {string} version - Semver version string
93
+ * @returns {boolean} True if pre-release
94
+ */
95
+ function isPrerelease(version) {
96
+ const parsed = semver.parse(version);
97
+ if (!parsed) return false;
98
+
99
+ return parsed.prerelease.length > 0;
100
+ }
101
+
102
+ /**
103
+ * Filter releases to stable GA releases only
104
+ * @param {Array} releases - Array of GitHub release objects
105
+ * @returns {Array} Filtered releases with only stable versions
106
+ */
107
+ function filterToStableReleases(releases) {
108
+ return releases.filter(release => {
109
+ // Skip drafts
110
+ if (release.draft) {
111
+ return false;
112
+ }
113
+
114
+ // Parse version
115
+ const version = parseVersionFromTag(release.tag_name);
116
+ if (!version) {
117
+ return false;
118
+ }
119
+
120
+ // Skip pre-releases (beta, RC, etc.)
121
+ if (isPrerelease(version)) {
122
+ return false;
123
+ }
124
+
125
+ return true;
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Discover all intermediate releases between two versions
131
+ * @param {string} fromVersion - Starting version (e.g., "4.50.0")
132
+ * @param {string} toVersion - Ending version (e.g., "4.54.0")
133
+ * @param {Object} options - Optional configuration
134
+ * @param {boolean} options.includePrerelease - Include beta/RC versions (default: false)
135
+ * @param {boolean} options.useCache - Use cached GitHub data (default: true)
136
+ * @returns {Promise<Array>} Array of version objects: [{version, tag, date, url}]
137
+ */
138
+ async function discoverIntermediateReleases(fromVersion, toVersion, options = {}) {
139
+ const {
140
+ includePrerelease = false,
141
+ useCache = true
142
+ } = options;
143
+
144
+ // Validate versions are strings
145
+ if (typeof fromVersion !== 'string') {
146
+ throw new Error(`Invalid starting version: ${fromVersion}`);
147
+ }
148
+ if (typeof toVersion !== 'string') {
149
+ throw new Error(`Invalid ending version: ${toVersion}`);
150
+ }
151
+
152
+ // Normalize versions (remove 'v' prefix if present)
153
+ const normalizedFrom = fromVersion.startsWith('v') ? fromVersion.slice(1) : fromVersion;
154
+ const normalizedTo = toVersion.startsWith('v') ? toVersion.slice(1) : toVersion;
155
+
156
+ // Validate versions
157
+ if (!semver.valid(normalizedFrom)) {
158
+ throw new Error(`Invalid starting version: ${fromVersion}`);
159
+ }
160
+ if (!semver.valid(normalizedTo)) {
161
+ throw new Error(`Invalid ending version: ${toVersion}`);
162
+ }
163
+
164
+ console.log('');
165
+ console.log(`Discovering releases between ${normalizedFrom} and ${normalizedTo}...`);
166
+
167
+ // Fetch all releases
168
+ const allReleases = await fetchAllReleases(useCache);
169
+
170
+ // Filter to stable releases unless includePrerelease is true
171
+ const filteredReleases = includePrerelease
172
+ ? allReleases.filter(r => !r.draft && parseVersionFromTag(r.tag_name))
173
+ : filterToStableReleases(allReleases);
174
+
175
+ // Parse and filter versions in range
176
+ const versionsInRange = [];
177
+
178
+ for (const release of filteredReleases) {
179
+ const version = parseVersionFromTag(release.tag_name);
180
+ if (!version) continue;
181
+
182
+ // Check if version is in range (inclusive)
183
+ if (
184
+ semver.gte(version, normalizedFrom) &&
185
+ semver.lte(version, normalizedTo)
186
+ ) {
187
+ versionsInRange.push({
188
+ version,
189
+ tag: release.tag_name,
190
+ date: release.published_at,
191
+ url: release.html_url,
192
+ isPrerelease: isPrerelease(version)
193
+ });
194
+ }
195
+ }
196
+
197
+ // Sort by semver (oldest to newest)
198
+ versionsInRange.sort((a, b) => semver.compare(a.version, b.version));
199
+
200
+ console.log(`✓ Found ${versionsInRange.length} release(s) in range`);
201
+
202
+ if (versionsInRange.length === 0) {
203
+ console.warn('⚠️ No releases found in the specified range');
204
+ return [];
205
+ }
206
+
207
+ // Log the discovered versions
208
+ console.log('');
209
+ console.log('Releases to process:');
210
+ versionsInRange.forEach((v, i) => {
211
+ const prereleaseTag = v.isPrerelease ? ' (pre-release)' : '';
212
+ const date = new Date(v.date).toLocaleDateString();
213
+ console.log(` ${i + 1}. ${v.version}${prereleaseTag} - ${date}`);
214
+ });
215
+ console.log('');
216
+
217
+ return versionsInRange;
218
+ }
219
+
220
+ /**
221
+ * Find the appropriate cloud version for a given OSS release date
222
+ * Returns the latest stable cloud version published on or before the OSS release date
223
+ * @param {string} ossReleaseDate - ISO date string of the OSS release
224
+ * @param {Object} options - Optional configuration
225
+ * @param {boolean} options.useCache - Use cached GitHub data (default: true)
226
+ * @returns {Promise<string|null>} Cloud version string or null if not found
227
+ */
228
+ async function findCloudVersionForDate(ossReleaseDate, options = {}) {
229
+ const { useCache = true } = options;
230
+
231
+ // Fetch all releases from the main connect repo (includes cloud builds)
232
+ const allReleases = await fetchAllReleases(useCache);
233
+
234
+ // Filter to stable releases
235
+ const stableReleases = filterToStableReleases(allReleases);
236
+
237
+ // Filter to releases on or before the OSS release date
238
+ const ossDate = new Date(ossReleaseDate);
239
+ const eligibleReleases = stableReleases.filter(release => {
240
+ const releaseDate = new Date(release.published_at);
241
+ return releaseDate <= ossDate;
242
+ });
243
+
244
+ if (eligibleReleases.length === 0) {
245
+ return null;
246
+ }
247
+
248
+ // Sort by date (most recent first)
249
+ eligibleReleases.sort((a, b) => {
250
+ return new Date(b.published_at) - new Date(a.published_at);
251
+ });
252
+
253
+ // Return the most recent version
254
+ const cloudVersion = parseVersionFromTag(eligibleReleases[0].tag_name);
255
+ return cloudVersion;
256
+ }
257
+
258
+ /**
259
+ * Clear the release cache
260
+ * Useful for testing or when you need fresh data
261
+ */
262
+ function clearCache() {
263
+ releaseCache = null;
264
+ cacheTimestamp = null;
265
+ }
266
+
267
+ module.exports = {
268
+ discoverIntermediateReleases,
269
+ fetchAllReleases,
270
+ parseVersionFromTag,
271
+ isPrerelease,
272
+ filterToStableReleases,
273
+ findCloudVersionForDate,
274
+ clearCache
275
+ };
@@ -1,10 +1,30 @@
1
1
  // Bloblang example formatting helper for Handlebars
2
2
  function bloblangExample(example) {
3
3
  if (typeof example === 'object' && example !== null && example.mapping) {
4
+ let leadIn = '';
4
5
  let codeBlock = '';
6
+
7
+ // Extract summary as lead-in prose (not a comment in code)
5
8
  if (example.summary && example.summary.trim()) {
6
- codeBlock += `# ${example.summary.trim().replace(/\n/g, '\n# ')}\n\n`;
9
+ let summary = example.summary.trim();
10
+
11
+ // Convert Markdown headings to AsciiDoc
12
+ // ##### Heading -> ==== Heading (H5 -> H4 in AsciiDoc)
13
+ summary = summary.replace(/^#####\s+(.+)$/gm, '==== $1');
14
+ // #### Heading -> === Heading (H4 -> H3 in AsciiDoc)
15
+ summary = summary.replace(/^####\s+(.+)$/gm, '=== $1');
16
+ // ### Heading -> == Heading (H3 -> H2 in AsciiDoc)
17
+ summary = summary.replace(/^###\s+(.+)$/gm, '== $1');
18
+
19
+ // Ensure lead-in ends with a colon (replace period/exclamation/question mark if present)
20
+ if (summary.endsWith('.') || summary.endsWith('!') || summary.endsWith('?')) {
21
+ summary = summary.slice(0, -1) + ':';
22
+ } else if (!summary.endsWith(':')) {
23
+ summary += ':';
24
+ }
25
+ leadIn = summary + '\n\n';
7
26
  }
27
+
8
28
  if (typeof example.mapping === 'string') {
9
29
  codeBlock += example.mapping.trim() + '\n';
10
30
  }
@@ -15,7 +35,7 @@ function bloblangExample(example) {
15
35
  }
16
36
  }
17
37
  }
18
- return `[,coffeescript]\n----\n${codeBlock.trim()}\n----\n`;
38
+ return `${leadIn}[,bloblang]\n----\n${codeBlock.trim()}\n----\n`;
19
39
  } else {
20
40
  let exStr = '';
21
41
  if (typeof example === 'string') {
@@ -35,7 +55,7 @@ function bloblangExample(example) {
35
55
  } else {
36
56
  exStr = String(example);
37
57
  }
38
- return `[source,coffeescript]\n----\n${exStr}\n----\n`;
58
+ return `[source,bloblang]\n----\n${exStr}\n----\n`;
39
59
  }
40
60
  }
41
61
 
@@ -1,11 +1,14 @@
1
1
  const renderLeafField = require('./renderLeafField');
2
2
  const renderObjectField = require('./renderObjectField');
3
3
 
4
+ // Component types that support the 'label' field
5
+ const TYPES_WITH_LABEL = new Set(['inputs', 'outputs', 'processors']);
6
+
4
7
  /**
5
- * Builds either Common or Advanced YAML for one connector.
8
+ * Builds either "Common" or "Advanced" YAML for one connector.
6
9
  *
7
- * - type = input or output (or whatever type)
8
- * - connectorName = such as amqp_1
10
+ * - type = "input" or "output" (or whatever type)
11
+ * - connectorName = such as "amqp_1"
9
12
  * - children = the array of field‐definitions (entry.config.children)
10
13
  * - includeAdvanced = if false → only fields where is_advanced !== true
11
14
  * if true → all fields (except deprecated)
@@ -13,18 +16,20 @@ const renderObjectField = require('./renderObjectField');
13
16
  * Structure produced:
14
17
  *
15
18
  * type:
16
- * label: ""
19
+ * label: "" (only for inputs, outputs, processors)
17
20
  * connectorName:
18
- * ...child fields (with comments for no default)
21
+ * ...child fields (with comments for "no default")
19
22
  */
20
23
  module.exports = function buildConfigYaml(type, connectorName, children, includeAdvanced) {
21
24
  const lines = [];
22
25
 
23
- // type:” top‐level
26
+ // "type:" top‐level
24
27
  lines.push(`${type}:`);
25
28
 
26
- // Two‐space indent for label
27
- lines.push(` label: ""`);
29
+ // Two‐space indent for "label" (only for types that support it)
30
+ if (TYPES_WITH_LABEL.has(type)) {
31
+ lines.push(` label: ""`);
32
+ }
28
33
 
29
34
  // Two‐space indent for connectorName heading
30
35
  lines.push(` ${connectorName}:`);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Ensures a text string ends with a period (or other terminal punctuation).
3
+ * Used for normalizing Bloblang descriptions.
4
+ *
5
+ * @param {string} text - The text to process
6
+ * @returns {string} Text ending with appropriate punctuation
7
+ */
8
+ function ensurePeriod(text) {
9
+ if (!text || typeof text !== 'string') {
10
+ return text;
11
+ }
12
+
13
+ const trimmed = text.trim();
14
+ if (!trimmed) {
15
+ return text;
16
+ }
17
+
18
+ // Check if already ends with terminal punctuation
19
+ if (trimmed.endsWith('.') || trimmed.endsWith('!') || trimmed.endsWith('?')) {
20
+ return text;
21
+ }
22
+
23
+ // Add period
24
+ return text.trim() + '.';
25
+ }
26
+
27
+ module.exports = ensurePeriod;
@@ -0,0 +1,7 @@
1
+ // Check if any parameters are optional
2
+ function hasOptionalParams(params) {
3
+ if (!Array.isArray(params)) return false;
4
+ return params.some(param => param.is_optional);
5
+ }
6
+
7
+ module.exports = hasOptionalParams;
@@ -17,4 +17,7 @@ module.exports = {
17
17
  commonConfig: require('./commonConfig.js'),
18
18
  advancedConfig: require('./advancedConfig.js'),
19
19
  bloblangExample: require('./bloblangExample.js'),
20
+ hasOptionalParams: require('./hasOptionalParams.js'),
21
+ ensurePeriod: require('./ensurePeriod.js'),
22
+ toSentenceCase: require('./toSentenceCase.js'),
20
23
  };
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Convert a string to sentence case, preserving acronyms and proper nouns
5
+ * @param {string} text - The text to convert
6
+ * @returns {string} The text in sentence case
7
+ */
8
+ function toSentenceCase(text) {
9
+ if (!text) return ''
10
+
11
+ // Map of exact word matches to preserve (case-sensitive)
12
+ const exactPreserve = new Map([
13
+ ['geoip', 'GeoIP'],
14
+ ['GEOIP', 'GeoIP'],
15
+ ['GeoIP', 'GeoIP']
16
+ ])
17
+
18
+ // List of acronyms to preserve (will be uppercased)
19
+ const preserveWords = new Set([
20
+ 'SQL',
21
+ 'JSON',
22
+ 'JWT',
23
+ 'XML',
24
+ 'HTML',
25
+ 'URL',
26
+ 'URI',
27
+ 'HTTP',
28
+ 'HTTPS',
29
+ 'TLS',
30
+ 'SSL',
31
+ 'AWS',
32
+ 'GCP',
33
+ 'API',
34
+ 'ID',
35
+ 'UUID',
36
+ 'CSV'
37
+ ])
38
+
39
+ // Split into words
40
+ const words = text.split(/\s+/)
41
+
42
+ return words.map((word, index) => {
43
+ // Check if word is in exact preserve map first
44
+ if (exactPreserve.has(word) || exactPreserve.has(word.toLowerCase()) || exactPreserve.has(word.toUpperCase())) {
45
+ const key = exactPreserve.has(word) ? word : (exactPreserve.has(word.toLowerCase()) ? word.toLowerCase() : word.toUpperCase())
46
+ return exactPreserve.get(key)
47
+ }
48
+
49
+ // Check if word is in preserve list (case-insensitive check)
50
+ if (preserveWords.has(word.toUpperCase())) {
51
+ return word.toUpperCase()
52
+ }
53
+
54
+ // Check if word contains special characters like &
55
+ if (word === '&') {
56
+ return word
57
+ }
58
+
59
+ // First word: capitalize first letter, lowercase rest
60
+ if (index === 0) {
61
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
62
+ }
63
+
64
+ // Subsequent words: lowercase
65
+ return word.toLowerCase()
66
+ }).join(' ')
67
+ }
68
+
69
+ module.exports = toSentenceCase