onepage-cli 1.0.2 → 1.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.
Files changed (3) hide show
  1. package/bin/op.js +8 -0
  2. package/lib/blog.js +386 -0
  3. package/package.json +3 -3
package/bin/op.js CHANGED
@@ -235,6 +235,9 @@ async function cmdPush() {
235
235
  if (!files.some((f) => f.rel.toLowerCase() === 'index.html')) {
236
236
  die('缺少 index.html(网站入口文件),无法部署');
237
237
  }
238
+ if (files.some((f) => f.rel.split('/').pop().toLowerCase() === 'theme.json')) {
239
+ die('检测到 theme.json:这是博客主题包,请改用 op blog theme push <主题包.zip> 上传(不要解压后当静态站部署)');
240
+ }
238
241
 
239
242
  // 默认短链名:目录名
240
243
  const dirName = path.basename(process.cwd()).replace(/[^A-Za-z0-9_\-]/g, '-').toLowerCase() || 'site';
@@ -392,6 +395,10 @@ function cmdHelp() {
392
395
  console.log(' op delete <name> 删除指定站点');
393
396
  console.log(' op open <name> 在浏览器打开站点');
394
397
  console.log('');
398
+ console.log('博客(op blog <子命令>,详情 op blog help):');
399
+ console.log(' op blog init / write / push / pull 文章写作与双向同步');
400
+ console.log(' op blog config / categories / theme 配置、分类与主题');
401
+ console.log('');
395
402
  console.log('示例:');
396
403
  console.log(' op login && op init && op add . && op push');
397
404
  console.log(' op clone https://app1.cloudstudio.site/demo/');
@@ -415,6 +422,7 @@ async function main() {
415
422
  case 'list': case 'ls': return await cmdList();
416
423
  case 'delete': case 'rm': return await cmdDelete(args);
417
424
  case 'open': return await cmdOpen(args);
425
+ case 'blog': return await require('../lib/blog')(args, conf.load());
418
426
  case 'help': case '--help': case '-h': return cmdHelp();
419
427
  default:
420
428
  err('未知命令:' + cmd);
package/lib/blog.js ADDED
@@ -0,0 +1,386 @@
1
+ 'use strict';
2
+ // op blog —— 博客模块命令组(文章 Markdown 双向同步 / 配置 / 分类 / 主题)
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { api } = require('./api');
6
+ const { ok, err, info, warn, table } = require('./util');
7
+
8
+ const BLOG_DIR = path.join('.onepage', 'blog');
9
+ const CFG_YML = path.join(BLOG_DIR, 'config.yml');
10
+ const POSTS_DIR = path.join(BLOG_DIR, 'posts');
11
+ const THEMES_DIR = path.join(BLOG_DIR, 'themes');
12
+
13
+ function die(msg) { err(msg); process.exit(1); }
14
+
15
+ /* ---------- 极简扁平 YAML(与 op.js preview.yml 同一套约定) ---------- */
16
+ function parseSimpleYaml(text) {
17
+ const out = {};
18
+ for (let line of String(text).split(/\r?\n/)) {
19
+ line = line.trim();
20
+ if (!line || line.startsWith('#')) continue;
21
+ const i = line.indexOf(':');
22
+ if (i < 1) continue;
23
+ const k = line.slice(0, i).trim();
24
+ let v = line.slice(i + 1).trim();
25
+ if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
26
+ (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) v = v.slice(1, -1);
27
+ out[k] = v;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /* ---------- front-matter ---------- */
33
+ function parseFrontMatter(text) {
34
+ const m = String(text).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
35
+ if (!m) return { meta: {}, body: String(text) };
36
+ return { meta: parseSimpleYaml(m[1]), body: m[2] };
37
+ }
38
+
39
+ function buildFrontMatter(meta, body) {
40
+ const lines = ['---'];
41
+ for (const [k, v] of Object.entries(meta)) {
42
+ if (v === '' || v == null) continue;
43
+ lines.push(`${k}: ${v}`);
44
+ }
45
+ lines.push('---', '');
46
+ return lines.join('\n') + '\n' + body;
47
+ }
48
+
49
+ function slugifyTitle(t) {
50
+ const s = String(t || '').trim().toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
52
+ return s || ('post-' + new Date().toISOString().slice(0, 10).replace(/-/g, ''));
53
+ }
54
+
55
+ function requireAuth(cfg) {
56
+ if (!cfg.token) die('尚未登录,请先执行 op login');
57
+ }
58
+
59
+ async function authed(cfg, method, pathname, body) {
60
+ const r = await api(cfg, method, pathname, body);
61
+ if (r.status === 401) die('登录已失效,请重新执行 op login');
62
+ return r;
63
+ }
64
+
65
+ function listLocalPosts() {
66
+ if (!fs.existsSync(POSTS_DIR)) return [];
67
+ return fs.readdirSync(POSTS_DIR).filter((f) => f.endsWith('.md')).map((f) => {
68
+ const full = path.join(POSTS_DIR, f);
69
+ const { meta, body } = parseFrontMatter(fs.readFileSync(full, 'utf8'));
70
+ return { file: f, path: full, meta, body };
71
+ });
72
+ }
73
+
74
+ /* ---------- init ---------- */
75
+ function cmdInit() {
76
+ if (fs.existsSync(BLOG_DIR)) {
77
+ warn('博客工作区已存在(' + BLOG_DIR + ')');
78
+ return;
79
+ }
80
+ fs.mkdirSync(POSTS_DIR, { recursive: true });
81
+ fs.mkdirSync(THEMES_DIR, { recursive: true });
82
+ fs.writeFileSync(CFG_YML, [
83
+ '# OnePage 博客配置 —— 编辑后执行 op blog config push 应用到线上',
84
+ '# 值一律写在冒号后面;theme 留空表示不改动当前主题',
85
+ "site_title: '' # 博客名称",
86
+ "description: '' # 博客介绍(显示在首页与分享卡片)",
87
+ "theme: '' # 主题 key(如 firefly / paper / illusion…)",
88
+ '# 主题选项(按所选主题的配置项填写,一行一项,前缀 option.):',
89
+ '# option.accent: \'#0969da\'',
90
+ ].join('\n') + '\n');
91
+ fs.writeFileSync(path.join(POSTS_DIR, 'hello-world.md'), [
92
+ '---',
93
+ "title: 你好,世界",
94
+ 'slug: hello-world',
95
+ 'status: draft # draft=草稿 published=发布',
96
+ "category: ''",
97
+ "description: ''",
98
+ '---',
99
+ '',
100
+ '这是第一篇文章的正文,支持 **Markdown** 与表格:',
101
+ '',
102
+ '| 表头A | 表头B |',
103
+ '| --- | --- |',
104
+ '| 1 | 2 |',
105
+ '',
106
+ ].join('\n'));
107
+ ok('已创建博客工作区 .onepage/blog/(config.yml + posts/ + themes/)');
108
+ console.log(' 1. op blog write 文章标题 新建一篇文章');
109
+ console.log(' 2. op blog push --publish 发布全部文章(自动过 AI 审核)');
110
+ console.log(' 3. op blog config push 应用配置/主题修改');
111
+ }
112
+
113
+ /* ---------- config ---------- */
114
+ async function cmdConfig(args, cfg) {
115
+ const sub = args[0] || 'pull';
116
+ if (sub === 'pull') {
117
+ const r = await authed(cfg, 'GET', '/api/cli/blog/config');
118
+ const c = r.data.config || {};
119
+ const lines = [
120
+ '# OnePage 博客配置(op blog config pull 生成)',
121
+ "site_title: '" + String(c.site_title || '').replace(/'/g, "''") + "'",
122
+ "description: '" + String(c.description || '').replace(/'/g, "''") + "'",
123
+ "theme: '" + String(c.theme || '') + "'",
124
+ ];
125
+ for (const [k, v] of Object.entries(c.options || {})) {
126
+ lines.push("option." + k + ": '" + String(v).replace(/'/g, "''") + "'");
127
+ }
128
+ fs.writeFileSync(CFG_YML, lines.join('\n') + '\n');
129
+ ok('已拉取线上配置 → ' + CFG_YML);
130
+ return;
131
+ }
132
+ if (sub === 'push') {
133
+ if (!fs.existsSync(CFG_YML)) die('未找到 ' + CFG_YML + '(先执行 op blog init 或 op blog config pull)');
134
+ const y = parseSimpleYaml(fs.readFileSync(CFG_YML, 'utf8'));
135
+ const body = {};
136
+ if (y.site_title !== undefined) body.site_title = y.site_title;
137
+ if (y.description !== undefined) body.description = y.description;
138
+ const options = {};
139
+ for (const [k, v] of Object.entries(y)) {
140
+ if (k.startsWith('option.')) options[k.slice(7)] = v;
141
+ }
142
+ if (y.theme) { body.theme = y.theme; if (Object.keys(options).length) body.options = options; }
143
+ const r = await authed(cfg, 'POST', '/api/cli/blog/config', body);
144
+ if (!r.data.ok) die(r.data.error || '保存失败');
145
+ ok('配置已应用' + (body.theme ? '(主题:' + body.theme + ',静态页已重建)' : ''));
146
+ return;
147
+ }
148
+ die('未知子命令:config ' + sub + '(可用:pull / push)');
149
+ }
150
+
151
+ /* ---------- write ---------- */
152
+ function cmdWrite(args) {
153
+ const title = args.filter((a) => !a.startsWith('--')).join(' ') || '未命名文章';
154
+ if (!fs.existsSync(POSTS_DIR)) die('未找到博客工作区,请先执行 op blog init');
155
+ const slug = slugifyTitle(title);
156
+ let file = path.join(POSTS_DIR, slug + '.md');
157
+ let n = 2;
158
+ while (fs.existsSync(file)) file = path.join(POSTS_DIR, slug + '-' + n++ + '.md');
159
+ fs.writeFileSync(file, buildFrontMatter({
160
+ title, slug: path.basename(file, '.md'),
161
+ status: 'draft', category: '', description: '',
162
+ }, '正文从这里开始…\n'));
163
+ ok('已创建 ' + file);
164
+ console.log(' 写完后执行 op blog push ' + path.basename(file) + ' --publish 发布');
165
+ }
166
+
167
+ /* ---------- list / status ---------- */
168
+ async function cmdList(cfg) {
169
+ const r = await authed(cfg, 'GET', '/api/cli/blog/posts');
170
+ const posts = r.data.posts || [];
171
+ if (!posts.length) { info('博客还没有文章'); return; }
172
+ table(['SLUG', '状态', '分类', '更新时间'], posts.map((p) => [
173
+ p.slug, p.status === 'published' ? '已发布' : '草稿', p.category || '-', p.updated_at || p.date,
174
+ ]));
175
+ info('共 ' + posts.length + ' 篇');
176
+ }
177
+
178
+ async function cmdStatus(cfg) {
179
+ const r = await authed(cfg, 'GET', '/api/cli/blog/posts');
180
+ const remote = new Set((r.data.posts || []).map((p) => p.slug));
181
+ const local = listLocalPosts();
182
+ if (!local.length) { info('本地 posts/ 没有文章'); return; }
183
+ table(['本地文件', 'slug', '状态', '远端'], local.map((l) => [
184
+ l.file, l.meta.slug || path.basename(l.file, '.md'),
185
+ l.meta.status === 'published' ? '已发布' : '草稿',
186
+ remote.has(l.meta.slug || path.basename(l.file, '.md')) ? '已存在(更新)' : '新文章',
187
+ ]));
188
+ info('执行 op blog push 上传变更;--publish 同时发布');
189
+ }
190
+
191
+ /* ---------- push ---------- */
192
+ async function cmdPush(args, cfg) {
193
+ const publishFlag = args.includes('--publish');
194
+ const targets = args.filter((a) => !a.startsWith('--'));
195
+ let files;
196
+ if (targets.length) {
197
+ files = targets.map((t) => (t.endsWith('.md') ? t : t + '.md'))
198
+ .map((t) => (fs.existsSync(t) ? t : path.join(POSTS_DIR, t)));
199
+ for (const f of files) if (!fs.existsSync(f)) die('文件不存在:' + f);
200
+ } else {
201
+ files = listLocalPosts().map((l) => l.path);
202
+ if (!files.length) { info('posts/ 没有可推送的文章'); return; }
203
+ }
204
+ let published = 0, saved = 0;
205
+ for (const f of files) {
206
+ const { meta, body } = parseFrontMatter(fs.readFileSync(f, 'utf8'));
207
+ if (!body.trim()) { warn('跳过空文件:' + f); continue; }
208
+ const publish = publishFlag || meta.status === 'published';
209
+ const r = await authed(cfg, 'POST', '/api/cli/blog/posts', {
210
+ slug: meta.slug || path.basename(f, '.md'),
211
+ title: meta.title || '',
212
+ markdown: body,
213
+ description: meta.description || '',
214
+ category: meta.category || '',
215
+ publish,
216
+ });
217
+ if (!r.data.ok) { warn((r.data.error || '推送失败') + ' ← ' + f); continue; }
218
+ if (publish) published++; else saved++;
219
+ console.log((publish ? ' ✓ 已发布' : ' ✓ 已保存草稿') + ' ' + r.data.slug +
220
+ (r.data.created ? '(新建)' : '(更新)'));
221
+ }
222
+ if (published) info(published + ' 篇已发布,将自动进行 AI 内容审核');
223
+ if (saved) info(saved + ' 篇存为草稿,发布执行 op blog push <文件> --publish 或到网页后台');
224
+ }
225
+
226
+ /* ---------- pull ---------- */
227
+ async function cmdPull(args, cfg) {
228
+ const force = args.includes('-f') || args.includes('--force');
229
+ const targets = args.filter((a) => !a.startsWith('-'));
230
+ if (!fs.existsSync(POSTS_DIR)) die('未找到博客工作区,请先执行 op blog init');
231
+ let slugs;
232
+ if (!targets.length || targets[0] === 'all') {
233
+ const r = await authed(cfg, 'GET', '/api/cli/blog/posts');
234
+ slugs = (r.data.posts || []).map((p) => p.slug);
235
+ if (!slugs.length) { info('远端还没有文章'); return; }
236
+ info('拉取全部 ' + slugs.length + ' 篇');
237
+ } else {
238
+ slugs = targets;
239
+ }
240
+ let done = 0, skipped = 0;
241
+ for (const slug of slugs) {
242
+ const r = await authed(cfg, 'GET', '/api/cli/blog/post/' + encodeURIComponent(slug));
243
+ if (!r.data.ok) { warn(r.data.error); continue; }
244
+ const p = r.data.post;
245
+ let md = p.markdown || '';
246
+ // 服务端导出首行为 "# 标题",标题已在 front-matter 中,去掉避免重复
247
+ md = md.replace(new RegExp('^# ' + p.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\r?\\n'), '');
248
+ const file = path.join(POSTS_DIR, p.slug + '.md');
249
+ const content = buildFrontMatter({
250
+ title: p.title, slug: p.slug, status: p.status,
251
+ category: p.category || '', description: p.description || '',
252
+ }, md);
253
+ if (fs.existsSync(file) && !force && fs.readFileSync(file, 'utf8') !== content) {
254
+ warn('本地有改动,跳过(用 -f 覆盖):' + p.slug + '.md');
255
+ skipped++;
256
+ continue;
257
+ }
258
+ fs.writeFileSync(file, content);
259
+ console.log(' ✓ ' + p.slug + '.md' + (p.status === 'draft' ? '(草稿)' : ''));
260
+ done++;
261
+ }
262
+ info('拉取 ' + done + ' 篇' + (skipped ? ',跳过 ' + skipped + ' 篇(本地有改动)' : ''));
263
+ }
264
+
265
+ /* ---------- delete ---------- */
266
+ async function cmdDelete(args, cfg) {
267
+ const slug = args.filter((a) => !a.startsWith('-'))[0];
268
+ if (!slug) die('用法:op blog delete <slug> [-y]');
269
+ const { ask } = require('./prompt');
270
+ if (!args.includes('-y') && !args.includes('--yes')) {
271
+ const ans = await ask('确认删除文章「' + slug + '」?(y/N) ');
272
+ if (ans.trim().toLowerCase() !== 'y') { info('已取消'); return; }
273
+ }
274
+ const r = await authed(cfg, 'DELETE', '/api/cli/blog/post/' + encodeURIComponent(slug));
275
+ if (!r.data.ok) die(r.data.error || '删除失败');
276
+ ok('已删除:' + slug);
277
+ }
278
+
279
+ /* ---------- categories ---------- */
280
+ async function cmdCategories(args, cfg) {
281
+ const sub = args[0];
282
+ if (sub === 'add' || sub === 'rename' || sub === 'delete') {
283
+ const body = { action: sub, name: args[1] || '' };
284
+ if (sub === 'rename') body.new_name = args[2] || '';
285
+ const r = await authed(cfg, 'POST', '/api/cli/blog/categories', body);
286
+ if (!r.data.ok) die(r.data.error || '操作失败');
287
+ ok({ add: '已创建分类:', rename: '已重命名为:', delete: '已删除分类:' }[sub] +
288
+ (sub === 'rename' ? body.new_name : body.name));
289
+ return;
290
+ }
291
+ const r = await authed(cfg, 'GET', '/api/cli/blog/categories');
292
+ const cats = r.data.categories || [];
293
+ if (!cats.length) { info('还没有分类(op blog categories add <名称>)'); return; }
294
+ table(['分类', '文章数'], cats.map((c) => [c.name, String(c.count)]));
295
+ }
296
+
297
+ /* ---------- themes ---------- */
298
+ async function cmdThemes(cfg) {
299
+ const r = await authed(cfg, 'GET', '/api/cli/blog/themes');
300
+ const themes = r.data.themes || [];
301
+ table(['KEY', '名称', '当前'], themes.map((t) => [t.key, t.name, t.current ? '✓' : '']));
302
+ info('应用:op blog theme apply <key> [--opt 选项名=值 ...]');
303
+ }
304
+
305
+ async function cmdThemeApply(args, cfg) {
306
+ const key = args.filter((a) => !a.startsWith('--'))[0];
307
+ if (!key) die('用法:op blog theme apply <key> [--opt 选项名=值 ...]');
308
+ const options = {};
309
+ const rest = args.filter((a) => !a.startsWith('--'));
310
+ for (const a of args) {
311
+ const m = a.match(/^--opt(?:ion)?[= ](.+?)=(.+)$/);
312
+ if (m) options[m[1]] = m[2];
313
+ }
314
+ if (rest[1]) options._hint = rest[1]; // 容错:位置参数不进 options
315
+ delete options._hint;
316
+ const r = await authed(cfg, 'POST', '/api/cli/blog/theme/apply', { theme: key, options });
317
+ if (!r.data.ok) die(r.data.error || '应用失败');
318
+ ok('主题已应用:' + key + '(静态页已重建)');
319
+ }
320
+
321
+ async function cmdThemePush(args, cfg) {
322
+ const zipPath = args.filter((a) => !a.startsWith('-'))[0];
323
+ if (!zipPath) die('用法:op blog theme push <主题包.zip> [--overwrite]');
324
+ if (!fs.existsSync(zipPath)) die('文件不存在:' + zipPath);
325
+ const form = new FormData();
326
+ form.append('file', new Blob([fs.readFileSync(zipPath)]), path.basename(zipPath));
327
+ if (args.includes('--overwrite')) form.append('overwrite', '1');
328
+ const res = await fetch(cfg.server + '/api/cli/blog/theme/import', {
329
+ method: 'POST',
330
+ headers: { Authorization: 'Bearer ' + cfg.token },
331
+ body: form,
332
+ });
333
+ const data = await res.json().catch(() => ({}));
334
+ if (res.status === 401) die('登录已失效,请重新执行 op login');
335
+ if (!data.ok) die(data.error || '导入失败');
336
+ ok('主题「' + data.key + '」导入成功,静态页已重建');
337
+ }
338
+
339
+ /* ---------- help ---------- */
340
+ function cmdHelp() {
341
+ console.log([
342
+ '',
343
+ '用法:op blog <子命令> [参数]',
344
+ '',
345
+ ' init 创建博客工作区 .onepage/blog/(config.yml + posts/ + themes/)',
346
+ ' write <标题> 新建带 front-matter 的 Markdown 文章模板',
347
+ ' status 本地文章与远端对比',
348
+ ' push [文件] [--publish] 推送文章(--publish 直接发布,自动过 AI 审核)',
349
+ ' pull [slug|all] [-f] 拉取远端文章为 Markdown(-f 覆盖本地改动)',
350
+ ' list 远端文章列表',
351
+ ' delete <slug> [-y] 删除文章',
352
+ ' config [pull|push] 拉取/推送博客配置(标题、介绍、主题及选项)',
353
+ ' categories [add|rename|delete ...] 分类管理',
354
+ ' themes 主题列表',
355
+ ' theme apply <key> [--opt k=v] 应用主题',
356
+ ' theme push <zip> [--overwrite] 上传主题包',
357
+ '',
358
+ ].join('\n'));
359
+ }
360
+
361
+ /* ---------- 入口 ---------- */
362
+ module.exports = async function (args, cfg) {
363
+ const sub = args[0] || 'help';
364
+ const rest = args.slice(1);
365
+ switch (sub) {
366
+ case 'init': return cmdInit();
367
+ case 'write': case 'new': return cmdWrite(rest);
368
+ case 'status': return await cmdStatus(cfg);
369
+ case 'push': return await cmdPush(rest, cfg);
370
+ case 'pull': return await cmdPull(rest, cfg);
371
+ case 'list': case 'ls': return await cmdList(cfg);
372
+ case 'delete': case 'rm': return await cmdDelete(rest, cfg);
373
+ case 'config': return await cmdConfig(rest, cfg);
374
+ case 'categories': case 'cat': return await cmdCategories(rest, cfg);
375
+ case 'themes': return await cmdThemes(cfg);
376
+ case 'theme': {
377
+ const action = rest[0];
378
+ if (action === 'apply') return await cmdThemeApply(rest.slice(1), cfg);
379
+ if (action === 'push' || action === 'import') return await cmdThemePush(rest.slice(1), cfg);
380
+ die('未知子命令:theme ' + (action || '') + '(可用:apply / push)');
381
+ }
382
+ case 'help': case '--help': case '-h': return cmdHelp();
383
+ default:
384
+ die('未知子命令:blog ' + sub + '(op blog help 查看用法)');
385
+ }
386
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "onepage-cli",
3
- "version": "1.0.2",
4
- "description": "OnePage 命令行工具:在终端生成、部署、管理你的静态网页(git 风格指令 op login / init / add / push / clone / list / delete)",
3
+ "version": "1.1.0",
4
+ "description": "OnePage 命令行工具:在终端部署管理静态网页与博客(op 静态站指令 + op blog 文章/配置/主题指令)",
5
5
  "bin": {
6
6
  "op": "bin/op.js"
7
7
  },
@@ -27,4 +27,4 @@
27
27
  "type": "url",
28
28
  "url": "https://app1.cloudstudio.site"
29
29
  }
30
- }
30
+ }