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.
@@ -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
+ };
@@ -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;