@redpanda-data/docs-extensions-and-macros 5.46.0 → 5.47.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.
@@ -1,4 +1,8 @@
1
- const AUTOGEN_NOTICE = '// This content is autogenerated. Do not edit manually. To override descriptions, use the doc-tools CLI with the --overrides option: https://redpandadata.atlassian.net/wiki/spaces/DOC/pages/1396244485/Review+Redpanda+configuration+properties\n';
1
+ // Points at the overrides file and its schema, both of which live in the repo a
2
+ // writer already has open. The previous pointer was a Confluence page, and
3
+ // Confluence is retired, so it sent every writer who read this line to a dead
4
+ // end. A repo-relative path cannot rot the same way.
5
+ const AUTOGEN_NOTICE = '// This content is autogenerated. Do not edit manually. To change a description, add a link, or scope text to one docs build, edit docs-data/property-overrides.json (fields are documented in docs-data/property-overrides.schema.json and docs-data/README.adoc) and regenerate.\n';
2
6
  'use strict';
3
7
 
4
8
  const fs = require('fs');
@@ -49,6 +53,35 @@ function isObjectStorageProperty(prop) {
49
53
  );
50
54
  }
51
55
 
56
+ /**
57
+ * Which reference page a property is written to.
58
+ *
59
+ * The one place that decides, so a link's target page cannot disagree with the
60
+ * page the property actually lands on. generatePropertyPartials uses it to pick
61
+ * the partial to append to; applyPropertyLinks uses it to tell a same-page
62
+ * reference from a cross-page one.
63
+ *
64
+ * Returns null for a property with no scope, which is not rendered at all.
65
+ *
66
+ * @param {Object} prop - The property object.
67
+ * @returns {('cluster'|'topic'|'broker'|'object-storage'|null)}
68
+ */
69
+ function propertyPageFor(prop) {
70
+ if (!prop || !prop.config_scope) return null;
71
+ switch (prop.config_scope) {
72
+ case 'topic':
73
+ return 'topic';
74
+ case 'broker':
75
+ return 'broker';
76
+ case 'cluster':
77
+ return isObjectStorageProperty(prop) ? 'object-storage' : 'cluster';
78
+ case 'object-storage':
79
+ return 'object-storage';
80
+ default:
81
+ return null;
82
+ }
83
+ }
84
+
52
85
  /**
53
86
  * Gets template path, checking environment variables for custom paths first
54
87
  */
@@ -249,20 +282,16 @@ function generatePropertyPartials(properties, partialsDir, onRender) {
249
282
  // This fixes issues where key != name field due to bugs in the source code
250
283
  prop.name = key;
251
284
 
252
- switch (prop.config_scope) {
285
+ const page = propertyPageFor(prop);
286
+ switch (page) {
253
287
  case 'topic':
254
288
  annotateCorrespondingClusterSupport(prop, properties);
255
289
  propertyGroups.topic.push(prop);
256
290
  break;
257
291
  case 'broker':
258
- propertyGroups.broker.push(prop);
259
- break;
260
292
  case 'cluster':
261
- if (isObjectStorageProperty(prop)) propertyGroups['object-storage'].push(prop);
262
- else propertyGroups.cluster.push(prop);
263
- break;
264
293
  case 'object-storage':
265
- propertyGroups['object-storage'].push(prop);
294
+ propertyGroups[page].push(prop);
266
295
  break;
267
296
  default:
268
297
  console.warn(`Warning: Unknown config_scope: ${prop.config_scope} for ${prop.name}`);
@@ -410,6 +439,13 @@ function generateErrorReports(properties, documentedProperties = []) {
410
439
  * backticked property name is redundant; anything else becomes text=.
411
440
  */
412
441
  function convertConfigRefsToProp(text) {
442
+ // An array is the audience-scoped paragraph form of a description, and it
443
+ // reaches here BEFORE applyPropertyLinks flattens it. Without this the
444
+ // `typeof text !== 'string'` guard below returned the array untouched, so a
445
+ // config_ref inside a scoped paragraph survived into the published page as
446
+ // literal text -- contradicting this pass's own contract of emitting the
447
+ // prop macro regardless of what the input or the overrides carry.
448
+ if (Array.isArray(text)) return text.map(convertConfigRefsToProp);
413
449
  if (!text || typeof text !== 'string') return text;
414
450
  return text.replace(/config_ref:([^[,]+)(?:,([^[,]*))?(?:,([^[,]*))?\[([^\]]*)\]/g, (match, name, isLink, _path, payload) => {
415
451
  const trimmed = name.trim();
@@ -440,6 +476,193 @@ function generateAllDocs(inputFile, outputDir) {
440
476
  if (prop && prop.description) prop.description = convertConfigRefsToProp(prop.description);
441
477
  });
442
478
 
479
+ // The published attachment (modules/reference/attachments/redpanda-properties-
480
+ // <tag>.json) is a DIFFERENT consumer of this same file than the .adoc
481
+ // partials generated below, and it was never taught the array-form
482
+ // description this feature introduces. property_extractor.py writes
483
+ // inputFile with whatever shape the override declared -- an array, for an
484
+ // audience-scoped description -- and the Makefile then copies inputFile
485
+ // to that attachment VERBATIM, before this function ever runs. Two real
486
+ // readers of that attachment assume `description` is always a string:
487
+ // extensions/render-property-descriptions.js skips anything else with a
488
+ // bare `continue` (no warning), so the browser-side fallback in docs-ui's
489
+ // tooltip formatter receives the raw array and does `String([...])`,
490
+ // which comma-joins it -- publishing the literal token "cloud-only:" and
491
+ // the Cloud-only sentence to Self-Managed readers, live on the two
492
+ // properties in today's corpus that use this shape.
493
+ //
494
+ // Fixed by snapshotting a flattened, link-free rendering of every
495
+ // array-form description (and every includes-bearing one, for the
496
+ // parallel case below) and writing it back into inputFile once partial
497
+ // generation finishes, so the Makefile's copy picks up a plain string. No
498
+ // links: the attachment has never carried them (see the comment on
499
+ // `enhancedOutputPath` near the end of this function), and Antora's own
500
+ // AsciiDoc loader resolves the ifdef/ifndef pair per SITE when
501
+ // render-property-descriptions.js converts it -- cloud-docs sets
502
+ // env-cloud in its antora.yml, so the existing per-site machinery is what
503
+ // makes this correct rather than anything new here.
504
+ //
505
+ // Includes are folded in for the same reason `includes` regressed a
506
+ // second surface: on origin/main the include:: directive was written
507
+ // inline into the description string, so the tooltip's Antora-loader
508
+ // conversion resolved it into a real admonition (an internal-use warning,
509
+ // or the HTTP Proxy ephemeral-credentials breaking-change notice on
510
+ // scram_password, scram_username and sasl_mechanism). Moving it to its
511
+ // own `includes` field fixed the .adoc partial but orphaned the
512
+ // attachment, since render-property-descriptions.js never reads
513
+ // `includes`. Appending it back as an inline directive, exactly the shape
514
+ // it used to be, restores that without teaching the extension a new
515
+ // field.
516
+ const attachmentDescriptions = {};
517
+ for (const [attachName, attachProp] of Object.entries(properties)) {
518
+ if (!attachProp) continue;
519
+ const hasArrayDescription = Array.isArray(attachProp.description);
520
+ const hasIncludes = attachProp.includes !== undefined;
521
+ if (!hasArrayDescription && !hasIncludes) continue;
522
+
523
+ let text = helpers.applyPropertyLinks.flattenDescription(attachProp.description);
524
+ if (hasIncludes) {
525
+ // A scratch warnings array: the real applyPropertyLinks call below
526
+ // normalizes and warns on the same includes once more, and that is
527
+ // the run's single copy of each warning. Duplicating the call here
528
+ // only to read the normalized shape must not double them.
529
+ const items = helpers.applyPropertyLinks.normalizeIncludes(attachName, attachProp.includes, []);
530
+ for (const item of items) {
531
+ const directive = `include::${item.target}`;
532
+ // Idempotency guard: in the real Makefile flow inputFile is fresh
533
+ // from property_extractor.py every run, so `text` never already
534
+ // carries this. It CAN already carry it if generateAllDocs runs
535
+ // twice against the same file without Python re-writing it in
536
+ // between (a repeated call in a test, or a re-run of just this
537
+ // step), because the append below is what makes it a string in
538
+ // the first place, and `includes` itself is never cleared. Without
539
+ // this check a second run appended the directive a second time.
540
+ if (text.includes(directive)) continue;
541
+ if (item.cloud_only) {
542
+ text += `\n\nifdef::env-cloud[]\n${directive}\nendif::[]`;
543
+ } else if (item.self_managed_only) {
544
+ text += `\n\nifndef::env-cloud[]\n${directive}\nendif::[]`;
545
+ } else {
546
+ text += `\n\n${directive}`;
547
+ }
548
+ }
549
+ }
550
+ attachmentDescriptions[attachName] = text;
551
+ }
552
+
553
+ // Links and audience-scoped text come out of the overrides as data -- a `links`
554
+ // map and, for a description, an array of optionally scoped paragraphs -- and
555
+ // become AsciiDoc here. Running before the three renderers means the property
556
+ // partials, the deprecated partial and the topic mappings partial all see the
557
+ // same text.
558
+ //
559
+ // This is what lets a description stay plain prose. Markup mixed into the
560
+ // sentence is what the overrides audit cannot compare against the source
561
+ // string, so an enriched description could never be retired or upstreamed;
562
+ // keeping the enrichment in its own fields keeps the prose comparable.
563
+ // Audience scopes that will not take effect, checked BEFORE the link pass:
564
+ // applyPropertyLinks flattens an array description into one string, after
565
+ // which a per-paragraph prefix can no longer be attributed to its paragraph.
566
+ //
567
+ // This is a louder failure than an unmatched link. An unmatched link key is
568
+ // simply not applied, so the page reads correctly minus a link. An
569
+ // unrecognised audience prefix is PUBLISHED: `cloud_only: Cloud clusters
570
+ // require...` renders as that literal string, prefix and all, in both
571
+ // builds. A misspelled audience boolean is silent the other way round, with
572
+ // the value rendering in both builds instead of the one it was scoped to.
573
+ const scopeProblems = helpers.findAudienceScopeProblems(properties);
574
+ if (scopeProblems.length > 0) {
575
+ console.warn(
576
+ `Warning: ${scopeProblems.length} audience scope(s) will not take effect:`
577
+ );
578
+ for (const p of scopeProblems) {
579
+ console.warn(
580
+ p.problem === 'unrecognized-prefix'
581
+ ? ` ${p.property}: ${p.field} starts with "${p.detail}:", which is not a recognized audience prefix, so it will publish as literal text. Use "cloud-only:" or "self-managed-only:".`
582
+ : ` ${p.property}: ${p.field} carries "${p.detail}", which is not a recognized audience flag, so the value will render in both builds. Use cloud_only or self_managed_only.`
583
+ );
584
+ }
585
+ }
586
+
587
+ const linkResult = helpers.applyPropertyLinks(properties);
588
+ if (linkResult.applied > 0) {
589
+ console.log(`Applied ${linkResult.applied} declared link(s) from the overrides file.`);
590
+ }
591
+ for (const warning of linkResult.warnings) console.warn(`Warning: ${warning}`);
592
+ if (linkResult.unmatched.length > 0) {
593
+ // A link key that matches nothing is silent link rot: the override looks
594
+ // maintained and the page has no link. Name every one.
595
+ console.warn(
596
+ `Warning: ${linkResult.unmatched.length} declared link(s) matched no text and were not applied:`
597
+ );
598
+ for (const { property, key } of linkResult.unmatched) {
599
+ console.warn(` ${property}: links["${key}"]`);
600
+ }
601
+ }
602
+
603
+ // Count the declarative enrichment so the caller can report what this run
604
+ // did, rather than a writer having to read a diff of generated AsciiDoc to
605
+ // find out. Counted here, after the link pass, while the override-derived
606
+ // fields are still on the property objects.
607
+ const enrichment = {
608
+ declaredLinks: 0,
609
+ appliedLinks: linkResult.applied,
610
+ unmatchedLinks: linkResult.unmatched,
611
+ // Everything else the link pass rejected: an empty key, a missing
612
+ // target, a #target naming no property, a glossterm with no term.
613
+ // Console-only until now, so it never reached a reviewer.
614
+ linkWarnings: linkResult.warnings,
615
+ audienceScopeProblems: scopeProblems,
616
+ includes: 0,
617
+ audienceScopedParagraphs: 0
618
+ };
619
+ for (const prop of Object.values(properties)) {
620
+ if (!prop) continue;
621
+ if (prop.links) enrichment.declaredLinks += Object.keys(prop.links).length;
622
+ if (Array.isArray(prop.includes)) enrichment.includes += prop.includes.length;
623
+ // Counted from the emitted directives, not from the paragraph array:
624
+ // applyPropertyLinks has already flattened an array description into a
625
+ // string by this point, so the array is gone. Counting the output also
626
+ // catches a legacy description that carries a hand-written conditional.
627
+ if (typeof prop.description === 'string') {
628
+ const conditionals = prop.description.match(/^ifn?def::env-cloud\[\]$/gm);
629
+ if (conditionals) enrichment.audienceScopedParagraphs += conditionals.length;
630
+ }
631
+ }
632
+
633
+ // Write the flattened attachment text back into inputFile, so the
634
+ // Makefile's later `cp` of this same file to
635
+ // modules/reference/attachments/ carries a plain string rather than the
636
+ // array/includes shape that broke the tooltip. Best-effort and re-reads
637
+ // inputFile fresh rather than serializing the already-mutated
638
+ // `properties` object, so only the specific properties snapshotted above
639
+ // change on disk -- every other property, and every field on these two,
640
+ // is byte-for-byte what property_extractor.py wrote.
641
+ if (Object.keys(attachmentDescriptions).length > 0) {
642
+ try {
643
+ const onDisk = bigIntJson.parse(fs.readFileSync(inputFile, 'utf8'));
644
+ const onDiskProperties = onDisk.properties || onDisk.topic_properties || {};
645
+ let flattenedCount = 0;
646
+ for (const [name, text] of Object.entries(attachmentDescriptions)) {
647
+ if (onDiskProperties[name]) {
648
+ onDiskProperties[name].description = text;
649
+ flattenedCount += 1;
650
+ }
651
+ }
652
+ if (flattenedCount > 0) {
653
+ // indent 4, matching property_extractor.py's json.dumps(indent=4), so
654
+ // a real regeneration's diff is the description fields that changed,
655
+ // not a whole-file reindent.
656
+ fs.writeFileSync(inputFile, bigIntJson.stringify(onDisk, 4) + '\n');
657
+ console.log(
658
+ `Flattened ${flattenedCount} audience-scoped/include-bearing description(s) back into ${inputFile} for the published attachment.`
659
+ );
660
+ }
661
+ } catch (err) {
662
+ console.warn(`Warning: could not write flattened descriptions back into ${inputFile}: ${err.message}`);
663
+ }
664
+ }
665
+
443
666
  registerPartials();
444
667
 
445
668
  let partialsCount = 0;
@@ -479,13 +702,40 @@ function generateAllDocs(inputFile, outputDir) {
479
702
  console.log('Ignored:\n ' + errors.undocumented_properties.join('\n '));
480
703
  }
481
704
 
482
- return {
705
+ const summary = {
483
706
  totalProperties,
484
707
  generatedPartials: partialsCount,
485
708
  undocumentedProperties: errors.undocumented_properties,
486
709
  deprecatedProperties: deprecatedCount,
487
- percentageRendered: pctRendered
710
+ percentageRendered: pctRendered,
711
+ enrichment
488
712
  };
713
+
714
+ // Opt-in machine-readable summary. The re-render workflows set this to a
715
+ // path outside the checkout and turn the file into the PR body, so a writer
716
+ // sees what the run did without reading a diff of generated AsciiDoc.
717
+ //
718
+ // Opt-in, and never defaulted to outputDir: that directory is inside the
719
+ // docs checkout, and the re-render workflow commits every change in the
720
+ // tree, so a default would commit a generated artefact into the repo on
721
+ // every run. Best-effort, because a summary that cannot be written must not
722
+ // fail a generation that otherwise succeeded.
723
+ const summaryPath = process.env.PROPERTY_DOCS_SUMMARY_PATH;
724
+ if (summaryPath) {
725
+ try {
726
+ // bigIntJson.stringify, not JSON.stringify: this file parses property
727
+ // data with bigIntJson.parse, so a uint64 limit arrives as a real
728
+ // BigInt. Plain JSON.stringify throws on one, and the throw lands in
729
+ // the catch below -- a warning and no summary file, which is exactly
730
+ // the silent degradation this summary exists to remove.
731
+ fs.writeFileSync(summaryPath, bigIntJson.stringify(summary, 2) + '\n');
732
+ console.log(`Wrote run summary to ${summaryPath}`);
733
+ } catch (err) {
734
+ console.warn(`Warning: could not write ${summaryPath}: ${err.message}`);
735
+ }
736
+ }
737
+
738
+ return summary;
489
739
  }
490
740
 
491
741
 
@@ -493,7 +743,8 @@ module.exports = {
493
743
  generateAllDocs,
494
744
  generateDeprecatedDocs,
495
745
  generatePropertyPartials,
496
- convertConfigRefsToProp
746
+ convertConfigRefsToProp,
747
+ propertyPageFor
497
748
  };
498
749
 
499
750
  // CLI