@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.
@@ -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();