@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.
@@ -13,6 +13,11 @@ function formatCliFieldValue(value) {
13
13
  return String(value);
14
14
  }
15
15
 
16
+ /** Spam is `flagged_at IS NOT NULL`; the inbox is everything else. */
17
+ function applySpamFilter(query, spam) {
18
+ return spam ? query.not('flagged_at', 'is', 'null') : query.is('flagged_at', 'null');
19
+ }
20
+
16
21
  async function list(options = {}) {
17
22
  const cwd = process.cwd();
18
23
  const config = getProjectConfig(cwd);
@@ -21,12 +26,17 @@ async function list(options = {}) {
21
26
  process.exit(1);
22
27
  }
23
28
 
29
+ const spam = Boolean(options.spam);
30
+ const columns = spam
31
+ ? 'id,form_id,form_name,page_url,created_at,form_index,spam_reason,flagged_at,flagged_by'
32
+ : 'id,form_id,form_name,page_url,created_at,form_index';
33
+
24
34
  let submissions;
25
35
  try {
26
- submissions = await db
27
- .from('form_submissions')
28
- .select('id,form_id,form_name,page_url,created_at,form_index')
29
- .eq('project_id', config.projectId)
36
+ submissions = await applySpamFilter(
37
+ db.from('form_submissions').select(columns).eq('project_id', config.projectId),
38
+ spam,
39
+ )
30
40
  .order('created_at', 'desc')
31
41
  .get();
32
42
  } catch (err) {
@@ -36,7 +46,7 @@ async function list(options = {}) {
36
46
  }
37
47
 
38
48
  if (!Array.isArray(submissions) || submissions.length === 0) {
39
- console.log('No form submissions.');
49
+ console.log(spam ? 'No spam submissions.' : 'No form submissions.');
40
50
  return;
41
51
  }
42
52
 
@@ -45,14 +55,20 @@ async function list(options = {}) {
45
55
  return;
46
56
  }
47
57
 
48
- const rows = submissions.map((s) => [
49
- s.id.slice(0, 8) + '…',
50
- s.form_id ? s.form_id.slice(0, 8) + '…' : '-',
51
- s.form_name || '-',
52
- s.page_url || '-',
53
- formatDate(s.created_at),
54
- ]);
55
- formatTable(rows, ['ID (short)', 'Form ID', 'Form', 'Page URL', 'Submitted']);
58
+ const rows = submissions.map((s) => {
59
+ const row = [
60
+ s.id.slice(0, 8) + '…',
61
+ s.form_id ? s.form_id.slice(0, 8) + '…' : '-',
62
+ s.form_name || '-',
63
+ s.page_url || '-',
64
+ formatDate(s.created_at),
65
+ ];
66
+ if (spam) row.push(s.spam_reason || '-');
67
+ return row;
68
+ });
69
+ const headers = ['ID (short)', 'Form ID', 'Form', 'Page URL', 'Submitted'];
70
+ if (spam) headers.push('Spam reason');
71
+ formatTable(rows, headers);
56
72
  }
57
73
 
58
74
  async function show(id, options = {}) {
@@ -85,6 +101,9 @@ async function show(id, options = {}) {
85
101
  }
86
102
 
87
103
  console.log('ID: ', submission.id);
104
+ if (submission.flagged_at) {
105
+ console.log('Spam: ', submission.spam_reason || 'yes');
106
+ }
88
107
  console.log('Form ID: ', submission.form_id || '-');
89
108
  console.log('Form: ', submission.form_name || '-');
90
109
  console.log('Page URL: ', submission.page_url || '-');
@@ -112,12 +131,14 @@ async function exportSubmissions(options = {}) {
112
131
  process.exit(1);
113
132
  }
114
133
 
134
+ const spam = Boolean(options.spam);
135
+
115
136
  let submissions;
116
137
  try {
117
- submissions = await db
118
- .from('form_submissions')
119
- .select('*')
120
- .eq('project_id', config.projectId)
138
+ submissions = await applySpamFilter(
139
+ db.from('form_submissions').select('*').eq('project_id', config.projectId),
140
+ spam,
141
+ )
121
142
  .order('created_at', 'desc')
122
143
  .get();
123
144
  } catch (err) {
@@ -127,7 +148,7 @@ async function exportSubmissions(options = {}) {
127
148
  }
128
149
 
129
150
  if (!Array.isArray(submissions) || submissions.length === 0) {
130
- console.log('No form submissions to export.');
151
+ console.log(spam ? 'No spam submissions to export.' : 'No form submissions to export.');
131
152
  return;
132
153
  }
133
154
 
@@ -137,7 +158,7 @@ async function exportSubmissions(options = {}) {
137
158
  process.exit(1);
138
159
  }
139
160
 
140
- const defaultName = `submissions.${format}`;
161
+ const defaultName = spam ? `submissions-spam.${format}` : `submissions.${format}`;
141
162
  const outFile = options.output
142
163
  ? path.isAbsolute(options.output)
143
164
  ? options.output
@@ -158,6 +179,7 @@ async function exportSubmissions(options = {}) {
158
179
  'build_id',
159
180
  'payload_json',
160
181
  ];
182
+ if (spam) header.push('spam_reason');
161
183
  const escapeCell = (value) => {
162
184
  if (value === null || value === undefined) return '';
163
185
  const str = String(value);
@@ -168,8 +190,8 @@ async function exportSubmissions(options = {}) {
168
190
  };
169
191
  const lines = [
170
192
  header.join(','),
171
- ...submissions.map((s) =>
172
- [
193
+ ...submissions.map((s) => {
194
+ const cells = [
173
195
  s.id,
174
196
  s.created_at,
175
197
  s.page_url || '',
@@ -178,8 +200,10 @@ async function exportSubmissions(options = {}) {
178
200
  s.form_index ?? '',
179
201
  s.build_id || '',
180
202
  JSON.stringify(s.payload || {}),
181
- ].map(escapeCell).join(','),
182
- ),
203
+ ];
204
+ if (spam) cells.push(s.spam_reason || '');
205
+ return cells.map(escapeCell).join(',');
206
+ }),
183
207
  ];
184
208
  fs.writeFileSync(outFile, lines.join('\n'), 'utf8');
185
209
  }
@@ -188,7 +212,8 @@ async function exportSubmissions(options = {}) {
188
212
  process.exit(1);
189
213
  }
190
214
 
191
- console.log(`Exported ${submissions.length} submission(s) → ${outFile}`);
215
+ const noun = spam ? 'spam submission(s)' : 'submission(s)';
216
+ console.log(`Exported ${submissions.length} ${noun} → ${outFile}`);
192
217
  }
193
218
 
194
- module.exports = { list, show, exportSubmissions };
219
+ module.exports = { list, show, exportSubmissions, applySpamFilter };
package/src/index.js CHANGED
@@ -13,6 +13,7 @@ const submissions = require('./commands/submissions');
13
13
  const forms = require('./commands/forms');
14
14
  const files = require('./commands/files');
15
15
  const links = require('./commands/links');
16
+ const posts = require('./commands/posts');
16
17
 
17
18
  const program = new Command();
18
19
 
@@ -218,8 +219,9 @@ const subCmd = program.command('submissions').description('View form submissions
218
219
 
219
220
  subCmd
220
221
  .command('list')
221
- .description('List form submissions for the current project')
222
+ .description('List form submissions for the current project (spam excluded)')
222
223
  .option('--json', 'Output as JSON')
224
+ .option('--spam', 'List only submissions flagged as spam')
223
225
  .action((opts) => submissions.list(opts));
224
226
 
225
227
  subCmd
@@ -230,15 +232,55 @@ subCmd
230
232
 
231
233
  subCmd
232
234
  .command('export')
233
- .description('Export form submissions for the current project to a file')
235
+ .description('Export form submissions for the current project to a file (spam excluded)')
236
+ .option('--spam', 'Export only submissions flagged as spam')
234
237
  .option(
235
238
  '-f, --format <format>',
236
239
  'Output format: csv or json (default: csv)',
237
240
  )
238
241
  .option(
239
242
  '-o, --output <file>',
240
- 'Output file path (default: submissions.<format>)',
243
+ 'Output file path (default: submissions.<format>, or submissions-spam.<format> with --spam)',
241
244
  )
242
245
  .action((opts) => submissions.exportSubmissions(opts));
243
246
 
247
+ // ---------------------------------------------------------------------------
248
+ // Posts (micropage posts <subcommand>)
249
+ // ---------------------------------------------------------------------------
250
+
251
+ const postsCmd = program.command('posts').description('Manage posts (dual-channel web + email content)');
252
+
253
+ postsCmd
254
+ .command('push')
255
+ .description('Save draft/update local posts/*.md as post rows (does not publish or email); reports remote-only posts as drift')
256
+ .action((opts) => posts.push(opts));
257
+
258
+ postsCmd
259
+ .command('pull')
260
+ .description('Write remote posts to local posts/*.md files')
261
+ .option('-f, --force', 'Overwrite existing local files without prompting')
262
+ .action((opts) => posts.pull(opts));
263
+
264
+ postsCmd
265
+ .command('list')
266
+ .description('List posts for the current project')
267
+ .option('--json', 'Output as JSON')
268
+ .action((opts) => posts.list(opts));
269
+
270
+ postsCmd
271
+ .command('publish [slug]')
272
+ .description('Publish one post by slug, or all local posts if omitted; (re)sends email for email-configured posts. Auto-triggers a site rebuild so the /content archive updates')
273
+ .option('-w, --watch', 'Stream build/deploy events until the auto-triggered site rebuild deploys')
274
+ .action((slug, opts) => posts.publish(slug, opts));
275
+
276
+ postsCmd
277
+ .command('unpublish <slug>')
278
+ .description('Take a post off the site (does not delete it; it remains as a draft)')
279
+ .action((slug, opts) => posts.unpublish(slug, opts));
280
+
281
+ postsCmd
282
+ .command('rm <slug>')
283
+ .description('Delete a post by slug (remote only; does not touch local files)')
284
+ .action((slug, opts) => posts.rm(slug, opts));
285
+
244
286
  program.parse();
package/src/parser.js CHANGED
@@ -48,6 +48,21 @@ function writePageFile(dir, content) {
48
48
  return dest;
49
49
  }
50
50
 
51
+ /**
52
+ * Read a root-level llms.txt from the given directory.
53
+ *
54
+ * Returns the file's UTF-8 content verbatim, or null if the file is absent or
55
+ * whitespace-only. Content is preserved as-is so authors control the served
56
+ * `/llms.txt` exactly (only a trailing newline is dropped).
57
+ */
58
+ function readLlmsTxtFromDir(dir) {
59
+ const file = path.join(dir, 'llms.txt');
60
+ if (!fs.existsSync(file)) return null;
61
+ const content = fs.readFileSync(file, 'utf8');
62
+ if (!content.trim()) return null;
63
+ return content.replace(/\n$/, '');
64
+ }
65
+
51
66
  function listAssetsFromDir(dir) {
52
67
  const assetsDir = path.join(dir, 'assets');
53
68
  if (!fs.existsSync(assetsDir)) return [];
@@ -65,4 +80,5 @@ module.exports = {
65
80
  readPageFilesFromDir,
66
81
  writePageFile,
67
82
  listAssetsFromDir,
83
+ readLlmsTxtFromDir,
68
84
  };
@@ -0,0 +1,211 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Asset resolution helpers for `micropage posts push`.
5
+ *
6
+ * Reuses the exact same asset pipeline the editor uses: `upload-file` (via
7
+ * uploadAssetWithToken + hashFile + list-files dedup) to store the image, then
8
+ * `get-file-url` to resolve its URL. Nothing here is CLI-specific — it mirrors
9
+ * the editor's file-manager / image-picker flow.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const { fn, hashFile, uploadAssetWithToken } = require('./supabase');
16
+
17
+ const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
18
+
19
+ function isAbsoluteUrl(s) {
20
+ return typeof s === 'string' && /^https?:\/\//i.test(s.trim());
21
+ }
22
+
23
+ /**
24
+ * Find a companion image next to a post file: `<postbasename>.<imgext>`,
25
+ * e.g. `posts/2026-01-01-launch.md` -> `posts/2026-01-01-launch.png`.
26
+ */
27
+ function findCompanionImage(postFilePath) {
28
+ const dir = path.dirname(postFilePath);
29
+ const base = path.basename(postFilePath, path.extname(postFilePath));
30
+ for (const ext of IMAGE_EXTS) {
31
+ const candidate = path.join(dir, `${base}${ext}`);
32
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
33
+ return candidate;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Fetch the project's remote file list once and expose lookup-by-filename.
41
+ * Callers should fetch this once per push and reuse it across posts.
42
+ */
43
+ async function fetchRemoteFileIndex(projectId) {
44
+ const data = await fn.invokeGet('list-files', { project_id: projectId });
45
+ const files = data?.files || [];
46
+ const byFilename = new Map();
47
+ const byHash = new Map();
48
+ for (const f of files) {
49
+ if (f.filename) byFilename.set(f.filename, f);
50
+ if (f.content_hash) byHash.set(f.content_hash, f);
51
+ }
52
+ return { byFilename, byHash };
53
+ }
54
+
55
+ /**
56
+ * Resolve a `file_id` to its URL via the `get-file-url` edge function — the same
57
+ * call the editor's image picker makes.
58
+ */
59
+ async function fileUrlFor(fileId) {
60
+ const data = await fn.invokeGet('get-file-url', { file_id: fileId });
61
+ const url = data?.url;
62
+ if (!url) throw new Error(`get-file-url returned no url for file_id=${fileId}`);
63
+ return url;
64
+ }
65
+
66
+ /**
67
+ * Upload a local file if its content hash isn't already present remotely
68
+ * (dedup, mirrors uploadAssetsWithToken), then resolve its absolute URL.
69
+ * Mutates `fileIndex` in place so repeated calls within the same push reuse it.
70
+ */
71
+ async function uploadLocalImageOnce(accessToken, projectId, filePath, fileIndex) {
72
+ const filename = path.basename(filePath);
73
+ const localHash = hashFile(filePath);
74
+
75
+ const existingByHash = fileIndex.byHash.get(localHash);
76
+ if (existingByHash) {
77
+ return { fileId: existingByHash.id, uploaded: false, filename: existingByHash.filename };
78
+ }
79
+
80
+ const uploadResult = await uploadAssetWithToken(accessToken, projectId, filePath, filename);
81
+ const file = uploadResult?.file;
82
+ if (!file?.id) throw new Error(`upload-file returned no file record for ${filename}`);
83
+
84
+ fileIndex.byFilename.set(file.filename, file);
85
+ if (file.content_hash) fileIndex.byHash.set(file.content_hash, file);
86
+
87
+ return { fileId: file.id, uploaded: true, filename: file.filename };
88
+ }
89
+
90
+ /**
91
+ * Resolve a post's hero image to an absolute URL.
92
+ *
93
+ * Priority: companion file next to the .md > front-matter `hero:` > none.
94
+ * `hero:` may be:
95
+ * - an absolute http(s) URL (passthrough, no upload)
96
+ * - a local file path (relative to the post file, or to assets/) that exists on disk (upload)
97
+ * - an existing uploaded asset filename (resolve via list-files, no upload)
98
+ *
99
+ * @returns {Promise<{ url: string|null, uploaded: boolean, source: string|null }>}
100
+ */
101
+ async function resolveHeroImage({ accessToken, projectId, postFilePath, cwd, heroFrontMatter, fileIndex }) {
102
+ const companion = findCompanionImage(postFilePath);
103
+ if (companion) {
104
+ const { fileId, uploaded, filename } = await uploadLocalImageOnce(accessToken, projectId, companion, fileIndex);
105
+ const url = await fileUrlFor(fileId);
106
+ return { url, uploaded, source: `companion:${filename}` };
107
+ }
108
+
109
+ const hero = typeof heroFrontMatter === 'string' ? heroFrontMatter.trim() : '';
110
+ if (!hero) return { url: null, uploaded: false, source: null };
111
+
112
+ if (isAbsoluteUrl(hero)) {
113
+ return { url: hero, uploaded: false, source: 'url' };
114
+ }
115
+
116
+ // Local file path: relative to the post file's directory, then to assets/, then to cwd.
117
+ const candidates = [
118
+ path.isAbsolute(hero) ? hero : path.join(path.dirname(postFilePath), hero),
119
+ path.join(cwd, 'assets', hero),
120
+ path.join(cwd, hero),
121
+ ];
122
+ const localPath = candidates.find((p) => fs.existsSync(p) && fs.statSync(p).isFile());
123
+ if (localPath) {
124
+ const { fileId, uploaded, filename } = await uploadLocalImageOnce(accessToken, projectId, localPath, fileIndex);
125
+ const url = await fileUrlFor(fileId);
126
+ return { url, uploaded, source: `local:${filename}` };
127
+ }
128
+
129
+ // Existing uploaded asset, referenced by filename only.
130
+ const existing = fileIndex.byFilename.get(hero) || fileIndex.byFilename.get(path.basename(hero));
131
+ if (existing) {
132
+ const url = await fileUrlFor(existing.id);
133
+ return { url, uploaded: false, source: `existing:${existing.filename}` };
134
+ }
135
+
136
+ throw new Error(`hero image not found: "${hero}" (not a URL, local file, or existing uploaded asset)`);
137
+ }
138
+
139
+ // Matches markdown image refs: ![alt](path "title"). Captures alt and the path only.
140
+ const MD_IMAGE_RE = /!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
141
+
142
+ /**
143
+ * Scan `body_markdown` for local image refs (`![alt](rel/path.png)`), upload each
144
+ * once (hash-dedup via `fileIndex`), and rewrite the markdown to use hosted
145
+ * absolute URLs. Absolute URLs and refs that don't resolve to a local file are
146
+ * left untouched.
147
+ *
148
+ * Refs that look local (relative, not a URL / root-absolute / anchor) but don't
149
+ * resolve to a file on disk are reported in `unresolved` so the caller can warn —
150
+ * otherwise a typo'd path would ship into body_markdown and 404 on the live page.
151
+ *
152
+ * @returns {Promise<{ markdown: string, uploaded: string[], mapping: Record<string,string>, unresolved: string[] }>}
153
+ */
154
+ async function resolveBodyImages({ accessToken, projectId, postFilePath, cwd, body, fileIndex }) {
155
+ const refs = [];
156
+ let match;
157
+ MD_IMAGE_RE.lastIndex = 0;
158
+ while ((match = MD_IMAGE_RE.exec(body)) !== null) {
159
+ refs.push(match[2]);
160
+ }
161
+
162
+ const mapping = {};
163
+ const uploaded = [];
164
+ const unresolved = [];
165
+
166
+ for (const ref of refs) {
167
+ if (mapping[ref] || isAbsoluteUrl(ref) || ref.startsWith('/') || ref.startsWith('#')) continue;
168
+
169
+ const candidates = [
170
+ path.join(path.dirname(postFilePath), ref),
171
+ path.join(cwd, 'assets', ref),
172
+ path.join(cwd, ref),
173
+ ];
174
+ const localPath = candidates.find((p) => fs.existsSync(p) && fs.statSync(p).isFile());
175
+ if (!localPath) {
176
+ // Looks like a local ref but no file on disk — surface it, don't ship it silently.
177
+ if (!unresolved.includes(ref)) unresolved.push(ref);
178
+ continue;
179
+ }
180
+
181
+ const { fileId, uploaded: wasUploaded, filename } = await uploadLocalImageOnce(
182
+ accessToken,
183
+ projectId,
184
+ localPath,
185
+ fileIndex,
186
+ );
187
+ const url = await fileUrlFor(fileId);
188
+ mapping[ref] = url;
189
+ if (wasUploaded) uploaded.push(filename);
190
+ }
191
+
192
+ if (Object.keys(mapping).length === 0) {
193
+ return { markdown: body, uploaded, mapping, unresolved };
194
+ }
195
+
196
+ MD_IMAGE_RE.lastIndex = 0;
197
+ const rewritten = body.replace(MD_IMAGE_RE, (full, alt, ref) => {
198
+ const resolved = mapping[ref];
199
+ return resolved ? `![${alt}](${resolved})` : full;
200
+ });
201
+
202
+ return { markdown: rewritten, uploaded, mapping, unresolved };
203
+ }
204
+
205
+ module.exports = {
206
+ findCompanionImage,
207
+ fetchRemoteFileIndex,
208
+ fileUrlFor,
209
+ resolveHeroImage,
210
+ resolveBodyImages,
211
+ };
package/src/supabase.js CHANGED
@@ -207,6 +207,8 @@ function buildQuery(table) {
207
207
  select(cols) { _select = cols; return q; },
208
208
  eq(col, val) { filters.push(`${col}=eq.${val}`); return q; },
209
209
  in(col, vals) { filters.push(`${col}=in.(${vals.join(',')})`); return q; },
210
+ is(col, val) { filters.push(`${col}=is.${val}`); return q; },
211
+ not(col, op, val) { filters.push(`${col}=not.${op}.${val}`); return q; },
210
212
  order(col, dir = 'asc') { _order = `${col}.${dir}`; return q; },
211
213
  limit(n) { _limit = n; return q; },
212
214
 
@@ -299,6 +301,16 @@ const TERMINAL_DEPLOY_EVENTS = new Set([
299
301
  'archive.failed',
300
302
  ]);
301
303
 
304
+ /**
305
+ * Collapse arbitrary text to a single bounded line. Keeps a Rails/HTML error
306
+ * body or backtrace that leaks into an event payload from flooding the deploy
307
+ * stream (the CLI is not the place to render a server stack trace).
308
+ */
309
+ function truncateForConsole(str, max = 200) {
310
+ const oneLine = String(str).replace(/\s+/g, ' ').trim();
311
+ return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
312
+ }
313
+
302
314
  /**
303
315
  * Human-readable line for deploy stream (falls back to event_type + JSON payload).
304
316
  * @param {{ event_type?: string, payload?: Record<string, unknown> }} ev
@@ -326,14 +338,14 @@ function formatDeployEventForConsole(ev) {
326
338
  case 'active':
327
339
  return `${eventType}: live — ${https}`;
328
340
  case 'failed':
329
- return `${eventType}: failed — ${payload.error || 'unknown'}`;
341
+ return `${eventType}: failed — ${truncateForConsole(payload.error || 'unknown', 160)}`;
330
342
  case 'timeout':
331
343
  return `${eventType}: still pending after wait (try ${https || 'URL'} shortly)`;
332
344
  default:
333
345
  break;
334
346
  }
335
347
  }
336
- const extra = ev.payload ? ` ${JSON.stringify(ev.payload)}` : '';
348
+ const extra = ev.payload ? ` ${truncateForConsole(JSON.stringify(ev.payload))}` : '';
337
349
  return `${eventType}${extra}`;
338
350
  }
339
351
 
@@ -353,6 +365,20 @@ async function getMaxDeployEventIdForBuild(buildId, accessToken = null) {
353
365
  return Number.isFinite(n) ? n : 0;
354
366
  }
355
367
 
368
+ /**
369
+ * Highest build_deploy_events.id across the project, 0 when none. A cursor
370
+ * taken before a post change, when the build the server will rebuild is not
371
+ * known yet: event ids are global and the stream filters by build.
372
+ */
373
+ async function getMaxDeployEventIdForProject(projectId) {
374
+ const url = `${SUPABASE_URL}/rest/v1/build_deploy_events?select=id&project_id=eq.${encodeURIComponent(String(projectId))}&order=id.desc&limit=1`;
375
+ const data = await request('GET', url, null);
376
+ const row = Array.isArray(data) ? data[0] : null;
377
+ if (!row || row.id == null) return 0;
378
+ const n = Number(row.id);
379
+ return Number.isFinite(n) ? n : 0;
380
+ }
381
+
356
382
  /**
357
383
  * Stream build/deploy/archive events until a terminal event or the connection closes.
358
384
  * @param {string} bearerToken
@@ -418,7 +444,13 @@ async function streamDeployEventsUntilDone(bearerToken, projectId, buildId, opti
418
444
  }
419
445
  } catch (e) {
420
446
  if (e instanceof SyntaxError) {
421
- console.log(json);
447
+ // A non-JSON data line usually means an HTML/error body leaked into
448
+ // the stream — summarize it instead of dumping the whole page.
449
+ if (/^\s*</.test(json)) {
450
+ console.log('[stream] skipped non-JSON response chunk');
451
+ } else {
452
+ console.log(truncateForConsole(json));
453
+ }
422
454
  } else {
423
455
  throw e;
424
456
  }
@@ -689,6 +721,7 @@ module.exports = {
689
721
  handleAuthError,
690
722
  getValidAccessToken,
691
723
  getMaxDeployEventIdForBuild,
724
+ getMaxDeployEventIdForProject,
692
725
  streamDeployEventsUntilDone,
693
726
  exchangeDeployTokenForAccessToken,
694
727
  pushWithToken,
@@ -13,11 +13,59 @@ This project was created with the Micropage CLI and is designed to be friendly t
13
13
  - `components-hero-variants.page` — multiple hero section variants (single CTA, image + copy, centered hero).
14
14
  - `components-pricing-and-forms.page` — pricing table (three tiers) and a richer contact/quote form, including an `img: <- pricing-cards` example.
15
15
  - `assets/logo.svg` and `assets/favicon.svg` — default logo and favicon for new projects (uploaded on push). `.page` files reference the stored filename: `logo: <- logo.svg` / `favicon: <- favicon.svg`.
16
+ - `posts/` — post files (blog/newsletter content), one Markdown file per post. See "Posts" below.
17
+
18
+ ## Posts
19
+
20
+ Each file in `posts/*.md` is one post: YAML front-matter + a Markdown body, pushed with `micropage posts push`.
21
+
22
+ ```markdown
23
+ ---
24
+ title: Launching our new dashboard
25
+ slug: launching-new-dashboard # optional; defaults to the filename minus a leading date prefix and .md
26
+ description: A quick look at what's new.
27
+ visibility: listed # listed | unlisted | none (default: listed)
28
+ hero: launch-hero.png # optional; local file, existing uploaded asset filename, or absolute URL
29
+ email: true # optional; send to a subscriber list (default: false)
30
+ list: Newsletter # required when email is true; must match a newsletter form name exactly
31
+ subject: We just shipped something new
32
+ preview: See what's new in this release
33
+ ---
34
+
35
+ Body content in Markdown. Local image refs like `![alt](screenshot.png)` are
36
+ uploaded automatically and rewritten to hosted URLs on push.
37
+ ```
38
+
39
+ A companion image file next to the post (`posts/launch.md` + `posts/launch.png`) is used as the hero automatically, taking priority over `hero:` in front-matter.
40
+
41
+ Commands:
42
+ - `micropage posts push` — upload local `posts/*.md` (create or update by slug); never deletes remote posts.
43
+ - `micropage posts pull` — write remote posts to local `posts/*.md` files.
44
+ - `micropage posts list` — list remote posts.
45
+ - `micropage posts rm <slug>` — delete a post remotely (local file is untouched).
46
+
47
+ ## The `.page` grammar (closed vocabulary)
48
+
49
+ Micropage is a line-oriented markup with a small, fixed set of tags — one element per line, no nesting, no inventing names. The whole grammar is roughly:
50
+
51
+ - File shape: a `[site]` block (title, description, logo, favicon, lang, colors, theme_color, og_image), optional `[nav]` and `[footer]`, then one or more page blocks like `[Home -> /]` / `[About -> /about]`.
52
+ - Sections inside a page: `/// hero`, `/// section`, and (rarely) `/// html`. Both `/// hero` and `/// section` take optional `align:center` and `bg:primary|secondary|muted|success|info`.
53
+ - Elements (~30 legal tags): `h1:`–`h5:`, `p:`, `small:`, `icon: bi bi-name`, `img: <- filename`, `button:`, `btn-secondary:`, `btn-outline:`, `link:`, `col:`, and form tags `form:`, `input:` (trailing `*` = required), `text:`, `textarea:`, `select: Label [A, B]`, `checkboxes:`, `radios:`, `submit:`.
54
+ - Images use the `<- filename` convention (e.g. `img: <- product-dashboard`); the file must be uploaded to the project. Don't hardcode random remote URLs unless asked.
55
+ - Colors and typography come from the `[site]` block, not from inline styles.
56
+
57
+ This is a summary. The canonical, always-current grammar lives at `https://micropage.sh/llms.txt` — read it before generating or heavily editing `.page` content.
58
+
59
+ <!-- PROJECT_TONE: (optional) one line describing this project's voice/tone for the agent. -->
16
60
 
17
61
  ## How to propose edits safely
18
62
 
19
- - Prefer editing existing `.page` files instead of introducing new formats.
20
- - Keep the Micropage DSL valid — follow the patterns used in the `examples/` folder.
63
+ - Prefer editing existing `.page` files in place instead of introducing new formats or new files.
64
+ - Keep the Micropage DSL valid — use only the tags above and follow the patterns in the `examples/` folder.
65
+ - Do not invent element names or new components, and do not emit React, Tailwind class soup, or custom HTML. Avoid `/// html` unless explicitly asked for raw markup.
66
+ - Do not wrap the file in markdown fences when writing it back — the `.page` file is not a Markdown document.
67
+ - Keep structure stable across edits: change copy and reorder before adding or removing sections.
68
+ - Read the current `[site]` block and reuse its declared colors; never introduce inline colors.
21
69
  - When creating alternative versions of a section, consider:
22
70
  - Copying the original block into `examples/` and annotating it there.
23
71
  - Proposing a diff-style change rather than rewriting entire files.
@@ -26,6 +74,7 @@ This project was created with the Micropage CLI and is designed to be friendly t
26
74
 
27
75
  For full documentation of the Micropage format and features, see:
28
76
 
29
- - `https://docs.micropage.sh`
77
+ - `https://micropage.sh/llms.txt` — the canonical, machine-readable grammar (start here when editing `.page` files)
78
+ - `https://docs.micropage.sh` — human-facing docs
30
79
 
31
- You can use the examples in this project as concrete references when generating or modifying `.page` content.*** End Patch***}"/>
80
+ You can use the examples in this project as concrete references when generating or modifying `.page` content.