@redpanda-data/docs-extensions-and-macros 5.13.0 → 5.13.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.
package/bin/doc-tools.js CHANGED
@@ -772,9 +772,11 @@ automation
772
772
  .option('--template-fields <path>', 'Fields section partial template', path.resolve(__dirname, '../tools/redpanda-connect/templates/fields-partials.hbs'))
773
773
  .option('--template-examples <path>', 'Examples section partial template', path.resolve(__dirname, '../tools/redpanda-connect/templates/examples-partials.hbs'))
774
774
  .option('--template-metadata <path>', 'Metadata section partial template', path.resolve(__dirname, '../tools/redpanda-connect/templates/metadata-partials.hbs'))
775
+ .option('--template-description <path>', 'Description section partial template', path.resolve(__dirname, '../tools/redpanda-connect/templates/descriptions-partials.hbs'))
775
776
  .option('--template-bloblang <path>', 'Custom Handlebars template for bloblang function/method partials')
776
777
  .option('--overrides <path>', 'Optional JSON file with overrides', 'docs-data/overrides.json')
777
778
  .option('--include-bloblang', 'Include Bloblang functions and methods in generation')
779
+ .option('--prune-orphaned-descriptions', 'Allow the description-partial orphan sweep to blank more than 10% of the tree. Only use this when the connector dataset is confirmed complete: an incomplete dataset looks identical to a mass upstream deletion and the sweep blanks published content')
778
780
  .option('--cloud-version <version>', 'Cloud binary version (default: auto-detect latest)')
779
781
  .option('--cgo-version <version>', 'cgo binary version (default: same as cloud-version)')
780
782
  .option('--skip-intermediate', 'Skip intermediate release processing (legacy mode - only compare latest vs last documented)')
@@ -794,6 +796,43 @@ automation
794
796
  await handleRpcnConnectorDocs(options)
795
797
  })
796
798
 
799
+ /**
800
+ * generate migrate-rpcn-descriptions
801
+ *
802
+ * @description
803
+ * One-time migration that rewires published connector reference pages onto the
804
+ * regenerated description partial: the header `:description:` becomes
805
+ * `include::...[tag=attrs]` and the frozen intro prose becomes
806
+ * `include::...[tag=body]`. Afterwards `generate rpcn-connector-docs` keeps
807
+ * both fresh on every run.
808
+ *
809
+ * @why
810
+ * Connector pages are one-time first drafts, so their summary, "Introduced in
811
+ * version" line and description prose never change again even when the
812
+ * connector does. The body rewire is guarded: a page whose intro carries
813
+ * anything the partial does not reproduce is reported and left for a human,
814
+ * because rewriting it would delete published content.
815
+ *
816
+ * @example
817
+ * # Dry run (default): report the pages that would change
818
+ * npx doc-tools generate migrate-rpcn-descriptions
819
+ *
820
+ * # Apply the migration
821
+ * npx doc-tools generate migrate-rpcn-descriptions --write
822
+ *
823
+ * # Rewire page headers only, leaving the body prose alone
824
+ * npx doc-tools generate migrate-rpcn-descriptions --write --skip-body
825
+ */
826
+ automation
827
+ .command('migrate-rpcn-descriptions')
828
+ .description('One-time migration of frozen connector summaries and description prose onto the regenerated description partial. Dry run unless --write is given.')
829
+ .option('--write', 'Apply changes (default is a dry run that only reports)')
830
+ .option('--skip-body', 'Rewire page headers only, leaving the frozen intro prose in the page')
831
+ .action((options) => {
832
+ const { migrateDescriptionsToPartials } = require('../tools/redpanda-connect/migrate-descriptions-to-partials.js')
833
+ migrateDescriptionsToPartials({ write: Boolean(options.write), skipBody: Boolean(options.skipBody) })
834
+ })
835
+
797
836
  /**
798
837
  * generate migrate-rpcn-metadata
799
838
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.13.0",
3
+ "version": "5.13.1",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -126,9 +126,19 @@ function parseJSDocComments(sourceFile) {
126
126
  const content = fs.readFileSync(sourceFile, 'utf8');
127
127
  const comments = {};
128
128
 
129
- // Regex to match JSDoc comments followed by command definitions
130
- // Matches both top-level commands and automation.command()
131
- const pattern = /\/\*\*\s*([\s\S]*?)\s*\*\/\s*(?:programCli|automation|validation)\s*\.command\(['"]([^'"]+)['"]\)/g;
129
+ // Regex to match JSDoc comments followed by command definitions.
130
+ // Matches both top-level commands and automation.command().
131
+ //
132
+ // The comment body is `(?:(?!\*\/)[\s\S])*?`, a tempered match that cannot
133
+ // cross a closing `*\/`, rather than a plain `[\s\S]*?`. With the plain
134
+ // version two ADJACENT JSDoc blocks merged into one match: the lazy body ran
135
+ // past the first block's terminator, because that terminator is followed by
136
+ // `/**` rather than by `.command(`, and kept going to the second. The
137
+ // extractor then took the FIRST @description it found, so the second command
138
+ // was documented with the first command's prose. That is exactly what
139
+ // happened to `generate migrate-rpcn-descriptions`, which shipped a
140
+ // regenerated section describing migrate-rpcn-metadata.
141
+ const pattern = /\/\*\*\s*((?:(?!\*\/)[\s\S])*?)\s*\*\/\s*(?:programCli|automation|validation)\s*\.command\(['"]([^'"]+)['"]\)/g;
132
142
 
133
143
  let match;
134
144
  while ((match = pattern.exec(content)) !== null) {
@@ -306,7 +316,8 @@ IMPORTANT: This documentation is auto-generated. Do not edit manually. Run \`npm
306
316
  'crd-spec',
307
317
  'bundle-openapi',
308
318
  'update-connect-version',
309
- 'migrate-rpcn-metadata'
319
+ 'migrate-rpcn-metadata',
320
+ 'migrate-rpcn-descriptions'
310
321
  ];
311
322
 
312
323
  generateSubcommands.forEach(subcmd => {
@@ -5,7 +5,9 @@ const path = require('path');
5
5
  const handlebars = require('handlebars');
6
6
  const yaml = require('yaml');
7
7
  const helpers = require('./helpers');
8
- const { lostMetadataSections, resolvePageTypeDir } = require('./metadata-utils.js');
8
+ const {
9
+ lostMetadataSections, typeDirFor, flattenToAttributeValue, CONNECTOR_TYPE_DIRS, resolvePageTypeDir,
10
+ } = require('./metadata-utils.js');
9
11
 
10
12
  // Register each helper under handlebars, verifying that it’s a function
11
13
  Object.entries(helpers).forEach(([name, fn]) => {
@@ -22,6 +24,12 @@ const DEFAULT_TEMPLATE = path.resolve(__dirname, './templates/connector.hbs');
22
24
  // Default template for the regenerated per-connector metadata partial.
23
25
  const DEFAULT_METADATA_TEMPLATE = path.resolve(__dirname, './templates/metadata-partials.hbs');
24
26
 
27
+ // Default template for the regenerated per-connector description partial. Like
28
+ // the metadata partial, this is rewritten on every run so summary/description
29
+ // changes upstream flow into already-published pages that include it.
30
+ const DEFAULT_DESCRIPTION_TEMPLATE = path.resolve(__dirname, './templates/descriptions-partials.hbs');
31
+ const { hasStructuralHeadings, hasMarkdownHeadings, firstHeadingDepth, escapePlaceholderBraces, LONG_HEADINGLESS_THRESHOLD } = require('./helpers/renderConnectDescription.js');
32
+
25
33
  // Banner-only content written to a metadata partial when its `== Metadata`
26
34
  // section is removed upstream. Kept as an AsciiDoc comment so the file renders
27
35
  // nothing while any lingering include directive still resolves cleanly.
@@ -30,6 +38,44 @@ const STALE_METADATA_PARTIAL =
30
38
  'The connector description no longer contains a `== Metadata` section, so this partial is intentionally empty. ' +
31
39
  'You can remove the include directive that points at this file from the connector page.\n';
32
40
 
41
+ // Banner-only content written to a description partial when the connector's
42
+ // description is removed upstream. Same rationale as STALE_METADATA_PARTIAL:
43
+ // deleting the file would break any published page that includes it, so blank
44
+ // it instead and log so a human removes the include line.
45
+ //
46
+ // The empty attrs/body regions are load-bearing. Pages include this file with
47
+ // `[tag=attrs]` and `[tag=body]`, and Asciidoctor logs `tag 'attrs' not found
48
+ // in include file` for every tag a target does not define -- so a banner
49
+ // without the regions turns one blanked partial into two build warnings on
50
+ // every page that includes it, on every build, which is a build failure
51
+ // wherever Asciidoctor warnings are treated as errors. An empty region
52
+ // resolves to nothing and says nothing.
53
+ const STALE_DESCRIPTION_PARTIAL =
54
+ '// This content is autogenerated. Do not edit manually. ' +
55
+ 'The connector no longer has a description, so this partial is intentionally empty. ' +
56
+ 'You can remove the include directive that points at this file from the connector page.\n' +
57
+ '\n// tag::attrs[]\n// end::attrs[]\n' +
58
+ '\n// tag::body[]\n// end::body[]\n';
59
+
60
+ // Component families with their own reference pages, keyed by the canonical
61
+ // type directory from typeDirFor(). Other data keys -- `config`, the bloblang
62
+ // functions and methods -- have no per-component page that could include a
63
+ // partial, so none is emitted for them.
64
+ const CONNECTOR_PAGE_TYPE_DIRS = new Set(CONNECTOR_TYPE_DIRS);
65
+
66
+ // Blast-radius guard for the post-generation orphan sweep. The sweep blanks
67
+ // every description partial the current dataset did not claim, so an
68
+ // incomplete dataset (a truncated fetch, a half-succeeded --force-fresh, a
69
+ // hand-made subset file) does not look different from "hundreds of connectors
70
+ // were deleted upstream" -- and the partials are published content. Blanking
71
+ // up to ORPHAN_SWEEP_ALWAYS_ALLOWED files is always fine (that is the size of
72
+ // a plausible upstream deletion); above that the sweep also has to stay under
73
+ // ORPHAN_SWEEP_MAX_FRACTION of the tree, or it refuses to run and reports
74
+ // instead. `pruneOrphanedDescriptions` is the explicit opt-in that overrides
75
+ // the refusal once a human has confirmed the dataset is complete.
76
+ const ORPHAN_SWEEP_ALWAYS_ALLOWED = 10;
77
+ const ORPHAN_SWEEP_MAX_FRACTION = 0.1;
78
+
33
79
  /**
34
80
  * Reads a file at `filePath` and registers it as a Handlebars partial called `name`.
35
81
  * Throws if the file cannot be read.
@@ -216,6 +262,7 @@ function resolveReferences(obj, root) {
216
262
  * @param {string} [options.templateFields] - Path to the fields partial template.
217
263
  * @param {string} [options.templateExamples] - Path to the examples partial template.
218
264
  * @param {boolean} options.writeFullDrafts - If true, generates full draft documentation; otherwise, generates partials.
265
+ * @param {boolean} [options.pruneOrphanedDescriptions=false] - Allow the post-generation sweep to blank description partials absent from the dataset even when they exceed the safety threshold. Only pass this for a dataset confirmed complete.
219
266
  * @returns {Promise<Object>} An object summarizing the number and paths of generated partials and drafts.
220
267
  *
221
268
  * @throws {Error} If reading or parsing input files fails, if template rendering fails for a component, or if $ref references cannot be resolved.
@@ -257,8 +304,12 @@ async function generateRpcnConnectorDocs(options) {
257
304
  templateFields,
258
305
  templateExamples,
259
306
  templateMetadata,
307
+ templateDescription,
260
308
  templateBloblang,
261
309
  writeFullDrafts,
310
+ // Explicit opt-in for the destructive description-partial orphan sweep.
311
+ // Off by default: see ORPHAN_SWEEP_MAX_FRACTION.
312
+ pruneOrphanedDescriptions = false,
262
313
  cgoOnly = [], // Array of cgo-only connectors from cgo binary inspection
263
314
  cloudOnly = [], // Array of cloud-only connectors from cloud binary inspection
264
315
  csvMetadata = [] // Array of CSV metadata with support levels
@@ -358,6 +409,12 @@ async function generateRpcnConnectorDocs(options) {
358
409
  const metadataTemplatePath = templateMetadata || DEFAULT_METADATA_TEMPLATE;
359
410
  registerPartial('metadata', metadataTemplatePath);
360
411
 
412
+ // Always register the description partial. Like metadata, it is regenerated
413
+ // on every run so summary/description edits upstream flow into any page that
414
+ // includes it, instead of being frozen into the page body at first draft.
415
+ const descriptionTemplatePath = templateDescription || DEFAULT_DESCRIPTION_TEMPLATE;
416
+ registerPartial('description', descriptionTemplatePath);
417
+
361
418
  // In draft mode, also register the intro partial
362
419
  if (writeFullDrafts && templateIntro) {
363
420
  registerPartial('intro', templateIntro);
@@ -367,6 +424,7 @@ async function generateRpcnConnectorDocs(options) {
367
424
  const fieldsOutRoot = path.join(outputRoot, 'fields');
368
425
  const examplesOutRoot = path.join(outputRoot, 'examples');
369
426
  const metadataOutRoot = path.join(outputRoot, 'metadata');
427
+ const descriptionOutRoot = path.join(outputRoot, 'descriptions');
370
428
  // Drafts go to pages/, not partials/
371
429
  const componentsRoot = writeFullDrafts
372
430
  ? path.resolve(process.cwd(), 'modules/components/pages')
@@ -377,10 +435,22 @@ async function generateRpcnConnectorDocs(options) {
377
435
  fs.mkdirSync(fieldsOutRoot, { recursive: true });
378
436
  fs.mkdirSync(examplesOutRoot, { recursive: true });
379
437
  fs.mkdirSync(metadataOutRoot, { recursive: true });
438
+ fs.mkdirSync(descriptionOutRoot, { recursive: true });
380
439
  }
381
440
 
441
+ // Compile the one-line partial wrappers once, not once per component: they
442
+ // are constants, and the loop runs for every entry in every data key.
443
+ const renderFieldsPartial = handlebars.compile('{{> fields children=config.children}}');
444
+ const renderExamplesPartial = handlebars.compile('{{> examples examples=examples}}');
445
+ const renderMetadataPartial = handlebars.compile('{{> metadata description=description type=type typeDir=typeDir name=name}}');
446
+ const renderDescriptionPartial = handlebars.compile('{{> description summary=summary version=version description=description type=type typeDir=typeDir name=name}}');
447
+
382
448
  let partialsWritten = 0;
449
+ const descriptionReports = [];
383
450
  const lostSectionWarnings = [];
451
+ // typeDir/name keys handled this run; used after the loop to blank
452
+ // description partials orphaned by a connector deleted upstream.
453
+ const visitedDescriptionPartials = new Set();
384
454
  let draftsWritten = 0;
385
455
  const partialFiles = [];
386
456
  const draftFiles = [];
@@ -395,17 +465,23 @@ async function generateRpcnConnectorDocs(options) {
395
465
  // Compute typeDir once for this item (used in templates and file paths)
396
466
  const typeDir = type.endsWith('s') ? type : `${type}s`;
397
467
  item.typeDir = typeDir;
468
+ // Canonical directory for the partials that pages address by include
469
+ // line. `typeDir` keeps the raw data-key spelling because the fields
470
+ // and examples partials are written under it and published pages
471
+ // already include them by that path; the metadata and description
472
+ // partials are addressed through typeDirFor(), so writing them under
473
+ // anything else leaves the include unresolved. Rate limits are the
474
+ // live case: data key `rate-limits`, include path `rate_limits`.
475
+ const partialTypeDir = typeDirFor(item);
398
476
 
399
477
  // Always generate field and example partials (needed for both standalone and draft modes)
400
478
  // Render fields using the registered "fields" partial
401
- const fieldsOut = handlebars
402
- .compile('{{> fields children=config.children}}')(item);
479
+ const fieldsOut = renderFieldsPartial(item);
403
480
 
404
481
  // Render examples only if an examples template was provided
405
482
  let examplesOut = '';
406
483
  if (examplesTemplatePath) {
407
- examplesOut = handlebars
408
- .compile('{{> examples examples=examples}}')(item);
484
+ examplesOut = renderExamplesPartial(item);
409
485
  }
410
486
 
411
487
  if (fieldsOut.trim()) {
@@ -418,7 +494,11 @@ async function generateRpcnConnectorDocs(options) {
418
494
  }
419
495
  }
420
496
 
421
- if (examplesOut.trim() && type !== 'bloblang-functions' && type !== 'bloblang-methods') {
497
+ // Same allowlist as the metadata and description partials below: one
498
+ // gate, one list. The old denylist named only the two bloblang keys, so
499
+ // any other non-page data key (config today, anything added upstream
500
+ // tomorrow) leaked partials no page can include.
501
+ if (examplesOut.trim() && CONNECTOR_PAGE_TYPE_DIRS.has(partialTypeDir)) {
422
502
  const ePath = path.join(examplesOutRoot, type, `${name}.adoc`);
423
503
  fs.mkdirSync(path.dirname(ePath), { recursive: true });
424
504
  fs.writeFileSync(ePath, examplesOut);
@@ -428,15 +508,17 @@ async function generateRpcnConnectorDocs(options) {
428
508
  }
429
509
  }
430
510
 
431
- // Render the metadata partial from the connector's description. Only
432
- // written when the description contains a `== Metadata` section.
433
- const metadataOut = handlebars
434
- .compile('{{> metadata description=description type=type typeDir=typeDir name=name}}')(item);
435
-
436
- if (type !== 'bloblang-functions' && type !== 'bloblang-methods') {
437
- // Write under typeDir (not the raw data key) so the file path always
438
- // matches the include directive built by metadataIncludeLine().
439
- const mPath = path.join(metadataOutRoot, typeDir, `${name}.adoc`);
511
+ if (CONNECTOR_PAGE_TYPE_DIRS.has(partialTypeDir)) {
512
+ // Render the metadata partial from the connector's description. Only
513
+ // written when the description contains a `== Metadata` section.
514
+ // Rendered inside the guard, not before it: the loop covers every
515
+ // data key, so rendering first meant computing and discarding a
516
+ // partial for every bloblang function, method and config entry.
517
+ const metadataOut = renderMetadataPartial(item);
518
+ // Write under the canonical type directory (not the raw data key) so
519
+ // the file path always matches the include directive built by
520
+ // metadataIncludeLine().
521
+ const mPath = path.join(metadataOutRoot, partialTypeDir, `${name}.adoc`);
440
522
  if (metadataOut.trim()) {
441
523
  fs.mkdirSync(path.dirname(mPath), { recursive: true });
442
524
  if (fs.existsSync(mPath)) {
@@ -478,6 +560,106 @@ async function generateRpcnConnectorDocs(options) {
478
560
  }
479
561
  }
480
562
 
563
+ // Render the description partial (summary + version + description, with
564
+ // the `== Metadata` block replaced by its own partial include). Emitted
565
+ // for every connector type that has reference pages so a page that
566
+ // includes it always shows the current summary/description instead of
567
+ // first-draft text. The summary is brace-escaped here, the same way
568
+ // renderConnectDescription escapes the description body: the template
569
+ // emits it raw in both the body and the :description: attribute, where
570
+ // an unescaped {placeholder} is silently consumed as a missing
571
+ // attribute reference.
572
+ if (CONNECTOR_PAGE_TYPE_DIRS.has(partialTypeDir)) {
573
+ const descriptionItem = typeof item.summary === 'string'
574
+ ? { ...item, summary: escapePlaceholderBraces(item.summary) }
575
+ : item;
576
+ const descriptionOut = renderDescriptionPartial(descriptionItem);
577
+ visitedDescriptionPartials.add(`${partialTypeDir}/${name}`);
578
+ const dPath = path.join(descriptionOutRoot, partialTypeDir, `${name}.adoc`);
579
+ if (descriptionOut.trim()) {
580
+ fs.mkdirSync(path.dirname(dPath), { recursive: true });
581
+ if (fs.existsSync(dPath)) {
582
+ // Same content-loss check the metadata partial ten lines up has
583
+ // had since #236, for the same reason: regeneration is
584
+ // authoritative, but a section disappearing from a published
585
+ // partial is content loss, not cleanup. Now that pages include
586
+ // these files the risk is real -- a section can only vanish
587
+ // because it left the upstream description or because a human
588
+ // added it here -- so warn before overwriting instead of
589
+ // dropping it without a trace.
590
+ const lost = lostMetadataSections(fs.readFileSync(dPath, 'utf8'), descriptionOut);
591
+ if (lost.length) {
592
+ lostSectionWarnings.push({ partial: path.relative(process.cwd(), dPath), sections: lost });
593
+ console.warn(
594
+ `Warning: regenerated description partial ${path.relative(process.cwd(), dPath)} ` +
595
+ `drops previously published section(s): ${lost.map((h) => `"${h}"`).join(', ')}. ` +
596
+ 'If this content is still wanted, move it to the connector page or restore it ' +
597
+ 'in the upstream description.'
598
+ );
599
+ }
600
+ }
601
+ fs.writeFileSync(dPath, descriptionOut);
602
+ // The partial now exists for this connector, so a drafted page can
603
+ // include its body instead of freezing the summary and description
604
+ // into the page. Gated on the file actually being written:
605
+ // component families outside CONNECTOR_PAGE_TYPE_DIRS, and
606
+ // connectors with neither summary nor description, keep the inline
607
+ // text rather than getting an include that cannot resolve.
608
+ item.hasDescriptionPartial = true;
609
+ // The attrs region only sets :description: when the connector has a
610
+ // summary, so a page may only replace its header attribute with the
611
+ // include when there is one. Otherwise the page would lose its meta
612
+ // description entirely and keep the inline attribute instead.
613
+ if (typeof item.summary === 'string' && item.summary.trim()) {
614
+ item.hasDescriptionAttrs = true;
615
+ }
616
+ if (!writeFullDrafts) {
617
+ partialsWritten++;
618
+ partialFiles.push(path.relative(process.cwd(), dPath));
619
+ }
620
+ // Long heading-less descriptions render as a wall of text. The fix
621
+ // belongs upstream (add == sections in the Connect source), so
622
+ // surface them here instead of hiding them in the docs build.
623
+ if (
624
+ typeof item.description === 'string' &&
625
+ item.description.length > LONG_HEADINGLESS_THRESHOLD &&
626
+ !hasStructuralHeadings(item.description)
627
+ ) {
628
+ const md = hasMarkdownHeadings(item.description);
629
+ const msg = md
630
+ ? `Markdown-style headings: ${partialTypeDir}/${name} structures its description with ## headings, ` +
631
+ 'which render unreliably. Convert them to == sections upstream in the Connect source.'
632
+ : `Long heading-less description: ${partialTypeDir}/${name} ` +
633
+ `(${item.description.length} chars). Consider adding == sections upstream in the Connect source.`;
634
+ descriptionReports.push({ connector: `${partialTypeDir}/${name}`, message: msg });
635
+ console.warn(msg);
636
+ }
637
+ // Headings that start deeper than level one render out of
638
+ // sequence on the page. Upstream fix, so report it.
639
+ const headingDepth = typeof item.description === 'string' ? firstHeadingDepth(item.description) : null;
640
+ if (headingDepth !== null && headingDepth > 2) {
641
+ const seqMsg =
642
+ `Out-of-sequence headings: ${partialTypeDir}/${name} starts at level ${headingDepth - 1} ` +
643
+ '(=== or ### with no parent section). Promote the top headings upstream in the Connect source.';
644
+ descriptionReports.push({ connector: `${partialTypeDir}/${name}`, message: seqMsg });
645
+ console.warn(seqMsg);
646
+ }
647
+ } else if (fs.existsSync(dPath)) {
648
+ // The upstream description disappeared, but a previously generated
649
+ // partial is still on disk and an already published page may include
650
+ // it. Deleting the file would break that include, so blank it to a
651
+ // banner-only file (the include still resolves and renders nothing)
652
+ // and log it so a human knows to remove the include line. Without
653
+ // this, the stale first-draft text would live on disk forever --
654
+ // exactly the drift these partials exist to prevent.
655
+ fs.writeFileSync(dPath, STALE_DESCRIPTION_PARTIAL);
656
+ console.log(
657
+ `Description removed upstream; emptied stale partial ${path.relative(process.cwd(), dPath)} ` +
658
+ `(remove its include directive from the ${type}/${name} page)`
659
+ );
660
+ }
661
+ }
662
+
481
663
  if (writeFullDrafts) {
482
664
  if (String(item.status || '').toLowerCase() === 'deprecated') {
483
665
  console.log(`Skipping draft for deprecated component: ${type}/${name}`);
@@ -677,6 +859,71 @@ async function generateRpcnConnectorDocs(options) {
677
859
  }
678
860
  }
679
861
 
862
+ // A connector deleted upstream leaves no dataset entry at all, so the
863
+ // in-loop stale-partial blanking above never sees it and its description
864
+ // partial would keep serving deleted-connector text forever, silently.
865
+ // Sweep the description output root for partials no connector claimed this
866
+ // run and blank them the same way. Skipped in draft mode: drafts run on a
867
+ // filtered dataset (missing connectors only), so "not visited" there means
868
+ // "already documented", not "deleted upstream".
869
+ // The sweep is destructive against published content, so collect the
870
+ // candidates first and check the blast radius before writing anything.
871
+ let orphanSweepSkipped = null;
872
+ if (!writeFullDrafts && fs.existsSync(descriptionOutRoot)) {
873
+ const orphans = [];
874
+ let partialsOnDisk = 0;
875
+ for (const typeDirName of fs.readdirSync(descriptionOutRoot)) {
876
+ const typeDirPath = path.join(descriptionOutRoot, typeDirName);
877
+ if (!fs.statSync(typeDirPath).isDirectory()) continue;
878
+ for (const file of fs.readdirSync(typeDirPath)) {
879
+ if (!file.endsWith('.adoc')) continue;
880
+ partialsOnDisk++;
881
+ const key = `${typeDirName}/${file.slice(0, -'.adoc'.length)}`;
882
+ if (visitedDescriptionPartials.has(key)) continue;
883
+ const orphanPath = path.join(typeDirPath, file);
884
+ // Already blanked on an earlier run: leave it alone so it is not
885
+ // re-reported forever.
886
+ if (fs.readFileSync(orphanPath, 'utf8') === STALE_DESCRIPTION_PARTIAL) continue;
887
+ orphans.push({ key, orphanPath });
888
+ }
889
+ }
890
+
891
+ // Over the threshold the far likelier explanation is an incomplete
892
+ // dataset, not a mass upstream deletion, so refuse and report.
893
+ const overBlastRadius =
894
+ orphans.length > ORPHAN_SWEEP_ALWAYS_ALLOWED &&
895
+ orphans.length > partialsOnDisk * ORPHAN_SWEEP_MAX_FRACTION;
896
+
897
+ if (orphans.length && overBlastRadius && !pruneOrphanedDescriptions) {
898
+ const percent = Math.round((orphans.length / partialsOnDisk) * 100);
899
+ const msg =
900
+ `Orphan sweep refused: ${orphans.length} of ${partialsOnDisk} description partial(s) (${percent}%) ` +
901
+ `are absent from this run's dataset, over the ${Math.round(ORPHAN_SWEEP_MAX_FRACTION * 100)}% safety ` +
902
+ 'threshold. That normally means the dataset is truncated or filtered, not that this many connectors ' +
903
+ 'were deleted upstream, and blanking them would wipe published content. Nothing was blanked. ' +
904
+ 'Confirm the dataset is complete, then re-run with --prune-orphaned-descriptions to sweep. ' +
905
+ `Affected: ${orphans.slice(0, 10).map((o) => o.key).join(', ')}` +
906
+ (orphans.length > 10 ? `, and ${orphans.length - 10} more` : '');
907
+ orphanSweepSkipped = {
908
+ orphans: orphans.length,
909
+ partialsOnDisk,
910
+ connectors: orphans.map((o) => o.key),
911
+ };
912
+ descriptionReports.push({ connector: 'descriptions (sweep)', message: msg });
913
+ console.error(msg);
914
+ } else {
915
+ for (const { key, orphanPath } of orphans) {
916
+ fs.writeFileSync(orphanPath, STALE_DESCRIPTION_PARTIAL);
917
+ const msg =
918
+ `Connector removed upstream: emptied orphaned description partial ` +
919
+ `${path.relative(process.cwd(), orphanPath)} ` +
920
+ `(remove its include directive and retire the ${key} page)`;
921
+ descriptionReports.push({ connector: key, message: msg });
922
+ console.log(msg);
923
+ }
924
+ }
925
+ }
926
+
680
927
  // Self-heal published page headers: pages are one-time drafts, so pages
681
928
  // created before the template emitted :description: never got one.
682
929
  let descriptionBackfill = { backfilled: [], skippedNoSummary: [] };
@@ -689,12 +936,13 @@ async function generateRpcnConnectorDocs(options) {
689
936
  draftsWritten,
690
937
  partialFiles,
691
938
  draftFiles,
939
+ descriptionReports,
692
940
  lostSectionWarnings,
941
+ orphanSweepSkipped,
693
942
  descriptionBackfill
694
943
  };
695
944
  }
696
945
 
697
-
698
946
  // Connector page families that have per-component pages under
699
947
  // modules/components/pages/. Other data keys (config, bloblang-functions,
700
948
  // bloblang-methods) have no per-component page to backfill.
@@ -740,25 +988,19 @@ function backfillPageDescriptions (connectorData, { pagesRoot, dryRun = false }
740
988
  const lines = content.split('\n');
741
989
  const headerEnd = lines.findIndex((l) => l.trim() === '');
742
990
  const header = lines.slice(0, headerEnd === -1 ? lines.length : headerEnd);
743
- if (header.some((l) => l.startsWith(':description:'))) continue;
991
+ // A page migrated to the description partial carries the attrs include
992
+ // instead of a literal :description:. Without this arm the backfill
993
+ // splices a competing meta block into every migrated page, so the page
994
+ // ends up with a dead include plus a hand-inserted attribute that
995
+ // silently wins.
996
+ if (header.some((l) => l.startsWith(':description:') || /partial\$descriptions\//.test(l))) continue;
744
997
 
745
998
  // Meta descriptions are read as plain text by search results, link
746
- // previews, and assistants, so flatten AsciiDoc markup mechanically:
747
- // xrefs and links become their labels, inline code loses its
748
- // backticks. No prose is edited.
749
- const summary = (item.summary || '')
750
- .replace(/(?:xref|link):[^\[\]]+\[([^\]]*)\]/g, '$1')
751
- // Bare URL macros (no xref:/link: prefix) and internal xref shorthand
752
- // also appear in source summaries and render literally if left in place.
753
- .replace(/https?:\/\/[^\s\[\]]+\[([^\]]*)\]/g, '$1')
754
- .replace(/<<[^,>]+,([^>]+)>>/g, '$1')
755
- .replace(/<<([^>]+)>>/g, '$1')
756
- // Strip the "open in new tab" caret that AsciiDoc URL macros carry
757
- // inside the link text, e.g. [Open Telemetry collector^].
758
- .replace(/\^/g, '')
759
- .replace(/`([^`]*)`/g, '$1')
760
- .replace(/\s+/g, ' ')
761
- .trim();
999
+ // previews, and assistants, so the markup comes out mechanically. Same
1000
+ // flattener the description partial's attrs region uses, so a page
1001
+ // cannot publish one meta description before migrating to the partial
1002
+ // and a different one after.
1003
+ const summary = flattenToAttributeValue(item.summary);
762
1004
  if (!summary) {
763
1005
  results.skippedNoSummary.push(`${typeDir}/${item.name}`);
764
1006
  continue;
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ const { descriptionIncludeLine } = require('../metadata-utils.js');
4
+
5
+ /**
6
+ * Handlebars helper: emit the include directive for a connector's description
7
+ * partial, optionally for one of its tag regions.
8
+ *
9
+ * {{{descriptionInclude this 'attrs'}}}
10
+ * {{{descriptionInclude this 'body'}}}
11
+ *
12
+ * Drafted pages use this instead of freezing the summary and description into
13
+ * the page, so an upstream wording change reaches the published page on the
14
+ * next generator run. Delegates to descriptionIncludeLine so the path a page
15
+ * carries is built by the same function that decides where the generator
16
+ * writes the file.
17
+ *
18
+ * @param {object} item connector data with `type`/`typeDir` and `name`
19
+ * @param {string} [tag] `attrs` or `body`
20
+ * @returns {string}
21
+ */
22
+ module.exports = function descriptionInclude (item, tag) {
23
+ return descriptionIncludeLine(item, typeof tag === 'string' ? tag : undefined);
24
+ };
@@ -12,7 +12,10 @@ module.exports = {
12
12
  renderConnectFields: require('./renderConnectFields.js'),
13
13
  renderConnectExamples: require('./renderConnectExamples.js'),
14
14
  renderConnectMetadata: require('./renderConnectMetadata.js'),
15
+ renderConnectDescription: require('./renderConnectDescription.js'),
16
+ summaryAttribute: require('./summaryAttribute.js'),
15
17
  descriptionWithMetadataInclude: require('./descriptionWithMetadataInclude.js'),
18
+ descriptionInclude: require('./descriptionInclude.js'),
16
19
  renderLeafField: require('./renderLeafField.js'),
17
20
  renderObjectField: require('./renderObjectField.js'),
18
21
  buildConfigYaml: require('./buildConfigYaml.js'),