@x-withu/page-withu 0.0.5 → 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';
@@ -12,131 +13,368 @@ const pkg = await fs.readJson(new URL('../package.json', import.meta.url));
12
13
  const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = path.dirname(__filename);
14
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'];
15
19
 
16
20
  const program = new Command();
17
21
 
18
- program
19
- .name('page-withu')
20
- .description('CLI to scaffold a lightweight, elegant personal homepage')
21
- .version(pkg.version);
22
+ function hashContent(content) {
23
+ return createHash('sha256').update(content).digest('hex');
24
+ }
22
25
 
23
- program
24
- .command('create [project-name]')
25
- .description('create a new WithU project')
26
- .option('-t, --title <title>', 'site title')
27
- .option('--tab-title <title>', 'browser tab title')
28
- .option('--favicon <path-or-url>', 'browser tab favicon path or URL')
29
- .option('-a, --author <author>', 'author name')
30
- .option('-g, --github <github>', 'github url')
31
- .option('-e, --email <email>', 'email address')
32
- .action(async (projectName, options) => {
33
- let targetDir = projectName;
34
-
35
- if (!targetDir) {
36
- const answers = await inquirer.prompt([
37
- {
38
- type: 'input',
39
- name: 'projectName',
40
- message: 'What is your project named?',
41
- default: 'my-homepage',
42
- },
43
- ]);
44
- 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))));
45
58
  }
46
59
 
47
- const fullPath = path.resolve(process.cwd(), targetDir);
60
+ return files.sort((a, b) => a.localeCompare(b));
61
+ }
48
62
 
49
- if (fs.existsSync(fullPath)) {
50
- console.error(chalk.red(`\nError: Directory ${targetDir} already exists.`));
51
- process.exit(1);
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;
70
+
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
+ }
78
+ }
79
+
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 });
52
91
  }
92
+ return manifest;
93
+ }
53
94
 
54
- const title = options.title || (await inquirer.prompt([{
55
- type: 'input',
56
- name: 'title',
57
- message: 'Site Title:',
58
- default: 'WithU',
59
- }])).title;
60
-
61
- const author = options.author || (await inquirer.prompt([{
62
- type: 'input',
63
- name: 'author',
64
- message: 'Author Name:',
65
- default: 'Your Name',
66
- }])).author;
67
-
68
- const tabTitle = options.tabTitle || (await inquirer.prompt([{
69
- type: 'input',
70
- name: 'tabTitle',
71
- message: 'Browser Tab Title:',
72
- default: title,
73
- }])).tabTitle;
74
-
75
- const favicon = options.favicon || (await inquirer.prompt([{
76
- type: 'input',
77
- name: 'favicon',
78
- message: 'Favicon path or URL:',
79
- default: '/src/assets/bulb.svg',
80
- }])).favicon;
81
-
82
- const github = options.github !== undefined ? options.github : (await inquirer.prompt([{
83
- type: 'input',
84
- name: 'github',
85
- message: 'GitHub URL (optional):',
86
- }])).github;
87
-
88
- const email = options.email !== undefined ? options.email : (await inquirer.prompt([{
89
- type: 'input',
90
- name: 'email',
91
- message: 'Email (optional):',
92
- }])).email;
93
-
94
- const spinner = ora('Creating project...').start();
95
-
96
- try {
97
- // 1. Copy template
98
- await fs.copy(templateDir, fullPath);
99
-
100
- // 2. Generate config.js based on answers
101
- const footerLinks = [];
102
- if (github) footerLinks.push(`{ label: 'GitHub', url: '${github}' }`);
103
- if (email) footerLinks.push(`{ label: 'Email', url: 'mailto:${email}' }`);
104
-
105
- const configContent = `export default {
106
- title: "${title}",
107
- tabTitle: "${tabTitle}",
108
- favicon: "${favicon}",
109
- author: "${author}",
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)},
110
102
  year: new Date().getFullYear(),
111
103
  footerLinks: [
112
- ${footerLinks.join(',\n ')}
104
+ ${links.join(',\n')}
113
105
  ],
114
106
  pagination: {
115
107
  pageSize: 5
116
108
  }
117
109
  }`;
118
- await fs.writeFile(path.join(fullPath, 'config.js'), configContent);
119
-
120
- // 3. Rename package.json project name
121
- const pkgPath = path.join(fullPath, 'package.json');
122
- const pkg = await fs.readJson(pkgPath);
123
- pkg.name = targetDir.toLowerCase().replace(/[^a-z0-9-]/g, '-');
124
- await fs.writeJson(pkgPath, pkg, { spaces: 2 });
125
-
126
- // 4. Create empty default directories
127
- await fs.ensureDir(path.join(fullPath, 'content/blog'));
128
-
129
- spinner.succeed(chalk.green(`Successfully created WithU project in ${targetDir}!`));
130
- console.log('\nNext steps:');
131
- console.log(chalk.cyan(` cd ${targetDir}`));
132
- console.log(chalk.cyan(' npm install'));
133
- console.log(chalk.cyan(' npm run dev\n'));
134
-
135
- } catch (err) {
136
- spinner.fail(chalk.red('Failed to create project.'));
137
- console.error(err);
138
- 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
+ }
181
+ }
182
+
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 });
139
214
  }
140
- });
141
215
 
142
- program.parse();
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.5",
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(() => {