seeemess 1.0.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/.github/workflows/publish.yml +34 -0
- package/.github/workflows/test.yml +30 -0
- package/CLAUDE.md +73 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/_config/filters.js +77 -0
- package/admin/config.js +84 -0
- package/admin/routes/images.js +126 -0
- package/admin/routes/posts.js +169 -0
- package/admin/routes/preview.js +111 -0
- package/admin/routes/publish.js +155 -0
- package/admin/routes/synopsis.js +39 -0
- package/admin/server.js +80 -0
- package/admin/templates/admin.eta +1377 -0
- package/admin/templates/preview.eta +266 -0
- package/admin/utils/git.js +16 -0
- package/admin/utils/markdown.js +137 -0
- package/index.js +29 -0
- package/package.json +51 -0
- package/test/config.test.js +157 -0
- package/test/filters.test.js +201 -0
- package/test/markdown.test.js +245 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [created]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
id-token: write
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-node@v4
|
|
17
|
+
with:
|
|
18
|
+
node-version: 20
|
|
19
|
+
cache: 'npm'
|
|
20
|
+
- run: npm ci
|
|
21
|
+
- run: npm test
|
|
22
|
+
|
|
23
|
+
publish:
|
|
24
|
+
needs: test
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: actions/setup-node@v4
|
|
29
|
+
with:
|
|
30
|
+
node-version: 20
|
|
31
|
+
cache: 'npm'
|
|
32
|
+
registry-url: 'https://registry.npmjs.org'
|
|
33
|
+
- run: npm ci
|
|
34
|
+
- run: npm publish --provenance --access public
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
node-version: [18, 20, 22]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Use Node.js ${{ matrix.node-version }}
|
|
21
|
+
uses: actions/setup-node@v4
|
|
22
|
+
with:
|
|
23
|
+
node-version: ${{ matrix.node-version }}
|
|
24
|
+
cache: 'npm'
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: npm ci
|
|
28
|
+
|
|
29
|
+
- name: Run tests
|
|
30
|
+
run: npm test
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
SeeEmEss is a CMS framework for Eleventy-based sites. It provides an admin interface for managing blog content with git-based publishing, image upload with automatic resizing, and Eleventy filters.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm test # No tests yet (placeholder)
|
|
13
|
+
npm run admin # Start admin server (configured in consuming project)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Node 20+ required (see .nvmrc).
|
|
17
|
+
|
|
18
|
+
## Architecture
|
|
19
|
+
|
|
20
|
+
### Entry Point (index.js)
|
|
21
|
+
Exports: `startAdminServer(options)`, `createConfig(options)`, `filters`, and config getters.
|
|
22
|
+
|
|
23
|
+
### Admin Server (admin/)
|
|
24
|
+
- **server.js**: Express app with routes for posts, images, publishing, preview, synopsis
|
|
25
|
+
- **config.js**: Singleton configuration pattern - `createConfig()` sets config, getters retrieve it
|
|
26
|
+
- **routes/**: API endpoints
|
|
27
|
+
- `posts.js`: CRUD for markdown posts, lede flagging, recursive directory scanning
|
|
28
|
+
- `images.js`: Base64 upload, sharp-based resizing (generates 4 sizes: thumb/400/600/800px)
|
|
29
|
+
- `publish.js`: Git workflow - creates branch, commits, merges to main with --no-ff, pushes
|
|
30
|
+
- `preview.js`: Markdown→HTML rendering with custom template support
|
|
31
|
+
- `synopsis.js`: Optional Ollama integration for AI summaries
|
|
32
|
+
- **utils/**:
|
|
33
|
+
- `git.js`: Git command helpers
|
|
34
|
+
- `markdown.js`: Custom YAML-like frontmatter parser (not full YAML)
|
|
35
|
+
- **templates/**: Eta templates for admin UI and preview
|
|
36
|
+
|
|
37
|
+
### Eleventy Integration (_config/filters.js)
|
|
38
|
+
11 filters: `readableDate`, `htmlDateString`, `head`, `min`, `getKeys`, `filterTagList`, `capitalize`, `sortAlphabetically`, `thumbSuffix`, `smartQuotes`
|
|
39
|
+
|
|
40
|
+
## Key Patterns
|
|
41
|
+
|
|
42
|
+
### Frontmatter Format
|
|
43
|
+
Custom parser in `admin/utils/markdown.js`. Supported fields: title, synopsis, date, tags, image, imageCaption, imageCredit, author, gallery, images, status, lede, showGallery.
|
|
44
|
+
|
|
45
|
+
### Git Publishing Workflow
|
|
46
|
+
Branch naming: `MMDDYYYY_word-word-timestamp` (48 pre-set words in config.js). Always merges with `--no-ff` for clear history.
|
|
47
|
+
|
|
48
|
+
### Image Processing
|
|
49
|
+
Uploads generate 4 size variants. Filenames sanitized to alphanumeric/hyphens/underscores. Size suffix added before extension (e.g., `photo-400.jpg`).
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
```javascript
|
|
54
|
+
startAdminServer({
|
|
55
|
+
sections: [{id, name, folder, tag}], // Required
|
|
56
|
+
cmsRoot: string, // Project root
|
|
57
|
+
contentDir: './content/blog',
|
|
58
|
+
publicDir: './public',
|
|
59
|
+
imageUrlPath: '/uploads',
|
|
60
|
+
previewTemplate: string, // Custom preview template path
|
|
61
|
+
port: 3000,
|
|
62
|
+
imageSizes: [150, 400, 600, 800],
|
|
63
|
+
branchWords: [] // Custom words for branch naming
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Important Notes
|
|
68
|
+
|
|
69
|
+
- Admin interface has no authentication (local-only by design)
|
|
70
|
+
- Requires git CLI in PATH
|
|
71
|
+
- Ollama with llama3 model optional for synopsis generation
|
|
72
|
+
- 50MB request limit for image uploads
|
|
73
|
+
- Relies on GitHub and GitHub Actions for publishing workflow
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2017–2024 Zach Leatherman @zachleat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# SeeEmEss
|
|
2
|
+
|
|
3
|
+
A simple CMS framework built on [Eleventy](https://www.11ty.dev/) with an admin interface for managing blog content. It is by-design simple.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Admin interface for creating and editing posts
|
|
8
|
+
- Image upload with automatic resizing
|
|
9
|
+
- Git-based publishing workflow
|
|
10
|
+
- Configurable content sections
|
|
11
|
+
- Eleventy filters for dates and content formatting
|
|
12
|
+
|
|
13
|
+
## Caveats
|
|
14
|
+
|
|
15
|
+
This is an opinionated project that is extremely limited. It relies on GitHub. The admin interface has no authentication mechanism, so it can only run locally with changes are published via GitHub actions.
|
|
16
|
+
|
|
17
|
+
If you think want this SeeEmEssOpen be better, I'm happy to review pull requests.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install seeemess
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### 1. Set up Eleventy filters
|
|
28
|
+
|
|
29
|
+
In your `eleventy.config.js`:
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
import filters from 'seeemess/filters';
|
|
33
|
+
|
|
34
|
+
export default function(eleventyConfig) {
|
|
35
|
+
// Add SeeEmEss filters
|
|
36
|
+
filters(eleventyConfig);
|
|
37
|
+
|
|
38
|
+
// Your other config...
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 2. Create admin entry point
|
|
43
|
+
|
|
44
|
+
Create `admin/start.js` in your project:
|
|
45
|
+
|
|
46
|
+
```javascript
|
|
47
|
+
import { startAdminServer } from 'seeemess';
|
|
48
|
+
|
|
49
|
+
startAdminServer({
|
|
50
|
+
sections: [
|
|
51
|
+
{ id: 'posts', name: 'Blog Posts', folder: 'posts', tag: 'post' },
|
|
52
|
+
{ id: 'news', name: 'News', folder: 'news', tag: 'news' }
|
|
53
|
+
]
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 3. Add npm script
|
|
58
|
+
|
|
59
|
+
In your `package.json`:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"scripts": {
|
|
64
|
+
"admin": "node admin/start.js"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 4. Run the admin
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm run admin
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The admin interface will be available at `http://localhost:3000`.
|
|
76
|
+
|
|
77
|
+
## Configuration Options
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
startAdminServer({
|
|
81
|
+
// Required: Define your content sections
|
|
82
|
+
sections: [
|
|
83
|
+
{ id: 'posts', name: 'Blog Posts', folder: 'posts', tag: 'post' }
|
|
84
|
+
],
|
|
85
|
+
|
|
86
|
+
// Optional: Override defaults
|
|
87
|
+
cmsRoot: process.cwd(), // Project root directory
|
|
88
|
+
contentDir: './content/blog', // Where posts are stored
|
|
89
|
+
publicDir: './public', // Where images are uploaded
|
|
90
|
+
port: 3000 // Admin server port
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Available Filters
|
|
95
|
+
|
|
96
|
+
When you import `seeemess/filters`, the following Eleventy filters are added:
|
|
97
|
+
|
|
98
|
+
- `readableDate` - Format dates for display
|
|
99
|
+
- `htmlDateString` - Format dates for HTML datetime attributes
|
|
100
|
+
- `head` - Get first N items from array
|
|
101
|
+
- `min` - Get minimum value
|
|
102
|
+
- `getKeys` - Get object keys
|
|
103
|
+
- `filterTagList` - Filter out system tags
|
|
104
|
+
- `capitalize` - Title case text
|
|
105
|
+
- `sortAlphabetically` - Sort strings alphabetically
|
|
106
|
+
- `thumbSuffix` - Add size suffix to image filenames
|
|
107
|
+
- `smartQuotes` - Convert straight quotes to typographic quotes
|
|
108
|
+
|
|
109
|
+
## Optional: AI Synopsis Generation
|
|
110
|
+
|
|
111
|
+
The admin interface includes a "Generate Synopsis" button that uses [Ollama](https://ollama.ai/) to automatically generate post summaries.
|
|
112
|
+
|
|
113
|
+
**Requirements:**
|
|
114
|
+
1. Install Ollama: https://ollama.ai/download
|
|
115
|
+
2. Pull the llama3 model: `ollama pull llama3`
|
|
116
|
+
3. Ensure Ollama is running when using the admin
|
|
117
|
+
|
|
118
|
+
If Ollama is not available, the button will show an error but the rest of the admin works normally.
|
|
119
|
+
|
|
120
|
+
## Project Structure
|
|
121
|
+
|
|
122
|
+
Your project should have this structure:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
your-site/
|
|
126
|
+
├── admin/
|
|
127
|
+
│ └── start.js # Admin entry point
|
|
128
|
+
├── content/
|
|
129
|
+
│ └── blog/ # Your posts (organized by section folders)
|
|
130
|
+
├── public/ # Uploaded images
|
|
131
|
+
├── _includes/ # Your Eleventy layouts
|
|
132
|
+
├── eleventy.config.js
|
|
133
|
+
└── package.json
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { DateTime } from "luxon";
|
|
2
|
+
|
|
3
|
+
export default function(eleventyConfig) {
|
|
4
|
+
eleventyConfig.addFilter("readableDate", (dateObj, format, zone) => {
|
|
5
|
+
// Formatting tokens for Luxon: https://moment.github.io/luxon/#/formatting?id=table-of-tokens
|
|
6
|
+
return DateTime.fromJSDate(dateObj, { zone: zone || "utc" }).toFormat(format || "dd LLLL yyyy");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
eleventyConfig.addFilter("htmlDateString", (dateObj) => {
|
|
10
|
+
// dateObj input: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-date-string
|
|
11
|
+
return DateTime.fromJSDate(dateObj, { zone: "utc" }).toFormat('yyyy-LL-dd');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Get the first `n` elements of a collection.
|
|
15
|
+
eleventyConfig.addFilter("head", (array, n) => {
|
|
16
|
+
if(!Array.isArray(array) || array.length === 0) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
if( n < 0 ) {
|
|
20
|
+
return array.slice(n);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return array.slice(0, n);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Return the smallest number argument
|
|
27
|
+
eleventyConfig.addFilter("min", (...numbers) => {
|
|
28
|
+
return Math.min.apply(null, numbers);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Return the keys used in an object
|
|
32
|
+
eleventyConfig.addFilter("getKeys", target => {
|
|
33
|
+
return Object.keys(target);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
eleventyConfig.addFilter("filterTagList", function filterTagList(tags) {
|
|
37
|
+
const filtered = (tags || []).filter(tag => ["all", "posts"].indexOf(tag) === -1);
|
|
38
|
+
return [...new Set(filtered)]; // deduplicate
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Title case: capitalize first letter of each word, lowercase the rest
|
|
42
|
+
eleventyConfig.addFilter("capitalize", (str) => {
|
|
43
|
+
if (!str) return str;
|
|
44
|
+
return str.split(' ').map(word =>
|
|
45
|
+
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
|
46
|
+
).join(' ');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
eleventyConfig.addFilter("sortAlphabetically", strings =>
|
|
50
|
+
(strings || []).sort((b, a) => b.localeCompare(a))
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Add suffix before file extension (e.g., "photo.jpg" -> "photo-400.jpg")
|
|
54
|
+
eleventyConfig.addFilter("thumbSuffix", (filename, suffix = "-400") => {
|
|
55
|
+
if (!filename) return filename;
|
|
56
|
+
const lastDot = filename.lastIndexOf('.');
|
|
57
|
+
if (lastDot === -1) return filename + suffix;
|
|
58
|
+
return filename.slice(0, lastDot) + suffix + filename.slice(lastDot);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Convert straight quotes to typographic "smart" quotes
|
|
62
|
+
eleventyConfig.addFilter("smartQuotes", (str) => {
|
|
63
|
+
if (!str) return str;
|
|
64
|
+
return str
|
|
65
|
+
// Double quotes: " -> curly quotes
|
|
66
|
+
.replace(/"(\S)/g, '\u201C$1') // Opening double quote (before non-space)
|
|
67
|
+
.replace(/(\S)"/g, '$1\u201D') // Closing double quote (after non-space)
|
|
68
|
+
.replace(/"\s/g, '\u201D ') // Closing double quote before space
|
|
69
|
+
.replace(/\s"/g, ' \u201C') // Opening double quote after space
|
|
70
|
+
.replace(/^"/g, '\u201C') // Opening double quote at start
|
|
71
|
+
// Single quotes/apostrophes: ' -> curly quotes
|
|
72
|
+
.replace(/'(\S)/g, '\u2018$1') // Opening single quote
|
|
73
|
+
.replace(/(\S)'/g, '$1\u2019') // Closing single quote / apostrophe
|
|
74
|
+
.replace(/'\s/g, '\u2019 ') // Closing single quote before space
|
|
75
|
+
.replace(/\s'/g, ' \u2018'); // Opening single quote after space
|
|
76
|
+
});
|
|
77
|
+
};
|
package/admin/config.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
|
|
3
|
+
// Default configuration values
|
|
4
|
+
const DEFAULT_IMAGE_SIZES = [
|
|
5
|
+
{ suffix: '-thumb', width: 150 },
|
|
6
|
+
{ suffix: '-400', width: 400 },
|
|
7
|
+
{ suffix: '-600', width: 600 },
|
|
8
|
+
{ suffix: '-800', width: 800 }
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const DEFAULT_BRANCH_WORDS = [
|
|
12
|
+
'alpha','bravo','charlie','delta','echo','foxtrot','golf','hotel','india',
|
|
13
|
+
'juliet','kilo','lima','mike','november','oscar','papa','quebec','romeo',
|
|
14
|
+
'sierra','tango','uniform','victor','whiskey','xray','yankee','zulu',
|
|
15
|
+
'apple','banana','cherry','dragon','eagle','falcon','grape','hawk','iris',
|
|
16
|
+
'jade','karma','lemon','mango','ninja','olive','pearl','quartz','ruby',
|
|
17
|
+
'sage','tiger','ultra','violet','willow','xenon','yellow','zebra'
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Singleton config - set by createConfig()
|
|
21
|
+
let currentConfig = null;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a configuration object for the CMS admin
|
|
25
|
+
* @param {Object} options Configuration options
|
|
26
|
+
* @param {string} [options.cmsRoot] Root directory of the CMS project (defaults to process.cwd())
|
|
27
|
+
* @param {string} [options.contentDir] Content directory path (defaults to cmsRoot/content/blog)
|
|
28
|
+
* @param {string} [options.publicDir] Public/uploads directory path (defaults to cmsRoot/public)
|
|
29
|
+
* @param {string} [options.imageUrlPath] URL path prefix for images in final site (defaults to '/uploads')
|
|
30
|
+
* @param {string} [options.previewTemplate] Path to custom preview template (relative to cmsRoot)
|
|
31
|
+
* @param {number} [options.port] Server port (defaults to 3000 or PORT env var)
|
|
32
|
+
* @param {Array} options.sections Array of section definitions: { id, name, folder, tag }
|
|
33
|
+
* @param {Array} [options.imageSizes] Array of image size definitions: { suffix, width }
|
|
34
|
+
* @param {Array} [options.branchWords] Array of words for git branch name generation
|
|
35
|
+
* @returns {Object} Configuration object
|
|
36
|
+
*/
|
|
37
|
+
export function createConfig(options = {}) {
|
|
38
|
+
const cmsRoot = options.cmsRoot || process.cwd();
|
|
39
|
+
|
|
40
|
+
// Normalize imageUrlPath - ensure it starts with / and doesn't end with /
|
|
41
|
+
let imageUrlPath = options.imageUrlPath || '/uploads';
|
|
42
|
+
if (!imageUrlPath.startsWith('/')) imageUrlPath = '/' + imageUrlPath;
|
|
43
|
+
if (imageUrlPath.endsWith('/')) imageUrlPath = imageUrlPath.slice(0, -1);
|
|
44
|
+
|
|
45
|
+
const config = {
|
|
46
|
+
CMS_ROOT: cmsRoot,
|
|
47
|
+
CONTENT_DIR: options.contentDir || join(cmsRoot, 'content', 'blog'),
|
|
48
|
+
PUBLIC_DIR: options.publicDir || join(cmsRoot, 'public'),
|
|
49
|
+
IMAGE_URL_PATH: imageUrlPath,
|
|
50
|
+
PREVIEW_TEMPLATE: options.previewTemplate ? join(cmsRoot, options.previewTemplate) : null,
|
|
51
|
+
PORT: options.port || process.env.PORT || 3000,
|
|
52
|
+
SECTIONS: options.sections || [],
|
|
53
|
+
IMAGE_SIZES: options.imageSizes || DEFAULT_IMAGE_SIZES,
|
|
54
|
+
BRANCH_WORDS: options.branchWords || DEFAULT_BRANCH_WORDS
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Set as current config for routes to access
|
|
58
|
+
currentConfig = config;
|
|
59
|
+
|
|
60
|
+
return config;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the current configuration (must call createConfig first)
|
|
65
|
+
* @returns {Object} Current configuration object
|
|
66
|
+
* @throws {Error} If createConfig hasn't been called
|
|
67
|
+
*/
|
|
68
|
+
export function getConfig() {
|
|
69
|
+
if (!currentConfig) {
|
|
70
|
+
throw new Error('Config not initialized. Call createConfig() first.');
|
|
71
|
+
}
|
|
72
|
+
return currentConfig;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Export individual getters for backward compatibility during migration
|
|
76
|
+
export const getCmsRoot = () => getConfig().CMS_ROOT;
|
|
77
|
+
export const getContentDir = () => getConfig().CONTENT_DIR;
|
|
78
|
+
export const getPublicDir = () => getConfig().PUBLIC_DIR;
|
|
79
|
+
export const getImageUrlPath = () => getConfig().IMAGE_URL_PATH;
|
|
80
|
+
export const getPreviewTemplate = () => getConfig().PREVIEW_TEMPLATE;
|
|
81
|
+
export const getPort = () => getConfig().PORT;
|
|
82
|
+
export const getSections = () => getConfig().SECTIONS;
|
|
83
|
+
export const getImageSizes = () => getConfig().IMAGE_SIZES;
|
|
84
|
+
export const getBranchWords = () => getConfig().BRANCH_WORDS;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { join, parse as parsePath } from 'path';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { getPublicDir, getImageSizes } from '../config.js';
|
|
6
|
+
import { runGit } from '../utils/git.js';
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
// Track deleted images for git staging
|
|
11
|
+
export let deletedImages = [];
|
|
12
|
+
|
|
13
|
+
export function clearDeletedImages() {
|
|
14
|
+
deletedImages = [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// List images
|
|
18
|
+
router.get('/', (req, res) => {
|
|
19
|
+
const images = fs.readdirSync(getPublicDir()).filter(f => /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(f));
|
|
20
|
+
res.json(images);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Upload image (creates multiple sizes)
|
|
24
|
+
router.post('/', async (req, res) => {
|
|
25
|
+
const { filename, data } = req.body;
|
|
26
|
+
if (!filename || !data) return res.status(400).json({ error: 'Missing filename or data' });
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
|
|
30
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
31
|
+
|
|
32
|
+
const parsed = parsePath(filename);
|
|
33
|
+
const safeName = parsed.name.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
34
|
+
const ext = parsed.ext.toLowerCase() || '.jpg';
|
|
35
|
+
|
|
36
|
+
// Save original
|
|
37
|
+
const originalName = safeName + ext;
|
|
38
|
+
const originalPath = join(getPublicDir(), originalName);
|
|
39
|
+
fs.writeFileSync(originalPath, buffer);
|
|
40
|
+
|
|
41
|
+
// Generate resized versions
|
|
42
|
+
const generatedFiles = [originalName];
|
|
43
|
+
const sharpInstance = sharp(buffer);
|
|
44
|
+
const metadata = await sharpInstance.metadata();
|
|
45
|
+
|
|
46
|
+
// Use sizes from config, but with different width thresholds
|
|
47
|
+
const sizes = [
|
|
48
|
+
{ width: 800, suffix: '-800' },
|
|
49
|
+
{ width: 600, suffix: '-600' },
|
|
50
|
+
{ width: 400, suffix: '-400' },
|
|
51
|
+
{ width: 150, suffix: '-thumb' }
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
for (const size of sizes) {
|
|
55
|
+
if (metadata.width && metadata.width > size.width) {
|
|
56
|
+
const resizedName = safeName + size.suffix + ext;
|
|
57
|
+
const resizedPath = join(getPublicDir(), resizedName);
|
|
58
|
+
|
|
59
|
+
await sharp(buffer)
|
|
60
|
+
.resize(size.width, null, { withoutEnlargement: true })
|
|
61
|
+
.toFile(resizedPath);
|
|
62
|
+
|
|
63
|
+
generatedFiles.push(resizedName);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log('Generated images:', generatedFiles);
|
|
68
|
+
res.json({
|
|
69
|
+
success: true,
|
|
70
|
+
filename: originalName,
|
|
71
|
+
sizes: generatedFiles,
|
|
72
|
+
original: { width: metadata.width, height: metadata.height }
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error('Image processing error:', err);
|
|
76
|
+
res.status(500).json({ error: 'Image processing failed: ' + err.message });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Delete an image and all its sizes
|
|
81
|
+
router.delete('/:filename', (req, res) => {
|
|
82
|
+
const filename = req.params.filename;
|
|
83
|
+
if (!filename) return res.status(400).json({ error: 'Missing filename' });
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const parsed = parsePath(filename);
|
|
87
|
+
const baseName = parsed.name;
|
|
88
|
+
const ext = parsed.ext;
|
|
89
|
+
|
|
90
|
+
const sizes = ['', '-800', '-600', '-400', '-thumb'];
|
|
91
|
+
const deleted = [];
|
|
92
|
+
const gitTracked = [];
|
|
93
|
+
|
|
94
|
+
sizes.forEach(suffix => {
|
|
95
|
+
const imgName = baseName + suffix + ext;
|
|
96
|
+
const imgPath = join(getPublicDir(), imgName);
|
|
97
|
+
const gitPath = 'public/' + imgName;
|
|
98
|
+
|
|
99
|
+
if (fs.existsSync(imgPath)) {
|
|
100
|
+
fs.unlinkSync(imgPath);
|
|
101
|
+
deleted.push(imgName);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
runGit('git ls-files --error-unmatch "' + gitPath + '"');
|
|
105
|
+
runGit('git rm --cached "' + gitPath + '"');
|
|
106
|
+
gitTracked.push(imgName);
|
|
107
|
+
deletedImages.push(gitPath);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
// File not tracked in git
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
console.log('Deleted images:', deleted);
|
|
115
|
+
if (gitTracked.length > 0) {
|
|
116
|
+
console.log('Staged for git deletion:', gitTracked);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
res.json({ success: true, deleted, gitTracked });
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error('Delete image error:', err);
|
|
122
|
+
res.status(500).json({ error: err.message });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export default router;
|