@chriswiegman/hugo-tools 0.2.0 → 0.2.2

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/src/publish.js CHANGED
@@ -25,172 +25,201 @@ const TZ = config.timezone;
25
25
  // --- Timezone helpers ---
26
26
 
27
27
  function getChicagoOffset(date) {
28
- const parts = new Intl.DateTimeFormat('en-US', {
29
- timeZone: TZ,
30
- timeZoneName: 'longOffset'
31
- }).formatToParts(date);
32
- // e.g. "GMT-06:00" → "-06:00"
33
- return parts.find(p => p.type === 'timeZoneName').value.replace('GMT', '');
28
+ const parts = new Intl.DateTimeFormat('en-US', {
29
+ timeZone: TZ,
30
+ timeZoneName: 'longOffset',
31
+ }).formatToParts(date);
32
+
33
+ // e.g. "GMT-06:00" → "-06:00"
34
+ return parts.find((p) => p.type === 'timeZoneName').value.replace('GMT', '');
34
35
  }
35
36
 
36
37
  function toChicagoISO(date) {
37
- const parts = new Intl.DateTimeFormat('en-CA', {
38
- timeZone: TZ,
39
- year: 'numeric',
40
- month: '2-digit',
41
- day: '2-digit',
42
- hour: '2-digit',
43
- minute: '2-digit',
44
- second: '2-digit',
45
- hour12: false
46
- }).formatToParts(date);
47
- const get = (type) => parts.find(p => p.type === type).value;
48
- let hour = get('hour');
49
- if (hour === '24') hour = '00';
50
- return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}${getChicagoOffset(date)}`;
38
+ const parts = new Intl.DateTimeFormat('en-CA', {
39
+ timeZone: TZ,
40
+ year: 'numeric',
41
+ month: '2-digit',
42
+ day: '2-digit',
43
+ hour: '2-digit',
44
+ minute: '2-digit',
45
+ second: '2-digit',
46
+ hour12: false,
47
+ }).formatToParts(date);
48
+ const get = (type) => parts.find((p) => p.type === type).value;
49
+ let hour = get('hour');
50
+
51
+ if (hour === '24') hour = '00';
52
+
53
+ return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}${getChicagoOffset(date)}`;
51
54
  }
52
55
 
53
56
  function getChicagoDateParts(date) {
54
- const parts = new Intl.DateTimeFormat('en-CA', {
55
- timeZone: TZ,
56
- year: 'numeric',
57
- month: '2-digit',
58
- day: '2-digit'
59
- }).formatToParts(date);
60
- const get = (type) => parts.find(p => p.type === type).value;
61
- return { year: get('year'), month: get('month'), day: get('day') };
57
+ const parts = new Intl.DateTimeFormat('en-CA', {
58
+ timeZone: TZ,
59
+ year: 'numeric',
60
+ month: '2-digit',
61
+ day: '2-digit',
62
+ }).formatToParts(date);
63
+ const get = (type) => parts.find((p) => p.type === type).value;
64
+
65
+ return { year: get('year'), month: get('month'), day: get('day') };
62
66
  }
63
67
 
64
68
  function chicagoAt8AM(dateStr) {
65
- // Returns ISO 8601 string for 08:00:00 America/Chicago on the given YYYY-MM-DD date.
66
- // Uses noon UTC to determine the DST-correct offset, then back-computes the UTC time for 08:00 local.
67
- const [year, month, day] = dateStr.split('-').map(Number);
68
- const noonUTC = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
69
- const offset = getChicagoOffset(noonUTC); // e.g. "-06:00"
70
- const match = offset.match(/([+-])(\d{2}):(\d{2})/);
71
- const offsetSign = match[1] === '+' ? 1 : -1;
72
- const offsetTotalMins = (parseInt(match[2], 10) * 60 + parseInt(match[3], 10)) * offsetSign;
73
- // UTCtime = localtime - offset → 08:00 local = (8 - offsetHours) UTC
74
- const utcHour = 8 - offsetTotalMins / 60;
75
- const target = new Date(Date.UTC(year, month - 1, day, utcHour, 0, 0));
76
- return `${dateStr}T08:00:00${getChicagoOffset(target)}`;
69
+ // Returns ISO 8601 string for 08:00:00 America/Chicago on the given YYYY-MM-DD date.
70
+ // Uses noon UTC to determine the DST-correct offset, then back-computes the UTC time for 08:00 local.
71
+ const [year, month, day] = dateStr.split('-').map(Number);
72
+ const noonUTC = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
73
+ const offset = getChicagoOffset(noonUTC); // e.g. "-06:00"
74
+ const match = offset.match(/([+-])(\d{2}):(\d{2})/);
75
+ const offsetSign = match[1] === '+' ? 1 : -1;
76
+ const offsetTotalMins = (parseInt(match[2], 10) * 60 + parseInt(match[3], 10)) * offsetSign;
77
+ // UTCtime = localtime - offset → 08:00 local = (8 - offsetHours) UTC
78
+ const utcHour = 8 - offsetTotalMins / 60;
79
+ const target = new Date(Date.UTC(year, month - 1, day, utcHour, 0, 0));
80
+
81
+ return `${dateStr}T08:00:00${getChicagoOffset(target)}`;
77
82
  }
78
83
 
79
84
  // --- Front matter helpers ---
80
85
 
81
86
  function escapeRegex(s) {
82
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
83
88
  }
84
89
 
85
90
  function splitFrontMatter(text) {
86
- if (!text.startsWith('---')) return { fm: '', body: text, had: false };
87
- const m1 = text.match(/^---\s*\n/);
88
- if (!m1) return { fm: '', body: text, had: false };
89
- const rest = text.slice(m1[0].length);
90
- const m2 = rest.match(/(?:^|\n)---\s*(?:\n|$)/m);
91
- if (!m2) return { fm: '', body: text, had: false };
92
- // When the match begins with \n that \n is the last line of fm, not part of the delimiter.
93
- const fmEnd = m2[0][0] === '\n' ? m2.index + 1 : m2.index;
94
- const fm = rest.slice(0, fmEnd);
95
- const body = rest.slice(m2.index + m2[0].length).replace(/^[\r\n]+/, '');
96
- return { fm, body, had: true };
91
+ if (!text.startsWith('---')) return { fm: '', body: text, had: false };
92
+
93
+ const m1 = text.match(/^---\s*\n/);
94
+
95
+ if (!m1) return { fm: '', body: text, had: false };
96
+
97
+ const rest = text.slice(m1[0].length);
98
+ const m2 = rest.match(/(?:^|\n)---\s*(?:\n|$)/m);
99
+
100
+ if (!m2) return { fm: '', body: text, had: false };
101
+
102
+ // When the match begins with \n that \n is the last line of fm, not part of the delimiter.
103
+ const fmEnd = m2[0][0] === '\n' ? m2.index + 1 : m2.index;
104
+ const fm = rest.slice(0, fmEnd);
105
+ const body = rest.slice(m2.index + m2[0].length).replace(/^[\r\n]+/, '');
106
+
107
+ return { fm, body, had: true };
97
108
  }
98
109
 
99
110
  function ensureLine(fm, key, value) {
100
- const ek = escapeRegex(key);
101
- const line = `${key}: ${value}`;
102
- if (new RegExp(`^${ek}\\s*:.*$`, 'm').test(fm)) {
103
- return fm.replace(new RegExp(`^${ek}\\s*:.*$`, 'gm'), line);
104
- }
105
- return fm.trimEnd() + (fm.trim() ? '\n' : '') + line + '\n';
111
+ const ek = escapeRegex(key);
112
+ const line = `${key}: ${value}`;
113
+
114
+ if (new RegExp(`^${ek}\\s*:.*$`, 'm').test(fm)) {
115
+ return fm.replace(new RegExp(`^${ek}\\s*:.*$`, 'gm'), line);
116
+ }
117
+
118
+ return fm.trimEnd() + (fm.trim() ? '\n' : '') + line + '\n';
106
119
  }
107
120
 
108
121
  function getScalarValue(fm, key) {
109
- const m = fm.match(new RegExp(`^${escapeRegex(key)}\\s*:\\s*(.*?)\\s*$`, 'm'));
110
- return m ? m[1].trim() : null;
122
+ const m = fm.match(new RegExp(`^${escapeRegex(key)}\\s*:\\s*(.*?)\\s*$`, 'm'));
123
+
124
+ return m ? m[1].trim() : null;
111
125
  }
112
126
 
113
127
  function removeKey(fm, key) {
114
- const ek = escapeRegex(key);
115
- fm = fm.replace(new RegExp(`^${ek}[ \\t]*:[ \\t]*\\n(?:[ \\t]*-[ \\t]*.*\\n)+`, 'gm'), '');
116
- fm = fm.replace(new RegExp(`^${ek}\\s*:.*(?:\\n|$)`, 'gm'), '');
117
- return fm;
128
+ const ek = escapeRegex(key);
129
+
130
+ fm = fm.replace(new RegExp(`^${ek}[ \\t]*:[ \\t]*\\n(?:[ \\t]*-[ \\t]*.*\\n)+`, 'gm'), '');
131
+ fm = fm.replace(new RegExp(`^${ek}\\s*:.*(?:\\n|$)`, 'gm'), '');
132
+ return fm;
118
133
  }
119
134
 
120
135
  function ensurePlaceholderList(fm, key) {
121
- const ek = escapeRegex(key);
122
- if (new RegExp(`^${ek}\\s*:\\s*\\n(?:\\s*-\\s*.*\\n)+`, 'm').test(fm)) return fm;
123
- return fm.trimEnd() + (fm.trim() ? '\n' : '') + `${key}:\n -\n`;
136
+ const ek = escapeRegex(key);
137
+
138
+ if (new RegExp(`^${ek}\\s*:\\s*\\n(?:\\s*-\\s*.*\\n)+`, 'm').test(fm)) return fm;
139
+
140
+ return fm.trimEnd() + (fm.trim() ? '\n' : '') + `${key}:\n -\n`;
124
141
  }
125
142
 
126
143
  function parseTitleFromFm(fm) {
127
- const m = fm.match(/^title\s*:\s*(.+)/m);
128
- if (!m) return null;
129
- let raw = m[1].trim();
130
- if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
131
- raw = raw.slice(1, -1);
132
- }
133
- return raw.trim() || null;
144
+ const m = fm.match(/^title\s*:\s*(.+)/m);
145
+
146
+ if (!m) return null;
147
+
148
+ let raw = m[1].trim();
149
+
150
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('\'') && raw.endsWith('\''))) {
151
+ raw = raw.slice(1, -1);
152
+ }
153
+
154
+ return raw.trim() || null;
134
155
  }
135
156
 
136
157
  function titleFromBasename(b) {
137
- return b.replace(/[-_]/g, ' ')
138
- .split(' ')
139
- .map(w => w.charAt(0).toUpperCase() + w.slice(1))
140
- .join(' ');
158
+ return b.replace(/[-_]/g, ' ')
159
+ .split(' ')
160
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
161
+ .join(' ');
141
162
  }
142
163
 
143
164
  function slugify(s) {
144
- s = s.trim().toLowerCase()
145
- .replace(/[\s_]+/g, '-')
146
- .replace(/[^a-z0-9-]/g, '')
147
- .replace(/-{2,}/g, '-')
148
- .replace(/^-+|-+$/g, '');
149
- return s || 'post';
165
+ s = s.trim().toLowerCase()
166
+ .replace(/[\s_]+/g, '-')
167
+ .replace(/[^a-z0-9-]/g, '')
168
+ .replace(/-{2,}/g, '-')
169
+ .replace(/^-+|-+$/g, '');
170
+ return s || 'post';
150
171
  }
151
172
 
152
173
  function stripQuotes(val) {
153
- const v = val.trim();
154
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
155
- return v.slice(1, -1).trim();
156
- }
157
- return v;
174
+ const v = val.trim();
175
+
176
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith('\'') && v.endsWith('\''))) {
177
+ return v.slice(1, -1).trim();
178
+ }
179
+
180
+ return v;
158
181
  }
159
182
 
160
183
  function extractListValues(fm, key) {
161
- const ek = escapeRegex(key);
162
- const blockMatch = fm.match(new RegExp(`^${ek}\\s*:\\s*\\n((?:\\s*-\\s*.*\\n)+)`, 'm'));
163
- if (blockMatch) {
164
- return blockMatch[1].split('\n')
165
- .map(line => line.match(/^\s*-\s*(.+)\s*$/))
166
- .filter(Boolean)
167
- .map(m => stripQuotes(m[1]))
168
- .filter(v => v);
169
- }
170
- const scalar = getScalarValue(fm, key);
171
- if (scalar) {
172
- return scalar.split(',').map(stripQuotes).filter(v => v);
173
- }
174
- return [];
184
+ const ek = escapeRegex(key);
185
+ const blockMatch = fm.match(new RegExp(`^${ek}\\s*:\\s*\\n((?:\\s*-\\s*.*\\n)+)`, 'm'));
186
+
187
+ if (blockMatch) {
188
+ return blockMatch[1].split('\n')
189
+ .map((line) => line.match(/^\s*-\s*(.+)\s*$/))
190
+ .filter(Boolean)
191
+ .map((m) => stripQuotes(m[1]))
192
+ .filter((v) => v);
193
+ }
194
+
195
+ const scalar = getScalarValue(fm, key);
196
+
197
+ if (scalar) {
198
+ return scalar.split(',').map(stripQuotes).filter((v) => v);
199
+ }
200
+
201
+ return [];
175
202
  }
176
203
 
177
204
  function hasCategory(fm, name) {
178
- const target = name.trim().toLowerCase();
179
- for (const key of ['category', 'categories']) {
180
- for (const val of extractListValues(fm, key)) {
181
- if (val.toLowerCase() === target) return true;
182
- }
183
- }
184
- return false;
205
+ const target = name.trim().toLowerCase();
206
+
207
+ for (const key of ['category', 'categories']) {
208
+ for (const val of extractListValues(fm, key)) {
209
+ if (val.toLowerCase() === target) return true;
210
+ }
211
+ }
212
+ return false;
185
213
  }
186
214
 
187
215
  function ghaWarning(msg, file) {
188
- const esc = s => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
189
- if (file) {
190
- process.stderr.write(`::warning file=${esc(file)},line=1,col=1::${esc(msg)}\n`);
191
- } else {
192
- process.stderr.write(`::warning::${esc(msg)}\n`);
193
- }
216
+ const esc = (s) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
217
+
218
+ if (file) {
219
+ process.stderr.write(`::warning file=${esc(file)},line=1,col=1::${esc(msg)}\n`);
220
+ } else {
221
+ process.stderr.write(`::warning::${esc(msg)}\n`);
222
+ }
194
223
  }
195
224
 
196
225
  // --- Date title helpers for notes ---
@@ -199,230 +228,247 @@ function ghaWarning(msg, file) {
199
228
  const DATE_TITLE_RE = /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2} (January|February|March|April|May|June|July|August|September|October|November|December), \d{4}$/;
200
229
 
201
230
  function formatChicagoDateTitle(isoStr) {
202
- const date = new Date(isoStr);
203
- const weekday = new Intl.DateTimeFormat('en-US', { timeZone: TZ, weekday: 'long' }).format(date);
204
- const day = new Intl.DateTimeFormat('en-US', { timeZone: TZ, day: '2-digit' }).format(date);
205
- const month = new Intl.DateTimeFormat('en-US', { timeZone: TZ, month: 'long' }).format(date);
206
- const year = new Intl.DateTimeFormat('en-US', { timeZone: TZ, year: 'numeric' }).format(date);
207
- return `${weekday}, ${day} ${month}, ${year}`;
231
+ const date = new Date(isoStr);
232
+ const weekday = new Intl.DateTimeFormat('en-US', { timeZone: TZ, weekday: 'long' }).format(date);
233
+ const day = new Intl.DateTimeFormat('en-US', { timeZone: TZ, day: '2-digit' }).format(date);
234
+ const month = new Intl.DateTimeFormat('en-US', { timeZone: TZ, month: 'long' }).format(date);
235
+ const year = new Intl.DateTimeFormat('en-US', { timeZone: TZ, year: 'numeric' }).format(date);
236
+
237
+ return `${weekday}, ${day} ${month}, ${year}`;
208
238
  }
209
239
 
210
240
  function formatChicagoTime(isoStr) {
211
- const date = new Date(isoStr);
212
- const parts = new Intl.DateTimeFormat('en-US', {
213
- timeZone: TZ,
214
- hour: '2-digit',
215
- minute: '2-digit',
216
- hour12: false
217
- }).formatToParts(date);
218
- const get = (type) => parts.find(p => p.type === type).value;
219
- let hour = get('hour');
220
- if (hour === '24') hour = '00';
221
- return `${hour}:${get('minute')}`;
241
+ const date = new Date(isoStr);
242
+ const parts = new Intl.DateTimeFormat('en-US', {
243
+ timeZone: TZ,
244
+ hour: '2-digit',
245
+ minute: '2-digit',
246
+ hour12: false,
247
+ }).formatToParts(date);
248
+ const get = (type) => parts.find((p) => p.type === type).value;
249
+ let hour = get('hour');
250
+
251
+ if (hour === '24') hour = '00';
252
+
253
+ return `${hour}:${get('minute')}`;
222
254
  }
223
255
 
224
256
  // --- Content processing ---
225
257
 
226
258
  function processContent(text, mode, baseName, dateStamp, publishStamp, draftPath) {
227
- let { fm, body, had } = splitFrontMatter(text);
228
- if (!had) {
229
- fm = '';
230
- body = text.replace(/^[\r\n]+/, '');
231
- }
232
-
233
- const rawTitle = parseTitleFromFm(fm);
234
- const trimmedBody = body.trim();
235
- const wordCount = trimmedBody ? trimmedBody.split(/\s+/).length : 0;
236
- const hasAnyCategory = extractListValues(fm, 'category').length > 0 ||
259
+ let { fm, body, had } = splitFrontMatter(text); // eslint-disable-line prefer-const
260
+
261
+ if (!had) {
262
+ fm = '';
263
+ body = text.replace(/^[\r\n]+/, '');
264
+ }
265
+
266
+ const rawTitle = parseTitleFromFm(fm);
267
+ const trimmedBody = body.trim();
268
+ const wordCount = trimmedBody ? trimmedBody.split(/\s+/).length : 0;
269
+ const hasAnyCategory = extractListValues(fm, 'category').length > 0 ||
237
270
  extractListValues(fm, 'categories').length > 0;
238
- const isNote = (hasCategory(fm, 'personal') || !hasAnyCategory) && wordCount < 200;
239
-
240
- fm = removeKey(fm, 'publishDate');
241
-
242
- const targetDate = mode === 'now' ? dateStamp : publishStamp;
243
-
244
- const existingDate = getScalarValue(fm, 'date');
245
- if (existingDate !== null && existingDate !== targetDate) {
246
- const warnMsg = `Draft already had date: ${existingDate} overriding with generated date: ${targetDate}`;
247
- if (process.env.GITHUB_ACTIONS === 'true') {
248
- ghaWarning(warnMsg, draftPath);
249
- } else {
250
- process.stderr.write(`WARN: ${warnMsg}\n`);
251
- }
252
- }
253
-
254
- fm = ensureLine(fm, 'date', targetDate);
255
-
256
- let title = rawTitle || titleFromBasename(baseName);
257
- if (isNote) {
258
- if (!rawTitle) {
259
- title = formatChicagoDateTitle(targetDate);
260
- }
261
- if (DATE_TITLE_RE.test(title)) {
262
- title = `${title} - ${formatChicagoTime(targetDate)}`;
263
- }
264
- }
265
-
266
- const slug = slugify(title);
267
-
268
- fm = ensureLine(fm, 'title', `"${title}"`);
269
- fm = ensureLine(fm, 'draft', 'false');
270
-
271
- if (!isNote && !fm.match(/^description\s*:/m)) {
272
- fm = ensureLine(fm, 'description', '""');
273
- }
274
-
275
- if (isNote) {
276
- for (const key of ['images', 'description', 'tags', 'category', 'categories']) {
277
- fm = removeKey(fm, key);
278
- }
279
- fm = ensureLine(fm, 'date', targetDate);
280
- } else {
281
- fm = ensurePlaceholderList(fm, 'categories');
282
- fm = ensurePlaceholderList(fm, 'tags');
283
- }
284
-
285
- if (!isNote) {
286
- const looksLikeNote = hasCategory(fm, 'personal') || !hasAnyCategory;
287
- if (looksLikeNote) {
288
- throw new Error(`Error: post has ${wordCount} words — notes must be under 200 words. Add categories and tags to publish as a post instead.`);
289
- }
290
- if (!rawTitle) {
291
- throw new Error('Error: post must have a title in front matter before publishing');
292
- }
293
- if (extractListValues(fm, 'tags').length === 0) {
294
- throw new Error('Error: post must have at least one tag before publishing');
295
- }
296
- }
297
-
298
- fm = fm.trimEnd() + '\n';
299
- const updated = `---\n${fm}---\n\n${body.trimStart()}`;
300
-
301
- return { slug, typeDir: isNote ? 'notes' : 'posts', updated };
271
+ const isNote = (hasCategory(fm, 'personal') || !hasAnyCategory) && wordCount < 200;
272
+
273
+ fm = removeKey(fm, 'publishDate');
274
+
275
+ const targetDate = mode === 'now' ? dateStamp : publishStamp;
276
+
277
+ const existingDate = getScalarValue(fm, 'date');
278
+
279
+ if (existingDate !== null && existingDate !== targetDate) {
280
+ const warnMsg = `Draft already had date: ${existingDate} — overriding with generated date: ${targetDate}`;
281
+
282
+ if (process.env.GITHUB_ACTIONS === 'true') {
283
+ ghaWarning(warnMsg, draftPath);
284
+ } else {
285
+ process.stderr.write(`WARN: ${warnMsg}\n`);
286
+ }
287
+ }
288
+
289
+ fm = ensureLine(fm, 'date', targetDate);
290
+
291
+ let title = rawTitle || titleFromBasename(baseName);
292
+
293
+ if (isNote) {
294
+ if (!rawTitle) {
295
+ title = formatChicagoDateTitle(targetDate);
296
+ }
297
+
298
+ if (DATE_TITLE_RE.test(title)) {
299
+ title = `${title} - ${formatChicagoTime(targetDate)}`;
300
+ }
301
+ }
302
+
303
+ const slug = slugify(title);
304
+
305
+ fm = ensureLine(fm, 'title', `"${title}"`);
306
+ fm = ensureLine(fm, 'draft', 'false');
307
+
308
+ if (!isNote && !fm.match(/^description\s*:/m)) {
309
+ fm = ensureLine(fm, 'description', '""');
310
+ }
311
+
312
+ if (isNote) {
313
+ for (const key of ['images', 'description', 'tags', 'category', 'categories']) {
314
+ fm = removeKey(fm, key);
315
+ }
316
+ fm = ensureLine(fm, 'date', targetDate);
317
+ } else {
318
+ fm = ensurePlaceholderList(fm, 'categories');
319
+ fm = ensurePlaceholderList(fm, 'tags');
320
+ }
321
+
322
+ if (!isNote) {
323
+ const looksLikeNote = hasCategory(fm, 'personal') || !hasAnyCategory;
324
+
325
+ if (looksLikeNote) {
326
+ throw new Error(`Error: post has ${wordCount} words — notes must be under 200 words. Add categories and tags to publish as a post instead.`);
327
+ }
328
+
329
+ if (!rawTitle) {
330
+ throw new Error('Error: post must have a title in front matter before publishing');
331
+ }
332
+
333
+ if (extractListValues(fm, 'tags').length === 0) {
334
+ throw new Error('Error: post must have at least one tag before publishing');
335
+ }
336
+ }
337
+
338
+ fm = fm.trimEnd() + '\n';
339
+ const updated = `---\n${fm}---\n\n${body.trimStart()}`;
340
+
341
+ return { slug, typeDir: isNote ? 'notes' : 'posts', updated };
302
342
  }
303
343
 
304
344
  // --- Interactive prompt ---
305
345
 
306
346
  async function ask(question) {
307
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
308
- return new Promise(resolve => {
309
- rl.question(question, answer => {
310
- rl.close();
311
- resolve(answer.trim());
312
- });
313
- });
347
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
348
+
349
+ return new Promise((resolve) => {
350
+ rl.question(question, (answer) => {
351
+ rl.close();
352
+ resolve(answer.trim());
353
+ });
354
+ });
314
355
  }
315
356
 
316
357
  // --- Main ---
317
358
 
318
359
  async function main() {
319
- config.requireHugoSite();
320
-
321
- const draftPath = process.argv[2];
322
- let mode = process.argv[3] || '';
323
-
324
- if (!draftPath) {
325
- console.error('Error: draft path required (e.g. content/drafts/My Draft.md)');
326
- process.exit(1);
327
- }
328
-
329
- const draftsPrefix = config.draftsDir.replace(/\\/g, '/').replace(/\/$/, '');
330
- if (!draftPath.startsWith(draftsPrefix + '/') || !draftPath.endsWith('.md')) {
331
- console.error(`Error: draft must be under ${config.draftsDir}/*.md`);
332
- process.exit(1);
333
- }
334
-
335
- if (!fs.existsSync(draftPath)) {
336
- console.error(`Error: file not found: ${draftPath}`);
337
- process.exit(1);
338
- }
339
-
340
- if (!mode) {
341
- mode = await ask('Choose mode: now | later\n');
342
- }
343
-
344
- const now = new Date();
345
- let dateStamp = '';
346
- let publishStamp = '';
347
- let year, month, day;
348
-
349
- if (mode === 'now') {
350
- dateStamp = toChicagoISO(now);
351
- ({ year, month, day } = getChicagoDateParts(now));
352
- } else if (mode === 'later') {
353
- let pubDate = process.argv[4] || '';
354
- if (!pubDate) {
355
- pubDate = await ask('Enter publish date (YYYY-MM-DD):\n');
356
- }
357
- if (!/^\d{4}-\d{2}-\d{2}$/.test(pubDate)) {
358
- console.error('Error: date must be YYYY-MM-DD');
359
- process.exit(1);
360
- }
361
- publishStamp = chicagoAt8AM(pubDate);
362
- year = pubDate.slice(0, 4);
363
- month = pubDate.slice(5, 7);
364
- day = pubDate.slice(8, 10);
365
- } else {
366
- console.error("Error: mode must be 'now' or 'later'");
367
- process.exit(1);
368
- }
369
-
370
- const baseName = path.basename(draftPath, '.md');
371
- const text = fs.readFileSync(draftPath, 'utf8');
372
-
373
- let slug, typeDir, updated;
374
- try {
375
- ({ slug, typeDir, updated } = processContent(
376
- text, mode, baseName, dateStamp, publishStamp, draftPath
377
- ));
378
- } catch (err) {
379
- console.error(err.message);
380
- process.exit(1);
381
- }
382
-
383
- const baseDir = typeDir === 'notes' ? config.notesDir : config.postsDir;
384
- const destDir = path.join(baseDir, year);
385
- const destPath = path.join(destDir, `${month}-${day}-${slug}.md`);
386
-
387
- if (fs.existsSync(destPath)) {
388
- console.error(`Error: destination already exists: ${destPath}`);
389
- process.exit(1);
390
- }
391
-
392
- fs.mkdirSync(destDir, { recursive: true });
393
- fs.writeFileSync(destPath, updated, 'utf8');
394
- fs.unlinkSync(draftPath);
395
-
396
- console.log('Published:');
397
- console.log(` ${destPath}`);
360
+ config.requireHugoSite();
361
+
362
+ const draftPath = process.argv[2];
363
+ let mode = process.argv[3] || '';
364
+
365
+ if (!draftPath) {
366
+ console.error('Error: draft path required (e.g. content/drafts/My Draft.md)');
367
+ process.exit(1);
368
+ }
369
+
370
+ const draftsPrefix = config.draftsDir.replace(/\\/g, '/').replace(/\/$/, '');
371
+
372
+ if (!draftPath.startsWith(draftsPrefix + '/') || !draftPath.endsWith('.md')) {
373
+ console.error(`Error: draft must be under ${config.draftsDir}/*.md`);
374
+ process.exit(1);
375
+ }
376
+
377
+ if (!fs.existsSync(draftPath)) {
378
+ console.error(`Error: file not found: ${draftPath}`);
379
+ process.exit(1);
380
+ }
381
+
382
+ if (!mode) {
383
+ mode = await ask('Choose mode: now | later\n');
384
+ }
385
+
386
+ const now = new Date();
387
+ let dateStamp = '';
388
+ let publishStamp = '';
389
+ let year, month, day;
390
+
391
+ if (mode === 'now') {
392
+ dateStamp = toChicagoISO(now);
393
+ ({ year, month, day } = getChicagoDateParts(now));
394
+ } else if (mode === 'later') {
395
+ let pubDate = process.argv[4] || '';
396
+
397
+ if (!pubDate) {
398
+ pubDate = await ask('Enter publish date (YYYY-MM-DD):\n');
399
+ }
400
+
401
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(pubDate)) {
402
+ console.error('Error: date must be YYYY-MM-DD');
403
+ process.exit(1);
404
+ }
405
+
406
+ publishStamp = chicagoAt8AM(pubDate);
407
+ year = pubDate.slice(0, 4);
408
+ month = pubDate.slice(5, 7);
409
+ day = pubDate.slice(8, 10);
410
+ } else {
411
+ console.error('Error: mode must be \'now\' or \'later\'');
412
+ process.exit(1);
413
+ }
414
+
415
+ const baseName = path.basename(draftPath, '.md');
416
+ const text = fs.readFileSync(draftPath, 'utf8');
417
+
418
+ let slug, typeDir, updated;
419
+
420
+ try {
421
+ ({ slug, typeDir, updated } = processContent(
422
+ text, mode, baseName, dateStamp, publishStamp, draftPath,
423
+ ));
424
+ } catch (err) {
425
+ console.error(err.message);
426
+ process.exit(1);
427
+ }
428
+
429
+ const baseDir = typeDir === 'notes' ? config.notesDir : config.postsDir;
430
+ const destDir = path.join(baseDir, year);
431
+ const destPath = path.join(destDir, `${month}-${day}-${slug}.md`);
432
+
433
+ if (fs.existsSync(destPath)) {
434
+ console.error(`Error: destination already exists: ${destPath}`);
435
+ process.exit(1);
436
+ }
437
+
438
+ fs.mkdirSync(destDir, { recursive: true });
439
+ fs.writeFileSync(destPath, updated, 'utf8');
440
+ fs.unlinkSync(draftPath);
441
+
442
+ console.log('Published:');
443
+ console.log(` ${destPath}`);
398
444
  }
399
445
 
400
446
  if (require.main === module) {
401
- process.on('SIGINT', () => {
402
- console.log('\nCancelled.');
403
- process.exit(0);
404
- });
405
-
406
- main().catch(err => {
407
- console.error('Error:', err);
408
- process.exit(1);
409
- });
447
+ process.on('SIGINT', () => {
448
+ console.log('\nCancelled.');
449
+ process.exit(0);
450
+ });
451
+
452
+ main().catch((err) => {
453
+ console.error('Error:', err);
454
+ process.exit(1);
455
+ });
410
456
  }
411
457
 
412
458
  module.exports = {
413
- processContent,
414
- splitFrontMatter,
415
- ensureLine,
416
- ensurePlaceholderList,
417
- getScalarValue,
418
- removeKey,
419
- extractListValues,
420
- hasCategory,
421
- parseTitleFromFm,
422
- titleFromBasename,
423
- slugify,
424
- stripQuotes,
425
- chicagoAt8AM,
426
- formatChicagoDateTitle,
427
- formatChicagoTime,
459
+ processContent,
460
+ splitFrontMatter,
461
+ ensureLine,
462
+ ensurePlaceholderList,
463
+ getScalarValue,
464
+ removeKey,
465
+ extractListValues,
466
+ hasCategory,
467
+ parseTitleFromFm,
468
+ titleFromBasename,
469
+ slugify,
470
+ stripQuotes,
471
+ chicagoAt8AM,
472
+ formatChicagoDateTitle,
473
+ formatChicagoTime,
428
474
  };