Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/spfx-template-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ The writer uses these helpers internally. You can also import them directly for
| `ICasedString` | Interface exposing `.camel`, `.pascal`, `.hyphen`, `.allCaps`; auto-applied to all string context values during rendering |
| `createCasedString` | Factory function that creates an `ICasedString` from a raw string |
| `SPFxTemplateRepositoryManager` | Aggregates sources and returns a `SPFxTemplateCollection` |
| `SPFxTemplateCollection` | `Map<string, SPFxTemplate>` of all loaded templates |
| `SPFxTemplateCollection` | `Map<string, SPFxTemplate>` of all loaded templates; call `toJsonString()` for structured JSON or `toFormattedStringAsync()` for a human-readable table |
| `SPFxTemplate` | Single template — exposes `name`, `category`, `spfxVersion`, and `renderAsync()` |
| `ITemplateOutputEntry` | A single file entry (text or binary contents) |
| `TemplateOutput` | In-memory file system implementation backed by a `Map`, returned by `renderAsync()` |
Expand Down
17 changes: 17 additions & 0 deletions api/spfx-template-api/etc/spfx-template-api.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,22 @@ export interface ISPFxTemplateJson {
version: string;
}

// @public
export interface ITemplateJsonOutputEntry {
// (undocumented)
category: string;
// (undocumented)
description: string | null;
// (undocumented)
fileCount: number;
// (undocumented)
name: string;
// (undocumented)
spfxVersion: string;
// (undocumented)
version: string;
}

// @public
export interface ITemplateOutputEntry {
readonly contents: string | Buffer;
Expand Down Expand Up @@ -288,6 +304,7 @@ export type SPFxTemplateCategory = (typeof SPFX_TEMPLATE_CATEGORIES)[number];
export class SPFxTemplateCollection extends Map<string, SPFxTemplate> {
constructor(templates: SPFxTemplate[]);
toFormattedStringAsync(): Promise<string>;
toJsonString(): string;
}

// @public
Expand Down
1 change: 1 addition & 0 deletions api/spfx-template-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
BaseSPFxTemplateRepositorySource,
type SPFxRepositorySource,
SPFxTemplateCollection,
type ITemplateJsonOutputEntry,
LocalFileSystemRepositorySource,
PublicGitHubRepositorySource,
type IPublicGitHubRepositorySourceOptions
Expand Down
42 changes: 42 additions & 0 deletions api/spfx-template-api/src/repositories/SPFxTemplateCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@

import type { SPFxTemplate } from '../templating';

/**
* @public
* Represents a single template entry in the JSON output produced by
* {@link SPFxTemplateCollection.toJsonString}.
*/
export interface ITemplateJsonOutputEntry {
name: string;
category: string;
// `null` (not `undefined`) is intentional: JSON.stringify drops `undefined` fields
// but preserves `null`, ensuring the field is always present in the output.
// eslint-disable-next-line @rushstack/no-new-null
description: string | null;
version: string;
spfxVersion: string;
fileCount: number;
}

/**
* @public
* Represents a collection of SharePoint Framework (SPFx) templates.
Expand All @@ -17,6 +34,31 @@ export class SPFxTemplateCollection extends Map<string, SPFxTemplate> {
super(templates.map((template) => [template.name, template]));
}

/**
* Returns a JSON string representation of the collection as an array of template objects.
* Each object includes `name`, `category`, `description`, `version`, `spfxVersion`, and `fileCount`.
*
* @remarks
* Unlike {@link SPFxTemplateCollection.toFormattedStringAsync}, this method is synchronous
* because it has no external dependencies.
*
* @returns A pretty-printed JSON string
*/
public toJsonString(): string {
const items: ITemplateJsonOutputEntry[] = [];
for (const template of this.values()) {
items.push({
name: template.name,
category: template.category,
description: template.description ?? null,
version: template.version,
spfxVersion: template.spfxVersion,
fileCount: template.fileCount
});
}
return JSON.stringify(items, undefined, 2);
}

/**
* Returns a formatted table string representation of the collection.
* Uses cli-table3, which is loaded asynchronously to reduce startup cost.
Expand Down
2 changes: 1 addition & 1 deletion api/spfx-template-api/src/repositories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export {
BaseSPFxTemplateRepositorySource,
type SPFxRepositorySource
} from './SPFxTemplateRepositorySource';
export { SPFxTemplateCollection } from './SPFxTemplateCollection';
export { SPFxTemplateCollection, type ITemplateJsonOutputEntry } from './SPFxTemplateCollection';
export { LocalFileSystemRepositorySource } from './LocalFileSystemRepositorySource';
export {
PublicGitHubRepositorySource,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { SPFxTemplateCollection } from '../SPFxTemplateCollection';
import { SPFxTemplateCollection, type ITemplateJsonOutputEntry } from '../SPFxTemplateCollection';
import { SPFxTemplate } from '../../templating';
import { SPFxTemplateJsonFile } from '../../templating';

Expand Down Expand Up @@ -172,6 +172,101 @@ describe(SPFxTemplateCollection.name, () => {
});
});

describe(SPFxTemplateCollection.prototype.toJsonString.name, () => {
it('should return an empty array for empty collection', () => {
const collection = new SPFxTemplateCollection([]);
expect(collection.toJsonString()).toBe('[]');
});

it('should serialize a single template with all fields', () => {
const template = new SPFxTemplate(
new SPFxTemplateJsonFile({
name: 'webpart-minimal',
category: 'webpart',
description: 'A minimal web part template',
version: '1.0.0',
spfxVersion: '1.22.0'
}),
new Map([
['file1.txt', Buffer.from('content1')],
['file2.txt', Buffer.from('content2')]
])
);

const collection = new SPFxTemplateCollection([template]);
const parsed: ITemplateJsonOutputEntry[] = JSON.parse(collection.toJsonString());

expect(parsed).toEqual([
{
name: 'webpart-minimal',
category: 'webpart',
description: 'A minimal web part template',
version: '1.0.0',
spfxVersion: '1.22.0',
fileCount: 2
}
]);
});

it('should use null for undefined description', () => {
const template = new SPFxTemplate(
new SPFxTemplateJsonFile({
name: 'NoDesc',
category: 'webpart',
version: '1.0.0',
spfxVersion: '1.18.0'
}),
new Map()
);

const collection = new SPFxTemplateCollection([template]);
const parsed = JSON.parse(collection.toJsonString());

expect(parsed[0].description).toBeNull();
});

it('should serialize multiple templates', () => {
const template1 = new SPFxTemplate(
new SPFxTemplateJsonFile({
name: 'WebPart',
category: 'webpart',
description: 'A web part template',
version: '1.0.0',
spfxVersion: '1.18.0'
}),
new Map([['file.txt', Buffer.from('content')]])
);

const template2 = new SPFxTemplate(
new SPFxTemplateJsonFile({
name: 'Extension',
category: 'extension',
version: '2.0.0',
spfxVersion: '1.18.0'
}),
new Map()
);

const collection = new SPFxTemplateCollection([template1, template2]);
expect(collection.toJsonString()).toMatchSnapshot();
});

it('should return valid JSON', () => {
const template = new SPFxTemplate(
new SPFxTemplateJsonFile({
name: 'Test',
category: 'library',
version: '1.0.0',
spfxVersion: '1.18.0'
}),
new Map()
);

const collection = new SPFxTemplateCollection([template]);
expect(() => JSON.parse(collection.toJsonString())).not.toThrow();
});
});

describe('toFormattedStringAsync', () => {
it('should show message for empty collection', async () => {
const collection = new SPFxTemplateCollection([]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`SPFxTemplateCollection toJsonString should serialize multiple templates 1`] = `
"[
{
\\"name\\": \\"WebPart\\",
\\"category\\": \\"webpart\\",
\\"description\\": \\"A web part template\\",
\\"version\\": \\"1.0.0\\",
\\"spfxVersion\\": \\"1.18.0\\",
\\"fileCount\\": 1
},
{
\\"name\\": \\"Extension\\",
\\"category\\": \\"extension\\",
\\"description\\": null,
\\"version\\": \\"2.0.0\\",
\\"spfxVersion\\": \\"1.18.0\\",
\\"fileCount\\": 0
}
]"
`;
13 changes: 13 additions & 0 deletions apps/spfx-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ spfx list-templates

| Flag | Default | Description |
|------|---------|-------------|
| `-o`, `--output {json,text}` | `json` | Output format. `json` writes machine-readable JSON to stdout (informational messages go to stderr). `text` writes a human-readable table |
| `--spfx-version VERSION` | `version/latest` branch | Branch/tag in the default template repo to use (e.g. `1.22`, `1.23-rc.0`) |
| `--template-url URL` | `https://github.com/SharePoint/spfx` | Custom GitHub template repository (default source) |
| `--local-source PATH` | — | Path to a local template folder to include (repeatable) |
Expand All @@ -87,6 +88,18 @@ spfx list-templates

### Examples

List templates as JSON (default, suitable for piping to `jq` or other tools):

```bash
spfx list-templates
```

List templates as a human-readable table:

```bash
spfx list-templates --output text
```

Include a local template folder alongside the default source:

```bash
Expand Down
24 changes: 22 additions & 2 deletions apps/spfx-cli/src/cli/actions/ListTemplatesAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@
// See LICENSE in the project root for license information.

import type { Terminal } from '@rushstack/terminal';
import type { IRequiredCommandLineChoiceParameter } from '@rushstack/ts-command-line';
import { type SPFxTemplateCollection, SPFxTemplateRepositoryManager } from '@microsoft/spfx-template-api';

import { SPFxActionBase } from './SPFxActionBase';

export type OutputFormat = 'json' | 'text';

export class ListTemplatesAction extends SPFxActionBase {
private readonly _outputParameter: IRequiredCommandLineChoiceParameter<OutputFormat>;

public constructor(terminal: Terminal) {
super(
{
Expand All @@ -18,10 +23,21 @@ export class ListTemplatesAction extends SPFxActionBase {
},
terminal
);

this._outputParameter = this.defineChoiceParameter({
parameterLongName: '--output',
parameterShortName: '-o',
description:
'Output format. "json" writes machine-readable JSON to stdout (informational ' +
'messages go to stderr). "text" writes a human-readable table.',
alternatives: ['json', 'text'],
defaultValue: 'json'
});
}

protected override async onExecuteAsync(): Promise<void> {
const terminal: Terminal = this._terminal;
const isJson: boolean = this._outputParameter.value === 'json';

try {
const manager: SPFxTemplateRepositoryManager = new SPFxTemplateRepositoryManager();
Expand All @@ -37,8 +53,12 @@ export class ListTemplatesAction extends SPFxActionBase {

const templates: SPFxTemplateCollection = await this._fetchTemplatesAsync(manager);

const formattedTable: string = await templates.toFormattedStringAsync();
terminal.writeLine(formattedTable);
if (isJson) {
terminal.write(templates.toJsonString() + '\n');
} else {
const formattedTable: string = await templates.toFormattedStringAsync();
terminal.writeLine(formattedTable);
}
} catch (error: unknown) {
const message: string = error instanceof Error ? error.message : String(error);
terminal.writeErrorLine(`Error listing templates: ${message}`);
Expand Down
20 changes: 17 additions & 3 deletions apps/spfx-cli/src/cli/actions/SPFxActionBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ export abstract class SPFxActionBase extends CommandLineAction {
'Required for GitHub Enterprise hosts and private repositories on github.com.',
environmentVariable: GITHUB_TOKEN_ENV_VAR_NAME
});

this.defineFlagParameter({
parameterLongName: '--verbose',
description: 'Show verbose output.'
});
}

/**
* Writes an informational verbose-level message to the terminal. Subclasses can
* override this to redirect informational output away from stdout (for example,
* when an action emits machine-readable JSON to stdout).
*/
protected _writeInfoLine(message: string): void {
this._terminal.writeVerboseLine(message);
}

/**
Expand Down Expand Up @@ -108,7 +122,7 @@ export abstract class SPFxActionBase extends CommandLineAction {

const token: string | undefined = this._githubTokenParameter.value?.trim() || undefined;

terminal.writeLine(`Using GitHub template source: ${repoUrl}${ref ? ` (branch: ${ref})` : ''}`);
this._writeInfoLine(`Using GitHub template source: ${repoUrl}${ref ? ` (branch: ${ref})` : ''}`);
manager.addSource(new PublicGitHubRepositorySource({ repoUrl, branch: ref, terminal, token }));
}

Expand All @@ -118,7 +132,7 @@ export abstract class SPFxActionBase extends CommandLineAction {
*/
protected _addLocalTemplateSources(manager: SPFxTemplateRepositoryManager): void {
for (const localPath of this._localSourceParameter.values) {
this._terminal.writeLine(`Adding local template source: ${localPath}`);
this._writeInfoLine(`Adding local template source: ${localPath}`);
manager.addSource(new LocalFileSystemRepositorySource(localPath));
}
}
Expand All @@ -132,7 +146,7 @@ export abstract class SPFxActionBase extends CommandLineAction {
const token: string | undefined = this._githubTokenParameter.value?.trim() || undefined;
for (const remoteUrl of this._remoteSourcesParameter.values) {
const { repoUrl, urlBranch } = parseGitHubUrlAndRef(remoteUrl);
terminal.writeLine(
this._writeInfoLine(
`Adding remote template source: ${repoUrl}${urlBranch ? ` (branch: ${urlBranch})` : ''}`
);
manager.addSource(new PublicGitHubRepositorySource({ repoUrl, branch: urlBranch, terminal, token }));
Expand Down
Loading