Hugoifier

hugoifier / hugoifier / utils / decapify.py
Blame History Raw 398 lines
1
"""
2
Generates Decap CMS integration for a Hugo site.
3
4
Writes:
5
static/admin/index.html — Decap CMS admin panel
6
static/admin/config.yml — CMS config mapped to actual content structure
7
"""
8
9
import logging
10
import os
11
import re
12
13
import yaml
14
15
DECAP_CDN = "https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js"
16
17
# Whitelabel defaults — override via decapify() kwargs or env vars
18
DEFAULT_CMS_NAME = os.getenv('CMS_NAME', 'Content Manager')
19
DEFAULT_CMS_LOGO = os.getenv('CMS_LOGO_URL', '') # URL or empty
20
DEFAULT_CMS_COLOR = os.getenv('CMS_COLOR', '#2e3748') # top-bar background
21
22
23
def decapify(
24
site_dir: str,
25
cms_name: str = None,
26
cms_logo: str = None,
27
cms_color: str = None,
28
github_repo: str = None,
29
) -> str:
30
"""
31
Add Decap CMS to a Hugo site directory.
32
33
Args:
34
site_dir: Root of the assembled Hugo site (has hugo.toml, content/, themes/).
35
cms_name: Whitelabel name shown in the admin UI (default: 'Content Manager').
36
cms_logo: URL to a logo image for the admin UI (optional).
37
cms_color: Hex color for the admin top bar (default: '#2e3748').
38
github_repo: GitHub repo slug e.g. 'ConflictHQ/my-site' (optional, for GitHub backend).
39
40
Returns:
41
Status message.
42
"""
43
logging.info(f"Adding Decap CMS to {site_dir} ...")
44
45
admin_dir = os.path.join(site_dir, 'static', 'admin')
46
os.makedirs(admin_dir, exist_ok=True)
47
48
branding = {
49
'name': cms_name or DEFAULT_CMS_NAME,
50
'logo': cms_logo or DEFAULT_CMS_LOGO,
51
'color': cms_color or DEFAULT_CMS_COLOR,
52
}
53
54
_write_admin_index(admin_dir, branding)
55
_write_decap_config(site_dir, admin_dir, github_repo=github_repo)
56
_write_oauth_functions(site_dir)
57
_create_media_dir(site_dir)
58
59
logging.info("Decap CMS integration complete.")
60
return "Decap CMS integration complete"
61
62
63
# ---------------------------------------------------------------------------
64
# Admin index.html
65
# ---------------------------------------------------------------------------
66
67
def _sanitize_color(color: str) -> str:
68
"""Allow only valid CSS hex colors (#rgb or #rrggbb) to prevent style injection."""
69
if re.fullmatch(r'#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?', color):
70
return color
71
return '#2e3748' # fall back to default
72
73
74
def _write_admin_index(admin_dir: str, branding: dict):
75
import html as html_mod
76
name = html_mod.escape(branding['name'])
77
logo_html = ''
78
if branding['logo']:
79
logo_url = html_mod.escape(branding['logo'])
80
logo_html = f'\n <img src="{logo_url}" alt="{name}" style="max-height:40px;margin:8px 0;">'
81
82
color_css = ''
83
if branding['color']:
84
safe_color = _sanitize_color(branding['color'])
85
color_css = f"""
86
<style>
87
[class^="AppHeader"] {{ background-color: {safe_color} !important; }}
88
</style>"""
89
90
html = f"""<!doctype html>
91
<html>
92
<head>
93
<meta charset="utf-8" />
94
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
95
<meta name="robots" content="noindex" />
96
<title>{name}</title>{color_css}
97
</head>
98
<body>{logo_html}
99
<script src="{DECAP_CDN}"></script>
100
</body>
101
</html>
102
"""
103
with open(os.path.join(admin_dir, 'index.html'), 'w') as f:
104
f.write(html)
105
106
107
# ---------------------------------------------------------------------------
108
# config.yml
109
# ---------------------------------------------------------------------------
110
111
def _write_decap_config(site_dir: str, admin_dir: str, github_repo: str = None):
112
content_dir = os.path.join(site_dir, 'content')
113
collections = _build_collections(content_dir)
114
115
backend = {
116
'name': 'github',
117
'branch': 'main',
118
}
119
if github_repo:
120
backend['repo'] = github_repo
121
backend['base_url'] = '' # placeholder — set to deployed site URL
122
backend['auth_endpoint'] = '/api/auth'
123
124
config = {
125
'backend': backend,
126
'media_folder': 'static/images/uploads',
127
'public_folder': '/images/uploads',
128
'collections': collections,
129
}
130
131
config_path = os.path.join(admin_dir, 'config.yml')
132
with open(config_path, 'w') as f:
133
yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
134
135
logging.info(f"Wrote Decap CMS config to {config_path}")
136
137
138
def _collect_md_files(dirpath: str) -> list:
139
"""Recursively collect all .md files under dirpath (excluding _index.md)."""
140
found = []
141
for root, dirs, files in os.walk(dirpath):
142
for f in files:
143
if f.endswith('.md') and f != '_index.md':
144
found.append(os.path.join(root, f))
145
return found
146
147
148
def _build_collections(content_dir: str) -> list:
149
"""
150
Inspect content/ to build Decap CMS collections.
151
- Subdirs with any .md files (at any depth) → folder collection (e.g. blog)
152
- Subdirs with only a top-level _index.md → file collection (e.g. about, contact)
153
"""
154
if not os.path.isdir(content_dir):
155
return [_default_pages_collection()]
156
157
collections = []
158
159
entries = sorted(os.listdir(content_dir))
160
for entry in entries:
161
subdir = os.path.join(content_dir, entry)
162
if not os.path.isdir(subdir):
163
continue
164
165
# Collect all .md files at any depth (excluding _index.md)
166
non_index = _collect_md_files(subdir)
167
has_index = os.path.exists(os.path.join(subdir, '_index.md'))
168
169
if non_index:
170
# Folder collection (blog, posts, etc.) — use shallowest sample for field inference
171
rel_files = [os.path.relpath(f, subdir) for f in non_index]
172
fields = _infer_fields_for_folder(subdir, rel_files)
173
collections.append({
174
'name': entry,
175
'label': entry.replace('-', ' ').title(),
176
'folder': f'content/{entry}',
177
'create': True,
178
'slug': '{{slug}}',
179
'fields': fields,
180
})
181
elif has_index:
182
# File collection (single page)
183
fields = _infer_fields_for_file(os.path.join(subdir, '_index.md'))
184
collections.append({
185
'name': entry,
186
'label': entry.replace('-', ' ').title(),
187
'files': [{
188
'name': entry,
189
'label': entry.replace('-', ' ').title(),
190
'file': f'content/{entry}/_index.md',
191
'fields': fields,
192
}],
193
})
194
195
if not collections:
196
collections.append(_default_pages_collection())
197
198
return collections
199
200
201
def _infer_fields_for_folder(subdir: str, md_files: list) -> list:
202
"""Read a sample .md file and extract frontmatter keys as fields."""
203
# md_files may be relative paths (from _collect_md_files); resolve to absolute
204
first = md_files[0]
205
sample = first if os.path.isabs(first) else os.path.join(subdir, first)
206
frontmatter = _parse_frontmatter(sample)
207
208
fields = []
209
field_map = {
210
'title': {'label': 'Title', 'name': 'title', 'widget': 'string'},
211
'date': {'label': 'Date', 'name': 'date', 'widget': 'datetime'},
212
'description': {'label': 'Description', 'name': 'description', 'widget': 'text'},
213
'image': {'label': 'Image', 'name': 'image', 'widget': 'image', 'required': False},
214
'categories': {'label': 'Categories', 'name': 'categories', 'widget': 'list',
215
'required': False},
216
'tags': {'label': 'Tags', 'name': 'tags', 'widget': 'list', 'required': False},
217
'draft': {'label': 'Draft', 'name': 'draft', 'widget': 'boolean', 'default': False},
218
'author': {'label': 'Author', 'name': 'author', 'widget': 'string', 'required': False},
219
}
220
221
# Add known fields in a logical order
222
for key in ['title', 'date', 'description', 'image', 'categories', 'tags', 'author', 'draft']:
223
if key in frontmatter:
224
fields.append(field_map[key])
225
226
# Add any remaining frontmatter keys not in our map
227
for key, value in frontmatter.items():
228
if key not in field_map and key not in ('type', 'layout', 'url'):
229
widget = _widget_for_value(value)
230
fields.append({'label': key.title(), 'name': key, 'widget': widget, 'required': False})
231
232
# Always include body
233
fields.append({'label': 'Body', 'name': 'body', 'widget': 'markdown'})
234
235
return fields
236
237
238
def _infer_fields_for_file(md_path: str) -> list:
239
"""For a single page (_index.md), infer fields from frontmatter."""
240
frontmatter = _parse_frontmatter(md_path)
241
fields = []
242
for key, value in frontmatter.items():
243
widget = _widget_for_value(value)
244
fields.append({'label': key.title(), 'name': key, 'widget': widget, 'required': False})
245
fields.append({'label': 'Body', 'name': 'body', 'widget': 'markdown'})
246
return fields
247
248
249
def _parse_frontmatter(md_path: str) -> dict:
250
"""Parse YAML frontmatter from a .md file."""
251
try:
252
with open(md_path, 'r', errors='replace') as f:
253
content = f.read()
254
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
255
if match:
256
return yaml.safe_load(match.group(1)) or {}
257
except Exception:
258
pass
259
return {}
260
261
262
def _widget_for_value(value) -> str:
263
if isinstance(value, bool):
264
return 'boolean'
265
if isinstance(value, (int, float)):
266
return 'number'
267
if isinstance(value, list):
268
return 'list'
269
return 'string'
270
271
272
def _default_pages_collection() -> dict:
273
return {
274
'name': 'pages',
275
'label': 'Pages',
276
'folder': 'content',
277
'create': True,
278
'slug': '{{slug}}',
279
'fields': [
280
{'label': 'Title', 'name': 'title', 'widget': 'string'},
281
{'label': 'Body', 'name': 'body', 'widget': 'markdown'},
282
],
283
}
284
285
286
# ---------------------------------------------------------------------------
287
# OAuth functions (Cloudflare Pages Functions)
288
# ---------------------------------------------------------------------------
289
290
_AUTH_JS = """\
291
export async function onRequest(context) {
292
const { request, env } = context;
293
const client_id = env.GITHUB_CLIENT_ID;
294
295
try {
296
const url = new URL(request.url);
297
const redirectUrl = new URL('https://github.com/login/oauth/authorize');
298
redirectUrl.searchParams.set('client_id', client_id);
299
redirectUrl.searchParams.set('redirect_uri', url.origin + '/api/callback');
300
redirectUrl.searchParams.set('scope', 'repo user');
301
redirectUrl.searchParams.set(
302
'state',
303
crypto.getRandomValues(new Uint8Array(12)).join(''),
304
);
305
return Response.redirect(redirectUrl.href, 301);
306
} catch (error) {
307
console.error(error);
308
return new Response(error.message, { status: 500 });
309
}
310
}
311
"""
312
313
_CALLBACK_JS = """\
314
function renderBody(status, content) {
315
const html = `
316
<script>
317
const receiveMessage = (message) => {
318
window.opener.postMessage(
319
'authorization:github:${status}:${JSON.stringify(content)}',
320
message.origin
321
);
322
window.removeEventListener("message", receiveMessage, false);
323
}
324
window.addEventListener("message", receiveMessage, false);
325
window.opener.postMessage("authorizing:github", "*");
326
</script>
327
`;
328
const blob = new Blob([html]);
329
return blob;
330
}
331
332
export async function onRequest(context) {
333
const { request, env } = context;
334
const client_id = env.GITHUB_CLIENT_ID;
335
const client_secret = env.GITHUB_CLIENT_SECRET;
336
337
try {
338
const url = new URL(request.url);
339
const code = url.searchParams.get('code');
340
const response = await fetch(
341
'https://github.com/login/oauth/access_token',
342
{
343
method: 'POST',
344
headers: {
345
'content-type': 'application/json',
346
'user-agent': 'hugoifier-cms-oauth',
347
'accept': 'application/json',
348
},
349
body: JSON.stringify({ client_id, client_secret, code }),
350
},
351
);
352
const result = await response.json();
353
if (result.error) {
354
return new Response(renderBody('error', result), {
355
headers: { 'content-type': 'text/html;charset=UTF-8' },
356
status: 401
357
});
358
}
359
const token = result.access_token;
360
const provider = 'github';
361
const responseBody = renderBody('success', { token, provider });
362
return new Response(responseBody, {
363
headers: { 'content-type': 'text/html;charset=UTF-8' },
364
status: 200
365
});
366
} catch (error) {
367
console.error(error);
368
return new Response(error.message, {
369
headers: { 'content-type': 'text/html;charset=UTF-8' },
370
status: 500,
371
});
372
}
373
}
374
"""
375
376
377
def _write_oauth_functions(site_dir: str):
378
"""Write Cloudflare Pages Functions for GitHub OAuth (Decap CMS auth)."""
379
functions_dir = os.path.join(site_dir, 'functions', 'api')
380
os.makedirs(functions_dir, exist_ok=True)
381
382
with open(os.path.join(functions_dir, 'auth.js'), 'w') as f:
383
f.write(_AUTH_JS)
384
385
with open(os.path.join(functions_dir, 'callback.js'), 'w') as f:
386
f.write(_CALLBACK_JS)
387
388
logging.info("Wrote OAuth functions to functions/api/")
389
390
391
def _create_media_dir(site_dir: str):
392
"""Create the media uploads directory so Decap doesn't 404."""
393
media_dir = os.path.join(site_dir, 'static', 'images', 'uploads')
394
os.makedirs(media_dir, exist_ok=True)
395
gitkeep = os.path.join(media_dir, '.gitkeep')
396
if not os.path.exists(gitkeep):
397
open(gitkeep, 'w').close()
398

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button