@knitli/astro-docs-template 0.2.2 → 0.2.4
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 +7 -3
- package/scaffolding/package.json +7 -6
- package/scaffolding/public/robots.txt +32 -0
- package/scaffolding/tsconfig.json +5 -2
- package/src/cli.ts +6 -1
- 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.4",
|
|
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",
|
|
@@ -69,12 +71,14 @@
|
|
|
69
71
|
"@astrojs/compiler-rs": "^0.1.4",
|
|
70
72
|
"@biomejs/biome": "^2.4.2",
|
|
71
73
|
"@knitli/tsconfig": "*",
|
|
74
|
+
"@types/bun": "^1.3.4",
|
|
72
75
|
"@types/node": "^24.0.2",
|
|
73
76
|
"bun": "^1.3.9",
|
|
74
77
|
"lightningcss": "^1.30.2",
|
|
75
78
|
"sharp": "^0.34.5",
|
|
76
79
|
"svgo": "^4.0.0",
|
|
77
|
-
"typescript": "^5.9.3"
|
|
80
|
+
"typescript": "^5.9.3",
|
|
81
|
+
"vitest": "^4.0.18"
|
|
78
82
|
},
|
|
79
83
|
"devEngines": {
|
|
80
84
|
"packageManager": {
|
package/scaffolding/package.json
CHANGED
|
@@ -14,14 +14,15 @@
|
|
|
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
|
-
"@
|
|
23
|
-
"
|
|
24
|
-
"
|
|
21
|
+
"@astrojs/check": "latest",
|
|
22
|
+
"@knitli/tsconfig": "latest",
|
|
23
|
+
"@types/node": "latest",
|
|
24
|
+
"typescript": "latest",
|
|
25
|
+
"wrangler": "latest"
|
|
25
26
|
},
|
|
26
27
|
"trustedDependencies": [
|
|
27
28
|
"bun"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# As a condition of accessing this website, you agree to abide by the
|
|
2
|
+
# following content signals:
|
|
3
|
+
|
|
4
|
+
# (a) If a content-signal = yes, you may collect content for the
|
|
5
|
+
# corresponding use.
|
|
6
|
+
# (b) If a content-signal = no, you may not collect content for the
|
|
7
|
+
# corresponding use.
|
|
8
|
+
# (c) If the website operator does not include a content signal for a
|
|
9
|
+
# corresponding use, the website operator neither grants nor restricts
|
|
10
|
+
# permission via content signal with respect to the corresponding use.
|
|
11
|
+
|
|
12
|
+
# The content signals and their meanings are:
|
|
13
|
+
|
|
14
|
+
# search: building a search index and providing search results (e.g., returning
|
|
15
|
+
# hyperlinks and short excerpts from your website's contents). Search
|
|
16
|
+
# does not include providing AI-generated search summaries.
|
|
17
|
+
# ai-input: inputting content into one or more AI models (e.g., retrieval
|
|
18
|
+
# augmented generation, grounding, or other real-time taking of
|
|
19
|
+
# content for generative AI search answers).
|
|
20
|
+
# ai-train: training or fine-tuning AI models.
|
|
21
|
+
|
|
22
|
+
# ANY RESTRICTIONS EXPRESSED VIA CONTENT SIGNALS ARE EXPRESS RESERVATIONS OF
|
|
23
|
+
# RIGHTS UNDER ARTICLE 4 OF THE EUROPEAN UNION DIRECTIVE 2019/790 ON COPYRIGHT
|
|
24
|
+
# AND RELATED RIGHTS IN THE DIGITAL SINGLE MARKET.
|
|
25
|
+
|
|
26
|
+
User-Agent: *
|
|
27
|
+
Content-Signal: ai-train=yes, search=yes, ai-input=yes
|
|
28
|
+
Allow: /
|
|
29
|
+
Disallow: /cdn-cgi/
|
|
30
|
+
Disallow: /admin/
|
|
31
|
+
|
|
32
|
+
Sitemap: https://docs.knitli.com/sitemap-index.xml
|
package/src/cli.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
type PieceName,
|
|
16
16
|
} from "./index";
|
|
17
17
|
|
|
18
|
+
const BINARY_NAME = "knitli-docs";
|
|
19
|
+
|
|
18
20
|
// ── Arg parsing ──
|
|
19
21
|
|
|
20
22
|
function parseArgs(argv: string[]) {
|
|
@@ -113,7 +115,10 @@ async function getOptions(flags: Record<string, string>): Promise<InitOptions> {
|
|
|
113
115
|
// ── Commands ──
|
|
114
116
|
|
|
115
117
|
async function main() {
|
|
116
|
-
const
|
|
118
|
+
const rawArgs = process.argv.slice(2);
|
|
119
|
+
// Strip binary name if passed as first arg (e.g. `bunx @knitli/astro-docs-template -- knitli-docs add`)
|
|
120
|
+
const args = rawArgs[0] === BINARY_NAME ? rawArgs.slice(1) : rawArgs;
|
|
121
|
+
const { flags, positional } = parseArgs(args);
|
|
117
122
|
const command = positional[0];
|
|
118
123
|
|
|
119
124
|
if (flags.help || !command) {
|
|
@@ -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
|
}
|