diff --git a/.gitignore b/.gitignore index 91b4187d..acf3e601 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ package.json.bak. /tmp /test/tmp +/test/tmp-md diff --git a/messages/main.md b/messages/main.md index 60b9bd1c..507115e9 100644 --- a/messages/main.md +++ b/messages/main.md @@ -28,7 +28,11 @@ fail the command if there are any warnings # flags.ditamap-suffix.summary -unique suffix to append to generated ditamap +unique suffix to append to generated DITA files + +# flags.output-format.summary + +output format for generated documentation; 'dita' (default) generates DITA XML files, 'markdown' generates Markdown files # flags.config-path.summary diff --git a/package.json b/package.json index bc553255..d3edace0 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,8 @@ ], "output": [], "dependencies": [ - "test:command-reference" + "test:command-reference", + "test:command-reference-markdown" ] }, "test:command-reference": { @@ -196,6 +197,17 @@ "messages/**/*.md" ], "output": [] + }, + "test:command-reference-markdown": { + "command": "node --loader ts-node/esm --no-warnings=ExperimentalWarning \"./bin/dev.js\" commandreference generate --plugins auth --plugins user --output-format markdown --output-dir test/tmp-md", + "files": [ + "src/**/*.ts", + "messages/**", + "package.json" + ], + "output": [ + "test/tmp-md" + ] } } } diff --git a/src/commands/commandreference/generate.ts b/src/commands/commandreference/generate.ts index af882482..341aca0a 100644 --- a/src/commands/commandreference/generate.ts +++ b/src/commands/commandreference/generate.ts @@ -73,6 +73,11 @@ export default class CommandReferenceGenerate extends SfCommand(), + cliMeta + ); events.on('topic', ({ topic }: { topic: string }) => { this.log(chalk.green(`Generating topic '${topic}'`)); diff --git a/src/ditamap/command-helpers.ts b/src/ditamap/command-helpers.ts new file mode 100644 index 00000000..2f0989df --- /dev/null +++ b/src/ditamap/command-helpers.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Dictionary, Optional } from '@salesforce/ts-types'; +import { CommandParameterData, replaceConfigVariables } from '../utils.js'; + +export type FlagInfo = { + hidden: boolean; + description: string; + summary: string; + required: boolean; + kind: string; + type: string; + defaultHelpValue?: string; + default: string | (() => Promise); + aliases?: string[]; + options?: string[]; + char?: string; + deprecated?: { version: string; to: string }; +}; + +export const getDefault = async (flag: FlagInfo, flagName: string): Promise => { + if (!flag) { + return ''; + } + if (flagName === 'target-org' || flagName === 'target-dev-hub') { + return ''; + } + if (typeof flag.default === 'function') { + try { + const help = await flag.default(); + return help.includes('[object Object]') ? '' : help ?? ''; + } catch { + return ''; + } + } else { + return flag.default; + } +}; + +export const flagIsDefined = (input: [string, Optional]): input is [string, FlagInfo] => + input[1] !== undefined; + +export const buildDescription = + (commandName: string) => + (binary: string) => + (flag: FlagInfo): string[] => { + const description = replaceConfigVariables( + Array.isArray(flag?.description) ? flag?.description.join('\n') : flag?.description ?? '', + binary, + commandName + ); + return formatParagraphs( + flag.summary ? `${replaceConfigVariables(flag.summary, binary, commandName)}\n${description}` : description + ); + }; + +export const formatParagraphs = (textToFormat?: string): string[] => + textToFormat ? textToFormat.split('\n').filter((n) => n !== '') : []; + +export const readBinary = (commandMeta: Record): string => + 'binary' in commandMeta && typeof commandMeta.binary === 'string' ? commandMeta.binary : 'unknown'; + +export const buildCommandParameters = async ( + commandName: string, + binary: string, + flags: Dictionary +): Promise => { + const descriptionBuilder = buildDescription(commandName)(binary); + return Promise.all( + [...Object.entries(flags)] + .filter(flagIsDefined) + .filter(([, flag]) => !flag.hidden) + .map( + async ([flagName, flag]) => + ({ + ...flag, + name: flagName, + description: descriptionBuilder(flag), + optional: !flag.required, + kind: flag.kind ?? flag.type, + hasValue: flag.type !== 'boolean', + defaultFlagValue: await getDefault(flag, flagName), + } satisfies CommandParameterData) + ) + ); +}; diff --git a/src/ditamap/command.ts b/src/ditamap/command.ts index a021c518..210c211a 100644 --- a/src/ditamap/command.ts +++ b/src/ditamap/command.ts @@ -15,40 +15,10 @@ */ import { join } from 'node:path'; -import { asString, Dictionary, ensureObject, ensureString, Optional } from '@salesforce/ts-types'; -import { CommandClass, CommandData, CommandParameterData, punctuate, replaceConfigVariables } from '../utils.js'; +import { asString, Dictionary, ensureObject, ensureString } from '@salesforce/ts-types'; +import { CommandClass, CommandData, punctuate, replaceConfigVariables } from '../utils.js'; import { Ditamap } from './ditamap.js'; - -type FlagInfo = { - hidden: boolean; - description: string; - summary: string; - required: boolean; - kind: string; - type: string; - defaultHelpValue?: string; - default: string | (() => Promise); -}; - -const getDefault = async (flag: FlagInfo, flagName: string): Promise => { - if (!flag) { - return ''; - } - if (flagName === 'target-org' || flagName === 'target-dev-hub') { - // special handling to prevent global/local default usernames from appearing in the docs, but they do appear in user's help - return ''; - } - if (typeof flag.default === 'function') { - try { - const help = await flag.default(); - return help.includes('[object Object]') ? '' : help ?? ''; - } catch { - return ''; - } - } else { - return flag.default; - } -}; +import { buildCommandParameters, FlagInfo, readBinary, formatParagraphs } from './command-helpers.js'; export class Command extends Ditamap { private flags: Dictionary; @@ -131,57 +101,14 @@ export class Command extends Ditamap { this.destination = join(Ditamap.outputDir, topic, filename); } - public async getParametersForTemplate(flags: Dictionary): Promise { - const descriptionBuilder = buildDescription(this.commandName)(readBinary(this.commandMeta)); - return Promise.all( - [...Object.entries(flags)] - .filter(flagIsDefined) - .filter(([, flag]) => !flag.hidden) - .map( - async ([flagName, flag]) => - ({ - ...flag, - name: flagName, - description: descriptionBuilder(flag), - optional: !flag.required, - kind: flag.kind ?? flag.type, - hasValue: flag.type !== 'boolean', - defaultFlagValue: await getDefault(flag, flagName), - } satisfies CommandParameterData) - ) - ); - } - // eslint-disable-next-line class-methods-use-this public getTemplateFileName(): string { return 'command.hbs'; } protected async transformToDitamap(): Promise { - const parameters = await this.getParametersForTemplate(this.flags); + const parameters = await buildCommandParameters(this.commandName, readBinary(this.commandMeta), this.flags); this.data = Object.assign({}, this.data, { parameters }); return super.transformToDitamap(); } } - -const flagIsDefined = (input: [string, Optional]): input is [string, FlagInfo] => input[1] !== undefined; - -const buildDescription = - (commandName: string) => - (binary: string) => - (flag: FlagInfo): string[] => { - const description = replaceConfigVariables( - Array.isArray(flag?.description) ? flag?.description.join('\n') : flag?.description ?? '', - binary, - commandName - ); - return formatParagraphs( - flag.summary ? `${replaceConfigVariables(flag.summary, binary, commandName)}\n${description}` : description - ); - }; - -const formatParagraphs = (textToFormat?: string): string[] => - textToFormat ? textToFormat.split('\n').filter((n) => n !== '') : []; - -const readBinary = (commandMeta: Record): string => - 'binary' in commandMeta && typeof commandMeta.binary === 'string' ? commandMeta.binary : 'unknown'; diff --git a/src/docs.ts b/src/docs.ts index ed86b64f..c48cac0a 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -17,13 +17,14 @@ import fs from 'node:fs/promises'; import { AnyJson, ensureString } from '@salesforce/ts-types'; import chalk from 'chalk'; -import { BaseDitamap } from './ditamap/base-ditamap.js'; -import { CLIReference } from './ditamap/cli-reference.js'; -import { Command } from './ditamap/command.js'; -import { TopicCommands } from './ditamap/topic-commands.js'; -import { TopicDitamap } from './ditamap/topic-ditamap.js'; import { CliMeta, events, punctuate, SfTopic, SfTopics, CommandClass } from './utils.js'; -import { HelpReference } from './ditamap/help-reference.js'; +import { + DitaGeneratorFactory, + GeneratorFactory, + MarkdownGeneratorFactory, + OutputFormat, + TocTopicEntry, +} from './generator-factory.js'; type TopicsByTopicsByTopLevel = Map>; @@ -37,12 +38,17 @@ function emitNoTopicMetadataWarning(topic: string): void { } export class Docs { + private factory: GeneratorFactory; + public constructor( private outputDir: string, + outputFormat: OutputFormat, private hidden: boolean, private topicMeta: SfTopics, private cliMeta: CliMeta - ) {} + ) { + this.factory = outputFormat === 'markdown' ? new MarkdownGeneratorFactory(outputDir) : new DitaGeneratorFactory(); + } public async build(commands: CommandClass[]): Promise { // Create if doesn't exist @@ -77,13 +83,6 @@ export class Docs { for (const [subtopic, classes] of subtopics.entries()) { try { - // const subTopicsMeta = topicMeta.subtopics; - - // if (!subTopicsMeta?.get(subtopic)) { - // emitNoTopicMetadataWarning(`${topic}:${subtopic}`); - // continue; - // } - subTopicNames.push(subtopic); // Commands within the sub topic @@ -110,8 +109,9 @@ export class Docs { // The topic ditamap with all of the subtopic links. events.emit('subtopics', topic, subTopicNames); - await new TopicCommands(topic, topicMeta).write(); - await new TopicDitamap(topic, commandIds).write(); + const topicCommands = this.factory.createTopicCommands(topic, topicMeta); + if (topicCommands) await topicCommands.write(); + await this.factory.createTopicIndex(topic, commandIds, topicMeta).write(); return subTopicNames; } @@ -123,7 +123,6 @@ export class Docs { * @returns The commands grouped by topics/subtopic/commands. */ private groupTopicsAndSubtopics(commands: CommandClass[]): TopicsByTopicsByTopLevel { - // const topLevelTopics: Dictionary> = {}; const topLevelTopics = new Map>(); for (const command of commands) { @@ -135,17 +134,14 @@ export class Docs { const plugin = command.plugin; if (plugin) { - // Also include the namespace on the commands so we don't need to do the split at other times in the code. command.topic = topLevelTopic; const existingTopicsForTopLevel = topLevelTopics.get(topLevelTopic) ?? new Map(); if (commandParts.length === 1) { - // This is a top-level topic that is also a command const existingTarget = existingTopicsForTopLevel.get(commandParts[0]) ?? []; existingTopicsForTopLevel.set(commandParts[0], [...existingTarget, command]); } else if (commandParts.length === 2) { - // This is a command directly under the top-level topic const existingTarget = existingTopicsForTopLevel.get(commandParts[1]) ?? []; existingTopicsForTopLevel.set(commandParts[1], [...existingTarget, command]); } else { @@ -177,17 +173,28 @@ export class Docs { private async populateTemplate(commands: CommandClass[]): Promise { const topicsAndSubtopics = this.groupTopicsAndSubtopics(commands); - await new CLIReference().write(); - await new HelpReference().write(); + await this.factory.createCliReference(Array.from(topicsAndSubtopics.keys())).write(); + + const helpReference = this.factory.createHelpReference(); + if (helpReference) await helpReference.write(); - // Generate one base file with all top-level topics. - await new BaseDitamap(Array.from(topicsAndSubtopics.keys())).write(); + const rootIndex = this.factory.createRootIndex(Array.from(topicsAndSubtopics.keys())); + if (rootIndex) await rootIndex.write(); + const tocEntries: TocTopicEntry[] = []; for (const [topic, subtopics] of topicsAndSubtopics.entries()) { events.emit('topic', { topic }); // eslint-disable-next-line no-await-in-loop await this.populateTopic(topic, subtopics); + const commandIds = [...subtopics.values()] + .flat() + .filter((cmd) => !cmd.hidden || this.hidden) + .map((cmd) => ({ id: ensureString(cmd.id), state: cmd.state, deprecated: cmd.deprecated ?? false })); + tocEntries.push({ topic, commandIds }); } + + const toc = this.factory.createToc(tocEntries); + if (toc) await toc.write(); } private resolveCommandMeta( @@ -240,8 +247,8 @@ export class Docs { return ''; } - const commandDitamap = new Command(topic, subtopic, command, commandMeta); - await commandDitamap.write(); - return commandDitamap.getFilename(); + const commandGenerator = this.factory.createCommand(topic, subtopic, command, commandMeta); + await commandGenerator.write(); + return commandGenerator.getFilename(); } } diff --git a/src/generator-factory.ts b/src/generator-factory.ts new file mode 100644 index 00000000..056808e0 --- /dev/null +++ b/src/generator-factory.ts @@ -0,0 +1,136 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BaseDitamap } from './ditamap/base-ditamap.js'; +import { CLIReference } from './ditamap/cli-reference.js'; +import { Command as DitaCommand } from './ditamap/command.js'; +import { Ditamap } from './ditamap/ditamap.js'; +import { HelpReference } from './ditamap/help-reference.js'; +import { TopicCommands } from './ditamap/topic-commands.js'; +import { TopicDitamap } from './ditamap/topic-ditamap.js'; +import { MarkdownCliReference } from './markdown/cli-reference.js'; +import { MarkdownCommand } from './markdown/command.js'; +import { MarkdownTopicIndex } from './markdown/topic-index.js'; +import { MarkdownToc } from './markdown/toc.js'; +import { CommandClass, SfTopic } from './utils.js'; + +export type OutputFormat = 'dita' | 'markdown'; + +type Writable = { write(): Promise }; +type WritableWithFilename = Writable & { getFilename(): string }; + +export type TocTopicEntry = { + topic: string; + commandIds: Array<{ id: string; state?: string; deprecated?: boolean }>; +}; + +export type GeneratorFactory = { + createCliReference(topics: string[]): Writable; + createHelpReference(): Writable | null; + createRootIndex(topics: string[]): Writable | null; + createToc(topicEntries: TocTopicEntry[]): Writable | null; + createTopicCommands(topic: string, topicMeta: SfTopic): Writable | null; + createTopicIndex(topic: string, commandIds: string[], topicMeta: SfTopic): Writable; + createCommand( + topic: string, + subtopic: string | null, + command: CommandClass, + commandMeta: Record + ): WritableWithFilename; +}; + +export class DitaGeneratorFactory implements GeneratorFactory { + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars + public createCliReference(_topics: string[]): Writable { + return new CLIReference(); + } + + // eslint-disable-next-line class-methods-use-this + public createHelpReference(): Writable { + return new HelpReference(); + } + + // eslint-disable-next-line class-methods-use-this + public createRootIndex(topics: string[]): Writable { + return new BaseDitamap(topics); + } + + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars + public createToc(_topicEntries: TocTopicEntry[]): null { + return null; + } + + // eslint-disable-next-line class-methods-use-this + public createTopicCommands(topic: string, topicMeta: SfTopic): Writable { + return new TopicCommands(topic, topicMeta); + } + + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars + public createTopicIndex(topic: string, commandIds: string[], _topicMeta: SfTopic): Writable { + return new TopicDitamap(topic, commandIds); + } + + // eslint-disable-next-line class-methods-use-this + public createCommand( + topic: string, + subtopic: string | null, + command: CommandClass, + commandMeta: Record + ): WritableWithFilename { + return new DitaCommand(topic, subtopic, command, commandMeta); + } +} + +export class MarkdownGeneratorFactory implements GeneratorFactory { + public constructor(private outputDir: string) {} + + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars + public createCliReference(_topics: string[]): Writable { + return new MarkdownCliReference(Ditamap.cliVersion, Ditamap.pluginVersions, this.outputDir); + } + + // eslint-disable-next-line class-methods-use-this + public createHelpReference(): null { + return null; + } + + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars + public createRootIndex(_topics: string[]): null { + return null; + } + + public createToc(topicEntries: TocTopicEntry[]): Writable { + return new MarkdownToc(topicEntries, this.outputDir); + } + + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars + public createTopicCommands(_topic: string, _topicMeta: SfTopic): null { + return null; + } + + public createTopicIndex(topic: string, commandIds: string[], topicMeta: SfTopic): Writable { + return new MarkdownTopicIndex(topic, commandIds, topicMeta, this.outputDir); + } + + public createCommand( + topic: string, + subtopic: string | null, + command: CommandClass, + commandMeta: Record + ): WritableWithFilename { + return new MarkdownCommand(topic, subtopic, command, commandMeta, this.outputDir); + } +} diff --git a/src/markdown/cli-reference.ts b/src/markdown/cli-reference.ts new file mode 100644 index 00000000..40823995 --- /dev/null +++ b/src/markdown/cli-reference.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MarkdownBase } from './markdown-base.js'; + +export class MarkdownCliReference extends MarkdownBase { + public constructor( + private cliVersion: string, + private pluginVersions: Array<{ name: string; version: string }>, + outputDir: string + ) { + super(MarkdownBase.file('cli_reference'), outputDir); + } + + protected generate(): Promise { + const lines: string[] = []; + lines.push('# Salesforce CLI Command Reference'); + lines.push(''); + lines.push( + 'This command reference contains information about the Salesforce CLI commands and their flags. Use these commands to build Agentforce agents, manage Salesforce DX projects, create and manage scratch orgs and sandboxes, synchronize source to and from orgs, create and install packages, and more.' + ); + lines.push(''); + lines.push(`Salesforce CLI version: \`${this.cliVersion}\``); + lines.push(''); + if (this.pluginVersions.length > 0) { + lines.push('## Plugin Versions'); + lines.push(''); + lines.push(''); + lines.push('| Plugin | Version |'); + lines.push('|--------|---------|'); + for (const { name, version } of this.pluginVersions) { + lines.push(`| \`${name}\` | \`${version}\` |`); + } + lines.push(''); + lines.push(''); + lines.push(''); + } + return Promise.resolve(lines.join('\n')); + } +} diff --git a/src/markdown/command.ts b/src/markdown/command.ts new file mode 100644 index 00000000..0fb2a94a --- /dev/null +++ b/src/markdown/command.ts @@ -0,0 +1,273 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { join } from 'node:path'; +import { asString, Dictionary, ensureObject, ensureString } from '@salesforce/ts-types'; +import { CommandClass, CommandParameterData, punctuate, replaceConfigVariables } from '../utils.js'; +import { buildCommandParameters, FlagInfo, formatParagraphs, readBinary } from '../ditamap/command-helpers.js'; +import { MarkdownBase } from './markdown-base.js'; + +type ParsedExample = { + description: string; + commands: string[]; +}; + +export class MarkdownCommand extends MarkdownBase { + private flags: Dictionary; + private commandMeta: Record; + private commandName: string; + private summary: string | undefined; + private help: string[]; + private examples: ParsedExample[]; + private state: unknown; + private deprecated: boolean; + private deprecationDetails: { version?: string; to?: string } | null; + + public constructor( + topic: string, + subtopic: string | null, + command: CommandClass, + commandMeta: Record = {}, + outputDir: string + ) { + const commandWithUnderscores = ensureString(command.id).replace(/:/g, '_'); + // If the command ID has no subtopic (e.g. "doctor"), its filename would collide with the topic + // index file (cli_reference_doctor.md), so append _command to disambiguate. + const isTopicLevelCommand = !ensureString(command.id).includes(':'); + const baseName = isTopicLevelCommand + ? `cli_reference_${commandWithUnderscores}_command` + : `cli_reference_${commandWithUnderscores}`; + const filename = MarkdownBase.file(baseName); + super(filename, outputDir); + this.destination = join(outputDir, topic, filename); + + this.flags = ensureObject(command.flags); + this.commandMeta = commandMeta; + const binary = readBinary(this.commandMeta); + + this.summary = punctuate(command.summary); + // commandName is the bare command ID used for template variable replacement (e.g. "agent activate") + // commandNameForDisplay is the full invocation shown in headings (e.g. "sf agent activate") + this.commandName = command.id.replace(/:/g, asString(this.commandMeta.topicSeparator, ' ')); + + const description = command.description + ? replaceConfigVariables(command.description, binary, this.commandName) + : undefined; + + this.help = formatParagraphs(description); + + this.examples = (command.examples ?? []).map((example) => { + let desc: string | null; + let commands: string[]; + if (typeof example === 'string') { + const parts = example.split('\n'); + desc = parts.length > 1 ? parts[0] : null; + commands = parts.length > 1 ? parts.slice(1) : [parts[0]]; + } else { + desc = example.description; + commands = [example.command]; + } + return { + description: replaceConfigVariables(desc ?? '', binary, this.commandName), + commands: commands.map((cmd) => replaceConfigVariables(cmd, binary, this.commandName)), + }; + }); + + this.state = command.state ?? this.commandMeta.state; + this.deprecated = (command.deprecated as boolean) ?? this.state === 'deprecated' ?? false; + const dep = command.deprecated; + this.deprecationDetails = dep && typeof dep === 'object' ? (dep as { version?: string; to?: string }) : null; + } + + protected async generate(): Promise { + const binary = readBinary(this.commandMeta); + const parameters = await buildCommandParameters(this.commandName, binary, this.flags); + + const lines: string[] = []; + + const stateLabel = resolveStateLabel(this.state, this.deprecated); + lines.push(`# ${this.commandName}${stateLabel ? ` (${stateLabel})` : ''}`); + lines.push(''); + + if (this.summary) { + lines.push(this.summary); + lines.push(''); + } + + const disclaimer = resolveDisclaimer(this.commandName, this.state, this.deprecated, this.deprecationDetails); + if (disclaimer) { + lines.push(':::note'); + lines.push(disclaimer); + lines.push(':::'); + lines.push(''); + } + + if (this.help.length > 0) { + lines.push(`## Description for ${this.commandName}`); + lines.push(''); + for (const paragraph of convertHyphenListsToMarkdown( + this.help.map((p) => applyCodeFormatting(escapeAngleBrackets(p))) + )) { + lines.push(paragraph); + lines.push(''); + } + } + + if (this.examples.length > 0) { + lines.push(`## Examples for ${this.commandName}`); + lines.push(''); + for (const example of this.examples) { + if (example.description) { + lines.push(example.description); + lines.push(''); + } + for (const cmd of example.commands) { + lines.push('```shell'); + lines.push(cmd.trim().replace(/^\$\s*/, '')); + lines.push('```'); + lines.push(''); + } + } + } + + if (parameters.length > 0) { + lines.push('## Flags'); + lines.push(''); + lines.push(''); + lines.push('| Flag | Description |'); + lines.push('|----------------------------------------|-------------|'); + for (const param of parameters) { + lines.push(renderFlagRow(param)); + } + lines.push(''); + lines.push(''); + lines.push(''); + } + + return lines.join('\n'); + } +} + +function escapeAngleBrackets(text: string): string { + return text.replace(//g, '>'); +} + +function applyCodeFormatting(text: string): string { + // Wrap --flag-name tokens (not already in backticks) + let result = text.replace(/(? for table cell rendering + const items: string[] = []; + while (i < paragraphs.length && paragraphs[i].startsWith('- ')) { + items.push(paragraphs[i]); + i++; + } + result.push(items.join('
')); + } else { + result.push(paragraphs[i]); + i++; + } + } + return result; +} + +function resolveStateLabel(state: unknown, deprecated: boolean): string | null { + if (deprecated) return 'Deprecated'; + if (state === 'beta') return 'Beta'; + if (state === 'preview') return 'Developer Preview'; + if (state === 'closedPilot' || state === 'openPilot') return 'Pilot'; + return null; +} + +function resolveDisclaimer( + commandName: string, + state: unknown, + deprecated: boolean, + deprecationDetails: { version?: string; to?: string } | null +): string | null { + if (deprecated) { + const versionNote = deprecationDetails?.version + ? ` and will be removed in v${deprecationDetails.version} or later` + : ''; + const toNote = deprecationDetails?.to ? ` Use \`${deprecationDetails.to}\` instead.` : ''; + return `The command \`${commandName}\` has been deprecated${versionNote}.${toNote}`; + } + if (state === 'closedPilot') { + // prettier-ignore + return `We provide the \`${commandName}\` command to selected customers through an invitation-only pilot program that requires agreement to specific terms and conditions. Pilot programs are subject to change, and we can\x27t guarantee acceptance. The \`${commandName}\` command isn\x27t generally available unless or until Salesforce announces its general availability in documentation or in press releases or public statements. We can\x27t guarantee general availability within any particular time frame or at all. Make your purchase decisions only on the basis of generally available products and features.`; + } + if (state === 'openPilot') { + // prettier-ignore + return `We provide the \`${commandName}\` command to selected customers through a pilot program that requires agreement to specific terms and conditions. To be nominated to participate in the program, contact Salesforce. Pilot programs are subject to change, and we can\x27t guarantee acceptance. The \`${commandName}\` command isn\x27t generally available unless or until Salesforce announces its general availability in documentation or in press releases or public statements. We can\x27t guarantee general availability within any particular time frame or at all. Make your purchase decisions only on the basis of generally available products and features.`; + } + if (state === 'beta') { + return 'This feature is a Beta Service. Customers may opt to try such Beta Service in its sole discretion. Any use of the Beta Service is subject to the applicable Beta Services Terms provided at [Agreements and Terms](https://www.salesforce.com/company/legal/agreements/).'; + } + if (state === 'preview') { + // prettier-ignore + return 'This command is available as a developer preview. The command isn\'t generally available unless or until Salesforce announces its general availability in documentation or in press releases or public statements. All commands, parameters, and other features are subject to change or deprecation at any time, with or without notice. Don\'t implement functionality developed with these commands or tools.'; + } + return null; +} + +function renderFlagRow(param: CommandParameterData): string { + const flagLabel = renderFlagLabel(param); + const desc = renderFlagDescription(param); + return `| ${flagLabel} | ${desc} |`; +} + +function renderFlagLabel(param: CommandParameterData): string { + const parts: string[] = []; + if (param.char) parts.push(`\`-${param.char}\``); + const longFlag = param.hasValue ? `\`--${param.name} ${param.name.toUpperCase()}\`` : `\`--${param.name}\``; + parts.push(longFlag); + return parts.join(', '); +} + +function renderFlagDescription(param: CommandParameterData): string { + const parts: string[] = []; + if (param.deprecated) { + const toNote = param.deprecated.to ? ` Use \`--${param.deprecated.to}\` instead.` : ''; + parts.push(`**This flag is deprecated.${toNote}**`); + } + if (!param.optional) parts.push('**Required**'); + if (param.options?.length) { + parts.push(`**Valid Values:** ${param.options.map((o) => `\`${o}\``).join(', ')}`); + } + if (param.defaultFlagValue) parts.push(`**Default value:** \`${param.defaultFlagValue}\``); + const desc = convertHyphenListsToMarkdown( + param.description.map((p) => applyCodeFormatting(escapeAngleBrackets(p.replace(/\|/g, '|')))) + ).join('

'); + if (desc) parts.push(desc); + return parts.join('
').replace(/\n/g, ' '); +} diff --git a/src/markdown/index.ts b/src/markdown/index.ts new file mode 100644 index 00000000..c4f15f7f --- /dev/null +++ b/src/markdown/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { MarkdownBase } from './markdown-base.js'; +export { MarkdownCommand } from './command.js'; +export { MarkdownCliReference } from './cli-reference.js'; +export { MarkdownRootIndex } from './root-index.js'; +export { MarkdownTopicCommands } from './topic-commands.js'; +export { MarkdownTopicIndex } from './topic-index.js'; +export { MarkdownToc } from './toc.js'; diff --git a/src/markdown/markdown-base.ts b/src/markdown/markdown-base.ts new file mode 100644 index 00000000..b67f6c6a --- /dev/null +++ b/src/markdown/markdown-base.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { dirname, join } from 'node:path'; +import fs from 'node:fs/promises'; + +export abstract class MarkdownBase { + protected destination: string; + + protected constructor(private filename: string, protected outputDir: string) { + this.destination = join(outputDir, filename); + } + + public static file(name: string): string { + return `${name}.md`; + } + + public getFilename(): string { + return this.filename; + } + + public async write(): Promise { + await fs.mkdir(dirname(this.destination), { recursive: true }); + const output = await this.generate(); + await fs.writeFile(this.destination, output); + } + + protected abstract generate(): Promise; +} diff --git a/src/markdown/root-index.ts b/src/markdown/root-index.ts new file mode 100644 index 00000000..b7a01977 --- /dev/null +++ b/src/markdown/root-index.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MarkdownBase } from './markdown-base.js'; + +export class MarkdownRootIndex extends MarkdownBase { + public constructor(private topics: string[], outputDir: string) { + super(MarkdownBase.file('cli_reference_index'), outputDir); + } + + protected generate(): Promise { + const lines: string[] = []; + lines.push(''); + lines.push('# sf CLI Reference — Topic Index'); + lines.push(''); + for (const topic of this.topics.sort()) { + lines.push(`- [${topic}](./${topic}/cli_reference_${topic}.md)`); + } + lines.push(''); + return Promise.resolve(lines.join('\n')); + } +} diff --git a/src/markdown/toc.ts b/src/markdown/toc.ts new file mode 100644 index 00000000..750bbad7 --- /dev/null +++ b/src/markdown/toc.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TocTopicEntry } from '../generator-factory.js'; +import { MarkdownBase } from './markdown-base.js'; + +const STATE_LABELS: Record = { + beta: 'Beta', + preview: 'Developer Preview', + closedPilot: 'Closed Pilot', + openPilot: 'Open Pilot', + deprecated: 'Deprecated', +}; + +export class MarkdownToc extends MarkdownBase { + public constructor(private topicEntries: TocTopicEntry[], outputDir: string) { + super('sfclireference-toc.yml', outputDir); + } + + public static override file(): string { + return 'sfclireference-toc.yml'; + } + + // eslint-disable-next-line class-methods-use-this + protected generate(): Promise { + const lines: string[] = [ + '- title: Salesforce CLI Command Reference', + ' link: cli_reference.md', + '- title: Release Notes', + ' link: cli_reference_release_notes.md', + '- title: Deprecation Policy', + ' link: cli_reference_deprecation.md', + ]; + + const sorted = [...this.topicEntries].sort((a, b) => a.topic.localeCompare(b.topic)); + + for (const { topic, commandIds } of sorted) { + lines.push(`- title: ${topic} Commands`); + lines.push(` link: ${topic}/cli_reference_${topic}.md`); + lines.push(' topics:'); + for (const { id, state, deprecated } of [...commandIds].sort((a, b) => a.id.localeCompare(b.id))) { + const commandWithUnderscores = id.replace(/:/g, '_'); + const commandWithSpaces = id.replace(/:/g, ' '); + const stateLabel = deprecated + ? ' (Deprecated)' + : state && STATE_LABELS[state] + ? ` (${STATE_LABELS[state]})` + : ''; + const isTopicLevelCommand = !id.includes(':'); + const linkTarget = isTopicLevelCommand + ? `cli_reference_${commandWithUnderscores}_command.md` + : `cli_reference_${commandWithUnderscores}.md`; + lines.push(` - title: ${commandWithSpaces}${stateLabel}`); + lines.push(` link: ${topic}/${linkTarget}`); + } + } + + lines.push(''); + return Promise.resolve(lines.join('\n')); + } +} diff --git a/src/markdown/topic-commands.ts b/src/markdown/topic-commands.ts new file mode 100644 index 00000000..4cd0f1e4 --- /dev/null +++ b/src/markdown/topic-commands.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { join } from 'node:path'; +import { SfTopic } from '../utils.js'; +import { MarkdownBase } from './markdown-base.js'; + +export class MarkdownTopicCommands extends MarkdownBase { + public constructor(private topic: string, private topicMeta: SfTopic, outputDir: string) { + const filename = MarkdownBase.file(`cli_reference_${topic}_commands`); + super(filename, outputDir); + this.destination = join(outputDir, topic, filename); + } + + protected generate(): Promise { + const lines: string[] = []; + lines.push(''); + lines.push(`# ${this.topic} Commands`); + lines.push(''); + if (this.topicMeta.description) { + lines.push(this.topicMeta.description); + lines.push(''); + } + return Promise.resolve(lines.join('\n')); + } +} diff --git a/src/markdown/topic-index.ts b/src/markdown/topic-index.ts new file mode 100644 index 00000000..8f18aa3e --- /dev/null +++ b/src/markdown/topic-index.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { join } from 'node:path'; +import { SfTopic } from '../utils.js'; +import { MarkdownBase } from './markdown-base.js'; + +export class MarkdownTopicIndex extends MarkdownBase { + public constructor( + private topic: string, + private commandIds: string[], + private topicMeta: SfTopic, + outputDir: string + ) { + const filename = MarkdownBase.file(`cli_reference_${topic}`); + super(filename, outputDir); + this.destination = join(outputDir, topic, filename); + } + + protected generate(): Promise { + const lines: string[] = []; + lines.push(`# ${this.topic} Commands`); + lines.push(''); + if (this.topicMeta.description) { + lines.push(this.topicMeta.description); + lines.push(''); + } + for (const id of [...this.commandIds].sort()) { + const commandWithUnderscores = id.replace(/:/g, '_'); + const commandWithSpaces = id.replace(/:/g, ' '); + const isTopicLevelCommand = !id.includes(':'); + const linkTarget = isTopicLevelCommand + ? `cli_reference_${commandWithUnderscores}_command.md` + : `cli_reference_${commandWithUnderscores}.md`; + lines.push(`- [${commandWithSpaces}](./${linkTarget})`); + } + lines.push(''); + return Promise.resolve(lines.join('\n')); + } +} diff --git a/test/unit/markdown.test.ts b/test/unit/markdown.test.ts new file mode 100644 index 00000000..4eea7969 --- /dev/null +++ b/test/unit/markdown.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { access, rm } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect } from 'chai'; + +const testFilesPath = './test/tmp-md'; + +function loadMdFile(path: string): string { + return readFileSync(join(testFilesPath, path), 'utf8'); +} + +describe('markdown output: plugin-auth and user', () => { + before(async () => { + try { + await access(testFilesPath); + } catch (e) { + throw new Error( + `Could not read generated Markdown test docs from ${testFilesPath}. Ensure the "test:command-reference-markdown" wireit task has run.` + ); + } + }); + + after(async () => { + await rm(testFilesPath, { recursive: true }); + }); + + it('produces no [object Object] nonsense for default flags', () => { + const md = loadMdFile(join('org', 'cli_reference_org_create_user.md')); + expect(md.includes('[object Object]')).to.be.false; + }); + + it('creates a command file with the correct H1 heading', () => { + const md = loadMdFile(join('org', 'cli_reference_org_login_jwt.md')); + expect(md.includes('# org login jwt')).to.be.true; + }); + + it('includes the command summary', () => { + const md = loadMdFile(join('org', 'cli_reference_org_login_jwt.md')); + expect(md.includes('Log in to a Salesforce org using a JSON web token (JWT).')).to.be.true; + }); + + it('includes a Flags section with a Markdown table', () => { + const md = loadMdFile(join('org', 'cli_reference_org_login_jwt.md')); + expect(md.includes('## Flags')).to.be.true; + expect(md.includes('| Flag | Description |')).to.be.true; + expect(md.includes('--username')).to.be.true; + }); + + it('includes an Examples section with a shell code block', () => { + const md = loadMdFile(join('org', 'cli_reference_org_login_jwt.md')); + expect(md.includes('## Examples')).to.be.true; + expect(md.includes('```shell')).to.be.true; + }); + + it('does not contain XML or DITA markup', () => { + const md = loadMdFile(join('org', 'cli_reference_org_login_jwt.md')); + expect(md.includes(' { + const md = loadMdFile(join('org', 'cli_reference_org.md')); + expect(md.includes('# org Commands')).to.be.true; + expect(md.includes('cli_reference_org_login_jwt.md')).to.be.true; + }); + + it('creates a toc file', () => { + const toc = loadMdFile('sfclireference-toc.yml'); + expect(toc.includes('- title: Salesforce CLI Command Reference')).to.be.true; + expect(toc.includes('org/cli_reference_org.md')).to.be.true; + }); + + it('creates a root cli reference file', () => { + const md = loadMdFile('cli_reference.md'); + expect(md.includes('# Salesforce CLI Command Reference')).to.be.true; + }); + + it('files use .md extension, not .xml or .ditamap', async () => { + const { readdir } = await import('node:fs/promises'); + const rootFiles = await readdir(testFilesPath); + expect(rootFiles.some((f) => f.endsWith('.xml'))).to.be.false; + expect(rootFiles.some((f) => f.endsWith('.ditamap'))).to.be.false; + expect(rootFiles.some((f) => f.endsWith('.md'))).to.be.true; + }); + + it('filenames do not contain the _unified suffix', () => { + const md = loadMdFile(join('org', 'cli_reference_org_login_jwt.md')); + expect(md).to.be.a('string'); + // Verify the file exists at the unsuffixed path (the load above would throw if not) + }); +});