sfdx-hardis
Version:
Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards
843 lines (814 loc) • 86.4 kB
JavaScript
/* jscpd:ignore-start */
import { SfCommand, Flags, optionalOrgFlagWithDeprecations } from '@salesforce/sf-plugins-core';
import fs from 'fs-extra';
import c from "chalk";
import * as path from "path";
import { process as ApexDocGen } from '@cparra/apexdocs';
import { XMLBuilder, XMLParser } from "fast-xml-parser";
import sortArray from 'sort-array';
import { Messages } from '@salesforce/core';
import { WebSocketClient } from '../../../common/websocketClient.js';
import { completeAttributesDescriptionWithAi, getMetaHideLines, readMkDocsFile, replaceInFile, writeMkDocsFile } from '../../../common/docBuilder/docUtils.js';
import { parseXmlFile } from '../../../common/utils/xmlUtils.js';
import { bool2emoji, createTempDir, execCommand, execSfdxJson, filterPackageXml, getCurrentGitBranch, sortCrossPlatform, uxLog } from '../../../common/utils/index.js';
import { CONSTANTS, getConfig } from '../../../config/index.js';
import { listMajorOrgs } from '../../../common/utils/orgConfigUtils.js';
import { glob } from 'glob';
import { GLOB_IGNORE_PATTERNS, listApexFiles, listFlowFiles, listPageFiles, returnApexType } from '../../../common/utils/projectUtils.js';
import { generateFlowMarkdownFile, generateHistoryDiffMarkdown, generateMarkdownFileWithMermaid } from '../../../common/utils/mermaidUtils.js';
import { MetadataUtils } from '../../../common/metadata-utils/index.js';
import { PACKAGE_ROOT_DIR } from '../../../settings.js';
import { BranchStrategyMermaidBuilder } from '../../../common/utils/branchStrategyMermaidBuilder.js';
import { prettifyFieldName } from '../../../common/utils/flowVisualiser/nodeFormatUtils.js';
import { ObjectModelBuilder } from '../../../common/docBuilder/objectModelBuilder.js';
import { generatePdfFileFromMarkdown } from '../../../common/utils/markdownUtils.js';
import { DocBuilderPage } from '../../../common/docBuilder/docBuilderPage.js';
import { DocBuilderProfile } from '../../../common/docBuilder/docBuilderProfile.js';
import { DocBuilderObject } from '../../../common/docBuilder/docBuilderObject.js';
import { DocBuilderApex } from '../../../common/docBuilder/docBuilderApex.js';
import { DocBuilderFlow } from '../../../common/docBuilder/docBuilderFlow.js';
import { DocBuilderLwc } from '../../../common/docBuilder/docBuilderLwc.js';
import { DocBuilderPackageXML } from '../../../common/docBuilder/docBuilderPackageXml.js';
import { DocBuilderPermissionSet } from '../../../common/docBuilder/docBuilderPermissionSet.js';
import { DocBuilderPermissionSetGroup } from '../../../common/docBuilder/docBuilderPermissionSetGroup.js';
import { DocBuilderAssignmentRules } from '../../../common/docBuilder/docBuilderAssignmentRules.js';
import { DocBuilderApprovalProcess } from '../../../common/docBuilder/docBuilderApprovalProcess.js';
import { DocBuilderAutoResponseRules } from "../../../common/docBuilder/docBuilderAutoResponseRules.js";
import { DocBuilderEscalationRules } from '../../../common/docBuilder/docBuilderEscalationRules.js';
import { DocBuilderRoles } from '../../../common/docBuilder/docBuilderRoles.js';
import { DocBuilderPackage } from '../../../common/docBuilder/docBuilderPackage.js';
import { setConnectionVariables } from '../../../common/utils/orgUtils.js';
import { makeFileNameGitCompliant } from '../../../common/utils/gitUtils.js';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');
export default class Project2Markdown extends SfCommand {
static title = 'SFDX Project to Markdown';
static htmlInstructions = `## Doc HTML Pages
To read the documentation as HTML pages, run the following code (you need [**Python**](https://www.python.org/downloads/) on your computer)
\`\`\`python
pip install mkdocs-material mkdocs-exclude-search mdx_truly_sane_lists || python -m pip install mkdocs-material mkdocs-exclude-search mdx_truly_sane_lists || py -m pip install mkdocs-material mkdocs-exclude-search mdx_truly_sane_lists
mkdocs serve -v || python -m mkdocs serve -v || py -m mkdocs serve -v
\`\`\`
To just generate HTML pages that you can host anywhere, run \`mkdocs build -v || python -m mkdocs build -v || py -m mkdocs build -v\`
`;
static description = `Generates Markdown documentation from a SFDX project
- Objects (with fields, validation rules, relationships and dependencies)
- Automations
- Approval Processes
- Assignment Rules
- AutoResponse Rules
- Escalation Rules
- Flows
- Authorizations
- Profiles
- Permission Set Groups
- Permission Sets
- Code
- Apex
- Lightning Web Components
- Lightning Pages
- Packages
- SFDX-Hardis Config
- Branches & Orgs
- Manifests
Can work on any sfdx project, no need for it to be a sfdx-hardis flavored one.
Generated markdown files will be written in the **docs** folder (except README.md, where a link to the doc index is added).
- You can customize the pages following [mkdocs-material setup documentation](https://squidfunk.github.io/mkdocs-material/setup/)
- You can manually add new markdown files in the "docs" folder to extend this documentation and add references to them in "mkdocs.yml"
- You can also add images in folder "docs/assets" and embed them in markdown files.
To read flow documentation, if your markdown reader doesn't handle MermaidJS syntax this command may require @mermaid-js/mermaid-cli.
- Run \`npm install @mermaid-js/mermaid-cli --global\` if puppeteer works in your environment
- It can also be run as a docker image
Both modes will be tried by default, but you can also force one of them by defining environment variable \`MERMAID_MODES=docker\` or \`MERMAID_MODES=cli\`
_sfdx-hardis docker image is alpine-based and does not succeed to run mermaid/puppeteer: if you can help, please submit a PR !_
If Flow history doc always display a single state, you probably need to update your workflow configuration:
- on Gitlab: Env variable [\`GIT_FETCH_EXTRA_FLAGS: --depth 10000\`](https://github.com/hardisgroupcom/sfdx-hardis/blob/main/defaults/monitoring/.gitlab-ci.yml#L11)
- on GitHub: [\`fetch-depth: 0\`](https://github.com/hardisgroupcom/sfdx-hardis/blob/main/defaults/monitoring/.github/workflows/org-monitoring.yml#L58)
- on Azure: [\`fetchDepth: "0"\`](https://github.com/hardisgroupcom/sfdx-hardis/blob/main/defaults/monitoring/azure-pipelines.yml#L39)
- on Bitbucket: [\`step: clone: depth: full\`](https://github.com/hardisgroupcom/sfdx-hardis/blob/main/defaults/monitoring/bitbucket-pipelines.yml#L18)






If it is a sfdx-hardis CI/CD project, a diagram of the branches and orgs strategy will be generated.

If [AI integration](${CONSTANTS.DOC_URL_ROOT}/salesforce-ai-setup/) is configured, documentation will contain a summary of the Flow.
- Use variable PROMPTS_LANGUAGE (ex: PROMPTS_LANGUAGE=fr) to force language for LLM calls (default:en)
If you have a complex strategy, you might need to input property **mergeTargets** in branch-scoped sfdx-hardis.yml file to have a correct diagram.
Define DO_NOT_OVERWRITE_INDEX_MD=true to avoid overwriting the index.md file in docs folder, useful if you want to keep your own index.md file.
${this.htmlInstructions}
`;
static examples = [
'$ sf hardis:doc:project2markdown',
'$ sf hardis:doc:project2markdown --with-history',
'$ sf hardis:doc:project2markdown --with-history --pdf',
'$ sf hardis:doc:project2markdown --hide-apex-code'
];
static flags = {
"diff-only": Flags.boolean({
default: false,
description: "Generate documentation only for changed files (used for monitoring)",
}),
"with-history": Flags.boolean({
default: false,
description: "Generate a markdown file with the history diff of the Flow",
}),
pdf: Flags.boolean({
description: 'Also generate the documentation in PDF format',
}),
"hide-apex-code": Flags.boolean({
default: false,
description: "Hide Apex code in the generated documentation for Apex classes.",
}),
debug: Flags.boolean({
char: 'd',
default: false,
description: messages.getMessage('debugMode'),
}),
websocket: Flags.string({
description: messages.getMessage('websocket'),
}),
skipauth: Flags.boolean({
description: 'Skip authentication check when a default username is required',
}),
"target-org": optionalOrgFlagWithDeprecations
};
// Set this to true if your command requires a project workspace; 'requiresProject' is false by default
static requiresProject = true;
diffOnly = false;
packageXmlCandidates;
outputMarkdownRoot = "docs";
outputMarkdownIndexFile = path.join(this.outputMarkdownRoot, "index.md");
mdLines = [];
sfdxHardisConfig = {};
outputPackageXmlMarkdownFiles = [];
mkDocsNavNodes = [{ "Home": "index.md" }];
withHistory = false;
withPdf = false;
hideApexCode = false;
debugMode = false;
footer;
apexDescriptions = [];
flowDescriptions = [];
lwcDescriptions = [];
packageDescriptions = [];
pageDescriptions = [];
profileDescriptions = [];
permissionSetsDescriptions = [];
permissionSetGroupsDescriptions = [];
assignmentRulesDescriptions = [];
autoResponseRulesDescriptions = [];
approvalProcessesDescriptions = [];
escalationRulesDescriptions = [];
roleDescriptions = [];
objectDescriptions = [];
objectFiles;
allObjectsNames;
tempDir;
/* jscpd:ignore-end */
async run() {
const { flags } = await this.parse(Project2Markdown);
this.diffOnly = flags["diff-only"] === true ? true : false;
this.withHistory = flags["with-history"] === true ? true : false;
this.withPdf = flags.pdf === true ? true : false;
this.hideApexCode = flags["hide-apex-code"] === true || process?.env?.HIDE_APEX_CODE === 'true' ? true : false;
this.debugMode = flags.debug || false;
await setConnectionVariables(flags['target-org']?.getConnection(), true); // Required for some notifications providers like Email, or for Agentforce
await fs.ensureDir(this.outputMarkdownRoot);
const currentBranch = await getCurrentGitBranch();
this.footer = `_Documentation generated from branch ${currentBranch} with [sfdx-hardis](${CONSTANTS.DOC_URL_ROOT}) by [Cloudity](${CONSTANTS.WEBSITE_URL}) command [\`sf hardis:doc:project2markdown\`](https://sfdx-hardis.cloudity.com/hardis/doc/project2markdown/)_`;
this.mdLines.push(...[
"Welcome to the documentation of your Salesforce project.",
"",
// "- [Object Model](object-model.md)",
"- [Objects](objects/index.md)",
"- Automations",
" - [Approval Processes](approvalProcesses/index.md)",
" - [Assignment Rules](assignmentRules/index.md)",
" - [AutoResponse Rules](autoResponseRules/index.md)",
" - [Escalation Rules](escalationRules/index.md)",
" - [Flows](flows/index.md)",
"- Authorizations",
" - [Profiles](profiles/index.md)",
" - [Permission Set Groups](permissionsetgroups/index.md)",
" - [Permission Sets](permissionsets/index.md)",
"- [Roles](roles.md)",
"- Code",
" - [Apex](apex/index.md)",
" - [Lightning Web Components](lwc/index.md)",
"- [Lightning Pages](pages/index.md)",
"- [Packages](packages/index.md)",
"- [SFDX-Hardis Config](sfdx-hardis-params.md)",
"- [Branches & Orgs](sfdx-hardis-branches-and-orgs.md)",
"- [Manifests](manifests.md)",
""
]);
let sfdxHardisParamsLines = ["Available only in a [sfdx-hardis CI/CD project](https://sfdx-hardis.cloudity.com/salesforce-ci-cd-home/)"];
let branchesAndOrgsLines = ["Available only in a [sfdx-hardis CI/CD project](https://sfdx-hardis.cloudity.com/salesforce-ci-cd-home/)"];
if (fs.existsSync("config/.sfdx-hardis.yml")) {
this.sfdxHardisConfig = await getConfig("project");
// General sfdx-hardis config
sfdxHardisParamsLines = this.buildSfdxHardisParams();
// Branches & orgs
branchesAndOrgsLines = await this.buildMajorBranchesAndOrgs();
}
await fs.writeFile(path.join(this.outputMarkdownRoot, "sfdx-hardis-params.md"), getMetaHideLines() + sfdxHardisParamsLines.join("\n") + `\n${this.footer}\n`);
this.addNavNode("SFDX-Hardis Config", "sfdx-hardis-params.md");
await fs.writeFile(path.join(this.outputMarkdownRoot, "sfdx-hardis-branches-and-orgs.md"), getMetaHideLines() + branchesAndOrgsLines.join("\n") + `\n${this.footer}\n`);
this.addNavNode("Branches & Orgs", "sfdx-hardis-branches-and-orgs.md");
// Object model Mermaid schema
/* Disabled: too messy to read
let mermaidSchema = await new ObjectModelBuilder().buildObjectsMermaidSchema();
mermaidSchema = "```mermaid\n" + mermaidSchema + "\n```";
await fs.writeFile(path.join(this.outputMarkdownRoot, "object-model.md"), getMetaHideLines() + mermaidSchema + `\n${this.footer}\n`);
this.addNavNode("Object Model", "object-model.md");
*/
// List SFDX packages and generate a manifest for each of them, except if there is only force-app with a package.xml
this.packageXmlCandidates = DocBuilderPackageXML.listPackageXmlCandidates();
await this.manageLocalPackages();
const instanceUrl = flags?.['target-org']?.getConnection()?.instanceUrl;
await this.generatePackageXmlMarkdown(this.packageXmlCandidates, instanceUrl);
const { packageLines, packagesForMenu } = await DocBuilderPackageXML.buildIndexTable(this.outputPackageXmlMarkdownFiles);
this.addNavNode("Manifests", packagesForMenu);
await fs.writeFile(path.join(this.outputMarkdownRoot, "manifests.md"), getMetaHideLines() + packageLines.join("\n") + `\n${this.footer}\n`);
this.tempDir = await createTempDir();
// Convert source to metadata API format to build prompts
uxLog("action", this, c.cyan("Converting source to metadata API format to ease the build of LLM prompts."));
await execCommand(`sf project convert source --metadata CustomObject --output-dir ${this.tempDir}`, this, { fail: true, output: true, debug: this.debugMode });
this.objectFiles = (await glob("**/*.object", { cwd: this.tempDir, ignore: GLOB_IGNORE_PATTERNS }));
sortCrossPlatform(this.objectFiles);
this.allObjectsNames = this.objectFiles.map(object => path.basename(object, ".object"));
// Generate packages documentation
if (!(process?.env?.GENERATE_PACKAGES_DOC === 'false')) {
await this.generatePackagesDocumentation();
}
// Generate Apex doc
if (!(process?.env?.GENERATE_APEX_DOC === 'false')) {
await this.generateApexDocumentation();
}
// List flows & generate doc
if (!(process?.env?.GENERATE_FLOW_DOC === 'false')) {
await this.generateFlowsDocumentation();
}
// List pages & generate doc
if (!(process?.env?.GENERATE_PAGES_DOC === 'false')) {
await this.generatePagesDocumentation();
}
// List profiles & generate doc
if (!(process?.env?.GENERATE_PROFILES_DOC === 'false')) {
await this.generateProfilesDocumentation();
await this.generatePermissionSetGroupsDocumentation();
await this.generatePermissionSetsDocumentation();
await this.generateRolesDocumentation();
}
// List objects & generate doc
if (!(process?.env?.GENERATE_OBJECTS_DOC === 'false')) {
await this.generateObjectsDocumentation();
}
if (!(process?.env?.GENERATE_AUTOMATIONS_DOC === 'false')) {
// List approval processes & generate doc
await this.generateApprovalProcessDocumentation();
// List assignment rules and generate doc
await this.generateAssignmentRulesDocumentation();
// List auto response rules and generate doc
await this.generateAutoResponseRulesDocumentation();
// List escalation rules and generate doc
await this.generateEscalationRulesDocumentation();
}
// List LWC & generate doc
if (!(process?.env?.GENERATE_LWC_DOC === 'false')) {
await this.generateLwcDocumentation();
}
// Write output index file
await fs.ensureDir(path.dirname(this.outputMarkdownIndexFile));
if (process.env.DO_NOT_OVERWRITE_INDEX_MD !== 'true' || !fs.existsSync(this.outputMarkdownIndexFile)) {
await fs.writeFile(this.outputMarkdownIndexFile, getMetaHideLines() + this.mdLines.join("\n") + `\n\n${this.footer}\n`);
uxLog("success", this, c.green(`Successfully generated doc index at ${this.outputMarkdownIndexFile}`));
}
const readmeFile = path.join(process.cwd(), "README.md");
if (fs.existsSync(readmeFile)) {
let readme = await fs.readFile(readmeFile, "utf8");
if (!readme.includes("docs/index.md")) {
readme += `
## Documentation
[Read auto-generated documentation of the SFDX project](docs/index.md)
${Project2Markdown.htmlInstructions}
`;
await fs.writeFile(readmeFile, readme);
uxLog("success", this, c.green(`Updated README.md to add link to docs/index.md`));
}
}
await this.buildMkDocsYml();
// Delete files found in docs folder that contain characters not compliant with Windows file system
// (e.g. /, \, :, *, ?, ", <, >, |)
const filesToDelete = await glob("**/*", { cwd: this.outputMarkdownRoot, nodir: true });
for (const file of filesToDelete) {
const fileName = path.basename(file);
if (fileName.includes("/") || fileName.includes("\\") || fileName.includes(":") || fileName.includes("*") || fileName.includes("?") || fileName.includes('"') || fileName.includes("<") || fileName.includes(">") || fileName.includes("|")) {
const filePath = path.join(this.outputMarkdownRoot, file);
uxLog("warning", this, c.yellow(`Deleting file ${filePath} because it contains characters not compliant with Windows file system`));
await fs.remove(filePath);
}
}
// Open file in a new VS Code tab if available
if (WebSocketClient.isAliveWithLwcUI()) {
WebSocketClient.sendReportFileMessage(this.outputMarkdownIndexFile, "Project documentation Index", "report");
}
else {
WebSocketClient.requestOpenFile(this.outputMarkdownIndexFile);
}
return { outputPackageXmlMarkdownFiles: this.outputPackageXmlMarkdownFiles };
}
async generateApexDocumentation() {
uxLog("action", this, c.cyan("Calling ApexDocGen to initialize Apex documentation... (if you don't want it, define GENERATE_APEX_DOC=false in your environment variables)"));
const tempDir = await createTempDir();
uxLog("log", this, c.grey(`Using temp directory ${tempDir}`));
const packageDirs = this.project?.getPackageDirectories() || [];
for (const packageDir of packageDirs) {
try {
await ApexDocGen({
sourceDir: packageDir.path,
targetDir: tempDir,
exclude: ["**/MetadataService.cls"],
scope: ['global', 'public', 'private'],
targetGenerator: "markdown"
});
// Copy files to apex folder
const apexDocFolder = path.join(this.outputMarkdownRoot, "apex");
await fs.ensureDir(apexDocFolder);
await fs.copy(path.join(tempDir, "miscellaneous"), apexDocFolder, { overwrite: true });
uxLog("log", this, c.grey(`Generated markdown for Apex classes in ${apexDocFolder}`));
}
catch (e) {
uxLog("warning", this, c.yellow(`Error generating Apex documentation: ${JSON.stringify(e, null, 2)}`));
uxLog("log", this, c.grey(e.stack));
}
/*
await ApexDocGen({
sourceDir: packageDir.path,
targetDir: tempDir,
targetGenerator: "openapi"
});
*/
}
const apexFiles = await listApexFiles(packageDirs);
const apexClassNames = apexFiles.map(file => path.basename(file, ".cls")).filter(name => !name.endsWith(".trigger"));
// Build relationship between apex classes and objects
for (const apexFile of apexFiles) {
const apexName = path.basename(apexFile, ".cls").replace(".trigger", "");
const apexContent = await fs.readFile(apexFile, "utf8");
this.apexDescriptions.push({
name: apexName,
type: returnApexType(apexContent),
impactedObjects: this.allObjectsNames.filter(objectName => apexContent.includes(`${objectName}`)),
relatedClasses: apexClassNames.filter(name => apexContent.includes(`${name}`) && name !== apexName),
});
}
// Complete generated documentation
if (apexFiles.length === 0) {
uxLog("log", this, c.yellow("No Apex class found in the project"));
return;
}
const apexForMenu = { "All Apex Classes": "apex/index.md" };
WebSocketClient.sendProgressStartMessage("Generating Apex documentation...", apexFiles.length);
let counter = 0;
for (const apexFile of apexFiles) {
const apexName = path.basename(apexFile, ".cls").replace(".trigger", "");
const apexContent = await fs.readFile(apexFile, "utf8");
const mdFile = path.join(this.outputMarkdownRoot, "apex", apexName + ".md");
if (fs.existsSync(mdFile)) {
const apexName = path.basename(apexFile, ".cls").replace(".trigger", "");
apexForMenu[apexName] = "apex/" + apexName + ".md";
let apexMdContent = await fs.readFile(mdFile, "utf8");
// Replace object links
apexMdContent = apexMdContent.replaceAll("..\\custom-objects\\", "../objects/").replaceAll("../custom-objects/", "../objects/");
// Add text before the first ##
if (!["MetadataService"].includes(apexName) &&
// Do not mess with existing apex doc if generation has crashed
!apexMdContent.includes(getMetaHideLines())) {
const mermaidClassDiagram = DocBuilderApex.buildMermaidClassDiagram(apexName, this.apexDescriptions);
let insertion = `${mermaidClassDiagram}\n\n<!-- Apex description -->\n\n`;
if (!this.hideApexCode) {
insertion += `## Apex Code\n\n\`\`\`java\n${apexContent}\n\`\`\`\n\n`;
}
const firstHeading = apexMdContent.indexOf("## ");
apexMdContent = apexMdContent.substring(0, firstHeading) + insertion + apexMdContent.substring(firstHeading);
const apexDocBuilder = new DocBuilderApex(apexName, apexContent, "", {
"CLASS_NAME": apexName,
"APEX_CODE": apexContent
});
apexDocBuilder.markdownDoc = apexMdContent;
apexMdContent = await apexDocBuilder.completeDocWithAiDescription();
await fs.writeFile(mdFile, getMetaHideLines() + apexMdContent);
}
uxLog("log", this, c.grey(`Generated markdown for Apex class ${apexName}`));
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
}
counter++;
WebSocketClient.sendProgressStepMessage(counter, apexFiles.length);
}
WebSocketClient.sendProgressEndMessage();
this.addNavNode("Apex", apexForMenu);
// Write index file for apex folder
await fs.ensureDir(path.join(this.outputMarkdownRoot, "apex"));
const apexIndexFile = path.join(this.outputMarkdownRoot, "apex", "index.md");
await fs.writeFile(apexIndexFile, getMetaHideLines() + DocBuilderApex.buildIndexTable('', this.apexDescriptions).join("\n") + `\n\n${this.footer}\n`);
}
async generatePackagesDocumentation() {
const packagesForMenu = { "All Packages": "packages/index.md" };
// List packages
const packages = this.sfdxHardisConfig.installedPackages || []; // CI/CD context
const packageFolder = path.join(process.cwd(), 'installedPackages'); // Monitoring context
if (packages.length === 0 && fs.existsSync(packageFolder)) {
const findManagedPattern = "**/*.json";
const matchingPackageFiles = await glob(findManagedPattern, { cwd: packageFolder, ignore: GLOB_IGNORE_PATTERNS });
for (const packageFile of matchingPackageFiles) {
const packageFileFull = path.join(packageFolder, packageFile);
if (!fs.existsSync(packageFileFull)) {
continue;
}
const pckg = await fs.readJSON(packageFileFull);
packages.push(pckg);
}
}
WebSocketClient.sendProgressStartMessage("Generating Installed Packages documentation...", packages.length);
let counter = 0;
// Process packages
for (const pckg of packages) {
const packageName = pckg.SubscriberPackageName;
const mdFile = path.join(this.outputMarkdownRoot, "packages", makeFileNameGitCompliant(packageName) + ".md");
// Generate package page and add it to menu
packagesForMenu[packageName] = "packages/" + makeFileNameGitCompliant(packageName) + ".md";
this.packageDescriptions.push({
name: packageName,
namespace: pckg.SubscriberPackageNamespace || "None",
versionNumber: pckg.SubscriberPackageVersionNumber || "Unknown",
versionName: pckg.SubscriberPackageVersionName || "Unknown",
versionId: pckg.SubscriberPackageVersionId || "Unknown",
});
let packageMetadatas = "Unable to list package Metadatas";
const packageWithAllMetadatas = path.join(process.cwd(), "manifest", "package-all-org-items.xml");
const tmpOutput = path.join(this.tempDir, pckg.SubscriberPackageVersionId + ".xml");
if (fs.existsSync(packageWithAllMetadatas) && pckg.SubscriberPackageNamespace) {
const filterRes = await filterPackageXml(packageWithAllMetadatas, tmpOutput, { keepOnlyNamespaces: [pckg.SubscriberPackageNamespace] });
if (filterRes.updated) {
packageMetadatas = await fs.readFile(tmpOutput, "utf8");
}
}
// Add Packages in documentation
await new DocBuilderPackage(makeFileNameGitCompliant(packageName), pckg, mdFile, {
"PACKAGE_METADATAS": packageMetadatas,
"PACKAGE_FILE": tmpOutput
}).generateMarkdownFileFromXml();
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
// Recovery to save git repos: Kill existing file if it has been created with forbidden characters
const mdFileBad = path.join(this.outputMarkdownRoot, "packages", packageName + ".md");
if (mdFileBad !== mdFile && fs.existsSync(mdFileBad)) {
await fs.remove(mdFileBad);
}
counter++;
WebSocketClient.sendProgressStepMessage(counter, packages.length);
}
this.addNavNode("Packages", packagesForMenu);
// Write index file for packages folder
await fs.ensureDir(path.join(this.outputMarkdownRoot, "packages"));
const packagesIndexFile = path.join(this.outputMarkdownRoot, "packages", "index.md");
await fs.writeFile(packagesIndexFile, getMetaHideLines() + DocBuilderPackage.buildIndexTable('', this.packageDescriptions).join("\n") + `\n\n${this.footer}\n`);
WebSocketClient.sendProgressEndMessage();
}
async generatePagesDocumentation() {
const packageDirs = this.project?.getPackageDirectories() || [];
const pageFiles = await listPageFiles(packageDirs);
const pagesForMenu = { "All Lightning pages": "pages/index.md" };
WebSocketClient.sendProgressStartMessage("Generating Lightning Pages documentation...", pageFiles.length);
let counter = 0;
for (const pagefile of pageFiles) {
const pageName = path.basename(pagefile, ".flexipage-meta.xml");
const mdFile = path.join(this.outputMarkdownRoot, "pages", pageName + ".md");
pagesForMenu[pageName] = "pages/" + pageName + ".md";
// Add Pages in documentation
const pageXml = await fs.readFile(pagefile, "utf8");
const pageXmlParsed = new XMLParser().parse(pageXml);
this.pageDescriptions.push({
name: pageName,
type: prettifyFieldName(pageXmlParsed?.FlexiPage?.type || "Unknown"),
impactedObjects: this.allObjectsNames.filter(objectName => pageXml.includes(`${objectName}`))
});
await new DocBuilderPage(pageName, pageXml, mdFile).generateMarkdownFileFromXml();
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
counter++;
WebSocketClient.sendProgressStepMessage(counter, pageFiles.length);
}
WebSocketClient.sendProgressEndMessage();
this.addNavNode("Lightning Pages", pagesForMenu);
// Write index file for pages folder
await fs.ensureDir(path.join(this.outputMarkdownRoot, "pages"));
const pagesIndexFile = path.join(this.outputMarkdownRoot, "pages", "index.md");
await fs.writeFile(pagesIndexFile, getMetaHideLines() + DocBuilderPage.buildIndexTable('', this.pageDescriptions).join("\n") + `\n\n${this.footer}\n`);
}
async generateProfilesDocumentation() {
uxLog("action", this, c.cyan("Preparing generation of Profiles documentation... (if you don't want it, define GENERATE_PROFILES_DOC=false in your environment variables)"));
const profilesForMenu = { "All Profiles": "profiles/index.md" };
const profilesFiles = (await glob("**/profiles/**.profile-meta.xml", { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }));
sortCrossPlatform(profilesFiles);
if (profilesFiles.length === 0) {
uxLog("log", this, c.yellow("No profile found in the project"));
return;
}
WebSocketClient.sendProgressStartMessage("Generating Profiles documentation...", profilesFiles.length);
let counter = 0;
for (const profileFile of profilesFiles) {
const profileName = path.basename(profileFile, ".profile-meta.xml");
const mdFile = path.join(this.outputMarkdownRoot, "profiles", profileName + ".md");
profilesForMenu[profileName] = "profiles/" + profileName + ".md";
const profileXml = await fs.readFile(profileFile, "utf8");
const profileXmlParsed = new XMLParser().parse(profileXml);
this.profileDescriptions.push({
name: profileName,
userLicense: prettifyFieldName(profileXmlParsed?.Profile?.userLicense || "Unknown"),
impactedObjects: this.allObjectsNames.filter(objectName => profileXml.includes(`${objectName}`))
});
// Add Profiles code in documentation
await new DocBuilderProfile(profileName, profileXml, mdFile).generateMarkdownFileFromXml();
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
counter++;
WebSocketClient.sendProgressStepMessage(counter, profilesFiles.length);
}
WebSocketClient.sendProgressEndMessage();
this.addNavNode("Profiles", profilesForMenu);
// Write index file for profiles folder
await fs.ensureDir(path.join(this.outputMarkdownRoot, "profiles"));
const profilesIndexFile = path.join(this.outputMarkdownRoot, "profiles", "index.md");
await fs.writeFile(profilesIndexFile, getMetaHideLines() + DocBuilderProfile.buildIndexTable('', this.profileDescriptions).join("\n") + `\n\n${this.footer}\n`);
}
async generatePermissionSetsDocumentation() {
uxLog("action", this, c.cyan("Preparing generation of Permission Sets documentation... (if you don't want it, define GENERATE_PROFILES_DOC=false in your environment variables)"));
const psForMenu = { "All Permission Sets": "permissionsets/index.md" };
const psFiles = (await glob("**/permissionsets/**.permissionset-meta.xml", { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }));
sortCrossPlatform(psFiles);
if (psFiles.length === 0) {
uxLog("log", this, c.yellow("No permission set found in the project"));
return;
}
WebSocketClient.sendProgressStartMessage("Generating Permission Sets documentation...", psFiles.length);
let counter = 0;
for (const psFile of psFiles) {
const psName = path.basename(psFile, ".permissionset-meta.xml");
const mdFile = path.join(this.outputMarkdownRoot, "permissionsets", psName + ".md");
psForMenu[psName] = "permissionsets/" + psName + ".md";
const psXml = await fs.readFile(psFile, "utf8");
const psXmlParsed = new XMLParser().parse(psXml);
this.permissionSetsDescriptions.push({
name: psName,
userLicense: prettifyFieldName(psXmlParsed?.PermissionSet?.license || "Unknown"),
impactedObjects: this.allObjectsNames.filter(objectName => psXml.includes(`${objectName}`))
});
// Add Permission Sets code in documentation
await new DocBuilderPermissionSet(psName, psXml, mdFile).generateMarkdownFileFromXml();
// Permission Set Groups Table
const relatedPsg = DocBuilderPermissionSetGroup.buildIndexTable('../permissionsetgroups/', this.permissionSetGroupsDescriptions, psName);
await replaceInFile(mdFile, '<!-- Permission Set Groups table -->', relatedPsg.join("\n"));
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
counter++;
WebSocketClient.sendProgressStepMessage(counter, psFiles.length);
}
WebSocketClient.sendProgressEndMessage();
this.addNavNode("Permission Sets", psForMenu);
// Write index file for permission sets folder
await fs.ensureDir(path.join(this.outputMarkdownRoot, "permissionsets"));
const psIndexFile = path.join(this.outputMarkdownRoot, "permissionsets", "index.md");
await fs.writeFile(psIndexFile, getMetaHideLines() + DocBuilderPermissionSet.buildIndexTable('', this.permissionSetsDescriptions).join("\n") + `\n\n${this.footer}\n`);
}
async generatePermissionSetGroupsDocumentation() {
uxLog("action", this, c.cyan("Preparing generation of Permission Set Groups documentation..."));
const psgForMenu = { "All Permission Set Groups": "permissionsetgroups/index.md" };
const psgFiles = (await glob("**/permissionsetgroups/**.permissionsetgroup-meta.xml", { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }));
sortCrossPlatform(psgFiles);
if (psgFiles.length === 0) {
uxLog("log", this, c.yellow("No permission set group found in the project"));
return;
}
WebSocketClient.sendProgressStartMessage("Generating Permission Set Groups documentation...", psgFiles.length);
let counter = 0;
for (const psgFile of psgFiles) {
const psgName = path.basename(psgFile, ".permissionsetgroup-meta.xml");
const mdFile = path.join(this.outputMarkdownRoot, "permissionsetgroups", psgName + ".md");
psgForMenu[psgName] = "permissionsetgroups/" + psgName + ".md";
const psgXml = await fs.readFile(psgFile, "utf8");
const psgXmlParsed = new XMLParser().parse(psgXml);
let permissionSets = psgXmlParsed?.PermissionSetGroup?.permissionSets || [];
if (!Array.isArray(permissionSets)) {
permissionSets = [permissionSets];
}
this.permissionSetGroupsDescriptions.push({
name: psgName,
description: psgXmlParsed?.PermissionSetGroup?.description || "None",
relatedPermissionSets: permissionSets,
});
await new DocBuilderPermissionSetGroup(psgName, psgXml, mdFile).generateMarkdownFileFromXml();
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
counter++;
WebSocketClient.sendProgressStepMessage(counter, psgFiles.length);
}
WebSocketClient.sendProgressEndMessage();
this.addNavNode("Permission Set Groups", psgForMenu);
// Write index file for permission set groups folder
await fs.ensureDir(path.join(this.outputMarkdownRoot, "permissionsetgroups"));
const psgIndexFile = path.join(this.outputMarkdownRoot, "permissionsetgroups", "index.md");
await fs.writeFile(psgIndexFile, getMetaHideLines() + DocBuilderPermissionSetGroup.buildIndexTable('', this.permissionSetGroupsDescriptions).join("\n") + `\n${this.footer}\n`);
}
async generateRolesDocumentation() {
uxLog("action", this, c.cyan("Generating Roles documentation... (if you don't want it, define GENERATE_PROFILES_DOC=false in your environment variables)"));
const roleFiles = (await glob("**/roles/**.role-meta.xml", { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }));
sortCrossPlatform(roleFiles);
if (roleFiles.length === 0) {
uxLog("log", this, c.yellow("No role found in the project"));
return;
}
for (const roleFile of roleFiles) {
const roleApiName = path.basename(roleFile, ".role-meta.xml");
const roleXml = await fs.readFile(roleFile, "utf8");
const roleXmlParsed = new XMLParser().parse(roleXml);
// build object with all XML root tags
const roleInfo = { apiName: roleApiName };
for (const roleAttribute of Object.keys(roleXmlParsed?.Role || {})) {
roleInfo[roleAttribute] = roleXmlParsed?.Role[roleAttribute] || "";
}
this.roleDescriptions.push(roleInfo);
}
this.addNavNode("Roles", "roles.md");
// Add Roles documentation
const rolesIndexFile = path.join(this.outputMarkdownRoot, "roles.md");
await DocBuilderRoles.generateMarkdownFileFromRoles(this.roleDescriptions, rolesIndexFile);
if (this.withPdf) {
await generatePdfFileFromMarkdown(rolesIndexFile);
}
}
async generateAssignmentRulesDocumentation() {
uxLog("action", this, c.cyan("Preparing generation of Assignment Rules documentation... " +
"(if you don't want it, define GENERATE_AUTOMATIONS_DOC=false in your environment variables)"));
const assignmentRulesForMenu = { "All Assignment Rules": "assignmentRules/index.md" };
const assignmentRulesFiles = (await glob("**/assignmentRules/**.assignmentRules-meta.xml", {
cwd: process.cwd(),
ignore: GLOB_IGNORE_PATTERNS
}));
sortCrossPlatform(assignmentRulesFiles);
const builder = new XMLBuilder();
// Count total rules for progress tracking
let totalRules = 0;
for (const assignmentRulesFile of assignmentRulesFiles) {
const assignmentRulesXml = await fs.readFile(assignmentRulesFile, "utf8");
const assignmentRulesXmlParsed = new XMLParser().parse(assignmentRulesXml);
let rulesList = assignmentRulesXmlParsed?.AssignmentRules?.assignmentRule || [];
if (!Array.isArray(rulesList)) {
rulesList = [rulesList];
}
totalRules += rulesList.length;
}
if (totalRules === 0) {
uxLog("log", this, c.yellow("No assignment rule found in the project"));
return;
}
WebSocketClient.sendProgressStartMessage("Generating Assignment Rules documentation...", totalRules);
let counter = 0;
for (const assignmentRulesFile of assignmentRulesFiles) {
const assignmentRulesXml = await fs.readFile(assignmentRulesFile, "utf8");
const assignmentRulesXmlParsed = new XMLParser().parse(assignmentRulesXml);
const assignmentRulesName = path.basename(assignmentRulesFile, ".assignmentRules-meta.xml");
// parsing one singe XML file with all the Assignment Rules per object:
let rulesList = assignmentRulesXmlParsed?.AssignmentRules?.assignmentRule || [];
if (!Array.isArray(rulesList)) {
rulesList = [rulesList];
}
for (const rule of rulesList) {
const currentRuleName = assignmentRulesName + "." + rule?.fullName;
assignmentRulesForMenu[currentRuleName] = "assignmentRules/" + currentRuleName + ".md";
const mdFile = path.join(this.outputMarkdownRoot, "assignmentRules", currentRuleName + ".md");
this.assignmentRulesDescriptions.push({
name: currentRuleName,
active: rule.active,
});
const ruleXml = builder.build({ assignmentRule: rule });
await new DocBuilderAssignmentRules(currentRuleName, ruleXml, mdFile).generateMarkdownFileFromXml();
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
counter++;
WebSocketClient.sendProgressStepMessage(counter, totalRules);
}
}
WebSocketClient.sendProgressEndMessage();
this.addNavNode("Assignment Rules", assignmentRulesForMenu);
await fs.ensureDir(path.join(this.outputMarkdownRoot, "assignmentRules"));
const psgIndexFile = path.join(this.outputMarkdownRoot, "assignmentRules", "index.md");
await fs.writeFile(psgIndexFile, getMetaHideLines() + DocBuilderAssignmentRules.buildIndexTable('', this.assignmentRulesDescriptions).join("\n") + `\n${this.footer}\n`);
}
async generateApprovalProcessDocumentation() {
uxLog("action", this, c.cyan("Preparing generation of Approval Processes documentation... " +
"(if you don't want it, define GENERATE_AUTOMATIONS_DOC=false in your environment variables)"));
const approvalProcessesForMenu = { "All Approval Processes": "approvalProcesses/index.md" };
const approvalProcessFiles = (await glob("**/approvalProcesses/**.approvalProcess-meta.xml", {
cwd: process.cwd(),
ignore: GLOB_IGNORE_PATTERNS
}));
sortCrossPlatform(approvalProcessFiles);
if (approvalProcessFiles.length === 0) {
uxLog("log", this, c.yellow("No approval process found in the project"));
return;
}
WebSocketClient.sendProgressStartMessage("Generating Approval Processes documentation...", approvalProcessFiles.length);
let counter = 0;
for (const approvalProcessFile of approvalProcessFiles) {
const approvalProcessName = path.basename(approvalProcessFile, ".approvalProcess-meta.xml");
const mdFile = path.join(this.outputMarkdownRoot, "approvalProcesses", approvalProcessName + ".md");
approvalProcessesForMenu[approvalProcessName] = "approvalProcesses/" + approvalProcessName + ".md";
const approvalProcessXml = await fs.readFile(approvalProcessFile, "utf8");
const approvalProcessXmlParsed = new XMLParser().parse(approvalProcessXml);
this.approvalProcessesDescriptions.push({
name: approvalProcessName,
active: approvalProcessXmlParsed?.ApprovalProcess?.active,
impactedObjects: this.allObjectsNames.filter(objectName => approvalProcessXml.includes(`${objectName}`))
});
await new DocBuilderApprovalProcess(approvalProcessName, approvalProcessXml, mdFile).generateMarkdownFileFromXml();
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
counter++;
WebSocketClient.sendProgressStepMessage(counter, approvalProcessFiles.length);
}
WebSocketClient.sendProgressEndMessage();
this.addNavNode("Approval Processes", approvalProcessesForMenu);
await fs.ensureDir(path.join(this.outputMarkdownRoot, "approvalProcesses"));
const approvalProcessesIndexFile = path.join(this.outputMarkdownRoot, "approvalProcesses", "index.md");
await fs.writeFile(approvalProcessesIndexFile, getMetaHideLines() + DocBuilderApprovalProcess.buildIndexTable('', this.approvalProcessesDescriptions).join("\n") + `\n\n${this.footer}\n`);
}
async generateAutoResponseRulesDocumentation() {
uxLog("action", this, c.cyan("Preparing generation of AutoResponse Rules documentation... " +
"(if you don't want it, define GENERATE_AUTOMATIONS_DOC=false in your environment variables)"));
const autoResponseRulesForMenu = { "All AutoResponse Rules": "autoResponseRules/index.md" };
const autoResponseRulesFiles = (await glob("**/autoResponseRules/**.autoResponseRules-meta.xml", {
cwd: process.cwd(),
ignore: GLOB_IGNORE_PATTERNS
}));
sortCrossPlatform(autoResponseRulesFiles);
const builder = new XMLBuilder();
// Count total rules for progress tracking
let totalRules = 0;
for (const autoResponseRulesFile of autoResponseRulesFiles) {
const autoResponseRulesXml = await fs.readFile(autoResponseRulesFile, "utf8");
const autoResponseRulesXmlParsed = new XMLParser().parse(autoResponseRulesXml);
let rulesList = autoResponseRulesXmlParsed?.AutoResponseRules?.autoResponseRule || [];
if (!Array.isArray(rulesList)) {
rulesList = [rulesList];
}
totalRules += rulesList.length;
}
if (totalRules === 0) {
uxLog("log", this, c.yellow("No auto-response rules found in the project"));
return;
}
WebSocketClient.sendProgressStartMessage("Generating AutoResponse Rules documentation...", totalRules);
let counter = 0;
for (const autoResponseRulesFile of autoResponseRulesFiles) {
const autoResponseRulesXml = await fs.readFile(autoResponseRulesFile, "utf8");
const autoResponseRulesXmlParsed = new XMLParser().parse(autoResponseRulesXml);
const autoResponseRulesName = path.basename(autoResponseRulesFile, ".autoResponseRules-meta.xml");
// parsing one single XML file with all the AutoResponse Rules per object:
let rulesList = autoResponseRulesXmlParsed?.AutoResponseRules?.autoResponseRule || [];
if (!Array.isArray(rulesList)) {
rulesList = [rulesList];
}
for (const rule of rulesList) {
const currentRuleName = autoResponseRulesName + "." + rule?.fullName;
autoResponseRulesForMenu[currentRuleName] = "autoResponseRules/" + currentRuleName + ".md";
const mdFile = path.join(this.outputMarkdownRoot, "autoResponseRules", currentRuleName + ".md");
this.autoResponseRulesDescriptions.push({
name: currentRuleName,
active: rule.active,
});
const ruleXml = builder.build({ autoResponseRule: rule });
await new DocBuilderAutoResponseRules(currentRuleName, ruleXml, mdFile).generateMarkdownFileFromXml();
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
counter++;
WebSocketClient.sendProgressStepMessage(counter, totalRules);
}
}
WebSocketClient.sendProgressEndMessage();
this.addNavNode("AutoResponse Rules", autoResponseRulesForMenu);
// Wr