|
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
|
|