Skip to content

Commit 2dec1fa

Browse files
Merge pull request #1166 from salesforcecli/js/markdown
W-24294178: escape angle brackets in command summary, description, and topic index
2 parents 88ef9f0 + e7cb909 commit 2dec1fa

7 files changed

Lines changed: 65 additions & 104 deletions

File tree

‎src/generator-factory.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,13 @@ export class MarkdownGeneratorFactory implements GeneratorFactory {
126126
return new MarkdownTopicIndex(topic, commands, topicMeta, this.outputDir);
127127
}
128128

129+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
129130
public createCommand(
130131
topic: string,
131-
subtopic: string | null,
132+
_subtopic: string | null,
132133
command: CommandClass,
133134
commandMeta: Record<string, unknown>
134135
): WritableWithFilename {
135-
return new MarkdownCommand(topic, subtopic, command, commandMeta, this.outputDir);
136+
return new MarkdownCommand(topic, command, commandMeta, this.outputDir);
136137
}
137138
}

‎src/markdown/command.ts‎

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@
1616

1717
import { join } from 'node:path';
1818
import { asString, Dictionary, ensureObject, ensureString } from '@salesforce/ts-types';
19-
import { CommandClass, CommandParameterData, punctuate, replaceConfigVariables } from '../utils.js';
19+
import {
20+
CommandClass,
21+
CommandParameterData,
22+
commandFileBase,
23+
escapeAngleBrackets,
24+
punctuate,
25+
replaceConfigVariables,
26+
stateLabel,
27+
} from '../utils.js';
2028
import { buildCommandParameters, FlagInfo, formatParagraphs, readBinary } from '../ditamap/command-helpers.js';
2129
import { MarkdownBase } from './markdown-base.js';
2230

@@ -40,19 +48,11 @@ export class MarkdownCommand extends MarkdownBase {
4048

4149
public constructor(
4250
topic: string,
43-
subtopic: string | null,
4451
command: CommandClass,
4552
commandMeta: Record<string, unknown> = {},
4653
outputDir: string
4754
) {
48-
const commandWithUnderscores = ensureString(command.id).replace(/:/g, '_');
49-
// If the command ID has no subtopic (e.g. "doctor"), its filename would collide with the topic
50-
// index file (cli_reference_doctor.md), so append _command to disambiguate.
51-
const isTopicLevelCommand = !ensureString(command.id).includes(':');
52-
const baseName = isTopicLevelCommand
53-
? `cli_reference_${commandWithUnderscores}_command`
54-
: `cli_reference_${commandWithUnderscores}`;
55-
const filename = MarkdownBase.file(baseName);
55+
const filename = MarkdownBase.file(commandFileBase(ensureString(command.id)));
5656
super(filename, outputDir);
5757
this.destination = join(outputDir, topic, filename);
5858

@@ -102,12 +102,12 @@ export class MarkdownCommand extends MarkdownBase {
102102
lines.push('<!-- prettier-ignore-start -->');
103103
lines.push('');
104104

105-
const stateLabel = resolveStateLabel(this.state, this.deprecated);
106-
lines.push(`# ${this.commandName}${stateLabel ? ` (${stateLabel})` : ''}`);
105+
const label = stateLabel(this.state, this.deprecated);
106+
lines.push(`# ${this.commandName}${label ? ` (${label})` : ''}`);
107107
lines.push('');
108108

109109
if (this.summary) {
110-
lines.push(this.summary);
110+
lines.push(escapeAngleBrackets(this.summary));
111111
lines.push('');
112112
}
113113

@@ -180,7 +180,7 @@ export class MarkdownCommand extends MarkdownBase {
180180

181181
function escapeForMarkdown(text: string): string {
182182
// Escape HTML entities for markdown safety
183-
let result = text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
183+
let result = escapeAngleBrackets(text);
184184

185185
// Normalize whitespace: collapse multiple spaces/tabs/newlines to single space
186186
result = result.replace(/\s+/g, ' ');
@@ -226,14 +226,6 @@ function convertBulletListsToHtml(paragraphs: string[]): string[] {
226226
return result;
227227
}
228228

229-
function resolveStateLabel(state: unknown, deprecated: boolean): string | null {
230-
if (deprecated) return 'Deprecated';
231-
if (state === 'beta') return 'Beta';
232-
if (state === 'preview') return 'Developer Preview';
233-
if (state === 'closedPilot' || state === 'openPilot') return 'Pilot';
234-
return null;
235-
}
236-
237229
function resolveDisclaimer(
238230
commandName: string,
239231
state: unknown,

‎src/markdown/index.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,5 @@ export { MarkdownBase } from './markdown-base.js';
1818
export { MarkdownCommand } from './command.js';
1919
export { MarkdownCliReference } from './cli-reference.js';
2020
export { MarkdownRootIndex } from './root-index.js';
21-
export { MarkdownTopicCommands } from './topic-commands.js';
2221
export { MarkdownTopicIndex } from './topic-index.js';
2322
export { MarkdownToc } from './toc.js';

‎src/markdown/toc.ts‎

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,9 @@
1515
*/
1616

1717
import { TocTopicEntry } from '../generator-factory.js';
18+
import { commandLinkTarget, stateLabel } from '../utils.js';
1819
import { MarkdownBase } from './markdown-base.js';
1920

20-
const STATE_LABELS: Record<string, string> = {
21-
beta: 'Beta',
22-
preview: 'Developer Preview',
23-
closedPilot: 'Closed Pilot',
24-
openPilot: 'Open Pilot',
25-
deprecated: 'Deprecated',
26-
};
27-
2821
export class MarkdownToc extends MarkdownBase {
2922
public constructor(private topicEntries: TocTopicEntry[], outputDir: string) {
3023
super('sfclireference-toc.yml', outputDir);
@@ -52,18 +45,10 @@ export class MarkdownToc extends MarkdownBase {
5245
lines.push(` link: ${topic}/cli_reference_${topic}.md`);
5346
lines.push(' topics:');
5447
for (const { id, state, deprecated } of [...commandIds].sort((a, b) => a.id.localeCompare(b.id))) {
55-
const commandWithUnderscores = id.replace(/:/g, '_');
5648
const commandWithSpaces = id.replace(/:/g, ' ');
57-
const stateLabel = deprecated
58-
? ' (Deprecated)'
59-
: state && STATE_LABELS[state]
60-
? ` (${STATE_LABELS[state]})`
61-
: '';
62-
const isTopicLevelCommand = !id.includes(':');
63-
const linkTarget = isTopicLevelCommand
64-
? `cli_reference_${commandWithUnderscores}_command.md`
65-
: `cli_reference_${commandWithUnderscores}.md`;
66-
lines.push(` - title: ${commandWithSpaces}${stateLabel}`);
49+
const label = stateLabel(state, deprecated);
50+
const linkTarget = commandLinkTarget(id);
51+
lines.push(` - title: ${commandWithSpaces}${label ? ` (${label})` : ''}`);
6752
lines.push(` link: ${topic}/${linkTarget}`);
6853
}
6954
}

‎src/markdown/topic-commands.ts‎

Lines changed: 0 additions & 39 deletions
This file was deleted.

‎src/markdown/topic-index.ts‎

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,9 @@
1515
*/
1616

1717
import { join } from 'node:path';
18-
import { CommandClass, punctuate, SfTopic } from '../utils.js';
18+
import { CommandClass, commandLinkTarget, escapeAngleBrackets, punctuate, SfTopic, stateLabel } from '../utils.js';
1919
import { MarkdownBase } from './markdown-base.js';
2020

21-
function resolveStateLabel(command: CommandClass): string | null {
22-
const deprecated = Boolean(command.deprecated);
23-
const state = command.state;
24-
25-
if (deprecated) return 'Deprecated';
26-
if (state === 'beta') return 'Beta';
27-
if (state === 'preview') return 'Developer Preview';
28-
if (state === 'closedPilot' || state === 'openPilot') return 'Pilot';
29-
return null;
30-
}
31-
3221
export class MarkdownTopicIndex extends MarkdownBase {
3322
public constructor(
3423
private topic: string,
@@ -48,24 +37,20 @@ export class MarkdownTopicIndex extends MarkdownBase {
4837
lines.push(`# ${this.topic} Commands`);
4938
lines.push('');
5039
if (this.topicMeta.description) {
51-
lines.push(this.topicMeta.description);
40+
lines.push(escapeAngleBrackets(this.topicMeta.description));
5241
lines.push('');
5342
}
5443
const sortedCommands = [...this.commands].sort((a, b) => a.id.localeCompare(b.id));
5544
for (const command of sortedCommands) {
5645
const id = command.id;
57-
const commandWithUnderscores = id.replace(/:/g, '_');
5846
const commandWithSpaces = id.replace(/:/g, ' ');
59-
const isTopicLevelCommand = !id.includes(':');
60-
const linkTarget = isTopicLevelCommand
61-
? `cli_reference_${commandWithUnderscores}_command.md`
62-
: `cli_reference_${commandWithUnderscores}.md`;
63-
const stateLabel = resolveStateLabel(command);
64-
const commandDisplay = stateLabel ? `${commandWithSpaces} (${stateLabel})` : commandWithSpaces;
47+
const linkTarget = commandLinkTarget(id);
48+
const label = stateLabel(command.state, Boolean(command.deprecated));
49+
const commandDisplay = label ? `${commandWithSpaces} (${label})` : commandWithSpaces;
6550
const summary = punctuate(command.summary);
6651
if (summary) {
6752
lines.push(`- **[${commandDisplay}](./${linkTarget})**<br>`);
68-
lines.push(` ${summary}`);
53+
lines.push(` ${escapeAngleBrackets(summary)}`);
6954
} else {
7055
lines.push(`- **[${commandDisplay}](./${linkTarget})**`);
7156
}

‎src/utils.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,44 @@ export function punctuate(description?: string): string | undefined {
4444
export const replaceConfigVariables = (text: string, bin: string, id: string): string =>
4545
text.replace(/<%= config.bin %>/g, bin ?? 'unknown').replace(/<%= command.id %>/g, id);
4646

47+
/**
48+
* Converts the `<` and `>` characters to their HTML entity equivalents (`&lt;` and `&gt;`)
49+
* so they render as literal characters instead of being interpreted as HTML tags.
50+
*/
51+
export function escapeAngleBrackets(text: string): string {
52+
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
53+
}
54+
55+
/**
56+
* Resolves a command's state (and deprecation) to the label shown next to its name, or `null`
57+
* when there's nothing to show. Both pilot states render as "Pilot".
58+
*/
59+
export function stateLabel(state: unknown, deprecated = false): string | null {
60+
if (deprecated) return 'Deprecated';
61+
if (state === 'beta') return 'Beta';
62+
if (state === 'preview') return 'Developer Preview';
63+
if (state === 'closedPilot' || state === 'openPilot') return 'Pilot';
64+
return null;
65+
}
66+
67+
/**
68+
* Returns the base filename (without extension) for a command's reference page.
69+
*
70+
* A command ID with no subtopic (e.g. "doctor") would collide with the topic index file
71+
* (cli_reference_doctor.md), so its page is suffixed with `_command` to disambiguate.
72+
*/
73+
export function commandFileBase(id: string): string {
74+
const commandWithUnderscores = id.replace(/:/g, '_');
75+
return id.includes(':')
76+
? `cli_reference_${commandWithUnderscores}`
77+
: `cli_reference_${commandWithUnderscores}_command`;
78+
}
79+
80+
/** Returns the relative Markdown filename to link to a command's reference page. */
81+
export function commandLinkTarget(id: string): string {
82+
return `${commandFileBase(id)}.md`;
83+
}
84+
4785
export type CliMeta = {
4886
binary: string;
4987
topicSeparator?: string;

0 commit comments

Comments
 (0)