@chriswiegman/hugo-tools 0.1.2 → 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 +10 -0
- package/README.md +24 -3
- package/package.json +2 -2
- package/src/pick-image.js +83 -36
- package/src/pick-image.test.mjs +110 -0
- package/src/vscode.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ 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
|
+
|
|
8
18
|
## 0.1.2 - 2026-05-04
|
|
9
19
|
|
|
10
20
|
### 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
|
-
|
|
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
|
-
|
|
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 `` 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.
|
|
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",
|
package/src/pick-image.js
CHANGED
|
@@ -1,42 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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\n`;
|
|
35
22
|
}
|
|
36
23
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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\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\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\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(''));
|
|
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(''));
|
|
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