@micropage-sh/cli 2.0.8 → 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 +51 -0
- package/bin/micropage.js +15 -0
- package/package.json +3 -2
- package/src/commands/builds.js +8 -1
- package/src/commands/forms.js +35 -10
- package/src/commands/posts.js +625 -0
- package/src/commands/projects.js +1 -0
- package/src/index.js +40 -0
- package/src/parser.js +16 -0
- package/src/posts-assets.js +211 -0
- package/src/supabase.js +19 -3
- package/templates/PROJECT_AGENT.template.md +53 -4
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
|
|
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": {
|
package/src/commands/builds.js
CHANGED
|
@@ -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) {
|
package/src/commands/forms.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
26
|
-
|
|
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) => ({
|
|
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
|
|
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
|
+
};
|
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
|
|
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: . 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 (``), 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 ? `` : 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
|
-
|
|
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
|
}
|
|
@@ -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 `` 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
|
|
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://
|
|
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
|
|
80
|
+
You can use the examples in this project as concrete references when generating or modifying `.page` content.
|