@chriswiegman/hugo-tools 0.1.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/src/publish.js ADDED
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Publishes a Hugo draft post now or on a specified date.
6
+ * Usage:
7
+ * node scripts/publish.js content/drafts/My\ Draft.md now
8
+ * node scripts/publish.js content/drafts/My\ Draft.md later 2026-01-05
9
+ *
10
+ * later mode sets publish datetime to 08:00 America/Chicago on the chosen date.
11
+ *
12
+ * Behavior:
13
+ * - Always overrides `date:` with the generated date (now/later).
14
+ * - If the draft already had a different `date:`, emits a prominent GitHub Actions warning
15
+ * annotation (and a stderr warning when not in Actions).
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const readline = require('readline');
21
+ const config = require('./config');
22
+
23
+ const TZ = config.timezone;
24
+
25
+ // --- Timezone helpers ---
26
+
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', '');
34
+ }
35
+
36
+ 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)}`;
51
+ }
52
+
53
+ 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') };
62
+ }
63
+
64
+ 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)}`;
77
+ }
78
+
79
+ // --- Front matter helpers ---
80
+
81
+ function escapeRegex(s) {
82
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
83
+ }
84
+
85
+ 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 };
97
+ }
98
+
99
+ 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';
106
+ }
107
+
108
+ function getScalarValue(fm, key) {
109
+ const m = fm.match(new RegExp(`^${escapeRegex(key)}\\s*:\\s*(.*?)\\s*$`, 'm'));
110
+ return m ? m[1].trim() : null;
111
+ }
112
+
113
+ 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;
118
+ }
119
+
120
+ 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`;
124
+ }
125
+
126
+ 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;
134
+ }
135
+
136
+ function titleFromBasename(b) {
137
+ return b.replace(/[-_]/g, ' ')
138
+ .split(' ')
139
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
140
+ .join(' ');
141
+ }
142
+
143
+ 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';
150
+ }
151
+
152
+ 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;
158
+ }
159
+
160
+ 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 [];
175
+ }
176
+
177
+ 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;
185
+ }
186
+
187
+ 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
+ }
194
+ }
195
+
196
+ // --- Date title helpers for notes ---
197
+
198
+ // Matches titles like "Monday, 29 March, 2026"
199
+ 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
+
201
+ 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}`;
208
+ }
209
+
210
+ 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')}`;
222
+ }
223
+
224
+ // --- Content processing ---
225
+
226
+ 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 ||
237
+ 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 };
302
+ }
303
+
304
+ // --- Interactive prompt ---
305
+
306
+ 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
+ });
314
+ }
315
+
316
+ // --- Main ---
317
+
318
+ 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}`);
398
+ }
399
+
400
+ 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
+ });
410
+ }
411
+
412
+ 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,
428
+ };