UNPKG

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
/* 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) ![Screenshot flow doc](https://github.com/hardisgroupcom/sfdx-hardis/raw/main/docs/assets/images/screenshot-flow-doc.jpg) ![Screenshot project documentation](https://github.com/hardisgroupcom/sfdx-hardis/raw/main/docs/assets/images/screenshot-project-doc.jpg) ![Screenshot project documentation](https://github.com/hardisgroupcom/sfdx-hardis/raw/main/docs/assets/images/screenshot-project-doc-2.jpg) ![Screenshot project documentation](https://github.com/hardisgroupcom/sfdx-hardis/raw/main/docs/assets/images/screenshot-object-diagram.jpg) ![Screenshot project documentation](https://github.com/hardisgroupcom/sfdx-hardis/raw/main/docs/assets/images/screenshot-project-doc-profile.gif) ![Screenshot project documentation](https://github.com/hardisgroupcom/sfdx-hardis/raw/main/docs/assets/images/screenshot-doc-apex.png) If it is a sfdx-hardis CI/CD project, a diagram of the branches and orgs strategy will be generated. ![](https://github.com/hardisgroupcom/sfdx-hardis/raw/main/docs/assets/images/screenshot-doc-branches-strategy.jpg) 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