Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/init-keeps-line-endings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@paramour-js/next": patch
---

`paramour init` keeps the line endings of the files it edits. In a CRLF checkout (git `core.autocrlf=true`), it used to report "updated paramour section" for an unchanged `AGENTS.md`/`CLAUDE.md` section and leave the file with mixed CRLF and LF line endings. It also rewrote a CRLF `package.json` or `next.config` entirely in LF. It now recognizes its own section regardless of line endings, and writes each edit using the file's existing line ending. Wrapping `next.config` also no longer drops the file's final newline.
10 changes: 10 additions & 0 deletions packages/next/src/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ export interface WriteIfChangedResult {
written: boolean;
}

/**
* The line ending a user-owned file already uses. Init edits files the user
* owns (package.json, next.config, AGENTS.md), and writing LF text into a CRLF file
* leaves mixed endings, so those edits keep the file's own ending. Generated
* files are always written LF instead.
*/
export function lineEnding(text: string): "\n" | "\r\n" {
return text.includes("\r\n") ? "\r\n" : "\n";
}

/**
* Content equality that ignores CRLF vs LF. A consumer repo with git
* `core.autocrlf=true` checks the committed LF artifact out as CRLF; that
Expand Down
13 changes: 8 additions & 5 deletions packages/next/src/init/agents-md.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { existsSync } from "node:fs";
import { join } from "node:path";

import { lineEnding, sameContent } from "../emit.js";

export const AGENTS_MARKER_START = "<!-- paramour:start -->";
export const AGENTS_MARKER_END = "<!-- paramour:end -->";

Expand Down Expand Up @@ -59,20 +61,21 @@ export function upsertAgentsSection(text: string): {
status: "added" | "unchanged" | "unterminated" | "updated";
text: string;
} {
const eol = lineEnding(text);
const snippet = agentsSnippet().replaceAll("\n", eol);
const start = text.indexOf(AGENTS_MARKER_START);
if (start === -1) {
const base = text === "" || text.endsWith("\n") ? text : `${text}\n`;
const separator = base === "" ? "" : "\n";
const base = text === "" || text.endsWith("\n") ? text : `${text}${eol}`;
const separator = base === "" ? "" : eol;
return {
status: "added",
text: `${base}${separator}${agentsSnippet()}\n`,
text: `${base}${separator}${snippet}${eol}`,
};
}
const end = text.indexOf(AGENTS_MARKER_END, start);
if (end === -1) return { status: "unterminated", text };
const current = text.slice(start, end + AGENTS_MARKER_END.length);
const snippet = agentsSnippet();
if (current === snippet) return { status: "unchanged", text };
if (sameContent(current, snippet)) return { status: "unchanged", text };
return {
status: "updated",
text:
Expand Down
15 changes: 10 additions & 5 deletions packages/next/src/init/scaffold.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";

import { lineEnding } from "../emit.js";
import { resolveRouteDirs } from "../scan.js";
import { loadPackagedSkill } from "../skills/packaged.js";
import { auditTarget, isOutdated } from "../skills/sync.js";
Expand All @@ -15,9 +16,9 @@ export interface SetupCheck {

/**
* Insert `"paramour": "paramour generate"` into a package.json's scripts,
* preserving the file's own indentation and trailing-newline choice.
* Throws on malformed JSON — a broken package.json is init's one hard
* prerequisite failure.
* preserving the file's own indentation, line ending, and trailing-newline
* choice. Throws on malformed JSON — a broken package.json is init's one
* hard prerequisite failure.
*/
export function addPackageScript(text: string): {
changed: boolean;
Expand All @@ -41,8 +42,12 @@ export function addPackageScript(text: string): {
if (scripts?.paramour !== undefined) return { changed: false, text };
pkg.scripts = { ...scripts, paramour: "paramour generate" };
const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
const trailing = text.endsWith("\n") ? "\n" : "";
return { changed: true, text: JSON.stringify(pkg, null, indent) + trailing };
const eol = lineEnding(text);
const trailing = text.endsWith("\n") ? eol : "";
return {
changed: true,
text: JSON.stringify(pkg, null, indent).replaceAll("\n", eol) + trailing,
};
}

/**
Expand Down
9 changes: 8 additions & 1 deletion packages/next/src/init/wrap-next-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { existsSync } from "node:fs";
import { join } from "node:path";

import { lineEnding } from "../emit.js";

/**
* The `paramour init` next.config codemod (magicast: recast printing +
* babel-ts parsing, so TS/ESM configs transform format-preservingly).
Expand Down Expand Up @@ -179,7 +181,12 @@ export async function wrapNextConfigSource(
local ?? "withTypedRoutes",
current,
);
return { code: generateCode(mod).code, status: "wrapped" };
// recast prints new nodes with LF and drops the final newline; keep the
// user's file as it was on both counts.
const eol = lineEnding(source);
let code = generateCode(mod).code.replaceAll("\r\n", "\n");
if (source.endsWith("\n") && !code.endsWith("\n")) code += "\n";
return { code: code.replaceAll("\n", eol), status: "wrapped" };
} catch {
return { snippet: manualSnippet(), status: "manual" };
}
Expand Down
53 changes: 53 additions & 0 deletions packages/next/test/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,59 @@ describe("paramour init", () => {
expect(snapshotTree(root)).toEqual(before);
});

it("treats a CRLF checkout of its own section as up to date", async () => {
// git core.autocrlf=true checks the committed LF file out as CRLF.
const checkedOut = `${AGENTS_MD}\n${agentsSnippet()}\n`.replaceAll(
"\n",
"\r\n",
);
const root = makeProject(["app/page.tsx"], {
"CLAUDE.md": checkedOut,
"package.json": PACKAGE_JSON,
});
const run = await init(["--no-wrap"]);
expect(run.out.join("\n")).toContain(
"• CLAUDE.md paramour section already up to date — skipped",
);
expect(readFileSync(join(root, "CLAUDE.md"), "utf8")).toBe(checkedOut);
});

it("keeps a CRLF instructions file CRLF when appending and updating", async () => {
const root = makeProject(["app/page.tsx"], {
"AGENTS.md": AGENTS_MD.replaceAll("\n", "\r\n"),
"package.json": PACKAGE_JSON,
});
await init(["--no-wrap"]);
const appended = readFileSync(join(root, "AGENTS.md"), "utf8");
expect(appended).toContain(AGENTS_MARKER_START);
expect(appended).not.toMatch(/(?<!\r)\n/);

writeFileSync(
join(root, "AGENTS.md"),
appended.replace("## paramour", "## paramour (edited)"),
);
const run = await init(["--no-wrap"]);
expect(run.out.join("\n")).toContain(
"✔ updated paramour section in AGENTS.md",
);
const updated = readFileSync(join(root, "AGENTS.md"), "utf8");
expect(updated).toBe(appended);
});

it("keeps a CRLF package.json CRLF when adding the script", async () => {
const root = makeProject(["app/page.tsx"], {
"package.json": PACKAGE_JSON.replaceAll("\n", "\r\n"),
});
const run = await init(["--no-wrap"]);
expect(run.out.join("\n")).toContain(
`✔ added "paramour" script to package.json`,
);
const text = readFileSync(join(root, "package.json"), "utf8");
expect(text).toContain('"paramour": "paramour generate"');
expect(text).not.toMatch(/(?<!\r)\n/);
expect(text.endsWith("}\r\n")).toBe(true);
});

it("starts the section on its own line when the file lacks a trailing newline", async () => {
const root = makeProject(["app/page.tsx"], {
"AGENTS.md": "# Notes without newline",
Expand Down
23 changes: 23 additions & 0 deletions packages/next/test/wrap-next-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,29 @@ describe("wrapNextConfigSource", () => {
);
// The untouched parts keep their original formatting (recast).
expect(result.code).toContain(" reactStrictMode: true,");
expect(
result.code.endsWith("export default withTypedRoutes(nextConfig);\n"),
).toBe(true);
});

it("keeps a CRLF config CRLF, including the added import", async () => {
const result = await wrapNextConfigSource(
IDENTIFIER_TS.replaceAll("\n", "\r\n"),
);
expect(result.status).toBe("wrapped");
if (result.status !== "wrapped") return;
expect(result.code).toContain(
"export default withTypedRoutes(nextConfig);",
);
expect(result.code).not.toMatch(/(?<!\r)\n/);
expect(result.code.endsWith(";\r\n")).toBe(true);
});

it("does not add a final newline the source did not have", async () => {
const result = await wrapNextConfigSource(IDENTIFIER_TS.trimEnd());
expect(result.status).toBe("wrapped");
if (result.status !== "wrapped") return;
expect(result.code.endsWith("withTypedRoutes(nextConfig);")).toBe(true);
});

it("wraps an object-literal default export", async () => {
Expand Down
Loading