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
1,268 lines • 76.6 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 a 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.
Generates markdown files will be written in **docs** folder (except README.md where a link to 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 documentations if your markdown reader doesn't handle MermaidJS syntax, this command could 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'
];
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',
}),
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;
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.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
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(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(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(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 VsCode tab if available
WebSocketClient.requestOpenFile(this.outputMarkdownIndexFile);
return { outputPackageXmlMarkdownFiles: this.outputPackageXmlMarkdownFiles };
}
async generateApexDocumentation() {
uxLog(this, c.cyan("Generating Apex documentation... (if you don't want it, define GENERATE_APEX_DOC=false in your environment variables)"));
const tempDir = await createTempDir();
uxLog(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(this, c.grey(`Generated markdown for Apex classes in ${apexDocFolder}`));
}
catch (e) {
uxLog(this, c.yellow(`Error generating Apex documentation: ${JSON.stringify(e, null, 2)}`));
uxLog(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
const apexForMenu = { "All Apex Classes": "apex/index.md" };
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);
const insertion = `${mermaidClassDiagram}\n\n<!-- Apex description -->\n\n## 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(this, c.grey(`Generated markdown for Apex class ${apexName}`));
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
}
}
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);
}
}
// 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);
}
}
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`);
}
async generatePagesDocumentation() {
const packageDirs = this.project?.getPackageDirectories() || [];
const pageFiles = await listPageFiles(packageDirs);
const pagesForMenu = { "All Lightning pages": "pages/index.md" };
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);
}
}
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(this, c.cyan("Generating 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);
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);
}
}
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(this, c.cyan("Generating 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);
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);
}
}
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(this, c.cyan("Generating 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);
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);
}
}
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(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);
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(this, c.cyan("Generating 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();
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);
}
}
}
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(this, c.cyan("Generating 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);
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);
}
}
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(this, c.cyan("Generating 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();
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);
}
}
}
this.addNavNode("AutoResponse Rules", autoResponseRulesForMenu);
// Write index file for permission set groups folder
await fs.ensureDir(path.join(this.outputMarkdownRoot, "autoResponseRules"));
const psgIndexFile = path.join(this.outputMarkdownRoot, "autoResponseRules", "index.md");
await fs.writeFile(psgIndexFile, getMetaHideLines() + DocBuilderAutoResponseRules.buildIndexTable('', this.autoResponseRulesDescriptions).join("\n") + `\n${this.footer}\n`);
}
async generateEscalationRulesDocumentation() {
uxLog(this, c.cyan("Generating Escalation Rules documentation... " +
"(if you don't want it, define GENERATE_AUTOMATIONS_DOC=false in your environment variables)"));
const escalationRulesForMenu = { "All Escalation Rules": "escalationRules/index.md" };
const escalationRulesFiles = (await glob("**/escalationRules/**.escalationRules-meta.xml", {
cwd: process.cwd(),
ignore: GLOB_IGNORE_PATTERNS
}));
sortCrossPlatform(escalationRulesFiles);
const builder = new XMLBuilder();
for (const escalationRulesFile of escalationRulesFiles) {
const escalationRulesXml = await fs.readFile(escalationRulesFile, "utf8");
const escalationRulesXmlParsed = new XMLParser().parse(escalationRulesXml);
const escalationRulesName = path.basename(escalationRulesFile, ".escalationRules-meta.xml");
// parsing one singe XML file with all the Escalation Rules for Case:
let rulesList = escalationRulesXmlParsed?.EscalationRules?.escalationRule || [];
if (!Array.isArray(rulesList)) {
rulesList = [rulesList];
}
for (const rule of rulesList) {
const currentRuleName = escalationRulesName + "." + rule?.fullName;
escalationRulesForMenu[currentRuleName] = "escalationRules/" + currentRuleName + ".md";
const mdFile = path.join(this.outputMarkdownRoot, "escalationRules", currentRuleName + ".md");
this.escalationRulesDescriptions.push({
name: currentRuleName,
active: rule.active,
});
const ruleXml = builder.build({ escalationRule: rule });
await new DocBuilderEscalationRules(currentRuleName, ruleXml, mdFile).generateMarkdownFileFromXml();
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
}
}
this.addNavNode("Escalation Rules", escalationRulesForMenu);
await fs.ensureDir(path.join(this.outputMarkdownRoot, "escalationRules"));
const psgIndexFile = path.join(this.outputMarkdownRoot, "escalationRules", "index.md");
await fs.writeFile(psgIndexFile, getMetaHideLines() + DocBuilderEscalationRules.buildIndexTable('', this.escalationRulesDescriptions).join("\n") + `\n${this.footer}\n`);
}
async buildMkDocsYml() {
// Copy default files (mkdocs.yml and other files can be updated by the SF Cli plugin developer later)
const mkdocsYmlFile = path.join(process.cwd(), 'mkdocs.yml');
const mkdocsYmlFileExists = fs.existsSync(mkdocsYmlFile);
await fs.copy(path.join(PACKAGE_ROOT_DIR, 'defaults/mkdocs-project-doc', '.'), process.cwd(), { overwrite: false });
if (!mkdocsYmlFileExists) {
uxLog(this, c.blue('Base mkdocs files copied in your Salesforce project repo'));
uxLog(this, c.yellow('You should probably manually update mkdocs.yml to add your own configuration, like theme, site_name, etc.'));
}
// Update mkdocs nav items
const mkdocsYml = readMkDocsFile(mkdocsYmlFile);
for (const navMenu of this.mkDocsNavNodes) {
let pos = 0;
let found = false;
for (const navItem of mkdocsYml.nav) {
if (Object.keys(navItem)[0] === Object.keys(navMenu)[0]) {
found = true;
break;
}
pos++;
}
if (found) {
mkdocsYml.nav[pos] = navMenu;
}
else {
mkdocsYml.nav.push(navMenu);
}
}
// Add missing javascripts if necessary
const allJavascripts = [
"https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.4/jquery.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/jstree/3.3.12/jstree.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/tablesort/5.2.1/tablesort.min.js",
"javascripts/tables.js",
"javascripts/gtag.js",
"javascripts/jstree-handler.js"
];
const extraJavascript = mkdocsYml.extra_javascript || [];
for (const jsItem of allJavascripts) {
if (!extraJavascript.includes(jsItem)) {
extraJavascript.push(jsItem);
}
}
mkdocsYml.extra_javascript = extraJavascript;
// Add missing CSS if necessary
const allCss = [
"https://cdnjs.cloudflare.com/ajax/libs/jstree/3.3.12/themes/default/style.min.css",
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css",
"stylesheets/extra.css",
"stylesheets/jstree-custom.css"
];
const extraCss = mkdocsYml.extra_css || [];
for (const cssItem of allCss) {
if (!extraCss.includes(cssItem)) {
extraCss.push(cssItem);
}
}
mkdocsYml.extra_css = extraCss;
// Add missing plugin config if necessary
if (!mkdocsYml.plugins) {
mkdocsYml.plugins = [
'search',
{
'exclude-search': {
'exclude': [
"index.md",
"cache-ai-results/*.md",
"*package.xml.md",
"package-*items.xml.md"
]
}
}
];
}
// Remove deprecated items if found
mkdocsYml.nav = mkdocsYml.nav.filter(navItem => !navItem["Flows History"]);
mkdocsYml.nav = mkdocsYml.nav.filter(navItem => !navItem["Installed Packages"]);
// Add root menus
const rootSections = [
{ menu: "Automations", subMenus: ["Approval Processes", "Assignment Rules", "AutoResponse Rules", "Escalation Rules", "Flows"] },
{ menu: "Authorizations", subMenus: ["Profiles", "Permission Set Groups", "Permission Sets"] },
{ menu: "Code", subMenus: ["Apex", "Lightning Web Components"] },
];
for (const rootSection of rootSections) {
const navSubmenus = [];
for (const subMenu of rootSection.subMenus) {
// Find submenu
const subMenuContent = mkdocsYml.nav.find(navItem => Object.keys(navItem)[0] === subMenu);
if (subMenuContent) {
navSubmenus.push(subMenuContent);
}
// Remove sub menus from root menus
mkdocsYml.nav = mkdocsYml.nav.filter(navItem => !navItem[subMenu]);
}
// Check if rootSection.menu already exists in nav
const existingRootMenuIndex = mkdocsYml.nav.findIndex(navItem => Object.keys(navItem)[0] === rootSection.menu);
if (existingRootMenuIndex > -1) {
// Append new submenus to existing root menu
const existingSubMenus = mkdocsYml.nav[existingRootMenuIndex][rootSection.menu];
const uniqueSubMenus = new Map();
for (const item of [...existingSubMenus, ...navSubmenus]) {
const key = Object.keys(item)[0];
if (!uniqueSubMenus.has(key) || navSubmenus.some(navItem => Object.keys(navItem)[0] === key)) {
uniqueSubMenus.set(key, item);
}
}
mkdocsYml.nav[existingRootMenuIndex][rootSection.menu] = Array.from(uniqueSubMenus.values()).sort((a, b) => {
const keyA = Object.keys(a)[0].toLowerCase();
const keyB = Object.keys(b)[0].toLowerCase();
return keyA.localeCompare(keyB, 'en', { sensitivity: 'base' });
});
}
else {
// Add root menu with submenus
mkdocsYml.nav.push({ [rootSection.menu]: navSubmenus });
}
}
// Order nav items with this elements in first
const firstItemsInOrder = [
"Home",
// "Object Model",
"Objects",
"Automations",
"Authorizations",
"Code",
"Lightning Pages",
"Packages",
"Roles",
"SFDX-Hardis Config",
"Branches & Orgs",
"Manifests"
];
mkdocsYml.nav = firstItemsInOrder.map(item => mkdocsYml.nav.find(navItem => Object.keys(navItem)[0] === item)).filter(item => item).concat(mkdocsYml.nav.filter(navItem => !firstItemsInOrder.includes(Object.keys(navItem)[0])));
// Update mkdocs file
await writeMkDocsFile(mkdocsYmlFile, mkdocsYml);
uxLog(this, c.cyan(`To generate a HTML WebSite with this documentation with a single command, see instructions at ${CONSTANTS.DOC_URL_ROOT}/hardis/doc/project2markdown/`));
}
async generateObjectsDocumentation() {
uxLog(this, c.cyan("Generating Objects AI documentation... (if you don't want it, define GENERATE_OBJECTS_DOC=false in your environment variables)"));
const objectLinksInfo = await this.generateLinksInfo();
const objectsForMenu = { "All objects": "objects/index.md" };
await fs.ensureDir(path.join(this.outputMarkdownRoot, "objects"));
for (const objectFile of this.objectFiles) {
const objectName = path.basename(objectFile, ".object");
if ((objectName.endsWith("__dlm") || objectName.endsWith("__dll")) && !(process.env?.INCLUDE_DATA_CLOUD_DOC === "true")) {
uxLog(this, c.grey(`Skip Data Cloud Object ${objectName}... (use INCLUDE_DATA_CLOUD_DOC=true to enforce it)`));
continue;
}
uxLog(this, c.cyan(`Generating markdown for Object ${objectName}...`));
const objectXml = (await fs.readFile(path.join(this.tempDir, objectFile), "utf8")).toString();
const objectMdFile = path.join(this.outputMarkdownRoot, "objects", objectName + ".md");
// Build filtered XML
const objectXmlParsed = new XMLParser().parse(objectXml);
// Main AI markdown
await new DocBuilderObject(objectName, objectXml, objectMdFile, {
"ALL_OBJECTS_LIST": this.allObjectsNames.join(","),
"ALL_OBJECT_LINKS": objectLinksInfo
}).generateMarkdownFileFromXml();
// Fields table
await this.buildAttributesTables(objectName, objectXmlParsed, objectMdFile);
// Mermaid schema
const mermaidSchema = await new ObjectModelBuilder(objectName).buildObjectsMermaidSchema();
await replaceInFile(objectMdFile, '<!-- Mermaid schema -->', '## Schema\n\n```mermaid\n' + mermaidSchema + '\n```\n');
if (this.withPdf) {
/** Regenerate using Mermaid CLI to convert Mermaid code into SVG */
await generateMarkdownFileWithMermaid(objectMdFile, objectMdFile, null, true);
}
// Flows Table
const relatedObjectFlowsTable = DocBuilderFlow.buildIndexTable('../flows/', this.flowDescriptions, this.outputMarkdownRoot, objectName);
await replaceInFile(objectMdFile, '<!-- Flows table -->', relatedObjectFlowsTable.join("\n"));
// Apex Table
const relatedApexTable = DocBuilderApex.buildIndexTable('../apex/', this.apexDescriptions, objectName);
await replaceInFile(objectMdFile, '<!-- Apex table -->', relatedApexTable.join("\n"));
// Lightning Pages table
const relatedPages = DocBuilderPage.buildIndexTable('../pages/', this.pageDescriptions, objectName);
await replaceInFile(objectMdFile, '<!-- Pages table -->', relatedPages.join("\n"));
// Add Profiles table
const relatedProfilesTable = DocBuilderProfile.buildIndexTable('../profiles/', this.profileDescriptions, objectName);
await replaceInFile(objectMdFile, '<!-- Profiles table -->', relatedProfilesTable.join("\n"));
// Add Permission Sets table
const relatedPermissionSetsTable = DocBuilderPermissionSet.buildIndexTable('../permissionsets/', this.permissionSetsDescriptions, objectName);
await replaceInFile(objectMdFile, '<!-- PermissionSets table -->', relatedPermissionSetsTable.join("\n"));
// Add Approval Processes table
const relatedApprovalProcessTable = DocBuilderApprovalProcess.buildIndexTable('../approvalProcesses/', this.approvalProcessesDescriptions, objectName);
await replaceInFile(objectMdFile, '<!-- ApprovalProcess table -->', relatedApprovalProcessTable.join("\n"));
// Assignment Rules table
const relatedAssignmentRulesTable = DocBuilderAssignmentRules.buildIndexTable('../assignmentRules/', this.assignmentRulesDescriptions, objectName);
await replaceInFile(objectMdFile, '<!-- AssignmentRules table -->', relatedAssignmentRulesTable.join("\n"));
// AutoResponse Rules table
const relatedAutoResponseRulesTable = DocBuilderAutoResponseRules.buildIndexTable('../autoResponseRules/', this.autoResponseRulesDescriptions, objectName);
await replaceInFile(objectMdFile, '<!-- AutoResponseRules table -->', relatedAutoResponseRulesTable.join("\n"));
// Escalation Rules table
const relatedEscalationRulesTable = DocBuilderEscalationRules.buildIndexTable('../escalationRules/', this.escalationRulesDescriptions, objectName);
await replaceInFile(objectMdFile, '<!-- EscalationRules table -->', relatedEscalationRulesTable.join("\n"));
this.objectDescriptions.push({
name: objectName,
label: objectXmlParsed?.CustomObject?.label || "",
description: objectXmlParsed?.CustomObject?.description || "",
});
objectsForMenu[objectName] = "objects/" + objectName + ".md";
if (this.withPdf) {
await generatePdfFileFromMarkdown(objectMdFile);
}
}
this.addNavNode("Objects", objectsForMenu);
// Write index file for objects folder
await fs.ensureDir(path.join(this.outputMarkdownRoot, "objects"));
const objectsTableLinesForIndex = DocBuilderObject.buildIndexTable('', this.objectDescriptions);
const objectsIndexFile = path.join(this.outputMarkdownRoot, "objects", "index.md");
await fs.writeFile(objectsIndexFile, getMetaHideLines() + objectsTableLinesForIndex.join("\n") + `\n${this.footer}\n`);
}
async buildAttributesTables(objectName, objectXmlParsed, objectMdFile) {
const fieldsTable = DocBuilderObject.buildCustomFieldsTable(objectXmlParsed?.CustomObject?.fields || []);
const validationRulesTable = DocBuilderObject.buildValidationRulesTable(objectXmlParsed?.CustomObject?.validationRules || []);
const attributesLines = [...fieldsTable, ...validationRulesTable];
const attributesMarkdown = await completeAttributesDescriptionWithAi(attributesLines.join("\n"), objectName);
await replaceInFile(objectMdFile, '<!-- Attributes tables -->', attributesMarkdown);
}
async generateLinksInfo() {
uxLog(this, c.cyan("Generate MasterDetail and Lookup infos to provide context to AI prompt"));
const findFieldsPattern = `**/objects/**/fields/**.field-meta.xml`;
const matchingFieldFiles = (await glob(findFieldsPattern, { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS })).map(file => file.replace(/\\/g, '/'));
const customFieldsLinks = [];
for (const fieldFile of matchingFieldFiles) {
const fieldXml = fs.readFileSync(fieldFile, "utf8").toString();
const fieldDetail = new XMLParser().parse(fieldXml);
if (fieldDetail?.CustomField?.type === "MasterDetail" || fieldDetail?.CustomField?.type === "Lookup") {
const fieldName = path.basename(fieldFile, ".field-meta.xml");
const objectName = fieldFile.substring(fieldFile.indexOf('objects/')).split("/")[1];
const linkDescription = `- ${fieldDetail.CustomField.type} field "${fieldName}" defined on object ${objectName}, with target object reference to ${fieldDetail.CustomField.referenceTo} (relationship name: "${fieldDetail.CustomField.relationshipName}", label: "${fieldDetail.CustomField.label}", description: "${fieldDetail.CustomField?.description || ''}")`;
customFieldsLinks.push(linkDescription);
}
}
return customFieldsLinks.join("\n") + "\n";
}
async generateFlowsDocumentation() {
uxLog(this, c.cyan("Generating Flows Visual documentation... (if you don't want it, define GENERATE_FLOW_DOC=false in your environment variables)"));
const flowsForMenu = { "All flows": "flows/index.md" };
await fs.ensureDir(path.join(this.outputMarkdownRoot, "flows"));
const packageDirs = this.project?.getPackageDirectories();
const updatedFlowNames = !this.diffOnly ?
[] :
(await MetadataUtils.listChangedOrFromCurrentCommitFiles()).filter(f => f?.path?.endsWith(".flow-meta.xml")).map(f => path.basename(f.path, ".flow-meta.xml"));
const flowFiles = await listFlowFiles(packageDirs);
const flowErrors = [];
const flowWarnings = [];
const flowSkips = [];
// List flows dependencies
const flowDeps = {};
for (const flowFile of flowFiles) {
const flowName = path.basename(flowFile, ".flow-meta.xml");
const flowXml = (await fs.readFile(flowFile, "utf8")).toString();
// Find all occurences of <flowName>.*</flowName> in flowXml
const regex = /<flowName>(.*?)<\/flowName>/g;
const extractedNames = [...flowXml.matchAll(regex)].map(match => match[1]);
flowDeps[flowName] = extractedNames;
}
// Generate Flows documentation
for (const flowFile of flowFiles) {
const flowName = path.basename(flowFile, ".flow-meta.xml");
const flowXml = (await fs.readFile(flowFile, "utf8")).toString();
const flowContent = await parseXmlFile(flowFile);
this.flowDescriptions.push({
name: flowName,
description: flowContent?.Flow?.description?.[0] || "",
type: flowContent?.Flow?.processType?.[0] === "Flow" ? "ScreenFlow" : flowContent?.Flow?.start?.[0]?.triggerType?.[0] ?? (flowContent?.Flow?.processType?.[0] || "ERROR (Unknown)"),
object: flowContent?.Flow?.start?.[0]?.object?.[0] || flowContent?.Flow?.processMetadataValues?.filter(pmv => pmv.name[0] === "ObjectType")?.[0]?.value?.[0]?.stringValue?.[0] || "",
impactedObjects: this.allObjectsNames.filter(objectName => flowXml.includes(`>${objectName}<`))
});
flowsForMenu[flowName] = "flows/" + flowName + ".md";
const outputFlowMdFile = path.join(this.outputMarkdownRoot, "flows", flowName + ".md");
if (this.diffOnly && !updatedFlowNames.includes(flowName) && fs.existsSync(outputFlowMdFile)) {
flowSkips.push(flowFile);
continue;
}
uxLog(this, c.grey(`Generating markdown for Flow ${flowFile}...`));
const genRes = await generateFlowMarkdownFile(flowName, flowXml, outputFlowMdFile, { collapsedDetails: false, describeWithAi: true, flowDependencies: flowDeps });
if (!genRes) {
flowErrors.push(flowFile);
continue;
}
if (this.debugMode) {
await fs.copyFile(outputFlowMdFile, outputFlowMdFile.replace(".md", ".mermaid.md"));
}
const gen2res = await generateMarkdownFileWithMermaid(outputFlowMdFile, outputFlowMdFile, null, this.withPdf);
if (!gen2res) {
flowWarnings.push(flowFile);
continue;
}
}
this.flowDescriptions = sortArray(this.flowDescriptions, { by: ['object', 'name'], order: ['asc', 'asc'] });
// History
if (this.withHistory) {
uxLog(this, c.cyan("Generating Flows Visual Git Diff History documentation..."));
for (const flowFile of flowFiles) {
const flowName = path.basename(flowFile, ".flow-meta.xml");
const diffMdFile = path.join("docs", "flows", path.basename(flowFile).replace(".flow-meta.xml", "-history.md"));
if (this.diffOnly && !updatedFlowNames.includes(flowName) && fs.existsSync(diffMdFile)) {
continue;
}
try {
await generateHistoryDiffMarkdown(flowFile, this.debugMode);
}
catch (e) {
uxLog(this, c.yellow(`Error generating history diff markdown: ${e.message}`));
}
}
}
// Summary
if (flowSkips.length > 0) {
uxLog(this, c.yellow(`Skipped generation for ${flowSkips.length} Flows that have not been updated: ${this.humanDisplay(flowSkips)}`));
}
uxLog(this, c.green(`Successfully generated ${flowFiles.length - flowSkips.length - flowWarnings.length - flowErrors.length} Flows documentation`));
if (flowWarnings.length > 0) {
uxLog(this, c.yellow(`Partially generated documentation (Markdown with mermaidJs but without SVG) for ${flowWarnings.length} Flows: ${this.humanDisplay(flowWarnings)}`));
}
if (flowErrors.length > 0) {
uxLog(this, c.yellow(`Error generating documentation for ${flowErrors.length} Flows: ${this.humanDisplay(flowErrors)}`));
}
// Write index file for flow folder
await fs.ensureDir(path.join(this.outputMarkdownRoot, "flows"));
const flowTableLinesForIndex = DocBuilderFlow.buildIndexTable('', this.flowDescriptions, this.outputMarkdownRoot);
const flowIndexFile = path.join(this.outputMarkdownRoot, "flows", "index.md");
await fs.writeFile(flowIndexFile, getMetaHideLines() + flowTableLinesForIndex.join("\n") + `\n${this.footer}\n`);
this.addNavNode("Flows", flowsForMenu);
uxLog(this, c.green(`Successfully generated doc index for Flows at ${flowIndexFile}`));
}
humanDisplay(flows) {
return flows.map(flow => path.basename(flow, ".flow-meta.xml")).join(", ");
}
buildSfdxHardisParams() {
const sfdxParamsTableLines = [];
sfdxParamsTableLines.push(...[
`## ${this.sfdxHardisConfig?.projectName?.toUpperCase() || "SFDX Project"} CI/CD configuration`,
""
]);
sfdxParamsTableLines.push(...[
"| Sfdx-hardis Parameter | Value | Description & doc link |",
"| :--------- | :---- | :---------- |"
]);
const installPackagesDuringCheckDeploy = this.sfdxHardisConfig?.installPackagesDuringCheckDeploy ?? false;
sfdxParamsTableLines.push(`| Install Packages During Check Deploy | ${bool2emoji(installPackagesDuringCheckDeploy)} | [Install 1GP & 2GP packages during deployment check CI/CD job](https://sfdx-hardis.cloudity.com/hardis/project/deploy/smart/#packages-installation) |`);
const useDeltaDeployment = this.sfdxHardisConfig?.useDeltaDeployment ?? false;
sfdxParamsTableLines.push(`| Use Delta Deployment | ${bool2emoji(useDeltaDeployment)} | [Deploys only updated metadatas , only when a MR/PR is from a minor branch to a major branch](https://sfdx-hardis.cloudity.com/salesforce-ci-cd-config-delta-deployment/#delta-mode) |`);
const useSmartDeploymentTests = this.sfdxHardisConfig?.useSmartDeploymentTests ?? false;
sfdxParamsTableLines.push(`| Use Smart Deployment Tests | ${bool2emoji(useSmartDeploymentTests)} | [Skip Apex test cases if delta metadatas can not impact them, only when a MR/PR is from a minor branch to a major branch](https://sfdx-hardis.cloudity.com/hardis/project/deploy/smart/#smart-deployments-tests) |`);
sfdxParamsTableLines.push("");
sfdxParamsTableLines.push("___");
sfdxParamsTableLines.push("");
return sfdxParamsTableLines;
}
async buildMajorBranchesAndOrgs() {
const branchesOrgsLines = [];
const majorOrgs = await listMajorOrgs();
if (majorOrgs.length > 0) {
branchesOrgsLines.push(...[
"## Branches & Orgs strategy",
"",
]);
const mermaidLines = new BranchStrategyMermaidBuilder(majorOrgs).build({ withMermaidTag: true, format: "list" });
branchesOrgsLines.push(...mermaidLines);
branchesOrgsLines.push(...[
"",
"| Git branch | Salesforce Org | Deployment Username |",
"| :--------- | :------------- | :------------------ |"
]);
for (const majorOrg of majorOrgs) {
const majorOrgLine = `| ${majorOrg.branchName} | ${majorOrg.instanceUrl} | ${majorOrg.targetUsername} |`;
branchesOrgsLines.push(majorOrgLine);
}
branchesOrgsLines.push("");
branchesOrgsLines.push("___");
branchesOrgsLines.push("");
}
return branchesOrgsLines;
}
async manageLocalPackages() {
const packageDirs = this.project?.getPackageDirectories();
if (!(packageDirs?.length === 1 && packageDirs[0].name === "force-app" && fs.existsSync("manifest/package.xml"))) {
for (const packageDir of packageDirs || []) {
// Generate manifest from package folder
const packageManifestFile = path.join("manifest", packageDir.name + '-package.xml');
await fs.ensureDir(path.dirname(packageManifestFile));
try {
await execSfdxJson("sf project generate manifest" +
` --source-dir ${packageDir.path}` +
` --name ${packageManifestFile}`, this, {
fail: true,
output: true,
debug: this.debugMode,
});
// Add package in available packages list
this.packageXmlCandidates.push({
path: packageManifestFile,
name: packageDir.name,
description: `Package.xml generated from content of SFDX package ${packageDir.name} (folder ${packageDir.path})`
});
}
catch (e) {
uxLog(this, c.red(`Unable to generate manifest from ${packageDir.path}: it won't appear in the documentation\n${e.message}`));
}
}
}
}
addNavNode(nodeName, nodeValue) {
const nodeIndex = this.mkDocsNavNodes.findIndex(navNode => Object.keys(navNode)[0] === nodeName);
if (nodeIndex > -1) {
this.mkDocsNavNodes[nodeIndex][nodeName] = nodeValue;
}
else {
const nodeMenu = {};
nodeMenu[nodeName] = nodeValue;
this.mkDocsNavNodes.push(nodeMenu);
}
}
async generatePackageXmlMarkdown(packageXmlCandidates, instanceUrl) {
// Generate packageXml doc when found
for (const packageXmlCandidate of packageXmlCandidates) {
if (fs.existsSync(packageXmlCandidate.path)) {
// Generate markdown for package.xml
const packageMarkdownFile = await DocBuilderPackageXML.generatePackageXmlMarkdown(packageXmlCandidate.path, null, packageXmlCandidate, instanceUrl);
// Open file in a new VsCode tab if available
WebSocketClient.requestOpenFile(packageMarkdownFile);
packageXmlCandidate.markdownFile = packageMarkdownFile;
this.outputPackageXmlMarkdownFiles.push(packageXmlCandidate);
}
}
}
async generateLwcDocumentation() {
uxLog(this, c.cyan("Generating Lightning Web Components documentation... " +
"(if you don't want it, define GENERATE_LWC_DOC=false in your environment variables)"));
const lwcForMenu = { "All Lightning Web Components": "lwc/index.md" };
await fs.ensureDir(path.join(this.outputMarkdownRoot, "lwc"));
const packageDirs = this.project?.getPackageDirectories() || [];
// Find all LWC components in all package directories
for (const packageDir of packageDirs) {
// Find LWC components (directories with .js-meta.xml files)
const lwcMetaFiles = await glob(`${packageDir.path}/**/lwc/**/*.js-meta.xml`, {
cwd: process.cwd(),
ignore: GLOB_IGNORE_PATTERNS
});
for (const lwcMetaFile of lwcMetaFiles) {
const lwcDirPath = path.dirname(lwcMetaFile);
const lwcName = path.basename(lwcDirPath);
const mdFile = path.join(this.outputMarkdownRoot, "lwc", lwcName + ".md");
lwcForMenu[lwcName] = "lwc/" + lwcName + ".md";
// Read XML metadata for information about the component
const lwcMetaXml = await fs.readFile(lwcMetaFile, "utf8");
const lwcMetaXmlParsed = new XMLParser().parse(lwcMetaXml);
// Read JS file to get a better idea of what objects this component works with
const jsFile = path.join(lwcDirPath, `${lwcName}.js`);
let jsContent = "none";
if (fs.existsSync(jsFile)) {
jsContent = await fs.readFile(jsFile, "utf8");
}
// Read HTML template file
const htmlFile = path.join(lwcDirPath, `${lwcName}.html`);
let htmlContent = "none";
if (fs.existsSync(htmlFile)) {
htmlContent = await fs.readFile(htmlFile, "utf8");
}
// Track this LWC in our descriptions array
this.lwcDescriptions.push({
name: lwcName,
description: lwcMetaXmlParsed?.LightningComponentBundle?.description ||
lwcMetaXmlParsed?.LightningComponentBundle?.masterLabel || "",
targets: Array.isArray(lwcMetaXmlParsed?.LightningComponentBundle?.targets?.target)
? lwcMetaXmlParsed?.LightningComponentBundle?.targets?.target.join(", ")
: lwcMetaXmlParsed?.LightningComponentBundle?.targets?.target || "",
isExposed: lwcMetaXmlParsed?.LightningComponentBundle?.isExposed,
impactedObjects: this.allObjectsNames.filter(objectName => lwcMetaXml.includes(`${objectName}`) ||
jsContent.includes(`${objectName}`))
});
// Generate the documentation file
await new DocBuilderLwc(lwcName, "", mdFile, {
LWC_PATH: lwcDirPath,
LWC_NAME: lwcName,
LWC_JS_CODE: jsContent,
LWC_HTML_CODE: htmlContent,
LWC_JS_META: lwcMetaXml
}).generateMarkdownFileFromXml();
if (this.withPdf) {
await generatePdfFileFromMarkdown(mdFile);
}
}
}
this.addNavNode("Lightning Web Components", lwcForMenu);
// Write index file for LWC folder
await fs.ensureDir(path.join(this.outputMarkdownRoot, "lwc"));
const lwcIndexFile = path.join(this.outputMarkdownRoot, "lwc", "index.md");
await fs.writeFile(lwcIndexFile, getMetaHideLines() +
DocBuilderLwc.buildIndexTable('', this.lwcDescriptions).join("\n") +
`\n\n${this.footer}\n`);
uxLog(this, c.green(`Successfully generated documentation for Lightning Web Components at ${lwcIndexFile}`));
}
}
//# sourceMappingURL=project2markdown.js.map