@bitnami/readme-generator-for-helm
Version:
Autogenerate READMEs tables and OpenAPI schemas for Helm Charts
304 lines (281 loc) • 10.9 kB
JavaScript
/*
* Copyright Broadcom, Inc. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable no-console */
const fs = require('fs');
const table = require('markdown-table');
const { cloneDeep } = require('lodash');
const utils = require('./utils');
/*
* Converts an array of objects of the same type to markdown table
*/
function createMarkdownTable(objArray) {
const modifiedArray = objArray.map((e) => {
if (e.value === '') {
e.value = '""';
}
if (typeof e.value === 'object') {
// the stringify prints the string '{}' or '[]'
e.value = JSON.stringify(e.value);
}
return [`\`${e.name}\``, e.description, e.extra ? '' : `\`${e.value}\``];
});
return table([
['Name', 'Description', 'Value'],
...modifiedArray,
]);
}
/*
* Returns the section rendered
*/
function renderSection({ name, description, parameters }, lineNumberSigns) {
let sectionTable = '';
sectionTable += '\r\n';
sectionTable += `${lineNumberSigns} ${name}\r\n\n`; // section header
if (description !== '') {
sectionTable += `${description}\r\n\n`; // section description
}
if (parameters.length > 0) {
sectionTable += createMarkdownTable(parameters); // section body parameters
sectionTable += '\r\n';
}
return sectionTable;
}
/*
* Returns the README's table as string
*/
function renderReadmeTable(sections, lineNumberSigns) {
let fullTable = '';
/* eslint-disable no-restricted-syntax */
for (const section of sections) {
fullTable += renderSection(section, lineNumberSigns);
}
return fullTable;
}
/*
* Add table to README.md
*/
function insertReadmeTable(readmeFilePath, sections, config) {
const data = fs.readFileSync(readmeFilePath, 'UTF-8');
const lines = data.split(/\r?\n/);
let lineNumberSigns; // Store section # starting symbols
// This array contains the index of the first and the last line to update in the README file
const paramsSectionLimits = [];
lines.forEach((line, i) => {
// Find parameters section start
const match = line.match(new RegExp(`^(##+) ${config.regexp.paramsSectionTitle}`)); // use minimun two # symbols since just one is the README title
if (match) {
/* eslint-disable prefer-destructuring */
lineNumberSigns = match[1];
paramsSectionLimits.push(i + 1);
console.log(`INFO: Found parameters section at line: ${i + 1}`);
}
});
if (paramsSectionLimits.length === 1) {
// Find parameters section end
let nextSectionFound = false;
lines.slice(paramsSectionLimits[0]).forEach((line, i) => {
const nextSectionRegExp = new RegExp(`^${lineNumberSigns}\\s`); // Match same level section
if (!nextSectionFound && line.match(nextSectionRegExp)) {
const index = paramsSectionLimits[0] + i;
console.log(`INFO: Found section end at line: ${index + 1}`);
paramsSectionLimits.push(index);
nextSectionFound = true;
}
});
if (!nextSectionFound) {
// The parameters section is the last section in the file
paramsSectionLimits.push(lines.length);
console.log('INFO: The parameters section seems to be the last section in the file');
}
// Detect last table-like line bottom to top to ignore description text between tables
let lastTableLikeLineFound = false;
const endParamsSectionRegExp = /(?!.*\|).*\S(?<!\|.*)(?<!#.*)/; // Match non empty or with non table format lines
lines.slice(paramsSectionLimits[0], paramsSectionLimits[1]).reverse().forEach((line, i) => {
if (!lastTableLikeLineFound && line && !line.match(endParamsSectionRegExp)) {
lastTableLikeLineFound = true;
// renderReadmeTable adds a blank line at the end, we have to remove it also.
// The index points to that blank line.
paramsSectionLimits[1] -= i;
// This log makes reference to the last line in the table.
console.log(`INFO: Last parameter table line found at line: ${paramsSectionLimits[1]}`);
}
});
if (!lastTableLikeLineFound) {
// If there is no parameter table, we will add the new table at the begining of the section.
paramsSectionLimits[1] = paramsSectionLimits[0];
console.log('INFO: No parameters table found');
}
}
if (paramsSectionLimits.length !== 2) {
throw new Error('ERROR: error getting current Parameters section from README');
}
console.log('INFO: Inserting the new table into the README...');
// Build the table adding the proper number of # to the section headers
const newParamsSection = renderReadmeTable(sections, `${lineNumberSigns}#`);
// Delete the old parameters section
lines.splice(paramsSectionLimits[0], paramsSectionLimits[1] - paramsSectionLimits[0] + 1);
// Add the new parameters section
lines.splice(paramsSectionLimits[0], 0, ...newParamsSection.split(/\r?\n/));
fs.writeFileSync(readmeFilePath, lines.join('\n'));
}
/*
* Updates the Schema of an object
* Inputs:
* - value: Parameter containing: 'type', 'description', 'value' and boolean 'nullable'.
* - tree: Array containing the value dict tree. E.g: "a.b" => ["a", "b"].
* - properties: Schema object properties that will be updated.
* Ref: https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.1.0.md#schema-object
* - ignoreDefault: If true, the default property is ignored.
*/
function generateSchema(value, tree, properties, ignoreDefault = false) {
/* eslint-disable no-param-reassign */
// Write schema for the value tree.
for (let i = 0; i < tree.length; i += 1) {
// If it is the last component of the tree, write its type and default value
if (i === tree.length - 1) {
properties[tree[i]] = {
type: value.type,
description: value.description,
};
if (!ignoreDefault) {
if (value.value === 'null') {
value.value = null;
}
properties[tree[i]].default = value.value;
}
if (value.nullable) {
properties[tree[i]].nullable = true;
}
if (value.type === 'array') {
// The last element of the tree is an array.
// It is a plain or empty array since if not, the tree would have more elements
if (value.value === null || value.value.length === 0) {
// When it is an empty array
properties[tree[i]].items = {}; // TODO: how do we know the type in empty arrays?
} else {
properties[tree[i]].items = { type: (typeof value.value[0]) };
}
}
} else {
// This is required to handle 'Array of objects' values, like a[0].b
// These values are instead stored in an Array type with items.
let isArray = false;
const arrayMatch = tree[i].match(/^(.+)\[.+\]$/);
let key;
if (arrayMatch && arrayMatch.length > 0) {
/* eslint-disable prefer-destructuring */
key = arrayMatch[1];
isArray = true;
} else {
key = tree[i];
}
if (isArray) {
if (!Object.prototype.hasOwnProperty.call(properties, key)) {
// Defines array schema
properties[key] = {
type: 'array',
description: value.description,
items: {
type: 'object',
properties: {},
},
};
}
// Creates the schema for the items in the array. Items defaults are ignored.
generateSchema(value, tree.splice(i + 1), properties[key].items.properties, true);
// Break the loop, as the array of objects is the last component.
break;
// If it is not an array and the value didn't exists, adds another block to the chain.
} else if (!Object.prototype.hasOwnProperty.call(properties, key)) {
properties[key] = {
type: 'object',
properties: {},
};
}
// Updates the properties for the next tree component
properties = properties[key].properties;
}
}
}
/*
* Creates a Values Schema using the OpenAPIv3 specification.
* Ref: https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.1.0.md#schema-object
*/
function createValuesSchema(values) {
const schema = {
title: 'Chart Values',
type: 'object',
properties: {},
};
values.forEach((value) => {
const tree = value.name.split('.');
generateSchema(value, tree, schema.properties);
});
return schema;
}
/*
* Export the information to be rendered into the JSON schema.
*/
function renderOpenAPISchema(schemaFilePath, parametersList, config) {
let paramsList = cloneDeep(parametersList);
// Find nil values in the between the parameters.
const nilValues = paramsList.filter((p) => p.value === 'nil' && !utils.containsModifier(p, config.modifiers.nullable)).map((p) => p.name);
if (nilValues.length > 0) {
throw new Error(`Invalid type 'nil' for the following values: ${nilValues.join(', ')}`);
}
// For nullable parameters with nil value, we need to convert to OpenAPI 'null'.
paramsList.forEach((p) => {
if (p.value === 'nil' && utils.containsModifier(p, config.modifiers.nullable)) {
console.log('Adding null parameter to the schema');
p.value = 'null';
}
});
// For nullable parameters we need to set the nullable property in the schema
paramsList.forEach((p) => {
if (utils.containsModifier(p, config.modifiers.nullable)) {
p.nullable = true;
}
});
// Apply modifiers to the type
paramsList.forEach((p) => {
if (p.modifiers.length > 0) {
p.modifiers.forEach((m) => {
switch (m) {
case `${config.modifiers.array}`:
p.type = 'array';
break;
case `${config.modifiers.object}`:
p.type = 'object';
break;
case `${config.modifiers.string}`:
p.type = 'string';
break;
default:
break;
}
});
}
});
// Filter extra parameters since they are not actually in the YAML object
paramsList = paramsList.filter((p) => !p.extra);
// The parameters with the "object" modifier must not end in the schema, the actual object should
// For example:
// @param a.b [object] Whatever
// a:
// b: "something"
// "a.b" must be in the schema, while "a" is not an actual entry (Rendered in the README only)
paramsList = paramsList.filter((p) => !utils.containsModifier(p, config.modifiers.object));
// Filter the parameters without a value.
// When there is a modifier a fake parameter is added into the list due to the metadata
paramsList = paramsList.filter((p) => p.value !== undefined);
// Render only parameter with schema=true
paramsList = paramsList.filter((p) => p.schema);
const schema = createValuesSchema(paramsList);
fs.writeFileSync(schemaFilePath, JSON.stringify(schema, null, 4));
}
module.exports = {
insertReadmeTable,
renderOpenAPISchema,
};