@micropage-sh/cli 2.6.3 → 2.6.5
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 +2 -1
- package/package.json +1 -1
- package/src/commands/posts.js +91 -1
- package/templates/PROJECT_AGENT.template.md +1 -0
package/README.md
CHANGED
|
@@ -73,6 +73,7 @@ Posts live in the project's `posts/` folder as Markdown files with YAML front-ma
|
|
|
73
73
|
---
|
|
74
74
|
title: Hello, world
|
|
75
75
|
slug: hello
|
|
76
|
+
date: 2026-07-07
|
|
76
77
|
description: A short summary for the archive, meta description, and og tags.
|
|
77
78
|
visibility: listed
|
|
78
79
|
hero: ./hello.jpg
|
|
@@ -84,7 +85,7 @@ preview: The first post on this site.
|
|
|
84
85
|
Body content goes here as standard Markdown.
|
|
85
86
|
```
|
|
86
87
|
|
|
87
|
-
`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`.
|
|
88
|
+
`title` is required. `slug` defaults to the filename minus a leading `YYYY-MM-DD-` date prefix. `date` is the post's public date: `YYYY-MM-DD` (midnight UTC) or a full ISO timestamp; it sets the published date of a live post, and a draft keeps it until its first publish. Omit it to use the publish time. Future dates are rejected (scheduling isn't supported); `posts pull` writes it back as the UTC day for published posts. `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`.
|
|
88
89
|
|
|
89
90
|
```bash
|
|
90
91
|
# Save posts/hello.md as a draft
|
package/package.json
CHANGED
package/src/commands/posts.js
CHANGED
|
@@ -92,11 +92,89 @@ async function resolveFormId(projectId, listName) {
|
|
|
92
92
|
return matches[0].id;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
const DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
96
|
+
const ISO_DATETIME_RE =
|
|
97
|
+
/^(\d{4}-\d{2}-\d{2})[Tt ](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)\s*([Zz]|[+-]\d{2}(?::?\d{2})?)?$/;
|
|
98
|
+
// Date-only values are midnight UTC server-side; allow "today" anywhere on
|
|
99
|
+
// Earth (UTC+14) so a local-calendar date isn't rejected as future.
|
|
100
|
+
const DATE_ONLY_FUTURE_SLACK_MS = 14 * 60 * 60 * 1000;
|
|
101
|
+
const DATETIME_FUTURE_SLACK_MS = 5 * 60 * 1000;
|
|
102
|
+
|
|
103
|
+
function utcDateString(d) {
|
|
104
|
+
return d.toISOString().slice(0, 10);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isoOffset(raw) {
|
|
108
|
+
if (!raw || raw === 'z' || raw === 'Z') return 'Z';
|
|
109
|
+
const digits = raw.slice(1).replace(':', '');
|
|
110
|
+
return `${raw[0]}${digits.slice(0, 2)}:${digits.slice(2) || '00'}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isMidnightUtc(d) {
|
|
114
|
+
return (
|
|
115
|
+
d.getUTCHours() === 0 && d.getUTCMinutes() === 0 && d.getUTCSeconds() === 0 && d.getUTCMilliseconds() === 0
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalize a front-matter `date` into what upsert-post accepts: `YYYY-MM-DD`
|
|
121
|
+
* or a full ISO timestamp. js-yaml turns unquoted dates into Date objects
|
|
122
|
+
* (UTC), quoted ones stay strings. Returns null when absent; throws on
|
|
123
|
+
* invalid or future values (scheduling isn't supported).
|
|
124
|
+
*/
|
|
125
|
+
function normalizePostDate(value, now = new Date()) {
|
|
126
|
+
if (value === undefined || value === null || value === '') return null;
|
|
127
|
+
|
|
128
|
+
let normalized;
|
|
129
|
+
if (value instanceof Date) {
|
|
130
|
+
if (Number.isNaN(value.getTime())) throw new Error('invalid "date" (not a valid date)');
|
|
131
|
+
normalized = isMidnightUtc(value) ? utcDateString(value) : value.toISOString();
|
|
132
|
+
} else if (typeof value === 'string') {
|
|
133
|
+
const s = value.trim();
|
|
134
|
+
const dateOnly = DATE_ONLY_RE.exec(s);
|
|
135
|
+
if (dateOnly) {
|
|
136
|
+
const d = new Date(`${s}T00:00:00.000Z`);
|
|
137
|
+
if (Number.isNaN(d.getTime()) || utcDateString(d) !== s) {
|
|
138
|
+
throw new Error(`invalid "date" (${s}); use YYYY-MM-DD or an ISO timestamp`);
|
|
139
|
+
}
|
|
140
|
+
normalized = s;
|
|
141
|
+
} else {
|
|
142
|
+
const m = ISO_DATETIME_RE.exec(s);
|
|
143
|
+
// A timestamp without an offset is read as UTC, matching how YAML
|
|
144
|
+
// resolves unquoted timestamps.
|
|
145
|
+
const d = m ? new Date(`${m[1]}T${m[2]}${isoOffset(m[3])}`) : null;
|
|
146
|
+
if (!d || Number.isNaN(d.getTime()) || utcDateString(new Date(`${m[1]}T00:00:00.000Z`)) !== m[1]) {
|
|
147
|
+
throw new Error(`invalid "date" (${s}); use YYYY-MM-DD or an ISO timestamp`);
|
|
148
|
+
}
|
|
149
|
+
normalized = d.toISOString();
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
throw new Error(`invalid "date" (${String(value)}); use YYYY-MM-DD or an ISO timestamp`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const isFuture = DATE_ONLY_RE.test(normalized)
|
|
156
|
+
? Date.parse(`${normalized}T00:00:00Z`) > now.getTime() + DATE_ONLY_FUTURE_SLACK_MS
|
|
157
|
+
: new Date(normalized).getTime() > now.getTime() + DATETIME_FUTURE_SLACK_MS;
|
|
158
|
+
if (isFuture) {
|
|
159
|
+
throw new Error(`"date" ${normalized} is in the future; scheduling posts is not supported`);
|
|
160
|
+
}
|
|
161
|
+
return normalized;
|
|
162
|
+
}
|
|
163
|
+
|
|
95
164
|
function frontMatterFromPost(post) {
|
|
96
165
|
const fmData = {
|
|
97
166
|
title: post.title || '',
|
|
98
167
|
};
|
|
99
168
|
if (post.slug) fmData.slug = post.slug;
|
|
169
|
+
if (post.published_at) {
|
|
170
|
+
const published = new Date(post.published_at);
|
|
171
|
+
if (!Number.isNaN(published.getTime())) fmData.date = utcDateString(published);
|
|
172
|
+
} else if (post.date_override) {
|
|
173
|
+
// A draft's held date must round-trip: push sends date: null for files without one,
|
|
174
|
+
// which clears it.
|
|
175
|
+
const held = new Date(post.date_override);
|
|
176
|
+
if (!Number.isNaN(held.getTime())) fmData.date = isMidnightUtc(held) ? utcDateString(held) : held.toISOString();
|
|
177
|
+
}
|
|
100
178
|
if (post.description) fmData.description = post.description;
|
|
101
179
|
if (post.web_visibility && post.web_visibility !== 'listed') fmData.visibility = post.web_visibility;
|
|
102
180
|
if (post.hero_image) fmData.hero = post.hero_image;
|
|
@@ -202,6 +280,15 @@ async function push(options = {}) {
|
|
|
202
280
|
continue;
|
|
203
281
|
}
|
|
204
282
|
|
|
283
|
+
let postDate;
|
|
284
|
+
try {
|
|
285
|
+
postDate = normalizePostDate(fm.date);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
console.error(`${relName}: ${err.message}`);
|
|
288
|
+
hadError = true;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
205
292
|
const emailWanted = fm.email === true;
|
|
206
293
|
let formId = null;
|
|
207
294
|
if (emailWanted) {
|
|
@@ -270,6 +357,8 @@ async function push(options = {}) {
|
|
|
270
357
|
subject: fm.subject || null,
|
|
271
358
|
preheader: fm.preview || null,
|
|
272
359
|
};
|
|
360
|
+
// An explicit null tells the server the file has no date:, so a held draft date is cleared.
|
|
361
|
+
payload.date = postDate || null;
|
|
273
362
|
|
|
274
363
|
try {
|
|
275
364
|
const result = await fn.invoke('upsert-post', payload);
|
|
@@ -319,7 +408,7 @@ async function pull(options = {}) {
|
|
|
319
408
|
remotePosts = await db
|
|
320
409
|
.from('posts')
|
|
321
410
|
.select(
|
|
322
|
-
'id,slug,title,description,body_markdown,web_visibility,email_enabled,status,hero_image,published_at,created_at',
|
|
411
|
+
'id,slug,title,description,body_markdown,web_visibility,email_enabled,status,hero_image,published_at,date_override,created_at',
|
|
323
412
|
)
|
|
324
413
|
.eq('project_id', config.projectId)
|
|
325
414
|
.order('created_at', 'desc')
|
|
@@ -643,4 +732,5 @@ module.exports = {
|
|
|
643
732
|
slugify,
|
|
644
733
|
defaultSlugFromFilename,
|
|
645
734
|
frontMatterFromPost,
|
|
735
|
+
normalizePostDate,
|
|
646
736
|
};
|
|
@@ -23,6 +23,7 @@ Each file in `posts/*.md` is one post: YAML front-matter + a Markdown body, push
|
|
|
23
23
|
---
|
|
24
24
|
title: Launching our new dashboard
|
|
25
25
|
slug: launching-new-dashboard # optional; defaults to the filename minus a leading date prefix and .md
|
|
26
|
+
date: 2026-07-07 # optional; public date, YYYY-MM-DD or ISO timestamp (default: publish time; no future dates)
|
|
26
27
|
description: A quick look at what's new.
|
|
27
28
|
visibility: listed # listed | unlisted | none (default: listed)
|
|
28
29
|
hero: launch-hero.png # optional; local file, existing uploaded asset filename, or absolute URL
|