@stripchatdev/create-stripchat-extension 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/dist/bin/cli.d.ts +2 -0
- package/dist/bin/cli.js +176 -0
- package/dist/package.json +28 -0
- package/dist/src/constants.d.ts +19 -0
- package/dist/src/constants.js +48 -0
- package/dist/src/executor.d.ts +2 -0
- package/dist/src/executor.js +22 -0
- package/dist/src/executor.test.d.ts +1 -0
- package/dist/src/executor.test.js +77 -0
- package/dist/src/helpers/detectPackageManager.d.ts +2 -0
- package/dist/src/helpers/detectPackageManager.js +12 -0
- package/dist/src/helpers/detectPackageManager.test.d.ts +1 -0
- package/dist/src/helpers/detectPackageManager.test.js +47 -0
- package/dist/src/helpers/isDirectory.d.ts +1 -0
- package/dist/src/helpers/isDirectory.js +9 -0
- package/dist/src/helpers/isDirectory.test.d.ts +1 -0
- package/dist/src/helpers/isDirectory.test.js +22 -0
- package/dist/src/helpers/isTypescriptOnlyFile.d.ts +1 -0
- package/dist/src/helpers/isTypescriptOnlyFile.js +5 -0
- package/dist/src/helpers/isTypescriptOnlyFile.test.d.ts +1 -0
- package/dist/src/helpers/isTypescriptOnlyFile.test.js +22 -0
- package/dist/src/helpers/normalizeRelPath.d.ts +1 -0
- package/dist/src/helpers/normalizeRelPath.js +1 -0
- package/dist/src/helpers/normalizeRelPath.test.d.ts +1 -0
- package/dist/src/helpers/normalizeRelPath.test.js +19 -0
- package/dist/src/helpers/resolveTemplateExtension.d.ts +1 -0
- package/dist/src/helpers/resolveTemplateExtension.js +17 -0
- package/dist/src/helpers/resolveTemplateExtension.test.d.ts +1 -0
- package/dist/src/helpers/resolveTemplateExtension.test.js +50 -0
- package/dist/src/helpers/resolveTemplatesDir.d.ts +1 -0
- package/dist/src/helpers/resolveTemplatesDir.js +11 -0
- package/dist/src/helpers/resolveTemplatesDir.test.d.ts +1 -0
- package/dist/src/helpers/resolveTemplatesDir.test.js +21 -0
- package/dist/src/mappers/mapProjectConfigToTemplateModel.d.ts +2 -0
- package/dist/src/mappers/mapProjectConfigToTemplateModel.js +26 -0
- package/dist/src/mappers/mapProjectConfigToTemplateModel.test.d.ts +1 -0
- package/dist/src/mappers/mapProjectConfigToTemplateModel.test.js +89 -0
- package/dist/src/planner.d.ts +2 -0
- package/dist/src/planner.js +126 -0
- package/dist/src/planner.test.d.ts +1 -0
- package/dist/src/planner.test.js +46 -0
- package/dist/src/types.d.ts +50 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validators/index.d.ts +6 -0
- package/dist/src/validators/index.js +6 -0
- package/dist/src/validators/isValidCategory.d.ts +2 -0
- package/dist/src/validators/isValidCategory.js +2 -0
- package/dist/src/validators/isValidCategory.test.d.ts +1 -0
- package/dist/src/validators/isValidCategory.test.js +10 -0
- package/dist/src/validators/isValidFramework.d.ts +2 -0
- package/dist/src/validators/isValidFramework.js +2 -0
- package/dist/src/validators/isValidFramework.test.d.ts +1 -0
- package/dist/src/validators/isValidFramework.test.js +10 -0
- package/dist/src/validators/isValidLanguage.d.ts +2 -0
- package/dist/src/validators/isValidLanguage.js +2 -0
- package/dist/src/validators/isValidLanguage.test.d.ts +1 -0
- package/dist/src/validators/isValidLanguage.test.js +10 -0
- package/dist/src/validators/validateName.d.ts +1 -0
- package/dist/src/validators/validateName.js +10 -0
- package/dist/src/validators/validateName.test.d.ts +1 -0
- package/dist/src/validators/validateName.test.js +33 -0
- package/dist/src/validators/validateProjectDir.d.ts +1 -0
- package/dist/src/validators/validateProjectDir.js +6 -0
- package/dist/src/validators/validateProjectDir.test.d.ts +1 -0
- package/dist/src/validators/validateProjectDir.test.js +19 -0
- package/dist/src/validators/validateSlots.d.ts +5 -0
- package/dist/src/validators/validateSlots.js +15 -0
- package/dist/src/validators/validateSlots.test.d.ts +1 -0
- package/dist/src/validators/validateSlots.test.js +34 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +28 -0
- package/templates/.gitignore.ejs +33 -0
- package/templates/AGENTS.md.ejs +503 -0
- package/templates/CLAUDE.md +1 -0
- package/templates/README.md.ejs +38 -0
- package/templates/package.json.ejs +19 -0
- package/templates/public/icon.svg +4 -0
- package/templates/public/manifest.json.ejs +16 -0
- package/templates/public/resolveSlotPage.js.ejs +10 -0
- package/templates/src/slots/background/app.ts.ejs +37 -0
- package/templates/src/slots/background/background.html.ejs +16 -0
- package/templates/src/slots/background/main.ts.ejs +3 -0
- package/templates/src/slots/slot/app.ts.ejs +22 -0
- package/templates/src/slots/slot/app.tsx.ejs +22 -0
- package/templates/src/slots/slot/main.css.ejs +30 -0
- package/templates/src/slots/slot/main.ts.ejs +3 -0
- package/templates/src/slots/slot/main.tsx.ejs +9 -0
- package/templates/src/slots/slot/slot.html.ejs +21 -0
- package/templates/tsconfig.json.ejs +26 -0
- package/templates/vite.config.ts.ejs +23 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveTemplateExtension } from './resolveTemplateExtension.js';
|
|
3
|
+
describe('resolveTemplateExtension', () => {
|
|
4
|
+
describe('with TypeScript enabled', () => {
|
|
5
|
+
const isTypescript = true;
|
|
6
|
+
it('strips .ejs and keeps .ts', () => {
|
|
7
|
+
expect(resolveTemplateExtension('main.ts.ejs', isTypescript)).toBe('main.ts');
|
|
8
|
+
});
|
|
9
|
+
it('strips .ejs and keeps .tsx', () => {
|
|
10
|
+
expect(resolveTemplateExtension('app.tsx.ejs', isTypescript)).toBe('app.tsx');
|
|
11
|
+
});
|
|
12
|
+
it('keeps .ts without .ejs suffix', () => {
|
|
13
|
+
expect(resolveTemplateExtension('main.ts', isTypescript)).toBe('main.ts');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('with JavaScript (isTypescript = false)', () => {
|
|
17
|
+
const isTypescript = false;
|
|
18
|
+
it('converts .ts to .js', () => {
|
|
19
|
+
expect(resolveTemplateExtension('main.ts.ejs', isTypescript)).toBe('main.js');
|
|
20
|
+
});
|
|
21
|
+
it('converts .tsx to .jsx', () => {
|
|
22
|
+
expect(resolveTemplateExtension('app.tsx.ejs', isTypescript)).toBe('app.jsx');
|
|
23
|
+
});
|
|
24
|
+
it('converts .ts without .ejs suffix', () => {
|
|
25
|
+
expect(resolveTemplateExtension('main.ts', isTypescript)).toBe('main.js');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('non-ts/tsx extensions', () => {
|
|
29
|
+
it('strips .ejs from .json.ejs', () => {
|
|
30
|
+
expect(resolveTemplateExtension('package.json.ejs', true)).toBe('package.json');
|
|
31
|
+
});
|
|
32
|
+
it('strips .ejs from .html.ejs', () => {
|
|
33
|
+
expect(resolveTemplateExtension('slot.html.ejs', false)).toBe('slot.html');
|
|
34
|
+
});
|
|
35
|
+
it('leaves .svg unchanged (no .ejs)', () => {
|
|
36
|
+
expect(resolveTemplateExtension('icon.svg', false)).toBe('icon.svg');
|
|
37
|
+
});
|
|
38
|
+
it('strips .ejs from bare .ejs file', () => {
|
|
39
|
+
expect(resolveTemplateExtension('.gitignore.ejs', false)).toBe('.gitignore');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('compound extensions like vite.config.ts', () => {
|
|
43
|
+
it('keeps .ts when typescript', () => {
|
|
44
|
+
expect(resolveTemplateExtension('vite.config.ts.ejs', true)).toBe('vite.config.ts');
|
|
45
|
+
});
|
|
46
|
+
it('converts .ts to .js when javascript', () => {
|
|
47
|
+
expect(resolveTemplateExtension('vite.config.ts.ejs', false)).toBe('vite.config.js');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const resolveTemplatesDir: (moduleDir: string) => string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { isDirectory } from './isDirectory.js';
|
|
3
|
+
export const resolveTemplatesDir = (moduleDir) => {
|
|
4
|
+
const candidates = ['../templates', '../../templates'].map((candidate) => resolve(moduleDir, candidate));
|
|
5
|
+
for (const candidate of candidates) {
|
|
6
|
+
if (isDirectory(candidate)) {
|
|
7
|
+
return candidate;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
throw new Error(`Could not find templates directory near ${moduleDir}. Tried: ${candidates.join(', ')}`);
|
|
11
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { resolveTemplatesDir } from './resolveTemplatesDir.js';
|
|
3
|
+
vi.mock('./isDirectory', () => ({
|
|
4
|
+
isDirectory: (path) => path.endsWith('/templates') && path.includes('good'),
|
|
5
|
+
}));
|
|
6
|
+
describe('resolveTemplatesDir', () => {
|
|
7
|
+
it('returns ../templates when it exists', () => {
|
|
8
|
+
const result = resolveTemplatesDir('/project/good/src');
|
|
9
|
+
expect(result).toMatch(/\/templates$/);
|
|
10
|
+
});
|
|
11
|
+
it('returns ../../templates when ../templates does not exist', () => {
|
|
12
|
+
const result = resolveTemplatesDir('/project/good/dist/src');
|
|
13
|
+
expect(result).toMatch(/\/templates$/);
|
|
14
|
+
});
|
|
15
|
+
it('throws when no templates directory is found', () => {
|
|
16
|
+
expect(() => resolveTemplatesDir('/project/bad/src')).toThrow(/Could not find templates directory/);
|
|
17
|
+
});
|
|
18
|
+
it('includes tried paths in error message', () => {
|
|
19
|
+
expect(() => resolveTemplatesDir('/project/bad/src')).toThrow(/Tried:/);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { EXT_HELPER_VERSION, JSX_FRAMEWORKS, PACKAGE_MANAGER_COMMANDS_MAP, SLOT_TYPES } from '../constants.js';
|
|
2
|
+
const buildSlotResolverEntries = (config) => config.slots.map((slot) => ({
|
|
3
|
+
slotType: SLOT_TYPES[slot],
|
|
4
|
+
slot,
|
|
5
|
+
}));
|
|
6
|
+
export const mapProjectConfigToTemplateData = (config) => {
|
|
7
|
+
const isTypescript = config.language === 'ts';
|
|
8
|
+
const isJsxFramework = JSX_FRAMEWORKS.includes(config.framework);
|
|
9
|
+
const ext = isTypescript ? 'ts' : 'js';
|
|
10
|
+
const jsxExt = isTypescript ? 'tsx' : 'jsx';
|
|
11
|
+
return {
|
|
12
|
+
config,
|
|
13
|
+
ext,
|
|
14
|
+
extHelperVersion: EXT_HELPER_VERSION,
|
|
15
|
+
framework: config.framework,
|
|
16
|
+
isJsxFramework,
|
|
17
|
+
isTypescript,
|
|
18
|
+
jsxExt,
|
|
19
|
+
language: config.language,
|
|
20
|
+
name: config.name,
|
|
21
|
+
packageManager: config.packageManager,
|
|
22
|
+
packageManagerCommands: PACKAGE_MANAGER_COMMANDS_MAP[config.packageManager],
|
|
23
|
+
slotResolverEntries: buildSlotResolverEntries(config),
|
|
24
|
+
slots: config.slots,
|
|
25
|
+
};
|
|
26
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { EXT_HELPER_VERSION } from '../constants.js';
|
|
3
|
+
import { mapProjectConfigToTemplateData } from '../mappers/mapProjectConfigToTemplateModel.js';
|
|
4
|
+
const makeConfig = (overrides = {}) => ({
|
|
5
|
+
category: 'games-and-fun',
|
|
6
|
+
framework: 'vanilla',
|
|
7
|
+
language: 'ts',
|
|
8
|
+
name: 'test-ext',
|
|
9
|
+
packageManager: 'npm',
|
|
10
|
+
slots: ['mainGameFun', 'background'],
|
|
11
|
+
...overrides,
|
|
12
|
+
});
|
|
13
|
+
describe('mapProjectConfigToTemplateData', () => {
|
|
14
|
+
it('sets typescript flags when language is ts', () => {
|
|
15
|
+
const result = mapProjectConfigToTemplateData(makeConfig({ language: 'ts' }));
|
|
16
|
+
expect(result.isTypescript).toBe(true);
|
|
17
|
+
expect(result.ext).toBe('ts');
|
|
18
|
+
expect(result.jsxExt).toBe('tsx');
|
|
19
|
+
});
|
|
20
|
+
it('sets javascript flags when language is js', () => {
|
|
21
|
+
const result = mapProjectConfigToTemplateData(makeConfig({ language: 'js' }));
|
|
22
|
+
expect(result.isTypescript).toBe(false);
|
|
23
|
+
expect(result.ext).toBe('js');
|
|
24
|
+
expect(result.jsxExt).toBe('jsx');
|
|
25
|
+
});
|
|
26
|
+
it('marks preact as a JSX framework', () => {
|
|
27
|
+
const result = mapProjectConfigToTemplateData(makeConfig({ framework: 'preact' }));
|
|
28
|
+
expect(result.isJsxFramework).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
it('marks vanilla as a non-JSX framework', () => {
|
|
31
|
+
const result = mapProjectConfigToTemplateData(makeConfig({ framework: 'vanilla' }));
|
|
32
|
+
expect(result.isJsxFramework).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
it('maps each slot to its SLOT_TYPE constant', () => {
|
|
35
|
+
const result = mapProjectConfigToTemplateData(makeConfig({ slots: ['mainGameFun', 'background', 'rightOverlay'] }));
|
|
36
|
+
expect(result.slotResolverEntries).toEqual([
|
|
37
|
+
{ slot: 'mainGameFun', slotType: 'EXTENSION_SLOT_MAIN_GAME_FUN' },
|
|
38
|
+
{ slot: 'background', slotType: 'EXTENSION_SLOT_BACKGROUND' },
|
|
39
|
+
{ slot: 'rightOverlay', slotType: 'EXTENSION_SLOT_RIGHT_OVERLAY' },
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
it('maps sex-toys category slots correctly', () => {
|
|
43
|
+
const result = mapProjectConfigToTemplateData(makeConfig({
|
|
44
|
+
category: 'sex-toys',
|
|
45
|
+
slots: ['mainSexToy', 'background', 'moveableOverlay'],
|
|
46
|
+
}));
|
|
47
|
+
expect(result.slotResolverEntries).toEqual([
|
|
48
|
+
{ slot: 'mainSexToy', slotType: 'EXTENSION_SLOT_MAIN_SEX_TOY' },
|
|
49
|
+
{ slot: 'background', slotType: 'EXTENSION_SLOT_BACKGROUND' },
|
|
50
|
+
{ slot: 'moveableOverlay', slotType: 'EXTENSION_SLOT_MOVEABLE_OVERLAY' },
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
it('preserves slot order from config', () => {
|
|
54
|
+
const result = mapProjectConfigToTemplateData(makeConfig({ slots: ['background', 'mainGameFun'] }));
|
|
55
|
+
expect(result.slotResolverEntries[0].slot).toBe('background');
|
|
56
|
+
expect(result.slotResolverEntries[1].slot).toBe('mainGameFun');
|
|
57
|
+
});
|
|
58
|
+
it('returns empty array when no slots provided', () => {
|
|
59
|
+
const result = mapProjectConfigToTemplateData(makeConfig({ slots: [] }));
|
|
60
|
+
expect(result.slotResolverEntries).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
it('passes config reference unchanged', () => {
|
|
63
|
+
const config = makeConfig();
|
|
64
|
+
const result = mapProjectConfigToTemplateData(config);
|
|
65
|
+
expect(result.config).toBe(config);
|
|
66
|
+
});
|
|
67
|
+
it('passes name, framework, language, and slots from config', () => {
|
|
68
|
+
const config = makeConfig({
|
|
69
|
+
framework: 'preact',
|
|
70
|
+
language: 'js',
|
|
71
|
+
name: 'my-project',
|
|
72
|
+
slots: ['mainGameFun'],
|
|
73
|
+
});
|
|
74
|
+
const result = mapProjectConfigToTemplateData(config);
|
|
75
|
+
expect(result.name).toBe('my-project');
|
|
76
|
+
expect(result.framework).toBe('preact');
|
|
77
|
+
expect(result.language).toBe('js');
|
|
78
|
+
expect(result.slots).toEqual(['mainGameFun']);
|
|
79
|
+
});
|
|
80
|
+
it('does not include entryModulePath or slot', () => {
|
|
81
|
+
const result = mapProjectConfigToTemplateData(makeConfig());
|
|
82
|
+
expect(result.entryModulePath).toBeUndefined();
|
|
83
|
+
expect(result.slot).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
it('sets extHelperVersion from package version', () => {
|
|
86
|
+
const result = mapProjectConfigToTemplateData(makeConfig());
|
|
87
|
+
expect(result.extHelperVersion).toBe(EXT_HELPER_VERSION);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, relative } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { isTypescriptOnlyFile } from './helpers/isTypescriptOnlyFile.js';
|
|
6
|
+
import { normalizeRelPath } from './helpers/normalizeRelPath.js';
|
|
7
|
+
import { resolveTemplateExtension } from './helpers/resolveTemplateExtension.js';
|
|
8
|
+
import { resolveTemplatesDir } from './helpers/resolveTemplatesDir.js';
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const TEMPLATES_DIR = resolveTemplatesDir(__dirname);
|
|
11
|
+
const SLOTS_TEMPLATE_DIR = join(TEMPLATES_DIR, 'src', 'slots');
|
|
12
|
+
const GENERIC_SLOT_DIR = join(SLOTS_TEMPLATE_DIR, 'slot');
|
|
13
|
+
const shouldSkipGlobalTemplate = (normalizedRelPath, data) => isTypescriptOnlyFile(normalizedRelPath) && !data.isTypescript;
|
|
14
|
+
const shouldSkipSlotTemplate = (normalizedRelPath, allPaths, isJsxFramework) => {
|
|
15
|
+
if (normalizedRelPath.endsWith('.tsx.ejs')) {
|
|
16
|
+
const tsCounterpart = normalizedRelPath.replace(/\.tsx\.ejs$/, '.ts.ejs');
|
|
17
|
+
if (allPaths.has(tsCounterpart) && !isJsxFramework) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (normalizedRelPath.endsWith('.ts.ejs')) {
|
|
22
|
+
const tsxCounterpart = normalizedRelPath.replace(/\.ts\.ejs$/, '.tsx.ejs');
|
|
23
|
+
if (allPaths.has(tsxCounterpart) && isJsxFramework) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
};
|
|
29
|
+
const listFiles = async (dir) => {
|
|
30
|
+
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
|
31
|
+
return entries
|
|
32
|
+
.filter((entry) => entry.isFile())
|
|
33
|
+
.map((entry) => ({
|
|
34
|
+
relPath: normalizeRelPath(relative(dir, join(entry.parentPath, entry.name))),
|
|
35
|
+
srcPath: join(entry.parentPath, entry.name),
|
|
36
|
+
}))
|
|
37
|
+
.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
38
|
+
};
|
|
39
|
+
const planGlobalTemplates = async (projectDir, data) => {
|
|
40
|
+
const files = await listFiles(TEMPLATES_DIR);
|
|
41
|
+
const commands = [];
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
if (file.relPath.startsWith('src/slots/')) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (shouldSkipGlobalTemplate(file.relPath, data)) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const isEjs = file.relPath.endsWith('.ejs');
|
|
50
|
+
if (!isEjs) {
|
|
51
|
+
commands.push({
|
|
52
|
+
destPath: join(projectDir, ...file.relPath.split('/')),
|
|
53
|
+
srcPath: file.srcPath,
|
|
54
|
+
type: 'copy',
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
commands.push({
|
|
59
|
+
data,
|
|
60
|
+
destPath: join(projectDir, ...resolveTemplateExtension(file.relPath, data.isTypescript).split('/')),
|
|
61
|
+
srcPath: file.srcPath,
|
|
62
|
+
type: 'render',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return commands;
|
|
66
|
+
};
|
|
67
|
+
const buildSlotTemplateData = (data, slotKey) => {
|
|
68
|
+
const isBackground = slotKey === 'background';
|
|
69
|
+
const entryExt = isBackground || !data.isJsxFramework ? data.ext : data.jsxExt;
|
|
70
|
+
return {
|
|
71
|
+
...data,
|
|
72
|
+
entryModulePath: `/src/slots/${slotKey}/main.${entryExt}`,
|
|
73
|
+
slot: slotKey,
|
|
74
|
+
stylePath: `/src/slots/${slotKey}/main.css`,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
const resolveSlotOutputPath = (templateRelPath, slotKey, templateDirName) => {
|
|
78
|
+
const parts = templateRelPath.split('/');
|
|
79
|
+
const fileName = parts.at(-1);
|
|
80
|
+
const dirPrefix = parts.slice(0, -1).join('/');
|
|
81
|
+
const renamedFile = fileName.startsWith(`${templateDirName}.`)
|
|
82
|
+
? `${slotKey}${fileName.slice(templateDirName.length)}`
|
|
83
|
+
: fileName;
|
|
84
|
+
return dirPrefix ? `${dirPrefix}/${renamedFile}` : renamedFile;
|
|
85
|
+
};
|
|
86
|
+
const planSlotTemplates = async (projectDir, data, slotKey) => {
|
|
87
|
+
const specificDir = join(SLOTS_TEMPLATE_DIR, slotKey);
|
|
88
|
+
const hasSpecificDir = existsSync(specificDir);
|
|
89
|
+
const templateDir = hasSpecificDir ? specificDir : GENERIC_SLOT_DIR;
|
|
90
|
+
const templateDirName = hasSpecificDir ? slotKey : 'slot';
|
|
91
|
+
const slotData = buildSlotTemplateData(data, slotKey);
|
|
92
|
+
const files = await listFiles(templateDir);
|
|
93
|
+
const allPaths = new Set(files.map((f) => f.relPath));
|
|
94
|
+
const commands = [];
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
if (shouldSkipSlotTemplate(file.relPath, allPaths, data.isJsxFramework)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const outputRelPath = resolveSlotOutputPath(file.relPath, slotKey, templateDirName);
|
|
100
|
+
const resolvedPath = resolveTemplateExtension(outputRelPath, data.isTypescript);
|
|
101
|
+
const isHtml = resolvedPath.endsWith('.html');
|
|
102
|
+
const destPath = isHtml
|
|
103
|
+
? join(projectDir, resolvedPath)
|
|
104
|
+
: join(projectDir, 'src', 'slots', slotKey, ...resolvedPath.split('/'));
|
|
105
|
+
if (!file.relPath.endsWith('.ejs')) {
|
|
106
|
+
commands.push({
|
|
107
|
+
destPath,
|
|
108
|
+
srcPath: file.srcPath,
|
|
109
|
+
type: 'copy',
|
|
110
|
+
});
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
commands.push({
|
|
114
|
+
data: slotData,
|
|
115
|
+
destPath,
|
|
116
|
+
srcPath: file.srcPath,
|
|
117
|
+
type: 'render',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return commands;
|
|
121
|
+
};
|
|
122
|
+
export const planProject = async (data, projectDir) => {
|
|
123
|
+
const globalCommands = await planGlobalTemplates(projectDir, data);
|
|
124
|
+
const slotCommandArrays = await Promise.all(data.slots.map((slot) => planSlotTemplates(projectDir, data, slot)));
|
|
125
|
+
return [...globalCommands, ...slotCommandArrays.flat()];
|
|
126
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CATEGORIES, FRAMEWORKS, LANGUAGES, REQUIRED_SLOTS_BY_CATEGORY, SELECTABLE_SLOTS_BY_CATEGORY, } from './constants.js';
|
|
3
|
+
import { mapProjectConfigToTemplateData } from './mappers/mapProjectConfigToTemplateModel.js';
|
|
4
|
+
import { planProject } from './planner.js';
|
|
5
|
+
const PROJECT_DIR = '/output/my-ext';
|
|
6
|
+
const buildLabel = (language, framework, category, slots) => `${language} + ${framework}, ${category} (${slots.join(', ')})`;
|
|
7
|
+
const SLOT_COMBOS_BY_CATEGORY = Object.fromEntries(CATEGORIES.map((category) => {
|
|
8
|
+
const required = REQUIRED_SLOTS_BY_CATEGORY[category];
|
|
9
|
+
const selectable = SELECTABLE_SLOTS_BY_CATEGORY[category];
|
|
10
|
+
return [
|
|
11
|
+
category,
|
|
12
|
+
[required, ...(selectable.length > 0 ? [[...required, ...selectable]] : [])],
|
|
13
|
+
];
|
|
14
|
+
}));
|
|
15
|
+
const TEST_MATRIX = CATEGORIES.flatMap((category) => SLOT_COMBOS_BY_CATEGORY[category].flatMap((slots) => FRAMEWORKS.flatMap((framework) => LANGUAGES.map((language) => ({
|
|
16
|
+
category,
|
|
17
|
+
framework,
|
|
18
|
+
label: buildLabel(language, framework, category, slots),
|
|
19
|
+
language,
|
|
20
|
+
slots,
|
|
21
|
+
})))));
|
|
22
|
+
const normalizeCommands = (commands) => commands.map((cmd) => {
|
|
23
|
+
const srcRel = cmd.srcPath.replace(/^.*\/templates\//, 'templates/');
|
|
24
|
+
const destRel = cmd.destPath.replace(/^\/output\//, '');
|
|
25
|
+
if (cmd.type === 'copy') {
|
|
26
|
+
return { type: 'copy', src: srcRel, dest: destRel };
|
|
27
|
+
}
|
|
28
|
+
return { type: 'render', src: srcRel, dest: destRel };
|
|
29
|
+
});
|
|
30
|
+
describe('planProject', () => {
|
|
31
|
+
for (const { category, framework, label, language, slots } of TEST_MATRIX) {
|
|
32
|
+
it(label, async () => {
|
|
33
|
+
const config = {
|
|
34
|
+
category,
|
|
35
|
+
framework,
|
|
36
|
+
language,
|
|
37
|
+
name: 'my-ext',
|
|
38
|
+
packageManager: 'npm',
|
|
39
|
+
slots,
|
|
40
|
+
};
|
|
41
|
+
const data = mapProjectConfigToTemplateData(config);
|
|
42
|
+
const commands = await planProject(data, PROJECT_DIR);
|
|
43
|
+
expect(normalizeCommands(commands)).toMatchSnapshot();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type TCategory = 'games-and-fun' | 'sex-toys';
|
|
2
|
+
export type TFramework = 'vanilla' | 'preact';
|
|
3
|
+
export type TLanguage = 'ts' | 'js';
|
|
4
|
+
export type TSlot = 'mainGameFun' | 'mainSexToy' | 'rightOverlay' | 'moveableOverlay' | 'background';
|
|
5
|
+
export type TPackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun';
|
|
6
|
+
export type TProjectConfig = {
|
|
7
|
+
category: TCategory;
|
|
8
|
+
framework: TFramework;
|
|
9
|
+
language: TLanguage;
|
|
10
|
+
name: string;
|
|
11
|
+
packageManager: TPackageManager;
|
|
12
|
+
slots: TSlot[];
|
|
13
|
+
};
|
|
14
|
+
export type TSlotResolverEntry = {
|
|
15
|
+
slot: string;
|
|
16
|
+
slotType: string;
|
|
17
|
+
};
|
|
18
|
+
export type TPackageManagerCommands = {
|
|
19
|
+
build: string;
|
|
20
|
+
dev: string;
|
|
21
|
+
install: string;
|
|
22
|
+
};
|
|
23
|
+
export type TTemplateData = {
|
|
24
|
+
config: TProjectConfig;
|
|
25
|
+
entryModulePath?: string;
|
|
26
|
+
ext: 'ts' | 'js';
|
|
27
|
+
extHelperVersion: string;
|
|
28
|
+
framework: TFramework;
|
|
29
|
+
isJsxFramework: boolean;
|
|
30
|
+
isTypescript: boolean;
|
|
31
|
+
jsxExt: 'tsx' | 'jsx';
|
|
32
|
+
language: TLanguage;
|
|
33
|
+
name: string;
|
|
34
|
+
packageManager: TPackageManager;
|
|
35
|
+
packageManagerCommands: TPackageManagerCommands;
|
|
36
|
+
slot?: string;
|
|
37
|
+
slotResolverEntries: TSlotResolverEntry[];
|
|
38
|
+
slots: TSlot[];
|
|
39
|
+
stylePath?: string;
|
|
40
|
+
};
|
|
41
|
+
export type TTemplateCommand = {
|
|
42
|
+
destPath: string;
|
|
43
|
+
srcPath: string;
|
|
44
|
+
type: 'copy';
|
|
45
|
+
} | {
|
|
46
|
+
data: TTemplateData;
|
|
47
|
+
destPath: string;
|
|
48
|
+
srcPath: string;
|
|
49
|
+
type: 'render';
|
|
50
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { isValidCategory } from './isValidCategory.js';
|
|
2
|
+
export { isValidFramework } from './isValidFramework.js';
|
|
3
|
+
export { isValidLanguage } from './isValidLanguage.js';
|
|
4
|
+
export { validateName } from './validateName.js';
|
|
5
|
+
export { validateProjectDir } from './validateProjectDir.js';
|
|
6
|
+
export { validateSlots } from './validateSlots.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { isValidCategory } from './isValidCategory.js';
|
|
2
|
+
export { isValidFramework } from './isValidFramework.js';
|
|
3
|
+
export { isValidLanguage } from './isValidLanguage.js';
|
|
4
|
+
export { validateName } from './validateName.js';
|
|
5
|
+
export { validateProjectDir } from './validateProjectDir.js';
|
|
6
|
+
export { validateSlots } from './validateSlots.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isValidCategory } from './isValidCategory.js';
|
|
3
|
+
describe('isValidCategory', () => {
|
|
4
|
+
it.each(['games-and-fun', 'sex-toys'])('returns true for valid category: %s', (cat) => {
|
|
5
|
+
expect(isValidCategory(cat)).toBe(true);
|
|
6
|
+
});
|
|
7
|
+
it.each(['invalid', '', 'Games-and-Fun', 'games_and_fun'])('returns false for invalid category: %s', (cat) => {
|
|
8
|
+
expect(isValidCategory(cat)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isValidFramework } from './isValidFramework.js';
|
|
3
|
+
describe('isValidFramework', () => {
|
|
4
|
+
it.each(['vanilla', 'preact'])('returns true for valid framework: %s', (fw) => {
|
|
5
|
+
expect(isValidFramework(fw)).toBe(true);
|
|
6
|
+
});
|
|
7
|
+
it.each(['react', '', 'Vanilla', 'vue'])('returns false for invalid framework: %s', (fw) => {
|
|
8
|
+
expect(isValidFramework(fw)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isValidLanguage } from './isValidLanguage.js';
|
|
3
|
+
describe('isValidLanguage', () => {
|
|
4
|
+
it.each(['ts', 'js'])('returns true for valid language: %s', (lang) => {
|
|
5
|
+
expect(isValidLanguage(lang)).toBe(true);
|
|
6
|
+
});
|
|
7
|
+
it.each(['typescript', '', 'TS', 'javascript'])('returns false for invalid language: %s', (lang) => {
|
|
8
|
+
expect(isValidLanguage(lang)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const validateName: (value: string) => string | true;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const validateName = (value) => {
|
|
2
|
+
const trimmed = value.trim();
|
|
3
|
+
if (!trimmed) {
|
|
4
|
+
return 'Project name is required';
|
|
5
|
+
}
|
|
6
|
+
if (!/^[a-z0-9@][a-z0-9._-]*[a-z0-9]?$/i.test(trimmed)) {
|
|
7
|
+
return 'Invalid project name. Use lowercase letters, numbers, hyphens, and dots.';
|
|
8
|
+
}
|
|
9
|
+
return true;
|
|
10
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { validateName } from './validateName.js';
|
|
3
|
+
describe('validateName', () => {
|
|
4
|
+
it.each([
|
|
5
|
+
'my-extension',
|
|
6
|
+
'my.extension',
|
|
7
|
+
'my-ext-123',
|
|
8
|
+
'a',
|
|
9
|
+
'@scope',
|
|
10
|
+
'MyExtension',
|
|
11
|
+
])('accepts valid name: %s', (name) => {
|
|
12
|
+
expect(validateName(name)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
it('accepts a name with leading/trailing whitespace by trimming', () => {
|
|
15
|
+
expect(validateName(' my-extension ')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
it('rejects empty string', () => {
|
|
18
|
+
expect(validateName('')).toBe('Project name is required');
|
|
19
|
+
});
|
|
20
|
+
it('rejects whitespace-only string', () => {
|
|
21
|
+
expect(validateName(' ')).toBe('Project name is required');
|
|
22
|
+
});
|
|
23
|
+
it.each([
|
|
24
|
+
'has spaces',
|
|
25
|
+
'-starts-with-dash',
|
|
26
|
+
'.starts-with-dot',
|
|
27
|
+
'special!chars',
|
|
28
|
+
'no/slashes',
|
|
29
|
+
])('rejects invalid name: %s', (name) => {
|
|
30
|
+
expect(validateName(name)).toBeTypeOf('string');
|
|
31
|
+
expect(validateName(name)).not.toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const validateProjectDir: (projectDir: string, projectName: string) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { validateProjectDir } from './validateProjectDir.js';
|
|
3
|
+
vi.mock('node:fs', () => ({
|
|
4
|
+
existsSync: (path) => path.includes('existing-dir'),
|
|
5
|
+
}));
|
|
6
|
+
describe('validateProjectDir', () => {
|
|
7
|
+
it('does not throw when directory does not exist', () => {
|
|
8
|
+
expect(() => validateProjectDir('/tmp/new-dir', 'new-dir')).not.toThrow();
|
|
9
|
+
});
|
|
10
|
+
it('includes project name and dir in the error', () => {
|
|
11
|
+
try {
|
|
12
|
+
validateProjectDir('/tmp/existing-dir', 'existing-dir');
|
|
13
|
+
expect.fail('should have thrown');
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
expect(error.message).toContain('existing-dir');
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { SELECTABLE_SLOTS_BY_CATEGORY } from '../constants.js';
|
|
2
|
+
export const validateSlots = (slots, category) => {
|
|
3
|
+
const allowed = new Set(SELECTABLE_SLOTS_BY_CATEGORY[category]);
|
|
4
|
+
const valid = [];
|
|
5
|
+
const invalid = [];
|
|
6
|
+
for (const slot of slots) {
|
|
7
|
+
if (allowed.has(slot)) {
|
|
8
|
+
valid.push(slot);
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
invalid.push(slot);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return { invalid, valid };
|
|
15
|
+
};
|