@knitli/astro-docs-template 0.1.0 → 0.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knitli/astro-docs-template",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Opinionated Astro + Starlight docs site template with Knitli branding",
5
5
  "keywords": [
6
6
  "knitli",
@@ -22,6 +22,9 @@
22
22
  ".": "./src/index.ts",
23
23
  "./config": "./src/config.ts"
24
24
  },
25
+ "bin": {
26
+ "knitli-docs": "src/cli.ts"
27
+ },
25
28
  "files": [
26
29
  "src",
27
30
  "scaffolding",
@@ -4,11 +4,13 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
+ "astro": "bunx astro",
8
+ "prebuild": "test -f ./node_modules/bun/install.js && node ./node_modules/bun/install.js; bunx wrangler types; bunx astro sync",
7
9
  "build": "bunx astro build",
8
- "deploy": "bunx astro build && bunx wrangler deploy",
10
+ "deploy": "bun run build && bunx wrangler deploy",
9
11
  "dev": "bunx astro dev",
10
- "generate-types": "bunx wrangler types",
11
- "preview": "bunx astro build && bunx astro preview"
12
+ "preview": "bunx astro preview",
13
+ "wrangler": "bunx wrangler"
12
14
  },
13
15
  "dependencies": {
14
16
  "@knitli/astro-docs-template": "workspace:*",
package/src/cli.ts ADDED
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env bun
2
+ // SPDX-FileCopyrightText: 2026 Knitli Inc.
3
+ //
4
+ // SPDX-License-Identifier: Apache-2.0
5
+
6
+ import { createInterface } from "node:readline/promises";
7
+ import { stdin, stdout } from "node:process";
8
+ import {
9
+ addPieces,
10
+ initDocsTemplate,
11
+ PIECES,
12
+ PIECE_NAMES,
13
+ type InitOptions,
14
+ type PieceName,
15
+ } from "./index.js";
16
+
17
+ // ── Arg parsing ──
18
+
19
+ function parseArgs(argv: string[]) {
20
+ const flags: Record<string, string> = {};
21
+ const positional: string[] = [];
22
+
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const arg = argv[i]!;
25
+ if (arg === "--help" || arg === "-h") {
26
+ flags.help = "true";
27
+ } else if (arg === "--force" || arg === "-f") {
28
+ flags.force = "true";
29
+ } else if (arg.startsWith("--") && i + 1 < argv.length) {
30
+ flags[arg.slice(2)] = argv[++i]!;
31
+ } else {
32
+ positional.push(arg);
33
+ }
34
+ }
35
+
36
+ return { flags, positional };
37
+ }
38
+
39
+ // ── Help text ──
40
+
41
+ const HELP = `knitli-docs — scaffold and manage Knitli docs sites
42
+
43
+ Usage:
44
+ knitli-docs init [dir] Full scaffold into dir (default: .)
45
+ knitli-docs add <piece...> [--dir d] Add specific pieces to a project
46
+ knitli-docs list Show available pieces
47
+
48
+ Pieces:
49
+ ${Object.entries(PIECES)
50
+ .map(([name, { description }]) => ` ${name.padEnd(18)} ${description}`)
51
+ .join("\n")}
52
+
53
+ Options:
54
+ --app-name <name> Product name (e.g. "Recoco")
55
+ --name <pkg> npm package name (e.g. "@knitli-site/recoco-docs")
56
+ --description <desc> Short product description
57
+ --worker-name <name> Cloudflare Worker name (default: <appname>-docs)
58
+ --dir <path> Target directory for 'add' (default: .)
59
+ --force, -f Overwrite existing files
60
+ --help, -h Show this help
61
+ `;
62
+
63
+ // ── Interactive prompts ──
64
+
65
+ async function getOptions(
66
+ flags: Record<string, string>,
67
+ ): Promise<InitOptions> {
68
+ const rl = createInterface({ input: stdin, output: stdout });
69
+
70
+ const ask = async (
71
+ question: string,
72
+ defaultValue?: string,
73
+ ): Promise<string> => {
74
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
75
+ const answer = await rl.question(`${question}${suffix}: `);
76
+ return answer.trim() || defaultValue || "";
77
+ };
78
+
79
+ try {
80
+ const appName =
81
+ flags["app-name"] || (await ask("Product name (e.g. Recoco)"));
82
+ if (!appName) {
83
+ console.error("Error: product name is required");
84
+ process.exit(1);
85
+ }
86
+
87
+ const name =
88
+ flags.name ||
89
+ (await ask(
90
+ "npm package name",
91
+ `@knitli-site/${appName.toLowerCase()}-docs`,
92
+ ));
93
+ const description =
94
+ flags.description || (await ask("Short description"));
95
+ const workerName =
96
+ flags["worker-name"] ||
97
+ (await ask("Worker name", `${appName.toLowerCase()}-docs`));
98
+
99
+ return { appName, name, description, workerName };
100
+ } finally {
101
+ rl.close();
102
+ }
103
+ }
104
+
105
+ // ── Commands ──
106
+
107
+ async function main() {
108
+ const { flags, positional } = parseArgs(process.argv.slice(2));
109
+ const command = positional[0];
110
+
111
+ if (flags.help || !command) {
112
+ console.log(HELP);
113
+ process.exit(0);
114
+ }
115
+
116
+ switch (command) {
117
+ case "list": {
118
+ console.log("\nAvailable pieces:\n");
119
+ for (const [name, { description, paths }] of Object.entries(PIECES)) {
120
+ console.log(` ${name.padEnd(18)} ${description}`);
121
+ for (const p of paths) {
122
+ console.log(` ${"".padEnd(18)} └ ${p}`);
123
+ }
124
+ }
125
+ console.log();
126
+ break;
127
+ }
128
+
129
+ case "init": {
130
+ const dir = flags.dir || positional[1] || ".";
131
+ const options = await getOptions(flags);
132
+ console.log(`\nScaffolding into ${dir} ...\n`);
133
+ const created = initDocsTemplate(dir, options);
134
+ console.log(`\nDone — ${created.length} files created`);
135
+ break;
136
+ }
137
+
138
+ case "add": {
139
+ const pieces = positional.slice(1).filter((p) => !p.startsWith("-"));
140
+ if (pieces.length === 0) {
141
+ console.error(
142
+ "Error: specify at least one piece. Run 'knitli-docs list' to see options.",
143
+ );
144
+ process.exit(1);
145
+ }
146
+
147
+ const invalid = pieces.filter(
148
+ (p) => !PIECE_NAMES.includes(p as PieceName),
149
+ );
150
+ if (invalid.length > 0) {
151
+ console.error(`Error: unknown piece(s): ${invalid.join(", ")}`);
152
+ console.error(`Available: ${PIECE_NAMES.join(", ")}`);
153
+ process.exit(1);
154
+ }
155
+
156
+ const dir = flags.dir || ".";
157
+ const options = await getOptions(flags);
158
+ console.log(`\nAdding ${pieces.join(", ")} to ${dir} ...\n`);
159
+ const created = addPieces(dir, {
160
+ ...options,
161
+ pieces: pieces as PieceName[],
162
+ force: flags.force === "true",
163
+ });
164
+ console.log(`\nDone — ${created.length} files created`);
165
+ break;
166
+ }
167
+
168
+ default: {
169
+ console.error(`Unknown command: ${command}\n`);
170
+ console.log(HELP);
171
+ process.exit(1);
172
+ }
173
+ }
174
+ }
175
+
176
+ main().catch((err) => {
177
+ console.error(err.message);
178
+ process.exit(1);
179
+ });
package/src/config.ts CHANGED
@@ -372,7 +372,7 @@ export default function createConfig(options: DocsTemplateOptions) {
372
372
  ),
373
373
  rehypePlugins: [
374
374
  rehypeExternalLinks({
375
- content: { type: "text", value: " 🔗" },
375
+ content: { type: "text", value: " " },
376
376
  rel: ["nofollow"],
377
377
  }),
378
378
  ],
package/src/index.ts CHANGED
@@ -19,6 +19,8 @@ export { type DocsTemplateOptions, default as createConfig } from "./config.js";
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
20
  const SCAFFOLDING_DIR = resolve(__dirname, "../scaffolding");
21
21
 
22
+ // ── Options ──
23
+
22
24
  export interface InitOptions {
23
25
  /** Display name for the product (e.g. "Recoco", "CodeWeaver") */
24
26
  appName: string;
@@ -32,6 +34,54 @@ export interface InitOptions {
32
34
  is_codeweaver?: boolean;
33
35
  }
34
36
 
37
+ export interface AddPiecesOptions extends InitOptions {
38
+ pieces: PieceName[];
39
+ /** Overwrite existing files (default: false — skips with a warning) */
40
+ force?: boolean;
41
+ }
42
+
43
+ // ── Piece definitions ──
44
+
45
+ export const PIECES = {
46
+ config: {
47
+ description: "Astro config using createConfig()",
48
+ paths: ["astro.config.ts"],
49
+ },
50
+ wrangler: {
51
+ description: "Cloudflare Worker deployment config",
52
+ paths: ["wrangler.jsonc"],
53
+ },
54
+ tsconfig: {
55
+ description: "TypeScript config extending shared base",
56
+ paths: ["tsconfig.json"],
57
+ },
58
+ content: {
59
+ description: "Content collections, env types, and tags",
60
+ paths: ["src/content.config.ts", "src/env.d.ts", "tags.yml"],
61
+ },
62
+ mise: {
63
+ description: "mise dev tasks (build, deploy, clean, etc.)",
64
+ paths: ["mise.toml"],
65
+ },
66
+ styles: {
67
+ description: "Custom CSS hook for site-specific styles",
68
+ paths: ["src/styles/custom.css"],
69
+ },
70
+ deps: {
71
+ description: "package.json with template dependencies",
72
+ paths: ["package.json"],
73
+ },
74
+ "starter-content": {
75
+ description: "Example documentation pages",
76
+ paths: ["src/content/docs"],
77
+ },
78
+ } as const;
79
+
80
+ export type PieceName = keyof typeof PIECES;
81
+ export const PIECE_NAMES = Object.keys(PIECES) as PieceName[];
82
+
83
+ // ── Shared helpers ──
84
+
35
85
  /** File extensions that should have placeholder substitution applied */
36
86
  const TEXT_EXTENSIONS = new Set([
37
87
  ".ts",
@@ -46,7 +96,7 @@ const TEXT_EXTENSIONS = new Set([
46
96
  ".css",
47
97
  ".html",
48
98
  ".astro",
49
- ".d.ts",
99
+ ".toml",
50
100
  ]);
51
101
 
52
102
  function isTextFile(filePath: string): boolean {
@@ -75,15 +125,53 @@ function applyReplacements(
75
125
  return result;
76
126
  }
77
127
 
128
+ function copyFile(
129
+ srcPath: string,
130
+ destPath: string,
131
+ replacements: Record<string, string>,
132
+ created: string[],
133
+ force?: boolean,
134
+ ): void {
135
+ if (!force && existsSync(destPath)) {
136
+ console.log(` skip ${destPath} (exists, use --force to overwrite)`);
137
+ return;
138
+ }
139
+ mkdirSync(dirname(destPath), { recursive: true });
140
+ if (isTextFile(srcPath)) {
141
+ const content = readFileSync(srcPath, "utf-8");
142
+ writeFileSync(destPath, applyReplacements(content, replacements), "utf-8");
143
+ } else {
144
+ cpSync(srcPath, destPath);
145
+ }
146
+ created.push(destPath);
147
+ }
148
+
149
+ function copyDirRecursive(
150
+ srcDir: string,
151
+ destDir: string,
152
+ replacements: Record<string, string>,
153
+ created: string[],
154
+ force?: boolean,
155
+ ): void {
156
+ mkdirSync(destDir, { recursive: true });
157
+ for (const entry of readdirSync(srcDir)) {
158
+ const srcPath = join(srcDir, entry);
159
+ const destPath = join(destDir, entry);
160
+ if (statSync(srcPath).isDirectory()) {
161
+ copyDirRecursive(srcPath, destPath, replacements, created, force);
162
+ } else {
163
+ copyFile(srcPath, destPath, replacements, created, force);
164
+ }
165
+ }
166
+ }
167
+
168
+ // ── Public API ──
169
+
78
170
  /**
79
171
  * Initialize a new Knitli docs site from the template scaffolding.
80
172
  *
81
173
  * Copies all files from the scaffolding directory to the target path,
82
174
  * applying placeholder substitution to text files.
83
- *
84
- * @param targetPath - Directory to scaffold into (will be created if it doesn't exist)
85
- * @param options - Configuration for placeholder substitution
86
- * @returns List of files created
87
175
  */
88
176
  export function initDocsTemplate(
89
177
  targetPath: string,
@@ -97,32 +185,52 @@ export function initDocsTemplate(
97
185
  throw new Error(`Scaffolding directory not found at ${SCAFFOLDING_DIR}`);
98
186
  }
99
187
 
100
- function copyDir(srcDir: string, destDir: string) {
101
- if (!existsSync(destDir)) {
102
- mkdirSync(destDir, { recursive: true });
188
+ copyDirRecursive(SCAFFOLDING_DIR, target, replacements, created, true);
189
+ return created;
190
+ }
191
+
192
+ /**
193
+ * Add specific pieces from the template scaffolding to an existing project.
194
+ *
195
+ * Unlike initDocsTemplate, this only copies the files belonging to the
196
+ * requested pieces and skips existing files unless force is set.
197
+ */
198
+ export function addPieces(
199
+ targetPath: string,
200
+ options: AddPiecesOptions,
201
+ ): string[] {
202
+ const target = resolve(targetPath);
203
+ const replacements = buildReplacements(options);
204
+ const created: string[] = [];
205
+
206
+ if (!existsSync(SCAFFOLDING_DIR)) {
207
+ throw new Error(`Scaffolding directory not found at ${SCAFFOLDING_DIR}`);
208
+ }
209
+
210
+ for (const piece of options.pieces) {
211
+ const pieceDef = PIECES[piece];
212
+ if (!pieceDef) {
213
+ throw new Error(
214
+ `Unknown piece: ${piece}. Available: ${PIECE_NAMES.join(", ")}`,
215
+ );
103
216
  }
104
217
 
105
- for (const entry of readdirSync(srcDir)) {
106
- const srcPath = join(srcDir, entry);
107
- const destPath = join(destDir, entry);
108
- const stat = statSync(srcPath);
109
-
110
- if (stat.isDirectory()) {
111
- copyDir(srcPath, destPath);
112
- } else if (isTextFile(srcPath)) {
113
- const content = readFileSync(srcPath, "utf-8");
114
- const processed = applyReplacements(content, replacements);
115
- mkdirSync(dirname(destPath), { recursive: true });
116
- writeFileSync(destPath, processed, "utf-8");
117
- created.push(destPath);
218
+ for (const relPath of pieceDef.paths) {
219
+ const srcPath = join(SCAFFOLDING_DIR, relPath);
220
+ const destPath = join(target, relPath);
221
+
222
+ if (!existsSync(srcPath)) {
223
+ console.warn(` warn: ${relPath} not found in scaffolding, skipping`);
224
+ continue;
225
+ }
226
+
227
+ if (statSync(srcPath).isDirectory()) {
228
+ copyDirRecursive(srcPath, destPath, replacements, created, options.force);
118
229
  } else {
119
- mkdirSync(dirname(destPath), { recursive: true });
120
- cpSync(srcPath, destPath);
121
- created.push(destPath);
230
+ copyFile(srcPath, destPath, replacements, created, options.force);
122
231
  }
123
232
  }
124
233
  }
125
234
 
126
- copyDir(SCAFFOLDING_DIR, target);
127
235
  return created;
128
236
  }