diff --git a/.changeset/init-trailing-newline.md b/.changeset/init-trailing-newline.md new file mode 100644 index 000000000..29ce4f63c --- /dev/null +++ b/.changeset/init-trailing-newline.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Keep the trailing newline on files that `clerk init` edits, so they no longer show "No newline at end of file" in `git diff`. diff --git a/packages/cli-core/src/commands/init/frameworks/astro.test.ts b/packages/cli-core/src/commands/init/frameworks/astro.test.ts index f6d7dd18f..00e87a6c2 100644 --- a/packages/cli-core/src/commands/init/frameworks/astro.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/astro.test.ts @@ -64,6 +64,7 @@ export default defineConfig({ if (config.type === "modify") { expect(config.content).toContain("clerk"); expect(config.content).toContain("@clerk/astro"); + expect(config.content.endsWith("\n")).toBe(true); } const mw = findAction(plan.actions, "src/middleware.ts"); diff --git a/packages/cli-core/src/commands/init/frameworks/astro.ts b/packages/cli-core/src/commands/init/frameworks/astro.ts index 2d37b2be4..72a74d6e8 100644 --- a/packages/cli-core/src/commands/init/frameworks/astro.ts +++ b/packages/cli-core/src/commands/init/frameworks/astro.ts @@ -8,6 +8,7 @@ import { hasTailwindStyles, headerHtmlBlock, htmlAuthComponentMarkup, + printModule, scaffoldAuthFiles, scaffoldConfigFile, scaffoldEnvVars, @@ -38,7 +39,7 @@ function addClerkImport(content: string): string { try { const mod = parseModule(content); mod.imports.$add({ from: "@clerk/astro", imported: "default", local: "clerk" }); - return mod.generate().code; + return printModule(mod, content); } catch { return `import clerk from "@clerk/astro";\n${content}`; } @@ -53,7 +54,7 @@ function addClerkToIntegrationsViaAst(content: string): string | null { if (!Array.isArray(defaultExport.integrations)) return null; defaultExport.integrations.push(builders.raw("clerk()")); - return mod.generate().code; + return printModule(mod, content); } catch { return null; } diff --git a/packages/cli-core/src/commands/init/frameworks/helpers.ts b/packages/cli-core/src/commands/init/frameworks/helpers.ts index acefea1d9..a18a6360b 100644 --- a/packages/cli-core/src/commands/init/frameworks/helpers.ts +++ b/packages/cli-core/src/commands/init/frameworks/helpers.ts @@ -14,6 +14,7 @@ export { hasClerkImport, indentBlock, safeAddImport, + printModule, insertAfterLastImport, addSuspenseImport, wrapBodyWithProvider, diff --git a/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts index 73625e503..fea0c0196 100644 --- a/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/nextjs-app.test.ts @@ -801,7 +801,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) ); -}`, +} +`, }); }); diff --git a/packages/cli-core/src/commands/init/frameworks/nuxt.test.ts b/packages/cli-core/src/commands/init/frameworks/nuxt.test.ts index c09c347dc..376611a41 100644 --- a/packages/cli-core/src/commands/init/frameworks/nuxt.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/nuxt.test.ts @@ -61,6 +61,7 @@ test("scaffolds all actions for a fresh Nuxt project (Nuxt 3 layout)", async () expect(config.type).toBe("modify"); if (config.type === "modify") { expect(config.content).toContain("@clerk/nuxt"); + expect(config.content.endsWith("\n")).toBe(true); } // Nuxt 3 layout: pages at pages/ (no app/ directory) diff --git a/packages/cli-core/src/commands/init/frameworks/nuxt.ts b/packages/cli-core/src/commands/init/frameworks/nuxt.ts index e0ec0a417..3a1575811 100644 --- a/packages/cli-core/src/commands/init/frameworks/nuxt.ts +++ b/packages/cli-core/src/commands/init/frameworks/nuxt.ts @@ -9,6 +9,7 @@ import { headerHtmlBlock, htmlAuthComponentMarkup, indentBlock, + printModule, scaffoldAuthFiles, scaffoldConfigFile, scaffoldEnvVars, @@ -46,7 +47,7 @@ function addNuxtModule(content: string): string { if (!defaultExport.modules) defaultExport.modules = []; if (Array.isArray(defaultExport.modules)) defaultExport.modules.push("@clerk/nuxt"); - return mod.generate().code; + return printModule(mod, content); } catch { if (content.includes("modules:")) { return content.replace(/(modules:\s*\[)/, "$1\n '@clerk/nuxt',"); diff --git a/packages/cli-core/src/commands/init/frameworks/react-router.test.ts b/packages/cli-core/src/commands/init/frameworks/react-router.test.ts index 7d198f64c..bc6c18eab 100644 --- a/packages/cli-core/src/commands/init/frameworks/react-router.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/react-router.test.ts @@ -420,6 +420,7 @@ export default { if (configAction?.type !== "modify") throw new Error("Expected config modify action"); expect(configAction.content).toContain("v8_middleware: true"); + expect(configAction.content.endsWith("\n")).toBe(true); }); test("does not enable v8_middleware for React Router 8", async () => { @@ -496,6 +497,7 @@ export default defineConfig({ expect(viteAction.content).toContain("noExternal"); expect(viteAction.content).toContain("@clerk/react-router"); expect(viteAction.content.match(/ssr\s*:/g)?.length).toBe(1); + expect(viteAction.content.endsWith("\n")).toBe(true); }); test("appends to an existing ssr.noExternal array for React Router 8", async () => { diff --git a/packages/cli-core/src/commands/init/frameworks/react-router.ts b/packages/cli-core/src/commands/init/frameworks/react-router.ts index 6242771b2..c2679cb49 100644 --- a/packages/cli-core/src/commands/init/frameworks/react-router.ts +++ b/packages/cli-core/src/commands/init/frameworks/react-router.ts @@ -10,6 +10,7 @@ import { jsxAuthPageContent, jsxExt, parseMajorVersion, + printModule, safeAddImport, scaffoldAuthFiles, scaffoldConfigFile, @@ -215,7 +216,7 @@ function enableV8Middleware(content: string): string { if (!defaultExport.future) defaultExport.future = {}; defaultExport.future.v8_middleware = true; - return mod.generate().code; + return printModule(mod, content); } catch { if (content.includes("future:")) { return content.replace(/(future:\s*\{)/, "$1\n v8_middleware: true,"); @@ -415,7 +416,7 @@ function addClerkNoExternal(content: string): string { if (!config.ssr.noExternal.includes("@clerk/react-router")) { config.ssr.noExternal.push("@clerk/react-router"); } - return mod.generate().code; + return printModule(mod, content); } catch { return content; } diff --git a/packages/cli-core/src/commands/init/frameworks/transformations.test.ts b/packages/cli-core/src/commands/init/frameworks/transformations.test.ts index 358200b9a..cc68e9d18 100644 --- a/packages/cli-core/src/commands/init/frameworks/transformations.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/transformations.test.ts @@ -1,5 +1,53 @@ import { test, expect, describe } from "bun:test"; -import { addSuspenseImport, insertAfterLastImport } from "./transformations.ts"; +import { parseModule } from "magicast"; +import { + addSuspenseImport, + insertAfterLastImport, + printModule, + safeAddImport, +} from "./transformations.ts"; + +describe("printModule", () => { + test.each([ + ["LF", "\n"], + ["CRLF", "\r\n"], + ])("keeps the source's %s line endings, including the trailing one", (_name, eol) => { + const source = `export default {${eol} a: 1,${eol}};${eol}`; + const mod = parseModule(source); + mod.exports.default.b = 2; + + const result = printModule(mod, source); + + expect(result).toBe(`export default {${eol} a: 1,${eol} b: 2,${eol}};${eol}`); + }); + + test("keeps CRLF line endings when the source ended without one", () => { + const source = `export default {\r\n a: 1,\r\n};`; + const mod = parseModule(source); + mod.exports.default.b = 2; + + expect(printModule(mod, source)).toBe(`export default {\r\n a: 1,\r\n b: 2,\r\n};`); + }); + + test("adds no line break when the source ended without one", () => { + const source = `export default {\n a: 1,\n};`; + const mod = parseModule(source); + mod.exports.default.b = 2; + + expect(printModule(mod, source).endsWith(";")).toBe(true); + }); +}); + +describe("safeAddImport", () => { + test("keeps the trailing newline", () => { + const source = `import "./globals.css";\n\nexport default function RootLayout() {\n return null;\n}\n`; + + const result = safeAddImport(source, "@clerk/nextjs", "ClerkProvider"); + + expect(result).toContain('import { ClerkProvider } from "@clerk/nextjs";'); + expect(result.endsWith("}\n")).toBe(true); + }); +}); describe("insertAfterLastImport", () => { test("inserts after a single-line import", () => { diff --git a/packages/cli-core/src/commands/init/frameworks/transformations.ts b/packages/cli-core/src/commands/init/frameworks/transformations.ts index 27206effd..5afe60699 100644 --- a/packages/cli-core/src/commands/init/frameworks/transformations.ts +++ b/packages/cli-core/src/commands/init/frameworks/transformations.ts @@ -3,9 +3,21 @@ * These functions take source code as input and return modified source code. * Used by framework scaffolders for import injection, provider wrapping, and indentation. */ -import { parseModule } from "magicast"; +import { parseModule, type ProxifiedModule } from "magicast"; import { maskCommentsAndStrings } from "./source-scan.js"; +/** + * Print a magicast module in the line endings of the source it was parsed from, keeping + * its trailing line break. magicast's printer joins lines with the host's EOL and drops + * the final line break, which leaves "No newline at end of file" in the user's diff. + */ +export function printModule(mod: ProxifiedModule, source: string): string { + const lineTerminator = /\r?\n/.exec(source)?.[0] ?? "\n"; + const code = mod.generate({ lineTerminator }).code; + const eol = /\r?\n$/.exec(source)?.[0]; + return eol && !code.endsWith("\n") ? code + eol : code; +} + /** Check if file content already imports from a @clerk/ package. */ export function hasClerkImport(content: string): boolean { return content.includes("@clerk/"); @@ -30,7 +42,10 @@ export function safeAddImport(content: string, source: string, imported: string) // spacing, and no printer option overrides that. Projects without a // formatter would keep `import {x}`, so re-space the one statement we // added — anchored on `from` so only an import statement can match. - return mod.generate().code.replace(`import {${imported}} from`, `import { ${imported} } from`); + return printModule(mod, content).replace( + `import {${imported}} from`, + `import { ${imported} } from`, + ); } catch { return `import { ${imported} } from "${source}";\n${content}`; }