@x-withu/page-withu 0.0.4 → 0.1.0

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/bin/page-withu.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto';
2
3
  import { Command } from 'commander';
3
4
  import inquirer from 'inquirer';
4
5
  import fs from 'fs-extra';
@@ -7,134 +8,373 @@ import { fileURLToPath } from 'node:url';
7
8
  import chalk from 'chalk';
8
9
  import ora from 'ora';
9
10
 
11
+ const pkg = await fs.readJson(new URL('../package.json', import.meta.url));
12
+
10
13
  const __filename = fileURLToPath(import.meta.url);
11
14
  const __dirname = path.dirname(__filename);
12
15
  const templateDir = path.resolve(__dirname, '../template');
16
+ const manifestName = '.page-withu.json';
17
+ const managedRoots = ['src'];
18
+ const managedFiles = ['index.html', 'vite.config.js'];
13
19
 
14
20
  const program = new Command();
15
21
 
16
- program
17
- .name('page-withu')
18
- .description('CLI to scaffold a lightweight, elegant personal homepage')
19
- .version('1.0.0');
22
+ function hashContent(content) {
23
+ return createHash('sha256').update(content).digest('hex');
24
+ }
20
25
 
21
- program
22
- .command('create [project-name]')
23
- .description('create a new WithU project')
24
- .option('-t, --title <title>', 'site title')
25
- .option('--tab-title <title>', 'browser tab title')
26
- .option('--favicon <path-or-url>', 'browser tab favicon path or URL')
27
- .option('-a, --author <author>', 'author name')
28
- .option('-g, --github <github>', 'github url')
29
- .option('-e, --email <email>', 'email address')
30
- .action(async (projectName, options) => {
31
- let targetDir = projectName;
32
-
33
- if (!targetDir) {
34
- const answers = await inquirer.prompt([
35
- {
36
- type: 'input',
37
- name: 'projectName',
38
- message: 'What is your project named?',
39
- default: 'my-homepage',
40
- },
41
- ]);
42
- targetDir = answers.projectName;
26
+ async function hashFile(filePath) {
27
+ return hashContent(await fs.readFile(filePath));
28
+ }
29
+
30
+ function toPosixPath(value) {
31
+ return value.split(path.sep).join('/');
32
+ }
33
+
34
+ async function listFiles(dir) {
35
+ const entries = await fs.readdir(dir, { withFileTypes: true });
36
+ const files = [];
37
+
38
+ for (const entry of entries) {
39
+ const entryPath = path.join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ files.push(...await listFiles(entryPath));
42
+ } else if (entry.isFile()) {
43
+ files.push(entryPath);
44
+ }
45
+ }
46
+
47
+ return files;
48
+ }
49
+
50
+ async function listManagedTemplateFiles() {
51
+ const files = [...managedFiles];
52
+
53
+ for (const root of managedRoots) {
54
+ const rootPath = path.join(templateDir, root);
55
+ if (!await fs.pathExists(rootPath)) continue;
56
+ const rootFiles = await listFiles(rootPath);
57
+ files.push(...rootFiles.map((file) => toPosixPath(path.relative(templateDir, file))));
43
58
  }
44
59
 
45
- const fullPath = path.resolve(process.cwd(), targetDir);
60
+ return files.sort((a, b) => a.localeCompare(b));
61
+ }
62
+
63
+ async function createManifest(projectDir, previousManifest = null) {
64
+ const files = {};
65
+
66
+ for (const file of await listManagedTemplateFiles()) {
67
+ const projectFile = path.join(projectDir, file);
68
+ const templateFile = path.join(templateDir, file);
69
+ if (!await fs.pathExists(projectFile)) continue;
46
70
 
47
- if (fs.existsSync(fullPath)) {
48
- console.error(chalk.red(`\nError: Directory ${targetDir} already exists.`));
49
- process.exit(1);
71
+ const projectHash = await hashFile(projectFile);
72
+ const templateHash = await hashFile(templateFile);
73
+ if (projectHash === templateHash) {
74
+ files[file] = templateHash;
75
+ } else if (previousManifest?.files?.[file]) {
76
+ files[file] = previousManifest.files[file];
77
+ }
50
78
  }
51
79
 
52
- const title = options.title || (await inquirer.prompt([{
53
- type: 'input',
54
- name: 'title',
55
- message: 'Site Title:',
56
- default: 'WithU',
57
- }])).title;
58
-
59
- const author = options.author || (await inquirer.prompt([{
60
- type: 'input',
61
- name: 'author',
62
- message: 'Author Name:',
63
- default: 'Your Name',
64
- }])).author;
65
-
66
- const tabTitle = options.tabTitle || (await inquirer.prompt([{
67
- type: 'input',
68
- name: 'tabTitle',
69
- message: 'Browser Tab Title:',
70
- default: title,
71
- }])).tabTitle;
72
-
73
- const favicon = options.favicon || (await inquirer.prompt([{
74
- type: 'input',
75
- name: 'favicon',
76
- message: 'Favicon path or URL:',
77
- default: '/src/assets/bulb.svg',
78
- }])).favicon;
79
-
80
- const github = options.github !== undefined ? options.github : (await inquirer.prompt([{
81
- type: 'input',
82
- name: 'github',
83
- message: 'GitHub URL (optional):',
84
- }])).github;
85
-
86
- const email = options.email !== undefined ? options.email : (await inquirer.prompt([{
87
- type: 'input',
88
- name: 'email',
89
- message: 'Email (optional):',
90
- }])).email;
91
-
92
- const spinner = ora('Creating project...').start();
93
-
94
- try {
95
- // 1. Copy template
96
- await fs.copy(templateDir, fullPath);
97
-
98
- // 2. Generate config.js based on answers
99
- const footerLinks = [];
100
- if (github) footerLinks.push(`{ label: 'GitHub', url: '${github}' }`);
101
- if (email) footerLinks.push(`{ label: 'Email', url: 'mailto:${email}' }`);
102
-
103
- const configContent = `export default {
104
- title: "${title}",
105
- tabTitle: "${tabTitle}",
106
- favicon: "${favicon}",
107
- author: "${author}",
80
+ return {
81
+ templateVersion: pkg.version,
82
+ updatedAt: new Date().toISOString(),
83
+ files,
84
+ };
85
+ }
86
+
87
+ async function writeManifest(projectDir, dryRun, previousManifest = null) {
88
+ const manifest = await createManifest(projectDir, previousManifest);
89
+ if (!dryRun) {
90
+ await fs.writeJson(path.join(projectDir, manifestName), manifest, { spaces: 2 });
91
+ }
92
+ return manifest;
93
+ }
94
+
95
+ function renderConfig({ title, tabTitle, favicon, author, footerLinks }) {
96
+ const links = footerLinks.map((link) => ` { label: ${JSON.stringify(link.label)}, url: ${JSON.stringify(link.url)} }`);
97
+ return `export default {
98
+ title: ${JSON.stringify(title)},
99
+ tabTitle: ${JSON.stringify(tabTitle)},
100
+ favicon: ${JSON.stringify(favicon)},
101
+ author: ${JSON.stringify(author)},
108
102
  year: new Date().getFullYear(),
109
103
  footerLinks: [
110
- ${footerLinks.join(',\n ')}
104
+ ${links.join(',\n')}
111
105
  ],
112
106
  pagination: {
113
107
  pageSize: 5
114
108
  }
115
109
  }`;
116
- await fs.writeFile(path.join(fullPath, 'config.js'), configContent);
117
-
118
- // 3. Rename package.json project name
119
- const pkgPath = path.join(fullPath, 'package.json');
120
- const pkg = await fs.readJson(pkgPath);
121
- pkg.name = targetDir.toLowerCase().replace(/[^a-z0-9-]/g, '-');
122
- await fs.writeJson(pkgPath, pkg, { spaces: 2 });
123
-
124
- // 4. Create empty default directories
125
- await fs.ensureDir(path.join(fullPath, 'content/blog'));
126
-
127
- spinner.succeed(chalk.green(`Successfully created WithU project in ${targetDir}!`));
128
- console.log('\nNext steps:');
129
- console.log(chalk.cyan(` cd ${targetDir}`));
130
- console.log(chalk.cyan(' npm install'));
131
- console.log(chalk.cyan(' npm run dev\n'));
132
-
133
- } catch (err) {
134
- spinner.fail(chalk.red('Failed to create project.'));
135
- console.error(err);
136
- process.exit(1);
110
+ }
111
+
112
+ async function detectProject(projectDir) {
113
+ const required = ['package.json', 'config.js', 'content', 'src', 'vite.config.js'];
114
+ const missing = [];
115
+
116
+ for (const item of required) {
117
+ if (!await fs.pathExists(path.join(projectDir, item))) missing.push(item);
118
+ }
119
+
120
+ return missing;
121
+ }
122
+
123
+ async function loadManifest(projectDir) {
124
+ const manifestPath = path.join(projectDir, manifestName);
125
+ if (!await fs.pathExists(manifestPath)) return null;
126
+ return fs.readJson(manifestPath);
127
+ }
128
+
129
+ async function shouldOverwriteConflict(file, options) {
130
+ if (options.force) return true;
131
+ if (options.yes || options.dryRun) return false;
132
+
133
+ const answer = await inquirer.prompt([{
134
+ type: 'confirm',
135
+ name: 'overwrite',
136
+ message: `${file} has local changes. Overwrite it?`,
137
+ default: false,
138
+ }]);
139
+
140
+ return answer.overwrite;
141
+ }
142
+
143
+ async function planTemplateUpdates(projectDir, options) {
144
+ const manifest = await loadManifest(projectDir);
145
+ const files = await listManagedTemplateFiles();
146
+ const result = {
147
+ added: [],
148
+ updated: [],
149
+ skipped: [],
150
+ unchanged: [],
151
+ };
152
+
153
+ for (const file of files) {
154
+ const sourceFile = path.join(templateDir, file);
155
+ const targetFile = path.join(projectDir, file);
156
+ const sourceHash = await hashFile(sourceFile);
157
+ const exists = await fs.pathExists(targetFile);
158
+
159
+ if (!exists) {
160
+ result.added.push(file);
161
+ if (!options.dryRun) await fs.copy(sourceFile, targetFile);
162
+ continue;
163
+ }
164
+
165
+ const currentHash = await hashFile(targetFile);
166
+ if (currentHash === sourceHash) {
167
+ result.unchanged.push(file);
168
+ continue;
169
+ }
170
+
171
+ const previousHash = manifest?.files?.[file];
172
+ const safeToUpdate = previousHash && currentHash === previousHash;
173
+ const overwrite = safeToUpdate || await shouldOverwriteConflict(file, options);
174
+
175
+ if (overwrite) {
176
+ result.updated.push(file);
177
+ if (!options.dryRun) await fs.copy(sourceFile, targetFile);
178
+ } else {
179
+ result.skipped.push(file);
180
+ }
137
181
  }
138
- });
139
182
 
140
- program.parse();
183
+ return result;
184
+ }
185
+
186
+ function mergeSection(projectPkg, templatePkg, key) {
187
+ const next = { ...(projectPkg[key] || {}) };
188
+ let changed = false;
189
+
190
+ for (const [name, value] of Object.entries(templatePkg[key] || {})) {
191
+ if (next[name] !== value) {
192
+ next[name] = value;
193
+ changed = true;
194
+ }
195
+ }
196
+
197
+ if (changed) projectPkg[key] = next;
198
+ return changed;
199
+ }
200
+
201
+ async function mergePackage(projectDir, dryRun) {
202
+ const projectPkgPath = path.join(projectDir, 'package.json');
203
+ const templatePkg = await fs.readJson(path.join(templateDir, 'package.json'));
204
+ const projectPkg = await fs.readJson(projectPkgPath);
205
+ const before = JSON.stringify(projectPkg);
206
+
207
+ mergeSection(projectPkg, templatePkg, 'scripts');
208
+ mergeSection(projectPkg, templatePkg, 'dependencies');
209
+ mergeSection(projectPkg, templatePkg, 'devDependencies');
210
+
211
+ const changed = JSON.stringify(projectPkg) !== before;
212
+ if (changed && !dryRun) {
213
+ await fs.writeJson(projectPkgPath, projectPkg, { spaces: 2 });
214
+ }
215
+
216
+ return changed;
217
+ }
218
+
219
+ function printUpdateSummary(summary, dryRun) {
220
+ const label = dryRun ? 'Would update' : 'Updated';
221
+
222
+ if (summary.added.length) console.log(chalk.green(`${label} ${summary.added.length} missing file(s).`));
223
+ if (summary.updated.length) console.log(chalk.green(`${label} ${summary.updated.length} template file(s).`));
224
+ if (summary.packageChanged) console.log(chalk.green(`${label} package.json dependencies and scripts.`));
225
+ if (summary.skipped.length) {
226
+ console.log(chalk.yellow(`Skipped ${summary.skipped.length} file(s) with local changes:`));
227
+ for (const file of summary.skipped) console.log(chalk.yellow(` - ${file}`));
228
+ }
229
+ if (!summary.added.length && !summary.updated.length && !summary.packageChanged && !summary.skipped.length) {
230
+ console.log(chalk.green('Page With U project is already up to date.'));
231
+ }
232
+ if (!dryRun && (summary.added.length || summary.updated.length || summary.packageChanged)) {
233
+ console.log(chalk.cyan('\nNext steps:'));
234
+ console.log(chalk.cyan(' npm install'));
235
+ console.log(chalk.cyan(' npm run build'));
236
+ }
237
+ }
238
+
239
+ program
240
+ .name('page-withu')
241
+ .description('CLI to scaffold a lightweight, elegant personal homepage')
242
+ .version(pkg.version);
243
+
244
+ program
245
+ .command('create [project-name]')
246
+ .description('create a new PageWithU project')
247
+ .option('-t, --title <title>', 'site title')
248
+ .option('--tab-title <title>', 'browser tab title')
249
+ .option('--favicon <path-or-url>', 'browser tab favicon path or URL')
250
+ .option('-a, --author <author>', 'author name')
251
+ .option('-g, --github <github>', 'github url')
252
+ .option('-e, --email <email>', 'email address')
253
+ .action(async (projectName, options) => {
254
+ let targetDir = projectName;
255
+
256
+ if (!targetDir) {
257
+ const answers = await inquirer.prompt([
258
+ {
259
+ type: 'input',
260
+ name: 'projectName',
261
+ message: 'What is your project named?',
262
+ default: 'my-homepage',
263
+ },
264
+ ]);
265
+ targetDir = answers.projectName;
266
+ }
267
+
268
+ const fullPath = path.resolve(process.cwd(), targetDir);
269
+
270
+ if (fs.existsSync(fullPath)) {
271
+ console.error(chalk.red(`\nError: Directory ${targetDir} already exists.`));
272
+ process.exit(1);
273
+ }
274
+
275
+ const title = options.title || (await inquirer.prompt([{
276
+ type: 'input',
277
+ name: 'title',
278
+ message: 'Site Title:',
279
+ default: 'PageWithU',
280
+ }])).title;
281
+
282
+ const author = options.author || (await inquirer.prompt([{
283
+ type: 'input',
284
+ name: 'author',
285
+ message: 'Author Name:',
286
+ default: 'Your Name',
287
+ }])).author;
288
+
289
+ const tabTitle = options.tabTitle || (await inquirer.prompt([{
290
+ type: 'input',
291
+ name: 'tabTitle',
292
+ message: 'Browser Tab Title:',
293
+ default: title,
294
+ }])).tabTitle;
295
+
296
+ const favicon = options.favicon || (await inquirer.prompt([{
297
+ type: 'input',
298
+ name: 'favicon',
299
+ message: 'Favicon path or URL:',
300
+ default: '/src/assets/bulb.svg',
301
+ }])).favicon;
302
+
303
+ const github = options.github !== undefined ? options.github : (await inquirer.prompt([{
304
+ type: 'input',
305
+ name: 'github',
306
+ message: 'GitHub URL (optional):',
307
+ }])).github;
308
+
309
+ const email = options.email !== undefined ? options.email : (await inquirer.prompt([{
310
+ type: 'input',
311
+ name: 'email',
312
+ message: 'Email (optional):',
313
+ }])).email;
314
+
315
+ const spinner = ora('Creating project...').start();
316
+
317
+ try {
318
+ await fs.copy(templateDir, fullPath);
319
+
320
+ const footerLinks = [];
321
+ if (github) footerLinks.push({ label: 'GitHub', url: github });
322
+ if (email) footerLinks.push({ label: 'Email', url: `mailto:${email}` });
323
+
324
+ await fs.writeFile(path.join(fullPath, 'config.js'), renderConfig({ title, tabTitle, favicon, author, footerLinks }));
325
+
326
+ const pkgPath = path.join(fullPath, 'package.json');
327
+ const projectPkg = await fs.readJson(pkgPath);
328
+ projectPkg.name = targetDir.toLowerCase().replace(/[^a-z0-9-]/g, '-');
329
+ await fs.writeJson(pkgPath, projectPkg, { spaces: 2 });
330
+
331
+ await fs.ensureDir(path.join(fullPath, 'content/blog'));
332
+ await writeManifest(fullPath, false);
333
+
334
+ spinner.succeed(chalk.green(`Successfully created PageWithU project in ${targetDir}!`));
335
+ console.log('\nNext steps:');
336
+ console.log(chalk.cyan(` cd ${targetDir}`));
337
+ console.log(chalk.cyan(' npm install'));
338
+ console.log(chalk.cyan(' npm run dev\n'));
339
+
340
+ } catch (err) {
341
+ spinner.fail(chalk.red('Failed to create project.'));
342
+ console.error(err);
343
+ process.exit(1);
344
+ }
345
+ });
346
+
347
+ program
348
+ .command('update')
349
+ .description('update an existing PageWithU project with the latest template')
350
+ .option('--dry-run', 'show what would change without writing files')
351
+ .option('--yes', 'skip prompts and apply safe updates')
352
+ .option('--force', 'overwrite changed template-managed files')
353
+ .action(async (options) => {
354
+ const projectDir = process.cwd();
355
+ const spinner = ora(options.dryRun ? 'Checking project update...' : 'Updating project...').start();
356
+
357
+ try {
358
+ const missing = await detectProject(projectDir);
359
+ if (missing.length) {
360
+ spinner.fail(chalk.red('Current directory does not look like a PageWithU project.'));
361
+ console.error(chalk.yellow(`Missing: ${missing.join(', ')}`));
362
+ process.exit(1);
363
+ }
364
+
365
+ spinner.stop();
366
+ const previousManifest = await loadManifest(projectDir);
367
+ const updates = await planTemplateUpdates(projectDir, options);
368
+ const packageChanged = await mergePackage(projectDir, options.dryRun);
369
+
370
+ if (!options.dryRun) await writeManifest(projectDir, false, previousManifest);
371
+
372
+ printUpdateSummary({ ...updates, packageChanged }, options.dryRun);
373
+ } catch (err) {
374
+ spinner.fail(chalk.red('Failed to update project.'));
375
+ console.error(err);
376
+ process.exit(1);
377
+ }
378
+ });
379
+
380
+ program.parse();
package/docs/develop.md CHANGED
@@ -57,6 +57,7 @@ bin/page-withu.js
57
57
  ```bash
58
58
  node bin/page-withu.js --help
59
59
  node bin/page-withu.js create my-homepage
60
+ node bin/page-withu.js update --dry-run
60
61
  ```
61
62
 
62
63
  如果想全局挂载本地版本:
package/docs/setup.md CHANGED
@@ -83,6 +83,37 @@ index_img: https://example.com/cover.jpg
83
83
  - `tags`:文章标签。
84
84
  - `index_img`:博客列表封面图,可选;不填会保留占位布局。
85
85
 
86
+ ## 更新项目
87
+
88
+ 如果你已经用 `page-withu create` 创建过项目,后续升级 CLI 后可以在项目目录中更新模板能力:
89
+
90
+ ```bash
91
+ npm install -g @x-withu/page-withu
92
+ cd my-homepage
93
+ page-withu update --dry-run
94
+ page-withu update
95
+ ```
96
+
97
+ `page-withu update` 会更新模板管理的文件,例如 `src/`、`index.html` 和 `vite.config.js`,并合并 `package.json` 中模板需要的脚本和依赖。
98
+
99
+ 为了避免覆盖你的内容,以下文件默认不会被更新:
100
+
101
+ - `config.js`
102
+ - `content/`
103
+
104
+ 如果你修改过模板源码,更新时会跳过有冲突的文件并在终端列出来。确认要覆盖这些模板源码时,可以使用:
105
+
106
+ ```bash
107
+ page-withu update --force
108
+ ```
109
+
110
+ 更新完成后建议执行:
111
+
112
+ ```bash
113
+ npm install
114
+ npm run build
115
+ ```
116
+
86
117
  ## Markdown
87
118
 
88
119
  文章中可以使用常见 Markdown 语法,也支持以下增强能力:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x-withu/page-withu",
3
- "version": "0.0.4",
3
+ "version": "0.1.0",
4
4
  "description": "A lightweight, elegant personal homepage generator.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -84,7 +84,7 @@ const activeMonth = ref('')
84
84
  const monthRefs = new Map()
85
85
 
86
86
  const orderedPosts = computed(() => {
87
- return [...props.posts].sort((a, b) => new Date(a.date) - new Date(b.date))
87
+ return [...props.posts].sort((a, b) => new Date(b.date) - new Date(a.date))
88
88
  })
89
89
 
90
90
  const tags = computed(() => {