@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/CHANGELOG.md +12 -0
- package/LICENSE.txt +7 -0
- package/README.md +231 -0
- package/package.json +49 -0
- package/src/add-tags.js +143 -0
- package/src/book.mjs +725 -0
- package/src/book.test.mjs +834 -0
- package/src/config-init.js +29 -0
- package/src/config.js +41 -0
- package/src/draft.js +77 -0
- package/src/draft.test.mjs +77 -0
- package/src/extract-tags.js +234 -0
- package/src/extract-tags.test.mjs +121 -0
- package/src/pick-image.js +80 -0
- package/src/publish.js +428 -0
- package/src/publish.test.mjs +496 -0
- package/src/vscode.js +101 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const {
|
|
7
|
+
processContent,
|
|
8
|
+
splitFrontMatter,
|
|
9
|
+
ensureLine,
|
|
10
|
+
ensurePlaceholderList,
|
|
11
|
+
getScalarValue,
|
|
12
|
+
removeKey,
|
|
13
|
+
extractListValues,
|
|
14
|
+
hasCategory,
|
|
15
|
+
parseTitleFromFm,
|
|
16
|
+
titleFromBasename,
|
|
17
|
+
slugify,
|
|
18
|
+
stripQuotes,
|
|
19
|
+
chicagoAt8AM,
|
|
20
|
+
formatChicagoDateTitle,
|
|
21
|
+
formatChicagoTime,
|
|
22
|
+
} = require('./publish.js');
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
// Produces a body string of exactly n space-separated words.
|
|
29
|
+
function words(n) {
|
|
30
|
+
return Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Mirrors the output of `hugo new content/drafts/<file>.md` using the drafts
|
|
34
|
+
// archetype, followed by the perl one-liner that strips the date: line.
|
|
35
|
+
// Callers can override any field; categories/tags default to placeholder lists.
|
|
36
|
+
function makeDraft({
|
|
37
|
+
title = '',
|
|
38
|
+
description = '',
|
|
39
|
+
categories = null, // null → placeholder " -", array → real values
|
|
40
|
+
tags = null, // null → placeholder " -", array → real values
|
|
41
|
+
body = '',
|
|
42
|
+
publishDate,
|
|
43
|
+
} = {}) {
|
|
44
|
+
const lines = [
|
|
45
|
+
'---',
|
|
46
|
+
`title: "${title}"`,
|
|
47
|
+
`description: "${description}"`,
|
|
48
|
+
'draft: true',
|
|
49
|
+
'images:',
|
|
50
|
+
' -',
|
|
51
|
+
'categories:',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
if (categories === null) {
|
|
55
|
+
lines.push(' -');
|
|
56
|
+
} else {
|
|
57
|
+
for (const c of categories) lines.push(` - ${c}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
lines.push('tags:');
|
|
61
|
+
if (tags === null) {
|
|
62
|
+
lines.push(' -');
|
|
63
|
+
} else {
|
|
64
|
+
for (const t of tags) lines.push(` - ${t}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (publishDate !== undefined) lines.push(`publishDate: ${publishDate}`);
|
|
68
|
+
lines.push('---');
|
|
69
|
+
if (body) lines.push('', body);
|
|
70
|
+
return lines.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const NOW_STAMP = '2026-05-04T10:00:00-05:00';
|
|
74
|
+
const LATER_STAMP = '2026-06-01T08:00:00-05:00';
|
|
75
|
+
const DRAFT_PATH = 'content/drafts/my-post.md';
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// splitFrontMatter
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
test('splitFrontMatter: parses standard frontmatter', () => {
|
|
82
|
+
const { fm, body, had } = splitFrontMatter('---\ntitle: foo\n---\nbody text');
|
|
83
|
+
assert.equal(had, true);
|
|
84
|
+
assert.ok(fm.includes('title: foo'));
|
|
85
|
+
assert.ok(body.includes('body text'));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('splitFrontMatter: returns had=false when no opening ---', () => {
|
|
89
|
+
const { had, body } = splitFrontMatter('just body text');
|
|
90
|
+
assert.equal(had, false);
|
|
91
|
+
assert.equal(body, 'just body text');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('splitFrontMatter: handles empty frontmatter block', () => {
|
|
95
|
+
const { fm, had } = splitFrontMatter('---\n---\nbody');
|
|
96
|
+
assert.equal(had, true);
|
|
97
|
+
assert.equal(fm, '');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('splitFrontMatter: body after closing --- is preserved', () => {
|
|
101
|
+
const { body } = splitFrontMatter('---\nfoo: bar\n---\nline1\nline2');
|
|
102
|
+
assert.ok(body.includes('line1'));
|
|
103
|
+
assert.ok(body.includes('line2'));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('splitFrontMatter: trailing newline in fm preserved for list parsing', () => {
|
|
107
|
+
// Regression: when closing --- immediately follows a list item, the \n
|
|
108
|
+
// before --- must remain in fm so extractListValues can match.
|
|
109
|
+
const text = '---\ncategories:\n - Personal\n---\nbody';
|
|
110
|
+
const { fm } = splitFrontMatter(text);
|
|
111
|
+
assert.ok(fm.endsWith('\n'), 'fm should end with newline');
|
|
112
|
+
assert.ok(fm.includes(' - Personal'));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// slugify
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
test('slugify: lowercases and hyphenates spaces', () => {
|
|
120
|
+
assert.equal(slugify('Hello World'), 'hello-world');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('slugify: strips special characters', () => {
|
|
124
|
+
assert.equal(slugify("It's Great!"), 'its-great');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('slugify: collapses multiple hyphens', () => {
|
|
128
|
+
assert.equal(slugify('a--b---c'), 'a-b-c');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('slugify: falls back to "post" for empty string', () => {
|
|
132
|
+
assert.equal(slugify(''), 'post');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// parseTitleFromFm
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
test('parseTitleFromFm: returns quoted title without quotes', () => {
|
|
140
|
+
assert.equal(parseTitleFromFm('title: "My Post"'), 'My Post');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('parseTitleFromFm: returns unquoted title', () => {
|
|
144
|
+
assert.equal(parseTitleFromFm('title: My Post'), 'My Post');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('parseTitleFromFm: returns null when title key is absent', () => {
|
|
148
|
+
assert.equal(parseTitleFromFm('draft: true\n'), null);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('parseTitleFromFm: returns null for empty quoted title (archetype default)', () => {
|
|
152
|
+
assert.equal(parseTitleFromFm('title: ""\n'), null);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// titleFromBasename
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
test('titleFromBasename: hyphen-separated becomes Title Case', () => {
|
|
160
|
+
assert.equal(titleFromBasename('my-draft-post'), 'My Draft Post');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('titleFromBasename: underscore-separated becomes Title Case', () => {
|
|
164
|
+
assert.equal(titleFromBasename('my_draft_post'), 'My Draft Post');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// extractListValues
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
test('extractListValues: reads YAML list items', () => {
|
|
172
|
+
const fm = 'tags:\n - foo\n - bar\n';
|
|
173
|
+
assert.deepEqual(extractListValues(fm, 'tags'), ['foo', 'bar']);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('extractListValues: strips quotes from items', () => {
|
|
177
|
+
const fm = 'tags:\n - "foo"\n - \'bar\'\n';
|
|
178
|
+
assert.deepEqual(extractListValues(fm, 'tags'), ['foo', 'bar']);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('extractListValues: returns [] for key not present', () => {
|
|
182
|
+
assert.deepEqual(extractListValues('title: foo\n', 'tags'), []);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('extractListValues: returns [] for archetype placeholder list ( -)', () => {
|
|
186
|
+
assert.deepEqual(extractListValues('tags:\n -\n', 'tags'), []);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// hasCategory
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
test('hasCategory: finds match in categories key', () => {
|
|
194
|
+
assert.equal(hasCategory('categories:\n - Technology\n', 'Technology'), true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('hasCategory: finds match in category key (singular)', () => {
|
|
198
|
+
assert.equal(hasCategory('category:\n - Personal\n', 'personal'), true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('hasCategory: is case-insensitive', () => {
|
|
202
|
+
assert.equal(hasCategory('categories:\n - PERSONAL\n', 'personal'), true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('hasCategory: returns false for placeholder list', () => {
|
|
206
|
+
assert.equal(hasCategory('categories:\n -\n', 'personal'), false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('hasCategory: returns false when not present', () => {
|
|
210
|
+
assert.equal(hasCategory('categories:\n - Technology\n', 'personal'), false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// getScalarValue / ensureLine / removeKey
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
test('getScalarValue: reads a scalar front matter value', () => {
|
|
218
|
+
assert.equal(getScalarValue('date: 2026-05-04\n', 'date'), '2026-05-04');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('getScalarValue: returns null when key absent', () => {
|
|
222
|
+
assert.equal(getScalarValue('title: foo\n', 'date'), null);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('ensureLine: adds a new key when absent', () => {
|
|
226
|
+
const result = ensureLine('title: foo\n', 'draft', 'false');
|
|
227
|
+
assert.ok(result.includes('draft: false'));
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('ensureLine: overwrites an existing key', () => {
|
|
231
|
+
const result = ensureLine('draft: true\n', 'draft', 'false');
|
|
232
|
+
assert.ok(result.includes('draft: false'));
|
|
233
|
+
assert.ok(!result.includes('draft: true'));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('removeKey: removes a scalar key', () => {
|
|
237
|
+
const result = removeKey('publishDate: 2026-01-01\ntitle: foo\n', 'publishDate');
|
|
238
|
+
assert.ok(!result.includes('publishDate'));
|
|
239
|
+
assert.ok(result.includes('title: foo'));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('removeKey: removes a list key and its items', () => {
|
|
243
|
+
const result = removeKey('tags:\n - foo\n - bar\ntitle: x\n', 'tags');
|
|
244
|
+
assert.ok(!result.includes('tags:'));
|
|
245
|
+
assert.ok(result.includes('title: x'));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('removeKey: removes placeholder list', () => {
|
|
249
|
+
const result = removeKey('tags:\n -\ntitle: x\n', 'tags');
|
|
250
|
+
assert.ok(!result.includes('tags:'));
|
|
251
|
+
assert.ok(result.includes('title: x'));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// chicagoAt8AM
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
test('chicagoAt8AM: returns CST offset in winter', () => {
|
|
259
|
+
assert.equal(chicagoAt8AM('2026-01-15'), '2026-01-15T08:00:00-06:00');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('chicagoAt8AM: returns CDT offset in summer', () => {
|
|
263
|
+
assert.equal(chicagoAt8AM('2026-07-15'), '2026-07-15T08:00:00-05:00');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// processContent — note detection
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
test('processContent: placeholder categories (no real cats) → note', () => {
|
|
271
|
+
const text = makeDraft({ body: words(10) });
|
|
272
|
+
const { typeDir } = processContent(text, 'now', 'a-note', NOW_STAMP, '', DRAFT_PATH);
|
|
273
|
+
assert.equal(typeDir, 'notes');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('processContent: personal category with short body → note', () => {
|
|
277
|
+
const text = makeDraft({ categories: ['Personal'], body: words(10) });
|
|
278
|
+
const { typeDir } = processContent(text, 'now', 'a-note', NOW_STAMP, '', DRAFT_PATH);
|
|
279
|
+
assert.equal(typeDir, 'notes');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('processContent: non-personal category with tags → post', () => {
|
|
283
|
+
const text = makeDraft({ title: 'My Post', categories: ['Technology'], tags: ['JavaScript'], body: words(10) });
|
|
284
|
+
const { typeDir } = processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH);
|
|
285
|
+
assert.equal(typeDir, 'posts');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('processContent: long post (>=200 words) with categories → post', () => {
|
|
289
|
+
const text = makeDraft({ title: 'Long Post', categories: ['Technology'], tags: ['Go'], body: words(200) });
|
|
290
|
+
const { typeDir } = processContent(text, 'now', 'long-post', NOW_STAMP, '', DRAFT_PATH);
|
|
291
|
+
assert.equal(typeDir, 'posts');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// processContent — frontmatter mutations
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
test('processContent: sets draft: false', () => {
|
|
299
|
+
const text = makeDraft({ title: 'My Post', categories: ['Tech'], tags: ['go'], body: words(10) });
|
|
300
|
+
const { updated } = processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH);
|
|
301
|
+
assert.ok(updated.includes('draft: false'));
|
|
302
|
+
assert.ok(!updated.includes('draft: true'));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('processContent: sets date to dateStamp in now mode', () => {
|
|
306
|
+
const text = makeDraft({ title: 'My Post', categories: ['Tech'], tags: ['go'], body: words(10) });
|
|
307
|
+
const { updated } = processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH);
|
|
308
|
+
assert.ok(updated.includes(`date: ${NOW_STAMP}`));
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('processContent: sets date to publishStamp in later mode', () => {
|
|
312
|
+
const text = makeDraft({ title: 'My Post', categories: ['Tech'], tags: ['go'], body: words(10) });
|
|
313
|
+
const { updated } = processContent(text, 'later', 'my-post', '', LATER_STAMP, DRAFT_PATH);
|
|
314
|
+
assert.ok(updated.includes(`date: ${LATER_STAMP}`));
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('processContent: removes publishDate key', () => {
|
|
318
|
+
const text = makeDraft({ title: 'My Post', categories: ['Tech'], tags: ['go'], publishDate: '2026-01-01', body: words(10) });
|
|
319
|
+
const { updated } = processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH);
|
|
320
|
+
assert.ok(!updated.includes('publishDate'));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('processContent: slug is derived from frontmatter title', () => {
|
|
324
|
+
const text = makeDraft({ title: 'Hello World', categories: ['Tech'], tags: ['go'], body: words(10) });
|
|
325
|
+
const { slug } = processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH);
|
|
326
|
+
assert.equal(slug, 'hello-world');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('processContent: body content is preserved', () => {
|
|
330
|
+
const body = 'This is the body. ' + words(5);
|
|
331
|
+
const text = makeDraft({ title: 'My Post', categories: ['Tech'], tags: ['go'], body });
|
|
332
|
+
const { updated } = processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH);
|
|
333
|
+
assert.ok(updated.includes('This is the body.'));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// processContent — post-specific frontmatter
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
test('processContent: preserves existing description for post', () => {
|
|
341
|
+
const text = makeDraft({ title: 'My Post', description: 'Existing desc', categories: ['Tech'], tags: ['go'], body: words(10) });
|
|
342
|
+
const { updated } = processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH);
|
|
343
|
+
assert.ok(updated.includes('description: "Existing desc"'));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('processContent: retains placeholder description when post has no description set', () => {
|
|
347
|
+
// Archetype provides description: "" — processContent should not overwrite it
|
|
348
|
+
const text = makeDraft({ title: 'My Post', categories: ['Tech'], tags: ['go'], body: words(10) });
|
|
349
|
+
const { updated } = processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH);
|
|
350
|
+
assert.ok(updated.includes('description:'));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('processContent: categories block is present in published post', () => {
|
|
354
|
+
const text = makeDraft({ title: 'My Post', categories: ['Technology'], tags: ['go'], body: words(10) });
|
|
355
|
+
const { updated } = processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH);
|
|
356
|
+
assert.ok(updated.includes('categories:'));
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('processContent: tags block is present in published post', () => {
|
|
360
|
+
const text = makeDraft({ title: 'My Post', categories: ['Technology'], tags: ['go'], body: words(10) });
|
|
361
|
+
const { updated } = processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH);
|
|
362
|
+
assert.ok(updated.includes('tags:'));
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// processContent — note-specific frontmatter
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
test('processContent: strips tags from note', () => {
|
|
370
|
+
const text = makeDraft({ body: words(5) });
|
|
371
|
+
const { updated } = processContent(text, 'now', 'a-note', NOW_STAMP, '', DRAFT_PATH);
|
|
372
|
+
assert.ok(!updated.includes('tags:'));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('processContent: strips categories from note', () => {
|
|
376
|
+
const text = makeDraft({ categories: ['Personal'], body: words(5) });
|
|
377
|
+
const { updated } = processContent(text, 'now', 'a-note', NOW_STAMP, '', DRAFT_PATH);
|
|
378
|
+
assert.ok(!updated.includes('categories:'));
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('processContent: strips description from note', () => {
|
|
382
|
+
const text = makeDraft({ body: words(5) });
|
|
383
|
+
const { updated } = processContent(text, 'now', 'a-note', NOW_STAMP, '', DRAFT_PATH);
|
|
384
|
+
assert.ok(!updated.includes('description:'));
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('processContent: strips images from note', () => {
|
|
388
|
+
const text = makeDraft({ body: words(5) });
|
|
389
|
+
const { updated } = processContent(text, 'now', 'a-note', NOW_STAMP, '', DRAFT_PATH);
|
|
390
|
+
assert.ok(!updated.includes('images:'));
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// processContent — no frontmatter input
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
test('processContent: handles draft with no frontmatter as note', () => {
|
|
398
|
+
const { typeDir } = processContent(words(5), 'now', 'quick-note', NOW_STAMP, '', DRAFT_PATH);
|
|
399
|
+
assert.equal(typeDir, 'notes');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// processContent — error cases
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
test('processContent: throws for 200+ word draft with no real categories', () => {
|
|
407
|
+
const text = makeDraft({ body: words(200) });
|
|
408
|
+
assert.throws(
|
|
409
|
+
() => processContent(text, 'now', 'long-post', NOW_STAMP, '', DRAFT_PATH),
|
|
410
|
+
/notes must be under 200 words/
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('processContent: throws for post without a frontmatter title', () => {
|
|
415
|
+
// title: "" is the archetype default — parseTitleFromFm returns null
|
|
416
|
+
const text = makeDraft({ categories: ['Technology'], tags: ['go'], body: words(10) });
|
|
417
|
+
assert.throws(
|
|
418
|
+
() => processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH),
|
|
419
|
+
/must have a title/
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('processContent: throws for post with placeholder tags only', () => {
|
|
424
|
+
// tags: - (placeholder) counts as no tags
|
|
425
|
+
const text = makeDraft({ title: 'My Post', categories: ['Technology'], body: words(10) });
|
|
426
|
+
assert.throws(
|
|
427
|
+
() => processContent(text, 'now', 'my-post', NOW_STAMP, '', DRAFT_PATH),
|
|
428
|
+
/must have at least one tag/
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// stripQuotes
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
test('stripQuotes: strips double quotes', () => {
|
|
437
|
+
assert.equal(stripQuotes('"hello"'), 'hello');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('stripQuotes: strips single quotes', () => {
|
|
441
|
+
assert.equal(stripQuotes("'hello'"), 'hello');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test('stripQuotes: returns value unchanged when no quotes', () => {
|
|
445
|
+
assert.equal(stripQuotes('hello'), 'hello');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test('stripQuotes: preserves internal spaces', () => {
|
|
449
|
+
assert.equal(stripQuotes('"hello world"'), 'hello world');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('stripQuotes: trims surrounding whitespace', () => {
|
|
453
|
+
assert.equal(stripQuotes(' hello '), 'hello');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// ensurePlaceholderList
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
test('ensurePlaceholderList: adds placeholder when key is absent', () => {
|
|
461
|
+
const result = ensurePlaceholderList('title: foo\n', 'tags');
|
|
462
|
+
assert.ok(result.includes('tags:\n -\n'));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('ensurePlaceholderList: does not add when list with items already present', () => {
|
|
466
|
+
const fm = 'tags:\n - go\n';
|
|
467
|
+
const result = ensurePlaceholderList(fm, 'tags');
|
|
468
|
+
assert.equal(result, fm);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('ensurePlaceholderList: does not add when placeholder already present', () => {
|
|
472
|
+
const fm = 'tags:\n -\n';
|
|
473
|
+
const result = ensurePlaceholderList(fm, 'tags');
|
|
474
|
+
assert.equal(result, fm);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// formatChicagoDateTitle / formatChicagoTime
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
test('formatChicagoDateTitle: formats as Weekday, DD Month, YYYY', () => {
|
|
482
|
+
assert.equal(formatChicagoDateTitle('2026-05-04T10:00:00-05:00'), 'Monday, 04 May, 2026');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('formatChicagoDateTitle: uses Chicago time (CDT in summer)', () => {
|
|
486
|
+
// 2026-07-15 is a Wednesday
|
|
487
|
+
assert.equal(formatChicagoDateTitle('2026-07-15T08:00:00-05:00'), 'Wednesday, 15 July, 2026');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('formatChicagoTime: returns HH:MM in 24-hour format', () => {
|
|
491
|
+
assert.equal(formatChicagoTime('2026-05-04T10:30:00-05:00'), '10:30');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test('formatChicagoTime: pads minutes to two digits', () => {
|
|
495
|
+
assert.equal(formatChicagoTime('2026-05-04T08:05:00-05:00'), '08:05');
|
|
496
|
+
});
|
package/src/vscode.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Scaffolds VS Code config for a Hugo site using hugo-tools.
|
|
6
|
+
* Usage:
|
|
7
|
+
* vscode
|
|
8
|
+
*
|
|
9
|
+
* Writes to <vscodeDir>/ (default .vscode/):
|
|
10
|
+
* tasks.json — tasks for draft, add-tags, publish, pick-image
|
|
11
|
+
* extensions.json — recommended extensions
|
|
12
|
+
* settings.json — markdown editor settings
|
|
13
|
+
*
|
|
14
|
+
* Existing files are left untouched.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { requireHugoSite, vscodeDir } = require('./config');
|
|
20
|
+
|
|
21
|
+
const TASKS = {
|
|
22
|
+
version: '2.0.0',
|
|
23
|
+
tasks: [
|
|
24
|
+
{
|
|
25
|
+
label: 'Create Draft',
|
|
26
|
+
type: 'shell',
|
|
27
|
+
command: 'draft',
|
|
28
|
+
problemMatcher: [],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
label: 'Add Tags',
|
|
32
|
+
type: 'shell',
|
|
33
|
+
command: 'add-tags',
|
|
34
|
+
problemMatcher: [],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
label: 'Publish Draft (now)',
|
|
38
|
+
type: 'shell',
|
|
39
|
+
// publish prints "Published:\n <path>" — grep extracts the path and opens it
|
|
40
|
+
command: 'output=$(publish "${relativeFile}" now); echo "$output"; newFile=$(echo "$output" | grep -oE \'content/[^ ]+\\.md\'); [ -n "$newFile" ] && code -r "${workspaceFolder}/$newFile"',
|
|
41
|
+
problemMatcher: [],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
label: 'Publish Draft (later)',
|
|
45
|
+
type: 'shell',
|
|
46
|
+
command: 'read -p "Publish date (YYYY-MM-DD): " d; output=$(publish "${relativeFile}" later "$d"); echo "$output"; newFile=$(echo "$output" | grep -oE \'content/[^ ]+\\.md\'); [ -n "$newFile" ] && code -r "${workspaceFolder}/$newFile"',
|
|
47
|
+
problemMatcher: [],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
label: 'Pick Image',
|
|
51
|
+
type: 'shell',
|
|
52
|
+
command: 'pick-image',
|
|
53
|
+
problemMatcher: [],
|
|
54
|
+
presentation: { focus: true },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const EXTENSIONS = {
|
|
60
|
+
recommendations: [
|
|
61
|
+
'chriswiegman.cw-markdown-word-count',
|
|
62
|
+
'streetsidesoftware.code-spell-checker',
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const SETTINGS = {
|
|
67
|
+
'[markdown]': {
|
|
68
|
+
'editor.quickSuggestions': {
|
|
69
|
+
comments: false,
|
|
70
|
+
other: false,
|
|
71
|
+
strings: true,
|
|
72
|
+
},
|
|
73
|
+
'editor.snippetSuggestions': 'top',
|
|
74
|
+
'editor.suggest.showSnippets': true,
|
|
75
|
+
'editor.wordBasedSuggestions': 'off',
|
|
76
|
+
},
|
|
77
|
+
'workbench.editor.closeOnFileDelete': true,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function writeIfAbsent(filePath, content) {
|
|
81
|
+
const rel = path.relative(process.cwd(), filePath);
|
|
82
|
+
if (fs.existsSync(filePath)) {
|
|
83
|
+
console.log(` skipped ${rel} (already exists)`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
87
|
+
console.log(` created ${rel}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function main() {
|
|
91
|
+
requireHugoSite();
|
|
92
|
+
|
|
93
|
+
const dir = path.join(process.cwd(), vscodeDir);
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
|
|
96
|
+
writeIfAbsent(path.join(dir, 'tasks.json'), JSON.stringify(TASKS, null, 2) + '\n');
|
|
97
|
+
writeIfAbsent(path.join(dir, 'extensions.json'), JSON.stringify(EXTENSIONS, null, 2) + '\n');
|
|
98
|
+
writeIfAbsent(path.join(dir, 'settings.json'), JSON.stringify(SETTINGS, null, 2) + '\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
main();
|