create-landing-app 0.1.0

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.
Files changed (91) hide show
  1. package/dist/index.js +21 -0
  2. package/dist/install.js +18 -0
  3. package/dist/prompts.js +62 -0
  4. package/dist/scaffold.js +159 -0
  5. package/dist/utils/__tests__/merge-json.test.js +144 -0
  6. package/dist/utils/__tests__/replace-tokens.test.js +212 -0
  7. package/dist/utils/copy-dir.js +22 -0
  8. package/dist/utils/merge-json.js +19 -0
  9. package/dist/utils/replace-tokens.js +8 -0
  10. package/package.json +48 -0
  11. package/templates/nextjs/base/.env.example +8 -0
  12. package/templates/nextjs/base/.github/workflows/ci.yml +40 -0
  13. package/templates/nextjs/base/.husky/commit-msg +7 -0
  14. package/templates/nextjs/base/.husky/pre-commit +3 -0
  15. package/templates/nextjs/base/.husky/pre-push +46 -0
  16. package/templates/nextjs/base/.lighthouserc.json +28 -0
  17. package/templates/nextjs/base/.prettierignore +11 -0
  18. package/templates/nextjs/base/.prettierrc.json +10 -0
  19. package/templates/nextjs/base/Dockerfile +42 -0
  20. package/templates/nextjs/base/app/globals.css +82 -0
  21. package/templates/nextjs/base/app/layout.tsx +32 -0
  22. package/templates/nextjs/base/app/not-found.tsx +13 -0
  23. package/templates/nextjs/base/app/page.tsx +15 -0
  24. package/templates/nextjs/base/app/robots.ts +9 -0
  25. package/templates/nextjs/base/commitlint.config.mjs +32 -0
  26. package/templates/nextjs/base/components/navs/navbar-mobile.tsx +39 -0
  27. package/templates/nextjs/base/components/navs/navbar.tsx +39 -0
  28. package/templates/nextjs/base/components/providers.tsx +12 -0
  29. package/templates/nextjs/base/components/sections/features-section.tsx +78 -0
  30. package/templates/nextjs/base/components/sections/footer-section.tsx +98 -0
  31. package/templates/nextjs/base/components/sections/hero-section.tsx +74 -0
  32. package/templates/nextjs/base/components/ui/accordion.tsx +47 -0
  33. package/templates/nextjs/base/components/ui/button.tsx +44 -0
  34. package/templates/nextjs/base/components/ui/dialog.tsx +61 -0
  35. package/templates/nextjs/base/components/ui/dropdown-menu.tsx +55 -0
  36. package/templates/nextjs/base/components/ui/sonner.tsx +6 -0
  37. package/templates/nextjs/base/components.json +19 -0
  38. package/templates/nextjs/base/constants/common.ts +15 -0
  39. package/templates/nextjs/base/eslint.config.mjs +25 -0
  40. package/templates/nextjs/base/lib/metadata.ts +36 -0
  41. package/templates/nextjs/base/lib/utils.ts +7 -0
  42. package/templates/nextjs/base/next.config.ts +33 -0
  43. package/templates/nextjs/base/package.json +61 -0
  44. package/templates/nextjs/base/postcss.config.mjs +7 -0
  45. package/templates/nextjs/base/scripts/build-and-scan.sh +127 -0
  46. package/templates/nextjs/base/scripts/lighthouse-check.sh +86 -0
  47. package/templates/nextjs/base/styles/theme.css +63 -0
  48. package/templates/nextjs/base/tsconfig.json +21 -0
  49. package/templates/nextjs/base/types/index.ts +16 -0
  50. package/templates/nextjs/optional/docker/files/.dockerignore +6 -0
  51. package/templates/nextjs/optional/docker/files/Dockerfile +36 -0
  52. package/templates/nextjs/optional/docker/files/docker-compose.yml +9 -0
  53. package/templates/nextjs/optional/i18n-dict/files/app/[lang]/layout.tsx +19 -0
  54. package/templates/nextjs/optional/i18n-dict/files/app/[lang]/page.tsx +15 -0
  55. package/templates/nextjs/optional/i18n-dict/files/components/navs/language-switcher.tsx +39 -0
  56. package/templates/nextjs/optional/i18n-dict/files/components/navs/navbar-mobile.tsx +41 -0
  57. package/templates/nextjs/optional/i18n-dict/files/components/navs/navbar.tsx +41 -0
  58. package/templates/nextjs/optional/i18n-dict/files/components/providers.tsx +16 -0
  59. package/templates/nextjs/optional/i18n-dict/files/components/sections/features-section.tsx +80 -0
  60. package/templates/nextjs/optional/i18n-dict/files/components/sections/footer-section.tsx +98 -0
  61. package/templates/nextjs/optional/i18n-dict/files/dictionaries/en.json +21 -0
  62. package/templates/nextjs/optional/i18n-dict/files/dictionaries/vi.json +21 -0
  63. package/templates/nextjs/optional/i18n-dict/files/get-dictionary.ts +10 -0
  64. package/templates/nextjs/optional/i18n-dict/files/i18n-config.ts +6 -0
  65. package/templates/nextjs/optional/i18n-dict/files/lib/dict-context.tsx +23 -0
  66. package/templates/nextjs/optional/i18n-dict/files/middleware.ts +31 -0
  67. package/templates/nextjs/optional/i18n-dict/pkg.json +9 -0
  68. package/templates/nextjs/optional/sections/about/files/components/sections/about-section.tsx +36 -0
  69. package/templates/nextjs/optional/sections/about/inject/app__[lang]__page.tsx +5 -0
  70. package/templates/nextjs/optional/sections/about/inject/app__page.tsx +5 -0
  71. package/templates/nextjs/optional/sections/about/inject/constants__common.ts +2 -0
  72. package/templates/nextjs/optional/sections/blog/files/components/sections/blog-section.tsx +191 -0
  73. package/templates/nextjs/optional/sections/blog/inject/app__[lang]__page.tsx +5 -0
  74. package/templates/nextjs/optional/sections/blog/inject/app__page.tsx +5 -0
  75. package/templates/nextjs/optional/sections/blog/inject/constants__common.ts +2 -0
  76. package/templates/nextjs/optional/sections/contact/files/components/sections/contact-section.tsx +79 -0
  77. package/templates/nextjs/optional/sections/contact/inject/app__[lang]__page.tsx +5 -0
  78. package/templates/nextjs/optional/sections/contact/inject/app__page.tsx +5 -0
  79. package/templates/nextjs/optional/sections/contact/inject/constants__common.ts +2 -0
  80. package/templates/nextjs/optional/tanstack-query/files/lib/custom-fetch.ts +9 -0
  81. package/templates/nextjs/optional/tanstack-query/files/lib/query-client.ts +21 -0
  82. package/templates/nextjs/optional/tanstack-query/inject/components__providers.tsx +9 -0
  83. package/templates/nextjs/optional/tanstack-query/pkg.json +5 -0
  84. package/templates/nextjs/optional/zustand/files/store/ui-store.ts +16 -0
  85. package/templates/nextjs/optional/zustand/inject/components__providers.tsx +3 -0
  86. package/templates/nextjs/optional/zustand/pkg.json +5 -0
  87. package/templates/nextjs/themes/dark.css +36 -0
  88. package/templates/nextjs/themes/forest.css +58 -0
  89. package/templates/nextjs/themes/ocean.css +58 -0
  90. package/templates/nextjs/themes/pila.css +75 -0
  91. package/templates/nextjs/themes/purple.css +58 -0
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { intro, outro, cancel } from "@clack/prompts";
3
+ import { bold, cyan } from "kolorist";
4
+ import { runPrompts } from "./prompts.js";
5
+ import { scaffold } from "./scaffold.js";
6
+ import { install } from "./install.js";
7
+ import path from "path";
8
+ async function main() {
9
+ console.log();
10
+ intro(`${bold(cyan("create-landing-app"))} — Next.js landing page scaffold`);
11
+ const config = await runPrompts();
12
+ if (!config) {
13
+ cancel("Cancelled.");
14
+ process.exit(0);
15
+ }
16
+ const targetDir = path.resolve(process.cwd(), config.projectName);
17
+ await scaffold(config, targetDir);
18
+ await install(config.packageManager, targetDir);
19
+ outro(`Done! cd ${config.projectName} && ${config.packageManager} dev`);
20
+ }
21
+ main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,18 @@
1
+ import { execa } from "execa";
2
+ import { spinner } from "@clack/prompts";
3
+ export async function install(pm, cwd) {
4
+ const s = spinner();
5
+ s.start(`Installing dependencies with ${pm}...`);
6
+ try {
7
+ if (pm === "bun")
8
+ await execa("bun", ["install"], { cwd });
9
+ else if (pm === "pnpm")
10
+ await execa("pnpm", ["install"], { cwd });
11
+ else
12
+ await execa("yarn", [], { cwd });
13
+ s.stop("Dependencies installed.");
14
+ }
15
+ catch {
16
+ s.stop("Install failed. Run install manually.");
17
+ }
18
+ }
@@ -0,0 +1,62 @@
1
+ import { text, select, confirm, isCancel } from "@clack/prompts";
2
+ export async function runPrompts() {
3
+ const projectName = await text({
4
+ message: "Project name?",
5
+ placeholder: "my-landing",
6
+ validate: (v) => (v.trim().length === 0 ? "Required" : undefined),
7
+ });
8
+ if (isCancel(projectName))
9
+ return null;
10
+ const packageManager = await select({
11
+ message: "Package manager?",
12
+ options: [
13
+ { value: "bun", label: "bun" },
14
+ { value: "pnpm", label: "pnpm" },
15
+ { value: "yarn", label: "yarn" },
16
+ ],
17
+ });
18
+ if (isCancel(packageManager))
19
+ return null;
20
+ const i18n = await select({
21
+ message: "i18n / Translation?",
22
+ options: [
23
+ { value: "none", label: "None" },
24
+ { value: "dict", label: "Dictionary-based (no extra deps)" },
25
+ ],
26
+ });
27
+ if (isCancel(i18n))
28
+ return null;
29
+ const stateManagement = await select({
30
+ message: "State management?",
31
+ options: [
32
+ { value: "none", label: "None (recommended for simple sites)" },
33
+ { value: "zustand", label: "Zustand" },
34
+ ],
35
+ });
36
+ if (isCancel(stateManagement))
37
+ return null;
38
+ const dataFetching = await select({
39
+ message: "Data fetching?",
40
+ options: [
41
+ { value: "none", label: "None" },
42
+ { value: "tanstack-query", label: "TanStack Query" },
43
+ ],
44
+ });
45
+ if (isCancel(dataFetching))
46
+ return null;
47
+ const blog = await confirm({ message: "Include Blog section?" });
48
+ if (isCancel(blog))
49
+ return null;
50
+ const docker = await confirm({ message: "Include Docker setup? (for VPS deploy)" });
51
+ if (isCancel(docker))
52
+ return null;
53
+ return {
54
+ projectName: String(projectName).trim(),
55
+ packageManager: packageManager,
56
+ i18n: i18n,
57
+ stateManagement: stateManagement,
58
+ dataFetching: dataFetching,
59
+ blog: Boolean(blog),
60
+ docker: Boolean(docker),
61
+ };
62
+ }
@@ -0,0 +1,159 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { fileURLToPath } from "url";
4
+ import { copyDir } from "./utils/copy-dir.js";
5
+ import { mergeJson } from "./utils/merge-json.js";
6
+ // Resolve templates root relative to this file (ESM-compatible)
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ // When installed from npm, templates are bundled at ../templates (prepublishOnly copies them).
10
+ // In local monorepo dev, templates live at ../../../templates.
11
+ const _npmTemplates = path.resolve(__dirname, "../templates/nextjs");
12
+ const _devTemplates = path.resolve(__dirname, "../../../templates/nextjs");
13
+ const TEMPLATES_ROOT = fs.existsSync(_npmTemplates) ? _npmTemplates : _devTemplates;
14
+ export async function scaffold(config, targetDir) {
15
+ fs.mkdirSync(targetDir, { recursive: true });
16
+ // 1. Copy base template
17
+ await copyDir(path.join(TEMPLATES_ROOT, "base"), targetDir);
18
+ // 2. Build list of optional features to apply
19
+ // ORDERING MATTERS: i18n-dict must run before any section inject that targets
20
+ // app/[lang]/page.tsx — that file is created by i18n-dict, so sections injecting
21
+ // into it must come after. Same logic applies to zustand/tanstack-query providers.
22
+ const optionals = [];
23
+ if (config.i18n === "dict")
24
+ optionals.push("i18n-dict");
25
+ if (config.stateManagement === "zustand")
26
+ optionals.push("zustand");
27
+ if (config.dataFetching === "tanstack-query")
28
+ optionals.push("tanstack-query");
29
+ // Sections run after i18n/state/data so their inject targets already exist
30
+ optionals.push("sections/about");
31
+ if (config.blog)
32
+ optionals.push("sections/blog");
33
+ if (config.docker)
34
+ optionals.push("docker");
35
+ // 3. Apply each optional feature (files + inject markers)
36
+ for (const opt of optionals) {
37
+ const optDir = path.join(TEMPLATES_ROOT, "optional", opt);
38
+ if (fs.existsSync(optDir)) {
39
+ await mergeOptional(optDir, targetDir);
40
+ }
41
+ }
42
+ // 4. Apply Pila theme by default
43
+ applyTheme("pila", targetDir);
44
+ // 5. Replace __TOKEN__ placeholders in all text files
45
+ replaceTokensInDir(targetDir, {
46
+ __PROJECT_NAME__: config.projectName,
47
+ __PACKAGE_MANAGER__: config.packageManager,
48
+ __THEME__: "pila",
49
+ });
50
+ // 6. Remove leftover marker comments from all text files
51
+ cleanupMarkersInDir(targetDir);
52
+ // 7. Merge package.json fragments from optional features
53
+ mergePackageJsonFragments(targetDir, optionals);
54
+ // 8. Make husky scripts executable
55
+ setupHusky(targetDir);
56
+ }
57
+ async function mergeOptional(optDir, targetDir) {
58
+ // Copy new files from optional/files/ — optional files override base
59
+ const filesDir = path.join(optDir, "files");
60
+ if (fs.existsSync(filesDir)) {
61
+ await copyDir(filesDir, targetDir, true);
62
+ }
63
+ // Inject code into base files via markers
64
+ const injectDir = path.join(optDir, "inject");
65
+ if (fs.existsSync(injectDir)) {
66
+ for (const injectFile of fs.readdirSync(injectDir)) {
67
+ const injectContent = fs.readFileSync(path.join(injectDir, injectFile), "utf8");
68
+ // Filename encodes path: "components__providers.tsx" → "components/providers.tsx"
69
+ const targetFile = path.join(targetDir, injectFile.replace(/__/g, "/"));
70
+ if (fs.existsSync(targetFile)) {
71
+ injectIntoMarkers(targetFile, injectContent);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ function injectIntoMarkers(filePath, injectContent) {
77
+ // Format: "MARKER:__MARKER_NAME__\n<code>\n---\nMARKER:...\n<code>"
78
+ let fileContent = fs.readFileSync(filePath, "utf8");
79
+ const blocks = injectContent.split(/^---$/m);
80
+ for (const block of blocks) {
81
+ const markerMatch = block.match(/^MARKER:(\S+)\n([\s\S]*)/m);
82
+ if (!markerMatch)
83
+ continue;
84
+ const [, marker, code] = markerMatch;
85
+ // Insert before marker, keeping marker intact so subsequent sections can inject too
86
+ fileContent = fileContent
87
+ .replace(`{/* ${marker} */}`, `${code.trim()}\n{/* ${marker} */}`)
88
+ .replace(`// ${marker}`, `${code.trim()}\n// ${marker}`);
89
+ }
90
+ fs.writeFileSync(filePath, fileContent);
91
+ }
92
+ function cleanupMarkersInDir(dir) {
93
+ // Remove residual marker comment lines: "// __MARKER__" and "{/* __MARKER__ */}"
94
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
95
+ const fullPath = path.join(dir, entry.name);
96
+ if (entry.isDirectory() && entry.name !== "node_modules") {
97
+ cleanupMarkersInDir(fullPath);
98
+ }
99
+ else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) {
100
+ let content = fs.readFileSync(fullPath, "utf8");
101
+ const cleaned = content
102
+ .replace(/^\s*\/\/ __[A-Z_]+__\s*\n/gm, "")
103
+ .replace(/^\s*\{\/\* __[A-Z_]+__ \*\/\}\s*\n/gm, "");
104
+ if (cleaned !== content)
105
+ fs.writeFileSync(fullPath, cleaned);
106
+ }
107
+ }
108
+ }
109
+ function applyTheme(theme, targetDir) {
110
+ const themeFile = path.join(TEMPLATES_ROOT, "themes", `${theme}.css`);
111
+ const targetTheme = path.join(targetDir, "styles", "theme.css");
112
+ if (fs.existsSync(themeFile)) {
113
+ fs.mkdirSync(path.dirname(targetTheme), { recursive: true });
114
+ fs.copyFileSync(themeFile, targetTheme);
115
+ }
116
+ }
117
+ function replaceTokensInDir(dir, tokens) {
118
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
119
+ const fullPath = path.join(dir, entry.name);
120
+ if (entry.isDirectory() && entry.name !== "node_modules") {
121
+ replaceTokensInDir(fullPath, tokens);
122
+ }
123
+ else if (entry.isFile() && /\.(ts|tsx|json|css|md|env|sh|yml|yaml)$/.test(entry.name)) {
124
+ let content = fs.readFileSync(fullPath, "utf8");
125
+ for (const [token, value] of Object.entries(tokens)) {
126
+ content = content.replaceAll(token, value);
127
+ }
128
+ fs.writeFileSync(fullPath, content);
129
+ }
130
+ }
131
+ }
132
+ function mergePackageJsonFragments(targetDir, optionals) {
133
+ const basePkgPath = path.join(targetDir, "package.json");
134
+ let basePkg = JSON.parse(fs.readFileSync(basePkgPath, "utf8"));
135
+ for (const opt of optionals) {
136
+ const fragPath = path.join(TEMPLATES_ROOT, "optional", opt, "pkg.json");
137
+ if (fs.existsSync(fragPath)) {
138
+ const fragment = JSON.parse(fs.readFileSync(fragPath, "utf8"));
139
+ basePkg = mergeJson(basePkg, fragment);
140
+ }
141
+ }
142
+ fs.writeFileSync(basePkgPath, JSON.stringify(basePkg, null, 2));
143
+ }
144
+ function setupHusky(targetDir) {
145
+ // Make husky hook scripts and shell scripts executable before install runs
146
+ const scripts = [
147
+ ".husky/pre-commit",
148
+ ".husky/commit-msg",
149
+ ".husky/pre-push",
150
+ "scripts/build-and-scan.sh",
151
+ "scripts/lighthouse-check.sh",
152
+ ];
153
+ for (const script of scripts) {
154
+ const fullPath = path.join(targetDir, script);
155
+ if (fs.existsSync(fullPath)) {
156
+ fs.chmodSync(fullPath, "755");
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,144 @@
1
+ import { mergeJson } from '../merge-json';
2
+ describe('mergeJson', () => {
3
+ describe('plain merge', () => {
4
+ it('should merge simple objects', () => {
5
+ const a = { x: 1, y: 2 };
6
+ const b = { z: 3 };
7
+ expect(mergeJson(a, b)).toEqual({ x: 1, y: 2, z: 3 });
8
+ });
9
+ it('should override scalar values in a with values from b', () => {
10
+ const a = { x: 1, y: 2 };
11
+ const b = { x: 10 };
12
+ expect(mergeJson(a, b)).toEqual({ x: 10, y: 2 });
13
+ });
14
+ it('should handle empty objects', () => {
15
+ expect(mergeJson({}, {})).toEqual({});
16
+ expect(mergeJson({ a: 1 }, {})).toEqual({ a: 1 });
17
+ expect(mergeJson({}, { b: 2 })).toEqual({ b: 2 });
18
+ });
19
+ });
20
+ describe('nested object merge', () => {
21
+ it('should deeply merge nested objects', () => {
22
+ const a = { config: { debug: true, port: 3000 } };
23
+ const b = { config: { timeout: 5000 } };
24
+ expect(mergeJson(a, b)).toEqual({
25
+ config: { debug: true, port: 3000, timeout: 5000 },
26
+ });
27
+ });
28
+ it('should override scalar values in nested objects', () => {
29
+ const a = { config: { debug: true, port: 3000 } };
30
+ const b = { config: { debug: false } };
31
+ expect(mergeJson(a, b)).toEqual({
32
+ config: { debug: false, port: 3000 },
33
+ });
34
+ });
35
+ it('should handle multiple levels of nesting', () => {
36
+ const a = {
37
+ app: { server: { port: 3000, host: 'localhost' }, name: 'app1' },
38
+ };
39
+ const b = {
40
+ app: { server: { timeout: 5000 }, version: '1.0.0' },
41
+ };
42
+ expect(mergeJson(a, b)).toEqual({
43
+ app: {
44
+ server: { port: 3000, host: 'localhost', timeout: 5000 },
45
+ name: 'app1',
46
+ version: '1.0.0',
47
+ },
48
+ });
49
+ });
50
+ });
51
+ describe('array deduplication', () => {
52
+ it('should concatenate and deduplicate arrays', () => {
53
+ const a = { items: [1, 2, 3] };
54
+ const b = { items: [3, 4, 5] };
55
+ const result = mergeJson(a, b);
56
+ expect(result.items).toHaveLength(5);
57
+ expect(result.items).toEqual(expect.arrayContaining([1, 2, 3, 4, 5]));
58
+ });
59
+ it('should handle arrays with strings', () => {
60
+ const a = { tags: ['a', 'b', 'c'] };
61
+ const b = { tags: ['c', 'd'] };
62
+ const result = mergeJson(a, b);
63
+ expect(result.tags).toHaveLength(4);
64
+ expect(result.tags).toEqual(expect.arrayContaining(['a', 'b', 'c', 'd']));
65
+ });
66
+ it('should handle empty arrays', () => {
67
+ const a = { items: [] };
68
+ const b = { items: [1, 2] };
69
+ expect(mergeJson(a, b)).toEqual({ items: [1, 2] });
70
+ const a2 = { items: [1, 2] };
71
+ const b2 = { items: [] };
72
+ const result = mergeJson(a2, b2);
73
+ expect(result.items).toHaveLength(2);
74
+ expect(result.items).toEqual(expect.arrayContaining([1, 2]));
75
+ });
76
+ it('should deduplicate arrays with duplicate elements', () => {
77
+ const a = { items: [1, 1, 2] };
78
+ const b = { items: [2, 3, 3] };
79
+ const result = mergeJson(a, b);
80
+ expect(result.items).toHaveLength(3);
81
+ expect(result.items).toEqual(expect.arrayContaining([1, 2, 3]));
82
+ });
83
+ });
84
+ describe('mixed scenarios', () => {
85
+ it('should handle arrays and nested objects together', () => {
86
+ const a = {
87
+ config: { tags: ['a', 'b'], settings: { debug: true } },
88
+ };
89
+ const b = {
90
+ config: { tags: ['b', 'c'], settings: { verbose: true } },
91
+ };
92
+ const result = mergeJson(a, b);
93
+ const config = result.config;
94
+ const settings = config.settings;
95
+ const tags = config.tags;
96
+ expect(settings).toEqual({ debug: true, verbose: true });
97
+ expect(tags).toHaveLength(3);
98
+ expect(tags).toEqual(expect.arrayContaining(['a', 'b', 'c']));
99
+ });
100
+ it('should replace arrays when not both are arrays', () => {
101
+ const a = { data: [1, 2, 3] };
102
+ const b = { data: 'string value' };
103
+ expect(mergeJson(a, b)).toEqual({ data: 'string value' });
104
+ });
105
+ it('should replace objects when b value is scalar', () => {
106
+ const a = { config: { x: 1, y: 2 } };
107
+ const b = { config: 'production' };
108
+ expect(mergeJson(a, b)).toEqual({ config: 'production' });
109
+ });
110
+ it('should preserve original objects (no mutation)', () => {
111
+ const a = { x: 1, nested: { y: 2 } };
112
+ const b = { nested: { z: 3 } };
113
+ const originalA = JSON.stringify(a);
114
+ mergeJson(a, b);
115
+ expect(JSON.stringify(a)).toEqual(originalA);
116
+ });
117
+ });
118
+ describe('edge cases', () => {
119
+ it('should handle null and undefined values', () => {
120
+ const a = { x: null };
121
+ const b = { x: 'value' };
122
+ expect(mergeJson(a, b)).toEqual({ x: 'value' });
123
+ });
124
+ it('should handle boolean values', () => {
125
+ const a = { debug: true };
126
+ const b = { debug: false };
127
+ expect(mergeJson(a, b)).toEqual({ debug: false });
128
+ });
129
+ it('should handle numeric values', () => {
130
+ const a = { count: 10, ratio: 0.5 };
131
+ const b = { count: 20 };
132
+ expect(mergeJson(a, b)).toEqual({ count: 20, ratio: 0.5 });
133
+ });
134
+ it('should handle objects with multiple array properties', () => {
135
+ const a = { deps: ['a', 'b'], devDeps: ['c', 'd'] };
136
+ const b = { deps: ['b', 'e'], devDeps: ['d', 'f'] };
137
+ const result = mergeJson(a, b);
138
+ expect(result.deps).toHaveLength(3);
139
+ expect(result.deps).toEqual(expect.arrayContaining(['a', 'b', 'e']));
140
+ expect(result.devDeps).toHaveLength(3);
141
+ expect(result.devDeps).toEqual(expect.arrayContaining(['c', 'd', 'f']));
142
+ });
143
+ });
144
+ });
@@ -0,0 +1,212 @@
1
+ import { replaceTokens } from '../replace-tokens';
2
+ describe('replaceTokens', () => {
3
+ describe('single token replacement', () => {
4
+ it('should replace a single token in content', () => {
5
+ const content = 'Hello __NAME__!';
6
+ const tokens = { __NAME__: 'World' };
7
+ expect(replaceTokens(content, tokens)).toBe('Hello World!');
8
+ });
9
+ it('should replace token at the start', () => {
10
+ const content = '__GREETING__ everyone!';
11
+ const tokens = { __GREETING__: 'Welcome' };
12
+ expect(replaceTokens(content, tokens)).toBe('Welcome everyone!');
13
+ });
14
+ it('should replace token at the end', () => {
15
+ const content = 'This is __VERSION__';
16
+ const tokens = { __VERSION__: '1.0.0' };
17
+ expect(replaceTokens(content, tokens)).toBe('This is 1.0.0');
18
+ });
19
+ it('should replace token when it is the entire content', () => {
20
+ const content = '__TOKEN__';
21
+ const tokens = { __TOKEN__: 'value' };
22
+ expect(replaceTokens(content, tokens)).toBe('value');
23
+ });
24
+ });
25
+ describe('multiple token replacement', () => {
26
+ it('should replace multiple different tokens', () => {
27
+ const content = 'Project: __PROJECT_NAME__, Version: __VERSION__';
28
+ const tokens = {
29
+ __PROJECT_NAME__: 'my-app',
30
+ __VERSION__: '2.0.0',
31
+ };
32
+ expect(replaceTokens(content, tokens)).toBe('Project: my-app, Version: 2.0.0');
33
+ });
34
+ it('should replace the same token multiple times', () => {
35
+ const content = '__PREFIX____SUFFIX__ uses __PREFIX__ everywhere';
36
+ const tokens = { __PREFIX__: 'app', __SUFFIX__: 'config' };
37
+ expect(replaceTokens(content, tokens)).toBe('appconfig uses app everywhere');
38
+ });
39
+ it('should replace tokens in JSON-like content', () => {
40
+ const content = '{ "name": "__NAME__", "version": "__VERSION__" }';
41
+ const tokens = {
42
+ __NAME__: 'my-package',
43
+ __VERSION__: '1.0.0',
44
+ };
45
+ expect(replaceTokens(content, tokens)).toBe('{ "name": "my-package", "version": "1.0.0" }');
46
+ });
47
+ it('should handle many tokens in one call', () => {
48
+ const content = '__A____B____C____D__';
49
+ const tokens = {
50
+ __A__: '1',
51
+ __B__: '2',
52
+ __C__: '3',
53
+ __D__: '4',
54
+ };
55
+ expect(replaceTokens(content, tokens)).toBe('1234');
56
+ });
57
+ });
58
+ describe('no tokens (passthrough)', () => {
59
+ it('should return unchanged content when no tokens present', () => {
60
+ const content = 'Hello World';
61
+ const tokens = { __NAME__: 'value' };
62
+ expect(replaceTokens(content, tokens)).toBe('Hello World');
63
+ });
64
+ it('should return unchanged content with empty token map', () => {
65
+ const content = 'Hello __NAME__!';
66
+ const tokens = {};
67
+ expect(replaceTokens(content, tokens)).toBe('Hello __NAME__!');
68
+ });
69
+ it('should handle empty content', () => {
70
+ const content = '';
71
+ const tokens = { __TOKEN__: 'value' };
72
+ expect(replaceTokens(content, tokens)).toBe('');
73
+ });
74
+ });
75
+ describe('token in middle of word', () => {
76
+ it('should replace token even when in middle of word', () => {
77
+ const content = 'my__APP__name';
78
+ const tokens = { __APP__: 'project' };
79
+ expect(replaceTokens(content, tokens)).toBe('myprojectname');
80
+ });
81
+ it('should handle hyphenated words with token', () => {
82
+ const content = 'my-__NAME__-app';
83
+ const tokens = { __NAME__: 'awesome' };
84
+ expect(replaceTokens(content, tokens)).toBe('my-awesome-app');
85
+ });
86
+ it('should handle underscores adjacent to token', () => {
87
+ const content = 'func___NAME___function';
88
+ const tokens = { __NAME__: 'test' };
89
+ expect(replaceTokens(content, tokens)).toBe('func_test_function');
90
+ });
91
+ });
92
+ describe('special characters and escaping', () => {
93
+ it('should handle token values with special characters', () => {
94
+ const content = 'Theme: __THEME__';
95
+ const tokens = { __THEME__: '@awesome/theme-v2.0' };
96
+ expect(replaceTokens(content, tokens)).toBe('Theme: @awesome/theme-v2.0');
97
+ });
98
+ it('should handle token values with spaces', () => {
99
+ const content = 'Company: __COMPANY__';
100
+ const tokens = { __COMPANY__: 'My Awesome Company' };
101
+ expect(replaceTokens(content, tokens)).toBe('Company: My Awesome Company');
102
+ });
103
+ it('should handle token values with newlines', () => {
104
+ const content = 'Script:\n__SCRIPT__\nEnd';
105
+ const tokens = { __SCRIPT__: 'echo "hello"\necho "world"' };
106
+ expect(replaceTokens(content, tokens)).toBe('Script:\necho "hello"\necho "world"\nEnd');
107
+ });
108
+ it('should handle token names with numbers', () => {
109
+ const content = '__TOKEN_1__ and __TOKEN_2__';
110
+ const tokens = {
111
+ __TOKEN_1__: 'first',
112
+ __TOKEN_2__: 'second',
113
+ };
114
+ expect(replaceTokens(content, tokens)).toBe('first and second');
115
+ });
116
+ });
117
+ describe('multiline content', () => {
118
+ it('should replace tokens across multiple lines', () => {
119
+ const content = `
120
+ Project: __NAME__
121
+ Version: __VERSION__
122
+ Author: __AUTHOR__
123
+ `;
124
+ const tokens = {
125
+ __NAME__: 'my-app',
126
+ __VERSION__: '1.0.0',
127
+ __AUTHOR__: 'John Doe',
128
+ };
129
+ const expected = `
130
+ Project: my-app
131
+ Version: 1.0.0
132
+ Author: John Doe
133
+ `;
134
+ expect(replaceTokens(content, tokens)).toBe(expected);
135
+ });
136
+ it('should replace tokens in code blocks', () => {
137
+ const content = `
138
+ import __PACKAGE_NAME__ from '@/__PACKAGE_NAME__';
139
+
140
+ export default __PACKAGE_NAME__;
141
+ `;
142
+ const tokens = { __PACKAGE_NAME__: 'config' };
143
+ const expected = `
144
+ import config from '@/config';
145
+
146
+ export default config;
147
+ `;
148
+ expect(replaceTokens(content, tokens)).toBe(expected);
149
+ });
150
+ });
151
+ describe('package.json specific use case', () => {
152
+ it('should replace project name in package.json', () => {
153
+ const pkgJson = `{
154
+ "name": "__PROJECT_NAME__",
155
+ "description": "This is __PROJECT_NAME__"
156
+ }`;
157
+ const tokens = { __PROJECT_NAME__: 'my-awesome-app' };
158
+ const expected = `{
159
+ "name": "my-awesome-app",
160
+ "description": "This is my-awesome-app"
161
+ }`;
162
+ expect(replaceTokens(pkgJson, tokens)).toBe(expected);
163
+ });
164
+ });
165
+ describe('env file use case', () => {
166
+ it('should replace tokens in .env files', () => {
167
+ const envContent = `
168
+ DATABASE_URL=__DB_URL__
169
+ API_KEY=__API_KEY__
170
+ NEXT_PUBLIC_THEME=__THEME__
171
+ `;
172
+ const tokens = {
173
+ __DB_URL__: 'postgresql://localhost/mydb',
174
+ __API_KEY__: 'secret-key-123',
175
+ __THEME__: 'dark',
176
+ };
177
+ const expected = `
178
+ DATABASE_URL=postgresql://localhost/mydb
179
+ API_KEY=secret-key-123
180
+ NEXT_PUBLIC_THEME=dark
181
+ `;
182
+ expect(replaceTokens(envContent, tokens)).toBe(expected);
183
+ });
184
+ });
185
+ describe('order independence', () => {
186
+ it('should produce same result regardless of token order', () => {
187
+ const content = '__A__ and __B__ and __C__';
188
+ const tokens = { __A__: '1', __B__: '2', __C__: '3' };
189
+ const result1 = replaceTokens(content, tokens);
190
+ expect(result1).toBe('1 and 2 and 3');
191
+ });
192
+ });
193
+ describe('edge cases', () => {
194
+ it('should handle empty token value', () => {
195
+ const content = 'Hello __NAME__!';
196
+ const tokens = { __NAME__: '' };
197
+ expect(replaceTokens(content, tokens)).toBe('Hello !');
198
+ });
199
+ it('should handle token value that looks like a token', () => {
200
+ const content = 'Value: __TOKEN__';
201
+ const tokens = { __TOKEN__: '__OTHER_TOKEN__' };
202
+ expect(replaceTokens(content, tokens)).toBe('Value: __OTHER_TOKEN__');
203
+ });
204
+ it('should not double-replace if token value contains the token', () => {
205
+ const content = '__NAME__';
206
+ const tokens = { __NAME__: '__NAME__' };
207
+ // replaceAll will keep replacing, so this will result in the same token infinitely
208
+ // This is expected behavior - the token replaces with itself
209
+ expect(replaceTokens(content, tokens)).toBe('__NAME__');
210
+ });
211
+ });
212
+ });
@@ -0,0 +1,22 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ // Recursively copy src directory to dest, skipping node_modules.
4
+ // overwrite=false: base template takes priority (default for base copy)
5
+ // overwrite=true: src takes priority (used for optional feature files)
6
+ export async function copyDir(src, dest, overwrite = false) {
7
+ fs.mkdirSync(dest, { recursive: true });
8
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
9
+ if (entry.name === "node_modules")
10
+ continue;
11
+ const srcPath = path.join(src, entry.name);
12
+ const destPath = path.join(dest, entry.name);
13
+ if (entry.isDirectory()) {
14
+ await copyDir(srcPath, destPath, overwrite);
15
+ }
16
+ else {
17
+ if (overwrite || !fs.existsSync(destPath)) {
18
+ fs.copyFileSync(srcPath, destPath);
19
+ }
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,19 @@
1
+ // Deep merge b into a. Arrays are concatenated and deduplicated.
2
+ export function mergeJson(a, b) {
3
+ const result = { ...a };
4
+ for (const key of Object.keys(b)) {
5
+ const aVal = a[key];
6
+ const bVal = b[key];
7
+ if (Array.isArray(aVal) && Array.isArray(bVal)) {
8
+ result[key] = [...new Set([...aVal, ...bVal])];
9
+ }
10
+ else if (typeof aVal === "object" && aVal !== null && !Array.isArray(aVal) &&
11
+ typeof bVal === "object" && bVal !== null && !Array.isArray(bVal)) {
12
+ result[key] = mergeJson(aVal, bVal);
13
+ }
14
+ else {
15
+ result[key] = bVal;
16
+ }
17
+ }
18
+ return result;
19
+ }
@@ -0,0 +1,8 @@
1
+ // Replace all __TOKEN__ occurrences in a string with their values
2
+ export function replaceTokens(content, tokens) {
3
+ let result = content;
4
+ for (const [token, value] of Object.entries(tokens)) {
5
+ result = result.replaceAll(token, value);
6
+ }
7
+ return result;
8
+ }