Hugoifier

fix: decapify uses GitHub backend with OAuth functions Replace git-gateway (requires Netlify) with github backend that works on Cloudflare Pages or any static host. - Backend uses github with base_url/auth_endpoint config - Generates Cloudflare Pages Functions for OAuth (auth.js + callback.js) using the proven two-step handshake pattern from calliope-cms - Creates static/images/uploads/.gitkeep so Decap media folder doesn't 404 - Accepts optional github_repo parameter for repo slug

lmata 2026-03-17 23:58 trunk
Commit 218ace349c1909c33c317b732c9c9122e35ace62cda066f06e201030eb86da32
--- hugoifier/utils/decapify.py
+++ hugoifier/utils/decapify.py
@@ -23,19 +23,21 @@
2323
def decapify(
2424
site_dir: str,
2525
cms_name: str = None,
2626
cms_logo: str = None,
2727
cms_color: str = None,
28
+ github_repo: str = None,
2829
) -> str:
2930
"""
3031
Add Decap CMS to a Hugo site directory.
3132
3233
Args:
33
- site_dir: Root of the assembled Hugo site (has hugo.toml, content/, themes/).
34
- cms_name: Whitelabel name shown in the admin UI (default: 'Content Manager').
35
- cms_logo: URL to a logo image for the admin UI (optional).
36
- cms_color: Hex color for the admin top bar (default: '#2e3748').
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).
3739
3840
Returns:
3941
Status message.
4042
"""
4143
logging.info(f"Adding Decap CMS to {site_dir} ...")
@@ -48,11 +50,13 @@
4850
'logo': cms_logo or DEFAULT_CMS_LOGO,
4951
'color': cms_color or DEFAULT_CMS_COLOR,
5052
}
5153
5254
_write_admin_index(admin_dir, branding)
53
- _write_decap_config(site_dir, admin_dir)
55
+ _write_decap_config(site_dir, admin_dir, github_repo=github_repo)
56
+ _write_oauth_functions(site_dir)
57
+ _create_media_dir(site_dir)
5458
5559
logging.info("Decap CMS integration complete.")
5660
return "Decap CMS integration complete"
5761
5862
@@ -102,19 +106,25 @@
102106
103107
# ---------------------------------------------------------------------------
104108
# config.yml
105109
# ---------------------------------------------------------------------------
106110
107
-def _write_decap_config(site_dir: str, admin_dir: str):
111
+def _write_decap_config(site_dir: str, admin_dir: str, github_repo: str = None):
108112
content_dir = os.path.join(site_dir, 'content')
109113
collections = _build_collections(content_dir)
110114
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
+
111124
config = {
112
- 'backend': {
113
- 'name': 'git-gateway',
114
- 'branch': 'main',
115
- },
125
+ 'backend': backend,
116126
'media_folder': 'static/images/uploads',
117127
'public_folder': '/images/uploads',
118128
'collections': collections,
119129
}
120130
@@ -269,5 +279,120 @@
269279
'fields': [
270280
{'label': 'Title', 'name': 'title', 'widget': 'string'},
271281
{'label': 'Body', 'name': 'body', 'widget': 'markdown'},
272282
],
273283
}
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
+ with open(gitkeep, 'w') as f:
398
+ pass
274399
--- hugoifier/utils/decapify.py
+++ hugoifier/utils/decapify.py
@@ -23,19 +23,21 @@
23 def decapify(
24 site_dir: str,
25 cms_name: str = None,
26 cms_logo: str = None,
27 cms_color: str = None,
 
28 ) -> str:
29 """
30 Add Decap CMS to a Hugo site directory.
31
32 Args:
33 site_dir: Root of the assembled Hugo site (has hugo.toml, content/, themes/).
34 cms_name: Whitelabel name shown in the admin UI (default: 'Content Manager').
35 cms_logo: URL to a logo image for the admin UI (optional).
36 cms_color: Hex color for the admin top bar (default: '#2e3748').
 
37
38 Returns:
39 Status message.
40 """
41 logging.info(f"Adding Decap CMS to {site_dir} ...")
@@ -48,11 +50,13 @@
48 'logo': cms_logo or DEFAULT_CMS_LOGO,
49 'color': cms_color or DEFAULT_CMS_COLOR,
50 }
51
52 _write_admin_index(admin_dir, branding)
53 _write_decap_config(site_dir, admin_dir)
 
 
54
55 logging.info("Decap CMS integration complete.")
56 return "Decap CMS integration complete"
57
58
@@ -102,19 +106,25 @@
102
103 # ---------------------------------------------------------------------------
104 # config.yml
105 # ---------------------------------------------------------------------------
106
107 def _write_decap_config(site_dir: str, admin_dir: str):
108 content_dir = os.path.join(site_dir, 'content')
109 collections = _build_collections(content_dir)
110
 
 
 
 
 
 
 
 
 
111 config = {
112 'backend': {
113 'name': 'git-gateway',
114 'branch': 'main',
115 },
116 'media_folder': 'static/images/uploads',
117 'public_folder': '/images/uploads',
118 'collections': collections,
119 }
120
@@ -269,5 +279,120 @@
269 'fields': [
270 {'label': 'Title', 'name': 'title', 'widget': 'string'},
271 {'label': 'Body', 'name': 'body', 'widget': 'markdown'},
272 ],
273 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
--- hugoifier/utils/decapify.py
+++ hugoifier/utils/decapify.py
@@ -23,19 +23,21 @@
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} ...")
@@ -48,11 +50,13 @@
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
@@ -102,19 +106,25 @@
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
@@ -269,5 +279,120 @@
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 with open(gitkeep, 'w') as f:
398 pass
399
--- tests/test_generate_decap_config.py
+++ tests/test_generate_decap_config.py
@@ -32,10 +32,10 @@
3232
with tempfile.TemporaryDirectory() as tmp:
3333
generate_decap_config(tmp)
3434
with open(os.path.join(tmp, "static", "admin", "config.yml")) as f:
3535
content = f.read()
3636
self.assertIn("backend", content)
37
- self.assertIn("git-gateway", content)
37
+ self.assertIn("github", content)
3838
3939
4040
if __name__ == "__main__":
4141
unittest.main()
4242
--- tests/test_generate_decap_config.py
+++ tests/test_generate_decap_config.py
@@ -32,10 +32,10 @@
32 with tempfile.TemporaryDirectory() as tmp:
33 generate_decap_config(tmp)
34 with open(os.path.join(tmp, "static", "admin", "config.yml")) as f:
35 content = f.read()
36 self.assertIn("backend", content)
37 self.assertIn("git-gateway", content)
38
39
40 if __name__ == "__main__":
41 unittest.main()
42
--- tests/test_generate_decap_config.py
+++ tests/test_generate_decap_config.py
@@ -32,10 +32,10 @@
32 with tempfile.TemporaryDirectory() as tmp:
33 generate_decap_config(tmp)
34 with open(os.path.join(tmp, "static", "admin", "config.yml")) as f:
35 content = f.read()
36 self.assertIn("backend", content)
37 self.assertIn("github", content)
38
39
40 if __name__ == "__main__":
41 unittest.main()
42

Keyboard Shortcuts

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