@micropage-sh/cli 2.0.8 → 2.6.1
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 +61 -4
- package/bin/micropage.js +15 -0
- package/package.json +3 -2
- package/src/commands/builds.js +8 -1
- package/src/commands/forms.js +37 -11
- package/src/commands/posts.js +646 -0
- package/src/commands/projects.js +1 -0
- package/src/commands/submissions.js +50 -25
- package/src/index.js +45 -3
- package/src/parser.js +16 -0
- package/src/posts-assets.js +211 -0
- package/src/supabase.js +36 -3
- package/templates/PROJECT_AGENT.template.md +53 -4
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const matter = require('gray-matter');
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
db,
|
|
9
|
+
fn,
|
|
10
|
+
handleAuthError,
|
|
11
|
+
getValidAccessToken,
|
|
12
|
+
getMaxDeployEventIdForProject,
|
|
13
|
+
streamDeployEventsUntilDone,
|
|
14
|
+
} = require('../supabase');
|
|
15
|
+
const { getProjectConfig } = require('../auth');
|
|
16
|
+
const { formatTable, formatDate } = require('../utils');
|
|
17
|
+
const { fetchRemoteFileIndex, resolveHeroImage, resolveBodyImages } = require('../posts-assets');
|
|
18
|
+
|
|
19
|
+
const POSTS_DIR = 'posts';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
function requireProjectConfig(cwd) {
|
|
26
|
+
const config = getProjectConfig(cwd);
|
|
27
|
+
if (!config?.projectId) {
|
|
28
|
+
console.error('Not in a project folder. Run from a folder with .micropage/project.json');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
return config;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function requirePostsDir(cwd) {
|
|
35
|
+
const dir = path.join(cwd, POSTS_DIR);
|
|
36
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
37
|
+
console.error(`No "${POSTS_DIR}/" folder found. Create one and add .md files, or run: micropage posts pull`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function listLocalPostFiles(postsDir) {
|
|
44
|
+
return fs
|
|
45
|
+
.readdirSync(postsDir, { withFileTypes: true })
|
|
46
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
47
|
+
.map((e) => path.join(postsDir, e.name))
|
|
48
|
+
.sort();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** filename minus a leading date prefix (YYYY-MM-DD-) and the .md extension. */
|
|
52
|
+
function defaultSlugFromFilename(filePath) {
|
|
53
|
+
const base = path.basename(filePath, '.md');
|
|
54
|
+
return base.replace(/^\d{4}-\d{2}-\d{2}-/, '');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function slugify(s) {
|
|
58
|
+
return String(s)
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.trim()
|
|
61
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
62
|
+
.replace(/^-+|-+$/g, '');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve `list: <form name>` -> form_id via the `forms` table (case-insensitive, newsletter forms only). */
|
|
66
|
+
async function resolveFormId(projectId, listName) {
|
|
67
|
+
const name = String(listName).trim();
|
|
68
|
+
let forms;
|
|
69
|
+
try {
|
|
70
|
+
forms = await db
|
|
71
|
+
.from('forms')
|
|
72
|
+
.select('id,form_name,is_newsletter')
|
|
73
|
+
.eq('project_id', projectId)
|
|
74
|
+
.eq('is_newsletter', true)
|
|
75
|
+
.get();
|
|
76
|
+
} catch (err) {
|
|
77
|
+
handleAuthError(err);
|
|
78
|
+
throw new Error(`Failed to look up form "${name}": ${err.message}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const matches = (forms || []).filter(
|
|
82
|
+
(f) => String(f.form_name || '').toLowerCase() === name.toLowerCase(),
|
|
83
|
+
);
|
|
84
|
+
if (matches.length === 0) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`No newsletter form named "${name}" found for this project. Check "micropage forms list".`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (matches.length > 1) {
|
|
90
|
+
throw new Error(`Multiple newsletter forms named "${name}" found — ambiguous. Rename one to disambiguate.`);
|
|
91
|
+
}
|
|
92
|
+
return matches[0].id;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function frontMatterFromPost(post) {
|
|
96
|
+
const fmData = {
|
|
97
|
+
title: post.title || '',
|
|
98
|
+
};
|
|
99
|
+
if (post.slug) fmData.slug = post.slug;
|
|
100
|
+
if (post.description) fmData.description = post.description;
|
|
101
|
+
if (post.web_visibility && post.web_visibility !== 'listed') fmData.visibility = post.web_visibility;
|
|
102
|
+
if (post.hero_image) fmData.hero = post.hero_image;
|
|
103
|
+
if (post.email_enabled) fmData.email = true;
|
|
104
|
+
if (post.subject && post.subject !== post.title) fmData.subject = post.subject;
|
|
105
|
+
if (post.preheader) fmData.preview = post.preheader;
|
|
106
|
+
return fmData;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// posts push
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async function push(options = {}) {
|
|
114
|
+
const cwd = process.cwd();
|
|
115
|
+
const config = requireProjectConfig(cwd);
|
|
116
|
+
const postsDir = requirePostsDir(cwd);
|
|
117
|
+
|
|
118
|
+
const localFiles = listLocalPostFiles(postsDir);
|
|
119
|
+
if (localFiles.length === 0) {
|
|
120
|
+
console.log(`No .md files found in "${POSTS_DIR}/". Nothing to push.`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let accessToken;
|
|
125
|
+
try {
|
|
126
|
+
accessToken = await getValidAccessToken();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
handleAuthError(err);
|
|
129
|
+
console.error('Failed to authenticate:', err.message);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Fetch remote posts once, both to report drift and to know created vs. updated.
|
|
134
|
+
let remotePosts = [];
|
|
135
|
+
try {
|
|
136
|
+
remotePosts = await db
|
|
137
|
+
.from('posts')
|
|
138
|
+
.select('id,slug,title,web_visibility,email_enabled,status,published_at,created_at')
|
|
139
|
+
.eq('project_id', config.projectId)
|
|
140
|
+
.order('created_at', 'desc')
|
|
141
|
+
.get();
|
|
142
|
+
} catch (err) {
|
|
143
|
+
handleAuthError(err);
|
|
144
|
+
console.error('Failed to fetch remote posts:', err.message);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
const remoteBySlug = new Map((remotePosts || []).map((p) => [p.slug, p]));
|
|
148
|
+
|
|
149
|
+
let fileIndex;
|
|
150
|
+
try {
|
|
151
|
+
fileIndex = await fetchRemoteFileIndex(config.projectId);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
handleAuthError(err);
|
|
154
|
+
console.error('Failed to list project files:', err.message);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const localSlugs = new Set();
|
|
159
|
+
const summary = [];
|
|
160
|
+
let hadError = false;
|
|
161
|
+
|
|
162
|
+
for (const filePath of localFiles) {
|
|
163
|
+
const relName = path.relative(cwd, filePath);
|
|
164
|
+
let raw;
|
|
165
|
+
try {
|
|
166
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error(`${relName}: failed to read (${err.message})`);
|
|
169
|
+
hadError = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = matter(raw);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error(`${relName}: invalid front-matter (${err.message})`);
|
|
178
|
+
hadError = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const fm = parsed.data || {};
|
|
183
|
+
const title = typeof fm.title === 'string' ? fm.title.trim() : '';
|
|
184
|
+
if (!title) {
|
|
185
|
+
console.error(`${relName}: missing required front-matter field "title"`);
|
|
186
|
+
hadError = true;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const slug = fm.slug ? slugify(String(fm.slug)) : slugify(defaultSlugFromFilename(filePath));
|
|
191
|
+
if (!slug) {
|
|
192
|
+
console.error(`${relName}: could not derive a slug (set "slug:" in front-matter)`);
|
|
193
|
+
hadError = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
localSlugs.add(slug);
|
|
197
|
+
|
|
198
|
+
const visibility = fm.visibility || 'listed';
|
|
199
|
+
if (!['listed', 'unlisted'].includes(visibility)) {
|
|
200
|
+
console.error(`${relName}: invalid "visibility" (${visibility}); use listed or unlisted`);
|
|
201
|
+
hadError = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const emailWanted = fm.email === true;
|
|
206
|
+
let formId = null;
|
|
207
|
+
if (emailWanted) {
|
|
208
|
+
if (!fm.list) {
|
|
209
|
+
console.error(`${relName}: "email: true" requires a "list:" front-matter field`);
|
|
210
|
+
hadError = true;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
formId = await resolveFormId(config.projectId, fm.list);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.error(`${relName}: ${err.message}`);
|
|
217
|
+
hadError = true;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let heroUrl = null;
|
|
223
|
+
try {
|
|
224
|
+
const hero = await resolveHeroImage({
|
|
225
|
+
accessToken,
|
|
226
|
+
projectId: config.projectId,
|
|
227
|
+
postFilePath: filePath,
|
|
228
|
+
cwd,
|
|
229
|
+
heroFrontMatter: fm.hero,
|
|
230
|
+
fileIndex,
|
|
231
|
+
});
|
|
232
|
+
heroUrl = hero.url;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.error(`${relName}: ${err.message}`);
|
|
235
|
+
hadError = true;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let bodyMarkdown;
|
|
240
|
+
try {
|
|
241
|
+
const resolvedBody = await resolveBodyImages({
|
|
242
|
+
accessToken,
|
|
243
|
+
projectId: config.projectId,
|
|
244
|
+
postFilePath: filePath,
|
|
245
|
+
cwd,
|
|
246
|
+
body: parsed.content || '',
|
|
247
|
+
fileIndex,
|
|
248
|
+
});
|
|
249
|
+
bodyMarkdown = resolvedBody.markdown;
|
|
250
|
+
if (resolvedBody.unresolved && resolvedBody.unresolved.length > 0) {
|
|
251
|
+
console.warn(
|
|
252
|
+
`${relName}: warning — ${resolvedBody.unresolved.length} body image(s) not found locally, shipped as-is (will 404 if unhosted): ${resolvedBody.unresolved.join(', ')}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error(`${relName}: failed to resolve body images (${err.message})`);
|
|
257
|
+
hadError = true;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const payload = {
|
|
262
|
+
project_id: config.projectId,
|
|
263
|
+
title,
|
|
264
|
+
slug,
|
|
265
|
+
body_markdown: bodyMarkdown,
|
|
266
|
+
description: fm.description || null,
|
|
267
|
+
web_visibility: visibility,
|
|
268
|
+
hero_image: heroUrl,
|
|
269
|
+
form_id: formId,
|
|
270
|
+
subject: fm.subject || null,
|
|
271
|
+
preheader: fm.preview || null,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const result = await fn.invoke('upsert-post', payload);
|
|
276
|
+
const note = `${result.action}, saved (${result.published ? 'live' : 'draft'})`;
|
|
277
|
+
summary.push({ file: relName, slug, ok: true, note });
|
|
278
|
+
console.log(`${relName} -> "${slug}": ${note}`);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
handleAuthError(err);
|
|
281
|
+
const msg = err.status === 409 ? 'slug already in use for another post' : err.message;
|
|
282
|
+
console.error(`${relName}: upsert failed (${msg})`);
|
|
283
|
+
summary.push({ file: relName, slug, ok: false, note: msg });
|
|
284
|
+
hadError = true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Drift report: remote posts with no matching local file. Never deleted here.
|
|
289
|
+
const driftSlugs = (remotePosts || [])
|
|
290
|
+
.map((p) => p.slug)
|
|
291
|
+
.filter((slug) => slug && !localSlugs.has(slug));
|
|
292
|
+
if (driftSlugs.length > 0) {
|
|
293
|
+
console.log('');
|
|
294
|
+
console.log(
|
|
295
|
+
`Note: ${driftSlugs.length} remote post(s) have no local file in "${POSTS_DIR}/" (not deleted): ${driftSlugs.join(', ')}`,
|
|
296
|
+
);
|
|
297
|
+
console.log('Run "micropage posts pull" to fetch them locally, or "micropage posts rm <slug>" to delete remotely.');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
console.log('');
|
|
301
|
+
const okCount = summary.filter((s) => s.ok).length;
|
|
302
|
+
console.log(`Pushed ${okCount}/${summary.length} post(s).`);
|
|
303
|
+
|
|
304
|
+
if (hadError) process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// posts pull
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
async function pull(options = {}) {
|
|
312
|
+
const cwd = process.cwd();
|
|
313
|
+
const config = requireProjectConfig(cwd);
|
|
314
|
+
const postsDir = path.join(cwd, POSTS_DIR);
|
|
315
|
+
fs.mkdirSync(postsDir, { recursive: true });
|
|
316
|
+
|
|
317
|
+
let remotePosts;
|
|
318
|
+
try {
|
|
319
|
+
remotePosts = await db
|
|
320
|
+
.from('posts')
|
|
321
|
+
.select(
|
|
322
|
+
'id,slug,title,description,body_markdown,web_visibility,email_enabled,status,hero_image,published_at,created_at',
|
|
323
|
+
)
|
|
324
|
+
.eq('project_id', config.projectId)
|
|
325
|
+
.order('created_at', 'desc')
|
|
326
|
+
.get();
|
|
327
|
+
} catch (err) {
|
|
328
|
+
handleAuthError(err);
|
|
329
|
+
console.error('Failed to fetch remote posts:', err.message);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!Array.isArray(remotePosts) || remotePosts.length === 0) {
|
|
334
|
+
console.log('No remote posts to pull.');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let rl = null;
|
|
339
|
+
const confirmOverwrite = async (filename) => {
|
|
340
|
+
if (options.force) return true;
|
|
341
|
+
if (!rl) {
|
|
342
|
+
const readline = require('readline');
|
|
343
|
+
rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
344
|
+
}
|
|
345
|
+
const answer = await new Promise((resolve) => {
|
|
346
|
+
rl.question(`Overwrite "${filename}"? [y/N] `, (a) => resolve((a || '').trim().toLowerCase()));
|
|
347
|
+
});
|
|
348
|
+
return answer === 'y' || answer === 'yes';
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
let written = 0;
|
|
352
|
+
let skipped = 0;
|
|
353
|
+
|
|
354
|
+
for (const post of remotePosts) {
|
|
355
|
+
if (!post.slug) {
|
|
356
|
+
console.warn(`Skipping post ${post.id}: no slug set.`);
|
|
357
|
+
skipped += 1;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const filename = `${post.slug}.md`;
|
|
361
|
+
const filePath = path.join(postsDir, filename);
|
|
362
|
+
|
|
363
|
+
if (fs.existsSync(filePath)) {
|
|
364
|
+
const ok = await confirmOverwrite(filename);
|
|
365
|
+
if (!ok) {
|
|
366
|
+
skipped += 1;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const fmData = frontMatterFromPost(post);
|
|
372
|
+
const content = matter.stringify(post.body_markdown || '', fmData);
|
|
373
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
374
|
+
written += 1;
|
|
375
|
+
console.log(`Wrote ${path.relative(cwd, filePath)}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (rl) rl.close();
|
|
379
|
+
|
|
380
|
+
console.log('');
|
|
381
|
+
console.log(`Pulled ${written} post(s)${skipped > 0 ? `, skipped ${skipped}` : ''}.`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// posts list
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
async function list(options = {}) {
|
|
389
|
+
const cwd = process.cwd();
|
|
390
|
+
const config = requireProjectConfig(cwd);
|
|
391
|
+
|
|
392
|
+
let posts;
|
|
393
|
+
try {
|
|
394
|
+
posts = await db
|
|
395
|
+
.from('posts')
|
|
396
|
+
.select(
|
|
397
|
+
'id,slug,title,web_visibility,email_enabled,status,published_at,created_at',
|
|
398
|
+
)
|
|
399
|
+
.eq('project_id', config.projectId)
|
|
400
|
+
.order('created_at', 'desc')
|
|
401
|
+
.get();
|
|
402
|
+
} catch (err) {
|
|
403
|
+
handleAuthError(err);
|
|
404
|
+
console.error('Failed to list posts:', err.message);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!Array.isArray(posts) || posts.length === 0) {
|
|
409
|
+
console.log('No posts for this project.');
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (options.json) {
|
|
414
|
+
console.log(JSON.stringify(posts, null, 2));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const rows = posts.map((p) => [
|
|
419
|
+
p.slug || '-',
|
|
420
|
+
p.title || '-',
|
|
421
|
+
p.web_visibility || '-',
|
|
422
|
+
p.published_at ? 'Published' : 'Draft',
|
|
423
|
+
p.email_enabled ? 'yes' : 'no',
|
|
424
|
+
// `status` tracks the newsletter send lifecycle only; it says nothing about
|
|
425
|
+
// deploy state, so it's meaningless (and misleading — reads as "pending") for
|
|
426
|
+
// web-only posts. Show it only when the post actually emails.
|
|
427
|
+
p.email_enabled ? (p.status || '-') : '-',
|
|
428
|
+
formatDate(p.created_at),
|
|
429
|
+
]);
|
|
430
|
+
formatTable(rows, ['Slug', 'Title', 'Visibility', 'Published', 'Emailed', 'Send status', 'Created']);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// posts rm <slug>
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
async function rm(slug, options = {}) {
|
|
438
|
+
if (!slug) {
|
|
439
|
+
console.error('Usage: micropage posts rm <slug>');
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
const cwd = process.cwd();
|
|
443
|
+
const config = requireProjectConfig(cwd);
|
|
444
|
+
|
|
445
|
+
let result;
|
|
446
|
+
try {
|
|
447
|
+
result = await fn.invoke('delete-post', { project_id: config.projectId, slug });
|
|
448
|
+
} catch (err) {
|
|
449
|
+
handleAuthError(err);
|
|
450
|
+
console.error('Failed to delete post:', err.message);
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (result?.deleted) {
|
|
455
|
+
console.log(`Deleted post "${slug}" (remote rebuild triggered if the project is deployed).`);
|
|
456
|
+
} else {
|
|
457
|
+
console.log(`No post found with slug "${slug}" — nothing to delete.`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// posts publish [slug]
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
/** Slugs to target when no explicit slug is given: every local posts/*.md file's resolved slug. */
|
|
466
|
+
function localSlugsFromPostsDir(postsDir) {
|
|
467
|
+
const slugs = [];
|
|
468
|
+
for (const filePath of listLocalPostFiles(postsDir)) {
|
|
469
|
+
let fm = {};
|
|
470
|
+
try {
|
|
471
|
+
fm = matter(fs.readFileSync(filePath, 'utf8')).data || {};
|
|
472
|
+
} catch {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const slug = fm.slug ? slugify(String(fm.slug)) : slugify(defaultSlugFromFilename(filePath));
|
|
476
|
+
if (slug) slugs.push(slug);
|
|
477
|
+
}
|
|
478
|
+
return slugs;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The build the publish-post rebuilds redeploy. Servers that report
|
|
483
|
+
* rebuild_build_id rebuild the live build, never a page draft; older ones
|
|
484
|
+
* leave it out and rebuild the project's active build.
|
|
485
|
+
*/
|
|
486
|
+
function rebuildTarget(responses, activeBuildId) {
|
|
487
|
+
const reported = responses.filter((r) => r && Object.prototype.hasOwnProperty.call(r, 'rebuild_build_id'));
|
|
488
|
+
if (reported.length === 0) return { buildId: activeBuildId || null, serverReported: false };
|
|
489
|
+
const ids = reported.map((r) => r.rebuild_build_id).filter((id) => id != null);
|
|
490
|
+
return { buildId: ids.length > 0 ? ids[ids.length - 1] : null, serverReported: true };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function publish(slugArg, options = {}) {
|
|
494
|
+
const cwd = process.cwd();
|
|
495
|
+
const config = requireProjectConfig(cwd);
|
|
496
|
+
|
|
497
|
+
let targetSlugs;
|
|
498
|
+
if (slugArg) {
|
|
499
|
+
targetSlugs = [slugify(String(slugArg))];
|
|
500
|
+
} else {
|
|
501
|
+
const postsDir = requirePostsDir(cwd);
|
|
502
|
+
targetSlugs = localSlugsFromPostsDir(postsDir);
|
|
503
|
+
if (targetSlugs.length === 0) {
|
|
504
|
+
console.log(`No .md files found in "${POSTS_DIR}/". Nothing to publish.`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
console.warn(
|
|
510
|
+
'Publishing sends (or re-sends) email to the active subscriber list for any email-configured post.',
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
// Publishing a post makes the publisher auto-rebuild the live site so the
|
|
514
|
+
// /content archive picks it up — no separate `micropage publish` needed.
|
|
515
|
+
// publish-post reports which build it rebuilds; active_build_id is only the
|
|
516
|
+
// fallback for servers that don't. The --watch cursor is project-wide and
|
|
517
|
+
// taken before the rebuild is queued, since the build isn't known yet.
|
|
518
|
+
let activeBuildId = null;
|
|
519
|
+
try {
|
|
520
|
+
const proj = await db
|
|
521
|
+
.from('projects')
|
|
522
|
+
.select('active_build_id')
|
|
523
|
+
.eq('id', config.projectId)
|
|
524
|
+
.single();
|
|
525
|
+
activeBuildId = proj?.active_build_id || null;
|
|
526
|
+
} catch {
|
|
527
|
+
// best-effort; treated as "unknown" below
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let eventCursor = 0;
|
|
531
|
+
if (options.watch) {
|
|
532
|
+
try {
|
|
533
|
+
eventCursor = await getMaxDeployEventIdForProject(config.projectId);
|
|
534
|
+
} catch {
|
|
535
|
+
eventCursor = 0;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
let hadError = false;
|
|
540
|
+
let publishedCount = 0;
|
|
541
|
+
const responses = [];
|
|
542
|
+
|
|
543
|
+
for (const slug of targetSlugs) {
|
|
544
|
+
try {
|
|
545
|
+
const result = await fn.invoke('publish-post', { project_id: config.projectId, slug });
|
|
546
|
+
responses.push(result);
|
|
547
|
+
const bits = [`published_at ${result.published_at}`];
|
|
548
|
+
bits.push(result.emailed ? `emailed ${result.recipient_count} recipient(s)` : 'no email');
|
|
549
|
+
console.log(`"${slug}": ${bits.join(', ')}`);
|
|
550
|
+
publishedCount += 1;
|
|
551
|
+
} catch (err) {
|
|
552
|
+
handleAuthError(err);
|
|
553
|
+
const msg = err.status === 404 ? 'post not found (push it first with "micropage posts push")' : err.message;
|
|
554
|
+
console.error(`"${slug}": publish failed (${msg})`);
|
|
555
|
+
hadError = true;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
console.log('');
|
|
560
|
+
console.log(`Published ${publishedCount}/${targetSlugs.length} post(s).`);
|
|
561
|
+
|
|
562
|
+
if (publishedCount > 0) {
|
|
563
|
+
const { buildId: rebuildBuildId, serverReported } = rebuildTarget(responses, activeBuildId);
|
|
564
|
+
if (rebuildBuildId) {
|
|
565
|
+
console.log(
|
|
566
|
+
'A site rebuild was queued automatically; the /content archive updates once it deploys (usually a minute or two).',
|
|
567
|
+
);
|
|
568
|
+
} else if (serverReported) {
|
|
569
|
+
console.log(
|
|
570
|
+
"No site rebuild was queued: the project has no deployed build yet (run 'micropage publish'), or the post is email-only.",
|
|
571
|
+
);
|
|
572
|
+
} else {
|
|
573
|
+
console.log(
|
|
574
|
+
"Note: this project hasn't been published yet, so the post won't appear until you run 'micropage publish'.",
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (options.watch && rebuildBuildId) {
|
|
579
|
+
console.log('');
|
|
580
|
+
console.log('Build / deploy events:');
|
|
581
|
+
try {
|
|
582
|
+
const accessToken = await getValidAccessToken();
|
|
583
|
+
const { terminalEvent } = await streamDeployEventsUntilDone(
|
|
584
|
+
accessToken,
|
|
585
|
+
config.projectId,
|
|
586
|
+
rebuildBuildId,
|
|
587
|
+
{ afterId: eventCursor },
|
|
588
|
+
);
|
|
589
|
+
if (terminalEvent?.event_type === 'build.failed') {
|
|
590
|
+
const msg =
|
|
591
|
+
(terminalEvent.payload && (terminalEvent.payload.error || terminalEvent.payload.message)) ||
|
|
592
|
+
'Build failed';
|
|
593
|
+
console.error(msg);
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
596
|
+
} catch (streamErr) {
|
|
597
|
+
console.error('Event stream failed:', streamErr.message);
|
|
598
|
+
process.exit(1);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (hadError) process.exit(1);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// posts unpublish <slug>
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
async function unpublish(slug) {
|
|
611
|
+
if (!slug) {
|
|
612
|
+
console.error('Usage: micropage posts unpublish <slug>');
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
const cwd = process.cwd();
|
|
616
|
+
const config = requireProjectConfig(cwd);
|
|
617
|
+
|
|
618
|
+
let result;
|
|
619
|
+
try {
|
|
620
|
+
result = await fn.invoke('unpublish-post', { project_id: config.projectId, slug: slugify(String(slug)) });
|
|
621
|
+
} catch (err) {
|
|
622
|
+
handleAuthError(err);
|
|
623
|
+
const msg = err.status === 404 ? 'post not found' : err.message;
|
|
624
|
+
console.error(`Failed to unpublish post: ${msg}`);
|
|
625
|
+
process.exit(1);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (result?.unpublished) {
|
|
629
|
+
console.log(`Unpublished post "${slug}" (removed from the site; remains as a draft).`);
|
|
630
|
+
} else {
|
|
631
|
+
console.log(`No post found with slug "${slug}" — nothing to unpublish.`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
module.exports = {
|
|
636
|
+
push,
|
|
637
|
+
pull,
|
|
638
|
+
list,
|
|
639
|
+
rm,
|
|
640
|
+
publish,
|
|
641
|
+
unpublish,
|
|
642
|
+
rebuildTarget,
|
|
643
|
+
slugify,
|
|
644
|
+
defaultSlugFromFilename,
|
|
645
|
+
frontMatterFromPost,
|
|
646
|
+
};
|
package/src/commands/projects.js
CHANGED
|
@@ -286,6 +286,7 @@ async function create(name, options = {}) {
|
|
|
286
286
|
name: project.name || name,
|
|
287
287
|
});
|
|
288
288
|
fs.mkdirSync(path.join(dir, 'assets'), { recursive: true });
|
|
289
|
+
fs.mkdirSync(path.join(dir, 'posts'), { recursive: true });
|
|
289
290
|
|
|
290
291
|
copyExamplesAndAssetsIntoProject(dir);
|
|
291
292
|
|