@micropage-sh/cli 2.0.8 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -4
- package/bin/micropage.js +15 -0
- package/package.json +3 -2
- package/src/commands/builds.js +8 -1
- package/src/commands/forms.js +37 -11
- package/src/commands/posts.js +646 -0
- package/src/commands/projects.js +1 -0
- package/src/commands/submissions.js +50 -25
- package/src/index.js +45 -3
- package/src/parser.js +16 -0
- package/src/posts-assets.js +211 -0
- package/src/supabase.js +36 -3
- package/templates/PROJECT_AGENT.template.md +53 -4
|
@@ -13,6 +13,11 @@ function formatCliFieldValue(value) {
|
|
|
13
13
|
return String(value);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** Spam is `flagged_at IS NOT NULL`; the inbox is everything else. */
|
|
17
|
+
function applySpamFilter(query, spam) {
|
|
18
|
+
return spam ? query.not('flagged_at', 'is', 'null') : query.is('flagged_at', 'null');
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
async function list(options = {}) {
|
|
17
22
|
const cwd = process.cwd();
|
|
18
23
|
const config = getProjectConfig(cwd);
|
|
@@ -21,12 +26,17 @@ async function list(options = {}) {
|
|
|
21
26
|
process.exit(1);
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
const spam = Boolean(options.spam);
|
|
30
|
+
const columns = spam
|
|
31
|
+
? 'id,form_id,form_name,page_url,created_at,form_index,spam_reason,flagged_at,flagged_by'
|
|
32
|
+
: 'id,form_id,form_name,page_url,created_at,form_index';
|
|
33
|
+
|
|
24
34
|
let submissions;
|
|
25
35
|
try {
|
|
26
|
-
submissions = await
|
|
27
|
-
.from('form_submissions')
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
submissions = await applySpamFilter(
|
|
37
|
+
db.from('form_submissions').select(columns).eq('project_id', config.projectId),
|
|
38
|
+
spam,
|
|
39
|
+
)
|
|
30
40
|
.order('created_at', 'desc')
|
|
31
41
|
.get();
|
|
32
42
|
} catch (err) {
|
|
@@ -36,7 +46,7 @@ async function list(options = {}) {
|
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
if (!Array.isArray(submissions) || submissions.length === 0) {
|
|
39
|
-
console.log('No form submissions.');
|
|
49
|
+
console.log(spam ? 'No spam submissions.' : 'No form submissions.');
|
|
40
50
|
return;
|
|
41
51
|
}
|
|
42
52
|
|
|
@@ -45,14 +55,20 @@ async function list(options = {}) {
|
|
|
45
55
|
return;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
const rows = submissions.map((s) =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
const rows = submissions.map((s) => {
|
|
59
|
+
const row = [
|
|
60
|
+
s.id.slice(0, 8) + '…',
|
|
61
|
+
s.form_id ? s.form_id.slice(0, 8) + '…' : '-',
|
|
62
|
+
s.form_name || '-',
|
|
63
|
+
s.page_url || '-',
|
|
64
|
+
formatDate(s.created_at),
|
|
65
|
+
];
|
|
66
|
+
if (spam) row.push(s.spam_reason || '-');
|
|
67
|
+
return row;
|
|
68
|
+
});
|
|
69
|
+
const headers = ['ID (short)', 'Form ID', 'Form', 'Page URL', 'Submitted'];
|
|
70
|
+
if (spam) headers.push('Spam reason');
|
|
71
|
+
formatTable(rows, headers);
|
|
56
72
|
}
|
|
57
73
|
|
|
58
74
|
async function show(id, options = {}) {
|
|
@@ -85,6 +101,9 @@ async function show(id, options = {}) {
|
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
console.log('ID: ', submission.id);
|
|
104
|
+
if (submission.flagged_at) {
|
|
105
|
+
console.log('Spam: ', submission.spam_reason || 'yes');
|
|
106
|
+
}
|
|
88
107
|
console.log('Form ID: ', submission.form_id || '-');
|
|
89
108
|
console.log('Form: ', submission.form_name || '-');
|
|
90
109
|
console.log('Page URL: ', submission.page_url || '-');
|
|
@@ -112,12 +131,14 @@ async function exportSubmissions(options = {}) {
|
|
|
112
131
|
process.exit(1);
|
|
113
132
|
}
|
|
114
133
|
|
|
134
|
+
const spam = Boolean(options.spam);
|
|
135
|
+
|
|
115
136
|
let submissions;
|
|
116
137
|
try {
|
|
117
|
-
submissions = await
|
|
118
|
-
.from('form_submissions')
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
submissions = await applySpamFilter(
|
|
139
|
+
db.from('form_submissions').select('*').eq('project_id', config.projectId),
|
|
140
|
+
spam,
|
|
141
|
+
)
|
|
121
142
|
.order('created_at', 'desc')
|
|
122
143
|
.get();
|
|
123
144
|
} catch (err) {
|
|
@@ -127,7 +148,7 @@ async function exportSubmissions(options = {}) {
|
|
|
127
148
|
}
|
|
128
149
|
|
|
129
150
|
if (!Array.isArray(submissions) || submissions.length === 0) {
|
|
130
|
-
console.log('No form submissions to export.');
|
|
151
|
+
console.log(spam ? 'No spam submissions to export.' : 'No form submissions to export.');
|
|
131
152
|
return;
|
|
132
153
|
}
|
|
133
154
|
|
|
@@ -137,7 +158,7 @@ async function exportSubmissions(options = {}) {
|
|
|
137
158
|
process.exit(1);
|
|
138
159
|
}
|
|
139
160
|
|
|
140
|
-
const defaultName = `submissions.${format}`;
|
|
161
|
+
const defaultName = spam ? `submissions-spam.${format}` : `submissions.${format}`;
|
|
141
162
|
const outFile = options.output
|
|
142
163
|
? path.isAbsolute(options.output)
|
|
143
164
|
? options.output
|
|
@@ -158,6 +179,7 @@ async function exportSubmissions(options = {}) {
|
|
|
158
179
|
'build_id',
|
|
159
180
|
'payload_json',
|
|
160
181
|
];
|
|
182
|
+
if (spam) header.push('spam_reason');
|
|
161
183
|
const escapeCell = (value) => {
|
|
162
184
|
if (value === null || value === undefined) return '';
|
|
163
185
|
const str = String(value);
|
|
@@ -168,8 +190,8 @@ async function exportSubmissions(options = {}) {
|
|
|
168
190
|
};
|
|
169
191
|
const lines = [
|
|
170
192
|
header.join(','),
|
|
171
|
-
...submissions.map((s) =>
|
|
172
|
-
[
|
|
193
|
+
...submissions.map((s) => {
|
|
194
|
+
const cells = [
|
|
173
195
|
s.id,
|
|
174
196
|
s.created_at,
|
|
175
197
|
s.page_url || '',
|
|
@@ -178,8 +200,10 @@ async function exportSubmissions(options = {}) {
|
|
|
178
200
|
s.form_index ?? '',
|
|
179
201
|
s.build_id || '',
|
|
180
202
|
JSON.stringify(s.payload || {}),
|
|
181
|
-
]
|
|
182
|
-
|
|
203
|
+
];
|
|
204
|
+
if (spam) cells.push(s.spam_reason || '');
|
|
205
|
+
return cells.map(escapeCell).join(',');
|
|
206
|
+
}),
|
|
183
207
|
];
|
|
184
208
|
fs.writeFileSync(outFile, lines.join('\n'), 'utf8');
|
|
185
209
|
}
|
|
@@ -188,7 +212,8 @@ async function exportSubmissions(options = {}) {
|
|
|
188
212
|
process.exit(1);
|
|
189
213
|
}
|
|
190
214
|
|
|
191
|
-
|
|
215
|
+
const noun = spam ? 'spam submission(s)' : 'submission(s)';
|
|
216
|
+
console.log(`Exported ${submissions.length} ${noun} → ${outFile}`);
|
|
192
217
|
}
|
|
193
218
|
|
|
194
|
-
module.exports = { list, show, exportSubmissions };
|
|
219
|
+
module.exports = { list, show, exportSubmissions, applySpamFilter };
|
package/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const submissions = require('./commands/submissions');
|
|
|
13
13
|
const forms = require('./commands/forms');
|
|
14
14
|
const files = require('./commands/files');
|
|
15
15
|
const links = require('./commands/links');
|
|
16
|
+
const posts = require('./commands/posts');
|
|
16
17
|
|
|
17
18
|
const program = new Command();
|
|
18
19
|
|
|
@@ -218,8 +219,9 @@ const subCmd = program.command('submissions').description('View form submissions
|
|
|
218
219
|
|
|
219
220
|
subCmd
|
|
220
221
|
.command('list')
|
|
221
|
-
.description('List form submissions for the current project')
|
|
222
|
+
.description('List form submissions for the current project (spam excluded)')
|
|
222
223
|
.option('--json', 'Output as JSON')
|
|
224
|
+
.option('--spam', 'List only submissions flagged as spam')
|
|
223
225
|
.action((opts) => submissions.list(opts));
|
|
224
226
|
|
|
225
227
|
subCmd
|
|
@@ -230,15 +232,55 @@ subCmd
|
|
|
230
232
|
|
|
231
233
|
subCmd
|
|
232
234
|
.command('export')
|
|
233
|
-
.description('Export form submissions for the current project to a file')
|
|
235
|
+
.description('Export form submissions for the current project to a file (spam excluded)')
|
|
236
|
+
.option('--spam', 'Export only submissions flagged as spam')
|
|
234
237
|
.option(
|
|
235
238
|
'-f, --format <format>',
|
|
236
239
|
'Output format: csv or json (default: csv)',
|
|
237
240
|
)
|
|
238
241
|
.option(
|
|
239
242
|
'-o, --output <file>',
|
|
240
|
-
'Output file path (default: submissions.<format>)',
|
|
243
|
+
'Output file path (default: submissions.<format>, or submissions-spam.<format> with --spam)',
|
|
241
244
|
)
|
|
242
245
|
.action((opts) => submissions.exportSubmissions(opts));
|
|
243
246
|
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Posts (micropage posts <subcommand>)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
const postsCmd = program.command('posts').description('Manage posts (dual-channel web + email content)');
|
|
252
|
+
|
|
253
|
+
postsCmd
|
|
254
|
+
.command('push')
|
|
255
|
+
.description('Save draft/update local posts/*.md as post rows (does not publish or email); reports remote-only posts as drift')
|
|
256
|
+
.action((opts) => posts.push(opts));
|
|
257
|
+
|
|
258
|
+
postsCmd
|
|
259
|
+
.command('pull')
|
|
260
|
+
.description('Write remote posts to local posts/*.md files')
|
|
261
|
+
.option('-f, --force', 'Overwrite existing local files without prompting')
|
|
262
|
+
.action((opts) => posts.pull(opts));
|
|
263
|
+
|
|
264
|
+
postsCmd
|
|
265
|
+
.command('list')
|
|
266
|
+
.description('List posts for the current project')
|
|
267
|
+
.option('--json', 'Output as JSON')
|
|
268
|
+
.action((opts) => posts.list(opts));
|
|
269
|
+
|
|
270
|
+
postsCmd
|
|
271
|
+
.command('publish [slug]')
|
|
272
|
+
.description('Publish one post by slug, or all local posts if omitted; (re)sends email for email-configured posts. Auto-triggers a site rebuild so the /content archive updates')
|
|
273
|
+
.option('-w, --watch', 'Stream build/deploy events until the auto-triggered site rebuild deploys')
|
|
274
|
+
.action((slug, opts) => posts.publish(slug, opts));
|
|
275
|
+
|
|
276
|
+
postsCmd
|
|
277
|
+
.command('unpublish <slug>')
|
|
278
|
+
.description('Take a post off the site (does not delete it; it remains as a draft)')
|
|
279
|
+
.action((slug, opts) => posts.unpublish(slug, opts));
|
|
280
|
+
|
|
281
|
+
postsCmd
|
|
282
|
+
.command('rm <slug>')
|
|
283
|
+
.description('Delete a post by slug (remote only; does not touch local files)')
|
|
284
|
+
.action((slug, opts) => posts.rm(slug, opts));
|
|
285
|
+
|
|
244
286
|
program.parse();
|
package/src/parser.js
CHANGED
|
@@ -48,6 +48,21 @@ function writePageFile(dir, content) {
|
|
|
48
48
|
return dest;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Read a root-level llms.txt from the given directory.
|
|
53
|
+
*
|
|
54
|
+
* Returns the file's UTF-8 content verbatim, or null if the file is absent or
|
|
55
|
+
* whitespace-only. Content is preserved as-is so authors control the served
|
|
56
|
+
* `/llms.txt` exactly (only a trailing newline is dropped).
|
|
57
|
+
*/
|
|
58
|
+
function readLlmsTxtFromDir(dir) {
|
|
59
|
+
const file = path.join(dir, 'llms.txt');
|
|
60
|
+
if (!fs.existsSync(file)) return null;
|
|
61
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
62
|
+
if (!content.trim()) return null;
|
|
63
|
+
return content.replace(/\n$/, '');
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
function listAssetsFromDir(dir) {
|
|
52
67
|
const assetsDir = path.join(dir, 'assets');
|
|
53
68
|
if (!fs.existsSync(assetsDir)) return [];
|
|
@@ -65,4 +80,5 @@ module.exports = {
|
|
|
65
80
|
readPageFilesFromDir,
|
|
66
81
|
writePageFile,
|
|
67
82
|
listAssetsFromDir,
|
|
83
|
+
readLlmsTxtFromDir,
|
|
68
84
|
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Asset resolution helpers for `micropage posts push`.
|
|
5
|
+
*
|
|
6
|
+
* Reuses the exact same asset pipeline the editor uses: `upload-file` (via
|
|
7
|
+
* uploadAssetWithToken + hashFile + list-files dedup) to store the image, then
|
|
8
|
+
* `get-file-url` to resolve its URL. Nothing here is CLI-specific — it mirrors
|
|
9
|
+
* the editor's file-manager / image-picker flow.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const { fn, hashFile, uploadAssetWithToken } = require('./supabase');
|
|
16
|
+
|
|
17
|
+
const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
|
|
18
|
+
|
|
19
|
+
function isAbsoluteUrl(s) {
|
|
20
|
+
return typeof s === 'string' && /^https?:\/\//i.test(s.trim());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Find a companion image next to a post file: `<postbasename>.<imgext>`,
|
|
25
|
+
* e.g. `posts/2026-01-01-launch.md` -> `posts/2026-01-01-launch.png`.
|
|
26
|
+
*/
|
|
27
|
+
function findCompanionImage(postFilePath) {
|
|
28
|
+
const dir = path.dirname(postFilePath);
|
|
29
|
+
const base = path.basename(postFilePath, path.extname(postFilePath));
|
|
30
|
+
for (const ext of IMAGE_EXTS) {
|
|
31
|
+
const candidate = path.join(dir, `${base}${ext}`);
|
|
32
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fetch the project's remote file list once and expose lookup-by-filename.
|
|
41
|
+
* Callers should fetch this once per push and reuse it across posts.
|
|
42
|
+
*/
|
|
43
|
+
async function fetchRemoteFileIndex(projectId) {
|
|
44
|
+
const data = await fn.invokeGet('list-files', { project_id: projectId });
|
|
45
|
+
const files = data?.files || [];
|
|
46
|
+
const byFilename = new Map();
|
|
47
|
+
const byHash = new Map();
|
|
48
|
+
for (const f of files) {
|
|
49
|
+
if (f.filename) byFilename.set(f.filename, f);
|
|
50
|
+
if (f.content_hash) byHash.set(f.content_hash, f);
|
|
51
|
+
}
|
|
52
|
+
return { byFilename, byHash };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a `file_id` to its URL via the `get-file-url` edge function — the same
|
|
57
|
+
* call the editor's image picker makes.
|
|
58
|
+
*/
|
|
59
|
+
async function fileUrlFor(fileId) {
|
|
60
|
+
const data = await fn.invokeGet('get-file-url', { file_id: fileId });
|
|
61
|
+
const url = data?.url;
|
|
62
|
+
if (!url) throw new Error(`get-file-url returned no url for file_id=${fileId}`);
|
|
63
|
+
return url;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Upload a local file if its content hash isn't already present remotely
|
|
68
|
+
* (dedup, mirrors uploadAssetsWithToken), then resolve its absolute URL.
|
|
69
|
+
* Mutates `fileIndex` in place so repeated calls within the same push reuse it.
|
|
70
|
+
*/
|
|
71
|
+
async function uploadLocalImageOnce(accessToken, projectId, filePath, fileIndex) {
|
|
72
|
+
const filename = path.basename(filePath);
|
|
73
|
+
const localHash = hashFile(filePath);
|
|
74
|
+
|
|
75
|
+
const existingByHash = fileIndex.byHash.get(localHash);
|
|
76
|
+
if (existingByHash) {
|
|
77
|
+
return { fileId: existingByHash.id, uploaded: false, filename: existingByHash.filename };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const uploadResult = await uploadAssetWithToken(accessToken, projectId, filePath, filename);
|
|
81
|
+
const file = uploadResult?.file;
|
|
82
|
+
if (!file?.id) throw new Error(`upload-file returned no file record for ${filename}`);
|
|
83
|
+
|
|
84
|
+
fileIndex.byFilename.set(file.filename, file);
|
|
85
|
+
if (file.content_hash) fileIndex.byHash.set(file.content_hash, file);
|
|
86
|
+
|
|
87
|
+
return { fileId: file.id, uploaded: true, filename: file.filename };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a post's hero image to an absolute URL.
|
|
92
|
+
*
|
|
93
|
+
* Priority: companion file next to the .md > front-matter `hero:` > none.
|
|
94
|
+
* `hero:` may be:
|
|
95
|
+
* - an absolute http(s) URL (passthrough, no upload)
|
|
96
|
+
* - a local file path (relative to the post file, or to assets/) that exists on disk (upload)
|
|
97
|
+
* - an existing uploaded asset filename (resolve via list-files, no upload)
|
|
98
|
+
*
|
|
99
|
+
* @returns {Promise<{ url: string|null, uploaded: boolean, source: string|null }>}
|
|
100
|
+
*/
|
|
101
|
+
async function resolveHeroImage({ accessToken, projectId, postFilePath, cwd, heroFrontMatter, fileIndex }) {
|
|
102
|
+
const companion = findCompanionImage(postFilePath);
|
|
103
|
+
if (companion) {
|
|
104
|
+
const { fileId, uploaded, filename } = await uploadLocalImageOnce(accessToken, projectId, companion, fileIndex);
|
|
105
|
+
const url = await fileUrlFor(fileId);
|
|
106
|
+
return { url, uploaded, source: `companion:${filename}` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const hero = typeof heroFrontMatter === 'string' ? heroFrontMatter.trim() : '';
|
|
110
|
+
if (!hero) return { url: null, uploaded: false, source: null };
|
|
111
|
+
|
|
112
|
+
if (isAbsoluteUrl(hero)) {
|
|
113
|
+
return { url: hero, uploaded: false, source: 'url' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Local file path: relative to the post file's directory, then to assets/, then to cwd.
|
|
117
|
+
const candidates = [
|
|
118
|
+
path.isAbsolute(hero) ? hero : path.join(path.dirname(postFilePath), hero),
|
|
119
|
+
path.join(cwd, 'assets', hero),
|
|
120
|
+
path.join(cwd, hero),
|
|
121
|
+
];
|
|
122
|
+
const localPath = candidates.find((p) => fs.existsSync(p) && fs.statSync(p).isFile());
|
|
123
|
+
if (localPath) {
|
|
124
|
+
const { fileId, uploaded, filename } = await uploadLocalImageOnce(accessToken, projectId, localPath, fileIndex);
|
|
125
|
+
const url = await fileUrlFor(fileId);
|
|
126
|
+
return { url, uploaded, source: `local:${filename}` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Existing uploaded asset, referenced by filename only.
|
|
130
|
+
const existing = fileIndex.byFilename.get(hero) || fileIndex.byFilename.get(path.basename(hero));
|
|
131
|
+
if (existing) {
|
|
132
|
+
const url = await fileUrlFor(existing.id);
|
|
133
|
+
return { url, uploaded: false, source: `existing:${existing.filename}` };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw new Error(`hero image not found: "${hero}" (not a URL, local file, or existing uploaded asset)`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Matches markdown image refs: . 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
|
@@ -207,6 +207,8 @@ function buildQuery(table) {
|
|
|
207
207
|
select(cols) { _select = cols; return q; },
|
|
208
208
|
eq(col, val) { filters.push(`${col}=eq.${val}`); return q; },
|
|
209
209
|
in(col, vals) { filters.push(`${col}=in.(${vals.join(',')})`); return q; },
|
|
210
|
+
is(col, val) { filters.push(`${col}=is.${val}`); return q; },
|
|
211
|
+
not(col, op, val) { filters.push(`${col}=not.${op}.${val}`); return q; },
|
|
210
212
|
order(col, dir = 'asc') { _order = `${col}.${dir}`; return q; },
|
|
211
213
|
limit(n) { _limit = n; return q; },
|
|
212
214
|
|
|
@@ -299,6 +301,16 @@ const TERMINAL_DEPLOY_EVENTS = new Set([
|
|
|
299
301
|
'archive.failed',
|
|
300
302
|
]);
|
|
301
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Collapse arbitrary text to a single bounded line. Keeps a Rails/HTML error
|
|
306
|
+
* body or backtrace that leaks into an event payload from flooding the deploy
|
|
307
|
+
* stream (the CLI is not the place to render a server stack trace).
|
|
308
|
+
*/
|
|
309
|
+
function truncateForConsole(str, max = 200) {
|
|
310
|
+
const oneLine = String(str).replace(/\s+/g, ' ').trim();
|
|
311
|
+
return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
|
|
312
|
+
}
|
|
313
|
+
|
|
302
314
|
/**
|
|
303
315
|
* Human-readable line for deploy stream (falls back to event_type + JSON payload).
|
|
304
316
|
* @param {{ event_type?: string, payload?: Record<string, unknown> }} ev
|
|
@@ -326,14 +338,14 @@ function formatDeployEventForConsole(ev) {
|
|
|
326
338
|
case 'active':
|
|
327
339
|
return `${eventType}: live — ${https}`;
|
|
328
340
|
case 'failed':
|
|
329
|
-
return `${eventType}: failed — ${payload.error || 'unknown'}`;
|
|
341
|
+
return `${eventType}: failed — ${truncateForConsole(payload.error || 'unknown', 160)}`;
|
|
330
342
|
case 'timeout':
|
|
331
343
|
return `${eventType}: still pending after wait (try ${https || 'URL'} shortly)`;
|
|
332
344
|
default:
|
|
333
345
|
break;
|
|
334
346
|
}
|
|
335
347
|
}
|
|
336
|
-
const extra = ev.payload ? ` ${JSON.stringify(ev.payload)}` : '';
|
|
348
|
+
const extra = ev.payload ? ` ${truncateForConsole(JSON.stringify(ev.payload))}` : '';
|
|
337
349
|
return `${eventType}${extra}`;
|
|
338
350
|
}
|
|
339
351
|
|
|
@@ -353,6 +365,20 @@ async function getMaxDeployEventIdForBuild(buildId, accessToken = null) {
|
|
|
353
365
|
return Number.isFinite(n) ? n : 0;
|
|
354
366
|
}
|
|
355
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Highest build_deploy_events.id across the project, 0 when none. A cursor
|
|
370
|
+
* taken before a post change, when the build the server will rebuild is not
|
|
371
|
+
* known yet: event ids are global and the stream filters by build.
|
|
372
|
+
*/
|
|
373
|
+
async function getMaxDeployEventIdForProject(projectId) {
|
|
374
|
+
const url = `${SUPABASE_URL}/rest/v1/build_deploy_events?select=id&project_id=eq.${encodeURIComponent(String(projectId))}&order=id.desc&limit=1`;
|
|
375
|
+
const data = await request('GET', url, null);
|
|
376
|
+
const row = Array.isArray(data) ? data[0] : null;
|
|
377
|
+
if (!row || row.id == null) return 0;
|
|
378
|
+
const n = Number(row.id);
|
|
379
|
+
return Number.isFinite(n) ? n : 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
356
382
|
/**
|
|
357
383
|
* Stream build/deploy/archive events until a terminal event or the connection closes.
|
|
358
384
|
* @param {string} bearerToken
|
|
@@ -418,7 +444,13 @@ async function streamDeployEventsUntilDone(bearerToken, projectId, buildId, opti
|
|
|
418
444
|
}
|
|
419
445
|
} catch (e) {
|
|
420
446
|
if (e instanceof SyntaxError) {
|
|
421
|
-
|
|
447
|
+
// A non-JSON data line usually means an HTML/error body leaked into
|
|
448
|
+
// the stream — summarize it instead of dumping the whole page.
|
|
449
|
+
if (/^\s*</.test(json)) {
|
|
450
|
+
console.log('[stream] skipped non-JSON response chunk');
|
|
451
|
+
} else {
|
|
452
|
+
console.log(truncateForConsole(json));
|
|
453
|
+
}
|
|
422
454
|
} else {
|
|
423
455
|
throw e;
|
|
424
456
|
}
|
|
@@ -689,6 +721,7 @@ module.exports = {
|
|
|
689
721
|
handleAuthError,
|
|
690
722
|
getValidAccessToken,
|
|
691
723
|
getMaxDeployEventIdForBuild,
|
|
724
|
+
getMaxDeployEventIdForProject,
|
|
692
725
|
streamDeployEventsUntilDone,
|
|
693
726
|
exchangeDeployTokenForAccessToken,
|
|
694
727
|
pushWithToken,
|
|
@@ -13,11 +13,59 @@ This project was created with the Micropage CLI and is designed to be friendly t
|
|
|
13
13
|
- `components-hero-variants.page` — multiple hero section variants (single CTA, image + copy, centered hero).
|
|
14
14
|
- `components-pricing-and-forms.page` — pricing table (three tiers) and a richer contact/quote form, including an `img: <- pricing-cards` example.
|
|
15
15
|
- `assets/logo.svg` and `assets/favicon.svg` — default logo and favicon for new projects (uploaded on push). `.page` files reference the stored filename: `logo: <- logo.svg` / `favicon: <- favicon.svg`.
|
|
16
|
+
- `posts/` — post files (blog/newsletter content), one Markdown file per post. See "Posts" below.
|
|
17
|
+
|
|
18
|
+
## Posts
|
|
19
|
+
|
|
20
|
+
Each file in `posts/*.md` is one post: YAML front-matter + a Markdown body, pushed with `micropage posts push`.
|
|
21
|
+
|
|
22
|
+
```markdown
|
|
23
|
+
---
|
|
24
|
+
title: Launching our new dashboard
|
|
25
|
+
slug: launching-new-dashboard # optional; defaults to the filename minus a leading date prefix and .md
|
|
26
|
+
description: A quick look at what's new.
|
|
27
|
+
visibility: listed # listed | unlisted | none (default: listed)
|
|
28
|
+
hero: launch-hero.png # optional; local file, existing uploaded asset filename, or absolute URL
|
|
29
|
+
email: true # optional; send to a subscriber list (default: false)
|
|
30
|
+
list: Newsletter # required when email is true; must match a newsletter form name exactly
|
|
31
|
+
subject: We just shipped something new
|
|
32
|
+
preview: See what's new in this release
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
Body content in Markdown. Local image refs like `` 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.
|