@redpanda-data/docs-extensions-and-macros 5.13.0 → 5.13.2

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.
@@ -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'),
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ // One annotator for every scanner over connector prose, including the ones in
4
+ // metadata-utils that run before these. See annotateVerbatimLines there for
5
+ // why there is exactly one.
6
+ const { descriptionWithMetadataInclude, annotateVerbatimLines } = require('../metadata-utils.js');
7
+
8
+ // Length (characters) above which a heading-less description is reported as a
9
+ // candidate for upstream structure. Roughly a screen of prose.
10
+ const LONG_HEADINGLESS_THRESHOLD = 1200;
11
+
12
+ /**
13
+ * True when the description contains at least one structural heading that sits
14
+ * outside a `----` listing block or a markdown ```/~~~ fence.
15
+ *
16
+ * Used to report long heading-less descriptions during generation. Embedded
17
+ * headings are otherwise passed through unchanged: they render as top-level
18
+ * page sections, which matches every published connector page today. Demoting
19
+ * them is not an option because the description renders before the page's
20
+ * first `==` section, so demoted headings produce "section title out of
21
+ * sequence" errors. Collapsible wrapping was evaluated and rejected: it hides
22
+ * primary content behind unreliable cross-browser find and deep-link behavior,
23
+ * and bodies containing their own `====` delimiters cannot be wrapped at all.
24
+ */
25
+ function hasStructuralHeadings (body) {
26
+ // AsciiDoc headings only. Markdown-style ## headings deliberately do NOT
27
+ // count: the descriptions that render worst are exactly the ones whose
28
+ // only structure is markdown headings, and counting them here exempted
29
+ // those from the long-description report (they get their own report).
30
+ return annotateVerbatimLines(body)
31
+ .some(({ line, verbatim }) => !verbatim && /^={2,}\s+\S/.test(line));
32
+ }
33
+
34
+ /**
35
+ * True when the description contains a markdown-style heading (`##`) outside
36
+ * listing blocks and fences. These are reported for upstream conversion to
37
+ * `==` (a `#` line inside a fence is a comment in an example, not a heading).
38
+ */
39
+ function hasMarkdownHeadings (body) {
40
+ return annotateVerbatimLines(body)
41
+ .some(({ line, verbatim }) => !verbatim && /^#{2,}\s+\S/.test(line));
42
+ }
43
+
44
+ /**
45
+ * Handlebars helper: render a connector's description for the regenerated
46
+ * description partial.
47
+ *
48
+ * The `== Metadata` block is replaced by an include of the metadata partial
49
+ * (de-duplicating it, since metadata is emitted as its own partial).
50
+ * Everything else passes through unchanged: the description is the page's
51
+ * primary content, and structure belongs upstream in the connector source.
52
+ * The generator reports long heading-less descriptions so they get headings
53
+ * added there instead of being hidden by the docs build.
54
+ *
55
+ * @param {object} item connector data (needs `description`, `type`/`typeDir`, `name`)
56
+ * @returns {string}
57
+ */
58
+ module.exports = function renderConnectDescription (item) {
59
+ const body = descriptionWithMetadataInclude(item);
60
+ if (!body || !body.trim()) return '';
61
+ return escapePlaceholderBraces(ensureHeadingSeparation(body.trim()));
62
+ };
63
+
64
+ /**
65
+ * Insert the blank line Asciidoctor requires before a section title. A
66
+ * heading glued to the paragraph above it renders as literal text and
67
+ * orphans its subsections into "section title out of sequence" warnings
68
+ * (live case: the protobuf processor's "== Operators"). Listing blocks and
69
+ * fences are untouched — a glued `#` comment inside a fenced example must
70
+ * not have a blank line pushed into the example.
71
+ */
72
+ function ensureHeadingSeparation (body) {
73
+ const out = [];
74
+ for (const { line, verbatim } of annotateVerbatimLines(body)) {
75
+ if (
76
+ !verbatim &&
77
+ /^(?:={2,6}|#{2,6})\s+\S/.test(line) &&
78
+ out.length > 0 &&
79
+ out[out.length - 1].trim() !== ''
80
+ ) {
81
+ out.push('');
82
+ }
83
+ out.push(line);
84
+ }
85
+ return out.join('\n');
86
+ }
87
+
88
+ /**
89
+ * Escape template placeholders like {endpoint} so Asciidoctor keeps them
90
+ * instead of consuming them as attribute references (which substitutes to
91
+ * nothing and logs "skipping reference to missing attribute" on the
92
+ * published page — live today on salesforce_graphql and otlp_http).
93
+ * Single-backtick spans still apply attribute substitution, so spans are
94
+ * escaped too; `----` listing blocks and ```/~~~ fences are verbatim and stay
95
+ * untouched (a `\{` inside a fence would render as a literal backslash).
96
+ */
97
+ function escapePlaceholderBraces (body) {
98
+ return annotateVerbatimLines(body).map(({ line, verbatim }) => (
99
+ verbatim ? line : line.replace(/(?<![\\{])\{([a-z][\w.-]{1,30})\}/g, '\\{$1}')
100
+ )).join('\n');
101
+ }
102
+
103
+ /**
104
+ * Depth of the first structural heading (AsciiDoc `=` or markdown-compat
105
+ * `#`) outside listing blocks and fences, or null when the body has none.
106
+ * Descriptions whose first heading is deeper than level one (`===`, `###`)
107
+ * render "section title out of sequence" on the page, so the generator
108
+ * reports them as upstream fixes (seen on aws_dynamodb_cdc, iceberg,
109
+ * protobuf).
110
+ */
111
+ function firstHeadingDepth (body) {
112
+ for (const { line, verbatim } of annotateVerbatimLines(body)) {
113
+ if (verbatim) continue;
114
+ const m = line.match(/^(={2,6}|#{2,6})\s+\S/);
115
+ if (m) return m[1].length;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ module.exports.hasStructuralHeadings = hasStructuralHeadings;
121
+ module.exports.hasMarkdownHeadings = hasMarkdownHeadings;
122
+ module.exports.escapePlaceholderBraces = escapePlaceholderBraces;
123
+ module.exports.ensureHeadingSeparation = ensureHeadingSeparation;
124
+ module.exports.firstHeadingDepth = firstHeadingDepth;
125
+ module.exports.LONG_HEADINGLESS_THRESHOLD = LONG_HEADINGLESS_THRESHOLD;
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ const { flattenToAttributeValue } = require('../metadata-utils.js');
4
+
5
+ /**
6
+ * Handlebars helper: flatten a connector summary into a single-line, plain-text
7
+ * value suitable for the `:description:` page attribute (and therefore for the
8
+ * page's `<meta name="description">`).
9
+ *
10
+ * Delegates to the shared flattener so the description partial's attrs region
11
+ * and `backfillPageDescriptions` publish the same text for the same summary.
12
+ * Collapsing whitespace alone is not enough: summaries carry xrefs, link
13
+ * macros and inline code, and an unflattened value ships raw
14
+ * `xref:guides:bloblang/about.adoc[Bloblang mapping]` markup into search
15
+ * snippets and link previews (verified in a real Antora build).
16
+ *
17
+ * @param {string} summary
18
+ * @returns {string}
19
+ */
20
+ module.exports = function summaryAttribute (summary) {
21
+ return flattenToAttributeValue(summary);
22
+ };