@micropage-sh/cli 2.0.7 → 2.5.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
@@ -63,6 +63,40 @@ micropage copy-link
63
63
 
64
64
  When you create a project without `-d`, the API assigns `projects.domain` as the initial host for your site (for example `my-site.micropage.sh`). You can optionally attach a custom domain (such as `www.example.com`) in the web editor; when present, the CLI shows that custom domain as the site URL (otherwise the default host).
65
65
 
66
+ ### Authoring posts
67
+
68
+ Posts live in the project's `posts/` folder as Markdown files with YAML front-matter:
69
+
70
+ ```markdown
71
+ ---
72
+ title: Hello, world
73
+ slug: hello
74
+ description: A short summary for the archive, meta description, and og tags.
75
+ visibility: listed
76
+ hero: ./hello.jpg
77
+ list: newsletter
78
+ subject: Hello, world!
79
+ preview: The first post on this site.
80
+ ---
81
+
82
+ Body content goes here as standard Markdown.
83
+ ```
84
+
85
+ `title` is required. `slug` defaults to the filename minus a leading `YYYY-MM-DD-` date prefix. `visibility` is `listed` (default, appears in the site's `/content` index) or `unlisted`. `list` names a newsletter form and is required to email the post on publish. Local image references in the body are auto-uploaded and rewritten to hosted URLs on `push`.
86
+
87
+ ```bash
88
+ # Save posts/hello.md as a draft
89
+ micropage posts push
90
+
91
+ # Publish it (and email the `list:` target, if set)
92
+ micropage posts publish hello
93
+
94
+ # Take it back down (stays as a draft)
95
+ micropage posts unpublish hello
96
+ ```
97
+
98
+ See [`micropage posts`](#posts) below for the full command set.
99
+
66
100
  ## Commands reference
67
101
 
68
102
  ### Auth
@@ -116,6 +150,19 @@ When you create a new project, the CLI also scaffolds:
116
150
  | `micropage copy-link` | Copy the live URL to clipboard |
117
151
  | `micropage open-pricing` | Open the pricing page in the browser |
118
152
 
153
+ ### Posts
154
+
155
+ | Command | Description |
156
+ |---|---|
157
+ | `micropage posts push` | Save local `posts/*.md` files as post drafts (or update already-published posts, which go live immediately) |
158
+ | `micropage posts publish [slug] [-w]` | Publish a post (or all local posts) to the web; sends the newsletter email if the post has a `list:`. Re-running re-sends the email. Auto-queues a rebuild of the site so the `/content` archive updates — no separate `micropage publish` needed. Use `-w` to stream the rebuild's deploy events until it's live. |
159
+ | `micropage posts unpublish <slug>` | Remove a post's `/content/<slug>` page; the post remains as a draft |
160
+ | `micropage posts pull` | Pull remote posts down to local `posts/*.md` files |
161
+ | `micropage posts list` | List the project's posts (slug, title, visibility, published/draft, emailed, send status, created). "Send status" is the newsletter send lifecycle and shows only for email posts — it does not reflect deploy state. |
162
+ | `micropage posts rm <slug>` | Delete a post entirely (remote) |
163
+
164
+ **Note:** `micropage posts publish` publishes a single *post* and automatically rebuilds the site so the post appears on the `/content` archive — you do **not** need to run `micropage publish` afterward. `micropage publish` is for deploying changes to the *site* content (`.page` files) itself.
165
+
119
166
  ### Files
120
167
 
121
168
  | Command | Description |
@@ -163,6 +210,10 @@ The CLI uses one or more `*.page` files in the working directory as the source o
163
210
 
164
211
  **Pulling** always writes to `landing.page` only.
165
212
 
213
+ ### `llms.txt`
214
+
215
+ If the project folder contains a root-level `llms.txt`, its contents are published verbatim and served at `/llms.txt` (a curated map of the site for LLM agents). Remove the local file and re-publish to take it down.
216
+
166
217
  ## Project configuration
167
218
 
168
219
  Each project folder has a `.micropage/project.json` file that stores:
package/bin/micropage.js CHANGED
@@ -1,3 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // Suppress Node's "buffer.File is an experimental feature" warning that fires
4
+ // when undici's FormData serializes a Blob upload. Stabilized in Node 22.
5
+ const origEmit = process.emit;
6
+ process.emit = function (name, data, ...rest) {
7
+ if (
8
+ name === 'warning' &&
9
+ data &&
10
+ data.name === 'ExperimentalWarning' &&
11
+ /buffer\.File/.test(String(data.message))
12
+ ) {
13
+ return false;
14
+ }
15
+ return origEmit.call(this, name, data, ...rest);
16
+ };
17
+
3
18
  require('../src/index.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micropage-sh/cli",
3
- "version": "2.0.7",
3
+ "version": "2.5.0",
4
4
  "description": "CLI for micropage.sh - create, sync, and publish microsites",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "README.md"
14
14
  ],
15
15
  "scripts": {
16
- "test": "node -e \"require('./src/index.js')\"",
16
+ "test": "node -e \"require('./src/index.js')\"; node --test test/",
17
17
  "build:bin": "pkg . --targets node18-linux-x64,node18-macos-x64,node18-macos-arm64 --output dist/micropage",
18
18
  "build:linux": "pkg . --target node18-linux-x64 --output dist/micropage-linux-amd64",
19
19
  "build:macos-amd64": "pkg . --target node18-macos-x64 --output dist/micropage-darwin-amd64",
@@ -57,6 +57,7 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "commander": "^12.0.0",
60
+ "gray-matter": "^4.0.3",
60
61
  "open": "^10.1.0"
61
62
  },
62
63
  "devDependencies": {
@@ -14,7 +14,7 @@ const {
14
14
  uploadAssetsWithToken,
15
15
  } = require('../supabase');
16
16
  const { getProjectConfig, setProjectConfig } = require('../auth');
17
- const { readPageFilesFromDir } = require('../parser');
17
+ const { readPageFilesFromDir, readLlmsTxtFromDir } = require('../parser');
18
18
  const { BUILD_COMPILER_URL } = require('../config');
19
19
  const { formatTable, formatDate } = require('../utils');
20
20
 
@@ -167,6 +167,13 @@ async function push(options = {}) {
167
167
  process.exit(1);
168
168
  }
169
169
 
170
+ const llmsTxt = readLlmsTxtFromDir(cwd);
171
+ if (llmsTxt != null) {
172
+ jsonContent.site = jsonContent.site || {};
173
+ jsonContent.site.llms_txt = llmsTxt;
174
+ console.log(`Including llms.txt (${Buffer.byteLength(llmsTxt, 'utf8')} bytes).`);
175
+ }
176
+
170
177
  // Decide whether to update existing draft or create new build
171
178
  let existingBuild = null;
172
179
  if (config.buildId) {
@@ -4,6 +4,25 @@ const { db, handleAuthError } = require('../supabase');
4
4
  const { getProjectConfig } = require('../auth');
5
5
  const { formatTable, formatDate } = require('../utils');
6
6
 
7
+ const FORM_COLUMNS = 'id,form_name,page_url,is_footer,created_at';
8
+
9
+ /** Occurrence number among same-named forms on the same page, 1-based. */
10
+ function formLabel(form) {
11
+ const ordinal = Number(form.form_ordinal) || 0;
12
+ return ordinal > 0 ? `${form.form_name} (${ordinal + 1})` : form.form_name;
13
+ }
14
+
15
+ function fetchForms(projectId, withOrdinal) {
16
+ const q = db
17
+ .from('forms')
18
+ .select(withOrdinal ? `${FORM_COLUMNS},form_ordinal` : FORM_COLUMNS)
19
+ .eq('project_id', projectId);
20
+
21
+ return withOrdinal
22
+ ? q.order('page_url,form_ordinal', 'asc').get()
23
+ : q.order('created_at', 'asc').get();
24
+ }
25
+
7
26
  async function list(options = {}) {
8
27
  const cwd = process.cwd();
9
28
  const config = getProjectConfig(cwd);
@@ -14,16 +33,18 @@ async function list(options = {}) {
14
33
 
15
34
  let forms;
16
35
  try {
17
- forms = await db
18
- .from('forms')
19
- .select('id,form_name,page_url,is_footer,created_at')
20
- .eq('project_id', config.projectId)
21
- .order('created_at', 'asc')
22
- .get();
36
+ forms = await fetchForms(config.projectId, true);
23
37
  } catch (err) {
24
38
  handleAuthError(err);
25
- console.error('Failed to list forms:', err.message);
26
- process.exit(1);
39
+ // form_ordinal is newer than some deployed databases; selecting a column
40
+ // that does not exist fails the whole request, so retry the older shape.
41
+ try {
42
+ forms = await fetchForms(config.projectId, false);
43
+ } catch (retryErr) {
44
+ handleAuthError(retryErr);
45
+ console.error('Failed to list forms:', retryErr.message);
46
+ process.exit(1);
47
+ }
27
48
  }
28
49
 
29
50
  if (!Array.isArray(forms) || forms.length === 0) {
@@ -53,13 +74,17 @@ async function list(options = {}) {
53
74
  }
54
75
 
55
76
  if (options.json) {
56
- const out = forms.map((f) => ({ ...f, submission_count: counts[f.id] || 0 }));
77
+ const out = forms.map((f) => ({
78
+ ...f,
79
+ form_ordinal: Number(f.form_ordinal) || 0,
80
+ submission_count: counts[f.id] || 0,
81
+ }));
57
82
  console.log(JSON.stringify(out, null, 2));
58
83
  return;
59
84
  }
60
85
 
61
86
  const rows = forms.map((f) => [
62
- f.form_name,
87
+ formLabel(f),
63
88
  f.page_url,
64
89
  f.is_footer ? 'footer' : 'inline',
65
90
  String(counts[f.id] || 0),
@@ -0,0 +1,625 @@
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
+ getMaxDeployEventIdForBuild,
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
+ async function publish(slugArg, options = {}) {
482
+ const cwd = process.cwd();
483
+ const config = requireProjectConfig(cwd);
484
+
485
+ let targetSlugs;
486
+ if (slugArg) {
487
+ targetSlugs = [slugify(String(slugArg))];
488
+ } else {
489
+ const postsDir = requirePostsDir(cwd);
490
+ targetSlugs = localSlugsFromPostsDir(postsDir);
491
+ if (targetSlugs.length === 0) {
492
+ console.log(`No .md files found in "${POSTS_DIR}/". Nothing to publish.`);
493
+ return;
494
+ }
495
+ }
496
+
497
+ console.warn(
498
+ 'Publishing sends (or re-sends) email to the active subscriber list for any email-configured post.',
499
+ );
500
+
501
+ // Publishing a post makes the publisher auto-rebuild the site's active build so
502
+ // the /content archive picks it up — no separate `micropage publish` needed.
503
+ // Resolve that build up front so we can report accurately and (with --watch)
504
+ // capture an event cursor before the rebuild is queued.
505
+ let activeBuildId = null;
506
+ try {
507
+ const proj = await db
508
+ .from('projects')
509
+ .select('active_build_id')
510
+ .eq('id', config.projectId)
511
+ .single();
512
+ activeBuildId = proj?.active_build_id || null;
513
+ } catch {
514
+ // best-effort; treated as "unknown" below
515
+ }
516
+
517
+ let eventCursor = 0;
518
+ if (options.watch && activeBuildId) {
519
+ try {
520
+ eventCursor = await getMaxDeployEventIdForBuild(activeBuildId);
521
+ } catch {
522
+ eventCursor = 0;
523
+ }
524
+ }
525
+
526
+ let hadError = false;
527
+ let publishedCount = 0;
528
+
529
+ for (const slug of targetSlugs) {
530
+ try {
531
+ const result = await fn.invoke('publish-post', { project_id: config.projectId, slug });
532
+ const bits = [`published_at ${result.published_at}`];
533
+ bits.push(result.emailed ? `emailed ${result.recipient_count} recipient(s)` : 'no email');
534
+ console.log(`"${slug}": ${bits.join(', ')}`);
535
+ publishedCount += 1;
536
+ } catch (err) {
537
+ handleAuthError(err);
538
+ const msg = err.status === 404 ? 'post not found (push it first with "micropage posts push")' : err.message;
539
+ console.error(`"${slug}": publish failed (${msg})`);
540
+ hadError = true;
541
+ }
542
+ }
543
+
544
+ console.log('');
545
+ console.log(`Published ${publishedCount}/${targetSlugs.length} post(s).`);
546
+
547
+ if (publishedCount > 0) {
548
+ if (activeBuildId) {
549
+ console.log(
550
+ 'A site rebuild was queued automatically; the /content archive updates once it deploys (usually a minute or two).',
551
+ );
552
+ } else {
553
+ console.log(
554
+ "Note: this project hasn't been published yet, so the post won't appear until you run 'micropage publish'.",
555
+ );
556
+ }
557
+
558
+ if (options.watch && activeBuildId) {
559
+ console.log('');
560
+ console.log('Build / deploy events:');
561
+ try {
562
+ const accessToken = await getValidAccessToken();
563
+ const { terminalEvent } = await streamDeployEventsUntilDone(
564
+ accessToken,
565
+ config.projectId,
566
+ activeBuildId,
567
+ { afterId: eventCursor },
568
+ );
569
+ if (terminalEvent?.event_type === 'build.failed') {
570
+ const msg =
571
+ (terminalEvent.payload && (terminalEvent.payload.error || terminalEvent.payload.message)) ||
572
+ 'Build failed';
573
+ console.error(msg);
574
+ process.exit(1);
575
+ }
576
+ } catch (streamErr) {
577
+ console.error('Event stream failed:', streamErr.message);
578
+ process.exit(1);
579
+ }
580
+ }
581
+ }
582
+
583
+ if (hadError) process.exit(1);
584
+ }
585
+
586
+ // ---------------------------------------------------------------------------
587
+ // posts unpublish <slug>
588
+ // ---------------------------------------------------------------------------
589
+
590
+ async function unpublish(slug) {
591
+ if (!slug) {
592
+ console.error('Usage: micropage posts unpublish <slug>');
593
+ process.exit(1);
594
+ }
595
+ const cwd = process.cwd();
596
+ const config = requireProjectConfig(cwd);
597
+
598
+ let result;
599
+ try {
600
+ result = await fn.invoke('unpublish-post', { project_id: config.projectId, slug: slugify(String(slug)) });
601
+ } catch (err) {
602
+ handleAuthError(err);
603
+ const msg = err.status === 404 ? 'post not found' : err.message;
604
+ console.error(`Failed to unpublish post: ${msg}`);
605
+ process.exit(1);
606
+ }
607
+
608
+ if (result?.unpublished) {
609
+ console.log(`Unpublished post "${slug}" (removed from the site; remains as a draft).`);
610
+ } else {
611
+ console.log(`No post found with slug "${slug}" — nothing to unpublish.`);
612
+ }
613
+ }
614
+
615
+ module.exports = {
616
+ push,
617
+ pull,
618
+ list,
619
+ rm,
620
+ publish,
621
+ unpublish,
622
+ slugify,
623
+ defaultSlugFromFilename,
624
+ frontMatterFromPost,
625
+ };
@@ -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
 
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
 
@@ -241,4 +242,43 @@ subCmd
241
242
  )
242
243
  .action((opts) => submissions.exportSubmissions(opts));
243
244
 
245
+ // ---------------------------------------------------------------------------
246
+ // Posts (micropage posts <subcommand>)
247
+ // ---------------------------------------------------------------------------
248
+
249
+ const postsCmd = program.command('posts').description('Manage posts (dual-channel web + email content)');
250
+
251
+ postsCmd
252
+ .command('push')
253
+ .description('Save draft/update local posts/*.md as post rows (does not publish or email); reports remote-only posts as drift')
254
+ .action((opts) => posts.push(opts));
255
+
256
+ postsCmd
257
+ .command('pull')
258
+ .description('Write remote posts to local posts/*.md files')
259
+ .option('-f, --force', 'Overwrite existing local files without prompting')
260
+ .action((opts) => posts.pull(opts));
261
+
262
+ postsCmd
263
+ .command('list')
264
+ .description('List posts for the current project')
265
+ .option('--json', 'Output as JSON')
266
+ .action((opts) => posts.list(opts));
267
+
268
+ postsCmd
269
+ .command('publish [slug]')
270
+ .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')
271
+ .option('-w, --watch', 'Stream build/deploy events until the auto-triggered site rebuild deploys')
272
+ .action((slug, opts) => posts.publish(slug, opts));
273
+
274
+ postsCmd
275
+ .command('unpublish <slug>')
276
+ .description('Take a post off the site (does not delete it; it remains as a draft)')
277
+ .action((slug, opts) => posts.unpublish(slug, opts));
278
+
279
+ postsCmd
280
+ .command('rm <slug>')
281
+ .description('Delete a post by slug (remote only; does not touch local files)')
282
+ .action((slug, opts) => posts.rm(slug, opts));
283
+
244
284
  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
@@ -299,6 +299,16 @@ const TERMINAL_DEPLOY_EVENTS = new Set([
299
299
  'archive.failed',
300
300
  ]);
301
301
 
302
+ /**
303
+ * Collapse arbitrary text to a single bounded line. Keeps a Rails/HTML error
304
+ * body or backtrace that leaks into an event payload from flooding the deploy
305
+ * stream (the CLI is not the place to render a server stack trace).
306
+ */
307
+ function truncateForConsole(str, max = 200) {
308
+ const oneLine = String(str).replace(/\s+/g, ' ').trim();
309
+ return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
310
+ }
311
+
302
312
  /**
303
313
  * Human-readable line for deploy stream (falls back to event_type + JSON payload).
304
314
  * @param {{ event_type?: string, payload?: Record<string, unknown> }} ev
@@ -326,14 +336,14 @@ function formatDeployEventForConsole(ev) {
326
336
  case 'active':
327
337
  return `${eventType}: live — ${https}`;
328
338
  case 'failed':
329
- return `${eventType}: failed — ${payload.error || 'unknown'}`;
339
+ return `${eventType}: failed — ${truncateForConsole(payload.error || 'unknown', 160)}`;
330
340
  case 'timeout':
331
341
  return `${eventType}: still pending after wait (try ${https || 'URL'} shortly)`;
332
342
  default:
333
343
  break;
334
344
  }
335
345
  }
336
- const extra = ev.payload ? ` ${JSON.stringify(ev.payload)}` : '';
346
+ const extra = ev.payload ? ` ${truncateForConsole(JSON.stringify(ev.payload))}` : '';
337
347
  return `${eventType}${extra}`;
338
348
  }
339
349
 
@@ -418,7 +428,13 @@ async function streamDeployEventsUntilDone(bearerToken, projectId, buildId, opti
418
428
  }
419
429
  } catch (e) {
420
430
  if (e instanceof SyntaxError) {
421
- console.log(json);
431
+ // A non-JSON data line usually means an HTML/error body leaked into
432
+ // the stream — summarize it instead of dumping the whole page.
433
+ if (/^\s*</.test(json)) {
434
+ console.log('[stream] skipped non-JSON response chunk');
435
+ } else {
436
+ console.log(truncateForConsole(json));
437
+ }
422
438
  } else {
423
439
  throw e;
424
440
  }
@@ -437,6 +453,12 @@ async function streamDeployEventsUntilDone(bearerToken, projectId, buildId, opti
437
453
  return { terminalEvent };
438
454
  }
439
455
 
456
+ function hashFile(filePath) {
457
+ const crypto = require('crypto');
458
+ const fs = require('fs');
459
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
460
+ }
461
+
440
462
  /**
441
463
  * Upload a single asset file to the project via the upload-file Edge function.
442
464
  * Uses a pre-obtained Supabase access token.
@@ -447,10 +469,12 @@ async function uploadAssetWithToken(accessToken, projectId, filePath, filename)
447
469
 
448
470
  const bytes = fs.readFileSync(filePath);
449
471
  const mimeType = mime.fromFilename(filename);
472
+ const sha = hashFile(filePath);
450
473
 
451
474
  const formData = new FormData();
452
475
  formData.append('file', new Blob([bytes], { type: mimeType }), filename);
453
476
  formData.append('project_id', String(projectId));
477
+ formData.append('content_hash', sha);
454
478
 
455
479
  const res = await fetch(`${SUPABASE_URL}/functions/v1/upload-file`, {
456
480
  method: 'POST',
@@ -473,9 +497,33 @@ async function uploadAssetWithToken(accessToken, projectId, filePath, filename)
473
497
  return data;
474
498
  }
475
499
 
500
+ async function deleteFileWithToken(accessToken, fileId) {
501
+ const res = await fetch(`${SUPABASE_URL}/functions/v1/delete-file`, {
502
+ method: 'POST',
503
+ headers: {
504
+ 'apikey': SUPABASE_ANON_KEY,
505
+ 'Authorization': `Bearer ${accessToken}`,
506
+ 'Content-Type': 'application/json',
507
+ },
508
+ body: JSON.stringify({ file_id: fileId }),
509
+ });
510
+ const text = await res.text();
511
+ let data = null;
512
+ try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
513
+ if (!res.ok) {
514
+ const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`;
515
+ const err = new Error(msg);
516
+ err.status = res.status;
517
+ throw err;
518
+ }
519
+ return data;
520
+ }
521
+
476
522
  /**
477
523
  * Upload all assets from the local `assets/` directory for a project.
478
- * Skips files that already exist on the server (matched by filename).
524
+ * Compares each local file's SHA-256 hash against the server's stored hash.
525
+ * Skips files whose content is unchanged. Deletes and re-uploads files whose
526
+ * content has changed (or whose server hash is NULL from before hashing was added).
479
527
  * Returns the count of files uploaded.
480
528
  */
481
529
  async function uploadAssetsWithToken(accessToken, projectId, cwd, onProgress) {
@@ -485,7 +533,9 @@ async function uploadAssetsWithToken(accessToken, projectId, cwd, onProgress) {
485
533
  const assetFiles = listAssetsFromDir(cwd);
486
534
  if (assetFiles.length === 0) return 0;
487
535
 
488
- // Fetch existing filenames so we can skip unchanged files
536
+ // Fetch existing files so we can skip unchanged ones. We MUST treat a
537
+ // non-OK response as fatal: continuing with an empty list would re-upload
538
+ // every asset and (when filenames collide) leave duplicate user_files rows.
489
539
  const listRes = await fetch(
490
540
  `${SUPABASE_URL}/functions/v1/list-files?project_id=${encodeURIComponent(projectId)}`,
491
541
  {
@@ -495,13 +545,28 @@ async function uploadAssetsWithToken(accessToken, projectId, cwd, onProgress) {
495
545
  },
496
546
  },
497
547
  );
498
- const listData = listRes.ok ? await listRes.json() : null;
499
- const existing = new Set((listData?.files || []).map((f) => f.filename));
548
+ if (!listRes.ok) {
549
+ const text = await listRes.text().catch(() => '');
550
+ const err = new Error(`Failed to list project files: HTTP ${listRes.status}${text ? ` — ${text}` : ''}`);
551
+ err.status = listRes.status;
552
+ throw err;
553
+ }
554
+ const listData = await listRes.json();
555
+ const existingByName = new Map(
556
+ (listData?.files || []).map((f) => [f.filename, { id: f.id, content_hash: f.content_hash }]),
557
+ );
500
558
 
501
559
  let uploaded = 0;
502
560
  for (const filename of assetFiles) {
503
- if (existing.has(filename)) continue;
504
561
  const filePath = path.join(cwd, 'assets', filename);
562
+ const localHash = hashFile(filePath);
563
+ const existing = existingByName.get(filename);
564
+
565
+ if (existing && existing.content_hash === localHash) continue;
566
+
567
+ if (existing) {
568
+ await deleteFileWithToken(accessToken, existing.id);
569
+ }
505
570
  await uploadAssetWithToken(accessToken, projectId, filePath, filename);
506
571
  if (onProgress) onProgress(filename);
507
572
  uploaded++;
@@ -645,4 +710,5 @@ module.exports = {
645
710
  pushWithToken,
646
711
  invokePublishBuild,
647
712
  uploadAssetsWithToken,
713
+ hashFile,
648
714
  };
@@ -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.