@karaoke-cms/create 0.6.1 → 0.6.2

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,18 +1,28 @@
1
1
  {
2
2
  "name": "@karaoke-cms/create",
3
3
  "type": "module",
4
- "version": "0.6.1",
4
+ "version": "0.6.2",
5
5
  "description": "Scaffold a new karaoke-cms project",
6
6
  "bin": {
7
7
  "create-karaoke-cms": "./src/index.js"
8
8
  },
9
+ "files": [
10
+ "src/"
11
+ ],
12
+ "engines": {
13
+ "node": ">=22.12.0"
14
+ },
9
15
  "keywords": [
10
16
  "astro",
11
17
  "cms",
12
18
  "create",
13
- "scaffold"
19
+ "scaffold",
20
+ "obsidian"
14
21
  ],
22
+ "devDependencies": {
23
+ "vitest": "^4.1.1"
24
+ },
15
25
  "scripts": {
16
- "test": "echo \"Stub — not yet implemented\""
26
+ "test": "vitest run test/templates.test.js"
17
27
  }
18
28
  }
package/src/index.js ADDED
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @karaoke-cms/create — scaffold a new karaoke-cms project.
4
+ * Usage: npm create @karaoke-cms@latest [project-dir]
5
+ *
6
+ * Zero runtime dependencies — uses Node.js built-ins only.
7
+ */
8
+
9
+ import { createInterface } from 'readline';
10
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
11
+ import { join, resolve } from 'path';
12
+ import {
13
+ packageJson, astroConfig, karaokeConfig, contentConfig,
14
+ envDts, tsConfig, gitignore, cloudflareRedirects,
15
+ helloWorld, draftPost, gettingStarted,
16
+ } from './templates.js';
17
+
18
+ // ── ANSI helpers ─────────────────────────────────────────────────────────────
19
+ const R = '\x1b[0m';
20
+ const BOLD = '\x1b[1m';
21
+ const GREEN = '\x1b[32m';
22
+ const CYAN = '\x1b[36m';
23
+ const GRAY = '\x1b[90m';
24
+ const RED = '\x1b[31m';
25
+
26
+ // Use create package's own version as the @karaoke-cms/astro dep range
27
+ // (packages ship in lockstep)
28
+ const { version: astroVersion } = JSON.parse(
29
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8')
30
+ );
31
+
32
+ // Separate input (readline) from output (process.stdout) so piped stdin works
33
+ // correctly. Buffer all 'line' events into a queue so that lines emitted before
34
+ // the next nextLine() call are never dropped (this happens with piped input
35
+ // because readline reads the entire pipe before the first await completes).
36
+ const rl = createInterface({ input: process.stdin });
37
+ const lineQueue = [];
38
+ let lineWaiter = null;
39
+ let inputClosed = false;
40
+
41
+ rl.on('line', line => {
42
+ if (lineWaiter) {
43
+ const resolve = lineWaiter;
44
+ lineWaiter = null;
45
+ resolve(line);
46
+ } else {
47
+ lineQueue.push(line);
48
+ }
49
+ });
50
+
51
+ rl.on('close', () => {
52
+ inputClosed = true;
53
+ if (lineWaiter) { lineWaiter(''); lineWaiter = null; }
54
+ });
55
+
56
+ function nextLine() {
57
+ if (lineQueue.length > 0) return Promise.resolve(lineQueue.shift());
58
+ if (inputClosed) return Promise.resolve('');
59
+ return new Promise(resolve => { lineWaiter = resolve; });
60
+ }
61
+
62
+ async function ask(question, defaultValue = '') {
63
+ const hint = defaultValue ? ` ${GRAY}(${defaultValue})${R}` : '';
64
+ process.stdout.write(`${CYAN}?${R} ${question}${hint} › `);
65
+ const ans = await nextLine();
66
+ return ans.trim() || defaultValue;
67
+ }
68
+
69
+ async function askYesNo(question, defaultYes = true) {
70
+ const hint = defaultYes ? 'Y/n' : 'y/N';
71
+ process.stdout.write(`${CYAN}?${R} ${question} ${GRAY}(${hint})${R} › `);
72
+ const ans = await nextLine();
73
+ return ans.trim() ? ans.trim().toLowerCase().startsWith('y') : defaultYes;
74
+ }
75
+
76
+ async function askChoice(question, choices, defaultIndex = 0) {
77
+ console.log(`${CYAN}?${R} ${question}`);
78
+ choices.forEach((c, i) => {
79
+ const marker = i === defaultIndex ? `${CYAN}›${R}` : ' ';
80
+ console.log(` ${marker} ${i + 1}) ${c}`);
81
+ });
82
+ process.stdout.write(` ${GRAY}Enter number (default ${defaultIndex + 1})${R} › `);
83
+ const ans = await nextLine();
84
+ const n = parseInt(ans.trim()) - 1;
85
+ return choices[n >= 0 && n < choices.length ? n : defaultIndex];
86
+ }
87
+
88
+ // ── Main ─────────────────────────────────────────────────────────────────────
89
+ async function main() {
90
+ console.log(`\n${BOLD}Create a new karaoke-cms project${R}\n`);
91
+
92
+ // Project directory (can be passed as first CLI arg)
93
+ let dir = process.argv[2]?.replace(/^-+/, '') || '';
94
+ if (!dir) dir = await ask('Project directory', 'my-cms');
95
+
96
+ const targetDir = resolve(process.cwd(), dir);
97
+
98
+ if (existsSync(targetDir)) {
99
+ console.error(`\n${RED}✗${R} Directory already exists: ${BOLD}${targetDir}${R}`);
100
+ rl.close();
101
+ process.exit(1);
102
+ }
103
+
104
+ // Derive a readable default title from the directory name
105
+ const defaultTitle = dir
106
+ .replace(/[-_]/g, ' ')
107
+ .replace(/\b\w/g, c => c.toUpperCase());
108
+
109
+ const title = await ask('Site title', defaultTitle);
110
+ const siteUrl = await ask('Site URL', 'https://my-site.pages.dev');
111
+ const description = await ask('Description', 'Our team knowledge base.');
112
+ const theme = await askChoice('Theme', ['default', 'minimal'], 0);
113
+ const search = await askYesNo('Enable search? (Pagefind)', true);
114
+ const commentsEnabled = await askYesNo('Enable comments? (requires Giscus setup)', false);
115
+
116
+ let comments = null;
117
+ if (commentsEnabled) {
118
+ console.log(`\n ${GRAY}Get these values from giscus.app after enabling Discussions on your repo.${R}\n`);
119
+ const repo = await ask('GitHub repo (owner/repo)');
120
+ const repoId = await ask('Repo ID');
121
+ const category = await ask('Discussion category', 'General');
122
+ const categoryId = await ask('Category ID');
123
+ comments = { repo, repoId, category, categoryId };
124
+ }
125
+
126
+ rl.close();
127
+
128
+ // ── Scaffold ────────────────────────────────────────────────────────────────
129
+ console.log(`\n Scaffolding ${BOLD}${dir}${R}...\n`);
130
+
131
+ const date = new Date().toISOString().split('T')[0];
132
+
133
+ mkdirSync(join(targetDir, 'src'), { recursive: true });
134
+ mkdirSync(join(targetDir, 'content/blog'), { recursive: true });
135
+ mkdirSync(join(targetDir, 'content/docs'), { recursive: true });
136
+ mkdirSync(join(targetDir, 'public'), { recursive: true });
137
+
138
+ const files = {
139
+ 'package.json': packageJson({ name: dir, astroVersion }),
140
+ 'astro.config.mjs': astroConfig({ siteUrl }),
141
+ 'karaoke.config.ts': karaokeConfig({ title, description, theme, search, comments }),
142
+ 'src/content.config.ts': contentConfig(),
143
+ 'src/env.d.ts': envDts(),
144
+ 'tsconfig.json': tsConfig(),
145
+ '.gitignore': gitignore(),
146
+ 'public/_redirects': cloudflareRedirects(),
147
+ 'content/blog/hello-world.md': helloWorld({ date }),
148
+ 'content/blog/draft-post.md': draftPost({ date }),
149
+ 'content/docs/getting-started.md': gettingStarted({ date }),
150
+ };
151
+
152
+ for (const [file, content] of Object.entries(files)) {
153
+ writeFileSync(join(targetDir, file), content);
154
+ console.log(` ${GREEN}+${R} ${file}`);
155
+ }
156
+
157
+ // ── Done ────────────────────────────────────────────────────────────────────
158
+ console.log(`\n${GREEN}✓${R} Done! Created ${BOLD}${dir}/${R}\n`);
159
+ console.log(` Next steps:\n`);
160
+ console.log(` ${CYAN}cd ${dir}${R}`);
161
+ console.log(` ${CYAN}npm install${R}`);
162
+ console.log(` ${CYAN}npm run dev${R} ${GRAY}→ http://localhost:4321${R}\n`);
163
+ console.log(` ${GRAY}Open ${BOLD}${dir}/${R}${GRAY} in Obsidian as a vault to write content.${R}\n`);
164
+ }
165
+
166
+ main().catch(err => {
167
+ console.error(`\n${RED}✗${R} ${err.message}`);
168
+ process.exit(1);
169
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Pure template functions for karaoke-cms project scaffolding.
3
+ * Each returns a file's content as a string.
4
+ * Exported separately so they're independently testable.
5
+ */
6
+
7
+ /**
8
+ * @param {{ name: string, astroVersion: string }} opts
9
+ */
10
+ export function packageJson({ name, astroVersion }) {
11
+ return JSON.stringify({
12
+ name,
13
+ private: true,
14
+ type: 'module',
15
+ engines: { node: '>=22.12.0' },
16
+ scripts: {
17
+ dev: 'astro dev',
18
+ build: 'astro build',
19
+ preview: 'astro preview',
20
+ },
21
+ dependencies: {
22
+ '@karaoke-cms/astro': `^${astroVersion}`,
23
+ astro: '^6.0.0',
24
+ },
25
+ }, null, 2) + '\n';
26
+ }
27
+
28
+ /**
29
+ * @param {{ siteUrl: string }} opts
30
+ */
31
+ export function astroConfig({ siteUrl }) {
32
+ return `// @ts-check
33
+ import { defineConfig } from 'astro/config';
34
+ import karaoke from '@karaoke-cms/astro';
35
+
36
+ let karaokeConfig = {};
37
+ try {
38
+ karaokeConfig = (await import('./karaoke.config.ts')).default;
39
+ } catch (err) {
40
+ if (err.code !== 'ERR_MODULE_NOT_FOUND') throw err;
41
+ }
42
+
43
+ export default defineConfig({
44
+ site: ${JSON.stringify(siteUrl)},
45
+ integrations: [karaoke(karaokeConfig)],
46
+ });
47
+ `;
48
+ }
49
+
50
+ /**
51
+ * @param {{
52
+ * title: string,
53
+ * description: string,
54
+ * theme: string,
55
+ * search: boolean,
56
+ * comments: { repo: string, repoId: string, category: string, categoryId: string } | null,
57
+ * }} opts
58
+ */
59
+ export function karaokeConfig({ title, description, theme, search, comments }) {
60
+ const modules = {};
61
+ if (search) modules.search = { enabled: true };
62
+ if (comments) modules.comments = { enabled: true, ...comments };
63
+
64
+ const modulesStr = Object.keys(modules).length > 0
65
+ ? `\n modules: ${JSON.stringify(modules, null, 2).replace(/\n/g, '\n ')},`
66
+ : '';
67
+
68
+ return `import type { KaraokeConfig } from '@karaoke-cms/astro';
69
+
70
+ const config: KaraokeConfig = {
71
+ title: ${JSON.stringify(title)},
72
+ description: ${JSON.stringify(description)},
73
+ theme: ${JSON.stringify(theme)},${modulesStr}
74
+ };
75
+
76
+ export default config;
77
+ `;
78
+ }
79
+
80
+ export function contentConfig() {
81
+ return `import { makeCollections } from '@karaoke-cms/astro/collections';
82
+
83
+ export const collections = makeCollections(new URL('..', import.meta.url));
84
+ `;
85
+ }
86
+
87
+ export function envDts() {
88
+ return `/// <reference types="astro/client" />
89
+ /// <reference types="@karaoke-cms/astro/client" />
90
+ `;
91
+ }
92
+
93
+ export function tsConfig() {
94
+ return JSON.stringify(
95
+ { extends: 'astro/tsconfigs/strict', include: ['.astro/types.d.ts', '**/*'], exclude: ['dist'] },
96
+ null, 2
97
+ ) + '\n';
98
+ }
99
+
100
+ export function gitignore() {
101
+ return `.DS_Store
102
+ node_modules/
103
+ dist/
104
+ .astro/
105
+ .env
106
+ .env.*
107
+ `;
108
+ }
109
+
110
+ export function cloudflareRedirects() {
111
+ return `/* /404.html 404\n`;
112
+ }
113
+
114
+ /** @param {{ date: string }} opts */
115
+ export function helloWorld({ date }) {
116
+ return `---
117
+ title: "Hello World"
118
+ publish: true
119
+ date: ${date}
120
+ author: "The Team"
121
+ description: "Our first published post."
122
+ ---
123
+
124
+ Welcome! This post has \`publish: true\` so it appears on the site.
125
+
126
+ ## How publishing works
127
+
128
+ 1. Write Markdown in Obsidian (or any editor)
129
+ 2. Add \`publish: true\` to the frontmatter when ready to share
130
+ 3. Push to \`main\` — GitHub Actions builds and deploys automatically
131
+
132
+ Posts without \`publish: true\` stay private in your vault.
133
+ `;
134
+ }
135
+
136
+ /** @param {{ date: string }} opts */
137
+ export function draftPost({ date }) {
138
+ return `---
139
+ title: "Draft Post"
140
+ publish: false
141
+ date: ${date}
142
+ ---
143
+
144
+ This post has \`publish: false\`. It lives in your vault but never appears on the site.
145
+
146
+ Change it to \`publish: true\` when you're ready to share it.
147
+ `;
148
+ }
149
+
150
+ /** @param {{ date: string }} opts */
151
+ export function gettingStarted({ date }) {
152
+ return `---
153
+ title: "Getting Started"
154
+ publish: true
155
+ date: ${date}
156
+ description: "How to set up and use your karaoke-cms site."
157
+ ---
158
+
159
+ ## Setup
160
+
161
+ 1. Open this folder in Obsidian as a vault
162
+ 2. Edit \`karaoke.config.ts\` — set your title, theme, and modules
163
+ 3. Set your Cloudflare Pages URL in \`astro.config.mjs\`
164
+ 4. Push to \`main\` — your site is live
165
+
166
+ ## Writing content
167
+
168
+ - Blog posts go in \`content/blog/\`
169
+ - Documentation goes in \`content/docs/\`
170
+ - Set \`publish: true\` to make a file public
171
+
172
+ ## Frontmatter reference
173
+
174
+ \`\`\`yaml
175
+ ---
176
+ title: "My Post" # required
177
+ publish: true # required to appear on site
178
+ date: 2026-01-15 # optional, YYYY-MM-DD
179
+ author: "Name" # optional
180
+ description: "..." # optional, used in OG tags and RSS
181
+ ---
182
+ \`\`\`
183
+ `;
184
+ }