Skip to content

Commit dc97f47

Browse files
committed
feat: add npm template support
Add support for using npm packages as templates when creating new projects. This feature allows users to specify custom templates from npm registry with optional version control. - Support multiple npm template formats (npm:, @scope/package, package-name) - Add --template-version flag for version specification - Implement smart caching mechanism (.temp-templates/) - Support flexible template structures (template/, templates/app/, root) - Export utility functions for downstream projects
1 parent e38493f commit dc97f47

4 files changed

Lines changed: 310 additions & 1 deletion

File tree

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,65 @@ A shared package for create-rspack, create-rsbuild, create-rspress and create-rs
1717
npm add create-rstack -D
1818
```
1919

20+
## Features
21+
22+
### NPM Template Support
23+
24+
`create-rstack` supports using npm packages as templates, allowing users to create projects from custom templates published to npm.
25+
26+
#### Usage
27+
28+
```bash
29+
# Using npm package name
30+
npm create rsbuild@latest my-project -- --template my-template-package
31+
32+
# Using scoped package
33+
npm create rsbuild@latest my-project -- --template @scope/template-package
34+
35+
# Using explicit npm: prefix
36+
npm create rsbuild@latest my-project -- --template npm:my-template-package
37+
38+
# With specific version
39+
npm create rsbuild@latest my-project -- --template my-template-package --template-version 1.2.3
40+
```
41+
42+
#### Template Package Structure
43+
44+
Your npm template package should have one of the following structures:
45+
46+
```
47+
my-template-package/
48+
├── template/ # Preferred
49+
│ ├── package.json
50+
│ └── src/
51+
├── templates/
52+
│ └── app/ # Alternative
53+
└── (root) # Fallback
54+
├── package.json
55+
└── src/
56+
```
57+
58+
#### Caching Strategy
59+
60+
- Templates with `latest` version are always re-installed to ensure the latest version
61+
- Specific versions are cached in `.temp-templates/` for faster reuse
62+
63+
#### API
64+
65+
```typescript
66+
import {
67+
isNpmTemplate,
68+
resolveCustomTemplate,
69+
resolveNpmTemplate,
70+
} from 'create-rstack';
71+
72+
// Check if template input is an npm package
73+
if (isNpmTemplate(templateInput)) {
74+
// Resolve npm template to local path
75+
const templatePath = resolveCustomTemplate(templateInput, version);
76+
}
77+
```
78+
2079
## Examples
2180

2281
| Project | Link |

src/index.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,18 @@ import { x } from 'tinyexec';
2323
const __filename = fileURLToPath(import.meta.url);
2424
const __dirname = dirname(__filename);
2525

26+
import { isNpmTemplate, resolveCustomTemplate } from './template-manager.js';
27+
2628
export { autocomplete, groupMultiselect, multiselect, select, text };
2729

30+
// Export npm template utilities
31+
export {
32+
isNpmTemplate,
33+
resolveCustomTemplate,
34+
resolveNpmTemplate,
35+
sanitizeCacheKey,
36+
} from './template-manager.js';
37+
2838
function cancelAndExit() {
2939
cancel('Operation cancelled.');
3040
process.exit(0);
@@ -113,6 +123,8 @@ export type Argv = {
113123
skill?: string | string[];
114124
packageName?: string;
115125
'package-name'?: string;
126+
templateVersion?: string;
127+
'template-version'?: string;
116128
};
117129

118130
export const BUILTIN_TOOLS = ['eslint', 'rslint', 'biome', 'prettier'];
@@ -163,6 +175,7 @@ function logHelpMessage(
163175
--tools <tool> add additional tools, comma separated
164176
${skillsOptionLine} --override override files in target directory
165177
--packageName <name> specify the package name
178+
--template-version <ver> specify the npm template version
166179
167180
Available templates:
168181
${templates.join(', ')}
@@ -341,6 +354,11 @@ const parseArgv = (processArgv: string[]) => {
341354
argv.packageName = argv['package-name'];
342355
}
343356

357+
// Handle template-version alias
358+
if (argv['template-version']) {
359+
argv.templateVersion = argv['template-version'];
360+
}
361+
344362
return argv;
345363
};
346364

@@ -592,6 +610,48 @@ export async function create({
592610
}
593611

594612
const templateName = await getTemplateName(argv);
613+
614+
const srcFolder = path.join(root, `template-${templateName}`);
615+
616+
// Handle npm template: only when the local template doesn't exist
617+
// and the template input looks like an npm package
618+
if (
619+
typeof argv.template === 'string' &&
620+
isNpmTemplate(argv.template) &&
621+
!fs.existsSync(srcFolder)
622+
) {
623+
const templateVersion = argv.templateVersion ?? argv['template-version'];
624+
const templatePath = resolveCustomTemplate(argv.template, templateVersion, {
625+
cacheDir: root,
626+
});
627+
628+
// Copy npm template directly to distFolder
629+
copyFolder({
630+
from: templatePath,
631+
to: distFolder,
632+
version,
633+
packageName,
634+
templateParameters,
635+
skipFiles,
636+
});
637+
638+
const nextSteps = noteInformation
639+
? noteInformation
640+
: [
641+
`1. ${color.cyan(`cd ${targetDir}`)}`,
642+
`2. ${color.cyan('git init')} ${color.dim('(optional)')}`,
643+
`3. ${color.cyan(`${packageManager} install`)}`,
644+
`4. ${color.cyan(`${packageManager} run dev`)}`,
645+
];
646+
647+
if (nextSteps.length) {
648+
note(nextSteps.map((step) => color.reset(step)).join('\n'), 'Next steps');
649+
}
650+
651+
outro('All set, happy coding!');
652+
return;
653+
}
654+
595655
const tools = await getTools(argv, extraTools, templateName);
596656
const skills = await getSkills(
597657
argv,
@@ -601,7 +661,6 @@ export async function create({
601661
multiselect,
602662
);
603663

604-
const srcFolder = path.join(root, `template-${templateName}`);
605664
const commonFolder = path.join(root, 'template-common');
606665

607666
if (!fs.existsSync(srcFolder)) {

src/template-manager.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { execSync } from 'node:child_process';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
5+
const NPM_TEMPLATE_PREFIX = 'npm:';
6+
7+
/**
8+
* Sanitize package name and version to create a valid cache key
9+
*/
10+
export const sanitizeCacheKey = (packageName: string, version: string) => {
11+
// Keep the slash for scoped packages (e.g., @scope/package)
12+
// but replace other slashes that would be invalid in file paths
13+
const normalized = packageName.startsWith('@')
14+
? packageName
15+
: packageName.replace(/[\\/]/g, '_');
16+
const versionLabel = version || 'latest';
17+
return `${normalized}@${versionLabel}`;
18+
};
19+
20+
/**
21+
* Check if the input is an npm package template
22+
*/
23+
export function isNpmTemplate(templateInput: string): boolean {
24+
const trimmedInput = templateInput.trim();
25+
26+
// Explicit npm: prefix
27+
if (trimmedInput.startsWith(NPM_TEMPLATE_PREFIX)) {
28+
return true;
29+
}
30+
31+
// Scoped package (@scope/package) or pure package name (no path separators)
32+
if (
33+
trimmedInput.startsWith('@') ||
34+
(!trimmedInput.includes('/') &&
35+
!trimmedInput.startsWith('http') &&
36+
!trimmedInput.startsWith('.') &&
37+
!trimmedInput.startsWith('github:'))
38+
) {
39+
return true;
40+
}
41+
42+
return false;
43+
}
44+
45+
/**
46+
* Resolve npm template package and return the local path
47+
*/
48+
export function resolveNpmTemplate(
49+
packageName: string,
50+
version?: string,
51+
options?: { forceLatest?: boolean; cacheDir?: string },
52+
): string {
53+
const normalizedName = packageName.trim();
54+
55+
// Handle version
56+
const versionSpecifier =
57+
version?.trim() && version.trim().toLowerCase() !== 'latest'
58+
? version.trim()
59+
: 'latest';
60+
61+
// Generate cache key
62+
const cacheKey = sanitizeCacheKey(normalizedName, versionSpecifier);
63+
const cacheRoot = options?.cacheDir || process.cwd();
64+
const templateDir = path.join(cacheRoot, '.temp-templates', cacheKey);
65+
const installRoot = path.dirname(templateDir);
66+
const packagePath = path.join(installRoot, 'node_modules', normalizedName);
67+
68+
// Check if we should reuse cache
69+
const forceLatest = options?.forceLatest ?? versionSpecifier === 'latest';
70+
const shouldReuseCache = !forceLatest && fs.existsSync(templateDir);
71+
72+
if (shouldReuseCache) {
73+
return templateDir;
74+
}
75+
76+
// Create isolated package.json to prevent workspace conflicts
77+
const anchorPkgJson = path.join(installRoot, 'package.json');
78+
if (!fs.existsSync(anchorPkgJson)) {
79+
const minimal = { name: 'create-rstack-template-cache', private: true };
80+
fs.writeFileSync(
81+
anchorPkgJson,
82+
`${JSON.stringify(minimal, null, 2)}\n`,
83+
'utf8',
84+
);
85+
}
86+
87+
// Install the package
88+
try {
89+
execSync(
90+
`npm install ${normalizedName}@${versionSpecifier} --no-save --package-lock=false --no-audit --no-fund --silent`,
91+
{
92+
cwd: installRoot,
93+
stdio: 'pipe',
94+
},
95+
);
96+
} catch {
97+
throw new Error(
98+
`Failed to install npm template "${normalizedName}@${versionSpecifier}". Please check if the package exists.`,
99+
);
100+
}
101+
102+
// Find template directory (by priority)
103+
const possibleTemplatePaths = [
104+
path.join(packagePath, 'template'), // Priority: package/template
105+
path.join(packagePath, 'templates', 'app'),
106+
path.join(packagePath, 'templates', 'default'),
107+
packagePath, // Fallback: package root
108+
];
109+
110+
for (const pathCandidate of possibleTemplatePaths) {
111+
if (
112+
fs.existsSync(pathCandidate) &&
113+
fs.statSync(pathCandidate).isDirectory()
114+
) {
115+
// Copy to cache directory
116+
fs.mkdirSync(templateDir, { recursive: true });
117+
// eslint-disable-next-line n/no-unsupported-features/node-builtins
118+
fs.cpSync(pathCandidate, templateDir, { recursive: true });
119+
return templateDir;
120+
}
121+
}
122+
123+
throw new Error(
124+
`No valid template directory found in package "${normalizedName}". Expected one of: template/, templates/app/, templates/default/, or package root.`,
125+
);
126+
}
127+
128+
/**
129+
* Resolve custom template (npm package, GitHub, or local path)
130+
*/
131+
export function resolveCustomTemplate(
132+
templateInput: string,
133+
version?: string,
134+
options?: { forceLatest?: boolean; cacheDir?: string },
135+
): string {
136+
const trimmedInput = templateInput.trim();
137+
138+
// Handle npm: prefix explicitly
139+
if (trimmedInput.startsWith(NPM_TEMPLATE_PREFIX)) {
140+
const packageName = trimmedInput.slice(NPM_TEMPLATE_PREFIX.length).trim();
141+
return resolveNpmTemplate(packageName, version, options);
142+
}
143+
144+
// Handle scoped package or pure package name
145+
if (isNpmTemplate(trimmedInput)) {
146+
return resolveNpmTemplate(trimmedInput, version, options);
147+
}
148+
149+
// For GitHub URLs or local paths, return as-is (handled by create-rstack)
150+
return trimmedInput;
151+
}

test/index.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { expect, test } from '@rstest/core';
33
import {
44
checkCancel,
55
create,
6+
isNpmTemplate,
67
multiselect,
8+
resolveCustomTemplate,
9+
resolveNpmTemplate,
10+
sanitizeCacheKey,
711
select,
812
text,
913
} from '../dist/index.js';
@@ -22,3 +26,39 @@ test('should expose selected clack prompt helpers from src entrypoint', () => {
2226
expect(publicApi.multiselect).toBe(promptsActual.multiselect);
2327
expect(publicApi.groupMultiselect).toBe(promptsActual.groupMultiselect);
2428
});
29+
30+
test('should export npm template utilities', () => {
31+
expect(typeof isNpmTemplate).toBe('function');
32+
expect(typeof resolveCustomTemplate).toBe('function');
33+
expect(typeof resolveNpmTemplate).toBe('function');
34+
expect(typeof sanitizeCacheKey).toBe('function');
35+
});
36+
37+
test('should detect npm templates correctly', () => {
38+
// npm: prefix
39+
expect(isNpmTemplate('npm:my-package')).toBe(true);
40+
expect(isNpmTemplate('npm:@scope/package')).toBe(true);
41+
42+
// Scoped packages
43+
expect(isNpmTemplate('@scope/package')).toBe(true);
44+
45+
// Pure package names
46+
expect(isNpmTemplate('my-package')).toBe(true);
47+
expect(isNpmTemplate('my-package-name')).toBe(true);
48+
49+
// Not npm templates
50+
expect(isNpmTemplate('./local-path')).toBe(false);
51+
expect(isNpmTemplate('../relative-path')).toBe(false);
52+
expect(isNpmTemplate('github:user/repo')).toBe(false);
53+
expect(isNpmTemplate('https://example.com')).toBe(false);
54+
expect(isNpmTemplate('/absolute/path')).toBe(false);
55+
});
56+
57+
test('should sanitize cache keys correctly', () => {
58+
expect(sanitizeCacheKey('my-package', '1.0.0')).toBe('my-package@1.0.0');
59+
expect(sanitizeCacheKey('@scope/package', 'latest')).toBe(
60+
'@scope/package@latest',
61+
);
62+
expect(sanitizeCacheKey('my-package', '')).toBe('my-package@latest');
63+
expect(sanitizeCacheKey('my/package', '1.0.0')).toBe('my_package@1.0.0');
64+
});

0 commit comments

Comments
 (0)