@knitli/astro-docs-template 0.2.1 → 0.2.3
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/package.json +8 -5
- package/scaffolding/package.json +10 -7
- package/src/index.test.ts +318 -0
- package/src/index.ts +121 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knitli/astro-docs-template",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Opinionated Astro + Starlight docs site template with Knitli branding",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"knitli",
|
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
"postbuild": "tsc --noCheck --emitDeclarationOnly --declaration --declarationDir dist",
|
|
38
38
|
"clean": "rm -rf dist",
|
|
39
39
|
"deploy": "bun publish --tag latest",
|
|
40
|
-
"prepack": "bun run build"
|
|
40
|
+
"prepack": "bun run build",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"test:watch": "vitest"
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
43
45
|
"@astrojs/cloudflare": "^13.1.1",
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
"@astrojs/mdx": "^5.0.0",
|
|
46
48
|
"@astrojs/sitemap": "^3.6.0",
|
|
47
49
|
"@astrojs/starlight": "^0.38.1",
|
|
48
|
-
"@knitli/docs-components": "
|
|
50
|
+
"@knitli/docs-components": "*",
|
|
49
51
|
"@nuasite/llm-enhancements": "^0.18.0",
|
|
50
52
|
"astro": "^6.0.4",
|
|
51
53
|
"astro-cloudflare-pages-headers": "^1.7.7",
|
|
@@ -68,13 +70,14 @@
|
|
|
68
70
|
"@astrojs/check": "^0.9.6",
|
|
69
71
|
"@astrojs/compiler-rs": "^0.1.4",
|
|
70
72
|
"@biomejs/biome": "^2.4.2",
|
|
71
|
-
"@knitli/tsconfig": "
|
|
73
|
+
"@knitli/tsconfig": "*",
|
|
72
74
|
"@types/node": "^24.0.2",
|
|
73
75
|
"bun": "^1.3.9",
|
|
74
76
|
"lightningcss": "^1.30.2",
|
|
75
77
|
"sharp": "^0.34.5",
|
|
76
78
|
"svgo": "^4.0.0",
|
|
77
|
-
"typescript": "^5.9.3"
|
|
79
|
+
"typescript": "^5.9.3",
|
|
80
|
+
"vitest": "^4.0.18"
|
|
78
81
|
},
|
|
79
82
|
"devEngines": {
|
|
80
83
|
"packageManager": {
|
package/scaffolding/package.json
CHANGED
|
@@ -14,13 +14,16 @@
|
|
|
14
14
|
"wrangler": "bunx wrangler"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@knitli/astro-docs-template": "
|
|
18
|
-
"@knitli/docs-components": "
|
|
17
|
+
"@knitli/astro-docs-template": "latest",
|
|
18
|
+
"@knitli/docs-components": "latest"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@astrojs/check": "
|
|
22
|
-
"@types/node": "
|
|
23
|
-
"typescript": "
|
|
24
|
-
"wrangler": "
|
|
25
|
-
}
|
|
21
|
+
"@astrojs/check": "latest",
|
|
22
|
+
"@types/node": "latest",
|
|
23
|
+
"typescript": "latest",
|
|
24
|
+
"wrangler": "latest"
|
|
25
|
+
},
|
|
26
|
+
"trustedDependencies": [
|
|
27
|
+
"bun"
|
|
28
|
+
]
|
|
26
29
|
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Knitli Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
addPieces,
|
|
11
|
+
initDocsTemplate,
|
|
12
|
+
mergePackageJsonDeps,
|
|
13
|
+
normaliseDependencyVersion,
|
|
14
|
+
PIECE_NAMES,
|
|
15
|
+
PIECES,
|
|
16
|
+
type InitOptions,
|
|
17
|
+
} from "./index";
|
|
18
|
+
|
|
19
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function makeTmpDir(): string {
|
|
22
|
+
return mkdtempSync(join(tmpdir(), "knitli-docs-test-"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const BASE_OPTIONS: InitOptions = {
|
|
26
|
+
appName: "TestApp",
|
|
27
|
+
name: "@test/test-docs",
|
|
28
|
+
description: "Test documentation site",
|
|
29
|
+
workerName: "test-app-docs",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ── normaliseDependencyVersion ────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe("normaliseDependencyVersion", () => {
|
|
35
|
+
it("converts workspace:* to *", () => {
|
|
36
|
+
expect(normaliseDependencyVersion("workspace:*")).toBe("*");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("strips workspace: prefix from semver ranges, preserving the range", () => {
|
|
40
|
+
expect(normaliseDependencyVersion("workspace:^1.2.3")).toBe("^1.2.3");
|
|
41
|
+
expect(normaliseDependencyVersion("workspace:~1.0.0")).toBe("~1.0.0");
|
|
42
|
+
expect(normaliseDependencyVersion("workspace:>=2.0.0")).toBe(">=2.0.0");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("converts catalog:dev-common to latest", () => {
|
|
46
|
+
expect(normaliseDependencyVersion("catalog:dev-common")).toBe("latest");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("converts catalog:types to latest", () => {
|
|
50
|
+
expect(normaliseDependencyVersion("catalog:types")).toBe("latest");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("leaves regular semver ranges unchanged", () => {
|
|
54
|
+
expect(normaliseDependencyVersion("^1.2.3")).toBe("^1.2.3");
|
|
55
|
+
expect(normaliseDependencyVersion("~2.0.0")).toBe("~2.0.0");
|
|
56
|
+
expect(normaliseDependencyVersion("latest")).toBe("latest");
|
|
57
|
+
expect(normaliseDependencyVersion("*")).toBe("*");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── mergePackageJsonDeps ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe("mergePackageJsonDeps", () => {
|
|
64
|
+
it("adds template dependencies to an existing package.json", () => {
|
|
65
|
+
const existing = { name: "my-project", dependencies: { react: "^19.0.0" } };
|
|
66
|
+
const template = { dependencies: { astro: "^5.0.0" } };
|
|
67
|
+
const result = mergePackageJsonDeps(existing, template);
|
|
68
|
+
expect(result.dependencies).toEqual({ react: "^19.0.0", astro: "^5.0.0" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("template deps take precedence over existing ones for the same package", () => {
|
|
72
|
+
const existing = { name: "x", dependencies: { astro: "^4.0.0" } };
|
|
73
|
+
const template = { dependencies: { astro: "^5.0.0" } };
|
|
74
|
+
const result = mergePackageJsonDeps(existing, template);
|
|
75
|
+
expect(result.dependencies?.astro).toBe("^5.0.0");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("normalises workspace:* in template deps", () => {
|
|
79
|
+
const existing = { name: "x" };
|
|
80
|
+
const template = { dependencies: { "@knitli/astro-docs-template": "workspace:*" } };
|
|
81
|
+
const result = mergePackageJsonDeps(existing, template);
|
|
82
|
+
expect(result.dependencies?.["@knitli/astro-docs-template"]).toBe("*");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("normalises catalog:* in template devDependencies", () => {
|
|
86
|
+
const existing = { name: "x" };
|
|
87
|
+
const template = { devDependencies: { typescript: "catalog:dev-common" } };
|
|
88
|
+
const result = mergePackageJsonDeps(existing, template);
|
|
89
|
+
expect(result.devDependencies?.typescript).toBe("latest");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("merges devDependencies", () => {
|
|
93
|
+
const existing = { name: "x", devDependencies: { vitest: "^4.0.0" } };
|
|
94
|
+
const template = { devDependencies: { typescript: "catalog:dev-common", "@types/node": "catalog:types" } };
|
|
95
|
+
const result = mergePackageJsonDeps(existing, template);
|
|
96
|
+
expect(result.devDependencies).toEqual({
|
|
97
|
+
vitest: "^4.0.0",
|
|
98
|
+
typescript: "latest",
|
|
99
|
+
"@types/node": "latest",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("merges peerDependencies", () => {
|
|
104
|
+
const existing = { name: "x" };
|
|
105
|
+
const template = { peerDependencies: { astro: ">=5.0.0" } };
|
|
106
|
+
const result = mergePackageJsonDeps(existing, template);
|
|
107
|
+
expect(result.peerDependencies).toEqual({ astro: ">=5.0.0" });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not touch name, version, scripts, or other fields", () => {
|
|
111
|
+
const existing = { name: "my-app", version: "1.2.3", scripts: { build: "tsc" }, private: true };
|
|
112
|
+
const template = { name: "template", version: "0.0.1", scripts: { dev: "astro dev" }, dependencies: { astro: "*" } };
|
|
113
|
+
const result = mergePackageJsonDeps(existing, template);
|
|
114
|
+
expect(result.name).toBe("my-app");
|
|
115
|
+
expect(result.version).toBe("1.2.3");
|
|
116
|
+
expect(result.scripts).toEqual({ build: "tsc" });
|
|
117
|
+
expect(result.private).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("handles missing dependency fields gracefully", () => {
|
|
121
|
+
const existing = { name: "x" };
|
|
122
|
+
const template = { name: "t" };
|
|
123
|
+
const result = mergePackageJsonDeps(existing, template);
|
|
124
|
+
expect(result.dependencies).toBeUndefined();
|
|
125
|
+
expect(result.devDependencies).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── initDocsTemplate ──────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
describe("initDocsTemplate", () => {
|
|
132
|
+
let tmpDir: string;
|
|
133
|
+
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
tmpDir = makeTmpDir();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("creates files in the target directory", () => {
|
|
143
|
+
const created = initDocsTemplate(tmpDir, BASE_OPTIONS);
|
|
144
|
+
expect(created.length).toBeGreaterThan(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("applies placeholder substitutions", () => {
|
|
148
|
+
initDocsTemplate(tmpDir, BASE_OPTIONS);
|
|
149
|
+
const pkg = JSON.parse(readFileSync(join(tmpDir, "package.json"), "utf-8"));
|
|
150
|
+
expect(pkg.name).toBe(BASE_OPTIONS.name);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("substitutes {{appName}} in text files", () => {
|
|
154
|
+
initDocsTemplate(tmpDir, BASE_OPTIONS);
|
|
155
|
+
const wrangler = readFileSync(join(tmpDir, "wrangler.jsonc"), "utf-8");
|
|
156
|
+
expect(wrangler).toContain(BASE_OPTIONS.workerName);
|
|
157
|
+
expect(wrangler).not.toContain("{{workerName}}");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("always clobbers existing files (init always overwrites, no flag needed)", () => {
|
|
161
|
+
// Write an existing file that will be overwritten
|
|
162
|
+
writeFileSync(join(tmpDir, "tsconfig.json"), '{"compilerOptions":{}}', "utf-8");
|
|
163
|
+
const created = initDocsTemplate(tmpDir, BASE_OPTIONS);
|
|
164
|
+
// tsconfig.json should have been re-created
|
|
165
|
+
expect(created.some((f) => f.includes("tsconfig.json"))).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("creates all expected piece files", () => {
|
|
169
|
+
initDocsTemplate(tmpDir, BASE_OPTIONS);
|
|
170
|
+
expect(existsSync(join(tmpDir, "astro.config.ts"))).toBe(true);
|
|
171
|
+
expect(existsSync(join(tmpDir, "wrangler.jsonc"))).toBe(true);
|
|
172
|
+
expect(existsSync(join(tmpDir, "tsconfig.json"))).toBe(true);
|
|
173
|
+
expect(existsSync(join(tmpDir, "package.json"))).toBe(true);
|
|
174
|
+
expect(existsSync(join(tmpDir, "mise.toml"))).toBe(true);
|
|
175
|
+
expect(existsSync(join(tmpDir, "src/content.config.ts"))).toBe(true);
|
|
176
|
+
expect(existsSync(join(tmpDir, "src/env.d.ts"))).toBe(true);
|
|
177
|
+
expect(existsSync(join(tmpDir, "src/styles/custom.css"))).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── addPieces ─────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
describe("addPieces", () => {
|
|
184
|
+
let tmpDir: string;
|
|
185
|
+
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
tmpDir = makeTmpDir();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
afterEach(() => {
|
|
191
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("creates the requested file when it does not exist", () => {
|
|
195
|
+
const created = addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["tsconfig"] });
|
|
196
|
+
expect(created.length).toBe(1);
|
|
197
|
+
expect(existsSync(join(tmpDir, "tsconfig.json"))).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("skips an existing non-package.json file without --force", () => {
|
|
201
|
+
// Pre-populate the tsconfig so it already exists
|
|
202
|
+
writeFileSync(join(tmpDir, "tsconfig.json"), '{"existing":true}', "utf-8");
|
|
203
|
+
const created = addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["tsconfig"] });
|
|
204
|
+
expect(created.length).toBe(0);
|
|
205
|
+
// Original content should be intact
|
|
206
|
+
const content = JSON.parse(readFileSync(join(tmpDir, "tsconfig.json"), "utf-8"));
|
|
207
|
+
expect(content.existing).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("overwrites an existing non-package.json file with --force", () => {
|
|
211
|
+
writeFileSync(join(tmpDir, "tsconfig.json"), '{"existing":true}', "utf-8");
|
|
212
|
+
const created = addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["tsconfig"], force: true });
|
|
213
|
+
expect(created.length).toBe(1);
|
|
214
|
+
const content = JSON.parse(readFileSync(join(tmpDir, "tsconfig.json"), "utf-8"));
|
|
215
|
+
expect(content.existing).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("applies placeholder substitutions to added files", () => {
|
|
219
|
+
addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["wrangler"] });
|
|
220
|
+
const content = readFileSync(join(tmpDir, "wrangler.jsonc"), "utf-8");
|
|
221
|
+
expect(content).not.toContain("{{workerName}}");
|
|
222
|
+
expect(content).toContain(BASE_OPTIONS.workerName);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── deps piece (the main bug fix) ─────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
describe("deps piece", () => {
|
|
228
|
+
it("creates package.json when it does not exist", () => {
|
|
229
|
+
const created = addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["deps"] });
|
|
230
|
+
expect(created.length).toBe(1);
|
|
231
|
+
expect(existsSync(join(tmpDir, "package.json"))).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("merges deps into an existing package.json instead of skipping", () => {
|
|
235
|
+
const existingPkg = {
|
|
236
|
+
name: "my-other-project",
|
|
237
|
+
version: "3.0.0",
|
|
238
|
+
private: true,
|
|
239
|
+
dependencies: { express: "^4.0.0" },
|
|
240
|
+
};
|
|
241
|
+
writeFileSync(join(tmpDir, "package.json"), JSON.stringify(existingPkg, null, 2), "utf-8");
|
|
242
|
+
|
|
243
|
+
const created = addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["deps"] });
|
|
244
|
+
// Should be modified (not 0 files)
|
|
245
|
+
expect(created.length).toBe(1);
|
|
246
|
+
|
|
247
|
+
const merged = JSON.parse(readFileSync(join(tmpDir, "package.json"), "utf-8"));
|
|
248
|
+
// Existing non-dep fields are preserved
|
|
249
|
+
expect(merged.name).toBe("my-other-project");
|
|
250
|
+
expect(merged.version).toBe("3.0.0");
|
|
251
|
+
expect(merged.private).toBe(true);
|
|
252
|
+
// Existing dep is preserved
|
|
253
|
+
expect(merged.dependencies.express).toBe("^4.0.0");
|
|
254
|
+
// Template deps are added
|
|
255
|
+
expect(merged.dependencies["@knitli/astro-docs-template"]).toBeDefined();
|
|
256
|
+
expect(merged.devDependencies?.["@astrojs/check"]).toBeDefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("normalises workspace:* references when merging", () => {
|
|
260
|
+
writeFileSync(join(tmpDir, "package.json"), '{"name":"x","dependencies":{}}', "utf-8");
|
|
261
|
+
addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["deps"] });
|
|
262
|
+
const merged = JSON.parse(readFileSync(join(tmpDir, "package.json"), "utf-8"));
|
|
263
|
+
// workspace:* should be normalised away
|
|
264
|
+
const allVersions = Object.values({
|
|
265
|
+
...merged.dependencies,
|
|
266
|
+
...merged.devDependencies,
|
|
267
|
+
}) as string[];
|
|
268
|
+
expect(allVersions.some((v) => v.startsWith("workspace:"))).toBe(false);
|
|
269
|
+
expect(allVersions.some((v) => v.startsWith("catalog:"))).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("throws a helpful error when existing package.json is not valid JSON", () => {
|
|
273
|
+
writeFileSync(join(tmpDir, "package.json"), "{ this is not valid json", "utf-8");
|
|
274
|
+
expect(() =>
|
|
275
|
+
addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["deps"] }),
|
|
276
|
+
).toThrow(/Failed to parse existing package\.json/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("replaces existing package.json wholesale with --force", () => {
|
|
280
|
+
const existingPkg = { name: "my-other-project", version: "3.0.0", scripts: { start: "node server.js" } };
|
|
281
|
+
writeFileSync(join(tmpDir, "package.json"), JSON.stringify(existingPkg, null, 2), "utf-8");
|
|
282
|
+
|
|
283
|
+
addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["deps"], force: true });
|
|
284
|
+
const result = JSON.parse(readFileSync(join(tmpDir, "package.json"), "utf-8"));
|
|
285
|
+
// Should be the template's package.json content (with substitution applied)
|
|
286
|
+
expect(result.name).toBe(BASE_OPTIONS.name);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ── content piece ─────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
it("copies directory pieces (starter-content)", () => {
|
|
293
|
+
const created = addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["starter-content"] });
|
|
294
|
+
expect(created.length).toBeGreaterThan(0);
|
|
295
|
+
expect(existsSync(join(tmpDir, "src/content/docs"))).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ── multiple pieces ───────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
it("can add multiple pieces at once", () => {
|
|
301
|
+
const created = addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["tsconfig", "styles"] });
|
|
302
|
+
expect(existsSync(join(tmpDir, "tsconfig.json"))).toBe(true);
|
|
303
|
+
expect(existsSync(join(tmpDir, "src/styles/custom.css"))).toBe(true);
|
|
304
|
+
expect(created.length).toBe(2);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ── validation ────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
it("throws for an unknown piece name", () => {
|
|
310
|
+
expect(() =>
|
|
311
|
+
addPieces(tmpDir, { ...BASE_OPTIONS, pieces: ["nonexistent" as never] }),
|
|
312
|
+
).toThrow(/Unknown piece/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("PIECE_NAMES lists all defined pieces", () => {
|
|
316
|
+
expect(PIECE_NAMES).toEqual(Object.keys(PIECES));
|
|
317
|
+
});
|
|
318
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -165,6 +165,116 @@ function copyDirRecursive(
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Normalise a dependency version specifier so that workspace-protocol and
|
|
170
|
+
* catalog entries from the Knitli monorepo become portable version strings
|
|
171
|
+
* usable in any project.
|
|
172
|
+
*
|
|
173
|
+
* "workspace:*" → "*"
|
|
174
|
+
* "workspace:^1.2.3" → "^1.2.3"
|
|
175
|
+
* "catalog:something" → "latest"
|
|
176
|
+
* anything else → unchanged
|
|
177
|
+
*/
|
|
178
|
+
export function normaliseDependencyVersion(version: string): string {
|
|
179
|
+
if (version.startsWith("workspace:")) {
|
|
180
|
+
const spec = version.slice("workspace:".length).trim();
|
|
181
|
+
if (spec === "" || spec === "*") return "*";
|
|
182
|
+
return spec;
|
|
183
|
+
}
|
|
184
|
+
if (version.startsWith("catalog:")) return "latest";
|
|
185
|
+
return version;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
type PackageJsonDeps = Record<string, string>;
|
|
189
|
+
type PackageJsonLike = {
|
|
190
|
+
dependencies?: PackageJsonDeps;
|
|
191
|
+
devDependencies?: PackageJsonDeps;
|
|
192
|
+
peerDependencies?: PackageJsonDeps;
|
|
193
|
+
[key: string]: unknown;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Merge the `dependencies`, `devDependencies`, and `peerDependencies` from
|
|
198
|
+
* `templatePkg` into `existingPkg`, normalising any monorepo-specific version
|
|
199
|
+
* specifiers so the result is portable. Template entries take precedence over
|
|
200
|
+
* existing entries for the same package name.
|
|
201
|
+
*
|
|
202
|
+
* All other fields (name, version, scripts, …) in `existingPkg` are left
|
|
203
|
+
* untouched.
|
|
204
|
+
*/
|
|
205
|
+
export function mergePackageJsonDeps(
|
|
206
|
+
existingPkg: PackageJsonLike,
|
|
207
|
+
templatePkg: PackageJsonLike,
|
|
208
|
+
): PackageJsonLike {
|
|
209
|
+
const result: PackageJsonLike = { ...existingPkg };
|
|
210
|
+
const depFields = [
|
|
211
|
+
"dependencies",
|
|
212
|
+
"devDependencies",
|
|
213
|
+
"peerDependencies",
|
|
214
|
+
] as const;
|
|
215
|
+
for (const field of depFields) {
|
|
216
|
+
const templateDeps = templatePkg[field];
|
|
217
|
+
if (!templateDeps || Object.keys(templateDeps).length === 0) continue;
|
|
218
|
+
const normalised: PackageJsonDeps = {};
|
|
219
|
+
for (const [pkg, version] of Object.entries(templateDeps)) {
|
|
220
|
+
normalised[pkg] = normaliseDependencyVersion(version);
|
|
221
|
+
}
|
|
222
|
+
result[field] = { ...(existingPkg[field] ?? {}), ...normalised };
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Merge template `package.json` dependencies into an existing one.
|
|
229
|
+
*
|
|
230
|
+
* - If the destination does not exist yet, the file is simply copied
|
|
231
|
+
* (with placeholder substitution).
|
|
232
|
+
* - If `force` is set, the file is overwritten wholesale.
|
|
233
|
+
* - Otherwise the `dependencies`, `devDependencies`, and `peerDependencies`
|
|
234
|
+
* from the template are merged into the existing file, normalising any
|
|
235
|
+
* workspace-protocol / catalog version specifiers to portable equivalents.
|
|
236
|
+
*
|
|
237
|
+
* **Note**: the merged file is re-serialised with 2-space indentation using
|
|
238
|
+
* `JSON.stringify`. Any bespoke formatting or key ordering in the original
|
|
239
|
+
* file will not be preserved.
|
|
240
|
+
*/
|
|
241
|
+
function mergeOrCopyPackageJson(
|
|
242
|
+
srcPath: string,
|
|
243
|
+
destPath: string,
|
|
244
|
+
replacements: Record<string, string>,
|
|
245
|
+
created: string[],
|
|
246
|
+
force?: boolean,
|
|
247
|
+
): void {
|
|
248
|
+
if (!existsSync(destPath) || force) {
|
|
249
|
+
copyFile(srcPath, destPath, replacements, created, force);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const templateRaw = applyReplacements(
|
|
254
|
+
readFileSync(srcPath, "utf-8"),
|
|
255
|
+
replacements,
|
|
256
|
+
);
|
|
257
|
+
const templatePkg = JSON.parse(templateRaw) as PackageJsonLike;
|
|
258
|
+
|
|
259
|
+
let existingPkg: PackageJsonLike;
|
|
260
|
+
try {
|
|
261
|
+
existingPkg = JSON.parse(readFileSync(destPath, "utf-8")) as PackageJsonLike;
|
|
262
|
+
} catch (err) {
|
|
263
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Failed to parse existing package.json at ${destPath}: ${msg}\n` +
|
|
266
|
+
"Fix or remove the file and try again.",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const merged = mergePackageJsonDeps(existingPkg, templatePkg);
|
|
271
|
+
|
|
272
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
273
|
+
writeFileSync(destPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
|
|
274
|
+
created.push(destPath);
|
|
275
|
+
console.log(` merged deps into ${destPath}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
168
278
|
// ── Public API ──
|
|
169
279
|
|
|
170
280
|
/**
|
|
@@ -172,6 +282,9 @@ function copyDirRecursive(
|
|
|
172
282
|
*
|
|
173
283
|
* Copies all files from the scaffolding directory to the target path,
|
|
174
284
|
* applying placeholder substitution to text files.
|
|
285
|
+
*
|
|
286
|
+
* **Note**: all files are written unconditionally — existing files are always
|
|
287
|
+
* overwritten. For selective, non-destructive installation use `addPieces`.
|
|
175
288
|
*/
|
|
176
289
|
export function initDocsTemplate(
|
|
177
290
|
targetPath: string,
|
|
@@ -232,6 +345,14 @@ export function addPieces(
|
|
|
232
345
|
created,
|
|
233
346
|
options.force,
|
|
234
347
|
);
|
|
348
|
+
} else if (relPath === "package.json") {
|
|
349
|
+
mergeOrCopyPackageJson(
|
|
350
|
+
srcPath,
|
|
351
|
+
destPath,
|
|
352
|
+
replacements,
|
|
353
|
+
created,
|
|
354
|
+
options.force,
|
|
355
|
+
);
|
|
235
356
|
} else {
|
|
236
357
|
copyFile(srcPath, destPath, replacements, created, options.force);
|
|
237
358
|
}
|