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,266 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Preview: <%= it.title || 'Untitled' %></title>
|
|
7
|
+
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Playfair+Display:wght@400;700&family=Playfair+Display+SC&display=swap" rel="stylesheet">
|
|
8
|
+
<link rel="stylesheet" href="/css/index.css">
|
|
9
|
+
<style>
|
|
10
|
+
.preview-banner{background:#333;color:#fff;padding:0.5rem 1rem;text-align:center;font-size:0.85rem;position:sticky;top:0;z-index:100;}
|
|
11
|
+
.byline::first-letter{font-size:inherit;float:none;padding:0;line-height:inherit;}
|
|
12
|
+
.post-content>p:first-child::first-letter{font-family:var(--font-headline);font-size:4rem;float:left;line-height:0.75;padding-top:8px;padding-right:12px;font-weight:400;color:var(--ink-color);}
|
|
13
|
+
.post-image-wrapper{position:relative;display:inline-block;width:100%;}
|
|
14
|
+
.post-image-wrapper .post-image{display:block;width:100%;}
|
|
15
|
+
.image-caption,.image-credit{position:absolute;bottom:0.5rem;padding:0.25rem 0.5rem;background:rgba(40,40,40,0.85);color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:0.75rem;font-style:normal;max-width:60%;line-height:1.3;}
|
|
16
|
+
.image-caption{left:0.5rem;}
|
|
17
|
+
.image-credit{right:0.5rem;text-align:right;}
|
|
18
|
+
.post-content ul{list-style:none;padding-left:1.5em;margin:1.25rem 0;}
|
|
19
|
+
.post-content ul li{position:relative;margin-bottom:0.5em;padding-left:0.25em;}
|
|
20
|
+
.post-content ul li::before{content:"✦";position:absolute;left:-1.25em;color:var(--ink-color-light);font-size:0.65em;top:0.35em;}
|
|
21
|
+
.post-content ol{padding-left:1.5em;margin:1.25rem 0;}
|
|
22
|
+
.post-content ol li{margin-bottom:0.5em;padding-left:0.25em;}
|
|
23
|
+
.post-content ul ul li::before{content:"◆";font-size:0.5em;top:0.45em;}
|
|
24
|
+
.post-content ul ul ul li::before{content:"•";font-size:0.7em;top:0.3em;}
|
|
25
|
+
.post-content img{cursor:pointer;transition:opacity 0.2s ease;}
|
|
26
|
+
.post-content img:hover{opacity:0.9;}
|
|
27
|
+
.photo-gallery{margin:2rem 0;padding:1.5rem;border-top:3px double var(--ink-color);border-bottom:3px double var(--ink-color);}
|
|
28
|
+
.gallery-title{text-align:center;font-family:var(--font-headline);letter-spacing:0.1em;text-transform:uppercase;margin:0 0 1rem 0;font-size:1rem;}
|
|
29
|
+
.gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;}
|
|
30
|
+
.gallery-item{display:block;overflow:hidden;margin:0;cursor:pointer;aspect-ratio:4/3;}
|
|
31
|
+
.gallery-item img{width:100%;height:100%;object-fit:cover;transition:transform 0.2s;}
|
|
32
|
+
.gallery-item:hover img{transform:scale(1.05);}
|
|
33
|
+
.lightbox-image-wrapper{position:relative;display:inline-block;}
|
|
34
|
+
.lightbox-image-wrapper img{max-width:90vw;max-height:80vh;display:block;}
|
|
35
|
+
.lightbox-img-caption,.lightbox-img-credit{position:absolute;bottom:0.5rem;padding:0.25rem 0.5rem;background:rgba(40,40,40,0.85);color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:0.85rem;max-width:60%;line-height:1.3;}
|
|
36
|
+
.lightbox-img-caption{left:0.5rem;}
|
|
37
|
+
.lightbox-img-credit{right:0.5rem;text-align:right;}
|
|
38
|
+
</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<div class="preview-banner">📄 Preview Mode - This is how your post will appear</div>
|
|
42
|
+
<main>
|
|
43
|
+
<h1><%= it.title || 'Untitled Post' %></h1>
|
|
44
|
+
<% if (it.image) { %>
|
|
45
|
+
<figure class="post-figure">
|
|
46
|
+
<div class="post-image-wrapper">
|
|
47
|
+
<img src="<%= it.imageUrlPath %>/<%= it.image %>" alt="<%= it.imageCaption || it.title || '' %>" class="post-image">
|
|
48
|
+
<% if (it.imageCaption) { %><span class="image-caption"><%= it.imageCaption %></span><% } %>
|
|
49
|
+
<% if (it.imageCredit) { %><span class="image-credit">Photo: <%= it.imageCredit %></span><% } %>
|
|
50
|
+
</div>
|
|
51
|
+
<figcaption class="post-date"><time><%= it.formattedDate %></time><%~ it.tagsHtml %></figcaption>
|
|
52
|
+
</figure>
|
|
53
|
+
<% } else { %>
|
|
54
|
+
<p class="post-date"><time><%= it.formattedDate %></time><%~ it.tagsHtml %></p>
|
|
55
|
+
<% } %>
|
|
56
|
+
<% if (it.author) { %>
|
|
57
|
+
<p class="byline"><span class="byline-author">By <%= it.author %></span></p>
|
|
58
|
+
<% } %>
|
|
59
|
+
<div class="post-content"><%~ it.htmlBody %></div>
|
|
60
|
+
<%~ it.galleryHtml %>
|
|
61
|
+
<div class="ornament-divider" aria-hidden="true"><span class="ornament-line"></span><span class="ornament-symbol">❧</span><span class="ornament-line"></span></div>
|
|
62
|
+
</main>
|
|
63
|
+
|
|
64
|
+
<% if (it.images.length > 0 || it.gallery.length > 0) { %>
|
|
65
|
+
<script id="contentImagesData" type="application/json"><%~ JSON.stringify({ images: it.images, gallery: it.gallery }) %></script>
|
|
66
|
+
<% } %>
|
|
67
|
+
|
|
68
|
+
<!-- Content Image Lightbox -->
|
|
69
|
+
<div class="lightbox-overlay" id="contentLightbox" onclick="closeContentLightbox(event)">
|
|
70
|
+
<button class="lightbox-close" onclick="closeContentLightbox(event)" aria-label="Close">×</button>
|
|
71
|
+
<button class="lightbox-nav lightbox-prev" id="contentLightboxPrev" onclick="navigateContentLightbox(-1, event)" aria-label="Previous">❮</button>
|
|
72
|
+
<div class="lightbox-content" onclick="event.stopPropagation()">
|
|
73
|
+
<div class="lightbox-image-wrapper">
|
|
74
|
+
<img id="contentLightboxImg" src="" alt="">
|
|
75
|
+
<span class="lightbox-img-caption" id="contentLightboxCaption"></span>
|
|
76
|
+
<span class="lightbox-img-credit" id="contentLightboxCredit"></span>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="lightbox-actions">
|
|
79
|
+
<span class="lightbox-counter" id="contentLightboxCounter"></span>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
<button class="lightbox-nav lightbox-next" id="contentLightboxNext" onclick="navigateContentLightbox(1, event)" aria-label="Next">❯</button>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<!-- Gallery Lightbox -->
|
|
86
|
+
<div class="lightbox-overlay" id="galleryLightbox" onclick="closeGalleryLightbox(event)">
|
|
87
|
+
<button class="lightbox-close" onclick="closeGalleryLightbox(event)" aria-label="Close">×</button>
|
|
88
|
+
<button class="lightbox-nav lightbox-prev" id="galleryLightboxPrev" onclick="navigateGalleryLightbox(-1, event)" aria-label="Previous">❮</button>
|
|
89
|
+
<div class="lightbox-content" onclick="event.stopPropagation()">
|
|
90
|
+
<div class="lightbox-image-wrapper">
|
|
91
|
+
<img id="galleryLightboxImg" src="" alt="">
|
|
92
|
+
<span class="lightbox-img-caption" id="galleryLightboxCaption"></span>
|
|
93
|
+
<span class="lightbox-img-credit" id="galleryLightboxCredit"></span>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="lightbox-actions">
|
|
96
|
+
<span class="lightbox-counter" id="galleryLightboxCounter"></span>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<button class="lightbox-nav lightbox-next" id="galleryLightboxNext" onclick="navigateGalleryLightbox(1, event)" aria-label="Next">❯</button>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<script>
|
|
103
|
+
(function() {
|
|
104
|
+
let contentCurrentIndex = 0;
|
|
105
|
+
let contentImages = [];
|
|
106
|
+
|
|
107
|
+
let imageMetadata = {};
|
|
108
|
+
const metaScript = document.getElementById('contentImagesData');
|
|
109
|
+
if (metaScript) {
|
|
110
|
+
try {
|
|
111
|
+
const data = JSON.parse(metaScript.textContent);
|
|
112
|
+
(data.gallery || []).forEach(item => {
|
|
113
|
+
const filename = (item.src || item).split('/').pop();
|
|
114
|
+
imageMetadata[filename] = typeof item === 'string' ? { src: item } : item;
|
|
115
|
+
});
|
|
116
|
+
(data.images || []).forEach(item => {
|
|
117
|
+
const filename = item.src.split('/').pop();
|
|
118
|
+
const existing = imageMetadata[filename] || {};
|
|
119
|
+
imageMetadata[filename] = {
|
|
120
|
+
src: item.src,
|
|
121
|
+
caption: item.caption || existing.caption || '',
|
|
122
|
+
credit: item.credit || existing.credit || ''
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
} catch (e) {}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function initContentLightbox() {
|
|
129
|
+
const postContent = document.querySelector('.post-content');
|
|
130
|
+
if (!postContent) return;
|
|
131
|
+
|
|
132
|
+
const imgs = postContent.querySelectorAll('img');
|
|
133
|
+
contentImages = Array.from(imgs).map(img => {
|
|
134
|
+
let src = img.src;
|
|
135
|
+
const fullSrc = src.replace(/-(?:400|600|800|thumb)(\.[^.]+)$/, '$1');
|
|
136
|
+
const filename = fullSrc.split('/').pop();
|
|
137
|
+
const meta = imageMetadata[filename] || {};
|
|
138
|
+
return {
|
|
139
|
+
src: fullSrc,
|
|
140
|
+
caption: meta.caption || img.alt || '',
|
|
141
|
+
credit: meta.credit || ''
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
imgs.forEach((img, index) => {
|
|
146
|
+
img.style.cursor = 'pointer';
|
|
147
|
+
img.addEventListener('click', function(e) {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
openContentLightbox(index);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
window.openContentLightbox = function(index) {
|
|
155
|
+
if (contentImages.length === 0) return;
|
|
156
|
+
contentCurrentIndex = index;
|
|
157
|
+
updateContentLightbox();
|
|
158
|
+
document.getElementById('contentLightbox').classList.add('active');
|
|
159
|
+
document.body.style.overflow = 'hidden';
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
window.closeContentLightbox = function(e) {
|
|
163
|
+
if (e && e.target !== e.currentTarget && !e.target.classList.contains('lightbox-close')) return;
|
|
164
|
+
document.getElementById('contentLightbox').classList.remove('active');
|
|
165
|
+
document.body.style.overflow = '';
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
window.navigateContentLightbox = function(dir, e) {
|
|
169
|
+
if (e) e.stopPropagation();
|
|
170
|
+
contentCurrentIndex += dir;
|
|
171
|
+
if (contentCurrentIndex < 0) contentCurrentIndex = contentImages.length - 1;
|
|
172
|
+
if (contentCurrentIndex >= contentImages.length) contentCurrentIndex = 0;
|
|
173
|
+
updateContentLightbox();
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
function updateContentLightbox() {
|
|
177
|
+
const item = contentImages[contentCurrentIndex];
|
|
178
|
+
if (!item) return;
|
|
179
|
+
document.getElementById('contentLightboxImg').src = item.src;
|
|
180
|
+
document.getElementById('contentLightboxImg').alt = item.caption || 'Image';
|
|
181
|
+
const captionEl = document.getElementById('contentLightboxCaption');
|
|
182
|
+
const creditEl = document.getElementById('contentLightboxCredit');
|
|
183
|
+
captionEl.textContent = item.caption || '';
|
|
184
|
+
captionEl.style.display = item.caption ? 'block' : 'none';
|
|
185
|
+
creditEl.textContent = item.credit ? 'Photo: ' + item.credit : '';
|
|
186
|
+
creditEl.style.display = item.credit ? 'block' : 'none';
|
|
187
|
+
document.getElementById('contentLightboxCounter').textContent = (contentCurrentIndex + 1) + ' / ' + contentImages.length;
|
|
188
|
+
document.getElementById('contentLightboxPrev').style.display = contentImages.length > 1 ? 'flex' : 'none';
|
|
189
|
+
document.getElementById('contentLightboxNext').style.display = contentImages.length > 1 ? 'flex' : 'none';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
document.addEventListener('keydown', function(e) {
|
|
193
|
+
const lightbox = document.getElementById('contentLightbox');
|
|
194
|
+
if (!lightbox || !lightbox.classList.contains('active')) return;
|
|
195
|
+
if (e.key === 'Escape') closeContentLightbox({target: lightbox, currentTarget: lightbox});
|
|
196
|
+
if (e.key === 'ArrowLeft') navigateContentLightbox(-1);
|
|
197
|
+
if (e.key === 'ArrowRight') navigateContentLightbox(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
initContentLightbox();
|
|
201
|
+
})();
|
|
202
|
+
|
|
203
|
+
(function() {
|
|
204
|
+
let galleryCurrentIndex = 0;
|
|
205
|
+
let galleryItems = [];
|
|
206
|
+
|
|
207
|
+
function initGalleryLightbox() {
|
|
208
|
+
const grid = document.getElementById('galleryGrid');
|
|
209
|
+
if (!grid) return;
|
|
210
|
+
galleryItems = Array.from(grid.querySelectorAll('.gallery-item')).map(el => ({
|
|
211
|
+
src: el.dataset.src,
|
|
212
|
+
caption: el.dataset.caption || '',
|
|
213
|
+
credit: el.dataset.credit || ''
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
window.openGalleryLightbox = function(index) {
|
|
218
|
+
initGalleryLightbox();
|
|
219
|
+
if (galleryItems.length === 0) return;
|
|
220
|
+
galleryCurrentIndex = index;
|
|
221
|
+
updateGalleryLightbox();
|
|
222
|
+
document.getElementById('galleryLightbox').classList.add('active');
|
|
223
|
+
document.body.style.overflow = 'hidden';
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
window.closeGalleryLightbox = function(e) {
|
|
227
|
+
if (e && e.target !== e.currentTarget && !e.target.classList.contains('lightbox-close')) return;
|
|
228
|
+
document.getElementById('galleryLightbox').classList.remove('active');
|
|
229
|
+
document.body.style.overflow = '';
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
window.navigateGalleryLightbox = function(dir, e) {
|
|
233
|
+
if (e) e.stopPropagation();
|
|
234
|
+
galleryCurrentIndex += dir;
|
|
235
|
+
if (galleryCurrentIndex < 0) galleryCurrentIndex = galleryItems.length - 1;
|
|
236
|
+
if (galleryCurrentIndex >= galleryItems.length) galleryCurrentIndex = 0;
|
|
237
|
+
updateGalleryLightbox();
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
function updateGalleryLightbox() {
|
|
241
|
+
const item = galleryItems[galleryCurrentIndex];
|
|
242
|
+
if (!item) return;
|
|
243
|
+
document.getElementById('galleryLightboxImg').src = item.src;
|
|
244
|
+
document.getElementById('galleryLightboxImg').alt = item.caption || 'Gallery image';
|
|
245
|
+
const captionEl = document.getElementById('galleryLightboxCaption');
|
|
246
|
+
const creditEl = document.getElementById('galleryLightboxCredit');
|
|
247
|
+
captionEl.textContent = item.caption || '';
|
|
248
|
+
captionEl.style.display = item.caption ? 'block' : 'none';
|
|
249
|
+
creditEl.textContent = item.credit ? 'Photo: ' + item.credit : '';
|
|
250
|
+
creditEl.style.display = item.credit ? 'block' : 'none';
|
|
251
|
+
document.getElementById('galleryLightboxCounter').textContent = (galleryCurrentIndex + 1) + ' / ' + galleryItems.length;
|
|
252
|
+
document.getElementById('galleryLightboxPrev').style.display = galleryItems.length > 1 ? 'flex' : 'none';
|
|
253
|
+
document.getElementById('galleryLightboxNext').style.display = galleryItems.length > 1 ? 'flex' : 'none';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
document.addEventListener('keydown', function(e) {
|
|
257
|
+
const lightbox = document.getElementById('galleryLightbox');
|
|
258
|
+
if (!lightbox || !lightbox.classList.contains('active')) return;
|
|
259
|
+
if (e.key === 'Escape') closeGalleryLightbox({target: lightbox, currentTarget: lightbox});
|
|
260
|
+
if (e.key === 'ArrowLeft') navigateGalleryLightbox(-1);
|
|
261
|
+
if (e.key === 'ArrowRight') navigateGalleryLightbox(1);
|
|
262
|
+
});
|
|
263
|
+
})();
|
|
264
|
+
</script>
|
|
265
|
+
</body>
|
|
266
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { getCmsRoot, getBranchWords } from '../config.js';
|
|
3
|
+
|
|
4
|
+
export function randomWord() {
|
|
5
|
+
const words = getBranchWords();
|
|
6
|
+
return words[Math.floor(Math.random() * words.length)];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function runGit(cmd, cwd = getCmsRoot()) {
|
|
10
|
+
console.log('Running:', cmd);
|
|
11
|
+
return execSync(cmd, { cwd, encoding: 'utf-8' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function generateBranchName() {
|
|
15
|
+
return `publish-${randomWord()}-${randomWord()}-${Date.now()}`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import markdownIt from 'markdown-it';
|
|
2
|
+
import markdownItAttrs from 'markdown-it-attrs';
|
|
3
|
+
|
|
4
|
+
// Configure markdown-it with attrs support
|
|
5
|
+
export const md = markdownIt({
|
|
6
|
+
html: true,
|
|
7
|
+
breaks: true,
|
|
8
|
+
linkify: true,
|
|
9
|
+
typographer: true
|
|
10
|
+
}).use(markdownItAttrs);
|
|
11
|
+
|
|
12
|
+
export function parseFrontmatter(content) {
|
|
13
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
14
|
+
if (!match) return {};
|
|
15
|
+
const fm = {};
|
|
16
|
+
const lines = match[1].split('\n');
|
|
17
|
+
let i = 0;
|
|
18
|
+
while (i < lines.length) {
|
|
19
|
+
const line = lines[i];
|
|
20
|
+
const colonIdx = line.indexOf(':');
|
|
21
|
+
if (colonIdx > 0 && !line.startsWith(' ') && !line.startsWith('\t')) {
|
|
22
|
+
const key = line.slice(0, colonIdx).trim();
|
|
23
|
+
let value = line.slice(colonIdx + 1).trim();
|
|
24
|
+
|
|
25
|
+
// Check if this is a multi-line YAML array (gallery with objects)
|
|
26
|
+
if (value === '' && i + 1 < lines.length && lines[i + 1].trim().startsWith('- ')) {
|
|
27
|
+
const items = [];
|
|
28
|
+
i++;
|
|
29
|
+
let currentItem = null;
|
|
30
|
+
while (i < lines.length && (lines[i].startsWith(' ') || lines[i].startsWith('\t') || lines[i].trim().startsWith('- '))) {
|
|
31
|
+
const itemLine = lines[i].trim();
|
|
32
|
+
if (itemLine.startsWith('- ')) {
|
|
33
|
+
if (currentItem) items.push(currentItem);
|
|
34
|
+
currentItem = {};
|
|
35
|
+
const firstProp = itemLine.slice(2).trim();
|
|
36
|
+
if (firstProp) {
|
|
37
|
+
const propColon = firstProp.indexOf(':');
|
|
38
|
+
if (propColon > 0) {
|
|
39
|
+
const propKey = firstProp.slice(0, propColon).trim();
|
|
40
|
+
let propVal = firstProp.slice(propColon + 1).trim();
|
|
41
|
+
if (propVal.startsWith('"') && propVal.endsWith('"')) {
|
|
42
|
+
propVal = propVal.slice(1, -1).replace(/\\"/g, '"');
|
|
43
|
+
}
|
|
44
|
+
currentItem[propKey] = propVal;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else if (currentItem) {
|
|
48
|
+
const propColon = itemLine.indexOf(':');
|
|
49
|
+
if (propColon > 0) {
|
|
50
|
+
const propKey = itemLine.slice(0, propColon).trim();
|
|
51
|
+
let propVal = itemLine.slice(propColon + 1).trim();
|
|
52
|
+
if (propVal.startsWith('"') && propVal.endsWith('"')) {
|
|
53
|
+
propVal = propVal.slice(1, -1).replace(/\\"/g, '"');
|
|
54
|
+
}
|
|
55
|
+
currentItem[propKey] = propVal;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
if (currentItem) items.push(currentItem);
|
|
61
|
+
fm[key] = items;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Handle quoted strings
|
|
66
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
67
|
+
value = value.slice(1, -1).replace(/\\"/g, '"');
|
|
68
|
+
}
|
|
69
|
+
// Handle inline arrays
|
|
70
|
+
else if (value.startsWith('[')) {
|
|
71
|
+
value = value.slice(1, -1).split(',').map(v => v.trim().replace(/^["']|["']$/g, ''));
|
|
72
|
+
}
|
|
73
|
+
// Handle booleans
|
|
74
|
+
else if (value === 'true') {
|
|
75
|
+
value = true;
|
|
76
|
+
} else if (value === 'false') {
|
|
77
|
+
value = false;
|
|
78
|
+
}
|
|
79
|
+
fm[key] = value;
|
|
80
|
+
}
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
return fm;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseFrontmatterAndBody(content) {
|
|
87
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n*([\s\S]*)/);
|
|
88
|
+
if (!match) return { frontmatter: {}, body: content };
|
|
89
|
+
return { frontmatter: parseFrontmatter(content), body: match[2].trim() };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildMarkdown(fm, body) {
|
|
93
|
+
const tagArray = typeof fm.tags === 'string'
|
|
94
|
+
? fm.tags.split(',').map(t => t.trim()).filter(Boolean)
|
|
95
|
+
: (fm.tags || []);
|
|
96
|
+
|
|
97
|
+
const galleryArray = Array.isArray(fm.gallery) ? fm.gallery : [];
|
|
98
|
+
const imagesArray = Array.isArray(fm.images) ? fm.images : [];
|
|
99
|
+
|
|
100
|
+
const lines = [
|
|
101
|
+
'---',
|
|
102
|
+
'title: "' + (fm.title || '').replace(/"/g, '\\"') + '"',
|
|
103
|
+
'synopsis: "' + (fm.synopsis || '').replace(/"/g, '\\"') + '"',
|
|
104
|
+
'date: ' + fm.date,
|
|
105
|
+
'tags: [' + tagArray.map(t => '"' + t + '"').join(', ') + ']',
|
|
106
|
+
];
|
|
107
|
+
if (fm.image) lines.push('image: ' + fm.image);
|
|
108
|
+
if (fm.imageCaption) lines.push('imageCaption: "' + fm.imageCaption.replace(/"/g, '\\"') + '"');
|
|
109
|
+
if (fm.imageCredit) lines.push('imageCredit: "' + fm.imageCredit.replace(/"/g, '\\"') + '"');
|
|
110
|
+
if (fm.author) lines.push('author: "' + fm.author.replace(/"/g, '\\"') + '"');
|
|
111
|
+
if (imagesArray.length > 0) {
|
|
112
|
+
const imagesYaml = imagesArray.map(item => {
|
|
113
|
+
let yaml = ' - src: "' + (item.src || '') + '"';
|
|
114
|
+
if (item.caption) yaml += '\n caption: "' + item.caption.replace(/"/g, '\\"') + '"';
|
|
115
|
+
if (item.credit) yaml += '\n credit: "' + item.credit.replace(/"/g, '\\"') + '"';
|
|
116
|
+
return yaml;
|
|
117
|
+
}).join('\n');
|
|
118
|
+
lines.push('images:\n' + imagesYaml);
|
|
119
|
+
}
|
|
120
|
+
if (galleryArray.length > 0) {
|
|
121
|
+
const galleryYaml = galleryArray.map(item => {
|
|
122
|
+
if (typeof item === 'string') {
|
|
123
|
+
return ' - src: "' + item + '"';
|
|
124
|
+
}
|
|
125
|
+
let yaml = ' - src: "' + (item.src || '') + '"';
|
|
126
|
+
if (item.caption) yaml += '\n caption: "' + item.caption.replace(/"/g, '\\"') + '"';
|
|
127
|
+
if (item.credit) yaml += '\n credit: "' + item.credit.replace(/"/g, '\\"') + '"';
|
|
128
|
+
return yaml;
|
|
129
|
+
}).join('\n');
|
|
130
|
+
lines.push('gallery:\n' + galleryYaml);
|
|
131
|
+
}
|
|
132
|
+
if (fm.showGallery) lines.push('showGallery: true');
|
|
133
|
+
if (fm.lede === true) lines.push('lede: true');
|
|
134
|
+
if (fm.status) lines.push('status: ' + fm.status);
|
|
135
|
+
lines.push('---');
|
|
136
|
+
return lines.join('\n') + '\n\n' + body;
|
|
137
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SeeEmEss - A simple CMS built on Eleventy
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* // In your eleventy.config.js:
|
|
6
|
+
* import filters from 'seeemess/filters';
|
|
7
|
+
* export default function(eleventyConfig) {
|
|
8
|
+
* filters(eleventyConfig);
|
|
9
|
+
* }
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // In your admin/start.js:
|
|
13
|
+
* import { startAdminServer, createConfig } from 'seeemess';
|
|
14
|
+
*
|
|
15
|
+
* startAdminServer({
|
|
16
|
+
* sections: [
|
|
17
|
+
* { id: 'posts', name: 'Blog Posts', folder: 'posts', tag: 'post' }
|
|
18
|
+
* ]
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Admin server and configuration
|
|
23
|
+
export { startAdminServer, createConfig } from './admin/server.js';
|
|
24
|
+
|
|
25
|
+
// Eleventy filters
|
|
26
|
+
export { default as filters } from './_config/filters.js';
|
|
27
|
+
|
|
28
|
+
// Re-export config utilities for advanced usage
|
|
29
|
+
export { getConfig, getContentDir, getPublicDir, getSections } from './admin/config.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seeemess",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A simple CMS framework built on Eleventy with an admin interface for managing blog content.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./filters": "./_config/filters.js",
|
|
10
|
+
"./admin": "./admin/server.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test",
|
|
14
|
+
"test:coverage": "node --test --experimental-test-coverage"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cms",
|
|
18
|
+
"eleventy",
|
|
19
|
+
"11ty",
|
|
20
|
+
"blog",
|
|
21
|
+
"admin",
|
|
22
|
+
"content-management"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git://github.com/user/seeemess.git"
|
|
27
|
+
},
|
|
28
|
+
"author": "",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"eta": "^3.5.0",
|
|
35
|
+
"express": "^4.22.1",
|
|
36
|
+
"luxon": "^3.6.1",
|
|
37
|
+
"markdown-it": "^14.1.0",
|
|
38
|
+
"markdown-it-attrs": "^4.3.1",
|
|
39
|
+
"sharp": "^0.34.5",
|
|
40
|
+
"zod": "^3.25.67",
|
|
41
|
+
"zod-validation-error": "^3.5.2"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@11ty/eleventy": ">=3.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@11ty/eleventy": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
// We need to reset the module state between tests, so we use dynamic imports
|
|
5
|
+
async function freshImport() {
|
|
6
|
+
// Clear the module from cache to reset singleton state
|
|
7
|
+
const modulePath = new URL('../admin/config.js', import.meta.url).href;
|
|
8
|
+
|
|
9
|
+
// Create a unique import each time by adding a cache-busting query
|
|
10
|
+
const uniqueUrl = `${modulePath}?t=${Date.now()}-${Math.random()}`;
|
|
11
|
+
return import(uniqueUrl);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('createConfig', () => {
|
|
15
|
+
it('returns config with defaults', async () => {
|
|
16
|
+
const { createConfig } = await freshImport();
|
|
17
|
+
const config = createConfig();
|
|
18
|
+
|
|
19
|
+
assert.ok(config.CMS_ROOT);
|
|
20
|
+
assert.ok(config.CONTENT_DIR);
|
|
21
|
+
assert.ok(config.PUBLIC_DIR);
|
|
22
|
+
assert.strictEqual(config.IMAGE_URL_PATH, '/uploads');
|
|
23
|
+
assert.strictEqual(config.PORT, 3000);
|
|
24
|
+
assert.deepStrictEqual(config.SECTIONS, []);
|
|
25
|
+
assert.ok(Array.isArray(config.IMAGE_SIZES));
|
|
26
|
+
assert.ok(Array.isArray(config.BRANCH_WORDS));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('merges provided options', async () => {
|
|
30
|
+
const { createConfig } = await freshImport();
|
|
31
|
+
const sections = [{ id: 'blog', name: 'Blog', folder: 'blog', tag: 'blog' }];
|
|
32
|
+
const config = createConfig({
|
|
33
|
+
cmsRoot: '/custom/root',
|
|
34
|
+
port: 4000,
|
|
35
|
+
sections
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
assert.strictEqual(config.CMS_ROOT, '/custom/root');
|
|
39
|
+
assert.strictEqual(config.PORT, 4000);
|
|
40
|
+
assert.deepStrictEqual(config.SECTIONS, sections);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('normalizes imageUrlPath - adds leading slash', async () => {
|
|
44
|
+
const { createConfig } = await freshImport();
|
|
45
|
+
const config = createConfig({ imageUrlPath: 'images' });
|
|
46
|
+
assert.strictEqual(config.IMAGE_URL_PATH, '/images');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('normalizes imageUrlPath - removes trailing slash', async () => {
|
|
50
|
+
const { createConfig } = await freshImport();
|
|
51
|
+
const config = createConfig({ imageUrlPath: '/uploads/' });
|
|
52
|
+
assert.strictEqual(config.IMAGE_URL_PATH, '/uploads');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('normalizes imageUrlPath - handles both issues', async () => {
|
|
56
|
+
const { createConfig } = await freshImport();
|
|
57
|
+
const config = createConfig({ imageUrlPath: 'assets/images/' });
|
|
58
|
+
assert.strictEqual(config.IMAGE_URL_PATH, '/assets/images');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('accepts custom image sizes', async () => {
|
|
62
|
+
const { createConfig } = await freshImport();
|
|
63
|
+
const customSizes = [{ suffix: '-sm', width: 200 }];
|
|
64
|
+
const config = createConfig({ imageSizes: customSizes });
|
|
65
|
+
assert.deepStrictEqual(config.IMAGE_SIZES, customSizes);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('accepts custom branch words', async () => {
|
|
69
|
+
const { createConfig } = await freshImport();
|
|
70
|
+
const customWords = ['foo', 'bar', 'baz'];
|
|
71
|
+
const config = createConfig({ branchWords: customWords });
|
|
72
|
+
assert.deepStrictEqual(config.BRANCH_WORDS, customWords);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('resolves previewTemplate relative to cmsRoot', async () => {
|
|
76
|
+
const { createConfig } = await freshImport();
|
|
77
|
+
const config = createConfig({
|
|
78
|
+
cmsRoot: '/project',
|
|
79
|
+
previewTemplate: 'templates/preview.eta'
|
|
80
|
+
});
|
|
81
|
+
assert.strictEqual(config.PREVIEW_TEMPLATE, '/project/templates/preview.eta');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('getConfig', () => {
|
|
86
|
+
it('throws if not initialized', async () => {
|
|
87
|
+
const { getConfig } = await freshImport();
|
|
88
|
+
assert.throws(
|
|
89
|
+
() => getConfig(),
|
|
90
|
+
{ message: 'Config not initialized. Call createConfig() first.' }
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns config after createConfig called', async () => {
|
|
95
|
+
const { createConfig, getConfig } = await freshImport();
|
|
96
|
+
const created = createConfig({ port: 5000 });
|
|
97
|
+
const retrieved = getConfig();
|
|
98
|
+
|
|
99
|
+
assert.strictEqual(retrieved, created);
|
|
100
|
+
assert.strictEqual(retrieved.PORT, 5000);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('individual getters', () => {
|
|
105
|
+
it('getCmsRoot returns CMS_ROOT', async () => {
|
|
106
|
+
const { createConfig, getCmsRoot } = await freshImport();
|
|
107
|
+
createConfig({ cmsRoot: '/test/root' });
|
|
108
|
+
assert.strictEqual(getCmsRoot(), '/test/root');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('getContentDir returns CONTENT_DIR', async () => {
|
|
112
|
+
const { createConfig, getContentDir } = await freshImport();
|
|
113
|
+
createConfig({ contentDir: '/custom/content' });
|
|
114
|
+
assert.strictEqual(getContentDir(), '/custom/content');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('getPublicDir returns PUBLIC_DIR', async () => {
|
|
118
|
+
const { createConfig, getPublicDir } = await freshImport();
|
|
119
|
+
createConfig({ publicDir: '/custom/public' });
|
|
120
|
+
assert.strictEqual(getPublicDir(), '/custom/public');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('getImageUrlPath returns IMAGE_URL_PATH', async () => {
|
|
124
|
+
const { createConfig, getImageUrlPath } = await freshImport();
|
|
125
|
+
createConfig({ imageUrlPath: '/media' });
|
|
126
|
+
assert.strictEqual(getImageUrlPath(), '/media');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('getPort returns PORT', async () => {
|
|
130
|
+
const { createConfig, getPort } = await freshImport();
|
|
131
|
+
createConfig({ port: 8080 });
|
|
132
|
+
assert.strictEqual(getPort(), 8080);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('getSections returns SECTIONS', async () => {
|
|
136
|
+
const { createConfig, getSections } = await freshImport();
|
|
137
|
+
const sections = [{ id: 'test' }];
|
|
138
|
+
createConfig({ sections });
|
|
139
|
+
assert.deepStrictEqual(getSections(), sections);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('getImageSizes returns IMAGE_SIZES', async () => {
|
|
143
|
+
const { createConfig, getImageSizes } = await freshImport();
|
|
144
|
+
createConfig();
|
|
145
|
+
const sizes = getImageSizes();
|
|
146
|
+
assert.ok(Array.isArray(sizes));
|
|
147
|
+
assert.ok(sizes.length > 0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('getBranchWords returns BRANCH_WORDS', async () => {
|
|
151
|
+
const { createConfig, getBranchWords } = await freshImport();
|
|
152
|
+
createConfig();
|
|
153
|
+
const words = getBranchWords();
|
|
154
|
+
assert.ok(Array.isArray(words));
|
|
155
|
+
assert.ok(words.length > 0);
|
|
156
|
+
});
|
|
157
|
+
});
|