@chriswiegman/hugo-tools 0.1.1 → 0.2.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 CHANGED
@@ -5,6 +5,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
6
6
  and is generated by [Changie](https://github.com/miniscruff/changie).
7
7
 
8
+ ## 0.2.0 - 2026-05-09
9
+
10
+ ### Features
11
+
12
+ * Rework pick-image to move images into the dated assets directory and insert references directly into content files (frontmatter images array or markdown image tag)
13
+
14
+ ### Chores
15
+
16
+ * Update project dependencies.
17
+
18
+ ## 0.1.2 - 2026-05-04
19
+
20
+ ### Features
21
+
22
+ * VS Code command also does initial tag extraction.
23
+
24
+ ### Bug Fixes
25
+
26
+ * Ensure VS Code tasks run on appropriate JS targets
27
+
8
28
  ## 0.1.1 - 2026-05-04
9
29
 
10
30
  ### Features
package/README.md CHANGED
@@ -196,13 +196,34 @@ Requires `extract-tags` to have been run at least once.
196
196
 
197
197
  ### `pick-image`
198
198
 
199
- Lists images in your `imagesDir` for the current year/month, lets you pick one, and copies the Hugo-relative path to the clipboard.
199
+ Moves an image into your `imagesDir` under the current year/month and inserts a reference into a Hugo content file.
200
200
 
201
201
  ```sh
202
- pick-image
202
+ pick-image <image-file> [content-file]
203
203
  ```
204
204
 
205
- Clipboard support: macOS (`pbcopy`), Windows (`clip`), Linux (`xclip`, `xsel`, or `wl-copy` — whichever is available).
205
+ **What it does:**
206
+
207
+ - Moves `<image-file>` to `assets/images/YYYY/MM/<filename>` (creates the directory if needed)
208
+ - Derives the Hugo URL path by stripping the `assets/` prefix (e.g. `/images/YYYY/MM/filename`)
209
+ - If a `[content-file]` is given:
210
+ - Appends the path to the `images:` array in front matter when that field is present (replacing any placeholder ` -` entry)
211
+ - Otherwise appends a `![](path)` markdown tag to the end of the body
212
+ - If no `[content-file]` is given, copies the URL path to the clipboard instead
213
+
214
+ **Examples:**
215
+
216
+ ```sh
217
+ # Move photo.jpg and insert into the currently open draft
218
+ pick-image ~/Downloads/photo.jpg content/drafts/my-post.md
219
+
220
+ # Move photo.jpg and copy the path to the clipboard
221
+ pick-image ~/Downloads/photo.jpg
222
+ ```
223
+
224
+ The VS Code "Pick Image" task prompts for the image path and automatically passes the currently open file as the content target — drag the image file into the terminal prompt to fill the path.
225
+
226
+ Clipboard support (no-content-file mode): macOS (`pbcopy`), Windows (`clip`), Linux (`xclip`, `xsel`, or `wl-copy` — whichever is available).
206
227
 
207
228
  ---
208
229
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chriswiegman/hugo-tools",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A set of tools to make blogging easier with Hugo.",
5
5
  "keywords": [
6
6
  "hugo",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "scripts": {
43
43
  "lint": "eslint src/",
44
- "test": "node --test src/book.test.mjs src/publish.test.mjs src/extract-tags.test.mjs src/draft.test.mjs"
44
+ "test": "node --test src/book.test.mjs src/publish.test.mjs src/extract-tags.test.mjs src/draft.test.mjs src/pick-image.test.mjs"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@eslint/js": "^10.0.1",
@@ -231,4 +231,4 @@ if (require.main === module) {
231
231
  main();
232
232
  }
233
233
 
234
- module.exports = { extractFrontmatter, parseListItems, generatePrefixes };
234
+ module.exports = { extractFrontmatter, parseListItems, generatePrefixes, main };
package/src/pick-image.js CHANGED
@@ -1,42 +1,43 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- // Lists images in the configured images directory for the current year/month
5
- // and copies the selected path to the clipboard.
4
+ // Moves an image into assets/images/<year>/<month>/ and inserts a reference
5
+ // into a Hugo content file — appending to the frontmatter images: array when
6
+ // present, or appending a markdown image tag to the body otherwise.
6
7
 
7
8
  const fs = require('fs');
8
9
  const path = require('path');
9
- const readline = require('readline');
10
10
  const { execSync, execFileSync } = require('child_process');
11
11
  const config = require('./config');
12
12
 
13
- config.requireHugoSite();
14
-
15
13
  const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
16
14
 
17
- const now = new Date();
18
- const year = now.getFullYear().toString();
19
- const month = String(now.getMonth() + 1).padStart(2, '0');
20
- const imageDir = path.join(process.cwd(), config.imagesDir, year, month);
21
- const imagePath = `/${config.imagesDir}/${year}/${month}`;
22
-
23
- if (!fs.existsSync(imageDir)) {
24
- console.error(`No image directory found at ${imageDir}`);
25
- process.exit(1);
15
+ function urlFromImagesDir(imagesDir, year, month, filename) {
16
+ const urlBase = imagesDir.replace(/^assets\//, '');
17
+ return `/${urlBase}/${year}/${month}/${filename}`;
26
18
  }
27
19
 
28
- const files = fs.readdirSync(imageDir)
29
- .filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()))
30
- .sort();
31
-
32
- if (files.length === 0) {
33
- console.error(`No images found in ${imageDir}`);
34
- process.exit(1);
20
+ function appendMarkdown(content, urlPath) {
21
+ return `${content.trimEnd()}\n\n![](${urlPath})\n`;
35
22
  }
36
23
 
37
- console.log(`\nImages in ${imagePath}/:\n`);
38
- files.forEach((f, i) => console.log(` ${i + 1}) ${f}`));
39
- console.log('');
24
+ function insertImage(content, urlPath) {
25
+ const fmEnd = content.indexOf('\n---', 4);
26
+ if (!content.startsWith('---\n') || fmEnd === -1) {
27
+ return appendMarkdown(content, urlPath);
28
+ }
29
+ const frontmatter = content.slice(4, fmEnd);
30
+ if (!/^images:/m.test(frontmatter)) {
31
+ return appendMarkdown(content, urlPath);
32
+ }
33
+ return content.replace(
34
+ /^(images:)((?:\n {2}-[^\n]*)*)/m,
35
+ (_, key, block) => {
36
+ const existing = block.split('\n').filter(line => /^ {2}- \S/.test(line));
37
+ return [key, ...existing, ` - ${urlPath}`].join('\n');
38
+ }
39
+ );
40
+ }
40
41
 
41
42
  function copyToClipboard(text) {
42
43
  const platform = process.platform;
@@ -48,7 +49,6 @@ function copyToClipboard(text) {
48
49
  execFileSync('clip', { input: text });
49
50
  return;
50
51
  }
51
- // Linux: try common clipboard tools in order
52
52
  const tools = [
53
53
  ['xclip', ['-selection', 'clipboard']],
54
54
  ['xsel', ['--clipboard', '--input']],
@@ -65,16 +65,63 @@ function copyToClipboard(text) {
65
65
  console.log('(clipboard unavailable — copy the path above manually)');
66
66
  }
67
67
 
68
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
69
- rl.question(`Select image (1-${files.length}): `, answer => {
70
- rl.close();
71
- const choice = parseInt(answer, 10);
72
- if (choice >= 1 && choice <= files.length) {
73
- const result = `${imagePath}/${files[choice - 1]}`;
74
- copyToClipboard(result);
75
- console.log(`\nCopied to clipboard: ${result}`);
76
- } else {
77
- console.error('Invalid selection');
68
+ if (require.main === module) {
69
+ config.requireHugoSite();
70
+
71
+ const args = process.argv.slice(2);
72
+ if (args.length === 0) {
73
+ console.error('Usage: pick-image <image-file> [content-file]');
74
+ process.exit(1);
75
+ }
76
+
77
+ const imageFile = path.resolve(args[0]);
78
+ const contentFile = args[1] ? path.resolve(args[1]) : null;
79
+
80
+ if (!fs.existsSync(imageFile)) {
81
+ console.error(`Image not found: ${imageFile}`);
82
+ process.exit(1);
83
+ }
84
+
85
+ const ext = path.extname(imageFile).toLowerCase();
86
+ if (!IMAGE_EXTS.has(ext)) {
87
+ console.error(`Unsupported image format: ${ext}`);
88
+ process.exit(1);
89
+ }
90
+
91
+ const now = new Date();
92
+ const year = now.getFullYear().toString();
93
+ const month = String(now.getMonth() + 1).padStart(2, '0');
94
+ const filename = path.basename(imageFile);
95
+ const destDir = path.join(process.cwd(), config.imagesDir, year, month);
96
+ const destFile = path.join(destDir, filename);
97
+ const urlPath = urlFromImagesDir(config.imagesDir, year, month, filename);
98
+
99
+ fs.mkdirSync(destDir, { recursive: true });
100
+
101
+ if (path.resolve(imageFile) !== path.resolve(destFile)) {
102
+ if (fs.existsSync(destFile)) {
103
+ console.error(`File already exists at destination: ${destFile}`);
104
+ process.exit(1);
105
+ }
106
+ fs.renameSync(imageFile, destFile);
107
+ console.log(`Moved to: ${destFile}`);
108
+ }
109
+
110
+ if (!contentFile) {
111
+ copyToClipboard(urlPath);
112
+ console.log(`Path copied to clipboard: ${urlPath}`);
113
+ process.exit(0);
114
+ }
115
+
116
+ if (!fs.existsSync(contentFile)) {
117
+ console.error(`Content file not found: ${contentFile}`);
78
118
  process.exit(1);
79
119
  }
80
- });
120
+
121
+ const original = fs.readFileSync(contentFile, 'utf8');
122
+ const updated = insertImage(original, urlPath);
123
+ fs.writeFileSync(contentFile, updated);
124
+ console.log(`Updated: ${path.relative(process.cwd(), contentFile)}`);
125
+ }
126
+
127
+ module.exports = { urlFromImagesDir, appendMarkdown, insertImage };
@@ -0,0 +1,110 @@
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 { urlFromImagesDir, appendMarkdown, insertImage } = require('./pick-image.js');
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // urlFromImagesDir
10
+ // ---------------------------------------------------------------------------
11
+
12
+ test('urlFromImagesDir: strips assets/ prefix', () => {
13
+ assert.equal(urlFromImagesDir('assets/images', '2026', '05', 'photo.jpg'), '/images/2026/05/photo.jpg');
14
+ });
15
+
16
+ test('urlFromImagesDir: preserves dir without assets/ prefix', () => {
17
+ assert.equal(urlFromImagesDir('images', '2026', '05', 'photo.jpg'), '/images/2026/05/photo.jpg');
18
+ });
19
+
20
+ test('urlFromImagesDir: preserves custom directory name', () => {
21
+ assert.equal(urlFromImagesDir('assets/media', '2026', '05', 'photo.jpg'), '/media/2026/05/photo.jpg');
22
+ });
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // appendMarkdown
26
+ // ---------------------------------------------------------------------------
27
+
28
+ test('appendMarkdown: appends image tag with blank line separator', () => {
29
+ const result = appendMarkdown('Body text.\n', '/images/2026/05/photo.jpg');
30
+ assert.equal(result, 'Body text.\n\n![](/images/2026/05/photo.jpg)\n');
31
+ });
32
+
33
+ test('appendMarkdown: trims trailing whitespace before appending', () => {
34
+ const result = appendMarkdown('Body text.\n\n\n', '/images/2026/05/photo.jpg');
35
+ assert.equal(result, 'Body text.\n\n![](/images/2026/05/photo.jpg)\n');
36
+ });
37
+
38
+ test('appendMarkdown: works when content has no trailing newline', () => {
39
+ const result = appendMarkdown('Body text.', '/images/2026/05/photo.jpg');
40
+ assert.equal(result, 'Body text.\n\n![](/images/2026/05/photo.jpg)\n');
41
+ });
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // insertImage — no frontmatter or no images: field → markdown fallback
45
+ // ---------------------------------------------------------------------------
46
+
47
+ test('insertImage: appends markdown when no frontmatter', () => {
48
+ const content = 'Just a plain body.\n';
49
+ const result = insertImage(content, '/images/2026/05/photo.jpg');
50
+ assert.ok(result.includes('![](/images/2026/05/photo.jpg)'));
51
+ assert.ok(result.includes('Just a plain body.'));
52
+ });
53
+
54
+ test('insertImage: appends markdown when frontmatter has no images: field', () => {
55
+ const content = '---\ntitle: "My Post"\n---\nBody text.\n';
56
+ const result = insertImage(content, '/images/2026/05/photo.jpg');
57
+ assert.ok(result.includes('![](/images/2026/05/photo.jpg)'));
58
+ assert.ok(result.includes('Body text.'));
59
+ });
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // insertImage — frontmatter images: array manipulation
63
+ // ---------------------------------------------------------------------------
64
+
65
+ test('insertImage: replaces placeholder ( -) with real path', () => {
66
+ const content = '---\ntitle: "My Post"\nimages:\n -\n---\nBody.\n';
67
+ const result = insertImage(content, '/images/2026/05/photo.jpg');
68
+ assert.ok(result.includes(' - /images/2026/05/photo.jpg'));
69
+ assert.ok(!result.includes('\n -\n'));
70
+ assert.ok(!result.includes('![]('));
71
+ });
72
+
73
+ test('insertImage: appends to existing single-item images array', () => {
74
+ const content = '---\ntitle: "My Post"\nimages:\n - /images/2026/01/old.jpg\n---\nBody.\n';
75
+ const result = insertImage(content, '/images/2026/05/new.jpg');
76
+ assert.ok(result.includes(' - /images/2026/01/old.jpg'));
77
+ assert.ok(result.includes(' - /images/2026/05/new.jpg'));
78
+ assert.ok(!result.includes('![]('));
79
+ });
80
+
81
+ test('insertImage: appends to existing multi-item images array', () => {
82
+ const content = '---\nimages:\n - /images/2026/01/a.jpg\n - /images/2026/02/b.jpg\n---\n';
83
+ const result = insertImage(content, '/images/2026/05/c.jpg');
84
+ const lines = result.split('\n');
85
+ const imgLines = lines.filter(l => l.startsWith(' - '));
86
+ assert.equal(imgLines.length, 3);
87
+ assert.equal(imgLines[2], ' - /images/2026/05/c.jpg');
88
+ });
89
+
90
+ test('insertImage: does not duplicate existing items', () => {
91
+ const content = '---\nimages:\n - /images/2026/01/a.jpg\n---\n';
92
+ const result = insertImage(content, '/images/2026/05/b.jpg');
93
+ const count = (result.match(/ {2}- \/images/g) || []).length;
94
+ assert.equal(count, 2);
95
+ });
96
+
97
+ test('insertImage: leaves other frontmatter fields untouched', () => {
98
+ const content = '---\ntitle: "My Post"\nimages:\n -\ncategories:\n - Tech\ntags:\n - go\n---\nBody.\n';
99
+ const result = insertImage(content, '/images/2026/05/photo.jpg');
100
+ assert.ok(result.includes('title: "My Post"'));
101
+ assert.ok(result.includes('categories:\n - Tech'));
102
+ assert.ok(result.includes('tags:\n - go'));
103
+ assert.ok(result.includes('Body.'));
104
+ });
105
+
106
+ test('insertImage: body content is preserved when updating frontmatter', () => {
107
+ const content = '---\ntitle: "My Post"\nimages:\n -\n---\nThis is the body.\n';
108
+ const result = insertImage(content, '/images/2026/05/photo.jpg');
109
+ assert.ok(result.includes('This is the body.'));
110
+ });
package/src/vscode.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * vscode
8
8
  *
9
9
  * Writes to <vscodeDir>/ (default .vscode/):
10
- * tasks.json — tasks for draft, add-tags, publish, pick-image
10
+ * tasks.json — tasks for draft, extract-tags, add-tags, publish, pick-image
11
11
  * extensions.json — recommended extensions
12
12
  * settings.json — markdown editor settings
13
13
  *
@@ -17,42 +17,44 @@
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { requireHugoSite, vscodeDir } = require('./config');
20
+ const { main: extractTags } = require('./extract-tags');
20
21
 
21
22
  const TASKS = {
22
- version: '2.0.0',
23
- tasks: [
23
+ "version": "2.0.0",
24
+ "tasks": [
24
25
  {
25
- label: 'Create Draft',
26
- type: 'shell',
27
- command: 'draft',
28
- problemMatcher: [],
26
+ "label": "Create Draft",
27
+ "type": "shell",
28
+ "command": "npx draft",
29
+ "problemMatcher": []
29
30
  },
30
31
  {
31
- label: 'Add Tags',
32
- type: 'shell',
33
- command: 'add-tags',
34
- problemMatcher: [],
32
+ "label": "Add Tags",
33
+ "type": "shell",
34
+ "command": "npx add-tags",
35
+ "problemMatcher": []
35
36
  },
36
37
  {
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: [],
38
+ "label": "Publish Draft (now)",
39
+ "type": "shell",
40
+ "command": "output=$(npx publish \"${relativeFile}\" now); echo \"$output\"; newFile=$(echo \"$output\" | grep -oE 'content/[^ ]+\\.md'); [ -n \"$newFile\" ] && code -r \"${workspaceFolder}/$newFile\"",
41
+ "problemMatcher": []
42
42
  },
43
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: [],
44
+ "label": "Publish Draft (later)",
45
+ "type": "shell",
46
+ "command": "read -p \"Publish date (YYYY-MM-DD): \" d; output=$(npx publish \"${relativeFile}\" later \"$d\"); echo \"$output\"; newFile=$(echo \"$output\" | grep -oE 'content/[^ ]+\\.md'); [ -n \"$newFile\" ] && code -r \"${workspaceFolder}/$newFile\"",
47
+ "problemMatcher": []
48
48
  },
49
49
  {
50
- label: 'Pick Image',
51
- type: 'shell',
52
- command: 'pick-image',
53
- problemMatcher: [],
54
- presentation: { focus: true },
55
- },
50
+ "label": "Pick Image",
51
+ "type": "shell",
52
+ "command": "read -p 'Image path: ' img; npx pick-image \"$img\" \"${relativeFile}\"",
53
+ "problemMatcher": [],
54
+ "presentation": {
55
+ "focus": true
56
+ }
57
+ }
56
58
  ],
57
59
  };
58
60
 
@@ -93,9 +95,11 @@ function main() {
93
95
  const dir = path.join(process.cwd(), vscodeDir);
94
96
  fs.mkdirSync(dir, { recursive: true });
95
97
 
96
- writeIfAbsent(path.join(dir, 'tasks.json'), JSON.stringify(TASKS, null, 2) + '\n');
98
+ writeIfAbsent(path.join(dir, 'tasks.json'), JSON.stringify(TASKS, null, 2) + '\n');
97
99
  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');
100
+ writeIfAbsent(path.join(dir, 'settings.json'), JSON.stringify(SETTINGS, null, 2) + '\n');
101
+
102
+ extractTags();
99
103
  }
100
104
 
101
105
  main();