diff --git a/src/generator-factory.ts b/src/generator-factory.ts index bcaf07cd..c4efc744 100644 --- a/src/generator-factory.ts +++ b/src/generator-factory.ts @@ -126,12 +126,13 @@ export class MarkdownGeneratorFactory implements GeneratorFactory { return new MarkdownTopicIndex(topic, commands, topicMeta, this.outputDir); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars public createCommand( topic: string, - subtopic: string | null, + _subtopic: string | null, command: CommandClass, commandMeta: Record ): WritableWithFilename { - return new MarkdownCommand(topic, subtopic, command, commandMeta, this.outputDir); + return new MarkdownCommand(topic, command, commandMeta, this.outputDir); } } diff --git a/src/markdown/command.ts b/src/markdown/command.ts index 2562a6a2..0f7224f6 100644 --- a/src/markdown/command.ts +++ b/src/markdown/command.ts @@ -16,7 +16,15 @@ import { join } from 'node:path'; import { asString, Dictionary, ensureObject, ensureString } from '@salesforce/ts-types'; -import { CommandClass, CommandParameterData, punctuate, replaceConfigVariables } from '../utils.js'; +import { + CommandClass, + CommandParameterData, + commandFileBase, + escapeAngleBrackets, + punctuate, + replaceConfigVariables, + stateLabel, +} from '../utils.js'; import { buildCommandParameters, FlagInfo, formatParagraphs, readBinary } from '../ditamap/command-helpers.js'; import { MarkdownBase } from './markdown-base.js'; @@ -40,19 +48,11 @@ export class MarkdownCommand extends MarkdownBase { 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); + const filename = MarkdownBase.file(commandFileBase(ensureString(command.id))); super(filename, outputDir); this.destination = join(outputDir, topic, filename); @@ -102,12 +102,12 @@ export class MarkdownCommand extends MarkdownBase { lines.push(''); lines.push(''); - const stateLabel = resolveStateLabel(this.state, this.deprecated); - lines.push(`# ${this.commandName}${stateLabel ? ` (${stateLabel})` : ''}`); + const label = stateLabel(this.state, this.deprecated); + lines.push(`# ${this.commandName}${label ? ` (${label})` : ''}`); lines.push(''); if (this.summary) { - lines.push(this.summary); + lines.push(escapeAngleBrackets(this.summary)); lines.push(''); } @@ -180,7 +180,7 @@ export class MarkdownCommand extends MarkdownBase { function escapeForMarkdown(text: string): string { // Escape HTML entities for markdown safety - let result = text.replace(//g, '>'); + let result = escapeAngleBrackets(text); // Normalize whitespace: collapse multiple spaces/tabs/newlines to single space result = result.replace(/\s+/g, ' '); @@ -226,14 +226,6 @@ function convertBulletListsToHtml(paragraphs: string[]): string[] { 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, diff --git a/src/markdown/index.ts b/src/markdown/index.ts index c4f15f7f..cf705a42 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -18,6 +18,5 @@ 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/toc.ts b/src/markdown/toc.ts index 750bbad7..180a6192 100644 --- a/src/markdown/toc.ts +++ b/src/markdown/toc.ts @@ -15,16 +15,9 @@ */ import { TocTopicEntry } from '../generator-factory.js'; +import { commandLinkTarget, stateLabel } from '../utils.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); @@ -52,18 +45,10 @@ export class MarkdownToc extends MarkdownBase { 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}`); + const label = stateLabel(state, deprecated); + const linkTarget = commandLinkTarget(id); + lines.push(` - title: ${commandWithSpaces}${label ? ` (${label})` : ''}`); lines.push(` link: ${topic}/${linkTarget}`); } } diff --git a/src/markdown/topic-commands.ts b/src/markdown/topic-commands.ts deleted file mode 100644 index 4cd0f1e4..00000000 --- a/src/markdown/topic-commands.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 index afa274ab..123adc8e 100644 --- a/src/markdown/topic-index.ts +++ b/src/markdown/topic-index.ts @@ -15,20 +15,9 @@ */ import { join } from 'node:path'; -import { CommandClass, punctuate, SfTopic } from '../utils.js'; +import { CommandClass, commandLinkTarget, escapeAngleBrackets, punctuate, SfTopic, stateLabel } from '../utils.js'; import { MarkdownBase } from './markdown-base.js'; -function resolveStateLabel(command: CommandClass): string | null { - const deprecated = Boolean(command.deprecated); - const state = command.state; - - if (deprecated) return 'Deprecated'; - if (state === 'beta') return 'Beta'; - if (state === 'preview') return 'Developer Preview'; - if (state === 'closedPilot' || state === 'openPilot') return 'Pilot'; - return null; -} - export class MarkdownTopicIndex extends MarkdownBase { public constructor( private topic: string, @@ -48,24 +37,20 @@ export class MarkdownTopicIndex extends MarkdownBase { lines.push(`# ${this.topic} Commands`); lines.push(''); if (this.topicMeta.description) { - lines.push(this.topicMeta.description); + lines.push(escapeAngleBrackets(this.topicMeta.description)); lines.push(''); } const sortedCommands = [...this.commands].sort((a, b) => a.id.localeCompare(b.id)); for (const command of sortedCommands) { const id = command.id; - 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`; - const stateLabel = resolveStateLabel(command); - const commandDisplay = stateLabel ? `${commandWithSpaces} (${stateLabel})` : commandWithSpaces; + const linkTarget = commandLinkTarget(id); + const label = stateLabel(command.state, Boolean(command.deprecated)); + const commandDisplay = label ? `${commandWithSpaces} (${label})` : commandWithSpaces; const summary = punctuate(command.summary); if (summary) { lines.push(`- **[${commandDisplay}](./${linkTarget})**
`); - lines.push(` ${summary}`); + lines.push(` ${escapeAngleBrackets(summary)}`); } else { lines.push(`- **[${commandDisplay}](./${linkTarget})**`); } diff --git a/src/utils.ts b/src/utils.ts index c2a826db..9b0ed264 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -44,6 +44,44 @@ export function punctuate(description?: string): string | undefined { export const replaceConfigVariables = (text: string, bin: string, id: string): string => text.replace(/<%= config.bin %>/g, bin ?? 'unknown').replace(/<%= command.id %>/g, id); +/** + * Converts the `<` and `>` characters to their HTML entity equivalents (`<` and `>`) + * so they render as literal characters instead of being interpreted as HTML tags. + */ +export function escapeAngleBrackets(text: string): string { + return text.replace(//g, '>'); +} + +/** + * Resolves a command's state (and deprecation) to the label shown next to its name, or `null` + * when there's nothing to show. Both pilot states render as "Pilot". + */ +export function stateLabel(state: unknown, deprecated = false): 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; +} + +/** + * Returns the base filename (without extension) for a command's reference page. + * + * A command ID with no subtopic (e.g. "doctor") would collide with the topic index file + * (cli_reference_doctor.md), so its page is suffixed with `_command` to disambiguate. + */ +export function commandFileBase(id: string): string { + const commandWithUnderscores = id.replace(/:/g, '_'); + return id.includes(':') + ? `cli_reference_${commandWithUnderscores}` + : `cli_reference_${commandWithUnderscores}_command`; +} + +/** Returns the relative Markdown filename to link to a command's reference page. */ +export function commandLinkTarget(id: string): string { + return `${commandFileBase(id)}.md`; +} + export type CliMeta = { binary: string; topicSeparator?: string;