@redpanda-data/docs-extensions-and-macros
Version:
Antora extensions and macros developed for Redpanda documentation.
180 lines (163 loc) • 7.4 kB
JavaScript
;
/**
* Utilities for extracting the "== Metadata" section out of a connector's
* `description` prose so it can be emitted as a regenerated partial (like the
* fields and examples partials) instead of being frozen into a hand-maintained
* main page.
*
* Convention: the connector `.Description()` in the Connect source contains a
* level-2 AsciiDoc heading `== Metadata` followed by a bullet list, terminated
* by the next level-2 heading (for example `== Permissions`) or end of string.
* As of Connect 4.99.0, 52 of the 65 metadata-documenting components already
* follow this exact heading convention.
*/
const METADATA_HEADING = /^==\s+Metadata\s*$/;
// AsciiDoc listing/literal block delimiter (`----`, possibly longer). Lines
// inside such blocks must not be treated as headings.
const BLOCK_DELIMITER = /^-{4,}$/;
// The metadata block ends at the next structural element. Besides the next
// level-2 heading, this also covers page-level constructs that follow the
// section when locateMetadata runs against a full reference page (not just a
// connector description): Antora include directives (for example the fields or
// examples partials) and single-source tag comments. Without these, a metadata
// section that is the last heading on a page would run to end-of-string and
// swallow the trailing `include::...partial$fields[]` and `// end::single-source[]`.
const SECTION_END = /^(?:==\s+\S|include::|\/\/\s*(?:tag|end)::)/;
/**
* Locate the `== Metadata` section within a description.
* @param {string} description
* @returns {{start:number, end:number, block:string}|null} character offsets of
* the section (heading through the last content line, trailing blank lines
* excluded) and the extracted block text, or null when no section is present.
*/
function locateMetadata (description) {
if (!description || typeof description !== 'string') return null;
const lines = description.split('\n');
let headingLine = -1;
let inBlock = false;
for (let i = 0; i < lines.length; i++) {
if (BLOCK_DELIMITER.test(lines[i])) { inBlock = !inBlock; continue; }
if (!inBlock && METADATA_HEADING.test(lines[i])) { headingLine = i; break; }
}
if (headingLine === -1) return null;
// Find the terminating element after the metadata heading: the next level-2
// heading, an Antora include directive, or a single-source tag comment.
let endLine = lines.length;
inBlock = false;
for (let i = headingLine + 1; i < lines.length; i++) {
if (BLOCK_DELIMITER.test(lines[i])) { inBlock = !inBlock; continue; }
if (!inBlock && SECTION_END.test(lines[i])) { endLine = i; break; }
}
// Trim trailing blank lines inside the section so the block ends cleanly.
let lastContent = endLine - 1;
while (lastContent > headingLine && lines[lastContent].trim() === '') lastContent--;
const startOffset = lines.slice(0, headingLine).join('\n').length + (headingLine > 0 ? 1 : 0);
const block = lines.slice(headingLine, lastContent + 1).join('\n');
const endOffset = startOffset + block.length;
return { start: startOffset, end: endOffset, block };
}
/**
* Return the extracted `== Metadata` block, or '' when there is none.
* @param {string} description
* @returns {string}
*/
function extractMetadata (description) {
const found = locateMetadata(description);
return found ? found.block : '';
}
/**
* Derive the plural type directory (for example `input` -> `inputs`).
* @param {object} item connector data with `type` and/or `typeDir`
* @returns {string}
*/
function typeDirFor (item) {
if (item && item.typeDir) return item.typeDir;
const type = item && item.type;
if (!type) return '';
return type.endsWith('s') ? type : `${type}s`;
}
/**
* Build the Antora include directive for a connector's metadata partial.
* @param {object} item connector data with `type`/`typeDir` and `name`
* @returns {string}
*/
function metadataIncludeLine (item) {
return `include::connect:components:partial$metadata/${typeDirFor(item)}/${item.name}.adoc[]`;
}
/**
* Replace the inline `== Metadata` block in a description with an include
* directive pointing at the regenerated metadata partial, preserving position
* relative to surrounding sections (such as `== Permissions`). Returns the
* description unchanged when no metadata section is present.
* @param {object} item connector data (needs `description`, `type`/`typeDir`, `name`)
* @returns {string}
*/
function descriptionWithMetadataInclude (item) {
const description = (item && item.description) || '';
const found = locateMetadata(description);
if (!found) return description;
return description.slice(0, found.start) + metadataIncludeLine(item) + description.slice(found.end);
}
// Markdown-style fence delimiter (``` or ~~~, possibly with a language tag).
// Metadata blocks can carry fenced examples alongside AsciiDoc ---- blocks.
const FENCE_DELIMITER = /^(`{3,}|~{3,})/;
/**
* Collect the section heading titles in an AsciiDoc block, ignoring lines
* inside `----` literal blocks and ```/~~~ fenced blocks. Titles are returned
* without their `=` markers so callers can compare sections across heading
* levels (the same section may be `==` in a connector description but `===`
* in a partial migrated from a page).
* @param {string} text
* @returns {string[]}
*/
function sectionHeadings (text) {
if (!text || typeof text !== 'string') return [];
const headings = [];
let inBlock = false;
let fence = null;
for (const line of text.split('\n')) {
// Layered state: while inside one delimiter kind, the only thing that
// matters is its own closer. A fence-like line inside a ---- literal
// block (or a ---- line inside a fence) is content, not a delimiter —
// treating it as one leaks the state and swallows every later heading.
if (inBlock) {
if (BLOCK_DELIMITER.test(line)) inBlock = false;
continue;
}
if (fence) {
const closer = line.match(FENCE_DELIMITER);
if (closer && closer[1][0] === fence) fence = null;
continue;
}
const fenceMatch = line.match(FENCE_DELIMITER);
if (fenceMatch) { fence = fenceMatch[1][0]; continue; }
if (BLOCK_DELIMITER.test(line)) { inBlock = true; continue; }
const m = line.match(/^=+\s+(\S.*)$/);
if (m) headings.push(m[1].trim());
}
return headings;
}
/**
* Report the section headings present in a previously generated metadata
* partial that are missing from its regenerated replacement. Regeneration is
* authoritative, but published content silently disappearing is how docs lose
* examples: a section that lives outside the upstream description's
* `== Metadata` block (or was hand-migrated from a page) is dropped without a
* trace on the next run. Callers use this to warn before overwriting.
* @param {string} oldContent existing partial on disk
* @param {string} newContent regenerated partial about to be written
* @returns {string[]} heading titles present in oldContent but not newContent
*/
function lostMetadataSections (oldContent, newContent) {
const newHeadings = new Set(sectionHeadings(newContent));
return sectionHeadings(oldContent).filter((h) => !newHeadings.has(h));
}
module.exports = {
locateMetadata,
extractMetadata,
typeDirFor,
metadataIncludeLine,
descriptionWithMetadataInclude,
sectionHeadings,
lostMetadataSections,
};