@navneet_25/tempjs 2.0.0 → 3.0.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 (90) hide show
  1. package/README.md +66 -18
  2. package/cli/brand-manager.js +183 -96
  3. package/cli/config.js +9 -1
  4. package/cli/doctor.js +393 -0
  5. package/cli/fetch-core-modules.js +130 -0
  6. package/cli/index.js +141 -10
  7. package/cli/info.js +28 -1
  8. package/cli/init-options.js +209 -0
  9. package/cli/module-manager.js +168 -0
  10. package/cli/parse-args.js +21 -0
  11. package/cli/update.js +15 -0
  12. package/cli/version-manager.js +518 -0
  13. package/package.json +27 -4
  14. package/packages/core/modules/README.md +72 -0
  15. package/packages/core/modules/blog-compose/app/admin/components/blog-compose-form.ts +44 -0
  16. package/packages/core/modules/blog-compose/app/admin/hooks/useBlogPosts.ts +103 -0
  17. package/packages/core/modules/blog-compose/app/admin/panels/BlogComposePanel.tsx +388 -0
  18. package/packages/core/modules/blog-compose/app/api/blog-posts/[id]/route.ts +29 -0
  19. package/packages/core/modules/blog-compose/app/api/blog-posts/route.ts +21 -0
  20. package/packages/core/modules/blog-compose/app/blog/[slug]/page.tsx +79 -0
  21. package/packages/core/modules/blog-compose/app/blog/layout.tsx +22 -0
  22. package/packages/core/modules/blog-compose/app/blog/page.tsx +102 -0
  23. package/packages/core/modules/blog-compose/app/components/BlogSection.tsx +74 -0
  24. package/packages/core/modules/blog-compose/app/components/BlogShell.tsx +36 -0
  25. package/packages/core/modules/blog-compose/app/components/BlogSidebar.tsx +112 -0
  26. package/packages/core/modules/blog-compose/lib/blog/compose/annotate-text.tsx +103 -0
  27. package/packages/core/modules/blog-compose/lib/blog/compose/blocks/media-blocks.tsx +97 -0
  28. package/packages/core/modules/blog-compose/lib/blog/compose/blocks/text-blocks.tsx +115 -0
  29. package/packages/core/modules/blog-compose/lib/blog/compose/index.ts +21 -0
  30. package/packages/core/modules/blog-compose/lib/blog/compose/interpreter.tsx +55 -0
  31. package/packages/core/modules/blog-compose/lib/blog/compose/registry.ts +276 -0
  32. package/packages/core/modules/blog-compose/lib/blog/compose/types.ts +128 -0
  33. package/packages/core/modules/blog-compose/lib/blog/register-sitemap.ts +18 -0
  34. package/packages/core/modules/blog-compose/lib/controllers/BlogComposeController.ts +2 -0
  35. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.controller.ts +78 -0
  36. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.repository.ts +78 -0
  37. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.service.ts +72 -0
  38. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.types.ts +20 -0
  39. package/packages/core/modules/blog-compose/lib/features/blog-compose/index.ts +7 -0
  40. package/packages/core/modules/blog-compose/prisma/blog-compose.prisma +13 -0
  41. package/packages/core/modules/enquiry-modal/app/components/EnquiryModal.tsx +308 -0
  42. package/packages/core/modules/footer/app/components/Footer.tsx +127 -0
  43. package/packages/core/modules/gallery/app/admin/components/GalleryFormModal.tsx +132 -0
  44. package/packages/core/modules/gallery/app/admin/components/GalleryList.tsx +142 -0
  45. package/packages/core/modules/gallery/app/admin/hooks/useGallery.ts +72 -0
  46. package/packages/core/modules/gallery/app/api/gallery/[id]/route.ts +29 -0
  47. package/packages/core/modules/gallery/app/api/gallery/route.ts +22 -0
  48. package/packages/core/modules/gallery/app/components/GallerySection.tsx +92 -0
  49. package/packages/core/modules/gallery/app/gallery/layout.tsx +12 -0
  50. package/packages/core/modules/gallery/app/gallery/page.tsx +179 -0
  51. package/packages/core/modules/gallery/lib/controllers/GalleryController.ts +2 -0
  52. package/packages/core/modules/gallery/lib/features/gallery/gallery.controller.ts +78 -0
  53. package/packages/core/modules/gallery/lib/features/gallery/gallery.repository.ts +55 -0
  54. package/packages/core/modules/gallery/lib/features/gallery/gallery.service.ts +43 -0
  55. package/packages/core/modules/gallery/lib/features/gallery/gallery.types.ts +17 -0
  56. package/packages/core/modules/gallery/lib/features/gallery/index.ts +4 -0
  57. package/packages/core/modules/gallery/prisma/gallery.prisma +11 -0
  58. package/packages/core/modules/hero-simple/app/components/Hero.tsx +160 -0
  59. package/packages/core/modules/legal-pages/app/components/LegalDocumentPage.tsx +61 -0
  60. package/packages/core/modules/legal-pages/app/privacy-policy/page.tsx +19 -0
  61. package/packages/core/modules/legal-pages/app/terms-and-conditions/page.tsx +19 -0
  62. package/packages/core/modules/reviews/app/admin/components/ReviewFormModal.tsx +110 -0
  63. package/packages/core/modules/reviews/app/admin/components/ReviewsList.tsx +116 -0
  64. package/packages/core/modules/reviews/app/admin/hooks/useReviews.ts +70 -0
  65. package/packages/core/modules/reviews/app/api/reviews/[id]/route.ts +29 -0
  66. package/packages/core/modules/reviews/app/api/reviews/route.ts +17 -0
  67. package/packages/core/modules/reviews/app/components/ReviewsSection.tsx +100 -0
  68. package/packages/core/modules/reviews/lib/controllers/ReviewController.ts +2 -0
  69. package/packages/core/modules/reviews/lib/features/reviews/index.ts +3 -0
  70. package/packages/core/modules/reviews/lib/features/reviews/review.controller.ts +64 -0
  71. package/packages/core/modules/reviews/lib/features/reviews/review.repository.ts +50 -0
  72. package/packages/core/modules/reviews/lib/features/reviews/review.service.ts +38 -0
  73. package/packages/core/modules/reviews/prisma/reviews.prisma +9 -0
  74. package/packages/core/modules/seo/app/components/SiteJsonLd.tsx +16 -0
  75. package/packages/core/modules/seo/app/robots.ts +30 -0
  76. package/packages/core/modules/seo/app/sitemap.ts +9 -0
  77. package/packages/core/modules/seo/lib/seo/index.ts +5 -0
  78. package/packages/core/modules/seo/lib/seo/json-ld.ts +128 -0
  79. package/packages/core/modules/seo/lib/seo/metadata.ts +65 -0
  80. package/packages/core/modules/seo/lib/seo/sitemap.ts +82 -0
  81. package/packages/core/modules/seo/lib/seo/types.ts +30 -0
  82. package/packages/core/modules/seo/lib/seo/urls.ts +27 -0
  83. package/packages/core/modules/theme-modes/app/components/ThemeModeInit.tsx +24 -0
  84. package/packages/core/modules/theme-modes/app/components/ThemeModeToggle.tsx +58 -0
  85. package/packages/core/modules.json +140 -0
  86. package/packages/core/prisma/schema.prisma +33 -0
  87. package/scripts/module-installer-core.mjs +657 -0
  88. package/scripts/template-modules-admin.mjs +366 -0
  89. package/scripts/template-modules-home.mjs +120 -0
  90. package/templates.json +163 -8
@@ -0,0 +1,657 @@
1
+ /**
2
+ * Shared core module installer — used by monorepo scripts and tempjs add-module.
3
+ */
4
+ import {
5
+ cpSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+
13
+ import { buildModulesHomePageSource } from "./template-modules-home.mjs";
14
+ import {
15
+ applyAdminTabRegistry,
16
+ applyAdminDashboard,
17
+ } from "./template-modules-admin.mjs";
18
+
19
+ /**
20
+ * @typedef {{
21
+ * label: string;
22
+ * description?: string;
23
+ * paths: string[];
24
+ * featureFlag?: string;
25
+ * prismaFragment?: string;
26
+ * default?: boolean;
27
+ * }} CoreModuleDef
28
+ */
29
+
30
+ /**
31
+ * @param {string} coreDir Absolute path to packages/core
32
+ */
33
+ export function loadModulesRegistry(coreDir) {
34
+ const modulesRegistryPath = join(coreDir, "modules.json");
35
+ if (!existsSync(modulesRegistryPath)) {
36
+ throw new Error(`Missing modules registry: ${modulesRegistryPath}`);
37
+ }
38
+ return JSON.parse(readFileSync(modulesRegistryPath, "utf8"));
39
+ }
40
+
41
+ /**
42
+ * @param {string} coreDir
43
+ * @returns {string[]}
44
+ */
45
+ export function listModuleIds(coreDir) {
46
+ const registry = loadModulesRegistry(coreDir);
47
+ return Object.keys(registry.modules);
48
+ }
49
+
50
+ /**
51
+ * @param {string} coreDir
52
+ * @param {string[]} moduleIds
53
+ * @returns {string[]}
54
+ */
55
+ export function resolveModuleIds(coreDir, moduleIds) {
56
+ const registry = loadModulesRegistry(coreDir);
57
+ const resolved = [];
58
+
59
+ for (const id of moduleIds) {
60
+ const trimmed = id.trim();
61
+ if (!trimmed) continue;
62
+ if (!registry.modules[trimmed]) {
63
+ throw new Error(
64
+ `Unknown core module "${trimmed}". Available: ${listModuleIds(coreDir).join(", ")}`
65
+ );
66
+ }
67
+ if (!resolved.includes(trimmed)) {
68
+ resolved.push(trimmed);
69
+ }
70
+ }
71
+
72
+ return resolved;
73
+ }
74
+
75
+ /**
76
+ * @param {string} templateRoot
77
+ * @param {string} moduleId
78
+ * @param {string} fragmentRelPath
79
+ * @param {string} modulesRoot
80
+ */
81
+ function appendPrismaFragment(templateRoot, moduleId, fragmentRelPath, modulesRoot) {
82
+ const fragmentPath = join(modulesRoot, moduleId, fragmentRelPath);
83
+ if (!existsSync(fragmentPath)) {
84
+ throw new Error(`Prisma fragment missing: ${fragmentPath}`);
85
+ }
86
+
87
+ const fragment = readFileSync(fragmentPath, "utf8").trim();
88
+ const modelMatch = fragment.match(/model\s+(\w+)\s*\{/);
89
+ const modelName = modelMatch?.[1];
90
+
91
+ const domainPath = join(templateRoot, "prisma", "domain.prisma");
92
+ let domain = existsSync(domainPath) ? readFileSync(domainPath, "utf8") : "";
93
+
94
+ if (modelName && domain.includes(`model ${modelName}`)) {
95
+ console.log(` · prisma model ${modelName} already in domain.prisma`);
96
+ return;
97
+ }
98
+
99
+ domain = domain.trim();
100
+ domain = domain ? `${domain}\n\n${fragment}\n` : `${fragment}\n`;
101
+ mkdirSync(dirname(domainPath), { recursive: true });
102
+ writeFileSync(domainPath, domain, "utf8");
103
+ console.log(` ✓ appended prisma fragment: ${modelName ?? fragmentRelPath}`);
104
+ }
105
+
106
+ /**
107
+ * @param {string} templateRoot
108
+ * @param {{ skipMetadataReplace?: boolean }} [patchOptions]
109
+ */
110
+ function applySeoLayoutPatch(templateRoot, patchOptions = {}) {
111
+ const layoutPath = join(templateRoot, "app", "layout.tsx");
112
+ if (!existsSync(layoutPath)) return;
113
+
114
+ let content = readFileSync(layoutPath, "utf8");
115
+
116
+ if (!content.includes("SiteJsonLd")) {
117
+ const cssImport = content.match(/import\s+["']\.\/globals\.css["'];?/);
118
+ if (cssImport) {
119
+ content = content.replace(
120
+ cssImport[0],
121
+ `${cssImport[0]}\nimport SiteJsonLd from "@/app/components/SiteJsonLd";`
122
+ );
123
+ } else {
124
+ content = `import SiteJsonLd from "@/app/components/SiteJsonLd";\n${content}`;
125
+ }
126
+
127
+ if (content.includes("<QueryProvider>")) {
128
+ content = content.replace("<QueryProvider>", "<SiteJsonLd />\n <QueryProvider>");
129
+ } else if (content.includes("<body")) {
130
+ content = content.replace(/<body([^>]*)>/, "<body$1>\n <SiteJsonLd />");
131
+ }
132
+
133
+ writeFileSync(layoutPath, content, "utf8");
134
+ console.log(" ✓ wired SiteJsonLd into app/layout.tsx");
135
+ content = readFileSync(layoutPath, "utf8");
136
+ }
137
+
138
+ if (!patchOptions.skipMetadataReplace && !content.includes("buildPageMetadata")) {
139
+ content = content.replace(
140
+ 'import type { Metadata } from "next";',
141
+ 'import type { Metadata } from "next";\nimport { buildPageMetadata } from "@/lib/seo/metadata";'
142
+ );
143
+ content = content.replace(
144
+ /export const metadata: Metadata = \{[\s\S]*?\};/,
145
+ "export const metadata: Metadata = buildPageMetadata();"
146
+ );
147
+ writeFileSync(layoutPath, content, "utf8");
148
+ console.log(" ✓ upgraded app/layout.tsx metadata via buildPageMetadata()");
149
+ }
150
+ }
151
+
152
+ /**
153
+ * @param {string} templateRoot
154
+ */
155
+ function applyBlogComposeSitemapPatch(templateRoot) {
156
+ const sitemapPath = join(templateRoot, "app", "sitemap.ts");
157
+ if (!existsSync(sitemapPath)) return;
158
+
159
+ let content = readFileSync(sitemapPath, "utf8");
160
+ if (content.includes("lib/blog/register-sitemap")) return;
161
+ if (!content.includes("buildAppSitemap")) return;
162
+
163
+ const importLine = 'import "@/lib/blog/register-sitemap";';
164
+ if (content.includes('from "@/lib/seo/sitemap"')) {
165
+ content = content.replace(
166
+ /import\s*\{[^}]*buildAppSitemap[^}]*\}\s*from\s*"@\/lib\/seo\/sitemap";/,
167
+ (match) => `${match}\n${importLine}`
168
+ );
169
+ } else {
170
+ content = `${importLine}\n${content}`;
171
+ }
172
+
173
+ writeFileSync(sitemapPath, content, "utf8");
174
+ console.log(" ✓ wired blog post slugs into app/sitemap.ts");
175
+ }
176
+
177
+ const THEME_COLOR_KEYS = [
178
+ "primary",
179
+ "primaryHover",
180
+ "accent",
181
+ "accentDark",
182
+ "accentLight",
183
+ "textMain",
184
+ "textMuted",
185
+ "bgMain",
186
+ "bgLight",
187
+ "bgCard",
188
+ "footerBg",
189
+ "ctaPrimary",
190
+ "ctaPrimaryHover",
191
+ ];
192
+
193
+ const THEME_CSS_VARS = [
194
+ "--primary",
195
+ "--primary-hover",
196
+ "--accent-gold",
197
+ "--accent-gold-dark",
198
+ "--accent-gold-light",
199
+ "--text-main",
200
+ "--text-muted",
201
+ "--bg-tan",
202
+ "--bg-light",
203
+ "--bg-card",
204
+ "--footer-bg",
205
+ "--cta-primary",
206
+ "--cta-primary-hover",
207
+ ];
208
+
209
+ /**
210
+ * @param {Record<string, string>} light
211
+ */
212
+ function deriveDarkColorsFromLight(light) {
213
+ return {
214
+ primary: light.primary ?? "#60a5fa",
215
+ primaryHover: light.primaryHover ?? "#3b82f6",
216
+ accent: light.accent ?? "#38bdf8",
217
+ accentDark: light.accentDark ?? "#0ea5e9",
218
+ accentLight: light.accentDark ?? "#1e3a5f",
219
+ textMain: "#f1f5f9",
220
+ textMuted: "#94a3b8",
221
+ bgMain: "#0f172a",
222
+ bgLight: "#1e293b",
223
+ bgCard: "#1e293b",
224
+ footerBg: "#020617",
225
+ ctaPrimary: light.ctaPrimary ?? "#60a5fa",
226
+ ctaPrimaryHover: light.ctaPrimaryHover ?? "#3b82f6",
227
+ };
228
+ }
229
+
230
+ /**
231
+ * @param {string} siteContent
232
+ */
233
+ function parseLightColorsFromSiteTs(siteContent) {
234
+ const themeIdx = siteContent.indexOf("theme:");
235
+ if (themeIdx < 0) return null;
236
+ const slice = siteContent.slice(themeIdx);
237
+ const colorsMatch = slice.match(/colors:\s*\{([\s\S]*?)\n\s*\},/);
238
+ if (!colorsMatch?.[1]) return null;
239
+ const inner = colorsMatch[1];
240
+ /** @type {Record<string, string>} */
241
+ const colors = {};
242
+ for (const key of THEME_COLOR_KEYS) {
243
+ const match = inner.match(new RegExp(`${key}\\s*:\\s*"([^"]+)"`));
244
+ if (match?.[1]) colors[key] = match[1];
245
+ }
246
+ return Object.keys(colors).length > 0 ? colors : null;
247
+ }
248
+
249
+ /**
250
+ * @param {string} templateRoot
251
+ */
252
+ function applyThemeModesSitePatch(templateRoot) {
253
+ const sitePath = join(templateRoot, "constants", "site.ts");
254
+ if (!existsSync(sitePath)) return;
255
+
256
+ let content = readFileSync(sitePath, "utf8");
257
+ if (content.includes("colorsDark:")) {
258
+ console.log(" · theme colorsDark already in site.ts");
259
+ return;
260
+ }
261
+
262
+ const light = parseLightColorsFromSiteTs(content) ?? {};
263
+ const dark = deriveDarkColorsFromLight(light);
264
+ const darkLines = THEME_COLOR_KEYS.map((key) => ` ${key}: "${dark[key]}",`).join("\n");
265
+
266
+ if (!content.includes("appearance:")) {
267
+ content = content.replace(
268
+ /(theme:\s*\{\s*\n)(\s*)colors:/,
269
+ `$1$2appearance: "system" as const,\n$2colors:`
270
+ );
271
+ }
272
+
273
+ const themeIdx = content.indexOf("theme:");
274
+ if (themeIdx < 0) return;
275
+ const before = content.slice(0, themeIdx);
276
+ const themeSlice = content.slice(themeIdx);
277
+ const patchedTheme = themeSlice.replace(
278
+ /(\n\s*colors:\s*\{[\s\S]*?\n\s*\},)(\n)/,
279
+ `$1\n colorsDark: {\n${darkLines}\n },$2`
280
+ );
281
+ content = before + patchedTheme;
282
+
283
+ writeFileSync(sitePath, content, "utf8");
284
+ console.log(" ✓ added theme.appearance and theme.colorsDark to constants/site.ts");
285
+ }
286
+
287
+ /**
288
+ * @param {string} templateRoot
289
+ * @param {Record<string, string>} dark
290
+ */
291
+ function applyThemeModesCssPatch(templateRoot, dark) {
292
+ const globalsPath = join(templateRoot, "app", "globals.css");
293
+ if (!existsSync(globalsPath)) return;
294
+
295
+ let css = readFileSync(globalsPath, "utf8");
296
+ if (css.includes("[data-theme=\"dark\"]")) {
297
+ console.log(" · [data-theme=\"dark\"] already in globals.css");
298
+ } else {
299
+ const lines = THEME_CSS_VARS.map((cssVar, index) => {
300
+ const key = THEME_COLOR_KEYS[index];
301
+ return ` ${cssVar}: ${dark[key]};`;
302
+ });
303
+ css = `${css.trim()}\n\n[data-theme="dark"] {\n${lines.join("\n")}\n}\n`;
304
+ console.log(" ✓ appended [data-theme=\"dark\"] CSS variables to app/globals.css");
305
+ }
306
+
307
+ if (!css.includes("color-scheme:")) {
308
+ css = css.replace(
309
+ /:root\s*\{/,
310
+ ":root {\n color-scheme: light;"
311
+ );
312
+ if (css.includes("[data-theme=\"dark\"]")) {
313
+ css = css.replace(
314
+ /\[data-theme="dark"\]\s*\{/,
315
+ "[data-theme=\"dark\"] {\n color-scheme: dark;"
316
+ );
317
+ }
318
+ console.log(" ✓ set color-scheme on :root and [data-theme=\"dark\"] for native UI");
319
+ }
320
+
321
+ writeFileSync(globalsPath, css, "utf8");
322
+ }
323
+
324
+ /**
325
+ * @param {string} templateRoot
326
+ */
327
+ function applyThemeModesLayoutPatch(templateRoot) {
328
+ const layoutPath = join(templateRoot, "app", "layout.tsx");
329
+ if (!existsSync(layoutPath)) return;
330
+
331
+ let content = readFileSync(layoutPath, "utf8");
332
+
333
+ if (content.includes("SITE.theme.colors") && content.includes("style={{")) {
334
+ content = content.replace(/\s*style=\{\{[\s\S]*?SITE\.theme\.colors[\s\S]*?\}\s*as React\.CSSProperties\}\}/, "");
335
+ console.log(" ✓ removed inline theme CSS vars from <html> (uses data-theme CSS instead)");
336
+ }
337
+
338
+ if (!content.includes("ThemeModeInit")) {
339
+ const cssImport = content.match(/import\s+["']\.\/globals\.css["'];?/);
340
+ if (cssImport) {
341
+ content = content.replace(
342
+ cssImport[0],
343
+ `${cssImport[0]}\nimport ThemeModeInit from "@/app/components/ThemeModeInit";`
344
+ );
345
+ } else {
346
+ content = `import ThemeModeInit from "@/app/components/ThemeModeInit";\n${content}`;
347
+ }
348
+
349
+ if (content.includes("<body")) {
350
+ content = content.replace(/<body([^>]*)>/, "<body$1>\n <ThemeModeInit />");
351
+ }
352
+
353
+ writeFileSync(layoutPath, content, "utf8");
354
+ console.log(" ✓ wired ThemeModeInit into app/layout.tsx");
355
+ content = readFileSync(layoutPath, "utf8");
356
+ }
357
+
358
+ if (!/<html[^>]*suppressHydrationWarning/.test(content)) {
359
+ content = content.replace(/<html([^>]*)>/, "<html$1 suppressHydrationWarning>");
360
+ writeFileSync(layoutPath, content, "utf8");
361
+ console.log(" ✓ added suppressHydrationWarning on <html> for theme mode init");
362
+ content = readFileSync(layoutPath, "utf8");
363
+ }
364
+
365
+ if (!/<body[^>]*suppressHydrationWarning/.test(content)) {
366
+ content = content.replace(/<body([^>]*)>/, (match, attrs) => {
367
+ if (attrs.includes("suppressHydrationWarning")) return match;
368
+ return `<body${attrs} suppressHydrationWarning>`;
369
+ });
370
+ writeFileSync(layoutPath, content, "utf8");
371
+ console.log(" ✓ added suppressHydrationWarning on <body> for theme mode init");
372
+ }
373
+ }
374
+
375
+ /**
376
+ * @param {string} templateRoot
377
+ */
378
+ function applyThemeModesInstall(templateRoot) {
379
+ const sitePath = join(templateRoot, "constants", "site.ts");
380
+ const light = existsSync(sitePath)
381
+ ? parseLightColorsFromSiteTs(readFileSync(sitePath, "utf8")) ?? {}
382
+ : {};
383
+ const dark = deriveDarkColorsFromLight(light);
384
+ applyThemeModesSitePatch(templateRoot);
385
+ applyThemeModesCssPatch(templateRoot, dark);
386
+ applyThemeModesLayoutPatch(templateRoot);
387
+ }
388
+
389
+ /**
390
+ * @param {string} templateRoot
391
+ * @param {string} featureFlag
392
+ */
393
+ function enableFeatureFlag(templateRoot, featureFlag) {
394
+ const sitePath = join(templateRoot, "constants", "site.ts");
395
+ if (!existsSync(sitePath)) return;
396
+
397
+ let content = readFileSync(sitePath, "utf8");
398
+ const pattern = new RegExp(`(${featureFlag}\\s*:\\s*)false`);
399
+ if (!pattern.test(content)) {
400
+ console.warn(` ⚠ feature flag ${featureFlag} not found in constants/site.ts`);
401
+ return;
402
+ }
403
+ content = content.replace(pattern, "$1true");
404
+ writeFileSync(sitePath, content, "utf8");
405
+ }
406
+
407
+ /**
408
+ * @param {string} templateRoot
409
+ * @param {string} displayName
410
+ */
411
+ function applyHeroSimpleDefaults(templateRoot, displayName) {
412
+ const sitePath = join(templateRoot, "constants", "site.ts");
413
+ if (!existsSync(sitePath)) return;
414
+
415
+ let content = readFileSync(sitePath, "utf8");
416
+ const replacements = [
417
+ ['eyebrow: "WELCOME"', `eyebrow: "WELCOME TO ${displayName.toUpperCase()}"`],
418
+ ['headline: "Your headline here"', `headline: "${displayName}"`],
419
+ [
420
+ 'subheadline: "Your subheadline here."',
421
+ 'subheadline: "Edit constants/site.ts to customize hero copy and add hero images."',
422
+ ],
423
+ [
424
+ "features: [] as { icon: string; label: string }[]",
425
+ "features: [{ icon: \"sparkles\", label: \"Quality\" }, { icon: \"shield\", label: \"Trusted\" }] as { icon: string; label: string }[]",
426
+ ],
427
+ [
428
+ "ctaButtons: [] as { label: string; enquiryLabel: string }[]",
429
+ "ctaButtons: [{ label: \"Contact us\", enquiryLabel: \"General enquiry\" }] as { label: string; enquiryLabel: string }[]",
430
+ ],
431
+ ];
432
+
433
+ for (const [find, replace] of replacements) {
434
+ if (content.includes(find)) {
435
+ content = content.replace(find, replace);
436
+ }
437
+ }
438
+
439
+ writeFileSync(sitePath, content, "utf8");
440
+ }
441
+
442
+ /**
443
+ * @param {string} templateRoot
444
+ * @param {string} displayName
445
+ */
446
+ function applyEnquiryModalDefaults(templateRoot, displayName) {
447
+ const sitePath = join(templateRoot, "constants", "site.ts");
448
+ if (!existsSync(sitePath)) return;
449
+
450
+ let content = readFileSync(sitePath, "utf8");
451
+ const replacements = [
452
+ ['modalTitle: "Demo Client Site"', `modalTitle: "${displayName}"`],
453
+ [
454
+ "selectionOptions: [] as { value: string; label: string }[]",
455
+ "selectionOptions: [{ value: \"General enquiry\", label: \"General enquiry\" }] as { value: string; label: string }[]",
456
+ ],
457
+ ];
458
+
459
+ for (const [find, replace] of replacements) {
460
+ if (content.includes(find)) {
461
+ content = content.replace(find, replace);
462
+ }
463
+ }
464
+
465
+ writeFileSync(sitePath, content, "utf8");
466
+ }
467
+
468
+ /**
469
+ * @param {string} templateRoot
470
+ * @param {string[]} moduleIds
471
+ */
472
+ export function writeInstalledModulesManifest(templateRoot, moduleIds) {
473
+ const manifestPath = join(templateRoot, ".tempjs-modules.json");
474
+ let existing = { coreModules: [] };
475
+
476
+ if (existsSync(manifestPath)) {
477
+ try {
478
+ existing = JSON.parse(readFileSync(manifestPath, "utf8"));
479
+ } catch {
480
+ existing = { coreModules: [] };
481
+ }
482
+ }
483
+
484
+ const merged = [...new Set([...(existing.coreModules ?? []), ...moduleIds])];
485
+ writeFileSync(
486
+ manifestPath,
487
+ JSON.stringify({ coreModules: merged, updatedAt: new Date().toISOString() }, null, 2) + "\n",
488
+ "utf8"
489
+ );
490
+ }
491
+
492
+ /**
493
+ * @param {string} templateRoot
494
+ * @param {string[]} moduleIds
495
+ */
496
+ export function applyModulesHomePage(templateRoot, moduleIds) {
497
+ const uiModules = [
498
+ "enquiry-modal",
499
+ "footer",
500
+ "hero-simple",
501
+ "gallery",
502
+ "reviews",
503
+ "blog-compose",
504
+ ];
505
+ const installedUi = moduleIds.filter((id) => uiModules.includes(id));
506
+
507
+ if (installedUi.length === 0) return;
508
+
509
+ const destPage = join(templateRoot, "app", "page.tsx");
510
+ mkdirSync(dirname(destPage), { recursive: true });
511
+ writeFileSync(destPage, buildModulesHomePageSource(moduleIds), "utf8");
512
+ console.log(" ✓ wired app/page.tsx with optional UI modules");
513
+ }
514
+
515
+ /**
516
+ * Copy module files into a project and enable feature flags.
517
+ * @param {string} templateRoot Absolute path to project/template directory
518
+ * @param {string[]} moduleIds Module ids (resolved or raw)
519
+ * @param {{
520
+ * coreDir: string;
521
+ * displayName?: string;
522
+ * excludePaths?: string[];
523
+ * skipHomePage?: boolean;
524
+ * skipAdminTabRegistry?: boolean;
525
+ * skipAdminContentPage?: boolean;
526
+ * skipSeoMetadataReplace?: boolean;
527
+ * installOptions?: { blogSidebar?: boolean };
528
+ * writePrismaSchema?: (root: string) => void;
529
+ * }} options
530
+ * @returns {string[]} Installed module ids
531
+ */
532
+ export function copyModulesIntoProject(templateRoot, moduleIds, options) {
533
+ const { coreDir } = options;
534
+ const modulesRoot = join(coreDir, "modules");
535
+ const registry = loadModulesRegistry(coreDir);
536
+ const resolved = resolveModuleIds(coreDir, moduleIds);
537
+ const displayName = options.displayName ?? "Your Site";
538
+ const exclude = new Set(options.excludePaths ?? []);
539
+
540
+ for (const id of resolved) {
541
+ const mod = registry.modules[id];
542
+ const moduleDir = join(modulesRoot, id);
543
+
544
+ if (!existsSync(moduleDir)) {
545
+ throw new Error(`Module folder missing: ${moduleDir}`);
546
+ }
547
+
548
+ for (const relPath of mod.paths) {
549
+ if (exclude.has(relPath)) {
550
+ console.log(` · skipped path (excluded): ${relPath}`);
551
+ continue;
552
+ }
553
+
554
+ const src = join(moduleDir, relPath);
555
+ const dest = join(templateRoot, relPath);
556
+
557
+ if (!existsSync(src)) {
558
+ throw new Error(`Module file missing: ${join(moduleDir, relPath)}`);
559
+ }
560
+
561
+ mkdirSync(dirname(dest), { recursive: true });
562
+ cpSync(src, dest, { force: true, recursive: true });
563
+ }
564
+
565
+ if (mod.featureFlag) {
566
+ enableFeatureFlag(templateRoot, mod.featureFlag);
567
+ }
568
+
569
+ if (mod.prismaFragment) {
570
+ appendPrismaFragment(templateRoot, id, mod.prismaFragment, modulesRoot);
571
+ }
572
+
573
+ if (id === "hero-simple") {
574
+ applyHeroSimpleDefaults(templateRoot, displayName);
575
+ }
576
+ if (id === "enquiry-modal") {
577
+ applyEnquiryModalDefaults(templateRoot, displayName);
578
+ }
579
+ if (id === "theme-modes") {
580
+ applyThemeModesInstall(templateRoot);
581
+ }
582
+
583
+ console.log(` ✓ module: ${id} (${mod.label})`);
584
+ }
585
+
586
+ if (resolved.includes("blog-compose")) {
587
+ applyBlogComposeSitemapPatch(templateRoot);
588
+ if (options.installOptions?.blogSidebar) {
589
+ enableFeatureFlag(templateRoot, "blogSidebar");
590
+ console.log(" ✓ enabled SITE.features.blogSidebar (blog article sidebar)");
591
+ }
592
+ }
593
+
594
+ if (options.writePrismaSchema) {
595
+ options.writePrismaSchema(templateRoot);
596
+ }
597
+
598
+ writeInstalledModulesManifest(templateRoot, resolved);
599
+
600
+ if (resolved.includes("seo")) {
601
+ applySeoLayoutPatch(templateRoot, {
602
+ skipMetadataReplace: options.skipSeoMetadataReplace,
603
+ });
604
+ }
605
+
606
+ if (!options.skipHomePage) {
607
+ applyModulesHomePage(templateRoot, resolved);
608
+ }
609
+
610
+ const skipAdminTabRegistry =
611
+ options.skipAdminTabRegistry ?? options.skipAdminContentPage ?? false;
612
+
613
+ if (
614
+ !skipAdminTabRegistry &&
615
+ resolved.some((id) => id === "gallery" || id === "reviews" || id === "blog-compose")
616
+ ) {
617
+ applyAdminTabRegistry(templateRoot, resolved);
618
+ }
619
+
620
+ return resolved;
621
+ }
622
+
623
+ /**
624
+ * @param {string[] | string | undefined} input
625
+ * @returns {{ id: string, extras: string[] }[]}
626
+ */
627
+ export function parseModuleSpecs(input) {
628
+ if (!input) return [];
629
+ const raw = Array.isArray(input) ? input.join(",") : input;
630
+ const specs = [];
631
+ for (const part of raw.split(",")) {
632
+ const trimmed = part.trim();
633
+ if (!trimmed) continue;
634
+ const pieces = trimmed.split("+").map((s) => s.trim()).filter(Boolean);
635
+ if (pieces.length === 0) continue;
636
+ specs.push({ id: pieces[0], extras: pieces.slice(1) });
637
+ }
638
+ return specs;
639
+ }
640
+
641
+ /**
642
+ * @param {{ id: string, extras: string[] }[]} specs
643
+ */
644
+ export function moduleInstallOptionsFromSpecs(specs) {
645
+ return {
646
+ blogSidebar:
647
+ specs.some((s) => s.id === "blog-compose" && s.extras.includes("sidebar")),
648
+ };
649
+ }
650
+
651
+ /**
652
+ * @param {string[] | string | undefined} input
653
+ * @returns {string[]}
654
+ */
655
+ export function parseModulesArg(input) {
656
+ return parseModuleSpecs(input).map((s) => s.id);
657
+ }