create-next-pro-cli 0.1.32 → 0.1.34

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 CHANGED
@@ -126,6 +126,8 @@ Every page belongs to an explicit route area. Use `--area public` for routes ren
126
126
 
127
127
  `addpage` creates `layout`, `page`, and `loading` files by default. Available long options are `--layout`, `--page`, `--loading`, `--not-found`, `--error`, `--global-error`, `--route`, `--template`, and `--default`. The historical short forms remain available.
128
128
 
129
+ Generated page-level layouts accept only `children` because they do not read route parameters. If a customized Next.js 16 layout needs the locale, make the component asynchronous, type `params` as `Promise<{ locale: string }>` and await it before use. Projects generated with `create-next-pro-cli@0.1.32` may contain the obsolete synchronous `params: { locale: string }` signature: remove that property when it is unused, or migrate it to the asynchronous contract.
130
+
129
131
  `rmpage` only lists routes that contain an actual `page.tsx` in the `(public)` or `(user)` route group. Technical directories remain hidden. Removal is confined to the selected area and project, and it preserves shared parent directories and unrelated files.
130
132
 
131
133
  ## Generated project architecture
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-next-pro-cli",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Advanced Next.js project scaffolder with i18n, Tailwind, App Router and more.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,11 +1,5 @@
1
- import React from "react";
1
+ import type { ReactNode } from "react";
2
2
 
3
- export default function templateLayout({
4
- children,
5
- params,
6
- }: {
7
- children: React.ReactNode;
8
- params: { locale: string };
9
- }) {
3
+ export default function templateLayout({ children }: { children: ReactNode }) {
10
4
  return children;
11
5
  }
@@ -47,6 +47,8 @@ For `Account.Security --area user`, expect resources under:
47
47
 
48
48
  The route area affects only the App Router path and layout. UI and message paths remain area-independent. Public page UI uses its own `<main>` landmark; user page UI uses `<section>` because the user layout already provides `<main>`.
49
49
 
50
+ The generated page-level `layout.tsx` accepts only `children` because it does not read route parameters. If you customize it to read the locale under Next.js 16, make the component asynchronous, type `params` as `Promise<{ locale: string }>` and await it before use.
51
+
50
52
  The command creates only missing code files and preserves existing ones as `unchanged`. Repeating the command in the same area is idempotent and can add requested missing route files. Requesting the same logical page in the other area fails with `TARGET_EXISTS`. Flat historical routes or ambiguous route definitions fail with `INCONSISTENT_ROUTE` before any write.
51
53
 
52
54
  ## Workflow
@@ -87,3 +89,5 @@ bun run check
87
89
  ```
88
90
 
89
91
  Confirm every `created` or `updated` event path exists. A repeated command should preserve existing code and normally report those resources as `unchanged`.
92
+
93
+ When a generated or customized layout reads route parameters, include `next build` in validation so Next.js checks the inferred `LayoutProps` contract.
@@ -107,6 +107,12 @@ interactive page flows expose the same area-aware catalog. Areas select a route
107
107
  layout without changing the public URL; see the linked command skills for the
108
108
  complete contract.
109
109
 
110
+ Page-level layouts generated by the CLI accept only `children` when they do not
111
+ read route parameters. A customized Next.js 16 layout that reads the locale must
112
+ type `params` as `Promise<{ locale: string }>` and await it. Projects created
113
+ with `create-next-pro-cli@0.1.32` should remove the obsolete synchronous
114
+ `params` property when unused, or migrate it to this asynchronous contract.
115
+
110
116
  ## Template Features
111
117
 
112
118
  - Next.js 16 App Router
@@ -4,7 +4,6 @@ import { setRequestLocale } from "next-intl/server";
4
4
  import { hasLocale } from "next-intl";
5
5
  import type { Metadata } from "next";
6
6
  import { notFound } from "next/navigation";
7
- import Script from "next/script";
8
7
  import React from "react";
9
8
  import "@/app/styles/globals.css";
10
9
  import { routing } from "@/lib/i18n/routing";
@@ -37,18 +36,11 @@ export default async function LocaleLayout({
37
36
  setRequestLocale(locale);
38
37
  const messages = getMessages(locale);
39
38
  return (
40
- <html lang={locale} className="light" suppressHydrationWarning>
39
+ <html lang={locale} className="light">
41
40
  <body>
42
41
  <NextIntlClientProvider locale={locale} messages={messages}>
43
42
  {children}
44
43
  </NextIntlClientProvider>
45
- <Script
46
- id="theme-initializer"
47
- strategy="beforeInteractive"
48
- dangerouslySetInnerHTML={{
49
- __html: `try{var t=localStorage.getItem("theme");var d=t?t==="dark":window.matchMedia("(prefers-color-scheme: dark)").matches;document.documentElement.classList.toggle("dark",d);document.documentElement.classList.toggle("light",!d);}catch{document.documentElement.classList.add("light");}`,
50
- }}
51
- />
52
44
  </body>
53
45
  </html>
54
46
  );
@@ -4,30 +4,81 @@ import { Moon, Sun } from "lucide-react";
4
4
  import { Button } from "./Button";
5
5
  import { useLocale, useTranslations } from "next-intl";
6
6
 
7
+ type Theme = "dark" | "light";
8
+
7
9
  const themeChangeEvent = "template-theme-change";
8
10
 
9
- function getThemeSnapshot() {
11
+ function getThemeSnapshot(): Theme {
10
12
  return document.documentElement.classList.contains("dark") ? "dark" : "light";
11
13
  }
12
14
 
13
- function getServerThemeSnapshot() {
15
+ function getServerThemeSnapshot(): Theme {
14
16
  return "light";
15
17
  }
16
18
 
19
+ function isTheme(value: string | null): value is Theme {
20
+ return value === "dark" || value === "light";
21
+ }
22
+
23
+ function readStoredTheme(): Theme | null {
24
+ try {
25
+ const theme = localStorage.getItem("theme");
26
+ return isTheme(theme) ? theme : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function getSystemTheme(): Theme {
33
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
34
+ ? "dark"
35
+ : "light";
36
+ }
37
+
38
+ function setDocumentTheme(theme: Theme) {
39
+ const isDark = theme === "dark";
40
+ document.documentElement.classList.toggle("dark", isDark);
41
+ document.documentElement.classList.toggle("light", !isDark);
42
+ }
43
+
44
+ function persistTheme(theme: Theme) {
45
+ try {
46
+ localStorage.setItem("theme", theme);
47
+ } catch {
48
+ // The active document theme still works when storage is unavailable.
49
+ }
50
+ }
51
+
17
52
  function subscribeToThemeChange(onStoreChange: () => void) {
53
+ const handleStorage = (event: StorageEvent) => {
54
+ if (event.key !== null && event.key !== "theme") return;
55
+
56
+ setDocumentTheme(readStoredTheme() ?? getSystemTheme());
57
+ onStoreChange();
58
+ };
59
+
60
+ const colorScheme = window.matchMedia("(prefers-color-scheme: dark)");
61
+ const handleSystemThemeChange = (event: MediaQueryListEvent) => {
62
+ if (readStoredTheme() !== null) return;
63
+
64
+ setDocumentTheme(event.matches ? "dark" : "light");
65
+ onStoreChange();
66
+ };
67
+
18
68
  window.addEventListener(themeChangeEvent, onStoreChange);
19
- window.addEventListener("storage", onStoreChange);
69
+ window.addEventListener("storage", handleStorage);
70
+ colorScheme.addEventListener("change", handleSystemThemeChange);
20
71
 
21
72
  return () => {
22
73
  window.removeEventListener(themeChangeEvent, onStoreChange);
23
- window.removeEventListener("storage", onStoreChange);
74
+ window.removeEventListener("storage", handleStorage);
75
+ colorScheme.removeEventListener("change", handleSystemThemeChange);
24
76
  };
25
77
  }
26
78
 
27
- function applyTheme(dark: boolean) {
28
- document.documentElement.classList.toggle("dark", dark);
29
- document.documentElement.classList.toggle("light", !dark);
30
- localStorage.setItem("theme", dark ? "dark" : "light");
79
+ function applyTheme(theme: Theme, persist: boolean) {
80
+ setDocumentTheme(theme);
81
+ if (persist) persistTheme(theme);
31
82
  window.dispatchEvent(new Event(themeChangeEvent));
32
83
  }
33
84
 
@@ -42,17 +93,12 @@ export default function ThemeToggle() {
42
93
  const isDark = theme === "dark";
43
94
 
44
95
  useEffect(() => {
45
- const storedTheme = localStorage.getItem("theme");
46
- const shouldUseDark = storedTheme
47
- ? storedTheme === "dark"
48
- : window.matchMedia("(prefers-color-scheme: dark)").matches;
49
-
50
- applyTheme(shouldUseDark);
96
+ applyTheme(readStoredTheme() ?? getSystemTheme(), false);
51
97
  }, [locale]);
52
98
 
53
99
  return (
54
100
  <Button
55
- onClick={() => applyTheme(!isDark)}
101
+ onClick={() => applyTheme(isDark ? "light" : "dark", true)}
56
102
  className="rounded-full p-0 dark:hover:bg-white/10 light:hover:bg-black/10 transition-colors"
57
103
  aria-label={t("toggle_theme")}
58
104
  variant="ghost"
@@ -1,10 +1,14 @@
1
1
  import { expect, test } from "@playwright/test";
2
+ import path from "node:path";
2
3
 
3
4
  const captureByProject: Record<string, string> = {
4
5
  desktop: "artifacts/captures/phase-2-11-template-reconciliation-desktop.png",
5
6
  mobile: "artifacts/captures/phase-2-11-template-reconciliation-mobile.png",
6
7
  };
7
8
 
9
+ const themeCaptureDirectory =
10
+ process.env.CNP_CAPTURE_DIRECTORY ?? "artifacts/captures";
11
+
8
12
  test("public template routes render in both locales", async ({
9
13
  page,
10
14
  }, testInfo) => {
@@ -107,32 +111,108 @@ test("security headers and disabled auth are explicit", async ({ request }) => {
107
111
  });
108
112
  });
109
113
 
110
- test("theme is initialized before hydration without a React warning", async ({
114
+ test("ThemeToggle restores and persists the active theme without scripts", async ({
111
115
  page,
112
- }) => {
113
- const hydrationWarnings: string[] = [];
116
+ }, testInfo) => {
117
+ const themeConsoleIssues: string[] = [];
114
118
  page.on("console", (message) => {
115
119
  if (
116
- message.type() === "warning" &&
117
- (message.text().includes("hydrated") ||
120
+ (message.type() === "warning" || message.type() === "error") &&
121
+ (message.text().toLowerCase().includes("hydrat") ||
118
122
  message.text().includes("Encountered a script tag"))
119
123
  ) {
120
- hydrationWarnings.push(message.text());
124
+ themeConsoleIssues.push(message.text());
121
125
  }
122
126
  });
123
127
 
124
- await page.addInitScript(() => localStorage.setItem("theme", "dark"));
128
+ await page.addInitScript(() => {
129
+ if (localStorage.getItem("theme") === null) {
130
+ localStorage.setItem("theme", "dark");
131
+ }
132
+ });
125
133
  await page.goto("/en", { waitUntil: "domcontentloaded" });
126
- await expect(page.locator("#theme-initializer")).toHaveCount(1);
134
+ await expect(page.locator("#theme-initializer")).toHaveCount(0);
135
+ await expect(page.locator("html")).toHaveClass(/dark/);
136
+ await expect
137
+ .poll(() => page.evaluate(() => localStorage.getItem("theme")))
138
+ .toBe("dark");
139
+
140
+ if (testInfo.project.name === "desktop") {
141
+ await page.screenshot({
142
+ path: path.join(
143
+ themeCaptureDirectory,
144
+ "phase-2-16-theme-dark-desktop.png",
145
+ ),
146
+ fullPage: true,
147
+ });
148
+ }
149
+
150
+ await page.getByLabel("Select language").selectOption("fr");
151
+ await expect(page).toHaveURL(/\/fr$/);
127
152
  await expect(page.locator("html")).toHaveClass(/dark/);
128
- expect(hydrationWarnings).toEqual([]);
153
+
154
+ await page.getByLabel("Changer de thème").click();
155
+ await expect(page.locator("html")).toHaveClass(/light/);
156
+ await expect
157
+ .poll(() => page.evaluate(() => localStorage.getItem("theme")))
158
+ .toBe("light");
159
+
160
+ await page.reload({ waitUntil: "domcontentloaded" });
161
+ await expect(page.locator("html")).toHaveClass(/light/);
162
+
163
+ await page.goto("/fr/dashboard", { waitUntil: "domcontentloaded" });
164
+ await expect(page).toHaveURL(/\/fr\/login/);
165
+ await expect(page.locator("html")).toHaveClass(/light/);
166
+
167
+ if (testInfo.project.name === "desktop") {
168
+ await page.screenshot({
169
+ path: path.join(
170
+ themeCaptureDirectory,
171
+ "phase-2-16-theme-light-desktop.png",
172
+ ),
173
+ fullPage: true,
174
+ });
175
+ }
176
+
177
+ expect(themeConsoleIssues).toEqual([]);
178
+ });
179
+
180
+ test("ThemeToggle follows the system preference without persisting it", async ({
181
+ page,
182
+ }) => {
183
+ await page.emulateMedia({ colorScheme: "dark" });
184
+ await page.addInitScript(() => localStorage.removeItem("theme"));
185
+ await page.goto("/en", { waitUntil: "domcontentloaded" });
186
+
187
+ await expect(page.locator("html")).toHaveClass(/dark/);
188
+ await expect
189
+ .poll(() => page.evaluate(() => localStorage.getItem("theme")))
190
+ .toBeNull();
191
+
192
+ await page.emulateMedia({ colorScheme: "light" });
193
+ await expect(page.locator("html")).toHaveClass(/light/);
194
+ await expect
195
+ .poll(() => page.evaluate(() => localStorage.getItem("theme")))
196
+ .toBeNull();
129
197
  });
130
198
 
131
- test("theme falls back to the system preference", async ({ page }) => {
199
+ test("ThemeToggle remains usable when browser storage is unavailable", async ({
200
+ page,
201
+ }) => {
132
202
  await page.emulateMedia({ colorScheme: "dark" });
203
+ await page.addInitScript(() => {
204
+ Object.defineProperty(window, "localStorage", {
205
+ configurable: true,
206
+ get() {
207
+ throw new DOMException("Storage is unavailable", "SecurityError");
208
+ },
209
+ });
210
+ });
133
211
  await page.goto("/en", { waitUntil: "domcontentloaded" });
134
212
 
135
213
  await expect(page.locator("html")).toHaveClass(/dark/);
214
+ await page.getByLabel("Toggle theme").click();
215
+ await expect(page.locator("html")).toHaveClass(/light/);
136
216
  });
137
217
 
138
218
  test("mobile navigation supports keyboard dismissal", async ({
@@ -58,4 +58,23 @@ describe("updated template baseline", () => {
58
58
  ].filter(Boolean);
59
59
  expect(configuredAuthValues).toHaveLength(3);
60
60
  });
61
+
62
+ test("delegates theme initialization to ThemeToggle without inline scripts", async () => {
63
+ const [layout, themeToggle] = await Promise.all([
64
+ source("src/app/[locale]/layout.tsx"),
65
+ source("src/ui/_global/ThemeToggle.tsx"),
66
+ ]);
67
+
68
+ expect(layout).toContain('className="light"');
69
+ expect(layout).not.toContain("next/script");
70
+ expect(layout).not.toContain("theme-initializer");
71
+ expect(layout).not.toContain("dangerouslySetInnerHTML");
72
+ expect(layout).not.toContain("suppressHydrationWarning");
73
+
74
+ expect(themeToggle).toContain('localStorage.getItem("theme")');
75
+ expect(themeToggle).toContain('localStorage.setItem("theme", theme)');
76
+ expect(themeToggle).toContain("prefers-color-scheme: dark");
77
+ expect(themeToggle).toContain('classList.toggle("dark", isDark)');
78
+ expect(themeToggle).toContain('classList.toggle("light", !isDark)');
79
+ });
61
80
  });