@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micropage-sh/cli",
3
- "version": "2.6.3",
3
+ "version": "2.6.5",
4
4
  "description": "Micropage CLI: keep your .page files in git and publish static sites from the terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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