@x-withu/page-withu 0.1.1 → 1.0.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/README.md CHANGED
@@ -41,18 +41,18 @@
41
41
  # 安装 page-withu
42
42
  npm install -g @x-withu/page-withu
43
43
 
44
- # 创建项目(会依次询问站点标题、作者名、标签页标题、favicon、GitHub 链接和邮箱)
45
- page-withu create my-homepage
46
-
47
- # 也可以通过参数直接指定
48
- page-withu create my-homepage --title "My Site" --tab-title "My Blog" --favicon /favicon.svg
44
+ # 创建项目,默认配置会写入 config.js,后续可自行编辑
45
+ page-withu new my-homepage
49
46
 
50
47
  # 安装依赖
51
48
  cd my-homepage
52
49
  npm install
53
50
 
54
- # 预览网站
55
- npm run dev
51
+ # 本地预览
52
+ page-withu serve
53
+
54
+ # 构建分发产物
55
+ page-withu build
56
56
  ```
57
57
 
58
58
  下一步?[配置网站](./docs/setup.md)、[部署网站](./docs/deploy.md)、[开发网站](./docs/develop.md)。
package/bin/page-withu.js CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
3
  import { Command } from 'commander';
4
- import inquirer from 'inquirer';
5
4
  import fs from 'fs-extra';
6
5
  import path from 'node:path';
7
6
  import { fileURLToPath } from 'node:url';
@@ -13,102 +12,8 @@ const pkg = await fs.readJson(new URL('../package.json', import.meta.url));
13
12
  const __filename = fileURLToPath(import.meta.url);
14
13
  const __dirname = path.dirname(__filename);
15
14
  const templateDir = path.resolve(__dirname, '../template');
16
- const manifestName = '.page-withu.json';
17
- const managedRoots = ['src'];
18
- const managedFiles = ['.gitignore', 'index.html', 'vite.config.js'];
19
-
20
15
  const program = new Command();
21
16
 
22
- function hashContent(content) {
23
- return createHash('sha256').update(content).digest('hex');
24
- }
25
-
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))));
58
- }
59
-
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;
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 });
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)},
102
- year: new Date().getFullYear(),
103
- footerLinks: [
104
- ${links.join(',\n')}
105
- ],
106
- pagination: {
107
- pageSize: 5
108
- }
109
- }`;
110
- }
111
-
112
17
  async function detectProject(projectDir) {
113
18
  const required = ['package.json', 'config.js', 'content', 'src', 'vite.config.js'];
114
19
  const missing = [];
@@ -120,261 +25,91 @@ async function detectProject(projectDir) {
120
25
  return missing;
121
26
  }
122
27
 
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);
28
+ function shouldCopyTemplateItem(source) {
29
+ return path.relative(templateDir, source) !== '.gitignore';
127
30
  }
128
31
 
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;
32
+ function normalizePackageName(name) {
33
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '') || 'page-withu-site';
141
34
  }
142
35
 
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
- }
36
+ async function createProject(projectName = 'my-homepage') {
37
+ const targetDir = projectName.trim() || 'my-homepage';
38
+ const fullPath = path.resolve(process.cwd(), targetDir);
164
39
 
165
- const currentHash = await hashFile(targetFile);
166
- if (currentHash === sourceHash) {
167
- result.unchanged.push(file);
168
- continue;
169
- }
40
+ if (await fs.pathExists(fullPath)) {
41
+ console.error(chalk.red(`\nError: Directory ${targetDir} already exists.`));
42
+ process.exit(1);
43
+ }
170
44
 
171
- const previousHash = manifest?.files?.[file];
172
- const safeToUpdate = previousHash && currentHash === previousHash;
173
- const overwrite = safeToUpdate || await shouldOverwriteConflict(file, options);
45
+ const spinner = ora('Creating project...').start();
174
46
 
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
- }
47
+ try {
48
+ await fs.copy(templateDir, fullPath, { filter: shouldCopyTemplateItem });
182
49
 
183
- return result;
184
- }
50
+ const pkgPath = path.join(fullPath, 'package.json');
51
+ const projectPkg = await fs.readJson(pkgPath);
52
+ projectPkg.name = normalizePackageName(path.basename(targetDir));
53
+ await fs.writeJson(pkgPath, projectPkg, { spaces: 2 });
185
54
 
186
- function mergeSection(projectPkg, templatePkg, key) {
187
- const next = { ...(projectPkg[key] || {}) };
188
- let changed = false;
55
+ await fs.ensureDir(path.join(fullPath, 'content/blog'));
189
56
 
190
- for (const [name, value] of Object.entries(templatePkg[key] || {})) {
191
- if (next[name] !== value) {
192
- next[name] = value;
193
- changed = true;
194
- }
57
+ spinner.succeed(chalk.green(`Successfully created PageWithU project in ${targetDir}!`));
58
+ console.log('\nNext steps:');
59
+ console.log(chalk.cyan(` cd ${targetDir}`));
60
+ console.log(chalk.cyan(' npm install'));
61
+ console.log(chalk.cyan(' page-withu serve'));
62
+ console.log(chalk.cyan(' page-withu build\n'));
63
+ } catch (err) {
64
+ spinner.fail(chalk.red('Failed to create project.'));
65
+ console.error(err);
66
+ process.exit(1);
195
67
  }
196
-
197
- if (changed) projectPkg[key] = next;
198
- return changed;
199
68
  }
200
69
 
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 });
70
+ async function runProjectScript(script, args) {
71
+ const missing = await detectProject(process.cwd());
72
+ if (missing.length) {
73
+ console.error(chalk.red('Current directory does not look like a PageWithU project.'));
74
+ console.error(chalk.yellow(`Missing: ${missing.join(', ')}`));
75
+ process.exit(1);
214
76
  }
215
77
 
216
- return changed;
217
- }
218
-
219
- function printUpdateSummary(summary, dryRun) {
220
- const label = dryRun ? 'Would update' : 'Updated';
78
+ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
79
+ const child = spawn(npmCommand, ['run', script, '--', ...args], {
80
+ cwd: process.cwd(),
81
+ stdio: 'inherit',
82
+ });
221
83
 
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
- }
84
+ child.on('exit', (code, signal) => {
85
+ if (signal) {
86
+ process.kill(process.pid, signal);
87
+ return;
88
+ }
89
+ process.exit(code ?? 1);
90
+ });
237
91
  }
238
92
 
239
93
  program
240
94
  .name('page-withu')
241
- .description('CLI to scaffold a lightweight, elegant personal homepage')
95
+ .description('CLI to scaffold, preview, and build a lightweight personal homepage')
242
96
  .version(pkg.version);
243
97
 
244
98
  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
- });
99
+ .command('new [project-name]')
100
+ .description('create a new PageWithU project with the default template config')
101
+ .action(createProject);
346
102
 
347
103
  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
- }
104
+ .command('serve [args...]')
105
+ .description('start local preview for the current PageWithU project')
106
+ .allowUnknownOption(true)
107
+ .action((args = []) => runProjectScript('dev', args));
364
108
 
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
- });
109
+ program
110
+ .command('build [args...]')
111
+ .description('build dist assets for the current PageWithU project')
112
+ .allowUnknownOption(true)
113
+ .action((args = []) => runProjectScript('build', args));
379
114
 
380
115
  program.parse();
package/docs/develop.md CHANGED
@@ -46,18 +46,18 @@ bin/page-withu.js
46
46
 
47
47
  当前 CLI 主要负责:
48
48
 
49
- 1. 读取命令行参数或交互式输入。
50
- 2. 复制 `template/` 到目标目录。
51
- 3. 根据用户输入生成目标项目的 `config.js`。
52
- 4. 修改目标项目 `package.json` 的 `name` 字段。
53
- 5. 输出下一步命令提示。
49
+ 1. `new`:复制 `template/` 到目标目录,保留默认配置。
50
+ 2. `serve`:在当前主页项目中启动本地预览。
51
+ 3. `build`:在当前主页项目中构建 `dist` 产物。
54
52
 
55
53
  本地调试 CLI:
56
54
 
57
55
  ```bash
58
56
  node bin/page-withu.js --help
59
- node bin/page-withu.js create my-homepage
60
- node bin/page-withu.js update --dry-run
57
+ node bin/page-withu.js new my-homepage
58
+ cd my-homepage
59
+ node /path/to/page-withu/bin/page-withu.js serve
60
+ node /path/to/page-withu/bin/page-withu.js build
61
61
  ```
62
62
 
63
63
  如果想全局挂载本地版本:
@@ -65,14 +65,12 @@ node bin/page-withu.js update --dry-run
65
65
  ```bash
66
66
  npm link
67
67
  page-withu --help
68
- page-withu create my-homepage
68
+ page-withu new my-homepage
69
69
  ```
70
70
 
71
- 新增 CLI 参数时,通常需要同步修改:
71
+ 调整 CLI 行为时,通常需要同步修改:
72
72
 
73
- - `bin/page-withu.js` 中的 commander option
74
- - 交互式 prompt 的默认值。
75
- - 生成 `config.js` 的逻辑。
73
+ - `bin/page-withu.js` 中的 commander command
76
74
  - 必要时修改 `template/config.js` 的默认字段。
77
75
  - 必要时修改模板中读取配置的 Vue 组件。
78
76
  - 必要时更新 `README.md` 和 `docs/setup.md`。
@@ -82,13 +80,13 @@ page-withu create my-homepage
82
80
  ```bash
83
81
  mkdir -p /tmp/page-withu-test
84
82
  cd /tmp/page-withu-test
85
- node /path/to/page-withu/bin/page-withu.js create demo-site --title "Demo" --author "Tester"
83
+ node /path/to/page-withu/bin/page-withu.js new demo-site
86
84
  cd demo-site
87
85
  npm install
88
- npm run build
86
+ node /path/to/page-withu/bin/page-withu.js build
89
87
  ```
90
88
 
91
- 如果 `npm run build` 成功,说明模板可以被正常生成和构建。
89
+ 如果构建成功,说明模板可以被正常生成和构建。
92
90
 
93
91
  ## 开发 Template
94
92
 
@@ -202,11 +200,11 @@ npm publish --access public
202
200
 
203
201
  ```bash
204
202
  npm install -g @x-withu/page-withu
205
- page-withu create my-homepage
203
+ page-withu new my-homepage
206
204
  ```
207
205
 
208
206
  或在线构建无需本地安装:
209
207
 
210
208
  ```bash
211
- npx @x-withu/page-withu create my-homepage
209
+ npx @x-withu/page-withu new my-homepage
212
210
  ```
package/docs/setup.md CHANGED
@@ -83,36 +83,21 @@ index_img: https://example.com/cover.jpg
83
83
  - `tags`:文章标签。
84
84
  - `index_img`:博客列表封面图,可选;不填会保留占位布局。
85
85
 
86
- ## 更新项目
86
+ ## CLI 命令
87
87
 
88
- 如果你已经用 `page-withu create` 创建过项目,后续升级 CLI 后可以在项目目录中更新模板能力:
88
+ Page With U 只提供三个常用命令:
89
89
 
90
90
  ```bash
91
- npm install -g @x-withu/page-withu
92
- cd my-homepage
93
- page-withu update --dry-run
94
- page-withu update
91
+ page-withu new my-homepage
92
+ page-withu serve
93
+ page-withu build
95
94
  ```
96
95
 
97
- `page-withu update` 会更新模板管理的文件,例如 `src/`、`index.html` 和 `vite.config.js`,并合并 `package.json` 中模板需要的脚本和依赖。
96
+ - `page-withu new my-homepage`:新建主页项目,所有站点配置都保持模板默认值。
97
+ - `page-withu serve`:在当前项目目录启动本地预览,对应模板项目的 `npm run dev`。
98
+ - `page-withu build`:在当前项目目录构建 `dist` 产物,对应模板项目的 `npm run build`。
98
99
 
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
- ```
100
+ 创建完成后,直接编辑 `config.js`、`content/index.md`、`content/domains.md` 和 `content/blog/` 即可定制网站。
116
101
 
117
102
  ## Markdown
118
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x-withu/page-withu",
3
- "version": "0.1.1",
3
+ "version": "1.0.0",
4
4
  "description": "A lightweight, elegant personal homepage generator.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,7 +16,6 @@
16
16
  "template/content/",
17
17
  "template/src/",
18
18
  "template/index.html",
19
- "template/.gitignore",
20
19
  "template/package.json",
21
20
  "template/vite.config.js",
22
21
  "docs/",
@@ -38,7 +37,6 @@
38
37
  "chalk": "^5.3.0",
39
38
  "commander": "^12.1.0",
40
39
  "fs-extra": "^11.2.0",
41
- "inquirer": "^9.3.2",
42
40
  "ora": "^8.0.1"
43
41
  }
44
42
  }
@@ -24,10 +24,19 @@
24
24
  </nav>
25
25
  </div>
26
26
 
27
- <aside v-if="toc.length" class="blog-toc" aria-label="Table of contents">
28
- <p>Contents</p>
27
+ <button v-if="toc.length" class="blog-toc-toggle" type="button" :aria-expanded="tocOpen"
28
+ aria-controls="blog-toc" @click="tocOpen = true">
29
+ 目录
30
+ </button>
31
+ <div v-if="toc.length && tocOpen" class="blog-toc-backdrop" @click="tocOpen = false"></div>
32
+ <aside v-if="toc.length" id="blog-toc" :class="['blog-toc', { open: tocOpen }]" aria-label="Table of contents">
33
+ <div class="blog-toc-header">
34
+ <p>目录</p>
35
+ <button class="blog-toc-close" type="button" aria-label="Close table of contents"
36
+ @click="tocOpen = false">×</button>
37
+ </div>
29
38
  <a v-for="item in toc" :key="item.id" :href="`#${item.id}`"
30
- :class="[`toc-level-${item.level}`, { active: activeHeading === item.id }]">
39
+ :class="[`toc-level-${item.level}`, { active: activeHeading === item.id }]" @click="tocOpen = false">
31
40
  {{ item.title }}
32
41
  </a>
33
42
  </aside>
@@ -58,6 +67,7 @@ defineEmits(['back', 'open-post'])
58
67
  const contentRef = ref(null)
59
68
  const zoomImage = ref(null)
60
69
  const activeHeading = ref('')
70
+ const tocOpen = ref(false)
61
71
  const basePath = import.meta.env.BASE_URL.replace(/\/$/, '')
62
72
  const blogHref = `${basePath}/blog.html` || '/blog.html'
63
73
  let mermaidModule
@@ -118,6 +128,7 @@ onMounted(() => {
118
128
  onUnmounted(() => window.removeEventListener('scroll', updateActiveHeading))
119
129
 
120
130
  watch(() => props.post.slug, () => {
131
+ tocOpen.value = false
121
132
  activeHeading.value = props.toc[0]?.id || ''
122
133
  nextTick(() => {
123
134
  updateActiveHeading()
@@ -992,6 +992,14 @@ footer p {
992
992
  margin-bottom: 8px;
993
993
  }
994
994
 
995
+ .blog-toc-toggle {
996
+ display: none;
997
+ }
998
+
999
+ .blog-toc-backdrop {
1000
+ display: none;
1001
+ }
1002
+
995
1003
  .blog-toc {
996
1004
  position: sticky;
997
1005
  top: 24px;
@@ -1005,6 +1013,17 @@ footer p {
1005
1013
  font-size: 0.84rem;
1006
1014
  }
1007
1015
 
1016
+ .blog-toc-header {
1017
+ display: flex;
1018
+ align-items: center;
1019
+ justify-content: space-between;
1020
+ gap: 12px;
1021
+ }
1022
+
1023
+ .blog-toc-close {
1024
+ display: none;
1025
+ }
1026
+
1008
1027
  .blog-toc p {
1009
1028
  margin: 0 0 4px;
1010
1029
  color: var(--color-heading);
@@ -1197,7 +1216,7 @@ footer p {
1197
1216
  }
1198
1217
 
1199
1218
  /* ===== Responsive: Mobile ===== */
1200
- @media (max-width: 480px) {
1219
+ @media (max-width: 960px) {
1201
1220
  body {
1202
1221
  padding: 0 16px;
1203
1222
  }
@@ -1258,10 +1277,80 @@ footer p {
1258
1277
  padding-bottom: 16px;
1259
1278
  }
1260
1279
 
1280
+ .blog-article-layout {
1281
+ grid-template-columns: 1fr;
1282
+ }
1283
+
1284
+ .blog-toc-toggle {
1285
+ position: fixed;
1286
+ right: 18px;
1287
+ bottom: 18px;
1288
+ z-index: 88;
1289
+ display: inline-flex;
1290
+ align-items: center;
1291
+ gap: 6px;
1292
+ padding: 9px 14px;
1293
+ border: 1px solid var(--color-card-border);
1294
+ border-radius: 999px;
1295
+ color: var(--color-heading);
1296
+ background: var(--color-bg);
1297
+ box-shadow: var(--shadow-card);
1298
+ font: inherit;
1299
+ font-size: 0.84rem;
1300
+ cursor: pointer;
1301
+ }
1302
+
1303
+ .blog-toc-backdrop {
1304
+ position: fixed;
1305
+ inset: 0;
1306
+ z-index: 89;
1307
+ display: block;
1308
+ background: rgba(15, 23, 42, 0.36);
1309
+ }
1310
+
1261
1311
  .blog-toc {
1262
- position: static;
1263
- order: -1;
1264
- padding: 12px 0 12px 14px;
1312
+ position: fixed;
1313
+ top: 50%;
1314
+ left: 50%;
1315
+ z-index: 90;
1316
+ width: min(calc(100vw - 48px), 320px);
1317
+ max-height: min(70vh, 480px);
1318
+ padding: 16px;
1319
+ border: 1px solid var(--color-card-border);
1320
+ border-radius: 16px;
1321
+ background: var(--color-card-bg);
1322
+ box-shadow: var(--shadow-card);
1323
+ transform: translate(-50%, -50%);
1324
+ display: none;
1325
+ }
1326
+
1327
+ .blog-toc.open {
1328
+ display: flex;
1329
+ }
1330
+
1331
+ .blog-toc-header {
1332
+ position: sticky;
1333
+ top: -16px;
1334
+ margin: -16px -16px 4px;
1335
+ padding: 16px;
1336
+ background: var(--color-card-bg);
1337
+ border-bottom: 1px solid var(--color-footer-border);
1338
+ }
1339
+
1340
+ .blog-toc-close {
1341
+ display: inline-flex;
1342
+ align-items: center;
1343
+ justify-content: center;
1344
+ width: 28px;
1345
+ height: 28px;
1346
+ border: 1px solid var(--color-card-border);
1347
+ border-radius: 999px;
1348
+ color: var(--color-subtext);
1349
+ background: var(--color-bg);
1350
+ font: inherit;
1351
+ font-size: 1rem;
1352
+ line-height: 1;
1353
+ cursor: pointer;
1265
1354
  }
1266
1355
 
1267
1356
  .blog-nav {
@@ -1,10 +0,0 @@
1
- node_modules/
2
- dist/
3
-
4
- .env
5
- .env.*
6
- !.env.example
7
- *.local
8
-
9
- .DS_Store
10
- .idea/